3D自己做。第1部分:像素和线条



我想将这一系列文章专门献给那些希望从头开始探索3D编程世界的读者,以及想要学习创建游戏和应用程序3D组件基础知识的读者。我们将从头开始执行每个操作,以了解各个方面,即使已经有现成的功能可以使其更快。了解了这一点之后,我们将切换到用于3D的内置工具。阅读了系列文章之后,您将了解如何创建具有光,阴影,纹理和效果的复杂三维场景,如何在不具备深入数学知识的情况下完成所有这些工作。您可以独立地使用现成的工具来完成所有这些工作。

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

  • 渲染概念(软件,硬件)
  • 什么是像素/表面?
  • 线路输出的详细分析

为了避免浪费您宝贵的时间阅读那些准备不足的人可能无法理解的文章,我将立即转向要求。如果您了解任何语言的编程基础,就可以安全地开始阅读3D文章,因为我将只专注于3D编程的研究,而不是语言的特性和编程基础的研究。至于数学准备,尽管许多人不希望学习3D,但您不必担心,因为它们因复杂的计算和公式而受到惊吓,因为它们梦later以求,但实际上没有什么可担心的。我将尝试尽可能清晰地解释3D所需的一切,您只需要能够进行乘法,除法,加法和减法即可。因此,如果您已通过选择标准,则可以开始阅读。

在开始探索有趣的3D世界之前,让我们选择一种编程语言作为示例,以及一个开发环境。我应该选择哪种语言来编程3D图形?任何人,您都可以在最舒适的地方工作,到处数学都是一样的。在本文中,所有示例都将在JS上下文中显示(这里有西红柿飞到我身上)。为什么用js?很简单-最近我主要和他一起工作,因此我可以更有效地向您传达要点。我将在示例中绕过JS的所有功能,因为我们只需要任何语言都具备的最基本的功能,因此我们将特别注意3D。但是你选择自己喜欢的东西,因为在文章中,所有公式都不会与任何编程语言的功能绑定。选择哪种环境?不要紧,对于JS,任何文本编辑器都适用,您可以使用更接近您的文本编辑器。

所有示例都将使用画布进行绘画,因为有了它,您可以快速开始绘制,而无需详细分析。 Canvas是一个功能强大的工具,具有许多现成的绘制方法,但是在所有功能方面,我们第一次将仅使用像素输出! 

屏幕上所有使用像素的三维显示,在后面的文章中,您将看到这种情况的发生。会慢下来吗?没有硬件加速(例如,通过视频卡加速)-将会。在第一篇文章中,我们将不使用加速,而是将从头开始编写所有内容,以了解3D的基本方面。让我们看一下将来的文章中将提到的一些术语:

  • (Rendering) — 3D- . , 3D- , , .
  • (Software Rendering) — . , , , - . , . 3D- , — .
  • 硬件渲染 -硬件辅助的渲染过程。我使用它的游戏和应用程序。一切工作都非常快,因为 为此,很多常规计算都接管了视频卡。

我不希望使用“年度定义”这个标题,而是尝试尽可能清晰地陈述所有术语描述。最主要的是要理解这个想法,然后可以独立地开发它。我还想提请注意以下事实:为了保持易于理解,文章中显示的所有代码示例通常都没有针对速度进行优化。当您了解主要内容-3D图形如何工作时,就可以自己优化所有内容。

首先,创建一个项目,对我来说,这只是一个文本index.html文件,其内容如下:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>3D it’s easy. Part 1</title>
</head>

<body>
    <!--         -->
    <canvas id="surface" width="800" height="600"></canvas>

    <script>
        //    
    </script>
</body>

</html>

我现在不会过多地关注JS和canvas-这些不是本文的主要特征。但是,为了大致了解,我将阐明<canvas ...>是一个矩形(在我的情况下,大小为800 x 600像素),我将在其上显示所有图形。我注册过画布一次,将不再更改。

<script></script> 

脚本-一个元素,我们将用自己的双手编写所有逻辑来渲染3D图形(在JavaScript中)。 

当我们刚刚查看了新创建项目index.html文件的结构时,我们将开始处理3D图形。

当我们在窗口中绘制某些东西时,最终的数量会变成像素,因为监视器显示的是它们。像素越多,图片越清晰,但是计算机的负载也更多。我们在窗口中绘制的内容如何存储?任何窗口中的图形都可以表示为像素阵列,而像素本身只是一种颜色。也就是说,屏幕分辨率为800x600意味着我们的窗口包含600行,每行800个像素,即800 * 600 = 480000像素,不是吗?像素存储在阵列中。让我们考虑将像素存储在哪个数组中。如果我们应该有800 x 600像素,那么最明显的选择是在800 x 600的二维数组中。这几乎是正确的选择,或者是完全正确的选择。但是窗口的像素最好以480,000个元素的一维数组存储(如果分辨率为800 x 600),只是因为使用一维数组更快,因为它以连续的字节顺序存储在内存中(所有内容都位于附近,因此很容易获取)。在二维数组中(例如,在JS中),每行可以分散在内存中的不同位置,因此访问此类数组的元素将花费更长的时间。另外,要对一维数组进行排序,只需要1个周期,对于二维整数2,考虑到需要进行数以万计的循环迭代,因此速度在这里很重要。这样的阵列中的像素是多少?如上所述-这只是一种颜色,或者说是它的3种成分(红色,绿色,蓝色)。任何一个,即使是最彩色的图片,也只是不同颜色的像素阵列。您可以根据需要将内存中的像素存储为3个元素的数组,也可以存储为红色,gree,蓝色;或者是其他东西。由我们刚刚解析的像素数组组成的图像,我将继续称其为表面。事实证明,由于屏幕上显示的所有内容都存储在一个像素数组中,因此更改了此数组中的元素(像素)-我们将逐像素更改屏幕上的图像。这正是我们在本文中将要做的。

画布中没有像素绘制功能,但是可以访问一维像素数组,如上所述。下面的示例显示了如何执行此操作(此示例以及以后的所有示例仅在script元素内部):

//     ()    
const ctx = document
.getElementById('surface')
.getContext('2d')

//     ,    
// +       
const imageData = ctx.createImageData(800, 600)

在示例中,imageData是一个对象,其中具有3个属性:

  • 高度和宽度 -存储用于绘制的窗口的高度和宽度的整数
  • 数据 -8位无符号整数数组(可以在其中存储0到255之间的数字)

数据数组具有简单但说明性的结构。这个一维数组存储每个像素的数据,我们将以以下格式在屏幕上显示它们:
数组的前4个元素(索引0、1、2、3)是第一行中第一个像素的数据。后四个元素(索引4、5、6、7)是第一行第二个像素的数据。当我们到达第一行的第800个像素时,假设窗口的宽度为800像素-第801个像素将已经属于第二行。如果我们更改它,在屏幕上我们将看到第二行的第一个像素已更改(尽管按数组中的计数它将是第801个像素)。为什么数组中每个像素有4个元素?这是因为在画布中,除了为每种颜色分配1个元素(红色,绿色,蓝色(这是3个元素)),还有1个元素用于透明度(它们也称为alpha通道或不透明度)。像颜色一样,Alpha通道的设置范围是0(透明)到255(不透明)。通过这种结构,我们得到一个32位的图像,因为每个像素都由4个8位元素组成。总结一下:每个像素都包含:红色,绿色,蓝色和Alpha通道(透明度)。这种配色方案称为ARGB(Alpha Red Green Blue)。每个像素占用32位的事实表明我们有一个32位的图像(它们也称为具有32位色深的图像)。

默认情况下,整个像素数组imageData.data(data是其中像素数组的属性,而imageData只是一个对象)填充了值0,并且如果我们尝试输出这样的数组,我们将在屏幕上看不到任何有趣的东西,因为0 ,0,0是黑色,但是由于这里的透明度也将是0,并且这是完全透明的颜色,所以我们甚至在屏幕上都看不到黑色!

直接使用这样的一维数组很不方便,因此我们将为其编写一个类,在其中创建绘制方法。我将命名为“ Drawer”。该类将仅存储必要的数据并执行必要的计算,并尽可能地从用于渲染的工具中抽象出来。这就是为什么我们要放置所有计算并在其中处理数组的原因。在画布上对display方法的调用,我们将放置在类之外,因为可能还有其他东西,而不是画布。在这种情况下,不必更改我们的班级。要处理像素(表面)数组,对于我们来说,将其以及图像的宽度和高度保存在Drawer类中更为方便,以便我们可以正确访问所需的像素。因此,Drawer类在保留绘图所需的最少数据的同时,对我来说是这样的:

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

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

如您在构造函数中所见,Drawer类获取所有必需的数据并保存。现在,您可以创建此类的实例,并将像素,宽度和高度的数组传递给它(我们已经拥有了所有这些数据,因为我们在上面创建了它并将其存储在imageData中):

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

在Drawer类中,我们将编写一些绘图函数,以简化将来的工作。我们将提供一个用于绘制像素的功能,一个用于绘制线条的功能,并且在进一步的文章中,将出现一个用于绘制三角形和其他形状的功能。但是,让我们从像素绘制方法开始。我称他为drawPixel。如果我们画一个像素,那么它应该具有坐标以及颜色:

drawPixel(x, y, r, g, b)  { }

请注意,drawPixel函数不接受alpha参数(透明度),并且在上面我们发现像素数组由3个颜色参数和1个透明度参数组成。我没有明确指出透明度,因为我们绝对不需要它作为示例。默认情况下,我们将设置为255(即所有内容都是不透明的)。现在让我们考虑如何将所需的颜色写入x,y坐标的像素数组中。由于我们拥有关于图像的所有信息,因此将其存储在一个一维数组中,其中为每个像素分配了1个像素(8位)。要访问阵列中的所需像素,我们首先需要确定红色位置索引,因为任何像素都以它开头(例如[r,g,b,a])。数组结构的一些解释:



绿色的表格表示颜色成分如何存储在一维表面阵列中。它们在同一数组中的索引用蓝色表​​示,接受drawPixel函数的像素坐标(我们需要将其转换为一维数组中的索引)用蓝色表示像素的r,g,b和a。因此,从表中可以看出,对于每个像素,颜色的红色分量首先出现,让我们从它开始。假设我们要更改图像尺寸为2 x 2像素的坐标X1Y1中像素颜色的红色分量。在表中我们看到这是索引12,但是如何计算呢?首先我们找到所需行的索引,为此我们将图像宽度乘以Y再乘以4(每个像素的值数)-这将是:

width * y * 4 
//  :
2 * 1 * 4 = 8

我们看到第二行从索引8开始。如果我们与板块进行比较,结果将收敛。

现在,您需要向找到的行索引添加列偏移量,以获取所需的红色索引。为此,将X乘以4到行索引,完整的公式将是:

width * y * 4 + x * 4 
//     :
(width * y + x) * 4
//  :
(2 * 1 + 1) * 4 = 12

现在我们将12与表格进行比较,可以看到像素X1Y1确实从索引12开始。

要查找其他颜色分量的索引,您需要向红色索引添加颜色偏移量:+1(绿色),+ 2(蓝色),+ 3(alpha) 。现在,我们可以使用上面的公式在Drawer类中实现drawPixel方法:

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

    this.surface[offset] = r
    this.surface[offset + 1] = g
    this.surface[offset + 2] = b
    this.surface[offset + 3] = 255
}

在此drawPixel方法中,我将公式的重复部分呈现为offset常数。还可以看到在alpha中我只写255,因为它在结构中,但是现在我们不需要输出像素。

现在该测试代码并最终在屏幕上看到第一个像素。这是使用像素渲染方法的示例:

//     Drawer
drawer.drawPixel(10, 10, 255, 0, 0)
drawer.drawPixel(10, 20, 0, 0, 255)

//         canvas
ctx.putImageData(imageData, 0, 0)

在上面的示例中,我绘制了2个像素,一个红色255、0、0,另一个蓝色0、0、255。但是imageData.data数组(也是Drawer类内部的表面)中的更改将不会出现在屏幕上。要进行绘制,您需要调用ctx.putImageData(imageData,0,0),其中imageData是对象,其中像素数组和绘图区域的宽度/高度; 0、0是相对于像素数组将相对于其显示的点(始终保留0、0 )如果正确完成所有操作,则浏览器窗口中canvas元素的左上方将显示以下图片:您看到



像素了吗?它们很小,已经完成了多少工作。

现在,让我们尝试为示例添加一些动态效果,例如,使像素每隔10毫秒向右移动一次(我们每隔10毫秒将X像素更改+1),我们将像素绘制代码每隔一秒校正一次:

let x = 10
setInterval(() => {

    drawer.drawPixel(x++, 20, 0, 0, 255)
    ctx.putImageData(imageData, 0, 0)

}, 10)

在此示例中,我只保留了蓝色像素的输出,并将setInterval函数包装为JavaScript中的参数10,这意味着将大约每10毫秒调用一次代码。如果运行这样的示例,您将看到,而不是向右移动像素,您将看到以下内容:



由于没有清除表面阵列中前一个像素的颜色,因此仍保留了很长的条(或迹线),因此每次调用间隔时我们都会添加一个像素让我们写一个将表面清洁到原始状态的方法。换句话说,用零填充数组。将clearSurface方法添加到Drawer类中:

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

此数组中没有逻辑,仅填充零。建议您每次绘制新图像之前都调用此方法。对于像素动画,在绘制此像素之前:

let x = 10
setInterval(() => {
    drawer.clearSurface()
    drawer.drawPixel(x++, 20, 0, 0, 255)
    ctx.putImageData(imageData, 0, 0)
}, 10)

现在,如果您运行此示例,则像素将一次向右移动-无需从先前的坐标进行不必要的跟踪。

我们在第一篇文章中实现的最后一件事是线条绘制方法。当然,将其添加到Drawer类中。我将调用drawLine的方法。他会吃什么?与点不同,线仍然具有结束点的坐标。换句话说,该行具有开始,结束和颜色,我们将其传递给方法:

drawLine(x1, y1, x2, y2, r, g, b) { }

任何行都由像素组成,仅用x1,y1到x2,y2的像素正确填充即可。首先,由于该行由像素组成,所以我们将在循环中逐像素输出它,但是如何计算要输出多少像素呢?例如,要从[0,0]到[3,0]画一条线,很明显,您需要4个像素([0,0],[1,0],[2,0],[3,0],) 。但是从[12,6]到[43,14],现在还不清楚线将是多长(要显示多少像素)以及它们将具有什么坐标。为此,请回忆一些几何图形。因此,我们有一条线从x1,y1开始,到x2,y2结束。


让我们从起点和终点画一条虚线,以便得到一个三角形(上图)。我们将看到在绘制线的交点处已形成90度角。如果三角形具有这样的角度,则将三角形称为矩形,将其两边之间的角度为90度的边称为边。第三条实线(我们试图绘制)称为三角形中的斜边。使用这两个引入的分支(图中的c1和c2),我们可以使用勾股定理来计算斜边的长度。让我们来看看如何做。斜边长度(或线长)的公式如下: 

=12+22


从三角形也可以看出如何获得双腿。现在,使用上面的公式,我们找到斜边,这将是长线(像素数):

 drawLine(x1, y1, x2, y2, r, g, b) {
         const c1 = y2 - y1
         const c2 = x2 - x1

         const length = Math.sqrt(c1 * c1 + c2 * c2)

我们已经知道画一条线要画多少像素。但是我们还不知道像素如何移动。也就是说,我们需要从x1,y1到x2,y2画一条线,我们知道该线的长度将是例如20个像素。我们可以在x1,y1中绘制第一个像素,在x2,y2中绘制最后一个像素,但是如何找到中间像素的坐标呢?为此,我们需要了解如何相对于x1,y1移动每个下一个像素,以获得所需的行。我将再举一个例子,以更好地了解我们正在谈论的位移类型。我们有点[0,0]和[0,3],我们需要在它们上画一条线。从示例中可以清楚地看到[0,0]之后的下一个点将是[0,1],然后是[0,2],最后是[0,3]。也就是说,每个点的X都没有偏移,或者可以说它偏移了0个像素,Y偏移了1个像素,这就是偏移量,它可以写为[0,1]。另一个例子:我们有一个点[0,0]和一个点[3,6],让我们尝试计算一下它们如何移动,第一个是[0,0],然后是[0.5,1],然后是[1,2]然后[1.5,3]依此类推,直到[3,6],在此示例中,偏移量将为[0.5,1]。如何计算呢? 

您可以使用以下公式:

   = 2 /  
  Y = 1 /   

在程序代码中,我们将有:

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

所有数据都已经存在:线的长度,像素沿X和Y的偏移量。我们从循环开始进行绘制:

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

作为Pixel函数的X坐标,我们传递X线的起点+偏移量X * i,从而获得第i个像素的坐标,我们还计算Y坐标。Math.trunc是JS中的一种方法,允许您舍弃数字的小数部分。整个方法代码如下所示:

drawLine(x1, y1, x2, y2, r, g, b) {
    const c1 = y2 - y1
    const c2 = x2 - x1

    const length = Math.sqrt(c1 * c1 + c2 * 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,
        )
    }
}

第一部分已经结束,这是理解3D世界的漫长而激动人心的道路。还没有三维,但是我们执行了绘制的准备操作:我们实现了绘制像素,线条,清除窗口的功能,并学会了一些术语。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

    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, g, b) {
    const c1 = y2 - y1
    const c2 = x2 - x1

    const length = Math.sqrt(c1 * c1 + c2 * 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
    }
  }
}

下一步是什么?


在下一篇文章中,我们将研究象素和线条的输出这样的简单操作如何变成有趣的3D对象。我们将熟悉矩阵及其操作,在窗口中显示三维对象,甚至添加动画。

All Articles