WebGPU Lines

This module implements high-performance, flexible GPU-accelerated line rendering for WebGPU. It is a direct port of regl-gpu-lines to WebGPU. The focus is on speed and customizability rather than sophisticated stroke expansion algorithms. For background on GPU line rendering, see Matt DesLauriers’ Drawing Lines is Hard and Rye Terrell’s Instanced Line Rendering. This library is a work in progress. You can find the source on GitHub, and it will eventually be published as a standalone NPM module.

Interactive Demo

Explore the line rendering options with interactive demo below. Drag handles to edit vertices, drag the background to pan, and scroll to zoom. Enable Debug view to see the underlying triangle strip structure. Alternating colors indicate separate instances.

How It Works

Background

The renderer uses instanced rendering with triangle strips. For a line with N points, it draws N-1 instances, where each instance renders one line segment plus half of the join geometry on each end. End caps are simply joins stretched around to form a cap.

The geometry is carefully generated to optimize for high-performance rendering without the full rigor of stroke expansion algorithms, which handle self-intersection more carefully. The lineCoord varying is constructed so that length(lineCoord) gives consistent radial distance from the line center across both segments and caps, permitting uniform stroke widths.

Features

  • Instanced rendering with triangle strips
  • Screen-projected lines using a custom vertex function that can read geometry from buffers, textures, or procedural computation. The vertex function also defines varying values, line widths, and vertex projection at runtime in the shader.
  • Bevel, miter, and round joins
  • Round, square, and butt end caps
  • Line breaks via w = 0 sentinel value
  • A lineCoord varying that can be used to construct SDF stroke outlines with anti-aliasing

Limitations

  • The library does not handle self-intersecting lines.
  • Rapidly varying line widths render incorrectly.
  • World-space line widths require custom work in the vertex shader function.

Shader Interface

You provide two pieces of WGSL code.

vertexShaderBody defines how to fetch and transform vertex data. You write bind group declarations (@group(1) and higher) for your data, a struct containing position: vec4f, width: f32, and any custom varyings, and a function (default name getVertex) that takes a point index and returns your struct.

fragmentShaderBody defines how to color the line. You write a getColor function that receives lineCoord: vec2f plus any varyings from your struct.

The library parses your struct to identify the position, width, and varying fields automatically. The library reserves @group(0) for its internal uniforms. Your shader code should use @group(1) and higher for your own data.

Example:

// In vertexShaderBody:
@group(1) @binding(0) var<storage, read> positions: array<vec4f>;

struct Vertex {
  position: vec4f,    // Required
  width: f32,         // Required
  distance: f32,      // Varying - passed to getColor
}

fn getVertex(index: u32) -> Vertex {
  let p = positions[index];
  return Vertex(p, 10.0, f32(index));
}

// In fragmentShaderBody:
fn getColor(lineCoord: vec2f, distance: f32) -> vec4f {
  return vec4f(0.2, 0.5, 0.9, 1.0);
}

Line Breaks

To create a line break (splitting one line into multiple segments with separate end caps), have your getVertex function return a position with w = 0 (or NaN in any component). The library detects these sentinel values and inserts caps on adjacent segments.

Vertex Window

Internally, each instance calls getVertex for a 4-point window of adjacent vertices. Points A (previous) and D (next) provide direction for computing join angles. Points B (start) and C (end) define the segment being drawn.

Within each instance, a vertex index (0 to ~30, depending on join/cap resolution) expands into the triangle strip vertices. The vertex shader uses this index to compute screen-space offsets perpendicular to the line direction, forming the segment body, joins, and caps.


API Reference

createGPULines(device, options)

Creates a new line renderer instance.

Parameters:

  • device - WebGPU device
  • options - Configuration object (see below)

Returns: Line renderer object with draw(), getBindGroupLayout(), and destroy() methods.

join

Controls how line segments are connected at vertices. Options are 'bevel', 'miter', and 'round'.

cap

Controls how line endpoints are rendered. Options are 'round', 'square', and 'none'.

miterLimit

When using join: 'miter', this controls when sharp angles fall back to bevel joins. Lower values create more bevels. Higher values allow longer miter points. Default is 4.

joinResolution and capResolution

Control the number of triangles used for round joins and caps. Higher values create smoother curves. Default is 8.

Line Breaks

Insert a point with w = 0 (or NaN for any coordinate) to create a line break. This splits the line into separate segments, each with its own end caps.


Custom Shaders

The library supports custom WGSL shaders for advanced rendering effects. Provide shader code via the fragmentShaderBody and vertexShaderBody options.

fragmentShaderBody

The fragment shader controls how lines are colored. Your code must define a getColor function.

fn getColor(lineCoord: vec2f) -> vec4f {
  // Return RGBA color (0-1 range)
  return vec4f(0.2, 0.5, 0.9, 1.0);
}

The lineCoord parameter provides spatial information about the current fragment (see below).

Any fields in your Vertex struct beyond position and width become varyings, interpolated across the line and passed as additional parameters to getColor. For example, if your struct includes dist: f32, your function signature becomes fn getColor(lineCoord: vec2f, dist: f32) -> vec4f.

If your shader code references instanceID, the library will automatically pass two additional parameters, instanceID: f32 (segment index, negative for end caps) and triStripCoord: vec2f (triangle strip vertex coordinates for wireframe visualization). These are useful for debug views showing the internal triangle strip structure.

lineCoord Values

Component Segments/Joins Caps Description
lineCoord.x 0 sin(θ) × sign Always 0 for segments. Varies around the semicircle for caps.
lineCoord.y -1 to 1 cos(θ) × sign Position across the line. 0 at center, ±1 at edges.

The lineCoord values are designed for SDF (signed distance field) rendering. length(lineCoord) gives radial distance from line center (0 at center, 1 at edge). For segments, length(lineCoord) = abs(lineCoord.y) since x=0. For caps, length(lineCoord) = 1 on the outer edge (unit circle).

Note that lineCoord.x does NOT provide distance along the line. To implement dashes, add a cumulative distance field to your Vertex struct. It will be interpolated and passed to getColor as an extra parameter. See the interactive demo’s “Stripes” option for an example.

Example Shaders

Solid color with edge darkening.

fn getColor(lineCoord: vec2f) -> vec4f {
  let edge = 1.0 - 0.3 * abs(lineCoord.y);
  return vec4f(0.2 * edge, 0.5 * edge, 0.9 * edge, 1.0);
}

Cross-line stripes using lineCoord.y.

fn getColor(lineCoord: vec2f) -> vec4f {
  let stripe = step(0.0, lineCoord.y);
  return vec4f(stripe * 0.2, 0.5, 0.9 - stripe * 0.4, 1.0);
}

SDF stroke with anti-aliasing.

fn linearstep(a: f32, b: f32, x: f32) -> f32 {
  return clamp((x - a) / (b - a), 0.0, 1.0);
}
fn getColor(lineCoord: vec2f) -> vec4f {
  let width = 20.0;
  let strokeWidth = 4.0;
  let sdf = 0.5 * width * length(lineCoord);
  let aa = linearstep(width * 0.5, width * 0.5 - 1.0, sdf);
  let strokeMask = linearstep(
    width * 0.5 - strokeWidth - 0.5,
    width * 0.5 - strokeWidth + 0.5, sdf);
  let fillColor = vec3f(0.4, 0.7, 1.0);
  let strokeColor = vec3f(0.1, 0.3, 0.6);
  let color = mix(fillColor, strokeColor, strokeMask);
  return vec4f(color, aa);
}

When using transparency or discard, enable alpha blending.

createGPULines(device, {
  blend: {
    color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
    alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }
  }
});

vertexShaderBody

The vertex shader body defines how line positions and per-vertex data are computed. You provide bind group declarations (group 1+) for your data, a struct defining the vertex output, and a vertex function that returns the struct given a point index.

@group(1) @binding(0) var<storage, read> positions: array<vec4f>;
@group(1) @binding(1) var<uniform> viewMatrix: mat4x4f;

struct Vertex {
  position: vec4f,  // Required, clip-space position (w component controls line breaks)
  width: f32,       // Optional, per-vertex line width
  // Additional fields become varyings passed to fragment shader
}

fn getVertex(index: u32) -> Vertex {
  let p = positions[index];
  let projected = viewMatrix * vec4f(p.xyz, 1.0);
  return Vertex(vec4f(projected.xyz, p.w * projected.w), 20.0);
}

Options for customization include vertexFunction (name of your vertex function, default 'getVertex'), positionField (name of position field in struct, default 'position'), and widthField (name of width field in struct, default 'width').

Available library uniforms are uniforms.resolution (canvas resolution in pixels), uniforms.width (fallback line width if no per-vertex width), and uniforms.pointCount (number of points).

Drawing

gpuLines.draw(pass, props, bindGroups)

Draws lines in a render pass. The props object includes vertexCount (number of points in the line), width (line width in pixels, fallback if no per-vertex width), and resolution (canvas resolution as [width, height]). The bindGroups parameter is an array of user bind groups for groups 1, 2, etc.

gpuLines.getBindGroupLayout(index)

Returns the bind group layout for the specified group index. Use this to create bind groups for your data.

const dataBindGroup = device.createBindGroup({
  layout: gpuLines.getBindGroupLayout(1),
  entries: [
    { binding: 0, resource: { buffer: positionBuffer } },
    { binding: 1, resource: { buffer: viewMatrixBuffer } }
  ]
});

const pass = encoder.beginRenderPass({ ... });
gpuLines.draw(pass, {
  vertexCount: points.length,
  width: 20,
  resolution: [canvas.width, canvas.height]
}, [dataBindGroup]);
pass.end();

Position Data Format

Your vertex function returns a vec4f position. The x and y components are position in clip space (-1 to 1), z is depth, and w should be 1 for valid points or 0 (or NaN for any component) for line breaks.

Example with a storage buffer.

const positions = new Float32Array([
  x0, y0, z0, w0,  // Point 0
  x1, y1, z1, w1,  // Point 1
  // ...
]);

const buffer = device.createBuffer({
  size: positions.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(buffer, 0, positions);

Your getVertex function can read from any source (buffers, textures, procedural) and transform to clip space however you like.