前言

本文介绍如何使用Three.js渲染高性能拟真草地,如下图所示,渲染20000棵草并流畅运行。

初始场景

创建threejs场景并添加天空球、地面,基础内容不再赘述。

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
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
let scene = new THREE.Scene();

let camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);

camera.position.set(0, 9, 57)
let renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 加载天空球
new THREE.TextureLoader().load('/textures/day.jpg', (loaded) => {
scene.background = loaded;
});
// 加载地面
const range = 100;
let planeGeometry = new THREE.PlaneGeometry(range, range, 1, 1);
const textureLoader = new THREE.TextureLoader();
textureLoader.load('/textures/ground.jpg', (texture) => {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(10, 15);
let material = new THREE.MeshBasicMaterial({
side: THREE.DoubleSide,
map: texture,
color: 0xb79f76
});
let mesh = new THREE.Mesh(planeGeometry, material);
mesh.rotation.x = -Math.PI / 2;
scene.add(mesh);

});

let controls = new OrbitControls(camera, renderer.domElement);
function animate(time) {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();

image.png

绘制第一棵草

我们将单棵草进行简化,将它定义为一个五边形,由五个顶点构成,由下至上,由宽变窄
未命名绘图.jpg
下面代码中的vertexesindices分别代表第一棵草的顶点坐标面片索引,他们的定义规则和上图一致,可对照理解。

1
2
3
4
5
6
7
8
9
10
11
12
 let vertexes = [
[-0.5, 0, 0],
[0.5, 0, 0],
[-0.3, 4, 0],
[0.3, 4, 0],
[0, 8, 0],
];
let indices = [
0, 1, 2,
1, 3, 2,
2, 3, 4
];

接着使用THREE.BufferGeometry来绘制它。

1
2
3
4
5
6
7
8
9
10
11
12
13
let grassGeometry = new THREE.BufferGeometry();
let grassMaterial = new THREE.MeshBasicMaterial({
color: 0x86d30e,
side: THREE.DoubleSide
});
grassGeometry.setAttribute(
'position',
new THREE.BufferAttribute(new Float32Array(vertexes.flat()), 3)
);
grassGeometry.setIndex(indices);

let grass = new THREE.Mesh(grassGeometry, grassMaterial);
scene.add(grass);

image.png

绘制整片草

下面在整片区域内的随机位置来创建草,我们只需要修重新定义vertexesindices,其余代码保持不变。关键的一点是无论渲染单棵草还是多棵草,都只创建一个BufferGeometry,这样做可以降低绘制调用的次数,大幅提升性能。

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
let vertexes = [];
let indices = [];
let getRandom = function(min, max) {
return Math.random() * (max - min) + min;
}
const count = 1000;
for (let i = 0; i < count; i++) {
//草中心的位置
let x = getRandom(-range / 2, range / 2);
let z = getRandom(-range / 2, range / 2);
const height = 8;//草的高度
const widthBottom = 0.3;//草下半段的宽度
const widthTop = 0.2;//草上半段的宽度
vertexes.push([
[x - widthBottom / 2, 0, z],
[x + widthBottom / 2, 0, z],
[x - widthTop / 2, height / 2, z],
[x + widthTop / 2, height / 2, z],
[x, height, z],
].flat());

let startIndex = i * 5;
indices.push(
startIndex, startIndex + 1, startIndex + 2,
startIndex + 1, startIndex + 3, startIndex + 2,
startIndex + 2, startIndex + 3, startIndex + 4
);
}

image.png

增加随机性

在上面的基础上再为草地增加一些随机性,包括单棵草的宽度、朝向、高度、草尖的倾斜角度。仍然只需要重新定义vertexesindices。下面使用矩阵修改草的朝向(绕Y轴的旋转角度),因此注意还需要创建两个平移矩阵,在旋转前将草移动至原点,施加旋转后再将其平移至原始位置。

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
let vertexes = [];
let indices = [];
let getRandom = function (min, max) {
return Math.random() * (max - min) + min;
}
const count = 1000;
for (let i = 0; i < count; i++) {
//草中心的位置
let x = getRandom(-range / 2, range / 2);
let z = getRandom(-range / 2, range / 2);
const angleBottom = getRandom(0, Math.PI * 2);//草整体的朝向角度
const angleTop = getRandom(-0.2, 0.2);//草尖的倾斜角度
const height = getRandom(2, 5);//草的高度
const widthBottom = 0.3;//草下半段的宽度
const widthTop = 0.2;//草上半段的宽度

let tempVertexes = [
[x - widthBottom / 2, 0, z],
[x + widthBottom / 2, 0, z],
[x - widthTop / 2, height / 2, z],
[x + widthTop / 2, height / 2, z],
[x, height, z],
];
//移动至中心位置的矩阵
let translateToOrigin = new THREE.Matrix4().makeTranslation(-x, -height / 2, -z);
//从中心移动至原始位置的矩阵
let translateBack = new THREE.Matrix4().makeTranslation(x, height / 2, z);
//修改草朝向的矩阵
let rotationY = new THREE.Matrix4().makeRotationY(angleBottom);
tempVertexes.map((vertex, index) => {
vertex = new THREE.Vector3(...vertex);
vertex.applyMatrix4(translateToOrigin);
vertex.applyMatrix4(rotationY);
if (index == 4) {
//下标为4则为草尖的顶点,对它施加绕z轴的旋转矩阵实现草尖倾斜的效果
vertex.applyMatrix4(new THREE.Matrix4().makeRotationZ(angleTop));
}
vertex.applyMatrix4(translateBack);
vertexes.push(...vertex.toArray());
})
let startIndex = i * 5;
indices.push(
startIndex, startIndex + 1, startIndex + 2,
startIndex + 1, startIndex + 3, startIndex + 2,
startIndex + 2, startIndex + 3, startIndex + 4
);
}

image.png

增加材质

我们通过着色器形式来实现草的摆动效果和光照效果,因此将材质替换为THREE.ShaderMaterial,其他部分保持不变。

uniform变量

首先定义好着色器所需要的uniform变量,暂时使用最基础的vertexShadervertexShader。请参照下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let grassMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },//时间变量,用于驱动动画效果
uLightColor: { value: new THREE.Color(0xd9d9d9) },//平行光颜色
uAmbientLight: { value: new THREE.Color(0xadadad) },//环境光颜色
uColor: { value: new THREE.Color(0x43c70d) },//草颜色
uLightDirection: { value: new THREE.Vector3(-10, -10, 0).normalize() },//平行光方向
uWindDirection: { value: new THREE.Vector2(0.7, 0.2).normalize() } // 风方向
},
side: THREE.DoubleSide,
vertexShader:`  
        void main() {
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }`,
fragmentShader: `  
        uniform vec3 uColor;
        void main() {
            gl_FragColor = vec4(uColor, 1.0);
        }`,
})

另外别忘了在animate函数中更新着色器里的驱动动画效果的变量:

1
grassMaterial.uniforms.uTime.value = time;

image.png

顶点着色器

顶点着色器主要用来实现摆动效果,并向片元着色器发送顶点的坐标、法线信息。

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
uniform float uTime;//时间变量,用于驱动动画效果
uniform vec2 uWindDirection;//风方向

varying vec3 vPosition;//传递到片元着色器中的顶点坐标
varying vec3 vNormal;//传递到片元着色器中的法线信息

void main() {
vPosition = position;
vNormal = normalize(normalMatrix * normal);

if (position.y > 0.1) {
//结合空间位置与风向计算当前顶点的空间因子
//简而言之,为了让草的摆动更加自然,将摆动幅度计算与其空间位置联系起来
//让不同位置的草在同一时刻有不同的摆动幅度
float factor = dot(vPosition.xz, uWindDirection) * 0.06;

//基础摆动幅度,结合空间因子传入正弦函数形成周期性差异化运动
float swayAmplitude = sin(uTime / 500.0 + factor);

//摆动幅度随高度增加而增强,为了形成更拟真的效果这里使用平方增长而非线性增长
float swayStrength = swayAmplitude * vPosition.y * vPosition.y * 0.07;

//根据风向将摆动应用到 x 和 z 方向
vPosition.x += swayStrength * uWindDirection.x;
vPosition.z += swayStrength * uWindDirection.y;
}
gl_Position = projectionMatrix * modelViewMatrix * vec4(vPosition, 1.0);
}

有点意思了是不是😎

片元着色器

片元着色器主要用来实现光照效果,之所以添加环境光照是为了柔和阴影。

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
varying vec3 vPosition;// 从顶点着色器传入的顶点位置
varying vec3 vNormal;// 从顶点着色器传入的顶点法线

uniform vec3 uLightColor;// 平行光源颜色
uniform vec3 uAmbientLight;// 环境光颜色
uniform vec3 uColor;// 基础颜色(用于草的颜色)
uniform vec3 uLightDirection;// 光源方向

void main() {
//根据顶点高度(y 值)进行颜色插值,模拟草由下到上的渐变效果
vec3 color = mix(uColor * 0.5, uColor, vPosition.y);

//计算光照方向与法线之间的夹角余弦值(漫反射系数)
//使用 max 避免负值
float nDotL = max(dot(uLightDirection, vNormal), 0.0);

//漫反射光照=光源颜色 * 材质颜色 * 漫反射系数
vec3 diffuse = uLightColor * color * nDotL;

//环境光照=环境光颜色 * 材质颜色
vec3 ambient = uAmbientLight * color;

//最终输出颜色为漫反射 + 环境光
gl_FragColor = vec4(diffuse + ambient, 1.0);
}

同时绘制数量改为20000:

1
const count = 20000;

计算法线

到此大功告成,激动人心的时刻到了!
u=739645572,825309115&fm=253&fmt=auto&app=138&f=JPEG.webp

看看最后的运行效果:

7i3nx-ursoo.gif

说不出哪里不对,但总觉得少了点什么……

微信图片_2025-07-04_170720_760.jpg

由上图可以看出,在片元着色器中添加的平行光照并没有起效。原因在于我们手动构建了顶点数据,但没有提供normal属性。Three.js不会为此主动生成法线,片元着色器中自然就无法正确计算入射角,需要手动调用来计算顶点法线。

1
grassGeometry.computeVertexNormals();

这次真的完成了!
4akpy-pmcyc.gif

完整代码

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
let scene = new THREE.Scene();

let camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);

camera.position.set(0, 9, 57)
let renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
new THREE.TextureLoader().load('/textures/day.jpg', (loaded) => {
loaded.mapping = THREE.EquirectangularReflectionMapping
scene.background = loaded
scene.environment = loaded
});

let controls = new OrbitControls(camera, renderer.domElement);



const range = 100;
let planeGeometry = new THREE.PlaneGeometry(range, range, 1, 1);
const textureLoader = new THREE.TextureLoader();


textureLoader.load('/textures/ground.jpg', (texture) => {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(10, 15);
let material = new THREE.MeshBasicMaterial({
side: THREE.DoubleSide,
map: texture,
color: 0xb79f76
});
let mesh = new THREE.Mesh(planeGeometry, material);
mesh.rotation.x = -Math.PI / 2;
scene.add(mesh);

})
const grassMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },//时间变量,用于驱动动画效果
uLightColor: { value: new THREE.Color(0xd9d9d9) },//平行光颜色
uAmbientLight: { value: new THREE.Color(0xadadad) },//环境光颜色
uColor: { value: new THREE.Color(0x43c70d) },//草颜色
uLightDirection: { value: new THREE.Vector3(-1.0, -1.0, 0).normalize() },//平行光方向
uWindDirection: { value: new THREE.Vector2(0.8,0.4).normalize() } // 风方向
},
side: THREE.DoubleSide,
vertexShader: `
uniform float uTime;//时间变量,用于驱动动画效果
uniform vec2 uWindDirection;//风方向

varying vec3 vPosition;//传递到片元着色器中的顶点坐标
varying vec3 vNormal;//传递到片元着色器中的法线信息

void main() {
vPosition = position;
vNormal = normalize(normalMatrix * normal);
if (position.y > 0.1) {
//结合空间位置与风向计算当前顶点的空间因子
//简而言之,为了让草的摆动更加自然,将摆动幅度计算与其空间位置联系起来
//让不同位置的草在同一时刻有不同的摆动幅度
float factor = dot(vPosition.xz, uWindDirection) * 0.06;

//基础摆动幅度,结合空间因子传入正弦函数形成周期性差异化运动
float swayAmplitude = sin(uTime / 500.0 + factor);

//摆动幅度随高度增加而增强,为了形成更拟真的效果这里使用平方增长而非线性增长
float swayStrength = swayAmplitude * vPosition.y * vPosition.y * 0.07;

//根据风向将摆动应用到 x 和 z 方向
vPosition.x += swayStrength * uWindDirection.x;
vPosition.z += swayStrength * uWindDirection.y;
}
gl_Position = projectionMatrix * modelViewMatrix * vec4(vPosition, 1.0);
}`,
fragmentShader: `
varying vec3 vPosition;// 从顶点着色器传入的顶点位置
varying vec3 vNormal;// 从顶点着色器传入的顶点法线

uniform vec3 uLightColor;// 平行光源颜色
uniform vec3 uAmbientLight;// 环境光颜色
uniform vec3 uColor;// 基础颜色(用于草的颜色)
uniform vec3 uLightDirection;// 光源方向

void main() {
//根据顶点高度(y 值)进行颜色插值,模拟草由下到上的渐变效果
vec3 color = mix(uColor * 0.5, uColor, vPosition.y);

//计算光照方向与法线之间的夹角余弦值(漫反射系数)
//使用 max 避免负值
float nDotL = max(dot(uLightDirection, vNormal), 0.0);

//漫反射光照=光源颜色 * 材质颜色 * 漫反射系数
vec3 diffuse = uLightColor * color * nDotL;

//环境光照=环境光颜色 * 材质颜色
vec3 ambient = uAmbientLight * color;

//最终输出颜色为漫反射 + 环境光
gl_FragColor = vec4(diffuse + ambient, 1.0);
}`,
})
let vertexes = [];
let indices = [];
let getRandom = function (min, max) {
return Math.random() * (max - min) + min;
}
const count = 20000;
for (let i = 0; i < count; i++) {
//草中心的位置
let x = getRandom(-range / 2, range / 2);
let z = getRandom(-range / 2, range / 2);
const angleBottom = getRandom(0, Math.PI * 2);//草整体的朝向角度
const angleTop = getRandom(-0.2, 0.2);//草尖的倾斜角度
const height = getRandom(2, 5);//草的高度
const widthBottom = 0.3;//草下半段的宽度
const widthTop = 0.2;//草上半段的宽度

let tempVertexes = [
[x - widthBottom / 2, 0, z],
[x + widthBottom / 2, 0, z],
[x - widthTop / 2, height / 2, z],
[x + widthTop / 2, height / 2, z],
[x, height, z],
];
//移动至中心位置的矩阵
let translateToOrigin = new THREE.Matrix4().makeTranslation(-x, -height / 2, -z);
//从中心移动至原始位置的矩阵
let translateBack = new THREE.Matrix4().makeTranslation(x, height / 2, z);
//修改草朝向的矩阵
let rotationY = new THREE.Matrix4().makeRotationY(angleBottom);
tempVertexes.map((vertex, index) => {
vertex = new THREE.Vector3(...vertex);
vertex.applyMatrix4(translateToOrigin);
vertex.applyMatrix4(rotationY);
if (index == 4) {
//下标为4则为草尖的顶点,对它施加绕z轴的旋转矩阵实现草尖倾斜的效果
vertex.applyMatrix4(new THREE.Matrix4().makeRotationZ(angleTop));
}
vertex.applyMatrix4(translateBack);
vertexes.push(...vertex.toArray());
})
let startIndex = i * 5;
indices.push(
startIndex, startIndex + 1, startIndex + 2,
startIndex + 1, startIndex + 3, startIndex + 2,
startIndex + 2, startIndex + 3, startIndex + 4
);
}

let grassGeometry = new THREE.BufferGeometry();
grassGeometry.setAttribute(
'position',
new THREE.BufferAttribute(new Float32Array(vertexes.flat()), 3)
);
grassGeometry.setIndex(indices);
grassGeometry.computeVertexNormals();

let grass = new THREE.Mesh(grassGeometry, grassMaterial);
scene.add(grass);
function animate(time) {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
grassMaterial.uniforms.uTime.value = time
}
animate();

总结

我们从最基础的单棵草模型开始,逐步构建出一个拥有20000棵草的高性能拟真草地场景。整个过程包括Three.js中几何体、材质、着色器的基本使用,并通过合并几何减少绘制调用、使用Shader控制动画等优化手段,实现了在浏览器中流畅运行的大规模草地渲染效果。

希望这篇文章能为你提供实用的参考,如有改进之处,欢迎在评论区交流。🌿✨