天天看點

OpenGL ES渲染管線與着色器

前言

在前文《[OpenGL ES 01]OpenGL ES之初體驗》中我們學習了如何在 iOS 平台上設定OpenGL ES 環境,主要是設定 CAEAGLLayer 屬性,建立 EAGLContext,建立和使用 renderbuffer 和 framebuffer,并知道如何清屏。但實際上并沒有真正描繪點什麼。在本文中,我們将學習OpenGL ES 渲染管線,頂點着色器和片元着色器相關知識,然後使用可程式設計管線在螢幕上描繪一個簡單三角形。

一,渲染管線

在 OpenGL ES 1.0 版本中,支援固定管線,而 OpenGL ES 2.0 版本不再支援固定管線,隻支援可程式設計管線。什麼是管線?什麼又是固定管線和可程式設計管線?管線(pipeline)也稱渲染管線,因為 OpenGL ES在渲染處理過程中會順序執行一系列操作,這一系列相關的處理階段就被稱為OpenGL ES 渲染管線。pipeline 來源于福特汽車生産工廠中的房間的流水線作業,在OpenGL ES 渲染過程中也是一樣,一個操作接着一個操作進行,就如流水線作業一樣,這樣的實作極大地提高了渲染的效率。整個渲染管線如下圖所示:

OpenGL ES渲染管線與着色器

圖中陰影部分的 Vertex Shader 和 Fragment Shader 是可程式設計管線。可程式設計管線就是說這個操作可以動态程式設計實作而不必固定寫死在代碼中。可動态程式設計實作這一功能一般都是腳本提供的,在OpenGL ES 中也一樣,編寫這樣腳本的能力是由着色語言(Shader Language)提供的。那可程式設計管線有什麼好處呢?友善我們動态修改渲染過程,而無需重寫編譯代碼,當然也和很多腳本語言一樣,調試起來不太友善。

再回到上圖,這張圖就是 OpenGL ES 的“架構圖”,學習OpenGL ES 就是學習這張圖中的每一個部分,在這裡先粗略地介紹一下。

Vertex Array/Buffer objects:頂點資料來源,這時渲染管線的頂點輸入,通常使用 Buffer objects效率更好。在今天的示例中,簡單起見,使用的是 Vertex Array;

Vertex Shader:頂點着色器通過可程式設計的方式實作對頂點的操作,如進行坐标空間轉換,計算 per-vertex color以及紋理坐标;

Primitive Assembly:圖元裝配,經過着色器處理之後的頂點在圖檔裝配階段被裝配為基本圖元。OpenGL ES 支援三種基本圖元:點,線和三角形,它們是可被 OpenGL ES 渲染的。接着對裝配好的圖元進行裁剪(clip):保留完全在視錐體中的圖元,丢棄完全不在視錐體中的圖元,對一半在一半不在的圖元進行裁剪;接着再對在視錐體中的圖元進行剔除處理(cull):這個過程可編碼來決定是剔除正面,背面還是全部剔除。

Rasterization:光栅化。在光栅化階段,基本圖元被轉換為二維的片元(fragment),fragment 表示可以被渲染到螢幕上的像素,它包含位置,顔色,紋理坐标等資訊,這些值是由圖元的頂點資訊進行插值計算得到的。這些片元接着被送到片元着色器中處理。這是從頂點資料到可渲染在顯示裝置上的像素的質變過程。

Fragment Shader:片元着色器通過可程式設計的方式實作對片元的操作。在這一階段它接受光栅化處理之後的fragment,color,深度值,模版值作為輸入。

Per-Fragment Operation:在這一階段對片元着色器輸出的每一個片元進行一系列測試與處理,進而決定最終用于渲染的像素。這一系列處理過程如下:

OpenGL ES渲染管線與着色器

Pixel ownership test:該測試決定像素在 framebuffer 中的位置是不是為目前 OpenGL ES 所有。也就是說測試某個像素是否對使用者可見或者被重疊視窗所阻擋;

Scissor Test:剪裁測試,判斷像素是否在由 glScissor 定義的剪裁矩形内,不在該剪裁區域内的像素就會被剪裁掉;

Stencil Test:模版測試,将模版緩存中的值與一個參考值進行比較,進而進行相應的處理;

Depth Test:深度測試,比較下一個片段與幀緩沖區中的片段的深度,進而決定哪一個像素在前面,哪一個像素被遮擋;

Blending:混合,混合是将片段的顔色和幀緩沖區中已有的顔色值進行混合,并将混合所得的新值寫入幀緩沖;

Dithering:抖動,抖動是使用有限的色彩讓你看到比實際圖象更多色彩的顯示方式,以緩解表示顔色的值的精度不夠大而導緻的顔色劇變的問題。

Framebuffer:這是流水線的最後一個階段,Framebuffer 中存儲這可以用于渲染到螢幕或紋理中的像素值,也可以從Framebuffer 中讀回像素值,但不能讀取其他值(如深度值,模版值等)。

二,頂點着色器

下面來仔細看看頂點着色器:

OpenGL ES渲染管線與着色器

頂點着色器接收的輸入:

Attributes:由 vertext array 提供的頂點資料,如空間位置,法向量,紋理坐标以及頂點顔色,它是針對每一個頂點的資料。屬性隻在頂點着色器中才有,片元着色器中沒有屬性。屬性可以了解為針對每一個頂點的輸入資料。OpenGL ES 2.0 規定了所有實作應該支援的最大屬性個數不能少于 8 個。

Uniforms:uniforms儲存由應用程式傳遞給着色器的隻讀常量資料。在頂點着色器中,這些資料通常是變換矩陣,光照參數,顔色等。由 uniform 修飾符修飾的變量屬于全局變量,該全局性對頂點着色器與片元着色器均可見,也就是說,這兩個着色器如果被連接配接到同一個應用程式中,它們共享同一份 uniform 全局變量集。是以如果在這兩個着色器中都聲明了同名的 uniform 變量,要保證這對同名變量完全相同:同名+同類型,因為它們實際是同一個變量。此外,uniform 變量存儲在常量存儲區,是以限制了 uniform 變量的個數,OpenGL ES 2.0 也規定了所有實作應該支援的最大頂點着色器 uniform 變量個數不能少于 128 個,最大的片元着色器 uniform 變量個數不能少于 16 個。

Samplers:一種特殊的 uniform,用于呈現紋理。sampler 可用于頂點着色器和片元着色器。

Shader program:由 main 申明的一段程式源碼,描述在頂點上執行的操作:如坐标變換,計算光照公式來産生 per-vertex 顔色或計算紋理坐标。

頂點着色器的輸出:

Varying:varying 變量用于存儲頂點着色器的輸出資料,當然也存儲片元着色器的輸入資料,varying 變量最終會在光栅化處理階段被線性插值。頂點着色器如果聲明了 varying 變量,它必須被傳遞到片元着色器中才能進一步傳遞到下一階段,是以頂點着色器中聲明的 varying 變量都應在片元着色器中重新聲明同名同類型的 varying 變量。OpenGL ES 2.0 也規定了所有實作應該支援的最大 varying 變量個數不能少于 8 個。

在頂點着色器階段至少應輸出位置資訊-即内建變量:gl_Position,其它兩個可選的變量為:gl_FrontFacing 和 gl_PointSize。

三,片元着色器

接下來仔細看看片元着色器:

OpenGL ES渲染管線與着色器

片元管理器接受如下輸入: 

Varyings:這個在前面已經講過了,頂點着色器階段輸出的 varying 變量在光栅化階段被線性插值計算之後輸出到片元着色器中作為它的輸入,即上圖中的 gl_FragCoord,gl_FrontFacing 和 gl_PointCoord。OpenGL ES 2.0 也規定了所有實作應該支援的最大 varying 變量個數不能少于 8 個。

Uniforms:前面也已經講過,這裡是用于片元着色器的常量,如霧化參數,紋理參數等;OpenGL ES 2.0 也規定了所有實作應該支援的最大的片元着色器 uniform 變量個數不能少于 16 個。

Samples:一種特殊的 uniform,用于呈現紋理。

Shader program:由 main 申明的一段程式源碼,描述在片元上執行的操作。

在頂點着色器階段隻有唯一的 varying 輸出變量-即内建變量:gl_FragColor。

四,頂點着色與片元着色在程式設計上的差異

1,精度上的差異

着色語言定了三種級别的精度:lowp, mediump, highp。我們可以在 glsl 腳本檔案的開頭定義預設的精度。如下代碼定義在 float 類型預設使用 highp 級别的精度

precision highp float;      

在頂點着色階段,如果沒有使用者自定義的預設精度,那麼 int 和 float 都預設為 highp 級别;而在片元着色階段,如果沒有使用者自定義的預設精度,那麼就真的沒有預設精度了,我們必須在每個變量前放置精度描述符。此外,OpenGL ES 2.0 标準也沒有強制要求所有實作在片元階段都支援 highp 精度的。我們可以通過檢視是否定義 GL_FRAGMENT_PRECISION_HIGH 來判斷具體實作是否在片元着色器階段支援 highp 精度,進而編寫出可移植的代碼。當然,通常我們不需要在片元着色器階段使用 highp 級别的精度,推薦的做法是先使用 mediump 級别的精度,隻有在效果不夠好的情況下再考慮 highp 精度。

2,attribute 修飾符隻可用于頂點着色。這個前面已經說過了。

3,或由于精度的不同,或因為編譯優化的原因,在頂點着色和片元着色階段同樣的計算可能會得到不同的結果,這會導緻一些問題(z-fighting)。是以 glsl 引入了 invariant 修飾符來修飾在兩個着色階段的同一變量,確定同樣的計算會得到相同的值。

五,使用頂點着色器與片元着色器

好了,理論知識講得足夠多了,下面我們來看看如何在代碼中添加頂點着色器與片元着色器。我們在前一篇文章《[OpenGL ES 01]OpenGL ES之初體驗》代碼的基礎上進行編碼。在前面提到可程式設計管線通過用 shader 語言編寫腳本檔案實作的,這些腳本檔案相當于 C 源碼,有源碼就需要編譯連結,是以需要對應的編譯器與連結器,shader 對象與 program 對象就相當于編譯器與連結器。shader 對象載入源碼,然後編譯成 object 形式(就像C源碼編譯成 .obj檔案)。經過編譯的 shader 就可以裝配到 program 對象中,每個 program對象必須裝配兩個 shader 對象:一個頂點 shader,一個片元 shader,然後 program 對象被連接配接成“可執行檔案”,這樣就可以在 render 中是由該“可執行檔案”了。

1,建立,裝載和編譯 shader

首先,我們向工程中添加新的類 GLESUtils,讓它繼承自 NSObject。修改 GLESUtils.h 為:

#import <Foundation/Foundation.h>
#include <OpenGLES/ES2/gl.h>

@interface GLESUtils : NSObject

// Create a shader object, load the shader source string, and compile the shader.
//
+(GLuint)loadShader:(GLenum)type withString:(NSString *)shaderString;

+(GLuint)loadShader:(GLenum)type withFilepath:(NSString *)shaderFilepath;

@end      

修改 GLESUtils.m 為:

#import "GLESUtils.h"

@implementation GLESUtils

+(GLuint)loadShader:(GLenum)type withFilepath:(NSString *)shaderFilepath
{
    NSError* error;
    NSString* shaderString = [NSString stringWithContentsOfFile:shaderFilepath 
                                                       encoding:NSUTF8StringEncoding
                                                          error:&error];
    if (!shaderString) {
        NSLog(@"Error: loading shader file: %@ %@", shaderFilepath, error.localizedDescription);
        return 0;
    }
    
    return [self loadShader:type withString:shaderString];
}

+(GLuint)loadShader:(GLenum)type withString:(NSString *)shaderString
{   
    // Create the shader object
    GLuint shader = glCreateShader(type);
    if (shader == 0) {
        NSLog(@"Error: failed to create shader.");
        return 0;
    }
    
    // Load the shader source
    const char * shaderStringUTF8 = [shaderString UTF8String];
    glShaderSource(shader, 1, &shaderStringUTF8, NULL);
    
    // Compile the shader
    glCompileShader(shader);
    
    // Check the compile status
    GLint compiled = 0;
    glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
    
    if (!compiled) {
        GLint infoLen = 0;
        glGetShaderiv ( shader, GL_INFO_LOG_LENGTH, &infoLen );
        
        if (infoLen > 1) {
            char * infoLog = malloc(sizeof(char) * infoLen);
            glGetShaderInfoLog (shader, infoLen, NULL, infoLog);
            NSLog(@"Error compiling shader:\n%s\n", infoLog );            
            
            free(infoLog);
        }
        
        glDeleteShader(shader);
        return 0;
    }

    return shader;
}

@end      

輔助類 GLESUtils 中有兩個類方法用來跟進 shader 腳本字元串或 shader 腳本檔案建立 shader,然後裝載它,編譯它。下面詳細介紹每個步驟。

1),建立/删除 shader

函數 glCreateShader 用來建立 shader,參數 GLenum type 表示我們要處理的 shader 類型,它可以是 GL_VERTEX_SHADER 或 GL_FRAGMENT_SHADER,分别表示頂點 shader 或 片元 shader。它傳回一個句柄指向建立好的 shader 對象。

函數 glDeleteShader 用來銷毀 shader,參數為 glCreateShader 傳回的 shader 對象句柄。

2),裝載 shader

函數 glShaderSource 用來給指定 shader 提供 shader 源碼。第一個參數是 shader 對象的句柄;第二個參數表示 shader 源碼字元串的個數;第三個參數是 shader 源碼字元串數組;第四個參數一個 int 數組,表示每個源碼字元串應該取用的長度,如果該參數為 NULL,表示假定源碼字元串是 \0 結尾的,讀取該字元串的内容指定 \0 為止作為源碼,如果該參數不是 NULL,則讀取每個源碼字元串中前 length(與每個字元串對應的 length)長度個字元作為源碼。

3),編譯 shader

函數 glCompileShader 用來編譯指定的 shader 對象,這将編譯存儲在 shader 對象中的源碼。我們可以通過函數 glGetShaderiv 來查詢 shader 對象的資訊,如本例中查詢編譯情況,此外還可以查詢 GL_DELETE_STATUS,GL_INFO_LOG_STATUS,GL_SHADER_SOURCE_LENGTH 和 GL_SHADER_TYPE。在這裡我們查詢編譯情況,如果傳回 0,表示編譯出錯了,錯誤資訊會寫入 info 日志中,我們可以查詢該 info 日志,進而獲得錯誤資訊。

2,編寫着色腳本

GLESUtils 提供的接口讓我們可以使用兩種方式:腳本字元串或腳本檔案來提供 shader 源碼,通常使用腳本檔案方式有更大的靈活性。(Cocos2D 源碼中倒是提供了不少腳本字元串應對一些常見的情況,有興趣的同學可以檢視下)。在這裡,我們使用腳本檔案方式。

1),添加頂點着色腳本

右擊 Supporting Files 目錄,New File->Other->Empty,輸入名稱:VertexShader.glsl,去除 target Tutorial02 中的勾選。字尾glsl 表示 GL Shader Language。

OpenGL ES渲染管線與着色器

編輯其内容如下:

attribute vec4 vPosition; 
 
void main(void)
{
    gl_Position = vPosition;
}      

然後選擇 Tutorial02,在 Build Phases -> Copy Bundle Sources 中添加 VertexShader.glsl。

頂點着色腳本的源碼很簡單,如果你仔細閱讀了前面的介紹,就一目了然。 attribute 屬性 vPosition 表示從應用程式輸入的類型為 vec4 的位置資訊,輸出内建 vary 變量 vPosition。留意:這裡使用了預設的精度。

2),添加片元着色腳本

用于添加頂點着色腳本同樣的方式添加名為 FragmentShader.glsl 的檔案,編輯其内容如下:

precision mediump float;

void main()
{
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}      

不用忘記在 Build Phases -> Copy Bundle Sources 中添加 FragmentShader.glsl。

片元着色腳本源碼也很簡單,前面說過片元着色要麼自己定義預設精度,要麼在每個變量前添加精度描述符,在這裡自定義 float 的精度為 mediump。然後為内建輸出變量 gl_FragColor 指定為紅色。

3,建立 program,裝配 shader,連結 program,使用 program

1),建立 program

在 OpenGLView.h 的 OpenGLView 類聲明中添加兩個成員:

GLuint _programHandle;
    GLuint _positionSlot;      

然後依然在 OpenGLView.m 中的匿名 category 中添加成員方法:

- (void)setupProgram;      

在 - (void)render 方法前,添加其實作:

- (void)setupProgram
{
    // Load shaders
    //
    NSString * vertexShaderPath = [[NSBundle mainBundle] pathForResource:@"VertexShader"
                                                                  ofType:@"glsl"];
    NSString * fragmentShaderPath = [[NSBundle mainBundle] pathForResource:@"FragmentShader"
                                                                    ofType:@"glsl"];
    GLuint vertexShader = [GLESUtils loadShader:GL_VERTEX_SHADER
                                   withFilepath:vertexShaderPath]; 
    GLuint fragmentShader = [GLESUtils loadShader:GL_FRAGMENT_SHADER
                                     withFilepath:fragmentShaderPath];

    // Create program, attach shaders.
    _programHandle = glCreateProgram();
    if (!_programHandle) {
        NSLog(@"Failed to create program.");
        return;
    }
    
    glAttachShader(_programHandle, vertexShader);
    glAttachShader(_programHandle, fragmentShader);
    
    // Link program
    //
    glLinkProgram(_programHandle);
    
    // Check the link status
    GLint linked;
    glGetProgramiv(_programHandle, GL_LINK_STATUS, &linked );
    if (!linked) 
    {
        GLint infoLen = 0;
        glGetProgramiv (_programHandle, GL_INFO_LOG_LENGTH, &infoLen );
        
        if (infoLen > 1)
        {
            char * infoLog = malloc(sizeof(char) * infoLen);
            glGetProgramInfoLog (_programHandle, infoLen, NULL, infoLog );
            NSLog(@"Error linking program:\n%s\n", infoLog );            
            
            free (infoLog );
        }
        
        glDeleteProgram(_programHandle);
        _programHandle = 0;
        return;
    }
    
    glUseProgram(_programHandle);
    
    // Get attribute slot from program
    //
    _positionSlot = glGetAttribLocation(_programHandle, "vPosition");
}      

有了前面的介紹,上面的代碼很容易了解。首先我們是由 GLESUtils 提供的輔助方法從前面建立的腳本中建立,裝載和編譯頂點 shader 和片元 shader;然後我們建立 program,将頂點 shader 和片元 shader 裝配到 program 對象中,再使用 glLinkProgram 将裝配的 shader 連結起來,這樣兩個 shader 就可以合作幹活了。注意:連結過程會對 shader 進行可連結性檢查,也就是前面說到同名變量必須同名同型以及變量個數不能超出範圍等檢查。我們如何檢查 shader 編譯情況一樣,對 program 的連結情況進行檢查。如果一切正确,那我們就可以調用 glUseProgram 激活 program 對象進而在 render 中使用它。通過調用 glGetAttribLocation 我們擷取到 shader 中定義的變量 vPosition 在 program 的槽位,通過該槽位我們就可以對 vPosition 進行操作。

4,使用示例

在 - (void)layoutSubviews 中調用 render 方法之前,插入對 setupProgram 的調用:

[self setupProgram];

    [self render];      

然後改寫 render 方法:

- (void)render
{
    glClearColor(0, 1.0, 0, 1.0);
    glClear(GL_COLOR_BUFFER_BIT);

    // Setup viewport
    //
    glViewport(0, 0, self.frame.size.width, self.frame.size.height);
    
    GLfloat vertices[] = {
        0.0f,  0.5f, 0.0f, 
        -0.5f, -0.5f, 0.0f,
        0.5f,  -0.5f, 0.0f };
    
    // Load the vertex data
    //
    glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, vertices );
    glEnableVertexAttribArray(_positionSlot);
    
    // Draw triangle
    //
    glDrawArrays(GL_TRIANGLES, 0, 3);
    
    [_context presentRenderbuffer:GL_RENDERBUFFER];
}      

在新增的代碼中,第一句 glViewport 表示渲染 surface 将在螢幕上的哪個區域呈現出來,然後我們建立一個三角形頂點數組,通過 glVertexAttribPointer 将三角形頂點資料裝載到 OpenGL ES 中并與 vPositon 關聯起來,最後通過  glDrawArrays 将三角形圖元渲染出來。

5,編譯運作

編譯運作,将看到一個紅色的三角形顯示在螢幕中央。知道為什麼是紅色的麼?那是因為 program 也連結了片元着色器,在片元着色腳本檔案中,我們指定 gl_FragColor 的值為紅色 vec4(1.0, 0.0, 0.0, 1.0)。

OpenGL ES渲染管線與着色器

六,總結

在前文《[OpenGL ES 01]OpenGL ES之初體驗》和本文中,我們詳細了解了如何在 iPhone 中使用 OpenGL ES 的整個過程,包括設定 CAEAGLLayer 屬性,建立 EAGLContext,建立和使用 renderbuffer 和 framebuffer,了解OpenGL ES 渲染管線,建立和使用 shader,建立和實作 program,使用頂點數組進行描繪。流程已經走通,接下來讓我們進入 OpenGL ES 各個具體的技術領域。

源碼連結:https://github.com/kesalin/OpenGLES/tree/master/Tutorial02

原文連結:http://blog.csdn.net/kesalin/article/details/8223649#comments

繼續閱讀