該是暫停一下,重新整理一下目前實作過的程式,把可以重用的部份放入程式庫中了,不過,得先解決一個問題,在〈座標系統們〉中談到了左手座標、右手座標,那麼這系列文件用了哪個呢?嗯!都有!
因為一開始接觸 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
中,Mesh
的 translate
是累計在 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);
}
}
OrthographicCamera
與 PerspectiveCamera
預設是放在世界座標原點看向遠面,這樣做的好處是,可以在單個實例上頭,取得觀察矩陣以及投影矩陣等資訊,直接看看範例中的完整程式碼來瞭解目前怎麼使用,這邊就不列出了。
現在的問題是,範例一直都只繪製一個物件,如果有多個物件要繪製呢?你可能也發現了,先前的程式範例,基本上都是換換幾何物件罷了,其他程式碼基本上沒變,著色器程式也相同,這也表示有多個物件要繪製時,就會產生大量重複的程式流程,可以將這些封裝起來。
例如,若想要繪製的物件都是 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,這邊就不示範了,來看看使用 BasicPainter
與 Scene
等之後,程式碼要怎麼寫:
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();
看來不錯,可以按一下完整的範例網頁看結果:
對於簡單的需求,用這種模式就足夠了,對於必須親自掌握著色器與 WebGL API 的情況,也可以使用既有的 Renderer
。