WebGL Background: Introduction

I've decided to revamp some of my older articles, and in this one, I'll show you how to prepare your webpage for a WebGL-based background. The process involves implementing just four key components:

1. CSS

In this part, we'll create a class to fix the position of your canvas beneath all other elements:

.canvas { 
  display: block; 
  top: 0;
  left: 0;  
  position: fixed;
  z-index: -100;
}

Our canvas should adapt to the size of the browser window:

canvas { 
  width: 100vw; 
  height: 100vh;
}

2. HTML

Here, all you need to do is include the following code in your webpage's body:

<div class="canvas">
    <canvas id="glcanvas"/>
</div>

We'll use the glcanvas ID to connect it to our upcoming WebGL context.

3. WebGL Shaders

You can store your shaders in the HTML using the script tag with types x-shader/x-vertex and x-shader/x-fragment, or in separate files, or simply within your JS code. In this example, we'll keep them in the HTML. Here are the vertex and fragment shaders:

Vertex shader computes each passed vertex:

<script id="vertex-shader" type="x-shader/x-vertex">
attribute vec2 aVertexPosition;
attribute vec2 aTexturePosition;


varying vec2 tPos;

void main() {
    tPos = aTexturePosition;
    gl_Position = vec4(aVertexPosition, 0.0, 1.0);
}
</script>
  • attribute vec2 aVertexPosition; represents the vertex position. Typically, it's a vector of 3 or 4 floats, but for this 2D example, 2 floats are sufficient.
  • attribute vec2 aTexturePosition; denotes the texture position, indicating the point in the texture corresponding to the current vertex.
  • varying vec2 tPos; is the output texture position. The varying qualifier means that this value is interpolated between vertices.
  • tPos = aTexturePosition; simply passes the input texture coordinates to the output.
  • gl_Position = vec4(aVertexPosition, 0.0, 1.0); retains the input vertex position as is but appends z and w values.

After computing each vertex, WebGL proceeds with the rasterization step and invokes a fragment shader for every pixel. In our scenario, this results in generating a color gradient.

<script id="fragment-shader" type="x-shader/x-fragment">
#ifdef GL_ES
    precision highp float;
#endif


varying vec2 tPos;

void main(void) {
    gl_FragColor = vec4(tPos, 0.0, 1.0);
}
</script>
  • precision highp float; specifies the precision level for floating-point calculations (higher precision can be slower but more accurate).
  • varying vec2 tPos; represents our interpolated texture position from the vertex shader.
  • gl_FragColor = vec4(tPos, 0.0, 1.0); is used to create a gradient that corresponds to the texture position and sets the fragment's color accordingly.

4. JavaScript

This is the most extensive part of the process. Here, we prepare the WebGL context, canvas, build shader programs, and handle drawing.

Firstly, we need to initialize the WebGL context and canvas (I'm currently providing only the functions, and we'll construct them together shortly):

let gl = null;
let glCanvas = null;


function initwebGL() {
    glCanvas = document.getElementById("glcanvas");
    gl = glCanvas.getContext("webgl");
}

Starting from OpenGL 3.2 and OpenGL ES 2.0 (which is what WebGL is based on), drawing can only be done using shaders. Therefore, the following step is to compile shaders and create a shader program, which requires both a vertex shader and a fragment shader.

function compileShader(id, type) {
    let code = document.getElementById(id).firstChild.nodeValue;
    let shader = gl.createShader(type);

    gl.shaderSource(shader, code);
    gl.compileShader(shader);

    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        console.log(`Error compiling ${type === gl.VERTEX_SHADER ? "vertex" : "fragment"} shader:`);
        console.log(gl.getShaderInfoLog(shader));
    }
    return shader;
}

function buildShaderProgram(shaderInfo, uniforms, attributes) {
    let program = gl.createProgram();

    shaderInfo.forEach(function(desc) {
        let shader = compileShader(desc.id, desc.type);

        if (shader) {
            gl.attachShader(program, shader);
        }
    });

    gl.linkProgram(program)

    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        console.log("Error linking shader program:");
        console.log(gl.getProgramInfoLog(program));
    }

    var unifrorms_dict = {}
    uniforms.forEach(function(name) {
        uniform_id = gl.getUniformLocation(program, name);
        unifrorms_dict[name] = uniform_id;
    });

    var attributes_dict = {}
    attributes.forEach(function(name) {
        attrib_id = gl.getAttribLocation(program, name);
        attributes_dict[name] = attrib_id;
    });

    return {
        program: program,
        uniforms: unifrorms_dict,
        attributes: attributes_dict
    };
}

The buildShaderProgram function takes an object that includes the vertex and fragment shaders, as well as the names of uniforms and vertex attributes. It returns an object containing the constructed shader program, along with dictionaries of uniform and attribute names and their respective IDs.

The subsequent step is to initialize all the required objects, which can be done while the page is loading:

// Vertex information
let vertexBuffer;
let vertexCount;

window.addEventListener("load", startup, false);

function startup() {
    initwebGL();

    const shaderSet = [{
            type: gl.VERTEX_SHADER,
            id: "vertex-shader"
        },
        {
            type: gl.FRAGMENT_SHADER,
            id: "fragment-shader"
        }
    ];
    const shaderUniforms = [];
    const shaderAttributes = [
        "aVertexPosition",
        "aTexturePosition"
    ];
    shaderProgram = buildShaderProgram(shaderSet,
        shaderUniforms,
        shaderAttributes);
    console.log(shaderProgram)

    // Here are attributes for 4 vertices (one per line):
    // - The first two numbers are vertex positions.
    // - The second two numbers are texture positions.
    let vertices = new Float32Array([
        -1, 1, 0, 0,
        1, 1, 1, 0,
        -1, -1, 0, 1,
        1, -1, 1, 1
    ]);

    vertexBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

    vertexCount = vertices.length / 4;

    animateScene();
}

We'll be drawing in the entire window area, which can be represented as a rectangle. Since OpenGL works with triangles, we'll use two triangles. However, by utilizing the GL_TRIANGLE_STRIP mode, we can achieve this with only 4 vertices, where two of them are shared:

Now that everything is set up and ready, we can proceed to draw our rectangular plane and apply a shader effect to it.

function resize(canvas) {
    // Look up the size the browser is displaying the canvas.
    var displayWidth  = canvas.clientWidth;
    var displayHeight = canvas.clientHeight;

    // Check if the canvas has different size and make it the same.
    if (canvas.width  !== displayWidth ||
        canvas.height !== displayHeight) 
    {
        canvas.width  = displayWidth;
        canvas.height = displayHeight;
    }
}

function animateScene() {
    // We need an actual window size for correctly viewport setup.
    resize(glCanvas);  

    // Setup viewport and clear it with black non transparent colour.
    gl.viewport(0, 0, glCanvas.width, glCanvas.height);
    gl.clearColor(0.0, 0.0, 0.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Select a buffer for vertices attributes.
    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

    // Enable and setup attributes.
    gl.enableVertexAttribArray(shaderProgram.attributes["aVertexPosition"]);
    gl.vertexAttribPointer(shaderProgram.attributes["aVertexPosition"], 2,
        gl.FLOAT, false, 4 * 4, 0);
    gl.enableVertexAttribArray(shaderProgram.attributes["aTexturePosition"]);
    gl.vertexAttribPointer(shaderProgram.attributes["aTexturePosition"], 2,
        gl.FLOAT, false, 4 * 4, 2 * 4);

    // Select shader program.
    gl.useProgram(shaderProgram.program);

    gl.drawArrays(gl.TRIANGLE_STRIP, 0, vertexCount);


    window.requestAnimationFrame(function(currentTime) {
        previousTime = currentTime;
        animateScene();
    });
}

You can observe the resulting background on the current page, where the [0, 0] point (black due to (0, 0, 0)) is located at the top left, and the [1, 1] point is situated at the bottom right, appearing yellow due to (1, 1, 0).

George Ostrobrod, 2019 (edited 2024)