Three.js 草地着色器教程
草地着色器 ·Grass Shader· ▶ 在线运行案例
- 案例合集:三维可视化功能案例(threehub.cn)
- 开源仓库github地址:https://github.com/z2586300277/three-cesium-examples
- 400个案例代码:网盘链接
你将学到什么
- 程序化生成草叶三角面(非模型导入)
- 自定义
GrassGeometry extends BufferGeometry - 顶点着色器sin 风摆+ 片元云影采样
gl_VertexID % 5区分草尖与草身
效果说明
半径 50 的圆盘上10 万根草叶随风摇摆,云纹理在草面缓慢漂移,底部圆形地面共用同一 shader。
核心概念
草叶几何
每根草5 顶点 / 3 三角面:底边两角 + 中段两角 + 尖端。随机:
yaw:绕 Y 轴朝向bend:尖端弯曲方向height:高度 ± 随机变化
class GrassGeometry extends THREE.BufferGeometry { constructor(size, count) { for (let i = 0; i < count; i++) { const radius = (size / 2) * Math.random(); const theta = Math.random()2Math.PI; const x = radius * Math.cos(theta); const y = radius * Math.sin(theta); const blade = this.computeBlade([x, 0, y], i); // push positions, uvs, indices } } }顶点风动
bool isTip = (gl_VertexID + 1) % 5 == 0;
float waveDistance = isTip ? 0.3 : 0.1; vPosition.x += sin((uTime / 500.0) + uv.x10.0)waveDistance;
草尖摆动幅度更大,视觉更自然。
片元着色
- 按
vPosition.y混深浅绿 texture2D(uCloud, vUv)叠云影(UV 随 uTime 平移)- 简单法线点乘做明暗
实现步骤
- 定义
BLADE_WIDTH/HEIGHT等常量 GrassGeometry生成 position / uv / indexShaderMaterial写 vertex + fragment,uniform:uTime、uCloudGrass类继承 Mesh,update(time)更新 uTime- 同材质
CircleGeometry作地面 代码要点
import * as THREE from 'three'import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
const box = document.getElementById('box') const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(75, box.clientWidth / box.clientHeight, 0.1, 1000) camera.position.set(0, 10, 10) const renderer = new THREE.WebGLRenderer({ antialias: true , alpha: true, logarithmicDepthBuffer: true}) renderer.setSize(box.clientWidth, box.clientHeight) box.appendChild(renderer.domElement) new OrbitControls(camera, renderer.domElement) window.onresize = () => { renderer.setSize(box.clientWidth, box.clientHeight) camera.aspect = box.clientWidth / box.clientHeight camera.updateProjectionMatrix() } scene.background = new THREE.CubeTextureLoader().load([0, 1, 2, 3, 4, 5].map(k => (FILE_HOST + 'files/sky/skyBox0/' + (k + 1) + '.png')));
let grass = null animate() function animate(time) { if(grass) grass.update(time); requestAnimationFrame(animate) renderer.render(scene, camera) }
const BLADE_WIDTH = 0.1 const BLADE_HEIGHT = 0.8 const BLADE_HEIGHT_VARIATION = 0.6 const BLADE_VERTEX_COUNT = 5 const BLADE_TIP_OFFSET = 0.1
function interpolate(val, oldMin, oldMax, newMin, newMax) { return ((val - oldMin) * (newMax - newMin)) / (oldMax - oldMin) + newMin }
class GrassGeometry extends THREE.BufferGeometry { constructor(size, count) { super()
const positions = [] const uvs = [] const indices = []
for (let i = 0; i < count; i++) { const surfaceMin = (size / 2) * -1 const surfaceMax = size / 2 const radius = (size / 2) * Math.random() const theta = Math.random()2Math.PI
const x = radius * Math.cos(theta) const y = radius * Math.sin(theta)
uvs.push( ...Array.from({ length: BLADE_VERTEX_COUNT }).flatMap(() => [ interpolate(x, surfaceMin, surfaceMax, 0, 1), interpolate(y, surfaceMin, surfaceMax, 0, 1) ]) )
const blade = this.computeBlade([x, 0, y], i) positions.push(...blade.positions) indices.push(...blade.indices) }
this.setAttribute( 'position', new THREE.BufferAttribute(new Float32Array(positions), 3) ) this.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), 2)) this.setIndex(indices) this.computeVertexNormals() }
// Grass blade generation, covered in https://smythdesign.com/blog/stylized-grass-webgl // TODO: reduce vertex count, optimize & possibly move to GPU computeBlade(center, index = 0) { const height = BLADE_HEIGHT + Math.random() * BLADE_HEIGHT_VARIATION const vIndex = index * BLADE_VERTEX_COUNT
// Randomize blade orientation and tip angle const yaw = Math.random()Math.PI2 const yawVec = [Math.sin(yaw), 0, -Math.cos(yaw)] const bend = Math.random()Math.PI2 const bendVec = [Math.sin(bend), 0, -Math.cos(bend)]
// Calc bottom, middle, and tip vertices const bl = yawVec.map((n, i) => n(BLADE_WIDTH / 2)1 + center[i]) const br = yawVec.map((n, i) => n(BLADE_WIDTH / 2)-1 + center[i]) const tl = yawVec.map((n, i) => n(BLADE_WIDTH / 4)1 + center[i]) const tr = yawVec.map((n, i) => n(BLADE_WIDTH / 4)-1 + center[i]) const tc = bendVec.map((n, i) => n * BLADE_TIP_OFFSET + center[i])
// Attenuate height tl[1] += height / 2 tr[1] += height / 2 tc[1] += height
return { positions: [...bl, ...br, ...tr, ...tl, ...tc], indices: [ vIndex, vIndex + 1, vIndex + 2, vIndex + 2, vIndex + 4, vIndex + 3, vIndex + 3, vIndex, vIndex + 2 ] } } }
const cloudTexture = new THREE.TextureLoader().load(FILE_HOST + 'threeExamples/shader/cloud.jpg') cloudTexture.wrapS = cloudTexture.wrapT = THREE.RepeatWrapping
class Grass extends THREE.Mesh { constructor(size, count) { const geometry = new GrassGeometry(size, count) const material = new THREE.ShaderMaterial({ uniforms: { uCloud: { value: cloudTexture }, offsetX: { value: 0.5 }, offsetY: { value: 0.3 }, uTime: { value: 0 }, }, side: THREE.DoubleSide, vertexShader:
uniform float uTime; uniform float offsetX; uniform float offsetY; varying vec3 vPosition; varying vec2 vUv; varying vec3 vNormal; float wave(float waveSize, float tipDistance, float centerDistance) { // Tip is the fifth vertex drawn per blade bool isTip = (gl_VertexID + 1) % 5 == 0; float waveDistance = isTip ? tipDistance : centerDistance; return sin((uTime / 500.0) + waveSize) * waveDistance; } void main() { vPosition = position; vUv = uv; // Cloud shadow move vUv.x += uTime0.0001offsetX; vUv.y += uTime0.0001offsetY; vNormal = normalize(normalMatrix * normal); if (vPosition.y < 0.0) { vPosition.y = 0.0; } else { vPosition.x += wave(uv.x * 10.0, 0.3, 0.1); } gl_Position = projectionMatrixmodelViewMatrixvec4(vPosition, 1.0); }, fragmentShader:uniform sampler2D uCloud; uniform float uTime; varying vec3 vPosition; varying vec2 vUv; varying vec3 vNormal; vec3 green = vec3(0.2, 0.6, 0.3); void main() { vec3 color = mix(green * 0.7, green, vPosition.y); color = mix(color, texture2D(uCloud, vUv).rgb, 0.4); float lighting = normalize(dot(vNormal, vec3(10))); gl_FragColor = vec4(color + lighting * 0.03, 1.0); }, }) super(geometry, material) const floor = new THREE.Mesh( new THREE.CircleGeometry(size / 2, 8).rotateX(Math.PI / 2), material ) floor.position.y = -Number.EPSILON this.add(floor)} update(time) { this.material.uniforms.uTime.value = time } }
grass = new Grass(50, 100000); scene.add(grass);完整源码:GitHub
小结
- 本文提供草地着色器完整 Three.js 源码与在线 Demo,建议先运行案例再改 uniform/参数做二次实验
- 更多 Three.js 实战案例见 three-cesium-examples 合集 与 GitHub 开源仓库
