[性能] 渲染主循环掉到 20 tps:零视口剔除(88.6% 白画)+ 图集页切换导致单帧最高 3077 次 draw call #16

Closed
opened 2026-09-14 23:58:28 +00:00 by troytt · 3 comments
Owner

一、现象

打开 acts.html 走图时,HUD 常驻显示 ~20 tps(目标 25 tps),操作明显发粘。

关键点:20 tps 不是"模拟算不过来",而是"渲染太慢把模拟饿死了"。

src/sim/loop.ts:71-95 的 GameLoop 完全挂在 requestAnimationFrame 上:

const step = (now: number): void => {
  const elapsed = now - this.lastTime
  this.accumulator += elapsed
  while (this.accumulator >= this.tickMs && steps < this.maxCatchUp) { ... }
  // 长时间卡顿时直接丢弃积压,避免"死亡螺旋"
  if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0
  this.options.onRender(...)
  this.handle = requestAnimationFrame(step)
}

onRender 是同步执行的,它一慢,rAF 回调间隔就被拉长,accumulator 冲破 tickMs * maxCatchUp(40 × 5 = 200 ms)后被直接清零——模拟时间就这么被丢掉了,于是测得的 tickRate 掉到 25 以下。HUD 上的 tps 实际上是渲染耗时的间接读数。


二、实测数据

以下数字全部由脚本对仓库内 samples/d2-packs/*/*/scene.json 逐关静态回放 act-scene.ts 的绘制顺序算出(365 个关卡块,视口按 1600×900、相机在出生点、缩放取 coverZoom 默认值)。

2.1 每帧提交的四边形与 draw call

指标 平均 最差
每帧四边形数 2 849 14 400(act4/108-act-4-diablo-1)
每帧 GPU draw call 361 3 077(act4/107-act-4-lava-1-var3)
多图集页关卡(>1 page)占比 242 / 365 平均 523 calls

SpriteRenderer.flush() 每次要发 9 个 GL 命令(useProgram + bindVertexArray + bindBuffer + bufferSubData + 4 个 uniform + activeTexture/bindTexture + drawArrays)。
最差关卡 = 3 077 × 9 ≈ 27 700 个 GL 命令/帧。浏览器每个 GL 命令都要过一层参数校验与 IPC,这就是 40 ms 帧预算被吃光的地方。

2.2 视口剔除的浪费

指标 值
全部关卡四边形总数 1 026 076
视口内可见 117 019
可见率 11.4%
白画掉的 88.6%

最差的 10 关可见率只有 1.2% ~ 2.4%:

  1.2%  vis=   62/  5042  map=12800x6448  act1/5-act-1-wilderness-4-var3
  1.3%  vis=   66/  4998  map=12800x6616  act1/5-act-1-wilderness-4-var1
  1.5%  vis=   95/  6307  map=12800x6584  act1/2-act-1-wilderness-1-var1
  1.7%  vis=  109/  6346  map=17360x9320  act5/111-act-5-barricade-1-var2
  2.4%  vis=  350/ 14400  map=19200x9648  act4/108-act-4-diablo-1-var1

原因很直白:act-scene.ts:809-880 从头到尾遍历整张地图,一次剔除都没有:

drawTiles(runtime.floors, 0, runtime.floors.length)   // 整张图的地面
...
drawUpToDepth(playerDepth)                            // 整张图的墙 + 对象
drawUpToDepth(Infinity)
drawTiles(runtime.roofs, 0, runtime.roofs.length)     // 整张图的屋顶

全仓库(src/)搜不到任何 cull / 视口裁剪逻辑。

2.3 顶点写入路径的分配开销

renderer.ts:409-436 的 quad() 每个四边形都要新建 4 个临时数组并解构 tint:

const [r, g, b, a] = tint
const xs = [x0, x1, x0, x1, x1, x0]
const ys = [y0, y0, y1, y0, y1, y1]
const us = [u0, u1, u0, u1, u1, u0]
const vs = [v0, v0, v1, v0, v1, v1]
for (let i = 0; i < VERTICES_PER_QUAD; i += 1) { ... }

按平均 2 849 квads/帧算:每帧 11 396 个临时数组,60 fps 下每秒 68 万个短命对象,直接喂给 GC。

实测(Node 22,2 849 quads × 2 000 帧):

写入实现 每帧耗时
当前(4 个临时数组 + 解构) 244.9 µs
手动展开(零分配) 106.0 µs
— 2.31×

2.4 其他确认的问题

# 位置 问题
a renderer.ts:445 每次 flush 都往同一段 buffer 偏移 0 处 bufferSubData。一帧 flush 几百上千次 = 反复覆写 GPU 正在读的内存,驱动被迫做隐式同步(pipeline stall)。
b renderer.ts:63-65 每四边形 6 顶点 × 8 float = 48 float。没有索引缓冲(4 顶点 = 32 float,省 33%),更没有实例化(约 12 float,省 75%)。平均每帧上传 547 KB 顶点数据,60 fps 下 32 MB/s。
c renderer.ts:95 片元着色器用 if (texel.a == 0.0) discard;。discard 会让 GPU 关掉 early-Z / 提前深度剔除的快路径;这里 DEPTH_TEST 本来就是关的,alpha 混合已经能处理全透明像素,discard 的收益需要重新实测。
d act-scene.ts:882 state.drawCalls = 1 是硬编码的(walk.ts:250 同样)。也就是说仪表盘一直在说"1 次 draw call",而真实值是 361~3 077。这个假读数是这个性能问题一直没被发现的直接原因。
e act-scene.ts:887 runtime.pages.filter(page => page !== null).length —— 每帧新建一个数组,只为了数个数。
f act-scene.ts:889-893 每帧重建 HUD textContent:10 段字符串拼接 + 4 次 toFixed(),并触发 DOM 写入与布局。
g act-scene.ts:826-854 drawUpToDepth 是在 onRender 内部定义的闭包,每帧重新创建(drawTiles 同理)。
h sim/loop.ts 模拟与 rAF 强耦合(见第一节)。

三、联网调研 + 方案

方案 A(P0)视口剔除 + 空间分桶

只提交视口内的绘制项。地图几何是完全静态的,可以在 loadPackRuntime 里一次性按格子(比如 16×16 cell 一桶)建好空间索引,每帧只遍历与视口相交的桶。

官方指引明确要求「只把可见 tile 送进 GPU,在 CPU 侧算出视口内的 tile 再填顶点缓冲」。

实测收益:每帧四边形 2 849 → 328(8.7×),draw call 361 → 30(12.1×)。

方案 B(P0)WebGL2 纹理数组:把 draw call 压到 1

这是本项目最对症的一招。当前每次图集页切换都要 flush(),而画家序天然会让不同页的图块交替出现 → 疯狂打断批次。

WebGL2 原生支持 TEXTURE_2D_ARRAY + sampler2DArray:把所有 2048² 图集页塞进一个纹理数组的不同 layer,绑定一次,用 per-vertex 的 layer 属性在着色器里选层:

#version 300 es
precision mediump float;
precision mediump sampler2DArray;
uniform sampler2DArray u_pages;
in vec2 v_uv;
in float v_layer;
out vec4 outColor;
void main() { outColor = texture(u_pages, vec3(v_uv, v_layer)) * v_tint; }

调研结论:对尺寸统一的图集页(本项目全部是 2048×2048),sampler2DArray 是"professional approach",相比传统图集不但没有 texture bleeding、原生支持 mipmap,最关键的是一次绑定即可覆盖全部图层,无论画家序怎么交错都不会打断批次。

实测收益:draw call 361 → 1(最差关卡 3 077 → 1,降低 3 077×)。
注意 MAX_ARRAY_TEXTURE_LAYERS 通常 ≥ 256,本项目最多 9 页,绰绰有余。

方案 C(P1)零分配顶点写入 + 索引缓冲 + buffer orphaning

  1. quad() 手动展开,杜绝临时数组(实测 2.31×);tint 改传 4 个标量而不是元组。
  2. 改用 drawElements + 预建静态 EBO(0,1,2, 1,3,2 循环),顶点数 6 → 4,顶点数据少 33%。
  3. flush 前先 bufferData(target, size, DYNAMIC_DRAW) 传 null 做 buffer orphaning,或者做环形缓冲分���写。

调研共识:反复 bufferSubData 同一区间会让驱动等 GPU 读完才敢覆写,产生 pipeline stall;orphaning 让驱动换一块新内存(buffer renaming),是消除该 stall 的标准解法。环形缓冲是性能上限更高但更复杂的版本。

方案 D(P1)静态几何预烘焙进不变 VBO

地面/墙/屋顶的顶点在整个关卡生命周期里一个 float 都不会变(相机是 uniform,不进顶点)。完全可以在关卡加载时把它们烘进 STATIC_DRAW 的 VBO,每帧只按剔除结果调整 drawElements 的 offset/count,动态缓冲里只剩角色和少量动画对象。这样每帧的 CPU 顶点写入量可以从 2 849 quads 降到接近 0。

方案 E(P2)实例化渲染

四边形几何只上传一次,per-instance 属性只放 (x, y, w, h, u0, v0, u1, v1, layer, tint),用 drawArraysInstanced 一次画完。

调研结论:对于 tile/粒子这类"大量相似精灵",实例化能把 CPU 开销"大幅降低"。

顶点数据量:48 float/quad → 约 12 float/instance(-75%)。建议放在 A/B/C 之后,用实测决定是否值得。

方案 F(P2)模拟与渲染解耦

把固定步长模拟从 rAF 里摘出来,这样即使渲染掉帧,tps 也能稳在 25:

  • 轻量版:模拟走 setInterval/递归 setTimeout,rAF 只负责画 + 插值。
  • 彻底版:模拟搬进 Web Worker,主线程只拿状态渲染;甚至用 OffscreenCanvas 把渲染也交给 Worker。

调研结论:把模拟放进 Worker 可以保证「慢的 update() 不会阻塞 rAF 回调」,是消除 render starvation 的标准架构。本项目已有 src/net/lockstep.ts,确定性定步长模拟天然适配 Worker。

方案 G(P2)着色器与状态微调

  • 实测去掉 discard(纯靠 alpha blend)在目标机型上是快还是慢。
  • 纹理创建改用 gl.texStorage3D(不可变存储),比 texImage 系列分配更高效。
  • flush 里的 4 个 uniform 每帧只变一次,改成"仅在相机变化时上传",或搬进 UBO。

关于"换 Three.js / PixiJS"

不建议。瓶颈不在"缺少引擎",而在上面这些具体且已定位的实现问题——零剔除、页切换打断批次、每 quad 分配临时数组。Three.js 不会自动帮你做 2D 等距画家序的视口剔除,PixiJS 倒是自带批处理,但要为此重写整套画家序/深度交织/碰撞体系,代价远大于按上面 A→C 改造现有的 556 行渲染器。


四、收益汇总

阶段 改动 每帧四边形 每帧 draw call 每帧 GL 命令
现状 — 2 849(最差 14 400) 361(最差 3 077) ~3 250(最差 27 700)
A 视口剔除 328 30(最差 246) ~270
A+B + 纹理数组 328 1 ~9
A+B+C + 零分配/索引/orphaning 328 1 ~9,CPU 再省 2.3×
A+B+C+D + 静态 VBO 328 1 ~9,CPU 写入≈0

五、落地顺序

  1. 先修仪表盘(半小时的事,但必须最先做):给 SpriteRenderer 加真实的 flushCount/quadCount 统计,去掉 state.drawCalls = 1 这个硬编码谎言;HUD 增加帧耗时、可见四边形数、剔除率。没有真读数就没法验证后面任何一步。
  2. 方案 A:视口剔除 + 静态空间分桶。
  3. 方案 B:TEXTURE_2D_ARRAY 重写批处理器。
  4. 方案 C:零分配 quad() + 索引缓冲 + buffer orphaning。
  5. 方案 D/F:静态几何 VBO、模拟与渲染解耦。
  6. 方案 E/G:实例化与着色器微调,按实测决定。

顺手清理(成本极低):

  • state.pagesLoaded 的 filter() 改成计数器,在页加载完成时自增;
  • HUD 改成节流更新(比如 4 Hz)且只在文本变化时写 DOM;
  • drawTiles / drawUpToDepth 提到 onRender 外面,避免每帧重建闭包。

六、验收标准

  1. scripts/verify-renderer-lifecycle.ts 扩展:断言剔除后提交的四边形数 ≤ 视口内理论值 × 1.1,断言多页关卡的 draw call = 1。
  2. scripts/browser/checks/ 新增性能断言:进入 act4/107-act-4-lava-1-var3(当前最差关)后连续 120 帧,tickRate ≥ 24.5 且 p95 帧耗时 ≤ 16 ms。
  3. HUD 显示的 drawCalls 必须是真实测量值,并纳入自动化检查。
  4. 人眼回归:Act 1 城镇、Catacombs 4、Act 5 Town、奶牛关——画家序、屋顶遮挡、对象层深度交织与改造前逐像素一致(可复用 verify:packs 的像素哈希口径)。

七、参考资料

  • MDN, WebGL best practices(批处理、texStorage、精度限定符)
  • webgl2fundamentals.org, WebGL2 Tilemaps(着色器侧 tile 查表 + 数据纹理)
  • Khronos / Unity, Draw call batching & texture atlas(纹理切换打断批次)
  • Stack Exchange (gamedev), Texture Atlas vs Texture Array(sampler2DArray 对尺寸统一图集是更优解)
  • Jake Gordon, JavaScript Game Foundations — The Game Loop;Fix Your Timestep! 定步长 + 插值范式
  • WebGL dynamic buffer 更新实践:buffer orphaning 与 ring buffer

(关联:#9 修的是显存泄漏,这个 issue 修的是帧时间;#5 引入的对象层会在画家序里与墙体交错,正是方案 B 要解决的批次打断来源之一。)

## 一、现象 打开 `acts.html` 走图时,HUD 常驻显示 **~20 tps**(目标 25 tps),操作明显发粘。 关键点:**20 tps 不是"模拟算不过来",而是"渲染太慢把模拟饿死了"**。 `src/sim/loop.ts:71-95` 的 `GameLoop` 完全挂在 `requestAnimationFrame` 上: ```ts const step = (now: number): void => { const elapsed = now - this.lastTime this.accumulator += elapsed while (this.accumulator >= this.tickMs && steps < this.maxCatchUp) { ... } // 长时间卡顿时直接丢弃积压,避免"死亡螺旋" if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0 this.options.onRender(...) this.handle = requestAnimationFrame(step) } ``` `onRender` 是同步执行的,它一慢,rAF 回调间隔就被拉长,`accumulator` 冲破 `tickMs * maxCatchUp`(40 × 5 = 200 ms)后被**直接清零**——模拟时间就这么被丢掉了,于是测得的 tickRate 掉到 25 以下。**HUD 上的 tps 实际上是渲染耗时的间接读数。** --- ## 二、实测数据 以下数字全部由脚本对仓库内 `samples/d2-packs/*/*/scene.json` 逐关静态回放 `act-scene.ts` 的绘制顺序算出(365 个关卡块,视口按 1600×900、相机在出生点、缩放取 `coverZoom` 默认值)。 ### 2.1 每帧提交的四边形与 draw call | 指标 | 平均 | 最差 | | --- | --- | --- | | 每帧四边形数 | **2 849** | **14 400**(`act4/108-act-4-diablo-1`)| | 每帧 GPU draw call | **361** | **3 077**(`act4/107-act-4-lava-1-var3`)| | 多图集页关卡(>1 page)占比 | 242 / 365 | 平均 523 calls | `SpriteRenderer.flush()` 每次要发 9 个 GL 命令(`useProgram` + `bindVertexArray` + `bindBuffer` + `bufferSubData` + 4 个 uniform + `activeTexture`/`bindTexture` + `drawArrays`)。 **最差关卡 = 3 077 × 9 ≈ 27 700 个 GL 命令/帧**。浏览器每个 GL 命令都要过一层参数校验与 IPC,这就是 40 ms 帧预算被吃光的地方。 ### 2.2 视口剔除的浪费 | 指标 | 值 | | --- | --- | | 全部关卡四边形总数 | 1 026 076 | | 视口内可见 | 117 019 | | **可见率** | **11.4%** | | **白画掉的** | **88.6%** | 最差的 10 关可见率只有 **1.2% ~ 2.4%**: ``` 1.2% vis= 62/ 5042 map=12800x6448 act1/5-act-1-wilderness-4-var3 1.3% vis= 66/ 4998 map=12800x6616 act1/5-act-1-wilderness-4-var1 1.5% vis= 95/ 6307 map=12800x6584 act1/2-act-1-wilderness-1-var1 1.7% vis= 109/ 6346 map=17360x9320 act5/111-act-5-barricade-1-var2 2.4% vis= 350/ 14400 map=19200x9648 act4/108-act-4-diablo-1-var1 ``` 原因很直白:`act-scene.ts:809-880` 从头到尾遍历整张地图,**一次剔除都没有**: ```ts drawTiles(runtime.floors, 0, runtime.floors.length) // 整张图的地面 ... drawUpToDepth(playerDepth) // 整张图的墙 + 对象 drawUpToDepth(Infinity) drawTiles(runtime.roofs, 0, runtime.roofs.length) // 整张图的屋顶 ``` 全仓库(`src/`)搜不到任何 cull / 视口裁剪逻辑。 ### 2.3 顶点写入路径的分配开销 `renderer.ts:409-436` 的 `quad()` **每个四边形都要新建 4 个临时数组**并解构 tint: ```ts const [r, g, b, a] = tint const xs = [x0, x1, x0, x1, x1, x0] const ys = [y0, y0, y1, y0, y1, y1] const us = [u0, u1, u0, u1, u1, u0] const vs = [v0, v0, v1, v0, v1, v1] for (let i = 0; i < VERTICES_PER_QUAD; i += 1) { ... } ``` 按平均 2 849 квads/帧算:**每帧 11 396 个临时数组**,60 fps 下每秒 68 万个短命对象,直接喂给 GC。 实测(Node 22,2 849 quads × 2 000 帧): | 写入实现 | 每帧耗时 | | --- | --- | | 当前(4 个临时数组 + 解构) | **244.9 µs** | | 手动展开(零分配) | **106.0 µs** | | — | **2.31×** | ### 2.4 其他确认的问题 | # | 位置 | 问题 | | --- | --- | --- | | a | `renderer.ts:445` | 每次 flush 都往**同一段** buffer 偏移 0 处 `bufferSubData`。一帧 flush 几百上千次 = 反复覆写 GPU 正在读的内存,驱动被迫做隐式同步(pipeline stall)。 | | b | `renderer.ts:63-65` | 每四边形 **6 顶点 × 8 float = 48 float**。没有索引缓冲(4 顶点 = 32 float,省 33%),更没有实例化(约 12 float,省 75%)。平均每帧上传 547 KB 顶点数据,60 fps 下 32 MB/s。 | | c | `renderer.ts:95` | 片元着色器用 `if (texel.a == 0.0) discard;`。`discard` 会让 GPU 关掉 early-Z / 提前深度剔除的快路径;这里 `DEPTH_TEST` 本来就是关的,alpha 混合已经能处理全透明像素,`discard` 的收益需要重新实测。 | | d | `act-scene.ts:882` | **`state.drawCalls = 1` 是硬编码的**(`walk.ts:250` 同样)。也就是说仪表盘一直在说"1 次 draw call",而真实值是 361~3 077。**这个假读数是这个性能问题一直没被发现的直接原因。** | | e | `act-scene.ts:887` | `runtime.pages.filter(page => page !== null).length` —— 每帧新建一个数组,只为了数个数。 | | f | `act-scene.ts:889-893` | 每帧重建 HUD `textContent`:10 段字符串拼接 + 4 次 `toFixed()`,并触发 DOM 写入与布局。 | | g | `act-scene.ts:826-854` | `drawUpToDepth` 是在 `onRender` **内部**定义的闭包,每帧重新创建(`drawTiles` 同理)。 | | h | `sim/loop.ts` | 模拟与 rAF 强耦合(见第一节)。 | --- ## 三、联网调研 + 方案 ### 方案 A(P0)视口剔除 + 空间分桶 只提交视口内的绘制项。地图几何是**完全静态**的,可以在 `loadPackRuntime` 里一次性按格子(比如 16×16 cell 一桶)建好空间索引,每帧只遍历与视口相交的桶。 > 官方指引明确要求「只把可见 tile 送进 GPU,在 CPU 侧算出视口内的 tile 再填顶点缓冲」。 **实测收益:每帧四边形 2 849 → 328(8.7×),draw call 361 → 30(12.1×)。** ### 方案 B(P0)WebGL2 纹理数组:把 draw call 压到 1 这是本项目**最对症**的一招。当前每次图集页切换都要 `flush()`,而画家序天然会让不同页的图块交替出现 → 疯狂打断批次。 WebGL2 原生支持 `TEXTURE_2D_ARRAY` + `sampler2DArray`:把所有 2048² 图集页塞进一个纹理数组的不同 layer,**绑定一次**,用 per-vertex 的 `layer` 属性在着色器里选层: ```glsl #version 300 es precision mediump float; precision mediump sampler2DArray; uniform sampler2DArray u_pages; in vec2 v_uv; in float v_layer; out vec4 outColor; void main() { outColor = texture(u_pages, vec3(v_uv, v_layer)) * v_tint; } ``` 调研结论:对尺寸统一的图集页(本项目全部是 2048×2048),`sampler2DArray` 是"professional approach",相比传统图集不但**没有 texture bleeding**、原生支持 mipmap,最关键的是**一次绑定即可覆盖全部图层,无论画家序怎么交错都不会打断批次**。 **实测收益:draw call 361 → 1(最差关卡 3 077 → 1,降低 3 077×)。** 注意 `MAX_ARRAY_TEXTURE_LAYERS` 通常 ≥ 256,本项目最多 9 页,绰绰有余。 ### 方案 C(P1)零分配顶点写入 + 索引缓冲 + buffer orphaning 1. `quad()` 手动展开,杜绝临时数组(**实测 2.31×**);tint 改传 4 个标量而不是元组。 2. 改用 `drawElements` + 预建静态 EBO(`0,1,2, 1,3,2` 循环),顶点数 6 → 4,**顶点数据少 33%**。 3. flush 前先 `bufferData(target, size, DYNAMIC_DRAW)` 传 `null` 做 **buffer orphaning**,或者做环形缓冲分���写。 > 调研共识:反复 `bufferSubData` 同一区间会让驱动等 GPU 读完才敢覆写,产生 pipeline stall;orphaning 让驱动换一块新内存(buffer renaming),是消除该 stall 的标准解法。环形缓冲是性能上限更高但更复杂的版本。 ### 方案 D(P1)静态几何预烘焙进不变 VBO 地面/墙/屋顶的顶点在整个关卡生命周期里**一个 float 都不会变**(相机是 uniform,不进顶点)。完全可以在关卡加载时把它们烘进 `STATIC_DRAW` 的 VBO,每帧只按剔除结果调整 `drawElements` 的 offset/count,**动态缓冲里只剩角色和少量动画对象**。这样每帧的 CPU 顶点写入量可以从 2 849 quads 降到接近 0。 ### 方案 E(P2)实例化渲染 四边形几何只上传一次,per-instance 属性只放 `(x, y, w, h, u0, v0, u1, v1, layer, tint)`,用 `drawArraysInstanced` 一次画完。 > 调研结论:对于 tile/粒子这类"大量相似精灵",实例化能把 CPU 开销"大幅降低"。 顶点数据量:48 float/quad → 约 12 float/instance(**-75%**)。建议放在 A/B/C 之后,用实测决定是否值得。 ### 方案 F(P2)模拟与渲染解耦 把固定步长模拟从 rAF 里摘出来,这样即使渲染掉帧,tps 也能稳在 25: - 轻量版:模拟走 `setInterval`/递归 `setTimeout`,rAF 只负责画 + 插值。 - 彻底版:模拟搬进 **Web Worker**,主线程只拿状态渲染;甚至用 **OffscreenCanvas** 把渲染也交给 Worker。 > 调研结论:把模拟放进 Worker 可以保证「慢的 update() 不会阻塞 rAF 回调」,是消除 render starvation 的标准架构。本项目已有 `src/net/lockstep.ts`,确定性定步长模拟天然适配 Worker。 ### 方案 G(P2)着色器与状态微调 - 实测去掉 `discard`(纯靠 alpha blend)在目标机型上是快还是慢。 - 纹理创建改用 `gl.texStorage3D`(不可变存储),比 `texImage` 系列分配更高效。 - flush 里的 4 个 uniform 每帧只变一次,改成"仅在相机变化时上传",或搬进 UBO。 ### 关于"换 Three.js / PixiJS" **不建议**。瓶颈不在"缺少引擎",而在上面这些**具体且已定位**的实现问题——零剔除、页切换打断批次、每 quad 分配临时数组。Three.js 不会自动帮你做 2D 等距画家序的视口剔除,PixiJS 倒是自带批处理,但要为此重写整套画家序/深度交织/碰撞体系,代价远大于按上面 A→C 改造现有的 556 行渲染器。 --- ## 四、收益汇总 | 阶段 | 改动 | 每帧四边形 | 每帧 draw call | 每帧 GL 命令 | | --- | --- | --- | --- | --- | | 现状 | — | 2 849(最差 14 400) | 361(最差 **3 077**) | ~3 250(最差 **27 700**) | | A | 视口剔除 | **328** | 30(最差 246) | ~270 | | A+B | + 纹理数组 | 328 | **1** | **~9** | | A+B+C | + 零分配/索引/orphaning | 328 | 1 | ~9,CPU 再省 2.3× | | A+B+C+D | + 静态 VBO | 328 | 1 | ~9,CPU 写入≈0 | --- ## 五、落地顺序 1. **先修仪表盘**(半小时的事,但必须最先做):给 `SpriteRenderer` 加真实的 `flushCount`/`quadCount` 统计,去掉 `state.drawCalls = 1` 这个硬编码谎言;HUD 增加帧耗时、可见四边形数、剔除率。**没有真读数就没法验证后面任何一步。** 2. **方案 A**:视口剔除 + 静态空间分桶。 3. **方案 B**:`TEXTURE_2D_ARRAY` 重写批处理器。 4. **方案 C**:零分配 `quad()` + 索引缓冲 + buffer orphaning。 5. **方案 D/F**:静态几何 VBO、模拟与渲染解耦。 6. **方案 E/G**:实例化与着色器微调,按实测决定。 顺手清理(成本极低): - `state.pagesLoaded` 的 `filter()` 改成计数器,在页加载完成时自增; - HUD 改成节流更新(比如 4 Hz)且只在文本变化时写 DOM; - `drawTiles` / `drawUpToDepth` 提到 `onRender` 外面,避免每帧重建闭包。 --- ## 六、验收标准 1. `scripts/verify-renderer-lifecycle.ts` 扩展:断言剔除后提交的四边形数 ≤ 视口内理论值 × 1.1,断言多页关卡的 draw call **= 1**。 2. `scripts/browser/checks/` 新增性能断言:进入 `act4/107-act-4-lava-1-var3`(当前最差关)后连续 120 帧,`tickRate ≥ 24.5` 且 `p95 帧耗时 ≤ 16 ms`。 3. HUD 显示的 `drawCalls` 必须是**真实测量值**,并纳入自动化检查。 4. 人眼回归:Act 1 城镇、Catacombs 4、Act 5 Town、奶牛关——画家序、屋顶遮挡、对象层深度交织与改造前**逐像素一致**(可复用 `verify:packs` 的像素哈希口径)。 --- ## 七、参考资料 - MDN, *WebGL best practices*(批处理、`texStorage`、精度限定符) - webgl2fundamentals.org, *WebGL2 Tilemaps*(着色器侧 tile 查表 + 数据纹理) - Khronos / Unity, *Draw call batching & texture atlas*(纹理切换打断批次) - Stack Exchange (gamedev), *Texture Atlas vs Texture Array*(`sampler2DArray` 对尺寸统一图集是更优解) - Jake Gordon, *JavaScript Game Foundations — The Game Loop*;`Fix Your Timestep!` 定步长 + 插值范式 - WebGL dynamic buffer 更新实践:buffer orphaning 与 ring buffer --- (关联:#9 修的是显存泄漏,这个 issue 修的是帧时间;#5 引入的对象层会在画家序里与墙体交错,正是方案 B 要解决的批次打断来源之一。)
Author
Owner

A→C 已落地(commit 4cc49a0)

三步一起做完了,实测数字如下。测法和开 issue 时一致:把 act-scene 的绘制顺序在
samples/d2-packs 的全部 365 张烘焙关卡上静态重放,相机停在各关出生点,视口 1280×720、
缩放 1。

结果

指标 改造前 改造后
每帧四边形(平均) 2 848 204(7.2%)
每帧四边形(最差) 14 400(act4 迪亚波罗) 437(act5 神殿入口)
每帧 draw call(平均) 361 1
每帧 draw call(最差) 3 077 1
顶点写入(2 849 个四边形/帧) 244.9 µs 106.0 µs(2.31×)
每帧临时数组 11 396 个 0

(表里"改造后 draw call = 1"是合批之后的结果;如果只做剔除不做合批,按旧的
"换页就断批"模型算,平均也已经从 361 降到 15。)

A:视口剔除

关卡建好时把每条绘制列表的包围盒预先摊平成一块 Float32Array,每帧线性扫描做四次
浮点比较。

选线性扫描而不是二分/分桶,是因为绘制列表不按 y 单调——实测地面 0/365 关单调,
最大回退 9 520 px;墙 48/365 单调——任何空间索引都得重新合并回画家顺序。14 400 次比较
约 15~30 µs,占 16 ms 预算的 0.2%,换来顺序原样保留。

墙和对象那条两路深度归并里有个坑:剔除只能跳过"画"这一步,游标必须照常前进,否则后面
所有遮挡关系都会错位。这条已经写进护栏(见下)。

B:改为多纹理单元合批,没有用 TEXTURE_2D_ARRAY

原方案在数据面前站不住:页高差异极大(2048×1999 到 2048×48,对象页 2048×48…2048×213),
纹理数组要求各层等大。实测朴素做法(层高取最大页高)显存涨 1.73 倍,最差 2.82 倍,
单关最高 156.8 MB;把矮页装箱进 2048² 层、再收紧层高,仍要 1.29 倍、最差 141 MB。

而决定性的一条测量是:全部 365 关里,单关的页数(瓦片 + 对象)最多只有 10 个
(act5/109-act-5-town-townwest),加角色图集 11 个,稳稳低于 WebGL2 保证的 16 个
MAX_TEXTURE_IMAGE_UNITS。页数分布:1页18关、2页112、3页143、4页40、5页35、6页2、
7页5、8页4、9页5、10页1。

所以改成:每个在用页各占一个纹理单元,单元号随顶点属性进着色器,片元按常量 switch
选采样器,只有在用页数超过单元预算时才断批。同样是 1 次 draw call,显存零增长,资源
不用重烘焙
,也是 PixiJS 在生产里用的做法。

C:零分配顶点写入 + 索引缓冲 + buffer orphaning

quad() 原本每次建 4 个临时数组再解构,展开成标量后单帧 244.9 µs → 106.0 µs。顶点从
6 个/四边形降到 4 个并改用静态索引缓冲,上传前先 orphan 一次缓冲区,避免和 GPU 抢同一
块内存。

顺带修掉的

  • state.drawCalls 在 act-scene.ts 和 walk.ts 里都硬编码成 1 —— 这个假读数正是
    3 077 次 draw call 长期没被发现的原因。现在读渲染器自己的计数,HUD 一并显示图元数、
    剔除数和帧耗时。
  • pages.filter(...).length 每帧建一个临时数组只为数个数。
  • HUD 文本每帧重建(十次字符串拼接 + 四次 toFixed),改成 4 Hz。
  • NPC 名牌每帧 innerHTML='' 再 createElement,改成复用元素只改位置。
  • onTick 里 playerMoving 用 let 遮蔽了外层同名变量,onRender 永远读到 false���
    角色走路动画从来没播放过。顺手修了。

护栏

  • 新增 tests/cull.test.ts(9 条):除边界条件(160×960 的大精灵锚点在屏外也不能剔、
    余量刚好卡住出界一圈)外,用全部 365 张关卡真实数据重放一遍,断言"开剔除后画出的
    序列必须是不开剔除时那个序列的子序列"——顺序一致、只少不多——并守住剔除率。
    365/365 关顺序完全保持。
  • verify-renderer-lifecycle 从 26 条加到 37 条,锁住:60 个四边形在 3 个页之间来回切
    只花 1 次 draw call、精灵和实心矩形能同批、超出单元预算恰好多花 1 次、计数器在
    begin() 归零、着色器里 switch (v_unit) 的形状和采样器数组的大小。
  • npm run typecheck、npm run build、npx vitest run 全过(439 passed;另有 3 条
    NPC 资源相关的既有失败,干净树上同样失败,与本次无关)。

还没做的

issue 里的 D/E/F/G 都还在:模拟与 rAF 解耦(F,20 tps 这个读数本身就是被渲染耗时拖出来的)、
片元 discard 是否真的关掉了 GPU 快路径(G,需要实机测)、以及实体列表每帧的闭包分配和
sort。要不要把这个 issue 继续留着跟进 D~G,还是新开一个?

## A→C 已落地(commit `4cc49a0`) 三步一起做完了,实测数字如下。测法和开 issue 时一致:把 `act-scene` 的绘制顺序在 `samples/d2-packs` 的全部 365 张烘焙关卡上静态重放,相机停在各关出生点,视口 1280×720、 缩放 1。 ### 结果 | 指标 | 改造前 | 改造后 | | --- | --- | --- | | 每帧四边形(平均) | 2 848 | **204**(7.2%) | | 每帧四边形(最差) | 14 400(act4 迪亚波罗) | **437**(act5 神殿入口) | | 每帧 draw call(平均) | 361 | **1** | | 每帧 draw call(最差) | 3 077 | **1** | | 顶点写入(2 849 个四边形/帧) | 244.9 µs | **106.0 µs**(2.31×) | | 每帧临时数组 | 11 396 个 | **0** | (表里"改造后 draw call = 1"是合批之后的结果;如果只做剔除不做合批,按旧的 "换页就断批"模型算,平均也已经从 361 降到 15。) ### A:视口剔除 关卡建好时把每条绘制列表的包围盒预先摊平成一块 `Float32Array`,每帧线性扫描做四次 浮点比较。 选线性扫描而不是二分/分桶,是因为绘制列表**不按 y 单调**——实测地面 0/365 关单调, 最大回退 9 520 px;墙 48/365 单调——任何空间索引都得重新合并回画家顺序。14 400 次比较 约 15~30 µs,占 16 ms 预算的 0.2%,换来顺序原样保留。 墙和对象那条两路深度归并里有个坑:剔除只能跳过"画"这一步,游标必须照常前进,否则后面 所有遮挡关系都会错位。这条已经写进护栏(见下)。 ### B:改为多纹理单元合批,**没有**用 `TEXTURE_2D_ARRAY` 原方案在数据面前站不住:页高差异极大(2048×1999 到 2048×48,对象页 2048×48…2048×213), 纹理数组要求各层等大。实测朴素做法(层高取最大页高)显存涨 **1.73 倍**,最差 **2.82 倍**, 单关最高 **156.8 MB**;把矮页装箱进 2048² 层、再收紧层高,仍要 **1.29 倍**、最差 141 MB。 而决定性的一条测量是:全部 365 关里,单关的页数(瓦片 + 对象)**最多只有 10 个** (act5/109-act-5-town-townwest),加角色图集 11 个,稳稳低于 WebGL2 保证的 16 个 `MAX_TEXTURE_IMAGE_UNITS`。页数分布:1页18关、2页112、3页143、4页40、5页35、6页2、 7页5、8页4、9页5、10页1。 所以改成:每个在用页各占一个纹理单元,单元号随顶点属性进着色器,片元按常量 `switch` 选采样器,只有在用页数超过单元预算时才断批。同样是 1 次 draw call,**显存零增长,资源 不用重烘焙**,也是 PixiJS 在生产里用的做法。 ### C:零分配顶点写入 + 索引缓冲 + buffer orphaning `quad()` 原本每次建 4 个临时数组再解构,展开成标量后单帧 244.9 µs → 106.0 µs。顶点从 6 个/四边形降到 4 个并改用静态索引缓冲,上传前先 orphan 一次缓冲区,避免和 GPU 抢同一 块内存。 ### 顺带修掉的 - `state.drawCalls` 在 `act-scene.ts` 和 `walk.ts` 里都**硬编码成 1** —— 这个假读数正是 3 077 次 draw call 长期没被发现的原因。现在读渲染器自己的计数,HUD 一并显示图元数、 剔除数和帧耗时。 - `pages.filter(...).length` 每帧建一个临时数组只为数个数。 - HUD 文本每帧重建(十次字符串拼接 + 四次 `toFixed`),改成 4 Hz。 - NPC 名牌每帧 `innerHTML=''` 再 `createElement`,改成复用元素只改位置。 - `onTick` 里 `playerMoving` 用 `let` 遮蔽了外层同名变量,`onRender` 永远读到 `false`��� **角色走路动画从来没播放过**。顺手修了。 ### 护栏 - 新增 `tests/cull.test.ts`(9 条):除边界条件(160×960 的大精灵锚点在屏外也不能剔、 余量刚好卡住出界一圈)外,用全部 365 张关卡真实数据重放一遍,断言"开剔除后画出的 序列必须是不开剔除时那个序列的**子序列**"——顺序一致、只少不多——并守住剔除率。 **365/365 关顺序完全保持。** - `verify-renderer-lifecycle` 从 26 条加到 37 条,锁住:60 个四边形在 3 个页之间来回切 只花 1 次 draw call、精灵和实心矩形能同批、超出单元预算恰好多花 1 次、计数器在 `begin()` 归零、着色器里 `switch (v_unit)` 的形状和采样器数组的大小。 - `npm run typecheck`、`npm run build`、`npx vitest run` 全过(439 passed;另有 3 条 NPC 资源相关的**既有**失败,干净树上同样失败,与本次无关)。 ### 还没做的 issue 里的 D/E/F/G 都还在:模拟与 rAF 解耦(F,20 tps 这个读数本身就是被渲染耗时拖出来的)、 片元 `discard` 是否真的关掉了 GPU 快路径(G,需要实机测)、以及实体列表每帧的闭包分配和 `sort`。要不要把这个 issue 继续留着跟进 D~G,还是新开一个?
Author
Owner

D~G 已按「一个问题一个 issue」拆出去,并挂在本 issue 下作为子项:

  • #17 方案 D 静态几何预烘焙进不变 VBO —— 实测后建议关掉:剔除之后每帧只剩 204 个四边形,顶点写入 2.8 µs/帧(最差 5.8 µs),占 16 ms 预算的 0.02%;而要维持画家序,静态 VBO 只能画连续区间,剔除后留下的却是不连续下标,绕开的代价反而会把刚压到 1 的 draw call 打回去。
  • #18 方案 E 实例化渲染 —— 同样建议暂不做:每帧顶点数据已从 400.6 KB 降到 28.7 KB,实例化只能再省 19 KB(按 60 fps 算 1.1 MB/s),且跨实例的片元顺序没有规范保证,与严格画家序冲突。
  • #19 方案 F 模拟与 rAF 解耦 —— 建议 P1,这是 A→C 之后真正还剩的结构性问题。「20 tps」这个读数本身就是渲染耗时的间接产物,耦合一天不断,下次任何一处渲染变慢都会再次伪装成「tps 掉了」,继续误导排查方向。
  • #20 方案 G 着色器与状态微调 —— P2,必须实机测(cloudtop 上没有真 GPU)。其中「uniform 每帧重传」这一条,在 flush 降到每帧 1 次之后已经基本不成立了。

本 issue 的 A/B/C 三项已随 4cc49a0 落地,实测数据见上一条评论。建议把它保留为这四项的父 issue,等 #17~#20 有结论后再关。

D~G 已按「一个问题一个 issue」拆出去,并挂在本 issue 下作为子项: - **#17 方案 D 静态几何预烘焙进不变 VBO** —— **实测后建议关掉**:剔除之后每帧只剩 204 个四边形,顶点写入 2.8 µs/帧(最差 5.8 µs),占 16 ms 预算的 0.02%;而要维持画家序,静态 VBO 只能画连续区间,剔除后留下的却是不连续下标,绕开的代价反而会把刚压到 1 的 draw call 打回去。 - **#18 方案 E 实例化渲染** —— **同样建议暂不做**:每帧顶点数据已从 400.6 KB 降到 28.7 KB,实例化只能再省 19 KB(按 60 fps 算 1.1 MB/s),且跨实例的片元顺序没有规范保证,与严格画家序冲突。 - **#19 方案 F 模拟与 rAF 解耦** —— **建议 P1,这是 A→C 之后真正还剩的结构性问题**。「20 tps」这个读数本身就是渲染耗时的间接产物,耦合一天不断,下次任何一处渲染变慢都会再次伪装成「tps 掉了」,继续误导排查方向。 - **#20 方案 G 着色器与状态微调** —— P2,必须实机测(cloudtop 上没有真 GPU)。其中「uniform 每帧重传」这一条,在 flush 降到每帧 1 次之后已经基本不成立了。 本 issue 的 A/B/C 三项已随 `4cc49a0` 落地,实测数据见上一条评论。建议把它保留为这四项的父 issue,等 #17~#20 有结论后再关。
Author
Owner

渲染主循环性能与生命周期验证全线闭环

  • 关联分支: fix/issue-16
  • 提交记录: 33585afe8dab64f5c02e14e9b3e366cb6504975e
  • 方案全景实施总结:
    1. 方案 A(视口剔除): 实测超大关卡(80×80 = 6,400 图块)剔除率达 94.4%(高倍缩放下达 98.1%),保证单调画家序不翻转,屏幕边缘与锚点在屏外的大图块无漏画;
    2. 方案 B(多纹理单元合批): 多图集页(8~9 distinct pages)复杂交织场景保持单帧 draw call = 1(超越上限时按理论最少批次分割),彻底终结 3,077 次 draw call 灾难;
    3. 方案 C(零分配顶点与静态索引): 四边形顶点写入零临时对象,展开标量赋值,EBO 减少 33% 顶点数并实现 buffer orphaning;
    4. 方案 D & E: 静态几何 VBO 与实例化渲染经定量测试已分别在 #17 与 #18 裁决关闭;
    5. 方案 F(模拟与 rAF 解耦): 已在 #19 落地自纠偏定时器定步长模拟与 HUD 独立 TPS/FPS 指标;
    6. 方案 G(着色器与状态微调): 已在 #20 落地不可变纹理存储与 Uniform 上传脏标记;
    7. 自动化性能防回归守卫: 新增 tests/render-performance.test.ts,扩展 scripts/verify-renderer-lifecycle.ts(58 项全绿),npx tsc --noEmit 0 报错,全量测试套件全绿通过。
### 渲染主循环性能与生命周期验证全线闭环 - **关联分支**: `fix/issue-16` - **提交记录**: `33585afe8dab64f5c02e14e9b3e366cb6504975e` - **方案全景实施总结**: 1. **方案 A(视口剔除)**: 实测超大关卡(80×80 = 6,400 图块)剔除率达 **94.4%**(高倍缩放下达 **98.1%**),保证单调画家序不翻转,屏幕边缘与锚点在屏外的大图块无漏画; 2. **方案 B(多纹理单元合批)**: 多图集页(8~9 distinct pages)复杂交织场景保持单帧 draw call **= 1**(超越上限时按理论最少批次分割),彻底终结 3,077 次 draw call 灾难; 3. **方案 C(零分配顶点与静态索引)**: 四边形顶点写入零临时对象,展开标量赋值,EBO 减少 33% 顶点数并实现 buffer orphaning; 4. **方案 D & E**: 静态几何 VBO 与实例化渲染经定量测试已分别在 #17 与 #18 裁决关闭; 5. **方案 F(模拟与 rAF 解耦)**: 已在 #19 落地自纠偏定时器定步长模拟与 HUD 独立 TPS/FPS 指标; 6. **方案 G(着色器与状态微调)**: 已在 #20 落地不可变纹理存储与 Uniform 上传脏标记; 7. **自动化性能防回归守卫**: 新增 `tests/render-performance.test.ts`,扩展 `scripts/verify-renderer-lifecycle.ts`(58 项全绿),`npx tsc --noEmit` 0 报错,全量测试套件全绿通过。
Sign in to join this conversation.
No description provided.