Skip to content

Color cycling

Download PNG

Palette animation, the way 8-bit games did it, in a fragment shader: each pattern computes a scalar index per pixel, and the colour is that index plus time turned into a hue. Move the hue and the whole picture flows without any geometry moving. The shader switches pattern about every seven seconds: plasma, concentric rings, diagonal stripes, a radial rainbow, then round again.

examples/samples/27_color_cycling.sjon
; Color cycling: a fullscreen triangle cycling every few seconds through four
; palette-animated patterns (plasma, concentric rings, diagonal stripes, radial
; rainbow), each mapping a pattern index through a time-shifted HSV hue. Driven
; by a 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;
fn hsv2rgb(h: f32, s: f32, v: f32) -> vec3f {
let c = v * s;
let x = c * (1.0 - abs(fract(h * 6.0) * 2.0 - 1.0));
let m = v - c;
var rgb = vec3f(0.0);
let hi = u32(h * 6.0) % 6u;
if (hi == 0u) { rgb = vec3f(c, x, 0.0); }
else if (hi == 1u) { rgb = vec3f(x, c, 0.0); }
else if (hi == 2u) { rgb = vec3f(0.0, c, x); }
else if (hi == 3u) { rgb = vec3f(0.0, x, c); }
else if (hi == 4u) { rgb = vec3f(x, 0.0, c); }
else { rgb = vec3f(c, 0.0, x); }
return rgb + m;
}
@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);
}
@fragment
fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let uv = vec2f(pos.x / u.width, pos.y / u.height);
let t = u.time;
// Different patterns based on time
let mode = u32(t * 0.15) % 4u;
var color = vec3f(0.0);
if (mode == 0u) {
// Plasma pattern
let p = uv * 8.0;
let v1 = sin(p.x + t);
let v2 = sin(10.0 * (p.x * sin(t * 0.5) + p.y * cos(t * 0.3)));
let cx = p.x + 0.5 * sin(t * 0.3);
let cy = p.y + 0.5 * cos(t * 0.4);
let v3 = sin(sqrt(100.0 * (cx * cx + cy * cy) + 1.0) + t);
let idx = (v1 + v2 + v3) / 3.0 * 0.5 + 0.5;
let hue = fract(idx + t * 0.1);
color = hsv2rgb(hue, 0.8, 0.9);
} else if (mode == 1u) {
// Concentric rings
let dist = length(uv - 0.5);
let idx = fract(dist * 10.0 - t * 0.5);
let hue = fract(idx + t * 0.05);
color = hsv2rgb(hue, 0.7, 0.85);
} else if (mode == 2u) {
// Diagonal stripes
let idx = fract((uv.x + uv.y) * 8.0 - t * 0.3);
let hue = fract(idx + t * 0.08);
color = hsv2rgb(hue, 0.75, 0.9);
} else {
// Radial rainbow
let centered = uv - 0.5;
let angle = atan2(centered.y, centered.x) / (2.0 * PI) + 0.5;
let hue = fract(angle + t * 0.15);
let dist = length(centered) * 2.0;
color = hsv2rgb(hue, 0.9 - dist * 0.3, 0.95);
}
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 0 0 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.

The fullscreen scaffold from 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), a bind group, a (render-pass …) drawing three vertices, and a (frame …).

hsv2rgb is the textbook conversion: chroma c = v · s, the secondary component x from the hue’s position within its sextant, and a six-way if / else if chain choosing which channels get c, x and 0 before adding m = v - c to all three. Hue is in [0, 1], so fract keeps every shifted hue in range.

mode = u32(t * 0.15) % 4u picks the pattern from time. Each computes an idx in [0, 1], then hue = fract(idx + t · rate):

  • Plasma: three sines over uv * 8, one of them radial from a moving centre, averaged.
  • Rings: fract(dist · 10 - 0.5 t), distance from the centre, so the rings expand outward.
  • Stripes: fract((uv.x + uv.y) · 8 - 0.3 t), drifting diagonally.
  • Radial rainbow: the angle around the centre from atan2, with saturation falling off with distance.

Because the hue offset is added after the pattern index, the pattern is static in shape and only its colours cycle: exactly what palette rotation did on hardware with a colour lookup table.

What the sample uses WebGPU WGSL
Fullscreen triangle and one draw draw() vertex_index, position
Uniform buffer, bind group writeBuffer(), bind group creation uniform address space
Choosing the pattern if statement, integer %, u32() conversion
The patterns sin, sqrt, length, atan2, fract
HSV conversion abs, user-defined functions