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

(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.

uniforms is allocated at 128 bytes but only the first 16 are written, by 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 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.

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