跳到正文
Pian

Three.js 首屏性能踩坑

从 8fps 到稳 60fps,中间没有魔法。记录一遍真实数据:draw call、纹理尺寸、tone mapping、后处理和 DPR 到底吃掉了多少帧。

2 分钟读完#Three.js #性能 #WebGL

场景不复杂。一个球体、一层粒子、一片渐变背景、一点辉光。在 M1 上跑 60fps 毫无压力。

在四年前的安卓中端机上,8fps。

我把整个排查过程记下来,因为网上大部分"Three.js 优化"文章讲的是原理,不讲先量哪一个。

先测,再改

第一步不是改代码,是接 renderer.info。

console.log(renderer.info.render.calls);      // draw call 数
console.log(renderer.info.render.triangles);  // 三角形数
console.log(renderer.info.memory.textures);   // 纹理张数

结果出乎意料:draw call 只有 14,三角形不到 3 万。这个量级不该掉到 8fps。

真正的问题藏在别处——像素。手机 DPR 是 3,屏幕 1080×2400,renderer.setPixelRatio(window.devicePixelRatio) 直接把渲染分辨率拉到 3240×7200,是 2300 万个像素。每一帧光栅化 2300 万像素,GPU 直接趴下。

五个真正有效的改动

  1. 给 DPR 设上限。 一行,收益最大。

    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
    

    从 8fps 到 26fps。视觉上 1.75 和 3 在手机上几乎分不出来,人的眼睛在那个尺寸下不检测像素。

  2. 后处理关掉一半。 Bloom 是罪魁。它在全分辨率上做多次高斯模糊,每一趟都是全屏采样。降到半分辨率跑:

    const bloom = new UnrealBloomPass(
      new THREE.Vector2(w / 2, h / 2),  // 分辨率减半
      0.6, 0.2, 0.85
    );
    

    26fps 到 41fps。而且因为加了噪点抖动,半分辨率的辉光反而没那么"塑料"。

  3. 粒子换成 Points,不要几百个 Sprite。 原本是 400 个 Sprite,每个一次 draw call。换成单个 Points + 自定义 ShaderMaterial,400 次变 1 次。

  4. 纹理别用 PNG 1024。 背景那张渐变是 1024×1024 的 PNG,解压后 4MB 显存,下载 800KB,而它本质上是四个色标之间的线性插值。改成 128×128 的 JPEG,再让 shader 做一次轻微模糊,下载降到 8KB,显存降到 64KB。视觉差异为零。

  5. powerPreference: 'high-performance' + 关掉 antialias。 后者听着反直觉,但后处理链路里 MSAA 本来就不生效,开着纯属浪费。

41fps 到 58fps。

剩下的两帧

最后卡在 58。查下来是 onBeforeRender 里每帧新建 new THREE.Vector3(),一秒 60 次分配,GC 偶尔来一下。

改成模块级复用一个临时向量,抖动消失。

三条结论

一,拉满 DPR 是移动端头号杀手,比三角形数和 draw call 加起来还狠。先查像素,再查几何。

二,后处理要按分辨率算成本,不是按"有几个 pass"算。半分辨率是最划算的一档。

三,别在渲染循环里分配对象。 new 出现在 requestAnimationFrame 里,基本等于埋雷。

最后还是那句话:先接 renderer.info,先测。我前面三十分钟都在猜 shader 写错了,方向全歪。