Reveal Prototype - Visuals

February 23rd, 2024

Links: Itch.io GitHub

Quick Navigation


Goal

The inspiration for this project was from the horror game 'The Voidness'. This game uses a LiDAR detector to map out the world in real time.

The technique of hiding visuals until light interacts with them was interesting to me and a few friends, and so in 2022 we created a puzzle game titled 'Luminous'. This project, which was successful, was also too highly scoped, as we included online multiplayer and three levels within a 12 week deadline and myself as the only programmer. This created limitations within the scope of the project, visuals and lighting being a major factor.

The Voidness Image

Image From: Luminous GitHub

The main limiting factor, and the goal of this project, is to address the lighting within this game. The shader was initially created using HLSL for Unity's base Render Pipeline, which is extremely limited in terms of lighting and visual support. As such, this prototype aims to update the shader code to use Unity's Universal Render Pipeline, allowing more interesting visuals.


Code Conversion

The initial code we're working with can be found below. To briefly explain its functionality, we use Unity's SpotLight system to visualize the values we're using. These values are LightPosition, LightRotation, LightAngle, Colour and LightRange. We pass each of these into the shader on each frame, and then each fragment computes how brightly it should be lit.


Shader "Reveal Object with Light"
{
Properties
{
    _Color("Color", Color) = (1,1,1,1)
    _MainTex("Albedo (RGB)", 2D) = "white" {}
    _Glossiness("Smoothness", Range(0,1)) = 0.5
    _Metallic("Metallic", Range(0,1)) = 0.0
    _Alpha("Alpha", Range(0,1)) = 0.0
    _LightDirection("Light Direction", Vector) = (0,0,1,0)
    _LightPosition("Light Position", Vector) = (0,0,0,0)
    _LightAngle("Light Angle", Range(0,180)) = 45
    _StrengthScalar("Strength", Float) = 50
    _StrengthDistance("StrengthDist", Float) = 0
    _RequirementsMet("Visible", Float) = 0
}
    SubShader
    {
        Tags{ "RenderType" = "Transparent" "Queue" = "Transparent" }
        Blend SrcAlpha OneMinusSrcAlpha
        LOD 200
        CGPROGRAM
        #pragma surface SurfaceReveal Standard fullforwardshadows alpha:fade
        #pragma target 3.0

        sampler2D _MainTex;
        struct Input
        {
            float2 UVMainTex;
            float3 worldPos;
        };//Struct end

        half   _Glossiness;
        half   _Metallic;
        fixed4 _Color;
        half Alpha;
        float4 _LightPosition;
        float4 _LightDirection;
        float  _LightAngle;
        float  _StrengthScalar;
        float  _StrengthDistance;

        void SurfaceReveal(Input input, inout SurfaceOutputStandard R)
        {
            float3 Dir = normalize(_LightPosition - input.worldPos);
            float  Scale = dot(Dir, _LightDirection);
            float  Strength = clamp(Scale - cos(_LightAngle * (UNITY_PI / 360.0)), 0, 1);
            Strength = clamp(Strength *	 * _StrengthDistance, 0, 1);
            fixed4 RC = tex2D(_MainTex, input.UVMainTex) * _Color;
            R.Albedo = RC.rgb;
            R.Metallic = _Metallic;
            R.Smoothness = _Glossiness;
            R.Alpha = Strength * RC.a;

        }
    ENDCG
    }
FallBack "Diffuse"
}
                                    

To convert this, first we create an Unlit Shader Graph and create the variables described earlier. We must also add in and expose a Texture2D for the objects texture, and a Strength Scalar to control the power of the material.

The Voidness Image

Variables

Now to calculate we need to calculate the intensity for each fragment. We're going to copy exactly what was done in the code snippet seen above, and replace the Color variable with our new Texture2D.

First we calculate the ideal direction, which would display the current fragment fully.

The Voidness Image

Ideal Direction

Next we use the dot product of the lights forward direction, and the ideal direction to get all intersections.

The Voidness Image

Visible Intersections

This then needs to be constrained to the Light Angles circle, which gives us exactly where the light is hitting for each fragment.

The Voidness Image

Constrained Light Hit

This then needs to be constrained to the Light Angles circle, which gives us exactly where the light is hitting for each fragment.

The Voidness Image

Constrained Light Hit

Next we must calculate the final strength, by applying the exposed Strength Scalar, and calculating the Strength Distance found by the distance between the objects position, and the lights position.

The Voidness Image

Constrained Light Hit

Finally, this is all applied to the textures alpha to get our final result.

The Voidness Image

Constrained Light Hit


Final

To finish this, we pass through the variables _Light_Position, _Light_Direction, _Light_Angle and _Range. These references can be changed within the shader on each of the variables. The code to pass these variables is provided below.


using UnityEngine;

public class ActivateLight : MonoBehaviour
{
    [SerializeField] Material revealMat;
    Light light;


    private void Awake()
    {
        light = GetComponent();
    }

    private void Update()
    {
        UpdateShader();
    }
    void UpdateShader()
    {
        revealMat.SetVector("_Light_Position", light.transform.position);
        revealMat.SetVector("_Light_Direction", -light.transform.forward);
        revealMat.SetFloat("_Light_Angle", light.spotAngle);
        revealMat.SetFloat("_Range", light.range);
    }
}

                                        

Below are some provided visuals of the final product. There is definitely some optimisation to be made, for instance, we update many variables that do not change, Range being a main example. We could create a check for each variable to see if it has been updated this frame, and if so, only then will we pass it through.

We also do not yet support multiple light sources, which could be a possible avenue for future development.

Animated GIF Level Level