状态机和混合解决的是"播放什么动画",IK 篇解决的是"末端钉在哪"。但美术落地还有一类需求 AnimGraph 很难表达:同一套程序化骨骼控制逻辑,要在编辑器预览、Sequencer、运行时之间共享。典型例子是全息骨骼修正:绑定师调的时候在编辑器里实时看,过场动画师在 Sequencer 里复用同一套逻辑,游戏运行时还要跑。把这类逻辑写进 AnimBP,就会出现三份拷贝、行为漂移。Control Rig 就是为这个问题造的,它的实现核心是 RigVM——一个把节点图编译成字节码的寄存器式虚拟机。基于 UE5.3 源码(Engine/Plugins/Runtime/ControlRig)拆。
为什么是字节码 VM,而不是直接调 UObject 节点
AnimBP 的执行模型是每帧对 AnimGraph 里的 UObject 节点做一次深度遍历:每个节点是一次虚函数调用 Evaluate(FPoseContext&),pose 数据在节点间按值传递。这个模型灵活(任意节点组合),但开销结构很差:每帧大量虚调用 + 临时 pose 分配 + 反射式参数传递。对编辑器工具链这个开销无所谓,但 Control Rig 的定位是运行时也要每帧跑(Full Body IK、骨骼程序化动画),就必须换成数据驱动的执行模型。
RigVM 的思路和脚本语言 VM 一样:把图编译成扁平的字节码序列,运行时循环取指令、按操作数读写内存,全程无 UObject 参与。区别在"指令"的粒度——RigVM 的一条指令对应图里的一个 Unit(URigUnit 派生类的 Execute 函数),操作数是图里引脚连接的内存槽位。
编译管线:从图到字节码
Control Rig 资产(UControlRigBlueprint)里存的是节点图(FRigVMGraph,节点是 URigVMNode,引脚是 URigVMPin)。编译分四步:
- AST 生成:遍历图结构生成指令列表(FRigVMInstructionArray),每个 Unit 节点生成一条 ExecuteOp,引脚连接生成 CopyOp,for-loop / if 生成对应的 BranchOp、JumpOp、BeginBlock/EndBlock 对。
- 操作数分配:每个引脚被映射到一个内存槽位。槽位分三种容器:
Literal(常量,编译期冻结)、Work(中间计算缓存,两个运行帧之间不保证保留)、External(外部变量,如骨骼引用、输入参数,运行时由宿主注入)。操作数 FRigVMOperand 就是一个 16 位容器号 + 16 位寄存器号 + 8 位偏移,32 位打包。 - 字节码发射:指令数组序列化进 FRigVMByteCode,按操作码枚举(ERigVMOpCode)排布,ExecuteOp 的操作数携带 Unit 的派发信息(FRigVMFunction 指针 + 用户数据索引)。
- 序列化固化:字节码作为资产的一部分存进 package,运行时加载后直接跑——运行时零编译。
关键文件:RigVMCompiler.cpp 的 FRigVMCompiler::Compile,RigVMByteCode.h 看操作码布局。
执行模型:派发与内存
执行入口是 FRigVM::Execute,循环:
$$\text{for } op \in \text{bytecode}:\quad \text{dispatch}(op)$$
ExecuteOp 的派发粒度值得展开。每个 Unit 类型在编译时注册一个 FRigVMFunction(函数指针 + 名字 + 参数签名),字节码里 ExecuteOp 记录的是这个 function 的索引和引脚操作数数组。运行时 FRigVM::ExecuteUnits 按索引取函数指针直接调用,参数是从三个内存容器按操作数解出的指针。整条链是数组索引 + 函数指针调用,没有 map 查找、没有反射、没有虚函数(Unit 的 Execute 是普通静态成员函数,不是 virtual)。
内存容器的设计回答"为什么 pose 缓存能复用":Work 容器是 Unit 之间共享的 scratch space,编译器保证生命周期重叠的引脚共享槽位(图着色式的寄存器分配思想)。对比 AnimBP 每帧为节点间传递 new 临时 pose,RigVM 的中间数据全在预分配数组里,零帧分配。
还有一个减少每帧开销的机制:字节码分块(FRigVMByteCode::Chunk),配合运行时的执行缓存(FRigVMExecuteContext),输入未变的 Unit 可以整段跳过——Control Rig 图通常前半段(setup 逻辑)只在初始化时跑一次,运行时从变化的节点继续。
与 AnimBP 的本质差异
把两条执行链并排看:
| 维度 | AnimBP AnimGraph | Control Rig RigVM |
|---|---|---|
| 执行体 | UObject 节点树 | 扁平字节码数组 |
| 节点调用 | virtual Evaluate | 函数指针数组索引 |
| 中间数据 | 每帧临时 pose 按值传递 | 预分配寄存器槽位(Work 容器) |
| 不变部分 | 每帧全图重算 | 分块缓存,未变跳过 |
| 宿主 | 仅运行时 FAnimInstance | 编辑器/Sequencer/运行时共用 |
代价是灵活性:图必须是静态可编译的(循环要展开成 BeginBlock/EndBlock 指令对,动态类型不允许),调试体验也从"断点进节点"降级成"单步字节码看寄存器"。对工具链资产这是合理取舍。
嵌套与模块化:bytecode 级别的函数调用
RigVM 支持把子图编译成独立的 Function 字节码段,ExecuteOp 遇到函数调用时通过 NestedEntry/NestedExit 操作码切换执行上下文(类似 call/ret,操作数做寄存器重映射)。FRigVM 的 Execute 是支持递归嵌套的,这套机制让 Control Rig 的"Rig Function"复用不需要复制图节点——字节码层面就是一次上下文切换,和真实函数调用同构。
落地判断
Control Rig 值得上的场景:同一套骨骼程序化逻辑要同时服务编辑器预览、Sequencer、运行时;或者逻辑复杂到 AnimBP 节点图难以维护(多末端效应器 + 接触约束)。纯运行时、逻辑固定的场景(简单 LookAt、武器吸附),用 AnimBP 的 SkeletalControl 节点或 TwoBoneIK 更省事——RigVM 的编译管线对一次性逻辑是 overhead。
编译产物到运行时执行的完整链路:
排查 Control Rig 性能问题时,打开 stat rigvm 看各 Unit 的耗时分布;卡顿但耗时均匀通常是寄存器分配后 Work 容器的内存带宽问题(pose 数据量大),这时该考虑拆图或降骨骼数,而不是换节点写法。