WebGL: 3 Ways to make a Checkerboard
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.
- Using mesh planes and arranging them in a grid pattern.
- Making a checkerboard in a paint program and applying it to a mesh.
- 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:
createProgram
: WebGL: Useful BoilerplatefetchShaderTexts
: 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.
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:
v1
,v3
,v2
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.
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 theimg
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.
gl.REPEAT
: Repeat the texture.gl.CLAMP_TO_EDGE
: Stretch the last colour to the edge.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.
gl.LINEAR
: Linearly blend between pixels.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.
NB: As a sidenote, we can use this to try other wrapping functions. The behaviour of
REPEAT
andMIRROR_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.
However, when we use such a small image, we get a blurred result.
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 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.
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.