c++光栅化软渲染器(二)框架搭建篇

c++光栅化软渲染器(二)框架搭建篇_c++本篇我们来设置Qt的默认画布,为之后的通信工作做准备;同时搭建渲染器的骨架部分——渲染管线。

  上一节我们把向量和矩阵的类写好了,接下来我们进入到实战环节——搭建框架。因为光栅化拥有一套非常规范的渲染管线,我们的目的就是要还原它最重要的部分。当然在此之前,我们也要把画布先配置好。

 

画布配置

  之所以选择Qt来制作软渲染器,是因为Qt能创建带窗体的工程,其中窗体上面拥有一张可以绘制像素的画布(canvas)。但是相应的,其工程架构也较为复杂,因此我们首先要剖析一下Qt的工程结构:

c++光栅化软渲染器(二)框架搭建篇_c++_02

  

  刚创建工程的时候,项目文件夹里应该只有画框的这4个文件。其中main.cpp是运行工程的主体文件,工程在start running时会直接跑main.cpp中的main()函数;mainwindow是一个窗体类,里面包括了打开窗体以后的所有逻辑;由于窗体需要展示一个可视化界面,因此还外置了一个mainwindow.ui作为界面的配置文件(和传统前端差不多,这种前端也有html语言和极其类似css的qss层叠样式表)。这里我们着重看一下mainwindow的类:

 1 #ifndef MAINWINDOW_H 2 #define MAINWINDOW_H 3   4 #include <qmainwindow> 5 QT_BEGIN_NAMESPACE 6 namespace Ui { class MainWindow; } 7 QT_END_NAMESPACE 8   9 class MainWindow : public QMainWindow10 {11     Q_OBJECT12  13 public:14     MainWindow(QWidget *parent = nullptr);15     ~MainWindow();16  17 private:18     Ui::MainWindow *ui;19 };20 #endif // MAINWINDOW_H

 

  首先要知道的是,我们的窗体类其实是继承了一个窗体模板:QMainWindow,我们是想在拥有窗体的基础上添加或修改一些内容;下面系统定义了一个ui指针,它指向的就是mainwindow.ui的内容。
  上面这两条信息知不知道都无所谓,还是来讲讲绘制操作相关的内容吧。首先,Qt自带一个update()函数,它是用来刷新或更新帧的,每当update()被调用后,都会自动调用paintevent(QPaintEvent *event)函数,而这个函数就是用来重绘画布的。现在我们要定义自己的绘制方法,只需要重写一下paintevent函数就可以了。
  查看paintevent函数的源码后会发现它是一个虚函数。回忆第一节我们讲的内容,对于基类的虚函数,我们要在派生类中重写它:

void paintEvent(QPaintEvent *) override;

  接下来我们需要在.h中声明一个QImage类型的指针变量canvas作为绘制对象,然后在.cpp中重写paintEvent,具体思路就是先创建一个笔刷painter,然后用painter将canvas绘制到窗体中:

1 void MainWindow::paintEvent(QPaintEvent *event)2 {3     if(canvas)4     {5         QPainter painter(this);6         painter.drawImage(0,0,*canvas);    //表示在窗体的(0,0)坐标开始绘制7     }8     QWidget::paintEvent(event);9 }

  画布就配置好了。不过大家也意识到canvas从头到尾就是个野指针,所以现在跑工程肯定是会崩的。要想让Qt的painter成功将canvas贴到窗体上,我们就必须向canvas里写入内容。通过查看api接口,发现QImage的构造函数是:

QImage(uchar *data, int width, int height, Format format, QImageCleanupFunction cleanupFunction = Q_NULLPTR, void *cleanupInfo = Q_NULLPTR);

  其中只有前4项是我们需要关注的,分别是颜色数据指针、画布宽度、画布高度、颜色类型。画布宽度和画布高度直接获取窗体的宽高就可以了,颜色类型我们默认都使用QImage::Format_RGBA8888(真彩24色位图),这个颜色数据指针怎么理解呢,它是一个unsigned char类型的一维数组指针,这个一维数组存储了整张图中每一个像素的rgba通道信息。要注意的是,r、g、b是分着存的,比如[0]是坐标(0,0)像素的r通道值,[1]是坐标(0,0)像素的g通道值,[4]是坐标(0,1)像素的r通道值,以此类推。因此我们需要在mainwindow.h里再声明一个unsigned char类型的指针*data,canvas由以下语句来完成赋值:

canvas = new QImage(data, width(), height(), QImage::Format_RGBA8888);

  但是我们现在还不知道谁来提供颜色数据,只有接下来引入并完成“帧缓冲”这个步骤,我们才能解答这个问题。

 

帧缓冲

  图形学中,帧缓冲是一个很重要的概念。我们在将像素写入图样时,不可能每更新一个像素就刷新一次屏幕,最合理的方法是当画布中所有像素都被写入以后再刷新屏幕。如果想要这样,我们就必须创建一个用于存储所有像素点的颜色信息的缓冲区,这里我们把它称作帧缓冲(framebuffer)。现在我们需要创建这个类:

 1 #ifndef FRAMEBUFFER_H 2 #define FRAMEBUFFER_H 3 #include "vector4d.h" 4   5 class FrameBuffer 6 { 7 private: 8     int width,height; 9     unsigned char mp[8294405];10 public:11     FrameBuffer(int w,int h):width(w),height(h){}12     ~FrameBuffer(){}13     void Fill(Vector4D vec);14     void Cover(int x,int y,Vector4D vec);15     unsigned char *getColorBuffer();16 };17  18 #endif // FRAMEBUFFER_H

 

  帧缓冲类首先包括窗体的宽高,不多赘述;还要包括上文提到的颜色数据数组mp,它对应的就是mainwindow中更新canvas用的像素数据data。由于目前电脑屏幕常用最大分辨率为1920*1080,每个像素需要有4个通道信息,因此数据数组的大小至少为1920*1080*4=8294400。
  接下来看一下成员函数,构造函数和析构函数没什么内容直接跳过;这里先定义了一个Fill()函数,作用是对画布中的所有像素进行初始化,传入参数就是初始化颜色;Cover()就是用来更新(x,y)坐标颜色的函数。然后又由于mp是private类型的,要想获取mp地址,需要再写一个getColorBuffer函数来获取像素数据的地址(或指针)。
.cpp文件:

 1 #include "framebuffer.h" 2   3 void FrameBuffer::Fill(Vector4D vec){ 4     unsigned char cl[4]; 5     cl[0]=static_cast<unsigned char>(vec.x*255); 6     cl[1]=static_cast<unsigned char>(vec.y*255); 7     cl[2]=static_cast<unsigned char>(vec.z*255); 8     cl[3]=static_cast<unsigned char>(vec.w*255); 9     for(int i=0;i<height;i++){10         for(int j=0;j<width;j++){11             for(int k=0;k<4;k++){12                 mp[(i*width+j)*4+k]=cl[k];13             }14         }15     }16 }17 void FrameBuffer::Cover(int x,int y,Vector4D vec){18     unsigned char cl[4];19     cl[0]=static_cast<unsigned char>(vec.x*255);20     cl[1]=static_cast<unsigned char>(vec.y*255);21     cl[2]=static_cast<unsigned char>(vec.z*255);22     cl[3]=static_cast<unsigned char>(vec.w*255);23     for(int k=0;k<4;k++){24         mp[(y*width+x)*4+k]=cl[k];25     }26 }27 unsigned char* FrameBuffer::getColorBuffer(){28     return mp;29 }

  要注意的是,传入的vec向量的rgb值范围是[0,1],表示的是一个比率,而最后写入到数据里需要映射到[0,255],这是因为我们提前设置了颜色数据类型为RGBA_8888。至此,我们帧缓冲的部分就介绍完毕了。
  接下来,我们就可以回答上面的遗留问题了:颜色数据的来源就是FrameBuffer,我们接下来各种对像素的计算和赋值,最后都要写入到FrameBuffer做缓冲。当画布已经被装填满了时,我们再对canvas进行填充。这一步具体的实现方法我会放在后几章讲。
  最后放一下mainwindow类的代码:
.h:

 1 #ifndef MAINWINDOW_H 2 #define MAINWINDOW_H 3   4 #include <QMainWindow> 5   6 QT_BEGIN_NAMESPACE 7 namespace Ui { class MainWindow; } 8 QT_END_NAMESPACE 9  10 class MainWindow : public QMainWindow11 {12     Q_OBJECT13  14 public:15     MainWindow(QWidget *parent = nullptr);16     ~MainWindow();17  18 private:19     void paintEvent(QPaintEvent *) override;20  21 private:22     Ui::MainWindow *ui;23     QImage *canvas;24 };25 #endif // MAINWINDOW_H

 

.cpp:

 1 #include "mainwindow.h" 2 #include "ui_mainwindow.h" 3 #include "QPainter" 4   5 void MainWindow::paintEvent(QPaintEvent *event) 6 { 7     if(canvas) 8     { 9         QPainter painter(this);10         painter.drawImage(0,0,*canvas);11     }12     QWidget::paintEvent(event);13 }

 

  接下来我会讲述图形学的一大核心部分——渲染管线篇。这一部分非常重要,它是图形学中渲染部分的骨架,支撑着所有渲染相关的操作实现。渲染管线相关的知识我在另一篇博客中详述过,这里就不再复述了。要想摸透渲染器的运行原理,理解渲染管线是第一步。

    考虑到软渲染器的效率及工程量,我们肯定不会去复现unity内置的复杂渲染管线,这里只提取至关重要的两大步骤——着色器、光栅化。还记得管线最开始处理的对象是什么吗?没错,是顶点。我们至今都没有定义顶点,因此第一步,我们需要创建一个polygon(多边形)类,来记录顶点类和网格体类。

 

几何单元

  首先创建最基础的几何单位——顶点。
  注意,顶点可不是记录一个位置就完事了,有unity-shader基础的同学可以想一下在hlsl或cg语言中,我们会制作一个a2v结构体来输入顶点参数,其中包括了4、5个顶点属性(没有shader基础的同学也不用慌,只要知道一个顶点包括很多属性就ok了),其中包括位置、颜色、纹理、法线等,因此我们可以作如下定义:

 1 class Vertex 2 { 3 public: 4     Vector4D position; 5     Vector4D color; 6     Vector2D texcoord; 7     Vector3D normal; 8   9     Vertex(){}10     ~Vertex(){}11     Vertex(Vector4D pos,Vector4D col,Vector2D tex,Vector3D nor):12         position(pos),color(col),texcoord(tex),normal(nor){}13     Vertex(const Vertex &ver):14         position(ver.position),15         color(ver.color),16         texcoord(ver.texcoord),17         normal(ver.normal){}18 };

 

  顶点的变换一般由着色器来操控,因此Vertex类里不需要写除了构造函数以外的任何方法。
  创建完顶点之后,我们回想一下渲染管线的流程:顶点着色器会对顶点坐标进行变换,对法线进行变换,涉及到光照系统时还会改变顶点的基础颜色……因此当顶点被顶点着色器加工后,它将会成为一个新的顶点类型,交付给后面的片元着色器。为了形象描述这个vertex to fragment的过程,我们直接把这个加工后的顶点类型起名为V2F(vertex to fragment的谐音)。

 1 class V2F 2 { 3 public: 4     Vector4D posM2W; 5     Vector4D posV2P; 6     Vector2D texcoord; 7     Vector3D normal; 8     Vector4D color; 9     double oneDivZ;10  11     V2F(){}12     V2F(Vector4D pMW, Vector4D pVP, Vector2D tex,Vector3D nor, Vector4D col, double oZ):13         posM2W(pMW),posV2P(pVP),texcoord(tex),normal(nor),color(col),oneDivZ(oZ) {}14     V2F(const V2F& ver):15         posM2W(ver.posM2W),16         posV2P(ver.posV2P),17         texcoord(ver.texcoord),18         normal(ver.normal),19         color(ver.color),20         oneDivZ(ver.oneDivZ){}21 };

 

  posM2W指的是Model to World,表示的是从模型空间变换来的世界坐标,之所以要声明世界空间坐标是因为在顶点着色器中,我们很多属性需要在世界空间中进行变换,使用世界坐标的频率会比较高;posV2P指的是View to Projection,表示的是从观察空间变换来的投影坐标,在2D渲染器中我们不涉及投影的概念,因此posV2P在这里表示的就是点的屏幕坐标。(写作posV2P纯粹是为了之后做3D更方便)
  这里还涉及到了一个oneDivZ,表示的是深度测试的指标,2D渲染器照样用不上。(这部分是从其他大佬的博客中抄过来的)


  空有顶点肯定是不够的,我们还要对模型整体进行分析。我们知道,无论是什么模型,它的基本几何单元都是三角形,即任何一个网格体模型mesh都是由很多三角形组成的。那么,我们既要描述模型包含哪些顶点,又要描述哪些顶点构成了哪个三角形。这一步,我们可以设置一种网格索引顺序来描述它的三角形构成。这部分是opengl中涉及到的一个基础,我直接上图:

c++光栅化软渲染器(二)框架搭建篇_c++_03

 

 

  解释一下,其实就是规定了一种三角形顶点的遍历顺序,在这种顺序下,相邻的三个顶点表示一个三角形。如上图,索引顺序其实就是[0,4,1,5,2,6...],所以041构成三角形,紧邻着415构成三角形,152构成三角形……当然也有特例,比如3、7、11三者构成的是一条直线,最后不会当成三角形进行渲染。
  那么mesh的定义方法就很显而易见了:

 1 class Mesh 2 { 3 public: 4     std::vector<vertex> vertices; 5     std::vector<unsigned int=""> index; 6   7     Mesh(){} 8     ~Mesh(){} 9  10     Mesh(const Mesh& msh):vertices(msh.vertices),index(msh.index){}11     Mesh& operator=(const Mesh& msh);12     void setVertices(Vertex* v, int count);13     void setIndex(int* i, int count);14  15     void triangle(Vector3D &v1,Vector3D &v2,Vector3D &v3);16 };

 

  vertices就是模型的顶点动态数组,下标就是对应顶点的编号;而index则存储的是索引顺序。这里我们一般不在构造函数中进行初始化,而是通过setXXX来动态初始化mesh的参数。最后哪个triangle是定义一个最简单的网格体——简单三角形,对应的方法如下:

 1 #include "polygon.h" 2   3 Mesh& Mesh::operator=(const Mesh& msh) 4 { 5     vertices=msh.vertices; 6     index=msh.index; 7     return *this; 8 } 9  10 void Mesh::setVertices(Vertex* v, int count)11 {12     vertices.resize(static_cast<unsigned long="">(count));13     new(&vertices[0])std::vector<vertex>(v,v+count);14 }15  16 void Mesh::setIndex(int* i, int count)17 {18     index.resize(static_cast<unsigned long="">(count));19     new(&index)std::vector<unsigned int="">(i,i+count);20 }21  22 void Mesh::triangle(Vector3D &v1, Vector3D &v2, Vector3D &v3)23 {24     vertices.resize(3);25     index.resize(3);26     vertices[0].position=v1;27     vertices[0].normal=Vector3D(0.f,0.f,1.f);28     vertices[0].color=Vector4D(1.f,0.f,0.f,1.f);29     vertices[0].texcoord=Vector2D(0.f,0.f);30     vertices[1].position=v2;31     vertices[1].normal=Vector3D(0.f,0.f,1.f);32     vertices[1].color=Vector4D(0.f,1.f,0.f,1.f);33     vertices[1].texcoord=Vector2D(1.f,0.f);34     vertices[2].position=v3;35     vertices[2].normal=Vector3D(0.f,0.f,1.f);36     vertices[2].color=Vector4D(0.f,0.f,1.f,1.f);37     vertices[2].texcoord=Vector2D(0.5f,1.f);38     index[0]=0;39     index[1]=1;40     index[2]=2;41 }

 

  构造三角形这部分想多提一嘴,我们知道在渲染管线中,会先后进行三角形设置、三角形遍历、片元着色器,这个片元着色器在填充像素的时候会对三个顶点做插值,只有三个顶点的参数不同,才能制作出漂亮的渐变片元。这里亦然,为了体现后期的插值效果,我们尽可能地将三个顶点的color和texcoord赋予不同的数值。

  渲染管线一共有三要素:被加工体、加工体和加工操作,到现在被加工体——几何单元已经制作完成了,接下来我们开始制作加工体——着色器。

 

着色器

  只要你是个CGer,着色器这个玩意应该就像亲爹一样,又亲切又得供起来(x)。着色器就是管线中的工人,对于拿到的图元进行各种各样的计算和变换。每个管线所包含的着色器都不太一样,除了必须包括的顶点着色器、片元着色器以外,unity自带表面着色器,有些引擎还会包含曲面细分着色器、几何着色器,因此我们可以设置一个虚基类shader来作为所有着色器的模板,定义如下:

 1 #ifndef SHADER_H 2 #define SHADER_H 3 #include "polygon.h" 4   5 class Shader 6 { 7 public: 8     Shader(){} 9     virtual ~Shader(){}10     virtual V2F vertexShader(const Vertex &in)=0;11     virtual Vector4D fragmentShader(const V2F &in)=0;12 };13  14 #endif // SHADER_H

 

  由于VS和FS是最基础的着色器,所以虚基类里面只定义了这两个方法。注意别忘了在析构函数前面加virtual。
  虚shader定义完了以后,我们就该定义第一个实shader了,这里我们起名为BasicShader,即2D渲染器种使用的最简单的着色器。.h部分就不写了,继承一下Shader就可以了。
.cpp:

 1 #include "basicshader.h" 2   3 V2F BasicShader::vertexShader(const Vertex &in){ 4     V2F ret; 5     ret.posM2W=in.position; 6     ret.posV2P=in.position; 7     ret.color=in.color; 8     ret.normal=in.normal; 9     ret.oneDivZ=1.0;10     ret.texcoord = in.texcoord;11     return ret;12 }13 Vector4D BasicShader::fragmentShader(const V2F &in){14     Vector4D retColor;15     retColor = in.color;16     return retColor;17 }

 

  你可能想说,这着色器里面的内容也太简单了,合着啥变换都没有。没错,2D渲染器就这么简单,但出于对渲染管线的“尊重”,我们还是要把流程写全,这也是为后期做3D打基础。
  截至目前,加工者也制作完成了。顶点、网格体和着色器都已经准备好了后,我们就可以“开工”了!

 

渲染循环附件

  这里可能是整个2D渲染器系列最难分析的一步,毕竟核心往往都是最难的。作为串联一切渲染操作的管线,我们必须先明确它要拥有哪些数据和资源。
  首先,作为掌控全局的管线,必定承担起分析、计算、产生颜色,并将颜色送向帧缓冲的责任,那么它必须拥有一个缓冲区的指针,且必须知道当前画布的长与宽;此外,渲染管线需要拿到所有的模型数据以及三角形顶点索引,才能进行整体分析;最后渲染管线必须知道自己使用的是哪一套着色器。
  除此之外,我们还有一些设置可以进行选择,例如光照模式ShadingMode(在光照的博客中会详解)、光栅化模式RenderMode(对三角形进行描线还是填充)。

  别着急开写,这里我想引入一个概念——双缓冲。
  它的描述是这样的:“在图形图像显示过程中,计算机从显示缓冲区取数据然后显示,很多图形的操作都很复杂需要大量的计算,很难访问一次显示缓冲区就能写入待显示的完整图形数据,通常需要多次访问显示缓冲区,每次访问时写入最新计算的图形数据。而这样造成的后果是一个需要复杂计算的图形,你看到的效果可能是一部分一部分地显示出来的,造成很大的闪烁不连贯。 而使用双缓冲,可以使你先将计算的中间结果存放在另一个缓冲区中,但全部的计算结束,该缓冲区已经存储了完整的图形之后,再将该缓冲区的图形数据一次性复制到显示缓冲区。”

  说白了,就是如果你只有一个缓冲区,那么在显示器刷新的时候很有可能把不完整的颜色缓冲传给渲染器进行渲染,导致画面撕裂;如果我们能开辟两个缓冲区,一个用于动态写入,另一个用来临时保存上一帧传输的颜色缓冲,那么在显示器刷新时,如果发现动态写入没有做完,就直接把暂存的上一帧的缓冲传给渲染器进行渲染,避免了闪烁、撕裂现象。这里我们就定义一个front,一个back。动态写入时我们往back里写,写完以后便换到front里暂存、传输,以此循环下去。

.h:

 1 #ifndef PIPELINE_H 2 #define PIPELINE_H 3 #include "shader.h" 4 #include "framebuffer.h" 5 #include "matrix.h" 6   7 class Pipeline 8 { 9 private:10     int width, height;11     Shader *m_shader;12     FrameBuffer *m_frontBuffer;13     FrameBuffer *m_backBuffer;14     std::vector<vertex> m_vertices;15     std::vector<unsigned int=""> m_indices;16 public:17     enum ShadingMode{Simple,Gouraud,Phong};18     enum RenderMode{Wire,Fill};19 public:20     Pipeline(int w,int h)21         :width(w),height(h)22         ,m_shader(nullptr),m_frontBuffer(nullptr)23         ,m_backBuffer(nullptr){}24     ~Pipeline();25  26     void initialize();27     void clearBuffer(const Vector4D &color, bool depth = false);28     void setVertexBuffer(const std::vector<vertex> &vertices){m_vertices = vertices;}29     void setIndexBuffer(const std::vector<unsigned int=""> &indices){m_indices = indices;}30     void setShaderMode(ShadingMode mode);31     void drawIndex(RenderMode mode);32     void swapBuffer();33     unsigned char *output(){return m_frontBuffer->getColorBuffer();}34 };35  36 #endif // PIPELINE_H

 

  代码比较好理解,initialize就是为渲染管线的shader和双缓冲区申请空间,clearBuffer就是清空缓冲区,后面就是一堆设置的过程;drawIndex是真正的渲染管线流程,是最重要的一步;swapBuffer就是交换front和back,output则是返还front缓冲区的指针,供给渲染器进行渲染。

.cpp:

 1 #include "basicshader.h" 2 #include "algorithm" 3 using namespace std; 4   5 Pipeline::~Pipeline() 6 { 7     if(m_shader)delete m_shader; 8     if(m_frontBuffer)delete m_frontBuffer; 9     if(m_backBuffer)delete m_backBuffer;10     m_shader=nullptr;11     m_frontBuffer=nullptr;12     m_backBuffer=nullptr;13 }14  15 void Pipeline::initialize()16 {17     if(m_frontBuffer!=nullptr)delete m_frontBuffer;18     if(m_backBuffer)delete m_backBuffer;19     if(m_shader)delete m_shader;20     m_frontBuffer=new FrameBuffer(width,height);21     m_backBuffer=new FrameBuffer(width,height);22     m_shader=new BasicShader();23 }24  25 void Pipeline::drawIndex(RenderMode mode)26 {27     if(m_indices.empty())return;28     for(unsigned int i=0;i<m_indices.size() 3="">vertexShader(vv1),v2=m_shader->vertexShader(vv2),v3=m_shader->vertexShader(vv3);29         m_backBuffer->Cover(static_cast<int>(v1.posV2P.x),static_cast<int>(v1.posV2P.y),v1.color);30         m_backBuffer->Cover(static_cast<int>(v2.posV2P.x),static_cast<int>(v2.posV2P.y),v2.color);31         m_backBuffer->Cover(static_cast<int>(v3.posV2P.x),static_cast<int>(v3.posV2P.y),v3.color);32         /*这部分是光栅化*/33     }34 }35  36 void Pipeline::clearBuffer(const Vector4D &color, bool depth)37 {38     (void)depth;39     m_backBuffer->Fill(color);40 }41  42 void Pipeline::setShaderMode(ShadingMode mode)43 {44     if(m_shader)delete m_shader;45     if(mode==Simple)46         m_shader=new BasicShader();47     /*else if(mode==Phong)48         ;*/49 }50  51 void Pipeline::swapBuffer()52 {53     FrameBuffer *tmp=m_frontBuffer;54     m_frontBuffer=m_backBuffer;55     m_backBuffer=tmp;56 }

 

  其他函数都比较好理解,重点关注一下drawIndex函数,首先遍历所有的三角形,将各个三角形的顶点取出来后,分别使用着色器去进行处理。在2D渲染器可能看不出来处理的结果,但是在3D渲染器中这一步是不可或缺的。处理完以后呢,我们事先把三个点的颜色写入到对应的缓冲区中(放心,你肉眼应该看不见),接下来就是进行光栅化(画线or填充)了,因为这部分涉及到几个光栅化算法,篇幅较长,因此我决定放在下一篇详述。最后提醒一点,一定要注意内存管理,初始化时不要忘记动态申请空间,删除时不要忘记回收空间,我在这里runtime error爆了好几次。

  至此,渲染管线的搭建就基本完成了,下一章我会讲述光栅化的核心部分——光栅化篇。