はじめに
こんにちは、サイバーエージェントゲームエンターテイメント事業部、SGEコア技術本部(コアテク)のグラフィックスチームに所属している清原です。
前回の記事からだいぶ時間が空いてしまいましたが、今回はスマートフォンのGPUで主流となっているタイルベースレンダリングのGPUの最適化についてお話していきます。
タイルベースレンダリング
現代のGPUは演算速度とメモリの読み書きの速度の差はどんどん大きくなってきていて、メモリアクセスが大きなボトルネックになってきています。
この問題をPCのディスクリートGPUなどではメモリ帯域の広いグラフィックス専用のメモリを搭載することで軽減することができています。しかし、多くのモバイルGPUでは発熱量、消費電力の問題などで専用のグラフィックスメモリなどは持っておらずCPUとGPUでメモリを共有しています。そのため、メモリの読み書き速度はPC以上に大きな問題となります。そこで、この問題を解決するためにタイルベースレンダリング(以下、TBR)アーキテクチャのGPUが生まれました。
TBRアーキテクチャのGPUではタイルメモリという小さなキャッシュメモリをシェーダーコアに載せていて、そのメモリに対して書き込みと読み込みを行っていきます。このタイルメモリは物理的距離もシェーダーコアから近くなっており、メモリ帯域の問題を軽減することができるTBRアーキテクチャのGPUがモバイルでは主流になってきました。
次の図はARM Mali-G71 GPUの公式資料から抜粋したシェーダーコアの設計図です。この図からシェーダーコアにタイルメモリが接続されていることが分かります。
このようなTBRアーキテクチャのGPUでは次の図のように画面をタイルで分割して、シェーダーコアに接続されたタイルメモリに対してレンダリングを行っていきます。
(※なお、説明の都合上タイル数を16個としていますが、ハードウェアによりますが実際には16×16ドットや,32×32ドットなどの矩形を1タイルとして扱うことが多いため、本来はもっと多くのタイルが存在します。)
速度の遅いCPU/GPUメモリに絵を直接描画していくのではなく、高速にアクセスできるタイルメモリに対してレンダリングを行っていくわけです。
そして、タイルメモリへのレンダリングが完了すると次の図のようにCPU/GPUメモリにストアします。こうすることで速度の遅いメモリへのアクセス回数を減らすことができ高速化することができます。
ロード/ストアアクション
さて、先ほどタイルメモリからCPU/GPUメモリへのストアについてお話ししたので、ここで少しだけロード/ストアアクションについて解説します。
ロードアクション
ロードアクションはCPU/GPUメモリからタイルメモリにデータをロードするときのアクションです。ロードアクションはレンダリングターゲットとしてテクスチャがアタッチされるときに実行されます。
ロードアクションは主として下記があります。
アクション | 説明 |
---|---|
Load | CPU/GPUメモリの内容をタイルメモリにコピー |
Clear | タイルメモリをクリア |
Don't Care | 何もしない |
この3つのアクションの場合、CPU/GPUメモリへのアクセスが発生するのはLoadアクションのみです。そのため、実行速度は次のようになります。
Don't Care > Clear >>> Load
CPU/GPUメモリの内容がこれからのレンダリングに必要な場合はLoadアクションを指定する必要があります。しかし、レンダリングターゲットをクリアしたり塗りつぶしたりする場合はClearかDon't Careを指定することができます。 LoadアクションはCPU/GPUメモリにアクセスするため、遅いアクションになります。ですので、Loadアクションの指定は必要最低限にすべきです。
ストアアクション
ストアアクションはタイルメモリからCPU/GPUメモリにデータをストアするときのアクションです。ストアアクションはレンダリングが終了すると実行されます。
ストアアクションは主として下記があります。
(※ここでは説明の簡略化のためMSAAテクスチャに関連するストアアクションは除外しています。)
アクション | 説明 |
---|---|
Store | タイルメモリの内容をCPU/GPUメモリにコピー |
Don't Care | なにもしない |
この2つのアクションの場合、CPU/GPUメモリへのアクセスが発生するのはStoreアクションです。そのため、実行速度は次のようになります。
Don't Care >>> Load
レンダリング結果をテクスチャとして保存したい場合はStoreアクションを指定する必要があります。しかし、レンダリング結果を保存せず破棄できる場合はDon't Careを指定することができます。 レンダリング結果を破棄するケースがパッと思いつかないかもしれませんが、実は後で説明するFrameBufferFetchを行うとレンダリング結果を破棄できるケースがあります。
StoreアクションもLoadアクションと同様にCPU/GPUメモリにアクセスするため、必要最低限の利用にすべきです。
レンダーバッファのロード/ストアを減らす
さて、モバイルのGPUではタイルメモリへのリードライトを間にはさむことによって、CPU/GPUメモリへのアクセス回数を減らすことができ高速化を行えることがわかりました。しかし、最終的にはタイルメモリの内容をメモリにストアする操作が必要になるため、この回数が多くなるとやはりネックになってきます。
例えばG-Bufferを作成してライティングを行うディファードレンダリングをタイルベースアーキテクチャのGPUで行う場合について考えてみます。ディファードレンダリングの流れは次のようになります。
1. G-Bufferの作成(アルベド、法線、メタリックの情報をタイルメモリに書き込んでいく)
2. アルベド、法線、メタリックの情報をメインメモリにストアする
3. 2でストアされたG-Bufferをサンプリングしてディファードライティングを行っていく
このように、1で作成されたG-Bufferを3でテクスチャとして利用するためには一度メインメモリにストアする必要があります。しかし、ここで一つ疑問が生まれます。3のG-Bufferのサンプリングをテクスチャからではなく、直接タイルメモリから読み込むことはできないのでしょうか。
もしタイルメモリからの読み込みができれば次のような処理になりメインメモリへのストアを削減できます。
1. G-Bufferの作成(アルベド、法線、メタリックの情報をタイルメモリに書き出していく)
2. タイルメモリからG-Bufferをサンプリングしてディファードライティングを行っていく
この機能はフレームバッファフェッチと呼ばれており、Vulkanとmetalでは用意されています。また、UnityもUnity 6から正式に利用できるようになっています。 (※Unity 2022でも利用できるが多少手を加える必要があります)
VulkanのRenderPass/SubPass
早速フレームバッファフェッチを利用してロードストアを減らす処理を実装していきたいところなのですが、少しだけ低レベルグラフィックスAPIであるVulkanの話をさせてください。 VulkanにはRender Passという概念があります。これはDirectXやOpenGLなどのAPIには存在しない概念です。 RenderPassはシャドウマップ描画パスや不透明描画パスなどをAPIレベルで定義するためのものです。またRenderPassには一つ以上のSubPassが含まれます。たとえばポストエフェクトのBloomをRenderPassで表すと次のようになります。
- Bloom(RenderPass)
- 輝度抽出(SubPass)
- ブラー(SubPass)
- 合成(SubPass)
SubPassは入力リソースと出力リソースを定義することができます。例えば、先ほどのBloomであれば次のようにリソースを定義できます。
- Bloom(RenderPass)
- 輝度抽出(SubPass)
- 入力リソース :シーンテクスチャ
- 出力リソース :輝度テクスチャ
- ブラー(SubPass)
- 入力リソース : 輝度テクスチャ
- 出力リソース : ブラーがかけられたテクスチャ
- 合成(SubPass)
- 入力リソース : ブラーがかけられたテクスチャ
- 出力リソース : フレームバッファ
- 輝度抽出(SubPass)
この入力リソース/出力リソースを定義することによって、「どのSubPassでリソースに書き込みが行われて、そのリソースがどこで使われるのか?」といったリソースの依存性をGPUに教えることができ、次のような有向非巡回グラフを作ることができます。
(青マスがSubPass、赤マスがリソース)
さて、FrameBufferFetchの話に戻しますが、FrameBufferFetchは同一のRenderPassの中で利用されたリソースに対してしか使えません。
例えば、前節のディファードレンダリングでG-Bufferにフレームバッファフェッチを使いたい場合は次のようなRenderPassを構築する必要があります。
- ディファードレンダリング(RenderPass)
- G-Bufferの作成(SubPass)
- ディファードライティング(Subpass)
AppleのOS上でサポートされるグラフィックスAPIのmetalにもRenderPassという概念があり、Frame Buffer Fetchについても同等のことを行うことができます。
Unityとの関係
さて、VulkanのRenderPassについての話をしてきましたが、ではUnityではどうなのか?
Unity 6ではRender Graphを利用の有無で話が変わってきますので、ここでは両方のケースについて見ていきます。
Render Graphを利用しない場合
まずはURPでRender Graphを利用しない場合を見ていきます。
RenderGraphを利用せずにScriptableRendererPassを素直に実装した場合はRenderPassとSubPassの関係が1体1になっているため、そもそもSubPassが複数存在しておらず、SubPass間でのリソース依存性はありません。
そのため、SubPassの処理でタイルメモリに乗せたリソースをそのまま使うFrame Buffer Fetchを使うことはできません。
しかし、VulkanのRenderPassとほぼほぼ同等のScriptableRenderContext.BeginRenderPass/BeginSubPassを利用することで複数のSubPassを作成することができます。
次のコードはScriptableRendererPassでScriptableRenderContext.BeginRenderPass/BeginSubPassを利用してフレームバッファフェッチを行っているサンプルコードです。
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(); var renderer = renderingData.cameraData.renderer; // カラーアタッチメントのロード/ストアアクションを設定とリソースとの関連付け // 0番目のアタッチメントはメインメモリに書き戻さないのでストアアクションにDontCareを設定する。 // 1番目のアタッチメントは書き戻すのでストアアクションにStoreを設定する。 // ロードアクションはBlitで全て塗りつぶすのでDontCareで問題ない。 _colorAttachmentDescriptors[0].loadAction = RenderBufferLoadAction.DontCare; _colorAttachmentDescriptors[0].storeAction = RenderBufferStoreAction.DontCare; _colorAttachmentDescriptors[1].loadAction = RenderBufferLoadAction.DontCare; _colorAttachmentDescriptors[1].storeAction = RenderBufferStoreAction.Store; // アタッチメントディスクリプタと実際のリソースを関連付ける // 0番目のアタッチメントはテンポラリテクスチャ _colorAttachmentDescriptors[0].ConfigureTarget(_tempRT[0].nameID, false, false); // 1番目のアタッチメントはカメラテクスチャ _colorAttachmentDescriptors[1].ConfigureTarget(renderer.cameraColorTargetHandle.nameID, false, true); // RenderPassを開始する // RenderPassに渡すためにディスクリプタのNateiveArrayを作成する var descriptors = new NativeArray<AttachmentDescriptor>(_colorAttachmentDescriptors, Allocator.Temp); context.BeginRenderPass(_tempRT[0].rt.width, _tempRT[0].rt.height, 1, descriptors); // カメラテクスチャをテンポラリテクスチャにBlitする(サブパス) // 出力アタッチメントの設定 // このサブパスではテンポラリテクスチャにBlitするので0番のアタッチメントを指定する var outputColorAttachment = new NativeArray<int>(new[] { 0 }, Allocator.Temp); // サブパスの開始 context.BeginSubPass(outputColorAttachment); Blitter.BlitTexture(cmd, renderer.cameraColorTargetHandle, Vector2.one, Blitter.GetBlitMaterial(TextureXR.dimension), 0); context.EndSubPass(); context.ExecuteCommandBuffer(cmd); cmd.Clear(); outputColorAttachment.Dispose(); // テンポラリテクスチャの内容をカメラテクスチャにBlitする(サブパス) NativeArray<int> inputColorAttachment; // 入力アタッチメントの設定 // ここでひとつ前のサブパスで書き込まれたテンポラリテクスチャのアタッチメントを入力アタッチメントにする inputColorAttachment = new NativeArray<int>(new[] { 0 }, Allocator.Temp); // カメラテクスチャを出力アタッチメントとして指定する outputColorAttachment = new NativeArray<int>(new[] { 2 }, Allocator.Temp); // サブパスを開始する context.BeginSubPass(outputColorAttachment, inputColorAttachment); Blitter.BlitTexture(cmd, Vector2.one, _material, 1); context.EndSubPass(); context.ExecuteCommandBuffer(cmd); cmd.Clear(); inputColorAttachment.Dispose(); outputColorAttachment.Dispose(); // RenderPassを終了する context.EndRenderPass(); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); }
このコードはカメラテクスチャをテンポラリテクスチャにBlitして、最後にテンポラリテクスチャからカメラテクスチャにBlitするというコードです。
コードの詳細については説明を簡略化しますが、出力アタッチメントに指定されたテクスチャが書き込み先、入力アタッチメントとして指定されたテクスチャがフレームバッファフェッチの対象になります。
二つ目のサブパスの入力アタッチメント(フレームバッファフェッチの対象)は一つ目のサブパスで書き込みされたテンポラリテクスチャのアタッチメントが指定されています。
処理の流れを図示化すると次のようになります。
なお、フレームバッファフェッチのBlitで利用しているシェーダーはUnity6から追加されたFrameBufferFetchのマクロを使っており、次のようなコードになっています。
Pass // 1 フレームバッファフェッチ(明るくしていく) { Name "Load Store" HLSLPROGRAM #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl" #pragma vertex Vert #pragma fragment Frag // 入力アタッチメント0番目を宣言する FRAMEBUFFER_INPUT_X_HALF(0); half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); // フレームバッファからカラーをフェッチしてそれを出力する half4 color = LOAD_FRAMEBUFFER_X_INPUT(0, input.positionCS); color.xyz *= 1.01f; return color; } ENDHLSL }
Render Graphを利用する場合
Render Graphを利用する場合は、次のようにScriptableRendererPassのRecordRenderGraphメソッドの中でRenderGraph.AddRasterRenderPassなどを呼び出すことで複数のパスを追加できます。
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) { // コピーパスを追加 using(var builder = renderGraph.AddRasterRenderPass<PassData>("Copy", out var passData, _profileSampler)) { } // 結合パスを追加 using (var builder = renderGraph.AddRasterRenderPass<PassData>("Combine", out var passData, _profileSampler)) { } }
では、RecordRenderGraph内で追加したパスがそのままサブパスになるのかというと、そういうわけではありません。Render Graphではリソース依存性を考慮して、RenderPass/SubPassの関係が最適になるように レンダリングパスを構築してくれます。
次のコードはRender Graphでフレームバッファフェッチを行っているサンプルコードです。
なお、処理の流れは先ほどのRender Graphを利用しない場合と同等です。
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) { if (_material == null) { _material = CoreUtils.CreateEngineMaterial("Hidden/GPU-Performance-Tuning/LoadStore"); } var resourceData = frameData.Get<UniversalResourceData>(); var cameraColorTex = resourceData.activeColorTexture; var tmpTextureDescriptor = renderGraph.GetTextureDesc(cameraColorTex); tmpTextureDescriptor.depthBufferBits = 0; _tempTextureHandle = renderGraph.CreateTexture(tmpTextureDescriptor); // カメラテクスチャをテンポラリテクスチャにBlitする(サブパス) using(var builder = renderGraph.AddRasterRenderPass<PassData>("Blit Camera Texture", out var passData, _profileSampler)) { // 出力アタッチメントにテンポラリテクスチャを指定する builder.SetRenderAttachment(_tempTextureHandle, 0, AccessFlags.WriteAll); // カメラカラーをテクスチャとして使用申請する builder.UseTexture(cameraColorTex); // パスデータにblitテクスチャを指定する passData._blitTexture = cameraColorTex; builder.SetRenderFunc((PassData passData, RasterGraphContext context) => { var cmd = context.cmd; // ソーステクスチャにカメラカラーを指定してBlitを行う Blitter.BlitTexture(cmd, passData._blitTexture, Vector2.one, _material, 0); }); } // テンポラリテクスチャの内容をカメラテクスチャにBlitする(サブパス) using (var builder = renderGraph.AddRasterRenderPass<PassData>("Combine", out var passData, _profileSampler)) { passData._material = _material; // カメラカラーを出力アタッチメントとして指定する builder.SetRenderAttachment(cameraColorTex, 0, AccessFlags.WriteAll); // 【注目】入力アタッチメントとしてテンポラリテクスチャ0を指定する builder.SetInputAttachment(_tempTextureHandle, 0); builder.SetRenderFunc((PassData passData, RasterGraphContext context) => { var cmd = context.cmd; Blitter.BlitTexture(cmd, Vector2.one, passData._material, 1); }); } }
メモリレスモード
最後にフレームバッファフェッチと関連が深いメモリレスモードについて説明します。
メモリレスモードを利用するとレンダリングテクスチャをタイルメモリに一時的に保存して、CPU/GPUメモリには保存しないということが行えます。これを行うことによってアプリのメモリ使用量を削減することができます。
例えば、次のようなケースであればテンポラリテクスチャはタイルメモリ内だけで完結しているため、メモリレスモードが使えます。
- カメラテクスチャをテンポラリテクスチャにBlit(全て塗りつぶす)
- テンポラリテクスチャをカメラテクスチャにBlit
これらをより詳細に見ていきましょう
1. カメラテクスチャをテンポラリテクスチャにBlit(全て塗りつぶす)
この処理では次の処理が行われています。
- a: テンポラリテクスチャをタイルメモリとしてアタッチする
- b: カメラテクスチャをBlitする
この時、テンポラリテクスチャが初めて出力先として利用される場合はメインメモリからロードする必要がないためClearかDon't Careを指定することができ、タイルメモリ内のメモリを確保するだけで終わります。
2. テンポラリテクスチャをカメラテクスチャにBlit
最後に次のような処理でテンポラリテクスチャの内容をカメラテクスチャにBlitします。
- a: テンポラリテクスチャがアタッチされているタイルメモリを読み込み先として指定する
- b: カメラテクスチャを出力先としてタイルメモリにアタッチする
- c: テンポラリテクスチャをカメラテクスチャにBlitする
- d: カメラテクスチャの内容をCPU/GPUメモリにストアする
テンポラリテクスチャが読み込み先として指定されていますが、タイルメモリの内容がそのまま使えます。
そして、テンポラリテクスチャの内容をカメラテクスチャがアタッチされたタイルメモリに書き込んでいます。 カメラテクスチャはこのパス移行でも利用するため、CPU/GPUメモリにストアされていますが、テンポラリテクスチャはこのパス移行では利用されません。そのためCPU/GPUメモリにストアする必要がなく、タイルメモリ内だけで操作が完結しています。
このようにタイルメモリ内だけで処理が完結する場合(この例であればテンポラリテクスチャ)にメモリレスモードを利用できます。
RenderGraphを利用している場合は、リソースの利用状況をRenderGraphに教えるため、RenderPass構築時に可能であればメモリレスモードを利用してくれます。
次の図はフレームバッファフェッチを行っているテクスチャのメモリレスモードをFrame Debuggerで確認しているものです。
最後に
最適化入門といいつつ、かなりヘビーな内容になった気がしますがタイルベースGPUの仕組みを理解することは、スマートフォンアプリの最適化を行う上でとても重要なことだと思います。
特にUntiy 6から使えるRender Graphでは実装の仕方によってロードストアアクション/メモリレスモードなどタイルベースGPUにとって重要な要素が変化していきます。
この記事がGPU最適化の一助になれば幸いです。