コンテンツにスキップ

OpenGLプログラミング/Glescraft 1

出典: フリー教科書『ウィキブックス(Wikibooks)』
A voxel world.

はじめに

[編集]

現代のCPUとGPUで利用可能な処理能力があれば、小さなキューブから構成される3Dの世界を完全にレンダリングすることができます。 これを行うよく知られているゲームは Minecraft (次いでそれに触発されたInfiniminer) と Voxatronです。 これらのゲームが備える独自の外観のほかにも、それ自体をグリッド上のキューブに制限することで、かなりの単純化を可能にしています。 このチュートリアルシリーズでは、膨大な数のキューブを管理できる方法、それらを非常に効率的に描画できる方法について説明していきます。

チャンク

[編集]

最初に行う重要なことは、レンダリングするボクセル世界を、管理しやすいチャンクに細分化することです。 この方法なら、どのチャンクを画面上に表示するかを決定でき、そして見えないチャンクのレンダリングをスキップすることができます。 また、メモリ·リソースをこの方法で管理することもできます。例えば、見えないチャンク頂点バッファオブジェクトをGPUメモリに保持する必要はありません。 しかし、チャンクの追跡にほとんどの時間を費やすわけにはいかないので、十分な大きさでなければなりません。 チャンクのサイズを変更可能にしていきますが、始めは比較的小さな16x16x16(4096)ブロックの塊にしましょう。 ブロックの種類を格納するためにbyteを使用します。 そのようなチャンクを表す構造体は次のようになります:

#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);
    vertices = 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()関数が呼び出されると、変更フラグがtrueに設定されます。 チャンクがレンダリングされているとき内容が変更されると、これがupdate()関数の呼び出しをトリガーしてVBOを更新します。

Only 4 bytes per vertex

[編集]

他のチュートリアルでは、GLfloat座標を頂点、テクスチャ、座標、色、などのためにも使用していました。 OpenGLは、他のタイプも同様に使用することができます。 私たちのボクセル世界では、すべてのキューブは同じ寸法を有しています。 したがって、座標の表現に整数を使用することができます。 世界は無限にできるにもかかわらず、1チャンク内であれば、可能性のあるすべての座標を表現するために、非常に小さな整数しか必要となりません。 GLbytesを使用する場合、-128から+127までの範囲の座標を、持つことができ、16x16x16のチャンクには十分すぎます!

シェーダでは4つのコンポーネントまでのベクトルを使うことができ、X、Y、Zの座標のほかにも、 情報の別のバイトを追加することができます。 私たちは、この "座標 "内にブロックの種類を格納します。 後のチュートリアルでは、テクスチャ座標を導き出すためにこれを使用しますが、今のところは、それぞれのタイプに色を与えるために使用します。

頂点ごとに4バイトの場面では、IBOを使用してもそれほど恩恵は多くありません。 立方体の各面に異なる色や風合いを出すときや、IBOを使用するとき、実際にメモリ使用量が増加します。

エクササイズ:

  • チャンクをレンダリングするときに、IBOインデックス用にGL_BYTEを使用できないのはなぜでしょうか?
  • IBOを使用する場合としない場合、および各面に別々の色を使用する場合としない場合の、1つのキューブの描画にかかるメモリを計算してみましょう。

VBOにつめこむ

[編集]

私たちは、バイトの4次元ベクトルで作業していくことになります。 残念なことに、それに利用するための事前に定義された型はありませんが、GLMは他のベクトル型と同様に、その役割を果たすものを手早く作成することができます:

typedef glm::detail::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++) {
        // Empty block?
        if(!blk[x][y][z])
          continue;

        // View from negative x
        vertex[i++] = byte4(x,     y,     z,     blk[x][y][z]);        
        vertex[i++] = byte4(x,     y,     z + 1, blk[x][y][z]);        
        vertex[i++] = byte4(x,     y + 1, z,     blk[x][y][z]);        
        vertex[i++] = byte4(x,     y + 1, z,     blk[x][y][z]);        
        vertex[i++] = byte4(x,     y,     z + 1, blk[x][y][z]);        
        vertex[i++] = byte4(x,     y + 1, z + 1, blk[x][y][z]);        

        // View from positive x
        vertex[i++] = byte4(x + 1, y,     z,     blk[x][y][z]);        
        vertex[i++] = byte4(x + 1, y + 1, z,     blk[x][y][z]);        
        vertex[i++] = byte4(x + 1, y,     z + 1, blk[x][y][z]);        
        vertex[i++] = byte4(x + 1, y + 1, z,     blk[x][y][z]);        
        vertex[i++] = byte4(x + 1, y + 1, z + 1, blk[x][y][z]);        
        vertex[i++] = byte4(x + 1, y    , z + 1, blk[x][y][z]);        

        // Repeat for y and z directions
        ...
      }
    }
  }

  elements = i;
  glBindBuffer(GL_ARRAY_BUFFER, vbo);
  glBufferData(GL_ARRAY_BUFFER, elements * sizeof *vertex, vertex, GL_STATIC_DRAW);
}

エクササイズ:

  • yおよびz方向の面の頂点を作成するコードを記述してみましょう。
  • 6つの面のそれぞれのコードを記述するのは非常に面倒です。 これを行うときのもう少し良い方法を考えることができるでしょうか?

シェーダ

[編集]

何かを描画する前には、ボクセルを描画するためのシェーダがここで必要になります。 まず、頂点シェーダ:

#version 120

attribute vec4 coord;
uniform mat4 mvp;
varying vec4 texcoord;

void main(void) {
        texcoord = coord;
        gl_Position = mvp * vec4(coord.xyz, 1);
}

頂点は、attribute coordを通して頂点シェーダに入ります。 model-view-projection行列を作成する必要があり、これはuniform mvpとして渡されます。 頂点シェーダは、varying 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をオンとオフにしてみてください。

Many chunks

[編集]

さて、1チャンクを描画する方法がわかったので、いっぱいチャンクを描画してみたくなります。 チャンクのコレクションを管理するには多くの方法があります。ただの三次元配列にしたり、または octree 構造にしたり、 または任意のチャンクを保持するデータベースを持つこともできます。 例えば、 Minecraftは、ハードディスク上にチャンクを格納し、チャンクの座標をファイル名にエンコードして、基本的にファイルシステムをデータベースとして使用しています。

"superchunk"という、基本的には通常のチャンクへのポインタの3次元配列を作成してみましょう。 非常に簡素にするなら、次のようになります:

#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() 関数を備え、そしてそれらの関数を適切なチャンク(複数可)に委譲します。 上記の例で 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()し、カメラを配置し、それをレンダリングしてみましょう。
  • Perlin noise または Simplex noiseを使用した各x、z座標の高さを計算することで、ランドスケープを作成してみましょう (たとえばGLMの glm::simplex()関数を使用して )。
  • Earthの全表面積がどのくらいかを探りあててみましょう。 私たちのボクセルが1立方メートルである場合、地球をカバーするにはどのくらい多くのボクセルが必要になるでしょうか? ボクセルごとに1バイトだけを使用するとして、これはコンピュータのハードディスクに収まるでしょうか?