Unity高效的DOTS动画方案Rukhanka深度剖析

Unity高效的DOTS动画方案Rukhanka深度剖析
Rukhanka 深度剖析前言一个常见的误解很多人第一次接触 Rukhanka 时以为“它把 SkinnedMeshRenderer 转成了 MeshRenderer然后走 GPU 蒙皮。”这个理解是错的。Rukhanka 根本没有做转换它做的事更底层接管骨骼矩阵的计算然后把结果写进 Unity ECS 的 SkinMatrix buffer剩下的交给 ECS 渲染管线。没有 SkinnedMeshRenderer → MeshRenderer 的转换过程。在 ECS 世界里压根就没有Renderer 组件这个概念只有 Entity Component。一、Rukhanka 的完整管线四个阶段Baking 阶段离线/编辑器 ┌────────────────────────────────────────────────────────────┐ │ 阶段一SkinnedMeshBaker │ │ BakerSkinnedMeshRenderer 主线程执行 │ │ 提取bones[], bindposes[], rootBone │ │ 输出临时 SkinnedMeshBakerData 组件 │ └────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 阶段二SkinnedMeshConversionSystem │ │ BakingSystemJobSystem 并行执行 │ │ 输出BlobAssetReferenceSkinnedMeshInfoBlob │ │ AnimatedSkinnedMeshComponent 组件 │ └────────────────────────────────────────────────────────────┘ │ ▼ Runtime 阶段每帧执行全 CPU JobSystem ┌────────────────────────────────────────────────────────────┐ │ 阶段三AnimationsProcessSystem.OnUpdate │ │ 9个 Job 串联成管线 │ │ 核心输出finalAllAnimatedBones (NativeListBoneTransform) │ └────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 阶段四ApplyAnimationToSkinnedMeshJob │ │ 读取骨骼动画结果 → 计算 SkinMatrix → 写入 DynamicBuffer │ └────────────────────────────────────────────────────────────┘ │ ▼ GPUECS Rendering 接管 ┌────────────────────────────────────────────────────────────┐ │ Vertex Shader 读 SkinMatrix buffer → 顶点蒙皮变形 → 渲染 │ └────────────────────────────────────────────────────────────┘二、阶段一Baker 提取骨骼数据2.1 入口publicclassSkinnedMeshBaker:BakerSkinnedMeshRenderer这是一个BakerSkinnedMeshRenderer在主线程执行。Unity ECS Baking 过程中遇到任何带有SkinnedMeshRenderer的 GameObject都会触发它。2.2 提取了什么// SkinnedMeshBaker.cs : 55-80RTP.SkinnedMeshBoneDataCreateSkinnedMeshBoneData(SkinnedMeshRendererr){bnd.bonesnewUnsafeListSkinnedMeshBoneDefinition(r.bones.Length,...);for(intj0;jr.bones.Length;j){varbr.bones[j];// ① 对骨骼名称计算 Hash128后续 Job 里做 O(1) 哈希匹配oneBoneInfo.hashnewFixedStringName(b.name).CalculateHash128();// ② 直接从 sharedMesh.bindposes 数组拷贝Unity 预计算的逆矩阵oneBoneInfo.bindPoser.sharedMesh.bindposes[j];// ③ 从 Transform 组件读取参考姿势oneBoneInfo.refPose.posb.localPosition;oneBoneInfo.refPose.scaleb.localScale;oneBoneInfo.refPose.rotb.localRotation;}}三个关键数据来源数据来源类型用途hashTransform.name→ Hash128Hash128运行时骨骼匹配bindPosesharedMesh.bindposes[j]float4x4最终蒙皮矩阵计算refPoseTransform.localPosition/Rotation/ScaleBoneTransform无动画时的参考姿势注意bindPose是Unity 在模型导入时预计算好的逆矩阵骨骼空间→模型空间。Rukhanka 不需要自己算直接拿来用。2.3 输出Baker 创建一个临时实体不会进入运行时 World挂上SkinnedMeshBakerDatavarbdnewSkinnedMeshBakerData{skinnedMeshBonessmbd,// 骨骼数据UnsafeListtargetEntityGetEntity(TransformUsageFlags.Dynamic),animatedRigEntityGetEntity(FindParentRigDefinitionAuthoring()),rootBoneEntityGetEntity(a.rootBone),hasha.sharedMesh.GetHashCode(),// 用于去重};三、阶段二BakingSystem 转成 Blob Asset3.1 SkinnedMeshConversionSystem这是一个[WorldSystemFilter(WorldSystemFilterFlags.BakingSystem)]系统在 Baking World 里运行。3.2 核心逻辑——去重// 按 hash 排序相同 mesh 只创建一个 Blob AssetskinnedMeshesData.Sort(newSkinnedMeshBakerDataSorter());for(inti1;iskinnedMeshesData.Length;i){if(rd.hash!startHash){// 相同 hash 的实体 → 共享同一个 BlobAssetReferencevarjnewCreateBlobAssetsJob(){datarefSkinnedMesh,outBlobAssetsblobAssetsSlice,};j.Schedule();}}意义100 个怪物用同一个模型 → 骨骼数据只 Blob 一次100 个实体共享一个BlobAssetReference。运行时读同一块内存不需要复制。3.3 CreateBlobAssetsJobIJobWorker Thread 执行publicvoidExecute(){varbbnewBlobBuilder(Allocator.Temp);refvarblobAssetrefbb.ConstructRootSkinnedMeshInfoBlob();varboneInfoArrbb.Allocate(refblobAsset.bones,data.skinnedMeshBones.bones.Length);for(inti0;idata.skinnedMeshBones.bones.Length;i){boneInfoArr[i].hashsrc.hash;boneInfoArr[i].bindPosesrc.bindPose;// float4x4boneInfoArr[i].refPosesrc.refPose;// BoneTransform}// BlobAsset 分配在 Persistent 内存Baking 结束后不会释放varrvbb.CreateBlobAssetReferenceSkinnedMeshInfoBlob(Allocator.Persistent);}3.4 CreateComponentDatasJobIJobParallelForBatch把AnimatedSkinnedMeshComponent通过 ECB 添加到目标 Entityecb.AddComponent(startIndex,e,newAnimatedSkinnedMeshComponent{boneInfosblobAssets[i],// BlobAssetReferenceSkinnedMeshInfoBlobrootBoneEntitysmb.rootBoneEntity,animatedRigEntitysmb.animatedRigEntity,});这个AnimatedSkinnedMeshComponent就是 Rukhanka 和 Unity ECS Rendering 之间的桥梁。有了它运行时系统才知道这个 Entity 的动画由哪个 Rig 驱动、骨骼数据在哪。同时 Unity ECS Baking 自动给这个 Entity 加上了RenderMeshSkinMatrixbuffer所以 ECS Rendering 也知道它是个蒙皮网格。四、阶段三运行时动画矩阵计算这是每帧最核心的部分。所有代码在AnimationsProcessSystem.OnUpdate里串联全部在 CPU Worker Thread 上通过 JobSystem 执行。4.1 整体调度// AnimationProcessSystem.cs : 333-374publicvoidOnUpdate(refSystemStatess){entityToDataOffsetMap.Clear();varentityCountanimatedObjectQuery.CalculateEntityCount();if(entityCount0)return;// Step 0: 数据准备——计算骨骼偏移压平 NativeArrayvarcalcBoneOffsetsJHPrepareComputationData(chunkBaseEntityIndices,entitiesArr,combinedJH);// Step 1: 计算骨骼动画采样曲线 混合varcomputeAnimationJobHandleAnimationCalculation(refss,entitiesArr,calcBoneOffsetsJH);// Step 2: 处理用户曲线Animator Controller 参数varuserCurveProcessJobHandleProcessUserCurves(refss,computeAnimationJobHandle);// Step 3: 应用 Root MotionvarapplyRootMotionJobHandleApplyRootMotion(refss,computeAnimationJobHandle);// Step 4: 骨骼 pose 写回 Entity 的 LocalTransform 组件varpropagateTRSJobHandlePropagateAnimatedBonesToEntitesTRS(refss,combinedJH);// Step 5: 计算 SkinMatrix 写入 DynamicBuffervarapplySkinJobHandleApplySkinning(refss,propagateTRSJobHandle);ss.DependencyapplySkinJobHandle;}4.2 数据压平PrepareComputationDataECS 的数据分散在 Chunk 里不能直接当数组随机访问。Rukhanka 先做一次数据压平// ① CalculateBoneOffsetsJob → 统计每个 Rig 有多少根骨骼// ② DoPrefixSumJob → 前缀和算出每个 Rig 在平铺数组里的起始偏移// [0, rig0.bones, rig0.bonesrig1.bones, ...]// ③ CalculatePerBoneInfoJob → boneToEntityArr[globalBoneIndex] (entityIndex, rigBoneIndex)结果是一个扁平的NativeListBoneTransform finalAllAnimatedBones| Rig0_bone0 | Rig0_bone1 | ... | Rig0_boneN | Rig1_bone0 | Rig1_bone1 | ... | ↑ entityToDataOffsetMap[Rig0] ↑ entityToDataOffsetMap[Rig1]这样 Job 里就能用boneTransforms[globalIndex]直接随机访问任意 Rig 的任意骨骼无锁。4.3 ComputeBoneAnimationJob最重的 JobIJobParallelForBatchBatch size 16一次处理 16 根骨骼voidExecuteSingle(intglobalBoneIndex){// ① 反查这根骨骼属于哪个 Entity、哪根 Rig 骨骼var(rigBoneIndex,entityIndex)boneToEntityIndices[globalBoneIndex];// ② 拿到骨骼的参考姿势varboneRefPoseGetBoneRefPose(refrb,rigBoneIndex,e);// ③ 没有动画在播放 → 直接返回参考姿势if(animationsToProcess.IsEmpty){outBlendedBones[globalBoneIndex]boneRefPose;}// ④ 计算各动画层权重varrefPosWeightCalculateFinalLayerWeights(layerWeights,animationsToProcess,...);// ⑤ 遍历所有正在播放的动画for(inti0;ianimationsToProcess.Length;i){// Hash128 在 boneMap 里做 O(1) 骨骼匹配varfoundMatchedBoneatp.boneMap.TryGetValue(rb.hash,outvaranimationBoneIndex);// 贝塞尔曲线插值采样关键帧var(bonePose,flags)SampleAnimation(refboneAnimation,animTime,atp);// 按权重混合Override / AdditiveMixPoses(refblendedBonePose,bonePose,modWeight,atp.blendMode);}outBlendedBones[globalBoneIndex]blendedBonePose;}这一步最耗 CPU因为每根骨骼、每层动画、每个通道position/rotation/scale都要做贝塞尔插值。但因为数据是扁平的 NativeArray128 根骨骼 × 3 层动画可以充分利用多核 CPU。贝塞尔插值的数学staticpublicfloatProcessBezierCurve(KeyFramef0,KeyFramef1,floatl){floatdtf1.time-f0.time;floatm0f0.outTan*dt;floatm1f1.inTan*dt;floatt2l*l;floatt3t2*l;floata2*t3-3*t21;floatbt3-2*t2l;floatct3-t2;floatd-2*t33*t2;returna*f0.vb*m0c*m1d*f1.v;}五、阶段四填 SkinMatrix Buffer5.1 ApplyAnimationToSkinnedMeshJob// AnimationProcessSystem_Jobs.cs : 856-917publicvoidExecute(inArchetypeChunkchunk,...){while(cee.NextEntityIndex(outvari)){varskinMatrixBufskinMatrixBufferAccessor[i];// ① 骨骼重映射Rig 骨骼 → SkinnedMesh 骨骼// 两者骨骼排列顺序可能不同用哈希匹配表做映射refvarboneRemapTablerefGetBoneRemapTable(skinnedMesh.boneInfos,rigDef.rigBlob);// ② 计算 entity→rootBone 变换矩阵varentityToRootBoneTransformfloat4x4.identity;if(skinnedMesh.rootBoneEntity!Entity.Null){vartrsltLookup[skinnedMesh.rootBoneEntity];entityToRootBoneTransformmath.inverse(newBoneTransform(trs).ToFloat4x4());}// ③ 遍历 Rig 所有骨骼for(intanimationBoneIndex0;animationBoneIndexrigBones.Length;animationBoneIndex){varskinnedMeshBoneIndexboneRemapTable.rigBoneToSkinnedMeshBoneRemapIndices[animationBoneIndex];if(skinnedMeshBoneIndex0)continue;// ④ 计算绝对骨骼变换从根节点到当前节点的完整变换链varabsBonePoseMakeAbsoluteTransform(absoluteBoneTransforms,animationBoneIndex,...);varboneXFormabsBonePose.ToFloat4x4();// ⑤ 最终蒙皮矩阵varskinMatrixMakeSkinMatrixForBone(refboneInfo,boneXForm,entityToRootBoneTransform);// ⑥ 写入 DynamicBufferSkinMatrixoutSkinMatricesBuf[skinnedMeshBoneIndex]skinMatrix;}}}5.2 骨骼层级展平MakeAbsoluteTransformBoneTransformMakeAbsoluteTransform(...,intboneIndex,...){// 从当前骨骼一路乘到根while(boneIndex0){varanimatedBoneTransformabsBoneTransforms[boneIndex];resultBoneTransformBoneTransform.Multiply(animatedBoneTransform,resultBoneTransform);if(isHierarchyTreeCombined)break;boneIndexboneDef.parentBoneIndex;// 往上走}returnresultBoneTransform;}5.3 最终蒙皮矩阵SkinMatrixMakeSkinMatrixForBone(refSkinnedMeshBoneInfoboneInfo,infloat4x4boneXForm,infloat4x4entityToRootBoneTransform){varboneTransformMatrixmath.mul(entityToRootBoneTransform,boneXForm);boneTransformMatrixmath.mul(boneTransformMatrix,boneInfo.bindPose);returnnewSkinMatrix(){Valuenewfloat3x4(boneTransformMatrix.c0.xyz,boneTransformMatrix.c1.xyz,boneTransformMatrix.c2.xyz,boneTransformMatrix.c3.xyz)};}数学本质最终蒙皮矩阵 entityToRootBone × 骨骼动画绝对变换 × bindPose六、Rukhanka 的 9 个 Job 总览Job 名类型输入输出用途CalculateBoneOffsetsJobIJobChunkRigDefinitionComponentbonePosesOffsets[]统计每个 Rig 骨骼数DoPrefixSumJobIJobbonePosesOffsets[]前缀和数组算平铺偏移CalculatePerBoneInfoJobIJobChunkRig 定义 实体boneToEntityArr[]entityToDataOffsetMap压平数据映射ComputeBoneAnimationJobIJobParallelForBatch动画曲线 Rig 定义finalAllAnimatedBones[]最重采样 混合ProcessUserCurvesJobIJobChunk动画曲线Animator 参数值驱动 Animator Controller 参数ApplyRootMotionJobIJobChunk根骨骼动画LocalTransform根运动应用到 EntityPropagateBoneTransformToEntityTRSJobIJobChunk骨骼 poseLocalTransform组件骨骼 pose 写回 TransformApplyAnimationToSkinnedMeshJobIJobChunk骨骼 pose 骨骼数据DynamicBufferSkinMatrix算蒙皮矩阵FillRigToSkinBonesRemapTableCacheJobIJobRig SkinnedMeshBoneRemapTableBlob哈希匹配骨骼映射七、CPU 与 GPU 的分工7.1 CPU 做的① 贝塞尔曲线采样每根骨骼每个通道的浮点插值 ② 多动画层混合Override/Additive线性加权 ③ 骨骼层级树遍历从子节点一路乘到根节点 ④ 计算最终 SkinMatrixentityToRoot × absPose × bindPose ⑤ 写入 DynamicBufferSkinMatrix7.2 GPU 做的⑥ Vertex Shader 从 SkinMatrix buffer 读矩阵 ⑦ 对每个顶点v Σ w_i × M_i × v4根骨骼加权蒙皮 ⑧ 继续常规渲染管线投影、光栅化、像素着色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 KB100 个同屏角色 ≈ 450 KB。对 PCIe 带宽来说微不足道。8.2 什么没有传给 GPU没传的数据留在了 CPUAnimationClip 曲线Job 采样完就丢弃了骨骼层级树结构只用于 CPU 侧链式相乘混合权重只在 MixPose 里用一次AvatarMask只在 CalculateFinalLayerWeights 里用GPU 只关心最终结果每根骨骼把顶点拉到哪个位置。它不关心这个位置是怎么算出来的。九、DynamicBufferSkinMatrix 是什么9.1 它是 ECS 的内置类型// Unity.Entities 核心库publicstructDynamicBufferT:...whereT:unmanaged,IBufferElementData和IComponentData平级区别IComponentDataDynamicBuffer大小固定struct 大小可变存储Chunk 主体内存Chunk 附加内存访问entity.GetComponentT()entity.GetBufferT()9.2 底层特殊处理DynamicBufferSkinMatrix虽然是 ECS API但 Unity ECS Rendering 在底层做了特殊处理识别到SkinMatrix类型 → 分配在CPU-GPU 共享内存D3D12/Vulkan 的 persistent mapped buffer注册为渲染管线的 vertex bufferCPU 写、GPU 读同一块物理地址零拷贝9.3 和 ComputeBuffer 的区别DynamicBufferSkinMatrixComputeBuffer内存位置CPU-GPU 共享内存GPU 显存CPU 写入直接写内存地址Job 也能写SetData触发 PCIe 拷贝必须主线程GPU 读取直接读取直接读取拷贝开销零拷贝每次 SetData 都传输同步引擎自动双缓冲手动 fence/barrierAPI 层级ECS 组件buf[i] value图形 APIcb.SetData()9.4 类比ComputeBuffer 是发快递你把数据打包SetData快递员送过去PCIe 传输收件人收到才能拆包。DynamicBufferSkinMatrix 是共享办公室你把文件直接写在共享桌子上同事走过来看。不需要寄送——你们在同一间屋子。十、骨骼匹配FillRigToSkinBonesRemapTableCache这是一个常被忽略但很关键的设计。问题RigAnimator 的骨骼层级和 SkinnedMeshbones[]数组的骨骼排列顺序不一定一致。同一个模型可能 Rig 里骨骼顺序是[hips, spine, chest, head]但 mesh 的bones[]顺序是[head, chest, spine, hips]。Rukhanka 的解法用 Hash128 做匹配。for(inti0;irigDef.rigBlob.Value.bones.Length;i){bba[i]-1;// 默认不匹配refvarrbrefrigDef.rigBlob.Value.bones[i];varrbHashrb.hash;for(intj0;jsm.boneInfos.Value.bones.Length;j){refvarbnrefsm.boneInfos.Value.bones[j];if(bn.hashrbHash)// Hash128 比较 一条 CPU 指令{bba[i]j;// Rig 骨骼 i → SkinnedMesh 骨骼 jbreak;}}}结果是一个BoneRemapTableBlobrigBoneToSkinnedMeshBoneRemapIndices[i] j表示Rig 的第 i 根骨骼对应 SkinnedMesh 的第 j 根骨骼。不匹配的设为 -1在ApplyAnimationToSkinnedMeshJob里continue跳过。这个 remap table 是一个BlobAsset只需算一次缓存在rigToSkinnedMeshRemapTablesHashMap 里后续帧直接 O(1) 查找。十一、去重机制总结Rukhanka 在两个层面做了去重层级时机方式收益Blob AssetBaking相同sharedMesh.GetHashCode()→ 共享一个SkinnedMeshInfoBlob100 个相同怪物1 份骨骼数据Remap Table运行时首次遇到相同(meshHash rigHash)→ 缓存BoneRemapTableBlob后续帧 O(1) 跳过哈希匹配十二、一句话总结全流程SkinnedMeshRenderer主线程 Baker 提取骨骼数据 → BlobAssetBakingSystem 转换Worker Thread 执行 → JobSystem 并行算动画ComputeBoneAnimationJob → 写入 DynamicBufferSkinMatrixApplyAnimationToSkinnedMeshJob → GPU Vertex Shader 读取Unity ECS Rendering 自动绑定 → 顶点蒙皮变形 → 渲染输出 CPU 管怎么动曲线采样 树遍历 矩阵乘法 GPU 管怎么变顶点加权蒙皮 Rukhanka 是 CPU 侧的骨骼矩阵计算引擎不做渲染这个方案相比较GPU ECS Animation的优势是支持丰富的动画应用场景之前的对比教程