verve.gl fat lines (Line2 / LineSegments2)

Guide: WebGL · Advanced WebGL.

Wide line segments rendered as instanced screen-space quads — three.js Line2 / LineSegments2 parity. Part of the Primitives slice (v0.20.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-lines.

Why not native lineWidth?

Fat lines are never drawn with the native lineWidth state — WebGPU locks it to 1px and most WebGL2 drivers cap it at 1px regardless of what you request. Instead each segment is an instanced screen-space quad: the vertex shader projects both endpoints to clip space, takes the screen-space perpendicular of the segment direction, and offsets the quad corner by width * 0.5 scaled by clip.w — giving a constant pixel width at any depth. A worldUnits flag switches to world-space width instead (the line thins with distance like a 3D tube). Square caps only — round caps and joins are deferred.

Wire facts

Draw tagdraw_lines = 43
Variant bitvariant_fatline = 1 << 19
Vertex record40 B/instance: p0 vec3@0, p1 vec3@12, color vec4@24
UBO80 B: vp@0, resolution@64, width@72, flags@76

A segment is one instance. The quad's 6 vertices are generated in the vertex shader from gl_VertexID / @builtin(vertex_index) as (t, side) pairs (no base or index buffer) — only the per-instance segment buffer is bound.

Combined VP, not separate view/proj

Unlike billboards, fat lines take a single combined view-projection matrix (proj · view), not separate view/proj pointers — the perpendicular offset is computed directly in clip space from the two projected endpoints, so there's no need for an intermediate view-space step.

Flags

flags is a bitfield passed per-draw:

BitMeaning
bit 0worldUnits — off (default): width is screen-space pixels, constant regardless of depth; on: width is world-space units, and the line thins with distance

Blend selection

Blend is chosen by set_pipeline state bits, not a shader variant — an opaque wireframe and a translucent trail can share the same shader:

ConstantEffect
state_depth_testopaque, depth-tested (e.g. a wireframe cube)
state_depth_test \| state_blenddepth-tested translucent (reads existing depth, doesn't write past it)

Encoder.drawLines

pub fn drawLines(
    self: *Encoder,
    vbuf_segments: u32,
    count: u32,
    width: f32,
    vp_ptr: u32,
    resolution_ptr: u32,
    flags: u32,
) void

vbuf_segments is a vertex buffer of count 40B segment records. width is @bitCast to u32 in the wire stream (pixels in screen-space, or world units when flags bit 0 is set). vp_ptr points to 16 f32 (the combined view-projection matrix); resolution_ptr points to 2 f32 (viewport width, height — needed to convert the clip-space perpendicular offset to a constant pixel width).

Demo (/gl-lines)

Two primitives share one draw stream:

  1. Static opaque wireframe cube — 12 edges, drawn first with state_depth_test only (no blend) so its depth writes gate the trail behind it.
  2. Animated translucent trail — a 3D Lissajous curve advancing every frame (64 points → 63 segments), drawn with state_depth_test | state_blend. Alpha fades from transparent at the tail to opaque at the head, and the color gradient shifts from dim cyan to bright white-yellow.

An orbit camera advances its yaw every frame unless frozen.

ControlExportEffect
Width +gllines_width_upincreases line width by 2px, capped at 32px
Width -gllines_width_downdecreases line width by 2px, floored at 1px
Toggle World Unitsgllines_toggle_worldunitsflips flags bit 0 — screen-constant pixel width vs world-space width
Freezegllines_freezepins the orbit camera (the trail keeps animating)
Unfreezegllines_unfreezeresumes the orbit

Both the wireframe cube and the trail share one width and one worldUnits flag — widening the line thickens both, and toggling world units changes how both scale with camera distance. Renders through WebGPU when available, else WebGL2.

Backend gotcha (WebGPU-only)

Each standalone variant that uses a texture binds a sampler + texture at @group(1). Fat lines use no texture, so this gotcha doesn't apply to /gl-lines directly — but it applies to the other Primitives-slice demos (/gl-points, /gl-decals) that share the same standalone-variant pattern: the WGSL declares @binding(0) sampler, @binding(1) texture_2d — the bridge's BindGroupLayout and bind-group entries must list them in that exact order, or createRenderPipeline rejects the pair with a blank canvas and no console error on some paths.