Lesson 46 - Three.js Game Development
Three.js Vegetation and Wind with Instancing and Shader Motion
A convincing biome needs thousands of small decisions without thousands of draw calls. We will scatter vegetation deterministically and animate it with one shared wind model.
1. Draw many grass blades in one call
InstancedMesh shares geometry and material while storing a matrix per blade. Use a reusable dummy object to compose transforms without allocating matrices in the loop.
const blade = new THREE.PlaneGeometry(0.16, 0.9);
blade.translate(0, 0.45, 0);
const material = new THREE.MeshStandardMaterial({ color: 0x4f8a42, side: THREE.DoubleSide });
const grass = new THREE.InstancedMesh(blade, material, 5000);
const dummy = new THREE.Object3D();
for (let i = 0; i < grass.count; i++) {
const x = (random() - 0.5) * 100;
const z = (random() - 0.5) * 100;
dummy.position.set(x, heightAt(x, z), z);
dummy.rotation.y = random() * Math.PI;
dummy.updateMatrix();
grass.setMatrixAt(i, dummy.matrix);
}
How this code works
Execution flow
One blade geometry and material feed an InstancedMesh with thousands of slots. A reusable Object3D receives each seeded position and rotation, calculates its matrix, and writes that matrix into the corresponding GPU instance entry.
Key Three.js decisions
InstancedMesh collapses identical geometry and material into one draw call, while per-instance matrices preserve unique transforms. Translating the blade origin to its base makes rotation and shader wind keep roots attached to terrain.
Adapt it
Add per-instance color for biome variation, scale matrices for height diversity, or divide grass into a few material groups. Rebuild density only when settings change rather than recreating the instance buffer every frame.
Watch out
Forgetting instanceMatrix.needsUpdate after runtime edits leaves old transforms on the GPU. Instancing does not provide automatic per-object culling or unique materials, so enormous world-spanning batches can render more instances than expected.
2. Reject impossible placement
Scatter candidates first, then reject water, steep slopes, roads, and protected gameplay zones. Density should never override playability.
const normal = new THREE.Vector3();
function canPlant(x, z) {
const y = heightAt(x, z);
if (y <= waterLevel + 0.15) return false;
sampleTerrainNormal(x, z, normal);
if (normal.y < 0.82) return false;
if (distanceToRoad(x, z) < 2.5) return false;
return true;
}
How this code works
Execution flow
Each candidate samples terrain height, rejects submerged points, obtains a terrain normal, rejects steep slopes, checks road clearance, and returns true only when every gameplay and biome condition passes.
Key Three.js decisions
A shared placement predicate keeps visual scatter consistent with terrain and water systems. normal.y is the cosine of slope against world up, making one threshold an efficient steepness test without converting to degrees.
Adapt it
Add exclusion zones around spawn points, objectives, racing lines, buildings, and camera corridors. Use species-specific thresholds so moss prefers damp shade while trees require deeper soil and flowers avoid heavily traveled paths.
Watch out
Retrying until a fixed count succeeds can loop excessively when valid land is scarce. Limit attempts, accept fewer objects, and record rejection reasons in debug mode when vegetation unexpectedly disappears from a biome.
3. Inject a shared wind uniform
Patch the material once and update only uniforms each frame. Vertex height can weight motion so roots stay planted while tips move.
const wind = { time: { value: 0 }, strength: { value: 0.35 } };
material.onBeforeCompile = (shader) => {
shader.uniforms.uWindTime = wind.time;
shader.uniforms.uWindStrength = wind.strength;
shader.vertexShader = shader.vertexShader.replace(
"#include <begin_vertex>",
"#include <begin_vertex>\ntransformed.x += sin(uWindTime + position.y * 2.0) * uWindStrength * uv.y;"
);
};
material.customProgramCacheKey = () => "grass-wind-v1";
How this code works
Execution flow
A shared wind object owns mutable time and strength uniforms. onBeforeCompile inserts those uniforms and one vertex displacement after Three.js creates the standard shader, weighting horizontal bend by blade height through the UV coordinate.
Key Three.js decisions
Shader injection preserves MeshStandardMaterial lighting while adding motion, and shared uniform objects update every material user without recompilation. customProgramCacheKey tells Three.js that this modified shader is a stable distinct program variant.
Adapt it
Add wind direction, gust noise, and instance phase while keeping roots weighted to zero. The same uniform state can drive flowers and tree leaves with different amplitude multipliers so the biome reacts coherently.
Watch out
Changing shader source without a stable cache key can reuse the wrong program or compile excessive variants. If roots slide, the geometry pivot or UV weighting is wrong; if motion freezes, confirm onBeforeCompile ran and uniforms are retained.
4. Drive every plant from one wind state
Trees, grass, flowers, and water should read the same time and strength. Rigid plants such as cacti need lower bend but their branches must inherit trunk motion.
const windState = { time: 0, strength: 0.4, gust: 0 };
function updateWind(dt) {
windState.time += dt;
windState.gust = 0.5 + 0.5 * Math.sin(windState.time * 0.37);
wind.time.value = windState.time;
wind.strength.value = windState.strength * (0.75 + windState.gust * 0.25);
treeSystem.setWind(windState);
cactusSystem.setWind({ ...windState, strength: windState.strength * 0.12 });
}
How this code works
Execution flow
One update advances global wind time, derives a slow gust wave, writes grass uniforms, and forwards normalized state to tree and cactus systems. Cacti receive a much smaller strength because their rigid structure should not bend like grass.
Key Three.js decisions
A shared state synchronizes visual systems and makes one UI slider meaningful. Uniform mutation is cheap, while subsystem adapters preserve different material implementations and biome-specific physical responses.
Adapt it
Feed the same state into water normal motion, airborne particles, and audio volume. Add spatial gust fields when storms need local variation, but keep a common direction and time origin so neighboring vegetation does not contradict itself.
Watch out
Allocating a new spread object for every cactus every frame is unnecessary in a large system; retain normalized adapter state. If branches remain still while trunks move, ensure child materials and cloned materials are registered with the wind updater.
5. Real Supagames example
Production source: js/environment-engine/vegetation/grass-field.js
Play the published example: Valley Racer
The environment engine combines instanced grass, EZ-Tree models, biome scatter rules, tree debris, wind uniforms, density sliders, and special rigid vegetation behavior for desert cacti.
The teaching snippets deliberately isolate one Three.js responsibility at a time. The production file also handles integration, lifecycle, quality settings, and game-specific state, so read it after the smaller examples make sense.
6. Common mistakes
- Creating geometry, materials, vectors, or arrays inside the frame loop and causing avoidable garbage collection pauses.
- Treating the Three.js scene graph as the only source of gameplay state instead of keeping explicit JavaScript state.
- Testing only on a fast desktop and leaving pixel ratio, shadows, and object counts uncapped on mobile devices.
7. Build checklist
- Serve ES modules through HTTP during development instead of opening the page through file://.
- Test resize, pause, restart, and cleanup paths as carefully as the first successful frame.
- Use browser performance tools and an on-screen FPS sample before guessing where a slowdown comes from.
- Verify keyboard, pointer, and touch controls without letting game shortcuts break forms or page navigation.