复刻一个Pixel Art水体渲染实现
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 就可以做出不同的地形表现.
思路
在这个 jess 的方案中, 考虑了水体表现的几个部分, 如图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
200Shader "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
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 | float2 squash_uv = world_pos.xy * float2(1, _CausticSquash); |
这里使用世界坐标加扰动来生成采样散焦纹理(_CausticTex
)的uv坐标. 使用 _CausticSquash
对竖直方向进行缩放, 以用模拟斜视水面的效果. gradient_noise 用于产生扰动uv所需的柏林噪声(Perlin Noise), 其定义见附录1.
- 倒影与水深
1 | // 倒影 / 水深 |
在这个实现方案中, 倒影(_ReflectionTex
)与水深(_UnderwaterTex
)有两个单独的渲染纹理(RT, Render Texture), 在渲染水体时进行采样, RT的生成参考倒影与水深实现方案一节. _ReflectionIntensity
用于控制两者混合的权重. 我感觉通常倒影和水深的效果只需取其一, 否则混合后重叠看着有点乱.
- 散焦纹理采样
1 | // 散焦纹理 |
这里有散焦底色的纹理(_CausticTex
)与高亮的纹理(_CausticHighlightTex
), 后者由前者图像处理腐蚀后生成, 保证了高亮的部分一定在底色的纹理之上, 看起来就像是部分波纹变成了高亮. 同时用柏林噪声增加了散焦纹理部分渐隐效果.
1 | // 高光 |
高光的部分使用2个反向运动的柏林噪声与一个静态的柏林噪声混合生成.
- 泡沫
1 | // 泡沫 |
这里 _FoamTex
泡沫贴图是散焦贴图缩放后得到的, 用于表现细小的岸边泡沫. 不同于3d中使用深度确定泡沫显示范围, jess 的方案使用高斯模糊使白色边缘具有渐变效果, 然后与泡沫纹理混合. gaussian_blur_5x5
的定义见附录1.
高斯模糊 + tilemap 会有一个隐含的坑点. 说来话长, 我把这个话题放到附录2中讨论. 其实可以预先在 tileset 中就处理好渐变, 这样可以省去高斯模糊这一步, 我看到 jess 后续的分享似乎也改变了泡沫的实现方法.
- 混合以上所有颜色
1 | // 混合 主色, 散焦, 高光, 泡沫, 倒影 |
pixelate_uv
的定义见附录1, 原理可以参考这篇文章浅谈Pixel Art缩放及抗锯齿问题 | Granvallen;Nest.
以上从我自己测试来看, 相同参数下, 水体表现和 jess 的实现基本一致. shader 中所用到的3张贴图(_CausticTex
, _CausticHighlightTex
和 _FoamTex
)都来自 jess 的示例工程, 实现效果如图3,
调整部分参数的效果变化如图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倒影实现思路, 对于上面要解决的问题可以简单总结为
- 倒影/水深贴图怎么获取?
- 怎么只在水体范围内渲染这些贴图内容?
对于第一个倒影贴图怎么来的问题, 如果要精细控制还是需要倒影与水深分开获取. 对于倒影, 需要能实时地渲染出物体的动态倒影, 这个也有几种方案, 像是屏幕空间上下倒转, 物体贴图的倒转. 水深与倒影的处理类似, 只是不需要进行倒转操作.
对于第二个问题, 倒影只出现在水体范围内, 大致有两种做法, 一是使用模板缓冲(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
28using UnityEngine.Rendering.Universal;
using UnityEngine;
public class RenderLayerFeature : ScriptableRendererFeature
{
private RenderLayerPass _pass;
[ ]
private LayerMask layer_mask;
[ ]
private string rt_name = "_TempRenderLayer";
[ ]
private RenderPassEvent pass_event = RenderPassEvent.BeforeRenderingOpaques;
[ ]
private TransparencySortMode sort_mode = TransparencySortMode.Default;
[ ]
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 | using UnityEngine.Rendering.Universal; |
这个 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
21v2f 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,1
"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 实现如下,
1 | using System.Collections.Generic; |
1 | Shader "Custom/MapObject2" |
挂上这个脚本后, 原来的 Sprite Renderer 会停止渲染, 渲染的工作交给三个子节点上的 Mesh.
最终实现的效果如图5,
附录
common.hlsl 与 pixel_art.hlsl
1 |
|
1 |
|
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
- 基于屏幕的2D实时反射
- 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
.
.
.
.
.
.
.
終わり