ブロックレンダラ, 特にAmbient Occlusionについての簡単なまとめ
Edit: 定義式と[1]における遮蔽度の計算が逆になっていることを追記
Minecraft 1.4.7とForge #534の環境において, Minecraftの通常ブロック用レンダラを再実装したので, 備忘録として学んだことをメモしておく. 参考文献は一番下にあり, 都度[1]とか[2]とかみたいに参照する. OpenGLやその基礎である3DCGについての知識は仮定するが, 知らなければ都度調べるか適当な入門書籍で勉強しよう. [11], [12]が個人的に参考になった.
はじめに
MinecraftはLWJGLを用いて3D描画を実現している. LWJGLはかなりローレベルなAPI群であり, ブロック描画や影処理, アニメーション等はMinecraft側でケアされている. このうち Minecraft#runGameLoop
において呼び出される EntityRenderer#updateCameraAndRender
から呼ばれる EntityRenderer#renderWorld
がメインのレンダリングコードであり, ここから全てのレンダラが呼び出される. 呼び出し順序は以下.
- ブロックの仮描画
RenderGlobal#updateRenderers
→WorldRenderer#updateRenderer
RenderBlocks
の各種メソッドが呼び出されるのもここである.- すなわち, 我々Modderが
ISimpleBlockRenderingHandler
を用いて描画する際にはここで当該描画処理が呼び出される.
- 雲 (y座標が128未満の場合)
EntityRenderer#renderCloudsCheck
- ブロックの実際の描画 (0パス目)
RenderGlobal#renderSortedRenderers
- エンティティ (0パス目)
RenderGlobal#renderEntities
- 描画順序
- 天気系エフェクト
- Entity
- TileEntitySpecialRenderer
- 軽いパーティクル
EffectRenderer#renderLitParticles
- 通常パーティクル
EffectRenderer#renderParticles
- ハイライト (水中)
RenderGlobal#drawSelectionBox
RenderGlobal#drawBlockBreaking
がその前に呼ばれるが, これはただの描画設定メソッドDrawBlockHighlightEvent
がキャンセルされた場合には実施しない- 水中に存在する場合のみ実施される
- ブロックの実際の描画 (1パス目)
RenderGlobal#renderSortedRenderers
- エンティティ (1パス目)
RenderGlobal#renderEntities
- ブロック破壊モーション
RenderGlobal#drawBlockBreaking
DrawBlockHighlightEvent
がキャンセルされた場合には実施しない- 水中に存在しない場合のみ実施される
- ハイライト (水中以外)
RenderGlobal#drawSelectionBox
RenderGlobal#drawBlockBreaking
がその前に呼ばれるが, これはただの描画設定メソッドDrawBlockHighlightEvent
がキャンセルされた場合には実施しない
- ブロック破壊時モーション
RenderGlobal#drawBlockDamageTexture
- 雨 / 雪エフェクト
EntityRenderer#renderRainSnow
- 雲 (y座標が128以上の場合)
EntityRenderer#renderCloudsCheck
RenderWorldLastEvent
の呼び出し- 手
EntityRenderer#renderHand
ブロックレンダラ
ブロックのレンダリングは上に書いている通り2パスで行われる. 仮描画が分かれているのはおそらく負荷軽減のためであろう. ブロックの更新頻度がtickよりも小さくなることは原理的にありえず, 1/20秒程度であればFPSに比較してキャッシュするメリットが大きいためだ. よく知られている通り, ブロックのレンダリングはTessellatorやその他頂点追加メソッドとテクスチャのバインドを組み合わせて行われるのが基本である. この頂点の並び, そしてテクスチャや各種オプションは一種の命令列と捉えることができる. OpenGLにはディスプレイリストという機構が存在し, 特定の命令列をID指定で呼び出すことができる1. これらを踏まえて仮描画という処理を説明すると, 仮描画とは「リストを作成すること」である. 上には記載されていないが, 実際には実際のブロックレンダリング(⇔ リスト呼び出し)の前に WorldRenderer
はインスタンス単位でソートされている. これは奥行きを考慮して描画するためであると思われる2. よって, ブロックレンダラにおいては動きのある描画処理を行うことはできない. 動きを取り入れたければ現状 Entity
化するか TileEntitySpecialRenderer
を利用するしかない. また, リストを利用する都合上「レンダリング処理コード内部で行われたJava的な結果」はすべて描画に影響しない. また, glTranslated
のような位置移動もこの場合通用しない. リストにこそ保存されるが, 描画位置は WorldRenderer
側で定められてしまうため, 指定しても意味がない.
余談: GLSL Shaders Modの効果について
LWJGLはGLSLによるシェーディングが可能である. これを描画処理に取り入れるのがShaders Mod ( https://www.minecraftforum.net/forums/mapping-and-modding-java-edition/minecraft-mods/1286604-shaders-mod-updated-by-karyonix )である. シェーダの基礎について[3], [5]が詳しいためそちらに譲る.
シェーディングによって頂点記述と実際の描画の間に様々な処理を挟むことが可能となる. 例えば有名なSonic Either's Unbelievable Shadersには雑草が風に沿って揺れているような効果や, 影の効果がこれにより実現されている. 本記事はあくまでMinecraftのデフォルト処理を記述するために深入りはしないが, GLSL Shaders Modはどの場面でどのプログラムを呼び出しているのかという点は気になるところだろう. これは上に挙げた各種メソッドのうちブロック, エンティティ, 手といったシェーディング対象になっているメソッドの開始・終了時に「シェーダ利用を開始する」「シェーダ利用を停止する」処理を挟むことでシェーダを利用可能としている. すなわち RenderGlobal
, EntityRenderer
の2クラスに主な修正が入っている. 実際にはデコンパイルするなり何なりして確認すればよいが, coremodを用いてその辺りをいじるときには注意が必要.
魔窟 renderStandardBlock
さて, 多くの人間が読み解くことを諦めコピペでほぼ全ての処理を行っているであろう場所が RenderBlocks#renderStandardBlock
だろう. なにせ RenderBlocks.java
自体が7000行程度存在する上に出てくるフィールド名が意味不明であるからこれは仕方がない. 実際にはどのような処理をしているのかについてここで詳しく見ていくことにする. 以下に登場するメソッド名・フィールド名は特記無き限り RenderBlocks
におけるメソッド名・フィールド名とする.
renderStandardBlock
はその名の通り直方体をベースとした各種ブロックのレンダリングを行うためのメソッドである. Ambient Occlusion(後述. 以下AOで略記する. )が有効であるか否かに応じて実際の描画処理は renderStandardBlockWithAmbientOcclusion
又は renderStandardBlockWithColorMultiplier
に委任されるわけだが, このdispatch処理を行うのがこのrenderStandardBlockである. やっていることは単純で, 乗算する色を取得し, AOが有効ならば renderStandardBlockWithAmbientOccuusion
, 無効ならば renderStandardBlockWithColorMultiplier
を呼び出す3. また, アナグリフが有効である場合はそのための色処理も行う.
基本となるブロックレンダリング処理
ここでは renderStandardBlockWithColorMultiplier
に注目して解説する. 中々長い処理が書かれているが, 大まかには以下のような流れである.
- 乗算する色の計算
- 下面のレンダリングをする場合, 明るさを取得・設定し, 色を設定した上で描画
- 上面のレンダリングをする場合, 明るさを取得・設定し, 色を設定した上で描画
- 以下東西南北繰り返し
- 一回でも描画処理が実施されたらtrueを返し, 一度も描画が走らなければfalseを返す
東西, 南北, 上面, 下面のそれぞれで明るさが異なることが特筆すべき点であろうか. それぞれの描画処理は renderBottomFace
, renderNorthFace
いった各種メソッドが担当し, その中でUV座標の回転も処理される. なおバニラのコードでは南北と東西が誤ってレンダリングされている4ため, 自分で書く場合はここを修正したほうがよいだろう. このことから, このメソッドが行っているのは「6面それぞれに対して明るさを加味 + 下から見ると一般に暗いというような事実の反映 + とりあえず描画」といういわば最低限のブロック描画処理だと言えよう. なので, ブロックレンダラを書く場合まず最初に参照するのはこのメソッドの処理が最も単純で適していると思われる.
Ambient Occlusion / 環境遮蔽 - voxel-basedの場合
次の renderStandardBlockWithAmbientOcclusion
を解説する前にAmbient Occlusion (アンビエントオクルージョン, 環境遮蔽, AO)について軽く解説する. これは環境光を考慮した陰影表現のための手法の一つである. しかし一般の3DCGに関する場合と今回考えたい場合は些か乖離が存在する. よって, まず一般の場合は[2], [4]に譲ることとする. この解説を読む前に画像だけでも目を通しておくと理解がしやすくなるかと思われる.
Minecraftの場合はこの環境遮蔽項というものがどのように影響してくるのかであるが, 結論から言えばかなり近似された結果を用いることから上に存在するやりかたほど複雑にはならないし, 環境遮蔽項は影の付け方にのみ依存する. 最も離散的に近似した手法が [1] に紹介されているためこれをまずは説明しよう. [1]の Ambient occlusion for voxels という節に存在する1枚目の画像では4パターンの影の付き方が考慮されている. ここでは環境光のみに絞って考えるため, 「ブロックが3面にある隅っこは暗い」という極めて単純なライティングで十分なのだ. そしてこの4種類それぞれの場合を判定するには「隣接ブロックがどのように配置されているか」を見るだけで十分である. 実装はその画像の直下に存在する vertexAO
がすべてだ. この関数の戻り値は0, 1, 2, 3のいずれかであり, 適当なfloat型配列, たとえば [0, 0.2, 0.8, 1]
の添字にそれを指定すれば, その部分の遮蔽度が計算できるという寸法だ. 遮蔽度が分かれば当該頂点の色をどれだけ暗くすればよいか計算できるため, たとえば 1 - 遮蔽度
を色のRGBそれぞれに乗じることでその頂点の色を計算できることになる5.
[1]はかなりstate-of-the-artに近いようで, たとえば[8, p.7]で紹介されている手法は[1]の焼き直しである. [7]で紹介されている手法は(シェーダ向けに特殊化されてはいるが)主な考え方は同じである.
NvidiaによりVoxel Ambient Occlusion(VXAO)という手法が提案されているが, この手法は一般の3DCGにおけるAO処理をvoxel単位に分割統治することによる高速化手法であるため, 今回のような場合には適用不可能である. 興味があれば[9]や[10]が入門となるだろう.
Minecraftにおける実装 - renderStandardBlockWithAmbientOcclusion
コメント欄で指摘されているとおり, [1]の手法はnaïve oversimplificationされた手法である. Minecraftにおける実際のライティングについては[6]に簡単に記述されている. Minecraftで大きく異なる点は, 環境光だけではなく光源ブロックからの光量を考慮しなければならない点である. [1]のアルゴリズムはいわば光量を固定していたアルゴリズムであるから, この点を考慮すればそれで十分である. [6]のSmooth Lightingにはある面のある頂点の明るさのためにその周辺4ブロック分を見ることが示されている. これは[1]における3ブロックに加えて丁度単位法線ベクトル分だけ移動した先, すなわち対象となる面に隣接したブロックを加えた4ブロック分が考慮されている. renderStandardBlockWithColorMultiplier
ではこの隣接ブロックの明るさのみを考慮していたのであった.
隣接するブロックにmain, それ以外に[1]と同様side1, side2, cornerと名前をつけよう. このとき, 頂点の明るさ・色を以下のように計算する. ただし, AO値とは脚注5で示したAmbient Occlusionの定義式における項を意味する. 即ち, 遮蔽されていれば小さく, されていなければ大きい.
- main, side1, side2の明るさ・AO値を取得する.
- cornerの明るさ・AO値を取得する.
- side1, side2が完全に遮蔽している(⇔ cornerが影響しない)場合, cornerの明るさ・AO値は使わず, side1かside2の値を流用する. ここではside1とする.
- どちらを使っても結果には影響しない. side1, side2共に完全に遮蔽されているということは, AO値は最小(遮蔽状態)であり, 明るさは0であるためである.
- 完全に遮蔽しているかどうかの判定は
Block.canBlockGrass
という配列のtrue / falseを見ることで行う. この配列はブロックの下に「草が生えるかどうか」を表している6.
- main, side1, side2, cornerの明るさ・AO値の平均値をとる. これらを
brightness
,ao
とする.- side1, side2, cornerについて, その明るさが0である場合 (⇔ 完全に真っ暗, 若しくは単純に埋まっている場合) はmainの明るさを採用する.
brightness
の値を明るさとして採用し,ao
の値を色に乗算する.ao
は遮蔽項, すなわち影の強さである. 理論的にも積分結果に等しい.- 色乗算は影らしく明度を落とすために行う.
MinecraftにおけるAO値は遮蔽されていれば 0.2
, 遮蔽されていなければ 1.0
の定数がそれぞれ割り当てられており, どんなに暗くても真っ暗にはならない7. このアルゴリズムを6面4頂点分実施する.
ここまでの流れをもとに renderStandardBlockWithAmbientOcclusion
を読むと, おそらく大したことはしていないという感想を持つだろう. 実際のところ, 各面について各頂点のAO値(実際にはAO light value)・明るさ(brightness)をそれぞれ取得し, 遮蔽度に応じてcornerの値を入れ替え, それをインスタンスフィールドに設定することしかしていない. ただただ冗長であり, かつ効率化のためか全てのフィールドの取得は最初に一括して行われている影響でフィールドの数が半端な数ではない. コード効率的な方向から書き直すことも可能だろうな, という気持ちからこれを書き直した. 以下に重要な部分を抜粋する.
private void computeAO(final Block block, final IBlockAccess world, final int x, final int y, final int z, final ForgeDirection dir, final boolean doAddOffset) { final int posX = x + (doAddOffset ? dir.offsetX : 0); final int posY = y + (doAddOffset ? dir.offsetY : 0); final int posZ = z + (doAddOffset ? dir.offsetZ : 0); ForgeDirection aboveDir, belowDir, leftDir, rightDir; if (dir == ForgeDirection.DOWN || dir == ForgeDirection.UP) { /* xz plane * * -z * ^ * | * -x < - + - > +x * | * V * +z * */ aboveDir = ForgeDirection.NORTH; belowDir = ForgeDirection.SOUTH; leftDir = ForgeDirection.WEST; rightDir = ForgeDirection.EAST; } else if (dir == ForgeDirection.NORTH || dir == ForgeDirection.SOUTH) { /* xy plane * * +y * ^ * | * -x < - + - > +x * | * V * -y * */ aboveDir = ForgeDirection.UP; belowDir = ForgeDirection.DOWN; leftDir = ForgeDirection.WEST; rightDir = ForgeDirection.EAST; } else { // WEST, EAST /* yz plane * * +y * ^ * | * -z < - + - > +z * | * V * -y * */ aboveDir = ForgeDirection.UP; belowDir = ForgeDirection.DOWN; leftDir = ForgeDirection.NORTH; rightDir = ForgeDirection.SOUTH; } /* * 0 | 1 | 2 * 3 | 4 | 5 * 6 | 7 | 8 * * 4 = (aoMain, brightnessMain) * * (main, side1, side2, corner) * * bottom left vertex -> [3, 4, 6, 7]: (4, 3, 7, 6) * bottom right vertex -> [4, 5, 7, 8]: (4, 5, 7, 8) * top left vertex -> [0, 1, 3, 4]: (4, 1, 3, 0) * top right vertex -> [1, 2, 4, 5]: (4, 1, 5, 2) */ final ChunkCoordinates params[] = new ChunkCoordinates[9]; // center params[4] = new ChunkCoordinates(posX, posY, posZ); params[0] = this.addDirectionToVec(params[4], aboveDir, leftDir); params[1] = this.addDirectionToVec(params[4], aboveDir); params[2] = this.addDirectionToVec(params[4], aboveDir, rightDir); params[3] = this.addDirectionToVec(params[4], leftDir); params[5] = this.addDirectionToVec(params[4], rightDir); params[6] = this.addDirectionToVec(params[4], belowDir, leftDir); params[7] = this.addDirectionToVec(params[4], belowDir); params[8] = this.addDirectionToVec(params[4], belowDir, rightDir); final Pair<Integer, Float> botLeft = this.computeAO_do(block, world, params[4], params[3], params[7], params[6]); this.brightnessBotLeft = botLeft.first; this.colorBotLeftR *= botLeft.second; this.colorBotLeftG *= botLeft.second; this.colorBotLeftB *= botLeft.second; final Pair<Integer, Float> botRight = this.computeAO_do(block, world, params[4], params[5], params[7], params[8]); this.brightnessBotRight = botRight.first; this.colorBotRightR *= botRight.second; this.colorBotRightG *= botRight.second; this.colorBotRightB *= botRight.second; final Pair<Integer, Float> topLeft = this.computeAO_do(block, world, params[4], params[1], params[3], params[0]); this.brightnessTopLeft = topLeft.first; this.colorTopLeftR *= topLeft.second; this.colorTopLeftG *= topLeft.second; this.colorTopLeftB *= topLeft.second; final Pair<Integer, Float> topRight = this.computeAO_do(block, world, params[4], params[1], params[5], params[2]); this.brightnessTopRight = topRight.first; this.colorTopRightR *= topRight.second; this.colorTopRightG *= topRight.second; this.colorTopRightB *= topRight.second; } private Pair<Integer, Float> computeAO_do(final Block block, final IBlockAccess world, final ChunkCoordinates mainVec, final ChunkCoordinates sideVec1, final ChunkCoordinates sideVec2, final ChunkCoordinates cornerVec) { // TODO: キャッシュ final int idMain = world.getBlockId(mainVec.posX, mainVec.posY, mainVec.posZ); final int brightnessMain = block.getMixedBrightnessForBlock(world, mainVec.posX, mainVec.posY, mainVec.posZ); final float aoMain = this.getAmbientOcclusionLightValue(world, mainVec.posX, mainVec.posY, mainVec.posZ); final int idSide1 = world.getBlockId(sideVec1.posX, sideVec1.posY, sideVec1.posZ); final int brightnessSide1 = block.getMixedBrightnessForBlock(world, sideVec1.posX, sideVec1.posY, sideVec1.posZ); final float aoSide1 = this.getAmbientOcclusionLightValue(world, sideVec1.posX, sideVec1.posY, sideVec1.posZ); final int idSide2 = world.getBlockId(sideVec2.posX, sideVec2.posY, sideVec2.posZ); final int brightnessSide2 = block.getMixedBrightnessForBlock(world, sideVec2.posX, sideVec2.posY, sideVec2.posZ); final float aoSide2 = this.getAmbientOcclusionLightValue(world, sideVec2.posX, sideVec2.posY, sideVec2.posZ); final int idCorner = world.getBlockId(cornerVec.posX, cornerVec.posY, cornerVec.posZ); int brightnessCorner; float aoCorner; if (!Block.canBlockGrass[idSide1] && !Block.canBlockGrass[idSide2]) { // 両方埋まっている場合は角を無視 brightnessCorner = brightnessSide1; aoCorner = aoSide1; } else { brightnessCorner = block.getMixedBrightnessForBlock(world, cornerVec.posX, cornerVec.posY, cornerVec.posZ); aoCorner = this.getAmbientOcclusionLightValue(world, cornerVec.posX, cornerVec.posY, cornerVec.posZ); } // 平均値 final int brightness = this.getAoBrightness(brightnessMain, brightnessSide1, brightnessSide2, brightnessCorner); final float ao = (aoMain + aoSide1 + aoSide2 + aoCorner) / 4F; return Pair.create(brightness, ao); }
非効率的なコードでこそあるものの, 全体としては先程のアルゴリズムの愚直な実装であることは明白であろう. コード全体は https://gist.github.com/r3qu13m/206a5d78a8484f657508b2c736782e72 に存在する.
まとめ
Minecraftのブロックレンダリング処理のうちAmbient Occlusionを用いた陰影処理について概説した. RenderBlocks
における実装は効率的でこそあるものの, 大変に読みづらい. 知識確認も兼ねてこれを再実装し, その過程で関連手法についても調査したが, 結局state-of-the-artとして扱われるのは[1]において紹介される手法であり, その発展手法であるMinecraftのAmbient Occlusionアルゴリズムはまあまあ理にかなった手法だと思えるだろう. パフォーマンス測定まではやっていないが, 筆者の実装によるオーバーヘッドは定数倍の差ではあるものの, メモリ確保 / instantiationを含む重いものであると予想されるため, 実際にはより最適化した形にする必要があるだろう.
発展として, 色付き光源というものを実装することを考えてみる. このとき, Ambient Occlusionをその光源に対して適用するにはどうすればいいだろうか. これはRGBそれぞれをもつ光源マップを考え, それをもとにAmbient OcclusionをR, G, Bの3回回してその結果を合成すればよい. 大変重い処理にはなるだろうが, 十分に実現可能な案である.
参考文献
- Ambient occlusion for Minecraft-like worlds – 0 FPS - https://0fps.net/2013/07/03/ambient-occlusion-for-minecraft-like-worlds/
- アンビエントオクルージョン・はじめの一歩 - https://ambientocclusion.hatenablog.com/entry/2013/10/15/223302
- 床井研究室 - 第1回 シェーダプログラムの読み込み - https://marina.sys.wakayama-u.ac.jp/~tokoi/?date=20051006
- 床井研究室 - SSAO (Screen Space Ambient Occlusion) - https://marina.sys.wakayama-u.ac.jp/~tokoi/?date=20101122
- シェーダの記述と基礎 - https://wgld.org/d/webgl/w008.html
- Minecraft-Overviewer/designdoc.rst at master · overviewer/Minecraft-Overviewer - https://github.com/overviewer/Minecraft-Overviewer/blob/master/docs/design/designdoc.rst#lighting
- Inigo Quilez :: fractals, computer graphics, mathematics, shaders, demoscene and more - https://www.iquilezles.org/www/articles/voxellines/voxellines.htm
- Eric Arnebäck, Felix Bärring, Johan Hage, Anton Lundén, Andreas Löfman, and Niclas Ogeryd. 2015. Bloxel: Developing a voxel game engine in Java using OpenGL - http://www.cse.chalmers.se/~uffe/bachelor/kandidatarbetestartpaket/Final-Voxelspel.pdf
- Andrei Tatarinov, and Alexey Panteleev. 2016. Advanced Ambient Occlusion Methods for Modern Games (スライド, 動画)
- Voxel-based Global Illumination - http://graphics.snu.ac.kr/class/graphics2011/materials/paper09_voxel_gi.pdf (論文をまとめたスライド ゼミ用?)
- Hisa Ando. 2021. GPUを支える技術. 技術評論社.
- 宮崎 大輔, 床井 浩平, 結城 修, 吉田 典正. 2020. IT Text コンピュータグラフィックスの基礎. オーム社.
-
http://wisdom.sakura.ne.jp/system/opengl/gl20.html や http://akasuku.blog.jp/archives/44623464.html を参照. オフィシャルな解説は https://www.glprogramming.com/red/chapter07.html に存在する. ↩
-
ワールドロード時にソートすればよかったのではないか, という声があるかもしれないが, チャンクロードがかかる毎・視点が変わる毎に変化する奥行きパラメータを考慮するためには毎回のソートが必須である. ↩
-
ShadersModではAO有効に加えて明るさ0であること (⇔ 他のブロックの明るさに依存すること) が条件となっている. ↩
-
おそらくx, zを入れ替えた歴史的事情に因る. https://twitter.com/notch/status/124414532847284224↩
-
ただし, 定義式のに沿って考えるならば, これは寧ろ「遮蔽されている度合い」だけであって遮蔽項の計算ではない. その意味では前後が逆である. ↩
-
余談として, この値のもとになっている
Material#getCanBlockGrass
は論理の真偽が逆転している. おそらくMaterial
クラスにおけるblockとは「ユーザーをブロックする」という動詞の意味で用いており,Block.canBlockGrass
クラスにおけるblockはBlockGrass
という固有名詞の意味で用いているのだろう. ↩ -
これは実際に明るさ0の場所でAOを有効化して隅の頂点を見たり, これを0にしたりと試せばよいのだが, 0.2よりも黒に近づけると少し強すぎるかなという感想を持つ. ↩