Skip to content

Spinning shapes

Download PNG

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.

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 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.

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.

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.

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