steins.gg から移行した記事です。.gg ドメインの維持費が高く、サイト運用を継続できなかったため steins.gg は閉鎖しました。
おはこんばんちは!最近 Minecraft の MOD 開発にハマっている主です!
Minecraft の MOD を作っていると、「毎回同じ計算してるだけなのにやたら重い処理」ってよくありますよね。
そういう処理は、一度計算した結果をキャッシュして使い回すだけで、体感の軽さがかなり変わります。
この記事では、
- Java でキャッシュを書くときの基本的な考え方
- Guava / Caffeine などのキャッシュライブラリの紹介
- Caffeine を使って MOD から手軽にキャッシュする方法
- Caffeine を jar に同梱するための Shadow 設定
あたりを、最低限のコード例と一緒にざっくり紹介します。
「とりあえず動く MOD は作れたけど、重い処理をなんとかしたい」という MOD 開発者向けの入門記事です。
目次
Open 目次
よくあるキャッシュ機構のパターン
素の Map で自前キャッシュ
一番シンプルなのは、Java 標準ライブラリの Map を使って自前キャッシュを書くやり方です。
たとえば、
Map<Key, Value>に結果を保存しておく- get して null なら計算してから put、null でなければそのまま返す みたいなやつですね。
メリット
- 外部ライブラリ不要 デメリット
- ライフサイクル管理(TTL / 最大サイズ / メモリ圧迫時 など)を全部自分で実装する必要がある
- マルチスレッド対応やロックの粒度まで考えると、そこそこしんどい
外部ライブラリを使う
Java 界隈でよく名前が挙がるキャッシュライブラリは、このあたりです。
- Guava Cache
- Caffeine(Guava Cache にインスパイアされて作られた、高性能版ポジション)
特に Caffeine は Window TinyLFU(W-TinyLFU)という賢いエビクションアルゴリズムを採用していて、高いヒット率と効率の良さが売り。 また、Spring Boot など有名な OSS でも採用されているので、「ナウいキャッシュライブラリ」の代表格みたいな立ち位置です。
というわけで、今回は Caffeine を使ってMOD開発の解説をしたいと思います。
実装サンプルと解説
キャッシュなし版
まずはキャッシュ一切なしの実装する場合です。
以下は、プレイヤーが土ブロックを持った状態で地面を右クリックすると、周囲のチャンクを総なめして「各種ブロックの個数」を数えるサンプル実装です。
@Mod(
modid = ChunkBlockScannerSampleMod.MODID,
name = ChunkBlockScannerSampleMod.NAME,
version = ChunkBlockScannerSampleMod.VERSION
)
public class ChunkBlockScannerSampleMod {
public static final String MODID = "chunk_block_scanner_sample_mod";
public static final String NAME = "chunk_block_scanner_sample_mod";
public static final String VERSION = "1";
private static final int SCAN_RADIUS_CHUNKS = 5;
@EventBusSubscriber(modid = MODID)
public static class Events {
@SubscribeEvent
public static void onRightClick(PlayerInteractEvent.RightClickItem e) {
World world = e.getWorld();
if (world.isRemote) return;
if (e.getHand() != EnumHand.MAIN_HAND) return;
EntityPlayer player = e.getEntityPlayer();
ItemStack held = player.getHeldItemMainhand();
if (held.isEmpty()) return;
Block heldBlock = Block.getBlockFromItem(held.getItem());
if (heldBlock != Blocks.DIRT) return;
final long startMillis = System.currentTimeMillis();
BlockPos clickPos = e.getEntityPlayer().getPosition();
final int cpx = clickPos.getX() >> 4;
final int cpz = clickPos.getZ() >> 4;
Map<Block, Integer> total = new HashMap<>();
for (int offsetChunkX = -SCAN_RADIUS_CHUNKS; offsetChunkX <= SCAN_RADIUS_CHUNKS; offsetChunkX++) {
for (int offsetChunkZ = -SCAN_RADIUS_CHUNKS; offsetChunkZ <= SCAN_RADIUS_CHUNKS; offsetChunkZ++) {
int targetChunkX = cpx + offsetChunkX;
int targetChunkZ = cpz + offsetChunkZ;
Map<Block, Integer> part = countOneChunk((WorldServer) world, targetChunkX, targetChunkZ);
part.forEach((k, v) -> total.merge(k, v, Integer::sum));
}
}
final long elapsedMillis = System.currentTimeMillis() - startMillis;
final int chunkCount = (2 * SCAN_RADIUS_CHUNKS + 1) * (2 * SCAN_RADIUS_CHUNKS + 1);
total.forEach((key, value) ->
player.sendMessage(new TextComponentString(key.getRegistryName() + " : " + value)));
player.sendMessage(new TextComponentString(
String.format("Scanned %d chunks: elapsed=%dms", chunkCount, elapsedMillis)));
}
}
private static Map<Block, Integer> countOneChunk(WorldServer ws, int chunkX, int chunkZ) {
BlockPos.MutableBlockPos mutablePos = new BlockPos.MutableBlockPos();
Chunk chunk = ws.getChunk(chunkX, chunkZ);
final int chunkOriginBlockX = chunkX << 4;
final int chunkOriginBlockZ = chunkZ << 4;
Map<Block, Integer> countsByBlockMap = new HashMap<>();
final int worldHeight = ws.getHeight();
for (int offsetX = 0; offsetX < 16; offsetX++) {
for (int offsetZ = 0; offsetZ < 16; offsetZ++) {
for (int yLevel = 0; yLevel < worldHeight; yLevel++) {
mutablePos.setPos(chunkOriginBlockX + offsetX, yLevel, chunkOriginBlockZ + offsetZ);
Block b = chunk.getBlockState(mutablePos).getBlock();
countsByBlockMap.merge(b, 1, Integer::sum);
}
}
}
return countsByBlockMap;
}
}
この実装だと、
- 同じ場所で何度右クリックしても、毎回すべてのチャンクを走査する
- チャンク数が増えるほど
16 × 16 × worldHeightの三重ループが効いてきて、実行の度に重い処理が走る(ゲームが重くなる)。 という状態です。
Caffeine で「チャンク単位キャッシュ」を挟む
ここで、「一度数えたチャンクの結果はしばらく再利用して良さそうだよね」となるので、 チャンク単位でキャッシュを挟みます。
キャッシュ設計
- キー: ディメンションID + チャンクX + チャンクZ(例:
0-10-5) - 値:
Map<Block, Integer>(そのチャンクでのブロック構成)
Caffeine の実装解説
get(key, k -> countOneChunk(...))で「キャッシュが無ければ計算して保存、あればキャッシュを即返す」が 1 行で書けるmaximumSize,expireAfterAccessなどでライフサイクル管理も簡単に実装できる
以下は、実際にキャッシュ機構を実装する際の差分です。これだけ少ない変更量でキャッシュ機構を挟めます。
+ // ディメンションID+チャンク座標をキーにした Caffeine キャッシュ
+ private static final Cache<String, Map<Block, Integer>> CHUNK_CACHE =
+ Caffeine.newBuilder()
+ .maximumSize(512) // この例では 512 チャンク分まで保持
+ .expireAfterAccess(60, TimeUnit.SECONDS) // 60秒アクセスが無ければ自動的に破棄
+ .build();
~~~~~~~~
~~~~~~~~
- Map<Block, Integer> part = countOneChunk((WorldServer) world, targetChunkX, targetChunkZ);
+ // 「ディメンションID-チャンクX-チャンクZ」をキーにキャッシュ
+ Map<Block, Integer> part = CHUNK_CACHE.get(
+ world.provider.getDimension() + "-" + targetChunkX + "-" + targetChunkZ,
+ k -> countOneChunk((WorldServer) world, targetChunkX, targetChunkZ));
~~~~~~~~
~~~~~~~~
- チャンクスキャン
- 経路探索
- 構造物生成の前処理 みたいな「入力が同じなら結果も同じ」系の重い処理はMOD開発をしていると本当に多いので、 こうやって 1 行キャッシュを挟めるようにしておくと、かなり開発もユーザー体験もラクになります。
マルチプレイ環境だとさらに効果的
ちなみに、この手のキャッシュは シングルプレイでもちゃんと効果がありますが、マルチプレイ環境だとさらにおいしい です。
今回の例のように「ワールド地形」「構造物の有無」みたいな、プレイヤー間で共有される情報を対象にしておけば、
- シングルプレイ
→ 自分が同じ場所で何度も処理を呼んでも、一度計算した結果を再利用できる - マルチプレイ(サーバー)
→ A さんが計算した結果を、あとから来た B さん・C さんもそのまま使い回せる
という形になります。
サーバー側でキャッシュを挟んでおくと、複数プレイヤーから同時に重い処理を呼ばれたときでも、
- 「最初の 1 回だけガチ計算」
- 「あとはキャッシュから即返す」
という動きにできるので、TPS の安定性やレスポンスもかなり変わってきます。
他ライブラリを使う場合は jar に同梱する必要あり
今回の例で利用している Caffeine は外部ライブラリです。
Gradle の implementation / shade で追加したライブラリは、開発時のクラスパスには乗るが、最終的な mod jar には自動では入らない。 Minecraft は「mods フォルダ内の jar だけ」を読み込むので、外部ライブラリ同梱していないと、作成した mod jar を利用するユーザーの環境では NoClassDefFoundError になってしまいます。
そこで、shadow plugin などを使って fat jar(依存込み jar)を作る必要があります。
以下は shadow plugin を使って一つの jar に同梱する例です。
// ビルドスクリプト自身のクラスパスに Shadow プラグインを追加
buildscript {
repositories { maven { url = "https://plugins.gradle.org/m2/" } }
dependencies { classpath "com.github.jengelman.gradle.plugins:shadow:2.0.4" }
}
apply plugin: "com.github.johnrengelman.shadow"
configurations {
shade
// shade に追加した依存を開発環境でも利用できるようにするため
implementation.extendsFrom(shade)
// MDK が compile を使っている場合はこちら
// compile.extendsFrom(shade)
}
repositories {
// Caffeine 取得用
mavenCentral()
}
dependencies {
// Caffeine を shade として追加
shade 'com.github.ben-manes.caffeine:caffeine:2.9.3'
}
// deobf 開発用
jar { classifier = "dev" }
shadowJar {
classifier = ""
// 他の依存まですべて fat-jar 化しないようにする
configurations = [configurations.shade]
// 他の MOD が別バージョンの Caffeine を同梱していても衝突しないようにリロケートする
relocate "com.github.benmanes.caffeine", "${project.group}.shadow.caffeine"
}
// ForgeGradle に shadowJar のほうを再難読化してもらう
reobf {
shadowJar {}
}
build.dependsOn reobfShadowJar
よくある便利ライブラリは、他の MOD でも使われていることが多いので、以下の問題が起きがちです。
- そのまま同じパッケージ名で同梱するとクラスが衝突する
- バージョン違いが同時にロードされてクラッシュ、ということもある
パッケージをリロケートしておくことで、他の MOD が別バージョンの Caffeine を同梱している場合でもお互いにクラスが衝突しない状態を作れるので、マルチ MOD 構成環境でも安心です。
まとめ
MOD開発で「毎回同じ計算してるのに重い」処理があるなら、まずはキャッシュを挟むのが手っ取り早いです。
最初は Map でもいいですが、TTL や最大サイズ、スレッド安全性まで考え始めると一気にしんどくなるので、Caffeine がおすすめです。




