Component that spawns and manages ash particle effects around the camera. It ensures a singleton manager, builds/configures a ParticleEffect with a ParticleBoxEmitter and sprite renderer, updates emitter rate, size and effect properties from console variables and fog weight, and exposes console commands to force/test and report status.
using Sandbox;
namespace NZombies;
/// <summary>
/// Real ash drifting in the air around the player while they stand in a fog area.
///
/// ⛔ THE EMITTER FOLLOWS THE CAMERA; THE PARTICLES DO NOT. This is the whole design, and it is what
/// makes the cost independent of the zone. Filling a drawn volume with particles would cost whatever
/// that volume happens to be — a corridor is cheap and a cavern is not, and the mapper would be
/// choosing a framerate every time they drew an area. Instead a small box rides the camera and
/// spawns into it, while `LocalSpace = 0` leaves every particle standing still in the world once
/// born. You walk THROUGH them, they do not travel with you, and there are never more than
/// `nz_ash_max` of them no matter how big the area is.
///
/// ⚠️ SO THE ZONE ONLY DECIDES THE RATE, NOT THE COUNT. Out of fog the rate is zero and the existing
/// particles live out their lifetime and are gone — which is also the fade-out, for free.
///
/// ⚠️ OVERDRAW IS THE COST HERE, NOT THE PARTICLE COUNT. These are alpha-blended sprites; two
/// hundred small ones are cheap and a dozen big ones near the lens are not. Hence a small `Scale`
/// and `CameraFadeNear`, which fades out anything close enough to cover the screen.
///
/// # MAPPORT: ash overlay
/// </summary>
public sealed class AshParticles : Component
{
public static AshParticles Instance { get; private set; }
protected override void OnAwake() => Instance = this;
public static AshParticles Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Ash Drift";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<AshParticles>();
}
public const string SpritePath = "sprites/nz/nz_ash_mote.sprite";
/// <summary>`nz_ash_particles 0` to rule them out without touching the fog.</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_ash_particles" )] public static new bool Enabled { get; set; } = true;
/// <summary>
/// Ceiling on live particles.
///
/// ⛔ AND IT IS THE BINDING LIMIT, NOT THE RATE — WHICH IS WHY BOTH HAD TO MOVE. Steady state is
/// `Rate × Life`, so 45/s over a 7s life asks for 315 and a cap of 250 silently threw the rest
/// away. Raising the rate alone would have changed nothing at all: the emitter would spawn
/// faster and the cap would drop more.
///
/// ⚠️ 110/s × 7s = 770, against a 700 cap — deliberately just over, so the air stays full rather
/// than thinning whenever a burst of particles happens to expire together.
/// </summary>
/// <remarks>
/// ⛔ A CHANGED DEFAULT DOES NOT REACH A RUNNING EDITOR, AND THE REASON IS THE CONVAR SYSTEM —
/// NOT THE HOTLOAD-STATIC TRAP IT LOOKS LIKE. Raising this from 250 to 700 left the game
/// reporting `rate 45/s of 45, live 117/250` while the source said otherwise, which is the exact
/// signature of a static whose initialiser did not re-run. It is not that. Once `nz_ash_max` has
/// been REGISTERED as a convar, the convar system owns the value and restores it over whatever
/// the property's default says.
///
/// ⚠️ THE TELL WAS IN THE SAME LOG LINE: `size 3.4` read correctly while rate and max were
/// stale. `nz_ash_size` was a NEW convar that had never been registered, so it took the
/// property's default; the other two already existed at their old values. Nullable-backing and a
/// version suffix were both applied here before that was understood — they are harmless and they
/// were not the fix.
///
/// So: a fresh install gets these numbers. A running editor needs `nz_ash_max 700` typed once,
/// and any value the player has set stays set, which is what a convar is supposed to do.
/// </remarks>
[ConVar( "nz_ash_max" )]
public static int MaxParticles
{
get => _maxV2 ??= 700;
set => _maxV2 = value;
}
static int? _maxV2;
/// <summary>Particles per second at full fog weight. Nullable-backed, see MaxParticles.</summary>
[ConVar( "nz_ash_rate" )]
public static float Rate
{
get => _rateV2 ??= 110f;
set => _rateV2 = value;
}
static float? _rateV2;
/// <summary>
/// How far the spawn box reaches from the camera, in units.
///
/// ⚠️ IT HAS TO OUTRUN THE PLAYER. A sprinting player crosses ~320 units a second, so a box
/// much tighter than this empties out ahead of them and refills behind — ash that only exists
/// where you have already been.
/// </summary>
[ConVar( "nz_ash_box" )]
public static float BoxSize
{
get => _boxV2 ??= 700f;
set => _boxV2 = value;
}
static float? _boxV2;
/// <summary>How long each mote lives. Also the fade-out when you leave a zone.</summary>
[ConVar( "nz_ash_life" )]
public static float Life
{
get => _lifeV2 ??= 7f;
set => _lifeV2 = value;
}
static float? _lifeV2;
/// <summary>
/// How big each mote is, in `ParticleEffect.Scale` units.
///
/// ⛔ NOT A 0-1 FRACTION, and assuming it was is what made the whole system invisible. See the
/// note in Build(). Anything below about 1 is smaller than a pixel at playable distances.
/// </summary>
[ConVar( "nz_ash_size" )] public static float Size
{
get => _sizeV2 ??= 3.4f;
set => _sizeV2 = value;
}
static float? _sizeV2;
/// <summary>Each mote's own size, 0.7 to 1.35 of <see cref="Size"/>, from its seed.</summary>
static ParticleFloat SizeSpread => EmberLook.Sizes( Size * 0.7f, Size * 1.35f );
/// <summary>The mote colour. Warm grey ash rather than white snow.</summary>
public static Color Tint
{
get => _tint ??= new Color( 0.78f, 0.70f, 0.60f, 1f );
set => _tint = value;
}
static Color? _tint;
ParticleEffect _fx;
ParticleBoxEmitter _emitter;
ParticleSpriteRenderer _renderer;
/// <summary>Last rate applied, for the status command.</summary>
public float AppliedRate { get; private set; }
/// <summary>How many are alive right now, or -1 if there is no effect.</summary>
public int Live => _fx.IsValid() ? _fx.Particles.Count : -1;
bool Build()
{
if ( _fx.IsValid() ) return true;
_fx = GameObject.Components.GetOrCreate<ParticleEffect>();
// ⛔ CREATED DISABLED, CONFIGURED, THEN TURNED ON — AND THAT ORDER IS NOT COSMETIC.
// `ParticleBoxEmitter.Burst` defaults to 100 and the burst fires from the component's own
// enable, which happens BEFORE the next line can set it to 0. Measured: a hundred motes in
// the air with the rate still reading `0/s`, appearing as a puff of ash every time a map
// loaded, in the one state where there should be none at all.
_emitter = GameObject.Components.Create<ParticleBoxEmitter>( false );
_renderer = GameObject.Components.GetOrCreate<ParticleSpriteRenderer>();
_fx.MaxParticles = MaxParticles;
_fx.Lifetime = Life;
// ⛔ WORLD SPACE, AND `LocalSpace` IS HOW YOU SAY SO. At 1 every mote would be glued to the
// emitter, riding along with the camera — and the entire point, moving through them, would be
// gone. This one number separates "ash in the air" from "ash on the visor".
//
// ⚠️ NOT `ParticleEffect.Space`, WHICH COMPILES AND IS OBSOLETE — the compiler says "use
// LocalSpace instead". Same value, and the deprecated one is the sort that disappears in an
// engine update.
_fx.LocalSpace = 0f;
// ⚠️ DRIFT, NOT GRAVITY. Ash is light enough that it hangs and slides rather than falls, so
// the downward component is small and there is a sideways one. `ConstantMovement` ignores
// drag and collisions, which is right for something this light and saves the simulation.
_fx.ConstantMovement = new Vector3( 6f, 3f, -11f );
// A little spread so they are not a marching grid.
_fx.StartVelocity = 5f;
_fx.Damping = 0.35f;
_fx.ApplyShape = true;
// ⛔ 3.4, AND IT WAS 0.55 — WHICH IS WHY THEY LOOKED LIKE AN OVERLAY AND NOTHING ELSE.
// `ParticleEffect.Scale` is not a 0-1 fraction; `VultureStink` already records that it runs
// at roughly 2.5x a Source PCF radius. At 0.55 each mote was SUB-UNIT — they simulated
// perfectly, the counts were right, the status command reported healthy, and on screen they
// were one or two pixels that read as compression noise. Reported as *"the ashes are an
// overlay at the moment"*: the real particles were there and invisible, so the only thing
// visible was the screen layer.
//
// ⚠️ MEASURED AGAINST A HUMAN FIGURE at 200-900 units, not chosen. 4 was slightly too much
// and 0.55 was nothing. ⚠️ AND NOW A SPREAD ABOUT IT (2026-09-28): 0.7 to 1.35 of it, each mote its own, so the air is
// not a field of identical dots.
_fx.Scale = SizeSpread;
_fx.InitialScale = 1f;
_fx.ApplyAlpha = true;
_fx.ApplyColor = true;
// ⚠️ IN AND OUT, NOT POPPING (2026-09-28): `ApplyAlpha` was on with no `Alpha`, so every mote appeared and vanished at full
_fx.Alpha = EmberLook.FadeInOut( 0.85f );
// ⚠️ AND EACH SWAYS ON ITS OWN, gently — the whole air no longer sliding in one direction in parallel lines
_fx.OnStep = EmberLook.Wander( 9f );
// ⚠️ WARM GREY, NOT WHITE. White motes on a pale background are snow; the warm tint is what
// makes them read as ash. On a dark map a LIGHT particle is still correct — soot is dark in
// the hand and bright in the air, because what you see is the light it catches.
_fx.Tint = Tint;
// ⛔ NO COLLISION. Two hundred colliding particles is two hundred traces a frame, to stop ash
// passing through a wall you cannot see it against anyway.
_fx.Collision = false;
_emitter.Size = BoxSize;
_emitter.Burst = 0;
_emitter.Rate = 0f;
_emitter.Loop = true;
_emitter.Duration = 1f;
// Safe to run now that Burst is zero.
_emitter.Enabled = true;
// ⚠️ NOT LIT AND NOT FOGGED. `Lighting` would make each mote shade against the room, which on
// a dark map turns them black; `FogStrength` at 1 would then have the gradient fog wash them
// out exactly where the fog is thickest — the one place they are meant to be visible.
_renderer.Lighting = false;
_renderer.FogStrength = 0f;
_renderer.Additive = false;
_renderer.Alignment = ParticleSpriteRenderer.BillboardAlignment.LookAtCamera;
_renderer.SortMode = ParticleSpriteRenderer.ParticleSortMode.ByDistance;
// ⚠️ FADES OUT WHAT IS ON THE LENS. A mote a few units from the camera covers a quarter of
// the screen and reads as a smudge rather than as a particle, and it is also the worst case
// for overdraw. Both problems have the same fix.
_renderer.CameraFadeNear = 40f;
var sprite = ResourceLibrary.Get<Sprite>( SpritePath );
if ( sprite is null )
{
// ⛔ SAYS SO LOUDLY. A null sprite renders NOTHING while the effect happily simulates
// hundreds of particles — the emitter reports healthy, the count is right, and the
// screen is empty. That is the hardest possible version of this bug to find.
Log.Warning( $"[nz-ash] '{SpritePath}' did not load — particles will simulate and draw"
+ " NOTHING. Check the .sprite compiled." );
}
else
{
_renderer.Sprite = sprite;
}
return true;
}
protected override void OnUpdate()
{
if ( !Build() ) return;
var cam = Scene.Camera;
var fog = FogAreaManager.Instance;
var weight = Enabled && fog.IsValid() ? fog.Weight.Clamp( 0f, 1f ) : 0f;
// `nz_ash_test` overrides the fog for a few seconds.
if ( Time.Now < _forceUntil ) weight = 1f;
// ⚠️ THE BOX RIDES THE CAMERA EVERY FRAME, not the player. The camera trails the body and
// can be a long way from it while spectating or downed — and it is the camera that decides
// what is on screen. `nz_corner_at` records the same distinction costing time elsewhere.
if ( cam.IsValid() )
WorldPosition = cam.WorldPosition;
AppliedRate = Rate * weight;
if ( _emitter.IsValid() ) _emitter.Rate = AppliedRate;
// Live-tunable without a rebuild.
if ( _fx.IsValid() )
{
_fx.MaxParticles = MaxParticles;
_fx.Lifetime = Life;
_fx.Scale = SizeSpread;
_fx.Tint = Tint;
}
if ( _emitter.IsValid() ) _emitter.Size = BoxSize;
}
/// <summary>
/// `nz_ash_test [seconds]` — force them on here, regardless of fog.
///
/// ⛔ BECAUSE "ARE THEY EVEN THERE" WAS UNANSWERABLE WITHOUT ONE. Seeing the particles required
/// standing inside a drawn fog area, and both test maps drop the player out of the world within
/// seconds — so the only way to check was to author a zone, respawn into it and hope. That is
/// also why they stayed invisible at a sub-unit scale for a whole revision: nothing made the
/// question cheap to ask.
/// </summary>
[ConCmd( "nz_ash_test" )]
public static void Test( float seconds = 8f )
{
var m = Ensure( Game.ActiveScene );
if ( !m.IsValid() ) { Log.Warning( "[nz-ash] no manager" ); return; }
m._forceUntil = Time.Now + seconds.Clamp( 0.5f, 120f );
Log.Info( $"[nz-ash] forced on for {seconds:0.#}s at {Rate:0.#}/s"
+ $" size {Size:0.##} box {BoxSize:0}u — look around, they are in WORLD space" );
}
float _forceUntil;
/// <summary>
/// `nz_ash_particles_status` — what is in the air and why.
///
/// ⛔ IT NAMES THE FOG WEIGHT, THE RATE AND THE SPRITE SEPARATELY, because "no ash" has four
/// causes that look identical from inside the game: switched off, not in a fog area, an emitter
/// producing nothing, or a sprite that failed to load so hundreds of invisible particles are
/// simulating perfectly. `VultureStink` learned the same lesson and its diagnostic says so.
/// </summary>
[ConCmd( "nz_ash_particles_status" )]
public static void Status()
{
var m = Ensure( Game.ActiveScene );
if ( !m.IsValid() ) { Log.Warning( "[nz-ash] no manager" ); return; }
var fog = FogAreaManager.Instance;
var w = fog.IsValid() ? fog.Weight : 0f;
Log.Info( $"[nz-ash] particles {( Enabled ? "on" : "OFF (nz_ash_particles 1)" )}"
+ $" fog weight {w:0.###} rate {m.AppliedRate:0.#}/s of {Rate:0.#}"
+ $" live {m.Live}/{MaxParticles} size {Size:0.##} box {BoxSize:0}u"
+ $" life {Life:0.#}s"
+ ( Time.Now < m._forceUntil ? " (FORCED, nz_ash_test)" : "" ) );
// ⚠️ THE SIZE IS CALLED OUT BECAUSE IT IS THE ONE THAT HID EVERYTHING. A sub-unit scale
// reports as a perfectly healthy system: right counts, right rate, nothing on screen.
if ( Size < 1f )
Log.Warning( $"[nz-ash] ⚠ size {Size:0.##} is SUB-UNIT — they will be a pixel or less."
+ " nz_ash_sizeV2 3.4" );
var sprite = ResourceLibrary.Get<Sprite>( SpritePath );
if ( sprite is null )
Log.Warning( $"[nz-ash] ⚠ '{SpritePath}' DID NOT LOAD — they simulate and draw nothing" );
if ( w <= 0.001f )
Log.Info( "[nz-ash] fog weight is zero — you are not inside a fog area (nz_fog_report)" );
}
}