Spinning shapes
Five shapes described by signed-distance functions rather than geometry:
i % 3 over the five picks a triangle and a hexagon, two five-point stars and
one 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 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.
The scaffold
Section titled “The scaffold”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 …).
The pass clears to the same dark blue the shader starts from, so the two
agree at the edges.
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.
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 …).