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. I switch the 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 fullscreen scaffold from Gradient background, written as SJON, the S-expression format pngine compiles, one form per WebGPU resource or operation: 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 …). The patterns and the palette are WGSL, WebGPU’s shading language, in the fragment stage.

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.

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

An exercise: slow the carousel down by lowering the 0.15 in mode = u32(t * 0.15) % 4u. The patterns still arrive in the same order at any speed, because the mode is a pure function of time, not a state that advances.

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