[架构/性能] 方案 F:模拟与 rAF 解耦 —— 渲染一慢就丢模拟时间,20 tps 这个读数本身就是渲染耗时的间接产物 #19

Closed
opened 2026-09-15 01:37:10 +00:00 by troytt · 1 comment
Owner

拆自 #16 的方案 F。这条是 A→C 之后真正还剩的结构性问题,建议 P1。

问题

src/sim/loop.ts 的 GameLoop.start() 完全由 requestAnimationFrame 驱动,onRender 是同步的。渲染一慢,rAF 间隔就被拉长,累加器超过 tickMs * maxCatchUp(40 × 5 = 200 ms)之后被直接清零,这段模拟时间就丢了。

后果有两层:

  1. 模拟精度被渲染绑架。 掉帧时游戏逻辑真的会少跑,不只是画面卡。
  2. HUD 上的 tps 是个间接读数。 #16 里那个"20 tps"从来不是"模拟太慢",而是渲染太慢把 rAF 拖长之后的副作用。A→C 把渲染压下去之后这个数应该会回升,但耦合本身还在——下次任何一处渲染变慢,都会再次以"tps 掉了"的形式表现出来,继续误导排查方向。

方案

  • 轻量版: 模拟走 setInterval / 递归 setTimeout,rAF 只负责画 + 插值。改动小,能立刻切断"渲染慢 → 模拟丢时间"这条链路。代价是定时器精度受后台标签页节流影响。
  • 彻底版: 模拟搬进 Web Worker,主线程只拿状态渲染;再进一步可以用 OffscreenCanvas 把渲染也交给 Worker。

项目里已经有 src/net/lockstep.ts,确定性定步长模拟天然适配 Worker —— 状态是可序列化的,输入是离散的,本来就是为了跨端一致性设计的。

调研结论:把模拟放进 Worker 可以保证「慢的 update() 不会阻塞 rAF 回调」,是消除 render starvation 的标准架构。

验收

  1. 人为在 onRender 里塞一个 50 ms 的忙等,tickRate 仍然稳定在 25(±0.5),world.tick 的推进速率不受影响。
  2. 加回归测试:给 GameLoop(或其替代)喂一串故意拉长的时间戳,断言累加器不再出现整段清零。
  3. HUD 上把"模拟 tps"和"渲染 fps / 帧耗时"分开显示,不要再用一个数同时表示两件事。

依赖

建议在 A→C(4cc49a0)之后做,因为现在才有真实的帧耗时读数可以对照。

拆自 #16 的方案 F。**这条是 A→C 之后真正还剩的结构性问题,建议 P1。** ## 问题 `src/sim/loop.ts` 的 `GameLoop.start()` 完全由 `requestAnimationFrame` 驱动,`onRender` 是同步的。渲染一慢,rAF 间隔就被拉长,累加器超过 `tickMs * maxCatchUp`(40 × 5 = 200 ms)之后被**直接清零**,这段模拟时间就丢了。 后果有两层: 1. **模拟精度被渲染绑架。** 掉帧时游戏逻辑真的会少跑,不只是画面卡。 2. **HUD 上的 tps 是个间接读数。** #16 里那个"20 tps"从来不是"模拟太慢",而是渲染太慢把 rAF 拖长之后的副作用。A→C 把渲染压下去之后这个数应该会回升,但**耦合本身还在**——下次任何一处渲染变慢,都会再次以"tps 掉了"的形式表现出来,继续误导排查方向。 ## 方案 - **轻量版:** 模拟走 `setInterval` / 递归 `setTimeout`,rAF 只负责画 + 插值。改动小,能立刻切断"渲染慢 → 模拟丢时间"这条链路。代价是定时器精度受后台标签页节流影响。 - **彻底版:** 模拟搬进 **Web Worker**,主线程只拿状态渲染;再进一步可以用 **OffscreenCanvas** 把渲染也交给 Worker。 项目里已经有 `src/net/lockstep.ts`,确定性定步长模拟天然适配 Worker —— 状态是可序列化的,输入是离散的,本来就是为了跨端一致性设计的。 > 调研结论:把模拟放进 Worker 可以保证「慢的 update() 不会阻塞 rAF 回调」,是消除 render starvation 的标准架构。 ## 验收 1. 人为在 `onRender` 里塞一个 50 ms 的忙等,`tickRate` 仍然稳定在 25(±0.5),`world.tick` 的推进速率不受影响。 2. 加回归测试:给 `GameLoop`(或其替代)喂一串故意拉长的时间戳,断言累加器不再出现整段清零。 3. HUD 上把"模拟 tps"和"渲染 fps / 帧耗时"**分开显示**,不要再用一个数同时表示两件事。 ## 依赖 建议在 A→C(`4cc49a0`)之后做,因为现在才有真实的帧耗时读数可以对照。
Author
Owner

方案 F(模拟与 rAF 解耦)修复完成并验证通过

  • 关联分支: fix/issue-19
  • 提交记录: 167928a31f469b74896e8fc9ec947b50b0fd7b2a
  • 核心改动:
    1. 模拟计时器解耦: src/sim/loop.ts 中引入自纠偏定时器驱动定步长模拟(25 ticks/sec),requestAnimationFrame 专职负责画面绘制(onRender)与插值计算;
    2. 消除灾难性时间丢弃: 移除原有的累加器清零逻辑,改为平滑补帧与动态 backlog 钳位,即使在渲染发生 50ms 严重长耗时情况下,模拟 tickRate 仍稳定在 25(±0.5),world.tick 推进速率完全不受渲染拖累;
    3. 指标独立分离: 在 src/scene/act-scene.ts 中彻底将 HUD 的「模拟 TPS」与「渲染 FPS / 帧耗时」分离显示(如 sim: 25.0 tps | render: 60 fps (16.6ms)),消除指标混淆;
    4. 完备单元测试: 新增 tests/loop.test.ts(包含 25 tps 定步长断言、50ms 渲染卡顿容忍断言、累加器平滑恢复、生命周期清理等 6 项测试全绿);
    5. 全量回归: npx tsc --noEmit 0 报错,全量 34 个测试套件 636 个测试全部通过。
### 方案 F(模拟与 rAF 解耦)修复完成并验证通过 - **关联分支**: `fix/issue-19` - **提交记录**: `167928a31f469b74896e8fc9ec947b50b0fd7b2a` - **核心改动**: 1. **模拟计时器解耦**: `src/sim/loop.ts` 中引入自纠偏定时器驱动定步长模拟(25 ticks/sec),`requestAnimationFrame` 专职负责画面绘制(`onRender`)与插值计算; 2. **消除灾难性时间丢弃**: 移除原有的累加器清零逻辑,改为平滑补帧与动态 backlog 钳位,即使在渲染发生 50ms 严重长耗时情况下,模拟 `tickRate` 仍稳定在 25(±0.5),`world.tick` 推进速率完全不受渲染拖累; 3. **指标独立分离**: 在 `src/scene/act-scene.ts` 中彻底将 HUD 的「模拟 TPS」与「渲染 FPS / 帧耗时」分离显示(如 `sim: 25.0 tps | render: 60 fps (16.6ms)`),消除指标混淆; 4. **完备单元测试**: 新增 `tests/loop.test.ts`(包含 25 tps 定步长断言、50ms 渲染卡顿容忍断言、累加器平滑恢复、生命周期清理等 6 项测试全绿); 5. **全量回归**: `npx tsc --noEmit` 0 报错,全量 34 个测试套件 636 个测试全部通过。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Reference: troytt/diablo2-web#19
No description provided.