微信号:FrontDev

介绍:分享 Web 前端相关的技术文章、工具资源、精选课程、热点资讯

用 JavaScript 构建一个3D引擎

2016-07-14 20:25 前端大全

(点击上方公众号,可快速关注)

原文:Jérémy Heleine、

译文:伯乐在线 - 刘健超-J.c

链接:http://web.jobbole.com/86929/


我们可以在网页中轻易地展示图片或其他平面形状。然而,当要展示 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() 函数与参数 dx、dy 分别是什么。其余的语句基本无需解释,基本上是遍历对象,然后绘制每一面。


正如其名字所示,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.


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


透视图


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


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



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



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


  1. 连接原始顶点和摄像源;

  2. 投影是线与面的交点;


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

}


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


结束语


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


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


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


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


译者简介


刘健超-J.c:前端,在路上...http://jchehe.github.io

打赏支持作者写出更多好文章,谢谢!



【今日微信公号推荐↓】

更多推荐请看值得关注的技术和设计公众号


其中推荐了包括技术设计极客 和 IT相亲相关的热门公众号。技术涵盖:Python、Web前端、Java、安卓、iOS、PHP、C/C++、.NET、Linux、数据库、运维、大数据、算法、IT职场等。点击《值得关注的技术和设计公众号》,发现精彩!


 
前端大全 更多文章 浅谈浏览器http的缓存机制 Web开发的发展史 Web开发技术的演变 抛弃臃肿的 jQuery,用 NodeList.js 操作 DOM 静态网站生成器将是下一个大事件
猜您喜欢 PHP语言基础简单整理 2015年流氓软件TOP10 如何把产品经理们的用户意识“逼”出来,我来支几个大招! Swift 3.0 发布,有大改进! 欢迎来到『英语流利说』技术团队的公众号!