Fullscreen Raymarching for Unity’s Post Processing V2 stack (PostFX V2)

image

Introduction

This article assumes you know the basics of raymarching, and have implemented it in the existing stack. Most literature on raymarching in Unity makes use of OnRenderImage to render a raymarching shader & material to a newly generated quad.

Here are a couple excellent examples of existing literature using the older stack:

  1. Peer Play
  2. Alan Zucconi
  3. Flafla2

This article would not have been possible without individuals like the aforementioned who take time to put out educational content to empower the community around them.

I also want to give a special shoutout to the following individuals who have shared their findings that which inadvertantly helped me port raymarching to the Post Processing V2 stack:

  1. Karmakat with their PostFX V2 port of screen space scattering
  2. Alex Vanden Abeele with his blog post on PostFX V2 volumetric fog.

Because we are using the Post Processing V2 stack, OnRenderImage is no longer called. Our solution will be to create the following two files:

1. Post Process Effect
A c# script that will allow us to interact with our Raymarching shader through the inspector in the Post Process Volume component.
image

2. Fragment Shader
An hlsl file that will do the calculations for raymarching. We will be rendering a simple signed distance field sphere.

Implementation

Step 1: Post Process Effect
Begin by creating a file named RaymarchPostProcess.cs. The file name is important – it will not serialize correctly if the name doesn’t match up with content in the file (more info found here - Writing Custom Effects).

Here are the contents of the file:

using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.PostProcessing;

[Serializable]
public sealed class ShaderParameter : ParameterOverride<Shader> { }

[Serializable]
[PostProcess(typeof(RaymarchPostProcessRenderer), PostProcessEvent.AfterStack, "Custom/RaymarchPostProcess")]
public sealed class RaymarchPostProcess : PostProcessEffectSettings
{
    public IntParameter maxIterations = new IntParameter { value = 64 };
    public FloatParameter maxDistance = new FloatParameter { value = 100f };
    public FloatParameter minDistance = new FloatParameter { value = 0.01f };

    public DepthTextureMode GetCameraFlags()
    {
        return DepthTextureMode.Depth; //| DepthTextureMode.DepthNormals;
    }
}

public sealed class RaymarchPostProcessRenderer : PostProcessEffectRenderer<RaymarchPostProcess>
{
    public override void Render(PostProcessRenderContext context)
    {
        Camera _cam = context.camera;

        var sheet = context.propertySheets.Get(Shader.Find("Raymarch/RaymarchHDRP"));
        sheet.properties.SetMatrix("_CamFrustum", FrustumCorners(_cam));
        sheet.properties.SetMatrix("_CamToWorld", _cam.cameraToWorldMatrix);
        sheet.properties.SetVector("_CamWorldSpace", _cam.transform.position);
        sheet.properties.SetInt("_MaxIterations", settings.maxIterations);
        sheet.properties.SetFloat("_MaxDistance", settings.maxDistance);
        sheet.properties.SetFloat("_MinDistance", settings.minDistance);

        context.command.BlitFullscreenTriangle(context.source, context.destination, sheet, 0);
    }

    private Matrix4x4 FrustumCorners(Camera cam)
    {
        Transform camtr = cam.transform;

        Vector3[] frustumCorners = new Vector3[4];
        cam.CalculateFrustumCorners(new Rect(0, 0, 1, 1),
        cam.farClipPlane, cam.stereoActiveEye, frustumCorners);

        Vector3 bottomLeft = camtr.TransformVector(frustumCorners[1]);
        Vector3 topLeft = camtr.TransformVector(frustumCorners[0]);
        Vector3 bottomRight = camtr.TransformVector(frustumCorners[2]);

        Matrix4x4 frustumVectorsArray = Matrix4x4.identity;
        frustumVectorsArray.SetRow(0, bottomLeft);
        frustumVectorsArray.SetRow(1, bottomLeft + (bottomRight - bottomLeft) * 2);
        frustumVectorsArray.SetRow(2, bottomLeft + (topLeft - bottomLeft) * 2);

        return frustumVectorsArray;
    }
}

Now you can add the raymarching post process effect to your camera:
image

Step 2: Fragment Shader
Create a file named RaymarchHDRP.shader.

Here are the contents of the file:

Shader "Raymarch/RaymarchHDRP"
{

    SubShader
    {
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            HLSLPROGRAM

            #pragma target 3.5

            #pragma vertex vert
            #pragma fragment frag

            //#include "Packages/com.unity.postprocessing/PostProcessing/Shaders/StdLib.hlsl"
            //#include "HLSLSupport.cginc"
            #include "UnityCG.cginc"

            uniform sampler2D _MainTex;
            uniform sampler2D_float _CameraDepthTexture, sampler_CameraDepthTexture;
            half4 _MainTex_ST;
            uniform float4 _CamWorldSpace;
            uniform float4x4 _CamFrustum,  _CamToWorld;
            uniform int _MaxIterations;
            uniform float _MaxDistance;
            uniform float _MinDistance;
            float4 _Tint;

            uniform float4 _MainTex_TexelSize;

            struct AttributesDefault
            {
                float3 vertex : POSITION;
                half2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
             float4 vertex : SV_POSITION;
             float2 texcoord : TEXCOORD0;
             float2 texcoordStereo : TEXCOORD1;
             float4 ray : TEXCOORD2;
            };

            // Vertex manipulation
            float2 TransformTriangleVertexToUV(float2 vertex)
            {
                float2 uv = (vertex + 1.0) * 0.5;
                return uv;
            }

            v2f vert(AttributesDefault v  )
            {
                v2f o;
                v.vertex.z = 0.1;
                o.vertex = float4(v.vertex.xy, 0.0, 1.0);
                o.texcoord = TransformTriangleVertexToUV(v.vertex.xy);
         
                o.texcoordStereo = TransformStereoScreenSpaceTex(o.texcoord, 1.0);
 
                int index = (o.texcoord.x / 2) + o.texcoord.y;
                o.ray = _CamFrustum[index];
 
                return o;
            }

            float sdSphere(float3 position, float3 origin, float radius)
            {
                return distance(position, origin) - radius;
            }


            fixed4 raymarching(float3 rayOrigin, float3 rayDirection) {
                fixed4 result = float4(1, 1, 1, 1);
                float t = 0.01; // Distance Traveled from ray origin (ro) along the ray direction (rd)

                for (int i = 0; i < _MaxIterations; i++)
                {
                    if (t > _MaxDistance)
                    {
                        result = float4(rayDirection, 1); // color backround from ray direction for debugging
                        break;
                    }

                    float3 p = rayOrigin + rayDirection * t;    // This is our current position
                    float d = sdSphere(p, float3(1, 0, 0), 2); // should be a sphere at (0, 0, 0) with a radius of 1
                    if (d <= _MinDistance) // We have hit something
                    {
                        // shading
                        result = float4(1, 1, 1, 1); // yellow sphere should be drawn at (0, 0, 0)
                        break;
                    }

                    t += d;
                }

                return result;
            }

            float4 frag(v2f i) : SV_Target
            {
                float3 rayOrigin = _CamWorldSpace;
                float3 rayDirection = normalize(i.ray);
                return raymarching(rayOrigin, rayDirection);
            }

            ENDHLSL
        }
    }
}

There will now be a sphere rendered at the origin of your scene (0, 0, 0). You should see the following if everything worked:
image.

Bonus: Lighting + Screen Space Object Culling

RaymarchPostProcess.cs

using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.PostProcessing;

[Serializable]
public sealed class ShaderParameter : ParameterOverride<Shader> { }

[Serializable]
[PostProcess(typeof(RaymarchPostProcessRenderer), PostProcessEvent.BeforeStack, "Custom/RaymarchPostProcess")]
public sealed class RaymarchPostProcess : PostProcessEffectSettings
{
    public IntParameter maxIterations = new IntParameter { value = 64 };
    public FloatParameter maxDistance = new FloatParameter { value = 100f };
    public FloatParameter minDistance = new FloatParameter { value = 0.01f };

    public DepthTextureMode GetCameraFlags()
    {
        return DepthTextureMode.Depth; // DepthTextureMode.DepthNormals;
    }
}

public sealed class RaymarchPostProcessRenderer : PostProcessEffectRenderer<RaymarchPostProcess>
{
    Transform directionalLight;

    public override void Init()
    {
        base.Init();

        GameObject light = GameObject.FindGameObjectWithTag("MainLight");

        if (light)
            directionalLight = light.transform;
    }

    public override void Render(PostProcessRenderContext context)
    {
        Camera _cam = context.camera;

        var sheet = context.propertySheets.Get(Shader.Find("Raymarch/RaymarchHDRP"));
        sheet.properties.SetMatrix("_CamFrustum", FrustumCorners(_cam));
        sheet.properties.SetMatrix("_CamToWorld", _cam.cameraToWorldMatrix);
        sheet.properties.SetVector("_CamWorldSpace", _cam.transform.position);
        sheet.properties.SetInt("_MaxIterations", settings.maxIterations);
        sheet.properties.SetFloat("_MaxDistance", settings.maxDistance);
        sheet.properties.SetFloat("_MinDistance", settings.minDistance);

        if (directionalLight)
        {
            Vector3 position = directionalLight.forward;
            sheet.properties.SetVector("_LightDir", new Vector4(position.x, position.y, position.z, 1));
        }

        context.command.BlitFullscreenTriangle(context.source, context.destination, sheet, 0);
    }

    private Matrix4x4 FrustumCorners(Camera cam)
    {
        Transform camtr = cam.transform;

        Vector3[] frustumCorners = new Vector3[4];
        cam.CalculateFrustumCorners(new Rect(0, 0, 1, 1),
        cam.farClipPlane, cam.stereoActiveEye, frustumCorners);

        Vector3 bottomLeft = camtr.TransformVector(frustumCorners[1]);
        Vector3 topLeft = camtr.TransformVector(frustumCorners[0]);
        Vector3 bottomRight = camtr.TransformVector(frustumCorners[2]);

        Matrix4x4 frustumVectorsArray = Matrix4x4.identity;
        frustumVectorsArray.SetRow(0, bottomLeft);
        frustumVectorsArray.SetRow(1, bottomLeft + (bottomRight - bottomLeft) * 2);
        frustumVectorsArray.SetRow(2, bottomLeft + (topLeft - bottomLeft) * 2);

        return frustumVectorsArray;
    }
}

RaymarchHDRP.shader

Shader "Raymarch/RaymarchHDRP"
{

    SubShader
    {
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            HLSLPROGRAM

            #pragma target 3.5

            #pragma vertex vert
            #pragma fragment frag

            //#include "Packages/com.unity.postprocessing/PostProcessing/Shaders/StdLib.hlsl"
            //#include "HLSLSupport.cginc"
            #include "UnityCG.cginc"

            //TEXTURE2D_SAMPLER2D(_MainTex, sampler_MainTex);
            uniform sampler2D _MainTex;
            uniform sampler2D_float _CameraDepthTexture, sampler_CameraDepthTexture;
            half4 _MainTex_ST;
            uniform float4 _CamWorldSpace;
            uniform float4x4 _CamFrustum,  _CamToWorld;
            uniform int _MaxIterations;
            uniform float _MaxDistance;
            uniform float _MinDistance;
            uniform float3 _LightDir;
            float4 _Tint;

            uniform float4 _MainTex_TexelSize;

            struct AttributesDefault
            {
                float3 vertex : POSITION;
                half2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
             float4 vertex : SV_POSITION;
             float2 texcoord : TEXCOORD0;
             float2 texcoordStereo : TEXCOORD1;
             float4 ray : TEXCOORD2;
            };

            // Vertex manipulation
            float2 TransformTriangleVertexToUV(float2 vertex)
            {
                float2 uv = (vertex + 1.0) * 0.5;
                return uv;
            }

            v2f vert(AttributesDefault v  )
            {
                v2f o;
                v.vertex.z = 0.1;
                o.vertex = float4(v.vertex.xy, 0.0, 1.0);
                o.texcoord = TransformTriangleVertexToUV(v.vertex.xy);
                o.texcoordStereo = TransformStereoScreenSpaceTex(o.texcoord, 1.0);
 
                int index = (o.texcoord.x / 2) + o.texcoord.y;
                o.ray = _CamFrustum[index];
 
                return o;
            }

            float sdSphere(float3 position, float3 origin, float radius)
            {
                return distance(position, origin) - radius;
            }

            float distanceField(float3 p) {
                return sdSphere(p, float3(1, 0, 0), 2);
            }

            float3 getNormal(float3 p)
            {
                const float2 offset = float2(0.001, 0.0);
                
                float3 n = float3(
                    distanceField(p + offset.xyy) - distanceField(p - offset.xyy),
                    distanceField(p + offset.yxy) - distanceField(p - offset.yxy),
                    distanceField(p + offset.yyx) - distanceField(p - offset.yyx));

                return normalize(n);
            }


            fixed4 raymarching(float3 rayOrigin, float3 rayDirection, float depth) {
                fixed4 result = float4(1, 1, 1, 1);
                float t = 0.01; // Distance Traveled from ray origin (ro) along the ray direction (rd)

                for (int i = 0; i < _MaxIterations; i++)
                {
                    if (t > _MaxDistance || t >= depth)
                    {
                        result = float4(rayDirection, 0); // color backround from ray direction for debugging
                        break;
                    }

                    float3 p = rayOrigin + rayDirection * t;    // This is our current position
                    float d = distanceField(p); // should be a sphere at (0, 0, 0) with a radius of 1
                    if (d <= _MinDistance) // We have hit something
                    {
                        // shading
                        float3 n = getNormal(p);
                        float light = dot(-_LightDir, n);
                        result = float4(fixed3(1, 1, 1) * light, 1); // yellow sphere should be drawn at (0, 0, 0)
                        break;
                    }

                    t += d;
                }

                return result;
            }

            float4 frag(v2f i) : SV_Target
            {
                i.texcoord.y = 1 - i.texcoord.y;
                float4 col = tex2D(_MainTex, i.texcoord);
                float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, UnityStereoTransformScreenSpaceTex(i.texcoord));
                depth = Linear01Depth(depth);
                depth *= length(i.ray);

                float3 rayOrigin = _CamWorldSpace;
                float3 rayDirection = normalize(i.ray);
                float4 result = raymarching(rayOrigin, rayDirection, depth);

                return fixed4(col * (1.0 - result.w) + result.xyz * result.w, 1.0);
            }

            ENDHLSL
        }
    }
}

Raymarched objects will now be lit by a light that has the tag MainLight. Raymarched objects will also occlude/cull with non-raymarched mesh.

Here we see a raymarched sphere that is occludes a 3d primitive cube and is lit by a directional light.
image

Thanks for reading. Feel free to contact me on Twitter if you have any questions.