KLabGames Tech Blog

KLabは、多くのスマートフォン向けゲームを開発・提供しています。 スマートフォン向けゲームの開発と運用は、Webソーシャルゲームと比べて格段に複雑で、またコンシューマゲーム開発とも異なったノウハウが求められる領域もあります。 このブログでは、KLabのゲーム開発・運用の中で培われた様々な技術や挑戦とそのノウハウについて、広く紹介していきます。

今回は、以前このブログで紹介した KMSについて、そのサーバサイドの構成を紹介したいと思います。

KMS の基本的なところ

汎用性

KMS は、チャットシステムを手軽に実現するための、SaaS型サービスとして企画されました。 つまり、1つの KMS のシステムを複数のゲームアプリが利用することが前提にありました。 そのため KMS は汎用性を常に意識して設計しています。

汎用的なシステムとして設計したため、KMS ではチャットに関するもの以外のデータ、例えばゲーム内のギルド情報などのデータは扱いません。 一方で NGワードなどのチャット機能に必要なデータは、利用ゲームごとに個別に保持し、必要に応じてゲームサーバから更新できるようにしています。

チャンネルを軸としたシステム

KMS はメッセージをやりとりする場であるチャンネルを軸としたチャットシステムです。 ユーザは特定のチャンネルに所属することで、そのチャンネルに対して発言することができるようになり、また同じチャンネルに所属する他のユーザが発したメッセージを受け取ることができます。 ユーザが発言したメッセージは、リアルタイムに他のユーザに転送されます。

KMS のチャンネル構成は、KMSを利用するゲームアプリ次第です。 例えば、自由に参加できるワールドチャットや、ギルドやチームに参加するユーザに限定したチャット、また 1 対 1 の個人チャットなどの用途が考えられます。

ユーザ認証

KMS はゲーム内で使うためのチャットシステムですので、クローズドなチャットです。 つまり、クライアントが KMS のサーバに接続する際は、ユーザ認証を実施する必要があります。 ユーザ認証に必要なデータは KMS 自体では生成せずに、ゲームサーバ側から受け取る形にしています。

KMS サーバの構成

KMS のサーバサイドの主要部分は、次の4種類のサーバで構成されています。

KMSの構成

UA サーバ

UA とは User Agent の略で、 クライアントアプリが接続する先のサーバです。 ユーザの発言をリアルタイムに他のユーザへ配信するために、クライアントアプリとサーバとの間の通信プロトコルとしては WebSocket を使っています。

構成としては、フロントエンドに nginx を置き、バックエンドには tornado を使っています(※)。 また tornado のスーパーバイザとして Circusを使っています。 nginx と Circus = tornado の間は Unix Domain Socket で接続しています。

※ フロントエンドサーバ、バックエンドサーバ
KLab では Web アプリケーションサーバを立てる際、フロントエンドとバックエンドの 2種類のサーバを動かします。 クライアントからの接続はフロントエンドのサーバが受け付け、バックエンドのサーバに proxy する構成です。

バックエンドのサーバは、Web アプリケーションの本体を動作させる環境になります。

フロントエンドのサーバの役割はいくつかあります。 例えば

  • アクセスログの標準化
    • tornado や uwsgi など、異なるバックエンドを併用する場合でも、アクセスログの形式を揃えられる
  • バックエンド処理の、タイムアウト管理
    • バックエンドの処理に時間がかかりすぎている場合に、フロントエンドがそのリクエストを強制的にエラーにすることで、接続リソースを開放する
  • クライアントリクエストのパーキング
    • 大量アクセス時に、全てのリクエストを並列に処理するとスラッシングなどの状態に陥るので、バックエンドの並列数は絞りつつ、クライアント接続をパーキングすることで混雑時のユーザエクスペリエンスの低下を緩和する

などがあります。

フロントエンドとバックエンドのサーバは、前記のようにその間を Unix Domain Socket で接続するので、同一のマシン上で動作させています。

API サーバ

ゲームサーバからのリクエストを受け付けるサーバです。 こちらはプロトコルとして HTTPS を使っています。 ユーザ認証用のデータなど、クライアントアプリが KMS を利用する上で必要なデータの追加更新削除の操作に加えて、ゲーム運用者がチャット上のデータを参照したりするためのリクエストも、この APIサーバが担当します。

構成としては、フロントエンドに nginx を置き、バックエンドには uwsgi を使っています。 こちらも、nginx と uwsgi の間は Unix Domain Socket で接続しています。

IRC サーバ

UA サーバ間及び、UAサーバと API サーバの間でのメッセージ配信用基盤として利用しています。

UA サーバも API サーバも、当然ながら複数台のサーバマシンを用意しています。 なので、 例えばサーバ S1 に接続している U1 ユーザが C チャンネルに対して発言した内容を、同じ C チャンネルに所属している S2 サーバ上の U2 ユーザに配信するためには、 S1 サーバと S2 サーバの間でメッセージを転送する必要があります。 そのための基盤として KMS では IRC サーバをチューニングした上で利用しています。

また、ユーザの発言を UA サーバ間で配送するためだけでなく、ゲームサーバがAPIサーバを通じてチャットへ送ってくるシステムメッセージの配信や、チャンネルへのユーザの入退室情報の配信などにも、IRCサーバを利用しています。

DB サーバ

ユーザ認証情報や、 チャンネルに対して発言されたメッセージのログや、ユーザが所属するチャンネル情報などを保管しています。 MySQL を利用しています。

KMS サーバアプリのバージョン管理

KMS は、複数のゲームアプリが利用するシステムです。 当然ながら KMS とゲームアプリの開発および運用は独立しています。 ですので KMS がバージョンアップしたからと言って、それに合わせてゲームアプリのバージョンアップを強制することはできません。

そのために KMS では UA サーバ・API サーバ共に、クライアントがサーバに接続する際に利用したい KMS バージョンを選択できるようにしています。 この仕組みにより KMS では下位互換性の無い改修を新規バージョンに施すことを容易にしました。 極端な話をすれば、利用するゲームアプリ専用バージョンの KMS を用意して同時に運用することも、可能になっています。

バージョン選択の仕組み

クライアント側からの、KMS サーバのバージョン選択は、次のような形で実現しています。

バージョン選択の仕組み(UAの場合)

まず、tornado(UAサーバ)と uwsgi(APIサーバ)上では、KMS を利用する全ゲームアプリが利用する予定のバージョンのサーバアプリを全て動かしています。 これらのサーバアプリと(フロントエンドの) nginx の間は Unix Domain Socket で接続しますが、この待ち受け用の Unix Domain Socket をサーバアプリのバージョンごとに用意しておきます。 つまり、バージョン1.1 とバージョン1.2 のサーバアプリが平行稼働していた場合、バージョン1.1用の待ち受けソケットと 1.2用の待ち受けソケットの2つの待ち受けソケットができます(※)。

※待ち受けソケット

Unix Domain Socket なので、待ち受けソケットとは、具体的にはファイルシステム上に配した socket ファイルのことです。

クライアントからの KMS サーバへの接続は、最初にフロントエンドの nginx が受け付けます。 その際にクライアントは利用したい KMS バージョンを、URL の中に埋め込みます。 nginx はその URL を見て、クライアントからの接続をどのバージョンのサーバアプリにプロキシーするかを判断し、適切な待ち受け用の Unix Domain Socket へ接続を回します。

この仕組みにより、KMS を利用するゲームアプリは、任意のタイミングで、利用する KMS のバージョンを変更することができます。 また KMS の運営側としては、利用するゲームアプリに気兼ねすること無く、新しいバージョンのサーバアプリをデプロイすることができます。

KMS の裏側の裏話

ここまでで説明した構成は、実装前の設計段階で固まっていて、大きな変更はありませんでした。 しかしながら、細かいところでは当然ながら初期の設計どおりではまずい箇所がいくつか出てきました。 また、負荷テストを実施してみると思わぬところがボトルネックになっていることが分かりました。

IRC サーバと UA の関係

最初期の構想では、UA の役割はクライアントと IRC ネットワークの間の橋渡し役として、ユーザ認証と NG ワードチェック、そして参加チャンネルの管理程度に留めたいと考えていました。 そのため、クライアント = UA 間の接続に 1 対 1 対応する UA = IRC 間の接続を作り、接続クライアントの数だけ IRC ネットワーク上にユーザを登録する構成を考えていました。

この方式では、UA を動かしている tornado が管理する接続の数は、1クライアントにつき2つの接続になります。 当然ながら tornado が管理する接続の数が増えるとそれだけリソースを消費するため、さばけるクライアントの上限が低くなってしまいます。 性能テストをしてみたところ、このクライアント接続に 1 対 1 対応する IRC 接続を作るという設計では、目標とする性能を実現できませんでした。

この問題に対処するために、当初の設計を変更して、UA と IRC の間の接続は 1つで済ませることにしました。 これに伴って、当初の設計では IRC の機能に依存していたクライアント間のメッセージの適切な配送機能を、UA 上にも実装する必要に迫られることになりました。

UA のチューニング

KMS では、クライアントと UA の間の接続は WebSocket を使った常時接続です。 前述したように、同時に取り扱う接続の数が多ければそれだけ tornado のリソースを消費することになります。 また UA の応答性はクライアントのユーザビリティに直結します。 そのため、UA チューニングには特に時間を費やしました。

その中で実施した施策の1つに、PyPy の導入があります。 PyPy は python インタプリタ実装の 1つで、 JIT を実装しているため標準のインタプリタである CPython よりも実行速度が期待できます。 PyPy の導入によりパフォーマンス向上の効果は一定ありましたが、それでもまだ目標とするパフォーマンスには到達しませんでした。

そのため、ひたすら cProfile プロファイリングを取得して、ボトルネックを解析し、対策を施しました。 その際に活躍したのが、 KCachegrind です。 KCachegrind を使った解析により、主なボトルネックとしては JSON の解析部分と tornado のマルチタスク処理部分であることが分かりました。

JSON

KMS はクライアントと UA の間や、(IRC を通じた) UA 同士、UA と API の間などの通信の全てにおいて JSON を使っています。 そのため JSON 処理の高速化が肝になりました。

一般的には JSON エンコードには json.dumps() を使うかと思いますが、その実装の実体は json.encoder.JSONEncoder クラスの encode() メソッドになります。 json パッケージでは json.dumps() 呼び出し時の JSONEncoder クラスの初期化コストを抑えるために、パッケージの初期化時にこのオブジェクトをキャッシュしますが、このオブジェクトキャッシュが使われるのは json.dumps() のデフォルト引数群に、デフォルト値以外の値が渡されない場合だけです。 KMS では separators オプションを指定したかったので、 独自に json.encoder.JSONEncoder クラスのオブジェクトをキャッシュしておくようにしました。

また、PyPy の JSON エンコードライブラリでは呼び出しオプションの与え方によって、その実装の一部分で C言語実装のコードが使われる場合と Python 実装が使われる場合があります(※)。 C言語実装が使われるか否かは、json.dumps() の引数 ensure_asciiTrue (default) か False かで変わります。 True であれば C言語実装が使われ、False であれば Python 実装が使われます。 当然ながら Python 実装版よりも、C言語版の方がパフォーマンスが高いので、コードを見直して必ず C言語版の実装が使われるようにしました。

※ JSON encoder の実装切替ポイント

前述の通り json.dumps() の実装の実体は json.encoder.JSONEncoder クラスにあります。 このクラスのコンストラクタ中で、 ensure_ascii 引数の値によって利用する実装を切り替えている部分(160行目)があります。

        if ensure_ascii:
            self.__encoder = raw_encode_basestring_ascii
        else:
            self.__encoder = raw_encode_basestring

この raw_encode_basestring_asciiraw_encode_basestring の実装の実体はそれぞれ同じファイルの49行目40行目にあるのですが、 raw_encode_basestring_ascii だけは更に536行目付近の

try:
    from _pypyjson import raw_encode_basestring_ascii
except ImportError:
    pass

このコードで、C言語実装をロードしています。

マルチタスク処理

Python はインタプリタの実装上、ネイティブ thread による並列実行のパフォーマンスがよくありません。 そのため tornado はノンプリエンプティブ型のマルチタスク処理システムを独自実装して、並列処理を実現しています。 このマルチタスク処理システムでは、ユーザコードから tornado への実行権限の譲り渡すタイミングを、ユーザコード上で指定する形になっています。 プロファイリングを取得してみると、このマルチタスク処理の部分がそれなりに重いことが分かりました。 その対策として、実行権限の譲渡のポイントを精査し、不要な実行権限の移譲が発生しないように対策しました。

最後に

KMS は SaaS として、1つのシステムで複数のゲームアプリ向けにチャットシステムを提供するために開発しました。 SaaS 型のシステムにした理由は、リアルタイムにチャットメッセージを配信するためのサーバシステムは、どうしても一般的なゲームのサーバシステムとは異なる部分が大きいため、利用案件の数だけチャットサーバシステムを立てていたのでは、運用が回らなくなると考えたからでした。

汎用的なシステムにしたために少し複雑になっている部分もありますが、KMS を利用するゲームアプリの自由度を損なわずに、それでいて運用性の高いシステムに仕上がりました。 この KMS の汎用的かつ柔軟な構造は、将来あるであろう、何か新しいことを KMS の上で実現したい、というリクエストに十分応えてくれることでしょう。

KG SDKに関するお問い合わせ

KLabでは、開発パートナー様と共同開発したゲームをKLabにてパブリッシング、プロモーションを行うというモデルを積極的に進めており、開発パートナー様にKG SDKの提供もしています。
パブリッシング事業につきましてはこちら
KG SDKに関するお問い合わせにつきましてはこちら
をご覧ください。

こんにちは、makki_dです。 今回はKLabでも利用しているSQLCipherについて、自前ビルドすることでapkファイル内のライブラリサイズを50%(当社比)削減する方法を紹介したいと思います。

暗号化機能付きSQLite、SQLCipher

SQLiteはファイルベースの軽量なRDBで、クライアント(iPhone/Android)内にステージや敵の基本情報(マスタデータ)等を保持するのにとても便利です。 ですが、SQLiteのデータファイルを平文のまま保持していると、改ざんされたり非公開のデータが覗き見られたりしてしまうので、暗号化しておきたいものです。 そんな時には、SQLiteに暗号化機能を加えたSQLCipher(※)がとても手軽で、KLabでも活用しています。

この記事では、Unity製のAndroid向けアプリで、SQLCipherを依存ファイルを少なくシンプルに使うための方法を紹介します。

※ここで扱うSQLCipherは、オープンソースなCommunity Editionを利用しています。

SQLCipherの普通の使い方 (Android)

SQLCipherのサイトにはAndroidへの導入手順のページもありますし、 Community Editionのバイナリパッケージも用意されています。 Unity5以降であれば、aarファイルをAssets/Plugins/Android以下に入れることでライブラリ自体は簡単に導入できます。

しかしこのライブラリはJavaから利用することを前提とするものですので、Javaのクラスに依存していますし、Unityから使う時もJavaの世界を経由して呼び出すことになってしまいます。

さらに、3.4.0まではICU (International Compornents for Unicode) という文字コード変換ライブラリが組み込まれているため、 icudt46.zipという2.2MBのファイルがもれなく含まれていました。 GooglePlayのAPKファイルサイズ制限的にも苦しいので、依存をなくしてしまいたいところです。

ネイティブ共有ライブラリとして使う方法

さて、ここでUnityでSQLiteを使う時を思い出したいと思います。

C#には、DllImport属性を使ってネイティブ共有ライブラリの関数を直接呼び出すことができます。 (MSDN, Mono)

Unityでもこの機能を使って、Plugins以下に配置したlibsqlite3.soの関数を呼びだすことでSQLiteが使えます。 SQLCipherもネイティブ共有ライブラリとしてビルドすれば、同様にDllImportできるはずです。

ネイティブ共有ライブラリのビルド方法

実際にARMv7向けのネイティブ共有ライブラリをビルドしてみたいと思います。 ビルド環境は Ubuntu 14.04、Android NDK r12b を想定してます。

前準備として次のように環境変数を設定します。 コマンド中のパスやAPI Levelは適宜読み替えてください。

export ANDROID_NDK_ROOT=$HOME/Android/android-ndk-r12b
export SYSROOT=$ANDROID_NDK_ROOT/platforms/android-14/arch-arm
export TOOLCHAIN=$ANDROID_NDK_ROOT/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64/bin
export PATH=$TOOLCHAIN:$PATH

SQLiteの場合

ここでまたSQLiteを思い出したいと思います。 SQLiteのビルドは、amalgamationされた単一のcソース(sqlite3.c)からコンパイルするのが簡単です。 SQLiteのDownloadsページよりソースを入手した後、 Android NDKを用いて次のようにビルドします。

unzip sqlite-amalgamation-3140100.zip
cd sqlite-amalgamation-3140100
arm-linux-androideabi-gcc --sysroot=$SYSROOT -march=armv7-a -shared sqlite3.c -o sqlite3.so

sqlite3.cは公式サイトからも入手できますが、生のソースコードツリーから生成することもできます。 configureしたあと、make sqlite3.c とするだけです。

unzip sqlite-src-3140100.zip
cd sqlite-src-3140100
./configure
make sqlite3.c

SQLCipherの場合

さて、SQLCipherではどうでしょう。 configureオプションで頑張ってクロスコンパイルすることもできるかもしれませんが、 SQLiteと同じようにamalgamationされたsqlite3.cからコンパイルするのが簡単です。

SQLCipherではsqlite3.c単体では提供されていないので、自分で生成します。 ソースをcloneしてきたら、configureしてmake sqlite3.cです。sqliteと同じですね。

git clone https://github.com/sqlcipher/sqlcipher.git
cd sqlcipher
./configure
make sqlite3.c

続いてNDKでビルドするのですが、その前にOpenSSLも用意する必要があります。 SQLCipherは暗号化にOpenSSLのlibcryptoを利用しているのですが、Androidが持つlibcrypto.soは使えませんので静的リンクします。 (Android6以降のlibcrypto.soはOpenSSLではなくBoringSSLのもので、ドキュメントにもリンクしてはならないと書かれてます。)

同じNDKでlibcrypto.aをビルドします。 (実際には使っていない機能を組み込まないために、このあたりを参考にConfigureオプションを設定します)

git clone -b openssl_1_0_2-stable https://github.com/openssl/openssl.git
cd openssl
./Configure dist
./Configure --cross-compile-prefix=$TOOLCHAIN/arm-linux-androideabi- --sysroot=$SYSROOT android-armv7
make build_libcrypto 

libcrypto.aがビルドされました。いよいよ生成したsqlite3.cからsqlcipherをビルドします。

cd sqlcipher
arm-linux-androideabi-gcc --sysroot=$SYSROOT -march=armv7-a -shared -DSQLITE_HAS_CODEC -I../openssl/include sqlite3.c -o libsqlcipher.so ../openssl/libcrypto.a

できあがったlibsqlcipher.soの使い方

こうしてAndroid用のlibsqlcipher.soを作ることが出来ました。 これは普通のネイティブ共有ライブラリなので、DllImportで直接呼び出すことができます。 Javaやicudt46.zipへの依存もありません。 既存のSQLite向けの実装のDllImportだけ書き換えれば使えるはずです。

またaarファイルでSQLCipherを導入した場合、Javaとのブリッジのためのコードの他、ARMv5向けのバイナリもapkファイルに含まれてしまいます。 Unity本体がARMv5をサポートしていないので、ARMv7とx86のネイティブ共有ライブラリのみに絞ることでapkファイルのサイズを節約できます。 空プロジェクトにライブラリのみ追加して試してみたところ、apkファイルサイズは次のようになりました。

ライブラリ導入方法 apkファイルサイズ ライブラリサイズ(差分)
ライブラリ無し 17.6 MB --
aar ver 3.4.0 24.7 MB 7.1 MB
aar ver 3.5.3 20.0 MB 2.4 MB
自前ビルド 18.8 MB 1.2 MB

この記事ではARMv7のAndroid向けバイナリのビルド方法を掲載しましたが、x86のAndroidやWindows向けのクロスコンパイルも基本的には同じ方法でできるはずです。 ぜひ挑戦してみてください。

Introduction - はじめに

R&D部に所属しつつ、社内のPlaygroundエンジンを開発しているピコアです。 今回のテーマは最新描画APIのVulkanです。 Vulkanはグラフィックス処理APIトレンドの最先端にあるもので、ゲーム開発者が従来よりもGPUのパワーをきめ細かく制御できます。 そのかわりに開発者がさまざまな責任を負うことにもなります。

Vulkanの使い方や細かなAPIの説明などはネット上のあちこちで見つけられるので、今回はあまり触れません。 この記事の目的は、ゲームエンジンの開発者が実際にVulkanを利用した感想、学習中に得られた経験・苦労、そしてVulkanの良いところについての知見の共有です。

History of 3D Graphic APIs - 描画APIの歴史

まず、グラフィックスAPIの歴史と変遷をおさらいします。 内容は設計思想の流れと時代に応じた変化を理解するための概要に留めます(例えば3DfxのGlideは無視する)。

OpenGL : ご先祖です。1992年1月に公開されました。 当時のGPUの設計は現在とかなり違っていて、決まった固定パイプラインで描画するAPIでした。 制限もありましたが、OpenGLの良いところはそれ以前の仕組みと違って、ハードウェアに依存しないAPIだったことです。 そして関数ポインターを利用して拡張機能を簡単に追加できる仕組みがあります。 そのおかげで、登場から25年近くが経った現在でもOpenGLにはまだ未来があります。 時代と共に技術の進歩へ追従してきた、現代のハードウェアでもまともに動くAPIです。

Windowsの世界ではDirectXが1995年にMicrosoft社から発表されました。
MicrosoftはDOSからWindowsへの移行にあたり、WinGやDirectDrawのAPIを公開したのちに、3D描画のAPIを公開しました。
DirectXの枠組みは音声、コントローラー入力など多くの仕様を含みますが、今回はDirect3Dの話です。 Microsoft社が設計したDirectXの大前提は、COMを使ったOOP(Object Oriented Programming)モデルです。
そして、ハードウェアの進化にあわせて、徐々に新しいバージョンのDirectXをリリースするという方針です。
OS上には以前のDirectXもインストールされており、古いアプリケーションがそのまま動作します。 APIを維持して前のバージョンとの互換性を保つという設計思想ではないことに注意が必要です。
DirectXは1995年~2003年の間に9個ものバージョンがリリースされました(DirectX 1.0 -> 9.0bまで全体的に24 Revisions)。
バージョンごとにAPI設計を大きく変更することで、OpenGLよりも理解しやすく時代にあわせて洗練されたAPI体系を保ってきました。 ドキュメントもOpenGLより詳細に書かれており、またWindows OSがゲーム環境として成長したことで、マーケットをリードするAPIとなりました。

Evolution of APIs over time

1. 固定パイプライン時代

パイプラインとは、頂点の入力からピクセルがバッファに描画されるまでの処理です。
登場当初のDirectXの機能はOpenGLと大差なく、ともに固定パイプラインを採用していました。

固定パイプライン時代は名前の通り、描画機能はすべてハードウェアに実装されたパスで計算され、それ以外の描画オペレーションは選択できませんでした。 このため、光の計算や素材の描画の仕方などはすべて固定されていました。

しかし、それでも進化は激しいものでした。 固定パイプラインといえども、DirectX 7まで変更がありました。そこから、たくさんの変更もありましたが、詳細はリンクを読んで下さい。

対するOpenGLについては、OpenGLのデータパスのイメージ図などを参照してください。

OpenGL対DirectXという枠組みでは政治的な戦いやマーケットでの力関係もあって、性能面やそのあとに出た新ハードウェア機能の対応面などOpenGLが遅れていた部分がありました。 しかし、OpenGLのメリットはWindows環境以外でも動作することであり、生き残る事は出来ました。

2. Shader誕生

DirectX 8.0からシェーダーが追加されました。 ポリゴンの頂点毎に自由な計算(プログラム可能)をおこない、さらに計算結果をピクセル毎に受け取って自由に処理(プログラム可能)できるようになりました。 これらをVertex / Pixel Shaderと呼びます。

OpenGLの世界でも、OpenGL 1.4からシェーダーをExtensionとしてサポートし、2.0から本体サポートに含まれるようになりました(2004年ごろ)。

3. パイプライン内にプログラム可能な部分を追加

その後に、Geometry ShaderやTessellation Shaderも追加されました。 一方DirectX 9まで存在した擬似的な固定パイプラインAPIがDirectX 10で削除されました(OpenGLES 1.x -> OpenGLES 2.xでも同じく、固定パイプラインの機能がAPIから消えた)。 つまりここでプログラマの学習ハードルが高くなりました。

これ以降、画面にピクセルを描画するためには自前でVertex ShaderとPixel Shaderを組むことになります。

4. ステートを先に持っていく、先に情報をフォーマットし、使いまわしの効率をあげる

さらにCPUの負担をおさえるために、ゲームプログラマ側では描画ステートの変更タイミングを変更する取り組みがおこなわれました。 従来は描画実行タイミングでおこなっていた処理を生成/設定タイミングへと移動し、実行時負荷を低減するというものです。

ここで「作成時」と「利用時」を分けて考えた場合、仮に「作成時コスト」が大きくなったとしてもそれはプログラム実行中に一度だけ必要なコストです。 描画に利用するデータを「作成時」に事前計算することで、何度も発生する「利用時コスト」を減らせるならば、積極的にコストを「作成時」へ押し付けて可能な限り「利用時コスト」を0に近づけるのが合理的です。

この考え方とトレンドはのちにVulkanまで続いていきます。

5. より自由度の高いGPGPU(General-purpose computing on GPU)のモデルも追加

そしてComputeモデル(OpenCLまたはNVIDIAのCUDAに近い)も描画APIに追加されました。 つまり描画専用ではなく、もっと自由な形でGPUに計算させる仕組みが追加されました。
これによって例えば物理計算、音声データ処理なども出来るようになりました。 描画の面でも、ピクセルに直接関係しない描画処理にもGPUを利用できます(例:グローバル・イルミネーションの計算)。

ハードウェア性能を限界まで引き出すという点については、歴史的に専用ゲーム機のほうが先行してきました。 PlayStation / Xbox世代のゲーム機においても、粒度のこまかなAPIが開発者へ公開され、性能向上に活用できました。 しかしPCの世界ではマシンごとにハードウェア構成が異なるため、粒度のこまかなAPIを提供することは難しいとされてきました。

6. OpenGLとDirectXに残された大きな弱点: マルチスレッド動作

さて、レンダリング機能が年々強化されていったOpenGLとDirectXですが、ともに処理モデル上の大きな弱点を持っていました。 複数のスレッドを利用して(マルチスレッドで)描画したいという開発者のニーズを満たすAPIの不在です。

まず、OpenGLにはマルチスレッドモデルがまったく存在しないに等しいです。 スレッドごとにContextを持つことはできますが、描画命令を実行できるOpenGLコンテキストはアプリケーション毎に1個しかありません。 マルチスレッドを活用しようとしても、2個目以降は非同期のテクスチャアップロードにしか使えないのです。 さらに、コンテキスト間でのテクスチャ共有には同期の面で注意を払う必要があります。

DirectXでは、DirectX 11からマルチスレッド描画がサポートされました。

DirectX 10までの世代とOpenGLをターゲットとするゲームエンジンのなかには、複数のCPUコアを有効利用するために自前の処理レイヤを構築しているものもありました。 具体的には、グラフィックスAPIを擬似的にマルチスレッド化するラッパーを作り、複数スレッドから描画コマンド列を作成する仕組みです。 記録した描画コマンド列はメインスレッドがまとめてGPUへ転送します。

この仕組みはゲームエンジン側のコードに新たなオーバーヘッド(データ構造をパースしてグラフィックAPI関数の呼び出しに必要な描画ステート、テクスチャ、モデル情報を検索するコスト)を生むため、非効率的と感じるかもしれません。 しかしこのコストを払ってもなお、自前でグラフィックAPI呼び出しに相当するコマンドキューを作成してデータ列を詰め込み、レンダリング用のスレッドがそれを取り出して利用するという仕組みには処理効率上のメリットがありました。

さて、先にDirectX 11でマルチスレッド描画がサポートされたと述べました。 このなかでモデルを変更してコマンド列を貯める仕組みが追加されましたが、貯めたバッファのコミットは依然としてメインスレッドの役目でした。 つまり、DirectX 11の方式はゲームエンジン開発者が自前でコマンド列を貯めて発行していた方式と基本的に変わりません。 唯一の違いはこれらの処理をDirectX自体がおこなうことです。 このため、コマンド列を貯めるのと並行してさまざまな準備処理を先行させられます。 たとえば、コマンド間の差分のみを含むセットへと変換したり、GPUが実行しやすい形に変換するなどの処理です。

7. グラフィックスAPIパラダイムの転換前夜

DirectX 11リリース後、2012年末ごろまでの業界状況は次のようなものでした。

  • ハードウェアの進化ペースは低下しつつも続いていた(例:Conservative Rasterization)。

  • DirectXやOpenGLはプログラマーの開発負担を低減するために、さまざまな処理を裏側でおこなっていた。 場合により、ドライバーがいきなり重い作業をおこなったりと、開発者を悩ませることもあった(この負荷を避けるために、DirectXの癖を把握して、わざとある無駄なオペーレションを実行してタイミングをずらすハックもあったと聞きます)。

この頃のMicrosoft社のスタンスは「DirectXは11で最後」というものでした。 もちろん最後ではないはずですが、しばらくの間はもう抜本的な変更を加えた新しいAPIは出さないという意味でした。

いっぽうで、ゲーム開発者は昔から「たとえ記述が面倒だとしても、できるだけ完全なコントロールが欲しい」と要求してきました。 DirectXやOpenGLの提供する水準ではまだまだ足りないと感じ、専用ゲーム機並の自由なアクセスをしたいというものです。 AAAゲームを出しているゲーム会社の人間は最先端の技術を理解し、できるだけ質の高い物を作ろうとするため、当然の事です。 彼らはMicrosoftのスタンスに対して、別なルートを探していました。 もちろんKhronos Groupの中でもこの議論がおそらくあったと思います(携帯端末でのゲーム市場が伸びる中で、バッテリー消費をおさえつつ高品質なグラフィック処理を実現したいという流れもありました)。

8. Mantleの登場と各社の追従、Vulkanへ

そして、Mantleが登場しました。 MantleとはAMD社(GPUメーカー), EA DICE(ゲーム会社)が組んで開発したAPIです。 Mantleの仕様は、他のGPUメーカーがMantle APIをサポートしたいならば、自由にやって良いという条件のもとで公開されました。 そしてEA DICEのFrostbite 3エンジンでの成果がかなり良く、業界に革命ともいえる大きなショックを与えました。

もちろん、いくら優れたAPIであっても標準仕様ではない独自APIが成功する例は少なく、MantleがメインストリームAPIになる可能性は低いものでした。 さらにMantleはPC向けの独自APIでモバイル環境を考慮していなかったため、たとえばRenderPassの概念が存在しませんでした。

しかし、まずは寝ていた巨人が起きました。 Microsoftが1年のずれでDirectX 12を発表したのです。

そして、Khronos GroupがMantleをベースにVulkanの仕様を仕上げました

さて、この間にApple社がMetalという描画APIを公開しました。 これはVulkanやDirectX 12に近い感じです。 細かくみていませんが、イメージ的にVulkanより扱いやすいと思います。 残念な事にAppleがVulkanをサポートする気配は今のところありません。 Metalの上で実行出来るVulkanのライブラリ(名前はMolten)が存在しますが、どこまで機能が動くのかはわかりません。

重要なのは、VulkanがMantleのコピーではなく、DirectX 12とも違う特徴を持つことです。 VulkanにはMantleにはなかった物も入っています。 VulkanとDirectX 12の概念はとても近いですが、Vulkanははじめからマルチプラットフォームを想定しています。 モバイルも、Windows OSもLinuxもサポートします。 PS4やXbox OneもAMDのチップを使っているので、Vulkan対応が可能だと考えられますが、政治的な意味でVulkan対応の可能性は低そうです。 しかし今後の新しい環境、ゲーム機などがサポートする可能性は十分あると考えられます。

つづいて、Vulkanが従来のAPIとどう違うかを見ていきましょう。

Vulkan addition in more detail - Vulkanが追加する機能と概念

Everything in advance / ほぼすべての状態、設定を先にオブジェクトへビルドする

  • 1/ 全体の描画パイプラインをイメージするオブジェクトを先に作成可能(Input Streamの設定・Shader・Blend・Depth Buffer・Stencil・View Portのdefault値の設定など)
  • 2/ パイプラインにつながるテクスチャーやテクスチャーサンプリング方法、Uniform値を指定
  • 3/ 描画コマンドバッファで描画命令を貯められる。これらは再利用も可能で、どのスレッドからでも作成可能。GPUに送る事も出来る(出来上がったオブジェクトはある状態を表現するため、Immutable型で変更出来ません)。

この3点によって、従来のOpenGLでは描画時にかかっていた処理コストをすべて作成時のコストに移動できます。 事前処理によって内部でGPUの一番わかりやすい形(GPUでの利用時にオーバーヘッドが最も少ない形)のデータに変換することも可能です。 さらに描画命令を貯める事で処理差分のみをGPUに対して発行する最適化も可能で、これも処理負担減に貢献します。

つまり、以前にあった描画時にコミットのオーバーヘッドやステートの変更によってドライバーがやらないといけない重い仕事が消えます。 データはすべて事前に計算されているため、最低限の差分適用で前の処理から次の処理へ切り替えることもでき、GPUにとってもやさしい設計です。

描画コマンドも再利用可能なので、さらにコストダウンにつながります。 一回だけの作成コストで複雑な命令を組み合わせ、その後はずっと描画にコミットするだけです。

ここで重要なのは、描画コマンドのバッファを再利用する際に、画面が全く同じ見た目になる必要はないということです。 例えばカメラの角度を変えたり、キャラクターの場所や動きを変える事は簡単に出来ます。 例えば10万個の草の描画命令を簡単に再利用することが可能です。 コマンドバッファが利用するバッファの中身を差し替えることで、同じコマンドバッファを利用して異なる物を描画する事も可能です。

Reusability in Vulkan

  • Render Passes 利用するバッファ、書き込み用・読み込み用の依存グラフを定義できます。 これによって画面出力までのバッファの流れが整理され、Vulkanドライバがハードの制御を最適化できます。

  • Free Memory Management / 自由なメモリ管理 DirectXやOpenGLでは、メモリのアロケーション時にHandleが戻ってきます(テクスチャー, VBOなど、とにかくオブジェクトポインターやIDが戻ってくる)。

Vulkan APIを利用するアプリケーションでも、Vulkanに対してメモリのアロケーションを要求します。 しかし、このときに対象メモリの用途がテクスチャーなのかバッファなのか、その他のユースケースを明示します。 要求したメモリはOpenGLやDirectXと同じく、Handleとして戻ってきます。

端末のプロフィール情報から、どれぐらいのメモリがあるのか、それらの種類は何か、なども知る事が出来ます。 この情報を利用して、メモリのアロケーション時にどの種類のメモリをどれだけ利用するか設定できます。 そして、割り当てられたメモリを利用する時に必ずすべてのAPIにHandleのオフセットを設定します。

これによって、自然な形ですべてのメモリブロックに対する自前のアロケーションの仕組みを作成できます。 メモリのポインタが直接見えないにも関わらず、Handle + Offsetのお陰でポインタを持っているのと同じ事が可能です。 これはすごく頭の良い仕組みだと思いました。 そして、対象がCPUから見えるメモリであれば、Handleからアドレスにマッピング出来ます(GPUへのメモリ転送によく利用される)。

  • Threading Modelの特徴と制約 基本的にスレッド毎にコマンドバッファで描画コマンドを貯めるのは変わりません。 かわるのはどのスレッドでもコミットが可能という点です。 ただし、同時に2つ以上のスレッドが同じQueueにアクセスしてはなりません。 同時アクセスを防ぐための制御はユーザの責任でおこないます。

すべてのデータの種類に関する、細かなルールは以下のページにあります。

https://www.khronos.org/registry/vulkan/specs/1.0/xhtml/vkspec.html#fundamentals-threadingbehavior

Vulkan Basic - Vulkanの基本

First Setup

初期設定

  • Enumerate Layers (=API Wrappers) and Extensions. ここでVulkan用にインストールされているレイヤー(APIのラッパーの理解で良い、目的によって、いろいろなレイヤーが存在する)を列挙します。 この段階で拡張関数の取得も可能です。

  • Create Instance of Vulkan. Vulkanのインスタンスを作成します。

  • List all physical GPU devices supporting Vulkan. 実際にコンピュター内にVulkan対応の物理デバイス(つまりグラフィックカード)のリストを取得します。
    • Can list / access all the features / properties / memory properties / extensions of device. デバイスごとにサポートされている機能、機能の制限、メモリの種類と制限、拡張の情報を取得できます。
    • Can list all the queues supported by the physical device. そしてデバイスが対応しているQueue一覧(Queueは命令の配列を実行する物)を取得できます。

  • Create a physical instance. 物理デバイスのプロファイルをVulkan APIでインスタンス化します。

  • Create a logical instance from physical device. そして、物理デバイスのインスタンスから、論理デバイスを作成します。

  • Get Queue and all ressources from logical device. 論理デバイスでリソース、Queueなどを取得します(作業はほぼすべて論理デバイスで行う)。

  • Job Done / 設定完了


Swap Chain Setup

Swap Chain / 描画用のスワップチェイン

  • Get Extensions for presentation. 表示用の拡張APIを取得します。

  • Create Surface Surfaceを作成します。

  • Get surface support and possible format. Surfaceが対応しているフォーマットや機能を確認します。

  • Get,select and set presentation mode. 表示の仕方(モデル)を取る、選択し、設定します。

  • Create Swap Chain スワップチェインを作成します。

  • Get images from swap chain. スワップチェイン内からのイメージ一覧を取得します。

    • Create command buffer for post/pre presentation for each images イメージ毎に描画前・後の表示用のCommand Bufferを作成します。
    • Related Image Memory Barrier メモリバリアを作成します。
    • Create View for each image. イメージ毎にViewを作成します。

  • Use 2 semaphores : 2つのセマフォを作成します。
    • One for presentation complete. 表示完了用です。
    • One for rendering complete. 描画完了用です。

そして、

  • Get format properties supported, create Depth/Stencil Buffer,Image and view. 対応できるフォーマットを取得し、Depth/Stencilバッファ、イメージ、ビューを作成します。
    • Allocate necessary memory and bind both. 必要なメモリを割り当ててこれらを紐付けます。

  • For each views of all images from the swap chain, create a framebuffer linking the depth buffer view and the image views.


Rendering / 描画

  • Pre Rendering / 描画前
    • Get Next Image ID. (=Current) / 次の描画バッファのIDを取得します。
    • Post Present Buffer / 描画後コマンドバッファ
      • Begin Command/コマンドの開始
      • Push Image Memory Barrier with Current Image / 現在のイメージオブジェクトにメモリバリアを設定
      • End Command / コマンドの完了
      • Send to Queue / Queueに転送
    • Wait for Queue to complete. (Queue idle wait) / Queueの完了待ち
  • Rendering itself / 描画本体
    • Submit current frame command queues [### エンジンの描画コマンド ###] with following condition / 現在のイメージオブジェクトに合っているコマンドバッファに[### エンジンの描画コマンド ###]を次の条件付きでコミット
      • Wait for "present complete semaphore" / 表示完了セマフォ待ちを指定
      • Set for "render complete semaphore" / 描画完了セマフォ待ちを指定
      • Send to Queue.
  • Post Rendering / 描画後
    • Pre Present Buffer
      • Begin Command / コマンドの開始
      • Push Image Memory Barrier with Current Image / 現在のイメージオブジェクトにメモリバリアを設定
      • End Command / コマンドの完了
      • Send to Queue / Queueに転送
    • Swap Chain Queue Present API with Current Image with following condition :
      • Wait "render complete semaphore" / 描画完了セマフォ待ち
    • Wait for Queue Idle. / Queueの完了待ち

[### エンジンの描画コマンド ###] は自分が描画したい物です。 もちろん、ここで挙げたのは単純なケースで、1つのターゲットバッファしか使わないパターンです。

What Vulkan is for - Vulkanは何のため

Not For :

  • 単純なテスト・単純な物で描画したい場合。基本的にVulkanは通常のアプリケーション本体に書く物ではありません。描画フレームワーク内から呼び出す物です。
  • CPUが描画以外の処理に忙しい場合。Vulkanの性質を活かすためには、描画用の前処理にCPU時間を割く必要があるためです。
  • GPUの細かな事(内部構造)を知らない方
  • OpenGL(ES) / DirectXの仕様を読んで悩んでいる方。
  • Single Thread描画。これはちょっと微妙な意見です。実際Single Threadの場合でもVulkanをうまく使うことで効果は出せます。しかし、性能を最も引き出せるのは複数のスレッドで描画した場合だと思います。

For :

  • CPUバウンド描画。今までOpenGLやDirectXを使って複数のコアで作業していても、描画による処理でCPUが足りなかったパターン。
  • たくさんのdraw callが必要なアプリケーション。
  • 安定した描画時間が必要な場合。ドライバの勝手な裏作業をなくすことで、描画にかかる時間を見積もりやすくなります。

つまり、描画APIの勉強以外、ほとんどOpenGL系でも十分です。 しかも、OpenGL AZDO Extensionを利用すれば、Vulkanとほぼ同じ事を楽にできます。

すなわち、Vulkanは仕事上で専門に描画APIを叩く方に向いている仕組みです。 個人的には、勉強・趣味とゲームエンジン/3D CAD類の開発者以外はあまり調べなくても困らないのではないかと思います。

What I found difficult - 難しいと思ったところ

今回の記事を書くにあたって、Vulkanのすべての機能を使ったわけではありません。 また仕様を細かいところまで完全にマスターして、理解したわけではありません。 あくまでも、使った分の個人的な感想です。

  • ユースケースごとの説明が足りない

このフラグの組み合わせで本当にいいのか、という確証を得られない場合がありました。 このため、あるフラグを設定しない場合はどうなるのかなどの疑問を持ちながら開発を進めました。 特にメモリのタイプ・転送について、またViewの切り替えについて、不明確な部分が多かったです。 例えばRender Targetのreadとwriteの状態を切り替える概念を理解しても、実装・設定に関して、不安なところがありました。

  • 同じくRender passについて、説明が物足りない

APIの説明はいいのですが、ユースケースやパターンに関する説明が不足しています。 APIの説明を読んでも、見えていないところもあります。

  • API仕様書ではなく、概念やユースケースを説明する本が存在しない

新しいAPIであるため一定は仕方ないことですが、これによってDirectXやOpenGLでの経験がないプログラマがVulkanを理解するのが難しくなっていると思います。 また、DirectXやOpenGLのAPIをたくさん叩いた経験があったとしても、内部で起きている事・GPUの設計を理解していなければ理解しにくい部分があります。 暗黙的なノウハウがかなり多いと感じています。

Hints - ヒント

Vulkanを利用した開発をおこなう際にヒントとなりそうなものをいくつか紹介します。

C++ラッパーはオーバーヘッドがなく、しょうもないミスを防ぎやすく、メンテナンスしやすいVulkanコードを組めます。 使わない理由はないと思います。

型安全なEnum指定によって定数指定ミスを防げますし、IDEで自動的に関数やEnumを補完できるため、開発がかなり楽になります。 今回は勉強のために、作業途中でC++版の自前ラッパーを作ったのですが、本記事の執筆時点でNVIDIAがC++ヘッダの自動生成ツールを提供しています。 Vulkanコードを真剣に組むなら、使うべきだと思います。

なお、NVIDIAのC++ヘッダ生成ツールはKhronos Groupからオフィシャルに認められて、2016年7月末からKhronos Groupによってメンテナンスされています

  • Debug Layerを使ったバリデーションを活用しましょう。

大変役に立ちます。 もちろん、もうちょっとわかりやすいエラーを出せばいいのにと感じることもあります。 それでも無言でcrashするより、大変素晴らしいです。 そして、エラーではないが推奨されない指定についてWarningを出してくれるケースもありました。 これによって、コピペによるフラグの設定ミスなどを修正出来ました。

Vulkan Validation Layersの使い方を説明したサンプルコードはこちらです。 http://gpuopen.com/using-the-vulkan-validation-layers/

In the case of Playground

当社で開発している2Dゲームエンジン"Playground"でVulkanを実験的にサポートするために行った対応を紹介します。 エンジン固有の事情も含まれるため、前提を補足しつつ説明します。

  • CPU Batching and dynamic buffer binding.

Playgroundではもともと描画用の頂点データを実行時にCPU側で計算しているため、GPU側のバッファを一切使っていません。 OpenGLのドライバーと同じ働きをし、命令コマンドを貯めながら、転送する頂点のデータも貯める構造です。 そして、描画命令を実行する前に先にメモリの転送を行います。

  • OpenGL like wrapper in engine (not modern stateless)

エンジンの実行時にOpenGLのステート切り替えをしていたため、エンジンのRefactoringはおこなわずに同じ動作を再現しました。 フラグとステートの差分を管理し、使うPipelineを切り替えるようにしました。 (Supported states : Viewport, scissor, depth read/write, depth function.)

  • Dynamic Shaders, target buffers : not supported.

エンジンではシェーダーやターゲットバッファの切り替えをサポートしていますが、今回は最小限の作業で可能な範囲の対応をおこなったため、シェーダーの切り替えやターゲットの切り替え部分は未実装です。

  • Texture Switch. テクスチャーの切り替え

Vulkanでは二つの方法が存在すると認識しているのですが、試したのは片方だけです。 OpenGLやDirectXのようなダイナミックな切り替えはVulkanの場合、PipelineにつなぐDescriptor Set Layoutをテクスチャー毎に作成(OpenGL系でいうと、Uniform値の塊を設定するオブジェクト)することで実現します。 もう一つの方法は、複数のDescriptor Set Layoutを作成せずに、すべてのテクスチャーを配列化して、indexでアクセスする方法です(試していないし、実装方法を調べていません)。

今回対応したものはこんな感じです。

Draw Command In Playground

Vulkan SDK, Sample Code of vulkan over the net :

とにかくVulkanを学んでみたいという方のために、Vulkan SDKとネット上に存在するVulkan関連のサンプルコードを追いかけて学習するためのおすすめフローを簡単に紹介します。

まずはKhronos Groupのホームページから始めましょう。 SDKへのリンク、仕様書、ドライバーなど、すべてそろっています。 https://www.khronos.org/vulkan/

個人的にNVIDIAのカードを持っているため、NVIDIAのドライバーをインストールし、LunarGのSDKもインストールしました。 LunarGのSDKをインストールすると、Debug/Validationレイヤーが追加されるので役に立つと思います。

今回の記事で扱ったのは感想や全体的な話がメインでした。 サンプルコードをもとにして具体的にVulkanを試したい場合は、次の順でみていくと良いでしょう。

Sascha Willemsのサンプルが一番機能が揃っていると同時にやる事が多く、フレームワーク化されています。 このため、生のAPIコールを連続的に綺麗に見ていきたい場合は、少々苦労するかもしれません(LunarGのAPI Dumpレイヤーを使ってよいかもしれませんが)。

将来は?

モダンなAPI(Vulkan, DirectX 12, Metal)はGPUの内部構造にフィットしやすい・計算モデルを近づけているため、今後のハードウェアの進化にあたって、APIも進化すると個人的に思います。 今までハードルだった古いAPIの体系が消えたことで、今後のAPI拡張はハードの性能を十分に引き出しながら進められると思います。 例えば、Raytracingの拡張、Conservative Rasterizationの拡張、などなど、すでに進化は始まっています。 例: http://gpuopen.com/gcn-shader-extensions-for-direct3d-and-vulkan/

Update :

Vulkanの調査とPlaygroundエンジンへの組み込み作業をおこなったのは2016年6月でした。 そしてこの記事を書いたのは7月でしたが、記事公開までの間の7月にSIGGRAPH 2016が開催されました。 このなかでVulkanに関する良い資料が公開されていたので紹介します。

https://www.khronos.org/assets/uploads/developers/library/2016-siggraph/3D-BOF-SIGGRAPH_Jul16.pdf

Conclusion :

Vulkanは低レベルAPIであると言われることが多いですが、個人的にはそうは思いません。 たしかに気をつけるべき事、やる事はかなり増えますが、今までの描画APIの流れで考えると自然なものが多いです。

つまり、Vulkanは完全に新しい概念というわけではなく、これまでの歴史の流れと追加されてきた概念の続きを担うAPIです。 具体的には、可能な限り事前処理できるものを増やす、データの再利用生を高める、描画時の負担を減らす、メモリアクセスをより柔軟にコントロールできるようにする、というトレンドに沿うものです。 これらを実現しやすくなるかわりに、GPUや描画パイプラインの細かいところまですべて理解する必要があります(覚える事は多いですが、昔からやっている描画エンジニアなら特に問題ありません)。

現時点のVulkanが抱える問題は、まだ丁寧なドキュメントがないことです。 ある事を実現したい時に複数の方法を考えつくことが多いのですが、何が「正しい」かという答えが出ない場合もあります(そもそも答えは存在しないかもしれません)。 つまり、ユースケースごとのわかりやすい例がありません。 おそらく何年間もゲーム機やPCでのグラフィック描画で苦労した経験者は悩む事が少ないでしょう。 私は描画APIをバリバリ触ってゲーム機向けの開発をおこなったことがないため、正当な評価をするための能力や経験が乏しい部分もありますが、より暗黙的なノウハウに依存しないマニュアル作りとさまざまな全体図/例の提示がほしいと感じました。

まずは、今後のGraham Sellers(AMD)のVulkan本に期待したいと思います。 https://www.amazon.com/Vulkan-Programming-Guide-Official-Learning/dp/0134464540

↑このページのトップヘ