認識著色器


在〈準備 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;
}

要編譯著色器的話很簡單,寫個如下的函式,glContextWebGLRenderingContext 實例,type 會是個常數 VERTEX_SHADERFRAGMENT_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);

WebGLRenderingContextdrawArrays 基於頂點的向量陣列資料來繪製,第一個參數指定了 POINTS 常數,這表示只繪製頂點,第二個參數指定從陣列中哪個索引開始,第三個參數指定要繪製幾筆資料,每取得一筆資料,就會執行一次頂點著色器,計算出裁剪空間中的座標,要進行顏色渲染時,就會執行一次片段著色器。

實際上,至今並沒有指定過陣列資料,而著色器本身的程式碼也沒有要求資料,需要用到的值都是直接寫死在著色器,因此第二個參數實際上沒做用,這邊只要求繪製一個點,因此第三個參數設定為 1。

費盡千辛萬苦,點一下範例網頁吧!你會看到全黑背景正中央有個紅點,嗯?長的像正方形?一個像素點就是一個正方形啊!