3D渲染原理与JS实现
在网页中显示图像和其他平面形状非常容易。 然而,当涉及到显示 3D 形状时,事情就变得不那么容易了,因为 3D 几何比 2D 几何更复杂。 为此,你可以使用专用技术和库,例如 WebGL 和 Three.js。
但是,如果你只想显示一些基本形状(例如立方体),则不需要这些技术。 此外,它们不会帮助你了解它们的工作原理以及我们如何在平面屏幕上显示 3D 形状。
本教程的目的是解释如何在没有 WebGL 的情况下为 Web 构建一个简单的 3D 引擎。 我们将首先了解如何存储 3D 形状。 然后,我们将了解如何在两个不同的视图中显示这些形状。
1、所有形状都是多面体
虚拟世界与现实世界有一个主要区别:没有什么是连续的,一切都是离散的。 例如,你无法在屏幕上显示完美的圆形,但可以通过绘制具有很多边的正多边形来实现它:边越多,圆就越“完美”。
在 3D 中,这是同样的事情,每个形状都必须用多边形的 3D 等价物来处理:多面体(Polyhedron)。在这种 3D 形状中,我们只能找到平面,而不是球体中的弯曲侧面。 当我们谈论已经是多面体的形状(例如立方体)时,这并不奇怪,但当我们想要显示其他形状(例如球体)时,需要记住这一点。
2、存储多面体
为了猜测如何存储多面体,我们必须记住如何在数学中识别这样的东西。 你在上学期间肯定已经学过一些基本的几何图形。 例如,要识别一个正方形,你可以将其称为 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 尝试使用尽可能少的内存。 为了实现这一点,它不会复制作为函数参数传递的对象,甚至不会复制存储到数组中的对象。 对于我们的例子来说,这是完美的行为。
事实上,每个顶点都包含三个数字(它们的坐标),如果我们需要添加它们,还可以加上几个方法。 如果对于每个面,我们存储顶点的副本,我们将使用大量内存,这是无用的。 在这里,我们拥有的只是引用(Reference):坐标(和其他方法)被存储一次,并且仅存储一次。 由于每个顶点由三个不同的面使用,通过存储引用而不是副本,我们将所需的内存除以3(或多或少)!
3、我们需要三角形吗?
如果你玩过 3D(例如使用 Blender 等软件,或 WebGL 等库),也许听说过我们应该使用三角形。 在这里,我选择不使用三角形。
这种选择背后的原因是本文是对该主题的介绍,我们将展示立方体等基本形状。 在我们的例子中,使用三角形来显示正方形比其他任何东西都更复杂。
但是,如果你计划构建一个更完整的渲染器,那么需要知道,一般来说,三角形是首选。 这有两个主要原因:
- 纹理:出于某些数学原因,为了在面上显示图像,我们需要三角形;
- 奇怪的面:三个顶点总是在同一平面上。 但是,你可以添加不在同一平面中的第四个顶点,并且可以创建连接这四个顶点的面。 在这种情况下,要绘制它,我们别无选择:我们必须将其分成两个三角形(只需尝试用一张纸即可!)。 通过使用三角形,你可以保持控制并选择分割发生的位置)。
4、作用于多面体
存储引用而不是副本还有另一个优点。 当我们想要修改多面体时,使用这样的系统也会将所需的操作数除以三。
为了理解为什么,让我们再回忆一下我们的数学课。 当你想要平移一个正方形时,你并没有真正平移(translate)它。 事实上,你平移四个顶点,然后加入平移。
在这里,我们也会做同样的事情:我们不会触摸脸部。 我们对每个顶点应用所需的操作,然后就完成了。 当面使用参考时,面的坐标会自动更新。 例如,看看我们如何平移之前创建的立方体:
for (var i = 0; i < 8; ++i) {
cube.vertices[i].x += 50;
cube.vertices[i].y += 20;
cube.vertices[i].z += 15;
}
我们知道如何存储 3D 对象以及如何对它们进行操作。 现在是时候看看如何查看它们了! 但是,首先我们需要一点理论背景,以便理解我们要做什么。
5、投影
目前,我们存储 3D 坐标。 然而,屏幕只能显示 2D 坐标,因此我们需要一种方法将 3D 坐标转换为 2D 坐标:这就是我们在数学中所说的投影。 3D 到 2D 投影是由称为虚拟相机的新对象进行的抽象操作。 该相机获取 3D 对象并将其坐标转换为 2D 坐标,将它们发送到渲染器,渲染器将它们显示在屏幕上。 我们假设我们的相机放置在 3D 空间的原点,因此它的坐标是 (0,0,0)
。
从本文开始,我们就讨论了坐标,由三个数字表示:x、y 和 z。 但要定义坐标,我们需要一个基础:z是垂直坐标吗? 它是到顶部还是到底部? 没有通用的答案,也没有惯例,因为事实是你可以选择想要的任何内容。 唯一需要记住的是,当你对 3D 对象进行操作时,必须保持一致,因为公式会根据它而变化。 在本文中,我选择了可以在上面的立方体架构中看到的基础:x 从左到右,y 从后到前,z 从下到上。
现在,我们知道该怎么做了:我们有 (x,y,z)
基础上的坐标,为了显示它们,我们需要将它们转换为 (x,z)
基础上的坐标:因为它是一个平面,我们将 能够显示它们。
不只有一种投影。 更糟糕的是,存在无数种不同的投影! 在本文中,我们将看到两种不同类型的投影,它们是实践中最常用的投影。
6、如何渲染我们的场景
在投影我们的3D对象之前,让我们先编写显示它们的函数。 该函数接受一个数组作为参数,该数组列出了要渲染的对象、必须用于显示对象的画布上下文以及在正确位置绘制对象所需的其他详细信息。
该数组可以包含多个要渲染的对象。 这些对象必须尊重一件事:有一个名为 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()
了。
7、正交视图
让我们从正交投影(Orthographic Projection)开始。 因为它是最简单的,所以很容易理解我们要做什么。
我们有三个坐标,但我们只需要两个。 在这种情况下,最简单的做法是什么? 删除其中一个坐标。 这就是我们在正交视图中所做的。 我们将删除表示深度的坐标:y 坐标。
function project(M) {
return new Vertex2D(M.x, M.z);
}
你现在可以测试自本文开头以来我们编写的所有代码:有效! 恭喜,你刚刚在平面屏幕上显示了 3D 对象!
该功能演示可以在这个CodePen查看,你可以通过鼠标旋转立方体来与其进行交互:
有时,正交视图正是我们想要的,因为它具有保留平行线的优点。 然而,这并不是最自然的景象:我们的眼睛并不是这样看的。 这就是为什么我们会看到第二个投影:透视图。
6、透视图
透视投影(Perspective Projection)比正交投影稍微复杂一些,因为我们需要做一些计算。 然而,这些计算并不那么复杂,你只需要知道一件事:如何使用截距定理(Intercept Theorem)。
为了理解原因,让我们看一下表示正交视图的模式。 我们以正交方式将点投影到平面上:
但是,在现实生活中,我们的眼睛的行为更像是以下模式:
基本上我们有两个步骤:
- 将原始顶点和相机原点连接起来;
- 投影是这条线与平面的交线。
与正交视图不同,在透视视图中投影平面的确切位置很重要:如果将平面放置在远离相机的位置,则不会获得与将其放置在靠近相机时相同的效果。 这里我们将其放置在距相机距离 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实例中进行测试,你可以再次与立方体进行交互:
7、结束语
我们的(非常基本的)3D 引擎现在已准备好显示我们想要的任何 3D 形状。 你可以采取一些措施来增强它。 例如,我们可以看到自己形状的每一个面,甚至是后面的脸。 要隐藏它们,你可以实施背面剔除(back-face culling)。
另外,我们没有讨论纹理(texture)。 在这里,我们所有的形状都具有相同的颜色。 例如,你可以通过在对象中添加颜色属性来更改它,以了解如何绘制它们。 你甚至可以为每个面选择一种颜色,而无需更改很多内容。 你还可以尝试在面上显示图像。 然而,这更困难,并且详细说明如何做这样的事情需要整篇文章。
其他事情可以改变。 我们将相机放置在空间的原点,但你可以移动它(在投影顶点之前需要更改基础)。 另外,这里绘制了放置在相机后面的顶点,这不是我们想要的。 裁剪平面可以解决这个问题(易于理解,但不太容易实现)。
正如你所看到的,我们在这里构建的 3D 引擎还远未完成,这也是我自己的解释。 你可以添加自己的其他类:例如,Three.js 使用专用类来管理相机和投影。 此外,我们使用基本数学来存储坐标,但如果你想创建更复杂的应用程序,并且例如需要在帧期间旋转大量顶点,你将不会获得流畅的体验。 为了优化它,你将需要一些更复杂的数学:齐次坐标和四元数。
原文链接:Building a 3D Engine with JavaScript
BimAnt翻译整理,转载请标明出处