2026.04.02

R3F 性能优化清单:把一个 24fps 的场景救回 120fps

一次真实的性能急救记录。六个步骤,把一个粒子 + 后期 + 十几个模型的 React Three Fiber 场景,从掉帧掉到肉眼可见,优化到高刷屏满帧运行。

上个月接手了一个营销页:R3F 写的,创意很棒,但在我的 M1 上只有 24fps,风扇转得像要起飞。最终它跑到了 120fps。这是完整的急救清单,按性价比排序。

1. 先测量,再动手

猜是没用的。装上 r3f-perf,让数据说话:

import { Perf } from 'r3f-perf'

<Canvas>
  <Perf position="top-left" />
  <Scene />
</Canvas>

看三个数字:draw calls、三角形数量、GPU 耗时。那个页面的初始状态:draw calls 217,三角形 340 万。目标:draw calls 50 以内,三角形 50 万以内。

2. setState 逐出 useFrame

搜一遍代码库,凡是 useFrame 里出现 setXxx 的,全部改成 ref 直改:

// ❌ 每帧一次 setState = 每帧一次协调,React 表示很累
useFrame(() => setRotation((r) => r + 0.01))

// ✅ 直接改 Three 对象,React 完全不知情
useFrame((_, delta) => {
  meshRef.current.rotation.y += delta * 0.6
})

这一步就拿回了 20fps。React 的协调器很快,但每秒 60 次全树 diff 谁也扛不住。

3. 重复物体全部实例化

场景里 90 个小行星是 90 次 draw call。InstancedMesh 合并成 1 次:

const dummy = useMemo(() => new THREE.Object3D(), [])

useFrame(() => {
  asteroids.forEach((a, i) => {
    dummy.position.copy(a.position)
    dummy.rotation.y = a.spin
    dummy.updateMatrix()
    meshRef.current.setMatrixAt(i, dummy.matrix)
  })
  meshRef.current.instanceMatrix.needsUpdate = true
})

<instancedMesh ref={meshRef} args={[undefined, undefined, 90]}>
  <icosahedronGeometry args={[0.4, 0]} />
  <meshStandardMaterial />
</instancedMesh>

217 个 draw call 降到 41 个。

4. dpr 封顶,抗锯齿关掉

4K 屏 + devicePixelRatio = 3 意味着每帧要画 2400 万像素。封顶到 2,视觉上几乎无损:

<Canvas
  dpr={[1, 2]}
  gl={{ antialias: false, powerPreference: 'high-performance' }}
>

粒子和辉光场景里 MSAA 基本看不出来,关掉换 15% 的 GPU 余量,值。

5. 灯光是奢侈品

每盏灯都是一次全场景着色计算,阴影贴图更是每帧一次额外渲染。那个页面里有 7 盏 PointLight,其中 5 盏「只是想加点氛围」:

  • 氛围光交给 ambientLight + 材质的 emissive
  • 真实阴影换成 drei 的 ContactShadows(烘焙一次,不逐帧算)
  • 保留的动态光不超过 2 盏

6. 设备分级,移动端主动降载

最后一步是承认现实:手机 GPU 就是画不动桌面级的粒子量。入口处做一次分级:

const lowTier =
  matchMedia('(pointer: coarse)').matches || innerWidth < 768

export const quality = {
  particles: lowTier ? 4000 : 10000,
  dpr: lowTier ? [1, 1.5] : [1, 2],
  crystals: lowTier ? 4 : 7,
} as const

低端机少画一半粒子,用户只会觉得流畅,不会觉得寒酸。流畅本身就是高级感的一部分。

急救结果

指标优化前优化后
FPS (M1)24120
Draw calls21741
三角形340 万38 万
GPU 帧耗时38ms6.2ms

7.(2026 补充)多场景站点:懒加载与「一个 Canvas 原则」

这个站现在有四套可切换的 3D 主题场景(星云 / 樱花 / 赛博 / 机甲),性能预算的玩法随之升级,补三条新经验:

每个场景一个 chunk。React.lazy(() => import('./scenes/XxxScene')) 按主题动态加载,用户没切过的世界一个字节都不下载。四个场景全量约 40KB gzip,但首屏永远只付一份的钱。

换场景不换 Canvas。 <Canvas> 卸载重建 = WebGL 上下文销毁重建 = 黑一帧 + 全部着色器重编译。让 Canvas 常驻、只替换场景子树(<Suspense> 包住 lazy 场景),GL 上下文全程复用,主题切换零闪烁。

透支填充率的是混合,不是粒子数。 樱花场景只有 1300 个粒子,却一度比 9500 粒子的星系更卡——大尺寸半透明点精灵叠加的 overdraw 才是移动端杀手。把花瓣 gl_PointSize 上限压小、雾团数量砍到 7 个,帧率立刻回满。粒子系统的性能直觉要建立在「像素被画了几遍」上,而不是「有几个粒子」上。

想把粒子玩到百万量级(GPGPU/FBO ping-pong 管线),我写了一篇完整推导:百万粒子的呼吸。准备把新材质默认迁到节点/WebGPU 时,用这篇当决策树:从 GLSL 到 WebGPU/TSL

性能优化最反直觉的一点:它几乎从不牺牲画面。上面几步做完,页面看起来和原来一模一样 —— 只是终于对得起那个创意了。