我们可以在网页中轻易地展示图片或其他平面形状然而, 当要展示 3D 模型时, 事情就不那么简单了, 因为三维空间比二维空间更复杂为了实现 3D 效果, 我们可以使用专门的技术和库, 如 webGL 和 Three.js
然而, 如果你只是想展示一些基本形状时, 如立方体, 那么这些技术就显得大材小用了另外, 使用它们并不会帮助你理解其工作原理, 或解答如何在平面中显示 3D 形状的疑问
我编写这篇教程的目的是: 阐述如何在 web 中构建一个简单的 3D 引擎 (无 WebGL) 我们将首先学习如何存储 3D 模型, 然后学习如何在两种不同视图 (正视图和透视图) 中展示这些形状
保存和转换 3D 模型
所有形状都是多面体
虚拟世界与现实的最大不同是: 没有东西是连续的, 即所有东西都是离散的例如, 你无法在屏幕上显示一个完美的圆你只能以一个正多边形表示圆: 边越多, 圆就越完美
同理, 在三维空间, 每个 3D 模型都等同于一个 多面体 (即 3D 模型只能由不弯曲的平面组成) 当我们讨论一个本身就是多面体 (如立方体) 的模型时并不足以为奇, 但当我们想展示其它模型时, 如球体时, 就需要记住这个原理了
保存一个多面体
想要保存一个多面体, 就需要运用数学知识将其表示出来你肯定在上学期间学过一些基本的几何知识以正方形为例, 你需要定义 ABCD 四个标识符, 它们分别代表正方形的每个直角
我们的 3D 引擎也一样我们从保存模型的每个顶点开始然后, 模型的每个面都会被这些顶点所标注
我们需要正确的结构体去表示顶点因此, 我们创建一个类去存储顶点的坐标
- var Vertex = function(x, y, z) {
- this.x = parseFloat(x);
- this.y = parseFloat(y);
- this.z = parseFloat(z);
- };
现在我们可以像下面这样创建顶点了
var A = new Vertex(10, 20, 0.5);
接着, 我们创建一个类去表示多面体我们以立方体为例下面是该类的定义, 后面会有相应的解释
- var Cube = function(center, size) {
- // Generate the vertices
- // 生成多个顶点
- var d = size / 2;
- this.vertices = [
- new Vertex(center.x - d, center.y - d, center.z + d),
- new Vertex(center.x - d, center.y - d, center.z - d),
- new Vertex(center.x + d, center.y - d, center.z - d),
- new Vertex(center.x + d, center.y - d, center.z + d),
- new Vertex(center.x + d, center.y + d, center.z + d),
- new Vertex(center.x + d, center.y + d, center.z - d),
- new Vertex(center.x - d, center.y + d, center.z - d),
- new Vertex(center.x - d, center.y + d, center.z + d)
- ];
- // Generate the faces
- // 生成面
- this.faces = [
- [this.vertices[0], this.vertices[1], this.vertices[2], this.vertices[3]],
- [this.vertices[3], this.vertices[2], this.vertices[5], this.vertices[4]],
- [this.vertices[4], this.vertices[5], this.vertices[6], this.vertices[7]],
- [this.vertices[7], this.vertices[6], this.vertices[1], this.vertices[0]],
- [this.vertices[7], this.vertices[0], this.vertices[3], this.vertices[4]],
- [this.vertices[1], this.vertices[6], this.vertices[5], this.vertices[2]]
- ];
- };
通过这个类, 我们只需指定中心和边长就可创建一个虚拟的立方体
var cube = new Cube(new Vertex(0, 0, 0), 200);
Cube 类的构造函数先通过指定的中心位置生成立方体的顶点通过下面的模型可更清晰地看到, 我们创建的 8 个顶点的位置:
然后, 我们列出了面由于每个面都是正方形, 所以需要为每个面指定 4 个顶点这里我选择用一个数组表示一个面, 当然, 你也可以创建一个专门的类表示面
当我们是通过 4 个顶点 (已存储在 this.vertices[i]) 创建一个面时, 就不需要再指定这面的位置而且, 下面有另外一个理由驱使我这样做
默认情况下, JavaScript 会尽可能少地占用内存因此, 通过参数传进函数的对象或数组 (数组也是对象) 都不是副本, 而只是引用因此, 我们在上面的例子中很好地做到这一点
实际上, 面上的每个顶点都含有 3 个数值 (它们的坐标) 假如我们将面上的顶点以副本进行存储, 这无疑会使用大量多余的内存这里, 我们使用了引用的方式: 坐标都仅需保存一次通过引用(而非副本的方式), 每个顶点会被 3 个面共同使用, 因此内存只需原来的三分之一左右
我们需要三角形吗?
Why do 3D engines primarily use triangles to draw surfaces? 这个提问说道: 三角形肯定不会是立体的, 但超过 3 点的面就可以是立体的, 因此不能得到渲染, 除非转为三角形具体可看看这个提问
如果我们曾经使用过 3D(如 Blender 软件或 WebGL 库), 可能已经听过三角形这里, 我们选择不使用三角形
之所以这样选择, 是因为这篇文章是以入门为主的, 而且我们只会展示一些基本的形状, 如立方体使用三角形表示正方形无疑会让问题复杂化
然而, 如果你计划构建一个更完整的渲染器, 那么就需要了解这方面的知识了, 一般来说, 三角形是完美的下面有两个主要理由支撑该说法:
纹理: 出于一些数学方面的原因, 想在面上展示图片就需要三角形;
不规则的面: 三个顶点总会在同一个面上然而, 你可以不在该平面上添加第四个顶点, 然后连接这四个顶点创建一个面在这种情况下, 为了能进行绘制, 我们别无选择, 只能将四边形切成两个三角形 (可用一张纸试试!) 通过使用三角形, 你能选择切开的位置
操作多面体
这是保存引用 (而不是副本) 的另一优势当我们因操作多面体而进行数值运算时, 效率能提高 3 倍(备注: 由于是引用, 只需修改一处)
为了理解当中的原因, 让我们再次回忆我们的数学课当你想平移一个正方形时, 你不是真的去移动它实际上, 你只是移动四个顶点
下面, 我们将尝试上述的平移操作: 我们无需理会面, 只需为每个顶点进行相应的运算这是因为面是由顶点的引用组成, 面的坐标会自动更新看看我们是如何移动上面所创建的立方体:
- for (var i = 0; i <8; ++i) {
- cube.vertices[i].x += 50;
- cube.vertices[i].y += 20;
- cube.vertices[i].z += 15;
- }
渲染图像
目前, 我们已懂得如何存储和操作 3D 对象了现在就看看如何渲染它们! 在这之前, 为了明白我们将要做的事, 需要普及一些理论知识
投影
目前, 我们存储的是 3D 坐标然而, 屏幕只能显示 2D 坐标, 因此我们需要一种将 3D 坐标转为 2D 的方式: 在数学中, 我们称之为投影 3D 转 2D 的投影是一个抽象的操作, 由一个被称为虚拟摄像机的对象构成该摄像机会将一个 3D 对象的坐标转为 2D 坐标, 然后将其传输给渲染器, 以在屏幕上进行显示我们假设这台摄像机放置在 3D 空间的原点(即(0,0,0))
在文章开头, 我们通过三个数值 x,y 和 z 表示坐标但为了定义坐标, 我们需要一个基础原则: z 是竖直坐标吗? 它用来表示上 / 下位移的吗? 这没有统一的答案, 也没有约定, 事实上, 你可以选择任何你想要的你唯一需要记住的是: 在操作 3D 对象时, 你必须保持一致, 因为它决定了公式的定义在这篇文章中, 我选择的基本原则能在上述的立方体模型中看出: x 是从左向右, y 是从后向前(备注: 我们是后, 屏幕是前),z 是从下向上
现在, 我们知道该做什么了: 为了显示三维空间上的坐标(x,y,z), 我们需要将它们转换为二维空间的坐标(x,y): 因为在平面中, 只有转换后才能够进行显示
不仅只有一个投影更坏的是, 有无数种不同的投影! 在这篇文章中, 我们会看到两种不同类型的, 且在实际中最常见的投影
如何渲染场景
在对对象进行投影前, 让我们编写用于显示的函数该函数接受一个对象数组作为参数, 而 canvas 的上下文是用于渲染这些对象的, 函数的其余部分则是将对象绘制在正确的位置上
该数组包含了用于渲染的对象这些对象必需能反映这样一件事: 拥有一个名为 faces 的公有属性, 该属性是一个存有该 3D 模型所有面的数组 (如先前创建的立方体) 而这些面可以是任何类型的(正方形, 三角形, 或甚至是十二边形(如果你愿意)): 面是一个保存着顶点的数组
让我们看看该函数的实现代码, 紧随其后的是解释:
- function render(objects, ctx, dx, dy) {
- // For each object
- for (var i = 0, n_obj = objects.length; i < n_obj; ++i) {
- // For each face
- for (var j = 0, n_faces = objects[i].faces.length; j < n_faces; ++j) {
- // Current face
- var face = objects[i].faces[j];
- // Draw the first vertex
- // 绘制第一个顶点
- var P = project(face[0]);
- ctx.beginPath();
- ctx.moveTo(P.x + dx, -P.y + dy);
- // Draw the other vertices
- // 绘制其余顶点
- for (var k = 1, n_vertices = face.length; k < n_vertices; ++k) {
- P = project(face[k]);
- ctx.lineTo(P.x + dx, -P.y + dy);
- }
- // Close the path and draw the face
- ctx.closePath();
- ctx.stroke();
- ctx.fill();
- }
- }
- }
该函数需要解释的部分应该是 project() 函数与参数 dxdy 分别是什么其余的语句基本无需解释, 基本上是遍历对象, 然后绘制每一面
正如其名字所示, project() 函数是用于将 3D 坐标转为 2D 坐标的它接收在 3D 空间的一个顶点, 然后返回 2D 平面的顶点下面是 2D 平面顶点的定义:
- var Vertex2D = function(x, y) {
- this.x = parseFloat(x);
- this.y = parseFloat(y);
- };
我在这选择将 z 坐标重命名为 y, 以保持 2D 几何学的传统约定, 当然你也可以保持 z
project() 的具体内容将在下一节看到: 这取决于你选择的 project 类型但无论它的类型是什么, render() 函数仍保持不变
一旦拥有平面坐标, 我们就能在 canvas 上进行渲染, 顺便提了一个小技巧: 我们没有绘制 project() 函数返回的实际坐标
实际上, project() 函数返回了一个虚拟 2D 平面的坐标, 但与 3D 空间的原点 (0,0,0) 相同然而, 我们想让该原点在 canvas(画布)的中心, 这就是为什么我们将坐标进行平移: 顶点 (0,0) 并不在画布的中心, 但 (0 + dx, 0 + dy) 是由于我们想将 (dx,dy) 放置在 canvas 中心, 我们没有什么好的选择, 就定义
- dx = canvas.width / 2,
- dy = canvas.height / 2
最后, 还有一点需要说明的是: 为什么我们使用 -y 而不是直接使用 y? 其实这是基于我们之前选择的基本原则之上: z 轴 是向上的在我们这种情景中, 顶点的 z 坐标若是正数, 则表示向上移然而, 在 canvas 中, y 轴是向下的: 顶点的 y 坐标若是正数, 则会向下移动这就是为什么在当前情景下, 定义的 z 坐标是与 y 坐标相反的
现在理解 render() 函数了, 是时候看看 project()
正视图
让我们开始正交投影吧这是最简单的一步了, 因此很容易理解我们将要做的事情
目前顶点有三个坐标值, 但我们只想要两个在这种情景下的最简单的处理方式是什么呢? 移除其中一个坐标值这也是我们在正视图中所做的事我们将移除用于表示深度的 y 坐标值
- function project(M) {
- return new Vertex2D(M.x, M.z);
- }
到目前为止, 结合文章的所有代码进行测试: 能运行! 这是值得庆祝的一刻, 你能在平面展示一个 3D 物体!
下面的线上案例正是实现的功能, 而且它还能通过鼠标让这个立方体进行旋转哦
线上 Demo: 3D Orthographic View by SitePoint (@SitePoint) on CodePen.
有时, 我们就是需要一个正视图, 因为他拥有正交投影的特点 (不变形) 然而, 这不是最自然的视图: 我们肉眼所看到的视觉效果并不像这样这就引出我们将要讲到的第二种投影: 透视图
透视图
透视图比正视图稍微复杂一点, 因为我们需要进行一些运算然而, 这些运算并不复杂, 你只需知道这么一件事: 如何使用 截线定理(又称为平行截割定理, 平行线分线段成比例定理)
为了明白其中的原因, 让我们看看正视图的模型我们将点以正交的方式投影在平面上
但在现实世界中, 我们眼睛的行为更像以下这种模型
接下来, 我们要进行以下两个步骤:
连接原始顶点和摄像源;
投影是线与面的交点;
与正视图不同, 平面的具体位置变得重要起来了: 如果你将平面放置在远离摄像机的地方, 效果就与平面靠近摄像机的效果不同现在我们将其放置在距离摄像机距离为 d 的位置
对于 3D 空间的顶点 M(x,y,z), 我们需要算出其投影在平面上的 M'点坐标 (x',z')
为了说明如何计算该坐标的, 我们从上往下观察上面这个模型
在上述模型中, 我们知道这些值: x, y 和 d 运用截线定理可得到该等式: x' = d / y * x
同理, 从侧面观察同一个模型, 可得该等式: z' = d / y z
现在我们能编写使用透视图的 project() 函数:
- function project(M) {
- // Distance between the camera and the plane
- // 摄像机与平面的距离
- var d = 200;
- var r = d / M.y;
- return new Vertex2D(r * M.x, r * M.z);
- }
该函数能在下面的线上案例进行测试当然, 你也能与立方体进行交互
线上 Demo:3D Perspective View by SitePoint (@SitePoint) on CodePen.
结束语
我们 (非常基础) 的 3D 引擎现在已经能展示任何 3D 模型了但它仍有几处可以完善的地方如我们能看到该模型的任何一面, 甚至是背面为了隐藏它, 你可以实现 背面剔除(back-face culling)
另外, 我们没讲到纹理目前, 模型的每个面都是同一种颜色的其实, 我们无须修改太多即可添加纹理, 如为对象添加一个颜色属性, 然后绘制上去你甚至可为每一面绘制一张图像为了保持文章的简易, 我并没有详细讲解这方面
我们还可以进行其它操作我们将摄像机放置在空间的中心, 但你可以移动它 (需在投影顶点前) 另外, 未被摄像机拍摄到的顶点也被绘制出来了, 这并不是我们想要的结果裁剪平面 (clipping plane) 能修复这个问题(易于理解, 但不容易实现)
如你所见, 3D 引擎到这里已经算完成了, 这也是我自己的实现方式你可以添加其它的类: 如 Three.js 使用一个专门的类去管理摄像机和投影另外, 我们使用基本的数学知识去存储坐标, 但如果你想创建一个更复杂的应用, 例如: 对于在一帧内旋转多个顶点的操作, 目前的引擎很难拥有一个流畅的体验为了优化这种情况, 你需要一些更复杂的数学知识: 齐次坐标 (射影几何) 和 四元数
来源: https://juejin.im/entry/5aac7d90518825556918c0b7