Euri Gilberto Herasme Cuevas

Volumetric Light Shaft

This is a recreation of the awesome stained glass light shafts @Cyanilux made for @HarryAlisavakis #TechnicallyAChallenge (theme: stained glass ). It was made with Unity - URP using a render feature.

Prev

Next

Tech Used

Unity

HLSL

URP

Scriptable Render Feature

C#

Showcase

How it works

Projected Texture

Step 1

Create WS position form depth texture.
PosWS = CamPosWS + ViewDirWS * EyeDepth;
There is a code snippet below that shows how to calculate the View Direction and Eye Depth.

Step 1.5

Sample shadow texture to see if that point would be occluded by a shadow.

Step 2

Cast a ray using “PosWS” as the ray origin and -SunDir as the ray direction.

Step 3

Check if the ray intersects the plane.

Step 4

If it intersected the plane, check if the ray hit where our texture is mapped.
This can be done by creating a vector that sits on the plane:
HitPosPlane = HitPosWs - PlaneCenter;

Then using a tangent and bitangent to our plane
float textureU = dot (HitPosPlane, tan) / (tanLength * tanLength);
float textureV = dot (HitPosPlane, btan) / (btanLength * btanLength);
float2 planeUV = float2 (textureU + 0.5, textureV + 0.5);
float2 absPlaneUV = abs (planeUV);
bool planeIntersected = (absPlaneUV.x <= (0.5)) && (absPlaneUV.y <= (0.5));

On unity you can calculate all the required vectors using:
var tMatrix = gameObject.transform.localToWorldMatrix
Vector3 tangent = tMatrix.MultiplyVector(Vector3.right);
Vector3 normal = tMatrix.MultiplyVector(Vector3.forward);
Vector3 bitangent = tMatrix.MultiplyVector(Vector3.up);

This asumes that your plane faces the local Z axis of the object and that the Quad used to render the stained glass texture is has side lengths of 1 and the aspect ratio is corrected by scaling the object.

sampler2D _CameraDepthTexture;

struct StainedGlass{
    float3 planeNormal;
    float3 planeTangent;
    float3 planeBitangent;
    float3 planePosition;
    int textureIndex;
};

struct PlaneInterData{
    float3 intersectionPosition;
    float2 textureUV;
    bool intersection;
};

PlaneInterData PlaneIntersectionData(StainedGlass stainedGlass, float3 rayOrigin, float3 rayDir){
    PlaneInterData intersectionData;

    intersectionData.intersection = false;
    float denom = dot(stainedGlass.planeNormal, rayDir); 
    if (abs(denom) > 0.0000001) {//it needs to be absolute value because the light vector could be oposite to the plane normal
        float3 p0l0 = stainedGlass.planePosition - rayOrigin; 
        float t = dot(p0l0, stainedGlass.planeNormal);
        t = t / denom;
        if(t >= 0){
            intersectionData.intersectionPosition = rayOrigin + rayDir * t;
            float3 planeVector = intersectionData.intersectionPosition - stainedGlass.planePosition;

            float tangentLength = length(stainedGlass.planeTangent);
            float bitangentLength = length(stainedGlass.planeBitangent);

            float textureU = dot(planeVector, stainedGlass.planeTangent) / (tangentLength * tangentLength);
            float textureV = dot(planeVector, stainedGlass.planeBitangent) / (bitangentLength * bitangentLength);

            float2 planeUV = float2(textureU, textureV);

            float2 absPlaneUV = abs(planeUV);
            intersectionData.intersection = (absPlaneUV.x <= (0.5)) && (absPlaneUV.y <= (0.5));
            planeUV += float2(0.5,0.5);
            intersectionData.textureUV = planeUV;
        }
        else{
            intersectionData.intersection = false;
        }
    }

    return intersectionData;
}

struct v2f
{
    float2 uv : TEXCOORD0;
    float4 vertex : SV_POSITION;
    
    float3 viewVector : TEXCOORD1; //required to pass the view vector to the pixel shader
};

v2f vert (appdata v)
{
    v2f o;
    //vertex shader things

    //The following calculation from the view vector was taken from Sebastian Cloud implementation https://github.com/SebLague/Clouds
    float3 viewVector = mul(unity_CameraInvProjection, float4(v.uv * 2 - 1, 0, -1));
    o.viewVector = mul(unity_CameraToWorld, float4(viewVector,0));

    return o;
}

float linearToEyeDepth(float z , float3 viewVector){ //From unity
    float divisor = _ZBufferParams.z * z + _ZBufferParams.w;
    float depth = 1.0/(divisor);
    depth *= length(viewVector);
    return depth;
}

float4 frag (v2f i) : SV_Target
{   
    float depth = tex2D(_CameraDepthTexture, i.uv).r;
    float2 UV = i.uv;

    float eyeDepth = linearToEyeDepth(depth, i.viewVector.xyz);
    
    //Continue shader
}

Light Shaft

The first step to render the light shaft is to know if there are any light shafts to render and where to start looking for them. This step is important because ray marching is expensive, so the less we do it the better. The render regions are calculated by doing a ray to AABB intersection test. The box is going to be aligned to the axis generated from the light direction, and the stained glass' quad tangent vector and bitangent vector.

float3 boxY = cross (lightVec, tangent);
float3 boxX = cross (bitangent, lightVec);
float3 boxZ = lightVec;

Remember not to normalize the plane vectors to make sure they retain the scale information.
Information on how to get the tangent and normal vectors in the previous section.

The previous image shows what happens to a ray after it is fired.

H1

The hit position in world space was at a distance from the camera greater than the scene depth distance, so the ray march is not started.

H2

The ray is not being occluded by anything, so the ray march is started, some of the light information is going to be attenuated by the shadow and the ray march stops at the limit of the box.

H3

The ray is not being occluded by anything, so the ray march is started, and the ray march stops early because the scene depth is reached.

H3.1

Same as H3 but stops early because the stained glass depth is reached (which in my implementation checks for every stained glass).

Render Step view

Another important thing I had to consider with this system is how the stained glasses were distributed. There is a limit to how many steps the system can take per light shaft (30 on the image), but there is no limit to how many light shafts a single ray can pass through. (The color in the image represents the amount of steps taken on that pixel, from 0 - 1 = 0 - 40 steps). On the “Base scene” image the windows on the sides have a stained glass object per window, on the “Optimized scene” image the windows are grouped in groups of 3, and it is apparent because of the reduction in illumination in the image. (On the “Optimized scene” there is a patch of white in the glass to the left, that is because there is a glass behind that one, it also appears on the other image but is less noticeable because that image has more steps overall).