DEV Community

loading...

Graphing with Web Components 2: WebGL

ndesmic
I like to make fun web things from scratch. Ideally build-less, framework-less, infrastructure-less and free from the annoyances of my day job.
・11 min read

In the last part we took a loot at graphing scatterplots and line graphs with SVG. If that seemed a little too trivial we'll try using WebGL this time. This post will not cover all the minutia of using WebGL, I have other posts on that you can use but we'll try to keep it as minimal as possible.

Boilerplate

I'm basically just going to rip out the render method of the SVG chart but keep all the same attributes. For now I'm going to drop the "shape" part of the tuple because this becomes a little more complicated than I want it to be but we can examine that later maybe.

function hyphenCaseToCamelCase(text) {
    return text.replace(/-([a-z])/g, g => g[1].toUpperCase());
}

class WcGraphGl extends HTMLElement {
    #points = [];
    #width = 320;
    #height = 240;
    #xmax = 100;
    #xmin = -100;
    #ymax = 100;
    #ymin = -100;
    #func;
    #step = 1;
    #thickness = 1;
    #continuous = false;

    #defaultSize = 2;
    #defaultColor = "#F00"

    static observedAttributes = ["points", "func", "step", "width", "height", "xmin", "xmax", "ymin", "ymax", "default-size", "default-color", "continuous", "thickness"];
    constructor() {
        super();
        this.bind(this);
    }
    bind(element) {
        element.attachEvents.bind(element);
    }
    render() {
        if (!this.shadowRoot) {
            this.attachShadow({ mode: "open" });
        }

    }
    attachEvents() {

    }
    connectedCallback() {
        this.render();
        this.attachEvents();
    }
    attributeChangedCallback(name, oldValue, newValue) {
        this[hyphenCaseToCamelCase(name)] = newValue;
    }
    set points(value) {
        if (typeof (value) === "string") {
            value = JSON.parse(value);
        }

        value = value.map(p => ({
            x: p[0],
            y: p[1],
            color: p[2] ?? this.#defaultColor,
            size: p[3] ?? this.#defaultSize
        }));

        this.#points = value;

        this.render();
    }
    get points() {
        return this.#points;
    }
    set width(value) {
        this.#width = parseFloat(value);
    }
    get width() {
        return this.#width;
    }
    set height(value) {
        this.#height = parseFloat(value);
    }
    get height() {
        return this.#height;
    }
    set xmax(value) {
        this.#xmax = parseFloat(value);
    }
    get xmax() {
        return this.#xmax;
    }
    set xmin(value) {
        this.#xmin = parseFloat(value);
    }
    get xmin() {
        return this.#xmin;
    }
    set ymax(value) {
        this.#ymax = parseFloat(value);
    }
    get ymax() {
        return this.#ymax;
    }
    set ymin(value) {
        this.#ymin = parseFloat(value);
    }
    get ymin() {
        return this.#ymin;
    }
    set func(value) {
        this.#func = new Function(["x"], value);
        this.render();
    }
    set step(value) {
        this.#step = parseFloat(value);
    }
    set defaultSize(value) {
        this.#defaultSize = parseFloat(value);
    }
    set defaultColor(value) {
        this.#defaultColor = value;
    }
    set continuous(value) {
        this.#continuous = value !== undefined;
    }
    set thickness(value) {
        this.#thickness = parseFloat(value);
    }
}

customElements.define("wc-graph-gl", WcGraphGl);

Enter fullscreen mode Exit fullscreen mode

Setting up the canvas

connectedCallback(){
    this.attachShadow({ mode: "open" });
    this.canvas = document.createElement("canvas");
    this.shadowRoot.appendChild(this.canvas);
    this.canvas.height = this.#height;
    this.canvas.width = this.#width;
    this.context = this.canvas.getContext("webgl2");
    this.render();
    this.attachEvents();
}
Enter fullscreen mode Exit fullscreen mode

Nothing fancy. We setup everything on connectedCallback so we can re-use it later. I'm also using WebGL2 which should be supported in all browsers now.

Shader Boilerplate

WebGL has a lot of boilerplate so lets get to it.

function compileShader(context, text, type) {
    const shader = context.createShader(type);
    context.shaderSource(shader, text);
    context.compileShader(shader);

    if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
        throw new Error(`Failed to compile shader: ${context.getShaderInfoLog(shader)}`);
    }
    return shader;
}

function compileProgram(context, vertexShader, fragmentShader) {
    const program = context.createProgram();
    context.attachShader(program, vertexShader);
    context.attachShader(program, fragmentShader);
    context.linkProgram(program);

    if (!context.getProgramParameter(program, context.LINK_STATUS)) {
        throw new Error(`Failed to compile WebGL program: ${context.getProgramInfoLog(program)}`);
    }

    return program;
}

render(){
  if(!this.context) return;

  const vertexShader = compileShader(this.context, `
    attribute vec2 aVertexPosition;
    void main(){
        gl_Position = vec4(aVertexPosition, 1.0, 1.0);
                gl_PointSize = 10.0;
    }
`, this.context.VERTEX_SHADER);
  const fragmentShader = compileShader(this.context, `
    void main() {
        gl_FragColor = vec4(1.0, 0, 0, 1);
    }
`, this.context.FRAGMENT_SHADER);
  const program = compileProgram(this.context, vertexShader, 
  fragmentShader)
  this.context.useProgram(program);
}

Enter fullscreen mode Exit fullscreen mode

If we don't have a context, which can happen if an attribute change triggers a render before we've attached to some DOM, we just abort. We continue in render to use the context and set up a basic vertex and fragment shader, compile them, link them, and then compile the program. We won't go into the the details here but this is the minimum we need to get started. The one interesting new thing here is gl_PointSize. This controls the size of the dots that are drawn.

Vertices

//setup vertices
const positionBuffer = this.context.createBuffer();
this.context.bindBuffer(this.context.ARRAY_BUFFER, positionBuffer);
const positions = new Float32Array(this.#points.flat());
this.context.bufferData(this.context.ARRAY_BUFFER, positions, this.context.STATIC_DRAW);
const positionLocation = this.context.getAttribLocation(program, "aVertexPosition");
this.context.enableVertexAttribArray(positionLocation);
this.context.vertexAttribPointer(positionLocation, 3, this.context.FLOAT, false, 0, 0);
Enter fullscreen mode Exit fullscreen mode

We create a buffer with our points, associate it with attribute aVertexPosition. Again lots of song and dance but we need all of it.

Drawing

Drawing is at least easier. We clear the screen and then tell it to draw everything as points:

this.context.clear(this.context.COLOR_BUFFER_BIT | this.context.DEPTH_BUFFER_BIT);
this.context.drawArrays(this.context.POINTS, this.#points.length, this.context.UNSIGNED_SHORT, 0);
Enter fullscreen mode Exit fullscreen mode

This won't work for a few reasons though. If you hardcode this.#points to

[
-1.0, -1.0,
1.0, -1.0,
1.0, 1.0,
-1.0, 1.0
]
Enter fullscreen mode Exit fullscreen mode

And set the the length of drawArrays to 4 (final parameter) you should see 4 red dots in the corners of the canvas. This is sufficient to see if you made any syntax mistakes so far.

Vertex Colors

Firstly is that our points are not the right shape. We have an array of struct objects representing the points but what we actually want is a series of vectors. We can change this pretty easily in set points but we run into a problem with the color. In the SVG chart we could use any DOM color (like "blue") because it was using the DOM to render it. In WebGL we don't have those, we just have vec4s of floats. Converting DOM colors to RGB is actually a lot harder than you think and usually means hackery so we'll have to drop that. What we can do is either use a series of floats or we can convert it from hex values (conversion from other formats is non-trivial). Let's start with a series of floats because it's easy.

So { x, y, color, size } becomes [x,y,r,g,b,size]

But we have another problem. The biggest vector we can pass to WebGL is a vec4 and we have 6 components. We need to split it up into two vectors. Color and x,y,size. We could also split off size but that'll just create more overhead so I'm going to squish it in to the existing buffer since we have extra space.

Let's rebuild set points:

set points(value) {
    if (typeof (value) === "string") {
        value = JSON.parse(value);
    }
    this.#points = value.map(p => [
        p[0],
        p[1],
        p[6] ?? this.#defaultSize
    ]);
    this.#colors = value.map(p => p.length > 2 ? [
        p[2],
        p[3],
        p[4],
        p[5]
    ] : this.#defaultColor);
    this.render();
}
Enter fullscreen mode Exit fullscreen mode

There's other ways you could approach how to input things but I thought a flat-array was best. But maybe you could nest the color array. In any case we pull out the x,y and size to one array and the color into another or if the input doesn't have a color we use the default. This means defaultColor has to also be a 4 value array so be sure to update that. But also, since we're dealing with alpha the 4th element should be 1 or you may be frustrated when things don't appear. There's also a lot of validation that could happen here, I won't do it for code size but you may if you wish.

Then we need to copy-paste the vertex code and update it to use this.#colors:

//setup color
const colorBuffer = this.context.createBuffer();
this.context.bindBuffer(this.context.ARRAY_BUFFER, colorBuffer);
const colorsArray = new Float32Array(this.#colors.flat());
this.context.bufferData(this.context.ARRAY_BUFFER, colorsArray, this.context.STATIC_DRAW);
const colorLocation = this.context.getAttribLocation(program, "aVertexColor");
this.context.enableVertexAttribArray(colorLocation);
this.context.vertexAttribPointer(colorLocation, 4, this.context.FLOAT, false, 0, 0);
Enter fullscreen mode Exit fullscreen mode

Please note that we also need to update to use this.#colors.flat() and this.#points.flat() because the buffers are flat series of values. When assigning the attributes make sure colors uses size 4, and update the name to aVertextColor (or whatever you want to call it) and update aVertexPosition to be size 3.

Then we can update the shaders:

//Setup WebGL shaders
const vertexShader = compileShader(this.context, `
    attribute vec3 aVertexPosition;
    attribute vec4 aVertexColor;
    varying mediump vec4 vColor;
    void main(){
        gl_Position = vec4(aVertexPosition.xy, 1.0, 1.0);
        gl_PointSize = aVertexPosition.z;
        vColor = aVertexColor;
    }
`, this.context.VERTEX_SHADER);
const fragmentShader = compileShader(this.context, `
    varying mediump vec4 vColor;
    void main() {
        gl_FragColor = vColor;
    }
`, this.context.FRAGMENT_SHADER);
Enter fullscreen mode Exit fullscreen mode

We have our 3-valued position (x,y,size), and our 4-valued color per vertex. The first 2 parts of the location become the x,y of a 4-valued final position, and the 3rd value z becomes the point size. Then we simply forward the color down to the fragment shader with the varying variable. In the fragment shader we use it directly. Now sizes and colors should work for points.

Still, we won't see anything outside the -1 to 1 range because that's the coordinates of screen space. We need to scale our points to fit but lucky for us that can be done directly in the vertex shader.

Scaling vertices

We need to setup the bounds. This is similar to setting up a buffer:

//setup bounds
const bounds = new Float32Array([this.#xmin, this.#xmax, this.#ymin, this.#ymax]);
const boundsLocation = this.context.getUniformLocation(program, "uBounds");
this.context.uniform4fv(boundsLocation, bounds);
Enter fullscreen mode Exit fullscreen mode

I'm putting them all into a single 4-value vector instead of 4 scalars because it makes more sense.

And now for the magic in the vertex shader:

attribute vec3 aVertexPosition;
attribute vec4 aVertexColor;
uniform vec4 uBounds;
varying mediump vec4 vColor;
float inverseLerp(float a, float b, float v){
    return (v-a)/(b-a);
}
void main(){
    gl_PointSize = aVertexPosition.z;
    gl_Position = vec4(mix(-1.0,1.0,inverseLerp(uBounds.x, uBounds.y, aVertexPosition.x)), mix(-1.0,1.0,inverseLerp(uBounds.z, uBounds.w, aVertexPosition.y)), 1.
0, 1.0);
    vColor = aVertexPosition;
}
Enter fullscreen mode Exit fullscreen mode

One of the cool things is that all the transforms can be done in on the GPU in the vertex shader but it takes a bit to know what you are doing. I've introduced a function inverseLerp which is the inverse of a lerp and if you remember last time I said that's exactly what the windowValue function did. Surprisingly, no inverse lerp function exists in GLSL so you have to write it. I've also called it inverseLerp because it's a little more understandable in the graphics context here. You can see that we use .x,.y,.z,.w to refer to the components. This is a shorthand but you can also use index notation [0],[1],[2],[3] if you want. But there's more, we also have mix. mix is a built-in function that is the lerp function. We need this because of how screen space works. Previously, we did the inverse lerp on both axes and multiplied by the max length. This works because the coordinates go from 0 to max length. However, in WebGL screen space they are -1 to 1 so we have to re-scale it back to the screen. Basically we take the original coordinates, normalize them and then use those normalized coordinates to re-project back to the canvas. I'm fairly sure that there must be a simplification, especially to do the vector multiplication in one step, so if you find it let me know! The rest is the same, we pass through the color and use the z coordinate for the size.

download (1)

And it should look roughly identical to the SVG ones. Keep in mind that the size of the pixel is 1/2 what it is in SVG. I'm not sure if that's due to DPI scaling factor or what but to get the same dot size it needs to be 2x bigger.

Finally, lets add the function graphing utility. We can start by making sure that default color works correctly:

set defaultColor(value) {
    if(typeof(value) === "string"){
        this.#defaultColor = JSON.parse(value);
    } else {
        this.#defaultColor = value;
    }
}
Enter fullscreen mode Exit fullscreen mode

We parse if it's a string otherwise assume the user knows what they are doing as passes and array. Then let's add the code to render out the function into points:

let points;
let colors;
if(this.#func){
    points = [];
    colors = [];
    for (let x = this.#xmin; x < this.#xmax; x += this.#step) {
        const y = this.#func(x);
        points.push([x, y, this.#defaultSize]);
        colors.push(this.#defaultColor);
    }
} else {
    points = this.#points;
    colors = this.#colors;
}
Enter fullscreen mode Exit fullscreen mode

We need to do some adjusting to the variable names so that when we bind we bind the buffers we use the data from colors rather than this.#colors and points rather this.#points.

Continuous lines

This part is pretty easy, we just draw twice, once with lines and once with points.

//draw
this.context.clear(this.context.COLOR_BUFFER_BIT | this.context.DEPTH_BUFFER_BIT);
if(this.#continuous){
        this.context.lineWidth(this.#thickness);
    this.context.drawArrays(this.context.LINE_STRIP, 0, points.length);
}
this.context.drawArrays(this.context.POINTS, 0, points.length);
Enter fullscreen mode Exit fullscreen mode

Sadly, while you can set the line width, it's very unlikely your platform will actually support it. See, WebGL doesn't have to respect the line width and at least on my machine it doesn't and I will always get 1.0 thick lines. You can query to see if it is supported:

console.log(this.context.getParameter(this.context.ALIASED_LINE_WIDTH_RANGE));
Enter fullscreen mode Exit fullscreen mode

This will get you the min and max supported line width which for me was [1.0, 1.0]. This sucks and in order to fix it we'll likely need to look into rasterizing lines ourselves, but that's for another day.

Another thing is that these lines will behave differently. Remember how in the last one we decided to make the continuous line a single color so as not to use line segments for everything? Well in WebGL we have the opposite problem. By default the lines will switch color based on the point, but if we wanted a solid line we'd need to create a new color buffer with all lines the same color, or we'd need to add a uniform parameter to the shader program to say "these are lines, so just make them this color". I'm not going to do that but that's how you could.

Drawing the guides

So we have those sorta useless guides on the SVG implementation and this turns out to be an interesting problem in WebGL. See, we setup all these buffers, uniforms and a shader program just to render one thing, the points. But how do we add other things? Basically we have to do it all again. But we can at least take one short-cut. Instead of creating a new shader program that draws the guides we can re-use the existing one, since afterall it's just a basic line-drawing shader.

//draw guides
{
    const positionBuffer = this.context.createBuffer();
    this.context.bindBuffer(this.context.ARRAY_BUFFER, positionBuffer);
    const positions = new Float32Array([
        (this.#xmax + this.#xmin) / 2, this.#ymin, 10,
        (this.#xmax + this.#xmin) / 2, this.#ymax, 10,
        this.#xmin, (this.#ymax + this.#ymin) / 2, 10,
        this.#xmax, (this.#ymax + this.#ymin) / 2, 10
    ]);
    this.context.bufferData(this.context.ARRAY_BUFFER, positions, this.context.STATIC_D
    const positionLocation = this.context.getAttribLocation(program, "aVertexPosition")
    this.context.enableVertexAttribArray(positionLocation);
    this.context.vertexAttribPointer(positionLocation, 3, this.context.FLOAT, false, 0,
    const colorBuffer = this.context.createBuffer();
    this.context.bindBuffer(this.context.ARRAY_BUFFER, colorBuffer);
    const colorsArray = new Float32Array([
        0,0,0,1,
        0,0,0,1,
        0,0,0,1,
        0,0,0,1
    ]);
    this.context.bufferData(this.context.ARRAY_BUFFER, colorsArray, this.context.STATIC
    const colorLocation = this.context.getAttribLocation(program, "aVertexColor");
    this.context.enableVertexAttribArray(colorLocation);
    this.context.vertexAttribPointer(colorLocation, 4, this.context.FLOAT, false, 0, 0)
    const bounds = new Float32Array([this.#xmin, this.#xmax, this.#ymin, this.#ymax]);
    const boundsLocation = this.context.getUniformLocation(program, "uBounds");
    this.context.uniform4fv(boundsLocation, bounds);
    this.context.clear(this.context.COLOR_BUFFER_BIT | this.context.DEPTH_BUFFER_BIT);
    this.context.drawArrays(this.context.LINES, 0, points.length);
}

Enter fullscreen mode Exit fullscreen mode

We can put this after we set useProgram but before we setup the attributes for the points. The curly block is intentional so I can reuse variable names. We set 4 points at the half-way point of each scale and we supply 4 colors for the vertices (in this case black). Then we can draw using gl.LINES which is similar to gl.LINE_STRIP except it's not continuous. We also need to move the buffer clear method here otherwise we'll clear the buffer when we do the point drawing instead of draw over it.

And there we go, an almost approximation of the SVG drawing:

download (2)

Conclusion

The code this time was bigger, more complex and even missing a few features like stroke width and shape. We can still add them but it'll be a lot more work. On the other hand all the heavy lifting was moved to the GPU which makes it really fast. It's really up to you to decide how you should proceed but don't let your framework make that decision for you!

Discussion (0)