顶点动画
让物体动起来不一定要骨骼和动画控制器。飘扬的旗帜、随风起伏的草地、翻滚的海浪、碎成千百片的雕像,都可以在顶点着色器里用几行数学完成——GPU 并行处理成千上万个顶点,CPU 一点也不用操心。难点不在“让顶点动”,而在“动完之后法线、阴影、深度、包围盒都要跟上”。
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 控制,而不是依赖全局时间。
_Time 只在视图刷新时前进。想在不运行游戏的情况下预览顶点动画,打开 Scene 视图工具栏的 。③ 法线怎么办
顶点动了,表面的朝向就变了,但网格里的法线还是原来的。如果不重算,光照就是“平的”——几何起伏了,明暗却没变(图 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 的顶点函数都写在公共文件里,统一调用它:
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) };
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); }
颜色 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 起可用)而不必修改共享的网格资源。
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 方向。位移函数由三部分组成:
- 主波 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, ε),叉乘就是新法线。
完整代码:
// 旗帜飘动:在细分平面(物体空间 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 坐标换算:
// 位移只依赖旗面上的 (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; }
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.2阴影 Pass 同步顶点偏移入门单独打开 ↗
目标:直观看到“只在颜色 Pass 里做顶点动画”的后果,理解为什么必须在 ShadowCaster、DepthOnly 中做同样的变形。
原理:主光阴影贴图是在相机渲染之前,由 ShadowCaster Pass 从光源视角单独渲染的。如果 ShadowCaster 不做变形,阴影贴图里记录的就是原始的平面旗子:地面上的影子是一个规规矩矩的矩形,而且旗子自己接收的自阴影也是“平面旗子”投下的,与它实际的起伏完全无关。
Flag Shader 为演示这个错误,给阴影 Pass 准备了一个带开关的变形函数,通过 VA_DEFORM_SHADOW 宏替换公共文件里阴影 Pass 调用的函数(见上一节 deform 片段末尾)。实际项目里不需要这个开关,公共文件默认就让阴影 Pass 调用同一个 Deform。

阴影 Pass 里的变形还有一个容易忽略的细节:ApplyShadowBias 的法线偏移(Normal Bias)使用的是变形后的法线。如果 Deform 只改位置不改法线,在起伏剧烈的地方法线偏移的方向是错的,可能产生阴影痤疮或漏光。本章的框架里 Deform 同时更新 normalOS,阴影 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)。风场用根部的世界坐标采样,整根草拿到同一个风力值,才会整体弯曲而不是“扭成麻花”。
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),用根部世界坐标采样,相邻的草拿到相近的值,于是成片的草一起被压弯,一道道“风浪”扫过草地;摆动是每根草相位不同的小幅正弦,相位由每根草的随机数和它在风向上的位置决定。
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。没有这一项,草被吹弯时会显得被“拉长”了。
// 草地风动:风场是一张在世界 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 传给片元。

GrassField.Build 里手动扩大了 bounds)。② 一个合并的大网格会被整体剔除或整体保留,看不见的部分也在消耗顶点着色器;大面积草地应该分块,或者用 GPU Instancing 每根(每簇)一个实例——第 20 章会讲 Graphics.RenderMeshInstanced 方案和角色压草的交互。③ 风向在 C# 里设置成全局 Shader 变量(Shader.SetGlobalVector)而不是每个材质一份,所有植被才能共享同一个风场。案例 17.4Gerstner 波高阶单独打开 ↗
目标:用 Gerstner 波叠加出尖峰平谷的海面,法线用解析偏导数精确计算,并用雅可比行列式在波峰挤压处生成泡沫。
原理:普通正弦波只让水面上的点上下运动,波形是对称的,看起来像丝绸而不像水。真实的深水波中,水质点做的是圆周运动:在波峰处向前、在波谷处向后。这就是 Gerstner 波(也叫摆线波、Trochoidal Wave),19 世纪由 Gerstner 提出,是流体力学方程在深水条件下的一个精确解。圆周运动让质点在波峰处聚集、在波谷处散开,于是波峰变尖、波谷变平。
公式
设水面上某点静止时的水平位置为 x0(世界 xz),对第 i 个波:传播方向 Di(单位向量)、波长 λi、陡度 si。定义:
ω = √(gk) 是深水波的色散关系:波速 c = ω/k = √(gλ/2π),长波比短波跑得快。这一点很重要——如果所有波用同样的速度,叠加出的海面会像一整块在平移;用色散关系,不同波长的波相对滑动,海面才有“活”的感觉。振幅不直接给,而是由陡度决定:a = s/k,s = 1 时波峰恰好收成尖角。叠加后的位置:
注意 θ 用的是静止位置 x0 而不是位移后的位置——这是拉格朗日描述(跟踪每个质点),正好对应顶点着色器:网格顶点的原始位置就是 x0。GPU Gems 第 1 章给出的版本多了一个参数 Q 控制水平位移相对竖直位移的比例(椭圆轨道);本例取圆形轨道,直接用陡度 s 参数化,更直观。
解析法线
对 x0 求偏导(注意 ∂θ/∂x0 = k·Dx,而 a·k = s,k 恰好被消掉):
每个波只多了几次乘加,比有限差分(要把整个位移函数再算两遍)便宜得多,而且没有步长误差。另外请留意:所有波共用的偏导数从 (1,0,0) 和 (0,0,1) 开始累加,这两个初值就是“未扰动的平面本身”的偏导;如果水面网格不是水平放置的,这里应改成网格自己的切线与副切线。
雅可比行列式与泡沫
水平方向上,质点从 x0 被映射到了新位置,这个映射的雅可比行列式
表示一小块水面被拉伸(J > 1)还是压缩(J < 1)。单个波时 J = 1 − s sin θ,在波峰(sin θ = 1)处最小,等于 1 − s;J 趋近 0 意味着质点挤到了一起、波峰即将翻卷——现实中这正是白浪碎沫出现的地方。J < 0 则说明曲面已经自相交,所以所有波的陡度之和最好不超过 1。这个判据在基于 FFT 的海洋渲染里同样被用来生成泡沫。
完整代码:
// 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 } } }
关键代码讲解:单个波的累加函数同时返回位移、累加两个偏导:
// 累加一个 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 交给片元:
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 章。
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); }

Shader.SetGlobalFloat 传入同一个时间值,而不是各用各的 Time.time)。案例 17.5膨胀与呼吸入门单独打开 ↗
目标:让物体整体膨胀收缩(呼吸、心跳、充气),以及一道从下往上传播的“脉冲”;理解硬边模型膨胀时的裂缝问题。
原理:沿法线把顶点推出去,positionOS += normalOS * amount,就是最简单的膨胀。这也是第 14 章背面外扩描边的同一个操作。呼吸只需要让 amount 随时间做正弦;在相位里减去 positionOS.y * _PulseFreq,不同高度的相位就不同,形成一道沿 Y 方向传播的鼓包。
问题出在硬边模型上。立方体的一个角在网格里其实是 3 个位置相同的顶点(分属三个面),它们的法线分别指向三个面的外侧。沿各自的法线推出去,三个顶点就分开了,立方体裂成六块分离的面片。解决思路是让同一位置的顶点拿到同一个方向:
- 径向方向:用“从物体中心指向顶点”的方向
normalize(positionOS)。同位置 → 同方向,不会裂开。只适用于大致凸、中心在原点的形体(球、方块、胶囊),对纽结这种形体就完全不对了。 - 平滑法线:预先把“同位置顶点法线的平均值”烘焙进顶点色或某个 UV 通道(第 14 章为描边写过这个烘焙工具),膨胀时用它。这是最通用的做法。
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 共享同一套变体,阴影和深度跟着一起切换。
// 膨胀 / 呼吸:沿某个方向把顶点推出去。 // 方向 = 网格法线:最简单,但硬边模型(立方体)的同一位置有多个法线不同的顶点,推开后会裂缝; // 方向 = 从中心指向顶点:同一位置的顶点方向相同,硬边不会裂开(适合大致凸的形体)。 // 呼吸:振幅随时间做正弦,相位再加上沿 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.6碎裂与吸附重组进阶单独打开 ↗
目标:物体沿某个方向逐渐碎成三角形碎片:碎片绕自身中心旋转、沿面法线飞出、受“重力”下落并缩小,刚脱落时发出橙色辉光;把进度反过来播放,就是碎片飞回来吸附成形。
原理:要让一个三角形作为一个整体运动,三个顶点必须知道“我属于哪块碎片”——碎片的中心、随机数、面法线。和重心坐标线框一样,这要求先把共享顶点拆开,再把每个三角形的公共数据写进它的三个顶点:
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:碎片中心在扫描方向上越靠前,开始得越早,再加一点随机,“裂解前沿”就有了参差感。然后依次做三件事:
- 旋转并缩小:把顶点相对碎片中心的偏移,绕一根随机轴旋转 p ×
_Spin弧度,再乘以 (1 − p ×_Shrink)。旋转矩阵用 Core RP 的RotationFromAxisAngle(GeometricTools.hlsl,即罗德里格斯旋转公式的矩阵形式)。 - 飞出:沿面法线移动,距离随随机数浮动;再加一个抛物线:先略微上抛,再按 p² 下落。
- 法线:碎片是刚体旋转,法线乘同一个旋转矩阵即可,不需要重算。
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) 做出“刚脱落时最亮、飞远后冷却”的辉光。
// 碎裂与吸附重组:每个三角形是一块碎片,围绕自己的中心旋转、沿面法线飞出并受“重力”下落。 // _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 } } }

_Progress 从 1 播到 0,碎片就会从四面八方飞回来,在扫描前沿依次“吸附”到原位——这正是很多游戏里“传送出现”“装备合成”的效果。配合第 18 章的溶解边缘发光,可以做出更丰富的过渡。如果希望碎片“吸附”时有一个过冲回弹,把 smoothstep 换成带回弹的缓动函数即可(例如 easeOutBack)。ShatterMesh.Build 默认四周扩 3 米),否则碎片飞出原包围盒后整个物体可能被剔除。④ Cull Off:碎片翻滚时会露出背面。案例 17.7VAT:顶点动画贴图高阶单独打开 ↗
目标:把一段“任意复杂”的逐顶点动画烘焙进两张贴图,运行时由 Shader 回放;同一份数据可以被多个实例以不同的帧偏移播放。
原理:前面的效果都是“用公式算出位置”,但很多动画根本没有简单的公式:布料模拟、刚体破碎、流体、复杂的变形动画。VAT(Vertex Animation Texture)的思路是:既然算不出来,就预先记录——离线把每一帧每个顶点的位置(和法线)写进贴图,横向是顶点编号,纵向是帧号。运行时每个顶点用“自己的编号”和“当前帧号”去查表,CPU 端既不需要骨骼也不需要每帧上传网格。
本书不依赖外部工具(实际项目里通常由 Houdini 的 VAT 导出工具或 Blender 插件生成),所以烘焙器也是 C# 写的:输入基础网格和一个“给定帧号返回全部顶点位置”的函数,输出两张贴图和一个写好顶点编号的网格:
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 完全不用变:
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做,可控且正确。 - 帧号对帧数取模,最后一帧插值回第一帧,循环无缝——前提是烘焙的动画本身首尾相接。
// 播放 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 } } }

VAT 的几种变体
本例存的是“每个顶点的绝对位置”,最通用,但数据量是 顶点数 × 帧数。实际项目里常见几种更省的变体:刚体 VAT——破碎动画中每块碎片是刚体,只需要存每块碎片每帧的位置和旋转(四元数),贴图宽度从“顶点数”降到“碎片数”,顶点用自己所属碎片的编号去查,再像案例 17.6 那样绕碎片中心旋转;偏移 VAT——只存相对静止姿态的偏移,数值小、精度高,静止姿态本身还能参与常规的烘焙光照;拓扑变化的动画(流体溅起、网格分裂合并)每帧顶点数都不同,无法用“固定编号查表”,需要另一种布局:按帧把整个网格的三角形“排队”写进贴图,顶点数取所有帧的最大值,多余的顶点塌缩到一点隐藏起来。三者回放 Shader 的结构都和本例相同,只是“用什么编号去查、查出来的是什么”不同。
UNITY_INSTANCING_BUFFER,第 20 章),或者用物体位置哈希出帧偏移。本章小结
- 顶点动画要同时照顾五件事:空间(物体/世界)、时间(
_Time+_Phase,或_Progress)、法线、所有 Pass、包围盒。 - 把变形写成一个
Deform函数,由颜色、阴影、深度 Pass 共用,是避免“影子不动”这类问题的结构性办法;阴影偏移用的是变形后的法线。 - 法线重算:能求导就解析偏导(Gerstner),不能求导用有限差分(旗帜、噪声),刚体运动直接旋转法线(碎片)。
- 草地风动依赖每顶点数据:高度权重决定弯曲分配,根部位置让整根草采样同一个风场值;弯曲时降低高度以保持叶长。
- Gerstner 波中质点做圆周运动,ω = √(gk) 的色散关系让不同波长相对滑动;雅可比行列式趋近 0 处生成泡沫,陡度之和应不超过 1。
- 硬边模型膨胀会裂开,原因是同一位置有多个法线不同的顶点;用径向或烘焙的平滑法线解决。
- 碎裂需要拆开共享顶点并为每个三角形写入中心、随机数和面法线;倒放进度就是吸附重组。
- VAT 把逐帧逐顶点的数据存成“顶点 × 帧”的浮点贴图,回放时点采样 + 手动帧插值,贴图须无压缩、无 mip、线性。
练习
- 为旗帜 Shader 改用解析法线:对主波和次波手工求偏导,噪声部分仍用有限差分,比较两种方法的效果与指令数。
- 在草地 Shader 中加入“交互压弯”:传入一个全局的球体位置和半径,球体附近的草沿远离球心的方向倒伏(第 20 章会给出多交互体的完整方案)。
- 写一个 C# 脚本
GerstnerSampler,用与 Shader 相同的四个波参数和同一个时间,在 CPU 端求任意世界点的水面高度(含迭代求逆),让一个小方块漂浮在图 17-7 的水面上。 - 把膨胀 Shader 的方向改为“烘焙在 UV3 中的平滑法线”,并为任意导入网格写一个烘焙平滑法线到 UV3 的编辑器工具,验证立方体不再裂开。
- 给碎裂效果加上“碎片落地后停住”:在 Deform 中把碎片中心的世界高度限制在地面以上,并让落地后的旋转停止。
- 修改 VAT 烘焙器,改为存储“相对静止姿态的偏移量”,并比较两种方式在 RGBAHalf 下的最大误差(提示:在 C# 中把贴图读回,与原始数据逐顶点比较)。
- 为 Flag Shader 写一个 MotionVectors Pass:在其中分别用当前时间和上一帧时间(
_Time.y - unity_DeltaTime.x)计算位置,输出两者在屏幕上的差,然后开启 TAA 观察差异。