jojo, 这是我今年最后的更新了! 咳咳, 这次继续来现学现卖, 分享下看到的 Pixel Art 相关的渲染技术. 前段时间油管上看到一个非常棒的 Pixel Art 水体渲染的分享, 是由游戏开发者 jess 制作的 How I Created 2D Pixel Art Water - Unity Shader Graph. 这虽然是她的第一个视频, 但是质量高得可怕, 不仅制作过程思路清晰, 最后实现的效果也相当不错. 那话怎么说的来着, 新人都是怪物. 此后的几次分享同样好评如潮, 因为内容都是我感兴趣的领域, 所以我大概都会稍微研究一下.

回到正题, jess 在这个视频中分享了她如何通过 unity shader graph 实现 Pixel Art 风格的水体, 并且开源了示例工程, 还非常”贴心”地更新了 godot 的实现(似乎 jess 也在之前的 unity 收费政策风波中打算把工程迁移至 godot). 如果你只关心实现, jess 的分享显然比这干巴巴的文字直观生动多了, 推荐直接去看.

而在这篇文章中, 我首先尝试用 unity 的 shaderlab 重写了 jess 的实现, 以便更容易地在项目中复用. 接着又试着实现一个2d水体倒影和水深效果的方案, 以期让水体与其他物体的交互看起来更生动. 事先申明, 这篇文章可能会涉及多个话题, 包括2d水体渲染、URP、shader、tilemap、渲染合批, 这每一个话题都是大坑, 也都值得单独拿出来讲. 如果你曾关注过这些关键词, 那么这个分享就不会有太多障碍, 可能还能些许帮到你. 同时必须说这些分享也还没有经过足够的实践, 仍需进一步迭代和验证.

复刻2d水体渲染

水体渲染算是图形学中比较基础且重要的部分, 有大量的研究和实践, 但通常是对于3d游戏而言. 不过近年来3d与2d实现方法结合的2d游戏似乎也越来越流行. 就我之前自己的调研来看, 传统2d, 特别是基于 tilemap 的游戏最常见的做法还是手绘水体 tile, 要更生动一点, 可以做一个 tile 的动画循环播放. 而有一些则在”2d游戏”里使用偏真实的水体渲染. 其实近年来也挺多的, 让我印象深刻的像是风来之国最后夜晚海面演出的那段. 再比如这个“2d游戏”的水体看起来也非常 nice. 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 中正分享了她是如何实现交互泡沫的.

大体上就是这些, 在 jess 的分享中似乎还不涉及光影的部分.

实现

完整的实现水体渲染 shader 如下, 部分函数定义在 pixel_art.hlsl 与 common.hlsl 见附录1.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
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扰动
1
2
3
4
5
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.

  • 倒影与水深
1
2
3
4
5
6
7
// 倒影 / 水深
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 用于控制两者混合的权重. 我感觉通常倒影和水深的效果只需取其一, 否则混合后重叠看着有点乱.

  • 散焦纹理采样
1
2
3
4
5
6
7
// 散焦纹理
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), 后者由前者图像处理腐蚀后生成, 保证了高亮的部分一定在底色的纹理之上, 看起来就像是部分波纹变成了高亮. 同时用柏林噪声增加了散焦纹理部分渐隐效果.

1
2
3
4
5
6
7
8
// 高光
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个反向运动的柏林噪声与一个静态的柏林噪声混合生成.

  • 泡沫
1
2
3
4
// 泡沫
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 后续的分享似乎也改变了泡沫的实现方法.

  • 混合以上所有颜色
1
2
3
4
5
6
7
8
9
10
11
// 混合 主色, 散焦, 高光, 泡沫, 倒影
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缩放及抗锯齿问题 | Granvallen;Nest.
以上从我自己测试来看, 相同参数下, 水体表现和 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.

思路

尝试寻找解决方案之前, 先考虑下这个问题最复杂的情况, 如果能处理好复杂的情况, 那更通常的情况应该也是ok的. 比如一个复杂的情况是, 一个有一半没入水中的动态物体在水的边缘处. 这种情况会同时出现动态的倒影与水深, 以及倒影和水深在水不规则边缘的处理.

一种2d倒影简单且广泛采用的实现思路是物体的倒影是单独贴图, 事先做好, 控制渲染顺序, 先渲染出倒影, 然后渲染水体时抓取屏幕贴图, 对水体范围的颜色进行叠加和扰动. 比如这个分享中的做法 UnityShader实现2D水面及物体水面投影的渲染.

显然这个思路是可行的, 但对于稍复杂的情况就暴露出一些问题, 需要进一步改进. 比如,

  • 因为是独立的贴图, 所以对于静态物体是比较好处理的, 但是如果贴图本身会动, 像是角色有序列帧动画, 就需要额外处理倒影贴图的变化, 这个就不太方便.
  • 因为是先渲染的倒影, 渲染出来后屏幕才能抓到帧进行水体表现效果, 而如果物体在水边缘, 露出来的部分倒影就会穿帮. 期望应该是倒影只会在水体内渲染.
  • 如果想处理地更精细一点, 单独控制倒影与水深的强度, 简单的抓帧显然是做不到的.

我初期尝试对这种方案进行修补, 但最终放弃了. 这里 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.

如果沿用这个屏幕空间反射的2d倒影实现思路, 对于上面要解决的问题可以简单总结为

  1. 倒影/水深贴图怎么获取?
  2. 怎么只在水体范围内渲染这些贴图内容?

对于第一个倒影贴图怎么来的问题, 如果要精细控制还是需要倒影与水深分开获取. 对于倒影, 需要能实时地渲染出物体的动态倒影, 这个也有几种方案, 像是屏幕空间上下倒转, 物体贴图的倒转. 水深与倒影的处理类似, 只是不需要进行倒转操作.

对于第二个问题, 倒影只出现在水体范围内, 大致有两种做法, 一是使用模板缓冲(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 代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
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, 一种实现如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 中上述局部空间坐标的失效, 参考,

解决方法是在 shader 中加个禁止 batching 的 tag,

1
"DisableBatching" = "True"

这能解决表现的问题, 不过代价是所有物体都需要单独进行绘制. 进一步检索之后发现, Sprite Renderer 在合批流程中的支持一直不太好, 原生不支持常见的合批方法, 而其自带的动态合批在使用不同的 Sprite 时也没办法合批(似乎是Sprite Renderer 针对不同的 Sprite 会生成不同的网格). 参考

似乎 unity 2023 版本 Sprite Renderer 才支持了 SRP Batcher.

不过好在已经有人探索过相关的解决方案了, 比如

从这个分享中看, 我猜铃兰之剑似乎也使用了类似的方案. 思路是自己创建管理相同的渲染网格, 用 Mesh Renderer 代替 Sprite Renderer 进行渲染, 而这个流程支持 GPU Instancing, 可以说是一个相当优雅的解决方案了. 那么问题就由创建 Sprite, 变成创建 Mesh. 处理后我们可以在 frame debuger 中看到, 所有物体是由单个批次绘制的. 物体挂载脚本 MapObjectRenerer.cs 与物体 shader mapobject.shader 实现如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
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<Texture2D, int> tex_indexes = new Dictionary<Texture2D, int>();
private static int array_size = 128;
private static Texture2DArray tex_array;
private static int tex_count = 0;

private void Awake()
{
_sprite_renderer = GetComponent<SpriteRenderer>();
_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<MeshFilter>();
mesh_filter.sharedMesh = _mesh;

_sprite_mesh_renderer = _sprite_mesh_go.AddComponent<MeshRenderer>();
_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<MeshFilter>();
mesh_filter.sharedMesh = _mesh;

_reflection_mesh_renderer = _reflection_mesh_go.AddComponent<MeshRenderer>();
_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<MeshFilter>();
mesh_filter.sharedMesh = _mesh;

_underwater_mesh_renderer = _underwater_mesh_go.AddComponent<MeshRenderer>();
_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);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
#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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#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游戏水体的参考

3d游戏水体的参考

有时可以从真实水体渲染方案中找找灵感