如何降低高频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 以下,并且不破坏业务逻辑可维护性。
知识点
- Unity 物理回调机制:
OnTriggerEnter在Physics.Simulate之后由底层PhysicsManager统一派发,参数对象每次都 new,无法避免。 - GC 根来源:Collision 结构体 → 装箱到堆;
Collider.gameObject属性每次访问都做一次Native-Mono 句柄转换;GetComponent会触发类型哈希查找与缓存数组扩容。 - 对象池与缓存:用静态字典
Dictionary<int, MyEntity>把InstanceID映射到自定义实体,避免反复GetComponent。 - Layer 与位掩码:提前把可交互对象放到专用 Layer,用
layerMask & (1 << other.gameObject.layer)代替CompareTag,完全无堆分配。 - 手动触发队列:在
FixedUpdate里用Physics.OverlapBoxNonAlloc或Physics.SphereCastNonAlloc一次性检测,把进出事件写进环形队列,彻底绕过原生回调。 - IL2CPP 与 Burst:在 IL2CPP 编译下,结构体泛型不再装箱,可把事件数据存进
NativeQueue<TriggerEvent>,配合 Burst 的IJobParallelForBatch实现零 GC 多线程处理。 - 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_MASK 做 Physics.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。”
拓展思考
- 如果项目已经重度依赖
OnTriggerXXX回调,可否用 Harmony 在 IL 层注入缓存,而不改业务脚本? - 当场景出现上千个刚体时,
OverlapBoxNonAlloc的 CPU 开销会反超原生回调,此时如何动态 LOD 检测频率,做到“远距 10 Hz、近距 60 Hz”? - DOTS 时代,Physics Shape Component 把 Trigger 事件写进
ITriggerEventsJob,但主线程仍需消费NativeStream,如何设计零拷贝的事件分发,让 UI、音频、VFX 三个系统并行订阅而不产生竞争?