ゲーム開発の備忘録

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

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を増やして切り替えているだけで、特有の高難易度な実装は含まれていません。流れが分かれば読者が作成しているエンジンへの導入も比較的容易に行えるかと思いますので、ぜひ挑戦してみてください!

DirectX12でステンシルテストを実行する

はじめに

本記事では、DirectX12でのステンシルテストの実行方法を解説します。
ステンシルテストはDirectXに昔から備わっている機能ですが、DirectX12での実行方法については書籍、Webサイトはおろか、Microsoft Docsにも解説がありません。また、サンプルコードも存在しません。とはいえ、基本は深度ステンシルステートの設定を変更してグラフィックスパイプラインステートオブジェクトに紐づければよいので、DirectX12をある程度触って慣れていれば大きく詰まる箇所は少ないです。しかし、1箇所だけDirectX12特有の設定項目があり、その部分に苦戦して2時間ほど消費しました。この記事が、今後DirectX12でステンシルテストを実行したいと考える方の助けとなれば幸いです。
なお、本記事では読者の方がステンシルテストそのものについては理解されている前提で話を進めます。

深度ステンシルバッファの設定変更

まず、ステンシルテストが実行できるように深度ステンシルバッファの設定を変更する必要があります。実はこの部分が最も見落としやすい要注意箇所なのです。分かってしまえば簡単な話なのですが……

深度ステンシルバッファのリソースを生成している箇所で、D3D12_RESOURCE_DESC構造体のFormatメンバの値をステンシルバッファのためのメモリを確保するようなメモリフォーマットに変更する必要があります。深度テストのみの場合、大抵はDXGI_FORMAT_D32_FLOATを指定しているかと思いますが、これをDXGI_FORMAT_D24_UNORM_S8_UINTのように、ステンシルバッファ用のメモリを確保するようなフォーマットに変更します。上記の定数の場合、D24で深度バッファが3Byte分、S8でステンシルバッファが1Byte分確保されます。
同様に、D3D12_CLEAR_VALUE構造体のFormatメンバ、D3D12_DEPTH_STENCIL_VIEW_DESC構造体のFormatメンバの値も変更しましょう。

この設定を忘れると、グラフィックスパイプラインステートオブジェクトに紐づけた深度ステンシルステートでステンシルテストを有効にしていても、ステンシルテストが実行されません。しかし、その場合でもエラーは全く表示されないため、原因を掴むのに非常に苦労しました……

深度ステンシルバッファのクリア処理の変更

深度ステンシルバッファの設定変更が完了したら、ID3D12GraphicsCommandList#ClearDepthStencilView()の第2引数に論理和を用いてD3D12_CLEAR_FLAG_DEPTH | D3D12_CLEAR_FLAG_STENCILのように指定し、ステンシルバッファ側もクリアするように変更しましょう。第4引数に指定するクリア時の設定値は0Uで問題ありません。

ステンシル参照値を初期化したい場合、ID3D12GraphicsCommandList#OMSetStencilRef()でステンシル参照値を設定することができます。

深度ステンシルステートの作成

ステンシルバッファ値を更新するための深度ステンシルステートと、ステンシルテストを実行するための深度ステンシルステートを作成します。
ステンシルバッファ値を更新するための深度ステンシルステートのD3D12_DEPTH_STENCIL_DESC構造体の設定例を以下に示します。

D3D12_DEPTH_STENCIL_DESC depthStencilDesc = {};

depthStencilDesc.DepthEnable = FALSE;
depthStencilDesc.StencilEnable = TRUE;
depthStencilDesc.StencilReadMask = D3D12_DEFAULT_STENCIL_READ_MASK;
depthStencilDesc.StencilWriteMask = D3D12_DEFAULT_STENCIL_WRITE_MASK;
depthStencilDesc.FrontFace.StencilFailOp = D3D12_STENCIL_OP_KEEP;
depthStencilDesc.FrontFace.StencilDepthFailOp = D3D12_STENCIL_OP_KEEP;
depthStencilDesc.FrontFace.StencilPassOp = D3D12_STENCIL_OP_INCR;
depthStencilDesc.FrontFace.StencilFunc = D3D12_COMPARISON_FUNC_ALWAYS;
depthStencilDesc.BackFace.StencilFailOp = depthStencilDesc.FrontFace.StencilFailOp;
depthStencilDesc.BackFace.StencilDepthFailOp = depthStencilDesc.FrontFace.StencilDepthFailOp;
depthStencilDesc.BackFace.StencilPassOp = depthStencilDesc.FrontFace.StencilPassOp;
depthStencilDesc.BackFace.StencilFunc = depthStencilDesc.FrontFace.StencilFunc;

ステンシルテストを実行するための深度ステンシルステートのD3D12_DEPTH_STENCIL_DESC構造体の設定例を以下に示します。

D3D12_DEPTH_STENCIL_DESC depthStencilDesc = {};

depthStencilDesc.DepthEnable = FALSE;
depthStencilDesc.StencilEnable = TRUE;
depthStencilDesc.StencilReadMask = D3D12_DEFAULT_STENCIL_READ_MASK;
depthStencilDesc.StencilWriteMask = 0x00;
depthStencilDesc.FrontFace.StencilFailOp = D3D12_STENCIL_OP_ZERO;
depthStencilDesc.FrontFace.StencilDepthFailOp = D3D12_STENCIL_OP_KEEP;
depthStencilDesc.FrontFace.StencilPassOp = D3D12_STENCIL_OP_KEEP;
depthStencilDesc.FrontFace.StencilFunc = D3D12_COMPARISON_FUNC_EQUAL;
depthStencilDesc.BackFace.StencilFailOp = depthStencilDesc.FrontFace.StencilFailOp;
depthStencilDesc.BackFace.StencilDepthFailOp = depthStencilDesc.FrontFace.StencilDepthFailOp;
depthStencilDesc.BackFace.StencilPassOp = depthStencilDesc.FrontFace.StencilPassOp;
depthStencilDesc.BackFace.StencilFunc = depthStencilDesc.FrontFace.StencilFunc;

depthStencilDesc.FrontFace.StencilFuncに指定する値をD3D12_COMPARISON_FUNC_NOT_EQUALに変更することで、くりぬく範囲を反転させることができます。

描画

ここまで準備できたら、早速ステンシルテストを利用した描画を試してみましょう。
ステンシルバッファ値を更新するための深度ステンシルステートを用いて描画し、その後、ステンシルテストを実行するための深度ステンシルステートを用いて描画することでステンシルテストを実行できます。
ただし、ここで一つ注意点があります。DirectX12ではステンシルバッファ値を更新するための深度ステンシルステートを用いて描画する際に、レンダリングターゲットへの書込みを抑制することができません。そのため、ピクセルシェーダ側で常に自身の描画結果を破棄するか、透過して出力する必要があります。

終わりに

お疲れ様でした。分かってしまえば、DirectX12でもDirectX11とほぼ同じ労力でステンシルテストを実行することができます。是非、エフェクト作成等で役立ててみてください!

DirectX12上でDirectWriteを利用して文字列を描画する

はじめに

DirectXで文字列描画をしたい時、古来から2つの方法が紹介されてきました。1つは、文字や文字列のテクスチャを用意して、文字コードやその他の変数によってテクスチャ内の描画範囲を特定し、描画する方法です。そしてもう一つは、DirectXが持つ機能を利用して画像を用意せずとも文字列を描画する方法です。この方法は、DirectX9ではDrawText()関数、DirectX11ではDirectWriteを利用することで実現できました。
あらゆる文字列を画像で用意しようとすると手間がかかる場面もあるでしょう。そのような場合は後者の方法で文字列を描画したいものです。

では、DirectX12ではどのようにして後者の方法を実現できるのでしょうか。本記事では、DirectX12で文字列画像を用意することなく文字列を描画する方法を解説します。

文字列描画方法の概要

DirectX12で文字列描画を行うには、DirectX11と同様にDirectWriteを利用します。しかし、ここで問題があります。DirectWriteで描画可能なレンダリングターゲットは、DirectX11のリソースからしか取得できないのです。そこで、DirectX12の上でDirectX11を動作させるための仕組みであるD3D11On12を利用します。D3D11On12を用いてDirectX12上で動作するDirectX11デバイスを用意し、そこからDirect2Dデバイス、DirectX11のリソース、DirectWriteで描画可能なレンダリングターゲットを作成し、DirectWriteを利用可能とします。

初期化手順

IDWriteFactoryの生成

まず、テキストフォーマットを生成するためのファクトリであるIDWriteFactoryを生成します。このファクトリは独立して生成可能ですので任意のタイミングで生成しておきましょう。今回の初期化手順の中で、唯一デバイスロストしても再生成する必要のないものですのでそれを考慮しておくと良いです。

if (FAILED(DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), &directWriteFactory))) [[unlikely]] {
	return false;
}
ID3D11On12Deviceの生成

DirectX12上で動作するDirectX11デバイスを表すID3D11On12Deviceを生成します。コマンドキュー生成後に生成可能となります。

/// <summary>
/// D3D11On12Deviceを作成する。
/// </summary>
/// <returns>D3D11On12Deviceの作成に成功したらtrue</returns>
bool D3DX12Wrapper::GraphicsCore::createD3D11On12Device() noexcept {
	ComPtr<ID3D11Device> d3d11Device = nullptr;
	UINT d3d11DeviceFlags = 0U;

#ifdef _DEBUG
	d3d11DeviceFlags = D3D11_CREATE_DEVICE_DEBUG | D3D11_CREATE_DEVICE_BGRA_SUPPORT;
#else
	d3d11DeviceFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
#endif

	if (FAILED(D3D11On12CreateDevice(device.Get(), d3d11DeviceFlags, nullptr, 0U, reinterpret_cast<IUnknown**>(cmdQueue.GetAddressOf()), 1U, 0U, &d3d11Device, &d3d11On12DeviceContext, nullptr))) [[unlikely]] {
		return false;
	}

	return SUCCEEDED(d3d11Device.As(&d3d11On12Device));
}

ID3D11Deviceの形式でデバイスが生成されるので、As()関数を利用してID3D11On12Deviceに変換します。ID3D11Deviceとしてはお役御免なので特に保存しておく必要はありません。ただし、同時に取得可能なID3D11DeviceContextのほうは後々描画内容のflushに必要になるので忘れずに保存しておきましょう。

ID2D1DeviceContextの生成

文字列描画時に必要となるID2D1DeviceContextを生成します。ID2D1Factory3を生成し、ID3D11On12Deviceから変換したIDXGIDeviceを用いてID2D1Deviceを生成して、ID2D1DeviceContextを生成します。

/// <summary>
/// Direct2DDeviceContextを生成する。
/// </summary>
/// <returns>Direct2DDeviceContextの生成に成功したらtrue</returns>
bool D3DX12Wrapper::GraphicsCore::createD2DDeviceContext() noexcept {
	ComPtr<ID2D1Factory3> d2dfactory = nullptr;
	constexpr D2D1_FACTORY_OPTIONS factoryOptions {};

	if (FAILED(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, __uuidof(ID2D1Factory3), &factoryOptions, &d2dfactory))) [[unlikely]] {
		return false;
	}

	ComPtr<IDXGIDevice> dxgiDevice = nullptr;
	if (FAILED(d3d11On12Device.As(&dxgiDevice))) [[unlikely]] {
		return false;
	}

	ComPtr<ID2D1Device> d2dDevice = nullptr;
	if (FAILED(d2dfactory->CreateDevice(dxgiDevice.Get(), d2dDevice.ReleaseAndGetAddressOf()))) [[unlikely]] {
		return false;
	}
	
	return SUCCEEDED(d2dDevice->CreateDeviceContext(D2D1_DEVICE_CONTEXT_OPTIONS_NONE, d2dDeviceContext.ReleaseAndGetAddressOf()));
}

ID2D1Factory3、IDXGIDevice、ID2D1Deviceは今後利用しないため、保存しておく必要はありません。ID2D1DeviceContextのみ保存しておきましょう。

ID2D1Bitmap1の生成

DirectWriteでのレンダリングターゲットとなるID2D1Bitmap1を生成します。本記事の冒頭でもお話ししたように、ID2D1Bitmap1はID3D11Resource経由でしか生成することができません。そこで、まずはDirectX12で利用可能なID3D12ResourceをラップしたID3D11ResourceをID3D11On12Device::CreateWrappedResource()関数で生成します。生成されたID3D11ResourceをAs()関数でIDXGISurfaceに変換し、IDXGISurfaceからID2D1Bitmap1を生成します。

ここで取得するID3D11ResourceはDirectX12のレンダリングターゲットであるリソースを変換したものであるため、実体はDirectX12のレンダリングターゲットとなります。そのため、結果的にDirectX12の描画フロー内で描画することになるので、ID2D1Bitmap1もバックバッファの数だけ準備する必要があることに注意してください。

/// <summary>
/// DirectWriteの描画先を生成する
/// </summary>
/// <param name="hwnd">ウィンドウハンドル</param>
/// <returns>生成に成功したらtrue</returns>
bool D3DX12Wrapper::GraphicsCore::createD2DRenderTarget(const HWND hwnd) noexcept {
	D3D11_RESOURCE_FLAGS flags = { D3D11_BIND_RENDER_TARGET };
	const UINT dpi = GetDpiForWindow(hwnd);
	D2D1_BITMAP_PROPERTIES1 bitmapProperties = D2D1::BitmapProperties1(D2D1_BITMAP_OPTIONS_TARGET | D2D1_BITMAP_OPTIONS_CANNOT_DRAW, D2D1::PixelFormat(DXGI_FORMAT_UNKNOWN, D2D1_ALPHA_MODE_PREMULTIPLIED), static_cast<float>(dpi), static_cast<float>(dpi));

	for (UINT i = 0U; i < backBufferCount; ++i) {
		ComPtr<ID3D11Resource> wrappedBackBuffer = nullptr;
		if (FAILED(d3d11On12Device->CreateWrappedResource(backBuffers[i].Get(), &flags, D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT, IID_PPV_ARGS(wrappedBackBuffer.ReleaseAndGetAddressOf())))) [[unlikely]] {
			return false;
		}

		ComPtr<IDXGISurface> dxgiSurface = nullptr;
		if (FAILED(wrappedBackBuffer.As(&dxgiSurface))) [[unlikely]] {
			return false;
		}
		
		ComPtr<ID2D1Bitmap1> d2dRenderTarget = nullptr;
		if (FAILED(d2dDeviceContext->CreateBitmapFromDxgiSurface(dxgiSurface.Get(), &bitmapProperties, &d2dRenderTarget))) [[unlikely]] {
			return false;
		}
		wrappedBackBuffers.emplace_back(wrappedBackBuffer);
		d2dRenderTargets.emplace_back(d2dRenderTarget);
	}

	return true;
}

IDXGISurfaceは保存する必要はないですが、ID3D11Resourceは後で必要になるので保存しておきます。CreateWrappedResource()関数の第3引数、第4引数の値が後で重要になってきます。詳しくは描画処理の解説の折に説明します。

ここまで問題なく処理できたら、ようやく初期化は完了です。

文字列描画準備

初期化が完了したら、文字列を描画するための準備としてID2D1SolidColorBrushIDWriteTextFormatを生成します。これはDirectX11の時と変わりありませんね。以下にまとめてC++コードを記載します。
なお、私の作成したライブラリではそれぞれunordered_mapで管理しています。

/// <summary>
/// SolidColorBrushを登録する
/// </summary>
/// <param name="key">SolidColorBrushに紐付けるキー</param>
/// <param name="color">色</param>
void registerSolidColorBrush(const std::string& key, const D2D1::ColorF color) noexcept {
	if (solidColorBrushMap.contains(key)) [[unlikely]] {
		return;
	}

	ComPtr<ID2D1SolidColorBrush> brush = nullptr;
	d2dDeviceContext->CreateSolidColorBrush(D2D1::ColorF(color), brush.GetAddressOf());
	solidColorBrushMap[key] = brush;
}

/// <summary>
/// テキストフォーマットを登録する
/// </summary>
/// <param name="key">テキストフォーマットに紐付けるキー</param>
/// <param name="fontName">フォント名</param>
/// <param name="fontSize">フォントサイズ</param>
void registerTextFormat(const std::string& key, const std::wstring& fontName,
	const FLOAT fontSize) noexcept {
	if (textFormatMap.contains(key)) [[unlikely]] {
		return;
	}

	ComPtr<IDWriteTextFormat> textFormat = nullptr;
	directWriteFactory->CreateTextFormat(fontName.c_str(), nullptr, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL, fontSize, L"ja-jp", textFormat.GetAddressOf());
	textFormatMap[key] = textFormat;
}

文字列描画処理

いよいよ文字列を描画していきます。まず注意すべき点として、DirectWriteの描画はDirect3D側の描画がすべて完了してから実施する必要があります。Direct3Dで構築したシーンの上から、Direct2Dで文字列を描画していくということですね。DirectX12で言えば、ExecuteCommandLists()関数、Signal()関数を実行してGPU側の処理がすべて完了したことを確認してからDirectWriteの描画に移ることになります。

なお、Direct3D側の描画のみ実施していた時には、リソースバリアでD3D12_RESOURCE_STATE_RENDER_TARGETからD3D12_RESOURCE_STATE_PRESENTへの状態遷移を実施していたかと思います。しかし、直後にDirect2D系の描画が実行される場合はこのリソースバリアの設定を削除しておかなければなりません。なぜなら、DirectX12のレンダリングターゲットとID2D1Bitmap1の内容は実質的に同一であるためです。ID2D1Bitmap1の生成時に、ID3D12ResourceをラップしたID3D11Resourceとしてリソースを準備していたことを思い出してください。Direct3D側もDirect2D側も実質的に同じレンダリングターゲットに描画することになるため、リソースの状態はDirect3D側の描画終了時もD3D12_RESOURCE_STATE_RENDER_TARGETのままである必要があります。

Direct2D側の描画を開始するためのC++コードを以下に示します。以下の関数はDirect3D側の描画完了後に呼び出します。

/// <summary>
/// Direct2Dの描画を開始する
/// </summary>
/// <returns></returns>
void D3DX12Wrapper::GraphicsCore::beginDrawWithD2D() const noexcept {
	const auto backBufferIndex = swapChain->GetCurrentBackBufferIndex();
	const auto wrappedBackBuffer = wrappedBackBuffers[backBufferIndex];
	const auto backBufferForD2D = d2dRenderTargets[backBufferIndex];

	d3d11On12Device->AcquireWrappedResources(wrappedBackBuffer.GetAddressOf(), 1);
	d2dDeviceContext->SetTarget(backBufferForD2D.Get());
	d2dDeviceContext->BeginDraw();
	d2dDeviceContext->SetTransform(D2D1::Matrix3x2F::Identity());
}

ID3D11On12Device::AcquireWrappedResources()関数により、ID3D11Resourceへのアクセス権を取得します。
上記の関数を呼んだ後に、ID2D1DeviceContextを利用して、DirectWriteによる文字列描画が可能になります。

/// <summary>
/// 文字列を描画する
/// </summary>
/// <param name="textFormatKey">テキストフォーマットに紐付けるキー</param>
/// <param name="solidColorBrushKey">SolidColorBrushに紐付けるキー</param>
/// <param name="text">描画対象文字列</param>
/// <param name="rect">描画矩形</param>
void drawText(const std::string& textFormatKey, const std::string& solidColorBrushKey,
	const std::wstring& text, const D2D1_RECT_F& rect) const noexcept {
	const auto textFormat = textFormatMap.at(textFormatKey);
	const auto solidColorBrush = solidColorBrushMap.at(solidColorBrushKey);

	d2dDeviceContext->DrawTextW(text.c_str(), static_cast<UINT32>(text.length()), textFormat.Get(), &rect, solidColorBrush.Get());
}

Direct2D側の描画が完了したら、Direct2D側の描画処理を終了します。Direct2D側の描画を終了するためのC++コードを以下に示します。

/// <summary>
/// Direct2Dの描画を終了する
/// </summary>
void D3DX12Wrapper::GraphicsCore::endDrawWithD2D() const noexcept {
	const auto backBufferIndex = swapChain->GetCurrentBackBufferIndex();
	const auto wrappedBackBuffer = wrappedBackBuffers[backBufferIndex];

	d2dDeviceContext->EndDraw();
	d3d11On12Device->ReleaseWrappedResources(wrappedBackBuffer.GetAddressOf(), 1);
	d3d11On12DeviceContext->Flush();
}

ID3D11On12Device::ReleaseWrappedResources()関数で、ID3D11Resourceのアクセス権を解放します。また、ID3D11DeviceContext::Flush()により、Direct2D側の描画内容も確定し、スワッピング可能な状態になります。ID3D11DeviceContextはここだけのために必要になります。

さて、DirectX12を触っている読者の方であれば、ここで一つ疑問が湧くはずです。Direct3D側の描画終了時には設定しなかったリソースバリアは結局どうなるのでしょうか。実は、リソースバリアの設定は必要ありません。ID3D11Resource取得時に利用したID3D11On12Device::CreateWrappedResource()関数の第3引数、第4引数にそれぞれD3D12_RESOURCE_STATE_RENDER_TARGET、D3D12_RESOURCE_STATE_PRESENTを設定していたことを思い出してください。ここで設定した状態遷移がID3D11On12Device::ReleaseWrappedResources()関数実行時のリソースの状態遷移となるのです。
リソースバリアと同様に、ID3D11On12Device::ReleaseWrappedResources()関数実行前のリソースの状態がID3D11On12Device::ReleaseWrappedResources()関数の第3引数で指定した状態である必要があり、ID3D11On12Device::ReleaseWrappedResources()関数実行後のリソースの状態がID3D11On12Device::ReleaseWrappedResources()関数の第4引数で指定した状態となります。

Direct2D側の描画も完了したら、スワップチェーンによるスワッピングを行い一連の描画処理が終了となります。

実行結果

上手く実行できると、上記のように文字列を描画できます。

ここまで頑張ってこれだけか……と感じてしまうかもしれませんが、後々の実装が楽になることは確実です。是非、皆さんもDirectX12での文字列描画を実現してみてください!

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と格闘している方の助けになれば幸いです!

libGDXでPlay Asset Deliveryに対応する

はじめに

Androidアプリをリリースする場合、Google Play Storeにて、AAB(Android App Bundle)を提出する必要があります。しかし、AABにはサイズ制限があり、150MBを超過すると、そのままではアップロードできなくなってしまいます。

以前は、APK拡張という方法でリソースをパッチに分割する方法がありましたが、それは廃止され、AABで一元的に管理する流れになっていました。
deep-verdure.hatenablog.com

では、APK拡張亡き現在ではどのように対処するのかというと、PAD(Play Asset Delivery)という手段で、リソースをAsset Packに移動します。
PADに対応することで、Android App Bundleのサイズのうち、リソースのサイズが除外され、Google Play Storeにリリースできるようになります。

PADにつきましては、以下の公式リファレンスを参照ください。
developer.android.com


さて、今回の問題は、やはりlibGDXプロジェクトでPAD対応するための情報が一切存在しないことでした。
色々試行錯誤して対応したのですが、今後のために知見をまとめておくことにします。

配信モードについて

PADには以下の3つの配信モードがあります。

・install-time
・fast-follow
・on-demand

今回は、このうちのinstall-timeで対応しました。install-timeは、アプリ起動直後からリソースにアクセス可能となるタイプの配信モードです。最も実装が簡単になるため、今回はこの配信モードを選択しました。
なお、fast-followはアプリダウンロード時にリソースを同時にダウンロードします。アプリ側では、リソース側のダウンロードが完了したかどうかの確認等の制御ロジックを組み込む必要が出てきます。
また、on-demandはその名の通りオンデマンド配信が可能になります。非常にリッチな機能ですが、fast-followと同様にダウンロード状況の制御ロジックが必要となります。fast-followとon-demandを用いる場合は、fast-follow、on-demandを利用する前提での設計を開発初期の段階から進めないと厳しい印象があります。

今回は趣味で3年近くメンテナンスしているプロジェクトへの対応となりましたので、install-time以外の配信モードでの対応は無謀と考えました。(といってもinstall-timeの対応も十分難しいのですが……)

PAD対応したAABのビルド

まずは、PADに対応したAABをビルドする必要があります。基本的には、以下の公式リファレンスの手順に沿って進めれば問題ありません。
今まで、android/assetsに配置していたアセットを、全てAsset Packに移動させます。
developer.android.com

libGDX特有の注意点として、ios-moeやdesktop等の他のプロジェクトでは、ワーキングディレクトリがandroid/assetsに設定されています。他プロジェクトのワーキングディレクトリをAsset Packのディレクトリに指し直すことで、最小限の修正で他のプロジェクトのビルドへの影響を無くすことができます。

コード上でのリソースの読込み

PAD対応したAABがビルドできたら、コード上で実際にリソースを読み込めるようにします。リソースはInputStream経由で読み込むことになりますが、libGDXでは、リソースの読込みはファイルからの読込みしか標準で対応されていません。リソースローダを自作することで対応が一応可能ですが、デフォルトのローダが実装している非同期処理を全て自前で実装しなければならないため、非常にコストがかかります。そこで、InputStreamをもとに一旦ファイルとして出力し、そのファイルをリソースとして読込むことにしました。

ここで問題になるのが、libGDXのファイルタイプです。libGDXではファイルをinternal, external, local, absoluteの4つのファイルタイプに分けて扱います。libGDXのチュートリアル通りに進めると、リソースは全てinternalで扱うべきと習うはずです。しかし、internalファイルはワーキングディレクトリ(デフォルトではandroid/assets)に、事前に配置されているアセットからしか生成することができません。今回の場合、Asset Packからリソースを読込むところからのスタートとなるため、internalファイルでは対応できないのです。今回は、一時ファイルとしてlocalファイルにAsset Packから読込んだリソースを出力し、それをlibGDX側で読込ませることにしました。

まずは、core側からリソースロード/アンロードを指示できるようにインターフェイスを用意します。

interface IAssetLoader {
 fun loadAsset(assetFilePath: String): String
 fun unloadAsset(tmpFilePath: String)
}

インターフェイスのメソッドの実装は、androidプロジェクト側に記述します。まずはloadAsset()の実装です。

override fun loadAsset(assetFilePath: String): String {
 if (resourceIDMap.get(assetFilePath) != null) {
  return ""
 }

 //一時ファイルを識別するIDを生成する
 var id: Int
 do {
  id = MathUtils.random(0, Int.MAX_VALUE - 1)
 } while (resourceIDMap.containsValue(id, true))

 //一時ファイルにリソースを書出す
 val split = assetFilePath.split(".")
 val suffix = split[split.size - 1]
 val tmpFile = Gdx.files.local("./usc/$id.$suffix")
 val input = assetManager.open(assetFilePath)
 tmpFile.writeBytes(input.readBytes(), false)
 input.close()
 resourceIDMap.put(tmpFile.path(), id)

 return tmpFile.path()
}

localファイルは、Androidではアプリケーションの内部ストレージからの相対パスの位置に生成されます。書出し毎に内部ストレージにディレクトリ階層を作ることは現実的ではないため、内部ストレージのルートに一律ですべての一時ファイルを生成することにしました。従って、ファイル名が重複しないように、ファイル名をIDとして管理する必要があります。
loadAsset()では、もともとのアセットのパス(=Asset Packのルートからのアセットの相対パス。desktop等の他のプロジェクトにおけるワーキングディレクトリのルートからのアセットの相対パスに相当)をもとに、Asset Pack内のアセットを読込み、一時ファイルにアセットの内容を出力して、その一時ファイルのパスを返します。この戻り値をcore側で利用することになります。

unloadAsset()の実装は以下の通りです。

override fun unloadAsset(tmpFilePath: String) {
 val tmpFile = File(tmpFilePath)
 tmpFile.delete()
 resourceIDMap.remove(tmpFilePath)
}

core側から一時ファイルのパスを受け取り、それをもとにlocalファイルを削除します。

次に、core側でのリソースの管理部を修正します。まず初めに、AssetManagerのコンストラクタに、LocalFileHandleResolverのインスタンスを渡します。デフォルトでは、AssetManagerはinternalファイルしか扱うことができません。コンストラクタに、LocalFileHandleResolverのインスタンスを渡すことで、AssetManagerがlocalファイルを扱えるようになります。これを忘れると、AssetManager#update()でGdxRuntimeExceptionが送出されてしまいます。

private val assetManager =
            if (GameMain.platform == GameMain.Platform.ANDROID) {
                AssetManager(LocalFileHandleResolver())
            } else {
                AssetManager()
            }

core側でのリソース管理の例として、SEの管理を挙げます。まずは、SEの読込みです。

fun loadSE(key: String, path: String) {
 if (seMap.get(key) != null) {
  return
 }

 val actualPath =
                if (GameMain.platform == GameMain.Platform.ANDROID) {
                    GameMain.assetLoader!!.loadAsset(path)
                } else {
                    path
                }
 assetManager.load(actualPath, Sound::class.java)
 seMap.put(key, actualPath)
}

actualPathをもとに、SEを読込んでいます。Androidの場合は先程のloadAsset()を呼び出して、一時ファイルのパスを取得します。

次に、SEの破棄です。

private fun unloadSE(key: String) {
 if (seMap.get(key) == null) {
  return
 }

 assetManager.unload(seMap[key])
 if (GameMain.platform == GameMain.Platform.ANDROID) {
  GameMain.assetLoader!!.unloadAsset(seMap[key])
 }

 seMap.remove(key)
}

Androidの場合は、先程のunloadAsset()を呼び出して、一時ファイルも削除するようにしています。

今回の対応では、SEの再生や音量調整等の他の操作のコードには手を加えずに済みました。SEの他には、テクスチャ、BGM、CSVファイル、シェーダファイルの読込み/破棄のコードを同様に改修しています。BGM周りはもともとlibGDXのバグ対応で複雑な処理をしていたため、対応に手間取りました。また、テクスチャも、端末メモリを圧迫しないようにオンデマンドロードを実装していたのですが、その部分の改修に手を焼かされました。

APKのテスト

コードの改修も完了したら、改修版のAPKをテストして、問題が発生しないことを確認します。APKのテスト方法は、以下のリファレンス通りに実施すれば問題ありません。
developer.android.com

おわりに

今回の方法で、無事にAABをリリースすることができるようになりましたが、もしかするとパフォーマンスが悪化しているかもしれません。(シミュレータや手元の端末では特に問題なさそうでしたが。BGMのロードが少し遅い?)
パフォーマンスチューニングについては、継続して実施していく所存です。

libGDXのios-moeプロジェクトのビルドでcompileJavaタスクに失敗する場合の対処法

かなり短いですが、嵌ったのでメモ。

Gradleのバージョンを6.7.1にしてから、libGDXのmoeIpaBuildや、moeLaunchといった、ios-moeプロジェクトのビルドを含むタスクが失敗するようになってしまいました。

暫定対処策

一度、手動でios-moeプロジェクトのother配下のcompileJavaタスクを実行しましょう。
単体で動かしてから、moeIpaBuildやmoeLaunchを実行すると(なぜか)上手くいくようになります。

なお、Kotlinを利用している場合は、compileJavaを実行する前にmoeIpaBuildやmoeLaunchを実行した時に表示されるエラーが、compileKotlinの実行に失敗したというものになりますが、その場合も手動でcompileJavaを実行することでmoeIpaBuildやmoeLaunchを実行できるようになります。

根本対処策

まだ見つけられていません。フォーラムへの報告も無いようです。
もし、何かご存知の方がいらっしゃったら、教えていただけますと助かります。

Appleの審査と格闘した話(NTP編)

Appleの審査で嵌ったので記事にしてみました。

Wi-Fiでインターネットに接続できない問題?

趣味開発のiOSアプリをAppleの審査にかけたのですが、Wi-Fi接続時にインターネットに接続不可のエラーメッセージが出るという理由で却下の判定を受けました。ここで気になるのが、アプリ起動時のGame Centerとの通信は上手くいっているのにその後の通信の方でエラーメッセージが出ていたことでした。となると、単純にインターネットに接続できないことが問題なのではないという仮説が立ちます。

犯人はNTP通信

エラーメッセージが出現した箇所ではNTP通信を行っていました。Game Centerとの通信は全てHTTPSなのでTCPの通信ですが、NTPはUDPを利用している通信です。UDPを利用している通信は、ICMPもそうですが得てしてDDoS攻撃に利用される可能性があり、NTPも例に漏れません。複数のクライアントから送信元IPアドレスを詐称したNTPリクエストを大量に送信し、NTPサーバからの応答を詐称先のホストに集中させてサービスをダウンさせるNTP Reflection攻撃など、実際に悪用されています。そのため、Apple側の社内APからインターネットに抜けていく途中のFWにてNTP通信が遮断されており、その結果NW接続不可の判定となったのではないかと考えました。そして、修正を試み、無事に審査を通過したため、この読みは当たっていたようです。
(以前の審査では普通に通過していたため、このタイミングでFWのACLが強化されたのではないでしょうか)
(そもそも、商業でNTP通信を必要とする場合は、自社内のサーバ側でNTPサーバと通信し、それをクライアント側に渡している場合がほとんどかと思います。(実際のところは不明ですが)今回は趣味開発かつNTP通信のためだけにサーバを立てたくないので、クライアント側でNTP通信をさせています)

HTTPSで時刻を取得する

なお、対策としてはHTTPSで時刻を取得するようにしました。(HTTPSでの時刻取得に失敗した時のみNTPを利用するようにしています)NICTがNTPで取得した時刻をHTTPS通信で取得できるようなWeb APIを公開しているため、それを利用させていただきました。
www.nict.go.jp

おまけ:libGDXでのNW通信について

libGDXのNW通信ライブラリは非同期かつTCPの場合のクライアントしか存在しません。そのため、UDP通信を行いたい場合は、Apache Commons Net等の外部ライブラリを利用する必要があります。TCP通信についても同期的な通信を行いたい場合は、Apache HttpComponents等の外部ライブラリを利用する必要があります。AndroidのみであればRetrofitやOkHttp3を利用するのが良いですが、Intel MOEを利用している場合にはビルドできないため、諦めてApacheのNWライブラリを利用しましょう……