verve.gl points & sprites (billboards)
Guide: WebGL · Advanced WebGL.
Camera-facing billboard quads — three.js PointsMaterial / SpriteMaterial parity. Part of the Primitives slice (v0.19.0): points/sprites, fat lines, and decals are all standalone shader-variant pairs — each owns its vertex shader, fragment shader, and uniform block; none extend the PBR über-shader, so PBR's UBO byte-layout stays untouched. Renders identically on WebGL2 and WebGPU. Demo: /examples/gl-points.
Wire facts
| Draw tag | draw_billboards = 42 |
| Variant bit | variant_billboard = 1 << 18 |
| Vertex record | 36 B/instance: center vec3@0, size f32@12, color vec4@16, rot f32@32 |
| UBO | 144 B: view@0, proj@64, flags@128 |
A particle (point) is one instance; a sprite is a single instance — both are camera-facing textured quads, so they share one draw path. The quad's 6 vertices are generated in the vertex shader from gl_VertexID / @builtin(vertex_index) (no base or index buffer) — only the per-instance buffer is bound.
Why view and projection are separate
View and projection are passed as two separate matrix pointers (not a pre-multiplied viewProj) because the quad expands in view space: the center is transformed to view space, the corner is offset there (by world-unit size when sizeAttenuation is on, or screen-constant via a clip.w scale when off), then projected and rotated by rot.
Flags
flags is a bitfield passed per-draw:
| Bit | Meaning |
|---|---|
| bit 0 | sizeAttenuation — on: size is world-space and shrinks with distance; off: size is a screen-constant NDC half-extent |
| bit 1 | round — the fragment shader discards outside the unit circle, producing a soft round point instead of a square quad |
Blend selection
Blend is chosen by set_pipeline state bits, not a shader variant:
| Constant | Effect |
|---|---|
state_blend | normal alpha-over |
state_blend_add | additive (ONE/ONE) |
On WebGPU both blend modes are pre-baked as two pipelines, selected at draw time by the state_blend_add bit — additive works without spending a separate variant bit.
Encoder.drawBillboards
pub fn drawBillboards(
self: *Encoder,
vbuf_instance: u32,
count: u32,
tex_handle: u32,
view_ptr: u32,
proj_ptr: u32,
flags: u32,
) voidtex_handle = 0 binds a white dummy texture — used for untextured round points where only the instance color matters. A real texture handle (from createTexture) tints per-texel, multiplied by the instance color — used for the sprite.
Demo (/gl-points)
A ~2000-point additive drifting cloud (round soft points, CPU-side upward-drift sim, respawning at the bottom when a particle drifts above the top of its box) plus one textured 16×16 disc sprite built procedurally at runtime (golden inner disc, white ring, transparent outside), rendered with sizeAttenuation off so it stays a constant screen size. An orbit camera advances its yaw every frame unless frozen — orbiting is what makes the camera-facing billboard behavior visible; a static quad would look identical from a fixed viewpoint otherwise.
| Control | Export | Effect |
|---|---|---|
| Toggle Attenuation | glpoints_toggle_attenuation | flips bit 0 of the particle cloud's flags — world-unit vs screen-constant point size |
| Toggle Additive | glpoints_toggle_additive | swaps the particle cloud's blend state between state_blend_add and state_blend |
| Freeze | glpoints_freeze | pins the orbit camera (the particle sim keeps running) |
| Unfreeze | glpoints_unfreeze | resumes the orbit |
The sprite always renders with state_blend (alpha) and flags = 0 (no attenuation, no round discard — the texture's alpha channel already clips the disc shape), independent of the cloud's toggles.
Backend gotcha (WebGPU-only)
Each standalone variant that uses a texture binds a sampler + texture at @group(1). The WGSL declares them @binding(0) sampler, @binding(1)
texture_2d — the bridge's BindGroupLayout and bind-group entries must list them in that exact order. A reversed order is a valid WGSL module and a valid layout in isolation, but createRenderPipeline rejects the pair, producing a blank canvas with no console error on some paths. WebGL2 is immune (the sampler unit is fixed via uniform1i at link time).