简介
这是可能一篇没有什么实际作用的文章,因为没有任何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