GameTech/URP 渲染实战

案例 17.4Gerstner 波高阶

目标:用 Gerstner 波叠加出尖峰平谷的海面,法线用解析偏导数精确计算,并用雅可比行列式在波峰挤压处生成泡沫。

原理:普通正弦波只让水面上的点上下运动,波形是对称的,看起来像丝绸而不像水。真实的深水波中,水质点做的是圆周运动:在波峰处向前、在波谷处向后。这就是 Gerstner 波(也叫摆线波、Trochoidal Wave),19 世纪由 Gerstner 提出,是流体力学方程在深水条件下的一个精确解。圆周运动让质点在波峰处聚集、在波谷处散开,于是波峰变尖、波谷变平。

水质点沿圆周运动(灰圈),把它们连起来就是 Gerstner 波形(蓝线): 波峰尖、波谷平;虚线是同振幅的正弦波 陡度 s = k·a:s → 1 时波峰收成尖角,s > 1 时曲线自相交(翻卷) 传播方向 →
图 17-6Gerstner 波:质点做圆周运动,波形呈摆线

公式

设水面上某点静止时的水平位置为 x0(世界 xz),对第 i 个波:传播方向 Di(单位向量)、波长 λi、陡度 si。定义:

ki=2πλi,ωi=gki,ai=siki,θi=ki(Di·x0)−ωit

ω = √(gk) 是深水波的色散关系:波速 c = ω/k = √(gλ/2π),长波比短波跑得快。这一点很重要——如果所有波用同样的速度,叠加出的海面会像一整块在平移;用色散关系,不同波长的波相对滑动,海面才有“活”的感觉。振幅不直接给,而是由陡度决定:a = s/k,s = 1 时波峰恰好收成尖角。叠加后的位置:

P=(x0+∑iDi,xaicosθi∑iaisinθiz0+∑iDi,zaicosθi)

注意 θ 用的是静止位置 x0 而不是位移后的位置——这是拉格朗日描述(跟踪每个质点),正好对应顶点着色器:网格顶点的原始位置就是 x0。GPU Gems 第 1 章给出的版本多了一个参数 Q 控制水平位移相对竖直位移的比例(椭圆轨道);本例取圆形轨道,直接用陡度 s 参数化,更直观。

解析法线

对 x0 求偏导(注意 ∂θ/∂x0 = k·Dx,而 a·k = s,k 恰好被消掉):

∂P∂x0=(1−∑Dx2ssinθ,∑Dxscosθ,−∑DxDzssinθ)
∂P∂z0=(−∑DxDzssinθ,∑Dzscosθ,1−∑Dz2ssinθ),N=normalize(∂P∂z0×∂P∂x0)

每个波只多了几次乘加,比有限差分(要把整个位移函数再算两遍)便宜得多,而且没有步长误差。另外请留意:所有波共用的偏导数从 (1,0,0) 和 (0,0,1) 开始累加,这两个初值就是“未扰动的平面本身”的偏导;如果水面网格不是水平放置的,这里应改成网格自己的切线与副切线。

雅可比行列式与泡沫

水平方向上,质点从 x0 被映射到了新位置,这个映射的雅可比行列式

J=∂Px∂x0∂Pz∂z0−∂Pz∂x0∂Px∂z0

表示一小块水面被拉伸(J > 1)还是压缩(J < 1)。单个波时 J = 1 − s sin θ,在波峰(sin θ = 1)处最小,等于 1 − s;J 趋近 0 意味着质点挤到了一起、波峰即将翻卷——现实中这正是白浪碎沫出现的地方。J < 0 则说明曲面已经自相交,所以所有波的陡度之和最好不超过 1。这个判据在基于 FFT 的海洋渲染里同样被用来生成泡沫。

完整代码:

Assets/Cookbook/Ch17/Gerstner.shader
// Gerstner 波(又称 Trochoidal 波):水面上的点不只上下动,还沿传播方向前后做圆周运动,
// 于是波峰变尖、波谷变平。最多叠加 4 个波,法线由解析偏导数求得,泡沫来自雅可比行列式。
Shader "Cookbook/Ch17/Gerstner"
{
    Properties
    {
        _DeepColor ("Deep Color", Color) = (0.02, 0.12, 0.2, 1)
        _ShallowColor ("Crest Color", Color) = (0.1, 0.45, 0.55, 1)
        _FoamColor ("Foam Color", Color) = (0.9, 0.95, 1, 1)
        // 每个波:xy = 传播方向,z = 陡度 steepness(0..1),w = 波长(米)
        _WaveA ("Wave A (dir.xy, steepness, wavelength)", Vector) = (1, 0, 0.5, 4)
        _WaveB ("Wave B", Vector) = (0, 1, 0.25, 3)
        _WaveC ("Wave C", Vector) = (1, 1, 0.15, 1.6)
        _WaveD ("Wave D", Vector) = (-0.6, 1, 0.1, 0.9)
        [Toggle] _Horizontal ("Horizontal Displacement (Gerstner)", Float) = 1
        _FoamThreshold ("Foam Threshold (Jacobian)", Range(0, 1)) = 0.35
        _Phase ("Phase (seconds, for stills)", Float) = 0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _DeepColor, _ShallowColor, _FoamColor;
            float4 _WaveA, _WaveB, _WaveC, _WaveD;
            float _Horizontal, _FoamThreshold, _Phase;
        CBUFFER_END
        #include "Assets/Cookbook/Ch17/VertexAnimCommon.hlsl"

        // 累加一个 Gerstner 波。x0:未扰动时的水平位置(世界 xz)。
        // 返回位移;同时累加对 x、z 的偏导数,用于求法线和雅可比行列式。
        float3 GerstnerWave(float4 wave, float2 x0, float t, inout float3 dPdx, inout float3 dPdz)
        {
            float steep = wave.z;
            if (steep <= 0) return 0;
            float k = TWO_PI / wave.w;                 // 波数
            float omega = sqrt(9.8 * k);               // 深水色散关系 ω² = g·k
            float2 D = normalize(wave.xy);
            float theta = k * dot(D, x0) - omega * t;
            float a = steep / k;                       // 振幅:陡度 = k·a,k·a = 1 时波峰恰好成尖角
            float s, c;
            sincos(theta, s, c);
            float h = _Horizontal;                     // 0 = 退化为普通正弦波(只有竖直位移)

            // 位移:水平方向 D·a·cosθ,竖直方向 a·sinθ
            float3 offset = float3(D.x * a * c * h, a * s, D.y * a * c * h);

            // 偏导数(对 x0.x、x0.z 求导,注意 ∂θ/∂x = k·D.x)
            dPdx += float3(-D.x * D.x * steep * s * h, D.x * steep * c, -D.x * D.y * steep * s * h);
            dPdz += float3(-D.x * D.y * steep * s * h, D.y * steep * c, -D.y * D.y * steep * s * h);
            return offset;
        }

        void Deform(inout VAAttributes v)
        {
            float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
            float2 x0 = positionWS.xz;
            float t = _Time.y + _Phase;
            float3 dPdx = float3(1, 0, 0), dPdz = float3(0, 0, 1);   // 平面本身的偏导数
            float3 offset = GerstnerWave(_WaveA, x0, t, dPdx, dPdz)
                          + GerstnerWave(_WaveB, x0, t, dPdx, dPdz)
                          + GerstnerWave(_WaveC, x0, t, dPdx, dPdz)
                          + GerstnerWave(_WaveD, x0, t, dPdx, dPdz);
            positionWS += offset;

            // 法线 = ∂P/∂z × ∂P/∂x(Unity 左手系里这个顺序朝上)
            float3 normalWS = normalize(cross(dPdz, dPdx));
            // 雅可比行列式:水平面被“挤压”的程度。< 1 表示表面被压缩,接近 0 时波峰要翻卷,正是起泡沫的地方
            float jacobian = dPdx.x * dPdz.z - dPdx.z * dPdz.x;

            v.positionOS.xyz = TransformWorldToObject(positionWS);
            v.normalOS = TransformWorldToObjectNormal(normalWS);
            v.uv2 = float4(jacobian, offset.y, 0, 0);
        }

        #include "Assets/Cookbook/Ch17/VertexAnimPasses.hlsl"
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            HLSLPROGRAM
            #pragma vertex VAForwardVert
            #pragma fragment frag
            #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

            half4 frag(VAVaryings i) : SV_Target
            {
                float3 N = normalize(i.normalWS);
                float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
                Light L = GetMainLight(TransformWorldToShadowCoord(i.positionWS));

                // 水体颜色:越高(波峰)越浅,模拟光在薄水层里的透射
                half crest = saturate(i.extra.y * 0.8h + 0.5h);
                half3 water = lerp(_DeepColor.rgb, _ShallowColor.rgb, crest * crest);
                half3 col = water * (saturate(dot(N, L.direction)) * 0.5h + 0.5h) * (L.color * L.shadowAttenuation * 0.6h + SampleSH(N));

                // 菲涅尔反射天空(水的 F0 约 0.02)
                half fresnel = 0.02h + 0.98h * pow(1.0h - saturate(dot(N, V)), 5.0h);
                half3 refl = GlossyEnvironmentReflection(reflect(-V, N), 0.05h, 1.0h);
                col = lerp(col, refl, fresnel);

                // 太阳高光
                float3 H = normalize(L.direction + V);
                col += pow(saturate(dot(N, H)), 400.0h) * 6.0h * L.color * L.shadowAttenuation;

                // 泡沫:雅可比行列式越小(越被压缩)泡沫越多
                half foam = saturate((_FoamThreshold - i.extra.x) / max(_FoamThreshold, 1e-3) * 2.0h);
                col = lerp(col, _FoamColor.rgb * (SampleSH(N) + L.color * 0.6h), foam);
                return half4(col, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex VAShadowVert
            #pragma fragment VANullFrag
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex VADepthVert
            #pragma fragment VANullFrag
            ENDHLSL
        }
    }
}
// Gerstner 波(又称 Trochoidal 波):水面上的点不只上下动,还沿传播方向前后做圆周运动,(GLSL 版)
// 于是波峰变尖、波谷变平。最多叠加 4 个波,法线由解析偏导数求得,泡沫来自雅可比行列式。
Shader "Cookbook/Ch17/Gerstner"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _DeepColor, _ShallowColor, _FoamColor;
        uniform vec4 _WaveA, _WaveB, _WaveC, _WaveD;
        uniform float _Horizontal, _FoamThreshold, _Phase;
        #include "Ch17/VertexAnimCommon.glsl"

        // 累加一个 Gerstner 波。x0:未扰动时的水平位置(世界 xz)。
        // 返回位移;同时累加对 x、z 的偏导数,用于求法线和雅可比行列式。
        vec3 GerstnerWave(vec4 wave, vec2 x0, float t, inout vec3 dPdx, inout vec3 dPdz)
        {
            float steep = wave.z;
            if (steep <= 0.0) return vec3(0.0);
            float k = TWO_PI / wave.w;                 // 波数
            float omega = sqrt(9.8 * k);               // 深水色散关系 ω² = g·k
            vec2 D = normalize(wave.xy);
            float theta = k * dot(D, x0) - omega * t;
            float a = steep / k;                       // 振幅:陡度 = k·a,k·a = 1 时波峰恰好成尖角
            float s = sin(theta), c = cos(theta);      // HLSL 的 sincos
            float h = _Horizontal;                     // 0 = 退化为普通正弦波(只有竖直位移)

            // 位移:水平方向 D·a·cosθ,竖直方向 a·sinθ
            vec3 offset = vec3(D.x * a * c * h, a * s, D.y * a * c * h);

            // 偏导数(对 x0.x、x0.z 求导,注意 ∂θ/∂x = k·D.x)
            dPdx += vec3(-D.x * D.x * steep * s * h, D.x * steep * c, -D.x * D.y * steep * s * h);
            dPdz += vec3(-D.x * D.y * steep * s * h, D.y * steep * c, -D.y * D.y * steep * s * h);
            return offset;
        }

        void Deform(inout VAAttributes v)
        {
            vec3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
            vec2 x0 = positionWS.xz;
            float t = _Time.y + _Phase;
            vec3 dPdx = vec3(1, 0, 0), dPdz = vec3(0, 0, 1);   // 平面本身的偏导数
            vec3 offset = GerstnerWave(_WaveA, x0, t, dPdx, dPdz)
                        + GerstnerWave(_WaveB, x0, t, dPdx, dPdz)
                        + GerstnerWave(_WaveC, x0, t, dPdx, dPdz)
                        + GerstnerWave(_WaveD, x0, t, dPdx, dPdz);
            positionWS += offset;

            // 法线 = ∂P/∂z × ∂P/∂x(Unity 左手系里这个顺序朝上)
            vec3 normalWS = normalize(cross(dPdz, dPdx));
            // 雅可比行列式:水平面被“挤压”的程度。< 1 表示表面被压缩,接近 0 时波峰要翻卷,正是起泡沫的地方
            float jacobian = dPdx.x * dPdz.z - dPdx.z * dPdz.x;

            v.positionOS.xyz = TransformWorldToObject(positionWS);
            v.normalOS = TransformWorldToObjectNormal(normalWS);
            v.uv2 = vec4(jacobian, offset.y, 0.0, 0.0);
        }

        #include "Ch17/VertexAnimPasses.glsl"
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VAForwardVert(); }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                vec3 N = normalize(normalWS);
                vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
                Light L = GetMainLight(TransformWorldToShadowCoord(positionWS));

                // 水体颜色:越高(波峰)越浅,模拟光在薄水层里的透射
                float crest = saturate(extra.y * 0.8 + 0.5);
                vec3 water = mix(_DeepColor.rgb, _ShallowColor.rgb, crest * crest);
                vec3 col = water * (saturate(dot(N, L.direction)) * 0.5 + 0.5) * (L.color * L.shadowAttenuation * 0.6 + SampleSH(N));

                // 菲涅尔反射天空(水的 F0 约 0.02)
                float fresnel = 0.02 + 0.98 * pow(1.0 - saturate(dot(N, V)), 5.0);
                vec3 refl = GlossyEnvironmentReflection(reflect(-V, N), 0.05, 1.0);
                col = mix(col, refl, fresnel);

                // 太阳高光
                vec3 H = normalize(L.direction + V);
                col += pow(saturate(dot(N, H)), 400.0) * 6.0 * L.color * L.shadowAttenuation;

                // 泡沫:雅可比行列式越小(越被压缩)泡沫越多
                float foam = saturate((_FoamThreshold - extra.x) / max(_FoamThreshold, 1e-3) * 2.0);
                col = mix(col, _FoamColor.rgb * (SampleSH(N) + L.color * 0.6), foam);
                fragColor = vec4(col, 1.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VAShadowVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { VANullFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VADepthVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { VANullFrag(); }
            #endif
            ENDGLSL
        }
    }
}

关键代码讲解:单个波的累加函数同时返回位移、累加两个偏导:

Assets/Cookbook/Ch17/Gerstner.shader(节选:gerstner)
// 累加一个 Gerstner 波。x0:未扰动时的水平位置(世界 xz)。
// 返回位移;同时累加对 x、z 的偏导数,用于求法线和雅可比行列式。
float3 GerstnerWave(float4 wave, float2 x0, float t, inout float3 dPdx, inout float3 dPdz)
{
    float steep = wave.z;
    if (steep <= 0) return 0;
    float k = TWO_PI / wave.w;                 // 波数
    float omega = sqrt(9.8 * k);               // 深水色散关系 ω² = g·k
    float2 D = normalize(wave.xy);
    float theta = k * dot(D, x0) - omega * t;
    float a = steep / k;                       // 振幅:陡度 = k·a,k·a = 1 时波峰恰好成尖角
    float s, c;
    sincos(theta, s, c);
    float h = _Horizontal;                     // 0 = 退化为普通正弦波(只有竖直位移)

    // 位移:水平方向 D·a·cosθ,竖直方向 a·sinθ
    float3 offset = float3(D.x * a * c * h, a * s, D.y * a * c * h);

    // 偏导数(对 x0.x、x0.z 求导,注意 ∂θ/∂x = k·D.x)
    dPdx += float3(-D.x * D.x * steep * s * h, D.x * steep * c, -D.x * D.y * steep * s * h);
    dPdz += float3(-D.x * D.y * steep * s * h, D.y * steep * c, -D.y * D.y * steep * s * h);
    return offset;
}
// 累加一个 Gerstner 波。x0:未扰动时的水平位置(世界 xz)。
// 返回位移;同时累加对 x、z 的偏导数,用于求法线和雅可比行列式。
vec3 GerstnerWave(vec4 wave, vec2 x0, float t, inout vec3 dPdx, inout vec3 dPdz)
{
    float steep = wave.z;
    if (steep <= 0.0) return vec3(0.0);
    float k = TWO_PI / wave.w;                 // 波数
    float omega = sqrt(9.8 * k);               // 深水色散关系 ω² = g·k
    vec2 D = normalize(wave.xy);
    float theta = k * dot(D, x0) - omega * t;
    float a = steep / k;                       // 振幅:陡度 = k·a,k·a = 1 时波峰恰好成尖角
    float s = sin(theta), c = cos(theta);      // HLSL 的 sincos
    float h = _Horizontal;                     // 0 = 退化为普通正弦波(只有竖直位移)

    // 位移:水平方向 D·a·cosθ,竖直方向 a·sinθ
    vec3 offset = vec3(D.x * a * c * h, a * s, D.y * a * c * h);

    // 偏导数(对 x0.x、x0.z 求导,注意 ∂θ/∂x = k·D.x)
    dPdx += vec3(-D.x * D.x * steep * s * h, D.x * steep * c, -D.x * D.y * steep * s * h);
    dPdz += vec3(-D.x * D.y * steep * s * h, D.y * steep * c, -D.y * D.y * steep * s * h);
    return offset;
}

Deform 在世界空间里计算(所有水面块共享同一个波场,拼接无缝),最后转回物体空间;法线、雅可比行列式也一并算出,后者通过 uv2 交给片元:

Assets/Cookbook/Ch17/Gerstner.shader(节选:deform)
void Deform(inout VAAttributes v)
{
    float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
    float2 x0 = positionWS.xz;
    float t = _Time.y + _Phase;
    float3 dPdx = float3(1, 0, 0), dPdz = float3(0, 0, 1);   // 平面本身的偏导数
    float3 offset = GerstnerWave(_WaveA, x0, t, dPdx, dPdz)
                  + GerstnerWave(_WaveB, x0, t, dPdx, dPdz)
                  + GerstnerWave(_WaveC, x0, t, dPdx, dPdz)
                  + GerstnerWave(_WaveD, x0, t, dPdx, dPdz);
    positionWS += offset;

    // 法线 = ∂P/∂z × ∂P/∂x(Unity 左手系里这个顺序朝上)
    float3 normalWS = normalize(cross(dPdz, dPdx));
    // 雅可比行列式:水平面被“挤压”的程度。< 1 表示表面被压缩,接近 0 时波峰要翻卷,正是起泡沫的地方
    float jacobian = dPdx.x * dPdz.z - dPdx.z * dPdz.x;

    v.positionOS.xyz = TransformWorldToObject(positionWS);
    v.normalOS = TransformWorldToObjectNormal(normalWS);
    v.uv2 = float4(jacobian, offset.y, 0, 0);
}
void Deform(inout VAAttributes v)
{
    vec3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
    vec2 x0 = positionWS.xz;
    float t = _Time.y + _Phase;
    vec3 dPdx = vec3(1, 0, 0), dPdz = vec3(0, 0, 1);   // 平面本身的偏导数
    vec3 offset = GerstnerWave(_WaveA, x0, t, dPdx, dPdz)
                + GerstnerWave(_WaveB, x0, t, dPdx, dPdz)
                + GerstnerWave(_WaveC, x0, t, dPdx, dPdz)
                + GerstnerWave(_WaveD, x0, t, dPdx, dPdz);
    positionWS += offset;

    // 法线 = ∂P/∂z × ∂P/∂x(Unity 左手系里这个顺序朝上)
    vec3 normalWS = normalize(cross(dPdz, dPdx));
    // 雅可比行列式:水平面被“挤压”的程度。< 1 表示表面被压缩,接近 0 时波峰要翻卷,正是起泡沫的地方
    float jacobian = dPdx.x * dPdz.z - dPdx.z * dPdz.x;

    v.positionOS.xyz = TransformWorldToObject(positionWS);
    v.normalOS = TransformWorldToObjectNormal(normalWS);
    v.uv2 = vec4(jacobian, offset.y, 0.0, 0.0);
}

_Horizontal 是一个对比开关:设为 0 时去掉水平位移和偏导中对应的项,Gerstner 退化成普通正弦波。片元部分是一个简化的水面着色:按高度在深浅两色间插值、菲涅尔(水的 F0 ≈ 0.02)混合环境反射(GlossyEnvironmentReflection)、一个很锐利的太阳高光,再按雅可比行列式叠加泡沫。完整的水面(折射、深度着色、岸边泡沫、焦散)放在第 19 章。

Assets/Cookbook/Ch17/Gerstner.shader(节选:frag)
half4 frag(VAVaryings i) : SV_Target
{
    float3 N = normalize(i.normalWS);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    Light L = GetMainLight(TransformWorldToShadowCoord(i.positionWS));

    // 水体颜色:越高(波峰)越浅,模拟光在薄水层里的透射
    half crest = saturate(i.extra.y * 0.8h + 0.5h);
    half3 water = lerp(_DeepColor.rgb, _ShallowColor.rgb, crest * crest);
    half3 col = water * (saturate(dot(N, L.direction)) * 0.5h + 0.5h) * (L.color * L.shadowAttenuation * 0.6h + SampleSH(N));

    // 菲涅尔反射天空(水的 F0 约 0.02)
    half fresnel = 0.02h + 0.98h * pow(1.0h - saturate(dot(N, V)), 5.0h);
    half3 refl = GlossyEnvironmentReflection(reflect(-V, N), 0.05h, 1.0h);
    col = lerp(col, refl, fresnel);

    // 太阳高光
    float3 H = normalize(L.direction + V);
    col += pow(saturate(dot(N, H)), 400.0h) * 6.0h * L.color * L.shadowAttenuation;

    // 泡沫:雅可比行列式越小(越被压缩)泡沫越多
    half foam = saturate((_FoamThreshold - i.extra.x) / max(_FoamThreshold, 1e-3) * 2.0h);
    col = lerp(col, _FoamColor.rgb * (SampleSH(N) + L.color * 0.6h), foam);
    return half4(col, 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    Light L = GetMainLight(TransformWorldToShadowCoord(positionWS));

    // 水体颜色:越高(波峰)越浅,模拟光在薄水层里的透射
    float crest = saturate(extra.y * 0.8 + 0.5);
    vec3 water = mix(_DeepColor.rgb, _ShallowColor.rgb, crest * crest);
    vec3 col = water * (saturate(dot(N, L.direction)) * 0.5 + 0.5) * (L.color * L.shadowAttenuation * 0.6 + SampleSH(N));

    // 菲涅尔反射天空(水的 F0 约 0.02)
    float fresnel = 0.02 + 0.98 * pow(1.0 - saturate(dot(N, V)), 5.0);
    vec3 refl = GlossyEnvironmentReflection(reflect(-V, N), 0.05, 1.0);
    col = mix(col, refl, fresnel);

    // 太阳高光
    vec3 H = normalize(L.direction + V);
    col += pow(saturate(dot(N, H)), 400.0) * 6.0 * L.color * L.shadowAttenuation;

    // 泡沫:雅可比行列式越小(越被压缩)泡沫越多
    float foam = saturate((_FoamThreshold - extra.x) / max(_FoamThreshold, 1e-3) * 2.0);
    col = mix(col, _FoamColor.rgb * (SampleSH(N) + L.color * 0.6), foam);
    fragColor = vec4(col, 1.0);
}
图 17-7逆光观察 18 米 × 18 米、260 段细分的水面。左:单个波,只有竖直位移(普通正弦),远处的轮廓是圆润对称的;中:同参数的 Gerstner 波(陡度 0.85),波峰尖、波谷平,波峰上的细亮线是雅可比泡沫;右:四个不同方向、波长的 Gerstner 波叠加
选波的经验① 方向集中在主风向 ±30° 以内,再加一两个偏离较大的小波打破规律;② 波长按大约 1.6~2 倍的比例递减(4.5、3.1、1.7、1.1),避免公倍数造成明显的重复周期;③ 陡度之和 ≤ 1,短波陡度更小;④ 网格的顶点间距至少要小于最短波长的 1/8,否则短波会因采样不足而“走样”——图 17-7 的网格间距约 7 厘米,最短波长 1.1 米。更细的波纹交给法线贴图(第 19 章)。
深入浮力和船只交互需要在 CPU 端知道某个世界坐标处的水面高度。注意 Gerstner 的输入是 x0 而不是最终位置:给定一个世界点 (x, z),要找的是“哪个 x0 被移到了 (x, z)”,这需要迭代求逆——常用做法是从 x0 = (x, z) 开始,用 x0 ← (x, z) − 水平位移(x0) 迭代三四次,就足够精确了。C# 端必须用和 Shader 完全一致的公式和时间(Shader.SetGlobalFloat 传入同一个时间值,而不是各用各的 Time.time)。