入門-05.著色器

著色器(Shader)

著色器是運行在GPU上的小程序。這些小程序為圖形渲染管線的一個特定部分而運行。從基本意義上來說,著色器不是別的,只是一種把輸入轉(zhuǎn)化為輸出的程序。著色器也是一種相當(dāng)獨立的程序,它們不能相互通信;只能通過輸入和輸出的方式來進行溝通。

GLSL

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

  • 著色器的開頭總是要聲明版本,接著是輸入和輸出變量、uniform和main函數(shù)。
  • 每個著色器的入口都是main函數(shù),在這里我們處理所有輸入變量,用輸出變量輸出結(jié)果。

一個典型的著色器有下面的結(jié)構(gòu):

#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;
}

當(dāng)我們談?wù)撎貏e是談到頂點著色器的時候,每個輸入變量也叫頂點屬性(Vertex Attribute)。能聲明多少個頂點屬性是由硬件決定的。OpenGL確保至少有16個包含4個元素的頂點屬性可用,但是有些硬件或許可用更多,你可以查詢GL_MAX_VERTEX_ATTRIBS來獲取這個數(shù)目。

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

數(shù)據(jù)類型

GLSL有C風(fēng)格的默認基礎(chǔ)數(shù)據(jù)類型:int、float、double、uint和bool。GLSL也有兩種容器類型,教程中我們會使用很多,它們是向量(Vector)和矩陣(Matrix),其中矩陣我們會在之后的教程里再討論。

向量(Vector)

GLSL中的向量可以包含有1、2、3或者4個分量,分量類型可以是前面默認基礎(chǔ)類型的任意一個。它們可以是下面的形式(n代表元素數(shù)量):

類型 含義
vecn 包含n個默認為float元素的向量
bvecn 包含n個布爾元素向量
ivecn 包含n個int元素的向量
uvecn 包含n個unsigned int元素的向量
dvecn 包含n個double元素的向量

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

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

向量的數(shù)據(jù)類型也允許一些有趣而靈活的元素選擇方式,叫做重組(Swizzling)。重組允許這樣的語法:

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

我們可以把一個向量作為一個參數(shù)傳給不同的向量構(gòu)造函數(shù),以減少參數(shù)需求的數(shù)量:

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

向量是一種靈活的數(shù)據(jù)類型,我們可以把它用在所有輸入和輸出上。

輸入與輸出(Ins and outs)

著色器是各自獨立的小程序,但是它們都是一個整體的局部,出于這樣的原因,我們希望每個著色器都有輸入和輸出,這樣才能進行數(shù)據(jù)交流和傳遞。

GLSL定義了in和out關(guān)鍵字來實現(xiàn)這個目的。每個著色器使用這些關(guān)鍵字定義輸入和輸出,無論在哪兒,一個輸出變量就能與一個下一個階段的輸入變量相匹配。他們在頂點和片段著色器之間有點不同。

頂點著色器應(yīng)該接收的輸入是一種特有形式,否則就會效率低下。頂點著色器的輸入是特殊的,它所接受的是從頂點數(shù)據(jù)直接輸入的。為了定義頂點數(shù)據(jù)被如何組織,我們使用location元數(shù)據(jù)指定輸入變量,這樣我們才可以在CPU上配置頂點屬性。我們已經(jīng)在前面的教程看過layout (location = 0)。頂點著色器需要為它的輸入提供一個額外的layout定義,這樣我們才能把它鏈接到頂點數(shù)據(jù)。

也可以移除layout (location = 0),通過在OpenGL代碼中使用glGetAttribLocation請求屬性地址(Location),但是我更喜歡在著色器中設(shè)置它們,理解容易而且節(jié)省時間。

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

所以,如果我們打算從一個著色器向另一個著色器發(fā)送數(shù)據(jù),我們必須在發(fā)送方著色器中聲明一個輸出,在接收方著色器中聲明一個同名輸入。當(dāng)名字和類型都一樣的時候,OpenGL就會把兩個變量鏈接到一起,它們之間就能發(fā)送數(shù)據(jù)了(這是在鏈接程序(Program)對象時完成的)。為了展示這是這么工作的,我們會改變前面教程里的那個著色器,讓頂點著色器為片段著色器決定顏色。

// 頂點著色器
#version 330 core
layout (location = 0) in vec3 position; // 位置變量的屬性為0

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

void main()
{
    gl_Position = vec4(position, 1.0); // 把一個vec3作為vec4的構(gòu)造器的參數(shù)
    vertexColor = vec4(0.5f, 0.0f, 0.0f, 1.0f); // 把輸出顏色設(shè)置為暗紅色
}

// 片段著色器
#version 330 core
in vec4 vertexColor; // 和頂點著色器的vertexColor變量類型相同、名稱相同

out vec4 color; // 片段著色器輸出的變量名可以任意命名,類型必須是vec4

void main()
{
    color = vertexColor;
}

你可以看到我們在頂點著色器中聲明了一個vertexColor變量作為vec4輸出,在片段著色器聲明了一個一樣的vertexColor。由于它們類型相同并且名字也相同,片段著色器中的vertexColor就和頂點著色器中的vertexColor鏈接了。下面的圖片展示了輸出結(jié)果:

代碼在這

讓我們更上一層樓,看看能否從應(yīng)用程序中直接給片段著色器發(fā)送一個顏色!

Uniform

uniform是另一種從CPU應(yīng)用向GPU著色器發(fā)送數(shù)據(jù)的方式,但uniform和頂點屬性有點不同。首先,uniform是全局的(Global)。這里全局的意思是uniform變量必須在所有著色器程序?qū)ο笾卸际仟氁粺o二的,它可以在著色器程序的任何著色器任何階段使用。第二,無論你把uniform值設(shè)置成什么,uniform會一直保存它們的數(shù)據(jù),直到它們被重置或更新。

我們可以簡單地通過在片段著色器中設(shè)置uniform關(guān)鍵字接類型和變量名來聲明一個GLSL的uniform。之后,我們可以在著色器中使用新聲明的uniform了。

我們來看看這次是否能通過uniform設(shè)置三角形的顏色:

#version 330 core
out vec4 color;

uniform vec4 ourColor; //在程序代碼中設(shè)置

void main()
{
    color = ourColor;
} 

我們在片段著色器中聲明了一個uniform vec4的ourColor,并把片段著色器的輸出顏色設(shè)置為uniform值。因為uniform是全局變量,我們我們可以在任何著色器中定義它們,而無需通過頂點著色器作為中介。頂點著色器中不需要這個uniform所以不用在那里定義它。

如果你聲明了一個uniform卻在GLSL代碼中沒用過,編譯器會靜默移除這個變量,從而最后編譯出的版本中并不會包含它,如果有一個從沒用過的uniform出現(xiàn)在已編譯版本中會出現(xiàn)錯誤,記住這點!

uniform現(xiàn)在還是空的;我們沒有給它添加任何數(shù)據(jù),所以下面就做這件事。我們首先需要找到著色器中uniform的索引/地址。當(dāng)我們得到uniform的索引/地址后,我們就可以更新它的值了。這里我們不去給像素傳遞一個顏色,而是隨著時間讓它改變顏色:

GLfloat timeValue = glfwGetTime();
GLfloat greenValue = (sin(timeValue) / 2) + 0.5;
GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

首先我們通過glfwGetTime()獲取運行的秒數(shù)。然后我們使用余弦函數(shù)在0.0到-1.0之間改變顏色,最后儲存到greenValue里。

接著,我們用glGetUniformLocation請求uniform ourColor的地址。我們?yōu)檎埱蠛瘮?shù)提供著色器程序和uniform的名字(這是我們希望獲得的地址的來源)。如果glGetUniformLocation返回-1就代表沒有找到這個地址。最后,我們可以通過glUniform4f函數(shù)設(shè)置uniform值。注意,查詢uniform地址不需要在之前使用著色器程序,但是更新一個unform之前必須使用程序(調(diào)用glUseProgram),因為它是在當(dāng)前激活的著色器程序中設(shè)置unform的。

因為OpenGL是C庫內(nèi)核,所以它不支持函數(shù)重載,在函數(shù)參數(shù)不同的時候就要定義新的函數(shù);glUniform是一個典型例子。這個函數(shù)有一個特定的作為類型的后綴。有幾種可用的后綴:
|后綴|含義|
|=====|===============|
|f|函數(shù)需要一個float作為它的值|
|i|函數(shù)需要一個int作為它的值|
|ui|函數(shù)需要一個unsigned int作為它的值|
|3f|函數(shù)需要3個float作為它的值|
|fv|函數(shù)需要一個float向量/數(shù)組作為它的值|
在我們的例子里,我們使用uniform的4float版,所以我們通過glUniform4f傳遞我們的數(shù)據(jù)(注意,我們也可以使用fv版本)。

如果我們打算讓顏色慢慢變化,我們就要在游戲循環(huán)的每一幀更新這個uniform,否則三角形就不會改變顏色。下面我們就計算greenValue然后每個渲染迭代都更新這個uniform:

while(!glfwWindowShouldClose(window))
{
    // 檢測事件
    glfwPollEvents();

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

    // 激活著色器
    glUseProgram(shaderProgram);

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

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

如果你成功更新uniform了,你會看到你的三角形逐漸由綠變黑再變綠。
完整代碼在這里

就像你所看到的那樣,uniform是個設(shè)置屬性的很有用的工具,它可以在渲染循環(huán)中改變,也可以在你的應(yīng)用和著色器之間進行數(shù)據(jù)交互,但假如我們打算為每個頂點設(shè)置一個顏色的時候該怎么辦?這種情況下,我們就不得不聲明和頂點數(shù)目一樣多的uniform了。在頂點屬性問題上一個更好的解決方案一定要能包含足夠多的數(shù)據(jù),這是我們接下來要講的內(nèi)容。。

更多屬性

們將把顏色數(shù)據(jù)表示為3個float的頂點數(shù)組(Vertex Array)。我們?yōu)槿切蔚拿總€角分別指定為紅色、綠色和藍色:

GLfloat 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    // 頂部
};

由于我們現(xiàn)在發(fā)送到頂點著色器的數(shù)據(jù)更多了,有必要調(diào)整頂點著色器,使它能夠把顏色值作為一個頂點屬性輸入。需要注意的是我們用layout標識符來吧color屬性的location設(shè)置為1:

#version 330 core
layout (location = 0) in vec3 position; // 位置變量的屬性position為 0 
layout (location = 1) in vec3 color;    // 顏色變量的屬性position為 1

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

void main()
{
    gl_Position = vec4(position, 1.0);
    ourColor = color; // 把ourColor設(shè)置為我們從頂點數(shù)據(jù)那里得到的輸入顏色
}

由于我們不再使用uniform來傳遞片段的顏色了,現(xiàn)在使用的ourColor輸出變量要求必須也去改變片段著色器:

#version 330 core
in vec3 ourColor
out vec4 color;
void main()
{
    color = vec4(ourColor, 1.0f);
}

因為我們添加了另一個頂點屬性,并且更新了VBO的內(nèi)存,我們就必須重新配置頂點屬性指針。更新后的VBO內(nèi)存中的數(shù)據(jù)現(xiàn)在看起來像這樣:

Image 006.png

知道了當(dāng)前使用的layout,我們就可以使用glVertexAttribPointer函數(shù)更新頂點格式,

// 頂點屬性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
// 顏色屬性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)(3* sizeof(GLfloat)));
glEnableVertexAttribArray(1);

glVertexAttribPointer函數(shù)的前幾個參數(shù)比較明了。這次我們配置屬性location為1的頂點屬性。顏色值有3個float那么大,我們不去標準化這些值。

由于我們現(xiàn)在有了兩個頂點屬性,我們不得不重新計算步長值(Stride)。為獲得數(shù)據(jù)隊列中下一個屬性值(比如位置向量的下個x元素)我們必須向右移動6個float,其中3個是位置值,另外三個是顏色值。這給了我們6個步長的大小,每個步長都是float的字節(jié)數(shù)(=24字節(jié))。

同樣,這次我們必須指定一個偏移量(Offset)。對于每個頂點來說,位置(Position)頂點屬性是先聲明的,所以它的偏移量是0。顏色屬性緊隨位置數(shù)據(jù)之后,所以偏移量就是3sizeof(GLfloat)*,用字節(jié)來計算就是12字節(jié)。
運行應(yīng)用你會看到如下結(jié)果:

Image 007.png

代碼在這里

這個圖片可能不是你所期望的那種,因為我們只提供3個顏色,而不是我們現(xiàn)在看到的大調(diào)色板。這是所謂片段著色器進行片段插值(Fragment Interpolation)的結(jié)果。當(dāng)渲染一個三角形在像素化(Rasterization 也譯為光柵化)階段通常生成比原來的頂點更多的像素。像素器就會基于每個像素在三角形的所處相對位置決定像素的位置?;谶@些位置,它插入(Interpolate)所有片段著色器的輸入變量。

比如說,我們有一個線段,上面的那個點是綠色的,下面的點是藍色的。如果一個片段著色器正在處理的那個片段(實際上就是像素)是在線段的70%的位置,它的顏色輸入屬性就會是一個綠色和藍色的線性結(jié)合;更精確地說就是30%藍+70%綠。

Fragment interpolation is applied to all the fragment shader's input attributes.
片段插值會應(yīng)用到所有片段著色器的輸入屬性上。

Our own shader class

在著色器的最后主題里,我們會寫一個類來讓我們的生活輕松一點,這個類從硬盤讀著色器,然后編譯和鏈接它們,對它們進行錯誤檢測,這就變得很好用了。

我們會在頭文件里創(chuàng)建整個類,主要為了學(xué)習(xí),也可以方便移植。我們先來添加必要的include,定義類結(jié)構(gòu):

#ifndef SHADER_H
#define SHADER_H

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

using namespace std;

#include <GL/glew.h>; // 包含glew獲取所有的OpenGL必要headers

class Shader
{
public:
        // 程序ID
        GLuint Program;
        // 構(gòu)造器讀取并創(chuàng)建Shader
        Shader(const GLchar * vertexSourcePath, const GLchar * fragmentSourcePath);
        // 使用Program
        void Use();
};

#endif

在上面,我們用了幾個預(yù)處理指令(Preprocessor Directives)。這些預(yù)處理指令告知你的編譯器,只在沒被包含過的情況下才包含和編譯這個頭文件,即使多個文件都包含了這個shader頭文件,它是用來防止鏈接沖突的。

shader類保留了著色器程序的ID。它的構(gòu)造器需要頂點和片段著色器源代碼的文件路徑,我們可以把各自的文本文件儲存在硬盤上。Use函數(shù)看似平常,但是能夠顯示這個自造類如何讓我們的生活變輕松(雖然只有一點)。

  • 從文件讀取

我們使用C++文件流讀取著色器內(nèi)容,儲存到幾個string對象里,

Shader(const GLchar* vertexPath, const GLchar* fragmentPath)
{
    // 1. Retrieve the vertex/fragment source code from filePath
    std::string vertexCode;
    std::string fragmentCode;
    std::ifstream vShaderFile;
    std::ifstream fShaderFile;
    // ensures ifstream objects can throw exceptions:
    vShaderFile.exceptions(std::ifstream::badbit);
    fShaderFile.exceptions(std::ifstream::badbit);
    try 
    {
        // Open files
        vShaderFile.open(vertexPath);
        fShaderFile.open(fragmentPath);
        std::stringstream vShaderStream, fShaderStream;
        // Read file's buffer contents into streams
        vShaderStream << vShaderFile.rdbuf();
        fShaderStream << fShaderFile.rdbuf();       
        // close file handlers
        vShaderFile.close();
        fShaderFile.close();
        // Convert stream into GLchar array
        vertexCode = vShaderStream.str();
        fragmentCode = fShaderStream.str();     
    }
    catch(std::ifstream::failure e)
    {
        std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
    }
    const GLchar* vShaderCode = vertexCode.c_str();
    const GLchar* fShaderCode = fragmentCode.c_str();
    [...]
    ```
    
下一步,我們需要編譯和鏈接著色器。注意,我們也要檢查編譯/鏈接是否失敗,如果失敗,打印編譯錯誤,調(diào)試的時候這及其重要(這些錯誤日志你總會需要的):

```c++
// 2. Compile shaders
GLuint vertex, fragment;
GLint success;
GLchar infoLog[512];
   
// Vertex Shader
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
// Print compile errors if any
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if(!success)
{
    glGetShaderInfoLog(vertex, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
};
  
// Similiar for Fragment Shader
[...]
  
// Shader Program
this->Program = glCreateProgram();
glAttachShader(this->Program, vertex);
glAttachShader(this->Program, fragment);
glLinkProgram(this->Program);
// Print linking errors if any
glGetProgramiv(this->Program, GL_LINK_STATUS, &success);
if(!success)
{
    glGetProgramInfoLog(this->Program, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
  
// Delete the shaders as they're linked into our program now and no longer necessery
glDeleteShader(vertex);
glDeleteShader(fragment);

最后我們也要實現(xiàn)Use函數(shù):

void Use() { glUseProgram(this->Program); }  

現(xiàn)在我們寫完了一個完整的著色器類。使用著色器類很簡單;我們創(chuàng)建一個著色器對象以后,就可以簡單的使用了:

Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.frag");
...
while(...)
{
    ourShader.Use();
    glUniform1f(glGetUniformLocation(ourShader.Program, "someUniform"), 1.0f);
    DrawStuff();
}

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

Image 007.png

Source code of the program with new shader class, the shader class, the vertex shader and the fragment shader.

練習(xí)

  1. 修改頂點著色器讓三角形上下顛倒:參考解答
Image 015.png
  1. 通過使用uniform定義一個水平偏移,在頂點著色器中使用這個偏移量把三角形移動到屏幕右側(cè):參考解答
Image 014.png
  1. 使用out關(guān)鍵字把頂點位置輸出到片段著色器,把像素的顏色設(shè)置為與頂點位置相等(看看頂點位置值是如何在三角形中進行插值的)。做完這些后,嘗試回答下面的問題:為什么在三角形的左下角是黑的?:參考解答
Image 013.png

因為在坐標軸的左下方,x,y值均小于0,又因為z等于0,所以,對應(yīng)的顏色值RGB為(0,0,0)黑色,所以,在左下角是黑的。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容