mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
perf(webui): 修星图形变活跃集的泄漏 + 加回归判据
上一提交(3e201dd4)把 starmapTickMorph 从「每帧全量遍历」改成 starmapMorphSet 活跃集,但我漏了一处:buildChatStarmapGraph 重建图时 只重置了 starmapNodeMeshes / starmapEdgeLines,**没清空 starmapMorphSet**。 后果:旧 mesh 已从场景摘掉,却留在活跃集里,于是 - 集合随每次重建单调增长、永不归零 ⇒「空集时 O(1) 返回」的快速路径 从此失效,正是这次优化想省掉的那笔开销又回来了 - 每帧遍历一批已经不在场景里的废弃 mesh 同时补 starmap_perf_test.go。为何用源码断言而非运行时测量: dashboard.js 是嵌入的浏览器脚本(依赖 three.js / DOM),Node 跑不了; 而这几条都是**结构性**缺陷(循环扫全表、循环不停、每帧重建), 源码里能可靠判定,回归时立刻红。 判据可杀变异(逐个实测过,不是装饰): - 节流常量改名 → FAIL「缺少节流」 - morph 早退条件删掉 → FAIL「必须提前返回」 - clear() 改成 if (false) 或整行删除 → FAIL「必须无条件清空」 第三处特意断言在**重建函数体内**且是 `(?m)^\s*if \(starmapMorphSet\) ...` (先写成全仓搜 `starmapMorphSet.clear()`,变异测试显示 `if (false)` 能骗过 它 —— 弱判据当场修强)。
This commit is contained in:
@ -478,8 +478,15 @@ function switchTab(n) {
|
|||||||
} else if (!state.starmapInit && !state.starmapLoading) {
|
} else if (!state.starmapInit && !state.starmapLoading) {
|
||||||
loadChatStarmapData();
|
loadChatStarmapData();
|
||||||
}
|
}
|
||||||
|
// ★ 重新点火渲染循环。
|
||||||
|
//
|
||||||
|
// 循环在不可见时会自行 return(见 starmapAnimate,修「非常卡」),
|
||||||
|
// 所以切回星图必须显式唤醒——否则图冻在切走前的那一帧。
|
||||||
|
starmapWake();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 总览页与聊天面板也内嵌星图:回到它们时同样要唤醒。
|
||||||
|
if (n === "overview" || n === "chat") starmapWake();
|
||||||
renderAll();
|
renderAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3078,8 +3085,9 @@ function initChatStarmap() {
|
|||||||
starmapRen.domElement.addEventListener("mousemove", onStarmapMove);
|
starmapRen.domElement.addEventListener("mousemove", onStarmapMove);
|
||||||
starmapRen.domElement.addEventListener("click", onStarmapClick);
|
starmapRen.domElement.addEventListener("click", onStarmapClick);
|
||||||
window.addEventListener("resize", onStarmapResize);
|
window.addEventListener("resize", onStarmapResize);
|
||||||
if (starmapRaf) cancelAnimationFrame(starmapRaf);
|
// 用 starmapWake(带开关)而不是裸调 starmapAnimate:
|
||||||
starmapAnimate();
|
// 避免重复点火时留下两个并行的 RAF 链(那会让每帧工作量翻倍)。
|
||||||
|
starmapWake();
|
||||||
// 活动数据源:/runtime 3s + /memory/graph/pulse 10s。
|
// 活动数据源:/runtime 3s + /memory/graph/pulse 10s。
|
||||||
// 只在星图真正初始化后启动,避免在隐藏页签空跑。
|
// 只在星图真正初始化后启动,避免在隐藏页签空跑。
|
||||||
starmapStartActivity();
|
starmapStartActivity();
|
||||||
@ -3144,6 +3152,12 @@ function buildChatStarmapGraph() {
|
|||||||
});
|
});
|
||||||
starmapNodeMeshes = [];
|
starmapNodeMeshes = [];
|
||||||
starmapEdgeLines = [];
|
starmapEdgeLines = [];
|
||||||
|
// ★ 必须同时清空形变活跃集(2026-10-08):上面刚把旧 mesh 从场景摘掉,
|
||||||
|
// 而它们可能仍在 starmapMorphSet 里。若不清,这个集合会随每次重建图
|
||||||
|
// 单调增长(永不归零),于是「空集合时 O(1) 返回」的快速路径永远失效,
|
||||||
|
// 每帧又要遍历一批**已经不在场景里**的废弃 mesh —— 正好把本次优化
|
||||||
|
// 的努力又还回去了。
|
||||||
|
if (starmapMorphSet) starmapMorphSet.clear();
|
||||||
if (starmapNodes.length === 0) return;
|
if (starmapNodes.length === 0) return;
|
||||||
// Calculate node degrees for leaf node detection
|
// Calculate node degrees for leaf node detection
|
||||||
var nodeDegs = {};
|
var nodeDegs = {};
|
||||||
@ -5753,6 +5767,13 @@ var starmapHovered = null,
|
|||||||
starmapSelected = null,
|
starmapSelected = null,
|
||||||
starmapAutoView = true;
|
starmapAutoView = true;
|
||||||
var starmapRaf = null;
|
var starmapRaf = null;
|
||||||
|
// starmapAnimateRunning:渲染循环的开关。
|
||||||
|
//
|
||||||
|
// ★ 为何要显式开关而不是只靠 requestAnimationFrame:
|
||||||
|
// RAF 一旦排上就会一直回调(除非 cancel)。本循环在**不可见时自行 return**,
|
||||||
|
// 若不用开关,那个 return 只是「不干活但仍在被 60fps 调用」,仍白耗一层
|
||||||
|
// 调度;而开关让它能真正停下,由 starmapWake 重新点火。
|
||||||
|
var starmapAnimateRunning = false;
|
||||||
// starmapDirty:图结构发生变化(新建/删除/合入块),需要重拉全量。
|
// starmapDirty:图结构发生变化(新建/删除/合入块),需要重拉全量。
|
||||||
//
|
//
|
||||||
// ★ 必须显式声明。此前两处 `starmapDirty = true` 赋的是**隐式全局**,
|
// ★ 必须显式声明。此前两处 `starmapDirty = true` 赋的是**隐式全局**,
|
||||||
@ -5910,6 +5931,7 @@ function starmapStartGrow(ids) {
|
|||||||
var m = idx[ids[i]];
|
var m = idx[ids[i]];
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
m.userData.growAt = Date.now();
|
m.userData.growAt = Date.now();
|
||||||
|
starmapMorphAdd(m); // 注册到形变活跃集(否则 tick 不会推进它)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5929,6 +5951,7 @@ function starmapStartDissolve(ids, delayMs) {
|
|||||||
m.userData.dissolveAt = now + (delayMs || 0);
|
m.userData.dissolveAt = now + (delayMs || 0);
|
||||||
m.userData.dissolveEnds = now + (delayMs || 0) + SM_DISSOLVE_MS;
|
m.userData.dissolveEnds = now + (delayMs || 0) + SM_DISSOLVE_MS;
|
||||||
m.userData.dissolveFrom = m.scale.x;
|
m.userData.dissolveFrom = m.scale.x;
|
||||||
|
starmapMorphAdd(m); // 注册到形变活跃集
|
||||||
starmapSpawnParticles(m);
|
starmapSpawnParticles(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5940,11 +5963,14 @@ function starmapStartMerge(fromID, toID) {
|
|||||||
starmapStartDissolve([fromID], 0);
|
starmapStartDissolve([fromID], 0);
|
||||||
// ② 目标块在停顿之后重生(不是立即)
|
// ② 目标块在停顿之后重生(不是立即)
|
||||||
var dst = idx[toID];
|
var dst = idx[toID];
|
||||||
if (dst) {
|
if (dst) {
|
||||||
var delayed = Date.now() + SM_DISSOLVE_MS + SM_MERGE_APPEAR_DELAY;
|
var delayed = Date.now() + SM_DISSOLVE_MS + SM_MERGE_APPEAR_DELAY;
|
||||||
var timer = setTimeout(function () {
|
var timer = setTimeout(function () {
|
||||||
if (dst && dst.parent) dst.userData.growAt = Date.now();
|
if (dst && dst.parent) {
|
||||||
}, SM_DISSOLVE_MS + SM_MERGE_APPEAR_DELAY);
|
dst.userData.growAt = Date.now();
|
||||||
|
starmapMorphAdd(dst); // 重生也要注册
|
||||||
|
}
|
||||||
|
}, SM_DISSOLVE_MS + SM_MERGE_APPEAR_DELAY);
|
||||||
if (state.starmapMergeTimers) state.starmapMergeTimers.push(timer);
|
if (state.starmapMergeTimers) state.starmapMergeTimers.push(timer);
|
||||||
else state.starmapMergeTimers = [timer];
|
else state.starmapMergeTimers = [timer];
|
||||||
// 记录重生时刻,供 tick 区分“合入重生”与普通生长(前者更短)
|
// 记录重生时刻,供 tick 区分“合入重生”与普通生长(前者更短)
|
||||||
@ -6304,28 +6330,44 @@ function starmapPullPulse() {
|
|||||||
// (同一个节点可以同时有好几个),而生长/消散是**排他的状态机**
|
// (同一个节点可以同时有好几个),而生长/消散是**排他的状态机**
|
||||||
// (一个节点不可能同时在长又在消)。混用会让基值互相覆盖,
|
// (一个节点不可能同时在长又在消)。混用会让基值互相覆盖,
|
||||||
// 出现「消散到一半被 pulse 拉回原大小」的鬼畜。
|
// 出现「消散到一半被 pulse 拉回原大小」的鬼畜。
|
||||||
|
var starmapMorphSet = null; // Set<mesh>,懒建
|
||||||
|
|
||||||
|
// starmapMorphAdd 把一个节点加入形变活跃集(生长/消散都要)。
|
||||||
|
function starmapMorphAdd(mesh) {
|
||||||
|
if (!starmapMorphSet) starmapMorphSet = new Set();
|
||||||
|
starmapMorphSet.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// starmapTickMorph 推进「生长」与「消散」两种形变。
|
||||||
|
//
|
||||||
|
// ★ 性能(2026-10-08 修「非常卡」):原先每帧无条件扫全部节点,
|
||||||
|
// 生产库 3190 块 × 60fps ⇒ 每秒 19 万次 userData 读取与分支,
|
||||||
|
// 而绝大多数帧里「正在形变」的节点是 0 个。
|
||||||
|
// 改用 starmapMorphSet 记录活跃节点,空集合时 O(1) 返回。
|
||||||
function starmapTickMorph(now) {
|
function starmapTickMorph(now) {
|
||||||
for (var i = 0; i < starmapNodeMeshes.length; i++) {
|
if (!starmapMorphSet || starmapMorphSet.size === 0) return; // 常见路径:零开销
|
||||||
var m = starmapNodeMeshes[i];
|
var done = [];
|
||||||
|
starmapMorphSet.forEach(function (m) {
|
||||||
var ud = m.userData;
|
var ud = m.userData;
|
||||||
|
|
||||||
// —— 消散 ——
|
// —— 消散 ——
|
||||||
if (ud.dissolving) {
|
if (ud.dissolving) {
|
||||||
if (now < ud.dissolveAt) continue; // 合入时源块的延迟(若有)
|
if (now < ud.dissolveAt) return; // 合入时源块的延迟(若有)
|
||||||
var dp = (now - ud.dissolveAt) / SM_DISSOLVE_MS;
|
var dp = (now - ud.dissolveAt) / SM_DISSOLVE_MS;
|
||||||
if (dp >= 1) {
|
if (dp >= 1) {
|
||||||
// 完了:必须真的从场景摘掉,否则一个 scale=0 的不可见 mesh
|
// 完了:必须真的从场景摘掉,否则一个 scale=0 的不可见 mesh
|
||||||
// 仍参与鼠标拾取,用户会“点中一个看不见的节点”。
|
// 仍参与鼠标拾取,用户会“点中一个看不见的节点”。
|
||||||
starmapScene.remove(m);
|
starmapScene.remove(m);
|
||||||
ud.dissolving = false;
|
ud.dissolving = false;
|
||||||
continue;
|
done.push(m);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// 收缩 + 变暗:两个通道同时走,单个通道(只缩)看着像“缩成点”,
|
// 收缩 + 变暗:两个通道同时走,单个通道(只缩)看着像“缩成点”,
|
||||||
// 加上变暗才有“消失”的观感。
|
// 加上变暗才有“消失”的观感。
|
||||||
var shrink = 1 - dp * dp; // 加速收缩(先慢后快)
|
var shrink = 1 - dp * dp; // 加速收缩(先慢后快)
|
||||||
m.scale.setScalar(ud.baseScale * shrink);
|
m.scale.setScalar(ud.baseScale * shrink);
|
||||||
if (m.material) m.material.emissiveIntensity = ud.baseEmissive * (1 - dp);
|
if (m.material) m.material.emissiveIntensity = ud.baseEmissive * (1 - dp);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 生长(插入 / 合入后重生)——
|
// —— 生长(插入 / 合入后重生)——
|
||||||
@ -6337,7 +6379,8 @@ function starmapTickMorph(now) {
|
|||||||
ud.mergeGrowAt = null;
|
ud.mergeGrowAt = null;
|
||||||
m.scale.setScalar(ud.baseScale);
|
m.scale.setScalar(ud.baseScale);
|
||||||
if (m.material) m.material.emissiveIntensity = ud.baseEmissive;
|
if (m.material) m.material.emissiveIntensity = ud.baseEmissive;
|
||||||
continue;
|
done.push(m);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// 过冲曲线(back-out):超过 1 再回落 —— 这才像“弹”而不是“淡入”。
|
// 过冲曲线(back-out):超过 1 再回落 —— 这才像“弹”而不是“淡入”。
|
||||||
var s = 1.7;
|
var s = 1.7;
|
||||||
@ -6348,9 +6391,10 @@ function starmapTickMorph(now) {
|
|||||||
var scale = 0.05 + 0.95 * Math.max(0, eased);
|
var scale = 0.05 + 0.95 * Math.max(0, eased);
|
||||||
m.scale.setScalar(ud.baseScale * scale);
|
m.scale.setScalar(ud.baseScale * scale);
|
||||||
if (m.material) m.material.emissiveIntensity = ud.baseEmissive + 0.9 * (1 - gp);
|
if (m.material) m.material.emissiveIntensity = ud.baseEmissive + 0.9 * (1 - gp);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
// 已完成的从活跃集移除(本次遍历后,不做边遍历边删)。
|
||||||
|
for (var i = 0; i < done.length; i++) starmapMorphSet.delete(done[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function starmapTickActivity(t) {
|
function starmapTickActivity(t) {
|
||||||
@ -6930,6 +6974,21 @@ function starmapMakeLabelPlan() {
|
|||||||
// ② 每个标签先做屏幕坐标与视锥检查,背面/屏外的直接跳过。
|
// ② 每个标签先做屏幕坐标与视锥检查,背面/屏外的直接跳过。
|
||||||
var starmapLabelSig = "";
|
var starmapLabelSig = "";
|
||||||
var starmapLabelLastCamKey = "";
|
var starmapLabelLastCamKey = "";
|
||||||
|
// starmapLabelLastDraw:标签层上次重绘时刻(节流用)。
|
||||||
|
//
|
||||||
|
// 为何需要节流:OrbitControls 带阻尼,拖动/缩放时相机位置**每帧都在变**,
|
||||||
|
// 于是 camKey 每帧不同 ⇒ 原先会每帧重建 3190 项的 plan 并对每个可见标签
|
||||||
|
// 调 measureText(文字度量是同步布局,很贵)。标签是文字层,100ms 刷新
|
||||||
|
// 肉眼已看不出滞后,而 3D 图形仍是 60fps。
|
||||||
|
var starmapLabelLastDraw = 0;
|
||||||
|
var SM_LABEL_MIN_INTERVAL = 100;
|
||||||
|
// starmapTextWidth:measureText 结果缓存(键 = 字体 + 文字)。
|
||||||
|
//
|
||||||
|
// 为何要缓存:measureText 触发同步文字布局,每帧对上千个标签调用会卡主线程。
|
||||||
|
// 同一文字在同字体下的宽度是确定的,缓存长期有效(字体模板只有两个)。
|
||||||
|
// 上限保护:避免极端情况下无界增长(超出时整表清空重填)。
|
||||||
|
var starmapTextWidth = {};
|
||||||
|
var SM_TEXTWIDTH_MAX = 20000;
|
||||||
|
|
||||||
function starmapDrawLabels() {
|
function starmapDrawLabels() {
|
||||||
if (!starmapRen || !starmapCam || !starmapNodeMeshes.length) {
|
if (!starmapRen || !starmapCam || !starmapNodeMeshes.length) {
|
||||||
@ -6967,8 +7026,13 @@ function starmapDrawLabels() {
|
|||||||
cw +
|
cw +
|
||||||
"x" +
|
"x" +
|
||||||
ch;
|
ch;
|
||||||
if (camKey === starmapLabelLastCamKey) return;
|
// ★ 节流:相机拖动(OrbitControls 阻尼)会让 camKey 每帧都变,
|
||||||
|
// 若每帧重建 plan(3190 项)+ measureText,拖动时就是持续掉帧。
|
||||||
|
// 标签是文字层,10fps 的刷新肉眼已看不出滞后(图形仍是 60fps)。
|
||||||
|
var nowMs = Date.now();
|
||||||
|
if (nowMs - starmapLabelLastDraw < SM_LABEL_MIN_INTERVAL || camKey === starmapLabelLastCamKey) return;
|
||||||
starmapLabelLastCamKey = camKey;
|
starmapLabelLastCamKey = camKey;
|
||||||
|
starmapLabelLastDraw = nowMs;
|
||||||
|
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
ctx.clearRect(0, 0, cw, ch);
|
||||||
var plan = starmapMakeLabelPlan();
|
var plan = starmapMakeLabelPlan();
|
||||||
@ -6991,12 +7055,25 @@ function starmapDrawLabels() {
|
|||||||
sy = (-v.y * 0.5 + 0.5) * ch;
|
sy = (-v.y * 0.5 + 0.5) * ch;
|
||||||
if (sx < -60 || sx > cw + 60 || sy < -20 || sy > ch + 20) continue;
|
if (sx < -60 || sx > cw + 60 || sy < -20 || sy > ch + 20) continue;
|
||||||
if (!drawnText[it.text]) {
|
if (!drawnText[it.text]) {
|
||||||
ctx.font =
|
var font =
|
||||||
(it.kind === "hot" ? "bold 12px " : "11px ") +
|
(it.kind === "hot" ? "bold 12px " : "11px ") +
|
||||||
'ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif';
|
'ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif';
|
||||||
|
ctx.font = font;
|
||||||
ctx.textAlign = "left";
|
ctx.textAlign = "left";
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
var tw = ctx.measureText(it.text).width;
|
// ★ measureText 缓存:它是**同步布局**,每帧对上千个标签逐次调会把
|
||||||
|
// 主线程卡住。同一串文字在同字体下的宽度只要字体未变就不会变。
|
||||||
|
var mkey = font + "\u0000" + it.text;
|
||||||
|
var tw = starmapTextWidth[mkey];
|
||||||
|
if (tw === undefined) {
|
||||||
|
tw = ctx.measureText(it.text).width;
|
||||||
|
// 上限保护:极端情况下(不断变动的块文本)避免无界增长。
|
||||||
|
// 超限时整表清空重填——比逐项 LRU 简单,而重填代价极低(一次性)。
|
||||||
|
if (Object.keys(starmapTextWidth).length > SM_TEXTWIDTH_MAX) {
|
||||||
|
starmapTextWidth = {};
|
||||||
|
}
|
||||||
|
starmapTextWidth[mkey] = tw;
|
||||||
|
}
|
||||||
// 名字画在节点**右侧**(左对齐是节点中心,留 7px 避让球体)。
|
// 名字画在节点**右侧**(左对齐是节点中心,留 7px 避让球体)。
|
||||||
var tx = sx + 7,
|
var tx = sx + 7,
|
||||||
y = sy;
|
y = sy;
|
||||||
@ -7017,7 +7094,24 @@ function starmapDrawLabels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function starmapAnimate() {
|
function starmapAnimate() {
|
||||||
|
if (!starmapAnimateRunning) return;
|
||||||
starmapRaf = requestAnimationFrame(starmapAnimate);
|
starmapRaf = requestAnimationFrame(starmapAnimate);
|
||||||
|
|
||||||
|
// ★ 不可见时不做任何工作(2026-10-08 修「非常卡」)。
|
||||||
|
//
|
||||||
|
// 为何必须这一步:本循环原先**从不停**——切到聊天/终端页签、甚至浏览器
|
||||||
|
// 最小化到后台,它仍以 60fps 跑着每帧 3190 节点的全量遍历(渲染、标签、
|
||||||
|
// 形变推进)。用户感知就是整页卡。
|
||||||
|
//
|
||||||
|
// 判据不用 document.hidden:星图可能在**未激活的页签**里(canvas 被搬到
|
||||||
|
// 别的容器),此时页面可见但星图看不到——document.hidden 为 false,
|
||||||
|
// 却白烧 CPU。故直接问「星图容器当前是否可见」。
|
||||||
|
var cont = starmapActiveContainer();
|
||||||
|
if (!cont || !cont.offsetParent) {
|
||||||
|
// 不可见:只保留最小状态(停渲染)。下次可见时由 starmapWake 重新驱动。
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (starmapCtrl) starmapCtrl.update();
|
if (starmapCtrl) starmapCtrl.update();
|
||||||
if (starmapStarField) starmapStarField.rotation.y += 0.0001;
|
if (starmapStarField) starmapStarField.rotation.y += 0.0001;
|
||||||
// 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。
|
// 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。
|
||||||
@ -7025,10 +7119,21 @@ function starmapAnimate() {
|
|||||||
if (starmapRen && starmapScene && starmapCam)
|
if (starmapRen && starmapScene && starmapCam)
|
||||||
starmapRen.render(starmapScene, starmapCam);
|
starmapRen.render(starmapScene, starmapCam);
|
||||||
// 节点名覆盖层:必须紧跟 render 之后,否则会与 3D 帧错位(看着像标签漂移)。
|
// 节点名覆盖层:必须紧跟 render 之后,否则会与 3D 帧错位(看着像标签漂移)。
|
||||||
// 无节点或页签不可见时内部自行早退。
|
|
||||||
starmapDrawLabels();
|
starmapDrawLabels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// starmapWake 确保渲染循环在跑(幂等)。
|
||||||
|
//
|
||||||
|
// 为何需要:循环现在会在不可见时自行 return(见 starmapAnimate),
|
||||||
|
// 因此切回星图页签时必须重新点火——否则图冻在最后一帧。
|
||||||
|
function starmapWake() {
|
||||||
|
if (starmapAnimateRunning) return;
|
||||||
|
starmapAnimateRunning = true;
|
||||||
|
starmapLabelLastCamKey = ""; // 强制标签重绘(可能已过去很久,相机也动过)
|
||||||
|
if (starmapRaf) cancelAnimationFrame(starmapRaf);
|
||||||
|
starmapAnimate();
|
||||||
|
}
|
||||||
|
|
||||||
// smStarmapFitDistance 估算能看到整个星图的相机距离。
|
// smStarmapFitDistance 估算能看到整个星图的相机距离。
|
||||||
//
|
//
|
||||||
// 为何不用固定值:节点数从 1294 变到 3178 后布局半径显著变大,
|
// 为何不用固定值:节点数从 1294 变到 3178 后布局半径显著变大,
|
||||||
|
|||||||
83
internal/plugins/webui/starmap_perf_test.go
Normal file
83
internal/plugins/webui/starmap_perf_test.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 星图渲染必须避免「每帧全量」——这些判据钉住 2026-10-08 修「非常卡」的三处。
|
||||||
|
//
|
||||||
|
// 为何用源码断言而不是运行时测量:dashboard.js 是嵌入的浏览器脚本,
|
||||||
|
// 用 Node 跑不了(依赖 three.js / DOM)。而这三条都是**结构性**缺陷
|
||||||
|
// (循环扫全表、循环不停、每帧重建),源码里能可靠判定,且回归时立刻红。
|
||||||
|
func TestStarmapNoPerFrameFullScan(t *testing.T) {
|
||||||
|
src := readDashboardJS(t)
|
||||||
|
|
||||||
|
// ① starmapTickMorph 不得再全量遍历 starmapNodeMeshes。
|
||||||
|
// 原先 3190 节点 × 60fps = 每秒 19 万次 userData 读取与分支,
|
||||||
|
// 而绝大多数帧里「正在形变」的节点是 0 个。
|
||||||
|
morph := extractFunc(t, src, "starmapTickMorph")
|
||||||
|
if strings.Contains(morph, "starmapNodeMeshes") {
|
||||||
|
t.Error("starmapTickMorph 又全量遍历 starmapNodeMeshes 了:应只遍历 starmapMorphSet 活跃集," +
|
||||||
|
"空集时 O(1) 返回(否则每帧 3190 次无谓遍历)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(morph, "starmapMorphSet") {
|
||||||
|
t.Error("starmapTickMorph 必须走 starmapMorphSet 活跃集合")
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`starmapMorphSet\.size\s*===\s*0`).MatchString(morph) {
|
||||||
|
t.Error("starmapTickMorph 必须在活跃集为空时提前返回(这是常见路径,应当零开销)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 渲染循环必须能被暂停(否则切走页签/后台仍 60fps 空转)。
|
||||||
|
anim := extractFunc(t, src, "starmapAnimate")
|
||||||
|
if !strings.Contains(anim, "starmapAnimateRunning") {
|
||||||
|
t.Error("starmapAnimate 缺少 starmapAnimateRunning 开关:切走页签后循环不会停")
|
||||||
|
}
|
||||||
|
if !strings.Contains(anim, "starmapActiveContainer") {
|
||||||
|
t.Error("starmapAnimate 必须按「星图容器是否可见」判断暂停," +
|
||||||
|
"不能用 document.hidden(星图可能在未激活页签里被搬走)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 重建图时必须清空形变活跃集,否则集合单调增长、
|
||||||
|
// 「空集 O(1)」快速路径永不生效,每帧还遍历已废弃 mesh。
|
||||||
|
// 断言必须在**重建函数体内**且真的在调用 clear(不能只看全仓有这串,
|
||||||
|
// 否则改成 `if (false) ...clear()` 也能骗过——变异测试实测过)。
|
||||||
|
rebuild := extractFunc(t, src, "buildChatStarmapGraph")
|
||||||
|
if !regexp.MustCompile(`(?m)^\s*if \(starmapMorphSet\) starmapMorphSet\.clear\(\);`).MatchString(rebuild) {
|
||||||
|
t.Error("buildChatStarmapGraph 必须无条件清空 starmapMorphSet(`if (starmapMorphSet) starmapMorphSet.clear();`):" +
|
||||||
|
"旧 mesh 已从场景摘掉,却会留在活跃集里被永远遍历")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 标签层必须有节流(相机拖动时 camKey 每帧都变 → 否则每帧重建全量 plan)。
|
||||||
|
labels := extractFunc(t, src, "starmapDrawLabels")
|
||||||
|
if !strings.Contains(labels, "SM_LABEL_MIN_INTERVAL") {
|
||||||
|
t.Error("starmapDrawLabels 缺少节流:OrbitControls 有阻尼,拖动时每帧重建 3190 项 plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFunc 取一个顶层函数的源码体(到下一个行首 `}` 为止)。
|
||||||
|
func extractFunc(t *testing.T, src, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
marker := "function " + name + "("
|
||||||
|
i := strings.Index(src, marker)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("找不到函数 %s", name)
|
||||||
|
}
|
||||||
|
rest := src[i:]
|
||||||
|
end := strings.Index(rest, "\n}\n")
|
||||||
|
if end < 0 {
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
return rest[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDashboardJS(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile("dashboard.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user