iT邦幫忙

2021 iThome 鐵人賽

DAY 14
0
Modern Web

如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起系列 第 14

使相機看著目標

大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 14 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。介紹完 WebGL 運作方式與 2D transform 後,本章節講述的是建構、transform 並渲染 3D 物件,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容

有了 perspective 投影以及加入反向 cameraMatrixviewkMatrix ,我們擁有一套系統來模擬現實生活中眼睛、相機在想要的位置進行成像,方法也跟場景中的 3D 物件類似:在 cameraMatrix 中加入想要的 transform。同時也可以來比較一下 orthogonal (live 版本)與 perspective (live 版本)投影的差別,最大的差別大概就是物件在不同 z 軸位置時成像的『遠近』了:

orthogonal-vs-perspective-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,
]

剩下的 ? 部份則是相機的方向,首先需要知道從 cameraPositiontarget 的方向向量 k,接著拿 upk 向量做外積得到與兩者都垂直的向量 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() 算出來的向量其實是從 targetcameraPosition 的方向?matrix4.perspective() 看著的方向是 -z,要把 -z 轉換成 targetcameraPosition 的方向,這個轉換就是 kHat,又因 "-"z 的關係使得 subtractVectors() 的參數得反向

使用 matrix4.lookAt()

回到主程式,讓 cameraMatrix 使用 matrix4.lookAt() 產生的矩陣:

const cameraMatrix = matrix4.lookAt([250, 0, 400], [250, 0, 0], [0, 1, 0]);

筆者使相機位置與之前 translate 時的位置相同,而且目標會使得相機平平的往 -z 看過去,因此改完之後不會有變化:

use-lookat

移動相機

為了看出 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 平面上移動相機囉,在手機上用起來像是這樣:

touch-demo


上一篇
視角 Transform
下一篇
Multiple objects (上)
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30

尚未有邦友留言

立即登入留言