Skip to content

Spinning shapes

Download PNG

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.

examples/samples/03_spinning_shapes.sjon
; 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])

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.

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.

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.

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.

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