Skip to content

被压扁的证件照

背景:缩略图对焦改造后角色被横向压扁、场景拍到天空盒、舞台卡片与角色同宽。 过程:五层连环坑——FOV 死分支、缓存键缺参数、投影矩阵时序错、远景包围盒污染、网格不辨横竖。


模型库的证件照又出事了。

上一次是空框——照片拍好了但没贴上去。这一次更诡异:照片贴上去了,但人的脸被横向压扁,像从门缝里偷看的侧面。而场景的照片更离谱——整张天空盒缩成一粒米,悬在画面正中央。

"我上次明明修好了拍照时序,"外交官盯着屏幕上那张扁脸说,"whenReadyAsync 等到位了才咔嚓,纹理齐全,骨骼就绪。怎么还是扁的?"

"你修的是'什么时候拍',"桌面壳把诊断数据推到他面前,"不是'怎么拍'。"

contentAspect: 0.364。rtAspect: 0.666。两个数字像两把尺子,量出了同一个物体的两种宽度——差了将近一倍。

"0.364 是照片里角色的实际宽高比。0.666 是相机的目标宽高比——2:3 竖屏。内容比画框窄了一半。它被压扁了。"

外交官看着那两个数字,感到一阵从后脊升起的寒意。


一、死掉的 0.8

故事要从头讲起。

最初的需求很简单:缩略图里角色太小,要把相机拉近。外交官翻到 thumbnail-capture.ts,看到了这段代码:

typescript
const fov = scene.activeCamera ? scene.activeCamera.fov : 0.8;

"复用主相机 FOV,没主相机就用 0.8,"他读着注释,"看起来挺合理。"

于是他调 0.8。改成 0.5。拉长焦,缩窄视野,角色就该变大了。

没变化。

改成 0.3。更长的焦距。还是没变化。

改成 0.1。极端长焦。角色纹丝不动。

"为什么改 FOV 没用?"外交官抓头发。

"因为你改的那个 0.8 是死代码,"桌面壳说。

它把三元表达式掰开给他看:scene.activeCamera ?——这个条件。场景一启动就创建主相机,所以 scene.activeCamera 永远是 truthy。0.8 永远不会被执行。外交官改的是一具尸体。

"那我改的是谁的 FOV?"

"主相机的。你反复调主相机 FOV,缩略图相机读到了同一个值——它以为自己也在用 0.8,实际上主相机的 FOV 本来就接近 0.8。你改来改去,它都不动。"

"然后呢?"

"然后你急了,"桌面壳说,"你开始调主相机的聚焦位置——把主场景的相机也改坏了。"

外交官沉默了。

死分支陷阱。一个永远为 true 的条件,把 fallback 值变成了幽灵。后来者以为自己在调参数,实际上在对着空气比划。

修复很简单:砍掉三元,给缩略图相机一个独立的常量 THUMB_FOV = 0.5。不再读主相机。两台相机,各管各的焦距。


二、4K 的皇帝新衣

FOV 修好了,相机拉近了。用户说:我要 4K 缩略图。

设置面板里,分辨率滑条拉到最大——×8,4096 像素长边。点击确认。

截图出来:512 × 288。

"我设了 4K,"用户说。

"我听到了,"外交官翻开缓存系统。

thumbnailKeyForModel() 生成的缓存键长这样:path/to/model.pmx。没有分辨率,没有宽高比。

"你上次生成缩略图时,分辨率是 512。缓存键是 model.pmx。现在你改成 4K 了,缓存键还是 model.pmx。命中旧缓存。永远返回 512 × 288 那张。"

"所以改了设置等于白改?"

"等于白改,"外交官苦笑,"你换了画布,但画框上贴的还是旧画。签名没变,画廊以为还是同一幅。"

修复:把分辨率和宽高比写进缓存键。

typescript
return `${key}::${res}::${aspect}`;

model.pmx::4096::2/3。分辨率变了,key 变了,缓存 miss,重新拍照。


三、两副画框

缓存键修好了。分辨率生效了。但角色的脸还是扁的。

外交官把诊断日志拉到最大:

contentAspect: 0.364  ← 角色实际宽高比
rtAspect:      0.666  ← 渲染目标 2:3
contentBox:    148 × 407
rtSize:        341 × 512

0.364 对 0.666。内容比画框窄了将近一倍。

"0.666 是对的——2:3 竖屏,"外交官自言自语,"但内容只有 0.364?角色不可能这么窄……除非相机用了错误的宽高比来计算投影矩阵。"

他翻开 Babylon.js 的源码。camera.getViewMatrix() 之后,camera.getProjectionMatrix() 被调用。投影矩阵内部用 engine.getAspectRatio(camera) 获取当前宽高比。

"这个函数从哪读宽高比?"外交官问。

"从当前绑定的 framebuffer,"桌面壳说。

"当前绑定的——等等。"

外交官看到了问题。缩略图渲染流程是:

  1. 创建 RenderTargetTexture(2:3 竖屏,341×512)
  2. 绑定 RT 的 framebuffer
  3. 渲染一帧
  4. 读取像素

但 Babylon.js 的渲染管线在步骤 2 和步骤 3 之间,还有一大堆内部调度——onBeforeRenderonAfterRender、多 pass 渲染。投影矩阵的计算发生在哪一步?

答案是:onBeforeRender。而那个时刻,主 Canvas 的 framebuffer 可能仍然是当前绑定状态。引擎读到的宽高比是主 Canvas 的 16:9(0.5625),而不是 RT 的 2:3(0.666)。

用 16:9 的投影矩阵渲染到 2:3 的画布上——横向被压缩。

"两副画框,"外交官说,"引擎以为在给 16:9 的画布构图,实际画在了 2:3 的画布上。画师按宽画布画的风景,贴到窄画布上——人就被压扁了。"

修复:不再信任引擎的时序。手动构造投影矩阵,用 RT 的实际尺寸算宽高比,然后冻结。

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

freezeProjectionMatrix——冻结。告诉引擎:别自己算了,用我给你的。不管 framebuffer 的绑定状态怎么变,投影矩阵纹丝不动。

外交官跑了一帧。contentAspect 从 0.364 涨到 0.664。接近 2:3。

脸不扁了。


四、天空盒里的一粒米

角色修好了。场景没有。

场景的缩略图拍出来是这样的:整张天空盒——蓝色穹顶、云的边缘、地平线的弧——全缩在画面里。模型变成画面正中央一个针尖大小的点。

"场景不是角色,"桌面壳说,"角色的 bounding box 很小,相机对准它就行。场景的 bounding box……"

外交官调出了数据:

fullHeight: 254.63
extentX:    1201.74

宽 1201 单位。那不是场景的主体——那是远景 mesh。天空盒的辅助几何体、远处的山、装饰用的云团——它们把 bounding box 撑到了离谱的尺度。

"我试过用中位数过滤,"外交官说,"把大于中位数 1.5 倍的 mesh 剔除。"

"然后呢?"

"大型场景的中位数本身就是远景。IQR 也救不了——四分位距里全是远处的东西。统计方法在异质场景面前太脆弱了。"

他看着那张天空盒缩略图,忽然想到了什么。

"等等——主相机已经对准了场景。用户打开场景时,主相机已经调好了 position 和 target。我为什么要重新算 bounding box?"

"对,"桌面壳说,"你不需要算。你已经有一个完美的答案——用户的视角。"

场景缩略图直接复用主相机的 position + target。不再算包围盒。不管远景多远、天空盒多大,相机拍出来的就是用户看到的样子。

一帧之后,场景缩略图里出现了完整的舞台,视角与主画面一致。天空盒退回了它该待的地方——背景。


五、同宽的悖论

渲染修好了。缓存修好了。对焦修好了。

最后一个问题:UI。

模型库里,角色卡片和舞台卡片肩并肩站着。角色 2:3 竖屏——窄而高。舞台 16:9 横屏——宽而扁。但它们在 UI 里占据完全相同的宽度。

"网格布局 minmax(100px, 1fr),"外交官念着 CSS,"所有卡片一视同仁。100px 宽,aspect-ratio 各自撑高。角色 100px 宽、150px 高。舞台 100px 宽、56px 高。"

"56 像素高的舞台卡片,"桌面壳说,"能看见什么?"

"什么都看不见。一张宽图被硬塞进窄框,变成了一条线。"

外交官想了想:"CSS Grid 有 grid-column: span 2。让舞台跨两列。"

css
.resource-card[data-aspect="16/9"] {
    grid-column: span 2;
}

角色占一列,舞台占两列。宽度翻倍,高度跟着 aspect-ratio 涨到 112px——刚好是角色的三分之二,视觉上比例协调。

"就这么简单?"

"就这么简单。五层坑修到最后,这一层反而是最简单的——一行 CSS。"


尾声:五层坑的地质图

桌面壳在墙上画了一张剖面图。五层坑,从上到下:

最上层是死分支——三元表达式的条件永远为 true,0.8 是一具尸体。AI 对着尸体做了半小时心肺复苏,无效后开始摇晃主相机的肩膀。

第二层是皇帝新衣——缓存键不含分辨率。4K 的画布上贴着 512px 的旧画。用户说"我设了 4K",系统说"我听到了",然后假装没听到。

第三层是时序陷阱——引擎以为在 16:9 的画布上构图,实际画在了 2:3 的画布上。两个画框同时存在,引擎选了错的那一个。手动冻结投影矩阵,是给画师塞了一把固定的尺子。

第四层是远景污染——统计方法试图从异质场景中找出"主体",但异质场景的统计量本身就是异质的。放弃计算,复用用户的视角——那个已经对准目标的相机。

第五层是网格盲区——CSS Grid 对所有子元素一视同仁,不区分竖屏和横屏。一行 span 2,宽幅元素跨列,窄幅元素留守。

五层坑里,只有第五层的修复是一行代码。前四层的排查每一层都够让人怀疑人生——尤其是第三层,投影矩阵时序问题,它藏得最深,表现最诡异:不是黑屏,不是缺失,是压扁。一种"看起来有东西但不对"的错,比"完全没有东西"的错难排查十倍。

外交官看着那张剖面图,想起了一句老话:

越是"差不多对"的 bug,越难抓。黑屏你一眼就知道出了问题。压扁你会以为——也许它本来就这么扁?

桌面壳把图钉上墙,旁边写:

离屏渲染的投影矩阵,不要相信引擎会替你算对。你比引擎更清楚画布有多大——那就亲手告诉它。冻结不是不信任,是确认。


教训:三元表达式的死分支比没有分支更危险——它让你以为自己在调参数,实际上在对着空气比划。投影矩阵的时序不是 bug,是引擎设计的必然——它有自己的调度节奏,不会为你的 RT 等一步。手动冻结是唯一确认。