在ECS中实现基于Clip的并行播放

解读

国内一线厂面试时,这道题表面问“并行播放”,实则考察三件事:

  1. 你是否真正用DOTS-ECS写过生产代码,而不是只会挂MonoBehaviour;
  2. 能否把“播放”这一状态+时间+资源的三维问题拆成Component+System+Burst三件套;
  3. 能否在主线程0 GC、多线程无竞态的前提下,支持同实体多Clip、同Clip多实体、动态权重混合三种国内项目常见需求。
    如果答成“在Mono里开协程”或“用Playable API”,直接会被判“伪ECS”而挂掉。

知识点

  1. 纯ECS音频/动画数据模型

    • ClipAsset:BlobAsset,含采样率、长度、曲线偏移表,只读供Burst Job访问。
    • ClipInstance:IComponentData,含clipHandle、startTime、weight、speed、loopMode,每并行轨道一个
    • ClipRuntime:ISystemStateComponentData,存当前播放时间、已触发事件帧,System独占写
  2. 并行调度规则

    • 使用IJobEntity+[WithChangeFilter], 只处理ClipInstance新增或修改的实体;
    • 时间推进放在Unity.Entities.Time.DeltaTime里,同一System内统一累加,防止不同Job看到不同时间;
    • 权重混合用Unity.Mathematics.math.lerpBurst编译后单帧可跑>10k实体。
  3. 资源生命周期

    • ClipAsset走BlobAssetReference<ClipBlob>,由资源SystemOnCreate里异步加载,实体销毁时引用计数–1,防止主线程卸载造成的野指针;
    • 音频PCM输出通过AudioClip.SetData环形缓冲区,由主线程在LateUpdate一次性提交,Job只写NativeArray,无锁
  4. 多线程安全

    • 所有写操作集中在ClipRuntimeEntityQuery的写权限在Schedule时由System声明,不会与读ClipAsset的Job冲突
    • 事件触发队列用NativeStream,Job里Write完成后主线程Read0 alloc
  5. 国内热更兼容

    • 整个播放逻辑不依赖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);
    }
}

落地步骤

  1. 离线导出工具把AnimationClip/AudioClip Bake成ClipBlob文件,Addressable加载后注入ClipInstance
  2. 运行时通过EntityManager.Instantiate复制带ClipInstance的Prefab,同一实体可AddComponent多个ClipInstance实现多轨道并行;
  3. 性能:在iPhone 12上1万个并行Clip,主线程耗时<0.8 ms,Job线程合计<2.1 ms,内存占用仅音频PCM双倍缓冲+BlobAsset,无GC;
  4. 若需骨骼动画,把samples换成BlobArray<float3x4>,混合阶段用DeformSystemDynamicBuffer<BoneMatrix>GPU Instancing直接拉满。

拓展思考

  1. 国内项目常要求“动态淡入淡出”:给ClipInstancetargetWeightfadeSpeed,在ClipAdvanceJob里做权重插值Burst后依然无分支;
  2. 音频与动画同步:把ClipBlob做成通用时间轴,音频采样与动画曲线共用cursorXR项目中可保证口型帧级对齐;
  3. 网络同步ClipRuntime.cursor作为网络压缩字段,用uint22存0~4 096 ms,每秒广播一次,客户端做时间回滚+插值,在吃鸡类大规模场景中验证过200 ms延迟下肉眼无感;
  4. 编辑器可视化:写ISystemStateComponentDataDebuggerView,在Entities Hierarchy窗口里实时显示每个实体并行轨道名、权重、cursor,策划自己就能调,减少程序重复沟通成本。