Drawing lines with WebGL is even more difficult than I imagined.

Written byKalanKalan
💡

If you have any questions or feedback, pleasefill out this form

This post is translated by ChatGPT and originally written in Mandarin, so there may be some inaccuracies or mistakes.

Recently, I've been looking to create some interactive effects with the canvas. To make the effects as vivid as possible and to leverage GPU acceleration, WebGL and Three.js naturally became my top choices. Writing in Three.js is quite intuitive, but I quickly encountered a significant issue: it's quite challenging to draw a line freely with Three.js.

lineWidth Doesn't Work

In Three.js, you can draw lines using LineBasicMaterial. After passing the positions of the points 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 the scene is rendered, you will notice that the thickness of the line is much thinner than expected—it's actually only 1px. Initially, I thought it was a bug, but after diving deeper into the documentation, I found this statement:

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.

This means that although OpenGL provides a lineWidth API, it is ignored on most platforms (almost all major browsers) and is set to 1.

To test my understanding, I created a pure WebGL example:

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);
}

You will notice that despite calling gl.lineWidth(100.0), the displayed line width is still only 1. With modern screens starting at 4K resolution, a 1px line looks quite unattractive, so using gl.LINE_STRIP or gl.LINE is clearly not a viable option (unless that’s the effect you’re aiming for).

Later, I checked the MDN documentation, which describes the same limitations:

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.

Interestingly, the only platform that implements lineWidth is IE11, which is quite disheartening.

Using Other Geometries

Since drawing with Line isn't feasible, can we force it to work with PlaneGeometry or ShapeGeometry?

While it is technically possible, built-in geometries often come with pre-defined vertices, making adjustments a bit more complicated. If I'm only trying to draw lines, I’d prefer to minimize the number of vertices.

Looking through the Three.js documentation, it seems there isn't a customizable lineWidth option for lines. I was quite surprised that there’s no straightforward solution for drawing lines—doesn’t anyone have simple line-drawing requirements?

Other Libraries

Although there isn't built-in support from the official library, the Three.js fat-line example actually shows how to adjust the width and even add dashes. I originally considered integrating this directly, but it seemed a bit overkill for my current implementation.

Another option is THREE.Meshline, which appears to offer a complete set of features and is relatively easy to implement. However, besides its somewhat outdated coding style, it doesn't provide all the functionality I need.

GitHub THREE MeshLine

To summarize my requirements, I only need:

  • To draw lines after sending points
  • The ability to adjust width
  • The ability to adjust color

Thus, I decided to write my own solution.

Solution: Forming Surfaces from Points

Given three points, connecting them to form a line is quite simple—you just connect them. In fact, this is how gl.LINE_STRIP or gl.LINE works. However, as mentioned, lineWidth is limited to 1, so we need to find a way to expand the width, which means we must use gl.TRIANGLE to draw and adjust the width.

We can calculate the normal vector from the vector formed by every two points and take two additional points above and below:

In this case, the gray points represent the desired vertex positions, and we will send these four points as vertices to the vertex shader.

This way, we ensure the number of points is minimized while also achieving control over the width.

Implementation

Given two points P1=(0,0)P*1=(0,0) and P2=(1,1)P*{2}=(1,1), we can represent them with a line equation: xy=0x-y=0. What we need to do is find the vector perpendicular to this equation that passes through P1 and P2, which is the normal vector.

Let's start with P1. We first find the vector P12=(1,1)P_{12}=(1, 1), and the normal vector becomes (1,1)(-1,1). We can find one point each above and below (or in positive and negative direction) at a distance of lineWidth / 2; we do the same for P2 to find points in both directions. This results in a total of four points, which can form a surface (two triangles).

It’s worth mentioning that a triangle is drawn only after the vertex shader is called three times, so we need 6 vertex coordinates to draw two triangles. To avoid duplicating vertex storage and wasting space, we will use an index array to tell 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: Finding Normals for Each Point

For every two points, we find the vector, and after determining the normal vector, we calculate the lengths of the line segments above and below. The result is as follows:

This simple method proves to be quite effective; however, problems arise at the points where there are bends. We can see that the handling of the bends in the lines does not seem to work correctly:

Coloring the lines makes the issue even more apparent.

You can see that the lines at the bends shrink significantly.

Handling Bends

The issue arises because the current implementation relies solely on the normal vector formed by the current point and the next point, which guarantees that the two upper and lower points will be perpendicular. However, upon reflection, this is not the desired outcome.

For this discussion, we assume all vectors are unit vectors, meaning their lengths are 1.

In this image, we want to take the positions of the points above and below vector A, but the previous implementation takes points from vector B, leading to incorrect vertex positions in subsequent calculations.

At the bends, we cannot rely solely on the normal vector of the next vector; instead, we need to base our calculations on the combined normal vector of the vectors before and after.

Normal Vector of A+B

Originally, the line width would merely require direct multiplication for the vertical component, but since the angle now depends on the combined normal vector of A+B, we need to compute the length of the vertical component's projection onto the normal vector.

The calculation of the projection length involves taking the dot product of the two vectors. With this, we can establish a strategy for drawing lines.

  • Determine the vector between the current point and the previous point, and the vector between the current point and the next point; let’s call them vectors A and B.
  • Calculate the normal vector of A and the normal vector of A+B.
  • Compute the projection length of the normal vector of A+B onto vector A.
  • Subtract the projection length from the line segment length.
  • Take points above and below, each at a distance equal to half the line segment length.

Writing the Vertex Shader

The calculation of the projection length can be done directly in JavaScript, but since we have WebGL at our disposal, let's implement it in the 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 using Three.js, and some parameters are provided by the built-in WebGL program of Three.js. You can check the official documentation on WebGLProgram for more details.

Here, I've passed a few attributes: position (the original point), lineNormal (the calculated normal vector), vColor (the color), and uLineWidth (the line width).

Implementation: BufferGeometry + ShaderMaterial

This implementation uses Three.js directly. However, the underlying principles remain the same, and it should also work fine with pure canvas + WebGL.

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);
}

Implementation of ShaderMaterial:

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;

In this case, I used react-three-fiber for wrapping, but standard Three.js can achieve the same effect.

Showcase Results

Lines with Bends

Sine Wave

Success! A WebGL line with customizable width has finally been created.

However, the current implementation isn't ideal, as it requires calling new BufferGeometry again whenever the point positions change. We only need to update the position and lineNormal, so the next optimization direction is to allow the geometry to update without needing to instantiate a new object.

Next Challenge: Anti-Aliasing

If you closely observe the lines, you'll notice that the jagged edges are quite pronounced (depending on the shape of the lines). Without browser assistance, in WebGL, we have to implement anti-aliasing ourselves.

The cause of jagged edges is that some line edges do not fill an entire pixel, but the smallest rendering unit on a screen is 1 pixel, leading to this edge aliasing.

In our line-drawing scenario, there are a few methods to handle anti-aliasing:

  • Use a fragment shader to draw a smooth edge for the lines
  • Directly use texture mapping
  • Implement Prefiltered lines handling

Currently, my implementation uses a fragment shader; Three.js already helps us handle UV, making it easy to calculate the boundaries through the vertices.

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, it seems the effect isn't significantly different, and I'm unsure if Three.js is applying any additional processing.

Conclusion

Drawing a line freely is more complicated than I initially imagined. I thought there would be existing implementations that I could directly use, but those were overly complex. After jumping in to implement my own solution, I realized the depth of the challenge.

There are additional implementation details that could be considered. However, the current implementation meets my needs, and I wanted to document this as a note:

  • Line segments can be made round → additional vertex coordinates need to be designed at bends.
  • Each line segment can have different colors.
  • Variations in thickness within line segments.

If you found this article helpful, please consider buying me a coffee ☕ It'll make my ordinary day shine ✨

Buy me a coffee