大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 14 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。介紹完 WebGL 運作方式與 2D transform 後,本章節講述的是建構、transform 並渲染 3D 物件,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
有了 perspective 投影以及加入反向 cameraMatrix
的 viewkMatrix
,我們擁有一套系統來模擬現實生活中眼睛、相機在想要的位置進行成像,方法也跟場景中的 3D 物件類似:在 cameraMatrix
中加入想要的 transform。同時也可以來比較一下 orthogonal (live 版本)與 perspective (live 版本)投影的差別,最大的差別大概就是物件在不同 z 軸位置時成像的『遠近』了:
matrix4.lookAt()
但是相機往往不會直直地往 -z 方向看,而且常常要對著某個目標,因此再介紹一個常用的 function:
matrix4.lookAt(
cameraPosition,
target,
up,
)
其前兩個參數意義蠻明顯的,分別是相機要放在什麼位置、看著的目標;接著不知道讀者在閱讀這系列文章時,有沒有常常歪著頭看螢幕,對,up
就是控制這件事情,如果傳入 [0, 1, 0]
即表示正正的看,沒有歪著頭看
關於 matrix4.lookAt()
的實做,想當然爾會有 cameraPosition
的平移,因此矩陣的一部分已經知道:
[
?, ?, ?, 0,
?, ?, ?, 0,
?, ?, ?, 0,
cameraPosition.x, cameraPosition.y, cameraPosition.z, 1,
]
剩下的 ?
部份則是相機的方向,首先需要知道從 cameraPosition
到 target
的方向向量 k
,接著拿 up
與 k
向量做外積得到與兩者都垂直的向量 i
,最後拿 k
, i
做外積得到與兩者都垂直的向量 j
,我們就得到 3Blue1Brown 這部 Youtube 影片 -- 三維線性變換 所說的變換矩陣的『基本矢量』,同時為了避免縮放,i
, j
, k
都應為單位向量
在上面這段提到 3 個新的運算:向量差異、外積、單位矩陣化,根據公式在 lib/matrix.js
中實做這幾個 function:
export const matrix4 = {
// ...
subtractVectors: (a, b) => ([
a[0] - b[0], a[1] - b[1], a[2] - b[2]
]),
cross: (a, b) => ([
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]),
normalize: v => {
const length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
// make sure we don't divide by 0.
if (length > 0.00001) {
return [v[0] / length, v[1] / length, v[2] / length];
} else {
return [0, 0, 0];
}
},
// ...
}
最後把 matrix4.lookAt()
實做起來:
export const matrix4 = {
// ...
lookAt: (cameraPosition, target, up) => {
const kHat = matrix4.normalize(
matrix4.subtractVectors(cameraPosition, target)
);
const iHat = matrix4.normalize(matrix4.cross(up, kHat));
const jHat = matrix4.normalize(matrix4.cross(kHat, iHat));
return [
iHat[0], iHat[1], iHat[2], 0,
jHat[0], jHat[1], jHat[2], 0,
kHat[0], kHat[1], kHat[2], 0,
cameraPosition[0],
cameraPosition[1],
cameraPosition[2],
1,
];
},
// ...
}
一樣,電腦中與上方 3Blue1Brown 影片中數學慣例用的行列是相反的,同時可能會有兩個疑問:最核心的相機方向 normalize(subtractVectors(cameraPosition, target))
為何是 kHat
? 而且 subtractVectors()
算出來的向量其實是從 target
到 cameraPosition
的方向?matrix4.perspective()
看著的方向是 -z,要把 -z 轉換成 target
到 cameraPosition
的方向,這個轉換就是 kHat
,又因 "-"z 的關係使得 subtractVectors()
的參數得反向
matrix4.lookAt()
回到主程式,讓 cameraMatrix
使用 matrix4.lookAt()
產生的矩陣:
const cameraMatrix = matrix4.lookAt([250, 0, 400], [250, 0, 0], [0, 1, 0]);
筆者使相機位置與之前 translate 時的位置相同,而且目標會使得相機平平的往 -z 看過去,因此改完之後不會有變化:
為了看出 matrix4.lookAt()
的功能,接下來加入相機位置的控制,不過這次不要再用 <input type='range' />
的 slider 了,筆者決定使用鍵盤 WASD/上下左右、用滑鼠/觸控按住畫面上下左右半部來移動相機
因為要接入的事件很多,而且這些事件都是按下開始移動,放開時候停止,因此讓這些事件 handler 設定相機的速度,再由 requestAnimationFrame
的迴圈來進行相機位置的更新,我們加上這兩個狀態:
// async function setup() {
// ...
return {
gl,
program, attributes, uniforms,
buffers, modelBufferArrays,
state: {
fieldOfView: 45 * Math.PI / 180,
translate: [150, 100, 0],
rotate: [degToRad(210), degToRad(30), degToRad(0)],
scale: [1, 1, 1],
+ cameraPosition: [250, 0, 400],
+ cameraVelocity: [0, 0, 0],
},
time: 0,
};
在 render()
中讓 matrix4.lookAt()
串上剛建立的狀態:
- const cameraMatrix = matrix4.lookAt([250, 0, 400], [250, 0, 0], [0, 1, 0]);
+ const cameraMatrix = matrix4.lookAt(state.cameraPosition, [250, 0, 0], [0, 1, 0]);
啟用 startLoop
,使用 cameraVelocity
來更新 cameraPosition
:
function startLoop(app, now = 0) {
const timeDiff = now - app.time;
app.time = now;
app.state.cameraPosition[0] += app.state.cameraVelocity[0] * timeDiff;
app.state.cameraPosition[1] += app.state.cameraVelocity[1] * timeDiff;
app.state.cameraPosition[2] += app.state.cameraVelocity[2] * timeDiff;
document.getElementById('camera-position').textContent = (
`cameraPosition: [${app.state.cameraPosition.map(f => f.toFixed(2)).join(', ')}]`
);
render(app, timeDiff);
requestAnimationFrame(now => startLoop(app, now));
}
同時筆者打算在畫面上面顯示當前的 cameraPosition
,因此得在 HTML 加入 <p id='camera-position'></p>
,最後就是監聽 keydown
, keyup
, mousedown
, mouseup
, touchstart
, touchend
並處理這些事件,這些程式碼比較冗長、瑣碎,筆者就不放在文章中了,有需要可以在完整程式碼中找到:
就可以用比較直覺的方法在 xy 平面上移動相機囉,在手機上用起來像是這樣: