You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

365 lines
16 KiB
Markdown

2 years ago
# 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)