關(guān)鍵英文術(shù)語
- Vertex Array Object(VAO) 頂點數(shù)組對象
- Vertex Buffer Object(VBO) 頂點緩沖對象
- Element Buffer Object(EBO) 索引緩沖對象
你好,三角形
在OpenGL中,任何事物都在3D空間中,而屏幕和窗口卻是2D像素數(shù)組,這導(dǎo)致OpenGL的大部分工作都是關(guān)于把3D坐標轉(zhuǎn)換為適配你屏幕的2D像素。
3D坐標轉(zhuǎn)換為2D坐標的處理過程就是OpenGL的圖形渲染管線(Graphics Pipeline), 實際上指的就是一堆原始圖形數(shù)據(jù)經(jīng)過一個輸送管道,期間經(jīng)過各種變化處理,最終出現(xiàn)在屏幕上的過程。
Shader
圖形渲染管線可以分為幾個階段,每個階段將會把前一個階段的輸出作為輸入。所有這些階段都是高度專門化的,并且很容易被并行執(zhí)行。由于并行的特性,當今大多數(shù)的顯卡都具有成千上萬的小處理核心,他們在GPU上為每一個階段(渲染管線)運行著自己的小程序,從而來快速處理你的數(shù)據(jù)。這些小程序叫做著色器(Shader)。
有些著色器允許開發(fā)者自己配置,這就允許我們用自己編寫的著色器程序來替換默認的。
下圖抽象化展示了圖形渲染管線的每個階段。注意藍色的部分代表可編程(自定義著色器)的階段。

頂點著色器(vertex shader), 它把一個單獨的頂點作為輸入。頂點著色器主要的目的是把3D坐標轉(zhuǎn)換到ClipSpace中。
圖元裝配(Primitive Assembly)階段將頂點著色器輸出的所有頂點作為輸入,并將所有的點裝配為指定的圖元形狀(GL_POINTS, GL_TRIANGLES, GL_LINE_STRIP等形狀)。
圖元裝配階段的輸出將會傳遞給幾何著色器(Geometry Shader)。它把圖元形式的一系列頂點的集合作為輸入,可以通過產(chǎn)生新頂點構(gòu)造出新圖元來生成其他形狀。(具體實現(xiàn)?)
幾何著色器的輸出將會傳遞給光柵化(Rasterization Stage)階段, 這里它會把圖元映射到最終屏幕上相應(yīng)的像素,生成供片段著色器(Fragment Shader)使用的片段。在片段著色器運行之前會執(zhí)行裁切(Clipping)。裁切會丟棄超出你的視圖以外的所有像素,用于提升執(zhí)行效率。(如何實現(xiàn)圖元到像素的映射?)
片段著色器的主要目的是計算一個像素的最終顏色,這也是所有OpenGL高級效果產(chǎn)生的地方。
在所有對應(yīng)的顏色值確定之后,最終的對象會被傳到最后一個階段,我們叫做Alpha測試和混合(Blending)階段。這個階段還會檢測片段的對應(yīng)深度(和模板(stencil))值,用它們來判斷這個像素是在其他物體的前面還是后面,最后決定是否應(yīng)該丟棄。這個階段也會檢測alpha值并對物體進行混合(blend)。(模板檢測作用?)
程序?qū)崿F(xiàn)
頂點輸入
由于我們希望渲染一個三角形,我們一共要指定三個頂點,每個頂點都有一個3d位置。我們會將它們以標準化設(shè)備坐標的形式(OpenGl可見區(qū)域)定義一個float數(shù)組。
float vertices [] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
標準化設(shè)備坐標(Normalized Device Coordinates, NDC)
一旦你的頂點坐標已經(jīng)在頂點著色器中處理過,它們就應(yīng)該是標準化設(shè)備坐標了,標準化設(shè)備坐標是一個x、y和z值在-1.0到1.0的一小段空間。任何落在范圍外的坐標都會被丟棄/裁剪,不會顯示在你的屏幕上。下面你會看到我們定義的在標準化設(shè)備坐標中的三角形(忽略z軸):
NDC與通常的屏幕坐標不同,y軸正方向為向上,(0, 0)坐標是這個圖像的中心,而不是左上角。最終你希望所有(變換過的)坐標都在這個坐標空間中,否則它們就不可見了。
你的標準化設(shè)備坐標接著會變換為屏幕空間坐標(Screen-space Coordinates),這是使用你通過glViewport函數(shù)提供的數(shù)據(jù),進行視口變換(Viewport Transform)完成的。所得的屏幕空間坐標又會被變換為片段輸入到片段著色器中。
定義了這樣的頂點數(shù)據(jù)之后,我們會把它作為輸入發(fā)送給圖形渲染管線的第一個處理階段: 頂點著色器。它會在GPU上創(chuàng)建內(nèi)存用于存儲我們的頂點數(shù)據(jù),還有配置OpenGL如何解釋這些內(nèi)容,并且指定其如何發(fā)送給顯卡。頂點著色器接著會處理我們在內(nèi)存中指定數(shù)量的頂點。
我們通過VBO來管理內(nèi)存,它在GPU中可以存儲大量頂點。使用這些緩沖對象的好處是我們可以一次性發(fā)送一大批數(shù)據(jù)到GPU,而不是分批一個個發(fā)送。因為CPU發(fā)送數(shù)據(jù)到GPU是相對較慢的,所以我們盡量一次性發(fā)送足夠多的數(shù)據(jù)。
創(chuàng)建VBO對象
unsigned int VBO;
glGenBuffers(1, &VBO);//(定義一個地址存儲buffer數(shù)據(jù))
將新創(chuàng)建的緩沖綁定到GL_ARRAY_BUFFER目標上
glBindBuffer(GL_ARRAY_BUFFER, VBO);// 指定buffer類型為GL_ARRAY_BUFFER
從這一刻起,我們使用的任何在GL_ARRAY_BUFFER目標上的緩沖調(diào)用都會用來設(shè)置當前綁定的緩沖(VBO)。然后我們可以調(diào)用glBufferData函數(shù),它會將之前定義的頂點數(shù)據(jù)復(fù)制到緩沖的內(nèi)存中。
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW)//填充GL_ARRAY_BUFFER類型數(shù)據(jù),數(shù)據(jù)將會存在我們綁定的GL_ARRAY_BUFFER類型vbo中。
它的第一個參數(shù)是目標緩沖的類型:頂點緩沖對象當前綁定到GL_ARRAY_BUFFER目標上。第二個參數(shù)指定傳輸數(shù)據(jù)的大小(以字節(jié)為單位);用一個簡單的sizeof計算出頂點數(shù)據(jù)大小就行。第三個參數(shù)是我們希望發(fā)送的實際數(shù)據(jù)。
第四個參數(shù)指定了我們希望顯卡如何管理給定的數(shù)據(jù)。它有三種形式:
- GL_STATIC_DRAW :數(shù)據(jù)不會或幾乎不會改變。
- GL_DYNAMIC_DRAW:數(shù)據(jù)會被改變很多。
- GL_STREAM_DRAW :數(shù)據(jù)每次繪制時都會改變。
現(xiàn)在我們已經(jīng)把頂點數(shù)據(jù)儲存在顯卡的內(nèi)存中,用VBO這個頂點緩沖對象管理。下面我們會創(chuàng)建一個頂點和片段著色器來真正處理這些數(shù)據(jù)。現(xiàn)在我們開始著手創(chuàng)建它們吧。
頂點著色器
下面是一個最基礎(chǔ)的GLSL的頂點著色器源碼:
#version 330 core
layout (location = 0) in vec3 aPos;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
代碼中使用了一個in關(guān)鍵字,在頂點著色器中聲明了所有的頂點屬性?,F(xiàn)在我們只關(guān)心位置數(shù)據(jù),所以我們只需要一個頂點屬性。由于每個頂點都有一個3D坐標,我們就創(chuàng)建一個vec3輸入變量aPos。我們同樣也通過layout (location = 0)設(shè)定了輸入變量的位置值(Location)你后面會看到為什么我們會需要這個位置值。(注意這個location = 0)
為了設(shè)置頂點著色器的輸出,我們必須把位置數(shù)值賦值給預(yù)定義的gl_position變量,它在幕后是vec4類似。(為什么把w分量設(shè)置為1.0f?)
在真實的程序里輸入數(shù)據(jù)通常都不是標準化設(shè)備坐標,所以我們首先必須先把它們轉(zhuǎn)換到OpenGL的可視區(qū)域內(nèi)。(坐標轉(zhuǎn)換)
gl_Position屬于頂點著色器階段的GL內(nèi)建變量,其輸出值代表的是頂點在裁剪空間(Clip Space)中的值。
詳細可見官方描述Built-in Variable (GLSL) - OpenGL Wiki (khronos.org)
編譯著色器
現(xiàn)在,我們暫時將頂點著色器的源代碼硬編碼在代碼文件頂部的C風格字符串中:
const char *vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
為了能夠讓OpenGL使用它,我們必須在運行時動態(tài)編譯它的源代碼。
首先要做的是創(chuàng)建一個著色器對象,注意還是用ID來引用的。
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
把創(chuàng)建的著色器類型以參數(shù)形式提供給 glCreateShader。因為我們正在創(chuàng)建一個頂點著色器,所以傳遞的參數(shù)是GL_VERTEX_SHADER。
下一步,我們將著色器源碼附加到著色器對象上,然后編譯它。
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
編譯結(jié)果檢測方式(fail or succes)略過。
片段著色器
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
聲明輸出的變量可以用out關(guān)鍵字,這里我們命名為FragColor。
這里我們輸出的是不透明的橘黃色。
編譯過程和頂點著色器類似,略過。
著色器程序
著色器程序?qū)ο?Shader Program Object)是多個著色器合并之后并最終鏈接完成的版本。如果要使用剛才編譯的著色器我們必須把它們鏈接(Link)為一個著色器程序?qū)ο?,然后在渲染對象的時候激活這個著色器程序。已激活著色器程序的著色器將在我們發(fā)送渲染調(diào)用的時候被使用。
當鏈接著色器時,若輸出和輸入不匹配時,你將得到一個鏈接錯誤。
鏈接程序如下:
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
鏈接成功后,我們需要激活這個程序?qū)ο蟆?/p>
glUseProgram(shaderProgram);
在glUseProgram函數(shù)調(diào)用之后,每個著色器調(diào)用和渲染調(diào)用都會使用這個程序?qū)ο螅ㄒ簿褪侵皩懙闹?了。
現(xiàn)在,我們已經(jīng)將頂點數(shù)據(jù)發(fā)送給了GPU,并創(chuàng)建了自定義著色器程序,并將其激活。但還未結(jié)束,OpenGL還不知道它該如何解釋GPU內(nèi)存中的頂點數(shù)據(jù),以及它將頂點數(shù)據(jù)鏈接到頂點著色器屬性上。我們需要告訴OpenGL怎么做。
鏈接頂點屬性
我們的頂點緩沖數(shù)據(jù)會被解析為下面樣子:

- 位置數(shù)據(jù)被儲存為32位(4字節(jié))浮點值。
- 每個位置包含3個這樣的值。
- 在這3個值之間沒有空隙(或其他值)。這幾個值在 數(shù)組中緊密排列(Tightly Packed)。
- 數(shù)據(jù)中第一個值在緩沖開始的位置。
有了這些信息我們就可以使用glVertexAttribPointer函數(shù)告訴OpenGL該如何解析頂點數(shù)據(jù)了:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer函數(shù)的參數(shù)非常多,所以我會逐一介紹它們:
- 第一個參數(shù)指定我們要配置的頂點屬性。我們在頂點著色器中使用了
layout(location = 0)定義了position頂點屬性的位置值(location)。它可以把頂點屬性的位置值設(shè)置為0。因為我們希望把數(shù)據(jù)傳遞到這一個頂點屬性中,所以這里我們傳入0。 - 第二個參數(shù)指定了頂點屬性的大小,頂點屬性是一個
vec3, 其由3個值構(gòu)成,所以傳3。 - 第三個參數(shù)指定了數(shù)據(jù)的類型,這里是GL_FLOAT。因為GLSL中vec*都是由浮點值組成。
- 第四個參數(shù)定義我們是否需要數(shù)據(jù)被標準化(Normalize)。如果我們設(shè)置為GL_TRUE, 所有數(shù)據(jù)都會被映射到0(對于有符號型signed數(shù)據(jù)是-1)到1之間。我們把它設(shè)置為GL_FALSE。
- 第五個參數(shù)叫做步長(stride),它告訴我們在連續(xù)的頂點屬性組之間的間隔。下個頂點數(shù)據(jù)是在3個
float后,所以我們將步長設(shè)置為3*sizeof(float)。要注意的是由于我們知道這個數(shù)組是緊密排列的(在兩個頂點屬性之間沒有空隙)我們也可以設(shè)置為0來讓OpenGL決定具體步長是多少(只有當數(shù)值是緊密排列時才可用)。一旦我們有更多的頂點屬性,我們就必須更小心地定義每個頂點屬性之間的間隔,我們在后面會看到更多的例子(譯注: 這個參數(shù)的意思簡單說就是從這個屬性第二次出現(xiàn)的地方到整個數(shù)組0位置之間有多少字節(jié))。 - 最后一個參數(shù)的類型是
void *,所以我們需要進行這個奇怪的強制類型轉(zhuǎn)換。它表示位置數(shù)據(jù)在緩沖中起始位置的便移量(offset)。由于位置數(shù)據(jù)在數(shù)組的開頭,所以這里是0。我們會在后面詳細解釋這個參數(shù)。
現(xiàn)在我們已經(jīng)定義了OpenGL該如何解釋頂點數(shù)據(jù),我們現(xiàn)在應(yīng)該使用glEnableVertexAttribArray,以頂點屬性位置值作為參數(shù),啟用頂點屬性。
注意: 默認情況下,出于性能考慮,所有頂點著色器的屬性(Attribute)變量都是關(guān)閉的,意味著數(shù)據(jù)在著色器端是不可見的,哪怕數(shù)據(jù)已經(jīng)上傳到GPU,由glEnableVertexAttribArray啟用指定屬性,才可在頂點著色器中訪問逐頂點的屬性數(shù)據(jù)。glVertexAttribPointer或VBO只是建立CPU和GPU之間的邏輯連接,從而實現(xiàn)了CPU數(shù)據(jù)上傳至GPU。但是,數(shù)據(jù)在GPU端是否可見,即,著色器能否讀取到數(shù)據(jù),由是否啟用了對應(yīng)的屬性決定,這就是glEnableVertexAttribArray的功能,允許頂點著色器讀取GPU(服務(wù)器端)數(shù)據(jù)。
為了避免每當我們繪制一個物體的時重復(fù)執(zhí)行buffer創(chuàng)建,數(shù)據(jù)綁定,頂點屬性設(shè)置,頂點屬性啟用這一過程。我們自然想到,有沒有一些方法可以使我們把所有這些狀態(tài)配置在這樣一個對象中, 并且可以通過綁定這個對象來恢復(fù)狀態(tài)呢?
頂點數(shù)組對象
VAO可以像頂點緩沖對象那樣被綁定,任何隨后的頂點屬性調(diào)用都會存儲在這個VAO中。這樣的好處就是,當配置頂點屬性指針時,你只需要將那些調(diào)用執(zhí)行一次,之后再繪制物體的時候只需要綁定相應(yīng)的VAO就行了。
一個頂點數(shù)組對象會儲存以下這些內(nèi)容:
- glEnableVertexAttribArray和glDisableVertexAttribArray的調(diào)用。
- 通過glVertexAttribPointer設(shè)置的頂點屬性配置。
- 通過glVertexAttribPointer調(diào)用與頂點屬性關(guān)聯(lián)的頂點緩沖對象。

創(chuàng)建過程如下,和VBO類似。
unsigned int VAO;
glGenVertexArrays(1, &VAO);
// ..:: 初始化代碼(只運行一次 (除非你的物體頻繁改變)) :: ..
// 1. 綁定VAO
glBindVertexArray(VAO);
// 2. 把頂點數(shù)組復(fù)制到緩沖中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 設(shè)置頂點屬性指針
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
[...]
// ..:: 繪制代碼(渲染循環(huán)中) :: ..
// 4. 繪制物體
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();
EBO索引緩沖對象略。
三角形正式繪制完整代碼:
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <iostream>
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void processInput(GLFWwindow *window);
// settings
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;
const char *vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
const char *fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
" FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";
int main()
{
// glfw: initialize and configure
// ------------------------------
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif
// glfw window creation
// --------------------
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
// glad: load all OpenGL function pointers
// ---------------------------------------
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
// build and compile our shader program
// ------------------------------------
// vertex shader
unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
// check for shader compile errors
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;
}
// fragment shader
unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
// check for shader compile errors
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}
// link shaders
unsigned int shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
// check for linking errors
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);
// set up vertex data (and buffer(s)) and configure vertex attributes
// ------------------------------------------------------------------
float vertices[] = {
-0.5f, -0.5f, 0.0f, // left
0.5f, -0.5f, 0.0f, // right
0.0f, 0.5f, 0.0f // top
};
unsigned int VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// bind the Vertex Array Object first, then bind and set vertex buffer(s), and then configure vertex attributes(s).
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// note that this is allowed, the call to glVertexAttribPointer registered VBO as the vertex attribute's bound vertex buffer object so afterwards we can safely unbind
glBindBuffer(GL_ARRAY_BUFFER, 0);
// You can unbind the VAO afterwards so other VAO calls won't accidentally modify this VAO, but this rarely happens. Modifying other
// VAOs requires a call to glBindVertexArray anyways so we generally don't unbind VAOs (nor VBOs) when it's not directly necessary.
glBindVertexArray(0);
// uncomment this call to draw in wireframe polygons.
//glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
// render loop
// -----------
while (!glfwWindowShouldClose(window))
{
// input
// -----
processInput(window);
// render
// ------
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// draw our first triangle
glUseProgram(shaderProgram);
glBindVertexArray(VAO); // seeing as we only have a single VAO there's no need to bind it every time, but we'll do so to keep things a bit more organized
glDrawArrays(GL_TRIANGLES, 0, 3);
// glBindVertexArray(0); // no need to unbind it every time
// glfw: swap buffers and poll IO events (keys pressed/released, mouse moved etc.)
// -------------------------------------------------------------------------------
glfwSwapBuffers(window);
glfwPollEvents();
}
// optional: de-allocate all resources once they've outlived their purpose:
// ------------------------------------------------------------------------
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glDeleteProgram(shaderProgram);
// glfw: terminate, clearing all previously allocated GLFW resources.
// ------------------------------------------------------------------
glfwTerminate();
return 0;
}
// process all input: query GLFW whether relevant keys are pressed/released this frame and react accordingly
// ---------------------------------------------------------------------------------------------------------
void processInput(GLFWwindow *window)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
}
// glfw: whenever the window size changed (by OS or user resize) this callback function executes
// ---------------------------------------------------------------------------------------------
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
// make sure the viewport matches the new window dimensions; note that width and
// height will be significantly larger than specified on retina displays.
glViewport(0, 0, width, height);
}
參考
https://learnopengl-cn.github.io/01%20Getting%20started/04%20Hello%20Triangle/
https://blog.csdn.net/gongxun1994/article/details/78271870
細說圖形學(xué)渲染管線 https://positiveczp.github.io/%E7%BB%86%E8%AF%B4%E5%9B%BE%E5%BD%A2%E5%AD%A6%E6%B8%B2%E6%9F%93%E7%AE%A1%E7%BA%BF.pdf
