DirectX11の UI 基盤 —— 三層シーングラフと描画パイプライン
1. はじめに
自作の DirectX11 エンジンで 3D アクション/サバイバルクラフトを開発しています。この記事では、そのエンジンの UI 基盤を解説します。
UI は 3 層のシーングラフとして構成しています。
- UISystem:複数の Canvas を優先度で統括するルート
- Canvas:描画空間(2D / 3D)・投影・優先度という描画ポリシーを持つツリーの根
- UINode:親相対の変換を持ち、見た目を描くツリーの基本ノード
この 3 層に加えて、入力を配送する PointerRouter、描画バックエンドを抽象化する ICanvasRenderer、Canvas 単位のポストエフェクトを担う ILayerCompositor があります。順に、設計と実装、そして最終的に HDR ポストプロセスパイプラインへどう合成するかまでを見ていきます。
2. 三層シーングラフ
責務を 3 つに切り分けています。
| 層 | 責務 |
|---|---|
| UISystem | 複数の Canvas を優先度で統括するルート。入力ゲートもここ |
| Canvas | 描画空間(2D / 3D)・投影・優先度という描画ポリシー |
| UINode | ツリーの 1 ノード。親相対の変換を持ち、見た目を描く |
2D と 3D の差は 「Canvas がどの空間か」という 1 つのフラグに押し込んでいます。ノードから下は 2D も 3D も同じツリー、同じ入力ロジックで扱えます。
2.1 UISystem —— 全 Canvas の統括
最上位の UISystem は、複数の Canvas を priority(優先度)で並べて統括します。
- 描画は priority 昇順(数字が大きいほど後に描く=上に来る)
- 入力は priority 降順(最前面から順にヒットテスト)
- ドラッグ中のゴーストやツールチップも、UISystem が最前面に描く
-
IsPointerOverUI()で「カーソルが UI の上か」を一発判定でき、ゲーム入力のゲートに使える
// 全 Canvas を統括する UI ルート。
// ・描画は priority 昇順 (大きいほど上)。
// ・入力は priority 降順 (最前面) でヒットテストし、PointerRouter が hover/click/D&D を配送。
// ・IsPointerOverUI でゲーム入力ゲート ("カーソルが UI 上か") を一発判定できる。
class UISystem {
public:
Canvas* CreateCanvas(CanvasSpace space, int priority);
void RemoveCanvas(Canvas* canvas);
void Update(float dt, const PointerState& pointer); // 各Canvas更新 + 最前面ヒットへ入力配送
void Draw(); // 昇順描画 → ドラッグゴースト → ツールチップ
UINode* HitTest(const Vector2& mousePixel); // 最前面 Canvas の interactive ノード
bool IsPointerOverUI(const Vector2& mousePixel); // UI 上にカーソルがあるか
private:
std::vector<std::unique_ptr<Canvas>> m_canvases; // priority 昇順を維持
PointerRouter m_router;
ILayerCompositor* m_compositor = nullptr; // null = 直接描画 (後述)
};
「HUD(ゲーム内の常時表示)の上にインベントリ、その上にモーダル、さらに上にドラッグゴースト」といった重なり順を、priority の数値だけで宣言的に決められます。
2.2 Canvas —— 描画空間のポリシー
Canvas は UI ツリーの根であり、どの空間にどう投影するかという描画ポリシーを持ちます。
// Canvas の描画空間。
enum class CanvasSpace {
Screen2D, // 正射影・ピクセル座標・左上原点
World3D, // 透視投影・UI空間・マウスパララックス
};
Screen2D はピクセル座標の正射影(=遠近感のない平行投影)、World3D は透視投影で、さらにマウスに追従するパララックスが乗ります。
投影行列の切り替えは Canvas が隠蔽するので、上位・下位からは「どちらの空間か」を意識せずに済みます。
Matrix4x4 Canvas::ViewMatrix() const; // 2D: Identity / 3D: cam.view
Matrix4x4 Canvas::ProjMatrix() const; // 2D: Ortho(0,W,H,0) / 3D: cam.proj
3D のときだけ有効になる Camera3D が、パララックスと固定傾き(UI パネル全体をわずかに傾ける演出)を担当します。個々のノードを動かすのではなく、カメラを動かす方式で、パネルはツリー上では静止したまま、見た目だけ視差で動きます。
struct Camera3D {
float parallaxPosAmp = 0.033f; // カメラ平行移動の振幅
float parallaxRotAmp = 0.01f; // 視線チルトの振幅
float parallaxSmooth = 15.0f; // 指数平滑の速さ (1/秒)
Vector2 tiltBias{ 0.075f, 0.275f }; // UI 全体の固定傾き (x=ヨー, y=ピッチ)
...
void Update(float dt, const Vector2& mousePixel);
} cam;
2.3 UINode —— ツリーの基本単位
UINode はツリーの 1 ノードです。設計上の要点は 3 つです。
- 親相対の変換を持ち、
World = Local × 親Worldで合成する - 見た目は
OnDrawを override したノード種(SpriteNode / HoloPanelNode / TextNode / UIButtonNode / SdfButtonNode …)が描く - 状態(tint / alpha / size …)は tween がアドレス駆動できるよう public
3 番目が効いています。当エンジンは演出タイミングを DOTween 風の tween ライブラリで書く方針なので、node.alpha のようなメンバのアドレスを直接 tween に渡してフェードやスケールアニメを回せます。
// UI ツリーの基本ノード。
// ・親に対する相対変換 (SRT) を持ち、World = Local × 親World で合成する。
// ・見た目は OnDraw を override したノード種別が描く。
// ・状態 (tint/alpha/...) は tween がアドレス駆動できるよう public。
class UINode {
public:
UINode* parent = nullptr;
std::vector<std::unique_ptr<UINode>> children;
SRT local; // pos/rot/scale (+pivot)
Matrix4x4 WorldMatrix() const; // World = Local × 親World
// 共通ビジュアル状態 (tween 駆動用に public)
Vector2 size{ 1.0f, 1.0f };
Color tint{ 1.0f, 1.0f, 1.0f, 1.0f };
float alpha = 1.0f;
bool interactive = false;
// 見た目を描く。canvas が投影/状態を保持、drawWorld = DrawWorld() の結果。
virtual void OnDraw(Canvas& canvas, const Matrix4x4& drawWorld) {}
// ポインタ入力 (必要な種別だけ override する)
virtual void OnHoverEnter() {}
virtual void OnClick() {}
virtual bool CanDrag() const { return false; }
virtual bool OnDropFrom(UINode* source) { return false; }
virtual bool GetTooltip(std::vector<UITooltipLine>& out) const { return false; }
// ヒット判定 (2D=点内包 / 3D=レイ交差)
virtual bool HitLocal2D(const Vector2& localUnit) const;
virtual bool RaycastLocal(const Vector3& originUi, const Vector3& dirUi, float& outT) const;
};
見た目も入力も必要な仮想関数だけ override する構造なので、新しいウィジェットは「OnDraw を書く/使う入力の仮想を実装する」だけで足せます。Canvas や後述の Router には手を入れません。
3. 入力の一元化 —— PointerRouter
入力は PointerRouter に一本化しています。
3.1 役割
Router は毎フレーム、「最前面のヒットノード」+「ポインタの状態」を受け取り、そこから状態遷移を計算して UINode の仮想関数へイベントを配送します。
// ポインタ入力のルーター。毎フレーム「最前面ヒットノード + PointerState」を受け取り、
// hover enter/exit ・ press ・ release→click/drop ・ drag 開始/終了 を UINode の
// 仮想 (OnHoverEnter/OnPress/OnClick/CanDrag/OnDropFrom …) へ配送する。
class PointerRouter {
public:
void Update(UINode* hit, const PointerState& p);
void Reset();
UINode* Hovered() const { return m_hovered; }
UINode* DragSource() const { return m_dragSource; }
private:
UINode* m_hovered = nullptr;
UINode* m_pressed = nullptr; // 押下した瞬間のノード (click 成立判定用)
UINode* m_dragSource = nullptr; // CanDrag なノードを押下したら掴み開始
};
m_pressed(押した瞬間のノード)を覚えておき、離した相手が同じなら OnClick、CanDrag なノードを押していたらドラッグ開始、というように、クリック成立とドラッグ開始の判定を Router 側で持ちます。各ウィジェットは「押された」「離された」の生イベントではなく、意味付けされた OnClick / OnDropFrom を受け取ります。
3.2 2D も 3D も同じロジック
Router は UINode* としか話さないので、そのノードが 2D Canvas に属するか 3D Canvas に属するかを知りません。
- 2D のヒットテストは「点がクアッド内にあるか」(
HitLocal2D) - 3D のヒットテストは「マウスから飛ばしたレイがパネルと交差するか」(
RaycastLocal)
この空間差は Canvas の HitTest が吸収し、Router には「最前面の interactive なノード 1 個」だけが渡ります。結果として、ドラッグ&ドロップもツールチップも、2D 画面と 3D ホログラム UI で同じコードが動きます。
void UISystem::Update(float dt, const PointerState& pointer) {
for (auto& c : m_canvases) c->Update(dt, pointer.pos);
// 全 Canvas 横断の最前面ヒットへ入力を配送 (hover/click/D&D)。
m_router.Update(HitTest(pointer.pos), pointer);
}
UINode* UISystem::HitTest(const Vector2& mousePixel) {
// priority 降順 (最前面) から探索。
for (auto it = m_canvases.rbegin(); it != m_canvases.rend(); ++it) {
if (!(*it)->visible) continue;
if (UINode* hit = (*it)->HitTest(mousePixel)) return hit;
}
return nullptr;
}
HitTest は priority 降順、つまり最前面の Canvas から探すので、重なった UI でも一番上のものだけがヒットします。これがそのまま IsPointerOverUI() になり、ゲーム側の入力ゲートに使えます。
4. 描画のレンダラ非依存化
コア(uicore)を特定のレンダラ実装に依存させないため、描画は 2 つの抽象インターフェース越しに行います。
4.1 ICanvasRenderer —— 空間別バックエンド
ICanvasRenderer は「Canvas を 1 枚描くための GPU 状態確立と後始末」を担います。具体実装(2D スプライト用の Sprite2DRenderer、3D ホログラム用の HoloCanvasRenderer)は uicore/nodes 側に置き、コアはインターフェースだけに依存します。
// Canvas 描画パスのバックエンド抽象。space 毎の GPU 状態確立と後始末を担う。
// ・Begin: 投影 / ブレンド / 深度 / シェーダー等を設定し、必要なら
// canvas.renderBackend に具体レンダラを張る (ノードの OnDraw がそれを使う)。
// ・End : 触ったステートを戻し、renderBackend をクリアする。
class ICanvasRenderer {
public:
virtual void Begin(Canvas& canvas) = 0;
virtual void End(Canvas& canvas) = 0;
};
Canvas の Draw() はツリーを走査して各ノードの OnDraw を呼ぶだけで、GPU 状態(投影行列・ブレンド・深度・シェーダー)の面倒は Begin/End が見ます。ノードは「今どの空間で描かれているか」を意識せず、渡された drawWorld 行列でクアッドを 1 枚出すことに集中できます。
4.2 投影とパララックスは Canvas 任せ
3D の投影行列とパララックスは Camera3D が毎フレーム更新し、ICanvasRenderer::Begin がその view/proj をシェーダーへ渡します。ノード側は関与しません。
5. 描画パイプラインへの接続
ここまでが UI 単体の構成です。最後に、この UI をエンジンの HDR ポストプロセスパイプラインへ合成する部分を見ます。
5.1 ILayerCompositor —— Canvas 単位のポストエフェクト
各 Canvas は「レイヤー」として扱います。Canvas ごとに SSAA(スーパーサンプリング=高解像度で描いて縮小し、輪郭のジャギを消すアンチエイリアス)や色収差(=レンズのように RGB をわずかにずらす演出)を個別に掛けられます。これを担うのが ILayerCompositor です。
// 各 Canvas を「レイヤー」として扱い、per-layer ポストエフェクト (SSAA/色収差) を
// 掛けて合成バッファへ重ねるための抽象。UISystem が各 Canvas 描画の前後で呼ぶ。
// ・BeginLayer: 効果があれば、現在の合成バッファ(背景)をオフスクリーンへコピーして
// RTV/ビューポートを張る (Canvas はその上に描く)。効果が無ければ何もしない。
// ・EndLayer : 効果を適用しつつレイヤーを合成バッファへ書き戻し、状態を復元する。
class ILayerCompositor {
public:
virtual void BeginLayer(Canvas& canvas) = 0;
virtual void EndLayer(Canvas& canvas) = 0;
};
効果を持たない Canvas では BeginLayer/EndLayer は実質何もせず、オフスクリーンを介さず合成バッファへ直接描きます。効果を使う Canvas だけがオフスクリーン往復のコストを払います。ここでも uicore コアはインターフェースにしか依存せず、実際に PostProcess のバッファを触るのは uicore/nodes 側の具体実装です。
5.2 UISystem::Draw の全体フロー
UISystem::Draw() は、priority 昇順に各 Canvas を描き、必要なら前後でレイヤー合成を挟み、最後にドラッグゴーストかツールチップを最前面に乗せます。
void UISystem::Draw() {
// priority 昇順で描画。コンポジタ注入時は前後で per-layer 効果を挟む。
for (auto& c : m_canvases) {
if (m_compositor) m_compositor->BeginLayer(*c);
c->Draw();
if (m_compositor) m_compositor->EndLayer(*c);
}
// ドラッグ中: 掴み元のゴーストをカーソル位置に最前面描画 (ツールチップは出さない)。
if (UINode* drag = m_router.DragSource()) {
drag->OnDragGhost(m_cursor);
return;
}
// ホバー中: ツールチップを最前面描画。
if (UINode* hov = m_router.Hovered()) {
std::vector<UITooltipLine> lines;
if (hov->GetTooltip(lines) && !lines.empty()) {
DrawTooltip(lines, m_cursor);
}
}
}
5.3 フレーム全体での UI の位置
エンジンのフレーム描画(EndScene)は、ざっくり次の順で進みます。UI はこのうち Composite 段で 3D シーンの上に重ねられ、そのあと画面全体の仕上げ効果がかかります。
[HDR] DoF → Bloom → Tonemap
↓
[LDR] SMAA → backdrop → 色収差 → Composite ← ここで UISystem::Draw を重ねる
↓
Finalize(EndPost): 全体ビネット → BackBuffer → ImGui(デバッグUI)
ここで 2 つ、破ると画面が壊れる不変条件があります。
- カメラ相対レンダリング:3D シーンは「描画原点=カメラ位置」で描き、数万に及ぶ大ワールド座標の float 桁落ち(葉の影や境界の点滅として現れる)を防いでいます。UI を描く前に原点を 0 へ戻す必要があり、これを忘れると UI が消えます。
- 状態の再確立:毎フレーム冒頭で RTV / 深度 / ブレンド / ビューポート / サンプラ / ラスタライザといった全グローバル状態を張り直し、UI やポスト処理からの状態リークがシーンへ漏れないようにしています。
UI は「Composite で重ねる 1 レイヤー」として扱えるので、ILayerCompositor による per-layer 効果と、フレーム末尾の全体効果(ビネットなど)を自然に両立できます。
6. 拡張の型 —— 新しい UI は「ノードを足すだけ」
この基盤では、新機能の UI を足すコストがノード 1 種の追加に収まります。
- 見た目を足したい →
UINodeを継承しOnDrawを書く(例:SdfButtonNode、ItemSlotNode) - 入力を足したい → 使う仮想関数(
OnClick/CanDrag/OnDropFrom/GetTooltip…)だけ実装する - Canvas / PointerRouter / UISystem は変更しない
この型で、ホットバー・インベントリ・クラフト画面・フィールドメニューといった画面を、2D/3D の別なく同じ基盤に載せています。
現時点で割り切っている点もあります。pivot(ノードの原点)は中央前提、Screen2D のテキストは当面別経路、PointerRouter は最前面 1 ヒットのみ(マルチタッチ非対応)です。
7. おわりに
UI 基盤を UISystem → Canvas → UINode の三層シーングラフとして構成し、
- 描画ポリシー(2D / 3D・投影・優先度)を Canvas に隔離し、
- 入力を PointerRouter に一元化して 2D / 3D を同じロジックで扱い、
- 描画を ICanvasRenderer / ILayerCompositor の抽象越しにしてコアをレンダラ非依存に保ち、
- 最終的に HDR ポストプロセスの Composite 段へ 1 レイヤーとして合成する
という形にしています。新しい UI はノードを 1 種足すだけで済みます。
技術スタック
- C++20 / DirectX11
