前言
在開始之前,先來看一段圖像解碼序列(格式為YUV420)的4個渲染結果,這裡我分别截了4張圖

其中4個渲染效果分别是
左上:直接渲染視訊幀并繪制到視窗上
右上:九宮格縮放繪制幀緻視窗上
左下:對視訊幀進行2D變換并繪制到視窗上
右下:渲染視訊幀并繪制到3D變換立方體的6個面上
試着想一下,如果在CPU端進行圖像處理,比如用C/C++實作,包括上述4種效果會涉及到的格式轉換、2D/3D變換、立方體貼圖、無鋸齒縮放等操作,實作的複雜度和代碼量如何,會涉及哪些知識?
如果直接使用OpenGL,實作的複雜度和代碼量又該如何?
問題
- 何種場景下更适合使用OpenGL?
- OpenGL程式設計與CPU程式設計的差別?
- 如何快速入門編寫OpenGL程式?
看完此文,或許你會覺得原來渲染并沒有想像的那麼難!
從C/C++開始
考慮上面的例子,都需要将輸入圖像序列的YUV420格式轉成RGBA32位進行後期渲染和顯示,顔色轉換比如像下面這樣實作
/**
* @param dst 輸出圖像位址RGBA
* @param data 輸入圖像位址YUV420P
* @param width 圖像寬度
* @param height 圖像高度
* @param coef YUV轉RGB的顔色矩陣,支援BT601/709/2020
*/
void yuv420p_2_rgba(uint8_t* dst, uint8_t* data, int width, int height, float coef[9])
{
for(int i = 0; i < (height >> 1); ++i) // 一次處理2行
{
for(int j = 0; j < (width >> 1); ++j) // 一交處理2列
{
auto py = data + width * 2 * i + j * 2; // 擷取左上角Y位址
auto u = data[width * height + i * (width >> 1) + j] - 128; // 擷取U值
auto v = data[width * height * 5 / 4 + i * (width >> 1) + j] - 128; // 擷取V值
// 奇數行奇數列
for(int k = 0; k < 3; ++k)
{
dst[i * width * 8 + j * 8 + k] = clamp(coef[k*3] * py[0] + coef[k*3+1] * u + coef[k*3+2] * v, 0, 255);
}
// 奇數行偶數列
for(int k = 0; k < 3; ++k)
{
dst[i * width * 8 + j * 8 + k + 4] = clamp(coef[k*3] * py[1] + coef[k*3+1] * u + coef[k*3+2] * v, 0, 255);
}
// Y位址下移一行
py += width;
// 偶數行奇數列
for(int k = 0; k < 3; ++k)
{
dst[i * width * 8 + j * 8 + k + width * 4] = clamp(coef[k*3] * py[0] + coef[k*3+1] * u + coef[k*3+2] * v, 0, 255);
}
// 偶數行偶數列
for(int k = 0; k < 3; ++k)
{
dst[i * width * 8 + j * 8 + k + width * 4 + 4] = clamp(coef[k*3] * py[1] + coef[k*3+1] * u + coef[k*3+2] * v, 0, 255);
}
}
}
}
上述C/C++代碼的實作是一個初級版本,如果希望更高性能的運作在CPU上,還需要進行類似彙編優化、多線程優化(如OpenMP)等,但即便這樣,對于解碼4K的圖像,運作在8核心超線程且主頻3.4GHz的CPU上,仍然無法滿足低延時計算的要求。
當C/C++實作的性能無法達到要求時,還可以采用彙編優化、多線程優化等方法,但複雜度會大大增加。
對于這一類計算密集型的工作,下面我們将看到更适合采用GPU進行處理,同時無須像CPU實作那樣需要關注過多的細節。
OpenGL實作
與CPU的計算過程類似,可以将OpenGL了解為一個子產品,我們通過設定給OpenGL子產品相應的參數,并拿到處理的結果。
- 紋理
類似CPU上的記憶體,需要建立相應的GPU顯存用于存儲圖像資料,如上所舉例子,YUV420P存在三片記憶體,分别是Y、U、V,是以需要建立三張顯存
glGenTextures(3, texture);
for (int i = 0; i < 3; ++i)
{
glBindTexture(GL_TEXTURE_2D, texture[i]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, texture_width[i], texture_height[i], 0, GL_RED, GL_UNSIGNED_BYTE, 0);
ASSERT_GL();
}
上述代碼是建立三張顯存,分别是Y、U、V,同時指定紋理縮小、放大使用線性插值,WRAP采樣使用邊緣像素顔色值。
當需要将CPU的記憶體資料上傳到GPU顯存中時,隻需要将記憶體指針和相應的寬高格式等資訊傳遞給glTexImage2D接口就行,如下
for (int i = 0; i < 3; ++i)
{
glBindTexture(GL_TEXTURE_2D, texture[i]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, texture_width[i], texture_height[i], 0, GL_RED, GL_UNSIGNED_BYTE, addressof(buffer[planar_offset[i]]));
ASSERT_GL();
}
這裡可以看兩次操作都需要使用glBindTexture接口,這是因為需要更新OpenGL内部TEXTURE_2D的目前綁定對象狀态。
CPU上我們可以直接逐像素甚至逐位元組的操作,但GPU不行,由于GPU内部是像素多線程并行處理,是以我們實際上是通過可程式設計一段可執行程式并發送給GPU處理的,可執行程式的生成分為兩步。
- 頂點着色器
由于不是像CPU上逐像素操作那樣程式設計,我們隻需要将渲染的頂點邊界值告訴GPU,GPU内部會自動幫我們對邊界以内的像素位置進行計算
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 TexCoord;
void main()
{
gl_Position = vec4(aPos, 0.0, 1.0);
TexCoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);
}
上述代碼示例了一個頂點着色器的實作,雙稱vertex shader,顧名思義,即是對像素頂點進行操作,其中aPos和aTexCoord分别是外部輸入給GPU的頂點位置和坐标,main函數計算更新後的位置和坐标,并發送給片段着色器。
- 片段着色器
#version 330 core
out vec4 FragColor;
in vec2 TexCoord;
uniform sampler2D texY;
uniform sampler2D texU;
uniform sampler2D texV;
uniform mat3 coef;
void main()
{
float y = (texture(texY, TexCoord).r - 16.0/255.0) * (255.0/219.0);
float u = (texture(texU, TexCoord).r - 0.5) * (255.0/224.0);
float v = (texture(texV, TexCoord).r - 0.5) * (255.0/224.0);
FragColor = vec4(coef * vec3(y, u, v), 1.0);
}
這裡可以看到,輸入給片段着色器的是TexCoord,即頂點着色器的輸出,這裡uniform表示可能随時會發生變化的變量,需要外部渲染前設定更新,main函數輸出計算後的像素顔色值。
- 可執行程式
當完成了頂點着色器和片段着色器後,我們就可以将兩者編譯并連結成可實際在GPU上運作的可執行程式了。
GLuint GenerateProgram(char const* vertexShaderSource, char const* fragmentShaderSource)
{
// 編譯頂點着色器
int vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
int success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}
// 編譯片段着色器
int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}
// 連結頂點着色器和片段着色器
int shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if (!success)
{
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
return shaderProgram;
}
就像編譯C++代碼一樣,先分别編譯頂點和片段着色器,生成目标檔案,再将兩個目标檔案連結成可執行程式。
-
頂點數組對象
又稱為VAO(Vertex Array Object),當我們生成可執行程式并發送給GPU後,還需要将頂點和紋理坐标等資訊告訴Program,VAO使用如下方法生成
// *INDENT-OFF*
float vertices[] =
{
// 頂點位置 // 紋理坐标
-1.0f, -1.0f, 0.0f, 0.0f, // bottom left
1.0f, -1.0f, 1.0f, 0.0f, // bottom right
-1.0f, 1.0f, 0.0f, 1.0f, // top left
1.0f, 1.0f, 1.0f, 1.0f, // top right
};
// *INDENT-ON*
glGenVertexArrays(1, &VAO[kRenderNormal]);
glGenBuffers(1, &VBO[kRenderNormal]);
glBindVertexArray(VAO[kRenderNormal]);
glBindBuffer(GL_ARRAY_BUFFER, VBO[kRenderNormal]);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 頂點位置屬性
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 紋理坐标屬性
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float)));
glEnableVertexAttribArray(1);
glBindVertexArray(0);
ASSERT_GL();
先建立VAO,再綁定VAO到目前使用,再建立相應的VBO并綁定到VAO上,注意這裡的頂點位置歸一化範圍為【-1,1】,紋理坐标歸一化範圍為【0,1】,其中左下角為原點坐标【0,0】,這與通常了解的視窗坐标系統和圖像坐标系不同。
這裡可以将VAO了解成一個對象,我們通過調用OpenGL的接口分别更新這個對象的參數值,尤其是頂點位置和紋理坐标固定的情況下,我們隻需要從CPU到GPU上傳一次資料,減少性能開銷。
-
視口
渲染之前,還需要告訴OpenGL渲染到視窗的具體位置和區域
glViewport(0, 0, width, height);
- 渲染
當上述OpenGL資源都準備好後,我們就可以開始渲染了
glUseProgram(program[render_mode]);
auto loc = glGetUniformLocation(program[kRenderNormal], "coef");
glUniformMatrix3fv(loc, 1, GL_FALSE, yuva_to_rgba_709);
ASSERT_GL();
for (int i = 0; i < 3; ++i)
{
glActiveTexture(GL_TEXTURE0 + i);
glBindTexture(GL_TEXTURE_2D, texture[i]);
auto loc = glGetUniformLocation(program[kRenderNormal], texture_name[i]);
glUniform1i(loc, i);
ASSERT_GL();
}
glBindVertexArray(VAO[render_mode]);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
ASSERT_GL();
glBindVertexArray(0);
如上代碼所示,先将CPU上計算好的值更新到GPU中的Program中,再采用相應的Draw接口,此時OpenGL就渲染完成。
需要注意的是,GL_TEXTURE_2D設定給Program,需要先Active相應的GL_TEXTUREi,再更新位置為索引glUniform1i。
更多渲染實作
-
九宮格
從一屏到九屏,使用OpenGL實作非常簡單,着色器可以複用,我們隻需要将CPU發送給GPU的頂點位置和紋理坐标改變一下即可,這裡我用了EBO實作作為例子,實際也可以使用上述的VBO。
// *INDENT-OFF*
float vertices[] =
{
// 頂點位置 // 紋理坐标
-1.0f, -1.0f, 0.0f, 3.0f, // bottom left
1.0f, -1.0f, 3.0f, 3.0f, // bottom right
-1.0f, 1.0f, 0.0f, 0.0f, // top left
1.0f, 1.0f, 3.0f, 0.0f, // top right
};
// *INDENT-ON*
unsigned char indices[] =
{
0, 1, 2, // first triangle
1, 2, 3 // second triangle
};
glGenVertexArrays(1, &VAO[kRenderRepeat]);
glGenBuffers(1, &VBO[kRenderRepeat]);
glGenBuffers(1, &EBO[kRenderRepeat]);
glBindVertexArray(VAO[kRenderRepeat]);
glBindBuffer(GL_ARRAY_BUFFER, VBO[kRenderRepeat]);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO[kRenderRepeat]);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 頂點位置屬性
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 紋理坐标屬性
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float)));
glEnableVertexAttribArray(1);
glBindVertexArray(0);
ASSERT_GL();
如上代碼所未,最大的不同,即是直接将紋理坐标位置從【0,1】的範圍變成了【0,3】,在渲染時還需要更新紋理的采樣模式
for (int i = 0; i < 3; ++i)
{
glBindTexture(GL_TEXTURE_2D, texture[i]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, texture_width[i], texture_height[i], 0, GL_RED, GL_UNSIGNED_BYTE, addressof(buffer[planar_offset[i]]));
ASSERT_GL();
if (render_mode == kRenderRepeat)
{
glGenerateMipmap(GL_TEXTURE_2D);
}
}
auto wrap_mode = render_mode == kRenderRepeat ? GL_REPEAT : GL_CLAMP_TO_EDGE;
auto filter_mode = render_mode == kRenderRepeat ? GL_NEAREST_MIPMAP_LINEAR : GL_LINEAR;
for (int i = 0; i < 3; ++i)
{
glBindTexture(GL_TEXTURE_2D, texture[i]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter_mode);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap_mode);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap_mode);
ASSERT_GL();
}
上述代碼修改後,由于使用EBO,是以需要采用索引繪制接口進行渲染
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_BYTE, 0);
如此就可以見到9屏的效果了,是不是so easy!
- 立方體
試想一下,如果使用C++實作一個立方體渲染,是不是相當複雜,光是想想空間變換及紋理貼圖這些東西就比較頭疼,但是GPU實作就相當簡單了,我們隻需要多增加幾個三角形繪制
// *INDENT-OFF*
float vertices[] =
{
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f,
0.5f, -0.5f, -0.5f, 1.0f, 0.0f,
0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
0.5f, -0.5f, 0.5f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 1.0f,
0.5f, 0.5f, 0.5f, 1.0f, 1.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 1.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
-0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
-0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
-0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
0.5f, -0.5f, -0.5f, 1.0f, 1.0f,
0.5f, -0.5f, 0.5f, 1.0f, 0.0f,
0.5f, -0.5f, 0.5f, 1.0f, 0.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f,
0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 0.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f
};
我們隻需要在2D渲染的基礎上,增加所有6個面的深度資訊
glDrawArrays(GL_TRIANGLES, 0, 36);
即是簡單的從渲染2個三角形,到改成渲染12個三角形,就完成立方體渲染。
-
2D變換
要讓圖像動起來,我們隻需要将計算逐幀變化的模型、視圖矩陣更新給Program
static glm::mat4 model = glm::mat4(1.0f);
static glm::mat4 view = glm::mat4(1.0f);
if (!pause)
{
model = glm::scale(glm::mat4(1.0f), glm::vec3(sinf(glfwGetTime()), sinf(glfwGetTime()), 1.f));
model = glm::scale(model, glm::vec3((float)window_height / window_width, 1.f, 1.f));
model = glm::rotate(model, glm::radians(-45.0f * (float)glfwGetTime()), glm::vec3(0.0f, 0.0f, 1.0f));
model = glm::scale(model, glm::vec3((float)window_width / window_height, 1.f, 1.f));
}
auto loc = glGetUniformLocation(program[render_mode], "model");
glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(model));
ASSERT_GL();
loc = glGetUniformLocation(program[render_mode], "view");
glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(view));
ASSERT_GL();
-
3D變換
三維變換稍微複雜些,需要考慮深度資訊,同時将計算好的變化的模型、視圖、投影矩陣更新給Program
glm::mat4 projection = glm::perspective(glm::radians(45.0f), (float)window_width / window_height, 0.1f, 100.0f);
loc = glGetUniformLocation(program[kRenderCube], "projection");
glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(projection));
ASSERT_GL();
static glm::mat4 view = glm::mat4(1.0f);
float radius = 5.0f;
float camX = sin(glfwGetTime()) * radius;
float camZ = cos(glfwGetTime()) * radius;
if (!pause)
{
view = glm::lookAt(glm::vec3(camX, 0.0f, camZ), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
}
auto loc = glGetUniformLocation(program[render_mode], "view");
glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(view));
ASSERT_GL();
glm::mat4 model = glm::mat4(1.0f);
float angle = 45.0f;
model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 1.0f, 1.0f));
loc = glGetUniformLocation(program[render_mode], "model");
glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(model));
ASSERT_GL();
總結
本文以執行個體的例子和代碼講解OpenGL的入門概念和實作步驟,附件為實作的具體代碼,更複雜的知識還等待你去發現。