Skip to content

UI elements

Download PNG

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.

examples/samples/08_ui_elements.sjon
; 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])

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.

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.

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.

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 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.

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