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 document is SJON, the S-expression format pngine compiles: each form is
one WebGPU resource or operation, and the shader text inside
(shader-module …) is plain WGSL, WebGPU’s shading language.
The scaffold
Section titled “The scaffold”The fullscreen scaffold from Gradient background:
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 …).
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, with the loop bounded at six andbreakwhen it is reached.
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 …).