ゲーム開発の備忘録

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

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での文字列描画を実現してみてください!