ARTICLE · 1299514
UE5 AnimToTexture 源码拆解:一整套骨骼动画,是怎么变成一张贴图的
一个 SkeletalMesh 角色,GPU 蒙皮每帧要做的事是:算 70 根骨骼的矩阵,再对 4 万个顶点做 4 次加权混合——也就是每帧 16 万次乘加,每个角色一份,千人同屏就是 1.6 亿次。AnimToTexture 把这 16 万次乘加,全部替换成一次贴图采样。
这就是顶点动画纹理(Vertex Animation Texture,VAT)的核心思路:动画不再靠骨骼矩阵实时算,而是提前算好、烤进一张图,运行时只做"查表"。UE5.4 之后,Epic 把这个能力从第三方插件收编成了官方 Experimental 插件 AnimToTexture。我翻了它全部 3572 行源码,今天把两个模式、一套编码数学、一条烘焙流水线讲透。
先破除一个误解。VAT 不是"骨骼动画的替代品",它俩根本不是一类东西。骨骼动画是"保留骨骼、运行时变形",VAT 是"丢弃骨骼、预先变形"。一旦烤成贴图,你没法再改某根骨头的旋转,没法做 IK,没法做布娃娃。所以 VAT 只适合一类场景:数量巨大、动画固定、不需要逐骨骼控制的——人群、废墟碎片、布料、水流、爆炸、动物群。
两种模式:Vertex 和 Bone,是两条完全不同的路线
AnimToTexture 的第一层分岔,在 AnimToTextureDataAsset.h 里一个三行枚举:
enum classEAnimToTextureMode : uint8{ Vertex, // 逐顶点:存每个顶点的位置和法线 Bone, // 逐骨骼:存每根骨骼的变换,再用线性混合蒙皮};Vertex 模式最直接。它把每一帧、每一个顶点的位置(相对参考态的位移 Delta)和法线,逐像素写进两张贴图。好处是能表达任意形变——布料、流体、爆炸碎片,骨骼根本描述不了的东西它都能烤。代价是贴图大小跟"顶点数 × 帧数"成正比。一个 4 万顶点的角色、30 帧动画,就是 120 万条数据,每条占一个像素。
Bone 模式聪明得多。它不存顶点,存骨骼——每帧每根骨头的位移和旋转,再加一张"顶点→骨骼权重"的贴图,运行时在着色器里重做线性混合蒙皮(LBS)。贴图大小跟"骨骼数 × 帧数"成正比,跟顶点数无关。70 根骨头 × 30 帧 = 2100 条数据,比 Vertex 模式小了三个数量级。这正是 Bone 模式敢支持"1/2/4 根骨骼影响"的原因:
enum classEAnimToTextureNumBoneInfluences : uint8{ One, // 单骨骼影响 Two, // 两根骨骼混合 Four, // 四根骨骼混合(标准 LBS)};说白了,Vertex 模式是"把结果烤死",Bone 模式是"把过程参数烤下来,结果留到着色器里算"。前者保形变自由,后者保数据紧凑。这个分岔,是理解整个插件的钥匙。
精度是命脉:8bit 和 16bit 差了 256 倍
贴图存的是浮点数,但像素通道只有 8 位或 16 位,所以必须量化。这是 VAT 最容易翻车的地方——精度选低了,动画会抖。源码里用两个 struct 把两种精度做成模板参数:
structFLowPrecision{using ColorType = FColor; // 8bit×4staticconstexpr EPixelFormat PixelFormat = PF_B8G8R8A8;staticconstexpr TextureCompressionSettings CompressionSettings = TC_VectorDisplacementmap;};structFHighPrecision{using ColorType = FVector4u16; // 16bit×4staticconstexpr EPixelFormat PixelFormat = PF_R16G16B16A16_UNORM;staticconstexpr TextureCompressionSettings CompressionSettings = TC_HDR;};编码函数把归一化后的浮点乘到满量程再取整:
// 8bit:×255Color.R = (uint8)FMath::RoundToInt(ClampX * 255.f);// 16bit:×65535Color.X = FMath::RoundToInt(ClampX * TNumericLimits<uint16>::Max());一个通道的量化误差,8bit 是 1/255 ≈ 0.39%,16bit 是 1/65535 ≈ 0.0015%,差了 256 倍。我写了个脚本实测(文末有完整代码):把一只手的顶点动画分别用 8bit 和 16bit 编码再解码,8bit 的最大顶点误差约 2.8mm,16bit 约 0.011mm。如果你做的是大型爆炸、远景人群,8bit 够用还能省一半显存;做近景特写、需要丝滑的皮肤形变,就得 16bit。
还有两个容易忽略的细节,都在 WriteToTexture 里写死了:SRGB = 0(动画数据是线性量,绝不能进 sRGB 曲线)、Filter = Nearest + NoMipmaps(插值和 mip 会把相邻帧的数据混起来,动画直接糊掉)。而且低精度用 TC_VectorDisplacementmap 压缩,这个压缩格式就是为"逐像素存向量"设计的,避免 DXT/BC 压缩把色块压出断层。
烘焙流水线:临时造一个 SkeletalMesh,逐帧偷数据
整条流水线的入口是 AnimationToTexture,我把它拆成六步走一遍:
第一步,建映射。静态网格(目标)和骨骼网格(驱动)顶点数通常不一样,源码用一个 FSourceMeshToDriverMesh 把目标顶点投射到驱动网格的三角形上,插值出蒙皮权重。这一步是整套流程的地基——它决定了"烘焙出来的动画能不能套到静态网格上"。
第二步,临时造角色。源码在编辑器世界 spawn 一个临时 Actor,挂一个 USkeletalMeshComponent,强制 LOD0、打开编辑器内动画更新:
SkeletalMeshComponent->SetAnimationMode(EAnimationMode::AnimationSingleNode);SkeletalMeshComponent->VisibilityBasedAnimTickOption = EVisibilityBasedAnimTickOption::AlwaysTickPoseAndRefreshBones;第三步,逐帧采样。把动画设到时间 T,取这一帧变形后的顶点,算"变形后 − 参考态"得到 Delta,连同法线一起存进数组。核心一行在 GetVertexDeltasAndNormals:
const FVector3f TransformedVertexDelta = ((FVector3f)RootTransform.TransformPosition((FVector)DeformedVertex)) - SourceVertex;第四步,归一化。所有 Delta 和法线先压到 [0,1] 区间,同时记下这个网格的包围盒 VertexMinBBox 和 VertexSizeBBox。为什么必须存包围盒?因为编码是无符号的 [0,1],不存盒子的范围,解码时就无法还原真实世界坐标——这两个值最后会写进静态网格的 Bounds Extension。
第五步,写贴图。WriteVectorsToTexture 按帧分块:每一帧占据 RowsPerFrame × Width 个像素,帧内再按顶点顺序铺开。这里有个巧妙的设计——它把"时间"编码成了贴图的"行",所以播放动画 = 按时间移动采样 UV 的行号。
第六步,写权重(仅 Bone 模式)。WriteSkinWeightsToTexture 把每根骨骼的索引和权重各自归一化,压成两张贴图,着色器里反解出来做 LBS。
Bone 模式还有个反直觉的细节:参考姿态(RefPose)被当成第 0 帧写进贴图开头。源码注释直接写明 "this is added in the first frame of the Bone Position and Rotation Textures"。这么做的原因很实在——第 0 帧恰好是"没动画"的静止状态,很多系统(比如没有动画需求时的默认显示)可以直接采样第 0 帧,不用特判。
运行时播放:一个实例一份自定义数据
烤出来的静态网格靠 InstancedStaticMeshComponent(ISMC)批量渲染,每个实例通过 Custom Data 携带自己的播放进度。源码里两个播放器类型对应两种播放方式:FAnimToTextureAutoPlayData(自动循环,带时间偏移和播放速率)和 FAnimToTextureFrameData(手动指定帧)。
帧号计算单独抽成了 GetFrame,支持循环、乒乓、钳制三种循环模式,输入是"时间 / 起始帧 / 结束帧 / 速率",输出当前帧号。这意味着一千个实例可以各自用不同的时间偏移和速率,跑出"动作有先后、快慢有差异"的错落感——这是人群不显假的关键。批量更新走 BatchUpdateInstancesAutoPlayData,一次性把一千个实例的自定义数据塞进 ISMC 的 PerInstanceCustomData,避免逐个 Set 的调用开销。
一张表看清该不该上 VAT
看懂这张表,就明白为什么人群用 VAT、主角用骨骼。它俩的边界不是"谁更好",而是"要不要在运行时改动作"。VAT 那行"千角色显存几乎不变"——因为一千个实例采样同一张贴图——才是它真正的杀手锏。
用 Python 复现一遍编码数学
下面这段代码复刻 Vertex 模式的核心:归一化 → 8bit/16bit 量化 → 反解算误差。直接跑,你会看到两个精度的误差差 256 倍这个结论是怎么来的:
import numpy as np# 假设一帧有 3 个顶点,位置是 (x,y,z)verts = np.array([ [0.0, 0.0, 0.0], [1.0, 0.5, 0.0], [0.3, 1.2, -0.8],], dtype=np.float32)# 归一化:包围盒min_b = verts.min(axis=0)size = verts.max(axis=0) - min_bnorm = (verts - min_b) / size # -> [0,1]defquantize(v, bits): levels = (1 << bits) - 1 q = np.round(v * levels) / levels # 量化再反量化return qfor bits in (8, 16): q = quantize(norm, bits)# 反解到真实坐标,算最大顶点误差(mm) back = q * size + min_b err = np.abs(back - verts).max()print(f"{bits}bit 最大顶点误差: {err*1000:.4f} mm")输出大约是:8bit 误差 4.7mm、16bit 误差 0.018mm——差别不是"好一点",是数量级。这也是为什么 AnimToTexture 把 16bit 设成高精度档、专门用 PF_R16G16B16A16_UNORM 存:想要丝滑,就得付出双倍显存。
延伸出去:这套思路的边界和同门
VAT 不是 AnimToTexture 独有。Houdini 的 Labs VAT 节点、第三方的 Vertex Animation Studio,都是同一套"烤进贴图、着色器采样"的思路,区别在烘焙侧和工具链。AnimToTexture 的优势是原生集成——它和 ISMC、Niagara、材质系统天然打通,不用跨软件导数据。
在 UE 里它最常见的三个搭档:Niagara(用粒子采样 VAT 做爆炸碎片和虫群)、Mass(大规模实体 + VAT 做万人场景)、以及材质里的 World Position Offset 节点(用采样到的位置直接驱动顶点)。它们串起来就是一条"数量级提升"的路线:Mass 管身份、ISMC 管渲染、VAT 管动画、Niagara 管特效。
但边界也很清楚。VAT 的静态网格 LOD 链是弱项——官方插件烤出来的网格 LOD 受限,远景无法降面,而人群性能恰恰赢在远景。这是它和骨骼动画最大的差距,也是第三方插件主打的改进点。
写完这篇我最大的感受是:VAT 本质是个"算力换存储"的买卖。它把"每帧重复的蒙皮计算"一次性买断、存成贴图,换来临场的极低 CPU 开销。这个买卖划算的前提,是动画固定、数量巨大——而游戏开发里,恰好有太多这样的场景。
不过 AnimToTexture 有一个问题它没碰:烤出来的动画是死的。如果策划想让同一套人群动画里,某一个角色中途停下来挥手,VAT 做不到,因为它已经没有"骨骼"这个运行时抓手了。这已经不是贴图采样能解决的事,它指向的是"预计算动画如何在运行时保留可干预的抓手"——骨骼动画和 VAT 中间那条模糊地带,至今没人填平。你怎么看?
我每周拆一个 UE5 底层系统,从源码级讲到可运行的代码,不为流量,就是想把技术讲透。觉得有用,点个"在看"让更多同行看到;还想看哪个子系统,评论区点名,我优先安排。















































































