Three.js 首帧卡顿排查:compileAsync 之后,为什么还会停一下?

最近在做一个三维钓鱼网页。岸边有树和人物,水面要画倒影,场景还开了阴影。

进入钓场时遇到一个问题:加载界面已经显示,动画却会停一下。看 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 记录。开发机的结果,也不能代替普通集显电脑的测试。

这次对我最有用的改动,是把首帧准备拆成明确阶段。以后多加一批树或换一张贴图,出现停顿时,就能沿着这条流程查。

本文来自我开发的 闲钓小栈,目前是电脑端单人三维垂钓与学习预览。

体验地址:闲钓小栈。

相关推荐
小李科技9 小时前
【性能优化】事件委托:目标定位与 React/Vue 实战
前端·vue.js·react.js·面试·性能优化·前端框架
csj5010 小时前
前端基础之《React(16)—Modal组件封装》
前端·react.js
ZoeLandia2 天前
React11:数据请求与 TanStack Query
前端·react.js·typescript·react
瓦瓦gogo3 天前
实习中接触前端开发后,我开始了解 React
前端·react.js·前端框架
ZoeLandia3 天前
React08:自定义 Hook
开发语言·前端·react.js·typescript
半生过往3 天前
智慧园区数字孪生大屏总结
echarts·three.js
空中湖5 天前
用 React 写数据大屏:为什么我不建议用图表库
前端·react.js·前端框架
Shimeri5 天前
越卷越像:我用遗传算法做了个"内卷"实验
算法·three.js·数据可视化·遗传算法·进化算法
Sakura_j_h_5 天前
Three.js 单文件离线机械臂工作台:爆炸图、透明外壳与剖面
前端·机械臂·webgl·three.js·1024程序员节·esbuild