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网站时,我们设法破解了自己的实现。
批处理网格变形:变形支持实例化但不支持批处理网格。如果想自己实现,建议阅读相关说明。