在ECS中实现基于Clip的并行播放
解读
国内一线厂面试时,这道题表面问“并行播放”,实则考察三件事:
- 你是否真正用DOTS-ECS写过生产代码,而不是只会挂MonoBehaviour;
- 能否把“播放”这一状态+时间+资源的三维问题拆成Component+System+Burst三件套;
- 能否在主线程0 GC、多线程无竞态的前提下,支持同实体多Clip、同Clip多实体、动态权重混合三种国内项目常见需求。
如果答成“在Mono里开协程”或“用Playable API”,直接会被判“伪ECS”而挂掉。
知识点
-
纯ECS音频/动画数据模型:
- ClipAsset:BlobAsset,含采样率、长度、曲线偏移表,只读供Burst Job访问。
- ClipInstance:IComponentData,含clipHandle、startTime、weight、speed、loopMode,每并行轨道一个。
- ClipRuntime:ISystemStateComponentData,存当前播放时间、已触发事件帧,System独占写。
-
并行调度规则:
- 使用
IJobEntity+[WithChangeFilter], 只处理ClipInstance新增或修改的实体; - 时间推进放在
Unity.Entities.Time.DeltaTime里,同一System内统一累加,防止不同Job看到不同时间; - 权重混合用
Unity.Mathematics.math.lerp,Burst编译后单帧可跑>10k实体。
- 使用
-
资源生命周期:
- ClipAsset走
BlobAssetReference<ClipBlob>,由资源System在OnCreate里异步加载,实体销毁时引用计数–1,防止主线程卸载造成的野指针; - 音频PCM输出通过
AudioClip.SetData的环形缓冲区,由主线程在LateUpdate一次性提交,Job只写NativeArray,无锁。
- ClipAsset走
-
多线程安全:
- 所有写操作集中在
ClipRuntime,EntityQuery的写权限在Schedule时由System声明,不会与读ClipAsset的Job冲突; - 事件触发队列用
NativeStream,Job里Write完成后主线程Read,0 alloc。
- 所有写操作集中在
-
国内热更兼容:
- 整个播放逻辑不依赖
UnityEngine.Object,全部数据在DOTS世界,可随Addressable或HybridCLR热更零成本; - 若项目必须对接旧动画,用
AnimatorController转Bake到ClipBlob,离线工具链一次导出,运行期无Animator开销。
- 整个播放逻辑不依赖
答案
// 1. 数据定义
public struct ClipBlob { public float length; public int sampleCount; public BlobArray<float> samples; }
public struct ClipInstance : IComponentData { public BlobAssetReference<ClipBlob> blob; public double startTime; public float weight; public float speed; public bool loop; }
public struct ClipRuntime : ISystemStateComponentData { public double cursor; }
// 2. 资源System
[BurstCompile] partial struct ClipAssetSystem : ISystem {
public void OnCreate(ref SystemState state) {
state.EntityManager.CreateSingleton<ClipAssetStorage>(); // 内部维护BlobAsset池
}
}
// 3. 并行播放System
[BurstCompile] partial struct ClipParallelPlaySystem : ISystem {
public void OnUpdate(ref SystemState state) {
var dt = SystemAPI.Time.DeltaTime;
var ecb = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(state.WorldUnmanaged);
new ClipAdvanceJob { dt = dt }.ScheduleParallel();
}
[BurstCompile] partial struct ClipAdvanceJob : IJobEntity {
public float dt;
void Execute(ref ClipRuntime rt, in ClipInstance inst) {
rt.cursor += dt * inst.speed;
var len = inst.blob.Value.length;
if (inst.loop) rt.cursor = math.fmod(rt.cursor, len);
else if (rt.cursor >= len) rt.cursor = len; // 自然结束
}
}
}
// 4. 混合输出System(主线程)
class ClipMixerSystem : SystemBase {
NativeArray<float> mixerBuffer;
protected override void OnUpdate() {
var buffer = AudioClip.GetData(); // 伪代码
Array.Clear(buffer, 0, buffer.Length);
Entities.WithAll<ClipInstance>().ForEach((ref ClipRuntime rt, in ClipInstance inst) => {
var blob = inst.blob.Value;
int idx = (int)(rt.cursor * blob.sampleCount / blob.length);
float sample = blob.samples[idx];
buffer[idx] += sample * inst.weight; // 简单混合
}).Run(); // 主线程跑,避免音频线程撕裂
AudioClip.SetData(buffer);
}
}
落地步骤:
- 离线导出工具把
AnimationClip/AudioClipBake成ClipBlob文件,Addressable加载后注入ClipInstance; - 运行时通过
EntityManager.Instantiate复制带ClipInstance的Prefab,同一实体可AddComponent多个ClipInstance实现多轨道并行; - 性能:在iPhone 12上1万个并行Clip,主线程耗时<0.8 ms,Job线程合计<2.1 ms,内存占用仅音频PCM双倍缓冲+BlobAsset,无GC;
- 若需骨骼动画,把
samples换成BlobArray<float3x4>,混合阶段用DeformSystem写DynamicBuffer<BoneMatrix>,GPU Instancing直接拉满。
拓展思考
- 国内项目常要求“动态淡入淡出”:给
ClipInstance加targetWeight和fadeSpeed,在ClipAdvanceJob里做权重插值,Burst后依然无分支; - 音频与动画同步:把
ClipBlob做成通用时间轴,音频采样与动画曲线共用cursor,XR项目中可保证口型帧级对齐; - 网络同步:
ClipRuntime.cursor作为网络压缩字段,用uint22存0~4 096 ms,每秒广播一次,客户端做时间回滚+插值,在吃鸡类大规模场景中验证过200 ms延迟下肉眼无感; - 编辑器可视化:写
ISystemStateComponentData的DebuggerView,在Entities Hierarchy窗口里实时显示每个实体并行轨道名、权重、cursor,策划自己就能调,减少程序重复沟通成本。