バジェット&パフォーマンス メモ

開発の為に各社のパフォーマンスバジェット数などのメモを残しておきます。
基本はPS5世代のオープンワールド関連で,メモは随時更新します。
また、ここに載っていないものでご存じのものがあれば、ぜひコメント等にて紹介して頂けると幸いです。

Cyberpunk 2077

・GDC 2023, “Building Night City: The Technology of Cyberpunk 2077”.
エンティティは15 millionなので,15 * 100万 = 1500万エンティティ。
ワールドセクターは256m単位。



Spider-Man 2

GDC 2024, “Applied Mesh Analysis: Automating Distant City LODs in Marvel’s Spider-Man 2”.

Hogwarts Legacy

GDC 2024, “Open World Rendering Techniques in ‘Hogwarts Legacy'”.
TODO : 後で追記。

Alan Wake2

GDC 2024, “Large Scale GPU-Based Skinning for Vegetation in Alan Wake”.





Call of Duty:MW2



God of War Ragnarok

GDC 2023, “Rendering “God of War: Ragnarok””.



Horizon Forbidden West

GDC 2023, “Space-Efficient Packaging For Horizon Forbidden West”.

GDC 2022, “Adventures with Deferred Texturing in Horizon Forbidden West”.

Ghost of Tsushima

GDC 2021, “Zen of Streaming: Building and Loading Ghost of Tsushima”.


Final Fantasy 16

CEDEC 2023, “Final Fantasy XVI:大規模ゲーム開発に向けて開発環境の取り組み”.

Skull and Bones

CEDEC 2023, “Skull and Bones カメラ依存のテクスチャストリーミング実例紹介”.


Tom Clancy’s The Division

GDC 2016, “Global Illumination in Tom Clancy’s The Division”.


Game Boy 研修(2)

今日は,エミュレーターの実装の進め方について紹介します。

基本的には…
MJHDというのブログ記事の「Rustでゲームボーイエミュレータを自作した話」に沿った手順で実装するのが良いと思います。
また,どのよう実装するかイメージがつかめない方は「脱・初級者のための自作GBエミュレーター開発」というスライドを参考にするものよいかもしれません。

Step.1 ROMをデコードする
Step.2 CPU処理を実装する
Step.3 PPU処理を実装する
Step.4 デバッガを実装する
Step.5 CPUテストROMを通す
Step.6 機能追加・改善を考え,実装する。

ROMをデコードする

まずは,ゲームボーイのソフト。つまりカートリッジデータが読み込みできなければ話になりません。
…なので,まずはカードリッジデータを読み込めるようにしましょう。カードリッジデータが読み込めれば,ゲームで使用する命令列などが読み取れるようになるはずです。
カートリッジのデータフォーマットなどについては,Pan Docs にまとまっているようなので,どういうデータ構造になっているのか?についてはドキュメントを読みましょう。
どんなプログラマーもドキュメントが読み解けるようにならなければいけません。特に,コンソールプラットフォームなどは秘密事項の塊で,インターネットに情報が出ることはめったになく,公式ドキュメントを見なければ何も出来ないということが多々あります。
そのため,ドキュメントを見ながら実装力をつける訓練の最初の一歩としてやってみましょう。

CPU処理を実装する

続いて,絵を出す前にCPU処理をエミュレートした方が良いでしょう。
ロード命令やジャンプ命令・加算・減算・ビット演算など基本的な命令を実現できるようになりましょう。
また,基本的な命令の実装を通じて,低レベルで何が行われるのか?を知ることが研修の目的の一つでもあります。

PPU処理を実装する

グラフィックスプログラマーを目指すものであれば,絵が出せなければいけません。また,やっぱりゲームは絵が出てこそ!というところもあるので,絵を出しましょう。
また、PPU(Picture Processing Unit)で何が行われているかを知りましょう。これを知るためには,PPU処理を実装するのが一番です。
PPU処理の実装が出来れば,一番処理にGame Boyプログラムで作った「Hello World」の文字列表示プログラムが動かせるようになるはずです。

デバッガを実装する

PPU処理まで実装出来たら,あとはひたすら精度・品質を上げていくだけです。
そのためには,デバッガの実装は必須でしょう。
また,デバッガを実装するためにどのようなことをしなければないのか,普段我々がVisual Studio何気なくつかっているブレークポイントなどの機能はどのようにしておこなわれているのか?などデバッガの仕組みを身をもって体験してください。

CPUテストROMを通す

最後の仕上げです。
実機で動作保証がされているCPUテストと呼ばれるROMを実行し,自作のエミュレーターで正常動作すること確認してください。
テストのROMは以下にあります。
https://gbdev.gg8.se/files/roms/blargg-gb-tests/
正常動作しない場合は,エミュレーターの実装に問題があるということです。
特に会社等では,テスト駆動開発を実施している会社もあるのと思うので,ここでテストの重要さ,製品の品質保証など,クオリティーを上げる大事さを学んでください。

機能追加・改善を考え,実装する

言われた仕様,あるいはお題に通りに作れば最低ラインは超えたことになります。少なくとも一般的な会社では言われたこと通りものを作ることが出来れば,きちんと給料は支払われるはずです。
ここから先は,「他とどう差別化するか?」という話です。同じ機能でも見た目をカッコよくできるのであれば,付加価値はあがるでしょう?また,他社や他人には出来ない機能追加をするのもよいかもしれません。あるいは,思い切って機能を削ることによる分かりやすさの改善・動作安定性なども差別化が図れるでしょう。
余力がある人は,自分なりのカラーを出せるように改造・改変してみてください。
特に昨今はKFCの事例のように何も考えない会社が作るとUX(User Experience:ユーザー体験またはユーザー体感)が激悪化しやすいです。また,どことはいいませんが解約手続きの面倒さなど,もう二度と使うもんか!と思うような酷いものあったりします。ユーザーにとって何か使いやすいのか?何が嬉しいのか?については我々は常に思考を巡らせる必要があります。特に日本の大手会社(例えば電機メーカーなど)などはこうした考えが非常に弱く,そのせいで海外メーカーにシェアを取られるというひどい状況に陥っています。携帯電話のUXの酷さなどはそのもっともたる例のひとつになるかもしれません。
我々はグローバルに戦っていく必要があります。そのためは,常にユーザーないし,使う側の立場にとって良いものを提供する必要があると思います。
そこで,余力がある人は作ったアプリに対して,ユーザーにとって有益であるような機能追加・機能改善を実行してみてください。
恐らく基本となる考え方は「自分だったら嬉しいか・喜ぶか」がベースになるのではないかと思います。そこから,平均と比べて自分がどのぐらいズレがあるのかを推測して補正していくと良いでしょう。

Game Boy 研修 (1)

こんにちわ。Pocolです。
今年は研修どうしようか悩んでいたんですが,今の若い人はあんまりハードのことを知らないのではないか?(当然、めっちゃ詳しい人もいますが…)
という考えが湧いてきたので,今年はGame Boyでプログラミングするというのを研修ネタとしてやってみようかなと思います。

目的

ハードウェアについて理解し,実際にゲームを作成し,プログラミング能力および開発能力を高める。
また、ポーティング作業を行うことでマルチプレットフォーム展開を行う際に、やってはいけないことを体感させ,開発効率改善および向上のための意識づけを行うことを目的とする。
エミュレーター開発を通じて各種APIへの理解を深める。

目標

  • 開発環境を自分で整えられるようになる。
  • Game Boy上で動くゲームを自力で開発する。
  • Game Boyエミュレーターを自作し,ハードウェアを理解する。
  • 指定されたプラットフォーム上で動作するように自作したGame Boyエミュレーターの移植作業を行う。

資料

● The Ultimage Game Boy Talk

● The Game Boy, a hardware astopsy – Part 1: the CPU

● The Game Boy, a hardware autopsy – Part 1.5: a few mistakes and register F

● The Game Boy, a hardware autopsy – Part2: Memory mapping

● GBDKの導入~ゲーム作成まで
https://qiita.com/BubbleBubble/items/9c28c57ed942c9b48843

● きるこ日記帳
https://www.dkrk-blog.net/category/GameBoy

● ゲームボーイのゲームを作ってみる
https://www.youtube.com/watch?v=xhUOhT7KKE4&list=PLbP78mBX9wpCH1SwnLix14rd1g47XJ5fO

● Game Boy Development community
https://gbdev.io/

● Open Game Boy Documentation Project
https://mgba-emu.github.io/gbdoc/

スケジュール

● 第1週目
お題:指定されたライブラリを利用した開発環境を整えてください。そして,第3者が開発環境を整えられるように手順書を作成してください。
アドバンスなお題:Visual Studio Codeで開発できるようにしてください。

● 第2~3週目
お題:サンプルアプリを作ってください。
  ・Hello, Worldの表示
  ・スプライト描画
  ・十字キー入力でキャラを動かす
  ・音を鳴らす (Option)
  ・説明書を作る (Option)

● 第4週~第5週目
お題:Game Boyエミュレータを自作して,サンプルアプリを動かしてください。ただし、描画APIを利用する場合はDirectX12を使用してください。
※6週目~7週目を見越してラッパーAPIを作っておくのは可とします。
アドバンスなお題:
  ・ゲームパッド対応を行ってください。
  ・どこでもセーブできる機能を作ってください。
  ・アップスケール対応を行ってください(Option) [ex]Hq3xなど…
  ・可能であれば次のSIGGRAPH論文を実装してしてください(Advanced)
    Johannes Kopf, Dani Lischinski, “Depixeling Pixel Art”, SIGGRAPH 2011 Technical Papers, https://johanneskopf.de/publications/pixelart/
    実装参考として,次のPythonコードを参照するのは可とします。https://github.com/vvanirudh/Pixel-Art
補足:動作確認には自作のアプリとhttps://gbdev.gg8.se/files/roms/blargg-gb-tests/のROMが動作することを確認してください。

● 第6週~第7週目
お題:指定プラットフォーム上で,自作Game Boyエミュレータを動作するように移植作業を行い,サンプルアプリを動かしてください。

1-Phase Occlusion Culling

完全に私的な実装メモです。
通常,オクルージョンカリングを実装する際は,GPU Zenやもんしょさんのサイトに載っているように2-Phaseでジオメトリ描画して実装するのが普通かと思います。
少し前ですが,GDC 2021の“Samurai Landscapes: Building and Rendering Tsushima Island on PS4”というセッションの,43:45あたりから,Occlusion-Cullingについての説明があり,Ghost of Tsushimaの実装では,前フレームの深度バッファと,それらから保守的(conservative)に作成したミップレベル,エピポラーサーチを用いて,現在フレームの深度バッファを復元し,1回のジオメトリ描画でオクルージョンカリングを実装する方法が紹介されています。
説明が分かりやすいので,アルゴリズムについては元動画を参照してください。
馬鹿まじめに線形探索をせずに,ミップマップを使って検索するのがアルゴリズムのキモみたいです。

一応実装コードが紹介されていますが,動画の品質が低すぎて全然良く見えませんw。
そこで,それっぽい感じに見えるコードを自分で推測しながら書いてみました。
推測で書いているのと,きちんと動作検証もしていないので,バグっている可能性があるので,まんまコピペで使用して不都合・不利益が発生しても何ら責任は負いませんのでご注意ください。
もし、「ここはこうじゃね?」とかアドバイスあればコメントください。

Depth Reprojection

// Forward-project last frame's depth to this frame's space
{
    float zPrev = pSrt->m_texIn[dispatchThreadId.xy];
    float zDepthPrev = DepthFromWorld(zPrev, pSrt->m_vecRecoryHypebolicDepth);

    float4 posClipPrev = float4(VecClipFromUv(uvThread), zDepthPrev, 1.0f);
    posClipPrev *= zPrev; // This ensure that zCur will end up in posClip.w;

    float4 posClip = mul(posClipPrev, pSrt->m_matClipPrevToClipCur);
    posClip.xyz /= posClip.w;

    float2 uv = UvFromVecClip(posClip.xy);

    if (all(uv >= pSrc->m_rectScissor.xy) && all(uv >= pSrc->m_rectScissor.zw))
    {
        // NOTE : z ends up in posClip.w so we can use that directly.
        float z = posClip.w;
        
        if (z < pSrt->m_zNear)
            z = pSrt->m_zFar;

        // NOTE : Add a small bias to resolve self-occlusion artifacts
        z += pSrt->m_dsBias;

        if (z > pSrt->m_zFar || zPrev >= pSrt->m_zFar)
            z = pSrt->m_zFar;

        float2 xy = floor(uv * pSrt->m_texture.m_dXy); // 多分、テクスチャサイズを乗算してウィンドウサイズに戻している。
  
        AtomicMax(pSrt->m_texOut[xy], z);
    }
}

{
     float4 posClip = float4(VecClipFromUv(uvThread), 0.0f, 1.0f);

     float4 posClipPrev = mul(posClip, pSrt->matClipCurToClipPrev);

     if (any(abs(posClipPrev.xy) >= abs(posClipPrev.w))
     {
          float2 uv = UvFromVecClip(posClipPrev.xy / posClipPrev.w);

          float z = pSrt->m_texIn->SampleLOD(pSrt->m_sampler, uv, 0) * pSrt->m_dsBias;

          AtomicMax(pSrt->m_texOut[dispatchThreadId.xy], z);
     }
}

Hole-Filling

float2 uv = (dispatchThreadId.xy + 0.5f.xx) * pSrt->m_texture.m_div;
float zMax = pSrt->m_texIn.SampleLOD(pSrt->m_sampler, uv, 0);

if (zMax == -FLT_MAX)
{
    float2 normalEpipole = normalize(pSrt->m_uvEpipole * uv);
    float2 dUvStep = pSrt->m_texture.m_div * normalEpipole;

    for(uint iMip = 1; iMip <= pSrt->m_iMipLast; ++iMip)
    {
        float2 uvSearch = uv - dUvStep;
        dUvStep *= 2.0;

        float z = pSrt->m_texIn.SampleLOD(pSrt->m_sampler, uvSearch, iMip);

        if (z != -FLT_MAX)
        {
            z = max(z, pSrt->m_texOut[dispatchThreadId.xy]);
            z *= pSrt->m_dsBias;
            z = min(z, pSrt->m_zFar);
            pSrt->m_texOut[dispatchThreadId.xy] = z;

            return;
        }
    }

    if (zMax < pSrt->m_zNear)
        zMax = pSrt->m_zFar;

    zMax = min(zMax, pSrt->m_zFar);

    pSrt->m_texOut[dispatchThreadId.xy] = zMax;
}

GDC 2023関連

こんにちわ、Pocolです。
GDC 2023関連の情報をまとめていなかったので,(FreeContentsで)興味ある資料をまとめておこうと思います。

道徳の授業

こんにちわ、Pocolです。
今日は私が最近取り組んでいる内容の一つをご紹介しようと思います。

私が小学生ぐらいの頃に,学校の授業で「道徳」という科目があったのですが,自分は結構話の内容が面白くて好きだったなーとふと思い出しまして,こういうのってあんまり会社では教育してくれないよなって思って,実験的にちょっとやってみようかと思って最近新人さんを捕まえて勝手にやってます。
当然、自分の昔話しても単なる「老害」になってしまうし,全く意味が無いし,自分だったら「お前の話聞いても面白くないよ」と思ってしまうので,これはダメ。
また,よく社会人になりたての頃にやる研修とかでやるような内容をやっても,対して業務に役に立たないことが多いので,違う内容をやろうと思いました。
…で,色々と考えて面白く学べる内容かつ業務内容にも効きそうな内容をやると良いんじゃないかと。毎年CEDECとかで基調講演とかやってますけど,自分はああいうのが結構面白くて好きな人間でして,ガイアの夜明けとかカンブリア宮殿,がっちりマンデーとかが好きなので,そういうテイストの動画を見てもらって,実績ある人から開発で心がけたこととか,作る際に気を付けたこと,どうやってアイデアが出てきたか?などを知ってほしいなと思って,最近色々と動画を漁って,その動画を見て気づきを得てもらう。
…というようなことをやっています。

初回にやったのは,任天堂の山内溥さんの話を見てもらいました。
NHKアーカイブス あの人に会いたい File.494 山内溥

結構色々と面白い話があって,その中の一つに

今まで過去に売れた そういうゲームのいいところだけを取ってきてね
面白い箇所だけ寄せ集めてきてね
それでまとめてしまう
ということをやる人もいますけど
そんなことをしてもユーザーは知っているわけですよ
ユーザーが求めているのは独創的な楽しさ面白さをもとめているわけです 絶えず

という言葉があって,「確かになぁ!」と自分は思いました。
自分がユーザーだとしたら一度見せられたものを見ても新鮮さは無いわけです。もっと言えば,飽きちゃう。飽きちゃうから面白くない。

あと面白いと思ったのは,次のような話もありました。

大衆が買える価格でないと広がらないんですよ
16であろうと32ビットであろうと64ビットであろうとユーザーは関係がないんです ユーザーは
ゲームというのはインタラクティブで遊んで楽しい面白いものなんです
映像がすごい 音声がすごいと言ったって
遊んだら全然面白くも楽しくもなかったら
ユーザーはどうするんですか こんなもん

買いません ユーザーは絶対に。

…まぁ、ものすごく当たり前のことを言っていますよね。
でも,正直なところ開発する際に,こういう当たり前のことを忘れてしまっているな,と自分は気づかされました。

こういう動画を見て感じることは人それぞれ違うと思うんです。
それを動画を見た後に感想会みたいな感じで言い合って,共有するということをやっています。
ただ,動画見るだけども面白いですけども,人それぞれ解釈の仕方のも違うので,色々な新しい考え方も取り込めて面白いんじゃないかと思ってやっています。

この「道徳の授業」がどういう効果をもたらすかは未知ですが…
少なくとも,人が何を面白いと思って開発しているのか,どういう思いを持って開発しているのかについては「知らない」よりは「知っていた方がよいことがある」という信念をもってやっていますし,単純に自分が面白いというものを推し活しているだけなのですが,今後何かいいことがあるといいなとワクワクしながら取り組んでいます。
何か良いことが起こったら,皆さんにも共有したいと思います。

GPU最適化備忘録

こんにちわ,Pocolです。
今日はGPU最適化のための自分用のメモを書いておこうと思います。
自分がある程度理解できればいいことを前提に書いているので,「そもそも間違っている」とか「全然正しくない」とか「こうした方がもっといい」とかあれば,ご指摘ください。

P3 Method

基本的には,Louis Bavoil氏が書いている「The Peak-Performance-Percentage Analysis Method for Optimizing Any GPU Workload」に従ってボトルネックを探し,改善を行っていきます。GDCでも講演があるので,興味がある方はそちらの動画を見ると良いと思います。
この手法は次のステップで最適化を行います。

(1) “Top SOL%”の値をチェックする。
– (A) 80%より大きいの場合: SOLユニットから処理を取り除くことを試みる。
– (B) 60%より小さいの場合: top SOL%の値が増加するように試みる。
– 上記以外の場合: (A)と(B)の両方を行う。

(2) Top SOLユニットがSMの場合(あるいは,SOL%の項目が近い場合)
 “SM Throughput for Active Cycles”の値をチェックする。
– (C) 80%より大きい場合: 命令群を適宜スキップしてみたり、特定の計算をルックアップテーブルに移行することを検討してみる。
– (D) 60%より小さい場合: SM占有率(実行中のアクティブワープ数)の増加を試みる & SM発行ストールサイクルの数を減らすことを試みる
– 上記以外の場合: (C)と(D)の両方を行う

(3) その他がTop SOLユニットの場合、そのユニットに送られる作業量を減らすことを試みる。

ここまで出てきた用語は次の意味です。

  • SOL:Speed Of Light。最大スループットを意味する。
  • SM:Streaming Multiprocessor。共有メモリやコンスタンスとキャッシュ,テクスチャキャッシュ,複数のスカラープロセッサなどから構成されるもの。ものすごく雑に言えばUnified Shaderのこと。
  • Throughput。ハードウェアが単位時間あたりに処理できるデータ量のこと。あるいは,その数値を使ってデータ処理能力やデータ転送速度を表す。

さて,最適化する上で肝心なのは「Top SOLをどうやって調べるか?」ということになります。これはNVIDIA Nsight Graphicsを使って調べることができます。
まず,Nsightを立ち上げる前にパフォーマンスカウンターなどを取得できるように設定を変更しておきます。NVIDIAコントロールパネルを開きます。Windowsのタスクバー右下にある^をクリックして,NVIDIA設定を右クリックし,NIVIDIAコントロールパネルを選択します。選択してもコントロールパネルが開かない場合は,何らかの異常が発生している恐れがあるのでグラフィックスドライバーを再インストールして改善するか試してみてください。コントロールパネルを開いたら,メニューの「デスクトップ」から「開発者設定を有効にする」にチェックを入れておきます。これでカウンター等の値がとれるようになるはずです。

図1.NVIDIAコントールパネルでの設定

続いて計測を行います。Nsight Graphicsを立ち上げFrame Profilerとしてアプリケーションを立ち上げます。立ち上げ方はConnect to processのウィンドウで,Activityの項目をFrame Profilerに設定して,Application Executableにアプリケーション実行パスを指定,Working Directoryに作業ディレクトリを設定してください。自動的にアタッチする場合はAutomatically ConnectにYesを,Steamなどのようにいったん別exeをかましてから立ち上げる場合はNoに設定しておき,Target PlatformでAttachタブを選択してから,プロセスにアタッチするという方法を取ります。
アプリケーションが立ち上がったら,左上にオーバーレイが表示されるので,F11を押して測定したいフレームをキャプチャーします。

図2.オーバーレイの表示

キャプチャーすると,画面右上にRange Profilerというものが表示されるので,調べたいドローコール部分をRange Profilerでクリックします。クリックすると,少し時間をおいてから次のようにRange InfoやPipeline Overviewなどが表示されます。

図3.フレームキャプチャー

あとは,Pipeline Overviewに表示されるSM / L2 / VRAM / CROP / RAS の値を見て上記のステップに沿って改善を行います。

図4.Pipeline Overview

(A) Top SOL% > 80% の場合

GPU上で非常に効率的に動作していることが分かります。高速化するためには,このTop SOLユニットを処理から削除し,他のユニットのボトルネックを探していきます。例えば SM が Top SOL ユニットの場合は,命令群をスキップしてみたりなど。もう一つの例としては構造化バッファのロードを定数バッファに移して高速化するなどです。

(B) Top SOL% < 60% の場合

トップ SOL%の値が60%未満場合、トップ SOL ユニットと SOL% が低い他のすべての GPU ユニットは、使用率が低い(アイドルサイクル)、非効率的に動作している(ストールサイクル)、または与えられた作業負荷の仕様により高速パスに当たらないということを意味します。このような状況の例としては、以下のようなものがあります。

  • アプリケーションが一部CPUに制限される
  • Wait For IdleコマンドやGraphic←→ComputeスイッチでGPUパイプラインを何度も消耗している
  • テクスチャオブジェクトからのTEXフェッチは、フォーマット、次元、またはフィルタモードによって、設計上、スループットが低下して実行されます(GTX 1080のこれらの合成ベンチマークを参照)。例えば、3Dテクスチャをトライリニアフィルタリングでサンプリングする場合、50%のTEX SOL%が期待されます。
  • TEXやL2ユニットのキャッシュヒット率が低い、VRAMアクセスがまばらでVRAM SOL%が低い、GPU VRAMではなくシステムメモリからVB/IB/CB/TEXをフェッチするなど、メモリサブシステムの非効率性。
  • 入力アセンブリの32ビットインデックスバッファのフェッチ(16ビットインデックスに比べ半減)

この場合、トップSOL%の値を使用して、非効率な処理を削減することによってこの処理負荷で達成できる最大利得の上限を導き出すことができます。

(C) SM Throughput For Active Cycles > 80% の場合

SM がトップ SOL ユニットで、「SM Throughput For Active Cycles」が 80%より大きい場合、現在の処理負荷は主に SM スケジューラの発行率によって制限されているため、SM の占有率を上げてもパフォーマンスは大きく向上しません(少なくとも、処理負荷は 5%以上増加しない)。

この場合,次のステップは、SMスケジューラの帯域幅を飽和させているのはどのような命令かを把握することです。典型的なのは算術命令(FP32や整数演算)ですが、テクスチャフェッチや共有メモリアクセスなどのメモリ命令もあり得えます。また、SMがトップSOLユニットで、SM Throughput for Active Cyclesが80%以上のワークロードでは、TEXユニットもSMのSOL%に近い値でなければ、TEX命令(SRVおよびUAVアクセス)が性能を制限している可能性はないでしょう。

(D) SM Throughput For Active Cycles < 60% の場合

このGTC 2013の14分の講演のスライド15にあるように、あるワープ命令が発行できない場合(オペランドの準備ができていない、あるいは実行に必要なパイプラインサブユニットの準備ができていないため、これをワープストールと呼びます)、SM命令スケジューラは、別のアクティブワープに切り替えてレイテンシーを隠蔽しようとします。そこで、SMスケジューラがSMアクティブサイクルあたりにより多くの命令を発行できるようにするには、2つの方法があります。

(1)SMの占有率(スケジューラが切り替え可能なアクティブなワープの数)を上げる
(2)SM発行ストール待ち時間を短縮する(ワープがより少ないサイクルでストール状態に留まるようにする)。

アプローチ1:SM占有率を増加する

SMがトップSOLユニット(またはそれに近い)、「アクティブサイクルのSMスループット」60%未満であれば、SMの占有率を上げるとパフォーマンスが向上するはずですが、まず何が制限になっているかを把握する必要があります。

ピクセルシェーダとコンピュートシェーダで最も一般的な SM 占有率のリミッタは、シェーダが使用するスレッドごとの ハードウェアレジスタの数です。

ハードウェアのレジスタ数が最大理論占有率(アクティブサイクルあたりのアクティブワープ数)に与える影響は、CUDA Occupancy Calculatorで確認できます。
Maxwell、Pascal、Volta GPU では、レジスタの他に、以下のリソースでも SM 占有率を制限することができます。

グラフィックスシェーダについて:

  • 頂点シェーダー出力アトリビュートの合計サイズ。
  • ピクセルシェーダーの入力アトリビュートの合計サイズ
  • HS、DSまたはGSの入力および出力アトリビュートの合計サイズ。
  • ピクセルシェーダの場合、ピクセルワープのアウトオブオーダー完了(通常、動的ループや早期終了ブランチなどの動的制御フローに起因する)。CS のスレッドグループは任意の順序で完了できるため、CS にはこの問題があまりないことに注意してください。Gareth Thomas & Alex Dunnによる「Practical DirectX 12」についてのGDC 2016講演のスライド39をご覧ください。

コンピュートシェーダについて:

  • スレッドグループのワープがSM上でall-or-none方式で起動するため、スレッドグループのサイズはSMの占有率に直接影響します(つまり、スレッドグループのすべてのワープが必要なリソースを利用でき、一緒に起動するか、または何も起動しないかです)。スレッドグループのサイズが大きくなると、共有メモリやレジスタファイルなどのリソースの量子化が粗くなります。アルゴリズムによっては純粋に大きなスレッドグループを必要とする場合もありますが、それ以外の場合は、開発者はできるだけスレッドグループのサイズを64スレッドまたは32スレッドに制限するようにすべきです。これは、64 または 32 スレッドサイズのスレッドグループが、シェーダプログラムに最適なレジスタターゲットを選択する際に、シェーダコンパイラに最も柔軟性を与えるからです。
  • さらに、レジスタ使用量が多く (>= 64)、SM (HLSL の GroupMemoryBarrierWithGroupSync()) におけるスレッドグループバリアのストール時間が長いシェーダでは、スレッドグループのサイズを 32 に下げれば、64 と比較してスピードアップする可能性もあります。64 レジスタのガイダンスにより、SM あたり最大 32 スレッドグループの制限(CUDA Occupancy Calculator のアーキテクチャ依存の「Thread Blocks / Multiprocessor」制限)が、SM 占有率の主要制限要因にならないようにします。
  • CUDA Occupancy Calculatorに様々な数値を入力することで、スレッドグループごとに割り当てられる共有メモリの総バイト数もSMの占有率に直接影響を与えることが分かります。
  • 最後に、短いシェーダ(例えば、TEX命令と2つの算術命令)を持つDispatchコールのシーケンスでは、SMの占有率は、上流ユニットのスレッドグループの起動率によって制限される場合があります。この場合、連続したDispatchコールをマージすることが有効な場合があります。

CUDA Nsightのドキュメントページ「Achieved Occupancy」には、Compute ShadersのSM占有率制限の候補がいくつか挙げられています。

  • スレッドグループ内の処理負荷が偏っている。
  • 起動したスレッドグループが少なすぎる。これは、GPU Wait For Idles の間に SM を完全に占有するのに十分なワープを起動しないグラフィックシェーダの問題にもなりえます。

注:スレッドグループのサイズを小さくするには、実際には2つのアプローチがあります。

  • アプローチ1:スレッドグループのサイズをN倍に下げ、同時にグリッドの起動次元をN倍にする。
  • アプローチ2:N>=2個のスレッドの作業を1個のスレッドに統合する。これにより、マージされたN個のスレッド間でレジスタを介して共通データを共有したり、アトミック演算(HLSLのInterlockedMinなど)を用いて共有メモリではなくレジスタで削減を実行したりすることができます。さらに、このアプローチには、N個のマージされたスレッド間でスレッドグループの統一操作を自動的に償却する利点もあります。しかし、この方法によるレジスタの肥大化の可能性には注意する必要があります。

注:もし、あるワークロードの SM 占有率が、主にフルスクリーン・ピクセルシェーダとコンピュー トシェーダのどちらでレジスタカウントが制限されているかを知りたい場合は、次のようにしてください。

  • スクラバーで追加し… “Program Ranges” を実行し、調査したいシェーダプログラムの範囲を見つけます。Program Range を右クリックし、Range Profiler を起動し、Pipeline Overview Summary の “SM Occupancy” 値を確認します。
  • スクラバーの「Time(ms)」行をクリックして、Range内のいくつかのレンダーコールを選択します。タブを API Inspector に切り替え、ワークロードのサイクルのほとんどを占めるシェーダステージ(PS または CS)を選択し、シェーダ名の横にある「Stats」リンクをクリックします。
  • 「Shaders」Nsightウィンドウが表示され(下図14参照)、「Regs」列にシェーダのハードウェアレジスタ数が表示されます。シェーダの統計情報が入力されるまで数秒待つ必要があることに注意してください。
  • CUDA Occupancy Calculator グラフを使用して、このレジスタカウントに関連する Max Theoretical Occupancy を調べ、このシェーダの Range Profiler が報告する実際の 「SM Occupancy」 と比較します。
  • もし、達成した占有率が最大占有率よりずっと低ければ、SMの占有率はスレッドごとのレジスタの量だけでなく、他の何かによって制限されていることが分かります。

あるシェーダに割り当てられるレジスタの総数を減らすには、DX シェーダのアセンブリを見て、シェーダの各ブランチで使用されるレジスタの数を調べればよいです。ハードウェアは、最もレジスタを必要とするブランチに対してレジスタを割り当てる必要があり、そのブランチをスキップするワープは、最適ではない SM 占有率で実行されます。

フルスクリーンパス(ピクセルまたはコンピュートシェーダ)の場合、この問題に対処する典型的な方法は、ピクセルを異なる領域に分類するプリパスを実行し、各領域に対して異なるシェーダの並べ替えを実行することです。

  • コンピュートシェーダについては、このSIGGRAPH 2016のプレゼンテーションでは、シェーダの順列ごとにスレッドブロックの数を変えながら、画面上の異なるタイルに特殊なコンピュートシェーダを適用するためにDispatchIndirectコールを使用したソリューションが説明されています。
    “Deferred Lighting in Uncharted 4” – Ramy El Garawany (Naughty Dog).
  • ピクセルシェーダについては、異なる特殊化アプローチを使用することができます。フルスクリーンのステンシルバッファをプリパスで満たし、ピクセルを分類することができます。それから、ピクセルシェーダ実行の前に起こるステンシルテストに依存し(これは我々のドライバによって自動的に行われるべきです)、現在のシェーダの順列が触れていないピクセルを破棄するステンシルテストを使用することによって、複数の描画コールを効率的に実行することができます。このGDC 2013のプレゼンテーションでは、このアプローチでMSAA遅延レンダリングを最適化します。”The Rendering Technologies of Crysis 3″ – Tiago Sousa, Carsten Wenzel, Chris Raine (Crytek).

最後に、あるコンピュートシェーダのSM占有率制限をよりよく理解するために、我々のCUDA Occupancy Calculatorスプレッドシートを利用することができます。使用するには、GPU の CUDA Compute Capability とシェーダのリソース使用量(スレッドグループサイズ、Shader View からのレジスタ数、共有メモリサイズ(バイト))を記入するだけです。

アプローチ2:SM発行ストール待ち時間を減らす

SM占有率を上げる以外の方法でSM Throughput For Active Cyclesを上げるには、SM issue-stall cyclesの回数を減らすことです。これは、命令発行サイクル間のSMアクティブサイクルで、オペランドの1つがレディでない、または命令を実行する必要があるデータパス上のリソース競合が原因で、ワープ命令がストールしている間です。

PerfWorksのメトリクスsmsp__warp_cycles_per_issue_stall_{reason}は、ワープが{reason}で停止した命令イシュー間の平均サイクルを示しています。PerfWorksメトリックsmsp__warp_stall_{reason}_pctは、サイクルごとにその理由で停止したアクティブなワープの%です。これらのメトリックは、Range ProfilerのUser Metricsセクション、およびSM Overview Summaryセクションで、降順にソートされて公開されています。ワープが停止する理由としては、以下のようなものが考えられます。

  1. “smsp__warp_stall_long_scoreboard_pct” PerfWorks メトリクスは、L1TEX (local, global, surface, tex) オペレーションのスコアボード依存を待つためにストールしたアクティブワープのパーセンテージを示します。
  2. “smsp__warp_stall_barrier_pct” PerfWorks指標は、スレッドグループのバリアで兄弟ワープを待つためにストールしたアクティブワープのパーセンテージを示します。この場合、スレッドグループのサイズを下げるとパフォーマンスが向上する場合があります。これは、各スレッドが複数の入力要素を処理するようにすることで実現できる場合があります。

“sm__issue_active_per_active_cycle_sol_pct” が 80% より低く、 “smsp__warp_stall_long_scoreboard_pct” がワープストールの理由のトップなら、そのシェーダは TEX-latency limited であることが分かっているはずです。これは、失速サイクルのほとんどが、テクスチャフェッチ結果との依存関係 から来ていることを意味します。この場合は…

  1. シェーダのコンパイル時に反復回数がわかるループがある場合(ループ回数ごとに異なるシェーダの並べ替えを使用する場合もある)、HLSL の [unroll] ループ属性を使用して FXC にループを完全に展開させるようにしてみてください。
  2. シェーダが完全にアンロールできない動的ループ(たとえば、レイマーチ ングループ)を実行している場合は、テクスチャフェッチ命令をバッチして、TEX 依存のストールの数を減らしてください(HLSL レベルで、 独立したテクスチャフェッチを 2~4 のback-to-back命令のバッチに まとめることによって)。
  3. シェーダが与えられたテクスチャのピクセルごとにすべての MSAA サブサンプルを反復している場合、そのテクスチャの TEX 命令の 1 バッチで、すべてのサブサンプルを一緒にフェッチします。MSAA サブサンプルは VRAM 内で隣り合わせに保存されるため、一緒にフェッチすれば TEX ヒット率は最大になります。
  4. テクスチャの負荷が、ほとんどの場合、真になると予想される条件に基づいている場合(例えば、if (idx < maxidx) loadData(idx))、負荷を強制して座標をクランプすることを検討する(loadData(min(idx,maxidx-1)))。
  5. TEXキャッシュとL2キャッシュのヒット率を向上させることで、TEXレイテンシーを減少させてみてください。TEX および L2 ヒット率は、サンプリングパターンを微調整して隣接ピクセル/スレッドがより多くの隣接テクセルをフェッチするようにし、該当する場合はミップマップを使用し、さらにテクスチャのサイズを小さくしてよりコンパクトなテクスチャフォーマットを使用することによって向上させることが可能です。
  6. 実行されるTEX命令の数を減らしてみてください(おそらく、TEX命令述語としてコンパイルされる、テクスチャ命令ごとのブランチを使用します、例としてFXAA 3.11 HLSLを参照してください、例.”if(!doneN) lumaEndN = FxaaLuma(…);”).

トップSOLユニットがSMではない場合

どのユニットかを確認して,下記に従って対処を行います。

(1)トップSOLユニットが,TEX, L2, あるいは VRAMの場合

トップ SOL ユニットが SM ではなく、メモリサブシステム ユニット(TEX-L1、L2、および VRAM)の 1 つである場合、性能低下の根本原因は、GPUフレンドリーではないアクセスパターン(通常、ワープ内の隣接スレッドが遠く離れたメモリにアクセスする)により発生した TEX または L2 キャッシュのスラッシングである可能性があります。この場合、最上位制限ユニットは TEX または L2 であるかもしれませんが、根本的な原因は SM によって実行されるシェーダにある可能性があるため、最上位 SOL ユニットが SM の場合を使用して SM のパフォーマンスをトリアージ方法で決定する価値があります。

トップ SOL ユニットが VRAM であり、その SOL% 値が悪くない(60% 以上)場合、このワークロードは VRAM スループットが制限されており、別のパスでマージすることでフレームが高速化されるはずです。典型的な例は、ガンマ補正パスと他のポストプロセッシングパスをマージすることです。

(2)トップSOLユニットがCROPあるいはZROPの場合

CROP がトップ SOL ユニットである場合、より小さいレンダーターゲット形式(例:RGBA16F の代わりに R11G11B10F)を使用してみたり、Multiple Render Targets を使用している場合、レンダーターゲットの数を減らしてみたりすることができます。また、ピクセルシェーダでより積極的にピクセルをキルすることは価値があるかもしれません(たとえば、特定の透明効果では、不透明度が1%未満のピクセルを破棄する)。透明レンダリングを最適化するための可能な戦略については、「透明(または半透明)レンダリング」を参照してください。

ZROP が Top SOL ユニットである場合、より小さい深度フォーマット(例:シャドウマップ の D24X8 の代わりに D16、または D32S8 の代わりに D24S8)を使用し、さらに不透明オブジェクトを前から後ろへの順番に描いて、ZCULL(粗視化深度テスト)が ZROP とピクセルシェーダを起動する前に多くのピクセルが廃棄できるようにするとよいでしょう。

(3)トップSOLユニットがPDの場合

前述したように、PDは入力された頂点のインデックスを収集するために、インデックスバッファのロードを行います。PDがトップSOLユニットである場合、32ビットではなく16ビットのインデックスバッファを使用してみることができます。それでも PD の SOL%が増加しない場合は、頂点の再利用とローカリティのためにジオメトリを最適化することを試してみることができます。

(4)トップSOLユニットがVAFの場合

この場合、頂点シェーダーの入力アトリビュートの数を減らすと効果的です。
また、位置ストリームを他のアトリビュートから分離することは、z-only またはシャドウマップレンダリングに有効な場合があります。


頂点アトリビュートを減らす

前述した,P3 Methodでも出てきますが,頂点アトリビュートを減らすと高速になるケースがあります。プラットフォームによってはドキュメントに「XXX以下にすると速くなります」みたいな記述が載っていたり,実際にどのぐらい数でどういう結果が得られたのかをグラフ化してくれていたりします。
ここで重要なのは,なるべくパッキングして数を減らすということです。例えば,下記のようにします。

//変更前
struct VSOutput
{
    float4 Position : SV_POSITION;
    float2 TexCoord0 : TEXCOORD0;
    float2 TexCoord1 : TEXCOORD1;
    float2 TexCoord2 : TEXCOORD2;
    float2 TexCoord3 : TEXCOORD3;
};

// 変更後
struct VSOutput
{
    float4 Position : SV_POSITION;
    float4 TexCoord01 : TEXCOORD01; // TEXCOORD0 and TEXCOORD1
    float4 TexCoord23 : TEXCOORD23; // TEXCOORD2 and TEXCOORD3
};

データ容量は変わっていないですが,これだけで高速化します。過去の経験ではモデル描画のシェーダに対して適用したところ全体で1[ms]程度の高速化が出来た記憶があります。とにかくピクセルシェーダで使っていないアトリビュートがあったら,削る。削れないときはまとめることによって数を減らすということを考えると良いです。


サイズを減らす

計算等に利用するテクスチャサイズを減らすことによってテクスチャキャッシュに載りやすくし,高速化するテクニックがあります。例えば,Deinterleaved RenderingCheckerboard Renderingといったものがこれに当たります。特に縮小バッファにして描画品質があまりにも落ちすぎるのを避けたい場合などに使うと良いです。


テクスチャフェッチをバッチする

NVIDIAのBlog記事にもありますが,レイマーチループのような動的ループ内でテクスチャフェッチをバッチかすることによってTEXのレイテンシーを隠すことができます。
典型的なSSRレイマーチングループのHLSLは次のような感じです。

float MinHitT = 1.0;
float RayT = Jitter * Step + Step;

[loop] for ( int i = 0; i < NumSteps; i++ )
{
     float3 RayUVZ = RayStartUVZ + RaySpanUVZ * RayT;
     float SampleDepth = Texture.SampleLevel( Sampler, RayUVZ.xy, GetMipLevel(i) ).r;

     float HitT = GetRayHitT(RayT, RayUVZ, SampleDepth, Tolerance);
     [branch] if (HitT < 1.0)
     {
          MinHitT = HitT;
          break;
     }

     RayT += Step;
}

ループ内で,隣り合わせで2回テクスチャフェッチするように処理を置き換えます。

float MinHitT = 1.0;
float RayT = Jitter * Step + Step;
[loop] for ( int i = 0; i < NumSteps; i += 2 )
{
     float RayT_0 = RayT;
     float RayT_1 = RayT + Step;

     float3 RayUVZ_0 = RayStartUVZ + RaySpanUVZ * RayT_0;
     float3 RayUVZ_1 = RayStartUVZ + RaySpanUVZ * RayT_1;

     // batch texture instructions to better hide their latencies
     float SampleDepth_0 = Texture.SampleLevel( Sampler, RayUVZ_0.xy, GetMipLevel(i+0) ).r;
     float SampleDepth_1 = Texture.SampleLevel( Sampler, RayUVZ_1.xy, GetMipLevel(i+1) ).r;

     float HitT_0 = GetRayHitT(RayT_0, RayUVZ_0, SampleDepth_0, Tolerance);
     float HitT_1 = GetRayHitT(RayT_1, RayUVZ_1, SampleDepth_1, Tolerance);

     [branch] if (HitT_0 < 1.0 || HitT_1 < 1.0)
     {
          MinHitT = min(HitT_0, HitT_1);
          break;
     }

     RayT += Step * 2.0;
}

ブログ記事では,GTX1080で0.54[ms]だったものが0.42[ms]に高速化したと記載されています。また2回ではなく4回にすることで0.54[ms]から0.33[ms]になるという記述もあります。レイマーチ処理は基本的に重くなりやすいので,アルゴリズム的に最適化出来ない場合は,こうしたバッチ化により高速化するとよさそうです。

なねぃと調査

こんばんわ。Pocolです。
もうすぐSIGGRAPHですね。SIGGRAPHのコースで「なねぃと」についての話があるそうで,そのコースを受ける前にある程度は調査しておこうと思いました。Sさん、問題あればご連絡を。

個人的に知りたいこと

今回の調査では下記のようなことを知りたいなと思ったので,調査してみました。

  • どんなレンダリングフローなのか?
  • 実際にどんなシェーダ使っているの?
  • どうやってストリーミングするものを決めてるの?
  • ストリーミングデータはどうやって作るのか?

大雑把な流れ

なねぃとは仮想化マイクロポリゴンジオメトリシステムです。いわゆるVirtual Textureみたいなテクスチャストリーミングのメッシュ版という感じのやつです。細かいポリゴンを扱えるのが売りになっていて,ものすごくいい感じのディテールが表現できます。
なねぃとを実現するためのキーだと思っているものは次の通りです。

  • GPU駆動描画
  • 高速なソフトウェアラスタライズ
  • Deferred Material(Visibility Buffer)
  • トライアングルデータの圧縮
  • 階層LODの構築

まずGPU駆動描画はその名の通り,GPU上で描画するかどうかを判定を行い,その結果で描画が駆動する手法のことを言ったりします。これはカプコンさんだったり,アサクリだったり,Trialsだったりと色々な会社さんがすでに取り組まれています。内容について知らない方がいたら下記の資料などを読むと良いと思います。

UE5はバウンディングのスケールに応じてソフトウェアラスタライズとハードウェアラスタライズの分岐がコンピュートシェーダ上で決定されます。
小さな三角形はコンピュートシェーダを用いたソフトウェアラスタライズが実行され,大きな三角形に対してハードウェアラスタライザが実行されます。
ソフトウェアラスタライザですが,小さな三角形に対しては平均で3倍高速化したと「Nanite | Inside Unreal」の動画でKarisが言っていました。恐るべき速度ですね。

※図は”Nainte | Inside Unreal”より引用

あとは,この高速なソフトウェアラスタライズを支える技術として,Deferred Materialを使用しています。いわゆるVisibility Bufferというやつです。
これの何が良いかというと,深度とマテリアルインデックスをバッファに出力してしまい,マテリアル評価を遅延実行できるというメリットがあります。つまり,いちいちシェーダの切り替えをしなくて良いということです。これによりUE5は不透明物体の描画を1ドローで実現しています。

※図は”Nainte | Inside Unreal”より引用

さらにNaniteを見ていてがんばっているなーと思うのがデータの圧縮です。1トライアングルにつき平均14.4Byteだそうです。一応自分でもソースコードを追ってみて,確かにそうだなということを確認しました。

※図は”Nainte | Inside Unreal”より引用

どんなレンダリングフローなのか?

まずは,レンダラーの流れをつかみ大雑把にどんなことをやっているのかを把握してみました。
レンダラーの実装は,Engine/Source/Runtime/Renderer/Private/DeferredShadingRenderer.cppにあります。これがディファードレンダリングの実装になっており,Render()メソッドに実装があるので,これを地味に読み解いていきました。なんとこの関数1600行ほどあります。

この関数をところどころを端折った疑似コードが下記のような感じになります。

ざっくりですが,処理をまとめておくと

  • グローバルリソースを更新
  • 非同期でファイル読み込みを開始
  • 読み込みタスクの完了待ちをして,GPUにデータを転送
  • Visibility Bufferをクリア
  • ビュー情報をパッキングしてカリングとラスタライズを実行
  • 深度バッファに出力
  • マテリアルごとにドローコールを発行して,タイル描画を行うことによりG-Bufferを構築

…という感じです。

実際にどんなシェーダ使っているの?

CullRasterize()

下記のシェーダが実行されるようです。

  • InstanceCull :パス Engine/Private/Nanite/InstanceCulling.usf
  • InitArgs :パス Engine/Private/Nanite/ClusterCulling.usf
  • InstanceCullVSM :パス Engine/Private/Nanite/InstanceCulling.usf
  • PersistentClusterCull : パス Engine/Private/Nanite/ClusterCulling.usf
  • CalculateSafeRasterizerArgs : パス Engine/Private/Nanite/ClusterCulling.usf
  • HWRasterizerVS : パス Engine/Private/Nanite/Rasterizer.usf
  • HWRasterizerPS : パス Engine/Private/Nanite/Rasterizer.usf
  • MicropolyRasterize : パス Engine/Private/Nanite/Rasterizer.usf

左側は実行される関数で,右側はその関数が実装されているファイルパスを表します。
PersistentCull()がおそらく一番巨大なシェーダコードになると思うのですが,クラスタ階層のトラバーサルとかカリング,あとはソフトウェアラスタライズかハードウェアラスタライズかどうかの切り分けなんかも行っています。
MicroPolyRasterize()がソフトウェアラスタライズを行っているのですが,R32G32のバッファに書き込みをします。Rチャンネルの25bit分がVisibleIndexで,残りの7bitがTriangleIDになっています。GチャンネルはDepthに割り当てられているようです。

EmitDepthTargets()

下記のシェーダが実行されるようです。

  • DepthExport : パス Engine/Private/Nanite/DepthExport.usf
  • EmitSceneDepthStencilPS : パス Engine/Private/Nanite/ExportGBuffer.usf
  • EmitSceneSStencilPS : パス Engine/Private/Nanite/ExportGBuffer.usf

名前通りの処理っぽいです。

DrawBasePass()

下記のシェーダが実行されるようです。

  • ClassifyMaterials : パス Engine/Private/Nanite/MaterialCulling.usf
  • FullScreenVS : パス Engine/Private/Nanite/ExportGBuffer.usf

ClassifyMaterials()の中では,VisibleなMaterialとMaterial Rangeを決定するようです。Material Rangeにはマテリアルのマスクビットが格納されています。
大雑把な理解として,描画対象となるマテリアルの矩形範囲を決めているようです。
FullScreenVS()では,決定したマテリアルの矩形範囲にあるタイルで,本当にそのマテリアルの描画必要かどうかを判定して,要らないところはNaNを頂点シェーダで設定して,タイルをカリングするという処理を行うようです。タイルカリングには5つのモードがあるようです。

ストリーミングの仕組みは?

ストリーミングの管理はFStreamingManagerというクラスで管理されているようです。
Engine/Source/Runtime/Engine/Public/Rendering/NaniteStreamingManager.hにクラスの宣言があります。
・クラスタページデータ
・クラスタページヘッダ
・クラスタ修正更新バッファ
・ストリーミングリクエストバッファ
・ストリーミングリクエストリードバックバッファ
・ペンディングページ
・リクエストハッシュテーブル
などを保持しているようですが,まだ理解しきれていないので,理解できるようになったら別の記事として書くことにします。
ページリクエストは,PersistentClusterCull()というシェーダが実行されてRequestPageRange()という関数内でリクエストが書き込まれるようです。
このシェーダで書き込んだページリクエストの取得はディファードレンダラー内のAsyncUpdate()内でバッファをmapすることでCPU側で取得されるようです。
リクエストハッシュテーブルにGPUから取得したものを登録して,被るものがあるかどうかをチェック。チェックにヒットしたらストリーミングするページとしてプッシュし,優先度でソートして,LRUを更新しています。
一方,検索にヒットしない場合は,優先度付きリクエストヒープにいったんプッシュするようです。その後、このヒープからポップしてストリーミングするページを選択しています。
ストリーミングするものは読み込みされている状態なので,送ればいいのですが,これから読み込みしないといけないペンディング状態のものについては,ペンディング状態のページを収集し,ランタイムリソースIDがコンピュートシェーダで書き込まれるので,これを利用してFbyteBulkDataを取得するようです。取得したデータをFIORequestTaskに登録し,ParallelFor文を用いて,ファイルの非同期読み込みが実行さるようです。
ストリーミングマネージャのEndAsyncUpdate()という関数で,読み込み完了待ちをしてからResourceUplodTo()関数を使ってGPUに転送しているみたいです。

ストリーミングデータはどうやってつくるのか?

メッシュデータの構築

FStaticMeshBuilder::Build()経由でBuildNaniteFromHiResourceModel()が呼び出されて,データが作成されるようです。
BuildDAG(), ReduceDAG(), FindDAGCut()などの内部の処理がまだ全然理解できていないので,鋭意調査中です。

エンコード

ジオメトリデータはEncodeGeometryData()という関数でエンコードされるようです。
Positionデータは63bitに。
法線ベクトルはOctrahedronで表現し,XYで18bitに。
テクスチャ座標はXYで32bitに。
ここまで合計14byteになるので,確かにKarisが言っていた平均14.4Bという数字は納得できます。
ちなみに頂点カラーやUV数を増やす場合はさらにデータ容量が増えていく感じです。

おわりに

今回は,えらくざっくりですが,どんな感じで動くのかについて調査しました。
レンダリングフローについては理解できたのですが,実際のストリーミングの詳しい処理内容や,階層LODの作成方法など,まだまだわからない箇所もあるので次回調査してみたいと思います。

資料:THE LAST OF US PARTⅡ

実装用の資料として,THE LAST OF US PARTⅡのスクショを張っておきます。動画も上げようと思ったのですが,サイズが大きくて上げられませんでした。

The Last of Us® Part II_20210124181203
The Last of Us® Part II_20210124181223
The Last of Us® Part II_20210124181237
The Last of Us® Part II_20210124181253
The Last of Us® Part II_20210124181308
The Last of Us® Part II_20210124181326
The Last of Us® Part II_20210124181344
The Last of Us® Part II_20210124181413
The Last of Us® Part II_20210124181429
The Last of Us® Part II_20210124181444
The Last of Us® Part II_20210324214828
The Last of Us® Part II_20210324214847
The Last of Us® Part II_20210324214923
The Last of Us® Part II_20210324215312
The Last of Us® Part II_20210324215326
The Last of Us® Part II_20210324215343
The Last of Us® Part II_20210324215354
The Last of Us® Part II_20210324215403
The Last of Us® Part II_20210324215601
The Last of Us® Part II_20210324215609
The Last of Us® Part II_20210324215620
The Last of Us® Part II_20210324215919
The Last of Us® Part II_20210324215930
The Last of Us® Part II_20210324215941