使用 Buffer


在〈使用 attribute 變數〉中,每次只傳遞一個向量或浮點數,如果有多個頂點要繪製呢?這時可以透過 Buffer,使用 JavaScript 將資料放入 Buffer,然後著色器從 Buffer 取得資料。

來看看怎麼畫兩個點好了,這只需要用到簡單的著色器:

<script id="vertex-shader" type="x-shader/x-vertex">
    attribute vec3 position;

    void main(void) {
        gl_Position = vec4(position, 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>    

在這邊只有一個 attribute,頂點著色器會從 Buffer 中逐一取出頂點指定給 position,然後執行 main;JavaScript 的部份,使用陣列作為 Buffer,例如:

import {getGLContext, shaderSourceById, installProgram} from './js/gl-comm-1.js';

const gl = getGLContext(document.getElementById('glCanvas'));
const prog = installProgram(gl, 
    shaderSourceById('vertex-shader'), 
    shaderSourceById('fragment-shader')
);

// 兩個頂點
const verteices = [
    0.5, 0.0, 0.0,
    -0.5, 0.0, 0.0
];

// 建立、指定、綁定 Buffer
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verteices), gl.STATIC_DRAW);

在這邊可以看到,因為 getGLContextshaderSourceByIdinstallProgram 經常使用,就放在模組然後匯入了,createBuffer 用來建立 Buffer,bindBuffer 用來指定 gl 接下來的操作會是針對哪個 Buffer,bufferData 會在被綁定的 Buffer 中將指定的資料置入。

因為這邊不做動畫,指定了 gl.STATIC_DRAW,這是個最佳化提示,表示會經常使用 Buffer,然而不常去變動它,在 MDN 的 bufferData 文件中,可以查看到其他提示,像是 gl.DYNAMIC_DRAWgl.STREAM_DRAW 等。

接下來可以指定 position 來取用這個 Buffer:

const attr_position = gl.getAttribLocation(prog, 'position');
gl.vertexAttribPointer(attr_position, 3, gl.FLOAT, false, 0 , 0);
gl.enableVertexAttribArray(attr_position);

vertexAttribPointer 的參數中 3 與 g.FLOAT 表示,每次從 Buffer 中取用三個單位的資料,型態是浮點數;falsenormalized 指定,簡單來說,若設定為 true,會把整數轉換為 -1 ~ 1 的值,因為這邊設定為 g.FLOAT 了,這個參數基本上就是 false(這時設成 true 也沒作用);下一個 0 是 stride,用來指定每單位資料是幾個位元組,被設成 0 的話,就表示使用指定的型態;最後一個 0 表示從 Buffer 哪裏開始讀取,這邊是從陣列開頭開始。

接下來就可以畫兩個點了:

let mode = gl.POINTS;
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(mode, 0, 2);

mode 如果改成 gl.LINES 的話,就會每兩個點連接起來,繪製出一條線來,例如,希望按下滑鼠時可以切換點與線的繪製:

gl.canvas.addEventListener('mousedown', () => {
    mode = mode === gl.POINTS ? gl.LINES : gl.POINTS;
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawArrays(mode, 0, 2);
});

可以點一下範例網頁看看效果,因為會有兩個頂點,頂點著色器會被呼叫兩次,然而卻畫出了一條線,這是片段著色器的功勞,它會自動根據兩個頂點座標處指定的 varying 變數值,計算出插值來執行演算內容,最後指定給 gl_FragColor,這個過程叫柵格化(Rasterisation),就目前來說,並沒有用到 varying 變數,片段著色器的顏色是寫死的,因此兩個點間的像素顏色也就固定了,之後會看到,頂點著色器如何使用 varying 變數傳遞資訊給片段著色器。

除了 gl.POINTSg.LINES 之外,還有 gl.LINE_STRIP 依序連接每個點,gl.LINE_LOOP 依序連接每個點外,最後一個點還會連接第一個點,在上頭的範例因為只有兩個點,設成這兩個效果都一樣,來看看正三角形的繪製好了:

// 正三角頂點
const verteices = [
     0.25, 0.0, 0.0,
     0.0, 0.433, 0.0,
    -0.25, 0.0, 0.0
];

// 建立、指定、綁定 Buffer
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verteices), gl.STATIC_DRAW);

const attr_position = gl.getAttribLocation(prog, 'position');
gl.vertexAttribPointer(attr_position, 3, gl.FLOAT, false, 0 , 0);
gl.enableVertexAttribArray(attr_position);

// 繪製
let i = 0;
let modes = [gl.LINE_STRIP, gl.LINE_LOOP, gl.TRIANGLES];
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(modes[i], 0, 3);

gl.canvas.addEventListener('mousedown', () => {
    i = (i + 1) % 3;
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawArrays(modes[i], 0, 3);
});

按下滑鼠的話,會依序展示 gl.LINE_STRIPgl.LINE_LOOPgl.TRIANGLES 的效果,gl.TRIANGLES 是每次取三個點,畫出一個三角形,你可以先點一下範例網頁看看效果,嗯?不是要畫正三角嗎?你看到的不是正三角?只是個等腰三角?頂點設錯了嗎?

使用 Buffer

如果單看 verteices 中的三個頂點值,確實是正三角沒錯,不過別忘了,在〈認識著色器〉談到:

如果要將 Canvas 的顯示空間對應至裁剪空間,那麼通常可以將 Canvas 顯示空間最左邊對應至裁剪空間的 x 的 -1.0,最右邊對應至 1.0,顯示空間最下方對應至裁剪空間 y 的 -1.0,最上方對應至 1.0。

這表示,如果你的 Canvas 寬高並不是一比一的話,畫出來的東西是會變形的,若不想如此,必須依 Canvas 寬高比例做出修正:

<script id="vertex-shader" type="x-shader/x-vertex">
    uniform float aspect;
    attribute vec3 position;

    void main(void) {
        gl_Position = vec4(position.x, position.y * aspect, position.z, 1.0);
        gl_PointSize = 5.0;
    }
</script>     

這邊看到了 uniform 修飾,它表示在繪製過程中,aspect 的值是固定的,配合這個變數,JavaScript 的部份加入變數設定:

gl.uniform1f(
    gl.getUniformLocation(prog, 'aspect'),
    gl.canvas.clientWidth / gl.canvas.clientHeight
);

要取得 uniform 變數的位置,必須使用 getUniformLocation 方法,而設定值時,使用的是 uniform1f 這類開頭為 uniform 的方法,點一下範例網頁看看效果,應該可以看到正三角了。

來順便看一下 gl.TRIANGLE_STRIPgl.TRIANGLE_FAN 好了。gl.TRIANGLE_STRIP 可以共用頂點,從第三個頂點開始,每個頂點與前兩個頂點連成三角形,也就是後續的三角形,會使用前兩個頂點形成的邊作為共用邊。例如:

const verteices = [
     0.25, 0.0, 0.0,
     0.0, 0.433, 0.0,                 
    -0.25, 0.0, 0.0,
    -0.5, 0.433, 0.0
];

...略

gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

看一下範例網頁,你會看到兩個正三角形成的平行四邊形,下圖列出了頂點關係:

使用 Buffer

至於 gl.TRIANGLE_FAN,正如其名稱所示,適合繪製扇形,因為它從第三個頂點開始,都會與第一個和上個頂點相連,例如:

// 圓的頂點
const r = 0.25;
const fn = 24;
const verteices = [0.0, 0.0, 0.0];
for(let i = 0, step = 2 * Math.PI / fn; i < fn; i++) {
    let a = step * i;
    verteices.push(r * Math.cos(a));
    verteices.push(r * Math.sin(a));
    verteices.push(0.0); 
}
verteices.push(r);
verteices.push(0.0);
verteices.push(0.0);

...

// 繪製
let i = 0;
gl.canvas.addEventListener('mousedown', () => {
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawArrays(gl.TRIANGLE_FAN, 0, 3 + i);
    i = (i + 1) % fn;
});

看一下範例網頁,每點一次就會增加扇形的面積,直到畫出一個圓。