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 tagdraw_billboards = 42
Variant bitvariant_billboard = 1 << 18
Vertex record36 B/instance: center vec3@0, size f32@12, color vec4@16, rot f32@32
UBO144 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:

BitMeaning
bit 0sizeAttenuation — on: size is world-space and shrinks with distance; off: size is a screen-constant NDC half-extent
bit 1round — 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:

ConstantEffect
state_blendnormal alpha-over
state_blend_addadditive (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,
) void

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

ControlExportEffect
Toggle Attenuationglpoints_toggle_attenuationflips bit 0 of the particle cloud's flags — world-unit vs screen-constant point size
Toggle Additiveglpoints_toggle_additiveswaps the particle cloud's blend state between state_blend_add and state_blend
Freezeglpoints_freezepins the orbit camera (the particle sim keeps running)
Unfreezeglpoints_unfreezeresumes 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).