右手座標的 gl-comm


該是暫停一下,重新整理一下目前實作過的程式,把可以重用的部份放入程式庫中了,不過,得先解決一個問題,在〈座標系統們〉中談到了左手座標、右手座標,那麼這系列文件用了哪個呢?嗯!都有!

因為一開始接觸 WebGL 時,一定會先遇到裁剪空間,而裁剪空間是左手座標系,為了避免在一開始就接觸到太多關於座標轉換細節,在定義頂點時,也就都先使用左手座標,一直到〈來個 WebGL 程式庫〉也是基於左手座標來撰寫程式庫。

接著,在〈轉換矩陣〉、〈Rodrigues 旋轉公式〉、〈四元數旋轉矩陣〉進行公式或矩陣導證時,有注意到我其實是基於右手座標嗎?因為在閱讀相關文件時,都是基於右手座標,我也就懶得換成左手座標來導證了。

雖然〈來個 WebGL 程式庫〉是基於左手座標,然而套用右手座標導出來的公式或矩陣,頂多就是令旋轉方向相反,因而或許你也沒注意到範例運行結果是相反的,不過在處理〈觀察矩陣〉、〈正交投影矩陣〉、〈透視投影矩陣〉時,座標系統沒對應,結果就會怪異或看不到了物件,因此,在這些文件中,為了配合〈來個 WebGL 程式庫〉的成果,我其實是基於左手座標來導證矩陣的。

裁剪空間必須得是左手座標,這沒法改,除此之外,在 WebGL 的慣例中,其他座標系統都是採用右手座標系統,為此,這邊將〈來個 WebGL 程式庫〉重新使用右手座標定義了,而〈觀察矩陣〉、〈正交投影矩陣〉、〈透視投影矩陣〉中導出來的矩陣,在這邊也重新以右手座標實作了,並放入了 gl-comm-3.js 之中。

接下來進行程式庫的封裝,以便簡化 WebGL 程式撰寫時一些重複而單調的過程,首先,希望可以基於個別 Mesh 來操作物件,在先前各揰平移、旋轉等操作中,想必覺得使用 mat4 的封裝,雖然簡化了矩陣操作,然而若轉換操作多一些時,很難掌控轉換的順序,最主要的原因在於平移,一但座標被平移轉換處理過後,後續旋轉該怎麼做就很麻煩了。

為了能更直覺地控制物件的動作,物件本身可以帶有其在世界座標中的絕對位置資訊,例如,在 Mesh 上實作:

class Mesh {
    constructor(geometry, material, position = [0, 0, 0]) {
        this.geometry = geometry;
        this.material = material;
        this.mPosition = [
            1, 0, 0, 0,
            0, 1, 0, 0,
            0, 0, 1, 0,
            position[0], position[1], position[2], 1
        ];
        this.mTransformation = mat4.create();
    }

    numberOfvertices() {
        return this.geometry.numberOfvertices();        
    }

    setPosition(x, y, z) {
        this.mPosition[12] = x;
        this.mPosition[13] = y;
        this.mPosition[14] = z;
    }

    getPosition() {
        return this.mPosition.slice(12, 15);
    }

    reset() {
        this.setPosition(0, 0, 0);
        this.mTransformation = mat4.create();        
    }

    getTransformation() {
        return mat4.multiply(this.mPosition, this.mTransformation);
    }    

    translate(tx, ty, tz) {
        this.setPosition(
            this.mPosition[12] + tx, 
            this.mPosition[13] + ty, 
            this.mPosition[14] + tz
        );
    }

    xRotate(radians) {
        this.mTransformation = mat4.xRotate(this.mTransformation, radians);
    }

    yRotate(radians) {
        this.mTransformation = mat4.yRotate(this.mTransformation, radians);
    }

    zRotate(radians) {
        this.mTransformation = mat4.zRotate(this.mTransformation, radians);
    }

    quatRotate(axis, rad) {
        this.mTransformation = mat4.quatRotate(this.mTransformation, axis, rad);
    }    

    scale(sx, sy, sz) {
        this.mTransformation = mat4.scale(this.mTransformation, sx, sy, sz);
    }    
}

個別的 Mesh 實例會記得 mat4 操作後的矩陣結果,setPosition 用來設置絕對位置,位置資訊其實是記錄在一個平移矩陣 mPosition 中,Meshtranslate 是累計在 mPosition,因而隨時可以知道 Mesh 的絕對位置資訊,其他旋轉、縮放等操作,則是累計在 mTransformation 上,getTransformation 取得的矩陣,會是 mPosition 乘上 mTransformation

簡單來說,因為平移總是最後處理,在旋轉、縮放物件時,就可以直覺地看成物件相對於指定位置旋轉、縮放,而不是相對於世界座標原點。

接下來,為了在處理觀察矩陣以及投影矩陣時簡單一些,這邊將它們封裝在一起成為相機的概念:

class Camera {
    constructor(center, position = [0, 0, 0], up = [0, 1, 0]) {
        this.position = position;
        this.center = center;
        this.up = up;
        this.look = mat4.lookAt(this.position, this.center, this.up);
    }

    lookAt(x, y, z) {
        this.center = [x, y, z];
        this.look = mat4.lookAt(this.position, this.center, this.up);
    }

    setPosition(x, y, z) {
        this.position = [x, y, z];
        this.look = mat4.lookAt(this.position, this.center, this.up);
    }    

    setUp(x, y, z) {
        this.up = [x, y, z];
        this.look = mat4.lookAt(this.position, this.center, this.up);
    }    

    viewingWindow() {
        return mat4.multiply(this.projection, this.look);
    }
}

class OrthographicCamera extends Camera {
    constructor(left, right, top, bottom, near, far) {
        super([0, 0, -far]);
        this.left = left;
        this.right = right;
        this.top = top;
        this.bottom = bottom;
        this.near = near;
        this.far = far;
        this.projection = mat4.ortho(left, right, top, bottom, near, far);
    }
}

class PerspectiveCamera extends Camera {
    constructor(fovy, aspect, near, far) {
        super([0, 0, -far]);
        this.fovy = fovy;
        this.aspect = aspect;
        this.near = near;
        this.far = far;
        this.projection = mat4.perspective(fovy, aspect, near, far);
    }
}

OrthographicCameraPerspectiveCamera 預設是放在世界座標原點看向遠面,這樣做的好處是,可以在單個實例上頭,取得觀察矩陣以及投影矩陣等資訊,直接看看範例中的完整程式碼來瞭解目前怎麼使用,這邊就不列出了。

現在的問題是,範例一直都只繪製一個物件,如果有多個物件要繪製呢?你可能也發現了,先前的程式範例,基本上都是換換幾何物件罷了,其他程式碼基本上沒變,著色器程式也相同,這也表示有多個物件要繪製時,就會產生大量重複的程式流程,可以將這些封裝起來。

例如,若想要繪製的物件都是 BasicMaterial 的話,可以寫個 BasicPainter,它的程式碼不過就是從先前範例中重構而來:

class BasicPainter {
    constructor(canvas = document.createElement('canvas')) {
        this.canvas = canvas;
        this.renderer = new Renderer(
            // vertex shader
            `
            uniform mat4 viewingWindow;
            uniform mat4 transformation;

            attribute vec3 position;
            attribute vec4 color;

            varying vec4 fColor;

            void main(void) {
                gl_Position = viewingWindow * transformation * vec4(position.x, position.y, position.z, 1.0);
                fColor = color;
            }
            `, 
            // fragment shader
            `
            precision mediump float;
            varying vec4 fColor;

            void main(void) {
                gl_FragColor = fColor;
            }
            `,
            this.canvas);
    }

    setBackgroundColor(red, green, blue, alpha) {
        this.renderer.setClearColor(red / 255, green / 255, blue / 255, alpha);
    }

    paint(scene) {
        const renderer = this.renderer;
        renderer.clear();
        renderer.uniformMatrix4fv('viewingWindow', scene.camera.viewingWindow());
        
        scene.meshes.forEach(mesh => {
            renderer.uniformMatrix4fv('transformation', mesh.getTransformation());
            
            const geometry = mesh.geometry;
            const material = mesh.material;
            renderer.buffer(GL.ELEMENT_ARRAY_BUFFER, geometry.indexes, GL.DYNAMIC_DRAW);

            const colorBuffer = renderer.buffer(GL.ARRAY_BUFFER, material.vertexColors(), GL.DYNAMIC_DRAW);
            renderer.enableVertexAttribArray(colorBuffer, 'color', 4);
            
            const vertBuffer = renderer.buffer(GL.ARRAY_BUFFER, geometry.verteices, GL.DYNAMIC_DRAW);
            renderer.enableVertexAttribArray(vertBuffer, 'position', 3);
            
            renderer.bindBuffer(GL.ARRAY_BUFFER, vertBuffer);
            renderer.bufferSubData(GL.ARRAY_BUFFER, 0, geometry.verteices);
            renderer.render(mesh);
        });
    }
}

在繪製時需要一個 Scene 實例,它帶有一個相機,並可以收納打算繪製的 Mesh 物件:

class Scene {
    constructor(camera) {
        this.camera = camera;
        this.meshes = new Set();
    }

    addMesh(mesh) {
        this.meshes.add(mesh);
    }

    removeMesh(mesh) {
        this.meshes.delete(mesh);
    }
}

依照類似的封裝方式,你可以重構出具有貼圖的 Painter,或者是混合基本材質與貼圖材質的 Painter,這邊就不示範了,來看看使用 BasicPainterScene 等之後,程式碼要怎麼寫:

import {TetrahedronGeometry, CubeGeometry, IcosahedronGeometry, BasicMaterial, Mesh, OrthographicCamera, PerspectiveCamera, Scene, BasicPainter} from './js/gl-comm-3.js';
            
function randomFaceColors(n) {
    const faceColors = [];
    for(let i = 0; i < n; i++) {
        faceColors.push([Math.random(), Math.random(), Math.random(), 1.0]);
    }
    return faceColors;
}

const canvas = document.getElementById('glCanvas');
const painter = new BasicPainter(canvas);
painter.setBackgroundColor(0, 0, 0, 1);

const perspective = new PerspectiveCamera(
    Math.PI / 4,                               // fovy
    canvas.clientWidth / canvas.clientHeight,  // 寬高比
    0.1,                                       // 近面
    canvas.clientWidth                         // 遠面
);
perspective.setPosition(0, canvas.clientHeight / 8, 0);

const ortho = new OrthographicCamera(
    -canvas.clientWidth / 2,  // 左邊界
    canvas.clientWidth / 2,   // 右邊界
    canvas.clientHeight / 2,  // 上邊界
    -canvas.clientHeight / 2, // 下邊界
    0.1,                      // 近面
    canvas.clientWidth        // 遠面
);
ortho.setPosition(0, canvas.clientHeight / 8, 0);

const scene = new Scene(perspective);
scene.addMesh(new Mesh(new TetrahedronGeometry(75), new BasicMaterial(3, randomFaceColors(4))));
scene.addMesh(new Mesh(new CubeGeometry(75), new BasicMaterial(4, randomFaceColors(6))));
scene.addMesh(new Mesh(new IcosahedronGeometry(75), new BasicMaterial(3, randomFaceColors(20))));

let changeProjection = false;
canvas.addEventListener('mousedown', () => {
    changeProjection = true;
});

const origin = [0, 0, -canvas.clientWidth / 2];
const r = canvas.clientWidth / 4;

let i = 0;
function drawIcosahedronGeometry() {
    if(changeProjection) {
        scene.camera = scene.camera === perspective ? ortho : perspective;
        changeProjection = false;
    }
    i++;
    
    Array.from(scene.meshes)
         .forEach((mesh, j) => {
            const a = 0.025 * i + 2 * Math.PI / scene.meshes.size * j;
            mesh.setPosition(
                origin[0] + r * Math.cos(a), 
                origin[1], 
                origin[2] - r * Math.sin(a)
            );
            mesh.yRotate(0.025);                         
         });

    painter.paint(scene);

    requestAnimationFrame(drawIcosahedronGeometry);                
}

drawIcosahedronGeometry();

看來不錯,可以按一下完整的範例網頁看結果:

右手座標的 gl-comm

對於簡單的需求,用這種模式就足夠了,對於必須親自掌握著色器與 WebGL API 的情況,也可以使用既有的 Renderer