Procedural normal mapping
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.
; 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])examples/samples/12_normal_mapping.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,
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.
Height from noise
Section titled “Height from noise”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.
Normal from height
Section titled “Normal from height”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.
Lighting
Section titled “Lighting”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.
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 |
| Noise and octaves | fract, floor, mix, for statement |
|
| Normal and lighting | normalize, dot, max, pow, length |
|
| Early return for the background | return statement |
Related
Section titled “Related”- Simple lighting applies the same Blinn-Phong model to real geometry with interpolated normals.
- Procedural noise shows the noise functions side by side.
- Forms:
(shader-module …),(render-pass …),(queue …).