1
\$\begingroup\$

My game's protagonist is after a specific resource on the map, its concentration stored in a field on the CPU-side, and I would like to provide a subtle hint as to where it is so that the player can "sniff" it out.

To clarify what I mean by "field", I mean it in the physics sense—like an electromagnetic field. For each coordinate, there's a value associated with it. It would be relatively easy to make it a render pass.

Post processing would be my go-to if this were a video or animation. My method would be to use a render pass to detect the concentration at that distance (or the accumulated concentration, like a mist layer), and then use it to, say, increase color saturation or slightly shift the hue. Areas with this resource should visually scream at the player, no matter what's present there.

Unity's post processing effects are wonderful, and this might be mostly taken care of for me. I figured I could write that camera pass to a render texture, and then use the render texture's value to determine how much the saturation is changed for that pixel; but I'm not seeing any way to tie a control, on an override in a post-processing Volume Profile, to the value of a pixel in a render texture. It's probably doable, but I haven't figured it out yet.

Is there a straightforward, or at least reliable, way to do this? That is, can I tie a post processing field to the value of a render texture?

\$\endgroup\$
5
  • \$\begingroup\$ This seems like just a matter of writing your own saturation-boosting image effect shader, that reads a second texture you provide as an intensity mask. To get help doing this, be sure to mention which render pipeline you're using. \$\endgroup\$
    – DMGregory
    Commented Jul 24, 2022 at 11:26
  • \$\begingroup\$ @DMGregory I've gone with URP. This does sound like a good idea, but I've never written a post-proc effect for Unity. Do you have a link you recommend? \$\endgroup\$ Commented Jul 24, 2022 at 15:24
  • \$\begingroup\$ To be even more clear, I'm using Unity 2021 LTS for this; it looks like there's been some evolution on building custom post-processing effects recently. \$\endgroup\$ Commented Jul 24, 2022 at 15:39
  • 1
    \$\begingroup\$ I wrote up an answer showing how to create one a couple years ago - some things may have changed since then though. \$\endgroup\$
    – DMGregory
    Commented Jul 24, 2022 at 16:11
  • \$\begingroup\$ @DMGregory Thanks, your comments have been a real help. It's been a weekend, but I've just about got this working. I'll try and update the question with an outline of how I figured it out, when I'm finished. \$\endgroup\$ Commented Jul 24, 2022 at 23:49

1 Answer 1

1
\$\begingroup\$

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.

final product, pink bloom field layered over green background by render texture

\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .