用完即毁:一个 77 行的对象池为什么选择不回收
godot-game-one 每秒要发射几十颗子弹、产生十几个受击特效。如果每次都 instantiate() + queue_free(),GC 压力会让帧率从 60fps 掉到 45fps。解决方案是对象池——但和教科书不同,这个池子的对象用完就销毁,不回收复用。
教科书对象池 vs 这个项目
教科书的经典实现:
1 | 获取对象 → 使用 → 归还池子 → 下次复用 |
pool.gd 的实际实现:
1 | 获取对象 → 使用 → 直接销毁(不归还) |
为什么?因为 Godot 的 queue_free() 会把节点从场景树移除,但如果节点还被其他地方引用(比如子弹的 _on_body_entered 回调还没执行完),强制回收会导致悬空引用和崩溃。“用完即毁”比”回收复用”更安全,代价是每帧多几个 instantiate() 调用——但对 Godot 的场景实例化来说,这完全可以接受。
77 行的核心实现
1 | # pool.gd |
三个函数,三种职责:
setup():预实例化并冻结(set_process(false)+set_physics_process(false)+visible=false)acquire():从池里取一个并解冻,池空时懒创建。_active[node] = type_name记录归属release():通过_active.get(node)判断是否池管理的节点——是则销毁,不是则跳过
预分配时机
池子在 main.gd 的 _ready 里一次性创建:
1 | # main.gd |
40 颗子弹 + 20 个特效 = 60 个节点,启动时一次性实例化完毕。游戏中弹幕最密集的时候可能同时有 80+ 颗子弹,超出预分配的部分走懒创建,不会卡顿。
set_process(false) 的”休眠”策略
这是最容易忽略的细节。预实例化的节点如果不冻结,它们的 _process / _physics_process 会在场景树里正常执行——即使它们还没被”激活”。
1 | # acquire 时解冻 |
set_process(false) 只关闭脚本逻辑,节点本身还在场景树里,不会被 GC 回收。这比”移出场景树再加回来”的开销小得多。
多人模式下的清理
场景切换或游戏结束时,需要清空所有池子:
1 | # main.gd |
reset_all() 遍历 _active(活跃节点)和 _pools(预分配但未使用的节点),全部 queue_free()。is_instance_valid() 检查是必要的——某些节点可能在清理前已经被销毁(比如飞出屏幕的子弹)。
为什么不用 WeakRef?
Godot 有 WeakRef,理论上可以做弱引用池。但 WeakRef.get_ref() 在节点被销毁后返回 null,你需要在每次使用前检查——漏了一次就是崩溃。对游戏这种性能敏感的场景,”显式 acquire/release”比”隐式 GC 回收”更可控。
和子弹系统的配合
子弹是最典型的池化对象。bullet.gd 的 _recycle() 调用 Pool.release(self):
1 | # bullet.gd |
子弹飞出屏幕或寿命到期,直接回收。每颗子弹的生命周期完全由自己控制,不需要外部管理器追踪。
总结
| 设计决策 | 原因 |
|---|---|
| 用完即毁而非回收复用 | 避免悬空引用崩溃 |
set_process(false) 休眠 |
避免未激活节点执行逻辑 |
| 预分配 + 懒创建 | 启动时预热,运行时不卡顿 |
is_instance_valid() 检查 |
多人模式下节点可能已被销毁 |
77 行代码,覆盖了游戏开发中 80% 的对象管理需求。剩下的 20%(真正的高频复用场景)可以用 Godot 4 的 ObjectPool 插件或者手写引用计数池——但对这个项目来说,够用了。
你们项目里对象池是怎么设计的?回收复用还是用完即毁?