[性能] 渲染主循环掉到 20 tps:零视口剔除(88.6% 白画)+ 图集页切换导致单帧最高 3077 次 draw call #16
Labels
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Depends on
#17 [性能] 方案 D:静态几何预烘焙进不变 VBO —— A→C 落地后实测只剩 2.8 µs/帧可省,请先裁决是否还做
troytt/diablo2-web
#18 [性能] 方案 E:实例化渲染 —— A→C 落地后每帧顶点数据只剩 28.7 KB,收益同样被剔除吃掉
troytt/diablo2-web
#19 [架构/性能] 方案 F:模拟与 rAF 解耦 —— 渲染一慢就丢模拟时间,20 tps 这个读数本身就是渲染耗时的间接产物
troytt/diablo2-web
#20 [性能] 方案 G:着色器与 GL 状态微调(discard / texStorage / uniform 上传)—— 需要实机测量才能定夺
troytt/diablo2-web
Reference: troytt/diablo2-web#16
Loading…
Reference in New Issue
No description provided.
Delete Branch "%!s(<nil>)"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
一、现象
打开
acts.html走图时,HUD 常驻显示 ~20 tps(目标 25 tps),操作明显发粘。关键点:20 tps 不是"模拟算不过来",而是"渲染太慢把模拟饿死了"。
src/sim/loop.ts:71-95的GameLoop完全挂在requestAnimationFrame上: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
act4/108-act-4-diablo-1)act4/107-act-4-lava-1-var3)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 视口剔除的浪费
最差的 10 关可见率只有 1.2% ~ 2.4%:
原因很直白:
act-scene.ts:809-880从头到尾遍历整张地图,一次剔除都没有:全仓库(
src/)搜不到任何 cull / 视口裁剪逻辑。2.3 顶点写入路径的分配开销
renderer.ts:409-436的quad()每个四边形都要新建 4 个临时数组并解构 tint:按平均 2 849 квads/帧算:每帧 11 396 个临时数组,60 fps 下每秒 68 万个短命对象,直接喂给 GC。
实测(Node 22,2 849 quads × 2 000 帧):
2.4 其他确认的问题
renderer.ts:445bufferSubData。一帧 flush 几百上千次 = 反复覆写 GPU 正在读的内存,驱动被迫做隐式同步(pipeline stall)。renderer.ts:63-65renderer.ts:95if (texel.a == 0.0) discard;。discard会让 GPU 关掉 early-Z / 提前深度剔除的快路径;这里DEPTH_TEST本来就是关的,alpha 混合已经能处理全透明像素,discard的收益需要重新实测。act-scene.ts:882state.drawCalls = 1是硬编码的(walk.ts:250同样)。也就是说仪表盘一直在说"1 次 draw call",而真实值是 361~3 077。这个假读数是这个性能问题一直没被发现的直接原因。act-scene.ts:887runtime.pages.filter(page => page !== null).length—— 每帧新建一个数组,只为了数个数。act-scene.ts:889-893textContent:10 段字符串拼接 + 4 次toFixed(),并触发 DOM 写入与布局。act-scene.ts:826-854drawUpToDepth是在onRender内部定义的闭包,每帧重新创建(drawTiles同理)。sim/loop.ts三、联网调研 + 方案
方案 A(P0)视口剔除 + 空间分桶
只提交视口内的绘制项。地图几何是完全静态的,可以在
loadPackRuntime里一次性按格子(比如 16×16 cell 一桶)建好空间索引,每帧只遍历与视口相交的桶。实测收益:每帧四边形 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属性在着色器里选层:调研结论:对尺寸统一的图集页(本项目全部是 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
quad()手动展开,杜绝临时数组(实测 2.31×);tint 改传 4 个标量而不是元组。drawElements+ 预建静态 EBO(0,1,2, 1,3,2循环),顶点数 6 → 4,顶点数据少 33%。bufferData(target, size, DYNAMIC_DRAW)传null做 buffer orphaning,或者做环形缓冲分���写。方案 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一次画完。顶点数据量:48 float/quad → 约 12 float/instance(-75%)。建议放在 A/B/C 之后,用实测决定是否值得。
方案 F(P2)模拟与渲染解耦
把固定步长模拟从 rAF 里摘出来,这样即使渲染掉帧,tps 也能稳在 25:
setInterval/递归setTimeout,rAF 只负责画 + 插值。方案 G(P2)着色器与状态微调
discard(纯靠 alpha blend)在目标机型上是快还是慢。gl.texStorage3D(不可变存储),比texImage系列分配更高效。关于"换 Three.js / PixiJS"
不建议。瓶颈不在"缺少引擎",而在上面这些具体且已定位的实现问题——零剔除、页切换打断批次、每 quad 分配临时数组。Three.js 不会自动帮你做 2D 等距画家序的视口剔除,PixiJS 倒是自带批处理,但要为此重写整套画家序/深度交织/碰撞体系,代价远大于按上面 A→C 改造现有的 556 行渲染器。
四、收益汇总
五、落地顺序
SpriteRenderer加真实的flushCount/quadCount统计,去掉state.drawCalls = 1这个硬编码谎言;HUD 增加帧耗时、可见四边形数、剔除率。没有真读数就没法验证后面任何一步。TEXTURE_2D_ARRAY重写批处理器。quad()+ 索引缓冲 + buffer orphaning。顺手清理(成本极低):
state.pagesLoaded的filter()改成计数器,在页加载完成时自增;drawTiles/drawUpToDepth提到onRender外面,避免每帧重建闭包。六、验收标准
scripts/verify-renderer-lifecycle.ts扩展:断言剔除后提交的四边形数 ≤ 视口内理论值 × 1.1,断言多页关卡的 draw call = 1。scripts/browser/checks/新增性能断言:进入act4/107-act-4-lava-1-var3(当前最差关)后连续 120 帧,tickRate ≥ 24.5且p95 帧耗时 ≤ 16 ms。drawCalls必须是真实测量值,并纳入自动化检查。verify:packs的像素哈希口径)。七、参考资料
texStorage、精度限定符)sampler2DArray对尺寸统一图集是更优解)Fix Your Timestep!定步长 + 插值范式(关联:#9 修的是显存泄漏,这个 issue 修的是帧时间;#5 引入的对象层会在画家序里与墙体交错,正是方案 B 要解决的批次打断来源之一。)
A→C 已落地(commit
4cc49a0)三步一起做完了,实测数字如下。测法和开 issue 时一致:把
act-scene的绘制顺序在samples/d2-packs的全部 365 张烘焙关卡上静态重放,相机停在各关出生点,视口 1280×720、缩放 1。
结果
(表里"改造后 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每帧建一个临时数组只为数个数。toFixed),改成 4 Hz。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,还是新开一个?D~G 已按「一个问题一个 issue」拆出去,并挂在本 issue 下作为子项:
本 issue 的 A/B/C 三项已随
4cc49a0落地,实测数据见上一条评论。建议把它保留为这四项的父 issue,等 #17~#20 有结论后再关。渲染主循环性能与生命周期验证全线闭环
fix/issue-1633585afe8dab64f5c02e14e9b3e366cb6504975etests/render-performance.test.ts,扩展scripts/verify-renderer-lifecycle.ts(58 项全绿),npx tsc --noEmit0 报错,全量测试套件全绿通过。