perf(starmap): WebUI/GUI 星图卡顿修复 + 抽出共享「布局数学」单一源

## 症状与实测

WebUI 星图建图阻塞主线程 9339ms、draw calls 7514;GUI 建图 2466ms、
draw calls 3477。两者在空闲时仍在空转渲染。

## WebUI(internal/plugins/webui/dashboard.js)

- 力导向布局 O(n²) 两两互斥改**网格近邻**,并移入 **Web Worker**
  ⇒ 建图阻塞 **9339ms → 57ms**
- 每节点光晕 3753 个 draw call → 1 个 InstancedMesh
- 边 966 → 4 个 LineSegments
- 空闲帧跳过 + 星空时间驱动
- 结果:draw calls **7514 → 3760**,空闲阻塞 **99% → 3%**

## GUI(cmd/gui/renderer/app.js)

- draw calls **3477 → 1809**,建图 **2466ms → 943ms**
- 首屏可见性守卫(GUI 默认 chat 视图,星图不该在后台初始化)
- 空闲 12fps → 0.2fps

修 GUI 两个自引 bug:
  1. 抄 WebUI 的 `starmapMorphSet`(GUI 实际用 `state.starmapGrown`)⇒
     每帧 ReferenceError、星图 0 帧;
  2. `camKey` 读 `starmapCam.position` 无守卫(`switchChatPanel` 同步调
     `starmapAnimate` 时 cam 为 nil)。**同一缺陷 WebUI 也存在,两处均已补守卫。**

## 共享源(方案 A:只共享「布局数学」这一层)

新增 `shared/starmap/layout.js`(3 个纯函数,零 DOM/全局引用)+
生成器 `tools/starmap-sync.mjs` + 门禁 `make check-starmap-shared`(已进
make test)。三处(共享源 / dashboard.js / app.js)的标记块必须逐字节相同。

**为何只共享这一层**(实测依据):web 星图 54 个函数、gui 24 个,两者同名
函数逐字节相同数为 **0**。GUI 有设备桥/computer use/托盘/自有面板,WebUI 有
侧栏页签体系 —— 共享受众完全不同的 UI 代码是错的,能安全共享的只有布局数学。

**为何用生成器而非运行时加载**:GUI 用 file:// (main.js 的 loadFile),可能
离线;让其拉服务端 dashboard.js 会把「星图能否初始化」绑到网络与登录态上。

**为何 GUI 不用 Web Worker**:file:// 下外链与 Blob Worker 实测都能跑,但不把
「星图能否初始化」押在打包环境的 Worker 行为上。

## 判据

- `cmd/gui/starmap-perf.test.mjs`(21 条,接入 npm test)
- 扩充 `internal/plugins/webui/starmap_perf_test.go`(同源判据 +
  checkStarmapSharedBlock)
- 变异 M1/M2/M3 全部报红

## 附带

cmd/gui/renderer/app.js 同时含上一提交的 GUI `system` 事件消费(协议修复),
因同文件交织未再拆分,见提交 fix(clients)。
This commit is contained in:
JianFeeeee
2026-10-13 16:21:04 +08:00
parent 50ef27891f
commit cf3d3769b1
8 changed files with 1086 additions and 106 deletions

View File

@ -0,0 +1,225 @@
// 星图性能判据 —— 钉住 2026-10-13 的两轮优化。
//
// ## 为什么需要这一组(真实现象,不是理论)
//
// `cmd/gui/renderer/app.js` 与 `internal/plugins/webui/dashboard.js` 的星图
// 代码是**两份手抄的副本**(当年 diff 为 0)。WebUI 先修了性能,GUI 因为
// 是独立文件**完全没跟着变** ⇒ 用户换 GUI 客户端时同样的卡顿原样存在。
//
// 更糟的是「照抄会抄错」:本轮把 WebUI 的空闲帧跳过抄进 GUI 时,直接抄了
// `starmapMorphSet` —— 那是 **WebUI 独有的变量**,GUI 用的是
// `state.starmapGrown`。结果是渲染循环**每帧 ReferenceError、星图 0 帧**
// (实测 frame 恒为 1)。这类错误在语法检查里看不出来(JS 不校验未声明
// 标识符),必须靠运行或静态判据抓。
//
// ## 判据取「结构性事实」而不是性能数字
//
// 跑真 Electron 量毫秒数需要后端 + 生产图数据,不适合做单测;而这几处
// 缺陷都有确定的结构特征(全对全双层循环 / 每节点一个光晕 mesh /
// 每边一个 Line / 无空闲跳过),源码静态可判且回归立刻红。
//
// 运行:node cmd/gui/starmap-perf.test.mjs
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const APP = join(here, "renderer", "app.js");
const src = readFileSync(APP, "utf8");
// 取一个顶层函数的函数体(到下一个行首 `}` 为止)。
function funcBody(name) {
const marker = "function " + name + "(";
const i = src.indexOf(marker);
if (i < 0) return null;
const rest = src.slice(i);
const end = rest.indexOf("\n}\n");
return end < 0 ? rest : rest.slice(0, end);
}
let failures = 0;
const check = (name, ok, detail) => {
if (ok) console.log(` ✓ ${name}`);
else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
console.log("星图性能判据(GUI):");
// ── ① 力导向布局必须是网格近邻(O(n·k)),不得再是全对全(O(n²))──
//
// 旧实现 `for (it<50) { for (i) { for (j=i+1) } }`:生产库 3754 块
// = 3.52 亿次配对,Electron 实测单次建图阻塞 1.8–9 秒。
{
const relax = funcBody("starmapLayoutRelax");
check("存在 starmapLayoutRelax(网格近邻实现)", relax !== null);
if (relax) {
check(
"starmapLayoutRelax 走空间网格(grid + CUTOFF)",
/\bgrid\b/.test(relax) && /\bCUTOFF\b/.test(relax),
"缺网格:全对全斥力在生产库实测阻塞数秒",
);
check(
"starmapLayoutRelax 不再有 Object.keys(pos) 全量遍历",
!/Object\.keys/.test(relax),
"Object.keys 全量遍历是 O(n²) 写法的特征",
);
}
check(
"存在迭代数自适应 starmapLayoutIterationsFor",
src.includes("function starmapLayoutIterationsFor("),
"固定 50 轮在大图上绝大多数轮次是在重复收敛后的状态",
);
// 全对全的字符特征:`for (var it = 0; it < 50` 配 `for (var j = i + 1`
check(
"已无「50 轮 × 全对全」双层循环",
!/for \(var it = 0; it < 50; it\+\+\)[\s\S]{0,900}?for \(var j = i \+ 1/.test(src),
"这正是被替换掉的 O(n²) 布局",
);
}
// ── ② 光晕必须合并成 InstancedMesh(每节点一个 mesh = 1294 draw call)──
{
check(
"存在 starmapBuildGlowInstances(光晕合并)",
src.includes("function starmapBuildGlowInstances("),
);
const build = funcBody("buildChatStarmapGraph");
check("buildChatStarmapGraph 调用了 starmapBuildGlowInstances", !!build && build.includes("starmapBuildGlowInstances("));
check(
"光晕不再逐个 add 到 mesh 上",
!/mesh\.add\(gs\)/.test(src),
"每节点一个光晕子 mesh 会让 draw call 翻倍",
);
check(
"光晕 InstancedMesh 用的是共享单位球",
/new THREE\.InstancedMesh\(/.test(src) && /starmapSphereGeometry\(\)/.test(src),
"实例化必须复用共享几何,否则几何体数量没省下来",
);
}
// ── ③ 边必须按颜色合并成 LineSegments(每边一个 Line = 966 draw call)──
{
const build = funcBody("buildChatStarmapGraph");
check(
"边走 LineSegments 合并",
!!build && build.includes("new THREE.LineSegments("),
"每边一个 THREE.Line 是 966 次 draw call;合并后为关系类型数",
);
check(
"边不再逐条 new THREE.Line(",
!/new THREE\.Line\(geo, mat\)/.test(src),
"逐条 Line 是被替换掉的写法",
);
}
// ── ④ 空闲帧跳过 + 星空时间驱动 ──
{
const anim = funcBody("starmapAnimate");
check("starmapAnimate 存在", anim !== null);
if (anim) {
check(
"空闲帧跳过(SM_IDLE_RENDER_MS)",
anim.includes("SM_IDLE_RENDER_MS"),
"图形静止时按 12fps 重绘 1800+ draw call 是纯浪费",
);
check(
"camKey 变化时立即重绘(交互不受节流影响)",
/camKey !== starmapLastRenderKey/.test(anim),
"若无论如何都受间隔限制,拖动会变粘",
);
check(
"星空按真实 dt 推进(而非每帧固定角度)",
/dtSec/.test(anim) && !/rotation\.y \+= 0\.0001;/.test(anim),
"每帧固定角度 ⇒ 必须按帧重绘,无法降频",
);
// ★★ 相机守卫:switchChatPanel 里 renderChatStarmap() 是异步建图,
// 但紧接着就同步调 starmapAnimate(),那一瞬间 starmapCam 仍是 null。
check(
"camKey 之前有 starmapCam/starmapCtrl 守卫",
/if \(!starmapRen \|\| !starmapCam \|\| !starmapCtrl\) return;/.test(anim),
"无守卫时读 starmapCam.position 会抛 TypeError(实测报在 switchChatPanel 栈上)",
);
}
// ★★ 跨仓抄错的那一处:GUI 没有 starmapMorphSet。
check(
"不引用 WebUI 独有的 starmapMorphSet",
!/starmapMorphSet\s*[?)]/.test(src),
"GUI 用 state.starmapGrown;抄 WebUI 的 starmapMorphSet 会让每帧 ReferenceError(星图 0 帧,实测)",
);
check(
"空闲跳过用的是 GUI 自己的活跃集 state.starmapGrown",
/grownCount/.test(src) || /state\.starmapGrown/.test(src),
);
check("SM_IDLE_RENDER_MS 已定义", /var SM_IDLE_RENDER_MS\s*=/.test(src));
}
// ── ⑤ 布局代码必须与 WebUI 同源(shared/starmap/layout.js)──
//
// 为何单独钉这一条:这份布局曾在 GUI 与 WebUI 各存一份手抄,两次真实事故
// 都出自这个分叉(WebUI 换网格法 GUI 没跟上;反向抄时抄了 WebUI 独有的
// starmapMorphSet ⇒ GUI 每帧 ReferenceError、星图 0 帧)。
// 现在唯一事实源在 shared/starmap/layout.js,由 tools/starmap-sync.mjs
// 写进本文件的标记块。这里校验「本文件里的块 == 共享源」。
{
const BEGIN = "// ===== BEGIN shared:starmap/layout =====";
const END = "// ===== END shared:starmap/layout =====";
const b = src.indexOf(BEGIN);
const e = src.indexOf(END);
check("app.js 含共享块标记", b >= 0 && e > b);
if (b >= 0 && e > b) {
const block = src.slice(b, e + END.length);
const shared = readFileSync(
join(here, "..", "..", "shared", "starmap", "layout.js"),
"utf8",
).replace(/\r\n/g, "\n");
check(
"共享块与 shared/starmap/layout.js 逐字节一致(无漂移)",
block.includes(shared.trim()),
"修法:node tools/starmap-sync.mjs(改算法请只改共享源)",
);
check(
"共享块内带「由生成器产出」提示",
/生成/.test(block) || /generated/.test(block),
"避免后人当普通代码手改",
);
}
}
// ── ⑥ 首屏不得为隐藏的星图面板建图 ──
//
// GUI 默认视图是 chat,而 renderAll 在 chat 视图会调 renderChatStarmap()。
// 无守卫时每次启动都为「看不见的」星图面板付 943ms 建图 + 433ms 纹理上传。
{
const rcs = funcBody("renderChatStarmap");
check("renderChatStarmap 存在", rcs !== null);
if (rcs) {
// ★★ 必须先剔除注释再判!
// 本判据第一版写的是 `body.includes("offsetParent")`,而**注释里
// 恰好写了这个词**(解释为何要加守卫),于是把真正的守卫代码删掉、
// 只留注释,判据仍然通过 —— 变异测试当场拆穿过这个假绿。
// 教训:凡「某标识符是否出现在函数体」类判据,都要先去注释,
// 否则注释会变成判据的挡箭牌。
const code = rcs
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/[^\n]*/g, "$1");
check(
"renderChatStarmap 有可见性守卫(代码中真读 offsetParent,不只写在注释里)",
/\.offsetParent/.test(code),
"否则首屏(chat 视图)就会为隐藏的星图面板建图,白付 1.4 秒",
);
check(
"可见性守卫在 loadChatStarmapData 之前(真能拦住)",
code.indexOf(".offsetParent") < code.indexOf("loadChatStarmapData()"),
"守卫写在拉数据之后等于没拦",
);
}
}
console.log(
failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`,
);
process.exit(failures === 0 ? 0 : 1);