WebGL in TypeScript

WebGL in TypeScript

This article explains WebGL in TypeScript.

We will introduce the concept of WebGL, its minimal configuration, rendering, extensions, and class design step by step with sample code.

YouTube Video

WebGL in TypeScript

WebGL is a low-level API that allows you to manipulate the GPU directly in the browser.

By using TypeScript, you can greatly reduce 'implementation mistakes' in WebGL through type safety, code completion, and structured coding.

TypeScript is especially effective in the following points:.

  • The types of WebGL objects become clear.
  • Mistakes in handling shader variables can be reduced.
  • It becomes easier to track the structure.

Minimal configuration of WebGL

The following are the minimum requirements for rendering with WebGL:.

  • <canvas> element
  • WebGL context
  • Vertex shader
  • Fragment shader

First, let's create a state where the screen can be initialized.

Acquiring the Canvas and WebGL Context

First, obtain the canvas and WebGLRenderingContext.

Here, we use TypeScript coding with an emphasis on null safety.

 1const canvas = document.getElementById('gl') as HTMLCanvasElement | null;
 2
 3if (!canvas) {
 4	throw new Error('Canvas not found');
 5}
 6
 7const gl = canvas.getContext('webgl');
 8
 9if (!gl) {
10	throw new Error('WebGL not supported');
11}
  • At this point, gl becomes the entry point for all WebGL operations.

Clearing the screen to confirm that WebGL is working

Before rendering, check whether the background color can be filled.

This is the initial check to verify that WebGL is functioning correctly.

1gl.clearColor(0.1, 0.1, 0.1, 1.0);
2gl.clear(gl.COLOR_BUFFER_BIT);
  • Up to this point, the canvas will be filled with a dark gray color.

What is a shader?

In WebGL, drawing processes are described using a special language called GLSL. In GLSL, you mainly prepare two types of shaders: vertex shaders and fragment shaders.

First, we will create a 'minimal working shader' using these two shaders.

Writing the vertex shader

Vertex shaders determine the positions of points or figures to be drawn.

The following is simple code that places a single point at the center of the screen.

1const vertexShaderSource = `
2attribute vec2 a_position;
3
4void main() {
5	gl_Position = vec4(a_position, 0.0, 1.0);
6}
7`;
  • a_position is the coordinate passed from the JavaScript side.

Writing the fragment shader

Fragment shaders determine the colors that appear on the screen.

This time, it always outputs red.

1const fragmentShaderSource = `
2precision mediump float;
3
4void main() {
5	gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
6}
7`;
  • precision must always be specified in WebGL.

Creating a function to compile shaders

Since shader creation processing is always the same, we turn it into a function.

 1function createShader(
 2	gl: WebGLRenderingContext,
 3	type: number,
 4	source: string
 5): WebGLShader {
 6	const shader = gl.createShader(type);
 7	if (!shader) {
 8		throw new Error('Failed to create shader');
 9	}
10
11	gl.shaderSource(shader, source);
12	gl.compileShader(shader);
13
14	if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
15		throw new Error(gl.getShaderInfoLog(shader) ?? 'Shader compile error');
16	}
17
18	return shader;
19}
  • This function creates a shader of the specified type, compiles the source code, throws an error if it fails, and returns the compiled shader if successful.

Creating a program (a set of shaders)

Combine the vertex and fragment shaders into one.

This is called a WebGLProgram.

 1const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
 2const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
 3
 4const program = gl.createProgram();
 5if (!program) {
 6	throw new Error('Failed to create program');
 7}
 8
 9gl.attachShader(program, vertexShader);
10gl.attachShader(program, fragmentShader);
11gl.linkProgram(program);
12
13if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
14	throw new Error(gl.getProgramInfoLog(program) ?? 'Program link error');
15}
  • This code links the vertex and fragment shaders into a single program and checks if they can be used for rendering.

Preparing vertex data

Here, we will prepare vertex data for drawing a triangle.

1// Vertex positions for a triangle in clip space (x, y)
2// Coordinates range from -1.0 to 1.0
3const vertices = new Float32Array([
4	0.0,  0.5,   // Top vertex
5	-0.5, -0.5,  // Bottom-left vertex
6	0.5, -0.5,   // Bottom-right vertex
7]);
  • The coordinate system in WebGL ranges from -1.0 to 1.0.

Creating a buffer and transferring it to the GPU

Next, to make the array of vertex data usable by the GPU, we transfer the data using a buffer.

1const buffer = gl.createBuffer();
2if (!buffer) {
3	throw new Error('Failed to create buffer');
4}
5
6gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
7gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
  • This code creates a buffer to store vertex data and transfers its contents to the GPU.

Associating the attribute with the buffer

Connect a_position in the shader with the buffer previously created.

 1const positionLocation = gl.getAttribLocation(program, 'a_position');
 2
 3gl.enableVertexAttribArray(positionLocation);
 4gl.vertexAttribPointer(
 5	positionLocation,
 6	2,
 7	gl.FLOAT,
 8	false,
 9	0,
10	0
11);

Rendering

Finally, use the program to issue the rendering command.

1gl.useProgram(program);
2gl.drawArrays(gl.TRIANGLES, 0, 3);
  • When you run this program, a red triangle will be displayed.

Class design with WebGL

WebGL is an imperative API, so the code can rapidly become bloated if written as is. Through class design, you can clearly separate initialization, rendering, and resource management.

Here, we will leverage the advantages of TypeScript and progressively evolve the design to focus on separation of concerns, reusability, and maintainability.

Design Policy (Minimal but Practical)

The class is divided into the following roles.

  • GLApp controls overall initialization and rendering.
  • ShaderProgram manages the shaders.
  • TriangleMesh manages the vertex data.

First, let's create the class that controls everything.

Entry class for a WebGL application

GLApp manages the canvas and WebGL context and serves as the starting point for rendering.

 1export class GLApp {
 2	private gl: WebGLRenderingContext;
 3	private shader!: ShaderProgram;
 4	private mesh!: TriangleMesh;
 5
 6	constructor(private canvas: HTMLCanvasElement) {
 7		const gl = canvas.getContext('webgl');
 8		if (!gl) {
 9			throw new Error('WebGL not supported');
10		}
11		this.gl = gl;
12	}
13
14	initialize() {
15		this.gl.clearColor(0.1, 0.1, 0.1, 1.0);
16
17		this.shader = new ShaderProgram(this.gl);
18		this.mesh = new TriangleMesh(this.gl);
19	}
20
21	render() {
22		this.gl.clear(this.gl.COLOR_BUFFER_BIT);
23
24		this.shader.use();
25		this.mesh.draw(this.shader);
26	}
27}
  • This class manages the initialization and rendering processes of WebGL and is responsible for drawing to the screen using shaders and meshes.
  • By separating initialize and render, it is easier to support re-initialization and animation in the future.

Class for managing shaders

Next, create a class to manage shaders, consolidating shader creation, linking, and usage into one place. This allows the rendering side to focus only on 'using' them.

 1export class ShaderProgram {
 2	private program: WebGLProgram;
 3
 4	constructor(private gl: WebGLRenderingContext) {
 5		const vertexSource = `
 6		attribute vec2 a_position;
 7		void main() {
 8			gl_Position = vec4(a_position, 0.0, 1.0);
 9		}
10		`;
11
12		const fragmentSource = `
13		precision mediump float;
14		void main() {
15			gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
16		}
17		`;
18
19		const vs = this.createShader(this.gl.VERTEX_SHADER, vertexSource);
20		const fs = this.createShader(this.gl.FRAGMENT_SHADER, fragmentSource);
21
22		const program = this.gl.createProgram();
23		if (!program) throw new Error('Program create failed');
24
25		this.gl.attachShader(program, vs);
26		this.gl.attachShader(program, fs);
27		this.gl.linkProgram(program);
28
29		if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
30			throw new Error(this.gl.getProgramInfoLog(program) ?? 'Link error');
31		}
32
33		this.program = program;
34	}
35
36	use() {
37		this.gl.useProgram(this.program);
38	}
39
40	getAttribLocation(name: string): number {
41		return this.gl.getAttribLocation(this.program, name);
42	}
43
44	private createShader(type: number, source: string): WebGLShader {
45		const shader = this.gl.createShader(type);
46		if (!shader) throw new Error('Shader create failed');
47
48		this.gl.shaderSource(shader, source);
49		this.gl.compileShader(shader);
50
51		if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
52			throw new Error(this.gl.getShaderInfoLog(shader) ?? 'Compile error');
53		}
54
55		return shader;
56	}
57}
  • This class unifies the creation, linking, and usage of vertex and fragment shaders, enabling the rendering side to use shaders safely.
  • By exposing getAttribLocation, it can be safely referenced from the mesh side.

Class for managing meshes (vertex data)

We will also create a class to manage the vertex data. By dividing mesh classes by object to draw, it becomes easy to extend.

 1export class TriangleMesh {
 2	private buffer: WebGLBuffer;
 3	private vertexCount = 3;
 4
 5	constructor(private gl: WebGLRenderingContext) {
 6		const vertices = new Float32Array([
 7			0.0,  0.5,
 8			-0.5, -0.5,
 9			0.5, -0.5,
10		]);
11
12		const buffer = gl.createBuffer();
13		if (!buffer) throw new Error('Buffer create failed');
14
15		this.buffer = buffer;
16
17		gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
18		gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
19	}
20
21	draw(shader: ShaderProgram) {
22		const gl = this.gl;
23		const positionLoc = shader.getAttribLocation('a_position');
24
25		gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
26		gl.enableVertexAttribArray(positionLoc);
27		gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
28
29		gl.drawArrays(gl.TRIANGLES, 0, this.vertexCount);
30	}
31}
  • This class manages the vertex data of the triangle and, by linking it to the shader, handles the actual rendering.
  • All the information needed for rendering is contained within TriangleMesh.

Entry point (Usage example)

Finally, combine the classes to launch the app.

1const canvas = document.getElementById('gl') as HTMLCanvasElement;
2
3const app = new GLApp(canvas);
4app.initialize();
5app.render();
  • With this structure, adding animations or multiple meshes becomes easy.

Practical tips for writing WebGL in TypeScript

Using TypeScript with WebGL offers the following advantages:.

  • By converting WebGL procedures into classes, they can be organized by role, making maintenance and extension easier.
  • By separating responsibilities such as rendering and shader management, code readability is improved.
  • By utilizing TypeScript’s type completion, you can reduce mistakes in calling WebGL APIs or specifying parameters.

Summary

By using TypeScript, even low-level WebGL processes can be stably handled with type safety and structure. By understanding the flow from minimal configuration to rendering, and by applying class design to separate roles such as initialization, rendering, and resource management, you can improve readability and maintainability. By implementing step by step, you can learn WebGL as practical knowledge that is not a black box and can be applied in real work.

You can follow along with the above article using Visual Studio Code on our YouTube channel. Please also check out the YouTube channel.

YouTube Video