我在做《星团大作战》时,遇到的性能问题并不是"Canvas 能不能画几个圆",而是下面这些工作会同时出现:
- 1 名玩家和 99 个 AI 持续移动、追击、逃跑、分裂与吞噬;
- 7600×7600 的世界里,基础食物对象有 2100 个;
- 病毒、吐出物、临时粒子和大量分裂球体继续增加更新与绘制压力;
- 游戏要能在高刷新率屏幕上平滑显示,但不能因为显示器变成 120Hz 或 160Hz,就把 AI 和碰撞也多算一倍;
- 高 DPI 和 4K 屏幕会让填充像素量快速膨胀,即使对象数量没有变化。
如果继续使用最直接的 update(dt) + draw(),并让每一次 requestAnimationFrame 都完整运行 AI、空间查询和碰撞,高刷屏反而会把 CPU 主线程先拖慢。
下面是项目调试接口自动构造的压力场景。它不是宣传效果图,而是 Playwright 启动真实页面、填充后期对象并截下来的游戏画面。

图 1:1 玩家 + 99 AI 的大逃杀压力场景。画面只显示视野内对象,世界中的对象总数与实际提交绘制的对象数并不相同。
这篇文章不做"优化后提升 10 倍"一类没有对照基准的结论。我更想完整解释:在一个真实浏览器游戏里,怎样确定预算、拆分更新与渲染、减少候选对象、批量提交绘制,并让性能数据可以复现。
先把场景规模说清楚
项目中的核心预算如下:
| 世界尺寸 | 7600×7600 | 大多数对象不在当前视野内 |
| 对局角色 | 100 | 1 名玩家 + 99 个 AI,不是 100 名联网真人 |
| 食物基线 | 2100 | 代码中的 FOOD_BASE_COUNT |
| 食物压力夹具 | 3600 | stressLateGame() 的初始填充值 |
| 大逃杀动态目标 | 最高 3888 | 3600 再乘该模式的 1.08 缩放 |
| 病毒基线 | 54 | 大小病毒共同存在 |
| 吐出物上限 | 720 | MAX_EJECTED |
| 临时特效粒子上限 | 560 | MAX_PARTICLES |
| 前景像素预算 | 3,200,000 | 控制高 DPI Canvas 内部尺寸 |
标题里的“2100+ 粒子”主要指用 GPU 点精灵绘制的食物对象。源码内部仍然严格区分 foods、ejected 和短生命周期的 particles,它们的更新规则并不相同。
还有一个容易误读的数字:FOOD_MAX_COUNT = 3600 是标准模式的目标上界,不是所有模式的绝对硬上限。大逃杀使用 foodTargetScale = 1.08,因此后期动态目标可以到 3888。把常量、模式倍率和实测数量分开,才能避免性能文章中的数字互相打架。
第一步不是换 WebGL,而是拆开模拟与渲染

图 2:输入、固定时间步模拟、状态快照、插值渲染之间的关系。高刷显示只提高呈现频率,不让 AI 和碰撞跟着重复运行。
我把游戏的时间分成了两条线:
核心常量很简单:
const SIMULATION_STEP = 1 / 60;
const MAX_SIMULATION_STEPS = 3;
const TARGET_RENDER_FPS = Math.min(120, REQUESTED_REFRESH_RATE);
主循环使用 accumulator 累积真实时间。时间足够一个固定步长时才执行 update():
simulationAccumulator = Math.min(0.1, simulationAccumulator + dt);
let steps = 0;
while (
simulationAccumulator >= SIMULATION_STEP &&
steps < MAX_SIMULATION_STEPS
) {
captureSimulationState();
simulationNow += SIMULATION_STEP * 1000;
update(SIMULATION_STEP, simulationNow);
simulationAccumulator -= SIMULATION_STEP;
steps += 1;
}
这样做有两个直接收益。
第一,60Hz、120Hz、160Hz 显示器上的游戏规则一致。速度、AI 思考和碰撞不再暗中依赖显示刷新率。
第二,渲染帧变多时,主线程不会等比例增加最重的逻辑工作。浏览器仍然会进入每个 requestAnimationFrame,但完整模拟只按固定步长推进。
每帧最多补 3 步,防止“死亡螺旋”
固定时间步还有一个常见陷阱:假设某一帧卡了很久,accumulator 会积压多个模拟步。补模拟本身又消耗时间,下一帧继续积压,最后进入越补越慢的循环。
因此项目限定一帧最多补 3 次:
if (
steps === MAX_SIMULATION_STEPS &&
simulationAccumulator >= SIMULATION_STEP
) {
simulationAccumulator %= SIMULATION_STEP;
simulationNow = now – simulationAccumulator * 1000;
}
这是一种明确的取舍:极端卡顿后允许丢掉一部分历史时间,也不要让主线程永远追不上现实时间。实时交互游戏里,恢复控制通常比逐毫秒补完旧状态更重要。
固定 60Hz 之后,怎样让 120 FPS 不显得一跳一跳
如果只把逻辑限制到 60Hz,却直接绘制最新状态,那么 120Hz 屏幕上会连续两帧看到几乎相同的位置。
解决办法是保存模拟前后的状态,再根据 accumulator 剩余比例插值:
const interpolation = simulationAccumulator / SIMULATION_STEP;
applyInterpolatedFrame(interpolation);
draw(now);
restoreInterpolatedFrame();
例如一个球体上一次模拟在 x = 100,本次模拟在 x = 110,当前 interpolation = 0.4,渲染位置就是 104。逻辑仍然只跑 60 次,显示位置却可以跟随更高刷新率平滑变化。
这里还需要注意内存分配。插值不应该每帧为所有球体新建数组和包装对象,否则平滑运动可能换来周期性的垃圾回收停顿。项目复用球体状态与渲染缓冲,并在绘制后恢复真实模拟状态。
空间网格:不要让每个球体检查所有球体
对象增加后,最先要解决的通常不是绘制,而是“谁需要和谁比较”。
如果每个球体都与所有球体做距离判断,候选配对在最坏情况下会按平方增长。但吞噬、碰撞和 AI 关注的对象通常只在附近,没有必要先扫描整个 7600×7600 世界。
项目把世界划分为边长 460 的 bucket,并用数值键存入 Map。

图 3:空间网格先收集查询范围覆盖的 bucket,再对候选对象执行精确距离与半径判断。网格不是碰撞答案,而是缩小候选集的工具。
网格构建的核心结构如下:
function cellBucketKey(bucketX, bucketY) {
return bucketX * 128 + bucketY;
}
function nearbyCells(grid, point, range, output) {
const list = output || [];
list.length = 0;
const minX = Math.floor((point.x – range) / CELL_BUCKET);
const maxX = Math.floor((point.x + range) / CELL_BUCKET);
const minY = Math.floor((point.y – range) / CELL_BUCKET);
const maxY = Math.floor((point.y + range) / CELL_BUCKET);
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
const bucket = grid.get(cellBucketKey(x, y));
if (bucket) list.push(…bucket);
}
}
return list;
}
这里还有四个比算法名字更容易被忽略的细节:
- cellSpacePool 使用两套空间结构轮换,不为每次构建创建新 Map;
- 每个空间结构保存 bucketPool,清空数组后再次使用;
- visibleCellBuffer、ejectedCandidateBuffer、cellCandidateBuffer、virusCandidateBuffer 都循环复用;
- AI 决策可以容忍上一帧的空间索引,因此同一个模拟帧不必为 AI 和碰撞各重建一次最大结构。
最后一点是一种业务允许的近似。AI 晚 1 帧获取附近目标,玩家通常感知不到;少构建一次大索引,却能稳定减少主线程工作。优化不是把所有数据都更新到“理论最新”,而是先判断哪一类数据真的需要零延迟。
WebGL2 点精灵:把数千次小绘制变成一次批量提交
食物对象数量多、尺寸小、结构一致,非常适合批处理。
GPU 渲染器给每个食物写入 8 个 float:世界坐标 x/y、半径、RGB、脉冲相位和稀有标记。缓冲容量不足时指数扩容,之后使用同一个 Float32Array 和 GPU buffer:
const floatsPerSprite = 8;
const needed = foods.length * floatsPerSprite;
if (needed > this.spriteCapacity) {
this.spriteCapacity = Math.max(
needed,
Math.max(1024, this.spriteCapacity * 2)
);
this.spriteData = new Float32Array(this.spriteCapacity);
gl.bufferData(
gl.ARRAY_BUFFER,
this.spriteData.byteLength,
gl.DYNAMIC_DRAW
);
}
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.spriteData, 0, needed);
gl.drawArrays(gl.POINTS, 0, foods.length);
这段优化的价值不在于“GPU 一定比 Canvas 快多少倍”——没有同机、同版本、同场景对照时,我不会给出倍数——而在于把大量结构一致的小对象压成一批连续数据和一次 gl.drawArrays(GL_POINTS)。
容量扩展只在不够时发生,也避免了每帧重新创建 GPU buffer。
为什么没有把整个游戏都改成 WebGL
项目采用的是混合渲染:
- WebGL2 负责背景和大量食物点精灵;
- Canvas 2D 负责球体、病毒、特效和游戏前景;
- DOM 负责 HUD、按钮、排行榜和可访问性文本;
- WebGL 不可用时自动回退 Canvas 2D。
这是维护成本与性能预算之间的选择。大量同构粒子适合 GPU 批处理,而频繁变化的文字、面板和复杂 2D 反馈继续使用 Canvas/DOM,迭代会更直接。
GPU 背景也没有在每个显示帧都重绘。它以最高 80 FPS 更新隐藏缓存,再复制到唯一可见的前景 Canvas:
const GPU_CACHE_FPS = Math.min(80, TARGET_RENDER_FPS);
const updateGpuCache =
gpuRenderer &&
gpuRenderer.active &&
(now – lastGpuFrame >= 1000 / GPU_CACHE_FPS || !backgroundCacheReady);
最终只保留一个不透明可见表面,可以减少双透明 Canvas 的合成压力。游戏过程中也不会为了短期降画质反复改变 4K Canvas 尺寸,因为 resize 往往伴随纹理重建、闪白和长停顿。
高 DPI 不能只看 devicePixelRatio
如果 3840×2160 屏幕再按 DPR 2 创建 Canvas,内部像素会达到约 3318 万。即使场景对象数量相同,清屏、混合和复制的成本也完全不同。
项目先按 320 万前景像素预算计算倍率上限:
function renderRatioCeiling() {
const deviceRatio = window.devicePixelRatio || 1;
const cssPixels = Math.max(1, innerWidth * innerHeight);
const pixelBudgetRatio = Math.sqrt(
MAX_FOREGROUND_PIXELS / cssPixels
);
return clamp(
pixelBudgetRatio,
0.7,
Math.min(1.25, deviceRatio)
);
}
DOM 界面仍由浏览器以原生分辨率排版,只有游戏前景 Canvas 受像素预算约束。这样牺牲的是一部分高压场景中的内部采样精度,不是把整个页面都变模糊。
系统还会根据平均帧间隔、平均工作时间、可见球体和吐出物压力切换低画质。低画质不是失败状态,而是预算控制器真的开始工作:先减少非关键特效和昂贵滤镜,保住操作反馈与模拟稳定。
Node 后端为什么存在:运行配置与遥测,而不是假装联网多人
这仍然是一场本地玩家与 AI 的对局。Node 服务没有把 99 个 AI 描述成在线真人,它主要负责静态资源、压缩、健康检查和运行时信息:
GET /api/health
GET /api/runtime
POST /api/telemetry
GET /api/telemetry
/api/runtime 会明确返回当前版本启用的性能策略,例如:
{
"fixedSimulationHz": 60,
"foregroundPixelBudget": 3200000,
"gpuSpriteBatching": true,
"stableCanvasDuringPlay": true,
"pooledCollisionBuffers": true,
"spatialIndexReuse": true
}
页面关闭时上报一条内存遥测样本,包括平均 FPS、帧工作时间、实际渲染后端、DPR、最长帧和是否进入低画质。它不落盘,也不替代完整的性能分析工具,但能回答“这次到底走了 WebGL2 还是 2D 回退”“画质控制器有没有触发”这些发布前问题。
最终使用独立显卡还是集成显卡,仍由浏览器和操作系统图形策略决定。请求 powerPreference: high-performance 不是独显保证,所以页面会显示 WebGL 实际设备,而不是根据机器配置猜测。
一次可复现的压力采样结果

图 4:Playwright Headless Edge、1600×900、DPR 1 下的 8 秒压力样本。它用于验证代码路径和预算机制,不代表不同设备上的统一成绩。
本次采集环境识别到:
WebGL2
ANGLE / AMD Radeon(TM) Graphics / Direct3D 11
Edge 150.0.4078.65
Windows · 1600×900 · DPR 1
8 秒压力样本的关键数据如下:
| 对局目标角色 | 100(1 玩家 + 99 AI) |
| 仍存活角色 | 99 |
| 食物对象 / 动态目标 | 3888 / 3888 |
| 吐出物 | 569(夹具初始 720) |
| 视野内实际绘制球体 | 90 |
| 视野内实际绘制食物 | 139 |
| 平均帧间隔 | 7.4ms |
| 平均每帧工作时间 | 4.1ms |
| 最长帧 | 31.3ms |
| 超过 80ms 的长帧 | 0 |
| 自适应低画质 | 已触发 |
这里不能直接把 7.4ms 写成所有设备都能达到的“135 FPS”。无头浏览器的 requestAnimationFrame 调度不等同于真实显示器呈现,代码中的渲染预算仍然是最高 120 FPS。这个样本能证明的是:指定环境里 WebGL2 与点精灵批处理被启用,压力夹具确实创建了目标规模,帧工作时间、长帧和自适应状态都被记录下来。
另一个重要区别是“世界对象数”和“绘制对象数”。场上有 3888 个食物对象,不代表每帧要把 3888 个全部提交到当前视野;样本里实际可见食物为 139。空间查询和视锥裁剪同样是渲染预算的一部分。
我没有采用的几种做法
1. 不让模拟频率跟着显示器刷新率走
这会让 160Hz 设备承担比 60Hz 设备更多的 AI 和碰撞工作,还容易造成规则差异。
2. 不把所有对象都交给 Canvas 2D 逐个绘制
Canvas 2D 继续承担擅长的前景反馈,大量同构食物则由 WebGL 点精灵批处理。
3. 不盲目追求原生 DPR
游戏画布有明确像素预算;界面清晰度与高压场景采样精度分别处理。
4. 不在游戏过程中频繁 resize 画布
动态缩放看起来很聪明,实际可能制造纹理重建和比降画质更明显的停顿。项目选择稳定画布尺寸,在内容和特效层面降级。
5. 不把 powerPreference 当成硬件保证
运行时读取真实 WebGL 设备,WebGL 不可用就回退,而不是在文案中承诺一定启用某张显卡。
这套优化顺序可以复用到其他浏览器游戏
如果重新做一遍,我仍会按下面的顺序处理:
这套顺序背后的原则是:先减少不该做的工作,再让必须做的工作更便宜,最后才讨论某个 API 在理论上快多少。
采集脚本与全部源码已开源
项目地址:
https://github.com/wangzifan396-wzf/mini-browser-games
对应目录与源码:
- 游戏前端:star-cluster-arena/frontend/
- 固定时间步、空间网格与 Canvas 前景:star-cluster-arena/frontend/js/game.js
- WebGL2 点精灵批处理:star-cluster-arena/frontend/js/gpu-renderer.js
- Node 运行时 API:star-cluster-arena/backend/server.mjs
- 本文压力采集:promo-video/scripts/capture-performance-article.mjs
- 本文配图生成:promo-video/scripts/create-browser-performance-article-images.py
- 原始测量数据:docs/images/browser-performance/runtime-measurement.json
复现采集与配图:
cd promo-video
npm install
npm.cmd run article:performance:capture
npm.cmd run article:performance:images
如果你也在做 Canvas/WebGL 游戏,希望这份拆解能给你一些灵感:性能优化不是把每一段代码都改得更复杂,而是让模拟、查询、绘制、像素和验证各自拥有清楚的预算。





