Three.js实例化渲染:高效绘制数千3D对象的技术解析

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

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

学习如何使用React Three Fiber中的实例化技术高效渲染数千个3D对象,正如性能优化的basement.studio网站所展示的那样。

介绍

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

绘制调用是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。

Instances组件充当提供者;它需要几何体和材质作为子项,每次我们向场景添加实例时都会使用这些子项。

Instance组件将在特定位置/旋转/缩放中放置这些实例之一。每个Instance将同时渲染,使用提供者配置的几何体和材质。

 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支持这一点。但是,其他属性(如贴图)应该相同,因为所有实例共享完全相同的材质。

我们将在本文后面看到如何破解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
26
27
28
29
30
31
32
33
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);
    }
  `
})

export function Scene() {
  return (
    <mesh material={baseMaterial}>
      <sphereGeometry />
    </mesh>
  )
}

现在我们有了测试对象,让我们为顶点添加一些运动:

我们将使用时间和振幅uniform在X轴上添加一些运动,并用它创建blob形状:

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
const baseMaterial = new THREE.RawShaderMaterial({
  // 一些uniforms
  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);
    }
  `,
});

export function Scene() {
  useFrame((state) => {
    // 更新时间uniform
    baseMaterial.uniforms.uTime.value = state.clock.elapsedTime;
  });

  return (
    <mesh material={baseMaterial}>
      <sphereGeometry args={[1, 32, 32]} />
    </mesh>
  );
}

现在,我们可以看到球体像blob一样移动:

现在,让我们使用实例化渲染一千个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中计算每个顶点的位置时,我们为所有顶点返回了相同的位置,所有这些属性对所有球体都是相同的,所以它们最终在同一位置:

1
2
3
4
5
vec3 blobShift = movement(position);
vec4 modelPosition = modelMatrix * vec4(deformedPosition, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPosition = projectionMatrix * viewPosition;
gl_Position = projectionPosition;

要解决此问题,我们需要使用一个名为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
26
27
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
23
24
25
26
27
// 告诉TypeScript我们的自定义属性
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
10
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
29
30
31
32
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 设计