Lesson 37 - Three.js Game Development

Three.js Geometry, Materials, and Textures for Readable Game Worlds

Shapes become game objects only when geometry, surface response, and texture data agree. We will build assets that remain readable, efficient, and safe to reuse.

Beginner to intermediateThree.js65-85 minutesBufferGeometryPBR materials

1. Read and change geometry attributes

BufferGeometry stores positions as typed arrays. Modify the Y component of each vertex, mark the attribute dirty, then regenerate normals so light follows the new shape.

const geometry = new THREE.PlaneGeometry(20, 20, 40, 40);
geometry.rotateX(-Math.PI / 2);
const positions = geometry.attributes.position;

for (let i = 0; i < positions.count; i += 1) {
  const x = positions.getX(i);
  const z = positions.getZ(i);
  positions.setY(i, Math.sin(x * 0.45) * Math.cos(z * 0.4));
}
positions.needsUpdate = true;
geometry.computeVertexNormals();

How this code works

Execution flow

PlaneGeometry creates a regular vertex grid, rotates it into the ground plane, and exposes its position attribute. The loop reads each X and Z coordinate, computes a height, writes Y, marks the buffer dirty, and recalculates normals for the modified surface.

Key Three.js decisions

BufferGeometry stores compact typed arrays that the GPU can consume directly. Attribute getters and setters preserve the array layout, needsUpdate uploads changed positions, and computeVertexNormals restores lighting after vertex displacement changes the face directions.

Adapt it

Replace the sine formula with seeded noise for hills, a track function for racing terrain, or authored height samples for a level editor. Keep a matching heightAt function so player collision and object placement agree with visible vertices.

Watch out

Changing position data without setting needsUpdate leaves the GPU drawing the old surface. Skipping normal recalculation creates flat or incorrect lighting, while using too many segments increases CPU generation time, memory, and triangle cost without guaranteed visual benefit.

2. Choose a material by gameplay purpose

MeshStandardMaterial reacts to lights and gives useful roughness and metalness controls. Reserve BasicMaterial for unlit markers, debug shapes, or UI-like meshes.

const groundMaterial = new THREE.MeshStandardMaterial({
  color: 0x426f42,
  roughness: 0.92,
  metalness: 0.0,
});
const pickupMaterial = new THREE.MeshStandardMaterial({
  color: 0xffd93d,
  emissive: 0x5a3100,
  emissiveIntensity: 1.2,
  roughness: 0.25,
});
const ground = new THREE.Mesh(geometry, groundMaterial);

How this code works

Execution flow

The sample creates separate Standard materials for ground and pickups, assigning surface response according to gameplay role. The terrain stays rough and nonmetallic, while the collectible uses brighter color and emissive energy to remain visible in shadow.

Key Three.js decisions

MeshStandardMaterial gives one consistent PBR lighting model for most game objects. Roughness controls reflection spread, metalness distinguishes conductive surfaces, and emissive adds self-lit readability without introducing another dynamic light and shadow pass.

Adapt it

Use the ground material for rock, soil, or track variants by adjusting color and texture maps. For hazards, create a shared emissive material with a distinct hue; for debug markers or always-visible guides, choose MeshBasicMaterial deliberately.

Watch out

Setting every object to high emissive intensity flattens the lighting hierarchy and hides depth cues. Creating a unique material per object also increases state changes and memory, so share materials unless an instance truly needs independent runtime properties.

3. Load color textures correctly

Color textures usually need the sRGB color space. Anisotropy improves angled ground surfaces, while repeat wrapping avoids stretching one image across an entire world.

const loader = new THREE.TextureLoader();
const grass = await loader.loadAsync("../assets/ground/grass.webp");
grass.colorSpace = THREE.SRGBColorSpace;
grass.wrapS = grass.wrapT = THREE.RepeatWrapping;
grass.repeat.set(18, 18);
grass.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());

groundMaterial.map = grass;
groundMaterial.needsUpdate = true;

How this code works

Execution flow

TextureLoader fetches the image asynchronously, then the code declares its color space, enables repeating on both axes, sets tile frequency, and chooses anisotropic filtering based on GPU capability. Finally the map is assigned and the material is marked for recompilation.

Key Three.js decisions

Color textures need SRGBColorSpace because their stored values are display colors, not linear lighting data. RepeatWrapping prevents edge clamping, anisotropy improves oblique ground detail, and a capability cap avoids requesting unsupported filtering levels.

Adapt it

Use different repeat values for roads, cliffs, and walls according to their real-world scale. Keep normal, roughness, and displacement maps in linear color space, and load all required maps before hiding a game's loading screen.

Watch out

Applying sRGB to normal or roughness data corrupts material calculations, while omitting it from a color map makes the surface look too dark or washed out. A missing needsUpdate after changing map-related material defines can leave the old shader active.

4. Share geometry, clone material state

Many objects can share immutable geometry. Clone a material only when an instance needs different color or opacity; otherwise every clone increases memory and shader state.

const crateGeometry = new THREE.BoxGeometry(1, 1, 1);
const crateMaterial = new THREE.MeshStandardMaterial({ color: 0x9a653a });

function makeCrate(position, damaged = false) {
  const material = damaged ? crateMaterial.clone() : crateMaterial;
  if (damaged) material.color.setHex(0x5f3928);
  const crate = new THREE.Mesh(crateGeometry, material);
  crate.position.copy(position);
  scene.add(crate);
  return crate;
}

How this code works

Execution flow

The code allocates one crate geometry and one base material, then each factory call creates only a Mesh. Healthy crates reference the shared material; damaged crates clone it before changing color so the base appearance and other instances remain untouched.

Key Three.js decisions

Three.js safely lets many meshes share immutable geometry and material resources, reducing memory and setup work. Material cloning is reserved for independent uniforms or properties because modifying a shared material immediately affects every mesh that references it.

Adapt it

Apply the same pattern to projectiles, coins, trees, or track barriers. For thousands of identical static objects, move one step further to InstancedMesh; for a few highlighted objects, clone only the material and keep geometry shared.

Watch out

Disposing shared geometry when one crate is removed breaks every remaining crate that uses it. Conversely, cloning every material silently increases GPU state and memory, so document ownership and dispose shared resources only when the whole system shuts down.

5. Real Supagames example

Production source: js/environment-engine/terrain/ground.js

Play the published example: Valley Racer

Supagames terrain builds indexed BufferGeometry, updates vertex heights, recalculates normals, and applies biome-aware materials. Peak Hopper uses the same fundamentals for platforms and interactive objects.

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.
Previous: Scene, camera, and renderer Next: Lighting and shadows