用 JavaScript 构建一个3D引擎

我们可以在网页中轻易地展示图片或其他平面形状。然而,当要展示 3D 模型时,事情就不那么简单了,因为三维空间比二维空间更复杂。为了实现 3D 效果,我们可以使用专门的技术和库,如 WebGL 和 Three.js

然而,如果你只是想展示一些基本形状时,如立方体,那么这些技术就显得大材小用了。另外,使用它们并不会帮助你理解其工作原理,或解答如何在平面中显示 3D 形状的疑问。

我编写这篇教程的目的是:阐述如何在 web 中构建一个简单的 3D 引擎(无 WebGL)。我们将首先学习如何存储 3D 模型,然后学习如何在两种不同视图(正视图和透视图)中展示这些形状。

保存和转换3D模型

所有形状都是多面体

虚拟世界与现实的最大不同是:没有东西是连续的,即所有东西都是离散的。例如,你无法在屏幕上显示一个完美的圆。你只能以一个正多边形表示圆:边越多,圆就越“完美”。

同理,在三维空间,每个 3D 模型都等同于一个 多面体(即 3D 模型只能由不弯曲的平面组成)。当我们讨论一个本身就是多面体(如立方体)的模型时并不足以为奇,但当我们想展示其它模型时,如球体时,就需要记住这个原理了。

1454758145sphere

保存一个多面体

想要保存一个多面体,就需要运用数学知识将其表示出来。你肯定在上学期间学过一些基本的几何知识。以正方形为例,你需要定义 ABCD 四个标识符,它们分别代表正方形的每个直角。

我们的 3D 引擎也一样。我们从保存模型的每个顶点开始。然后,模型的每个面都会被这些顶点所标注。
我们需要正确的结构体去表示顶点。因此,我们创建一个类去存储顶点的坐标。

现在我们可以像下面这样创建顶点了。

接着,我们创建一个类去表示多面体。我们以立方体为例。下面是该类的定义,后面会有相应的解释。

通过这个类,我们只需指定中心和边长就可创建一个虚拟的立方体。

Cube 类的构造函数先通过指定的中心位置生成立方体的顶点。通过下面的模型可更清晰地看到,我们创建的8个顶点的位置:

1454758168cube

然后,我们列出了面。由于每个面都是正方形,所以需要为每个面指定4个顶点。这里我选择用一个数组表示一个面,当然,你也可以创建一个专门的类表示面。

当我们是通过 4 个顶点(已存储在 this.vertices[i])创建一个面时,就不需要再指定这面的位置。而且,下面有另外一个理由驱使我这样做。

默认情况下,JavaScript 会尽可能少地占用内存。因此,通过参数传进函数的对象或数组(数组也是对象)都不是副本,而只是引用。因此,我们在上面的例子中很好地做到这一点。

实际上,面上的每个顶点都含有 3 个数值(它们的坐标)。假如我们将面上的顶点以副本进行存储,这无疑会使用大量多余的内存。这里,我们使用了引用的方式:坐标都仅需保存一次。通过引用(而非副本的方式),每个顶点会被 3 个面共同使用,因此内存只需原来的三分之一左右。

我们需要三角形吗?

Why do 3D engines primarily use triangles to draw surfaces? 这个提问说道:三角形肯定不会是立体的,但超过3点的面就可以是立体的,因此不能得到渲染,除非转为三角形。具体可看看这个提问。

如果我们曾经使用过 3D(如 Blender 软件或 WebGL 库),可能已经听过三角形。这里,我们选择不使用三角形。

之所以这样选择,是因为这篇文章是以入门为主的,而且我们只会展示一些基本的形状,如立方体。使用三角形表示正方形无疑会让问题复杂化。

然而,如果你计划构建一个更完整的渲染器,那么就需要了解这方面的知识了,一般来说,三角形是完美的。下面有两个主要理由支撑该说法:

  • 纹理:出于一些数学方面的原因,想在面上展示图片就需要三角形;
  • 不规则的面:三个顶点总会在同一个面上。然而,你可以不在该平面上添加第四个顶点,然后连接这四个顶点创建一个面。在这种情况下,为了能进行绘制,我们别无选择,只能将四边形切成两个三角形(可用一张纸试试!)。通过使用三角形,你能选择切开的位置。

操作多面体

这是保存引用(而不是副本)的另一优势。当我们因操作多面体而进行数值运算时,效率能提高3倍(备注:由于是引用,只需修改一处)。

为了理解当中的原因,让我们再次回忆我们的数学课。当你想平移一个正方形时,你不是真的去移动它。实际上,你只是移动四个顶点。

下面,我们将尝试上述的平移操作:我们无需理会面,只需为每个顶点进行相应的运算。这是因为面是由顶点的引用组成,面的坐标会自动更新。看看我们是如何移动上面所创建的立方体:

渲染图像

目前,我们已懂得如何存储和操作 3D 对象了。现在就看看如何渲染它们!在这之前,为了明白我们将要做的事,需要普及一些理论知识。

投影

目前,我们存储的是 3D 坐标。然而,屏幕只能显示 2D 坐标,因此我们需要一种将 3D 坐标转为 2D 的方式:在数学中,我们称之为投影。3D 转 2D 的投影是一个抽象的操作,由一个被称为虚拟摄像机的对象构成。该摄像机会将一个 3D 对象的坐标转为 2D 坐标,然后将其传输给渲染器,以在屏幕上进行显示。我们假设这台摄像机放置在 3D 空间的原点(即(0,0,0))。

在文章开头,我们通过三个数值 xy和 表示坐标。但为了定义坐标,我们需要一个基础原则:是竖直坐标吗?它用来表示上/下位移的吗?这没有统一的答案,也没有约定,事实上,你可以选择任何你想要的。你唯一需要记住的是:在操作 3D 对象时,你必须保持一致,因为它决定了公式的定义。在这篇文章中,我选择的基本原则能在上述的立方体模型中看出:是从左向右,是从后向前(备注:我们是后,屏幕是前),是从下向上。

现在,我们知道该做什么了:为了显示三维空间上的坐标(x,y,z),我们需要将它们转换为二维空间的坐标(x,y):因为在平面中,只有转换后才能够进行显示。

不仅只有一个投影。更坏的是,有无数种不同的投影!在这篇文章中,我们会看到两种不同类型的,且在实际中最常见的投影。

如何渲染场景

在对对象进行投影前,让我们编写用于显示的函数。该函数接受一个对象数组作为参数,而 canvas 的上下文是用于渲染这些对象的,函数的其余部分则是将对象绘制在正确的位置上。

该数组包含了用于渲染的对象。这些对象必需能反映这样一件事:拥有一个名为 faces 的公有属性,该属性是一个存有该 3D 模型所有面的数组(如先前创建的立方体)。而这些面可以是任何类型的(正方形,三角形,或甚至是十二边形(如果你愿意)):面是一个保存着顶点的数组。

让我们看看该函数的实现代码,紧随其后的是解释:

该函数需要解释的部分应该是 project() 函数与参数 dxdy 分别是什么。其余的语句基本无需解释,基本上是遍历对象,然后绘制每一面。

正如其名字所示,project() 函数是用于将 3D 坐标转为 2D 坐标的。它接收在 3D 空间的一个顶点,然后返回 2D 平面的顶点。下面是 2D 平面顶点的定义:

我在这选择将 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()

正视图

让我们开始正交投影吧。这是最简单的一步了,因此很容易理解我们将要做的事情。

目前顶点有三个坐标值,但我们只想要两个。在这种情景下的最简单的处理方式是什么呢?移除其中一个坐标值。这也是我们在正视图中所做的事。我们将移除用于表示深度的 坐标值。

到目前为止,结合文章的所有代码进行测试:能运行!这是值得庆祝的一刻,你能在平面展示一个 3D 物体!

下面的线上案例正是实现的功能,而且它还能通过鼠标让这个立方体进行旋转哦。

线上 Demo: 3D Orthographic View by SitePoint (@SitePoint) on CodePen.

有时,我们就是需要一个正视图,因为他拥有正交投影的特点(不变形)。然而,这不是最自然的视图:我们肉眼所看到的视觉效果并不像这样。这就引出我们将要讲到的第二种投影:透视图。

透视图

透视图比正视图稍微复杂一点,因为我们需要进行一些运算。然而,这些运算并不复杂,你只需知道这么一件事:如何使用 截线定理(又称为平行截割定理,平行线分线段成比例定理)。

为了明白其中的原因,让我们看看正视图的模型。我们将点以正交的方式投影在平面上。
1454758163orthographic-view

但在现实世界中,我们眼睛的行为更像以下这种模型。

1454758150perspective-view

接下来,我们要进行以下两个步骤:

  1. 连接原始顶点和摄像源;
  2. 投影是线与面的交点;

与正视图不同,平面的具体位置变得重要起来了:如果你将平面放置在远离摄像机的地方,效果就与平面靠近摄像机的效果不同。现在我们将其放置在距离摄像机距离为 d 的位置。

对于 3D 空间的顶点 M(x,y,z),我们需要算出其投影在平面上的 M' 点坐标 (x',z')

1454758154perspective-projection

为了说明如何计算该坐标的,我们从上往下观察上面这个模型。
1454758159perspective-projection-top

在上述模型中,我们知道这些值:x, yd。运用截线定理可得到该等式:x' = d / y * x

同理,从侧面观察同一个模型,可得该等式:z' = d / y z

现在我们能编写使用透视图的 project() 函数:

该函数能在下面的线上案例进行测试。当然,你也能与立方体进行交互。

线上Demo:3D Perspective View by SitePoint (@SitePoint) on CodePen.

结束语

我们(非常基础)的 3D 引擎现在已经能展示任何 3D 模型了。但它仍有几处可以完善的地方。如我们能看到该模型的任何一面,甚至是背面。为了隐藏它,你可以实现 背面剔除(back-face culling)。

另外,我们没讲到纹理。目前,模型的每个面都是同一种颜色的。其实,我们无须修改太多即可添加纹理,如为对象添加一个颜色属性,然后绘制上去。你甚至可为每一面绘制一张图像。为了保持文章的简易,我并没有详细讲解这方面。

我们还可以进行其它操作。我们将摄像机放置在空间的中心,但你可以移动它(需在投影顶点前)。另外,未被摄像机拍摄到的顶点也被绘制出来了,这并不是我们想要的结果。裁剪平面(clipping plane)能修复这个问题(易于理解,但不容易实现)。

如你所见,3D 引擎到这里已经算完成了,这也是我自己的实现方式。你可以添加其它的类:如 Three.js 使用一个专门的类去管理摄像机和投影。另外,我们使用基本的数学知识去存储坐标,但如果你想创建一个更复杂的应用,例如:对于在一帧内旋转多个顶点的操作,目前的引擎很难拥有一个流畅的体验。为了优化这种情况,你需要一些更复杂的数学知识:齐次坐标(射影几何)和 四元数

打赏支持我翻译更多好文章,谢谢!

打赏译者

打赏支持我翻译更多好文章,谢谢!

任选一种支付方式

2 3 收藏 评论

关于作者:刘健超-J.c

前端,在路上...http://jchehe.github.io 个人主页 · 我的文章 · 19 ·     

可能感兴趣的话题



直接登录
跳到底部
返回顶部