Skip to content

Procedural normal mapping

Download PNG

Normal mapping without a normal map: the fragment shader builds a height field from fractal noise, differentiates it to get a per-pixel normal, and lights the result with diffuse and specular terms from a light that circles overhead. Despite the name there is no texture and no sampler in the document; everything is computed in the shader. The disc drifts slowly, so the bumps flow past the light.

examples/samples/12_normal_mapping.sjon
; Procedural normal mapping: a lit, bumpy disc painted by a fullscreen
; triangle: the fragment shader derives a normal from finite differences of an
; fbm height field, then applies Phong diffuse + specular under an orbiting
; light. Despite the name there is no normal-map or diffuse texture and no
; sampler; everything is computed in-shader. 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);
}
// Hash function for noise
fn hash(p: vec2f) -> f32 {
return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453);
}
fn noise(p: vec2f) -> f32 {
let i = floor(p);
let f = fract(p);
let u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i + vec2f(0.0, 0.0)), hash(i + vec2f(1.0, 0.0)), u.x),
mix(hash(i + vec2f(0.0, 1.0)), hash(i + vec2f(1.0, 1.0)), u.x),
u.y
);
}
fn fbm(p: vec2f) -> f32 {
var value = 0.0;
var amplitude = 0.5;
var freq = 1.0;
for (var i = 0u; i < 5u; i++) {
value += amplitude * noise(p * freq);
amplitude *= 0.5;
freq *= 2.0;
}
return value;
}
// Get normal from height map gradient
fn getNormal(uv: vec2f) -> vec3f {
let eps = 0.005;
let h = fbm(uv * 8.0);
let hL = fbm((uv + vec2f(-eps, 0.0)) * 8.0);
let hR = fbm((uv + vec2f(eps, 0.0)) * 8.0);
let hD = fbm((uv + vec2f(0.0, -eps)) * 8.0);
let hU = fbm((uv + vec2f(0.0, eps)) * 8.0);
return normalize(vec3f(hL - hR, hD - hU, 0.1));
}
@fragment
fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let uv = vec2f(pos.x / u.width, pos.y / u.height);
var p = (uv - 0.5) * 2.0;
p.x *= u.aspect;
// Check if inside the bumped surface area
if (length(p) > 0.8) {
return vec4f(0.1, 0.1, 0.15, 1.0);
}
// Surface UV (animate slowly)
let surfUV = uv + vec2f(u.time * 0.02, u.time * 0.01);
// Get procedural normal
let normal = getNormal(surfUV);
// Transform normal from tangent space to view space
// Base surface normal is (0, 0, 1), tangent is (1, 0, 0), bitangent is (0, 1, 0)
let worldNormal = normalize(vec3f(normal.x, normal.y, 1.0 - abs(normal.x) - abs(normal.y)));
// Animated light position
let lightAngle = u.time * 0.5;
let lightPos = vec3f(cos(lightAngle) * 0.5, sin(lightAngle) * 0.5, 1.0);
let lightDir = normalize(lightPos);
// View direction (from camera looking at surface)
let viewDir = vec3f(0.0, 0.0, 1.0);
// Phong lighting
let ambient = 0.1;
let diffuse = max(dot(worldNormal, lightDir), 0.0);
// Specular
let halfDir = normalize(lightDir + viewDir);
let specular = pow(max(dot(worldNormal, halfDir), 0.0), 64.0);
// Base color
let height = fbm(surfUV * 8.0);
let baseColor = mix(
vec3f(0.4, 0.3, 0.2),
vec3f(0.6, 0.5, 0.4),
height
);
let finalColor = baseColor * (ambient + diffuse * 0.8) + vec3f(1.0, 0.9, 0.8) * specular * 0.5;
return vec4f(finalColor, 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.1 0.1 0.15 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, in a slightly different order: the 16-byte uniform buffer and its (queue …) writer (pngine-inputs, the built-in time/width/height/aspect source) come first, then the shader, the (render-pipeline …) with :layout auto, the bind group, a (render-pass …) drawing three vertices, and the (frame …). Order between top-level forms does not matter to the compiler; names are resolved across the whole document.

hash is the classic fract(sin(dot(p, k)) * 43758.5453); noise interpolates hashes at the four corners of the cell around p with a smoothstep-shaped weight (f * f * (3 - 2f)), which is value noise; fbm sums five octaves, halving the amplitude and doubling the frequency each time. The height field is fbm(surfUV * 8), with surfUV drifting at (0.02, 0.01) per second so the surface slides under the light.

getNormal samples the height at ±eps in x and y and forms normalize(vec3f(hL - hR, hD - hU, 0.1)): a central-difference gradient, with the small z setting how steep the bumps look. fs then re-normalises it around the surface normal (0, 0, 1); since the disc faces the camera, tangent space and view space coincide and no TBN matrix is needed.

The light orbits at (0.5 cos, 0.5 sin, 1), so it circles above the disc. diffuse = max(dot(N, L), 0) and, with the view direction fixed at (0, 0, 1), specular = pow(max(dot(N, H), 0), 64) where H is the half vector: Blinn-Phong. The base colour is a mix of two browns by height, and the final colour is base * (ambient + 0.8 diffuse) + warm white * 0.5 specular. Pixels outside the radius-0.8 disc return the background early; the disc stays round because p.x is scaled by u.aspect.

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
Noise and octaves fract, floor, mix, for statement
Normal and lighting normalize, dot, max, pow, length
Early return for the background return statement