はじめに
この記事ではopenFrameworksを使用してボクセルの世界を描画します。
開発環境
openFrameworks v0.11.2
macOS monterey 12.0.1
Xcode 13.2.1
1. 最初のステップ
まずはoFに最初から用意されている機能で実現してみます。
ofBox
というまさに立方体を描画するためのメソッドが用意されています。
※今回の記事の本筋とはあまり関係しませんが、簡単に書くために ofEasyCam
を使用します。
これを使うとマウスでグリグリ画面を動かすことができ、
その状態における各種行列の値がシェーダーのuniform変数に自動で割り当てられます。
#pragma once
#include "ofMain.h"
class ofApp : public ofBaseApp {
public:
// ~~~
private:
ofEasyCam m_camera;
};
以下のように三重ループで大量のボックスを描画してみます。
#include "ofApp.h"
//--------------------------------------------------------------
void ofApp::draw() {
glDisable(GL_CULL_FACE);
// FPSの描画
{
char buf[128];
::sprintf(buf, "%.2f", ofGetFrameRate());
ofDrawBitmapString(buf, glm::vec2(50, 50));
}
m_camera.begin();
// ワールドの描画
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
ofEnableDepthTest();
for (int x = 0; x < 100; x += 2) {
for (int y = 0; y < 100; y += 2) {
for (int z = 0; z < 100; z += 2) {
ofBox(glm::vec3(x, y, z), 1.0f);
}
}
}
m_camera.end();
}
実行してみるとわかりますが、描画性能があまり良くありません。
私の環境では 5~10FPS 程度でした。
2. 最適化
なぜ ofBox
ではあまり性能が出なかったのかというと、恐らくドローコールが多かったためです。
ofBox
の実装を追いかけていくと ofGLProgrammableRenderer#draw(const ofMesh & vertexData, ofPolyRenderMode renderType, bool useColors, bool useTextures, bool useNormals)
というメソッドに辿り着きますが、
このメソッドの中で glDrawArrays
または glDrawElements
が1回呼び出されます。
ということは先ほどのコードではこの描画命令が 50^3 回呼び出されていたことになります。
これから行う最適化で、この描画命令を1回に削減します。
インスタンシングという機能を使います。
例えばある形状を複数箇所に描画するとき、以下のような方法が考えられます。
(1) 頂点自体を複製し、オフセットを加算した別の頂点情報を作成する
(2) uniform変数としてオフセットを受け取り、移動させる
(3) インスタンシングを使用してベースとなる頂点情報に加えてオフセットも受け取る
(1)は結局別の場所に描画するたびに頂点情報をバインドし直さなければならず、描画回数の削減ができません。
(2)は少し近いのですがこれだけだと一つ形状を描画するたびに uniform変数 を割り当て直さなければなりません。
(3)が今回行う方法で、頂点情報の attribute とは別にオフセットのための attribute も受け取ります。
ただし、この attribute は頂点と 1:1 で対応する配列ではありません。
オフセットのための attribute は一回インデックスバッファを描画するたびに次の要素に進むイメージです。
例えばプレーンは三角形二つで描画でき、インデックスの長さは6です。なので頂点の attribute が6回進んだ後オフセットのための attribute が1回進みます。
さらに言うなら Box は Plane を6個張り合わせたものなので、これも事前にオフセットと回転を受け取れば上下左右前後に移動できます。
ただし、こちらは vec3
の attribute ではなく float
の attribute として受け取ります。
これに加えて uniform変数 として vec3[6]
と mat4[6]
を予め受け取っておき、シェーダ側で間接的にオフセット、回転情報を受け取ります。
こうすると単に vec3
の attribute として受け取るよりもデータ量を削減できます。
本当は float
ではなく int
として宣言したいところですが、 attribute のデータ型として int
は使用できないようです。
ということで、実際に使うOpenGLの命令は以下二つです。
glVertexAttribDivisor
このメソッドで頂点と 1:1 対応しない attribute を指定できる
// index 対応する attribute の番号
// divisor 恐らく、インデックスバッファ一回につき何回 attribute を進めるか?
void glVertexAttribDivisor(
GLuint index,
GLuint divisor
);
glDrawElementsInstanced
このメソッド一回の呼び出しで instancecount に指定された数の形状を描画できる
// mode 頂点の結び方を指定します。GL_TRIANGLESなど
// count インデックスバッファの長さを指定します。
// type インデックスバッファの型を指定します。 GL_UNSIGNED_SHORTなど
// indices インデックスバッファを指定します。glBindで事前にバインドしてあるならここは nullptr でOK
// instancecount 何回インデックスバッファを描画するか。例えば四角形を二つ描画するなら count=6, instancecount=2 となる
void glDrawElementsInstanced(
GLenum mode,
GLsizei count,
GLenum type,
const void * indices,
GLsizei instancecount
);
3. シェーダー
上記を踏まえて、以下に使用するシェーダーを抜粋します。
10, 11, 12で定義している属性が上で述べた attribute です。
(0~4番は openFrameworks 側から予約されている頂点情報なので、それとバッティングしなければ何番でもOK)
13は不要なのですがデバッグ用に定義しており、面ごとに色を変えるために使います。
※後でテクスチャを使うように変更されます。
頂点シェーダー
#version 410
layout(location=0) in vec3 aVertex;
layout(location=10) in vec3 aPosition;
layout(location=11) in float aLocalOffset;
layout(location=12) in float aLocalRotation;
layout(location=13) in float aPalleteColor;
uniform mat4 modelViewProjectionMatrix;
uniform vec3 localOffsetTable[6];
uniform mat4 localRotationTable[6];
uniform vec4 palletColorTable[10];
out vec4 color;
mat4 translate(vec3 v) {
return mat4(
vec4(1, 0, 0, 0),
vec4(0, 1, 0, 0),
vec4(0, 0, 1, 0),
vec4(v, 1)
);
}
void main(void) {
vec3 localOffset = localOffsetTable[int(aLocalOffset)];
mat4 localRotation = localRotationTable[int(aLocalRotation)];
vec3 position = aPosition + localOffset;
mat4 localTransform = translate(position) * localRotation * translate(-position);
mat4 MVP = (modelViewProjectionMatrix * localTransform);
color = palletColorTable[int(aPalleteColor)];
gl_Position = MVP * vec4(aVertex + position, 1);
}
ピクセルシェーダー
#version 410
in vec4 color;
out vec4 fragColor;
void main (void) {
fragColor = color;
}
そして、先ほど紹介したOpenGLの命令を使って1回で描画を行うクラスを作成します。
ヘッダー
#pragma once
#include <ofMesh.h>
#include <ofShader.h>
#include <ofVbo.h>
namespace ofBoxel {
class BoxelRenderer {
public:
explicit BoxelRenderer(ofShader shader, const ofMesh& mesh,
float offset = 0.5f);
void clear();
void batch(const glm::vec3& pos, int localOffset, int localRotation,
int palletColor);
void rehash();
void render();
private:
void setUniformMatrixArray(const std::string& name,
const std::vector<glm::mat4>& mvec);
void setUniformVec3Array(const std::string& name,
const std::vector<glm::vec3>& vvec);
void setUniformVec4Array(const std::string& name,
const std::vector<glm::vec4>& vvec);
ofShader m_shader;
ofVbo m_vbo;
bool m_dirty;
std::vector<glm::vec3> m_attribPosition;
std::vector<float> m_attribLocalOffset;
std::vector<float> m_attribLocalRotation;
std::vector<float> m_attribPalletColor;
};
} // namespace ofBoxel
実装
#include "BoxelRenderer.hpp"
namespace ofBoxel {
BoxelRenderer::BoxelRenderer(ofShader shader, const ofMesh& mesh, float offset)
: m_shader(shader),
m_vbo(),
m_dirty(false),
m_attribPosition(),
m_attribLocalOffset(),
m_attribLocalRotation(),
m_attribPalletColor() {
// 頂点情報の設定
const auto& vertices = mesh.getVertices();
const auto& normals = mesh.getNormals();
const auto& index = mesh.getIndices();
const auto& texCoords = mesh.getTexCoords();
m_vbo.setVertexData(vertices.data(), vertices.size(), GL_STATIC_DRAW);
m_vbo.setNormalData(normals.data(), normals.size(), GL_STATIC_DRAW);
m_vbo.setIndexData(index.data(), index.size(), GL_STATIC_DRAW);
m_vbo.setTexCoordData(texCoords.data(), texCoords.size(), GL_STATIC_DRAW);
// 各種行列の作成
m_shader.begin();
setUniformVec3Array("localOffsetTable",
std::vector<glm::vec3>{
glm::vec3(0.0f, offset, 0.0f), // top
glm::vec3(0.0f, -offset, 0.0f), // bottom
glm::vec3(-offset, 0.0f, 0.0f), // left
glm::vec3(offset, 0.0f, 0.0f), // right
glm::vec3(0.0f, 0.0f, offset), // front
glm::vec3(0.0f, 0.0f, -offset), // back
});
setUniformMatrixArray(
"localRotationTable",
std::vector<glm::mat4>{
glm::rotate(glm::radians(270.0f), glm::vec3(1, 0, 0)), // top
glm::rotate(glm::radians(90.0f), glm::vec3(1, 0, 0)), // bottom
glm::rotate(glm::radians(270.0f), glm::vec3(0, 1, 0)), // left
glm::rotate(glm::radians(90.0f), glm::vec3(0, 1, 0)), // right
glm::mat4(1.0f), // front
glm::rotate(glm::radians(180.0f), glm::vec3(0, 1, 0)), // back
});
setUniformVec4Array("palletColorTable", std::vector<glm::vec4>{
glm::vec4(1, 0, 0, 1),
glm::vec4(0, 1, 0, 1),
glm::vec4(0, 0, 0, 1),
glm::vec4(0, 0, 1, 1),
glm::vec4(1, 1, 0, 1),
glm::vec4(0, 1, 1, 1),
glm::vec4(1, 0, 1, 1),
glm::vec4(1, 1, 1, 1),
glm::vec4(0.2, 0.2, 0, 1),
glm::vec4(0.2, 0.2, 1, 1),
});
m_shader.end();
}
void BoxelRenderer::batch(const glm::vec3& pos, int localOffset,
int localRotation, int palletColor) {
this->m_dirty = true;
m_attribPosition.emplace_back(pos);
m_attribLocalOffset.emplace_back(static_cast<float>(localOffset));
m_attribLocalRotation.emplace_back(static_cast<float>(localRotation));
m_attribPalletColor.emplace_back(static_cast<float>(palletColor));
}
void BoxelRenderer::clear() {
m_attribPosition.clear();
m_attribLocalOffset.clear();
m_attribLocalRotation.clear();
m_attribPalletColor.clear();
this->m_dirty = true;
}
void BoxelRenderer::rehash() {
if (!m_dirty) {
return;
}
this->m_dirty = false;
m_vbo.setAttributeDivisor(0, 0);
m_vbo.setAttributeDivisor(3, 0);
// 位置を設定
m_vbo.setAttributeData(10, &m_attribPosition.front().x, 3,
m_attribPosition.size(), GL_STATIC_DRAW,
sizeof(float) * 3);
m_vbo.setAttributeDivisor(10, 1);
// ローカル位置を設定
m_vbo.setAttributeData(11, &m_attribLocalOffset.front(), 1,
m_attribLocalOffset.size(), GL_STATIC_DRAW,
sizeof(float));
m_vbo.setAttributeDivisor(11, 1);
// ローカル回転を設定
m_vbo.setAttributeData(12, &m_attribLocalRotation.front(), 1,
m_attribLocalRotation.size(), GL_STATIC_DRAW,
sizeof(float));
m_vbo.setAttributeDivisor(12, 1);
// 色を設定
m_vbo.setAttributeData(13, &m_attribPalletColor.front(), 1,
m_attribPalletColor.size(), GL_STATIC_DRAW,
sizeof(float));
m_vbo.setAttributeDivisor(13, 1);
}
void BoxelRenderer::render() {
rehash();
m_vbo.drawElementsInstanced(GL_TRIANGLES, 6,
static_cast<int>(m_attribPosition.size()));
}
// private
void BoxelRenderer::setUniformMatrixArray(const std::string& name,
const std::vector<glm::mat4>& mvec) {
GLint loc = m_shader.getUniformLocation(name);
if (loc == -1) {
ofLog() << name << " is not found";
return;
}
std::vector<float> ptr;
ptr.reserve(16 * mvec.size());
for (int mp = 0; mp < 6; mp++) {
auto const& m = mvec.at(mp);
const float* data = glm::value_ptr(m);
for (int i = 0; i < 16; i++) {
ptr.emplace_back(data[i]);
}
}
glUniformMatrix4fv(loc, mvec.size(), false, ptr.data());
}
void BoxelRenderer::setUniformVec3Array(const std::string& name,
const std::vector<glm::vec3>& vvec) {
m_shader.setUniform3fv(name, &vvec.front().x, vvec.size());
}
void BoxelRenderer::setUniformVec4Array(const std::string& name,
const std::vector<glm::vec4>& vvec) {
m_shader.setUniform4fv(name, &vvec.front().x, vvec.size());
}
} // namespace ofBoxel
このシェーダーを使って改めて 50^3 のボックスを描画すると、性能が劇的に向上します。
4. テクスチャ
上記の実装はまだ色をつけた Box でしかありません。これにテクスチャを貼っていきます。
全ての面に同じテクスチャを貼るだけなら特に考えることはなく、以下でOKです。
#version 410
in vec2 uv;
uniform sampler2D textureMap;
out vec4 fragColor;
void main (void) {
fragColor = texture(textureMap, uv);
}
しかし、minecraftのように多種多様なテクスチャの組み合わせを持つブロックをたくさん描画する場合には工夫が必要です。
愚直に glBindTexture
でテクスチャ切り替えを行うとまたその分だけドローコールが増えてしまいます。
ではどうするのかというと、特定のサイズごとに区切られた1枚の画像を一度だけバインドします。
シェーダーでは画像を切り替えるためにUV側を適切にずらすことで対応します。
そのためにはシェーダー側でテクスチャ番号を指すための attribute を新たに受け取る必要があります。
※頂点シェーダーの方はUVとテクスチャ番号のための attribute を追加しているだけなのでここでは省略
実際にUVをずらす処理は以下のようになっています。
UVは0~1の範囲に正規化されているので、1.0f/8.0fで特定のスロットの範囲が取得できます。
(今回は8x8枚敷き詰められた画像なので。行数列数も外から渡すようにしたらもう少し汎用性上がりそうです。)
テクスチャを指すために vec2
ではなく float
を取得してますが、これもデータ量削減のためです。
単に行数で割ってその商と余りを行番号、列番号とします。
あとは渡されたUVをその範囲を指すようにマッピングし直すだけです。
#version 410
in float textureSlot;
in vec2 uv;
uniform sampler2D textureMap;
out vec4 fragColor;
float map(float min, float max, float t) {
return min + ((max - min) * t);
}
void main (void) {
float slotSize = 1.0f / 8.0f;
float row = floor(textureSlot / 8.0f);
float col = mod(textureSlot, 8.0f);
float umin = col * slotSize;
float umax = umin + slotSize;
float vmin = row * slotSize;
float vmax = vmin + slotSize;
vec2 slotPos = vec2(
map(umin, umax, uv.x),
map(vmin, vmax, uv.y)
);
fragColor = texture(textureMap, slotPos);
}
確認しづらいので一旦一個だけ描画するように修正しましたが、
これで以下のようにインスタンシングで描画しつつテクスチャを貼ることができます。
5. テクスチャ変更
ここまでくると、あとはゲームっぽいテクスチャに差し替えて適切な座標、面、テクスチャを BoxelRenderer.batch
することでそれっぽいものを表示できるようになります。
(エディターとかないのでコードに座標直打ちですが…)
6. パーリンノイズ
パーリンノイズというものを使用するともう少し地形らしいものを表示できます。
今回のサンプルリポジトリではこちらのライブラリを使っています。
私はあまりこのアルゴリズムのことを知らないのでパラメータは適当ですがこんな感じに生成できます。
7. ボクセルの更新
インスタンシングによる最適化は静的な大量のボクセルを描画する分には高速ですが、
実行時にボクセルを設置したり破壊したりする場合には問題があります。
[6]では説明を省略しましたが、ボクセルを複数連続して配置するときには見える面と見えない面を区別する必要が生じます。
例えばボクセルが9個立方体をなす様に配置されているとき真ん中のボクセルは全ての面が見えません。
なのでこのボクセルについては描画を省略するためにそもそも attribute を送りません。
このような面ごとの判断をボクセルひとつひとつに対して行い作成した attribute で VBO を更新するわけです。
さらにいうなら、VBOの更新は長さが変わる場合にはglBufferData
で丸ごと更新するしかありません。
(長さが変わらないなら glBufferSubData
, glMapBuffer
も使えるのですが。)
ボクセルが更新されるたびに上記の処理が必要です。
というわけで問題は以下二つです。
- 面ごとに見える/見えないを判断する必要があり、これに無視できないコストがかかる
- バッファを丸ごと更新しなければいけない
- ただし、世界をチャンクごとに区切るなら一度に更新されるバッファは小さくできるかもしれない。
- が、その分ドローコールは増えることになる。
最初に私が行った実装は以下です。
- 毎回前回計算した結果を破棄して一から全てのボクセルの全ての面のチェック
- バッファは毎回丸ごと更新
この実装で256x256x256の世界を生成し、実行時にボクセルを設置したり破壊したりしたときの処理時間を計測してみました。
すると面ごとの可視チェックは2000~3000ms、バッファの更新は0~1ms程度でした。
後者は今の規模ではまだ問題なさそうなので、ひとまず前者の解決を考えます。
まず思いつくのはどこかでボクセルが更新された時にその近傍6マスを記録しておくことです。
次に attribute を作成するときに基本的には前回の結果を使用し、記録された箇所だけ別途更新するイメージです。
これを実装するためには attribute のどの箇所がどのボクセル座標に対応するか知る必要がありますが、今回は aPosition
の中から検索すれば良さそうです。
そして当然この記録された箇所の更新の時更新する必要のない座標の attribute に影響を与えてはいけません。
変更したいところと変更してはいけないところが交互に並んでいるとこれはちょっと面倒で、今回はより分けてみることにしました。
少し説明が長くなってしまいましたが以下に実際のコードを抜粋します。
void BoxelRenderer::compact(const std::vector<glm::ivec3>& update) {
// updateに入っている更新予定の座標が attribtue の中で何番目であるかを取得
std::vector<int> table;
for (int i = 0; i < update.size(); i++) {
glm::ivec3 pos = update.at(i);
int sides = 6;
for (int j = 0; j < m_attribPosition.size() - m_freeIndex; j++) {
glm::ivec3 aPos = glm::ivec3(m_attribPosition.at(j));
if (pos == aPos) {
table.emplace_back(j);
// 全ての面を取得したら終了
if (sides-- == 0) {
break;
}
}
}
}
// 前回コンパクションしたときの要素がまだ余っていたら今回もそれらを拾う
while (m_freeIndex > 0) {
table.emplace_back(m_attribPosition.size() - m_freeIndex);
m_freeIndex--;
}
// 添字テーブルを昇順ソート
// 後で選り分けるときにこちらの方が都合が良いので
std::sort(table.begin(), table.end());
// コンパクションの結果再利用可能になる要素の数
this->m_freeIndex = static_cast<int>(table.size());
// 選り分ける
compact(table, m_attribPosition);
compact(table, m_attribLocalOffset);
compact(table, m_attribLocalRotation);
compact(table, m_attribTextureSlot);
}
内部的に呼び出しているcompactの実装は以下
template <typename T>
void compact(const std::vector<int>& table, std::vector<T>& src) {
std::vector<T> tmp;
tmp.resize(src.size());
// 変更してはいけない領域を詰めながら追加
int index = 0;
int offset = 0;
for (int next : table) {
if (index == next) {
index = next + 1;
continue;
}
std::copy(src.begin() + index, src.begin() + next, tmp.begin() + offset);
offset += next - index;
index = next + 1;
}
if (index < src.size()) {
std::copy(src.begin() + index, src.end(), tmp.begin() + offset);
offset += src.size() - index;
}
// 最後に変更してもいい領域を追加
for (int i = 0; i < table.size(); i++) {
tmp.at(offset + i) = src.at(table.at(i));
}
// 本体にコピペ
src.swap(tmp);
}
このコンパクトメソッドによってこれから更新されるデータだけが右に寄せられます。
そして m_freeIndex
には次にバッチ処理を開始するときの書き込み開始オフセットが記録されます。
次にバッチするときは全てのボクセルを対象とするのではなく、予め記録された範囲だけを対象とし、なおかつ m_freeIndex
から書き込み始めることで前回の計算結果をかなり使いまわせます。
注意点として、より分け+バッチ処理の後もまだデータが余っている、つまり attribute が更新前よりも縮んでいる場合もあります。
なので、BoxelRenderer.batch
は以下のように修正が必要です。
void BoxelRenderer::render() {
rehash();
m_vbo.drawElementsInstanced(
GL_TRIANGLES, 6, static_cast<int>(m_attribPosition.size()) - m_freeIndex);
}
※実際にはもちろん他にもいろいろ変更が必要です。
例えば変更箇所を登録する invalidate()
やその一覧を保存する m_dirtyPositions
など。
今回の章の本質的な部分ではないのでここでは省略していますが全体はサンプルリポジトリから確認できます。
というわけで、この必要に応じた更新処理を仕込んだところボクセル変更時の処理時間は2000~3000msから20~30ms程度になりました。
60FPS出すには16msで処理を終えなければいけないことを考えると正直まだ少し物足りないですが、一旦この実装で進めることにします。
8. ハーフブロックの表現
minecraft にはY方向の大きさが半分のブロックがいくつか存在します。
これはスケールをかけた上で1/4ボクセル単位で軸方向に移動させることで表現できます。
(スケールは両端から半分づつ小さくなるのでそれを加味する必要がある。)
#version 410
layout(location=0) in vec3 aVertex;
layout(location=3) in vec2 aUV;
layout(location=10) in vec3 aPosition;
layout(location=11) in float aLocalOffset;
layout(location=12) in float aLocalScale;
layout(location=13) in float aLocalRotation;
layout(location=14) in float aTextureSlot;
uniform mat4 modelViewProjectionMatrix;
uniform vec3 localOffsetTable[42];
uniform vec3 localScaleTable[4];
uniform mat4 localRotationTable[6];
out float textureSlot;
out vec2 uv;
mat4 translate(vec3 v) {
return mat4(
vec4(1, 0, 0, 0),
vec4(0, 1, 0, 0),
vec4(0, 0, 1, 0),
vec4(v, 1)
);
}
void main(void) {
vec3 localOffset = localOffsetTable[int(aLocalOffset)];
vec3 localScale = localScaleTable[int(aLocalScale)];
mat4 localRotation = localRotationTable[int(aLocalRotation)];
vec3 scaled = localScale * aVertex;
vec3 position = aPosition + localOffset;
mat4 localTransform = translate(position) * localRotation * translate(-position);
mat4 MVP = (modelViewProjectionMatrix * localTransform);
textureSlot = aTextureSlot;
uv = aUV;
gl_Position = MVP * vec4(scaled + position, 1);
}
以下のように平行移動用のテーブルがものすごく大きくなっています。
これは ブロック + 上下ハーフ + 左右ハーフ + 前後ハーフ
で七種類のバリエーションが必要かつそれぞれに六つの面が存在するためですが、
実ははもっと切り詰めることも可能です。ただキリが良くてプログラム的にわかりやすいのでこうなっています。
uniform vec3 localOffsetTable[42];
カーペットや雪、ドアのようなブロックもスケール設定を変えれば再現できると思われます。
(ドアはまず向きの概念をどうにかする必要がありますが...)
しかし階段や松明、柵のような複雑な形状についてはドローコールを分けるしかないかもしれません。
課題
- minecraftのような光の表現
- attribute で渡せば良さそうですが、面ごとに明るさレベルを渡すのは少し無駄がある
- minecraftのカボチャやピストンのような向き情報を持つブロック
- テクスチャ番号覚えるのは数が増えてくると面倒なので、ブロック定義ファイルとバラで用意された画像からパックされた画像を自動生成するツールも必要