使用openFrameworks绘制体素世界
首先
开发环境
openFrameworks v0.11.2
macOS monterey 12.0.1
Xcode 13.2.1
最开始的步骤
首先,我們將嘗試使用oF最初提供的功能來實現。
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();
}
2. 优化效果最佳
为什么 ofBox 在性能上表现不佳的原因可能是因为绘制调用过多。
跟踪 ofBox 的实现,可以找到 ofGLProgrammableRenderer#draw(const ofMesh & vertexData, ofPolyRenderMode renderType, bool useColors, bool useTextures, bool useNormals) 这个方法,
在这个方法中,glDrawArrays 或者 glDrawElements 被调用一次。
换句话说,之前的代码中这个绘制命令被调用了50^3次。
我们将在接下来的优化中使用实例化功能,将这个绘图命令减少到一次。
例えばある形状を複数箇所に描画するとき、以下のような方法が考えられます。
(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函数可以指定与顶点不是一对一对应的属性。
// 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中,这些属性是前面提到的属性。
(0〜4被openFrameworks保留用于顶点信息,只要不与其冲突,可以使用任何编号)
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指令创建一个能够一次绘制的类。
#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
4. 材质
以上实施仅仅是一个上色的盒子,接下来我们会贴上纹理。
如果只是在所有的面上贴上相同的纹理,就没有什么特别需要考虑的事情了,在下面就可以了。
#version 410
in vec2 uv;
uniform sampler2D textureMap;
out vec4 fragColor;
void main (void) {
fragColor = texture(textureMap, uv);
}
※由于顶点着色器只是为了UV和纹理编号添加了属性,所以这里省略了一些内容。
实际上,偏移UV的处理如下所示。
由于UV已经归一化到0~1的范围内,所以可以通过1.0f/8.0f来获取特定槽位的范围。
(这次是一幅由8×8块铺满的图片,如果行数和列数从外部传入的话,可能会更具通用性。)
为了指定纹理,我们不是获取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. 修改纹理
6. 嘈杂声
7. 更新体素。
インスタンシングによる最適化は静的な大量のボクセルを描画する分には高速ですが、
実行時にボクセルを設置したり破壊したりする場合には問題があります。
[6]では説明を省略しましたが、ボクセルを複数連続して配置するときには見える面と見えない面を区別する必要が生じます。
例えばボクセルが9個立方体をなす様に配置されているとき真ん中のボクセルは全ての面が見えません。
なのでこのボクセルについては描画を省略するためにそもそも attribute を送りません。
このような面ごとの判断をボクセルひとつひとつに対して行い作成した attribute で VBO を更新するわけです。
さらにいうなら、VBOの更新は長さが変わる場合にはglBufferDataで丸ごと更新するしかありません。
(長さが変わらないなら glBufferSubData, glMapBufferも使えるのですが。)
ボクセルが更新されるたびに上記の処理が必要です。
というわけで問題は以下二つです。
-
- 面ごとに見える/見えないを判断する必要があり、これに無視できないコストがかかる
-
- バッファを丸ごと更新しなければいけない
ただし、世界をチャンクごとに区切るなら一度に更新されるバッファは小さくできるかもしれない。
が、その分ドローコールは増えることになる。
我最初的实现如下。
-
- 毎回前回計算した結果を破棄して一から全てのボクセルの全ての面のチェック
- バッファは毎回丸ごと更新
我用这个实现生成了一个256x256x256的世界,并在运行时测量了放置或破坏方块时的处理时间。结果显示,面的可见性检查大约需要2000~3000毫秒,而缓冲区的更新只需要0~1毫秒左右。
後者は今の規模ではまだ問題なさそうなので、ひとまず前者の解決を考えます。
まず思いつくのはどこかでボクセルが更新された時にその近傍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 など。
今回の章の本質的な部分ではないのでここでは省略していますが全体はサンプルリポジトリから確認できます。
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);
}
以下是平行移动的表格非常大。这是因为需要七种变化,分别为块+上下半+左右半+前后半,并且每种变化都有六个面。实际上,它可以被进一步简化。但为了便于程序的理解和整齐,它被设计成这样。
一致性的三维本地偏移表 [42];
如果改变比例设置,像地毯、雪和门这样的方块也可以再现。 (虽然门首先需要解决方向的概念问题…)
然而,對於像是階梯、火炬和柵欄等複雜的形狀,可能只能進行手繪來區分。
问题
-
- minecraftのような光の表現
attribute で渡せば良さそうですが、面ごとに明るさレベルを渡すのは少し無駄がある
minecraftのカボチャやピストンのような向き情報を持つブロック
テクスチャ番号覚えるのは数が増えてくると面倒なので、ブロック定義ファイルとバラで用意された画像からパックされた画像を自動生成するツールも必要