A GameObjectSystem that scales a scene's AmbientLight and DirectionalLight colours to darken or restore imported maps. It stores original colours per-component, applies configured multipliers (ambient, sky, sun), exposes console commands to set colours, trigger rebakes, report status, reset, and toggle behavior, and ensures values are applied periodically.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Scales down whatever ambient and sun a ported map arrived with, so the map goes DARK and its
/// own light fixtures become the light.
///
/// ⛔ A MULTIPLIER, NOT A COLOUR, AND THAT IS THE WHOLE REASON THIS WORKS ON THIRTEEN MAPS. Every
/// imported `light_environment` carries its own authored colour — Basalt's sun is a pale blue-white
/// `0.583 0.767 0.797` over a `0.169 0.176 0.224` sky. Writing an absolute colour here would
/// flatten every map to the same one and throw away the character the original mapper chose.
/// Scaling keeps the hue and takes the level.
///
/// ⚠️ A MAP MAY NAME ITS OWN COLOURS NOW (`LightingSettings.SunColour`, `SkyColour`, `AmbientColour`, 2026-09-29), for a map
/// imported in grey that no level of grey makes look like daylight (City Uprising). Blank keeps the imported colour, so the rule
/// above still holds for every map that names none.
///
/// ⛔ AND IT SCALES FROM THE ORIGINAL EVERY TIME, NEVER FROM THE CURRENT VALUE. Reading the live
/// colour and multiplying it would compound: at 0.2 the map is a fifth as bright on the first
/// frame, a twenty-fifth on the second, black by the third — and the cause is invisible, because
/// each individual frame did exactly what it was told. This project has already paid for that once
/// in `ModelTuner`, where a bone read in one space and written in another doubled its offset every
/// frame until the position reached 116,818,000.
///
/// ⚠️ RUNTIME, NOT A SCENE EDIT, FOR TWO REASONS. The map's lights live inside the `MapInstance`'s
/// loaded copy, and INSTRUCTIONS has a whole section on what happens when tool passes and the
/// editor fight over `scenes/maps/*.scene` — *"81 objects became 111 again, the AmbientLight
/// vanished"*. Verified: driving the values through the MapInstance leaves the imported file's
/// mtime untouched. The second reason is that lighting step 5 needs the ambient to be runtime-
/// driven anyway, so zones can retint it as the player crosses between them.
///
/// # MAPPORT: lighting step 3
/// </summary>
public sealed class NZAtmosphere : GameObjectSystem<NZAtmosphere>
{
public NZAtmosphere( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, 0, Tick, "nz.atmos" );
}
/// <summary>`nz_atmos 0` — leave every map exactly as it was imported.</summary>
[ConVar( "nz_atmos" )] public static bool Enabled { get; set; } = true;
/// <summary>
/// The numbers themselves live in the MAP'S CONFIG, not here.
///
/// ⛔ ONE SOURCE OF TRUTH, AND IT IS THE ONE THAT SAVES. Statics would mean the darkness is
/// whatever the last console command left behind — right until a map load, at which point it
/// silently persists onto a map that wanted its sun. Per-map is also the only correct answer:
/// `gm_island_d` has no emissive fixtures to see by, so a global "dark" would make it
/// unplayable rather than moody.
///
/// ⚠️ NEVER NULL. `ActiveConfig.Current` is a fresh MapConfig before anything is loaded, whose
/// Lighting is all 1.0 — as imported, which is exactly the right behaviour for a map with no
/// config yet.
/// </summary>
static LightingSettings Cfg => ActiveConfig.Current?.Lighting ?? new LightingSettings();
/// <summary>
/// How much of the map's flat fill to keep. 1 is as imported.
///
/// ⚠️ THIS IS THE ONE THAT DECIDES WHETHER A MAP IS DARK. Measured on Basalt: with the sun and
/// sky zeroed the interior barely changed, and with the ambient zeroed as well it went COMPLETELY
/// black but for two white marks. Practically all of that map's light is this number.
/// </summary>
public static float Ambient => BakeOverride ?? Cfg.Ambient;
/// <summary>
/// While a bake runs, the ambient the probes should see — see `LightingSettings.BakeAmbient`.
///
/// ⛔ IT HAS TO OVERRIDE RATHER THAN BE WRITTEN ONCE, because this class re-applies the scaled
/// colours every 0.25s from the config. Setting the lights directly before a bake would simply
/// be undone by the next tick, mid-bake, and the probes would capture whatever the poll had just
/// restored — a race that would look like an intermittently dim bake.
///
/// ⚠️ AND IT COVERS SKY AS WELL AS AMBIENT, because on these maps the sky term is half the
/// fill and raising only one leaves the bake short.
/// </summary>
public static float? BakeOverride { get; set; }
/// <summary>
/// The second ambient term, the one that lives on the DirectionalLight.
///
/// ⛔ SEPARATE FROM `Ambient`, AND FORGETTING IT IS WHY "THE AMBIENT IS OFF" CAN STILL
/// LOOK LIT. `DirectionalLight.SkyColor` is a sky-tinted fill applied independently of the
/// `AmbientLight` component; zeroing one alone left Basalt's interior almost unchanged. Both
/// have to come down together or the map does not get dark.
/// </summary>
public static float Sky => BakeOverride ?? Cfg.Sky;
/// <summary>The directional's own light. Outdoors this is the sun; indoors on a map like Basalt
/// it contributes almost nothing.</summary>
public static float Sun => Cfg.Sun;
/// <summary>
/// Whether the directional still casts shadows. `nz_atmos_set sunshadows 0`.
///
/// ⚠️ THE ONLY SHADOW-CASTER MOST OF THESE MAPS HAVE, and on Basalt it is a 4-cascade one doing
/// almost nothing indoors. Worth being able to switch off and measure, given shadows are the
/// single biggest cost in this engine — see `LightBake`.
/// </summary>
public static bool SunShadows => Cfg.SunShadows;
/// <summary>
/// What each light looked like before we touched it. See the compounding note in the class doc.
///
/// ⚠️ KEYED BY COMPONENT, so a map load that brings new lights captures them fresh rather than
/// scaling a new map's lights by an old map's baseline.
/// </summary>
readonly Dictionary<AmbientLight, Color> _ambientRest = new();
readonly Dictionary<DirectionalLight, (Color Light, Color Sky, bool Shadows)> _sunRest = new();
TimeSince _sinceScan;
void Tick()
{
// ⚠️ POLLED, NOT EVERY FRAME. `GetAllComponents` walks the scene, and a map's lights change
// only when a map loads. A quarter second is imperceptible to a mapper and costs nothing.
if ( _sinceScan < 0.25f ) return;
_sinceScan = 0f;
Apply( Scene );
}
// ⛔ THE AUTOMATIC BAKE USED TO LIVE HERE AND IT WAS ACTIVELY HARMFUL. It fired the moment the
// map's AmbientLight appeared, which is map-LOAD time — before `NZGame.ShowConfig` has built a
// single placeable. On Basalt the map's only real light source is a LAVA damage wall built by
// `DamageWallManager.Rebuild()`, so this reliably baked a room with no lava in it.
//
// ⚠️ AND IT MASQUERADED AS DECAY. A hand-run `nz_light_bake` lit the map perfectly, then it
// went dark again "on its own" — reported twice as the bake being unreliable. It was this,
// overwriting a good bake with a pre-config snapshot.
//
// ⚠️ WHAT THE PROBES CAPTURE IS WHATEVER EXISTS WHEN THEY RENDER, so the trigger belongs with
// whatever finishes building the world, not with a light appearing. It is now the last line of
// `NZGame.ShowConfig`, via `LightBake.BakeIfConfigured`.
/// <summary>Capture-then-scale every light in the scene. Idempotent by construction.</summary>
public static void Apply( Scene scene )
{
var self = Current;
if ( self is null || !scene.IsValid() ) return;
// ⛔ THE EDITOR'S OWN SCENE KEEPS ITS LIGHTS AS IMPORTED (2026-09-28). This system ticks there as well, and
// `ActiveConfig.Current` outlives a game: after playing basalt, the editor's own map sat at basalt's darkness — countdown's
// light_environment measured at 0.001 of its ambient, 0.2 of its sky and 0.1 of its sun (`NZPostProcess` had the same leak).
var on = Enabled && !scene.IsEditor;
// ⛔ A BAKE SET UP IN THE EDITOR'S SCENE HAS ITS OWN LIGHTS (`MapBake`, 2026-09-29): putting them back to imported here, four
// times a second, would bake the map under other lights than the game's
if ( scene.IsEditor && MapBake.Prepared( scene ) ) return;
// ⚠️ THE CONFIG'S COLOUR, WHERE IT NAMES ONE, IN PLACE OF THE IMPORTED ONE (`LightingSettings.SunColour`…), then the multiplier
var cfg = Cfg;
foreach ( var a in scene.GetAllComponents<AmbientLight>() )
{
if ( !a.IsValid() ) continue;
if ( !self._ambientRest.TryGetValue( a, out var rest ) )
self._ambientRest[a] = rest = a.Color;
a.Color = on ? Scale( LightingSettings.ColourOr( cfg.AmbientColour, rest ), Ambient ) : rest;
}
foreach ( var d in scene.GetAllComponents<DirectionalLight>() )
{
if ( !d.IsValid() ) continue;
if ( !self._sunRest.TryGetValue( d, out var rest ) )
self._sunRest[d] = rest = (d.LightColor, d.SkyColor, d.Shadows);
d.LightColor = on ? Scale( LightingSettings.ColourOr( cfg.SunColour, rest.Light ), Sun ) : rest.Light;
d.SkyColor = on ? Scale( LightingSettings.ColourOr( cfg.SkyColour, rest.Sky ), Sky ) : rest.Sky;
d.Shadows = on ? SunShadows : rest.Shadows;
}
// ⚠️ DROP DEAD ENTRIES, or a map that has been loaded and unloaded a dozen times leaves a
// dozen dead lights in here holding references the scene has finished with.
self._ambientRest.Keys.Where( k => !k.IsValid() ).ToList()
.ForEach( k => self._ambientRest.Remove( k ) );
self._sunRest.Keys.Where( k => !k.IsValid() ).ToList()
.ForEach( k => self._sunRest.Remove( k ) );
}
/// <summary>⚠️ ALPHA IS LEFT ALONE. It is not a brightness on any of these and scaling it would
/// quietly change what "fully opaque" means.</summary>
static Color Scale( Color c, float f )
=> new( c.r * f, c.g * f, c.b * f, c.a );
/// <summary>
/// `nz_dark [amount]` — the whole step-3 look in one command. 0.15 by default.
///
/// ⚠️ ONE COMMAND BECAUSE IT IS THREE NUMBERS AND TWO OF THEM ARE NOT OBVIOUS. Turning down
/// only `nz_atmos_ambient` leaves the sky term lighting the map and looks like the command did
/// nothing — which is exactly the trap the `nz_atmos_sky` note describes.
/// </summary>
[ConCmd( "nz_dark" )]
public static void Dark( float amount = 0.15f )
{
Enabled = true;
var c = ActiveConfig.Current?.Lighting;
if ( c is null ) { Log.Warning( "[nz-atmos] no config" ); return; }
c.Ambient = c.Sky = c.Sun = amount;
// ⛔ THE BAKE COMES WITH IT, BECAUSE A DARK MAP WITHOUT ONE IS BLACK. Measured: at ambient
// 0 the only things visible in Basalt were the two light strips. The probes are what carry
// light off those fixtures onto the concrete, so darkening without baking is not a dimmer
// version of the look, it is a different and unusable one.
if ( amount < 0.9f ) c.Bake = true;
ActiveConfig.NotifyChanged();
Apply( Game.ActiveScene );
Rebake();
Report();
Log.Info( "[nz-atmos] `nz_save` to keep this with the map's config" );
}
/// <summary>
/// `nz_atmos_set <ambient|sky|sun|sunshadows|bake|density> <value>` — one knob at a time.
///
/// ⚠️ NAMED PARTS RATHER THAN A CONVAR EACH. These live in the map's config now, so a ConVar
/// would be a second copy that disagrees with the saved one the moment either is touched.
/// </summary>
[ConCmd( "nz_atmos_set" )]
public static void Set( string part = "", float value = float.NaN )
{
var c = ActiveConfig.Current?.Lighting;
if ( c is null ) { Log.Warning( "[nz-atmos] no config" ); return; }
if ( string.IsNullOrWhiteSpace( part ) || float.IsNaN( value ) )
{
Log.Info( "[nz-atmos] nz_atmos_set <ambient|bakeambient|sky|sun|sunshadows|bake|density> <value>" );
Report();
return;
}
switch ( part.ToLowerInvariant() )
{
case "ambient": c.Ambient = value; break;
case "bakeambient": c.BakeAmbient = value; break;
case "sky": c.Sky = value; break;
case "sun": c.Sun = value; break;
case "sunshadows": c.SunShadows = value != 0f; break;
case "bake": c.Bake = value != 0f; break;
case "density": c.BakeDensity = (int)MathF.Max( 1f, value ); break;
default:
Log.Warning( $"[nz-atmos] no such part '{part}' — "
+ "ambient, bakeambient, sky, sun, sunshadows, bake, density" );
return;
}
ActiveConfig.NotifyChanged();
Apply( Game.ActiveScene );
Report();
}
/// <summary>`nz_atmos_rebake` — bake the indirect volume again, at the config's density.
///
/// ⚠️ NEEDED AFTER ANY LIGHTING CHANGE. The probes store what the map looked like AT BAKE
/// TIME, so brightening a panel or dropping the ambient leaves the bounce describing the old
/// map until this is run.</summary>
/// <summary>
/// `nz_atmos_colour <sun|sky|ambient> <#rrggbb | off>` — a light's colour in place of the imported one, `off` for the
/// imported one again (`LightingSettings.SunColour`…). Bare, it says what is set. `nz_save` keeps it; a baked map needs baking
/// again for its bounce light to follow (`nz_map_bake`).
/// </summary>
[ConCmd( "nz_atmos_colour" )]
public static void Colour( string part = "", string value = "" )
{
var c = ActiveConfig.Current?.Lighting;
if ( c is null ) { Log.Warning( "[nz-atmos] no config" ); return; }
var v = value.Trim();
if ( v.Equals( "off", StringComparison.OrdinalIgnoreCase ) || v == "-" ) v = "";
else if ( v.Length > 0 && Color.Parse( v ) is null ) { Log.Warning( $"[nz-atmos] '{value}' is not a colour — #RRGGBB, or off" ); return; }
switch ( part.Trim().ToLowerInvariant() )
{
case "sun": c.SunColour = v; break;
case "sky": c.SkyColour = v; break;
case "ambient": c.AmbientColour = v; break;
default:
Log.Info( "[nz-atmos] nz_atmos_colour <sun|sky|ambient> <#rrggbb|off> — now: sun "
+ $"'{c.SunColour}', sky '{c.SkyColour}', ambient '{c.AmbientColour}' (blank: as imported)" );
return;
}
ActiveConfig.NotifyChanged();
Apply( Game.ActiveScene );
Report();
Log.Info( "[nz-atmos] `nz_save` to keep it with the map's config; a baked map needs baking again to match (nz_map_bake)" );
}
[ConCmd( "nz_atmos_rebake" )]
public static void Rebake()
{
var c = ActiveConfig.Current?.Lighting;
if ( c is null || !c.Bake ) { Log.Info( "[nz-atmos] baking is off (nz_atmos_set bake 1)" ); return; }
LightBake.Bake( c.BakeDensity );
}
/// <summary>`nz_atmos_reset` — put every map back exactly as it was imported.</summary>
[ConCmd( "nz_atmos_reset" )]
public static void Reset()
{
var c = ActiveConfig.Current?.Lighting;
if ( c is null ) { Log.Warning( "[nz-atmos] no config" ); return; }
c.Ambient = c.Sky = c.Sun = 1f;
c.SunShadows = true;
c.Bake = false;
Enabled = true;
ActiveConfig.NotifyChanged();
Apply( Game.ActiveScene );
// ⚠️ THE VOLUME HAS TO GO TOO. Leaving it behind means the ambient is restored and still
// overridden by stale probes — "reset" that does not look reset.
LightBake.Volume( 0 );
Log.Info( "[nz-atmos] back to as-imported (indirect volume removed too)" );
}
/// <summary>`nz_atmos_report` — what the map's lights are, and what they started as.</summary>
[ConCmd( "nz_atmos_report" )]
public static void Report()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-atmos] no scene" ); return; }
Apply( scene );
var self = Current;
var amb = scene.GetAllComponents<AmbientLight>().Where( x => x.IsValid() ).ToList();
var sun = scene.GetAllComponents<DirectionalLight>().Where( x => x.IsValid() ).ToList();
Log.Info( $"[nz-atmos] {( scene.IsEditor ? "the editor's own scene - as imported" : Enabled ? "on" : "OFF - as imported" )}"
+ $" ambient x{Ambient:0.###} sky x{Sky:0.###} sun x{Sun:0.###}"
+ $" sun shadows {( SunShadows ? "on" : "off" )}" );
Log.Info( $"[nz-atmos] {amb.Count} ambient light(s), {sun.Count} directional(s)" );
foreach ( var a in amb )
{
var rest = self is not null && self._ambientRest.TryGetValue( a, out var r ) ? r : a.Color;
Log.Info( $"[nz-atmos] ambient now {Fmt( a.Color )} was {Fmt( rest )}" );
}
foreach ( var d in sun )
{
// ⚠️ THE FALLBACK TUPLE IS NAMED EXPLICITLY. Written as `(d.LightColor, d.SkyColor,
// d.Shadows)` the compiler infers the names from the members, giving a tuple whose
// fields are `LightColor`/`SkyColor` — which does not match the dictionary's
// `Light`/`Sky` and fails to compile on the ternary rather than at the tuple.
var rest = self is not null && self._sunRest.TryGetValue( d, out var r )
? r
: (Light: d.LightColor, Sky: d.SkyColor, Shadows: d.Shadows);
Log.Info( $"[nz-atmos] sun now {Fmt( d.LightColor )} sky {Fmt( d.SkyColor )}"
+ $" was {Fmt( rest.Light )} / {Fmt( rest.Sky )}" );
}
// ⚠️ NAMES THE TRAP. "I turned the ambient down and nothing happened" is the sky term, every
// time, and it is invisible from the values alone unless you know to look at both.
if ( Enabled && Ambient < 0.5f && Sky >= 0.9f )
Log.Info( "[nz-atmos] note: ambient is down but SKY is still full — on these maps the"
+ " sky term alone keeps interiors lit. `nz_dark` sets all three." );
}
static string Fmt( Color c ) => $"({c.r:0.##},{c.g:0.##},{c.b:0.##})";
}