images/logo_white.png

グラフィックスエンジン作成してみるブログ

レンダリングエンジン設計(4)

前回の続きになります。

レンダリングエンジンの設計(4)

ウインドウの生成

では、何はともあれウインドウを生成しないとゲームは始まりません。
ここはいきなりOSに依存するロジックになるので、マルチプラットフォームでは各OS毎に処理が分岐する部分になります。
WindowsではWinAPI(User32.dll)のCreateWindowExを呼ぶことになります。LinuxはX11とかWaylandとかまぁGUIがOSと結びついていないので自由奔放…、MacはCocoaみたいな感じですよね… (いや、Windows以外はそれほど詳しくないんですが)
まぁ暫定でウインドウ生成関数と破棄関数を定義します。
上手いこと抽象化しなくてはならないんですが、今のところ幅と高さのみ。必要に応じて今後変更しましょう。
それと、エディタでの描画等、既存のウインドウを描画先にできる必要もあるので、それも後回しにします。

ovlit_window OVLIT_CreateWindow(const ovlit_char8_t* title_utf8, int width, int height) OVLIT_NOEXCEPT;  
void OVLIT_DestroyWindow(ovlit_window window) OVLIT_NOEXCEPT;

で、ここで考えなければならないのが、戻り値の識別子である ovlit_window についてです。
今はウインドウですが、ここは例えばロードした画像を識別するのも同様なので、一律リソース管理というテーマにして考えます。

リソース管理

実装側はC++なので、リソースは普通に考えるとオブジェクトのインスタンスで管理するのが自然ですよね。

typedef void* ovlit_window; //この様に別名定義して、利用者側には実装を隠す

Window* window = new Window();
return reinterpret_cast<ovlit_window>(window);

単にvoid*だと、異なるリーソース間の代入が誤ってできてしまうので、以下のように不完全型を使って、種類を分けてしまうのがよくあるパターンでしょうか。ポインタとして使うだけならば、定義は必要ありません。
C++側で使うときはちゃんとreinterpret_castしてから使う。

typedef struct OVIT_WINDOW_HANDLE_ *ovlit_window;
typedef struct OVIT_TEXTURE_HANDLE_ *ovlit_texture;

解放側は型情報が必要なので、ちゃんと元の型にキャストしてから解放。
以下のようにできます。

レンダリングエンジン設計(3)

前回の続きになります。

レンダリングエンジンの設計(3)

世界の状態頻度を上回るレンダリング

下図は、前回までに説明した状態の更新とレンダリングの関係図です。

説明図

上記の図では、状態更新が完了した結果を使ってレンダリングをしているため、レンダリングFPSが状態更新頻度を上回ることはできません。
ところが実際には、状態更新は1/60秒の頻度で更新し、レンダリングはモニタの上限FPSを出す(例えば144FPなど)ことも多いです。

これまでの説明では、世界の状態は1/60秒の頻度でした変わらないため、下図のようにその状態で何度レンダリングしても画面上の結果は同じになってしまうはずです。

説明図

それでは高頻度でレンダリングする意味がなくなってしまうため、レンダリング時点の位置を、前回の位置と今回の位置から計算により補完して描画します。

説明図

上記の絵は、単純に加速度と速度を持つものとしてモデル化していますが、実際は衝突・摩擦・拘束・バネ・流体等複雑な要素を取り扱うため、このような単純化できるものだけではありません。
では、どのように補完する方法があるのか、という点について考察していきます。

と思ったのですが、こんな実装はまだ先なので、その時にまた触れることにします。
記載がめんどくさくなったとか、こんな事やってたらまったく進まないとか、思ってはいません…

レンダリングエンジン設計(2)

レンダリングエンジンの設計(2)

コマンドキュー

描画コマンドをキューイングする、コマンドキューを実装していきます。
C++では、素直にstd::queueを使えばいいと考えるのですが、それは個人的には避けたいところです。
その理由は、動的メモリ確保と解放は意外と遅い という事実があります。

Note
通常利用で問題になるような遅さでは全くないのですが、任意サイズのメモリ管理は、それ自体が複雑、さらにOSのメモリ管理機構とその状態や、スレッド同期とも絡むため、実行時間のブレが大きく、毎フレーム大量に呼ばれる場合は、無視できないコストになりますし、1msを争っているゲームライブラリではなおさらです。
使い方が決められないゲームエンジンではなおさら早いに越したことはありません。

そのため、コマンドキューは可変長でありながら、メモリ確保はなるべくしたくないのです。
しかも、コマンドキューはレンダリングが終われば全て不要です。なので、個別に解放する必要がありません。
また、全コマンドを列挙することになるため、メモリのキャッシュミスもなるべく避けたいです。

まとめると、求められる特性は以下のようになります。

  1. 可変長であり、n個の要素をpushできる
  2. push時に、追加のメモリ確保は行わない
  3. pushする各要素は、任意のサイズとすることが可能
    描画コマンド毎に、必要とする属性の種類、サイズが異なるため
  4. 解放時は、全要素を一気に解放可能 => O(1)
    解放しても、次フレームでまた利用するため、再利用したい
  5. コマンド保持用のメモリは、連続メモリ上に確保する
    LinkedListの様な間接参照はキャッシュ効率の観点から控える

なかなか贅沢な要求ですが、ここはほぼお決まりのパターンがあります。
お決まりなのですが、一般的に何て呼ぶのか英語圏では「Arena Allocator」とでも呼ばれているのでしょうか。
日本語ではあまり参考になる記事が見つけられませんでした。

参考: Arena Allocators:現代ハイパフォーマンス・コンピューティングを支える「物理層」の共通言語

要するに大きなメモリブロックを確保して、先頭から順番に埋めていく方式です。
ただ、このメモリブロックが1つだけだと、確保するサイズに悩むし、もし足りなくなったらどうするの?という問題があるため、このメモリブロックをチャンクとして分割管理し、鎖状に管理します。

flowchart LR Arena["ChunkArena"] Arena --> C0 subgraph C0["Chunk 0"] direction LR C0U["used"] C0F["free"] C0U --> C0F end subgraph C1["Chunk 1"] direction LR C1U["used"] C1F["free"] C1U --> C1F end subgraph C2["Chunk 2 current"] direction LR C2U["used"] C2F["free"] C2U --> C2F end C0 --> C1 --> C2

各チャンクはシンプルに、どこまで利用済みかをOffsetで管理するだけです。

レンダリングエンジン設計(1)

レンダリングエンジンの設計(1)

ここでは、実装に入る前に、レンダリングエンジンの全体像・目指す姿を設計していきます。

ゲームにおけるレンダリングとは

ゲームの世界は時間とともに、移動するキャラクター(以後、アクター)の座標が更新されていきます。
まぁ誤解を恐れずに言うとそれだけです。
もちろん、それらが相互干渉することでゲームらしさがでてくるのですが。

なので、その世界においてレンダリングはあくまで私たち人間が視覚的に認知するための、ある時間tにおける一瞬をスナップショットしたものを可視化したものとみなすことができます。

flowchart TD A["ゲーム世界"] B["時間の進行"] C["アクターの座標が更新される"] D["アクター同士が相互干渉する"] E["次の世界状態になる"] R1["ある時刻 t"] R2["世界状態のスナップショット"] R3["レンダリング"] R4["人間が認知する映像"] A --> B B --> C C --> D D --> E E --> B E -. "任意の瞬間を取り出す" .-> R1 R1 --> R2 R2 --> R3 R3 --> R4

現実の世界は、時間とともに連続的に変化しています。
物体の位置、速度、姿勢、力のかかり方は、本来であれば滑らかに変化していくものです。

しかし、コンピューター上でゲーム世界を扱う場合、その連続的な変化を完全にそのまま表現することはできません。
コンピューターは、ある瞬間の状態を計算し、その結果を次の状態として保存する、という離散的な処理を行うからです。

そのためゲームでは、世界の時間を小さな単位に分割し、一定または可変の時間幅ごとに状態を更新します。
これが、いわゆるステップ更新です。

たとえば、あるアクターが速度を持って移動しているなら、ゲームは「前回の位置」と「経過時間」と「速度」から、次の位置を計算します。物理演算であれば、力、加速度、速度、衝突判定などを順番に評価し、次の世界状態を決定します。

つまりゲーム世界は、概念的には連続的な世界を扱っているように見えても、実装上は次のような状態の列として表現されます。

flowchart LR A["World(t0)"] B["World(t1)"] C["World(t2)"] Dots["…"] A --"Δt" --> B B --"Δt"--> C C --"Δt"--> Dots classDef dots fill:transparent,stroke:transparent,color:#888,font-size:24px; class Dots dots;

Δtに関して、一定または可変の時間幅と書きましたが、世界の更新は固定の時間間隔を利用するのがよりシンプルかつ、マシンのスペック差による不平等も発生しないため、本エンジンでは固定のΔtを利用します。
正確に言うと、ここはエンジン利用者が選択できる自由度を残したいと思いますが、このΔtの間隔と、実際の時間tとなるべく近づける処理等が必要になるため、そこら辺の支援も含めたいと思います。

ライブラリ初期化

1 ライブラリ形式

ライブラリはダイナミックリンクライブラリ(*.dll, *.so, …)として生成します。
ライブラリ自体の開発はC++を利用しますが、なるべく多くの言語から利用可能とするため、ダイナミックリンクライブラリからエクスポートする関数は C言語 の形式とします。

Cランタイム
MSVCではCランタイムのリンク形式として、静的リンク/動的リンクの選択が可能で、デフォルトでは 動的リンク になっています。 動的リンクすると、メモリマネージャーが共有されるため、モジュールを超えてのメモリ管理が効率化できたり、メモリ確保と開放がモジュールをまたいでも安全におこなうことができます。
ただし、デメリットとして マイクロソフトが公開しているCランタイムをインストールしていない環境では実行できないモジュール になってしまいます。
特に今回のライブラリでは前述のメリットが活きてくることはないため、ポータビリティを優先して 静的リンク します。

2 例外

C++言語はエラー伝達の仕組みとして例外が存在します。
ただしC言語形式のAPIとするため、この例外を外に伝播させてはいけません。 C++を使う以上、「気合で例外なんて使わないつもりです!!」みたいなものは基本的に不可能です…標準ライブラリ使う以上どうしようもありませんしね。

従って、全ての多言語との境界となるAPI関数は、以下のような形式になります。
もちろん、具体的な例外を補足して、挙動を変えるということもありでしょう。
C++的にはnoexceptを付けてもいいと思います。(この場合例外が境界を超えるとterminate()されます。)

int ApiFunction(...) noexcept {
	try{
		//各種処理
	}
	catch(...){
		return 0;
	}
	
	return 1;
}

上記は STDCALLCDECL 等の呼び出し規約を省略していますが、X64ではどれを書いても同じだからです。
このライブラリは X64のみのサポート とするので、これでOKです。

さてさて、ゲームで使う場合、このAPI呼び出し回数はかなり多くなるはずで、try catch の速度的なコストが気になるところですよね。
技術者的には気になるはず…

ゲームエンジンを作成する

1 はじめに

C++ / DirectX12 で、簡易的な研究用・実験用のレンダリングエンジンを作成する道筋を記載していく予定です。
非ゲーム業界にいながら、OpenGL・DirectX9・DirectX11・DirectX12・レイトレーシングオフラインレンダラは経験がありますが、非ゲームでの利用なので、それほど詳しいわけではありません…ので完成する見込みはないのですが、ゆっくりと歩んでいきます。
もちろん、時代が時代なのでAIに助けてもらいながらになりますが、なんでも相談できるいい時代になったものです。
まぁ今からこんなことをやる意味なんてあるのかと自問自答しながら…
ただ、非ゲーム用途ではありますが、これまでの知識も動員し、なんか詳しく知っているがごとく書き進めていきます…

Note
言語:C++23 (もちろんまだプレビューですが、自分用なので知ったこっちゃありません…)
開発環境:Visual Studio 2026
ターゲット:Windows (とりあえずは…)
3DAPI:DirectX12

2 3Dとの出会い

今思えば、初めて3Dに触れたのがDirectX5時代。
今は亡き DirectDraw が2D機能として存在していた時代ですね。
NVIDIAのRIVA TNTというGPUがその当時からもてはやされていました。
なんと3D処理がCPUでなくGPUでハードウェア処理ができるということでね…

はや25年。時代は進化したものです。

3 エンジン名称

ovlit

発音は「オブリット」
語源は Over the Limit から造語しています。