前言
本文介绍如何使用Three.js渲染高性能拟真草地,如下图所示,渲染20000棵草并流畅运行(GIF图帧数比实际低)。
🕹️🕹️在线Demo🕹️🕹️
初始场景
创建threejs场景并添加天空球、地面,基础内容不再赘述。
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();
绘制第一棵草
我们将单棵草进行简化,将它定义为一个五边形,由五个顶点构成,由下至上,由窄变宽。
下面代码中的
vertexes的indices分别代表第一棵草的顶点坐标和面片索引,他们的定义规则和上图一致,可对照理解。
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来绘制它。
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);
绘制整片草
下面在整片区域内的随机位置来创建草,我们只需要修重新定义vertexes和indices,其余代码保持不变。关键的一点是无论渲染单棵草还是多棵草,都只创建一个BufferGeometry,这样做可以降低绘制调用的次数,大幅提升性能。
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
);
}
增加随机性
在上面的基础上再为草地增加一些随机性,包括单棵草的宽度、朝向、高度、草尖的倾斜角度。仍然只需要重新定义vertexes和indices。下面使用矩阵修改草的朝向(绕Y轴的旋转角度),因此注意还需要创建两个平移矩阵,在旋转前将草移动至原点,施加旋转后再将其平移至原始位置。
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
);
}
增加材质
我们通过着色器形式来实现草的摆动效果和光照效果,因此将材质替换为THREE.ShaderMaterial,其他部分保持不变。
uniform变量
首先定义好着色器所需要的uniform变量,暂时使用最基础的vertexShader和vertexShader。请参照下面代码:
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函数中更新着色器里的驱动动画效果的变量:
grassMaterial.uniforms.uTime.value = time;
顶点着色器
顶点着色器主要用来实现摆动效果,并向片元着色器发送顶点的坐标、法线信息。
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);
}
有点意思了是不是😎
片元着色器
片元着色器主要用来实现光照效果,之所以添加环境光照是为了柔和阴影。
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:
const count = 20000;
计算法线
到此大功告成,激动人心的时刻到了!
看看最后的运行效果:
说不出哪里不对,但总觉得少了点什么……
由上图可以看出,在片元着色器中添加的平行光照并没有起效。原因在于我们手动构建了顶点数据,但没有提供normal属性。Three.js不会为此主动生成法线,片元着色器中自然就无法正确计算入射角,需要手动调用来计算顶点法线。
grassGeometry.computeVertexNormals();
这次真的完成了!
完整代码
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控制动画等优化手段,实现了在浏览器中流畅运行的大规模草地渲染效果。
希望这篇文章能为你提供实用的参考,如有改进之处,欢迎在评论区交流。🌿✨