OpenGL/OpenGL ES入門: 影象渲染實現以及渲染問題

佐籩發表於2019-05-17

系列推薦文章:
OpenGL/OpenGL ES入門:圖形API以及專業名詞解析
OpenGL/OpenGL ES入門:渲染流程以及固定儲存著色器
OpenGL/OpenGL ES入門:影象渲染實現以及渲染問題
OpenGL/OpenGL ES入門:基礎變換 - 初識向量/矩陣
OpenGL/OpenGL ES入門:紋理初探 - 常用API解析
OpenGL/OpenGL ES入門:紋理應用 - 紋理座標及案例解析(金字塔)

影象渲染的實現

先看用一個平面著色器渲染出的一個甜甜圈

平面著色器渲染甜甜圈效果圖

程式碼實現:

  • main 函式,程式入口。所以OpenGL處理圖形、影象都是鏈式形式,以及基於OpenGL封裝的影象處理框架也是鏈式程式設計
    gltSetWorkingDirectory(argv[0]);
    
    glutInit(&argc, argv);
   // 初始化視窗
    glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH | GLUT_STENCIL);
    glutInitWindowSize(800, 600);
    glutCreateWindow("ZB");
    // 註冊函式
    glutReshapeFunc(ChangeSize);
    glutSpecialFunc(SpecialKeys);
    glutDisplayFunc(RenderScene);
    
    GLenum err = glewInit();
    if (GLEW_OK != err) {
        fprintf(stderr, "GLEW Error:%s\n", glewGetErrorString(err));
        return 1;
    }
    // 主動觸發,準備工作
    SetupRC();
    // 一個無限執行的迴圈,負責一直處理視窗和作業系統的使用者輸入等操作
    glutMainLoop();
    return 0;
複製程式碼
  • changeSize 通過glutReshapeFunc註冊為重塑函式,當第一次建立視窗或螢幕大小發生改變時,會呼叫該函式調整視窗大小/視口大小
    // 保證高度不能為0
    if (h == 0) {
        h = 1;
    }
    
    // 將視口設定為視窗尺寸
    glViewport(0, 0, w, h);
    // 建立投影矩陣,並將它載入投影矩陣堆疊中
    viewFrustum.SetPerspective(35, float(w)/float(h), 1, 1000);
    projectionMatrix.LoadMatrix(viewFrustum.GetProjectionMatrix());
    
    // 初始化渲染管線
    transformPipeline.SetMatrixStacks(modelViweMatix, projectionMatrix);
複製程式碼
  • SetupRC 設定需要渲染圖形相關頂點資料、顏色值等,手動在main函式呼叫
    // 1. 設定背景色
    glClearColor(0.3, 0.3, 0.3, 1);
    
    // 2. 初始化著色器管理器
    shaderManager.InitializeStockShaders();
    
    // 3. 將相機向後移動7個單元,肉眼到物體的距離
    viewFrame.MoveForward(5.0);
    
    // 4. 建立一個甜甜圈
    /**
     void gltMakeTorus(GLTriangleBatch& torusBatch, GLfloat majorRadius, GLfloat minorRadius, GLint numMajor, GLint numMinor);
     引數1: GLTriangleBatch 容器幫助類
     引數2: 外邊緣半徑
     引數3: 內邊緣半徑
     引數4、5: 主半徑和從半徑的細分單元數量
     */
    gltMakeTorus(torusBatch, 1, 0.3, 88, 33);
    
    // 5. 點的大小(方便點填充時,肉眼觀察)
    glPointSize(4.0);
複製程式碼
  • RenderScene 通過glutDisplayFunc註冊為渲染函式。當螢幕發生變化或者開發者主動渲染會呼叫此函式,用來實現資料->渲染過程
    // 1. 清除視窗和深度緩衝區
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    // 2. 把攝像機矩陣壓入模型矩陣中,壓棧 -- 儲存一個狀態
    modelViweMatix.PushMatrix(viewFrame);
    
    // 3. 設定繪圖顏色
    GLfloat vRed[] = {1, 0, 0, 1};
    
    // 4. 使用平面著色器
    shaderManager.UseStockShader(GLT_SHADER_FLAT, transformPipeline.GetModelViewProjectionMatrix(), vRed);
    
    // 5. 繪製
    torusBatch.Draw();
    
    // 6. 出棧,繪製完成恢復  出棧 -- 恢復一個狀態
    modelViweMatix.PopMatrix();
    
    // 7. 強制執行快取區
    glutSwapBuffers();
複製程式碼

到這裡為止,編譯執行就能過出現上圖所示的效果圖。利用的是平面著色器。 相當的low。

下面在此基礎上進行酷炫的一波操作。

main函式中註冊了一個函式SpecialKeys,顧名思義,特殊鍵位,這裡控制的是上下左右鍵位

    // 1. 判斷方向
    if (key == GLUT_KEY_UP) {
        // 2. 根據方向調整觀察者位置
        // 引數1: 旋轉的弧度
        // 引數2、3、4:表示繞哪個軸進行旋轉
        viewFrame.RotateWorld(m3dDegToRad(-5), 1, 0, 0);
    }
    if (key == GLUT_KEY_DOWN) {
        viewFrame.RotateWorld(m3dDegToRad(5), 1, 0, 0);
    }
    if (key == GLUT_KEY_LEFT) {
        viewFrame.RotateWorld(m3dDegToRad(-5), 0, 1, 0);
    }
    if (key == GLUT_KEY_RIGHT) {
        viewFrame.RotateWorld(m3dDegToRad(5), 0, 1, 0);
    }
    // 3. 重新重新整理
    glutPostRedisplay();
複製程式碼

看實現效果

能夠旋轉的甜甜圈

在來一波更真實的操作,我們使用預設光源著色器來實現

    // 1. 清除視窗和深度緩衝區
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    // 2. 把攝像機矩陣壓入模型矩陣中
    modelViweMatix.PushMatrix(viewFrame);
    
    // 3. 設定繪圖顏色
    GLfloat vRed[] = {1, 0, 0, 1};
    
    // 4. 使用平面著色器
//    shaderManager.UseStockShader(GLT_SHADER_FLAT, transformPipeline.GetModelViewProjectionMatrix(), vRed);
    
    // 4.1 使用預設光源著色器
    // 通過光源、陰影效果跟體現立體效果
    // 引數1:GLT_SHADER_DEFAULT_LIGHT 預設光源著色器
    // 引數2:模型檢視矩陣
    // 引數3:投影矩陣
    // 引數4:基本顏色值
    shaderManager.UseStockShader(GLT_SHADER_DEFAULT_LIGHT, transformPipeline.GetModelViewMatrix(), transformPipeline.GetProjectionMatrix(), vRed);
    
    // 5. 繪製
    torusBatch.Draw();
    
    // 6. 出棧,繪製完成恢復
    modelViweMatix.PopMatrix();
    
    // 7. 強制執行快取區
    glutSwapBuffers();
複製程式碼

效果圖如下:

未正背面剔除的渲染

可以看出,我們的渲染出了問題。

問題分析

在使用預設光源著色器時,由於產生了光照,有光照的一面,按照原本的顏色顯示,而背光面,則是黑暗的,我們看不見的。其實很好理解,太陽光照地球,迎光面是白天,背光面是黑夜。

在繪製3D場景的時候,我們需要決定哪些部分是對觀察者可見的,或者哪些部分是對觀察者不可見的,對於不可見的部分,應該及早丟棄。例如在一個不透明的牆壁後,就不應該有渲染,這種情況叫做隱藏面消除

下面討論一下解決這個問題的方案。

解決問題的方案

油畫演算法

先繪製場景中離觀察者較遠的物體,在繪製較近的物體,如下圖

油畫演算法

繪製順序依次是紅、黃、灰,這樣的話按序渲染能過解決隱藏面消除的問題。

但是隨之而來的會有一些不好的問題出現

  • 效率很低,重疊部分會進行多次繪製渲染,浪費資源
  • 對於某些存在場景,無法區別遠近順序的,無法用該方法解決問題,如下圖

無法區別遠近

正背面剔除

首先需要確定一個問題,任何平面都有2個面,正面/背面,意味著你一個時刻只能看到一面。

一個立方體圖形,從任何一個方向去觀察,最多可以看到3個面,意味著其他看不到的面,我們不需要去繪製它,如果能以某種方式去丟棄這部分資料,OpenGL在渲染的效能即可提高50%。

沒錯,OpenGL能夠區別正面和背面,通過分析頂點資料的順序

OpenGL區別正背面

正面/背面區分

  • 正面:按照逆時針頂點連結順序的三角形面
  • 背面:按照順時針頂點連線順序的三角形面

立方體中的正背面

立方體中的正背面

分析:

  • 左側三角形頂點順序為:1->2->3; 右側三角形的頂點順序為:1->2->3
  • 當觀察者在右側時,則右邊的三角形方向為逆時針方向為正面,而左側的三角形為順時針則為反面
  • 當觀察者在左側時,則左邊的三⻆形方向為逆時針⽅方向為正面,⽽右側的三角形為順時針則為背面

總結: 正面和背面是由三角形的頂點定義順序和觀察者方向共同決定的,隨著觀察者的角度方向的改變,正面背面也會跟著改變

相關程式碼

// 開啟表面剔除(預設背面剔除)
void glEnable(GL_CULL_FACE);

// 關閉表面剔除(預設背面剔除)
void glDisable(GL_CULL_FACE);

// 使用者選擇剔除那個面(即可自定義剔除,預設為正面)
void glCullFace(GLenum mode);
mode引數為:GL_FRONT, GL_BACK, GL_FRONT_AND_BACK, 預設為GL_BACK

// 使用者也可以指定正面
void glFrontFace(GLenum mode);
mode引數為:GL_CW, GL_CCW, 預設為GL_CCW

// 剔除正面實現
glCullFace(GL_BACK);
glFrontFace(GL_CW);
或
glCullface(GL_FRONT);
複製程式碼

具體程式碼實現

    // 1. 清除視窗和深度緩衝區
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    // 開啟正背面剔除
    glEnable(GL_CULL_FACE);
    glFrontFace(GL_CCW);
    glCullFace(GL_BACK);
    
    // 2. 把攝像機矩陣壓入模型矩陣中
    modelViweMatix.PushMatrix(viewFrame);
    
    // 3. 設定繪圖顏色
    GLfloat vRed[] = {1, 0, 0, 1};
    
    // 後面程式碼和上面一樣,不再重複
複製程式碼

實現效果如下圖:

未進行深度測試的甜甜圈

可以看到,之前的問題已經解決了,可是又面臨了一個尷尬的問題,這個甜甜圈貌似有個很大的缺口,瞭解過圖形渲染的讀者肯定知道,這是深度問題,下面來了解一下。

深度

深度就是該畫素點在3D世界中距離攝像機的距離,也就是Z值。
深度緩衝區就是一塊記憶體區域,專門儲存著每個畫素點(繪製在螢幕上的)深度值Z。Z越大,則距離螢幕越遠。

那麼為什麼需要深度緩衝區?
在不實用深度測試的時候,如果我們先繪製一個距離比較近的物體,在繪製距離遠的物體,則距離遠的點陣圖因為後繪製,會把距離近的物體覆蓋掉。有了深度緩衝區後,繪製物體的順序就不那麼重要了。上面出現的大缺口,也就是這個問題造成的。

實際上,只要存在深度緩衝區,OpenGL都會把畫素的深度值寫入到緩衝區中,除非呼叫glDepthMask(GL_FALSE)來禁止寫入。

深度測試
深度緩衝區和顏色緩衝區是對應的。顏色緩衝區儲存畫素的顏色資訊,而深度緩衝區儲存畫素的深度資訊。在決定是否繪製一個物體表面時,首先要將表面對應的畫素的深度值與當前深度緩衝區中的值進行比較,如果大於深度緩衝區的值,則丟棄這部分,否則利用這個畫素對應的深度值和顏色值,分別更新深度緩衝區和顏色緩衝區。這個過程稱為深度測試

相關程式碼

// 開啟深度測試
glEnable(GL_DEPTH_TEST);

// 在繪製場景前,清除顏色緩衝區和深度緩衝區
glClearColor(0, 0, 0, 1);
glClear(GL_COLOR_BUFFER_BIT | GL_GEPTH_BUFFER_BIT);
複製程式碼

清除深度緩衝區預設值為1.0,表示最大的深度值,深度值的範圍為(0,1)之間。值越小表示越靠近觀察者,反正表示距離觀察者越遠。

下面有關深度測試的判斷式

指定深度測試判斷模式
void glDepthFunc(GLEnum mode);
開啟/阻斷 深度緩衝區寫入
void glDepthMask(GKBool value);
value : GL_TURE 開啟寫入 GL_FALSE 關閉寫入

深度測試判斷模式

最終的實現效果如下:

最終效果圖

ZFighting閃爍問題

為什麼會出現ZFighting閃爍問題

因為開啟深度測試後,OpenGL就不會去繪製模型被遮擋的部分,這樣實現現實更加真實,但是由於深度緩衝區精度的限制,對於深度相差無幾的情況下,OpenGL就可能出現不能正確判斷兩者深度值,會導致深度測試的結果不可預測,現實出來的現象會交錯閃爍。

深度相差無幾

解決方式

  • 第一步:啟用Polygon Offset方式解決
    讓深度值之間產生間隔,可以理解為在執行深度測試前,將立方體的深度值做一些細微的增加,於是就能將重疊的2個圖形深度值之間有所區分。
// 啟用Polygon Offset方式
glEnable(GL_POLYGON_OFFSET_FILL);

引數列表:
GL_POLYGON_OFFSET_POINT 對應光柵化模式:GL_POINT
GL_POLYGON_OFFSET_LINE 對應光柵化模式:GL_LINE
GL_POLYGON_OFFSET_FILL 對應光柵化模式:GL_FILL

複製程式碼
  • 第二步:指定偏移量

    • 通過glPolygon Offset 來指定. glPolygon Offset需要2個引數: factor , units.
    • 每個Fragment 的深度值都會增加如下所示的偏移量:
      Offset = ( m * factor ) + ( r * units);
      m : 多邊形的深度的斜率的最大值,理解一個多邊形越是與近裁剪⾯平行,m就越接近於0.
      r : 能產生於視窗座標系的深度值中可分辨的差異最小值.r是由具體是由具體OpenGL平臺指定的一個常量.
    • 一個⼤於0的Offset會把模型推到離你(攝像機)更遠的位置,相應的⼀個小於0的Offset 會把模型拉近
    • 一般⽽言,只需要將-1.0 和 -1 這樣簡單賦值給glPolygon Offset 基本可以滿⾜足需求.
  • 第三步:關閉Polygon Offset

glDisable(GL_POLYGON_OFFSET_FILL);
複製程式碼

OK,到此為止,我們完美的把這個甜甜圈給渲染出來了。上面遇到的一些問題也得已解決。

相關文章