對於 3D 物件,平移、縮放、旋轉是基本的轉換(Transformation)操作,計算方式〈電腦圖學入門〉中有討論,雖然之前的文件在旋轉時,直接實現了公式計算,然而平移、縮放、旋轉使用矩陣相乘來表示的話會更方便而且有效率,這在〈齊次座標〉有列出,為了方便接下來的討論,這邊再列出來:
- 平移
- 縮放
- 旋轉
使用 WebGL 時,為了能夠對 3D 物件進行平移、縮放、旋轉,可以將上頭的矩陣表示實現為程式碼,這時就有了問題了,矩陣表示法是一回事,那麼該怎麼用程式碼來實作矩陣?
直覺地會想到,可以使用 JavaScript 陣列,那麼如何用陣列表示平移矩陣呢?或許有人會這麼想:
[
1, 0, 0, tx,
0, 1, 0, ty,
0, 0, 1, tz,
0, 0, 0, 1
]
視覺上看來,這似乎比較符合矩陣表示法,不過,其實有另外一派的實現方式:
[
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
tx, ty, tz, 1
]
嗯?這看來不自然嗎?不會地,兩派其實各自有人馬支持,因為要使用線性的陣列來表達一個二維的矩陣表示,本來就會有兩種觀點。
第一個派別是列為主(row-major),也就是在線性陣列中實現時是逐列編寫,也就是這派是這麼看待陣列中的矩陣元素的:
[
row1
row2
row3
row4
]
如果你覺得第一個派別比較自然,而第二個派別行為主(column-major)很奇怪,那只是觀點不同,因為行為主的派別是這麼陣列中的矩陣元素的:
[column1 column2 column3 column4]
這樣列出來,應該覺得列為主也是蠻自然的,對吧!
WebGL 是從 OpenGL 沿生而來,許多寫 OpenGL 的開發者,經常忘一件事:矩陣表示法就只是矩陣表示法,線性結構實現時 OpenGL 是行為主。
既然 WebGL 是從 OpenGL 沿生而來,WebGL 中使用陣列實現矩陣時,慣例上遵合 OpenGL 以行為主會比較好,因此,可以將平移、縮放、旋轉的轉換矩陣實作如下:
const mat4 = {
translation(tx, ty, tz) {
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
tx, ty, tz, 1
];
},
scaling(sx, sy, sz) {
return [
sx, 0, 0, 0,
0, sy, 0, 0,
0, 0, sz, 0,
0, 0, 0, 1,
];
},
xRotation(radians) {
const c = Math.cos(radians);
const s = Math.sin(radians);
return [
1, 0, 0, 0,
0, c, s, 0,
0, -s, c, 0,
0, 0, 0, 1,
];
},
yRotation(radians) {
const c = Math.cos(radians);
const s = Math.sin(radians);
return [
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1,
];
},
zRotation(radians) {
const c = Math.cos(radians);
const s = Math.sin(radians);
return [
c, s, 0, 0,
-s, c, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
}
};
如果要將模型旋轉、縮放與平移,那麼著色器程式可以這麼撰寫:
<script id="vertex-shader" type="x-shader/x-vertex">
uniform float aspect;
uniform mat4 rotation;
uniform mat4 scaling;
uniform mat4 translation;
attribute vec3 position;
attribute vec4 color;
varying vec4 fColor;
void main(void) {
vec4 pos = translation * scaling * rotation * vec4(position.x, position.y, position.z, 1.0);
gl_Position = vec4(pos.x, pos.y * aspect, pos.z, 1.0);
fColor = color;
}
</script>
<script id="fragment-shader" type="x-shader/x-fragment">
precision mediump float;
varying vec4 fColor;
void main(void) {
gl_FragColor = fColor;
}
</script>
在頂點著色器中,使用 mat4
型態分別建立了三個 4X4 的矩陣資料,GLSL 的乘法直接支援矩陣運算,只要將代表矩陣的陣列傳入就可以了,例如若使用〈來個 WebGL 程式庫〉中的成果,主要就是在透過 Renderer
的 uniformMatrix4fv
來設置:
renderer.uniformMatrix4fv('rotation', mat4.xRotation(0.5));
renderer.uniformMatrix4fv('scaling', mat4.scaling(1.5, 1, 1));
renderer.uniformMatrix4fv('translation', mat4.translation(0.25, 0.25, 0.25));
在 Renderer
的 uniformMatrix4fv
中,使用 gl
的 uniformMatrix4fv
來實現了:
...
uniformMatrix4fv(name, value) {
this.gl.uniformMatrix4fv(
this.getUniformLocation(name),
false,
value
);
}
...
你可以看一下範例網頁,結果會是個 X 軸方向被拉長的立方體,放置在 [0.25, 0.25, 0.25] 的位置。
雖然 GLSL 處理矩半很方便,然而,我們會有各種轉換模型的需求,為了撰寫上的彈性與方便,在 JavaScript 上實現矩陣乘法還是必要的,因此,可以在 mat4
上增加以下方法:
const mat4 = {
create() {
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
},
...略
multiply(a, b) {
const a00 = a[0 * 4 + 0];
const a01 = a[0 * 4 + 1];
const a02 = a[0 * 4 + 2];
const a03 = a[0 * 4 + 3];
const a10 = a[1 * 4 + 0];
const a11 = a[1 * 4 + 1];
const a12 = a[1 * 4 + 2];
const a13 = a[1 * 4 + 3];
const a20 = a[2 * 4 + 0];
const a21 = a[2 * 4 + 1];
const a22 = a[2 * 4 + 2];
const a23 = a[2 * 4 + 3];
const a30 = a[3 * 4 + 0];
const a31 = a[3 * 4 + 1];
const a32 = a[3 * 4 + 2];
const a33 = a[3 * 4 + 3];
const b00 = b[0 * 4 + 0];
const b01 = b[0 * 4 + 1];
const b02 = b[0 * 4 + 2];
const b03 = b[0 * 4 + 3];
const b10 = b[1 * 4 + 0];
const b11 = b[1 * 4 + 1];
const b12 = b[1 * 4 + 2];
const b13 = b[1 * 4 + 3];
const b20 = b[2 * 4 + 0];
const b21 = b[2 * 4 + 1];
const b22 = b[2 * 4 + 2];
const b23 = b[2 * 4 + 3];
const b30 = b[3 * 4 + 0];
const b31 = b[3 * 4 + 1];
const b32 = b[3 * 4 + 2];
const b33 = b[3 * 4 + 3];
return [
b00 * a00 + b01 * a10 + b02 * a20 + b03 * a30,
b00 * a01 + b01 * a11 + b02 * a21 + b03 * a31,
b00 * a02 + b01 * a12 + b02 * a22 + b03 * a32,
b00 * a03 + b01 * a13 + b02 * a23 + b03 * a33,
b10 * a00 + b11 * a10 + b12 * a20 + b13 * a30,
b10 * a01 + b11 * a11 + b12 * a21 + b13 * a31,
b10 * a02 + b11 * a12 + b12 * a22 + b13 * a32,
b10 * a03 + b11 * a13 + b12 * a23 + b13 * a33,
b20 * a00 + b21 * a10 + b22 * a20 + b23 * a30,
b20 * a01 + b21 * a11 + b22 * a21 + b23 * a31,
b20 * a02 + b21 * a12 + b22 * a22 + b23 * a32,
b20 * a03 + b21 * a13 + b22 * a23 + b23 * a33,
b30 * a00 + b31 * a10 + b32 * a20 + b33 * a30,
b30 * a01 + b31 * a11 + b32 * a21 + b33 * a31,
b30 * a02 + b31 * a12 + b32 * a22 + b33 * a32,
b30 * a03 + b31 * a13 + b32 * a23 + b33 * a33
];
},
translate(m, tx, ty, tz) {
return this.multiply(this.translation(tx, ty, tz), m);
},
scale(m, sx, sy, sz) {
return this.multiply(this.scaling(sx, sy, sz), m);
},
xRotate(m, radians) {
return this.multiply(this.xRotation(radians), m);
},
yRotate(m, radians) {
return this.multiply(this.yRotation(radians), m);
},
zRotate: function(m, radians) {
return this.multiply(this.zRotation(radians), m);
}
};
為了令事情簡單一些,矩陣相乘後會傳回新陣列,而不是在現有的陣列上修改,create
用來建立單位矩陣,作為一開始沒有任何操作時的矩陣。
現在,可以把著色器修改為:
<script id="vertex-shader" type="x-shader/x-vertex">
uniform float aspect;
uniform mat4 transformation;
attribute vec3 position;
attribute vec4 color;
varying vec4 fColor;
void main(void) {
vec4 pos = transformation * vec4(position.x, position.y, position.z, 1.0);
gl_Position = vec4(pos.x, pos.y * aspect, pos.z, 1.0);
fColor = color;
}
</script>
<script id="fragment-shader" type="x-shader/x-fragment">
precision mediump float;
varying vec4 fColor;
void main(void) {
gl_FragColor = fColor;
}
</script>
你只要將轉換處理後的矩陣傳入就可以了,例如,若要有個方塊繞著中心轉動,可以如下:
let transformation = mat4.create();
transformation = mat4.xRotate(transformation, 0.5);
transformation = mat4.yRotate(transformation, 0.5);
transformation = mat4.translate(transformation, 0.25, 0, 0);
let i = 0;
function drawCube() {
i++;
renderer.uniformMatrix4fv('transformation',
mat4.zRotate(transformation, 0.025 * i)
);
renderer.clear();
renderer.bindBuffer(GL.ARRAY_BUFFER, vertBuffer);
renderer.bufferSubData(GL.ARRAY_BUFFER, 0, geometry.verteices);
renderer.render(cube);
requestAnimationFrame(drawCube);
}
drawCube();
可以直接看一下範例網頁,從 -Z 往 +Z 看的話會是逆時針:
效率的展現在於,如果你有一組轉換操作,最後都可以使用一個矩陣來表示,你可以保留這個矩陣,每次都用這個矩陣來轉換,想像一下,如果有 1000 個頂點,也只需要一次矩陣運算,若是單純使用函式實現公式來完成各種轉換,組合 n 次轉換,就是 n * 1000 個函式呼叫,效率的差異就在於此。