这是一篇关于性能优化的简要介绍

Scaling performance 性能优化

运行WebGL可能会非常耗费设备的性能,如果你设备性能本身足够强大,一般会有更好的体验。但是为了尽可能地减轻三维效果对性能性能的拖累,尤其是如果你想让你的应用程序(三维网页)可用于各种设备,包括较差的那些设备,您应该研究性能优化。本文介绍了其中的一些。

On-demand rendering 按需渲染

three.js 应用程序通常像游戏循环的方式那样执行,每秒执行 60 次,React Three Fiber 也不例外。

当您的场景中有不断移动的元素时,这是完全可以接受的。这通常会耗费电池电量最多,并使风扇旋转。 但是,如果场景中的进行运动或者变化的元素允许停止,那么保持渲染将是浪费的。

在这种情况下,您可以选择按需渲染,这将仅在必要时进行渲染。这可以节省电池电量并使嘈杂的风扇保持稳定。 点击下方图片,打开下面的codesandbox并查看开发工具,您会发现当没有任何操作时它完全处于空闲状态。只有在移动模型时才进行渲染。

Color grading 设置方式

<Canvas frameloop="demand">

所需做的就是将画布的帧循环属性设置为 demand。它将在检测到组件树中组件的属性更改时进行渲染。

Triggering manual frames 手动触发渲染

在 React 中,如果您在组件内部直接修改属性,那么 React 将无法感知到这种修改,导致显示变得陈旧(没有更新到最新的状态)。而在 React Three Fiber 中,如果组件树中的任何内容更改了属性,React 也无法意识到这种更改,导致显示变得陈旧。因此,在 React Three Fiber 中,您可以使用 invalidate 函数手动触发帧以更新显示,而在 React 中,您应该通过更改状态或传递新的属性来实现属性的更改。

例如,相机控件只是抓取相机并改变其值。在这里,您可以使用 React Three Fiber 的 invalidate 函数手动触发帧。

function Controls() {
  const ref = useRef()
  const { invalidate, camera, gl } = useThree()
  useEffect(() => {
    ref.current.addEventListener('change', invalidate)
    return () => ref.current.removeEventListener('change', invalidate)
  }, [])
  return <orbitControls ref={ref} args={[camera, gl.domElement]} />

Drei库的controls会自动为你做这些

通常来说,只要你想要进行渲染,你可以在任何情况下调用invalidate()

调用 invalidate() 不会立即渲染,它只是请求渲染一个新的帧。多次调用 invalidate() 不会多次渲染。可以将其视为一个标志,告诉系统有些东西已经发生了变化。

Re-using geometries and materials

几何体与材质的复用

每多一个几何体和材质都意味着会给GPU带来额外的负担。如果你知道这些资源是可以重复用在不同的地方的,那就应该重复使用它们。

你可以全局式的这样做:

const red = new THREE.MeshLambertMaterial({ color: "red" })
const sphere = new THREE.SphereGeometry(1, 28, 28)

function Scene() {
  return (
    <>
      <mesh geometry={sphere} material={red} />
      <mesh position={[1, 2, 3]} geometry={sphere} material={red} />

如果您在 React Three Fiber 的 Canvas 上下文之外的全局空间中创建材质或颜色,您应该在 three.js 中启用 ColorManagement。这将允许某些转换(对于十六进制和 CSS 颜色在 sRGB 中)自动进行,从而在所有情况下产生正确的颜色。

import * as THREE from 'three'

// r150
THREE.ColorManagement.enabled = true

// r139-r149
THREE.ColorManagement.legacyMode = false

Caching with useLoader 缓存与useLoader

所有通过useLoader加载的资源都会自动进行缓存!

如果通过 useLoader 访问具有相同 URL 的资源,则在整个组件树中始终会引用同一资产,从而实现重用。如果您通过 GLTFJSX 运行 GLTF 资产,则此功能尤其有用,因为它链接几何和材料,从而创建可重用的模型。

Re-using GLTFs

function Shoe(props) {
  const { nodes, materials } = useLoader(GLTFLoader, "/shoe.glb")
  return (
    <group {...props} dispose={null}>
      <mesh geometry={nodes.shoe.geometry} material={materials.canvas} />
    </group>
  )
}

<Shoe position={[1, 2, 3]} />
<Shoe position={[4, 5, 6]} />

Instancing 实例化

每个Mesh都是一个绘制调用,您应该注意您使用了多少:最多不超过1000个,而且最好只有几百个或更少。通过实例化重复对象,您可以通过减少绘制调用来获得性能优势。这样,您可以在单个绘制调用中拥有数十万个对象。

Instances 多实例处理

使用实例化的方式并没有那么难,如果你需要帮助的话可以参考threejs相关的文档

function Instances({ count = 100000, temp = new THREE.Object3D() }) {
  const ref = useRef()
  useEffect(() => {
    // Set positions
    for (let i = 0; i < count; i++) {
      temp.position.set(Math.random(), Math.random(), Math.random())
      temp.updateMatrix()
      ref.current.setMatrixAt(i, temp.matrix)
    }
    // Update the instance
    ref.current.instanceMatrix.needsUpdate = true
  }, [])
  return (
    <instancedMesh ref={ref} args={[null, null, count]}>
      <boxGeometry />
      <meshPhongMaterial />
    </instancedMesh>
  )
}

Level of detail 细节的程度

有时候,如果一个物体离摄像机越远,减少其显示的细节水平可以是有益的。如果它几乎看不到,为什么要显示全分辨率呢?这是减少顶点数量的好策略,这意味着 GPU 的工作量更少。

点击下面这张图,打开这个项目,滚动放大和缩小来查看效果。

Re-using geometry and level of detail 重复使用几何体与展示细节

在 Drei 中有一个名为 <Detailed /> 的小组件,它可以轻松设置 LOD(Level of Detail) 模式。您可以加载或准备一些不同精度的模型,多少个都可以,然后以相同的距离从相机开始,从最高质量到最低质量,组件会自动决定显示哪一个质量的模型。

import { Detailed, useGLTF } from '@react-three/drei'

function Model() {
  const [low, mid, high] = useGLTF(["/low.glb", "/mid.glb", "/high.glb"])
  return (
    <Detailed distances={[0, 10, 20]}>
      <mesh geometry={high} />
      <mesh geometry={mid} />
      <mesh geometry={low} />
    <Detailed/>
  )
}

Nested Loading 逐步加载

逐步加载意味着先加载低分辨率的纹理和模型,然后再逐步加载更高分辨率的资源。

点击下面图片,打开的codesanbox案例经历了三个加载阶段:

  1. 加载指示器

  2. 低质量

  3. 高质量

Progressive loading states with suspense 通过suspense实现渐进加载

这就是实现它的简单方法,您可以嵌套使用Suspense并设置相应的fallback。

function App() {
  return (
    <Suspense fallback={<span>loading...</span>}>
      <Canvas>
        <Suspense fallback={<Model url="/low-quality.glb" />}>
          <Model url="/high-quality.glb" />
        </Suspense>
      </Canvas>
    </Suspense>
  )
}

function Model({ url }) {
  const { scene } = useGLTF(url)
  return <primitive object={scene} />
}

Performance monitoring 性能监控

Drei有一个新的PerformanceMonitor组件,可以让您监视并适应设备性能。此组件将收集一段时间内的平均fps(每秒帧数)。如果经过几次迭代后,平均值低于或高于阈值,它将触发onIncline和onDecline回调,让你进行处理。通常,你会减少场景的质量、分辨率、效果、要渲染的物体数量,或者如果你有足够的帧率来填充,则会增加它。

由于这通常会在两个回调之间来回触发,因此您需要定义上限和下限帧率范围,只要保持在该范围内,就不会触发回调。理想情况下,你的应用程序应该通过逐渐改变质量找到进入该范围的方法。

这是一个简单的例子,用于调节屏幕像素比设定。它从1.5开始,如果系统低于范围,则变为1,如果速度足够快,则变为2。

function App() {
  const [dpr, setDpr] = useState(1.5)
  return (
    <Canvas dpr={dpr}>
      <PerformanceMonitor onIncline={() => setDpr(2)} onDecline={() => setDpr(1)} >

你还可以使用onChange回调,在平均值朝任何方向更改时收到通知。这使你能够进行渐进式更改。它为您提供了介于0和1之间的因子,该因子会受到incline的增加和decline的减少而增加或减少。该因子最初默认为0.5。

import round from 'lodash/round'

const [dpr, set] = useState(1)
return (
 <Canvas dpr={dpr}>
  <PerformanceMonitor onChange={({ factor }) => setDpr(round(0.5 + 1.5 * factor, 1))}>

如果尽管已经设定了上下限,仍然遇到了翻转,则可以定义翻转次数的限制。如果达到该限制,将会触发onFallback,通常会为应用程序设置最低可能的基线。调用回退后,PerformanceMonitor将关闭。

<PerformanceMonitor flipflops={3} onFallback={() => setDpr(1)}>

翻转,指的是性能监控器在两个回调之间来回触发的现象。这通常会导致应用程序在两个质量级别之间不断切换,从而影响用户体验。

PerformanceMonitor 还可以拥有子组件,如果你将应用程序包装在其中,则可以使用 usePerformanceMonitor,使嵌套树中的各个组件能够对性能变化做出自己的响应。

<PerformanceMonitor>
  <Effects />
</PerformanceMonitor>

function Effects() {
  usePerformanceMonitor({ onIncline, onDecline, onFallback, onChange })
  // ...
}

Movement regression 动态衰减

像Sketchfab这样的网站确保场景始终流畅,以60 fps运行,并且响应灵敏,无论使用哪种设备或加载的模型有多耗费性能。他们通过动态衰减来实现这一点,其中效果、纹理和阴影将略微降低质量,直到静止状态。

下面的codesandbox(点击图片打开)使用了昂贵的灯光和后期处理。为了使其相对平稳地运行,它将在运动时缩放像素比率,并跳过像环境遮蔽这样的重型后期处理效果。

Performance scaling 性能伸缩

可以设置一个performance的对象来实现监控状态

performance: {
  current: 1,
  min: 0.1,
  max: 1,
  debounce: 200,
  regress: () => void,
},
  • current: 性能因子在最小值和最大值之间交替

  • min: 性能下限(应小于1)

  • max: 性能上限 去(不应高于1)

  • debounce:抖动事件直到再次达到上限(1)

  • regress(): 一个暂时降低性能的函数

你可以这样进行默认设置:

<Canvas performance={{ min: 0.5 }}>...</Canvas>

This is how you can put the system into regression 你如何动态衰减

你唯一需要做的就是调用 regress() 函数。什么时候调用它,取决于你自己,但它可以在鼠标移动或场景移动时,例如当控件触发它们的change事件时调用。

假设你正在使用controls,那么以下代码将在controls激活时将系统置于衰减状态:

const regress = useThree((state) => state.performance.regress)
useEffect(() => {
  controls.current?.addEventListener('change', regress)

This how you can respond to it 如何处理

仅仅调用regress()函数不会改变或影响任何事情!

你的应用程序必须通过监听性能当前(performance current)来选择性能缩放!数字本身会告诉你该做什么。1 (max) 表示一切正常,这是默认值。小于1 (min) 表示请求回归,并且数字本身告诉您在缩小比例时应该走多远。

例如,您可以简单地将当前值与像素比率相乘,以减少分辨率。如果您定义了 min: 0.5,那么当调用regress时,它将至少在200ms(延迟)内减半分辨率。它也可以用于其他任何事情:当当前值小于1时关闭灯光,使用低分辨率纹理,跳过后期处理效果等。当然,您也可以animation/lerp(通过逐步变化的方式)进行这些更改。

下面是一个简单的代码示例用来掩饰如何降低pixel ratio:

function AdaptivePixelRatio() {
  const current = useThree((state) => state.performance.current)
  const setPixelRatio = useThree((state) => state.setPixelRatio)
  useEffect(() => {
    setPixelRatio(window.devicePixelRatio * current)
  }, [current])
  return null
}

将此组件拖放到场景中,将其与上面调用 regress() 的代码组合在一起,即可获得自适应分辨率:

<AdaptivePixelRatio />

drei库中,已经有一些类似的预制组件了。

Enable concurrency 启用React中的concurrency

React 18 引入了并发调度(concurrency),具体来说是通过 startTransition 和 useTransition 实现的时间切片。这将虚拟化组件图,从而使你可以优先考虑组件和操作。想象一下虚拟列表如何避免缩放问题,因为它只呈现屏幕可以容纳的项目数量,而不受它必须呈现的项目数量的影响,无论是 10 还是 100000000。

React 18 的功能非常类似于此,它可以以可能难以或不可能在原始应用程序中实现的方式推迟负载和重型任务。因此,即使在最苛刻的情况下,它也能保持稳定的帧速率。

以下基准测试显示了并发性的强大之处:https://github.com/drcmda/scheduler-test

它通过创建数百个 THREE.TextGeometry 实例(确切地说是 510 个)来模拟重载。像 three.js 中的许多其他类一样,这个类是昂贵的,并且需要一段时间来构建。如果同时创建所有 510 个实例,它将导致大约 1.5 秒的卡顿(Apple M1),选项卡通常会冻结。它在间隔中运行,并将每 2 秒执行一次

DISTRIBUTED

AT-ONCE

three.js

~20fps

~5fps

React

~60fps

~60fps

想要了解更多关于这个API的使用,可以查看Performance pitfalls中的startTransition部分