复刻一个 Pixel Art 水体渲染实现

作者:Granvallen
2025-01-02
11 10 1

前段时间,在 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 水面及物体水面投影的渲染》

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

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

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

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 中上述局部空间坐标的失效,可参考以下讨论:

解决方法是在 Shader 中加个禁止 Batching 的 Tag:

"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 实现如下:

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 游戏水体的参考
3D 游戏水体的参考

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

本文为用户投稿,不代表 indienova 观点。

近期点赞的会员

 分享这篇文章

Granvallen 

一个爱好漫画的游戏开发者 

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. sdjdasha 2025-01-03

    哇哦跑去看了下原视频像素风格真好看。感谢分享!

您需要登录或者注册后才能发表评论

登录/注册