[P1][CR-F6/F7] 渲染热路径每帧分配与 DOM 布局抖动 #531

Open
opened 2026-09-29 06:43:59 +00:00 by troytt · 0 comments
Owner

来源:#516|优先级 P1|评审编号 F6、F7|基线 e475c2e

问题描述

渲染热路径每帧都会分配大量临时对象和闭包,HUD 和头顶标签每帧读写 DOM 布局属性。实体、标签数量一多,GC 停顿和强制同步布局(layout thrashing)就会造成掉帧。

说明:本次评审没有做运行时 profiling,以下结论全部来自代码层面的分配和 DOM 读写分析。修复前请先测量(见"修复指南"第 1 步),按实测数据排优先级。

证据

F6:每帧分配

  • act-scene 的 onTick / onRender(act-scene.ts:571-646):
    • onRender 每帧构造一个约 40 个字段的上下文对象;
    • onTick 每个 tick 构造一个约 15 个字段的对象,还会调用一次 document.querySelector('#dialog')(L597)。
  • renderWorldLayers(world-renderer.ts:533)每帧都会:
    • 新建多个闭包;
    • 为每个 tile 新建 { atlas },为每个阴影新建 { atlas, tint };
    • 为每个实体新建 { x, y, depth, draw } 和一个 draw 闭包,再对整个数组排序;
    • 对全部 NPC 和怪物做一次 .filter().map(),生成 automap 数据。
  • getActiveButtons(frontend-renderer.ts:561,约 436 行):每次渲染、每个鼠标事件都会重建整张按钮表和其中的闭包。

F7:布局抖动

  • HUD 渲染:HudManager.render 每帧先读 clientWidth / clientHeight(hud-manager.ts:1277-1278),再写 style(L1297)。
  • HUD 鼠标移动:每个 mousemove 事件都要先经 clientToLogical 读 getBoundingClientRect()(hud-manager.ts:657-659),再写 style.pointerEvents(hud-manager.ts:860)。
  • 头顶标签:updateOverheadLabels 每次调用都读一次 getBoundingClientRect()(overhead-labels.ts:240),然后给每个标签写约 10 个 style 属性(overhead-labels.ts:304-313)。其中 color、background、border、padding、borderRadius、fontWeight、boxShadow 这 7 个是常量,每帧都在重复写。

根因

  • 渲染代码按"每帧从头构建"的方式编写,没有区分哪些数据每帧都变、哪些只在状态变化时才变。
  • 读取 DOM 尺寸没有缓存,读写交错进行。

修复指南

  1. 先测量:
    • 用 Chrome Performance 录制两个固定场景各 10 秒:城镇(NPC 多)和 Chaos Sanctuary(怪物、投射物多);
    • 记录帧时间 P50 / P95、每秒 GC 次数、"Recalculate Style / Layout" 的耗时;
    • 把数据贴在本 issue 里,按实测排序后再动手。
  2. 持久化上下文对象:
    • onTick / onRender 的上下文对象在 SceneController(见 #526)里只创建一次,每帧只更新会变的字段;
    • #dialog 元素在初始化时查找一次,缓存起来。
  3. 复用绘制参数:
    • tile / 阴影的 { atlas }、{ atlas, tint } 按 atlas 缓存,或者改为向 renderer.draw 传入原始参数;
    • 实体排序改用预分配的数组,每帧只重置长度;draw 闭包改为"实体类型 + 索引",由一个固定的分发函数来画;
    • automap 数据只在实体列表变化时重算。
  4. 按钮表做 memoize:getActiveButtons 以"当前界面状态"为 key 缓存,状态变化时才重建。
  5. DOM 读写分离:
    • 画布和 HUD 画布的 rect 用 ResizeObserver 缓存(再加上 scroll / resize 事件更新位置),热路径里不再调用 getBoundingClientRect;
    • 标签的常量样式放进 CSS class,每帧只写位置,并用 transform: translate(...) 代替 left / top;
    • 写之前先和上一帧的值比较,没有变化就不写(地面标签已经这样做了,可以参考)。

验收标准

  • 在本 issue 中附上修复前后的 profiling 数据(同一场景、同一台机器):帧时间 P95 和每秒 GC 次数下降,热路径中不再出现"Forced reflow"警告。
  • renderWorldLayers 在实体数量不变时,每帧不再为每个 tile / 实体分配新对象。
  • updateOverheadLabels 和 HUD 的 mousemove 中不再调用 getBoundingClientRect。
  • 涉及渲染,按惯例跑一遍全部 136 个关卡的无头审计,确认画面无回归。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #526:SceneController。
  • #527:renderWorldLayers 的拆分。
  • #532:首屏和包体性能。
  • #505:首屏性能。
> 来源:#516|优先级 P1|评审编号 F6、F7|基线 `e475c2e` ## 问题描述 渲染热路径每帧都会分配大量临时对象和闭包,HUD 和头顶标签每帧读写 DOM 布局属性。实体、标签数量一多,GC 停顿和强制同步布局(layout thrashing)就会造成掉帧。 **说明**:本次评审**没有做运行时 profiling**,以下结论全部来自代码层面的分配和 DOM 读写分析。修复前请先测量(见"修复指南"第 1 步),按实测数据排优先级。 ## 证据 ### F6:每帧分配 - **act-scene 的 `onTick` / `onRender`**([act-scene.ts:571-646](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/act-scene.ts#L571-L646)): - `onRender` 每帧构造一个约 40 个字段的上下文对象; - `onTick` 每个 tick 构造一个约 15 个字段的对象,还会调用一次 `document.querySelector('#dialog')`(L597)。 - **`renderWorldLayers`**([world-renderer.ts:533](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/world-renderer.ts#L533))每帧都会: - 新建多个闭包; - 为每个 tile 新建 `{ atlas }`,为每个阴影新建 `{ atlas, tint }`; - 为每个实体新建 `{ x, y, depth, draw }` 和一个 `draw` 闭包,再对整个数组排序; - 对全部 NPC 和怪物做一次 `.filter().map()`,生成 automap 数据。 - **`getActiveButtons`**([frontend-renderer.ts:561](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/frontend/frontend-renderer.ts#L561),约 436 行):每次渲染、每个鼠标事件都会重建整张按钮表和其中的闭包。 ### F7:布局抖动 - **HUD 渲染**:`HudManager.render` 每帧先读 `clientWidth` / `clientHeight`([hud-manager.ts:1277-1278](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/ui/hud-manager.ts#L1277-L1278)),再写 style(L1297)。 - **HUD 鼠标移动**:每个 mousemove 事件都要先经 `clientToLogical` 读 `getBoundingClientRect()`([hud-manager.ts:657-659](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/ui/hud-manager.ts#L657-L659)),再写 `style.pointerEvents`([hud-manager.ts:860](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/ui/hud-manager.ts#L860))。 - **头顶标签**:`updateOverheadLabels` 每次调用都读一次 `getBoundingClientRect()`([overhead-labels.ts:240](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/overhead-labels.ts#L240)),然后给每个标签写约 10 个 style 属性([overhead-labels.ts:304-313](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/overhead-labels.ts#L304-L313))。其中 color、background、border、padding、borderRadius、fontWeight、boxShadow 这 7 个是常量,每帧都在重复写。 ## 根因 - 渲染代码按"每帧从头构建"的方式编写,没有区分哪些数据每帧都变、哪些只在状态变化时才变。 - 读取 DOM 尺寸没有缓存,读写交错进行。 ## 修复指南 1. **先测量**: - 用 Chrome Performance 录制两个固定场景各 10 秒:城镇(NPC 多)和 Chaos Sanctuary(怪物、投射物多); - 记录帧时间 P50 / P95、每秒 GC 次数、"Recalculate Style / Layout" 的耗时; - 把数据贴在本 issue 里,按实测排序后再动手。 2. **持久化上下文对象**: - `onTick` / `onRender` 的上下文对象在 `SceneController`(见 #526)里只创建一次,每帧只更新会变的字段; - `#dialog` 元素在初始化时查找一次,缓存起来。 3. **复用绘制参数**: - tile / 阴影的 `{ atlas }`、`{ atlas, tint }` 按 atlas 缓存,或者改为向 `renderer.draw` 传入原始参数; - 实体排序改用预分配的数组,每帧只重置长度;`draw` 闭包改为"实体类型 + 索引",由一个固定的分发函数来画; - automap 数据只在实体列表变化时重算。 4. **按钮表做 memoize**:`getActiveButtons` 以"当前界面状态"为 key 缓存,状态变化时才重建。 5. **DOM 读写分离**: - 画布和 HUD 画布的 rect 用 `ResizeObserver` 缓存(再加上 scroll / resize 事件更新位置),热路径里不再调用 `getBoundingClientRect`; - 标签的常量样式放进 CSS class,每帧只写位置,并用 `transform: translate(...)` 代替 `left` / `top`; - 写之前先和上一帧的值比较,没有变化就不写(地面标签已经这样做了,可以参考)。 ## 验收标准 - [ ] 在本 issue 中附上修复前后的 profiling 数据(同一场景、同一台机器):帧时间 P95 和每秒 GC 次数下降,热路径中不再出现"Forced reflow"警告。 - [ ] `renderWorldLayers` 在实体数量不变时,每帧不再为每个 tile / 实体分配新对象。 - [ ] `updateOverheadLabels` 和 HUD 的 mousemove 中不再调用 `getBoundingClientRect`。 - [ ] 涉及渲染,按惯例跑一遍全部 136 个关卡的无头审计,确认画面无回归。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #526:`SceneController`。 - #527:`renderWorldLayers` 的拆分。 - #532:首屏和包体性能。 - #505:首屏性能。
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.

Dependencies

No dependencies set.

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