URP 渲染实战 / 第 17 章 顶点动画 / 案例 17.5
案例 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 } } }

常见坑① 膨胀量较大时,凹处的表面会互相穿插(纽结管子相交处),这是“沿法线位移”的固有局限。② 均匀膨胀不改变光滑曲面的法线方向,所以这里没有重算法线;但脉冲这种不均匀的位移会让表面倾斜,严格说应当重算(可以用有限差分沿切线方向取邻点),幅度小时可以忽略。③ 阴影 Pass 也要用同样的方向(我们的框架天然保证),否则裂缝会出现在影子里。
本案例出自《URP 渲染实战》第 17 章「顶点动画」。在章节中阅读上下文 →