Spinning shapes
Five shapes described by signed-distance functions rather than geometry: two polygons (a triangle and a hexagon), two five-point stars and a circle, each on its own orbit and spin, blended over a dark background with a soft edge and a glow that falls off with distance. The fragment shader evaluates every shape for every pixel; there is no vertex data anywhere in the document.
; Spinning 2D shapes: a fullscreen triangle whose fragment shader draws five; signed-distance shapes (regular polygons, a five-point star, circles) that; orbit and spin over time, with soft edges and a glow. Aspect-corrected using; the pngine-inputs uniform.
(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); }
// Floor modulo. WGSL's `%` on floats truncates toward zero, keeping the // sign of the dividend (C fmod), so folding an angle from atan2 with it // is only right for positive angles; the negative half of every shape // came out as spikes. GLSL's mod is the floor kind; this is that. fn floorMod(x: f32, y: f32) -> f32 { return x - y * floor(x / y); }
// SDF for regular polygon fn sdPolygon(p: vec2f, r: f32, n: f32) -> f32 { let an = PI / n; let en = PI / n; let acs = vec2f(cos(an), sin(an)); let bn = floorMod(atan2(p.x, p.y), 2.0 * an) - an; let pp = length(p) * vec2f(cos(bn), abs(sin(bn))); return pp.x * acs.x + pp.y * acs.y - r; }
// SDF for star fn sdStar(p: vec2f, r: f32, n: u32, m: f32) -> f32 { let an = PI / f32(n); let en = PI / m; let acs = vec2f(cos(an), sin(an)); let ecs = vec2f(cos(en), sin(en)); let bn = floorMod(atan2(p.x, p.y), 2.0 * an) - an; var pp = length(p) * vec2f(cos(bn), abs(sin(bn))); pp -= r * acs; pp += ecs * clamp(-dot(pp, ecs), 0.0, r * acs.y / ecs.y); return length(pp) * sign(pp.x); }
@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;
let t = u.time; var color = vec3f(0.05, 0.05, 0.1);
// Draw multiple spinning shapes for (var i = 0; i < 5; i++) { let fi = f32(i); let angle = t * (0.3 + fi * 0.1) + fi * PI * 0.4; let orbitRadius = 0.3 + fi * 0.12; let center = vec2f(cos(angle * 0.7), sin(angle)) * orbitRadius;
// Rotate local coordinates let localAngle = t * (1.0 - fi * 0.15); let c = cos(localAngle); let s = sin(localAngle); let localP = p - center; let rotatedP = vec2f(localP.x * c - localP.y * s, localP.x * s + localP.y * c);
var d = 1000.0; let shapeType = i % 3; let size = 0.08 + fi * 0.015;
if (shapeType == 0) { d = sdPolygon(rotatedP, size, 3.0 + fi); // Polygon } else if (shapeType == 1) { d = sdStar(rotatedP, size, 5u, 2.5); // Star } else { d = length(rotatedP) - size; // Circle }
// Color based on index let hue = fract(fi * 0.2 + t * 0.05); let shapeColor = vec3f( 0.5 + 0.5 * cos(hue * 6.28), 0.5 + 0.5 * cos(hue * 6.28 + 2.09), 0.5 + 0.5 * cos(hue * 6.28 + 4.19) );
// Smooth edge with glow let edge = smoothstep(0.02, 0.0, d); let glow = 0.01 / (abs(d) + 0.01);
color = mix(color, shapeColor, edge); color += shapeColor * glow * 0.15; }
return vec4f(color, 1.0); } """)
(render-pipeline :name pipeline :layout auto (vertex :module shader :entry vs) (fragment :module shader :entry fs (target :format preferred-canvas-format)))
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeUniforms (write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(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.05 0.05 0.1 1] :load-op clear :store-op store) :pipeline pipeline :bind-groups [uniformsBindGroup] (draw :vertex-count 3))
(frame :name main :perform [writeUniforms mainPass])examples/samples/03_spinning_shapes.sjon in the pngine repository.
How it works
Section titled “How it works”The scaffold
Section titled “The scaffold”The SJON, the S-expression format pngine compiles (one form per WebGPU
resource or operation), is identical to
Gradient background:
one shader module, a (render-pipeline …) with :layout auto targeting the
canvas format, a 16-byte uniform buffer refilled every frame from
pngine-inputs (the built-in time/width/height/aspect source), its
bind group, a (render-pass …) drawing three vertices, and a (frame …).
I clear the pass to the same dark blue the shader starts from, so the two
agree at the edges. The five shapes exist only in the WGSL, WebGPU’s
shading language, evaluated per pixel in the fragment stage.
Aspect-corrected coordinates
Section titled “Aspect-corrected coordinates”fs maps the pixel position to p in [-1, 1] and multiplies p.x by
u.aspect. That is what keeps circles circular on the wide canvas above:
the horizontal axis is stretched by width/height, so a unit of p is the
same number of pixels in both directions. Resize the window and the shapes
keep their proportions.
Signed-distance shapes
Section titled “Signed-distance shapes”A signed-distance function returns, for a point, how far it is from a
shape’s edge: negative inside, positive outside. sdPolygon and sdStar
are the standard formulas for regular polygons and stars: fold the angle
from atan2 into one sector with a modulo, then measure against the
sector’s edge. The circle is just length(p) - r.
The fold is where a GLSL habit bites in WGSL. GLSL’s mod is a floor
modulo; WGSL’s % on floats truncates toward zero and keeps the sign of its
left operand (C’s fmod), so a negative angle lands in the wrong sector and
the negative half of every shape comes out as spikes. The shader therefore
defines floorMod(x, y) = x - y * floor(x / y) and folds with that. If you
port a distance field from a GLSL source, this is the line to check first.
An exercise: put WGSL’s % back in place of floorMod in the two fold
lines and recompile. The lower half of every polygon and star grows the
spikes this paragraph promised, and you will have reproduced the original
port bug on demand.
The loop for (var i = 0; i < 5; i++) places shape i on an orbit of
radius 0.3 + 0.12 i at its own angular rate, rotates the local frame by a
per-shape spin, and picks polygon / star / circle from i % 3.
Edge and glow
Section titled “Edge and glow”For each shape, smoothstep(0.02, 0.0, d) is 1 inside and fades to 0 over
a 0.02 band outside; mix(color, shapeColor, edge) paints it. The glow is
0.01 / (abs(d) + 0.01), large near the edge and decaying with distance,
added at 15% weight. Colours come from a hue per shape through the
three-cosines palette (0.5 + 0.5 cos(hue 2π + phase)), drifting slowly
with time.
In the specifications
Section titled “In the specifications”| What the sample uses | WebGPU | WGSL |
|---|---|---|
| Fullscreen triangle and one draw | draw(), rasterization |
vertex_index, position |
| Uniforms (time, size, aspect) | writeBuffer(), GPUBufferUsage.UNIFORM |
uniform address space |
| Loops and branches in the fragment shader | for statement, if statement, user-defined functions |
|
| The distance-field math | atan2, % on floats (why floorMod exists), floor, length, clamp, dot, sign |
|
| Edge, glow and palette | smoothstep, mix, abs, fract, cos |
Related
Section titled “Related”- Sprite rendering draws the same kind of distance-field shapes, but one per instanced quad instead of all in one fullscreen pass.
- Resolution-adaptive rendering takes the aspect handling further.
- Forms:
(shader-module …),(render-pass …),(queue …).