把项目从「手写 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 处引用自包含。
31 KiB
透视场景对齐:现状与下一步(交接)
目标见会话 goal:让原神 4 页(camera type 1 + 真实景深)构图正确、与页面真实渲染对拍一致, 补一条能抓住取景/投影算错的门,然后撤掉 promote 对透视场景的默认拦截。
关键认识(2026-09-20 第 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)。 - 引擎有 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(改完更放大,那是正交相机那条路用的)。
下一步(顺序)
- 重启 dev 服并重拍,确认
defaultAnimation/skin那一步的视觉影响(截图 SHA 未变 = 没生效)。 - 定义基准时刻:与用户确认对拍的是哪一帧(进入后稳定态?时间线某一时刻?)。 建议:先把时间线不驱动的属性(未被 timelineSetting 点名的节点)对齐,被驱动的那些单独处理。
- 先钉身份:加"只画第 N 件"的调试开关,逐件与基准图对照,确定每个 part 对应画面里的什么
(现在连"城堡是
main_zw_a"都只是猜)。 - 补门:内容中心落在视锥中心附近、内容/视锥尺寸比在合理区间。
- 最后做倾斜面片的真四边形(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(翻符号)。
结果:画面戏剧性变好——角色、茶桌、甜品架、城堡、标题全出现了, 构图第一次接近基准图。很容易就此收工。
为什么必须撤回:
- 物理相反:翻过来后天空(z=−1414)深度 = 506(最近)、角色(z=+849)深度 = 2770(最远), 等于把天空放到相机前面、角色放到后面。与"天空在最后、角色/桌面在前"直接矛盾。
- 包围盒证据:
content从 18.9×11.8 涨到 40.9×17.4(帧的 8.8 倍), 说明大量元素被错误放大——只是被巨大的天空盖住、看起来"丰富"。 - 角色之所以出现,是翻符号后它的缩放变小、原点恰好落在画面上沿内侧一点点——巧合,不是对齐。
- 那条
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 是哪个对象",然后:
- 用 live 的 z(负)与相机 z=1800 重算深度;
- 把时间线的末态(或某一确定时刻)也读出来(
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));
三条权威语义(替代此前的猜测)
- 节点局部矩阵 =
compose(position, rotation, scale)(three.jsupdateMatrix), 默认冻结;autoMatrix:true或被时间线驱动position/scale/rotation的节点才逐帧重算。 → 所以我"只合成位移与缩放、忽略旋转"对叶子是安全的(两片倾斜面片是叶子), 但对有子节点的旋转节点不成立。 - 时间线是覆盖(
target[prop] = 值),不是叠加。 - 因此"整场平移"= 某个公共祖先容器的 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)。两条线索:
loading时间线在第 91 帧有 cue「stop,退场同时播放 page_cut」—— 25 秒时页面正在跑的是page_cut,它的轨道在另一个变量里(我只取到了loading那一支)。 把page_cut等其余时间线的轨道也取出来并按先后覆盖,才等于最终稳定态。- 基准帧本身要定:
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)逐位一致——两条独立证据互相印证。
门里的期望值也改成同一算法(仍是独立实现),门通过。
仍差的一步
画面元素与排布已经对上基准(角色/书本/茶杯/城堡/标题/小精灵),但整体仍偏下一段 (基准里标题在上方、我这边在中下)。下一轮优先验两件事:
- 基准帧的态:匿名页面 10 秒后停在入场态(
loading/pv之前),而我们还原的是scene_main; - 逐段推进:
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。候选原因(按可能性):
- y 方向:
flipY目前是"取反";若基准对应的是不取反 + 另一组偏移,会呈现为整体上/下移。 - 相机看向点:我用
camera.position与"看向 -z";若引擎实际用lookAt对准别的点,会整体平移。 - 基准帧的态:匿名页面可能停在入场态(
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)一行未动,冻结基线不受影响。
留给以后的两条方法论(比这次的代码更值钱)
- 遇到问题先读来源源码——本目标的每一处真修复都来自源码(
getCamera、resizeUI、parsePath、matrixAutoUpdate、playTimeline{trackName,frameN}),而两处"看起来成功"的假修复都来自猜。 - 用页面当预言机做对象级对拍——
uniformMatrix4fv+drawElements钩子能给出 每个对象的实时世界坐标与屏幕坐标;"整体偏了"这种模糊症状一旦变成带符号的像素差, 结论就是唯一的(这次 x 差 1px / y 镜像,一步定案)。
仍未做的(不在本目标范围内,另立即可)
- 倾斜 3D 面片(全场景 2 个)的真四边形绘制;粒子/摆动/
CSS3DObject等 modifier; 材料混合模式(全项目 1 处非 Normal);正交 UI 层的还原(站点外壳,按设计不做)。