GameTech/URP 渲染实战
CHAPTER 19第 19 章

水

水几乎用到了实时渲染的所有招数:深度图、不透明纹理、法线贴图、菲涅尔、环境反射、阴影、顶点动画,再加一台反射相机。本章从一块蓝色的半透明平面开始,一步一步把它变成一个完整的风格化水面。

本章你会学到
用深度图重建水底世界坐标,区分“竖直水深”与“视线路径长度”两种深度
用不透明纹理做折射、处理前景泄漏,用 Beer-Lambert 定律做按通道的水体吸收
双层法线流动、菲涅尔与天空反射、太阳高光、岸边泡沫、程序化 Voronoi 焦散的实现
Gerstner 波在水面 Shader 中的接入,以及切线空间的同步更新
URP 14 平面反射的正确写法:反射矩阵、斜裁剪投影、SubmitRenderRequest,以及为什么不能在 beginCameraRendering 里提交请求

19.1 一束光照到水面上会发生什么

动手之前先想清楚要模拟什么。看向一片水,你看到的颜色来自下面几条光路(图 19-1):

  • 反射:天空、岸上的物体在水面上的倒影。反射有多强取决于视角——低头看脚下的水几乎看不到倒影,看远处的水面则像一面镜子,这是菲涅尔效应。
  • 折射:水底的沙子、石头透过水面被看到,而且被水面的起伏弯折。
  • 吸收与散射:光在水里走得越远,被吸收得越多,而且红光比蓝绿光吸收得快得多,所以深水发蓝绿;水中的微粒把光散射回来,给水体本身一个“颜色”。
  • 高光:太阳在波光粼粼的水面上的镜面反射。
  • 泡沫:浅滩、礁石、岸边的白色泡沫。
  • 焦散:起伏的水面像无数个透镜,把阳光会聚到水底,形成游动的亮纹。
太阳 眼睛 反射(天空/倒影) 折射(看到水底) 路径越长吸收越多 焦散:光被会聚 高光 岸边:泡沫
图 19-1水面颜色的来源:反射、折射、吸收、高光、焦散与岸边泡沫。本章每个案例模拟其中一项。

本章的做法是:先写一个包含所有功能的 Shader 框架 Water.shader,每一项特性对应一个 shader_feature_local 关键字;所有“积木”函数放在 WaterCore.hlsl 里。每个案例打开一个新关键字、讲清楚一个函数。最后全部打开,就是完整的水面。这样做的好处是你可以随时关掉某一项,看它到底贡献了什么——调水的时候这比什么都重要。

案例关键字 / 函数依赖
19.1 场景与最简单的水(无)透明队列
19.2 深度着色SampleWaterDepthDepth Texture
19.3 折射与吸收_REFRACTION、RefractUV、UnderwaterColorOpaque Texture
19.4 双法线流动_NORMALS、DualNormalTS可平铺法线贴图
19.5 菲涅尔与天空反射_REFLECTION、WaterFresnel天空/反射探针
19.6 太阳高光_SPECULAR、SunSpecular主光、阴影
19.7 岸边泡沫_FOAM、ShoreFoamDepth Texture
19.8 焦散_CAUSTICS、CausticsDepth + Opaque
19.9 Gerstner 波_GERSTNER、GerstnerWave细分网格
19.10 平面反射_PLANAR_REFLECTION、PlanarReflection.cs反射相机 + RT

案例 19.1搭场景:一个有深有浅的水池入门单独打开 ↗

目标:准备一个能“考验”水面的场景,并放上最朴素的水:一块单色半透明平面。

水的效果几乎全部依赖“水下有什么”。一块水面放在空无一物的平地上,深度着色、折射、泡沫、焦散都无从体现。所以我们先搭一个小水池:地形是一张程序生成的高度场网格,中间深约 1.7 米,左后方是缓坡沙滩;水里有几块石头(一块完全淹没、顶部离水面很近,一块露出水面),三根从水底伸出水面的石柱,以及一座横跨在水面上方的木栈道。沙子用棋盘格纹理——规则的格子最能看出折射的扭曲和焦散的形状。

水面本身是一张 60 米见方的细分网格(Shot.Grid),放在 y=0。它被放在内置的 Water 层(第 4 层),后面平面反射的相机会排除这一层。

Shader 的框架如下(顶点、片元函数在后面的案例里逐段讲解):

Assets/Cookbook/Ch19/Water.shader
// 第 19 章:完整的风格化水面。每个特性一个关键字,正文按案例逐个打开。
// 依赖:URP Asset 开启 Depth Texture 与 Opaque Texture;水面放在 Water 层(平面反射相机不渲染它)。
Shader "Cookbook/Ch19/Water"
{
    Properties
    {
        [Header(Depth Color)]
        _ShallowColor ("Shallow Color (A=alpha)", Color) = (0.35,0.85,0.8,0.35)
        _DeepColor ("Deep / Scatter Color (A=alpha)", Color) = (0.03,0.22,0.35,0.95)
        _DepthMax ("Depth For Deep Color (m)", Float) = 2
        _Absorption ("Absorption per meter (RGB)", Vector) = (0.9,0.32,0.22,0)
        _EdgeFade ("Shore Edge Fade (m)", Float) = 0.08

        [Header(Refraction)]
        [Toggle(_REFRACTION)] _Refraction ("Refraction", Float) = 1
        _RefractionStrength ("Refraction Strength", Range(0,0.2)) = 0.05
        [Toggle] _RefractLeakFix ("Fix Foreground Leak", Float) = 1

        [Header(Normals)]
        [Toggle(_NORMALS)] _Normals ("Dual Normals", Float) = 1
        [NoScaleOffset][Normal] _NormalMap ("Normal Map", 2D) = "bump" {}
        _NormalTiling ("Tiling A/B (per meter)", Vector) = (0.18,0.31,0,0)
        _FlowA ("Flow A (xy dir*speed, z strength)", Vector) = (0.03,0.02,0.6,0)
        _FlowB ("Flow B (xy dir*speed, z strength)", Vector) = (-0.025,0.035,0.45,0)

        [Header(Reflection)]
        [Toggle(_REFLECTION)] _Reflection ("Reflection", Float) = 1
        [Toggle(_PLANAR_REFLECTION)] _PlanarReflection ("Use Planar Reflection Texture", Float) = 0
        _ReflectionStrength ("Reflection Strength", Range(0,1)) = 1
        [Toggle] _UseFresnel ("Use Fresnel", Float) = 1
        _Roughness ("Reflection Roughness", Range(0,1)) = 0.05
        _PlanarDistortion ("Planar Reflection Distortion", Range(0,0.2)) = 0.04

        [Header(Specular)]
        [Toggle(_SPECULAR)] _Specular ("Sun Specular", Float) = 1
        _SpecPower ("Spec Power", Range(16,2048)) = 600
        _SpecIntensity ("Spec Intensity", Range(0,4)) = 1

        [Header(Foam)]
        [Toggle(_FOAM)] _Foam ("Shore Foam", Float) = 1
        [NoScaleOffset] _FoamNoise ("Foam Noise", 2D) = "gray" {}
        _FoamColor ("Foam Color", Color) = (0.95,0.98,1,1)
        _FoamDistance ("Foam Distance (m)", Float) = 0.6
        _FoamTiling ("Foam Tiling (per meter)", Float) = 0.35
        _FoamSoftness ("Foam Softness", Range(0.001,0.3)) = 0.03

        [Header(Caustics)]
        [Toggle(_CAUSTICS)] _Caustics ("Caustics", Float) = 1
        _CausticScale ("Caustic Scale (cells per meter)", Float) = 1.6
        _CausticIntensity ("Caustic Intensity", Range(0,4)) = 1.2
        _CausticDepthFade ("Caustic Depth Fade (m)", Float) = 1.5
        _CausticSplit ("Caustic RGB Split", Range(0,0.1)) = 0.025

        [Header(Gerstner Waves)]
        [Toggle(_GERSTNER)] _Gerstner ("Gerstner Waves", Float) = 0
        _WaveA ("Wave A (dir, steepness, wavelength)", Vector) = (1,0.3,0.18,7)
        _WaveB ("Wave B", Vector) = (0.4,1,0.15,4.3)
        _WaveC ("Wave C", Vector) = (-0.7,0.6,0.12,2.6)

        [Header(Time and Debug)]
        [Toggle] _AutoPlay ("Auto Play (_Time)", Float) = 0
        _Phase ("Phase (seconds)", Float) = 0
        [Enum(Off,0,VerticalDepth,1,ViewPath,2,Normal,3,Foam,4,Caustics,5,Fresnel,6)] _DebugView ("Debug View", Float) = 0
    }

    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent-100" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Water"
            Tags { "LightMode"="UniversalForward" }
            Blend SrcAlpha OneMinusSrcAlpha
            ZWrite Off
            Cull Back

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma shader_feature_local _REFRACTION
            #pragma shader_feature_local _NORMALS
            #pragma shader_feature_local_fragment _REFLECTION
            #pragma shader_feature_local_fragment _PLANAR_REFLECTION
            #pragma shader_feature_local_fragment _SPECULAR
            #pragma shader_feature_local_fragment _FOAM
            #pragma shader_feature_local_fragment _CAUSTICS
            #pragma shader_feature_local_vertex _GERSTNER
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
            #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
            #pragma multi_compile _ _FORWARD_PLUS
            #pragma multi_compile_fog

            #include "Assets/Cookbook/Ch19/WaterCore.hlsl"

            struct Attributes
            {
                float4 positionOS : POSITION;
                float3 normalOS   : NORMAL;
                float4 tangentOS  : TANGENT;
            };

            struct Varyings
            {
                float4 positionCS  : SV_POSITION;
                float3 positionWS  : TEXCOORD0;
                half3  normalWS    : TEXCOORD1;
                half3  tangentWS   : TEXCOORD2;
                half3  bitangentWS : TEXCOORD3;
                half   fogFactor   : TEXCOORD4;
            };

            Varyings vert(Attributes v)
            {
                Varyings o;
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                // 平静水面:切线 +X、副切线 +Z、法线 +Y
                float3 tangent = float3(1, 0, 0);
                float3 binormal = float3(0, 0, 1);
            #if defined(_GERSTNER)
                float t = WaterTime();
                float3 p = positionWS;
                positionWS += GerstnerWave(_WaveA, p, t, tangent, binormal);
                positionWS += GerstnerWave(_WaveB, p, t, tangent, binormal);
                positionWS += GerstnerWave(_WaveC, p, t, tangent, binormal);
            #endif
                o.positionWS = positionWS;
                o.positionCS = TransformWorldToHClip(positionWS);
                o.tangentWS = normalize(tangent);
                o.bitangentWS = normalize(binormal);
                o.normalWS = normalize(cross(binormal, tangent));
                o.fogFactor = ComputeFogFactor(o.positionCS.z);
                return o;
            }

            half4 frag(Varyings i) : SV_Target
            {
                float t = WaterTime();
                float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);
                half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
                float surfaceEye = LinearEyeDepth(i.positionWS, UNITY_MATRIX_V);
                Light mainLight = GetMainLight(TransformWorldToShadowCoord(i.positionWS));

                // ---- 法线 ----
                half3 normalTS = half3(0, 0, 1);
            #if defined(_NORMALS)
                normalTS = DualNormalTS(i.positionWS.xz, t);
            #endif
                half3x3 tbn = half3x3(i.tangentWS, i.bitangentWS, i.normalWS);
                half3 normalWS = normalize(mul(normalTS, tbn));

                // ---- 深度 ----
                WaterDepth wd = SampleWaterDepth(screenUV, i.positionWS);
                // 水体自身被照亮的颜色(散射色):主光 + 环境光
                half3 waterLight = mainLight.color * saturate(mainLight.direction.y) * mainLight.shadowAttenuation + SampleSH(half3(0, 1, 0));
                half3 color;
                half alpha;

            #if defined(_REFRACTION)
                // ---- 折射 + 吸收(不透明合成:自己把“水下画面”算出来,alpha = 1)----
                float2 uvR = RefractUV(screenUV, normalTS, surfaceEye, wd.vertical);
                WaterDepth wdR = SampleWaterDepth(uvR, i.positionWS);
                half3 sceneColor = SampleSceneColor(uvR);
                #if defined(_CAUSTICS)
                sceneColor += Caustics(wdR.sceneWS, wdR.vertical, SceneUpFacing(wd.sceneWS), mainLight, t);
                #endif
                color = UnderwaterColor(sceneColor, wdR.path, _DeepColor.rgb * waterLight);
                // 岸线处和未扰动的画面过渡,避免水面边缘出现一条硬线
                color = lerp(SampleSceneColor(screenUV), color, saturate(wd.vertical / _EdgeFade));
                alpha = 1;
            #else
                // ---- 最简单的版本:按竖直水深在浅色和深色之间插值,靠 alpha 混合透出水底 ----
                half d01 = saturate(wd.vertical / _DepthMax);
                color = lerp(_ShallowColor.rgb, _DeepColor.rgb, d01) * waterLight;
                alpha = lerp(_ShallowColor.a, _DeepColor.a, d01) * saturate(wd.vertical / _EdgeFade);
            #endif

                // ---- 反射 ----
                half fresnel = _UseFresnel > 0.5 ? WaterFresnel(normalWS, viewWS) : 1.0h;
            #if defined(_REFLECTION)
                #if defined(_PLANAR_REFLECTION)
                    half3 reflection = SAMPLE_TEXTURE2D(_PlanarReflectionTex, sampler_PlanarReflectionTex,
                                                        screenUV + normalTS.xy * _PlanarDistortion).rgb;
                #else
                    half3 reflectDir = reflect(-viewWS, normalWS);
                    // Forward+ 下只有带屏幕 UV 的 5 参数版本(反射探针按屏幕分块查找),4 参数版本被 #if 掉了
                    half3 reflection = GlossyEnvironmentReflection(reflectDir, i.positionWS, _Roughness, 1.0h, screenUV);
                #endif
                half kr = fresnel * _ReflectionStrength;
                color = lerp(color, reflection, kr);
                alpha = lerp(alpha, 1.0h, kr);
            #endif

                // ---- 高光 ----
            #if defined(_SPECULAR)
                color += SunSpecular(mainLight, normalWS, viewWS);
            #endif

                // ---- 泡沫 ----
                half foam = 0;
            #if defined(_FOAM)
                foam = ShoreFoam(i.positionWS.xz, wd.vertical, normalTS, t);
                color = lerp(color, _FoamColor.rgb * waterLight, foam);
                alpha = max(alpha, foam);
            #endif

                // ---- 调试视图:把中间量直接画出来 ----
                if (_DebugView > 0.5)
                {
                    half3 dbg = 0;
                    if (_DebugView < 1.5)      dbg = saturate(wd.vertical / _DepthMax);
                    else if (_DebugView < 2.5) dbg = saturate(wd.path / _DepthMax);
                    else if (_DebugView < 3.5) dbg = half3(normalTS.xy * 1.5h + 0.5h, 0.5h);   // 放大切线空间 xy,看清纹理
                    else if (_DebugView < 4.5) dbg = foam;
                    else if (_DebugView < 5.5) dbg = CausticPattern(wd.sceneWS.xz * _CausticScale, t) * 0.5;
                    else                       dbg = fresnel;
                    return half4(dbg, 1);
                }

                color = MixFog(color, i.fogFactor);
                return half4(color, alpha);
            }
            ENDHLSL
        }
    }
}
// 第 19 章:完整的风格化水面。每个特性一个关键字,正文按案例逐个打开。(GLSL 版,对应 Ch19/Water.shader)
// 依赖:URP Asset 开启 Depth Texture 与 Opaque Texture;水面放在 Water 层(平面反射相机不渲染它)。
Shader "Cookbook/Ch19/Water"
{
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent-100" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            Name "Water"
            Tags { "LightMode"="UniversalForward" }
            Blend SrcAlpha OneMinusSrcAlpha
            ZWrite Off
            Cull Back

            GLSLPROGRAM
            #pragma shader_feature_local _REFRACTION
            #pragma shader_feature_local _NORMALS
            #pragma shader_feature_local_fragment _REFLECTION
            #pragma shader_feature_local_fragment _PLANAR_REFLECTION
            #pragma shader_feature_local_fragment _SPECULAR
            #pragma shader_feature_local_fragment _FOAM
            #pragma shader_feature_local_fragment _CAUSTICS
            #pragma shader_feature_local_vertex _GERSTNER
            #pragma multi_compile_fog

            #include "Ch19/WaterCore.glsl"

            VARYING vec3  positionWS;
            VARYING vec3  normalWS;
            VARYING vec3  tangentWS;
            VARYING vec3  bitangentWS;
            VARYING float fogFactor;

            #ifdef VERTEX
            void main()
            {
                vec3 pWS = TransformObjectToWorld(positionOS.xyz);
                // 平静水面:切线 +X、副切线 +Z、法线 +Y
                vec3 tangent = vec3(1, 0, 0);
                vec3 binormal = vec3(0, 0, 1);
            #if defined(_GERSTNER)
                float t = WaterTime();
                vec3 p = pWS;
                pWS += GerstnerWave(_WaveA, p, t, tangent, binormal);
                pWS += GerstnerWave(_WaveB, p, t, tangent, binormal);
                pWS += GerstnerWave(_WaveC, p, t, tangent, binormal);
            #endif
                positionWS = pWS;
                gl_Position = TransformWorldToHClip(pWS);
                tangentWS = normalize(tangent);
                bitangentWS = normalize(binormal);
                normalWS = normalize(cross(binormal, tangent));
                fogFactor = ComputeFogFactor(gl_Position.z);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                float t = WaterTime();
                vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
                vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
                float surfaceEye = LinearEyeDepth(positionWS, UNITY_MATRIX_V);
                Light mainLight = GetMainLight(TransformWorldToShadowCoord(positionWS));

                // ---- 法线 ----
                vec3 normalTS = vec3(0, 0, 1);
            #if defined(_NORMALS)
                normalTS = DualNormalTS(positionWS.xz, t);
            #endif
                // HLSL 的 half3x3(T, B, N) 按行构造、mul(normalTS, tbn);GLSL 的 mat3(T, B, N) 按列,写成 M * v 结果相同
                mat3 tbn = mat3(tangentWS, bitangentWS, normalWS);
                vec3 N = normalize(tbn * normalTS);

                // ---- 深度 ----
                WaterDepth wd = SampleWaterDepth(screenUV, positionWS);
                // 水体自身被照亮的颜色(散射色):主光 + 环境光
                vec3 waterLight = mainLight.color * saturate(mainLight.direction.y) * mainLight.shadowAttenuation + SampleSH(vec3(0, 1, 0));
                vec3 color;
                float alpha;

            #if defined(_REFRACTION)
                // ---- 折射 + 吸收(不透明合成:自己把“水下画面”算出来,alpha = 1)----
                vec2 uvR = RefractUV(screenUV, normalTS, surfaceEye, wd.vertical);
                WaterDepth wdR = SampleWaterDepth(uvR, positionWS);
                vec3 sceneColor = SampleSceneColor(uvR);
                #if defined(_CAUSTICS)
                sceneColor += Caustics(wdR.sceneWS, wdR.vertical, SceneUpFacing(wd.sceneWS), mainLight, t);
                #endif
                color = UnderwaterColor(sceneColor, wdR.path, _DeepColor.rgb * waterLight);
                // 岸线处和未扰动的画面过渡,避免水面边缘出现一条硬线
                color = mix(SampleSceneColor(screenUV), color, saturate(wd.vertical / _EdgeFade));
                alpha = 1.0;
            #else
                // ---- 最简单的版本:按竖直水深在浅色和深色之间插值,靠 alpha 混合透出水底 ----
                float d01 = saturate(wd.vertical / _DepthMax);
                color = mix(_ShallowColor.rgb, _DeepColor.rgb, d01) * waterLight;
                alpha = mix(_ShallowColor.a, _DeepColor.a, d01) * saturate(wd.vertical / _EdgeFade);
            #endif

                // ---- 反射 ----
                float fresnel = _UseFresnel > 0.5 ? WaterFresnel(N, viewWS) : 1.0;
            #if defined(_REFLECTION)
                #if defined(_PLANAR_REFLECTION)
                    vec3 reflection = texture(_PlanarReflectionTex, screenUV + normalTS.xy * _PlanarDistortion).rgb;
                #else
                    vec3 reflectDir = reflect(-viewWS, N);
                    // HLSL 版用 Forward+ 的 5 参数版本(带屏幕 UV);网页版没有反射探针分块,用 4 参数版本
                    vec3 reflection = GlossyEnvironmentReflection(reflectDir, positionWS, _Roughness, 1.0);
                #endif
                float kr = fresnel * _ReflectionStrength;
                color = mix(color, reflection, kr);
                alpha = mix(alpha, 1.0, kr);
            #endif

                // ---- 高光 ----
            #if defined(_SPECULAR)
                color += SunSpecular(mainLight, N, viewWS);
            #endif

                // ---- 泡沫 ----
                float foam = 0.0;
            #if defined(_FOAM)
                foam = ShoreFoam(positionWS.xz, wd.vertical, normalTS, t);
                color = mix(color, _FoamColor.rgb * waterLight, foam);
                alpha = max(alpha, foam);
            #endif

                // ---- 调试视图:把中间量直接画出来 ----
                if (_DebugView > 0.5)
                {
                    vec3 dbg = vec3(0.0);
                    if (_DebugView < 1.5)      dbg = vec3(saturate(wd.vertical / _DepthMax));
                    else if (_DebugView < 2.5) dbg = vec3(saturate(wd.path / _DepthMax));
                    else if (_DebugView < 3.5) dbg = vec3(normalTS.xy * 1.5 + 0.5, 0.5);   // 放大切线空间 xy,看清纹理
                    else if (_DebugView < 4.5) dbg = vec3(foam);
                    else if (_DebugView < 5.5) dbg = vec3(CausticPattern(wd.sceneWS.xz * _CausticScale, t) * 0.5);
                    else                       dbg = vec3(fresnel);
                    fragColor = vec4(dbg, 1.0);
                    return;
                }

                color = MixFog(color, fogFactor);
                fragColor = vec4(color, alpha);
            }
            #endif
            ENDGLSL
        }
    }
}

先看渲染状态:"Queue"="Transparent-100",比普通透明物体早一点画,这样水面之后再画的粒子、玻璃会正确地叠在水面上;Blend SrcAlpha OneMinusSrcAlpha 标准 Alpha 混合;ZWrite Off 不写深度。水面不需要 ShadowCaster(它不该在水底投下影子),也不需要 DepthOnly(它不能出现在深度图里,否则后面读深度图时读到的就是水面自己)。

关闭所有关键字时,片元走最简单的分支:颜色和 alpha 都是常数。

图 19-2左:没有水的水池场景;右:最朴素的水——单色、单一透明度。它的问题一眼可见:深处和浅处一样透明,水面像一块蓝色塑料片,和岸边的交线生硬。

案例 19.2深度着色:深的地方更深入门单独打开 ↗

目标:让水的颜色和透明度随水深变化:浅处清澈泛青,深处浓重发蓝;岸边水深趋于 0 时平滑过渡,不出现硬边。

原理:从深度图重建水底

水面在透明队列里绘制时,深度图 _CameraDepthTexture 里存着水底(以及水中所有不透明物体)的深度。在水面的每个像素上,我们用同一个屏幕 UV 去采样深度图,就得到“透过这个像素看下去,水底在哪里”。有了原始深度,可以用 URP Core 库里的 ComputeWorldSpacePosition(uv, rawDepth, UNITY_MATRIX_I_VP) 把它还原成世界坐标——这个函数用逆视图投影矩阵把 NDC 坐标变换回世界空间(第 1 章、第 28 章)。

有了水底的世界坐标,就能定义两种“水深”:

  • 竖直水深:水面 y − 水底 y。它是水体本身的属性,与从哪个角度看无关,适合用来决定“这里是浅滩还是深潭”——比如水的颜色、岸边泡沫。
  • 视线路径长度:从水面上这一点沿视线到水底的距离。斜着看时,视线在水里走得更长,被吸收得更多——这正是为什么远处的浅水看起来也比脚下的深。它适合用来计算吸收。
水面 水底 相机 视线路径长度 path 竖直水深 水面像素 sceneWS
图 19-3两种水深。竖直水深 = 水面 y − 重建出的水底 y;视线路径 = 水面点到水底点的距离,斜看时远大于竖直水深。

严格地说,“竖直水深”应该取水面这一点正下方的水底高度,但我们只能拿到沿视线方向的那个水底点。代码里用的是“水面 y − 重建点 y”:水底起伏平缓时二者很接近,而且计算极其便宜。这是实时水面普遍采用的近似。

Assets/Cookbook/Ch19/WaterCore.hlsl(节选:depth)
// 水下深度:用水面这个像素的屏幕 UV 取场景深度,重建出水底的世界坐标
struct WaterDepth
{
    float3 sceneWS;     // 水底(或水中物体)表面的世界坐标
    float  vertical;    // 竖直水深 = 水面 y - 水底 y(与视角无关)
    float  path;        // 视线在水里走过的距离(与视角有关,斜看时更长)
    float  sceneEye;    // 场景的观察空间深度
};

WaterDepth SampleWaterDepth(float2 uv, float3 surfaceWS)
{
    WaterDepth o;
    float raw = SampleSceneDepth(uv);
    o.sceneWS = ComputeWorldSpacePosition(uv, raw, UNITY_MATRIX_I_VP);
    o.vertical = surfaceWS.y - o.sceneWS.y;
    o.path = distance(o.sceneWS, surfaceWS);
    o.sceneEye = LinearEyeDepth(raw, _ZBufferParams);
    return o;
}
// 水下深度:用水面这个像素的屏幕 UV 取场景深度,重建出水底的世界坐标
struct WaterDepth
{
    vec3  sceneWS;     // 水底(或水中物体)表面的世界坐标
    float vertical;    // 竖直水深 = 水面 y - 水底 y(与视角无关)
    float path;        // 视线在水里走过的距离(与视角有关,斜看时更长)
    float sceneEye;    // 场景的观察空间深度
};

WaterDepth SampleWaterDepth(vec2 uv, vec3 surfaceWS)
{
    WaterDepth o;
    float raw = SampleSceneDepth(uv);
    o.sceneWS = ComputeWorldSpacePosition(uv, raw, UNITY_MATRIX_I_VP);
    o.vertical = surfaceWS.y - o.sceneWS.y;
    o.path = distance(o.sceneWS, surfaceWS);
    o.sceneEye = LinearEyeDepth(raw, _ZBufferParams);
    return o;
}

深度着色本身很简单:竖直水深除以一个“完全变深”的深度 _DepthMax,在浅色和深色之间插值,透明度也同样插值;再乘一个 saturate(depth / _EdgeFade),水深趋于 0 时 alpha 也趋于 0,岸线就从硬边变成了柔和的过渡。颜色还乘了一个 waterLight(主光 × 阴影 + 环境光),让水体在柱子的阴影里也会变暗。

对应的代码在上面完整 Shader 片元函数的“深度”部分和 #else 分支(没有 _REFRACTION 时)里:

WaterDepth wd = SampleWaterDepth(screenUV, i.positionWS);
half3 waterLight = mainLight.color * saturate(mainLight.direction.y) * mainLight.shadowAttenuation
                 + SampleSH(half3(0, 1, 0));
// ……
half d01 = saturate(wd.vertical / _DepthMax);
color = lerp(_ShallowColor.rgb, _DeepColor.rgb, d01) * waterLight;
alpha = lerp(_ShallowColor.a, _DeepColor.a, d01) * saturate(wd.vertical / _EdgeFade);

片元函数的其余部分会在后面的案例里逐段展开。

图 19-4左:调试视图,竖直水深(除以 2 米,白=深);中:调试视图,视线路径长度(同样除以 2 米)——远处的水路径更长,更早变白;右:按竖直水深着色的水,浅处青绿透明、深处深蓝,岸线柔和过渡。
注意深度图必须开启(URP Asset 的 Depth Texture,或相机上的覆盖选项)。深度图里不包含透明物体,所以两片重叠的水面彼此“看不见”;水面下方的半透明物体(比如水草用的 Alpha Blend 材质)也不会被算作水底。

案例 19.3折射与吸收进阶单独打开 ↗

目标:水底的画面被水面的起伏弯折;光在水里按颜色被吸收,深处偏蓝绿;同时解决折射最常见的瑕疵——水面上方的物体“漏”进水里。

不再依赖 Alpha 混合

案例 19.2 的水靠 Alpha 混合透出水底:GPU 把水的颜色和已有画面按比例混合。要做折射,我们需要读取“旁边一点”的水底画面,Alpha 混合做不到。所以从这一步开始换一种思路:自己把水下画面算出来,输出 alpha = 1。水下画面从 _CameraOpaqueTexture 读取(第 18 章案例 18.8 讲过它的拷贝时机),采样位置按法线偏移。

折射偏移量用法线的切线空间 xy 分量:平静水面法线是 (0,0,1),没有偏移;起伏处 xy 越大偏移越大。这一步需要法线贴图,所以配图里提前打开了 _NORMALS(下一个案例讲它),暂时只用一层。

两个让折射更可信的细节

第一,越浅扰动越小。岸边几厘米深的水不应该把岸上的沙子扭得很厉害,否则岸线会抖成锯齿。偏移量乘 saturate(水深 × 2),半米以内逐渐减弱。

第二,远处的偏移要缩小。偏移是在屏幕空间加的,同样 0.05 的屏幕偏移,对近处的水只是几厘米,对远处的水可能是好几米。除以水面的观察深度做一个近似的透视补偿,远近的扭曲幅度看起来就一致了。

前景泄漏

屏幕空间折射有一个固有缺陷:偏移后的 UV 可能落在水面上方的物体上。图 19-5 里,水面像素 A 的折射 UV 偏到了 B,而 B 处的不透明纹理里是一个悬在水面上方的箱子——于是箱子的橙色出现在了水里。修正方法是在偏移后的位置再采样一次深度:如果那里的场景深度比水面本身还近,说明采到的是前景,就放弃偏移,退回原来的 UV。

水面上方的箱子 相机 A:水面像素 偏移后的 UV 指向 B B B 处场景深度 < A 处水面深度 → B 在水面前方 → 是前景,不能用 → 退回 A 的 UV
图 19-5前景泄漏:折射偏移把采样点挪到了水面之上的物体上。比较偏移点的场景深度与水面深度即可识别。
Assets/Cookbook/Ch19/WaterCore.hlsl(节选:refraction)
// 折射:按法线扰动屏幕 UV;如果扰动后取到的是“水面之上的东西”(深度比水面还近),说明发生了前景泄漏,退回原 UV
float2 RefractUV(float2 screenUV, half3 normalTS, float surfaceEye, float verticalDepth)
{
    // 越浅扰动越小:岸边几厘米深的水不该把岸上的东西扭得很厉害
    float2 offset = normalTS.xy * _RefractionStrength * saturate(verticalDepth * 2.0);
    // 远处的水在屏幕上更小,同样的偏移量会显得更夸张,除以深度做个近似的透视补偿
    offset /= max(1.0, surfaceEye * 0.25);
    float2 uv = screenUV + offset;
    if (_RefractLeakFix > 0.5)
    {
        float eye = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams);
        if (eye < surfaceEye) uv = screenUV;
    }
    return uv;
}
// 折射:按法线扰动屏幕 UV;如果扰动后取到的是“水面之上的东西”(深度比水面还近),说明发生了前景泄漏,退回原 UV
vec2 RefractUV(vec2 screenUV, vec3 normalTS, float surfaceEye, float verticalDepth)
{
    // 越浅扰动越小:岸边几厘米深的水不该把岸上的东西扭得很厉害
    vec2 offset = normalTS.xy * _RefractionStrength * saturate(verticalDepth * 2.0);
    // 远处的水在屏幕上更小,同样的偏移量会显得更夸张,除以深度做个近似的透视补偿
    offset /= max(1.0, surfaceEye * 0.25);
    vec2 uv = screenUV + offset;
    if (_RefractLeakFix > 0.5)
    {
        float eye = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams);
        if (eye < surfaceEye) uv = screenUV;
    }
    return uv;
}

这个修正不是完美的:被拒绝的像素退回原 UV,失去了扭曲,在箱子的轮廓附近会出现一小圈“不扭曲”的区域。但比起前景颜色漏进水里,这点瑕疵几乎察觉不到。

Beer-Lambert 吸收

光在均匀介质中传播距离 d 后,剩下的比例(透射率)是

T=e−σd

σ 是吸收系数,单位是“每米”。水对红光的吸收系数远大于蓝绿光,所以 σ 按 RGB 分别给(本例默认 (0.9, 0.32, 0.22),这是为了风格化效果调出来的数值,不是实测数据)。被吸收掉的那部分光并不是变成黑色——水中的微粒会把环境光散射回来,所以用“散射色”(水体被照亮的颜色)补上:

C=C水底·T+C散射·(1−T)
Assets/Cookbook/Ch19/WaterCore.hlsl(节选:absorption)
// Beer-Lambert:光在水里每走 1 米,各通道按 exp(-σ) 衰减。红光衰减最快,所以深水偏蓝绿。
// 被吸收掉的部分用“散射色”补上(水体本身被照亮的颜色)
half3 UnderwaterColor(half3 sceneColor, float pathLength, half3 scatterColor)
{
    half3 transmittance = exp(-_Absorption.rgb * pathLength);
    return sceneColor * transmittance + scatterColor * (1.0h - transmittance);
}
// Beer-Lambert:光在水里每走 1 米,各通道按 exp(-σ) 衰减。红光衰减最快,所以深水偏蓝绿。
// 被吸收掉的部分用“散射色”补上(水体本身被照亮的颜色)
vec3 UnderwaterColor(vec3 sceneColor, float pathLength, vec3 scatterColor)
{
    vec3 transmittance = exp(-_Absorption.rgb * pathLength);
    return sceneColor * transmittance + scatterColor * (1.0 - transmittance);
}

这里的 d 用的是视线路径长度,而不是竖直水深:从远处斜看浅水,视线在水里走得长,水的颜色会更浓——和真实世界一致。由于三个通道衰减速度不同,浅水几乎无色透明、稍深一点泛青绿、再深变成蓝色,这个颜色渐变是 Beer-Lambert 自动给出的,不需要手调渐变色。

图 19-6左:折射强度 0(只有吸收);中:折射强度 0.2、不做泄漏修正——箱子右上方的水面里出现了橙色的“倒影”,这是箱子颜色漏了进来;右:开启修正后泄漏消失,水底的折射保持不变。

折射分支里还有一行:color = lerp(SampleSceneColor(screenUV), color, saturate(wd.vertical / _EdgeFade))。水深趋近 0 时,最终颜色过渡到“没有水”的原始画面,代替了案例 19.2 用 alpha 做的岸线柔化。

案例 19.4双法线流动进阶单独打开 ↗

目标:用法线贴图给水面加上细碎的波纹,并让波纹“流动”起来,同时避免一眼就能看出的重复平铺。

原理:两层不同尺度、不同方向的法线

一张法线贴图平铺在大面积水面上,重复感非常明显;让它沿一个方向滚动,看起来又像整片水面在平移,而不是在波动。经典做法是用同一张法线贴图采样两次:两次的平铺尺度不同(比如每米 0.18 次和 0.31 次,比例不是整数,两层的重复周期错开),流动方向和速度也不同。两层叠加后,任何一处的图案都在不断变化,重复与平移感都被打破了。

两层法线的混合用 Whiteout 方法:xy 相加、z 相乘再归一化。第 7 章对比过几种细节法线混合方法,Whiteout 比简单平均保留更多起伏,计算又比 RNM 便宜。

Assets/Cookbook/Ch19/WaterCore.hlsl(节选:normals)
// 双层法线:同一张 water_normal 以不同尺度、不同方向流动,两层叠加打破重复感
half3 DualNormalTS(float2 xz, float t)
{
    float2 uvA = xz * _NormalTiling.x + _FlowA.xy * t;
    float2 uvB = xz * _NormalTiling.y + _FlowB.xy * t;
    half3 nA = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvA), _FlowA.z);
    half3 nB = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvB), _FlowB.z);
    // Whiteout 混合:xy 相加、z 相乘,比简单平均保留更多细节(第 7 章有对比)
    return normalize(half3(nA.xy + nB.xy, nA.z * nB.z));
}
// 双层法线:同一张 water_normal 以不同尺度、不同方向流动,两层叠加打破重复感
vec3 DualNormalTS(vec2 xz, float t)
{
    vec2 uvA = xz * _NormalTiling.x + _FlowA.xy * t;
    vec2 uvB = xz * _NormalTiling.y + _FlowB.xy * t;
    vec3 nA = UnpackNormalScale(texture(_NormalMap, uvA), _FlowA.z);
    vec3 nB = UnpackNormalScale(texture(_NormalMap, uvB), _FlowB.z);
    // Whiteout 混合:xy 相加、z 相乘,比简单平均保留更多细节(第 7 章有对比)
    return normalize(vec3(nA.xy + nB.xy, nA.z * nB.z));
}

采样坐标直接用世界坐标 xz,而不是网格 UV:这样水面的波纹尺度以“米”为单位,水面网格放大缩小都不会改变波纹大小,多块水面拼在一起也天然连续。

切线空间

平静水面的切线空间很简单:切线 +X、副切线 +Z、法线 +Y。顶点着色器把这三个向量传给片元,片元用 mul(normalTS, half3x3(T, B, N)) 变换到世界空间。之所以不直接写死,是因为加入 Gerstner 波(案例 19.9)后,T、B、N 会随波面倾斜,到时只需要在顶点着色器里改它们。

法线贴图必须能无缝平铺

大面积平铺时,法线贴图左右、上下边界上任何一点不连续,都会在水面上形成一条条笔直的细缝,在高光和反射里格外刺眼。一个典型的坑是:用若干正弦波叠加高度场时,如果某个波在贴图周期内走的不是整数圈,边界处就会断开,求导后边缘一圈像素的法线明显错误。本章用 C# 程序生成一张可平铺的水面法线(配套的公共贴图 water_normal 也按同样的规则生成),顺便讲清楚“可平铺”的三个条件:

Assets/Cookbook/Ch19/Editor/WaterNormalGenerator.cs(节选:gen)
public static Texture2D Create(int size = 256, float strength = 3.5f)
{
    if (s_Cached != null) return s_Cached;
    var h = new float[size, size];
    // 整数波矢 (kx, ky):在一个贴图周期里正好走完整数个波长,左右/上下边界自然衔接。
    // 方向大致集中在“风向”附近,看起来才像被风吹出来的水纹
    int[,] k = { { 3, 1 }, { 2, 3 }, { 4, -1 }, { 5, 2 }, { 3, 5 }, { 6, -3 }, { 7, 4 }, { -2, 6 },
                 { 9, 2 }, { 8, 7 }, { 11, -4 }, { 5, 10 }, { 13, 3 }, { -7, 11 } };
    var rnd = new System.Random(19);
    var phase = new float[k.GetLength(0)];
    for (int i = 0; i < phase.Length; i++) phase[i] = (float)rnd.NextDouble() * Mathf.PI * 2;

    for (int y = 0; y < size; y++)
        for (int x = 0; x < size; x++)
        {
            float u = (float)x / size, v = (float)y / size, sum = 0;
            // 域扭曲:用周期噪声扰动相位,打破正弦叠加的“格子布”感(噪声是周期的,所以仍然无缝)
            float warp = PeriodicNoise(u, v, 4, 7) * 2.2f + PeriodicNoise(u, v, 8, 8) * 1.1f;
            for (int i = 0; i < phase.Length; i++)
            {
                float kx = k[i, 0], ky = k[i, 1];
                float amp = 1f / Mathf.Sqrt(kx * kx + ky * ky);
                sum += amp * Mathf.Sin(2 * Mathf.PI * (kx * u + ky * v) + phase[i] + warp);
            }
            // 再叠几层周期性值噪声(fBm)
            sum += 0.3f * PeriodicNoise(u, v, 8, 1) + 0.15f * PeriodicNoise(u, v, 16, 2) + 0.08f * PeriodicNoise(u, v, 32, 3);
            h[x, y] = sum;
        }

    var px = new Color[size * size];
    for (int y = 0; y < size; y++)
        for (int x = 0; x < size; x++)
        {
            // 中心差分,坐标按周期回绕
            float dx = (h[(x + 1) % size, y] - h[(x - 1 + size) % size, y]) * 0.5f * strength;
            float dy = (h[x, (y + 1) % size] - h[x, (y - 1 + size) % size]) * 0.5f * strength;
            var n = new Vector3(-dx, -dy, 1).normalized;
            // R=x、G=y、B=z、A=1。URP 的 UnpackNormalScale 在桌面平台走 UnpackNormalmapRGorAG:
            // 先 a *= r 再取 (a, g),A=1 时得到的正好是 (r, g),所以这种未压缩的 RGBA 也能正确解码
            px[y * size + x] = new Color(n.x * 0.5f + 0.5f, n.y * 0.5f + 0.5f, n.z * 0.5f + 0.5f, 1);
        }

    s_Cached = new Texture2D(size, size, TextureFormat.RGBA32, true, true)
    {
        name = "WaterNormal_Tileable", wrapMode = TextureWrapMode.Repeat,
        filterMode = FilterMode.Trilinear, anisoLevel = 4, hideFlags = HideFlags.DontSave
    };
    s_Cached.SetPixels(px);
    s_Cached.Apply(true);
    return s_Cached;
}
  1. 每个正弦波的波矢 (kx, ky) 都是整数,在一个贴图周期内正好走完整数个波长;
  2. 值噪声的格点索引对周期取模,u=0 与 u=1 取到同一组格点;
  3. 用中心差分求导时,坐标回绕(x+1 超出边界就回到 0)。

此外还用了一个技巧:用周期噪声扰动正弦的相位(域扭曲),否则十几个正弦叠加会呈现规则的“格子布”纹理。生成的贴图是未压缩的 RGBA,R、G、B 存法线的 xyz,A 存 1。URP 的 UnpackNormalScale 在桌面平台走 UnpackNormalmapRGorAG:先 a *= r,再取 (a, g) 作为 xy——A=1 时得到的正好是 (r, g);在定义了 UNITY_NO_DXT5nm 的平台上则走 UnpackNormalRGB 直接读 RGB,同样正确。所以这种贴图不用标记为 Normal Map 也能正确解码。如果你用的是导入的法线贴图,正常勾选 Texture Type = Normal Map 即可。

图 19-7左:调试视图,单层法线(切线空间 xy 放大显示);中:调试视图,双层法线叠加,图案更细碎、更不规则;右:双层法线 + 折射 + 天空反射的效果。

案例 19.5菲涅尔与天空反射进阶单独打开 ↗

目标:水面反射天空;低头看时几乎透明,看向远处时像镜子。

水的 F0

第 9 章讲过,电介质在垂直入射时的反射率 F0 由折射率决定:

F0=(n−1n+1)2≈(0.332.33)2≈0.02

水的折射率约 1.33,F0 只有 2%:正对着看,98% 的光都透进水里,所以脚下的水清澈见底。随着视线变平,Schlick 近似 F = F0 + (1 − F0)(1 − N·V)5 迅速上升,掠射时接近 100%。这条曲线就是水面“近处透、远处亮”的全部原因。

Assets/Cookbook/Ch19/WaterCore.hlsl(节选:fresnel)
// Schlick 菲涅尔。水的折射率约 1.33,F0 = ((1.33-1)/(1.33+1))² ≈ 0.02:正看几乎全透,掠射角几乎全反
half WaterFresnel(half3 normalWS, half3 viewWS)
{
    half NdotV = saturate(dot(normalWS, viewWS));
    return 0.02h + 0.98h * pow(1.0h - NdotV, 5.0h);
}
// Schlick 菲涅尔。水的折射率约 1.33,F0 = ((1.33-1)/(1.33+1))² ≈ 0.02:正看几乎全透,掠射角几乎全反
float WaterFresnel(vec3 normalWS, vec3 viewWS)
{
    float NdotV = saturate(dot(normalWS, viewWS));
    return 0.02 + 0.98 * pow(1.0 - NdotV, 5.0);
}

天空反射:GlossyEnvironmentReflection

反射颜色先用环境反射:反射方向 reflect(-V, N) 去查天空盒或反射探针。URP 14 的 GlobalIllumination.hlsl 提供了 GlossyEnvironmentReflection,它会根据粗糙度选择 mip 级别,并处理反射探针混合与盒体投影。这里有一个 URP 14 特有的坑:

// 摘自 URP 14 ShaderLibrary/GlobalIllumination.hlsl(节选)
half3 GlossyEnvironmentReflection(half3 reflectVector, float3 positionWS, half perceptualRoughness,
                                  half occlusion, float2 normalizedScreenSpaceUV) { ... }

#if !USE_FORWARD_PLUS
half3 GlossyEnvironmentReflection(half3 reflectVector, float3 positionWS, half perceptualRoughness, half occlusion)
{ ... }
#endif

4 参数的版本被 #if !USE_FORWARD_PLUS 包住了。Forward+ 下反射探针是按屏幕分块(cluster)查找的,必须传入屏幕 UV。如果你只写了 4 参数调用,在 Forward 路径下能编译,切换到 Forward+ 就会报 “no matching 4 parameter function”。所以本书统一使用 5 参数版本。

反射与水下颜色按菲涅尔混合:color = lerp(underwater, reflection, F)。注意 alpha 也同时向 1 插值——在没有折射的简单版本里,强反射处的水面不应该再透明。

图 19-8左:没有反射;中:不用菲涅尔、恒定 50% 反射——近处的水也像蒙了一层雾,失去通透感;右:菲涅尔反射——近处清澈见底,远处逐渐倒映出天空。
提示风格化水面常常故意“作弊”:把菲涅尔的指数从 5 调小(比如 3),让反射更早出现;或者给反射乘一个大于 1 的强度。物理正确是起点,不是终点——但先有正确的版本,你才知道自己在偏离什么。

案例 19.6太阳高光入门单独打开 ↗

目标:太阳在水面上拉出一条闪烁的亮带(日光路径),被柱子挡住的地方没有高光。

天空盒里虽然也画了太阳,但它是烘焙在立方体贴图里的,位置不一定和场景里的方向光一致(本例为了拍日光路径,把方向光转到了相机对面,而天空贴图里的太阳仍在原处);环境反射还会按粗糙度取较模糊的 mip,得不到锐利的闪点。所以单独用主光算一次镜面高光。水面非常光滑,用指数很高的 Blinn-Phong,乘以第 5 章介绍过的归一化系数 (n+8)/(8π):指数增大时高光面积变小、峰值变高,总能量大致守恒,调指数时不用反复回头调强度。

Assets/Cookbook/Ch19/WaterCore.hlsl(节选:specular)
// 太阳高光:Blinn-Phong,指数很高(水面很光滑),乘上阴影,让高光不会出现在柱子的影子里
half3 SunSpecular(Light light, half3 normalWS, half3 viewWS)
{
    half3 h = SafeNormalize(light.direction + viewWS);
    half spec = pow(saturate(dot(normalWS, h)), _SpecPower);
    // 能量归一化的近似系数 (n+8)/(8π),让指数变大时高光变小但更亮(第 5 章)
    spec *= (_SpecPower + 8.0h) / (8.0h * PI);
    return light.color * spec * _SpecIntensity * light.shadowAttenuation;
}
// 太阳高光:Blinn-Phong,指数很高(水面很光滑),乘上阴影,让高光不会出现在柱子的影子里
vec3 SunSpecular(Light light, vec3 normalWS, vec3 viewWS)
{
    vec3 h = SafeNormalize(light.direction + viewWS);
    float spec = pow(saturate(dot(normalWS, h)), _SpecPower);
    // 能量归一化的近似系数 (n+8)/(8π),让指数变大时高光变小但更亮(第 5 章)
    spec *= (_SpecPower + 8.0) / (8.0 * PI);
    return light.color * spec * _SpecIntensity * light.shadowAttenuation;
}

高光乘了 light.shadowAttenuation:主光阴影在水面上同样有效(水面 Shader 里调用了 GetMainLight(TransformWorldToShadowCoord(positionWS))),柱子的影子里不会有阳光的反射。透明物体虽然不投射阴影,但完全可以接收阴影。

图 19-9太阳位于相机对面的低空。左:没有高光;中:指数 120,高光连成一大片,像一层油膜;右:指数 1200,碎成一个个闪点,才是“波光粼粼”。

高光的形状完全由法线决定。法线贴图的尺度、两层的强度、Gerstner 波的陡度都会改变日光路径的宽窄和碎裂程度。HDR 下高光的峰值远大于 1,开启 Bloom 后会有漂亮的光晕;如果画面里一片死白,先降低 _SpecIntensity 再看 Tonemapping 设置。

案例 19.7岸边泡沫进阶单独打开 ↗

目标:岸线、礁石和柱子周围出现白色泡沫:贴岸的一条不规则实心带,外面还有一圈圈向岸边推进的细泡沫线。

原理:竖直水深就是“离岸距离”

岸边泡沫出现在水浅的地方。竖直水深在岸线处为 0,向外逐渐增大,所以 shore = saturate(水深 / 泡沫带宽度) 就是一个“离岸距离”:岸线 0,泡沫带外沿 1。在它上面做两件事:

  1. 实心带:shore 小于某个阈值的地方全是泡沫。阈值被噪声上下扰动,带子的外沿就呈现不规则的锯齿。
  2. 推进线:frac(shore × 2.5 − t × 0.3) 是一组“等深线”,随时间减小,等深线就一圈圈向岸边移动;取其中很窄的一段作为细线,再用噪声把线打断成一截一截。这正是海浪拍岸时泡沫线的运动方式。
Assets/Cookbook/Ch19/WaterCore.hlsl(节选:foam)
// 岸边泡沫 = 贴岸的一条实心带 + 一圈圈向岸边推进的细线,都用噪声打散
half ShoreFoam(float2 xz, float verticalDepth, half3 normalTS, float t)
{
    half shore = saturate(verticalDepth / _FoamDistance);          // 0 在岸线,1 在泡沫带外沿
    float2 uv = xz * _FoamTiling + normalTS.xy * 0.1 + float2(t * 0.02, t * 0.015);
    half n = SAMPLE_TEXTURE2D(_FoamNoise, sampler_FoamNoise, uv).r;

    // ① 实心带:阈值被噪声上下扰动,边缘呈不规则的锯齿
    half core = 1.0h - smoothstep(0.25h - _FoamSoftness, 0.25h + _FoamSoftness, shore + (n - 0.5h) * 0.35h);
    // ② 推进线:shore 是“离岸距离”,减去时间就得到一圈圈朝岸边移动的等深线
    half ripple = frac(shore * 2.5h - t * 0.3h + n * 0.6h);
    half lines = smoothstep(0.8h, 0.9h, ripple) * step(0.45h, n) * (1.0h - shore);
    return saturate(core + lines);
}
// 岸边泡沫 = 贴岸的一条实心带 + 一圈圈向岸边推进的细线,都用噪声打散
float ShoreFoam(vec2 xz, float verticalDepth, vec3 normalTS, float t)
{
    float shore = saturate(verticalDepth / _FoamDistance);          // 0 在岸线,1 在泡沫带外沿
    vec2 uv = xz * _FoamTiling + normalTS.xy * 0.1 + vec2(t * 0.02, t * 0.015);
    float n = texture(_FoamNoise, uv).r;

    // ① 实心带:阈值被噪声上下扰动,边缘呈不规则的锯齿
    float core = 1.0 - smoothstep(0.25 - _FoamSoftness, 0.25 + _FoamSoftness, shore + (n - 0.5) * 0.35);
    // ② 推进线:shore 是“离岸距离”,减去时间就得到一圈圈朝岸边移动的等深线
    float ripple = fract(shore * 2.5 - t * 0.3 + n * 0.6);
    float lines = smoothstep(0.8, 0.9, ripple) * step(0.45, n) * (1.0 - shore);
    return saturate(core + lines);
}

噪声采样坐标也加了一点法线扰动,泡沫会跟着水面波纹轻微晃动;泡沫颜色乘了 waterLight,在阴影里会变暗,不会像贴纸一样浮在画面上。

图 19-10左:没有泡沫;中:调试视图,泡沫遮罩——岸线的实心带、外圈的推进线,以及顶部接近水面的暗礁和柱子周围的泡沫;右:最终效果。

深度泡沫的局限

注意中图柱子周围的泡沫:只有柱子朝向相机的一侧有泡沫带。原因是泡沫靠“这个像素看下去,水底有多深”来判断:柱子前方的水面像素,看下去是柱子的水下部分,离水面很近,所以是浅水;柱子后方的水面像素被柱子本身挡住了,根本没有被画出来;而柱子两侧的水面像素,看下去是远处的水底,是深水。这是所有基于屏幕深度的泡沫(以及护盾交接高亮)的共同局限:它测的是“视线方向上的距离”,而不是“到物体表面的真实距离”。

要做到四周均匀的泡沫,需要真正的距离信息:例如离线烘焙一张水面的“到岸/到障碍物的距离场”贴图,或者用 SDF。动态物体(船、角色)周围的泡沫,常见做法是由物体自己在一张俯视的 RT 上绘制“泡沫源”,水面再采样这张 RT——这和第 20 章草地交互的思路一样。

案例 19.8焦散:程序化 Voronoi 投射到水下高阶单独打开 ↗

目标:水底出现游动的亮纹网;只出现在水下、只出现在朝上的面上、深处逐渐消失、柱子的阴影里没有;亮纹边缘带一点彩虹色的色散。

焦散的图案

真实焦散是起伏的水面把光线会聚到水底形成的亮线网。精确计算需要追踪大量光线,实时渲染里通常用一张循环的焦散贴图或程序化图案来“画”出来。Voronoi(Worley)噪声的 F2 − F1(到第二近特征点的距离减去到最近特征点的距离)在细胞边界处为 0、细胞内部较大,取反后就是一张亮线网,形态和焦散非常接近。让每个特征点绕自己的中心转圈,亮线网就“活”了起来;两层不同尺度的网叠加,交叉处更亮,形成焦散特有的亮节点。

Assets/Cookbook/Ch19/WaterCore.hlsl(节选:caustics)
// 程序化焦散:Voronoi 的 F2-F1 在细胞边界处接近 0,取反后就是一张亮线网,很像焦散的光网
float2 VoronoiF1F2(float2 p, float t)
{
    float2 cell = floor(p), f = frac(p);
    float F1 = 8.0, F2 = 8.0;
    [unroll] for (int j = -1; j <= 1; j++)
    [unroll] for (int i = -1; i <= 1; i++)
    {
        float2 g = float2(i, j);
        float2 o = Hash22(cell + g);
        o = 0.5 + 0.45 * sin(t + 6.2831 * o);      // 特征点绕各自中心转圈,图案就“活”了
        float2 r = g + o - f;
        float d = dot(r, r);
        if (d < F1) { F2 = F1; F1 = d; }
        else if (d < F2) { F2 = d; }
    }
    return sqrt(float2(F1, F2));
}

half CausticPattern(float2 p, float t)
{
    float2 v1 = VoronoiF1F2(p, t);
    float2 v2 = VoronoiF1F2(p * 1.43 + 7.1, t * 1.21);
    half c1 = 1.0h - smoothstep(0.0h, 0.18h, v1.y - v1.x);
    half c2 = 1.0h - smoothstep(0.0h, 0.18h, v2.y - v2.x);
    // 两层叠加:交叉处更亮,形成焦散特有的“亮节点”
    return c1 * c1 + c2 * c2 + 2.0h * c1 * c2;
}

// 把焦散投到水下表面:沿主光方向把水底点“投影回”水面,在那里查图案,图案就会随太阳方向偏移
half3 Caustics(float3 sceneWS, float verticalDepth, half upFacing, Light light, float t)
{
    float3 L = light.direction;
    float2 p = sceneWS.xz + L.xz / max(L.y, 0.2) * verticalDepth;
    p *= _CausticScale;
    // RGB 三个通道错开一点点采样,模拟不同波长折射角不同产生的色散
    float2 split = float2(_CausticSplit, _CausticSplit * 0.5);
    half3 c = half3(CausticPattern(p + split, t), CausticPattern(p, t), CausticPattern(p - split, t));
    // 刚入水时焦散还没聚焦,太深又被吸收:用两个衰减把它限制在“中间深度”
    half fade = saturate(verticalDepth * 4.0) * exp(-verticalDepth / _CausticDepthFade);
    // 只有朝上的面才接到焦散(upFacing 由调用方用屏幕导数求出,见 SceneUpFacing)
    fade *= upFacing;
    // 阴影要在水底那一点上查:柱子的影子里不该有焦散
    half shadow = MainLightRealtimeShadow(TransformWorldToShadowCoord(sceneWS));
    return c * fade * _CausticIntensity * light.color * shadow;
}
// 程序化焦散:Voronoi 的 F2-F1 在细胞边界处接近 0,取反后就是一张亮线网,很像焦散的光网
vec2 VoronoiF1F2(vec2 p, float t)
{
    vec2 cell = floor(p), f = fract(p);
    float F1 = 8.0, F2 = 8.0;
    for (int j = -1; j <= 1; j++)
    for (int i = -1; i <= 1; i++)
    {
        vec2 g = vec2(i, j);
        vec2 o = Hash22(cell + g);
        o = 0.5 + 0.45 * sin(t + 6.2831 * o);      // 特征点绕各自中心转圈,图案就“活”了
        vec2 r = g + o - f;
        float d = dot(r, r);
        if (d < F1) { F2 = F1; F1 = d; }
        else if (d < F2) { F2 = d; }
    }
    return sqrt(vec2(F1, F2));
}

float CausticPattern(vec2 p, float t)
{
    vec2 v1 = VoronoiF1F2(p, t);
    vec2 v2 = VoronoiF1F2(p * 1.43 + 7.1, t * 1.21);
    float c1 = 1.0 - smoothstep(0.0, 0.18, v1.y - v1.x);
    float c2 = 1.0 - smoothstep(0.0, 0.18, v2.y - v2.x);
    // 两层叠加:交叉处更亮,形成焦散特有的“亮节点”
    return c1 * c1 + c2 * c2 + 2.0 * c1 * c2;
}

// 把焦散投到水下表面:沿主光方向把水底点“投影回”水面,在那里查图案,图案就会随太阳方向偏移
vec3 Caustics(vec3 sceneWS, float verticalDepth, float upFacing, Light light, float t)
{
    vec3 L = light.direction;
    vec2 p = sceneWS.xz + L.xz / max(L.y, 0.2) * verticalDepth;
    p *= _CausticScale;
    // RGB 三个通道错开一点点采样,模拟不同波长折射角不同产生的色散
    vec2 split = vec2(_CausticSplit, _CausticSplit * 0.5);
    vec3 c = vec3(CausticPattern(p + split, t), CausticPattern(p, t), CausticPattern(p - split, t));
    // 刚入水时焦散还没聚焦,太深又被吸收:用两个衰减把它限制在“中间深度”
    float fade = saturate(verticalDepth * 4.0) * exp(-verticalDepth / _CausticDepthFade);
    // 只有朝上的面才接到焦散(upFacing 由调用方用屏幕导数求出,见 SceneUpFacing)
    fade *= upFacing;
    // 阴影要在水底那一点上查:柱子的影子里不该有焦散
    float shadow = MainLightRealtimeShadow(TransformWorldToShadowCoord(sceneWS));
    return c * fade * _CausticIntensity * light.color * shadow;
}

投射到水下:在哪里查图案

焦散画在水底,而不是水面上。我们在水面 Shader 里已经重建了水底的世界坐标 sceneWS(案例 19.2),所以只要用它来查焦散图案,再把结果加到折射得到的水下颜色上——焦散自然只出现在“透过水面看到的地方”,岸上的沙滩不会有。

更进一步:光是从太阳方向斜射下来的。水底一点 P 接收到的光,是从水面上 P 沿光线方向往回追溯到的那一点进来的。所以查图案的位置应该是 P 沿光方向投影回水面的位置:

pxz=Pxz+LxzLy·depth

这样太阳斜照时,越深的地方图案偏移越多;石头侧面与顶面的图案也会错开,立体感更好。

水面 y=0 水底 光方向 L P(sceneWS) 在这里查焦散图案 depth L.xz / L.y × depth
图 19-11把水底点沿光方向投影回水面,在投影点查焦散图案。

三个衰减

  • 深度衰减:刚入水的地方光还没被会聚,焦散很弱;太深的地方光被吸收殆尽。用 saturate(depth × 4) × exp(−depth / fade) 把它限制在“中间深度”。
  • 朝向衰减:焦散只照在朝上的面上。我们不知道水底的法线(没有法线图),但可以用重建坐标的屏幕导数求:ddx(sceneWS)、ddy(sceneWS) 是相邻像素的世界坐标差,它们的叉积就是那个表面的法线。注意要用未经折射偏移的坐标求导:折射后每个像素的偏移量都不同,导数会变成噪声。
  • 阴影:在水底点 P 上重新查一次主光阴影(MainLightRealtimeShadow(TransformWorldToShadowCoord(P))),柱子影子里就没有焦散了。注意这是在水底点查阴影,不是在水面点。
Assets/Cookbook/Ch19/WaterCore.hlsl(节选:upfacing)
// 用重建坐标的屏幕导数求水下表面的法线:ddx/ddy 是相邻像素的世界坐标差,叉积就是面法线。
// 屏幕 y 方向在不同平台可能相反,叉积符号会翻转,所以取绝对值。要用“未折射”的坐标:
// 折射后的 UV 每个像素扰动不同,求出的导数全是噪声
half SceneUpFacing(float3 sceneWS)
{
    float3 n = normalize(cross(ddy(sceneWS), ddx(sceneWS)));
    return saturate(abs(n.y) * 1.5 - 0.3);
}
#ifdef FRAGMENT   // dFdx/dFdy 只能在片元着色器里用
// 用重建坐标的屏幕导数求水下表面的法线:dFdx/dFdy 是相邻像素的世界坐标差,叉积就是面法线。
// 屏幕 y 方向在不同平台可能相反,叉积符号会翻转,所以取绝对值。要用“未折射”的坐标:
// 折射后的 UV 每个像素扰动不同,求出的导数全是噪声
float SceneUpFacing(vec3 sceneWS)
{
    vec3 n = normalize(cross(dFdy(sceneWS), dFdx(sceneWS)));
    return saturate(abs(n.y) * 1.5 - 0.3);
}
#endif

最后,R、G、B 三个通道在略微错开的位置查图案,亮线边缘出现彩色镶边,模拟不同波长折射角不同造成的色散——这和第 18 章全息色差是同一个技巧。

图 19-12左:调试视图,直接用水底坐标查到的 Voronoi 焦散图案(未做任何衰减,柱子侧面也被画上了条纹);中:没有焦散;右:加入焦散——亮纹只出现在水下朝上的表面,暗礁顶部最强,柱子的阴影里没有焦散。
深入程序化 Voronoi 每个像素要遍历 3×3 个格子,两层、三个通道一共 54 次哈希与距离计算,在移动端偏贵。常见的替代方案是预先生成一张可循环的焦散贴图(甚至可以用本案例的函数在编辑器里烘焙出来),运行时采样两次、错开流动。另一种架构是把焦散做成贴花或灯光 Cookie 投射到水底物体上,好处是不依赖水面像素,从水下看也有焦散。

案例 19.9Gerstner 波进阶单独打开 ↗

目标:让水面真正起伏,有波峰波谷,并且波峰比波谷尖——这是 Gerstner 波和简单正弦波最明显的区别。

第 17 章已经推导过 Gerstner 波:水面上的每个点不只是上下运动,还会沿传播方向前后运动,做圆周运动。一个波由方向 d、陡度 Q、波长 L 决定:

k=2πL,c=gk,a=Qk,f=k(d·pxz−ct)
Δp=(dxacosf,asinf,dzacosf)

相速度 c 来自深水波的色散关系 ω² = gk:长波跑得快、短波跑得慢,多个波叠加时自然会“错开”,不会整齐地同步。Q = 1 时波峰恰好变成尖角,再大就会自交打结,所以多个波叠加时它们的陡度之和最好不超过 1。

移植到水面 Shader 时要注意两件事。一是法线必须解析地求:位移函数对 x、z 求偏导得到切线 T 和副切线 B,法线 = B × T。函数里用 inout 把每个波的偏导累加进去,三个波叠加完一次性得到正确的切线空间。二是切线空间要同步给法线贴图:波面倾斜后,细节法线也要跟着倾斜,所以顶点着色器把 T、B、N 一起传给片元,片元用它们构造 TBN 矩阵(案例 19.4)。

Assets/Cookbook/Ch19/WaterCore.hlsl(节选:gerstner)
// 单个 Gerstner 波。wave = (方向x, 方向z, 陡度 Q, 波长 L)。累加位移,同时累加切线/副切线用于求法线。
// 推导见第 17 章:k = 2π/L,相速度 c = sqrt(g/k),振幅 a = Q/k(Q=1 时波峰刚好变尖)
float3 GerstnerWave(float4 wave, float3 p, float t, inout float3 tangent, inout float3 binormal)
{
    float steep = wave.z;
    float k = 2.0 * PI / wave.w;
    float c = sqrt(9.8 / k);
    float2 d = normalize(wave.xy);
    float f = k * (dot(d, p.xz) - c * t);
    float a = steep / k;
    float s = sin(f), co = cos(f);

    tangent  += float3(-d.x * d.x * steep * s, d.x * steep * co, -d.x * d.y * steep * s);
    binormal += float3(-d.x * d.y * steep * s, d.y * steep * co, -d.y * d.y * steep * s);
    return float3(d.x * a * co, a * s, d.y * a * co);
}
// 单个 Gerstner 波。wave = (方向x, 方向z, 陡度 Q, 波长 L)。累加位移,同时累加切线/副切线用于求法线。
// 推导见第 17 章:k = 2π/L,相速度 c = sqrt(g/k),振幅 a = Q/k(Q=1 时波峰刚好变尖)
vec3 GerstnerWave(vec4 wave, vec3 p, float t, inout vec3 tangent, inout vec3 binormal)
{
    float steep = wave.z;
    float k = 2.0 * PI / wave.w;
    float c = sqrt(9.8 / k);
    vec2 d = normalize(wave.xy);
    float f = k * (dot(d, p.xz) - c * t);
    float a = steep / k;
    float s = sin(f), co = cos(f);

    tangent  += vec3(-d.x * d.x * steep * s, d.x * steep * co, -d.x * d.y * steep * s);
    binormal += vec3(-d.x * d.y * steep * s, d.y * steep * co, -d.y * d.y * steep * s);
    return vec3(d.x * a * co, a * s, d.y * a * co);
}
Assets/Cookbook/Ch19/Water.shader(节选:vert)
Varyings vert(Attributes v)
{
    Varyings o;
    float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
    // 平静水面:切线 +X、副切线 +Z、法线 +Y
    float3 tangent = float3(1, 0, 0);
    float3 binormal = float3(0, 0, 1);
#if defined(_GERSTNER)
    float t = WaterTime();
    float3 p = positionWS;
    positionWS += GerstnerWave(_WaveA, p, t, tangent, binormal);
    positionWS += GerstnerWave(_WaveB, p, t, tangent, binormal);
    positionWS += GerstnerWave(_WaveC, p, t, tangent, binormal);
#endif
    o.positionWS = positionWS;
    o.positionCS = TransformWorldToHClip(positionWS);
    o.tangentWS = normalize(tangent);
    o.bitangentWS = normalize(binormal);
    o.normalWS = normalize(cross(binormal, tangent));
    o.fogFactor = ComputeFogFactor(o.positionCS.z);
    return o;
}
void main()
{
    vec3 pWS = TransformObjectToWorld(positionOS.xyz);
    // 平静水面:切线 +X、副切线 +Z、法线 +Y
    vec3 tangent = vec3(1, 0, 0);
    vec3 binormal = vec3(0, 0, 1);
#if defined(_GERSTNER)
    float t = WaterTime();
    vec3 p = pWS;
    pWS += GerstnerWave(_WaveA, p, t, tangent, binormal);
    pWS += GerstnerWave(_WaveB, p, t, tangent, binormal);
    pWS += GerstnerWave(_WaveC, p, t, tangent, binormal);
#endif
    positionWS = pWS;
    gl_Position = TransformWorldToHClip(pWS);
    tangentWS = normalize(tangent);
    bitangentWS = normalize(binormal);
    normalWS = normalize(cross(binormal, tangent));
    fogFactor = ComputeFogFactor(gl_Position.z);
}

还有一个容易忽略的影响:顶点被 Gerstner 波抬高或压低之后,片元里的 positionWS 是位移后的位置,所以水深、泡沫、焦散都会随波起伏自动变化——波谷处水变浅,泡沫带向外扩;波峰处水变深。这种联动是“免费”的,因为后续计算全部基于实际的水面位置。

图 19-13低机位观察。左:平静水面;中:单个 Gerstner 波(波长 6 米、陡度 0.28),波峰尖、波谷宽;右:三个方向不同的波叠加,形成不规则的起伏,岸边泡沫随水位线一起移动。

网格密度与常见坑

  • 网格要够密:顶点位移只能表现比网格间距大得多的波。本例 60 米的水面用了 400×400 的网格(每格 15 厘米)。大面积水面通常用以相机为中心的同心网格或曲面细分来分配顶点。
  • 包围盒:顶点被抬高后可能超出网格原本的包围盒,相机边缘的水面被视锥剔除。本书的 Meshes.Grid 已经把包围盒的高度设得和宽度一样;自己建网格时记得扩大 mesh.bounds。
  • 岸边:波浪会把水面抬到沙滩上方,或者在浅水处露出水底。小水池里浪不宜太大;真正的海岸线需要按水深衰减波高(用一张水深图或离岸距离图驱动振幅)。
  • 物理交互:浮在水上的物体需要知道某一点的水面高度。Gerstner 波是解析函数,可以在 C# 里用同样的公式计算——注意它是水平位移过的,查询点 (x, z) 处的高度需要迭代求解,或者近似地忽略水平位移。

案例 19.10平面反射:C# 反射相机 + RenderTexture高阶单独打开 ↗

目标:水面倒映出岸上的柱子、石头和木栈道,而不只是天空。

为什么天空反射不够

案例 19.5 的反射来自天空盒(或反射探针),它只知道“某个方向上很远的地方是什么颜色”,不知道水面附近有一根柱子。反射探针可以捕捉场景,但它是从探针位置看出去的立方体贴图,对于一个大平面上近处物体的倒影,位置会严重错位。对于水面、镜面地板这类大平面,最准确的实时做法是平面反射:再架一台相机,从水面下方“镜像”的位置往上看,把画面渲染到一张 RT 里,水面采样它。

镜像矩阵与斜裁剪

平面用 (n, d) 表示,平面上的点满足 n·p + d = 0。点 p 关于平面的镜像是

p′=p−2(n·p+d)n

写成 4×4 矩阵 R,反射相机的视图矩阵就是主相机视图矩阵乘以 R:worldToCameraMatrix = cam.worldToCameraMatrix * R。它的含义是“先把世界镜像一次,再用主相机去看”。这样渲染出来的画面里,倒影点恰好出现在主相机看到的对应水面像素位置上——所以水面直接用自己的屏幕 UV 采样反射 RT 即可,不需要任何额外的坐标变换。

还有两个必须处理的问题:

  • 水面以下的东西不能进反射:反射相机在水面下方,它会看到水底、水中的柱子下半截。用 Camera.CalculateObliqueMatrix 构造“斜裁剪投影”:把投影矩阵的近裁剪面替换成水面本身,水面以下的几何直接被裁掉。传入的平面必须是观察空间中的平面,所以先用反射相机的视图矩阵把它变换过去。
  • 三角形绕序反了:镜像矩阵的行列式为 −1,它把左手系变成右手系,所有三角形的顺时针/逆时针颠倒,正常的背面剔除会把正面剔掉。渲染反射前设置 GL.invertCulling = true,渲染后恢复。
Assets/Cookbook/Ch19/PlanarReflection.cs(节选:mirror)
void SetupReflectionCamera(Camera cam)
{
    m_ReflCam.CopyFrom(cam);                 // 视场角、远近裁剪面、清屏方式等与主相机一致
    m_ReflCam.enabled = false;
    m_ReflCam.targetTexture = null;          // 由渲染请求临时指定
    m_ReflCam.cullingMask = reflectLayers;
    m_ReflCam.cameraType = CameraType.Reflection;
    m_ReflCam.useOcclusionCulling = false;

    Vector3 normal = transform.up;
    Vector3 pos = transform.position;
    Vector4 plane = new Vector4(normal.x, normal.y, normal.z, -Vector3.Dot(normal, pos));
    Matrix4x4 reflection = ReflectionMatrix(plane);

    // 相机位置也镜像过去:_WorldSpaceCameraPos、视线方向等都要基于镜像后的位置
    m_ReflCam.transform.position = reflection.MultiplyPoint(cam.transform.position);
    m_ReflCam.transform.rotation = Quaternion.LookRotation(
        Vector3.Reflect(cam.transform.forward, normal), Vector3.Reflect(cam.transform.up, normal));
    m_ReflCam.worldToCameraMatrix = cam.worldToCameraMatrix * reflection;

    // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射
    Vector4 clipPlane = CameraSpacePlane(m_ReflCam.worldToCameraMatrix,
                                         pos - normal * clipPlaneOffset, normal);
    m_ReflCam.projectionMatrix = cam.CalculateObliqueMatrix(clipPlane);
}

// 关于平面 (n, d) 的镜像矩阵:p' = p - 2 (n·p + d) n
static Matrix4x4 ReflectionMatrix(Vector4 p)
{
    var m = Matrix4x4.identity;
    m.m00 = 1 - 2 * p.x * p.x;  m.m01 = -2 * p.x * p.y;
    m.m02 = -2 * p.x * p.z;     m.m03 = -2 * p.w * p.x;
    m.m10 = -2 * p.y * p.x;     m.m11 = 1 - 2 * p.y * p.y;
    m.m12 = -2 * p.y * p.z;     m.m13 = -2 * p.w * p.y;
    m.m20 = -2 * p.z * p.x;     m.m21 = -2 * p.z * p.y;
    m.m22 = 1 - 2 * p.z * p.z;  m.m23 = -2 * p.w * p.z;
    return m;   // 第四行保持 (0, 0, 0, 1)
}

// 把世界空间的平面变换到观察空间(CalculateObliqueMatrix 需要观察空间平面)
static Vector4 CameraSpacePlane(Matrix4x4 worldToCamera, Vector3 pos, Vector3 normal)
{
    Vector3 cpos = worldToCamera.MultiplyPoint(pos);
    Vector3 cnormal = worldToCamera.MultiplyVector(normal).normalized;
    return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal));
}
setupReflectionCamera(cam) {
  // 平面:法线 = 水面物体的 up,经过物体原点
  const m = this.obj.matrix;
  const normal = vec3.normalize([m[4], m[5], m[6]]);
  const pos = [m[12], m[13], m[14]];
  const plane = [normal[0], normal[1], normal[2], -vec3.dot(normal, pos)];
  const reflection = reflectionMatrix(plane);

  // 视图矩阵 = 主相机视图 × 镜像:相机位置、_WorldSpaceCameraPos 都随之镜像
  const view = mat4.mul(cam.view, reflection);

  // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射
  const clipPlane = cameraSpacePlane(view, vec3.sub(pos, vec3.scale(normal, this.clipPlaneOffset)), normal);
  const proj = calculateObliqueMatrix(cam.projection(), clipPlane);

  return {
    json: cam.json, view, projection: () => proj,
    fov: cam.fov, aspect: cam.aspect, near: cam.near, far: cam.far,
    orthographic: cam.orthographic, orthoSize: cam.orthoSize,
    clearFlags: cam.clearFlags, background: cam.background,
    cullingMask: this.reflectLayers,
    allowHDR: cam.allowHDR, allowMSAA: false,
    renderShadows: this.renderShadows, postProcessing: false, antialiasing: 0,
  };
}

URP 14 里怎么让一台相机渲染一次

这是平面反射在 URP 里最容易写错的地方。网上流传最广的写法来自 URP 早期版本:订阅 RenderPipelineManager.beginCameraRendering,在回调里调用 UniversalRenderPipeline.RenderSingleCamera(context, reflectionCamera)。在 URP 14.0.12 里,这个函数已经被标记为过时:

// 摘自 URP 14 Runtime/UniversalRenderPipeline.cs
[Obsolete("RenderSingleCamera is obsolete, please use RenderPipeline.SubmitRenderRequest with UniversalRenderer.SingleCameraRequest as RequestData type", false)]
public static void RenderSingleCamera(ScriptableRenderContext context, Camera camera)

提示里说的新方式是 RenderPipeline.SubmitRenderRequest(camera, request),请求类型是嵌套在 UniversalRenderPipeline 里的 SingleCameraRequest(提示文字里写的是 UniversalRenderer.SingleCameraRequest,但在 14.0.12 源码中这个类实际定义在 UniversalRenderPipeline 内部,以源码为准)。读 ProcessRenderRequests 的实现可以看到,它临时把相机的 targetTexture 换成请求里的 destination,依次调用 BeginContextRendering、BeginCameraRendering、渲染、EndCameraRendering、EndContextRendering,最后把 targetTexture 恢复原样。

那么,能不能把新 API 直接放进 beginCameraRendering 回调里,替换掉旧的 RenderSingleCamera?不能。拍摄本章配图时实测,在回调里调用 SubmitRenderRequest,Unity 会报错:

Recursive rendering is not supported in SRP (are you calling Camera.Render from within a render pipeline?).

原因是 beginCameraRendering 发生在 SRP 渲染循环内部,而 SubmitRenderRequest 本身要启动一次完整的渲染循环——这是一次递归渲染。旧的 RenderSingleCamera 能在回调里用,是因为它直接把命令录进主相机正在使用的 context,并不开启新的循环;但它已过时;本书在 URP 14 下试验这种写法时,程序能运行,但主相机水面的折射与焦散全部失效、反射画面也不正确(推测是两台相机共用同一个 context 时,相机相关的全局纹理与状态互相覆盖),因此不建议继续使用。

正确的做法是在渲染循环之外提交请求:运行时在 LateUpdate 里为主相机渲染一次反射(LateUpdate 在所有相机渲染之前执行,此时主相机的位置已经被 Update 里的逻辑更新完毕);编辑器拍图时,在主相机 Render() 之前手动调用一次。

Assets/Cookbook/Ch19/PlanarReflection.cs(节选:render)
/// 在渲染循环之外调用(LateUpdate、或者主相机 Render 之前)。
/// 不要在 beginCameraRendering 回调里调用它:回调发生在 SRP 渲染循环内部,再提交请求会报
/// “Recursive rendering is not supported in SRP”(本书拍摄时实测)。
public void RenderFor(Camera cam)
{
    if (m_Rendering) return;
    m_Rendering = true;
    EnsureResources(cam);
    SetupReflectionCamera(cam);

    var request = new UniversalRenderPipeline.SingleCameraRequest { destination = m_RT };
    if (RenderPipeline.SupportsRenderRequest(m_ReflCam, request))
    {
        // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除,否则只看得到物体的背面
        GL.invertCulling = true;
        RenderPipeline.SubmitRenderRequest(m_ReflCam, request);
        GL.invertCulling = false;
    }
    Shader.SetGlobalTexture(s_TexId, m_RT);
    m_Rendering = false;
}
renderFor(cam, time, dt) {
  if (this.rendering) return;
  this.rendering = true;
  const E = this.engine, gl = E.gl;
  const W = E.canvas.width, H = E.canvas.height;
  const w = Math.max(1, (W * this.resolutionScale) | 0), h = Math.max(1, (H * this.resolutionScale) | 0);
  // 反射 RT:主相机允许 HDR 就用浮点格式(C# 版的 DefaultHDR),自带深度,不开 MSAA
  const rt = E.rt('planarReflection', w, h, { color: cam.allowHDR ? 'RGBA16F' : 'SRGB8_ALPHA8', depth: 'DEPTH24_STENCIL8', samples: 1 });

  const reflCam = this.setupReflectionCamera(cam);
  // 用一个“只换了相机”的场景视图建 Frame:灯光、材质、物体都和主相机共用
  const view = Object.create(this.scene);
  view.camera = reflCam;
  const prevVP = E.prevViewProj;
  const frame = new Frame(E, view, w, h, time, dt);
  frame.requireDepth = false;    // requiresDepthOption = Off
  frame.requireOpaque = false;   // requiresColorOption = Off
  frame.setupCamera();
  frame.setupLights();
  frame.setupEnvironment();
  frame.renderShadows();         // reflCam.renderShadows = false 时只是把阴影关掉

  // 清屏(和主相机的清屏方式一致)
  frame.cameraTarget = rt;
  E.bindTarget(rt);
  gl.depthMask(true); gl.colorMask(true, true, true, true); gl.stencilMask(255);
  const bg = reflCam.clearFlags === 'SolidColor' ? reflCam.background : [0, 0, 0, 0];
  gl.clearColor(bg[0], bg[1], bg[2], bg[3]); gl.clearDepth(1); gl.clearStencil(0);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);

  // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除(C# 版的 GL.invertCulling = true),
  // 否则只看得到物体的背面。引擎按物体的 flip 标志决定正面绕序,这里把它们临时取反
  const rs = this.scene.renderers;
  rs.forEach((o) => (o.flip = !o.flip));
  try {
    frame.renderOpaques();
    frame.renderSkybox();
    frame.renderTransparents();
  } finally {
    rs.forEach((o) => (o.flip = !o.flip));
  }

  E.prevViewProj = prevVP;
  E.setGlobal('_PlanarReflectionTex', rt.texture);
  this.rendering = false;
}

完整的组件:

Assets/Cookbook/Ch19/PlanarReflection.cs
// 案例 19.9:平面反射。用一台“镜像相机”从水面下方往上看,把画面渲染进 RenderTexture,
// 再设成全局纹理 _PlanarReflectionTex,水面 Shader 用屏幕 UV 采样它。
// URP 14 推荐的渲染方式:RenderPipeline.SubmitRenderRequest + UniversalRenderPipeline.SingleCameraRequest
// (UniversalRenderPipeline.RenderSingleCamera 在 14 里已标记为 Obsolete)。
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

namespace Cookbook.Ch19
{
    [ExecuteAlways]
    public class PlanarReflection : MonoBehaviour
    {
        public enum UpdateMode
        {
            /// 在 LateUpdate 里为 Camera.main 提交渲染请求(SubmitRenderRequest,推荐)
            LateUpdate,
            /// 不自动渲染,由外部调用 RenderFor(camera)
            Manual,
        }

        public UpdateMode updateMode = UpdateMode.LateUpdate;
        [Tooltip("反射相机渲染哪些层。水面自己必须排除在外,否则会把自己画进反射里")]
        public LayerMask reflectLayers = ~(1 << 4);          // 4 = 内置的 Water 层
        [Range(0.25f, 1f)] public float resolutionScale = 0.5f;
        [Tooltip("裁剪平面相对水面的偏移,略低于水面可以避免岸边出现缝隙")]
        public float clipPlaneOffset = 0.05f;
        public bool renderShadows = false;

        static readonly int s_TexId = Shader.PropertyToID("_PlanarReflectionTex");
        Camera m_ReflCam;
        RenderTexture m_RT;
        bool m_Rendering;           // 防止重入

        void OnDisable()
        {
            if (m_ReflCam != null) DestroyImmediate(m_ReflCam.gameObject);
            if (m_RT != null) { m_RT.Release(); DestroyImmediate(m_RT); }
            m_ReflCam = null; m_RT = null;
        }

        void LateUpdate()
        {
            if (updateMode == UpdateMode.LateUpdate && Camera.main != null) RenderFor(Camera.main);
        }

        /// 在渲染循环之外调用(LateUpdate、或者主相机 Render 之前)。
        /// 不要在 beginCameraRendering 回调里调用它:回调发生在 SRP 渲染循环内部,再提交请求会报
        /// “Recursive rendering is not supported in SRP”(本书拍摄时实测)。
        public void RenderFor(Camera cam)
        {
            if (m_Rendering) return;
            m_Rendering = true;
            EnsureResources(cam);
            SetupReflectionCamera(cam);

            var request = new UniversalRenderPipeline.SingleCameraRequest { destination = m_RT };
            if (RenderPipeline.SupportsRenderRequest(m_ReflCam, request))
            {
                // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除,否则只看得到物体的背面
                GL.invertCulling = true;
                RenderPipeline.SubmitRenderRequest(m_ReflCam, request);
                GL.invertCulling = false;
            }
            Shader.SetGlobalTexture(s_TexId, m_RT);
            m_Rendering = false;
        }

        void EnsureResources(Camera cam)
        {
            int w = Mathf.Max(1, (int)(cam.pixelWidth * resolutionScale));
            int h = Mathf.Max(1, (int)(cam.pixelHeight * resolutionScale));
            if (m_RT == null || m_RT.width != w || m_RT.height != h)
            {
                if (m_RT != null) { m_RT.Release(); DestroyImmediate(m_RT); }
                var fmt = cam.allowHDR ? RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default;
                m_RT = new RenderTexture(w, h, 24, fmt) { name = "_PlanarReflectionTex", hideFlags = HideFlags.DontSave };
                m_RT.Create();
            }
            if (m_ReflCam == null)
            {
                var go = new GameObject("Planar Reflection Camera") { hideFlags = HideFlags.HideAndDontSave };
                m_ReflCam = go.AddComponent<Camera>();
                m_ReflCam.enabled = false;           // 不参与正常的相机循环,只在我们提交请求时渲染
                var data = go.AddComponent<UniversalAdditionalCameraData>();
                data.renderPostProcessing = false;   // 后处理留给主相机统一做
                data.requiresColorOption = CameraOverrideOption.Off;
                data.requiresDepthOption = CameraOverrideOption.Off;
                data.SetRenderer(0);
            }
            m_ReflCam.GetComponent<UniversalAdditionalCameraData>().renderShadows = renderShadows;
        }

        void SetupReflectionCamera(Camera cam)
        {
            m_ReflCam.CopyFrom(cam);                 // 视场角、远近裁剪面、清屏方式等与主相机一致
            m_ReflCam.enabled = false;
            m_ReflCam.targetTexture = null;          // 由渲染请求临时指定
            m_ReflCam.cullingMask = reflectLayers;
            m_ReflCam.cameraType = CameraType.Reflection;
            m_ReflCam.useOcclusionCulling = false;

            Vector3 normal = transform.up;
            Vector3 pos = transform.position;
            Vector4 plane = new Vector4(normal.x, normal.y, normal.z, -Vector3.Dot(normal, pos));
            Matrix4x4 reflection = ReflectionMatrix(plane);

            // 相机位置也镜像过去:_WorldSpaceCameraPos、视线方向等都要基于镜像后的位置
            m_ReflCam.transform.position = reflection.MultiplyPoint(cam.transform.position);
            m_ReflCam.transform.rotation = Quaternion.LookRotation(
                Vector3.Reflect(cam.transform.forward, normal), Vector3.Reflect(cam.transform.up, normal));
            m_ReflCam.worldToCameraMatrix = cam.worldToCameraMatrix * reflection;

            // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射
            Vector4 clipPlane = CameraSpacePlane(m_ReflCam.worldToCameraMatrix,
                                                 pos - normal * clipPlaneOffset, normal);
            m_ReflCam.projectionMatrix = cam.CalculateObliqueMatrix(clipPlane);
        }

        // 关于平面 (n, d) 的镜像矩阵:p' = p - 2 (n·p + d) n
        static Matrix4x4 ReflectionMatrix(Vector4 p)
        {
            var m = Matrix4x4.identity;
            m.m00 = 1 - 2 * p.x * p.x;  m.m01 = -2 * p.x * p.y;
            m.m02 = -2 * p.x * p.z;     m.m03 = -2 * p.w * p.x;
            m.m10 = -2 * p.y * p.x;     m.m11 = 1 - 2 * p.y * p.y;
            m.m12 = -2 * p.y * p.z;     m.m13 = -2 * p.w * p.y;
            m.m20 = -2 * p.z * p.x;     m.m21 = -2 * p.z * p.y;
            m.m22 = 1 - 2 * p.z * p.z;  m.m23 = -2 * p.w * p.z;
            return m;   // 第四行保持 (0, 0, 0, 1)
        }

        // 把世界空间的平面变换到观察空间(CalculateObliqueMatrix 需要观察空间平面)
        static Vector4 CameraSpacePlane(Matrix4x4 worldToCamera, Vector3 pos, Vector3 normal)
        {
            Vector3 cpos = worldToCamera.MultiplyPoint(pos);
            Vector3 cnormal = worldToCamera.MultiplyVector(normal).normalized;
            return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal));
        }
    }
}
// 案例 19.9:平面反射(JS 版,对应 Ch19/PlanarReflection.cs)。
// 用一台“镜像相机”从水面下方往上看,把画面渲染进一张 RenderTexture,
// 再设成全局纹理 _PlanarReflectionTex,水面 Shader 用屏幕 UV 采样它。
//
// C# 版用 RenderPipeline.SubmitRenderRequest 让 URP 为反射相机完整渲染一次;
// 网页版在 update() 里(主相机渲染之前)用引擎的 Frame 为镜像相机画一遍:不透明 → 天空盒 → 半透明。
// 和 C# 版一样:不画阴影、不做后处理、不生成深度/不透明贴图。
import { Frame } from '../engine/pipeline.js';
import { mat4, vec3 } from '../engine/math.js';
import { layerMask } from '../engine/features.js';

export default class PlanarReflection {
  constructor(engine, json, obj, scene) {
    this.engine = engine;
    this.obj = obj;            // 水面物体
    this.scene = scene;
    const d = json.data || {};
    this.enabled = json.enabled !== false && d.m_Enabled !== false;
    this.reflectLayers = layerMask(d.reflectLayers ?? ~(1 << 4));   // 4 = 内置的 Water 层
    this.resolutionScale = d.resolutionScale ?? 0.5;
    this.clipPlaneOffset = d.clipPlaneOffset ?? 0.05;
    this.renderShadows = !!d.renderShadows;
    this.rendering = false;   // 防止重入
  }

  // C# 版的 LateUpdate / 拍图时的 RenderFor(Camera.main):每帧在主相机渲染之前调用
  update(time, dt) {
    if (!this.enabled || !this.obj.active || !this.scene.camera) return;
    this.renderFor(this.scene.camera, time, dt);
  }

  renderFor(cam, time, dt) {
    if (this.rendering) return;
    this.rendering = true;
    const E = this.engine, gl = E.gl;
    const W = E.canvas.width, H = E.canvas.height;
    const w = Math.max(1, (W * this.resolutionScale) | 0), h = Math.max(1, (H * this.resolutionScale) | 0);
    // 反射 RT:主相机允许 HDR 就用浮点格式(C# 版的 DefaultHDR),自带深度,不开 MSAA
    const rt = E.rt('planarReflection', w, h, { color: cam.allowHDR ? 'RGBA16F' : 'SRGB8_ALPHA8', depth: 'DEPTH24_STENCIL8', samples: 1 });

    const reflCam = this.setupReflectionCamera(cam);
    // 用一个“只换了相机”的场景视图建 Frame:灯光、材质、物体都和主相机共用
    const view = Object.create(this.scene);
    view.camera = reflCam;
    const prevVP = E.prevViewProj;
    const frame = new Frame(E, view, w, h, time, dt);
    frame.requireDepth = false;    // requiresDepthOption = Off
    frame.requireOpaque = false;   // requiresColorOption = Off
    frame.setupCamera();
    frame.setupLights();
    frame.setupEnvironment();
    frame.renderShadows();         // reflCam.renderShadows = false 时只是把阴影关掉

    // 清屏(和主相机的清屏方式一致)
    frame.cameraTarget = rt;
    E.bindTarget(rt);
    gl.depthMask(true); gl.colorMask(true, true, true, true); gl.stencilMask(255);
    const bg = reflCam.clearFlags === 'SolidColor' ? reflCam.background : [0, 0, 0, 0];
    gl.clearColor(bg[0], bg[1], bg[2], bg[3]); gl.clearDepth(1); gl.clearStencil(0);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);

    // 镜像矩阵把左手系变成右手系,三角形绕序反了:临时反转剔除(C# 版的 GL.invertCulling = true),
    // 否则只看得到物体的背面。引擎按物体的 flip 标志决定正面绕序,这里把它们临时取反
    const rs = this.scene.renderers;
    rs.forEach((o) => (o.flip = !o.flip));
    try {
      frame.renderOpaques();
      frame.renderSkybox();
      frame.renderTransparents();
    } finally {
      rs.forEach((o) => (o.flip = !o.flip));
    }

    E.prevViewProj = prevVP;
    E.setGlobal('_PlanarReflectionTex', rt.texture);
    this.rendering = false;
  }

  setupReflectionCamera(cam) {
    // 平面:法线 = 水面物体的 up,经过物体原点
    const m = this.obj.matrix;
    const normal = vec3.normalize([m[4], m[5], m[6]]);
    const pos = [m[12], m[13], m[14]];
    const plane = [normal[0], normal[1], normal[2], -vec3.dot(normal, pos)];
    const reflection = reflectionMatrix(plane);

    // 视图矩阵 = 主相机视图 × 镜像:相机位置、_WorldSpaceCameraPos 都随之镜像
    const view = mat4.mul(cam.view, reflection);

    // 斜裁剪(Oblique)投影:把近裁剪面换成水面,水面以下的东西不进反射
    const clipPlane = cameraSpacePlane(view, vec3.sub(pos, vec3.scale(normal, this.clipPlaneOffset)), normal);
    const proj = calculateObliqueMatrix(cam.projection(), clipPlane);

    return {
      json: cam.json, view, projection: () => proj,
      fov: cam.fov, aspect: cam.aspect, near: cam.near, far: cam.far,
      orthographic: cam.orthographic, orthoSize: cam.orthoSize,
      clearFlags: cam.clearFlags, background: cam.background,
      cullingMask: this.reflectLayers,
      allowHDR: cam.allowHDR, allowMSAA: false,
      renderShadows: this.renderShadows, postProcessing: false, antialiasing: 0,
    };
  }
}

// 关于平面 (n, d) 的镜像矩阵:p' = p - 2 (n·p + d) n(列主序,下标 m[列*4+行])
function reflectionMatrix(p) {
  const [x, y, z, w] = p;
  return new Float32Array([
    1 - 2 * x * x, -2 * y * x, -2 * z * x, 0,
    -2 * x * y, 1 - 2 * y * y, -2 * z * y, 0,
    -2 * x * z, -2 * y * z, 1 - 2 * z * z, 0,
    -2 * w * x, -2 * w * y, -2 * w * z, 1,
  ]);
}

// 把世界空间的平面变换到观察空间(斜裁剪需要观察空间平面)
function cameraSpacePlane(worldToCamera, pos, normal) {
  const cpos = mat4.transformPoint(worldToCamera, pos);
  const cn = vec3.normalize(mat4.transformDir(worldToCamera, normal));
  return [cn[0], cn[1], cn[2], -vec3.dot(cpos, cn)];
}

// Unity 的 Camera.CalculateObliqueMatrix(Lengyel 的斜裁剪近平面):替换投影矩阵第 3 行
function calculateObliqueMatrix(projection, c) {
  const P = new Float32Array(projection);
  const g = (r, col) => P[col * 4 + r];
  const sgn = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0);
  const q = [(sgn(c[0]) + g(0, 2)) / g(0, 0), (sgn(c[1]) + g(1, 2)) / g(1, 1), -1, (1 + g(2, 2)) / g(2, 3)];
  const s = 2 / (c[0] * q[0] + c[1] * q[1] + c[2] * q[2] + c[3] * q[3]);
  P[0 * 4 + 2] = c[0] * s;
  P[1 * 4 + 2] = c[1] * s;
  P[2 * 4 + 2] = c[2] * s + 1;
  P[3 * 4 + 2] = c[3] * s;
  return P;
}

关键细节

  • 反射相机本身:用 HideAndDontSave 创建,enabled = false,它不会参与正常的相机循环,只在我们提交请求时渲染。挂上 UniversalAdditionalCameraData,关闭后处理、关闭它自己的深度/不透明纹理需求、默认关闭阴影——反射里的阴影通常看不出来,省掉一整套阴影贴图的渲染。
  • 层:reflectLayers 默认排除 Water 层,否则水面会把自己渲染进反射里(在镜像相机看来,水面正好在近裁剪面上,会产生各种闪烁)。
  • 分辨率:默认半分辨率。水面的反射会被法线扰动,半分辨率几乎看不出差别,渲染开销却只有四分之一。
  • Shader 端:RT 用 Shader.SetGlobalTexture("_PlanarReflectionTex", rt) 设为全局纹理。水面 Shader 在 CBUFFER 外声明 TEXTURE2D(_PlanarReflectionTex),用屏幕 UV 加上一点法线扰动采样它,替换掉天空反射。

在本书的拍摄脚本里,平面反射是这样触发的(摄影棚没有 LateUpdate,所以先给主相机挂一个临时 RT 确定分辨率与宽高比,手动渲染一次反射,再交给拍摄流程):

var pr = water.AddComponent<PlanarReflection>();
pr.updateMode = PlanarReflection.UpdateMode.Manual;
var tmp = RenderTexture.GetTemporary(w, h, 24);
s.Cam.targetTexture = tmp; s.Cam.aspect = (float)w / h;
pr.RenderFor(s.Cam);                 // 在主相机渲染之前、渲染循环之外
s.Cam.targetTexture = null;
RenderTexture.ReleaseTemporary(tmp);
图 19-14低机位对比。左:天空反射,水面只映出天空;右:平面反射,木栈道、石柱在水面上投下与之对齐的倒影,倒影被波纹扰动、随菲涅尔在近处变淡。

屏幕空间反射(SSR)简介

平面反射的代价是“整个场景再渲染一遍”,而且只适用于一个平面——起伏很大的海面就不准确了。另一种思路是屏幕空间反射(Screen Space Reflection):沿反射方向在深度图里做光线步进,找到交点后从当前画面取颜色。它不需要额外的相机,适用于任意形状的表面,但只能反射屏幕上已经存在的东西:画面边缘、被遮挡的物体、相机背后的物体都反射不出来,需要退回到反射探针。URP 14 没有内置 SSR,第 28 章会介绍它的原理。实际项目里,水面常见的组合是:平面反射(或 SSR)负责近处物体,反射探针/天空负责其余部分,用菲涅尔和距离混合。

19.2 最终合成

把所有关键字打开,配合轻微的 Bloom 和 Tonemapping,就是本章的完整水面。最终版用了比案例 19.9 更平缓的波浪(陡度 0.05~0.07)——小水池里浪太大会漫上沙滩;太阳高光的强度也调低了,避免日光路径在 Bloom 下变成一片白。

图 19-15完整的风格化水面:深度吸收 + 折射 + 双层法线 + 平面反射 + 菲涅尔 + 太阳闪点 + 岸边泡沫 + 焦散 + Gerstner 波。开启 Bloom 与 Neutral Tonemapping。
图 19-16近景:暗礁顶部的焦散与泡沫、石柱水下部分的折射与吸收、岸边的泡沫推进线。

调水时推荐的顺序,基本就是本章案例的顺序:先关掉所有特性,把深度和吸收调对——这决定了水的“底色”和通透感;再调法线(尺度、速度、强度),因为反射、高光、折射、泡沫的外观全部受它影响;然后是反射和菲涅尔;最后才是泡沫、焦散这些“点缀”。每加一项,都用调试视图(_DebugView)单独看它的遮罩是否合理。

参数作用调参建议
_Absorption每米吸收系数(RGB)R 最大、B 最小得到蓝绿色水;整体放大让水更浑浊;想要泥水可以让 B 最大
_DeepColor散射色,深处的颜色不要用纯黑;它乘了光照,阴影里会自动变暗
_RefractionStrength折射偏移0.02~0.08;过大时前景泄漏修正的“无扭曲圈”会变明显
_NormalTiling/_FlowA/B两层法线的尺度、流向与强度两层尺度比避开整数;方向夹角 60°~120°;远景可以降低强度防止闪烁
_SpecPower高光锐度水面 500~2000;越大闪点越碎
_FoamDistance泡沫带宽度(米)与水池坡度相关:坡越缓,同样的宽度覆盖的水平距离越大
_CausticScale/_CausticDepthFade焦散尺度与深度衰减每米 1~3 个细胞;深度衰减 1~2 米

性能与移动端

  • 两张屏幕纹理:水面需要 Depth Texture 和 Opaque Texture。它们分别意味着一次深度拷贝(或深度预 Pass)和一次颜色拷贝。如果场景里只有水用到它们,可以只在有水的相机上开启(相机组件的 Depth Texture / Opaque Texture 覆盖选项)。
  • 平面反射是最贵的一项:它把场景再渲染一遍。降低分辨率、只渲染必要的层(比如排除小物件和粒子)、关闭反射相机的阴影,或者在低端设备上退回天空/探针反射。
  • 焦散:程序化 Voronoi 在移动端换成预烘焙贴图。
  • 精度:颜色、法线、菲涅尔等可以用 half;但世界坐标、深度重建、Gerstner 相位必须用 float——half 只有约 3 位有效数字,几十米外的坐标会出现明显的阶梯和抖动。
  • 大面积透明:水面通常占据大半个屏幕,片元着色器的每一行都很贵。用 shader_feature_local 把不用的特性彻底剥离,而不是用材质参数在运行时“关掉”。

和 Built-in 管线相比:Built-in 的水通常用 GrabPass 抓取屏幕做折射,URP 不支持 GrabPass,改用 _CameraOpaqueTexture;Built-in 的平面反射可以在 OnWillRenderObject 里调用 Camera.Render(),在 SRP 里这些回调与 Camera.Render 的用法都不一样,需要按本章的 SubmitRenderRequest 方式改写。

本章小结

  • 水面在透明队列中读取深度图重建水底世界坐标,由此得到竖直水深(决定颜色、泡沫)与视线路径长度(决定吸收)。
  • 折射从 _CameraOpaqueTexture 按法线偏移采样,需要用偏移点的深度判断并拒绝前景泄漏;Beer-Lambert 吸收 T = e−σd 按通道衰减,自动给出浅青深蓝的颜色渐变。
  • 双层法线以不同尺度与方向流动,打破重复与平移感;法线贴图必须能无缝平铺。
  • 水的 F0 约为 0.02,菲涅尔让近处通透、远处如镜;URP 14 Forward+ 下 GlossyEnvironmentReflection 必须使用带屏幕 UV 的 5 参数版本。
  • 泡沫、焦散都建立在深度重建之上;基于屏幕深度的泡沫测的是视线方向上的距离,有固有的局限。
  • Gerstner 波在顶点阶段累加位移与偏导,解析得到法线与切线空间,并自动联动水深相关的效果。
  • URP 14 的平面反射用 RenderPipeline.SubmitRenderRequest + UniversalRenderPipeline.SingleCameraRequest,必须在渲染循环之外提交;在 beginCameraRendering 回调里提交会触发递归渲染错误。

练习

  1. 给水面加一个“水下视角”:相机在水面以下时,水面的反射与折射角色互换(从水下看水面,会看到全反射的现象)。提示:用 IS_FRONT_VFACE 判断看到的是水面的背面。
  2. 把焦散改为预烘焙贴图:写一个编辑器脚本,用本章的 CausticPattern 公式(C# 版本)生成一张 256×256 可平铺的焦散贴图,Shader 改为采样两次并错开流动,比较效果与开销。
  3. 实现一个“离岸距离图”:在编辑器里从水池地形高度场计算每个水面点到岸线的水平距离,存成贴图,用它驱动泡沫与 Gerstner 波的振幅衰减,消除图 19-10 中泡沫只出现在柱子一侧的问题。
  4. 给 PlanarReflection 加上“只在水面可见时渲染”的优化:用 Renderer.isVisible 或 GeometryUtility.TestPlanesAABB 判断水面是否在主相机视锥内。
  5. 在 C# 里实现与 Shader 相同的 Gerstner 公式,写一个浮漂组件,让一个小球随波起伏。