Pixelate Filter in URP using Compute Shaders

URP Pixelate Filter Example

This article will use the pixelate image filter from the previous post but in URP, the Universal Render Pipeline. We’ll set up a ScriptableRendererFeature and ScriptableRenderPass to run the compute shader at the end of the rendering pipeline. I recommend you read the previous pixelate filter post before this one to understand the full context.

Scriptable Renderer Features

In case you forgot, Scriptable Renderer Features are the mechanism through which we can extend the Universal Rendering Pipeline. They pair with Scriptable Render Passes. A Scriptable Render Pass is a small, potentially reusable sequence of rendering instructions, and a Scriptable Renderer Feature is a collection of one or more passes.

Let’s breakdown what we need our feature to do:

  1. Access the camera texture after rendering.
  2. Blit (copy) the camera texture to another render texture.
  3. Send that render texture to our pixelate compute shader.
  4. Run the compute shader.
  5. Copy the render texture back to camera texture, where it will ultimately find its way back to the screen.

There’s no reason to break this up into multiple passes, so it’ll be a feature that runs a single pass. In theory, we could generalize the render pass to run any image filter compute shader, but frankly, the added complexity isn’t worth it.

Let’s start with the meat, that is, the execute function of the render pass.

Pixelate Render Pass

As mentioned, we’ll start with the Execute method and work our way backwards. The execute method does what I outlined in the previous breakdown. The rest of the code we’ll write is either set up or boilerplate. To send commands to the GPU, we need a CommandBuffer. To use a command buffer, we declare a sequence of commands to execute and, well, execute them. So, first, we ask the CommandBufferPool for the next available CommandBuffer. Then, we Blit the camera texture to a temporary render texture, set all the compute shader parameters, dispatch the compute shader, and finally Blit the temporary render texture back to the camera target texture. Finally, at the end, we’ll execute the command buffer and release all our CommandBuffer back into the pool.

There’s one last caveat. This render pass will attempt to execute on every camera, including the scene view camera. We’ll prevent it from running in the scene view by exiting early.

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
    if (renderingData.cameraData.isSceneViewCamera)
        return;

    CommandBuffer cmd = CommandBufferPool.Get();
    var mainKernel = _filterComputeShader.FindKernel(_kernelName);
    _filterComputeShader.GetKernelThreadGroupSizes(mainKernel, out uint xGroupSize, out uint yGroupSize, out _);
    cmd.Blit(renderingData.cameraData.targetTexture, _renderTargetIdentifier);
    cmd.SetComputeTextureParam(_filterComputeShader, mainKernel, _renderTargetId, _renderTargetIdentifier);
    cmd.SetComputeIntParam(_filterComputeShader, "_BlockSize", _blockSize);
    cmd.SetComputeIntParam(_filterComputeShader, "_ResultWidth", renderTextureWidth);
    cmd.SetComputeIntParam(_filterComputeShader, "_ResultHeight", renderTextureHeight);
    cmd.DispatchCompute(_filterComputeShader, mainKernel,
        Mathf.CeilToInt(_renderTextureWidth / (float) _blockSize / xGroupSize),
        Mathf.CeilToInt(_renderTextureHeight / (float) _blockSize / yGroupSize),
        1);
    cmd.Blit(_renderTargetIdentifier, renderingData.cameraData.renderer.cameraColorTarget);

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

So that’s our render pass. Now we’ll build up all the boilerplate around it. First, let’s get our render texture. Scriptable Render Passes have OnCameraSetup and OnCameraCleanup callbacks to configure and release any resources you need. Each of these callbacks receives a CommandBuffer of their own, which the rendering pipeline executes automatically at the appropriate time. We’ll use it to ask for a temporary render texture and then later release it. To request a temporary render texture, we need to provide a descriptor. The descriptor contains information like the width, height, pixel format, etc. Since we want a texture that’s identical to the camera’s target texture, we reuse that descriptor with enableRandomWrite enabled because otherwise, the compute shader can’t write to it. Additionally, we’ll cache the width and height of the render texture to pass it to the compute shader later.

public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
    var cameraTargetDescriptor = renderingData.cameraData.cameraTargetDescriptor;
    cameraTargetDescriptor.enableRandomWrite = true;
    cmd.GetTemporaryRT(_renderTargetId, cameraTargetDescriptor);
    _renderTargetIdentifier = new RenderTargetIdentifier(_renderTargetId);

    _renderTextureWidth = cameraTargetDescriptor.width;
    _renderTextureHeight = cameraTargetDescriptor.height;
}

public override void OnCameraCleanup(CommandBuffer cmd)
{
    cmd.ReleaseTemporaryRT(_renderTargetId);
}

To compile, we’re still missing a handful of variables. These leftover variables are the kind that would be nice to adjust through the Inspector. These include the compute shader, the pixel block size, the kernel name, and the name of the render texture. So, let’s write a constructor that takes these arguments, and we’ll pass them from the Scriptable Renderer Feature. That’s because the Scriptable Renderer Feature can have serialized fields that are assignable via the Inspector. Here’s the constructor.

public PixelateRenderPass(ComputeShader filterComputeShader, string kernelName, int blockSize, int renderTargetId)
{
    _filterComputeShader = filterComputeShader;
    _kernelName = kernelName;
    _blockSize = blockSize;
    _renderTargetId = renderTargetId;
}

Next, let’s implement the Scriptable Renderer Feature.

Pixelate Scriptable Renderer Feature

Scriptable Renderer Features have two important methods: Create and AddRenderPasses. Create is the constructor equivalent, or Start if you’re used to MonoBehaviours. AddRenderPasses is where we enqueue the render passes. Remember when I said Scriptable Renderer Features contained one or more passes? Well, AddRenderPasses is where you add the collection of passes to the render pipeline. Let’s start with the serialized fields.

public class ComputeShaderPixelateImageFilter : ScriptableRendererFeature
{
	PixelateRenderPass _scriptablePass;
	bool _initialized;

	public ComputeShader FilterComputeShader;
	public string KernelName = "Pixelate";
	[Range(2, 40)] public int BlockSize = 3;
}

These fields are modifiable in the Inspector, like any standard MonoBehaviour. They’re also the same variables we said we’d pass into the render pass constructor.

public override void Create()
{
    if (FilterComputeShader == null)
    {
        _initialized = false;
        return;
    }
    
    int renderTargetId = Shader.PropertyToID("_ImageFilterResult");
    _scriptablePass = new PixelateRenderPass(FilterComputeShader, KernelName, BlockSize, renderTargetId);
    _scriptablePass.renderPassEvent = RenderPassEvent.AfterRendering;
    _initialized = true;
}

Here’s the create function. Aside from constructing the render pass, there are two items of note here. First, what’s the renderTargetId? It’s the name of the render texture on the GPU, converted into an Id. By the way, we’ll use the same name for the result texture in the Pixelate compute shader. Second, what’s renderPassEvent? It’s is the point within the render pipeline where our render pass will run. Since we want to run the image filter at the end of the pipeline, we set it to AfterRendering.

Finally, let’s move on to the AddRenderPasses method.

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
    if (_initialized)
    {
        renderer.EnqueuePass(_scriptablePass);
    }
}

It’s remarkably straightforward; we enqueue the pass. So that’s all the code. I’ll drop the entire file here for posterity, but I recommend you check it out on Github if you’re interested in exploring the project. The Github link is at the bottom of this article.

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class ComputeShaderPixelateImageFilter : ScriptableRendererFeature
{
    #region Renderer Pass
    class PixelateRenderPass : ScriptableRenderPass
    {
        ComputeShader _filterComputeShader;
        string _kernelName;
        int _renderTargetId;

        RenderTargetIdentifier _renderTargetIdentifier;
        int _renderTextureWidth;
        int _renderTextureHeight;

        int _blockSize;

        public PixelateRenderPass(ComputeShader filterComputeShader, string kernelName, int blockSize, int renderTargetId)
        {
            _filterComputeShader = filterComputeShader;
            _kernelName = kernelName;
            _blockSize = blockSize;
            _renderTargetId = renderTargetId;
        }

        public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
        {
            var cameraTargetDescriptor = renderingData.cameraData.cameraTargetDescriptor;
            cameraTargetDescriptor.enableRandomWrite = true;
            cmd.GetTemporaryRT(_renderTargetId, cameraTargetDescriptor);
            _renderTargetIdentifier = new RenderTargetIdentifier(_renderTargetId);

            _renderTextureWidth = cameraTargetDescriptor.width;
            _renderTextureHeight = cameraTargetDescriptor.height;
        }

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            if (renderingData.cameraData.isSceneViewCamera)
                return;

            CommandBuffer cmd = CommandBufferPool.Get();
            var mainKernel = _filterComputeShader.FindKernel(_kernelName);
            _filterComputeShader.GetKernelThreadGroupSizes(mainKernel, out uint xGroupSize, out uint yGroupSize, out _);
            cmd.Blit(renderingData.cameraData.targetTexture, _renderTargetIdentifier);
            cmd.SetComputeTextureParam(_filterComputeShader, mainKernel, _renderTargetId, _renderTargetIdentifier);
            cmd.SetComputeIntParam(_filterComputeShader, "_BlockSize", _blockSize);
            cmd.SetComputeIntParam(_filterComputeShader, "_ResultWidth", _renderTextureWidth);
            cmd.SetComputeIntParam(_filterComputeShader, "_ResultHeight", _renderTextureHeight);
            cmd.DispatchCompute(_filterComputeShader, mainKernel,
                Mathf.CeilToInt(_renderTextureWidth / (float) _blockSize / xGroupSize),
                Mathf.CeilToInt(_renderTextureHeight / (float) _blockSize / yGroupSize),
                1);
            cmd.Blit(_renderTargetIdentifier, renderingData.cameraData.renderer.cameraColorTarget);

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

        public override void OnCameraCleanup(CommandBuffer cmd)
        {
            cmd.ReleaseTemporaryRT(_renderTargetId);
        }
    }

    #endregion

    #region Renderer Feature

    PixelateRenderPass _scriptablePass;
    bool _initialized;
    
    public ComputeShader FilterComputeShader;
    public string KernelName = "Pixelate";
    [Range(2, 40)] public int BlockSize = 3;

    public override void Create()
    {
        if (FilterComputeShader == null)
        {
            _initialized = false;
            return;
        }
        
        int renderTargetId = Shader.PropertyToID("_ImageFilterResult");
        _scriptablePass = new PixelateRenderPass(FilterComputeShader, KernelName, BlockSize, renderTargetId);
        _scriptablePass.renderPassEvent = RenderPassEvent.AfterRendering;
        _initialized = true;
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (_initialized)
        {
            renderer.EnqueuePass(_scriptablePass);
        }
    }

    #endregion
}

The pixelate compute shader is nearly unchanged from the previous post. The only difference is I changed the name of the result render texture to _ImageFilterResult to be more descriptive. At this point, you can add this Renderer Feature to your URP renderer, set the inspector values, and you’re good to go.

So now we have a template for fullscreen Compute Shader effects in URP. That means we can write Compute Shader image effects and reuse them in more projects. Compute Shaders, unlike regular Shaders, are rendering pipeline agnostic, so this is a win.

Explore the project here on GitHub. If my work is helpful to you, join my mailing list, and I’ll email you whenever a new post is released.

Leave A Comment