23.5K Views
September 28, 23
スライド概要
▼受講スキル
・CPUコア、スレッド、ロックフリーなど、マルチスレッドプログラミングにある程度触れたことがある方。
・ゲームロジックからGPUコマンド生成までのゲーム全体の処理の流れをある程度理解している方。
・マルチスレッドを前提としたプログラム設計方針に興味のある方。
▼得られる知見
・マルチスレッドを前提としたプログラム設計・考え方の一例。
▼概要
ハードウェアの世代交代により、ゲームが利用できるCPUのコア数が増えましたが、増えたCPUコアをうまく活用できず、一部のスレッドにだけ処理が集中する状態になってしまいました。
弊社の内製エンジン「Toylo Engine」における、マルチスレッド設計問題点から、どういった方針で改善を行ったかを解説いたします。
主にプログラムから見たゲームの1フレームの考え方と、データの取り回し、マルチスレッド化しやすい仕組みや概念についての説明となります。
========
▼ Attendance Skills
The course is designed for students who have some familiarity with multi-threaded programming, including CPU cores, threads, and lock-free programming.
Those who have some understanding of the overall game processing flow from game logic to GPU command generation.
Those who are interested in program design policies that assume multi-threading.
▼ What you will learn
An example of program design and thinking based on the assumption of multi-threading.
▼Summary
Due to the generation change of hardware, the number of CPU cores available for games has increased, but the increased number of CPU cores cannot be utilized properly and processing is concentrated on only some threads.
This presentation will explain the multi-threaded design problem in our in-house engine "Toylo Engine," and how we made improvements based on this policy.
This presentation will mainly explain the concept of one frame of a game from a programmatic point of view, data handling, and mechanisms and concepts that facilitate multithreading.
========
トイロジックは、家庭用ゲームをはじめとしたソフトウェアの開発・販売を行う企業です。いつの時代も子供から大人までワクワクする「遊び心(TOY)」と、時代をけん引する世界先端の「技術力(LOGIC)」を融合させ、「世界中の人々に喜び・驚き・感動を与えるゲームを届けたい」 そんなミッションを掲げて2006年に設立されました。 自社タイトル第1弾となるオンラインアクション『Happy Wars』は、北米を中心に約1,500万ダウンロードのヒットを記録。2021年4月に発売された『NieR Replicant ver.1.22474487139...』や、海外パブリッシャーのAAAタイトルなど高度な技術力を要する数々の開発実績を武器に、日本のみならず世界のゲーマーに向けて、オリジナルゲームを発信していきます。
これからの時代と戦う! 内製エンジンにおけるマルチスレッド設計方針のご紹介 株式会社トイロジック 林 祐一郎 それでは 「これからの時代と戦う!内製エンジンにおけるマルチスレッド設計 方針のご紹介」 を始めさせていただきます。 株式会社トイロジックの林祐一郎と申します。 よろしくお願いいたします。
全体内容 ◆ はじめに ◆ マルチスレッド設計方針 ◆ 実装詳細 ◆ タイトル事例 ◆ まとめ 本セッションの全体内容です。 はじめに、講演内容をご紹介する前に、前提となる考えの共有を行い ます。 そのあと、マルチスレッドの設計方針と概要をご説明させていただき ます。 概要を理解して頂いたうえで、どうやって実現したか、実装に関して C++コードを交えて説明します。 実際のタイトルでどのように使用したかも 紹介し、最後に感想を述べ て終了となります。 なお、事前収録という環境を活かし、少し早口でお届け致します。 Cedilにて読み上げ台本含めて資料公開予定ですので、わかりづらかっ た部分は、後程ご確認ください。 また、写真撮影とSNSへの投稿は自由に行ってください。特にポジティ ブな意見なら大歓迎です。 それでは 1時間という短い間ですが、よろしくお願いいたします。
はじめに ✓ エンジン紹介 ✓ 設計見直しの理由 ✓ CPU最適化について はじめに、本題に入る前に、前提となる部分の共有をさせていただき ます。 どういったエンジンなのか、 なぜ設計を見直さないといけなかったのか、 CPU最適化とは何か という点をご説明させていただきます。
エンジン紹介 ◆ Toyloエンジン(十色エンジン) ◆ 社内保守されている開発環境 ◆ チーム8名(+サポート) ◆ 汎用エンジンは目指していない ◆ 直近では『NieR Replicant ver. 1.22474487139...』で使用 ◆ 現在、 PlayStation 5 / Xbox Series X|S / Steam 向け社内プロジェクトでも利用中 ◆ 会社としては Unreal Engine / Unity プロジェクトもある中での選択肢の一つ © SQUARE ENIX Developed by Toylogic Inc. まずは弊社のエンジンを紹介させてください。 Toyloエンジンという名前で開発を進めています。社内で保守されてい る、ツール・フレームワーク環境です。 チームメンバーは8名で、汎用エンジンは目指しておらず、プロジェ クトに必要な機能のみを特化させて作るという流れになっています 直近では2021年4月にスクウェア・エニックス様より発売されました 『NieR Replicant ver.1.22474487139...』でも、コチラのエンジンが利用 されました。 現在、PlayStation5、XboxSeriesX|S、Steamを対象とした社内プロジェク トでも利用しており、本公演の発表内容はそちらのタイトル事例とな ります。 タイトルの詳細は後程 ご説明させていただきます。 また、会社のプロジェクト全てが内製エンジンを利用しているわけで は無く、UnrealEngineやUnityを利用しているプロジェクトもあり、選択 肢の一つとして用意されているエンジンとなります。
マルチスレッド設計見直しの理由 ◆ コンシューマはPlayStation 4 / Xbox One 世代からPlayStation 5 / Xbox Series X|S世代に ◆ ゲームが使用できるCPUコア数が増加 続いて、設計見直しを行った理由を簡単に説明します コンシューマゲームはPlayStation4、XboxOneの世代から、PlayStation5、 XboxSeriesX|Sの世代に移行しました。 それに伴い、ゲームが使用できるCPUコア数が増加しました
マルチスレッド設計見直しの理由 ◆ コンシューマはPlayStation 4 / Xbox One 世代からPlayStation 5 / Xbox Series X|S世代に ◆ ゲームが使用できるCPUコア数が増加 ◆ 今までの作りでは、CPUコアがさらに余ってしまう! ◆ もっとCPUコアを効率よく使えるようにならなければ! という取り組みの話をします 「前世代機でも、そこまで使い切ることが出来ていなかったCPUが、こ のままだと さらに余ってしまう状況になる」という危機感があり、 「もっとCPUを効率よく使うようにならなければ!」という状況の中、 弊社エンジンの取り組みの話をしたいと思います
マルチスレッド設計見直しの理由 ◆ コンシューマはPlayStation 4 / Xbox One 世代からPlayStation 5 / Xbox Series X|S世代に ◆ ゲームが使用できるCPUコア数が増加 ◆ 今までの作りでは、CPUコアがさらに余ってしまう! ◆ もっとCPUコアを効率よく使えるようにならなければ! という取り組みの話をします ◆ 既にCPUコアを有効活用しているエンジン、タイトルの関係者は、生暖かい目でご覧ください ◆ このあたりの情報が少ないので、ぜひ共有してください 既にCPUコアを有効活用しており、特に問題も発生していないエンジン、 タイトルの関係者は、生暖かい目でご覧ください このあたりの情報がカナリ少ないと感じています。 中小企業だと、相談する場も少なく、僕のように悩んでいる方も多い と思います。 同じような開発者が一定数いることを信じて、弊社のエンジン事例を ご紹介します。 本公演の内容が、皆さんのお力になれれば幸いです!
CPU処理の最適化について 「CPUコアが余るのは悪い事なのか」という部分で認識のすりあわせ まず最初に、「CPUコアが余るのは悪い事なのか」という部分で認識の すり合わせをさせてください。
CPU処理の最適化について 「CPUコアが余るのは悪い事なのか」という部分で認識のすりあわせ ◆ 「フレームレートの向上」のためにCPU処理の最適化を行う ゲームに関して、CPU処理の最適化は「フレームレートの向上」のため に行っていると思います。 基本的には目標フレームレートを達成するためにCPU処理の最適化が行 われます
CPU処理の最適化について 「CPUコアが余るのは悪い事なのか」という部分で認識のすりあわせ ◆ 「フレームレートの向上」のためにCPU処理の最適化を行う ◆ それを実行するのがCPUコア そして、それを行うために働くのがCPUのコアになります。 CPUのコアを使用してプログラムが動作しています。
CPU処理の最適化について 「CPUコアが余るのは悪い事なのか」という部分で認識のすりあわせ ◆ 「フレームレートの向上」のためにCPU処理の最適化を行う ◆ それを実行するのがCPUコア ◆ 33msの処理を16msで終わるように最適化する場合 ◆ C++処理の高速化手法は沢山ありますが、かなり大変 33.3ms 16.6ms 処理 高速化 33msかかる処理を16msで終わるように最適化する場合、 C++の高速化手法は沢山ありますが、挙動を変えずに処理時間を半分に するのは、かなり大変です
CPU処理の最適化について 「CPUコアが余るのは悪い事なのか」という部分で認識のすりあわせ ◆ 「フレームレートの向上」のためにCPU処理の最適化を行う ◆ それを実行するのがCPUコア ◆ 33msの処理を16msで終わるように最適化する場合 ◆ C++処理の高速化手法は沢山ありますが、かなり大変 ◆ 処理を分割し、CPUコアの数だけ並列実行も可能 33.3ms 16.6ms 処理 並列化 並列化 処理を高速化するのではなく、処理を分割し並列実行すれば、CPUコア の数だけ早く終わることが出来ます
CPU処理の最適化について 「CPUコアが余るのは悪い事なのか」という部分で認識のすりあわせ ◆ 「フレームレートの向上」のためにCPU処理の最適化を行う ◆ それを実行するのがCPUコア ◆ 33msの処理を16msで終わるように最適化する場合 ◆ C++処理の高速化手法は沢山ありますが、かなり大変 ◆ 処理を分割し、CPUコアの数だけ並列実行も可能 ◆ CPUコアが余っているなら ◆ 並列実行すれば処理コストゼロへ ◆ 追加で処理を入れても実質無料! 33.3ms 別処理 余ってるコアで実行 もちろん、1つずつの処理の最適化も大事なんですが、 CPUコアが余っている状況であれば、別処理の裏で実行することで、コ ストゼロになります。 また、追加で別の処理を入れたい場合にも、実質無料で機能提供出来 ます。
CPU処理の最適化について 「CPUコアが余るのは悪い事なのか」という部分で認識のすりあわせ ◆ 「フレームレートの向上」のためにCPU処理の最適化を行う ◆ それを実行するのがCPUコア ◆ 33msの処理を16msで終わるように最適化する場合 ◆ C++処理の高速化手法は沢山ありますが、かなり大変 ◆ 処理を分割し、CPUコアの数だけ並列実行も可能 ◆ CPUコアが余っているなら ◆ 並列実行すれば処理コストゼロへ ◆ 追加で処理を入れても実質無料! 余っているCPUコアを活用すれば「フレームレート向上」 も「仕様追加」も可能 結果としてゲームのクオリティの底上げが可能 CPUコアが余るのは悪い事か 余っているコアがあるなら 様の追加」も目指せます という点についてですが、 「フレームレートの向上」も「更なる仕 結果としてゲームのクオリティの底上げが可能になるという事になり ます。 出来るだけコアを使い切れるようになりたいですね!
マルチスレッド設計 ✓ ✓ ✓ ✓ 設計方針 システム概要 システム活用例1:パラメータの受け渡し システム活用例2:バッファリングからの移行 それでは、本公演の核となるマルチスレッド設計に関してです。 設計方針から、システム概要、 それを理解するために2つの活用例を示します。 コチラの内容を理解しないと後半が全く意味のないものとなりますの で、しっかり説明します。 図を大量に用意しましたので、図が出ているタイミングでは文字では なく図を見るようにしてください。 それでは始めましょう!
設計方針::考察 ◆ 「SoA(Structure of Arrays)」 「Entity Component System」「データ指向設計( Data-Oriented Design )」 マルチスレッドに関して「どう改善しようか」と悩んでいる時期の話 になりますが、 「SoA」をベースとした「ECS」や「データ志向設計」といった言葉を よく聞きました。 実際に実装して検証を行う時間は無かったのですが、当時、概要だけ 調べた感想を言いますと、
設計方針::考察 ◆ 「SoA(Structure of Arrays)」 「Entity Component System」「データ指向設計( Data-Oriented Design )」 ◆ メモリキャッシュ効率重視 ◆ マルチスレッド対応しやすい ◆ 大量の同種オブジェクトが存在する前提の「サブシステム用」の設計という認識で、 ゲーム全体の処理からすると部分的な最適化を指す仕組み? ◆ ゲームの検証時からSoAで作るのか? CPUメモリキャッシュの効率化に重点を置き、マルチスレッドにもうま く利用できる とても面白い設計だと思いました。 ただ、大量の同種オブジェクトが存在する前提の「サブシステム用」 で、ゲーム全体の処理からすると「部分的な最適化」を指す仕組みで はないか?と感じました。 また、懸念点として ちょっと試しに機能実装しましたというタイミングでも全てSoAで作る のか? という疑問もありました。
設計方針::考察 ◆ 「SoA(Structure of Arrays)」 「Entity Component System」「データ指向設計( Data-Oriented Design )」 ◆ メモリキャッシュ効率重視 ◆ マルチスレッド対応しやすい ◆ 大量の同種オブジェクトが存在する前提の「サブシステム用」の設計という認識で、 ゲーム全体の処理からすると部分的な最適化を指す仕組み? ◆ ゲームの検証時からSoAで作るのか? ◆ これまでのサブシステムのマルチスレッド化、高速化 ◆ 一時的にコアをすべて使う事は可能だが、常に動くものではない ◆ ゲーム全体で常にコアを有効に使うには大量のサブシステムを用意する必要がありそう エンジン内のサブシステムのマルチスレッド化、高速化は今までも 行ってきました。 ただ、「一時的にコアを使い切るように並列化」することは可能でし たが、 エフェクトやアニメーションの更新など、常に動き続いているような ものではありませんでした。 「ゲーム全体で常にコアを使い続ける」ためには、大量のサブシステ ムを用意する必要がありそうですが、あまり現実的に感じませんでし た。
設計方針::考察 ◆ 「SoA(Structure of Arrays)」 「Entity Component System」「データ指向設計( Data-Oriented Design )」 ◆ メモリキャッシュ効率重視 ◆ マルチスレッド対応しやすい ◆ 大量の同種オブジェクトが存在する前提の「サブシステム用」の設計という認識で、 ゲーム全体の処理からすると部分的な最適化を指す仕組み? ◆ ゲームの検証時からSoAで作るのか? ◆ これまでのサブシステムのマルチスレッド化、高速化 ◆ 一時的にコアをすべて使う事は可能だが、常に動くものではない ◆ ゲーム全体で常にコアを有効に使うには大量のサブシステムを用意する必要がありそう ◆ 現時点の状況では それだけでは足りないのではないか、 もう少し「全体的な視点」でコアを使い切れる仕組みも必要ではないか 現時点の弊社エンジンの状況や過去開発タイトルの状況を考えると 「それだけでは足りないのではないか。 もう少し「全体的な視点」 でコアを使い切れる仕組みが必要ではないか」と考えました。
設計方針::考察 ◆ そもそも、C++でのマルチスレッド処理は難しい! ◆ 誰が書いてもミスが多い&ミスが見つかりづらい! ただ、そもそもC++のマルチスレッド処理は難しいという問題がありま す。 誰が書いてもミスが多く、さらにはミスを見つけるのも大変です。 新卒や一時契約のプログラマ含め、チーム全員がマルチスレッド処理 を書くのは現実的では無さそうです。
設計方針::考察 ◆ そもそも、C++でのマルチスレッド処理は難しい! ◆ 誰が書いてもミスが多い&ミスが見つかりづらい! 必要とされているのは ◆ 簡単に利用可能 ◆ シンプルな概念 ◆ 出来ればマルチスレッドではない方が良い 必要とされているのは「簡単に利用可能」な「シンプルな概念」であ り、「出来ればマルチスレッドではない方がいい」 ということです。
設計方針::考察 ◆ そもそも、C++でのマルチスレッド処理は難しい! ◆ 誰が書いてもミスが多い&ミスが見つかりづらい! 必要とされているのは ◆ 簡単に利用可能 ◆ シンプルな概念 ◆ 出来ればマルチスレッドではない方が良い では どうすればいいの? それではどうすればいいのか、
設計方針::考察 ◆ そもそも、C++でのマルチスレッド処理は難しい! ◆ 誰が書いてもミスが多い&ミスが見つかりづらい! 必要とされているのは ◆ 簡単に利用可能 ◆ シンプルな概念 ◆ 出来ればマルチスレッドではない方が良い では どうすればいいの? GDC 2015 『Parallelizing the Naughty Dog Engine Using Fibers 』 FrameParams そんな時に見つけたのがGDC2015で発表されました「Parallelizing the Naughty Dog Engine Using Fibers」の中で紹介されている 「FrameParams」という部分です。 本公演では詳しく触れませんが、とても面白い内容ですので、まだご 覧になってない方は、ぜひご確認ください。 そのアイディアをベースにした方針を、これから説明させていただき ます。
設計方針 ◆ ゲームの1フレームの処理を分解 1フレームの画面を出す処理 まずは設計方針の説明をする前に、既存のゲームの1フレームの処理 を分解して説明します
設計方針 ◆ ゲームの1フレームの処理を分解 1フレームの画面を出す処理 Present フレーム処理の最後は、ディスプレイに画像が表示される事です。
設計方針 ◆ ゲームの1フレームの処理を分解 1フレームの画面を出す処理 GPU Execute その画像を作成するために、GPUが動作します Present
設計方針 ◆ ゲームの1フレームの処理を分解 1フレームの画面を出す処理 GPU Command GPU Execute Present GPUを動かすために、GPUコマンドを生成する必要があります
設計方針 ◆ ゲームの1フレームの処理を分解 1フレームの画面を出す処理 Game Simulation GPU Command GPU Execute GPUコマンドを生成するために、ゲームを更新します Present
設計方針 ◆ ゲームの1フレームの処理を分解 1フレームの画面を出す処理 Game Simulation GPU Command GPU Execute Present CPU Game() Render() この流れをCPU処理に表したものを します GpuExecute() Game Render GpuExecute 関数と
設計方針 ◆ ゲームの1フレームの処理を分解 1フレームの画面を出す処理 Game Simulation GPU Command GPU Execute Present CPU Game() Render() GpuExecute() GPU 実際にはGPUの処理も関わりますが、内容が少し外れますので、今後 は省略して説明します。
設計方針 ◆ CPUの処理を図にすると・・・ Thread 0 Game() Render() GpuExecute() Thread 1 Thread 2 Thread 3 Thread 4 先ほどのCPUの処理を図にしてみました。 横軸が時間軸で、縦軸がスレッドです。 Game,Render,GpuExecuteの関数が終わるとディスプレイ画像が更新され ます
設計方針 ◆ 実際には直列で動作することなく、それぞれの処理はスレッドに分かれます Thread 0 Thread 1 Thread 2 Game() Render() GpuExecute() Thread 3 Thread 4 実際には 直列で動作することなく、 それぞれの処理はスレッドに 分かれます。
設計方針 ◆ 各処理が並列動作することで、 実際に1フレーム分の画像を作る処理時間より短い時間で、画面が更新出来ます 16.6ms Thread 0 Thread 1 Game() Game() Game() Render() Render() Render() GpuExecute() GpuExecute() Thread 2 GpuExecute() Thread 3 Thread 4 更にそれを並列動作させることで、 1フレーム分の処理を直列で並べるより、短い時間で画面更新が可能 になります この図のように、1フレームの処理を2~3スレッドで構成している 環境が多いのではないかと思います。 弊社エンジンも、こんな感じで動作していました。 では、ここからが本題です。
設計方針 ◆ さらにGame()とRender()の処理を分割することが出来れば・・・ 16.6ms Thread 0 Thread 1 Thread 2 Game1() Game2() Render1() Render2() Gpu Execute() Thread 3 Thread 4 GameとRenderの処理を半分に分割することが出来れば・・・・
設計方針 ◆ さらにGame()とRender()の処理を分割することができば・・・ 並列化して・・・ 16.6ms Thread 0 Thread 1 Thread 2 Thread 3 Thread 4 Game1() Game2() Render1() Render2() Gpu Execute() それぞれを並列化することができ、
設計方針 ◆ さらにGame()とRender()の処理を分割することができば・・・ 並列化して・・・ 更新頻度が早くなり、CPU使用率も上がります! 8.3ms Thread 0 Game1() Thread 1 Thread 2 Thread 3 Thread 4 結果として Game1() Game1() Game2() Game2() Game2() Render1() Render1() Render1() Render2() Render2() Render2() Gpu Execute() Gpu Execute() Gpu Execute() 更新頻度も早くなって、コアの使用数も増えます! これは、説明用にカナリ大雑把にした図になってしまっていますが、 「1フレーム処理をさらに分割して、CPU使用率を上げつつもフ レー ムレートも向上しよう!」という方針の方が、分かりやすく単純では ないかと考えました
設計方針 ◆ 全ての処理を並列化する事を目指さない ◆ 1つずつのシングルスレッド処理をパイプライン化して繋げる ◆ 結果的にマルチスレッド処理になるが、プログラマはシングルスレッド処理を記述 Thread 0 Thread 1 Thread 2 Thread 3 Thread 4 Game1() Game1() Game1() Game2() Game2() Game2() Render1() Render1() Render1() Render2() Render2() Render2() Gpu Execute() Gpu Execute() Gpu Execute() この方針では、個々の処理を並列化することを目指しません。 1つずつのシングルスレッド処理がパイプライン化して繋がります。 結果的にマルチスレッド処理になっていますが、すべてのプログラマ がマルチスレッドコードを書く必要が無くなります。
設計方針 ◆ 全ての処理を並列化する事を目指さない ◆ 1つずつのシングルスレッド処理をパイプライン化して繋げる ◆ 結果的にマルチスレッド処理になるが、プログラマはシングルスレッド処理を記述 FramePipelineSystem Thread 0 Thread 1 Thread 2 Thread 3 Thread 4 Game1() Game1() Game1() Game2() Game2() Game2() Render1() Render1() Render1() Render2() Render2() Render2() Gpu Execute() Gpu Execute() Gpu Execute() このシステムを「FramePipelineシステム」として構築する事を目指し ました
マルチスレッド設計 ✓ ✓ ✓ ✓ 設計方針 システム概要 システム活用例1:パラメータの受け渡し システム活用例2:バッファリングからの移行 それではシステムの概要に入ります。 先ほどの方針を、どんなシステムで実現するの?という点をご説明し ます。
システム概要 どんなシステム? Game Thread Render Thread GpuExec Thread - 先ほどの図と同様に、横軸が時間、縦軸がスレッドの図を用意しまし た。 同じフレームの処理毎に色分けされています。 3フレーム分の処理がGame,Render,GpuExecuteの3スレッドで並列化し ています。
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます Game Thread FramePipeline 1 Render Thread GpuExec Thread - 最初のフレームのGemeThreadが開始したタイミングで、 FramePipelineクラスがGameThreadに渡されます。
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます ◆ FramePipelineにFrameObjectデータを追加します Game Thread FramePipeline 1 Render Thread GpuExec Thread - GameThreadは、FramePipelineクラスに、FrameObjectと呼ばれる情報を 追加します
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます ◆ FramePipelineにFrameObjectデータを追加します ◆ FramePipelineが次の処理に渡されます Game Thread Render Thread FramePipeline 1 FramePipeline 1 GpuExec Thread - GameThreadが完了すると、後続処理のRenderThreadにFramePipelineク ラスが渡されます。
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます ◆ FramePipelineにFrameObjectデータを追加します ◆ FramePipelineが次の処理に渡されます ◆ FrameObjectの情報を元に、更にFrameObjectが追加されます Game Thread Render Thread FramePipeline 1 FramePipeline 1 GpuExec Thread - RenderThreadは受け取ったFramePipelineクラス内の情報を読み取り、新 規にFrameObjectを追加します
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます ◆ FramePipelineにFrameObjectデータを追加します ◆ FramePipelineが次の処理に渡されます ◆ FrameObjectの情報を元に、更にFrameObjectが追加されます ◆ フレーム毎に新しいFramePipelineが渡されます Game Thread Render Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 GpuExec Thread - そして、新たに走り始めたGameThreadには、別のFramePipelineクラス が渡され・・・
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます ◆ FramePipelineにFrameObjectデータを追加します ◆ FramePipelineが次の処理に渡されます ◆ FrameObjectの情報を元に、更にFrameObjectが追加されます ◆ フレーム毎に新しいFramePipelineが渡されます Game Thread Render Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 GpuExec Thread - そのフレーム用のGameObjectを追加します
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます ◆ FramePipelineにFrameObjectデータを追加します ◆ FramePipelineが次の処理に渡されます ◆ FrameObjectの情報を元に、更にFrameObjectが追加されます ◆ フレーム毎に新しいFramePipelineが渡されます Game Thread Render Thread GpuExec Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 - それぞれのFramePipelineクラスは、次の処理に渡され・・・
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます ◆ FramePipelineにFrameObjectデータを追加します ◆ FramePipelineが次の処理に渡されます ◆ FrameObjectの情報を元に、更にFrameObjectが追加されます ◆ フレーム毎に新しいFramePipelineが渡されます Game Thread Render Thread GpuExec Thread FramePipeline 1 FramePipeline 2 FramePipeline 3 FramePipeline 1 FramePipeline 2 FramePipeline 1 - 次のフレームのGameThreadには新たにFramePipelineクラスが渡されま す。
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます ◆ FramePipelineにFrameObjectデータを追加します ◆ FramePipelineが次の処理に渡されます ◆ FrameObjectの情報を元に、更にFrameObjectが追加されます ◆ フレーム毎に新しいFramePipelineが渡されます Game Thread Render Thread GpuExec Thread FramePipeline 1 FramePipeline 2 FramePipeline 3 FramePipeline 1 FramePipeline 2 FramePipeline 1 - GameThreadは、そのフレーム用のGameObjectを追加します
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます ◆ FramePipelineにFrameObjectデータを追加します ◆ FramePipelineが次の処理に渡されます ◆ FrameObjectの情報を元に、更にFrameObjectが追加されます ◆ フレーム毎に新しいFramePipelineが渡されます Game Thread Render Thread FramePipeline 1 FramePipeline 2 FramePipeline 3 FramePipeline 1 FramePipeline 2 FramePipeline 2 FramePipeline 1 FramePipeline 2 GpuExec Thread - この流れを繰り返します
システム概要 どんなシステム? ◆ FramePipelineクラスが処理に渡されます ◆ FramePipelineにFrameObjectデータを追加します ◆ FramePipelineが次の処理に渡されます ◆ FrameObjectの情報を元に、更にFrameObjectが追加されます ◆ フレーム毎に新しいFramePipelineが渡されます Game Thread Render Thread GpuExec Thread FramePipeline 1 FramePipeline 2 FramePipeline 3 FramePipeline 1 FramePipeline 2 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 2 - このように、フレーム毎に別のインスタンスのFramePipelineクラスが 作成され、 各 処理スレッドに受け渡されていくシステムでフレームのパイプライ ン化を行います
システム概要::まとめ ◆ 各フレーム毎に違うFramePipelineクラスが渡ってくる まとめると、 各フレーム毎に違うFramePipelineクラスが渡ってきます
システム概要::まとめ ◆ 各フレーム毎に違うFramePipelineクラスが渡ってくる ◆ FramePipelineにはデータ(FrameObject)を登録する ◆ FrameObjectは登録した時点で値が変更不可に FramePipelineにはFrameObjectという名のデータを登録します。 FrameObjectは登録した時点で値が変更できなくなるため、常に確定し た情報が埋め込まれます
システム概要::まとめ ◆ 各フレーム毎に違うFramePipelineクラスが渡ってくる ◆ FramePipelineにはデータ(FrameObject)を登録する ◆ FrameObjectは登録した時点で値が変更不可に ◆ 前処理のFrameObjectを参照し 後続処理に必要なFrameObjectを新たに登録していく 前処理のFrameObjectを参照し、 後続処理に必要なFrameObjectを新たに登録していくことでパイプライ ンがつながります。
システム概要::まとめ ◆ 各フレーム毎に違うFramePipelineクラスが渡ってくる ◆ FramePipelineにはデータ(FrameObject)を登録する ◆ FrameObjectは登録した時点で値が変更不可に ◆ 前処理のFrameObjectを参照し 後続処理に必要なFrameObjectを新たに登録していく ◆ 各パイプラインスレッドはFramePipelineクラスに情報をまとめる ◆ それぞれのスレッドの依存が無くなり、独立して実行が可能に 各パイプラインスレッドは FramePipelineクラスにデータをまとめる ことで、スレッド同士の依存関係が薄くなり、独立して実行が可能に なります
マルチスレッド設計 ✓ ✓ ✓ ✓ 設計方針 システム概要 システム活用例1:パラメータの受け渡し システム活用例2:バッファリングからの移行 今の話だと、全く意味が分からなかったかと思いますので、 2つの具体的な活用例を示しながら、理解を深めていきましょう
システム活用例::その1::Before クラスの状態と並列化問題 ◆ 「カメラ情報(位置、画角など)」を例に Game Thread Render Thread それではクラスの状態と並列化問題について解説します。 「位置や画角などのカメラ情報」を例に、「よくある実装」を説明し た後、「FramePipelineシステムでどう改善するか」を解説していこう と思います。 コチラの図は先ほどと同様に、GameThreadとRenderThreadになります
システム活用例::その1::Before クラスの状態と並列化問題 ◆ 「カメラ情報(位置、画角など)」を例に ◆ カメラの更新は毎フレーム行われます Game Thread Render Thread カメラの更新は毎フレーム行われます
システム活用例::その1::Before クラスの状態と並列化問題 ◆ 「カメラ情報(位置、画角など)」を例に ◆ カメラの更新は毎フレーム行われます ◆ 更新されるとGameCameraクラスの状態が変わります(内部変数の変更) Game Thread Render Thread Game Camera カメラの更新により、ゲームカメラクラスのメンバー変数の値が変わ ります。 オレンジ色のラインは「ゲームカメラのメンバー変数が ムの値になっているか」を表しています。 どのフレー ゲームスレッドで更新が行われると、次の更新が行われるまで、前フ レームの情報を保持している期間があることが分かります。
システム活用例::その1::Before クラスの状態と並列化問題 ◆ 「カメラ情報(位置、画角など)」を例に ◆ カメラの更新は毎フレーム行われます ◆ 更新されるとGameCameraクラスの状態が変わります(内部変数の変更) Game Thread Render Thread Game Camera そのフレーム内でゲームスレッドからGameCameraクラスの情報を参照 するのは問題ありませんが、
システム活用例::その1::Before クラスの状態と並列化問題 ◆ 「カメラ情報(位置、画角など)」を例に ◆ カメラの更新は毎フレーム行われます ◆ 更新されるとGameCameraクラスの状態が変わります(内部変数の変更) Game Thread Render Thread Game Camera 次フレームでも、カメラ更新前にゲームカメラを参照すると、前フ レームの情報となってしまいます
システム活用例::その1::Before クラスの状態と並列化問題 ◆ 「カメラ情報(位置、画角など)」を例に ◆ カメラの更新は毎フレーム行われます ◆ 更新されるとGameCameraクラスの状態が変わります(内部変数の変更) Game Thread Render Thread Game Camera 別のスレッドから参照すると、変数がスレッドセーフでも どのフレームの情報が取得できるかが、タイミング次第となります
システム活用例::その1::Before クラスの状態と並列化問題 ◆ 「カメラ情報(位置、画角など)」を例に ◆ カメラの更新は毎フレーム行われます ◆ 更新されるとGameCameraクラスの状態が変わります(内部変数の変更) ◆ RenderCamreaクラスがRenderスレッド用の情報を保持します Game Thread Render Thread Game Camera Render Camera RenderThreadで安定した値を取得するために、RenderCameraというク ラスを用意しました。 これは専用のクラスである必要はありません、 GameとRenderの同期タイミングで、Renderスレッドにカメラ情報をコ ピーします
システム活用例::その1::Before クラスの状態と並列化問題 ◆ 「カメラ情報(位置、画角など)」を例に ◆ カメラの更新は毎フレーム行われます ◆ 更新されるとGameCameraクラスの状態が変わります(内部変数の変更) ◆ RenderCamreaクラスがRenderスレッド用の情報を保持します Game Thread Render Thread Game Camera Render Camera これで、それぞれのスレッドで安全にパラメータにアクセスで出来る ようになります。 これが弊社エンジンにおける、従来の設計です。
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう Game Thread Render Thread Game Camera それではFramePipelineSystemを使用してみましょう GameThread,RenderThread,GameCameraのクラス更新の部分までは同じ です。従来通りの実装を行います
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう Game Thread FramePipeline 1 Render Thread Game Camera FramePipeline 1 GameThreadにはFramePipelineクラスが渡されています グラフの下に、FramePipelineクラス内のカメラ情報を表す図も追加し ました。
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう ◆ そのフレームにおける、「最終決定されたカメラ情報」が登録されます Game Thread FramePipeline 1 Render Thread Game Camera FramePipeline 1 GameCameraの更新と同時に FramePipelineにカメラ情報を登録します FramePipelineに登録された情報は変更できないので、このFramePipeline には、そのフレームにおける、「最終決定されたカメラ情報」が登録 されることになります
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう ◆ そのフレームにおける、「最終決定されたカメラ情報」が登録されます Game Thread Render Thread FramePipeline 1 FramePipeline 1 Game Camera FramePipeline 1 そのFramePipelineは、後続のRenderThreadに渡されます
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう ◆ そのフレームにおける、「最終決定されたカメラ情報」が登録されます ◆ 現在のFramePipelineから情報を取得することで、正しいカメラ情報が取得できます Game Thread Render Thread FramePipeline 1 FramePipeline 1 Game Camera FramePipeline 1 各スレッドは、GameCameraクラスではなく、現在渡されている FramePipelineクラスからカメラ情報を取得することで、 確実にそのフレームのカメラ情報が取得できるようになりました。
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう ◆ そのフレームにおける、「最終決定されたカメラ情報」が登録されます ◆ 現在のFramePipelineから情報を取得することで、正しいカメラ情報が取得できます Game Thread FramePipeline 2 Render Thread Game Camera FramePipeline 1 FramePipeline 2 次のフレームのFramePipelineクラスにでも同様の処理が行われます。 ここで、FramePipelineクラスのインスタンスが別であるという事に注 目してください。 次のフレーム用のFramePipelineを図に追加しました。 こちらのFramePipelineクラスには、まだカメラ情報が登録されていま せん。
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう ◆ そのフレームにおける、「最終決定されたカメラ情報」が登録されます ◆ 現在のFramePipelineから情報を取得することで、正しいカメラ情報が取得できます Game Thread FramePipeline 2 Render Thread Game Camera FramePipeline 1 FramePipeline 2 GameCameraクラスは、現在のGameスレッドに渡されている FramePipelineクラスに対して、そのフレームのカメラ情報を登録しま す。 1フレーム目と2フレーム目で FramePipelineクラスは別になります。 そのため、FramePipelineクラスには、それぞれのフレームのカメラ情 報が1回だけ登録されることになります。
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう ◆ そのフレームにおける、「最終決定されたカメラ情報」が登録されます ◆ 現在のFramePipelineから情報を取得することで、正しいカメラ情報が取得できます Game Thread Render Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 このように、それぞれのスレッドのタイミングで適切なFramePipeline クラスが存在することで
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう ◆ そのフレームにおける、「最終決定されたカメラ情報」が登録されます ◆ 現在のFramePipelineから情報を取得することで、正しいカメラ情報が取得できます Game Thread Render Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 正しいフレーム情報が取得可能になります。
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう ◆ そのフレームにおける、「最終決定されたカメラ情報」が登録されます ◆ 現在のFramePipelineから情報を取得することで、正しいカメラ情報が取得できます Game Thread Render Thread **** Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 そして、パイプラインスレッドを増やしても・・・・
システム活用例::その1::After クラスの状態と並列化問題 ◆ FramePipelineを使用してみましょう ◆ そのフレームにおける、「最終決定されたカメラ情報」が登録されます ◆ 現在のFramePipelineから情報を取得することで、正しいカメラ情報が取得できます Game Thread Render Thread **** Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipelineクラスから情報を取得すれば、全く問題ありません これが活用例の1つ目となります。
システム活用例::その2::Before スレッド対応済のダブルバッファ・トリプルバッファ Game Thread Render Thread GPU それでは、もう1つの活用例を説明しながら、少し踏み込んでいきま しょう。 スレッドをまたぐ処理に対応した「ダブルバッファ」「トリプルバッ ファ」の実装改善をご説明します。 オブジェクト指向なクラスとの熱い戦いになります 今回は、GPUで利用するメモリが絡むため、GPUを紫色のバーで追加し ました。 GPUがアクセスする可能性があるリソースは、その期間保持していな いといけません。
システム活用例::その2::Before スレッド対応済のダブルバッファ・トリプルバッファ ◆ 毎フレーム更新される描画リソース(定数バッファなど)について ◆ 各スレッドで変更、GPUが使用されるまで保持する必要があるため、バッファリングを行う Game Thread Render Thread GPU Graphics Object - 今回は毎フレーム更新される定数バッファを想定していただいて構い ません。 例としては、ゲームの状況に合わせてアニメーションするマテリアル などが当てはまります。 各スレッドで表示したいパラメータが決定したら、そのフレームの処 理がGPUで消化されるまで、表示させたい値を保持しておかなければ いけません このクラスをGraphicsObjectとして図に追加しました
システム活用例::その2::Before スレッド対応済のダブルバッファ・トリプルバッファ ◆ 毎フレーム更新される描画リソース(定数バッファなど)について ◆ 各スレッドで変更、GPUが使用されるまで保持する必要があるため、バッファリングを行う Game Thread Render Thread GPU Graphics Object Frame 1 - こういった実装で、一般的なのはダブルバッファリング、トリプル バッファリングという手法かと思います。 今回の場合だと、同じパラメータの配列を持ち、GameThreadにて、パ ラメータを設定します
システム活用例::その2::Before スレッド対応済のダブルバッファ・トリプルバッファ ◆ 毎フレーム更新される描画リソース(定数バッファなど)について ◆ 各スレッドで変更、GPUが使用されるまで保持する必要があるため、バッファリングを行う Game Thread Render Thread GPU Graphics Object Frame 1 Frame 1 - Frame 2 - - 次のフレームでは、前回のパラメータを残しておき、違うインデック スにそのフレームの新しい値を設定します。
システム活用例::その2::Before スレッド対応済のダブルバッファ・トリプルバッファ ◆ 毎フレーム更新される描画リソース(定数バッファなど)について ◆ 各スレッドで変更、GPUが使用されるまで保持する必要があるため、バッファリングを行う Game Thread Render Thread GPU Graphics Object Frame 1 Frame 1 - Frame 2 - - インデックスを変更するフリップ処理が、切り替えタイミングで走っ ていることになります
システム活用例::その2::Before スレッド対応済のダブルバッファ・トリプルバッファ ◆ 毎フレーム更新される描画リソース(定数バッファなど)について ◆ 各スレッドで変更、GPUが使用されるまで保持する必要があるため、バッファリングを行う Game Thread Render Thread GPU Graphics Object Frame 1 Frame 1 Frame 1 - Frame 2 Frame 2 - - Frame 3 次のフレームになると、また違うインデックスにそのフレームのパラ メータを設定します。
システム活用例::その2::Before スレッド対応済のダブルバッファ・トリプルバッファ ◆ 毎フレーム更新される描画リソース(定数バッファなど)について ◆ 各スレッドで変更、GPUが使用されるまで保持する必要があるため、バッファリングを行う Game Thread Render Thread GPU Graphics Object Frame 1 Frame 1 Frame 1 - Frame 2 Frame 2 - - Frame 3 このタイミングで、1フレーム目のパラメータをGPUが参照します。
システム活用例::その2::Before スレッド対応済のダブルバッファ・トリプルバッファ ◆ 毎フレーム更新される描画リソース(定数バッファなど)について ◆ 各スレッドで変更、GPUが使用されるまで保持する必要があるため、バッファリングを行う Game Thread Render Thread GPU Graphics Object Frame 1 Frame 1 Frame 1 Frame 4 - Frame 2 Frame 2 Frame 2 - - Frame 3 Frame 3 GPUが使用したら、過去のパラメータが不要になるので、書き込みイ ンデックスが一周し、1フレーム目のパラメータが上書きされます これが繰り返し動作することで、ゲームスレッドとGPUが安全にパラ メータにアクセスできる仕組みです。 弊社エンジンでは、ゲームスレッドで値が更新される描画オブジェク トは、ホボ全てこの方法で実装されていました。
システム活用例::その2::Before スレッド対応済のダブルバッファ・トリプルバッファ ◆ 毎フレーム更新される描画リソース(定数バッファなど)について ◆ 各スレッドで変更、GPUが使用されるまで保持する必要があるため、バッファリングを行う ◆ もう1段階スレッドが増えると、バッファリング数が増えてしまう ◆ GameThreadとRenderThreadの両方から更新が入るオブジェクトでミスが多くなる Game Thread Render Thread GPU Graphics Object Frame 1 Frame 1 Frame 1 Frame 4 - Frame 2 Frame 2 Frame 2 - - Frame 3 Frame 3 ただ、想像して分かる通り、この仕組みだとGameThreadと RenderThread意外にもう1つパイプラインスレッドが増えると、バッ ファリング数が増えてしまいます。 また、GameThreadとRenderThreadの両方から更新が入るようなオブ ジェクトの場合、アクセスする場所を間違えるなどの、ミスが多くな るという問題も抱えていました。
システム活用例::その2::After スレッド対応済のダブルバッファ・トリプルバッファ ◆ FramePipelineを使用してみましょう Game Thread FramePipeline 1 **** Thread **** Thread Render Thread GPU それではFramePipelineクラスを使用してみましょう 今度は最初から、GameThreadとRenderThreadの間に2スレッド追加し てみました
システム活用例::その2::After スレッド対応済のダブルバッファ・トリプルバッファ ◆ FramePipelineを使用してみましょう ◆ FramePipelineにGraphicsObjectの描画用情報を登録します Game Thread FramePipeline 1 **** Thread **** Thread Render Thread GPU FramePipelineに描画したいオブジェクト情報を設定します
システム活用例::その2::After スレッド対応済のダブルバッファ・トリプルバッファ ◆ FramePipelineを使用してみましょう ◆ FramePipelineにGraphicsObjectの描画用情報を登録します Game Thread **** Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 **** Thread Render Thread GPU そのままフレーム処理が進みます もちろん次のフレームでも新しいFramePipelineに情報を追加します
システム活用例::その2::After スレッド対応済のダブルバッファ・トリプルバッファ ◆ FramePipelineを使用してみましょう ◆ FramePipelineにGraphicsObjectの描画用情報を登録します ◆ 好きなタイミングで登録することができます Game Thread **** Thread **** Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 Render Thread GPU おっと、2フレーム目のFramePipelineに、GameThread以外からも同じ種 類の別パラメータが追加されました!
システム活用例::その2::After スレッド対応済のダブルバッファ・トリプルバッファ ◆ FramePipelineを使用してみましょう ◆ FramePipelineにGraphicsObjectの描画用情報を登録します ◆ 好きなタイミングで登録することができます Game Thread **** Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 **** Thread Render Thread FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 GPU 何とRenderThreadから、もう一つパラメータが追加されています! とてもやんちゃな実装ですね。
システム活用例::その2::After スレッド対応済のダブルバッファ・トリプルバッファ ◆ FramePipelineを使用してみましょう ◆ FramePipelineにGraphicsObjectの描画用情報を登録します ◆ 好きなタイミングで登録することができます ◆ GPU使用後にFramePipelineと共に破棄されます Game Thread **** Thread **** Thread Render Thread FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 FramePipeline 2 FramePipeline 1 GPU FramePipeline 2 FramePipeline 1 しかし、特に問題ありません。そのままのパラメータが変更されるこ となく、確実にGPUに届きます。 ダブルバッファ、トリプルバッファの構造は必要なくなり、好きなタ イミングで描画に必要なパラメータを追加するだけです。 オブジェクトは、GPU処理後に破棄されます。 各グラフィクス用のクラスは「決定した値をFramePipelineに登録する 関数」を持つだけになります。
システム活用例::その2::After GPUリソースのプール実装(定数バッファプールの例) ◆ 256、512バイトなどの固定サイズのバッファを複数持つ ◆ 必要になったらプールから取得、不要になったらプールに戻す ◆ 足りなくなったら「プールが管理するバッファ数」を増やす Game Thread **** Thread **** Thread Render Thread GPU Graphics Resource Pool FramePipeline 1 FramePipeline 1 FramePipeline 1 FramePipeline 1 FramePipeline 1 注意点としては、GPUリソースは、メモリ的にも生成負荷的にも高価 な場合があります。 そのため、一部GPUリソースには、プール機能を作成してもらいまし た。 例えば、定数バッファプールは256,512バイトなど固定サイズの定数 バッファを1024個ずつ持っているとします。 ゲーム全体で、このプール内から定数バッファを取得し、不要になっ たタイミングで返却します。 プール内のリソースが足りなくなると、新たに1024個の定数バッファ をプールに追加するような仕組みです。
システム活用例::その2::After GPUリソースのプール実装(定数バッファプールの例) ◆ 256、512バイトなどの固定サイズのバッファを複数持つ ◆ 必要になったらプールから取得、不要になったらプールに戻す ◆ 足りなくなったら「プールが管理するバッファ数」を増やす ◆ できるだけGPUコマンド生成に近いタイミングでプールから取得する Game Thread **** Thread **** Thread Render Thread GPU Graphics Resource Pool FramePipeline 1 FramePipeline 1 FramePipeline 1 FramePipeline 1 FramePipeline 1 GPUスレッド手前のRenderThreadにてPoolからGPUリソースを取得し
システム活用例::その2::After GPUリソースのプール実装(定数バッファプールの例) ◆ 256、512バイトなどの固定サイズのバッファを複数持つ ◆ 必要になったらプールから取得、不要になったらプールに戻す ◆ 足りなくなったら「プールが管理するバッファ数」を増やす ◆ できるだけGPUコマンド生成に近いタイミングでプールから取得する ◆ GPU処理完了時にプールに戻す Game Thread **** Thread **** Thread Graphics Resource Pool FramePipeline 1 FramePipeline 1 FramePipeline 1 Render Thread GPU FramePipeline 1 FramePipeline 1 GPU処理の完了と共にプールに返却します。 この機能と組み合わせることで、必要最低限のGPUリソースに近づけ ることができます。 ただ、プールという仕組み上、余分に生成されるバッファ数が多くな るため、プールサイズの調整が必要になります。
システム活用例::まとめ ◆ 今までの「よくある実装」が、とてもシンプルになっていると思いませんか? ◆ スレッドを増やしても、実装が難しくならない ◆ 今までの方が、難しい実装をしていたのではないか? 今までの「よくある実装」が、とてもシンプルになっていると思いま せんか? スレッドを増やすことで、難しくなると思われた実装が、意外と単純 に見えます 逆に今までの方が難しい方法で実装していたのではないか?と感じま す。
FramePipelineSytem実装 ✓ ✓ ✓ ✓ ✓ FrameAllocator C++基本操作 FramePipeline FramePipelineの受け渡し FrameThread ここからは、先ほどのFramePipelineシステムを、どうやって実装した のか? という話になります。 アロケータの話から入り、C++でのFramePipelineの使用方法。 FramePipelineの内部実装。 どのようにFramePipelineを受け渡すのか、 そして、FramePipelineシステムのスレッド構成をどのように作るのか を説明していきます。
FrameAllocator ◆ FrameAllocator ◆ メモリブロックの先頭から埋めていき、終端を保持するだけのアロケータ atomic<int> Memory Data1 Data2 Data3 まず最初に、FrameObjectを生成するアロケータについての説明をしま す。 FrameAllocatorは、メモリブロックの先頭からデータを埋めていき、終 端位置を保持するだけのアロケータです。 生成されたFrameObjectが順番に並ぶことになります。
FrameAllocator ◆ FrameAllocator ◆ メモリブロックの先頭から埋めていき、終端を保持するだけのアロケータ ◆ 解放処理は個別に行えず、確保メモリの全解放のみ Memory Data1 Data2 Data3 解放処理は個別に行えず、メモリの全解放のみ対応しています
FrameAllocator ◆ FrameAllocator ◆ メモリブロックの先頭から埋めていき、終端を保持するだけのアロケータ ◆ 解放処理は個別に行えず、確保メモリの全解放のみ ◆ 大きめに仮想メモリ領域をリザーブ Memory Data1 Data2 Data3 Reserve Commit 最初に、十分な量の仮想メモリをリザーブしておき、
FrameAllocator ◆ FrameAllocator ◆ メモリブロックの先頭から埋めていき、終端を保持するだけのアロケータ ◆ 解放処理は個別に行えず、確保メモリの全解放のみ ◆ 大きめに仮想メモリ領域をリザーブ ◆ 足りなくなった場合は、物理メモリを後ろに追加 ◆ 物理メモリの確保時以外はロックフリーで動作 Memory Data1 Data2 Data3 物理メモリ 64KB 64KB 64KB 64KB Data4 Reserve Commit メモリ領域が足りなくなった場合に、物理メモリを追加していくとい う流れです。 物理メモリの拡張時以外は、アトミック変数を足すだけなので、ロッ クフリーで動作します <メモ> 64KBは参考数字です。実際にはゲームに合わせて、初期確保サイズと、 拡張サイズを調整します。 物理メモリのマッピングは重いため、基本的には実行されない方が良 いですが、限定的なシーンでエラーにならないように、拡張をサポー トします。
FrameAllocator::FramePipeline ◆ FramePipelineクラスはインスタンスごとにFrameAllocatorを複数持つ FramePipeline FrameAllocator [ ] Game Render Gpu Global FrameAllocatorは、1つのFramePipelineクラスに複数個 存在します この例では、Game/Render/Gpu/Globalなどです。 図には表示していないですが、開発時のDebug用アロケータなどもあり ます
FrameAllocator::FramePipeline ◆ FramePipelineクラスはインスタンスごとにFrameAllocatorを複数持つ ◆ FrameObjectはどのアロケータで生成されるか決まっている FrameObject型 AllocatorType FO_Sample Game FO_ViewInfo Render FO_TimeStamp Global FramePipeline FrameAllocator [ ] Game Render Gpu Global FrameObjectは、型毎に どのアロケータで生成されるかが決まってい ます。 ランタイムで変更することはできず、「この型であれば、このアロ ケータで生成される」 という事が決定されています
FrameAllocator::FramePipeline ◆ FramePipelineクラスはインスタンスごとにFrameAllocatorを複数持つ ◆ FrameObjectはどのアロケータで生成されるか決まっている ◆ アロケータタイプごとに寿命が違う FrameObject型 AllocatorType FO_Sample Game FO_ViewInfo Render FO_TimeStamp Global FramePipeline Game Thread FrameAllocator [ ] Render Thread Game Render GPU Gpu Global Allocator Lifetime 16Frame… アロケータタイプごとに寿命が違い、 GameThread終了時に破棄されるもの、 RenderThread終了時に破棄されるもの、 GPU実行完了後に破棄されるもの、 16Frame保持されるものなど、用途に合わせて設定されています
FrameAllocator::FramePipeline ◆ FramePipelineクラスはインスタンスごとにFrameAllocatorを複数持つ ◆ FrameObjectはどのアロケータで生成されるか決まっている ◆ アロケータタイプごとに寿命が違う FrameObject型 ◆ アロケータの終了タイミングで関連FrameObjectが破棄 FO_Sample ◆ アロケータの寿命外での生成・参照時にはASSERT AllocatorType Game FO_ViewInfo Render FO_TimeStamp Global FramePipeline Game Thread FrameAllocator [ ] Render Thread Game Render GPU Destructor Gpu Global Allocator Lifetime 16Frame… アロケータ終了タイミングになると、そのアロケータに紐づいた FrameObjectは全て破棄されます。 アロケータの寿命外でFrameObjectを生成したり、参照しようとすると 開発時はASSERTになります。 これにより、マルチスレッドでの不正なタイミングでの取得の発見を 早めることができます
FramePipeline::C++
◆ FrameObjectの作成・登録
{
// FrameObjectの作成。FrameObjectのポインタではなく、ポインタっぽい特殊な型が返る
auto obj = frame_pipeline->CreateFrameObject< FrameObjectSample >();
// 作成後、設定を行う
obj->SetInt(777);
// 「Registerを呼ぶ」or「Scopeが外れる」とFramePipelineに登録される
// Register後はアクセスできない(nullptr参照になります)
// 使用アロケータの終了時にデストラクタが呼ばれる
obj.Register();
// キャンセルも可能
// その場でデストラクタが呼ばれる
// obj->RegisterCancel();
}
FrameObjectを作成するC++コードの紹介です。
FramePipelineクラスから、CreateObject関数を呼ぶことでFrameObjectが
作成されます。
実態ではなく、ポインタのように扱えるラップされたクラスが返りま
す。
FramePipeline::C++
◆ FrameObjectの作成・登録
{
// FrameObjectの作成。FrameObjectのポインタではなく、ポインタっぽい特殊な型が返る
auto obj = frame_pipeline->CreateFrameObject< FrameObjectSample >();
// 作成後、設定を行う
obj->SetInt(777);
// 「Registerを呼ぶ」or「Scopeが外れる」とFramePipelineに登録される
// Register後はアクセスできない(nullptr参照になります)
// 使用アロケータの終了時にデストラクタが呼ばれる
obj.Register();
// キャンセルも可能
// その場でデストラクタが呼ばれる
// obj->RegisterCancel();
}
FrameObjectの値を設定し
FramePipeline::C++
◆ FrameObjectの作成・登録
{
// FrameObjectの作成。FrameObjectのポインタではなく、ポインタっぽい特殊な型が返る
auto obj = frame_pipeline->CreateFrameObject< FrameObjectSample >();
// 作成後、設定を行う
obj->SetInt(777);
// 「Registerを呼ぶ」or「Scopeが外れる」とFramePipelineに登録される
// Register後はアクセスできない(nullptr参照になります)
// 使用アロケータの終了時にデストラクタが呼ばれる
obj.Register();
// キャンセルも可能
// その場でデストラクタが呼ばれる
// obj->RegisterCancel();
}
Register関数を呼ぶか、スコープから外れたタイミングで、
FramePipelineに登録されます。
今回開発したタイトルでは、Registerを明示的に呼ぶようなケースはあ
りませんでした。
FramePipeline::C++
◆ FrameObjectの作成・登録
{
// FrameObjectの作成。FrameObjectのポインタではなく、ポインタっぽい特殊な型が返る
auto obj = frame_pipeline->CreateFrameObject< FrameObjectSample >();
// 作成後、設定を行う
obj->SetInt(777);
// 「Registerを呼ぶ」or「Scopeが外れる」とFramePipelineに登録される
// Register後はアクセスできない(nullptr参照になります)
// 使用アロケータの終了時にデストラクタが呼ばれる
obj.Register();
// キャンセルも可能
// その場でデストラクタが呼ばれる
// obj->RegisterCancel();
}
作成してしまったけど、何かしらのエラーで登録したくない場合は、
キャンセルも可能です
FramePipeline::C++
◆ FrameObjectの取得・使用
◆ 単体
// ポインタのような特殊な型が返ります。開発時のみ参照タイミングチェックを行います
if (auto obj = frame_pipeline->GetFrameObject< FrameObjectSample >())
{
int value = obj->GetInt();
}
取得コードの紹介です。
FramePipelineクラスからGetFrameObjectで型を指定することで、その型
が存在していた場合にアクセス可能になります。
コチラも、ポインタのような型を返しており、開発時のみ、
FramePipelineクラスの寿命を超えて参照していないかなどのチェック
を行います
FramePipeline::C++
◆ FrameObjectの取得・使用
◆ 単体
// ポインタのような特殊な型が返ります。開発時のみ参照タイミングチェックを行います
if (auto obj = frame_pipeline->GetFrameObject< FrameObjectSample >())
{
int value = obj->GetInt();
}
◆ リスト
// 他スレッドで追加されている状態でも、リストの中身が変わらないことが保証されています
auto list = frame_pipeline->GetFrameObjectList< FrameObjectSample >();
for (auto&& obj : list)
{
int value = obj.GetInt();
}
FrameObjectは、1つの型を複数個登録できますので、リストで取得す
ることも可能です。
取得したリストは、参照中に中身が変わらないことを保証しています。
FramePipeline::内部実装 ◆ FramePipelineに登録されるFrameObjectには、型毎にユニークなIDが割り振られます FrameObject型 ID FO_Sample 0 FO_ViewInfo 1 FO_DirectionalLight 2 それでは、FramePipelineクラス内のObject管理の実装を説明します。 FramePipelineに登録されるFrameObjectには、型毎に「ユニークなID」 が割り振られます。 このIDは0から連番で割り振られます。
FramePipeline::内部実装 ◆ FramePipelineに登録されるFrameObjectには、型毎にユニークなIDが割り振られます ◆ FramePipelineクラスは、FrameObjectの型毎にリストを持ちます FramePipeline LockFreeList [ ] FrameObject型 ID [0] FO_Sample FO_Sample 0 FO_ViewInfo 1 FO_DirectionalLight 2 [1] FO_ViewInfo [2] FO_DirectionalLight FramePipelineのインスタンスは、FrameObjectのIDの数だけリストを所 持しており、型とリストが1対1で紐づいています。
FramePipeline::内部実装 ◆ FramePipelineに登録されるFrameObjectには、型毎にユニークなIDが割り振られます ◆ FramePipelineクラスは、FrameObjectの型毎にリストを持ちます FramePipeline LockFreeList [ ] FrameObject型 ID [0] FO_Sample FO_Sample 0 FO_ViewInfo 1 FO_DirectionalLight 2 FO_Sample FO_Sample [1] FO_ViewInfo [2] FO_DirectionalLight FO_DirectionalLight FramePipelineに登録されたオブジェクトは、自分の型のIDをインデック スとして、型毎に個別のロックフリーリストに挿入されます。 リストは単方向になっており、一度FramePipelineに登録されたら、個 別に削除されることが無いので、ロックフリーリストの実装も簡単で す。 <メモ> Atomicなポインタ1つで、あとは内包型の単方向リストを繋げるだけ で実現できます。
FramePipeline::内部実装 ◆ FramePipelineに登録されるFrameObjectには、型毎にユニークなIDが割り振られます ◆ FramePipelineクラスは、FrameObjectの型毎にリストを持ちます ◆ 取得したタイミングで単方向リストの先頭を返します FramePipeline LockFreeList [ ] FrameObject型 ID FO_Sample [0] FO_Sample FO_Sample 0 FO_ViewInfo 1 FO_DirectionalLight 2 FO_Sample [1] FO_ViewInfo [2] FO_DirectionalLight GetFrameObject<FO_Sample> ptr FrameObjectを単体で取得した場合は、リストの先頭のオブジェクトを 返します。 単方向リストの特性上、最後に登録されたオブジェクトが返されるこ とが保証されており、一般的な需要とマッチします <メモ> FrameObjectが1つしか利用されない場合、「最後に追加されたオブ ジェクトを優先的に使用する」といった実装が行えます。 デバッグ目的で上書きする場合などに簡単に使用可能です。
FramePipeline::内部実装 ◆ FramePipelineに登録されるFrameObjectには、型毎にユニークなIDが割り振られます ◆ FramePipelineクラスは、FrameObjectの型毎にリストを持ちます ◆ 取得したタイミングで単方向リストの先頭を返します FramePipeline LockFreeList [ ] FrameObject型 ID FO_Sample [0] FO_Sample FO_Sample 0 FO_ViewInfo 1 FO_DirectionalLight 2 FO_Sample [1] FO_ViewInfo [2] FO_DirectionalLight GetFrameObject<FO_Sample> ptr GetFrameObjectList<FO_Sample> FrameObjectをリストで取得した場合は、取得したタイミングでの 「ロックフリーリストの先頭」をbeginとした、参照専用のリストを返 します begin
FramePipeline::内部実装 ◆ FramePipelineに登録されるFrameObjectには、型毎にユニークなIDが割り振られます ◆ FramePipelineクラスは、FrameObjectの型毎にリストを持ちます ◆ 取得したタイミングで単方向リストの先頭を返します FramePipeline LockFreeList [ ] FrameObject型 New! ID FO_Sample [0] FO_Sample FO_Sample 0 FO_ViewInfo 1 FO_DirectionalLight 2 FO_Sample FO_Sample [1] FO_ViewInfo [2] FO_DirectionalLight GetFrameObject<FO_Sample> ptr GetFrameObjectList<FO_Sample> 取得したオブジェクトを参照中に、新規にオブジェクトが登録された 場合も、 参照しているリストに変更が入ることが無いため、安全に使用できま す。 begin
FramePipeline::内部実装 ◆ FramePipelineに登録されるFrameObjectには、型毎にユニークなIDが割り振られます ◆ FramePipelineクラスは、FrameObjectの型毎にリストを持ちます ◆ 取得したタイミングで単方向リストの先頭を返します FramePipeline LockFreeList [ ] FrameObject型 ID FO_Sample [0] FO_Sample FO_Sample 0 FO_ViewInfo 1 FO_DirectionalLight 2 FO_Sample FO_Sample [1] FO_ViewInfo [2] FO_DirectionalLight GetFrameObject<FO_Sample> ptr GetFrameObjectList<FO_Sample> もちろん、もう一度取得しなおすことで、最新の状態を得ることがで きます。 <メモ> 実際には、こういった用途で使用することは少ないですが、 「使用した場合に安全であること」「前回取得した情報(順番)が変 更されていない事」が重要です。 begin
FramePipeline ◆ ロックフリーな挿入、参照が可能に! ◆ 基本的には、マルチスレッドコードを書かなくてOK! ◆ Thread / Atomic / Mutex / ConditionVariable… 使うのはとても簡単! ロックフリーな挿入と参照が可能になり、マルチスレッドで軽量な実 装になりました。 基本的には、マルチスレッドコードを書くことなく利用できます。 使うのはとても簡単です。
FramePipeline::受け渡し ◆ どのように既存システムにFramePipelineクラスを渡していくか? ◆ 各管理クラスにFramePipelineクラスを渡す? ◆ Update引数にFramePipelineクラスを強制する? ◆ それぞれのスレッド毎に、それぞれのSingletonクラス作る? 本当に正しいタイミングのFramePipelineクラスを使用しているのか? どれもミスが発生しやすい! FramePipelineクラスの使用方法は簡単でしたが、少し問題があります。 それは、どのように既存システムにFramePipelineクラスを渡していく か? という点です。 各「管理クラス」にFramePipelineクラスを渡すのか、更新関数の引数 にFramePipelineクラスを渡すことを強制するのか、 それぞれのスレッドからアクセスするための、専用Singletonクラスを作 るのか? FramePipelineSystemの肝は、正しいタイミングのFramePipelineクラスを キチンと参照することで成り立ちます。 上記の実装だと、どれもミスが発生する可能性があり、さらに既存 コードの修正も多くなりそうでした。
FramePipeline::受け渡し ◆ そもそも、1つの関数内で複数フレームの処理を行うコードはホトンド無い ◆ 並列動作する部分はスレッドが分かれている 受け渡し方法はかなり悩んだのですが、組み込んでいる最中に、ある ことに気づきました。 大元のメインループを除くと、1つの関数内で、複数フレームの処理 を行うコードはありません。 並列動作する部分はスレッドで分かれています。
FramePipeline::受け渡し ◆ そもそも、1つの関数内で複数フレームの処理を行うコードはホトンド無い ◆ 並列動作する部分はスレッドが分かれている ThreadLocalで解決! { // このマクロのスコープ範囲内でFramePipelineが取得できる TP_FRAME_THREAD_PIPELINE_SCOPE( frame_pipeline ); Game(); } そう。答えは単純でスレッドローカルを使用すれば良かっただけでし た。 スコープに対してフレームパイプラインクラスを割り当て、
FramePipeline::受け渡し ◆ そもそも、1つの関数内で複数フレームの処理を行うコードはホトンド無い ◆ 並列動作する部分はスレッドが分かれている ThreadLocalで解決! { // このマクロのスコープ範囲内でFramePipelineが取得できる TP_FRAME_THREAD_PIPELINE_SCOPE( frame_pipeline ); Game(); } { // 基本的にはFramePipelineが取得可能。不正呼び出しはASSERT auto frame_pipeline = GetFrameThreadPipeline(); } 専用関数で取得できるようにしました。 スレッドローカルに設定されていないタイミングで呼び出すと、ア サートが発生するようにしています。 ただ、これでも少し問題が発生します。
FramePipeline::受け渡し
◆ ThreadLocalだとワーカースレッドに投げるジョブで取れない!
job_system->PushJob("JobTest",
[]() {
// 別スレッドで呼ぶとFramePipelineが取得できずにASSERT
auto frame_pipeline = GetFrameThreadPipeline();
});
スレッドローカルを利用すると、
ワーカースレッドに投げたジョブの中では別スレッドになるため、
FramePipelineクラスが取得できません。
ワーカースレッドは、違うフレーム処理を実行している複数のスレッ
ドから呼び出されるのため、ワーカースレッド自体にFramePipelineク
ラスを設定することもできません。
FramePipeline::受け渡し
◆ ThreadLocalだとワーカースレッドに投げるジョブで取れない!
job_system->PushJob("JobTest",
[]() {
// 別スレッドで呼ぶとFramePipelineが取得できずにASSERT
auto frame_pipeline = GetFrameThreadPipeline();
});
◆ ジョブシステム側でFramePipelineを引き継ぐようにして解決
template<class Func>
void PushJob(const char* job_name, const Func& func)
{
// 呼び出しスレッドのパイプラインを引き継ぐ
auto current_thread_pipeline = detail::GetFrameThreadPipelineNullable();
normal_jobs_.PushJob(job_name,
[=]
{
TP_FRAME_THREAD_PIPELINE_SCOPE( current_thread_pipeline );
func();
});
}
コチラは、エンジンが提供するSingletonのジョブシステムを利用する場
合に限り、FramePipelineクラスを自動で引き継ぐようにして解決しま
した
ジョブがプッシュされた場合に、スレッドローカルからFramePipeline
クラスを取得し、
存在する場合は、ジョブが動作する前にスレッドローカルに設定する
だけです。
FramePipeline::受け渡し ◆ ほぼ全てのコードで正しいFramePipelineを利用できるように ◆ ジョブシステムを使用しないスレッドのみ自前で受け渡し ほぼ全てのコードで、正しいタイミングのFramePipelineクラスを利用 できるようになりました。 例外としては、ジョブシステムを使用しないスレッドを利用した処理 だけです。
FramePipeline::受け渡し ◆ ほぼ全てのコードで正しいFramePipelineを利用できるように ◆ ジョブシステムを使用しないスレッドのみ自前で受け渡し ◆ 既存コードの修正無しでFramePipelineシステムの組み込みが可能に 更に、既存コードの修正無しで組み込みも可能になりました。
FramePipeline::受け渡し ◆ ほぼ全てのコードで正しいFramePipelineを利用できるように ◆ ジョブシステムを使用しないスレッドのみ自前で受け渡し ◆ 既存コードの修正無しでFramePipelineシステムの組み込みが可能に ◆ ワーカースレッドがどのフレーム処理を行っていたかの追跡も可能に 副次効果となりますが、ランタイムで表示することのできる、CPUマー カー/FlameGraph機能があったのですが、 ワーカースレッドが、どのフレームに関わるジョブを実行しているの かも確認出来るようになりました。
FramePipeline::受け渡し ◆ ほぼ全てのコードで正しいFramePipelineを利用できるように ◆ ジョブシステムを使用しないスレッドのみ自前で受け渡し ◆ 既存コードの修正無しでFramePipelineシステムの組み込みが可能に ◆ ワーカースレッドがどのフレーム処理を行っていたかの追跡も可能に この図は、ランタイムで2フレーム分のスレッド状況を表示する機能 なんですが、1フレーム分だけハイライトされるようにしています。 複数のスレッドが同じワーカースレッドを使用する際に、ワーカース レッドのつまりの原因が確認しやすくなります
FrameThread ◆ FramePipelineSystemを活用するために、簡単にフレームを構成するスレッドを扱いたい ◆ whileループでは記述できなくなる Game Game Game Render Render Render GpuExe GpuExe GpuExe FramePipelineシステムを活用するために、簡単にフレームを構成する スレッドを扱いたいという状況になりました。 Whileループでは記述出来ないため、テストでパイプラインスレッドを 増やしたり、検証を行う際にも使用可能なものが必要です
FrameThread::while ◆ whileループでの実装 while( IsApplicationValid() ) { Sync_Game_Render(); Game Game Game Begin_RenderThread(); Game(); End_RenderThread(); GpuExecute(); } Whileループでの今までの実装を見てみましょう まず、アプリケーションが有効な間は、常にGame関数が呼ばれます
FrameThread::while ◆ whileループでの実装 while( IsApplicationValid() ) { Sync_Game_Render(); Begin_RenderThread(); Game Game Game Render Render Render Game(); End_RenderThread(); GpuExecute(); } Game関数と並列動作させるために、Renderスレッドの起動と、終了待 ち関数でGame関数を挟みます
FrameThread::while ◆ whileループでの実装 while( IsApplicationValid() ) { Sync_Game_Render(); Begin_RenderThread(); Game Game Game Render Render Game(); End_RenderThread(); GpuExecute(); } GameとRenderが動いていない場所で、同期関数を呼びます Render
FrameThread::while ◆ whileループでの実装 while( IsApplicationValid() ) { Sync_Game_Render(); Game Begin_RenderThread(); Game Game Render Render Game(); End_RenderThread(); GpuExecute(); } Render関数終了を待ってGpuの実行リクエストを送ります。 これが、簡略化されたWhileループでの実装です Render
FrameThread ◆ FrameThreadクラスを使用した実装 tp::fwk::FrameThread GameThread("FrameGame"); tp::fwk::FrameThread RenderThread("FrameRender"); tp::fwk::FrameThread GpuExecuteThread("FrameGpuExecute"); これを、FrameThreadというクラスを使用した実装に置き換えました。 パイプラインごとにFrameThreadクラスを作成します。FrameThreadク ラスが、1つのスレッドを所持しているようなイメージです。 GameTherad、RenderThread、GpuExecuteThreadを定義しました。
FrameThread
◆ FrameThreadクラスを使用した実装
GameThread.Setup( [&](tp::fwk::FrameThread* ft) {
Game();
ft->WaitPrevFrame( &RenderThread );
Sync_Game_Render();
ft->Start(&RenderThread);
});
Game
FrameThreadのインスタンスごとにラムダ関数を渡します。
ラムダ内に、1フレーム分のスレッド処理を定義します
FrameThread
◆ FrameThreadクラスを使用した実装
GameThread.Setup( [&](tp::fwk::FrameThread* ft) {
Game();
ft->WaitPrevFrame( &RenderThread );
Sync_Game_Render();
ft->Start(&RenderThread);
});
Game
Game
Game
// 3並列で実行
//
新規のFramePipelineを生成して開始する
frameMan.PushThreadQueue(&GameThread);
frameMan.PushThreadQueue(&GameThread);
frameMan.PushThreadQueue(&GameThread);
3並列で実行するため、GameThreadを、管理クラスに3回キューイン
グします。
そうすると、3回GameTheradが呼び出されます
FrameThread
◆ FrameThreadクラスを使用した実装
GameThread.Setup( [&](tp::fwk::FrameThread* ft) {
Game();
ft->WaitPrevFrame( &RenderThread );
Sync_Game_Render();
ft->Start( &RenderThread );
});
Game
RenderThread.Setup( [&](tp::fwk::FrameThread* ft) {
Render();
ft->WaitPrevFrame(&GpuExecuteThread);
RenderFlip();
ft->Start(&GpuExecuteThread);
});
Game
Render
Game
Render
Render
GameThreadでは、Game関数が終わったタイミングでGameとRenderの
同期関数を呼び出し、RenderThreadの開始をリクエストします。
RenderThreadのラムダ式には、Render関数の呼び出しを記述します
FrameThread
◆ FrameThreadクラスを使用した実装
GameThread.Setup( [&](tp::fwk::FrameThread* ft) {
Game();
ft->WaitPrevFrame( &RenderThread );
Sync_Game_Render();
ft->Start( &RenderThread );
});
Game
RenderThread.Setup( [&](tp::fwk::FrameThread* ft) {
Render();
ft->WaitPrevFrame(&GpuExecuteThread);
RenderFlip();
ft->Start( &GpuExecuteThread );
});
Game
Render
Game
Render
GpuExe
Render
GpuExe
GpuExecuteThread.Setup( [&](tp::fwk::FrameThread* ft) {
GpuExecute();
if(IsApplicationValid())
frameMan.PushThreadQueue(&GameThread);
});
RenderThreadは、GpuExecuteThreadの開始をリクエストします。
GpuExe
FrameThread
◆ FrameThreadクラスを使用した実装
GameThread.Setup( [&](tp::fwk::FrameThread* ft) {
Game();
ft->WaitPrevFrame( &RenderThread );
Sync_Game_Render();
ft->Start( &RenderThread );
});
RenderThread.Setup( [&](tp::fwk::FrameThread* ft) {
Render();
ft->WaitPrevFrame(&GpuExecuteThread);
RenderFlip();
ft->Start( &GpuExecuteThread );
});
Game
Game
Game
FP 1
FP 2
FP 3
Render
Render
Render
FP 1
FP 2
FP 3
GpuExe
GpuExe
GpuExe
FP 1
FP 2
FP 3
GpuExecuteThread.Setup( [&](tp::fwk::FrameThread* ft) {
GpuExecute();
if(IsApplicationValid())
frameMan.PushThreadQueue(&GameThread);
});
FrameThreadクラスで記述すると、自動でFramePipelineクラスが生成さ
れ、スレッドローカルに正しいFramePipelineクラスが設定されます。
次のFrameThreadの開始要求する際に、
今 所持しているFramePipelineを自動で受け渡すため、FramePipelineク
ラスの受け渡しミスが発生しなくなります。
FrameThread
◆ FrameThreadクラスを使用した実装
GameThread.Setup( [&](tp::fwk::FrameThread* ft) {
Game();
ft->WaitPrevFrame( &RenderThread );
Sync_Game_Render();
ft->Start( &RenderThread );
});
Game
Game
Game
Game
NEW
RenderThread.Setup( [&](tp::fwk::FrameThread* ft) {
Render();
ft->WaitPrevFrame(&GpuExecuteThread);
RenderFlip();
ft->Start( &GpuExecuteThread );
});
Render
Render
GpuExe
Render
GpuExe
GpuExe
GpuExecuteThread.Setup( [&](tp::fwk::FrameThread* ft) {
GpuExecute();
if(IsApplicationValid())
frameMan.PushThreadQueue( &GameThread );
});
GpuExecuteスレッドは、アプリケーションが有効な間は、新規に
GameThreadを実行キューに積みます。
実行キューは、新しいFramePipelineクラスを生成し、GameThreadの実
行をリクエストします。
これで、FramePipelineのスレッドが、動き続ける仕組みの基礎が出来
ました!
FrameThread
◆ FrameThreadクラスを使用した実装
GameThread.Setup( [&](tp::fwk::FrameThread* ft) {
Game();
ft->WaitPrevFrame( &RenderThread );
Sync_Game_Render();
ft->Start( &RenderThread );
});
Game
RenderThread.Setup( [&](tp::fwk::FrameThread* ft) {
Render();
ft->WaitPrevFrame(&GpuExecuteThread);
RenderFlip();
ft->Start( &GpuExecuteThread );
});
Game
Render
Game
Render
GpuExe
Game
Render
GpuExe
GpuExe
GpuExecuteThread.Setup( [&](tp::fwk::FrameThread* ft) {
GpuExecute();
if(IsApplicationValid())
frameMan.PushThreadQueue( &GameThread );
});
しかし、GameとRenderの同期処理中は、2つのスレッドが止まってい
る必要があります。
GameThreadに「前フレームのRenderThread」を待つ関数を挟みます。
FramePipelineクラスをベースにスレッドが動作していますので、どの
フレームの処理を実行しているか判断できます。
FrameThread
◆ FrameThreadクラスを使用した実装
GameThread.Setup( [&](tp::fwk::FrameThread* ft) {
Game();
ft->WaitPrevFrame( &RenderThread );
Sync_Game_Render();
ft->Start( &RenderThread );
});
Game
RenderThread.Setup( [&](tp::fwk::FrameThread* ft) {
Render();
ft->WaitPrevFrame(&GpuExecuteThread);
RenderFlip();
ft->Start( &GpuExecuteThread );
});
Game
Render
Game
Render
GpuExe
Game
Render
GpuExe
GpuExe
GpuExecuteThread.Setup( [&](tp::fwk::FrameThread* ft) {
GpuExecute();
if(IsApplicationValid())
frameMan.PushThreadQueue( &GameThread );
});
この同期処理があれば、GameスレッドとRenderThreadのどちらかが重
くなっても正しく同期できます。
FrameThread
◆ FrameThreadクラスを使用した実装
GameThread.Setup( [&](tp::fwk::FrameThread* ft) {
Game();
ft->WaitPrevFrame( &RenderThread );
Sync_Game_Render();
ft->Start( &RenderThread );
});
Game
RenderThread.Setup( [&](tp::fwk::FrameThread* ft) {
Render();
ft->WaitPrevFrame( &GpuExecuteThread );
RenderFlip();
ft->Start( &GpuExecuteThread );
});
Game
Render
Game
Render
GpuExe
Game
Render
GpuExe
GpuExe
GpuExecuteThread.Setup( [&](tp::fwk::FrameThread* ft) {
GpuExecute();
if(IsApplicationValid())
frameMan.PushThreadQueue( &GameThread );
});
GPU処理結果をCPUで使用するような処理がある場合は、
前回のGpuExecuteThreadが完了しているかを待つ必要があります。
バッファインデックスをフリップして
します。
次のGpuExecuteThreadを呼び出
FrameThread
◆ FrameThreadクラスを使用した実装
GameThread.Setup( [&](tp::fwk::FrameThread* ft) {
Game();
ft->WaitPrevFrame( &RenderThread );
Sync_Game_Render();
ft->Start( &RenderThread );
});
Game
RenderThread.Setup( [&](tp::fwk::FrameThread* ft) {
Render();
ft->WaitPrevFrame( &GpuExecuteThread );
RenderFlip();
ft->Start( &GpuExecuteThread );
});
Game
Render
Game
Render
GpuExe
Game
Render
GpuExe
GpuExe
GpuExecuteThread.Setup( [&](tp::fwk::FrameThread* ft) {
GpuExecute();
if(IsApplicationValid())
frameMan.PushThreadQueue( &GameThread );
});
これでRenderスレッドと、Gpu処理のどちらが遅くなっても正しく同期
されます
これが3スレッドで動作させる場合のFrameThread実装です。
Whileループよりは複雑になってしまっていますが、そこまで難しい実
装ではないと思います。
また、「FramePipelineクラスの生成」と「スレッドローカルを使用し
たFramePipelineクラスの受け渡し」を自動化することも出来ました。
FrameThread::まとめ ◆ FrameThreadクラスを使用した実装 ◆ 実装し終えて気づいた点 ◆ いかに同期処理を減らすかが重要 Game Game Render Game Render GpuExe Game Render GpuExe GpuExe FrameThreadクラスを使用した実装を行い、実装し終えて気づいた点が あります。 まず、紫ラインで示している同期は出来る限り無くすべきです。 FramePipelineに完全移行すれば消せる部分があります。 また、同期が必要になってしまった場合でも、図の黄色部分の処理を 出来るだけ減らすことが重要となります。
FrameThread::まとめ ◆ FrameThreadクラスを使用した実装 ◆ 実装し終えて気づいた点 ◆ いかに同期処理を減らすかが重要 ◆ 後続スレッドが軽量だと入力遅延がwhileループより少ない Game Game Render GpuExe Game Render GpuExe Game Render GpuExe 入力遅延 後続スレッドが軽量な場合、入力遅延が、whileループでの実装よりか なり少なくなることが分かりました。
FrameThread::まとめ ◆ FrameThreadクラスを使用した実装 ◆ 実装し終えて気づいた点 ◆ いかに同期処理を減らすかが重要 ◆ 後続スレッドが軽量だと入力遅延がwhileループより少ない ◆ ランタイム パフォーマンスメーターも自動生成 ◆ 過去16フレーム分のスレッド時間を記録 ◆ Wait***Frame()で待機タイミングを計測 待機時間 同期時間 Game Bottleneck Render Bottleneck GPU Bottleneck また、ランタイムのパフォーマンスメーターも自動で生成出来るよう になりました。 FrameThreadクラスの機能として、スレッド待ちが関数化されているた め、待機時間も自動計測可能になりました。 それにより、ランタイムを実行しながら各スレッドの動作状況が手軽 に確認出来るようになりました。 過去16フレーム分のFramePipelineのスレッド実行時間を記録してお り、リアルタイムに表示しています。 待ち時間の可視化により、Game/Render/GPUボトルネックが判断しや すくなりました。 それぞれ可視化したスクショを貼っておきましたので、後で資料をご 確認ください
FramePipelineSytem実装 ✓ ✓ ✓ ✓ ✓ ✓ FrameAllocator C++基本操作 FramePipeline FramePipelineの受け渡し FrameThread おまけ 最後に、FramePipelineシステムとは関係ありませんが、無くてはなら なかった機能の紹介を挟みます。
FramePipelineSytem実装::おまけ ◆ マルチスレッド処理に移行する場合、Mutexの競合に注意 ◆ 競合が発生すると、大幅に処理が遅くなる ◆ シングルスレッドで実行していた方が早かったという場合も ◆ 並列実行するスレッドが多くなるほど悪化 Lock 今までの処理をマルチスレッド処理に移行する場合、Mutexの競合に注 意する必要があります。 競合が発生すると、大幅に処理が遅くなります。 シングルスレッドで実行していた方が早かったという場合も何度か遭 遇しました。 これは、並列実行するスレッドが多くなるほど悪化します。 <メモ> 同じ処理を並列で動かすことで、同じタイミングで競合する確率が増 えます。 2スレッドで問題なくても、10スレッドを並列動作させると、シン グルスレッドより遅くなることが多く発生します。 また、シングルスレッドより遅くならなくても、かなり無駄な場合も あります。必ずプロファイラで確認しましょう。
FramePipelineSytem実装::おまけ ◆ マルチスレッド処理に移行する場合、Mutexの競合に注意 ◆ 競合が発生すると、大幅に処理が遅くなる ◆ シングルスレッドで実行していた方が早かったという場合も ◆ 並列実行するスレッドが多くなるほど悪化 ◆ ある程度の処理はMutexを避けて通れるが、メモリ確保は避けられない ◆ LockFreeQueueのNode ◆ ジョブに渡すラムダの関数オブジェクト ◆ 計算用の一時バッファ ある程度の処理はMutexを避けて通れますが、メモリ確保に関しては並 列処理でも避けれないことが多いです。 ロックフリーキューは、実装の都合上、ノードを確保する必要があり ます。これは、Nodeのプールをロックフリースタックで管理するなど、 ちょっと気持ち悪い実装になっていました。 ワーカースレッドにジョブを発行する際に、ラムダ式で記述したいの ですが、ラムダキャプチャの関係で、関数オブジェクトのサイズが不 定です。 std::functionは内部でメモリアロケーションが発生する可能性があるの で選択肢から外し、固定長のファンクションオブジェクトを利用して いましたが、ラムダキャプチャに制限が出てしまい、80点の実装でし た。 また、並列処理を行っている最中に、計算用の一時バッファが必要な 場合がカナリあります。 <メモ> std::functionは、関数オブジェクトのサイズにより、内部でメモリ確保 が動きます。また、サイズは環境依存なので、注意が必要です。
FramePipelineSytem実装::おまけ ◆ マルチスレッド処理に移行する場合、Mutexの競合に注意 ◆ 競合が発生すると、大幅に処理が遅くなる ◆ シングルスレッドで実行していた方が早かったという場合も ◆ 並列実行するスレッドが多くなるほど悪化 ◆ ある程度の処理はMutexを避けて通れるが、メモリ確保は避けられない ◆ LockFreeQueueのNode ◆ ジョブに渡すラムダの関数オブジェクト ◆ 計算用の一時バッファ かなり短い期間で解放されることを前提 確保スレッド、解放スレッドが別 ロックフリーで動作する 軽量なアロケータが欲しい! かなり短い期間で解放されることを前提として良いので、 確保スレッド、解放スレッドが別、 ロックフリーで動作する軽量なアロケータが欲しい!となりました。 ということで、みんな大好き す! オレオレアロケータ の説明に入りま
ThreadTempAllocator ◆ 32KB固定のFrameAllocatorのプールを用意(全メモリは連続している) FrameAllocator Pool 32KB 32KB 32KB 32KB 32KB 32KB ThreadTempAllocatorという名前になっています。 まず、グローバルな場所に、FrameAllocatorのプールを用意します。 FrameAllocatorは、32KB固定で、1つの大きなメモリを、32KBにスライ スしたような状態です。 <メモ> 当初は4KB固定でしたが、意外に大きなバッファを確保する需要が高く なり、32KBまで拡張されました。
ThreadTempAllocator ◆ 32KB固定のFrameAllocatorのプールを用意(全メモリは連続している) ◆ ThreadLocalな変数に、空いているFrameAllocatorを取得 ◆ FrameAllocatorは、「メモリ確保した回数」だけ記録 FrameAllocator Pool thread_local 32KB 32KB 32KB 32KB 32KB 32KB alloc_count = 60 メモリ確保が行われると、ThreadLocal変数で、空いている FrameAllocatorを取得し、それを使用します。 スレッドローカルを使用することで、このアロケータのメモリは、同 一のスレッドでしか確保されないため、競合することは無くなります。 また、その際に、メモリ確保した回数を記録します
ThreadTempAllocator ◆ 32KB固定のFrameAllocatorのプールを用意(全メモリは連続している) ◆ ThreadLocalな変数に、空いているFrameAllocatorを取得 ◆ FrameAllocatorは、「メモリ確保した回数」だけ記録 ◆ メモリ確保できなくなった場合は、新しいFrameAllocatorを取得 FrameAllocator Pool thread_local New! alloc_count = 1 32KB 32KB 32KB 32KB 32KB 32KB alloc_count = 80 メモリ確保できなくなったタイミングで、新しいFrameAllocatorを取得 し、使用を切り換えます
ThreadTempAllocator ◆ 32KB固定のFrameAllocatorのプールを用意(全メモリは連続している) ◆ ThreadLocalな変数に、空いているFrameAllocatorを取得 ◆ FrameAllocatorは、「メモリ確保した回数」だけ記録 ◆ メモリ確保できなくなった場合は、新しいFrameAllocatorを取得 ◆ メモリ解放時に、解放回数を記録 ◆ ポインタから、どのFrameAllocatorのメモリか計算可能 FrameAllocator Pool thread_local alloc_count = 1 free_count = 0 32KB 32KB 32KB 32KB 32KB alloc_count = 80 free_count = 31 続いて、メモリ解放時ですが、アロケータ単位で、解放回数を記録し ます。 全メモリ連続している領域で管理されていますので、解放メモリのポ インタさえあれば、どのFrameAllocatorかが計算できます。 アトミック変数を足すだけですので、解放処理はどのスレッドから行 われても高速です。 32KB
ThreadTempAllocator ◆ 32KB固定のFrameAllocatorのプールを用意(全メモリは連続している) ◆ ThreadLocalな変数に、空いているFrameAllocatorを取得 ◆ FrameAllocatorは、「メモリ確保した回数」だけ記録 ◆ メモリ確保できなくなった場合は、新しいFrameAllocatorを取得 ◆ メモリ解放時に、解放回数を記録 ◆ ポインタから、どのFrameAllocatorのメモリか計算可能 ◆ 確保回数=解放回数になったタイミングで未使用 FrameAllocator Pool thread_local alloc_count = 1 free_count = 0 32KB 32KB 32KB 32KB 32KB alloc_count = 80 free_count = 80 確保回数と解放回数が同じになったタイミングで、全メモリが未使用 になったと判断し、FrameAllocatorを未使用扱いに戻します 32KB
ThreadTempAllocator ◆ 32KB固定のFrameAllocatorのプールを用意(全メモリは連続している) ◆ ThreadLocalな変数に、空いているFrameAllocatorを取得 ◆ FrameAllocatorは、「メモリ確保した回数」だけ記録 ◆ メモリ確保できなくなった場合は、新しいFrameAllocatorを取得 ◆ メモリ解放時に、解放回数を記録 ◆ ポインタから、どのFrameAllocatorのメモリか計算可能 ◆ 確保回数=解放回数になったタイミングで未使用 ◆ 一定以上のサイズの場合は、デフォルトのアロケータへつなぐ FrameAllocator Pool thread_local alloc_count = 1 free_count = 0 32KB 32KB 32KB 32KB 32KB 32KB alloc_count = 80 free_count = 80 また、32KBという上限のあるメモリ領域のため、一定サイズ以上のメ モリ確保は、デフォルトのアロケータを利用するように内部で切り替 えます。 このアロケータを用意した関係で、メモリ確保のMutexロックや、ジョ ブスレッド、ロックフリーキューなどの実装がとても軽量になりまし た。 また、大量のメモリを短期間で使い捨てする、FramePipelineシステム とも、親和性の高いものになりました。 FrameObjectは、FramePipelineシステムのアロケータを使用し、 FrameObjectが所持するメモリはこちらのThreadTempAllocatorを使用し ました。
タイトル活用例 ✓ ✓ ✓ ✓ タイトル紹介 スレッドの移行 FrameObject具体例 まとめ ココまでで、マルチスレッドの設計方針と、どういったシステムかの 説明を終わりました。 ここからは、実際に開発タイトルへの適用した結果などについてご紹 介いたします。 実際のゲームのスクリーンショットを何枚か撮影しましたので、スラ イド背景に使用させていただいてます。 タイトル紹介の後、 スレッド構成の移行に関して、 使用されたFrameObjectについて、 ご紹介します。
開発タイトル紹介 ◆ 近日中に公開予定 ◆ 大人数ネットワーク対戦ゲーム ◆ PlayStation5 / Xbox Series X|S / Steam 向けに開発中 実際に今回のシステムを適用させたタイトルをご紹介します。 実は、まだ動画撮影中には公開されておらず、正式名称を発表できな いのですが、近日公開される予定です。 大人数ネットワーク対戦ゲームとなっておりまして、 PlayStation5、XboxSeriesX|S、Steam向けに開発を行っています。
開発タイトル紹介 ◆ 近日中に公開予定 ◆ 大人数ネットワーク対戦ゲーム ◆ PlayStation5 / Xbox Series X|S / Steam 向けに開発中 ◆ 大量のプレイヤー( or BOT ) ◆ 大量のギミック 特徴としては、当社比になりますが、大量のプレイヤーやボット、ギ ミックが存在し、
開発タイトル紹介 ◆ 近日中に公開予定 ◆ 大人数ネットワーク対戦ゲーム ◆ PlayStation5 / Xbox Series X|S / Steam 向けに開発中 ◆ 大量のプレイヤー( or BOT ) ◆ 大量のギミック ◆ 大量のオブジェクトが「燃える」「壊れる」 それら含めた、大小 数千を超える量のオブジェクトが、戦闘によっ て燃えたり壊れたりするゲームとなっております! とても楽しそうですね!
開発タイトル紹介 ◆ 開発タイトルへの適用 ◆ 少人数で長年開発が行われていた ◆ 「今世代機コンソール対応」「FramePipelineSystem移行」を後から適用する流れ ◆ FramePipelineSystemが上手く動くのか?という懸念もあったため ◆ Game処理にはほぼ適用しない ◆ Render処理を中心に移行したが、すべてを移行しきれていない ※全て開発時の情報となります。 実は、今回のタイトルは少人数で長年開発が行われていました。 そのため、今世代機のコンソール対応や、FramePipelineSystemへの移行 を、開発の途中から適用するような流れとなっています。 FramePipelineSystemが上手く動くのか?という懸念があったため、既に 量産に入っていたゲーム処理にはほぼ適用していません。 描画処理やエンジン処理を中心に移行を開始し始めましたが、コン ソールの対応も多かったため、全てを移行しきれませんでした。 ただし、一定量の改善と移行に対する知見は溜まりましたので、ご紹 介していきます。 また、全て開発時の情報になりますので、その点ご了承ください。
タイトル活用例 ✓ ✓ ✓ ✓ タイトル紹介 スレッドの移行 FrameObject具体例 まとめ 最初に、スレッド構成に関してどういった変更を行ったのか、 まずは改善前のスレッド構成について説明し、その後に移行の流れを ご説明していきます。
タイトル事例::スレッド::Before ◆ 変更前のスレッド構成 Game Game Render Render この図は、GameスレッドとRenderスレッドがあり、 ・1フレーム分の処理を濃い色 ・並列動作部分は薄い色 で表示しています。
タイトル事例::スレッド::Before ◆ 変更前のスレッド構成 ◆ GameとRenderの同期処理 ◆ 矢印はワーカースレッド。矢印の数はワーカースレッド数をそれっぽく見せたもの Sync Game Render Sync Game Render GameスレッドとRenderスレッドの同期処理が入ります。 描画オブジェクトの数によりますが、過去タイトルでは全コア使い切 る形の並列化を行い、3ms前後かかっていました。 スレッド下の矢印は、ワーカースレッドを使用した並列処理を表しま す。 矢印の数はワーカースレッドの数を表しておりますが、参考程度とし てください。 <メモ> ワーカースレッドの数は、プラットフォーム毎に調整を行います。
タイトル事例::スレッド::Before ◆ 変更前のスレッド構成 ◆ GpuExecute Sync Game Render Sync Game Render 更に、RenderスレッドとGameスレッドの完了を待って、GpuのExecute を実行していました 大体0.5msはかからないくらいのコストでした
タイトル事例::スレッド::Before ◆ 変更前のスレッド構成 ◆ Gameスレッド :Animation/描画オブジェクト更新 などを並列化 ◆ Renderスレッド:カリングなどの事前処理後に、PreZ、Gbuffer、Shadowの3パスコマンドを並列 Sync Game Render Sync Game Render もちろん、GameスレッドとRenderスレッド内でも並列化処理が走って いました。 スレッドが一部白くなっている部分は、ワーカースレッド待ちでス レッドが寝ている状態です。 Gameスレッドでは、「アニメーション更新」や「描画関連のオブジェ クト更新」でコアを使い切る形の並列化が行われており、 他にも、ちょっとした処理をいくつかワーカースレッドで走らせてい ます。 Renderスレッドでは、フラスタムカリングや、パスに必要な情報収集 やソートを行ったのち、 パス毎に並列でコマンドバッファを生成し、最後に並べ替えています。 PreZ, Gbuffer, Shadowの3パスが1スレッドずつ使用して、大部分を占 めていました。
タイトル事例::スレッド::Before ◆ 変更前のスレッド構成 ◆ Effect:ゲームスレッドで積まれたリクエストを次フレームで実行・更新 ◆ GPUコマンド生成まで同時に行い、コマンドバッファをRenderスレッドとマージ Game Sync Game Sync Render Effect Render Effect エフェクトの更新は、ゲームスレッドからのリクエストがキューイン グされ、次のフレームで生成と更新、GPUコマンドの生成まで連続で 行います。 最終的に生成されたコマンドバッファをRenderスレッドとマージして いました。
タイトル事例::スレッド::Before ◆ 変更前のスレッド構成 ◆ UI Animation / Physics はGameスレッドと連動して動作 Game Sync Game Sync Render Render Effect Effect UI Animation Physics その他にも「UIアニメーションスレッドや、物理スレッド」がGameス レッドと連動して動いており
タイトル事例::スレッド::Before ◆ 変更前のスレッド構成 ◆ Sound/Gi/Navmesh/Assetなど、フレームと関係なく別スレッドで動作 Game Sync Game Sync Render Render Effect Effect UI Animation Physics Sound / GI / NavMesh / Asset サウンド、GI、ナビメッシュ、ファイルIOなどアセット関連などのシ ステムが、フレームに関係なく、別スレッドで動作している状況でし た。 ここまでが、FramePipelineシステム導入前までの弊社のエンジンの作 りとなっています。 コアをすべて使いきれるタイミングはいくつかあるのですが、コア数 が増えたタイミングで、CPU空きが目立ちました
タイトル事例::スレッド::After ◆ FramePipeline移行したスレッド構成 Sync Game Render Sync Game Render それでは、どのような変更を行ったのか見ていきましょう 今回のFramePipelineの移行で、エフェクトやその他スレッドは大きな 変更を行っていないので、一旦除外します。
タイトル事例::スレッド::After ◆ FramePipeline移行したスレッド構成 ◆ GpuExecuteだけを別スレッドへ Sync Game Sync Render Game Render GpuExec まずGpuのExecuteを別スレッドにしました。 負荷はプラットフォームに依存しますが、どのプラットフォームでも 共通の無駄処理だったため、移動を行いました。 概念としては簡単そうだったんですが、各プラットフォーム毎に対応 が必要なため、思いのほか大変な部分でした。 担当者と綿密な相談が必要です。
タイトル事例::スレッド::After ◆ FramePipeline移行したスレッド構成 ◆ カメラ情報決定したタイミングでFrameThreadとしてRenderSetupスレッドを起動 ◆ カリング情報などの準備を行う Sync Game Sync Game RenderSetup Render Render GpuExec そして、GameとRenderの間にRenderSetupというFrameThreadを追加し ました。 全てのグラフィクスオブジェクトをFramePipelineに移行出来なかった ため、GameとRenderの間のSync処理が消せませんでした。 そのため、GameThreadの途中から開始し、RenderThread用のデータを 作成する流れを追加しています。 GameThreadにてカメラ情報が確定されたタイミングで、CPUのオク ルージョンカリング用やフラスタムカリングの開始準備を行うスレッ ドです。
タイトル事例::スレッド::After ◆ FramePipeline移行したスレッド構成 ◆ グラフィクスオブジェクトがFramePipelineに移行、カリング先行することで同期処理を削減 Game Game RenderSetup Render Render GpuExec グラフィクスオブジェクトのの多くが、FramePipelineに移行したこと と、フラスタムカリングを先に実行し、描画されないことが決定した オブジェクトの同期を取り除いたことで、 同期にかかる時間がカナリ減りました。
タイトル事例::スレッド::After ◆ FramePipeline移行したスレッド構成 ◆ RenderSetupスレッドで準備完了になっていたジョブを、コアが空く同期後に実行 ◆ DrawCall数に耐えるため、PreZ、Gbuffer、Shadowパス自体も並列化 Game Game RenderSetup Render Render GpuExec また、描画オブジェクトの情報がFramePipeline化されたことにより、 RenderThread処理の大部分が並列処理に移行できました。 RenderSetupスレッド中は、GameThread側がほとんどのCPUコアを占有 していますので、GameとRenderの同期後にRenderSetupで準備完了に なったジョブを走らせます。 DrawCallに関しても、今世代機のDrawCall数が大幅に増えたため、 PreZ,Gbuffer,Shadowパスをさらに分割し、並列でコマンドバッファを生 成するように変更してもらいました
タイトル事例::スレッド::After ◆ FramePipeline移行したスレッド構成 ◆ GpuExecuteはFrameThread対応でRenderスレッド直後に実行されるように Game Game RenderSetup Render Render GpuExec GpuExecuteはWhileループからFrameThreadに移行することでRenderス レッドの直後に実行されるようになりました
タイトル事例::スレッド::After ◆ FramePipeline移行したスレッド構成 Game Game RenderSetup Render Render GpuExec Effect Effect UI Animation Physics Sound / GI / NavMesh / Asset ここに、既存のスレッドを追加します。 これが、FramePipelineシステムを使用した状態のスレッド構成です。
タイトル事例::スレッド::After ◆ FramePipeline移行したスレッド構成 ◆ 完全にFramePipelineに対応していないGameスレッドがボトルネックになった Game Game RenderSetup Render Render GpuExec GpuExec Effect Effect UI Animation UI Animation Physics Physics Physics Sound / GI / NavMesh / Asset 図を見やすくするために、表示を省略していた並列スレッドを表示さ せます。 FramePipelineに移行している、RenderSetupスレッドとRenderスレッド がボトルネックになることはホボ無くなりました。 まだまだRenderSetupとRenderのスレッドは改善できそうだったのです が、完全にGameスレッドがボトルネックになっている状況のため、現 在はそちらの改善に取り組んでいます
タイトル事例::スレッド::After ◆ FramePipeline移行したスレッド構成 ◆ Renderスレッドが終了するとコアが余る Game Game RenderSetup Render Render GpuExec GpuExec Effect Effect UI Animation UI Animation Physics Physics Physics Sound / GI / NavMesh / Asset Gameスレッドは、分割されていないため、Renderスレッドが終了する と、コアが余っている状況が発生します。 Gameスレッドが重い状況だと、この余っている部分が悪化してしまう といった感じです。 FramePipelineシステムをゲームスレッドにも適用出来るようになれば、 改善可能かと考えています。
タイトル事例::スレッド::After ◆ FramePipeline移行したスレッド構成 ◆ Renderスレッド中も一時的に使用率が下がる Game Game RenderSetup Render Render GpuExec GpuExec Effect Effect UI Animation UI Animation Physics Physics Physics Sound / GI / NavMesh / Asset FramePipelineシステムへの移行を進めている描画処理では、ホボ、コ アを使い切れる状態になりました。 処理の実行順番を入れ替えて、出来るだけ並列化できるように調整し ている状態です。 まだ、一時的にコア使用率が少なくなることがありますが、これは、 別スレッドからのジョブと混ぜることで消えると良いなと考えていま す。 これで、タイトルのスレッドの移行に関しての説明を終わります。
タイトル活用例 ✓ ✓ ✓ ✓ タイトル紹介 スレッドの移行 FrameObject具体例 まとめ 続いては、実際にどういったFrameObjectがどの程度作成されたのか? という点の説明をしたいと思います。
タイトル事例::FrameObject ◆ FrameObjectの各個数や用途を具体的に説明 ◆ 2つの対戦マップで、FrameObjectの量を計測 ◆ 対戦人数、マップ広さが違います ◆ 開発時の数字です 小マップ 対戦人数 :少 マップ範囲:狭 FPS :60 大マップ 対戦人数 :多 マップ範囲:広 FPS :60以下許容 一番効果の高かったFrameObjectの流れを具体的に説明していこうかと 思います。 その際に、実際にタイトルで使用しているFrameObjectの数などを計測 してみましたので、参考数値も記載します。 参考数値に関しましては、規模の違うマップが2つありましたので、 それぞれの数値を出します。 「小マップ」「大マップ」と呼ばせていただきます、この2つのマップ は、対戦人数、マップの広さがかなり違ってきます。 また、大マップに関しては、対戦人数が重視され、60FPSを下回ること も許容されている点に注意してください。
タイトル事例::FrameObject ◆ FrameObjectの各個数や用途を説明 1FrameのFrameObject情報 FrameObject 小 大 ALL 5,400 10,500 FO_CpuMarker 2,800 5,000 まず、1フレームにおけるFrameObject数は小マップで5400、大マップ で10,500個作成されていました。 その中で一番多いのは、開発時専用のCPUマーカーでした。 手作業で埋め込んでいるCPUマーカーもありますが、ほとんどがワー カースレッドに投げているジョブの数と考えていただいて大丈夫です。 <メモ> 1フレームに投げられているジョブの数となります。 ワーカースレッドのジョブ情報は「ジョブ名+ラムダ関数」となりま す。
タイトル事例::FrameObject ◆ FrameObjectの各個数や用途を説明 1FrameのFrameObject情報 FrameObject Game 小 大 ALL 5,400 10,500 FO_CpuMarker 2,800 5,000 RenderSetup Render 先ほどのFrameThread構成を使用して、使用されていたFrameObjectの具 体例を説明します。 今回は、インスタンスメッシュが描画されるまでの流れをご説明しま す。
タイトル事例::FrameObject ◆ FrameObjectを使用したCPUカリング 1FrameのFrameObject情報 FO_OccluderMesh FrameObject FO_ViewInfo Game 小 大 ALL 5,400 10,500 FO_CpuMarker 2,800 5,000 400 1,600 FO_OccluderMesh RenderSetup Render まずは、カリング関連の説明をします。 ゲームの序盤から中盤にかけて、CPUオクルージョンカリング用のオク ルーダーメッシュをFrameObjectとして投入してもらいます。 さらに、カメラ情報が設定されたタイミングでRenderSetupスレッドが 開始します。 オクルーダーメッシュは小マップで400、大マップでは1600個程度でし た。 壊れるものなどもありますので、毎フレームFramePipelineに投入して もらいます。
タイトル事例::FrameObject ◆ FrameObjectを使用したCPUカリング 1FrameのFrameObject情報 FO_OccluderMesh FrameObject FO_ViewInfo Game 小 大 ALL 5,400 10,500 FO_CpuMarker 2,800 5,000 400 1,600 FO_OccluderMesh RenderSetup Occlusion Culling Buffer Render カメラ情報とオクルーダーメッシュのリストを元に、一定数のメッ シュがCPUラスタライズされます。 CPUでメッシュラスタライズする機能を作っていただいた担当者には脱 帽です。
タイトル事例::FrameObject ◆ FrameObjectを使用したCPUカリング 1FrameのFrameObject情報 FO_ViewInfo FrameObject FO_Shadow Game 小 大 ALL 5,400 10,500 FO_CpuMarker 2,800 5,000 400 1,600 FO_OccluderMesh RenderSetup Render その後、カメラとシャドウカスケードの情報を使用し、フラスタムカ リング用のキャッシュを生成します。 中身を少しだけ説明します。
タイトル事例::FrameObject::カリング ◆ カメラを中心としたUniformGridごとに、フラスタムカリング情報を事前計算 カメラ位置を中心とした、ユニフォームグリッド毎に、フラスタムカ リングの情報を事前計算します。 こちら、カメラフラスタムを上から見た図となります。
タイトル事例::FrameObject::カリング ◆ 球が「フラスタムに接触するために必要な半径」 がわかる ◆ バウンディング球半径 < 接触半径 だった場合、メッシュはフラスタムに入らない 球の中心がグリッド内にある場合、「フラスタムに接触するのに必要 な半径」が事前計算できます という事は、これを保存しておけば、バウンディング球の所属するグ リッドを計算で求めると、半径の比較だけでフラスタムカリング出来 るようになります。 Float比較1回でメッシュがフラスタム外にいるかどうかが判定可能に なります。 <メモ> 各グリッド中心からフラスタムへのsigned distance field のようなもので す。
タイトル事例::FrameObject::カリング ◆ 「グリッドに一番近い面」「グリッドと交差している面」も記録しておく また、グリッド単位で、一番近いフラスタムの面と、グリッドと交差 している面を記録しておくことで、 半径の比較で、フラスタム内に入る可能性のある球があった場合の後 続の判定も簡略化可能です。
タイトル事例::FrameObject::カリング ◆ カメラだけでなく、シャドウカスケード情報も一緒に記録 ◆ カメラ付近など、クリップ面と交差したグリッドで計算が増えるため、 UniformGridは多階層 グリッド 個数 15m 8x8x8 60m 8x8x8 240m 8x4x8 960m 9x5x9 4階層 1685 グリッド 幅 8940m 高さ 4800m この計算を、カメラのフラスタムだけでなく、シャドウカスケードの クリップ面も同様に全て記録し、一度に判定可能にします。 大量に配置されている小さなメッシュが、カメラとシャドウ含めて Float比較1回でクリップ可能になります。 この最適化は、グリッド内にクリップ面が入っている場合に計算が重 くなりやすいため、 できるだけそういったグリッドを減らすように、カメラを中心に UniformGridを複数の階層で構築しています。 <メモ> 「全フラスタムをマージした距離」「各フラスタム単位の距離」「各 フラスタムのチェックすべき面のインデックスマスク」がそれぞれ記 録されています。 シャドウに関しては、クリップ面を8面までサポートしています。
タイトル事例::FrameObject::カリング
◆ FrameObjectを使用したCPUカリング
1FrameのFrameObject情報
FO_ViewInfo
FO_Shadow
Game
FO_ViewCull
FrameObject
小
大
ALL
5,400
10,500
FO_CpuMarker
2,800
5,000
400
1,600
FO_OccluderMesh
RenderSetup
if (auto cull = frame_pipeline->GetFrameObject< FO_ViewCull >())
Render
{
// フラスタムカリング
auto result = cull.CheckViewHit( bounding_sphere, mask(MainView|Shadow) );
if( result.view_mask & mask(MainView) )
{
// オクルージョンカリング
if( cull.CheckViewOcclusionVisible( bounding_box ) )
{
/* Draw Main Camera*/ }
}
}
フラスタムカリングと、オクルージョンカリングの計算データを
ViewCullingデータとしてFramePipelineに登録します。
これで、いつでもカリング計算が可能になります。
ワールドのシーンをツリー構造で管理するのではなく、毎フレーム、
全オブジェクトをカリングする方式に切り替えました。
最初に、軽量なフラスタムカリングを行った後に、オクルージョンカ
リングを行います。
出来るだけ事前計算を用いてカリング処理を高速化する必要がありま
した
タイトル事例::FrameObject
◆ Gameスレッドでは、毎フレームメッシュ情報を登録
◆ 位置、ディザアルファ、燃え状態などのパラメータが変更可能
1FrameのFrameObject情報
◆ スケルタルアニメなどを行うメッシュ意外すべてインスタンス化
FrameObject
Game
小
大
ALL
5,400
10,500
FO_CpuMarker
2,800
5,000
FO_OccluderMesh
400
1,600
FO_InstanceMesh
250
350
RenderSetup
// アセット毎にハンドルを取得しておく
auto dim_module = levelMan->GetModule< LevelModuleDynamicInstanceMesh >();
Render
auto handle = dim_module->ReseterAsset( asset_name
);
InstanceMesh
39,000
// 毎フレーム パラメータと一緒に登録する
//
内部でマテリアルスイッチ毎に切り分けて FO_InstanceMeshにまとめてくれる
AppInstanceMeshParam param;
param.mtx = mat;
param.alpha = 1.f;
param.burnt_level = 0.f;
dim_module->DrawInstanceMesh( handle, param );
インスタンスメッシュ描画の流れに入ります。
ゲームスレッドでは、管理クラスに「毎フレーム」
モデルの情報をプッシュする形式にしました。
描画して欲しい
モデルを表示したい位置や、ディザアルファの値、燃えているパラ
メータ など、タイトルで良く使用される値を一緒に登録します。
これにより、今まで静的なオブジェクトしかインスタンスメッシュ描
画されなかったのですが、スケルタルアニメーションを行わない ホ
ボすべてのメッシュが インスタンスメッシュ描画可能になりました。
管理クラスは、大量に登録されたパラメータ情報から、マテリアルの
シェーダーバリエーション毎にインスタンスを分類します。
分類ごとにまとめた状態で、インスタンスメッシュのFrameObjectとし
て、FramePipelineに追加します。
<メモ>
ディザアルファの値が0じゃない場合にMaskedマテリアル、
燃えパラメータが0でない場合に特殊シェーダーに切り替えるなど
68,000
同じアセットでもレンダリングステート、シェーダーの異なるものに、自動 で分類します。 ゲーム処理側では、構造体を毎フレームプッシュするだけで、内部のシェー ダーバリエーションの切り分けや、それぞれをDrawInstance用の配列にまとめ る処理などはシステム側で行います。
タイトル事例::FrameObject ◆ シェーダー毎にメッシュ情報をまとめ、FO_InstanceMeshを登録 1FrameのFrameObject情報 FO_InstanceMesh Game FrameObject 小 大 ALL 5,400 10,500 FO_CpuMarker 2,800 5,000 FO_OccluderMesh 400 1,600 FO_InstanceMesh 250 350 RenderSetup Render 1Frameの描画・メモリ情報 InstanceMesh 39,000 インスタンスメッシュのFrameObjectは、小マップで250、大マップで 350くらいでした。 これは、アセットのシェーダーバリエーション毎にまとめられた数字 ですので、実際に登録されたメッシュ数は、小マップで3万9千、大 マップで6万8千個です。 この数が、毎フレーム登録されることになります。 68,000
タイトル事例::FrameObject ◆ カリング、LOD評価などを行ったものをFO_CullInstMeshに 1FrameのFrameObject情報 FrameObject Game FO_CullInstMesh 小 大 ALL 5,400 10,500 FO_CpuMarker 2,800 5,000 FO_OccluderMesh 400 1,600 FO_InstanceMesh 250 350 RenderSetup Render 1Frameの描画・メモリ情報 InstanceMesh 39,000 そのインスタンス全てのカリング判定を行い、表示LOD判定も行ったも のが、カリングインスタンスメッシュとしてFramePipelineに登録され ます 68,000
タイトル事例::FrameObject ◆ インスタンス非対応のメッシュもカリング判定してFO_CullMeshにマージ 1FrameのFrameObject情報 ◆ RenderスレッドはFO_CullMeshのリストを使用 FrameObject FO_Mesh Game FO_CullInstMesh 小 大 ALL 5,400 10,500 FO_CpuMarker 2,800 5,000 FO_OccluderMesh 400 1,600 FO_Mesh 750 1,800 FO_InstanceMesh 250 350 FO_CullMesh 750 1,500 RenderSetup Render 1Frameの描画・メモリ情報 InstanceMesh 39,000 FO_CullMesh また、「スケルタルメッシュや、特殊なマテリアルを持つ、インスタ ンスメッシュで対応出来ないメッシュなど」も、別軸で処理が進みま す。 最終的にCullingMeshとして カリングされたインスタンスメッシュと 一緒にFrameObjectのリストにまとめられます。 Renderスレッドは、このリストをソートしてパスで描画を行います。 68,000
タイトル事例::FrameObject ◆ インスタンスメッシュ描画に切り替え、DrawCallを抑制 ◆ FramePipelineメモリ ~1MB ◆ ThreadTempAllocatorメモリ ~6MB Game 1FrameのFrameObject情報 小 FrameObject 大 ALL 5,400 10,500 FO_CpuMarker 2,800 5,000 FO_OccluderMesh 400 1,600 FO_Mesh 750 1,800 FO_InstanceMesh 250 350 FO_CullMesh 750 1,500 RenderSetup Render 1Frameの描画・メモリ情報 InstanceMesh 39,000 68,000 DrawCall 2000~5000 2000~7000 DrawPolygon 10~30百万 10~45百万 600KB 1MB 4MB 6MB FO Memory TempMemory 最終的に実行しているDrawCall数と投入ポリゴン数、使用メモリなども 参考に載せておきます。 DrawCallは、InstanceMeshの描画にホトンド移行したため、 PreZ,Gbuffer,Shadow すべて合わせると1万を超えていた事も多かった のですが、半分程度まで下げる事が出来ました。 描画に投入しているポリゴン数もPreZ,Gbuffer,Shadow を合わせたもの になっているのですが、前世代機と比較すると、考えられないくらい の数字になっています。 メモリに関してですが、 FramePipelineが所持しているFrameAllocator の合計は、小マップで600KB、大マップで1MB程度でした。 FrameObjectが所持している動的メモリや、InstanceMeshなどの作業 ワーク用に使用されている、ThreadTempAllocatorの方は、 小マップで4MB、大マップで6MB程使用されている状態でした。 タイトルにおけるFrameObjectの情報は以上となります。 <メモ> FrameObjectのMemoryは、開発時のCPUマーカーも含んだ値で、1フ レームにおける使用メモリ合計です。(先に解放される部分を無視し
た計測結果です) ThreadTempAllocatorの値は、毎フレームの最大値(単一フレームにおける値 ではなく、複数フレーム並列実行している状態での計測)となります。
タイトル事例::FrameObject::SyncPoint ◆ FrameThread以外での同期処理 Game RenderSetup Render ここで、FramePipelineシステムの機能を幾つか追加で紹介します。 FrameThreadを利用したスレッド起動意外にも、同期処理が必要になり ました。 RenderSetupスレッドは、GameスレッドとRenderスレッドの実行中にサ ポートするスレッドのため、 いくつかの場所で、後続スレッドが前処理の完了を確認する部分があ ります。
タイトル事例::FrameObject::SyncPoint
◆ FrameThread以外での同期処理
◆ 同期ポイント通過をFramePipelineに通知
{
Game
// 同期ポイントを通過。同期ポイント待ちのスレッドを全て起こす
frame_pipeline->PassSyncPoint< FSyncPoint_RenderSetupComplete >();
}
RenderSetup
Render
この部分は、同期ポイントという機能を使用します。
事前処理側では、
FramePipelineに同期ポイントを通過したかどうかを通知する関数を呼
び出します。
タイトル事例::FrameObject::SyncPoint
◆ FrameThread以外での同期処理
◆ 同期ポイント通過をFramePipelineに通知
◆ 後続処理では、同期ポイント通過を待つ
{
Game
// 同期ポイントを通過。同期ポイント待ちのスレッドを全て起こす
frame_pipeline->PassSyncPoint< FSyncPoint_RenderSetupComplete >();
}
RenderSetup
Render
{
// 同期ポイント待ち。同期ポイントが通過していない場合にスレッド待機
frame_pipeline->WaitSyncPoint< FSyncPoint_RenderSetupComplete >();
}
処理の完了確認が必要な後続スレッドは、同期ポイント待ち関数を使
用します。
「機能を持ったクラスが同期オブジェクトを持つ」のではなく、
「FramePipelineが同期オブジェクトを保持」することで、
スレッドをまたいだクラスのハンドリングが不要になり、安心して利
用できます。
タイトル事例::FrameObject::Fix ◆ FrameObjectの登録タイミング Game FO_OccluderMesh RenderSetup Render また、FrameObjectの運用は、登録しても意味を為さない期間がありま す。 例えばですが、OccluderMeshは、オクルージョンカリング用のラスタ ライズに必要な情報です。 ラスタライズはRenderSetupスレッドの最初に呼ばれるので、その前に FramePipelineに登録する必要があります。
タイトル事例::FrameObject::Fix ◆ FrameObjectの登録タイミング 登録しても使用されない Game FO_OccluderMesh RenderSetup Render ラスタライズ処理の後にオクルーダーメッシュが登録された場合、ラ スタライズに反映されることが無いため、意味のない登録という事に なります。 FrameObjectを使用した処理を書くと、大体こういったパターンが発生 します。 後続の何かの処理のために登録されているFrameObjectが、後続処理の 後に追加されると、発見しづらい意図しない不具合が発生します。 処理順を変えたりすると良く発生する不具合となります。
タイトル事例::FrameObject::Fix
◆ FrameObjectの登録タイミング
登録しても使用されない
Game
RenderSetup
{
Render
// 利用タイミングで、追加禁止にする
frame_pipeline->FixFrameObject< FO_OccluderMesh >();
auto occluder_mesh_list = frame_pipeline->GetFrameObjectList< FO_OccluderMesh >();
}
そのため、FrameObjectが消費されるタイミングで、追加禁止にする関
数を用意してあります。
この機能のおかげで、スレッドの処理順番を変更したり、マルチス
レッドにおけるタイミング問題が発生した際に、早い段階でミスに気
付くことが出来るようになりました。
タイトル活用例 ✓ ✓ ✓ ✓ タイトル紹介 スレッドの移行 FrameObject具体例 まとめ タイトル適用に関するまとめを行います。
タイトル活用例::まとめ ◆ お気づきいただけただろうか・・・ 皆さん、タイトル事例の途中でお気づきいただけたかもしれません が・・・
タイトル活用例::まとめ ◆ お気づきいただけただろうか・・・ 目標 ◆ 簡単に利用可能 ◆ シンプルな概念 ◆ 出来ればマルチスレッドではない方が良い システム概要 ◆ 全ての処理を並列化する事を目指さない ◆ 1つずつのシングルスレッド処理をパイプライン化して繋げる ◆ 結果的にマルチスレッド処理になる FramePipelineSystem設計時に立てた目標、とシステム概要に、 マルチスレッドコードは出来るだけ書かず、シングルスレッドを繋げ ると記載しているにもかかわらず、
タイトル活用例::まとめ ◆ お気づきいただけただろうか・・・ 目標 ◆ 簡単に利用可能 ◆ シンプルな概念 ◆ 出来ればマルチスレッドではない方が良い システム概要 ◆ 全ての処理を並列化する事を目指さない ◆ 1つずつのシングルスレッド処理をパイプライン化して繋げる ◆ 結果的にマルチスレッド処理になる ほとんど、並列化で乗り切ってる! FramePipelineシステムに移行したものの、結果として、ホトンド並列 化を増やす形で乗り切ってしましました。
タイトル活用例::まとめ ◆ お気づきいただけただろうか・・・ 目標 ◆ 簡単に利用可能 ◆ シンプルな概念 ◆ 出来ればマルチスレッドではない方が良い システム概要 ◆ 全ての処理を並列化する事を目指さない ◆ 1つずつのシングルスレッド処理をパイプライン化して繋げる ◆ 結果的にマルチスレッド処理になる ほとんど、並列化で乗り切ってる! FramePipelineSystemの設計方針自体は間違ってはいないハズ・・ その前に並列実行しやすくなってしまい、CPUコアを使ってしまった 今後に期待 この点に関してましては、 「設計方針自体はそこまで間違っていないけど、その前に並列実行し やすくなってしまい、結果的にCPUコアをいい感じに使ってしまった」 ということにしようかと思います。 レンダリング関連は、実装するメンバーがエンジンチームのため、並 列処理に慣れていることも多く、データ構造が並列化出来るように なったタイミングで、並列化してしまいました。 今後、エンジン機能の多くがFramePipelineに対応することで、ゲーム 側の処理もパイプライン化されていけば、さらなる改善が期待できる と感じています。
タイトル活用例::まとめ FramePipelineシステムへの移行に関して ◆ コスト ◆ システム自体の実装は軽量 今回、既にゲームが動いてたタイトルに対して、FramePipelineシステ ムへの移行を行いました。 FramePipelineシステムの実装自体は、そこまで大きくなく軽量でした。
タイトル活用例::まとめ FramePipelineシステムへの移行に関して ◆ コスト ◆ システム自体の実装は軽量 ◆ 既存コードの移行コストは個別に差がある (数時間~2週間など) ◆ 1フレームの取得ズレのミスの上に成り立っていたコードの修正 ◆ whileループ前提で組まれていたシステムの改修 ◆ マルチスレッド化出来るようになった部分のスレッドセーフ対応 既存コードの移行コストには、かなり差がありました。 ポストエフェクトなどのパラメータを受け渡すだけのようなものは、 実行確認の時間の方が長いくらい簡単でしたが、 いくつか、移行に時間のかかるものもありました。 取得タイミングが1フレームズレていて、そのタイミングで実装が成 り立っていたコードの修正であったり、 Whileループ前提で組まれていたシステムの改修、 マルチスレッド化出来るようになった部分をスレッドセーフな実装に 置き換える場合などに時間がかかりました。
タイトル活用例::まとめ FramePipelineシステムへの移行に関して ◆ 良かった点 ◆ 既存コードと共存可能、徐々に移行できた ◆ コードが単純になり、ミスが減った ◆ エラー検知が強化され、スレッド実行タイミングの調整も容易に ◆ FramePipeline対応後にマルチスレッド化しやすい設計になる 良かった点としては、既存コードと共存可能だったため、メイン作業 の裏で徐々に移行が出来たことです。 また、コードが単純になる場合が多く、今まで発生していた実装ミス が減りました。 最適化を行う場合に、スレッドの実行タイミングを調整することが多 いのですが、エラー検知がシステム化されたため、調整が容易になり ました。 一番大きな点としては、FramePipeline対応したコードがマルチスレッ ド化しやすい設計になるという事でした。
タイトル活用例::まとめ FramePipelineシステムへの移行に関して ◆ 良かった点 ◆ 既存コードと共存可能、徐々に移行できた ◆ コードが単純になり、ミスが減った ◆ エラー検知が強化され、スレッド実行タイミングの調整も容易に ◆ FramePipeline対応後にマルチスレッド化しやすい設計になる ◆ 懸念点 ◆ 全システムを移行するのには時間がかかる ◆ ゲームコードの依存関係の強い処理をどう切り分けるか ◆ 「Dear ImGui」などのシングルトン設計の便利機能の使用タイミングの制限が強くなった Dear ImGui : https://github.com/ocornut/imgui 懸念点としては、 全システムを移行するのには時間がかかるという点と、 まだ移行出 来ていない、ゲームコードなどの依存関係の強い処理をどうパイプラ イン化するか、 また、DearImGuiなどのシングルトン設計の便利機能を使用するタイミ ングの制限が強くなったという事です。 大変便利で、弊社エンジンのデバッグ機能で組み込んでいるのですが、 メインループが複数スレッドで構成されるため、イミディエイト系GUI がマルチスレッドで複数のウィンドウ開けない点など、対応が必要に なってきました。
まとめ 最後に、本公演全体のまとめに入りたいと思います
今後 ◆ FramePipelineSystemに移行し始めて ◆ 今世代機に関しては不安はなくなった ◆ CPUコアを使い切る状況が増えるため、 並列スレッドを減らし、パイプライン段数を増やす高速化になりそう 今後の展望として、FramePipelineSystemに移行し始めた結果、 まだまだ問題はありますが、今世代機に関しては不安はなくなりまし た。 タイミングによってはCPUコアを使い切ってしまい、足りなくなる場面 が増えてきました。 並列スレッド数や、ジョブのプライオリティの調整を行いながら、パ イプライン段数を増やす高速化に移行していくと思います。
今後 ◆ FramePipelineSystemに移行し始めて ◆ 今世代機に関しては不安はなくなった ◆ CPUコアを使い切る状況が増えるため、 並列スレッドを減らし、パイプライン段数を増やす高速化になりそう ◆ FrameThreadの自動バランス ◆ コア数や処理負荷に合わせて、FrameThreadと実スレッドのバランスを自動調整したい 1 1 2 2 3 3 4 4 FrameThreadがある程度の段数になった場合、実行しているプラット フォームのコア数や、それぞれのスレッドの処理負荷に合わせて、 FrameThreadを実行する実スレッド数や割り当てを自動でバランス調整 出来たら面白いなと感じています。 こちらは、4コアなどのコアの少ないPC環境などで、高プライオリ ティの実行スレッドを複数動かすと、ワーカースレッドに処理が回っ てこない問題の改善につながります
今後 ◆ FramePipelineSystemに移行し始めて ◆ 今世代機に関しては不安はなくなった ◆ CPUコアを使い切る状況が増えるため、 並列スレッドを減らし、パイプライン段数を増やす高速化になりそう ◆ FrameThreadの自動バランス ◆ コア数や処理負荷に合わせて、FrameThreadと実スレッドのバランスを自動調整したい ◆ Game処理の分割はHUD / Objectグループから? Gimmick Update Animation Draw HUD / UI Update Animation Draw Player/Enemy Update Animation Draw HUD / UI また、ゲームスレッドの複数分割に関しては、いくつかの方法が考え られますが、 「HUD処理は後続に回せそう」と考えているのと、 全てのオブジェクトの更新、アニメーション更新、描画などの機能を まとめて行っていたのですが、それをギミックやプレイヤーなどの ジャンルごとに分けて更新してみるところから始めようかと考えてい ます。
最後に ◆ 結果として、大枠で見た「データ指向設計」になっているように見える? ◆ フレーム単位でのメモリの局所化 ◆ スレッド間のオブジェクト指向設計を捨て、データの受け渡しへ 最後に感想となります。 設計当初は気づかなかったのですが、結果としては、大枠で見ると 「データ指向設計」になっているように見えました。 そもそも「データ指向設計」を語れるほど、僕の中で言葉の定義が ハッキリしていないので間違っているかもしれませんが。 SoAではないけど、フレーム単位では、メモリが局所化しやすくなって います。 また、オブジェクト指向設計から少し離れ、確定したデータの受け渡 しで、処理が構成されています。
最後に ◆ 結果として、大枠で見た「データ指向設計」になっているように見える? ◆ フレーム単位でのメモリの局所化 ◆ スレッド間のオブジェクト指向設計を捨て、データの受け渡しへ ◆ ゲーム処理は「オブジェクト指向設計」 ◆ 高速化が必要なサブシステムは「データ指向設計」 ◆ フレーム全体は「FramePipelineSystem」 ◆ という緩い感じで作れたらいいかもしれない ゲーム処理はオブジェクト指向設計、高速化が必要なサブシステムは データ指向設計、フレーム全体はFramePipelineSystemといった、緩い感 じで作れると良いなと考えています。
ご清聴いただき ありがとうございました Thanks Toyloエンジンチーム 関係プロジェクト開発スタッフ トイロジック 以上で、発表を終了します。 今回の発表内容は僕個人だけの作業ではございません。 Toyloエンジンチーム。プロジェクトスタッフやトイロジックのメン バーの協力やフィードバックを得て成り立っています。 本講演内容が、皆様のお役に立てれば幸いです。 Twitterでつぶやくと、僕が見てるかもしれません。ぜひ傷つかない程 度に、ご感想など投稿してください。 ご清聴いただきありがとうございました。
Q&A
当日の質問の要約(AskTheSpeaker含む) ◆ GPUからCPUへのリードバック処理がある場合に、うまく動かないのではないか? ◆ FramePipelineSystemは適用せず、システム外で実装を行っていました。 ◆ FrameObjectはListではなくVectorではダメなのか? ◆ そちらの方がキャッシュ効率も良さそうで、可能かもしれませんが、スレッド競合しない「追加」と「参照」の 実装に不安があったため、実装が簡単なリストにしました。今後の課題かもしれません ◆ FrameObjectのLockFreeListの実装は本当に簡単か? ◆ LockFreeListと呼んでいいのか不明ですが、削除機能がなく、追加しか発生しないため、LockFreeStackの機能 削除版くらいの実装コストになります。ABA問題も発生しませんので、簡単な部類に入ります。 ◆ オブジェクトの物理処理の反映が上手く動かないのではないか? ◆ 弊社エンジンでは「物理挙動の結果反映が次フレームの更新時に適用される」仕組みだったため、特に問題はあ りませんでしたが、実装方法によっては問題が発生する可能性がありそうです。 ◆ 各ステージ毎にコアを割り当てを変更したか? ◆ FrameThread単位で、コアのAffinityMaskとスレッドプライオリティの設定を行っています。 ◆ GameThreadはコア0,RenderThreadはコア5、GpuExecuteThreadはALLコアのような実装です。 ◆ パッドのInput情報の取得から、画面への反映までの時間を安定させるために何か行っているか? ◆ 何も行っておらず、Input情報の取得タイミングはFramePipeline導入前と変わらずGameThreadの更新間隔に依 存し、画面までの反映までは後続スレッドの負荷に依存するため、短かったり長かったりする実装になります。 <メモ> 当日のチャットおよびAskTheSpeakerでの質問をまとめました。 記憶を元に記載していますので、質問間違っている、抜けている場合 があります。 ご了承ください。