UI elements
Twelve rectangles, one draw call, and a fragment shader that knows which
rectangle it is drawing. The instance data is a table of
x y width height r g b a rows written straight into the document; the
shaders read @builtin(instance_index) to give the buttons rounded
corners, the panel a border, the icon and the status dot a circular shape,
and to animate the progress bar and the dot’s pulse. Everything is blended
over a dark background.
; UI elements: a mock UI (panel with border, title bar, two rounded buttons,; an animated progress bar, sliders, a round icon, a pulsing status dot) built; from 12 instanced quads. Exercises alpha blending with separate colour and; alpha blend components, per-instance rect+colour vertex data, and; `@builtin(instance_index)` to special-case individual elements in the shaders.
(define :name NUM_UI_ELEMENTS :value 12)
(data :name quadVertices :float32 [ 0.0 0.0 1.0 0.0 0.0 1.0 1.0 0.0 1.0 1.0 0.0 1.0])
; UI element data: x, y, width, height, r, g, b, a (normalized coords)(data :name uiData :float32 [ 0.05 0.1 0.35 0.5 0.15 0.15 0.2 0.9 0.05 0.52 0.35 0.06 0.3 0.3 0.4 1.0 0.08 0.38 0.12 0.06 0.2 0.5 0.2 1.0 0.24 0.38 0.12 0.06 0.5 0.2 0.2 1.0 0.08 0.26 0.28 0.04 0.1 0.1 0.12 1.0 0.08 0.26 0.14 0.04 0.3 0.7 0.3 1.0 0.08 0.46 0.2 0.025 0.7 0.7 0.7 1.0 0.08 0.32 0.15 0.02 0.6 0.6 0.6 1.0 0.32 0.14 0.05 0.05 0.5 0.5 0.8 1.0 0.05 0.1 0.35 0.005 0.4 0.7 0.4 1.0 0.36 0.55 0.02 0.02 0.3 0.8 0.3 1.0 0.08 0.24 0.28 0.003 0.3 0.3 0.35 1.0])
(buffer :name quadBuffer :usage [vertex] :data quadVertices)
(buffer :name uiBuffer :usage [vertex] :data uiData)
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeUniforms (write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(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) color: vec4f, @location(1) localUV: vec2f, @location(2) @interpolate(flat) elementIdx: u32, }
@vertex fn vs( @builtin(instance_index) instIdx: u32, @location(0) quadPos: vec2f, @location(1) rect: vec4f, @location(2) color: vec4f ) -> VertexOutput { var r = rect;
// Animate progress bar (element 5) if (instIdx == 5u) { r.z = 0.05 + (sin(u.time * 0.5) * 0.5 + 0.5) * 0.23; }
// Animate status dot (element 10) - pulsing if (instIdx == 10u) { let pulse = 1.0 + sin(u.time * 3.0) * 0.2; r.z *= pulse; r.w *= pulse; }
// Convert from normalized [0,1] to clip space [-1,1] let x = (r.x + quadPos.x * r.z) * 2.0 - 1.0; let y = (r.y + quadPos.y * r.w) * 2.0 - 1.0;
var out: VertexOutput; out.pos = vec4f(x, y, 0.0, 1.0); out.color = color; out.localUV = quadPos; out.elementIdx = instIdx; return out; }
@fragment fn fs(in: VertexOutput) -> @location(0) vec4f { var color = in.color;
// Add rounded corners for buttons (elements 2, 3) if (in.elementIdx == 2u || in.elementIdx == 3u) { let cornerRadius = 0.15; let uv = in.localUV;
// Check corners var d = 0.0; if (uv.x < cornerRadius && uv.y < cornerRadius) { d = length(uv - vec2f(cornerRadius)) - cornerRadius; } else if (uv.x > 1.0 - cornerRadius && uv.y < cornerRadius) { d = length(uv - vec2f(1.0 - cornerRadius, cornerRadius)) - cornerRadius; } else if (uv.x < cornerRadius && uv.y > 1.0 - cornerRadius) { d = length(uv - vec2f(cornerRadius, 1.0 - cornerRadius)) - cornerRadius; } else if (uv.x > 1.0 - cornerRadius && uv.y > 1.0 - cornerRadius) { d = length(uv - vec2f(1.0 - cornerRadius)) - cornerRadius; }
if (d > 0.0) { color.a = 0.0; }
// Add highlight at top if (in.localUV.y > 0.7) { color = mix(color, vec4f(1.0, 1.0, 1.0, color.a), 0.2); } }
// Add subtle border to panel (element 0) if (in.elementIdx == 0u) { let borderWidth = 0.008; let isEdge = in.localUV.x < borderWidth || in.localUV.x > 1.0 - borderWidth || in.localUV.y < borderWidth || in.localUV.y > 1.0 - borderWidth; if (isEdge) { color = mix(color, vec4f(0.4, 0.4, 0.5, color.a), 0.5); } }
// Round the icon (element 8) if (in.elementIdx == 8u) { let uv = in.localUV * 2.0 - 1.0; if (length(uv) > 1.0) { color.a = 0.0; } }
// Round the status dot (element 10) if (in.elementIdx == 10u) { let uv = in.localUV * 2.0 - 1.0; if (length(uv) > 1.0) { color.a = 0.0; } }
if (color.a < 0.01) { discard; }
return color; } """)
(render-pipeline :name pipeline :layout auto (vertex :module shader :entry vs (vertex-buffer :array-stride 8 :step-mode vertex (attribute :shader-location 0 :offset 0 :format float32x2)) (vertex-buffer :array-stride 32 :step-mode instance (attribute :shader-location 1 :offset 0 :format float32x4) (attribute :shader-location 2 :offset 16 :format float32x4))) (fragment :module shader :entry fs (target :format preferred-canvas-format (blend (color :src-factor src-alpha :dst-factor one-minus-src-alpha) (alpha :src-factor one :dst-factor one-minus-src-alpha)))))
(bind-group :name bindings :layout pipeline :group 0 (entry :binding 0 :buffer uniforms))
(render-pass :name drawPass (color-attachment :view context-current-texture :clear-value [0.08 0.08 0.12 1] :load-op clear :store-op store) :pipeline pipeline :vertex-buffers [quadBuffer uiBuffer] :bind-groups [bindings] (draw :vertex-count 6 :instance-count NUM_UI_ELEMENTS))
(frame :name main :perform [writeUniforms drawPass])examples/samples/08_ui_elements.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 layout as data
Section titled “The layout as data”quadVertices is a unit quad in [0, 1]² (six vertices). uiData is
twelve rows of eight floats: a rectangle in normalised screen coordinates
and an RGBA colour, in draw order: instances rasterize in row order, so the
panel (row 0) is underneath and each later row paints over what it overlaps
(the status dot is row 10; row 11, the last, is a hairline separator). Both go into buffers with :data, which fills
each buffer at creation and gives it its size, and the pipeline reads them as
a per-vertex float32x2 and a per-instance pair of float32x4 at a 32-byte
stride. (draw :vertex-count 6 :instance-count NUM_UI_ELEMENTS) draws the
lot.
instance_index in both stages
Section titled “instance_index in both stages”vs takes @builtin(instance_index) alongside its attributes. Two elements
are animated there: element 5, the progress bar’s fill, gets its width
rewritten from a slow sine, and element 10, the status dot, is scaled by a
pulse. The index is also passed to the fragment stage as
@location(2) @interpolate(flat) elementIdx: u32; integers cannot be
interpolated, so flat is required for a u32 varying.
fs then branches on it: elements 2 and 3 (the buttons) compute a
rounded-corner distance and set alpha to 0 outside it, plus a lighter band
across the top; element 0 (the panel) mixes in a border colour along its
edges; elements 8 and 10 are clipped to a circle. Any fragment left with
alpha below 0.01 is discarded.
Blending
Section titled “Blending”The pipeline’s (blend (color :src-factor src-alpha :dst-factor one-minus-src-alpha) (alpha :src-factor one :dst-factor one-minus-src-alpha))
is source-over with separate colour and alpha components: the panel’s 0.9
alpha lets a hint of background through, and the buttons’ clipped corners
are transparent rather than dark. The clear colour in the pass sets the
“desktop” behind it all.
The rest
Section titled “The rest”The uniform buffer refilled every frame from pngine-inputs (the built-in
time/width/height/aspect source; only time is read here), the bind
group and the frame are the scaffold from
Gradient background.
In the specifications
Section titled “In the specifications”| What the sample uses | WebGPU | WGSL |
|---|---|---|
| Instance index in shaders | instance_index, @interpolate(flat), interpolation |
|
| Instanced quads from two buffers | draw(), GPUVertexStepMode, mappedAtCreation |
@location inputs |
| Source-over blending | blend state, GPUBlendComponent, "one-minus-src-alpha" |
|
| Clipping corners and circles | length, discard statement, if statement, mix |
|
| Draw order and coverage | rasterization, GPULoadOp |
Related
Section titled “Related”- Sprite rendering is the same quad instancing with compute-seeded instances and distance-field shapes.
- Forms:
(data …),(render-pipeline …)((blend …)),(render-pass …).