LOADING

加载过慢请开启缓存 浏览器默认开启

Unity 对象池的设计与实现:以弹幕游戏的子弹管理为例

游戏里每颗子弹、每个粒子、每次特效,背后都是一次创建和销毁。当同屏上千个对象在飞,这种频繁分配会直接拖垮帧率。对象池(Object Pool)就是为解决这个问题而生的设计模式——预先创建一批对象放在一边,需要的时候借出来,用完再还回去,而不是每次都新建、销毁。
这篇文章以我做过的一个弹幕游戏《车万空寂花》为例,把对象池这项技术从原理到完整实现拆开讲。

## 一、为什么需要对象池
先说清楚我们要解决的问题。在 Unity 里,你在场景中看到的一个物体就是一个 GameObject,它的模板叫 Prefab(预制体)。要用它,调用 Object.Instantiate(prefab) 复制一份到场景;不再需要了,调用 Object.Destroy(go) 销毁。
这两步看着轻巧,代价却不小:
- Instantiate 要在托管堆上分配 C# 对象,还要在引擎底层创建对应的原生资源(Transform、渲染状态等)。频繁调用就是频繁分配。
- Destroy 不会立刻回收内存,被销毁对象会在某一刻交给 C# 的垃圾回收(Garbage Collection,简称 GC)去清理。GC 一跑就要暂停主线程做标记-整理,表现就是一次突然而短暂的卡顿。
弹幕游戏是这个问题的放大器:一场战斗里可能有成千上万颗子弹在生成、飞行、命中、消失。如果每颗都 Instantiate / Destroy,内存分配和 GC 会反复冲击主线程,帧率跟着上上下下。
对象池的思路很直接——子弹不要真的销毁,而是藏起来备用。需要一颗新子弹时,从「备用堆」里拿一颗出来重新激活;它该消失时,关掉显示、放回备用堆,等下一颗复用。这样一来,分配只发生在第一次填满池子的时候,之后几乎不再有 Instantiate / Destroy,GC 压力随之消失。
下面要讲的,就是《车万空寂花》里这套池子的完整实现。
## 二、核心结构:分桶字典池
整个池子建立在两个概念上:桶(Bucket) 和 池根(PoolRoot)。
一颗子弹的模板(Prefab)对应一个桶,桶里维护一个「当前空闲、可被借出」的对象栈。多个不同的 Prefab 就对应多个桶,用一个字典串起来。代码如下:
csharp // DemoFrameWork.Pool public class GameObjectPool : MonoBehaviour { // 一个 Prefab 对应一个桶 private class PrefabBucket { public GameObject Prefab; public Transform Container; // 空闲实例挂在这里 public Stack<GameObject> Available = new Stack<GameObject>(); } // key(Prefab 路径或名字)→ 桶 private Dictionary<string, PrefabBucket> _buckets = new Dictionary<string, PrefabBucket>(); private Transform _poolRoot; private bool _initialized; public void Initialize() { if (_initialized) return; _poolRoot = new GameObject("PoolRoot").transform; _poolRoot.SetParent(transform); DontDestroyOnLoad(gameObject); // 切场景也不丢 _initialized = true; } }
这里有几个设计点值得说:
为什么用 Stack 而不是 Queue? 栈是后进先出(LIFO)。一颗刚还回来的子弹,它的内存、组件状态还在 CPU 缓存里「热」着,马上再借出去命中率最高;队列是先进先出,反而容易借到很久没用的冷对象。对子弹这种高频复用、生命周期短的对象,LIFO 在缓存局部性上更友好。
为什么需要 PoolRoot + DontDestroyOnLoad? 池子本身是一个 MonoBehaviour,挂在某个不销毁的节点上。所有空闲实例统一挂在 PoolRoot 下的各个桶 Container 里,既不会污染场景层级,也能在切场景时完整保留,下次进场景直接复用。
桶的 key 用什么? 可以是 Prefab 的资源路径(Resources 或资源系统路径),也可以是 Prefab 的名字。GetOrCreateBucketByPath 在找不到桶时会按需创建,并优先从游戏的资源系统加载,失败再退回 Resources.Load:
csharp private PrefabBucket GetOrCreateBucketByPath(string path) { if (_buckets.TryGetValue(path, out var b)) return b; GameObject prefab = null; if (DemoGameEntry.Resource != null) prefab = DemoGameEntry.Resource.Load<GameObject>(path); if (prefab == null) prefab = Resources.Load<GameObject>(path); if (prefab == null) { Debug.LogWarning($"[GameObjectPool] Prefab not found: {path}"); return null; } return GetOrCreateBucketByPrefab(prefab, path); }
池子要管理对象,对象也得「认识」自己的池。所以每个被池管理的 GameObject 上都挂一个 PooledObject 组件,记下它属于哪个桶、哪个池:
csharp public class PooledObject : MonoBehaviour { public string BucketKey { get; internal set; } // 属于哪个桶 internal GameObjectPool Pool { get; set; } // 属于哪个池 public void Recycle() { if (Pool != null) Pool.Recycle(gameObject); } }
需要借出/归还时收到生命周期回调的对象,可以实现 IPoolable 接口,池会在关键时刻通知它:
csharp public interface IPoolable { void OnSpawn(); // 借出时 void OnRecycle(); // 归还时 }
这三个类(IPoolable / PooledObject / GameObjectPool)就是整座池的骨架。
## 三、借出:Spawn 是怎么工作的
借出逻辑全在 SpawnFromBucket 里。它从桶的空闲栈里弹出一个对象,能复用就复用,不能就新建:
csharp private GameObject SpawnFromBucket(PrefabBucket bucket, Vector3 position, Quaternion rotation) { // 栈里可能有「已销毁」的引用(切场景等导致)。 // Pop 后必须用 == null 判断(Unity 重载了 ==,含已销毁情况),再访问 .activeSelf GameObject go = null; while (bucket.Available.Count > 0) { var candidate = bucket.Available.Pop(); if (candidate != null) { go = candidate; break; } } if (go == null) { // 池空了,新建一颗,挂上 PooledObject 登记身份 go = Object.Instantiate(bucket.Prefab, position, rotation); var po = go.GetComponent<PooledObject>() ?? go.AddComponent<PooledObject>(); po.BucketKey = GetKeyForBucket(bucket); po.Pool = this; } else { // 栈损坏或重复 Recycle 时,弹出的对象可能仍激活、或已不在桶下。 // 这种情况丢弃并新建,避免后续 SetParent / MoveGameObjectToScene 触发引擎断言 if (go.activeSelf || go.transform.parent != bucket.Container) { Debug.LogWarning( $"[GameObjectPool] Pooled instance in bad state (active={go.activeSelf}, parentOk={go.transform.parent == bucket.Container}); destroying and creating new. Prefab={bucket.Prefab?.name}", go); Object.Destroy(go); go = Object.Instantiate(bucket.Prefab, position, rotation); var poNew = go.GetComponent<PooledObject>() ?? go.AddComponent<PooledObject>(); poNew.BucketKey = GetKeyForBucket(bucket); poNew.Pool = this; } else { if (go.transform.parent != null) go.transform.SetParent(null, true); go.transform.position = position; go.transform.rotation = rotation; // 从 PoolRoot(DontDestroyOnLoad)下取出后若不重设场景, // 根物体会留在 DDOL,切场景也不会被卸载 var active = SceneManager.GetActiveScene(); if (active.IsValid() && go.scene != active) SceneManager.MoveGameObjectToScene(go, active); } } go.SetActive(true); foreach (var c in go.GetComponentsInChildren<IPoolable>(true)) c.OnSpawn(); return go; }
这段代码里有三个坑,是对象池最容易翻车的地方:
1. Unity 里的「空引用」和你想的不一样。 在 C# 里 Destroy(go) 之后,go 这个变量还是指向那个托管对象的,普通 go == null 会是 false。但 Unity 重载了 == 和 != 运算符:当一个 GameObject 的原生部分已被销毁时,go == null 会返回 true。所以 while 循环里用 candidate != null 来判断,正好能把「已经销毁、只剩空壳」的引用过滤掉,不会借到一颗幽灵子弹。这一点如果没意识到,池子用几次就会借出已死亡的对象,表现极其诡异。
2. 借出来的对象状态可能「坏」了。 理论上栈里弹出来的都该是已归还、未激活、挂在桶下的。但万一之前发生过重复归还、或外部代码乱动了父子关系,弹出来的对象可能还亮着、或父节点已经不对。这时硬把它 SetParent / 移场景,Unity 会直接抛断言。代码选择「宁可不信任,销毁重建」,用一次 Instantiate 的代价换掉一个不可信状态,比冒险复用安全得多。
3. 跨场景归属。 池里的对象挂在 PoolRoot 下,而 PoolRoot 是 DontDestroyOnLoad 的,不属于任何具体场景。从池里借出一颗子弹后,如果不把它显式移回当前活动场景,它的「根」会一直留在 DDOL 里,切场景时也卸载不掉,慢慢堆积。所以 Spawn 时检查 go.scene != active,用 SceneManager.MoveGameObjectToScene 把它放回当前场景。
最后,SetActive(true) 激活,并广播 OnSpawn() 给所有实现了 IPoolable 的子组件——这是借出方做「出场初始化」的钩子。
## 四、归还:Recycle 与幂等防护
借出难,归还更容易出错,因为「还」这个动作可能被重复调用。看 Recycle:
csharp public void Recycle(GameObject go) { if (go == null) return; var po = go.GetComponent<PooledObject>(); if (po == null || string.IsNullOrEmpty(po.BucketKey)) { Object.Destroy(go); // 来历不明,直接销毁 return; } if (!_buckets.TryGetValue(po.BucketKey, out var bucket)) { Object.Destroy(go); return; } // 已在该桶下且未激活:视为重复 Recycle,直接返回,避免同一实例两次 Push if (!go.activeSelf && go.transform.parent == bucket.Container) return; foreach (var c in go.GetComponentsInChildren<IPoolable>(true)) c.OnRecycle(); go.SetActive(false); go.transform.SetParent(bucket.Container, true); bucket.Available.Push(go); }
最关键的防护是那行提前返回: if (!go.activeSelf && go.transform.parent == bucket.Container) return;。设想一颗子弹被 Kill 了,触发 Recycle,正常还回桶里(此时它未激活、父节点是桶)。如果同一帧里某个逻辑又误调了一次 Recycle,对象其实已经在桶里了——要是不拦,它会被再次 Push 进栈。于是栈里出现两份指向同一颗子弹的引用,下次 Spawn 就可能把同一颗子弹借出去两次,场景里出现「双胞胎」,再 SetParent 时引擎直接断言崩溃。
所以「已还过的,就别再还」是池子的基本纪律,而这行判断就是这道纪律的代码表达。
另一个细节:来历不明的对象直接 Destroy。 如果一个 GameObject 没有 PooledObject,或者 BucketKey 对不上任何桶,说明它不是这个池管理的,池子不敢随便收,直接销毁最稳妥。这也意味着,任何想进池的对象,都得先有 PooledObject 身份。
## 五、预热、延迟归还与清理
光有借和还还不够,真实项目里还有几件配套的事。
预热(Prewarm)。 如果等战斗开始才第一次 Spawn,池子是空的,几千颗子弹会在一瞬间全部 Instantiate,造成开局尖峰卡顿。预热就是在加载阶段先把 N 颗建好藏进桶里:
csharp public void Prewarm(string prefabPath, int count) { var bucket = GetOrCreateBucketByPath(prefabPath); if (bucket == null) return; for (int i = 0; i < count; i++) { var go = Object.Instantiate(bucket.Prefab, bucket.Container); var po = go.GetComponent<PooledObject>() ?? go.AddComponent<PooledObject>(); po.BucketKey = prefabPath; po.Pool = this; go.SetActive(false); bucket.Available.Push(go); } }
延迟归还(RecycleDelay)。 有些对象「该消失」和「能从场景移除」不是同一时刻。比如子弹命中爆炸,你希望先播完一小段消亡动画,再把它收回池。用协程等一小会儿再 Recycle 即可:
csharp public void RecycleDelay(GameObject go, float delay) { if (go == null) return; StartCoroutine(RecycleDelayCoroutine(go, delay)); } private IEnumerator RecycleDelayCoroutine(GameObject go, float delay) { yield return new WaitForSeconds(delay); Recycle(go); }
清理与缩容。 切场景、换关卡时,可以把空闲实例交还内存;平时池子只增不减,所以也提供按上限裁剪的能力:
csharp public void Clear(string prefabPath) // 清掉某个 Prefab 的空闲实例 public void ClearAll() // 清掉所有空闲实例 public void TrimExcessIdle(int maxIdlePerBucket) // 每桶只留 maxIdle 个空闲,其余 Destroy
这里有个要心里有数的事实:池子平时是只增不减的。 一旦某波弹幕把池子撑到很大,即使之后没那么多子弹,空闲实例也一直占着内存,直到你主动调 TrimExcessIdle 或 Clear。在暂停菜单、关卡切换这类「安全时机」调用缩容,才能把峰值内存还回去。
## 六、消费端:子弹管理器怎么用池
池子建好,得有人来借、来还、来驱动逻辑。这就是 BulletManager 的职责。它维护一个「当前存活子弹」的列表:
csharp // 预分配容量,避免运行时反复扩容产生 GC private readonly List<DanmakuBullet> _aliveBullets = new List<DanmakuBullet>(256);
生成一颗子弹时,先向池借对象,再填参数:
csharp public DanmakuBullet SpawnBullet(Vector2 worldPos, float angleDeg, float speed, float hitRadius = 0.06f, int layer = 0, string prefabPath = null, int bulletStyleFolderIndex = -1, int bulletSpriteVariantIndex = -1) { GameObject go = null; if (!string.IsNullOrEmpty(prefabPath)) go = _pool != null ? _pool.Spawn(prefabPath, worldPos, Quaternion.identity) : Instantiate(Resources.Load<GameObject>(prefabPath), worldPos, Quaternion.identity); else if (_bulletPrefab != null) go = _pool != null ? _pool.Spawn(_bulletPrefab, worldPos, Quaternion.identity) : Instantiate(_bulletPrefab, worldPos, Quaternion.identity); else go = _pool != null ? _pool.Spawn(_defaultBulletPrefabPath, worldPos, Quaternion.identity) : Instantiate(Resources.Load<GameObject>(_defaultBulletPrefabPath), worldPos, Quaternion.identity); if (go == null) return null; var bullet = go.GetComponent<DanmakuBullet>(); if (bullet == null) bullet = go.AddComponent<DanmakuBullet>(); float rad = angleDeg * Mathf.Deg2Rad; Vector2 dir = new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)); int style = ResolveBulletStyleFolderIndex(bulletStyleFolderIndex); int variant = ResolveBulletSpriteVariant(style, bulletSpriteVariantIndex); bullet.Init(this, dir, speed, hitRadius, layer, _defaultMinLiveOutScreen, style, variant); _aliveBullets.Add(bullet); return bullet; }
注意这里有个降级写法:优先走池(_pool.Spawn),如果池没接好就退回普通 Instantiate。这是给「还没接池」的情况留的后路,但正式运行一定走池。借到对象后,调 bullet.Init(...) 把所有运行参数一次性填进去——位置、方向、速度、碰撞半径、图层、样式——然后丢进存活列表。
每帧推进时,管理器遍历存活子弹做移动和碰撞:
csharp public void LogicUpdate(float dt) { DanmakuCircle playerHit = _player != null ? _player.GetHitCircle() : default; DanmakuCircle playerGraze = default; bool haveGraze = false; if (_player != null && _player.IsAlive) { float r = playerHit.Radius * _grazeRadiusMultiplier; playerGraze = new DanmakuCircle(playerHit.X, playerHit.Y, r); haveGraze = true; } int n = _aliveBullets.Count; for (int i = 0; i < n; i++) { var b = _aliveBullets[i]; if (b == null || !b.IsAlive) continue; b.LogicUpdate(dt, _playArea); if (!b.IsAlive) continue; if (_player != null && _player.IsAlive) { var bulletC = b.GetCircle(); if (!_player.IsInvincible && DanmakuCollision.CheckCircleToCircle(bulletC, playerHit)) { b.Kill(); _player.TakeDamage(); if (_player == null || !_player.IsAlive) break; continue; } if (haveGraze && !b.GrazeConsumed && DanmakuCollision.CheckBulletGraze(bulletC, playerHit, playerGraze)) { b.MarkGrazeConsumed(); _onPlayerGrazed?.Invoke(); } } } CompactAliveBullets(); }
碰撞检测用的是 DanmakuCircle——一个值类型(struct)。每帧给每颗子弹、CheckCircleToCircle 传的都是一个栈上的小数组,不产生堆分配,所以这场每帧上千次的碰撞计算不会给 GC 添负担。
存活列表的维护是个性能要点。 一颗子弹命中后 IsAlive 变 false,但它在列表里的位置还在。如果每死一颗就 List.RemoveAt,而 RemoveAt 会把后面所有元素往前挪,一帧死几百颗就是几百次 O(n) 搬移,整体退化成 O(n²)。改用一次正向扫描的双指针压实:
csharp // 单遍压实列表,避免大量 RemoveAt 导致 O(n²) 移动 private void CompactAliveBullets() { int w = 0; int c = _aliveBullets.Count; for (int r = 0; r < c; r++) { var b = _aliveBullets[r]; if (b != null && b.IsAlive) _aliveBullets[w++] = b; // 只搬活着的 } if (w < c) _aliveBullets.RemoveRange(w, c - w); // 尾部一次性截断 }
读指针 r 走全程,写指针 w 只记录「下一个该留的位置」。活着的往前搬,死了的跳过,最后把 w 之后的尾巴一次 RemoveRange 砍掉。一趟 O(n) 搞定,子弹死再多也不放大开销。
清屏(炸弹、关卡切换)和回收也很直接:
csharp public void ClearBullets(int layer = -1) { for (int i = 0; i < _aliveBullets.Count; i++) { var b = _aliveBullets[i]; if (b == null || !b.IsAlive) continue; if (layer < 0 || b.Layer == layer) b.Kill(); } CompactAliveBullets(); } public void RecycleBullet(DanmakuBullet bullet) { if (_pool != null) _pool.Recycle(bullet.gameObject); else Destroy(bullet.gameObject); }
## 七、子弹本身:复用时的干净复位
借出来的对象是「旧的」,它身上可能还留着上一次飞行的速度、图层、贴图。复用最坑的地方就在这里——忘了复位某个字段,子弹就会带着上辈子的状态出场。所以每颗子弹有一个显式的 Init:
csharp public void Init(BulletManager manager, Vector2 direction, float speed, float hitRadius, int layer, int minLiveOutScreen, int resolvedBulletStyleFolderIndex, int resolvedSpriteVariantIndex) { _manager = manager; Direction = direction.normalized; Speed = speed; HitRadius = hitRadius; Layer = layer; MinLiveOutScreen = minLiveOutScreen; IsAlive = true; _outOfBoundsFrames = 0; GrazeConsumed = false; _lastVisualUpDir = Vector2.zero; ApplyBulletSpriteFromResources(resolvedBulletStyleFolderIndex, resolvedSpriteVariantIndex); }
每个字段都重新赋值,不留死角。这里要回答一个自然的问题:为什么子弹不用 IPoolable.OnSpawn 来复位,而要单独搞一个 Init? 因为 OnSpawn() 没有参数,而子弹出场时必须带上「往哪飞、飞多快、长什么样」这些外部传入的信息。Init 把参数一路传进来,才能在一次调用里完成「复位 + 配置」。池子只负责借还,不负责知道子弹该怎么配置——配置是子弹自己的事。这也是为什么 BulletManager 借到对象后,紧接着调的是 bullet.Init(...) 而不是指望池替它初始化。
每帧更新里还有一处小优化——贴图朝向:
csharp public void LogicUpdate(float dt, Rect playArea) { if (!IsAlive) return; transform.position += (Vector3)(Direction * Speed * dt); // 旋转贴图朝向运动方向;方向没变时跳过,减轻大量同向弹的 Transform 写入 if (Direction != Vector2.zero) { const float rotEpsilon = 1e-4f; if (Vector2.Dot(_lastVisualUpDir, Direction) < 1f - rotEpsilon) { transform.up = Direction; _lastVisualUpDir = Direction; } } Vector2 pos = transform.position; if (!playArea.Contains(pos)) { _outOfBoundsFrames++; if (_outOfBoundsFrames > MinLiveOutScreen) Kill(); } else { _outOfBoundsFrames = 0; } }
长条形子弹要根据飞行方向旋转贴图。但同一波弹幕往往方向一致,如果每帧都写 transform.up,上千颗做着相同的写入纯属浪费。所以用 _lastVisualUpDir 记上次的朝向,方向没变就跳过这次 Transform 写入。一颗子弹省一次,上千颗就是上千次。
死亡也要幂等,和池的 Recycle 防护是同一套纪律:
csharp public void Kill() { if (!IsAlive) return; IsAlive = false; _manager?.RecycleBullet(this); }
if (!IsAlive) return 保证一颗子弹不会因为重复命中而被 Kill 两次、进而被 Recycle 两次。
贴图加载也走缓存:所有样式贴图在启动时由 DanmakuBulletResourceIndex.WarmupIfNeeded 预热进一个字典,Init 里只做 TryGetSprite 查表,不重复 Resources.Load。资源加载这种重活,能提前做就提前做,运行时别碰。
## 八、对象池没有消灭 GC
必须说清楚一件事:对象池大幅减少了分配,但没有彻底消灭 GC。 它把「每颗子弹一次分配」压成了「池子填满后几乎零分配」,但下面这些地方依然会分配:
- GetComponentsInChildren<IPoolable>(true) 每次调用都会 new 一个数组来装结果。借出/归还各调一次,高频下仍有小额分配。
- 闭包与协程。 RecycleDelay 用了协程和 WaitForSeconds,本身有分配;弹幕波次的回调则预先缓存成字段委托 _stageWaveSpawnHandler,避免每波 new 一个闭包。
- List 扩容。 所以存活列表 new List<DanmakuBullet>(256) 一开始就给足容量,运行时不再扩容。
- 值类型帮忙。 碰撞用的 DanmakuCircle 是 struct,每帧上千次碰撞计算都在栈上完成,不进堆,这是把这块 GC 彻底拿掉的关键。
还有一个真实存在的低效点值得点名:GetKeyForBucket 为了在「桶对象」和「桶 key」之间反查,用了一个线性遍历:
csharp private string GetKeyForBucket(PrefabBucket bucket) { foreach (var kv in _buckets) if (kv.Value == bucket) return kv.Key; return bucket.Prefab.name; }
桶数量一多,每次 Spawn 新建对象都要扫一遍字典,是 O(n)。更干净的做法是在 PrefabBucket 上直接存一份 Key,或者在 PooledObject 创建时就记好,新建路径就不再反查。这个项目当时桶少、没到瓶颈,所以留着;但要讲透,就得把这块如实摆出来。
## 九、自写池 vs Unity 官方方案
写到这你可能会问:Unity 自己不是有 ObjectPool<T> 之类的东西吗,为什么还要自己写?
官方方案胜在通用、省事,适合「我就要一个能借能还的容器」的场景。这个项目选择自写,主要为了三件事官方接口不一定顺手:
1. 生命周期钩子。 OnSpawn / OnRecycle 让任何挂了 IPoolable 的子组件都能在借还时刻自动被通知,不用调用方手动记得初始化谁。
2. 跨场景归属。 PoolRoot + DontDestroyOnLoad + MoveGameObjectToScene 这套,保证池和对象在关卡切换里行为正确,这是 GameObject 池特有的麻烦,通用容器不会管。
3. 按 Prefab 分桶的简洁性。 子弹、粒子、敌机各自一桶,key 就是资源路径,调用方只管 Spawn(path)。
如果你的需求只是「缓存一堆非 Unity 对象」,直接用官方 ObjectPool<T>;如果管的是 GameObject、还要跨场景、还要钩子,自写一套像上面这样的反而更可控。两者不冲突,看复杂度决定。
## 十、什么时候用,什么时候别用
对象池有它合适的场景,也有不合适的,先把这两头分清楚:
- 适合: 大量、短命、同构的对象——子弹、粒子、敌人、掉落物、滚动列表项。这类对象反复创建销毁,池的回报最大。
- 不适合: 数量很少、生命周期长、或者形态差异极大的对象。给三五个长命对象建池,反而增加复杂度,没收益。
- 线程假设: 上面这套池全程假设在 Unity 主线程跑。Instantiate / Destroy / SceneManager 都是主线程 API,没有做任何加锁。如果你的逻辑在子线程,这套不能直接用,得加同步或换成线程安全的容器。

写在最后。
对象池说到底是「用空间换时间、用复用换稳定」的一招。把它讲清楚,重点不在那几行 Spawn / Recycle 代码,而在理解每一处防护为什么要存在:幽灵引用要靠 Unity 重载的 == 看穿,重复归还要靠幂等判断拦下,跨场景要靠显式移动归属,复用干净要靠显式 Init 复位。把这些边界想明白,你写的池子才不会因为一个边缘情况突然崩在玩家面前。
《车万空寂花》的这套实现就到这里。它没有跑过 Profiler 基准,所以我没有给你「帧率从 X 到 Y」的漂亮数字——但结构和当时踩到的坑都是真的。源码在 qian488/Danmaku,想看完整工程可以顺着翻。