[P0][CR-F2/F3] 关卡切换、换英雄、重刷怪的异步竞态与 GPU 纹理泄漏 #529

Closed
opened 2026-09-29 06:43:59 +00:00 by troytt · 1 comment
Owner

来源:#516|优先级 P0|评审编号 F2、F3|基线 e475c2e

问题描述

关卡切换、换英雄、调整怪物密度或精英倍率,都会发起异步的图集加载。这些加载都不能取消,也不检查结果是否已经过期,所以:

  • GPU 纹理泄漏:迟到的结果会 addAtlas 到已经废弃的 runtime 上,这些纹理再也不会被释放。
  • 状态错乱:快速连续操作时,后发的请求可能先完成,最终留下的是错误的英雄或错误关卡的怪物美术。
  • 未捕获的 rejection:部分 async 事件处理函数没有 catch。

证据

F2:关卡切换泄漏 GPU 纹理

  1. 首帧之后,preloadRemaining 用 void runtime.loadPages(...) 在后台加载剩余的页面,这个加载无法取消:pack-loader.ts:757-765。
  2. 切换关卡时,swapLevel 删除旧 runtime 的全部 pages 和 objectPages:pack-loader.ts:899-902。
  3. 但在途的加载完成后,仍然会把新纹理 addAtlas 到旧 runtime 的数组里:pack-loader.ts:683、pack-loader.ts:704。这个 runtime 已经没人引用,这些纹理也永远不会被 deleteAtlas。
  4. 进度回调还会把旧 runtime 的计数写进全局的 state.pagesLoaded(L761)。

F3:换英雄与重刷怪的竞态

  • 换英雄:switchHero 在 await loadCharacterArt(...) 之后,才删除"当前"英雄的图集并换上新的:toolbar-chat-ui.ts:383-395。快速切换两次(A→B→C)时,如果 B 比 C 晚完成:
    • 最后显示的是 B;
    • C 的图集被当作"上一个"删掉;
    • 当前英雄的标记也被改回 B。
  • 重刷怪:reloadMonsters(toolbar-chat-ui.ts:431-545)有两个问题:
    • 没有 travelling 守卫(这个标志是 pack-loader 的局部变量,pack-loader.ts:823)。加载途中切换了关卡,完成后仍会用旧关卡的怪物美术覆盖新关卡;
    • setMonsterArtMap(loaded.map)(L541)之前没有删除旧 map 的图集,所以每调整一次密度或精英倍率,就泄漏一批纹理。
  • 未捕获的 async 处理函数:heroSelect.onchange = async () => { await switchHero(...) } 没有 catch:act-scene.ts:259。

仓库里已有的正确写法

  • syncSorceressAppearance 使用 appearanceSeq 令牌,await 之后检查 seq !== appearanceSeq 就丢弃结果:toolbar-chat-ui.ts:306-310。
  • pack-loader 用 travelling 防止重复切换。

这两种模式都是对的,只是没有推广到其他加载路径。

根因

异步加载没有统一的"所有权"概念:谁发起的请求、结果属于哪一代 runtime、过期的结果由谁释放,都没有约定。每个调用点各自处理,大多数调用点根本没有处理。

修复指南

  1. 引入一个小工具,统一处理代际和取消:
    export class LoadGeneration {
      private gen = 0
      private controller = new AbortController()
    
      /** 开始新一代加载:作废上一代,并返回本代的令牌 */
      next(): { gen: number; signal: AbortSignal } {
        this.controller.abort()
        this.controller = new AbortController()
        return { gen: ++this.gen, signal: this.controller.signal }
      }
    
      isCurrent(gen: number): boolean {
        return gen === this.gen
      }
    }
    
  2. 所有加载都带上令牌:
    • fetch 传入 signal;
    • await 之后先检查 isCurrent(gen);
    • 如果已经过期,立即 renderer.deleteAtlas(handle) 并返回,不写任何状态。
  3. runtime 持有自己的令牌:
    • swapLevel 删除旧 pages 之前先调用 next(),让在途的 loadPages 失效;
    • loadPages 在 addAtlas 之前和之后都检查一次;
    • 进度回调只在令牌仍然有效时写 state。
  4. 换英雄:
    • 使用一个英雄专用的 LoadGeneration;
    • 只有当前一代的结果才能替换角色,替换时删除的是"被替换掉的那个"图集;
    • 过期的结果直接 deleteAtlas。
  5. 重刷怪:
    • 同样使用令牌,并在关卡切换时作废它;
    • 换上新 map 之前,先 deleteAtlas 旧 map 的全部 handle。
  6. async 事件处理函数:统一用一个包装函数,捕获错误后写入状态栏并 console.error,不产生未处理的 rejection。
  7. 可观测性:
    • 给 SpriteRenderer 加一个只读的 liveTextureCount(它已经有纹理注册表);
    • 在 import.meta.env.DEV 下显示到调试面板里,泄漏就能被直接看到。

验收标准

  • 新增测试(用 mock renderer 统计 addAtlas / deleteAtlas),每种情况结束后存活的纹理数都回到基线:
    • 关卡切换时,让 loadPages 的 fetch 延迟到切换之后才完成;
    • A→B→C 快速换英雄,其中 B 最慢,最终显示的是 C;
    • 连续调用 10 次 reloadMonsters,其中一次在加载途中切换关卡。
  • 所有 void somePromise 调用点都有 .catch,或者改为通过统一的包装函数调用。
  • 涉及渲染,按惯例跑一遍全部 136 个关卡的无头审计,确认每次切换后 liveTextureCount 不再单调增长。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #530:同一批加载函数的错误处理。
  • #533:场景级的资源与监听器生命周期。
  • #523:死亡掉落的时机也不应该依赖加载进度。
> 来源:#516|优先级 P0|评审编号 F2、F3|基线 `e475c2e` ## 问题描述 关卡切换、换英雄、调整怪物密度或精英倍率,都会发起异步的图集加载。这些加载都**不能取消**,也**不检查结果是否已经过期**,所以: - **GPU 纹理泄漏**:迟到的结果会 `addAtlas` 到已经废弃的 runtime 上,这些纹理再也不会被释放。 - **状态错乱**:快速连续操作时,后发的请求可能先完成,最终留下的是错误的英雄或错误关卡的怪物美术。 - **未捕获的 rejection**:部分 async 事件处理函数没有 catch。 ## 证据 ### F2:关卡切换泄漏 GPU 纹理 1. 首帧之后,`preloadRemaining` 用 `void runtime.loadPages(...)` 在后台加载剩余的页面,这个加载无法取消:[pack-loader.ts:757-765](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/pack-loader.ts#L757-L765)。 2. 切换关卡时,`swapLevel` 删除旧 runtime 的全部 pages 和 objectPages:[pack-loader.ts:899-902](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/pack-loader.ts#L899-L902)。 3. 但在途的加载完成后,仍然会把新纹理 `addAtlas` 到旧 runtime 的数组里:[pack-loader.ts:683](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/pack-loader.ts#L683)、[pack-loader.ts:704](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/pack-loader.ts#L704)。这个 runtime 已经没人引用,这些纹理也永远不会被 `deleteAtlas`。 4. 进度回调还会把旧 runtime 的计数写进全局的 `state.pagesLoaded`(L761)。 ### F3:换英雄与重刷怪的竞态 - **换英雄**:`switchHero` 在 `await loadCharacterArt(...)` 之后,才删除"当前"英雄的图集并换上新的:[toolbar-chat-ui.ts:383-395](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/toolbar-chat-ui.ts#L383-L395)。快速切换两次(A→B→C)时,如果 B 比 C 晚完成: - 最后显示的是 B; - C 的图集被当作"上一个"删掉; - 当前英雄的标记也被改回 B。 - **重刷怪**:`reloadMonsters`([toolbar-chat-ui.ts:431-545](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/toolbar-chat-ui.ts#L431-L545))有两个问题: - 没有 `travelling` 守卫(这个标志是 pack-loader 的局部变量,[pack-loader.ts:823](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/pack-loader.ts#L823))。加载途中切换了关卡,完成后仍会用旧关卡的怪物美术覆盖新关卡; - `setMonsterArtMap(loaded.map)`(L541)之前没有删除旧 map 的图集,所以每调整一次密度或精英倍率,就泄漏一批纹理。 - **未捕获的 async 处理函数**:`heroSelect.onchange = async () => { await switchHero(...) }` 没有 catch:[act-scene.ts:259](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/act-scene.ts#L259)。 ### 仓库里已有的正确写法 - `syncSorceressAppearance` 使用 `appearanceSeq` 令牌,await 之后检查 `seq !== appearanceSeq` 就丢弃结果:[toolbar-chat-ui.ts:306-310](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/toolbar-chat-ui.ts#L306-L310)。 - pack-loader 用 `travelling` 防止重复切换。 这两种模式都是对的,只是没有推广到其他加载路径。 ## 根因 异步加载没有统一的"所有权"概念:谁发起的请求、结果属于哪一代 runtime、过期的结果由谁释放,都没有约定。每个调用点各自处理,大多数调用点根本没有处理。 ## 修复指南 1. **引入一个小工具**,统一处理代际和取消: ```ts export class LoadGeneration { private gen = 0 private controller = new AbortController() /** 开始新一代加载:作废上一代,并返回本代的令牌 */ next(): { gen: number; signal: AbortSignal } { this.controller.abort() this.controller = new AbortController() return { gen: ++this.gen, signal: this.controller.signal } } isCurrent(gen: number): boolean { return gen === this.gen } } ``` 2. **所有加载都带上令牌**: - `fetch` 传入 `signal`; - await 之后先检查 `isCurrent(gen)`; - 如果已经过期,立即 `renderer.deleteAtlas(handle)` 并返回,不写任何状态。 3. **runtime 持有自己的令牌**: - `swapLevel` 删除旧 pages 之前先调用 `next()`,让在途的 `loadPages` 失效; - `loadPages` 在 `addAtlas` 之前和之后都检查一次; - 进度回调只在令牌仍然有效时写 `state`。 4. **换英雄**: - 使用一个英雄专用的 `LoadGeneration`; - 只有当前一代的结果才能替换角色,替换时删除的是"被替换掉的那个"图集; - 过期的结果直接 `deleteAtlas`。 5. **重刷怪**: - 同样使用令牌,并在关卡切换时作废它; - 换上新 map 之前,先 `deleteAtlas` 旧 map 的全部 handle。 6. **async 事件处理函数**:统一用一个包装函数,捕获错误后写入状态栏并 `console.error`,不产生未处理的 rejection。 7. **可观测性**: - 给 `SpriteRenderer` 加一个只读的 `liveTextureCount`(它已经有纹理注册表); - 在 `import.meta.env.DEV` 下显示到调试面板里,泄漏就能被直接看到。 ## 验收标准 - [ ] 新增测试(用 mock renderer 统计 `addAtlas` / `deleteAtlas`),每种情况结束后存活的纹理数都回到基线: - 关卡切换时,让 `loadPages` 的 fetch 延迟到切换之后才完成; - A→B→C 快速换英雄,其中 B 最慢,最终显示的是 C; - 连续调用 10 次 `reloadMonsters`,其中一次在加载途中切换关卡。 - [ ] 所有 `void somePromise` 调用点都有 `.catch`,或者改为通过统一的包装函数调用。 - [ ] 涉及渲染,按惯例跑一遍全部 136 个关卡的无头审计,确认每次切换后 `liveTextureCount` 不再单调增长。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #530:同一批加载函数的错误处理。 - #533:场景级的资源与监听器生命周期。 - #523:死亡掉落的时机也不应该依赖加载进度。
Author
Owner

已在提交 97abcb3(fix(scene): guard async scene transitions with generation tokens and dispose WebGL textures (#529))中完成修复,并在 5de27f8 中补充端到端与 136 关全量验证套件。

修复与验证摘要

  • 修复内容:为 swapLevel/switchArea、switchHero/changeHeroClass、reloadMonsters/respawnMonsters、loadPages 及延迟回调引入单调递增的 LoadGeneration 令牌与 AbortSignal 守卫,并在过期/淘汰/切幕时立即调用 renderer.deleteAtlas(gl.deleteTexture)释放 WebGL 纹理。
  • 专项回归测试:tests/p0-529-async-lifecycle.test.ts
  • 总体验收门禁:npm run build 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
已在提交 [`97abcb3`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/97abcb3bf7172a86f8de416020f5596c60241e30)(`fix(scene): guard async scene transitions with generation tokens and dispose WebGL textures (#529)`)中完成修复,并在 [`5de27f8`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/5de27f8177d43524444de7aa301cc470c4f0844e) 中补充端到端与 136 关全量验证套件。 ### 修复与验证摘要 - **修复内容**:为 `swapLevel`/`switchArea`、`switchHero`/`changeHeroClass`、`reloadMonsters`/`respawnMonsters`、`loadPages` 及延迟回调引入单调递增的 `LoadGeneration` 令牌与 `AbortSignal` 守卫,并在过期/淘汰/切幕时立即调用 `renderer.deleteAtlas`(`gl.deleteTexture`)释放 WebGL 纹理。 - **专项回归测试**:[`tests/p0-529-async-lifecycle.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-529-async-lifecycle.test.ts) - **总体验收门禁**:`npm run build` 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
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#529
No description provided.