【软光栅开发笔记】(一) 基础绘制

用SDL2窗口输出渲染结果

Posted Kongouuu's Blog on March 6, 2022

前言

为了巩固对渲染管线以及软光栅的思路,准备自己手写一个 CPU 的软光栅。功能要求不是很多,主要能熟悉一下渲染管线就可以了。不过在实际进去渲染管线前,要研究一下怎么去像 GPU 一样绘制画面,并且显示到我们的屏幕上。

这里绘制的方式主要参考的是 TinyRenderer,不过比起绘制到一个离线的图片中,我更希望像普通的图形 API 一样能够动态的显示在一个窗口内。为了达到这样的绘制效果,我选择了比较轻量级的 SDL2.0 这个图形库来进行操作。不过我并不会使用太多图形库内置的功能,而单纯是把它当作显示我计算结果的一个载体。

1 绘制

1.1 绘制直线 (Bresenham)

一条线的绘制是由线内无数个点去组成的。因此我们需要定义好怎么去绘制线内的所有的点。一般来说我们会选取使用其中一个轴来进行循环。 如果我们的线坡度较低,也就是说在 x轴 的总移动距离大于在 y轴 的总移动距离,那么我们的绘制点的操作就会从x轴 渐渐步进, y轴 的步进则要看坡度。

我们可以直接的通过计算线的两个点来得到一个坡度值 dy/dx, 实际的意义就是在我们循环中,每一次步进 x 时, y 的值应该改变多少。所以说我们可以在代码中在每次步进 x 的同时去步进 y,不过由于我们像素的格子是不允许浮点表达式的,所以我们需要把y 步进的结果整理成 int 来进行点的绘制。

为了后期可以更容易的抛弃掉浮点数表示,我们并不打算使用 floor() 这样的函数去把浮点数变整数,而是在每次我们的本地坡度 (y) 超过0.5 的时候让 y 步进一步,并且值减去1

简单点来看就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void DrawLine(int2 start, int2 end)
{
	if(end.x - start.x > end.y - start.y && end.y > start.y)
	{
		int y = start.y;
		float gradient = (end.y - start.y) / (end.x - start.x);
		float slope = 0;
		for(int x = start.x; x <= end.x; x++)
		{
			drawdot(x,y,color);
			slope += gradient;
			if(slope > 0.5)
			{
				y += 1;
				slope -=1;
			}
		}
	}
	...
	
}

1.2 填充三角形

填充三角形实际上就是把所有属于三角形的点用一个颜色去填充。比较传统的做法是我们从三角形高度最中间的那个顶点的高度当作一个中间线,把三角形切割成两个部分。这样的做法可以让我们对两个三角形分别的去进行一行一行的绘制处理。不过这样做效率是比较低的,一般来说还是应该使用重心坐标去计算:

重心坐标 (Barycentric Coordinates) 一般是把我们的点,以及三角形的三个顶点形容成下面的形式:

\(P = (1-u-v)A + uB+vC\\ 目标点P靠近三角形顶点A的权重:(1-u-v)\\ 目标点P靠近三角形顶点B的权重:u\\ 目标点P靠近三角形顶点C的权重:v\\\) 那我们也可以把它写成下面的形式:

\[P = A + u\vec{AB} + v\vec{AC}\\ u\vec{AB} + v\vec{AC} + \vec{PA} = 0\\ \implies\\ u\vec{AB}_x + v\vec{AC}_x + \vec{PA}_x = 0\\ u\vec{AB}_y + v\vec{AC}_y + \vec{PA}_y = 0\]

那么找到重心坐标参数的 u和v 也很简单了,这里我们可以当作参数 [u,v,1] 点乘 Pxy 两方向的向量都为 0, 也就是说垂直于两个向量。因此我们只需要把两个向量做叉乘就可以得到我们要的结果:

\[(u,v,1) = (\vec{AB}_x , \vec{AC}_x , \vec{PA}_x) \cross (\vec{AB}_y, \vec{AC}_y ,\vec{PA}_y)\\\]

所以我们可以通过单次叉乘来得到重心坐标的值,并且根据里面是否含有负号来判断一个点是否在三角形中间。

1.3 Z Buffer

通过使用重心坐标我们可以得到一个点在三角形内的插值,这同时也可以用来找到当前点在三角形面上的深度。我们只需要用一个额外的buffer去记录深度就可以进行对比来达到一个深度Pass 的操作。

2 实现

这里主要讲一下比起渲染到一个离线的图片,怎么构建一个可以动态渲染的渲染器。

2.1 框架

实际上我们的软光栅渲染其框架也是十分简单的,一样是依照经典的游戏循环就可以了,也就是说包含下面几个函数:

  1. Initialize
  2. Update
  3. Render

2.2 初始化

2.2.1 系统初始化

初始化这里我们首先要初始化一下 SDL 系统,以及它的窗口和渲染器:

1
2
3
4
5
6
void Init(int width, int height)
{
	SDL_Init(SDL_INIT_EVERYTHING) {
    pWindow = SDL_CreateWindow(NULL,SDL_WINDOWPOS_CENTERED,SDL_WINDOWPOS_CENTERED,width,height,0);
    pRenderer = SDL_CreateRenderer(pWindow, -1, 0);
}

SDL 本身的 Renderer 在我们这里的主要作用就是把我们离线绘制的图片拉到窗口内。某种意义上来说算是我们普通 API 使用的 BackBuffer。 使用它就能跑出一个能动的画面,不过唯一的遗憾是如果用了 SDL 这套库,大概是没办法自己实现前后缓冲的交换链。加上我把图片数据使用 SDL Renderer 放到窗口上这个行为的时间开销还是挺大的,不过毕竟不是实现的重点,也没什么关系。

2.2.2 数据初始化

数据初始化这里主要是用于加载我们要用到的顶点数据,或者贴图等。不过目前还不会深入处理这块

2.2 绘制

我们整个框架的绘制这里主要分为两个部分,一个是怎么储存我们的绘制结果,第二个是怎么把我们的结果放到屏幕上面。

2.2.1 Buffer

首先是储存,因为我们的光栅化它不一定是渲染到后缓冲的,所以不能直接让 SDL 跟着我们的指令去绘制点,而是要储存到一个缓冲中。所以这里我们要建立一个缓冲类,可以用于当作:渲染目标/纹理/深度缓冲

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class T>
class Buffer
{
public:
	void Init(int _width, int _height);
	const T& GetValue(int x, int y);
	void SetValueAll(T t );
	void SetValue(int x, int y, T t);
	int GetHeight();
	int GetWidth();
private:
	std::vector<std::vector<T>> mBuffer;
	int width = 0;
	int height = 0;
};

我这里为缓冲采用的是一个模板类的构造。因为我们会有颜色缓冲以及深度缓冲这样不同格式的缓冲,不过他们大致的功能都是一样的,如果要为这样的情况设置两个类其实是不太合适的。(颜色缓冲这里我这里定义了一个Pixel类包含rgba四个通道)

2.2.2 Render

在定义完一个缓冲类之后,我们就可以用上面形容好的绘制三角形的函数把数据写到一个颜色缓冲内部了。那么我们接下来要怎么把颜色缓冲给输出到屏幕上?

我这里使用的是一个自定义的缓冲来模拟渲染管线里面的资源,不过这样的数据类型和 SDL 的相形并没有很好。也就是说不能很简单的去把我们的颜色缓冲绘制出来,而是得逐像素复制到 SDL 的后缓冲中。虽然效率上并不是很理想,不过效果达到了就好:

2.3 数学库

为了实现三角形的绘制和很多其他效果,我们肯定是需要用到一个数学库的。不过今天这个比较小的项目我打算自己写一个简单的数学库看看。 数学库本身并不会太难去设计,在每次我需要一个功能的时候再去写对应的函数就可以了。也不需要支持过于复杂的情况。

2.4 运行效果

总结

这里主要是跟着 TinyRenderer 了解了一下最基础的绘制思路,以及搭建了一个非常基本的框架。

既然我能够绘制三角形,并且也能够让它显示在窗口上。那么我们就已经完成了一个非常简单的渲染器了。不过这里开始就能进一步的去构造一个更加灵活的渲染管线了,围绕着三角形绘制展开。

三角形上面提到的最主要的地方在于重心坐标,使用它我们就可以随意的在三个顶点的信息之间取插值,这将不限于位置。并且如果我们在填充三角形的时候不止是使用了颜色,而是考虑到一些全局变量的光照信息,那么就可以搭出一个更好看的场景。