📖

Unityで真面目にOcclusionCullingをやってみた

2024/12/19に公開

はじめに

この記事はQualiArts Advent Calendar 2024の19日目の記事です。
こんにちは、はろーです。お久しぶりに記事が復活しました。
もしかしたら今後もこういった場で記事を書くことがあるかもしれませんね。
ところで、直近の記事すべてがDirectXの記事でしたが、Unityでも書きたいと思います。

背景

まず、今回何故Unityでオクルージョンカリングを行おうかと思った経緯ですが、私自身Unityが不慣れな部分もありながら単純にオクルージョンカリングは今までに実装したことがなかったなと思い学習も兼ねてやってみました。

OcclusionCullingとは

今回実装するオクルージョンカリングですが、先に簡単な説明をしておきます。
下のSSを見てもらうと分かりやすいですが、壁で遮蔽されてしまったオブジェクトは本来描画されないですが、視錐台カリングだけではカリングすることは不可能です。そんな時に事前にOccluder(遮蔽物)の深度値をテクスチャに書き込みOccludee(被遮蔽物)を描画する前にBoundSphereと深度比較を行う事で事前に可視されているかどうかを判定することが出来ます。

実装手順

簡単に以下のフェーズで実装していきます。
1.Occluderの深度情報をテクスチャに書き込む。
2.深度情報を持って視錐台カリングとオクリュージョンカリングを行う。
3.最終結果バッファを参照してメッシュの描画を切り替える。

Occluder

他のオブジェクトの視界を遮るオブジェクトの事で、オクルージョンカリングにおいてカメラから見えないオブジェクトを判定するために使用します。

public class Occluder : MonoBehaviour
{
    private void Start()
    {
        gameObject.layer = LayerMask.NameToLayer("Occluder");
    }
}

Occludee

完全に隠れている場合、描画しないがOccludeeが視界内に現れると、そのオブジェクトは描画対象となります。

public class Occludee : MonoBehaviour
{
    private Bounds meshBounds;
    
    public Bounds MeshBounds
    {
        get
        {
            if (meshBounds.size == Vector3.zero)
            {
                MeshFilter meshFilter = GetComponent<MeshFilter>();
                if (meshFilter != null)
                {
                    meshBounds = meshFilter.sharedMesh.bounds;
                    meshBounds.center = transform.position;
                }
            }
            return meshBounds;
        }
    }
    
    private MeshRenderer _meshRenderer;
    
    public MeshRenderer MeshRenderer {
        get
        {
            if (_meshRenderer == null)
            {
                _meshRenderer = GetComponent<MeshRenderer>();
            }
            return _meshRenderer;
        }
    }

    void Start()
    {
        gameObject.layer = LayerMask.NameToLayer("Occludee");
        Renderer renderer = GetComponent<Renderer>();
        if (renderer != null)
        {
            Bounds maxBounds = new Bounds(transform.position, new Vector3(1000000f, 1000000f, 1000000f));
            renderer.bounds = maxBounds;
        }
    }
}

可視性判定

視錐台カリング (Frustum Culling)

視錐台カリングは、物体(この場合、球体)が視錐台内に存在するかどうかを確認する方法です。視錐台を構成する6つの平面(前、後、左、右、上、下)に対して、物体の中心から各平面への距離を計算し、物体が視錐台の外にある場合はカリングされます。

float DistanceToPlane(float4 vPlane, float3 vPoint)
{
    return dot(vPlane.xyz, vPoint) + vPlane.w;
}

float FrustumCulling(float4 vPlanes[6], float3 vCenter, float fRadius)
{
    // 各平面からの距離を計算
    float distLeft = DistanceToPlane(vPlanes[0], vCenter); // 左
    float distRight = DistanceToPlane(vPlanes[1], vCenter); // 右
    float distTop = DistanceToPlane(vPlanes[2], vCenter); // 上
    float distBottom = DistanceToPlane(vPlanes[3], vCenter); // 下
    float distNear = DistanceToPlane(vPlanes[4], vCenter); // 近距離
    float distFar = DistanceToPlane(vPlanes[5], vCenter); // 遠距離

    if (distLeft < -fRadius || distRight < -fRadius || distTop < -fRadius || distBottom < -fRadius || distNear < -
        fRadius || distFar < -fRadius)
    {
        return 0;
    }

    return 1;
}

カメラとオブジェクトの準備

ここからはオクリュージョン部分です。ビュー行列にはカメラの位置や向きが含まれており、カメラの位置(viewEye)、上方向(cameraUp)、右方向(camRight)の情報を取得します。

float3 viewEye = c_View._m03_m13_m23;
float3 cameraUp = c_View._m01_m11_m21;
float3 camRight = c_View._m00_m10_m20;

カメラからオブジェクトの中心(Bounds.xyz)までの距離を計算し、オブジェクトがカメラに対してどれくらいの大きさで表示されるかを計算します。物体の半径(Bounds.w)は、カメラから物体までの距離に基づいて計算されます。視界内での物体の角度を求め、その角度に基づいて物体の見かけの半径を算出します。

float cameraToSphereDistance = distance(viewEye, Bounds.xyz);
float radius = cameraToSphereDistance * tan(asin(Bounds.w / cameraToSphereDistance));

この半径をカメラの上方向(cameraUp)と右方向(camRight)に沿ってオフセットを計算します。これによって、物体のサイズがカメラ空間でどのように見えるかを調整することができます。

float3 upRadius = cameraUp * radius;      // 上方向のオフセット
float3 rightRadius = camRight * radius;   // 右方向のオフセット

これらのオフセットを使って物体のバウンディングボックス(四隅)の位置を計算します。具体的には、物体の中心位置(Bounds.xyz)を基準にして、上方向と右方向に沿った距離分だけオフセットを加減して四隅の位置を求めます。その後ワールド空間からクリップ空間に変換を行い、さらに画面空間に変換を行います。

float4 topLeftWS = float4(Bounds.xyz + upRadius - rightRadius, 1); // Top-Left
float4 topRightWS = float4(Bounds.xyz + upRadius + rightRadius, 1); // Top-Right
float4 bottomLeftWS = float4(Bounds.xyz - upRadius - rightRadius, 1); // Bottom-Left
float4 bottomRightWS = float4(Bounds.xyz - upRadius + rightRadius, 1); // Bottom-Right

float4 topLeftCS = mul(c_ViewProjection, topLeftWS);
float4 topRightCS = mul(c_ViewProjection, topRightWS);
float4 bottomLeftCS = mul(c_ViewProjection, bottomLeftWS);
float4 bottomRightCS = mul(c_ViewProjection, bottomRightWS);

float2 topLeftNDC = topLeftCS.xy / topLeftCS.w;
float2 topRightNDC = topRightCS.xy / topRightCS.w;
float2 bottomLeftNDC = bottomLeftCS.xy / bottomLeftCS.w;
float2 bottomRightNDC = bottomRightCS.xy / bottomRightCS.w;
topLeftNDC = float2(0.5, -0.5) * topLeftNDC + float2(0.5, 0.5);
topRightNDC = float2(0.5, -0.5) * topRightNDC + float2(0.5, 0.5);
bottomLeftNDC = float2(0.5, -0.5) * bottomLeftNDC + float2(0.5, 0.5);
bottomRightNDC = float2(0.5, -0.5) * bottomRightNDC + float2(0.5, 0.5);

球の中心をカメラ座標系からワールド座標系に変換、そして球の表面上で最もカメラに近い点を計算します。

float3 sphereCenterCS = mul(c_View, float4(Bounds.xyz, 1)).xyz;
float3 Pv = sphereCenterCS - normalize(sphereCenterCS) * Bounds.w;
float4 closestSpherePoint = mul(c_Projection, float4(Pv, 1));

最後に画面空間の四隅座標をUVとして深度マップから深度情報を取得し、最も手前に位置している座標を球の最もカメラから近い点と比較し可視するかどうかを決めてします。

float4 CornerDepth;
CornerDepth.x = DepthMap.SampleLevel(pointClampSampler, topLeftNDC, 0);
CornerDepth.y = DepthMap.SampleLevel(pointClampSampler, topRightNDC, 0);
CornerDepth.z = DepthMap.SampleLevel(pointClampSampler, bottomLeftNDC, 0);
CornerDepth.w = DepthMap.SampleLevel(pointClampSampler, bottomRightNDC, 0);

float maxCornerDepth = max(max(CornerDepth.x, CornerDepth.y), max(CornerDepth.z, CornerDepth.w));
float closestSphereDepth = (closestSpherePoint.z / closestSpherePoint.w);

OutputBuffer[index].x = (closestSphereDepth > maxCornerDepth) ? 0 : 1;

OcclusionCullingRenderFeature/Pass

RenderFeatureとPassは特に特別な事はしていないので解説なしです。

public class OcclusionCullingPassFeature : ScriptableRendererFeature
{
    private static OcclusionCullingPass _occlusionCullingPass;

    [SerializeField] 
    private ComputeShader _calculateCulling;

    public override void Create()
    {
        var occludees = Resources.FindObjectsOfTypeAll<Occludee>().ToList();

        _occlusionCullingPass = new OcclusionCullingPass(_calculateCulling, occludees)
        {
            renderPassEvent = RenderPassEvent.BeforeRenderingOpaques
        };
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (_calculateCulling != null)
        {
            renderer.EnqueuePass(_occlusionCullingPass);
        }
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        _occlusionCullingPass.Dispose();
    }
    class OcclusionCullingPass : ScriptableRenderPass
    {
        private readonly List<ShaderTagId> _shaderTagIdList = new();
        private RTHandle _depthMap;

        private ComputeShader _calculateCulling;

        private GraphicsBuffer _inputCullingBuffer;
        private GraphicsBuffer _outputVisibleBuffer;

        private List<Occludee> _occludees = new();

        private struct CullingData
        {
            public Matrix4x4 viewMatrix;
            public Matrix4x4 projectionMatrix;
            public Matrix4x4 viewProjMatrix;

            public Vector4[] frustumPlanes;

            // z.w is padding
            public Vector4 viewportSize;
        }

        private CullingData _cullingData;


        public OcclusionCullingPass(ComputeShader calculateCulling, List<Occludee> occludees)
        {
            _occludees = occludees;

            _shaderTagIdList.Add(new ShaderTagId("Occluder"));

            ===== 中略 ======
            Bufferの初期化とか
            =================
        }

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            CommandBuffer cmd = CommandBufferPool.Get("Occlusion Culling Pass");
            using (new ProfilingScope(cmd, new ProfilingSampler("Occlusion Culling Pass")))
            {
                context.ExecuteCommandBuffer(cmd);
                cmd.Clear();

                var drawingSettings =
                    CreateDrawingSettings(_shaderTagIdList, ref renderingData, SortingCriteria.CommonOpaque);
                var filteringSettings = new FilteringSettings(RenderQueueRange.opaque, LayerMask.GetMask("Occluder"));
                context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref filteringSettings);
            }

            UpdateCullingData(renderingData.cameraData.camera);
            CalculateCulling();

            context.ExecuteCommandBuffer(cmd);
            CommandBufferPool.Release(cmd);
        }

        private void UpdateCullingData(Camera camera)
        {
            ===== 中略 ======
            cullDataの更新
            =================
        }

        private void CalculateCulling()
        {
            var kernelIndex = _calculateCulling.FindKernel("CalculateCulling");
            
            ===== 中略 ======
            cullDataをセット
            =================
            
            _calculateCulling.SetTexture(kernelIndex, "DepthMap", _depthMap);
            
            _calculateCulling.Dispatch(kernelIndex, _occludees.Count, 1, 1);
            
            var visibleList = new int[_occludees.Count];
            _outputVisibleBuffer.GetData(visibleList);
            
            for (int i = 0; i < _occludees.Count; i++)
            {
                _occludees[i].MeshRenderer.enabled = visibleList[i] > 0;
            }
        }

        public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
        {
            var colorDesc = new RenderTextureDescriptor(renderingData.cameraData.camera.pixelWidth, renderingData.cameraData.camera.pixelWidth, GraphicsFormat.R32_SFloat, 0);

            RenderingUtils.ReAllocateIfNeeded(ref _depthMap, colorDesc, FilterMode.Point, TextureWrapMode.Clamp, name: "Render Occluder");
            
            ConfigureTarget(_depthMap);
            ConfigureClear(ClearFlag.All, Color.red);
        }

        public void Dispose()
        {
            _depthMap?.Release();
            _inputCullingBuffer?.Release();
            _outputVisibleBuffer?.Release();
        }
    }

結果画面


参考記事

https://www.nickdarnell.com/hierarchical-z-buffer-occlusion-culling-shadows/

https://sites.google.com/site/monshonosuana/directxの話/directxの話-第122回

おわりに

今回Unityでオクリュージョンカリングをしてみましたが、もっと早くに実装していればなと感じました。特にDirectXを触ってた時はカリングについてそこまで詳しくなかったのもあって処理負荷部分について深く考えることはありませんでしたが基礎となる部分を改めて学ぶことが出来たなと感じました。

最後にですが、今後も何かしら不定期で記事の更新が出来たらいいなと考えておりますので今後もよろしくお願いします。

Discussion