Editor utility for setting up, previewing and tearing down a scene's indirect light bake. It positions and configures an IndirectLightVolume from a MapConfig, creates temporary bake-only lights and placed lights, stores original light colours on unsaved marker objects, and restores them on Finish.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace NZombies;
/// <summary>
/// A MAP'S LIGHT BAKE, SET UP AND PUT BACK — any map's, in the editor, round the engine's `IndirectLightVolume.BakeProbes`
/// (`Editor/MapLightBake.cs`: `nz_map_bake <map>`). Made for City Uprising (2026-09-29, the user: *"i want to remake its
/// lighting, actually give it more natural light baked in"*). Basalt keeps its own (`HexPlatforms.PrepareBake`), which also
/// builds its lava and its arena for the probes to see; a map with nothing the game builds that should light it needs only this.
///
/// ⛔ THE LIGHTS ARE SET AS THE GAME SETS THEM IN PLAY (`NZAtmosphere.Apply`): the config's colour where it names one
/// (`LightingSettings.SunColour` …), else the imported one, times its multiplier — the ambient and the sky at the bake's own level
/// when `BakeAmbient` asks for one. The probes keep what they saw, so a bake under other lights is a bake of another map.
///
/// ⛔ AND WHILE A BAKE IS SET UP, `NZAtmosphere` LEAVES THE SCENE'S LIGHTS ALONE (<see cref="Prepared"/>). It runs in the editor's
/// scene too, and there it puts every light back to its imported colour four times a second — which would have undone this setup
/// within a quarter of a second, and does undo Basalt's own since that rule came in (2026-09-28).
///
/// ⚠️ THE IMPORTED COLOURS ARE KEPT ON UNSAVED, TAGGED MARKERS IN THE SCENE, not in statics, so a hotload during the minutes a
/// bake takes does not lose them — Basalt's way (`HexPlatforms.BakeRestMarker`).
/// </summary>
public static class MapBake
{
/// <summary>The tag on what a set-up bake leaves in the scene.</summary>
public const string Tag = "nz_mapbake";
/// <summary>What a marker of a light's colours before the bake is named, before the light's object id and the colours.</summary>
const string MarkerPrefix = "nz_mapbake_rest ";
/// <summary>
/// Is a bake set up in this scene — this one's markers standing, or Basalt's (`HexPlatforms.BakeProxyTag`)?
/// `NZAtmosphere` leaves such a scene's lights as the bake set them.
/// </summary>
public static bool Prepared( Scene scene )
=> scene.IsValid() && scene.GetAllObjects( false ).Any( x => x.Tags.Has( Tag ) || x.Tags.Has( HexPlatforms.BakeProxyTag ) );
/// <summary>
/// Set the scene up for the bake: its volume fitted to the config's bounds and density, with no old textures (a bake over
/// old ones yields nothing, `LightBake`), and the map's lights as the game has them in play. What it did, in words; begins
/// "⛔" when it could not.
/// </summary>
public static string Prepare( Scene scene, MapConfig cfg )
{
if ( !scene.IsValid() || cfg is null ) return "⛔ no scene, or no config";
var vol = scene.GetAllComponents<IndirectLightVolume>().FirstOrDefault( v => v.IsValid() );
if ( !vol.IsValid() ) return "⛔ this scene has no Indirect Light Volume — open the map's bake scene";
var l = cfg.Lighting;
if ( l is null || l.BakeSize.Length < 1f ) return "⛔ the config has no bake bounds (Lighting.BakeCenter, BakeSize)";
Finish( scene ); // a second set-up starts from clean
var said = new List<string>();
vol.GameObject.WorldPosition = l.BakeCenter;
vol.GameObject.WorldRotation = Rotation.Identity;
vol.Bounds = new BBox( -l.BakeSize * 0.5f, l.BakeSize * 0.5f );
if ( l.BakeDensity > 0 ) vol.ProbeDensity = l.BakeDensity;
vol.IrradianceTexture = null;
vol.DistanceTexture = null;
vol.RelocationTexture = null;
var n = vol.ProbeCounts;
var size = l.BakeSize;
said.Add( $"the volume: centre {l.BakeCenter}, size {size}, density {vol.ProbeDensity} — {n.x}×{n.y}×{n.z} probes,"
+ $" {size.x / MathF.Max( 1, n.x - 1 ):0}×{size.y / MathF.Max( 1, n.y - 1 ):0}×{size.z / MathF.Max( 1, n.z - 1 ):0}u apart" );
var ambient = l.BakeAmbient > 0f ? l.BakeAmbient : l.Ambient;
var sky = l.BakeAmbient > 0f ? l.BakeAmbient : l.Sky;
said.Add( SetMapLights( scene, l, ambient, sky, null ) );
// ⛔ THE MAP'S OWN LAMPS, FOR THE BAKE ONLY (`LightingSettings.BakeLights`): tagged, so `Finish` takes them with the markers.
// ⚠️ SHADOWED, or a lamp lights the far side of its wall and the probes there bake light the room never gets
int points = 0, spots = 0;
foreach ( var b in l.BakeLights ?? new() )
{
if ( b is null || b.Brightness <= 0f || b.Radius <= 0f ) continue;
var go = scene.CreateObject();
go.Name = $"nz bake light — {b.From}";
go.Flags |= GameObjectFlags.NotSaved;
go.Tags.Add( Tag );
go.WorldPosition = b.Position;
go.WorldRotation = Rotation.From( b.Pitch, b.Yaw, 0f );
var colour = LightingSettings.ColourOr( b.Colour, Color.White ) * (b.Brightness * l.BakeLightScale);
if ( b.Kind == "spot" )
{
var s = go.Components.Create<SpotLight>();
s.LightColor = colour;
s.Radius = b.Radius;
s.ConeOuter = b.ConeOuter;
s.ConeInner = MathF.Min( b.ConeInner, b.ConeOuter );
s.Shadows = true;
spots++;
}
else
{
var p = go.Components.Create<PointLight>();
p.LightColor = colour;
p.Radius = b.Radius;
p.Shadows = true;
points++;
}
}
if ( points + spots > 0 )
said.Add( $"the map's own lamps, for the bake only: {points} point, {spots} spot, brightness ×{l.BakeLightScale:0.##}" );
// ⛔ AND THE CONFIG'S PLACED LIGHTS (`MapConfig.Lights`, 2026-10-01). `MapLightManager` builds them in play, every frame, so
// the probes must see them too, or the bounce describes the map without them. Until Defocus none of the maps baked here had
// any, and this bake left them out (the user: *"i placed a bunch of lights on the config, can you bake those lights instead
// of the sun"*).
var placed = PlaceLights( scene, cfg, true, out var waiting );
if ( placed + waiting > 0 )
said.Add( $"the config's placed lights: {placed}, shadowed for the bake, brightness ×{l.BakeLightScale:0.##}"
+ ( waiting > 0 ? $"; {waiting} that wait for the power left out" : "" ) );
return string.Join( "\n", said );
}
/// <summary>
/// The scene's lights as the game has them IN PLAY, the volume left as it is (`nz_map_bake_look`): the map's sun, sky and
/// ambient at the config's play levels, and its placed lights as `MapLightManager` builds them. No bake-only lamps, since play
/// has none. With the volume switched on and off, this judges a bake against play in the editor. `Finish` puts it back. What it
/// did, in words.
///
/// ⚠️ MADE FOR DEFOCUS (2026-10-01). Its play has no sun and 56 placed lights, while the editor's own scene keeps the imported
/// sun and builds no placed lights, so judged there its bake would have been judged under another map's light.
/// </summary>
public static string Look( Scene scene, MapConfig cfg )
{
if ( !scene.IsValid() || cfg is null ) return "⛔ no scene, or no config";
Finish( scene );
var l = cfg.Lighting ?? new LightingSettings();
var said = new List<string> { SetMapLights( scene, l, l.Ambient, l.Sky, l.SunShadows ) };
var placed = PlaceLights( scene, cfg, false, out var waiting );
said.Add( $"the config's placed lights, as in play: {placed}"
+ ( waiting > 0 ? $"; {waiting} that wait for the power left out" : "" ) );
return string.Join( "\n", said );
}
/// <summary>After the bake: every light back as it was, the markers gone. What it did, in words.</summary>
public static string Finish( Scene scene )
{
if ( !scene.IsValid() ) return "⛔ no scene";
var back = 0;
foreach ( var go in scene.GetAllObjects( false ).Where( x => x.Tags.Has( Tag ) && !x.IsDestroyed ).ToList() )
{
if ( go.Name.StartsWith( MarkerPrefix, StringComparison.Ordinal ) && Restore( scene, go.Name ) ) back++;
go.Destroy();
}
return $"{back} light(s) put back as they were";
}
/// <summary>
/// The map's own ambient and sun set as the game sets them (`NZAtmosphere.Apply`), the ambient and sky at the levels given —
/// each light's colours kept on a marker first, for `Finish`. <paramref name="sunShadows"/>, when given, is the sun's shadows
/// too; `NZAtmosphere` puts those back by itself once the markers are gone. What it set, in words.
/// </summary>
static string SetMapLights( Scene scene, LightingSettings l, float ambient, float sky, bool? sunShadows )
{
var lit = 0;
foreach ( var a in scene.GetAllComponents<AmbientLight>().Where( a => a.IsValid() ).ToList() )
{
Marker( scene, a.GameObject, "ambient", a.Color );
a.Color = Scale( LightingSettings.ColourOr( l.AmbientColour, a.Color ), ambient );
lit++;
}
foreach ( var d in scene.GetAllComponents<DirectionalLight>().Where( d => d.IsValid() ).ToList() )
{
Marker( scene, d.GameObject, "sun", d.LightColor, d.SkyColor );
d.LightColor = Scale( LightingSettings.ColourOr( l.SunColour, d.LightColor ), l.Sun );
d.SkyColor = Scale( LightingSettings.ColourOr( l.SkyColour, d.SkyColor ), sky );
if ( sunShadows is bool shadows ) d.Shadows = shadows;
lit++;
}
return $"{lit} map light(s) set as in play: sun {Name( l.SunColour )} ×{l.Sun:0.###}, sky {Name( l.SkyColour )}"
+ $" ×{sky:0.###}, ambient {Name( l.AmbientColour )} ×{ambient:0.###}";
}
/// <summary>
/// The config's placed lights into the scene, tagged so `Finish` takes them, built as `MapLightManager.Build` builds them —
/// for the bake, shadowed and times `BakeLightScale`. How many; <paramref name="waiting"/>, how many were left out for waiting
/// on the power.
///
/// ⚠️ SHADOWED FOR THE BAKE THOUGH UNSHADOWED IN PLAY, as the lamps are: unshadowed, a light lights the far side of its wall,
/// and the probes there bake bounce the room never gets.
/// ⚠️ TIMES `BakeLightScale` FOR THE BAKE, as the lamps are. The bake keeps only a light's bounce, never its direct light, so a
/// map lit by faint ones can bake darker than its flat fill (Port Klax), and this is the knob.
/// ⚠️ ONE THAT WAITS FOR THE POWER IS LEFT OUT on a map with a switch: it is dark when a game starts (`MapLightManager`). On a map
/// with none, the power is always on, and so is it.
/// </summary>
static int PlaceLights( Scene scene, MapConfig cfg, bool bake, out int waiting )
{
waiting = 0;
var placed = 0;
var scale = bake ? (cfg.Lighting?.BakeLightScale ?? 1f) : 1f;
var hasSwitch = cfg.PowerSwitches?.Count > 0;
foreach ( var m in cfg.Lights ?? new() )
{
if ( m is null || m.Brightness <= 0f || m.Radius <= 0f ) continue;
if ( m.RequiresPower && hasSwitch ) { waiting++; continue; }
var go = scene.CreateObject();
go.Name = $"nz placed light {placed}";
go.Flags |= GameObjectFlags.NotSaved;
go.Tags.Add( Tag );
go.WorldPosition = m.Position;
var col = Color.Parse( m.Color ) ?? Color.White;
var f = m.Brightness * scale;
var p = go.Components.Create<PointLight>();
p.LightColor = new Color( col.r * f, col.g * f, col.b * f, 1f );
p.Radius = m.Radius;
p.Shadows = bake || m.Shadows;
placed++;
}
return placed;
}
static string Name( string colour ) => string.IsNullOrWhiteSpace( colour ) ? "as imported" : colour.Trim();
static Color Scale( Color c, float f ) => new( c.r * f, c.g * f, c.b * f, c.a );
/// <summary>
/// A light's colours before the bake, as an unsaved, tagged object's name. Only a light's first marker counts.
///
/// ⚠️ A MARKER `Finish` HAS DESTROYED DOESN'T COUNT (`IsDestroyed`). `Destroy` waits for the end of the frame, so straight after
/// a `Finish` (the first line of `Prepare` and `Look`) the old markers still stand, and the new light's marker was skipped:
/// `nz_map_bake_look` twice, then `off`, put 0 lights back (2026-10-01).
/// </summary>
static void Marker( Scene scene, GameObject light, string kind, params Color[] colours )
{
var start = $"{MarkerPrefix}{light.Id} {kind} ";
if ( scene.GetAllObjects( false ).Any( x => x.Tags.Has( Tag ) && !x.IsDestroyed && x.Name.StartsWith( start, StringComparison.Ordinal ) ) )
return;
var go = scene.CreateObject();
go.Name = start + string.Join( "|", colours.Select( c =>
string.Join( ",", new[] { c.r, c.g, c.b, c.a }.Select( v => v.ToString( "R", CultureInfo.InvariantCulture ) ) ) ) );
go.Flags |= GameObjectFlags.NotSaved;
go.Tags.Add( Tag );
}
/// <summary>A light's colours put back from its marker's name. Whether the light was found.</summary>
static bool Restore( Scene scene, string name )
{
var parts = name[MarkerPrefix.Length..].Split( ' ', 3 );
if ( parts.Length < 3 || !Guid.TryParse( parts[0], out var id ) ) return false;
var light = scene.Directory.FindByGuid( id );
if ( !light.IsValid() ) return false;
var colours = parts[2].Split( '|' ).Select( s =>
{
var v = s.Split( ',' ).Select( x => float.Parse( x, CultureInfo.InvariantCulture ) ).ToArray();
return new Color( v[0], v[1], v[2], v[3] );
} ).ToArray();
if ( parts[1] == "ambient" && light.Components.Get<AmbientLight>( FindMode.EverythingInSelf ) is AmbientLight a )
{
a.Color = colours[0];
return true;
}
if ( parts[1] == "sun" && colours.Length > 1 && light.Components.Get<DirectionalLight>( FindMode.EverythingInSelf ) is DirectionalLight d )
{
d.LightColor = colours[0];
d.SkyColor = colours[1];
return true;
}
return false;
}
}