Creating Distinctive Sci-Fi Lighting Quick in Unity

Results First

Recently I needed to overhaul the intro level of the game I’ve been working on. Beyond some strategic light placement, this involved writing some custom shaders and scripts, which is what this video focuses on.

The Technical Stuff

Let’s take a look at the code featured in this video. The following code snippets can be inserted into any standard surface shader, and easily adapted to other Unity shader types if you know what you’re doing. Also, note that the appearance of “. . .” in the snippets implies that I’m omitting irrelevant code. We’ll start with the fake realtime GI emission shader first. The idea is that potentially glowing surfaces around the scene will follow the point light, so this shader’s emission intensity is determined by the distance between the point light and its surface. In this shader, we make the distance calculation in the surface output:

//shader
void surf (Input IN, inout SurfaceOutputStandard o)
{

    float distFromLight = distance(_lightPos, IN.worldPos);

    o.Emission =  _Intensity;

    o.Emission -= _Intensity*saturate(sqrt(distFromLight)*_Falloff);

    o.Emission *= _Emission;

    ...
}

This snippet also requires that the following properties be declared:

//shader
float3 _lightPos;
float4 _Emission;
float _Intensity;
half _Falloff;

You will also need to declare the world position property in the shader’s input struct.

//shader
struct Input
{
    ...
    float3 worldPos;
};

Every property except _lightPos will need to be exposed in the inspector. This is easily done like so:

//shader
Properties
{
    ...

    _Intensity("Intensity", Float) = 1
    _Emission("Emissive", Color) = (1,1,1,1)
    _Falloff("Falloff Factor", Range(0,1)) = 0.5

}

Without being set by a script, the _lightPos value defaults to a zero vector. This means that, until we set that value via a c# script, the shader will always light up at the zero position in your scene. For simplicity’s sake, this script is attached to the point light, where it then passes its position to the shader of the material on each renderer assigned to it. It’s short enough to include the full thing below.

//cs
using UnityEngine;

[RequireComponent(typeof(Light))]
public class ReactiveLight : MonoBehaviour
{
    public Renderer[] rends;
    public int index;

    private void Update()
    {
        foreach(Renderer rend in rends)
        {
            rend.materials[index].SetVector("_lightPos", transform.position);
        }
    }
}

At runtime, the shader’s light emission now follows the point light. In a test scene, it appears like this:

Moving on, our next shader handles the flickering wall-mounted CRT screens – though the content of the screens is irrelevant – we’re just after the flicker functionality here. As shown in the video, we’re going for a screen glitch effect whenever the power surge light gets close. We need a shader that allows for intermittent flickering via a passed parameter, a script with helper functions to manage each individual screen game object, and a secondary controller script that uses those helper functions to synchronize the screen flickering.

Keep in mind that the shader and scripts assume that each CRT screen is a separate mesh. With that out of the way, we can get to the shader. The screen shouldn’t be constantly flickering, so we start by checking the value of a parameter which determines that. If it’s met, we add together sine and cosine functions, passing them the current time multiplied by some large, but arbitrarily different, number. Importantly, we’re also offsetting the time by the magnitude of the world space origin of our mesh. This makes sure that multiple screen objects don’t flicker in sync, given that they’re not occupying the same space. When we saturate the sine waves, you’ll see only a brief transition between 0 and 1, rather than a smoothly oscillating pattern. Finally, the emission color can be multiplied by this value.

//shader
void surf (Input IN, inout SurfaceOutputStandard o)
{
    //starting emission can also be determined via inspector
    //for simplicity's sake, it's set to 1 here
    float4 emission = 1;

    if(_isFlickering == 1)
    {

        float posMagnitude = length(ourOrigin);

        float flicker = 0;

        flicker += sin((_Time.x + posMagnitude)*(750 * _flickerSpeed));
        flicker += cos((_Time.x + posMagnitude)*(1000 * _flickerSpeed));

        emission *= saturate(flicker);

    }
    
    o.Emission = emission;

    ...
}

Like earlier, these calculations require the following properties to be declared:

//shader
float4 ourOrigin;
float _isFlickering;
float _flickerSpeed;

And two of these properties also need to be exposed in Unity’s inspector:

//shader
[Toggle] _isFlickering("Flickering", Float) = 0
_flickerSpeed("Flicker Speed", Range(0,1)) = 1

Now we have proper flickering functionality, but without the scripts it won’t be very impressive. Because we aren’t yet passing our world space origin through a script, all screen objects will still flicker in sync. Let’s take care of that next. This c# script is a monobehaviour that is attached to every game object using the shader. On awake, it sets the shader’s origin position property with its transform.position, and provides easy access to the flicker parameter.

//cs
using UnityEngine;

[RequireComponent(typeof(Renderer))]
public class CRTScreen : MonoBehaviour
{

    private Renderer rend;
    public int index;

    private void Awake()
    {
        rend = GetComponent<Renderer>();

        rend.materials[index].SetVector("ourOrigin", new Vector4(transform.position.x, transform.position.y, transform.position.z, 0));
    }
    
    //This function is used by the controller
    public void Flicker(float value)
    {
        rend.materials[index].SetFloat("_isFlickering", value);
    }

}

Now that the functionality for individual screens is completed, we need to set up the main controller that can orchestrate their behaviours in a way that’s neat to look at. The following script is a little longer, but still simple. The individual CRT screens from earlier should be children of the object with the CRTController component as it will be looking to find them in its array of children. Every quarter of a second, the FlickerOnDistance coroutine iterates through the array of screens, and sets their flicker parameter based on if the distance to the proximityFlicker transform is less or greater than the minDistToFlicker property. To achieve the effect shown in the video, the proximityFlicker property should be set to the same point light that affects the emissive shader.

//cs
using UnityEngine;
using System.Collections;

public class CRTController : MonoBehaviour
{
    private CRTScreen[] screens;

    public Transform proximityFlicker;
    public float minDistToFlicker = 5;

    private void Awake()
    {
        screens = GetComponentsInChildren<CRTScreen>();

        if (proximityFlicker != null)
            StartCoroutine(FlickerOnDistance());
    }

    private IEnumerator FlickerOnDistance()
    {
        while(true)
        {

            yield return new WaitForSeconds(0.25f);

            foreach (CRTScreen screen in screens)
            {

                float dist = Vector3.Distance(screen.transform.position, proximityFlicker.position);

                if (dist <= minDistToFlicker)
                    screen.Flicker(1);
                
                //minDist is multiplied by 3 here
                //not necessary, but lets the flicker linger a bit
                if(dist > minDistToFlicker*3)
                    screen.Flicker(0);

            }
        }
    }
}

Assuming that, in your Unity hierarchy, that script is the parent of every screen with the CRTScreen component, your screens will flicker asynchronously based on their distance from the point light. In the test scene it looks something like this:

That wraps up our screen flicker, and by extension, this post. With that finished, you’ll find that these effects can also be modified to fit a variety of circumstances. For example, passing the screen flicker controller the player’s position instead may make for some behaviour with very different implications. Hopefully you can successfully build on the results of the shared code here!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Create a website or blog at WordPress.com

Up ↑

%d bloggers like this: