WebGL 模型视图投影

本文探讨了如何在 WebGL 项目中获取数据,并将数据投影到适当的空间中,以便在屏幕上显示。它假设您了解使用平移、缩放和旋转矩阵的基本矩阵数学知识。它解释了在合成 3D 场景时通常使用的三个核心矩阵:模型、视图和投影矩阵。

注意: 本文还以 MDN 内容工具包 的形式提供。它还使用一组在 MDN 全局对象下可用的 实用程序函数

模型、视图和投影矩阵

WebGL 中对空间中的点和多边形的单个变换由基本的变换矩阵处理,如平移、缩放和旋转。这些矩阵可以组合在一起并以特殊的方式分组,使其在渲染复杂的 3D 场景时很有用。这些组合矩阵最终将原始模型数据移动到称为 **裁剪空间** 的特殊坐标空间中。这是一个 2 个单位宽的立方体,以 (0,0,0) 为中心,角点范围从 (-1,-1,-1) 到 (1,1,1)。此裁剪空间被压缩成 2D 空间并光栅化为图像。

下面讨论的第一个矩阵是 **模型矩阵**,它定义了如何获取原始模型数据并在 3D 世界空间中移动它。**投影矩阵** 用于将世界空间坐标转换为裁剪空间坐标。一个常用的投影矩阵 **透视投影矩阵** 用于模拟典型相机的效果,该相机作为 3D 虚拟世界中查看者的替身。**视图矩阵** 负责移动场景中的对象以模拟相机位置的变化,从而改变查看者当前可以看到的内容。

以下部分深入探讨了模型、视图和投影矩阵背后的思想及其实现。这些矩阵是将数据在屏幕上移动的核心,并且是超越单个框架和引擎的概念。

裁剪空间

在 WebGL 程序中,数据通常以其自己的坐标系上传到 GPU,然后顶点着色器将这些点转换为称为 **裁剪空间** 的特殊坐标系。超出裁剪空间的任何数据都将被剪切掉,不会被渲染。但是,如果三角形跨越此空间的边界,则它将被分割成新的三角形,并且只保留新三角形中位于裁剪空间的部分。

A 3d graph showing clip space in WebGL.

上图是对所有点必须适应的裁剪空间的形象化表示。它是一个每个边长为两个单位的立方体,一个角点位于 (-1,-1,-1),对角点位于 (1,1,1)。立方体的中心是点 (0,0,0)。裁剪空间使用的这种 8 立方米坐标系称为归一化设备坐标 (NDC)。您可能会在研究和使用 WebGL 代码时不时地遇到此术语。

在本节中,我们将直接将数据放入裁剪空间坐标系中。通常,使用在某个任意坐标系中的模型数据,然后使用矩阵对其进行变换,将模型坐标转换为裁剪空间坐标系。对于此示例,通过使用范围从 (-1,-1,-1) 到 (1,1,1) 的模型坐标值来说明裁剪空间的工作方式最容易。下面的代码将创建 2 个三角形,它们将在屏幕上绘制一个正方形。正方形中的 Z 深度决定了当正方形共享相同空间时,哪个被绘制在顶部。较小的 Z 值被渲染在较大的 Z 值之上。

WebGLBox 示例

此示例将创建一个自定义 WebGLBox 对象,该对象将在屏幕上绘制一个 2D 框。

注意: 每个 WebGLBox 示例的代码都可以在此 GitHub 仓库 中找到,并按部分进行组织。此外,每个部分的底部都有一个 JSFiddle 链接。

WebGLBox 构造函数

构造函数如下所示

js
function WebGLBox() {
  // Setup the canvas and WebGL context
  this.canvas = document.getElementById("canvas");
  this.canvas.width = window.innerWidth;
  this.canvas.height = window.innerHeight;
  this.gl = MDN.createContext(canvas);

  const gl = this.gl;

  // Setup a WebGL program, anything part of the MDN object is defined outside of this article
  this.webglProgram = MDN.createWebGLProgramFromIds(
    gl,
    "vertex-shader",
    "fragment-shader",
  );
  gl.useProgram(this.webglProgram);

  // Save the attribute and uniform locations
  this.positionLocation = gl.getAttribLocation(this.webglProgram, "position");
  this.colorLocation = gl.getUniformLocation(this.webglProgram, "color");

  // Tell WebGL to test the depth when drawing, so if a square is behind
  // another square it won't be drawn
  gl.enable(gl.DEPTH_TEST);
}

WebGLBox 绘制

现在我们将创建一个方法来在屏幕上绘制一个框。

js
WebGLBox.prototype.draw = function (settings) {
  // Create some attribute data; these are the triangles that will end being
  // drawn to the screen. There are two that form a square.

  const data = new Float32Array([
    //Triangle 1
    settings.left,
    settings.bottom,
    settings.depth,
    settings.right,
    settings.bottom,
    settings.depth,
    settings.left,
    settings.top,
    settings.depth,

    //Triangle 2
    settings.left,
    settings.top,
    settings.depth,
    settings.right,
    settings.bottom,
    settings.depth,
    settings.right,
    settings.top,
    settings.depth,
  ]);

  // Use WebGL to draw this onto the screen.

  // Performance Note: Creating a new array buffer for every draw call is slow.
  // This function is for illustration purposes only.

  const gl = this.gl;

  // Create a buffer and bind the data
  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);

  // Setup the pointer to our attribute data (the triangles)
  gl.enableVertexAttribArray(this.positionLocation);
  gl.vertexAttribPointer(this.positionLocation, 3, gl.FLOAT, false, 0, 0);

  // Setup the color uniform that will be shared across all triangles
  gl.uniform4fv(this.colorLocation, settings.color);

  // Draw the triangles to the screen
  gl.drawArrays(gl.TRIANGLES, 0, 6);
};

着色器是在 GLSL 中编写的代码片段,这些代码片段接受我们的数据点并最终将其渲染到屏幕上。为了方便起见,这些着色器存储在 <script> 元素中,并通过自定义函数 MDN.createWebGLProgramFromIds() 引入到程序中。此函数是为这些教程编写的 实用程序函数 集合的一部分,此处没有深入解释。此函数处理将一些 GLSL 源代码编译成 WebGL 程序的基本操作。该函数采用三个参数——渲染程序的上下文、包含顶点着色器的 <script> 元素的 ID 以及包含片段着色器的 <script> 元素的 ID。顶点着色器定位顶点,片段着色器为每个像素着色。

首先,看一下将顶点在屏幕上移动的顶点着色器

glsl
// The individual position vertex
attribute vec3 position;

void main() {
  // the gl_Position is the final position in clip space after the vertex shader modifies it
  gl_Position = vec4(position, 1.0);
}

接下来,为了将数据实际光栅化为像素,片段着色器以每个像素为基础评估所有内容,设置单个颜色。GPU 为它需要渲染的每个像素调用着色器函数;着色器的工作是返回要用于该像素的颜色。

glsl
precision mediump float;
uniform vec4 color;

void main() {
  gl_FragColor = color;
}

包含了这些设置后,就可以使用裁剪空间坐标直接绘制到屏幕上了。

js
const box = new WebGLBox();

首先在中间绘制一个红色框。

js
box.draw({
  top: 0.5, // x
  bottom: -0.5, // x
  left: -0.5, // y
  right: 0.5, // y

  depth: 0, // z
  color: [1, 0.4, 0.4, 1], // red
});

接下来,在红色框的顶部和后面绘制一个绿色框。

js
box.draw({
  top: 0.9, // x
  bottom: 0, // x
  left: -0.9, // y
  right: 0.9, // y

  depth: 0.5, // z
  color: [0.4, 1, 0.4, 1], // green
});

最后,为了演示裁剪实际上正在进行,这个框没有被绘制,因为它完全位于裁剪空间之外。深度超出了 -1.0 到 1.0 的范围。

js
box.draw({
  top: 1, // x
  bottom: -1, // x
  left: -1, // y
  right: 1, // y

  depth: -1.5, // z
  color: [0.4, 0.4, 1, 1], // blue
});

结果

在 JSFiddle 上查看

The results of drawing to clip space using WebGL.

练习

此时,一个有用的练习是通过改变代码来移动裁剪空间中的框,以了解点如何在裁剪空间中被裁剪和移动。尝试绘制类似于有背景的方块笑脸的图片。

齐次坐标

之前裁剪空间顶点着色器的主要行包含以下代码

js
gl_Position = vec4(position, 1.0);

position 变量是在 draw() 方法中定义的,并作为属性传递给着色器。这是一个三维点,但最终传递到管道中的 gl_Position 变量实际上是四维的——而不是 (x, y, z),而是 (x, y, z, w)z 之后没有字母,因此按照惯例,这个第四维被标记为 w。在上面的示例中,w 坐标被设置为 1.0。

显而易见的问题是“为什么要多加一维?”事实证明,这为操作 3D 数据提供了许多不错的技巧。添加的这一维引入了透视的概念到坐标系中;有了它,我们就可以将 3D 坐标映射到 2D 空间中——从而允许两条平行线在远离观察者时相交。w 的值用作坐标其他分量的除数,因此 xyz 的真实值计算为 x/wy/wz/w(然后 w 也是 w/w,变为 1)。

在一个典型的笛卡尔坐标系中,三维点被定义。添加的第四维将该点变成了一个齐次坐标。它仍然代表三维空间中的一个点,并且可以通过一对简单的函数轻松地演示如何构造这种类型的坐标。

js
function cartesianToHomogeneous(point) {
  let x = point[0];
  let y = point[1];
  let z = point[2];

  return [x, y, z, 1];
}

function homogeneousToCartesian(point) {
  let x = point[0];
  let y = point[1];
  let z = point[2];
  let w = point[3];

  return [x / w, y / w, z / w];
}

如前所述,并且如上面的函数所示,w 分量将 x、y 和 z 分量相除。当 w 分量为非零实数时,齐次坐标可以轻松地转换回笛卡尔空间中的普通点。现在,如果 w 分量为零会发生什么?在 JavaScript 中,返回的值如下所示。

js
homogeneousToCartesian([10, 4, 5, 0]);

这将计算为:[Infinity, Infinity, Infinity]

此齐次坐标表示无穷远处的某个点。这是一种方便的方式来表示从原点以特定方向发射的光线。除了光线之外,它还可以被认为是方向向量的表示。如果此齐次坐标乘以具有平移的矩阵,则平移将被有效地去除。

当计算机上的数字非常大(或非常小)时,它们开始变得越来越不精确,因为用于表示它们的 1 和 0 数量有限。对更大数字执行的操作越多,累积到结果中的错误就越多。当除以 w 时,这可以通过对两个可能更小、更不易出错的数字进行运算,从而有效地提高非常大数字的精度。

使用齐次坐标的最后一个好处是它们非常适合乘以 4x4 矩阵。为了能够与矩阵相乘,顶点必须至少与矩阵的其中一个维度匹配。4x4 矩阵可用于编码各种有用的变换。实际上,典型的透视投影矩阵使用除以 w 分量来实现其变换。

从裁剪空间裁剪点和多边形发生在齐次坐标被转换回笛卡尔坐标(通过除以 w)之前。此最终空间被称为归一化设备坐标或 NDC。

要开始使用此想法,可以修改前面的示例以允许使用w分量。

js
//Redefine the triangles to use the W component
const data = new Float32Array([
  //Triangle 1
  settings.left,
  settings.bottom,
  settings.depth,
  settings.w,
  settings.right,
  settings.bottom,
  settings.depth,
  settings.w,
  settings.left,
  settings.top,
  settings.depth,
  settings.w,

  //Triangle 2
  settings.left,
  settings.top,
  settings.depth,
  settings.w,
  settings.right,
  settings.bottom,
  settings.depth,
  settings.w,
  settings.right,
  settings.top,
  settings.depth,
  settings.w,
]);

然后顶点着色器使用传入的四维点。

glsl
attribute vec4 position;

void main() {
  gl_Position = position;
}

首先,我们在中间绘制一个红色方框,但将 W 设置为 0.7。由于坐标被 0.7 除,因此它们都会被放大。

js
box.draw({
  top: 0.5, // y
  bottom: -0.5, // y
  left: -0.5, // x
  right: 0.5, // x
  w: 0.7, // w - enlarge this box

  depth: 0, // z
  color: [1, 0.4, 0.4, 1], // red
});

现在,我们在上面绘制一个绿色方框,但通过将 w 分量设置为 1.1 来缩小它。

js
box.draw({
  top: 0.9, // y
  bottom: 0, // y
  left: -0.9, // x
  right: 0.9, // x
  w: 1.1, // w - shrink this box

  depth: 0.5, // z
  color: [0.4, 1, 0.4, 1], // green
});

最后一个方框没有被绘制,因为它在裁剪空间之外。深度在 -1.0 到 1.0 范围内之外。

js
box.draw({
  top: 1, // y
  bottom: -1, // y
  left: -1, // x
  right: 1, // x
  w: 1.5, // w - Bring this box into range

  depth: -1.5, // z
  color: [0.4, 0.4, 1, 1], // blue
});

结果

The results of using homogeneous coordinates to move the boxes around in WebGL.

练习

  • 玩弄这些值以查看它如何影响屏幕上渲染的内容。注意之前被裁剪的蓝色方框是如何通过设置其 w 分量而被带回到范围内的。
  • 尝试创建一个新的方框,该方框位于裁剪空间之外,然后通过除以 w 将其带回。

模型变换

将点直接放到裁剪空间中用途有限。在实际应用中,你并不总是拥有所有源坐标的裁剪空间坐标。因此,大多数情况下,你需要将模型数据和其他坐标变换到裁剪空间。简陋的立方体是如何做到这一点的简单例子。立方体数据包括顶点位置、立方体面的颜色以及构成各个多边形的顶点位置的顺序(以 3 个顶点为一组构建构成立方体面的三角形)。位置和颜色存储在 GL 缓冲区中,作为属性发送到着色器,然后分别对其进行操作。

最后,计算并设置单个模型矩阵。该矩阵代表要对构成模型的每个点执行的变换,以便将其移动到正确的空间,并对模型中的每个点执行任何其他必要的变换。这不仅适用于每个顶点,而且适用于模型每个表面上的每个点。

在本例中,对于动画的每一帧,一系列缩放、旋转和平移矩阵将数据移动到裁剪空间中所需的点。立方体的大小与裁剪空间 (-1,-1,-1) 到 (1,1,1) 相同,因此需要缩小它,以避免填充整个裁剪空间。该矩阵直接发送到着色器,并在 JavaScript 中事先进行乘法运算。

以下代码示例定义了 CubeDemo 对象上的一个方法,该方法将创建模型矩阵。它使用自定义函数来创建和乘以矩阵,如MDN WebGL 共享代码中所定义。新函数如下所示。

js
CubeDemo.prototype.computeModelMatrix = function (now) {
  //Scale down by 50%
  const scale = MDN.scaleMatrix(0.5, 0.5, 0.5);

  // Rotate a slight tilt
  const rotateX = MDN.rotateXMatrix(now * 0.0003);

  // Rotate according to time
  const rotateY = MDN.rotateYMatrix(now * 0.0005);

  // Move slightly down
  const position = MDN.translateMatrix(0, -0.1, 0);

  // Multiply together, make sure and read them in opposite order
  this.transforms.model = MDN.multiplyArrayOfMatrices([
    position, // step 4
    rotateY, // step 3
    rotateX, // step 2
    scale, // step 1
  ]);
};

为了在着色器中使用它,必须将其设置为统一位置。统一的 location 存储在下面显示的 locations 对象中。

js
this.locations.model = gl.getUniformLocation(webglProgram, "model");

最后,统一值被设置为该 location。这将矩阵传递给 GPU。

js
gl.uniformMatrix4fv(
  this.locations.model,
  false,
  new Float32Array(this.transforms.model),
);

在着色器中,每个位置顶点首先被变换为齐次坐标(一个 vec4 对象),然后乘以模型矩阵。

glsl
gl_Position = model * vec4(position, 1.0);

注意:在 JavaScript 中,矩阵乘法需要自定义函数,而在着色器中,它内置在语言中,使用简单的 * 运算符。

结果

在 JSFiddle 上查看

Using a model matrix

此时,变换后的点的 w 值仍然为 1.0。立方体仍然没有任何透视。下一节将采用此设置并修改 w 值以提供一些透视。

练习

  • 使用缩放矩阵缩小方框,并将其放置在裁剪空间中的不同位置。
  • 尝试将其移动到裁剪空间之外。
  • 调整窗口大小,观察方框如何发生扭曲。
  • 添加 rotateZ 矩阵。

除以 W

在我们的立方体模型上获得一些透视感的简单方法是,将 Z 坐标复制到 w 坐标。通常,在将笛卡尔点转换为齐次点时,它会变成 (x,y,z,1),但我们将将其设置为类似于 (x,y,z,z) 的值。实际上,我们需要确保 z 大于 0,以便在视图中的点,因此我们将通过将值更改为 ((1.0 + z) * scaleFactor) 来稍微修改它。这将把通常位于裁剪空间 (-1 到 1) 中的点移动到更像 (0 到 1) 的空间中,具体取决于 scaleFactor 的设置。scaleFactor 更改最终的 w 值,使其总体上更高或更低。

着色器代码如下所示。

glsl
// First transform the point
vec4 transformedPosition = model * vec4(position, 1.0);

// How much effect does the perspective have?
float scaleFactor = 0.5;

// Set w by taking the z value which is typically ranged -1 to 1, then scale
// it to be from 0 to some number, in this case 0-1.
float w = (1.0 + transformedPosition.z) * scaleFactor;

// Save the new gl_Position with the custom w component
gl_Position = vec4(transformedPosition.xyz, w);

结果

在 JSFiddle 上查看

Filling the W component and creating some projection.

看到那个小的深蓝色三角形了吗?那是我们对象中添加的另一个面,因为我们形状的旋转导致该角延伸到裁剪空间之外,从而导致该角被裁剪掉。有关如何使用更复杂的矩阵来帮助控制和防止裁剪的介绍,请参见下面的透视投影矩阵

练习

如果这听起来有点抽象,请打开顶点着色器,并玩弄 scaleFactor,观察它如何将顶点更多地缩放到表面。完全更改 w 分量的值,以便创建空间的非常奇怪的表示。

在下一节中,我们将把这一步(将 Z 复制到 w 槽中)转换为矩阵。

简单投影

最后一步填充 w 分量实际上可以通过一个简单的矩阵来完成。从单位矩阵开始。

js
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];

MDN.multiplyPoint(identity, [2, 3, 4, 1]);
//> [2, 3, 4, 1]

然后将最后一列的 1 向上移动一位。

js
const copyZ = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0];

MDN.multiplyPoint(copyZ, [2, 3, 4, 1]);
//> [2, 3, 4, 4]

然而,在最后一个例子中,我们执行了 (z + 1) * scaleFactor

js
const scaleFactor = 0.5;

const simpleProjection = [
  1,
  0,
  0,
  0,
  0,
  1,
  0,
  0,
  0,
  0,
  1,
  scaleFactor,
  0,
  0,
  0,
  scaleFactor,
];

MDN.multiplyPoint(simpleProjection, [2, 3, 4, 1]);
//> [2, 3, 4, 2.5]

进一步分解它,我们可以看到它是如何工作的。

js
let x = 2 * 1 + 3 * 0 + 4 * 0 + 1 * 0;
let y = 2 * 0 + 3 * 1 + 4 * 0 + 1 * 0;
let z = 2 * 0 + 3 * 0 + 4 * 1 + 1 * 0;
let w = 2 * 0 + 3 * 0 + 4 * scaleFactor + 1 * scaleFactor;

最后一行可以简化为。

js
w = 4 * scaleFactor + 1 * scaleFactor;

然后将 scaleFactor 提取出来,我们得到。

js
w = (4 + 1) * scaleFactor;

这与我们在上一个示例中使用的 (z + 1) * scaleFactor 完全相同。

在方框演示中,添加了一个 computeSimpleProjectionMatrix() 方法。它在 draw() 方法中被调用,并将 scaleFactor 传递给它。结果应该与最后一个示例相同。

js
CubeDemo.prototype.computeSimpleProjectionMatrix = function (scaleFactor) {
  this.transforms.projection = [
    1,
    0,
    0,
    0,
    0,
    1,
    0,
    0,
    0,
    0,
    1,
    scaleFactor,
    0,
    0,
    0,
    scaleFactor,
  ];
};

尽管结果相同,但这里的重要步骤是在顶点着色器中。顶点不是直接被修改,而是乘以一个额外的投影矩阵,该矩阵(顾名思义)将 3D 点投影到 2D 绘图表面上。

glsl
// Make sure to read the transformations in reverse order
gl_Position = projection * model * vec4(position, 1.0);

结果

视锥体

在我们继续介绍如何计算透视投影矩阵之前,我们需要介绍视锥(也称为视景体)的概念。这是在当前时间用户可以看到的内容的空间区域。它是 3D 空间区域,由视野以及指定为应渲染的最靠近和最远内容的距离来定义。

在渲染过程中,我们需要确定哪些多边形需要被渲染以表示场景。这就是视锥的定义。但视锥究竟是什么?

视锥是通过取任何实体并使用两个平行平面切除它的一部分而产生的 3D 实体。考虑我们的相机,它正在查看一个区域,该区域从镜头正前方开始,延伸到远处。可视区域是一个四面金字塔,它的顶点在镜头处,它的四条边对应于其周边视觉范围的范围,它的底面在它所能看到的最远距离处,就像这样。

A depiction of the entire viewing area of a camera. This area is a four-sided pyramid with its peak at the lens and its base at the world's maximum viewable distance.

如果我们使用它来确定每帧要渲染的多边形,我们的渲染器将需要渲染该金字塔内的每个多边形,一直延伸到无穷远,包括非常靠近镜头的多边形——很可能太靠近而无法使用(当然也包括那些太靠近以至于人类在同一环境中无法集中注意力观看的东西)。

因此,减少我们需要计算和渲染的多边形数量的第一步是将这个金字塔变成视锥。我们将用于切除顶点以减少多边形数量的两个平面是近裁剪平面远裁剪平面

在 WebGL 中,近裁剪平面和远裁剪平面是通过指定从镜头到垂直于观看方向的平面上最靠近点的距离来定义的。任何比近裁剪平面更靠近镜头或比远裁剪平面更远离镜头的物体都会被移除。这将产生视锥,它看起来像这样。

A depiction of the camera's view frustum; the near and far planes have removed part of the volume, reducing the polygon count.

每帧要渲染的物体集合本质上是通过从场景中的所有物体集合开始创建的。然后,任何完全位于视锥之外的物体都会从集合中移除。接下来,部分突出到视锥之外的物体将通过删除完全位于视锥之外的任何多边形,以及通过裁剪穿过视锥外部的多边形,使它们不再超出视锥,从而被裁剪。

完成此操作后,我们将获得最大的一组完全位于视锥内的多边形。这个列表通常会使用背面剔除(删除背面对着摄像头的多边形)和使用隐藏面剔除(删除完全被更靠近镜头的多边形遮挡的不可见的多边形)等过程进一步缩减。

透视投影矩阵

到目前为止,我们已经逐步构建了自己的 3D 渲染设置。然而,我们构建的当前代码存在一些问题。首先,当我们调整窗口大小时,它会发生倾斜。其次,我们简单的投影无法处理场景数据的广泛值。大多数场景不适用于裁剪空间。定义与场景相关的距离将非常有用,这样在转换数字时就不会丢失精度。最后,对哪些点被放置在裁剪空间内和外部进行微调控制非常有用。在之前的示例中,立方体的角偶尔会被裁剪掉。

**透视投影矩阵**是一种投影矩阵,可以满足所有这些要求。该数学公式也变得更加复杂,在本示例中不会完全解释。简而言之,它将除以 w(如之前的示例中所做的那样)与基于相似三角形的一些巧妙操作相结合。如果你想阅读关于其数学原理的完整解释,请查看以下一些链接

需要注意的是,下面使用的透视投影矩阵会翻转 z 轴。在裁剪空间中,z+ 远离观察者,而使用此矩阵,它则朝向观察者。

翻转 z 轴的原因是裁剪空间坐标系是左手坐标系(其中 z 轴指向远离观察者并进入屏幕),而在数学、物理学和 3D 建模以及 OpenGL 中的视图/眼睛坐标系中,约定使用右手坐标系(z 轴指向屏幕外,朝向观察者)。有关详细信息,请参阅相关的维基百科文章:笛卡尔坐标系右手规则

让我们看一下 `perspectiveMatrix()` 函数,该函数计算透视投影矩阵。

js
MDN.perspectiveMatrix = function (
  fieldOfViewInRadians,
  aspectRatio,
  near,
  far,
) {
  const f = 1.0 / Math.tan(fieldOfViewInRadians / 2);
  const rangeInv = 1 / (near - far);

  return [
    f / aspectRatio,
    0,
    0,
    0,
    0,
    f,
    0,
    0,
    0,
    0,
    (near + far) * rangeInv,
    -1,
    0,
    0,
    near * far * rangeInv * 2,
    0,
  ];
};

此函数的四个参数是

fieldOfViewInRadians

一个以弧度表示的角度,指示观察者一次可以看见场景的多少。数字越大,相机可见的范围就越大。边缘处的几何图形会变得越来越扭曲,这相当于广角镜头。当视野较大时,物体通常会变得更小。当视野较小时,相机在场景中可见的范围就会越来越小。物体受到透视的影响要小得多,并且物体看起来离相机更近。

aspectRatio

场景的纵横比,相当于其宽度除以高度。在本例中,它是窗口宽度除以窗口高度。该参数的引入最终解决了模型在调整画布大小和形状时发生扭曲的问题。

nearClippingPlaneDistance

一个正数,表示进入屏幕到一个垂直于地面的平面的距离,该平面比该距离更近,所有东西都会被裁剪掉。这映射到裁剪空间中的 -1,并且不应设置为 0。

farClippingPlaneDistance

一个正数,表示超出该距离的几何图形将被裁剪掉的平面距离。这映射到裁剪空间中的 1。该值应保持在合理接近几何图形距离的范围内,以避免渲染过程中出现精度误差。

在最新版本的盒子演示中,`computeSimpleProjectionMatrix()` 方法已被 `computePerspectiveMatrix()` 方法替换。

js
CubeDemo.prototype.computePerspectiveMatrix = function () {
  const fieldOfViewInRadians = Math.PI * 0.5;
  const aspectRatio = window.innerWidth / window.innerHeight;
  const nearClippingPlaneDistance = 1;
  const farClippingPlaneDistance = 50;

  this.transforms.projection = MDN.perspectiveMatrix(
    fieldOfViewInRadians,
    aspectRatio,
    nearClippingPlaneDistance,
    farClippingPlaneDistance,
  );
};

着色器代码与之前的示例相同

js
gl_Position = projection * model * vec4(position, 1.0);

此外(未显示),模型的位置和比例矩阵已更改,使其从裁剪空间移出,进入更大的坐标系。

结果

练习

  • 尝试使用透视投影矩阵和模型矩阵的参数。
  • 更换透视投影矩阵以使用正投影。在 MDN WebGL 共享代码中,你将找到 `MDN.orthographicMatrix()`。这可以替换 `CubeDemo.prototype.computePerspectiveMatrix()` 中的 `MDN.perspectiveMatrix()` 函数。

视图矩阵

虽然一些图形库有一个虚拟相机,可以在合成场景时进行定位和指向,但 OpenGL(以及 WebGL 的扩展)没有。这就是**视图矩阵**的作用。它的作用是平移、旋转和缩放场景中的物体,以便它们相对于观察者的位置和方向位于正确的位置。

模拟相机

这利用了爱因斯坦狭义相对论的基本方面之一:参考系和相对运动的原理指出,从观察者的角度来看,可以通过对场景中的物体应用相反的改变来模拟改变观察者的位置和方向。无论哪种方式,结果对观察者来说都是一样的。

假设一个盒子放在桌子上,一台相机放在桌子上一米远的地方,指向盒子,盒子的正面朝向相机。然后考虑将相机从盒子移开,直到它距离盒子两米远(通过在相机的 Z 位置添加一米),然后将其向左滑动 10 厘米。盒子从相机后退了那么多的距离,并向右稍微滑动,从而看起来对相机更小,并向相机暴露了左侧的一小部分。

现在让我们重置场景,将盒子放回其起点,相机距离盒子两米远,并且直接面对盒子。然而,这次相机固定在桌子上,不能移动或转动。这就是在 WebGL 中工作的体验。那么我们如何模拟相机在空间中的移动呢?

我们不是将相机向后和向左移动,而是对盒子应用反向变换:将盒子向后移动一米,然后向右移动 10 厘米。从两个物体中的每一个的视角来看,结果都是一样的。

所有这一切的最后一步是创建**视图矩阵**,它转换场景中的物体,以便它们的位置模拟相机的当前位置和方向。我们目前的代码可以将立方体在世界空间中移动,并将所有内容投影以具有透视效果,但我们仍然无法移动相机。

想象一下用一台实体相机拍摄电影。你可以自由地将相机放置在任何你想要的地方,并将相机对准你选择的任何方向。为了在 3D 图形中模拟这一点,我们使用一个视图矩阵来模拟该实体相机的定位和旋转。

与模型矩阵直接转换模型顶点不同,视图矩阵移动一个抽象的相机。实际上,顶点着色器仍然只是移动模型,而“相机”保持在原地。为了使其正常工作,必须使用变换矩阵的逆矩阵。逆矩阵本质上是反转一个变换,因此如果我们向前移动相机视图,逆矩阵会导致场景中的物体向后移动。

下面的 `computeViewMatrix()` 方法通过在内外和左右移动来动画化视图矩阵。

js
CubeDemo.prototype.computeViewMatrix = function (now) {
  const moveInAndOut = 20 * Math.sin(now * 0.002);
  const moveLeftAndRight = 15 * Math.sin(now * 0.0017);

  // Move the camera around
  const position = MDN.translateMatrix(moveLeftAndRight, 0, 50 + moveInAndOut);

  // Multiply together, make sure and read them in opposite order
  const matrix = MDN.multiplyArrayOfMatrices([
    // Exercise: rotate the camera view
    position,
  ]);

  // Inverse the operation for camera movements, because we are actually
  // moving the geometry in the scene, not the camera itself.
  this.transforms.view = MDN.invertMatrix(matrix);
};

着色器现在使用三个矩阵。

glsl
gl_Position = projection * view * model * vec4(position, 1.0);

在此步骤之后,GPU 管线将剪裁掉超出范围的顶点,并将模型发送到片段着色器进行光栅化。

结果

关联坐标系

在这一点上,回顾并标记我们使用的各种坐标系将是有益的。首先,立方体的顶点在**模型空间**中定义。为了在场景中移动模型。这些顶点需要通过应用模型矩阵转换为**世界空间**。

模型空间 → 模型矩阵 → 世界空间

相机还没有做任何事情,需要再次移动这些点。它们目前在世界空间中,但需要移动到**视图空间**(使用视图矩阵)以表示相机的放置位置。

世界空间 → 视图矩阵 → 视图空间

最后,需要添加一个**投影**(在本例中是透视投影矩阵)以便将世界坐标映射到裁剪空间坐标。

视图空间 → 投影矩阵 → 裁剪空间

练习

  • 在场景中移动相机。
  • 在视图矩阵中添加一些旋转矩阵以环顾四周。
  • 最后,跟踪鼠标的位置。使用两个旋转矩阵让相机根据用户在屏幕上的鼠标位置上下查看。

另请参阅