Utility class exposing console commands to create, inspect, bake and apply IndirectLightVolume probe-based indirect lighting for scenes. It reports light counts and probe textures, creates/removes volumes, runs async bake with safety checks, applies shipped textures, and exposes bounds/density/inside-geometry controls.
using Sandbox;
using System;
using System.Linq;
using System.Threading;
namespace NZombies;
/// <summary>
/// BAKED INDIRECT LIGHTING — the closest thing s&box has to a lightmap.
///
/// ⛔ THERE IS NO SHADOW BAKING IN THIS ENGINE. `Light` exposes exactly four properties —
/// Contribution, LegacyData, LightColor, Shadows — with no static or baked mode, and nothing named
/// "lightmap" exists in the API at all. Shadows are dynamic every frame or they do not exist.
/// `Light.LegacyData` is documented as carrying a baking flag "without a public property yet", which
/// makes it an opaque blob rather than a lever.
///
/// ⛔ SO THIS IS THE TRADE, AND IT IS WHY THE SHADOWS COULD BE TURNED OFF AT ALL. In Source 1 the
/// canyon map's lighting was BAKED — vrad wrote lightmaps and its 71 light entities were static data
/// costing nothing. Porting the entities into Source 2 turned all 71 into dynamic shadow-casters:
/// GPU-bound on every frame, with a 3.5x fps swing by view direction. Killing the shadows recovers
/// that and flattens the look; an IndirectLightVolume bakes the bounce back in, once, into volume
/// textures.
///
/// ⚠️ DDGI IS NOT A LIGHTMAP AND WILL NOT LOOK LIKE ONE. It is a 3D probe grid storing irradiance —
/// soft, low-frequency, and it cannot give a hard shadow edge. What it does give is rooms that are
/// still lit and surfaces that still have direction to their light, which is what shadowless local
/// lights lose.
/// </summary>
public static class LightBake
{
/// <summary>
/// Probes per 1024 world units.
///
/// ⚠️ A GUESS, AND MEANT TO BE TUNED. Probe count scales with the CUBE of this over the volume,
/// so doubling it is 8x the bake and 8x the volume texture. Start low and raise it only if the
/// lighting reads as blotchy.
/// </summary>
public static int Density { get; set; } = 4;
/// <summary>
/// True while a bake is in flight.
///
/// ⛔ OVERLAPPING BAKES BECAME DANGEROUS THE MOMENT BAKING STARTED BY DESTROYING THE OLD
/// VOLUME. A second `nz_light_bake` while the first is still running destroys the object the
/// first one is about to report on, and the first's continuation then dereferences a dead
/// component. That surfaced as "BAKE FAILED: NullReferenceException" pointing at this file — a
/// fault in the diagnostic, wearing the costume of the engine bug it was written to catch.
/// </summary>
static bool _baking;
/// <summary>The volume in the scene, or null.</summary>
public static IndirectLightVolume Find()
=> Game.ActiveScene?.GetAllComponents<IndirectLightVolume>().FirstOrDefault( v => v.IsValid() );
/// <summary>
/// `nz_light_report` — what the scene's lighting actually costs.
/// </summary>
[ConCmd( "nz_light_report" )]
public static void Report()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Info( "[nz-light] no scene" ); return; }
var lights = scene.GetAllComponents<Light>().Where( l => l.IsValid() ).ToList();
var shad = lights.Count( l => l.Shadows );
var dir = lights.Count( l => l is DirectionalLight );
Log.Info( $"[nz-light] {lights.Count} light(s) {shad} casting shadows"
+ $" {dir} directional" );
// ⚠️ THE PER-TYPE SPLIT IS THE ACTIONABLE PART. One shadowing directional is the sun and is
// fine; sixty shadowing spots is the bill.
foreach ( var g in lights.GroupBy( l => l.GetType().Name ) )
Log.Info( $"[nz-light] {g.Key,-18} {g.Count(),4}"
+ $" {g.Count( l => l.Shadows ),4} shadowing" );
var vol = Find();
if ( !vol.IsValid() )
{
Log.Info( "[nz-light] no IndirectLightVolume — nz_light_bake creates and bakes one" );
return;
}
Log.Info( $"[nz-light] volume: bounds {vol.Bounds.Size} density {vol.ProbeDensity}"
+ $" probes {vol.ProbeCounts}" );
Spacing( vol );
Textures( vol );
// ⛔ THE KNOBS THAT DECIDE WHETHER PROBES CONTRIBUTE AT ALL. A volume can hold perfectly
// good textures and still light nothing if its probes were deactivated for sitting inside
// geometry — and Basalt is dense concrete slabs, so a probe grid at 179-unit spacing puts a
// lot of them inside a wall. None of this is visible from bounds, density or probe counts,
// which is why a dead volume passed every earlier readout.
Log.Info( $"[nz-light] insideGeometry {vol.InsideGeometry}"
+ $" normalBias {vol.NormalBias:0.###} contrast {vol.Contrast:0.###}" );
Log.Info( "[nz-light] insideGeometry options: "
+ string.Join( ", ", Enum.GetNames( vol.InsideGeometry.GetType() ) ) );
}
/// <summary>
/// `nz_light_bake [density]` — create the volume if needed, size it to the map, and bake.
///
/// ⚠️ DENSITY IS AN INT — probes per 1024 units, the engine's own unit. It is not a scale factor,
/// so 4 and 5 are a real step apart, not a nudge.
///
/// ⛔ IT NO LONGER SIZES ITSELF FROM THE SCENE BY DEFAULT, AND THAT CHANGE CAME FROM A MAP THAT
/// WENT BLACK. `ExtendToSceneBounds` covers EVERYTHING in the scene — including the 3D skybox,
/// which on Basalt is a forest sitting thousands of units above the map. Measured: the volume
/// went from 9320x5314x**3106** to 9320x5314x**20927**, so the same probe budget was stretched
/// over seven times the vertical range and the playable rooms got one probe per ~520 units. The
/// map rendered black, exactly as it did at density 2.
///
/// ⚠️ SO `LightingSettings.BakeSize` WINS WHEN SET. A hand-entered bounds is wrong the first
/// time a different map loads — which is why it lives in the MAP'S CONFIG rather than here.
///
/// ⚠️ THE BAKE IS NOT INSTANT AND NOT SILENT. Probe counts scale cubically with density, so this
/// says what it is about to do before doing it — a command that appears to hang is worse than
/// one that warns.
/// </summary>
[ConCmd( "nz_light_bake" )]
public static void Bake( int density = -1 )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Info( "[nz-light] no scene" ); return; }
// ⛔ TWO DENSITIES EXISTED AND THEY DISAGREED SILENTLY. `nz_atmos_set density 6` writes the
// MAP'S CONFIG; this command used its own static and baked at 4 anyway, reporting "density 4"
// one line after the config said 6. The config wins when nothing is passed explicitly, so
// there is one number and the readout cannot contradict the thing that saves.
var cfg = ActiveConfig.Current?.Lighting;
if ( _baking )
{
Log.Warning( "[nz-light] a bake is already running — ignoring. Wait for it to report." );
return;
}
if ( density > 0 ) Density = density;
else if ( cfg is not null && cfg.BakeDensity > 0 ) Density = cfg.BakeDensity;
// ⛔ ALWAYS A FRESH VOLUME. BAKING OVER AN EXISTING ONE DESTROYS ITS TEXTURES AND PRODUCES
// NOTHING — reproduced deterministically on Basalt, one re-bake apart:
//
// 20:18:35 irradiance 320x208 distance 640x416 (before)
// 20:18:37 irradiance NULL distance NULL (after)
//
// Every good bake in this project came from a volume created moments earlier; every black
// map came from a second bake onto a volume that already had data. `nz_atmos_rebake` was a
// reliable way to destroy a working one.
//
// ⚠️ AND THE WRECKAGE IS WORSE THAN NO BAKE, which is why this is not left to the caller.
// An IndirectLightVolume REPLACES the ambient whether or not its probes hold anything, so a
// failed re-bake does not leave the map as it was — it turns it black. Destroying first
// costs one object and removes the entire failure mode.
var old = Find();
if ( old.IsValid() )
{
old.GameObject?.Destroy();
Log.Info( "[nz-light] dropped the previous volume (baking in place yields NULL textures)" );
}
IndirectLightVolume vol;
{
// ⚠️ NotSaved. The volume is rebuilt and re-baked on demand rather than written into the
// map scene, because the bake's own textures are runtime state — and a half-baked volume
// serialised into a shipped map is worse than none.
var go = scene.CreateObject();
go.Name = "Indirect Light Volume";
go.Flags |= GameObjectFlags.NotSaved;
vol = go.Components.Create<IndirectLightVolume>();
Log.Info( "[nz-light] created an IndirectLightVolume" );
}
vol.ProbeDensity = Density;
if ( cfg is not null && cfg.BakeSize.Length > 1f )
{
vol.Bounds = new BBox( cfg.BakeCenter - cfg.BakeSize * 0.5f,
cfg.BakeCenter + cfg.BakeSize * 0.5f );
}
else
{
vol.ExtendToSceneBounds();
}
Log.Info( $"[nz-light] baking: bounds {vol.Bounds.Size} density {Density}"
+ $" probes {vol.ProbeCounts}"
+ $" {( cfg is not null && cfg.BakeSize.Length > 1f ? "(config bounds)" : "(scene bounds)" )}"
+ " — this takes a moment" );
Spacing( vol );
// ⛔ THE RESULT IS OBSERVED NOW, AND IT WAS NOT BEFORE. `BakeProbes` is async, and this
// called it fire-and-forget — so when it threw, the exception surfaced on the FINALIZER
// thread as "A Task's exception(s) were not observed", minutes later, detached from the
// command that caused it. Nothing ever said the bake had failed, so an empty volume read as
// a slow one and then as a black map. Awaiting it is the difference between a bug you can
// see and one you infer three screenshots later.
// ⛔ RAISE THE AMBIENT FOR THE PROBES, THEN PUT IT BACK. `BakeAmbient` exists because the
// bake needs far more ambient than the map should be lit with — and that same value is the
// fallback for everything OUTSIDE the volume, so leaving it raised lights the whole map
// below the probe band at twice its original brightness.
if ( cfg is not null && cfg.BakeAmbient > 0f )
{
NZAtmosphere.BakeOverride = cfg.BakeAmbient;
NZAtmosphere.Apply( scene );
Log.Info( $"[nz-light] ambient raised to x{cfg.BakeAmbient:0.##} for the bake"
+ $" (back to x{cfg.Ambient:0.##} after)" );
}
_baking = true;
_ = Watch( vol, vol.BakeProbes( CancellationToken.None ) );
Log.Info( "[nz-light] bake started. nz_light_report shows the volume;"
+ " look at the map to judge it." );
// ⛔ SAID AT THE START, NOT DISCOVERED AT THE END. A bake in play cannot save, and the failed save turns its distance
// texture into the engine's error texture (CHANGELOG 2026-09-16 22:02) — the map goes dark once the bake is done.
// Reproduced 2026-09-27: 17 minutes of baking, then "distance 32x32", and *"it stays dark, we had this problem before
// but im unsure how we fixed it"*. The fix then and now is the editor's bake.
Log.Warning( "[nz-light] ⚠️ a bake in PLAY does not keep: it cannot be saved, and its distance texture turns into the"
+ " error texture when it finishes — the rooms go dark. To keep one, bake in the editor, not playing: on basalt,"
+ " Basalt.scene open, nZombies ▸ Basalt light bake ▸ Bake now; elsewhere, Scene ▸ Bake Indirect Light Volumes." );
}
/// <summary>
/// Await the bake and say what came of it.
///
/// ⚠️ A VOLUME THAT BAKED NOTHING IS WORSE THAN NO VOLUME, which is why this reports rather
/// than shrugging: an IndirectLightVolume REPLACES the ambient term whether or not its probes
/// hold anything, so a failed bake does not leave the map as it was — it turns it black.
/// </summary>
static async System.Threading.Tasks.Task Watch( IndirectLightVolume vol,
System.Threading.Tasks.Task bake )
{
System.Exception failure = null;
try { await bake; }
catch ( Exception e ) { failure = e; }
// ⛔ THE THROW ARRIVES BEFORE THE TEXTURES DO, AND CLASSIFYING IN THE CATCH GOT IT WRONG
// TWICE. Checked the instant the exception surfaced, `IrradianceTexture` is still null and
// the bake looks like a total failure; a second later the same volume reports
// `irradiance 320x208 / distance 640x416` and lights the map correctly. So the verdict waits
// for the data rather than racing it.
try { await System.Threading.Tasks.Task.Delay( 750 ); } catch { }
// ⛔ RE-FOUND, NOT THE REFERENCE WE STARTED WITH. Holding a component across an await is
// unsafe in this editor: a hotload swaps instances, so the captured `vol` goes invalid while
// the volume itself is alive and healthy. That produced "volume was replaced while baking"
// on a bake that had in fact just succeeded — a diagnostic lying about a working system,
// which is the most expensive kind. `Find()` asks the scene what is there now.
vol = Find();
if ( !vol.IsValid() )
{
Log.Info( "[nz-light] no volume after the bake — nothing to report" );
_baking = false;
return;
}
var ok = vol.IrradianceTexture is not null;
if ( ok && failure is not null )
{
// ⚠️ THIS IS THE NORMAL OUTCOME IN PLAY MODE, NOT AN ERROR. The engine's
// `IndirectLightVolume.SaveTexture` raises a NullReferenceException on a runtime scene
// because there is no scene folder to write baked data into — the engine's own docs
// describe that folder as where "envmap textures, lightmaps, baked data" go. The probes
// themselves are computed and bound, so the map lights correctly; the bake just does not
// survive the session.
Log.Info( $"[nz-light] bake OK — probe data present. The engine could not SAVE it "
+ $"({failure.GetType().Name}), so it will not persist past this session." );
Textures( vol );
}
else if ( ok )
{
Log.Info( "[nz-light] bake OK" );
Textures( vol );
}
else
{
Log.Error( "[nz-light] BAKE PRODUCED NOTHING"
+ ( failure is null ? "" : $" ({failure.GetType().Name}: {failure.Message})" ) );
Log.Warning( "[nz-light] the volume is OVERRIDING the ambient with nothing — the map "
+ "will be black. `nz_light_volume 0` to drop it." );
}
// ⚠️ RESTORED HERE, NOT AFTER THE BakeProbes CALL. The bake is async — putting the ambient
// back on the next line would restore it before a single probe had rendered, which is the
// same "looks like it did nothing" failure as issuing `nz_dark` before the map has loaded.
if ( NZAtmosphere.BakeOverride is not null )
{
NZAtmosphere.BakeOverride = null;
NZAtmosphere.Apply( Game.ActiveScene );
Log.Info( "[nz-light] ambient restored" );
}
_baking = false;
}
/// <summary>
/// Did the bake actually produce probe data?
///
/// ⛔ THE ONLY DIRECT ANSWER TO "IS THIS VOLUME EMPTY". Bounds, density and probe counts are
/// all set the moment the volume is configured, so they read exactly the same for a good bake
/// and a failed one — which is precisely how a dead volume passed three separate readouts.
/// </summary>
static void Textures( IndirectLightVolume vol )
{
if ( !vol.IsValid() ) return;
var irr = vol.IrradianceTexture;
var dist = vol.DistanceTexture;
Log.Info( $"[nz-light] irradiance {( irr is null ? "NULL" : $"{irr.Width}x{irr.Height}" )}"
+ $" distance {( dist is null ? "NULL" : $"{dist.Width}x{dist.Height}" )}" );
if ( irr is null )
Log.Warning( "[nz-light] no irradiance texture — this volume holds NO light and is "
+ "blacking the map out. `nz_light_volume 0`." );
// ⛔ THE COLLAPSE, NAMED. The error texture is 32x32; a real distance texture is the irradiance's size or bigger (640x416
// against 320x208, 2026-09-16). Printed as a size alone it read as a result, not a failure.
else if ( dist is not null && dist.Width <= 32 && dist.Height <= 32 && irr.Width > 32 )
Log.Error( "[nz-light] the DISTANCE texture is the engine's 32x32 ERROR texture — the failed save of a bake in"
+ " play. Light cannot spread between the probes: the map will be dark. Bake in the editor instead"
+ " (on basalt: nZombies ▸ Basalt light bake ▸ Bake now)." );
}
/// <summary>
/// How far apart the probes actually are, in world units.
///
/// ⛔ THE NUMBER THAT MAKES A BAD BAKE VISIBLE. Probe COUNT tells you nothing on its own —
/// 38x22x40 sounds generous and was one probe per 520 units of height, which is useless for a
/// room 300 units tall. Both times this map went black, the spacing was the tell and nothing
/// else in the readout was.
///
/// ⚠️ A PROBE SHOULD SIT WELL INSIDE A ROOM. Once spacing exceeds room height the probes
/// straddle floors and ceilings, relocation deactivates the ones stuck in geometry, and what is
/// left describes the building rather than the room you are standing in.
/// </summary>
static void Spacing( IndirectLightVolume vol )
{
if ( !vol.IsValid() ) return;
var size = vol.Bounds.Size;
var n = vol.ProbeCounts;
var sx = n.x > 1 ? size.x / ( n.x - 1 ) : size.x;
var sy = n.y > 1 ? size.y / ( n.y - 1 ) : size.y;
var sz = n.z > 1 ? size.z / ( n.z - 1 ) : size.z;
// ⚠️ NO TEXTURE CHECK HERE. This runs BEFORE the bake as well as after, and a volume
// created one line earlier is empty BY DESIGN — warning about it there cried wolf on every
// single bake, which is how a real "holds no light" warning would have been missed.
Log.Info( $"[nz-light] probe spacing {sx:0}x{sy:0}x{sz:0} units" );
// ⚠️ 400 IS NOT A TUNED THRESHOLD, IT IS "TALLER THAN ANY ROOM IN THESE MAPS". Basalt's
// playable band is 1185..1696 — about 510 units for the WHOLE map vertically.
if ( MathF.Max( sx, MathF.Max( sy, sz ) ) > 400f )
Log.Warning( "[nz-light] probes are further apart than a room is tall — interiors will "
+ "come out BLACK. Shrink the volume (nz_light_bounds) or raise the density." );
}
/// <summary>
/// `nz_light_bounds <cx> <cy> <cz> <sx> <sy> <sz>` — confine the bake to the playable
/// area. No arguments reports; `nz_light_bounds auto` goes back to whole-scene sizing.
///
/// ⚠️ THE POINT IS TO STOP SPENDING PROBES ON SKY. A 3D skybox is scene geometry like any
/// other, so the automatic sizing swallows it and the rooms starve.
/// </summary>
[ConCmd( "nz_light_bounds" )]
public static void SetBounds( string cx = "", float cy = 0f, float cz = 0f,
float sx = 0f, float sy = 0f, float sz = 0f )
{
var cfg = ActiveConfig.Current?.Lighting;
if ( cfg is null ) { Log.Warning( "[nz-light] no config" ); return; }
if ( string.Equals( cx, "auto", System.StringComparison.OrdinalIgnoreCase ) )
{
cfg.BakeSize = Vector3.Zero;
Log.Info( "[nz-light] bake bounds: AUTO (whole scene — includes the 3D skybox)" );
return;
}
if ( string.IsNullOrWhiteSpace( cx ) || sx <= 0f )
{
Log.Info( cfg.BakeSize.Length > 1f
? $"[nz-light] bake bounds: centre {cfg.BakeCenter} size {cfg.BakeSize}"
: "[nz-light] bake bounds: AUTO (whole scene)" );
Log.Info( "[nz-light] nz_light_bounds <cx> <cy> <cz> <sx> <sy> <sz> | nz_light_bounds auto" );
return;
}
if ( !float.TryParse( cx, out var x ) )
{
Log.Warning( $"[nz-light] '{cx}' is not a number" );
return;
}
cfg.BakeCenter = new Vector3( x, cy, cz );
cfg.BakeSize = new Vector3( sx, sy, sz );
Log.Info( $"[nz-light] bake bounds: centre {cfg.BakeCenter} size {cfg.BakeSize}"
+ " — nz_light_bake to apply, nz_save to keep" );
}
/// <summary>
/// Bake, but only if this map's config asked for one. Called at the END of `NZGame.ShowConfig`.
///
/// ⚠️ A SEPARATE ENTRY POINT SO THE ORDERING IS THE CALLER'S, NOT A TIMER'S. See the note at
/// the call site: what the probes capture is whatever exists at the moment they render, so the
/// bake has to be the last thing that happens after the world is built.
/// </summary>
public static void BakeIfConfigured()
{
var cfg = ActiveConfig.Current?.Lighting;
if ( cfg is null || !cfg.Bake ) return;
// ⛔ A SHIPPED BAKE ALWAYS WINS OVER BAKING AGAIN. The runtime bake is not merely slower,
// it is self-destroying: `SaveTexture` throws on a play-mode scene and the engine then swaps
// the distance texture for an error texture, so the map lights correctly for about a minute
// and then goes black. If the map ships textures, load them and never bake.
if ( cfg.HasBakedTextures && ApplyBaked( Game.ActiveScene, cfg ) ) return;
Bake();
}
/// <summary>
/// Build the volume from the map's shipped bake. True when it worked.
///
/// ⚠️ BOUNDS AND DENSITY STILL COME FROM THE CONFIG, because the textures encode a probe GRID
/// and nothing else — point them at a differently sized volume and the irradiance is sampled at
/// the wrong places, which looks like a subtly wrong bake rather than a mismatch.
/// </summary>
public static bool ApplyBaked( Scene scene, LightingSettings cfg )
{
if ( !scene.IsValid() || cfg is null ) return false;
// ⚠️ `LoadFromFileSystem`, WITH THE ARGUMENTS THE OTHER WAY ROUND. The old
// `Texture.Load( fileSystem, path )` is obsolete and its replacement takes the PATH first —
// so a mechanical swap of the method name alone compiles and looks up a texture named after
// the file system. Both textures below are checked for null, which is the only reason that
// mistake would have been caught here rather than shipping as "the bake is missing".
var irr = Texture.LoadFromFileSystem( cfg.BakedIrradiance, FileSystem.Mounted );
var dist = Texture.LoadFromFileSystem( cfg.BakedDistance, FileSystem.Mounted );
if ( irr is null || dist is null )
{
// ⚠️ SAYS WHICH ONE, because "the lighting is wrong" with a silent missing texture is
// indistinguishable from a bad bake — and this project has now chased that twice.
Log.Warning( $"[nz-light] shipped bake missing:"
+ $"{( irr is null ? " irradiance" : "" )}{( dist is null ? " distance" : "" )}"
+ " — falling back to a runtime bake, which will not persist" );
return false;
}
var old = Find();
if ( old.IsValid() ) old.GameObject?.Destroy();
var go = scene.CreateObject();
go.Name = "Indirect Light Volume (baked)";
go.Flags |= GameObjectFlags.NotSaved;
var vol = go.Components.Create<IndirectLightVolume>();
vol.ProbeDensity = cfg.BakeDensity;
if ( cfg.BakeSize.Length > 1f )
vol.Bounds = new BBox( cfg.BakeCenter - cfg.BakeSize * 0.5f,
cfg.BakeCenter + cfg.BakeSize * 0.5f );
else
vol.ExtendToSceneBounds();
vol.IrradianceTexture = irr;
vol.DistanceTexture = dist;
var reloc = Texture.LoadFromFileSystem( cfg.BakedRelocation, FileSystem.Mounted );
if ( reloc is not null ) vol.RelocationTexture = reloc;
Log.Info( $"[nz-light] loaded a SHIPPED bake — irradiance {irr.Width}x{irr.Height}"
+ $" distance {dist.Width}x{dist.Height}"
+ $" relocation {( reloc is null ? "absent" : "ok" )} (no runtime bake)" );
Spacing( vol );
return true;
}
/// <summary>
/// `nz_light_inside <name>` — what the bake does with probes that land inside geometry.
///
/// ⚠️ PARSED BY NAME AT RUNTIME rather than hard-coded, because the enum lives in the engine
/// and guessing its members is how you write a setter that compiles and sets nothing.
/// `nz_light_report` prints the available options.
/// </summary>
[ConCmd( "nz_light_inside" )]
public static void Inside( string name = "" )
{
var vol = Find();
if ( !vol.IsValid() ) { Log.Warning( "[nz-light] no volume — nz_light_bake first" ); return; }
var type = vol.InsideGeometry.GetType();
if ( string.IsNullOrWhiteSpace( name ) )
{
Log.Info( $"[nz-light] insideGeometry is {vol.InsideGeometry}"
+ $" options: {string.Join( ", ", Enum.GetNames( type ) )}" );
return;
}
try
{
vol.InsideGeometry = (IndirectLightVolume.InsideGeometryBehavior)Enum.Parse( type, name, true );
Log.Info( $"[nz-light] insideGeometry = {vol.InsideGeometry}"
+ " — nz_light_bake to apply it" );
}
catch
{
Log.Warning( $"[nz-light] '{name}' is not one of: {string.Join( ", ", Enum.GetNames( type ) )}" );
}
}
/// <summary>
/// `nz_light_volume 0` — remove the volume, to A/B the bake against no indirect at all.
/// </summary>
[ConCmd( "nz_light_volume" )]
public static void Volume( int on = -1 )
{
var vol = Find();
if ( on == 0 )
{
if ( !vol.IsValid() ) { Log.Info( "[nz-light] no volume to remove" ); return; }
vol.GameObject?.Destroy();
Log.Info( "[nz-light] volume removed — the map now has no baked indirect" );
return;
}
if ( on == 1 && !vol.IsValid() ) { Bake(); return; }
Log.Info( vol.IsValid()
? $"[nz-light] volume present, density {vol.ProbeDensity}, probes {vol.ProbeCounts}"
: "[nz-light] no volume — nz_light_bake makes one" );
}
}