在〈準備 WebGL Canvas〉中談到,WebGL 的組成中需要著色器程式,初學 WebGL 時,著色器程式中基本上會有頂點著色器(Vertex shader)及片段著色器(Fragment shader),前者主要負責頂點的運算,將頂點對應至畫面上的二維座標,後者則是計算出需要繪製的像素顏色值。
著色器使用 GLSL 撰寫,並透過 WebGL 的 JavaScript API 編譯、繫結(attach)、鏈結(link)等動作成為著色器程式,接下來會藉由在 Canvas 的正中央繪製一個點,認識一下這個流程。
頂點著色器的作用是產生裁剪空間(Clip space)座標,例如,底下的頂點著色器始終生成裁剪空間中心座標:
void main(void) {
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
在被要求計算裁剪空間座標時,這個頂點著色器實際上沒有從屬性(Attribute)或緩衝區(Buffer)取得資料來進行計算,只是單純地生成固定的座標值 (0.0, 0.0, 0.0),分別代表 (x, y, z),裁剪空間三維座標的分量值必定介於 -1.0 ~ 1.0,超過範圍的資料會被裁剪不被繪製,至於 (x, y, z) 的正方向如下(中心為 (0, 0, 0)):
如果要將 Canvas 的顯示空間對應至裁剪空間,那麼通常可以將 Canvas 顯示空間最左邊對應至裁剪空間的 x 的 -1.0,最右邊對應至 1.0,顯示空間最下方對應至裁剪空間 y 的 -1.0,最上方對應至 1.0,而 z 代表著深度,繪製像素時,z 的資訊會轉換為 0 ~ 1 寫入深度緩衝,在啟用深度測試的情況下,預設像素的深度輸入值小於深度緩衝中對應位置的值才會進行繪製,也就是說,近物遮蓋遠物(可以改變這個行為)。
在 GLSL 中,gl_
開頭的變數意謂著保留的變數,gl_Position
用來指定裁剪空間中的座標,它需要指定 GLSL 中 vec4
型態的值,也就是具有四個分量的浮點數,實際上座標只需要用到前三個分量,第四個分量就座標本身來說用不上,然而慣例上會設為 1.0,這在一些向量計算時會比較方便。
那麼一個片段著色器,看起來會是如何呢?
void main(void) {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
這個片段著色器實際上也沒有從屬性或緩衝區取得資料來進行計算,只是單純地生成固定的的顏色值 (1.0, 0.0, 0.0),也就是 RGB,三個分量必定介於 -1.0 ~ 1.0,同樣地,在這邊 vec4
的第四個分量慣例上會設為 1.0。
在這邊要先知道的是,在被要求渲染時,這邊頂點的頂點著色器直接設定裁剪空間座標為空間的中心,之後片段著色器將直接將之繪製為紅色,之後會看到如何從屬性或緩衝區取得資料進行計算。
著色器要寫在哪呢?可以是個(透過 Ajax 或 Fetch API)下載的檔案,或者是寫在 JavaScript 字串裏(透過 ES6 的模版字串會比較方便),或者是個寫在 <script></script>
裏的文字。例如:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Shader Program</title>
<style>
body {
margin: 0;
overflow: hidden;
}
#glCanvas {
width: 100vw;
height: 100vh;
display: block;
}
</style>
<script id="vertex-shader" type="x-shader/x-vertex">
void main(void) {
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
// 預設的點太小了,設大一些看得清楚些
gl_PointSize = 5.0;
}
</script>
<script id="fragment-shader" type="x-shader/x-fragment">
void main(void) {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
</script>
</head>
<body>
<canvas id="glCanvas">
<script src="shader-1.js" type="text/javascript"></script>
</body>
</html>
type
的設定值 "x-shader/x-vertex"
或 "x-shader/x-fragment"
為自訂的,要設為別的值也可以,反正這表示它不是 JavaScript,之後只要取得對應的 script
的 DOM 元素,透過 textContent
就可以取得著色器程式碼。例如,在 shader-1.js 中寫個函式:
function shaderSourceById(id) {
return document.getElementById(id).textContent;
}
要編譯著色器的話很簡單,寫個如下的函式,glContext
是 WebGLRenderingContext
實例,type
會是個常數 VERTEX_SHADER
或 FRAGMENT_SHADER
,表示要建立哪種著色器,source
是著色器程式的原始碼字串,而 API 名稱應該是很清楚地提示了它在做些什麼:
function shader(glContext, type, source) {
const shader = glContext.createShader(type);
glContext.shaderSource(shader, source);
glContext.compileShader(shader);
if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) {
throw '編譯著色器時發生錯誤:' + glContext.getShaderInfoLog(shader);
}
return shader;
}
接下來,必須將建立的著色器組合為一個著色器程式,並指定給 WebGLRenderingContext
實例使用:
function installProgram(glContext, vertexSource, fragSource) {
const vertexShader = shader(glContext, glContext.VERTEX_SHADER, vertexSource);
const fragShader = shader(glContext, glContext.FRAGMENT_SHADER, fragSource);
const prog = glContext.createProgram();
glContext.attachShader(prog, vertexShader);
glContext.attachShader(prog, fragShader);
glContext.linkProgram(prog);
if (!glContext.getProgramParameter(prog, glContext.LINK_STATUS)) {
throw '編譯著色器時發生錯誤:' + glContext.getProgramInfoLog(prog);
}
glContext.useProgram(prog);
return prog;
}
為了方便,將〈準備 WebGL Canvas〉中取得 WebGLRenderingContext
實例的程式碼也封裝為一個函式:
function getGLContext(glCanvas) {
glCanvas.width = glCanvas.clientWidth;
glCanvas.height = glCanvas.clientHeight;
const gl = glCanvas.getContext('webgl');
if (!gl) {
throw '無法初始化 WebGL,您的瀏覽器不支援';
}
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
return gl;
}
接下來,只要取得 WebGLRenderingContext
實例,安裝著色器程式,就可以來畫個點了:
const gl = getGLContext(document.getElementById('glCanvas'));
installProgram(gl,
shaderSourceById('vertex-shader'),
shaderSourceById('fragment-shader')
);
gl.drawArrays(gl.POINTS, 0, 1);
WebGLRenderingContext
的 drawArrays
基於頂點的向量陣列資料來繪製,第一個參數指定了 POINTS
常數,這表示只繪製頂點,第二個參數指定從陣列中哪個索引開始,第三個參數指定要繪製幾筆資料,每取得一筆資料,就會執行一次頂點著色器,計算出裁剪空間中的座標,要進行顏色渲染時,就會執行一次片段著色器。
實際上,至今並沒有指定過陣列資料,而著色器本身的程式碼也沒有要求資料,需要用到的值都是直接寫死在著色器,因此第二個參數實際上沒做用,這邊只要求繪製一個點,因此第三個參數設定為 1。
費盡千辛萬苦,點一下範例網頁吧!你會看到全黑背景正中央有個紅點,嗯?長的像正方形?一個像素點就是一個正方形啊!