# 21 | 如何添加相机,用透视原理对物体进行投影? 你好,我是月影。 上节课,我们在绘制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)拍摄图像的最终效果就如下所示。 ![](https://static001.geekbang.org/resource/image/0c/3d/0cb89b225568d718d3b0e29ec2107a3d.jpeg?wh=1920*660) ## 剪裁空间和投影对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); ... } ``` ![](https://static001.geekbang.org/resource/image/0c/69/0ce1b0da1ddaf4c993f928a6fc16a869.jpeg?wh=1920*761 "给x、y增加0.5平移后的效果") 对于只有x、y的二维坐标系来说,这一点很好理解。但是,对于三维坐标系来说,不仅x、y轴会被剪裁,z轴同样也会被剪裁。我们还是直接修改代码,给z轴增加0.5的平移。你会看到,最终绘制出来的图形非常奇怪。 ![](https://static001.geekbang.org/resource/image/a0/8c/a0808ebaf37784b633271e1b41047e8c.jpeg?wh=1920*728 "给z轴增加0.5平移后的效果") 会显示这么奇怪的结果,就是因为z轴超过范围的部分也被剪裁掉了,导致投影出现了问题。 既然是投影出现了问题,我们先回想一下,我们都对z轴做过哪些投影操作。在绘制圆柱体的时候,我们只是用投影矩阵非常简单地反转了一下z轴,除此之外,没做过其他任何操作了。所以,为了让图形在剪裁空间中正确显示,我们不能只反转z轴,还需要将图像从三维空间中**投影**到剪裁坐标内。那么问题来了,图像是怎么被投影到剪裁坐标内的呢? 一般来说,投影有两种方式,分别是**正投影**与**透视投影**。你可以结合我给出的示意图,来理解它们各自的特点。 **首先是正投影**,它又叫做平行投影。正投影是将物体投影到一个长方体的空间(又称为视景体),并且无论相机与物体距离多远,投影的大小都不变。 [![](https://static001.geekbang.org/resource/image/d6/b1/d69c5a24cf92bea9ebf24f6222a225b1.jpeg?wh=1920*1049 "正投影示意图")](https://glumes.com/post/opengl/opengl-tutorial-projection-matrix/) 而**透视投影**则更接近我们的视觉感知。它投影的规律是,离相机近的物体大,离相机远的物体小。与正投影不同,正投影的视景体是一个长方体,而透视投影的视景体是一个棱台。 [![](https://static001.geekbang.org/resource/image/fd/43/fd76c623daba4a80f6c557e03a82bb43.jpeg?wh=1920*1080 "透视投影示意图")](https://glumes.com/post/opengl/opengl-tutorial-projection-matrix/) 知道了不同投影方式的特点,我们就可以根据投影方式和给定的参数来计算投影矩阵了。因为数学推导过程比较复杂,我在这里就不详细推导了,直接给出对应的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]); // 设置相机位置 ``` ![](https://static001.geekbang.org/resource/image/05/yb/05fdd4fe5e56eaf27e1f1a00825e8yyb.jpeg?wh=1920*508) 接下来,我们再试一下对圆柱体进行透视投影。在进行透视投影的时候,我们将相机的位置放在(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]); // 设置相机位置 ``` ![](https://static001.geekbang.org/resource/image/cb/38/cb20b6589554e7bae62567d68bff7938.jpeg?wh=1920*547) 我们发现,在透视投影下,距离观察者(相机)近的部分大,距离它远的部分小。这更符合真实世界中我们看到的效果,所以一般来说,在绘制3D图形时,我们更偏向使用透视投影。 ## 3D绘图标准模型 实际上,通过上节课和刚才的内容,我们已经能总结出3D绘制几何体的基本数学模型,也就是3D绘图的**标准模型**。这个标准模型一共有四个矩阵,它们分别是:**投影矩阵、视图矩阵(ViewMatrix)、模型矩阵(ModelMatrix)、法向量矩阵(NormalMatrix)**。 其中,前三个矩阵用来计算最终显示的几何体的顶点位置,第四个矩阵用来实现光照等效果。比较成熟的图形库,如[ThreeJS](https://threejs.org/)、[BabylonJS](https://www.babylonjs.com/),基本上都是采用这个标准模型来进行3D绘图的。所以理解这个模型,也有助于增强我们对图形库的认识,帮助我们更好地去使用这些流行的图形库。 在前面的课程中,因为WebGL原生的API在使用上比较复杂,所以我们使用了简易的gl-renderer库来简化2D绘图过程。而3D绘图是一个比2D绘图更加复杂的过程,即使是gl-renderer库也有点力不从心,我们需要更加强大的绘图库,来简化我们的绘制,以便于我们能够把精力专注于理解图形学本身的核心内容。 当然,使用ThreeJS或BabeylonJS都是不错的选择。但是在这节课中,我会使用一个更加轻量级的图形库,叫做[OGL](https://github.com/oframe/ogl)。它拥有我们可视化绘图需要的所有基本功能,而且,相比于ThreeJS等流行图形库,它的AP相对更底层、更简单一些。因此不会有太多高级的特性对我们的学习造成干扰。 接下来,我就用这个库来绘制一些简单的圆柱体、立方体等等,让你对这个库的使用有一个全面的了解。 ## 如何使用OGL绘制基本的几何体 OGL库使用的也是我们刚才说的标准模型,因此,使用它所以绘制几何体非常简单,分成以下7个步骤,如下图所示。 ![](https://static001.geekbang.org/resource/image/5b/4c/5b2b4622f1bea87199788d20a2629b4c.jpg?wh=2253*693?wh=2253*693) 接下来,我们详细来看看每一步的操作。 首先,是创建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}); } ``` ![](https://static001.geekbang.org/resource/image/b8/41/b8eb9696d1201035d962bf39f6105141.gif?wh=530*490) ## 要点总结 在这一节课,我们在三维空间里,引入了相机和视图矩阵的概念,相机分为透视相机和正交相机,它们有不同的投影方式,并且设置它们还可以改变剪裁空间。视图矩阵和前一节课介绍的投影矩阵、模型矩阵、法向量矩阵一起,构成了3D绘图标准模型,这是一般的图形库遵循的标准绘图方式。 为了巩固学习到的知识,我们使用OGL库来尝试绘制不同的3D几何体,我们依次用OGL绘制了球体、立方体、圆柱体和环面。OGL绘制图形的基本步骤可以总结为7步,如下图: ![](https://static001.geekbang.org/resource/image/5b/4c/5b2b4622f1bea87199788d20a2629b4c.jpg?wh=2253*693?wh=2253*693) ## 小试牛刀 1. 在上面的例子里,使用OGL绘制的球体看起来不是很圆,你可以研究一下[OGL的代码](https://github.com/oframe/ogl),修改一下创建球体的参数,让它看起来更圆。 2. 你能试着修改一下片元着色器,让上面绘制的4个几何体呈现不同的颜色吗?将它们分别改成红色、黄色、蓝色和绿色。 欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见! * * * ## 源码 课程中完整示例代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/3d-camera) ## 推荐阅读 [OGL](https://github.com/oframe/ogl)