[JavaScript] Three.js RenderTarget ミニマップを“ゲームUI”にする(円形マスク+フレーム+時計表示)

はじめに

早朝に作成した、ミニマップを回転する実装の続き。

RPGなどで左上にミニマップを含めたUIを見ますが、あれと似たようなミニマップとUIが合体した実装をしてみたので、その備忘録メモです。

1. 概要

今回やるのは、前回つくった Three.js のミニマップ(サブカメラ + RenderTarget) を さらに “ゲーム UI(HUD)” として完成させる工程。

最終的にはこんな UI を目指す:

  • ミニマップを 円形に切り抜く(alphaMap)
  • 金色の UI フレーム PNG を重ねる
  • 日付・時刻・称号(例:Wizard) を CanvasTexture に描画
  • カメラに追従して 完全固定 UI として動作
  • サブカメラはプレイヤーの真上に移動しつつ スムーズ回転

「ブラウザでここまでやれるのか?」という “ゲーム UI らしさ” を Three.js だけで再現するのが今回のゴール。


2. なぜ Three.js のゲーム UI は難しいのか?

Unity のような GUI システムと違い、Three.js の UI は すべて 3D オブジェクト扱い になる。

Three.js で UI が難しい理由

  • 画面左上に固定したい → 通常のオブジェクトは カメラ移動の影響を受ける
  • PNG を綺麗に重ねたい → depthTest / depthWrite / renderOrder を調整しないと崩れる
  • 円形マスク → CSS でできるが、Three.js では alphaMap を自作
  • テキスト → DOM ではなく CanvasTexture に毎フレーム描画
  • サブカメラの位置と回転 → 自前で プレイヤー追従 する必要がある

つまり、Three.js における UI は 3D 空間に浮かんでいる板(Plane)をカメラに貼り付けて作る というアプローチになる。

UI = スクリーン空間の 2D ではなく UI = カメラに固定した 3D

ここが最大のポイント。


3. 前回までの振り返り

前回の記事で実装したのは以下の仕組み:


3-1. サブカメラ + RenderTarget のミニマップ

  1. もう1つカメラを作り(cameraSub)
  2. そのカメラが映した映像を RenderTarget に書き出し
  3. そのテクスチャを Plane に貼って UI にする

という “ミニマップの基礎” を実装した。


3-2. プレイヤー方向に合わせたスムーズ回転

ミニマップを単純にプレイヤーの上に固定すると、 プレイヤーが回転した瞬間にミニマップが ガクッと回る

そこで角度差を補正して スムーズ回転 を追加した:

let delta = target - current;
delta = Math.atan2(Math.sin(delta), Math.cos(delta));
current += delta * smooth;

これは Three.js / Unity 共通で “カメラ回転の補間” をするときの定番テクニック。

4. UI パーツを追加して “本物のゲーム UI” にする

前回までで ミニマップそのものは動いた。 ここからは、いよいよ「ゲーム画面の左上に存在する UI」として完成させていく。

今回追加した UI パーツはこの3つ:

  1. 円形のマスク(alphaMap)
  2. 金色の UI フレーム PNG
  3. CanvasTexture に描くテキスト(日付 / 時刻 / 称号)

4-1. ミニマップを「円形」に切り抜く(alphaMap)

Three.js で丸いマスクをかける方法はシンプルで、

MeshBasicMaterial に alphaMap を入れる

だけでいける。

const miniMapMat = new THREE.MeshBasicMaterial({
  map: miniMapRT.texture,
  alphaMap: circleMask,   // ★ これが円形マスク
  transparent: true,
  opacity: 0.8,
  depthTest: false,
  depthWrite: false,
  toneMapped: false,
});

使った circleMask.png白い丸(中100%)+黒背景(0%)の単純な画像 だけ。

白 = 不透明 黒 = 透明

これだけでミニマップが 綺麗な円形 UI になる。 最初は “え、こんなんでいいの?” というほど簡単。


4-2. UI フレーム PNG を重ねて「ゲーム UI 化」

円形ミニマップの上に PNG フレーム を重ねるだけで、一気にゲームっぽさが出る。

const uiFrameMat = new THREE.MeshBasicMaterial({
  map: frameTex,
  transparent: true,
  opacity: 0.8,
  depthTest: false,
  depthWrite: false,
  toneMapped: false,
});
config.uiFrame.mesh = new THREE.Mesh(
  new THREE.PlaneGeometry(0.7, 0.3),
  uiFrameMat
);
config.uiFrame.mesh.renderOrder = 2; // ミニマップより上
config.camera.add(config.uiFrame.mesh);

ポイントは以下:

  • カメラに add() → UI として固定される
  • renderOrder で前後関係を制御
  • depthTest=false で背景オブジェクトにめり込まない

この3つを守ると、Three.js の UI は一気に安定する。


4-3. テキストは CanvasTexture で描画

UI に文字を載せる方法はいろいろあるが、 もっとも安定するのが HTML Canvas → CanvasTexture にする方法。

作成部分

config.uiFrame.textCanvas = document.createElement('canvas');
config.uiFrame.textCanvas.width = 1024;
config.uiFrame.textCanvas.height = 512;
config.uiFrame.ctx = config.uiFrame.textCanvas.getContext('2d');

config.uiFrame.textTexture = new THREE.CanvasTexture(
  config.uiFrame.textCanvas
);

描画部分

ctx.clearRect(0, 0, textCanvas.width, textCanvas.height);
ctx.font = "44px 'Shippori Mincho', serif";
ctx.fillStyle = 'rgba(0, 0, 0, 1)';
ctx.textAlign = 'left';

ctx.fillText(dateStr, 50, 110);
ctx.fillText(timeStr, 65, 180);
ctx.fillText('- Wizard -', 65, 250);

textTexture.needsUpdate = true;

毎フレーム呼び出せば 時計はリアルタイム更新される。

lain の UI は

  • 日付
  • 時刻(秒まで)
  • 称号(Wizard) を掲載している。

4-4. すべてカメラに貼り付ける

最後に、これらの UI パーツを すべてメインカメラに add() する。

config.camera.add(config.miniMapPlane);
config.camera.add(config.uiFrame.mesh);
config.camera.add(config.uiFrame.textPlane);

UI は本来 “2D” だが、Three.js では カメラに固定された 3D Plane として成立させる。


4-5. 画面左上に配置する(FOV 計算 → オフセット)

UI を左上に固定するには、 カメラの FOV とアスペクト比から可視範囲を計算して、 “左上の座標” を毎フレーム出す必要がある。

const vFov = (cam.fov * Math.PI) / 180;
const height = 2 * Math.tan(vFov / 2) * distance;
const width = height * cam.aspect;

const mmOffsetX = -width / 2 + 0.15;
const mmOffsetY =  height / 2 - 0.16;
config.miniMapPlane.position.set(mmOffsetX, mmOffsetY, -distance);

この仕組みがないと、 解像度が変わった瞬間 UI が崩壊する。 Three.js の UI では 必須テクニック

5. UI 更新処理をどう設計するか?

― updateCamera / サブカメラ / UI 更新の“責務分離” ―

今回の UI では次の3つが同時に動く:

  1. メインカメラ(プレイヤー追従 / 角度 / 視点)
  2. サブカメラ(ミニマップ専用)
  3. ミニマップ UI / フレーム / テキストの再配置

Three.js は Unity のような GUI システムがないため、 「UI をどう更新するか?」の設計が非常に重要になる。


5-1. メインカメラの更新:updateCameraMain()

メインカメラは「プレイヤーの後方からの追従」を担当。

config.camera.position.x =
  playerPos.x +
  radius * Math.cos(theta) * Math.cos(phi);

config.camera.position.z =
  playerPos.z +
  radius * Math.sin(theta) * Math.cos(phi);

config.camera.position.y =
  playerPos.y + radius * Math.sin(phi);

config.camera.lookAt(playerPos);
  • キーボード操作
  • 右スティック操作
  • プレイヤー追従
  • カメラ回転

これらは ゲーム本編のカメラロジックであり、 UI と絶対に混ぜてはいけない領域。


5-2. サブカメラ(ミニマップ専用)の更新:updateCameraSub()

役割は3つ:

  1. プレイヤーの真上に移動する
  2. プレイヤーの向きに合わせてスムーズ回転
  3. RenderTarget に書き込むカメラとして機能
config.cameraSub.position.set(
  playerPos.x,
  config.cameraSubHeight,
  playerPos.z
);

const target = -playerRotY + Math.PI;
let delta = target - currentRotZ;
delta = Math.atan2(Math.sin(delta), Math.cos(delta));
currentRotZ += delta * smooth;
config.cameraSub.rotation.z = currentRotZ;

ここでも UI とは役割が完全に分かれている。


5-3. UI の更新:updateMapFrameUI()

UI は以下を行う:

  • ミニマップを「画面左上」に配置(解像度 / FOV に依存)
  • UI フレーム(PNG)を位置調整して重ねる
  • テキスト(CanvasTexture)を右のスペースに配置
  • 毎秒 時計のテキスト更新

すべて カメラに貼り付けた Plane を“画面基準で動かすだけ” の処理。

const vFov = (cam.fov * Math.PI) / 180;
const height = 2 * Math.tan(vFov / 2) * distance;
const width = height * cam.aspect;

const mmOffsetX = -width / 2 + 0.15;
const mmOffsetY =  height / 2 - 0.16;
config.miniMapPlane.position.set(mmOffsetX, mmOffsetY, -distance);

const frameOffsetX = mmOffsetX + 0.2;
const frameOffsetY = mmOffsetY + 0.0;
config.uiFrame.mesh.position.set(frameOffsetX, frameOffsetY, -distance - 0.01);

const textOffsetX = mmOffsetX + 0.62;
const textOffsetY = mmOffsetY - 0.03;
config.uiFrame.textPlane.position.set(textOffsetX, textOffsetY, -distance - 0.01);

UI が複雑化したとき、この関数を別ファイル(mapFrame.js)に分離したのは 大正解


5-4. UI 更新をメインループに入れる

Three.js では Update フレームがないため、 UI の更新も 毎フレーム手動で呼び出す

lain の構成では:

updateCamera();      // 本編のカメラ更新
updateMapFrameUI();  // UI更新
updateRender();      // レンダリング

これは Unity のゲームループに極めて近い:

Update() {
  Camera.Update();
  UI.Update();
  Render();
}

UI をカメラロジックと混ぜず、別ファイルとして動かすことで ・可読性 ・デバッグ性 ・拡張性 が飛躍的に上がる。


5-5. UI を updateCameraSub に入れなかった理由

Three.js 初心者がよくやりがちなミス:

❌ 「UI もサブカメラの位置更新に混ぜてしまう」 → こうすると責務が崩壊し、後の管理が地獄になる。

UI はカメラの都合(移動/回転)に影響を受けるべきではなく、 純粋に「画面の左上に固定されるべきもの」。

6. 解像度や FOV が変わっても UI が崩れない仕組み

― Three.js の UI 固定は「FOVベース」で行う ―

Three.js による 3D UI の最大の弱点は、

解像度が変わると UI の位置がズレる

という問題。

特にフルスクリーン化 / ウィンドウ縮小 / スマホ / 4K など、 あらゆる場面で UI が壊れる。

lain の実装がすごいのは、 この問題を “FOVベースの位置計算” で完全に回避している点。


6-1. なぜ UI がズレるのか?

UI はカメラに貼り付けた Plane(3Dオブジェクト)なので、 「画面左上」などの 2D座標とは直接関係していない

普通に mesh.position.set(-0.5, 0.2, -1.5) と書いてしまうと、 解像度が変わった瞬間に UI が上下左右にズレる。


6-2. 解決方法:FOV から “可視範囲の幅と高さ” を逆算する

Three.js の PerspectiveCamera では、 距離・FOV・アスペクト比 から見える範囲を計算できる

可視範囲の高さ

const vFov = (cam.fov * Math.PI) / 180;
const height = 2 * Math.tan(vFov / 2) * distance;

可視範囲の幅

const width = height * cam.aspect;

これで「カメラから distance だけ離れた地点の表示範囲」が 正確に計算できる。


6-3. 左上の座標はこうして計算する

const mmOffsetX = -width / 2 + 0.15;   // 左端から +0.15
const mmOffsetY =  height / 2 - 0.16;  // 上端から -0.16

config.miniMapPlane.position.set(mmOffsetX, mmOffsetY, -distance);

この計算方式にすると、解像度変更に完全対応する。

  • フルスクリーン
  • ウィンドウリサイズ
  • スマホ / タブレット
  • 4K
  • ウルトラワイドモニタ

どれでも “常に左上” に固定される。


6-4. UI フレームとテキストも同じ仕組みで追従

フレームもテキストもミニマップからの相対位置で計算する:

const frameOffsetX = mmOffsetX + 0.20;
const frameOffsetY = mmOffsetY + 0.00;

const textOffsetX = mmOffsetX + 0.62;
const textOffsetY = mmOffsetY - 0.03;

“画面の左上” を基準にして、 UI 全体をひとつの固まりとして固定できる。


6-5. UI がズレないまとめ

Three.js の UI を安定させるために必要なのはこの4つ。

✔ ① UI は「カメラに add」して固定

✔ ② depthTest / depthWrite = false

✔ ③ renderOrder で前後制御

✔ ④ FOV位置計算で左上(固定位置)を算出する

この4つが揃うと、 ブラウザゲームとして十分成立する HUD(ゲーム UI) になる。

7. UI 技術まとめ

― Three.js UI で絶対にハマる4つの罠と、その解決策 ―

今回の UI 強化では、次の4つの技術が中核になっている:

  1. alphaMap(円形マスク)
  2. depthTest / depthWrite(UIを前に出す)
  3. renderOrder(UIの重ね順を保証)
  4. toneMapped(色が白くなる問題を防ぐ)

Three.js は本来 3D のためのライブラリで、 UI は「例外的な使い方」をするため、 このあたりを理解してないと UI がすぐ壊れる。

ここでは、それぞれの意味と効果をまとめる。


7-1. alphaMap:円形ミニマップを作る秘密

Three.js で画像の形に切り抜く方法は2つ:

① ShaderMaterial で discard する(難しい)

② alphaMap に画像を入れる(簡単)

lain は ② を採用している。


alphaMap の仕組み

白い部分 → 不透明 黒い部分 → 完全透明 中間のグレー → 半透明

const miniMapMat = new THREE.MeshBasicMaterial({
  map: config.miniMapRT.texture,
  alphaMap: circleMask,   // これで円形に切り抜ける
  transparent: true,
});

メリット

  • shader 不要
  • WebGPU / WebGL2 どちらでも動く
  • わずか 1 行でミニマップが「円形 UI」に

これは Three.js の UI 実装では最も重要なテクニックのひとつ。


7-2. depthTest / depthWrite

UI は 3D オブジェクトの裏に消えてはいけない

UI は「画面の前面に存在すべきもの」。

しかし Three.js の場合、UI も 3D オブジェクトなので 普通に描画すると 背景の建物にめり込む

これを防ぐのが:

depthTest: false,
depthWrite: false,

depthTest: false

奥にあるジオメトリの z-buffer の影響を受けなくなる → UI がオブジェクトの後ろに隠れなくなる

depthWrite: false

UI が z-buffer に書き込まなくなる → 他の UI の描画を妨げなくなる

UI 実装では “ほぼ必須”。


7-3. renderOrder

UI の重ね順(z-index の代わり)

Three.js の Plane は どちらが前に描画されるか保証されない。 とくに透明 PNG を使うと、描画順によっては

  • ミニマップよりフレームが後ろ
  • フレームの中にミニマップが消える

などの事故が起きる。

そこで:

config.miniMapPlane.renderOrder = 1;
config.uiFrame.mesh.renderOrder = 2;
config.uiFrame.textPlane.renderOrder = 3;

UI だけで小さなレンダリングレイヤーを作る。

Z-index のように考えると理解しやすい。


7-4. toneMapped: false

UI の色が白っぽく壊れるのを防ぐ

これが 最大のハマりポイント

Three.js r150 以降、toneMapping が強化されたことで UI に貼った PNG や CanvasTexture が 白く飛ぶことがある。

魔法陣、UIフレーム、CanvasTexture などは 「ライティングの影響を受けてはいけない」ので、 すべて toneMapped: false にする。

const mat = new THREE.MeshBasicMaterial({
  map: tex,
  transparent: true,
  toneMapped: false,   // ← Must
});

これを忘れると:

  • UI が白くにじむ
  • PNG が妙に明るくなる
  • MMD モデルの色補正に影響
  • GLTF のアルファが壊れる

など予期せぬ問題が発生する。

今回の大修正で一番効いた設定。


7-5. まとめ:UI を安定させる“黄金セット”

Three.js の UI では、下記 4 点は 絶対にセットで考える

機能 役割
alphaMap 円形・星形など任意マスク
depthTest: false UI が背景に隠れない
depthWrite: false UI 同士の重なりが安定
renderOrder 上下の優先順位
toneMapped: false 白飛び・色化けを防ぐ

この4つが揃うと、 Three.js の UI は安定し、 ゲーム開発で使えるレベルになる。