Skip to content

Procedural noise

Download PNG

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.

examples/samples/23_procedural_noise.sjon
; 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])

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 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 …).

  • hash2 and hash2v are the primitives: a scalar in [0, 1] and a 2D vector in [-1, 1] from a position, via fract of 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.
  • gradientNoise is 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].
  • worleyNoise scatters one feature point per cell (hash2 twice), scans the 3×3 neighbourhood, and returns the distance to the nearest one: small near the points, larger in between, which reads as cells.
  • fbm sums octaves of gradientNoise, halving the amplitude and doubling the frequency each time; the octave count is a parameter, with the loop bounded at six and break when it is reached.

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.

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