Three.js实例化渲染:高效绘制数千3D对象的终极指南

本文深入探讨Three.js实例化渲染技术,通过React Three Fiber实现高效绘制数千3D对象。涵盖基础实例化、自定义着色器、多实例集和森林场景构建,显著降低GPU绘制调用提升性能。

Three.js实例化渲染:同时渲染多个对象

在构建basement.studio网站时,我们希望在保持性能的同时添加3D角色。通过实例化技术,我们实现了所有角色的同时渲染。本文介绍实例化概念及其在React Three Fiber中的使用方法。

简介

实例化是一种性能优化技术,允许同时渲染许多共享相同几何体和材质的对象。例如渲染森林场景时,需要大量树木、岩石和草地。如果它们共享相同的基础网格和材质,可以通过单次绘制调用完成渲染。

绘制调用是CPU向GPU发出的绘制命令(如网格)。每个独特的几何体或材质通常需要单独的调用。过多绘制调用会损害性能,而实例化通过批量处理多个副本来减少调用次数。

基础实例化

首先以传统方式渲染一千个盒子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const boxCount = 1000

function Scene() {
  return (
    <>
      {Array.from({ length: boxCount }).map((_, index) => (
        <mesh
          key={index}
          position={getRandomPosition()}
          scale={getRandomScale()}
        >
          <boxGeometry />
          <meshBasicMaterial color={getRandomColor()} />
        </mesh>
      ))}
    </>
  )
}

添加性能监视器后,会发现"calls"数量与boxCount匹配。

使用drei/instances快速实现实例化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { Instance, Instances } from "@react-three/drei"

const boxCount = 1000

function Scene() {
  return (
    <Instances limit={boxCount}>
      <boxGeometry />
      <meshBasicMaterial />
      {Array.from({ length: boxCount }).map((_, index) => (
        <Instance
          key={index}
          position={getRandomPosition()}
          scale={getRandomScale()}
          color={getRandomColor()}
        />
      ))}
    </Instances>
  )
}

现在"calls"减少到1,尽管显示了一千个盒子。我们将盒子几何体和材质一次性发送到GPU,并命令其重复使用相同数据一千次,因此所有盒子同时绘制。

注意:虽然使用相同材质,但Three.js支持多种颜色。然而其他属性(如贴图)应该相同,因为所有实例共享完全相同材质。

多组实例集

渲染森林时可能需要不同的实例集:树木、岩石和草地各一组。前面的示例只支持一个实例提供者,如何处理这种情况?

drei的createInstance()函数允许创建多个实例。它返回两个React组件:第一个是设置实例的提供者,第二个是用于在场景中定位实例的组件。

首先设置提供者:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { createInstances } from "@react-three/drei"

const boxCount = 1000
const sphereCount = 1000

const [CubeInstances, Cube] = createInstances()
const [SphereInstances, Sphere] = createInstances()

function InstancesProvider({ children }: { children: React.ReactNode }) {
  return (
    <CubeInstances limit={boxCount}>
      <boxGeometry />
      <meshBasicMaterial />
      <SphereInstances limit={sphereCount}>
        <sphereGeometry />
        <meshBasicMaterial />
        {children}
      </SphereInstances>
    </CubeInstances>
  )
}

然后向场景添加大量立方体和球体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function Scene() {
  return (
    <InstancesProvider>
      {Array.from({ length: boxCount }).map((_, index) => (
        <Cube
          key={index}
          position={getRandomPosition()}
          color={getRandomColor()}
          scale={getRandomScale()}
        />
      ))}

      {Array.from({ length: sphereCount }).map((_, index) => (
        <Sphere
          key={index}
          position={getRandomPosition()}
          color={getRandomColor()}
          scale={getRandomScale()}
        />
      ))}
    </InstancesProvider>
  )
}

尽管渲染了两千个对象,但GPU只运行两次绘制调用。

自定义着色器实例

到目前为止,所有示例都使用Three.js内置材质将网格添加到场景中,但有时需要创建自己的材质。如何为着色器添加实例支持?

首先设置一个非常基础的着色器材质:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import * as THREE from "three"

const baseMaterial = new THREE.RawShaderMaterial({
  vertexShader: /*glsl*/ `
    attribute vec3 position;
    attribute vec3 instanceColor;
    attribute vec3 normal;
    attribute vec2 uv;
    uniform mat4 modelMatrix;
    uniform mat4 viewMatrix;
    uniform mat4 projectionMatrix;

    void main() {
      vec4 modelPosition = modelMatrix * vec4(position, 1.0);
      vec4 viewPosition = viewMatrix * modelPosition;
      vec4 projectionPosition = projectionMatrix * viewPosition;
      gl_Position = projectionPosition;
    }
  `,
  fragmentShader: /*glsl*/ `
    void main() {
      gl_FragColor = vec4(1, 0, 0, 1);
    }
  `
})

现在为顶点添加一些运动:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const baseMaterial = new THREE.RawShaderMaterial({
  uniforms: {
    uTime: { value: 0 },
    uAmplitude: { value: 1 },
  },
  vertexShader: /*glsl*/ `
    attribute vec3 position;
    attribute vec3 instanceColor;
    attribute vec3 normal;
    attribute vec2 uv;
    uniform mat4 modelMatrix;
    uniform mat4 viewMatrix;
    uniform mat4 projectionMatrix;

    uniform float uTime;
    uniform float uAmplitude;
    vec3 movement(vec3 position) {
      vec3 pos = position;
      pos.x += sin(position.y + uTime) * uAmplitude;
      return pos;
    }

    void main() {
      vec3 blobShift = movement(position);
      vec4 modelPosition = modelMatrix * vec4(blobShift, 1.0);
      vec4 viewPosition = viewMatrix * modelPosition;
      vec4 projectionPosition = projectionMatrix * viewPosition;
      gl_Position = projectionPosition;
    }
  `,
  fragmentShader: /*glsl*/ `
    void main() {
      gl_FragColor = vec4(1, 0, 0, 1);
    }
  `,
});

现在使用实例化渲染一千个blob。首先向场景添加实例提供者:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { createInstances } from '@react-three/drei';

const [BlobInstances, Blob] = createInstances();

function Scene() {
  useFrame((state) => {
    baseMaterial.uniforms.uTime.value = state.clock.elapsedTime;
  });

  return (
    <BlobInstances material={baseMaterial} limit={sphereCount}>
      <sphereGeometry args={[1, 32, 32]} />
      {Array.from({ length: sphereCount }).map((_, index) => (
        <Blob key={index} position={getRandomPosition()} />
      ))}
    </BlobInstances>
  );
}

代码成功运行,但所有球体都在同一位置,尽管添加了不同位置。这是因为在vertexShader中计算每个顶点位置时,为所有顶点返回了相同位置。

要解决此问题,需要使用名为instanceMatrix的新属性。该属性对每个渲染实例都不同:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
attribute vec3 position;
attribute vec3 instanceColor;
attribute vec3 normal;
attribute vec2 uv;
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
attribute mat4 instanceMatrix;

uniform float uTime;
uniform float uAmplitude;

vec3 movement(vec3 position) {
  vec3 pos = position;
  pos.x += sin(position.y + uTime) * uAmplitude;
  return pos;
}

void main() {
  vec3 blobShift = movement(position);
  vec4 modelPosition = modelMatrix * instanceMatrix * vec4(blobShift, 1.0);
  vec4 viewPosition = viewMatrix * modelPosition;
  vec4 projectionPosition = projectionMatrix * viewPosition;
  gl_Position = projectionPosition;
}

使用instanceMatrix属性后,每个blob都位于其对应位置、旋转和缩放。

按实例更改属性

我们成功在不同位置渲染了所有blob,但由于uniform在所有实例间共享,它们最终都具有相同动画。

要解决此问题,需要为每个实例提供自定义信息。实际上之前已经这样做过,当时使用instanceMatrix将每个实例移动到对应位置。让我们调试instanceMatrix背后的魔法,了解如何创建自己的实例属性。

查看instancedMatrix的实现,可以看到它使用了称为InstancedAttribute的东西:

InstancedBufferAttribute允许创建对每个实例都会变化的变量。使用它来改变blob的动画。

Drei有一个名为InstancedAttribute的组件来简化此过程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const [BlobInstances, Blob] = createInstances<{ timeShift: number }>()

function Scene() {
  useFrame((state) => {
    baseMaterial.uniforms.uTime.value = state.clock.elapsedTime
  })

  return (
    <BlobInstances material={baseMaterial} limit={sphereCount}>
      <InstancedAttribute name="timeShift" defaultValue={0} />
      
      <sphereGeometry args={[1, 32, 32]} />
      {Array.from({ length: sphereCount }).map((_, index) => (
        <Blob
          key={index}
          position={getRandomPosition()}
          timeShift={Math.random() * 10}
        />
      ))}
    </BlobInstances>
  )
}

在着色器材质中使用此时间偏移属性来更改blob动画:

1
2
3
4
5
6
7
8
9
uniform float uTime;
uniform float uAmplitude;
attribute float timeShift;

vec3 movement(vec3 position) {
  vec3 pos = position;
  pos.x += sin(position.y + uTime + timeShift) * uAmplitude;
  return pos;
}

现在每个blob都有自己的动画。

创建森林

使用实例化网格创建森林。使用SketchFab的3D模型:Batuhan13的风格化松树。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useGLTF } from "@react-three/drei"
import * as THREE from "three"
import { GLTF } from "three/examples/jsm/Addons.js"

interface TreeGltf extends GLTF {
  nodes: {
    tree_low001_StylizedTree_0: THREE.Mesh<
      THREE.BufferGeometry,
      THREE.MeshStandardMaterial
    >
  }
}

function Scene() {
  const { nodes } = useGLTF(
    "/stylized_pine_tree_tree.glb"
  ) as unknown as TreeGltf

  return (
    <group>
      <mesh
        scale={0.02}
        geometry={nodes.tree_low001_StylizedTree_0.geometry}
        material={nodes.tree_low001_StylizedTree_0.material}
      />
    </group>
  )
}

现在应用实例化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const getRandomPosition = () => {
  return [
    (Math.random() - 0.5) * 10000,
    0,
    (Math.random() - 0.5) * 10000
  ] as const
}

const [TreeInstances, Tree] = createInstances()
const treeCount = 1000

function Scene() {
  const { scene, nodes } = useGLTF(
    "/stylized_pine_tree_tree.glb"
  ) as unknown as TreeGltf

  return (
    <group>
      <TreeInstances
        limit={treeCount}
        scale={0.02}
        geometry={nodes.tree_low001_StylizedTree_0.geometry}
        material={nodes.tree_low001_StylizedTree_0.material}
      >
        {Array.from({ length: treeCount }).map((_, index) => (
          <Tree key={index} position={getRandomPosition()} />
        ))}
      </TreeInstances>
    </group>
  )
}

整个森林仅用三次绘制调用渲染:一次用于天空盒,一次用于地平面,第三次用于所有树木。

为使场景更有趣,可以变化每棵树的高度和旋转:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const getRandomPosition = () => {
  return [
    (Math.random() - 0.5) * 10000,
    0,
    (Math.random() - 0.5) * 10000
  ] as const
}

function getRandomScale() {
  return Math.random() * 0.7 + 0.5
}

// ...
<Tree
  key={index}
  position={getRandomPosition()}
  scale={getRandomScale()}
  rotation-y={Math.random() * Math.PI * 2}
/>
// ...

延伸阅读

本文未涵盖但值得提及的主题:

批处理网格:现在可以多次渲染一个几何体,但使用批处理网格允许同时渲染不同几何体,共享相同材质。这样就不限于渲染一种树几何体,可以变化每个树的形状。

骨骼:当前不支持实例化,在创建最新basement.studio网站时,我们设法破解了自己的实现。

批处理网格变形:变形支持实例化但不支持批处理网格。如果想自己实现,建议阅读相关说明。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计