GameTech/URP 渲染实战

案例 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 };
}

演示动画是一根程序化的“触手”: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));
}
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 章),或者用物体位置哈希出帧偏移。