浏览器里同时跑 99 个 AI 和 2100+ 粒子:WebGL2、Canvas 与固定时间步优化实战

发布时间:2026/7/19 19:05:14
浏览器里同时跑 99 个 AI 和 2100+ 粒子:WebGL2、Canvas 与固定时间步优化实战 我在做《星团大作战》时遇到的性能问题并不是Canvas 能不能画几个圆而是下面这些工作会同时出现1 名玩家和 99 个 AI 持续移动、追击、逃跑、分裂与吞噬7600×7600 的世界里基础食物对象有 2100 个病毒、吐出物、临时粒子和大量分裂球体继续增加更新与绘制压力游戏要能在高刷新率屏幕上平滑显示但不能因为显示器变成 120Hz 或 160Hz就把 AI 和碰撞也多算一倍高 DPI 和 4K 屏幕会让填充像素量快速膨胀即使对象数量没有变化。如果继续使用最直接的update(dt) draw()并让每一次requestAnimationFrame都完整运行 AI、空间查询和碰撞高刷屏反而会把 CPU 主线程先拖慢。下面是项目调试接口自动构造的压力场景。它不是宣传效果图而是 Playwright 启动真实页面、填充后期对象并截下来的游戏画面。图 11 玩家 99 AI 的大逃杀压力场景。画面只显示视野内对象世界中的对象总数与实际提交绘制的对象数并不相同。这篇文章不做优化后提升 10 倍一类没有对照基准的结论。我更想完整解释在一个真实浏览器游戏里怎样确定预算、拆分更新与渲染、减少候选对象、批量提交绘制并让性能数据可以复现。先把场景规模说清楚项目中的核心预算如下项目配置含义世界尺寸7600×7600大多数对象不在当前视野内对局角色1001 名玩家 99 个 AI不是 100 名联网真人食物基线2100代码中的FOOD_BASE_COUNT食物压力夹具3600stressLateGame()的初始填充值大逃杀动态目标最高 38883600 再乘该模式的 1.08 缩放病毒基线54大小病毒共同存在吐出物上限720MAX_EJECTED临时特效粒子上限560MAX_PARTICLES前景像素预算3,200,000控制高 DPI Canvas 内部尺寸标题里的“2100 粒子”主要指用 GPU 点精灵绘制的食物对象。源码内部仍然严格区分foods、ejected和短生命周期的particles它们的更新规则并不相同。还有一个容易误读的数字FOOD_MAX_COUNT 3600是标准模式的目标上界不是所有模式的绝对硬上限。大逃杀使用foodTargetScale 1.08因此后期动态目标可以到 3888。把常量、模式倍率和实测数量分开才能避免性能文章中的数字互相打架。第一步不是换 WebGL而是拆开模拟与渲染图 2输入、固定时间步模拟、状态快照、插值渲染之间的关系。高刷显示只提高呈现频率不让 AI 和碰撞跟着重复运行。我把游戏的时间分成了两条线游戏模拟固定为 60Hz负责 AI、移动、碰撞和规则结算渲染目标最高 120 FPS负责把两个模拟状态之间的画面平滑显示出来。核心常量很简单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 bufferconst 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 更新隐藏缓存再复制到唯一可见的前景 Canvasconst 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 实际设备而不是根据机器配置猜测。一次可复现的压力采样结果图 4Playwright Headless Edge、1600×900、DPR 1 下的 8 秒压力样本。它用于验证代码路径和预算机制不代表不同设备上的统一成绩。本次采集环境识别到WebGL2 ANGLE / AMD Radeon(TM) Graphics / Direct3D 11 Edge 150.0.4078.65 Windows · 1600×900 · DPR 18 秒压力样本的关键数据如下指标结果对局目标角色1001 玩家 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 不可用就回退而不是在文案中承诺一定启用某张显卡。这套优化顺序可以复用到其他浏览器游戏如果重新做一遍我仍会按下面的顺序处理先定义角色数、世界对象数、可见对象数、像素数和目标帧预算把固定频率模拟与高刷新率渲染拆开用空间结构减少候选对象而不是先微调绘制语句把大量同构小对象改成连续数据和批量提交复用热点路径上的数组、桶、包装对象和缓冲区给高 DPI Canvas 设置像素预算保留清晰的回退路径和自适应降级用真实压力夹具、运行时快照和截图验证而不是只凭肉眼感觉流畅。这套顺序背后的原则是先减少不该做的工作再让必须做的工作更便宜最后才讨论某个 API 在理论上快多少。采集脚本与全部源码已开源项目地址https://github.com/wangzifan396-wzf/mini-browser-games对应目录与源码游戏前端star-cluster-arena/frontend/固定时间步、空间网格与 Canvas 前景star-cluster-arena/frontend/js/game.jsWebGL2 点精灵批处理star-cluster-arena/frontend/js/gpu-renderer.jsNode 运行时 APIstar-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 游戏希望这份拆解能给你一些灵感性能优化不是把每一段代码都改得更复杂而是让模拟、查询、绘制、像素和验证各自拥有清楚的预算。