Skip to content

Gradient background

Download PNG

The smallest animated program in the series, and the scaffold every other fullscreen sample builds on: one shader module, one pipeline, one 16-byte uniform buffer, one pass that draws three vertices. The fragment shader turns the pixel’s position into a rotating gradient and pushes it through three sines a third of a turn apart, so the colours cycle without ever repeating a frame exactly.

examples/samples/01_gradient_background.sjon
; Gradient background: a fullscreen triangle whose fragment shader paints a
; slowly rotating colour gradient, its three channels phase-shifted sines that
; drift with time. Driven by a single 16-byte pngine-inputs uniform
; (time/width/height/aspect); no vertex buffer.
(shader-module :name shader :code """
struct Uniforms {
time: f32,
width: f32,
height: f32,
aspect: f32,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) uv: vec2f,
}
@vertex
fn vs(@builtin(vertex_index) i: u32) -> VertexOutput {
// Fullscreen triangle (covers entire screen with single triangle)
let x = f32(i & 1u) * 4.0 - 1.0;
let y = f32((i >> 1u) & 1u) * 4.0 - 1.0;
var out: VertexOutput;
out.pos = vec4f(x, y, 0.0, 1.0);
out.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);
return out;
}
@fragment
fn fs(in: VertexOutput) -> @location(0) vec4f {
let t = u.time;
// Animated gradient
let angle = t * 0.3;
let c = cos(angle);
let s = sin(angle);
// Rotate UV coordinates
let centered = in.uv - 0.5;
let rotated = vec2f(
centered.x * c - centered.y * s,
centered.x * s + centered.y * c
);
// Create gradient based on rotated position
let gradient = rotated.x + rotated.y + 0.5;
// Color palette with time-based shift
let r = sin(gradient * 3.14159 + t) * 0.5 + 0.5;
let g = sin(gradient * 3.14159 + t + 2.094) * 0.5 + 0.5;
let b = sin(gradient * 3.14159 + t + 4.188) * 0.5 + 0.5;
return vec4f(r, g, b, 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.

Seven forms. Four of them, the pipeline, the uniform buffer, its queue write and the frame, appear byte for byte in every fullscreen sample; the bind group and the render pass change only a name or a clear colour:

  • (shader-module :name shader :code """…""") holds both entry points, vs and fs.
  • (render-pipeline :name pipeline :layout auto (vertex …) (fragment … (target :format preferred-canvas-format))) is a GPURenderPipeline with the layout derived from the shader and one colour target in the canvas’s preferred format.
  • (buffer :name uniforms :size 16 :usage [uniform copy-dst]) and (queue :name writeUniforms (write-buffer :buffer uniforms :offset 0 :data pngine-inputs)) are the animation input: pngine-inputs is a built-in source the runtime writes every frame, 16 bytes of time, width, height and aspect as f32, matching the Uniforms struct in the shader.
  • (bind-group :name uniformsBindGroup :layout pipeline :group 0 (entry :binding 0 :buffer uniforms)) binds that buffer at @group(0) @binding(0), using the layout the pipeline derived for group 0.
  • (render-pass :name mainPass (color-attachment :view context-current-texture …) :pipeline pipeline :bind-groups [uniformsBindGroup] (draw :vertex-count 3)) clears the canvas texture and draws three vertices.
  • (frame :name main :perform [writeUniforms mainPass]) runs the queue write and the pass, in that order, once per animation frame.

vs receives only @builtin(vertex_index). Two bit tricks turn the indices 0, 1, 2 into the corners (-1, -1), (3, -1), (-1, 3): a triangle three times the size of clip space, whose visible part is exactly the canvas. No vertex buffer, no vertex state in the pipeline. vs also hands the fragment stage a uv in [0, 1], flipping y so 0 is the top.

fs centres uv, rotates it by time * 0.3 with a 2D rotation, and takes x + y + 0.5 as a scalar gradient across the diagonal. Each colour channel is sin(gradient * π + time + phase) * 0.5 + 0.5 with phases 0, 2.094 and 4.188 (thirds of 2π), which is the standard trick for a smooth rainbow: three sines a third of a cycle apart, remapped from [-1, 1] to [0, 1].

What the sample uses WebGPU WGSL
Render pipeline with an auto layout render pipeline creation, layout: "auto", color target state entry points, @vertex / @fragment
Canvas texture and format getCurrentTexture(), getPreferredCanvasFormat()
Uniform buffer written each frame writeBuffer(), GPUBufferUsage.UNIFORM, bind group creation uniform address space, structure member layout
A pass that clears and draws three vertices render passes, GPULoadOp, draw() vertex_index, position
Passing uv between stages rasterization @location, interpolation
The colour math sin, cos, bit expressions (i & 1u, i >> 1u)