Hi all,
I’ve read many threads in this forum about grass rendering in Three.js, but I still don’t fully understand how to achieve good performance.
Currently, I have:
- 1 million grass blades, using
InstancedMesh
- my plane or ground 100x100 plane geometry
- I want to scale this up to 3 million grass (for a 1000 x 1000 area)
Right now, the FPS is starting to drop, and I haven’t implemented any kind of LOD or frustum culling yet. I’m not sure what the best approach is to improve performance at this scale.
Could anyone share tips or examples on how to:
- Efficiently cull distant or off-screen grass
- Use chunking or LOD for instanced grass
- Animate grass efficiently with shaders
Any help would be really appreciated. Thanks!
Demo here: https://archieve-wildy13.vercel.app/
and my code
import * as THREE from 'three';
import { Announcement } from './assets/js/Announcement.js'
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { PerformanceHelper } from './assets/js/PerformanceHelper.js';
import Stats from 'three/addons/libs/stats.module.js';
class App {
constructor() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xFFFFFF);
this.scene.fog = new THREE.Fog(new THREE.Color(0xFFFFFF), 0, 20)
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
10000
);
this.camera.position.set(0, 2, 5);
this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.shadowMap.enabled = true;
this.renderer.autoClear = false;
this.renderer.shadowMap.type = THREE.PCFShadowMap;
this.renderer.shadowMap.needsUpdate = true;
this.renderer.toneMapping = THREE.ReinhardToneMapping;
this.renderer.toneMappingExposure = 1.2;
this.renderer.outputEncoding = THREE.sRGBEncoding;
this.renderer.setAnimationLoop(this.animate.bind(this));
document.getElementById('container').appendChild(this.renderer.domElement);
window.addEventListener('resize', this.onResize.bind(this));
//this.orbitControls = new OrbitControls(this.camera, this.renderer.domElement);
this.announcement = new Announcement();
this.announcement.text({ content: '3D World\n@author Wildy13', color: 'black' });
document.getElementById('container')?.appendChild(this.announcement.dom);
this.stats = new Stats();
this.stats.dom.style.width = '80px';
this.stats.dom.style.height = '48px';
document.getElementById('container')?.appendChild(this.stats.dom);
this.performance = new PerformanceHelper();
document.getElementById('container')?.appendChild(this.performance.dom);
this.onRegister();
this.onLight();
const skyboxLoader = new THREE.CubeTextureLoader(this.loadingManager);
const skyboxTexture = skyboxLoader.load(
[
"./public/textures/sky box/right.jpg",
"./public/textures/sky box/left.jpg",
"./public/textures/sky box/top.jpg",
"./public/textures/sky box/bottom.jpg",
"./public/textures/sky box/front.jpg",
"./public/textures/sky box/back.jpg",
]
);
this.scene.background = skyboxTexture;
this.scene.environment = skyboxTexture;
this.scene.environmentIntensity = 1.5;
this.onLoad();
document.addEventListener('keydown', this.onKeyDown.bind(this));
document.addEventListener('keyup', this.onKeyUp.bind(this));
}
onRegister() {
this.model = null;
this.clock = new THREE.Clock();
this.group = new THREE.Group();
this.group.add(new THREE.AxesHelper());
this.group.add(this.camera);
this.scene.add(this.group)
this.skeleton = null;
this.animations = null;
this.mixer = null;
this.actions = null;
this.keys = {
forward: null,
backward: null,
left: null,
right: null,
shift: null
};
this.CActions = null;
this.grassCount = 1000000;
this.grassStuff = null;
this.loadingManager = new THREE.LoadingManager();
const progressBar = document.getElementById('progress-bar');
const textProgress = document.getElementById('progress-text');
this.loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => {
const progress = (itemsLoaded / itemsTotal) * 100;
progressBar.style.width = `${progress}%`;
const fileName = url.split('/').pop().split('.')[0];
textProgress.textContent = `Loading ${fileName}`;
};
this.loadingManager.onLoad = () => {
if (progressBar.style.width === '100%') {
document.getElementById('container').style.display = 'block';
document.getElementById('loading').style.display = 'none';
}
};
this.loadingManager.onError = (url) => {
console.error(`There was an error loading ${url}`);
};
}
onLight() {
const directionalLight = new THREE.DirectionalLight(0xffffff, 2.5);
directionalLight.position.set(-10, 20, 10);
directionalLight.castShadow = true;
// Konfigurasi bayangan
directionalLight.shadow.bias = -0.0001;
directionalLight.shadow.radius = 4;
directionalLight.shadow.mapSize.width = 2048 * 2;
directionalLight.shadow.mapSize.height = 2048 * 2;
const d = 30;
directionalLight.shadow.camera.left = -d;
directionalLight.shadow.camera.right = d;
directionalLight.shadow.camera.top = d;
directionalLight.shadow.camera.bottom = -d;
directionalLight.shadow.camera.near = 1;
directionalLight.shadow.camera.far = 100;
this.scene.add(directionalLight);
// Ambient light untuk soft fill
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
this.scene.add(ambient);
}
onLoad() {
const loader = new GLTFLoader(this.loadingManager);
loader.load('./public/models/Soldier.glb', async (gltf) => {
this.model = gltf.scene;
this.group.add(this.model);
this.camera.lookAt(this.model.position);
this.model.traverse((object) => {
if (object.isMesh) {
if (object.name == 'vanguard_Mesh') {
object.castShadow = true;
object.receiveShadow = true;
object.material.shadowSide = THREE.DoubleSide;
object.material.metalness = 1.0;
object.material.roughness = 0.2;
object.material.color.set(1, 1, 1);
object.material.metalnessMap = object.material.map;
} else {
object.material.metalness = 1;
object.material.roughness = 0;
object.material.transparent = true;
object.material.opacity = 0.8;
object.material.color.set(1, 1, 1);
}
}
});
this.skeleton = new THREE.SkeletonHelper(this.model);
this.scene.add(this.skeleton);
this.animations = gltf.animations;
this.mixer = new THREE.AnimationMixer(this.model);
this.actions = {
Idle: this.mixer.clipAction(this.animations[0]),
Walk: this.mixer.clipAction(this.animations[3]),
Run: this.mixer.clipAction(this.animations[1])
};
for (let name in this.actions) {
const action = this.actions[name];
action.enabled = true;
action.setEffectiveTimeScale(1);
action.setEffectiveWeight(1);
}
this.actions.Idle.play();
this.CActions = 'Idle';
this.ground = await this.createGround();
this.scene.add(this.ground);
});
}
async createGround() {
let size = 100;
let mat = new THREE.MeshStandardMaterial({ normalScale: new THREE.Vector2(0.5, 0.5), color: 0x7CFC00, depthWrite: false, roughness: 0.85 })
let g = new THREE.PlaneGeometry(size, size, 50, 50);
g.rotateX(-Math.PI / 2);
this.floor = new THREE.Mesh(g, mat);
this.floor.receiveShadow = true;
const gltf = await new GLTFLoader(this.loadingManager).loadAsync('./public/models/grass.glb');
const grassMesh = gltf.scene.children[0];
const instanceMesh = new THREE.InstancedMesh(
grassMesh.geometry,
new GrassMaterial({ side: THREE.DoubleSide }),
this.grassCount
);
this.scene.add(instanceMesh);
const dummy = new THREE.Object3D();
for (let i = 0; i < this.grassCount; i++) {
const position = new THREE.Vector3(
(Math.random() - 0.5) * size,
0.0,
(Math.random() - 0.5) * size
);
const rotation = new THREE.Euler(0.0, Math.random() * Math.PI * 2.0, 0.0);
const scale = new THREE.Vector3().setScalar(Math.random() * 0.05 + 0.05);
dummy.position.copy(position);
dummy.rotation.copy(rotation);
dummy.scale.copy(scale);
dummy.updateMatrix();
instanceMesh.setMatrixAt(i, dummy.matrix);
instanceMesh.setColorAt(i, new THREE.Color(Math.random() * 0xffffff));
}
this.updateGrass = () => {
const frustum = new THREE.Frustum();
const cameraViewProjectionMatrix = new THREE.Matrix4();
const box = new THREE.Box3();
this.camera.updateMatrixWorld();
instanceMesh.instanceMatrix.needsUpdate = true;
};
instanceMesh.receiveShadow = true;
instanceMesh.instanceMatrix.needsUpdate = true;
instanceMesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(this.grassCount * 3), 3);
instanceMesh.instanceColor.needsUpdate = true;
this.grassStuff = {
clock: new THREE.Clock(),
mesh: instanceMesh,
update: this.updateGrass
};
return this.floor;
}
onKeyDown(event) {
switch (event.code) {
case 'ArrowUp':
case 'KeyW':
this.keys.forward = event.code.toLowerCase();
break;
case 'ArrowDown':
case 'KeyS':
this.keys.backward = event.code.toLowerCase();
break;
case 'ArrowLeft':
case 'KeyA':
this.keys.left = event.code.toLowerCase();
break;
case 'ArrowRight':
case 'KeyD':
this.keys.right = event.code.toLowerCase();
break;
case 'ShiftLeft':
case 'ShiftRight':
this.keys.shift = event.code.toLowerCase();
break;
}
}
onKeyUp(event) {
switch (event.code) {
case 'ArrowUp':
case 'KeyW':
this.keys.forward = null;
break;
case 'ArrowDown':
case 'KeyS':
this.keys.backward = null;
break;
case 'ArrowLeft':
case 'KeyA':
this.keys.left = null;
break;
case 'ArrowRight':
case 'KeyD':
this.keys.right = null;
break;
case 'ShiftLeft':
case 'ShiftRight':
this.keys.shift = null;
break;
}
}
updateCharacter(delta) {
if (!this.mixer || !this.actions) return;
const isAnyMovement =
this.keys.forward !== null ||
this.keys.backward !== null;
const movementState = !isAnyMovement
? 'Idle'
: this.keys.shift !== null
? 'Run'
: 'Walk';
if (this.CActions !== movementState) {
const fromAction = this.actions[this.CActions];
const toAction = this.actions[movementState];
toAction.reset().fadeIn(0.5).play();
fromAction.fadeOut(0.5);
this.CActions = movementState;
}
const speed = movementState === 'Run' ? 5 : movementState === 'Walk' ? 2.5 : 0;
const direction = new THREE.Vector3();
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.group.quaternion);
if (this.keys.forward) direction.add(forward);
if (this.keys.backward) direction.sub(forward);
if (direction.lengthSq() > 0) {
direction.normalize();
this.group.position.addScaledVector(direction, delta * speed);
}
if (this.keys.left) {
this.group.rotation.y += delta * 2;
}
if (this.keys.right) {
this.group.rotation.y -= delta * 2;
}
this.mixer.update(delta);
}
animate() {
// this.orbitControls.update();
this.stats.update();
this.performance.update(this.renderer);
const delta = this.clock.getDelta();
this.updateCharacter(delta);
if (this.grassStuff) {
this.grassStuff.update();
this.grassStuff.mesh.material.uniforms.fTime.value = this.grassStuff.clock.getElapsedTime();
this.grassStuff.mesh.material.uniforms.vPlayerPosition.value.copy(this.group.position);
};
this.renderer.render(this.scene, this.camera);
}
onResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
}
class GrassMaterial extends THREE.ShaderMaterial {
uniforms = {
fTime: { value: 0.0 },
vPlayerPosition: { value: new THREE.Vector3(0.0, -1.0, 0.0) },
fPlayerColliderRadius: { value: 1.1 },
fogColor: { value: new THREE.Color(0xFFFFFF) }, // warna fog
fogNear: { value: 0.0 },
fogFar: { value: 20.0 },
};
vertexShader = `
uniform float fTime;
uniform vec3 vPlayerPosition;
uniform float fPlayerColliderRadius;
varying float fDistanceFromGround;
varying vec3 vInstanceColor;
varying vec3 vWorldPosition;
float rand(float n){return fract(sin(n) * 43758.5453123);}
float rand(vec2 n) {
return fract(sin(dot(n, vec2(12.9898, 4.1414))) * 43758.5453);
}
float createNoise(vec2 n) {
vec2 d = vec2(0.0, 1.0);
vec2 b = floor(n);
vec2 f = smoothstep(vec2(0.0), vec2(1.0), fract(n));
return mix(mix(rand(b), rand(b + d.yx), f.x), mix(rand(b + d.xy), rand(b + d.yy), f.x), f.y);
}
vec3 localToWorld(vec3 target) {
return (modelMatrix * instanceMatrix * vec4(target, 1.0)).xyz;
}
void main() {
fDistanceFromGround = max(0.0, position.y);
vInstanceColor = instanceColor;
vec3 worldPosition = localToWorld(position);
float noise = createNoise(vec2(position.x, position.z)) * 0.6 + 0.4;
// Only sway, no interaction
vec3 sway = 0.1 * vec3(
cos(fTime + position.x * 2.0) * noise * fDistanceFromGround,
0.0,
sin(fTime + position.z * 2.0) * noise * fDistanceFromGround
);
worldPosition += sway;
// Simpan worldPosition ke varying
vWorldPosition = worldPosition;
gl_Position = projectionMatrix * viewMatrix * vec4(worldPosition, 1.0);
}
`;
fragmentShader = `
uniform vec3 fogColor;
uniform float fogNear;
uniform float fogFar;
varying vec3 vWorldPosition;
varying float fDistanceFromGround;
varying vec3 vInstanceColor;
void main() {
vec3 colorDarkest = vec3(24.0 / 255.0, 30.0 / 255.0, 41.0 / 255.0);
vec3 colorBrightest = vec3(88.0 / 255.0, 176.0 / 255.0, 110.0 / 255.0);
vec3 color = mix(colorDarkest, colorBrightest, fDistanceFromGround / 2.0);
color = clamp(color, 0.0, 1.0);
float depth = length(cameraPosition - vWorldPosition);
float fogFactor = smoothstep(fogNear, fogFar, depth);
vec3 finalColor = mix(color, fogColor, fogFactor);
gl_FragColor = vec4(finalColor, 1.0);
}
`;
constructor(props) {
super(props);
}
}
const app = new App();