视点和观看者:在 WebXR 中模拟相机

在考虑管理应用程序中视点和相机的代码时,首先也是最重要的是要了解这一点:WebXR 没有相机。无论是 WebGL 还是 WebXR API 都没有提供可以旋转和移动以自动更改屏幕上显示内容的魔术对象。在本指南中,我们将展示如何使用 WebGL 模拟相机移动,而无需移动相机。这些技术可以在任何 WebGL(或 WebXR)项目中使用。

3D 图形的动画是软件开发的一个领域,它将计算机科学、数学、艺术、图形设计、运动学、解剖学、生理学、物理学和电影摄影的多个学科融合在一起。由于我们没有真正的相机,所以我们想象一个相机,再现拥有相机的效果,而实际上却没有能力在场景中移动用户。

关于 WebGL 和 WebXR 背后的基本数学、几何和其他概念,有一些文章可能在阅读本文之前或同时阅读很有用,包括

编者注:本文中用于显示相机在执行标准移动时移动方式的大多数图表都来自 FilmmakerIQ 网站上的文章;具体来说,来自 此图像,该图像在整个网络中都有。我们假设由于它们经常被重复使用,因此它们是在宽松许可下提供的,所有权尚不确定。我们希望它可以免费使用;如果没有,并且您是所有者,请告诉我们,我们会找到或制作新的图表。或者,如果您乐意让我们继续使用这些图像,请告诉我们,以便我们能够正确地为您提供版权信息!

相机和相对运动

当拍摄一部经典的真人电影时,演员会在一个场景中表演,并随着表演移动,同时有一个或多个摄像机拍摄他们的动作。摄像机可以固定在某个位置,但也可以设置为移动,跟踪表演者的移动,推拉镜头以达到情感效果等等。

虚拟相机

在 WebGL(以及扩展到 WebXR)中,没有可以移动和旋转的相机对象,因此我们必须找到一种方法来伪造这些移动。由于没有相机,我们必须找到一种方法来伪造它。幸运的是,伽利略、牛顿、洛伦兹和爱因斯坦等物理学家为我们提供了相对性原理,该原理指出,物理定律在每个参考系中都具有相同的形式。也就是说,无论您站在哪里,物理定律都以相同的方式起作用。

扩展到,如果您和另一个人站在一个空旷的、坚实的石头地上,除了肉眼所能看到的范围之外什么也看不见,如果您向另一个人移动三米,那么看起来就像另一个人向您移动了三米一样。你们两个都没有办法看到区别。第三方可以分辨出区别,但你们两个不行。如果您是相机,您可以通过移动相机或移动相机周围的所有东西来实现相同的视觉效果。

这就是我们的解决方案。由于我们无法移动相机,因此我们移动相机周围的世界。我们的渲染器需要知道我们想象相机的位置,然后改变每个可见对象的的位置以模拟该位置和方向。因此,在 WebGL 和 WebXR 编程中,我们使用术语相机来指代描述假设观察者在场景中的位置和观看方向的对象,无论该场景中是否有实际存在于 3D 空间中的对象。

视点

由于相机是一个虚拟对象,它并不一定代表虚拟世界中的物理对象,而是代表观察者的位置和观看方向,因此考虑需要使用相机的场景类型非常有用。游戏相关的场景被单独列出,因为它们通常是特定于游戏的特殊情况,但这些视角中的任何一个都可能适用于任何 3D 图形场景。

通用相机

一般来说,虚拟相机可能被包含在场景内的物理对象中,也可能不被包含在其中。事实上,在 3D 游戏之外,相机更有可能不会对应于场景中出现的对象。3D 相机的一些使用方式示例

  • 在渲染动画时,无论是为了电影制作还是为了在演示文稿或游戏中使用,虚拟相机都像现实世界中的电影相机一样使用。尽可能使用标准电影摄影技术,因为观众很可能从小就观看使用这些技术的电影,并且对电影或动画会遵循这些方法有着潜意识的期望。偏离这些方法可能会让观众从此时此刻中抽离出来。
  • 在商业应用程序中,3D 相机用于在渲染图表和图表时设置表观大小和透视效果。
  • 在地图应用程序中,相机可以放置在场景的正上方,也可以使用各种角度来显示透视效果。对于 3D GPS 解决方案,相机的位置设置为显示用户周围的区域,显示屏的大部分区域显示用户运动路径前方的区域。
  • 当使用 WebGL 加速 2D 图形绘制时,相机通常放置在场景中心的正上方,距离和视野设置为允许呈现整个场景。
  • 在加速位图图形时,渲染器会将 2D 图像绘制到 WebGL 纹理的缓冲区中,然后重新绘制纹理以刷新屏幕。这实际上使用纹理作为后缓冲区,用于在您的 2D 图形应用程序中执行多重缓冲

游戏中的相机

游戏类型很多,因此在游戏中也存在多种使用相机的方式。一些常见的情况包括

  • 在第一人称游戏中,相机位于玩家的化身头部内,面向与化身眼睛相同的方向。这样,玩家的屏幕或耳机上呈现的视图就是他们的化身所看到的。
  • 在一些第三人称游戏中,相机位于玩家化身或车辆后方不远处,当他们移动穿过游戏世界时从后面显示他们。这在许多多人在线角色扮演游戏、某些射击游戏等游戏中都有使用。流行的示例包括魔兽世界古墓丽影堡垒之夜。此类别还包括相机放置在玩家肩膀上的游戏。
  • 一些 3D 游戏提供更改视点的能力,例如查看飞行模拟器中飞机的各个窗户,或者查看游戏关卡内所有安全摄像头的视图(间谍和潜行类游戏的常见功能)。此功能也被游戏提供给使用带有瞄准镜的武器的游戏,在这种情况下,视图不再完全基于头部的方位了。
  • 3D 游戏还可以让非玩家角色观察游戏中的动作,方法是放置一个虚拟的化身或选择一个固定的虚拟相机进行观察。
  • 在高级 3D 游戏中,相机或类似相机的对象可以用于确定非玩家角色可以看到什么,依赖于玩家角色使用的相同渲染和物理引擎来模拟非玩家角色。
  • 在单屏 2D 游戏中,相机不会直接与玩家或游戏中的任何其他角色关联,而是固定在游戏区域的上方或旁边,或者随着动作在滚动游戏世界中移动而跟随动作。例如,经典的街机游戏,如吃豆人,在一个固定的游戏地图上进行,因此相机保持固定在地图上方一定距离,始终直直地指向游戏世界。
  • 超级马里奥兄弟等横向滚动或纵向滚动游戏中,相机沿左右方向(或上下方向,或两者兼而有之)移动,以确保即使游戏关卡远远大于视窗,动作仍然可见。

放置相机

由于 WebGL 或 WebXR 中没有标准的相机对象,我们需要自己模拟相机。在模拟相机之前,以及模拟相机运动之前,让我们先看看虚拟相机及其最基本的运动方式。就像所有事物一样,物体在空间中的**位置**——即使是在虚拟空间中——可以使用三个数字来表示,这些数字表示它相对于原点的坐标,原点的坐标定义为 (0, 0, 0)。

还有另一个方面需要考虑的是物体在空间中与原点的空间关系:**透视**。正确应用于场景中的物体,透视可以将原本看起来像典型的二维屏幕一样平坦的场景变得栩栩如生,仿佛它是真正的三维空间。透视有几种类型;这些类型及其数学公式在文章WebGL 模型视图投影中定义和解释。重要的是,透视对向量的影响可以通过向向量添加第四个分量来表示:透视分量,称为 w

w 的值通过将其除以其他三个分量的每一个来应用,从而得到最终位置或向量;也就是说,对于给定的坐标 (x, y, z, w),三维空间中的点实际上是 (x/w, y/w, z/w, 1) 或 (x/w, y/w, z/w)。如果您没有使用透视,w 始终为 1。在这种情况下,位于 (1, 0, 3) 的物体的完整坐标为 (1, 0, 3, 1)。

但是,位置不足以描述三维空间中的物体,因为物体在空间中的状态不仅仅是其位置,还包括其旋转或朝向方向,也称为其**方向**。方向可以使用三维向量来表示,该向量通常被标准化,使其长度为 1.0。例如,如果物体面向位于 (3, 1, -2) 的物体——即,距离原点右移 3 米、向上移动 1 米、后退 2 米——则结果是

[ 3 1 - 2 ] \left [ \begin{matrix} 3 \\ 1 \\ -2 \end{matrix} \right ]

这也可以表示为数组

js
let directionVector = [3, 1, -2];

为了执行涉及坐标和朝向方向向量两者的操作,向量需要包含 w 分量。对于向量,w 的值始终为 0,因此上述向量也可以使用 [3, 1, -2, 0]

[ 3 1 - 2 0 ] \left [ \begin{matrix} 3 \\ 1 \\ -2 \\ 0 \end{matrix} \right ]

WebXR 会自动将向量标准化为长度为 1 米;但是,您可能会发现出于各种原因,自己执行标准化是有意义的,例如为了提高计算性能,不必反复执行标准化。

确定表示您希望相机执行的组合运动的矩阵后,您需要将其反转,因为您实际上并没有移动相机。由于您实际上移动的是除了相机之外的所有东西,因此对变换矩阵求逆以获得逆变换矩阵。然后,此逆矩阵可以应用于世界中的物体,以改变其位置和方向,从而模拟所需的相机位置。

这就是为什么 WebXR 用于表示变换的 XRRigidTransform 对象包含一个 inverse 属性。inverse 属性是另一个 XRRigidTransform 对象,它是父变换的逆。由于表示视图的 XRView 具有一个 transform 属性,该属性是提供相机视图的 XRRigidTransform,因此您可以获取模型视图矩阵——模拟所需相机位置所需的变换矩阵——如下所示

js
let viewMatrix = view.transform.inverse.matrix;

如果使用的库可以直接接受 XRRigidTransform 对象,则可以使用 view.transform.inverse,而不是仅提取表示视图矩阵的数组。

组合多个变换

如果您的相机需要同时执行多个变换,例如同时进行缩放和平移,则可以将变换矩阵相乘以将其组合成一个矩阵,该矩阵可以同时应用两种更改。请参见文章 WebGL 矩阵数学 中的 乘以两个矩阵,了解一个清晰易读的执行此操作的函数,或者使用您喜欢的矩阵数学库(如 glMatrix)来完成这项工作。

必须牢记,与典型的算术不同,算术中的乘法是可交换的(即,无论您从左到右还是从右到左乘以,结果都相同),矩阵乘法不是可交换的! 这是因为每个变换都会影响物体的位置,甚至可能影响坐标系本身,这会极大地改变执行的下一个操作的结果。因此,在构建合成变换(或直接按顺序应用变换)时,您需要小心应用变换的顺序。

应用变换

要应用变换,请将点或向量乘以变换或变换组合。

以上是对位置概念的快速概述,包括物理位置、方向或朝向方向和透视。有关该主题的更多详细信息,请参阅文章 几何形状和参考空间WebGL 模型视图投影WebGL 矩阵数学

模拟经典电影摄影

摄影术是设计、规划和执行相机运动以在动画或电影中为场景创造所需的外观和情感的艺术。有一些术语非常有用,主要与相机运动有关,因为这些术语用于描述使用虚拟相机设计的视点变化。同时执行多个运动也是完全可能的;例如,您可以同时平移相机并放大场景。

请记住,大多数相机运动是相对于相机的参考空间来描述的。

存储矩阵的格式通常为按列主序排列的扁平数组;也就是说,矩阵的值从左上角开始写入,并向下移动到底部,然后向右移动一行,并重复此过程,直到数组中包含所有值。

因此,看起来像这样的矩阵

[ a 1 a 5 a 9 a 13 a 2 a 6 a 10 a 14 a 3 a 7 a 11 a 15 a 4 a 8 a 12 a 16 ] \left [ \begin{matrix} a_{1} & a_{5} & a_{9} & a_{13} \\ a_{2} & a_{6} & a_{10} & a_{14} \\ a_{3} & a_{7} & a_{11} & a_{15} \\ a_{4} & a_{8} & a_{12} & a_{16} \end{matrix} \right ]

在数组形式中表示如下

js
let matrixArray = [
  a1, a2, a3, a4,
  a5, a6, a7, a8,
  a9, a10, a11, a12,
  a13, a14, a15, a16,
];

在这个数组中,最左边的列包含条目 a1a2a3a4。最上面的行包含条目 a1a5a9a13

请记住,大多数 WebGL 和 WebXR 编程都是使用扩展 WebGL 基本功能的第三方库完成的,这些库添加了一些例程,使其更易于执行核心矩阵和其他操作,以及模拟这些标准摄影技术。您应该认真考虑使用其中一个库,而不是直接使用 WebGL。本指南直接使用 WebGL,因为它有助于在一定程度上理解幕后发生的事情,并帮助开发库或优化代码。

注意:虽然我们使用诸如“移动相机”之类的短语,但我们实际上是在移动围绕相机的整个世界。这会影响某些值的工作方式,这些值将在下面出现时进行说明。

缩放

最著名的相机效果之一是**缩放**。在物理相机中,通过改变镜头的焦距来执行缩放;这是镜头中心到相机光传感器之间的距离。因此,缩放实际上并不涉及移动相机。相反,缩放镜头会随着时间的推移改变相机的放大倍数,使焦点区域看起来更靠近或更远离观看者,而无需实际移动相机。缓慢移动可以为场景带来运动、轻松或聚焦感,而快速缩放可以营造焦虑、惊讶或紧张感。

由于缩放不会移动相机的位置,因此产生的效果不自然。人眼没有缩放镜头。我们通过远离或靠近物体来使物体变大或变小。在摄影中,这称为推拉镜头

3D 图形中有两种技术可以创建类似但并不完全相同的结果,它们的方法在不同的情况下更容易应用。

通过调整视场进行缩放

您可以通过改变相机的**视场** (FOV) 来执行更类似于真正的“缩放”操作。视场是一个角度,定义了相机周围整个可视区域上的弧线的长度,该弧线应同时可见。这是物理相机中焦距的一种效果,因此由于没有真正的相机,因此改变 FOV 是一个可行的替代方法。

回想一下,圆的周长是 2π⋅r 弧度(360°);因此,这是理论上的最大 FOV。但实际上,不仅人类的视野远没有那么大,而且显示器和 VR 眼镜等观看设备往往会进一步缩小视场。人眼的水平视场通常约为 135°(约 2.356 弧度),垂直 FOV 约为 180°(π 或约 3.142 弧度)。

缩小相机的 FOV 会减小视口中包含的弧线,从而在渲染到视图时放大该内容。这与光学变焦效果之间存在差异,但结果通常足够接近以完成工作。

以下函数返回一个投影透视矩阵,该矩阵集成了指定的视场角以及给定的近裁剪平面距离和远裁剪平面距离

js
function createPerspectiveMatrix(viewport, fovDegrees, nearClip, farClip) {
  const fovRadians = fovDegrees * (Math.PI / 180.0);
  const aspectRatio = viewport.width / viewport.height;

  const transform = mat4.create();
  mat4.perspective(transform, fovRadians, aspectRatio, nearClip, farClip);
  return transform;
}

在将 FOV 角 fovDegrees 从度数转换为弧度,并计算由 viewport 参数指定的 XRViewport 的纵横比之后,此函数使用 glMatrix 库的 mat4.perspective() 函数来计算透视矩阵。

透视矩阵将视场(严格来说,这是垂直视场)、纵横比以及近裁剪平面和远裁剪平面封装在 4x4 矩阵 transform 中,然后将其返回给调用者。

近裁剪平面是距离显示器表面平行平面上的距离,在此平面内不会绘制任何内容。任何位于该平面与摄像机相同侧的顶点都不会被绘制。相反,远裁剪平面是距离该平面以外的距离,在此平面以外不会绘制任何顶点。

要使用缩放比例或百分比进行缩放,可以将 1x(正常尺寸的 100%)映射到您允许的 FOV 最大值(这将导致显示最多内容),然后将您的最大放大倍数映射到支持的 FOV 最大值,并映射之间的对应值。

如果在每一帧的渲染传递开始时计算透视矩阵,那么您就可以将所有其他需要应用的变换乘入该矩阵,以获得帧所需的几何图形。例如

js
const transform = createPerspectiveMatrix(viewport, 130, 1, 100);
const translateVec = vec3.fromValues(
  -trackDistance,
  -craneDistance,
  pushDistance,
);
mat4.translate(transform, transform, translateVec);

这从表示 130° 垂直视场的透视矩阵开始,然后应用一个平移,以移动摄像机,包括 跟踪起重机推入 运动。

缩放变换

与真正的“缩放”不同,**缩放** 涉及将位置或顶点中的每个 xyz 坐标值乘以该轴的缩放因子。这些对于每个轴可能不一定相同,尽管您所能获得的与缩放效果最接近的结果将涉及对每个轴使用相同的值。这需要应用于场景中的每个顶点——理想情况下是在顶点着色器中。

如果您要放大 2 倍,则需要将每个分量乘以 2.0。要缩小相同倍数,将它们乘以 -2.0。在矩阵术语中,这使用将缩放因子融入其中的变换矩阵来执行,如下所示

js
let scaleTransform = [
  Sx, 0, 0, 0,
  0, Sy, 0, 0,
  0, 0, Sz, 0,
  0, 0, 0, 1
];

此矩阵表示一个变换,该变换按 (Sx, Sy, Sz) 表示的因子放大或缩小,其中 Sx 表示沿 X 轴的缩放因子,Sy 表示沿 Y 轴的缩放因子,Sz 表示沿 Z 轴的缩放因子。如果这些值中的任何一个与其他值不同,则结果将是拉伸或收缩,这与其他维度相比在某些维度上有所不同。

如果要在每个方向应用相同的缩放因子,可以创建一个简单的函数来为您生成缩放变换矩阵

js
function createScalingMatrix(f) {
  return [f, 0, 0, 0, 0, f, 0, 0, 0, 0, f, 0, 0, 0, 0, 1];
}

有了变换矩阵,我们将变换 scaleTransform 应用于向量(或顶点)myVector

js
let myVector = [2, 1, -3];
let scaleTransform = [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1];
vec4.transformMat4(myVector, myVector, scaleTransform);

或者,使用上述 createScalingMatrix() 函数沿每个轴进行缩放,使用相同的因子

js
let myVector = [2, 1, -3];
vec4.transformMat4(myVector, myVector, createScalingMatrix(2.0));

平移(向左或向右偏航)

**平移** 或 **偏航** 是摄像机向左或向右旋转,而其基座在其他方面保持固定。摄像机在空间中的位置不会改变,只有它所看的方向会改变。而且该方向不会发生变化,除了水平方向。平移非常适合建立场景或在广阔的空间或巨大的物体上提供范围感。或者只是向左和向右看,就像模拟玩家在沉浸式或 VR 场景中转动头部一样。

A diagram showing a camera panning left or right

为此,我们需要绕 Y 轴旋转,以模拟摄像机的左右旋转。使用我们之前使用过的 glMatrix 库,这可以使用 mat4 类上的 rotateY() 方法来执行,该方法表示标准 4x4 矩阵。要将矩阵 viewMatrix 定义的视点旋转 panAngle 弧度

js
mat4.rotateY(viewMatrix, viewMatrix, panAngle);

如果 panAngle 为正,则此变换将使摄像机向右平移;panAngle 的负值将向左平移。

倾斜(向上或向下俯仰)

当您**倾斜** 或 **俯仰** 摄像机时,您会将其固定在空间中相同的坐标处,同时更改其垂直方向,而不会改变其水平方向。它调整它指向向上和向下的方向。倾斜非常适合捕捉高大物体或场景的范围,例如森林或山脉,但也常用于引入具有重要意义或激发敬畏之情的角色或地点。当然,它也适用于实现玩家向上和向下看。的

A diagram showing a camera tilting up and down

因此,可以通过绕 X 轴旋转摄像机来实现倾斜摄像机,以便摄像机枢转以向上和向下看。这可以使用矩阵数学库中的相应方法来完成,例如 glMatrix 的 mat4 类中的 rotateX() 方法

js
mat4.rotateX(viewMatrix, viewMatrix, angle);

angle 的正值将使摄像机向下倾斜,而 angle 的负值将向上倾斜。

推轨(进出移动)

**推轨** 镜头是指整个摄像机向前和向后移动的镜头。在传统的电影制作中,这通常是通过将摄像机安装在轨道或移动车辆上实现的。产生的运动可以产生令人印象深刻的平滑效果,尤其是在与镜头焦点的对象或人一起移动时。

A diagram showing how a camera moves for a dolly shot

虽然推轨镜头和缩放看起来应该差不多,但实际上并不一样。缩放会改变摄像头的焦距,这意味着目标与其周围环境的空间关系不会改变,即使目标在画面中变大或变小。另一方面,推轨镜头通过实际移动摄像机来复制物理运动的感觉,从而导致场景中物体的关系发生变化,就像您预期的那样,当您走向或远离镜头的目标时,您会经过这些物体。

要执行推轨操作,请沿 Z 轴前后平移摄像机视图

js
mat4.translate(viewMatrix, viewMatrix, [0, 0, dollyDistance]);

这里,[0, 0, dollyDistance] 是一个向量,其中 dollyDistance 是推轨摄像机的距离。由于这是通过将整个世界绕摄像机移动来实现的,因此这里真正发生的是,整个世界相对于摄像机沿 Z 轴移动了 dollyDistance 米。如果 dollyDistance 为正,则世界将向用户移动该距离,导致摄像机更靠近场景。相反,dollyDistance 的负值会将世界移开用户,导致摄像机看起来从目标向后移动。

横移(向左或向右移动)

使用物理摄像机**横移** 使用与推轨相同的装配,但不是将摄像机前后移动,而是左右移动。摄像机不会旋转,因此镜头的焦点会慢慢滑出屏幕。这可以表明专注、时间流逝或沉思,当试图在场景中建立情感时。它也经常用于“边走边说”的场景中,其中摄像机与角色一起滑动,角色穿过场景。

A diagram showing how a camera trucks left and right

要将摄像机左右移动,请沿 X 轴平移视图矩阵,方向与所需摄像机移动方向相反

js
mat4.translate(viewMatrix, viewMatrix, [-truckDistance, 0, 0]);

注意向量 [-truckDistance, 0, 0]。这补偿了横移操作是通过移动世界而不是摄像机来实现的事实。通过将整个世界移到与 truckDistance 指示的方向相反的方向,我们实现了将摄像机移动到预期方向的效果。这样,truckDistance 的正值将使摄像机向右移动(通过将世界向左移动),truckDistance 的负值将使摄像机向左移动(通过将世界向右移动)。

升降(向上或向下移动)

**升降** 镜头是指将摄像机水平固定在地板上,但直接向上或向下移动的镜头。想象一下,摄像机安装在底座(或杆子)上,底座(或杆子)会变高或变矮。当跟踪越来越高或越来越矮的物体、从椅子上站起来或坐下、或直接向上或向下移动时,这很有用。

A diagram showing a camera moving up and down using a pedestal motion

这类似于**起重机** 镜头,它涉及将连接到起重机的摄像机上下移动。要执行升降或起重机运动,请沿 Y 轴平移视图,方向与您想要移动摄像机的方向相反

js
mat4.translate(viewMatrix, viewMatrix, [0, -pedestalDistance, 0]);

通过对 pedestalDistance 的值取反,我们补偿了实际上是移动世界而不是摄像机的事实。因此,pedestalDistance 的正值将使摄像机向上移动,而负值将使摄像机向下移动。

倾斜(左右滚动)

**倾斜**(或**滚动**)是摄像机绕其滚动轴旋转;也就是说,摄像机保持固定在空间中,并保持指向相同的位置,但绕着旋转,使摄像机的顶部指向不同的方向。

A diagram showing a camera rolling left and right

您可以通过将手臂伸到您面前并张开手掌来可视化这一点。想象一下,您的手是摄像机,您的手背代表摄像机的顶部。现在旋转您的手,使“摄像机”上下颠倒。您刚刚绕滚动轴倾斜了您的手。在电影摄影中,倾斜可以用来模拟各种不稳定的运动,例如波浪或湍流,但也可以用来产生戏剧性的效果。

要使用 glMatrix 完成此绕 Z 轴的旋转

js
mat4.rotateZ(viewMatrix, viewMatrix, cantAngle);

组合动作

您可以同时执行多个运动,例如缩放时平移,或同时倾斜和倾斜。

沿多个轴平移

沿多个轴平移非常容易。之前,我们是这样执行平移的

js
mat4.translate(viewMatrix, viewMatrix, [-truckDistance, 0, 0]);
mat4.translate(viewMatrix, viewMatrix, [0, -pedestalDistance, 0]);
mat4.translate(viewMatrix, viewMatrix, [0, 0, dollyDistance]);

这里的解决方案很明显。由于平移表示为一个向量,提供沿每个轴移动的距离,因此我们可以将它们组合起来,如下所示

js
mat4.translate(viewMatrix, viewMatrix, [
  -truckDistance,
  -pedestalDistance,
  dollyDistance,
]);

这将使矩阵 viewMatrix 的原点沿每个轴移动指定的量。

绕多个轴旋转

您还可以将绕多个轴的旋转组合成绕表示旋转共享轴的四元数的单个旋转。要分别执行旋转,您可以使用 欧拉角(绕每个轴的单独角度)来应用俯仰、偏航和滚动,如下所示

js
mat4.rotateX(viewMatrix, viewMatrix, pitchAngle);
mat4.rotateY(viewMatrix, viewMatrix, yawAngle);
mat4.rotateZ(viewMatrix, viewMatrix, rollAngle);

您也可以从欧拉角构建一个 四元数,表示组合的旋转轴,然后使用乘法来旋转矩阵,如下所示

js
const axisQuat = quat.create();
const rotateMatrix = mat4.create();
quat.fromEuler(axisQuat, pitchAngle, yawAngle, rollAngle);
mat4.fromQuat(rotateMatrix, axisQuat);
mat4.multiply(viewMatrix, viewMatrix, rotateMatrix);

这将俯仰、偏航和滚动的欧拉角转换为表示所有三个旋转的四元数。然后将其转换为旋转变换矩阵;最后,视图矩阵乘以旋转变换以完成旋转。

用 WebXR 表示 3D

WebXR 使 3D 图形更进一步,允许它们使用特殊的视觉硬件(例如护目镜或耳机)来呈现,从而创建看起来真正存在于三个维度中的 3D 图形,可能存在于现实世界的背景中(例如增强现实)。

为了感知深度,必须对场景有两个视角。通过比较这两个视图,可以识别物体的深度,以及观看者与所见物体的距离。这就是我们有两眼的原因,它们之间略微分开。您可以通过一次闭上一只眼睛,在两只眼睛之间来回切换,来提醒自己这个事实。注意,您的左眼可以看到您鼻子的左侧,但看不到右侧,而您的右眼可以看到您鼻子的右侧,但看不到左侧。这只是您两只眼睛看到的许多差异之一。

我们的大脑从视野中的每个眼睛接收两组关于光照水平和波长的数据。大脑利用这些数据在我们脑海中构建场景,利用这两个视角之间细微的差异来判断深度和距离。

渲染场景

XR(涵盖虚拟现实 (VR) 和增强现实 (AR) 的缩写)头显通过绘制场景的两个略微偏移的视图来向我们呈现 3D 图像,就像我们两只眼睛获得的视图一样。然后,这些视图分别馈送到每个眼睛,以便它们能够收集我们的大脑构建我们脑海中 3D 图像所需的数据。

为此,WebXR 要求你的渲染器为每一帧视频绘制两次场景——一次用于每只眼睛。这两个视图被渲染到同一个帧缓冲区,一个在左边,一个在右边。XR 设备然后使用屏幕和透镜将产生的图像的左半部分呈现给我们的左眼,将右半部分呈现给我们的右眼。

例如,考虑一个使用 2560x1440 像素帧缓冲区的设备。将其分成两部分——每只眼睛一半——导致每只眼睛的视图以 1280x1440 像素的分辨率绘制。从概念上讲,它看起来像这样

Diagram showing how a framebuffer is divided between two eyes' viewpoints

你的代码通过调用 XRSession 方法 requestAnimationFrame() 来告诉 WebXR 引擎你想要提供下一个动画帧,并提供一个渲染动画帧的回调函数。当浏览器需要你渲染场景时,它会调用回调函数,并将当前时间和一个 XRFrame 作为输入参数提供,该参数封装了渲染正确帧所需的数据。

此信息包括描述场景中查看者位置和面向方向的 XRViewerPose 以及 XRView 对象列表,每个对象代表场景的一个视角。在当前的 WebXR 实现中,此列表中永远不会超过两个条目:一个描述左眼的定位和视角,另一个描述右眼的定位和视角。你可以通过检查其 eye 属性的值来判断哪个眼睛代表了一个给定的 XRView,该属性是一个字符串,其值为 leftright(理论上,第三个可能的值 none 可用于表示另一个视点,但对它的支持在当前 API 中并不完全可用)。

示例帧回调

渲染帧的相当基本(但典型)的回调可能如下所示

js
function myAnimationFrameCallback(time, frame) {
  const adjustedRefSpace = applyPositionOffsets(xrReferenceSpace);
  const pose = frame.getViewerPose(adjustedRefSpace);

  animationFrameRequestID = frame.session.requestAnimationFrame(
    myAnimationFrameCallback,
  );

  if (pose) {
    const glLayer = frame.session.renderState.baseLayer;
    gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer);
    CheckGLError("Binding the framebuffer");

    gl.clearColor(0, 0, 0, 1.0);
    gl.clearDepth(1.0);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    CheckGLError("Clearing the framebuffer");

    const deltaTime = (time - lastFrameTime) * 0.001;
    lastFrameTime = time;

    for (const view of pose.views) {
      const viewport = glLayer.getViewport(view);
      gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
      CheckGLError(`Setting viewport for eye: ${view.eye}`);

      myRenderScene(gl, view, sceneData, deltaTime);
    }
  }
}

回调函数首先调用一个自定义函数 applyPositionOffsets(),该函数接收一个参考空间,并将其变换矩阵应用于任何需要进行的更改,以考虑来自非 WebXR 控制的设备(如键盘和鼠标)的用户输入。此函数返回的调整后的 XRReferenceSpace 然后传递到 XRFrame 方法 getViewerPose() 以获取代表查看者位置和视角的 XRViewerPose

接下来,我们继续排队请求渲染下一帧视频,因此我们不必担心稍后执行此操作,方法是再次调用 requestAnimationFrame()

现在是渲染场景的时候了。如果我们成功地获得了姿势,我们从会话的 renderState 对象的 baseLayer 属性中获取渲染所需使用的 XRWebGLLayer。我们使用 WebGLRenderingContext 方法 gl.bindFrameBuffer() 将其绑定到 WebGL 的 gl.FRAMEBUFFER 目标。

然后,我们清除帧缓冲区,以确保我们从已知状态开始,因为我们的渲染器不会触碰每个像素。我们使用 gl.clearColor() 将清除颜色设置为不透明的黑色,并将清除深度缓冲区的值设置为 1.0,方法是调用 WebGLRenderingContext 方法 gl.clearDepth()。然后,我们调用 WebGLRenderingContext 方法 gl.clear(),该方法清除帧缓冲区(因为我们在掩码参数中包含了 gl.COLOR_BUFFER_BIT)和深度缓冲区(因为我们包含了 gl.DEPTH_BUFFER_BIT)。

然后,我们通过将帧的预期渲染时间与绘制上一帧的时间进行比较,来确定自渲染上一帧以来经过了多少时间。由于此值以毫秒为单位,因此我们通过乘以 0.001(或除以 1000)将其转换为秒。

现在,我们循环遍历姿势的视图,这些视图在 XRViewerPose 数组 views 中找到。对于每个视图,我们向 XRWebGLLayer 请求要使用的适当视口,通过将位置和大小信息传递到 gl.viewport() 来配置 WebGL 视口以匹配。这限制了渲染,以便我们只能绘制到代表由 view.eye 标识的眼睛看到的图像的帧缓冲区的部分。

在建立了这些约束并将所有其他所需内容准备好后,我们调用一个自定义函数 myRenderScene() 来实际执行计算和 WebGL 渲染以渲染帧。在这种情况下,我们传递 WebGL 上下文 glXRView view、一个 sceneData 对象(包含顶点和片段着色器、顶点列表、纹理等)以及 deltaTime,它表示自上一帧以来经过了多少时间,以便我们知道动画应该前进多少。

当此函数返回时,WebXR 使用的 WebGL 帧缓冲区现在包含场景的两个副本,每个副本占用帧的一半:一个用于左眼,一个用于右眼。它通过 XR 软件和驱动程序进入头显,在那里每一半都显示在相应的眼睛中。

另请参见