Editor helper in HexPlatforms that creates temporary, unsaved stand-in objects and markers used to bake indirect lighting in the Basalt scene. PrepareBake positions an IndirectLightVolume to the map config, darkens scene lights and creates proxy geometry for lava walls and arena surfaces; FinishBake removes proxies and restores original light colors.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT'S LIGHT BAKE — what the editor's scene needs for the probes to see the map as the game builds it (2026-09-27).
/// Asked for as *"now we need to bake lights in here too, since it did not have lava before"*, then *"increase both
/// limits, because some high up areas or areas more on the extremes do not get light, like increase the top botom and all
/// sides"*.
///
/// ⛔ THE BAKE IS AN EDITOR ACTION: `Scene > Bake Indirect Light Volumes` on `Assets/scenes/Basalt.scene`, the only place
/// the engine saves the probes' textures (CHANGELOG 2026-09-16 22:02). But the editor's scene holds the map and nothing the
/// game builds from the config at runtime — the lava, the boss arena's floors and its ceiling — and the probes capture
/// whatever exists when they render. So <see cref="PrepareBake"/> builds stand-ins for them, from the same config and the
/// same numbers the game uses, and <see cref="FinishBake"/> takes them away (`Editor/BasaltLightBake.cs`, the menu).
///
/// ⛔ THE STAND-INS ARE NEVER SAVED: each is `NotSaved` and tagged <see cref="BakeProxyTag"/>, so a save between the two
/// steps cannot write one into the scene.
///
/// ⛔ THE VOLUME TAKES THE CONFIG'S BOUNDS, NEVER THE OTHER WAY ROUND. The shipped textures encode a probe grid, and the
/// game lays them over `LightingSettings.BakeCenter` and `BakeSize` (`LightBake.ApplyBaked`): a bake made in other bounds
/// lights the map at the wrong places.
///
/// ⛔ AND THE MAP'S OWN LIGHTS ARE DARKENED FOR IT, as the game darkens them in play (`NZAtmosphere`, which does not run in
/// the editor): an editor bake of the imported, bright light_environment came out bright (2026-09-16). Their colours
/// before are kept in unsaved markers in the scene, so step 2 puts them back even after a hotload.
/// </summary>
public sealed partial class HexPlatforms
{
/// <summary>What every stand-in, and every marker of a light's colours before, carries.</summary>
public const string BakeProxyTag = "nz_bakeproxy";
/// <summary>
/// The hand-made lava stand-in Basalt.scene already had (2026-09-16): a flat box over the lava bed's rectangle. The lava
/// built from the config's own walls supersedes it, so it is switched off for the bake — marked with this — and on again
/// after.
/// </summary>
const string OldLavaProxyName = "BAKE PROXY - lava (disable after baking)", OldLavaProxyOff = "nz_bakeproxy_was_on";
/// <summary>A stand-in, empty, unsaved and tagged.</summary>
static GameObject BakeProxyObject( Scene scene, string name, Vector3 at, Rotation turn )
{
var go = scene.CreateObject();
go.Name = "BAKE PROXY (nz) — " + name;
go.Flags |= GameObjectFlags.NotSaved;
go.Tags.Add( BakeProxyTag );
go.WorldPosition = at;
go.WorldRotation = turn;
return go;
}
/// <summary>A stand-in drawing this model, and solid, as the thing it stands in for is.</summary>
static void BakeProxyModel( GameObject go, Model model, Color tint )
{
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
r.Tint = tint;
go.Components.Create<ModelCollider>().Model = model;
}
/// <summary>
/// Step 1: the volume fitted to the config's bounds, the map's lights darkened as the game darkens them, the old lava
/// stand-in off, and a stand-in for everything the game builds that the probes should see — every config wall drawn in
/// lava, and the boss arena's raised floor, upper floor and ceiling. What it did, in words; begins "⛔" when it could not.
/// </summary>
public static string PrepareBake( 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 Assets/scenes/Basalt.scene";
var said = new List<string>();
FinishBake( scene ); // a second step 1 starts from clean
// the volume: the config's bounds and density, and none of its old textures, as a fresh one has
var l = cfg.Lighting;
if ( l is null || l.BakeSize.Length < 1f ) return "⛔ the config has no bake bounds (Lighting.BakeCenter, BakeSize)";
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" );
// the map's lights, darkened as the game has them in play — the bake's own ambient if the config asks for one
var ambient = l.BakeAmbient > 0f ? l.BakeAmbient : l.Ambient;
var sky = l.BakeAmbient > 0f ? l.BakeAmbient : l.Sky;
var dimmed = 0;
foreach ( var a in scene.GetAllComponents<AmbientLight>().Where( a => a.IsValid() ).ToList() )
{
BakeRestMarker( scene, a.GameObject, "ambient", a.Color );
a.Color = Scaled( a.Color, ambient );
dimmed++;
}
foreach ( var d in scene.GetAllComponents<DirectionalLight>().Where( d => d.IsValid() ).ToList() )
{
BakeRestMarker( scene, d.GameObject, "sun", d.LightColor, d.SkyColor );
d.LightColor = Scaled( d.LightColor, l.Sun );
d.SkyColor = Scaled( d.SkyColor, sky );
dimmed++;
}
said.Add( $"{dimmed} map light(s) darkened as in play: ambient ×{ambient:0.###}, sky ×{sky:0.###}, sun ×{l.Sun:0.###}" );
// the old hand-made lava stand-in, off
foreach ( var old in scene.GetAllObjects( false ).Where( x => x.Name == OldLavaProxyName && x.Enabled ).ToList() )
{
old.Enabled = false;
old.Tags.Add( OldLavaProxyOff );
said.Add( "the old lava stand-in switched off — the config's own lava replaces it" );
}
// every config wall drawn in lava, as `DamageWallManager` draws it: its footprint extruded from its middle down
var lava = 0;
foreach ( var w in cfg.DamageWalls ?? new() )
{
if ( w is null || !(w.Visible || w.VisibleInGame) || !(w.Material ?? "").Contains( "lava", StringComparison.OrdinalIgnoreCase ) )
continue;
var material = Material.Load( w.Material );
var model = w.HasFootprint ? DebrisMesh.Build( w.Footprint, w.Size.z, material ) : null;
if ( model is null ) continue;
var root = BakeProxyObject( scene, $"lava wall {cfg.DamageWalls.IndexOf( w )}", w.Position, w.Rotation );
var vis = scene.CreateObject();
vis.Name = "visual";
vis.Flags |= GameObjectFlags.NotSaved;
vis.SetParent( root, false );
vis.LocalPosition = Vector3.Down * w.Size.z * 0.5f;
vis.LocalRotation = Rotation.Identity;
BakeProxyModel( vis, model, w.Tint );
lava++;
}
said.Add( $"{lava} lava wall(s) from the config" );
// the boss arena: its raised floor, its upper floor, its ceiling — the game's own numbers (`HexPlatforms.Arena.cs`)
var floorTop = ArenaSlabMax.z + ArenaRaise;
var floorMaterial = Material.Load( ArenaFloorMaterial );
var o = ArenaStageOrigin;
var slab = DebrisMesh.Build( new List<Vector2>
{
new Vector2( ArenaSlabMin.x, ArenaSlabMin.y ) - o, new Vector2( ArenaSlabMax.x, ArenaSlabMin.y ) - o,
new Vector2( ArenaSlabMax.x, ArenaSlabMax.y ) - o, new Vector2( ArenaSlabMin.x, ArenaSlabMax.y ) - o,
}, ArenaSlabMax.z - ArenaSlabMin.z, floorMaterial );
if ( slab is not null )
BakeProxyModel( BakeProxyObject( scene, "the arena's raised floor", new Vector3( o.x, o.y, ArenaSlabMin.z + ArenaRaise ),
Rotation.Identity ), slab, Color.White );
var stage = ArenaStageHeight >= 0.5f
? DebrisMesh.Build( ArenaStageOutline.Select( p => p - o ).ToList(), ArenaStageHeight, floorMaterial )
: null;
if ( stage is not null )
BakeProxyModel( BakeProxyObject( scene, "the arena's upper floor", new Vector3( o.x, o.y, floorTop ), Rotation.Identity ),
stage, Color.White );
var bed = cfg.DamageWalls?.FirstOrDefault( w => w is not null && w.VisibleInGame
&& (w.Material ?? "").Contains( "lava", StringComparison.OrdinalIgnoreCase ) );
if ( bed is not null )
{
var under = bed.Position.z - bed.Size.z * 0.5f - ArenaCeilingGap - ArenaCeilingThickness;
var ceiling = DebrisMesh.Build( new List<Vector2>
{
new Vector2( ArenaCeilingMin.x, ArenaCeilingMin.y ) - o, new Vector2( ArenaCeilingMax.x, ArenaCeilingMin.y ) - o,
new Vector2( ArenaCeilingMax.x, ArenaCeilingMax.y ) - o, new Vector2( ArenaCeilingMin.x, ArenaCeilingMax.y ) - o,
}, ArenaCeilingThickness, Material.Load( StoneMaterial ) );
if ( ceiling is not null )
BakeProxyModel( BakeProxyObject( scene, "the arena's ceiling", new Vector3( o.x, o.y, under ), Rotation.Identity ),
ceiling, Color.White );
}
said.Add( $"the boss arena: its raised floor (top {floorTop:0}), its upper floor ({ArenaStageHeight:0}u over it) and its"
+ ( bed is null ? " — no lava bed, so no — ceiling" : " ceiling, under the lava bed" ) );
return string.Join( "\n", said );
}
/// <summary>
/// Step 2: every stand-in gone, the map's lights their colours again, and the old lava stand-in back on. What it did, in
/// words. Safe to run when there is nothing to undo.
/// </summary>
public static string FinishBake( Scene scene )
{
if ( !scene.IsValid() ) return "⛔ no scene";
int proxies = 0, lights = 0, olds = 0;
foreach ( var go in scene.GetAllObjects( false ).Where( x => x.Tags.Has( BakeProxyTag ) ).ToList() )
{
if ( go.Name.StartsWith( RestMarkerPrefix, StringComparison.Ordinal ) && RestoreFromMarker( scene, go.Name ) ) lights++;
else proxies++;
go.Destroy();
}
foreach ( var old in scene.GetAllObjects( false ).Where( x => x.Tags.Has( OldLavaProxyOff ) ).ToList() )
{
old.Tags.Remove( OldLavaProxyOff );
old.Enabled = true;
olds++;
}
return $"{proxies} stand-in(s) taken away, {lights} light(s) put back as they were"
+ ( olds > 0 ? ", the old lava stand-in on again" : "" );
}
/// <summary>What a marker of a light's colours before is named, before the light's object id and the colours.</summary>
const string RestMarkerPrefix = "nz_bakerest ";
/// <summary>
/// A light's colours before the bake darkened them, kept in the scene as an unsaved, tagged object's name — so step 2
/// finds them even after a hotload has wiped every static. Only the first marker for a light counts: its true colours.
/// </summary>
static void BakeRestMarker( Scene scene, GameObject light, string kind, params Color[] colours )
{
var name = $"{RestMarkerPrefix}{light.Id} {kind} "
+ string.Join( "|", colours.Select( c => string.Join( ",", new[] { c.r, c.g, c.b, c.a }.Select( v => v.ToString( "R", CultureInfo.InvariantCulture ) ) ) ) );
if ( scene.GetAllObjects( false ).Any( x => x.Tags.Has( BakeProxyTag ) && x.Name.StartsWith( $"{RestMarkerPrefix}{light.Id} {kind} ", StringComparison.Ordinal ) ) )
return;
var go = scene.CreateObject();
go.Name = name;
go.Flags |= GameObjectFlags.NotSaved;
go.Tags.Add( BakeProxyTag );
}
/// <summary>A light's colours put back from its marker's name. Whether the light was found.</summary>
static bool RestoreFromMarker( Scene scene, string name )
{
var parts = name[RestMarkerPrefix.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 >= 2 && light.Components.Get<DirectionalLight>( FindMode.EverythingInSelf ) is DirectionalLight d )
{
d.LightColor = colours[0];
d.SkyColor = colours[1];
return true;
}
return false;
}
/// <summary>A colour's brightness scaled, its alpha left alone — as `NZAtmosphere` scales them.</summary>
static Color Scaled( Color c, float f ) => new( c.r * f, c.g * f, c.b * f, c.a );
}