Some people ask me: how do you get your WebGL lines so thicc? Well, I put a big chonky quad on each segment; that's how. Let me show you.
WebGL and OpenGL in general have a LINES primitive type, and the gl.lineWidth() method should allow setting the thickness of lines. In practice however, WebGL implementations often only allow a line width of 1 pixel. I.e. gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE) returns a range of [1,1].
This line rendering method uses a series of connected capsules of arbitrary thickness. The capsule shape gives each line segment the appearance of rounded joints and endcaps.
First, the input data for the line coordinates is defined. In this example, the input data is one-dimensional: each value represents the Y position at regularly-spaced intervals. This would be the case when drawing a graph, for example. Two-dimensional input data would work for this method as well.
const data = new Float32Array(32);
Assuming the input data is a TypedArray, this method allows the input data to be directly uploaded to the GPU without pre-processing in JavaScript. If you have a large volume of data, this can free up the main UI thread and keep your page responsive.
const dataBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, dataBuffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
Then set up a vertex/fragment shader:
const shaderProgram = gl.createProgram();
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
const [vertexShaderSource, fragmentShaderSource] = await Promise.all([
fetch('./vertex-shader.glsl').then(response => response.text()),
fetch('./fragment-shader.glsl').then(response => response.text()),
]);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(vertexShader);
gl.compileShader(fragmentShader);
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
throw `Unable to link shader program: ${gl.getProgramInfoLog(shaderProgram)}`;
}
gl.useProgram(shaderProgram);
Define the vertices for a single quad (rectangle). Instanced rendering will be used to draw this once per line segment:
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Int8Array([
// As a triangle fan. Any other primitive type would work though.
0, 1, // Top-left
1, 1, // Top-right
1, -1, // Bottom-right
0, -1, // Bottom-left
]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(attributes.vertexPosition);
gl.vertexAttribIPointer(
attributes.vertexPosition,
2, // Number of components
gl.BYTE,
0, // Stride
0, // Offset
);
For the vertex shader to determine the position and orientation of each quad, the data points for the starting and ending positions of each line segment are needed.
Instanced rendering allows each element of an arbitrary buffer to passed to the vertex buffer for each instance being drawn. The tricky part is that we need to give it access to both a "current" value and "next" value for each instance, so it can determine the start and ending positions of the line segment. To do this, we specify the source data as a vertex attribute twice, with the second having a +1 element offset.
gl.enableVertexAttribArray(attributes.dataValueCurrent);
gl.vertexAttribPointer(
attributes.dataValueCurrent,
1, // size (num values to pull from buffer per iteration)
gl.FLOAT, // type of data in buffer
false, // normalize
0, // stride, num bytes to advance to get to next set of values
0, // offset in buffer
);
gl.enableVertexAttribArray(attributes.dataValueNext);
gl.vertexAttribPointer(
attributes.dataValueNext,
1, // size (num values to pull from buffer per iteration)
gl.FLOAT, // type of data in buffer
false, // normalize
0, // stride, num bytes to advance to get to next set of values
4, // offset in buffer (4 bytes = 1 32-bit float)
);
// Specify that each element in the source data corresponds to each instance/quad (instead of each vertex).
gl.vertexAttribDivisor(attributes.dataValueCurrent, 1);
gl.vertexAttribDivisor(attributes.dataValueNext, 1);
Now the vertex shader has access to the starting and ending Y positions for each line segment. The X positions can be inferred from the index of the instance being drawn, aka gl_InstanceID.
The fragment shader draws an anti-aliased capsule shape on each quad:
With everything set up, rendering comes down to just one draw call:
gl.drawArraysInstanced(gl.TRIANGLE_FAN, 0, 4, data.length-1);
Overdraw: Each quad overlaps its neighbors, so line joints get drawn twice. The anti-aliased parts of the joints also get stacked, ending up darker on screen they should.