ブロックレンダラ, 特に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#updateRenderersWorldRenderer#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 に注目して解説する. 中々長い処理が書かれているが, 大まかには以下のような流れである.

  1. 乗算する色の計算
  2. 下面のレンダリングをする場合, 明るさを取得・設定し, 色を設定した上で描画
  3. 上面のレンダリングをする場合, 明るさを取得・設定し, 色を設定した上で描画
  4. 以下東西南北繰り返し
  5. 一回でも描画処理が実施されたら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の定義式における A_p項を意味する. 即ち, 遮蔽されていれば小さく, されていなければ大きい.

  • 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 は遮蔽項, すなわち影の強さである. 理論的にも積分結果 A_pに等しい.
    • 色乗算は影らしく明度を落とすために行う.

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回回してその結果を合成すればよい. 大変重い処理にはなるだろうが, 十分に実現可能な案である.

参考文献

  1. Ambient occlusion for Minecraft-like worlds – 0 FPS - https://0fps.net/2013/07/03/ambient-occlusion-for-minecraft-like-worlds/
  2. アンビエントオクルージョン・はじめの一歩 - https://ambientocclusion.hatenablog.com/entry/2013/10/15/223302
  3. 床井研究室 - 第1回 シェーダプログラムの読み込み - https://marina.sys.wakayama-u.ac.jp/~tokoi/?date=20051006
  4. 床井研究室 - SSAO (Screen Space Ambient Occlusion) - https://marina.sys.wakayama-u.ac.jp/~tokoi/?date=20101122
  5. シェーダの記述と基礎 - https://wgld.org/d/webgl/w008.html
  6. Minecraft-Overviewer/designdoc.rst at master · overviewer/Minecraft-Overviewer - https://github.com/overviewer/Minecraft-Overviewer/blob/master/docs/design/designdoc.rst#lighting
  7. Inigo Quilez :: fractals, computer graphics, mathematics, shaders, demoscene and more - https://www.iquilezles.org/www/articles/voxellines/voxellines.htm
  8. 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
  9. Andrei Tatarinov, and Alexey Panteleev. 2016. Advanced Ambient Occlusion Methods for Modern Games (スライド, 動画)
  10. Voxel-based Global Illumination - http://graphics.snu.ac.kr/class/graphics2011/materials/paper09_voxel_gi.pdf (論文をまとめたスライド ゼミ用?)
  11. Hisa Ando. 2021. GPUを支える技術. 技術評論社.
  12. 宮崎 大輔, 床井 浩平, 結城 修, 吉田 典正. 2020. IT Text コンピュータグラフィックスの基礎. オーム社.

  1. http://wisdom.sakura.ne.jp/system/opengl/gl20.htmlhttp://akasuku.blog.jp/archives/44623464.html を参照. オフィシャルな解説は https://www.glprogramming.com/red/chapter07.html に存在する.

  2. ワールドロード時にソートすればよかったのではないか, という声があるかもしれないが, チャンクロードがかかる毎・視点が変わる毎に変化する奥行きパラメータを考慮するためには毎回のソートが必須である.

  3. ShadersModではAO有効に加えて明るさ0であること (⇔ 他のブロックの明るさに依存すること) が条件となっている.

  4. おそらくx, zを入れ替えた歴史的事情に因る. https://twitter.com/notch/status/124414532847284224

  5. ただし, 定義式の \displaystyle A_p = \frac{1}{\pi}\int _ \Omega V(p, \omega)\mathbf{(n} _ p\cdot\omega) \ {\mathop{\mathrm{d}\omega}}に沿って考えるならば, これは寧ろ「遮蔽されている度合い」だけであって遮蔽項 A_pの計算ではない. その意味では前後が逆である.

  6. 余談として, この値のもとになっている Material#getCanBlockGrass は論理の真偽が逆転している. おそらく Material クラスにおけるblockとは「ユーザーをブロックする」という動詞の意味で用いており, Block.canBlockGrass クラスにおけるblockは BlockGrass という固有名詞の意味で用いているのだろう.

  7. これは実際に明るさ0の場所でAOを有効化して隅の頂点を見たり, これを0にしたりと試せばよいのだが, 0.2よりも黒に近づけると少し強すぎるかなという感想を持つ.

Java8においてForgeの完全動作のために書いたパッチ類

Java8環境において, 内部のコレクション/マップのとり方が変更されたためと思しきパッチ失敗が発生するようになっていた. これを修正したので, 備忘録も兼ねてパッチを公開する.

環境としてはMinecraftForge #534が対象である. forgeディレクトリの展開の後, このパッチを適用してinstall.shを実行する.

mcp関係のファイルをいちいちファイルダウンロードさせるのにも困っていたので, fml/fml.pyにもパッチを書いた. 使い方はパッチを読めばわかるが, LWJGL-2.9.3のファイル, 各種ライブラリファイル(asmもパッチ通りasm-all-5.2で良い), minecraft.jar, mincraft_server.jar, mcp7.26a.zipを用意してforgeのinstall.shからの相対パス./mcp_files, ライブラリファイルだけ./mcp_files/lib/に突っ込めばよい. install.shを叩くのが楽になった.

Modのロードの流れとロードに介入する方法

https://github.com/r3qu13m/PythonModLoader を書いた時に調べたことのメモ

Minecraft 1.4.7, Minecraft Forge #534を対象とする.

基本的な流れ

まず最初に cpw.mods.fml.common.Loader#loadMods から始まる. ファイル自体の読み込みと識別, クラス探索はここでは置いておい, 実際にModのpreinit, init等をどのようにして呼んでいるのかについて見ていく.

まずModを探索し, 依存関係によってModをソートした後にリスト自体をImmutableListへ突っ込む. 493行目の時点でModのリストアップは終わっているので, 後はLoaderStateをCONSTRUCTING, PREINITIALIZATION, INITIALIZATIONとtransitionさせるだけ. この時, EventBusへイベントを流していくことでModのinit, preinit等を呼ぶことになる.

ModContainer

coremodsを作るなら大体cpw.fml.common.DummyModContainerを実装する(はず)だが, これについてちゃんと調べた. ModContainer自体はインターフェースで, Modのメインファイルや名前, ModIdについての情報のメソッドを定義している. この中にregisterBusというメソッドがあり, 次のような定義となっている.

    /**
     * Register the event bus for the mod and the controller for error handling
     * Returns if this bus was successfully registered - disabled mods and other
     * mods that don't need real events should return false and avoid further
     * processing
     *
     * @param bus
     * @param controller
     */
    boolean registerBus(EventBus bus, LoadController controller);

このbus変数はその名の通りModのロードについてのEventBus. cpw.fml.common.FMLModContainerクラスではこの時にオブジェクトとして自分自身を登録しており, 次のようなメソッドでイベントを受け取っている.

cpw.fml.common.FMLModContainer 474〜495行目

    @Subscribe
    public void handleModStateEvent(FMLEvent event)
    {
        Class<? extends Annotation> annotation = modAnnotationTypes.get(event.getClass());
        if (annotation == null)
        {
            return;
        }
        try
        {
            for (Object o : annotations.get(annotation))
            {
                Method m = (Method) o;
                m.invoke(modInstance, event);
            }
        }
        catch (Throwable t)
        {
            controller.errorOccurred(this, t);
            Throwables.propagateIfPossible(t);
        }
    }

各フィールドの意味については実際にコードを読んでもらえば分かるが, 見たままアノテーションとイベントのクラスが一致するメソッドを順に呼び出していることが分かる. これは結局, @Mod.Init等のアノテーションの付いているメソッドを順に呼び出していることに等しい.

以上で大まかな流れは終わり.

実際の介入

ここに介入するタイミングはいくつか存在する. 例えば,

  • Modのメインクラスのコンストラクタ (LoaderState.CONSTRUCTING)
  • @Mod.PreInit (LoaderState.PREINITIALIZATION)
  • @Mod.Init (LoaderState.INITIALIZATION)

等が挙げられる. ここでは, Mod自体にinit, preinit, postinitを持たせたかったので, コンストラクタで書くことにした. コンストラクタ内ではReflectionHelper経由でLoadController, EventBus, Modの一覧のリストを取得し, このうちModのリストについてはImmutableListになっているのでこれをLinkedListへ変更する. その後, 適宜検索・JavaのクラスとしてのModを構築し, Mod一覧のリストへこれを追加する. 注意点として, LoadController#getActiveModList()で取得できるリストへModContainerを追加しておかないとActive ModとLoaded Modの数に差が発生し, 見た目だけだが少し微妙になってしまう. また, registerBusも自前で呼ぶ必要がある. 最後にReflectionHelperで操作後のModのリストをセットする. これにより, この後のpreinit, init, postinitでのイベントをModContainerの実装側で受け取れるようになる. PythonModLoaderのPythonModContainer.javaのhandleModStateEventメソッドでは簡単にこれを受け取るだけのメソッドを実装して利用している.

PythonModLoaderではJythonを利用したが, Jythonは少し遅いので実際に使うのであれば標準のRhinoインタプリタや高速ということで有名なJPHPを利用するほうが良いだろう. ファイル監視とRhinoインタプリタを組み合わせれば, JavaScriptで実装を書けるアイテムレンダラなんかも作ることが出来ると思う.

ネタのメモ

  • ケルトンのレアドロにSkeleton's Arrow(テクスチャは普通の矢にポーションキラキラつけただけ)、これを弓で撃ってクリーパーを倒すとレアドロでレコード - coremods?
  • スライムのドロップにSlime Bloodを追加, これでSlime-Bloody Swordを作って使うとスライムが分裂せずに一発で倒せる(ドロップは一体分) - スライムの死亡判定周りでcoremods化必要?
  • BuildCraftの木エンジンがエネルギーパイプに繋がるようにする - 書いた
  • BuildCraftのクァーリー周りの処理を書き直す - 面倒
  • Forestory for MinecraftのアイテムがLP Extractor Mk[1-3]で吸いだされた時にNullPointerExceptionで落ちるバグの修正 - デコンパイラ作ってから or coremodsでファイル差し替えする?
  • 和製版 Additional Pipes 2.0.10のテレポパイプでLPネットワークがつながらないバグの修正 - ソースコードあるしやっておきたい
  • RP2のコンフィグを読み込めるパーサー - RP2のアイテム使いたい
  • フィラーのアイテム回収モード追加Mod - 書いたけど公開面倒なので放置
  • 1.2.5のModの移植 - 面倒
  • コンパイラ作る - 面倒
  • ライブラリの更新, Java 8への対応 - 面倒そう
  • リソースパック?とか1.5.xのModを無修正で使えるようにするようなローダー or コンバーター - 確実に面倒
  • 描画方式の最適化 - 大体検討はついてる
  • Bukkitプラグインをロード出来るようにするcoremods - 面倒

サウンドシステムの扱いについて

Minecraft 1.4.7, Minecraft Forge #534でサウンドシステムを触った時のメモ。


MinecraftのサウンドシステムはPaul's Code SoundSystem (http://www.paulscode.com/forum/index.php?topic=4.0)を利用していることがクレジット等からわかるので、まずはそのソースコードを読む。また、Forge側にもいくつかサウンド関連のイベントが存在するのでそちらも読む。列挙すると、

  • SoundEvent(抽象クラス)
  • SoundSetupEvent
  • SoundLoadEvent
  • PlaySoundSourceEvent
  • PlayStreamingSourceEvent
  • PlaySoundEffectSourceEvent

  • SoundResultEvent(抽象クラス)

  • PlaySoundEvent
  • PlayBackgroundMusicEvent
  • PlaySoundEffectEvent

となる。今回やりたいのはいわゆる効果音の再生なので、用があるのはSoundSetupEventだけになる。

そのSoundSetupEventは、SoundManagerクラスのtryToSetLibraryAndCodecsメソッドでイベントバスに投げられている。実際にやってみると分かるが、このイベントが投げられるのは@Mod.PreInit@Mod.Initの間なので、イベントハンドラの設定は@Mod.PreInitでするべき。

SoundSetupEventは、SoundManagerの初期化終了後に呼び出されるイベントという意味になる。今回のような時はいちいち取得するのも面倒なので、このイベントでSoundManagerを取得し、適当なフィールドに保存しておくのが良いかと思う。

@SideOnly(Side.CLIENT)
public class SoundEventHandler {
  public static SoundManager manager;
  
  @ForgeSubscribe
  public void onSoundSetup(SoundSetupEvent event) {
    manager = event.manager;
  }
}

サウンドを利用するにはまず登録が必要となる。この登録処理はどこで行っても良いが、私はイベントハンドラ内で行っている。 注意点として、.minecraft/resources/***/***のような実際のファイルシステム上のパスではなくSomeMod.class.getResource("***/***")のような取得方法をしている場合、開発環境ではうまく行っても実際にはうまくいかない。理由としては、恐らく内部実装がjar(zip)内データの扱いが出来ないことが原因だと推測する。

登録は以下の形にする。

manager.addSound(登録名, 実体へのFileインスタンス);

登録名とは、"aaa/bbb/ccc.ogg"というような任意の文字列 + 拡張子の形の文字列、実体へのFileインスタンスは、実際のファイルへのFile(java.io.File)インスタンスである。


アイテムのonItemUse内で音を再生する時、次のように書く。

public boolean onItemUse(ItemStack par1ItemStack, EntityPlayer par2EntityPlayer, World par3World, int par4, int par5, int par6, int par7, float par8, float par9, float par10) {
    if (FMLCommonHandler.instance().getEffectiveSide() == Side.CLIENT) {
        System.out.printf("[+] Play Sound!\n");
        SoundEventHandler.manager.playSound("aaa.bbb.ccc", (float) par2EntityPlayer.posX + 0.5F, (float) par2EntityPlayer.posY + 0.5F, (float) par2EntityPlayer.posZ + 0.5F, 1.0F, 1.0F);
    }
    return true;
}

playSoundのパラメータは、登録名から拡張子を除いたもの, 再生するX座標, 再生するY座標, 再生するZ座標, ボリューム, ピッチとなっている。


Streaming等の他の要素についても同様に進めることが出来るが、それについてはソースコードを参照。

チェストのようなブロックのレンダリング処理周りについて

Minecraft 1.4.7, Minecraft Forge #534でチェストのようにアニメーションするブロックを書いた時に感じたことのメモ。

目的はいわばIronChestsのダイヤモンドチェストのような機能を提供するチェストブロックを書くこと。基本はEnderChestのような感じなので、まずはEnderChest関係のコードを読み、実装する。

別に星形等の変な形を作るのでもない限り、ここはMinecraftのデフォルトのチェストのモデルを活用すべきだろう。また、TileEntity, レンダラなどを先のEnderChestの実装から考えると次のような構成が考えられる。->のように短い矢印はextends, implements等継承関係を表し、それ以外の矢印は単なる関係を表している。

BlockMyChest <- BlockContainer
     |
     |
     +--> TileEntityMyChest <- TileEntity, IInventory
     |            |
     +------------+----------> TileEntityMyChestRenderer
                                          Λ
                                          |
                               TileEntitySpecialRenderer

これだけを実装し、適宜登録処理をすればひと通りは動く。しかし、よくよく考えるとTileEntitySpecialRendererの実装範囲はワールド上のブロックのレンダリング処理のみを提供しており、インベントリでの描画についてはノータッチとなる。EnderChestやChestはRenderBlocksクラスのハードコーディング部分でうまく処理分けされているのでちゃんと描画される。しかし、今回のようにデフォルト処理を流用して構築したクラスにはハードコーディングされているために対応できず、結果どうなるかといえばチェストが描画されてしまうのである。

これを解決するには以下のような方法がある。

  • ハードコーディング部分をcoremodsやクラス書き換えを用いて拡張可能なコードに置き換える
  • IronChestで用いられている方法を用いる(後述)
  • 地道にアイテムレンダラを書く(デフォルト処理の流用の恩恵を受けない)

2番目の手法について、実際のソースコードを示す。

ironchest/ClientProxy.java at 2735d04de27f5244458d30818e55ad38fe3ec1d6 · cpw/ironchest · GitHub

ironchest/IronChestRenderHelper.java at 2735d04de27f5244458d30818e55ad38fe3ec1d6 · cpw/ironchest · GitHub

この手法は、RenderBlocksの以下の箇所に関連している。

ChestItemRenderHelper.instance.renderChest(par1Block, par2, par3);

RenderTypeがChest/EnderChestの場合(つまり、getRenderType() == 22)の、ChestItemRenderHelper.instance.renderChestというメソッドを利用してインベントリ描画を行っている。

IronChestでは、このChestItemRenderHelper.instanceフィールドに着目した。instanceフィールドは次のように宣言されている。

public static ChestItemRenderHelper instance = new ChestItemRenderHelper();

見て分かるとおり、const指定がないのでこのフィールドには再代入することが可能だ。さらに、ChestItemRenderHelperクラスはfinal指定がされていないため、拡張クラスを作ることが出来る。これらから、ChestItemRenderHelperクラスの拡張クラスを作り、ChestItemRenderHelper.instanceフィールドを拡張したクラスのインスタンスに置き換えることで、レンダリング処理を任意の差し替えることが可能となる。

この手法にはデメリットが存在する。というのも、見て分かる通りこの手法でのキーとなっているChestItemRenderHelper.instanceフィールドは何度でも書き換えることが出来てしまう。その上、同様の手法を他のModで利用しようとすると、何も考えずに書くと競合してどちらかのModのレンダリング結果がおかしくなってしまう。これについては、個別に対応するか、自動判定処理を書いて一般的に適用出来るようにする他ないだろう。その意味では、この手法は非常にその場任せの適当なコードに見える。(しかし、本来書かないといけなかったアイテムレンダラよりは何十倍も短いコード量となる。)

ITickHandler#tickStart, tickEndの引数の調査

Minecraft 1.4.7, Minecraft Forge #534。

ゲーム内でTickごとに実行される処理を記述するのに使われるITickHandlerと呼ばれるインターフェースが存在する。その中で、実際にTickの始まり/終わりのそれぞれで呼ばれるメソッドがtickStart, tickEndだが、それぞれ定義は次のようになっている。

    /**
     * Called at the "start" phase of a tick
     * 
     * Multiple ticks may fire simultaneously- you will only be called once with all the firing ticks
     * 
     * @param type
     * @param tickData
     */
    public void tickStart(EnumSet<TickType> type, Object... tickData);
    
    /**
     * Called at the "end" phase of a tick
     * 
     * Multiple ticks may fire simultaneously- you will only be called once with all the firing ticks
     * 
     * @param type
     * @param tickData
     */
    public void tickEnd(EnumSet<TickType> type, Object... tickData);

typeはともかく、tickDataはこれだけでは何が渡されるのかも分からない。そこで、呼び出し元を探してこれをまとめてみることにした。

以下、基本的なITickHandlerの理解はできているものとし、TickTypeによって場合分けをする。

基本的に、tickStart/tickEndはcpw.mods.fml.common.FMLCommonHandlerから呼び出される。そして、 on(Pre|Post)\w+Tick()メソッドで実際に値を代入して実行しているため、これを見ることで調査が可能。

TickType.WORLD

FMLCommonHandler#on(Pre|Post)WorldTickメソッドが該当。実際の引数はWorldのインスタンス

TickType.WORLDLOAD

FMLCommonHandler#onWorldLoadTickメソッドが該当。こちらはワールドロード時に呼び出され、読み込み可能なWorld全てについて一度ずつ呼び出される。引数はTickType.WORLDと同じく該当するWorldのインスタンス

このTickTypeの時は、tickEndは呼び出されない。

TickType.SERVER

FMLCommonHandler#on(Pre|Post)ServerTickメソッドが該当。引数はなし。

TickType.CLIENT

FMLCommonHandler#on(Pre|Post)ClientTickメソッドが該当。引数はTickType.SERVERと同様になし。

TickType.Render

FMLCommonHandler#onRenderTick(Start|End)メソッドが該当。引数はPartial Render Timeと呼ばれる補正用?の値。一度レンダリング関連で使ったことがあるものの理解はできていない。

TickType.Player

FMLCommonHandler#onPlayer(Pre|Post)Tickメソッドが該当。引数はEntityPlayerインスタンスで、注意点としてはServer/Client両方で呼び出されること。