ゲーム開発の備忘録

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

MobiVMでビルド時にIBAgent-iOS failed to launchが発生した際の対処法

はじめに

今回はMobiVMでCreate IPAを実行した際にIBAgent-iOS failed to launchが発生し、ビルドが失敗した際の対処法について説明します。このエラー自体はMobiVMを利用しないiOSアプリ開発でも発生することがあり、エラーメッセージで検索するとXcodeの再インストールやMacの再起動をはじめとした様々な対処法がヒットするかと思います。しかし、MobiVMのCreate IPAで当該エラーが発生した場合は上記の対処法では解決できませんでした。今後も同様のエラーが発生する可能性があるため、備忘のために本記事に対処法を残しておきます。

Console.appでのログの確認

当該エラーが発生した際、MobiVMのビルドログには Please check Console.app for crash reports for "IBAgent-iOS" for further information. というメッセージが出力されています。
ここは、提案通りConsole.appを確認してみましょう。IBAgent-iOSに関するログは、ログレポートCoreSimulator.logから確認できます。私が確認した際は、 Unable to discover any simulator runtimes. というメッセージが出力されていました。
このエラーメッセージの内容から、何らかの要因で当時の最新のiOSランタイムであるiOS17.0.1ランタイムがMacにインストールされていないことが分かりました。通常はXcode起動時にランタイムの不足があれば更新を促してくれるのですが、iOS17.0.1のランタイムはXcodeを起動してもインストールされなかったため、それに気付かずランタイムが不足していたという訳です。

iOSランタイムの最新化

以下のコマンドを実行することで、iOSランタイムを最新化できます。ランタイムを最新化することでCreate IPAが実行可能となります。

xcodebuild -downloadPlatform iOS

MobiVMでビルド時にAssertion Failed: (aliasSectionNum == sectionNum && "alias and its target must be located in the same section")が表示される場合の対処法

はじめに

今回は、MobiVMでビルド時にAssertion Failed: (aliasSectionNum == sectionNum && "alias and its target must be located in the same section")という、アサーションチェックに失敗した旨のエラーメッセージが表示された場合の対処法について共有します。
このエラーはiOS17対応がなされているXcode 15以降を用いてビルドする際に、それ以前のバージョンのXcode向けに作成されたプロジェクトをビルドしようとすると発生するものです。

MobiVMを使用しない場合は、XcodeプロジェクトのBuild SettingsにてOther Linker Flagsに-ld_classicを追加することで対処できます。(-ld64でもよいですが、deprecatedとなっているので-ld_classicのほうを使用しましょう)

では、Xcodeプロジェクトがビルド前に存在しないMobiVMの場合は、どのようにすればOther Linker Flagsに-ld_classicを指定してビルドできるのでしょうか。

MobiVMプラグインの更新

実は、つい最近まではMobiVMではOther Linker Flagsへの設定は行えませんでした。
2023/9/17に、Android Studioで利用可能なMobiVM Pluginのver 2.3.20がリリースされました。このバージョンであればOther Linker Flagsへの設定ができるようになっています。(そのため、MobiVMのフォーク元であるRoboVMでは本件には対処不可能ということになります)

まずは、Android StudioのMobiVM Pluginを最新化しましょう。

robovm.xmlの変更

MobiVM Plugin ver 2.3.20であれば、以下のようにしてrobovm.xmlからOther Linker Flagsの設定が行えます。(ちなみに、設定方法はMobiVMのGitHubリポジトリのPRを漁って見つけました)

<config>
 <!-- その他の設定 -->
 
 <tools>
  <linker>
   <flags>
    <flag>-ld_classic</flag>
   </flags>
  </linker>
 </tools>
</config>

おわりに

今回は直近のXcodeの更新への対応ということでWeb上の情報量が極端に少なく、Android StudioのMobiVM Pluginのリリースノートが一番の情報源でした。今後もiOSXcodeの更新に追従してMobiVMが更新されることを祈るばかりです。

1フレーム中にDirectX12の描画とD3D11On12の描画を任意の順序で実行する

はじめに

本題に入る前に、まず、レイヤ分けについての話をします。ゲームを作っていると、通常の3D描画と、UI表示等の2D描画を任意の順序で実行したい場合が出てきます。例えば、ポストエフェクトが良い例で、UIを含めた上からぼかしやモザイクをまとめて掛けるといったことが、ポーズ画面の実装等の場合で必要になるはずです。このような例も含め、一般的にゲームの画面描画は複数のレイヤに分けて実施することが多いでしょう。2Dゲームであっても、任意のエフェクトを別のあるエフェクトよりも必ず上側に表示されるようにしたいといったケースはあり、そうするとレイヤを明確に分けないと管理が煩雑になります。
レイヤ分け自体はどのDirectXのバージョンでも自前での実装が必要となります。私の場合は、エフェクトオブジェクトを格納するlistを要素とするarrayを用意しています。このarrayの各要素が独立したレイヤと表現でき、各要素のlistごとにまとめてエフェクトを描画していくことでレイヤごとの描画を実現しています。以下にサンプルコードを示します。

for (auto& effects : effectLists) {
  for (auto& effect : effects) {
	  effect->draw();
  }
}

さて、ここからが本題です。DirectX11の時点で基本的な2D描画はDirect3D11側で実施することになるので、例え2Dゲームを作る場合であっても2Dと3Dが混在したレイヤ分けが必要になります。DirectX11であれば、これはそこまで難しくありません。Direct3D11、Direct2D、DirectWriteのすべてでレンダリングターゲットを同一のテクスチャにしたうえで通常通りレイヤごとの描画を行い、その後ピクセルシェーダで効果を加えたうえでそのテクスチャをバックバッファに出力すればよいのです。レンダリングターゲットの設定が一回で済み、Direct3D11、Direct2D、DirectWriteの描画は順不同でまとめて実施できるということがポイントです。

これがDirectX12とD3D11On12となると途端に難題になります。なぜなら、Direct3Ð12とD3D11On12は同時に描画できないためです。D3D11On12が内部実装で利用しているCommandListは外部から取得できず非D3D11On12のコンテキストからはCommandを投入できないため、純粋なDirect3D12で利用するCommandListとは独立して扱うことになります。つまり、2D描画用のCommandList、3D描画用のCommandListが独立して存在することになります。そして、1フレーム中で任意の回数、順不同で2D描画、3D描画を実施するにあたっては、それぞれの描画で利用するCommandListをはじめとしたリソースの初期化、フラッシュ等をどのタイミングで実施するべきかという頭の痛い問題がついて回ります。当然のようにWebサイトや書籍や公式のリファレンス実装等に解説がなく、トライ&エラーの繰り返しで何とか解消する羽目になったので、今回はこの問題への対応策について解説します。

描画ループの全体構成

まずは1フレーム中の描画ループをどのような構成にしたらよいかについて示します。描画ループは大きく分けて以下の6つの部位に分けることができます。

① 全体初期化処理
② 3D描画開始
③ 3D描画終了
④ 2D描画開始
⑤ 2D描画終了
⑥ 全体終了処理

このうち、②、③および④、⑤は必ず連続して実行されることになります。それぞれひとまとめにすると以下のようになります。

① 全体初期化処理
② 3D描画
③ 2D描画
④ 全体終了処理

上記の4つの処理のうち、②、③は順不同で複数回繰り返す必要がありますのでループで実装します。具体例を以下で示します。

/// <summary>
/// エフェクトを描画する
/// </summary>
/// <returns></returns>
void EffectManager::draw() const noexcept {
  for (auto& effects : effectLists) {
    graphicsCore->beginDrawWithD3D();
    for (auto& effect : effects) {
      if (effect->getDimension() == EffectBase::Dimension::D3D) {
        effect->draw();
      }
    }
    graphicsCore->endDrawWithD3D();

    graphicsCore->beginDrawWithD2D();
    for (auto& effect : effects) {
      if (effect->getDimension() == EffectBase::Dimension::D2D) {
        effect->draw();
      }
    }
    graphicsCore->endDrawWithD2D();
  }
}

このEffectManager::draw()を呼ぶ前に全体初期化処理を行っており、EffectManager::draw()を呼んだ後に全体終了処理を行っています。また、3D描画開始、3D描画終了、2D描画開始、2D描画終了の各処理をまとめたメソッドを作成して呼んでいるのが分かると思います。そして、各エフェクトに2Dで描画すべきか3Dで描画すべきかを判定させるためのメンバdimensionを持たせているシンプルな設計です。これでレイヤ毎に3D描画、2D描画を実施し、例えば、最上位のレイヤでポストエフェクトを描画することを3D描画側で実行できるわけです。

なお、実際は上記にもう一工夫必要です。CommandListの切替えはオーバーヘッドが高くつくため、3D側のエフェクトが存在しない場合は3D描画開始、3D描画終了を通過させない等といった追加実装が必要です。

さて、後は、最初に挙げた6つの処理のそれぞれで何を実施するべきかという解説をしましょう。

全体初期化処理

Direct3D12のCommandListを用いて以下を順に実施します。

・レンダーターゲットの設定
・バックバッファのリソースバリア設定(PRESENT⇒RENDER_TARGET)
・レンダーターゲットビュー初期化
・デプスステンシルビュー初期化

2D描画実行時にはここで利用しているCommandListをそのまま利用し続けられないため、直後に2D描画が来ても問題ないように画面の初期化結果はこの時点でバックバッファにフラッシュさせる必要があります。
そのため、全体初期化処理の最後にCommandListを実行しますが、GPUの待ち合わせは不要です。

3D描画開始

Direct3D12のCommandListを用いて以下を実施します。もちろん最後はCommandListは実行せず、各エフェクトの描画命令を積み上げられるようにしておきます。

・レンダーターゲットの設定
・ビューポートの設定
・シザー矩形の設定
ディスクリプタヒープの設定

3D描画終了

CommandListのクローズおよび実行のみ実施します。直後にGPUの待ち合わせが必要です。

2D描画開始

ここが本記事のメインコンテンツといっても良いでしょう。2D描画開始では以下を実施します。コードのほうがわかりやすいのでコードを掲載しましょう。

d3d11On12Device->AcquireWrappedResources(wrappedBackBuffer.GetAddressOf(), 1);
d2dDeviceContext->SetTarget(backBufferFor2D.Get());

WrappedResourceを掴んだままDirect3D12側の描画をしようとするとエラーが発生するため、AcquireWrappedResources()およびSetTarget()は全体初期化時ではなく、2D描画開始時に実施しなければならないことに注意して下さい。このエラーが、エラーメッセージの内容と原因が乖離しており、エラーメッセージを基に修正するのは困難で、トライ&エラーで何とかこの結論に達しました。
なお、今回の構成では2D描画開始時にバックバッファの状態がRENDER_TARGETで入ってくることになり、2D描画が完了してもすぐ次の描画に移るためRENDER_TARGETのまま抜ける必要があります。そのため、D3d11On12Device#CreateWrappedResource()の第3引数、第4引数はともにD3D12_RESOURCE_STATE_RENDER_TARGETを指定して作成する必要があります。忘れずに変更しておきましょう。

2D描画終了

以下を実施します。ここもコードを掲載します。

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

直後にDirect3D12側でGPUの待ち合わせを実施しなくても問題なかったため、おそらくFlush()内部でバックバッファへの出力とGPUの待ち合わせの両方を実施していると思われます。

全体終了処理

Direct3D12側のCommandListを用いてバックバッファのリソースバリアの設定(RENDER_TARGET⇒PRESENT)のみを行い、CommandListを実行します。リソースバリアの設定しかしていないので、GPUの待ち合わせはもちろん不要です。その後、バックバッファのPresentを行います。
このためだけにCommandListの準備を行うのはもったいなく感じますが、このタイミングで実施する以外に方法はないと思われます。もしより良い方法があれば教えてください!

おわりに

今回はとにかく設計を考えるのが大変だったのと、エラーメッセージが役に立たないGPUエラーに苦しめられました。D3D11On12の内部実装を推測しながら対処を打つ必要があり難しかったのですが、理解が深まり良い経験になったとは思います。(誤った推測の上での理解をしていなければよいのですが……。ご指摘があればお待ちしております)

MobiVMでビルドしたアプリがiCloud Driveにアクセスできるようにする

はじめに

今回は、MobiVMでビルドしたアプリが、GKSavedGame等の機能の利用時にiCloud Driveにアクセスできるようにする方法を共有します。
MobiVMについては以下の前回記事をご確認ください。
deep-verdure.hatenablog.com

MobiVMでビルドしたアプリが、本記事の対応をせずにiCloud Driveにアクセスしようとすると、以下のようなエラーメッセージが出力されます。

Error Domain=GKErrorDomain Code=27 "The requested operation could not be completed because you are not signed in to iCloud or have not enabled iCloud Drive" UserInfo={NSLocalizedDescription=The requested operation could not be completed because you are not signed in to iCloud or have not enabled iCloud Drive}

Entitlements.plist.xmlの追加

MobiVMではRoboVMと同様に、Xcodeプロジェクトファイルが存在しません。そのため、iCloud Driveの利用に必要な資格設定をXcode上で実施できません。

ではどうすればよいのかというと、通常はXcodeが自動出力する、資格情報を記述したファイルであるEntitlements.plist.xmlを手動作成し、MoviVMプロジェクト直下(robovm.xmlと同じ場所)に格納します。

そして、robovm.xmlに以下の一行を追加し、ipaビルド時に自作したEntitlements.plist.xmlが参照されるようにします。

<iosEntitlementsPList>Entitlements.plist.xml</iosEntitlementsPList>

Entitlements.plist.xmlの内容も掲載します。「iCloud Containers IDを指定」となっている箇所には、払出済みのiCloud Containers IDを指定してください。
設定内容の詳細は、以下の公式リファレンスをご確認ください。
https://developer.apple.com/documentation/bundleresources/entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.developer.icloud-container-environment</key>
    <array>
      <string>Production</string>
    </array>
    <key>com.apple.developer.icloud-container-identifiers</key>
    <array>
      <string><!-- iCloud Containers IDを指定 --></string
    </array>
    <key>com.apple.developer.icloud-services</key>
    <array>
      <string>CloudDocuments</string>
    </array>
    <key>com.apple.developer.ubiquity-container-identifiers</key>
    <array>
      <string><!-- iCloud Containers IDを指定 --></string>
    </array>
  </dict>
</plist>

シミュレータ利用時の注意点

シミュレータでMobiVMプロジェクトの動作確認を実施する場合、自作したEntitlements.plists.xmlを読み込むことができず、設定が空のEntitlements.plists.xmlが既定のものとして一時的に作成、適用される仕様となっています。(MobiVMが残すビルドログを細かく確認すると気付けます……)
そのため、自作したEntitlements.plist.xmlは実機でしか動作確認できないことに注意して下さい。

私はこれに気付かずに頭を抱え、かなりの時間を無駄にしてしまいました……

libGDXでMobiVMを用いてiOS版アプリを開発する

はじめに

2018年11月に、RoboVMが開発終了し、iOS12が非対応となったため、libGDXのiOSバックエンドをIntel-MOEに変更しなければならなくなった旨について記事を書きました。
deep-verdure.hatenablog.com

Intel-MOEも大元のIntel側での開発が終了し、有志によるコミュニティ版のアップデートが続けられていましたが、そんな中、2020年10月31日のlibGDX ver 1.9.12で衝撃的な変更が加えられます。

・As announced in Status Report #1, the iOS MOE backend was removed in favour of the RoboVM one.

libGDXはIntel-MOEを非対応とし、RoboVMを優先するとのコメントがリリースノートに記載されています。

変更コストを鑑みて、Intel-MOEでのビルド時のみver 1.9.11のlibGDXコンポーネントを用いるような構成とし、今日まで凌いでいましたが、2022年10月中旬頃のXcodeの更新後、moe-binding内のコードがエラーを出力するようになり、とうとうビルドができなくなってしまいました。libGDXの声明通り、RoboVMに戻る時が来たのです。しかし、RoboVMは開発終了したはず……。一体どうすればよいのでしょうか?

実は、RoboVMからフォークされ、最新のiOSに対応したオープンソースのクロスコンパイラが登場していました。それがMobiVMです。(一部でZombie RoboVMと言われていたり……)
mobivm.github.io

libGDX ver 1.9.12以降で記述されているRoboVMは、実際にはこのMobiVMを指しています。
(実は、libGDXのプロダクトページと、MobiVMのプロダクトページはUIがそっくりです。libGDXがIntel-MOEを切り捨て、MobiVMを優先した理由が読み取れる気がします)

本記事では、MobiVMを用いたiOSアプリ開発と、それに付随するlibGDX ver 1.11.0に完全対応したDesktop版の開発における注意点を説明します。

MobiVMを用いたiOS版アプリの開発

MobiVMを採用するとなると、libGDXの最新バージョンを全面的に使用可能になります。ios-moeプロジェクトが存在していた頃のlibGDXと比較すると、トップレベルから各プロジェクトに至るまで、build.gradleを始めとした様々な資材の内容が大幅に変更されています。
そこでおすすめしたいのが、最新のgdx-setup.jarを用いてプロジェクトを作成し、そこに今まで利用していたandroidios、desktop、core内のソースファイルを持ってくることです。思い切って綺麗にしてしまいましょう。

MobiVMを利用したエミュレータによるアプリ実行およびIPAの生成は、かつてのRoboVMを用いた方法と変わりありません。(Intel-MOEを使用していた場合、思い出すのに苦労するかもしれませんが……)
一点注意するとすれば、MobiVMプラグインを導入したAndroid Studio for MacでRun/Debugの構成設定編集をする際に、「RoboVM iOS」を押下してから設定項目欄が表示されるまでの間に結構な時間がかかることです。この間にAndroid Studio for Macの別のところをクリックしたりしてしまうとフリーズします。
私は当初これに気付かず、MobiVMプラグインに問題があるのではと疑って無駄な調査時間を過ごしてしまいました……。「RoboVM iOS」を押下したらじっと待ちましょう。

libGDX ver 1.11.0自動生成プロジェクトにおけるDesktop版アプリのエントリポイントについて

libGDX ver 1.11.0において、Desktop版アプリのエントリポイントをKotlinで記述する場合は注意が必要です。
以前はmain関数のみ記述したファイルをAndroid Studio for Macの構成設定編集で指定して実行が可能でしたが、ver 1.11.0の自動生成プロジェクトにおいてはその方法が使用できなくなりました。
Gradleスクリプトからのエントリポイント起動のみ受け付けるため、Javaで記述されたエントリポイントと正しく同じものをKotlinで記述しなければなりません。以下を参考に記述してください。

object DesktopLauncher {
    @JvmStatic
    fun main(args: Array<String>) {
        val gameMainObject = GameMain() //Gameを継承した独自クラス
        val config = Lwjgl3ApplicationConfiguration()
        config.setTitle("title")
        config.setWindowIcon("img/desktop/icon.png")
        Lwjgl3Application(gameMainObject, config)
    }
}

なお、libGDX ver 1.11.0からは、LwjglApplicationではなくLwjgl3Applicationクラスを利用するようになっています。これはM1 Mac対応のために拡張されたLwjglApplicationクラスです。

ちなみに、LwjglApplicationクラスでは、widthフィールドとheightフィールドの値を設定することでウィンドウサイズを設定できていましたが、Lwjgl3Applicationクラスではその方法は使えなくなっています。
coreプロジェクト側でGdx.graphics.setWindowedMode()関数を呼び出して、ウィンドウサイズを設定しましょう。

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ゲームであればほぼすべての場合に必要になると思いますので、ぜひ導入してみてください!