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网站,我们设法破解了自己的实现,我邀请您在那里阅读我们的实现。
使用批处理网格进行变形:变形支持实例化但不支持批处理网格。如果您想自己实现它,我建议您阅读这些笔记。