Appearance
被压扁的证件照
背景:缩略图对焦改造后角色被横向压扁、场景拍到天空盒、舞台卡片与角色同宽。 过程:五层连环坑——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 × 5120.364 对 0.666。内容比画框窄了将近一倍。
"0.666 是对的——2:3 竖屏,"外交官自言自语,"但内容只有 0.364?角色不可能这么窄……除非相机用了错误的宽高比来计算投影矩阵。"
他翻开 Babylon.js 的源码。camera.getViewMatrix() 之后,camera.getProjectionMatrix() 被调用。投影矩阵内部用 engine.getAspectRatio(camera) 获取当前宽高比。
"这个函数从哪读宽高比?"外交官问。
"从当前绑定的 framebuffer,"桌面壳说。
"当前绑定的——等等。"
外交官看到了问题。缩略图渲染流程是:
- 创建 RenderTargetTexture(2:3 竖屏,341×512)
- 绑定 RT 的 framebuffer
- 渲染一帧
- 读取像素
但 Babylon.js 的渲染管线在步骤 2 和步骤 3 之间,还有一大堆内部调度——onBeforeRender、onAfterRender、多 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 等一步。手动冻结是唯一确认。