From the original submission's feedback, we have introduced a new mechanic that helps players find the direction of the exit.

Ideation
Luminth is a dreamy game, so it felt fitting to have a "focus" mechanic to listen closely, and sense the direction of the exit.
I took inspiration from Trap Nation's music videos, where their beats created a trail of chromatic abbreviation.
![]() | ![]() |
---|
Planning
First, let's look at how we might create a sense to where the exit is. There are three important actors:
- Exit: the location on the circle that will produce a bump.
- Player: determines the relative location the exit is.
- Mouse: the position the player is "focusing" at, which will go around the circle in search for a bump.
Finding the Wave Equation
Let's use Desmos to figure out how to achieve this bumpy circle.
- r = θ is the equation to make a "loop" pattern
- Recall the transformation of functions.
The equivalent to transform our function is r = a ( θ - h ) + k.
- Since this is a loop/circular function, h and k behave similarly. Therefore, Let's say k = 0.
- Since this is a loop/circular function, h and k behave similarly. Therefore, Let's say k = 0.
- Let's turn the loop pattern into a wave to make it an enclosed circle! We can simply use sin.
- To actually produce a bump, we have to add a few more variables
- as long as camplitude is an integer, it produces one single bump. Let's use this to show the "peak".
Subsituting 1 gives two bumps, so let's use 2. - cintensity determines how "slim" the bump is.
- cradius is the radius of the base circle. Since we're dealing with texturespace, 1 is a good value.
- as long as camplitude is an integer, it produces one single bump. Let's use this to show the "peak".
So our wave function is
fwave(x,k) = camplitudesin( (x-k)/camplitude) )cintensity + cradius
fwave(x,k) = 2sin( (x-k)/2) )80 + 1
Now the rest is easy.
To show two different bumps, we simply multiply two equations like so:
Adding a few more constants to create the desired shape and voila, it's perfect. vexit and vmouse has a big bump whenever I go around it, and shows nothing if they aren't aligned.
The final equation is r = fwave(θ,vexit) × fwave(θ,vmouse) × csize + cradius
Rendering
Let's import these equations to our project.
float _Intensity, _ExitAngle, _MouseAngle;
fixed wave(float theta, float pos){
float eq = 2*sin(0.5*(theta-pos))+1;
return pow(eq,_Intensity);
}
fixed peakEquation(float uvAngle, float refAngle) {
return wave(uvAngle, _ExitAngle) * wave(uvAngle, refAngle) * 1/10 + 1;
}
If we want to render this in the frag function, we notice that we're missing a few steps:
- Getting the "θ", as it's not simply the texture coordinates.
- Rendering a circle based on the equation.
fixed4 frag (Interpolator i) : SV_Target{
float eq = peakEquation(???(i.uv), _MouseAngle);
return fixed4(1);
}
A Simple Circle base
Firstly, we can reference the previous code to render a circle like so:
fixed renderCircle(float2 uv, float r) {
float res = step(0,length(uv) - r); // using sdf method
return 1-res;
}
fixed4 frag (Interpolator i) : SV_Target{
float eq = peakEquation(???(i.uv), _MouseAngle);
return fixed4(renderCircle(uv,eq));
}
Getting the Theta Angle
As for the angle, we might want to look at our texturespace as we do a Unit Circle. The Unit Circle has the same domains!

#define PI 3.14159
fixed findAngle(float2 v) {
fixed2 o = fixed2(1,0); // The starting point
float dot = o.x*v.x + o.y*v.y;
float len = 1 * length(v);
float angle = acos(dot/len)*0.5; // Cosine Rule Equation
// If negative-x, other side of the circle
if (v.y < 0) {angle = PI-angle;}
return angle/PI;
}
fixed4 frag (Interpolator i) : SV_Target{
float2 uv = i.uv*2-1; // Make (0,0) at the center of the screen
float eq = peakEquation(findAngle(uv), _MouseAngle);
return fixed4(renderCircle(uv,eq));
}
We just need to adjust the formula slightly to align with the new changes.
#define TAU 6.283185
#define PI 3.14159
fixed wave(float theta, float pos){
float eq = 0.5*sin(TAU*(theta-pos)+PI/2)+0.5+_Intensity*0.00012;
return pow(eq,_Intensity);
}
fixed peakEquation(float uvAngle, float refAngle) {
return wave(uvAngle, _ExitAngle) * wave(uvAngle, refAngle)/2.5 * 1/10 + 0.3;
}
Data
Now that we have a shader that takes any angle and renders a radar, let's see how we can actually get those data from Unity.
First, let's define the data we have to send to the shader:
public Material ringMat;
private void FixedUpdate()
{
ringMat.SetFloat("_MouseAngle", ???); // the angle the mouse is pointing at
ringMat.SetFloat("_ExitAngle", ???); // the angle the exit is located at
ringMat.SetFloat("_Intensity", ???); // how "slim" the bump should loop
}
Calculating Angle Data
First, let's make a similar findAngle
function as we wrote in the shader.
private float FindAngle(Vector2 v)
{
var angleRadians = Mathf.Atan2(v.y, v.x);
if (angleRadians < 0) { angleRadians += Mathf.PI * 2; }
angleRadians /= Mathf.PI*2;
return angleRadians;
}
Now it's as simple as adding the input in the function to get the correct values!
public float GetMouseAngle()
{
// Getting the Mouse angle, relative to the screen's origin
Vector3 screenOrigin = new Vector3(screen.w / 2, screen.h / 2, 0);
return FindAngle(Input.mousePosition - screenOrigin);
}
public float GetExitAngle()
{
// Getting the Exit angle, relative to the player's position
float line = values.exitPos - values.playerPos;
return FindAngle(line);
}
Intensity
Intensity should be based on how close or far the player is from the exit.
public float GetIntensity()
{
float distance = Vector2.Distance(values.exitPos, values.playerPos);
return distance;
}
However, distance can range from 0 to 1920. Let's cap this so that the bump doesn't cover the full screen
public float GetIntensity()
{
float longestDistance = 30;
float distance = Vector2.Distance(values.exitPos, values.playerPos);
float intensity = 1-(distance/longestDistance);
return intensity;
}
Now it ranges from 0 to 30! However, players wouldn't be able to see any bump despite pointing at the right direction. Let's change this:
public float GetIntensity()
{
float longestDistance = 30;
float minI = 5;
float maxI = 90;
float distance = Vector2.Distance(values.exitPos, values.playerPos);
float intensity = minI+(maxI-minI)*(1-(distance/longestDistance));
return intensity;
}
Adding all the functions into FixedUpdate()
gives
public Material ringMat;
private void FixedUpdate()
{
ringMat.SetFloat("_MouseAngle", GetMouseAngle());
ringMat.SetFloat("_ExitAngle", GetExitAngle());
ringMat.SetFloat("_Intensity", GetIntensity());
}
Final Touches
We successfully have a working radar! But if you recall, TrapNation's MVs has more than just a bump. We're missing two things:
- Black circle base
- Chromatic Abbreviation
Simple Black Circle base
We can simply use the renderCircle()
function we made earlier, right after our previous work
fixed4 renderInnerBlack(fixed4 col, fixed2 uv){
float circ = 1-renderCircle(uv,0.28);
return fixed4(circ.xxx,1-circ);
}
fixed4 renderFocus(fixed2 uv){
float eq = peakEquation(findAngle(uv), _MouseAngle);
return fixed4(eq.xxx,1);
}
fixed4 frag (Interpolator i) : SV_Target {
float2 uv = i.uv*2-1;
fixed4 col = renderFocus(uv);
col *= fixed4(renderInnerBlack(col, uv).rgb,col.a);
return col;
}
Chromatic Abbreviation
Chromatic Abbreviation in nature manipulates the red, green and blue channels separately. This means we'll have to write something like.
fixed4 renderFocus(fixed2 uv){
float eq = peakEquation(findAngle(uv), _MouseAngle);
fixed r = renderCircle(uv,eq);
fixed g = renderCircle(uv,???);
fixed b = renderCircle(uv,???);
return fixed4(r,g,b,1);
}
This indicates that we'll need a slightly different version of eq for the other two channels.
Looking at TrapNation MVs, the Chromatic Abbreviation happens when the bump itself moves. What if we used the mouse position's previous location to replicate this effect?
This will require a new data variable prevMousePosition
public Material ringMat;
private float previousMousePos;
...
public float GetMouseAngle()
{
// Getting the Mouse angle, relative to the screen's origin
Vector3 screenOrigin = new Vector3(screen.w / 2, screen.h / 2, 0);
float mouse = FindAngle(Input.mousePosition - screenOrigin);
previousMousePos = mouse;
return mouse;
}
...
private void FixedUpdate()
{
ringMat.SetFloat("_PrevMouseAngle", previousMousePos);
ringMat.SetFloat("_MouseAngle", GetMouseAngle());
ringMat.SetFloat("_ExitAngle", GetExitAngle());
ringMat.SetFloat("_Intensity", GetIntensity());
}
fixed4 renderFocus(fixed2 uv){
float eq = peakEquation(findAngle(uv), _MouseAngle);
float eqPrev = peakEquation(findAngle(uv), _PrevMouseAngle);
fixed r = renderCircle(uv,eq);
fixed g = renderCircle(uv,lerp(eq,eqPrev,0.5));
fixed b = renderCircle(uv,eqPrev);
return fixed4(r,g,b,1);
}
Results
Congratulations for making it this far! That was the base logic for how I render this TrapNation-inspired shader.
As a recap, we:
- Used Desmos to find the mathematical function to represent a peak in a circle.
- Considered the UV Coordinates as a Unit Circle to render angle coordinates and values.
- Made a dynamic Intensity value that considered how close the player is to the exit.
- Used Chromatic Abbreviation to make a mouse-movement effect.
Thank you for reading! Check out the game that used this, or the project details about it here!
Luminth Details