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 tag | draw_lines = 43 |
| Variant bit | variant_fatline = 1 << 19 |
| Vertex record | 40 B/instance: p0 vec3@0, p1 vec3@12, color vec4@24 |
| UBO | 80 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:
| Bit | Meaning |
|---|---|
| bit 0 | worldUnits — 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:
| Constant | Effect |
|---|---|
state_depth_test | opaque, depth-tested (e.g. a wireframe cube) |
state_depth_test \| state_blend | depth-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,
) voidvbuf_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:
- Static opaque wireframe cube — 12 edges, drawn first with
state_depth_testonly (no blend) so its depth writes gate the trail behind it. - 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.
| Control | Export | Effect |
|---|---|---|
| Width + | gllines_width_up | increases line width by 2px, capped at 32px |
| Width - | gllines_width_down | decreases line width by 2px, floored at 1px |
| Toggle World Units | gllines_toggle_worldunits | flips flags bit 0 — screen-constant pixel width vs world-space width |
| Freeze | gllines_freeze | pins the orbit camera (the trail keeps animating) |
| Unfreeze | gllines_unfreeze | resumes 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.