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?