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.
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.
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:
position
is the position of the instance geometry vertexpointA
is the position of the first vertex of our line segmentpointB
is the position of the second vertex of our line segment 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 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).
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 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 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):
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):
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):
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):
Now for something a little more colorful. With careful consideration of drawing order, width, and color, we can render some handy line effects.
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
});
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
});
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
});
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.
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.
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.