Manager component that controls scene fog areas. It finds the strongest FogArea around the local player, cross-fades a global GradientFog component (color, height, distances) and optionally builds stacked translucent sheet geometry so authored fog volumes are visible from outside.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Cross-fades the scene's fog to whatever area the listener is standing in.
///
/// ⛔ THERE IS ONE FOG IN THE SCENE, NOT ONE PER AREA, AND THAT IS THE ENGINE'S DOING.
/// `VolumetricFogVolume` is the component built to hang fog inside a box, and on the scene path it
/// renders NOTHING — tested with Bounds set and Strength at 25, with and without a
/// `VolumetricFogController`, and the frame came back pixel-identical. Its own controller explains
/// it: the thing exists "to fetch the baked fog texture FROM THE MAP FILE". Volumetric fog joins
/// lightmaps and shadow baking on the list of map-compile features this project cannot reach.
///
/// `GradientFog` does work, and it is global. So the areas vote on what the one global fog looks
/// like, weighted by how deep inside you are — and that alone was invisible from outside, which is
/// the limitation this file carried from the day it was written.
///
/// ⛔ SO EACH AREA ALSO GETS GEOMETRY NOW, AND THAT IS WHAT MAKES IT VISIBLE FROM OUTSIDE. Stacked
/// flat sheets across the drawn footprint, shaded by `lavafog.shader` — the same layered trick the
/// lava fog proved out, for the same reason: a hollow box only has surfaces at its edges, so a
/// volume has to be SAMPLED at many heights rather than enclosed once.
///
/// ⚠️ THE TWO HALVES DO DIFFERENT JOBS AND BOTH ARE NEEDED. The sheets are the bank you see across
/// the room; the GradientFog is what closes in once you are inside it, which no amount of geometry
/// gives you because you cannot draw sheets between the camera and its own near plane. Screen-space
/// for being in it, geometry for looking at it — the same split the ash settled into.
///
/// ⚠️ SO THE COST IS FLAT, WHICH IS THE POINT. One component, a handful of floats lerped ten times
/// a second, and a distance check per area. Twenty fog areas cost the same to render as one, and
/// none of them cost anything when you are not in them. On a map that has to stay smooth, that is
/// the whole reason this shape was chosen rather than worked around.
///
/// # MAPPORT: fog placeable
/// </summary>
public sealed class FogAreaManager : Component
{
public static FogAreaManager Instance { get; private set; }
protected override void OnAwake() => Instance = this;
public static FogAreaManager Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Fog Area Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<FogAreaManager>();
}
/// <summary>
/// `nz_fogzone 0` to rule the drawn zones out without clearing anything.
///
/// ⛔ NOT `nz_fog`, WHICH WAS TAKEN AND SILENTLY LOST. `SpecialFog` — the round manager's fog —
/// already registers `nz_fog` as a ConCmd, so this ConVar never registered at all: the engine
/// logged `Convar nz_fog already exists - not overwriting` once at startup and then every
/// `nz_fog 0` went to the OTHER system, which answered about SpecialFog. The switch did nothing
/// and said something plausible while doing it.
///
/// ⚠️ THE WARNING IS ONE LINE AT LOAD, AMONG HUNDREDS. Console noise at startup is where a
/// collision like this hides — it was on screen for weeks before a command that happened to
/// answer wrongly gave it away.
/// </summary>
// ⚠️ `new` BECAUSE THIS DELIBERATELY HIDES `Component.Enabled`, AND THE COMPILER WAS RIGHT TO
// ASK. Every bare `Enabled` inside this class means THE CONVAR — which is what the call sites
// want — but a future `Enabled = false` written to disable the component would instead switch
// the feature off globally for everyone. The keyword states the intent; if that trap ever bites,
// rename this rather than removing the keyword.
[ConVar( "nz_fogzone" )] public static new bool Enabled { get; set; } = true;
/// <summary>`nz_fog_visible 0` drops the geometry and leaves only the blended GradientFog.</summary>
[ConVar( "nz_fog_visible" )] public static bool Visible { get; set; } = true;
/// <summary>
/// Sheets per area.
///
/// ⛔ THE RESOLUTION OF THE VOLUME AND THE COST. Each sheet is a full-area translucent pass, so
/// overdraw is this times the zone's screen coverage times the number of zones you can see at
/// once — and unlike the lava, a map can have many zones. Ten reads as continuous; drop it first
/// if a foggy room gets heavy.
/// </summary>
[ConVar( "nz_fog_layers" )] public static int Layers { get; set; } = 10;
/// <summary>
/// How thick each sheet is drawn.
///
/// ⚠️ MUCH LOWER THAN IT LOOKS, BECAUSE TEN OF THEM ACCUMULATE. The drawn zones already carry
/// their own `Density`; this scales it per sheet so the stack lands somewhere sane rather than
/// ten times too solid.
/// </summary>
[ConVar( "nz_fog_sheet" )] public static float SheetDensity { get; set; } = 0.11f;
/// <summary>
/// Seconds the fog holds at full strength after you leave a zone, before it starts fading.
///
/// ⛔ ON THE WAY OUT ONLY — ENTERING IS STILL IMMEDIATE. A delay in both directions is just a
/// slower blend, and it would mean walking into a bank and standing in clear air for two seconds
/// while the haze catches up. Air you have just left staying thick is how fog behaves; air you
/// have just entered staying clear is not.
///
/// ⚠️ IT HOLDS, THEN FADES — it does not slow the fade down. After the hold the area's own
/// `Blend` runs exactly as before, so the character of the fade is unchanged and only its start
/// is late.
/// </summary>
[ConVar( "nz_fog_hold" )] public static float Hold { get; set; } = 2f;
/// <summary>
/// While you are inside ANY zone, the fog never thins — it only holds or gets thicker.
///
/// ⛔ BECAUSE A ZONE'S EDGE WEIGHS ALMOST NOTHING, AND THAT IS WHAT MADE IT CLEAR. The weight is
/// a smoothstep from the boundary inward, so the moment you cross into a new zone its target is
/// near ZERO — lower than the fog you are already standing in. Walking from one bank straight
/// into another therefore asked the fog to fade, and the two-second hold only postponed it:
/// stand near the edge of the second zone and it clears while you are inside fog.
///
/// ⚠️ THE TRADE IS THAT A DENSE ZONE DOES NOT THIN OUT INTO A LIGHT ONE UNTIL YOU LEAVE BOTH.
/// That is the behaviour that was asked for — fog that persists while you are in fog — and it is
/// the right call for walking a foggy map, but it does mean the zones' densities stop being a
/// gradient you can walk down. `nz_fog_keep 0` restores the strict per-zone behaviour.
/// </summary>
[ConVar( "nz_fog_keep" )] public static bool KeepWhileInside { get; set; } = true;
TimeSince _sinceFalling;
public const string SheetMaterial = "materials/nz/lavafog.vmat";
readonly List<GameObject> _sheets = new();
/// <summary>How many sheet objects are standing.</summary>
public int SheetCount => _sheets.Count;
GradientFog _fog;
/// <summary>What the blend is currently at — kept so a report can print it.</summary>
public float Weight { get; private set; }
/// <summary>
/// An area that holds the listener wherever they stand, or null: its look at full weight, over every drawn area — and
/// with it the ash overlay and the ash motes, which both follow <see cref="Weight"/>. Set by basalt's rising lava for
/// the rounds it is out of its bed (`HexPlatforms.Lava.cs`). LOCAL, like the fog itself.
/// </summary>
public static FogArea Forced { get; set; }
/// <summary>
/// True while the fog is being held after leaving a zone.
///
/// ⚠️ WORTH REPORTING, because during the hold the fog and the weight disagree with where you
/// are standing — which is the intended behaviour and looks exactly like the blend being stuck.
/// </summary>
public bool Holding { get; private set; }
public Color Tint { get; private set; }
public float EndDistance { get; private set; }
public string Inside { get; private set; } = "-";
TimeSince _sinceTick;
/// <summary>
/// The fog component, created next to the manager.
///
/// ⚠️ NOT ON THE CAMERA. `NZPostProcess` attaches tonemapping and bloom to `Scene.Camera`
/// because those are camera effects; `GradientFog` is a world effect and survives the camera
/// being replaced — which happens on every respawn and every map load.
/// </summary>
GradientFog Fog()
{
if ( _fog.IsValid() ) return _fog;
_fog = GameObject.Components.GetOrCreate<GradientFog>();
return _fog;
}
protected override void OnUpdate()
{
// Ten times a second. The blend below is time-based, not tick-based, so a coarse tick
// changes nothing about how smooth the fade looks.
if ( _sinceTick < 0.1f ) return;
var dt = (float)_sinceTick;
_sinceTick = 0f;
Tick( dt );
}
void Tick( float dt )
{
var fog = Fog();
if ( !fog.IsValid() ) return;
var list = ActiveConfig.Current?.Fog;
var p = NZPlayer.Local;
FogArea best = null;
var bestWeight = 0f;
if ( Enabled && list is { Count: > 0 } && p.IsValid() )
{
var ear = p.WorldPosition;
foreach ( var a in list )
{
var w = WeightAt( a, ear );
// ⚠️ THE STRONGEST AREA WINS RATHER THAN THE SUM. Two overlapping patches of soot
// should not be twice as thick as either — that is how a quiet overlap becomes an
// opaque wall exactly where two authored areas meet, which is the seam a mapper is
// least likely to look at and most likely to walk through.
if ( w <= bestWeight ) continue;
bestWeight = w;
best = a;
}
}
// ⛔ A FORCED AREA WINS OUTRIGHT, WHEREVER THE LISTENER STANDS — above the loop's result, at full weight
if ( Forced is not null && Enabled )
{
best = Forced;
bestWeight = 1f;
}
// The target: the winning area's look at its weight, or clear air at zero.
var targetWeight = best is null ? 0f : bestWeight * best.Density.Clamp( 0f, 1f );
var targetTint = best is null ? Tint : ( Color.Parse( best.Color ) ?? Color.Black );
// ⛔ THE TOP OF THE DRAWN PRISM, IN WORLD Z. `GradientFog.Height` is the world altitude at
// which the haze has thinned away, not a thickness above wherever the volume sits — so this
// has to be an absolute, and an area drawn on a floor at z≈1200 has to say 1200-and-up. An
// earlier version passed an authored thickness straight through, which on Basalt described a
// fog column ending a thousand units UNDERGROUND: nothing rendered, nothing errored, and
// every number in the config read correctly.
//
// ⚠️ Now it comes from the shape the mapper actually drew, so there is no second number to
// keep in step with the volume.
var targetHeight = best is null
? 1200f
: best.Position.z + ( best.Size.z * 0.5f );
// ⛔ DENSITY IS EXPRESSED AS A DISTANCE, BECAUSE THAT IS WHAT THE COMPONENT TAKES.
// `GradientFog` has no opacity — it has a start and an end distance, and "thicker" means
// "the end is nearer".
//
// ⛔ AND THE RELATIONSHIP IS INVERSE, NOT LINEAR. The first version mapped 0..1 onto
// 4000..300 units, which put the default density of 0.18 at an end distance of 3334 — and
// MEASURED on a row of figures at 200/500/900/1600 units, 3334 is invisible and so is 2150.
// Fog only began to read at about 900. A linear map spends almost its whole range in the
// region where nothing is visible, so every density below 0.7 looked identical and the
// setting felt broken rather than subtle.
//
// 160/d is the inverse curve that actually spreads the control out:
// 0.05 -> 3200u (a suggestion) 0.18 -> 900u (the default, light but present)
// 0.50 -> 320u (heavy) 1.00 -> 160u (a wall)
var targetEnd = targetWeight <= 0.001f
? 20000f
: ( 160f / targetWeight ).Clamp( 120f, 20000f );
// ⛔ THE HOLD PINS THE TARGET, IT DOES NOT SKIP THE UPDATE. Skipping would freeze the TINT and
// the end distance too, so a zone left while its colour was still crossfading would sit
// half-way between two colours for the whole hold. Pinning only the weight lets everything
// else keep resolving.
var falling = targetWeight < Weight;
// ⚠️ INSIDE ANY ZONE AT ALL, however weakly. `bestWeight` is the raw containment weight
// before density is applied, so this is "is the player within some zone's bounds" rather
// than "is the fog thick" — which is the question that matters here.
var inside = bestWeight > 0f;
if ( falling && ( ( KeepWhileInside && inside ) || _sinceFalling < Hold ) )
{
targetWeight = Weight;
Holding = true;
}
else
{
Holding = false;
// ⛔ THE TIMER RESETS WHILE INSIDE A ZONE TOO, NOT ONLY WHEN RISING. Otherwise stepping
// out of a second zone would carry over however much of the hold had already elapsed
// crossing the first one, and the two-second delay would be whatever was left of it.
if ( !falling || inside ) _sinceFalling = 0f;
}
// Blend time comes from the area being entered, or from the one being left.
var blend = MathF.Max( 0.05f, best?.Blend ?? 0.75f );
var t = ( dt / blend ).Clamp( 0f, 1f );
Weight = Weight.LerpTo( targetWeight, t );
Tint = Color.Lerp( Tint, targetTint, t );
EndDistance = EndDistance.LerpTo( targetEnd, t );
Inside = best is null
? "-"
: $"{best.Color} d{best.Density:0.##} {best.Footprint.Count}-sided {best.Size.z:0} tall";
fog.Color = Tint;
fog.Height = targetHeight;
// ⚠️ FLAT 1, AND 2.5 WAS TRIED AND MADE IT WORSE. Pooling the haze toward the floor sounds
// truer — soot settles — but on a GradientFog whose Height is already the top of a drawn
// volume, a steep exponent just thins the whole column and the areas read as weaker rather
// than as lower. Reported plainly: "the fog that existed became worse".
fog.VerticalFalloffExponent = 1f;
fog.StartDistance = 0f;
fog.EndDistance = EndDistance;
fog.FalloffExponent = 1.6f;
// ⚠️ DISABLED RATHER THAN SET TO NOTHING WHEN THERE IS NO FOG. A GradientFog with a huge end
// distance is still a fullscreen pass doing arithmetic that resolves to "unchanged"; a map
// with no fog areas should pay nothing at all for the feature.
fog.Enabled = Weight > 0.002f;
}
/// <summary>
/// How much say this area has at a point — 1 well inside it, falling to 0 at the boundary.
///
/// ⛔ MEASURED INWARD FROM EVERY FACE, INCLUDING THE CAPS. The nearest boundary might be a wall
/// of the polygon, the top of the prism or its floor, and only the smallest of those describes
/// how deep inside you actually are. Using the polygon alone would snap the haze on and off as
/// you step over the top edge of an area drawn around a walkway.
///
/// ⚠️ SMOOTHSTEP, NOT LINEAR. A linear ramp has a visible corner at both ends, and on a fog
/// colour that reads as the fog "starting" at a particular step rather than gathering.
/// </summary>
public static float WeightAt( FogArea a, Vector3 point )
{
var local = a.Rotation.Inverse * ( point - a.Position );
var half = a.Size.z * 0.5f;
// Outside the prism vertically.
if ( local.z < -half || local.z > half ) return 0f;
// ⛔ THE TOP FEATHERS AND THE FLOOR DOES NOT, AND FEATHERING BOTH WAS WRONG. An area is drawn
// standing ON a floor, so its lower cap sits at the surface the player walks on — and fading
// from it meant the haze was at its THINNEST exactly at eye height. Measured: an area 440
// tall drawn at a spawn gave a weight of 0.104 to a player standing in the middle of it,
// which reads as the feature barely working.
//
// ⚠️ The floor is a hard boundary because it is a floor; there is no "just below" to fade
// into. The top still fades, because ash genuinely does thin out as it rises.
var dz = half - local.z;
var xy = new Vector2( local.x, local.y );
float dEdge;
if ( a.HasFootprint )
{
if ( !InPolygon( xy, a.Footprint ) ) return 0f;
dEdge = DistanceToEdge( xy, a.Footprint );
}
else
{
// ⚠️ A DRAWN AREA ALWAYS HAS A FOOTPRINT; THIS IS FOR ONE BUILT BY HAND. `nz_fog_set`
// can only reach an existing entry, but a config edited by hand can carry a Size with
// no outline, and falling through to "no fog" would look like the area being ignored.
var hx = ( a.Size.x * 0.5f ) - MathF.Abs( local.x );
var hy = ( a.Size.y * 0.5f ) - MathF.Abs( local.y );
if ( hx <= 0f || hy <= 0f ) return 0f;
dEdge = MathF.Min( hx, hy );
}
var feather = MathF.Max( 1f, a.Feather );
var x = ( MathF.Min( dEdge, dz ) / feather ).Clamp( 0f, 1f );
return x * x * ( 3f - 2f * x );
}
/// <summary>
/// Crossing-number point-in-polygon.
///
/// ⚠️ WORKS FOR CONCAVE SHAPES, which matters: the block builder explicitly allows a non-convex
/// footprint, so a convex-only test would quietly mis-handle exactly the shapes this is for.
/// The `(a.y > y) != (b.y > y)` form counts each edge once at a shared vertex, so a point level
/// with a corner is not double-counted and reported outside. Same test as
/// `DamageWallVolume.InPolygon` — deliberately, because a fog area and a damage wall are drawn
/// by the same tool and must agree about what "inside" means.
/// </summary>
static bool InPolygon( Vector2 pt, IReadOnlyList<Vector2> poly )
{
if ( poly is null || poly.Count < 3 ) return false;
var inside = false;
for ( int i = 0, j = poly.Count - 1; i < poly.Count; j = i++ )
{
Vector2 a = poly[i], b = poly[j];
if ( ( a.y > pt.y ) != ( b.y > pt.y )
&& pt.x < ( b.x - a.x ) * ( pt.y - a.y ) / ( b.y - a.y ) + a.x )
{
inside = !inside;
}
}
return inside;
}
/// <summary>Shortest distance from an interior point to the outline.</summary>
static float DistanceToEdge( Vector2 pt, IReadOnlyList<Vector2> poly )
{
var best = float.MaxValue;
for ( int i = 0; i < poly.Count; i++ )
{
var a = poly[i];
var b = poly[( i + 1 ) % poly.Count];
var ab = b - a;
var len2 = ab.LengthSquared;
// A degenerate edge — two corners clicked in the same place — would divide by zero.
var t = len2 <= 0.0001f ? 0f : ( ( pt - a ).Dot( ab ) / len2 ).Clamp( 0f, 1f );
best = MathF.Min( best, pt.Distance( a + ab * t ) );
}
return best;
}
/// <summary>Drop the blend so a rebuild does not fade from the old map's fog.</summary>
public void Rebuild()
{
Weight = 0f;
EndDistance = 20000f;
if ( _fog.IsValid() ) _fog.Enabled = false;
BuildSheets();
var n = ActiveConfig.Current?.Fog?.Count ?? 0;
Log.Info( $"[nz-fog] {n} fog area(s), {SheetCount} sheet(s)"
+ ( n == 0 ? "" : $" — visible from outside, {Layers} per area" ) );
}
/// <summary>
/// The geometry that makes a zone visible from across the room.
///
/// ⚠️ ONE MESH PER AREA, REUSED BY ITS SHEETS. `DebrisMesh.Build` triangulates the footprint,
/// which is real work on a many-sided outline — building it ten times per zone would be ten
/// times the cost for identical vertices.
/// </summary>
void BuildSheets()
{
foreach ( var g in _sheets ) g?.Destroy();
_sheets.Clear();
var list = ActiveConfig.Current?.Fog;
if ( !Enabled || !Visible || list is null || list.Count == 0 ) return;
var mat = Material.Load( SheetMaterial );
if ( mat is null )
{
Log.Warning( $"[nz-fog] '{SheetMaterial}' did not load — zones will only show from inside" );
return;
}
var n = Layers.Clamp( 1, 48 );
for ( int i = 0; i < list.Count; i++ )
{
var a = list[i];
if ( !a.HasFootprint || a.Density <= 0.001f ) continue;
var model = DebrisMesh.Build( a.Footprint, 2f, mat );
if ( model is null ) continue;
// The prism runs from z=0 up and the area's origin is its middle — the convention the
// wall tools set, and the one `WeightAt` already assumes.
var baseZ = a.Position.z - ( a.Size.z * 0.5f );
var tint = Color.Parse( a.Color ) ?? Color.White;
var root = Scene.CreateObject();
root.Name = $"Fog Sheets {i}";
root.Flags |= GameObjectFlags.NotSaved;
root.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
root.SetParent( GameObject );
root.WorldPosition = a.Position.WithZ( baseZ );
root.WorldRotation = a.Rotation;
for ( int s = 0; s < n; s++ )
{
var frac = n == 1 ? 0f : (float)s / ( n - 1 );
var sheet = Scene.CreateObject();
sheet.Name = $"sheet {s}";
sheet.Flags |= GameObjectFlags.NotSaved;
sheet.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
sheet.SetParent( root );
sheet.LocalPosition = Vector3.Up * ( frac * a.Size.z );
sheet.LocalRotation = Rotation.Identity;
var mr = sheet.Components.Create<ModelRenderer>();
mr.Model = model;
// ⛔ PER-RENDERER, BECAUSE ONE MATERIAL SERVES EVERY ZONE ON THE MAP. A material
// parameter would give them all whichever zone's colour and height were written
// last — and zones differ in both by design.
if ( mr.SceneObject.IsValid() )
{
mr.SceneObject.Attributes.Set( "g_flLavaFogBase", baseZ );
mr.SceneObject.Attributes.Set( "g_flLavaFogHeight", a.Size.z );
mr.SceneObject.Attributes.Set( "g_vLavaFogTint", new Vector3( tint.r, tint.g, tint.b ) );
mr.SceneObject.Attributes.Set( "g_flLavaFogDensity",
( a.Density * SheetDensity ).Clamp( 0f, 1f ) );
}
}
_sheets.Add( root );
}
}
protected override void OnDestroy()
{
// ⚠️ The sheets are children of this object and go with it, but the list must not survive
// into a new manager holding destroyed handles.
_sheets.Clear();
if ( _fog.IsValid() ) _fog.Enabled = false;
if ( Instance == this ) Instance = null;
}
}