Procedural Weather
Dynamic, layered weather effects in Three.js — GPU particle precipitation, volumetric fog, lightning, and smooth state transitions.
Architecture Overview
┌──────────────────────────────────────────────────────┐
│ Weather System │
│ │
│ WeatherController (state machine) │
│ ├── current state + target state │
│ ├── transition progress (0→1) │
│ └── drives all subsystems: │
│ │
│ ┌─ Precipitation ──────────────────────────────┐ │
│ │ GPU particles: rain, snow, hail │ │
│ │ Ground splashes, accumulation │ │
│ └──────────────────────────────────────────────┘ │
│ ┌─ Atmosphere ─────────────────────────────────┐ │
│ │ Fog, mist, dust, haze │ │
│ │ Volumetric (WebGPU) or exponential (WebGL) │ │
│ └──────────────────────────────────────────────┘ │
│ ┌─ Electrical ─────────────────────────────────┐ │
│ │ Lightning bolts, sheet flashes │ │
│ │ Cloud-internal illumination │ │
│ └──────────────────────────────────────────────┘ │
│ ┌─ Optical ────────────────────────────────────┐ │
│ │ Rainbows, aurora, god rays │ │
│ │ Wet lens, frost overlay │ │
│ └──────────────────────────────────────────────┘ │
│ ┌─ Environment ────────────────────────────────┐ │
│ │ Sky darkening, light color shift │ │
│ │ Wind direction (shared across systems) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
Renderer Setup
import * as THREE from 'three';
async function createRenderer(canvas) {
let renderer, gpuAvailable = false;
try {
const WebGPU = (await import('three/addons/capabilities/WebGPU.js')).default;
if (WebGPU.isAvailable()) {
const { default: WebGPURenderer } = await import(
'three/addons/renderers/webgpu/WebGPURenderer.js'
);
renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init();
gpuAvailable = true;
}
} catch (e) {}
if (!renderer) {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.toneMapping = THREE.ACESFilmicToneMapping;
}
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
return { renderer, gpuAvailable };
}
Shared Wind System
Wind drives all weather subsystems — precipitation angle, fog drift, debris direction.
class WindSystem {
constructor() {
this.direction = new THREE.Vector3(1, 0, 0.3).normalize();
this.baseSpeed = 5; // m/s
this.gustSpeed = 0;
this.gustFrequency = 0.5;
this.turbulence = 0.1;
this._time = 0;
}
update(dt) {
this._time += dt;
// Gusts: low-frequency intensity variation
const gustEnvelope = Math.sin(this._time * this.gustFrequency) * 0.5 + 0.5;
this.gustSpeed = gustEnvelope * this.baseSpeed * 0.6;
}
get speed() { return this.baseSpeed + this.gustSpeed; }
// Wind force vector for particle displacement
get force() {
const s = this.speed;
const turb = new THREE.Vector3(
Math.sin(this._time * 2.3) * this.turbulence,
0,
Math.cos(this._time * 1.7) * this.turbulence
);
return this.direction.clone().multiplyScalar(s).add(turb);
}
}
Precipitation System
GPU particle system for rain, snow, and hail. Particles spawn in a box above the camera, fall with gravity + wind, and recycle when below ground.
Particle Buffer Layout
function createPrecipitationBuffers(maxParticles) {
// Each particle: vec4(x, y, z, life) + vec4(vx, vy, vz, size)
const positions = new Float32Array(maxParticles * 4);
const velocities = new Float32Array(maxParticles * 4);
return { positions, velocities, count: maxParticles };
}
Rain System
class RainSystem {
constructor(scene, options = {}) {
this.scene = scene;
this.count = options.count ?? 50000;
this.spawnRadius = options.spawnRadius ?? 40;
this.spawnHeight = options.spawnHeight ?? 30;
this.dropLength = options.dropLength ?? 0.3;
this.intensity = options.intensity ?? 1.0; // 0-1
this._buildGeometry();
}
_buildGeometry() {
// Each raindrop: a short line segment (2 vertices)
const positions = new Float32Array(this.count * 6); // 2 verts × xyz
const randoms = new Float32Array(this.count * 2); // per-drop seed + phase
for (let i = 0; i < this.count; i++) {
const x = (Math.random() - 0.5) * this.spawnRadius * 2;
const y = Math.random() * this.spawnHeight;
const z = (Math.random() - 0.5) * this.spawnRadius * 2;
// Top vertex
positions[i * 6] = x;
positions[i * 6 + 1] = y;
positions[i * 6 + 2] = z;
// Bottom vertex (offset by drop length)
positions[i * 6 + 3] = x;
positions[i * 6 + 4] = y - this.dropLength;
positions[i * 6 + 5] = z;
randoms[i * 2] = Math.random(); // seed
randoms[i * 2 + 1] = Math.random(); // phase offset
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 2, false, this.count));
this.material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
intensity: { value: this.intensity },
windForce: { value: new THREE.Vector3() },
gravity: { value: -15.0 },
spawnHeight: { value: this.spawnHeight },
spawnRadius: { value: this.spawnRadius },
dropLength: { value: this.dropLength },
opacity: { value: 0.35 },
cameraPos: { value: new THREE.Vector3() },
},
vertexShader: RAIN_VERT, // See references/weather-shaders.md
fragmentShader: RAIN_FRAG, // See references/weather-shaders.md
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
this.mesh = new THREE.LineSegments(geometry, this.material);
this.mesh.frustumCulled = false;
this.scene.add(this.mesh);
}
update(time, wind, cameraPos) {
this.material.uniforms.time.value = time;
this.material.uniforms.windForce.value.copy(wind.force);
this.material.uniforms.cameraPos.value.copy(cameraPos);
this.material.uniforms.intensity.value = this.intensity;
// Recenter spawn box around camera
this.mesh.position.x = cameraPos.x;
this.mesh.position.z = cameraPos.z;
}
dispose() {
this.scene.remove(this.mesh);
this.mesh.geometry.dispose();
this.material.dispose();
}
}
Snow System
Snow uses Points with per-particle flutter and tumble. Larger particles, slower fall,
more wind influence.
class SnowSystem {
constructor(scene, options = {}) {
this.scene = scene;
this.count = options.count ?? 20000;
this.spawnRadius = options.spawnRadius ?? 50;
this.spawnHeight = options.spawnHeight ?? 25;
this.intensity = options.intensity ?? 1.0;
this._build();
}
_build() {
const positions = new Float32Array(this.count * 3);
const seeds = new Float32Array(this.count * 3); // seed, phase, size
for (let i = 0; i < this.count; i++) {
positions[i * 3] = (Math.random() - 0.5) * this.spawnRadius * 2;
positions[i * 3 + 1] = Math.random() * this.spawnHeight;
positions[i * 3 + 2] = (Math.random() - 0.5) * this.spawnRadius * 2;
seeds[i * 3] = Math.random();
seeds[i * 3 + 1] = Math.random() * Math.PI * 2;
seeds[i * 3 + 2] = 0.5 + Math.random() * 1.5; // size variation
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 3));
this.material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
intensity: { value: this.intensity },
windForce: { value: new THREE.Vector3() },
gravity: { value: -1.5 },
spawnHeight: { value: this.spawnHeight },
spawnRadius: { value: this.spawnRadius },
flutterAmp: { value: 2.0 },
opacity: { value: 0.85 },
pointSize: { value: 3.0 },
cameraPos: { value: new THREE.Vector3() },
},
vertexShader: SNOW_VERT, // See references/weather-shaders.md
fragmentShader: SNOW_FRAG, // See references/weather-shaders.md
transparent: true,
depthWrite: false,
});
this.mesh = new THREE.Points(geometry, this.material);
this.mesh.frustumCulled = false;
this.scene.add(this.mesh);
}
update(time, wind, cameraPos) {
this.material.uniforms.time.value = time;
this.material.uniforms.windForce.value.copy(wind.force);
this.material.uniforms.cameraPos.value.copy(cameraPos);
this.material.uniforms.intensity.value = this.intensity;
this.mesh.position.x = cameraPos.x;
this.mesh.position.z = cameraPos.z;
}
dispose() {
this.scene.remove(this.mesh);
this.mesh.geometry.dispose();
this.material.dispose();
}
}
Splash / Impact System
Ground splashes when rain hits surfaces — instanced ring sprites at impact points.
class SplashSystem {
constructor(scene, maxSplashes = 500) {
this.scene = scene;
this.max = maxSplashes;
const geo = new THREE.PlaneGeometry(0.15, 0.15);
geo.rotateX(-Math.PI / 2);
this.material = new THREE.ShaderMaterial({
uniforms: { time: { value: 0 } },
vertexShader: SPLASH_VERT,
fragmentShader: SPLASH_FRAG,
transparent: true,
depthWrite: false,
});
this.mesh = new THREE.InstancedMesh(geo, this.material, maxSplashes);
this.mesh.frustumCulled = false;
this.lifetimes = new Float32Array(maxSplashes);
this.nextIdx = 0;
this.scene.add(this.mesh);
}
spawn(position) {
const dummy = new THREE.Object3D();
dummy.position.copy(position);
dummy.position.y += 0.01;
dummy.scale.setScalar(0.5 + Math.random() * 0.5);
dummy.updateMatrix();
this.mesh.setMatrixAt(this.nextIdx, dummy.matrix);
this.lifetimes[this.nextIdx] = 1.0;
this.mesh.instanceMatrix.needsUpdate = true;
this.nextIdx = (this.nextIdx + 1) % this.max;
}
update(dt) {
for (let i = 0; i < this.max; i++) {
if (this.lifetimes[i] > 0) {
this.lifetimes[i] -= dt * 4; // ~0.25s lifetime
}
}
this.material.uniforms.time.value += dt;
}
}
Fog & Atmosphere
Exponential Fog (WebGL — built-in)
function applyFog(scene, density = 0.01, color = 0xcccccc) {
scene.fog = new THREE.FogExp2(color, density);
scene.background = new THREE.Color(color);
}
Ground Fog (Shader-based)
Height-attenuated fog that pools in valleys. Applied as a post-process or scene material.
function createGroundFog(size = 200, maxHeight = 8) {
const geo = new THREE.PlaneGeometry(size, size, 1, 1);
geo.rotateX(-Math.PI / 2);
const material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
fogColor: { value: new THREE.Color(0xdddddd) },
maxHeight: { value: maxHeight },
density: { value: 0.8 },
windDir: { value: new THREE.Vector2(1, 0) },
windSpeed: { value: 2.0 },
},
vertexShader: `
varying vec3 vWorldPos;
varying vec2 vUv;
void main() {
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: GROUND_FOG_FRAG, // See references/weather-shaders.md
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geo, material);
mesh.position.y = maxHeight * 0.5;
mesh.renderOrder = 999;
return mesh;
}
Dust / Sandstorm
Dense particle field with color tinting and reduced visibility.
function createDustStorm(scene, options = {}) {
const count = options.count ?? 30000;
const color = options.color ?? new THREE.Color(0xc4a060);
const positions = new Float32Array(count * 3);
const seeds = new Float32Array(count);
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * 80;
positions[i * 3 + 1] = Math.random() * 15;
positions[i * 3 + 2] = (Math.random() - 0.5) * 80;
seeds[i] = Math.random();
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1));
const material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
dustColor: { value: color },
windForce: { value: new THREE.Vector3(8, 0, 2) },
opacity: { value: 0.4 },
pointSize: { value: 4.0 },
},
vertexShader: DUST_VERT,
fragmentShader: DUST_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.NormalBlending,
});
const mesh = new THREE.Points(geo, material);
mesh.frustumCulled = false;
scene.add(mesh);
return { mesh, material };
}
Lightning System
Procedural branching lightning bolts with flash illumination.
class LightningSystem {
constructor(scene) {
this.scene = scene;
this.bolts = [];
this.flashLight = new THREE.PointLight(0xccccff, 0, 500);
this.flashLight.position.set(0, 80, 0);
this.scene.add(this.flashLight);
this._nextStrike = 2 + Math.random() * 8;
this._flashDecay = 0;
}
// Generate a branching bolt path
generateBolt(start, end, generations = 5, jitter = 15) {
if (generations <= 0) return [start, end];
const mid = start.clone().lerp(end, 0.4 + Math.random() * 0.2);
const perpX = (Math.random() - 0.5) * jitter;
const perpZ = (Math.random() - 0.5) * jitter;
mid.x += perpX;
mid.z += perpZ;
const left = this.generateBolt(start, mid, generations - 1, jitter * 0.6);
const right = this.generateBolt(mid, end, generations - 1, jitter * 0.6);
// Branch: 30% chance at each midpoint
let branch = [];
if (Math.random() < 0.3 && generations > 2) {
const branchEnd = mid.clone().add(
new THREE.Vector3((Math.random() - 0.5) * jitter * 2, -jitter, (Math.random() - 0.5) * jitter * 2)
);
branch = this.generateBolt(mid, branchEnd, generations - 2, jitter * 0.4);
}
return [...left, ...right.slice(1), ...branch];
}
_createBoltMesh(points) {
const positions = new Float32Array(points.length * 3);
for (let i = 0; i < points.length; i++) {
positions[i * 3] = points[i].x;
positions[i * 3 + 1] = points[i].y;
positions[i * 3 + 2] = points[i].z;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.LineBasicMaterial({
color: 0xeeeeff,
transparent: true,
opacity: 1.0,
linewidth: 2,
});
// Core bolt
const line = new THREE.Line(geo, material);
// Glow (wider, transparent)
const glowMat = new THREE.LineBasicMaterial({
color: 0x8888ff,
transparent: true,
opacity: 0.4,
linewidth: 1,
});
const glow = new THREE.Line(geo.clone(), glowMat);
glow.scale.setScalar(1.02);
const group = new THREE.Group();
group.add(line);
group.add(glow);
return { group, material, glowMat, life: 0.3 };
}
strike(origin, groundY = 0) {
const start = origin ?? new THREE.Vector3(
(Math.random() - 0.5) * 100, 70 + Math.random() * 30, (Math.random() - 0.5) * 100
);
const end = new THREE.Vector3(start.x + (Math.random() - 0.5) * 20, groundY, start.z + (Math.random() - 0.5) * 20);
const points = this.generateBolt(start, end, 6, 12);
const bolt = this._createBoltMesh(points);
this.scene.add(bolt.group);
this.bolts.push(bolt);
// Flash
this.flashLight.position.copy(start);
this.flashLight.intensity = 15;
this._flashDecay = 0.15;
}
update(dt, stormIntensity = 0.5) {
// Auto-strike timing
this._nextStrike -= dt;
if (this._nextStrike <= 0 && stormIntensity > 0.3) {
this.strike();
this._nextStrike = (1 / stormIntensity) * (3 + Math.random() * 7);
}
// Fade bolts
for (let i = this.bolts.length - 1; i >= 0; i--) {
const bolt = this.bolts[i];
bolt.life -= dt;
const alpha = Math.max(bolt.life / 0.3, 0);
bolt.material.opacity = alpha;
bolt.glowMat.opacity = alpha * 0.4;
if (bolt.life <= 0) {
this.scene.remove(bolt.group);
bolt.group.traverse(c => { if (c.geometry) c.geometry.dispose(); });
this.bolts.splice(i, 1);
}
}
// Flash decay
if (this._flashDecay > 0) {
this._flashDecay -= dt;
this.flashLight.intensity *= 0.85;
} else {
this.flashLight.intensity = 0;
}
}
dispose() {
for (const bolt of this.bolts) {
this.scene.remove(bolt.group);
}
this.scene.remove(this.flashLight);
}
}
Optical Effects
Rainbow
Arc rendered as a screen-space shader overlay or a mesh arc in world space.
function createRainbow(scene, sunDir) {
const geo = new THREE.TorusGeometry(120, 3, 16, 64, Math.PI);
const material = new THREE.ShaderMaterial({
uniforms: {
opacity: { value: 0.25 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
uniform float opacity;
void main() {
float t = vUv.x;
// ROYGBIV spectrum across the arc width
vec3 col;
if (t < 0.14) col = vec3(1.0, 0.0, 0.0);
else if (t < 0.28) col = vec3(1.0, 0.5, 0.0);
else if (t < 0.42) col = vec3(1.0, 1.0, 0.0);
else if (t < 0.57) col = vec3(0.0, 0.8, 0.0);
else if (t < 0.71) col = vec3(0.0, 0.4, 1.0);
else if (t < 0.85) col = vec3(0.3, 0.0, 0.8);
else col = vec3(0.5, 0.0, 0.5);
// Soft edges
float edge = smoothstep(0.0, 0.05, t) * smoothstep(1.0, 0.95, t);
gl_FragColor = vec4(col, opacity * edge);
}
`,
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
});
const rainbow = new THREE.Mesh(geo, material);
// Position opposite to sun direction
rainbow.position.set(-sunDir.x * 80, 20, -sunDir.z * 80);
rainbow.rotation.z = Math.PI;
rainbow.rotation.y = Math.atan2(-sunDir.x, -sunDir.z);
scene.add(rainbow);
return rainbow;
}
Aurora Borealis
Undulating curtain of colored light using a displaced vertical plane.
function createAurora(scene) {
const geo = new THREE.PlaneGeometry(300, 40, 128, 16);
const material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
color1: { value: new THREE.Color(0x00ff88) },
color2: { value: new THREE.Color(0x4400ff) },
color3: { value: new THREE.Color(0xff0066) },
},
vertexShader: AURORA_VERT, // See references/weather-shaders.md
fragmentShader: AURORA_FRAG, // See references/weather-shaders.md
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
});
const mesh = new THREE.Mesh(geo, material);
mesh.position.set(0, 60, -100);
mesh.rotation.x = -0.3;
scene.add(mesh);
return { mesh, material };
}
Weather State Machine
Smooth transitions between weather states by interpolating all subsystem parameters.
class WeatherController {
constructor(scene, camera, wind) {
this.scene = scene;
this.camera = camera;
this.wind = wind;
this.rain = null;
this.snow = null;
this.lightning = null;
this.fog = null;
this.currentState = 'clear';
this.targetState = 'clear';
this.transition = 1.0; // 1 = fully arrived at target
this.transitionSpeed = 0.3; // 0→1 over ~3 seconds
}
setState(stateName) {
if (stateName === this.currentState && this.transition >= 1) return;
this.currentState = this.targetState;
this.targetState = stateName;
this.transition = 0;
}
update(dt) {
this.wind.update(dt);
if (this.transition < 1) {
this.transition = Math.min(this.transition + dt * this.transitionSpeed, 1);
}
const t = this.transition;
const from = WEATHER_STATES[this.currentState];
const to = WEATHER_STATES[this.targetState];
// Interpolate parameters
const rainIntensity = lerp(from.rain, to.rain, t);
const snowIntensity = lerp(from.snow, to.snow, t);
const fogDensity = lerp(from.fogDensity, to.fogDensity, t);
const stormIntensity = lerp(from.lightning, to.lightning, t);
const skyDarkness = lerp(from.skyDarkness, to.skyDarkness, t);
const windMult = lerp(from.windMultiplier, to.windMultiplier, t);
this.wind.baseSpeed = 5 * windMult;
// Update subsystems
if (this.rain) {
this.rain.intensity = rainIntensity;
this.rain.update(performance.now() * 0.001, this.wind, this.camera.position);
}
if (this.snow) {
this.snow.intensity = snowIntensity;
this.snow.update(performance.now() * 0.001, this.wind, this.camera.position);
}
if (this.lightning) {
this.lightning.update(dt, stormIntensity);
}
// Fog
if (this.scene.fog) {
this.scene.fog.density = fogDensity;
}
// Sky darkening
const skyColor = new THREE.Color(0x87ceeb).lerp(new THREE.Color(0x333340), skyDarkness);
if (this.scene.background instanceof THREE.Color) {
this.scene.background.copy(skyColor);
}
}
}
function lerp(a, b, t) { return a + (b - a) * t; }
const WEATHER_STATES = {
clear: { rain: 0, snow: 0, fogDensity: 0.0005, lightning: 0, skyDarkness: 0, windMultiplier: 0.5 },
cloudy: { rain: 0, snow: 0, fogDensity: 0.002, lightning: 0, skyDarkness: 0.2, windMultiplier: 0.8 },
drizzle: { rain: 0.3, snow: 0, fogDensity: 0.004, lightning: 0, skyDarkness: 0.3, windMultiplier: 0.7 },
rain: { rain: 0.7, snow: 0, fogDensity: 0.006, lightning: 0.1, skyDarkness: 0.5, windMultiplier: 1.2 },
heavyRain: { rain: 1.0, snow: 0, fogDensity: 0.01, lightning: 0.3, skyDarkness: 0.7, windMultiplier: 1.8 },
storm: { rain: 1.0, snow: 0, fogDensity: 0.015, lightning: 0.8, skyDarkness: 0.85, windMultiplier: 2.5 },
lightSnow: { rain: 0, snow: 0.3, fogDensity: 0.003, lightning: 0, skyDarkness: 0.15, windMultiplier: 0.6 },
snow: { rain: 0, snow: 0.7, fogDensity: 0.006, lightning: 0, skyDarkness: 0.3, windMultiplier: 1.0 },
blizzard: { rain: 0, snow: 1.0, fogDensity: 0.025, lightning: 0, skyDarkness: 0.6, windMultiplier: 3.0 },
fog: { rain: 0, snow: 0, fogDensity: 0.03, lightning: 0, skyDarkness: 0.25, windMultiplier: 0.2 },
sandstorm: { rain: 0, snow: 0, fogDensity: 0.02, lightning: 0.1, skyDarkness: 0.5, windMultiplier: 3.0 },
};
Camera Post-Processing
Wet Lens Effect
Droplets on screen when raining, applied as a fullscreen overlay.
function createWetLensOverlay(intensity = 0.5) {
return new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
intensity: { value: intensity },
tScene: { value: null },
},
vertexShader: FULLSCREEN_VERT,
fragmentShader: WET_LENS_FRAG, // See references/weather-shaders.md
transparent: true,
});
}
Frost Overlay
Progressive frost creep from screen edges during cold/blizzard conditions.
function createFrostOverlay(intensity = 0.0) {
return new THREE.ShaderMaterial({
uniforms: {
intensity: { value: intensity }, // 0=clear, 1=fully frosted
time: { value: 0 },
},
vertexShader: FULLSCREEN_VERT,
fragmentShader: FROST_FRAG,
transparent: true,
});
}
Complete Scene Assembly
async function init() {
const canvas = document.querySelector('#canvas');
const { renderer } = await createRenderer(canvas);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.FogExp2(0x87ceeb, 0.001);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.5, 500);
camera.position.set(0, 5, 20);
const { OrbitControls } = await import('three/addons/controls/OrbitControls.js');
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// Ground
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(200, 200),
new THREE.MeshStandardMaterial({ color: 0x4a7c3f, roughness: 0.9 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Lighting
const sun = new THREE.DirectionalLight(0xfff4e5, 1.2);
sun.position.set(30, 40, 20);
scene.add(sun);
scene.add(new THREE.HemisphereLight(0x87ceeb, 0x4a7c3f, 0.5));
// Weather
const wind = new WindSystem();
const weather = new WeatherController(scene, camera, wind);
weather.rain = new RainSystem(scene);
weather.snow = new SnowSystem(scene);
weather.lightning = new LightningSystem(scene);
// Start with rain
weather.setState('rain');
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
weather.update(clock.getDelta());
controls.update();
renderer.render(scene, camera);
});
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
}
init();
Performance Guidelines
| Effect | Particle Count | Draw Calls | Notes |
|---|---|---|---|
| Rain | 30K–80K | 1 (LineSegments) | Additive blend, no depth write |
| Snow | 10K–30K | 1 (Points) | Larger point size = fewer needed |
| Splashes | 200–500 | 1 (InstancedMesh) | Short lifetime, ring-buffer |
| Dust | 15K–40K | 1 (Points) | Camera-centered spawn box |
| Lightning | 1–3 bolts | 2–6 (Lines) | Ephemeral, negligible cost |
| Fog plane | 1 quad | 1 | Fragment-heavy, keep simple |
| Aurora | 1 plane | 1 | Vertex displacement only |
Total budget: All weather combined stays under 5 draw calls and ~100K particles.
Key rules:
- All animation in vertex shaders via uniforms (time, wind). Zero per-frame JS particle loops.
frustumCulled = falseon all particle meshes — spawn box follows camera.- Recenter spawn box on camera each frame so particles always surround the viewer.
depthWrite: falseon all transparent weather effects to avoid sorting artifacts.
Common Pitfalls
- Rain falls through roof: Precipitation has no collision. For indoor scenes, cull particles below a Y threshold or use a height map mask uniform.
- Snow accumulation: Not simulated per-particle. Use a ground plane shader that blends white coverage based on
snowIntensity * time, or a decal system. - Lightning too regular: Randomize interval per
stormIntensity. Real lightning is clustered — bursts of 2–3 strikes then silence. - Fog and sky mismatch: Always set
scene.fog.color=scene.background. Mismatched colors create visible hard edges at the distance limit. - Weather pops on/off: Always use the state machine transition. Interpolating intensity from 0→target over 2–3 seconds looks natural.
References
references/weather-shaders.md— Complete GLSL vertex/fragment shaders for rain, snow, dust, ground fog, aurora, wet lens, frost, and WGSL compute particles.references/weather-types.md— Detailed profiles for 12 weather states with parameter tables, artistic direction, environmental effects, and combination rules.