ゲーム開発の備忘録

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

DirectX12でガンマ補正ありのテクスチャとガンマ補正なしのテクスチャを混在して利用する

はじめに

今回は、DirectX12でガンマ補正ありのテクスチャとガンマ補正なしのテクスチャを混在して利用する方法について説明します。ガンマ補正そのものについては読者がご存知のものとして話を進めます。
プロジェクトによっては利用する全てのテクスチャがガンマ補正なし、あるいは利用する全てのテクスチャがガンマ補正ありだとは限らないでしょう。そんな時、DirectX11ではプログラマ側が特に何も考えずとも、DirectX側がテクスチャのフォーマットから適切な色味の出力を行ってくれていましたが、DirectX12では適切な色味が出るようにプログラマが自分で制御する必要があります。

テクスチャのフォーマットを取得する

まずは、描画しようとするテクスチャが、ガンマ補正ありのフォーマットなのか、ガンマ補正なしのフォーマットなのかを取得する必要があります。事前準備として、そのフォーマット情報を格納する場所を確保します。

私の場合は、テクスチャを以下のようなクラスに分けて管理しています。

  • TextureBase

 テクスチャバッファを示すID3D12Resourceをラップした規定となる抽象クラス。

  • RenderingTexture

 レンダリングテクスチャを表すクラス。データの書込みはGPU上で行われ、GPUのテクスチャ用のレジスタへの転送はGPUGPUなのでグラフィックスボードの利用形態によって実装を分ける必要がない。TextureBaseを継承している。

  • TextureForUMA

 動作環境がUMA(CPUとGPUが一体となっているアーキテクチャ)の場合に利用するテクスチャの基底となる抽象クラス。TextureBaseを継承している。

  • DataTextureForUMA

 UMAの場合のテクスチャのうち、自前で作成した色情報をCPUから渡して構築するテクスチャ。TextureForUMAを継承している。

  • ImgTextureForUMA

 UMAの場合のテクスチャのうち、画像ファイルからデータを読み込んで構築するテクスチャ。TextureForUMAを継承している。

  • TextureForDescreteGPU

 動作環境がDescrete GPU(外付けグラフィックスボードを利用しているアーキテクチャ)の場合に利用するテクスチャの基底となる抽象クラス。TextureBaseを継承している。

  • DataTextureForDescreteGPU

 Descrete GPUの場合のテクスチャのうち、自前で作成した色情報をCPUから渡して構築するテクスチャ。TextureForDescreteGPUを継承している。

  • ImgTextureForDescreteGPU

 Descrete GPUの場合のテクスチャのうち、画像ファイルからデータを読み込んで構築するテクスチャ。TextureForDescreteGPUを継承している。

私の場合は、TextureBaseにDXGI_FORMAT型のデータメンバtextureFormatを追加しました。
上記の種類のテクスチャのうち、RenderingTexture、DataTextureForUMA、DataTextureForDescreteGPUはテクスチャのフォーマットをプログラマ側から指定しているので、それをtextureFormatにも代入しています。
ImgTextureForUMA、ImgTextureForDescreteGPUのテクスチャのフォーマットは画像ファイル依存です。私の場合は、テクスチャの読込みにDirtectXTexを利用していますので、TexMetaData#formatメンバの値をtextureFormatに代入しています。

テクスチャがガンマ補正を有効化しているかを確認する

保存したテクスチャのフォーマットを利用して、テクスチャがガンマ補正を有効化しているかを確認します。私の場合は、TextureBaseに以下のメンバ関数を追加しました。

/// <summary>
/// ガンマ補正が有効になっているか確認する
/// </summary>
/// <returns>ガンマ補正が有効になっていればtrue</returns>
[[nodiscard]] constexpr bool enabledGamma() const noexcept {
	return textureFormat == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB || textureFormat == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB || textureFormat == DXGI_FORMAT_B8G8R8X8_UNORM_SRGB;
}

DXGI_FORMAT_R8G8B8A8_UNORM_SRGBの確認だけでは不十分なこともあるので注意しましょう。上記の条件式に記載したフォーマット以外にもいくつかガンマ補正が効いたフォーマットがあるようですが、とりあえず上の3つを押さえておけば困らなさそうです。

ガンマ補正あり・なしでRTVを分ける

次に、ガンマ補正あり・なしでRTVを分けます。RTV用のディスクリプタヒープについては、今までは初期化時にバックバッファの枚数分の領域確保およびハンドル生成を行っていましたが、ガンマ補正あり・なしの2パターンになるので、バックバッファの枚数 * 2の分だけ領域確保およびハンドル生成が必要です。

以下に、私のエンジンでの実例を掲載します。i + backBufferCountの添字でガンマ補正ありの場合のハンドルを設定していることに注目してください。

rtvDescriptorSize = device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);
rtvDescriptorHeapHandles.resize(backBufferCount * 2);
for (UINT i = 0U; i < backBufferCount; ++i) {
	rtvDescriptorHeapHandles[i] = rtvDescriptorHeap->GetCPUDescriptorHandleForHeapStart();
	rtvDescriptorHeapHandles[i + backBufferCount] = rtvDescriptorHeap->GetCPUDescriptorHandleForHeapStart();

	rtvDescriptorHeapHandles[i].ptr += i * static_cast<SIZE_T>(rtvDescriptorSize);
	rtvDescriptorHeapHandles[i + backBufferCount].ptr += (i + backBufferCount) * static_cast<SIZE_T>(rtvDescriptorSize);
}

D3D12_DESCRIPTOR_HEAP_DESC#NumDescriptorsメンバの値の変更も忘れずに行いましょう。

ディスクリプタヒープの修正が完了したら、RTVの生成を行いましょう。ガンマ補正ありの場合、フォーマットの設定を施したD3D12_RENDER_TARGET_VIEW_DESCの作成を行い、ID3D12Device#CreateRenderTargetView()の第2引数に渡す必要があります。(第2引数に渡さない場合、スワップチェーンのフォーマットをそのまま使用することを意味します)

以下に、私のエンジンでの実例を掲載します。2種類のRTVを生成していることに注目してください。

/// <summary>
/// バックバッファに紐付けるレンダーターゲットビューを生成する
/// ガンマ補正あり・なしでそれぞれ生成する
/// </summary>
/// <returns>レンダーターゲットビューの生成に成功したらtrue</returns>
constexpr bool D3DX12Wrapper::GraphicsCore::createRenderTargetViewOfBackBuffer() noexcept {
	D3D12_RENDER_TARGET_VIEW_DESC renderTargetViewDesc = {};
	renderTargetViewDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM_SRGB;
	renderTargetViewDesc.ViewDimension = D3D12_RTV_DIMENSION_TEXTURE2D;

	backBuffers.resize(backBufferCount);
	for (UINT i = 0U; i < backBufferCount; ++i) {
		if (FAILED(swapChain->GetBuffer(i, IID_PPV_ARGS(backBuffers[i].ReleaseAndGetAddressOf())))) {
			return false;
		}
		device->CreateRenderTargetView(backBuffers[i].Get(), nullptr, rtvDescriptorHeapHandles[i]);
		device->CreateRenderTargetView(backBuffers[i].Get(), &renderTargetViewDesc, rtvDescriptorHeapHandles[i + backBufferCount]);
	}
	return true;
}

ガンマ補正あり・なしでパイプラインステートオブジェクトを分ける

PSOもガンマ補正あり・なしで分ける必要があります。差異があるのはD3D12_GRAPHICS_PIPELINE_STATE_DESC#RTVFormatsメンバのみなので、ガンマ補正なしのPSOの作成に続けて、RTVFormatsのみ変更してガンマ補正ありのPSOを作成するのが簡単です。

以下に、私のエンジンでの実例を掲載します。pipelineState生成時の設定をの大部分を流用しつつ、pipelineStateSRGBを生成していることに注目してください。

//この時点で、pipelineStateDesc.RTVFormats[0]はDXGI_FORMAT_R8G8B8A8_UNORM
ComPtr<ID3D12PipelineState> pipelineState;
if (SUCCEEDED(device->CreateGraphicsPipelineState(&pipelineStateDesc, IID_PPV_ARGS(pipelineState.ReleaseAndGetAddressOf())))) [[likely]] {
	graphicsPipelineStates[stateKey] = std::pair(pipelineState, rootSignature);
}

pipelineStateDesc.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM_SRGB;

ComPtr<ID3D12PipelineState> pipelineStateSRGB;
if (SUCCEEDED(device->CreateGraphicsPipelineState(&pipelineStateDesc, IID_PPV_ARGS(pipelineStateSRGB.ReleaseAndGetAddressOf())))) [[likely]] {
	graphicsPipelineStates[stateKey + "_SRGB"] = std::pair(pipelineStateSRGB, rootSignature);
}

私のエンジンでは、テクスチャを利用するメッシュの生成時に、使用するテクスチャのガンマ補正の有無を確認してPSOを選択するようにしています。

/// <summary>
/// テクスチャを貼るメッシュを生成して返す。(TにはTextureMeshかTextureMeshWithIndexを指定する)
/// 頂点やインデックスの登録はメッシュに対して実施すること。
/// グローバルコンスタントバッファがある場合、ローカルと並び順を同じにする必要がある。
/// </summary>
/// <param name="vertexCount">頂点数</param>
/// <param name="instanceCount">インスタンス数</param>
/// <param name="cmdListIndex">割り当てるコマンドリストのIndex</param>
/// <param name="graphicsPipelineStateKey">適用するグラフィックスパイプラインステートのキー</param>
/// <param name="textureKey">テクスチャに紐づくキー</param>
/// <param name="vertexConstantBufferKeys">頂点シェーダで利用するコンスタントバッファのキー群</param>
/// <param name="pixelConstantBufferKeys">ピクセルシェーダで利用するコンスタントバッファのキー群</param>
/// <param name="vertexConstantBufferKeysOfGlobal">頂点シェーダで利用するコンスタントバッファのキー群(グローバル)</param>
/// <param name="pixelConstantBufferKeysOfGlobal">ピクセルシェーダで利用するコンスタントバッファのキー群(グローバル)</param>
/// <returns>メッシュ</returns>
template <typename T>
[[nodiscard]] constexpr std::shared_ptr<T> createTextureMesh(const int vertexCount, const int instanceCount, const int cmdListIndex, const std::string& graphicsPipelineStateKey, const std::string& textureKey, const std::vector<std::string>& vertexConstantBufferKeys, const std::vector<std::string>& pixelConstantBufferKeys, const std::vector<std::string>& vertexConstantBufferKeysOfGlobal, const std::vector<std::string>& pixelConstantBufferKeysOfGlobal) const noexcept {
	const auto texture = textureRepository->get(textureKey);
	if (texture->enabledGamma()) {
		return std::make_shared<T>(this, vertexCount, instanceCount, cmdListIndex, graphicsPipelineStateRepository->getGraphicsPipelineState(graphicsPipelineStateKey + "_SRGB"), texture, vertexConstantBufferKeys, pixelConstantBufferKeys, vertexConstantBufferKeysOfGlobal, pixelConstantBufferKeysOfGlobal);
	}
	else {
		return std::make_shared<T>(this, vertexCount, instanceCount, cmdListIndex, graphicsPipelineStateRepository->getGraphicsPipelineState(graphicsPipelineStateKey), texture, vertexConstantBufferKeys, pixelConstantBufferKeys, vertexConstantBufferKeysOfGlobal, pixelConstantBufferKeysOfGlobal);
	}
}

描画時にガンマ補正あり・なしでRTVを切り替える

RTV、PSOの修正が完了したら、いよいよ描画です。メッシュの描画時にガンマ補正のあり・なしでRTVを切り替える必要があるため、その部分の実装を行います。
まずは、RTVを切り替える処理を実装します。

以下に、私のエンジンでの実例を掲載します。引数enableGammaによって使用するハンドルを変え、ガンマ補正あり・なしのRTVを参照し分けていることに注目してください。

/// <summary>
/// レンダリングターゲットをガンマ補正あり・なしで切り替える
/// </summary>
/// <param name="enableGamma">ガンマ補正を有効にする場合はtrue</param>
/// <returns></returns>
void D3DX12Wrapper::GraphicsCore::changeRenderTargetByGamma(const bool enableGamma) const noexcept {
	const auto backBufferIndex = swapChain->GetCurrentBackBufferIndex();
	const auto cmdLists = cmdListsOfBackBuffer[backBufferIndex];
	int index = 0;
	if (enableGamma) {
		index = backBufferIndex + backBufferCount;
	}
	else {
		index = backBufferIndex;
	}
	const auto rtvDescriptorHeapHandle = rtvDescriptorHeapHandles[index];
	
	for (UINT i = 0; i < commandListCount; ++i) {
		cmdLists[i]->OMSetRenderTargets(1, &rtvDescriptorHeapHandle, true, &dsvDescriptorHeapHandle);
	}
}

後は、上記の実装を用いて、メッシュの描画時にRTVを切り替えます。ガンマ補正ありのテクスチャを用いて描画した場合、描画後にもとのRTVに戻すようにします。RTVの切替は、結果的にレンダリングターゲットの切替えも伴いそれなりにコストがかかるので、必要な場合のみ切り替えるようにしましょう。

以下に、私のエンジンでの実例を掲載します。TextureBase#enabledGamma()の結果によって、描画の前後にRTVを切り替えていることに注目してください。

/// <summary>
/// メッシュを描画する
/// </summary>
/// <returns></returns>
void D3DX12Wrapper::TextureMeshWithIndex::draw() const noexcept {
	const auto cmdList = parent->getCurrentCommandList(cmdListIndex);

	if (texture->enabledGamma()) {
		parent->changeRenderTargetByGamma(true);
	}

	cmdList->SetPipelineState(graphicsPipelineState.first.Get());
	cmdList->SetGraphicsRootSignature(graphicsPipelineState.second.Get());
	if (!vertexConstantBufferKeys.empty()) {
		auto constantBuffer = parent->getConstantBuffer(vertexConstantBufferKeys[0]);
		cmdList->SetGraphicsRootDescriptorTable(0U, parent->getGPUHandleOfResourceDescriptorHeap(constantBuffer->getId()));
	}
	if (!pixelConstantBufferKeys.empty()) {
		auto constantBuffer = parent->getConstantBuffer(pixelConstantBufferKeys[0]);
		cmdList->SetGraphicsRootDescriptorTable(1U, parent->getGPUHandleOfResourceDescriptorHeap(constantBuffer->getId()));
	}
	cmdList->SetGraphicsRootDescriptorTable(2U, parent->getGPUHandleOfResourceDescriptorHeap(texture->getId()));

	cmdList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
	cmdList->IASetVertexBuffers(0U, 1U, &vertexBufferView);
	cmdList->IASetIndexBuffer(&indexBufferView);
	cmdList->DrawIndexedInstanced(static_cast<UINT>(indices.size()), instanceCount, 0U, 0U, 0U);

	if (texture->enabledGamma()) {
		parent->changeRenderTargetByGamma(false);
	}
}

おわりに

お疲れ様でした!
今回は私のエンジンからの実例の掲載が多めだったので、なかなか読み解くのが難しかったのではないでしょうか。ただ、今回の内容は結局のところガンマ補正あり・なしでRTV、PSOを増やして切り替えているだけで、特有の高難易度な実装は含まれていません。流れが分かれば読者が作成しているエンジンへの導入も比較的容易に行えるかと思いますので、ぜひ挑戦してみてください!