3D自己做。第2部分:三维



上一部分中,我们了解了如何显示二维对象,例如像素和线条(段),但是您确实想快速创建三维对象。在本文中,我们将首次尝试在屏幕上显示3D对象并熟悉新的数学对象,例如向量和矩阵,以及对它们的一些操作,但仅限于在实践中适用的对象。

在第二部分中,我们将考虑:

  • 坐标系
  • 点和向量
  • 矩阵
  • 顶点和索引
  • 可视化传送带

坐标系


值得注意的是,文章中的一些示例和操作显示不正确,并大大简化了,以增进对材料的理解,掌握本质,您可以独立地找到最佳解决方案或修复演示代码中的错误和不准确性。在绘制三维图形之前,请务必记住,屏幕上的所有三维图形均以二维像素显示。为了使像素绘制的对象看起来是三维的,我们需要做一些数学运算。我们将在不看公式和对象的情况下考虑它们的应用。这就是为什么,您将在本文中遇到的所有数学运算都将付诸实践,从而简化他们的理解。 

首先要了解的是坐标系。让我们看看使用了哪些坐标系,还选择了我们要使用的坐标系。


什么是坐标系?这是一种使用数字确定点或角色在由点组成的游戏中的位置的方法。如果使用2D图形,则坐标系具有2个轴方向(我们将其表示为X,Y)。如果我们将一个2D对象设置为更大的Y,并且它变得比以前更高,则意味着Y轴朝上。如果我们给对象一个更大的X并且它变得更向右,这意味着X轴指向右边。这是轴的方向,它们一起称为坐标系。如果在X轴和Y轴的交点处形成90度角,则这种坐标系称为直角坐标(也称为笛卡尔坐标系)(请参见上图)。


但这是2D世界中的一个坐标系,在三维空间中,会出现另一个Z轴。如果Y轴(他们说是纵坐标)允许您向上/向下绘制,则X轴(他们也说是横坐标)向左/右绘制,然后是Z轴(仍然是说应用程序)可以放大/缩小对象。在三维图形中,通常(但不总是)使用一个坐标系,其中Y轴指向上方,X轴指向右侧,但是Z可以沿一个方向或另一个方向指向。这就是为什么我们将坐标系分为两种类型-左侧和右侧(请参见上图)。

从图中可以看出,当Z轴远离我们(Z越大,对象越远)时,称为左手坐标系(也称左坐标系),如果Z轴指向我们,则这是右手坐标系(他们也说右坐标系)。他们为什么这样称呼它?左手是因为如果左手掌心向上,并且手指指向X轴,那么拇指将指示Z方向,也就是说,如果X指向右,则拇指将指示显示器。用右手执行相同的操作,Z轴将指向远离显示器的方向,X指向右侧。与手指混淆?在Internet上,可以通过多种方式放置手和手指来获得轴的必要方向,但这不是必须的。

为了处理3D图形,有许多不同语言的库,其中使用了不同的坐标系。例如,Direct3D库使用左手坐标系,而在OpenGL和WebGL中使用右手坐标系,在VulkanAPI中,Y轴朝下(Y越小,对象越高),Z是从我们这里来的,但这只是约定,在库中,我们可以指定坐标系统,我们认为这更方便。

我们应该选择哪种坐标系?任何一种都是合适的,我们只是学习而已,轴的方向现在不会影响材料的吸收。在示例中,我们将使用右手坐标系,而我们为该点指定的Z越少,则它离屏幕越远,而X,Y将指向右/上。

点和向量


现在,您基本上了解了什么是坐标系以及什么是轴方向。接下来,您需要解析一个点和一个向量,因为我们将在本文中将其用于实践。 3D空间中的点是通过[X,Y,Z]指定的位置。例如,我们想将角色放置在最原始的位置(可能在窗口的中心),那么他的位置将是[0,0,0],或者我们可以说他位于[0,0,0]点。现在,我们要将对手放置在玩家的左边20个单位(例如,像素),这意味着他将位于点[-20,0,0]。我们将不断处理点,因此稍后我们将对其进行详细分析。 

什么是向量?这是方向。在3D空间中,就像一个点一样,用3个值[X,Y,Z]来描述。例如,我们需要每秒将字符上移5个单位,这意味着我们将更改Y,每秒将其增加5,但是我们不会触摸X和Z,这种移动可以写为矢量[0,5,0]。如果我们的角色不断向下移动2个单位,向右移动1个单位,那么他的移动矢量将如下所示:[1,-2,0]。我们写-2是因为Y下降减小。

向量没有位置,[X,Y,Z]指示方向。可以将一个矢量添加到一个点,以便获得一个由矢量移动的新点。例如,我上面已经提到过,如果我们想每隔5个单位向上移动3D对象(例如游戏角色),则位移矢量将如下所示:[0,5,0]。但是如何使用它来移动? 

假设字符在点[5,7,0],位移矢量为[0,5,0]。如果将矢量添加到该点,我们将获得新的玩家位置。您可以根据以下规则添加带有矢量的点或带有矢量的矢量。

添加向量的示例

[ 5,7,0 ] + [ 0,5,0 ] = [ 5 + 07 + 50 + 0 ] = [5,12,0] -这是我们的字符的新位置。 

如您所见,我们的角色向上移动了5个单位,从这里出现了一个新概念-矢量的长度。每个矢量都有它,除了矢量[0,0,0](称为零矢量)外,这样的矢量也没有方向。对于向量[0,5,0],长度为5,因为这样的向量会将点向上移动5个单位。向量[0,0,10]的长度为10,因为它可以沿Z轴将点移动10。但是向量[12,3,-4]不能告诉您长度是多少,因此我们将使用公式来计算向量的长度。问题出现了,为什么我们需要向量的长度?一种应用是找出角色将移动多远,或者比较位移向量较长的角色的速度,速度更快。长度也用于向量的某些运算。可以从第一部分使用以下公式计算矢量的长度(仅添加Z):

Length=X2+Y2+Z2


让我们使用上面的[6,3,-8]公式计算向量的长度;

Length=66+33+88=36+9+64=10910.44


向量[6、3,-8]的长度大约为10.44。

我们已经知道什么是点,向量是什么,如何将一个点和一个向量(或2个向量)相加,以及如何计算向量的长度。让我们添加一个向量类,并在其中实现求和和长度计算。我还想注意一个事实,我们不会为一个点创建一个类,如果我们需要一个点,那么我们将使用向量类,因为 点和向量都存储X,Y,Z,仅是该位置的点,向量是方向。

将vector类添加到上一篇文章的项目中,可以将其添加到Drawer类的下面。我将Vector称为班级,并在其中添加了3个属性X,Y,Z:

class Vector {
  x = 0;
  y = 0;
  z = 0;

  constructor(x, y, z) {
    this.x = x;
    this.y = y;
    this.z = z;
  }
}

请注意,字段x,y,z没有“访问器”的功能,因此我们可以直接访问对象中的数据,这样做是为了加快访问速度。稍后,我们将进一步优化此代码,但现在,保留它以提高可读性。

现在我们实现向量的求和。该函数将采用2个可加向量,因此我正在考虑使其变为静态。该函数的主体将根据上面的公式工作。求和的结果是一个新的向量,我们将使用该向量返回:

static add(v1, v2) {
    return new Vector(
        v1.x + v2.x,
        v1.y + v2.y,
        v1.z + v2.z,
    );
}

仍然需要执行计算向量长度的功能。同样,我们根据更高的公式来实现所有操作:

getLength() {
    return Math.sqrt(
        this.x * this.x + this.y * this.y + this.z * this.z
    );
}

现在,让我们看一下对向量的另一种操作,这将在稍后以及后续文章中大量使用,即“向量的归一化”。假设我们在游戏中有一个使用箭头键移动的角色。如果按下,则移动到矢量[0,1,0],如果按下,则移动到[0,-1,0],向左[-1,0,0],向右[1,0,0]。可以清楚地看到,每个矢量的长度为1,即角色的速度为1。让我们添加一个对角线移动,如果玩家将箭头向上和向右夹紧,位移矢量将是什么?最明显的选择是向量[1,1,0]。但是,如果我们计算它的长度,我们将看到它大约等于1.414。事实证明,我们的角色会在对角移动得更快?此选项不合适,但为了使我们的角色以1的速度对角移动,矢量应为:[0.707,0.707,0]。我从哪里得到这样的向量?我取了矢量[1,1,0]并对其进行了归一化,之后得到[0.707,0.707,0]。即,归一化是在不改变向量方向的情况下将向量缩小为1(单位长度)的长度。请注意,向量[0.707,0.707,0]和[1,1,0]指向相同的方向,也就是说,在两种情况下,字符都将严格向右移动,但是向量[0.707,0.707,0]被归一化并且字符的速度现在等于1,这消除了对角线运动加快的错误。始终建议在进行任何计算之前对向量进行归一化,以避免各种错误。让我们看看如何规范向量。有必要将其每个分量(X,Y,Z)除以长度。找到长度的功能已经存在,完成了一半的工作,现在我们编写向量的归一化函数(在Vector类内部):

normalize() {
    const length = this.getLength();
    
    this.x /= length;
    this.y /= length;
    this.z /= length;
    
    return this;
}

normalize方法对向量进行归一化并返回它(this),这是必需的,以便将来有可能在表达式中使用normalize。

既然我们知道向量的归一化了,并且知道在使用向量之前执行它更好,那么问题就来了。如果向量的归一化是减少到单位长度,即对象(字符)的移动速度等于1,那么如何加快字符的速度?例如,当以1的速度对角向上/向右移动一个字符时,他的向量将为[0.707,0.707,0],如果我们想将字符快移6次,该向量将是什么?为此,有一个操作称为“向量乘标量”。标量是向量乘以的常用数字。如果标量等于6,则向量将分别变长6倍和我们的字符快6倍。标量乘法怎么办?为此,必须将向量的每个分量乘以标量。例如,我们解决了以上问题,当向矢量[0.707,0.707,0](速度1)移动的字符需要加速6次时,即,将矢量乘以标量6。将矢量“ V”乘以标量“ s”的公式如下:

Vs=[VxsVysVzs]


在我们的情况下,它将是:
[0.70760.707606]=[4.2424.2420]-一个长度为6的新位移矢量。

重要的是要知道正标量会缩放矢量而不改变其方向;如果标量为负,它也会缩放矢量(增加其长度),但另外会将矢量的方向更改为相反方向。

让我们multiplyByScalar在Vector类中实现将矢量乘以标量的功能

multiplyByScalar(s) {
    this.x *= s;
    this.y *= s;
    this.z *= s;
    
    return this;
}

矩阵


我们对向量进行了一些了解,并对本文进行了一些操作。接下来,您需要处理矩阵。

我们可以说矩阵是最常见的二维数组。只是在编程中他们使用了“二维数组”这个术语,而在数学中他们使用了“矩阵”。为什么在3D编程中需要矩阵?一旦学会与他们合作,我们将对此进行分析。 

我们将仅使用数字矩阵(数字数组)。每个矩阵都有自己的大小(就像任何二维数组一样)。以下是一些矩阵示例:

M=[123456]


2乘3矩阵

M=[243344522]


3乘3矩阵

M=[2305]


4合1矩阵

M=[507217928351]


4乘3矩阵

现在,在所有关于矩阵的运算中,我们仅考虑相乘(稍后再讨论)。事实证明,矩阵乘法不是最简单的操作,如果不仔细遵循乘法的顺序,它很容易造成混淆。但是不用担心,您会成功的,因为在这里,我们将仅进行总结和总结。首先,我们需要记住一些我们需要的乘法功能:

  • 如果我们尝试将数字A乘以数字B,则这与B * A相同。如果我们重新排列操作数并且结果在任何操作下都没有改变,则他们说该操作是可交换的。例如:a + b = b + a运算是可交换的,a-b≠b-a运算是不可交换的,a * b = b * a乘数运算是可交换的。因此,与数字乘法相比,矩阵乘法的运算是不可交换的。即,将矩阵M乘以矩阵N将不等于将矩阵N乘以M。
  • 如果第一个矩阵的列数(在左侧)等于第二个矩阵的行数(在右侧),则可以进行矩阵乘法。 

现在,我们来看看矩阵乘法的第二个特征(当可以进行乘法时)。以下是一些示例,它们说明何时可以进行乘法运算,何时不可以进行乘法运算:

M1=[12]


M2=[123456]


M1 M2 , .. 2 , 2 .

M1=[325442745794]


M2=[104569]


1 2 , .. 3 , 3 .

M1=[5403]


M2=[730363]


1 2 , .. 2 , 3 .

我认为这些示例在可以进行乘法运算时可以使图片更清晰。矩阵乘法的结果将始终是一个矩阵,其行数将等于第一个矩阵的行数,并且列数等于第二个矩阵的列数。例如,如果我们将矩阵2乘以6,将6乘以8,则得到大小为2乘以8的矩阵。现在,我们直接进入乘法本身。

对于乘法,重要的是要记住矩阵中的列和行从1开始编号,而数组中的行从0开始编号。矩阵元素中的第一个索引指示行号,第二个索引指示列号。也就是说,如果矩阵元素(数组元素)写为:m28,则意味着我们转到第二行和第八列。但是,由于我们将在代码中使用数组,因此所有行和列的索引都将从0开始。

让我们尝试将2个矩阵A和B与特定的大小和元素相乘:

A=[123456]


B=[78910]


可以看出,矩阵A的大小为3×2,矩阵B的大小为2×2,可以相乘:

AB=[17+2918+21037+4938+41057+6958+610]=[2528576489100]


如您所见,我们有一个3 x 2的矩阵,乘法运算最初是令人困惑的,但是如果有一个目标是学习如何“无压力”地乘法,则需要解决几个示例。这是将矩阵A和B相乘的另一个示例:

A=[32]


B=[230142]


AB=[32+2133+2430+22]=[814]


如果乘法不完全清楚,那就可以了,因为 我们不必乘以叶子。我们将编写一次矩阵乘法函数并将使用它。总的来说,所有这些功能都已经编写好了,但是我们自己做所有事情。

现在,将来还会使用更多术语:

  • 方阵是行数等于列数的矩阵,这是方阵的示例:

[2364]


2乘2方阵

[567902451]


3乘3方阵

[5673902145131798]


4乘4方阵

  • 方阵的主要对角线称为行号等于列号的矩阵的所有元素。对角线的示例(在此示例中,主对角线用9填充): 

[9339]


[933393339]


[9333393333933339]



  • 单位矩阵是一个方矩阵,其中主对角线的所有元素均为1,所有其他元素均为0.单位矩阵的示例:

[1001]


[100010001]


[1000010000100001]



同样重要的是要记住这样一个属性:如果我们将任意矩阵M乘以一个大小合适的单位矩阵(例如称其为I),我们将得到原始矩阵M,例如:M * I = M或I * M =M。将矩阵乘以单位矩阵不会影响结果。稍后我们将返回身份矩阵。在3D编程中,我们通常会使用4 x 4的正方形矩阵。

现在让我们看一下为什么我们需要矩阵以及为何将它们相乘?在3D编程中,有许多不同的4×4矩阵,如果乘以向量或点,它们将执行我们需要的动作。例如,我们需要围绕X轴在三维空间中旋转字符,该怎么做?将向量乘以负责绕X轴旋转的特殊矩阵,如果需要围绕原点移动和旋转点,则需要将此点乘以特殊矩阵。矩阵具有出色的属性-结合转换(我们将在本文中进行讨论)。假设在应用程序中我们需要一个由100个点(顶点,但这也会稍低)组成的字符,增加5次,然后旋转90度X,然后将其向上移动30个单位。如前所述,对于不同的动作,我们已经考虑了特殊的矩阵。例如,要完成上述任务,我们循环遍历所有100个点,然后首先与第一个矩阵相乘以增加字符,然后与第二个矩阵相乘以在X中旋转90度,然后再乘以3向上移动30个单位。总的来说,每个点都有3个矩阵乘法,而100个点则意味着有300个乘法,但是如果我们对每个矩阵进行乘积乘以5倍,则沿X旋转90度并移动30个单位。向上,我们得到一个包含所有这些动作的矩阵。将点乘以这样的矩阵,该点将是需要的点。现在,我们来计算执行了多少操作:3个矩阵的2次乘法,以及100点的100次乘法,总共102次乘法肯定比之前的300次乘法好。我们将3个矩阵相乘以将不同的动作组合到一个矩阵中的那一刻被称为变换的组合,我们当然会举例说明。

我们研究了如何将矩阵乘以矩阵,但是上面阅读的段落谈到了矩阵与点或向量的乘积。要乘以一个点或向量,将它们表示为矩阵就足够了。

例如,我们有一个向量[10,2,5]并有一个矩阵: 

[121221043]


可以看出向量可以由1乘3的矩阵或3乘1的矩阵表示。因此,我们可以将向量乘以2种方式的矩阵:

[1025][121221043]


在这里,我们将向量表示为1 x 3矩阵(也称为行向量)。这样的乘法是可能的,因为 第一个矩阵(行向量)有3列,第二个矩阵有3行。

[121221043][1025]


在这里,我们将向量表示为3 x 1矩阵(也称为列向量)。这样的乘法是可能的,因为 在第一个矩阵中有3列,在第二个(列向量)中有3行。

如您所见,我们可以将向量表示为行向量,然后将其乘以矩阵,或者将向量表示为列向量,然后将其乘以矩阵。让我们检查两种情况下

乘法结果是否相同:将行向量乘以矩阵:

[1025][121221043]=


=[101+22+50102+22+54101+21+53]=[144427]


现在,将矩阵乘以列向量:

[121221043][1025]=[110+25+15210+22+15010+42+35]=[192923]


我们看到,将行向量乘以矩阵,将矩阵乘以列向量,我们得到了完全不同的结果(我们回忆了可交换性)。因此,在3D编程中,有些矩阵被设计为仅与行向量或仅与列向量相乘。如果我们将用于行向量的矩阵乘以列向量,则得到的结果将不会有任何结果。仅在将来使用对您方便的矢量/点表示(行或列),才对矢量/点表示使用适当的矩阵。例如,Direct3D使用向量的字符串表示形式,并且Direct3D中的所有矩阵都设计为将行向量乘以矩阵。 OpenGL使用向量(或点)的表示作为列,并且所有矩阵都设计为将矩阵乘以列向量。在文章中,我们将使用列向量,并将矩阵乘以列向量。

总结一下我们对矩阵的了解。

  • 为了对向量(或点)执行操作,有一些特殊的矩阵,我们将在本文中介绍其中的一些矩阵。
  • 要组合变换(位移,旋转等),我们可以将每个变换的矩阵彼此相乘,并获得一个包含所有变换在一起的矩阵。
  • 在3D编程中,我们将不断使用4×4平方矩阵。
  • 通过将矩阵表示为列或行,可以将其乘以向量(或点)。但是对于列向量和行向量,您需要使用不同的矩阵。

在对矩阵稍作分析之后,让我们添加一个4 x 4矩阵类,并实现将矩阵乘以矩阵,将向量乘以矩阵的方法。我们将使用矩阵4乘4的大小,因为用于各种动作(运动,旋转,缩放等)的所有标准矩阵都具有这样的大小,我们不需要使用其他大小的矩阵。  

让我们将Matrix类添加到项目中。有时,用于处理4 x 4矩阵的类称为Matrix4,标题中的这个4告诉我们矩阵的大小(它们也称为4阶矩阵)。所有矩阵数据将存储在4 x 4二维数组中。

我们转向乘法运算的实现。我不建议为此使用循环。为了提高性能,我们所有人都必须逐行相乘-之所以会发生这种情况,是因为所有相乘都会发生在固定大小的矩阵上。我将使用循环进行乘法运算,仅节省代码量,您可以编写所有乘法而无需循环。我的乘法代码如下所示:

class Matrix {
  static multiply(a, b) {
    const m = [
      [0, 0, 0, 0],
      [0, 0, 0, 0],
      [0, 0, 0, 0],
      [0, 0, 0, 0],
    ];

    for (let i = 0; i < 4; i++) {
      for (let j = 0; j < 4; j++) {
        m[i][j] = a[i][0] * b[0][j] +
          a[i][1] * b[1][j] +
          a[i][2] * b[2][j] +
          a[i][3] * b[3][j];
      }
    }

    return m;
  }
}

如您所见,该方法采用矩阵a和b,将它们相乘并将结果返回到同一数组4中乘以4。在该方法的开头,我创建了一个由零填充的矩阵m,但这不是必需的,因此我想显示结果的维数,您您可以创建没有数据的4 x 4阵列。

现在,您需要如上所述将矩阵乘以列向量。但是,如果将向量表示为列,则会得到以下形式的矩阵:[xyz]
我们将需要乘以4乘以4的矩阵来执行各种操作。但是在此示例中可以清楚地看到,由于列向量具有3行,而矩阵具有4列,因此无法执行这种乘法。那该怎么办?需要第四个元素,那么向量将具有4行,这将等于矩阵中的列数。让我们在向量上添加第4个参数,并将其命名为W,现在所有3D向量都以[X,Y,Z,W]的形式出现,并且这些向量已经可以乘以4乘以4。实际上,W分量一个更深层次的目标,但是我们将在下一部分中了解他(我们拥有4 x 4矩阵,而不是3 x 3矩阵并不是没有目的的)。添加到在w组件上方创建的Vector类。现在,Vector类的开始看起来像这样:

class Vector {
    x = 0;
    y = 0;
    z = 0;
    w = 1;

    constructor(x, y, z, w = 1) {
        this.x = x;
        this.y = y;
        this.z = z;
        this.w = w;
    }

我将W初始化为1,但是为什么是1?如果我们看一下矩阵和向量的乘积是如何相乘的(下面的代码示例),可以看到如果将W设置为0或除1以外的任何其他值,则将该W相乘会影响结果,但不会我们知道如何使用它,如果我们将其设为1,则它将位于向量中,但结果不会发生任何变化。 

现在回到矩阵并在Matrix类中实现(您也可以在Vector类中实现),将矩阵乘以一个向量,这要归功于W:

static multiplyVector(m, v) {
  return new Vector(
    m[0][0] * v.x + m[0][1] * v.y + m[0][2] * v.z + m[0][3] * v.w,
    m[1][0] * v.x + m[1][1] * v.y + m[1][2] * v.z + m[1][3] * v.w,
    m[2][0] * v.x + m[2][1] * v.y + m[2][2] * v.z + m[2][3] * v.w,
    m[3][0] * v.x + m[3][1] * v.y + m[3][2] * v.z + m[3][3] * v.w,
  )
}

请注意,我们将矩阵表示为4×4数组,并将向量表示为具有x,y,z,w属性的对象,将来我们将更改向量,并以1×4数组表示,因为它将加快乘法。但是现在,为了更好地了解乘法是如何发生的,并提高对代码的理解,我们将不更改向量。

我们编写了自己之间矩阵乘法和矩阵向量乘法的代码,但仍不清楚这将如何帮助我们处理三维图形。

我还想提醒您,我将向量称为点(在空间中的位置)和方向,因为两个对象都包含相同的数据结构x,y,z和新引入的w。 

让我们看一些对向量执行基本运算的矩阵。这些矩阵中的第一个将是转换矩阵。将位移矩阵乘以一个向量(位置),它将在空间中移动指定数量的单位。这是位移矩阵:

[100dx010dy001dz0001]


其中dx,dy,dz分别表示沿x,y,z轴的位移,此矩阵设计为与列向量相乘。这样的矩阵可以在Internet或任何有关3D编程的文献中找到,我们不需要自己创建它们,现在就使用它们,因为您在学校使用的公式只需要知道或理解为什么要使用它们。让我们检查一下,是否确实将这样的矩阵乘以向量,是否会发生偏移。以矢量为例,我们将移动矢量[10,10,10,1](我们始终将第4个参数W始终保持为1),假设这是我们角色在游戏中的位置,我们想将其向上移动10个单位,即5个单位右边,离屏幕1个单位。那么位移向量将像[10,5,-1](-1,因为我们有一个右手坐标系,而另外一个Z越小)。如果我们在不使用矩阵的情况下计算结果,则通常采用向量求和。这将导致以下结果:[10 + 10、10 + 5、10 + -1,1] = [20、15、9、1]-这些是角色的新坐标。将上面的矩阵乘以初始坐标[10,10,10,1],我们应该得到相同的结果,让我们在代码中检查一下,在Drawer,Vector和Matrix类之后编写乘法:
const translationMatrix = [
  [1, 0, 0, 10],
  [0, 1, 0, 5],
  [0, 0, 1, -1],
  [0, 0, 0, 1],
]
        
const characterPosition = new Vector(10, 10, 10)
        
const newCharacterPosition = Matrix.multiplyVector(
  translationMatrix, characterPosition
)
console.log(newCharacterPosition)

在此示例中,我们将所需的字符偏移量(translationMatrix)替换为位移矩阵,初始化其初始位置(characterPosition),然后将其与矩阵相乘,结果通过console.log输出(这是JS中的调试输出)。如果您使用非JS,则使用您的语言工具自己输出X,Y,Z。我们在控制台中得到的结果:[20、15、9、1],一切都与我们上面计算的结果一致。您可能有一个问题,如果将向量与一个偏移量分量求和要容易得多,为什么要通过将向量乘以特殊矩阵来获得相同的结果。答案不是最简单的,我们将更详细地讨论它,但是现在可以注意到,正如前面所讨论的,我们可以将矩阵与它们之间不同的变换组合在一起,因此减少了很多计算。在上面的示例中,我们手动创建了translationMatrix矩阵,并在其中替换了必要的偏移量,但是由于我们将经常使用此矩阵和其他矩阵,因此让我们将其放入Matrix类的方法中,并使用参数将偏移量传递给它:

static getTranslation(dx, dy, dz) {
  return [
    [1, 0, 0, dx],
    [0, 1, 0, dy],
    [0, 0, 1, dz],
    [0, 0, 0, 1],
  ]
}

仔细看一下位移矩阵,您会发现dx,dy,dz在最后一列中,如果我们看一下将矩阵乘以矢量的代码,我们会注意到此列乘以矢量的W分量。例如,如果它是0,则dx,dy,dz,我们将乘以0,并且移动将不起作用。但是如果要将方向存储在Vector类中,则可以将W等于0,因为 不可能移动方向,所以我们会保护自己,即使将方向乘以位移矩阵也不会破坏方向矢量,因为 所有运动都将乘以0。

总计,我们可以应用这样的规则,我们创建一个类似这样的位置:

new Vector(x, y, z, 1) // 1    ,   

我们将按照以下方式创建方向:

new Vector(x, y, z, 0)

因此,我们可以区分位置和方向,并且在将方向乘以位移矩阵时,不会意外破坏方向矢量。

顶点和索引


在了解其他矩阵之前,我们将先看一下如何应用现有知识在屏幕上显示三维物体。我们在此之前推断出的只是线条和像素。但是,现在让我们使用这些工具来导出例如多维数据集。为此,我们需要弄清楚三维模型的组成。任何3D模型中最基本的组成部分是可以沿着其绘制的点(以下称为顶点);实际上,这些点是很多位置向量,如果我们将它们正确地与线连接,则可以得到3D模型(模型网格) )在屏幕上,它将没有纹理并且没有许多其他属性,但是一切都有时间。看一下我们要输出的多维数据集,并尝试了解它具有多少个顶点:



在图像中,我们看到该多维数据集具有8个顶点(为方便起见,我对它们进行了编号)。并且所有顶点都通过线(立方体的边缘)相互连接。也就是说,为了描述多维数据集并用线绘制,我们需要每个顶点有8个坐标,并且还需要指定从哪个顶点绘制线来创建多维数据集,因为如果我们错误地连接了顶点,例如,从顶点绘制一条线0到顶点6,那么它绝对不是立方体,而是另一个对象。现在让我们描述8个顶点中每个顶点的坐标。在现代图形中,3D模型可以包含数以万计的顶点,当然没有人手动指定它们。模型是在3D编辑器中绘制的,导出3D模型时,它的代码中已经包含了所有顶点,我们只需要加载和绘制它们,但是现在我们正在学习并且无法读取3D模型的格式,因此我们将手动描述立方体。他很简单。

想象一下,上面的多维数据集在坐标中心,其中心在点0、0、0,并且应该在该中心周围显示:


让我们从顶点0开始,让我们的立方体很小,以免现在不写大的值,我的立方体的尺寸将是2宽,2高和2深,即2 by 2 by2。图片显示顶点0稍微位于中心0、0、0的左侧,因此我将X = -1设置为左侧,X越小,顶点0也比中心0、0、0略高,并且在我们的坐标系中,位置越高,Y越大,我将设置顶点Y = 1,也将Z设置为顶点0,离屏幕更近一点相对于点0、0、0,它等于Z = 1,因为在右手坐标系中,Z随着物体的靠近而增加。结果,我们得到了零顶点的坐标-1、1、1,让我们对其余7个顶点进行相同的操作,然后将其保存在数组中,以便可以循环使用它们,我得到了这个结果(可以在Drawer,Vector,Marix类下创建一个数组):

// Cube vertices
const vertices = [
  new Vector(-1, 1, 1), // 0 
  new Vector(-1, 1, -1), // 1 
  new Vector(1, 1, -1), // 2 
  new Vector(1, 1, 1), // 3 
  new Vector(-1, -1, 1), // 4 
  new Vector(-1, -1, -1), // 5 
  new Vector(1, -1, -1), // 6 
  new Vector(1, -1, 1), // 7 
];

我将每个顶点放在Vector类的实例中,这不是提高性能的最佳选择(更好地放在数组中),但是现在我们的目标是弄清楚一切如何工作。

现在,让我们将立方体的顶点坐标作为要在屏幕上绘制的像素,在这种情况下,我们看到立方体的大小为2 x 2 x 2像素。我们创建了一个很小的立方体,以便看一下缩放矩阵的工作,我们将用它来增加它。将来,将模型缩小甚至小于我们的模型是一种很好的做法,以便在不大相异的标量的情况下将模型增加到所需的大小。

只是用像素绘制立方体点不是很清楚,因为我们将看到的是8个像素,每个顶点一个,使用上一篇文章的drawLine函数用线条绘制一个立方体要好得多。但是为此,我们需要了解从哪些顶点传递到哪些线。再次查看带有索引的多维数据集的图像,我们将看到它由12条线(或边)组成。还很容易看到我们知道每行的开始和结束的坐标。例如,应从顶点0到顶点3,或从坐标[-1,1,1]到坐标[1,1,1]绘制其中一条线(附近)。我们将不得不手动查看多维数据集的图像来在代码中编写有关每一行的信息,但是如何正确执行呢?如果我们有12行,并且每行都有一个起点和终点,即2分画一个立方体,我们需要24点?这是正确的答案,但让我们再次看一下多维数据集的图像,并注意以下事实:多维数据集的每一行都有共同的顶点,例如,在顶点0处连接了3条线,每个顶点也是如此。我们可以节省内存,而不必写下每条线的起点和终点的坐标,只需创建一个数组并从这些线的起点和终点的顶点数组中指定顶点索引。让我们创建一个这样的数组,并仅使用顶点索引(每行2个索引(行的开头和结尾))来描述它。再进一步,当我们绘制这些线时,我们可以轻松地从顶点数组中获取它们的坐标。我的线数组(我称其为边,因为它们是立方体的边),我在下面创建了一个顶点数组,它看起来像这样:但是,让我们再次看一下多维数据集的图像,并注意以下事实:多维数据集的每条线都有共同的顶点,例如,在顶点0处,有3条线相连,每个顶点也是如此。我们可以节省内存,而不必写下每条线的起点和终点的坐标,只需创建一个数组并从这些线的起点和终点的顶点数组中指定顶点索引。让我们创建一个这样的数组,并仅使用顶点索引(每行2个索引(行的开头和结尾))来描述它。再进一步,当我们绘制这些线时,我们可以轻松地从顶点数组中获取它们的坐标。我的线数组(我称其为边,因为它们是立方体的边),我在下面创建了一个顶点数组,它看起来像这样:但是,让我们再次看一下多维数据集的图像,并注意以下事实:多维数据集的每条线都有共同的顶点,例如,在顶点0处,有3条线相连,每个顶点也是如此。我们可以节省内存,而不必写下每条线的起点和终点的坐标,只需创建一个数组并从这些线的起点和终点的顶点数组中指定顶点索引。让我们创建一个这样的数组,并仅使用顶点索引(每行2个索引(行的开头和结尾))来描述它。再进一步,当我们绘制这些线时,我们可以轻松地从顶点数组中获取它们的坐标。我的线数组(我称其为边,因为它们是立方体的边),我在下面创建了一个顶点数组,它看起来像这样:以及每个顶点。我们可以节省内存,而不必写下每条线的起点和终点的坐标,只需创建一个数组并从这些线的起点和终点的顶点数组中指定顶点索引。让我们创建一个这样的数组,并仅使用顶点索引(每行2个索引(行的开头和结尾))来描述它。再进一步,当我们绘制这些线时,我们可以轻松地从顶点数组中获取它们的坐标。我的线数组(我称其为边,因为它们是立方体的边),我在下面创建了一个顶点数组,它看起来像这样:以及每个顶点。我们可以节省内存,而不必写下每条线的起点和终点的坐标,只需创建一个数组并从这些线的起点和终点的顶点数组中指定顶点索引。让我们创建一个这样的数组,并仅使用顶点索引(每行2个索引(行的开头和结尾))来描述它。再进一步,当我们绘制这些线时,我们可以轻松地从顶点数组中获取它们的坐标。我的线数组(我称其为边,因为它们是立方体的边),我在下面创建了一个顶点数组,它看起来像这样:每行2个索引(行的开头和结尾)。再进一步,当我们绘制这些线时,我们可以轻松地从顶点数组中获取它们的坐标。我的线数组(我称其为边,因为它们是立方体的边),我在下面创建了一个顶点数组,它看起来像这样:每行2个索引(行的开头和结尾)。再进一步,当我们绘制这些线时,我们可以轻松地从顶点数组中获取它们的坐标。我的线数组(我称其为边,因为它们是立方体的边),我在下面创建了一个顶点数组,它看起来像这样:

// Cube edges
const edges = [
  [0, 1],
  [1, 2],
  [2, 3],
  [3, 0],

  [0, 4],
  [1, 5],
  [2, 6],
  [3, 7],

  [4, 5],
  [5, 6],
  [6, 7],
  [7, 4],
];

此数组中有12对索引,每行2个顶点索引。

让我们熟悉另一个可以增加立方体的矩阵,最后,尝试将其绘制在屏幕上。比例矩阵如下所示:

[sx0000sy0000sz00001]


主对角线上的参数sx,sy,sz表示我们要增加对象多少次。如果我们将10、10、10替换为矩阵而不是sx,sy,sz,然后将该矩阵乘以立方体的顶点,这将使我们的立方体大十倍,不再是2乘2乘2,而是20乘20乘20.

对于缩放矩阵以及位移矩阵,我们在Matrix类中实现该方法,该方法将返回已替换参数的矩阵:

static getScale(sx, sy, sz) {
  return [
    [sx, 0, 0, 0],
    [0, sy, 0, 0],
    [0, 0, sz, 0],
    [0, 0, 0, 1],
  ]
}

可视化传送带


如果现在尝试使用顶点的当前坐标绘制带有线的立方体,则在屏幕的左上角将得到一个非常小的两像素的立方体,因为画布的原点在那里。让我们循环遍历多维数据集的所有顶点,然后将它们与缩放矩阵相乘以使多维数据集变大,然后与位移矩阵相乘,以查看不在左上角但在屏幕中间的多维数据集,下面是用于枚举具有矩阵乘法的顶点的代码边缘数组,如下所示:

const sceneVertices = []
for(let i = 0 ; i < vertices.length ; i++) {
  let vertex = Matrix.multiplyVector(
    Matrix.getScale(100, 100, 100),
    vertices[i]
  );

  vertex = Matrix.multiplyVector(
    Matrix.getTranslation(400, -300, 0),
    vertex
  );

  sceneVertices.push(vertex);
}

请注意,我们不会更改多维数据集的原始顶点,而是将乘法结果保存到sceneVertices数组中,因为我们可能想在不同的坐标中绘制几个不同大小的多维数据集,并且如果我们更改初始坐标,则将无法绘制下一个多维数据集t 。至。没有任何起点,第一个立方体将破坏初始坐标。在上面的代码中,由于将所有顶点乘以带有参数100、100、100的缩放矩阵,我在各个方向上将原始多维数据集增加了100倍,并且我还将多维数据集的所有顶点分别向右和向下移动了400和-300像素,因为上一篇文章中的画布大小是800 x 600,它将只是绘图区域的宽度和高度的一半,换句话说,就是中心。

到目前为止,我们已经完成了顶点的制作,但是我们仍然需要使用drawLine和edges数组绘制所有这些顶点,让我们在vertices循环下面编写另一个循环以遍历边缘并在其中绘制所有线条:

drawer.clearSurface()

for (let i = 0, l = edges.length ; i < l ; i++) {
  const e = edges[i]

  drawer.drawLine(
    sceneVertices[e[0]].x,
    sceneVertices[e[0]].y,
    sceneVertices[e[1]].x,
    sceneVertices[e[1]].y,
    0, 0, 255
  )
}

ctx.putImageData(imageData, 0, 0)

回想一下,在上一篇文章中,我们通过调用clearSurface方法从屏幕上的前一个状态清除屏幕来开始绘制所有图形,然后遍历多维数据集的所有面并用蓝线(0、0、255)绘制多维数据集,然后从sceneVertices数组t中获取线的坐标。至。在上一个循环中已经有缩放和移动的顶点,但是这些顶点的索引与来自顶点数组的原始顶点的索引一致,因为 我处理了它们,然后将它们放到sceneVertices数组中,而无需更改顺序。 

如果现在运行代码,则屏幕上将看不到任何内容。这是因为在我们的坐标系中,Y向上看,而在坐标系中,画布向下看。事实证明,这里有我们的立方体,但是它在屏幕之外,为了解决这个问题,我们需要在Drawer类中绘制像素之前将图片翻转为Y(镜像)。到目前为止,此选项对我们已经足够了,因此,为我绘制像素的代码如下所示:

drawPixel(x, y, r, g, b) {
  const offset = (this.width * -y + x) * 4;

  if (x >= 0 && x < this.width && -y >= 0 && y < this.height) {
    this.surface[offset] = r;
    this.surface[offset + 1] = g;
    this.surface[offset + 2] = b;
    this.surface[offset + 3] = 255;
  }
}

可以看出,在获得偏移量的公式中,Y现在带有负号,并且轴现在朝着我们所需的方向看,而且在此方法中,我还添加了检查是否超出像素阵列的限制。由于上一篇文章中的评论,在Drawer类中出现了一些其他优化,因此我在整个Drawer类中发布了一些优化,您可以用这个替代旧的Drawer:

改进的抽屉类代码
class Drawer {
  surface = null;
  width = 0;
  height = 0;

  constructor(surface, width, height) {
    this.surface = surface;
    this.width = width;
    this.height = height;
  }

  drawPixel(x, y, r, g, b) {
    const offset = (this.width * -y + x) * 4;

    if (x >= 0 && x < this.width && -y >= 0 && y < this.height) {
      this.surface[offset] = r;
      this.surface[offset + 1] = g;
      this.surface[offset + 2] = b;
      this.surface[offset + 3] = 255;
    }
  }

  drawLine(x1, y1, x2, y2, r = 0, g = 0, b = 0) {
    const round = Math.trunc;
    x1 = round(x1);
    y1 = round(y1);
    x2 = round(x2);
    y2 = round(y2);

    const c1 = y2 - y1;
    const c2 = x2 - x1;

    const length = Math.max(
      Math.abs(c1),
      Math.abs(c2)
    );

    const xStep = c2 / length;
    const yStep = c1 / length;

    for (let i = 0 ; i <= length ; i++) {
      this.drawPixel(
        Math.trunc(x1 + xStep * i),
        Math.trunc(y1 + yStep * i),
        r, g, b,
      );
    }
  }

  clearSurface() {
    const surfaceSize = this.width * this.height * 4;
    for (let i = 0; i < surfaceSize; i++) {
      this.surface[i] = 0;
    }
  }
}

const drawer = new Drawer(
  imageData.data,
  imageData.width,
  imageData.height
);


如果您现在运行代码,则以下图像将出现在屏幕上:


在这里您可以看到在中心有一个正方形,尽管我们希望得到一个立方体,这是怎么回事?实际上-这是立方体,它只是与面向我们的一张面孔(侧面)完美对齐,因此我们看不到其余部分。另外,我们还不熟悉投影,因此立方体的背面不会像现实生活中那样随着距离变小。为了确保它确实是一个立方体,让我们旋转一下它,使其看起来像我们在构造顶点数组时看到的图像。为了旋转3D图像,您可以使用3个特殊矩阵,因为我们可以绕X,Y或Z轴之一旋转,这意味着每个轴都有其自己的旋转矩阵(还有其他旋转方式,但这是下一篇文章的主题)。这些矩阵如下所示:

Rx(a)=[10000cos(a)sin(a)00sin(a)cos(a)00001]


X轴旋转矩阵

Ry(a)[cos(a)0sin(a)00100sin(a)0cos(a)00001]


Y轴旋转矩阵

Rz(a)[cos(a)sin(a)00sin(a)cos(a)0000100001]


Z轴旋转矩阵

如果我们将多维数据集的顶点乘以这些矩阵之一,则多维数据集将绕轴旋转指定角度(a),我们将围绕该轴旋转。一次旋转多个轴时有一些功能,下面我们将对其进行介绍。从矩阵示例中可以看到,它们使用2个函数sin和cos,JavaScript已经具有用于计算Math.sin(a)和Math.cos(a)的函数,但是它们使用弧度测量角度,这似乎并不是最方便的方法如果我们想旋转模型。例如,对于我来说,将某物旋转90度(度数)会更方便,这在弧度数中表示Pi / 2(JS中也有一个近似的Pi值,这是常量Math.PI)。让我们在Matrix类中添加3种方法来获得旋转矩阵,它们具有可接受的旋转角度(以度为单位),我们将其转换为弧度,因为 它们是sin / cos功能起作用所必需的:

static getRotationX(angle) {
  const rad = Math.PI / 180 * angle;

  return [
    [1, 0, 0, 0],
    [0, Math.cos(rad), -Math.sin(rad), 0],
    [0, Math.sin(rad), Math.cos(rad), 0],
    [0, 0, 0, 1],
  ];
}

static getRotationY(angle) {
  const rad = Math.PI / 180 * angle;

  return [
    [Math.cos(rad), 0, Math.sin(rad), 0],
    [0, 1, 0, 0],
    [-Math.sin(rad), 0, Math.cos(rad), 0],
    [0, 0, 0, 1],
  ];
}

static getRotationZ(angle) {
  const rad = Math.PI / 180 * angle;

  return [
    [Math.cos(rad), -Math.sin(rad), 0, 0],
    [Math.sin(rad), Math.cos(rad), 0, 0],
    [0, 0, 1, 0],
    [0, 0, 0, 1],
  ];
}

这3种方法均始于将度数转换为弧度,然后将弧度的旋转角度代入旋转矩阵,然后将角度传递给函数sin和cos。为什么矩阵如此,您可以在主题文章的中心上阅读更多信息,并提供非常详细的说明,否则您可以将这些矩阵视为已为我们计算出的公式,我们可以确定它们是否有效。

在上面的代码中,我们实现了2个循环,第一个循环转换顶点,第二个循环通过顶点索引绘制线,结果,我们从顶点在屏幕上得到了图片,我们将此代码段称为可视化管道。输送机是因为我们要达到峰值,然后像正常的工业输送机一样对它进行缩放,移动,旋转,渲染等操作。现在,除了缩放,绕轴旋转之外,我们还添加到可视化管道的第一个循环中。首先,我将绕过X,然后绕过Y,然后增加模型并将其移动(最后两个动作已经存在),因此整个循环代码将如下所示:

for(let i = 0 ; i < vertices.length ; i++) {
  let vertex = Matrix.multiplyVector(
    Matrix.getRotationX(20),
    vertices[i]
  );

  vertex = Matrix.multiplyVector(
    Matrix.getRotationY(20),
    vertex
  );

  vertex = Matrix.multiplyVector(
    Matrix.getScale(100, 100, 100),
    vertex
  );

  vertex = Matrix.multiplyVector(
    Matrix.getTranslation(400, -300, 0),
    vertex
  );

  sceneVertices.push(vertex);
}

在此示例中,我将所有顶点绕X轴旋转了20度,然后围绕Y轴旋转了20度,我已经有2个剩余的变换。如果正确完成所有操作,则多维数据集现在应该看起来是三维的:


绕轴旋转有一个功能,例如,如果先绕Y轴旋转立方体,然后绕X轴旋转立方体,则结果将有所不同:



绕X旋转20度,然后绕Y旋转20度绕Y旋转20度,然后绕X旋转20度

还有其他功能,例如,如果将多维数据集在X轴上旋转90度,然后在Y轴上旋转90度,最后围绕Z轴旋转90度,那么最后绕Z轴旋转将取消绕X轴旋转,并且得到相同的结果结果就好像您只是将图形围绕Y轴旋转了90度。要了解为什么会发生这种情况,请拿起手中的任何矩形(或立方体)对象(例如组装好的Rubik立方体),记住该对象的初始位置并先将其旋转90度围绕虚构的X,然后围绕Y旋转90度,围绕Z旋转90度,并记住它已经变成了您的哪一侧,然后从您之前记得的初始位置开始并进行相同的操作,移除X和Z的转弯,仅转弯是-您会看到结果相同。现在,我们将不解决此问题并对其进行详细说明,这种轮换目前对我们完全令人满意,但是我们将在第三部分中提到此问题(如果您现在想了解更多,请尝试通过“铰链锁”查询在中心上搜索文章) 。

现在让我们对代码进行一些优化,上面提到了可以通过乘以变换矩阵将矩阵变换相互组合。让我们尝试不首先将每个向量乘以围绕X的旋转矩阵,然后乘以Y,然后缩放并在移动结束时乘以,首先,在循环之前,我们将所有矩阵相乘,然后在循环中,我们将每个顶点仅与一个结果矩阵相乘,我有代码像这样出来:

let matrix = Matrix.getRotationX(20);

matrix = Matrix.multiply(
  Matrix.getRotationY(20),
  matrix
);

matrix = Matrix.multiply(
  Matrix.getScale(100, 100, 100),
  matrix,
);

matrix = Matrix.multiply(
  Matrix.getTranslation(400, -300, 0),
  matrix,
);

const sceneVertices = [];
for(let i = 0 ; i < vertices.length ; i++) {
  let vertex = Matrix.multiplyVector(
    matrix,
    vertices[i]
  );

  sceneVertices.push(vertex);
}

在此示例中,转换组合是在循环之前1次执行的,因此每个顶点只有1个矩阵乘法。如果运行此代码,则多维数据集模式应保持不变。

让我们添加最简单的动画,即,我们将在间隔中更改围绕Y轴的旋转角度,例如,每100毫秒将更改围绕Y轴的旋转角度1度。为此,将可视化管道的代码放入setInterval函数中,我们在第一篇文章中首次使用了该函数。动画管道代码如下所示:

let angle = 0
setInterval(() => {
  let matrix = Matrix.getRotationX(20)

  matrix = Matrix.multiply(
    Matrix.getRotationY(angle += 1),
    matrix
  )

  matrix = Matrix.multiply(
    Matrix.getScale(100, 100, 100),
    matrix,
  )

  matrix = Matrix.multiply(
    Matrix.getTranslation(400, -300, 0),
    matrix,
  )

  const sceneVertices = []
  for(let i = 0 ; i < vertices.length ; i++) {
    let vertex = Matrix.multiplyVector(
      matrix,
      vertices[i]
    )

    sceneVertices.push(vertex)
  }

  drawer.clearSurface()

  for (let i = 0, l = edges.length ; i < l ; i++) {
    const e = edges[i]

    drawer.drawLine(
      sceneVertices[e[0]].x,
      sceneVertices[e[0]].y,
      sceneVertices[e[1]].x,
      sceneVertices[e[1]].y,
      0, 0, 255
    )
  }

  ctx.putImageData(imageData, 0, 0)
}, 100)

结果应该是这样的:


在这部分中,我们要做的最后一件事是在屏幕上显示坐标系的轴,以便可以看到立方体绕其旋转的坐标系。我们从中心向上绘制Y轴,长200像素,向右绘制X轴,也长200像素,然后从Z轴向下和向左(对角线)绘制150像素,如右手坐标系图中文章的开头所示。 。让我们从最简单的部分开始,它们是X,Y轴,因为他们的线只向一个方向移动。在绘制立方体的循环(边循环)之后,添加X,Y轴渲染:

const center = new Vector(400, -300, 0)
drawer.drawLine(
  center.x, center.y,
  center.x, center.y + 200,
  150, 150, 150
)

drawer.drawLine(
  center.x, center.y,
  center.x + 200, center.y,
  150, 150, 150
)

中心向量在绘图窗口的中间,因为我指出,当前尺寸为800 x 600,Y为-300,因为drawPixel函数翻转Y并使其方向适合于画布(在画布中,Y向下看)。然后,我们使用drawLine绘制2个轴,首先将Y向上移动200个像素(Y轴的末端),然后向右移动200个像素(X轴的末端)。结果:


现在让我们画出Z轴的线,它是\向下的对角线,其位移矢量将为[-1,-1,0],我们还需要画一条150像素长的线,即位移矢量[-1,-1,0]应为150长,第一个选项为[-150,-150,0],但如果我们计算这样一个矢量的长度,它将约为212个像素。在本文的前面,我们讨论了如何正确获取所需长度的向量。首先,我们需要对其进行归一化,以使其长度为1,然后将标量乘以我们想要的长度,在本例中为150。最后,我们将屏幕中心的坐标和Z轴的位移矢量相加,因此得出Z轴线应终止。让我们编写代码,在前两个轴的输出代码之后绘制Z轴的线:

const zVector = new Vector(-1, -1, 0);
const zCoords = Vector.add(
  center,
  zVector.normalize().multiplyByScalar(150)
);
drawer.drawLine(
  center.x, center.y,
  zCoords.x, zCoords.y,
  150, 150, 150
);

结果,您将获得所需长度的所有3个轴:


在此示例中,Z轴仅显示了我们拥有的坐标系,我们将其对角地绘制以便可以看到它,因为 实际的Z轴垂直于我们的视线,因此我们可以在屏幕上画一个点,这不会太漂亮。

总的来说,在本文中,我们基本了解了坐标系,向量以及对其进行的一些操作,矩阵及其在坐标变换中的作用,整理了顶点并编写了一个简单的传送器以可视化坐标系的立方体和轴,从而将理论与实践相结合。所有应用程序代码都可以在扰流器下找到:

整个应用程序的代码
const ctx = document.getElementById('surface').getContext('2d');
const imageData = ctx.createImageData(800, 600);

class Vector {
  x = 0;
  y = 0;
  z = 0;
  w = 1;

  constructor(x, y, z, w = 1) {
    this.x = x;
    this.y = y;
    this.z = z;
    this.w = w;
  }

  multiplyByScalar(s) {
    this.x *= s;
    this.y *= s;
    this.z *= s;

    return this;
  }

  static add(v1, v2) {
    return new Vector(
      v1.x + v2.x,
      v1.y + v2.y,
      v1.z + v2.z,
    );
  }

  getLength() {
    return Math.sqrt(
      this.x * this.x + this.y * this.y + this.z * this.z
    );
  }

  normalize() {
    const length = this.getLength();

    this.x /= length;
    this.y /= length;
    this.z /= length;

    return this;
  }
}

class Matrix {
  static multiply(a, b) {
    const m = [
      [0, 0, 0, 0],
      [0, 0, 0, 0],
      [0, 0, 0, 0],
      [0, 0, 0, 0],
    ];

    for (let i = 0 ; i < 4 ; i++) {
      for(let j = 0 ; j < 4 ; j++) {
        m[i][j] = a[i][0] * b[0][j] +
          a[i][1] * b[1][j] +
          a[i][2] * b[2][j] +
          a[i][3] * b[3][j];
      }
    }

    return m;
  }

  static getRotationX(angle) {
    const rad = Math.PI / 180 * angle;

    return [
      [1, 0, 0, 0],
      [0, Math.cos(rad), -Math.sin(rad), 0],
      [0, Math.sin(rad), Math.cos(rad), 0],
      [0, 0, 0, 1],
    ];
  }

  static getRotationY(angle) {
    const rad = Math.PI / 180 * angle;

    return [
      [Math.cos(rad), 0, Math.sin(rad), 0],
      [0, 1, 0, 0],
      [-Math.sin(rad), 0, Math.cos(rad), 0],
      [0, 0, 0, 1],
    ];
  }

  static getRotationZ(angle) {
    const rad = Math.PI / 180 * angle;

    return [
      [Math.cos(rad), -Math.sin(rad), 0, 0],
      [Math.sin(rad), Math.cos(rad), 0, 0],
      [0, 0, 1, 0],
      [0, 0, 0, 1],
    ];
  }

  static getTranslation(dx, dy, dz) {
    return [
      [1, 0, 0, dx],
      [0, 1, 0, dy],
      [0, 0, 1, dz],
      [0, 0, 0, 1],
    ];
  }

  static getScale(sx, sy, sz) {
    return [
      [sx, 0, 0, 0],
      [0, sy, 0, 0],
      [0, 0, sz, 0],
      [0, 0, 0, 1],
    ];
  }

  static multiplyVector(m, v) {
    return new Vector(
      m[0][0] * v.x + m[0][1] * v.y + m[0][2] * v.z + m[0][3] * v.w,
      m[1][0] * v.x + m[1][1] * v.y + m[1][2] * v.z + m[1][3] * v.w,
      m[2][0] * v.x + m[2][1] * v.y + m[2][2] * v.z + m[2][3] * v.w,
      m[3][0] * v.x + m[3][1] * v.y + m[3][2] * v.z + m[3][3] * v.w,
    );
  }
}

class Drawer {
  surface = null;
  width = 0;
  height = 0;

  constructor(surface, width, height) {
    this.surface = surface;
    this.width = width;
    this.height = height;
  }

  drawPixel(x, y, r, g, b) {
    const offset = (this.width * -y + x) * 4;

    if (x >= 0 && x < this.width && -y >= 0 && -y < this.height) {
      this.surface[offset] = r;
      this.surface[offset + 1] = g;
      this.surface[offset + 2] = b;
      this.surface[offset + 3] = 255;
    }
  }
  drawLine(x1, y1, x2, y2, r = 0, g = 0, b = 0) {
    const round = Math.trunc;
    x1 = round(x1);
    y1 = round(y1);
    x2 = round(x2);
    y2 = round(y2);

    const c1 = y2 - y1;
    const c2 = x2 - x1;

    const length = Math.max(
      Math.abs(c1),
      Math.abs(c2)
    );

    const xStep = c2 / length;
    const yStep = c1 / length;

    for (let i = 0 ; i <= length ; i++) {
      this.drawPixel(
        Math.trunc(x1 + xStep * i),
        Math.trunc(y1 + yStep * i),
        r, g, b,
      );
    }
  }

  clearSurface() {
    const surfaceSize = this.width * this.height * 4;
    for (let i = 0; i < surfaceSize; i++) {
      this.surface[i] = 0;
    }
  }
}

const drawer = new Drawer(
  imageData.data,
  imageData.width,
  imageData.height
);

// Cube vertices
const vertices = [
  new Vector(-1, 1, 1), // 0 
  new Vector(-1, 1, -1), // 1 
  new Vector(1, 1, -1), // 2 
  new Vector(1, 1, 1), // 3 
  new Vector(-1, -1, 1), // 4 
  new Vector(-1, -1, -1), // 5 
  new Vector(1, -1, -1), // 6 
  new Vector(1, -1, 1), // 7 
];

// Cube edges
const edges = [
  [0, 1],
  [1, 2],
  [2, 3],
  [3, 0],

  [0, 4],
  [1, 5],
  [2, 6],
  [3, 7],

  [4, 5],
  [5, 6],
  [6, 7],
  [7, 4],
];

let angle = 0;
setInterval(() => {
  let matrix = Matrix.getRotationX(20);

  matrix = Matrix.multiply(
    Matrix.getRotationY(angle += 1),
    matrix
  );

  matrix = Matrix.multiply(
    Matrix.getScale(100, 100, 100),
    matrix,
  );

  matrix = Matrix.multiply(
    Matrix.getTranslation(400, -300, 0),
    matrix,
  );

  const sceneVertices = [];
  for(let i = 0 ; i < vertices.length ; i++) {
    let vertex = Matrix.multiplyVector(
      matrix,
      vertices[i]
    );

    sceneVertices.push(vertex);
  }

  drawer.clearSurface();

  for (let i = 0, l = edges.length ; i < l ; i++) {
    const e = edges[i];

    drawer.drawLine(
      sceneVertices[e[0]].x,
      sceneVertices[e[0]].y,
      sceneVertices[e[1]].x,
      sceneVertices[e[1]].y,
      0, 0, 255
    );
  }

  const center = new Vector(400, -300, 0)
  drawer.drawLine(
    center.x, center.y,
    center.x, center.y + 200,
    150, 150, 150
  );

  drawer.drawLine(
    center.x, center.y,
    center.x + 200, center.y,
    150, 150, 150
  );

  const zVector = new Vector(-1, -1, 0, 0);
  const zCoords = Vector.add(
    center,
    zVector.normalize().multiplyByScalar(150)
  );
  drawer.drawLine(
    center.x, center.y,
    zCoords.x, zCoords.y,
    150, 150, 150
  );

  ctx.putImageData(imageData, 0, 0);
}, 100);


下一步是什么?


在下一部分中,我们将考虑如何控制相机以及如何进行投影(对象越远,直径越小),了解三角形并了解如何根据它们构建3D模型,分析法线是什么以及为什么需要它们。

All Articles