It took a while, but I finally have something going. This is all for URP, HDRP is likely similar.
To begin, you'll need a shader (you can make it with shader graph if it helps you) which will base its operations on the actual render (call it _MainTex as a reference, so Unity knows to use it) and a control texture, which should be a render texture. Have it do whatever you would like to _MainTex, we can use it in a Blit
operation in custom post-processing.
Duplicate your camera, set it to be a child (with an identity transform) of your main camera. Turn off shadows and post-processing, and give it a reasonable name. Create a render texture, and assign that render texture to the camera's Output Texture under Output. Lastly, create a render layer specifically for this effect. Set the new camera's culling mask to only render your new layer, and usually, you'll want to adjust your main camera so that it doesn't render this layer.
Everything the camera renders will go on that render texture. Make sure it's set to an appropriate bit depth and number of channels for what you're doing. We'll also need our render texture's aspect to match our display aspect, which can be wonky and unpredictable. This should technically run any time the resolution changes, but I haven't gotten around to that yet and just adjust it at game start, with this component:
public class AdjustResolution : MonoBehaviour
{
void Start()
{
Camera camera = GetComponent<Camera>();
RenderTexture currentRT = camera.targetTexture;
currentRT.width = Screen.width;
currentRT.height = Screen.height;
}
}
We now have a render texture which follows the main camera and mimics its transform, which also has an adjusted width/height to match the main camera. (Technically what matters most is the aspect, but that's gotten very hard to predict these days!) It renders only the custom layer.
After building your shader and creating a material, plug the render texture into the material's control parameter. Now we need to implement some basic custom post processing.
Grab your new camera's renderer, I believe it defaults to URP-HighFidelity-Renderer. At the bottom, you will have "Add Renderer Feature". Create a renderer feature script under Create>Rendering>URP Renderer Feature. This is a class that bears more explanation than I can give it here, but it basically controls how the post processing feature should be done. Give it an appropriate name. For completeness, I'm going to drop the source of my current feature here, though I may not be done with it yet.
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class BlitRenderPassFeature : ScriptableRendererFeature
{
class CustomRenderPass : ScriptableRenderPass
{
public RenderTargetIdentifier source;
private Material material;
private RenderTargetHandle tempRenderTargetHandler;
public CustomRenderPass(Material material)
{
this.material = material;
tempRenderTargetHandler.Init("_TemporaryColorTexture");
}
// This method is called before executing the render pass.
// It can be used to configure render targets and their clear state. Also to create temporary render target textures.
// When empty this render pass will render to the active camera render target.
// You should never call CommandBuffer.SetRenderTarget. Instead call <c>ConfigureTarget</c> and <c>ConfigureClear</c>.
// The render pipeline will ensure target setup and clearing happens in a performant manner.
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
}
// Here you can implement the rendering logic.
// Use <c>ScriptableRenderContext</c> to issue drawing commands or execute command buffers
// https://docs.unity3d.com/ScriptReference/Rendering.ScriptableRenderContext.html
// You don't have to call ScriptableRenderContext.submit, the render pipeline will call it at specific points in the pipeline.
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
CommandBuffer cmdBuffer = CommandBufferPool.Get("BlitRenderPass(Custom)");
cmdBuffer.GetTemporaryRT(tempRenderTargetHandler.id, renderingData.cameraData.cameraTargetDescriptor);
Blit(cmdBuffer, source, tempRenderTargetHandler.Identifier(), material);
Blit(cmdBuffer, tempRenderTargetHandler.Identifier(), source);
context.ExecuteCommandBuffer(cmdBuffer);
CommandBufferPool.Release(cmdBuffer);
}
// Cleanup any allocated resources that were created during the execution of this render pass.
public override void OnCameraCleanup(CommandBuffer cmd)
{
}
}
[System.Serializable]
public class Settings {
public Material material = null;
}
public Settings settings = new Settings();
CustomRenderPass m_ScriptablePass;
/// <inheritdoc/>
public override void Create()
{
m_ScriptablePass = new CustomRenderPass(settings.material);
// Configures where the render pass should be injected.
m_ScriptablePass.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
}
// 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)
{
m_ScriptablePass.source = renderer.cameraColorTarget;
renderer.EnqueuePass(m_ScriptablePass);
}
}
As you can see there's a lot going on in there, but it's relatively straightforward in its activity. By attaching a material to our render pass, we can call the Blit
function with it to apply it to our full render. The four-parameter Blit
call allows for a material to be passed in. Note that Unity has issues with rendering a source pass onto itself, so you'll need to create a render target handle for it, blit to that, and then blit back.
Add your post-processing feature to your renderer via the "Add Renderer Feature"; if all goes well, it should show up in the menu. Remember to attach your material, with your custom shader, to it.
Lastly, we need to ensure that our new camera is only rendering what it's supposed to. (You will likely need duplicate objects for this; I have a separate terrain, in my game, starring a bunch of bees, that simply encodes bloom data so that they can see it at a distance, even when details aren't rendering.) Set all objects that contain data for this camera to be the same layer as its culling mask, so they're only rendered to the render texture.
I generally do this in code by using a script to change the non-shared texture, and am working with a terrain; this is unlikely to be true for all users, but to provide an example, I set my terrain to use a basic URP unlit texture and did this.
Material nectarMaterial = Instantiate(nectarTerrain.materialTemplate);
Texture2D nectarTexture = new Texture2D(
data.detailResolution,
data.detailResolution,
TextureFormat.R16, -1, true);
for(int i = 0; i < nectarTexture.width; i++)
for(int j = 0; j < nectarTexture.height; j++)
nectarTexture.SetPixel(i, j, fertility[i, j] * Color.white);
nectarTexture.Apply();
Debug.Log("Default texture: " + (nectarTexture == null ? "NULL" : nectarTexture));
nectarMaterial.SetTexture("_BaseMap", nectarTexture);
nectarTerrain.materialTemplate = nectarMaterial;
Whatever you put on this is going to update the render texture, which will control the post-processing effect. We have a render texture that is adjusted to match our display, rendered exactly from the current camera; so UV0 should be the input for both textures in your shader if you want them to line up accurately.
You now have a pixel-accurate post-processing effect.