1. 程式人生 > >opengl學習之路三,著色器

opengl學習之路三,著色器

QQ:609162385 連結:https://blog.csdn.net/cqltbe131421 在Hello Triangle教程中提到,著色器(Shader)是執行在GPU上的小程式。這些小程式為圖形渲染管線的某個特定部分而執行。從基本意義上來說,著色器只是一種把輸入轉化為輸出的程式。著色器也是一種非常獨立的程式,因為它們之間不能相互通訊;它們之間唯一的溝通只有通過輸入和輸出。

前面的教程裡我們簡要地觸及了一點著色器的皮毛,並瞭解瞭如何恰當地使用它們。現在我們會用一種更加廣泛的形式詳細解釋著色器,特別是OpenGL著色器語言(GLSL)。

GLSL

著色器是使用一種叫GLSL的類C語言寫成的。GLSL是為圖形計算量身定製的,它包含一些針對向量和矩陣操作的有用特性。

著色器的開頭總是要宣告版本,接著是輸入和輸出變數、uniform和main函式。每個著色器的入口點都是main函式,在這個函式中我們處理所有的輸入變數,並將結果輸出到輸出變數中。如果你不知道什麼是uniform也不用擔心,我們後面會進行講解。

一個典型的著色器有下面的結構:

#version version_number
in type in_variable_name;
in type in_variable_name;

out type out_variable_name;

uniform type uniform_name;

int main()
{
  // 處理輸入並進行一些圖形操作
  ...
  // 輸出處理過的結果到輸出變數
  out_variable_name = weird_stuff_we_processed;
}

當我們特別談論到頂點著色器的時候,每個輸入變數也叫頂點屬性(Vertex Attribute)。我們能宣告的頂點屬性是有上限的,它一般由硬體來決定。OpenGL確保至少有16個包含4分量的頂點屬性可用,但是有些硬體或許允許更多的頂點屬性,你可以查詢GL_MAX_VERTEX_ATTRIBS來獲取具體的上限:

int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;

通常情況下它至少會返回16個,大部分情況下是夠用了。

資料型別

和其他程式語言一樣,GLSL有資料型別可以來指定變數的種類。GLSL中包含C等其它語言大部分的預設基礎資料型別:int、float、double、uint和bool。GLSL也有兩種容器型別,它們會在這個教程中使用很多,分別是向量(Vector)和矩陣(Matrix),其中矩陣我們會在之後的教程裡再討論。

向量

GLSL中的向量是一個可以包含有1、2、3或者4個分量的容器,分量的型別可以是前面預設基礎型別的任意一個。它們可以是下面的形式(n代表分量的數量):

型別

含義

vecn 包含n個float分量的預設向量 bvecn 包含n個bool分量的向量 ivecn 包含n個int分量的向量 uvecn 包含n個unsigned int分量的向量 dvecn 包含n個double分量的向量

大多數時候我們使用vecn,因為float足夠滿足大多數要求了。

一個向量的分量可以通過vec.x這種方式獲取,這裡x是指這個向量的第一個分量。你可以分別使用.x、.y、.z和.w來獲取它們的第1、2、3、4個分量。GLSL也允許你對顏色使用rgba,或是對紋理座標使用stpq訪問相同的分量。

向量這一資料型別也允許一些有趣而靈活的分量選擇方式,叫做重組(Swizzling)。重組允許這樣的語法:

vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

你可以使用上面4個字母任意組合來建立一個和原來向量一樣長的(同類型)新向量,只要原來向量有那些分量即可;然而,你不允許在一個vec2向量中去獲取.z元素。我們也可以把一個向量作為一個引數傳給不同的向量建構函式,以減少需求引數的數量:

vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);


向量是一種靈活的資料型別,我們可以把用在各種輸入和輸出上。學完教程你會看到很多新穎的管理向量的例子。

輸入與輸出

雖然著色器是各自獨立的小程式,但是它們都是一個整體的一部分,出於這樣的原因,我們希望每個著色器都有輸入和輸出,這樣才能進行資料交流和傳遞。GLSL定義了in和out關鍵字專門來實現這個目的。每個著色器使用這兩個關鍵字設定輸入和輸出,只要一個輸出變數與下一個著色器階段的輸入匹配,它就會傳遞下去。但在頂點和片段著色器中會有點不同。

頂點著色器應該接收的是一種特殊形式的輸入,否則就會效率低下。頂點著色器的輸入特殊在,它從頂點資料中直接接收輸入。為了定義頂點資料該如何管理,我們使用location這一元資料指定輸入變數,這樣我們才可以在CPU上配置頂點屬性。我們已經在前面的教程看過這個了,layout (location = 0)。頂點著色器需要為它的輸入提供一個額外的layout標識,這樣我們才能把它連結到頂點資料。

你也可以忽略layout (location = 0)識別符號,通過在OpenGL程式碼中使用glGetAttribLocation查詢屬性位置值(Location),但是我更喜歡在著色器中設定它們,這樣會更容易理解而且節省你(和OpenGL)的工作量。

另一個例外是片段著色器,它需要一個vec4顏色輸出變數,因為片段著色器需要生成一個最終輸出的顏色。如果你在片段著色器沒有定義輸出顏色,OpenGL會把你的物體渲染為黑色(或白色)。

所以,如果我們打算從一個著色器向另一個著色器傳送資料,我們必須在傳送方著色器中宣告一個輸出,在接收方著色器中宣告一個類似的輸入。當型別和名字都一樣的時候,OpenGL就會把兩個變數連結到一起,它們之間就能傳送資料了(這是在連結程式物件時完成的)。為了展示這是如何工作的,我們會稍微改動一下之前教程裡的那個著色器,讓頂點著色器為片段著色器決定顏色。

頂點著色器

#version 330 core
layout (location = 0) in vec3 aPos; // 位置變數的屬性位置值為0

out vec4 vertexColor; // 為片段著色器指定一個顏色輸出

void main()
{
    gl_Position = vec4(aPos, 1.0); // 注意我們如何把一個vec3作為vec4的構造器的引數
    vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // 把輸出變數設定為暗紅色
}

片段著色器

#version 330 core
out vec4 FragColor;

in vec4 vertexColor; // 從頂點著色器傳來的輸入變數(名稱相同、型別相同)

void main()
{
    FragColor = vertexColor;
}

你可以看到我們在頂點著色器中聲明瞭一個vertexColor變數作為vec4輸出,並在片段著色器中聲明瞭一個類似的vertexColor。由於它們名字相同且型別相同,片段著色器中的vertexColor就和頂點著色器中的vertexColor連結了。由於我們在頂點著色器中將顏色設定為深紅色,最終的片段也是深紅色的。下面的圖片展示了輸出結果:

在這裡插入圖片描述

完成了!我們成功地從頂點著色器向片段著色器傳送資料。讓我們更上一層樓,看看能否從應用程式中直接給片段著色器傳送一個顏色!

Uniform

Uniform是一種從CPU中的應用向GPU中的著色器傳送資料的方式,但uniform和頂點屬性有些不同。首先,uniform是全域性的(Global)。全域性意味著uniform變數必須在每個著色器程式物件中都是獨一無二的,而且它可以被著色器程式的任意著色器在任意階段訪問。第二,無論你把uniform值設定成什麼,uniform會一直儲存它們的資料,直到它們被重置或更新。

我們可以在一個著色器中新增uniform關鍵字至型別和變數名前來宣告一個GLSL的uniform。從此處開始我們就可以在著色器中使用新宣告的uniform了。我們來看看這次是否能通過uniform設定三角形的顏色:

#version 330 core
out vec4 FragColor;

uniform vec4 ourColor; // 在OpenGL程式程式碼中設定這個變數

void main()
{
    FragColor = ourColor;
}

我們在片段著色器中聲明瞭一個uniform vec4的ourColor,並把片段著色器的輸出顏色設定為uniform值的內容。因為uniform是全域性變數,我們可以在任何著色器中定義它們,而無需通過頂點著色器作為中介。頂點著色器中不需要這個uniform,所以我們不用在那裡定義它。

如果你聲明瞭一個uniform卻在GLSL程式碼中沒用過,編譯器會靜默移除這個變數,導致最後編譯出的版本中並不會包含它,這可能導致幾個非常麻煩的錯誤,記住這點!

這個uniform現在還是空的;我們還沒有給它新增任何資料,所以下面我們就做這件事。我們首先需要找到著色器中uniform屬性的索引/位置值。當我們得到uniform的索引/位置值後,我們就可以更新它的值了。這次我們不去給畫素傳遞單獨一個顏色,而是讓它隨著時間改變顏色:

float timeValue = glfwGetTime();
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

首先我們通過glfwGetTime()獲取執行的秒數。然後我們使用sin函式讓顏色在0.0到1.0之間改變,最後將結果儲存到greenValue裡。

接著,我們用glGetUniformLocation查詢uniform ourColor的位置值。我們為查詢函式提供著色器程式和uniform的名字(這是我們希望獲得的位置值的來源)。如果glGetUniformLocation返回-1就代表沒有找到這個位置值。最後,我們可以通過glUniform4f函式設定uniform值。注意,查詢uniform地址不要求你之前使用過著色器程式,但是更新一個uniform之前你必須先使用程式(呼叫glUseProgram),因為它是在當前啟用的著色器程式中設定uniform的。

因為OpenGL在其核心是一個C庫,所以它不支援型別過載,在函式引數不同的時候就要為其定義新的函式;glUniform是一個典型例子。這個函式有一個特定的字尾,標識設定的uniform的型別。可能的字尾有:

字尾

含義

f 函式需要一個float作為它的值 i 函式需要一個int作為它的值 ui 函式需要一個unsigned int作為它的值 3f 函式需要3個float作為它的值 fv 函式需要一個float向量/陣列作為它的值

每當你打算配置一個OpenGL的選項時就可以簡單地根據這些規則選擇適合你的資料型別的過載函式。在我們的例子裡,我們希望分別設定uniform的4個float值,所以我們通過glUniform4f傳遞我們的資料(注意,我們也可以使用fv版本)。

現在你知道如何設定uniform變數的值了,我們可以使用它們來渲染了。如果我們打算讓顏色慢慢變化,我們就要在遊戲迴圈的每一次迭代中(所以他會逐幀改變)更新這個uniform,否則三角形就不會改變顏色。下面我們就計算greenValue然後每個渲染迭代都更新這個uniform:

while(!glfwWindowShouldClose(window))
{
    // 輸入
    processInput(window);

    // 渲染
    // 清除顏色緩衝
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    // 記得啟用著色器
    glUseProgram(shaderProgram);

    // 更新uniform顏色
    float timeValue = glfwGetTime();
    float greenValue = sin(timeValue) / 2.0f + 0.5f;
    int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
    glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

    // 繪製三角形
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // 交換緩衝並查詢IO事件
    glfwSwapBuffers(window);
    glfwPollEvents();
}

這裡的程式碼對之前程式碼是一次非常直接的修改。這次,我們在每次迭代繪製三角形前先更新uniform值。如果你正確更新了uniform,你會看到你的三角形逐漸由綠變黑再變回綠色。

可以看到,uniform對於設定一個在渲染迭代中會改變的屬性是一個非常有用的工具,它也是一個在程式和著色器間資料互動的很好工具,但假如我們打算為每個頂點設定一個顏色的時候該怎麼辦?這種情況下,我們就不得不宣告和頂點數目一樣多的uniform了。在這一問題上更好的解決方案是在頂點屬性中包含更多的資料,這是我們接下來要做的事情。

更多屬性!

在前面的教程中,我們瞭解瞭如何填充VBO、配置頂點屬性指標以及如何把它們都儲存到一個VAO裡。這次,我們同樣打算把顏色資料加進頂點資料中。我們將把顏色資料新增為3個float值至vertices陣列。我們將把三角形的三個角分別指定為紅色、綠色和藍色:

float vertices[] = {
    // 位置              // 顏色
     0.5f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,   // 右下
    -0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,   // 左下
     0.0f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f    // 頂部
};

由於現在有更多的資料要傳送到頂點著色器,我們有必要去調整一下頂點著色器,使它能夠接收顏色值作為一個頂點屬性輸入。需要注意的是我們用layout識別符號來把aColor屬性的位置值設定為1:

#version 330 core
layout (location = 0) in vec3 aPos;   // 位置變數的屬性位置值為 0 
layout (location = 1) in vec3 aColor; // 顏色變數的屬性位置值為 1

out vec3 ourColor; // 向片段著色器輸出一個顏色

void main()
{
    gl_Position = vec4(aPos, 1.0);
    ourColor = aColor; // 將ourColor設定為我們從頂點資料那裡得到的輸入顏色
}


由於我們不再使用uniform來傳遞片段的顏色了,現在使用ourColor輸出變數,我們必須再修改一下片段著色器:

#version 330 core
out vec4 FragColor;  
in vec3 ourColor;

void main()
{
    FragColor = vec4(ourColor, 1.0);
}

因為我們添加了另一個頂點屬性,並且更新了VBO的記憶體,我們就必須重新配置頂點屬性指標。更新後的VBO記憶體中的資料現在看起來像這樣:

知道了現在使用的佈局,我們就可以使用glVertexAttribPointer函式更新頂點格式, // 位置屬性

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 顏色屬性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3* sizeof(float)));
glEnableVertexAttribArray(1);


glVertexAttribPointer函式的前幾個引數比較明瞭。這次我們配置屬性位置值為1的頂點屬性。顏色值有3個float那麼大,我們不去標準化這些值。

由於我們現在有了兩個頂點屬性,我們不得不重新計算步長值。為獲得資料佇列中下一個屬性值(比如位置向量的下個x分量)我們必須向右移動6個float,其中3個是位置值,另外3個是顏色值。這使我們的步長值為6乘以float的位元組數(=24位元組)。 同樣,這次我們必須指定一個偏移量。對於每個頂點來說,位置頂點屬性在前,所以它的偏移量是0。顏色屬性緊隨位置資料之後,所以偏移量就是3 * sizeof(float),用位元組來計算就是12位元組。

執行程式你應該會看到如下結果:

在這裡插入圖片描述

這個圖片可能不是你所期望的那種,因為我們只提供了3個顏色,而不是我們現在看到的大調色盤。這是在片段著色器中進行的所謂片段插值(Fragment Interpolation)的結果。當渲染一個三角形時,光柵化(Rasterization)階段通常會造成比原指定頂點更多的片段。光柵會根據每個片段在三角形形狀上所處相對位置決定這些片段的位置。 基於這些位置,它會插值(Interpolate)所有片段著色器的輸入變數。比如說,我們有一個線段,上面的端點是綠色的,下面的端點是藍色的。如果一個片段著色器線上段的70%的位置執行,它的顏色輸入屬性就會是一個綠色和藍色的線性結合;更精確地說就是30%藍 + 70%綠。

這正是在這個三角形中發生了什麼。我們有3個頂點,和相應的3個顏色,從這個三角形的畫素來看它可能包含50000左右的片段,片段著色器為這些畫素進行插值顏色。如果你仔細看這些顏色就應該能明白了:紅首先變成到紫再變為藍色。片段插值會被應用到片段著色器的所有輸入屬性上。

我們自己的著色器類

編寫、編譯、管理著色器是件麻煩事。在著色器主題的最後,我們會寫一個類來讓我們的生活輕鬆一點,它可以從硬碟讀取著色器,然後編譯並連結它們,並對它們進行錯誤檢測,這就變得很好用了。這也會讓你瞭解該如何封裝目前所學的知識到一個抽象物件中。

我們會把著色器類全部放在在標頭檔案裡,主要是為了學習用途,當然也方便移植。我們先來新增必要的include,並定義類結構:

#ifndef SHADER_H
#define SHADER_H

#include <glad/glad.h>; // 包含glad來獲取所有的必須OpenGL標頭檔案

#include <string>
#include <fstream>
#include <sstream>
#include <iostream>


class Shader
{
public:
    // 程式ID
    unsigned int ID;

    // 構造器讀取並構建著色器
    Shader(const GLchar* vertexPath, const GLchar* fragmentPath);
    // 使用/啟用程式
    void use();
    // uniform工具函式
    void setBool(const std::string &name, bool value) const;  
    void setInt(const std::string &name, int value) const;   
    void setFloat(const std::string &name, float value) const;
};

#endif

在上面,我們在標頭檔案頂部使用了幾個預處理指令(Preprocessor Directives)。這些預處理指令會告知你的編譯器只在它沒被包含過的情況下才包含和編譯這個標頭檔案,即使多個檔案都包含了這個著色器標頭檔案。它是用來防止連結衝突的。

著色器類儲存了著色器程式的ID。它的構造器需要頂點和片段著色器原始碼的檔案路徑,這樣我們就可以把原始碼的文字檔案儲存在硬碟上了。除此之外,為了讓我們的生活更輕鬆一點,還加入了一些工具函式:use用來啟用著色器程式,所有的set…函式能夠查詢一個unform的位置值並設定它的值。

從檔案讀取

我們使用C++檔案流讀取著色器內容,儲存到幾個string物件裡:

Shader(const char* vertexPath, const char* fragmentPath)
{
    // 1. 從檔案路徑中獲取頂點/片段著色器
    std::string vertexCode;
    std::string fragmentCode;
    std::ifstream vShaderFile;
    std::ifstream fShaderFile;
    // 保證ifstream物件可以丟擲異常:
    vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
    fShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
    try 
    {
        // 開啟檔案
        vShaderFile.open(vertexPath);
        fShaderFile.open(fragmentPath);
        std::stringstream vShaderStream, fShaderStream;
        // 讀取檔案的緩衝內容到資料流中
        vShaderStream << vShaderFile.rdbuf();
        fShaderStream << fShaderFile.rdbuf();       
        // 關閉檔案處理器
        vShaderFile.close();
        fShaderFile.close();
        // 轉換資料流到string
        vertexCode   = vShaderStream.str();
        fragmentCode = fShaderStream.str();     
    }
    catch(std::ifstream::failure e)
    {
        std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
    }
    const char* vShaderCode = vertexCode.c_str();
    const char* fShaderCode = fragmentCode.c_str();
    [...]

下一步,我們需要編譯和連結著色器。注意,我們也將檢查編譯/連結是否失敗,如果失敗則列印編譯時錯誤,除錯的時候這些錯誤輸出會及其重要(你總會需要這些錯誤日誌的): // 2. 編譯著色器

unsigned int vertex, fragment;
int success;
char infoLog[512];

// 頂點著色器
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
// 列印編譯錯誤(如果有的話)
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if(!success)
{
    glGetShaderInfoLog(vertex, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
};

// 片段著色器也類似 […]

// 著色器程式

ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
// 列印連線錯誤(如果有的話)
glGetProgramiv(ID, GL_LINK_STATUS, &success);
if(!success)
{
    glGetProgramInfoLog(ID, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}

// 刪除著色器,它們已經連結到我們的程式中了,已經不再需要了

glDeleteShader(vertex);
glDeleteShader(fragment);


use函式非常簡單:

void use() 
{ 
    glUseProgram(ID);
}

uniform的setter函式也很類似:

void setBool(const std::string &name, bool value) const
{
    glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value); 
}
void setInt(const std::string &name, int value) const
{ 
    glUniform1i(glGetUniformLocation(ID, name.c_str()), value); 
}
void setFloat(const std::string &name, float value) const
{ 
    glUniform1f(glGetUniformLocation(ID, name.c_str()), value); 
} 

現在我們就寫完了一個完整的著色器類。使用這個著色器類很簡單;只要建立一個著色器物件,從那一點開始我們就可以開始使用了:

Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.fs");
...
while(...)
{
    ourShader.use();
    ourShader.setFloat("someUniform", 1.0f);
    DrawStuff();
}

我們把頂點和片段著色器儲存為兩個叫做shader.vs和shader.fs的檔案。你可以使用自己喜歡的名字命名著色器檔案;我自己覺得用.vs和.fs作為副檔名很直觀。