mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 症状与实测
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)。
226 lines
9.1 KiB
JavaScript
226 lines
9.1 KiB
JavaScript
// 星图性能判据 —— 钉住 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);
|