Diagnostics/MapBake.cs

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.

File Access
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 &lt;map&gt;`). 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;
	}
}