Files
SpineWallpaper/.scratch/scene-player/perspective.md
T
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

31 KiB
Raw Blame History

透视场景对齐:现状与下一步(交接)

目标见会话 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 拼出来:

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 原文)

// 节点局部矩阵:只有 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 原文)

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 轮:源码里一直写着「按第几帧」,我漏了它

漏掉的字段

{ 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 层的还原(站点外壳,按设计不做)。