godot-game-one 每秒要发射几十颗子弹、产生十几个受击特效。如果每次都 instantiate() + queue_free(),GC 压力会让帧率从 60fps 掉到 45fps。解决方案是对象池——但和教科书不同,这个池子的对象用完就销毁,不回收复用。

教科书对象池 vs 这个项目

教科书的经典实现:

1
获取对象 → 使用 → 归还池子 → 下次复用

pool.gd 的实际实现:

1
获取对象 → 使用 → 直接销毁(不归还)

为什么?因为 Godot 的 queue_free() 会把节点从场景树移除,但如果节点还被其他地方引用(比如子弹的 _on_body_entered 回调还没执行完),强制回收会导致悬空引用和崩溃。“用完即毁”比”回收复用”更安全,代价是每帧多几个 instantiate() 调用——但对 Godot 的场景实例化来说,这完全可以接受。

77 行的核心实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# pool.gd
const DEFAULT_POOL_SIZE: int = 20
const MAX_POOL_SIZE: int = 100

var _pools: Dictionary = {} # { type_name : [pooled_nodes] }
var _active: Dictionary = {} # { node_instance : type_name }

func setup(type_name: String, scene: PackedScene, size: int = DEFAULT_POOL_SIZE) -> void:
if _pools.has(type_name):
return # 已初始化
var arr: Array[Node] = []
for i in size:
var obj := scene.instantiate()
obj.set_process(false)
obj.set_physics_process(false)
obj.visible = false
obj.set_name("%s_pooled_%d" % [type_name, i])
arr.append(obj)
_pools[type_name] = arr

func acquire(type_name: String, scene: PackedScene) -> Node:
var pool: Array = _pools.get(type_name)
if pool == null:
setup(type_name, scene)
pool = _pools[type_name]
var node: Node
if pool.is_empty():
node = scene.instantiate() # 池空,懒创建
else:
node = pool.pop_back()
node.set_process(true)
node.set_physics_process(true)
node.visible = true
_active[node] = type_name
return node

func release(node: Node) -> void:
if node == null or not is_instance_valid(node):
return
var type_name: String = _active.get(node, "")
if type_name.is_empty():
if node.is_inside_tree():
node.queue_free() # 不在池中管理,直接释放
return
# 直接释放 — 不保留引用,pool 用完了会自动创建新节点
node.queue_free()

三个函数,三种职责:

  • setup():预实例化并冻结(set_process(false) + set_physics_process(false) + visible=false)
  • acquire():从池里取一个并解冻,池空时懒创建。_active[node] = type_name 记录归属
  • release():通过 _active.get(node) 判断是否池管理的节点——是则销毁,不是则跳过

预分配时机

池子在 main.gd 的 _ready 里一次性创建:

1
2
3
# main.gd
Pool.setup("bullet", bullet_scene, 40) # 预分配 40 颗子弹
Pool.setup("hit_effect", hit_effect_scene, 20) # 预分配 20 个受击特效

40 颗子弹 + 20 个特效 = 60 个节点,启动时一次性实例化完毕。游戏中弹幕最密集的时候可能同时有 80+ 颗子弹,超出预分配的部分走懒创建,不会卡顿。

set_process(false) 的”休眠”策略

这是最容易忽略的细节。预实例化的节点如果不冻结,它们的 _process / _physics_process 会在场景树里正常执行——即使它们还没被”激活”。

1
2
3
4
5
6
7
# acquire 时解冻
instance.set_process(true)
instance.visible = true

# setup 时冻结
instance.set_process(false)
instance.visible = false

set_process(false) 只关闭脚本逻辑,节点本身还在场景树里,不会被 GC 回收。这比”移出场景树再加回来”的开销小得多。

多人模式下的清理

场景切换或游戏结束时,需要清空所有池子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# main.gd
func _cleanup_network_state() -> void:
Pool.reset_all()

# pool.gd
func reset_all() -> void:
for node in _active.keys():
if is_instance_valid(node):
node.queue_free()
_active.clear()
for arr in _pools.values():
for node in arr:
if is_instance_valid(node):
node.queue_free()
_pools.clear()

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
2
3
4
5
6
7
8
9
10
11
12
13
# bullet.gd
func _recycle() -> void:
Pool.release(self)

func _physics_process(delta: float) -> void:
global_position += _direction * _speed * delta
_lifetime -= delta
# 屏幕外裁剪
if global_position.x < -margin or ...:
_recycle()
return
if _lifetime <= 0:
_recycle()

子弹飞出屏幕或寿命到期,直接回收。每颗子弹的生命周期完全由自己控制,不需要外部管理器追踪。

总结

设计决策 原因
用完即毁而非回收复用 避免悬空引用崩溃
set_process(false) 休眠 避免未激活节点执行逻辑
预分配 + 懒创建 启动时预热,运行时不卡顿
is_instance_valid() 检查 多人模式下节点可能已被销毁

77 行代码,覆盖了游戏开发中 80% 的对象管理需求。剩下的 20%(真正的高频复用场景)可以用 Godot 4 的 ObjectPool 插件或者手写引用计数池——但对这个项目来说,够用了。

你们项目里对象池是怎么设计的?回收复用还是用完即毁?