So maybe the wrong place to put this but I am looking for a way to have my player take damage from the “dark” aka being in an area that isnt lit.
Are there any examples of how to do this that anyone knows of?
So maybe the wrong place to put this but I am looking for a way to have my player take damage from the “dark” aka being in an area that isnt lit.
Are there any examples of how to do this that anyone knows of?
Lots of different ways to do this, trading off between the simplicity of just hand-defining all the dark areas in advance (perhaps as trigger volumes?) versus a more-dynamic approach that tries to judge how bright a given area is.
For version 1 I would recommend just making trigger volumes where there is darkness and moving on with the rest of your game.
Otherwise one simple approach is to raycast between your player and potential light source(s) and see if they are in line and/or close enough. This obviously presumes that all visually-blocking geometry also has physics colliders on it.
EDIT: somehow I forgot that it is also possible to Attack the Darkness. Wow, that was 18 years ago…
In this kind of approach you’d also take into account the light fall-off, typically an inverse square formula.
And then there’s also Minecraft’s way of discretely defining the quantity of illumination per block…
So there are dozens of valid answers to your question. Nobody knows what kind of game you’re making so we can’t make good assumptions. “Light” in general does not mean the same thing in Pacman, Outrun, Doom, Minecraft, Limbo, Don’t Starve, or CoD.
The high-level concept of light/dark in all of these games “appears” to be same, but that’s because all games try to mimic the way we perceive reality in whatever way is optimal/useful for their gameplay. Technically however, we’re talking about apples and oranges in terms of technologies and the actual code.
it might be possible to use your light probes to do this if you already have them baked
though by far easiest is just place triggers
I would also agree that placing triggers is the easiest solution, but if you want to do it in an “automatic” way…
…I saw this video a while ago, where someone tried to implement the V Rising logic, which deals damage in the sun, maybe you can somehow butcher and invert the logic of the video to deal damage in the dark?
I started with this suggestion which works its just not the most accurate and dialling it in for every light source is a bit crazy, as the whole game takes place in the dark with having to get into the light and switch on new lights etc.
I tried ray casting but performance is just crap. Going to try calculating intensity but im really not sure what to do with that.
If your game is taking place in the dark with sporadic lights, you want to play with the general proximity first (via Euclidean distance between points). Then, once you narrow down the light sources to 1, 2, 3 at most, then you only apply ray casting for stuff that can occlude the player (and cast a shadow), like walls, and use extremely rough colliders (i.e. boxes) and exclude (or attenuate) lights that do not past this test.
Setting up your collision tests properly isn’t actually trivial. Even though ray casts are popular, with thousands of tutorials, and everybody does it, the performance window is huge and largely depends on what you’re doing. I.e. if you can say “I tried ray casting but performance is just crap” that sounds like you won’t get far without spending at least a month figuring out what the hell you’re doing.
You can’t possibly say that with such conviction because there are so many parameters to it: static colliders, layer masks, primitive colliders, overlap tests, non-allocating methods, and so on… Also, just in case, you don’t actually need rigidbodies for ray casting to work.
In other words, you can’t just ray cast from hundreds of lights from every corner of your level and assume that’s how games are made. Try different things, employ tricks, be more prudent, more patient, more clever.
If the game is mostly in the dark, then you can reverse the dynamic. Put triggers around light sources, rather than the dark. Being in the light stops you being damaged, rather than being damaged when in the dark.
Some editor tooling should make this seamless to do in editor as well.
My reply was pretty crap i guess lol, but so was my implementation of ray casting sooo, 0 for 2
My programming knowledge is an absolute fat 0. I have just started learning so take anything I say with an absolute fist full of salt, ray casting from the player in a sphere to check if it hits a light within X distance was my first thought but lights of varying intensities makes for different distances. Well at least that’s my thinking.
So I moved onto checking if I could calculate light intensity by using a light source script on each light and then a detection script on my player but the accuracy is kind of terrible, my understanding of the intensity at a player vs the visual intensity is some weird brain disconnect I’m having.
Using triggers feels like the easiest to create but in the long run likely the most time consuming hand adjusting each one to make sure it feels like it matches the light source.
Overall im being pointlessly stubborn while trying to learn ![]()
This was the first thing I did but I didn’t think of editor tooling at all to make implementation easier ![]()
Definitely going to look at trying that.
Point sources of light have their rays propagate through medium divergently. And this divergence correlates with the inverse of distance squared, and you can multiple this by some artificial intensity
var receivedLight = 0f;
for(int i = 0; i < nearestLights.Count; i++) {
var sqrDistance = (nearestLights[i].position - player).sqrMagnitude;
receivedLight += nearestLights[i].intensity / sqrDistance;
}
You can get a list of all nearest lights reasonably quickly if you use Physics.OverlapBoxNonAlloc for example.
Edit: fixed = to +=
Your thoughts would be appreciated as Im currently using this as the player health/detect light code:
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
public class PlayerHealth : MonoBehaviour
{
public static PlayerHealth Instance { get; private set; }
public float maxHealth = 100f;
public float currentHealth;
public float damagePerSecond = 10f;
public Slider healthSlider;
public float lightThreshold = 0.5f; // Adjust this value to change the light threshold
public float checkInterval = 0.5f; // Interval in seconds to check light intensity
private bool isTakingDamage = true;
private Light[] allLights;
private float timer = 0f;
private void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
}
}
private void Start()
{
currentHealth = maxHealth;
UpdateHealthUI();
allLights = FindObjectsOfType<Light>(); // Get all light sources in the scene
}
private void Update()
{
timer += Time.deltaTime;
if (timer >= checkInterval)
{
CheckLightIntensity();
timer = 0f;
}
if (isTakingDamage)
{
TakeDamage(damagePerSecond * Time.deltaTime);
}
}
private void CheckLightIntensity()
{
float receivedLight = 0f;
foreach (Light light in allLights)
{
if (light != null && light.enabled)
{
var sqrDistance = (light.transform.position - transform.position).sqrMagnitude;
receivedLight += light.intensity / sqrDistance;
}
}
if (receivedLight >= lightThreshold)
{
StopTakingDamage();
Debug.Log("Player is in the light.");
}
else
{
ResumeTakingDamage();
Debug.Log("Player is in the darkness.");
}
}
public void TakeDamage(float amount)
{
if (isTakingDamage)
{
currentHealth -= amount;
UpdateHealthUI();
if (currentHealth <= 0f)
{
Die();
}
}
}
public void StopTakingDamage()
{
isTakingDamage = false;
Debug.Log("Player stopped taking damage.");
}
public void ResumeTakingDamage()
{
isTakingDamage = true;
Debug.Log("Player resumed taking damage.");
}
private void Die()
{
// Implement death logic here (e.g., restart level, show game over screen)
Debug.Log("Player has died.");
}
private void UpdateHealthUI()
{
if (healthSlider != null)
{
healthSlider.value = currentHealth / maxHealth;
}
}
}
Right now while using this code my weirdest kinda issue is understanding why light threshold ends up being the values that it needs to be to work. Standing 2 units under a spot light of 4 intensity requires my threshold to be 0.14 while directly under the center and around 0.08 while on the edge of it with a 90 degree angle.
I really dont understand all the math involved but something just isnt clicking in my head.
This
and this
are showstoppers when it comes to performance. This doesn’t scale well.
This is exactly why I’ve recommended stuff like BoxOverlap or SphereOverlap which are heavy-duty API methods for optimally searching for things in some arbitrary volume, especially if it’s distance-based. This is how you leverage the power in the C++ side of Unity, not everything is doable or practical from the C# side.
But apart from that
First of all inverse squared distance law works differently for spot lights. It’s much more complicated because you have to consider only the concentrated (spot) area.
Second, the point of artificial intensity is that it is artificial. It’s not the same kind of intensity that goes into Light component intended for rendering. Though it might be if you are careful with how your scene, lighting, rendering, and postprocessing works. Game dev is all about smoke & mirrors, oftentimes we don’t really care for realistic and exact physical simulations which are needlessly expensive, if we can arrive to a same-ish result through sheer ingenuity, hacks, simple models, or approximations…
You just want to gather how much lighting in your game affects some arbitrary point in space. And you absolutely need to take care of your basic math knowledge to accomplish that. There are no shortcuts here, it’s too basic.
That said, I can’t help you any more than what I gave you in post #11. That should be enough. And even if the inverse squared distance law does not deliver what you had in mind, it should be enough to point you in the right direction.
Btw your code looks ok (apart from constantly rolling through every light in the scene).
I see you’ve fixed receivedLight +=, great catch, that’s was a typo in my code.
I’m guessing your spot lights aim directly down and thus the fall off doesn’t really match with the inverse squared distance. And that’s ok, you can try go down that route, using spots, just try to fudge things with some sort of similar math and you’ll get somewhere.
Edit:
Here’s an idea, you can very easily project the spot’s direction to the ground and treat it as a linear fall off circle (which you can scale manually). Then you project your player character’s pivot to the ground as well, and get the distance in 2D.
In fact, if you treat that circle as SDF, you can get a very fast system (that’s also smooth), and if you need shadow casters as well then you can also include ray casting as a final check.
Here’s SDF for a circle. To project a point onto the ground, use ProjectOnPlane.
Edit2
I might make you a working demo for the math alone, but you’d have to be able to reimplement this again for your specific case, for example optimally finding all nearby sources of light, but you then just push them to a collection, and get back the result.
Ideally, you want to build a custom component and place it on a prefab which also has Light intended for rendering. I.e. GameLightSource. Here you add some properties that you can assign from the inspector and interrogate later, this is how you can build variety in your scene, and it’s easy to implement in code. Instead of querying for Light objects, you query for GameLightSource objects and get all that data + the active transform of the light’s game object.
Most of the time, btw, and I feel this should be emphasized — we don’t actually source visuals and the logic surrounding these visuals from the same place. These two domains are separated (most of the time). Regarding what you said about your brain disconnecting. Visuals (“what you see on the screen”) should only ever closely resemble what is really going on mechanically (“what is actually the ground truth that’s manipulated and tested in memory”). We envelop everything with graphics only to provide feedback on whatever is going on under the hood. The visuals are a big part of the UI concept. So it pays to consider Light to be “what you need for rendering” and GameLightSource (or whatever) to be “what you actually use for the gameplay”.
At least one benefit from this mind set is that sometime in the future you’ll be free to upgrade or modify your light setup and rendering, without inadvertently affecting the gameplay. This is where your artificial intensity should be as a parameter, and stylistically yes, you can decide if that parameter should follow Light’s intensity, just be aware that you’re likely to change that value later, perhaps when you introduce tonemapping or bloom, change the color or add a cookie, who knows.
Thanks for all the advice as is, im going to play around and try and switch in an API as you suggested just so I can claw back some performance as it will get very heavy if I keep it as is.
As for a demo build I would love to see your take on it/how it ends up working.
Ok, here’s what I did.
I’ve made GameLight, which is a custom component that requires to be accompanied by Light. In other words, game objects that have Light may also contain GameLight.
It began like this.
using System;
using UnityEngine;
[RequireComponent(typeof(Light))]
public class GameLight : MonoBehaviour {
[SerializeField] [Min(1E-5f)] float _radius;
[SerializeField] [ColorUsage(showAlpha: false)] Color _color;
public float radius => _radius;
public Color gizmoColor => setAlpha(_color);
static Color setAlpha(Color c, float alpha = 1f) => new(c.r, c.g, c.b, alpha);
}
Open a new scene and give it a try. Create 3 new objects (call them ‘L1’ ‘L2’ ‘L3’), and attach GameLight to each. (You’ll automatically get a Light component which you can ignore for now.)
Then I made another component
using System;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
[ExecuteInEditMode]
public class GameLightsTest : MonoBehaviour {
[Header("Objects")]
[SerializeField] GameLight[] _lights; // lights container
[Space]
[Header("Plane")]
[SerializeField] Vector3 _planeOrigin; // keep this at (0,0,0)
[SerializeField] Vector2 _planeAngles; // and (0,0) for top-down flat ground
[Space]
[Header("Gizmos")]
[SerializeField] bool _showLightPoints;
[SerializeField] bool _showLightCircles;
[Space]
[SerializeField] [Min(0f)] float _gizmoScale = 1f;
#if UNITY_EDITOR
void OnDrawGizmos() {
// .. code goes here
}
#endif
}
We can use this to experiment a little and then build a proof of concept that works live in the editor. Create a new game object and attach this component, then add the 3 lights objects to the inspector list.
Make sure to position the light object at some height (Y) above the XZ plane (try single-digit heights) and then configure radii for each GameLight component (i.e. between 1 and 5). You are free to arrange the light objects on the XZ plane with the move tool.
Now we want to show gizmos for these lights.
void OnDrawGizmos() {
var normal = Quaternion.Euler(_planeAngles) * Vector3.up;
if(_lights == null) return;
for(int i = 0; i < _lights.Length; i++) {
var light = _lights[i];
if(light == null) continue;
var lightProj = nearestPlanePoint(light.position, _planeOrigin, normal);
drawPoint(...);
drawCircle(...);
}
}
First we get a surface normal (direction vector) from plane angles. If you keep the angles at (0, 0) it’ll remain flat (Vector3.up).
Then if there are any GameLight instances in the list, we run through each, find the nearest point on the plane and draw a point and a circle there. You’ll find the drawing functions in the final code. I’m deliberately omitting some stuff to make this more readable.
As you can see I’m referring to light.position which we don’t have yet. Let’s enable that by adding the following to GameLight.
public class GameLight : MonoBehaviour {
// [SerializeField] ...
Transform _xf;
public Vector3 position => _xf.position;
// ...
void Awake() {
_xf = gameObject.transform;
}
// ...
}
By caching the transform in Awake, we can quickly get the light’s position when we need it.
This Awake won’t be called in the editor however, so let’s enable this by doing
[ExecuteAlways] // <<<<
[RequireComponent(typeof(Light))]
public class GameLight : MonoBehaviour {
Next, I wanted to implement an improvised fall-off ramp to configure the projected light’s intensity. To do this I played around in Desmos. I wanted something that can do a linear ramp (x=y), but also offers a few parameters that will produce an exponential curve to let us introduce “ease out” or “ease in” tempo in how the values are distributed. This took most of my time, but I have to keep it short:
p [1…inf] makes the curve stronger (must be >= 1; if it’s 1, the ramp is linear),n [-1…1] interpolates between two shape variants (if it’s 0, the ramp is linear),The base functions are very simple
y1 = x^p, and
y2 = 1 - (1 - x)^p
But then it got complicated once I combined these in order to interpolate between them. Here you can play with the original (where 0 <= n <= 1).
// in GameLight
float value_func(float x, float n, float p) {
var v = n <= 0f? (n + 1f) * x - n * (1f - pow(1f - x, p))
: n * pow(x, p) + (1f - n) * x;
return 1f - v;
}
Then I added more stuff to GameLight
using System;
using UnityEngine;
[ExecuteAlways]
[RequireComponent(typeof(Light))]
public class GameLight : MonoBehaviour {
[SerializeField] [Min(1E-5f)] float _radius = 1f;
[SerializeField] [Range(-1f, 1f)] float _rampBias = 0f;
[SerializeField] [Range(1f, 6f)] float _rampPower = 2.2f; // I like 2.2
[SerializeField] [ColorUsage(showAlpha: false)] Color _color;
Light _light;
Transform _xf;
public float radius => _radius;
public float rampBias => _rampBias;
public float rampPower => _rampPower;
public Color gizmoColor => setAlpha(_color);
public Color trueColor => setAlpha(_light.color); // easy access to light's true color
public Vector3 position => _xf.position;
void Awake() {
_xf = gameObject.transform;
_light = GetComponent<Light>(); // cache the Light component as well
}
float value_func(float x, float n, float p) { ... }
//...
}
Then we add the three public methods we’ll actually call from GameLightsTest
// Value accepts n in the [0..1] interval
public float Value(float n) => value_func(sat(n), _rampBias, _rampPower); // result [0..1]
// Contribution takes into account the radius and takes in distance [0..inf]
public float Contribution(float distance) => Value(distance / _radius); // result [0..1]
// Color contribution modifies the color according to contribution %
public Color ColorContribution(float distance, Color color) => setAlpha(color * Contribution(distance));
Now to make use of all this we need to add another component GameLightSampler.
Attach it to a new object in the scene, call it ‘sampler’ or something like that.
Same trick as before, we use this only to have something to move around.
using UnityEngine;
[ExecuteAlways]
public class GameLightSampler : MonoBehaviour {
Transform _xf;
public Vector3 position => _xf.position;
void Awake() => _xf = gameObject.transform;
}
Now we can go back to GameLightsTest and finish this.
using System;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
[ExecuteInEditMode]
public class GameLightsTest : MonoBehaviour {
[Header("Objects")]
[SerializeField] GameLightSampler _sampler; // for hooking the sampler with this demo
[SerializeField] GameLight[] _lights;
[Space]
[Header("Plane")]
[SerializeField] Vector3 _planeOrigin;
[SerializeField] Vector2 _planeAngles;
[Space]
[Header("Gizmos")]
[SerializeField] bool _showLightPoints;
[SerializeField] bool _showLightCircles;
[Space]
[SerializeField] [Min(0f)] float _gizmoScale = 1f;
#if UNITY_EDITOR
void OnDrawGizmos() {
var normal = Quaternion.Euler(_planeAngles) * Vector3.up;
// draw plane gizmo
// ...
if(_lights == null) return;
var mix = Color.clear;
var samplerPoint = Vector3.zero;
if(_sampler != null) samplerPoint = nearestPlanePoint(_sampler.position, _planeOrigin, normal);
for(int i = 0; i < _lights.Length; i++) {
var light = _lights[i];
if(light == null) continue;
var lightPoint = nearestPlanePoint(light.position, _planeOrigin, normal);
drawPoint(...) // draws light point in light's true color
drawCircle(...) // draws concentric circles in light's gizmo color
if(_sampler != null) {
var d = Vector3.Distance(lightPoint, samplerPoint);
mix += light.ColorContribution(d, light.trueColor);
}
}
if(_sampler != null) {
mix /= mix.a; // we can use the accumulated alpha to find the average
drawPoint(...); // show final 'mix' color
}
}
Vector3 nearestPlanePoint(Vector3 p, Vector3 po, Vector3 pn)
=> Vector3.ProjectOnPlane(p - po, pn) + po;
#endif
}
Full code behind the spoilers
GameLight
using System;
using UnityEngine;
[ExecuteAlways]
[RequireComponent(typeof(Light))]
public class GameLight : MonoBehaviour {
[SerializeField] [Min(1E-5f)] float _radius = 1f;
[SerializeField] [Range(-1f, 1f)] float _rampBias = 0f;
[SerializeField] [Range(1f, 6f)] float _rampPower = 2.2f;
[SerializeField] [ColorUsage(showAlpha: false)] Color _color;
Light _light;
Transform _xf;
public float radius => _radius;
public float rampBias => _rampBias;
public float rampPower => _rampPower;
public Color gizmoColor => setAlpha(_color);
public Color trueColor => setAlpha(_light.color);
public Vector3 position => _xf.position;
void Awake() {
_xf = gameObject.transform;
_light = GetComponent<Light>();
}
public float Value(float n) => value_func(sat(n), _rampBias, _rampPower); // 0 <= n <= 1
public float Contribution(float distance) => Value(distance / _radius); // result in %
public Color ColorContribution(float distance, Color color) => setAlpha(color * Contribution(distance));
float value_func(float x, float n, float p) {
var v = n <= 0f? (n + 1f) * x - n * (1f - pow(1f - x, p))
: n * pow(x, p) + (1f - n) * x;
return 1f - v;
}
static float pow(float b, float p) => MathF.Pow(b, p);
static float sat(float n) => n < 1f? n > 0f? n : 0f : 1f;
static Color setAlpha(Color c, float alpha = 1f) => new(c.r, c.g, c.b, alpha);
}
GameLightSampler
using UnityEngine;
[ExecuteAlways]
public class GameLightSampler : MonoBehaviour {
Transform _xf;
public Vector3 position => _xf.position;
void Awake() => _xf = gameObject.transform;
}
GameLightsTest
using System;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
[ExecuteInEditMode]
public class GameLightsTest : MonoBehaviour {
[Header("Objects")]
[SerializeField] GameLightSampler _sampler;
[SerializeField] GameLight[] _lights;
[Space]
[Header("Plane")]
[SerializeField] Vector3 _planeOrigin;
[SerializeField] Vector2 _planeAngles;
[Space]
[Header("Gizmos")]
[SerializeField] bool _showPlaneGizmo = true;
[SerializeField] bool _showLightPoints = true;
[SerializeField] bool _showLightCircles = true;
[Space]
[SerializeField] [Min(0f)] float _gizmoScale = 1f;
#if UNITY_EDITOR
void OnDrawGizmos() {
var samplerAvailable = _sampler != null;
var normal = Quaternion.Euler(_planeAngles) * Vector3.up;
if(_showPlaneGizmo) {
drawSeg(_planeOrigin, _planeOrigin + normal, Color.green, 2f);
drawArrow(_planeOrigin + normal, normal, .2f, Color.green);
drawSeg(_planeOrigin, _planeOrigin + perp(normal), Color.red, 2f);
drawRect(_planeOrigin, Vector2.one, normal, 0f, Color.white, 1f);
}
if(_lights == null) return;
var mix = Color.clear;
var samplerPoint = Vector3.zero;
if(samplerAvailable) samplerPoint = nearestPlanePoint(_sampler.position, _planeOrigin, normal);
for(int i = 0; i < _lights.Length; i++) {
var light = _lights[i];
if(light == null) continue;
var lightPoint = nearestPlanePoint(light.position, _planeOrigin, normal);
if(_showLightPoints) drawPoint(lightPoint, normal, .1f, light.trueColor);
if(_showLightCircles) {
drawCircle(lightPoint, light.radius, normal, 48, light.gizmoColor, 3f);
for(int k = 0; k < 3; k++) {
var pct = (k + 1f) * .25f; // 0.25, 0.5, 0.75
var clr = setAlpha(light.gizmoColor, k == 1? 1f : .5f);
drawCircle(lightPoint, light.Value(pct) * light.radius, normal, 48, clr, k == 1? 2f : 1f);
}
}
if(samplerAvailable) {
var d = Vector3.Distance(lightPoint, samplerPoint);
mix += light.ColorContribution(d, light.trueColor);
}
}
if(samplerAvailable) {
mix /= mix.a;
drawPoint(samplerPoint, normal, .2f, mix);
}
}
Vector3 nearestPlanePoint(Vector3 p, Vector3 po, Vector3 pn)
=> Vector3.ProjectOnPlane(p - po, pn) + po;
//-----------------------------------
// gizmos, math, utility
void drawSeg(Vector3 a, Vector3 b, Color? color = null, float thickness = 1f) {
if(color.HasValue) Handles.color = color.Value;
Handles.DrawLine(a, b, thickness);
}
void drawCircle(Vector3 c, float r, Vector3 n, int segments = 48, Color? color = null, float thickness = 1f)
=> drawArc(c, r, n, 0f, tau, segments, color, thickness);
void drawArc(Vector3 c, float r, Vector3 n, float angle, float theta, int segments = 24, Color? color = null, float thickness = 1f) {
if(color.HasValue) Handles.color = color.Value;
segments = max(1, segments);
var last = Vector3.zero;
var step = theta / segments;
var spnt = r * perp(n);
for(int i = 0; i <= segments; i++) {
var cur = angleAxis(i * step + angle, n) * spnt + c;
if(i > 0) drawSeg(last, cur, null, thickness);
last = cur;
}
}
static readonly Vector2[] _rverts = new Vector2[] { new(-1f, 1f), new(1f, 1f), new(1f, -1f), new(-1f, -1f) };
void drawRect(Vector3 c, Vector2 s, Vector3 n, float rot, Color? color = null, float thickness = 1f) {
if(color.HasValue) Handles.color = color.Value;
s *= .5f;
var last = Vector3.zero;
var reor = fromTo(Vector3.forward, n);
for(int i = 0; i <= 4; i++) {
var he = _rverts[i < 4? i : 0];
var cur = angleAxis(rot, n) * reor * v3(cmul(he, s)) + c;
if(i > 0) drawSeg(last, cur, null, thickness);
last = cur;
}
}
void drawPoint(Vector3 p, Vector3 n, float radius, Color? color = null) {
if(color.HasValue) Handles.color = color.Value;
Handles.DrawSolidDisc(p, n, radius * _gizmoScale);
}
void drawArrow(Vector3 p, Vector3 d, float scale, Color? color = null) {
if(color.HasValue) Handles.color = color.Value;
Handles.ConeHandleCap(0, p, fromTo(Vector3.forward, d), scale * _gizmoScale, EventType.Repaint);
}
static readonly float pi = MathF.PI;
static readonly float tau = 2f * pi;
static int max(int a, int b) => Math.Max(a, b);
static Color setAlpha(Color c, float alpha = 1f) => new(c.r, c.g, c.b, alpha);
static float rsqrt(float n) => 1f / MathF.Sqrt(n);
static Vector3 v3(Vector2 v) => new Vector3(v.x, v.y, 0f);
static float sum(Vector3 v) => v.x + v.y + v.z;
static float dot(Vector3 a, Vector3 b) => sum(cmul(a, b));
static Vector2 cmul(Vector2 a, Vector2 b) => new(a.x * b.x, a.y * b.y);
static Vector3 cmul(Vector3 a, Vector3 b) => new(a.x * b.x, a.y * b.y, a.z * b.z);
static Vector3 cross(Vector3 a, Vector3 b) => Vector3.Cross(a, b);
static Quaternion angleAxis(float rad, Vector3 axis) => Quaternion.AngleAxis(rad * Mathf.Rad2Deg, axis);
static Quaternion fromTo(Vector3 from, Vector3 to) => Quaternion.FromToRotation(from, to);
// fast perp
static public Vector3 perp(Vector3 v, bool normalize = true) {
var im = 1f;
var sqrm = v.x * v.x + v.y * v.y; // (0,0,1) x (x,y,z)
if(sqrm > 0f) {
if(normalize) im = rsqrt(sqrm);
return new(-v.y * im, v.x * im, 0f);
}
sqrm = v.y * v.y + v.z * v.z; // (1,0,0) x (x,y,z)
if(normalize) im = rsqrt(sqrm);
return new(0f, -v.z * im, v.y * im);
}
#endif
}
Don’t forget to set the actual (true) light colors to something like red, green, blue, and to arrange them nearby to each other. Then try moving the sampler inside their influence areas (depicted by circles) and, as you move it, you should see the sampler’s disc changing color.
The full code might look intimidating to you (and it probably is), but I’ll remind you that only 10 or so lines in GameLightsTest are the actual solution (in OnDrawGizmos) and GameLight is what you actually need for your game.
Tomorrow I’ll try to convert this into a dedicated class that you can use normally in your game (no visualizations and editor shenanigans) and show you how to compute monochromatic (combined) intensity for your game.

Oh btw, the thicker middle concentric ring shows the light’s fall off at exactly 50% (the faint ones are 25% and 75%).
Edit: small updates (and picture)
Edit2: moved ‘mix’ averaging outside of the loop (fix)
Edit3: typos
Edit4: modified the final code to actually use bools for visualization toggling
Okay so like i just finished a 20 hour shift so im really tired BUT I cannot wait to read through everything properly because HOLY COW THAT LOOKS SO COOL.
Looking forward to learning some new things ![]()
I didn’t have much time to advance this over the weekend, but feel free to write if you have any questions.
Will continue working on this during the week (not much is left anyway), and once the proper solution is done you’ll also have this extra in-editor visualizer that lets you debug and configure stuff more easily.
That said, this is just a part of the bigger puzzle, as you’re still expected to optimize the amount of lights you’re taking into account at any one moment (there should never be more than 3-4), but also to cast rays if you have occluded lights in your scene (you probably do unless you’re making a soccer field game).
I can’t do everything (I have a full time job as well), this is only to point you in the right direction.