Learn Creative Coding (#75) - Scenes and Environments: Building Worlds
Every scene we've built since episode 62 has been objects floating in a void. A dark background, some meshes, a few lights. That's fine for isolated experiments but it doesn't feel like a place. There's no sky overhead, no ground underfoot, no atmosphere softening things in the distance. The objects exist but the world doesn't.
This episode changes that. We're building environments -- the stuff that surrounds the objects and makes a scene feel like somewhere you could actually stand. Skyboxes for infinite horizons, environment maps for realistic reflections, fog for depth and atmosphere, ground planes that receive shadows, procedural sky shaders, and atmospheric particles like dust and rain. By the end you'll have the tools to make a Three.js scene that feels less like a tech demo and more like a place.
Some of this builds on lighting from episode 68 and the shader knowledge from the whole shader arc (ep032-045). The new bit is tying it all together into cohesive environments where every element supports every other element. Fog matches the sky color. Ground receives shadows from the same light that illuminates the sky. Reflections on objects show the actual environment around them. It's the difference between individual effects and a unified world.
Skyboxes: infinite horizons from six images
The simplest way to surround your scene with an environment is a cubemap skybox. Six images -- one for each face of a cube (positive X, negative X, positive Y, negative Y, positive Z, negative Z) -- mapped onto the inside of an infinitely distant box. The camera sits inside the box. No matter where you look, you see sky.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 0.1, 1000
);
camera.position.set(0, 2, 5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// load cubemap skybox
const cubeTextureLoader = new THREE.CubeTextureLoader();
const skybox = cubeTextureLoader.load([
'px.jpg', 'nx.jpg', // positive X, negative X
'py.jpg', 'ny.jpg', // positive Y, negative Y
'pz.jpg', 'nz.jpg' // positive Z, negative Z
]);
scene.background = skybox;
That's it. scene.background accepts a CubeTexture and Three.js renders it as an infinitely distant backdrop. The skybox moves with the camera -- you can never get closer to it or farther from it. It's always "over there" on the horizon no matter how far you walk. This is the same technique every 3D game uses for distant scenery.
The six images need to match at the seams. You can find free skybox cubemaps at polyhaven.com, Humus.name, or generate them from equirectangular HDR images (more on that in a moment). The image order matters: Three.js expects them in the order px, nx, py, ny, pz, nz (right, left, top, bottom, front, back in a right-handed coordinate system).
Environment maps: reflections from the world
A skybox shows the world around you. An environment map makes objects reflect that world. In Three.js, you can use the same CubeTexture as both the scene background and the material's environment map:
// a chrome sphere that reflects the sky
const chromeSphere = new THREE.Mesh(
new THREE.SphereGeometry(1, 64, 64),
new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 1.0,
roughness: 0.0,
envMap: skybox,
envMapIntensity: 1.0
})
);
scene.add(chromeSphere);
// or set it globally for all standard materials
scene.environment = skybox;
When you set scene.environment, every MeshStandardMaterial and MeshPhysicalMaterial in the scene picks it up automatically for reflections and ambient lighting. You don't need to set envMap on each material individually. The global approach is cleaner for scenes with lots of objects.
metalness: 1.0, roughness: 0.0 gives a perfect mirror -- the sphere reflects the skybox like polished chrome. Increasing roughness blurs the reflection (the material samples from higher mipmap levels of the cubemap, averaging nearby pixels). At roughness 0.5 you get a brushed metal look. At 1.0 the reflection is completely diffused -- the environment still contributes ambient light but there's no visible reflection.
This is Image-Based Lighting (IBL). Instead of calculating light from point lights and directional lights alone, the material samples light from the environment texture in every direction. It's why PBR materials in Three.js look so much better with an environment map -- the ambient lighting is rich and directional instead of flat.
HDR environments: the real deal
Regular cubemap images are LDR (Low Dynamic Range) -- pixel values clamped to 0-255. Real environments have massive brightness differences: the sun is thousands of times brighter than the sky around it. HDR images capture that full range, which means reflections and ambient lighting are physically accurate.
Three.js supports equirectangular HDR images (a single panoramic .hdr file) through RGBELoader:
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
const rgbeLoader = new RGBELoader();
rgbeLoader.load('environment.hdr', function (texture) {
texture.mapping = THREE.EquirectangularReflectionMapping;
scene.background = texture;
scene.environment = texture;
});
One file instead of six. And because it's HDR, bright areas in the environment (a sun, bright clouds, light sources) create specular highlights on shiny surfaces that LDR cubemaps can't match. The tone mapping we set on the renderer (ACESFilmicToneMapping) compresses the HDR values into displayable range while preserving the brightness relationships.
Polyhaven.com has hundreds of free HDR environments -- outdoor landscapes, studio lighting setups, urban scenes, forests. Download a 1K or 2K resolution .hdr file, load it, and your scene instantly gets realistic lighting and reflections from a real photographed environment. It's one of the highest impact-to-effort techniques in 3D.
For performance: HDR environment maps are larger than LDR cubemaps. A 2K equirectangular HDR is about 8-16 MB. For web delivery, consider using compressed formats or generating a cubemap at load time and caching it. Three.js's PMREMGenerator preprocesses the environment into a prefiltered mipmap chain optimized for PBR roughness sampling:
const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();
rgbeLoader.load('environment.hdr', function (texture) {
const envMap = pmremGenerator.fromEquirectangular(texture).texture;
scene.background = texture;
scene.environment = envMap;
texture.dispose();
pmremGenerator.dispose();
});
The PMREM (Prefiltered Mipmapped Radiance Environment Map) is what MeshStandardMaterial actually needs for roughness-based reflections. Without it, Three.js generates the PMREM on the fly the first time a material samples the environment -- which can cause a visible stutter. Pre-generating it avoids that.
Procedural sky: no images needed
Images are convenient but limiting -- you're stuck with whatever weather and time of day was photographed. A procedural sky generates the atmosphere from math. Sun position, Rayleigh scattering (why the sky is blue), Mie scattering (the halo around the sun), horizon gradient. All controllable, all animatable.
Three.js includes a Sky shader in its examples:
import { Sky } from 'three/addons/objects/Sky.js';
const sky = new Sky();
sky.scale.setScalar(10000);
scene.add(sky);
const skyUniforms = sky.material.uniforms;
skyUniforms['turbidity'].value = 2;
skyUniforms['rayleigh'].value = 1.5;
skyUniforms['mieCoefficient'].value = 0.005;
skyUniforms['mieDirectionalG'].value = 0.8;
// sun position
const sun = new THREE.Vector3();
const phi = THREE.MathUtils.degToRad(90 - 30); // elevation: 30 degrees
const theta = THREE.MathUtils.degToRad(180); // azimuth
sun.setFromSphericalCoords(1, phi, theta);
skyUniforms['sunPosition'].value.copy(sun);
Turbidity controls atmospheric haze -- higher values make the sky more washed out and yellowish, like a dusty afternoon. Rayleigh controls the blue scattering -- higher values deepen the blue. Mie controls the bright glow around the sun -- stronger for hazy conditions. MieDirectionalG controls how concentrated the sun glow is (0.8 is a tight halo, 0.99 is a sharp sun disk).
The sun position drives everything. At high elevation (midday), you get bright blue sky with a white-ish sun area. At low elevation (sunset/sunrise), the path through the atmosphere is longer, scattering out more blue and leaving orange and red. Below the horizon, the sky darkens toward night.
Fog: depth and atmosphere
Fog does two things. Visually, it adds depth cues -- distant objects fade into the fog color, making the scene feel deeper and more atmospheric. Practically, it hides the far clipping plane -- objects disappear into fog before they pop out of existence at the render distance.
Three.js has two fog types:
// linear fog: objects between near and far fade from clear to fog color
scene.fog = new THREE.Fog(0xc8d8e8, 10, 80);
// args: color, near distance, far distance
// exponential fog: density increases exponentially with distance
scene.fog = new THREE.FogExp2(0xc8d8e8, 0.015);
// args: color, density
Linear fog has a hard start and end distance. Objects closer than near aren't fogged at all. Objects beyond far are 100% fog color. In between, there's a linear gradient. This gives you precise control but can look artificial -- the transition from clear to fogged is uniform.
Exponential fog is more natural. The density parameter controls how quickly objects fade. At density 0.01 you barely notice fog until things are quite far away. At 0.05 it's thick and close. There's no sharp boundary, which usually looks more convincing for outdoor environments.
The critical trick: match the fog color to your sky's horizon color. If your sky fades to a pale blue at the horizon and your fog is white, there'll be a visible seam where the ground meets the sky. Match them and objects seamlessly dissolve into the distance.
// match fog to sky horizon color
const horizonColor = new THREE.Color(0xc8d8e8);
scene.fog = new THREE.FogExp2(horizonColor, 0.012);
scene.background = horizonColor; // or let the sky shader handle it
If you're using the Sky shader, sample the sky color at the horizon angle and use that as your fog color. For a day scene with a blue sky, the horizon is typically a desaturated pale blue. For a sunset, it's warm orange fading to purple.
Ground planes: something to stand on
A ground plane is just a large flat mesh that receives shadows. Simple, but the details matter:
// ground plane
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(200, 200),
new THREE.MeshStandardMaterial({
color: 0x4a6741,
roughness: 0.85,
metalness: 0.0
})
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// directional light for shadows
const dirLight = new THREE.DirectionalLight(0xffeedd, 2.0);
dirLight.position.set(10, 20, 8);
dirLight.castShadow = true;
dirLight.shadow.mapSize.set(2048, 2048);
dirLight.shadow.camera.left = -30;
dirLight.shadow.camera.right = 30;
dirLight.shadow.camera.top = 30;
dirLight.shadow.camera.bottom = -30;
dirLight.shadow.camera.near = 0.1;
dirLight.shadow.camera.far = 60;
scene.add(dirLight);
The shadow camera frustum (left, right, top, bottom) needs to cover the area where you want shadows to appear. Too small and shadows get clipped at the edges. Too large and you waste shadow map resolution on empty space (shadows become blocky). A common mistake is leaving the defaults (which are tiny) and wondering why nothing casts shadows.
For a more realistic ground, add a texture:
const textureLoader = new THREE.TextureLoader();
const groundTexture = textureLoader.load('grass-diffuse.jpg');
groundTexture.wrapS = THREE.RepeatWrapping;
groundTexture.wrapT = THREE.RepeatWrapping;
groundTexture.repeat.set(20, 20);
const groundNormal = textureLoader.load('grass-normal.jpg');
groundNormal.wrapS = THREE.RepeatWrapping;
groundNormal.wrapT = THREE.RepeatWrapping;
groundNormal.repeat.set(20, 20);
ground.material.map = groundTexture;
ground.material.normalMap = groundNormal;
ground.material.normalScale.set(0.8, 0.8);
The repeat wrapping tiles the texture across the ground. 20 repeats over a 200-unit plane means each tile covers 10 units. The normal map adds surface detail (bumps, grass blade direction) without extra geometry. This is the bare minimum for a textured ground -- in a polished scene you'd also add a roughness map and maybe a displacement map for actual geometric detail.
Fog hides the ground's edges. Without fog, you see a sharp straight line where the ground plane ends and the void begins. With fog, the ground fades into the distance and the edge is invisible. This is why almost every outdoor 3D scene uses fog -- it's doing practical work, not just aesthetic work.
Procedural terrain from noise
A flat ground works, but a terrain with hills, valleys, and ridges is way more interesting. Take a plane geometry with many subdivisions and displace the vertices using noise:
const terrainGeo = new THREE.PlaneGeometry(100, 100, 128, 128);
terrainGeo.rotateX(-Math.PI / 2);
const positions = terrainGeo.attributes.position;
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i);
const z = positions.getZ(i);
// layered noise for natural-looking terrain
let height = 0;
height += Math.sin(x * 0.03) * Math.cos(z * 0.02) * 4.0;
height += Math.sin(x * 0.08 + 1.3) * Math.cos(z * 0.06 + 0.7) * 2.0;
height += Math.sin(x * 0.2 + 3.1) * Math.cos(z * 0.15 + 2.2) * 0.8;
height += Math.sin(x * 0.5) * Math.cos(z * 0.4 + 1.0) * 0.3;
positions.setY(i, height);
}
terrainGeo.computeVertexNormals();
const terrain = new THREE.Mesh(
terrainGeo,
new THREE.MeshStandardMaterial({
color: 0x5a7d4a,
roughness: 0.85,
flatShading: false
})
);
terrain.receiveShadow = true;
terrain.castShadow = true;
scene.add(terrain);
I'm using layered sine functions here as a standin for proper Perlin/simplex noise (which we covered in ep012). Four octaves: a big slow wave for the overall hill shape, progressively smaller and faster waves for detail. Real terrain would use simplex noise with octave layering, but the sine approach works and keeps the code self-contained.
computeVertexNormals() recalculates the normals after displacement so lighting reflects the actual surface angle, not the original flat plane. Without this call, the terrain would light as if it were still flat even though the vertices are displaced. We covered this in ep066 when building procedural meshes.
For more realistic terrain, you can also color vertices based on height -- green at low elevations, brown on slopes, white on peaks:
const colors = new Float32Array(positions.count * 3);
for (let i = 0; i < positions.count; i++) {
const y = positions.getY(i);
const normalized = (y + 5) / 10; // remap height to 0..1 roughly
const color = new THREE.Color();
if (normalized < 0.3) {
color.setHSL(0.3, 0.4, 0.25); // dark green lowlands
} else if (normalized < 0.6) {
color.setHSL(0.25, 0.35, 0.35); // lighter green hills
} else if (normalized < 0.8) {
color.setHSL(0.08, 0.3, 0.4); // brown high slopes
} else {
color.setHSL(0.0, 0.0, 0.8); // white peaks
}
colors[i * 3] = color.r;
colors[i * 3 + 1] = color.g;
colors[i * 3 + 2] = color.b;
}
terrainGeo.setAttribute('color',
new THREE.BufferAttribute(colors, 3));
terrain.material.vertexColors = true;
terrain.material.color.set(0xffffff); // neutral base so vertex colors show
Vertex colors give each vertex its own tint. The material multiplies its base color by the vertex color, so setting the base to white means vertex colors dominate. Transitions between zones are abrupt at these subdivision levels -- for smooth gradients you'd interpolate between zone colors or use more subdivisions. But even with hard transitions, it reads as a natural landscape from a distance, especially with fog softening everything.
Instanced grass: the ground comes alive
A bare ground plane or terrain looks barren. Grass fixes that. But grass means thousands of individual blades, and from episode 70 we know that individual meshes don't scale. Instanced mesh is the answer -- one geometry, one material, thousands of transforms:
const bladeGeo = new THREE.PlaneGeometry(0.05, 0.4, 1, 4);
// shift pivot to bottom
bladeGeo.translate(0, 0.2, 0);
const bladeMat = new THREE.MeshStandardMaterial({
color: 0x3d7a2a,
roughness: 0.8,
side: THREE.DoubleSide
});
const grassCount = 50000;
const grass = new THREE.InstancedMesh(bladeGeo, bladeMat, grassCount);
grass.castShadow = true;
grass.receiveShadow = true;
const dummy = new THREE.Object3D();
for (let i = 0; i < grassCount; i++) {
// random position on the ground
const x = (Math.random() - 0.5) * 60;
const z = (Math.random() - 0.5) * 60;
// sample terrain height at this position (simplified)
const y = 0; // replace with actual terrain height lookup
dummy.position.set(x, y, z);
dummy.rotation.y = Math.random() * Math.PI * 2;
dummy.scale.set(
0.8 + Math.random() * 0.4,
0.6 + Math.random() * 0.8,
1
);
dummy.updateMatrix();
grass.setMatrixAt(i, dummy.matrix);
// color variation
const hue = 0.25 + (Math.random() - 0.5) * 0.06;
const sat = 0.35 + Math.random() * 0.2;
const lgt = 0.2 + Math.random() * 0.15;
grass.setColorAt(i, new THREE.Color().setHSL(hue, sat, lgt));
}
grass.instanceMatrix.needsUpdate = true;
grass.instanceColor.needsUpdate = true;
scene.add(grass);
50,000 blades in a single draw call. Each blade is a small plane with 4 vertical segments (so it can bend with a shader later), placed randomly, rotated randomly around Y, scaled randomly for variation. The color varies per-instance around green with slight hue, saturation, and lightness shifts so no two blades are identical.
The plane geometry is double-sided so you see the blade regardless of viewing angle. Without side: THREE.DoubleSide, blades viewed from behind would be invisible. For grass this is essentual -- you're always looking at some blades from the back.
For animated grass swaying in wind, you'd use a custom ShaderMaterial that displaces vertices based on a time uniform and the blade's world position. The top vertices move more than the bottom (grass bends from the base, stays rooted at the ground). We'll leave that for a future deep dive, but the instancing structure is the same.
Water
A reflective water plane adds immediate life to a scene. Three.js includes a Water addon that handles reflective/refractive water with animated normal maps:
import { Water } from 'three/addons/objects/Water.js';
const waterGeometry = new THREE.PlaneGeometry(200, 200);
const water = new Water(waterGeometry, {
textureWidth: 512,
textureHeight: 512,
waterNormals: new THREE.TextureLoader().load(
'waternormals.jpg',
function (texture) {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
}
),
sunDirection: new THREE.Vector3(0.5, 0.8, 0.3).normalize(),
sunColor: 0xffffff,
waterColor: 0x001e2f,
distortionScale: 3.7,
fog: scene.fog !== undefined
});
water.rotation.x = -Math.PI / 2;
water.position.y = -0.5; // water level
scene.add(water);
// in animation loop:
// water.material.uniforms['time'].value += delta;
The Water class renders the scene from a mirrored camera position (flipped below the water plane) into a texture, then blends that reflection with the water color and animated normals. The waternormals.jpg texture is a tiling normal map that creates ripple patterns when scrolled over time. The result is a reflective, animated water surface that reacts to the scene above it.
distortionScale controls how warped the reflections are -- higher values make rougher water. The fog parameter lets the water interact with scene fog so distant water fades correctly.
For creative coding, you don't need photorealistic water. Even a semi-transparent blue plane with a normal-mapped material gives the impression of water. The reflection is what sells it -- without reflection, a blue plane just looks like a blue floor.
Day/night cycle: animating the whole environment
Everything we've built so far can be animated. The sun moves, the sky changes, the fog color shifts, the light intensity ramps. A day/night cycle ties them all together:
function updateDayCycle(timeOfDay) {
// timeOfDay: 0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset
// sun elevation: rises from 0 to 80 degrees, then back
const dayPhase = timeOfDay * Math.PI * 2;
const elevation = Math.max(0, Math.sin(dayPhase)) * 80;
const azimuth = timeOfDay * 360;
const phi = THREE.MathUtils.degToRad(90 - elevation);
const theta = THREE.MathUtils.degToRad(azimuth);
const sunPos = new THREE.Vector3().setFromSphericalCoords(1, phi, theta);
// update sky shader
sky.material.uniforms['sunPosition'].value.copy(sunPos);
// update directional light to match sun
dirLight.position.copy(sunPos).multiplyScalar(50);
// intensity ramps with elevation
const sunIntensity = Math.max(0, Math.sin(dayPhase));
dirLight.intensity = sunIntensity * 2.5;
// light color shifts: white at noon, warm at sunset/sunrise
const warmth = 1.0 - Math.pow(sunIntensity, 0.5);
dirLight.color.setHSL(0.08 * warmth, 0.3 * warmth, 0.5 + sunIntensity * 0.5);
// ambient light: dim blue at night, brighter during day
const ambientLevel = 0.05 + sunIntensity * 0.4;
ambientLight.intensity = ambientLevel;
ambientLight.color.setHSL(
0.6 - sunIntensity * 0.1, // blue at night, slightly warm during day
0.3,
0.3 + sunIntensity * 0.3
);
// fog color matches sky at horizon
const fogHue = 0.58 - warmth * 0.5; // blue -> orange at sunset
const fogSat = 0.15 + warmth * 0.25;
const fogLight = 0.3 + sunIntensity * 0.5;
const fogColor = new THREE.Color().setHSL(fogHue, fogSat, fogLight);
scene.fog.color.copy(fogColor);
// night sky: darken background when sun is down
if (elevation < 5) {
const nightFactor = elevation / 5;
scene.background = new THREE.Color().lerpColors(
new THREE.Color(0x020208), // night sky
fogColor,
nightFactor
);
}
}
// in animation loop:
// const timeOfDay = (Date.now() % 60000) / 60000; // 1 minute = 1 day
// updateDayCycle(timeOfDay);
The function takes a normalized time (0 to 1 = one full day) and updates everything: sun position (which drives the Sky shader), directional light position and intensity, ambient light color, fog color, and background. The warmth calculation makes the light orange at low sun angles (sunrise/sunset) and white at noon. The fog tracks the sky's horizon color so there's never a visible seam.
A 60-second day cycle is fast enough to watch comfortably. In a creative piece you might want 5-10 minutes for a full cycle. Or lock it to a specific time of day -- golden hour (sun at 10-15 degrees elevation) is always gorgeous.
Atmospheric particles: dust, rain, snow
Particles scattered through the scene add life that static geometry can't. Dust motes drifting in sunlight. Rain streaking downward. Snow drifting slowly. These use the same particle techniques from ep065 and ep011 but adapted for environment scale.
const particleCount = 5000;
const particlePositions = new Float32Array(particleCount * 3);
const particleSpeeds = new Float32Array(particleCount);
const range = 40; // particles fill a 40x40x40 cube around the camera
for (let i = 0; i < particleCount; i++) {
particlePositions[i * 3] = (Math.random() - 0.5) * range;
particlePositions[i * 3 + 1] = Math.random() * range * 0.5;
particlePositions[i * 3 + 2] = (Math.random() - 0.5) * range;
particleSpeeds[i] = 0.3 + Math.random() * 0.7;
}
const dustGeo = new THREE.BufferGeometry();
dustGeo.setAttribute('position',
new THREE.BufferAttribute(particlePositions, 3));
const dustMat = new THREE.PointsMaterial({
color: 0xeeddcc,
size: 0.08,
transparent: true,
opacity: 0.4,
sizeAttenuation: true,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const dust = new THREE.Points(dustGeo, dustMat);
scene.add(dust);
function updateDust(delta) {
const positions = dustGeo.attributes.position.array;
for (let i = 0; i < particleCount; i++) {
const i3 = i * 3;
const speed = particleSpeeds[i];
// slow downward drift
positions[i3 + 1] -= speed * delta * 0.3;
// gentle horizontal drift (wind)
positions[i3] += Math.sin(positions[i3 + 1] * 0.5) * delta * 0.1;
positions[i3 + 2] += Math.cos(positions[i3 + 1] * 0.3 + 1.0) * delta * 0.08;
// wrap around when below ground or out of bounds
if (positions[i3 + 1] < -1) {
positions[i3 + 1] = range * 0.5;
positions[i3] = (Math.random() - 0.5) * range;
positions[i3 + 2] = (Math.random() - 0.5) * range;
}
}
dustGeo.attributes.position.needsUpdate = true;
}
Dust motes are tiny additive particles that drift slowly downward with slight horizontal wobble. The wrapping behavior means particles that fall below the ground respawn at the top -- the system runs forever without losing particles.
For rain, increase the downward speed dramatically and make the particles elongated. Instead of PointsMaterial, use a custom shader that stretches points into vertical streaks based on their velocity. Or use thin line segments:
// rain variant: faster, streaked
const rainSpeeds = new Float32Array(particleCount);
for (let i = 0; i < particleCount; i++) {
rainSpeeds[i] = 8 + Math.random() * 4; // much faster than dust
}
// update: fast vertical fall, slight wind
function updateRain(delta) {
const positions = dustGeo.attributes.position.array;
for (let i = 0; i < particleCount; i++) {
const i3 = i * 3;
positions[i3 + 1] -= rainSpeeds[i] * delta;
positions[i3] += 1.5 * delta; // wind pushes rain at an angle
if (positions[i3 + 1] < -1) {
positions[i3 + 1] = range * 0.5 + Math.random() * 5;
positions[i3] = (Math.random() - 0.5) * range;
positions[i3 + 2] = (Math.random() - 0.5) * range;
}
}
dustGeo.attributes.position.needsUpdate = true;
}
Snow is between dust and rain -- moderate speed, larger particles, more horizontal drift. The same system with different numbers. You can even blend weather types by running multiple particle systems simultaneously: dust motes on a sunny day, rain on a stormy one, snow in winter. The day/night cycle controls which particle systems are active and their intensity.
Putting it together: procedural landscape
Allez, the creative exercise. A complete procedural environment: terrain from noise, procedural sky, fog matched to the horizon, a water plane, instanced grass, atmospheric dust, and a slow day/night cycle. Everything built from code, no external images except the water normal map (which you can replace with a procedural one if you want pure code).
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { Sky } from 'three/addons/objects/Sky.js';
// --- renderer ---
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.8;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
65, window.innerWidth / window.innerHeight, 0.1, 500
);
camera.position.set(0, 8, 25);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.maxPolarAngle = Math.PI * 0.48; // don't look below ground
// --- sky ---
const sky = new Sky();
sky.scale.setScalar(10000);
scene.add(sky);
const skyUniforms = sky.material.uniforms;
skyUniforms['turbidity'].value = 2;
skyUniforms['rayleigh'].value = 1.2;
skyUniforms['mieCoefficient'].value = 0.005;
skyUniforms['mieDirectionalG'].value = 0.85;
// --- fog ---
scene.fog = new THREE.FogExp2(0x88aacc, 0.008);
// --- lights ---
const ambientLight = new THREE.AmbientLight(0x445566, 0.5);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffeedd, 2.2);
dirLight.castShadow = true;
dirLight.shadow.mapSize.set(2048, 2048);
dirLight.shadow.camera.left = -40;
dirLight.shadow.camera.right = 40;
dirLight.shadow.camera.top = 40;
dirLight.shadow.camera.bottom = -40;
scene.add(dirLight);
// --- terrain ---
function generateTerrain() {
const geo = new THREE.PlaneGeometry(200, 200, 150, 150);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
const colors = new Float32Array(pos.count * 3);
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
let h = 0;
h += Math.sin(x * 0.025) * Math.cos(z * 0.02) * 5.0;
h += Math.sin(x * 0.06 + 2.0) * Math.cos(z * 0.05 + 1.3) * 2.5;
h += Math.sin(x * 0.15 + 0.7) * Math.cos(z * 0.12 + 3.1) * 1.0;
h += Math.sin(x * 0.35) * Math.cos(z * 0.3 + 0.5) * 0.4;
pos.setY(i, h);
// height-based coloring
const n = (h + 6) / 12;
const col = new THREE.Color();
if (n < 0.35) {
col.setHSL(0.28, 0.4, 0.2);
} else if (n < 0.55) {
col.setHSL(0.25, 0.35, 0.3);
} else if (n < 0.75) {
col.setHSL(0.1, 0.3, 0.35);
} else {
col.setHSL(0.0, 0.0, 0.7);
}
colors[i * 3] = col.r;
colors[i * 3 + 1] = col.g;
colors[i * 3 + 2] = col.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.computeVertexNormals();
const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 0.85,
metalness: 0.0
}));
mesh.receiveShadow = true;
mesh.castShadow = true;
return mesh;
}
const terrain = generateTerrain();
scene.add(terrain);
// --- water plane ---
const waterGeo = new THREE.PlaneGeometry(200, 200);
const waterMat = new THREE.MeshStandardMaterial({
color: 0x1a4d6e,
roughness: 0.1,
metalness: 0.6,
transparent: true,
opacity: 0.75
});
const waterPlane = new THREE.Mesh(waterGeo, waterMat);
waterPlane.rotation.x = -Math.PI / 2;
waterPlane.position.y = -1.5;
waterPlane.receiveShadow = true;
scene.add(waterPlane);
// --- instanced grass ---
function createGrass() {
const bladeGeo = new THREE.PlaneGeometry(0.04, 0.35, 1, 3);
bladeGeo.translate(0, 0.175, 0);
const bladeMat = new THREE.MeshStandardMaterial({
color: 0x3d7a2a,
roughness: 0.8,
side: THREE.DoubleSide
});
const count = 30000;
const mesh = new THREE.InstancedMesh(bladeGeo, bladeMat, count);
const dummy = new THREE.Object3D();
const terrainPos = terrain.geometry.attributes.position;
let placed = 0;
while (placed < count) {
const x = (Math.random() - 0.5) * 80;
const z = (Math.random() - 0.5) * 80;
// approximate terrain height at this xz
const h = sampleTerrainHeight(x, z);
// only place grass above water level
if (h < -1.0) continue;
dummy.position.set(x, h, z);
dummy.rotation.y = Math.random() * Math.PI * 2;
dummy.scale.set(
0.7 + Math.random() * 0.6,
0.5 + Math.random() * 1.0,
1
);
dummy.updateMatrix();
mesh.setMatrixAt(placed, dummy.matrix);
const col = new THREE.Color().setHSL(
0.24 + (Math.random() - 0.5) * 0.05,
0.3 + Math.random() * 0.2,
0.2 + Math.random() * 0.12
);
mesh.setColorAt(placed, col);
placed++;
}
mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor.needsUpdate = true;
mesh.castShadow = true;
return mesh;
}
function sampleTerrainHeight(x, z) {
let h = 0;
h += Math.sin(x * 0.025) * Math.cos(z * 0.02) * 5.0;
h += Math.sin(x * 0.06 + 2.0) * Math.cos(z * 0.05 + 1.3) * 2.5;
h += Math.sin(x * 0.15 + 0.7) * Math.cos(z * 0.12 + 3.1) * 1.0;
h += Math.sin(x * 0.35) * Math.cos(z * 0.3 + 0.5) * 0.4;
return h;
}
const grass = createGrass();
scene.add(grass);
// --- dust particles ---
const dustCount = 3000;
const dustPositions = new Float32Array(dustCount * 3);
const dustVelocities = new Float32Array(dustCount);
for (let i = 0; i < dustCount; i++) {
dustPositions[i * 3] = (Math.random() - 0.5) * 50;
dustPositions[i * 3 + 1] = Math.random() * 20 + 1;
dustPositions[i * 3 + 2] = (Math.random() - 0.5) * 50;
dustVelocities[i] = 0.2 + Math.random() * 0.5;
}
const dustGeo = new THREE.BufferGeometry();
dustGeo.setAttribute('position',
new THREE.BufferAttribute(dustPositions, 3));
const dustMat = new THREE.PointsMaterial({
color: 0xeeddbb,
size: 0.06,
transparent: true,
opacity: 0.35,
sizeAttenuation: true,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const dustParticles = new THREE.Points(dustGeo, dustMat);
scene.add(dustParticles);
// --- animation ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
const elapsed = clock.getElapsedTime();
// day/night cycle (2 minutes per day)
const dayTime = (elapsed % 120) / 120;
updateSky(dayTime);
// dust particles
const dPos = dustGeo.attributes.position.array;
for (let i = 0; i < dustCount; i++) {
const i3 = i * 3;
dPos[i3 + 1] -= dustVelocities[i] * delta * 0.4;
dPos[i3] += Math.sin(dPos[i3 + 1] * 0.3 + elapsed * 0.2) * delta * 0.15;
dPos[i3 + 2] += Math.cos(dPos[i3 + 1] * 0.2 + elapsed * 0.15) * delta * 0.1;
if (dPos[i3 + 1] < 0) {
dPos[i3 + 1] = 20 + Math.random() * 5;
dPos[i3] = (Math.random() - 0.5) * 50;
dPos[i3 + 2] = (Math.random() - 0.5) * 50;
}
}
dustGeo.attributes.position.needsUpdate = true;
controls.update();
renderer.render(scene, camera);
}
function updateSky(t) {
const dayPhase = t * Math.PI * 2;
const elevation = Math.max(2, Math.sin(dayPhase) * 60 + 15);
const azimuth = t * 360;
const phi = THREE.MathUtils.degToRad(90 - elevation);
const theta = THREE.MathUtils.degToRad(azimuth);
const sunPos = new THREE.Vector3().setFromSphericalCoords(1, phi, theta);
skyUniforms['sunPosition'].value.copy(sunPos);
dirLight.position.copy(sunPos).multiplyScalar(50);
const sunFactor = Math.max(0, Math.sin(dayPhase));
dirLight.intensity = 0.3 + sunFactor * 2.2;
const warmth = 1.0 - Math.pow(Math.max(0.01, sunFactor), 0.4);
dirLight.color.setHSL(0.08 * warmth, 0.4 * warmth, 0.5 + sunFactor * 0.5);
ambientLight.intensity = 0.1 + sunFactor * 0.5;
const fogHue = 0.55 - warmth * 0.45;
const fogLgt = 0.2 + sunFactor * 0.5;
scene.fog.color.setHSL(fogHue, 0.2, fogLgt);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
That's a complete procedural landscape in ~180 lines. Terrain from layered sines, height-based coloring, a water plane at the valleys, 30,000 instanced grass blades that only grow above water level, 3,000 dust motes drifting through the air, a procedural sky, fog that matches the horizon, and a slow day/night cycle animating everything together. Orbit around it and the scene feels like a place -- hills roll, water catches the light, grass covers the slopes, dust catches sunbeams, and the whole mood shifts as the sun arcs overhead.
The terrain height sampling function (sampleTerrainHeight) duplicates the same noise formula used to generate the terrain mesh. This is a common pattern -- you need to query the terrain height at arbitrary positions (for placing grass, trees, buildings, characters) and the easiest way is to evaluate the same noise function rather than doing a spatial lookup into the mesh vertices. For complex noise (proper simplex with many octaves), you'd factor the noise function out so it's shared between terrain generation and height queries.
What's ahead
We've built environments from individual components: sky, fog, ground, terrain, water, vegetation, atmosphere, lighting. Each piece supports the others -- fog matches the sky, grass follows the terrain, lights drive the mood, particles add life. The techniques work for any aesthetic: realistic landscapes, alien planets, abstract void-scapes, underwater worlds. Swap the numbers and the same systems produce radically different places.
Next time we're adding interaction to these 3D worlds -- raycasting to detect what the mouse points at, picking and moving objects, hovering effects. Making the world respond to you instead of just existing around you.
't Komt erop neer...
- Skyboxes use
CubeTextureLoaderto load six images (px, nx, py, ny, pz, nz) into aCubeTexture. Setscene.background = cubeTextureand Three.js renders it as an infinitely distant backdrop. The camera can never get closer to it -- it's always on the horizon. Same technique every 3D game uses for distant scenery - Environment maps use the same CubeTexture as
scene.environmentfor Image-Based Lighting. EveryMeshStandardMaterialpicks it up for reflections and ambient light.metalness: 1, roughness: 0gives a perfect mirror. Increasing roughness blurs the reflection by sampling higher mipmap levels. IBL is why PBR materials look flat without an environment map - HDR environments via
RGBELoadercapture the full brightness range of real scenes. The sun can be thousands of times brighter than the sky. Tone mapping (ACESFilmicToneMapping) compresses HDR into displayable range while preserving brightness relationships. Polyhaven.com has hundreds of free HDR environments. UsePMREMGeneratorto preprocess the map for roughness-based sampling - The Sky shader (
three/addons/objects/Sky.js) generates a procedural atmosphere from Rayleigh and Mie scattering parameters. Sun position drives everything -- high elevation gives bright blue sky, low elevation gives orange/red sunset, below horizon darkens to night. Turbidity controls haze, rayleigh controls blue depth, mie controls sun glow - Fog (
THREE.Fogfor linear,THREE.FogExp2for exponential) fades distant objects into a solid color. Match fog color to the sky's horizon color to avoid visible seams between ground and sky. Fog also hides the far clip plane and ground plane edges -- it's practical infrastructure, not just aesthetic - Ground planes: large
PlaneGeometryrotated horizontal,receiveShadow = true. Shadow camera frustum (left/right/top/bottom) needs to cover the shadow area -- too small clips shadows, too large wastes resolution. Add textures (diffuse + normal map) withRepeatWrappingfor surface detail - Procedural terrain: displace plane vertices with layered noise (sine standin or real simplex). Multiple octaves: big slow shapes plus small fast details.
computeVertexNormals()after displacement for correct lighting. Height-based vertex coloring for biome zones. Factor out the noise function for terrain height queries at arbitrary positions - Instanced grass: 30,000-50,000 tiny double-sided plane blades in a single draw call via
InstancedMesh. Random position, rotation, scale per instance. Per-instance color variation. Only place above water level by sampling terrain height. Double-sided material so blades are visible from every angle - Water: semi-transparent reflective plane at a fixed Y level, or the full
Wateraddon for animated normal-mapped reflections. The addon renders a mirrored camera view into a reflection texture.distortionScalecontrols roughness. Even a simple transparent blue plane reads as water if it catches the right light - Day/night cycle: animate sun position (spherical coords), update Sky shader, ramp light intensity with elevation, shift light color warm at low angles. Sync fog color to sky horizon. Dim ambient at night, brighten during day. One function updates everything -- call it with a normalized time value (0-1 = one day)
- Atmospheric particles: dust (slow drift, additive blend, small size), rain (fast downward, wind-angled), snow (moderate speed, larger, more drift). Wrapping bounds so particles respawn at the top. Match particle style to environment mood -- dust for sunny, rain for storms, snow for winter
Sallukes! Thanks for reading.
X