Skip to content

给模型拍证件照

背景:低端 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
);

withTimeoutwhenReadyAsync 包起来。等它 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% 的时候给你惊喜。如果有事件驱动的方案,就用事件驱动。猜出来的正确,永远不如等出来的正确。