Threejs实现拟真草地

1,064 阅读9分钟

前言

本文介绍如何使用Three.js渲染高性能拟真草地,如下图所示,渲染20000棵草并流畅运行(GIF图帧数比实际低)。

🕹️🕹️在线Demo🕹️🕹️

4akpy-pmcyc.gif

初始场景

创建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();

image.png

绘制第一棵草

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

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);

image.png

绘制整片草

下面在整片区域内的随机位置来创建草,我们只需要修重新定义vertexesindices,其余代码保持不变。关键的一点是无论渲染单棵草还是多棵草,都只创建一个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
    );
}

image.png

增加随机性

在上面的基础上再为草地增加一些随机性,包括单棵草的宽度、朝向、高度、草尖的倾斜角度。仍然只需要重新定义vertexesindices。下面使用矩阵修改草的朝向(绕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
    );
}

image.png

增加材质

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

uniform变量

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

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;

image.png

顶点着色器

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

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);
}

qr7ui-7dtbb.gif

有点意思了是不是😎

片元着色器

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

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;

计算法线

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

看看最后的运行效果:

7i3nx-ursoo.gif

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

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

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

grassGeometry.computeVertexNormals();

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

完整代码

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控制动画等优化手段,实现了在浏览器中流畅运行的大规模草地渲染效果。

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