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

顶点动画

让物体动起来不一定要骨骼和动画控制器。飘扬的旗帜、随风起伏的草地、翻滚的海浪、碎成千百片的雕像,都可以在顶点着色器里用几行数学完成——GPU 并行处理成千上万个顶点,CPU 一点也不用操心。难点不在“让顶点动”,而在“动完之后法线、阴影、深度、包围盒都要跟上”。

本章你会学到
顶点动画的通用框架:一个 Deform 函数被颜色、阴影、深度三个 Pass 共用
重算法线的两种方法:有限差分与解析偏导,以及它们各自的适用场景
旗帜、草地风场、Gerstner 波(含完整推导、解析法线与雅可比泡沫)、膨胀呼吸
逐三角形碎裂与吸附重组、VAT 顶点动画贴图的烘焙与回放
阴影不同步、包围盒剔除、运动矢量等工程问题

17.1 顶点动画的基本套路

顶点动画就是在顶点着色器里,把输入的 positionOS 换成一个随时间(或参数)变化的新位置。看起来简单,但一个“可以上线”的顶点动画 Shader 要回答五个问题:

① 在哪个空间里动

物体空间的位移会跟着物体旋转缩放:旗子转了 90°,波浪方向也跟着转,适合“这个物体自己的动作”(旗帜飘、心脏跳)。世界空间的位移与物体朝向无关,适合“环境施加的动作”(风从西边吹来、海浪向岸边推进)——所有草、所有水面块共享同一个风场,拼在一起没有接缝。本章的旗帜在物体空间,草和 Gerstner 波在世界空间计算后再转回物体空间。

② 时间从哪来

URP 提供的时间变量(摘自 URP 14 ShaderLibrary/UnityInput.hlsl):

float4 _Time;           // (t/20, t, t*2, t*3)
float4 _SinTime;        // sin(t/8), sin(t/4), sin(t/2), sin(t)
float4 _CosTime;        // cos(t/8), cos(t/4), cos(t/2), cos(t)
float4 unity_DeltaTime; // dt, 1/dt, smoothdt, 1/smoothdt
float4 _TimeParameters; // t, sin(t), cos(t)

本章所有 Shader 都写成 t = _Time.y * _Speed + _Phase。_Phase 是一个材质参数:运行时它让同一材质的多个实例错开相位,拍摄静帧时(书中配图拍摄时 _Time 接近 0)用它摆出想要的姿态。碎裂这种“有开始有结束”的动画,则直接用 _Progress(0~1)驱动,由 C# 或 Timeline 控制,而不是依赖全局时间。

提示编辑器在非播放状态下,Scene 视图里的 _Time 只在视图刷新时前进。想在不运行游戏的情况下预览顶点动画,打开 Scene 视图工具栏的 Effects › Always Refresh。

③ 法线怎么办

顶点动了,表面的朝向就变了,但网格里的法线还是原来的。如果不重算,光照就是“平的”——几何起伏了,明暗却没变(图 17-3 左)。重算法线有三条路:

  • 解析偏导:位移函数如果能手工求导,直接写出切线方向的偏导数再叉乘。精确、便宜,Gerstner 波就是这样做的。
  • 有限差分:在当前点附近沿两个方向各挪一小步,再算两次位移,用三个点的差构造切线。不用推导,任何位移函数都适用(包括采样噪声贴图的),代价是位移函数要算三遍。旗帜用的是这种方法。
  • 不重算:均匀膨胀、整体平移旋转(碎片)这类变换,法线可以直接跟着变换或者保持不变。

④ 所有 Pass 都要动

一个物体在一帧里会被画好几遍:颜色 Pass(UniversalForwardOnly)、阴影 Pass(ShadowCaster)、深度预通道(DepthOnly,开启 Depth Texture 或 Depth Priming 时)、深度法线预通道(DepthNormals,SSAO 等需要时)。每一个 Pass 都必须做同样的变形,否则阴影还是静止的形状(案例 17.2),深度图里的物体也不会动,基于深度的效果(SSAO、软粒子、水面深度)会和画面对不上。

为了从结构上避免遗漏,本章所有 Shader 采用同一个框架:每个 Shader 只实现一个 Deform(inout VAAttributes v) 函数,三个 Pass 的顶点函数都写在公共文件里,统一调用它:

Assets/Cookbook/Ch17/VertexAnimCommon.hlsl(节选:attributes)
struct VAAttributes
{
    float4 positionOS : POSITION;
    float3 normalOS   : NORMAL;
    float4 color      : COLOR;       // 草:R = 高度权重,G = 随机数
    float2 uv         : TEXCOORD0;
    float4 uv1        : TEXCOORD1;   // 草:根部位置;碎裂:三角形中心 + 随机数;VAT:顶点编号
    float4 uv2        : TEXCOORD2;   // 碎裂:面法线;也可在 Deform 里写入,传给片元(extra)
};
// GLSL 的顶点输入是固定名字的全局变量(positionOS、normalOS、color、texcoord0..3),
// 这里把它们装进一个结构体,Deform 才能像 HLSL 版一样 inout 修改
struct VAAttributes
{
    vec4 positionOS;
    vec3 normalOS;
    vec4 color;       // 草:R = 高度权重,G = 随机数
    vec2 uv;
    vec4 uv1;         // 草:根部位置;碎裂:三角形中心 + 随机数;VAT:顶点编号
    vec4 uv2;         // 碎裂:面法线;也可在 Deform 里写入,传给片元(extra)
};
Assets/Cookbook/Ch17/VertexAnimPasses.hlsl(节选:passes)
VAVaryings VAForwardVert(VAAttributes v)
{
    Deform(v);                                           // ① 颜色 Pass:变形
    VAVaryings o;
    o.positionWS = TransformObjectToWorld(v.positionOS.xyz);
    o.positionCS = TransformWorldToHClip(o.positionWS);
    o.normalWS   = TransformObjectToWorldNormal(v.normalOS);
    o.uv = v.uv; o.color = v.color; o.extra = v.uv2;
    return o;
}

float4 VAShadowVert(VAAttributes v) : SV_POSITION
{
    VA_DEFORM_SHADOW(v);                                 // ② 阴影 Pass:同样的变形
    float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
    float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
    float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, _LightDirection));
#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#endif
    return positionCS;
}

float4 VADepthVert(VAAttributes v) : SV_POSITION
{
    Deform(v);                                           // ③ 深度 Pass:同样的变形
    return TransformObjectToHClip(v.positionOS.xyz);
}
void VAForwardVert()
{
    VAAttributes v = VAGetAttributes();
    Deform(v);                                           // ① 颜色 Pass:变形
    positionWS  = TransformObjectToWorld(v.positionOS.xyz);
    gl_Position = TransformWorldToHClip(positionWS);
    normalWS    = TransformObjectToWorldNormal(v.normalOS);
    uv = v.uv; vertColor = v.color; extra = v.uv2;
}

void VAShadowVert()
{
    VAAttributes v = VAGetAttributes();
    VA_DEFORM_SHADOW(v);                                 // ② 阴影 Pass:同样的变形
    vec3 posWS = TransformObjectToWorld(v.positionOS.xyz);
    vec3 nrmWS = TransformObjectToWorldNormal(v.normalOS);
    vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(posWS, nrmWS, _LightDirection));
#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w);
#endif
    gl_Position = positionCS;
}

void VADepthVert()
{
    VAAttributes v = VAGetAttributes();
    Deform(v);                                           // ③ 深度 Pass:同样的变形
    gl_Position = TransformObjectToHClip(v.positionOS.xyz);
}
Deform(v)每个 Shader 自己实现 UniversalForwardOnly:颜色ShadowCaster:阴影贴图DepthOnly:深度图 画面投影SSAO 等 VertexAnimPasses.hlsl
图 17-1同一个 Deform 函数被三个 Pass 共用,保证颜色、阴影、深度一致

颜色 Pass 的 LightMode 用的是 UniversalForwardOnly(原因见第 15 章 15.1 节:保证在 Deferred 路径下也会以前向方式绘制)。

VAAttributes 里多带了几个通道:顶点色、UV1、UV2。不同的效果用它们存不同的“每顶点数据”——草的高度权重和根部位置、碎片的中心和面法线、VAT 的顶点编号。Deform 还可以往 uv2 里写东西,原样传到片元(extra),用来把顶点阶段算出的中间量(阵风强度、雅可比行列式、碎裂进度)交给着色。

⑤ 包围盒

Unity 用网格的包围盒(Bounds)做视锥剔除和阴影投射物剔除,而包围盒是 CPU 端根据原始顶点算的,它不知道 Shader 把顶点挪到了哪里。如果位移超出了原包围盒,物体会在屏幕边缘被错误剔除——“草在镜头边缘突然消失”“碎片飞出去后整个物体闪没”就是这个原因。解决办法是手动扩大包围盒:程序生成的网格直接设置 mesh.bounds(本章的草、碎片、VAT 都这么做);导入的网格可以设置 Renderer.localBounds(Unity 2021.2 起可用)而不必修改共享的网格资源。

SRP Batcher 与“每个实例不同”材质参数放在 UnityPerMaterial 里,同一材质的所有物体共享。想让每棵树、每面旗的相位不同,又不想为每个物体复制材质(复制材质不会打断 SRP Batcher,但会增加材质数量和管理成本),可以在 Shader 里用物体的世界位置算一个哈希作为相位偏移:float rnd = frac(sin(dot(UNITY_MATRIX_M._m03_m23, float2(12.9898, 78.233))) * 43758.5453);。避免用 MaterialPropertyBlock 传参——它会让物体退出 SRP Batcher。

什么时候该用顶点动画

方案适合代价与限制
Shader 顶点动画大量重复、规律性强、不需要和游戏逻辑交互的运动:植被、旗帜、水面、呼吸、特效CPU 不知道顶点在哪:射线检测、碰撞、挂点都以原始网格为准;动画逻辑写在 Shader 里,美术调整需要改参数
骨骼动画(Skinned Mesh)角色、需要与动画状态机配合的运动每个角色都要蒙皮计算;骨骼数有限,难以表达布料、流体这类“无数自由度”的变形
CPU 改网格需要与物理/逻辑完全一致的变形(可破坏地形)每帧上传顶点数据,数量大时带宽和 CPU 都吃紧
VAT预先计算好的复杂动画(模拟、破碎)大量重复播放占用贴图内存;动画是固定的,不能实时响应

顶点动画的计算发生在顶点着色器,开销与顶点数成正比,而且一个物体每个 Pass 都要算一遍(颜色、阴影的每个级联、深度……)。移动端上,像本章草地这样几十万顶点、每个顶点采样一次噪声的做法,需要配合分块剔除和 LOD;离相机远的物体可以用关键字关掉次要的波和噪声。

时间精度_Time.y 是从启动开始累计的秒数,用 float 存储。游戏连续运行几个小时后,它的值达到 10⁴ 量级,float 的有效精度降到毫秒级以下;再乘上较高的频率传进 sin,动画就会开始“一顿一顿”地跳。在移动端如果把相位算成 half,问题会在几十秒内就出现。稳妥的做法是:相位计算全部用 float;对于周期运动,在 C# 端把时间对一个足够长的公共周期取模后作为全局变量传入(Shader.SetGlobalFloat),而不是直接用 _Time.y。

案例 17.1旗帜飘动入门单独打开 ↗

目标:一面固定在旗杆上的旗子,波浪从旗杆向外传播,越靠外摆幅越大,并叠加噪声制造阵风般的不规则起伏;正确重算法线。

原理:旗面是一张细分平面(物体空间 XZ 平面,法线朝 +Y),位移沿 Y 方向。位移函数由三部分组成:

h(x,z,t)=A·p(u)·[sin(kx−t)+0.35sin(1.7k(0.6x+z)−1.3t)+n(x,z,t)]
  • 主波 sin(kx − t):沿 +x 传播的正弦,k = 2π/波长。相位里减去 t,波峰就随时间向 +x 移动。
  • 次波:斜向传播、频率和速度都不同,打破主波“一条条平行波峰”的机械感。
  • 噪声 n:采样随时间平移的 fBm 噪声,模拟阵风造成的不规则鼓包。
  • 钉住函数 p(u) = smoothstep(0, 0.25, u)·u:u 是从旗杆(0)到自由端(1)的位置。旗杆处必须为 0,越往外越大。

法线用有限差分:在 (x, z) 处算出高度 h,再在 (x+ε, z) 和 (x, z+ε) 处各算一次,得到两条切线 (ε, hx − h, 0) 和 (0, hz − h, ε),叉乘就是新法线。

ε P = (x, h(x))Pₓ = (x+ε, h(x+ε)) 切线 T = Pₓ − P N ⟂ T 3D 中再取 z 方向的邻点得到第二条切线 B,N = normalize(B × T)
图 17-2有限差分求法线(剖面示意):用邻点的差近似切线,再求垂直方向

完整代码:

Assets/Cookbook/Ch17/Flag.shader
// 旗帜飘动:在细分平面(物体空间 XZ 平面,法线 +Y)上沿 Y 方向位移。
// 位移 = 沿旗面传播的正弦波 + 噪声扰动,振幅从旗杆处的 0 逐渐增大;
// 法线用有限差分重新计算(对位移函数在 x、z 方向各取一个邻点)。
Shader "Cookbook/Ch17/Flag"
{
    Properties
    {
        [MainTexture] _BaseMap ("Flag Texture", 2D) = "white" {}
        [MainColor] _BaseColor ("Tint", Color) = (1, 1, 1, 1)
        [NoScaleOffset] _NoiseTex ("Noise", 2D) = "gray" {}
        _Amplitude ("Amplitude", Range(0, 0.5)) = 0.18
        _WaveLength ("Wave Length", Range(0.2, 4)) = 1.3
        _Speed ("Speed", Range(0, 5)) = 2.2
        _NoiseStrength ("Noise Strength", Range(0, 1)) = 0.5
        _Phase ("Phase (for stills)", Float) = 0
        _FlagWidth ("Flag Width (object space X)", Float) = 2
        [Toggle] _RecalcNormal ("Recalculate Normal", Float) = 1
        [Toggle] _DeformShadow ("Deform In Shadow Pass", Float) = 1
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            float4 _BaseMap_ST;
            half4 _BaseColor;
            float _Amplitude, _WaveLength, _Speed, _NoiseStrength, _Phase, _FlagWidth, _RecalcNormal, _DeformShadow;
        CBUFFER_END
        TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
        TEXTURE2D(_NoiseTex); SAMPLER(sampler_NoiseTex);
        #include "Assets/Cookbook/Ch17/VertexAnimCommon.hlsl"

        // 位移只依赖旗面上的 (x, z) 坐标和时间:这是能用有限差分求法线的前提
        float FlagOffset(float2 xz)
        {
            float u = xz.x / _FlagWidth + 0.5;             // 0 = 旗杆一侧,1 = 自由端
            float t = _Time.y * _Speed + _Phase;           // 运行时 _Time 驱动;静帧用 _Phase
            float k = TWO_PI / _WaveLength;
            float pin = smoothstep(0.0, 0.25, u) * u;      // 旗杆处钉死,越往外摆得越大
            // 主波沿 +x 传播,次波斜向传播,让波峰不那么整齐
            float w = sin(k * xz.x - t) + 0.35 * sin(k * 1.7 * (xz.x * 0.6 + xz.y) - t * 1.3);
            // 噪声:随时间平移,制造“阵风”造成的不规则起伏
            float2 nuv = xz * 0.12 + float2(-t * 0.05, t * 0.02);   // 低频采样:只要大尺度的起伏
            float n = SAMPLE_TEXTURE2D_LOD(_NoiseTex, sampler_NoiseTex, nuv, 0).r * 2.0 - 1.0;
            return (w + n * _NoiseStrength * 2.0) * _Amplitude * pin;
        }

        void Deform(inout VAAttributes v)
        {
            float3 p = v.positionOS.xyz;
            float h = FlagOffset(p.xz);

            if (_RecalcNormal > 0.5)
            {
                // 有限差分:在 x、z 方向各挪一小步,得到两个“切线”,叉乘就是新法线
                const float e = 0.04;   // 步长太小会放大噪声贴图的双线性折痕
                float hx = FlagOffset(p.xz + float2(e, 0));
                float hz = FlagOffset(p.xz + float2(0, e));
                float3 tangent   = float3(e, hx - h, 0);
                float3 bitangent = float3(0, hz - h, e);
                v.normalOS = normalize(cross(bitangent, tangent));
            }
            v.positionOS.y += h;
        }

        // 阴影 Pass 专用:_DeformShadow = 0 时不变形,用来演示“影子不动”的错误
        void DeformForShadow(inout VAAttributes v)
        {
            if (_DeformShadow > 0.5) Deform(v);
        }
        #define VA_DEFORM_SHADOW DeformForShadow

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

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull Off
            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, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
            {
                float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0);   // 旗子两面都要正确受光
                half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, TRANSFORM_TEX(i.uv, _BaseMap)).rgb * _BaseColor.rgb;
                return half4(VALighting(albedo, N, i.positionWS, 0.25h, 24.0h), 1);
            }
            ENDHLSL
        }

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

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R Cull Off
            HLSLPROGRAM
            #pragma vertex VADepthVert
            #pragma fragment VANullFrag
            ENDHLSL
        }
    }
}
// 旗帜飘动:在细分平面(物体空间 XZ 平面,法线 +Y)上沿 Y 方向位移。(GLSL 版)
// 位移 = 沿旗面传播的正弦波 + 噪声扰动,振幅从旗杆处的 0 逐渐增大;
// 法线用有限差分重新计算(对位移函数在 x、z 方向各取一个邻点)。
Shader "Cookbook/Ch17/Flag"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseMap_ST;
        uniform vec4 _BaseColor;
        uniform float _Amplitude, _WaveLength, _Speed, _NoiseStrength, _Phase, _FlagWidth, _RecalcNormal, _DeformShadow;
        uniform sampler2D _BaseMap;
        uniform sampler2D _NoiseTex;
        #include "Ch17/VertexAnimCommon.glsl"

        // 位移只依赖旗面上的 (x, z) 坐标和时间:这是能用有限差分求法线的前提
        float FlagOffset(vec2 xz)
        {
            float u = xz.x / _FlagWidth + 0.5;             // 0 = 旗杆一侧,1 = 自由端
            float t = _Time.y * _Speed + _Phase;           // 运行时 _Time 驱动;静帧用 _Phase
            float k = TWO_PI / _WaveLength;
            float pin = smoothstep(0.0, 0.25, u) * u;      // 旗杆处钉死,越往外摆得越大
            // 主波沿 +x 传播,次波斜向传播,让波峰不那么整齐
            float w = sin(k * xz.x - t) + 0.35 * sin(k * 1.7 * (xz.x * 0.6 + xz.y) - t * 1.3);
            // 噪声:随时间平移,制造“阵风”造成的不规则起伏
            vec2 nuv = xz * 0.12 + vec2(-t * 0.05, t * 0.02);   // 低频采样:只要大尺度的起伏
            float n = textureLod(_NoiseTex, nuv, 0.0).r * 2.0 - 1.0;
            return (w + n * _NoiseStrength * 2.0) * _Amplitude * pin;
        }

        void Deform(inout VAAttributes v)
        {
            vec3 p = v.positionOS.xyz;
            float h = FlagOffset(p.xz);

            if (_RecalcNormal > 0.5)
            {
                // 有限差分:在 x、z 方向各挪一小步,得到两个“切线”,叉乘就是新法线
                const float e = 0.04;   // 步长太小会放大噪声贴图的双线性折痕
                float hx = FlagOffset(p.xz + vec2(e, 0.0));
                float hz = FlagOffset(p.xz + vec2(0.0, e));
                vec3 tangent   = vec3(e, hx - h, 0.0);
                vec3 bitangent = vec3(0.0, hz - h, e);
                v.normalOS = normalize(cross(bitangent, tangent));
            }
            v.positionOS.y += h;
        }

        // 阴影 Pass 专用:_DeformShadow = 0 时不变形,用来演示“影子不动”的错误
        void DeformForShadow(inout VAAttributes v)
        {
            if (_DeformShadow > 0.5) Deform(v);
        }
        #define VA_DEFORM_SHADOW DeformForShadow

        #include "Ch17/VertexAnimPasses.glsl"
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull Off
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VAForwardVert(); }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);   // 旗子两面都要正确受光
                vec3 albedo = texture(_BaseMap, TransformTex(uv, _BaseMap_ST)).rgb * _BaseColor.rgb;
                fragColor = vec4(VALighting(albedo, N, positionWS, 0.25, 24.0), 1.0);
            }
            #endif
            ENDGLSL
        }

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

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

关键代码讲解:位移函数只依赖 (x, z) 和时间,这是有限差分的前提——如果函数里用了 UV 或顶点色这类“只有当前顶点才有”的数据,邻点就算不出来。所以钉住函数里的 u 不是从 UV 取,而是从 x 坐标换算:

Assets/Cookbook/Ch17/Flag.shader(节选:displace)
// 位移只依赖旗面上的 (x, z) 坐标和时间:这是能用有限差分求法线的前提
float FlagOffset(float2 xz)
{
    float u = xz.x / _FlagWidth + 0.5;             // 0 = 旗杆一侧,1 = 自由端
    float t = _Time.y * _Speed + _Phase;           // 运行时 _Time 驱动;静帧用 _Phase
    float k = TWO_PI / _WaveLength;
    float pin = smoothstep(0.0, 0.25, u) * u;      // 旗杆处钉死,越往外摆得越大
    // 主波沿 +x 传播,次波斜向传播,让波峰不那么整齐
    float w = sin(k * xz.x - t) + 0.35 * sin(k * 1.7 * (xz.x * 0.6 + xz.y) - t * 1.3);
    // 噪声:随时间平移,制造“阵风”造成的不规则起伏
    float2 nuv = xz * 0.12 + float2(-t * 0.05, t * 0.02);   // 低频采样:只要大尺度的起伏
    float n = SAMPLE_TEXTURE2D_LOD(_NoiseTex, sampler_NoiseTex, nuv, 0).r * 2.0 - 1.0;
    return (w + n * _NoiseStrength * 2.0) * _Amplitude * pin;
}
// 位移只依赖旗面上的 (x, z) 坐标和时间:这是能用有限差分求法线的前提
float FlagOffset(vec2 xz)
{
    float u = xz.x / _FlagWidth + 0.5;             // 0 = 旗杆一侧,1 = 自由端
    float t = _Time.y * _Speed + _Phase;           // 运行时 _Time 驱动;静帧用 _Phase
    float k = TWO_PI / _WaveLength;
    float pin = smoothstep(0.0, 0.25, u) * u;      // 旗杆处钉死,越往外摆得越大
    // 主波沿 +x 传播,次波斜向传播,让波峰不那么整齐
    float w = sin(k * xz.x - t) + 0.35 * sin(k * 1.7 * (xz.x * 0.6 + xz.y) - t * 1.3);
    // 噪声:随时间平移,制造“阵风”造成的不规则起伏
    vec2 nuv = xz * 0.12 + vec2(-t * 0.05, t * 0.02);   // 低频采样:只要大尺度的起伏
    float n = textureLod(_NoiseTex, nuv, 0.0).r * 2.0 - 1.0;
    return (w + n * _NoiseStrength * 2.0) * _Amplitude * pin;
}
Assets/Cookbook/Ch17/Flag.shader(节选:deform)
void Deform(inout VAAttributes v)
{
    float3 p = v.positionOS.xyz;
    float h = FlagOffset(p.xz);

    if (_RecalcNormal > 0.5)
    {
        // 有限差分:在 x、z 方向各挪一小步,得到两个“切线”,叉乘就是新法线
        const float e = 0.04;   // 步长太小会放大噪声贴图的双线性折痕
        float hx = FlagOffset(p.xz + float2(e, 0));
        float hz = FlagOffset(p.xz + float2(0, e));
        float3 tangent   = float3(e, hx - h, 0);
        float3 bitangent = float3(0, hz - h, e);
        v.normalOS = normalize(cross(bitangent, tangent));
    }
    v.positionOS.y += h;
}

// 阴影 Pass 专用:_DeformShadow = 0 时不变形,用来演示“影子不动”的错误
void DeformForShadow(inout VAAttributes v)
{
    if (_DeformShadow > 0.5) Deform(v);
}
#define VA_DEFORM_SHADOW DeformForShadow
void Deform(inout VAAttributes v)
{
    vec3 p = v.positionOS.xyz;
    float h = FlagOffset(p.xz);

    if (_RecalcNormal > 0.5)
    {
        // 有限差分:在 x、z 方向各挪一小步,得到两个“切线”,叉乘就是新法线
        const float e = 0.04;   // 步长太小会放大噪声贴图的双线性折痕
        float hx = FlagOffset(p.xz + vec2(e, 0.0));
        float hz = FlagOffset(p.xz + vec2(0.0, e));
        vec3 tangent   = vec3(e, hx - h, 0.0);
        vec3 bitangent = vec3(0.0, hz - h, e);
        v.normalOS = normalize(cross(bitangent, tangent));
    }
    v.positionOS.y += h;
}

// 阴影 Pass 专用:_DeformShadow = 0 时不变形,用来演示“影子不动”的错误
void DeformForShadow(inout VAAttributes v)
{
    if (_DeformShadow > 0.5) Deform(v);
}
#define VA_DEFORM_SHADOW DeformForShadow
  • 叉乘顺序 cross(bitangent, tangent):对平坦的平面,bitangent = (0,0,ε)、tangent = (ε,0,0),叉乘得 (0, ε², 0),朝 +Y,与网格原法线一致。顺序反了法线就朝下,光照全反。
  • 步长 ε 取 0.04(网格边长 2 米、80 段,顶点间距 0.025 米)。步长太小,会把噪声贴图双线性插值留下的折痕放大成法线上的噪点;太大则丢失细节。经验值是与顶点间距同一数量级。
  • 噪声在顶点着色器里采样,必须用 SAMPLE_TEXTURE2D_LOD 显式指定 mip 0——顶点阶段没有屏幕空间导数,GPU 无法自动选择 mip。
  • 旗面双面可见,颜色 Pass 里用 IS_FRONT_VFACE 翻转背面法线(第 16 章案例 16.6)。
图 17-3左:只位移、不重算法线,几何在起伏(看旗面上的自阴影和右边缘轮廓),但明暗几乎是平的;中:有限差分重算法线,波峰波谷的明暗清楚;右:再叠加低频噪声,起伏不再规则
调参建议真实的旗帜在风中,靠近自由端的部分波长更短、摆动更剧烈,可以让 k 随 u 增大;旗子在大风中还会整体“缩短”(布料不可拉伸,起伏越大投影长度越短),可以按局部斜率把顶点向旗杆方向拉回一点。这些都只是在位移函数里多写一项,有限差分会自动处理法线。

案例 17.2阴影 Pass 同步顶点偏移入门单独打开 ↗

目标:直观看到“只在颜色 Pass 里做顶点动画”的后果,理解为什么必须在 ShadowCaster、DepthOnly 中做同样的变形。

原理:主光阴影贴图是在相机渲染之前,由 ShadowCaster Pass 从光源视角单独渲染的。如果 ShadowCaster 不做变形,阴影贴图里记录的就是原始的平面旗子:地面上的影子是一个规规矩矩的矩形,而且旗子自己接收的自阴影也是“平面旗子”投下的,与它实际的起伏完全无关。

Flag Shader 为演示这个错误,给阴影 Pass 准备了一个带开关的变形函数,通过 VA_DEFORM_SHADOW 宏替换公共文件里阴影 Pass 调用的函数(见上一节 deform 片段末尾)。实际项目里不需要这个开关,公共文件默认就让阴影 Pass 调用同一个 Deform。

图 17-4俯视旗帜与地面上的投影。左:阴影 Pass 不变形,影子是一个平整的矩形,与飘动的旗面毫无关系;右:阴影 Pass 与颜色 Pass 使用同一个 Deform,影子随旗面起伏

阴影 Pass 里的变形还有一个容易忽略的细节:ApplyShadowBias 的法线偏移(Normal Bias)使用的是变形后的法线。如果 Deform 只改位置不改法线,在起伏剧烈的地方法线偏移的方向是错的,可能产生阴影痤疮或漏光。本章的框架里 Deform 同时更新 normalOS,阴影 Pass 自然拿到正确的法线。

深入:其他需要同步的地方① DepthOnly / DepthNormals:开启了 Depth Texture、SSAO 或 Depth Priming 时它们会被执行;不同步会导致 SSAO 的暗角留在原位置、软粒子在错误的地方变淡。② MotionVectors:URP 14 有对象运动矢量 Pass(MotionVectorRenderPass 绘制 LightMode 为 MotionVectors 的 Pass,Shader 没有这个 Pass 时使用内置的回退材质)。回退材质不知道你的顶点动画,所以依赖运动矢量的效果(例如 TAA、运动模糊的对象模式)看到的是静止的物体。需要的话可以仿照 URP 的 ObjectMotionVectors.shader 自己写一个 MotionVectors Pass,在里面用当前帧和上一帧的时间各算一次 Deform。③ Meta Pass:烘焙光照贴图时用,顶点动画物体一般不参与烘焙,可以忽略。

案例 17.3草地风动进阶单独打开 ↗

目标:一大片草(6 万根,合并成一个网格)在世界空间风场中起伏:成片的阵风扫过草地,每根草又各有细小的摆动;根部不动、越往上越弯。

原理:草的顶点动画需要两类“每顶点数据”,这是顶点动画最常见的设计——美术在 DCC 软件里刷顶点色,或者程序生成网格时直接写入:

  • 弯曲权重:根部为 0、草尖为 1。写在顶点色 R 里。实际弯曲量按权重的平方分配,模拟草茎下部硬、上部软。
  • 根部位置:同一根草的所有顶点都存一份相同的根部坐标(UV1.xyz)。风场用根部的世界坐标采样,整根草拿到同一个风力值,才会整体弯曲而不是“扭成麻花”。
Assets/Cookbook/Ch17/GrassField.cs(节选:build)
public static Mesh Build(float size, int count, int seed = 7)
{
    var rnd = new System.Random(seed);
    float R() => (float)rnd.NextDouble();
    var v = new List<Vector3>(); var n = new List<Vector3>(); var c = new List<Color>();
    var uv = new List<Vector2>(); var uv1 = new List<Vector4>(); var idx = new List<int>();
    float[] hs = { 0f, 0.35f, 0.7f };                     // 三段的高度比例
    for (int b = 0; b < count; b++)
    {
        var root = new Vector3((R() - 0.5f) * size, 0, (R() - 0.5f) * size);
        float height = 0.35f + 0.35f * R();
        float width = 0.03f + 0.02f * R();
        float yaw = R() * Mathf.PI * 2f;
        var side = new Vector3(Mathf.Cos(yaw), 0, Mathf.Sin(yaw));      // 叶片宽度方向
        var face = Vector3.Cross(side, Vector3.up);                      // 叶片朝向
        float lean = (R() - 0.5f) * 0.25f;                               // 自然的前倾
        float random = R();
        int start = v.Count;
        for (int k = 0; k < 3; k++)
        {
            float h = hs[k], w = width * (1f - h * 0.8f);
            var center = root + Vector3.up * (h * height) + face * (lean * h * h * height);
            v.Add(center - side * w); v.Add(center + side * w);
            for (int s = 0; s < 2; s++)
            {
                n.Add(face); c.Add(new Color(h, random, 0, 1));
                uv.Add(new Vector2(s, h)); uv1.Add(new Vector4(root.x, root.y, root.z, height));
            }
        }
        v.Add(root + Vector3.up * height + face * (lean * height));      // 草尖
        n.Add(face); c.Add(new Color(1, random, 0, 1)); uv.Add(new Vector2(0.5f, 1)); uv1.Add(new Vector4(root.x, root.y, root.z, height));
        for (int k = 0; k < 2; k++)
        {
            int a = start + k * 2;
            idx.AddRange(new[] { a, a + 2, a + 1, a + 1, a + 2, a + 3 });
        }
        idx.AddRange(new[] { start + 4, start + 6, start + 5 });
    }
    var m = new Mesh { name = "GrassField", indexFormat = IndexFormat.UInt32 };
    m.SetVertices(v); m.SetNormals(n); m.SetColors(c); m.SetUVs(0, uv); m.SetUVs(1, uv1);
    m.SetTriangles(idx, 0);
    // 顶点会被风吹离原位:包围盒要留余量,否则摄像机边缘的草会被错误剔除
    m.bounds = new Bounds(new Vector3(0, 0.4f, 0), new Vector3(size + 1.5f, 2f, size + 1.5f));
    return m;
}

风场由两部分叠加:阵风是一张沿风向平移的噪声图(fBm),用根部世界坐标采样,相邻的草拿到相近的值,于是成片的草一起被压弯,一道道“风浪”扫过草地;摆动是每根草相位不同的小幅正弦,相位由每根草的随机数和它在风向上的位置决定。

Assets/Cookbook/Ch17/Grass.shader(节选:deform)
void Deform(inout VAAttributes v)
{
    float h = v.color.r;                 // 0 根 → 1 尖
    float rnd = v.color.g;
    float bladeHeight = v.uv1.w;
    float3 rootWS = TransformObjectToWorld(v.uv1.xyz);
    float2 wind = normalize(_WindDir.xy);
    float t = _Time.y * _WindSpeed + _Phase;

    // 1) 阵风:噪声图沿风向平移,用根部的世界坐标采样 → 整根草取同一个值,相邻的草相近
    float2 nuv = rootWS.xz * _WindScale - wind * t * 0.15;
    float gust = SAMPLE_TEXTURE2D_LOD(_WindNoise, sampler_WindNoise, nuv, 0).r;
    gust = smoothstep(0.3, 0.75, gust);

    // 2) 摆动:每根草相位不同的小幅正弦,避免整齐划一
    float sway = sin(t * 3.0 + rnd * TWO_PI + dot(rootWS.xz, wind) * 2.0) * _Sway;

    // 3) 弯曲:根部不动,按 h² 分配;水平位移 d 时高度大约降低 d²/2(保持叶长)
    float bend = (_WindStrength * (0.2 + gust) + sway) * h * h;
    float3 dirOS = mul((float3x3)GetWorldToObjectMatrix(), float3(wind.x, 0, wind.y));
    v.positionOS.xyz += dirOS * bend * bladeHeight;
    v.positionOS.y   -= 0.5 * bend * bend * bladeHeight;

    v.uv2 = float4(gust, 0, 0, 0);       // 把阵风强度交给片元,用来提亮被风压弯的草尖
}
void Deform(inout VAAttributes v)
{
    float h = v.color.r;                 // 0 根 → 1 尖
    float rnd = v.color.g;
    float bladeHeight = v.uv1.w;
    vec3 rootWS = TransformObjectToWorld(v.uv1.xyz);
    vec2 wind = normalize(_WindDir.xy);
    float t = _Time.y * _WindSpeed + _Phase;

    // 1) 阵风:噪声图沿风向平移,用根部的世界坐标采样 → 整根草取同一个值,相邻的草相近
    vec2 nuv = rootWS.xz * _WindScale - wind * t * 0.15;
    float gust = textureLod(_WindNoise, nuv, 0.0).r;
    gust = smoothstep(0.3, 0.75, gust);

    // 2) 摆动:每根草相位不同的小幅正弦,避免整齐划一
    float sway = sin(t * 3.0 + rnd * TWO_PI + dot(rootWS.xz, wind) * 2.0) * _Sway;

    // 3) 弯曲:根部不动,按 h² 分配;水平位移 d 时高度大约降低 d²/2(保持叶长)
    float bend = (_WindStrength * (0.2 + gust) + sway) * h * h;
    vec3 dirOS = mat3(unity_WorldToObject) * vec3(wind.x, 0.0, wind.y);   // HLSL:mul((float3x3)GetWorldToObjectMatrix(), dir)
    v.positionOS.xyz += dirOS * bend * bladeHeight;
    v.positionOS.y   -= 0.5 * bend * bend * bladeHeight;

    v.uv2 = vec4(gust, 0.0, 0.0, 0.0);   // 把阵风强度交给片元,用来提亮被风压弯的草尖
}

弯曲后高度略微下降:草尖水平移动 d 时,若保持叶长 H 不变,高度降低约 d²/(2H)(由 H − √(H² − d²) 的泰勒展开得到)。代码里 bend 是相对于草高的比例,所以写成 0.5 * bend * bend * bladeHeight。没有这一项,草被吹弯时会显得被“拉长”了。

Assets/Cookbook/Ch17/Grass.shader
// 草地风动:风场是一张在世界 XZ 上平移的噪声图(阵风),再叠加每根草相位不同的小幅摆动。
// 弯曲量按高度权重的平方分配:根部不动、越靠上越弯;同时略微压低高度,近似保持草叶长度。
Shader "Cookbook/Ch17/Grass"
{
    Properties
    {
        _RootColor ("Root Color", Color) = (0.12, 0.3, 0.08, 1)
        _TipColor ("Tip Color", Color) = (0.6, 0.8, 0.3, 1)
        _GustColor ("Gust Highlight", Color) = (0.95, 0.95, 0.6, 1)
        [NoScaleOffset] _WindNoise ("Wind Noise", 2D) = "gray" {}
        _WindDir ("Wind Direction (xz)", Vector) = (1, 0.3, 0, 0)
        _WindStrength ("Wind Strength", Range(0, 2)) = 0.8
        _WindScale ("Wind Noise Scale", Float) = 0.12
        _WindSpeed ("Wind Speed", Float) = 1
        _Sway ("Sway", Range(0, 0.5)) = 0.12
        _Phase ("Phase (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 _RootColor, _TipColor, _GustColor;
            float4 _WindDir;
            float _WindStrength, _WindScale, _WindSpeed, _Sway, _Phase;
        CBUFFER_END
        TEXTURE2D(_WindNoise); SAMPLER(sampler_WindNoise);
        #include "Assets/Cookbook/Ch17/VertexAnimCommon.hlsl"

        void Deform(inout VAAttributes v)
        {
            float h = v.color.r;                 // 0 根 → 1 尖
            float rnd = v.color.g;
            float bladeHeight = v.uv1.w;
            float3 rootWS = TransformObjectToWorld(v.uv1.xyz);
            float2 wind = normalize(_WindDir.xy);
            float t = _Time.y * _WindSpeed + _Phase;

            // 1) 阵风:噪声图沿风向平移,用根部的世界坐标采样 → 整根草取同一个值,相邻的草相近
            float2 nuv = rootWS.xz * _WindScale - wind * t * 0.15;
            float gust = SAMPLE_TEXTURE2D_LOD(_WindNoise, sampler_WindNoise, nuv, 0).r;
            gust = smoothstep(0.3, 0.75, gust);

            // 2) 摆动:每根草相位不同的小幅正弦,避免整齐划一
            float sway = sin(t * 3.0 + rnd * TWO_PI + dot(rootWS.xz, wind) * 2.0) * _Sway;

            // 3) 弯曲:根部不动,按 h² 分配;水平位移 d 时高度大约降低 d²/2(保持叶长)
            float bend = (_WindStrength * (0.2 + gust) + sway) * h * h;
            float3 dirOS = mul((float3x3)GetWorldToObjectMatrix(), float3(wind.x, 0, wind.y));
            v.positionOS.xyz += dirOS * bend * bladeHeight;
            v.positionOS.y   -= 0.5 * bend * bend * bladeHeight;

            v.uv2 = float4(gust, 0, 0, 0);       // 把阵风强度交给片元,用来提亮被风压弯的草尖
        }

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

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull Off
            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, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
            {
                float h = i.color.r, rnd = i.color.g;
                // 细长的草叶法线意义不大:往“上”方向偏,整片草地的明暗更柔和
                float3 N = normalize(normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0) + float3(0, 1.5, 0));
                half3 albedo = lerp(_RootColor.rgb, _TipColor.rgb, h) * (0.8h + 0.4h * rnd);
                albedo = lerp(albedo, _GustColor.rgb, i.extra.x * h * h * 0.6h);
                half ao = lerp(0.45h, 1.0h, h);   // 草根互相遮挡,简单压暗
                return half4(VALighting(albedo, N, i.positionWS, 0.1h, 16.0h) * ao, 1);
            }
            ENDHLSL
        }

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

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R Cull Off
            HLSLPROGRAM
            #pragma vertex VADepthVert
            #pragma fragment VANullFrag
            ENDHLSL
        }
    }
}
// 草地风动:风场是一张在世界 XZ 上平移的噪声图(阵风),再叠加每根草相位不同的小幅摆动。(GLSL 版)
// 弯曲量按高度权重的平方分配:根部不动、越靠上越弯;同时略微压低高度,近似保持草叶长度。
Shader "Cookbook/Ch17/Grass"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _RootColor, _TipColor, _GustColor;
        uniform vec4 _WindDir;
        uniform float _WindStrength, _WindScale, _WindSpeed, _Sway, _Phase;
        uniform sampler2D _WindNoise;
        #include "Ch17/VertexAnimCommon.glsl"

        void Deform(inout VAAttributes v)
        {
            float h = v.color.r;                 // 0 根 → 1 尖
            float rnd = v.color.g;
            float bladeHeight = v.uv1.w;
            vec3 rootWS = TransformObjectToWorld(v.uv1.xyz);
            vec2 wind = normalize(_WindDir.xy);
            float t = _Time.y * _WindSpeed + _Phase;

            // 1) 阵风:噪声图沿风向平移,用根部的世界坐标采样 → 整根草取同一个值,相邻的草相近
            vec2 nuv = rootWS.xz * _WindScale - wind * t * 0.15;
            float gust = textureLod(_WindNoise, nuv, 0.0).r;
            gust = smoothstep(0.3, 0.75, gust);

            // 2) 摆动:每根草相位不同的小幅正弦,避免整齐划一
            float sway = sin(t * 3.0 + rnd * TWO_PI + dot(rootWS.xz, wind) * 2.0) * _Sway;

            // 3) 弯曲:根部不动,按 h² 分配;水平位移 d 时高度大约降低 d²/2(保持叶长)
            float bend = (_WindStrength * (0.2 + gust) + sway) * h * h;
            vec3 dirOS = mat3(unity_WorldToObject) * vec3(wind.x, 0.0, wind.y);   // HLSL:mul((float3x3)GetWorldToObjectMatrix(), dir)
            v.positionOS.xyz += dirOS * bend * bladeHeight;
            v.positionOS.y   -= 0.5 * bend * bend * bladeHeight;

            v.uv2 = vec4(gust, 0.0, 0.0, 0.0);   // 把阵风强度交给片元,用来提亮被风压弯的草尖
        }

        #include "Ch17/VertexAnimPasses.glsl"
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull Off
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VAForwardVert(); }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                float h = vertColor.r, rnd = vertColor.g;
                // 细长的草叶法线意义不大:往“上”方向偏,整片草地的明暗更柔和
                vec3 N = normalize(normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0) + vec3(0.0, 1.5, 0.0));
                vec3 albedo = mix(_RootColor.rgb, _TipColor.rgb, h) * (0.8 + 0.4 * rnd);
                albedo = mix(albedo, _GustColor.rgb, extra.x * h * h * 0.6);
                float ao = mix(0.45, 1.0, h);   // 草根互相遮挡,简单压暗
                fragColor = vec4(VALighting(albedo, N, positionWS, 0.1, 16.0) * ao, 1.0);
            }
            #endif
            ENDGLSL
        }

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

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

着色上做了三个风格化处理:法线向上偏(细长草叶的几何法线意义不大,向上偏能让整片草地的明暗更柔和统一);根部压暗模拟草丛的相互遮挡;被阵风压弯的草尖略微提亮(真实草地上,被风压倒的草叶露出更亮的背面),阵风强度由 Deform 写入 uv2.x 传给片元。

图 17-56 万根草(合并为一个网格)。左:无风;右:风从左向右吹,近处成片的草被阵风压弯、草尖提亮,远处可以看到阵风的明暗条带
常见坑① 草的包围盒必须预留弯曲的余量(GrassField.Build 里手动扩大了 bounds)。② 一个合并的大网格会被整体剔除或整体保留,看不见的部分也在消耗顶点着色器;大面积草地应该分块,或者用 GPU Instancing 每根(每簇)一个实例——第 20 章会讲 Graphics.RenderMeshInstanced 方案和角色压草的交互。③ 风向在 C# 里设置成全局 Shader 变量(Shader.SetGlobalVector)而不是每个材质一份,所有植被才能共享同一个风场。

案例 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)。

案例 17.5膨胀与呼吸入门单独打开 ↗

目标:让物体整体膨胀收缩(呼吸、心跳、充气),以及一道从下往上传播的“脉冲”;理解硬边模型膨胀时的裂缝问题。

原理:沿法线把顶点推出去,positionOS += normalOS * amount,就是最简单的膨胀。这也是第 14 章背面外扩描边的同一个操作。呼吸只需要让 amount 随时间做正弦;在相位里减去 positionOS.y * _PulseFreq,不同高度的相位就不同,形成一道沿 Y 方向传播的鼓包。

问题出在硬边模型上。立方体的一个角在网格里其实是 3 个位置相同的顶点(分属三个面),它们的法线分别指向三个面的外侧。沿各自的法线推出去,三个顶点就分开了,立方体裂成六块分离的面片。解决思路是让同一位置的顶点拿到同一个方向:

  • 径向方向:用“从物体中心指向顶点”的方向 normalize(positionOS)。同位置 → 同方向,不会裂开。只适用于大致凸、中心在原点的形体(球、方块、胶囊),对纽结这种形体就完全不对了。
  • 平滑法线:预先把“同位置顶点法线的平均值”烘焙进顶点色或某个 UV 通道(第 14 章为描边写过这个烘焙工具),膨胀时用它。这是最通用的做法。
Assets/Cookbook/Ch17/Inflate.shader(节选:deform)
void Deform(inout VAAttributes v)
{
#if defined(_DIR_RADIAL)
    float3 dir = normalize(v.positionOS.xyz);       // 同一位置 → 同一方向,硬边不裂
#else
    float3 dir = v.normalOS;
#endif
    // 呼吸:0..1 之间往复;_PulseFreq > 0 时不同高度相位不同,形成向上传播的脉冲
    float t = _Time.y * _Speed + _Phase;
    float breath = sin(t - v.positionOS.y * _PulseFreq) * 0.5 + 0.5;
    v.positionOS.xyz += dir * _Amount * breath;
    // 均匀膨胀不改变光滑表面的法线方向,这里保持原法线
}
void Deform(inout VAAttributes v)
{
#if defined(_DIR_RADIAL)
    vec3 dir = normalize(v.positionOS.xyz);       // 同一位置 → 同一方向,硬边不裂
#else
    vec3 dir = v.normalOS;
#endif
    // 呼吸:0..1 之间往复;_PulseFreq > 0 时不同高度相位不同,形成向上传播的脉冲
    float t = _Time.y * _Speed + _Phase;
    float breath = sin(t - v.positionOS.y * _PulseFreq) * 0.5 + 0.5;
    v.positionOS.xyz += dir * _Amount * breath;
    // 均匀膨胀不改变光滑表面的法线方向,这里保持原法线
}

推动方向用 [KeywordEnum(Normal, Radial)] 切换,变体只在顶点阶段生效(shader_feature_local_vertex)。注意 KeywordEnum 和 #pragma 写在 HLSLINCLUDE 里,三个 Pass 共享同一套变体,阴影和深度跟着一起切换。

Assets/Cookbook/Ch17/Inflate.shader
// 膨胀 / 呼吸:沿某个方向把顶点推出去。
//   方向 = 网格法线:最简单,但硬边模型(立方体)的同一位置有多个法线不同的顶点,推开后会裂缝;
//   方向 = 从中心指向顶点:同一位置的顶点方向相同,硬边不会裂开(适合大致凸的形体)。
// 呼吸:振幅随时间做正弦,相位再加上沿 Y 的偏移,就成了一道从下往上走的“脉冲”。
Shader "Cookbook/Ch17/Inflate"
{
    Properties
    {
        _BaseColor ("Color", Color) = (0.85, 0.45, 0.4, 1)
        _Amount ("Inflate Amount", Range(-0.2, 0.5)) = 0.08
        [KeywordEnum(Normal, Radial)] _Dir ("Push Direction", Float) = 0
        _PulseFreq ("Pulse Spatial Frequency", Float) = 0
        _Speed ("Breath Speed", Float) = 2
        _Phase ("Phase (for stills)", Float) = 1.5708
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #pragma shader_feature_local_vertex _DIR_NORMAL _DIR_RADIAL
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            float _Amount, _PulseFreq, _Speed, _Phase;
        CBUFFER_END
        #include "Assets/Cookbook/Ch17/VertexAnimCommon.hlsl"

        void Deform(inout VAAttributes v)
        {
        #if defined(_DIR_RADIAL)
            float3 dir = normalize(v.positionOS.xyz);       // 同一位置 → 同一方向,硬边不裂
        #else
            float3 dir = v.normalOS;
        #endif
            // 呼吸:0..1 之间往复;_PulseFreq > 0 时不同高度相位不同,形成向上传播的脉冲
            float t = _Time.y * _Speed + _Phase;
            float breath = sin(t - v.positionOS.y * _PulseFreq) * 0.5 + 0.5;
            v.positionOS.xyz += dir * _Amount * breath;
            // 均匀膨胀不改变光滑表面的法线方向,这里保持原法线
        }

        #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
            {
                return half4(VALighting(_BaseColor.rgb, normalize(i.normalWS), i.positionWS, 0.5h, 48.0h), 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
        }
    }
}
// 膨胀 / 呼吸:沿某个方向把顶点推出去。(GLSL 版)
//   方向 = 网格法线:最简单,但硬边模型(立方体)的同一位置有多个法线不同的顶点,推开后会裂缝;
//   方向 = 从中心指向顶点:同一位置的顶点方向相同,硬边不会裂开(适合大致凸的形体)。
// 呼吸:振幅随时间做正弦,相位再加上沿 Y 的偏移,就成了一道从下往上走的“脉冲”。
Shader "Cookbook/Ch17/Inflate"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        #pragma shader_feature_local_vertex _DIR_NORMAL _DIR_RADIAL
        uniform vec4 _BaseColor;
        uniform float _Amount, _PulseFreq, _Speed, _Phase;
        #include "Ch17/VertexAnimCommon.glsl"

        void Deform(inout VAAttributes v)
        {
        #if defined(_DIR_RADIAL)
            vec3 dir = normalize(v.positionOS.xyz);       // 同一位置 → 同一方向,硬边不裂
        #else
            vec3 dir = v.normalOS;
        #endif
            // 呼吸:0..1 之间往复;_PulseFreq > 0 时不同高度相位不同,形成向上传播的脉冲
            float t = _Time.y * _Speed + _Phase;
            float breath = sin(t - v.positionOS.y * _PulseFreq) * 0.5 + 0.5;
            v.positionOS.xyz += dir * _Amount * breath;
            // 均匀膨胀不改变光滑表面的法线方向,这里保持原法线
        }

        #include "Ch17/VertexAnimPasses.glsl"
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VAForwardVert(); }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                fragColor = vec4(VALighting(_BaseColor.rgb, normalize(normalWS), positionWS, 0.5, 48.0), 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
        }
    }
}
图 17-8从左到右:原始纽结;沿法线膨胀 0.07;沿 Y 方向传播的脉冲(不同高度膨胀程度不同,纽结上出现鼓包);两个立方体,左边沿网格法线膨胀而裂成六块,右边沿径向膨胀保持闭合
常见坑① 膨胀量较大时,凹处的表面会互相穿插(纽结管子相交处),这是“沿法线位移”的固有局限。② 均匀膨胀不改变光滑曲面的法线方向,所以这里没有重算法线;但脉冲这种不均匀的位移会让表面倾斜,严格说应当重算(可以用有限差分沿切线方向取邻点),幅度小时可以忽略。③ 阴影 Pass 也要用同样的方向(我们的框架天然保证),否则裂缝会出现在影子里。

案例 17.6碎裂与吸附重组进阶单独打开 ↗

目标:物体沿某个方向逐渐碎成三角形碎片:碎片绕自身中心旋转、沿面法线飞出、受“重力”下落并缩小,刚脱落时发出橙色辉光;把进度反过来播放,就是碎片飞回来吸附成形。

原理:要让一个三角形作为一个整体运动,三个顶点必须知道“我属于哪块碎片”——碎片的中心、随机数、面法线。和重心坐标线框一样,这要求先把共享顶点拆开,再把每个三角形的公共数据写进它的三个顶点:

Assets/Cookbook/Ch17/ShatterMesh.cs(节选:build)
public static Mesh Build(Mesh src, int seed = 3, float boundsPadding = 3f)
{
    var rnd = new System.Random(seed);
    var sv = src.vertices; var sn = src.normals; var suv = src.uv; var tris = src.triangles;
    int n = tris.Length;
    var v = new Vector3[n]; var nr = new Vector3[n]; var uv = new Vector2[n];
    var uv1 = new Vector4[n]; var uv2 = new Vector4[n]; var idx = new int[n];
    for (int t = 0; t < n; t += 3)
    {
        Vector3 a = sv[tris[t]], b = sv[tris[t + 1]], c = sv[tris[t + 2]];
        Vector3 center = (a + b + c) / 3f;
        Vector3 face = Vector3.Cross(b - a, c - a).normalized;     // Unity 的绕序下这就是朝外的面法线
        float r = (float)rnd.NextDouble();
        for (int k = 0; k < 3; k++)
        {
            int i = t + k, s = tris[i];
            v[i] = sv[s]; nr[i] = sn.Length > 0 ? sn[s] : face; uv[i] = suv.Length > 0 ? suv[s] : Vector2.zero;
            uv1[i] = new Vector4(center.x, center.y, center.z, r);
            uv2[i] = new Vector4(face.x, face.y, face.z, 0);
            idx[i] = i;
        }
    }
    var m = new Mesh { name = src.name + "_Shatter", indexFormat = IndexFormat.UInt32 };
    m.vertices = v; m.normals = nr; m.uv = uv; m.SetUVs(1, uv1); m.SetUVs(2, uv2); m.triangles = idx;
    m.RecalculateBounds();
    var bounds = m.bounds; bounds.Expand(boundsPadding);   // 碎片会飞出原包围盒
    m.bounds = bounds;
    return m;
}

Shader 中每块碎片有自己的局部进度 p:碎片中心在扫描方向上越靠前,开始得越早,再加一点随机,“裂解前沿”就有了参差感。然后依次做三件事:

  1. 旋转并缩小:把顶点相对碎片中心的偏移,绕一根随机轴旋转 p × _Spin 弧度,再乘以 (1 − p × _Shrink)。旋转矩阵用 Core RP 的 RotationFromAxisAngle(GeometricTools.hlsl,即罗德里格斯旋转公式的矩阵形式)。
  2. 飞出:沿面法线移动,距离随随机数浮动;再加一个抛物线:先略微上抛,再按 p² 下落。
  3. 法线:碎片是刚体旋转,法线乘同一个旋转矩阵即可,不需要重算。
Assets/Cookbook/Ch17/Shatter.shader(节选:deform)
void Deform(inout VAAttributes v)
{
    float3 center = v.uv1.xyz;
    float rnd = v.uv1.w;
    float3 faceN = v.uv2.xyz;

    // 1) 这块碎片自己的进度 p:沿扫描方向越靠前越早开始,再加一点随机
    // 物体空间坐标大致在 -1..1,映射到 0..1 后乘以 _Spread
    float start = saturate(dot(center, normalize(_SweepDir.xyz)) * 0.5 + 0.5) * _Spread + rnd * 0.2;
    float p = saturate(_Progress * (1.0 + _Spread + 0.2) - start);
    float ease = p * p * (3.0 - 2.0 * p);                  // smoothstep 曲线,起步柔和

    // 2) 绕碎片中心、绕一根随机轴旋转,并逐渐缩小
    float3 axis = normalize(float3(rnd - 0.5, frac(rnd * 7.13) - 0.5, frac(rnd * 3.71) - 0.5) + 1e-4);
    float angle = ease * _Spin * (0.5 + rnd);
    float3x3 R = RotationFromAxisAngle(axis, sin(angle), cos(angle));
    float3 local = mul(R, v.positionOS.xyz - center) * (1.0 - ease * _Shrink);

    // 3) 沿面法线飞出 + 抛物线下落(物体空间的 -Y)
    float3 offset = faceN * ease * _Distance * (0.6 + rnd) + float3(0, 1, 0) * (0.3 * ease - _Gravity * ease * ease);

    v.positionOS.xyz = center + local + offset;
    v.normalOS = mul(R, v.normalOS);                     // 法线跟着碎片一起转
    v.uv2.w = p;                                         // 进度交给片元做发光
}
void Deform(inout VAAttributes v)
{
    vec3 center = v.uv1.xyz;
    float rnd = v.uv1.w;
    vec3 faceN = v.uv2.xyz;

    // 1) 这块碎片自己的进度 p:沿扫描方向越靠前越早开始,再加一点随机
    // 物体空间坐标大致在 -1..1,映射到 0..1 后乘以 _Spread
    float start = saturate(dot(center, normalize(_SweepDir.xyz)) * 0.5 + 0.5) * _Spread + rnd * 0.2;
    float p = saturate(_Progress * (1.0 + _Spread + 0.2) - start);
    float ease = p * p * (3.0 - 2.0 * p);                  // smoothstep 曲线,起步柔和

    // 2) 绕碎片中心、绕一根随机轴旋转,并逐渐缩小
    vec3 axis = normalize(vec3(rnd - 0.5, fract(rnd * 7.13) - 0.5, fract(rnd * 3.71) - 0.5) + 1e-4);
    float angle = ease * _Spin * (0.5 + rnd);
    mat3 R = RotationFromAxisAngle(axis, sin(angle), cos(angle));
    vec3 local = (R * (v.positionOS.xyz - center)) * (1.0 - ease * _Shrink);

    // 3) 沿面法线飞出 + 抛物线下落(物体空间的 -Y)
    vec3 offset = faceN * ease * _Distance * (0.6 + rnd) + vec3(0.0, 1.0, 0.0) * (0.3 * ease - _Gravity * ease * ease);

    v.positionOS.xyz = center + local + offset;
    v.normalOS = R * v.normalOS;                         // 法线跟着碎片一起转
    v.uv2.w = p;                                         // 进度交给片元做发光
}

进度 p 先经过 smoothstep 曲线 p²(3 − 2p) 再用于运动,起步和结束都很柔和;原始的 p 通过 uv2.w 传给片元,用 saturate(p·6)·(1 − p) 做出“刚脱落时最亮、飞远后冷却”的辉光。

Assets/Cookbook/Ch17/Shatter.shader
// 碎裂与吸附重组:每个三角形是一块碎片,围绕自己的中心旋转、沿面法线飞出并受“重力”下落。
// _Progress 从 0 到 1 是碎裂;反过来从 1 到 0 播放,就是碎片飞回来“吸附”成形。
// 碎片开始运动的时间由它在 _SweepDir 方向上的位置决定,形成一道扫过模型的“裂解前沿”。
Shader "Cookbook/Ch17/Shatter"
{
    Properties
    {
        _BaseColor ("Color", Color) = (0.75, 0.78, 0.85, 1)
        [HDR] _GlowColor ("Glow Color", Color) = (2.5, 0.9, 0.3, 1)
        _Progress ("Progress", Range(0, 1)) = 0.4
        _SweepDir ("Sweep Direction", Vector) = (0, 1, 0, 0)
        _Spread ("Sweep Spread", Range(0, 2)) = 1
        _Distance ("Fly Distance", Range(0, 3)) = 0.8
        _Spin ("Spin (radians)", Range(0, 20)) = 8
        _Gravity ("Gravity", Range(0, 3)) = 0.6
        _Shrink ("Shrink", Range(0, 1)) = 0.6
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/GeometricTools.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor, _GlowColor;
            float4 _SweepDir;
            float _Progress, _Spread, _Distance, _Spin, _Gravity, _Shrink;
        CBUFFER_END
        #include "Assets/Cookbook/Ch17/VertexAnimCommon.hlsl"

        void Deform(inout VAAttributes v)
        {
            float3 center = v.uv1.xyz;
            float rnd = v.uv1.w;
            float3 faceN = v.uv2.xyz;

            // 1) 这块碎片自己的进度 p:沿扫描方向越靠前越早开始,再加一点随机
            // 物体空间坐标大致在 -1..1,映射到 0..1 后乘以 _Spread
            float start = saturate(dot(center, normalize(_SweepDir.xyz)) * 0.5 + 0.5) * _Spread + rnd * 0.2;
            float p = saturate(_Progress * (1.0 + _Spread + 0.2) - start);
            float ease = p * p * (3.0 - 2.0 * p);                  // smoothstep 曲线,起步柔和

            // 2) 绕碎片中心、绕一根随机轴旋转,并逐渐缩小
            float3 axis = normalize(float3(rnd - 0.5, frac(rnd * 7.13) - 0.5, frac(rnd * 3.71) - 0.5) + 1e-4);
            float angle = ease * _Spin * (0.5 + rnd);
            float3x3 R = RotationFromAxisAngle(axis, sin(angle), cos(angle));
            float3 local = mul(R, v.positionOS.xyz - center) * (1.0 - ease * _Shrink);

            // 3) 沿面法线飞出 + 抛物线下落(物体空间的 -Y)
            float3 offset = faceN * ease * _Distance * (0.6 + rnd) + float3(0, 1, 0) * (0.3 * ease - _Gravity * ease * ease);

            v.positionOS.xyz = center + local + offset;
            v.normalOS = mul(R, v.normalOS);                     // 法线跟着碎片一起转
            v.uv2.w = p;                                         // 进度交给片元做发光
        }

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

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull Off    // 碎片翻转后会露出背面
            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, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
            {
                float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0);
                half3 col = VALighting(_BaseColor.rgb, N, i.positionWS, 0.6h, 64.0h);
                // 刚脱离的碎片最亮,飞远后逐渐冷却
                half p = i.extra.w;
                half glow = saturate(p * 6.0h) * (1.0h - p);
                return half4(col + _GlowColor.rgb * glow, 1);
            }
            ENDHLSL
        }

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

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R Cull Off
            HLSLPROGRAM
            #pragma vertex VADepthVert
            #pragma fragment VANullFrag
            ENDHLSL
        }
    }
}
// 碎裂与吸附重组:每个三角形是一块碎片,围绕自己的中心旋转、沿面法线飞出并受“重力”下落。(GLSL 版)
// _Progress 从 0 到 1 是碎裂;反过来从 1 到 0 播放,就是碎片飞回来“吸附”成形。
// 碎片开始运动的时间由它在 _SweepDir 方向上的位置决定,形成一道扫过模型的“裂解前沿”。
Shader "Cookbook/Ch17/Shatter"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseColor, _GlowColor;
        uniform vec4 _SweepDir;
        uniform float _Progress, _Spread, _Distance, _Spin, _Gravity, _Shrink;
        #include "Ch17/VertexAnimCommon.glsl"

        // URP Core 的 GeometricTools.hlsl 里的 RotationFromAxisAngle(绕单位轴 A 旋转)。
        // HLSL 的 float3x3(...) 按行构造,GLSL 的 mat3(...) 按列构造,所以外面套一个 transpose
        mat3 RotationFromAxisAngle(vec3 A, float sinAngle, float cosAngle)
        {
            float c = cosAngle, s = sinAngle;
            return transpose(mat3(
                A.x * A.x * (1.0 - c) + c,       A.x * A.y * (1.0 - c) - A.z * s, A.x * A.z * (1.0 - c) + A.y * s,
                A.x * A.y * (1.0 - c) + A.z * s, A.y * A.y * (1.0 - c) + c,       A.y * A.z * (1.0 - c) - A.x * s,
                A.x * A.z * (1.0 - c) - A.y * s, A.y * A.z * (1.0 - c) + A.x * s, A.z * A.z * (1.0 - c) + c));
        }

        void Deform(inout VAAttributes v)
        {
            vec3 center = v.uv1.xyz;
            float rnd = v.uv1.w;
            vec3 faceN = v.uv2.xyz;

            // 1) 这块碎片自己的进度 p:沿扫描方向越靠前越早开始,再加一点随机
            // 物体空间坐标大致在 -1..1,映射到 0..1 后乘以 _Spread
            float start = saturate(dot(center, normalize(_SweepDir.xyz)) * 0.5 + 0.5) * _Spread + rnd * 0.2;
            float p = saturate(_Progress * (1.0 + _Spread + 0.2) - start);
            float ease = p * p * (3.0 - 2.0 * p);                  // smoothstep 曲线,起步柔和

            // 2) 绕碎片中心、绕一根随机轴旋转,并逐渐缩小
            vec3 axis = normalize(vec3(rnd - 0.5, fract(rnd * 7.13) - 0.5, fract(rnd * 3.71) - 0.5) + 1e-4);
            float angle = ease * _Spin * (0.5 + rnd);
            mat3 R = RotationFromAxisAngle(axis, sin(angle), cos(angle));
            vec3 local = (R * (v.positionOS.xyz - center)) * (1.0 - ease * _Shrink);

            // 3) 沿面法线飞出 + 抛物线下落(物体空间的 -Y)
            vec3 offset = faceN * ease * _Distance * (0.6 + rnd) + vec3(0.0, 1.0, 0.0) * (0.3 * ease - _Gravity * ease * ease);

            v.positionOS.xyz = center + local + offset;
            v.normalOS = R * v.normalOS;                         // 法线跟着碎片一起转
            v.uv2.w = p;                                         // 进度交给片元做发光
        }

        #include "Ch17/VertexAnimPasses.glsl"
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull Off    // 碎片翻转后会露出背面
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VAForwardVert(); }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0);
                vec3 col = VALighting(_BaseColor.rgb, N, positionWS, 0.6, 64.0);
                // 刚脱离的碎片最亮,飞远后逐渐冷却
                float p = extra.w;
                float glow = saturate(p * 6.0) * (1.0 - p);
                fragColor = vec4(col + _GlowColor.rgb * glow, 1.0);
            }
            #endif
            ENDGLSL
        }

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

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R Cull Off
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VADepthVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { VANullFrag(); }
            #endif
            ENDGLSL
        }
    }
}
图 17-9碎裂进度 0、0.3、0.55。圆环(约 1.2 万个三角形)沿竖直方向扫描,碎成细密的碎屑;球体(Unity 内置球)沿 X 方向扫描,碎片更大。刚脱落的碎片发出橙色辉光,碎片投下的阴影也同步运动
吸附重组把 _Progress 从 1 播到 0,碎片就会从四面八方飞回来,在扫描前沿依次“吸附”到原位——这正是很多游戏里“传送出现”“装备合成”的效果。配合第 18 章的溶解边缘发光,可以做出更丰富的过渡。如果希望碎片“吸附”时有一个过冲回弹,把 smoothstep 换成带回弹的缓动函数即可(例如 easeOutBack)。
常见坑① 碎片大小由原网格的三角形决定:Unity 胶囊体的柱身三角形又细又长,碎出来像一根根针;想要块状碎片,用 Voronoi 预切分(在 DCC 或 C# 里把网格切成凸块,每块写同一个中心)。② 拆开共享顶点后顶点数变成三角形数 × 3,对高面数模型要注意内存。③ 包围盒要扩大(ShatterMesh.Build 默认四周扩 3 米),否则碎片飞出原包围盒后整个物体可能被剔除。④ Cull Off:碎片翻滚时会露出背面。

案例 17.7VAT:顶点动画贴图高阶单独打开 ↗

目标:把一段“任意复杂”的逐顶点动画烘焙进两张贴图,运行时由 Shader 回放;同一份数据可以被多个实例以不同的帧偏移播放。

原理:前面的效果都是“用公式算出位置”,但很多动画根本没有简单的公式:布料模拟、刚体破碎、流体、复杂的变形动画。VAT(Vertex Animation Texture)的思路是:既然算不出来,就预先记录——离线把每一帧每个顶点的位置(和法线)写进贴图,横向是顶点编号,纵向是帧号。运行时每个顶点用“自己的编号”和“当前帧号”去查表,CPU 端既不需要骨骼也不需要每帧上传网格。

u = (顶点编号 + 0.5) / 顶点数 → v = (帧 + 0.5) / 帧数 → P(i,f) 位置贴图:RGB = 第 f 帧第 i 个顶点的位置法线贴图:RGB = 对应的法线 格式:RGBAHalf(16 位浮点)过滤:Point;不压缩、无 mip、线性 网格 uv1.x 存顶点编号对应的 uShader 按帧号算 v,相邻两帧插值
图 17-10VAT 的贴图布局:一列是一个顶点的“时间线”,一行是一帧的“全部顶点”

本书不依赖外部工具(实际项目里通常由 Houdini 的 VAT 导出工具或 Blender 插件生成),所以烘焙器也是 C# 写的:输入基础网格和一个“给定帧号返回全部顶点位置”的函数,输出两张贴图和一个写好顶点编号的网格:

Assets/Cookbook/Ch17/VatBaker.cs(节选:bake)
public static Result Bake(Mesh baseMesh, int frameCount, Func<int, Vector3[]> framePositions)
{
    int vc = baseMesh.vertexCount;
    var posTex = new Texture2D(vc, frameCount, TextureFormat.RGBAHalf, false, true)
    { name = "VAT_Position", filterMode = FilterMode.Point, wrapMode = TextureWrapMode.Clamp };
    var nrmTex = new Texture2D(vc, frameCount, TextureFormat.RGBAHalf, false, true)
    { name = "VAT_Normal", filterMode = FilterMode.Point, wrapMode = TextureWrapMode.Clamp };

    var tmp = UnityEngine.Object.Instantiate(baseMesh);        // 只用来逐帧重算法线
    var pos = new Color[vc * frameCount]; var nrm = new Color[vc * frameCount];
    var bounds = new Bounds(baseMesh.bounds.center, Vector3.zero);
    for (int f = 0; f < frameCount; f++)
    {
        var p = framePositions(f);
        tmp.vertices = p;
        tmp.RecalculateNormals();
        var n = tmp.normals;
        for (int i = 0; i < vc; i++)
        {
            pos[f * vc + i] = new Color(p[i].x, p[i].y, p[i].z, 1);   // 行 = 帧,列 = 顶点
            nrm[f * vc + i] = new Color(n[i].x, n[i].y, n[i].z, 0);
            bounds.Encapsulate(p[i]);
        }
    }
    UnityEngine.Object.DestroyImmediate(tmp);
    posTex.SetPixels(pos); posTex.Apply(false);
    nrmTex.SetPixels(nrm); nrmTex.Apply(false);

    // 网格:拓扑不变,额外写入顶点编号对应的 u 坐标(采样纹素中心,所以 +0.5)
    var mesh = UnityEngine.Object.Instantiate(baseMesh);
    mesh.name = baseMesh.name + "_VAT";
    var uv1 = new Vector2[vc];
    for (int i = 0; i < vc; i++) uv1[i] = new Vector2((i + 0.5f) / vc, 0);
    mesh.uv2 = uv1;                                                // uv2 即 TEXCOORD1
    mesh.bounds = bounds;                                          // 包围盒要覆盖所有帧
    return new Result { mesh = mesh, position = posTex, normal = nrmTex };
}
  • 法线每帧用 RecalculateNormals 重算后一并存入。也可以不存法线、在 Shader 里用相邻顶点重算,但 VAT 里“相邻顶点”的编号关系不好查,存下来最省事。
  • 顶点编号写进 mesh.uv2——Unity 的命名里 uv 是 TEXCOORD0,uv2 是 TEXCOORD1,这是个经典的差一陷阱。采样坐标要对准纹素中心,所以是 (i + 0.5)/N。
  • 包围盒取所有帧所有顶点的并集,保证任何一帧都不会被错误剔除。

演示动画是一根程序化的“触手”:40 环 × 21 列共 861 个顶点,48 帧一个循环,弯曲波从根部传向尖端,同时绕自身轴扭转(VatBaker.TentacleFrame)。在真实项目里,这一步换成读取模拟软件导出的逐帧数据即可,Shader 完全不用变:

Assets/Cookbook/Ch17/VAT.shader(节选:deform)
void Deform(inout VAAttributes v)
{
    float frames = _PosTex_TexelSize.w;
    float f = fmod(_Time.y * _FPS + _Frame, frames);
    float f0 = floor(f);
    float f1 = fmod(f0 + 1.0, frames);       // 最后一帧插值回第一帧,循环无缝
    float w = f - f0;

    // 采样纹素中心;必须用点采样 + LOD 0,否则相邻顶点/相邻帧会被双线性混在一起
    float2 uv0 = float2(v.uv1.x, (f0 + 0.5) / frames);
    float2 uv1 = float2(v.uv1.x, (f1 + 0.5) / frames);
    float3 p0 = SAMPLE_TEXTURE2D_LOD(_PosTex, sampler_PointClamp, uv0, 0).xyz;
    float3 p1 = SAMPLE_TEXTURE2D_LOD(_PosTex, sampler_PointClamp, uv1, 0).xyz;
    float3 n0 = SAMPLE_TEXTURE2D_LOD(_NrmTex, sampler_PointClamp, uv0, 0).xyz;
    float3 n1 = SAMPLE_TEXTURE2D_LOD(_NrmTex, sampler_PointClamp, uv1, 0).xyz;

    v.positionOS.xyz = lerp(p0, p1, w);      // 贴图里存的是绝对位置,直接替换
    v.normalOS = normalize(lerp(n0, n1, w));
}
void Deform(inout VAAttributes v)
{
    float frames = _PosTex_TexelSize.w;
    float f = fmodHLSL(_Time.y * _FPS + _Frame, frames);
    float f0 = floor(f);
    float f1 = fmodHLSL(f0 + 1.0, frames);   // 最后一帧插值回第一帧,循环无缝
    float w = f - f0;

    // 采样纹素中心;必须用点采样 + LOD 0,否则相邻顶点/相邻帧会被双线性混在一起
    vec2 uv0 = vec2(v.uv1.x, (f0 + 0.5) / frames);
    vec2 uv1 = vec2(v.uv1.x, (f1 + 0.5) / frames);
    vec3 p0 = SamplePoint(_PosTex, uv0).xyz;
    vec3 p1 = SamplePoint(_PosTex, uv1).xyz;
    vec3 n0 = SamplePoint(_NrmTex, uv0).xyz;
    vec3 n1 = SamplePoint(_NrmTex, uv1).xyz;

    v.positionOS.xyz = mix(p0, p1, w);       // 贴图里存的是绝对位置,直接替换
    v.normalOS = normalize(mix(n0, n1, w));
}
  • _PosTex_TexelSize.w 是贴图高度,也就是帧数,Unity 会自动填充 _TexelSize 变量。
  • 必须用点采样(sampler_PointClamp,Core RP 的全局采样器)和 LOD 0:双线性过滤会把相邻顶点(横向)和相邻帧(纵向)的数据混在一起。帧间插值我们自己在 Shader 里用 lerp 做,可控且正确。
  • 帧号对帧数取模,最后一帧插值回第一帧,循环无缝——前提是烘焙的动画本身首尾相接。
Assets/Cookbook/Ch17/VAT.shader
// 播放 VAT:顶点用 uv1.x(自己的编号)+ 当前帧号去查位置/法线贴图,相邻两帧之间线性插值。
// 帧号 = _Time.y * _FPS + _Frame;静帧时 _Time≈0,直接用 _Frame 指定。
Shader "Cookbook/Ch17/VAT"
{
    Properties
    {
        [NoScaleOffset] _PosTex ("VAT Position", 2D) = "black" {}
        [NoScaleOffset] _NrmTex ("VAT Normal", 2D) = "bump" {}
        _FPS ("Frames Per Second", Float) = 24
        _Frame ("Frame Offset", Float) = 0
        _RootColor ("Root Color", Color) = (0.45, 0.12, 0.35, 1)
        _TipColor ("Tip Color", Color) = (1, 0.55, 0.45, 1)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            float _FPS, _Frame;
            half4 _RootColor, _TipColor;
            float4 _PosTex_TexelSize;      // (1/宽, 1/高, 宽, 高):高 = 帧数
        CBUFFER_END
        TEXTURE2D(_PosTex);
        TEXTURE2D(_NrmTex);
        #include "Assets/Cookbook/Ch17/VertexAnimCommon.hlsl"

        void Deform(inout VAAttributes v)
        {
            float frames = _PosTex_TexelSize.w;
            float f = fmod(_Time.y * _FPS + _Frame, frames);
            float f0 = floor(f);
            float f1 = fmod(f0 + 1.0, frames);       // 最后一帧插值回第一帧,循环无缝
            float w = f - f0;

            // 采样纹素中心;必须用点采样 + LOD 0,否则相邻顶点/相邻帧会被双线性混在一起
            float2 uv0 = float2(v.uv1.x, (f0 + 0.5) / frames);
            float2 uv1 = float2(v.uv1.x, (f1 + 0.5) / frames);
            float3 p0 = SAMPLE_TEXTURE2D_LOD(_PosTex, sampler_PointClamp, uv0, 0).xyz;
            float3 p1 = SAMPLE_TEXTURE2D_LOD(_PosTex, sampler_PointClamp, uv1, 0).xyz;
            float3 n0 = SAMPLE_TEXTURE2D_LOD(_NrmTex, sampler_PointClamp, uv0, 0).xyz;
            float3 n1 = SAMPLE_TEXTURE2D_LOD(_NrmTex, sampler_PointClamp, uv1, 0).xyz;

            v.positionOS.xyz = lerp(p0, p1, w);      // 贴图里存的是绝对位置,直接替换
            v.normalOS = normalize(lerp(n0, n1, w));
        }

        #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
            {
                half3 albedo = lerp(_RootColor.rgb, _TipColor.rgb, i.uv.y);
                // 横向的环纹,方便看出扭转
                albedo *= 0.8h + 0.2h * step(0.5h, frac(i.uv.x * 4.0h));
                return half4(VALighting(albedo, normalize(i.normalWS), i.positionWS, 0.6h, 48.0h), 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
        }
    }
}
// 播放 VAT:顶点用 uv1.x(自己的编号)+ 当前帧号去查位置/法线贴图,相邻两帧之间线性插值。(GLSL 版)
// 帧号 = _Time.y * _FPS + _Frame;静帧时 _Time≈0,直接用 _Frame 指定。
Shader "Cookbook/Ch17/VAT"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform float _FPS, _Frame;
        uniform vec4 _RootColor, _TipColor;
        uniform vec4 _PosTex_TexelSize;      // (1/宽, 1/高, 宽, 高):高 = 帧数
        uniform highp sampler2D _PosTex;
        uniform highp sampler2D _NrmTex;
        #include "Ch17/VertexAnimCommon.glsl"

        // HLSL 的 fmod:结果与被除数同号(GLSL 的 mod 对负数不同)
        float fmodHLSL(float a, float b) { return a - b * trunc(a / b); }

        // GLSL 不能按调用指定采样器(sampler_PointClamp):点采样直接用 texelFetch 取纹素
        vec4 SamplePoint(highp sampler2D tex, vec2 uv)
        {
            ivec2 size = textureSize(tex, 0);
            ivec2 p = clamp(ivec2(uv * vec2(size)), ivec2(0), size - 1);
            return texelFetch(tex, p, 0);
        }

        void Deform(inout VAAttributes v)
        {
            float frames = _PosTex_TexelSize.w;
            float f = fmodHLSL(_Time.y * _FPS + _Frame, frames);
            float f0 = floor(f);
            float f1 = fmodHLSL(f0 + 1.0, frames);   // 最后一帧插值回第一帧,循环无缝
            float w = f - f0;

            // 采样纹素中心;必须用点采样 + LOD 0,否则相邻顶点/相邻帧会被双线性混在一起
            vec2 uv0 = vec2(v.uv1.x, (f0 + 0.5) / frames);
            vec2 uv1 = vec2(v.uv1.x, (f1 + 0.5) / frames);
            vec3 p0 = SamplePoint(_PosTex, uv0).xyz;
            vec3 p1 = SamplePoint(_PosTex, uv1).xyz;
            vec3 n0 = SamplePoint(_NrmTex, uv0).xyz;
            vec3 n1 = SamplePoint(_NrmTex, uv1).xyz;

            v.positionOS.xyz = mix(p0, p1, w);       // 贴图里存的是绝对位置,直接替换
            v.normalOS = normalize(mix(n0, n1, w));
        }

        #include "Ch17/VertexAnimPasses.glsl"
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            #ifdef VERTEX
            void main() { VAForwardVert(); }
            #endif
            #ifdef FRAGMENT
            void main()
            {
                vec3 albedo = mix(_RootColor.rgb, _TipColor.rgb, uv.y);
                // 横向的环纹,方便看出扭转
                albedo *= 0.8 + 0.2 * step(0.5, fract(uv.x * 4.0));
                fragColor = vec4(VALighting(albedo, normalize(normalWS), positionWS, 0.6, 48.0), 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
        }
    }
}
图 17-11左:位置贴图的可视化(按包围盒把 xyz 映射到 RGB),横向为靠近触手尖端的 40% 顶点,纵向为 48 帧,纵向的波纹就是随时间变化的位置;右:同一份 VAT 数据、5 个实例,各自用不同的帧偏移播放

VAT 的几种变体

本例存的是“每个顶点的绝对位置”,最通用,但数据量是 顶点数 × 帧数。实际项目里常见几种更省的变体:刚体 VAT——破碎动画中每块碎片是刚体,只需要存每块碎片每帧的位置和旋转(四元数),贴图宽度从“顶点数”降到“碎片数”,顶点用自己所属碎片的编号去查,再像案例 17.6 那样绕碎片中心旋转;偏移 VAT——只存相对静止姿态的偏移,数值小、精度高,静止姿态本身还能参与常规的烘焙光照;拓扑变化的动画(流体溅起、网格分裂合并)每帧顶点数都不同,无法用“固定编号查表”,需要另一种布局:按帧把整个网格的三角形“排队”写进贴图,顶点数取所有帧的最大值,多余的顶点塌缩到一点隐藏起来。三者回放 Shader 的结构都和本例相同,只是“用什么编号去查、查出来的是什么”不同。

精度与格式① 半精度浮点有 10 位尾数,相对精度约 1/2048,2 米范围内的坐标误差约 1 毫米,对大多数动画足够;超大物体或需要高精度时改用 RGBAFloat,或者存“相对静止姿态的偏移量”(数值更小、精度更高)。② 如果为了兼容性只能用 8 位贴图,需要把位置按包围盒归一化到 0~1 再存,并把包围盒传给 Shader 还原,8 位只有 256 级,通常要拆成高低两张贴图。③ 这类数据贴图一定要关闭压缩、关闭 mipmap、关闭 sRGB(导入设置里勾掉 sRGB (Color Texture)),否则数据会被“美化”掉。
提示VAT 的单张贴图宽度受平台最大纹理尺寸限制(常见 8192 或 16384),顶点很多时要把顶点编号折行排布(u、v 都参与编码顶点编号,帧号再占一段 v)。另外,让多个实例播放不同帧:本例为每个实例复制了材质;大量实例时应改用 GPU Instancing 的逐实例属性(UNITY_INSTANCING_BUFFER,第 20 章),或者用物体位置哈希出帧偏移。

本章小结

  • 顶点动画要同时照顾五件事:空间(物体/世界)、时间(_Time + _Phase,或 _Progress)、法线、所有 Pass、包围盒。
  • 把变形写成一个 Deform 函数,由颜色、阴影、深度 Pass 共用,是避免“影子不动”这类问题的结构性办法;阴影偏移用的是变形后的法线。
  • 法线重算:能求导就解析偏导(Gerstner),不能求导用有限差分(旗帜、噪声),刚体运动直接旋转法线(碎片)。
  • 草地风动依赖每顶点数据:高度权重决定弯曲分配,根部位置让整根草采样同一个风场值;弯曲时降低高度以保持叶长。
  • Gerstner 波中质点做圆周运动,ω = √(gk) 的色散关系让不同波长相对滑动;雅可比行列式趋近 0 处生成泡沫,陡度之和应不超过 1。
  • 硬边模型膨胀会裂开,原因是同一位置有多个法线不同的顶点;用径向或烘焙的平滑法线解决。
  • 碎裂需要拆开共享顶点并为每个三角形写入中心、随机数和面法线;倒放进度就是吸附重组。
  • VAT 把逐帧逐顶点的数据存成“顶点 × 帧”的浮点贴图,回放时点采样 + 手动帧插值,贴图须无压缩、无 mip、线性。

练习

  1. 为旗帜 Shader 改用解析法线:对主波和次波手工求偏导,噪声部分仍用有限差分,比较两种方法的效果与指令数。
  2. 在草地 Shader 中加入“交互压弯”:传入一个全局的球体位置和半径,球体附近的草沿远离球心的方向倒伏(第 20 章会给出多交互体的完整方案)。
  3. 写一个 C# 脚本 GerstnerSampler,用与 Shader 相同的四个波参数和同一个时间,在 CPU 端求任意世界点的水面高度(含迭代求逆),让一个小方块漂浮在图 17-7 的水面上。
  4. 把膨胀 Shader 的方向改为“烘焙在 UV3 中的平滑法线”,并为任意导入网格写一个烘焙平滑法线到 UV3 的编辑器工具,验证立方体不再裂开。
  5. 给碎裂效果加上“碎片落地后停住”:在 Deform 中把碎片中心的世界高度限制在地面以上,并让落地后的旋转停止。
  6. 修改 VAT 烘焙器,改为存储“相对静止姿态的偏移量”,并比较两种方式在 RGBAHalf 下的最大误差(提示:在 C# 中把贴图读回,与原始数据逐顶点比较)。
  7. 为 Flag Shader 写一个 MotionVectors Pass:在其中分别用当前时间和上一帧时间(_Time.y - unity_DeltaTime.x)计算位置,输出两者在屏幕上的差,然后开启 TAA 观察差异。