Three.js From Zero · Article s0-01

Haunted House

Haunted House is Article s0-01 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From Zero S0-01 · Quick Wins

Season 0 · Article 01 · Quick Wins

The iconic spooky-scene project — a creaky house, a graveyard of low-poly tombstones, fog rolling in, and three ghosts swooping around with their own colored point-lights casting moving shadows. Self-contained, ~300 lines, runs anywhere. The "I want to make this" demo that pulls beginners into Three.js.

— fps

Drag to orbit. The scene is laid out in real meters — the house is 4×2.5×4, the ground is 25×25, the ghosts orbit at human eye height. We'll build it piece by piece, name every part, then turn the lights down.

The shopping list

Six things make this scene work, in order:

  1. A fog and a near-black background — the atmosphere lives in the air, not the geometry.
  2. A ground plane with a grass-ish color, big enough that you never see its edge through the fog.
  3. A house made of three primitives — box (walls), cone (roof), plane (door) — plus a sphere or two for bushes.
  4. A graveyard — small boxes scattered in a ring around the house, each rotated and tilted a hair so they look settled in.
  5. An ambient + directional pair for moonlight, plus one little point light at the door.
  6. Three ghosts — point lights, each on its own orbit path with a different color and speed.

That's it. No models, no textures (we use colors and shading), no shaders. The whole scene fits in one file and one <script type="module">.

Step 1 — Fog and the night

Most "spooky" scenes try to fake darkness with dark materials. That doesn't work — your materials need light to render, and what you actually want is the air itself eating the distance. That's Scene.fog:

const scene = new THREE.Scene();
scene.background = new THREE.Color('#0b0b14');
scene.fog = new THREE.Fog('#0b0b14', 4, 18);  // color, near, far

The fog color must match the background or you'll see a halo where geometry ends and sky begins. near is where the fog starts (geometry closer than 4m is fully visible), far is where it's fully opaque. With far = 18 on a 25m ground plane, the edges of the ground vanish naturally — no need for trickery.

Step 2 — The ground

const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(25, 25),
  new THREE.MeshStandardMaterial({ color: '#3a4a2c', roughness: 1 }),
);
ground.rotation.x = -Math.PI / 2;   // lay flat
ground.receiveShadow = true;
scene.add(ground);

A 25×25 plane, rotated -90° on X so it lies flat (planes are vertical by default). Color a desaturated mossy green. Crucially, receiveShadow = true — without it, shadows from the house and ghosts won't land on the ground at all.

We pick MeshStandardMaterial not MeshLambertMaterial for one reason: roughness. You want a slightly less perfect surface than the default. Roughness 1 means no specular highlight, which is right for grass.

Step 3 — The house

We're going to build the house as a Group so we can move/rotate it as one thing later. The house is four primitives: walls (box), roof (cone), door (plane), bushes (sphere).

const house = new THREE.Group();

const walls = new THREE.Mesh(
  new THREE.BoxGeometry(4, 2.5, 4),
  new THREE.MeshStandardMaterial({ color: '#7a5a48', roughness: 0.9 }),
);
walls.position.y = 1.25;            // box origin is its center; raise to ground
walls.castShadow = true;
house.add(walls);

const roof = new THREE.Mesh(
  new THREE.ConeGeometry(3.5, 1.5, 4),    // 4 segments → pyramidal roof
  new THREE.MeshStandardMaterial({ color: '#4b2c1d', roughness: 0.95 }),
);
roof.position.y = 2.5 + 0.75;        // walls top + half cone height
roof.rotation.y = Math.PI / 4;       // align corners with walls
roof.castShadow = true;
house.add(roof);

const door = new THREE.Mesh(
  new THREE.PlaneGeometry(1, 1.8),
  new THREE.MeshStandardMaterial({ color: '#2a1810', roughness: 1, side: THREE.DoubleSide }),
);
door.position.set(0, 0.9, 2.001);   // 2.001 to avoid z-fighting with the wall at z=2
house.add(door);

// Bushes — two of different sizes flanking the door
const bushMat = new THREE.MeshStandardMaterial({ color: '#2c4a1f', roughness: 1 });
const bushA = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 8), bushMat);
bushA.position.set(-0.9, 0.4, 2.3);
bushA.castShadow = true;
house.add(bushA);
const bushB = new THREE.Mesh(new THREE.SphereGeometry(0.35, 12, 8), bushMat);
bushB.position.set(0.95, 0.3, 2.4);
bushB.castShadow = true;
house.add(bushB);

scene.add(house);

Four notes from that block, because beginners trip on each:

  • Box origin is its center. A 2.5-tall box at y=0 half-buries into the ground. Lift it by half its height.
  • The roof is a 4-segment cone — that's a pyramid, not a cone. Rotate it 45° on Y so the corners align with the box.
  • The door has side: THREE.DoubleSide because the player camera will pass by both sides as you orbit, and one-sided plane geometry disappears from the back.
  • That z = 2.001 isn't a typo — the door is in the same plane as the wall it sits in front of. If both are at z = 2, the GPU can't decide which is in front, and you get the flickering pattern called z-fighting. 1mm of clearance fixes it.
Pitfall. Forgetting castShadow = true on a mesh is the #1 reason "shadows aren't working." It's also the #1 reason "shadows work but performance tanked" — every mesh you flag gets included in the shadow pass. Flag only what you need to cast (walls, roof, ghosts) — not bushes so small you can't see their shadow anyway.

Step 4 — The graveyard

The grave-scattering trick is a single loop. Pick a random angle, place a tombstone at a fixed radius from the house, rotate it slightly so it looks weathered.

const graveGeo = new THREE.BoxGeometry(0.55, 0.85, 0.15);
const graveMat = new THREE.MeshStandardMaterial({ color: '#3d3d3d', roughness: 1 });

const graveCount = 36;
for (let i = 0; i < graveCount; i++) {
  const angle = Math.random() * Math.PI * 2;
  const radius = 4 + Math.random() * 6;         // ring 4-10m from center
  const x = Math.cos(angle) * radius;
  const z = Math.sin(angle) * radius;

  const grave = new THREE.Mesh(graveGeo, graveMat);
  grave.position.set(x, 0.4, z);
  grave.rotation.y = (Math.random() - 0.5) * 0.6;
  grave.rotation.z = (Math.random() - 0.5) * 0.4;   // settled / leaning
  grave.castShadow = true;
  scene.add(grave);
}

Key trick: share the geometry and material across all 36 graves. Don't construct a new BoxGeometry per iteration — that's 36 GPU buffers when 1 suffices. The mesh is the cheap part; geometry and materials are the expensive part.

If you wanted thousands of graves, you'd promote this to InstancedMesh (covered in S1-09). For 36, a regular mesh per grave is fine and easier to reason about.

Step 5 — Moonlight

const ambient = new THREE.AmbientLight('#a0b4d6', 0.12);
scene.add(ambient);

const moon = new THREE.DirectionalLight('#b8c8e8', 0.35);
moon.position.set(4, 5, -3);
moon.castShadow = true;
moon.shadow.mapSize.set(1024, 1024);
moon.shadow.camera.near = 1;
moon.shadow.camera.far = 20;
moon.shadow.camera.top = 8;
moon.shadow.camera.bottom = -8;
moon.shadow.camera.left = -8;
moon.shadow.camera.right = 8;
scene.add(moon);

const doorLight = new THREE.PointLight('#ff8a3d', 1.4, 7, 2);
doorLight.position.set(0, 2.2, 2.5);
scene.add(doorLight);

Three lights, three jobs:

  • Ambient (intensity 0.12, bluish) — fills the un-lit faces just enough that they aren't pitch-black. Anything brighter and you lose the dark mood.
  • Directional (the moon) — the only light that casts shadows in this scene. The shadow.camera.{top,bottom,left,right} values define the orthographic frustum that the shadow is rendered through; tight bounds = crisp shadows, loose bounds = blurry. Match it to your scene size.
  • Point light at the door — warm orange, short range, falloff 2. It picks up the door, the bushes, and a little of the wall. It does not cast shadows — we deliberately keep it cheap.
Pitfall. shadow.mapSize defaults to 512×512, which produces visibly pixelated shadow edges. 1024 is the sweet spot for one directional light in a small scene. Don't go to 4096 reflexively — on mobile that's a perf cliff.

Step 6 — The ghosts

Ghosts in this scene are point lights, not meshes. Each one casts colored light on whatever it floats past, and (because we'll flag castShadow) they animate the shadows on the ground. That's the entire effect.

const ghost1 = new THREE.PointLight('#ff3a8b', 2.4, 6, 2);
const ghost2 = new THREE.PointLight('#3affc2', 2.4, 6, 2);
const ghost3 = new THREE.PointLight('#ffcb3a', 2.4, 6, 2);
scene.add(ghost1, ghost2, ghost3);

Now animate them in the loop. Each ghost gets a different orbit radius, height pattern, and angular speed — the differences are what makes the scene look alive instead of mechanical.

renderer.setAnimationLoop((t) => {
  const tSec = t * 0.001;

  // Ghost 1 — fast wide orbit
  const a1 = tSec * 0.7;
  ghost1.position.x = Math.cos(a1) * 6;
  ghost1.position.z = Math.sin(a1) * 6;
  ghost1.position.y = Math.sin(tSec * 3) * 0.5 + 0.8;

  // Ghost 2 — slower, tighter, dips low
  const a2 = -tSec * 0.45;
  ghost2.position.x = Math.cos(a2) * 4.5;
  ghost2.position.z = Math.sin(a2) * 4.5;
  ghost2.position.y = Math.abs(Math.sin(tSec * 2)) * 1.4 + 0.3;

  // Ghost 3 — figure-eight, varying altitude
  const a3 = tSec * 0.6;
  ghost3.position.x = Math.sin(a3 * 2) * 5;
  ghost3.position.z = Math.cos(a3) * 5;
  ghost3.position.y = Math.cos(tSec * 1.5) * 0.6 + 1;

  renderer.render(scene, camera);
});

Three things sell the haunted feel here:

  1. Each ghost moves on its own clock (different multipliers on tSec).
  2. Each ghost moves differently: orbit, dipping orbit, figure-eight. Same shape three times would feel like a fan.
  3. Math.abs(Math.sin(...)) for Ghost 2 gives a pumping motion — it kisses the ground and rises back up, instead of floating evenly.

The renderer setup

One bit we haven't shown — the renderer and camera. Standard for the series, with shadow maps on:

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(mount.clientWidth, mount.clientHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
mount.appendChild(renderer.domElement);

const camera = new THREE.PerspectiveCamera(
  60, mount.clientWidth / mount.clientHeight, 0.1, 50,
);
camera.position.set(8, 4, 10);
camera.lookAt(0, 1, 0);

PCFSoftShadowMap is the soft-shadow filter — slightly more expensive than the default, worth it. Camera far = 50 is generous; you could lower it to 30 since the fog already hides everything past 18m, and a smaller far plane gives you better depth precision.

Common first-time pitfalls

"Everything is black." You forgot renderer.outputColorSpace = THREE.SRGBColorSpace, or you have no ambient light and your moonlight intensity is too low. Bump ambient to 0.3 temporarily to confirm it's a lighting issue, not a transform issue.
"Shadows are visible everywhere except where they should be." You forgot receiveShadow = true on the ground, or your shadow camera frustum (the shadow.camera.{top,bottom,left,right} values) is too small and clips the shadow.
"FPS tanks when I add ghosts." All three ghosts have castShadow = true on a 1024 shadow map — that's three shadow passes per frame. Either drop shadow casting on two of them, drop the map size, or accept it (modern desktop GPUs handle it fine; mobile struggles).
"Fog looks weird at the horizon." Background color doesn't match fog color. They must match — the fog blends with whatever color you set as the background.

Exercises

  1. Add a flickering candle in the window. A small PointLight with a noise-modulated intensity: doorLight.intensity = 1.2 + Math.sin(t * 0.013) * 0.2 + Math.random() * 0.3. The random component is what makes it feel like a real flame.
  2. Replace one of the ghosts with a small glowing mesh. Sphere with MeshBasicMaterial (unlit, so it stays bright in the dark) parented to the point light's position. The light still casts the glow on the scene; the mesh gives it visible form.
  3. Increase grave count to 200. The frame rate will dip. Re-implement using InstancedMesh (see S1-09) and watch FPS recover. The graves are perfect instancing candidates — same mesh, different per-instance transforms.