Files
Robot_Arm/docs/查看器性能优化报告.md
T

206 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 机器人手臂3D查看器 · 性能优化报告
- 目标文件:`机器人手臂3D查看器.html`(15,314,130 → 15,316,745 字节)
- 优化日期:2026-09-29
- 优化前版本标识:`v1_2026-09-29_2151`(原件最后写入时间)
- 备份:`backups/机器人手臂3D查看器.v1_2026-09-29_2151_优化前.html`
- SHA-256(原件=备份,已校验一致):`1BDA568269842C403D0FB5D63B7610818BE2DDC28C3E7B9B90498349A3B1252E`
- 优化后 SHA-256:`4F672CD8A66107D3EFDE6CD55FDC2C2CD878CBF64140F8497FB8CA3F2BC9B57D`
- 回滚:`Copy-Item backups/机器人手臂3D查看器.v1_2026-09-29_2151_优化前.html 机器人手臂3D查看器.html`(原件为只读属性,已按原状恢复)
---
## 一、摘要(实测数据)
同一台机器、同一个浏览器(Edge,160 Hz 显示器)、同一份插桩脚本,改前 / 改后各测 2 次:
| 指标 | 改前 | 改后 | 变化 |
| --- | ---: | ---: | ---: |
| **空闲 5 s 渲染次数** | 800 / 800 | **0 / 0** | **−100%** |
| 空闲渲染 CPU 时长 | 271.4 / 289.9 ms | **0 / 0 ms** | −100% |
| 单次滑块输入后 500 ms 渲染次数 | 82 / 82 | **1 / 1** | −98.8% |
| 进入「运动模拟」后 4 s 渲染次数 | 626 / 617 | **103 / 104** | −83.5% |
| 按钮求解后 4 s 渲染次数 | 626 / 622 | **103 / 102** | −83.6% |
| 30 次 pointermove 的射线拾取次数 | 30 / 30 | **9 / 9** | −70% |
| 30 次 pointermove 的拾取总耗时 | 13.1 / 19.3 ms | **5.2 / 4.4 ms** | −60% ~ −77% |
| 索引缓冲内存 | 4,123,320 B(Uint32×92) | **2,061,660 B(Uint16×92)** | **−2.00 MiB(−50%)** |
| draw call / 三角面 | 92 / 340,178 | 92 / 340,178 | 不变(画质不变) |
| 约 20 s 会话总渲染次数 | 2576 / 2564 | **299 / 296** | −88.4% |
| 约 20 s 会话渲染 CPU 总时长 | 1114 / 1267.9 ms | **143.9 / 205.6 ms** | −84% ~ −87% |
| 首帧时间(导航→首帧) | 691 / 749 ms | 771 / 518 ms | 噪声内(中位数 720 → 645 ms) |
| DOMContentLoaded | 685 / 745 ms | 766 / 512 ms | 噪声内 |
一句话:**空闲时不再空转满帧渲染(160 次/s → 0),交互时才渲染且每次状态变化恰好渲染 1 次**;拾取与几何内存同步下降;渲染输出(draw call、三角面、截图逐像素)与改前一致。
功能验证:改前 / 改后跑同一份 18.5 s 全项自检脚本,**每一项结果逐字相同**(含 92 零件、72 螺钉、BOM 高亮 18 颗与定位跳转、爆炸/线框/半透/包络开关、装配图↔三维切换、IK 求解残差 1.1e-13 / 1.8e-13 mm、姿态控制、右键隐藏与撤销)。截图对比:`scratch/perf/shots/before_0*.png` 与 `after_0*.png` 中模型视图、装配图、螺钉高亮三张**字节数完全相同**(357630 / 338438 / 347999),运动视图仅动画时序差 167 字节。
---
## 二、改动清单
共 6 处代码改动(成品 HTML 内 19 个 diff 块),全部集中在查看器主逻辑与 IK glue,**未触碰任何数据行与求解器脚本**。
### 1. 按需渲染(核心)
- 问题:`loop()` 无条件每次 rAF 都 `resize() + updCam() + rend.render()`;`needRender` 变量被 `requestRender()` 置位后**全文件没有任何读取点**(改动前 grep 仅 3 处:声明、置位、注释),等于恒以显示器刷新率空转满帧渲染 34 万三角面 ×92 draw call,并且 `preserveDrawingBuffer:true` 会让每帧多一次缓冲拷贝。
- 改法:
```js
function loop() {
if (needRender && cv.style.display !== 'none') {
needRender = false;
resize();
updCam();
rend.render(scene, cam);
}
requestAnimationFrame(loop);
}
```
- 关键约束:**保留 `preserveDrawingBuffer:true`**(不重绘时靠它保住最后一帧画面);**保留 rAF 循环**(入场动画 `animTo`、实时追踪、拖拽都依赖后续帧渲染);装配图页画布隐藏时**不清标志**,切回三维页由 tab 处理器 `resize()+requestRender()` 补渲染。
- 安全性核对:改动前逐一核对全部渲染状态变更点均已调用 `requestRender()`(查看器主逻辑 36 处;glue 的 `refreshTarget()` / `setJoints()` / `highlightSpec()` / `setView()` 内部均调用),窗口尺寸变化由 `ResizeObserver → resize() → requestRender()` 覆盖。
- 顺带收益:`resize()` 读 `stage.clientWidth/clientHeight` 会造成强制布局,旧版每帧都读,现在只在真正渲染前读一次。
### 2. 悬浮拾取限流 + 零分配
- 问题:射线要对 34 万三角面全量求交(实测 0.5~1.5 ms/次),旧版每个 `pointermove` 都算(rAF 合帧后仍≈刷新率次数);且每次 `meshes.filter(...)` 都新建数组 + `new THREE.Vector2`。
- 改法:`schedHover()` 加 **60 ms 时间配额 + 位置去重**(指针静止时一次都不算,尾随仍补最新位置);`pick()` 复用模块级 `pickList` 数组与 `ndcPick` 向量,可见性仍逐次重算。
- 注意:three r159 的 `Raycaster` **不检查 `object.visible`**(已在 `web/three_inline.js` 源码确认),因此可见性过滤必须保留。
- 点击 / 右键拾取不经过 `schedHover`,仍是即时求交,交互判定不受影响。
### 3. `applyJoint()` 去分配
- 问题:每次调用新建 2 个 `Vector3` + 3 个 `Matrix4`,且用 `JO.find` 线性查找;一次关节写入 = 6 次调用 = 30 个临时对象,动画/实时追踪每帧一批。
- 改法:轴心 `JP` / 轴向 `JD` 预计算一次;矩阵合成改用 3 个共享 `Matrix4` 实例。数学与原式逐位相同(`T(P)·R(D,θ)·T(−P)`)——场景图残差复算结果与改前完全一致(1.1e-13 / 1.8e-13 mm)即为证据。
### 4. 索引缓冲降位(Uint16)
- 问题:92 件零件索引一律 `Uint32Array`。
- 改法:按顶点数选择 `p.v.length <= 65535 ? Uint16Array : Uint32Array`(实测单件最大顶点 47,483,全部走 Uint16),索引内存 4.08 MB → 2.00 MB。
### 5. BOM 高亮只记一行
- 问题:点选 / ESC 时 `for (const tr of tb.children) tr.style.background = ''` 把整张表(13 行零件 + 10 行螺钉)刷一遍。
- 改法:新增 `bomHlRow` / `bomHlClear()`,只清理上一次高亮的那一行,设置高亮时同步记录。可见结果完全一致。
### 6. Worker Blob URL 回收
- 问题:`new Worker(URL.createObjectURL(new Blob([...])))` 不回收,常驻一份 ~86 KB Blob;Worker 构造失败路径同样泄漏。
- 改法:先取 URL,`try { new Worker(url) } finally { URL.revokeObjectURL(url) }`(URL 只在构造时被读取)。
---
## 三、验证方法与证据
### 3.1 静态校验(`scratch/perf/verify_edit.py`)
- 4 个内联 `<script>` 的 SHA-256 对比:
- `[0] three.js`:**SAME**(9a4c1f2f…)
- `[1] 数据+查看器`:DIFF(改动区)
- `[2] IK 求解器`:**SAME**(49e969dc…)——指纹依赖的求解器源码一字未动
- `[3] IK glue`:DIFF(仅第 6 项那一处)
- 与备份逐行 diff:**19 个改动块,全部落在上述 6 项改动内**;`DATA` / `SHEETS` / `TRAVEL` / `COLLIDE` 数据行零改动。
- `node --check`:改后的查看器主脚本(14.1 MB)与 glue 脚本均语法通过;模板补丁后的脚本同样通过。
### 3.2 运行期一致性
| 检查项 | 改前 | 改后 |
| --- | --- | --- |
| 预置自检指纹(`chkSig()` 当前值) | `3e03f22b` | **`3e03f22b`(未变)** |
| IK 残差(闭式 / 场景图) | 1.1e-13 / 1.8e-13 mm | **1.1e-13 / 1.8e-13 mm** |
| 零件 / 螺钉 / draw call / 三角面 | 92 / 72 / 92 / 340,178 | 相同 |
| 关节矩阵元(J2 滑块 25°→夹到 7.867°) | 7.1441 | 7.1441 |
### 3.3 功能对照自检(`scratch/perf/func_check.js`,18.5 s 全项)
改前 / 改后逐项结果**完全相同**:首屏 92 网格 / 91 可见;零件树 30 行、BOM 13 行、螺钉表 10 行;J2 滑块(含动态限位夹取到 7.867°)与画面重绘;爆炸 60% 最大位移 129.6 mm 且「装配视图」回位;线框 91 开 / 0 关;电机半透 0.34;螺钉组 72/72 隐藏与恢复;可达包络显示;复位相机;悬浮 tooltip 命中 #11 小臂骨架;BOM S01 行点击→18 颗高亮、行 `.scrHl` 标记、HUD「第 1/18 处」→ 再点「第 2/18 处」→ ESC 全清;装配图↔三维切换且画布内容完好;运动模拟「已到达」、6 行关节表、6 个互异构型;自检面板文本;姿态控制(取当前 / 垂直向下 / ball,head,knob 拖拽把手);视图拾取模式;右键隐藏 1 件 + 撤销恢复;全选恢复 92 件。
截图:`scratch/perf/shots/`(before/after 各 4 张)。
### 3.3b 交互路径补充探针(`scratch/perf/interact_check.js`,改前 / 改后各跑 1 次)
针对"按需渲染是否会漏帧"这一风险点,逐条验证交互后的即时重绘(画布 64×36 降采样指纹对比):
| 交互 | 改前 | 改后 |
| --- | --- | --- |
| 轨道拖拽(pointerdown+10×pointermove) | Δaz −0.64、Δpo −0.213、画面重绘 ✓ | 完全相同 |
| 滚轮缩放 | Δrd −159.9、画面重绘 ✓ | 完全相同 |
| 爆炸滑块连续拖拽(7 次 input) | 最大偏移 226.8 mm、状态栏「爆炸 70%」、画面重绘 ✓ | 完全相同 |
| 线框开关点击 | 91 件线框、画面重绘 ✓ | 完全相同 |
| 关节滑块连续拖拽(4 次 input) | 见下方说明 | 完全相同 |
| 目标球拖拽 | 见下方说明 | 完全相同 |
说明:表中"关节滑块 dAng=0"与"目标球 moved=0"两项在**改前改后结果逐字相同**,属合成事件测试的固有现象(合成 pointerdown 无活动指针,`setPointerCapture` 抛错中断了抓取判定;且当时手臂姿态经动态限位夹取后 J3 只允许 ±0.06°),**不是回归**——同一探针在改前文件上得到同一结果即为证据。真实鼠标操作不受此影响(`screenshot` 与 func_check 中的姿态控制、拖拽把手、视图拾取等均已通过)。
### 3.3c URL 参数脚本化视图(`?view=motion&tgt=150,128,320&j1=20&expl=40&env=1&xray=0&hide=小臂`)
改前 / 改后返回的 12 项状态**逐字相同**:view=motion;tgt=[150,128,320];J1 经动态限位夹取到 19.09°;J2=0;运动模拟下自动回到装配状态(expl=0);可达包络已显示;电机半透已关(opacity 1);「小臂」相关 8 个网格全部隐藏;徽标「已到达」;画布/图纸显示状态正确。
### 3.4 性能插桩(`scratch/perf/harness.js` + `make_measure.py`)
插桩副本(`measure_before.html` / `measure_after.html`)只多一个尾部 `<script>`,不改变原有 4 个脚本索引,因此指纹逻辑不受影响。包装 `rend.render` 统计次数与 CPU 时长、包装 `hover` 统计拾取次数与耗时,探针覆盖空闲 / 滑块 / 动画 / 求解 / 悬浮扫过;结果同时输出到页面浮层、`document.title` 与 console,便于截图与读取。
环境:Edge(Chromium)+ `--headed`,`visible`、`rafHz≈160` 均已记录(若非前台标签页 rAF 会被节流,数据无效,故测量必须在可见窗口进行)。
---
## 四、经实测否决 / 未做的优化
| 候选 | 实测 | 结论 |
| --- | --- | --- |
| `scene.updateMatrixWorld(true)` 改 6 级关节链更新 | 全量强制刷新仅 **0.004 ms/次**(≈110 对象) | 绝对值太小,不值得引入风险,保留原实现 |
| `updXform()` / `updStat()` 优化(92 件零件的 reduce/filter/拷贝) | 微秒量级 | 不做 |
| 材质合并 / 几何合并 | 只有 92 个 draw call,合并会破坏零件树勾选显隐、点选与单独高亮 | 不做 |
| 分片 / 延迟建场景 | 初始化约 106 ms(JSON.parse 69 ms + 建几何 37 ms),分片不减少总时间 | 不做 |
| 降低 `antialias` / `setPixelRatio` 上限 | 属画质取舍 | 保留原样 |
| 实时追踪时对结果面板 DOM 节流 | `renderResult()` 含"场景图残差复算"与"备选构型"两块**验证语义**内容 | 保留原样,避免削弱验证可观测性 |
| 按需渲染是否该去掉 `preserveDrawingBuffer` | 不重绘时画布会被清空 | 必须保留 `true` |
---
## 五、重要发现与后续建议
### 5.1 预置自检指纹在**优化前就已经不匹配**(非本次改动引入)
原件打开「结构与精度自检」会显示:`预置结果已失效:预置指纹 47141160 ≠ 当前 3e03f22b(模型或求解器已变更)`。
说明文件里的 `PRECOMPUTED_CHK`(生成时间戳 `2026-09-29 14:37`)是**更早状态**下算出的,而文件在 21:51 又被改动过。本次优化前后该值都是 `3e03f22b`,未使情况变化。
建议:点面板里的「重新计算」(本机分片实测,约 20–40 s),或按文件内注释所述用离线脚本重算,把新的 FK/IK/姿态/工作空间结果与指纹写回 `PRECOMPUTED_CHK`。
### 5.2 生成器源文件与成品已漂移(**在完成反向同步前不要重跑构建**)
`src/build_viewer.py` 由 `src/viewer_tpl.html` + `web/ik-glue.js` + `web/arm-ik-solver.js` + `web/mesh_arm.json` 拼装成品,但成品比源文件新:**Worker 化求解、`PRECOMPUTED_CHK` 预置自检、`refreshJointLabelsLazy` 节流、±360° 滑块**等只存在于成品里(`web/ik-glue.js` 仍是同步求解的旧版,776 行 vs 成品 961 行)。
本次处理:按约定**直接优化成品**,并把性能补丁同步回 `src/viewer_tpl.html`(补丁前副本:`backups/viewer_tpl.html.v1_2026-09-29_性能补丁前.bak`)。`web/ik-glue.js` 中不存在本次改动的对应代码(无 Worker、无拾取/渲染循环/几何构建),故无需同步。
**在把成品里的新代码反向同步回模板/glue 之前,重跑 `python src/arm_model.py` 或 `build_viewer.py` 会把成品回退到旧功能(并丢掉本次性能优化)。**
### 5.3 内存与泄漏审计结论
- 几何:`p.v / p.t / p.s` 在转成 typed array 后立即 `delete`,嵌套 JSON 数组可被回收(保留的只有 92 份 position/index 缓冲);索引缓冲本次减半。
- 运行时 DOM:IK 结果表与备选构型每次求解重建,旧节点随闭包一起被 GC;`hidePart` 的「撤销」按钮用属性赋值而非 `addEventListener`,无监听器累积。
- Worker:单例 + Blob URL 已回收;`IKWjob` 每次覆盖,无排队泄漏。
- `performance.memory`(`usedJSHeapSize`)在本次环境波动较大(改前 145–291 MB,改后 264–421 MB,GC 时机主导),不作为结论性指标;确定性内存指标用索引缓冲字节数。
---
## 六、复现方法
```powershell
# 1) 本机静态服务器(把 /v.html /mb.html /ma.html 等别名映射到对应文件)
python scratch/perf/serve.py 8123
# 2) 生成插桩副本(源 HTML → 尾部追加 harness.js)
python scratch/perf/make_measure.py "机器人手臂3D查看器.html" "scratch/perf/measure_after.html" after
# 3) 浏览器打开 http://127.0.0.1:8123/ma.html ,约 25 s 后读取 #perfOut(或 document.title)
# 注意:必须用可见窗口(headed),后台标签页的 rAF 会被节流导致数据无效
# 4) 静态校验(脚本哈希 + diff 范围)
python scratch/perf/verify_edit.py
# 5) 功能对照自检:把 scratch/perf/func_check.js 用 base64 喂给浏览器 eval,
# 约 19 s 后读取 window.__FC
```
测量脚本均在 `scratch/`(已被 `.gitignore` 忽略),成品文件保持干净、无任何插桩代码。