はじめに
こんにちは、サイバーエージェントゲームエンターテイメント事業部、SGEコア技術本部(コアテク)のグラフィックスチームに所属している清原です。
前回の記事でタイルベースGPUの話を通してメモリの速度についてみていきました。 今回はそれに関連して、GPU負荷とレンダリングターゲットの解像度の関係について解説します。
レンダリングターゲットの解像度
レンダリングターゲットとは、描画結果を出力するフレームバッファ(出力先)のことを指します。前回の記事でレンダリングターゲットに対する描き込みのメモリ負荷を減らすためにタイルベースGPUではタイルメモリが使われているという話をしました。 今回はレンダリングターゲットの解像度を下げることによって、GPU負荷を下げていくという話をしていきます。
レンダリングターゲットの解像度が高いと、その分フラグメントシェーダーの実行回数も増え、計算量が増えることで、GPUへの演算負荷も大きくなります。 メモリアクセスという観点からもフラグメントシェーダーが実行回数が多くなるため、テクスチャアクセスも頻繁に行われます。 また、タイルメモリからメインメモリにストアするメモリ量も増えるためメモリ帯域が消費されます。
基本的にレンダリングターゲットの解像度は高ければ高いほどGPU負荷は上がっていきます。そのため、有名な川瀬式Bloomなどをはじめとして、多くのレンダリングテクニックではレンダリングターゲットの解像度を落としていく、ダウンサンプリングを活用して処理の高速化を図っています。
オーバードローについて
さて、オーバードローによって処理負荷が増大するという話はよく耳にすると思いますが、これは前述のフラグメントシェーダーが実行される回数が増えてしまうからです。
フラグメントシェーダーの実行回数が増えるということは、メモリアクセス、計算量の問題ともに大きくなってしまいます。
オーバードローは不透明描画においても描画順番によっては発生するのですが、特に顕著に問題になるのは半透明オブジェクトの描画です。
半透明オブジェクトは奥が見えるため、重なっているオブジェクトも表示する必要があります。そのため、描画を正しく行おうとすると奥から順番に描画していく or ZBufferに深度値の書き込みを行わずに描画していくなどの工夫が必要になります。そうした理由から、不透明オブジェクトの描画では有効だった早期Zテストによるフラグメントの破棄が、半透明オブジェクトの描画では効果的に機能しません。
次の図は5枚の半透明な四角形ポリゴンを描画している様子です。この図であればポリゴンが最も重なっている部分ではフラグメント処理が5回実行されています。
この問題が特に顕著に起きるのは半透明描画を多用するパーティクルエフェクトです。エフェクトでは生成されるパーティクルの数と大きさ、カメラとの距離によって大量のオーバードローが発生することがあります。このようなエフェクトを再生した場合、スペックの低い端末によっては大きな処理負荷になってしまいます。
このような問題を解決するときに昔からよく使われているのが縮小バッファを使ったエフェクト描画です。
縮小バッファを使ったエフェクト描画
縮小バッファを使ったエフェクト描画は小さなレンダリングターゲットに対してエフェクトを描画することでGPU負荷を軽減し問題を解決します。
アルゴリズムは下記のような流れになります。
- 不透明オブジェクトをシーンに描画
- 縮小バッファにエフェクトを描画
- 2で描画されたエフェクトをシーンに合成
では、この流れを詳細に見ていきましょう
1. 不透明オブジェクトをシーンに描画
ここでは特に変わったことはせずに次の図のように最終的に画面に表示されるカラーバッファと深度バッファに不透明オブジェクトを描画していきます。
URPのレンダリングパスであればDraw Opaque
パスが該当します。
2. 縮小バッファにエフェクトを描画
ここでは縮小されたレンダリングターゲットに対して半透明のエフェクトを描画してきます。今回は1/2の解像度のレンダリングターゲットとして話をしていきますが、このサイズは調整可能です。
また、エフェクトを描画する際のシェーダーで1. 不透明オブジェクトをシーンに描画
で作成された深度バッファを参照してソフトウェア的な深度テストを行います。こうすることで半透明オブジェクトと不透明オブジェクトの前後関係は正しく描画できます。
(カラーバッファと深度バッファの解像度が違う場合はハードウェア的な深度テストが行えないため、ソフトウェア的な深度テストを行っています)
また、半透明物を描画する際には深度バッファへの書き込みをオフにする
or 奥から順番に描画する(画家のアルゴリズム)
ことで半透明エフェクト同士の描画結果を正しいものとしています。
なお、図の縮小バッファの背景が黒になっている理由は重要なので、後述のアルファブレンディングの問題
で解説します。
3. 2で描画されたエフェクトをシーンに合成
最後に2. 縮小バッファにエフェクトを描画
で描画された絵をシーンに合成します。
アルファブレンディングの問題
さて、典型的な半透明エフェクトを縮小バッファに描画して最終合成するためには、アルファブレンディングの設定に一工夫が必要になります。
というのも、半透明描画を行うためのアルファブレンディング設定ではソースカラー(描きこもうとしているカラー)
とディスティネーションカラー(すでに描き込まれているカラー)
が線形補間されます。つまり、すでに描き込まれている色が最終的な描画結果に影響を与えてしまいます。しかし、縮小バッファには不透明物が描画されていないため、そこを何とかするための工夫が必要になります。
具体的な計算で見てみます。
ソースカラー | ソースα | ディスティネーションカラー | 演算 | 結果 |
---|---|---|---|---|
(10,30,40) | 0.7 | (20,30,40) | (10,30,40) × 0.7 + (20,30,40) × (1 - 0.7) | (13,30,40) |
(10,30,40) | 0.7 | (0,0,0) | (10,30,40) × 0.7 + (0,0,0) × (1 - 0.7) | (7,21,28) |
この表のデータはソースカラー
、ソースα
、演算
は同じで、ディスティネーションカラー
のみが違います。
つまり、ディスティネーションカラーが異なるため、最終的な描画結果に違いが生まれてしまうのです。
この問題を解決するためにエフェクトを描画する時と、最終合成をする時で一工夫が必要になります。
では、具体的にどのように描画していくかと言うと次のような手順になります。
- 縮小バッファを黒(αは1)でクリア
- 縮小バッファに描画する際のブレンドステートをカラーとαで分ける
- カラーRGBのブレンドステートは一般的な半透明合成、αにはディスティネーションα×ソースαの逆数を累積していく
- 最終合成では、カラーバッファのカラー×縮小バッファのα + 縮小バッファのカラーで合成する
なぜこのような工夫が必要になるのかというと、半透明オブジェクトの合成を複数回行う場合、最終的な色(カラー)は次のような計算式で求められます。
最終カラー = d × (1 - α₁)(1 - α₂)(1 - α₃) + s₁ × α₁ × (1 - α₂)(1 - α₃) + s₂ × α₂ × (1 - α₃) + s₃ × α₃
ここで使われている記号は次の意味を表します。
- d:すでに書き込まれているカラー(今回であれば不透明オブジェクトの描画結果)
- s₁, s₂, s₃:それぞれの半透明オブジェクトのソースカラー
- α₁, α₂, α₃:各オブジェクトのアルファ値
この式を見ると、半透明エフェクトの合成において不透明オブジェクトの描画結果(d)が必要になるのは最終的な合成の段階だけです。そのため、エフェクトを描画する段階では、dを含まない部分だけ記憶していきます。
つまり、以下の2つを縮小バッファにそれぞれ格納しておくという考え方です。
- RGBには、dを除いたソースカラーの合成部分(s₁α₁(1-α₂)(1-α₃) + s₂α₂(1-α₃) + s₃α₃)
- αには、dにかかる係数である(1 - α₁)(1 - α₂)(1 - α₃)
このため、RGBとαに対して異なるブレンディング設定を行う必要があるのです。
また、この時点では不透明オブジェクト(d)との合成はまだ行っていないため、縮小バッファの背景は何も描画されていない状態、すなわち「黒」である必要があります。
最後に、最終合成フェーズで以下の式に基づいて、カラーバッファに合成を行います。
合成結果 = 不透明物を描画したカラーバッファのRGB × 縮小バッファのα + 縮小バッファのRGB
これにより、縮小バッファに対する半透明エフェクトの描画結果と、不透明オブジェクトの描画結果(d)との合成が正しく行われるようになります。
この手法に関するより詳しい情報は、以下の資料の「23.5 Alpha Blending」の節をご参照ください。
GPUGems3 Chapter 23. High-Speed, Off-Screen Particles
具体的なソースコード
では、最後に具体的なソースコードを提示していきます。 今回の実装はScriptableRenderPassを利用して縮小バッファに描画していきます。
1. 縮小バッファを作成して半透明エフェクトを描画しているパス
まずは、縮小バッファを作成して半透明エフェクトを描画しているパスのC#側のコード(Render Graph)の全容です。 重要な部分は後で解説をしていきます。
C#側のコードの全容
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) { if (_combineMaterial == null) { _combineMaterial = CoreUtils.CreateEngineMaterial( "Hidden/GPU-Performance-Tuning/CombineDownSampledEffect"); } var resourceData = frameData.Get<UniversalResourceData>(); var cameraData = frameData.Get<UniversalCameraData>(); var renderingData = frameData.Get<UniversalRenderingData>(); var lightData = frameData.Get<UniversalLightData>(); // ※1. 縮小バッファを作成する var downSampledEffectBufferDesc = renderGraph.GetTextureDesc(resourceData.activeColorTexture); downSampledEffectBufferDesc.width /= 2; downSampledEffectBufferDesc.height /= 2; downSampledEffectBufferDesc.colorFormat = GraphicsFormat.R16G16B16A16_SFloat; var downSampledEffectBuffer = renderGraph.CreateTexture(downSampledEffectBufferDesc); // ※2. 縮小バッファにエフェクトを描画する using(var builder = renderGraph.AddRasterRenderPass<PassData>( "Draw Downsampled Effect", out var passData)) { var sortFlags = SortingCriteria.CommonTransparent; var drawSettings = RenderingUtils.CreateDrawingSettings( new ShaderTagId("Render Downsampled Effect"), renderingData, cameraData, lightData, sortFlags); var filteringSettings = new FilteringSettings(RenderQueueRange.transparent); drawSettings.perObjectData = PerObjectData.None; var renderListParam = new RendererListParams( renderingData.cullResults, drawSettings, filteringSettings); passData.rendererListHandle = renderGraph.CreateRendererList(renderListParam); builder.SetRenderAttachment(downSampledEffectBuffer, 0); builder.UseTexture(resourceData.cameraDepth); builder.AllowPassCulling(false); builder.AllowGlobalStateModification(true); builder.UseRendererList(passData.rendererListHandle); passData.scaledScreenParams = new Vector4( downSampledEffectBufferDesc.width, downSampledEffectBufferDesc.height, 1.0f + 1.0f / downSampledEffectBufferDesc.width, 1.0f + 1.0f / downSampledEffectBufferDesc.height); builder.SetRenderFunc((PassData data, RasterGraphContext context) => { var cmd = context.cmd; // GetNormalizedScreenSpaceUV関数で利用される_ScaledScreenParamsをセットする cmd.SetGlobalVector("_ScaledScreenParams", passData.scaledScreenParams); cmd.DrawRendererList(data.rendererListHandle); }); } // ※3 最終合成 var combineMaterialparameter = new RenderGraphUtils.BlitMaterialParameters( downSampledEffectBuffer,resourceData.activeColorTexture, _combineMaterial, 0); renderGraph.AddBlitPass(combineMaterialparameter); }
では、この中で重要な3カ所について深掘りしていきます。
※1. 縮小バッファを作成する
まずはアクティブなカラーターゲットの1/2の解像度の縮小バッファを作成します。
今回の実装では必ずαチャネルが必要なのですが、カメラのHDRが有効な場合はαチャネルがないフォーマットが利用されるため、αチャンネル付きの16ビット浮動小数点テクスチャを作成します。 なお、この部分は16bit浮動小数点テクスチャを利用するのではなく、8bitのRチャネルだけのテクスチャを作成して、Multi Render Targetを利用してエフェクト描画することで、さらにメモリ帯域を減らすという最適化の余地がある部分になります。
// ※1. 縮小バッファを作成する // TextureDescを設定する var downSampledEffectBufferDesc = renderGraph.GetTextureDesc(resourceData.activeColorTexture); downSampledEffectBufferDesc.width /= 2; downSampledEffectBufferDesc.height /= 2; if(cameraData.isHdrEnabled){ downSampledEffectBufferDesc.colorFormat = GraphicsFormat.R16G16B16A16_SFloat; }else{ downSampledEffectBufferDesc.colorFormat = downSampledEffectBufferDesc.colorFormat; } // テクスチャを作成 var downSampledEffectBuffer = renderGraph.CreateTexture(downSampledEffectBufferDesc);
※2. 縮小バッファにエフェクトを描画する
続いで、縮小バッファにエフェクトを描画する処理です。
特定のRendererの描画をスクリプトから実行するコード自体は典型的なものなので特出すべき点は少ないのですが、シェーダーの深度テストで利用しているURPの関数のGetNormalizedScreenSpaceUV
の中で_ScaledScreenParams
を利用しているため、その値の設定を行っています。このコードはURP内部のC#コードを参考にしています。
// ※2. 縮小バッファにエフェクトを描画する using(var builder = renderGraph.AddRasterRenderPass<PassData>( "Draw Downsampled Effect", out var passData)) { var sortFlags = SortingCriteria.CommonTransparent; var drawSettings = RenderingUtils.CreateDrawingSettings( new ShaderTagId("Render Downsampled Effect"), renderingData, cameraData, lightData, sortFlags); var filteringSettings = new FilteringSettings(RenderQueueRange.transparent); drawSettings.perObjectData = PerObjectData.None; var renderListParam = new RendererListParams( renderingData.cullResults, drawSettings, filteringSettings); passData.rendererListHandle = renderGraph.CreateRendererList(renderListParam); builder.SetRenderAttachment(downSampledEffectBuffer, 0); builder.UseTexture(resourceData.cameraDepth); builder.AllowPassCulling(false); builder.AllowGlobalStateModification(true); builder.UseRendererList(passData.rendererListHandle); passData.scaledScreenParams = new Vector4( downSampledEffectBufferDesc.width, downSampledEffectBufferDesc.height, 1.0f + 1.0f / downSampledEffectBufferDesc.width, 1.0f + 1.0f / downSampledEffectBufferDesc.height); builder.SetRenderFunc((PassData data, RasterGraphContext context) => { var cmd = context.cmd; // GetNormalizedScreenSpaceUV関数で利用される_ScaledScreenParamsをセットする cmd.SetGlobalVector("_ScaledScreenParams", passData.scaledScreenParams); cmd.DrawRendererList(data.rendererListHandle); }); }
※3 最終合成
最後に最終合成です。縮小バッファをソーステクスチャ、カメラのアクティブなカラーテクスチャ(元々の出力先)をディスティネーションテクスチャとしてBlitを行っています。 このBlitの際にカスタムシェーダーを利用して合成のための特殊なブレンディングを行っています。
// ※3 最終合成 var combineMaterialparameter = new RenderGraphUtils.BlitMaterialParameters( downSampledEffectBuffer,resourceData.activeColorTexture, _combineMaterial, 0); renderGraph.AddBlitPass(combineMaterialparameter);
2. 縮小バッファへの半透明描画のブレンドステート
続いて、縮小バッファに描画する半透明描画のブレンディングステートです。
アルファブレンディングの問題
でお話ししたように、カラーとαでブレンディングステートを分けています。
Blend SrcAlpha OneMinusSrcAlpha, Zero OneMinusSrcAlpha
以下はシンプルなエフェクト描画用のシェーダーの全容です。
シンプルなエフェクト描画シェーダー
Pass { Tags { "LightMode"="Render Downsampled Effect" } // 縮小バッファへの半透明描画のブレンドステートを設定する Blend SrcAlpha OneMinusSrcAlpha, Zero OneMinusSrcAlpha ZWrite Off HLSLPROGRAM #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #pragma vertex vert #pragma fragment frag #pragma enable_d3d11_debug_symbols TEXTURE2D_X_FLOAT(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture); float4 _Color; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex); return o; } half4 frag (v2f i) : SV_Target { float2 screenSpaceUV = GetNormalizedScreenSpaceUV(i.vertex); float depth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, screenSpaceUV).r; clip(i.vertex.z - depth); return _Color; } ENDHLSL }
3. 最終合成のブレンディングステート
最後に最終合成のブレンディングステートです。ディスティネーションカラーの係数としてソースαを指定します。
Blend One SrcAlpha
この部分以外は通常のBlit用のシェーダーと同様です。
以下は最終合成用のシェーダーの全容です。
最終合成シェーダー
Shader "Hidden/GPU-Performance-Tuning/CombineDownSampledEffect" { SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline"} LOD 100 ZTest Always ZWrite Off Cull Off // 最終合成のブレンディング設定 Blend One SrcAlpha Pass { Name "Combine Downsampled Effect" 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 half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = UnityStereoTransformScreenSpaceTex(input.texcoord); half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv); return color; } ENDHLSL } } }
最後に
今回はレンダリングターゲットと半透明エフェクトに絞って縮小バッファエフェクトの描画についてお話しました。半透明以外の加算合成、乗算合成のエフェクトなどもカバーしたい場合は、この記事の内容だけでは実現できないのですが、どちらもブレンディング設定を工夫することで実現することができます。
縮小バッファエフェクトは昔からコンソールゲームの世界でも使われていますが、今のモバイル環境においても大きな効果を発揮する手法ですので、是非ご活用いただければ幸いです。