Rendering

Tutorial

Render Graph - Polishing Rain Atmosphere Effect

Jun 17, 2025

:image-description:

Read time: 11 min

:center-100:

___

In this article, I improved the rain atmosphere effect I built in the previous article. This effect works by postprocessing a GBuffer in deferred rendering path, just before the light caluclation, where I increased a smoothness and decreased the GI and albedo.

There were two main issues I had to tackle:

  1. High smoothness resulted in flickering when using the bloom post process.

  2. All parameters were hardcoded, so I needed to make them accessible through the material's properties.

:center-px:

:image-description:

Bloom flickers a lot during a small camera movements.


___

Bloom flickering

High smoothness in the GBuffer can create very bright points on the screen in certain light conditions. It makes the bloom postprocess shimmer and flicker. Bloom flicker can be implemented by modifying the bloom effect itself or modifying its inputs. I decided to limit the smoothness value in the GBuffer to fix the issue. In the previous article I used hardware blending for GBuffer modifications, which has limited capabilities and doesn't allow me to limit the maximum value of the target texture. I can fix that by copying the normal-smoothness buffer and sampling the copy to implement a custom blending in the fragment shader. I will keep the smoothness in the reasonable range in the shader that applies the effect.

Current render graph looks like this (Apply Rain Atmosphere was implemented in the previous article):

:center-px:

:image-description:

Current render graph looks like this. Apply Rain Atmosphere was implemented in the previous article.

I can add a custom node between DrawGBuffer and Apply Rain Atmosphere that will create a copy of the GBuffer[2] texture and share it with other passes. I will call this pass Initialize Rain Resources. Data shared between passes will be called RainResourceData. Then Apply Rain Atmosphere will be able access the copy of GBuffer[2] and use it in the shader with custom blending.

:center-px:

:image-description:

This is how I want to modify the nodes in the render graph.

My plan:

  1. Implement Initialize Rain Resources pass.

    1. Create a RainResourceData class to store resources shared between passes.

    2. Copy the GBuffer[2] contents into a custom texture using a custom shader.

  2. Access the copied GBuffer[2] in Apply Rain Atmosphere pass.

  3. Sample the copied GBuffer[2] in the fragment shader to implement custom blending and avoid bloom shimmer.


___

Implement resource initialization pass

My goal is to create a new texture in the Render Graph and copy the content of GBuffer[2] into this texture.

I created an InitializeRainResourcesPass and modified the RainAtmosphereFeature to execute it before the main pass. I've added a material I want to use with a custom shader to copy the GBuffer[2] contents.

public class RainAtmosphereFeature : ScriptableRendererFeature
{
    public Material copyGBufferMaterial; // Added a material that will be used to copy GBuffer[2] contents.
    public Material applyRainMaterial;

    private bool HasValidResources => applyRainMaterial != null && copyGBufferMaterial != null; // Update validation

    private InitializeRainResourcesPass initializeResourcesPass; // Added this pass to the render feature
    private ApplyRainAtmospherePass applyRainAtmospherePass;

    public override void Create()
    {
        applyRainAtmospherePass = new ApplyRainAtmospherePass();
        initializeResourcesPass = new InitializeRainResourcesPass();
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        bool isGameOrSceneCamera = renderingData.cameraData.cameraType == CameraType.Game || renderingData.cameraData.cameraType == CameraType.SceneView;
        if (isGameOrSceneCamera && HasValidResources)
        {
            initializeResourcesPass.material = copyGBufferMaterial; // Updated the material
            applyRainAtmospherePass.material = applyRainMaterial;

            renderer.EnqueuePass(initializeResourcesPass); // Enqueued as first pass
            renderer.EnqueuePass(applyRainAtmospherePass);
        }
    }
}

// This pass will initialize the resources and copy the GBuffer.
public class InitializeRainResourcesPass : ScriptableRenderPass
{
    public Material material { get; internal set; }

    public InitializeRainResourcesPass()
    {
        this.renderPassEvent = RenderPassEvent.AfterRenderingGbuffer;
    }

    public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
    {
        // TODO: Initialize shared resources
        // TODO: Copy GBuffer[2] contents
    }
}

Shared resources

I started by creating a class that store the shared resources. When using Render Graph API, shared resources must inherit ContextItem. They must release manually allocated resources in the `Reset()` function. In my case, I only need to store a TextureHandle - the copy of GBuffer[2].

// Resources shared between passes need to inherit from ContextItem
public class RainResourceData : ContextItem
{
	// Handle for Gbuffer[2] copy.
    public TextureHandle normalSmoothnessTexture { get; internal set; }

	// And reset all resources in this method.
    public override void Reset()
    {
	    // Use TextureHandle.nullHandle to reset texture handles.
        normalSmoothnessTexture = TextureHandle.nullHandle;
    }
}

I created the shared resources object in the Initialize Rain Resources Pass using the frameData.GetOrCreate<>() API. After using this method, those resources can be accessed in other passes.

public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
	// Initialize the shared resources
	RainResourceData rainResources = frameData.GetOrCreate<RainResourceData>();
	...
Accessing the GBuffer

I checked if GBuffer is initialized to avoid executing this pass in a forward rendering path.

public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
	RainResourceData rainResources = frameData.GetOrCreate<RainResourceData>();

	// Access URP GBuffer
	var urpResources = frameData.Get<UniversalResourceData>();
	var gBuffer = urpResources.gBuffer;

	// Check if the GBuffer exists.
	if (gBuffer == null || gBuffer.Length == 0 || gBuffer[0].Equals(TextureHandle.nullHandle))
		return;

Allocate GBuffer copy texture

I allocated the target texture by using the renderGraph.CreateTexture(textureDescriptior) method. This method does not allocate the texture immediately, but it allows the Render Graph to manage the lifetime of the texture. Render Graph will ensure that the texture exists when the render function is executed.

...
if (gBuffer == null || gBuffer.Length == 0 || gBuffer[0].Equals(TextureHandle.nullHandle))
		return;

// Allocate the texture handle in the render graph.
rainResources.normalSmoothnessTexture = renderGraph.CreateTexture(gBuffer[2].GetDescriptor(renderGraph));

Creating render pass

Then, I created a pass in the render graph. I used a raster render pass where I specified:

  • Render attachment - our freshly allocated texture handle

  • Dependencies - gBuffer[2] texture

  • Render function

	...
	rainResources.normalSmoothnessTexture = renderGraph.CreateTexture(gBuffer[2].GetDescriptor(renderGraph));
	
	// Create a render pass that will create a copy of GBuffer[2]
	using (var builder = renderGraph.AddRasterRenderPass(GetType().Name, out PassData passData))
	{
		// Specify dependencies
		builder.SetRenderAttachment(rainResources.normalSmoothnessTexture, 0);
		builder.UseTexture(gBuffer[2]);

		// Save data for render function
		passData.gBuffer_2 = gBuffer[2];
		passData.material = material;

		// Set the render function
		builder.SetRenderFunc((PassData passData, RasterGraphContext context) => RenderFunction(passData, context));
	}
}

private static void RenderFunction(PassData passData, RasterGraphContext context)
{
	// TODO: Implement the rendering here
}

internal class PassData
{
	internal Material material; // Material used to copy the GBuffer
	internal TextureHandle gBuffer_2; // Original GBuffer[2] handle
}

Render function

Then, I implemented a render function. Raster Graph Context contains object pooling for PropertyBlocks - I used that to set the shader properties. _GBuffer2 property was set to the original texture. The shader used in the material will use that property.

private static void RenderFunction(PassData passData, RasterGraphContext context)
{
	// Get property block and set shader parameters.
	MaterialPropertyBlock propertyBlock = context.renderGraphPool.GetTempMaterialPropertyBlock();
	propertyBlock.SetTexture(Uniforms._GBuffer2, passData.gBuffer_2);

	// Execute procedural fullscreen draw.
	context.cmd.DrawProcedural(Matrix4x4.identity, passData.material, 0, MeshTopology.Triangles, 6, 1, propertyBlock);
}

// Utility class that I like to use to store shader properties.
public static class Uniforms
{
    internal static readonly int _GBuffer2 = Shader.PropertyToID(nameof(_GBuffer2));
}

Writing shader

The RenderGraph node is ready, and it's time to implement the shader that copies the contents of the GBuffer. The shader should read the contents of the GBuffer texture and then output those in the fragment shader. I used the fullscreen draw template to achieve that.

Shader "Hidden/CopyGBuffer2"
{
    SubShader
    {
        Pass
        {
            // Disable triangle culling and depth testing
            Cull Off
            ZTest Off
            ZWrite Off
            
            HLSLPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            // Fulscreen quad in clip space
            static const float4 verticesCS[] = 
            {
                float4(-1.0, -1.0, 0.0, 1.0),
                float4(-1.0, 1.0, 0.0, 1.0),
                float4(1.0, 1.0, 0.0, 1.0),
                float4(-1.0, -1.0, 0.0, 1.0),
                float4(1.0, 1.0, 0.0, 1.0),
                float4(1.0, -1.0, 0.0, 1.0)
            };
			
            void vert (uint vertexID : SV_VertexID, out float4 OUT_PositionCS : SV_Position)
            {
	            // Output a fullscreen quad.
                float4 positionCS = verticesCS[vertexID];
            }

            void frag (out float4 OUT_Color : SV_Target0) 
            {
				// TODO.
            }

            ENDHLSL
        }
    }
}

Vertex Shader

I modified the vertex shader to output a UV calculated from the clip space. Clip space range is from -1.0 to 1.0, and UV is from 0.0 to 1.0, so I needed to remap those values.

On some platforms, UV starts at the top of the screen. In this case, Y-axis needs to be flipped.

// Add UV interpolator output to the vertex shader.
void vert (uint vertexID : SV_VertexID, out float2 OUT_uv : TEXCOORD0, out float4 OUT_PositionCS : SV_Position)
{
	float4 positionCS = verticesCS[vertexID];

	// Calculate screen UV from the clip space
	float2 uv = positionCS.xy * 0.5 + 0.5;
	#if UNITY_UV_STARTS_AT_TOP
		uv.y = 1.0 - uv.y;
	#endif

	// Set position on the screen and UV
	OUT_PositionCS = verticesCS[vertexID];
	OUT_uv = uv;
}

Fragment Shader

I declared Texture2D _GBuffer2 - the texture I assigned in the render function. The UV was used to sample the GBuffer and output its contents into our render attachment. I used an object-oriented HLSL syntax to sample the texture.

SamplerState pointClampSampler; // Point clamp sampler we will use to sample the GBuffer
Texture2D _GBuffer2; // GBuffer texture

void frag (in float2 IN_uv : TEXCOORD0, out float4 OUT_Color : SV_Target0) 
{
	// Use screen UV to sample and output the GBuffer contents
	OUT_Color = _GBuffer2.SampleLevel(pointClampSampler, IN_uv, 0.0f);
}

Now I could create a material with this shader and assign it to the renderer feature.


___

Access the copied GBuffer in ApplyRainAtmospherePass

After assigning the material to the render feature, there is no `InitializeRainResourcesPass` in the Frame Debugger and Render Graph Viewer. It is not executed at all.

:center-px:

:image-description:

My pass is not executed!

Render Graph detects that I don't use the created texture in other passes, so it culls the render node created in the `InitializeRainResourcesPass`. To fix that, I need to declare the usage of the texture in the `ApplyRainAtmospherePass`. I modified the `ApplyRainAtmospherePass` by accessing shared resources with the `frameData.Get<>()` API and declared that the usage of the texture.

public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
	...
	// Get the rain resources
	RainResourceData rainResources = frameData.Get<RainResourceData>();

	using (var builder = renderGraph.AddRasterRenderPass(GetType().Name, out PassData passData))
	{
		// Declare the texture usage
		builder.UseTexture(rainResources.normalSmoothnessTexture);
		...
	}
}

And after this change, Frame Debugger and Render Graph Viewer correctly display the InitializeRainResourcesPass.

:center-px:

:image-description:

My pass is executed! Happy Johny noises!

Right now, when I know that Render Graph properly executes the initialization pass, I can use the copy of the GBuffer in the `ApplyRainAtmospherePass`. I forwarded the texture handle to the passData, used in our render function.

...
builder.UseTexture(rainResources.normalSmoothnessTexture);

passData.gBuffer2 = rainResources.normalSmoothnessTexture; // Pass the texture to the render function
passData.material = material;
...
internal class PassData
{
	internal Material material;
	internal TextureHandle gBuffer2; // Added the texture handle to the data used in the render function
}

I used a property block from the render graph pool to set the texture in the shader.

private static void RenderFunction(PassData passData, RasterGraphContext context)
{
	// Get property block from the pool and assign the texture.
	MaterialPropertyBlock propertyBlock = context.renderGraphPool.GetTempMaterialPropertyBlock();
	propertyBlock.SetTexture(Uniforms._GBuffer2, passData.gBuffer2);

	context.cmd.DrawProcedural(Matrix4x4.identity, passData.material, 0, MeshTopology.Triangles, 6, 1, propertyBlock);
}

All the data is set right now, so it's time to adjust the shader.


___

Adjusting the rain atmosphere shader

My goal was to reduce the shimmering in the bloom postprocess. I could do that by implementing a custom blending for the smoothness stored in the alpha channel of the second GBuffer target.

The plan was:

  1. Add texture resources to the shader.

  2. Calculate screen UV to sample the GBuffer copy.

  3. Disable hardware blending and implement a custom one.

I declared the texture in the shader.

SamplerState pointClampSampler; // Point clamp sampler we will use to sample the GBuffer
Texture2D _GBuffer2; // GBuffer texture

Then, I needed a UV to sample the texture. I copied the vertex shader from the CopyGBuffer.shader.

void vert (uint vertexID : SV_VertexID, out float2 OUT_uv : TEXCOORD0, out float4 OUT_PositionCS : SV_Position)
{
	float4 positionCS = verticesCS[vertexID];

	float2 uv = positionCS.xy * 0.5 + 0.5;
	#if UNITY_UV_STARTS_AT_TOP
		uv.y = 1.0 - uv.y;
	#endif

	OUT_PositionCS = verticesCS[vertexID];
	OUT_uv = uv;
}

It is a good practice to turn off the hardware blending when switching to the software blending - it can save some GPU resources.

Blend 2 Off // Disable hardware blending for the 2nd target.

The last step was to sample the GBuffer contents in the fragment shader and write a custom blending. I've found that additive blending for smoothness and clamping the result to a max of 0.86 works best in my case, so I hardcoded those values. This is a final fragment shader code:

void frag 
(
	in float2 IN_uv : TEXCOORD0, // Getting UV from the vertex shader
	out float4 OUT_GBuffer0 : SV_Target0,
	out float4 OUT_GBuffer2 : SV_Target2,
	out float4 OUT_GBuffer3 : SV_Target3
) 
{
	// Access GBuffer[2] value
	float4 rawGBuffer2 = _GBuffer2.SampleLevel(pointClampSampler, IN_uv, 0.0);

	// Increase smoothness and clamp the value
	rawGBuffer2.a = min(0.86, rawGBuffer2.a + 0.7);

	OUT_GBuffer0 = float4(0.5, 0.5, 0.5, 1.0);
	OUT_GBuffer2 = rawGBuffer2; // Write it back into GBuffer
	OUT_GBuffer3 = float4(0.5, 0.5, 0.5, 1.0);
}

I could test the results. As you can see, the shimmer was mostly reduced!

:center-px:

:center-px:

:image-description:

Comparison of before and after. Bloom flickering is successfuly reduced. I left a little bit of a flicker, because, as a player, I prefer having a little bit of flicker and aliasing rather then blurry image.

You probably noticed that I needed to write much more code just to copy a texture. In the previous API I would use a `CommandBuffer.Blit` function to achieve that. Here I needed to explicitly create a separate pass. There are some utility methods to *blit* textures in Render Graph, but they still require to create a separate pass, custom shader, and manage the dependencies between passes - no way around that.


___

Shader Parameters

Because I hardcoded all values in the shader, the effect can't be adjusted outside the code. I will add parameters to the material to make the visuals easier to adjust:

  • Albedo multiplier

  • Ambient light multiplier

  • Smoothness

  • Max Smoothness

  • Effect strength - controls the strength of the effect. 0 - no effect is applied, 1 - full effect is applied.

Let's start by adding shader properties. I added this at the beginning of the shader.

Shader "Hidden/ApplyRainAtmosphere"
{
    Properties
    {
        _AlbedoMultiplier("Albedo multiplier", Range(0.0, 1.0)) = 0.5
        _AmbientLightMultiplier("Ambient multiplier", Range(0.0, 1.0)) = 0.5
        _SmoothnessAdd("Added smoothness", Range(0.0, 1.0)) = 0.7
        _SmoothnessMax("Maximum smoothness", Range(0.0, 1.0)) = 0.86
        _EffectStrength("Overall effect strength", Range(0.0, 1.0)) = 1.0
    }
    ...

:center-px:

:image-description:

This section added sliders to the material that renders the effect.

Then, I included all those properties in the shader code, just above the fragment shader.

...
float _AlbedoMultiplier;
float _AmbientLightMultiplier;
float _SmoothnessAdd;
float _SmoothnessMax;
float _EffectStrength;

void frag 
(
	in float2 IN_uv : TEXCOORD0,
	...

Next, I replaced all parameters except _EffectStrength.

...
rawGBuffer2.a = min(_SmoothnessMax, rawGBuffer2.a + _SmoothnessAdd);

OUT_GBuffer0 = float4((float3)_AlbedoMultiplier, 1.0);
OUT_GBuffer2 = rawGBuffer2; // Write it back into GBuffer

OUT_GBuffer3 = float4((float3)_AmbientLightMultiplier, 1.0);
...

To implement the `_EffectStrength` parameter, blending in the shader needs to do nothing when the value is 0. I implemented that using a Lerp function that interpolate all the values to the neutral ones. If the effect strength is set to 0, we must output 1 for the targets with multiply-blend and leave the smoothness as is.

...
// Lerp from original smoothness value to the custom one
rawGBuffer2.a = lerp(rawGBuffer2.a, min(_SmoothnessMax, rawGBuffer2.a + _SmoothnessAdd), _EffectStrength);

// Lerp from 1 to custom values in multiply-blended targets
OUT_GBuffer0 = float4((float3)lerp(1.0, _AlbedoMultiplier, _EffectStrength), 1.0);
OUT_GBuffer2 = rawGBuffer2;
OUT_GBuffer3 = float4((float3)lerp(1.0, _AmbientLightMultiplier, _EffectStrength), 1.0);
...

All parameters work nicely.

:center-px:

:image-description:

Effect strength parameter can smoothly interpolate the amosphere of the scene.


___

Performance

My rain atmosphere effect is ready. It takes only 0.44ms to render in 1440p on RTX 3060, increasing frame time from 7.77ms to 8.21ms (5.7% increase) - Measured with NVidia Nsight Graphics. The shader is mostly screen pipe bound - which means that most of the time is spent blending the GBuffer colors using the hardware units - I can't get much faster than that!

:center-px:

:image-description:

Overwiew of GPU hardware units throughput while rendering our feature.

Sign up for my newsletter

Get the latest updates on my posts, plus tips on rendering and optimization strategies in every email.

© 2025 Jan Mróz | Procedural Pixels.

Made by Natalia Bracikowska

© 2025 Jan Mróz | Procedural Pixels.

Made by Natalia Bracikowska

© 2025 Jan Mróz | Procedural Pixels.

Made by Natalia Bracikowska

Sign up for my newsletter

Get the latest updates on my posts, plus tips on rendering and optimization strategies in every email.

Sign up for my newsletter

Get the latest updates on my posts, plus tips on rendering and optimization strategies in every email.