前言
紋理貼圖是在光柵話的模型表面覆蓋圖像的技術。他是為渲染場景添加真是感的最基本最重要的方法之一。
紋理貼圖非常重要,因此硬件也提供了實現(xiàn)實時的照片真是感的超高性能。紋理單元是專為紋理設計的硬件組件,現(xiàn)代顯卡通常帶有數(shù)個紋理單元。
關于圖片的加載,本篇博客采用的是 std_image, 在很多關于OpenGL 相關的書籍都使用 SOIL 庫作為工具,而經(jīng)過我一段時間的摸索,SOIL 在M1 芯片的Mac電腦上安裝實在費勁,反正我是沒有安裝成功,如果有朋友安裝成過的可以評論區(qū)留言或者私信,你將獲得拼多多終身砍一刀服務。
紋理坐標
下面你會看到在之前幾篇文章中的三角形,貼上來一張磚墻圖。

為了能夠把紋理映射到三角形上,我們需要制定三角形的每個頂點個字對應紋理的哪個部分。這樣每個頂點就會關聯(lián)著一個紋理坐標,用來表明該紋理圖像的哪個部分采樣。之后通過光柵化過程在其他片段進行線性插值。
紋理坐標在x 和 y軸上,范圍為0到1之間。使用紋理坐標獲取紋理顏色叫做采樣。紋理坐標的起始于(0,0),也就是紋理圖片的左下角,終點為(1,1),即紋理圖片的右上角。下面的圖片展示了我們是如何把紋理坐標映射到三角形上的

我們?yōu)槿切沃贫?個頂點坐標點。如上圖所示,我們希望三角形的左下角對應紋理的左下角,因此我們把三角形左下角的紋理坐標設置為(0,0),同理把右下方的頂點設置為(1, 0)。我們只要給頂點著色器傳遞這三個紋理坐標就行了,接下來他們會被光柵化過程進行線性插值后傳入片段著色器中。
紋理坐標看起來就像這樣:
float texCoords[] = {
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.5f, 1.0f // 上中
};
對紋理采樣的解釋非常寬松,他可以采用幾種不同的插值方式。所以我們需要告訴OpenGL該怎樣對紋理采樣。
紋理環(huán)繞方式
紋理坐標的范圍通常是(0,0)到(1,1),那如果我們把紋理坐標設置在范圍之外會發(fā)生什么?OpenGL默認行為是重復這個紋理圖像,但是OpenGL提供了更多的選擇:
- GL_REPEAT:對紋理的默認行為。重復紋理圖像。
- GL_MIRRORED_REPEAT:和GL_REPEAT一樣,但每次重復圖片是鏡像放置的。
- GL_CLAMP_TO_EDGE:紋理坐標會被約束在0到1之間,超出的部分會重復紋理坐標的邊緣,產(chǎn)生一種邊緣被拉伸的效果。
- GL_CLAMP_TO_BORDER:超出的坐標為用戶指定的邊緣顏色。
當紋理坐標超出默認范圍是,每個選項都有不同的視覺效果輸出。我們來看看這些紋理圖像的例子:

前面提到的每個選項都可以使用glTexParameter*函數(shù)對單獨的一個坐標軸設置(s、t(如果是使用3D紋理那么還有一個r)它們和x、y、z是等價的)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
紋理過濾
紋理坐標不依賴于分辨率,他可以是任意浮點數(shù),所以OpenGL需要知道怎樣將紋理像素映射到紋理坐標。當你有一個很大的物體但是紋理的分辨率很低時,這就變得很重要了。你可能已經(jīng)對于紋理過濾的選項,但是現(xiàn)在我們只討論最重要的兩種:GL_NEAREST 和 GL_LINEAR.
GL_NEAREST(也叫鄰近過濾)是OpenGL默認過濾方式。當設置為 GL_NEAREST的時候,OpenGL會選擇中心點最接近紋理坐標的那個像素,加號代表紋理坐標

GL_LINEAR(也叫線性過濾)它會基于紋理坐標附近的紋理像素,計算出一個插值,近似出這些紋理像素之間的顏色。一個紋理像素的中心距離紋理坐標越近,那么這個紋理像素的顏色對最終的樣本顏色的貢獻越大。下圖中你可以看到返回的顏色是鄰近像素的混合色

那么這兩種紋理過濾方式有怎樣的視覺效果呢?讓我們看看在一個很大的物體上應用一張低分辨率的紋理會發(fā)生什么吧(紋理被放大了,每個紋理像素都能看到):

GL_NEAREST產(chǎn)生了顆粒狀的圖案,我們能夠清晰看到組成紋理的像素,而GL_LINEAR能夠產(chǎn)生更平滑的圖案,很難看出單個的紋理像素。GL_LINEAR可以產(chǎn)生更真實的輸出,但有些開發(fā)者更喜歡8-bit風格,所以他們會用GL_NEAREST選項。
當進行放大(Magnify)和縮小(Minify)操作的時候可以設置紋理過濾的選項,比如你可以在紋理被縮小的時候使用鄰近過濾,被放大時使用線性過濾。我們需要使用glTexParameter*函數(shù)為放大和縮小指定過濾方式。這段代碼看起來會和紋理環(huán)繞方式的設置很相似:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
多級漸遠紋理
想象一下,假設我們有一個包含著上千物體的大房間,每個物體上都有紋理。有些物體會很遠,但其紋理會擁有與近處物體同樣高的分辨率。由于遠處的物體可能只產(chǎn)生很少的片段,OpenGL從高分辨率紋理中為這些片段獲取正確的顏色值就很困難,因為它需要對一個跨過紋理很大部分的片段只拾取一個紋理顏色。在小物體上這會產(chǎn)生不真實的感覺,更不用說對它們使用高分辨率紋理浪費內(nèi)存的問題了。
OpenGL使用一種叫做多級漸遠紋理(Mipmap)的概念來解決這個問題,它簡單來說就是一系列的紋理圖像,后一個紋理圖像是前一個的二分之一。多級漸遠紋理背后的理念很簡單:距觀察者的距離超過一定的閾值,OpenGL會使用不同的多級漸遠紋理,即最適合物體的距離的那個。由于距離遠,解析度不高也不會被用戶注意到。同時,多級漸遠紋理另一加分之處是它的性能非常好。讓我們看一下多級漸遠紋理是什么樣子的:

手工為每個紋理圖像創(chuàng)建一系列多級漸遠紋理很麻煩,幸好OpenGL有一個glGenerateMipmaps函數(shù),在創(chuàng)建完一個紋理后調(diào)用它OpenGL就會承擔接下來的所有工作了。后面的教程中你會看到該如何使用它。
在渲染中切換多級漸遠紋理級別(Level)時,OpenGL在兩個不同級別的多級漸遠紋理層之間會產(chǎn)生不真實的生硬邊界。就像普通的紋理過濾一樣,切換多級漸遠紋理級別時你也可以在兩個不同多級漸遠紋理級別之間使用NEAREST和LINEAR過濾。為了指定不同多級漸遠紋理級別之間的過濾方式,你可以使用下面四個選項中的一個代替原有的過濾方式:
- GL_NEAREST_MIPMAP_NEAREST:使用最鄰近的多級漸遠紋理來匹配像素大小,并使用鄰近插值進行紋理采樣
- GL_LINEAR_MIPMAP_NEAREST:使用最鄰近的多級漸遠紋理級別,并使用線性插值進行采樣
- GL_NEAREST_MIPMAP_LINEAR:在兩個最匹配像素大小的多級漸遠紋理之間進行線性插值,使用鄰近插值進行采樣
- GL_LINEAR_MIPMAP_LINEAR:在兩個鄰近的多級漸遠紋理之間使用線性插值,并使用線性插值進行采樣
就像紋理過濾一樣,我們可以使用glTexParameteri將過濾方式設置為前面四種提到的方法之一:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
加載紋理圖像文件
為了在OpenGL/glsl 中有效地完成紋理貼圖,需要協(xié)調(diào)好一下幾個不同的數(shù)據(jù)集和機制:
- 用于保存紋理圖像的紋理對象
- 一個特殊的統(tǒng)一采樣器變量,一遍頂點著色器可以訪問紋理;
- 用于保存紋理坐標的緩沖區(qū);
- 用于將紋理坐標傳遞給管線的頂點屬性;
- 顯卡上的紋理單元。
紋理圖像可以是任何圖像,他可以是人造的或者自然產(chǎn)生的食物的圖片,例如布、草、行星表面;它也可以是幾何圖樣,如下圖中的棋盤圖樣。子啊電子游戲和動畫電影中,紋理圖像通常用于給角色繪制表面和衣服,如下圖中的海豚生物身上繪制皮膚。

圖像通常存儲在圖像文件中,例如 .jpg、.png、.gif或者tiff格式。為了使紋理圖像可以被用于OpenGL管線中的著色器,我們需要從圖像中提取顏色并將它們放入OpenGL紋理對象中。
許多C++庫可以用于讀取和處理圖像文件,我這里選擇使用 std_image庫。通常我們將紋理加載到OpenGL 應用程序的步驟是:
- 使用 stb_image 實例化OpenGL 紋理對象并從圖像文件中讀入數(shù)據(jù);
- 調(diào)用glBindTexture() 以使新創(chuàng)建的紋理對象處于激活狀態(tài);
- 使用gltexParameter()函數(shù)調(diào)整紋理設置。
- 最終使用glTexImage2D()函數(shù) 獲得的結果就是現(xiàn)在可用的OpenGL紋理對象整形ID。
對讀取圖片和創(chuàng)建紋理,我們經(jīng)常使用這個函數(shù),于是做一個簡單的封裝,便于代碼的復用
#include <stdio.h>
#include <string>
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image_write.h"
#define STB_IMAGE_RESIZE_IMPLEMENTATION
#include "stb_image_resize.h"
#include <stdio.h>
#define GLEW_STATIC
#include <GL/glew.h>
using namespace std;
enum UFPixelSpace {
UFPixelSpaceGrayscale = 1, // 灰度圖
UFPixelSpaceGrayscaleAndAlpha, // 灰度加透明度
UFPixelSpaceRGB, // rgb 顏色空間
UFPixelSpaceRGBA, // rgba 顏色空間
};
class UFImage {
public:
unsigned char *data{nullptr}; // 圖片數(shù)據(jù)
UFPixelSpace format; // 圖像色彩空間格式
int width; // 圖像寬度
int height; // 圖像高度
UFImage(std::string path) {
this->path = path;
textureID = 0;
decode();
}
/** 圖片轉(zhuǎn)紋理 */
GLuint glTexture() {
if (textureID) {
return textureID;
}
// 函數(shù)首先需要輸入生成紋理的數(shù)量,然后把它們儲存在第二個參數(shù)的unsigned int數(shù)組中
glGenTextures(1, &textureID);
// 綁定紋理,讓之后任何的紋理指令都可以配置當前綁定的紋理:
glBindTexture(GL_TEXTURE_2D, textureID);
// 紋理過濾器
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, (GLint)GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, (GLint)GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, (GLint)GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, (GLint)GL_CLAMP_TO_EDGE);
// 圖片紋理通過 glTexImage2D 來生成
if (format == UFPixelSpaceRGBA) {
glTexImage2D(GL_TEXTURE_2D, 0, (GLint)GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
} else if (format == UFPixelSpaceRGB) {
glTexImage2D(GL_TEXTURE_2D, 0, (GLint)GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
} else if (format == UFPixelSpaceGrayscale) {
glTexImage2D(GL_TEXTURE_2D, 0, (GLint)GL_RG, width, height, 0, GL_RG, GL_UNSIGNED_BYTE, data);
} else {
glTexImage2D(GL_TEXTURE_2D, 0, (GLint)GL_RED, width, height, 0, GL_RED, GL_UNSIGNED_BYTE, data);
}
return textureID;
}
private:
GLuint textureID;
string path;
void decode() {
int iw, ih, n;
data = stbi_load(path.c_str(), &iw, &ih, &n, 0);
format = static_cast<UFPixelSpace>(n);
width = iw;
height = ih;
}
void destroy() {
if (textureID) {
glDeleteTextures(1, &textureID);
textureID = 0;
}
if (data) {
stbi_image_free(data);
data = NULL;
}
}
};
這樣,我們的C++ 應用程序就只管調(diào)用上述的UFImage 類來創(chuàng)建OpenGL紋理對象,代碼如下:
UFImage *image = new UFImage("image.png");
GLuint texture = image->glTexture();
紋理的應用
我們需要告知OpenGL如何采樣紋理,所以我們必須使用紋理坐標更新頂點數(shù)據(jù):
float vertices[] = {
// ---- 位置 ---- ---- 顏色 ---- - 紋理坐標 -
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, // 左上
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下
};
由于我們添加了一個額外的頂點屬性,我們必須告訴OpenGL我們新的頂點格式:

創(chuàng)建 VBO,VAO,并將頂點數(shù)據(jù)發(fā)送到頂點緩沖區(qū)
// vbo,vao
unsigned int vbo, vao;
glGenBuffers(1, &vbo);
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8*sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3* sizeof(float)));
glEnableVertexAttribArray(1);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8*sizeof(float), (void*)(6* sizeof(float)));
glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
注意,我們同樣需要調(diào)整前面兩個頂點屬性的步長參數(shù)為8 * sizeof(float)。
創(chuàng)建紋理,我們使用上面封裝的類來創(chuàng)建紋理
string path = string("/Users/chenxueming/Desktop/LearnOpenGL/Texture/Texture/container.jpeg");
UFImage *image = new UFImage(path);
GLuint texture = image->glTexture();
注:這里的路徑,調(diào)試過程中需要替換成自己電腦的路徑。
接著我們需要調(diào)整頂點著色器使其能夠接受頂點坐標為一個頂點屬性,并把坐標傳給片段著色器:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;
out vec3 ourColor;
out vec2 TexCoord;
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor;
TexCoord = aTexCoord;
}
片段著色器應該接下來會把輸出變量TexCoord作為輸入變量。
片段著色器也應該能訪問紋理對象,但是我們怎樣能把紋理對象傳給片段著色器呢?GLSL有一個供紋理對象使用的內(nèi)建數(shù)據(jù)類型,叫做采樣器(Sampler),它以紋理類型作為后綴,比如sampler1D、sampler3D,或在我們的例子中的sampler2D。我們可以簡單聲明一個uniform sampler2D把一個紋理添加到片段著色器中,稍后我們會把紋理賦值給這個uniform。
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main()
{
FragColor = texture(ourTexture, TexCoord);
}
我們使用GLSL內(nèi)建的texture函數(shù)來采樣紋理的顏色,它第一個參數(shù)是紋理采樣器,第二個參數(shù)是對應的紋理坐標。texture函數(shù)會使用之前設置的紋理參數(shù)對相應的顏色值進行采樣。這個片段著色器的輸出就是紋理的(插值)紋理坐標上的(過濾后的)顏色。
現(xiàn)在只剩下在調(diào)用 glDrawArrays 之前綁定紋理了,它會自動把紋理賦值給片段著色器的采樣器:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
GLint location = glGetUniformLocation(shader->ID, "ourTexture");
glUniform1i(location, 0);
如果你跟著這個教程正確地做完了,你會看到下面的圖像:

我們還可以把得到的紋理顏色與頂點顏色混合,來獲得更有趣的效果。我們只需把紋理顏色與頂點顏色在片段著色器中相乘來混合二者的顏色:
FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
最終的效果應該是頂點顏色和紋理顏色的混合色:

我猜你會說我們的箱子喜歡跳70年代的迪斯科。
紋理單元
使用glUniform1i,我們可以給紋理采樣器分配一個位置值,這樣的話我們能夠在一個片段著色器中設置多個紋理。一個紋理的位置值通常稱為一個紋理單元。一個紋理的默認紋理單元是0,它是默認的激活紋理單元。
紋理單元的主要目的是讓我們在著色器中可以使用多于一個的紋理。通過把紋理單元賦值給采樣器,我們可以一次綁定多個紋理,只要我們首先激活對應的紋理單元。就像glBindTexture一樣,我們可以使用glActiveTexture激活紋理單元,傳入我們需要使用的紋理單元:
glActiveTexture(GL_TEXTURE0); // 在綁定紋理之前先激活紋理單元
glBindTexture(GL_TEXTURE_2D, texture);
激活紋理單元之后,接下來的glBindTexture函數(shù)調(diào)用會綁定這個紋理到當前激活的紋理單元,紋理單元GL_TEXTURE0默認總是被激活,所以我們在前面的例子里當我們使用glBindTexture的時候,無需激活任何紋理單元。我們?nèi)匀恍枰庉嬈沃鱽斫邮樟硪粋€采樣器。這應該相對來說非常直接了:
最終輸出顏色現(xiàn)在是兩個紋理的結合。GLSL內(nèi)建的mix函數(shù)需要接受兩個值作為參數(shù),并對它們根據(jù)第三個參數(shù)進行線性插值。如果第三個值是0.0,它會返回第一個輸入;如果是1.0,會返回第二個輸入值。0.2會返回80%的第一個輸入顏色和20%的第二個輸入顏色,即返回兩個紋理的混合色。
我們現(xiàn)在需要載入并創(chuàng)建另一個紋理;你應該對這些步驟很熟悉了。記得創(chuàng)建另一個紋理對象,載入圖片,使用glTexImage2D生成最終紋理。對于第二個紋理我們使用一張你學習OpenGL時的面部表情圖片。
為了使用第二個紋理(以及第一個),我們必須改變一點渲染流程,先綁定兩個紋理到對應的紋理單元,然后定義哪個uniform采樣器對應哪個紋理單元:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
我們還要通過使用glUniform1i設置每個采樣器的方式告訴OpenGL每個著色器采樣器屬于哪個紋理單元。我們只需要設置一次即可,所以這個會放在渲染循環(huán)的前面:
ourShader.use(); // 不要忘記在設置uniform變量之前激活著色器程序!
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // 手動設置
ourShader.setInt("texture2", 1); // 或者使用著色器類設置
while(...)
{
[...]
}
通過使用glUniform1i設置采樣器,我們保證了每個uniform采樣器對應著正確的紋理單元。你應該能得到下面的結果:

你可能注意到紋理上下顛倒了!這是因為OpenGL要求y軸0.0坐標是在圖片的底部的,但是圖片的y軸0.0坐標通常在頂部。很幸運,stb_image.h能夠在圖像加載時幫助我們翻轉(zhuǎn)y軸,只需要在UFImage 加載圖像之前加上這行代碼即可
stbi_set_flip_vertically_on_load(true);
在讓stb_image.h在加載圖片時翻轉(zhuǎn)y軸之后你就應該能夠獲得下面的結果了:

如果你看到了一個開心的箱子,你就做對了。
修改片段著色器,僅讓笑臉圖案朝另一個方向看
把紋理的x坐標做一個水平翻轉(zhuǎn)就能實現(xiàn)這一需求:
vec2 faceCoord = vec2(1.0 - TexCoord.x, TexCoord.y);
FragColor = mix(texture(texture1, TexCoord), texture(texture2, faceCoord), 0.2);
看效果:

如果你運行出來有問題或者出錯了,情仔細檢查一下代碼,或者在本文下方下載筆者寫的demo。
代碼示例大多來自于 https://learnopengl-cn.github.io/,作者把相關代碼結構性的封裝了一下,如果想學習更多 OpenGL 以及相關知識,請移步 learnopengl。