[Performance] Optimizing 3M Instanced Grass in Three.js

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


1 Like

Avoid doing individual blades, and do clumps of grass on quads instead.

so merge somegrass to make that ? and instance them ?

Yeah. that’s one approach. but you’ll still be limited by poly count. the safe/sweet spot for total number of triangles is ~3million.. so.. if you’re rendering 3 million instances of an actual 3d blade of grass model (~20 triangles.. ) You’re about 20x over budget.

If, instead you use an image of a grass clump, on a 2 triangle quad.. you can probably push a few million of them.

If you try to merge your individual blades into a larger clump, you will probably get better performance, but you’re potentially moving the bottleneck from being transform bound, to vertex/triangle bound.

These numbers will vary from hardware to hardware, and also there are other major factors that influence rendering performance, a big one being “overdraw”… so if you can sort your instances front to back, you’ll get better performance, at the cost of doing the sorting somehow (probably on the CPU)..

From gpt.. list of major gpu bottlenecks:


1. **Shader/Compute Bound** – Too much math or complex shaders.
2. **Pixel Fill-Rate Bound** – Too many fragments per pixel (e.g. overdraw, transparency).
3. **Memory Bandwidth Bound** – Too much data moving to/from VRAM.
4. **Memory Latency Bound** – Random or inefficient memory access patterns.
5. **Geometry Bound** – Too many vertices or primitives.
6. **CPU/Draw Call Bound** – Too many draw calls or state changes from CPU.
7. **Synchronization Bound** – Stalls from GPU-CPU sync points or readbacks.

Let me know if you want a cheat-sheet or visual for quick reference.

so better using png than glb ? hmm lets try

1 Like

yeah! I think most/all games with large amounts of foliage/open world, use quads/sprites somewhere in the pipeline to increase throughput.

1 Like

Your grass looks pretty cool regardless.. very botw. :smiley: are you following simondevs tutorial?

1 Like

yaps, the best

1 Like

no, i cant pay for his courses

yeah i tried it and it worked haha ​​maybe i will make it even better

check it my demo in 3D Game project

2 Likes