Appearance
给模型拍证件照
背景:低端 GPU 上缩略图常为黑屏/半缺纹理,因为双 rAF 不足以等待资源就绪。 过程:改用
scene.whenReadyAsync()+ 5 秒超时保护,低端设备退化到单 rAF 兜底。
联邦的模型库里,每张模型卡片左上角都有一张小小的缩略图。
有的是全身像,有的是半身像,有的——是黑的。
"为什么有的缩略图是黑的?"用户问。
"因为拍照的时候,模型还没准备好,"外交官说。
拍照的时机
给 3D 模型拍缩略图,不是一件简单的事。
你以为把模型往场景里一放,咔嚓一声就行了?不是的。模型加载进来之后,还有一大堆事情要做:
- 纹理上传:PMX 文件里的贴图要从 CPU 内存传到 GPU 显存。一张 2048×2048 的纹理就是 16MB,十张就是 160MB。
- Shader 编译:第一次渲染某个材质时,GPU 要编译对应的 Shader。复杂的材质可能要编译几十毫秒。
- 骨骼初始化:MMD 模型的骨骼、IK、物理刚体要初始化。
- 光照计算:第一帧的阴影贴图还没生成。
这些事情都做完了,模型才是"真正就绪"的状态。在那之前拍照,拍出来的就是半黑半白、缺胳膊少腿的半成品。
"那怎么知道模型准备好了?"AI 问。
"问得好,"外交官翻开 scene-loader.ts,"现在的做法是——等两帧。"
双 rAF 的妥协
旧代码是这样的:
typescript
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// 两帧之后,拍照
canvas.toDataURL(...);
});
});两层 requestAnimationFrame 嵌套。俗称"双 rAF"。
"为什么是两帧?"AI 问,"不是一帧,不是三帧?"
"因为……经验,"外交官耸耸肩,"大家都这么写。第一帧浏览器布局,第二帧 GPU 渲染,两帧差不多够了。"
"差不多?"
"差不多,"外交官重复了一遍,"高端显卡上,一帧就够了。中端显卡,两帧刚好。低端显卡……两帧可能还不够。"
这就是问题所在。
双 rAF 是一个经验值的妥协。它假设"两帧足够所有准备工作完成"。这个假设在大多数时候是对的——现代 GPU 很快,纹理上传几毫秒就完事了。但在低端显卡上、在大模型上、在第一次加载时(Shader 还没缓存),两帧不够。
不够的结果就是——缩略图是黑的。
或者更糟——半黑的。头发的纹理还没上来,脸是白的,衣服是灰的。用户一看就觉得"这个模型有问题"。
"那为什么不等久一点?"AI 说,"十帧?一百帧?"
"等久了也不行,"外交官摇头,"用户在模型库里翻页,翻到哪张缩略图就要显示哪张。等一百帧——半秒都过去了——用户都翻到下一页了,缩略图还没出来。"
"所以是一个权衡?"
"是一个权衡,"外交官确认,"太快了,图是黑的。太慢了,用户等不及。双 rAF 就是在中间选了一个点——大多数时候刚好,少数时候翻车。"
更好的时钟:whenReadyAsync
"但是——"外交官话锋一转,"我们不需要猜。"
"猜?"
"双 rAF 是在猜——猜两帧够不够。但 Babylon.js 有一个更好的时钟。它知道场景什么时候真正就绪。"
他指着文档里的一个函数:scene.whenReadyAsync()。
"这是 Babylon.js 内置的一个 Promise。它会等所有事情都做完——纹理上传完了、Shader 编译完了、粒子系统启动了、后处理管线准备好了——然后才 resolve。"
"真的什么都等?"
"几乎所有异步资源,"外交官点头,"它比两帧聪明多了。两帧是用时间猜,whenReadyAsync 是用事件等。资源真的准备好了,它才说'好了'。"
"那直接换成 whenReadyAsync 不就完了?"
"没那么简单,"外交官皱起眉,"whenReadyAsync 有一个问题——它可能永远不 resolve。"
"啊?"
"如果场景里有一个资源永远加载不出来呢?比如一张纹理的 URL 是错的,或者一个材质的 Shader 编译失败了。whenReadyAsync 会一直等下去。缩略图就永远拍不了。"
AI 沉默了。
"所以……不能直接换?"
"不能直接换,"外交官说,"但可以加一个保险。"
双保险:超时保护
外交官的方案是这样的:
typescript
await withTimeout(
scene.whenReadyAsync().then(() => { ready = true; }),
THUMBNAIL_TIMEOUT_MS,
undefined
);用 withTimeout 把 whenReadyAsync 包起来。等它 resolve,但最多等 5 秒。5 秒到了还没好——不管了,直接拍。
"5 秒是不是太长了?"AI 问。
"5 秒是上限,不是平均,"外交官解释,"正常情况下,whenReadyAsync 几十毫秒就 resolve 了——比双 rAF 还快。5 秒只有在极端情况下才会触发——比如低端显卡 + 超大型模型 + 第一次加载。"
"那 5 秒到了拍出来的图还是黑的怎么办?"
"总比永远拍不出来强,"外交官说,"而且 5 秒都没准备好的模型,大概率是有问题的——纹理缺失、Shader 编译失败。这种模型的缩略图本来就没法看,拍出来是黑的也正常。"
他在纸上画了一个决策树:
加载模型
↓
whenReadyAsync 开始等
↓
├─ 50ms 就绪 → 拍照(清晰)✅
├─ 500ms 就绪 → 拍照(清晰)✅
└─ 5000ms 超时 → 拍照(可能不全,但总比没有强)⚠️"双保险,"外交官总结,"whenReadyAsync 保证正常情况下拍得好,超时保护保证极端情况下不会卡死。"
为什么不用三 rAF
"等等,"AI 想到了一个问题,"既然双 rAF 不够,那三 rAF 呢?四 rAF 呢?为什么非要换成 whenReadyAsync?"
"因为 rAF 是帧数,不是时间,"外交官说,"60 帧的显示器,一帧 16ms。144 帧的显示器,一帧 7ms。30 帧的低端卡,一帧 33ms。同样是两帧,在不同机器上完全不一样。"
"啊……对,"AI 反应过来,"帧率不一样,rAF 的时间就不一样。"
"低端卡本来就慢,两帧的时间反而更长?不对——低端卡帧率低,每帧时间长,两帧反而更久?"AI 有点绕。
"听起来好像是这样,但其实不是,"外交官摇摇头,"低端卡的问题不是时间不够,是工作没做完。GPU 在两帧的时间里只完成了一半的纹理上传,不是因为时间短,是因为带宽不够。你给它四帧,它也许能传完。但你怎么知道要几帧?八帧?十六帧?"
他指了指屏幕:
"whenReadyAsync 的优势就在这里——它不看时间,不看帧数,它看事件。事情真的做完了,它才说好了。不管你是 60 帧还是 30 帧,不管你是高端卡还是低端卡,标准是一样的——资源就绪了。"
"所以这才是正确的方式。"
"这才是正确的方式,"外交官确认,"经验值能不用就不用。有事件驱动的方案,就不要用猜测的方案。"
退化的艺术
"还有一个细节,"外交官说,"超时之后怎么办?"
"直接拍啊,你刚才说了。"
"不对,"外交官摇头,"超时之后,应该退化到单 rAF。"
"单 rAF?为什么不是直接拍?"
"因为 whenReadyAsync 超时,不代表场景一帧都没渲染,"外交官解释,"5 秒过去了,GPU 可能已经做了不少工作——只是还没全部做完。这时候至少等一帧,让当前已经准备好的东西渲染出来,比直接拍第一帧要强。"
"退化也是有层次的。"
"对,"外交官笑了,"最佳方案(whenReadyAsync)→ 次佳方案(单 rAF)→ 最差方案(直接拍)。一层一层退化,每一层都比上一层差一点,但每一层都比完全失败强。"
他在笔记本上写下一行字:
好的系统不是不会失败,是失败的时候也尽量好看一点。
模型库里的小照片
改造完的那天,外交官在模型库里翻了一遍。
以前那些半黑半白的缩略图,现在都清晰了。头发的纹理、眼睛的高光、衣服的褶皱——该有的都有了。
"快了还是慢了?"AI 问。
"高端卡上,差不多快——whenReadyAsync 几十毫秒就好了,和双 rAF 差不多。"外交官说,"低端卡上,慢了一点点——但总比黑的强。"
"用户会注意到吗?"
"不会,"外交官摇摇头,"用户不会注意到'缩略图变清晰了'——因为他们不知道以前是糊的。但他们会下意识地觉得'这个软件的模型库看起来挺舒服的'。"
他指着屏幕上整整齐齐的模型卡片:
"好的优化就是这样——你做了,用户不知道。但如果没做,用户一眼就觉得不对。"
第二颗黄石子落进了"已处理"的堆里。
第一颗是空白的一秒——看不见的体验。 第二颗是黑黑的缩略图——看不见的等待。
都是小事。 但小事加起来,就是一个软件的质感。
附录:异步资源就绪设计原则
| 原则 | 说明 |
|---|---|
| 事件优先于猜测 | 有 whenReadyAsync 就不用双 rAF。有事件就不用定时器猜 |
| 永远要有超时 | 任何"等待就绪"的操作都要有超时保护。极端情况下,永远不就绪怎么办 |
| 分层退化 | 最佳方案 → 次佳方案 → 保底方案。一层比一层差,但都比完全失败强 |
| 帧率不是时钟 | rAF 是帧数,不是时间。不同设备帧率不同,不能用帧数当绝对时钟 |
| 体验优化是隐形的 | 用户不会说"缩略图变清晰了",但他们会觉得"软件挺舒服的" |
教训:经验值是最便宜的方案,也是最脆弱的方案。它在 90% 的情况下工作得很好,然后在剩下 10% 的时候给你惊喜。如果有事件驱动的方案,就用事件驱动。猜出来的正确,永远不如等出来的正确。