mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
fix(gui): 星图几何体共享 + 重建时 dispose,修聊天页长时间挂起后卡死
## 实测故障
聊天页长时间挂着之后 GUI 卡死。CDP 抓生产实例(2588 节点):
Mesh/SphereGeometry 2588 个 geometry 数据合计 22.67MB
Sprite/BufferGeometry 1294 个 label 纹理,256×64 RGBA ≈ 64KB/个
进程常驻内存反复冲上 GB 级、被 GC 拉回后又再度暴涨(实测 986MB ↔ 3.8GB
震荡),CPU 稳定 30%(多线程耗在 GC 与对象构建),句柄数稳定 490 ⇒
不是句柄泄漏,而是持续的 GPU 资源分配。
## 两个叠加缺陷
1. **几何体从不共享**。每个节点 `new THREE.SphereGeometry(rad, 16, 12)`、
每个光晕球 `new THREE.SphereGeometry(gr, 16, 12)` —— 半径各异看起来
「只能各自 new」。但 three.js 里大小应由 mesh.scale 表达,几何体应共享。
2. **重建时不 dispose**。buildChatStarmapGraph 只 `starmapScene.remove(m)`,
从不 dispose()。remove 只把对象从场景树摘下来,geometry/material/texture
仍占着 WebGL 资源。全代码搜不到 dispose() 就是铁证。星图每次全量重建
泄漏一整轮,节点数还随记忆增长 ⇒ 泄漏只增不减。
## 修法
- `starmapSphereGeometry()`:建一次半径 0.5 的球,全场景共用。
**半径必须仍是 0.5** —— 节点缩放基准 `baseScale = rad / 0.5` 的分母
就是它,改成 1 会让所有节点缩放翻倍,且与脉冲结束时的
`setScalar(baseScale)` 复原逻辑不自洽。
- `disposeStarmapObject()`:递归 traverse 子树(节点下挂着 glow sphere
与 label sprite,只 dispose 顶层会漏),释放 geometry/material/texture;
用 seen 集合防重复释放,并**排除共享几何体**(释放它会让仍在渲染的
mesh 变黑/报错)。
- 重建时节点与边都走 dispose。
几何体数据从 2588 份降到 1 份(22.67MB → 9KB),label 纹理由
「只增不减」变成「每轮释放」。
## 判据(cmd/gui/starmap-gpu-lifecycle.test.mjs)
13 条。变异测试验证非假红:
- 还原 `new SphereGeometry(rad,…)` ⇒ 「节点循环用共享几何体」+
「代码里只有一处构造」转红
- `disposeStarmapObject(m)` 换回 `starmapScene.remove(m)` ⇒
「重建时释放旧节点资源」转红
排障中判据自身报过 3 次假红,根因都写进注释:
- 注释里也写着 `starmapNodeMeshes.forEach` 与 `disposeStarmapObject`,
裸正则从注释开始匹配 ⇒ 改为先剥注释
- 匹配窗口 200 太大:节点那处换成 remove 后,**边**的
`disposeStarmapObject(l)`(相距 84 字符)仍落在窗口内 ⇒ 收到 40
- 摘要里 `new SphereGeometry` 计数包含注释里的引用,显示 2 次而真实
只有 1 次 ⇒ 计数前先剥注释
## 验证
6 个门禁全绿:starmap-gpu-lifecycle / window-liveness /
device-bridge-liveness / endpoint-align / device-align /
device-gateway-discovery。
This commit is contained in:
213
cmd/gui/starmap-gpu-lifecycle.test.mjs
Normal file
213
cmd/gui/starmap-gpu-lifecycle.test.mjs
Normal file
@ -0,0 +1,213 @@
|
||||
// 星图 GPU 资源生命周期判据(2026-10-05)。
|
||||
//
|
||||
// ## 要判的是什么
|
||||
//
|
||||
// 实测故障:聊天页长时间挂着之后 GUI 卡死。CDP 抓生产实例(2588 节点):
|
||||
//
|
||||
// Mesh/SphereGeometry 2588 个 geometry 数据合计 22.67MB
|
||||
// Sprite/BufferGeometry 1294 个 label 纹理,256×64 RGBA ≈ 64KB/个
|
||||
//
|
||||
// 进程常驻内存反复冲上 GB 级、被 GC 拉回后又再度暴涨,界面随之卡住。
|
||||
//
|
||||
// 两个叠加缺陷:
|
||||
//
|
||||
// 1. **几何体从不共享**。每个节点都 new THREE.SphereGeometry(r, 16, 12)
|
||||
// —— 因为半径各异,看起来「只能各自 new」。但 three.js 里大小应由
|
||||
// mesh.scale 表达,几何体应共享。2588 份独立几何体数据。
|
||||
//
|
||||
// 2. **重建时不 dispose**。buildChatStarmapGraph 只 starmapScene.remove(m),
|
||||
// 从不 dispose()。remove 只改场景树,geometry/material/texture 仍占着
|
||||
// WebGL 资源。星图每次全量重建泄漏一整轮,节点数还随记忆增长 ——
|
||||
// 泄漏只增不减。全代码搜不到 dispose() 就是铁证。
|
||||
//
|
||||
// ## 为什么用文本扫描
|
||||
//
|
||||
// 真跑需要在 CDP 里连生产后端抓 renderer.info.memory.geometries,
|
||||
// 依赖 driver/GL 与网络,CI 不可靠。判据钉「该有的共享与释放是否在位」。
|
||||
//
|
||||
// 运行:node starmap-gpu-lifecycle.test.mjs
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const APP = join(here, "renderer", "app.js");
|
||||
|
||||
let failures = 0;
|
||||
const check = (name, ok, detail) => {
|
||||
if (ok) {
|
||||
console.log(` ✓ ${name}`);
|
||||
} else {
|
||||
failures++;
|
||||
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!existsSync(APP)) {
|
||||
console.error(`找不到 ${APP}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const src = readFileSync(APP, "utf8");
|
||||
|
||||
// 从函数声明扫到列0 的 }(嵌套闭合都有缩进)。
|
||||
function sliceFunction(name) {
|
||||
const start = src.indexOf(`function ${name}(`);
|
||||
if (start < 0) return null;
|
||||
const lines = src.slice(start).split("\n");
|
||||
const out = [lines[0]];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
out.push(lines[i]);
|
||||
if (lines[i] === "}") return out.join("\n");
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
// ── 1) 球体几何体必须共享 ───────────────────────────────────────────
|
||||
//
|
||||
// 判据要盯的是「循环体内不再 new SphereGeometry(...)」——
|
||||
// 只看文件里有没有 SphereGeometry 会被starmapSphereGeometry 里的
|
||||
// 那一次正当构造骗过去。
|
||||
|
||||
const build = sliceFunction("buildChatStarmapGraph");
|
||||
check(
|
||||
"能抽到 buildChatStarmapGraph",
|
||||
!!build,
|
||||
"找不到该函数 —— 正则可能已与 app.js 漂移",
|
||||
);
|
||||
|
||||
// 共享几何体入口:建一次、缓存、复用。
|
||||
const shared = sliceFunction("starmapSphereGeometry");
|
||||
check(
|
||||
"存在共享球体几何体入口",
|
||||
!!shared && /SphereGeometry/.test(shared) && /starmapUnitSphere/.test(shared),
|
||||
"没有共享几何体入口 —— 每节点一份 SphereGeometry,2588 节点即 2588 份",
|
||||
);
|
||||
|
||||
if (build) {
|
||||
check(
|
||||
"节点循环用共享几何体",
|
||||
/starmapSphereGeometry\(\)/.test(build) &&
|
||||
!/new THREE\.SphereGeometry\(\s*rad/.test(build),
|
||||
"buildChatStarmapGraph 里仍在 new SphereGeometry(rad, ...) —— 未共享",
|
||||
);
|
||||
check(
|
||||
"光晕球也用共享几何体",
|
||||
!/new THREE\.SphereGeometry\(\s*gr/.test(build),
|
||||
"光晕球仍各自 new SphereGeometry(gr, ...) —— 1294 份未共享",
|
||||
);
|
||||
|
||||
// 共享球的半径必须仍是 0.5:节点缩放基准 baseScale = rad / 0.5
|
||||
// 的分母就是它,改成 1 会让所有节点缩放翻倍。
|
||||
const r05 = shared && /SphereGeometry\(\s*0\.5\s*,/.test(shared);
|
||||
const baseScale05 = build && /baseScale\s*=\s*rad\s*\/\s*0\.5/.test(build);
|
||||
check(
|
||||
"共享球半径与 baseScale 基准一致(0.5)",
|
||||
r05 && baseScale05,
|
||||
r05
|
||||
? "baseScale 公式不是 rad/0.5 —— 两边不自洽,节点大小会错"
|
||||
: "共享球半径不是 0.5 —— baseScale=rad/0.5 会让所有节点缩放翻倍",
|
||||
);
|
||||
|
||||
// ── 2) 重建必须 dispose ──────────────────────────────────────────
|
||||
//
|
||||
// 只 remove 不 dispose 是泄漏的根。判据要求 remove 旁边有 dispose。
|
||||
//
|
||||
// ★ 先剥注释再匹配:函数上方的说明块里也写着
|
||||
// `starmapNodeMeshes.forEach` 与 `disposeStarmapObject`(用于描述原
|
||||
// 实现的缺陷),裸正则会从注释开始匹配、在 200 字窗口内撞上别处的
|
||||
// disposeStarmapObject 而误判为绿。
|
||||
const buildCode = build
|
||||
.split("\n")
|
||||
.filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l))
|
||||
.join("\n");
|
||||
|
||||
//
|
||||
//
|
||||
// 窗口必须**小于**「节点 forEach 结束」到「边的 disposeStarmapObject(l)」
|
||||
// 之间的距离。实测距离 84 字符(变异后):
|
||||
// starmapNodeMeshes.forEach(function (m) {\n starmapScene.remove(m);\n });\n
|
||||
// starmapEdgeLines.forEach(function (l) {\n disposeStarmapObject(l);
|
||||
// 窗口开到 120/200 时,节点那处换成 remove 之后,**边**的 dispose 仍在
|
||||
// 窗口内 ⇒ 误判为绿(实测踩过两次)。
|
||||
// 取 40:足够跨过 forEach 的函数头,又够不到下一个 forEach。
|
||||
const NODE_WINDOW = 40;
|
||||
check(
|
||||
"重建时释放旧节点资源",
|
||||
new RegExp(
|
||||
`starmapNodeMeshes\\.forEach[\\s\\S]{0,${NODE_WINDOW}}?disposeStarmapObject\\s*\\(`,
|
||||
).test(buildCode),
|
||||
"starmapNodeMeshes.forEach 里只有 remove —— GPU 资源不释放,每次重建泄漏一轮",
|
||||
);
|
||||
check(
|
||||
"重建时释放旧边资源",
|
||||
/starmapEdgeLines\.forEach[\s\S]{0,120}?disposeStarmapObject\s*\(/.test(buildCode),
|
||||
"starmapEdgeLines 同样需要 dispose",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3) dispose 必须递归到子节点 ─────────────────────────────────────
|
||||
//
|
||||
// 节点 mesh 下挂着 glow sphere(子 mesh)与 label sprite,
|
||||
// 只 dispose 顶层会漏掉子节点的 geometry/material/texture。
|
||||
|
||||
const disposeFn = sliceFunction("disposeStarmapObject");
|
||||
check(
|
||||
"存在 disposeStarmapObject",
|
||||
!!disposeFn,
|
||||
"找不到释放函数",
|
||||
);
|
||||
check(
|
||||
"递归遍历子树",
|
||||
disposeFn ? /traverse\(/.test(disposeFn) : false,
|
||||
"没有 traverse —— 子节点(glow sphere / label sprite)资源会漏",
|
||||
);
|
||||
check(
|
||||
"释放纹理",
|
||||
disposeFn ? /isTexture/.test(disposeFn) && /\.dispose\(\)/.test(disposeFn) : false,
|
||||
disposeFn ? "没有释放 texture(label 纹理 1294 个 × 64KB)" : "",
|
||||
);
|
||||
check(
|
||||
"共享几何体不被误 dispose",
|
||||
disposeFn ? /starmapUnitSphere/.test(disposeFn) : false,
|
||||
disposeFn
|
||||
? "没有排除共享几何体 —— 释放后仍在渲染的 mesh 会变黑或报错"
|
||||
: "",
|
||||
);
|
||||
|
||||
// ── 4) label 纹理数量应有上限感知的注释 ─────────────────────────────
|
||||
//
|
||||
// label 是 CanvasTexture,节点越多纹理越多;与节点同阶增长,
|
||||
// 这次不单独限流(共享/释放已解决主要泄漏),但要留下判据位置,
|
||||
// 避免将来有人再加节点类型时无人察觉。
|
||||
|
||||
check(
|
||||
"label 纹理有 dispose 覆盖",
|
||||
/new THREE\.CanvasTexture/.test(src) && !!disposeFn,
|
||||
"存在 CanvasTexture 但无释放路径",
|
||||
);
|
||||
|
||||
// ── 汇总 ────────────────────────────────────────────────────────────
|
||||
|
||||
console.log("");
|
||||
// 计数要排除注释:本文件在 starmapSphereGeometry 上方有一段注释
|
||||
// 引用了 `new THREE.SphereGeometry(gr, 16, 12)` 来描述**原**实现,
|
||||
// 裸grep 会把它算进去,显示 2 次而真实只有 1 次 —— 一个会误导人的摘要
|
||||
// 比没有摘要更糟。
|
||||
const codeOnly = src
|
||||
.split("\n")
|
||||
.filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l))
|
||||
.join("\n");
|
||||
const nSphere = (codeOnly.match(/new THREE\.SphereGeometry/g) || []).length;
|
||||
console.log(` 代码中 new SphereGeometry ${nSphere} 次(应为 1:共享入口那一次;注释里的引用已排除)`);
|
||||
check(
|
||||
"代码里只有一处 SphereGeometry 构造",
|
||||
nSphere === 1,
|
||||
`代码中出现 ${nSphere} 处 —— 某处又各自 new 了几何体`,
|
||||
);
|
||||
console.log("");
|
||||
if (failures > 0) {
|
||||
console.log(`失败 ${failures} 条`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("全部通过");
|
||||
Reference in New Issue
Block a user