Minecraft 1.4.7 on Java 21

はじめに

Minecraft 1.4.7からアップデートしないままに11年が過ぎた. 最初は1.5.xのテクスチャ周りの変更に追従しきれないmodの対応待ちであったが, MojangがMinecraftMicrosoftに明け渡したこと(私はこれを開発終了宣言だと見做している), またアップデートしたところで大して面白くもない要素を追加し続けるだけだったこと, 何より面白い要素は随時1.4.7環境に自力で取り込んでしまっていたこと等から, 5年を過ぎたあたりでもうMinecraftというソフトウェアを完全に自力でメンテナンスし続けることに決めていた.

とはいえここ3年程は(転職等も相俟って)プレイ時間も漸減しており, 今の私にとってのMinecraftとは開発フレームワークの一種である. 事実, 本業で触れることがないタイプの大規模な1開発の総指揮を取り, またその開発に携わることが今の最大の開発目的なのである.

さて, 個人で開発し続けるだけならば特に問題ないわけだが, 今の私はとある事情からマルチサーバーの利用者というステークホルダー, そして数人の協力者を得た状況にある. このため, 既にその維持管理が切実な問題として顕れている. 本記事はその一環として, Minecraft 1.4.7をJava 21で動作させるまでの道のりを軽く要約しておくものである.

留意事項

Minecraftや周辺のMod本体に深く手を入れる必要があるため, パッチの公開とコード本体の公開がほぼ同義になるような状況である. このため, 本記事に書かれる内容に関してコードを公開する予定はなく, また一般にバイナリを公開する予定もない. あくまで本記事は通過点を自分のために要約する備忘録としての役割を担うものである.

加えて, コードをそのまま共有するとEULAに抵触するような箇所も存在しており, パッチのみを共有するとしても労力的に面倒である. したがって, 開発に協力できるという申し出があったとしても, 基本的に全て拒否させていただくこととなる. 公開している範囲でPRを送ってもらう / Issueを立ててもらう分には構わない.

歴史的事情の整理

Minecraft 1.4.7はJava 5を基準とした2ソフトウェアであり, そのままではJava 9以降で利用不可能なレガシーソフトウェアだ. Minecraft Forge #534 のリリース時点でJava 6までは利用可能な扱いであった3が, 実際にはこのパラメータもJava 7までは書き換えて開発・利用していた記憶がある4.

大きな転機はJava 9のリリース時に訪れた. Java 9はProject Jigsawと呼ばれるモジュールシステムを導入したバージョンであり, クラスパスシステムは事実上非推奨となった. Minecraft ForgeやModLoaderはModファイルをクラスパスに追加するという形でModのロードシステムを実現しており, クラスパスシステムを通じない形への移行とは即ち方式全体を改訂することを迫られるものであった. 加えてLWJGLのJNI APIも非互換となり, Java 9では本体を含めてまともに動作しないコードだらけとなった.

加えて, 開発者視点ではJava 8の時点で既に問題が発生していた. MCPに利用されていた難読化・難読化解除用の変換ソフトウェア(Retroguard)はInvokedynamic命令へのサポートが不十分であり5, Java 8を用いたコードを通した際に難読化不可能な場合があった. Minecraft Forgeで利用されていたライブラリであるObjectweb ASM 4.xはそもそもJava 8のバイトコードに対応しておらず, 仮にRetroguard問題を解決できたところで, ユーザー環境にロードさせることは叶わなかった6. 開発環境そのものの構築にも支障が出るようになっており, 事実私が重い腰を上げてJava 8対応を試みた際には長々とパッチを書く羽目になった7.

Javaはその後も次々変更されていき, いつしかJava 17やJava 18といった番号にまでなった. しかしながら, Java 8のサポートはまだ続く. Sun MicrosystemsもといOracleJava 8からJava 9の間に崖が存在することは認知しており, Java 8はExtended Supportを設定したLTSとしてサポート期間を伸ばしていたのである. そうやって伸びた期間は最終的に2030年12月まで. 一方Java 11のサポート期間は当初2026年9月8, Java 17に至っては2029年9月である.

ところが, 昨年登場したLTSであるJava 21のExtended Support期限は2031年9月である. Java 8よりも長く使えるJavaがとうとう登場したということで, いい加減に移植するかという本腰が入った. これが本記事に繋がる開発が開始された経緯である.

Java 8までの流れ

Java 21の話が登場するよりも以前, 2015年頃からJava 8を使えないかという試行錯誤は実施していた. 本節はまずその点について記す.

上にも書いた通り, まずは兎にも角にもRetroguardをなんとかせねばならない. ただ, ASMの問題を考慮すると安易に持ち込めるものでもない. そこでまずはluontola/retrolambdaを適用してJava 7基準のクラスファイルに統一することで事なきを得た. メモリ効率等は多少犠牲になるものの, この案そのものはかなり良いworkaroundであった.

ただretrolambdaはAPIの変換まではしない. よって我々はAPI的にはJava 7に縛られたままであった. デフォルトメソッドも基本的に命令書き換えで対応するため, たとえば適当なMinecraft本体のインターフェイスにdefault methodをcoremodで追加し, 透過的に全てのMinecraftクラスから当該メソッドを利用可能であるかのように思わせることも不可能である. static methodも同様. Stream APIに限ってはstefan-zobel/streamsupportを利用して解決できたが, それ以外にはこのような制限があった.

これらを解消するため, Retroguard本体の改良を思い立ち, これを実施した. ローレイヤーな世界はもともと私の本職エリアでもあり, 仕様とやりたいことが決まっていれば特に難しい要素はなかった.

ASMの問題があるためretrolambdaの利用は変わらず継続していたが, もともとreobfuscate前の(Minecraft本体を含めた全ての)クラスをretrolambdaにかけていたところ, 必要最低限のクラスのみがretrolambdaに渡るようになったため, ビルド時間の高速化には寄与した.

このような体制でしばらく動いていたところ, 2021年3月に https://files.minecraftforge.net/fmllibs/ が廃止された. 各種ライブラリのダウンロード元がこのURLであったため, 1.4.7を含めた古いバージョンのMinecraft + Minecraft Forge環境におけるライブラリダウンロード処理は最早動作しない. 手動で配置すれば動作するものの, 我々にとってはこれ幸いとばかりにこの部分に手を加える口実になった. ASMは5.2にアップデートされ, 他のライブラリはMavenからダウンロードするようになる. retrolambdaも外せた. これによってJava 8対応は大まかに完了となる.

問題点の洗い出し

実のところ, Java 14, Java 17, Java 20とそれぞれのバージョンで何度かアップグレードにトライしていた. その際にある程度問題点は判明していたので, 以下に列挙しておく.

  • 先述のRetroguard問題
    • Java 8には対応したものの, それ以降には未対応
  • 先述のASM問題
    • ASM 5.2はJava 8程度までしか対応できない. Java 21まで行くなら最新版のASMに追従する必要がある.
  • クラスパス問題を解決するためにはクラスローダ周りに手を入れなければならないが, RelaunchClassLoader周りに手を入れるにはどうしてもjar書き換えが必要になってしまう
    • この辺りに手を入れようとして頑張っていた時期の副産物が r3qu13m/PythonModLoader である
  • LWJGL 2からLWJGL 3への対応に苦戦する
    • 2.6.6 LWJGL3 migration にあるような形でmigrateすればよい
    • それに関しては何も難しくはないのだが, いかんせん手を入れなければいけないコードが多すぎる
    • GLFWへの移行が最大の問題
    • 仮にMinecraft本体をGLFW対応させられたところで, 100個近くある常用Mod全ての検査をしなければならない
    • かといってLWJGL2を使い続けようとすればJNIのバージョン問題を踏む
  • EnumHelperを始めとした黒魔術の存在
    • Javaはリフレクションによって様々なコードのオブジェクト指向性が剥奪可能であることは周知の事実
    • private finalなメソッドを外から勝手に書き換えるようなことも可能であるが, その中でもEnumに関しては特別な立場にあり, 通常の手続きではfinal外しに失敗する
    • でも sun.reflect.ConstructorAccessorsun.reflect.ReflectionFactory といったInternal APIを利用すれば書き換えられた. これを用いて動的に列挙子を追加していたのが net.minecraftforge.common.EnumHelper
    • ところが, Java 9以降, 特にJava 17以降においてはInternal APIの隠蔽度が上がり, 従来Enumに対して適用できたfinal外しの手法はほぼ塞がれてしまっていた
    • しかしこれを解消するためにはかなりの量の書き換えを必要としてしまう
  • mcp自体の古さ
    • 10年前のSDKを使い続けるのは様々な点で厳しい
    • Python 2.xを早く消し去りたいし, Antをだましだまし使っていたものの, 流石に管理プロジェクト数が増えると扱いづらくなってきた

対応作業

以上の問題を時系列でどう解消していったかをメモしておく.

MCP / Retroguard

まずはMCPをGradleベースに書き直すこととした.

最新版のMinecraftではFabricかForgeGradleのほぼ二択になるようで, Forge-basedに考えたいのでFabricは論外. ForgeGradleもレガシー版を開発している人間を発見できた(RetroGradle/ForgeGradle).

ただし, MinecraftForgeに対して私はもう信用を置いていない. 便利なAPIであり, 大変世話になっているのは事実そうなのだが, そもそも彼ら彼女らの見ているものは最新版であって, 1.4.7という化石のようなバージョンのサポートは次々に切っていくフェーズにある. また, LexManosとcpwで揉めてNeoForgedに分裂した事件は今でも記憶に新しい9し, https://files.minecraftforge.net/fmllibs/ の件でも依存先にするにはちと困るなあという印象を持っていた. 頼らないので良いのであれば頼らずに済ませたいというのが本音である. なお, 難読化マップに関してはSu5eD/MCPConfigに存在する10ものの, よくよく掘っていくとこれもSu5eD氏のMavenサーバーを利用せねばならない形になっていたために除外. そもそもとして, Forge本体はおろかMinecraft本体にかなり手を加えなければならない状況においてForgeGradleは使いづらい.

他にも検討はしたが, 大まかには上の通りの理由からForgeGradleを検討対象から外した. すると候補はほぼ存在しなくなり, SDK全体の再開発が必須となった. ついでなのでRetroguardをJava 20まで対応させる作業も実施し, これを踏まえてGradleによるプロジェクト管理に全面移行. GitHub ActionsによるCIも回しやすくなり, 管理も楽になった.

MCPは難読化マップのライセンスの問題から公開していないが, RetroguardはGPLv2ライセンスであったためMeiServer/Retroguardにて公開している. 公式にもプルリクエストを送ったがrejectされた. その理由は「もうこれはメンテナンスしていないし, ForgeAutoRenamingToolなんかの代替があるからそっちを使ってくれ」ということであった11. 他にもバグを発見次第修正している.

LWJGL

コンパイルターゲットをJava 8に変更し, javahコマンドが消滅したところの修正を入れたところ, Java 21でも使えるLWJGL2バイナリをうまく作成できた. そのため特にそれ以外はいじっていない.

なお, 私がLinux環境しか使わないせいでWindows環境等での利用可能性は不明である. AArch64環境下での実行を念頭に置くといずれどこかでgdbと向かい合う日々となりそうな予感がある. ただし, この点に関しては r58Playz/lwjgl2-m1 に完成品が存在するため, これを / これにマージするという方向性でも考えたほうがよいだろう.

ASM / Guava

ASMは9.5まではアップデートできている. 9.7へのアップデートは特に変更なしに済むものと思われるが, 未検証. Guavaも33までアップデートすることを考えているが, 現状MCPC+のみに適用できている状況である.

クラスパス問題

cpw/modlauncherでは動くという話だったのでそれを見に行く. クラスパス探索を自前でやってしまえば特に問題ないという話のようだったので, 適当にjava.class.pathよりクラスパス情報を引っ張ってくるだけのコードを書けば事足りた.

diff --git a/src/cpw/mods/fml/relauncher/FMLRelauncher.java b/src/cpw/mods/fml/relauncher/FMLRelauncher.java
index eb8ea45..d587b2f 100644
--- a/src/cpw/mods/fml/relauncher/FMLRelauncher.java
+++ b/src/cpw/mods/fml/relauncher/FMLRelauncher.java
@@ -3,7 +3,12 @@
 import java.applet.Applet;
 import java.io.File;
 import java.lang.reflect.Method;
+import java.net.MalformedURLException;
+import java.net.URL;
 import java.net.URLClassLoader;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
 
 import javax.swing.JDialog;
 import javax.swing.JOptionPane;
@@ -39,9 +44,16 @@ static FMLRelauncher instance() {
    }
 
    private FMLRelauncher() {
-      URLClassLoader ucl = (URLClassLoader) getClass().getClassLoader();
+       String classpath = System.getProperty("java.class.path");
+       List<URL> classPathValues = Arrays.stream(classpath.split(File.pathSeparator)).map(x -> {
+           try {
+               return new File(x).toURI().toURL();
+           } catch (MalformedURLException e) {
+               throw new RuntimeException(e);
+           }
+       }).collect(Collectors.toList());
 
-      classLoader = new RelaunchClassLoader(ucl.getURLs());
+       classLoader = new RelaunchClassLoader(classPathValues.toArray(new URL[0]));
 
    }

EnumHelper

結論から言うと, sun.misc.Unsafe を用いることで解決可能である. getDeclaredFields0 辺りの実行に --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED を必要とするため, 本機構のためにランチャーはオプションについて配慮せねばならず, またJava8以前 / Java9以降を同一コードで実現するのは難しい可能性がある. この点に関しては今後の検討課題である.

diff --git a/src/net/minecraftforge/common/EnumHelper.java b/src/net/minecraftforge/common/EnumHelper.java
index 8efe3aa..608c51c 100644
--- a/src/net/minecraftforge/common/EnumHelper.java
+++ b/src/net/minecraftforge/common/EnumHelper.java
@@ -3,11 +3,16 @@
  */
 package net.minecraftforge.common;
 
+import sun.misc.Unsafe;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
 import java.lang.reflect.AccessibleObject;
 import java.lang.reflect.Array;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Map;
@@ -137,6 +142,8 @@ private static void setup() {
            return;
        }
        try {
+           /*
+           FIXME: these methods are not able to use on Java 20 
            final Method getReflectionFactory = Class.forName("sun.reflect.ReflectionFactory")
                    .getDeclaredMethod("getReflectionFactory", new Class[0]);
            EnumHelper.reflectionFactory = getReflectionFactory.invoke(null, new Object[0]);
@@ -148,40 +155,62 @@ private static void setup() {
                    .getDeclaredMethod("newFieldAccessor", Field.class, Boolean.TYPE);
            EnumHelper.fieldAccessorSet = Class.forName("sun.reflect.FieldAccessor").getDeclaredMethod("set",
                    Object.class, Object.class);
+           */
        } catch (final Exception e) {
            e.printStackTrace();
        }
        EnumHelper.isSetup = true;
    }
 
-  private static Object getConstructorAccessor(final Class<?> enumClass, final Class<?>[] additionalParameterTypes)
-          throws Exception {
+   private static MethodHandle getConstructorAccessor(final Class<?> enumClass,
+           final Class<?>[] additionalParameterTypes) throws Exception {
        final Class[] parameterTypes = new Class[additionalParameterTypes.length + 2];
        parameterTypes[0] = String.class;
        parameterTypes[1] = Integer.TYPE;
        System.arraycopy(additionalParameterTypes, 0, parameterTypes, 2, additionalParameterTypes.length);
-      return EnumHelper.newConstructorAccessor.invoke(EnumHelper.reflectionFactory,
-              enumClass.getDeclaredConstructor(parameterTypes));
+       Constructor<?> c = enumClass.getDeclaredConstructor(parameterTypes);
+       c.setAccessible(true);
+       return MethodHandles.lookup().unreflectConstructor(c);
    }
 
    private static <T extends Enum<?>> T makeEnum(final Class<T> enumClass, final String value, final int ordinal,
-          final Class<?>[] additionalTypes, final Object[] additionalValues) throws Exception {
+           final Class<?>[] additionalTypes, final Object[] additionalValues) throws Throwable {
        final Object[] parms = new Object[additionalValues.length + 2];
        parms[0] = value;
        parms[1] = ordinal;
        System.arraycopy(additionalValues, 0, parms, 2, additionalValues.length);
-      return (T) (Enum) enumClass.cast(EnumHelper.newInstance
-              .invoke(EnumHelper.getConstructorAccessor(enumClass, additionalTypes), new Object[] { parms }));
+       return enumClass.cast(getConstructorAccessor(enumClass, additionalTypes).invokeWithArguments(parms));
    }
 
    public static void setFailsafeFieldValue(final Field field, final Object target, final Object value)
            throws Exception {
        field.setAccessible(true);
-      final Field modifiersField = Field.class.getDeclaredField("modifiers");
+       // https://stackoverflow.com/questions/74723932/java-17-reflection-issue/74727966#74727966
+       Method getDeclaredFields0 = Class.class.getDeclaredMethod("getDeclaredFields0", boolean.class);
+       getDeclaredFields0.setAccessible(true);
+       Field[] fields = (Field[]) getDeclaredFields0.invoke(Field.class, false);
+       Field modifiersField = null;
+       for (Field each : fields) {
+           if ("modifiers".equals(each.getName())) {
+               modifiersField = each;
+               break;
+           }
+       }
        modifiersField.setAccessible(true);
-      modifiersField.setInt(field, field.getModifiers() & 0xFFFFFFEF);
-      final Object fieldAccessor = EnumHelper.newFieldAccessor.invoke(EnumHelper.reflectionFactory, field, false);
-      EnumHelper.fieldAccessorSet.invoke(fieldAccessor, target, value);
+       modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
+
+       final Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
+       unsafeField.setAccessible(true);
+       final Unsafe unsafe = (Unsafe) unsafeField.get(null);
+
+       if (target == null) {
+           final Object staticFieldBase = unsafe.staticFieldBase(field);
+           final long staticFieldOffset = unsafe.staticFieldOffset(field);
+           unsafe.putObject(staticFieldBase, staticFieldOffset, value);
+       } else {
+           final long offset = unsafe.objectFieldOffset(field);
+           unsafe.putObject(target, offset, value);
+       }
    }
 
    private static void blankField(final Class<?> enumClass, final String fieldName) throws Exception {
@@ -225,7 +254,7 @@ public static <T extends Enum<?>> T addEnum(final Class<T> enumType, final Strin
        if (!EnumHelper.isSetup) {
            EnumHelper.setup();
        }
-      AccessibleObject valuesField = null;
+       Field valuesField = null;
        final Field[] fields = enumType.getDeclaredFields();
        final int flags = 4122;
        final String valueType = String.format("[L%s;", enumType.getName().replace('.', '/'));
@@ -243,11 +272,11 @@ public static <T extends Enum<?>> T addEnum(final Class<T> enumType, final Strin
            final ArrayList<Enum> values = new ArrayList<>(Arrays.asList(previousValues));
            final T newValue = EnumHelper.makeEnum(enumType, enumName, values.size(), paramTypes, paramValues);
            values.add(newValue);
-          EnumHelper.setFailsafeFieldValue((Field) valuesField, null,
-                  values.toArray((Enum[]) Array.newInstance(enumType, 0)));
-          EnumHelper.cleanEnumCache(enumType);
+
+           setFailsafeFieldValue((Field) valuesField, null, values.toArray((T[]) Array.newInstance(enumType, 0)));
+           cleanEnumCache(enumType);
            return newValue;
-      } catch (final Exception e) {
+       } catch (final Throwable e) {
            e.printStackTrace();
            throw new RuntimeException(e.getMessage(), e);
        }

その他

細々とした仕様変更に纏わるバグをいくつも修正する必要があった. 例えばRedPower2は(Integer[]) Arrays.asList(1, 2, 3).toArray()に相当する処理を入れていたためクラッシュする12し, rptweaksはASMのバージョンアップに伴いvisitMethodInsnの引数追加に追従する必要があった. いずれもJava 8 compatibleに修正可能である.

結果

2024/07/31時点で, MCPC+サーバー・プラグインを含めたほぼフルセットの環境が動作している. ただし未だステージング環境であり, クラッシュバグや, 落とされた後ワールドに再度入れなくなるタイプのバグも複数報告されている. coremodの変更は幸い少なく済み, OptifineやShadersModといったJAR書き換え系Modをcoremod化すれば残るはバグ修正のみ. 既存環境用のビルドはJava 8環境で動作しているCIがチェックしてくれるので, ユーザー側に影響することはない. しばらくは同時並行でJava 8 compatibleなコードを中心にマージしていく作業が続きそうだ.

コミュニティ的な貢献はRetroguardぐらいしか存在しない状況であるが, いずれ公開できるものは公開していきたいと考えている. ただ, 私のスタンスとして「Minecraft 1.4.7からアップデートしないために書く」という立場からの公開になるため, 世間とのギャップは多かれ少なかれ存在するであろう. これに伴う認識の相違から始まる謎のサポートが発生することを考えると少し微妙な気分になるところである. もうそれは本業でやっているし, 趣味なんだから使う人間を突き放すぐらいしてもいいのではないか?

終わりに

雑な紹介にはなったが, これが最新の1.4.7である. これを地味と捉えるか変なやつだと捉えるか, はたまたバカだと捉えるか, その辺りは読者諸賢にお任せする. 私にとってマイクラとは面白い開発プラットフォームであり, そのサンドボックス性はゲームから飛び出しコードになっても変わっていないなと思っている次第である.

ここには書いていないが, プロジェクト遂行に際して必要な補助ツールとしてVite + Vue3 + Vercelなツールを書いていたり, self-hosted runnerの環境構築用にDockerを利用していたり, デプロイパイプラインのためにいくらかサービス開発をしたりと実に様々な技術が投入されてきている. そしてそれらは全て開発の土台となるべくして投入した. 実際にユーザーの触る部分は従来通りのModding風景と変わっておらず, 体験はそのまま変わらないようにできている. 地味だがこのことが何より喜ばしい.

ゲーム開発と言うとやはりユーザーの手に触れる箇所ばかりが取り沙汰されがちだが, 10年後を見据えて開発していくためには, SDKの更新・最新プレイ環境への追従といったソフトウェア的なインフラ整備, GitHub Projectsを用いたカンバンの導入, 或いはそこまで行かずとも「とりあえずissueを登録してもらう(登録できるようにしておく)」というような 文化的なインフラ の整備, そして何より技術的なチャレンジ要素によって飽きずに居られること等, 土台作りにも時間を十分に割く必要があると考える.

Java 21対応は実のところメインラインではなく, 私個人の独断でやっているプロジェクトである. しかしブランチ切り替え一個で済ませられる程度には環境切り替えも容易であり, 現状EnumHelperを除いてはどちらの環境でも上手に動くようなコードを書くことにも成功している. このまま実運用まで持っていきたいところだ.


  1. 2024/07/31現在私の管理下に存在するコードはおおよそ100万行と少し存在する(拡張子にjavaをもつ8349ファイルそれぞれの行数の総和). 開発者が数人しかいないコミュニティとしてはまあまあな量だと経験上感じる量.
  2. 当時のMinecraft.jarのクラスをjavapコマンドで眺めると49.0というバージョンを確認できる. これはJava 5でコンパイルされたクラスファイルであることを意味する.
  3. mcp 7.26aのデフォルト設定は1.6である
  4. 私が初めてMinecraftに触れた際にはJava 7が最新版であり, 寧ろLWJGLの更新と共に最新版の利用が推奨されていた.
  5. 今でこそラムダ式やString concatenationのイメージが強いInvokedynamicであるが, もともとはJRubyのようなJVM系統の言語におけるJIT性能の向上が目的であったため, サポートがおざなりであったのだろうと推察される. 以下に示すスライドにより詳しい事情が記載されているため, 読んでおくことを推奨する.
  6. アップデートすればいいじゃん, という向きもあろう. Minecraft Forgeが当時採用していたライブラリダウンローダはcoremodですら書き換え不可能な初期段階に利用されており, いわゆるjar書き換え必須な状態だったのである. coremod側の対応状況もASM4に偏っている状況を踏まえると, アップデートは事実上不可能だと結論する他なかったのである.
  7. Java8においてForgeの完全動作のために書いたパッチ類 - https://r3qu13m.hatenablog.com/entry/2018/01/08/061936 なおこの時点でASMを5.2にアップデートしようと試行しているが, Retroguard問題を解消できていなかったので, このパッチだけでは動作しない.
  8. 2023年に顧客要望で更新され, 2032年1月となった.
  9. What is happening? - https://neoforged.net/news/theproject/
  10. これはMCP本体の規約(再配布の禁止等)に抵触するのではないかとSu5eD氏に直接問い合わせたところ, Discordでcpw / LexManosに許可を取ったという話であった. LexManosもcpwもMCP Teamsの人間として当該マップファイルの開発に参加していたわけではなかったと記憶していたが...?
  11. https://github.com/ModCoderPack/Retroguard/pull/3#issuecomment-1730469067 だったらArchiveしてくれ...
  12. (coll) Arrays.asList(x).toArray().getClass() should be Object[].class - https://bugs.openjdk.org/browse/JDK-6260652 により, Java9以降の Arrays.asList はObjectの配列を返すようになった. Java8以前では単にcloneを返していたことから先述のコードはvalidであったわけだが, この変更によって純粋な Object[] からのキャスト試行となり, キャスト失敗からの例外発生と続く. toArray(new Integer[0]) にすれば解決する.

ブロックレンダラ, 特に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のレンダリング結果がおかしくなってしまう。これについては、個別に対応するか、自動判定処理を書いて一般的に適用出来るようにする他ないだろう。その意味では、この手法は非常にその場任せの適当なコードに見える。(しかし、本来書かないといけなかったアイテムレンダラよりは何十倍も短いコード量となる。)