Files
Shuery 3f11426964 feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源
把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
2026-10-02 01:27:02 +08:00

499 lines
31 KiB
Markdown
Raw Permalink 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.
# 透视场景对齐:现状与下一步(交接)
> 目标见会话 goal:让原神 4 页(camera type 1 + 真实景深)构图正确、与页面真实渲染对拍一致,
> 补一条能抓住取景/投影算错的门,然后撤掉 promote 对透视场景的默认拦截。
## 关键认识(2026-09-20 第 1 轮末):页面是**多动画 + 时间线驱动**的,静态快照对不上
用户提出"是不是和同个场景多个动画及其前后播放顺序有关"——**证实了,而且是两层**:
1. **每个 spine 节点自带播放指令**(`spine:{id, defaultAnimation, skin, timeScale, otherAnimation:[{track,animation}]}`)。
nico-tea 的 35 个 spine 节点里 8 个带 `defaultAnimation`;`scene_main` 里有 3 个:
`main_d_books → "in0"`、`main_nike → skin "b"`、`main_nike → skin "a"`。
我原来一律播 `data.animations[0]` + `default` 皮肤——姿态与页面不同(很多骨架的第一个动画是入场动画 `in`)。
2. **引擎有 timelineSetting**:按时间驱动 38 条属性,例如
`layout.position`、`img.scale`、`bei_e.position`、`inout.position`、`jiulaoshi.position`、
`main_slg.material.uniforms.opacity.value`、`main_btn.material.uniforms.opacity.value`。
**也就是说场景树里的 position/scale 只是某一时刻的值**——页面任意一帧的构图由时间线决定。
结论:**"与页面基准图一致"必须先定义"哪一帧"**。现在拿静态场景树 + 骨架首动画去对拍一张动态页面的截图,
本质上是在比两个不同时刻的画面。这是继续推进前要先定的事。
## 已落地
| 项 | 状态 |
| --- | --- |
| billboard 投影 `s = 1/(tan(fov/2)·(camZ−z))` | ✓ |
| 相机进预设 schema(`camera:{type,fov,position}`) | ✓ |
| 透视取景 = 视锥 = UI 矩形(投影单位 `uiW·s0 × uiH·s0`) | ✓ |
| **按相机深度从远到近绘制**(复刻页面的 z 缓冲) | ✓ **本轮关键修复**(构图由此出现) |
| **带旋转的倾斜面片不画**(`rotatedSkipped` 计数) | ✓(全场景只有 2 个,正是撑爆画面的那两块) |
| **按 `defaultAnimation` / `skin` / `timeScale` 播** | ✓ 已实现(抓取 → 预设 → 运行时);**视觉效果尚未验证**(需重启 dev 服重拍) |
| 诊断面 `content` / `solids` / `rotatedSkipped` | ✓ |
| promote 拦透视场景 | ✓(`--allow-perspective` 放行) |
| 页面真实渲染基准 | `tools/.cache/page-truth-2-main.png`(`.scratch/page-truth.mjs`) |
已**否证**:套 `cameraAdaptScreen` 的 `zoom = uiHeight/canvasHeight`(改完更放大,那是正交相机那条路用的)。
## 下一步(顺序)
1. **重启 dev 服并重拍**,确认 `defaultAnimation`/`skin` 那一步的视觉影响(截图 SHA 未变 = 没生效)。
2. **定义基准时刻**:与用户确认对拍的是哪一帧(进入后稳定态?时间线某一时刻?)。
建议:先把**时间线不驱动的属性**(未被 timelineSetting 点名的节点)对齐,被驱动的那些单独处理。
3. **先钉身份**:加"只画第 N 件"的调试开关,逐件与基准图对照,确定每个 part 对应画面里的什么
(现在连"城堡是 `main_zw_a`"都只是猜)。
4. 补门:内容中心落在视锥中心附近、内容/视锥尺寸比在合理区间。
5. 最后做倾斜面片的真四边形(4 角点过旋转 + 透视,用 `PolygonBatcher`)。
## 不要做的事
- 别调经验系数(权威值能从 bundle 读到,本轮就是这样读到 `aspect = uiWidth/uiHeight` 与 timelineSetting 的)。
- 别在门通过之前 promote 透视场景。
---
## 第 2 轮补充(2026-09-20)
**门已落地**:`tools/checks/verify-scene-player.mts` 新增透视样本(staged nico-tea),
在门里**独立算一遍**期望取景矩形(`s0 = 1/(tan(fov/2)·camZ)`、可见区 = `ui × s0`、再按画布比例 contain),
与运行时 `__sceneDebug.framing` 断言(相对容差 1e-6)。实测通过:4.6297 × 2.6042 两边一致。
这条门能抓住两类**真实发生过**的错:视锥用画布比例(差 1.30×)、取景用内容包围盒(差 4×)。
**角色为什么看不见**:`main_nike` 的页面坐标 y = −804.5、z = 849.7 → 投影后 y ≈ 2.67(半视锥是 1.30),
**整块在画面外**。所以本轮落地的 `defaultAnimation`/`skin`(`main_nike` 皮肤 b/a、`main_d_books` 播 `in0`)
虽然真的生效了,画面却**一个像素都没变**(截图 SHA 未变,已用 `Network.setCacheDisabled` 排除缓存因素)。
结论:**必须先解决"时间线把角色移进画面"这一层**,否则皮肤/动画这类改动无法验证。
**一个坑**:`preset.js` 是模块脚本,浏览器会复用缓存副本——改了预设却截出同一张图,白等一轮。
验收/截图脚本要 `Network.setCacheDisabled(true)` 或加 `?v=<timestamp>`。
**flaky 门**:`verify-scene-player` 曾报"失败 1 处"、重跑通过(疑似"隐藏画布前后截图不同"那条的时序)。
flaky 的门比没有门更糟,下一轮要加 settle/重试。
---
## 第 3 轮补充(2026-09-20):时间线的真实结构,以及读"实时世界坐标"的正确姿势
**时间线在哪**:nico-tea 的时间线表在**入口 bundle**(不在 vendors),由嵌套 merge 拼出来:
```js
mt = Fe(Fe(Fe(Fe(Fe({}, {loading:{type:1,start:0,name:"loading",loop:false,
cues:[{frame:91,method:"stop,退场同时播放page_cut动画"}], clips:[…]}}), …), …), …), …)
```
- 顶层时间线名:`loading` / `page_cut` / …(`loading` 是**入场序列**,第 91 帧有"退场并播 page_cut"的 cue)。
- **clip 可以嵌套**:`type:1` 的 clip 自带 `name`(如 `fbx`)与内层 `clips`,所以
"找 position 轨道"必须**递归**走 `clips`,只扫顶层会漏(我第一次就只捞到一个 `page_cut`)。
- 全页共 **16 个 clip、14 条 `.position` 轨道**:`layout` / `bg` / `bei_e` / `bei_f` / `bei_g` / `xl` /
`jiulaoshi` / `inout` / `play` / `d` / `c` / `b` / `a` …——**正是把角色与道具移进画面的那批**。
- 解析嵌套 merge 时 `Fe` 是**深合并**,用 `Object.assign` 顶替只能得到最后一个键(实测只出 `page_cut`)。
**读实时世界坐标:`__vue__` 这条路走不通**。生产版 Vue 2.7 不挂 `__vue__`(实测 `hasVue:false`)。
正确姿势是**在页面脚本执行前注入**(CDP `Page.addScriptToEvaluateOnNewDocument`):
- 包一层 `PerspectiveCamera` 构造,抓到相机实例(读它的 `fov/aspect/zoom/position` 就是权威值);
- 包 `Object3D.prototype.updateMatrixWorld`(或 `add`),按 `name` 建一张 `name → 世界矩阵` 表,
页面跑起来后直接读 `main_nike` / `main_zw_a` 等的**实时世界坐标**。
**为什么值得**:这一招能一次性回答三个悬着的问题——相机真实参数、每个 part 的真实世界坐标、
时间线到底把谁移动到了哪里。有了它,"静态场景树 vs 页面某一帧"的差就变成了可测的数字,
不用再靠猜或靠解析 48KB 的嵌套时间线字面量。
**建议顺序**(下一轮):先做注入钩子拿实时世界坐标 → 用它校正投影/摆放 → 再谈时间线的逐帧复刻。
---
## 第 4 轮补充:钩 WebGL 拿到**权威相机参数**(技术路线确定)
`__vue__` 走不通之后,换了个一定能成的办法:**在页面脚本执行前注入**
(CDP `Page.addScriptToEvaluateOnNewDocument`),包住
`WebGLRenderingContext.prototype.getUniformLocation / uniformMatrix4fv`,
把引擎每帧上传的矩阵按 uniform 名字记下来。实测抓到 8000 次上传、`projectionMatrix` 3 种、
`modelViewMatrix` 6014 种。三种投影矩阵:
| # | 形状 | 读出来的东西 |
| --- | --- | --- |
| 1 | 单位阵(m0=m5=1, m14=-1) | 某个中间 pass |
| 2 | **m0=1.536014, m5=3.555588, m11=-1, m10=-1.001001, m14=-20.01** | **fov = 31.417°(竖直)、aspect = m5/m0 = 2.3148 = 2500/1080**、near=10 / far≈20000 |
| 3 | m0=0.001042, m5=0.001852(正交) | 视口 **1920×1080**(宽高比 1.7778 = 画布比例)——**另一条 2D/UI pass** |
**结论一**:#2 证实了我这条路的取景模型——**透视相机的 fov 是竖直 31.417°、aspect 就是 `uiWidth/uiHeight`**
(不是画布比例)。这与第 1 轮从 bundle 里读到的 `new PerspectiveCamera(a.fov, s/l, …)` 互相印证。
**结论二(新)**:引擎还有**第二条正交 pass,跨度 1920×1080**(画布比例、1080 高)。
也就是说页面同时活在两个坐标空间里:3D 场景用 2500×1080 的透视空间,
UI/2D 层用 1920×1080 的正交空间。**场景节点属于哪个空间,是下一步要判的**——
`main_nike` 的 y=−804 在 2500×1080(y∈±540)里本来就在画面外,在 1920×1080 里更在外面,
所以它必然是被时间线移进来的,这条没变。
**结论三**:live 的 `modelViewMatrix` 里第一条是 `m14 = −1800`(相机 z≈**1800**),
而场景数据写的是 `position:[0,0,1920]`。差 6.7%,不是倍率问题,但值得记一笔。
**技术路线(下一轮直接用)**:这套注入钩子还能继续往下挖——
- 按 draw call 关联 `modelViewMatrix`(配合 `drawElements` 计数)就能拿到**每个对象的世界坐标**;
- 钩 `requestAnimationFrame` 可以**冻结时间源**,拿到确定的基准帧(与 `page-mirror` 规格是同一件事的两半)。
---
## 第 5 轮:一次"看起来成功"的假修复,与它带来的真结论
**试了什么**:从 live `modelViewMatrix` 里读到一条 `m14 = −2648.534`、缩放 1.379,
按"相机在 z=1800"反推出对象世界 z = **−848.5**,而场景树里 `main_nike` 的合成 z = **+849.66**——
大小几乎相等、符号相反。于是把深度改成 `camZ + z`(翻符号)。
**结果**:画面**戏剧性变好**——角色、茶桌、甜品架、城堡、标题全出现了,
构图第一次接近基准图。很容易就此收工。
**为什么必须撤回**:
1. **物理相反**:翻过来后天空(z=−1414)深度 = 506(**最近**)、角色(z=+849)深度 = 2770(**最远**),
等于把天空放到相机前面、角色放到后面。与"天空在最后、角色/桌面在前"直接矛盾。
2. **包围盒证据**:`content` 从 18.9×11.8 涨到 **40.9×17.4**(帧的 8.8 倍),
说明大量元素被错误放大——只是被巨大的天空盖住、看起来"丰富"。
3. 角色之所以出现,是翻符号后它的缩放变小、原点恰好落在画面上沿内侧一点点——**巧合,不是对齐**。
4. 那条 `modelViewMatrix` **很可能属于另一条正交 pass**(该 pass 相机在 z=1800,对象 z 是 UI 层自己的深度),
拿它推场景 z 的符号本来就不成立。
已撤回,`depth = camZ − z` 保持不变。
**真结论(这一轮的收获)**:
- 透视相机的 fov/aspect 已被 live 投影矩阵钉死(31.417° 竖直、aspect = 2500/1080)——**取景模型是对的**。
- 剩下唯一的大缺口仍是**时间线**:`main_nike` 的静态 y = −804.5,投影后 y ≈ +2.67(半视锥 1.30),
必然在画面外;基准图里它居中,说明入场时间线把它移动了约 **+800 页面单位**。
这与"14 条 `.position` 轨道"完全吻合。
**下一步(不再猜)**:用同一套注入钩子,按 draw call 关联 `modelViewMatrix` 与 `drawElements`,
**直接读出每个对象在页面里的实时世界坐标**(尤其 `main_nike`),
拿它当基准去校正"静态摆放 + 时间线位移"这条链。坐标一旦可读,对齐就是测量问题,不是猜测问题。
---
## 第 6 轮:读到页面的**实时世界坐标**(注入钩子按 draw call 关联 modelViewMatrix)
做法:注入时同时包住 `uniformMatrix4fv`(只记 `modelViewMatrix`)与 `drawElements`/`drawArrays`,
按"每个 draw 用最近一次上传的 MV"配对。相机无旋转,所以 MV 的平移列 = `对象世界坐标 − 相机坐标`。
实测 17668 条日志、8015 次 draw、7087 个不同 MV。
**关键读数**(按出现次数排序,截取):
| 次数 | scale | 世界坐标(= MV 平移 + 相机坐标) |
| --- | --- | --- |
| 290 | 1.000 | (0, 0, **−1800**) ← 纯视图矩阵 ⇒ **相机 z = +1800** |
| 82 | 22.320 | (0, −3.9, −2142.7) |
| 82 | 1.000 | (0, −223.0, −1920.0) |
| 68 | 1.379 | (0, 0.7, −2648.5) |
| 动画中 | 1.301 | (−98.9, **−863.8 → −840.3 → −827.5 → … → −555.6**, −2498.7) |
| 动画中 | 1.218 | (34.1, **−545.9 → … → −262.5**, −2338.4) |
| 动画中 | 0.106→0.341 | (1.0, 42.0, −1965.1) ← 缩放从小长大(入场) |
**结论一:相机 z = 1800,不是场景数据里的 1920。** 那条 290 次的单位缩放 MV 就是纯视图矩阵,
平移 = −相机坐标。所以 **`camera.position:[0,0,1920]` 不是 live 相机 z**(差 6.7%),以后一律用 1800。
**结论二:时间线正在把对象往上移——实测数据。** 两条轨道在世界坐标里连续变化:
y 从 −863.8 一路升到 −555.6(同一 x/z、scale 不变),另一条从 −545.9 升到 −262.5。
这就是入场动画,**上升方向 = +y**,速率约每帧几单位。上一轮推断的"时间线把角色移进画面"由此坐实。
**结论三:live 的 z 与我的合成 z 大小相等、符号相反。**
例:live z = −848.5(= MV −2648.5 + 1800)↔ 我的 `main_nike` 合成 z = **+849.66**。
上一轮我因为"天空会跑到相机前面"而否掉了翻符号——**但那条否证的前提(场景 z 就是 live z)现在被推翻了**:
live 相机 z 是 1800、且 live z 全为负。所以符号问题要重新审,不能再用旧前提否它。
**下一步**:把每个 draw 的世界坐标与 `scene.json` 的 part 逐个**配对**(x/y/z + scale 三元组匹配),
钉死"哪个 part 是哪个对象",然后:
1. 用 live 的 z(负)与相机 z=1800 重算深度;
2. 把时间线的**末态**(或某一确定时刻)也读出来(`requestAnimationFrame` 钩子冻结时间源),
得到确定的基准帧与基准坐标。
---
## 第 7 轮(关键):两条 pass 分开之后,一切对上了
**做法**:给每个 draw 记录**当时生效的投影矩阵**(是透视还是正交),把两条 pass 分开统计。
```
透视 pass(3D 场景,7.5k draws 里的绝大多数)
×218 scale=1.000 MV=( -4.0, -379.0, -1920.0) ↔ main_btn ( -4.00, -379.00, 0.00) ✓ 逐位
× 39 scale=1.000 MV=(-212.1, -844.1, -764.5) ↔ main_down (-212.07,-844.15, 1155.45) ✓ 逐位
×186 scale=22.300 MV=(0.0, -3.9, -2142.7)
×149 scale=1.400 MV=(0.0, 0.7, -2648.5)
× 84 scale=1.300 MV=(-98.9, -376.0, -2498.7) ← y 在动(入场)
× 84 scale=1.200 MV=(34.1, -58.2, -2338.4) ← y 在动
正交 pass(UI/2D)
×372 scale=1.000 MV=(0.0, 0.0, -1800.0) ← **这条就是上一轮误判的来源**
```
**结论一:透视相机 z = 1920**(与场景数据 `position:[0,0,1920]` 一致)。
验证:`main_btn` 的世界 z=0 → MV z = 0 − 1920 = −1920 ✓;`main_down` 的 z=1155.45 → MV z = −764.5 ✓。
**结论二:我的世界变换合成是对的**——两个 part 的 x/y/z **逐位命中**。
所以"静态摆放 + 透视投影 + 相机参数"这条链**已经正确**,不需要再动。
**结论三:第 5、6 轮两次误判的根源找到了**:把两条 pass 的相机混在一起看。
正交 pass 的相机在 z=1800,我拿它的 `(0,0,−1800)` 当"透视相机 z=1800",
于是推出"对象 z 符号相反"——**纯属跨 pass 误读**。
**结论四:唯一剩下的差异就是时间线。** 被动画驱动的是**容器节点**
(`layout` / `inout` / `bei_e…g` / `xl` / `jiulaoshi` / `play` / `d` / `c` / `b` / `a`),
叶子 part 跟着父容器走。所以:
**下一步(明确)**:把时间线**末态**的容器变换量出来(等入场动画跑完再读 MV,
或用 `requestAnimationFrame` 冻结时间源取确定时刻),把它作为"容器附加位移/缩放"
烘进预设——**摆放本身不用再推导,只要补这一个位移量**。
---
## 第 8 轮:改为**读源码**(用户要求:出问题先看来源网站源码,别盲猜)
### 引擎的权威实现(vendors bundle 原文)
```js
// 节点局部矩阵:只有 autoMatrix 为真才自动更新,否则算一次就冻结
a.matrixAutoUpdate = void 0 !== e.autoMatrix && e.autoMatrix;
a.matrixAutoUpdate || a.updateMatrix();
// 时间线:按路径取目标对象,**直接写**它的属性;属性在 matrixMatch 里则强制打开自动更新
var v = parsePath(i, this.name), g = v.object, y = v.target, w = v.prop;
g && (g.matrixAutoUpdate = g.matrixAutoUpdate || s.matrixMatch.includes(w));
```
### 三条权威语义(替代此前的猜测)
1. **节点局部矩阵 = `compose(position, rotation, scale)`**(three.js `updateMatrix`),
**默认冻结**;`autoMatrix:true` 或被时间线驱动 `position/scale/rotation` 的节点才逐帧重算。
→ 所以我"只合成位移与缩放、忽略旋转"对**叶子**是安全的(两片倾斜面片是叶子),
但对**有子节点的旋转节点**不成立。
2. **时间线是覆盖(`target[prop] = 值`),不是叠加。**
3. 因此"整场平移"= 某个**公共祖先容器**的 position 被时间线改写;该容器是所有相关 part 的祖先,
所以对每个 part 表现为**同一个位移**——与第 8 轮实测的 `Δ=(212.1, 365.1, −1123.5)` 完全吻合。
### 本轮同时落地的实现
- `sceneConfig.timelineOffset`(页面单位)进 schema:types / globals / generate / 运行时都支持;
运行时在投影前把它加到每个 part 的 position 上。
- nico-tea 的 `preset.template.json` 里已写入实测值 `[212.1, 365.1, -1123.5]`。
- 效果:`content` 包围盒从 18.9×11.8 收到 **7.58×3.62**(视锥 4.63×2.60),
画面与基准图明显接近(角色、茶桌、甜品架、城堡、标题各就各位)。
### 还差的(下一步,仍按"读源码"办)
- 实测到**分组位移**不完全相同:`desk/nike` Δ=(138, 394, −1066)、`root/btn` Δ=(206, 70, 19)、
`desk/xl` Δ=(−176, 271, −1123)——说明**不止一个容器**被时间线改写。
下一步:把时间线里每条 `.position` 轨道的**末帧值**读出来(源码里 `data[].frames` 就是关键帧值,
`getValue()` 返回 `target[prop]`),按容器分别落到 part 上,而不是一个全局值。
- 读源码时优先看 `parsePath` / `matrixMatch` 的定义,确认 `position` 轨道的目标对象到底是哪个容器。
---
## 第 9 轮:继续读源码——轨道的目标怎么解析,以及入场时间线的真实关键帧
### `parsePath`(vendors bundle 原文)
```js
parsePath(e, t) {
var n = t.split("."), r, i;
if (e && e.isObject3D) i = r = e.getObjectByName(n[0]); // ← 轨道名第一段是**节点名**
else r = e;
if (!r) return null;
for (var o = n.length - 1, a = 1; a < o; a++) if (!(r = r[n[a]])) return null;
return { target: r, object: i, prop: n[o] }; // 写的是 target[prop]
}
```
**结论**:`layout.position` 这种轨道名 = `getObjectByName("layout")` 找到节点 → 写它的 `.position`。
所以时间线**按名字**定位容器,与树路径无关。这解释了为什么我按 `path` 分组时
`wiggle/bg`、`wiggle/desk` 等会呈现出各自不同的位移——**它们各自被不同的同名节点驱动**。
### 入场时间线(`loading`)自己写的值
```
loading/fbx/layout.position 末帧 z = 539 (从 z=0.801 一路升上来)
loading/fbx/bg.position 末帧 y = 0 (从 y=−502.6 一路升上来)
loading/fbx/img.scale 末帧 = 1.023
cues: [{frame:91, method:"stop,退场同时播放page_cut动画"}]
```
即:入场时 `bg` 从 y=−502.6 升到 **0**、`layout` 的 z 从 0.8 升到 **539**。
这两条正是"整场在动"的来源,而**稳定态就是末帧值**。
### 为什么之前算不出全部轨道
`timelineSetting` 是**多个变量**深合并起来的(`var rt,ot,mt=Fe(Fe(…{loading:…}…), …)`),
我按 `mt=Fe(` 取到的那个表达式里只有一部分键(`page_cut` 等),其余时间线在**别的变量**里。
下一步:把 `Fe(` 合并链的每个参数分别取出来解析(或直接找所有 `\w+=\{[a-z_]+:\{type:\d+,start:` 的赋值),
拿到全部 14 条 `.position` 轨道的末帧值,按**节点名**落到对应容器上——
这就是"读源码"版本的正确做法,不需要再拟合任何偏移量。
---
## 第 10 轮:把时间线**按源码语义**接进摆放(取代拟合的偏移量)
### 拿到了全部 17 条轨道(末帧 = 稳定态)
```
layout.position [0,0,539] bg.position [0,0,0]
inout.position [0,0,-265] xl.position [736.665,52.619,-141.804]
bei_e.position [0,0,0] bei_f.position [103.18,174.725,-35.011]
bei_g.position [223.623,387.687,-179.149]
a/b/c/d.position (−389…420, −396…−410, ±5) ← 桌子上的四件
jiulaoshi/play [0,0,0] img.scale 1.023 btn_b/btn_c.scale 1
```
### 实现(不再是补丁式的偏移)
- 抓取期:`_collect_timeline()` 按 `name:"X.position"` 锚定 clip → 取 `data[-1].frames[-1]`(末帧)。
- 走场景树时:**节点名命中轨道就用末帧值覆盖**该节点的 position/scale(源码语义:`getObjectByName` + `target[prop] = 值`)。
- 上一轮那个拟合出来的 `timelineOffset` **已从预设里删掉**,不留两套机制。
实测效果:`main_nike` 的合成位置从 `(−64.65, −804.50, 849.66)` 变成 `(47.42, −312.91, 146.71)`
(时间线自己把它抬了 +492、拉近了 −703),画面上角色/书本/茶具/城堡/标题各就各位。
### 仍差一步(下一轮)
合成值 `(47.4, −312.9, 146.7)` 与 live 实测稳定值 `(147.4, −439.4, −273.8)` 还差 `(~100, ~126, ~420)`。两条线索:
1. `loading` 时间线在第 91 帧有 cue「stop,退场同时播放 page_cut」——
**25 秒时页面正在跑的是 `page_cut`**,它的轨道在**另一个变量**里(我只取到了 `loading` 那一支)。
把 `page_cut` 等其余时间线的轨道也取出来并**按先后覆盖**,才等于最终稳定态。
2. **基准帧本身要定**:`tools/.cache/page-truth-2-main.png` 是"点两下、等 7 秒"拍的,
当时登录弹窗还在、可能还没进 `scene_main` 的稳定态。整个目标以它为基准,
所以"哪一帧"这个决定必须先落地(用户此前未答)。
---
## 第 11 轮:基准帧确定下来了,`page_cut` 的交接仍是嫌疑
**不再等"哪一帧"这个决定,我自己取了确定的基准**:不点击、禁缓存、等 10/20/30 秒各拍一张,
落 `tools/.cache/page-settled-{10,20,30}s.png`。三张一致 ⇒ 页面在 ~10 秒后就稳定了,
所以 **`page-settled-30s.png` 就是稳定态基准**(比原来那张"点两下、7 秒、登录弹窗还在"的
`page-truth-2-main.png` 干净)。
**关键观察(这次能对齐判断了)**:稳定态基准里的元素与我渲染的**是同一批**——
居中偏右的尼可、她身后的城堡、左边的甜品架、带书本的茶桌、左上标题、右侧蓝色小精灵、右上三个圆按钮。
也就是说 **`scene_main` 选对了**(不是"选错场景"),差的仍是**摆放**:基准里角色居中且大,
我这边角色被顶到画面上沿、书本占据中央。
**因此剩下的嫌疑收窄成一条**:`loading` 时间线在第 91 帧 cue「stop,退场同时播放 page_cut」——
我应用的 17 条轨道只包含 `loading` 那一支;`page_cut`(以及 merge 链里其它变量的时间线)
的轨道没并进来,而 10 秒后页面跑的很可能正是后者。**按先后顺序覆盖**才是最终稳定态。
**注意**:`page-truth-2-main.png`(含登录弹窗与"进入活动"按钮)是同场景的**未稳定态**,
以后一律用 `page-settled-30s.png` 作对拍基准。
---
## 第 13 轮:与**来源代码的逻辑对照**(用户要求),并修掉最后一处取景差异
### 对照表(引擎源码 vs 我们的实现)
| 环节 | 引擎(源码原文/行为) | 我们的实现 | 判定 |
| --- | --- | --- | --- |
| 相机创建 | `new PerspectiveCamera(a.fov ‖ 32, uiWidth/uiHeight, near, far)`,position/rotation 来自数据 | 取 `camera.fov` + `camera.position[2]` 做 billboard 投影 | ✅ 一致(aspect 由 ui 决定,已用 live 投影矩阵验证) |
| 画布适配 | `resizeUI`:`b = canvasAspect/uiAspect; b<1 ? uiW*=b : uiH/=b`,再 `cameraAdaptScreen` 只作用于 **scene_ui 相机** | **本轮改成复刻 resizeUI**(此前用 contain,多露 1.302×) | ✅ 已对齐 |
| 节点矩阵 | `compose(position, rotation, scale)`;`matrixAutoUpdate = autoMatrix ?? false`,否则 `updateMatrix()` 一次(冻结) | 只合成位移+缩放;旋转不参与 | ⚠️ 已知差异(受影响的是两片倾斜面片,已跳过并计数) |
| 父子链 | 矩阵相乘 | 位移受父 scale 缩放、缩放连乘 | ✅ 等价(无旋转时) |
| 时间线取值 | 场景在 modifier 里声明 `playTimeline{sceneName, trackName, frame}`;`parsePath` 按**节点名**定位,`target[prop] = 帧值` | 按场景点名的**块**取轨道末帧,覆盖同名节点的 position/scale | ✅ 本轮修好(此前全局扫名字,拿错块) |
| 时间线播放顺序 | 块内 `cues` 在第 N 帧"stop,退场同时播放下一条"(`loading`@91、`pv`@150、`page_cut`@20) | 只取单一块的末帧,未做逐段推进 | ⚠️ 近似(稳定态可取,过渡态不可) |
| 绘制遮挡 | three.js z 缓冲(材质带 `depthTest/depthWrite`) | 按相机深度从远到近排序(画家算法) | ✅ 不透明情形等价 |
| 混合模式 | 材质 `blending`(three.js 枚举) | 一律普通混合 | ⚠️ 未实现(全项目仅 1 处非 Normal) |
| 第二条 pass | 正交 UI 层(1920×1080)另有一条渲染链 | 只做透视 3D pass | ⚠️ 未实现(UI 层不还原) |
### 本轮的取景修复
`resizeUI` 是引擎作者写的适配算法,直接照抄之后:
`framing` 从 `4.6297 × 2.6042`(contain 的过露)变成 **`3.5556 × 2.0000` = 1920×1080 页面单位**,
与第 4 轮量到的正交投影矩阵(1920×1080)逐位一致——两条独立证据互相印证。
门里的期望值也改成同一算法(仍是独立实现),门通过。
### 仍差的一步
画面元素与排布已经对上基准(角色/书本/茶杯/城堡/标题/小精灵),但**整体仍偏下一段**
(基准里标题在上方、我这边在中下)。下一轮优先验两件事:
1. **基准帧的态**:匿名页面 10 秒后停在入场态(`loading`/`pv` 之前),而我们还原的是 `scene_main`;
2. **逐段推进**:`pv` 在第 150 帧 cue 切 `page_cut`——若页面停在 `pv` 的**中段**,末帧值就不对。
---
## 第 14 轮:源码里一直写着「按第几帧」,我漏了它
### 漏掉的字段
```js
{ id: "playTimeline", data: { sceneName: "scene_main", trackName1: "pv", frame1: 0, … } }
```
`frame1: 0` 就是**该轨道要停在第几帧**。我一直取轨道**末帧**,等于假设它播到了结尾。
### 用算术验证(不需要浏览器)
```
pv/fbx/layout.position 首帧 = [-0.487, 0.647, 2.136] 静态 = [-212.073, -358.588, 1141.952]
delta = (+211.586, +359.235, −1139.816)
main_nike 静态 (−64.65, −804.50, 849.66) + delta = (146.9, −445.3, −290.2)
live 实测稳定值(第 6 轮钩 WebGL 量的) = (147.4, −439.4, −273.8)
```
**逐位吻合**(x 差 0.5、y 差 6、z 差 16)。也就是说:`scene_main` 的稳定态 = `pv` 在**第 0 帧**的值,
而我们此前的"末帧"取值本身就错了。残差那十几单位应当来自 part 自己那一层的轨道(`bei_f/bei_g` 等)。
### 落地
`_collect_timeline` 现在解析 `frameN` 并与 `trackNameN` 配对,取该帧的关键帧值(越界回退首/末帧)。
抓取结果:`main_nike = (146.9, −445.3, −290.2)`,与 live 实测一致——**纯源码推导,无拟合量**。
### 仍然存在的差异(下一轮)
渲染出的元素与排布已与基准一致(角色/书本/茶杯/城堡/甜品架/标题/小精灵),
但**整体仍比基准低约 250px**。候选原因(按可能性):
1. **y 方向**:`flipY` 目前是"取反";若基准对应的是不取反 + 另一组偏移,会呈现为整体上/下移。
2. **相机看向点**:我用 `camera.position` 与"看向 -z";若引擎实际用 `lookAt` 对准别的点,会整体平移。
3. **基准帧的态**:匿名页面可能停在入场态(`loading` 那一支)而不是 `scene_main` 的 `pv@0`。
---
## 达成(2026-09-21):对象级对拍定位到「y 方向」,改完构图与基准一致
### 最后一步的铁证(对象级对拍 = 页面当预言机)
用注入钩子拿页面自己的 `P × MV`,把每个对象投到**屏幕像素**(画布 1664×936 → 按 720/936 缩放对齐):
| 对象 | 页面屏幕坐标 | 我们的 | 差 |
| --- | --- | --- | --- |
| `main_btn`(世界 y=−379) | **(638, 612)** | (637, **107**) | x 差 1px,**y 完全镜像** |
| `main_nike`(世界 y=−445) | **(706, 616)** | (725, **102**) | 同上 |
x 逐像素吻合说明"摆放 + 投影 + 相机 + 取景"**全对**;y 是镜像说明 `flipY` 那次取反是多余的——
页面投影本身就把「世界 y 向上」映成「屏幕 y 向下」(`(1 − ndc.y)/2`)。
默认改成不取反(需要时写 `flipY: true`)后,渲染与 `page-settled-30s.png` 的构图一致:
角色居中持杯、城堡右后、标题左上、甜品架左、茶桌与书在底部、蓝色小精灵右。
### 收尾
- **promote 拦截已撤**(验收:不带任何标志 `promote` 退出码 0)。
- 门全绿:`pnpm check`(6 分发自包含)、`verify-scene-player`(含透视取景门)、
`check-downloader`、`verify`(7 页 101 骨架 85.5 MB)、`diff-project`(新增 nico-tea 档 + 属性差异,符合预期)。
- 单骨架路径(xilian/kv37)**一行未动**,冻结基线不受影响。
### 留给以后的两条方法论(比这次的代码更值钱)
1. **遇到问题先读来源源码**——本目标的每一处真修复都来自源码(`getCamera`、`resizeUI`、`parsePath`、
`matrixAutoUpdate`、`playTimeline{trackName,frameN}`),而两处"看起来成功"的假修复都来自猜。
2. **用页面当预言机做对象级对拍**——`uniformMatrix4fv` + `drawElements` 钩子能给出
每个对象的实时世界坐标与屏幕坐标;"整体偏了"这种模糊症状一旦变成带符号的像素差,
结论就是唯一的(这次 x 差 1px / y 镜像,一步定案)。
### 仍未做的(不在本目标范围内,另立即可)
- 倾斜 3D 面片(全场景 2 个)的真四边形绘制;粒子/摆动/`CSS3DObject` 等 modifier;
材料混合模式(全项目 1 处非 Normal);正交 UI 层的还原(站点外壳,按设计不做)。