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.
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.
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:
- A fog and a near-black background — the atmosphere lives in the air, not the geometry.
- A ground plane with a grass-ish color, big enough that you never see its edge through the fog.
- A house made of three primitives — box (walls), cone (roof), plane (door) — plus a sphere or two for bushes.
- A graveyard — small boxes scattered in a ring around the house, each rotated and tilted a hair so they look settled in.
- An ambient + directional pair for moonlight, plus one little point light at the door.
- 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=0half-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.DoubleSidebecause the player camera will pass by both sides as you orbit, and one-sided plane geometry disappears from the back. - That
z = 2.001isn't a typo — the door is in the same plane as the wall it sits in front of. If both are atz = 2, the GPU can't decide which is in front, and you get the flickering pattern called z-fighting. 1mm of clearance fixes it.
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.
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:
- Each ghost moves on its own clock (different multipliers on
tSec). - Each ghost moves differently: orbit, dipping orbit, figure-eight. Same shape three times would feel like a fan.
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
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.
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.
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).
Exercises
-
Add a flickering candle in the window. A small
PointLightwith 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. -
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. -
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.
UP NEXT
S0-02 — Galaxy Generator → 200,000 particles, color ramp by distance, animated rotation. The signature Three.js "viral demo" rebuilt from first principles.