Skip to content

状态: 🟢 已修复

日期: 2026-07-16 严重程度: 🔴 P1 影响范围: frontend/src/thumbnail-capture.ts, frontend/src/library-core.ts, frontend/src/ui-resource-panel.ts发现方式: 用户反馈 修复方案: 5 处修复——独立 THUMB_FOV / 缓存键加分辨率与宽高比 / 手动冻结投影矩阵 / 场景缩略图复用主相机 / 舞台卡片跨两列

缩略图宽高比连环坑:从FOV误解、投影矩阵时序到UI网格自适应

日期: 2026-07-16 严重程度: 🔴 P1(缩略图被压扁+场景全屏天空盒+UI舞台卡片同宽,三重视觉缺陷) 影响范围: thumbnail-capture.ts(渲染)+ library-core.ts(缓存键)+ ui-resource-panel.ts(UI网格) 发现方式: 用户观察 + 审计对比 v1.4.1


问题描述

一次看似简单的"缩略图对焦拉近距离"修改,引发五个串联缺陷:

  1. FOV 与系数互相抵消fov = scene.activeCamera ? scene.activeCamera.fov : 0.8 导致 fallback 0.8 永远无效(主相机存在时直接复用其 FOV),AI 反复调 0.8 无效后误改主相机聚焦
  2. 缩略图分辨率调整不生效:设 4K 后截图仍是 512×288,因为缓存键不含分辨率
  3. 角色缩略图被横向压扁:contentAspect=0.364 远窄于 RT 的 0.666,画面水平压缩
  4. 场景缩略图拍到整个天空盒:重新计算 bounding box 被远景 mesh 污染,导致相机远到离谱
  5. 舞台 UI 卡片与角色同宽:16:9 横屏卡片和 2:3 竖屏卡片占据相同列宽,舞台缩略图缩成窄条

排查时间线

第一坑:FOV fallback 死代码

typescript
// AI 写的"复用主相机 FOV"逻辑
const fov = scene.activeCamera ? scene.activeCamera.fov : 0.8;

表面看:合理——有主相机就复用,没有就 fallback 0.8。 实际:主相机永远存在(3D 场景启动就创建),所以 0.8 永远不走。AI 反复修改 0.8 这个"死值"来调焦距,毫无效果。绝望之下开始改主相机的聚焦参数,把正常的主场景相机也改坏了。

根因:三元表达式的条件永远为 true,fallback 分支是死代码。正确做法是缩略图相机使用独立 FOV(如 0.5 长焦),不与主相机耦合。

第二坑:缓存键缺分辨率

用户设 4K(×8 分辨率)截图仍是 512×288。

根因thumbnailKeyForModel() 生成的缓存键只有 filePath,不含分辨率。切换分辨率后缓存命中旧图,永远不会重新截图。

typescript
// 修复前
return `${key}`;

// 修复后:分辨率 + 宽高比都进 key
const res = resolution ?? uiState.thumbnailResolution ?? 512;
const aspect = isStage ? '16/9' : '2/3';
return `${key}::${res}::${aspect}`;

第三坑:投影矩阵时序——Canvas 16:9 覆盖 RT 2:3

角色缩略图横向压扁。诊断数据:

contentAspect: 0.364  (内容实际宽高比)
rtAspect:      0.666  (RT 目标 2:3)
contentBox:    148×407 (内容区域)
rtSize:        341×512 (渲染目标尺寸)

contentAspect 远小于 rtAspect,说明内容被水平压缩了。

根因:Babylon.js 计算投影矩阵时,使用 engine.getAspectRatio(camera) 获取宽高比。但此时主 Canvas 的 framebuffer 仍绑定(16:9),而非刚绑定的 RT(2:3)。引擎拿到的是 Canvas 的 16:9 而非 RT 的 2:3,所以投影矩阵按 16:9 计算,内容被压扁。

修复:手动构造投影矩阵并冻结,绕过引擎的时序问题:

typescript
const projMatrix = new Matrix();
Matrix.PerspectiveFovLHToRef(
    THUMB_FOV,
    rtW / rtH,  // 用 RT 的实际宽高比
    thumbCam.minZ,
    thumbCam.maxZ,
    projMatrix,
    true
);
thumbCam.freezeProjectionMatrix(projMatrix);

第四坑:场景 bounding box 被远景 mesh 污染

场景缩略图拍到整个天空盒,角色变成远处的点。

根因:对场景 mesh 计算包围盒时,远景/天空盒 mesh 也参与计算,导致 bounding box 极大。曾尝试用中位数阈值过滤,但大型场景的中位数本身就是远景 mesh,IQR 剔除也不可靠。

修复:场景缩略图不复算 bounding box,直接复用主相机的 position + target。主相机已经由用户或默认逻辑调好了场景聚焦,直接拿来用最可靠。

第五坑:UI 网格不区分横竖屏

舞台卡片(16:9)和角色卡片(2:3)在 UI 中同宽。

根因:网格布局 grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)) 对所有卡片一视同仁,不考虑宽高比差异。舞台 16:9 被塞进 100px 宽的列,高度只有 56px,变成窄条。

修复:舞台卡片 grid-column: span 2,跨越两列,宽度翻倍以匹配 16:9 比例。


修复清单

#缺陷文件修复
1FOV fallback 死代码thumbnail-capture.ts独立 THUMB_FOV 常量,不读主相机
2缓存键缺分辨率library-core.tskey 加入 ::res::aspect
3投影矩阵用错宽高比thumbnail-capture.ts手动构造 + freezeProjectionMatrix
4场景包围盒远景污染thumbnail-capture.ts复用主相机 position/target
5舞台卡片同宽ui-resource-panel.ts16:9 卡片 grid-column: span 2

教训

1. 三元表达式的"死分支陷阱"

typescript
const x = alwaysTrue ? readFromGlobal : hardcodedValue;

当条件几乎永远为 true 时,hardcodedValue 变成死代码。后续维护者修改 hardcodedValue 以为能生效,实际毫无作用。替代方案:使用独立常量,不要用三元"兼容"两个不可能共存的分支。

2. 离屏渲染的 Aspect Ratio 时序

Babylon.js(以及多数 WebGL 引擎)的投影矩阵计算发生在 onBeforeRender 阶段,此时 framebuffer 绑定状态取决于引擎内部调度。手动冻结投影矩阵是绕过时序问题的可靠手段。

3. 缓存键必须包含所有影响输出的参数

分辨率、宽高比、甚至渲染质量设置——只要改变输出结果,就必须进缓存键。否则用户改设置后看到旧缓存,以为功能坏了。

4. 复用主相机比重新计算更可靠

场景缩略图的"正确对焦"是个开放问题(bounding box 计算受远景污染、大型场景中位数失效)。而主相机已经解决了这个问题(用户或默认逻辑已调好),直接复用就是最可靠的方案。

5. 网格布局的宽高比适配

auto-fill + minmax 的 CSS Grid 对所有子元素一视同仁。当子元素有不同宽高比需求时,需用 grid-column: span N 让宽幅元素跨列。