home / twitter / github / rss

Instanced Line Rendering Part I

Rye Terrell
November 18, 2019

Native line rendering in WebGL leaves a lot to be desired. There’s inconsistency in the implementation, so you can’t trust that the line width you want will be available to you, for example. It’s also missing some pretty important features, like line joins and caps. For these reasons, quite a bit of work has been done to implement custom line rendering - summarized expertly by Matt Deslauriers in his excellent piece “Drawing Lines is Hard”.

Still, there’s one piece of the WebGL native line rendering that I rather like - the data structure. All you need to ship to the GPU are the vertices in your line segments or line strip - so one or two vertices per line segment. It’s easy to reason about and efficient to create, ship to the GPU, and update. With that in mind I’d like to present another line rendering technique for your consideration: instanced line rendering. It’s not quite as simple as native line rendering, but it’s very close, it’s very fast, and it’s a lot more flexible and featureful.

In this post, I’m going to review native line rendering, then present a few implementations of instanced line rendering, including one implementation that allows you to render a line, complete with caps and joins, in a single draw call.

TL;DR

Native WebGL Lines

We’ll start by taking a look at two primitives that can be used to draw lines natively in WebGL: GL_LINES and GL_LINE_STRIP. Given data of the form

vertexData = [x0, y0, x1, y1, x2, y2, ..., xn, yn];

GL_LINES will render a set of independent line segments where each segment is each sequential pair of points in your data:

The GL_LINES primitive.

GL_LINE_STRIP, on the other hand, will render a line segment between every vertex and its neighbors:

The GL_LINE_STRIP primitive.

I’ll be using my favorite WebGL library, regl, for all of the examples in this post. In regl, we define commands that wrap up all our shader code and render state into a single configurable function, called a draw command. Here’s what writing a simple GL_LINES draw command might look like.

First, we’ll create our regl context:

const REGL = require("regl");
const regl = REGL({ canvas: canvas });

Then the command - we’ll start with simple vertex and fragment shaders:

const glLines = regl({
  vert: `
    precision highp float;
    attribute vec2 position;
    uniform mat4 projection;

    void main() {
      gl_Position = projection * vec4(position, 0, 1);
    }`,

  frag: `
    precision highp float;
    uniform vec4 color;
    void main() {
      gl_FragColor = color;
    }`,

Then we’ll bring in the position attribute:

  attributes: {
    position: regl.prop("position")
  },

And the color and projection uniforms:

  uniforms: {
    color: regl.prop("color"),
    projection: regl.prop("projection")
  },

We’ll set the primitive to GL_LINES and the line width to a configurable property:

  primitive: "lines",
  lineWidth: regl.prop("width"),

And finally define the number of vertices we’ll be rendering and the viewport:

  count: regl.prop("count"),
  viewport: regl.prop("viewport")
});

Next, we need to generate the data to send to our command (in the position prop). The example code has a convenience function for generating this data that I won’t waste your time with here. Suffice it to say that the data is generated in the form described previously:

const vertexData = [x0, y0, x1, y1, x2, y2, ..., xn, yn];

And then wrapped in a regl.buffer before being shipped off to the regl command:

const buffer = regl.buffer(vertexData);

Now we have everything we need to invoke our regl command and render our lines:

glLines({
  position: buffer,
  count: vertexData.length,
  width: regl.limits.lineWidthDims[1],
  color: [0, 0, 0, 1],
  projection,
  viewport
});

Note that regl.limits.lineWidthDims[1] is the maximum line width supported by the client’s browser.

Here’s the result (mouse over to animate):

GL_LINES in action.

Unfortunately, I don’t really know what you’re seeing right now! As mentioned above, I can’t know (as I write this) what your implementation supports, for example, in terms of maximum line width. As of this writing, I see lines that are 10 pixels in width, which may or may not be what you see.

With a simple change, we can use the same code to render our data as a line strip:

  primitive: "line strip",

Which results in the following (mouse over to animate):

GL_LINE_STRIP in action.

Basic instanced line rendering

Let’s start by reimplementing what WebGL already gives us - some simple line segments and strips with no caps or joins. We’ll take it a little bit farther and add line width support.

In a nutshell, instancing is a technique you can use to render the same geometry lots of times, with slight variations to that geometry for each instance of it. So, if you wanted to render a million bunnies, each with their own color and rotation every frame, you’d want to use instancing. If you didn’t, you’d need to either a) create a single million-bunny geometry and ship it to the GPU every frame or b) execute a million draw calls every frame. Neither of those options are going to perform well. Instead, you’d create a single bunny geometry and a list of the colors and rotations for each bunny, each frame. Much, much faster.

In order to render our instanced lines, we’re going to need two things - a WebGL context that supports instancing, and an instance geometry for each segment. Here’s how we can create a regl context with the instancing extension:

const regl = REGL({ canvas: canvas, extensions: ["ANGLE_instanced_arrays"] });

Now the instance geometry - here’s what it looks like:

The instance geometry we’ll use for a line segment.

So, two triangles, six vertices, centered on the origin vertically, but shifted to the right one unit horizontally. The \(x\)-component will represent distance along the length of our line segment, and the \(y\)-component will represent a distance along its width. Let’s go ahead and define it:

const segmentInstanceGeometry = [
  [0, -0.5],
  [1, -0.5],
  [1,  0.5],
  [0, -0.5],
  [1,  0.5],
  [0,  0.5]
];

Let’s work on replicating GL_LINES behavior. We’ll start with the command, vertex shader first:

const interleavedSegments = regl({
  vert: `
    precision highp float;
    uniform float width;

We’ll pass in three attributes:

    attribute vec2 position, pointA, pointB;

And we’ll pass in our projection matrix as a uniform:

    uniform mat4 projection;

Now the brass tacks. Let’s start by calculating the vector from pointA to pointB:

    void main() {
      vec2 xBasis = pointB - pointA;

And then we’ll calculate the (normalized) perpendicular direction:

      vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x));

Recall that the \(x\)-component of position is along the length of our line segment. We’ll take our pointA endpoint and add the vector from pointA to pointB multiplied by position.x. Since position.x will be either zero or one, we’ll end up at either pointA or pointB. Then we’ll do the same thing for position.y (which is along the width of our line segment), but using the width of the line and the perpendicular direction. Adding those two offsets leaves us at the appropriate point in space for this vertex.

Transforming the instance geometry into the line segment.

      vec2 point = pointA + xBasis * position.x + yBasis * width * position.y;

And finally we’ll apply the projection:

      gl_Position = projection * vec4(point, 0, 1);
    }`,

The vertex shader was one of the two interesting bits of this command. Defining our attributes is the other. First we’ll add an attribute for our instanced geometry. Note that we set the divisor to zero, which indicates that this attribute is identical for every instance:

  attributes: {
    position: {
      buffer: regl.buffer(segmentInstanceGeometry),
      divisor: 0
    },

Next we’ll define the attributes for our line vertices. There’s a few things to note here. First, we set the divisor to one, which indicates that this attribute has one value per instance. Second, we set the offset and stride of each point attribute such that each instance receives the appropriate endpoints. Third, we’re using a single buffer for both attributes, with no unnecessarily duplicated data:

Using different offsets and strides allow us to reuse the same set of data for different attributes.

    pointA: {
      buffer: regl.prop("points"),
      divisor: 1,
      offset: Float32Array.BYTES_PER_ELEMENT * 0,
      stride: Float32Array.BYTES_PER_ELEMENT * 4
    },
    pointB: {
      buffer: regl.prop("points"),
      divisor: 1,
      offset: Float32Array.BYTES_PER_ELEMENT * 2,
      stride: Float32Array.BYTES_PER_ELEMENT * 4
    }
  },

We’ll pull in our width (which we can now precisely and confidently control), color, and projection uniforms:

  uniforms: {
    width: regl.prop("width"),
    color: regl.prop("color"),
    projection: regl.prop("projection")
  },

And define our vertex count which is the number of vertices in our instance geometry:

  count: segmentInstanceGeometry.length,

Then we’ll define how many instances we have, which is the number of segments:

  instances: regl.prop("segments"),

And finish up with the viewport:

  viewport: regl.prop("viewport")
});

Now we can invoke our command:

interleavedSegments({
  points: buffer,
  width: canvas.width / 18,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  segments: vertexData.length / 2
});

(Here we’ve named the command interleavedSegments because the \(x-\) and \(y-\) components of our data are interleaved into a single buffer. There are cases for which you would likely not want to organize your data this way - more on that in a bit.)

Here’s the result (mouse over to animate):

GL_LINES replicated with instanced line rendering. Note that we now have full control over the width.

With only a few minor changes, we can render line strips as well. We’ll make a new command with the more appropriate name interleavedStrip:

const interleavedStrip = regl({
  ...

and we’ll change the offset and stride of the point attributes so that each instance is getting the appropriate endpoints:

The attribute layout for line strips.

  attributes: {
    ...
    pointA: {
      buffer: regl.prop("points"),
      divisor: 1,
      offset: Float32Array.BYTES_PER_ELEMENT * 0
    },
    pointB: {
      buffer: regl.prop("points"),
      divisor: 1,
      offset: Float32Array.BYTES_PER_ELEMENT * 2
    }
  },

Everything else about the command will remain the same, including the vertex and fragment shaders.

Note that for the same data set, we’ll be bumping the number of segments, since a line strip will give us \(N - 1\) line segments instead of the \(N/2\) we got with our interleavedSegments command, where \(N\) is the number of vertices:

interleavedStrip({
  points: buffer,
  width: canvas.width / 18,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  segments: vertexData.length - 1
});

GL_LINE_STRIP replicated with instanced line rendering.

Line Caps and Joins

Line joins are the shapes used to join to line segments where they meet, while caps are the shapes used at the ends of lines.

There’s three common line joins - miter, round, and bevel, and three common line caps - butt, round, and square:

The various common caps and joins.

Note that we get butt caps for free, since that’s what the ends of our line segments already look like!

The strategy we’ll use for rendering lines with joins and caps is to render the lines, joins, and caps with independent instanced draw calls, all using the same attribute buffer - no duplicate data. For example, if we’re rendering a line strip with four segments, we’ll render

I’m employing this strategy for simplicity and consistency, but it may be possible to reduce the number of draw calls with vertex shaders more specific to your purpose. For example, you might be able to write a vertex shader that would render the segments and miter joins in a single draw call (though I’m dubious about also fitting the caps into it except in the case of round caps and round joins, which we’ll cover later).

Round Joins

For round joins, we’ll create an instance geometry in the shape of a circle, using the GL_TRIANGLE_FAN primitive. Here’s a simple function that generates the geometry buffer given a regl context and a resolution:

function circleGeometry(regl, resolution) {
  const position = [[0, 0]];
  for (wedge = 0; wedge <= resolution; wedge++) {
    const theta = (2 * Math.PI * wedge) / resolution;
    position.push([0.5 * Math.cos(theta), 0.5 * Math.sin(theta)]);
  }
  return {
    buffer: regl.buffer(position),
    count: position.length
  };
}

We’ll call the circleGeometry function:

const roundBuffer = circleGeometry(regl, 16);

Next we’ll create the render command. The vertex shader is trivial - it scales the geometry by the width and positions it. The only subtle piece here is the offset for the points attribute. We’ll skip the first point (since it’s an endpoint), and when we call the command, we’ll call it with \(N - 2\) instances, where \(N\) is the number of vertices in the line.

const roundJoin = regl({
  vert: `
      precision highp float;
      attribute vec2 position;
      attribute vec2 point;
      uniform float width;
      uniform mat4 projection;
  
      void main() {
        gl_Position = projection * vec4(width * position + point, 0, 1);
      }`,

  ...

  attributes: {
    position: {
      buffer: roundBuffer.buffer,
      divisor: 0
    },
    point: {
      buffer: regl.prop("points"),
      divisor: 1,
      offset: Float32Array.BYTES_PER_ELEMENT * 2
    }
  },

  primitive: "triangle fan",
  count: roundBuffer.count,
  instances: regl.prop("instances"),
});

Next we’ll invoke the command to render the round joins:

roundJoin({
  points: buffer,
  width: canvas.width / 18,
  color: [1, 0, 0, 1],
  projection,
  viewport,
  instances: vertexData.length - 2
});

And then we’ll draw our line strip the usual way:

interleavedStrip({
  points: buffer,
  width: canvas.width / 18,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  segments: vertexData.length - 1
});

Here’s the example in action (mouseover to animate, click to toggle join highlights):

Instanced line strip and round joins.

Miter Joins

Miter join geometry.

We’ll render two triangles for each miter join, as indicated by the highlighted area in the figure above. First we’ll define our miter join instance geometry, which isn’t so much a geometry as a set of coefficients we’ll use to index our miter basis vectors (more on that in a moment):

instanceMiterJoin = [
  [0, 0, 0],
  [1, 0, 0],
  [0, 1, 0],
  [0, 0, 0],
  [0, 1, 0],
  [0, 0, 1]
];

Next we’ll create our vertex shader. We’ll pass in the three vertices of the two neighboring line segments we’re considering, our instance geometry position, the line width, and the projection matrix:

const miterJoin = regl({
  vert: `
    precision highp float;
    attribute vec2 pointA, pointB, pointC;
    attribute vec3 position;
    uniform float width;
    uniform mat4 projection;

We’ll calculate the miter vector:

    void main() {
      vec2 tangent = normalize(normalize(pointC - pointB) + normalize(pointB - pointA));
      vec2 miter = vec2(-tangent.y, tangent.x);

Then we’ll find the two perpendicular vectors for each line:

      vec2 ab = pointB - pointA;
      vec2 cb = pointB - pointC;
      vec2 abNorm = normalize(vec2(-ab.y, ab.x));
      vec2 cbNorm = -normalize(vec2(-cb.y, cb.x));

Then we’ll determine the direction of the bend:

      float sigma = sign(dot(ab + cb, miter));

And use it to calculate the basis vectors for the miter geometry:

      vec2 p0 = 0.5 * width * sigma * (sigma < 0.0 ? abNorm : cbNorm);
      vec2 p1 = 0.5 * miter * sigma * width / dot(miter, abNorm);
      vec2 p2 = 0.5 * width * sigma * (sigma < 0.0 ? cbNorm : abNorm);

Finally we use the basis vectors and the position attribute coefficients to calculate the final vertex position:

      vec2 point = pointB + position.x * p0 + position.y * p1 + position.z * p2;
      gl_Position = projection * vec4(point, 0, 1);
    }`,

This time we’ll pass in three different views, again to the same buffer:

Reusing the line vertex buffer for rendering miter joins.

  attributes: {
    position: {
      buffer: regl.buffer(instanceMiterJoin),
      divisor: 0
    },
    pointA: {
      buffer: regl.prop("points"),
      divisor: 1,
      offset: Float32Array.BYTES_PER_ELEMENT * 0
    },
    pointB: {
      buffer: regl.prop("points"),
      divisor: 1,
      offset: Float32Array.BYTES_PER_ELEMENT * 2
    },
    pointC: {
      buffer: regl.prop("points"),
      divisor: 1,
      offset: Float32Array.BYTES_PER_ELEMENT * 4
    }
  },

And we’ll finish it off with the instance geometry count:

  count: instanceMiterJoin.length,
});

The rest of the command is identical to what we’ve used previously.

Finally we’ll invoke the interleavedStrip and miterJoin commands to render our line:

interleavedStrip({
  points: buffer,
  width: canvas.width / 18,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  segments: vertexData.length - 1
});

miterJoin({
  points: buffer,
  width: canvas.width / 18,
  color: [1, 0, 0, 1],
  projection,
  viewport,
  instances: vertexData.length - 2
});

Here’s the example in action (mouse over to animate, click to toggle join highlights):

Instanced line strip and miter joins.

Bevel Joins

Bevel joins are very similar to miter joins - we’ll be locating points \(p_0\) and \(p_1\) in the figure below and creating a triangle with the already-known point \(B\). First we’ll create the instance geometry, which again is less a geometry and more a set of coefficients we’ll use with our bevel basis vectors:

Bevel join geometry.

instanceBevelJoin = [[0, 0], [1, 0], [0, 1]];

Next we’ll start our bevel join command, which again will take the endpoints of the two segments as vertex attributes in the vertex shader:

const bevelJoin = regl({
  vert: `
  precision highp float;
  attribute vec2 pointA, pointB, pointC;
  attribute vec2 position;
  uniform float width;
  uniform mat4 projection;

As before, we’ll calculate a tangent vector and normal to it:

  void main() {
    vec2 tangent = normalize(normalize(pointC - pointB) + normalize(pointB - pointA));
    vec2 normal = vec2(-tangent.y, tangent.x);

Then we’ll calculate two perpendicular vectors for each segment:

    vec2 ab = pointB - pointA;
    vec2 cb = pointB - pointC;
    vec2 abn = normalize(vec2(-ab.y, ab.x));
    vec2 cbn = -normalize(vec2(-cb.y, cb.x));

And the direction of the bend:

    float sigma = sign(dot(ab + cb, normal));

And finally calculate the basis vectors for the bevel geometry and the final vertex position:

    vec2 p0 = 0.5 * sigma * width * (sigma < 0.0 ? abn : cbn);
    vec2 p1 = 0.5 * sigma * width * (sigma < 0.0 ? cbn : abn);
    vec2 point = pointB + position.x * p0 + position.y * p1;
    gl_Position = projection * vec4(point, 0, 1);
  }`,

Everything else is the same as the miter join command, we’ll just pass in the instanceBevelJoin geometry instead.

Here’s the example in action (mouse over to animate, click to toggle join highlights):

Round Caps

We’ll render solid circles for our round caps, and reuse the circle geometry from the round joins. The command is simple:

const roundCap = regl({
  vert: `
    precision highp float;
    attribute vec2 position;
    uniform vec2 point;
    uniform float width;
    uniform mat4 projection;

    void main() {
      gl_Position = projection * vec4(point + width * position, 0, 1);
  }`,

  frag: `
    precision highp float;
    uniform vec4 color;
    void main() {
      gl_FragColor = color;
    }`,

  attributes: {
    position: {
      buffer: roundBuffer.buffer
    }
  },

  uniforms: {
    point: regl.prop("point"),
    width: regl.prop("width"),
    color: regl.prop("color"),
    projection: regl.prop("projection")
  },

  primitive: "triangle fan",
  count: roundBuffer.count,
  viewport: regl.prop("viewport")
});

We’re not even using instanced rendering here, just throwing circles on the screen with a single draw call each. The position of each cap is no longer an attribute, but rather stored in the point uniform. Let’s render our caps first. Note that we pass the first and last vertex to the invocations of roundCap in the point uniform:

roundCap({
  point: vertexData[0],
  width: canvas.width / 18,
  color: [1, 0, 0, 1],
  projection,
  viewport
});

roundCap({
  point: vertexData[vertexData.length - 1],
  width: canvas.width / 18,
  color: [1, 0, 0, 1],
  projection,
  viewport
});

And finally we’ll render the line strip and bevel join:

interleavedStrip({
  points: buffer,
  width: canvas.width / 18,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  segments: vertexData.length - 1
});

bevelJoin({
  points: buffer,
  width: canvas.width / 18,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  instances: vertexData.length - 2
});

Since we’re rendering a full circle for the cap, there’s going to be some overdraw. If you’d like to avoid that, you can use a semicircle instead, just make sure you pass in information about the orientation of the semicircle as well.

Here’s what the round caps with bevel joins looks like (mouse over to animate, click to toggle cap highlights):

Square Caps

Next up is square caps. For these, we can’t get away with not orienting them, so we’ll need to pass in a little more data. For each cap, we’ll pass in the line endpoint (beginning or end), and the point immediately after or before it on the line, depending on which cap we’re rendering:

Square caps are oriented along the first and last line segments.

We’ll reuse segmentInstanceGeometry since it is already prepared in the way we need. Let’s take a look at the vertex shader. We’ll pass in the geometry position and points A and B:

const squareCap = regl({
  vert: `
      precision highp float;
      attribute vec2 position;
      uniform vec2 pointA, pointB;
      uniform float width;
      uniform mat4 projection;

Then we’ll calculate our basis vectors and multiply them by the width of the line and the geometry vertex position to recover the final vertex position:

      void main() {
        vec2 xBasis = normalize(pointB - pointA);
        vec2 yBasis = vec2(-xBasis.y, xBasis.x);
        vec2 point = pointB + xBasis * 0.5 * width * position.x + yBasis * width * position.y;
        gl_Position = projection * vec4(point, 0, 1);
    }`,

The rest of the command is as you’d expect:

  frag: `
      precision highp float;
      uniform vec4 color;
      void main() {
        gl_FragColor = color;
      }`,

  attributes: {
    position: {
      buffer: regl.buffer(segmentInstanceGeometry)
    }
  },

  uniforms: {
    pointA: regl.prop("pointA"),
    pointB: regl.prop("pointB"),
    width: regl.prop("width"),
    color: regl.prop("color"),
    projection: regl.prop("projection")
  },

  count: segmentInstanceGeometry.length,
  viewport: regl.prop("viewport")
});

Now we can invoke our command, once for each cap:

squareCap({
  pointA: vertexData[1],
  pointB: vertexData[0],
  width: canvas.width / 18,
  color: [1, 0, 0, 1],
  projection,
  viewport
});

squareCap({
  pointA: vertexData[vertexData.length - 2],
  pointB: vertexData[vertexData.length - 1],
  width: canvas.width / 18,
  color: [1, 0, 0, 1],
  projection,
  viewport
});

Here’s what square caps with bevel joins looks like (mouse over to animate, click to toggle cap highlights):

Special Case: Round Caps and Joins

If we want to render round caps and joins, we can organize our geometry such that we can render the line strip, joins, and caps all in a single draw call. I find this particularly satisfying for three reasons:

The first thing we’ll need is an instance geometry for each line segment. The approach we’ll take is to include the rectangular piece of the segment and a semicircle on both ends. Where line segments join, the two semicircles will form the join with no gaps, and will serve as the round caps at the ends of our line strip. Instead of storing just an x- and y- component, we’ll also use a z-component that will indicate which end of the line segment each vertex belongs to. If we move the vertices left and right according to the value of their z-component, the geometry will look like this:

An instance geometry for rendering round caps and joins in a single draw call.

Alright, let’s take a look at the code for creating the geometry. We’ll write a function that takes our regl context and a semicircle resolution, and we’ll return an attribute buffer and a vertex count. First we’ll create the simple rectangular section of our geometry:

function roundCapJoinGeometry(regl, resolution) {
  const instanceRoundRound = [
    [0, -0.5, 0],
    [0, -0.5, 1],
    [0, 0.5, 1],
    [0, -0.5, 0],
    [0, 0.5, 1],
    [0, 0.5, 0]
  ];

Note that each vertex is given three components instead of two. The third component describes which side of the line segment that vertex belongs to - zero for “left”, one for “right”. I’m using “left” and “right” here, but the term really means “proximity to the beginning of the data set”.

Then we’ll add the vertices for the “left” semicircle (Note that the vertices define \(z=0\)):

  for (let step = 0; step < resolution; step++) {
    const theta0 = Math.PI / 2 + ((step + 0) * Math.PI) / resolution;
    const theta1 = Math.PI / 2 + ((step + 1) * Math.PI) / resolution;
    instanceRoundRound.push([0, 0, 0]);
    instanceRoundRound.push([0.5 * Math.cos(theta0), 0.5 * Math.sin(theta0), 0]);
    instanceRoundRound.push([0.5 * Math.cos(theta1), 0.5 * Math.sin(theta1), 0]);
  }

And we’ll do the same thing for the “right” semicircle (Note that the vertices define \(z=1\)):

  for (let step = 0; step < resolution; step++) {
    const theta0 = (3 * Math.PI) / 2 + ((step + 0) * Math.PI) / resolution;
    const theta1 = (3 * Math.PI) / 2 + ((step + 1) * Math.PI) / resolution;
    instanceRoundRound.push([0, 0, 1]);
    instanceRoundRound.push([0.5 * Math.cos(theta0), 0.5 * Math.sin(theta0), 1]);
    instanceRoundRound.push([0.5 * Math.cos(theta1), 0.5 * Math.sin(theta1), 1]);
  }

Finally we’ll create the attribute buffer and return it with the vertex count:

  return {
    buffer: regl.buffer(instanceRoundRound),
    count: instanceRoundRound.length
  };
}

Now we’ll invoke our roundCapJoinGeometry function to build our instance geometry:

const roundCapJoin = roundCapJoinGeometry(regl, 16);

Then we can start on our command. There’s two pieces we need to look at - the vertex shader and the attributes. Let’s start with the vertex shader. We’ll pass in the usual suspects - the instance geometry vertex position, and pointA and pointB of our line vertices:

  const interleavedStripRoundCapJoin = regl({
    vert: `
      precision highp float;
      attribute vec3 position;
      attribute vec2 pointA, pointB;
      uniform float width;
      uniform mat4 projection;

In the logic of our vertex shader, we’ll calculate the normalized vector from pointA to pointB:

      void main() {
        vec2 xBasis = normalize(pointB - pointA);

… find a perpendicular vector to it:

        vec2 yBasis = vec2(-xBasis.y, xBasis.x);

Calculate the offset position from both pointA and pointB:

        vec2 offsetA = pointA + width * (position.x * xBasis + position.y * yBasis);
        vec2 offsetB = pointB + width * (position.x * xBasis + position.y * yBasis);

And then select the correct offset using the third component of the instance geometry before reporting the final gl_Position:

        vec2 point = mix(offsetA, offsetB, position.z);
        gl_Position = projection * vec4(point, 0, 1);
      }`,

The attributes are as you’d expect - zero divisor for the position attribute, one for the pointA and pointB attributes, and an appropriate offset for each:

    attributes: {
      position: {
        buffer: roundCapJoin.buffer,
        divisor: 0
      },
      pointA: {
        buffer: regl.prop("points"),
        divisor: 1,
        offset: Float32Array.BYTES_PER_ELEMENT * 0
      },
      pointB: {
        buffer: regl.prop("points"),
        divisor: 1,
        offset: Float32Array.BYTES_PER_ELEMENT * 2
      }
    },

Now my favorite part, wherein we render an entire line, complete with caps and joins - with a single draw call:

interleavedStripRoundCapJoin({
  points: buffer,
  width: canvas.width / 18,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  segments: vertexData.length - 1
});

Here it is in action (mouse over to animate, click to toggle cap & join highlights):

Decorative Lines

Now for something a little more colorful. With careful consideration of drawing order, width, and color, we can render some handy line effects.

Outlines

Outlines can be accomplished by rendering a thick line with a slimmer line on top, using the same data set for each. Render the thick line in the color you want the outline to be, and then the slimmer line with the interior color. For example, here’s an orange line with a black outline rendered with two draw calls:

interleavedStripRoundCapJoin({
  points: buffer,
  width: canvas.width / 18,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  segments: vertexData.length - 1
});

interleavedStripRoundCapJoin({
  points: buffer,
  width: canvas.width / 36,
  color: [1, 0.25, 0, 1],
  projection,
  viewport,
  segments: vertexData.length - 1
});

Custom Points

We can also render custom points on our lines at the joints and caps, an effect you may wish to use to highlight data points in a plot, for example. All we need is a simple command that can render our custom point geometry at our line joints:

const customPoints = regl({
  vert: `
  precision highp float;
  attribute vec2 position;
  attribute vec2 point;
  uniform float width;
  uniform mat4 projection;

  void main() {
    gl_Position = projection * vec4(width * position + point, 0, 1);
  }`,

  frag: `
  precision highp float;
  uniform vec4 color;
  void main() {
    gl_FragColor = color;
  }`,

  depth: {
    enable: false
  },

  attributes: {
    position: {
      buffer: regl.prop("pointGeometry"),
      divisor: 0
    },
    point: {
      buffer: regl.prop("points"),
      divisor: 1
    }
  },

  uniforms: {
    width: regl.prop("width"),
    color: regl.prop("color"),
    projection: regl.prop("projection")
  },

  primitive: regl.prop("pointPrimitive"),
  count: regl.prop("pointCount"),
  instances: regl.prop("instances"),
  viewport: regl.prop("viewport")
});

We’ll need a custom point instance geometry (here we create a diamond geometry):

const diamond = regl.buffer([0, -0.5, 0.5, 0, 0, 0.5, 0, -0.5, -0.5, 0, 0, 0.5]);

Then we just need to call our commands - line strip first, custom points second. Note that we can get away with no caps or joins if the interior diameter of our custom point is greater than or equal to the width of our line:

interleavedStrip({
  points: buffer,
  width: canvas.width / 36,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  segments: vertexData.length - 1
});

customPoints({
  pointGeometry: diamond,
  pointCount: 6,
  pointPrimitive: "triangles",
  points: buffer,
  width: canvas.width / 18,
  color: [1, 0.25, 0, 1],
  projection:,
  viewport:,
  instances: vertexData.length
});

Custom points and outlines

And we can of course combine these effects:

interleavedStrip({
  points: buffer,
  width: canvas.width / 36,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  segments: vertexData.length - 1
});

interleavedStrip({
  points: buffer,
  width: 4,
  color: [0, 1, 0.5, 1],
  projection,
  viewport,
  segments: vertexData.length - 1
});

customPoints({
  pointGeometry: diamond,
  pointCount: 6,
  pointPrimitive: "triangles",
  points: buffer,
  width: canvas.width / 18,
  color: [0, 0, 0, 1],
  projection,
  viewport,
  instances: vertexData.length
});

customPoints({
  pointGeometry: diamond,
  pointCount: 6,
  pointPrimitive: "triangles",
  points: buffer,
  width: canvas.width / 24,
  color: [0, 1, 0.5, 1],
  projection,
  viewport,
  instances: vertexData.length
});

Case Study: Fast Interactive Plot

For our first case study, we’ll take a look at an interactive plot. In this example, instead of interleaving the x- and y- coordinates of our data into a single buffer, we’ll separate them into an x-coordinate and one or more y-coordinate buffers. The reason we do this is because it’s nice to reuse the data for the x-coordinate instead of replicating it across multiple buffers, which would waste GPU memory and bandwidth.

Previously our data looked like this:

const data = [x0, y0, x1, y1, x2, y2...];

We’re going to split it out and add more y-coordinate buffers:

const dataX = [x0, x1, x2, ...];
const dataY0 = [y0, y1, y2, ...];
const dataY1 = [y0, y1, y2, ...];

This means we’ll need to change our attribute definitions a bit:

    attributes: {
      ...
      ax: {
        buffer: regl.prop("dataX"),
        divisor: 1,
        offset: Float32Array.BYTES_PER_ELEMENT * 0
      },
      ay: {
        buffer: regl.prop("dataY"),
        divisor: 1,
        offset: Float32Array.BYTES_PER_ELEMENT * 0
      },
      bx: {
        buffer: regl.prop("dataX"),
        divisor: 1,
        offset: Float32Array.BYTES_PER_ELEMENT * 1
      },
      by: {
        buffer: regl.prop("dataY"),
        divisor: 1,
        offset: Float32Array.BYTES_PER_ELEMENT * 1
      }
    },

We’ll need to update our vertex shader in a couple of ways. First we’ll combine the separated attributes defined above into two-dimensional points. Then we’ll convert our data points from whatever domain and range they exist in to screen space. Let’s take a look at both of those.

We’ll pass in the usual data, but this time pointA and pointB will be separated into ax, ay, bx, and by:

    vert: `
      precision highp float;
      attribute vec3 position;
      attribute float ax, ay, bx, by;
      uniform float width;
      uniform vec2 resolution;
      uniform mat4 projection;

We’ll merge our separate components into a single point, and then apply the projection matrix to transform them to clip space:

      void main() {
        vec2 clipA = (projection * vec4(ax, ay, 0, 1)).xy;
        vec2 clipB = (projection * vec4(bx, by, 0, 1)).xy;

Then we’ll transform them to screen space:

        vec2 offsetA = resolution * (0.5 * clipA + 0.5);
        vec2 offsetB = resolution * (0.5 * clipB + 0.5);

Then we’ll perform the same operations we performed before to map the instance geometry onto our line segment:

        vec2 xBasis = normalize(offsetB - offsetA);
        vec2 yBasis = vec2(-xBasis.y, xBasis.x);
        vec2 pointA = offsetA + width * (position.x * xBasis + position.y * yBasis);
        vec2 pointB = offsetB + width * (position.x * xBasis + position.y * yBasis);
        vec2 point = mix(pointA, pointB, position.z);

Finally we’ll convert our point back into clip space:

        gl_Position = vec4(2.0 * point/resolution - 1.0, 0, 1);
      }`,

We’ll name our new command noninterleavedStripRoundCapJoin and invoke it, once per line on our plot:

    for (let i = 0; i < nItems; i++) {
      noninterleavedStripRoundCapJoin({
        dataX,
        dataY: dataY[i],
        width: 3,
        color: colors[i],
        projection,
        resolution: [canvas.width, canvas.height],
        viewport: { x: 0, y: 0, width: canvas.width, height: canvas.height },
        segments: pointDataX.length - 1
      });
    }

Here’s the plot in action (left click and drag to translate, mouse wheel to zoom in and out):

A 500,000 point interactive plot rendered with instanced lines.

There’s five plot lines, each with 100,000 data points. That’s quite a bit of data, and there’s a pretty good chance it’s performant on your machine - I chose that dataset size to run at interactive speeds on an old chromebook I have.

Case Study: Fast Streaming Plot

Here’s another plotting example. Three hundred plot lines, each with 800 data points, with a new data point being added to each plot line each frame. Since we don’t need to do anything special with our data to render it, it’s fast and easy to update each buffer and render the lines.

We’ll keep track of “time” in the time variable:

let time = 0;
function renderLoop() {

And increment it in our loop:

  time++;

We’ll shift the x-coordinate (time axis) data to the left one point, add the time to the end, and update the GPU buffer:

  pointDataX.copyWithin(0, 1);
  pointDataX[buffer - 1] = time;
  dataX(pointDataX);

We’ll do the same thing for each of our 300 plot lines, but we’ll shift each one randomly up or down a bit each frame:

  for (const dy of pointDataY) {
    dy.array.copyWithin(0, 1);
    let y = dy.array[buffer - 2];
    y += noise * (2 * Math.random() - 1);
    y = Math.min(yMax, Math.max(yMin, y));
    dy.array[buffer - 1] = y;
    dy.data(dy.array);
  }

We’ll set up our projection matrix to place the current time and canvas.width previous frames of data into view:

  const projection = mat4.ortho(mat4.create(), time - canvas.width, time, yMin, yMax, 0, -1);

Then we’ll call our render command for each line:

  for (let i = 0; i < nItems; i++) {
    noninterleavedStripRoundCapJoin({
      dataX,
      dataY: pointDataY[i].data,
      width: 0.5,
      color: colors[i],
      projection,
      resolution: [canvas.width, canvas.height],
      viewport: { x: 0, y: 0, width: canvas.width, height: canvas.height },
      segments: buffer - 1
    });
  }

  requestAnimationFrame(renderLoop);
}

Mouse over the plot to observe the data stream in:

Updating 300 lines per frame, rendering 800 points per line.

Case Study: 3D Lines

Finally we’ll take a look at rendering lines in 3D. Nothing significant changes except some of the logic in our vertex shader. Let’s take a look.

We’ll pass in the usual attributes, but vec3 instead of vec2 for the line vertices this time. We’ll also add a per-vertex color for a bit more flair:

    vert: `
      precision highp float;
      attribute vec3 position;
      attribute vec3 pointA, pointB;
      attribute vec3 colorA, colorB;

The we’ll be adding the model and view matrices:

      uniform float width;
      uniform vec2 resolution;
      uniform mat4 model, view, projection;

We’re going to be interpolating color between vertices, so we’ll create a varying for that:

      varying vec3 vColor;

Next we’ll transform our vertices to clip space:

      void main() {
        vec4 clip0 = projection * view * model * vec4(pointA, 1.0);
        vec4 clip1 = projection * view * model * vec4(pointB, 1.0);

Then transform them to screen space:

        vec2 screen0 = resolution * (0.5 * clip0.xy/clip0.w + 0.5);
        vec2 screen1 = resolution * (0.5 * clip1.xy/clip1.w + 0.5);

Then we’ll expand the line segment as usual:

        vec2 xBasis = normalize(screen1 - screen0);
        vec2 yBasis = vec2(-xBasis.y, xBasis.x);
        vec2 pt0 = screen0 + width * (position.x * xBasis + position.y * yBasis);
        vec2 pt1 = screen1 + width * (position.x * xBasis + position.y * yBasis);
        vec2 pt = mix(pt0, pt1, position.z);

We’ll also interpolate our clip coordinates across position.z so that we can pull out interpolated z and w components.

        vec4 clip = mix(clip0, clip1, position.z);

Now we’ll recover our final position by converting our vertex back into clip space. Note we’re undoing the perspective divide we performed earlier:

        gl_Position = vec4(clip.w * ((2.0 * pt) / resolution - 1.0), clip.z, clip.w);

Finally we’ll interpolate the color and ship it to the fragment shader:

        vColor = mix(colorA, colorB, position.z);
      }`,

Here’s a simple N-body simulation to demonstrate 3D instanced lines in action (mouse over to animate, click to reset):

3D instanced lines used to plot an N-body simulation.

Final notes