在 TypeScript 中使用 WebGL

在 TypeScript 中使用 WebGL

本文將介紹如何在 TypeScript 中使用 WebGL。

我們將透過範例程式碼,逐步介紹 WebGL 的基本概念、最小設定、繪製、延伸與類別設計。

YouTube Video

在 TypeScript 中使用 WebGL

WebGL 是一個低階 API,可以讓你在瀏覽器中直接操作 GPU。

透過使用 TypeScript,您可以藉由型別安全、自動補全以及結構化編碼,大幅降低在 WebGL 開發中的「實作錯誤」。

TypeScript 在以下幾點特別有效:。

  • WebGL 物件的型別變得明確。
  • 可以減少操作著色器變數時的錯誤。
  • 追蹤程式結構變得更容易。

WebGL 的最小設定

以下是在 WebGL 中進行繪製的最低限度需求:。

  • <canvas> 元素
  • WebGL 上下文
  • 頂點著色器
  • 片段著色器

首先,讓我們先建立可以初始化畫面的狀態。

取得 Canvas 與 WebGL 上下文

首先,取得 canvasWebGLRenderingContext

在這裡,我們以強調 null 安全性 的 TypeScript 方式來編碼。

 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}
  • 此時,gl 就成為所有 WebGL 操作的進入點。

清除螢幕以確認 WebGL 已經正常運作

在繪製之前,檢查是否可以填滿背景色。

這是確認 WebGL 是否正常運作的初步檢查。

1gl.clearColor(0.1, 0.1, 0.1, 1.0);
2gl.clear(gl.COLOR_BUFFER_BIT);
  • 到目前為止,畫布會被填滿為深灰色。

什麼是著色器?

在 WebGL 中,繪圖邏輯是用一種稱為 GLSL 的特殊語言描述的。在 GLSL 中,主要需要準備兩種類型的著色器:頂點著色器片段著色器

首先,我們先用這兩種著色器建立一個「最精簡可用的著色器」。

撰寫頂點著色器

頂點著色器 決定要繪製的點與圖形的位置。

下面是將一個點顯示在畫面中央的簡單程式碼。

1const vertexShaderSource = `
2attribute vec2 a_position;
3
4void main() {
5	gl_Position = vec4(a_position, 0.0, 1.0);
6}
7`;
  • a_position 是從 JavaScript 傳遞過來的座標。

撰寫片段著色器

片段著色器 決定螢幕上顯示的顏色。

這次的著色器會一直輸出紅色。

1const fragmentShaderSource = `
2precision mediump float;
3
4void main() {
5	gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
6}
7`;
  • 在 WebGL 中,必須要指定 precision

建立一個編譯著色器的功能

由於每次產生著色器的流程都相同,我們將它寫成一個函式。

 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}
  • 這個函式會根據指定的種類建立著色器、編譯來源程式,如果失敗就拋出錯誤,成功則回傳已編譯的著色器。

建立程式(著色器的組合)

將頂點著色器與片段著色器組合為一個程式。

這稱之為 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}
  • 這段程式碼將頂點著色器和片段著色器連結為單一程式,並確認其是否可用於繪製。

準備頂點資料

這裡我們會準備用於繪製三角形的頂點資料。

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]);
  • WebGL 的座標系統範圍是從 -1.01.0

建立緩衝區並傳送至 GPU

接下來,為了讓 GPU 能使用這些頂點資料,我們會利用緩衝區傳送資料。

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);
  • 這段程式碼建立了一個緩衝區來存放頂點資料,並將其內容傳送到 GPU。

將屬性綁定到緩衝區

將著色器中的 a_position 與先前建立的緩衝區連接起來。

 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);

繪製

最後,使用此程式對畫布發出繪製指令。

1gl.useProgram(program);
2gl.drawArrays(gl.TRIANGLES, 0, 3);
  • 當你執行此程式時,畫面會顯示一個 紅色三角形

WebGL 的類別設計

WebGL 是命令式 API,若用傳統方式撰寫,程式碼會急速膨脹。透過類別設計,可以明確區分 初始化、繪製、資源管理

在這裡,我們將運用 TypeScript 的優點,循序漸進地設計以強化 職責分離、可重用性、維護性

設計方針(最小化但實用)

班級分為以下幾個角色。

  • GLApp 負責全體的初始化與繪製。
  • ShaderProgram 負責管理著色器。
  • TriangleMesh 負責管理頂點資料。

首先,讓我們先建立總攬全局的類別。

WebGL 應用程式的進入類別

GLApp 管理畫布與 WebGL 上下文,並作為繪製的起始點。

 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}
  • 這個類別負責管理 WebGL 的初始化與繪製過程,用著色器與網格對螢幕進行繪圖。
  • initializerender 分開,可以更容易地支援未來的重新初始化與動畫。

管理著色器的類別

接著,建立一個用於管理著色器的類別,將著色器的建立、連結和使用集中管理。這樣繪製邏輯就只需專注於「使用」著色器即可。

 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}
  • 這個類別整合了頂點著色器與片段著色器的建立、連結與使用,讓渲染邏輯能安全地善用著色器。
  • 透過對外提供 getAttribLocation,可從 mesh 類別安全地引用。

管理網格(頂點資料)的類別

我們也會建立一個類別來管理頂點資料。將 mesh 類別按要繪製的物件分開後,擴充性變得很高。

 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}
  • 這個類別管理三角形的頂點資料,並與著色器綁定,負責實際的繪製。
  • 所有繪製所需資料,都包含在 TriangleMesh 之中。

進入點(使用範例)

最後,將這些類別組合起來啟動應用程式。

1const canvas = document.getElementById('gl') as HTMLCanvasElement;
2
3const app = new GLApp(canvas);
4app.initialize();
5app.render();
  • 採用這種結構,新增動畫或多重 mesh 也很容易。

用 TypeScript 編寫 WebGL 的實用技巧

在 WebGL 中使用 TypeScript 有以下優點:。

  • 把 WebGL 的處理流程轉為類別後,可以依職責分工,更容易維護與擴充。
  • 將繪製、著色器管理等責任分開,可以提升程式碼的可讀性。
  • 運用 TypeScript 型別補全功能,可以減少呼叫 WebGL API 或指定參數時的錯誤。

總結

利用 TypeScript,即使是底層的 WebGL 處理也能以型別安全且結構化的方式穩定執行。掌握從最小設定到繪製的整體流程,並將初始化、繪製與資源管理等職責以類別分開後,可以大幅提升可讀性與維護性。按步驟實作,你可以把 WebGL 當成可用於實務的知識學習,而不只是黑盒子知識。

您可以在我們的 YouTube 頻道上使用 Visual Studio Code 來跟隨上述文章一起學習。 請也查看我們的 YouTube 頻道。

YouTube Video