Sprite rendering
Sprites without a sprite sheet. Each of the 64 instances is a quad (two triangles from six vertices) placed and scaled by a per-instance record, and its fragment shader draws one of four shapes with a signed-distance function, fading the edge to transparent and discarding what is fully clear. The pipeline blends source-over, so the glows overlap softly. As in Multiple triangles, a compute shader seeds the instance records once.
; Sprite rendering: 64 gently floating "sprites" drawn as instanced,; alpha-blended quads. Despite the name there are no textures: each sprite is a; procedural SDF shape (circle, star, ring or diamond) picked per instance and; evaluated in the fragment shader with a soft edge and glow. A compute; `(init …)` fills the per-instance buffer once with random position, scale and; type (one vec4f per sprite); the pipeline blends src-alpha over; one-minus-src-alpha.;; Every vertex buffer states its `:step-mode` (`vertex` on the quad, `instance`; on the sprite data) even though `vertex` is the default, so the per-vertex /; per-instance split reads at a glance.
(define :name NUM_SPRITES :value 64)
(data :name quadVertices :float32 [ -0.5 -0.5 0.5 -0.5 -0.5 0.5 0.5 -0.5 0.5 0.5 -0.5 0.5])
; quadBuffer is a `:data` fill from the static quad; instanceBuffer is; storage+vertex and is seeded by the init pass (no mapped data); uniforms; receives pngine-inputs each frame.(buffer :name quadBuffer :usage [vertex] :data quadVertices)
(buffer :name instanceBuffer :size (* NUM_SPRITES 4 4) :usage [vertex storage])
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeUniforms (write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
; One-shot seed of the per-instance buffer: `:workgroups [1]` dispatches; (1,1,1), the missing y/z default to 1. One 64-thread workgroup covers all; 64 sprites.(init :name initSprites :buffer instanceBuffer :module initShader :workgroups [1])
(shader-module :name initShader :code """ struct Instances { data: array<vec4f>, } @binding(0) @group(0) var<storage, read_write> instances: Instances;
fn hash(n: u32) -> f32 { var x = n; x = ((x >> 16u) ^ x) * 0x45d9f3bu; x = ((x >> 16u) ^ x) * 0x45d9f3bu; x = (x >> 16u) ^ x; return f32(x) / f32(0xffffffffu); }
const NUM: u32 = 64u;
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3u) { let i = id.x; if (i >= NUM) { return; }
let x = hash(i * 7u) * 1.8 - 0.9; let y = hash(i * 11u) * 1.8 - 0.9; let scale = hash(i * 13u) * 0.12 + 0.04; let spriteType = hash(i * 17u); // 0-1 for sprite variety
instances.data[i] = vec4f(x, y, scale, spriteType); }""")
(shader-module :name renderShader :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) localUV: vec2f, @location(1) spriteType: f32, }
@vertex fn vs( @location(0) quadPos: vec2f, @location(1) instanceData: vec4f ) -> VertexOutput { let pos = instanceData.xy; let scale = instanceData.z; let spriteType = instanceData.w;
// Gentle floating animation let floatY = sin(u.time * 2.0 + pos.x * 5.0) * 0.02;
let scaledPos = quadPos * scale;
var out: VertexOutput; out.pos = vec4f(scaledPos.x / u.aspect + pos.x, scaledPos.y + pos.y + floatY, 0.0, 1.0); out.localUV = quadPos + 0.5; // Convert to 0-1 range out.spriteType = spriteType; return out; }
fn sdCircle(p: vec2f, r: f32) -> f32 { return length(p) - r; }
// Floor modulo: WGSL's float `%` truncates toward zero (C fmod), which // folds negative angles the wrong way and turns the star into an arrow. fn floorMod(x: f32, y: f32) -> f32 { return x - y * floor(x / y); }
fn sdStar(p: vec2f, r: f32, n: u32) -> f32 { let an = 3.14159 / f32(n); let en = 3.14159 / 2.5; let acs = vec2f(cos(an), sin(an)); let ecs = vec2f(cos(en), sin(en)); let bn = floorMod(atan2(p.x, p.y), 2.0 * an) - an; var pp = length(p) * vec2f(cos(bn), abs(sin(bn))); pp -= r * acs; pp += ecs * clamp(-dot(pp, ecs), 0.0, r * acs.y / ecs.y); return length(pp) * sign(pp.x); }
@fragment fn fs(in: VertexOutput) -> @location(0) vec4f { let uv = in.localUV * 2.0 - 1.0; // -1 to 1
var d: f32; var color: vec3f;
// Different sprite types based on spriteType value if (in.spriteType < 0.25) { // Circle sprite d = sdCircle(uv, 0.6); color = vec3f(1.0, 0.4, 0.4); } else if (in.spriteType < 0.5) { // Star sprite d = sdStar(uv, 0.4, 5u); color = vec3f(1.0, 0.9, 0.3); } else if (in.spriteType < 0.75) { // Ring sprite d = abs(sdCircle(uv, 0.5)) - 0.1; color = vec3f(0.4, 0.8, 1.0); } else { // Diamond sprite d = (abs(uv.x) + abs(uv.y)) - 0.6; color = vec3f(0.6, 1.0, 0.6); }
// Smooth edge with glow let alpha = 1.0 - smoothstep(0.0, 0.1, d); let glow = 0.05 / (abs(d) + 0.05);
if (alpha < 0.01) { discard; }
return vec4f(color * (alpha + glow * 0.3), alpha); }""")
(render-pipeline :name pipeline :layout auto (vertex :module renderShader :entry vs (vertex-buffer :array-stride 8 :step-mode vertex (attribute :shader-location 0 :offset 0 :format float32x2)) (vertex-buffer :array-stride 16 :step-mode instance (attribute :shader-location 1 :offset 0 :format float32x4))) (fragment :module renderShader :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.1 0.1 0.2 1] :load-op clear :store-op store) :pipeline pipeline :vertex-buffers [quadBuffer instanceBuffer] :bind-groups [bindings] (draw :vertex-count 6 :instance-count NUM_SPRITES))
(frame :name main :init [initSprites] :perform [writeUniforms drawPass])examples/samples/07_sprite_rendering.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 quad and the instances
Section titled “The quad and the instances”quadVertices is a unit quad centred on the origin as two triangles (six
vec2f), uploaded at creation into quadBuffer. instanceBuffer holds
one vec4f per sprite, x y scale type, and is created empty with
[vertex storage] usage; (init :name initSprites :buffer instanceBuffer :module initShader :workgroups [1])
fills it once from a compute shader with hashed positions in [-0.9, 0.9],
a scale in [0.04, 0.16] and a random type in [0, 1].
The pipeline declares both layouts with their step modes spelled out,
:step-mode vertex on the 8-byte quad and :step-mode instance on the
16-byte record, even though vertex is the default: the sample does it so
the per-vertex / per-instance split reads at a glance.
(draw :vertex-count 6 :instance-count NUM_SPRITES) draws all of them.
The vertex stage
Section titled “The vertex stage”vs receives the quad corner in @location(0) and the instance record in
@location(1). It scales the corner, divides x by the aspect ratio so
sprites stay square, adds the instance position and a small vertical bob
from sin(2t + 5x), and passes the corner as a [0, 1] localUV plus the
sprite type to the fragment stage.
Shapes and blending
Section titled “Shapes and blending”fs recentres localUV to [-1, 1] and picks a shape by type quartile:
circle, five-point star (sdStar), ring (|circle| - 0.1) or diamond
(|x| + |y| - 0.6), each with its own colour. sdStar folds its angle
with a local floorMod rather than WGSL’s %, which truncates toward zero
and would turn the star into an arrow; see
Spinning shapes for the why. alpha = 1 - smoothstep(0, 0.1, d)
softens the edge; a glow 0.05 / (|d| + 0.05) brightens the colour near
it; if (alpha < 0.01) { discard; } drops the transparent corners of the
quad entirely, so they neither blend nor cover neighbours.
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 standard source-over blending: colour weighted by the sprite’s alpha
over the background weighted by what is left, and alpha accumulating.
Because the returned colour is already multiplied by alpha + glow, bright
cores stay bright and the glow fades out over the dark clear colour.
In the specifications
Section titled “In the specifications”| What the sample uses | WebGPU | WGSL |
|---|---|---|
| Alpha blending | blend state, GPUBlendComponent, GPUBlendFactor, "src-alpha" |
|
| Discarding fragments | discard statement |
|
| Instanced quads from two buffers | draw(), GPUVertexStepMode, vertex state |
@location inputs |
| One-shot compute seeding | compute pipelines, STORAGE + VERTEX |
@compute, storage address space |
| Distance-field shapes | length, atan2, abs, smoothstep, if statement |
Related
Section titled “Related”- UI elements uses the same blend state and quad instancing for an interface; Particle fountain blends additively instead.
- Spinning shapes draws distance-field shapes in a single fullscreen pass.
- Forms:
(render-pipeline …)((blend …)),(init …),(data …).