Volumetric Lighting in Unity using Shader Graph

Aki Aoki
6 min readJan 18, 2023

Lighting is one of the most important parts of making a quality composition. As such, different techniques are known to simulate various light effects depending on the platforms being targeted.

Volumetric Lighting is one of my favorites. This technique simulates sun rays coming through interior particles and highly improves the visual style of the game adding a feeling of depth.

Fake Volumetric Lighting made with Geometry Shaders in Unity

The Idea — Mesh Displacement Fake Volumetric Lighting

Volumetric Lighting is the result of dust particles receiving light rays. Or, in other words, attenuation of those being hidden by walls. Light volumes are defined by the window shape, often being a simple rectangle. By extruding this window frame along the light direction we actually could receive a similar-looking light volume. As I experimented, I started with a Geometry Shader first constructing new triangles along a quad mesh.

Extruded Quad using Geometry Shader

Later, adding distance blending and color gradient, I received an astonishing result, and I really liked what it looked like. It performed quite well but there was no real point in using geometry shaders. Especially, considering their platform availability and extra complexity.

Fake Volumetric Lighting using Geometry Shader

So, we will use Shader Graph instead and simplify things a little bit. Do not worry, it looks absolutely the same! And also works on mobile platforms, with more complex shapes.

Using Shader Graph to Implement Fake Volumetric Lighting

Let’s get started! Firstly, let’s create a new unlit shader. The most important thing for our shader is the light direction. For older Unity versions it can be obtained by defining a new non-exposed property called “_WorldSpaceLightPos0” with rather simple transformations.

_WorldSpaceLightPos0 Property

Applying world to object space transformation and normalization we can define a light direction that will be extremely useful later.

Object Light Direction

Now, let’s talk a little bit about how we can achieve the same geometry extrusion without Geometry Shader. For that purpose, we can split mesh such as a cube into two parts along the Z-axis plane. Vertices situated on the same side as light are “Front Vertices” and those on the opposite one are “Back Vertices”. It would be quite helpful to determine which side the vertex is on, so a new boolean “Front / Back Vertex Flag” can be created.

Using earlier calculated Object Light Direction, Front / Back Vertex Flag can be determined

The same extruded volume can be achieved by projecting Front Vertices onto the plane and displacing Back Vertices by a fixed value along the light direction. Projection can be obtained simply by discarding z-axis. Extrusion displacement can be calculated as a sum of the previously determined projection position and light direction multiplied by a fixed value.

Cube after projection and displacement transformations
Projection and displacement (Vertex Light Offset) transformations

Vertex position can be split into two possible options: Projection Position (if the vertex is on the “Front” side) and Displacement Position (if the vertex is on the “Back” side). A Branch node would be ideal for this task. Output value can be used as a Position output inside the vertex shader program.

Right now we can take a look at what is going on. Applying UVs as the fragment base color output, the following result is created.

Geometry Visualization using UV

Ok, looks great! Now it’s time to change some Graph Settings. We want our shader to be Transparent, having Additive blending mode, rendering Both faces, and have disabled depth write and shadows casting.

Applying Additive Blending Mode with Cull Off

We need some way of determining how far the position is located from the projection plane, all inside our fragment shader. That’s where Custom Interpolators are very useful! Let’s create one inside the vertex shader program and call it “LightIntensity”.

This value shows how brightly current fragment is lit by the sun where 0.0 would be nothing / transparent and 1.0 fully bright. It turns out that all of the Front Vertices have exactly 1.0 LightIntensity and all of the Back Vertices have 0.0. One more Branch node is needed here. The intermediate values are interpolated, so there’s no need to worry about further interpolation.

It would be also visually appealing to use the Power function for gradual light scattering. Here’s the blending algorithm I’ve used.

Blending Algorithm
Applying Distance Blending

You can notice that light volumes currently have some hard edges intersecting other geometry. This is not good as in reality these volumes are just dust particles with no sharp geometry. Luckily, a ready-to-use feature called “SoftParticles” can be used to get rid of this undesirable effect (I call it Soft Edges). As a matter of fact, a similar problem is noticeable when the camera intersects these light volumes. To solve this, we can use a simple Camera Near Fade algorithm.

Applying Soft Edges

Lastly, we can apply some colors. Let’s define StartColor and FinalColor. Interpolating between these colors results in a color gradient. We already have a LightIntensity value that we can use inside the Lerp node.

Applying Color Gradient… and Tadaaa!

Here’s the complete shader diagram (source file on GitHub):

One more thing to mention: mesh bounds. Because we generate all the geometry in real-time inside shaders, mesh bounds are not recalculated. To solve this problem, I’ve created a simple OverrideBounds script. Attach it as a component, set up Size (As a general rule — each axis should be equal to the double value of the Length parameter inside our shader), and click “Create Updated Mesh”.

using UnityEngine;

public class OverrideBounds : MonoBehaviour
{
public Vector3 Center;
public Vector3 Size;

public void CreateUpdatedMesh()
{
var meshFilter = GetComponent<MeshFilter>();
var meshCopy = Instantiate(meshFilter.sharedMesh);
meshCopy.bounds = new Bounds (Center, Size);
meshFilter.sharedMesh = meshCopy;
}
}
using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(OverrideBounds))]
public class OverrideBoundsEditor : Editor
{
public override void OnInspectorGUI()
{
var component = (OverrideBounds)target;
DrawDefaultInspector();

if (GUILayout.Button("Create Updated Mesh"))
{
component.CreateUpdatedMesh();
}
}
}

My ArtStation

Thank you for your time! You can find more on my ArtStation

--

--