如何降低高频Trigger Enter/Exit的GC

解读

国内项目普遍在移动端跑60 fps,Trigger 事件每帧都可能被物理引擎回调几十次。Unity 的 OnTriggerEnter/Exit 会把碰撞对包装成临时 Collision 数组并做装箱(boxing),如果脚本里写了 GetComponent<>、CompareTag、gameObject.name 等高频 API,就会连带产生字符串拼接、HashTable 查找、GC.Alloc。面试时,考官想看的不只是“少写代码”,而是能否从物理引擎层、缓存层、框架层三处同时下手,把 0.3 ms 的 GC 峰值压到 0.03 ms 以下,并且不破坏业务逻辑可维护性

知识点

  1. Unity 物理回调机制OnTriggerEnterPhysics.Simulate 之后由底层 PhysicsManager 统一派发,参数对象每次都 new,无法避免。
  2. GC 根来源:Collision 结构体 → 装箱到堆;Collider.gameObject 属性每次访问都做一次Native-Mono 句柄转换GetComponent 会触发类型哈希查找缓存数组扩容
  3. 对象池与缓存:用静态字典 Dictionary<int, MyEntity>InstanceID 映射到自定义实体,避免反复 GetComponent
  4. Layer 与位掩码:提前把可交互对象放到专用 Layer,用 layerMask & (1 << other.gameObject.layer) 代替 CompareTag完全无堆分配
  5. 手动触发队列:在 FixedUpdate 里用 Physics.OverlapBoxNonAllocPhysics.SphereCastNonAlloc一次性检测,把进出事件写进环形队列,彻底绕过原生回调。
  6. IL2CPP 与 Burst:在 IL2CPP 编译下,结构体泛型不再装箱,可把事件数据存进 NativeQueue<TriggerEvent>,配合 Burst 的 IJobParallelForBatch 实现零 GC 多线程处理
  7. Profiler 验证:使用Unity Profiler 的 GC.Alloc 列Deep Profile 模式,确认单次 Trigger 回调内存分配从 120 B 降到 0 B。

答案

“我们分三步把高频 Trigger 的 GC 降到 0:
第一步,框架层缓存。项目启动时把所有可交互对象注册到 EntityManager,以 InstanceID 为 key 缓存组件引用;OnTriggerEnter 里只取一次 other.GetInstanceID(),从字典直接拿实体,杜绝 GetComponent
第二步,层与位掩码。把玩家、敌人、道具分到 8~15 层,用 contactFilter.layerMask = PLAYER_MASK | PROP_MASKPhysics.OverlapBoxNonAlloc 预筛选,完全避免字符串比较
第三步,队列化替代回调。在 FixedUpdate 里用 PhysicsScene.OverlapBox 批量检测,对比上一帧的 NativeHashMap<int, byte> 记录,手动 emit Enter/Exit 事件,数据用 TriggerEvent 结构体(含两个 int ID)存入 NativeQueue主线程只消费队列,无装箱、无托管内存分配。上线实测 iPhone 11 连续 30 min 每秒 200 次 Trigger,GC 从 1.2 MB/min 降到 0 B,帧时间抖动 < 0.2 ms。”

拓展思考

  1. 如果项目已经重度依赖 OnTriggerXXX 回调,可否用 Harmony 在 IL 层注入缓存,而不改业务脚本?
  2. 当场景出现上千个刚体时,OverlapBoxNonAlloc 的 CPU 开销会反超原生回调,此时如何动态 LOD 检测频率,做到“远距 10 Hz、近距 60 Hz”?
  3. DOTS 时代,Physics Shape Component 把 Trigger 事件写进 ITriggerEventsJob,但主线程仍需消费 NativeStream,如何设计零拷贝的事件分发,让 UI、音频、VFX 三个系统并行订阅而不产生竞争?