ゲーム開発の備忘録

趣味のゲーム開発でのノウハウや、技術的に嵌ったポイントを忘れないように書き記しておくブログです。

DirectX12で実際にゲームを制作する際のリソース管理方法について

はじめに

DirectX12でゲームを作ろうとすると、リソースをどのように管理するかという問題が巨大な壁として立ちはだかります。私も趣味でDirectX12でゲームを制作しているのですが、リソースの管理方法には1ヶ月くらい頭を悩まされました。

DirectX12は近年になってようやく日本語の書籍(物理、電子両媒体)も充実し、ポリゴンを出してみる、モデルを描画してみるといったサンプルを動作させるだけであれば比較的容易に実現できるようになってきました。しかし、そのようなサンプルを動作させる場合、リソース数が少なく、かつ動的にリソースを準備しなくても良い(事前に必要なリソースが分かっている)ため、リソース管理についてはあまり考える必要がありません。
対して、ゲームを動作させようとすると、プレイヤーの動きに合わせてメッシュを生成して描画するようにしたりと、動的なリソース管理が必要になってきます。どのシェーダ、テクスチャ、コンスタントバッファがいつ必要になるか、その組み合わせはゲーム内の状況に応じて刻々と変化するものです。そして、この動的なリソースをどのように管理するかといった観点で記載された書籍や技術記事は非常に数が少ないのです。

そこで、この記事では、私が考案した、動的なリソース管理の実現方法を紹介したいと思います。なお、この方法が最適かどうかは分かりません。より良い方法もあると思いますので、もしアドバイスいただける方は是非コメント等でアドバイスいただけますと喜びます!

前提知識

この記事の読者は前提としてDirectX12の基礎知識を持っているものとします。記事が非常に長くなってしまうので、基本要素の詳細な説明は実施しません。
具体的には、以下の知識は習得済みとして話を進めます。

上記について分からない方や、知識がおぼろげな方は先に書籍やMSDN等で概念を習得されることをおすすめします。(まぁ、私も慣れ始めたばかりなので怪しいですが……)

動的なリソース生成は何が難しいか

動的なリソース生成での大きな課題は、全メッシュで統一して利用するコンスタントバッファと、メッシュごとに用意するコンスタントバッファの共存が難しいということです。
例として、フォンシェーディングで利用する以下の頂点シェーダで考えてみましょう。必要な部分だけ記載します。

/*includeは省略*/

cbuffer camera : register(b0) {
	float3 camera_position : packoffset(c0);
}

cbuffer transform : register(b1) {
	float4x4 world_matrix : packoffset(c0);
	float4x4 rotation_matrix : packoffset(c4);
	float4x4 view_matrix : packoffset(c8);
	float4x4 projection_matrix : packoffset(c12);
};

/*座標変換をするmain関数は省略*/

コンスタントバッファcameraはカメラの位置情報を格納しています。この情報はメッシュごとに作る必要はないですね。そして、コンスタントバッファtransformは座標変換の情報を格納しています。これはメッシュごとに作らないといけない情報ですね。

ここで重要なのは、ディスクリプタテーブルでコンスタントバッファをマッピングする場合、ディスクリプタレンジでディスクリプタヒープの情報を連携しますので、連続したディスクリプタとしてしかコンスタントバッファを渡すことができないということです。(1つ飛ばしとかで連携することもできますが、あまり意味はないでしょう。重要なのは、柔軟に各ディスクリプタの位置を都度指定できないという点です)
言い換えれば、シェーダ上でコンスタントバッファのレジスタ番号は常に連続していなければなりません。

上記の制約が問題になってくるのは、複数のメッシュを描画する時です。例えば、メッシュを3枚フォンシェーディングしながら描画することを考えます。この時、cameraの情報は全体として1つでよいので、以下のように座標変換情報3つをディスクリプタヒープに格納したくなるかもしれません。



しかし、これでは正しく動作させることができません。なぜなら、cameraがシェーダ上でb0に格納される時、b1に来るのは常に隣接しているtransform1になるからです。結果、cameraがb0に来るようにディスクリプタレンジの設定を行うと、3つのメッシュにはすべて同じ座標変換が適用されてしまいます。

では、ディスクリプタレンジが常にcamera , transformの並びになるようにすることを考えましょう。結果、以下のようになります。



これであれば、b0をcamera1、camera2、camera3にそれぞれ合わせながら描画することで、b1は各メッシュの座標変換情報を正しく指すようになります。
しかし、この方法にも問題があります。それはパフォーマンスが悪いという点です。ディスクリプタは別のディスクリプタを指すことができませんので、camera1、camera2、camera3は中身は同じ情報であるにもかかわらず、全く別のコンスタントバッファとして生成しなければならないのです。例えば同じメッシュを3000個描画するとなった場合、カメラの位置を表すコンスタントバッファも3000個作成し、データの流し込みを行い、コンスタントバッファビューを生成してディスクリプタヒープに紐付けなければなりません。これは非常に無駄です。

また、複数のメッシュを描画する時以外にも、複数種類のシェーダ間で一部のコンスタントバッファだけ再利用したい(b0が共通でb1以降が異なる等)場合にも、前述と同じ問題が発生します。コンスタントバッファを再利用できず、無駄にコンスタントバッファの生成が発生してしまうのです。

動的なリソース管理を実現する方法

それでは、前述の問題を解消する方法を説明します。まずは、描画用以外に、シーンで共通して利用する、メッシュに紐付かないコンスタントバッファを格納するためだけのディスクリプタヒープを生成します。本記事ではこれをグローバルディスクリプタヒープと呼ぶことにします。

レンダリング時には、グローバルディスクリプタヒープから、必要なディスクリプタID3D12Device::CopyDescriptors()関数レンダリング用のディスクリプタヒープにコピーします。このコピーはコンスタントバッファを生成するよりも軽量に処理することができます。



グローバルディスクリプタヒープにコンスタントバッファを格納するC++コードを以下に示します。ここで、ConstantBufferはコンスタントバッファを表す自作のクラスで、ディスクリプタヒープ上の位置を指すために必要な添字代わりとなるid、コンスタントバッファを表すID3D12Resource、コンスタントバッファ内の値に紐付いた構造体のインスタンス等をメンバとして持っています。

/// <summary>
/// グローバルディスクリプタヒープにコンスタントバッファを登録する
/// </summary>
/// <param name="parent">親となるGraphicsCore</param>
/// <param name="key">コンスタントバッファに紐付くキー</param>
/// <param name="structure">コンスタントバッファに紐付ける構造体</param>
/// <param name="structureSize">コンスタントバッファに紐づける構造体のサイズ</param>
void D3DX12Wrapper::ConstantBufferRepository::setGlobal(const std::string& key,
	const ConstantStructureBase* structure, const UINT structureSize) noexcept {
	if (globalConstantBuffers.contains(key)) [[unlikely]] {
		return;
	}

	if (globalIdCounter >= globalIdLimit) [[unlikely]] {
		return;
	}

	const auto constantBuffer = std::make_shared<ConstantBuffer>(parent, globalIdCounter, structure, structureSize, true);
	globalConstantBuffers[key] = constantBuffer;
	++globalIdCounter;
}

次に、グローバルディスクリプタヒープからレンダリング用のディスクリプタヒープにディスクリプタをコピーするC++コードを以下に示します。

/// <summary>
/// グローバルディスクリプタヒープからレンダリング用のディスクリプタヒープにディスクリプタをコピーする。
/// </summary>
/// <param name="keyOfGlobal">グローバルディスクリプタヒープ側のキー</param>
/// <param name="keyOfLocal">レンダリング用のディスクリプタヒープ側のキー</param>
/// <returns>コピー先のハンドルを指す添字</returns>
void D3DX12Wrapper::ConstantBufferRepository::copyFromGlobal(
	const std::string& keyOfGlobal, const std::string& keyOfLocal) noexcept {
	if (constantBuffers.contains(keyOfLocal)) [[unlikely]] {
		return;
	}

	if (idCounter >= idLimit) [[unlikely]] {
		return;
	}

	//バックバッファの数だけコンスタントバッファを生成する必要がある
	const auto idLimitPerBackBuffer = (idLimit - idOffset) / parent->getBackBufferCount();
	const auto device = parent->getDevice();
	const auto handleOfGlobal = parent->getCPUHandleOfGlobalResourceDescriptorHeap(getGlobal(keyOfGlobal)->getId());
	const auto sizeOfDescriptor = device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);
	for (UINT i = 0U; i < parent->getBackBufferCount(); ++i) {
		const auto handleOfLocal = parent->getCPUHandleOfResourceDescriptorHeap(idCounter + idLimitPerBackBuffer * i);
		device->CopyDescriptors(1U, &handleOfLocal, &sizeOfDescriptor, 1U, &handleOfGlobal, &sizeOfDescriptor, D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV);
		const auto constantBuffer = std::make_shared<ConstantBuffer>(idCounter + idLimitPerBackBuffer * i);
		constantBuffers[keyOfLocal].emplace_back(constantBuffer);
	}
	++idCounter;
}

ConstantBufferクラスではidのみを受け取るコンストラクタをオーバーロードしています。グローバルディスクリプタヒープからレンダリング用のディスクリプタヒープ上にコピーするコンスタントバッファの場合、リソース実体はCopyDescriptors()の時点でディスクリプタヒープ上に出来上がるため、別途ID3D12Resourceで持っておく必要がないためです。ディスクリプタテーブルに情報連携する際はディスクリプタヒープ上の位置さえわかればよいので、idを持っておくだけで十分なのですね。なお、座標変換情報のようにメッシュ毎に生成が必要なコンスタントバッファはレンダリング用のディスクリプタヒープ上に直接格納します。

ちなみに、私が作成したライブラリの場合、コンスタントバッファ情報はunordered_mapで管理しています。レンダリング用のディスクリプタヒープにコピーしてきたディスクリプタについてもunordered_mapで管理しているため、一意なキーを作る必要がある(上記のコードの場合はkeyOfLocalが該当します)のですが、その部分のコードは微妙なのであえて掲載しないでおきます。是非、皆さんのほうでより良い仕組みを作ってみてください!

おわりに

記事を書いてみて、改めてDirectX12でのリソースバインディングの複雑さを実感しました。DirectX11が赤子に見えるような難しさ、間違いない。
実はまだインスタンシングに対応していないので、今後もう少し内容が変わるかもしれませんが、備忘の意味合いも込めて記事にしてみました。

本記事が、DirectX12と格闘している方の助けになれば幸いです!