Procedural noise
Four noise functions side by side, all computed in the fragment shader from hash functions and scrolled slowly with time. Top left: fractal Brownian motion (five octaves of gradient noise). Top right: a single octave of gradient noise. Bottom left: Worley (cellular) noise. Bottom right: a marble pattern that warps stripes with fbm and tints them with Worley. A thin grey border separates the quadrants.
; Procedural noise: a fullscreen triangle split into four quadrants, each; showing a different noise: fbm, gradient noise, Worley (cellular) noise, and; a turbulent marble mixing fbm with Worley. All of it is computed in the; fragment shader from hash functions; a pngine-inputs uniform scrolls it over; time.
(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); }
// Hash functions fn hash2(p: vec2f) -> f32 { var p3 = fract(vec3f(p.x, p.y, p.x) * 0.13); p3 += dot(p3, p3.yzx + 3.333); return fract((p3.x + p3.y) * p3.z); }
fn hash2v(p: vec2f) -> vec2f { let k = vec2f(0.3183099, 0.3678794); var pp = p * k + k.yx; return -1.0 + 2.0 * fract(16.0 * k * fract(pp.x * pp.y * (pp.x + pp.y))); }
// Value noise fn valueNoise(p: vec2f) -> f32 { let i = floor(p); let f = fract(p); let u = f * f * (3.0 - 2.0 * f);
return mix( mix(hash2(i + vec2f(0.0, 0.0)), hash2(i + vec2f(1.0, 0.0)), u.x), mix(hash2(i + vec2f(0.0, 1.0)), hash2(i + vec2f(1.0, 1.0)), u.x), u.y ); }
// Gradient noise (Perlin-like) fn gradientNoise(p: vec2f) -> f32 { let i = floor(p); let f = fract(p); let u = f * f * (3.0 - 2.0 * f);
return mix( mix(dot(hash2v(i + vec2f(0.0, 0.0)), f - vec2f(0.0, 0.0)), dot(hash2v(i + vec2f(1.0, 0.0)), f - vec2f(1.0, 0.0)), u.x), mix(dot(hash2v(i + vec2f(0.0, 1.0)), f - vec2f(0.0, 1.0)), dot(hash2v(i + vec2f(1.0, 1.0)), f - vec2f(1.0, 1.0)), u.x), u.y ) * 0.5 + 0.5; }
// Worley (cellular) noise fn worleyNoise(p: vec2f) -> f32 { let n = floor(p); let f = fract(p);
var minDist = 1.0;
for (var j = -1; j <= 1; j++) { for (var i = -1; i <= 1; i++) { let g = vec2f(f32(i), f32(j)); let cellHash = n + g; let o = vec2f(hash2(cellHash), hash2(cellHash + vec2f(7.0, 13.0))); let r = g + o - f; minDist = min(minDist, dot(r, r)); } }
return sqrt(minDist); }
// FBM fn fbm(p: vec2f, octaves: i32) -> f32 { var value = 0.0; var amplitude = 0.5; var frequency = 1.0; var pp = p;
for (var i = 0; i < 6; i++) { if (i >= octaves) { break; } value += amplitude * gradientNoise(pp * frequency); amplitude *= 0.5; frequency *= 2.0; }
return value; }
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f { let uv = vec2f(pos.x / u.width, pos.y / u.height); let t = u.time * 0.2;
// Divide screen into 4 quadrants showing different noise types let quadX = select(0, 1, uv.x > 0.5); let quadY = select(0, 1, uv.y > 0.5); let quad = quadY * 2 + quadX;
let localUV = fract(uv * 2.0) * 4.0 + vec2f(t * 0.5, t * 0.3);
var value = 0.0; var color = vec3f(0.0);
if (quad == 0) { // Value noise FBM value = fbm(localUV, 5); color = vec3f(value); } else if (quad == 1) { // Gradient noise value = gradientNoise(localUV * 2.0); color = vec3f(value * 0.8, value * 0.9, value); } else if (quad == 2) { // Worley noise value = worleyNoise(localUV * 1.5); color = vec3f(value * 0.9, value * 0.7, value * 0.5); } else { // Combined: turbulent marble let marble = sin((uv.x * 20.0 + fbm(localUV, 5) * 8.0) * PI); let worley = worleyNoise(localUV * 2.0);
color = vec3f( marble * 0.5 + 0.5, marble * 0.4 + 0.3 + worley * 0.3, marble * 0.3 + 0.2 + (1.0 - worley) * 0.4 ); }
// Add subtle borders between quadrants let borderX = smoothstep(0.498, 0.5, abs(uv.x - 0.5)); let borderY = smoothstep(0.498, 0.5, abs(uv.y - 0.5)); let border = max(borderX, borderY); color = mix(color, vec3f(0.3), border);
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)))
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeUniforms (write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(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/23_procedural_noise.sjon in the pngine repository.
How it works
Section titled “How it works”The scaffold
Section titled “The scaffold”The fullscreen scaffold from Gradient background,
in SJON, the S-expression format pngine compiles, each form one WebGPU
resource or operation: one shader module, a (render-pipeline …) with
:layout auto targeting the canvas format, a 16-byte uniform buffer
refilled every frame from pngine-inputs (the built-in
time/width/height/aspect source), a bind group, a (render-pass …)
drawing three vertices, and a (frame …). All four noises are WGSL,
WebGPU’s shading language, built from nothing but hash functions.
The noises
Section titled “The noises”hash2andhash2vare the primitives: a scalar in[0, 1]and a 2D vector in[-1, 1]from a position, viafractof products of large constants. They are deterministic, so the picture is stable from frame to frame and only the scroll moves it.valueNoise(declared, not shown in any quadrant) interpolates corner hashes with a smoothstep weight.gradientNoiseis Perlin-style: at each cell corner, dot the corner’s random gradient with the offset to the point, then interpolate; the result is remapped from[-1, 1]to[0, 1].worleyNoisescatters one feature point per cell (hash2twice), scans the 3×3 neighbourhood, and returns the distance to the nearest one: small near the points, larger in between, which reads as cells.fbmsums octaves ofgradientNoise, halving the amplitude and doubling the frequency each time; the octave count is a parameter, and I bound the loop at six with abreakwhen the count is reached.
An exercise: valueNoise is declared and never called. Swap it in for
gradientNoise inside fbm and compare the top-left quadrant: value noise
carries the grid it is built on, and gradient noise is the cure for exactly
that.
The quadrants
Section titled “The quadrants”fs decides the quadrant with select on uv > 0.5, and rebuilds a local
uv per quadrant with fract(uv * 2) * 4, offset by time so each panel
scrolls. The marble is sin((uv.x · 20 + fbm · 8) π): straight stripes
displaced by noise, coloured with a Worley term in the green and blue
channels. Borders come from smoothstep(0.498, 0.5, |uv - 0.5|) on each
axis, mixed to grey.
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 |
| Hashing and interpolation | fract, floor, dot, mix, swizzles (p3.yzx, k.yx) |
|
| Cellular search | for statement, min, sqrt, break statement |
|
| Quadrant selection and borders | select, smoothstep, abs, sin |
Related
Section titled “Related”- Procedural normal mapping and Procedural skybox use the same value-noise fbm to shade a surface and to shape clouds.
- Forms:
(shader-module …),(render-pass …),(queue …).