前言
车万空寂花里的敌弹数量比较多,每颗子弹还要处理移动、碰撞、越界和擦弹。如果全部各写各的,清屏、暂停和回收都会比较麻烦。目前项目把敌弹统一交给 BulletManager 管理,再配合对象池复用 GameObject。
这篇把这条链路单独拆出来,顺便记录几个容易出问题的地方。公开仓库目前没有完整的 Profiler 对照数据,所以性能部分只写已经实现的结构和后续验证方法。
源码入口:qian488/Danmaku。
这个系列
- 从可玩成品倒推整体架构
- 本文:集中更新、对象池与碰撞判定
- 时间轴关卡、资源预热与加载流程
一颗敌弹从生成到回收
发射器计算位置、方向和速度
↓
BulletManager 从池中取对象
↓
初始化样式、速度、半径和归属
↓
加入 alive list
↓
每帧移动 → 碰撞/擦弹 → 越界判断
↓
命中、清屏、切阶段或出界
↓
移出 alive list,重置后回池
移动计算很简单,生命周期更容易出错。初始化或回收阶段遗漏字段,会留下幽灵对象、重复擦弹、错误贴图或无法回收的引用。
用活跃列表集中更新
最直观的写法是给每个子弹 MonoBehaviour 一个 Update()。它在小规模 Demo 中没有问题,但规模增大后有三个缺点:
- Unity 需要逐个调度大量消息;
- 当前活跃子弹分散,统一清屏、暂停和统计不方便;
- 移动、碰撞、越界和回收的顺序不容易保持一致。
项目由 BulletManager 维护预分配容量的活跃列表。核心结构可以概括为:
private readonly List<DanmakuBullet> aliveBullets = new(256);
void UpdateBullets(float deltaTime)
{
for (int i = aliveBullets.Count - 1; i >= 0; --i)
{
DanmakuBullet bullet = aliveBullets[i];
bullet.Tick(deltaTime);
if (ShouldRecycle(bullet))
RecycleAt(i, bullet);
}
}
这里使用倒序遍历,是因为更新中会移除元素。正序删除后,后面的索引前移,很容易跳过下一颗子弹。
集中更新的收益不仅是少一些 Unity 消息,还包括:
- 同一帧中的处理顺序明确;
- 暂停时只需停一个入口;
- Bomb 清屏、Boss 切阶段可批量回收;
- 可以记录活跃数量和每帧生成量;
- 后续抽成纯数据数组时有清晰迁移边界。
代价则是 BulletManager 容易承担过多职责。因此更稳妥的演进是保留集中调度,但将移动、碰撞、样式和回收规则拆成明确方法或子系统。
对象池以及状态重置
池化的基本操作很简单:
GameObject instance = pool.Spawn(prefabKey);
// 初始化本次子弹
pool.Despawn(instance);
// 不 Destroy,等待下次复用
难点是“复用前后状态必须等价于一个新对象”。弹幕至少需要重置:
- 位置、旋转、缩放和父节点;
- 速度、加速度、角速度和存活时间;
- Sprite、颜色、材质参数与动画帧;
- 命中半径、阵营和伤害;
- 是否已经对本局玩家触发擦弹;
- 事件订阅、协程和延迟回调;
- Renderer、Collider 或其他组件的启用状态。
如果这些清理由外部管理器针对每一种对象硬编码,池会迅速变得不可维护。更合适的方式是让池化对象实现统一生命周期:
public interface IPoolable
{
void OnSpawned();
void OnDespawned();
}
管理器负责“何时取出与归还”,对象负责“怎样恢复干净状态”。
池容量需要根据运行时峰值设置。预热过少会在战斗中扩容;预热过多会让 GameObject、组件和贴图引用长期占用内存。项目后续需要记录峰值,并据此设置预热量和空闲上限。
圆形受击判定
项目为玩家使用独立的圆形受击范围。这个范围可以小于角色贴图,方便按弹幕游戏的手感单独调节,无需跟随视觉轮廓改变 Collider 形状。
圆与圆相交只需要比较距离平方:
static bool Intersects(in Circle a, in Circle b)
{
float dx = a.x - b.x;
float dy = a.y - b.y;
float radius = a.radius + b.radius;
return dx * dx + dy * dy <= radius * radius;
}
不做平方根,既减少计算,也避免不必要的浮点转换。
在当前玩法中,主要检测目标是单个玩家,因此每帧遍历所有敌弹做 O(N) 判定是一个合理基线。空间划分只有在目标数量、弹幕规模或复杂碰撞明显增加后才值得引入,否则网格维护本身也有成本。
擦弹判定
玩家有小的受击圆和更大的擦弹圆:
进入受击圆 → 命中
未进入受击圆但进入擦弹圆 → 擦弹
两者都未进入 → 无事发生
判定顺序应先检查命中,再检查擦弹,避免同一颗子弹在命中帧同时奖励擦弹。
此外,每颗子弹通常只能贡献一次擦弹。如果池化对象没有在下次 Spawn 时清掉 hasGrazed,它会永远无法再次触发;如果一帧后没有保留这个状态,又会连续刷分。这正是对象池重置和玩法状态必须一起设计的例子。
还要留意的 GC 来源
使用对象池之后仍可能产生分配:
- 每波发射时创建闭包或临时委托;
- 热路径使用 LINQ;
- 每帧创建临时 List;
- 字符串拼接日志;
- 接口或枚举处理中的装箱;
- 运行时按字符串反复查找资源。
项目将弹幕生成回调和集合缓存起来,并为活跃列表预分配容量,目的就是让常规战斗帧尽量不产生托管分配。
这些代码的目标是减少常规战斗帧的分配。项目目前没有公开的 Profiler 基准,仍需在固定关卡中记录 GC Alloc、主线程耗时、渲染批次和活跃子弹峰值,才能评价实际效果。
我准备怎样补性能验证
我会固定一组可重复测试:
- 相同 Unity 版本、分辨率和目标帧率;
- 相同关卡、难度、随机种子和持续时间;
- 记录平均/峰值活跃弹幕、每帧 Spawn 数量;
- 对比 CPU 主线程、GC Alloc、Batches 和内存;
- 分别测试首次进入和资源已缓存的第二次进入。
如果只截一张“某一帧 60 FPS”的图,无法说明尖刺、首次加载和长时运行问题。
当前方案的边界
GameObject + 集中更新适合当前中等规模,但继续增大时会出现新的限制:
- Transform 和 SpriteRenderer 数量仍然很高;
- 活跃列表中的数据布局不利于批量计算;
- 每颗子弹仍有完整 GameObject 组件开销。
下一阶段可以依次考虑:
- 将移动和碰撞数据抽成结构体数组;
- GameObject 只保留表现代理;
- 使用网格分桶处理多目标碰撞;
- 使用 Mesh、粒子或 instancing 批量渲染;
- 规模确实需要时再评估 Job System 或 DOTS。
这些方案的投入差异很大,应先用性能数据定位热区,再替换对应部分。
写在最后
- 集中更新主要解决调度、顺序和全局生命周期管理问题。
- 对象池主要解决高频创建销毁,但必须完整重置状态。
- 圆形判定既符合弹幕手感,也提供低成本、可控的碰撞模型。
- 池化不代表没有 GC,优化结论必须来自固定场景和 Profiler。
- 当前方案有明确规模边界,进一步优化应从数据布局和渲染批次入手。
下一篇继续整理关卡时间轴和加载。第一次进入关卡时还要准备 prefab、贴图、音效和池对象,这部分和每帧更新是另一条链路。