Strange camera depth buffer behavior with custom render pass

Hi there,

I’ve encountered a quite strange situation that I don’t seem to be able to make any sense of. Here it is:

  • ScriptableRendererFeature with a single render pass, set to RenderPassEvent.BeforeRenderingTransparents.
  • During the pass’ Execute method, I call commandBuffer.Blit(myRT, renderer.cameraColorTarget, myMat);
  • myMat uses a shader that reads/writes from/to the depth buffer.

I would expect the Blit to perform depth testing against the camera’s depth buffer, however no depth buffer seems to be attached to the cameraColorTarget.

However, if I replace the Blit() with what I believe to be its manual equivalent (setting the color and depth render targets myself) things work as expected:

// Draw a quad manually, as cmd.Blit does not have a depth buffer attached?
cmd.SetRenderTarget(renderer.cameraColorTarget, renderer.cameraDepth);
cmd.SetViewProjectionMatrices(Matrix4x4.identity, Matrix4x4.identity);
cmd.DrawMesh(UnityEngine.Rendering.Universal.RenderingUtils.fullscreenMesh, Matrix4x4.identity, myMat);
cmd.SetViewProjectionMatrices(camera.worldToCameraMatrix, camera.projectionMatrix);

I’d assume cameraColorTarget to have a depth buffer attached (using the Frame Debugger shows this is the case during normal opaque rendering), however I can’t figure out why it no longer has one during my render pass, or what the exact role of renderer.cameraDepth is.

Is cameraColorTarget supposed to have a depth buffer at all times? Does not seem like it should, but if it does, then why do we have a separate cameraDepth property?

Can anyone shed some light on this? Thanks!

To make things more interesting, line #3 of the above snippet:

cmd.SetRenderTarget(renderer.cameraColorTarget, renderer.cameraDepth);

Only renders correctly in game view, but in scene view it ignores the depth buffer. Also, if I turn on msaa, it no longer works correctly in either view.

Now, replacing it with this:

cmd.SetRenderTarget(renderer.cameraColorTarget);

Renders correctly in scene view, but not in game view. :hushed:

Minimal example that reproduces this. You should see the mesh passed to the renderer feature rendered in the scene: it will ignore the depth buffer in the scene view, but render correctly in the game view, assuming no msaa.

public class TestRendererFeature : ScriptableRendererFeature
    {
        [System.Serializable]
        public class Settings
        {
            public Mesh mesh;
            public Material mat;
        }

        public Settings settings = new Settings();
        private TestPass m_TestPass;

        public override void Create()
        {
           m_TestPass = new TestPass();
            m_TestPass.renderPassEvent = RenderPassEvent.BeforeRenderingTransparents;
        }

        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            m_TestPass.Setup(renderer.cameraColorTarget,renderer.cameraDepth, settings);
            renderer.EnqueuePass(m_TestPass);
        }

    }

public class TestPass : ScriptableRenderPass
    {
        const string k_RenderPassTag = "TestPass";
        private ProfilingSampler m_Thickness_Profile = new ProfilingSampler(k_RenderPassTag);

        private TestRendererFeature.Settings settings;

        RenderTargetIdentifier target;
        RenderTargetIdentifier depth;

        public void Setup(RenderTargetIdentifier colorSource, RenderTargetIdentifier depthSource, TestRendererFeature.Settings settings)
        {
            target = colorSource;
            depth = depthSource;
            this.settings = settings;
        }

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            CommandBuffer cmd = CommandBufferPool.Get(k_RenderPassTag);
            using (new ProfilingScope(cmd, m_Thickness_Profile))
            {
                cmd.SetRenderTarget(target, depth);
                cmd.DrawMesh(settings.mesh,Matrix4x4.identity,settings.mat);
            }

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

Further investigation (by reading ScriptableRenderPass’s sources): turns out that some command buffer methods should not be directly called from within a RenderPass, or you risk breaking the renderer’s internal state.

Take for instance commandBuffer.Blit(): RenderPasses have an equivalent Blit(), that passes to the ScriptableRenderer whatever color/depth buffers and clear flags/colors you had specified in the pass’s Configure() method, using renderPass.ConfigureTarget() and renderPass.ConfigureClear. Here’s the implementation, taken from ScriptableRenderPass.cs:

public void Blit(CommandBuffer cmd, RenderTargetIdentifier source, RenderTargetIdentifier destination, Material material = null, int passIndex = 0)
{
     ScriptableRenderer.SetRenderTarget(cmd, destination, BuiltinRenderTextureType.CameraTarget, clearFlag, clearColor);
     cmd.Blit(source, destination, material, passIndex);
}

As you can see, it changes the renderer’s state, then calls Blit() on the command buffer. So it’s basically a call to commandBuffer.Blit(), after they set the renderer’s render target.

Turns out, this is not equivalent to:

cmd.SetRenderTarget(BuiltinRenderTextureType.CameraTarget, clearFlag, clearColor);
cmd.Blit(source, destination, material, passIndex);

As evidenced by taking a look at ScriptableRenderer.SetRenderTarget()'s implementation.

So if you call commandBuffer.SetRenderTarget() or commandBuffer.Clear() explicitly, you are messing with the renderer’s state and the result is pretty much unpredictable. Since my original post, I’ve got a variety of weird results, depending on the specific render pass event /camera combination used.

In my specific case, I could call ConfigureTarget and ConfigureClear in the pass’ Configure method, and then use commandBuffer.DrawMesh()…except that the actual render target/clear is not performed unless you call render pass.Blit(). But wait! since there’s no renderPass.DrawMesh(), and no way to manually bind/clear a target at a specific point in the Execute() method, it is just not possible to draw a mesh to an arbitrary target using ScriptableRenderPasses.

Worse still, I got the impression that command buffers were completely safe to use inside RenderPasses, thanks to Unity’s own sample project: https://blogs.unity3d.com/es/2020/02/10/achieve-beautiful-scalable-and-performant-graphics-with-the-universal-render-pipeline/. There, they use commandBuffer.DrawMesh() to perform caustics rendering. Good thing that they didn’t need to render to an offscreen buffer, since that would have gone boom.

Unless I’m missing something important, this is a bug in URP, and it feels like awful API/architectural design to me:

  • Why place an abstraction layer over the scriptable renderer that mixes calls to the renderer and calls to command buffer methods?
  • Why having wrappers over some command buffer methods, but not others? I get that most rendering systems usually have global state, but allowing to modify it from two different places is just asking for trouble.
  • Wouldn’t it have been better to re-implement/wrap all commandBuffer functionality in the ScriptableRenderPass class, and forbid the use of command buffers in URP? That would give the renderer total control over command execution, and would prevent users from breaking the renderer by calling stuff they’re not supposed to call.

Can anyone confirm if this is all correct, or I’m just braindead at this point? :face_with_spiral_eyes:

6 Likes

So what did you do in the end?

Desist. I couldn’t implement this properly using URP :(.

@arkano22 I must admit that I couldn’t understand the technical details in your post in full but I had a similar depth buffer issue caused by a 3rd party post processing effect in my game and the trick was to change cmd.Blit with ScriptableRenderPass’ Blit function. You saved my day, thank you!

In ScriptableRendererFeature.AddRenderPasses, I’m adding the ScriptableRenderPass only when renderingData.cameraData.cameraType is CameraType.Game. Possibly why I didn’t encounter any other issues after the fix.

1 Like