前段时间,在 Youtube 看到一个非常棒的 Pixel Art 水体渲染的分享,是由游戏开发者 Jess 制作的《How I Created 2D Pixel Art Water - Unity Shader Graph》。这虽然是她的第一部视频,但质量高得可怕,不仅过程中思路清晰,最后实现的效果也相当不错(新人都是怪物)。她此后的几次分享同样好评如潮,由于内容都是我感兴趣的,我大概都会稍研究一下。
回到正题,Jess 在上述视频中分享了她如何通过 Unity Shader Graph 实现 Pixel Art 风格的水体,并将示例工程开源,还非常“贴心”地更新了 Godot 的实现(在之前的 Unity 收费政策风波中,Jess 似乎也计划把工程迁移至 Godot)。如果你只关心实现,Jess 的分享显然比这干巴巴的文字直观、生动多了,推荐直接观看原视频。
而在这篇文章中,我首先尝试用 Unity 的 Shaderlab 重写了 Jess 的实现,以便更容易地在项目中复用。接着又试着实现一个 2D 水体倒影和水深效果的方案,以期让水体与其他物体的交互看起来更生动。事先申明,这篇文章可能会涉及多个话题,包括 2D 水体渲染、URP、Shader、Tilemap、渲染合批,每一个话题都是大坑,也都值得单独拿出来讲。如果你曾关注过这些关键词,那么以下分享就不会有太多障碍,可能还能些许帮到你。同时必须要先说明,这些分享也还没有经过足够的实践,仍需进一步迭代和验证。
(编辑注:本文内链较多,推荐“查看原文”查看。)
复刻 2D 水体渲染
水体渲染算是图形学中比较基础且重要的部分,有大量的研究和实践,但通常是对于 3D 游戏而言。不过,近年来 3D 与 2D 实现方法结合的 2D 游戏似乎也越来越流行。就我之前自己的调研来看,传统 2D,特别是基于 Tilemap 的游戏,最常见的做法还是手绘水体 Tile,若要更生动一点,可以做一个 Tile 的动画循环播放。而另一些则在“2D 游戏”里使用偏真实的水体渲染,近年来案例不少,让我印象深刻的是《风来之国》最后夜晚海面演出的那段。再比如这款“2D 游戏”,其中的水体看起来也非常不错。
Jess 的方案也是类似,通过把 3D 水体渲染方法进行简化、Pixel Art 风格化来实现,这种方法基于纯 Shader 实现,复用性非常高,同时还让水体有丰富的变化。而 2D 与 3D 不同的部分(比如没有体积与深度)则需要使用一些特殊方法——这比较有趣,却较少能看到同类分享,特别是像 Jess 展示的如此细腻的 2D 水体的分享。
另一个我觉得需要说明的是水体是一个大类,海洋、湖泊、河流、瀑布中水的表现是存在差异的。这也是我觉得视频标题有那么点名不副实的地方。Jess 实现的水体其实更接近海洋与湖泊的水体,特点是运动缓慢,泛着波光。而河水通常水流较急,运动具有明显的方向性,波光也相对规律。
水体的 Tile
在具体的 Shader 实现前,必须先说一下这个方案中水体材质的载体 Tilemap。Jess 的游戏是基于 Tilemap 的,她使用内部(水体部分)为黑色、边缘为白色的图块作为水体的 Tileset。如图 1,是我自己做的水体 Tileset。
这里同样可以做成 Animate Tile,增加水体伸展的动画。而 Shader 则负责在黑色的部分画出水体效果。这种制作方式同样有极高的复用性,只需要更换材质,不必重新制作 Tileset 就可以做出不同的地形表现。
图 1
思路
在 Jess 的这个方案中,考虑了水体表现的几个部分,如图 2:
图 2
海水近岸颜色渐变
随着海水变深,岸底能见度下降,海水颜色与沙滩颜色的混合渐变。
这是一个非常细节的表现,其实很少在 2D 游戏中看到,做法也非常地 3D,使用高度图或深度图进行颜色混合。Jess 的游戏是一个程序生成地形的开放世界游戏,所以本身就有高度信息。我自己的实现中不存在地形高度,所以这一条不考虑在内。
波纹
波纹表现通常是在水体有相当透明度时出现,有两个部分,其一是使用散焦纹理(Caustic Texture)实现波纹底色的部分(图 2 位置 1),以及在底色基础上增加的高亮(图 2 位置 2),使水体看起来像是有深度且受光的表现。
同时,这个波纹有扰动效果,以及渐隐渐现,体现水体的运动。
波光
即水面模拟受光镜面反射(Specular)的部分(图 2 位置 3)。
泡沫
泡沫(Foam)也是水体重要的部分。其实严格来说也有好几种,一种通常称为岸边泡沫,是海岸与海水分界线上产生的细密泡沫(图 2 位置 4)。另一种是物体在水中,接触面上产生的交互泡沫。Jess 的另一个分享《How I Made Pixel Art Water Trails - Godot Visual Shader》,介绍了她是如何实现交互泡沫的。
大体上就是这些,视频内容似乎还不涉及光影的部分。
实现
完整实现水体渲染 Shader 如下,部分函数定义在 pixel_art.hlsl 与 common.hlsl,见文末附录 1。
Shader "Custom/PixelWater3"
{
Properties
{
_MainTex("Texture", 2D) = "" {}
_Color("Color", Color) = (0, 0.57, 1, 1)
_PPU("Pixels Per Unit", Range(1, 100)) = 32
_CausticTex("Caustic Texture", 2D) = "" {}
_CausticColor("Caustic Color", Color) = (0, 1, 0.98, 0.12)
_CausticScale("Caustic Scale", Range(0.01, 0.1)) = 0.08
_CausticSpeed("Caustic Speed", Range(0, 2)) = 0.8
_CausticHighlightTex("Caustic Highlight Tex", 2D) = "" {}
_CausticHighlightColor("Caustic Highlight Color", Color) = (1, 1, 1, 0.67)
_CausticNoiseScale("Caustic Noise Scale", Range(0, 2)) = 1.63
_CausticNoiseBlendScale("Caustic Noise Blend Scale", Range(0, 0.1)) = 0.018
_CausticSquash("Caustic Squash", Range(0.1, 2)) = 1.3 // 散焦 y 轴缩放
_CausticFadeNoiseScale("Caustic Fade Noise Scale", Range(0, 1)) = 0.41
_CausticFadeMultiplier("Caustic Fade Multiplier", Range(0, 1)) = 0.12
_SpecularSpeed("Specular Speed", Range(0, 2)) = 0.3
_SpecularNoiseScale("Specular Noise Scale", Range(0.1, 2)) = 0.83
_SpecularStaticScale("Specular Static Scale", Range(0.1, 5)) = 3.68
_SpecularColor("Specular Color", Color) = (1, 1, 1, 0.87)
_SpecularThreshold("Specular Threshold", Range(-1, 1)) = -0.65
_FoamTex("Foam Texture", 2D) = "" {}
_FoamColor("Foam Color", Color) = (1, 1, 1, 1)
_FoamScale("Foam Scale", Range(0, 1)) = 0.5
_FoamBlurScale("Foam Blur Scale", Range(0, 5)) = 3
_FoamTexelSize("Foam Texel Size", Range(0, 0.01)) = 0.003
// 倒影扭曲
_ReflectionNoiseScale("Reflection Noise Scale", Range(0, 0.1)) = 0.01
_ReflectionNoiseBlendScale("Reflection Noise Blend Scale", Range(0, 1)) = 0.2
_ReflectionIntensity("Reflection Intensity", Range(0, 1)) = 0.5
}
SubShader
{
Tags
{
"Queue" = "Transparent"
"IgnoreProjector" = "True"
"RenderType" = "Transparent"
}
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Cull Off
Pass
{
Name "RenderWater"
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Assets/AssetsPackage/Shader/Includes/pixel_art.hlsl"
#include "Assets/AssetsPackage/Shader/Includes/common.hlsl"
CBUFFER_START(UnityPerMaterial)
float _PPU;
SamplerState linear_clamp_sampler;
SamplerState point_clamp_sampler;
SamplerState point_repeat_sampler;
SamplerState linear_repeat_sampler;
Texture2D _MainTex;
float4 _Color;
float4 _MainTex_TexelSize;
// 散焦纹理
Texture2D _CausticTex;
Texture2D _CausticHighlightTex;
float4 _CausticColor;
float4 _CausticTex_TexelSize;
float _CausticScale;
float _CausticSpeed;
float4 _CausticHighlightColor;
float _CausticSquash;
// 噪声扰动
float _CausticNoiseScale;
float _CausticNoiseBlendScale;
// 散焦渐隐
float _CausticFadeNoiseScale;
float _CausticFadeMultiplier;
// 高光
float _SpecularSpeed;
float _SpecularNoiseScale;
float _SpecularStaticScale;
float4 _SpecularColor;
float _SpecularThreshold;
// 边缘泡沫
Texture2D _FoamTex;
float4 _FoamColor;
float _FoamScale;
float _FoamBlurScale;
float _FoamTexelSize;
// 倒影
Texture2D _CameraSortingLayerTexture;
Texture2D _ReflectionTex;
Texture2D _UnderwaterTex;
float _ReflectionNoiseScale;
float _ReflectionNoiseBlendScale;
float _ReflectionIntensity;
CBUFFER_END
struct a2v
{
float4 vertex: POSITION;
float2 texcoord: TEXCOORD0;
float4 color: COLOR;
};
struct v2f
{
float4 vertex: SV_POSITION;
float2 uv: TEXCOORD0;
float4 color: COLOR;
float3 world_pos: TEXCOORD1;
float4 screen_pos: TEXCOORD2;
};
v2f vert(a2v input)
{
v2f output;
output.vertex = TransformObjectToHClip(input.vertex.xyz);
output.uv = input.texcoord;
output.color = input.color;
output.world_pos = mul(unity_ObjectToWorld, input.vertex).xyz;
output.screen_pos = ComputeScreenPos(output.vertex);
return output;
}
float4 frag(v2f input) : SV_Target
{
float3 world_pos = pixelate_world_pos(input.world_pos, _PPU);
float2 uv = input.uv;
// 散焦纹理 uv 扰动
float2 squash_uv = world_pos.xy * float2(1, _CausticSquash);
float2 caustic_uv = squash_uv * _CausticScale;
float caustic_noise = gradient_noise(squash_uv + _Time.y * _CausticSpeed, _CausticNoiseScale);
float2 caustic_noise_uv = float2(caustic_noise, caustic_noise);
caustic_uv = blend_subtract(caustic_uv, caustic_noise_uv, _CausticNoiseBlendScale);
// 倒影 / 水深
float2 screen_uv = input.screen_pos.xy / input.screen_pos.w;
screen_uv += caustic_noise_uv * _ReflectionNoiseScale / unity_OrthoParams.y;
// float4 screen_col = _CameraSortingLayerTexture.Sample(point_clamp_sampler, clamp(screen_uv + caustic_noise_uv * _ReflectionNoiseScale, 0, 1));
float4 reflection_col = _ReflectionTex.Sample(point_clamp_sampler, screen_uv);
float4 underwater_col = _UnderwaterTex.Sample(point_clamp_sampler, screen_uv);
float4 screen_col = lerp(underwater_col, reflection_col, _ReflectionIntensity);
// 散焦纹理
float4 caustic_col = _CausticTex.Sample(point_repeat_sampler, caustic_uv) * _CausticColor;
float4 highlight_col = _CausticHighlightTex.Sample(point_repeat_sampler, caustic_uv) * _CausticHighlightColor;
caustic_col = lerp(caustic_col, highlight_col, highlight_col.a);
// 散焦渐隐
float fade_noise = gradient_noise(input.world_pos.xy, _CausticFadeNoiseScale) * _CausticFadeMultiplier;
caustic_col.a = clamp(caustic_col.a - fade_noise, 0, 1);
// 高光
float2 delta = float2(_Time.y, 0) * _SpecularSpeed;
float noise_left = gradient_noise(world_pos.xy + delta, _SpecularNoiseScale);
float noise_right = gradient_noise(world_pos.xy - delta, _SpecularNoiseScale);
float noise_static = gradient_noise(world_pos.xy, _SpecularStaticScale);
float specular_noise = blend_overlay(noise_left, noise_right, 1);
specular_noise = blend_subtract(specular_noise, noise_static, 1);
float4 specular_col = step(specular_noise, _SpecularThreshold) * _SpecularColor;
// 泡沫
float4 foam_col = _FoamTex.Sample(point_repeat_sampler, input.world_pos.xy * _FoamScale) * _FoamColor;
float4 blur = gaussian_blur_5x5(_MainTex, point_repeat_sampler, uv, float2(_FoamTexelSize, _FoamTexelSize)) * _FoamBlurScale;
foam_col.a *= blur.r;
// 混合 主色, 散焦, 高光, 泡沫, 倒影
caustic_col.rgb = lerp(_Color.rgb, caustic_col.rgb, caustic_col.a);
caustic_col = lerp(caustic_col, specular_col, ceil(caustic_col.a) * specular_col.a);
caustic_col = lerp(caustic_col, foam_col, foam_col.a);
caustic_col = lerp(caustic_col, screen_col, screen_col.a * _ReflectionNoiseBlendScale);
uv = pixelate_uv(uv, _MainTex_TexelSize);
float4 col = _MainTex.Sample(point_clamp_sampler, uv);
foam_col = _FoamColor * (col.r * col.a); // 复用 foam_col 变量
col.rgb = lerp(caustic_col.rgb, foam_col.rgb, col.r * col.a);
col.a = col.a * _Color.a;
return col;
}
ENDHLSL
}
}
}
分块简单解释一下:
产生散焦贴图 uv 扰动
float2 squash_uv = world_pos.xy * float2(1, _CausticSquash);
float2 caustic_uv = squash_uv * _CausticScale;
float caustic_noise = gradient_noise(squash_uv + _Time.y * _CausticSpeed, _CausticNoiseScale);
float2 caustic_noise_uv = float2(caustic_noise, caustic_noise);
caustic_uv = blend_subtract(caustic_uv, caustic_noise_uv, _CausticNoiseBlendScale);
这里使用世界坐标加扰动来生成采样散焦纹理(_CausticTex
)的 uv 坐标。使用 _CausticSquash
对竖直方向进行缩放,用以模拟斜视水面的效果。gradient_noise 用于产生扰动 uv 所需的柏林噪声(Perlin Noise),其定义见附录 1。
倒影与水深
// 倒影 / 水深
float2 screen_uv = input.screen_pos.xy / input.screen_pos.w;
screen_uv += caustic_noise_uv * _ReflectionNoiseScale / unity_OrthoParams.y;
// float4 screen_col = _CameraSortingLayerTexture.Sample(point_clamp_sampler, clamp(screen_uv + caustic_noise_uv * _ReflectionNoiseScale, 0, 1));
float4 reflection_col = _ReflectionTex.Sample(point_clamp_sampler, screen_uv);
float4 underwater_col = _UnderwaterTex.Sample(point_clamp_sampler, screen_uv);
float4 screen_col = lerp(underwater_col, reflection_col, _ReflectionIntensity);
在这个实现方案中,倒影(_ReflectionTex
)与水深(_UnderwaterTex
)有两个单独的渲染纹理(RT,Render Texture),在渲染水体时进行采样,RT 的生成参考倒影与水深实现方案一节。_ReflectionIntensity
用于控制两者混合的权重。我感觉,倒影和水深的效果通常只需取其一,否则混合后重叠看着有点乱。
散焦纹理采样
// 散焦纹理
float4 caustic_col = _CausticTex.Sample(point_repeat_sampler, caustic_uv) * _CausticColor;
float4 highlight_col = _CausticHighlightTex.Sample(point_repeat_sampler, caustic_uv) * _CausticHighlightColor;
caustic_col = lerp(caustic_col, highlight_col, highlight_col.a);
// 散焦渐隐
float fade_noise = gradient_noise(input.world_pos.xy, _CausticFadeNoiseScale) * _CausticFadeMultiplier;
caustic_col.a = clamp(caustic_col.a - fade_noise, 0, 1);
这里有散焦底色的纹理(_CausticTex
)与高亮的纹理(_CausticHighlightTex
),后者由前者图像处理腐蚀后生成,保证了高亮的部分一定在底色的纹理之上,看起来就像是部分波纹变成了高亮。同时用柏林噪声增加了散焦纹理的部分渐隐效果。
// 高光
float2 delta = float2(_Time.y, 0) * _SpecularSpeed;
float noise_left = gradient_noise(world_pos.xy + delta, _SpecularNoiseScale);
float noise_right = gradient_noise(world_pos.xy - delta, _SpecularNoiseScale);
float noise_static = gradient_noise(world_pos.xy, _SpecularStaticScale);
float specular_noise = blend_overlay(noise_left, noise_right, 1);
specular_noise = blend_subtract(specular_noise, noise_static, 1);
float4 specular_col = step(specular_noise, _SpecularThreshold) * _SpecularColor;
高光的部分使用 2 个反向运动的柏林噪声与一个静态的柏林噪声混合生成。
泡沫
// 泡沫
float4 foam_col = _FoamTex.Sample(point_repeat_sampler, input.world_pos.xy * _FoamScale) * _FoamColor;
float4 blur = gaussian_blur_5x5(_MainTex, point_repeat_sampler, uv, float2(_FoamTexelSize, _FoamTexelSize)) * _FoamBlurScale;
foam_col.a *= blur.r;
这里 _FoamTex
泡沫贴图是散焦贴图缩放后得到的,用于表现细小的岸边泡沫。不同于 3D 环境中使用深度确定泡沫显示范围,Jess 的方案使用高斯模糊使白色边缘具有渐变效果,然后与泡沫纹理混合。gaussian_blur_5x5
的定义见附录 1。
高斯模糊 + Tilemap 会有一个隐含的坑点。说来话长,我把这个话题放到附录 2 中讨论。其实可以预先在 Tileset 中就处理好渐变,这样可以省去高斯模糊这一步,我看到 Jess 后续的分享似乎也改变了泡沫的实现方法。
混合以上所有颜色
// 混合 主色, 散焦, 高光, 泡沫, 倒影
caustic_col.rgb = lerp(_Color.rgb, caustic_col.rgb, caustic_col.a);
caustic_col = lerp(caustic_col, specular_col, ceil(caustic_col.a) * specular_col.a);
caustic_col = lerp(caustic_col, foam_col, foam_col.a);
caustic_col = lerp(caustic_col, screen_col, screen_col.a * _ReflectionNoiseBlendScale);
uv = pixelate_uv(uv, _MainTex_TexelSize);
float4 col = _MainTex.Sample(point_clamp_sampler, uv);
foam_col = _FoamColor * (col.r * col.a); // 复用 foam_col 变量
col.rgb = lerp(caustic_col.rgb, foam_col.rgb, col.r * col.a);
col.a = col.a * _Color.a;
pixelate_uv
的定义见附录 1,原理可以参考这篇文章《浅谈 Pixel Art 缩放及抗锯齿问题》。
针对以上内容,从我自己的测试来看,相同参数下,水体表现和 Jess 的实现基本一致。Shader 中所用到的 3 张贴图(_CausticTex
,_CausticHighlightTex
和 _FoamTex
)都来自 Jess 的示例工程,实现效果如图 3:
图 3
调整部分参数的效果变化如图 4:
图 4
2D 水体的倒影与水深
在我少量的调研中,那些使用 3D 技术的 2D 游戏,为了表现水的真实,大都实现了这类效果,特别是水深的效果。也有少部分纯 2D 游戏实现了简单的黑色模糊倒影效果,水深则较为少见。
说是 2D,其实 2D 游戏的表现方式五花八门,最常见的有横版平台跳跃,类似 Jess 这个游戏的正面(Top-down)视角。还有 2D 等距视角(Isometric),但这里只讨论正面 Top-down 视角的情况。关于这个视角,突然想起王老菊有个视频很有意思《未来科技开发日记#1》。
思路
尝试寻找解决方案前,先考虑下这个问题最复杂的情况,如果能处理好复杂情况,那更通常的情况应该也没问题。比如某种复杂的情况是:一个有一半没入水中的动态物体在水的边缘处。这种情况会同时面临动态的倒影与水深,以及倒影和水深在水不规则边缘的处理。
一种 2D 倒影简单且被广泛采用的实现思路是:物体的倒影为单独贴图,事先做好,控制渲染顺序,先渲染出倒影,然后在渲染水体时抓取屏幕贴图,对水体范围的颜色进行叠加和扰动。比如这个分享中的做法《UnityShader 实现 2D 水面及物体水面投影的渲染》(https://www.bilibili.com/video/BV1364y1X7NZ/)。
显然这个思路是可行的,但对于稍复杂的情况就暴露出一些问题,需要进一步改进。比如:
因为是独立的贴图,所以对于静态物体较好处理,但如果贴图本身会动,像是角色有序列帧动画,就需要额外处理倒影贴图的变化,不太方便。
因为是先渲染的倒影,渲染出来后屏幕才能抓到帧进行水体表现效果,而如果物体在水边缘,露出来的部分倒影就会穿帮。期望应该是倒影只会在水体内渲染。
如果想处理地更精细一点,单独控制倒影与水深的强度,简单的抓帧显然是做不到的。
我初期尝试对这种方案进行修补,但最终放弃了。这里 URP 2D 渲染管线 + 屏幕抓帧也有很多坑,我简单提一下碰到的问题。URP 中无法使用 Build-in 管线中的 GrabPass 进行屏幕抓帧,而是需要在 Render Pipeline Asset 检视界面中勾选 Opaque Texture,这样屏幕贴图会自动渲染到一个内置的全局 RT _CameraOpaqueTexture
中,在 Shader 中采样这个 RT 即可。而如果你使用 URP 2D 管线,那么这个方法也是失效的,你需要单独设置一个 Layer 和在 Render Data 检视界面中新增 Render Object 来进行屏幕贴图的渲染,然后使用 _CameraSortingLayerTexture
进行采样。这种做法目前有很多局限,可以参考这个讨论《Is it possible to have transparents using the 2D renderer in URP? - Unity Engine - Unity Discussions》(https://discussions.unity.com/t/is-it-possible-to-have-transparents-using-the-2d-renderer-in-urp/772498/38)。
如果沿用这个基于屏幕空间反射的 2D 倒影实现思路,对于上面要解决的问题可以简单总结为:
倒影/水深贴图怎么获取?
怎么只在水体范围内渲染这些贴图内容?
对于第一个倒影贴图怎么来的问题,如果要精细控制还是需要将倒影与水深分开获取。对于倒影,需要能实时地渲染出物体的动态倒影,这个也有几种方案,像是屏幕空间上下倒转,物体贴图的倒转。水深与倒影的处理类似,只是不需要进行倒转操作。
对于第二个问题,倒影只出现在水体范围内,大致有两种做法,一是使用模板缓冲(Stencil Buffer):水体渲染需要两个 pass,第一个 pass 渲染水体时只写入模板缓存,然后渲染倒影/水深时读取模板,最后第二个 pass 读取倒影渲染结果再混合渲染水体颜色。然而 URP 似乎不支持多 pass 的 Shader,只能作罢。模板匹配的另一个麻烦之处在于水边缘的处理,如果边缘是不规则的,就需要根据透明度的情况处理模板缓存。
另一种做法则更直接,把倒影与水深渲染到单独 RT 上,而主相机中不对其进行渲染,后续水体渲染时读取 RT 进行混合。这个方案使用自定义的 Renderer Featuer 可以很容易做到。
于是我想到一种简单可行,且统一反射与水深的方案。给每个入水物体节点再挂两个 Sprite 节点,一个设置为倒影层用于渲染倒影,一个设置为水深层渲染水深,每帧把主 Sprite 的贴图更新到这两个子 Sprite 上,而这两个 Sprite Renderer 各自使用单独的 Shader 渲染自己的贴图,Shader 的参数也可以直接在更新时赋值。只需要这两个 Sprite 设置为不同的 Layer,比如反射层(Reflection) 与 水深层(Underwater),借助自定义 Renderer Feature 就可以渲染出这两个单独的 RT。
光这样还不够,还需要实现一个特殊的 Sprite 分割 Shader:外部脚本可以传参数进去,其中一个参数决定 Sprite 在 Y 方向上显示的分割线,另一个参数决定是分割线以上还是以下的部分显示,这样就可以模拟出 Sprite 没入水中的效果。
实现
自定义的 Renderer Feature 与 Render Pass 代码如下。
using UnityEngine.Rendering.Universal;
using UnityEngine;
public class RenderLayerFeature : ScriptableRendererFeature
{
private RenderLayerPass _pass;
[SerializeField]
private LayerMask layer_mask;
[SerializeField]
private string rt_name = "_TempRenderLayer";
[SerializeField]
private RenderPassEvent pass_event = RenderPassEvent.BeforeRenderingOpaques;
[SerializeField]
private TransparencySortMode sort_mode = TransparencySortMode.Default;
[SerializeField]
private Vector3 custom_axis = Vector3.up;
public override void Create()
{
_pass = new RenderLayerPass(layer_mask, rt_name, pass_event, sort_mode, custom_axis);
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData rendering_data)
{
_pass.Setup();
renderer.EnqueuePass(_pass);
}
}
using UnityEngine.Rendering.Universal;
using UnityEngine.Rendering;
using UnityEngine;
public class RenderLayerPass : ScriptableRenderPass
{
private RenderTargetHandle rt_handle;
private string rt_name;
private ShaderTagId shader_tag_id = new ShaderTagId("SRPDefaultUnlit");
private FilteringSettings filtering_settings;
private LayerMask layer_mask;
private TransparencySortMode sort_mode;
private Vector3 custom_axis;
public RenderLayerPass(LayerMask layer_mask, string rt_name, RenderPassEvent pass_event, TransparencySortMode sort_mode, Vector3 custom_axis)
{
this.layer_mask = layer_mask;
this.rt_name = rt_name;
this.sort_mode = sort_mode;
this.custom_axis = custom_axis;
renderPassEvent = pass_event; // 设置渲染时机
filtering_settings = new FilteringSettings(RenderQueueRange.all, layer_mask);
rt_handle.Init(rt_name);
}
public void Setup()
{
}
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
RenderTextureDescriptor rt_desc = cameraTextureDescriptor; // RenderTexture 描述符
cmd.GetTemporaryRT(rt_handle.id, rt_desc.width, rt_desc.height, 0, FilterMode.Bilinear, RenderTextureFormat.ARGBHalf); // 临时 RT
cmd.SetGlobalTexture(rt_name, rt_handle.Identifier()); // 设置全局纹理
// 配置渲染目标和清除设置
ConfigureTarget(rt_handle.Identifier());
ConfigureClear(ClearFlag.All, Color.clear);
}
public override void Execute(ScriptableRenderContext context, ref RenderingData rendering_data)
{
// 手动创建剔除参数
Camera camera = rendering_data.cameraData.camera;
// 必须设置, 否则倒影渲染顺序会有问题
camera.transparencySortMode = sort_mode;
camera.transparencySortAxis = custom_axis;
if (!camera.TryGetCullingParameters(out ScriptableCullingParameters culling_params))
return;
// 设置剔除遮罩
culling_params.cullingMask = (uint)layer_mask.value;
CullingResults culling_results = context.Cull(ref culling_params);
DrawingSettings draw_settings = CreateDrawingSettings(
shader_tag_id,
ref rendering_data,
SortingCriteria.SortingLayer | SortingCriteria.CommonTransparent
);
context.DrawRenderers(culling_results, ref draw_settings, ref filtering_settings);
}
public override void FrameCleanup(CommandBuffer cmd)
{
cmd.ReleaseTemporaryRT(rt_handle.id);
}
}
这个 RenderLayerFeature 实现的功能非常简单,就是把特定 Layer 的内容渲染到一个自命名的全局 RT 里,可以在 Render Data 资源检视界面里创建两个实例分别渲染物体的倒影层(_ReflectionTex
)与水深层(_UnderwaterTex
)。
至于入水物体 Shader 的实现,就不得不提到 Sprite Renderer 的一个坑点。
水中物体处理与 Sprite 合批
在前面提到的实现思路中,水中物体需要一个分割 Sprite 的 Shader,其一种实现如下。
v2f vert(a2v input)
{
v2f output;
input.vertex.y -= _OffsetY; // 竖直移动
output.local_pos = input.vertex;
output.vertex = TransformObjectToHClip(input.vertex.xyz);
output.uv = input.texcoord;
output.color = input.color * _Color;
return output;
}
float4 frag(v2f input) : SV_Target
{
// sprite 剔除
float is_discard = _Upper * input.local_pos.y + (1.0 / _PPU - input.local_pos.y) * (1 - _Upper);
clip(is_discard);
float2 uv = pixelate_uv(input.uv, _MainTex_TexelSize);
float4 col = _MainTex.Sample(linear_clamp_sampler, uv);
return col * input.color;
}
在顶点着色器中移动并缓存下物体偏移后的局部坐标,然后在片元着色器中进行剔除。
当场景中该物体只有一个时,表现符合期望,但当场景中有两个以上的相同物体时,Sprite 的渲染就会出现问题,看表现像是 Shader 中的局部坐标不再可靠。检索之后才知道 Sprite Renderer 会自带一个动态合批处理(与工程本身合批配置无关),当场景中有多个相同 Sprite 且材质相同时,就会触发这个幕后的动态合批,进行网格合并操作,以便同时绘制多个 Sprite。但这会导致 Shader 中上述局部空间坐标的失效,可参考以下讨论:
How to prevent sprite batching OR display sprites without using sprite renderer? - Unity Engine - Unity Discussions
How to get an absolute object-space position when draw calls are batched? - Unity Engine - Unity Discussions
解决方法是在 Shader 中加个禁止 Batching 的 Tag:
"DisableBatching" = "True"
这能解决表现的问题,不过代价是所有物体都需要单独进行绘制。进一步检索之后发现,Sprite Renderer 在合批流程中的支持一直不太好,原生不支持常见的合批方法,而其自带的动态合批在使用不同的 Sprite 时也没办法合批(似乎是 Sprite Renderer 针对不同的 Sprite 会生成不同的网格)。可参考以下讨论:
Batching and Sprite Renderers - Unity Engine - Unity Discussions
SRP Batcher & SpriteRenderer - Unity Engine - Unity Discussions
似乎 Unity 2023 版本,Sprite Renderer 才支持了 SRP Batcher。
不过好在已经有人探索过相关的解决方案了,可参考如下:
为 Unity Sprite 实现 GPU Instancing
GitHub - ownself/UnitySpriteGPUInstancing: A Unity Sprite GPU Instancing Implementation Demo
从这个分享中,我猜《铃兰之剑》可能也使用了类似方案。思路是自己创建管理相同的渲染网格,用 Mesh Renderer 代替 Sprite Renderer 进行渲染,而这个流程支持 GPU Instancing,可以说是一个相当优雅的解决方案了。那么问题就由创建 Sprite,变成创建 Mesh。处理后,我们可以在 Frame Debuger 中看到,所有物体是由单个批次绘制的。物体挂载脚本 MapObjectRenerer.cs 与物体 Shader Mapobject.shader 实现如下:
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(SpriteRenderer))]
public class MapObjectRenderer : MonoBehaviour
{
private SpriteRenderer _sprite_renderer;
private MaterialPropertyBlock _prop_block;
private int _tex_index = 0;
private Vector4 _pivot;
private Vector4 _uv;
private float _ppu = 32f;
public float offset_y = 0f;
// sprite mesh
private GameObject _sprite_mesh_go;
private MeshRenderer _sprite_mesh_renderer;
// reflection mesh
private GameObject _reflection_mesh_go;
private MeshRenderer _reflection_mesh_renderer;
// underwater mesh
private GameObject _underwater_mesh_go;
private MeshRenderer _underwater_mesh_renderer;
// 静态变量
private static Mesh _mesh;
private static Dictionary tex_indexes = new Dictionary();
private static int array_size = 128;
private static Texture2DArray tex_array;
private static int tex_count = 0;
private void Awake()
{
_sprite_renderer = GetComponent();
_sprite_renderer.enabled = false;
_ppu = _sprite_renderer.sprite.pixelsPerUnit;
Texture2D tex = _sprite_renderer.sprite.texture; // tex 是 sprite sheet
if (tex_array == null)
{
tex_array = new Texture2DArray(tex.width, tex.height, array_size, tex.format, false);
tex_count = 0;
}
if (_prop_block == null)
{
_prop_block = new MaterialPropertyBlock();
}
if (!tex_indexes.ContainsKey(tex))
{
Graphics.CopyTexture(tex, 0, 0, tex_array, tex_count, 0);
_tex_index = tex_count;
tex_indexes[tex] = _tex_index;
tex_count++;
}
else
{
_tex_index = tex_indexes[tex];
}
if (_mesh == null)
{
_mesh = create_mesh();
}
create_sprite_mesh();
create_reflection_mesh();
create_underwater_mesh();
}
private void Update()
{
update_mesh();
}
private Mesh create_mesh()
{
Mesh mesh = new Mesh();
// 定义顶点,枢轴位于底部中心
Vector3[] vertices = new Vector3[]
{
new Vector3(-1f, 0f, 0), // 左下
new Vector3(1f, 0f, 0), // 右下
new Vector3(-1f, 2f, 0), // 左上
new Vector3(1f, 2f, 0) // 右上
};
// 定义 UV
Vector2[] uvs = new Vector2[]
{
new Vector2(0, 0),
new Vector2(1, 0),
new Vector2(0, 1),
new Vector2(1, 1)
};
// 定义三角形
int[] triangles = new int[]
{
0, 2, 1,
2, 3, 1
};
mesh.vertices = vertices;
mesh.uv = uvs;
mesh.triangles = triangles;
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
// 更新 pivot uv 变量
private void update_pivot_uv()
{
Sprite sprite = _sprite_renderer.sprite;
_pivot.x = sprite.rect.width * 0.5f / sprite.pixelsPerUnit;
_pivot.y = sprite.rect.height * 0.5f / sprite.pixelsPerUnit;
_pivot.z = (sprite.rect.width * 0.5f - sprite.pivot.x) / sprite.pixelsPerUnit;
_pivot.w = sprite.pivot.y / sprite.pixelsPerUnit;
_uv.x = sprite.uv[1].x - sprite.uv[0].x;
_uv.y = sprite.uv[0].y - sprite.uv[2].y;
_uv.z = sprite.uv[2].x;
_uv.w = sprite.uv[2].y;
}
private void create_sprite_mesh()
{
if (_sprite_mesh_go != null)
DestroyImmediate(_sprite_mesh_go);
_sprite_mesh_go = new GameObject("sprite_mesh");
_sprite_mesh_go.layer = gameObject.layer;
_sprite_mesh_go.transform.SetParent(transform);
_sprite_mesh_go.transform.localPosition = Vector3.zero;
_sprite_mesh_go.transform.localRotation = Quaternion.identity;
_sprite_mesh_go.transform.localScale = Vector3.one;
MeshFilter mesh_filter = _sprite_mesh_go.AddComponent();
mesh_filter.sharedMesh = _mesh;
_sprite_mesh_renderer = _sprite_mesh_go.AddComponent();
_sprite_mesh_renderer.enabled = true;
_sprite_mesh_renderer.sortingLayerID = _sprite_renderer.sortingLayerID;
_sprite_mesh_renderer.sortingOrder = _sprite_renderer.sortingOrder;
_sprite_mesh_renderer.sharedMaterial = ResMgr.instance.load_material("Material/mapobject.mat");
_sprite_mesh_renderer.sharedMaterial.SetTexture("_Textures", tex_array);
}
private void create_reflection_mesh()
{
if (_reflection_mesh_go != null)
DestroyImmediate(_reflection_mesh_go);
_reflection_mesh_go = new GameObject("reflection_mesh");
_reflection_mesh_go.layer = LayerMask.NameToLayer("Reflection");
_reflection_mesh_go.transform.SetParent(transform);
_reflection_mesh_go.transform.localPosition = Vector3.zero;
_reflection_mesh_go.transform.localRotation = Quaternion.identity;
_reflection_mesh_go.transform.localScale = new Vector3(1, -1, 1);
MeshFilter mesh_filter = _reflection_mesh_go.AddComponent();
mesh_filter.sharedMesh = _mesh;
_reflection_mesh_renderer = _reflection_mesh_go.AddComponent();
_reflection_mesh_renderer.enabled = true;
_reflection_mesh_renderer.sharedMaterial = ResMgr.instance.load_material("Material/mapobject.mat");
_reflection_mesh_renderer.sharedMaterial.SetTexture("_Textures", tex_array);
}
private void create_underwater_mesh()
{
if (_underwater_mesh_go != null)
DestroyImmediate(_underwater_mesh_go);
_underwater_mesh_go = new GameObject("underwater_mesh");
_underwater_mesh_go.layer = LayerMask.NameToLayer("Underwater");
_underwater_mesh_go.transform.SetParent(transform);
_underwater_mesh_go.transform.localPosition = Vector3.zero;
_underwater_mesh_go.transform.localRotation = Quaternion.identity;
_underwater_mesh_go.transform.localScale = Vector3.one;
MeshFilter mesh_filter = _underwater_mesh_go.AddComponent();
mesh_filter.sharedMesh = _mesh;
_underwater_mesh_renderer = _underwater_mesh_go.AddComponent();
_underwater_mesh_renderer.enabled = true;
_underwater_mesh_renderer.sharedMaterial = ResMgr.instance.load_material("Material/mapobject.mat");
_underwater_mesh_renderer.sharedMaterial.SetTexture("_Textures", tex_array);
}
private void update_mesh()
{
update_pivot_uv();
_sprite_mesh_renderer.GetPropertyBlock(_prop_block);
_prop_block.SetFloat("_PPU", _ppu);
_prop_block.SetFloat("_TexIndex", _tex_index);
_prop_block.SetVector("_Pivot", _pivot);
_prop_block.SetVector("_UV", _uv);
_prop_block.SetFloat("_Upper", 1);
_prop_block.SetFloat("_OffsetY", offset_y);
_sprite_mesh_renderer.SetPropertyBlock(_prop_block);
_reflection_mesh_renderer?.SetPropertyBlock(_prop_block);
_prop_block.SetFloat("_Upper", 0);
_underwater_mesh_renderer?.SetPropertyBlock(_prop_block);
}
}
Shader "Custom/MapObject2"
{
Properties
{
_Color("Color", Color) = (1, 1, 1, 1)
_Textures("Textures", 2DArray) = "" {}
}
SubShader
{
Tags
{
"Queue" = "Transparent"
"IgnoreProjector" = "True"
"RenderType" = "Opaque"
"PreviewType" = "Plane"
"CanUseSpriteAtlas" = "True"
// "DisableBatching"="True"
}
ZWrite Off
Lighting Off
Cull Off
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma require 2darray
#pragma multi_compile_instancing
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Assets/AssetsPackage/Shader/Includes/pixel_art.hlsl"
Texture2DArray _Textures;
SamplerState linear_clamp_sampler;
SamplerState point_clamp_sampler;
float4 _Textures_TexelSize;
float4 _Color;
UNITY_INSTANCING_BUFFER_START(Props)
UNITY_DEFINE_INSTANCED_PROP(float, _PPU)
UNITY_DEFINE_INSTANCED_PROP(float, _TexIndex)
UNITY_DEFINE_INSTANCED_PROP(half4, _Pivot)
UNITY_DEFINE_INSTANCED_PROP(half4, _UV)
UNITY_DEFINE_INSTANCED_PROP(float, _Upper)
UNITY_DEFINE_INSTANCED_PROP(float, _OffsetY)
UNITY_INSTANCING_BUFFER_END(Props)
struct a2v
{
float4 vertex: POSITION;
float2 texcoord: TEXCOORD0;
float4 color: COLOR;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f
{
float4 vertex: SV_POSITION;
float2 uv: TEXCOORD0;
float4 color: COLOR;
float4 local_pos: TEXCOORD1;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
v2f vert(a2v input)
{
UNITY_SETUP_INSTANCE_ID(input);
v2f output;
UNITY_TRANSFER_INSTANCE_ID(input, output);
// pivot
half4 pivot = UNITY_ACCESS_INSTANCED_PROP(Props, _Pivot);
half4x4 pivot_m = {
pivot.x, 0, 0, pivot.z,
0, pivot.y, 0, pivot.w,
0, 0, 1, 0,
0, 0, 0, 1
};
float offset_y = UNITY_ACCESS_INSTANCED_PROP(Props, _OffsetY);
input.vertex = mul(pivot_m, input.vertex);
input.vertex.y -= offset_y; // 竖直移动
output.vertex = TransformObjectToHClip(input.vertex.xyz);
output.local_pos = input.vertex; // 记录局部坐标用于剔除
// uv
half4 uv = UNITY_ACCESS_INSTANCED_PROP(Props, _UV);
half3x3 uv_m = {
uv.x, 0, uv.z,
0, uv.y, uv.w,
0, 0, 1
};
output.uv = mul(uv_m, half3(input.texcoord, 1)).xy;
output.color = input.color * _Color;
return output;
}
float4 frag(v2f input) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
// sprite 剔除
float ppu = UNITY_ACCESS_INSTANCED_PROP(Props, _PPU);
float upper = UNITY_ACCESS_INSTANCED_PROP(Props, _Upper);
float is_discard = upper * input.local_pos.y + (1.0 / ppu - input.local_pos.y) * (1 - upper);
clip(is_discard);
float2 uv = input.uv;
uv = pixelate_uv(uv, _Textures_TexelSize);
int tex_index = UNITY_ACCESS_INSTANCED_PROP(Props, _TexIndex);
float4 col = _Textures.Sample(linear_clamp_sampler, float3(uv, tex_index));
return col * input.color;
}
ENDHLSL
}
}
}
挂上这个脚本后,原来的 Sprite Renderer 会停止渲染,渲染工作交给三个子节点上的 Mesh。最终实现的效果如图 5。
图 5
附录
common.hlsl 与 pixel_art.hlsl
#ifndef _INCLUDE_COMMON_HLSL_
#define _INCLUDE_COMMON_HLSL_
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// ref: https://docs.unity3d.com/Packages/com.unity.shadergraph@12.1/manual/Blend-Node.html
float blend_overlay(float base, float blend, float opacity)
{
float result1 = 1.0 - 2.0 * (1.0 - base) * (1.0 - blend);
float result2 = 2.0 * base * blend;
float zero_or_one = step(base, 0.5);
float output = result2 * zero_or_one + (1 - zero_or_one) * result1;
return lerp(base, output, opacity);
}
float blend_subtract(float base, float blend, float opacity)
{
return lerp(base, base - blend, opacity);
}
float2 blend_subtract(float2 base, float2 blend, float opacity)
{
return lerp(base, base - blend, opacity);
}
// -------------------------------------- Gradient Noise --------------------------------------
// gradient noise
// ref: https://docs.unity3d.com/Packages/com.unity.shadergraph@6.9/manual/Gradient-Noise-Node.html
float2 gradient_noise_dir(float2 p)
{
p = p % 289;
float x = (34 * p.x + 1) * p.x % 289 + p.y;
x = (34 * x + 1) * x % 289;
x = frac(x / 41) * 2 - 1;
return normalize(float2(x - floor(x + 0.5), abs(x) - 0.5));
}
float gradient_noise(float2 uv, float scale)
{
float2 p = uv * scale;
float2 ip = floor(p);
float2 fp = frac(p);
float d00 = dot(gradient_noise_dir(ip), fp);
float d01 = dot(gradient_noise_dir(ip + float2(0, 1)), fp - float2(0, 1));
float d10 = dot(gradient_noise_dir(ip + float2(1, 0)), fp - float2(1, 0));
float d11 = dot(gradient_noise_dir(ip + float2(1, 1)), fp - float2(1, 1));
fp = fp * fp * fp * (fp * (fp * 6 - 15) + 10);
return lerp(lerp(d00, d01, fp.y), lerp(d10, d11, fp.y), fp.x) + 0.5;
}
// -------------------------------------- Gaussian Blur --------------------------------------
#define SAMPLE_KERNEL(i, x, y) \
color += kernel[i] * tex.Sample(ss, uv + texel_size.xy * float2(x, y));
// Gaussian Blur Function
float4 gaussian_blur_3x3(Texture2D tex, SamplerState ss, float2 uv, float2 texel_size)
{
// Gaussian kernel
float kernel[9] = {
0.0625, 0.125, 0.0625,
0.125, 0.25, 0.125,
0.0625, 0.125, 0.0625,
};
// Sample the texture
float4 color = float4(0.0, 0.0, 0.0, 0.0);
SAMPLE_KERNEL(0, -1, -1);
SAMPLE_KERNEL(1, 0, -1);
SAMPLE_KERNEL(2, 1, -1);
SAMPLE_KERNEL(3, -1, 0);
SAMPLE_KERNEL(4, 0, 0);
SAMPLE_KERNEL(5, 1, 0);
SAMPLE_KERNEL(6, -1, 1);
SAMPLE_KERNEL(7, 0, 1);
SAMPLE_KERNEL(8, 1, 1);
return color;
}
float4 gaussian_blur_5x5(Texture2D tex, SamplerState ss, float2 uv, float2 texel_size)
{
// Gaussian kernel
float kernel[25] = {
0.00390625, 0.015625, 0.0234375, 0.015625, 0.00390625,
0.015625, 0.0625, 0.09375, 0.0625, 0.015625,
0.0234375, 0.09375, 0.140625, 0.09375, 0.0234375,
0.015625, 0.0625, 0.09375, 0.0625, 0.015625,
0.00390625, 0.015625, 0.0234375, 0.015625, 0.00390625,
};
// Sample the texture
float4 color = float4(0.0, 0.0, 0.0, 0.0);
SAMPLE_KERNEL(0, -2, -2);
SAMPLE_KERNEL(1, -1, -2);
SAMPLE_KERNEL(2, 0, -2);
SAMPLE_KERNEL(3, 1, -2);
SAMPLE_KERNEL(4, 2, -2);
SAMPLE_KERNEL(5, -2, -1);
SAMPLE_KERNEL(6, -1, -1);
SAMPLE_KERNEL(7, 0, -1);
SAMPLE_KERNEL(8, 1, -1);
SAMPLE_KERNEL(9, 2, -1);
SAMPLE_KERNEL(10, -2, 0);
SAMPLE_KERNEL(11, -1, 0);
SAMPLE_KERNEL(12, 0, 0);
SAMPLE_KERNEL(13, 1, 0);
SAMPLE_KERNEL(14, 2, 0);
SAMPLE_KERNEL(15, -2, 1);
SAMPLE_KERNEL(16, -1, 1);
SAMPLE_KERNEL(17, 0, 1);
SAMPLE_KERNEL(18, 1, 1);
SAMPLE_KERNEL(19, 2, 1);
SAMPLE_KERNEL(20, -2, 2);
SAMPLE_KERNEL(21, -1, 2);
SAMPLE_KERNEL(22, 0, 2);
SAMPLE_KERNEL(23, 1, 2);
SAMPLE_KERNEL(24, 2, 2);
return color;
}
float4 gaussian_blur(Texture2D tex, SamplerState ss, float2 uv, float2 texel_size, float blur)
{
float4 col = float4(0.0, 0.0, 0.0, 0.0);
float kernel_sum = 0.0;
int upper = (blur - 1) * 0.5;
int lower = -upper;
for (int x = lower; x <= upper; ++x)
{
for (int y = lower; y <= upper; ++y)
{
kernel_sum++;
float2 offset = float2(texel_size.x * x, texel_size.y * y);
col += tex.Sample(ss, uv + offset);
}
}
col /= kernel_sum;
return col;
}
#endif
#ifndef _INCLUDE_PIXEL_ART_HLSL_
#define _INCLUDE_PIXEL_ART_HLSL_
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
float2 pixelate_uv(float2 uv, float4 texel_size)
{
float2 tpp = clamp(fwidth(uv) * texel_size.zw, 1e-5, 1);
float2 tx = uv * texel_size.zw - 0.5 * tpp;
float2 tx_offset = smoothstep(1 - tpp, 1, frac(tx)) + 0.5; // saturate((frac(tx) + tpp - 1) / tpp) + 0.5;
uv = (floor(tx) + tx_offset) * texel_size.xy;
return uv;
}
float3 pixelate_world_pos(float3 world_pos, float ppu)
{
return floor(world_pos * ppu) / ppu;
}
#endif
Tilemap 的缝隙问题与解决方案
正文中提到,在 Tilemap 中使用高斯模糊,特定情况下可能会有问题,如果模糊采样的范围(_FoamTexelSize
)扩得太大,Tile 的表现可能会异常。具体到泡沫这里的例子,随着 _FoamTexelSize
的增大,水体内部的 Tile 可能会出现异常的白色。
这个问题要从 Tilemap 缝隙问题谈起,这是几乎所有刚开始使用 Tilemap 的人都会遇到的一个坑,表现是 Tilemap 在镜头移动过程中会出现缝隙,即使所使用的 Tileset 是严丝合缝的。关于这个问题,比较好的解释可以参考 Fixing Seams - Tiled2Unity,简单来说是 Shader 在采样 Tile 贴图时,由于数值的精度问题会导致采样到贴图外。处理的方法也很多,一种方式类似 Jess 的 Tileset 中的处理:给每个 16x16 Tile 的外面多一个像素宽度的颜色,防止采样到 Tile 外面后颜色不对,导致出现缝隙。
我使用 Supertile2Unity 这个项目,把 Tiled 导出的 Tilemap 导入到 Unity。在最新版本中,这个插件会自动分割整张 Tileset 纹理,为了实现 Seamless 的 Tilemap,可以手动创建一个 Sprite Atlas,把这些分割的 Sprite 做成一个图集,此时,这些 Tile 的图像边缘会自动增加外扩的像素(在 Atlas 不勾选 Alpha Dilation 的情况下),从而避免缝隙的产生。只是打成图集后,这些 Tile 图片挨得非常近,只有几个像素的间距(Atlas 检索面板中只能设置 2、4、8 个像素距离),所以模糊采样时,如果范围太大,就会采到相邻图片的颜色。一种解决办法是自定义图集的生成,扩大图集中每张图片的间距。这里也有一些坑,不过和这次的主题有些远,就先不提了。
其他参考
2D 游戏水体的参考
Creating a 2D Water Shader in Unity - YouTube
How to create a Water Tile Map Shader in Godot 4 - 2 minutes tutorial - YouTube
Creating Animated Pixelart Water - Aseprite Tutorial - YouTube
3D 游戏水体的参考
有时可以从真实水体渲染方案中找找灵感
Unity URP 风格化水 – 放課後ティータイム
在 URP 实现水面效果 | Musoucrow' BLOG
Unity 实现平面反射(基于 URP)
TA 实践分享:材质与渲染——水体(Unity+UE) - UWATech
Making Interactive Water using RenderTexture | Patreon
水面シェーダーを作成する方法 [Unity] – Site-Builder.wiki
Are water shaders still popular? (breakdown video) : r/Unity3D
Yet Another Stylised Water Shader - Half Past Yellow | Blog
* 本文为用户投稿,不代表 indienova 观点。