Learn Creative Coding (#68) - Creative Lighting and Shadows
Last episode we animated everything -- frame-independent timing, procedural motion from layered sine waves, spring physics for bounce, morph targets for shape-shifting, vertex shader deformation for massive-scale motion. Our meshes moved, breathed, swam. But they were lit with the same basic setup every time: an ambient light, a directional light, done. The lighting was functional but boring. A stage with fluorescent ceiling panels.
This episode we fix that. Lighting is one of the most powerful creative tools in 3D -- more than geometry, more than color, more than animation. The same scene lit warmly from below feels cozy. Lit with a cold blue from above it feels clinical. Hit it with a single sharp spotlight and long shadows and suddenly it's a noir film. The geometry hasn't changed at all. The mood changed completely.
Three.js gives us six types of lights, a full shadow mapping system, fog for atmospheric depth, and emissive materials for objects that glow from within. We'll go through all of it. By the end you'll have the tools to make your 3D scenes not just visible but emotionally charged.
The six lights
Three.js has six light types. Each one behaves differently, costs differently on the GPU, and creates a different aesthetic. Here's the full lineup:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080810);
const camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(0, 5, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// a floor to catch shadows and show light falloff
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.9 })
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
// some objects to light
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.8, 32, 32),
new THREE.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.4 })
);
sphere.position.set(0, 0.8, 0);
scene.add(sphere);
const box = new THREE.Mesh(
new THREE.BoxGeometry(1, 1.6, 1),
new THREE.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.5 })
);
box.position.set(-3, 0.8, 0);
scene.add(box);
const torus = new THREE.Mesh(
new THREE.TorusGeometry(0.6, 0.25, 16, 32),
new THREE.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.3 })
);
torus.position.set(3, 1, 0);
torus.rotation.x = Math.PI / 4;
scene.add(torus);
Now the lights themselves:
// 1. AmbientLight: uniform light everywhere, no direction, no shadows
// good as a base layer so nothing is pure black
const ambient = new THREE.AmbientLight(0x222244, 0.5);
scene.add(ambient);
// 2. DirectionalLight: parallel rays, like sunlight
// infinite distance, everything lit from the same angle
const directional = new THREE.DirectionalLight(0xffeedd, 1.5);
directional.position.set(5, 8, 3);
scene.add(directional);
// 3. PointLight: radiates from a single point, like a light bulb
// intensity falls off with distance
const point = new THREE.PointLight(0xff6633, 2.0, 10);
point.position.set(-2, 3, 2);
scene.add(point);
// 4. SpotLight: cone of light, like a flashlight or stage spot
const spot = new THREE.SpotLight(0x44aaff, 3.0, 15, Math.PI / 6, 0.3);
spot.position.set(3, 5, -2);
spot.target.position.set(0, 0, 0);
scene.add(spot);
scene.add(spot.target);
// 5. HemisphereLight: sky/ground gradient
// sky color from above, ground color from below, no shadows
const hemisphere = new THREE.HemisphereLight(0x8888cc, 0x443322, 0.4);
scene.add(hemisphere);
// 6. RectAreaLight: rectangular light panel, like a softbox
// only works with MeshStandardMaterial and MeshPhysicalMaterial
import { RectAreaLightHelper } from 'three/addons/helpers/RectAreaLightHelper.js';
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
RectAreaLightUniformsLib.init();
const rectArea = new THREE.RectAreaLight(0xffffff, 5, 2, 1);
rectArea.position.set(0, 4, -3);
rectArea.lookAt(0, 0, 0);
scene.add(rectArea);
scene.add(new RectAreaLightHelper(rectArea));
Each light has its personality. AmbientLight is the lazy one -- it adds a flat tint everywhere, no direction, no depth. Use it as a base to keep dark areas from going completely black. DirectionalLight is sunlight -- parallel rays, same angle everywhere, good for outdoor scenes. PointLight is a bare bulb -- radiates in all directions, falls off with distance. SpotLight is theatrical -- a cone of light you can aim, with configurable cone angle and edge softness. HemisphereLight fakes sky/ground ambient -- blue from above, brown from below, creates a more natural ambient than flat AmbientLight. RectAreaLight is a photography softbox -- soft, diffused, rectangular.
For creative coding you'll mostly use combinations. A hemisphere light for subtle ambient, a directional for the main illumination (the "key light"), and maybe a point or spot for accent. That three-light setup covers 90% of scenes.
Shadow mapping
Light without shadow is flat. Shadows give objects weight, ground them in space, reveal depth relationships. Three.js has a shadow mapping system that works with DirectionalLight, SpotLight, and PointLight.
Enabling shadows is a four-step process. Every step matters -- miss one and nothing shows up:
// step 1: tell the renderer to compute shadows
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// step 2: tell the light to cast shadows
directional.castShadow = true;
// step 3: tell objects to cast shadows
sphere.castShadow = true;
box.castShadow = true;
torus.castShadow = true;
// step 4: tell the floor to receive shadows
floor.receiveShadow = true;
That's it. Four lines of configuration and suddenly objects cast shadows on the floor. The shadow appears because the GPU renders the scene from the light's perspective into a depth texture (the shadow map), then during the normal render it checks: "is this pixel visible from the light? if not, it's in shadow."
PCFSoftShadowMap gives soft-edged shadows using Percentage Closer Filtering. The default (PCFShadowMap) gives slightly harder edges. BasicShadowMap is the cheapest but jaggiest. For creative work, PCFSoft looks best.
Shadow quality
The default shadow map is 512x512 pixels. That's blurry. For crisp shadows, increase the resolution:
directional.shadow.mapSize.width = 2048;
directional.shadow.mapSize.height = 2048;
// shadow camera frustum (how much area the shadow covers)
directional.shadow.camera.near = 0.5;
directional.shadow.camera.far = 30;
directional.shadow.camera.left = -10;
directional.shadow.camera.right = 10;
directional.shadow.camera.top = 10;
directional.shadow.camera.bottom = -10;
// shadow bias: prevents "shadow acne" (self-shadowing artifacts)
directional.shadow.bias = -0.001;
// shadow radius: edge softness (only with PCFSoftShadowMap)
directional.shadow.radius = 3;
Shadow acne is the most annoying artifact. It shows up as weird striped patterns on surfaces that should be smoothly lit. The cause: floating-point imprecision in the shadow depth comparison. The surface thinks it's shadowing itself. shadow.bias offsets the depth comparison by a tiny amount to fix it. Too little bias and you get acne. Too much and shadows "detach" from objects (called peter panning). -0.001 is usually a good starting point.
The shadow camera frustum defines the area that gets shadowed. For a directional light, this is an orthographic box. If your scene is wider than the frustum, objects outside it won't cast shadows. The helper visualizes this:
const shadowHelper = new THREE.CameraHelper(directional.shadow.camera);
scene.add(shadowHelper);
// remove after debugging
SpotLight shadows are easier to configure because the shadow camera is perspective (matches the cone). PointLight shadows are the most expensive -- they need a cube shadow map (6 faces, like a cubemap) because point lights radiate in all directions.
Three-point lighting
Cinematic lighting uses three lights working together. This comes from photography and film but works identically in 3D:
// key light: main illumination, creates the primary shadows
const keyLight = new THREE.DirectionalLight(0xfff0dd, 2.0);
keyLight.position.set(4, 6, 3);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.bias = -0.001;
keyLight.shadow.camera.left = -8;
keyLight.shadow.camera.right = 8;
keyLight.shadow.camera.top = 8;
keyLight.shadow.camera.bottom = -8;
scene.add(keyLight);
// fill light: softens the shadows from the key light
// lower intensity, placed opposite side
const fillLight = new THREE.DirectionalLight(0x8899bb, 0.6);
fillLight.position.set(-3, 4, -2);
scene.add(fillLight);
// rim light: highlights the edge/silhouette of objects
// positioned behind and above, creates that thin bright outline
const rimLight = new THREE.DirectionalLight(0xaaccff, 1.2);
rimLight.position.set(-2, 5, -5);
scene.add(rimLight);
// subtle ambient so nothing goes completely black
scene.add(new THREE.HemisphereLight(0x222244, 0x111111, 0.3));
The key light does the heavy lifting -- it's the brightest, positioned to one side, and it's the only one casting shadows. The fill light comes from the opposite side at lower intensity, preventing the shadow side from going completely dark. The rim light sits behind the subject and creates a thin bright edge that separates the object from the background. That separation is what makes objects pop out of the scene.
Play with the colors. A warm key (0xfff0dd) and cool fill (0x8899bb) creates a natural, slightly cinematic feel. Make the key orange and the fill blue and you get a sunset look. Both cool blue and you're underwater. Both warm amber and you're by candlelight. The temperature contrast between key and fill is one of the strongest mood controls you have.
Colored lights and mood
Light has color. And color carries emotion. Two identical scenes with different light colors feel completely different:
function createLitScene(keyColor, fillColor, ambientColor) {
const group = new THREE.Group();
// floor
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(12, 12),
new THREE.MeshStandardMaterial({ color: 0x666666, roughness: 0.85 })
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
group.add(floor);
// some objects
for (let i = 0; i < 5; i++) {
const geo = i % 2 === 0
? new THREE.BoxGeometry(0.6, 0.8 + Math.random() * 1.2, 0.6)
: new THREE.CylinderGeometry(0.3, 0.3, 0.8 + Math.random() * 1.4, 16);
const mesh = new THREE.Mesh(
geo,
new THREE.MeshStandardMaterial({ color: 0xbbbbbb, roughness: 0.5 })
);
mesh.position.set(
(Math.random() - 0.5) * 4,
geo.parameters.height / 2,
(Math.random() - 0.5) * 4
);
mesh.castShadow = true;
mesh.receiveShadow = true;
group.add(mesh);
}
// key light
const key = new THREE.DirectionalLight(keyColor, 2.0);
key.position.set(4, 6, 3);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.bias = -0.001;
group.add(key);
// fill light
const fill = new THREE.DirectionalLight(fillColor, 0.5);
fill.position.set(-3, 3, -2);
group.add(fill);
// ambient
group.add(new THREE.AmbientLight(ambientColor, 0.3));
return group;
}
// warm sunset
const sunset = createLitScene(0xff8833, 0x3344aa, 0x221111);
sunset.position.x = -6;
scene.add(sunset);
// cold moonlight
const moonlight = createLitScene(0x4466bb, 0x112233, 0x050510);
moonlight.position.x = 6;
scene.add(moonlight);
Same geometry. Same materials. Completely different vibes. The sunset scene feels warm, inviting, golden hour. The moonlight scene feels cold, quiet, a little eerie. The materials are neutral grey -- all the color comes from the lights. This is why lighting design is a speciality in film. The light tells the story.
Complementary colors work especially well. Orange key + blue fill. Teal key + warm pink fill. The contrast between warm and cool creates depth even in simple scenes because warm colors appear to advance (come closer) and cool colors appear to recede. Put the warm light in front and cool light behind and objects gain a sense of three-dimensionality beyond what the geometry alone provides.
Fog: atmosphere and depth
Fog fades distant objects into a background color. It's a simple trick that adds huge amounts of atmosphere. Three.js has two fog types:
// linear fog: starts fading at 'near', fully opaque at 'far'
scene.fog = new THREE.Fog(0x080810, 5, 25);
// exponential fog: density-based, more natural falloff
scene.fog = new THREE.FogExp2(0x080810, 0.06);
Match the fog color to the background color and distant objects blend seamlessly into the horizon. Mismatch them and you get a visible wall of fog -- sometimes useful creatively but usually jarring.
Linear fog gives you precise control: nothing fades before near, everything's invisible past far. Exponential fog is more natural -- there's no hard boundary, just increasing density. The density parameter controls how thick it is. 0.02 is light haze. 0.1 is heavy fog. 0.2 and you can barely see 5 units ahead.
Fog works beautifully with the underwater scene from ep067. Dark blue fog + blue-green lights + particles = instant underwater atmosphere. Warm grey fog + orange point lights = campfire in mist. The fog doesn't just hide distant objects -- it adds light scattering depth. Things look farther away because they're faded.
// moody scene: dark fog, warm lights, long shadows
scene.background = new THREE.Color(0x0a0608);
scene.fog = new THREE.FogExp2(0x0a0608, 0.05);
const warmPoint = new THREE.PointLight(0xff6622, 3.0, 12);
warmPoint.position.set(0, 2, 0);
warmPoint.castShadow = true;
warmPoint.shadow.mapSize.set(1024, 1024);
scene.add(warmPoint);
// just a dim ambient so shadows aren't pure black
scene.add(new THREE.AmbientLight(0x110808, 0.2));
A single warm point light in dark fog. Objects near the light glow warm, objects far from it disappear into the dark. The falloff creates a natural vignette effect -- the light carves a visible space out of the darkness. Very atmospheric, very moody, and computationally cheap.
Emissive materials: things that glow
Emissive materials appear to emit light. They don't actually illuminate other objects (that would require complex global illumination), but they render as bright regardless of lighting conditions. Combined with bloom post-processing (we'll get to that in a future episode), emissive materials look like they truly glow.
// a glowing orb
const glowOrb = new THREE.Mesh(
new THREE.SphereGeometry(0.3, 32, 32),
new THREE.MeshStandardMaterial({
color: 0x000000, // base color (dark, the emissive does the work)
emissive: 0x44aaff, // the glow color
emissiveIntensity: 2.0, // brightness of the glow
roughness: 0.2
})
);
glowOrb.position.set(0, 1.5, 0);
scene.add(glowOrb);
// pair it with an actual point light at the same position
// so it illuminates nearby surfaces
const orbLight = new THREE.PointLight(0x44aaff, 2.0, 8);
orbLight.position.copy(glowOrb.position);
scene.add(orbLight);
The trick: the emissive material makes the orb LOOK like it's glowing. The point light at the same position makes it actually illuminate surroundings. Together they create a convincing light source. Without the point light, the orb glows but nothing around it is lit. Without the emissive material, nearby surfaces are lit but the light source itself is dark. You need both.
Emissive intensity above 1.0 makes the material brighter than white. With HDR rendering and bloom post-processing, this creates the characteristic soft glow halo around bright objects -- neon signs, lava, bioluminescent creatures, magic effects. We'll build a full bloom pipeline in the post-processing episode, but even without it, high emissive intensity reads as "this thing glows."
Animating lights
Lights can be animated just like any other Three.js object. Move them, change their color, change their intensity. A static light setup is fine for renders, but for creative coding, animated lights are where the magic happens:
const clock = new THREE.Clock();
// a rotating spotlight creates sweeping shadows
const sweepSpot = new THREE.SpotLight(0xffffff, 3.0, 20, Math.PI / 8, 0.4);
sweepSpot.position.set(0, 6, 0);
sweepSpot.castShadow = true;
sweepSpot.shadow.mapSize.set(1024, 1024);
scene.add(sweepSpot);
scene.add(sweepSpot.target);
// a pulsing point light creates breathing illumination
const pulseLight = new THREE.PointLight(0xff4422, 0, 8);
pulseLight.position.set(2, 1.5, -1);
scene.add(pulseLight);
// orbiting colored lights
const orbitLight1 = new THREE.PointLight(0xff2244, 1.5, 6);
const orbitLight2 = new THREE.PointLight(0x2244ff, 1.5, 6);
scene.add(orbitLight1);
scene.add(orbitLight2);
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
// sweep the spotlight in a circle
sweepSpot.target.position.x = Math.cos(t * 0.5) * 4;
sweepSpot.target.position.z = Math.sin(t * 0.5) * 4;
sweepSpot.target.updateMatrixWorld();
// pulse the point light
pulseLight.intensity = 1.5 + Math.sin(t * 2.0) * 1.0;
// orbit two colored lights around the center
orbitLight1.position.x = Math.cos(t * 0.7) * 3;
orbitLight1.position.z = Math.sin(t * 0.7) * 3;
orbitLight1.position.y = 1.5 + Math.sin(t * 1.2) * 0.5;
orbitLight2.position.x = Math.cos(t * 0.7 + Math.PI) * 3;
orbitLight2.position.z = Math.sin(t * 0.7 + Math.PI) * 3;
orbitLight2.position.y = 1.5 + Math.cos(t * 1.2) * 0.5;
controls.update();
renderer.render(scene, camera);
}
animate();
The sweeping spotlight creates shadows that rotate around the scene -- dramatic, theatrical. The pulsing point light makes everything nearby breathe with warm light. The two orbiting lights (red and blue) paint alternating colored highlights on surfaces as they pass. Together, the scene is constantly shifting -- no frame looks the same.
Notice sweepSpot.target.updateMatrixWorld(). SpotLight has a .target property that defines where it points. When you move the target, you need to update its world matrix so the light recalculates its direction. Forget this and the spotlight doesn't follow the target.
Light as the primary visual
Here's the creative leap: instead of using light to reveal geometry, use light as the subject itself. The geometry becomes secondary -- it's just there to catch and shape the light. Shadow art, light painting, abstract illumination.
// shadow sculpture: simple objects, complex shadows
const pillarGroup = new THREE.Group();
for (let i = 0; i < 12; i++) {
const angle = (i / 12) * Math.PI * 2;
const r = 2;
const height = 1 + Math.random() * 2;
const pillar = new THREE.Mesh(
new THREE.CylinderGeometry(0.08, 0.08, height, 8),
new THREE.MeshStandardMaterial({ color: 0x222222 })
);
pillar.position.set(
Math.cos(angle) * r,
height / 2,
Math.sin(angle) * r
);
pillar.castShadow = true;
pillarGroup.add(pillar);
}
scene.add(pillarGroup);
// a single strong point light inside the ring
const centerLight = new THREE.PointLight(0xffaa44, 4.0, 12);
centerLight.position.set(0, 2, 0);
centerLight.castShadow = true;
centerLight.shadow.mapSize.set(2048, 2048);
centerLight.shadow.bias = -0.002;
scene.add(centerLight);
// the floor catches the shadow pattern
const bigFloor = new THREE.Mesh(
new THREE.PlaneGeometry(30, 30),
new THREE.MeshStandardMaterial({ color: 0x444444, roughness: 0.9 })
);
bigFloor.rotation.x = -Math.PI / 2;
bigFloor.receiveShadow = true;
scene.add(bigFloor);
Twelve thin pillars in a ring, one point light in the center. The shadows radiate outward like sun rays through trees. Animate the light position (bob it up and down, orbit it slightly) and the shadow pattern shifts and dances. The pillars themselves are nearly invisible in the dark -- the shadows are the artwork.
Add a wall behind the pillars and you get shadow puppetry in code. Shape the objects to cast specific shadow patterns. Use multiple lights at different angles for overlapping shadows that create moire-like interference patterns. The geometry is the tool, the shadow is the output.
Volumetric light approximation
True volumetric lighting (light scattering through particles in air) is expensive. But we can fake it convincingly with semi-transparent cone meshes placed where light beams would be visible:
function createLightCone(light, length, topRadius, bottomRadius) {
const geo = new THREE.ConeGeometry(bottomRadius, length, 32, 1, true);
geo.translate(0, -length / 2, 0);
const mat = new THREE.MeshBasicMaterial({
color: light.color,
transparent: true,
opacity: 0.06,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const cone = new THREE.Mesh(geo, mat);
cone.position.copy(light.position);
// point cone toward light target
if (light.target) {
cone.lookAt(light.target.position);
cone.rotateX(Math.PI / 2);
}
return cone;
}
// spotlight with visible beam
const spotForBeam = new THREE.SpotLight(0xffeedd, 3.0, 15, Math.PI / 7, 0.4);
spotForBeam.position.set(0, 8, 0);
spotForBeam.target.position.set(0, 0, 0);
spotForBeam.castShadow = true;
spotForBeam.shadow.mapSize.set(1024, 1024);
scene.add(spotForBeam);
scene.add(spotForBeam.target);
const beam = createLightCone(spotForBeam, 8, 0.1, 2.5);
scene.add(beam);
The trick is AdditiveBlending and very low opacity (0.06). The cone mesh itself is barely visible from one angle, but when you look through the length of it (so many transparent layers overlap), the light accumulates and the beam becomes visible. From the side it's a subtle glow. Looking into the beam it's bright. This is optically accurate -- real volumetric light works the same way (more air particles between you and the source = more visible scattering).
Multiple beams from different angles create dramatic "god ray" compositions. A beam coming through a rectangular opening (use a box instead of a cone) looks like light through a window. Dust particles in the beam (add a small particle system from ep065 inside the cone volume) sell the effect even more.
Creative exercise: moody scene
Allez, time to combine everything into a scene that communicates purely through light. Dark fog, warm and cool lights, long shadows, emissive accents, volumetric beams:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x060408);
scene.fog = new THREE.FogExp2(0x060408, 0.06);
const camera = new THREE.PerspectiveCamera(
55, window.innerWidth / window.innerHeight, 0.1, 80
);
camera.position.set(0, 4, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// floor
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(30, 30),
new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.95 })
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
// architectural columns (shadow casters)
const columns = [];
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
const r = 4;
const h = 3 + Math.random() * 2;
const col = new THREE.Mesh(
new THREE.CylinderGeometry(0.12, 0.15, h, 8),
new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.8 })
);
col.position.set(Math.cos(angle) * r, h / 2, Math.sin(angle) * r);
col.castShadow = true;
col.receiveShadow = true;
scene.add(col);
columns.push(col);
}
// center piece: floating emissive orb
const orbGeo = new THREE.SphereGeometry(0.4, 32, 32);
const orbMat = new THREE.MeshStandardMaterial({
color: 0x000000,
emissive: 0xff6633,
emissiveIntensity: 2.5,
roughness: 0.1
});
const orb = new THREE.Mesh(orbGeo, orbMat);
orb.position.set(0, 2.5, 0);
scene.add(orb);
// the orb's light
const orbLight = new THREE.PointLight(0xff6633, 3.0, 10);
orbLight.position.copy(orb.position);
orbLight.castShadow = true;
orbLight.shadow.mapSize.set(1024, 1024);
orbLight.shadow.bias = -0.002;
scene.add(orbLight);
// cool accent light from above (counterpoint to warm orb)
const coolSpot = new THREE.SpotLight(0x3355aa, 2.0, 20, Math.PI / 5, 0.5);
coolSpot.position.set(0, 10, 0);
coolSpot.target.position.set(0, 0, 0);
coolSpot.castShadow = true;
coolSpot.shadow.mapSize.set(1024, 1024);
scene.add(coolSpot);
scene.add(coolSpot.target);
// emissive accent: small glowing crystals at column bases
for (let i = 0; i < 8; i++) {
const col = columns[i];
const crystal = new THREE.Mesh(
new THREE.OctahedronGeometry(0.08),
new THREE.MeshStandardMaterial({
color: 0x000000,
emissive: new THREE.Color().setHSL(0.55 + Math.random() * 0.15, 0.7, 0.4),
emissiveIntensity: 1.5
})
);
crystal.position.set(
col.position.x + (Math.random() - 0.5) * 0.3,
0.08,
col.position.z + (Math.random() - 0.5) * 0.3
);
crystal.rotation.set(Math.random(), Math.random(), Math.random());
scene.add(crystal);
}
// very dim hemisphere for base visibility
scene.add(new THREE.HemisphereLight(0x111122, 0x080808, 0.15));
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
// orb bobs gently
orb.position.y = 2.5 + Math.sin(t * 0.6) * 0.2;
orbLight.position.copy(orb.position);
// orb light pulses slightly
orbLight.intensity = 3.0 + Math.sin(t * 1.5) * 0.5;
orbMat.emissiveIntensity = 2.5 + Math.sin(t * 1.5) * 0.5;
// cool spot rotates slowly
coolSpot.position.x = Math.sin(t * 0.15) * 3;
coolSpot.position.z = Math.cos(t * 0.15) * 3;
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
A ring of dark columns around a floating, pulsing warm orb. Cool blue light from above. Dark fog eating the edges. Small emissive crystals at the column bases like scattered gems. The orb's warm light casts radiating shadows from the columns while the cool spotlight from above creates a second shadow layer at a different angle.
The mood here is entirely from light. The geometry is minimal -- cylinders, a sphere, some octahedra. The materials are nearly all the same dark grey. But the warm/cool contrast, the shadow interplay, the fog, the pulsing glow -- it feels like a scene from a fantasy game or a ritual chamber. Remove the lights and add flat white ambient and the same geometry becomes a boring room with posts in it.
Performance notes
Shadows are the most expensive lighting feature. Each shadow-casting light requires an extra render pass from the light's perspective. A scene with 4 shadow-casting lights renders the geometry 5 times (once per shadow map + once for the final image). Keep shadow-casting lights to 2-3 max.
Shadow map size: 1024x1024 is fine for most cases. 2048x2048 for hero shadows you want crisp. 4096x4096 if you really need it but that's 16MB of GPU memory per shadow map. Don't go higher unless you have a specific reason.
PointLight shadows are 6x more expensive than directional/spot because they need a cube map (6 faces). Use them sparingly for shadow casting -- a PointLight without castShadow = true is cheap.
Fog is essentially free -- it's computed per-pixel in the fragment shader with a simple distance calculation. Use it liberally.
AmbientLight and HemisphereLight don't cast shadows by design. They're fill lights. Adding shadow capability to them doesn't make physical sense (ambient light comes from everywhere, not a specific direction).
RectAreaLight doesn't cast shadows in Three.js. It's the softest, most photographic light type but you'd need a separate shadow-casting directional or spot to fake shadows from it.
What's ahead
We covered the full lighting toolkit: six light types, shadow mapping with quality control, three-point lighting for cinematic composition, colored lights for mood, fog for atmosphere, emissive materials for glowing objects, animated lights for dynamic scenes, shadow art, and fake volumetric beams. These techniques apply to every 3D scene from here on -- lighting is a skill that compounds across all your work.
Next episode we'll look at post-processing -- taking the rendered image and applying fullscreen effects like bloom (making emissive objects truly glow with halos), color grading, chromatic aberration, and more. The raw render is the starting point. Post-processing is the finishing touch that makes it look polished.
't Komt erop neer...
- Three.js has six light types: AmbientLight (flat uniform fill), DirectionalLight (parallel sun-like rays), PointLight (radiates from a point like a bulb), SpotLight (aimed cone with configurable angle and edge softness), HemisphereLight (sky/ground color gradient), RectAreaLight (soft rectangular panel). Each has different cost and use cases -- combine 2-3 for most scenes
- Shadow mapping requires four steps:
renderer.shadowMap.enabled = true,light.castShadow = true,mesh.castShadow = true,floor.receiveShadow = true. Miss any one step and shadows won't appear. UsePCFSoftShadowMapfor soft edges. Increaseshadow.mapSizefor sharper shadows. Setshadow.biasto prevent shadow acne - Three-point lighting from film: key light (bright, positioned to one side, casts shadows), fill light (dimmer, opposite side, softens shadows), rim light (behind subject, highlights silhouette edges). This setup works for 90% of creative coding scenes
- Colored lights control mood. Warm key + cool fill = natural/cinematic. Orange + blue = sunset. Teal + pink = synthwave. Complementary color contrast creates depth because warm colors appear to advance and cool colors recede
- Fog (
THREE.Foglinear orTHREE.FogExp2exponential) fades distant objects into the background color. Match fog color to background for seamless horizons. Exponential is more natural. Combine with colored lights for atmosphere -- dark fog + warm point light = campfire, blue fog + cool ambient = underwater - Emissive materials (
emissive+emissiveIntensityon MeshStandardMaterial) make objects appear to glow without actually casting light. Pair with a PointLight at the same position for convincing light sources. High emissive intensity (>1.0) works with bloom post-processing for neon/lava/magic glow halos - Animated lights create dynamic scenes: orbit PointLights for moving colored highlights, sweep SpotLight targets for rotating shadows, pulse intensity with sin(time) for breathing illumination. Update
spotLight.target.updateMatrixWorld()after moving the target - Shadow art: use simple geometry (thin cylinders, blocks) purely as shadow casters with a strong point light. The shadows become the visual subject. Fake volumetric beams with semi-transparent ConeGeometry + AdditiveBlending + very low opacity -- light accumulates through overlapping transparent layers
Sallukes! Thanks for reading.
X