refactor(model): 重构P03为轻量化板式桁架并统一M4×16自攻螺钉连接方案

This commit is contained in:
cbb
2026-10-02 04:39:12 +08:00
parent 208b37048b
commit 6fe97fc643
29 changed files with 139069 additions and 1296 deletions
@@ -0,0 +1,258 @@
# 机器人手臂3D查看器 · 性能优化方案
目标文件:`G:\Robot_v2\机器人手臂\机器人手臂3D查看器.html`(15.31 MB / 35986 行)
用户要求:先做完整备份(带版本标识)→ 优化 → 验证功能完好 → 记录优化项与性能数据。
---
## 一、现状分析(实测,非猜测)
### 1.1 文件结构
| 位置 | 内容 |
| --- | --- |
| L1~L420 | HTML + CSS + 界面骨架(左栏/IK 控制条/舞台/页脚/BOM 抽屉) |
| L421~L428 | `<script>` three.js r159 内联(652 KB,UMD build,无 CDN) |
| L429~L33251 | `<script>`:`DATA`(13.99 MB 网格 JSON)+ `SHEETS`(52 KB SVG)+ `TRAVEL` + `COLLIDE` + 查看器主逻辑(`const TL = {}` 起 738 行) |
| L33252~L35022 | `<script>`:`arm-ik-solver.js` 逆运动学求解器(1770 行,自带 `window.ArmIK`) |
| L35023~L35984 | `<script>`:`ik-glue.js` 界面胶水层(961 行,Worker 化求解、预置自检、姿态拖拽) |
四个内联脚本的**索引被 `chkSig()` 指纹逻辑依赖**(`document.scripts[2]` = 求解器源码),因此本次优化**不得增删 `<script>` 标签、不得改动求解器脚本块**,否则预置自检指纹失效(会误报"预置结果已失效")。
### 1.2 数据规模(node 实测)
- 零件 92 件(打印件 9 + 电机 6 + 螺钉 72 + 包络 1 + 经纬线 1)
- 顶点 354,697 / 三角面 340,178 / 线段 10,296
- **单件最大顶点 47,483(< 65536 → 索引可全部用 Uint16)**
- `JSON.parse` 69 ms;按查看器原样建几何(含 `computeVertexNormals` / 包围盒球)37 ms
- 全场景射线拾取(92 件、34 万面):**1.3 ms/次**(60 次均值),200 次连测 2.1 ms/次
- `scene.updateMatrixWorld(true)` 全量强制刷新:**0.004 ms/次**(≈110 个对象)
### 1.3 已定位的性能问题
| # | 问题 | 证据 | 影响 |
| --- | --- | --- | --- |
| **P1** | **rAF 循环无条件每帧 `resize() + updCam() + rend.render()`**;`needRender` 被 `requestRender()` 置位后**全文件没有任何读取点**(`Grep needRender` 仅 3 处:声明、置位、注释) | L33207~L33215 | 空闲时仍 60 fps 满帧渲染 34 万面 ×92 draw call;`preserveDrawingBuffer:true` 使每帧多一次缓冲拷贝;持续占用 GPU/功耗 |
| **P2** | `resize()` 每帧读 `stage.clientWidth/clientHeight` | 同上 | 每帧强制布局(forced reflow),与 P1 同源 |
| **P3** | `pick()` 每次 pointermove 先 `meshes.filter(m => m.visible)` 再全量射线求交 | L32668~L32673、L32642~L32652 | 每帧≥1 次 1.3~2.1 ms 求交 + 每帧一个 92 元素临时数组,鼠标扫过模型时持续吃 CPU |
| **P4** | `applyJoint()` 每次新建 2 个 `Vector3` + 3 个 `Matrix4`,并线性查 `JO.find` | L32506~L32518 | 每写一个关节角 = 6 次调用 = 30 个临时对象;动画/实时追踪每帧一批 |
| **P5** | 几何索引一律 `Uint32Array` | L32531、L32546 | 索引显存 340,178×3×4 B = 4.08 MB(Uint16 只需 2.04 MB) |
| **P6** | 点选/ESC 时 `for (const tr of tb.children) tr.style.background = ''` 全表刷 style | L32727、L32736、L32745 | 每次点击写 ~92 行样式 |
| **P7** | Worker 的 Blob URL 不回收(`URL.createObjectURL` 后无 `revokeObjectURL`) | L35510 附近 | 常驻一份 ~86 KB Blob;构造失败路径同样泄漏 |
### 1.4 经实测**否决**的优化(不做,写进报告避免后人重复劳动)
- **全量矩阵刷新**:0.004 ms/次 → 改"6 级关节链更新"只有 13× 相对提速但绝对量 4 μs,风险不值。
- **`updXform()`(92 件)/ `updStat()`(92 件 reduce+filter)**:量级为微秒,爆炸滑块拖动时也远低于 1 ms。
- **材质合并 / 几何合并**:只有 92 个 draw call,合并会破坏"零件树勾选显隐 + 点选 + 单独高亮"这些核心功能。
- **分片/延迟建场景**:初始化总计约 106 ms(69 解析 + 37 建几何),分片只会增加复杂度,不降低总时间。
- **实时追踪时的结果面板 DOM 节流**:`renderResult()` 含"场景图残差复算"与"备选构型"两块**验证语义**内容(用户此前专门建立了指纹/预置自检体系),节流会削弱验证可观测性,保持原样。
### 1.5 关键发现:源文件与成品已漂移(影响改动范围)
`src/build_viewer.py` 由 `src/viewer_tpl.html` + `web/ik-glue.js` + `web/arm-ik-solver.js` + `web/mesh_arm.json` 拼装成品,但:
- 成品 L35023~L35984 的 glue 区域(961 行)**比 `web/ik-glue.js`(776 行)新**:Worker 化求解、`PRECOMPUTED_CHK` 预置自检、`refreshJointLabelsLazy` 节流、`±360°` 滑块等**只存在于成品内**。
- 成品主逻辑区与 `src/viewer_tpl.html` 的差异只有:① `±360°` 滑块范围(成品新)② `/*__SCREWS__*/` 占位符展开(预期)。
**已与用户确认的处理方式**:本次**直接改成品 HTML**;同时把"能对得上"的性能补丁**同步回 `src/viewer_tpl.html`**(源文件落后部分保持原状)。在源文件完成反向同步之前**不要重跑 `build_viewer.py`**(会把成品回退到旧功能)——此警告写入报告。
---
## 二、改动清单
### 改动 0:备份(先做,且必须先验证备份完整)
1. 新建目录 `backups/`,复制原件为
`backups/机器人手臂3D查看器.v1_2026-09-29_2151_优化前.html`
(v1 = 优化前版本号;2151 取自原件最后写入时间 2026-09-29 21:51)
2. 记录 `SHA-256` 与字节数(`Get-FileHash`),校验备份与原件一致后再动原件。
3. `.gitignore` 追加 `backups/`,避免 15 MB 备份污染 git 状态。
### 改动 1(P1+P2,**核心**):按需渲染
文件:成品 `loop()`(L33207~L33215)与 `src/viewer_tpl.html` 对应段。
```js
/* 改动前 */
function loop() {
resize();
if (cv.style.display !== 'none') { updCam(); rend.render(scene, cam); }
requestAnimationFrame(loop);
}
```
```js
/* 改动后 */
/* 按需渲染:needRender 由 requestRender() 置位(所有交互/状态变化都已调用它);
旧版置位后无人读取,故恒以 60 fps 空转。装配图页(画布隐藏)不清标志,
切回三维页由 tab 处理器 resize()+requestRender() 补渲染。 */
function loop() {
if (needRender && cv.style.display !== 'none') {
needRender = false;
resize();
updCam();
rend.render(scene, cam);
}
requestAnimationFrame(loop);
}
```
要点:
- **保留 `preserveDrawingBuffer:true`**:按需渲染后画布不再重绘,必须靠它保住最后一帧图像。
- **保留 rAF 循环**:`animTo()` 动画、实时追踪、拖拽都依赖后续 rAF 里的渲染。
- 已核对全部渲染状态变更点均有 `requestRender()`(主逻辑 36 处;glue 的 `refreshTarget()`/`setJoints()`/`highlightSpec()`/`setView()` 内部均调用),无遗漏。
- `resize()` 仍在渲染前调用一次(进入渲染路径时同步画布尺寸),窗口尺寸变化由 `ResizeObserver → resize() → requestRender()` 覆盖。
### 改动 2(P3):拾取零分配 + 悬浮限流
文件:成品 L32642~L32682 与 `src/viewer_tpl.html` 对应段。
- `pick()`:`meshes.filter(...)` → 复用模块级数组
```js
const pickList = [], ndcPick = new THREE.Vector2();
function pick(e) {
const r = cv.getBoundingClientRect();
ndcPick.set(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1);
ray.setFromCamera(ndcPick, cam);
pickList.length = 0;
for (const m of meshes) if (m.visible) pickList.push(m); // 语义与 filter 完全一致
const hit = ray.intersectObjects(pickList, false)[0];
return hit ? hit.object : null;
}
```
(three r159 的 Raycaster **不检查 `object.visible`**,已在源码中确认,故可见性过滤必须保留。)
- `schedHover()`:rAF 节流 → **60 ms 时间配额 + 位置去重**(实测 1.3 ms/次求交,60 次/s → ~16 次/s):
```js
let hoverPending = false, lastHoverEv = null, hoverT = -1e9, hoverX = -1, hoverY = -1;
const HOVER_MS = 60;
function schedHover(e) {
if (e.clientX === hoverX && e.clientY === hoverY) return; // 未移动不重算
hoverX = e.clientX; hoverY = e.clientY; lastHoverEv = e;
if (hoverPending) return;
hoverPending = true;
setTimeout(() => {
hoverPending = false;
if (!lastHoverEv || drag) return;
hoverT = performance.now(); hover(lastHoverEv);
}, Math.max(0, HOVER_MS - (performance.now() - hoverT)));
}
```
尾部仍会补一次(指针停住后取最新坐标),tooltip 行为不变;点击/右键拾取仍**立即**执行,不受限流影响。
### 改动 3(P4):`applyJoint()` 去分配
```js
const JP = {}, JD = {};
for (const j of JO) { JP[j.id] = new V3(...VC(j.rear)); JD[j.id] = new V3(...VC(j.d)).normalize(); }
const _mT = new THREE.Matrix4(), _mR = new THREE.Matrix4(), _mTi = new THREE.Matrix4();
function applyJoint(id) {
const P = JP[id], D = JD[id]; if (!P) return;
const a = (ang[id] || 0) * Math.PI / 180;
JG[id].matrix.copy(_mT.makeTranslation(P.x, P.y, P.z)
.multiply(_mR.makeRotationAxis(D, a))
.multiply(_mTi.makeTranslation(-P.x, -P.y, -P.z)));
JG[id].matrixWorldNeedsUpdate = true;
requestRender();
}
```
数学与原式逐位相同(`T(P)·R(D,θ)·T(−P)`),仅把每次新建的 5 个对象换成预计算 + 3 个共享矩阵。
### 改动 4(P5):索引降位(Uint16)
```js
const sIdx = p.v.length > 65535 ? new Uint32Array(p.s) : new Uint16Array(p.s); // line 分支
const idx = p.v.length > 65535 ? new Uint32Array(p.t.length*3) : new Uint16Array(p.t.length*3);
```
全部 92 件顶点数 ≤ 47,483(实测),Uint16 合法;索引内存 4.08 MB → 2.04 MB,上传带宽减半。保留 >65535 分支作安全兜底。
### 改动 5(P6):BOM 高亮行只记一行
新增 `let bomHlRow = null; function bomHlClear() { if (bomHlRow) { bomHlRow.style.background = ''; bomHlRow = null; } }`,替换 L32727、L32736、L32745 三处全表 `tr.style.background = ''` 循环;设置高亮处同步记录 `bomHlRow = tr`。可见结果完全一致。
### 改动 6(P7):Worker Blob URL 回收
```js
const u = URL.createObjectURL(new Blob([src + boot], { type: 'application/javascript' }));
let w; try { w = new Worker(u); } finally { URL.revokeObjectURL(u); }
```
(URL 只在 Worker 构造时被读取,构造后即可回收;失败路径也不再泄漏。此改动**仅成品有**——`web/ik-glue.js` 仍是旧同步求解版,无 Worker。)
### 改动 7:源文件同步(用户已确认)
- `src/viewer_tpl.html`:逐条应用改动 1~5(该文件对应代码段存在,锚点为唯一代码串;`±360°` 差异行不与改动区重叠)。
- `web/ik-glue.js`:核对后逐条应用(预计仅"渲染相关"部分不适用——拾取/循环/几何都在模板里;Worker 相关不存在)。
- 同步后**不重跑 `build_viewer.py`**(会回退旧功能),源文件只作为"补丁已留存"。
### 改动 8:文档
新建 `docs/查看器性能优化报告.md`(与 `docs/姿态估计算法评估报告.md` 同目录同风格,中文):优化项逐条说明(问题/证据/改法/收益)、前后实测数据表、功能验证清单与结论、已否决项、备份路径 + SHA-256、**"源文件漂移未修,勿重跑构建"**警告。
---
## 三、假定与决策
1. 只改代码区,**不动** `DATA` / `SHEETS` / `TRAVEL` / `COLLIDE` 数据行与求解器脚本(指纹依赖)。
2. 不新增/不拆分 `<script>` 标签(`document.scripts[2]` 索引依赖)。
3. 不改 `antialias` / `setPixelRatio(min(dpr,1.5))` / 灯光 / 材质参数——属画质取舍,且实测 draw call 与面数不构成瓶颈。
4. 测量用的"插桩副本"只放在 `scratch/perf/`(已被 `.gitignore` 忽略),成品文件保持干净。
5. 备份放项目内 `backups/`(用户已选),并写入 `.gitignore`。
---
## 四、验证方案
### 4.1 静态校验
1. `backups/` 内备份与原件 `SHA-256` 一致(改前)。
2. 抽取改后文件的 4 个 `<script>` 块到 `scratch/perf/`,逐个 `node --check` 通过。
3. 求解器脚本块 + `TRAVEL` / `COLLIDE` 数据行改前/改后**字节一致**(保证指纹)。
4. 全文 diff 只落在预期的 8 处锚点内。
### 4.2 功能验证(浏览器,改前/改后各跑一遍对照)
用 `scratch/perf/serve.py`(`SimpleHTTPRequestHandler`,把 `/v.html` 映射到项目根的真实成品)起 `http://localhost:8123/v.html`,用浏览器自动化逐项核对并截图:
| 项 | 判据 |
| --- | --- |
| 首屏加载 | 无 JS 报错;三维模型正常显示 |
| 关节滑块 | 拖动后手臂实时跟随、角度标签同步 |
| 爆炸滑块 / 装配视图按钮 | 模型散开与复位正确 |
| 线框 / 电机半透 / 螺钉 / 可达包络 / 复位 | 逐项开关生效 |
| 装配图页 ↔ 三维页切换 | SVG 显示、切回后三维画布重新渲染(验证"隐藏分支不清标志") |
| 运动模拟页 | 进页即求解并动画到位;IK 表格/徽标/备选构型正常 |
| 姿态控制 | 开关、取当前、垂直向下、箭头拖拽可用 |
| 自检面板 | 显示"指纹 … 匹配";FK/IK/场景图残差仍为 1e-13 mm 量级(验证改动的数学等价性) |
| BOM 螺丝行点击 | 高亮 + 相机对焦 + HUD 文案(昨天的功能) |
| 悬浮 tooltip / 右键隐藏 / H 键 / ESC | 行为一致 |
| 相机预设 / 拖拽旋转 / 滚轮缩放 | 行为一致 |
| URL 参数(`?view=motion&tgt=…&j1=…&hide=…`) | 脚本化视图仍生效 |
### 4.3 性能前后对比(插桩副本,同一浏览器同一机器)
生成 `scratch/perf/measure_before.html`(备份 + 尾部追加插桩脚本)与 `measure_after.html`(改后 + 同一插桩脚本),插桩内容:包装 `rend.render` 计次与计 CPU 时长、`rend.info`、`performance.memory`、导航时序、交互探针(滑块 input、`IK.setView('motion')`+`IK.run()` 动画、20 次 pointermove 拾取计时),结果输出到页面固定 div + `document.title` 以便截图/读取。
预期指标:
| 指标 | 改前(预期) | 改后(预期) |
| --- | --- | --- |
| 空闲 5 s 渲染次数 | ≈300 | ≈0~1 |
| 单次 render CPU 时长 / draw call / 三角面 | 基线 | 相同(画质不变) |
| 20 次 pointermove 拾取总耗时 | ≈26~42 ms | ≈7~11 ms(限流 + 零分配) |
| 索引显存 | 4.08 MB | 2.04 MB |
| 加载(navigation→首次渲染、DOMContentLoaded→load) | 基线 | 持平或略优 |
| 交互后渲染次数(滑块/动画) | — | 每次变化 ≥1 次,无丢帧 |
### 4.4 回滚
`Copy-Item backups/机器人手臂3D查看器.v1_2026-09-29_2151_优化前.html 机器人手臂3D查看器.html`(并同步回源文件的 `git checkout`)。
---
## 五、执行顺序
1. 备份 + 校验 + `.gitignore`(改动 0)
2. 起静态服务器 + 跑改前功能对照与改前性能插桩(拿到基线数据)
3. 改成品 HTML(改动 1→6,逐处 Edit,锚点唯一串)
4. 静态校验(4.1)
5. 改后功能对照 + 改后性能插桩(4.2/4.3)
6. 同步源文件补丁(改动 7)
7. 写报告(改动 8)