ゲーム開発の備忘録

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

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