Gradient background
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.
; 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])examples/samples/01_gradient_background.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”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,vsandfs.(render-pipeline :name pipeline :layout auto (vertex …) (fragment … (target :format preferred-canvas-format)))is aGPURenderPipelinewith 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-inputsis a built-in source the runtime writes every frame, 16 bytes oftime,width,heightandaspectasf32, matching theUniformsstruct 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.
The fullscreen triangle
Section titled “The fullscreen triangle”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.
The gradient
Section titled “The gradient”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].
In the specifications
Section titled “In the specifications”| 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) |
Related
Section titled “Related”- Shader art (plasma) and Color cycling: the same scaffold with richer fragment shaders.
- Forms:
(shader-module …),(render-pipeline …),(queue …)(thepngine-inputssource),(render-pass …),(frame …). - The
(pass …)sugar writes this whole scaffold for you when all you want is a fragment shader.