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 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+498
View File
@@ -0,0 +1,498 @@
# 透视场景对齐:现状与下一步(交接)
> 目标见会话 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 层的还原(站点外壳,按设计不做)。
+161
View File
@@ -0,0 +1,161 @@
# 规格:场景播放器(一档壁纸 = 一整页场景)
> ⚠ 本文写于 ADR 0008 之前:下面的 preset 示例里 `./images/…`、`./effects/<名>/…` 是**当时的布局**,
> 现在读作 `./scene/…`、`./spines/<名>/…`(字段与规则没变,只是目录名换了)。
> 状态:**已落地**(2026-09-20)。抓取侧 6 页 74.15 MB 已落 staging,`verify` 绿。
> 门:`node tools/checks/verify-scene-player.mts`(夹具绿 + atlas 指错必红 + 还原重新变绿)。
> 本文只覆盖播放器与预设 schema,不含 promote 与文案。
## 落地记录(与规格的三处差异)
1. **取景参考矩形改成"所有 part 变换后的并集包围盒"**,不是 `ui` 矩形。
页面相机看向的是**场景原点**,而 `ui` 矩形的原点是它的左下角——直接拿它当可见区会把整个场景推向右上。
旧项目用的也是并集包围盒。`ui` 只留作字段,不再参与取景。
2. **`flipY` 的实测结论**:`position[1]` 取反(默认)即可让 kv45 的场景正立;
`scaleY` **不取反**(骨架本身在 spine-webgl 下渲染就是正的)。实测截图见
`tools/.cache/scene-player-shot.png`。
3. **glTF 网格平面的尺寸已补上**(同日):抓取期解 bundle 里的 `geometries` 表(`position.array` 是扁平 xyz),
算顶点包围盒 → `geometrySize` / `geometryCenter` 落进 `scene.json` 与 preset。实测这些网格都是中心为零的四边形;
kv45 的 `w22_slg` 其实是 `geometry.type: 2`(自带 config 尺寸),不受影响。
## 仍未做(按价值排序)
1. **modifier(`wiggle` / `BEZIER_PARTICLE` 粒子 / `CSS3DObject` DOM 层)**——真正的观感缺口:
粒子与摆动现在一律静态化。这是一个 feature 级工程(页面插件系统 + 贝塞尔粒子参数 + wiggle 数学),
值得单独立规格。
2. **纯色平面(`kind: "solid"`)不画**:它没有贴图,而 SceneRenderer 只提供 `drawTexture`/`drawSkeleton`
(传 undefined 会直接崩,已修)。数量进 `__sceneDebug.solids`,验收断言它能被看见;
要真画就得走 `ShapeRenderer` 那条路。
3. **页面 material 的混合模式**:实测 `blending` 只有 `1`(Normal)与 `2`(Additive,6 页共 1 处)。
`PolygonBatcher.setBlendMode(mode, pma)` 存在、映射也清楚(three.js 枚举 → spine BlendMode),
但收益只有一个节点,**暂不做**。
4. 旋转合成(抓取期与运行时都只记录)。
5. promote 进 `wallpapers/`(需要壁纸文案与音源清单)。
## 一、目标
让一档壁纸能播放"整页场景":**N 具骨架 + M 块贴图平面**,按抓取期定下的世界变换与绘制层级合成。
单骨架路径(`spineConfig`,xilian / kv37)**一行不动**——它被 `tools/shots/baseline-pre-refactor/`
的冻结基线钉着,`verify-visual-equivalence.mts` 靠它判等。
非目标(本轮):
- 不换 vendored 包(`src/vendor/spine-player.js` 是 spine-ts 4.2 线)、不引三方依赖;
- 不做几何平面的动画(`BEZIER_PARTICLE` 粒子等先静态化);
- 不做 promote(等本规格定案;验证走临时夹具,不碰 `wallpapers/`);
- 不做旋转合成(与抓取期一致:`rotation` 只记录不参与,`stats.rotatedNodes` 报数量)。
## 二、数据契约(抓取侧已产出,别再改形状)
`tools/downloader/_out/<游戏>/<页面>/`:
| 文件 | 内容 |
| --- | --- |
| `scene.json` | `{version, id, ui, camera, parts[], stats}`;part = `{kind, id, order, renderOrder, position[3], scale[3], localPosition?, localScale?, rotation?, geometryType?, modifiers?, runtime?}` |
| `spine/<id>/<id>.json` | 骨架(`skeleton.images` 已归一化为空串) |
| `spine/<id>/<id>.atlas` | 第一行 = 贴图页名,与落盘文件名逐字一致 |
| `spine/<id>/meta.json` | `spine` 版本 / `animations` / `skins` / `pages` / `originalImages` |
| `scene/<id>.<ext>` | `kind: "image"` 的平面贴的图 |
| `page.json` | 来源 URL、入口脚本、场景清单、警告 |
- `kind: "solid"`(`USE_TEXTURE == 0` 的纯色平面)**没有资源**,只按几何尺寸画一块颜色。
- `"runtime": true` 的 image part 是**由骨架渲染进贴图缓冲**的平面(`cacheContainer`),
运行时不下载也不画(它本来就是骨架的中间结果)。
- 世界变换语义:`world_position = parent_position + parent_scale ⊙ local_position`、
`world_scale = parent_scale ⊙ local_scale`;`order` = 树序遍历序(绘制层级)。
## 三、预设 schema
**单骨架(现状,不动)**
```json
{
"backgroundImage": "./images/ava.jpg",
"spineConfig": { "jsonUrl": "./effects/x.json", "atlasUrl": "./images/x.atlas",
"animation": "idle", "viewport": { "padLeft": "-25%" } }
}
```
**场景(新)**
```json
{
"backgroundImage": "./images/cover.jpg",
"sceneConfig": {
"ui": [2500, 1080],
"parts": [
{ "kind": "image", "id": "main_sky_jpg", "image": "./images/main_sky_jpg.jpg",
"order": 0, "renderOrder": 0, "position": [0, 0, 0], "scale": [1, 1, 1] },
{ "kind": "spine", "id": "main_nike",
"jsonUrl": "./effects/main_nike/main_nike.json",
"atlasUrl": "./images/main_nike/main_nike.atlas",
"animation": "眨眼",
"order": 3, "renderOrder": 0, "position": [-120.5, 480.25, 0], "scale": [1.09, 1.09, 1] }
]
}
}
```
约定:
- `spineConfig` 与 `sceneConfig` **二选一**;同时出现时**构建期直接报错**(不做静默优先级,歧义会烂在产物里)。
- part 的摆放与层级**由构建从 `scene.json` 抄进 `preset.js`**(构建期烘焙),运行时只读 `preset.js`:
分发自包含、`check:dist` 已经会校验 preset.js 引用的文件都在,且避免运行时多一次 sidecar 探测。
`scene.json` 留在抓取器 `_out/` 作留档与重跑凭据。
- 每个 part 的路径都用现有 `asset()`(`import.meta.url` 推导)解析;缺文件由构建 fail-fast + `check:dist` 兜住。
- `animation` 缺省 = 该骨架的第一个动画(清单在 `spine/<id>/meta.json` 的 `animations` 里,抓取期已记)。
- `Preset` 接口新增可选 `sceneConfig`;`generatePresetModule` 新增一个分支(与 `spineConfig` 对称)。
## 四、运行时
新增 `src/runtime/scene-controller.ts`,**不改** `spine-controller.ts`:
- 画布与渲染器:用 vendored 包已导出的 `ManagedWebGLRenderingContext` + `SceneRenderer`
自建(已核实这些名字都在 UMD 导出表里:`SkeletonJson` / `TextureAtlas` /
`AtlasAttachmentLoader` / `AnimationState` / `AssetManager` / `SceneRenderer` / `SpineCanvas` /
`ResizeMode` / `OrthoCamera` / `ManagedWebGLRenderingContext`)。
**不经过 `spine.SpinePlayer`**——`config.draw` 只在宿主骨架画完之后调用(vendored 包 15128 行),
画不出"位于宿主下面的层",而 nico-tea 的 `scene_main` 第一件就是贴图平面。
- 加载:每个 part 一个 `AssetManager`(或 `SkeletonJson` + `TextureAtlas` + `AtlasAttachmentLoader`)。
贴图直通 alpha、`premultipliedAlpha=false`(沿用现有渲染约定)。
- 每帧:逐 spine part `AnimationState.update(delta)` → `apply(skeleton)` →
`updateWorldTransform(Physics.update)`;然后 `renderer.begin()` → 按 `order` 依次
`drawTexture(...)`(image/solid)/ `drawSkeleton(skeleton, pma, ..., transform)` → `renderer.end()`。
- fps 门控:WE 不替壁纸限流,沿用现有"包一层排帧函数"的做法(自持循环后更简单)。
- 取景:`ui` 矩形(如 2500×1080)按等比 contain 映射到画布,再套现有背景比例链
(`viewport-fitter.frameForAspect`)——与单骨架的构图行为保持一致,`framing: "author"` 仍然生效。
- y 轴:页面是 y-up(three.js),spine 是 y-down;抓取期 `scene.json` 的坐标保持页面原样,
方向在渲染时统一处理(先按"整体 y 取反"实现,实测后定)。
- 就绪与失败:`window.__sceneDebug = { parts, loaded, errors, framing }` 供验收断言;
加载失败**显式记录**,不静默降级。
## 五、验证(不碰 `wallpapers/`)
1. `tools/checks/verify-scene-player.mts`(新):
- 临时目录里放一份**生成的** scene preset + staged 的 `kv45`(2.8 MB、4 骨架 + 1 平面,最小样本)
的资产拷贝 + 构建出的 `dist/scripts/spine-player.js`;
- 起一个临时静态服务(复用 `tools/serve.mjs` 的思路),用现有 CDP 管线(`tools/cdp.mjs`)打开;
- 断言:`__sceneDebug.parts` 全部 loaded、`errors` 为空、0 未捕获异常、画布非空、
取景矩形包含 parts 的变换后包围盒。
2. **先证明它会红**:故意把某个 part 的 `atlasUrl` 指错 → 断言错误被报出来、且 `loaded < parts`。
3. 现有门必须全绿:`pnpm check`(含 `check:dist` 自包含)与 `verify-visual-equivalence`
(单骨架基线,**不许动**)。
## 六、风险
| 风险 | 处置 |
| --- | --- |
| y 轴方向 / 页面相机(type 1 透视 fov、type 2 正交)不一致 | 先用正交近似;拿 nico-tea(camera type 1)实测,必要时把相机参数也抄进 preset |
| 页面 material 的混合模式(`【相加】`/`【滤色】` 等) | 先支持 NORMAL / ADD,其余记进 `page.json` 的 warnings,不假装还原 |
| 粒子与 modifier(`BEZIER_PARTICLE` / `wiggle` / `CSS3DObject`) | 本轮静态化,只画 diffuse 贴图 |
| 单骨架路径被误伤 | 新控制器独立文件;预设二选一由构建期报错保证;基线对拍门 |
## 七、待你定的三件事
1. **part 变换抄什么**:直接抄 `scene.json` 的世界变换(我建议;简单、且抓取期已按引擎语义算过),
还是抄 local + 树结构让运行时自己合成(更忠实,但运行时更复杂、且要复刻引擎语义)?
2. **`backgroundImage` 从哪来**:场景里没有"整页背景"这一件,但 WE 面板预览与单骨架路径都要它。
建议抓取期另挑一张全屏图当封面(或把场景第一张全屏 image part 复用为 backgroundImage)。
3. **验证样本**:先用 staged 的 kv45 走临时夹具(不碰 `wallpapers/`),还是直接 promote 一页
(那就需要你给壁纸的 `name` / `title` / `description` / 音源清单)?