Cyanilux

Game Dev Blog & Tutorials

Sun Beams / God Rays Shader Breakdown

Intro

In my current project I wanted to add some light/sun beams (also referred to as god rays or light shafts), to somewhat simulate light passing between leaves/trees in a dense forest.

(And to make the scene look pretty ✨)

There’s a few techniques I’m aware of that can achieve effects like this :

Setup

Breakdown

Billboarding

For rendering billboarded quads there’s a few options we should go through.

The simplest (and method I used) is to just use regular GameObjects with a quad mesh assigned to MeshFilter, and hope the SRP Batcher helps with optimising (assuming URP/HDRP). Alternatively, using GPU instanced draw calls from the Graphics/CommandBuffer APIs may be an option if you’re familiar with coding that kind of stuff. For either of these the shader would need to handle the billboarding calculations.

You could also use particles, as those systems are designed to handle billboarding for us, specifically :

I won’t go over further details of how to set these systems up as I didn’t use them - I’m focusing more on the Shader Graph itself. But if you can figure it out and want to use particles, you can add a Position node set to World space to the Shader Graph and skip to next section, otherwise click the foldout below.

For billboarding we typically want to skip using the view/camera matrix for rotation. This is fairly easy to do in shader code where the vertex shader is responsible for converting the vertex positions from object to clip space, but Shader Graph does this for us behind the scenes.

Instead, we can use Inverse View matrix from the Transformation Matrix node. The idea is to do the inverse of the view matrix so when it’s applied later by Shader Graph, it cancels out. Since we only want this on the rotational parts, we should Multiply using a 3x3 matrix, or (4x4 but set the W/A component of the input Vector4 to 0). When multiplying with matrices, the order is also important - the matrix should be in the A port in this case.

There’s also some extra nodes in the image though these are unlikely to make much difference in the final result - so I’d consider them optional :

I’d like to apply the stretching (towards light) in world space, but want to avoid a Transform node here as I’d rather not apply rotations from the GameObject/Transform.

We still might want the ability to scale the god rays horizontally (e.g. for variation), so take the Scale output from the Object node, Split or Swizzle to obtain the R/X component and put it into the X and Z axis on a Vector3 node. Then Multiply with the output from our billboarding group.

And to apply translation, Add the Position output from the Object node.

Stretch Towards Light

To stretch these quads towards the light, we’ll need the light direction. Newer versions of Shader Graph have a Get Main Light Direction node for this that should work in URP. My Custom Lighting package also contains a Main Light subgraph that outputs this. For other pipelines, another option is to use a Vector3 property (e.g. with Reference of _MainLightDirection, and Exposed option unticked to make it global) and a C# script on the light that uses Shader.SetGlobalVector("_MainLightDirection", -transform.forward);

Since the direction would only move vertices by a single unit, we’ll want to Multiply by some value to increase the length. I’ve hardcoded this as 25, but could use a float property if you want control from the Material. If the quads are rendered via GameObjects, could also use the Y axis of the Scale from the Object node.

We only want the top vertices to be displaced, so we Multiply by the G/Y component of the UV to mask it. (Which works as the bottom vertices have a value of 0 here, and top ones have a value of 1).

We Add this to our position (from the billboard group, or Position node set to World space if using particles). But before connecting to the Vertex stage of the Master Stack, we’ll also need to Transform from World to Object space as that is what space the Position port expects.

(Image)

Scene & Camera Fades

For the fragment stage, we’ll be putting together a bunch of groups to mask the alpha. These will all be multiplied together and are mostly optional, depending on what you want the shader to do.

Ideally we don’t want to see the rays intersecting with objects or the camera near plane, so we’ll use depth values to fade the alpha.

To obtain the depth of the fragments/pixels being drawn, use the Position node set to View space, Split or Swizzle to obtain the B/Z axis and Absolute (or Negate). You’ll want to group this under a “Fragment Depth” to help keep track of it, as we’ll need the output twice.

To fade when the camera gets close, we just need to remap this a bit. Subtract the distance you want it to start fading (e.g. 5), and Multiply to control the falloff (e.g. 0.5). (Alternatively could use an Inverse Lerp or Smoothstep and specify the start and end points of the fade in the A/B or Edge ports). We should then Saturate to clamp the value between 0 and 1 (as values outside this range don’t make sense for alpha values, and would mess with the other masks)

To fade into the floor and other objects, can use the Scene Depth node (make sure Depth Texture is enabled on the URP Asset for this to work), and Subtract the “Fragment Depth” group. Use a Multiply to control the falloff and Saturate like before.

(Image)

Stripes

Currently, the rays would mostly be a single transparency (unless multiple quads overlap that is). To add more variation we can sample a stripy texture, e.g.

(Image)

Or create one procedurally. e.g. using Gradient Noise with Tiling And Offset in the UV port and Y axis of the Tiling as 0 to stretch it vertically.

For added variation could use one of the following values as an Offset (assuming texture is imported with Repeat wrap mode!) :

Could also add some scrolling to the Offset if you want, by adding Time output from Time node with Multiply to control the speed.

Fade Edges

If you want to fade the sides of the quad, use a Smoothstep on the X axis of the UV. Subtract a value of 0.5 (as that is the center of the quad) and put into Absolute to handle both sides at once.

Can also do the same on the Y axis of the UV to produce a mask which fades the top/bottom. (In my case I had to do this as the god rays can appear over the void, and so I couldn’t rely just on fading using scene depth)

(Image)

I chose to show them separately above, but we can even do both axis at the same time without duplicating nodes, by using “xy” in the Swizzle, and using a Split node to obtain the R and G channels after the Smoothstep.

Shadows

Ideally we wouldn’t want rays appearing in areas completely in shadow. You could maybe hand-place them to prevent this, but that also assumes the light direction won’t change as areas of shadow would shift. If generating quads procedurally / using particles, it is likely we’d want to mask based on the shadowmap. If using URP, we can use the Main Light Shadows subgraph from my Custom Lighting package. I’m not sure on methods for other pipelines, sorry~!

This likely won’t apply to others, but in my case I also had to replicate some things from the toon shaders used in the scene, as shadows are put through a Step node for a harsh transition and I use the Main Light Cookie subgraph to fake additional shadows for clouds.

Dust/Sparkles

You may be able to handle dust/sparkles as additional particles, but I chose to use some good ol’ scrolling textures~

(Image)

Voronoi/Worley Noise like the texture above, can produce a good distribution of dots (though as these are black in the above, we’ll also need to One Minus). While there is a Voronoi node, handling it procedurally is likely more costly than sampling a texture (using Sample Texture 2D), especially as I’d like to sample twice with different amounts/directions of scrolling, by using Time as an Offset to Tiling And Offset node. The Y axis of the Tiling should also higher since our quads are stretched vertically.

We can combine the two scrolling textures by using a Multiply, then Multiply again by 2 to increase the brightness a bit.

Currently the dots/texture in the preview is always visible, though some parts are brighter when the textures overlap. We can Smoothstep to remap and only obtain a few dots when the value falls into a given range (the Edge1 and Edge2 inputs). Higher values means less of the texture is visible - so with values of 0.9 and 1 it produces “occassional dots/sparkles”, which we can then Multiply by a high value like 500 to make them very bright. For some “ambient dust”, you can use another Smoothstep with slightly lower values like 0.6 and 0.8, which makes the dots more common (but not always visible).

(Image)

(Click image to view fullscreen)

We also Add a value of 1 at the end so the result can be multiplied with the other masks without hiding the rays. Note we don’t clamp the value anywhere after this - I want to keep the high intensity values for the final colour output so it’ll glow when using Bloom Post Processing (and assuming HDR is enabled on the URP asset)

Colour & Alpha

Finally we can add a colour to tint and adjust the intensity of the god rays. If applying the shader to a Shuriken Particle System, can use the Vertex Color node - otherwise for VFX Graph or regular GameObjects & MeshRenderers, create a Color property in the Blackboard window. Connect that to the Base Color port in the Master Stack.

!
If using a property, can change the Default value under the Node Settings. Be sure to set the alpha component! Shader Graph defaults this to 0 which means the god rays will be invisible! (unless overidden on material)

For the Alpha port, Split that colour, take the A component and Multiply with the result of all the masks (created in the above sections) multiplied together.

(Image)

Final Notes

The shader should now be complete. We can assign it to a material on our objects / particle systems and manually place these in areas of the scene we want to draw attention to - maybe chests, level entrance/exits, etc.

We can also have these spawn around the player/camera for general ambient rays - assuming that’s appropriate for the scenery (e.g. forest, maybe dungeons or cavern close to surface)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
using System.Collections.Generic;
using UnityEngine;

public class LightRayHandler : MonoBehaviour {

    public int lightRayCount = 50;
    public float boundsSize = 35;
    public Transform player;
    public Mesh quadMesh;
    public Material lightRayMaterial;

    private List<Transform> rays = new();
    private float halfBounds;
    private Mesh meshInstance;
    private float initalY;

    void Start() {
        halfBounds = boundsSize * 0.5f;
        initalY = transform.position.y;
        Vector3 targetPos = player.position;
        targetPos.y = 0;

        // Create instance of mesh & adjust bounds to avoid frustum culling
        meshInstance = Instantiate(quadMesh);
        meshInstance.bounds = new Bounds(Vector3.zero, Vector3.one * 100);

        // Spawn rays 
        for (int i = 0; i < lightRayCount; i++) {
            GameObject obj = new("LightRay");
            obj.transform.SetParent(transform);
            obj.AddComponent<MeshFilter>().sharedMesh = meshInstance;
            obj.AddComponent<MeshRenderer>().sharedMaterial = lightRayMaterial;
            rays.Add(obj.transform);

            // Randomise starting location
            obj.transform.position = targetPos + new Vector3(
                Random.Range(-halfBounds, halfBounds),
                initalY,
                Random.Range(-halfBounds, halfBounds)
            );
            // Randomise scale
            obj.transform.localScale = new Vector3(
              Random.Range(0.5f, 3),
              25,
              1
            );
        }

    }

    void FixedUpdate() {
        Vector3 targetPos = player.position;

        for (int i = 0; i < rays.Count; i++) {
            Transform ray = rays[i];
            if (ray.position.x < targetPos.x - halfBounds) {
                ray.position += Vector3.right * (boundsSize - 1);
            }
            if (ray.position.x > targetPos.x + halfBounds) {
                ray.position -= Vector3.right * (boundsSize - 1);
            }
            if (ray.position.z < targetPos.z - halfBounds) {
                ray.position += Vector3.forward * (boundsSize - 1);
            }
            if (ray.position.z > targetPos.z + halfBounds) {
                ray.position -= Vector3.forward * (boundsSize - 1);
            }
        }
    }

    void OnDestroy(){
        Destroy(meshInstance);
    }
}


Thanks for reading! 😊

If you find this post helpful, please consider sharing it with others / on socials
Donations are also greatly appreciated! 🙏✨

(Keeps this site free from ads and allows me to focus more on tutorials)


License / Usage Cookies & Privacy RSS Feed