Skip to content

Simple lighting

Download PNG

One cube, one light, the classic lighting model. The cube’s vertices carry a position, a normal and a per-face colour from pngine’s cube generator; the vertex shader rotates position and normal together and hands both to the fragment stage, where a light circling the cube produces the ambient, diffuse and specular terms of Blinn-Phong. A depth texture and back-face culling keep the near faces on top.

examples/samples/11_simple_lighting.sjon
; Simple lighting: a rotating cube shaded with Blinn-Phong (ambient + diffuse
; + specular) under an orbiting light, depth-tested with back-face culling.
; Exercises the `cube` shape generator with a three-attribute interleaved
; layout (position3 normal3 color3, array-stride 36), depth-stencil pipeline
; state with a depth24plus texture sized `canvas`, and a single colour+depth
; render pass.
;
; `uniforms` is allocated at 128 bytes; only the first 16 are written, via
; pngine-inputs.
(data :name cubeVertices (cube :format [position3 normal3 color3]))
(buffer :name vertexBuffer :usage [vertex]
:data cubeVertices)
(buffer :name uniforms :size 128 :usage [uniform copy-dst])
(queue :name writeUniforms
(write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(texture :name depthTexture :format depth24plus :size canvas :usage [render-attachment])
(shader-module :name shader :code """
struct Uniforms {
time: f32,
width: f32,
height: f32,
aspect: f32,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VertexInput {
@location(0) pos: vec3f,
@location(1) normal: vec3f,
@location(2) color: vec3f,
}
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) worldPos: vec3f,
@location(1) normal: vec3f,
@location(2) color: vec3f,
}
const PI: f32 = 3.14159265359;
fn rotateY(p: vec3f, angle: f32) -> vec3f {
let c = cos(angle);
let s = sin(angle);
return vec3f(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
}
fn rotateX(p: vec3f, angle: f32) -> vec3f {
let c = cos(angle);
let s = sin(angle);
return vec3f(p.x, p.y * c - p.z * s, p.y * s + p.z * c);
}
@vertex
fn vs(in: VertexInput) -> VertexOutput {
let t = u.time;
// Rotate cube
var pos = in.pos * 0.4;
var normal = in.normal;
pos = rotateY(pos, t * 0.5);
pos = rotateX(pos, t * 0.3);
normal = rotateY(normal, t * 0.5);
normal = rotateX(normal, t * 0.3);
// Simple perspective
let z = pos.z + 2.0;
let projX = pos.x / z / u.aspect;
let projY = pos.y / z;
var out: VertexOutput;
out.pos = vec4f(projX, projY, pos.z * 0.1 + 0.5, 1.0);
out.worldPos = pos;
out.normal = normal;
out.color = in.color;
return out;
}
@fragment
fn fs(in: VertexOutput) -> @location(0) vec4f {
// Light direction (animated)
let lightAngle = u.time * 0.7;
let lightDir = normalize(vec3f(sin(lightAngle), 0.7, cos(lightAngle)));
// View direction (from camera at z=-2)
let viewDir = normalize(vec3f(0.0, 0.0, -1.0) - in.worldPos);
// Normal
let N = normalize(in.normal);
// Phong lighting
let ambient = 0.15;
let diffuse = max(dot(N, lightDir), 0.0);
// Specular (Blinn-Phong)
let halfDir = normalize(lightDir + viewDir);
let specular = pow(max(dot(N, halfDir), 0.0), 32.0);
let lighting = ambient + diffuse * 0.7 + specular * 0.5;
let color = in.color * lighting;
return vec4f(color, 1.0);
}
""")
(render-pipeline :name pipeline
:layout auto
(vertex :module shader :entry vs
(vertex-buffer :array-stride 36
(attribute :shader-location 0 :offset 0 :format float32x3)
(attribute :shader-location 1 :offset 12 :format float32x3)
(attribute :shader-location 2 :offset 24 :format float32x3)))
(fragment :module shader :entry fs
(target :format preferred-canvas-format))
(primitive :cull-mode back)
(depth-stencil :format depth24plus :depth-write-enabled true :depth-compare less))
(bind-group :name uniformsBindGroup :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)
(depth-stencil-attachment :view depthTexture :depth-clear-value 1.0 :depth-load-op clear :depth-store-op store)
:pipeline pipeline
:vertex-buffers [vertexBuffer]
:bind-groups [uniformsBindGroup]
(draw :vertex-count 36))
(frame :name main :perform [writeUniforms mainPass])

The mesh comes from a generator invoked in the document. In SJON, the S-expression format pngine compiles (each form one WebGPU resource or operation), (data :name cubeVertices (cube :format [position3 normal3 color3])) generates 36 vertices with three vec3f each: 36 bytes per vertex, which is the pipeline’s :array-stride 36 with attributes at offsets 0, 12 and 24 into @location(0), @location(1) and @location(2). The shader gathers them in a VertexInput struct. vertexBuffer is filled at creation from the generated data.

I allocate uniforms at 128 bytes and write only the first 16, through the (queue …) form from pngine-inputs (the built-in time/width/height/aspect source): the extra room is harmless and the shader’s Uniforms struct only declares those four fields.

(texture :name depthTexture :format depth24plus :size canvas :usage [render-attachment]) is a depth buffer that resizes with the canvas. The pipeline enables it with (depth-stencil :format depth24plus :depth-write-enabled true :depth-compare less), (primitive :cull-mode back) drops the three faces pointing away, and the pass clears the depth attachment to 1.0 alongside the colour clear. vs writes pos.z * 0.1 + 0.5 into position.z, keeping the depth in [0, 1] with nearer vertices smaller.

vs (WGSL, WebGPU’s shading language) scales the cube to 0.4, rotates it about y and x with time, and applies the same rotations to the normal (valid because they are pure rotations). It outputs the rotated position as worldPos for the view vector, and projects with a manual perspective divide (z + 2, x / z / aspect).

fs builds the light direction from an angle that advances with time (sin, 0.7, cos: a light circling above the cube), the view direction from a camera point on the negative z axis towards the fragment, and normalises the interpolated normal. Then:

  • diffuse = max(dot(N, L), 0), Lambert’s cosine law;
  • specular = pow(max(dot(N, H), 0), 32) with H = normalize(L + V), the Blinn half-vector highlight, exponent 32 for a fairly tight spot;
  • lighting = 0.15 + 0.7 · diffuse + 0.5 · specular, multiplied into the face colour.

Because the light moves and the cube spins, each face passes through shadow, full light and the highlight in turn.

An exercise: lower the specular exponent from 32 to 4 and recompile. The highlight spreads from a tight spot into a broad sheen and the cube reads as satin rather than plastic; the exponent is the whole difference.

What the sample uses WebGPU WGSL
Depth testing and the depth texture depth/stencil state, depthWriteEnabled, "less", depth formats, GPURenderPassDepthStencilAttachment
Back-face culling cullMode, primitive assembly
Interleaved vertex attributes GPUVertexBufferLayout, GPUVertexAttribute @location inputs, structure types
Varyings between stages rasterization interpolation, @location outputs
The lighting math normalize, dot, max, pow, sin / cos