この記事は、2022のUnityアドベントカレンダー25日目の記事です。
皆さん、素敵なクリスマスをお過ごしでしょうか?私は18時間の大爆睡をかまして17時に起き、今この記事を書いています🥹(日付変わる前に間に合うかな?)
前日24日は、@kayk5654氏の「UnityでPhysarum Simulationをやってみた話」でした。
あのような表現は時々目にしますが、具体的にどんなものなのか考えたこともなかったので、非常に為になりました!
はじめに
URPのバージョンは14を前提とします。そしてURP15では、HDRPで先行導入されているRenderGraphに移行されていて、この記事の内容が役に立たない可能性があります。RanderGraphに関しては私も勉強中なので、気が向いたら記事にします。
コマンド
レンダリングは基本的にはGPUに行わせます。しかし、アプリケーションはCPUを中心に稼働していますので、CPUからGPUに「命令」と「データ」を転送する必要があります。これらはリスト構造として送信され、コマンドバッファと呼ばれます。
var cmd = CommandPool.Get();
cmd.SetRenderTarget(color, depth);
cmd.ClearRenderTarget(Color.back, 0);
cmd.Draw(mesh, material)
context.Execute(cmd);
GPU内部でどうなっているかはさておき、このコマンドはリニアで手続き的なもので、特別な概念や構造は持たないリストです。
ちなみに、ここでのコマンドバッファは、Unityによってラッピングされたコマンドセットであり、エンジン内部でGraphicsAPIのコマンドセットに変換され、GraphicsDriverでGPUネイティブなコマンドセットに変換され送信されます。これらは表現が異なるだけで、構造としては大きく変わることのないものと思われます。
URPのScriptableRenderPass
URPでは、管理しやすいようにコマンドバッファを抽象化しScriptableRenderPass
(以降SRPass)としています。SRPassは以下のようなメンバで構成されています。
abstract class ScriptableRenderPass
{
RenderPassEvent renderPassEvent { get; set; }
RenderTargetIdentifier[] colorAttachments { get; }
RenderTargetIdentifier depthAttachment { get; }
ClearFlag clearFlag
RenderBufferStoreAction[] colorStoreActions { get; }
RenderBufferStoreAction depthStoreAction { get; }
ScriptableRenderPassInput input { get; }
bool useNativeRenderPass { get; set; }
virtual void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData);
virtual void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor);
abstract void Execute(ScriptableRenderContext context, ref RenderingData renderingData);
virtual void OnCameraCleanup(CommandBuffer cmd);
}
そしてこの実行側はエッセンスだけ抜粋すると…
class UniversalRenderPipeline : RenderPipeline
{
// Unityのネイティブエンジンから呼び出されるエントリーポイント
override void Render(ScriptableRenderContext renderContext, List<Camera> cameras)
{
foreach(var camera in cameras)
{
var passes = camera.renderer.Setup();
passes.Sort((a, b) => a.renderPassEvent > b.renderPassEvent);
foreach(var pass in passes) pass.OnCameraSetup();
foreach(var pass in passes)
{
pass.Configure();
{
var loadAction = GetLoadAction(pass.clearFlag);
var cmd = CommandPool.Get();
cmd.SetRenderTarget(
pass.colorAttachments, loadAction.color, pass.colorStoreActions,
pass.depthAttachments, loadAction.depth, pass.depthStoreActions);
cmd.ClearRenderTarget(pass.ClearFlag, Color.black, 0);
context.Execute(cmd);
}
pass.Execute();
}
foreach(var pass in passes) pass.OnCameraCleanup();
}
}
}
ザックリ行ってしまうと、初期化、実行を明瞭にし、初期化中にRenderTarget(以降RT)を直接コマンドバッファに積まずにSRPassのメンバとして設定するように求めます。
NativeRenderPass
NativeRenderPass(以降NRPass)は、VulkanのRenderPass/SubPassに対応した機能です。(名前がかぶってしまったのでNativeとつけて区別しているものかと)インターフェースはVulkanに対応していますが、MetalもTBRのアーキテクチャは大体同じなので同じように動作します。
NRPassの使い方は以下のように、初めにAttachmentsと呼ばれるRTを指定し、SubPassではAttachmentsから実際に読み書きするRTのインデックスを指定します。
using (context.BeginScopedRenderPass(width, height, 1, attachments, 0))
{
using (context.BeginScopedSubPass(colorIndexes, depthIndex, inputIndexes))
{
// cmd.Drawなど
}
}
コマンドの章で述べた通り、GPUに伝えるコマンドは、基本的にはリニアで手続き的なもので、特別な概念や構造は持たないリスト構造のはずでした。しかし、NRPassではDrawコマンドを跨ぐような概念を伝達しています。ドライバーやGPUはこの情報を使うことで、RTが切り替わるタイミングを把握することが出来、RTをキャッシュメモリから書き出す回数を減らし高速化が期待できます。
詳しく知りたい方は、タイルベースレンダリングで調べてみると幸せになれるでしょう。QiitaやUnityLerningMaterialsにもわかりやすい記事が多くはないですがあるので参照してみてください。細かいことはともかく、モバイルなどの特定HWを中心にDraw命令ごとにRTをメモリに書き戻すオーバーヘッドが無くなり、複数Shaderをまとめて1Drawで済ますなどの面倒な手動最適化もしなくて済みます。
UnityEngine.Rendering.Universal.NativeRenderPass
クラス
NRPassはDrawコマンドを横断するRTを指定する概念でした。一方SRPassはDrawコマンドとRTをセットで抽象化するシステムでした。つまり、URPでNRPassを有効にしようとした場合、NRPassはSRPassを横断するものとなり、絶妙に噛み合いません。
そこで、URPではNRPassのAttechmentをどうするかという部分をユーザー(SRPass実装)から隠蔽してしまい、SRPassの持つRTが似ていて実行順が連続する者同士を自動でマージするという戦略をとっています。UnityEngine.Rendering.Universal.NativeRenderPass
クラス(以降uNRPass)はその自動マージを司るクラスです。
実際にマージを行うSetupNativeRenderPassFrameData
では、積んであSRPass(m_ActiveRenderPassQueue
)をループで舐めていき、前のSRPassを互換性があれば、m_MergeableRenderPassesMap
に追加します。互換性の判断にはSRPassに設定されたDepthRTとRTサイズ、currentHashIndex
(連続していないとインクリされる)の一致をもって互換性ありと判断しています。VulkanのNRPassでは、SubPass間でDepthRTが一致している必要はないのですが、uNRPassでは追加の制約となってしまっています。
for (int i = 0; i < m_ActiveRenderPassQueue.Count; ++i)
{
var renderPass = m_ActiveRenderPassQueue[i];
// rpDescは使用RTs+サイズの構造体
var rpDesc = InitializeRenderPassDescriptor(cameraData, renderPass);
// 連続性を加味
Hash128 hash = CreateRenderPassHash(rpDesc, currentHashIndex);
if (!m_MergeableRenderPassesMap.ContainsKey(hash))
{
m_MergeableRenderPassesMap.Add(hash, m_MergeableRenderPassesMapArrays[m_MergeableRenderPassesMap.Count]);
m_RenderPassesAttachmentCount.Add(hash, 0);
}
else if (m_MergeableRenderPassesMap[hash][GetValidPassIndexCount(m_MergeableRenderPassesMap[hash]) - 1] != (i - 1))
{
// 連続していないので、currentHashIndexを+1し、ハッシュ(マージ先)を再計算
currentHashIndex++;
hash = CreateRenderPassHash(rpDesc, currentHashIndex);
m_PassIndexToPassHash[i] = hash;
m_MergeableRenderPassesMap.Add(hash, m_MergeableRenderPassesMapArrays[m_MergeableRenderPassesMap.Count]);
m_RenderPassesAttachmentCount.Add(hash, 0);
}
m_MergeableRenderPassesMap[hash][GetValidPassIndexCount(m_MergeableRenderPassesMap[hash])] = i
...
}
これで、SRPassのマージ作業は完了です。この後、MRTとそうでない版の二つSetNativeRenderPassAttachmentList
、SetNativeRenderPassMRTAttachmentList
にて、NRPassとSubPassに設定されるAttachmentのリスト(m_ActiveColorAttachmentDescriptors
)を構築します。
このうち前者は、マージ候補のSRPassのうち、二つ目以降のRTも1枚しかないものとして扱うというバグがあります。ちなみに、このバグを回避するには、マージ候補となるSRPassの最初にRTを複数枚指定してExecute
でDrawコマンドを発行しないダミーSRPassを積んでおくことで後者のメソッドが使われ、回避で来ます。
ユーザー側でNativeRenderPassを使ったPassを作る
internalへのアクセスが必要になってくるので、リフレクションなど適当な方法で何とかしてください。
ちなみにSRPassのメンバにuseNativeRenderPass
というのがありましたが、これがfalse
の場合、そもそもマージ候補になりません。なのでこれをtrue
にする必要があります。注意すべきは、ScriptableRenderFeature
でrender.Enqueue
する際にfalse
に戻される点です。必ず、Enqueueした後にtrue
にしましょう。これでm_MergeableRenderPassesMap
に任意のSRPassが追加されることが確認できるはずです。
class MyFeature : ScriptableRendererFeature
{
void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.Enqueue(myPass);
renderer.useNativeRenderPass = true;
}
}
次に別のSRPassにマージさせるために必要なことをします。つまり、RTと実行順を調整するということです。RTに関しては、useNativeRenderPass
がtrueの場合、SRPass.Configure
は効かなくなるので、OnCameraSetup
で設定します。
class MyPass : ScriptableRenderPass
{
RenderTargetIdentifer[] m_Colors;
RenderTargetIdentifer m_Depth;
void Setup(RenderTargetIdentifer[] colors, RenderTargetIdentifer depth)
=> (m_Color, m_Depth) = (colors, depth);
override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
=> Configure(m_Colors, m_Depth);
}
class MyFeature : ScriptableRendererFeature
{
void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
myPass.renderPassEvent = mergeTarget.renderPassEvent;
myPass.Setup(mergeTarget.colorAttachments, depthAttachments);
renderer.Enqueue(myPass);
renderer.useNativeRenderPass = true;
}
}
需要としては、UniversalRenderer.m_RenderOpaqueForwardPass
やUniversalRenderer.m_RenderTransparentForwardPass
などにマージしたくなるでしょう。この場合、SRPass.colorAttachments
ではなく、UniversalRenderer.cameraColorTarget
あたりから取ってくる必要があります。ScriptableRenderPass.overrideCameraTarget
を確認してください。
さらに、DrawObjrctPassでMaterialIDを追加RTに書き出し、PostProcess的なことをしたいような場合、マージ先のSRPassにも同じRTを設定する必要があります。
class MyFeature : ScriptableRendererFeature
{
CallbackPass m_CallbackPass; // OnCameraSetupイベントを発行するためだけのダミーPass
void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
m_CallbackPass.OnCameraSetupAction = (() =>
{
foreach(var pass in renderer.m_ActiveRenderPassQueue)
{
var colors = pass.colorAttachments.Append(addRT).ToArray();
pass.ConfigureTarget(colors, pass.depthAttachment);
}
}
renderer.Enqueue(m_CallbackPass);
... // Enqueue MyPass
}
}
まとめ
URPでNRPassを使う拡張を行うことは想定されていませんが(internal)、URPの実装を理解することでURPを使いつつNRPassの恩恵にあずかることが出来ました。
しかし、バグっぽい挙動があったり、マージの実装も気持ち悪くデバッグが非常に辛いので、それなりの覚悟で臨んでみてくださいw(この辺、RenderGraphでいい感じになるといいのですが…)
P.S.
やっぱり間に合わなかったよ…🥹
でも、寝るまでが今日だよね?このまま寝なければ永遠にエンドレスクリスマス!?(深夜テンション)