本文由Tim Severien和Simon Codrington进行同行评审。 感谢所有SitePoint的同行评审人员使SitePoint内容达到最佳状态!
在网页中显示图像和其他平面形状非常容易。 但是,在显示3D形状时,事情变得不那么容易了,因为3D几何比2D几何更复杂。 为此,您可以使用专用技术和库,例如WebGL和Three.js 。
但是,如果您只想显示一些基本形状(例如立方体),则不需要这些技术。 而且,它们不会帮助您了解它们的工作方式以及我们如何在平面屏幕上显示3D形状。
本教程的目的是解释如何在没有WebGL的情况下为Web构建简单的3D引擎。 我们首先将看到如何存储3D形状。 然后,我们将看到如何在两个不同的视图中显示这些形状。
存储和变换3D形状
所有形状都是多面体
虚拟世界与真实世界的主要不同之处在于:没有什么是连续的,而一切都是离散的。 例如,您不能在屏幕上显示完美的圆圈。 您可以通过绘制具有很多边的规则多边形来实现它:您拥有的边越多,圆就越“完美”。
在3D中,这是同一件事,必须使用与多边形等效的3D来逼近每种形状: 多面体 (3D形状,在该3D形状中,我们仅发现平面而不是球体中的弯曲侧面)。 当我们谈论已经是多面体的形状(例如立方体)时,这并不奇怪,但是当我们要显示其他形状(例如球体)时,要牢记这一点。
储存多面体
要猜测如何存储多面体,我们必须记住如何在数学中识别这种事物。 您肯定在学年已经做过一些基本的几何设计。 例如,要标识一个正方形,可将其称为ABCD
,其中A
, B
, C
和D
指的是构成正方形每个角的顶点。
对于我们的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
类的构造函数从生成立方体的顶点开始,该顶点是根据指示的中心位置计算得出的。 模式将更加清晰,因此请参见下面生成的八个顶点的位置:
然后,我们列出这些面孔。 每个面都是一个正方形,因此我们需要为每个面指定四个顶点。 在这里,我选择用数组表示一张脸,但是,如果需要,可以为此创建一个专用类。
创建面时,我们使用四个顶点。 因为它们存储在this.vertices[i]
对象中,所以我们无需再次指出它们的位置。 这很实用,但是我们这样做还有另一个原因。
默认情况下,JavaScript尝试使用最少的内存。 为此,它不会复制作为函数参数传递甚至存储在数组中的对象。 对于我们来说,这是完美的行为。
实际上,每个顶点包含三个数字(它们的坐标),如果需要将它们相加,还会添加几种方法。 如果对于每个面,我们存储顶点的副本,则将使用大量内存,这是无用的。 在这里,我们所拥有的只是引用:坐标(和其他方法)仅存储一次,而仅存储一次。 由于每个顶点由三个不同的面使用,因此通过存储引用而不是副本,我们将所需的内存除以三(或多或少)!
我们需要三角形吗?
如果您已经玩过3D(例如使用Blender之类的软件或使用WebGL之类的库),也许您听说过我们应该使用三角形。 在这里,我选择不使用三角形。
之所以选择该选项,是因为本文是对该主题的介绍,我们将显示诸如立方体之类的基本形状。 在我们的案例中,使用三角形来显示正方形比其他任何事情都要复杂。
但是,如果您计划构建更完整的渲染器,则需要知道一般而言,三角形是首选。 这有两个主要原因:
- 纹理:出于某些数学原因,要在脸上显示图像,我们需要三角形;
- 怪异的面孔:三个顶点始终在同一平面上。 但是,您可以添加不在同一平面上的第四个顶点,并且可以创建连接这四个顶点的面。 在这种情况下,要绘制它,我们别无选择:我们必须将它分成两个三角形(只需尝试用一张纸!)。 通过使用三角形,您可以保留控件并选择拆分发生的位置(感谢蒂姆的提醒!)。
代理多面体
存储引用而不是副本还有另一个优势。 当我们想要修改多面体时,使用这样的系统还将需要的操作数除以三。
要了解原因,让我们再次回想一下我们的数学课。 当您要翻译一个正方形时,实际上并不需要翻译。 实际上,您翻译了四个顶点,然后加入了翻译。
在这里,我们将做同样的事情:我们不会碰到脸。 我们在每个顶点上应用所需的操作,然后完成。 当面孔使用参考时,面孔的坐标会自动更新。 例如,查看如何转换先前创建的多维数据集:
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对象执行操作时,您必须保持一致,因为公式会根据3D对象而改变。 在本文中,我选择了可以在上面的多维数据集的架构中看到的基础: x
从左到右, y
从后到前, z
从下到上。
现在,我们知道该怎么做:我们以(x,y,z)
基础具有坐标,并且要显示它们,我们需要将其转换为以(x,z)
基础的坐标:因为它是一个平面,所以我们将能够显示它们。
不仅有一个投影。 更糟糕的是,存在无数种不同的投影! 在本文中,我们将看到两种不同类型的投影,它们实际上是最常用的一种。
如何渲染场景
在投影我们的对象之前,让我们编写将显示它们的函数。 此函数接受列出要渲染的对象,必须用于显示对象的画布上下文以及在正确位置绘制对象所需的其他详细信息作为数组的参数。
该数组可以包含多个要渲染的对象。 这些对象必须尊重一件事:拥有一个名为faces
的公共属性,该属性是一个列出对象所有面的数组(例如我们先前创建的多维数据集)。 这些面可以是任何东西(如果需要,可以是正方形,三角形甚至十二边形):它们只需要是列出其顶点的数组即可。
让我们看一下该函数的代码,然后进行解释:
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平面中返回一个我们可以如下定义的顶点。
var Vertex2D = function(x, y) {
this.x = parseFloat(x);
this.y = parseFloat(y);
};
我没有在这里命名x
和z
坐标,而是选择将z
坐标重命名为y
,以保持我们在2D几何中经常发现的经典约定,但是如果愿意,可以保留z
。
project()
的确切内容是我们在下一节中看到的内容:它取决于您选择的投影类型。 但是无论这种类型是什么, render()
函数都可以保持原样。
一旦我们在平面上有了坐标,我们就可以在画布上显示它们,这就是我们要做的……有一点技巧:我们实际上并没有绘制出project()
函数返回的实际坐标。
实际上, project()
函数返回虚拟2D平面上的坐标,但其原点与我们为3D空间定义的原点相同。 但是,我们希望原点位于画布的中心,这就是为什么要翻译坐标的原因:顶点(0,0)
不在画布的中心,而(0 + dx,0 + dy)
是,如果我们明智地选择dx
和dy
。 因为我们希望(dx,dy)
位于画布的中心,所以我们实际上没有选择,而是定义dx = canvas.width / 2
和dy = canvas.height / 2
。
最后,最后一个细节:为什么我们使用-y
而不直接使用y
? 答案取决于我们的选择基础: z
轴指向顶部。 然后,在我们的场景中,z坐标为正的顶点将向上移动。 但是,在画布上, y
轴指向底部:y坐标为正的顶点将向下移动。 这就是为什么我们需要在画布上将画布的y坐标定义为场景的z坐标的反方向。
既然render()
函数已经很清楚了,现在该看一下project()
。
正交视图
让我们从正交投影开始。 因为这是最简单的,所以了解我们将要做的事情是完美的。
我们有三个坐标,而我们只想要两个。 在这种情况下最简单的操作是什么? 删除坐标之一。 这就是我们在正交视图中所做的。 我们将删除代表深度的坐标: y
坐标。
function project(M) {
return new Vertex2D(M.x, M.z);
}
现在,您可以测试自本文开始以来我们编写的所有代码:它可以工作! 恭喜,您刚刚在平面屏幕上显示了3D对象!
此功能在下面的实时示例中实现,您可以在其中通过使用鼠标旋转多维数据集来与多维数据集进行交互。
请参见CodePen上的SitePoint ( @SitePoint )提供的Pen 3D正交视图 。
有时,我们想要的是正交视图,因为它具有保留相似之处的优势。 但是,这不是最自然的视图:我们的眼睛看不到那样的景象。 这就是为什么我们将看到第二个投影:透视图的原因。
透视图
透视图比正交视图要复杂一些,因为我们需要进行一些计算。 但是,这些计算并不那么复杂,您只需要知道一件事:如何使用截距定理 。
为了理解原因,让我们看一下表示正交视图的模式。 我们以正交方式将点投影在平面上。
但是,在现实生活中,我们的眼睛的行为更像是以下模式。
基本上,我们有两个步骤:
- 我们将原始顶点和相机的原点连接在一起;
- 投影是该线与平面之间的交点。
与正交视图相反,此处的平面的确切位置很重要:如果将平面远离相机放置,将不会获得与将其放置在靠近相机时相同的效果。 在这里,我们将其放置在距相机的距离d
处。
从3D空间中的顶点M(x,y,z)
,我们要计算平面上投影M'
的坐标(x',z')
。
为了猜测我们将如何计算这些坐标,让我们从另一个角度来看,并看到与上面相同的模式,但是从顶部看。
我们可以识别出截距定理中使用的配置。 在上面的模式中,我们知道一些值: x
, y
和d
等。 我们要计算x'
因此我们应用截距定理并获得以下等式: x' = d / y * x
。
现在,如果从侧面看相同的场景,则会得到类似的模式,这要感谢z
, y
和d
来获得z'
的值: 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);
}
可以在下面的实时示例中测试此功能。 再一次,您可以与多维数据集进行交互。
请参阅CodePen上的SitePoint ( @SitePoint )提供的Pen 3D透视 图 。
结束语
我们的(非常基本的)3D引擎现在可以显示我们想要的任何3D形状。 您可以采取一些措施来增强它。 例如,我们看到形状的每个面,甚至背面的面。 要隐藏它们,可以实施背面剔除 。
另外,我们没有谈论纹理。 在这里,我们所有的形状共享相同的颜色。 您可以通过例如在对象中添加color
属性来更改color
,以了解如何绘制它们。 您甚至可以为每张脸选择一种颜色,而无需进行很多更改。 您也可以尝试在脸上显示图像。 但是,这比较困难,并且详细说明如何执行此操作将花费整篇文章。
其他事情可以更改。 我们将相机放置在空间的原点,但是您可以移动它(在投影顶点之前需要更改基准)。 此外,此处绘制了位于摄像机后面的顶点,这不是我们想要的。 剪切平面可以解决此问题(易于理解,难以实现)。
如您所见,我们在这里构建的3D引擎远远不够完善,这也是我自己的解释。 您可以与其他类一起添加自己的风格:例如,Three.js使用专用的类来管理摄像机和投影。 另外,我们使用基本的数学运算来存储坐标,但是,如果您想创建一个更复杂的应用程序,并且例如在一个帧中需要旋转很多顶点,那么您将不会有流畅的体验。 要对其进行优化,您将需要一些更复杂的数学运算: 齐次坐标 (射影几何)和四元数 。
如果您有自己改进引擎的想法,或者基于此代码构建了一些很棒的东西,请在下面的评论中告诉我!
From: https://www.sitepoint.com/building-3d-engine-javascript/