使相机看着目标

大家好,我是西瓜,你现在看到的是 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


<<:  Pima Indians diabetes dataset 考古溯源 & model prediction

>>:  Day08 永丰金API 建立订单交易

菁英软件工程师 #体验, 软件服务是这麽诞生的!

CMoney的日子在周五画上了休止符,尽管最後一个多月的时间遇到了防疫升级,采线上的方式进行,甚至和...

Day 01 HTML<常用标签>

标题标签** <h1> - <h6> (一级标题 - 六级标题) 文字粗体...

JavaScript Day 7. 浅谈 Function

自己常常在写程序的时候,因为习惯一种写法就很自然写下去,不太会去思考为什麽要这样用,就像每天早上都会...

iOS APP 开发 OC 第十天,NSObject

tags: OC 30 day NSObject 是什麽? 是Foundation 框架中的类,在这...

[进阶指南] 深入 JSX( Day25 )

基本上,JSX 单纯只是 React.createElement(component, props...