WebGL: 3 Ways to make a Checkerboard

David Banks
11 min readMay 14, 2023

--

Busy robots trying different ways to make a checkerboard. (Image: DALL-E)

Introduction

I was wondering how I could make a checkerboard without resorting to any if statements. There are quite a few ways to achieve it, but some are more interesting than others.

  1. Using mesh planes and arranging them in a grid pattern.
  2. Making a checkerboard in a paint program and applying it to a mesh.
  3. Doing the whole thing in the fragment shader.

We’ll build a checkerboard in using each of these methods.

With the exception of an imported texture, we’ll need to write an algorithm for each of them that is capable of determining whether a coordinate should be one colour or the other though. That can take a little thinking.

All of these are rendered using the following HTML:

<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<title>Some title</title>
<style>
canvas {
border: gray solid 1px;
}
</style>
</head>
<body>
<h1>Some title</h1>
<canvas id='webgl-checkerboard'
height='500'
width='500'></canvas>
<script src='checkerboard.js' type='module'></script>
</body>
</html>

I’ll also be using some helper functions I’ve described in previous articles:

  1. createProgram: WebGL: Useful Boilerplate
  2. fetchShaderTexts: WebGL: External GLSL Files

In order to reduce masses of distracting code, each checkerboard.js file is structured with this code:

import {
fetchShaderTexts,
createProgram,
} from '../../../helpers/webglHelpers.js';

console.log('Some title');
const shaderTextPromise = fetchShaderTexts(
'checkerboard.vert.glsl',
'checkerboard.frag.glsl'
);
const CANVAS = document.getElementById('webgl-checkerboard');
const gl = CANVAS.getContext('webgl2');
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK);
gl.frontFace(gl.CCW);
gl.clearColor(1.0, 1.0, 1.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const shaderTexts = await shaderTextPromise;

const program = createProgram(
gl,
shaderTexts.vertexShaderText,
shaderTexts.fragmentShaderText
);

gl.useProgram(program);

/*******************************\
* Interesting stuff goes here. *
\*******************************/

const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(triangleData), gl.STATIC_DRAW);

const vertexAttributeLocation = gl.getAttribLocation(
program,
'VERTEX_POSITION'
);
gl.vertexAttribPointer(
vertexAttributeLocation,
2,
gl.FLOAT,
false,
2 * Float32Array.BYTES_PER_ELEMENT,
0
);
gl.enableVertexAttribArray(vertexAttributeLocation);
gl.drawArrays(gl.TRIANGLES, 0, triangleData.length);

Using Meshes

Shaders

We’ll start with the shaders, as these are both as simple as they get. First, the vertex shader:

precision mediump float;
attribute vec2 VERTEX_POSITION;

void main() {
gl_Position = vec4(VERTEX_POSITION, 0.0, 1.0);
}

Now, the fragment shader:

precision mediump float;

void main() {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
}

The only thing to note here is that we set the fragment colour to black. With the background cleared white, any mesh we generate will be black against it.

Meshes

Meshes are built from triangles, and a square uses two triangles.

Plane mesh layout with vertices numbered.

When we use the variables v1, v2, v3, and v4 below, they refer to these positions on the plane. We need to declare vertices in counter-clockwise order, so we’ll use these definitions for the two triangles:

  1. v1, v3, v2
  2. v3, v1, v4

Firstly, we’ll declare a constant to hold our desired cell count and calculate how large the cells should be.

/**
* Number of cells in each dimension.
* @type {{width: number, height: number}}
*/
const CELL_COUNT = {
width: 8,
height: 6,
};

/**
* The size of each cell in each dimension.
* @type {{x: number, y: number}}
*/
const CELL_SIZE = {
x: 2 / CELL_COUNT.width,
y: 2 / CELL_COUNT.height,
};

We 2 divide by the cell count because the coordinate system goes from -1.0 to 1.0, therefore a size of 2.0.

Next, we need to generate the vertices and triangles. This involves two loops: one to iterate over the x-axis, one to iterate over the y-axis. We need each row to alternate between starting with a filled square and not, as well as alternate as we go along the row. We can achieve this with a pair of Booleans that keep track of whether the current cell should be filled or not.

You can test the logic with this snippet:

/**
* Character representing a filled cell.
* @type {string}
*/
const BLACK = 'X';
/**
* Character representing an unfilled cell.
* @type {string}
*/
const WHITE = '-';
/**
* Number of cells in each dimension.
* @type {{width: number, height: number}}
*/
const CELL_COUNT = {
width: 8,
height: 6,
};

/**
* The size of each cell in each dimension.
* @type {{x: number, y: number}}
*/
const CELL_SIZE = {
x: 2 / CELL_COUNT.width,
y: 2 / CELL_COUNT.height,
};
let output = '';

/**
* Define if a row should start with a filled or unfilled square.
* @type {boolean}
*/
let rowAlternate = true;
for (let i = -1.0; i < 1.0; i += CELL_SIZE.x) {
/**
* Keep track of whether the current cell should be filled.
* @type {boolean}
*/
let cellAlternate = rowAlternate;

for (let j = -1.0; j < 1.0; j += CELL_SIZE.y) {
cellAlternate = !cellAlternate;
if (cellAlternate) {
output += WHITE + ' ';
continue;
}
output += BLACK + ' ';
}
output += '\n';
rowAlternate = !rowAlternate;
}

console.log(output)

This outputs the characters stored in BLACK and WHITE in a grid, allowing us to confirm functionality.

The output is as follows:

X - X - X - X 
- X - X - X -
X - X - X - X
- X - X - X -
X - X - X - X
- X - X - X -
X - X - X - X
- X - X - X -

Now, instead of printing characters, we want to generate four coordinates and use these to define the vertices of our two triangles.


/**
* Vertex coordinate data for mesh generation.
* @type {Array<number>}
*/
const triangleData = [];

let rowAlternate = false;
for (let i = -1.0; i < 1.0; i += CELL_SIZE.x) {
let cellAlternate = rowAlternate;

for (let j = -1.0; j < 1.0; j += CELL_SIZE.y) {
cellAlternate = !cellAlternate;
if (cellAlternate) {
continue;
}
const v1 = [i, j];
const v2 = [i, j + CELL_SIZE.y];
const v3 = [i + CELL_SIZE.x, j + CELL_SIZE.y];
const v4 = [i + CELL_SIZE.x, j];

triangleData.push(...v1);
triangleData.push(...v3);
triangleData.push(...v2);
triangleData.push(...v3);
triangleData.push(...v1);
triangleData.push(...v4);
}

rowAlternate = !rowAlternate;
}

With the suffix described in the introduction this should display a checkerboard with the cell counts defined.

Creating a “Fill Mesh”

For the following, we need a single plane that fills the canvas. To accomplish this, I have created the following function:

/**
* Add a flat mesh that fills the screen.
* @param program {WebGLProgram}
* @param webGlContext {WebGLRenderingContext | WebGL2RenderingContext}
* @param vertexPositionAttributeName {string | null}
*/
export function initialiseMesh(
program,
webGlContext,
vertexPositionAttributeName = 'vertexPosition'
) {
const vertexBuffer = webGlContext.createBuffer();
const vertices = new Float32Array([
...[-1.0, 1.0],
...[1.0, 1.0],
...[1.0, -1.0],
...[-1.0, 1.0],
...[1.0, -1.0],
...[-1.0, -1.0],
]);

webGlContext.bindBuffer(webGlContext.ARRAY_BUFFER, vertexBuffer);
webGlContext.bufferData(
webGlContext.ARRAY_BUFFER,
vertices,
webGlContext.STATIC_DRAW
);

const vertexPositionAttributeLocation = webGlContext.getAttribLocation(
program,
vertexPositionAttributeName
);

webGlContext.vertexAttribPointer(
vertexPositionAttributeLocation,
2,
webGlContext.FLOAT,
false,
2 * Float32Array.BYTES_PER_ELEMENT,
0
);

webGlContext.enableVertexAttribArray(vertexPositionAttributeLocation);
}

I’m not going to show calling this function explicitly. It’s should be called just before useProgram(program). Assume that we have a single plane stretching from [-1.0,-1.0] to [1.0,1.0].

Making a Texture Using Paint

Shout out Indigo Code whose video I relied on heavily to figure this out.

Firstly, instead of using a plain checkerboard for development, I’m going to use a UV map that I created in a paint program. This allows me to verify if the rendering is correct.

Simple UV mapping grid for development.

Once we confirm that it’s working properly, we can easily swap it out for the checkerboard. I’m using a 512 * 512 pixel image, but you can use any image you prefer (although non-square or non-power-of-two sizes may result in strange outcomes).

There are various image formats, we can leverage the browsers built-in ability to handle these. To do this we load the image into a img tag and reference it. We don’t want the user to see it so we’ll use styling to hide the image and remove it from the DOM flow.

<img alt='Hidden image element containing checkerboard texture.'
id='checkerboard-texture'
src='./checkerboard.jpg'
style='visibility: hidden; position: absolute;' />

NB: There is a subtle problem here. If we’re fetching the image over the internet, it might be retrieved and loaded by the time the code executes. A simple way to get around this is to fire the code on the onload event of the img tag.

Create a Texture

Grab the img element, create a texture and bind it.

const textureImageElement = document.getElementById('checkerboard-texture');
const texture = gl.createTexture();

gl.bindTexture(gl.TEXTURE_2D, texture);

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
textureImageElement
);

The texParameteri function is peculiar. It sets the value of the property referenced by the second argument to the value of the third argument. This means that only certain combinations are value.

Valid arguments are defined on MDN.

The gl.TEXTURE_WRAP_[S|T] sets the wrapping function behaviour. This tells the compiler how to handle the situation when the texture doesn’t perfectly fit the mesh. There’s three options.

  1. gl.REPEAT: Repeat the texture.
  2. gl.CLAMP_TO_EDGE: Stretch the last colour to the edge.
  3. gl.MIRRORED_REPEAT: Similar to repeat, but mirror the texture.

The gl.TEXTURE_[MIN|MAG]_FILTER defines how to handle minifying and magnifying. Magnification has many more options but both support.

  1. gl.LINEAR: Linearly blend between pixels.
  2. gl.NEAREST: Use the colour of the nearest pixel.

Lastly, call gl.texImage2D which set yet more values. This function is described on MDN.

The Shaders

The vertex shader is a basic boilerplate implementation with the addition of the FRAGMENT_TEXTURE_COORDINATES. We use this to store the VERTEX_POSITION so that we can pick it up in the fragment shader.

precision mediump float;

attribute vec2 VERTEX_POSITION;
varying vec2 FRAGMENT_TEXTURE_COORDINATES;

void main() {
FRAGMENT_TEXTURE_COORDINATES = VERTEX_POSITION;
gl_Position = vec4(VERTEX_POSITION, 0.0, 1.0);
}

Initially we will create a naïve implementation.

precision mediump float;

uniform sampler2D TEXTURE;
varying vec2 FRAGMENT_TEXTURE_COORDINATES;

void main() {
gl_FragColor = texture2D(TEXTURE, FRAGMENT_TEXTURE_COORDINATES);
}

This will map the texture to the on a 1:1 basis to the mesh. Running this reveals the problem with our current implementation.

Reversed, scaled to 0.25 and shifted to the top right.

NB: As a sidenote, we can use this to try other wrapping functions. The behaviour of REPEAT and MIRROR_REPEAT will be very easy to see.

The reason for this issue is that we are changing our coordinate system from the [-1,-1] (bottom-left) to [1,1] (top-right) that the vertices are in to the UV coordinate system which goes from [0,0] (top-left) to [1,1] (bottom-right). This involves some simple mathmatics.

precision mediump float;

uniform sampler2D TEXTURE;
varying vec2 FRAGMENT_TEXTURE_COORDINATES;

void main() {
vec2 normalisedCoordinates
= (FRAGMENT_TEXTURE_COORDINATES * vec2(0.5, -0.5)) + vec2(0.5, 0.5);
gl_FragColor = texture2D(TEXTURE, normalisedCoordinates);
}

We stretch by multiplying by vec2(0.5, -0.5) and shift it left and down by adding vec2(0.5, 0.5).

Now we can run this and see it work as expected. Swapping out for a checkerboard of the same size will give us the correct rendering.

Now, let’s optimise further. We don’t actually need a large image file for this. We could just use a single pixel per cell. For example, 8 * 8 pixel checkerboard would suffice.

A tiny, 8 * 8 pixel checkerboard. Too small to see.

However, when we use such a small image, we get a blurred result.

A very blurred checkerboard

The reason for this is the gl.TEXTURE_[MIN|MAG]_FILTER, in this specific case, the gl.TEXTURE_MAG_FILTER as we’re magnifying the image. Currently it’s set to gl.LINEAR which tells tells the engine to interpolate between the colours linearly. In this case, we don’t want that; we simply want it to use the nearest pixel. Changing the value of to gl.NEAREST fixes it all.

A crisp checkerboard.

A Further Optimisation

The image file we’re using is is a repetition of a 2 * 2 grid. We mentioned the wrapping functions above. We could just use a 2 * 2 pixel grid and set the wrapping mode to repeat.

To do this we just change the wrapping mode, as mentioned, and then change the fragment shader so that it multiplies the FRAGMENT_TEXTURE_COORDINATE by vec2(2.0, -2.0) instead. We now have exactly the same rendering, but with even less wastage.

Using the Fragment Shader

After exploring various options, we have arrived at the last option: programming the fragment shader to handle all the logic.

The vertex shader remains a simple boilerplate implementation to display the mesh.

precision mediump float;

attribute vec2 vertexPosition;

void main() {
gl_Position = vec4(vertexPosition, 0.0, 1.0);
}

Surprisingly, the fragment shader is also quite simple.

precision mediump float;

uniform vec2 CANVAS_SIZE;
uniform vec2 CELL_COUNT;
uniform vec4 COLOR_1;
uniform vec4 COLOR_2;

void main() {
vec2 boardCoordinates = floor(gl_FragCoord.xy * CELL_COUNT.xy / CANVAS_SIZE);

float xMod = mod(boardCoordinates.x, 2.0);
float yMod = mod(boardCoordinates.y, 2.0);
float state = mod(xMod + yMod, 2.0);

gl_FragColor = mix(COLOR_1, COLOR_2, state);
}

We have some uniform fields that will need to be supplied from the JavaScript. Once we have these, we calculate which cell we are in. Then we determine what colour the cell should be. The xMod will alternate the colour along the row and the yMod alternates it along the columns. Summing them and applying modulo ensures that we have a checkerboard pattern instead of stripes. Finally, we use the mix function function to linearly interpolate between the two colors, as the value of state can only be 0.0 or 1.0 we can only have either COLOUR_1 or COLOUR_2.

Now we need to populate those fields from the JavaScript.

const canvasSizeLocation = gl.getUniformLocation(program, 'CANVAS_SIZE');
gl.uniform2f(canvasSizeLocation, CANVAS.width, CANVAS.height);

const cellCountLocation = gl.getUniformLocation(program, 'CELL_COUNT');
gl.uniform2f(cellCountLocation, 8.0, 8.0);

const color1Location = gl.getUniformLocation(program, 'COLOR_1');
gl.uniform4f(color1Location, 0.0, 0.0, 0.0, 1.0);

const color2Location = gl.getUniformLocation(program, 'COLOR_2');
gl.uniform4f(color2Location, 1.0, 1.0, 1.0, 1.0);

We define COLOUR_1 and COLOUR_2 to be black and white, we set the number of cells we want we well as the canvas size (i.e. the resolution). We can even have fractional cell counts without problems.

Checkerboard with width 1.5 and 2.25.

Conclusion

In conclusion, this exploration turned out to be more interesting than I initially thought. It provided me with better insights into the available options and how different techniques work. I had initially forgotten that the coordinate space of meshes and textures is different, so it took some time to figure out why the UV map was displayed upside-down and pushed into the corner. It was a good reminder for the future.

Leveraging the browser to handle image encoding types was a cool hack.

Even though the rendered graphics appear the same, it’s clear that leveraging GLSL is much more powerful than the other options. For patterns that are less geometric, supplying a texture is probably a must.

I assume that textures can be used for purposes other than rendering directly to the mesh. Since it is a 2D array of data, it could also be used for things like terrain elevation, vector fields, or other creative applications. It’ll be interesting to try some of those techniques.

Anyhow, thank you for reading. I hope you’ve found it interesting too.

--

--

Responses (5)