OpenGL 程式設計/Glescraft 1

當今 CPU 和 GPU 的處理能力使人們能夠渲染一個完全由小立方體組成的 3D 世界。一些知名的遊戲,例如 Minecraft(它反過來受到了 Infiniminer 的啟發)和 Voxatron 都是這樣做的。除了這些遊戲所呈現的獨特外觀之外,將自己限制在網格上的立方體允許進行許多簡化。在本教程系列中,我們將瞭解如何管理大量立方體以及如何高效地繪製它們。
要做的第一件重要的事情是將我們要渲染的體素世界細分為易於管理的區塊。這樣,我們可以決定哪些區塊在螢幕上可見,並跳過渲染不可見的區塊。我們還可以透過這種方式管理記憶體資源;例如,我們不需要為 GPU 記憶體中的不可見區塊保留頂點緩衝區物件。但區塊應該足夠大,以至於我們不會花費大部分時間來跟蹤它們。讓我們使區塊的大小可配置,但從 16x16x16(4096)個塊的相對較小的區塊開始。我們將使用一個位元組來儲存一個塊的型別。表示此類區塊的結構將如下所示
#define CX 16
#define CY 16
#define CZ 16
struct chunk {
uint8_t blk[CX][CY][CZ];
GLuint vbo;
int elements;
bool changed;
chunk() {
memset(blk, 0, sizeof(blk));
elements = 0;
changed = true;
glGenBuffers(1, &vbo);
}
~chunk() {
glDeleteBuffers(1, &vbo);
}
uint8_t get(int x, int y, int z) {
return blk[x][y][z];
}
void set(int x, int y, int z, uint8_t type) {
blk[x][y][z] = type;
changed = true;
}
void update() {
changed = false;
// Fill in the VBO here
}
void render() {
if(changed)
update();
// Render the VBO here
}
};
blk 陣列將儲存塊型別。get() 和 set() 函式相當簡單,但在後面會派上用場。當呼叫 set() 函式時,changed 標誌將被設定為 true。當渲染區塊且內容已更改時,這將觸發對 update() 函式的呼叫,該函式將更新頂點緩衝區物件 (VBO)。將體素資料轉換為填充 VBO 的多邊形網格的過程稱為“網格化”;高階演算法被稱為 等值面提取。有關更多詳細資訊,請參閱 0fps 平滑體素地形。
在其他教程中,我們使用 GLfloat 座標來表示頂點、紋理座標、顏色等等。OpenGL 允許我們也使用其他型別。在我們的體素世界中,所有立方體都具有相同的尺寸。因此,我們可以使用整數來表示座標。即使世界可以是無限的,在一個區塊內,我們只需要非常小的整數來表示所有可能的座標。如果我們使用 GLbytes,我們可以有從 -128 到 +127 的座標,這對我們 16x16x16 的區塊來說綽綽有餘!
我們可以在著色器中使用最多四個分量的向量,所以除了我們的 x、y 和 z 座標之外,我們還可以新增另一個位元組的資訊。我們將在這個“座標”中儲存塊的型別。在後面的教程中,我們將使用它來推匯出紋理座標,但現在我們可以使用它為每個型別賦予自己的顏色。
每個頂點只有 4 個位元組,使用 IBO 就不再有太大好處。當我們為立方體的每個面賦予不同的顏色或紋理時,使用 IBO 實際上會增加記憶體使用量。
練習
- 為什麼我們不能使用 GL_BYTE 作為 IBO 索引來渲染我們的區塊?
- 計算渲染一個立方體所需的記憶體,包括使用和不使用 IBO,以及使用和不使用每個面的單獨顏色。
我們將使用一個四維位元組向量。不幸的是,沒有為其提供的預定義型別,但 GLM 允許我們快速建立一個與其他向量型別工作方式相同的型別
typedef glm::tvec4<GLbyte> byte4;
現在我們可以建立一個足夠大的陣列來容納所有頂點,並將其填充。您應該已經知道如何製作一個立方體。如果我們將頂點按正確的順序放置,我們可以使用glEnable(GL_CULL_FACE)來避免繪製立方體的內部面。
void update() {
changed = false;
byte4 vertex[CX * CY * CZ * 6 * 6];
int i = 0;
for(int x = 0; x < CX; x++) {
for(int y = 0; y < CY; y++) {
for(int z = 0; z < CZ; z++) {
uint8_t type = blk[x][y][z];
// Empty block?
if(!type)
continue;
// View from negative x
vertex[i++] = byte4(x, y, z, type);
vertex[i++] = byte4(x, y, z + 1, type);
vertex[i++] = byte4(x, y + 1, z, type);
vertex[i++] = byte4(x, y + 1, z, type);
vertex[i++] = byte4(x, y, z + 1, type);
vertex[i++] = byte4(x, y + 1, z + 1, type);
// View from positive x
vertex[i++] = byte4(x + 1, y, z, type);
vertex[i++] = byte4(x + 1, y + 1, z, type);
vertex[i++] = byte4(x + 1, y, z + 1, type);
vertex[i++] = byte4(x + 1, y + 1, z, type);
vertex[i++] = byte4(x + 1, y + 1, z + 1, type);
vertex[i++] = byte4(x + 1, y , z + 1, type);
// Repeat for +y, -y, +z, and -z directions
...
}
}
}
elements = i;
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, elements * sizeof *vertex, vertex, GL_STATIC_DRAW);
}
練習
- 編寫程式碼來建立 y 和 z 方向的面上的頂點。
- 為六個面中的每一個編寫程式碼非常繁瑣。你能想到一個更好的方法嗎?
在我們能夠繪製任何東西之前,這裡是我們繪製體素所需的著色器。首先是頂點著色器
#version 120
attribute vec4 coord;
uniform mat4 mvp;
varying vec4 texcoord;
void main(void) {
texcoord = coord;
gl_Position = mvp * vec4(coord.xyz, 1);
}
頂點透過屬性進入頂點著色器coord。您應該建立一個模型-檢視-投影矩陣,它作為統一變數傳遞mvp。頂點著色器將透過變化量將輸入座標未經修改地傳遞給片段著色器texcoord。一個基本的片段著色器如下所示
#version 120
varying vec4 texcoord;
void main(void) {
gl_FragColor = vec4(texcoord.w / 128.0, texcoord.w / 256.0, texcoord.w / 512.0, 1.0);
}
請記住我們使用的是 GL_BYTEs,因此“w”座標在 0..255 範圍內。OpenGL 不會神奇地將其對映到 0..1 範圍內,因此我們必須將其除以獲得片段顏色可用的值。
練習
- 這些著色器是否只適用於我們的byte4頂點?
渲染整個區塊現在非常容易。我們只需將圖形卡指向我們的 VBO,並讓它繪製所有三角形
void render() {
if(changed)
update();
// If this chunk is empty, we don't need to draw anything.
if(!elements)
return;
glEnable(GL_CULL_FACE);
glEnable(GL_DEPTH_TEST);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glVertexAttribPointer(attribute_coord, 4, GL_BYTE, GL_FALSE, 0, 0);
glDrawArrays(GL_TRIANGLES, 0, elements);
}
練習
- 建立一個區塊,set()塊到隨機值,定位一個相機,並將其渲染。
- 將相機放在區塊內部。嘗試開啟和關閉 GL_CULL_FACE。
現在我們已經知道如何繪製一個區塊,我們希望繪製很多區塊。有許多方法可以管理區塊的集合;您可以只使用區塊的三維陣列,或使用 八叉樹 結構,或者您可以擁有一個數據庫來儲存您擁有的任何區塊。例如,Minecraft 曾經將區塊儲存在硬碟上,區塊的座標被編碼在檔名中,基本上使用檔案系統作為資料庫。
讓我們建立一個“超級區塊”,它基本上是一個指向普通區塊的三維陣列指標。非常天真地,它看起來像這樣
#define SCX 16
#define SCY 16
#define SCZ 16
struct superchunk {
chunk *c[SCX][SCY][SCZ];
superchunk() {
memset(c, 0, sizeof c);
}
~superchunk() {
for(int x = 0; x < SCX; x++)
for(int y = 0; y < SCX; y++)
for(int z = 0; z < SCX; z++)
delete c[x][y][z];
}
uint8_t get(int x, int y, int z) {
int cx = x / CX;
int cy = y / CY;
int cz = z / CZ;
x %= CX;
y %= CY;
z %= CZ;
if(!c[cx][cy][cz])
return 0;
else
return c[cx][cy][cz]->get(x, y, z);
}
void set(int x, int y, int z, uint8_t type) {
int cx = x / CX;
int cy = y / CY;
int cz = z / CZ;
x %= CX;
y %= CY;
z %= CZ;
if(!c[cx][cy][cz])
c[cx][cy][cz] = new chunk();
c[cx][cy][cz]->set(x, y, z, type);
}
void render() {
for(int x = 0; x < SCX; x++)
for(int y = 0; y < SCY; y++)
for(int z = 0; z < SCZ; z++)
if(c[x][y][z])
c[x][y][z]->render();
}
};
基本上,超級區塊也實現了get(), set()和render()普通區塊具有的函式,並將這些函式委託給相應的區塊(s)。上面的示例中的render()函式缺少一個重要功能:它應該在渲染每個區塊之前更改模型矩陣,以便它們被翻譯到正確的位置,否則它們都會被繪製在彼此的頂部
if(c[x][y][z]) {
glm::mat4 model = glm::translate(glm::mat4(1), glm::vec3(x * CX, y * CY, z * CZ));
// calculate the full MVP matrix here and pass it to the vertex shader
c[x][y][z]->render();
}
練習
- 建立一個超級區塊,set()塊到隨機值,定位一個相機,並將其渲染。
- 透過使用 柏林噪聲 或 單純噪聲 為每個 x、z 座標計算高度來建立一個景觀(例如,使用 GLM 的glm::simplex()函式)。
- 找出 地球 的總表面積是多少。如果我們的體素是 1 立方米,那麼你需要多少個體素才能覆蓋地球?僅使用每個體素一個位元組,這是否適合您的計算機硬碟驅動器?