Color cycling
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.
; 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])examples/samples/27_color_cycling.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”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 …).
HSV to RGB
Section titled “HSV to RGB”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.
The four patterns
Section titled “The four patterns”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.
In the specifications
Section titled “In the specifications”| 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 |
Related
Section titled “Related”- Shader art (plasma) is the first pattern on its own; Scene transitions also switches between fragment-shader scenes, but blends across the switch.
- Fluid simulation reuses
hsv2rgbto colour velocity direction. - Forms:
(shader-module …),(render-pass …),(queue …).