URP 渲染实战 / 第 17 章 顶点动画 / 案例 17.1
案例 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, ε),叉乘就是新法线。
完整代码:
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)。

调参建议真实的旗帜在风中,靠近自由端的部分波长更短、摆动更剧烈,可以让 k 随 u 增大;旗子在大风中还会整体“缩短”(布料不可拉伸,起伏越大投影长度越短),可以按局部斜率把顶点向旗杆方向拉回一点。这些都只是在位移函数里多写一项,有限差分会自动处理法线。
本案例出自《URP 渲染实战》第 17 章「顶点动画」。在章节中阅读上下文 →