弹幕系统的敌人是峰值
弹幕游戏最容易在演示时显得很酷,也最容易在上线后暴露性能问题。开发机上 300 发子弹很顺,低端手机上 Boss 一开全屏技能就卡。更隐蔽的是平均帧率不低,但每隔几秒会因为 GC 或碰撞峰值卡一下。玩家躲弹幕时,任何一次卡顿都可能变成误伤。
我做过一个竖屏弹幕 H5,早期所有子弹都是需要时创建、出屏后 destroy。第一版只有几十发,没问题。后来 Boss 加了旋转弹幕、分裂弹、延迟爆炸和掉落物,运行几分钟后就开始周期性卡顿。Profiler 显示不是渲染单独慢,而是创建销毁、碰撞检测和数组清理叠在一起形成峰值。
对象池不是弹幕系统的装饰,而是基础设施。它让子弹数量、内存波动、生命周期和降级策略都变得可控。Phaser 的 Group 能帮忙,但项目仍然要定义自己的 Bullet、Emitter、Collision 和 Recycle 规则。
flowchart TD
A[发射器请求生成子弹] --> B{对象池有空闲对象}
B -->|有| C[激活子弹]
B -->|无| D{是否允许扩容}
D -->|允许| E[创建新子弹]
D -->|不允许| F[丢弃低优先级发射]
C --> G[运行期更新]
E --> G
G --> H{命中/出界/超时}
H -->|是| I[重置并回收到池]
H -->|否| G
子弹要有明确生命周期
一颗子弹从出生到回收,至少经历初始化、激活、飞行、命中、失效和回收几个阶段。不要让子弹对象在任何阶段都可以被任意系统修改。比如命中后应该立即关闭碰撞,播放命中特效可以交给另一个表现池,子弹本体尽快回收。
生命周期字段可以很简单:active、age、ttl、owner、damage、team、patternId。每帧更新 age,超过 ttl 回收;出屏一定距离回收;命中后根据 piercing 或 bounce 决定继续存在还是回收。规则写清楚,调试时就能解释为什么某颗子弹还在场上。
回收时必须重置状态。速度、旋转、缩放、透明度、碰撞开关、伤害、穿透次数、事件监听和自定义数据都要归零。对象池最常见的 Bug 是旧状态泄漏:上一颗冰弹的减速标记留到下一颗火弹上,上一颗 Boss 弹的伤害留给小怪弹。
对象池要有上限和优先级
对象池不是无限仓库。高密度弹幕里,池子满了怎么办?如果无限创建,就回到原来的问题;如果简单拒绝所有新子弹,Boss 技能可能变得不可读。应该按优先级决定哪些发射可以丢弃。
玩家子弹通常优先级最高,因为它影响操作反馈。Boss 核心弹幕高于装饰弹,近屏危险弹高于远处未进入视野的弹,伤害弹高于纯视觉拖尾。池子耗尽时,可以丢弃低优先级装饰弹,或者降低某些发射器频率。
还可以按类型分池:玩家子弹池、敌人子弹池、特效弹池、掉落物池。不同类型有不同上限和回收策略。不要所有对象共用一个池,否则掉落金币太多可能挤掉敌人子弹,或者 Boss 弹幕挤掉玩家攻击反馈。
发射器负责模式,不负责生命周期
弹幕发射器应该描述“怎么发”:角度、速度、间隔、数量、旋转、延迟、追踪、分裂。它不应该负责子弹回收、碰撞和伤害结算。发射器向对象池请求子弹,给出初始参数,然后结束自己的工作。
这样一个 Boss 可以组合多个发射器:圆形扩散、扇形扫射、延迟爆炸、跟踪弹。每个发射器有自己的节奏和预算。Boss AI 只负责在某个阶段启动或停止发射器,不直接 new 子弹。
发射器也要支持暂停和恢复。游戏暂停、Boss 入场演出、页面后台时,发射器不能继续累计大量待发子弹。恢复后也不要把暂停期间欠的子弹一次性补发出来。时间控制必须清楚。
碰撞检测要减少无意义组合
弹幕场景里,碰撞检测比创建对象更容易成为瓶颈。玩家子弹只需要打敌人,敌人子弹只需要打玩家,掉落物只需要和拾取范围检测。不要让所有子弹都和所有对象进入同一套 overlap。
Phaser Arcade Physics 可以用 group 做分组检测。更高密度场景可以进一步按屏幕区域或网格做粗筛。比如只检测进入玩家附近区域的敌方子弹,远处弹幕先不做精细碰撞。玩家看不到也碰不到的东西,不应该消耗同等预算。
碰撞回调也要短。不要在 overlap 里做复杂掉落计算、音频播放、对象创建和大量日志。回调里记录命中事件,把复杂处理交给后续系统分帧执行。否则大量子弹同帧命中时,会产生尖峰。
子弹表现和规则分离
有些弹幕看起来很复杂:拖尾、闪烁、变色、缩放、路径弯曲。不要把所有表现都写进规则子弹。规则层只关心位置、速度、半径、伤害、阵营和生命周期。表现层可以根据 bullet type 播放动画、粒子或 shader。
如果一个子弹需要拖尾,拖尾最好也来自对象池。命中爆炸也来自特效池。不要在子弹命中时临时创建一堆 particle emitter 或 sprite。弹幕系统里的每个高频对象都应该能被回收。
视觉半径和碰撞半径也可以不同。玩家通常愿意接受敌方子弹碰撞半径略小于视觉,玩家子弹碰撞半径略大于视觉。这样体验更公平。这个差异应该写进配置,而不是靠缩放 sprite 偶然得到。
Debug 面板必须显示池状态
弹幕问题没有池状态很难排查。开发版应该显示每个池的 active、free、max、spawn per second、recycle per second、drop count。Boss 技能卡顿时,先看是活跃子弹太多,还是创建峰值,还是碰撞峰值。
还可以给子弹加 patternId,在 debug 模式下用颜色区分不同发射器。玩家说“这个阶段有一圈子弹不消失”,工程可以看到它来自 boss_ring_03,ttl 是不是没设置,出界判断是不是被关闭。
线上不需要记录每颗子弹,但可以记录池峰值、丢弃数量、帧时间峰值和 Boss 阶段。这样如果某个渠道设备掉帧严重,团队能知道是弹幕数量过高,还是资源和渲染问题。
一个可用的对象池骨架
下面示例展示了子弹池的核心接口。真实项目会加入碰撞、纹理、动画和配置,但 spawn、update、recycle 三个动作应该稳定存在。
class Bullet extends Phaser.Physics.Arcade.Image {
ttl = 0;
age = 0;
damage = 0;
fire(x: number, y: number, vx: number, vy: number, ttl: number) {
this.enableBody(true, x, y, true, true);
this.setVelocity(vx, vy);
this.ttl = ttl;
this.age = 0;
}
reset() {
this.disableBody(true, true);
this.setVelocity(0, 0);
this.damage = 0;
}
}
reset 里故意重置速度和伤害。实际还要重置 tint、alpha、scale、rotation、pierce、owner 和事件。对象池的稳定性取决于回收是否彻底,而不是创建是否优雅。
上线前检查清单
上线前检查:每种子弹是否有 ttl,出屏是否回收,命中后是否关闭碰撞,池子是否有上限,池满时是否有丢弃策略,发射器暂停是否不补发历史子弹,碰撞分组是否最小化,特效是否也走池,debug 面板是否显示 active/free/drop。
还要做压力场景:Boss 最强弹幕、玩家最高攻速、掉落物最多、低端设备、页面后台恢复、连续三局不刷新页面。很多内存泄漏不是第一局出现,而是第三局开始逐渐变卡。
弹幕配置要能复盘
弹幕问题经常发生在某个具体 pattern,而不是整个系统。比如 Boss 第二阶段第 3 个技能,旋转角速度叠加了两次;或者分裂弹的子弹继承了父弹 ttl,刚生成就被回收。为了复盘这些问题,每个发射器和每颗子弹都应该带 patternId、ownerId 和 spawnSeq。调试日志里看到某颗子弹异常,就能追到配置源头。
配置里最好把视觉参数和规则参数分开。规则参数包括速度、半径、伤害、ttl、阵营、穿透次数;视觉参数包括纹理、颜色、拖尾、缩放和闪烁。低端机降级时,可以关闭拖尾和闪烁,但不能改伤害半径。规则和视觉混在一起,降级就会变得危险。
弹幕编辑也要有预览入口。策划调整角度、间隔、旋转速度后,应该能在专门的预览 Scene 里看效果,而不是每次进入完整 Boss 战。预览里显示当前活跃子弹数、每秒生成数和池占用,能让内容设计一开始就看到性能成本。这样弹幕不是写完再优化,而是在设计时就带着预算。
对象池也要服务可读性
有些团队为了性能,把池满后的策略写成直接丢弃新子弹。这样虽然保住了帧率,但可能破坏 Boss 技能形状。比如一个圆形弹幕少了几发,玩家会看到缺口,难度也变化。更好的策略是先丢弃装饰弹、远离屏幕的弹、低伤害重复弹,尽量保留技能的核心轮廓。
如果必须丢弃核心子弹,也要记录 drop count,并在调试面板中标红。上线数据里某些低端设备 drop 很高,说明当前弹幕设计已经超出预算,需要调配置,而不是继续扩大池子。对象池的上限是一条产品约束,它提醒团队“这台设备最多只能承受这么多对象”。
命中结算不要阻塞回收
高密度弹幕里,命中一颗子弹可能触发扣血、闪白、音效、震屏、掉落、成就计数和埋点。如果这些都在碰撞回调里同步完成,几百颗子弹同帧命中时就会产生明显卡顿。更稳的方式是碰撞回调只生成 hit event,关闭子弹碰撞并回收子弹,后续伤害和表现由事件队列处理。
事件队列也要有预算。比如一帧最多处理 50 个飘字,剩下的合并;同一敌人短时间内多个小伤害可以合成一个显示;同一种命中音效可以限频播放。玩家需要知道自己打中了,不需要看到每个微小事件都完整播放。规则结算必须准确,表现反馈可以合并。
子弹回收和命中表现分离后,还能避免对象池复用事故。子弹已经回收给下一次发射,旧命中特效不应该继续引用它。命中事件里保存必要快照,例如位置、伤害、元素类型和目标 id,而不是保存 bullet 对象引用。对象池系统里,引用旧对象是很多奇怪 Bug 的源头。
关卡节奏和弹幕预算一起设计
弹幕数量不是越多越好。一个 Boss 阶段如果同时有旋转弹、追踪弹、分裂弹、地面预警和小怪子弹,玩家可能看不懂威胁来源。性能预算和可读性预算经常是一回事。减少同时存在的弹幕类型,既能提升帧率,也能让玩家更公平地躲避。
策划设计技能时,可以给每个阶段一张预算表:最大敌弹数、最大玩家弹数、最大掉落物、最大特效数、最大发射器数量。工程把这些预算放进调试面板,超过就报警。这样弹幕系统不会在最后测试阶段才发现“好看但跑不动”。
预算表还可以反过来帮助设计难度。某一阶段如果只能承受 120 发敌弹,就要靠速度、间隔、预警和空间压迫做变化,而不是继续堆数量。限制会迫使技能更清楚,也让低端设备体验更稳定。
最终测试时,可以把预算表和实际峰值并排记录。设计值和运行值不一致时,说明某个发射器、分裂逻辑或回收条件没有按配置执行。
这能让性能问题直接回到具体配置。
结语
Phaser 可以很快做出弹幕效果,但高密度弹幕不是多创建几个 Sprite。真正可靠的弹幕系统,需要对象池、发射器、生命周期、碰撞分组、表现池和调试指标一起工作。少了任何一环,问题都会在峰值时暴露。
对象池的目标不是写一个漂亮工具类,而是让弹幕系统可预算、可降级、可解释。玩家看到的是满屏弹幕和顺滑躲避,工程背后需要的是每颗子弹从出生到回收都有清楚规则。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。