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 quad and the instances
Section titled “The quad and the instances”Two buffers carry the picture, each declared by one form of SJON, the
S-expression format pngine compiles (a form per WebGPU resource or
operation). 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 (WGSL, WebGPU’s shading language) 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: I spell both out 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.
An exercise: change the colour blend’s :dst-factor from
one-minus-src-alpha to one and recompile. That one word turns
source-over into the additive blend of
Particle fountain:
overlapping glows now sum towards white instead of covering each other.
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 …).