Rukhanka 深度剖析

前言:一个常见的误解
很多人第一次接触 Rukhanka 时以为:"它把 SkinnedMeshRenderer 转成了 MeshRenderer,然后走 GPU 蒙皮。"
这个理解是错的。 Rukhanka 根本没有做"转换",它做的事更底层:接管骨骼矩阵的计算,然后把结果写进 Unity ECS 的 SkinMatrix buffer,剩下的交给 ECS 渲染管线。
没有 SkinnedMeshRenderer → MeshRenderer 的转换过程。在 ECS 世界里,压根就没有"Renderer 组件"这个概念,只有 Entity + Component。
一、Rukhanka 的完整管线(四个阶段)
Baking 阶段(离线/编辑器)
┌────────────────────────────────────────────────────────────┐
│ 阶段一:SkinnedMeshBaker │
│ Baker<SkinnedMeshRenderer> 主线程执行 │
│ 提取:bones[], bindposes[], rootBone │
│ 输出:临时 SkinnedMeshBakerData 组件 │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 阶段二:SkinnedMeshConversionSystem │
│ BakingSystem,JobSystem 并行执行 │
│ 输出:BlobAssetReference<SkinnedMeshInfoBlob> │
│ + AnimatedSkinnedMeshComponent 组件 │
└────────────────────────────────────────────────────────────┘
│
▼
Runtime 阶段(每帧执行,全 CPU + JobSystem)
┌────────────────────────────────────────────────────────────┐
│ 阶段三:AnimationsProcessSystem.OnUpdate │
│ 9个 Job 串联成管线 │
│ 核心输出:finalAllAnimatedBones (NativeList<BoneTransform>) │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 阶段四:ApplyAnimationToSkinnedMeshJob │
│ 读取骨骼动画结果 → 计算 SkinMatrix → 写入 DynamicBuffer │
└────────────────────────────────────────────────────────────┘
│
▼
GPU(ECS Rendering 接管)
┌────────────────────────────────────────────────────────────┐
│ Vertex Shader 读 SkinMatrix buffer → 顶点蒙皮变形 → 渲染 │
└────────────────────────────────────────────────────────────┘
二、阶段一:Baker 提取骨骼数据
2.1 入口
csharp
public class SkinnedMeshBaker : Baker<SkinnedMeshRenderer>
这是一个 Baker<SkinnedMeshRenderer>,在主线程 执行。Unity ECS Baking 过程中遇到任何带有 SkinnedMeshRenderer 的 GameObject,都会触发它。
2.2 提取了什么
csharp
// SkinnedMeshBaker.cs : 55-80
RTP.SkinnedMeshBoneData CreateSkinnedMeshBoneData(SkinnedMeshRenderer r)
{
bnd.bones = new UnsafeList<SkinnedMeshBoneDefinition>(r.bones.Length, ...);
for (int j = 0; j < r.bones.Length; ++j)
{
var b = r.bones[j];
// ① 对骨骼名称计算 Hash128(后续 Job 里做 O(1) 哈希匹配)
oneBoneInfo.hash = new FixedStringName(b.name).CalculateHash128();
// ② 直接从 sharedMesh.bindposes 数组拷贝(Unity 预计算的逆矩阵)
oneBoneInfo.bindPose = r.sharedMesh.bindposes[j];
// ③ 从 Transform 组件读取参考姿势
oneBoneInfo.refPose.pos = b.localPosition;
oneBoneInfo.refPose.scale = b.localScale;
oneBoneInfo.refPose.rot = b.localRotation;
}
}
三个关键数据来源:
| 数据 | 来源 | 类型 | 用途 |
|---|---|---|---|
hash |
Transform.name → Hash128 |
Hash128 |
运行时骨骼匹配 |
bindPose |
sharedMesh.bindposes[j] |
float4x4 |
最终蒙皮矩阵计算 |
refPose |
Transform.localPosition/Rotation/Scale |
BoneTransform |
无动画时的参考姿势 |
注意 bindPose 是 Unity 在模型导入时预计算好的逆矩阵(骨骼空间→模型空间)。Rukhanka 不需要自己算,直接拿来用。
2.3 输出
Baker 创建一个临时实体 (不会进入运行时 World),挂上 SkinnedMeshBakerData:
csharp
var bd = new SkinnedMeshBakerData
{
skinnedMeshBones = smbd, // 骨骼数据(UnsafeList)
targetEntity = GetEntity(TransformUsageFlags.Dynamic),
animatedRigEntity = GetEntity(FindParent<RigDefinitionAuthoring>()),
rootBoneEntity = GetEntity(a.rootBone),
hash = a.sharedMesh.GetHashCode(), // 用于去重
};
三、阶段二:BakingSystem 转成 Blob Asset
3.1 SkinnedMeshConversionSystem
这是一个 [WorldSystemFilter(WorldSystemFilterFlags.BakingSystem)] 系统,在 Baking World 里运行。
3.2 核心逻辑------去重
csharp
// 按 hash 排序,相同 mesh 只创建一个 Blob Asset
skinnedMeshesData.Sort(new SkinnedMeshBakerDataSorter());
for (int i = 1; i <= skinnedMeshesData.Length; ++i)
{
if (rd.hash != startHash)
{
// 相同 hash 的实体 → 共享同一个 BlobAssetReference
var j = new CreateBlobAssetsJob() {
data = refSkinnedMesh,
outBlobAssets = blobAssetsSlice,
};
j.Schedule();
}
}
意义 :100 个怪物用同一个模型 → 骨骼数据只 Blob 一次,100 个实体共享一个 BlobAssetReference。运行时读同一块内存,不需要复制。
3.3 CreateBlobAssetsJob(IJob,Worker Thread 执行)
csharp
public void Execute()
{
var bb = new BlobBuilder(Allocator.Temp);
ref var blobAsset = ref bb.ConstructRoot<SkinnedMeshInfoBlob>();
var boneInfoArr = bb.Allocate(ref blobAsset.bones, data.skinnedMeshBones.bones.Length);
for (int i = 0; i < data.skinnedMeshBones.bones.Length; ++i)
{
boneInfoArr[i].hash = src.hash;
boneInfoArr[i].bindPose = src.bindPose; // float4x4
boneInfoArr[i].refPose = src.refPose; // BoneTransform
}
// BlobAsset 分配在 Persistent 内存,Baking 结束后不会释放
var rv = bb.CreateBlobAssetReference<SkinnedMeshInfoBlob>(Allocator.Persistent);
}
3.4 CreateComponentDatasJob(IJobParallelForBatch)
把 AnimatedSkinnedMeshComponent 通过 ECB 添加到目标 Entity:
csharp
ecb.AddComponent(startIndex, e, new AnimatedSkinnedMeshComponent {
boneInfos = blobAssets[i], // BlobAssetReference<SkinnedMeshInfoBlob>
rootBoneEntity = smb.rootBoneEntity,
animatedRigEntity = smb.animatedRigEntity,
});
这个 AnimatedSkinnedMeshComponent 就是 Rukhanka 和 Unity ECS Rendering 之间的桥梁。 有了它,运行时系统才知道"这个 Entity 的动画由哪个 Rig 驱动、骨骼数据在哪"。同时 Unity ECS Baking 自动给这个 Entity 加上了 RenderMesh + SkinMatrix buffer,所以 ECS Rendering 也知道它是个蒙皮网格。
四、阶段三:运行时动画矩阵计算
这是每帧最核心的部分。所有代码在 AnimationsProcessSystem.OnUpdate 里串联,全部在 CPU Worker Thread 上通过 JobSystem 执行。
4.1 整体调度
csharp
// AnimationProcessSystem.cs : 333-374
public void OnUpdate(ref SystemState ss)
{
entityToDataOffsetMap.Clear();
var entityCount = animatedObjectQuery.CalculateEntityCount();
if (entityCount == 0) return;
// Step 0: 数据准备------计算骨骼偏移,压平 NativeArray
var calcBoneOffsetsJH = PrepareComputationData(chunkBaseEntityIndices, entitiesArr, combinedJH);
// Step 1: 计算骨骼动画(采样曲线 + 混合)
var computeAnimationJobHandle = AnimationCalculation(ref ss, entitiesArr, calcBoneOffsetsJH);
// Step 2: 处理用户曲线(Animator Controller 参数)
var userCurveProcessJobHandle = ProcessUserCurves(ref ss, computeAnimationJobHandle);
// Step 3: 应用 Root Motion
var applyRootMotionJobHandle = ApplyRootMotion(ref ss, computeAnimationJobHandle);
// Step 4: 骨骼 pose 写回 Entity 的 LocalTransform 组件
var propagateTRSJobHandle = PropagateAnimatedBonesToEntitesTRS(ref ss, combinedJH);
// Step 5: 计算 SkinMatrix 写入 DynamicBuffer
var applySkinJobHandle = ApplySkinning(ref ss, propagateTRSJobHandle);
ss.Dependency = applySkinJobHandle;
}
4.2 数据压平(PrepareComputationData)
ECS 的数据分散在 Chunk 里,不能直接当数组随机访问。Rukhanka 先做一次数据压平:
csharp
// ① CalculateBoneOffsetsJob → 统计每个 Rig 有多少根骨骼
// ② DoPrefixSumJob → 前缀和,算出每个 Rig 在平铺数组里的起始偏移
// [0, rig0.bones, rig0.bones+rig1.bones, ...]
// ③ CalculatePerBoneInfoJob → boneToEntityArr[globalBoneIndex] = (entityIndex, rigBoneIndex)
结果是一个扁平的 NativeList<BoneTransform> finalAllAnimatedBones:
| Rig0_bone0 | Rig0_bone1 | ... | Rig0_boneN | Rig1_bone0 | Rig1_bone1 | ... |
↑ entityToDataOffsetMap[Rig0] ↑ entityToDataOffsetMap[Rig1]
这样 Job 里就能用 boneTransforms[globalIndex] 直接随机访问任意 Rig 的任意骨骼,无锁。
4.3 ComputeBoneAnimationJob(最重的 Job)
IJobParallelForBatch,Batch size = 16(一次处理 16 根骨骼):
csharp
void ExecuteSingle(int globalBoneIndex)
{
// ① 反查这根骨骼属于哪个 Entity、哪根 Rig 骨骼
var (rigBoneIndex, entityIndex) = boneToEntityIndices[globalBoneIndex];
// ② 拿到骨骼的参考姿势
var boneRefPose = GetBoneRefPose(ref rb, rigBoneIndex, e);
// ③ 没有动画在播放 → 直接返回参考姿势
if (animationsToProcess.IsEmpty) { outBlendedBones[globalBoneIndex] = boneRefPose; }
// ④ 计算各动画层权重
var refPosWeight = CalculateFinalLayerWeights(layerWeights, animationsToProcess, ...);
// ⑤ 遍历所有正在播放的动画
for (int i = 0; i < animationsToProcess.Length; ++i)
{
// Hash128 在 boneMap 里做 O(1) 骨骼匹配
var foundMatchedBone = atp.boneMap.TryGetValue(rb.hash, out var animationBoneIndex);
// 贝塞尔曲线插值采样关键帧
var (bonePose, flags) = SampleAnimation(ref boneAnimation, animTime, atp);
// 按权重混合(Override / Additive)
MixPoses(ref blendedBonePose, bonePose, modWeight, atp.blendMode);
}
outBlendedBones[globalBoneIndex] = blendedBonePose;
}
这一步最耗 CPU,因为每根骨骼、每层动画、每个通道(position/rotation/scale)都要做贝塞尔插值。但因为数据是扁平的 NativeArray,128 根骨骼 × 3 层动画可以充分利用多核 CPU。
贝塞尔插值的数学:
csharp
static public float ProcessBezierCurve(KeyFrame f0, KeyFrame f1, float l)
{
float dt = f1.time - f0.time;
float m0 = f0.outTan * dt;
float m1 = f1.inTan * dt;
float t2 = l * l;
float t3 = t2 * l;
float a = 2 * t3 - 3 * t2 + 1;
float b = t3 - 2 * t2 + l;
float c = t3 - t2;
float d = -2 * t3 + 3 * t2;
return a * f0.v + b * m0 + c * m1 + d * f1.v;
}
五、阶段四:填 SkinMatrix Buffer
5.1 ApplyAnimationToSkinnedMeshJob
csharp
// AnimationProcessSystem_Jobs.cs : 856-917
public void Execute(in ArchetypeChunk chunk, ...)
{
while (cee.NextEntityIndex(out var i))
{
var skinMatrixBuf = skinMatrixBufferAccessor[i];
// ① 骨骼重映射:Rig 骨骼 → SkinnedMesh 骨骼
// (两者骨骼排列顺序可能不同,用哈希匹配表做映射)
ref var boneRemapTable = ref GetBoneRemapTable(skinnedMesh.boneInfos, rigDef.rigBlob);
// ② 计算 entity→rootBone 变换矩阵
var entityToRootBoneTransform = float4x4.identity;
if (skinnedMesh.rootBoneEntity != Entity.Null) {
var trs = ltLookup[skinnedMesh.rootBoneEntity];
entityToRootBoneTransform = math.inverse(new BoneTransform(trs).ToFloat4x4());
}
// ③ 遍历 Rig 所有骨骼
for (int animationBoneIndex = 0; animationBoneIndex < rigBones.Length; ++animationBoneIndex)
{
var skinnedMeshBoneIndex = boneRemapTable.rigBoneToSkinnedMeshBoneRemapIndices[animationBoneIndex];
if (skinnedMeshBoneIndex < 0) continue;
// ④ 计算绝对骨骼变换(从根节点到当前节点的完整变换链)
var absBonePose = MakeAbsoluteTransform(absoluteBoneTransforms, animationBoneIndex, ...);
var boneXForm = absBonePose.ToFloat4x4();
// ⑤ 最终蒙皮矩阵
var skinMatrix = MakeSkinMatrixForBone(ref boneInfo, boneXForm, entityToRootBoneTransform);
// ⑥ 写入 DynamicBuffer<SkinMatrix>
outSkinMatricesBuf[skinnedMeshBoneIndex] = skinMatrix;
}
}
}
5.2 骨骼层级展平(MakeAbsoluteTransform)
csharp
BoneTransform MakeAbsoluteTransform(..., int boneIndex, ...)
{
// 从当前骨骼一路乘到根
while (boneIndex >= 0)
{
var animatedBoneTransform = absBoneTransforms[boneIndex];
resultBoneTransform = BoneTransform.Multiply(animatedBoneTransform, resultBoneTransform);
if (isHierarchyTreeCombined) break;
boneIndex = boneDef.parentBoneIndex; // 往上走
}
return resultBoneTransform;
}
5.3 最终蒙皮矩阵
csharp
SkinMatrix MakeSkinMatrixForBone(ref SkinnedMeshBoneInfo boneInfo,
in float4x4 boneXForm, in float4x4 entityToRootBoneTransform)
{
var boneTransformMatrix = math.mul(entityToRootBoneTransform, boneXForm);
boneTransformMatrix = math.mul(boneTransformMatrix, boneInfo.bindPose);
return new SkinMatrix() {
Value = new float3x4(
boneTransformMatrix.c0.xyz,
boneTransformMatrix.c1.xyz,
boneTransformMatrix.c2.xyz,
boneTransformMatrix.c3.xyz
)
};
}
数学本质 :最终蒙皮矩阵 = entityToRootBone × 骨骼动画绝对变换 × bindPose
六、Rukhanka 的 9 个 Job 总览
| Job 名 | 类型 | 输入 | 输出 | 用途 |
|---|---|---|---|---|
CalculateBoneOffsetsJob |
IJobChunk |
RigDefinitionComponent |
bonePosesOffsets[] |
统计每个 Rig 骨骼数 |
DoPrefixSumJob |
IJob |
bonePosesOffsets[] |
前缀和数组 | 算平铺偏移 |
CalculatePerBoneInfoJob |
IJobChunk |
Rig 定义 + 实体 | boneToEntityArr[] + entityToDataOffsetMap |
压平数据映射 |
ComputeBoneAnimationJob |
IJobParallelForBatch |
动画曲线 + Rig 定义 | finalAllAnimatedBones[] |
最重:采样 + 混合 |
ProcessUserCurvesJob |
IJobChunk |
动画曲线 | Animator 参数值 | 驱动 Animator Controller 参数 |
ApplyRootMotionJob |
IJobChunk |
根骨骼动画 | LocalTransform |
根运动应用到 Entity |
PropagateBoneTransformToEntityTRSJob |
IJobChunk |
骨骼 pose | LocalTransform 组件 |
骨骼 pose 写回 Transform |
ApplyAnimationToSkinnedMeshJob |
IJobChunk |
骨骼 pose + 骨骼数据 | DynamicBuffer<SkinMatrix> |
算蒙皮矩阵 |
FillRigToSkinBonesRemapTableCacheJob |
IJob |
Rig + SkinnedMesh | BoneRemapTableBlob |
哈希匹配骨骼映射 |
七、CPU 与 GPU 的分工
7.1 CPU 做的
① 贝塞尔曲线采样(每根骨骼每个通道的浮点插值)
② 多动画层混合(Override/Additive,线性加权)
③ 骨骼层级树遍历(从子节点一路乘到根节点)
④ 计算最终 SkinMatrix(entityToRoot × absPose × bindPose)
⑤ 写入 DynamicBuffer<SkinMatrix>
7.2 GPU 做的
⑥ Vertex Shader 从 SkinMatrix buffer 读矩阵
⑦ 对每个顶点:v' = Σ w_i × M_i × v(4根骨骼加权蒙皮)
⑧ 继续常规渲染管线(投影、光栅化、像素着色)
7.3 为什么这种分工合理
| 任务类型 | 在哪跑 | 原因 |
|---|---|---|
| 曲线采样 + 树遍历 | CPU | 分支多、数据依赖强,CPU 的分支预测和缓存友好 |
| MAC 运算(乘加) | GPU | 每个顶点做一样的事,SIMT 架构天然适合 |
Rukhanka 的本质优化 不是"把 CPU 活搬到 GPU",而是把原来 SkinnedMeshRenderer 主线程单线程的 CPU 蒙皮,拆成:
- 动画计算:JobSystem 多线程 + Burst SIMD
- 顶点变形:GPU 硬件加速
八、CPU 传给 GPU 的数据
8.1 只有一样东西:SkinMatrix 数组
每个蒙皮网格 → N 个 float3x4
N = 该网格的骨骼数量
以 96 根骨骼的人形角色为例:
96 根骨骼 × 3 个 float4 × 16 bytes = 4,608 bytes ≈ 4.5 KB
100 个同屏角色 ≈ 450 KB。对 PCIe 带宽来说微不足道。
8.2 什么没有传给 GPU
| 没传的数据 | 留在了 CPU |
|---|---|
| AnimationClip 曲线 | Job 采样完就丢弃了 |
| 骨骼层级树结构 | 只用于 CPU 侧链式相乘 |
| 混合权重 | 只在 MixPose 里用一次 |
| AvatarMask | 只在 CalculateFinalLayerWeights 里用 |
GPU 只关心最终结果:每根骨骼把顶点拉到哪个位置。它不关心这个位置是怎么算出来的。
九、DynamicBuffer<SkinMatrix> 是什么
9.1 它是 ECS 的内置类型
csharp
// Unity.Entities 核心库
public struct DynamicBuffer<T> : ... where T : unmanaged, IBufferElementData
和 IComponentData 平级,区别:
| IComponentData | DynamicBuffer | |
|---|---|---|
| 大小 | 固定(struct 大小) | 可变 |
| 存储 | Chunk 主体内存 | Chunk 附加内存 |
| 访问 | entity.GetComponent<T>() |
entity.GetBuffer<T>() |
9.2 底层特殊处理
DynamicBuffer<SkinMatrix> 虽然是 ECS API,但 Unity ECS Rendering 在底层做了特殊处理:
- 识别到
SkinMatrix类型 → 分配在 CPU-GPU 共享内存(D3D12/Vulkan 的 persistent mapped buffer) - 注册为渲染管线的 vertex buffer
- CPU 写、GPU 读同一块物理地址,零拷贝
9.3 和 ComputeBuffer 的区别
| DynamicBuffer<SkinMatrix> | ComputeBuffer | |
|---|---|---|
| 内存位置 | CPU-GPU 共享内存 | GPU 显存 |
| CPU 写入 | 直接写内存地址(Job 也能写) | SetData 触发 PCIe 拷贝(必须主线程) |
| GPU 读取 | 直接读取 | 直接读取 |
| 拷贝开销 | 零拷贝 | 每次 SetData 都传输 |
| 同步 | 引擎自动双缓冲 | 手动 fence/barrier |
| API 层级 | ECS 组件,buf[i] = value |
图形 API,cb.SetData() |
9.4 类比
ComputeBuffer 是"发快递":你把数据打包(SetData),快递员送过去(PCIe 传输),收件人收到才能拆包。
DynamicBuffer<SkinMatrix> 是"共享办公室":你把文件直接写在共享桌子上,同事走过来看。不需要寄送------你们在同一间屋子。
十、骨骼匹配:FillRigToSkinBonesRemapTableCache
这是一个常被忽略但很关键的设计。
问题 :Rig(Animator 的骨骼层级)和 SkinnedMesh(bones[] 数组)的骨骼排列顺序不一定一致。同一个模型可能 Rig 里骨骼顺序是 [hips, spine, chest, head],但 mesh 的 bones[] 顺序是 [head, chest, spine, hips]。
Rukhanka 的解法:用 Hash128 做匹配。
csharp
for (int i = 0; i < rigDef.rigBlob.Value.bones.Length; ++i)
{
bba[i] = -1; // 默认不匹配
ref var rb = ref rigDef.rigBlob.Value.bones[i];
var rbHash = rb.hash;
for (int j = 0; j < sm.boneInfos.Value.bones.Length; ++j)
{
ref var bn = ref sm.boneInfos.Value.bones[j];
if (bn.hash == rbHash) // Hash128 比较 = 一条 CPU 指令
{
bba[i] = j; // Rig 骨骼 i → SkinnedMesh 骨骼 j
break;
}
}
}
结果是一个 BoneRemapTableBlob:rigBoneToSkinnedMeshBoneRemapIndices[i] = j,表示"Rig 的第 i 根骨骼对应 SkinnedMesh 的第 j 根骨骼"。不匹配的设为 -1,在 ApplyAnimationToSkinnedMeshJob 里 continue 跳过。
这个 remap table 是一个 BlobAsset ,只需算一次,缓存在 rigToSkinnedMeshRemapTables HashMap 里,后续帧直接 O(1) 查找。
十一、去重机制总结
Rukhanka 在两个层面做了去重:
| 层级 | 时机 | 方式 | 收益 |
|---|---|---|---|
| Blob Asset | Baking | 相同 sharedMesh.GetHashCode() → 共享一个 SkinnedMeshInfoBlob |
100 个相同怪物=1 份骨骼数据 |
| Remap Table | 运行时首次遇到 | 相同 (meshHash + rigHash) → 缓存 BoneRemapTableBlob |
后续帧 O(1) 跳过哈希匹配 |
十二、一句话总结全流程
SkinnedMeshRenderer(主线程 Baker 提取骨骼数据)
→ BlobAsset(BakingSystem 转换,Worker Thread 执行)
→ JobSystem 并行算动画(ComputeBoneAnimationJob)
→ 写入 DynamicBuffer<SkinMatrix>(ApplyAnimationToSkinnedMeshJob)
→ GPU Vertex Shader 读取(Unity ECS Rendering 自动绑定)
→ 顶点蒙皮变形 → 渲染输出
CPU 管"怎么动"(曲线采样 + 树遍历 + 矩阵乘法)
GPU 管"怎么变"(顶点加权蒙皮)
Rukhanka 是 CPU 侧的"骨骼矩阵计算引擎",不做渲染
这个方案相比较GPU ECS Animation的优势是支持丰富的动画应用场景