你好,我是月影。

上节课,我们在绘制3D几何体的时候,实际上有一个假设,那就是观察者始终从三维空间坐标系的正面,也就是z轴正方向,看向坐标原点。但在真实世界的模型里,观察者可以处在任何一个位置上。

那今天,我们就在上节课的基础上,引入一个空间观察者的角色,或者说是相机(Camera),来总结一个更通用的绘图模型。这样,我们就能绘制出,从三维空间中任意一个位置观察物体的效果了。

首先,我们来说说什么是相机。

如何理解相机和视图矩阵?

我们现在假设,在WebGL的三维世界任意位置上有一个相机,它可以用一个三维坐标(Position)和一个三维向量方向(LookAt Target)来表示。

在初始情况下,相机的参考坐标和世界坐标是重合的。但是,当我们移动或者旋转相机的时候,相机的参考坐标和世界坐标就不重合了。

而我们最终要在Canvas画布上绘制出的是,以相机为观察者的图形,所以我们就需要用一个变换,将世界坐标转换为相机坐标。这个变换的矩阵就是视图矩阵(ViewMatrix)。

计算视图矩阵比较简单的一种方法是,我们先计算相机的模型矩阵,然后对矩阵使用lookAt函数,这样我们得到的矩阵就是视图矩阵的逆矩阵。然后,我们再对这个逆矩阵求一次逆,就可以得到视图矩阵了。

这么说还是有点比较抽象,我们通过代码来理解。

function updateCamera(eye, target = [0, 0, 0]) {
  const [x, y, z] = eye;
  const m = new Mat4(
    1, 0, 0, 0,
    0, 1, 0, 0,
    0, 0, 1, 0,
    x, y, z, 1,
  );
  const up = [0, 1, 0];
  m.lookAt(eye, target, up).inverse();
  renderer.uniforms.viewMatrix = m;
}

如上面代码所示,我们设置相机初始位置矩阵m,然后执行m.lookAt(eye, target, up),这里的up是一个向量,表示朝上的方向,我们把它定义为y轴正向。然后我们调用inverse,将这个结果求逆,得到的就是视图矩阵。

为了让你看到相机的效果,我们改写上节课圆柱体的顶点着色器代码,加入视图矩阵。

 attribute vec3 a_vertexPosition;
  attribute vec4 color;
  attribute vec3 normal;

  varying vec4 vColor;
  varying float vCos;
  uniform mat4 projectionMatrix;
  uniform mat4 modelMatrix;
  uniform mat4 viewMatrix;
  uniform mat3 normalMatrix;
  
  const vec3 lightPosition = vec3(1, 0, 0);

  void main() {
    gl_PointSize = 1.0;
    vColor = color;
    vec4 pos = viewMatrix * modelMatrix * vec4(a_vertexPosition, 1.0);
    vec4 lp = viewMatrix * vec4(lightPosition, 1.0);
    vec3 invLight = lp.xyz - pos.xyz;
    vec3 norm = normalize(normalMatrix * normal);
    vCos = max(dot(normalize(invLight), norm), 0.0);
    gl_Position = projectionMatrix * pos;
  }

这样,如果我们就把相机位置改变了。我们以updateCamera([0.5, 0, 0.5]); 为例,这样朝向(0, 0, 0)拍摄图像的最终效果就如下所示。

剪裁空间和投影对3D图像的影响

在前面的课程中我们说过,WebGL的默认坐标范围是从-1到1的。也就是说,只有当图像的x、y、z的值在-1到1区间内才会被显示在画布上,而在其他位置上的图像都会被剪裁掉。

举个例子,如果我们修改模型矩阵,让圆柱体沿x、y轴平移,向右上方各平移0.5,那么圆柱中x、y值大于1的部分都会被剪裁掉,因为这些部分已经超过了Canvas边缘。操作代码和最终效果如下:

function update() {
  const modelMatrix = fromRotation(rotationX, rotationY, rotationZ);
  modelMatrix[12] = 0.5; // 给 x 轴增加 0.5 的平移
  modelMatrix[13] = 0.5; // 给 y 轴也增加 0.5 的平移
  renderer.uniforms.modelMatrix = modelMatrix;
  renderer.uniforms.normalMatrix = normalFromMat4([], modelMatrix);
  ...
}

对于只有x、y的二维坐标系来说,这一点很好理解。但是,对于三维坐标系来说,不仅x、y轴会被剪裁,z轴同样也会被剪裁。我们还是直接修改代码,给z轴增加0.5的平移。你会看到,最终绘制出来的图形非常奇怪。

会显示这么奇怪的结果,就是因为z轴超过范围的部分也被剪裁掉了,导致投影出现了问题。

既然是投影出现了问题,我们先回想一下,我们都对z轴做过哪些投影操作。在绘制圆柱体的时候,我们只是用投影矩阵非常简单地反转了一下z轴,除此之外,没做过其他任何操作了。所以,为了让图形在剪裁空间中正确显示,我们不能只反转z轴,还需要将图像从三维空间中投影到剪裁坐标内。那么问题来了,图像是怎么被投影到剪裁坐标内的呢?

一般来说,投影有两种方式,分别是正投影透视投影。你可以结合我给出的示意图,来理解它们各自的特点。

首先是正投影,它又叫做平行投影。正投影是将物体投影到一个长方体的空间(又称为视景体),并且无论相机与物体距离多远,投影的大小都不变。

透视投影则更接近我们的视觉感知。它投影的规律是,离相机近的物体大,离相机远的物体小。与正投影不同,正投影的视景体是一个长方体,而透视投影的视景体是一个棱台。

知道了不同投影方式的特点,我们就可以根据投影方式和给定的参数来计算投影矩阵了。因为数学推导过程比较复杂,我在这里就不详细推导了,直接给出对应的JavaScript函数,你只要记住ortho和perspective这两个投影函数就可以了,函数如下所示。

其中,ortho是计算正投影的函数,它的参数是视景体x、y、z三个方向的坐标范围,它的返回值就是投影矩阵。而perspective是计算透视投影的函数,它的参数是近景平面near、远景平面far、视角fov和宽高比率aspect,返回值也是投影矩阵。

// 计算正投影矩阵
function ortho(out, left, right, bottom, top, near, far) {
   let lr = 1 / (left - right);
   let bt = 1 / (bottom - top);
   let nf = 1 / (near - far);
   out[0] = -2 * lr;
   out[1] = 0;
   out[2] = 0;
   out[3] = 0;
   out[4] = 0;
   out[5] = -2 * bt;
   out[6] = 0;
   out[7] = 0;
   out[8] = 0;
   out[9] = 0;
   out[10] = 2 * nf;
   out[11] = 0;
   out[12] = (left + right) * lr;
   out[13] = (top + bottom) * bt;
   out[14] = (far + near) * nf;
   out[15] = 1;
   return out;
}

// 计算透视投影矩阵
function perspective(out, fovy, aspect, near, far) {
   let f = 1.0 / Math.tan(fovy / 2);
   let nf = 1 / (near - far);
   out[0] = f / aspect;
   out[1] = 0;
   out[2] = 0;
   out[3] = 0;
   out[4] = 0;
   out[5] = f;
   out[6] = 0;
   out[7] = 0;
   out[8] = 0;
   out[9] = 0;
   out[10] = (far + near) * nf;
   out[11] = -1;
   out[12] = 0;
   out[13] = 0;
   out[14] = 2 * far * near * nf;
   out[15] = 0;
   return out;
}

接下来,我们先试试对圆柱体进行正投影。假设,在正投影的时候,我们让视景体三个方向的范围都是(-2,2)。以刚才的相机位置为参照(任何一个位置观察都一样,不管物体在哪里,都是只有之前大小的一半。因为视景体范围增加了),我们绘制出来的圆柱体的大小只有之前的一半。这是因为我们通过投影变换将空间坐标范围增大了一倍。

import {ortho} from '../common/lib/math/functions/Mat4Func.js';
function projection(left, right, bottom, top, near, far) {
  return ortho([], left, right, bottom, top, near, far);
}

const projectionMatrix = projection(-2, 2, -2, 2, -2, 2);
renderer.uniforms.projectionMatrix = projectionMatrix; // 投影矩阵 

updateCamera([0.5, 0, 0.5]); // 设置相机位置

接下来,我们再试一下对圆柱体进行透视投影。在进行透视投影的时候,我们将相机的位置放在(2, 2, 3)的地方。

import {perspective} from '../common/lib/math/functions/Mat4Func.js';

function projection(near = 0.1, far = 100, fov = 45, aspect = 1) {
  return perspective([], fov * Math.PI / 180, aspect, near, far);
}

const projectionMatrix = projection();
renderer.uniforms.projectionMatrix = projectionMatrix;

updateCamera([2, 2, 3]); // 设置相机位置

我们发现,在透视投影下,距离观察者(相机)近的部分大,距离它远的部分小。这更符合真实世界中我们看到的效果,所以一般来说,在绘制3D图形时,我们更偏向使用透视投影。

3D绘图标准模型

实际上,通过上节课和刚才的内容,我们已经能总结出3D绘制几何体的基本数学模型,也就是3D绘图的标准模型。这个标准模型一共有四个矩阵,它们分别是:投影矩阵、视图矩阵(ViewMatrix)、模型矩阵(ModelMatrix)、法向量矩阵(NormalMatrix)

其中,前三个矩阵用来计算最终显示的几何体的顶点位置,第四个矩阵用来实现光照等效果。比较成熟的图形库,如ThreeJSBabylonJS,基本上都是采用这个标准模型来进行3D绘图的。所以理解这个模型,也有助于增强我们对图形库的认识,帮助我们更好地去使用这些流行的图形库。

在前面的课程中,因为WebGL原生的API在使用上比较复杂,所以我们使用了简易的gl-renderer库来简化2D绘图过程。而3D绘图是一个比2D绘图更加复杂的过程,即使是gl-renderer库也有点力不从心,我们需要更加强大的绘图库,来简化我们的绘制,以便于我们能够把精力专注于理解图形学本身的核心内容。

当然,使用ThreeJS或BabeylonJS都是不错的选择。但是在这节课中,我会使用一个更加轻量级的图形库,叫做OGL。它拥有我们可视化绘图需要的所有基本功能,而且,相比于ThreeJS等流行图形库,它的AP相对更底层、更简单一些。因此不会有太多高级的特性对我们的学习造成干扰。

接下来,我就用这个库来绘制一些简单的圆柱体、立方体等等,让你对这个库的使用有一个全面的了解。

如何使用OGL绘制基本的几何体

OGL库使用的也是我们刚才说的标准模型,因此,使用它所以绘制几何体非常简单,分成以下7个步骤,如下图所示。

接下来,我们详细来看看每一步的操作。

首先,是创建Renderer对象。我们可以创建一个画布宽高为512的Renderer对象。代码如下:

const canvas = document.querySelector('canvas');
const renderer = new Renderer({
  canvas,
  width: 512,
  height: 512,
});

然后,我们在OGL中,通过new Camera来创建相机,默认创建出的是透视投影相机。这里我们把视角设置为35度,位置设置为(0,1,7),朝向为(0,0,0)。代码如下:

const gl = renderer.gl;
gl.clearColor(1, 1, 1, 1);
const camera = new Camera(gl, {fov: 35});
camera.position.set(0, 1, 7);
camera.lookAt([0, 0, 0]);

接着,我们创建场景。OGL使用树形渲染的方式,所以在用OGL创建场景时,我们要使用Transform元素。Transform类型是基本元素,它可以添加子元素和设置几何变换,如果父元素设置了变换,这些变换也会被应用到子元素。

const scene = new Transform();

然后,我们创建几何体对象。OGL内置了许多常用的几何体对象,包括球体Sphere、立方体Box、柱/锥体Cylinder以及环面Torus等等。使用这些对象,我们可以快速创建这些几何体的顶点信息。那在这里,我创建了4个几何体对象,分别是球体、立方体、椎体和环面。

const sphereGeometry = new Sphere(gl);
const cubeGeometry = new Box(gl);
const cylinderGeometry = new Cylinder(gl);
const torusGeometry = new Torus(gl);

再然后,我们创建 WebGL 程序。并且,我们在着色器中给这些几何体设置了浅蓝色和简单的光照效果。

const vertex = /* glsl */ `
  precision highp float;

  attribute vec3 position;
  attribute vec3 normal;
  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;
  varying vec3 vNormal;
  void main() {
      vNormal = normalize(normalMatrix * normal);
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragment = /* glsl */ `
  precision highp float;

  varying vec3 vNormal;
  void main() {
      vec3 normal = normalize(vNormal);
      float lighting = dot(normal, normalize(vec3(-0.3, 0.8, 0.6)));
      gl_FragColor.rgb = vec3(0.2, 0.8, 1.0) + lighting * 0.1;
      gl_FragColor.a = 1.0;
  }
`;

const program = new Program(gl, {
  vertex,
  fragment,
});

有了WebGL程序之后,我们使用它和几何体对象来构建真正的网格(Mesh)元素,最终再把这些元素渲染到画布上。我们创建了4个网格对象,它们的形状分别是环面、球体、立方体和圆柱,我们给它们设置了不同的位置,然后将它们添加到场景scene中去。

const torus = new Mesh(gl, {geometry: torusGeometry, program});
torus.position.set(0, 1.3, 0);
torus.setParent(scene);

const sphere = new Mesh(gl, {geometry: sphereGeometry, program});
sphere.position.set(1.3, 0, 0);
sphere.setParent(scene);

const cube = new Mesh(gl, {geometry: cubeGeometry, program});
cube.position.set(0, -1.3, 0);
cube.setParent(scene);

const cylinder = new Mesh(gl, {geometry: cylinderGeometry, program});
cylinder.position.set(-1.3, 0, 0);
cylinder.setParent(scene);

最后,我们将它们用相机camera对象的设定渲染出来,并分别设置绕y轴旋转的动画,你就能看到这4个图像旋转的画面了。代码如下:

requestAnimationFrame(update);
function update() {
  requestAnimationFrame(update);

  torus.rotation.y -= 0.02;
  sphere.rotation.y -= 0.03;
  cube.rotation.y -= 0.04;
  cylinder.rotation.y -= 0.02;

  renderer.render({scene, camera});
}

要点总结

在这一节课,我们在三维空间里,引入了相机和视图矩阵的概念,相机分为透视相机和正交相机,它们有不同的投影方式,并且设置它们还可以改变剪裁空间。视图矩阵和前一节课介绍的投影矩阵、模型矩阵、法向量矩阵一起,构成了3D绘图标准模型,这是一般的图形库遵循的标准绘图方式。

为了巩固学习到的知识,我们使用OGL库来尝试绘制不同的3D几何体,我们依次用OGL绘制了球体、立方体、圆柱体和环面。OGL绘制图形的基本步骤可以总结为7步,如下图:

小试牛刀

  1. 在上面的例子里,使用OGL绘制的球体看起来不是很圆,你可以研究一下OGL的代码,修改一下创建球体的参数,让它看起来更圆。
  2. 你能试着修改一下片元着色器,让上面绘制的4个几何体呈现不同的颜色吗?将它们分别改成红色、黄色、蓝色和绿色。

欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!


源码

课程中完整示例代码详见GitHub仓库

推荐阅读

OGL

评论