SoftRenderer&RenderPipeline(从迷你光栅化软渲染器的实现看渲染流水线)

本文详细介绍了软渲染的实现过程,包括渲染流水线的各个阶段:从直线和三角形的绘制,到ObjectToWorld、WorldToCamera和透视投影矩阵的构建。此外,还探讨了CVV裁剪、透视除法、屏幕坐标映射、ZBuffer算法,以及在软渲染中实现这些特性的方法。通过这个过程,作者展示了如何用C++模拟OpenGL或D3D渲染流程,最终实现了一个旋转立方体的软渲染器。尽管软渲染在实际应用中并不常见,但它是理解渲染原理的有效途径。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

简介

这是可能一篇没有什么实际作用的文章,因为没有任何shader效果实现,整篇文章到最后,我只实现了一个旋转的立方体(o(╯□╰)o,好弱),和游戏引擎渲染的万紫千红的3D世界显得有很大落差,仿佛一切都回到了最初的起点(不知道有没有人能猜出来左侧的是哪部游戏大作的截图(*^▽^*))。


不过实时渲染繁华的背后,还是这看似简单的光栅化。今天,本人打算学习一下基本的光栅化渲染的过程,看一下怎样把一个模型从一些顶点数据,变成最终显示在屏幕上的图像,也就是所谓的渲染流水线。

所谓SoftRenderer(软渲染),就是用纯代码逻辑模拟OpenGL或者D3D渲染流水线,当然,只是实现了冰山一角,软渲染没有什么实际运用意义,但是对学习渲染原理来说应该是一个比较有效的手段了。之前开了个SoftRenderer的小坑,只是实现了基本的3D变换,三角形图元绘制,仿射纹理映射,最近本想写一篇深度相关的blog的,奈何写了一半发现好多内容需要推导计算,于是乎索性先把软渲染的坑补完,正好这几天把透视校正纹理映射加上了,顺便修了几个bug,基本可以运行起来了(然而我只画了个正方体,帧率还惨不忍睹)。一些高级的特性,比如法线贴图,复杂光照,双线性采样,RT之类的就等之后再慢慢填坑啦(恩,这很有可能是个弃坑-_-)。

简单介绍一下,项目使用的是C++,类似D3D的3D模式,左手坐标系,NDC中Z是(0,1)区间,向量为行向量,左乘矩阵,没有第三方库,只用到了Windows GDI相关,工程VS2015。基本实现了模型->世界->齐次裁剪空间->裁剪->透视除法->视口映射->ZBuffer深度测试->透视校正纹理映射等功能。实现过程中参考了知乎大佬们的各种回答。本人才疏学浅,尤其是渲染这里,很可能只是“看起来是对的“,再加上哥们600多的近视,所以如果有错误,还望各位高手批评指正。

渲染流水线

《Real Time Renderering》中把渲染流水线的功能简要描述如下:给定一个虚拟相机,一些三维物体,灯光,着色方程,贴图等资源,最终生成一张二维图片的过程。所谓流水线Pipline,也就是把原本线性执行的工作,改为并行执行,这样可以极大地提高效率。流水线的瓶颈也就是流水线中最弱的(耗时最长的)。渲染流水线在《RTR》中定义为三个基本阶段,而每一个阶段里面,又分为一些具体的操作步骤,下面分别看一下。

Application(应用程序阶段):字面意思即可,就是相对于渲染来说,其他的内容基本都可以定义在这个阶段。比如游戏逻辑,物理等等。个人理解为在调用DrawCall之前的阶段,该阶段主要在CPU上进行。

Geometry(几何阶段):主要是顶点着色器(MVP变换),裁剪,屏幕映射。处理上一阶段传递进来的图元和位置等信息,计算变换位置,最终决定物体在屏幕上的哪个位置,过程中还需进行裁剪,计算一些需要传递给下一阶段的数据。下图是《RTR3》中定义的Gemmetry阶段:


Rasterizer(光栅化阶段):光栅化阶段是将上一阶段变换投影映射后屏幕空间的顶点(包括顶点包含的各种数据),转化为屏幕上像素的一个过程。在该阶段主要进行的是三角形数据的设置,数据插值,像素着色(包括纹理采样),Alpha测试,深度测试,模板测试,混合。


为了更好地显示,一般会采用双缓冲技术,即在backbuffer渲染,完成后swap到前台。

上面的流水线,可能并不是很全面,新版本的有可能有Geometry Shader,Early-Z,Tiled Based等等,而且这个东西每个厂商可能都不太一样,但是上面的应该算是比较经典的一个流水线,并且都是必不可少的阶段(曾经以为裁剪可以不用,偷一点懒,大不了性能差点,后来发现自己真是太天真)。

下面本人就来大致实现基本的渲染流水线,本文的顺序是绘制直线,三角形,MVP矩阵变换,裁剪,视口映射,深度测试,透视校正纹理采样,是以复杂度作为顺序,并非真正的渲染顺序。

绘制直线

首先来研究一下怎么绘制直线,软渲染中每一个阶段其实都有多种方法进行实现,只不过有些是比较公认的通用方案。我这里只是找了一个适合我的方案。

我们显示在屏幕上的图像,其实就是一幅二维图片,而图片其实是离散的,换句话说,就是一个二维数组,屏幕的坐标就是数组的索引。所以,实际上我们在光栅化阶段绘制的时候,直接使用int型数据即可。俗话说得好,两点确定一条直线,那么,这一个步骤的输入就是两个二维坐标,输出就是一条直线。

实现画线有几种算法,DDA(最易理解,直接算斜率,有除法,不利于硬件实现,慢),Bresenham算法(没有除法和浮点数,快),吴小林算法(带抗锯齿,比Bresenham慢)。这里,我采用了Bresenham算法进行绘制:

//斜率k = dy / dx
//以斜率小于1为例,x轴方向每单位都应该绘制一个像素,x累加即可,而y需要判断。
//误差项errorValue = errorValue + k,一旦k > 1,errorValue = errorValue - 1,保证0 < errorValue < 1
//errorValue > 0.5时,距离y + 1点较近,因而y++,否则y不变。
int dx = x1 - x0;
int dy = y1 - y0;
float errorValue = 0;
for (int x = x0, y = y0; x <= x1; x++)
{
	DrawPixel(x, y);
	errorValue += (float)dy / dx;
	if (errorValue > 0.5)
	{
		errorValue = errorValue - 1;
		y++;
	}
}

上面算法的主要思想是,按照直线的一个方向,以斜率小于1为例的话,在x轴方向每次步进一个像素,y判断离哪个像素近。通过y方向增加一个累计误差值,当误差值超过1了,说明y方向应该上移一个像素,否则仍然是距离当前y值近。不过上面的算法还是没有避免掉除法的问题,我们可以通过修改一下判断的条件,去掉除法和浮点数,并完善各种情况:

void ApcDevice::DrawLine(int x0, int y0, int x1, int y1)
{
	int dx = x1 - x0;
	int dy = y1 - y0;

	int stepx = 1;
	int stepy = 1;
	
	if (dx < 0)
	{
		stepx = -1;
		dx = -dx;
	}

	if (dy < 0)
	{
		stepy = -1;
		dy = -dy;
	}

	int dy2 = dy << 1;
	int dx2 = dx << 1;

	int x = x0;
	int y = y0;
	int errorValue;

	//改为整数计算,去掉除法
	if (dy < dx)
	{
		errorValue = dy2 - dx;
		for (int i = 0; i <= dx; i++)
		{
			DrawPixel(x, y);
			x += stepx;
			errorValue += dy2;
			if (errorValue >= 0)
			{
				errorValue -= dx2;
				y += stepy;
			}
		}
	}
	else
	{
		errorValue = dx2 - dy;
		for (int i = 0; i <= dy; i++)
		{
			DrawPixel(x, y);
			y += stepy;
			errorValue += dx2;
			if (errorValue >= 0)
			{
				errorValue -= dy2;
				x += stepx;
			}
		}
	}
}

那么,我们随机在屏幕上画两条线的效果如下,分辨率是600*450,仔细看锯齿还是挺严重的,也让我深刻地意识到了抗锯齿的重要性o(╯□╰)o:


绘制三角形

迈出了最难的第一步,接下来就会好很多了。点,线,面,三个点确定一个三角形,所以我们再加一个参数,绘制一个三角形看一下。但是此处并非直接给三个点,连在一起,而是要把三角形内部填充上颜色。最简单的填充就是扫描线填充,换句话说我们按照屏幕的y轴方向,从上向下,每一行进行绘制,直到把三角形全部填充上为止。首先要把三角形分一下类:


我们已知的是y的方向,那么就需要带入三角形边的直线方程求得x的坐标,然后在两个点之间画线。如果三角形是平顶的或者是平底的,那么我们只需要考虑两条边即可,但是如果是一般的三角形,我们就需要做一下拆分,把三角形划分成一个平顶三角形和一个平底三角形,以上图为例,三角形GHI,以H的y值带入GI直线方程求得J的x坐标,生成GHJ和HJI两个三角形,这两个三角形就可以按照平顶和平底的方式进行光栅化了。

以平底三角形ABC为例,假设A(x0,y0),B(x1,y1),C(x2,y2),AB直线上随机一点(x,y),那么直线方程如下:

(y - y1) / (x - x1) = (y0 - y1) / (x0 - x1),我们已知y(从y0到y1循环),则x值为: x = (y - y0) * (x0 - x1) /  (y0 - y1) + x1。AC边类似得到x值,那么循环中我们就可以根据y值得到每条扫描线左右的x值。平底和平顶三角形的绘制代码如下:

void ApcDevice::DrawBottomFlatTrangle(int x0, int y0, int x1, int y1, int x2, int y2)
{
	for (int y = y0; y <= y1; y++)
	{
		int xl = (y - y1) * (x0 - x1) / (y0 - y1) + x1;
		int xr = (y - y2) * (x0 - x2) / (y0 - y2) + x2;
		DrawLine(xl, y, xr, y);
	}
}

void ApcDevice::DrawTopFlatTrangle(int x0, int y0, int x1, int y1, int x2, int y2)
{
	for (int y = y0; y <= y2; y++)
	{
		int xl = (y - y0) * (x2 - x0) / (y2 - y0) + x0;
		int xr = (y - y1) * (x2 - x1) / (y2 - y1) + x1;
		DrawLine(xl, y, xr, y);
	}
}

知道了平顶和平底,我们只要根据拐点计算出拐点y轴对应另一边的x值,生成新的三角形即可。但是此处我们为了代码简单一些,先对传入的三个顶点进行一下排序:、

void ApcDevice::DrawTrangle(int x0, int y0, int x1, int y1, int x2, int y2)
{
	//按照y进行排序,使y0 < y1 < y2
	if (y1 < y0)
	{
		std::swap(x0, x1);
		std::swap(y0, y1);
	}
	if (y2 < y0)
	{
		std::swap(x0, x2);
		std::swap(y0, y2);
	}
	if (y2 < y1)
	{
		st
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值