引言
上一節我們把向量和矩陣的類寫好了,接下來我們進入到實戰環節——搭建框架。因為光柵化擁有一套非常規范的渲染管線,我們的目的就是要還原它最重要的部分。當然在此之前,我們也要把畫布先配置好。
畫布配置
之所以選擇Qt來制作軟渲染器,是因為Qt能創建帶窗體的工程,其中窗體上面擁有一張可以繪制像素的畫布(canvas)。但是相應的,其工程架構也較為復雜,因此我們首先要剖析一下Qt的工程結構:
剛創建工程的時候,項目文件夾里應該只有畫框的這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 QMainWindow 10 { 11 Q_OBJECT 12 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 QMainWindow 11 { 12 Q_OBJECT 13 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中涉及到的一個基礎,我直接上圖:
解釋一下,其實就是規定了一種三角形頂點的遍歷順序,在這種順序下,相鄰的三個頂點表示一個三角形。如上圖,索引順序其實就是[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爆了好幾次。
至此,渲染管線的搭建就基本完成了,接下來我們進入到光柵化的核心部分——光柵化篇。
光柵化
上一節我們完成了渲染管線的搭建。該渲染管線是一個高度簡化過的管線,只保留了傳入模型網格、簡單頂點着色、簡單片元着色、光柵化寫入雙緩沖這幾個步驟。由於上篇的篇幅較長,因此把光柵化的具體算法留在本篇來講。
我們一共要完成兩部分內容:分別是已知兩個點,繪制其中的直線(畫線操作)、已知三角形的三個點,填充三角形內部像素(填充操作)。
插值lerp
我們現有的頂點數量有限,但需要光柵化出來的像素數量卻非常多,其中有很多像素是通過頂點數據插值得出來的,因此我們需要再補充一個lerp函數,用來對V2F類型的頂點做插值,其中要涉及V2F中的所有屬性(包括各個空間的位置、顏色、法線、uv)
1 V2F Pipeline::lerp(const V2F &n1, const V2F &n2, float weight) 2 { 3 V2F result; 4 result.posV2P=n1.posV2P.lerp(n2.posV2P,weight); 5 result.posM2W=n1.posM2W.lerp(n2.posM2W,weight); 6 result.color=n1.color.lerp(n2.color,weight); 7 result.normal = n1.normal.lerp(n2.normal, weight); 8 result.texcoord = n1.texcoord.lerp(n2.texcoord, weight); 9 //result.oneDivZ=(1.0-weight)*n1.oneDivZ+weight*n2.oneDivZ; 10 result.textureID=n1.textureID; 11 return result; 12 }
bresenham算法
首先是畫線操作。直線一共有4種基礎狀態,分別是橫線、豎線和兩種45°角的斜線:
既然要畫線,我們就必須要得出直線的函數表達式。斜截式(y=kx+b)是我們最熟悉的直線公式,只要我們給出一個x,就能得到相應的y值。但是在圖2種,很顯然給出一個x,我們得到的是無數個y,導致這個問題出現的原因是沒有正確的選擇自變量。
上述圖中,圖1必須讓x作為自變量,y作為因變量;圖2必須讓y作為自變量,x作為因變量;圖3、圖4則隨意。不難總結出以下判斷方式:計算兩點之間的橫坐標差值△x和縱坐標差值△y,比較一下|△x|和|△y|之間的大小,若|△x|更大,則以x為自變量;否則以y作為自變量。
對於上述四張圖而言,只要寫出對應的y=kx+b或x=my+n即可精確的定位每個像素的位置,但是對於下述兩張圖而言會出現新的問題:
由於電腦顯示器的精度有限,我們不可能讓直線做到完全平滑,對於非45°角的斜線便會出現不同自變量對應相同因變量的現象,稱作“直線走樣”。當然為了解決這個問題會有相應的反走樣和抗鋸齒技術,但不是本節重點,先暫且不提。
首先我們看圖1,很顯然|△x|>|△y|,選擇x為自變量,我們來分析其中的細節部分:
當我們掃描x到了$x_{i+1}$點時,通過解析式算出的y值並沒有落在整數點上(如圖的B點),由於屏幕分辨率的限制,我們只能選擇在C點或在D點上進行繪制。我們取C和D連線的中點A作為分界,若B的y值大於A則繪制D點,否則繪制C點。
具體算法如下:首先我們規定x的步長為sx=1,y的步長為sy=k(即斜率)。為了方便計算,我們先把y值舍去小數部分取整,這樣默認落在了C點上,然后直接取(B的y值)-(A的y值)作為$x_{i+1}$點的偏移量,寫作ε($x_{i+1}$)。ε($x_{i+1}$)>0則將y值增加一個像素單位長度,否則不變。公式即為ε($x_{i+1}$)=BC-AC=($y_{i+1}$-$y_{ir}$)-0.5。
為了簡化運算,我們可以迭代出ε($x_{i+k}$)的值。
ε($x_{i+2}$)=($y_{i+2}$-$x_{(i+1)r}$)-0.5
=$y_{i+1}$+sy-$y_{(i+1)r}$-0.5
=ε($x_{i+1}$)+$y_{ir}$-$y_{(i+1)r}$
以此類推下去:
ε($x_{i+3}$)=ε($x_{i+2}$)+sy+$y_{(i+1)r}$-$y_{(i+2)r}$
...
ε($x_{i+k}$)=ε($x_{i+k-1}$)+sy+$y_{(i+k-2)r}$-$y_{(i+k-1)r}$
個人認為$y_{(i+k-2)r}$-$y_{(i+k-1)r}$這個式子擺在這里很礙眼,不妨直接分類討論:
若ε($x_{i+k-1}$)<0,則$y_{(i+k-2)r}$-$y_{(i+k-1)r}$=0:ε($x_{i+k}$)=ε($x_{i+k-1}$)+sy
若ε($x_{i+k-1}$)≥0,則$y_{(i+k-2)r}$-$y_{(i+k-1)r}$=-1:ε($x_{i+k}$)=ε($x_{i+k-1}$)+sy-1
現在遞推公式求出來了,基本就可以編程了。但是,事實上我們可以避免浮點運算,只需要進一步優化一下公式。對於起點s而言,有:
①ε($x_{s+1}$)=sy-0.5
②ε($x_{i+k}$)=ε($x_{i+k-1}$)+sy。
③ε($x_{i+k}$)=ε($x_{i+k-1}$)+sy-1。
其中sy和0.5都是討厭的浮點數,因此我們可以在左右都乘一個2*△x(兩個目標點的橫坐標差),這樣公式也就變成了:
①2*△x*ε($x_{s+1}$)=2*△y-△x
②2*△x*ε($x_{i+k}$)=2*△x*ε($x_{i+k-1}$)+2*△y
③2*△x*ε($x_{i+k}$)=2*△x*ε($x_{i+k-1}$)+2*△y-2*△x
將2*△x*ε統一寫成d,則遞推式變為:
②d=d+2*△y
③d=d+2*△y-2*△x
注意d=2*△x*ε,我們默認△x>0,因此d和ε具有相同的符號。之前我們說當ε≥0時則y值增加,現在我們也可以說d≥0則y值增加。
整理一下,也就是說我們只需要維護一個d值,初始情況下d=2*△y-△x,接下來每次迭代都需要判斷一下d的大小,d<0的話則d=d+2*△y;d>0的話則d=d+2*△y-2*△x並且y要增加1個單位,注意每次迭代都要給x++。這部分代碼非常簡潔,所以我也就不放偽代碼了。要注意的是上述我們默認了△x>0,其實存在這樣一種情況:x1<x2 y1>y2,此時我們如果取點2為起點的話,就會導致△x<0,這種時候我們就強行把△x取相反數,同時把sx取成-1即可。
最后不要忘了在寫入緩沖區的時候,顏色等信息都要取兩點之間的插值。因此在傳入函數時,我們不能僅僅傳入兩個坐標,而是要把經過着色器運算的這兩個頂點整體傳入:
1 void Pipeline::bresenham(const V2F &from, const V2F &to) 2 { 3 int dx=to.x-from.x,dy=to.y-from.y; 4 int sx=1,sy=1; 5 int nowX=from.x,nowY=from.y; 6 if(dx<0){ 7 sx=-1; 8 dx=-dx; 9 } 10 if(dy<0){ 11 sy=-1; 12 dy=-dy; 13 } 14 Vector4D tmp; 15 if(dy<=dx) 16 { 17 int d=2*dy-dx; 18 for(int i=0;i<=dx;++i) 19 { 20 tmp=lerp(from,to,static_cast<double>(i)/dx); 21 m_backBuffer->Cover(nowX,nowY,m_shader->fragmentShader(tmp)); 22 nowX += sx; 23 if(d<=0)d+=2*dy; 24 else{ 25 nowY+=sy; 26 d+=2*dy-2*dx; 27 } 28 } 29 } 30 else 31 { 32 int d=2*dx-dy; 33 for(int i=0;i<=dy;++i) 34 { 35 tmp=lerp(from,to,static_cast<double>(i)/dy); 36 m_backBuffer->Cover(nowX,nowY,m_shader->fragmentShader(tmp)); 37 nowY += sy; 38 if(d<0)d+=2*dx; 39 else{ 40 nowX+=sx; 41 d-=2*dy-2*dx; 42 } 43 } 44 } 45 }
柵欄填充算法
上面我們完成了直線繪制,接下來我們進行三角形內部填充。接下來要說的這個算法沒找到它的學名是什么,所以直接按照自己的理解起了“柵欄填充”這么個名字。算法方法很簡單,不需要像上面那個算法那樣推導迭代,直接說思路:
一張圖直接說明算法核心。對於任意三角形,我們從y值處於中間的那個點做一個橫線,將三角形一分為二,分為平底和平頂三角形。對於上面的平底三角形,我們只需要找到最上面的點,以它為起點向下迭代,每一行都畫一條橫線即可。
可能大家會有一個問題,理論上來說這個算法應該比畫線要更復雜吧,畢竟三角形邊框也是線做成的,為什么我們反而沒有那么大計算量呢?事實上我們用這種朴素算法畫出來的邊框一定是嚴重走樣的,比上述的Bresenhan算法的效果要差很多,但是因為填充的面積非常大,所以讓觀察者會忽略邊框的鋸齒走樣。(事實上你在這個算法上做做改進,用Bresenhan算法先把邊框描出來再根據邊框位置進行填充也完全ok,但開銷不太值得)
所以思路就是,首先我們將三角形進行分割為兩個標准三角形,對於其中每個三角形,找出填充的y值范圍;對於每一行而言,我們通過插值來獲取橫線的左端點和右端點信息(不僅是坐標,還要有顏色、法線等信息),接下來再畫橫線。畫橫線的過程別忘了也要對每個點做插值。
void Pipeline::scanLinePerRow(const V2F &left, const V2F &right) { V2F current; int length = right.posV2P.x - left.posV2P.x + 1; for(int i = 0;i <= length;++i) { // linear interpolation double weight = static_cast<double>(i)/length; current = lerp(left, right, weight); current.posV2P.x = left.posV2P.x + i; current.posV2P.y = left.posV2P.y; // fragment shader m_backBuffer->Cover(current.posV2P.x, current.posV2P.y, m_shader->fragmentShader(current)); } } void Pipeline::rasterTopTriangle(V2F &v1, V2F &v2, V2F &v3) { V2F left = v2; V2F right = v3; V2F dest = v1; V2F tmp, newleft, newright; if(left.posV2P.x > right.posV2P.x) { tmp = left; left = right; right = tmp; } int dy = left.posV2P.y - dest.posV2P.y + 1; for(int i = 0;i < dy;++i) { double weight = 0; if(dy != 0) weight = static_cast<double>(i)/dy; newleft = lerp(left, dest, weight); newright = lerp(right, dest, weight); newleft.posV2P.y = newright.posV2P.y = left.posV2P.y - i; scanLinePerRow(newleft, newright); } } void Pipeline::rasterBottomTriangle(V2F &v1, V2F &v2, V2F &v3) { V2F left = v1; V2F right = v2; V2F dest = v3; V2F tmp, newleft, newright; if(left.posV2P.x > right.posV2P.x) { tmp = left; left = right; right = tmp; } int dy = dest.posV2P.y - left.posV2P.y + 1; for(int i = 0;i < dy;++i) { double weight = 0; if(dy != 0) weight = static_cast<double>(i)/dy; newleft = lerp(left, dest, weight); newright = lerp(right, dest, weight); newleft.posV2P.y = newright.posV2P.y = left.posV2P.y + i; scanLinePerRow(newleft, newright); } } void Pipeline::edgeWalkingFillRasterization(const V2F &v1, const V2F &v2, const V2F &v3) { V2F tmp; V2F target[3] = {v1, v2,v3}; if(target[0].posV2P.y > target[1].posV2P.y) { tmp = target[0]; target[0] = target[1]; target[1] = tmp; } if(target[0].posV2P.y > target[2].posV2P.y) { tmp = target[0]; target[0] = target[2]; target[2] = tmp; } if(target[1].posV2P.y > target[2].posV2P.y) { tmp = target[1]; target[1] = target[2]; target[2] = tmp; } if(target[0].posV2P.y==target[1].posV2P.y){ rasterBottomTriangle(target[0],target[1],target[2]); } else if(target[1].posV2P.y==target[2].posV2P.y){ rasterTopTriangle(target[0], target[1], target[2]); } else { double weight = static_cast<double>(target[1].posV2P.y-target[0].posV2P.y)/(target[2].posV2P.y-target[0].posV2P.y); V2F newPoint = lerp(target[0],target[2],weight); newPoint.posV2P.y = target[1].posV2P.y; rasterTopTriangle(target[0], newPoint, target[1]); rasterBottomTriangle(newPoint,target[1],target[2]); } }
至此,光柵化的關鍵算法我們就完成了。
網格體類
之前我們在渲染管線中聲明了一系列的頂點和頂點索引,我們可以使用這兩個數據來進行光柵化操作,但現在的問題是這兩個數據從哪來?但凡對於游戲制作方面有點了解的朋友都知道,來自於模型文件。所以除此之外,我們需要再定義一個新的類——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 void pyramid(); 17 void cube(double width, double height, double depth, int id, Vector4D pos); 18 void plane(double width,double height,int id,Vector4D pos); 19 };
這里我們定義了頂點組和索引組,同時也定義了幾個給自己初始化的函數,例如triangle函數,它的作用就是存儲傳入頂點的信息,並完成索引、法線、顏色等賦值。.cpp文件如下:
void Mesh::setVertices(Vertex* v, int count) { vertices.resize(static_cast<unsigned long>(count)); new(&vertices[0])std::vector<Vertex>(v,v+count); } void Mesh::setIndex(int* i, int count) { index.resize(static_cast<unsigned long>(count)); new(&index)std::vector<unsigned int>(i,i+count); } void Mesh::triangle(Vector3D &v1, Vector3D &v2, Vector3D &v3) { vertices.resize(3); index.resize(3); vertices[0].position=v1; vertices[0].normal=Vector3D(0.f,0.f,1.f); vertices[0].color=Vector4D(1.f,0.f,0.f,1.f); vertices[0].texcoord=Vector2D(0.f,0.f); vertices[1].position=v2; vertices[1].normal=Vector3D(0.f,0.f,1.f); vertices[1].color=Vector4D(0.f,1.f,0.f,1.f); vertices[1].texcoord=Vector2D(1.f,0.f); vertices[2].position=v3; vertices[2].normal=Vector3D(0.f,0.f,1.f); vertices[2].color=Vector4D(0.f,0.f,1.f,1.f); vertices[2].texcoord=Vector2D(0.5f,1.f); index[0]=0; index[1]=1; index[2]=2; }
渲染循環附件
目前我們缺少一個系統將管線各功能串聯起來,並將訊息傳送給畫布,本篇我們的目的就是要構造一個這樣的系統,我稱之為renderRoute,即渲染循環的意思。它的作用主要有兩部分,第一是儲存待渲染的對象,然后逐步調用渲染管線類中的方法完成渲染;第二是在渲染結束的時候將訊息及時送往渲染器窗口mainwindow。
為了提升效率,就不再一點點分析怎么去設計了,直接上頭文件:
1 #ifndef RENDERROUTE_H 2 #define RENDERROUTE_H 3 #include"QObject" 4 #include "pipeline.h" 5 6 class RenderRoute:public QObject 7 { 8 Q_OBJECT 9 public: 10 explicit RenderRoute(int w,int h,QObject *parent=nullptr); 11 ~RenderRoute(){} 12 void stopIt(); 13 signals: 14 void frameOut(unsigned char *image); 15 16 public slots: 17 void loop(); 18 19 private: 20 bool stopped; 21 int width,height,channel; 22 Pipeline *pipeline; 23 }; 24 25 #endif
首先最基礎的,我們定義了一個管理渲染狀態的bool值stopped,然后定義了畫布的屬性:寬、高、通道數(不出意外都是4,除非是灰度圖),然后定義了一個管理的對象Pineline。函數方面,首先聲明了個構造函數和析構函數,然后定義了一個用來暫停的函數stopIt。frameout是一種信號函數,這個我們下文再解釋。最后的loop則是我們的核心函數,表明渲染循環開始執行,它是由mainwindow來操縱的。
接下來我們來詳細看各個函數,首先是構造函數:
1 RenderRoute::RenderRoute(int w, int h, QObject *parent) 2 : QObject(parent), width(w), height(h), channel(4) 3 { 4 stopped=false; 5 pipeline=new Pipeline(width, height); 6 } 7 8 void RenderRoute::stopIt(){ 9 stopped=true; 10 }
構造函數就是初始化的過程,先把stopped歸為false表明渲染進行中,然后就是要給pipeline對象申請內存並初始化,將渲染管線中的寬高也置為渲染循環默認的寬高。stopIt函數就不解釋了。下面來看一下最關鍵的渲染函數loop:
1 void RenderRoute::loop() 2 { 3 pipeline->initialize(); 4 Vector3D v1(100,100,0),v2(1200,200,0),v3(500,700,0); 5 Mesh *msh=new Mesh; 6 msh->triangle(v1,v2,v3); 7 pipeline->setVertexBuffer(msh->vertices); 8 pipeline->setIndexBuffer(msh->index); 9 while(!stopped) 10 { 11 pipeline->clearBuffer(Vector4D(0,0,0,1.0f)); 12 pipeline->drawIndex(Pipeline::Fill); 13 pipeline->swapBuffer(); 14 emit frameOut(pipeline->output()); 15 } 16 }
上文我們提到,渲染循環是存儲基礎數據的地方。因此在這里,我們必須把每幀要渲染的對象數據給出。在對渲染管線對象進行初始化(各種申請空間)后,我們便給出待渲染對象:一個三角形模型。三角形模型本質上就是三個點,因此直接創建三個Vector類型的對象,調用mesh類對象的triangle方法,傳入三個頂點,這樣我們就可以得到一個完整的頂點數組和序號數組,然后再將它們傳入pipeline中作預備。
當我們成功把數據導入渲染管線后,若渲染未被停止,則開始調用渲染管線類中的方法——首先是清空緩沖區,將所有顏色都歸為黑色,然后調用drawIndex來進行幾何處理、着色器計算和光柵化操作。接下來顏色數據已經放入到back緩沖區中了,我們要手動調換front和back緩沖區,讓已經渲染完成的顏色數據進入待命狀態。上述步驟全部完成之后,我們便發射信號:emit frameOut(pipeline->output()),將渲染管線中front緩沖區中的顏色數據發射出去。
多線程通訊
最后一步,便是完成mainwindow類的最后一點配置。上文我們提到渲染循環renderRoute會將front的緩存數據發射出去。有發射就得有接收,這里我們定義一個接受信號函數receiveFrame:
1 void MainWindow::receiveFrame(unsigned char *data) 2 { 3 if(canvas) delete canvas; 4 canvas = new QImage(data, width(), height(), QImage::Format_RGBA8888); 5 update(); 6 }
這里就是說當我們拿到了需要渲染的顏色緩存數據后,將舊的畫布刪掉,創建一個新的畫布后進行update更新操作。在幀緩沖那一節,我們override了一個paintEvent函數,是真正的渲染操作,而它就是update中的一步。因此整件事的邏輯順序就是:renderRoute渲染完成,發射信號,mainwindow接收到顏色數據,重置畫布,調用update函數,update中會自動調用paintEvent函數,來完成渲染。
這樣,我們的整個渲染操作的思路就全部理完了。最后還差什么呢,一是信號連接,二是多線程,這里我們直接把兩部分合在一起:
1 MainWindow::MainWindow(QWidget *parent) 2 : QMainWindow(parent) 3 , ui(new Ui::MainWindow) 4 { 5 ui->setupUi(this); 6 this->resize(1280,768); 7 loop=new RenderRoute(width(),height(),nullptr); 8 loopThread=new QThread(this); 9 loop->moveToThread(loopThread); 10 connect(loopThread,&QThread::finished,loop, &RenderRoute::deleteLater); 11 connect(loopThread,&QThread::started,loop,&RenderRoute::loop); 12 connect(loop,&RenderRoute::frameOut,this,&MainWindow::receiveFrame); 13 loopThread->start(); 14 } 15 16 MainWindow::~MainWindow() 17 { 18 delete ui; 19 loop->stopIt(); 20 loopThread->quit(); 21 loopThread->wait(); 22 if(canvas)delete canvas; 23 if(loopThread)delete loopThread; 24 loop=nullptr; 25 canvas=nullptr; 26 loopThread=nullptr; 27 }
首先在構造函數中,我們先聲明ui(固定操作,不用搞懂它是干嘛的),設置默認窗口大小,然后我們創建一個渲染循環實例,一個渲染多線程。moveToThread可以將對象自身移交到多線程對象中,這樣它的所有函數方法都會使用多線程來調用,然后就是三個連接:第一個連接,聲明了多線程的結束條件;第二個連接,聲明了多線程開始的時候要調用的渲染循環的方法;第三個連接,就是將發射信號和接收信號鏈接在一起,這樣發射信號的函數可以精准無誤地把訊息送到接收信號的函數中。
當一切都准備完畢后,便可以開啟多線程了。此時,渲染循環就會逐幀運作,將內部的數據信息通過渲染管線類來完成計算和光柵化等操作。mainwindow還需要詳細寫一下析構函數,因為線程必須要及時停止或結束,然后釋放空間,指針歸位等等。至此,我們2D渲染的部分就完成了。