Clouds and sky
A real sky for a three.js game on WebGPU: volumetric clouds with cloud shadows and light shafts, a day-night cycle with a sun, moon and stars, and air that hazes the distance. Set the time, the cover, the height and the wind.
It's takram's clouds and atmosphere, by way of nagoya0's WebGPU port webgpu-skyscape, flattened from a flight demo over a planet to a game world you stand in. In the live demo you stand in a valley: pick a weather, then drag the time of day. Drag the frame to look around, and scroll to zoom.
Add it to a game
Copy lib/sky/, vendor/@takram/ and public/clouds, public/noise and public/stars into your project. Point the takram imports at vendor/ as vite.config.mjs does. Needs three 0.186.1 exactly, fast-png, @petamoriken/float16 and tiny-invariant.
import { PerspectiveCamera, Scene, WebGPURenderer } from 'three/webgpu';
import { createSky, requestSkyDevice } from './lib/sky/index';
// Its own device, because the clouds need more of the card than three asks for. got.ok is false where they can't run.
const got = await requestSkyDevice();
const renderer = new WebGPURenderer({ canvas, device: got.device, antialias: false, reversedDepthBuffer: true });
await renderer.init();
const scene = new Scene();
const camera = new PerspectiveCamera(58, innerWidth / innerHeight, 0.1, 1e7);
const sky = await createSky(renderer, scene, camera, { quality: 'medium' });
sky.setTime(17.5); // hours
sky.setCover(0.45); // 0 clear to 1 overcast
sky.setHeight(1200); // the lowest cloud base, metres above sea level
sky.setWind(8, 60); // metres per second, and the bearing it blows towards
// Your objects are lit by the sky's lights. receiveShadow gives them the clouds' shadow and the sun's.
mesh.receiveShadow = true;
mesh.castShadow = true;
renderer.setAnimationLoop(() => {
sky.update(1 / 60); // call it before render
sky.render(); // draws your scene and the sky; don't call renderer.render
});
The world. y is up, y = 0 is sea level, +x is north and +z is east, in metres. Keep the camera above y = 0. Underneath, the clouds and the air still curve round a planet, because that's what makes clouds crowd towards the horizon, but nothing outside lib/sky/ has to know.
What it asks of your materials. Any lit node material works: sky.sun and the others are ordinary lights in your scene, and the sky adds them itself. Don't add your own sun, and don't set scene.background.
Vertices you place in a shader need their own velocity. The anti-aliasing reads each pixel's motion, and takram's velocity compares where a vertex is now with its raw geometry. Place vertices with positionNode (grass, wind, anything instanced by hand) and every one looks as if it jumped across the world, so it keeps no history and shimmers, even standing still. Set the material's mrtNode to a velocity of your own: grassField in src/valley.ts shows how.
Things you can read.
sky.sunDirection,sky.moonDirection,sky.sunAltitude: for your own effects.sky.overcast: 0 to 1, for rain and wet ground.sky.sunlightAt(position),sky.densityAt(position): how much sun reaches a point, and whether it's in a cloud, on the CPU.
Your own effects. createSky(..., { stages: [(input, depth) => node] }) adds a step after the clouds, in HDR, before the lens flare and tone mapping. The demo's lake is one (src/lake.ts): it mirrors what's already on the screen, clouds and all, with no second draw of the scene.
Loading without freezing the page. The first draw of everything compiles its shader, and by default that blocks Chrome's GPU process, so the whole page stops painting. compileInBackground(renderer) (lib/sky/warm.ts) sends those compiles to other threads while you warm up behind a cover. See src/app.ts for the order.
Other settings. latitude, day, moonPhase; shadowMapSize and shadowRange, for your scene's own shadow map from the sun (it's drawn again only when the sun has moved or the camera has gone a good way, or when you call sky.refreshShadows()); flare, to leave out the lens flare and bloom; sky.setExposure().
Quality
setQuality | For | Cloud steps | Shadow maps | Light shafts | Pixels (RENDER_SCALE) |
|---|---|---|---|---|---|
high | about an RTX 2060 and up | 160 | 512 | yes | all |
medium | a good laptop | 96 | 384 | fewer steps | 85% across |
low | a weak laptop with WebGPU | 56 | 256 | none | 60% across |
Changing level compiles nothing: it only changes uniforms and the shadow maps' size. Pixels are yours to set: call renderer.setPixelRatio() with the level's RENDER_SCALE (lib/sky/quality.ts), which is most of the saving. Changing the size restarts the clouds' temporal fill, so they speckle for 16 frames.
The baked sky, for everything below that. lib/sky/baked.ts has the same methods, and draws pictures taken of the live sky (tools/bake.mjs) instead of marching anything, on WebGPU or WebGL 2. It costs a few texture reads. Thirteen times and three covers come to about 1 MB, and it fades between them. Clouds don't move in it, cloud height and wind do nothing, and the ground gets no cloud shadows. Rebake if you change the look.
import { createBakedSky } from './lib/sky/baked';
const sky = await createBakedSky(renderer, scene, camera, { url: './baked/' });
The demo picks its level by timing the card during the warm-up. It steps down if frames run slow, and gives up the live clouds if Low can't hold. Try the fallback at ?quality=baked, and on WebGL 2 at ?quality=baked&webgl=1.
What it's bad at
- WebGPU only, for the live clouds. The baked sky is the only thing on anything else.
- It's not cheap. 7 ms a frame at 1080p at High on an RTX 4090, with this demo's scene and a shared GPU; the sky is most of that. Slower cards are estimates: only the 4090 has run it. Our old stylised clouds cost a tenth of that.
- It takes over your frame. It owns the post chain: tone mapping, anti-aliasing and bloom are its own. Adding to it means a stage. Putting it into a pipeline you already have means taking
lib/sky/render/pipeline.tsapart. - Flying through clouds works, but this demo doesn't test it. The source was built for that. Close up, a cloud is blurry: its steps are 50 m at best.
- Clouds are one kind of weather. Fair, broken and overcast come from one noise. There's no storm front, no rain and no shelf cloud, and past about 75% cover it's a soft grey sheet.
- Time-lapse is honest, not pretty. The clouds fill in over 16 frames, so a fast cut, like a teleport, shows speckle for a quarter of a second. Slow moves are clean.
- Night's light is a choice. It's dark, with moonlit cloud edges and stars, and the ground is only just readable. A game played at night will want its own lights. The moon has no phases on the ground (
moonPhasemoves it, and its light stays that of a full moon). - Sun position is simple. Solar time and a circular orbit: right for a game, not an almanac.
- It hasn't run on a laptop. Everything was measured on one RTX 4090, shared with other work. Which card gets which level is an estimate.
- Pinned. three 0.186.1 and takram's packages with patches (
vendor/VENDOR.md). Upgrading three means redoing them.
How it's made
lib/sky/clouds/ is nagoya0's port of takram's clouds, in WGSL. Patch's changes, each marked in the code:
- Flat to the game. A clock instead of dates and geodetic positions (
ephemeris.ts), a cloud base you can move, and wind that can change without the clouds jumping. - From the ground, a cloud's base was navy and overcast was a flat blue shell. Clouds now get the sunlight that diffuses through them, as a two-stream model has it, and the sky's light on them follows how grey the day is. Cover stops at the point the noise fills in, so overcast keeps its structure.
- The harsh, sepia sunset the source could show was its colour grade: a straight contrast line that clipped everything below a linear 0.009 to black. It's an S-curve now.
- Exposure follows how fast light really goes, standing on the ground, in place of a straight ramp through twilight that left the ground black at sunset.
- Speckled cloud edges: each new pixel blends into the history rather than replacing it, which suits slow clouds and a still camera.
- Both shadows: the source had cloud shadows and no scene shadow map; the sun now has both.
- Dusk and night grades: AgX turns dim blues violet, and night's colour goes as it does for the eye.
- Night is dark: moonlight only catches a cloud's edges, and the exposure doesn't make up for a dark sky.
- The anti-aliasing's blend: as released it took 40% of every new frame, so a still camera shimmered. A still pixel now blends slowly (
vendor/VENDOR.md). - Loading doesn't freeze the page: shaders compile off Chrome's GPU process's main thread (
lib/sky/warm.ts).
@pmndrs/sky isn't wired in. takram's stars, moon and haze were already good enough, so it wouldn't have been clearly better.
Controls in the demo
| id | Label | What it does |
|---|---|---|
weather | Weather | Fair, Broken, Overcast, Sunset or Night: sets the next four, which you can then change |
time | Time of day | 0 to 24 h |
cover | Cloud cover | 0 to 100 % |
height | Cloud height | 500 to 3,500 m |
wind | Wind | 0 to 40 m/s: the clouds, the grass and the water |
bearing | Wind direction | For agents: 0 to 360° |
latitude | Latitude | For agents: 0 to 70° |
day | Day of the year | For agents: 1 to 365 |
moon | Moon phase | For agents: 0 to 100 %, 50 is full |
exposure | Exposure | For agents: 0.5 to 5 |
quality | Quality | For agents: Auto, High, Medium or Low. Baked comes by itself where Low can't hold, or from ?quality=baked |
reset | Reset | Back to the first sky and the first view |
Credits
- The clouds' port to WebGPU, the shadow maps, the night exposure and the blue noise: webgpu-skyscape by nagoya0, MIT, commit
322e9fe. - The clouds, their noise and weather textures, the atmosphere, the anti-aliasing and the lens flare: three-geospatial by Shota Matsuda and takram, MIT.
- The cascades: three-csm by vtHawk, MIT.
- The stars: the Bright Star Catalogue, 5th Revised Edition (Hoffleit and Warren, 1991; Yale University Observatory), through takram.
- The valley, the lake, the grass, the trees and the baked sky: Patch, MIT, all made in code.
Every part and its licence is in LICENCE-NOTES.md. Licences ship in dist/licenses/ with every build.
A separate block makes a stylised sky. This one stays realistic.