Occlusion Culling Renderer Feature: Fragment shader writing to RWStructuredBuffer, and proper access in Render Graph

I’m working on an implementation of GPU Occlusion culling that disabled/enables Mesh Renderers based on their visibility. This is based on this repo here:
przemyslawzaworski/Unity-GPU-Based-Occlusion-Culling (github.com)

The concept is simple enough:

  • A shader is used to draw some procedural cube geometry that represent bounding boxes for target Game Objects to be tested for culling.
  • The bounding box objects use a shader with the attribute [earlydepthstencil] on the pixel/fragment shader to skip rendering pixels it fails the depth stencil test. If it passes (object is visible) the vertex coordinates for this object are written to a RWStructuredBuffer, otherwise the buffer will just contain 0 values for these vertices in the array.
  • On the CPU side, this buffer is copied back to an array that can be used to update the visibility of each Game Object.

In my Renderer feature, I have two passes.
First pass draws the geometry from a list of vertices that represent all of the bounding boxes.
My result:

        public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
        {
            if (this.m_Material == null || m_Vertices == null || m_Vertices.Count == 0)
                return;

            // Get frame resources and camera data
            UniversalResourceData resourceData = frameData.Get<UniversalResourceData>();
            UniversalCameraData cameraData = frameData.Get<UniversalCameraData>();
            TextureHandle cameraColorTextureHandle = resourceData.activeColorTexture;

            RenderTextureDescriptor desc = cameraData.cameraTargetDescriptor;
            desc.msaaSamples = 1;
            desc.depthBufferBits = 0;

            // Draw directly to camera color handle
            using (IRasterRenderGraphBuilder builder = renderGraph.AddRasterRenderPass<DrawPassData>(PASS_NAME, out var passData, this.profilingSampler))
            {
                var colorDesc = new TextureDesc(Vector2.one) {
                    clearBuffer = false,
                    enableRandomWrite = true,
                };
                // Bind color buffer to render pass (this is where the Load/Store action is implicitly set)
                passData.textureHandle = cameraColorTextureHandle;

                // Set target for write. SetRenderAttachment is equivalent of SetRenderTarget
                builder.SetRenderAttachment(cameraColorTextureHandle, 0, AccessFlags.Write);

                // Disable pass culling - Passes are culled if no other passes access the write target
                builder.AllowPassCulling(false);

                // Resources/References for pass execution
                passData.material = m_Material;
                passData.vertexCount = m_Vertices.Count;

                // Set render function
                builder.SetRenderFunc((DrawPassData passData, RasterGraphContext graphContext) => ExecuteDrawCubesPass(passData, graphContext));
            }
        }

        private static void ExecuteDrawCubesPass(DrawPassData passData, RasterGraphContext graphContext)
        {
            RasterCommandBuffer cmd = graphContext.cmd;

            // Set the material pass and draw the bounding boxes
            passData.material.SetPass(0);
            cmd.DrawProcedural(Matrix4x4.identity, passData.material, 0, MeshTopology.Triangles, passData.vertexCount);
        }

So far so good. The Shader is correctly reading the reader buffer of vertex data, and is drawing as expected. In the Renderer feature asset inspector, a Debug boolean will switch the alpha on/off in the pixel shader to allow visualizing it in Game mode. This is the complete shader:

Shader "HardwareOcclusion"
{
	SubShader
	{
		Cull Off
		Tags { "RenderType" = "Transparent" "Queue" = "Transparent" }
		Pass
		{
			Blend SrcAlpha OneMinusSrcAlpha
			ZWrite Off
			CGPROGRAM
			#pragma vertex VSMain
			#pragma fragment PSMain
			#pragma target 5.0

			RWStructuredBuffer<float4> _Writer : register(u1);
			StructuredBuffer<float4> _Reader;
			int _Debug;

			float4 VSMain (float4 vertex : POSITION, out uint instance : TEXCOORD0, uint id : SV_VertexID) : SV_POSITION
			{
				instance = _Reader[id].w;
				
				return mul (UNITY_MATRIX_VP, float4(_Reader[id].xyz, 1.0));
			}

			[earlydepthstencil]
			float4 PSMain (float4 vertex : SV_POSITION, uint instance : TEXCOORD0) : SV_TARGET
			{
				_Writer[instance] = vertex;
				return float4(0.0, 0.0, 1.0, 0.2 * _Debug);
			}
			ENDCG
		}
	}
}

The second pass needs to be able to copy the GPU data from the RWStructuredBuffer that is written to in the pixel shader, and use that to update the Mesh Renderer visibility. This is what I have:

        public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
        {
            if (m_Elements == null || m_Cache == null) return;

            m_Writer.GetData(m_Elements);

            string elem = "";
            foreach (var e in m_Elements)
            {
                elem += $"{e.x} {e.y} {e.z} {e.w}, ";
            }
            Debug.Log($"UpdateVisPass m_Elements {elem}");

            BufferHandle writerHandle = renderGraph.ImportBuffer(m_Writer);

            UniversalResourceData resourceData = frameData.Get<UniversalResourceData>();
            TextureHandle cameraColorTextureHandle = resourceData.activeColorTexture;

            using (IComputeRenderGraphBuilder builder = renderGraph.AddComputePass<UpdateVisPassData>("BufferPass", out var passData))
            {
                // Set pass data
                passData.material = m_Material;
                passData.meshRenderers = m_MeshRenderers;
                passData.elements = m_Elements;
                passData.cache = m_Cache;
                passData.delay = 1;
                passData.writer = writerHandle;

                builder.AllowPassCulling(false);

                builder.UseBuffer(writerHandle, AccessFlags.Write);

                builder.SetRenderFunc((UpdateVisPassData passData, ComputeGraphContext graphContext) => Execute(passData, graphContext));
            }
        }

        static void Execute(UpdateVisPassData passData, ComputeGraphContext context)
        {
            ComputeCommandBuffer cmd = context.cmd;

            if (Time.frameCount % passData.delay != 0) return;

            bool state = ArrayState(passData.elements, passData.cache);
            if (!state)
            {
                for (int i = 0; i < passData.meshRenderers.Count; i++)
                {
                    for (int j = 0; j < passData.meshRenderers[i].Count; j++)
                    {
                        passData.meshRenderers[i][j].enabled = (Vector4.Dot(passData.elements[i], passData.elements[i]) > 0.0f);
                    }
                }
                ArrayCopy(passData.elements, passData.cache);
            }
            Array.Clear(passData.elements, 0, passData.elements.Length);

            cmd.SetBufferData(passData.writer, passData.elements);
        }

So what I’m expecting, is that once the Record method runs GetData is called, that my Elements array would be filled with the latest data from the shader.

First thing I tried was writing a constant value (99.0, 99.0, 99.0, 99.0) to the writer inside of the pixel shader, to see if I could read that back. Same result.

So it’s either not writing to the buffer at all, or the CPU isn’t reading it back.

To get a baseline, I ran a build of the original repo which uses the built-in render pipeline. I inspected a frame in Renderdoc:


Here, I clearly see the UAV in the resource list. Inspecting its contents I confirm that its being written to with valid vertex data as expected.

I did a build of my Project, ran a capture in Renderdoc and inspect the resource list:

No UAV shows in the resource list at all, just the reader buffer, which would make sense because I’m having no problem drawing geometry from my pass. What is causing the writer buffer to seemingly not be used at all?

I’ve hit a wall here. Am I passing references incorrectly, or is the material.SetBuffer not sticking somehow in rendergraph? I had heard that Unity can lose the binding easily, so I tried doing material.SetBuffer(m_Writer) in the Record methods but this made no difference.

Full Renderer Feature Code:

using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.Rendering.RenderGraphModule;
using System.Collections.Generic;
using System.Linq;
using static UnityEngine.GraphicsBuffer;

public class GPUOcclusionCullingRendererFeature : ScriptableRendererFeature
{
    private GameObject[] Targets;
    private Material Material;

    private GraphicsBuffer Reader;
    private GraphicsBuffer Writer;

    private Vector4[] Elements;
    private Vector4[] Cache;
    private List<List<MeshRenderer>> MeshRenderers;
    private List<Vector4> Vertices;

    /// <summary>
    /// A system where proxy bbox objects are rendered to determine if the associated game object is visible to the camera.
    /// </summary>
    [Serializable]
    public class Settings
    {
        public RenderPassEvent DrawRenderPassEvent;
        public RenderPassEvent UpdateVisRenderPassEvent;
        public Shader HardwareOcclusionShader;
        public bool Debug;
    }
    
    public class UpdateVisibilityPass : ScriptableRenderPass
    {
        private Material m_Material;
        private Vector4[] m_Elements;
        private Vector4[] m_Cache;
        private GraphicsBuffer m_Writer;
        private List<List<MeshRenderer>> m_MeshRenderers;

        public UpdateVisibilityPass(GraphicsBuffer writer, Vector4[] elements, Vector4[] cache, List<List<MeshRenderer>> meshRenderers, Material material)
        {
            m_Material = material;
            m_Elements = elements;
            m_Cache = cache;
            m_Writer = writer;
            m_MeshRenderers = meshRenderers;
        }

        private class UpdateVisPassData
        {
            public Material material;
            public List<List<MeshRenderer>> meshRenderers;
            public Vector4[] elements;
            public Vector4[] cache;
            public BufferHandle writer;
            public uint delay;
        }

        static void Execute(UpdateVisPassData passData, ComputeGraphContext context)
        {
            ComputeCommandBuffer cmd = context.cmd;

            if (Time.frameCount % passData.delay != 0) return;

            bool state = ArrayState(passData.elements, passData.cache);
            if (!state)
            {
                for (int i = 0; i < passData.meshRenderers.Count; i++)
                {
                    for (int j = 0; j < passData.meshRenderers[i].Count; j++)
                    {
                        passData.meshRenderers[i][j].enabled = (Vector4.Dot(passData.elements[i], passData.elements[i]) > 0.0f);
                    }
                }
                ArrayCopy(passData.elements, passData.cache);
            }
            Array.Clear(passData.elements, 0, passData.elements.Length);

            cmd.SetBufferData(passData.writer, passData.elements);
        }

        public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
        {
            if (m_Elements == null || m_Cache == null) return;

            m_Writer.GetData(m_Elements);

            string elem = "";
            foreach (var e in m_Elements)
            {
                elem += $"{e.x} {e.y} {e.z} {e.w}, ";
            }
            Debug.Log($"UpdateVisPass m_Elements {elem}");

            BufferHandle writerHandle = renderGraph.ImportBuffer(m_Writer);

            UniversalResourceData resourceData = frameData.Get<UniversalResourceData>();
            TextureHandle cameraColorTextureHandle = resourceData.activeColorTexture;

            using (IComputeRenderGraphBuilder builder = renderGraph.AddComputePass<UpdateVisPassData>("BufferPass", out var passData))
            {
                // Set pass data
                passData.material = m_Material;
                passData.meshRenderers = m_MeshRenderers;
                passData.elements = m_Elements;
                passData.cache = m_Cache;
                passData.delay = 1;
                passData.writer = writerHandle;

                builder.AllowPassCulling(false);

                builder.UseBuffer(writerHandle, AccessFlags.Write);

                builder.SetRenderFunc((UpdateVisPassData passData, ComputeGraphContext graphContext) => Execute(passData, graphContext));
            }
        }
    }
    
    public class DrawCubesRenderPass : ScriptableRenderPass
    {
        private const string PASS_NAME = nameof(DrawCubesRenderPass);

        private Material m_Material;
        private GraphicsBuffer m_Reader;
        private GraphicsBuffer m_Writer;

        private Vector4[] m_Elements;
        private Vector4[] m_Cache;
        private List<List<MeshRenderer>> m_MeshRenderers;
        private List<Vector4> m_Vertices;
        public bool Dynamic = false;
        public uint Delay = 1;
        private bool _Debug;

        private class DrawPassData
        {
            public Material material;
            public TextureHandle textureHandle;
            public BufferHandle writerHandle;
            public int vertexCount;
        }

        public DrawCubesRenderPass(RenderPassEvent renderPassEvent)
        {
            this.profilingSampler = new ProfilingSampler(nameof(DrawCubesRenderPass));
            this.renderPassEvent = renderPassEvent;
        }

        public void SetDebug(bool debug)
        {
            this._Debug = debug;
        }

        public void SetOcclusionMaterial(Material occlusionMaterial)
        {
            this.m_Material = occlusionMaterial;
        }

        public void Init(List<List<MeshRenderer>> meshRenderers, GraphicsBuffer reader, GraphicsBuffer writer, Vector4[] elements, Vector4[] cache, List<Vector4> vertices)
        {
            m_MeshRenderers = meshRenderers;
            m_Reader = reader;
            m_Writer = writer;
            m_Elements = elements;
            m_Cache = cache;
            m_Vertices = vertices;
        }

        // Record render graph
        public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
        {
            if (this.m_Material == null || m_Vertices == null || m_Vertices.Count == 0)
                return;

            // Get frame resources and camera data
            UniversalResourceData resourceData = frameData.Get<UniversalResourceData>();
            UniversalCameraData cameraData = frameData.Get<UniversalCameraData>();
            TextureHandle cameraColorTextureHandle = resourceData.activeColorTexture;

            RenderTextureDescriptor desc = cameraData.cameraTargetDescriptor;
            desc.msaaSamples = 1;
            desc.depthBufferBits = 0;

            // Draw directly to camera color handle
            using (IRasterRenderGraphBuilder builder = renderGraph.AddRasterRenderPass<DrawPassData>(PASS_NAME, out var passData, this.profilingSampler))
            {
                var colorDesc = new TextureDesc(Vector2.one) {
                    clearBuffer = false,
                    enableRandomWrite = true,
                };
                // Bind color buffer to render pass (this is where the Load/Store action is implicitly set)
                passData.textureHandle = cameraColorTextureHandle;

                // Set target for write. SetRenderAttachment is equivalent of SetRenderTarget
                builder.SetRenderAttachment(cameraColorTextureHandle, 0, AccessFlags.Write);

                // Disable pass culling - Passes are culled if no other passes access the write target
                builder.AllowPassCulling(false);

                // Resources/References for pass execution
                passData.material = m_Material;
                passData.vertexCount = m_Vertices.Count;

                // Set render function
                builder.SetRenderFunc((DrawPassData passData, RasterGraphContext graphContext) => ExecuteDrawCubesPass(passData, graphContext));
            }
        }

        private static void ExecuteDrawCubesPass(DrawPassData passData, RasterGraphContext graphContext)
        {
            RasterCommandBuffer cmd = graphContext.cmd;

            // Set the material pass and draw the bounding boxes
            passData.material.SetPass(0);
            cmd.DrawProcedural(Matrix4x4.identity, passData.material, 0, MeshTopology.Triangles, passData.vertexCount);
        }
    }


    [SerializeField] private Settings settings = new Settings();

    private DrawCubesRenderPass drawCubesRenderPass;
    private UpdateVisibilityPass updateVisibilityPass;

    public override void Create()
    {
        if (this.settings.HardwareOcclusionShader == null) 
            return;

        this.drawCubesRenderPass = new DrawCubesRenderPass(
            this.settings.DrawRenderPassEvent
        );
#if UNITY_EDITOR
        if (!UnityEditor.EditorApplication.isPlaying)
            return;
#endif
        Targets = GetTargets();

        if (Targets == null) return;

        Material = new Material(this.settings.HardwareOcclusionShader);
        MeshRenderers = GetTargetMeshRenderers();
        Vertices = GetTargetVertices();
        Elements = new Vector4[Targets.Length];
        Cache = new Vector4[Targets.Length];

        Writer = new GraphicsBuffer(Target.Structured, Targets.Length, sizeof(float) * 4);
        Reader = new GraphicsBuffer(Target.Structured, Vertices.Count, sizeof(float) * 4);
        Reader.SetData(Vertices.ToArray());

        Material.SetBuffer("_Reader", Reader);
        Material.SetBuffer("_Writer", Writer);
        Material.SetInt("_Debug", Convert.ToInt32(settings.Debug));

        Writer.SetData(Elements);

        Graphics.ClearRandomWriteTargets();
        Graphics.SetRandomWriteTarget(1, Writer, false);

        this.drawCubesRenderPass.SetOcclusionMaterial(Material);
        this.drawCubesRenderPass.SetDebug(this.settings.Debug);
        this.drawCubesRenderPass.Init(MeshRenderers, Reader, Writer, Elements, Cache, Vertices);

        // Update visibility pass
        this.updateVisibilityPass = new UpdateVisibilityPass(Writer, Elements, Cache, MeshRenderers, Material);
        this.updateVisibilityPass.renderPassEvent = this.settings.UpdateVisRenderPassEvent;
    }

    // Here you can inject one or multiple render passes in the renderer.
    // This method is called when setting up the renderer once per-camera.
    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (this.settings.HardwareOcclusionShader == null) return;
        if (this.drawCubesRenderPass == null || this.updateVisibilityPass == null) return;
        renderer.EnqueuePass(this.updateVisibilityPass);
        renderer.EnqueuePass(this.drawCubesRenderPass);
        
    }

    private static bool ArrayState(Vector4[] a, Vector4[] b)
    {
        for (int i = 0; i < a.Length; i++)
        {
            bool x = Vector4.Dot(a[i], a[i]) > 0.0f;
            bool y = Vector4.Dot(b[i], b[i]) > 0.0f;
            if (x != y) return false;
        }
        return true;
    }

    private static void ArrayCopy(Vector4[] source, Vector4[] destination)
    {
        for (int i = 0; i < source.Length; i++) destination[i] = source[i];
    }

    /// <summary>
    /// Confirmed this is working, a cube is created around the object. 
    /// Disable delete immediately to see it working...
    /// </summary>
    /// <param name="parent"></param>
    /// <param name="index"></param>
    /// <returns></returns>
    static Vector4[] GenerateCell(GameObject parent, int index)
    {
        BoxCollider bc = parent.AddComponent<BoxCollider>();
        Bounds bounds = new Bounds(Vector3.zero, Vector3.zero);
        bool hasBounds = false;
        MeshRenderer[] renderers = parent.GetComponentsInChildren<MeshRenderer>();
        for (int i = 0; i < renderers.Length; i++)
        {
            if (hasBounds)
            {
                bounds.Encapsulate(renderers[i].bounds);
            }
            else
            {
                bounds = renderers[i].bounds;
                hasBounds = true;
            }
        }
        if (hasBounds)
        {
            bc.center = bounds.center - parent.transform.position;
            bc.size = bounds.size;
        }
        else
        {
            bc.size = bc.center = Vector3.zero;
            bc.size = Vector3.zero;
        }
        bc.size = Vector3.Scale(bc.size, new Vector3(1.01f, 1.01f, 1.01f));
        GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
        cube.transform.position = parent.transform.position + bc.center;
        cube.transform.localScale = bc.size;
        Mesh mesh = cube.GetComponent<MeshFilter>().sharedMesh;
        Vector4[] vertices = new Vector4[mesh.triangles.Length];
        for (int i = 0; i < vertices.Length; i++)
        {
            Vector3 p = cube.transform.TransformPoint(mesh.vertices[mesh.triangles[i]]);
            vertices[i] = new Vector4(p.x, p.y, p.z, index);
        }

        Destroy(bc);
        Destroy(cube);

        return vertices;
    }

    private static List<List<MeshRenderer>> GetTargetMeshRenderers()
    {
        List<List<MeshRenderer>> meshRenderers = new List<List<MeshRenderer>>();

        var cullingManager = FindFirstObjectByType<GPUOcclusionCullingManager>();

        if (cullingManager == null || cullingManager.Targets == null) return null;

        for (int i = 0; i < cullingManager.Targets.Length; i++)
        {
            if (cullingManager.Targets[i] == null) continue;
            meshRenderers.Add(cullingManager.Targets[i].GetComponentsInChildren<MeshRenderer>().ToList());

            Vector4[] aabb = GenerateCell(cullingManager.Targets[i], i);
        }

        return meshRenderers;
    }

    private static List<Vector4> GetTargetVertices()
    {
        var cullingManager = FindFirstObjectByType<GPUOcclusionCullingManager>();
        if (cullingManager == null) return null;

        List<Vector4> vertices = new List<Vector4>();
        for (int i = 0; i < cullingManager.Targets.Length; i++)
        {
            Vector4[] aabb = GenerateCell(cullingManager.Targets[i], i);
            vertices.AddRange(aabb);
        }
        return vertices;
    }

    private static GameObject[] GetTargets()
    {
        List<List<MeshRenderer>> meshRenderers = new List<List<MeshRenderer>>();

        var cullingManager = FindFirstObjectByType<GPUOcclusionCullingManager>();
        if (cullingManager == null) return null;
        return cullingManager.Targets;
    }

    
}