最近在做一个三维钓鱼网页。岸边有树和人物,水面要画倒影,场景还开了阴影。
进入钓场时遇到一个问题:加载界面已经显示,动画却会停一下。看 Network,模型和贴图也不一定还在下载。
检查这段初始化流程后发现,只盯着 compileAsync 还不够。地形计算、纹理上传,以及不同渲染路径的程序准备,都可能挤在第一次绘制之前。
下面按项目实际实现拆开说,使用的是 Three.js 0.186.1 和 TypeScript。

1. 先分清卡在哪一步
资源下载结束,到画面真正开始绘制,中间还有这些工作:
| 工作 | 项目里的例子 |
|---|---|
| CPU 计算 | 地形高度、坡度、顶点颜色和法线 |
| 纹理初始化 | 岸边、树木和人物的贴图上传 |
| 着色器编译 | 主画面、离屏反射和阴影需要的程序 |
| 实际绘制 | 正式相机下的反射、阴影和主场景 |
compileAsync 处理的是程序预编译,不能替我们完成前面所有工作。地形生成外面套一层 async,里面的同步循环也不会自动变成多线程。
我的地形计算每处理 256 个顶点检查一次耗时,超过约 8ms 就等待下一次动画帧回调,再继续计算。这是软预算:后面的 computeVertexNormals() 仍然同步执行,不能说整段初始化都被限制在 8ms 以内。
2. 图片读完了,纹理也可能还没上传
贴图文件读取完成,不代表 GPU 侧的纹理已经准备好。等第一次 render() 才初始化一批贴图,开销就会集中出现。
我在场景组装好之后收集纹理,用 Set 去重,然后逐张初始化。下面保留调度部分,textures 由调用方传入:
ts
import * as THREE from 'three';
const nextFrame = () =>
new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
async function prepareTextures(
renderer: THREE.WebGLRenderer,
textures: Iterable<THREE.Texture>,
cancelled: () => boolean,
): Promise<boolean> {
for (const texture of new Set(textures)) {
if (cancelled()) return false;
renderer.initTexture(texture);
await nextFrame();
}
return !cancelled();
}
initTexture 是公开 API,用来提前初始化纹理,避免一直等到首轮渲染才处理解码和上传。Three.js 接口说明
项目目前收集材质直接引用的纹理,以及 ShaderMaterial 中直接作为 uniform 值的纹理。纹理数组等结构需要另外处理。
这里有两个容易误解的地方:
- 单张贴图的初始化仍可能阻塞。如果一张图特别大,还得检查尺寸和格式。
await nextFrame()是把工作分散到不同帧的调度中,不代表 GPU 工作已经完成。rAF 回调发生在重绘之前。
3. 主画面预编译了,水面反射呢?
纹理之后,我用 renderer.compileAsync(scene, camera) 准备材质程序。调用前,灯光、环境和材质需要配置好。Three.js 预编译说明
但这个钓场有一条额外路径:主画面输出到 Canvas,水面反射输出到离屏渲染目标。
核对项目锁定的 Three.js 源码,可以看到当前渲染目标会参与输出色彩空间的选择,对应参数又参与程序缓存键的生成。主画面的程序准备过,不等于离屏路径一定会复用它。
因此,我在主场景预编译后,切换到一个小的离屏目标,再准备一次当前项目需要的输出路径。下面的函数与上一段共用 THREE 导入:
ts
async function prepareMainAndOffscreen(
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
cancelled: () => boolean,
): Promise<boolean> {
if (cancelled()) return false;
await renderer.compileAsync(scene, camera);
if (cancelled()) return false;
const previous = renderer.getRenderTarget();
const target = new THREE.WebGLRenderTarget(1, 1);
try {
renderer.setRenderTarget(target);
await renderer.compileAsync(scene, camera);
return !cancelled();
} finally {
renderer.setRenderTarget(previous);
target.dispose();
}
}
这个 1×1 目标只用于切换编译环境,没有绘制完整倒影,更没有把正式反射的开销降成一个像素。换成其他离屏配置、相机或后处理,也需要重新核对程序是否匹配。
阴影还要单独检查。比如树叶有透明裁切,深度路径就要考虑 map、alphaMap、alphaTest 和面方向。项目会为相关投影物件创建临时深度材质代理,预编译后保持引用,等第一次正式阴影绘制完成再释放。上面的简化函数只展示主画面和离屏部分。
另外,compileAsync 利用的 KHR_parallel_shader_compile 主要提供非阻塞的编译状态查询。编译本身仍有成本,扩展支持情况也要考虑。MDN 扩展说明
4. 预热期间,别让正常循环抢先画了
分帧准备还有一个前提:正常渲染循环不能同时拿着尚未准备好的场景一直画,否则首次上传和编译可能提前发生。
我用 preparing 状态挡住正常场景更新与绘制。准备阶段显示已有封面和入场动画,流程是:
text
显示入场界面
→ 读取资源、分片生成地形
→ 组装场景
→ 初始化纹理、预编译相关程序
→ 用正式相机实际绘制并检查上下文
→ 结束 preparing,恢复正常循环
进入流程独占渲染器,避免离屏目标切换期间被其他渲染打断。用户退出后,每个异步步骤检查取消状态;临时目标和材质要清理。加载失败则给出重试入口。
这也不代表"预热完,所有设备都不卡"。GPU 缓冲区上传、模型处理和同步计算仍可能留下长任务,需要继续看 Performance 记录。开发机的结果,也不能代替普通集显电脑的测试。
这次对我最有用的改动,是把首帧准备拆成明确阶段。以后多加一批树或换一张贴图,出现停顿时,就能沿着这条流程查。
本文来自我开发的 闲钓小栈,目前是电脑端单人三维垂钓与学习预览。
体验地址:闲钓小栈。