ゲーム開発の備忘録

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

DirectWriteで文字列の透過、文字列幅の取得、縁付き文字列の描画を行う

はじめに

今回は、DirectWriteを利用して実現をしたいものの、Web上や書籍で情報が少なく実装に迷った、あるいは時間がかかったものについてまとめました。なお、前回説明したフォントファイルからのフォントデータの読込みは実装済みの前提で話を進めますのでご注意ください。

文字列を透過する

アルファ値を操作して文字列を透過させたいと考える場面は多いはずです。しかし、DirectWriteでは文字列に関連するクラスにはアルファ値を設定するメンバ関数がありません。
一体どこでアルファ値を設定すればよいのかというと、DrawTextW()関数呼出し時に渡すブラシにアルファ値を設定する必要があります。具体的には、ID2D1SolidColorBrush#SetOpacity()関数でアルファ値を設定できます。もちろん、アルファ値を取得する手段も用意されており、ID2D1SolidColorBrush#GetOpacity()関数でアルファ値を取得できます。

ブラシはストックして使い回している場合もあると思いますので、その場合は文字列描画前にブラシにアルファ値を設定し、文字列描画後にブラシのアルファ値を1.0fに戻しておくと良いでしょう。

文字列幅を取得する

描画する文字列の幅を取得し、その幅に応じて文字列描画位置を調整したい場合もあるでしょう。そのような時はテキストレイアウトを使用した文字列描画へと方法を切替える必要があります。テキストレイアウトはIDWriteTextLayoutクラスで表現されます。

IDWriteTextLayputインスタンスを取得するには、IDWriteFactory#CreateTextLayout()関数を利用します。第1引数には描画する文字列を、第2引数には文字列の長さを、第3引数にはIDWriteTextFormatのポインタを、第4引数には最大横幅(画面の横幅と同じでOK)を、第5引数には最大縦幅(画面の縦幅と同じでOK)を、第6引数にはIDWriteTextLayoutインスタンスのポインタのポインタを指定します。
ここで注意が必要なのは、テキストレイアウトを利用した描画は別の関数で実施するということです。文字列を描画する度にテキストレイアウトを生成するのは非効率なので、少々面倒ですが対象文字列を描画し始めるタイミングでテキストレイアウトを生成し、文字列の描画がされなくなるタイミングでテキストレイアウトを解放するようにしましょう。

テキストレイアウトを生成したら、ID2D1DeviceContext#DrawTextLayout()関数で文字列を描画できます。第1引数にはD2D1_POINT_2F型で描画位置を、第2引数にはIDWriteTextLayoutインスタンスのポインタを、第3引数には描画色を示すID2D1SolidColorBrushインスタンスのポインタを渡します。

テキストレイアウトを用いた描画方法が分かったところで、本題の文字列幅の取得に移りましょう。
テキストレイアウトから文字列幅を取得することができます。方法としては、まずIDWritetextLeyout#GetMetrics()関数でDWRITE_TEXT_METRICS構造体のインスタンスを取得します。引数には格納先となるDWRITE_TEXT_METRICSインスタンスのポインタを渡します。DWRITE_TEXT_METRICSインスタンスを取得したら、widthメンバで文字列の幅を、heightメンバで文字列の高さを取得できます。この値を用いてID2D1DeviceContext#DrawTextLayout()関数に渡す座標値を変更することで、文字列幅に合わせた描画位置の変更が実現できます。

以下にテキストレイアウトを用いた文字列描画の関数例のコードを掲載します。

/// <summary>
/// 文字列を描画する
/// </summary>
/// <param name="position">座標</param>
/// <param name="text">描画対象文字列</param>
/// <param name="textLayoutKey">テキストレイアウトに紐付けるキー</param>
/// <param name="solidColorBrushKey">SolidColorBrushに紐付けるキー</param>
/// <param name="alpha">アルファ値</param>
/// <param name="slideRate">文字列描画位置をスライドさせる倍率(1.0fで文字列の幅と同じ分だけ左にスライド)</param>
void D3DX12Wrapper::TextDrawer::drawText(D2D1_POINT_2F position, const std::wstring& text, const std::string& textLayoutKey, const std::string& solidColorBrushKey, const float alpha, const float slideRate) const noexcept {
	const auto textLayout = textLayoutMap.at(textLayoutKey);
	const auto solidColorBrush = solidColorBrushMap.at(solidColorBrushKey);

	if (slideRate > 0.0f) {
		DWRITE_TEXT_METRICS textMetrics;
		textLayout->GetMetrics(&textMetrics);
		position.x -= textMetrics.width * slideRate;
	}

	solidColorBrush->SetOpacity(alpha);
	parent->getD2DDeviceContext()->DrawTextLayout(position, textLayout.Get(), solidColorBrush.Get());
	solidColorBrush->SetOpacity(1.0f);
}

縁付き文字列の描画を行う

これも必要となる場面は非常に多いと思いますが、これまでの文字列描画と比較して手順が複雑です。縁付き文字列はテキストフォーマットやテキストレイアウトによる描画では実現できず、Direct2Dの機能を用いてジオメトリ情報(文字の外形情報)を取得し、それをもとに縁の描画を行わなければなりません。今まで作成してきたコードとはがらりと内容が変わります。しかし、この方法は手順が多く処理コストが高めなので、縁付きでない文字列であれば先ほど解説したテキストレイアウトによる描画を行うのがおすすめです。(文字列幅を取得する必要も無ければ、最も処理コストが低いテキストフォーマットによる描画で良いでしょう)

縁付き文字列を描画するには、まずフォントフェイスを取得する必要があります。フォントフェイスはIDWriteFontFaceクラスで表されます。
IDWriteFontFaceインスタンスを生成するには、IDWriteFactory#CreateFontFace()関数を利用します。第1引数にはTrueTypeフォントOpenTypeフォントかを定数で指定します。例えばTrueTypeフォントの場合はDWRITE_FONT_FACE_TYPE_TRUETYPEを指定します。第2引数にはフォントファイルの数を、第3引数にはフォントファイル配列を指定します。第4引数には0Uを指定して問題ありません。第5引数はイタリック体、ボールド体での描画を想定するかを定数で指定します。特に指定がない場合、DWRITE_FONT_SIMULATIONS_NONEを指定します。第6引数にはIDWriteFontFaceインスタンスのポインタのポインタを指定します。
フォントフェイス生成のタイミングとしては、IDWriteFontFile取得後となるため、フォントコレクションの取得と同時に実施するのが良いでしょう。

以下に、フォントフェイスを取得する関数例のコードを掲載します。

/// <summary>
/// フォントフェイスを登録する
/// </summary>
/// <param name="fontFamilyName">フォントファミリ名</param>
/// <param name="fontFile">フォントファイルオブジェクト</param>
/// <returns></returns>
void D3DX12Wrapper::TextDrawer::registerFontFace(const std::wstring& fontFamilyName, ComPtr<IDWriteFontFile>& fontFile) noexcept {
	std::array<IDWriteFontFile*, 1U> fontFileArray = { fontFile.Get() };
	ComPtr<IDWriteFontFace> fontFace = nullptr;
	if (SUCCEEDED(directWriteFactory->CreateFontFace(DWRITE_FONT_FACE_TYPE_TRUETYPE, 1U, fontFileArray.data(), 0U, DWRITE_FONT_SIMULATIONS_NONE, fontFace.ReleaseAndGetAddressOf()))) [[likely]] {
		fontFaceMap[fontFamilyName] = fontFace;
	}
}

フォントフェイスを取得したら、それを用いて描画対象文字列のジオメトリ情報を取得します。
これまでと比較するとコードが複雑なため、先にコードを掲載し、後から各部分の解説を行います。

/// <summary>
/// 文字列のジオメトリ情報を登録する
/// </summary>
/// <param name="key">ジオメトリ情報に紐付くキー</param>
/// <param name="text">文字列</param>
/// <param name="fontFamilyName">フォントファミリ名</param>
/// <param name="size">文字のサイズ</param>
/// <returns></returns>
void D3DX12Wrapper::TextDrawer::registerTextPathGeometry(const std::string& key, const std::wstring& text, const std::wstring& fontFamilyName, const FLOAT size) noexcept {
	//グリフインデックス列を取得する
	std::vector<UINT> codePoints;
	auto glyphIndices = new UINT16[text.length()];
	ZeroMemory(glyphIndices, sizeof(UINT16) * text.length());
	for (auto character : text) {
		codePoints.emplace_back(character);
	}
	const auto fontFace = fontFaceMap[fontFamilyName];
	fontFace->GetGlyphIndicesW(codePoints.data(), static_cast<UINT32>(codePoints.size()), glyphIndices);

	ComPtr<ID2D1PathGeometry> pathGeometry = nullptr;
	if (FAILED(parent->getD2DFactory()->CreatePathGeometry(pathGeometry.ReleaseAndGetAddressOf()))) [[unlikely]] {
		return;
	}

	ComPtr<ID2D1GeometrySink> geometrySink = nullptr;
	if (FAILED(pathGeometry->Open(geometrySink.ReleaseAndGetAddressOf()))) [[unlikely]] {
		return;
	}

	//アウトライン情報を取得する
	if (FAILED(fontFace->GetGlyphRunOutline((size / 72.0f) * 96.0f, glyphIndices, nullptr, nullptr, static_cast<UINT32>(text.length()), FALSE, FALSE, geometrySink.Get()))) [[unlikely]] {
		return;
	}

	if (FAILED(geometrySink->Close())) [[unlikely]] {
		return;
	}

	codePoints.clear();
	delete[] glyphIndices;
	pathGeometryMap[key] = { pathGeometry, geometrySink };
}

上記の関数は大きく前半、後半に分けることができます。前半ではフォントフェイス内に記録されている各文字のグリフ(形状情報)へアクセスするために必要なグリフインデックス列の取得を行います。後半ではグリフインデックス列からグリフにアクセスし、文字のアウトライン情報をジオメトリとして取得します。

18行目のIDWriteFontFace#GetGlyphIndicesW()関数で、グリフインデックス列を取得できます。第1引数には文字列の各文字のコードポイントの配列を、第2引数には第1引数に渡した配列のサイズを、第3引数にはグリフインデックス列の格納先となるUINT16配列を渡します。18行目までの間で必要な配列群の準備を行っています。第3引数はstd::vectorのdataを渡すとエラーになるので動的配列を渡しています。

21行目のID2D1Factory3#CreatePathGeometry()関数で文字列等の図形の外形情報を示すID2D1PathGeometryクラスのインスタンスを生成しています。そして、26行目ではPathGeometryに溜めておく描画情報を保管するID2D1GeometrySinkクラスのインスタンスを、ID2D1PathGeometry#Open()関数によって生成しています。
ここで気が付くと思われますが、Direct2Dのファクトリが必要になります。DirectXの初期化完了時にファクトリインスタンスを破棄していた場合は、それを保管しておかなければなりません。また、ID2D1Factory3クラスのインスタンスが必要になるので、ID2D1Factoryクラスを使用していた場合は切替える必要があります。生成自体は変わらずD2D1CreateFactory()で実施できます。

31行目のIDWriteFontFace#GetGlyphRunOutline()関数によって、フォントフェイス中に存在するアウトラインに関するグリフ情報を、グリフインデックス列を基にして取得します。第1引数には文字のサイズを指定します。DPI値で渡す必要があるため、外部からはピクセル値でサイズを渡したいという場合は、ここでDPI値に変換する必要があります。第2引数にはグリフインデックス列を渡します。第3引数、第4引数は通常はnullptrの指定で問題ありません。第5引数にはグリフインデックス列の長さを指定します。第6引数は、文字を90度左回転した状態をデフォルトとする場合にTRUEを指定します。第7引数は、RTL文字列を描画する場合にTRUEを指定します。第8引数にはID2D1GeometrySinkインスタンスのポインタを指定します。
31行目でPathGeometryへの描画情報の記録が完了するため、35行目でID2D1GeometrySink#Close()関数を呼び出して記録終了を指示します。

なお、テキストレイアウトと同様、文字列を描画する度にID2D1PathGeometryインスタンス、ID2D1GeometrySinkインスタンスを生成するのは非効率なので、少々面倒ですが対象文字列を描画し始めるタイミングでジオメトリ情報を生成し、文字列の描画がされなくなるタイミングでジオメトリ情報を解放するようにしましょう。

ID2D1PathGeometryインスタンス、ID2D1GeometrySinkインスタンスの生成が完了したら、ようやく縁付き文字列を描画できます。ここも少々複雑なため、先にコードを掲載します。

/// <summary>
/// 縁付きの文字列を描画する
/// </summary>
/// <param name="position">座標</param>
/// <param name="text">描画対象文字列</param>
/// <param name="textPathGeometryKey">テキストのジオメトリ情報に紐付いたキー</param>
/// <param name="solidColorBrushKey">SolidColorBrushに紐付けるキー</param>
/// <param name="solidColorBrushKeyOfEdge">縁に利用するSolidColorBrushに紐付けるキー</param>
/// <param name="strokeWidth">縁の幅(px)</param>
/// <param name="alpha">アルファ値</param>
/// <param name="slideRate">文字列描画位置をスライドさせる倍率(1.0fで文字列の幅と同じ分だけ左にスライド)</param>
void D3DX12Wrapper::TextDrawer::drawTextWithEdge(D2D1_POINT_2F position, const std::wstring& text, const std::string& textPathGeometryKey, const std::string& solidColorBrushKey, const std::string& solidColorBrushKeyOfEdge, const float strokeWidth, const float alpha, const float slideRate) const noexcept {
	const auto textPathGeometry = pathGeometryMap.at(textPathGeometryKey).first;
	const auto solidColorBrush = solidColorBrushMap.at(solidColorBrushKey);
	const auto solidColorBrushOfEdge = solidColorBrushMap.at(solidColorBrushKeyOfEdge);
	const auto d2dDeviceContext = parent->getD2DDeviceContext();
	
	if (slideRate > 0.0f) {
		D2D1_RECT_F rect {};
		textPathGeometry->GetBounds(nullptr, &rect);
		position.x -= (rect.right - rect.left) * slideRate;
	}

	solidColorBrush->SetOpacity(alpha);
	solidColorBrushOfEdge->SetOpacity(alpha);
	d2dDeviceContext->SetTransform(D2D1::Matrix3x2F::Translation(position.x, position.y));
	d2dDeviceContext->DrawGeometry(textPathGeometry.Get(), solidColorBrushOfEdge.Get(), strokeWidth);
	d2dDeviceContext->FillGeometry(textPathGeometry.Get(), solidColorBrush.Get());
	solidColorBrush->SetOpacity(1.0f);
	solidColorBrushOfEdge->SetOpacity(1.0f);
}

文字列の描画を行っているのは、27行目、28行目です。
27行目ではID2D1DeviceContext#DrawGeometry()関数により輪郭線、つまり縁を描画しています。第1引数にはID2D1PathGeometryインスタンスのポインタを、第2引数には縁の色を示すID2D1SolidColorBrushインスタンスのポインタを、第3引数には輪郭線の太さ(px)を渡しています。
28行目ではID2D1DeviceContext#FillGeometry()関数により、文字内部を描画しています。第1引数にはID2D1PathGeometryインスタンスのポインタを、第2引数には文字内部の色を示すID2D1SolidColorBrushインスタンスのポインタを渡します。
文字列の描画位置の設定は26行目にあるように、行列による座標変換によって行っています。

文字列の幅を取得して、それをもとに描画位置を調整したい場合は、まず20行目のようにID2D1PathGeometry#GetBounds()関数を呼んで、文字列を囲む矩形情報を取得します。この矩形の各メンバは原点の位置に文字列を描画した際の実際の座標値を示すため、文字列の幅を取得するには21行目のように右端x座標から左端x座標を減算する必要があります。(左端x座標に負数が入っている場合があるため、右端x座標の値を文字列の横幅として扱ってはいけません)

おわりに

お疲れ様でした!3つのトピックのいずれも、想像よりも複雑な内容だったのではないでしょうか。特に最後の縁付き文字列の描画は、最初はどのように実装したらよいかの見当がつかず大いに苦戦させられました。よく使われる機能のはずなので、もう少し簡単に描画できるようになっていればと思ったのですが…… 本記事が、DirectWriteを利用している方のお役に立てば幸いです。

DirectWrite+Direct2Dでは文字列のジオメトリ情報を操作できるということもあり、処理の自由度は非常に高く、かなり凝った文字列も描画できそうということが分かりました。皆さんも是非、装飾マシマシの文字列の描画等、様々なテーマでチャレンジしてみてはいかがでしょうか。

DirectWriteでフォントファイルからフォントデータを読込む

はじめに

今回は、DirectWriteでttf等のフォントファイルを読みこんで、フォントデータを抽出する方法を説明します。
ゲーム内でDirectWriteで文字列を描画する際、ユーザの環境にその文字列のフォントがインストールされていない場合があります。そのため、datファイルの中にttfファイルを含め、そこからフォントデータを抽出できるようにしなければなりません。

DirectWriteでフォントファイルからフォントデータを読込む手段は登場当初から提供されていたものの、手間のかかる内容となっていました。しかし、Windows 10 Creators Update (プレビュー ビルド 15021 以降) でより簡単にフォントファイルを読込む手段が提供されています。今回は、新しい手段でのフォントファイルの読み込みについて解説します。

FontSetBuilderの準備

はじめに、フォントセットの手動生成に必要となるIDWriteFontSetBuilder1クラスのインスタンスを生成します。このインスタンスを生成するには、dwrite_3.hに含まれるIDWriteFactory5クラスのインスタンスが必要になりますので、IDWriteFactoryを使っていた方はdwrite.hをdwrite_3.hに差し替えて、IDWriteFactoryインスタンスを取得している箇所を、IDWriteFactory5インスタンスを取得するように変更しましょう。
なお、dwrite.libのリンクはそのままで問題なく、また、IDWriteFactory5インスタンスはIDWriteFactoryインスタンスと同様、DWriteCreateFactory()関数で生成できます。

IDWriteFactory5インスタンスが生成できたら、IDWriteFactory5#CreateFontSetBuilder()関数でIDWriteFontSetBuilder1インスタンスを生成します。引数にはIDWriteFontSetBuilder1インスタンスのポインタのポインタを指定します。ここまではデバイスロストの影響を受けないため、ゲーム起動後に一回だけ実施できればよいでしょう。

フォントファイルの読込み

必要な前準備が完了したらフォントファイルを読み込みます。まずはdatファイル内ではなく、フォルダ内にttf等のファイルが存在する場合から説明します。
DirectWriteでは、フォントファイルはIDWriteFontFileクラスのインスタンスとして扱います。

フォルダ内のフォントファイルを読み込むにはIDWriteFactory5#CreateFontFileReference()関数を利用します。第1引数にはフォントファイルのパスを、第3引数にはIDWriteFontFileインスタンスのポインタのポインタを指定します。第2引数は通常はnullptrで問題ありません。

datファイル内のフォントファイルの読込み

datファイル内に存在するフォントファイル等、メモリ上にバイナリデータとして存在するフォントファイルを読み込むには一手間が必要で、フォントファイルを読込む前にInMemoryFontFileLoaderを生成してFontFileLoaderとしてシステムに登録しなければなりません。

まずは、IDWriteInMemoryFontFileLoaderクラスのインスタンスを、IDWriteFactory5#CreateInMemoryFileLoader()関数を用いて生成します。引数にはIDWriteInMemoryFontFileLoaderインスタンスのポインタのポインタを指定します。
次に、IDWriteFactory5#RegisterFontFileLoader()関数を、先程生成したIDWriteInMemoryFontFileLoaderインスタンスのポインタを引数に渡して呼出し、システムが使用するFontFileLoaderとして登録します。
ここまでの作業はデバイスロストの影響を受けないため、ゲーム起動後に一回だけ実施できればよいでしょう。なお、登録したFontFileLoaderはアプリケーションが終了する前に登録解除する必要があります。IDWriteFactory5#UnregisterFontFileLoader()関数で登録解除を行えます。引数には、登録したIDWriteInMemoryFontFileLoaderインスタンスのポインタを指定してください。

IDWriteInMemoryFontFileLoaderインスタンスが準備できたら、IDWriteInMemoryFontFileLoader#CreateInMemoryFontFileReference()関数を利用して、IDWriteFontFileインスタンスを生成します。第1引数にはIDWriteFactory5インスタンスのポインタを、第2引数にはメモリ上のフォントファイルの先頭を指すポインタを、第3引数にはメモリ上のフォントファイルのサイズを、第5引数にはIDWriteFontFileインスタンスのポインタのポインタを指定します。第4引数は通常はnullptrで問題ありません。事前に必要となる、datファイル等からフォントファイルの先頭のアドレスを取得する処理は読者各々で実装しておいてください。

フォントコレクションの生成

IDWriteFontFileインスタンスの準備ができたら、それをもとにフォントコレクションを生成します。
まずは、IDWriteFontSetBuilder1#AddFontFile()関数で、FontSetBuilderにフォントファイルを登録します。引数にはIDWriteFontFileインスタンスのポインタを指定します。
次に、IDWriteFontSetBuilder1#CreateFontSet()関数で、フォントファイルからフォントセットを生成します。引数にはIDWriteFointSetクラスのインスタンスのポインタのポインタを指定します、
最後に、IDWriteFactory5#CreateFontCollectionFromFontSet()関数で、フォントセットからフォントコレクションを生成します。第1引数にはIDWriteFointSetインスタンスのポインタを、第2引数にはIDWriteFontCollection1クラスのインスタンスのポインタのポインタを指定します。

なお、最終的に必要になるのはIDWriteFontCollectionクラスのインスタンスですが、IDWriteFontCollection1からIDWriteFontCollectionへはキャストできますので問題ありません。

フォントコレクションを指定したTextFormat生成

フォントコレクションを生成したら、いよいよTextFormatの生成を行います。
IDWriteFactory#CreateTextFormat()関数の第2引数に、生成したIDWriteFontCollectionインスタンスを指定することでフォントコレクションを用いたTextFormatの生成ができます。この時、第1引数のフォントファミリ名は、フォントコレクションに含まれるフォントのフォントファミリ名を指定してください。
あとは通常の描画時と同様に、そのTextFormatを用いて文字列描画をすることができます。

おわりに

お疲れ様でした!DirectWriteも情報源が少なく、MSDNでも難解な言い回しがされていることもあり、いまいち何ができるのかが分かりにくく感じることが多々ありましたので、同じように感じている方に情報共有をすることができれば嬉しく思います。
今回解説した内容は、DirectX10以上を使用しているPCゲームであればほぼすべての場合に必要になると思いますので、ぜひ導入してみてください!

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のロードが少し遅い?)
パフォーマンスチューニングについては、継続して実施していく所存です。