はじめに
早朝に作成した、ミニマップを回転する実装の続き。
[JavaScript] Three.jsでミニマップ実装:サブカメラ+RenderTarget+カーナビ式スムーズ回転
Three.jsでミニマップを作る実用実装。サブカメラ(俯瞰)→WebGLRenderTarget→Plane表示の基本から、FOV/アスペクト比を使った左上固定、キャラ回転に追従するカーナビ式回転、atan2で角度差を-π〜+πに正規化してlerpでスムーズ …
https://humanxai.info/posts/javascript-threejs-minimap-render-target-subcamera-smooth-rotation/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つカメラを作り(cameraSub)
- そのカメラが映した映像を RenderTarget に書き出し
- そのテクスチャを 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つ:
- 円形のマスク(alphaMap)
- 金色の UI フレーム PNG
- 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つが同時に動く:
- メインカメラ(プレイヤー追従 / 角度 / 視点)
- サブカメラ(ミニマップ専用)
- ミニマップ 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つ:
- プレイヤーの真上に移動する
- プレイヤーの向きに合わせてスムーズ回転
- 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つの技術が中核になっている:
- alphaMap(円形マスク)
- depthTest / depthWrite(UIを前に出す)
- renderOrder(UIの重ね順を保証)
- 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 は安定し、 ゲーム開発で使えるレベルになる。
💬 コメント