从一个真实问题开始
测试机上 60 帧,线上用户反馈卡。这个场景太常见了:开发机 Chrome 很顺,低端安卓 WebView 里一开技能雨就掉到 25 帧。性能问题不是上线前压一遍 profiler 就能解决,它需要从玩法设计阶段就有预算。
这个问题发生在一款弹幕割草 H5 小游戏里。当时我的角色是负责性能治理的客户端工程师,最先做的不是马上改代码,而是把玩家路径、设备环境、资源状态和场景切换顺序重新走了一遍。Phaser 项目很容易给人一种“代码都在前端,问题应该很好定位”的错觉;实际到了线上,浏览器、渠道容器、资源缓存、输入焦点和玩家习惯会一起参与结果。
这篇文章讨论的核心是:帧预算就是承认每一帧只有固定时间,逻辑、渲染、GC、音频和浏览器杂务都要从里面花。如果只看 API,很容易把 Phaser 学成一组函数;如果从项目交付看,就必须关心边界、生命周期、失败兜底和调试证据。下面会围绕 玩家满屏技能、怪物高密度刷新、拾取物爆出和低端机降级 展开,把经验落到可执行的工程判断上。
先看整体结构
flowchart TD
A[一帧 16.6ms 预算] --> B[输入和状态 1ms]
A --> C[玩法逻辑 4ms]
A --> D[物理检测 3ms]
A --> E[渲染提交 5ms]
A --> F[音频/UI/浏览器杂务 2ms]
A --> G[预留波动 1.6ms]
C --> H{超预算}
H -->|是| I[降低刷怪/粒子/拾取物密度]
H -->|否| J[保持当前档位]
这张图不是为了显得复杂,而是提醒我们:玩家看到的是一个连续体验,工程上却是多个系统串起来的结果。frame budget、object pool、texture atlas、particle、GC、draw call 都有自己的职责,任何一个环节偷懒,最后都会变成“怎么偶尔不对”的线上问题。
一段可以落地的代码切口
下面这段示例不是完整框架,只是为了说明 性能预算分配 应该如何从一开始就留下边界。真实项目里可以继续封装,但不要在还没说清职责前就追求抽象。
class BulletPool {
constructor(scene) {
this.group = scene.physics.add.group({ classType: Bullet, maxSize: 300 });
}
spawn(x, y, vx, vy) {
const bullet = this.group.get(x, y);
if (!bullet) return null;
bullet.activate(vx, vy);
return bullet;
}
}
代码里的重点不是语法,而是控制权。Phaser 的对象和插件都很好调用,难点是不要让每个回调都直接修改全局状态。只要控制权分散,后续就会出现“这个字段到底是谁改的”“为什么第二次进入场景不一样”“为什么关闭弹窗后玩法状态变了”之类的问题。
先定义目标设备
性能优化最怕没有目标。60 帧是目标,但在哪些设备、什么场景、多少敌人、多少粒子下达到 60 帧,必须说清楚。否则团队只会用开发机互相说服,直到线上低端机数据把问题暴露出来。
H5 游戏更要小心 WebView 差异。相同硬件在系统浏览器、微信内置 WebView、广告落地页容器里表现可能不同。性能预算要以目标渠道的真实设备为准。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
对象池不是过度设计
Phaser 项目里频繁创建销毁子弹、伤害数字、粒子、掉落物,会制造大量 GC 压力。桌面浏览器可能看不出来,低端移动端会在几秒后突然卡一下。对象池的价值不是省几行 new,而是让内存波动可控。
对象池也要有上限。满屏弹幕时,如果池子耗尽,应该按玩法优先级丢弃低价值对象,例如远处装饰粒子或重复伤害数字,而不是无限扩容。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
纹理图集要服务渲染
很多小图单独加载会增加纹理切换和管理成本。把同一场景、同一角色或同一 UI 模块的图片打成 atlas,通常能减少渲染压力。问题是图集也不能无限大,移动端纹理尺寸有限,过大的图集会增加内存和上传成本。
图集策略要按使用场景拆分:战斗常驻图集、UI 通用图集、活动临时图集、角色皮肤图集。不要为了减少文件数把所有东西塞进一个巨大图集,这会让首屏和内存都变差。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
粒子和飘字要限流
技能特效最容易把性能预算烧光。一次暴击 20 个数字、每个怪死亡 30 个粒子、金币爆出 50 个小图标,看起来热闹,叠加起来就是掉帧。玩家真正需要的是反馈清楚,不是每个事件都完整播放。
可以按屏幕密度限流:同一帧最多显示多少伤害数字,同一区域粒子合并,低端机减少粒子数量和寿命,远离玩家的效果降级。表现系统应该有预算意识,而不是收到事件就全量播放。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
物理检测要分层
高密度割草游戏里,玩家、敌人、子弹、拾取物、技能区域之间的检测数量很容易爆炸。Arcade Physics 已经够轻,但 O(n²) 的逻辑照样会拖垮。要用 group、空间范围、碰撞矩阵和频率控制减少无意义检测。
不是所有检测都要每帧执行。远处拾取物可以低频检查,持续伤害区域可以按 tick 判定,非关键 AI 可以隔帧更新。只要规则清楚,玩家不会察觉这些优化。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
GC 峰值比平均值更伤
平均帧率 58 看起来不错,但每隔 5 秒卡 200ms,玩家体验仍然很差。GC 峰值、资源上传、解码和数组临时分配都会造成瞬间卡顿。Profiler 里要看帧时间分布,而不是只看平均 FPS。
编码时少在 update 里创建临时对象,避免每帧 filter/map 产生新数组,复用 vector 和 rectangle,对日志和调试字符串做开关。小习惯在高频路径上会被放大。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
降级策略要早做
低端机降级不是最后加一个 lowQuality 开关。玩法、表现、资源、音频都要知道设备档位。低端机可以减少怪物上限、粒子数量、阴影、背景层、伤害数字密度和音效并发。
关键是降级不要改变核心规则。减少装饰可以,减少碰撞判定不行;降低粒子可以,偷偷降低玩家伤害反馈不行。降级要保护可读性和公平性。
设备档位也不要只在启动时判断一次。移动浏览器可能因为发热、低电量、后台恢复或系统调度突然降频。可以在运行中观察连续帧耗时,如果 3 到 5 秒内持续超预算,就逐级降低表现密度;如果稳定一段时间,再谨慎恢复。动态降级要有冷却时间,避免画质在高低档之间来回跳。对玩家来说,稳定的中等表现通常比忽高忽低的高画质更可信。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
落地清单
上线前检查:目标设备是否明确;最重战斗场景是否压测;对象池是否有上限;update 是否避免临时分配;粒子和飘字是否限流;图集是否按场景拆分;低端档位是否真实启用;帧时间峰值是否被记录。
性能优化不是把代码写得更神秘,而是让每一帧的钱花得明白。Phaser 足够支撑很多高密度 H5 游戏,但前提是团队用预算管理复杂度,而不是等掉帧以后再逐个灭火。
在实际协作中,我会把这部分写进开发检查表,而不是只放在口头约定里。因为 Phaser 项目的迭代速度通常很快,今天为了活动临时加的逻辑,三周后就可能变成复用模板。越是轻量项目,越要在关键位置把规则写清楚。
排查问题时的顺序
遇到相关问题时,不建议先凭经验改参数。更稳的顺序是先复现,再缩小范围,最后才动代码。复现时要记录设备、浏览器、渠道容器、网络、页面可见状态、游戏版本和资源版本。很多 H5 游戏问题只在特定容器里出现,如果只在桌面 Chrome 里验证,很容易得到错误结论。
缩小范围时,可以把链路拆成输入、状态、资源、表现和持久化几段。先确认玩家意图有没有被收到,再确认状态机有没有接受,再确认 Phaser 对象有没有正确执行,再确认表现层有没有被镜头、缩放、缓存或音频策略影响。这样的排查路径比“看哪里像问题就改哪里”慢一点,但能避免改出新问题。
最后是留证据。开发版日志、调试面板、可视化边界、状态快照和小型回放,都比口头描述可靠。尤其是涉及 性能预算分配 的问题,录屏只能告诉你现象,不能告诉你内部状态。把内部状态展示出来,团队才有共同语言。
团队协作里的责任划分
Phaser 项目经常由少数工程师快速推进,因此容易忽略协作边界。可是一旦项目进入运营,策划会改配置,美术会换资源,运营会调整活动,渠道会接 SDK,测试会覆盖多设备。工程代码如果没有把责任划清,每个角色都会被迫理解太多底层细节。
比较健康的方式是让配置描述意图,让服务层解释规则,让 Scene 编排生命周期,让表现对象执行动画和反馈,让平台适配器处理浏览器或渠道差异。这样策划新增内容时不需要知道 Scene 的内部结构,美术替换资源时不会改变玩法规则,运营关闭活动时不会留下半开半关的 UI 状态。
这不是大团队才需要的流程。越小的团队越需要减少隐性沟通成本。一个清楚的边界,可以让后续每一次临时需求都少一点风险。
上线前最后一轮检查
最后一轮检查不要只点一遍主流程。至少要覆盖首次进入、第二次进入、弱网、低端设备、后台恢复、快速重复点击、资源失败、配置缺字段和旧数据升级。很多 Phaser Bug 都出现在“第二次”或者“恢复后”:第二次开局、第二次打开弹窗、第二次播放音频、第二次加载同一图集。
如果这篇文章讨论的系统已经接近上线,我会要求团队给出三类证据。第一是功能证据,证明主流程确实可用;第二是边界证据,证明失败和异常路径不会把玩家卡死;第三是观测证据,证明线上再出问题时能定位。只有这三类证据都存在,才算不是靠运气发布。
结语
Phaser 的优势是轻、快、直接。它能让一个想法很快变成可以玩的东西,也正因为如此,项目很容易在“先跑起来”之后忽略工程边界。帧预算就是承认每一帧只有固定时间,逻辑、渲染、GC、音频和浏览器杂务都要从里面花。把这个原则落实到代码里,项目就不会因为功能增加而迅速失控。
真正可靠的 Phaser 游戏,不是每个模块都写得很重,而是关键链路有清楚的生命周期、明确的责任、可降级的失败路径和能解释问题的调试证据。做到这些,即使项目仍然保持轻量,也能承受上线后的真实流量和频繁改动。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。