さて、3次元グリッドのパズルゲームを作るときに避けて通れない最大の視覚的課題が「オクルージョン(手前のブロックが奥や中心のブロックを隠してしまう死角問題)」です。3×3×3の盤面では27マスのうち外周の26マスがすべて埋まると、ど真ん中のコア座標(1, 1, 1)はどの角度からカメラを回しても外側のブロックに遮られてしまいます(4×4×4では内部の2×2×2=8マスが完全に埋もれます)。
「真ん中のマスに何の数字が入っているか分からないまま勘でスワイプしたらゲームオーバーになった」というのはパズルゲームとして最悪の体験なわけです。そこでjs/game-renderer.jsに実装した2つの死角解消機構「X-Ray(外殻透過)モード」と「Layer Slicer(階層断面表示)」、および外部画像アセットを1枚も使わずに全タイルの文字盤を生成しているCanvas2Dプロシージャル・テクスチャ生成器の仕組みを解説します。
01. 外殻タイル判定「isOuterTile(gx, gy, gz)」とX-Rayモードの透過パラメータ
まず、どのブロックが「外側から見える外殻(Outer)」で、どのブロックが「外殻に囲まれた内部コア(Inner)」なのかを判定する関数isOuterTile(gx, gy, gz)を見てみましょう。
// js/game-renderer.js : 外殻タイルか内部コアかの判定ロジック
isOuterTile(gx, gy, gz) {
const max = this.size - 1;
return gx === 0 || gx === max || gy === 0 || gy === max || gz === 0 || gz === max;
}
3次元グリッドにおいて、X・Y・Zいずれか1つの座標でも0またはthis.size - 1(外周の端)に接していれば、そのタイルはキューブの6面のどこかに露出している外殻タイルです。逆に、すべての座標が0 < g < max(3×3×3なら(1, 1, 1)の1マス、4×4×4ならx, y, z ∈ {1, 2}の8マス)であるタイルは、外周から完全に隔離された内部コアとなります。
プレイヤーが画面の「X-Ray」ボタン(またはXキー)をオンにすると、applyTileVisibility(mesh)メソッドが各タイルのマテリアルを次のように書き換えます。
// js/game-renderer.js : applyTileVisibility(mesh) 内のX-Rayモード分岐
if (this.xrayMode) {
if (outer) {
targetOpacity = Math.min(targetOpacity, 0.28);
} else {
targetOpacity = 1.0;
targetEmissive *= 2.2;
}
}
- 外殻タイル(
outer === true):不透明度(opacity)を通常の0.95から0.28(約72%透過)まで一気に落とします。 - 内部コアタイル(
outer === false):不透明度を1.0(完全不透明)に引き上げた上で、自己発光強度(emissiveIntensity)を2.2倍にブーストします。
単に外側のブロックを薄くするだけでなく、中心にあるブロックの発光強度を2.2倍に跳ね上げることで、外殻の半透明ガラス越しでも内部の数字がくっきりと浮き上がって見えるわけです。
02. WebGL半透明描画の罠:「depthWrite = false」を切り替え忘れると何が起きるか
ここでThree.js(WebGL)を触ったことがある方ならピンとくるのが、「半透明オブジェクトのZバッファ(深度バッファ)書き込み問題」です。
WebGLでは通常、手前にあるオブジェクトを描画した際にそのピクセルの奥行き(深度値)をデプスバッファに書き込み(depthWrite = true)、後から描画される奥のオブジェクトを「手前に遮蔽物があるから描画しない」とカリングします。ところが、外殻タイルのopacityを0.28に下げたときにdepthWrite = trueのまま放置してしまうと、「手前の半透明タイルがデプスバッファを埋めてしまい、その奥にあるはずの中心タイル(1, 1, 1)が描画テストに落ちて消えてしまう」という怪現象が発生します。
// js/game-renderer.js : 透過率に応じた depthWrite の動的切り替え
mat.opacity = targetOpacity;
mat.emissiveIntensity = targetEmissive;
mat.depthWrite = targetOpacity > 0.5;
この1行mat.depthWrite = targetOpacity > 0.5;が極めて重要です。不透明度が0.5を超える通常表示時(0.95)やX-Ray中の内部コア(1.0)ではdepthWrite = trueとして正確な前後関係を保ちつつ、不透明度が0.5以下に落ちた半透明の外殻タイルでは自動的にdepthWrite = falseへ切り替えることで、奥にある内部コアのピクセルが確実に画面へ届くように制御しています。
03. 特定フロアだけを浮かび上がらせる「Layer Slicer」とネオンスライスガイド
X-Rayモードが「外殻と内部」を切り分ける機能だとすれば、もうひとつの「Layer Slicer(階層断面表示)」は、Y軸の高さ(Floor 1〜Floor 3/4)ごとに盤面を輪切りにして観察する機能です。
// js/game-renderer.js : Layer Slicer 有効時の非選択フロア減衰
if (this.sliceLayer !== null && gy !== this.sliceLayer) {
targetOpacity = 0.08;
targetEmissive *= 0.25;
}
例えばプレイヤーが「1F(最下層 gy = 0)」を選択すると、2F以上のタイルはすべて不透明度0.08(ほぼ幽霊のような輪郭のみ)・発光強度0.25倍まで減衰し、1Fの9マス(または16マス)だけが完全な明るさで表示されます。さらにupdateSliceGuide()メソッドによって、現在選択しているフロアの周囲にシアン色(0x00f3ff)のネオンワイヤーフレーム枠と半透明スライス平面が描画されるため、「今どの高さを観察しているか」を空間的に見失わないようになっています。
余談ですが、X-RayとLayer Slicerはキーボードの数字キー(1 / 2 / 3 / 4でフロア切替、0で全層復帰、XでX-Ray切替)にすべてバインドしてあるので、慣れると左手だけで断面をスパスパ切り替えながら思考できるようになります。
04. 画像ファイル0枚!512×512 Canvas2Dによるプロシージャル・テクスチャ生成とキャッシュ
さて、2048×3Dのネットワーク通信量をブラウザのDevToolsで確認してもらうと分かるのですが、このゲームはブロック用のPNG/WebP画像ファイルを1枚もダウンロードしていません。2から8192(あるいはそれ以上)、さらには+TIMEバッジ付きタイルやGLITCH(#)に至るまで、すべてのテクスチャを実行時にHTML5の<canvas>(512×512ピクセル)で動的描画し、THREE.CanvasTextureに変換して貼り付けています。
// js/game-renderer.js : 桁数に応じたフォントサイズ自動スケーリングとテクスチャキャッシュ
const cacheKey = `${value}_${type}`;
if (this.textureCache.has(cacheKey)) return this.textureCache.get(cacheKey);
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
// ... グラデーション背景・サイバーパンク角ブラケットの描画 ...
const digits = String(value).length;
const fontSize = digits <= 1 ? 184 : digits === 2 ? 152 : digits === 3 ? 124 : 100;
ctx.font = `900 ${fontSize}px "Inter", "Noto Sans JP", sans-serif`;
なぜわざわざCanvas2Dでプロシージャル生成しているのかというと、理由は3つあります。
- 初期ロード時間ゼロとPWAオフライン完全対応:外部画像ファイルを何十枚もリクエストする必要がないため、初回表示が瞬時に完了し、通信が不安定なモバイル回線でもテクスチャ欠けが一切起きません。
- 未知の巨大数値(16384や32768)への自動対応:画像ファイル方式だと用意した数値以上のタイルが出た瞬間に表示が壊れますが、Canvas2D方式なら
String(value).length(桁数)に応じてフォントサイズを184px / 152px / 124px / 100pxへ自動縮小するため、理論上どこまで大きな数字が作られてもレイアウトが破綻しません。 textureCache(Map)によるGPUメモリ節約:同じ種類(例:通常の「16」)のテクスチャはcacheKeyでthis.textureCacheに保持して全タイル間で共有(インスタンス再利用)しているため、盤面に同じ数字が何個並んでもCanvas生成は最初の1回だけで済みます。
さて、画像アセットをゼロにしたとなれば、当然気になるのが「じゃあBGMや効果音はどうしているのか?」という点ですよね。次回(第7回)は、MP3/WAVファイルを1つも使わずにブラウザのWeb Audio APIだけでBGMと効果音をリアルタイム合成している「プロシージャル音源エンジン(js/audio.js)のシンセサイザー設計」について解説します。どうぞお楽しみに!
X-Ray透過とLayer Slicerを実機で試す
ゲーム画面の「X-Ray」ボタンと「ALL / 1F / 2F / 3F」スライサーで、3D空間の内部コアを覗き見よう。