Basics of WebGL

Basics of WebGL

Published: 4/30/2025
by: Tommy Sarni

What is WebGL

Web Graphics Library (WebGL) is a JavaScript API leveraging the device's Graphical Processing Unit (GPU) to display 2D and 3D graphics directly in the web browser.

This article uses WebGL 2 which is an extension to WebGL 1. WebGL 2 is widely available across browsers. Mozilla Documentation - WebGL2RenderingContext

Why WebGL

  1. Performance: WebGL hands off rendering tasks to the GPU significantly improving performance.
  2. Cross-Platform & Cross-Browser: WebGL is a standard supported by major web browsers, ensuring that content can be viewed on various devices and platforms without needing any plugins

Want to see just how much faster is the GPU at rendering? Check out this short video of MythBusters demonstrating it. Youtube: Mythbusters Demo GPU versus CPU

Use Cases

Basics

Overview

The basic principle of working with WebGL is to separate the jobs of the CPU and the GPU. The GPU's job is to render objects – often called meshes – based on various factors like orientation, scale, perspective, lighting, etc. The CPU's job is to initialize these meshes with their vertices – often called geometry – and appearance – often called material. An example of a 2D mesh could be a red square and an example of a 3D mesh could be a blue Sphere. Along with initializing meshes, the CPU handles interactivity like zooming, panning, and keyboard inputs. The CPU will then update the constants – called uniforms – the GPU uses to render objects.

Implementation

Setup

There is quite a bit of starter code that is needed to get WebGL up and running. The first thing we need is an HTML document with a <canvas> element. I'll also include some basic styling and an empty <script> tag.

Create index.html

<!DOCTYPE html>

<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<title>WebGL</title>
		<style>
			html,
			body {
				margin: 0;
				height: 100%;
				overflow: hidden;
			}
			
			canvas {
				width: 100%;
				height: 100%;
				display: block;
			}
		</style>
	</head>
	<body>
		<canvas></canvas>
		<script></script>
	</body>
</html>

Next, creating a WebGL program requires three things:

  1. WebGL2RenderingContext
  2. Vertex Shader
  3. Fragment Shader

WebGL2RenderingContext

const canvasElement = document.querySelector('canvas');
const gl = canvasElement.getContext('webgl2') // WebGL2RenderingContext

Shaders Shaders are small programs that run on the GPU to control how meshes appear on the screen. The vertex shader manipulates the geometry – determining where and how vertices are positioned, often based on transformations like translation, rotation, and scaling. The fragment shader defines the material — how the surface of the object looks. This can include solid colors, textures, lighting, transparency, or even complex effects like procedural patterns or glow.

Vertex Shader This shader receives a vertex position (as a 2D vector) and a uniform position offset. It moves each vertex by the offset before passing it to the GPU for rendering.

#version 300 es

layout(location = 0) in vec2 vertex;
uniform vec2 uPosition;

void main() {
	vec2 pos = vertex + uPosition;
	gl_Position = vec4(pos, 0.0, 1.0); // Note: vec4(x, y, z, w);
}

Fragment Shader This shader takes in a uniform color and paints each fragment (pixel) of the mesh with it.

#version 300 es
precision highp float;

uniform vec3 uColor;
out vec4 fragColor;

void main() {
	fragColor = vec4(uColor, 1.0);
}

Using the rendering context, vertex shader, and fragment shader, create and use the WebGL program.

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl2'); // WebGL2RenderingContext
const vs = `
#version 300 es

layout(location = 0) in vec2 vertex;
uniform vec2 uPosition;

void main() {
	vec2 pos = vertex + uPosition;
	gl_Position = vec4(pos, 0.0, 1.0);
}
`.trim();

const fs = `
#version 300 es
precision highp float;

uniform vec3 uColor;
out vec4 fragColor;

void main() {
	fragColor = vec4(uColor, 1.0);
}
`.trim();

const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vs);
gl.compileShader(vertexShader);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fs);
gl.compileShader(fragmentShader);

const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);

At this point, if you open up this document in a browser, you will see nothing. We have created a program, linked it to our canvas, and provided it instructions on how meshes should be drawn – but we haven't given it any meshes yet.

Drawing a Shape

We need something to draw. Based on our shaders, we require three things: vertices for geometry, a position offset, and a color.

We'll start with the vertices of a triangle, since triangles are the most fundamental building blocks of more complex geometries. Below is the mesh data for a single triangle:

// Note: WebGL operates in clip space from -1 -> 1, so (0,0) is the center.
const triangle = new Float32Array([
	0, 0,    // vertex 1
	1, 0,    // vertex 2
	0.5, 1   // vertex 3
]);

const triangleVertexCount = 3; // We'll use this when drawing
const [x, y] = [0, 0];         // Position offset
const [r, g, b] = [0, 1, 0];   // Green color

To send our triangle data to the GPU, we’ll use a Vertex Array Object (VAO) and a Vertex Buffer Object (VBO).

A VAO stores the configuration for how vertex attributes are pulled from buffers. This helps simplify making draw calls.

const vao = gl.createVertexArray();
gl.bindVertexArray(vao);

Next, we create a VBO, which holds the triangle’s vertex data. Think of it as a raw data container:

const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, triangle, gl.STATIC_DRAW);

Now we need to describe how the GPU should interpret the data in our buffer and link it to the vertex attribute in our shader:

// layout(location = 0) in vec2 vertex; The shader expects 2 floats per vertex
gl.vertexAttribPointer(
  0,        // Attribute location (matches the layout location in the shader)
  2,        // Number of components per vertex (x and y)
  gl.FLOAT, // Data type
  false,    // Do not normalize
  0,        // Stride (0 = move to the next vertex by sizeof(vec2) automatically)
  0         // Offset (start at the beginning of the buffer)
);
gl.enableVertexAttribArray(0); // Enable the attribute so it's used in rendering

The last thing to do before drawing is to set our uniformsposition offset and color:


const uPosition = gl.getUniformLocation(program, "uPosition");
gl.uniform2f(uPosition, x, y);

const uColor = gl.getUniformLocation(program, "uColor");
gl.uniform3f(uColor, r, g, b);

With everything in place — our geometry, shader program, and uniforms — we’re ready to render.

First, we prepare the canvas by resizing it to match the display size and clearing any previous frame:

canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;

gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);

Finally, we bind our VAO and issue the draw call:

gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, triangleVertexCount);

With that we now have our triangle on the screen! Here is all the code in the <script> tag so far:

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl2');

const vs = `
#version 300 es

layout(location = 0) in vec2 vertex;
uniform vec2 uPosition;

void main() {
	vec2 pos = vertex + uPosition;
	gl_Position = vec4(pos, 0.0, 1.0);
}
`.trim();

const fs = `
#version 300 es
precision highp float;

uniform vec3 uColor;
out vec4 fragColor;

void main() {
	fragColor = vec4(uColor, 1.0);
}
`.trim();

const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vs);
gl.compileShader(vertexShader);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fs);
gl.compileShader(fragmentShader);

const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);

const triangle = new Float32Array([
	0, 0,  // vertex 1
	1, 0,  // vertex 2
	0.5, 1 // vertex 3
]);

const triangleVertexCount = 3;
const [x, y] = [0, 0]; // Position offset
const [r, g, b] = [0, 1, 0]; // Green color

const vao = gl.createVertexArray();
gl.bindVertexArray(vao);

const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, triangle, gl.STATIC_DRAW);

gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(0);

const uPosition = gl.getUniformLocation(program, "uPosition");
gl.uniform2f(uPosition, x, y);

const uColor = gl.getUniformLocation(program, "uColor");
gl.uniform3f(uColor, r, g, b);

function draw() {
	canvas.width = canvas.clientWidth;
	canvas.height = canvas.clientHeight;
	
	gl.viewport(0, 0, canvas.width, canvas.height);
	gl.clearColor(0, 0, 0, 1);
	gl.clear(gl.COLOR_BUFFER_BIT);
	
	gl.bindVertexArray(vao);
	gl.drawArrays(gl.TRIANGLES, 0, triangleVertexCount);
}

draw();
Fixing Squish and Stretch

Our triangle might look fine at first, but if you resize the window, it will stretch or squish. This happens because our vertex positions assume the canvas is a square — meaning 1 unit on the x-axis equals 1 unit on the y-axis.

To keep the triangle's shape consistent regardless of the canvas dimensions, we need to scale one axis so that it matches the other. To scale the x-axis, multiply the x position by a variable, m, such that x equals y:

y=mxm=yxy = m \cdot x \quad \Rightarrow \quad m = \frac{\text{y}}{\text{x}}

We substitute the canvas width and canvas height for x and y because the canvas dimensions determine how unit lengths appear along each axis.

m=canvas heightcanvas widthm = \frac{\text{canvas height}}{\text{canvas width}}

To match x units and y units, you multiply x positions by:

heightwidth=1aspect ratio\frac{\text{height}}{\text{width}} = \frac{1}{\text{aspect ratio}}

This value is known as the inverse aspect ratio.

Update the vertex shader multiplying the vertex's x position by the inverse aspect ratio. This transformation happens last (after updating the position) because we can assume prior transformations are in a square grid.

#version 300 es

layout(location = 0) in vec2 vertex;

uniform vec2 uPosition;
uniform float uInvAspect;

void main() {
	vec2 pos = vertex + uPosition;
	vec2 aspectPos = vec2(pos.x * uInvAspect, pos.y);
	gl_Position = vec4(aspectPos, 0.0, 1.0);
}

Set our new uInvAspect uniform:

canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const invAspect = Math.fround(canvas.height / canvas.width); // 32 bit float
const uInvAspect = gl.getUniformLocation(program, "uInvAspect");
gl.uniform1f(uInvAspect, invAspect);

This works when we resize the window and refresh — the triangle keeps its shape. But to make it respond as the window resizes, we need to update it dynamically.

Update the uniform and redraw whenever the window is resized:

window.addEventListener('resize', () => {
	canvas.width = canvas.clientWidth;
	canvas.height = canvas.clientHeight;
	const invAspect = Math.fround(canvas.height / canvas.width);
	const uInvAspect = gl.getUniformLocation(program, "uInvAspect");
	gl.uniform1f(uInvAspect, invAspect);
	
	draw();
})

Here is our current script tag:

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl2'); // WebGL2RenderingContext
const vs = `
#version 300 es

layout(location = 0) in vec2 vertex;

uniform vec2 uPosition;
uniform float uInvAspect;

void main() {
	float aspectX = vertex.x * uInvAspect;
	vec2 pos = vec2(aspectX, vertex.y) + uPosition;
	gl_Position = vec4(pos, 0.0, 1.0);
}
`.trim();

const fs = `
#version 300 es
precision highp float;

uniform vec3 uColor;
out vec4 fragColor;

void main() {
	fragColor = vec4(uColor, 1.0);
}
`.trim();

const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vs);
gl.compileShader(vertexShader);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fs);
gl.compileShader(fragmentShader);

const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);

const triangle = new Float32Array([
	0, 0, // vertex 1
	1, 0, // vertex 2
	0.5, 1 // vertex 3
]);

const triangleVertexCount = 3;
const [x, y] = [-.5, -.5]; // Position offset
const [r, g, b] = [0, 1, 0]; // Green color

const vao = gl.createVertexArray();
gl.bindVertexArray(vao);

const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, triangle, gl.STATIC_DRAW);

gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(0);


const uPosition = gl.getUniformLocation(program, "uPosition");
gl.uniform2f(uPosition, x, y);

const uColor = gl.getUniformLocation(program, "uColor");
gl.uniform3f(uColor, r, g, b);

canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const invAspect = Math.fround(canvas.height / canvas.width);
const uInvAspect = gl.getUniformLocation(program, "uInvAspect");
gl.uniform1f(uInvAspect, invAspect);

function draw() {

	gl.viewport(0, 0, canvas.width, canvas.height);
	gl.clearColor(0, 0, 0, 1);
	gl.clear(gl.COLOR_BUFFER_BIT);
	
	gl.bindVertexArray(vao);
	gl.drawArrays(gl.TRIANGLES, 0, triangleVertexCount);
}

draw();

window.addEventListener('resize', () => {
	canvas.width = canvas.clientWidth;
	canvas.height = canvas.clientHeight;

	const invAspect = Math.fround(canvas.height / canvas.width);
	const uInvAspect = gl.getUniformLocation(program, "uInvAspect");
	gl.uniform1f(uInvAspect, invAspect);

	draw();
});
Scale & Rotation

We can successfully draw a triangle, update it's position, and change it's color. We will now add the ability to update the triangle's scale and rotation.

Add a scale and rotation data to the triangle's mesh.

const triangle = new Float32Array([
	0, 0, // vertex 1
	1, 0, // vertex 2
	0.5, 1 // vertex 3
]);

const triangleVertexCount = 3;
const [x, y] = [-.5, -.5]; // Position offset
const [r, g, b] = [0, 1, 0]; // Green color
const scale = .5;
const rotation = 45; // in Degrees

Updating the scale is straightforward — just multiply the vertex coordinates by the scale Updating the rotation is a bit trickier: the idea is to rotate the vertex using the unit circle (i.e., using cos(ϕ) and sin(ϕ) for the angle of rotation, ϕ).

Update the Vertex Shader:

#version 300 es

layout(location = 0) in vec2 vertex;
uniform vec2 uPosition;
uniform float uInvAspect;
uniform float uScale;
uniform float uRotate;

void main() {

	vec2 scaledPos = vertex * uScale;
	vec2 rotated = vec2(cos(radians(uRotate)), sin(radians(uRotate)));
	vec2 rotatedPos = vec2(
		scaledPos.x * rotated.x - scaledPos.y * rotated.y,
		scaledPos.x * rotated.y + scaledPos.y * rotated.x
	);

	vec2 pos = rotatedPos + uPosition;
	vec2 aspectPos = vec2(pos.x * uInvAspect, pos.y);
	gl_Position = vec4(pos, 0.0, 1.0);

}

2D Rotation Math

In the above sample we are trying to rotate the green triangle's bottom right vertex (x, y) to the red triangle's vertex (x', y').

𝜭 is the angle counterclockwise from the x-axis to the green triangle 𝜱 is the angle counterclockwise from the bottom of the green triangle to the bottom of the red triangle

We know from Trigonometry that:

sin(a)=opposite/hypotenuse\sin(a) = opposite / hypotenuse

cos(a)=adjacent/hypotenuse\cos(a) = adjacent / hypotenuse

Substitute the initial point (x,y) (Note: r is radius)

sin(θ)=yry=rsin(θ)\sin(\theta) = \frac{y}{r} \quad \Rightarrow \quad y = r \cdot \sin(\theta) cos(θ)=xrx=rcos(θ)\cos(\theta) = \frac{x}{r} \quad \Rightarrow \quad x = r \cdot \cos(\theta)

Get the formula for x' and y'

cos(θ+ϕ)=xrx=rcos(θ+ϕ)\cos(\theta + \phi) = \frac{x'}{r} \quad \Rightarrow \quad x' = r \cdot \cos(\theta + \phi) sin(θ+ϕ)=yry=rsin(θ+ϕ)\sin(\theta + \phi) = \frac{y'}{r} \quad \Rightarrow \quad y' = r \cdot \sin(\theta + \phi)

Apply the Trigonometric Sum Identities:

  • cos(A+B) = cos(A) cos(B) - sin(A) sin(B)
  • sin(A+B) = sin(A) cos(B) + cos(A) sin(B)

Find x'

x=r(cos(θ)cos(ϕ)sin(θ)sin(ϕ))x' = r \cdot (\cos(\theta)\cos(\phi) - \sin(\theta)\sin(\phi)) x=rcos(θ)cos(ϕ)rsin(θ)sin(ϕ)x' = r \cdot \cos(\theta)\cos(\phi) - r \cdot\sin(\theta)\sin(\phi)

Substitute in x and y

x=xcos(ϕ)ysin(ϕ)x' = x\cdot\cos(\phi) - y \cdot\sin(\phi)

Find y'

y=rsin(θ)cos(ϕ)+cos(θ)sin(ϕ)y' = r \cdot \sin(\theta)\cos(\phi) + \cos(\theta)\sin(\phi) y=rsin(θ)cos(ϕ)+rcos(θ)sin(ϕ)y' = r \cdot\sin(\theta)\cos(\phi) + r \cdot \cos(\theta)\sin(\phi)

Substitute in x and y

y=ycos(ϕ)+xsin(ϕ)y' = y \cdot\cos(\phi) + x \cdot\sin(\phi)

To rotate a vertex (x,y) by an angle ϕ, we apply the 2D rotation transformation, resulting in the new coordinates (x',y'):

(x,y)=[xcos(ϕ)ysin(ϕ)ycos(ϕ)+xsin(ϕ)](x', y') = \begin{bmatrix} x \cdot \cos(\phi) - y \cdot \sin(\phi) \\ y \cdot \cos(\phi) + x \cdot \sin(\phi) \end{bmatrix}

Set the scale and rotation uniforms:

const uScale = gl.getUniformLocation(program, "uScale");
gl.uniform1f(uScale, scale);

const uRotate = gl.getUniformLocation(program, "uRotate");
gl.uniform1f(uRotate, rotation);

While rotation around the origin works, there are cases where rotation around an arbitrary point is required. To accomplish this transformation, follow these three steps: (1) translate the vertex so that the desired rotation center moves to the origin (vertex - center), (2) apply the rotation transformation, and (3) translate the vertex back to its original position relative to the center (vertex + center).

#version 300 es

layout(location = 0) in vec2 vertex;

  
uniform vec2 uPosition;
uniform float uInvAspect;
uniform float uScale;
uniform float uRotate;
uniform vec2 uRotateCenter;

void main() {
	vec2 scaledPos = vertex * uScale;
	vec2 preRotatePos = scaledPos - uRotateCenter;
	vec2 rotated = vec2(cos(radians(uRotate)), sin(radians(uRotate)));
	vec2 rotatedPos = vec2(
		preRotatePos.x * rotated.x - preRotatePos.y * rotated.y,
		preRotatePos.x * rotated.y + preRotatePos.y * rotated.x
	);

	vec2 postRotatePos = rotatedPos + uRotateCenter;
	vec2 pos = postRotatePos + uPosition;
	
	vec2 aspectPos = vec2(pos.x * uInvAspect, pos.y);
	gl_Position = vec4(aspectPos, 0.0, 1.0);
}

Note: since we are scaling first, we must remember to update position offset (px, py) and rotation center (cx, cy) based on the vertices already scaled by s. For example, let's scale the triangle, t, where t = [(0,0), (1,0), (.5, 1)] by s, rotate it ϕ degrees around its centroid, C, and then position C at (0,0).

Assuming s = 0.5 and C is calculated before scaling, t will be scaled by half, then the t will rotate around C, instead of C_scaled and will translate -C, instead of -C_scaled, which is double the distance to the origin of C_scaled.

Let's update our triangle's mesh data solve our problem by finding C_scaled. The formula for a centroid, C, is:

C=(x1+x2+x33,y1+y2+y33)\text{C} = (\frac{\text{x1} + \text{x2} + \text{x3}}{3},\frac{\text{y1} + \text{y2} + \text{y3}}{3})

And with that we can find the scaled centroid, C_scaled and the knowledge that scaling vertices is just multiplying them by s:

Cscaled=((sx1+sx2+sx3)3,(sy1+sy2+sy3)3)C_\text{scaled} = (\frac{(s \cdot \text{x1} + s \cdot \text{x2} + s \cdot \text{x3})}{3},\frac{(s \cdot \text{y1} + s \cdot \text{y2} + s \cdot \text{y3})}{3}) Cscaled=(s(x1+x2+x3)3,s(y1+y2+y3)3)C_\text{scaled} = (\frac{s \cdot (\text{x1} + \text{x2} + \text{x3})}{3},\frac{s \cdot (\text{y1} + \text{y2} + \text{y3})}{3})

Now inputting what we know:

Cscaled(t)=(.5(0+1+.5)3,.5(0+0+1)3)C_\text{scaled}(t) = (\frac{.5 \cdot (0 + 1 + .5)}{3},\frac{.5 \cdot (0 + 0 + 1)}{3}) Cscaled(t)=(.25,.16)C_\text{scaled}(t) = (.25, .1\overline{6})

Now updating the triangle's mesh:

const centroid = (x1,y1, x2, y2, x3, y3, s) => {
	return new Float32Array([s * (x1 + x2 + x3) / 3, s * (y1 + y2 + y3) / 3]);
}

const triangle = new Float32Array([
	0, 0, // vertex 1
	1, 0, // vertex 2
	0.5, 1 // vertex 3
]);

const triangleVertexCount = 3;
const [r, g, b] = new Float32Array([0, 1, 0]); // Green color
let rotation = degToRad(15);
const scale = .5;

const triangle_centroid = centroid(...triangle, scale);
const [x, y] = new Float32Array([-triangle_centroid[0], -triangle_centroid[1]]);
const rotateCenter = triangle_centroid;

A benefit to using the centroid for rotation is that there is no drift so the triangle rotates on itself, similar to a spinning top.

Using Matrices

Before adding our last features, zooming and panning, let's condense our transformations into a simpler and more efficient format – the matrix. Using matrices offers several important benefits:

Now, we’ll separate our transformations into two matrices: the model matrix and the projection matrix. The model matrix will handle transforming an object's local coordinates into world space, handling translation, scaling, and rotation. The projection matrix transforms the pixel space into clip space by accounting for screen dimensions.

The model matrix with uScale (s), uRotation ϕ, uRotateCenter (cx, cy), and uPosition (px, py) looks as such:

TpTcRTcSI[ 100px 010py 0010 0001][ 100cx 010cy 0010 0001][cos(ϕ)sin(ϕ)00sin(ϕ)cos(ϕ)00 0010 0001][ 100cx 010cy 0010 0001][ s000 0s00 0010 0001][xyzw]\begin{array}{ccccccccccc} \text{T}_{p} & & \text{T}_{c} & & \text{R} & & \text{T}_{-c} & & \text{S} & & \text{I} \\ \begin{bmatrix} \ 1 & 0 & 0 & px \\ \ 0 & 1 & 0 & py \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} & \cdot & \begin{bmatrix} \ 1 & 0 & 0 & cx \\ \ 0 & 1 & 0 & cy \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} & \cdot & \begin{bmatrix} \cos(\phi) & -\sin(\phi) & 0 & 0 \\ \sin(\phi) & \cos(\phi) & 0 & 0 \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} & \cdot & \begin{bmatrix} \ 1 & 0 & 0 & -cx \\ \ 0 & 1 & 0 & -cy \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} & \cdot & \begin{bmatrix} \ s & 0 & 0 & 0 \\ \ 0 & s & 0 & 0\\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} & \cdot & \begin{bmatrix} x \\ y \\ z \\ w \end{bmatrix} \end{array}

We can multiply the matrices to get a final transformation matrix before multiplying it by the vector like so:

[ scos(ϕ)ssin(ϕ)0px+cxcxcos(ϕ)+cysin(ϕ) ssin(ϕ)scos(ϕ)0py+cycxsin(ϕ)cycos(ϕ) 0010 0001][xyzw]\begin{bmatrix} \ s \cdot cos(\phi) & -s \cdot sin(\phi) & 0 & px + cx -cx \cdot cos(\phi) + cy \cdot sin(\phi) \\ \ s \cdot sin(\phi) & s \cdot cos(\phi) & 0 & py + cy -cx \cdot sin(\phi) -cy \cdot cos(\phi) \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} \cdot \begin{bmatrix} x \\ y \\ z \\ w \end{bmatrix}

The projection matrix with uInvAspect (m), looks as such:

[ m000 0100 0010 0001]\begin{bmatrix} \ m & 0 & 0 & 0 \\ \ 0 & 1 & 0 & 0 \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix}

With that our final formula is: Thus, our final transformation formula becomes:

projection×model×vertex\text{projection} \times \text{model} \times \text{vertex} [ m000 0100 0010 0001][ scos(ϕ)ssin(ϕ)0px+cxcxcos(ϕ)+cysin(ϕ) ssin(ϕ)scos(ϕ)0py+cycxsin(ϕ)cycos(ϕ) 0010 0001][xyzw]\begin{bmatrix} \ m & 0 & 0 & 0 \\ \ 0 & 1 & 0 & 0 \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} \cdot \begin{bmatrix} \ s \cdot cos(\phi) & -s \cdot sin(\phi) & 0 & px + cx -cx \cdot cos(\phi) + cy \cdot sin(\phi) \\ \ s \cdot sin(\phi) & s \cdot cos(\phi) & 0 & py + cy -cx \cdot sin(\phi) -cy \cdot cos(\phi) \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} \cdot \begin{bmatrix} x \\ y \\ z \\ w \end{bmatrix}

Let’s update our vertex shader to accept two new uniforms: uProjection and uModel and remove the others.

#version 300 es

layout(location = 0) in vec2 vertex;

uniform mat4 uProjection;
uniform mat4 uModel;

void main() {
	gl_Position = uProjection * uModel * vec4(vertex, 0.0, 1.0);
}

Now let’s update our JavaScript to create and send these matrices to the shader. Note: WebGL expects us to pass columns of the matrix rather than rows so you'll see that we have transposed our written matrices above when using matrices in the <script> tag.


	function makeModel({ px, py, cx, cy, angleRad, s }) {
		const cos = Math.cos(angleRad);
		const sin = Math.sin(angleRad);
		const oneMinusCos = 1 - cos;
		// Note: Expressions below simplified by factoring cx and cy
		const xAddon = px + cx * oneMinusCos + cy * sin;
		const yAddon = py + cy * oneMinusCos - cx * sin;
		
		return new Float32Array([
			s * cos, s * sin, 0, 0,
			-s * sin, s * cos, 0, 0,
			0, 0, 1, 0,
			xAddon, yAddon, 0, 1,
		]);
	}

Next, update rotation to use radians instead of degrees since our shader is no longer calculating this value for us:

const degToRad = (degrees) => {
	return degrees * (Math.PI / 180);
}

const rotation = degToRad(0);

Now create and upload the projection and model matrices:


canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const invAspect = Math.fround(canvas.height / canvas.width);
let projection = new Float32Array([
	invAspect, 0, 0, 0,
	0, 1, 0, 0,
	0, 0, 1, 0,
	0, 0, 0, 1,
]);
const uProjection = gl.getUniformLocation(program, "uProjection");
gl.uniformMatrix4fv(uProjection, false, projection);

const model = makeModel({ px: x, py: y, cx: rotateCenter[0], cy: rotateCenter[1], angleRad: rotation, s: scale })
const uModel = gl.getUniformLocation(program, "uModel");
gl.uniformMatrix4fv(uModel, false, model);

Note: We’ve removed all other uniforms except for uColor.

Finally, update the resize handler to refresh the projection matrix whenever the canvas resizes:

window.addEventListener('resize', () => {
	canvas.width = canvas.clientWidth;
	canvas.height = canvas.clientHeight;
	const invAspect = Math.fround(canvas.height / canvas.width);
	projection = new Float32Array([
		invAspect, 0, 0, 0,
		0, 1, 0, 0,
		0, 0, 1, 0,
		0, 0, 0, 1,
	]);
	const uProjection = gl.getUniformLocation(program, "uProjection");
	gl.uniformMatrix4fv(uProjection, false, projection);
	draw();
})

Here is our complete code up to this point:

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl2'); // WebGL2RenderingContext

const vs = `
#version 300 es

layout(location = 0) in vec2 vertex;

uniform mat4 uProjection;
uniform mat4 uModel;

void main() {
	gl_Position = uProjection * uModel * vec4(vertex, 0.0, 1.0);
}
`.trim();

  

const fs = `
#version 300 es
precision highp float;

uniform vec3 uColor;
out vec4 fragColor;

void main() {
	fragColor = vec4(uColor, 1.0);
}
`.trim();

const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vs);
gl.compileShader(vertexShader);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fs);
gl.compileShader(fragmentShader);

const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);

function makeModel({ px, py, cx, cy, angleRad, s }) {
	const cos = Math.cos(angleRad);
	const sin = Math.sin(angleRad);
	const oneMinusCos = 1 - cos;
	
	const xAddon = px + cx * oneMinusCos + cy * sin;
	const yAddon = py + cy * oneMinusCos - cx * sin;
	
	return new Float32Array([
		s * cos, s * sin, 0, 0,
		-s * sin, s * cos, 0, 0,
		0, 0, 1, 0,
		xAddon, yAddon, 0, 1,
	]);
	
}

const degToRad = (degrees) => {
	return degrees * (Math.PI / 180);
}

  

const centroid = (x1,y1, x2, y2, x3, y3, s) => {
	return new Float32Array([s * (x1 + x2 + x3) / 3, s * (y1 + y2 + y3) / 3]);
}

const triangle = new Float32Array([
	0, 0, // vertex 1
	1, 0, // vertex 2
	0.5, 1 // vertex 3
]);

const triangleVertexCount = 3;
const [r, g, b] = new Float32Array([0, 1, 0]); // Green color
let rotation = degToRad(15);
const scale = .5;

const triangle_centroid = centroid(...triangle, scale);
const [x, y] = new Float32Array([-triangle_centroid[0], -triangle_centroid[1]]); 
const rotateCenter = triangle_centroid;

let model = makeModel({ px: x, py: y, cx: rotateCenter[0], cy: rotateCenter[1], angleRad: rotation, s: scale })

canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const invAspect = Math.fround(canvas.height / canvas.width);
let projection = new Float32Array([
	invAspect, 0, 0, 0,
	0, 1, 0, 0,
	0, 0, 1, 0,
	0, 0, 0, 1,
]);

const vao = gl.createVertexArray();
gl.bindVertexArray(vao);

const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, triangle, gl.STATIC_DRAW);

gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(0);

const uProjection = gl.getUniformLocation(program, "uProjection");
gl.uniformMatrix4fv(uProjection, false, projection);

const uModel = gl.getUniformLocation(program, "uModel");
gl.uniformMatrix4fv(uModel, false, model);

const uColor = gl.getUniformLocation(program, "uColor");
gl.uniform3f(uColor, r, g, b);

function draw() {
	gl.viewport(0, 0, canvas.width, canvas.height);
	gl.clearColor(0, 0, 0, 1);
	gl.clear(gl.COLOR_BUFFER_BIT);
	
	gl.bindVertexArray(vao);
	gl.drawArrays(gl.TRIANGLES, 0, triangleVertexCount);
}

draw();

window.addEventListener('resize', () => {
	canvas.width = canvas.clientWidth;
	canvas.height = canvas.clientHeight;
	const invAspect = Math.fround(canvas.height / canvas.width);
	projection = new Float32Array([
		invAspect, 0, 0, 0,
		0, 1, 0, 0,
		0, 0, 1, 0,
		0, 0, 0, 1,
	]);
	
	const uProjection = gl.getUniformLocation(program, "uProjection");
	gl.uniformMatrix4fv(uProjection, false, projection);
	
	draw();

})
Zoom and Pan

We can properly place meshes in our space, now let's add a way to interact with the space my moving and zooming our view. Our view can be thought of as a camera. As we pan our camera around the meshes will appear opposite of the movement and as we zoom our camera, the meshes will appear the scaled.

With this knowledge we can create a new view matrix that will multiply after the model matrix since we want the camera to update our view of the already transformed mesh. So our new formula will be:

projection×view×model×vertex\text{projection} \times \text{view} \times \text{model} \times \text{vertex}

We want to zoom and then, pan our zoomed view so our view matrix will look like this:

P×Z\text{P} \times \text{Z}

Panning is just translating so P (mx, my) is:

[ 100mx 010my 0010 0001]\begin{bmatrix} \ 1 & 0 & 0 & \text{mx} \\ \ 0 & 1 & 0 & \text{my} \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix}

Zooming is just scaling so Z (z) is:

[ z000 0z00 0010 0001]\begin{bmatrix} \ \text{z} & 0 & 0 & 0 \\ \ 0 & \text{z} & 0 & 0 \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix}

Combining:

PZ[ 100mx 010my 0010 0001][ z000 0z00 0010 0001][ z00mx 0z0my 0010 0001]\begin{array}{ccccc} \text{P} & & \text{Z} \\ \begin{bmatrix} \ 1 & 0 & 0 & \text{mx} \\ \ 0 & 1 & 0 & \text{my} \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} & \cdot & \begin{bmatrix} \ \text{z} & 0 & 0 & 0 \\ \ 0 & \text{z} & 0 & 0 \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} & \Rightarrow & \begin{bmatrix} \ z & 0 & 0 & \text{mx} \\ \ 0 & z & 0 & \text{my} \\ \ 0 & 0 & 1 & 0 \\ \ 0 & 0 & 0 & 1 \\ \end{bmatrix} \end{array}

Note: just as we have updated the model matrix's position offset and rotation center according to the model's scale, we must also do the same for mx and my. For example, if the zoom was 2 then panning should only move the camera half the distance (dx, dy) in the opposite direction. Heres a general formula:

(mx,my)(-dxz,-dyz)(\text{mx}, \text{my}) \quad \Rightarrow \quad (\frac{\text{-dx}}{\text{z}}, \frac{\text{-dy}}{\text{z}})

Let's update the triangle's mesh data and create the view matrix:

const makeView = (panX, panY, z) => {
	return new Float32Array([
		z, 0, 0, 0,
		0, z, 0, 0,
		0, 0, 1, 0,
		panX, panY, 0, 1
	]);
}

const getPan = (dx, dy, z) => {
	return new Float32Array([-dx / z, -dy / z]);
}

let zoom = 1.2;
let pan = getPan(.25, .25, zoom);
let view = makeView(...pan, zoom);

Pass the view matrix to the vertex shader:

const uView = gl.getUniformLocation(program, "uView");
gl.uniformMatrix4fv(uView, false, view);

Update the vertex shader

#version 300 es

layout(location = 0) in vec2 vertex;

uniform mat4 uProjection;
uniform mat4 uView;
uniform mat4 uModel;

void main() {
	gl_Position = uProjection * uView * uModel * vec4(vertex, 0.0, 1.0);
}

Let's hook up our zoom and pan to mouse events. For zoom, we will add the wheel event listener and when we scroll up (deltaY < 0), we will multiply zoom by 1.1 and when we scroll down, multiply zoom by .9.

canvas.addEventListener("wheel", e => {
	zoom *= e.deltaY < 0 ? 1.1 : 0.9;
	
	let view = makeView(...pan, zoom);
	const uView = gl.getUniformLocation(program, "uView");
	gl.uniformMatrix4fv(uView, false, view);
	draw();

});

The core idea is of panning is to compute how much the mouse has moved (in pixels) while dragging, convert that motion into clip space coordinates, and update the pan vector accordingly.

An important detail to consider is that screen-space coordinates have their origin at the top-left with the Y-axis increasing downwards. Therefore, when computing vertical movement (dy), we must negate the value to account for this flipped Y-axis.

First, we get the change in pixels from mouse position and the previous mouse position. Then, to convert it into clip space, we normalize by the canvas's dimensions (scaling to the range [0, 1]) and multiply by 2 to account for the full range of clip space ([-1, 1]).

Using the change in clip space and the zoom, we get the change in pan, deltaPan. This deltaPan is then subtracted from the existing pan vector to get our new desired pan.

let isDragging = false;
let lastX, lastY;

canvas.addEventListener("mousedown", (e) => {
	isDragging = true;
	lastX = e.clientX;
	lastY = e.clientY;
});

canvas.addEventListener("mousemove", (e) => {
	if (!isDragging) return;

	const dx = (e.clientX - lastX) / canvas.clientWidth * 2;
	const dy = -(e.clientY - lastY) / canvas.clientHeight * 2;

	const deltaPan = getPan(dx, dy, zoom);
	pan[0] -= deltaPan[0];
	pan[1] -= deltaPan[1];

	view = makeView(...pan, zoom);
	const uView = gl.getUniformLocation(program, "uView");
	gl.uniformMatrix4fv(uView, false, view);
	draw();

	lastX = e.clientX;
	lastY = e.clientY;
});

canvas.addEventListener("mouseup", () => isDragging = false);
canvas.addEventListener("mouseleave", () => isDragging = false);

Lastly, let's update our styles to allow the user to know panning is available:

canvas {
	width: 100%;
	height: 100%;
	display: block;
	cursor: grab;
}

canvas:active {
	cursor: grabbing;
}
Final Code
<!DOCTYPE html>

<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<title>WebGL</title>
		<style>
			html,
			body {
				margin: 0;
				height: 100%;
				overflow: hidden;
			}
			
			canvas {
				width: 100%;
				height: 100%;
				display: block;
				cursor: grab;
			}
			
			canvas:active {
				cursor: grabbing;
			}
		</style>
	</head>
	<body>
	<canvas></canvas>
	<script>
	const canvas = document.querySelector('canvas');
	const gl = canvas.getContext('webgl2'); // WebGL2RenderingContext
	const vs = `
	#version 300 es
	
	layout(location = 0) in vec2 vertex;
	
	uniform mat4 uProjection;
	uniform mat4 uView;
	uniform mat4 uModel;
	
	void main() {
		gl_Position = uProjection * uView * uModel * vec4(vertex, 0.0, 1.0);
	}
	`.trim();
	
	const fs = `
	#version 300 es
	precision highp float;
	
	uniform vec3 uColor;
	out vec4 fragColor;
	
	void main() {
		fragColor = vec4(uColor, 1.0);
	}
	`.trim();
	
	const vertexShader = gl.createShader(gl.VERTEX_SHADER);
	gl.shaderSource(vertexShader, vs);
	gl.compileShader(vertexShader);
	
	const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
	gl.shaderSource(fragmentShader, fs);
	gl.compileShader(fragmentShader);
	
	const program = gl.createProgram();
	gl.attachShader(program, vertexShader);
	gl.attachShader(program, fragmentShader);
	gl.linkProgram(program);
	gl.useProgram(program);
	
	
	function makeModel({ px, py, cx, cy, angleRad, s }) {
		const cos = Math.cos(angleRad);
		const sin = Math.sin(angleRad);
		const oneMinusCos = 1 - cos;
		const xAddon = px + cx * oneMinusCos + cy * sin;
		const yAddon = py + cy * oneMinusCos - cx * sin;
		
		return new Float32Array([
			s * cos, s * sin, 0, 0,
			-s * sin, s * cos, 0, 0,
			0, 0, 1, 0,
			xAddon, yAddon, 0, 1,
		]);
	}
	
	const degToRad = (degrees) => {
		return degrees * (Math.PI / 180);
	}
	
	
	const centroid = (x1, y1, x2, y2, x3, y3, s) => {
		return new Float32Array([
			s * (x1 + x2 + x3) / 3, 
			s * (y1 + y2 + y3) / 3
		]);
	}
	
	const getPan = (dx, dy, z) => {
		return new Float32Array([-dx / z, -dy / z]);
	}
	
	const makeView = (panX, panY, z) => {
	
		return new Float32Array([
		z, 0, 0, 0,
		0, z, 0, 0,
		0, 0, 1, 0,
		panX, panY, 0, 1
		]);
	}
	
	const triangle = new Float32Array([
		0, 0, // vertex 1
		1, 0, // vertex 2
		0.5, 1 // vertex 3
	]);
	
	const triangleVertexCount = 3;
	const [r, g, b] = new Float32Array([0, 1, 0]); // Green color
	let rotation = degToRad(15);
	const scale = .5;
	
	const triangle_centroid = centroid(...triangle, scale);
	const [x, y] = new Float32Array([
		 -triangle_centroid[0],
		 -triangle_centroid[1]
	]);
	const rotateCenter = triangle_centroid;
	
	let zoom = 1.2;
	let pan = getPan(.25, .25, zoom);
	let view = makeView(...pan, zoom);
	
	let model = makeModel({ px: x, py: y, cx: rotateCenter[0], cy: rotateCenter[1], angleRad: rotation, s: scale })
	
	canvas.width = canvas.clientWidth;
	canvas.height = canvas.clientHeight;
	const invAspect = Math.fround(canvas.height / canvas.width);
	let projection = new Float32Array([
		invAspect, 0, 0, 0,
		0, 1, 0, 0,
		0, 0, 1, 0,
		0, 0, 0, 1,
	]);
	
	
	const vao = gl.createVertexArray();
	gl.bindVertexArray(vao);
	
	const vbo = gl.createBuffer();
	gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
	gl.bufferData(gl.ARRAY_BUFFER, triangle, gl.STATIC_DRAW);
	
	gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
	gl.enableVertexAttribArray(0);
	
	const uProjection = gl.getUniformLocation(program, "uProjection");
	gl.uniformMatrix4fv(uProjection, false, projection);
	
	const uView = gl.getUniformLocation(program, "uView");
	gl.uniformMatrix4fv(uView, false, view);
	
	const uModel = gl.getUniformLocation(program, "uModel");
	gl.uniformMatrix4fv(uModel, false, model);
	
	const uColor = gl.getUniformLocation(program, "uColor");
	gl.uniform3f(uColor, r, g, b);
	
	function draw() {
		gl.viewport(0, 0, canvas.width, canvas.height);
		gl.clearColor(0, 0, 0, 1);
		gl.clear(gl.COLOR_BUFFER_BIT);
		
		gl.bindVertexArray(vao);
		gl.drawArrays(gl.TRIANGLES, 0, triangleVertexCount);
	}
	
	draw();
	
	
	window.addEventListener('resize', () => {
		canvas.width = canvas.clientWidth;
		canvas.height = canvas.clientHeight;
		const invAspect = Math.fround(canvas.height / canvas.width);
		projection = new Float32Array([
			invAspect, 0, 0, 0,
			0, 1, 0, 0,
			0, 0, 1, 0,
			0, 0, 0, 1,
		]);
		
		const uProjection = gl.getUniformLocation(program, "uProjection");
		gl.uniformMatrix4fv(uProjection, false, projection);
		
		draw();
	})
	
	canvas.addEventListener("wheel", e => {
		zoom *= e.deltaY < 0 ? 1.1 : 0.9;
		view = makeView(...pan, zoom);
		const uView = gl.getUniformLocation(program, "uView");
		gl.uniformMatrix4fv(uView, false, view);
	
		draw();
	});
	
	let isDragging = false;
	let lastX, lastY;
	
	canvas.addEventListener("mousedown", (e) => {
		isDragging = true;
		lastX = e.clientX;
		lastY = e.clientY;
	});
	
	canvas.addEventListener("mousemove", (e) => {
		if (!isDragging) return;
		
		const dx = (e.clientX - lastX) / canvas.clientWidth * 2;
		const dy = -(e.clientY - lastY) / canvas.clientHeight * 2;
		
		const deltaPan = getPan(dx, dy, zoom);
		pan[0] -= deltaPan[0];
		pan[1] -= deltaPan[1];
		
		view = makeView(...pan, zoom);
		const uView = gl.getUniformLocation(program, "uView");
		gl.uniformMatrix4fv(uView, false, view);
		draw();
		
		lastX = e.clientX;
		lastY = e.clientY;
	});
	
	canvas.addEventListener("mouseup", () => isDragging = false);
	canvas.addEventListener("mouseleave", () => isDragging = false);
	</script>
	</body>
</html>