案例 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 章),或者用物体位置哈希出帧偏移。本案例出自《URP 渲染实战》第 17 章「顶点动画」。在章节中阅读上下文 →