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”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.
Three attributes per vertex
Section titled “Three attributes per vertex”(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.
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 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.
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 …).