· 12 min read

How to Draw Lines in WebGL

This article was auto-translated from Chinese. Some nuances may be lost in translation.

Recently, I wanted to create some interactive canvas effects. To make the visuals as dynamic as possible while taking advantage of GPU acceleration, WebGL and Three.js were natural choices. Writing code with Three.js is quite a pleasant experience, but I soon ran into a major roadblock: it’s surprisingly hard to freely draw a line in Three.js.

lineWidth Does Not Work

In Three.js, you can draw lines using LineBasicMaterial. By passing point coordinates into BufferGeometry, you can achieve the desired effect:

const points = [p1, p2, p3];
const material = new THREE.LineBasicMaterial( { color: 0x0000ff } );
const geometry = new THREE.BufferGeometry().setFromPoints( points );
const line = new THREE.Line(geometry, material);

Sounds pretty straightforward, right? However, once rendered on screen, you’ll notice the line is far thinner than expected—in fact, it’s strictly 1px. I initially assumed it was a bug, but upon checking the documentation, I found:

Due to limitations of the OpenGL Core Profile with the WebGL renderer on most platforms linewidth will always be 1 regardless of the set value.

In other words, even though OpenGL provides a lineWidth API, most platforms (almost all major browsers) ignore this call and default to 1.

To verify my understanding, here is an example written in pure WebGL:

See the Pen webGL-line by 愷開 (@kjj6198) on CodePen.

function main() {
  var gl = initGL();
  var shaderProgram = initShaders(gl);
  var vertices = createPoints(gl, shaderProgram);
  gl.lineWidth(100.0);
  draw(gl, vertices);
}

As you can see, even after calling gl.lineWidth(100.0), the rendered line width is still just 1. Nowadays, screens are 4K or higher, and a 1px line looks terribly thin and jagged. Clearly, relying on gl.LINE_STRIP or gl.LINE won’t work (unless that’s specifically the aesthetic you’re after).

Checking MDN’s documentation confirms this limitation:

The maximum minimum width is allowed to be 1.0. The minimum maximum width is also allowed to be 1.0. Because of these implementation defined limits it is not recommended to use line widths other than 1.0 since there is no guarantee any user’s browser will display any other width.

Ironically, the only platform that actually implemented lineWidth was IE11—how’s that for a twist?

Using Other Geometries

Since Line doesn’t cut it, could we brute-force it with PlaneGeometry or ShapeGeometry?

Technically yes, but built-in geometries have predefined vertices, making dynamic adjustments cumbersome. Moreover, if we’re just drawing lines, I’d prefer keeping the vertex count to an absolute minimum.

Searching through the Three.js documentation, there didn’t seem to be any built-in Line variant with customizable line width. I was quite surprised by this lack of support—doesn’t everyone need to draw lines at some point?

Other Libraries

Although there is no built-in primitive, Three.js does provide a fat-line example that supports custom widths and even dashed lines. I initially considered adopting it directly, but integrating it into my current setup felt a bit overkill.

Another option is THREE.MeshLine. It looks feature-complete and straightforward to implement. However, its coding style is somewhat dated, and it offers far more features than what I actually need.

GitHub THREE MeshLine

Summing up my requirements, all I need is:

  • Feed in points and draw a line
  • Adjustable width
  • Adjustable color

So, I decided to build one myself.

Solution: Forming Surfaces from Points

Given three points, drawing a line connecting them is straightforward—you simply join the points in sequence. In fact, that’s exactly how gl.LINE_STRIP or gl.LINE works. But as mentioned earlier, lineWidth is locked to 1. To expand the line’s thickness, we must construct surfaces using gl.TRIANGLE instead.

For the vector formed by every two points, we can compute its normal vector and offset two points perpendicularly (above and below):

Here, the gray dots represent the target vertex positions, which are then passed as vertices to the vertex shader.

This keeps the vertex count to a minimum while giving us full control over line width.

Implementation

Given two points P∗1=(0,0)P*1=(0,0) and P∗2=(1,1)P*{2}=(1,1) , they can be represented by a linear equation: x−y=0x-y=0. What we need to do is find a vector perpendicular to this line passing through P1 and P2—that is, the normal vector.

Let’s start with P1: we find the vector P12=(1,1)P_{12}=(1, 1), whose normal vector is (−1,1)(-1, 1). We can then offset one point along the positive and negative directions of the normal, at a distance of lineWidth / 2. We do the same for P2, offsetting one point in each direction. This gives us four points in total, forming a quad (two triangles).

As an aside, a triangle is drawn only after the vertex shader is invoked three times, meaning two triangles normally require 6 vertex coordinates. To avoid wasting memory on duplicate vertices, we use an index array to instruct WebGL how to access the vertex coordinates.

const indices = [0, 1, 2, 2, 1, 3]; // Tells WebGL the order to access vertices
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint8Array(indices), gl.STATIC_DRAW);

Method 1: Computing the Normal for Each Point

For every two points, find the vector, determine its normal vector, and offset upward and downward by the segment length. Here is the result:

As you can see, this simple approach works reasonably well. However, problems arise at joints and corners. When we add bends to the line, the corner rendering is clearly incorrect:

Adding color makes the issue even more apparent:

You can see the line pinched and collapsed completely at the joints.

Handling Joints and Corners

The root problem is that our current implementation only relies on the normal computed from the current point to the next point. While this ensures the two offset points are perpendicular to that single segment, it doesn’t give us the result we actually want.

Here, we assume all vectors are normalized (unit vectors with a length of 1).

In this diagram, the points we actually want to sample are the upper and lower points along vector A, but the previous implementation used the two points from vector B instead. As a result, the subsequent vertex positions were calculated incorrectly.

At corners, we cannot simply take the normal of the next vector. Instead, we must rely on the normal derived from the sum of both the previous and next vectors.

Normal of A+B

Originally, calculating the line width along the perpendicular component was just a simple multiplication. But because the corner angle is now governed by the normal of A + B, we must separately calculate the length of the perpendicular component projected onto this normal vector.

The projected length is calculated using the dot product of the two vectors. At this point, we can formalize our line-drawing strategy:

  • Find the vector between the current point and the previous point (Vector A), and between the current point and the next point (Vector B).
  • Calculate the normal of A and the normal of (A + B).
  • Calculate the projection of the (A + B) normal onto Vector A’s normal.
  • Divide the line width by the projected length.
  • Offset one point upward and one point downward, each by half of the adjusted line width.

Writing the Vertex Shader

The projection calculation could be done in JavaScript, but since we have WebGL, we might as well write it directly in a shader:

uniform vec3 uColor;
uniform float uLineWidth;
attribute vec3 lineNormal;
varying vec3 vColor;

void main() {
  float width = (uLineWidth / lineNormal.z / 2.0);
  vec3 pos = vec3(position + vec3(lineNormal.xy, 0.0) * width);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
  vColor = uColor;
}

This example is implemented with Three.js. Some parameters are provided by Three.js’s built-in WebGL programs; you can check the official WebGLProgram documentation for more details.

Here, I passed several attributes and uniforms: position (original vertex coordinates), lineNormal (the computed normal vector and projection length), vColor (color), and uLineWidth (line width).

Implementation: BufferGeometry + ShaderMaterial

This implementation uses Three.js directly. However, the underlying principles are identical, so writing it in pure canvas + WebGL would work just as well.

BufferGeometry

export default class MyLineGeometry extends BufferGeometry {
  constructor(points) {
    super()
    const lineNormal = []
    const vertices = []
    const indices = []
    const last = points[points.length - 1]
    let currentIdx = 0

    points.forEach((p, index) => {
      if (index <= points.length - 2) {
        indices[index * 6 + 0] = currentIdx
        indices[index * 6 + 1] = currentIdx + 1
        indices[index * 6 + 2] = currentIdx + 2
        indices[index * 6 + 3] = currentIdx + 2
        indices[index * 6 + 4] = currentIdx + 1
        indices[index * 6 + 5] = currentIdx + 3
        currentIdx += 2
      } else if (points.length === 2 && index === 0) {
        indices[index * 6 + 0] = currentIdx
        indices[index * 6 + 1] = currentIdx + 1
        indices[index * 6 + 2] = currentIdx + 2
        indices[index * 6 + 3] = currentIdx + 2
        indices[index * 6 + 4] = currentIdx + 1
        indices[index * 6 + 5] = currentIdx + 3
        currentIdx += 2
      }

      vertices.push(p[0], p[1], 0)
      vertices.push(p[0], p[1], 0)
    })

    for (let i = 1; i < points.length; i++) {
      const point = points[i]
      const prev = points[i - 1]
      const next = points[i + 1] || null

      const a = new Vector2(point[0] - prev[0], point[1] - prev[1]).normalize()
      if (i === 1) { // first point
        lineNormal.push(-a.y, a.x, 1)
        lineNormal.push(-a.y, a.x, -1)
      }

      if (!next) {
        lineNormal.push(-a.y, a.x, 1)
        lineNormal.push(-a.y, a.x, -1)
      } else {
        const b = new Vector2(next[0] - point[0], next[1] - point[1]).normalize().add(a)
        const c = new Vector2(-b.y, b.x)
        const projection = c.clone().dot(new Vector2(-a.y, a.x).normalize())
        lineNormal.push(c.x, c.y, projection)
        lineNormal.push(c.x, c.y, -projection)
      }
    }

    this.setAttribute('position', new BufferAttribute(new Float32Array(vertices), 3));
    this.setAttribute('lineNormal', new BufferAttribute(new Float32Array(lineNormal), 3)); // [x, y, projection]
    this.setIndex(new BufferAttribute(new Uint16Array(indices), 1));
    const indexAttr = this.getIndex()
    this.position.needsUpdate = true;
    indexAttr.needsUpdate = true;
  }
}

Fragment Shader

varying vec3 vColor;
varying vec2 vUv;
void main() {
  float dist = length(vUv - 0.5);
  vec3 color = vColor;
  if (dist > 0.1) {
    color = smoothstep(dist - 0.02, dist, vUv.y) * color;
  }			
  gl_FragColor = vec4(color, 1.0);
}

The ShaderMaterial implementation:

import { shaderMaterial } from "@react-three/drei";
import { Color, DoubleSide } from "three";

const MyLineShaderMaterial = shaderMaterial(
  {
    uColor: new Color(0.0, 0.0, 0.0, 1.0),
    uLineWidth: 10,
  },
  vertexShader,
  fragmentShader,
  (material) => material.side = DoubleSide
)

export default MyLineShaderMaterial;

Here I used @react-three/drei as a wrapper, but standard Three.js achieves the exact same result.

Results

Lines with Corners

Sine Wave

Success! A WebGL line with custom width is finally born.

To be honest, the current implementation isn’t ideal because changing point positions requires calling new BufferGeometry each time. In reality, we only need to update position and lineNormal. The next optimization step is allowing the geometry to update without having to recreate the instance.

The Next Challenge: Anti-aliasing

If you look closely at the lines, you’ll notice some aliasing (depending on the shape of the line). Without the browser smoothing things out automatically, anti-aliasing in WebGL must be handled manually.

Aliasing occurs because the edges of a line cannot completely fill a whole pixel, yet the screen’s minimum rendering unit is 1 pixel, leading to jagged edges.

In our line-drawing scenario, there are several ways to tackle anti-aliasing:

  • Use the fragment shader to render a smooth edge for the line
  • Handle it directly via texture mapping
  • Implement Prefiltered lines

My current implementation uses a fragment shader. Since Three.js handles UVs for us, it’s easy to calculate the boundaries based on vertex coordinates:

varying vec3 vColor;
varying vec2 vUv;
void main() {
  float dist = length(vUv - 0.5);
  vec3 color = vColor;
  float progress = smoothstep(1.0 - 0.03, 1.0, 1.0 - dist);
  if (dist > 0.9) {
    vec3 col = mix(color, vec3(1, 1, 1), progress);
    gl_FragColor = vec4(col, 1.0);
  } else {
    gl_FragColor = vec4(color, 1.0);
  }
}

However, the visual difference doesn’t look significant; I’m not sure if Three.js applies any other processing under the hood.

Conclusion

Drawing a line freely turned out to be far trickier than I anticipated. I originally assumed existing implementations could be plugged in directly, but they felt overly complex. Diving in to implement it myself revealed just how deep the rabbit hole really goes.

There are other implementation details worth exploring, but the current solution already satisfies my needs. Documenting them here for future reference:

  • Rounded line caps/joins → requires designing additional vertex coordinates at corners
  • Different colors per line segment
  • Varying thickness along the line segment

Related Posts

Explore Other Topics