A GameObjectSystem that manages post-processing on the scene camera: tonemapping, bloom, and an optional map-specific colour grade. It listens each tick, applies or removes Camera components (Tonemapping, Bloom, ColorAdjustments, ColorGrading, Vignette, FilmGrain), exposes console commands and ConVars for tuning, and writes values from the active map LightingSettings into the camera.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Tonemapping, bloom and the map's colour grade on the gameplay camera.
///
/// ⛔ WITHOUT THIS, NOTHING IN THE GAME CAN BE BRIGHTER THAN WHITE — and that is not a figure of
/// speech, it was measured. With the panel material tinted red (below 1.0) the strips turned red
/// instantly; tinted `[4 4 4]`, and again on `complex.shader` with self-illum brightness 8, the
/// frame came back PIXEL-IDENTICAL to `[1 1 1]`. The renderer was clamping at 1.0 because nothing
/// was mapping the high range into view. So a light panel and a sheet of pale concrete were the
/// same pixels, and bloom had nothing it could single out.
///
/// ⚠️ THE PROJECT HAD NO POST-PROCESSING AT ALL. Not on the scene camera, not on the player
/// prefab, nowhere — zero `Bloom`, `Tonemapping`, `Vignette`, `ColorAdjustments` or `Sharpen`
/// components in any scene or prefab. That is the single biggest reason the ported maps read flat.
///
/// ⛔ A `GameObjectSystem`, NOT A COMPONENT IN THE SCENE, for the reason `NZStartup` already
/// records: a component would have to be placed in `scenes/nzombies.scene`, and this project's rule
/// is to never rewrite a `.scene` from a script. The engine makes one of these per scene by itself.
///
/// ⚠️ AND IT ATTACHES TO `Scene.Camera` EVERY TICK RATHER THAN ONCE. `NZPlayer.Camera` is
/// `Scene?.Camera`, which is whichever object currently wears the `maincamera` tag — that survives
/// a map load, but it is not guaranteed to be the same object forever, and a one-shot attach would
/// leave a fresh camera with no post-processing and no clue why. The work is a cached reference
/// compare in the common case.
///
/// ⚠️ THE COST IS FIXED AND DOES NOT SCALE WITH THE MAP. Two full-screen passes, the same on
/// Basalt as on an empty scene — which is the whole reason this is the FIRST lighting step and
/// dynamic lights are the last one. `nz_post 0` turns it off if it ever needs ruling out. A map's
/// grade adds up to four more, each only when the map asks for it.
///
/// ⚠️ THE GRADE (2026-09-28) — *"i love 5 too, the color grade"* — is the map's own, in its config beside the exposure
/// (`LightingSettings.Grade…`), neutral by default so a map that names none is drawn as it always was. The engine's own effects:
/// `ColorAdjustments` for the colour, `ColorGrading`'s curves for the contrast and the split tone, `Vignette`, `FilmGrain`, and
/// the tonemapper's mode. `nz_grade` tunes it a part at a time.
/// ⛔ ONLY IN A GAME — NEVER ON THE CAMERA OF THE EDITOR'S OWN SCENE (2026-09-28). This system ticks in that scene as well, and
/// the editor's viewport draws with that camera's post-processing (the note here used to say it had none): the grade it laid there
/// covered the whole editor in film grain — *"the editor gets progressively more static the more i start and stop a game"*. And
/// `ActiveConfig.Current` outlives a game, so the editor took the LAST MAP PLAYED's look. There the tonemapping and bloom stay, as
/// they always have, at the exposure the editor opens with (`CfgFor`), and the grade comes off (`TakeOffGrade`).
///
/// # MAPPORT: lighting step 1
/// </summary>
public sealed class NZPostProcess : GameObjectSystem<NZPostProcess>
{
public NZPostProcess( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, 0, Tick, "nz.post" );
}
/// <summary>`nz_post 0` — strip post-processing off the camera entirely.
///
/// ⚠️ IT REMOVES THE COMPONENTS RATHER THAN DISABLING ITSELF. A flag that only stopped this
/// class re-applying would leave whatever was last written on the camera, so turning it off to
/// rule it out would prove nothing.</summary>
[ConVar( "nz_post" )] public static bool Enabled { get; set; } = true;
/// <summary>
/// `nz_post_bloom` — how much the glow spreads.
///
/// ⚠️ 0.5, DOWN FROM 1.5 (and 0.8 between). At 1.5 on a darkened Basalt the strips were fine but everything else
/// suffered: a skylight became a featureless white blob and a haze washed across the concrete,
/// flattening exactly the contrast the darkening was for. Bloom's job here is to say "that is a
/// light", and past a point it stops saying that and just lowers the whole image's contrast.
/// </summary>
[ConVar( "nz_post_bloom" )] public static float BloomStrength { get; set; } = 0.5f;
/// <summary>
/// `nz_post_bloom_threshold` — how bright a pixel must be before it blooms.
///
/// ⛔ 1.0 MEANS "ONLY THINGS BRIGHTER THAN WHITE", AND THAT IS DELIBERATE, NOT TIMID. Dropped
/// to 0.05 on Basalt the entire frame glowed, because lit concrete sits near the same value as
/// the light panels do. Anything below 1.0 blooms the walls as readily as the lamps; the point
/// of bloom is to say "this is a light source", and it can only say that about a pixel that no
/// ordinary surface can reach.
/// </summary>
[ConVar( "nz_post_bloom_threshold" )] public static float BloomThreshold { get; set; } = 1.0f;
/// <summary>
/// Exposure lives in the MAP'S CONFIG, not in a ConVar.
///
/// ⛔ BECAUSE IT TURNED OUT TO BE THE DARKNESS CONTROL, AND DARKNESS IS PER MAP. On a baked
/// map the ambient sets how much SHAPE the probes can produce and the bias sets how dark the
/// result reads — ambient 2 and ambient 5 render almost identically because auto-exposure
/// normalises them. A global ConVar doing that job would carry one map's mood onto the next,
/// which is exactly the argument `NZAtmosphere` already records about its own numbers.
///
/// ⚠️ NEVER NULL — a fresh `MapConfig` has neutral defaults, which is the right behaviour for
/// a map with no config yet.
/// </summary>
static LightingSettings Cfg => ActiveConfig.Current?.Lighting ?? new LightingSettings();
/// <summary>
/// What a camera in <paramref name="scene"/> is drawn with: the map's config in a game, and in the editor's own scene the defaults
/// it opens with — see the class notes.
/// </summary>
static LightingSettings CfgFor( Scene scene ) => (scene?.IsEditor ?? false) ? new LightingSettings() : Cfg;
/// <inheritdoc cref="LightingSettings.AutoExposure"/>
public static bool AutoExposure => Cfg.AutoExposure;
/// <inheritdoc cref="LightingSettings.ExposureMin"/>
public static float ExposureMin => Cfg.ExposureMin;
/// <inheritdoc cref="LightingSettings.ExposureMax"/>
public static float ExposureMax => Cfg.ExposureMax;
/// <inheritdoc cref="LightingSettings.ExposureBias"/>
public static float ExposureBias => Cfg.ExposureBias;
/// <summary>
/// `nz_exposure <auto|min|max|bias> <value>` — one knob at a time, written to the map config.
///
/// ⚠️ `bias` IS THE ONE THAT MAKES A BAKED MAP DARK. Turning the ambient down instead falls off
/// a cliff into black, because the probes stop having anything to integrate.
/// </summary>
[ConCmd( "nz_exposure" )]
public static void Exposure( string part = "", float value = float.NaN )
{
var c = ActiveConfig.Current?.Lighting;
if ( c is null ) { Log.Warning( "[nz-post] no config" ); return; }
if ( string.IsNullOrWhiteSpace( part ) || float.IsNaN( value ) )
{
Log.Info( "[nz-post] nz_exposure <auto|min|max|bias> <value>" );
Report();
return;
}
switch ( part.ToLowerInvariant() )
{
case "auto": c.AutoExposure = value != 0f; break;
case "min": c.ExposureMin = value; break;
case "max": c.ExposureMax = value; break;
case "bias": c.ExposureBias = value; break;
default:
Log.Warning( $"[nz-post] no such part '{part}' — auto, min, max, bias" );
return;
}
ActiveConfig.NotifyChanged();
Refresh();
Log.Info( "[nz-post] `nz_save` to keep this with the map's config" );
}
/// <summary>`nz_post_exposure_rate` — how fast the adaptation follows. Low is sluggish and
/// obvious; high chases every muzzle flash.</summary>
[ConVar( "nz_post_exposure_rate" )] public static float ExposureRate { get; set; } = 1.0f;
/// <summary>The camera we last set up, so the common tick is one reference compare.</summary>
CameraComponent _camera;
/// <summary>
/// Everything written onto the camera, compared whole each tick, so a config change reaches the camera without a command.
///
/// ⛔ THE OLD TICK RE-APPLIED ONLY WHEN THE CAMERA CHANGED, and that was fine while these were
/// ConVars written by a command that pushed them itself. Now that they load with a MAP CONFIG,
/// nothing would push them — the map would load dark settings and render at the previous map's
/// exposure until something happened to swap the camera.
/// ⚠️ THE EXPOSURE RATE IS IN IT NOW TOO (2026-09-28): out of the compare, `nz_post_exposure_rate` did nothing until
/// something else changed.
/// </summary>
readonly record struct Look( bool Auto, float Min, float Max, float Bias, float Rate, float Strength, float Threshold,
float Saturation, float Contrast, float Brightness, float Warmth, float Vignette, string VignetteColour, float Grain,
string Tonemap );
Look _applied;
static Look Wanted( LightingSettings c ) => new Look( c.AutoExposure, c.ExposureMin, c.ExposureMax, c.ExposureBias, ExposureRate,
BloomStrength, BloomThreshold, c.GradeSaturation, c.GradeContrast, c.GradeBrightness, c.GradeWarmth, c.GradeVignette,
c.GradeVignetteColour ?? "", c.GradeGrain, c.GradeTonemap ?? "" );
void Tick()
{
var cam = Scene?.Camera;
if ( !cam.IsValid() ) { _camera = null; return; }
if ( !Enabled )
{
Strip( cam );
_camera = null;
return;
}
// ⛔ NO GRADE IN THE EDITOR'S OWN SCENE — see the class notes. ⚠️ LOOKED AT EVERY TICK THERE, NOT ONLY WHEN THE LOOK
// CHANGES: the first cut of this ran only on a change, the editor hotloaded it an edit at a time, and by the last edit there
// was no change left to run it — the grain stayed. Four lookups on one camera, in the editor only.
if ( Scene.IsEditor ) TakeOffGrade( cam );
// ⚠️ A CHEAP VALUE COMPARE, NOT A WRITE. A few numbers compared per frame is nothing; writing
// engine properties sixty times a second is not. This fires on a camera swap OR on the
// map config bringing different numbers with it. ⚠️ AGAINST WHAT IS DRAWN (`CfgFor`): in the editor's own scene the defaults,
// so its camera is written with them once — not again after every game, nor left at the last map's.
var c = CfgFor( Scene );
var want = Wanted( c );
if ( _camera == cam && _applied == want ) return;
_camera = cam;
_applied = want;
Apply( cam );
Log.Info( $"[nz-post] attached to '{cam.GameObject?.Name}'{( Scene.IsEditor ? " in the editor's own scene" : "" )}"
+ $" — bloom {BloomStrength:0.##} over {BloomThreshold:0.##}"
+ $", auto-exposure {( c.AutoExposure ? $"{c.ExposureMin:0.##}..{c.ExposureMax:0.##}" : "off" )}"
+ $", grade {GradeText( c )}" );
}
/// <summary>Write every value onto the camera, creating the components if they are missing.</summary>
static void Apply( CameraComponent cam )
{
if ( !cam.IsValid() ) return;
// ⛔ IN THE EDITOR'S OWN SCENE, THE DEFAULTS — never the last map played: basalt's 0.6..1 exposure stayed on the editor's
// camera after a game
var c = CfgFor( cam.Scene );
var tone = cam.Components.GetOrCreate<Tonemapping>();
if ( tone.IsValid() )
{
tone.AutoExposureEnabled = c.AutoExposure;
tone.MinimumExposure = c.ExposureMin;
tone.MaximumExposure = c.ExposureMax;
tone.ExposureCompensation = c.ExposureBias;
tone.Rate = ExposureRate;
tone.Mode = ToneOf( c.GradeTonemap );
}
var bloom = cam.Components.GetOrCreate<Bloom>();
if ( bloom.IsValid() )
{
bloom.Strength = BloomStrength;
bloom.Threshold = BloomThreshold;
}
ApplyGrade( cam, c );
}
// ══ the grade ══════════════════════════════════════════════════════════════════════════
static bool Near( float a, float b ) => MathF.Abs( a - b ) < 0.001f;
/// <summary>
/// The map's grade, each effect only while it does something — a map with a neutral grade has none of them on its camera.
///
/// ⛔ NOT THE ENGINE'S CONTRAST. `ColorAdjustments.Contrast` pivots at 0.5 IN LINEAR LIGHT (`pp_color.shader` reads the frame
/// with sRGB decoding): on a map as dark as basalt, where nearly every pixel is under 0.2, a contrast of 1.1 pushes the shadows
/// to black. The contrast is the curves' (<see cref="Curves"/>), about mid-grey.
/// </summary>
static void ApplyGrade( CameraComponent cam, LightingSettings c )
{
// ⛔ NEVER IN THE EDITOR'S OWN SCENE — see the class notes
if ( cam.Scene?.IsEditor ?? false )
{
TakeOffGrade( cam );
return;
}
Keep<ColorAdjustments>( cam, !Near( c.GradeSaturation, 1f ) || !Near( c.GradeBrightness, 1f ), a =>
{
a.Saturation = c.GradeSaturation;
a.Brightness = c.GradeBrightness;
a.Contrast = 1f;
a.HueRotate = 0f;
a.Blend = 1f;
} );
Keep<ColorGrading>( cam, !Near( c.GradeContrast, 1f ) || c.GradeWarmth > 0.001f, g =>
{
g.GradingMethod = ColorGrading.GradingType.None;
g.ColorSpace = ColorGrading.ColorSpaceEnum.RGB;
g.BlendFactor = 1f;
var (r, gr, b) = Curves( c.GradeContrast, c.GradeWarmth );
g.RedCurve = r;
g.GreenCurve = gr;
g.BlueCurve = b;
} );
Keep<Vignette>( cam, c.GradeVignette > 0.001f, v =>
{
v.Intensity = c.GradeVignette;
v.Smoothness = 0.75f;
v.Roundness = 1f;
v.Center = new Vector2( 0.5f, 0.5f );
v.Color = Color.Parse( c.GradeVignetteColour ?? "" ) ?? Color.Black;
} );
Keep<FilmGrain>( cam, c.GradeGrain > 0.001f, f =>
{
f.Intensity = c.GradeGrain;
f.Response = 0.7f;
} );
}
/// <summary>An effect on the camera, set — or taken off, when <paramref name="on"/> is false.</summary>
static void Keep<T>( CameraComponent cam, bool on, Action<T> set ) where T : Component, new()
{
var have = cam.Components.Get<T>();
if ( !on )
{
have?.Destroy();
return;
}
set( have ?? cam.Components.Create<T>() );
}
/// <summary>
/// The grade's four effects off a camera — every one of each, on or off, so neither a leftover nor a double survives.
/// ⚠️ `Component.Destroy` takes it out of the list at once (the engine's own code), so the next tick's lookup finds nothing.
/// </summary>
static void TakeOffGrade( CameraComponent cam )
{
TakeOff<ColorAdjustments>( cam );
TakeOff<ColorGrading>( cam );
TakeOff<Vignette>( cam );
TakeOff<FilmGrain>( cam );
}
static void TakeOff<T>( CameraComponent cam ) where T : Component
{
if ( cam.Components.Get<T>( FindMode.EverythingInSelf ) is null ) return;
foreach ( var c in cam.Components.GetAll<T>( FindMode.EverythingInSelf ).ToArray() ) c.Destroy();
}
static Tonemapping.TonemappingMode ToneOf( string name ) => (name ?? "").Trim().ToLowerInvariant() switch
{
"aces" => Tonemapping.TonemappingMode.ACES,
"agx" => Tonemapping.TonemappingMode.AgX,
"reinhard" or "reinhardjodie" => Tonemapping.TonemappingMode.ReinhardJodie,
"linear" => Tonemapping.TonemappingMode.Linear,
_ => Tonemapping.TonemappingMode.HableFilmic,
};
/// <summary>
/// The grade's three curves, in linear light as the grade reads the frame, four keys a channel (the most its shader takes):
/// - contrast: an S about mid-grey (0.18) — the shadows below it pushed down, the highlights above it lifted;
/// - warmth: a split tone, the highlights warmed towards the lava's orange (less blue, a touch more red) and the deepest
/// shadows cooled a hair (a little blue in them), the mids left be.
/// Both small by design: 0.5 warmth takes 4% of the blue out of a white.
/// </summary>
static (Curve R, Curve G, Curve B) Curves( float contrast, float warmth )
{
var k = contrast - 1f;
const float Shadow = 0.06f, Pivot = 0.18f;
var shadow = Shadow * (1f - 1.5f * k);
Curve Make( float bottom, float atShadow, float atPivot, float top )
{
var toe = (atShadow - bottom) / Shadow;
var knee = (atPivot - atShadow) / (Pivot - Shadow);
return Channel( (0f, bottom, toe), (Shadow, atShadow, 0.5f * (toe + knee)), (Pivot, atPivot, 1f + 1.5f * k),
(1f, top, MathF.Max( 0.1f, 1f - k )) );
}
var w = MathF.Max( 0f, warmth );
return (
Make( 0f, shadow - 0.004f * w, Pivot + 0.004f * w, 1f ),
Make( 0f, shadow, Pivot, 1f - 0.01f * w ),
Make( 0.004f * w, shadow + 0.006f * w, Pivot - 0.012f * w, 1f - 0.08f * w ) );
}
/// <summary>
/// A curve through these keys, each with its slope. ⚠️ `In` HOLDS THE SLOPE NEGATED: the engine's own evaluator and the grade's
/// shader both read it times -1 (`Curve.GetInterpolatedValue`, `ColorGrading.shader`'s `GetYonCurve`).
/// </summary>
static Curve Channel( params (float X, float Y, float Slope)[] keys )
{
var frames = new Curve.Frame[keys.Length];
for ( var i = 0; i < keys.Length; i++ )
frames[i] = new Curve.Frame { Time = keys[i].X, Value = keys[i].Y, In = -keys[i].Slope, Out = keys[i].Slope, Mode = Curve.HandleMode.Split };
return new Curve( frames );
}
static string GradeText( LightingSettings c )
{
var neutral = Near( c.GradeSaturation, 1f ) && Near( c.GradeContrast, 1f ) && Near( c.GradeBrightness, 1f )
&& c.GradeWarmth <= 0.001f && c.GradeVignette <= 0.001f && c.GradeGrain <= 0.001f && string.IsNullOrWhiteSpace( c.GradeTonemap );
if ( neutral ) return "neutral";
return $"saturation {c.GradeSaturation:0.##} contrast {c.GradeContrast:0.##} brightness {c.GradeBrightness:0.##}"
+ $" warmth {c.GradeWarmth:0.##} vignette {c.GradeVignette:0.##}{(string.IsNullOrWhiteSpace( c.GradeVignetteColour ) ? "" : $" {c.GradeVignetteColour}")}"
+ $" grain {c.GradeGrain:0.##} tonemap {(string.IsNullOrWhiteSpace( c.GradeTonemap ) ? "hable" : c.GradeTonemap)}";
}
/// <summary>
/// `nz_grade [part value]` — the map's grade, one part at a time, written to its config (`nz_save` keeps it): `saturation`,
/// `contrast`, `brightness` (1 = as it is), `warmth`, `vignette`, `grain` (0 = none), `vignettecolour #rrggbb`, `tonemap
/// hable|aces|agx|reinhard|linear`. `nz_grade off` sets it all neutral. Bare, what it is now.
/// </summary>
[ConCmd( "nz_grade" )]
public static void Grade( string part = "", string value = "" )
{
var c = ActiveConfig.Current?.Lighting;
if ( c is null ) { Log.Warning( "[nz-post] no config" ); return; }
var p = part.Trim().ToLowerInvariant();
float.TryParse( value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v );
switch ( p )
{
case "":
Log.Info( $"[nz-post] the grade: {GradeText( c )} · nz_grade <part> <value> · nz_grade off" );
return;
case "saturation": c.GradeSaturation = Math.Clamp( v, 0f, 2f ); break;
case "contrast": c.GradeContrast = Math.Clamp( v, 0.5f, 1.6f ); break;
case "brightness": c.GradeBrightness = Math.Clamp( v, 0f, 2f ); break;
case "warmth": c.GradeWarmth = Math.Clamp( v, 0f, 2f ); break;
case "vignette": c.GradeVignette = Math.Clamp( v, 0f, 1f ); break;
case "grain": c.GradeGrain = Math.Clamp( v, 0f, 1f ); break;
case "vignettecolour" or "vignettecolor": c.GradeVignetteColour = value.Trim(); break;
case "tonemap": c.GradeTonemap = value.Trim().ToLowerInvariant() is "hable" or "hablefilmic" ? "" : value.Trim().ToLowerInvariant(); break;
case "off":
c.GradeSaturation = c.GradeContrast = c.GradeBrightness = 1f;
c.GradeWarmth = c.GradeVignette = c.GradeGrain = 0f;
c.GradeVignetteColour = "";
c.GradeTonemap = "";
break;
default:
Log.Warning( "[nz-post] nz_grade [saturation|contrast|brightness|warmth|vignette|grain|vignettecolour|tonemap <value> | off]" );
return;
}
ActiveConfig.NotifyChanged();
Refresh();
Log.Info( $"[nz-post] the grade: {GradeText( c )} · `nz_save` to keep it with the map's config" );
}
/// <summary>Take them back off — see <see cref="Enabled"/>.</summary>
static void Strip( CameraComponent cam )
{
if ( !cam.IsValid() ) return;
cam.Components.Get<Tonemapping>()?.Destroy();
cam.Components.Get<Bloom>()?.Destroy();
cam.Components.Get<ColorAdjustments>()?.Destroy();
cam.Components.Get<ColorGrading>()?.Destroy();
cam.Components.Get<Vignette>()?.Destroy();
cam.Components.Get<FilmGrain>()?.Destroy();
}
/// <summary>
/// `nz_post_refresh` — push the current values onto the camera now.
///
/// ⚠️ THE TICK DOES THIS BY ITSELF when a value it compares changes (<see cref="Look"/>); this is for pushing without waiting
/// a frame, and for putting the effects back after something else took them off.
/// </summary>
[ConCmd( "nz_post_refresh" )]
public static void Refresh()
{
var cam = Game.ActiveScene?.Camera;
if ( !cam.IsValid() ) { Log.Warning( "[nz-post] no scene camera" ); return; }
if ( !Enabled ) { Strip( cam ); Log.Info( "[nz-post] stripped (nz_post 0)" ); return; }
Apply( cam );
Report();
}
/// <summary>`nz_post_report` — what is actually on the camera right now.</summary>
[ConCmd( "nz_post_report" )]
public static void Report()
{
var cam = Game.ActiveScene?.Camera;
if ( !cam.IsValid() ) { Log.Warning( "[nz-post] no scene camera" ); return; }
var tone = cam.Components.Get<Tonemapping>();
var bloom = cam.Components.Get<Bloom>();
Log.Info( $"[nz-post] camera '{cam.GameObject?.Name}'"
+ $" tonemapping {( tone.IsValid() ? $"ON ({tone.Mode})" : "absent" )}"
+ $" bloom {( bloom.IsValid() ? "ON" : "absent" )}"
+ $" grade: {( cam.Components.Get<ColorAdjustments>().IsValid() ? "colour " : "" )}"
+ $"{( cam.Components.Get<ColorGrading>().IsValid() ? "curves " : "" )}"
+ $"{( cam.Components.Get<Vignette>().IsValid() ? "vignette " : "" )}"
+ $"{( cam.Components.Get<FilmGrain>().IsValid() ? "grain" : "" )} — {GradeText( CfgFor( cam.Scene ) )}" );
if ( tone.IsValid() )
Log.Info( $"[nz-post] exposure: auto {tone.AutoExposureEnabled}"
+ $" range {tone.MinimumExposure:0.##}..{tone.MaximumExposure:0.##}"
+ $" bias {tone.ExposureCompensation:0.##} rate {tone.Rate:0.##}" );
if ( bloom.IsValid() )
Log.Info( $"[nz-post] bloom: strength {bloom.Strength:0.##}"
+ $" threshold {bloom.Threshold:0.##}" );
// ⚠️ SAYS WHAT THE THRESHOLD MEANS RATHER THAN ASSERTING WHAT IS IN THE MAP. This line used
// to claim "nothing in the map exceeds 1.0 yet" — true when it was written and FALSE from
// the moment the texture-light materials went emissive at 8x. A diagnostic that states a
// fact about the world goes stale silently; one that explains its own setting does not.
if ( bloom.IsValid() && bloom.Threshold >= 1f )
Log.Info( "[nz-post] threshold >= 1.0, so ONLY surfaces brighter than white bloom — "
+ "emissive light materials do, lit concrete does not. Lower it and the walls "
+ "bloom too." );
}
}