前言

  • 本文初衷是为了配合入门图形学教程制作的同期系列编程教程,面向具有一定的编程语言学习基础的同学们,欢迎各位讨论交流!
  • 本文将在图形学第八节课后随课程实时更新,共分为四个板块——直线绘制,三角形绘制,三角形填充,光线着色计算,最后可以得到一个具备基本亮暗面阴影的模型。
  • 本文只是最基本的渲染器部分,后续会继续更新进阶版本内容。本文需要配合IDE使用,你可以访问我的库,其中的EasyRender项目有项目源码

1.1直线的数学定义

1.1.1 为什么又是数学

  • 可能很多同学看到数学又出现了会感到厌烦,我很能理解大家的感受,但实际情况是,我们不能避免这种情况的发生,因为数学是我们的工具,我们做渲染本身实际上就是用数学去表达世界,因此数学是我们必须要去学习和认识的,况且,我们所用到的数学并 不难只需要大家去分析理解就够了。

1.1.2 我们如何表示直线

  • 在高中我们就学过了一条直线基本的表示方法,那就是点斜式表示法,可以写成:
    • *P = P0 + t(P1 - P0)
  • 同样,我们也可以将其拆分为x轴向上的以及y轴向上的坐标,方便我们理解实际的实现
    • *x = x0 + t(x1 -x0)
    • *y = y0 + t(y1 - y0)

  • 非常好,我们现在只需要去解决t这个变量了,通过联立上面的等式,我们可以得到
    • *t = x - x0 / y - y0
  • 随后,我们将其带入方程,就能得到我们想要的点斜式雏形了。
    • *y = y0 + (x - x0)(y1 - y0)/x1 - x0
  • 这时,我们发现,其实 (y1 - y0)/x1 - x0 是一个常数,所以我将其设为a , 当然 y0也是一个常数,我们设其为b,因此我们得到了我们喜闻乐见的公式
    • y = ax + b
  • 现在,我们已经具备的基本的数学认知,可以进行实践了!

1.2 准备工作

  • 首先,请保证你正确地将以下资源放入你的项目目录中,它们会提供一切你所需的包括但不限于函数,变量等。
  • 1.geomertry.h
    • 这个头文件定义了基本的几何数据类型,如二维向量,三维向量等。
  • 2.tgaimage.h
    • 这个头文件包含我们设置颜色产生图片的函数声明,非常重要,是帮助我们创建画布的关键对象。
  • 3.tgaimage.cpp
    • 这个文件包含了上述tgaimage头文件的函数实现,会为我们设置对应的像素颜色。

1.2.1 什么是TGA格式

  • 它是一种光栅化图形格式
  • image.png
    TR 1.2.1TGA 的维基百科定义
  • 我们可以自由的用其最关键的set()函数来填充像素,它接受四个参数,分别是x,y坐标以及绘制对象的引用还有颜色。下面让我们创建你的第一个光栅化着色器。

1.2.2 创建项目

  • 你可以使用任何你喜欢的IDE,这里演示时我们将使用VS2022版本进行演示。首先,创建你的项目,这里默认各位都是没有问题的,我们直接快进到main函数所在的CPP文件。
  • 请包含以下基础的头文件
1
include"TGAImage.h"

  • 下面我们来完成main函数,我们需要明确我们光栅化的逻辑,我们对对象进行算法处理,最后告诉TGA我们要怎么绘制它,所以基本的main函数应该是这样的
1
2
3
4
5
int main()
{
TGAImage image(width, height, TGAImage::RGB);
image.write_tga_file("TRIANGLE.tga");
}
  • 在第一行我们定义了一个TGAImage类型的变量image,它接受三个值,用于设定这个变量的长宽以及色彩通道,这里我们需要创建全局固定变量width以及height用于设定图像的长宽高,这里我们设定为1920×1080。
  • 第二行我们用这个实例化对象调用了内置的write_tga_file()方法,这个方法接受一个字符串变量,是我们在文件夹中创建的tga格式的文件,设置完成后,我们就可以来到我们最关注的代码部分了。

1.3 直线算法实现

1.3.1 第一版直线代码

  • 如你所见,我们只需要根据我们第一次想到的逻辑去绘制这条直线就好了,它看起来应该是这样的:
1
2
3
4
5
6
7
8
void line(int x0 , int y0,int x1,int y1 , TGAImage &imag,TGAColor)
{
for(float t = 0.; t<1;t+=.01){
int x = x0 + (x1 - x0)*t;
int y = y0 + (y1 - x0)*t;
image.set(x,y,color);
}
}

  • 这些代码是什么意思呢?首先它接受六个参数,分别是这条直线的起始和结束的横纵坐标,以及需要写入的TGA图片对象,以及颜色信息。
  • 随后我们以0.01为步长,开始这个循环,也就是一共会完成十次渲染每一次逐渐逼近填充到目标位置,最后一次就会设置所有路径上的像素。

  • 最后使用image对象自身具备的set函数,设置像素,看起来是这样的:
  • image.png
    1.3.1 线段——第一次尝试

1.3.2 第一次尝试改进

  • 首先这段代码要遍历100次,速度非常慢,并且如果调整步长,会导致像素之间分离,让直线断开。接下来我们需要对其调整,我们不希望因为步长原因舍弃直线的精度同样也不希望因此导致绘制失败,所以我们要回归最基本的内容,那就是我们只负责绘制每一个点上的像素,而其解决方法就是,以x为准,绘制x1 - x0 次,我们就应该能够得到所需线段。
  • 于是我们有了以下的改进:
1
2
3
4
5
6
7
8
void line(int x0,int y0,int x1,int y1,TGAImage &image,TGAColor color)
{
for(int x =x0 ,x <= x1 ;x++)
{
float t = (x - x0) / (float)(x1 - x0);
int y = y0*(1.-t) + y1*t;
}
}

  • 现在我们不用担心步长变化给直线本身带来的错误了,但还有个问题,那就是如果我们绘制一条很陡的线段,也就是 x1 - x0 很小几乎接近于0 的线段时,t增加的速度就会变得非常快,因此还是会出现分离的状况:
  • image.png
    1.3.2 线段——第二次尝试
  • 其实原因就在于x变化太快了,我们只要选择变化慢一些的算法(这本质上是一种走样)也就是使用y作为变化量即可,用选择分支来判断是否要执行这样的操作。

1.3.3 第三次改进——分类绘制

  • 按照以上的思路,我们使用一下代码….
    if (dx > dy) {for (int x) else {for int y}}
  • 但是等一下,我们可以做的更好,实际上我们只需要写一套逻辑就够了,我们让对应的数据交换,而不是单独为其编写一套新逻辑,这样能节省我们的步骤同样是优化的一种方式。
  • 代码看起来是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void line( int x0, int y0, int x1 ,int y1 , TGAImage &image, TGAColor color)
{
bool steep = false;
if(std::abs(x0 - x1) < std::(y0 - y1))
{
std::swap(x0,y0);
std::swap(x1,y1);
steep = true;
}
if(x0 > x1)
{
std::swap(x0,x1);
std::swap(y0,y1);
}
for(int x =x0 ,x <= x1 ;x++)
{
float t = (x - x0) / (float)(x1 - x0);
int y = y0*(1.-t) + y1*t;
if(steep)
{
image.set(y,x,color);
}
else
{
image.set(x,y,color);
}
}
}

  • 我们在上述的过程中首先对y0 - y1值与x1 - x0 值进行了判断。并根据结果考虑是否要交换其值,本质上相当于对线段进行了一次以横坐标为轴向的翻转。
  • 但翻转过后我们也得考虑是否破坏了我们原本想要的图像,因此如果x0比x1小的话,我们只需要再次将其翻转即可,后续在循环中我们只需要依据是否交换了数据来填写不同的set函数参数即可完成任意线段的绘制了。
  • 但如果你现在运行这个程序,它绘制的依然没有达到我们想要的最高速度,这是因为在循环中存在着大量的乘除法运算,它的开销在一些低端平台上很大,因此考虑到这一点,我们需要去对其进行改变,这里我们用到一个误差思想。

1.3.4 第四次尝试——优化乘除法与浮点数

  • 我们可以设置一个标准斜率也就是我们的k值,其叫做debugger,这个标识用于判断我们的直线是否离开了选定的范围,随后,我们用error来进行标志,每一次设置颜色就将error加上一个debugger,这很容易理解,因为回到线段的最初始定义,y = kx + b ,每一次y增加的就是 一个k的距离,因此如果设置第二个点,偏差就应该超过这个范围了,因此y+1,随后偏移值再次减少,循环这个过程,代码修改部分是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int dx = x1-x0; 
int dy = y1-y0;
float derror = std::abs(dy/float(dx));
float error = 0;
int y = y0;
for (int x=x0; x<=x1; x++) {
if (steep) {
image.set(y, x, color);
} else {
image.set(x, y, color);
}
error += derror;
if (error>.5) {
y += (y1>y0?1:-1);
error -= 1.;
}
}

  • 在此基础上我们依然也可以继续修改,现在的代码看起来 已经很好了,但我们还能再继续修改,因为浮点数在CPU上的开销同样比较大,因此我们可以使用整数型代替浮点型,这就要求我们需要是的这个算法对于误差感知更精准,所以我们将误差感知的范围从原来的浮点型放大为以dx代表的整数型,并以此来进行逻辑判断,其余的形式一样,这样的思想类似于数学上的放缩思想。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int derror2 = std::abs(dy)*2; 
int error2 = 0;
int y = y0;
for (int x=x0; x<=x1; x++) {
if (steep) {
image.set(y, x, color);
} else {
image.set(x, y, color);
}
error2 += derror2;
if (error2 > dx) {
y += (y1>y0?1:-1);
error2 -= dx*2;
}

  • 后面两种方法只需要了解即可,我们需要关注的实际上是如何绘制线段本身,性能需求虽然也是考虑的内容,但在如今的计算平台上已经是可被忽略的改进了。至此我们第一部分线段的内容就已经完成了,下一节我们将用这个算法绘制一个苹果线框以及考虑三角形的绘制。

参考资料