Procedural skybox
A sky with no texture in it. The fragment shader turns each pixel into a direction on a sphere, rotates that direction slowly, and builds the colour from a zenith-to-horizon gradient, a sun with a glow, clouds from 3D fractal noise, a sunset tint where the view grazes the horizon towards the sun, and a final tone-map. Despite the name there is no cubemap and no sampler; the whole sky is arithmetic.
; Procedural skybox: an animated sky (gradient, sun with glow, fbm noise; clouds, sunset tint near the horizon, tone-mapped) painted by a fullscreen; triangle whose fragment shader turns screen UV into a slowly rotating; spherical view direction. Despite the name there is no cubemap and no; texture; the sky is entirely procedural. Driven by a pngine-inputs uniform.
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeUniforms (write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(shader-module :name shader :code """ struct Uniforms { time: f32, width: f32, height: f32, aspect: f32, } @group(0) @binding(0) var<uniform> u: Uniforms;
const PI: f32 = 3.14159265359;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { let x = f32(i & 1u) * 4.0 - 1.0; let y = f32((i >> 1u) & 1u) * 4.0 - 1.0; return vec4f(x, y, 0.0, 1.0); }
fn hash(p: vec3f) -> f32 { var p3 = fract(p * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
fn noise(p: vec3f) -> f32 { let i = floor(p); let f = fract(p); let u = f * f * (3.0 - 2.0 * f);
return mix( mix( mix(hash(i + vec3f(0.0, 0.0, 0.0)), hash(i + vec3f(1.0, 0.0, 0.0)), u.x), mix(hash(i + vec3f(0.0, 1.0, 0.0)), hash(i + vec3f(1.0, 1.0, 0.0)), u.x), u.y ), mix( mix(hash(i + vec3f(0.0, 0.0, 1.0)), hash(i + vec3f(1.0, 0.0, 1.0)), u.x), mix(hash(i + vec3f(0.0, 1.0, 1.0)), hash(i + vec3f(1.0, 1.0, 1.0)), u.x), u.y ), u.z ); }
fn fbm(p: vec3f) -> f32 { var value = 0.0; var amplitude = 0.5; var freq = 1.0; var pos = p; for (var i = 0u; i < 4u; i++) { value += amplitude * noise(pos * freq); amplitude *= 0.5; freq *= 2.0; } return value; }
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f { let uv = vec2f(pos.x / u.width, 1.0 - pos.y / u.height);
// Create ray direction (simple spherical projection) let phi = uv.x * PI * 2.0; let theta = uv.y * PI; let dir = vec3f( sin(theta) * cos(phi), cos(theta), sin(theta) * sin(phi) );
// Animated rotation let angle = u.time * 0.05; let c = cos(angle); let s = sin(angle); let rotDir = vec3f( dir.x * c - dir.z * s, dir.y, dir.x * s + dir.z * c );
// Sun position let sunAngle = u.time * 0.1; let sunDir = normalize(vec3f(cos(sunAngle) * 0.5, 0.4 + sin(sunAngle * 0.3) * 0.2, sin(sunAngle) * 0.5));
// Sky gradient let horizon = 1.0 - abs(rotDir.y); let skyColor = mix( vec3f(0.2, 0.4, 0.8), // Zenith blue vec3f(0.7, 0.8, 0.95), // Horizon light pow(horizon, 0.5) );
// Sun let sunDist = length(rotDir - sunDir); let sun = exp(-sunDist * 8.0) * 1.5; let sunGlow = exp(-sunDist * 2.0) * 0.5;
// Clouds let cloudPos = rotDir * 2.0 + vec3f(u.time * 0.02, 0.0, u.time * 0.01); let cloudDensity = fbm(cloudPos * 3.0); let cloudShape = smoothstep(0.4, 0.7, cloudDensity);
// Cloud color (lit by sun) let cloudSunDot = max(dot(rotDir, sunDir), 0.0); let cloudColor = mix( vec3f(0.8, 0.85, 0.9), vec3f(1.0, 0.95, 0.9), cloudSunDot );
// Atmosphere scattering (sunset colors near horizon) let sunset = pow(horizon, 2.0) * max(dot(normalize(vec3f(rotDir.x, 0.0, rotDir.z)), sunDir), 0.0); let sunsetColor = vec3f(1.0, 0.5, 0.2) * sunset * 0.5;
// Combine var color = skyColor; color += sunsetColor; color = mix(color, cloudColor, cloudShape * 0.8); color += vec3f(1.0, 0.95, 0.8) * sun; color += vec3f(1.0, 0.8, 0.5) * sunGlow;
// Tone mapping color = color / (color + vec3f(1.0));
return vec4f(color, 1.0); } """)
(render-pipeline :name pipeline :layout auto (vertex :module shader :entry vs) (fragment :module shader :entry fs (target :format preferred-canvas-format)))
(bind-group :name bindings :layout pipeline :group 0 (entry :binding 0 :buffer uniforms))
(render-pass :name mainPass (color-attachment :view context-current-texture :clear-value [0 0 0 1] :load-op clear :store-op store) :pipeline pipeline :bind-groups [bindings] (draw :vertex-count 3))
(frame :name main :perform [writeUniforms mainPass])examples/samples/14_skybox.sjon in the pngine repository.
How it works
Section titled “How it works”The scaffold
Section titled “The scaffold”The SJON (the S-expression format pngine compiles, one form per WebGPU
resource or operation) is the fullscreen scaffold from
Gradient background:
a 16-byte uniform buffer refilled every frame from pngine-inputs (the
built-in time/width/height/aspect source), one shader module, a
(render-pipeline …) with :layout auto targeting the canvas format, a
bind group, a (render-pass …) drawing three vertices, and a (frame …).
Every layer of the sky is WGSL, WebGPU’s shading language, in one fragment
function.
From pixel to sky direction
Section titled “From pixel to sky direction”fs maps the pixel to uv with y flipped so 0 is the bottom, then to
spherical angles: phi = uv.x · 2π around, theta = uv.y · π from the
zenith. dir is the unit vector for those angles, so the canvas is an
equirectangular view of the whole sphere. I turn the view slowly, a
rotation about y by time * 0.05, so the whole sky drifts past.
Layers of sky
Section titled “Layers of sky”- Gradient:
horizon = 1 - |dir.y|is 0 at the poles and 1 at the horizon;mix(zenith blue, horizon light, sqrt(horizon))is the base. - Sun:
sunDircircles slowly and bobs;sunDistis the chord to it, and two exponentialsexp(-8 d)andexp(-2 d)give the disc and the glow, added in warm whites. - Clouds:
fbmis four octaves of 3D value noise (noiseinterpolates eight corner hashes), sampled atdir * 2scrolled by time and scaled by 3 insidefbm.smoothstep(0.4, 0.7, density)cuts the cloud shape, and the cloud colour is lit bydot(dir, sunDir). - Sunset:
horizon²times how much the horizontal part ofdirpoints at the sun, in orange. - Tone map:
color / (color + 1)(Reinhard) compresses the bright sun back into range.
An exercise: delete the tone-map line and look at the sun. Its disc and glow overshoot 1 and clip to a hard-edged white blot; Reinhard was quietly folding that overshoot back into range.
In the specifications
Section titled “In the specifications”| What the sample uses | WebGPU | WGSL |
|---|---|---|
| Fullscreen triangle and one draw | draw() |
vertex_index, position |
| Uniform buffer, bind group | writeBuffer(), bind group creation |
uniform address space |
| Direction and rotation | sin, cos, normalize, vector types and swizzles (p3.yzx) |
|
| Sun, clouds, sunset | exp, length, smoothstep, mix, pow, dot, for statement |
|
| Tone mapping | abs, component-wise vector arithmetic |
Related
Section titled “Related”- Procedural noise shows the noise functions in isolation; Procedural normal mapping uses the 2D version.
- Forms:
(shader-module …),(render-pass …),(queue …).