From 04c5883f4b820e739407a495bd05ea09fc93320c Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:29:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(gui):=20=E6=98=9F=E5=9B=BE=E5=87=A0?= =?UTF-8?q?=E4=BD=95=E4=BD=93=E5=85=B1=E4=BA=AB=20+=20=E9=87=8D=E5=BB=BA?= =?UTF-8?q?=E6=97=B6=20dispose=EF=BC=8C=E4=BF=AE=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E9=A1=B5=E9=95=BF=E6=97=B6=E9=97=B4=E6=8C=82=E8=B5=B7=E5=90=8E?= =?UTF-8?q?=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 实测故障 聊天页长时间挂着之后 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。 --- cmd/gui/renderer/app.js | 106 +++++++++++- cmd/gui/starmap-gpu-lifecycle.test.mjs | 213 +++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 cmd/gui/starmap-gpu-lifecycle.test.mjs diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index c56a298a..db15e5bd 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -3066,12 +3066,98 @@ function initChatStarmap() { starmapAnimate(); } -function buildChatStarmapGraph() { - starmapNodeMeshes.forEach((m) => { - starmapScene.remove(m); +// ★ 几何体/材质共享(2026-10-05 修星图内存暴涨) +// +// 原实现每个节点都`new THREE.SphereGeometry(gr, 16, 12)`——因为半径 +// gr 各不相同,看起来「只能各自 new」。但 three.js 里几何体的大小应当由 +// **mesh.scale** 表达,几何体本身应当共享。 +// +// 实测(CDP 抓生产实例,2588 个节点): +// +// Mesh/SphereGeometry 2588 个 geometry 数据合计 22.67MB +// Sprite/BufferGeometry 1294 个(label 纹理,每个 256×64 RGBA ≈ 64KB) +// +// 两者都不释放:buildChatStarmapGraph 只starmapScene.remove(m), +// **从不 dispose()** —— remove 只把对象从场景树摘下来,WebGL 侧资源 +// (buffer/texture)仍被引用,不释放。星图每次全量重建泄漏一整轮, +// 于是进程常驻内存反复冲上GB 级、靠 GC 回收后再度暴涨,界面随之卡住。 +// +// 修法两条:单位球共享(几何体从 2588 份降到 1 份)+ 重建时 dispose。 +// 单位球细节:SphereGeometry(1, 16, 12) 建一次,实例按 gr 缩放, +// 因为 mesh 是父节点,缩放会叠加到子 glow 上—— 所以 glow 挂到 mesh 下时 +// 要用 gr/rad 的相对比例。 +// ★ 单位球几何体的**半径取0.5**,不是 1。 +// +// 原因:节点缩放基准是 mesh.userData.baseScale = rad / 0.5 —— +// 分母0.5 就是原SphereGeometry 的半径。若把共享球改成半径 1, +// baseScale 会整体缩放一倍(大的节点变得更大、小的更小),且与 +// 脉冲结束时的复原逻辑(setScalar(baseScale))不自洽。 +// 保持 0.5 ⇒ baseScale 公式原样成立,行为与修复前逐像素一致。 +var starmapUnitSphere = null; +function starmapSphereGeometry() { + if (!starmapUnitSphere) starmapUnitSphere = new THREE.SphereGeometry(0.5, 16, 12); + return starmapUnitSphere; +} + +// 释放一个 three.js 子树占用的 GPU 资源(geometry / material / texture)。 +// +// 递归处理子树:节点 mesh 下挂着 glow sphere(子 mesh)与 label sprite, +// 只 dispose 顶层会漏掉子节点的资源。 +// +// 共享资源要防误 dispose:starmapUnitSphere 被全场景共用, +// dispose 掉它会让仍在渲染的 mesh 变黑/报错 —— 用 seen 集合兜底。 +function disposeStarmapObject(root) { + var seenGeo = new Set(); + var seenMat = new Set(); + var seenTex = new Set(); + if (starmapScene) starmapScene.remove(root); + root.traverse(function (o) { + var g = o.geometry; + if (g && g.dispose && !seenGeo.has(g) && g !== starmapUnitSphere) { + seenGeo.add(g); + g.dispose(); + } + var m = o.material; + if (!m) return; + var mats = Array.isArray(m) ? m : [m]; + mats.forEach(function (mm) { + if (!mm || seenMat.has(mm)) return; + seenMat.add(mm); + // 纹理在 material.map / 上多通道贴图上。 + Object.keys(mm).forEach(function (k) { + var v = mm[k]; + if (v && v.isTexture && !seenTex.has(v)) { + seenTex.add(v); + v.dispose(); + } + }); + if (mm.dispose) mm.dispose(); + }); }); - starmapEdgeLines.forEach((l) => { - starmapScene.remove(l); + if (root.geometry && root.geometry.dispose && + root.geometry !== starmapUnitSphere && !seenGeo.has(root.geometry)) { + root.geometry.dispose(); + } + var rm = root.material; + if (rm && !Array.isArray(rm) && !seenMat.has(rm)) { + Object.keys(rm).forEach(function (k) { + var v = rm[k]; + if (v && v.isTexture && !seenTex.has(v)) v.dispose(); + }); + if (rm.dispose) rm.dispose(); + } +} + +function buildChatStarmapGraph() { + // ★ 先释放上一轮的 GPU 资源,再重建(2026-10-05 修内存暴涨)。 + // + // 原实现只 remove 不 dispose。remove 只改场景树,geometry/material/texture + // 仍占着 WebGL 资源;节点数还会随记忆增长,于是泄漏只增不减。 + starmapNodeMeshes.forEach(function (m) { + disposeStarmapObject(m); + }); + starmapEdgeLines.forEach(function (l) { + disposeStarmapObject(l); }); starmapNodeMeshes = []; starmapEdgeLines = []; @@ -3207,7 +3293,7 @@ function buildChatStarmapGraph() { var rad = 0.5 + mnr * 2.0; var col = smTypeColors[n.type] || 0xcccccc; var ei = 0.3 + mnr * 0.7; - var g = new THREE.SphereGeometry(rad, 16, 12); + var g = starmapSphereGeometry(); var mat = new THREE.MeshPhongMaterial({ color: col, emissive: col, @@ -3223,8 +3309,12 @@ function buildChatStarmapGraph() { // 缩放后」的值,而不是 set(1,1,1)—— 那会把大节点缩成最小尺寸。 mesh.userData.baseScale = rad / 0.5; // Glow sphere + // Glow sphere —— 共享单位球,靠 scale 表达半径(详见 starmapSphereGeometry)。 var gr = rad * 1.2 + mnr * 0.5; - var gg = new THREE.SphereGeometry(gr, 16, 12); + // mesh 已按 rad/baseScale 缩放过(baseScale = rad/0.5),故 glow 用 + // 相对比例:世界半径 gr = mesh 缩放 × 本地 scale。 + var parentScale = mesh.scale.x || 1; + var gg = starmapSphereGeometry(); var gm = new THREE.MeshBasicMaterial({ color: col, transparent: true, @@ -3233,6 +3323,8 @@ function buildChatStarmapGraph() { blending: THREE.AdditiveBlending, }); var gs = new THREE.Mesh(gg, gm); + // 共享球半径 0.5,故要 ×2 才是单位半径的相对值。 + gs.scale.setScalar((gr / parentScale) * 2); mesh.add(gs); mesh.userData.glowSphere = gs; // Label sprite diff --git a/cmd/gui/starmap-gpu-lifecycle.test.mjs b/cmd/gui/starmap-gpu-lifecycle.test.mjs new file mode 100644 index 00000000..8da0e973 --- /dev/null +++ b/cmd/gui/starmap-gpu-lifecycle.test.mjs @@ -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("全部通过"); \ No newline at end of file