AR Foundationで使うオクルージョン機能を自分で作ってみるログ

これは何?
AR FoundationにはAR Occlusion Managerというものがあるが,これによるオクルージョンの内部処理は自分でいじることができない.研究でここを色々いじりたい需要が出てきたので,色々使ってやってみることにした.
ちなみに自分用の備忘録なので,色々端折り気味に書いています.予めご了承のほど.
特にScriptable Render PipelineへのPassやFeatureの登録方法などはバッサリカットしています.
環境
Unity 6000.0.49f1
AR Foundation 6.0.5
Scriptable Render Pipeline Core 17.0.4
Universal Render Pipeline 17.0.4
動作確認した実機:Pixel 8
(多分使ったのはこの辺でいいはず…)

前提
Occlusionには主にHard OcclusionとSoft Occlusionがある(と僕は思っている).これらの違いは,HardはZ Bufferのみで完結するのに対して,SoftはColor BufferのAlphaを使ってうまいこと見た目をいい感じにするところも含んでいる.
さらに言うと,Occlusionをするための深度情報の実装方式にはいくつかある.1つ目は深度画像で,LiDARやステレオカメラから取れる2Dの深度情報である.それに対して,2つ目はこの深度画像から得られたメッシュ情報によるオクルージョンである.大体のものの中身のアルゴリズムがブラックボックスなので実際は分からないが,大体のものは後者のメッシュによるオクルージョンが多いように思う(Meta Questの中身でとれるのも大体深度メッシュである.)
※画像はこちらから引用
そのうえで,今回はHardで深度画像をベースとしたものをまず実装することを目指す.その後Softを実装してみる.深度画像ベースの理由としては,研究が画像処理関係のものなのでメッシュは一旦いいかな,っていうのと,深度画像からのメッシュ再構成のいい感じのライブラリが見つからなかったのがある.
また,今回はAndroidのAR Coreを前提としている.LiDARが入っているのはiOSの方なので深度情報の正確性はそっちの方が上である.それでもAndroidなのは,AR Foundationのサンプルを使ってみてAndroidの深度推定でもまぁまぁいい感じに動いてくれてそうなのと,後々Playback APIを使いたいという需要がある(iOSのARセッションリプレイもあるらしいが安定性に欠けるらしい)のと,あとはWindowsから直接ビルドできるという開発上の利便性が理由である.

基本戦略
UnityにはSRP(Scriptable Render Pipeline)というものがあり,これにPassを追加することでレンダリングに色々干渉できるようになっている.なので,SRPに割り込んで,不透明の3Dオブジェクトがレンダリングされる前に深度バッファに任意の深度値を書き込むことで,特定の深度以下にあるfragment shaderのZ Testが失敗するようにする.

実装
一部Claudeにコードを書かせているので,掲載しているコードでも細かい部分で理解していない部分が含まれています.自分用の備忘録に近いので,参考程度に見てください.動かなかったら順次更新します.
Passの実装
RecordRenderGraph
一旦諸々は置いておいて,RecordRenderGraph
メソッドを置く.
他は普通のRenderGraphの構造なので,こちらの記事などが解説していただいているような感じで,FeatureはPassをそのままSRPのパイプラインに入れる感じでヨシ.
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) {
var resourceData = frameData.Get<UniversalResourceData>();
var cameraColorTextureHandle = resourceData.activeColorTexture;
var cameraDepthTextureHandle = resourceData.activeDepthTexture;
UniversalCameraData cameraData = frameData.Get<UniversalCameraData>();
int w = cameraData.scaledWidth;
int h = cameraData.scaledHeight;
if (!_isDepthInited) {
var planeWidth = (int)(w * (_settings.OccArea.z - _settings.OccArea.x));
var planeHeight = (int)(h * (_settings.OccArea.w - _settings.OccArea.y));
_depthTexture = DepthTextureCreator.CreateTestDepthTexture(width: planeWidth, height: planeHeight, depthPlane: _settings.OccPlaneDepth);
_isDepthInited = true;
}
var depthMaskMaterial = new Material(_settings.ApplyMaterial);
using (var builder = renderGraph.AddRasterRenderPass("DepthMaskPass", out DepthMaskPassData passData)) {
builder.SetRenderAttachment(cameraColorTextureHandle, 0, AccessFlags.ReadWrite);
builder.AllowPassCulling(false);
builder.SetRenderAttachmentDepth(cameraDepthTextureHandle, AccessFlags.Write);
passData.DepthMaskMaterial = depthMaskMaterial;
passData.DepthTex = _depthTexture;
builder.SetRenderFunc((DepthMaskPassData data, RasterGraphContext context) => {
data.DepthMaskMaterial.SetTexture(DEPTH_TEX, data.DepthTex);
context.cmd.DrawProcedural(Matrix4x4.identity, data.DepthMaskMaterial, 0, MeshTopology.Triangles, 3);
});
}
}
色々独自メソッドなどがあるのでわかりにくいが,キモはここで,
context.cmd.DrawProcedural(Matrix4x4.identity, data.DepthMaskMaterial, 0, MeshTopology.Triangles, 3);
DrawProcedural
とは,ProceduralMeshというものを描くCommandBufferのコマンドである.このProceduralMeshというのは動的に作れるMeshのことなのだが,今回はメッシュとして捉えるよりも,最後の引数(今回は3)の分だけマテリアルに設定された頂点シェーダ(今回はdata.DepthMaskMaterial
の0番目のシェーダ)を実行してくれると捉えるとよい.今回は3点で画面全体を覆う三角形を作り,そこに深度マップを書き込むことでオクルージョンを実装する.
renderPassEvent
SRPにおけるrenderPassEvent
はPassが割り込む位置を決定するパラメータである.オクルージョンを実装する上で,一番ややこしいのがここである.まず,深度マップの仕様上,たとえシェーダ側でカラーバッファに描かないようにした(ColorMask 0
にする)としても,深度値が存在する=オブジェクトがあると判断されてしまい,上に描画するためにはZ値が勝っていないといけなくなる.そして,skyboxやARのカメラパススルーはZ値が一番後ろである.つまり何が言いたいかというと,深度マップを描いた後に,後ろのカメラパススルーやskyboxを追加描画することはできないということである[1](これはUnity6で仕様変化したらしい?).もしパススルーやskyboxなどの描画の前に深度マップを書いてしまうと,その部分には本来描かれるべき背景が描画されず,真っ黒になったりカメラのSolid Colorの色が描画されてしまったりする.しかし,オブジェクトの描画後に深度マップを入れてもオクルードされない.つまり,
ARBackgroundRendererFeature -> Occlusion -> 不透明オブジェクトの描画
の順でやる必要がある.こうすると,背景を描画→カラーバッファには触らず深度バッファに深度を書き込む→オブジェクトは深度バッファに影響を受けて,オクルージョン部分は描画が遮ぎられる,という形になる.
ここで面倒なのがARFaoundationの背景描画PassであるARBackgroundRendererFeature
の仕様で,こいつはrenderPassEvent
をBefore Opaques
かAfter Opaques
のどちらかにしか設定できない.Before Opaques
の後は不透明オブジェクトの描画が発生してしまうので,先ほどのオクルージョンの実行順を実現するには,ARBackgroundRendererFeature
とOcclusion
をどちらもBefore Opaques
の中に置いたうえで,その中でPassの実行順を制御しないといけない.
ここで大分スタックしたのだが,Claudeに聞いてみたらあっさりと解決できた.実はこのrenderPassEventは各eventの間は整数値で1しかないわけではなく,いくらか余裕をもって設定されている.(例えば,Before Opaques
は300である.)なので,シンプルに次のように+1してあげれば,優先度は後ろに回される.
renderPassEvent = (RenderPassEvent)((int)RenderPassEvent.BeforeRenderingOpaques + 1); // 301
ちなみに,Universal Renderer DataにアタッチしたPassの並び順でもこの優先順をある程度制御できるようだが,多分ARBackgroundRendererFeature
はARCameraとの連携をする都合上この並び順による優先度を無視するみたい(要検証)で,並び順を変えてもうまくいかなかった.
skyboxがオクルージョンマスクで塗りつぶされる!
仕様です
というのも,上で解説した描画順の問題で,skyboxは不透明オブジェクトの後ろで描画されるので,
ARBackgroundRendererFeature -> Occlusion -> 不透明オブジェクトの描画 -> skybox
となるので,深度マップを書き込まれてしまっている分その上にskyboxを描くことはできないのである.そして面倒なことにskyboxは不透明オブジェクトの後ろで描画されるので,skyboxの後でオクルージョンしようものなら不透明オブジェクトもオクルージョンされなくなってしまう.よって,Unity Editorの中オクルージョンが適用できないのは,今回の手法だと残念ながら避けられない.マクロなどでPassを制御することで開発中の違和感は抑えられるかもしれない.
シェーダ
先ほど紹介したdata.DepthMaskMaterial
のなかにあるシェーダは次の通りに描いた.
Shader "Unlit/FullscreenTriangle"
{
SubShader
{
Tags {
"RenderPipeline"="UniversalPipeline"
}
Pass
{
Cull Off
Blend One Zero
ZTest Always
ZWrite On
ColorMask 0
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D(_DepthTex);
SAMPLER(sampler_DepthTex);
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
Varyings vert(uint vertexID : SV_VertexID)
{
Varyings o;
// 頂点IDに基づいてUV座標を生成
// (0,0), (2,0), (0,2) というUVを作る
o.uv = float2((vertexID << 1) & 2, vertexID & 2);
// UVからクリップスペース座標を生成
// (0,0) -> (-1,-1)
// (2,0) -> ( 3,-1)
// (0,2) -> (-1, 3)
#if UNITY_REVERSED_Z
o.positionCS = float4(o.uv * 2.0 - 1.0, 1.0, 1.0); // Near = 0
#else
o.positionCS = float4(o.uv * 2.0 - 1.0, 0.0, 1.0); // Far = 1
#endif
// DirectX環境ではYが反転するので、ここで修正する
#if UNITY_UV_STARTS_AT_TOP
o.positionCS.y = -o.positionCS.y;
#endif
return o;
}
float frag(Varyings i) : SV_Depth
{
float rawDepth = SAMPLE_TEXTURE2D(_DepthTex, sampler_DepthTex, i.uv).r; // Depth val in R channel
if (rawDepth < 0)
discard;
return rawDepth;
}
ENDHLSL
}
}
}
画面全体に適用するシェーダ(Fullscreen Triangle)
画面全体を覆う三角形を描くのは,頂点シェーダの次の部分である.
// (0,0), (2,0), (0,2) というUVを作る
o.uv = float2((vertexID << 1) & 2, vertexID & 2);
ProceduralMeshの頂点シェーダは,vertexID
がそれぞれ連番で降ってくる.今回は頂点数が3なので,これには0~3が降ってくる.これをビット演算して(0,0), (2,0),(0,2)という座標に変換すると,下のような三角形になる.
このうち画面にかかっている部分をクランプすることで画面全体に適用するメッシュになる.
フラグメントシェーダ(Work in Progress)
float frag(Varyings i) : SV_Depth
{
float rawDepth = SAMPLE_TEXTURE2D(_DepthTex, sampler_DepthTex, i.uv).r; // Depth val in R channel
if (rawDepth < 0)
discard;
return rawDepth;
}
今回,深度マスクを適用しない部分には0より下の値を割り当てることにしたので,そこはdiscard.
そして,今回はrawDepth
をそのまま適用しているが,実際のところUnityのDepthは0~1の値で非線形(カメラに近いほど分解能が細かい)になっているので,ここで変換処理を挟む必要がある.変換は後でやる.
その他スタックしたところ
- FullScreenTriangleは表裏が変になるっぽいので,
Cull Off
にする必要がある.
現状
上を使って,テストの深度画像でHard Occlusionするところまではできた.
実際の深度画像でテストしてできたら,今度はStencilを使ってSoft Occlusionを実装してみる.