Simple lighting
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.
; 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])examples/samples/11_simple_lighting.sjon in the pngine repository.
How it works
Section titled “How it works”Three attributes per vertex
Section titled “Three attributes per vertex”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.
Depth and culling
Section titled “Depth and culling”(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.
Blinn-Phong
Section titled “Blinn-Phong”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)withH = 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.
In the specifications
Section titled “In the specifications”| 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 |
Related
Section titled “Related”- Multiple objects instances the same generated cube eight times with Lambert shading; Procedural normal mapping applies Blinn-Phong to a normal computed from noise.
- Upstream: the WebGPU Samples rotatingCube sample is the unlit version with matrices from JavaScript.
- Forms:
(data …)(shape generators),(texture …),(render-pipeline …),(render-pass …).