Weapons/TravelTracer.cs

Client-side renderer for travelling bullet tracers. Manages a pooled set of Round and Ember objects, draws streak lines, glow heads and smoke using LineRenderer and SpriteRenderer, updates per-frame via a Driver component, and exposes console commands to configure and test the effect.

File Access
using Sandbox;
using System;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// THE TRAVELLING TRACER — a round you can watch cross the gap: a white-hot head, a tail that cools towards red, a soft glow
/// round it, and a burning ember where it lands. The look was chosen on `Docs/tracer_lab.html`, and its defaults are the
/// defaults here (400 m/s, a 12 m streak, 1.6 cm wide, brightness 1.3, lit from 1 m out).
///
/// ⛔ THE HIT IS STILL INSTANT. The user: *"hits are instant, the tracer is not"*. Damage, sparks and the decal all land the
/// frame you fire, exactly as before (`HitScanBulletInfo.SpawnEffects`); this only shows the way the round went, and gets
/// there after. Arriving, it adds nothing but its ember.
///
/// ⛔ NOTHING HERE LIGHTS ANYTHING. The user: *"the rounds should not iluminate the surroundings as that is laggy"* / *"they
/// can be self illuminated though"*. There is no light in this file: every piece is additive and unlit, brighter than white
/// where it should glow, and `NZPostProcess`'s bloom (threshold 1.0) does the glowing.
///
/// ⚠️ POOLED, LIKE `FastTracer`, AND FOR ITS REASON. The particle tracer this project gave up cost ~667us to clone and a
/// simulation per streak per frame. Here a round is a slot made once and reused: firing one is a few field writes, and a frame
/// is some points, a gradient and a width curve per line.
///
/// ⚠️ NEVER TOO THIN TO SEE. Every width is held at a pixel minimum (core ~1.7 px, glow ~7 px, head ~12 px), and dims a little
/// when it is held, so a far tracer reads as a thin bright thread instead of fading out as a plain line does.
///
/// ⚠️ A RICOCHET IS THE SAME ROUND, CARRYING ON. The shot path draws each leg as its own tracer, the next one starting where
/// the last one stopped (`tracerSegmentStart`). The leg that starts where a live round is about to arrive waits for it, and
/// flies on slower, dimmer and pulsing as it tumbles, as on the lab page. No ember is left at the bounce: the round went on.
/// </summary>
public static class TravelTracer
{
	/// <summary>Game units in a metre (1 u = 1 inch).</summary>
	const float Metre = 39.37f;

	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.

	static bool? _enabled;
	/// <summary>Draw shots with this tracer. On — `nz_tracer_style` picks the line or the particle instead.</summary>
	public static bool Enabled { get => _enabled ?? true; set => _enabled = value; }

	static float? _speed;
	/// <summary>How fast the round flies, units/s. 15,748 (400 m/s).</summary>
	public static float Speed { get => _speed ?? 400f * Metre; set => _speed = value; }

	static float? _length;
	/// <summary>How long the streak behind the head is, units. 472 (12 m).</summary>
	public static float Length { get => _length ?? 12f * Metre; set => _length = value; }

	static float? _width;
	/// <summary>The core's width at the head, units. 0.63 (1.6 cm). The glow is 5.2 times it, the head 7 times.</summary>
	public static float Width { get => _width ?? 0.016f * Metre; set => _width = value; }

	static float? _brightness;
	/// <summary>How bright the round burns. 1.3.</summary>
	public static float Brightness { get => _brightness ?? 1.3f; set => _brightness = value; }

	static float? _ignite;
	/// <summary>
	/// How far out the tracer compound lights, units. 39 (1 m).
	/// </summary>
	///
	/// ⚠️ CLEAR OF THE MUZZLE FLASH, AND OUT OF THE FACE. In first person the muzzle is a couple of feet from the eye; a streak
	/// lit from the barrel would be a bar across the view before it was a round.
	public static float Ignite { get => _ignite ?? 1f * Metre; set => _ignite = value; }

	static bool? _smoke;
	/// <summary>A faint smoke line along the path, spreading and thinning behind the round. On.</summary>
	public static bool Smoke { get => _smoke ?? true; set => _smoke = value; }

	static bool? _embers;
	/// <summary>The compound still burning where the round stopped. On.</summary>
	public static bool Embers { get => _embers ?? true; set => _embers = value; }

	static int? _poolSize;
	/// <summary>
	/// How many rounds may be in the air (or still smoking) at once. 64.
	/// </summary>
	///
	/// ⚠️ A CEILING, NOT A QUEUE. Past it the oldest is reused, one that is only smoking first, so a long burst never grows
	/// the pool mid-fight.
	public static int PoolSize { get => _poolSize ?? 64; set => _poolSize = value; }

	// ── the lab's constants ──────────────────────────────────────────────
	//
	// ⚠️ THE GAINS ARE THE LAB'S, CARRIED FROM ITS SOFT RIBBONS TO FLAT LINES BY THEIR MEAN LIGHT. The page's ribbons fall off
	// across their width as (1 - smoothstep(across))², which averages 0.37 of the centre; a `LineRenderer` is flat across. So:
	//   core     CORE_GAIN 2.2  x 0.37 = 0.81 on the body colour;
	//   white    its hot centre falls off faster, (1 - smoothstep(0, 0.55, across)), mean 0.275: 2.2 x 0.275 = 0.6;
	//   glow     GLOW_GAIN 0.2  x 0.37 = 0.074, drawn as two flat layers (full width and half) of 0.05 each, so its edge still
	//            falls off: 0.05 at the rim, 0.1 down the middle;
	//   head     HEAD_GAIN 0.6 as it is — the sprite is soft already.
	// The pixel minimums are the lab's full widths (it states half-widths: 0.85, 3.5) and its dimming exponents.

	const float CoreGain = 0.81f;
	const float HeatGain = 0.6f;
	const float GlowGain = 0.05f;
	const float HeadGain = 0.6f;
	const float CoreMinPx = 1.7f, GlowMinPx = 7f, HeadMinPx = 12f;
	const float CoreDim = 0.35f, GlowDim = 0.2f, HeadDim = 0.35f;
	const float SmokeLife = 1.5f;

	/// <summary>Points along each streak line.</summary>
	///
	/// ⛔ MORE THAN TWO, OR THE COLOUR RAMP IS LOST. A line takes its colour and width at its points and blends between them,
	/// so two points would draw one straight fade from tail to head. The white-hot head is the last fifth of the ramp and only
	/// exists if there are points there to carry it.
	const int Points = 8;
	const int SmokePoints = 6;

	static readonly Color WhiteHot = new( 1f, 0.95f, 0.86f );
	static readonly Color SmokeGrey = new( 0.55f, 0.57f, 0.6f );

	/// <summary>The soft glow the head and the ember are drawn with — the power-up glow, already in the project.</summary>
	const string GlowSpriteFile = "sprites/nz/powerup_glow.sprite";

	static Sprite _glowSprite;
	static bool _glowLooked;

	/// <summary>
	/// The glow sprite, loaded once.
	/// </summary>
	///
	/// ⚠️ CACHED INCLUDING THE FAILURE, as `BulletTracers.Tracer` is: a missing resource looked up per shot spams the log at
	/// the fire rate. Without it the head and the ember are left out and the streak still draws.
	static Sprite GlowSprite
	{
		get
		{
			if ( _glowLooked ) return _glowSprite;
			_glowLooked = true;
			_glowSprite = ResourceLibrary.Get<Sprite>( GlowSpriteFile );
			if ( _glowSprite is null ) Log.Warning( $"[nz-tracer] '{GlowSpriteFile}' not found — travelling tracers draw without a head" );
			return _glowSprite;
		}
	}

	// ══ the pool ═════════════════════════════════════════════════════════════

	sealed class Round
	{
		public GameObject Go, HeadGo;
		public LineRenderer Core, GlowA, GlowB, SmokeLine;
		public SpriteRenderer Head;

		public readonly List<Vector3> CorePts = new( Points ), GlowAPts = new( Points ), GlowBPts = new( Points ),
			SmokePts = new( SmokePoints );
		public readonly Gradient.ColorFrame[] CoreCol = new Gradient.ColorFrame[Points], GlowCol = new Gradient.ColorFrame[Points],
			SmokeCol = new Gradient.ColorFrame[SmokePoints];
		public readonly Curve.Frame[] CoreW = new Curve.Frame[Points], GlowAW = new Curve.Frame[Points],
			GlowBW = new Curve.Frame[Points], SmokeW = new Curve.Frame[SmokePoints];

		public Vector3 From, To, Dir;
		public float Total, Speed, Born, Spawned, Seed, Ignite;
		public Color Tint;

		/// <summary>This leg is a ricochet: slower, dimmer, pulsing.</summary>
		public bool Ricochet;
		/// <summary>Leave an ember on arrival.</summary>
		public bool Ember;
		/// <summary>Found nothing: fade out over the end of the path instead of stopping.</summary>
		public bool Burnout;
		public bool EmberDone, Chained, Live, Gone;
	}

	sealed class Ember
	{
		public GameObject Go;
		public SpriteRenderer Sprite;
		public Color Colour;
		public float Born, Life, Seed;
		public bool Live;
	}

	static List<Round> _rounds;
	static List<Ember> _emberPool;
	static int _next, _nextEmber;

	/// <summary>Rounds in the air or still smoking, for the report.</summary>
	public static int Live
	{
		get
		{
			var n = 0;
			if ( _rounds is not null ) foreach ( var r in _rounds ) if ( r.Live ) n++;
			return n;
		}
	}

	// ══ firing ═══════════════════════════════════════════════════════════════

	/// <summary>
	/// Send a round from <paramref name="from"/> to <paramref name="to"/>.
	/// </summary>
	/// <param name="colour">The streak's tint; null is `FastTracer.Tint`, the unpacked tracer's colour.</param>
	/// <param name="landed">The shot stopped on something here (an ember), rather than running out of range (a burn-out).</param>
	public static void Fire( Vector3 from, Vector3 to, Color? colour = null, bool landed = true )
	{
		if ( Application.IsDedicatedServer ) return;

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var span = to - from;
		var total = span.Length;
		if ( total < 1f ) return;

		var now = Time.Now;

		// ⚠️ A LEG THAT STARTS WHERE A ROUND FIRED THIS MOMENT IS ABOUT TO ARRIVE IS THAT ROUND'S RICOCHET. The whole bullet
		// resolves in one frame, so the legs arrive here back to back, the first one first.
		Round parent = null;
		if ( _rounds is not null )
		{
			foreach ( var q in _rounds )
			{
				if ( !q.Live || q.Chained || now - q.Spawned > 0.25f ) continue;
				if ( (q.To - from).LengthSquared > 4f ) continue;
				parent = q;
				break;
			}
		}

		var r = Take( scene );
		if ( r is null ) return;

		r.From = from;
		r.To = to;
		r.Dir = span / total;
		r.Total = total;
		r.Spawned = now;
		r.Seed = Game.Random.Float( 0f, 1000f );
		r.Ember = landed;
		r.Burnout = !landed;
		r.EmberDone = false;
		r.Chained = false;
		r.Gone = false;

		if ( parent is not null )
		{
			// the round carried on: nothing burns at the bounce, and it does not fade out before it
			parent.Chained = true;
			parent.Ember = false;
			parent.Burnout = false;

			r.Tint = parent.Tint;
			r.Born = parent.Born + parent.Total / parent.Speed;
			r.Speed = parent.Speed * Game.Random.Float( 0.42f, 0.62f );
			r.Ricochet = true;
			r.Ignite = 0f;
		}
		else
		{
			r.Tint = colour ?? FastTracer.Tint;
			r.Born = now;
			r.Speed = MathF.Max( 100f, Speed );
			r.Ricochet = false;
			r.Ignite = MathF.Max( 0f, Ignite );
		}

		r.Live = true;
		r.Go.Enabled = true;

		Driver.Ensure( scene );
	}

	static Round Take( Scene scene )
	{
		_rounds ??= new List<Round>();

		for ( var i = 0; i < _rounds.Count; i++ )
		{
			var q = _rounds[i];
			if ( !q.Go.IsValid() || q.Go.Scene != scene ) { _rounds[i] = Build( scene ); return _rounds[i]; }
			if ( !q.Live ) return q;
		}

		if ( _rounds.Count < Math.Max( 1, PoolSize ) )
		{
			var fresh = Build( scene );
			_rounds.Add( fresh );
			return fresh;
		}

		// ⚠️ FULL: TAKE ONE THAT IS ONLY SMOKING, ELSE THE NEXT IN TURN. A round cut short mid-flight is the one thing to avoid.
		for ( var i = 0; i < _rounds.Count; i++ )
		{
			_next = (_next + 1) % _rounds.Count;
			if ( _rounds[_next].Gone ) return _rounds[_next];
		}

		_next = (_next + 1) % _rounds.Count;
		return _rounds[_next];
	}

	static Round Build( Scene scene )
	{
		var go = scene.CreateObject();
		go.Name = "nz_travel_tracer";
		go.Flags |= GameObjectFlags.NotSaved;

		// ⚠️ NEVER NETWORKED — client eye-candy, as every effect in this folder. Other machines draw their own from `NZNet.ShotTracer`.
		go.NetworkMode = NetworkMode.Never;

		var r = new Round { Go = go };
		r.Core = MakeLine( go, true );
		r.GlowA = MakeLine( go, true );
		r.GlowB = MakeLine( go, true );
		r.SmokeLine = MakeLine( go, false );

		var head = scene.CreateObject();
		head.Name = "head";
		head.Flags |= GameObjectFlags.NotSaved;
		head.NetworkMode = NetworkMode.Never;
		head.SetParent( go, false );
		r.HeadGo = head;

		if ( GlowSprite is Sprite sprite )
		{
			r.Head = head.Components.Create<SpriteRenderer>();
			r.Head.Sprite = sprite;
			r.Head.Additive = true;
			r.Head.Lighting = false;
			r.Head.Shadows = false;
			r.Head.DepthFeather = 4f;
			r.Head.Enabled = false;
		}

		go.Enabled = false;
		return r;
	}

	/// <summary>One line of a round.</summary>
	///
	/// ⚠️ THE ROUND LIGHTS ITSELF; THE SMOKE IS LIT BY THE ROOM. The streak lines are additive and unlit, so they read as light;
	/// the smoke line is blended and lit, so it is grey where the room is lit and gone where it is dark, like smoke.
	static LineRenderer MakeLine( GameObject go, bool additive )
	{
		var line = go.Components.Create<LineRenderer>();
		line.UseVectorPoints = true;
		line.Additive = additive;
		line.Opaque = false;
		line.Lighting = !additive;
		line.CastShadows = false;
		line.Enabled = false;
		return line;
	}

	static void Free( Round r )
	{
		r.Live = false;
		r.Gone = false;
		if ( r.Go.IsValid() ) r.Go.Enabled = false;
	}

	// ══ the frame ════════════════════════════════════════════════════════════

	static Vector3 _camPos;
	static float _pxK = 0.0015f;

	/// <summary>
	/// World units per pixel, per unit of distance, from the camera that is drawing.
	/// </summary>
	///
	/// ⚠️ THE CAMERA'S FIELD OF VIEW IS HORIZONTAL here (`PlayerCameraHandler` sets it with `Screen.CreateVerticalFieldOfView`),
	/// so the span it covers is the screen's width — unless the camera says otherwise.
	static void ReadCamera( Scene scene )
	{
		var cam = scene.Camera;
		if ( !cam.IsValid() ) return;

		_camPos = cam.WorldPosition;

		var span = cam.FovAxis == CameraComponent.Axis.Vertical ? Screen.Height : Screen.Width;
		var fov = MathX.Clamp( cam.FieldOfView, 1f, 179f );
		_pxK = span > 1f ? 2f * MathF.Tan( fov * 0.5f * MathF.PI / 180f ) / span : 0.0015f;
	}

	static float PixelAt( Vector3 p ) => _pxK * MathF.Max( 1f, (p - _camPos).Length );

	/// <summary>Age every round and ember. Called once per frame by <see cref="Driver"/>.</summary>
	public static void Tick()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		ReadCamera( scene );
		var now = Time.Now;

		if ( _rounds is not null )
			foreach ( var r in _rounds )
				if ( r.Live ) TickRound( scene, r, now );

		if ( _emberPool is not null )
			foreach ( var e in _emberPool )
				if ( e.Live ) TickEmber( e, now );
	}

	static void TickRound( Scene scene, Round r, float now )
	{
		if ( !r.Go.IsValid() ) { r.Live = false; return; }

		var age = now - r.Born;

		// ⚠️ A RICOCHET WAITS FOR ITS ROUND. It is fired the same frame as the leg before it and flies once that leg arrives.
		if ( age < 0f )
		{
			Show( r.Core, false ); Show( r.GlowA, false ); Show( r.GlowB, false );
			Show( r.Head, false ); Show( r.SmokeLine, false );
			return;
		}

		var hs = age * r.Speed;          // how far the head has flown
		var ts = hs - Length;            // where the tail is

		if ( !r.EmberDone && hs >= r.Total )
		{
			r.EmberDone = true;
			if ( r.Ember && Embers ) SpawnEmber( scene, r.To - r.Dir * 0.5f, r.Tint );
		}

		r.Gone = ts >= r.Total;

		var a0 = MathF.Max( 0f, ts );
		var b0 = MathF.Min( hs, r.Total );
		var flick = Flicker( r, now );

		if ( !r.Gone && b0 - a0 > 0.05f ) DrawStreak( r, a0, b0, ts, flick, now );
		else { Show( r.Core, false ); Show( r.GlowA, false ); Show( r.GlowB, false ); }

		if ( hs <= r.Total ) DrawHead( r, hs, flick, now );
		else Show( r.Head, false );

		var smoking = Smoke && DrawSmoke( r, now, hs );
		if ( !smoking ) Show( r.SmokeLine, false );

		if ( r.Gone && !smoking ) Free( r );
	}

	/// <summary>
	/// How bright the round is at a point along its path.
	/// </summary>
	///
	/// ⚠️ THE LAB'S RULES, IN ITS ORDER: dark until the compound lights, dimmer and pulsing after a ricochet, fading out over the
	/// end of a path that found nothing.
	static float Intensity( Round r, float s, float flick, float now )
	{
		var i = Brightness * flick;

		if ( r.Ignite > 0f ) i *= Smooth( 0f, r.Ignite, s );
		if ( r.Ricochet ) i *= 0.72f * (0.55f + 0.45f * MathF.Abs( MathF.Sin( now * 64f + r.Seed ) ));

		if ( r.Burnout )
		{
			var f0 = r.Total * (r.Ricochet ? 0.35f : 0.72f);
			if ( s > f0 ) i *= 1f - Smooth( f0, r.Total, s );
		}

		return i;
	}

	/// <summary>A burning round is never quite steady.</summary>
	static float Flicker( Round r, float now )
	{
		var x = now * 60f + r.Seed;
		return 0.87f + 0.08f * MathF.Sin( x * 1.7f ) + 0.05f * MathF.Sin( x * 3.9f + 1.3f ) + Game.Random.Float( -0.04f, 0.04f );
	}

	static float Smooth( float a, float b, float x )
	{
		var t = MathX.Clamp( (x - a) / MathF.Max( 1e-4f, b - a ), 0f, 1f );
		return t * t * (3f - 2f * t);
	}

	/// <summary>
	/// The streak's colour at <paramref name="u"/> along it (0 the tail, 1 the head): the tint, cooling towards red at the tail.
	/// </summary>
	static Color Body( Color tint, float u )
		=> Color.Lerp( new Color( tint.r * 0.9f, tint.g * 0.52f, tint.b * 0.42f ), tint, u );

	/// <summary>
	/// One stretch of streak, <paramref name="a0"/> to <paramref name="b0"/> along the path: a hot core and a soft glow round it,
	/// both brightest at the head.
	/// </summary>
	static void DrawStreak( Round r, float a0, float b0, float ts, float flick, float now )
	{
		var len = MathF.Max( 1f, Length );
		var w = MathF.Max( 0.01f, Width );

		r.CorePts.Clear();
		r.GlowAPts.Clear();
		r.GlowBPts.Clear();

		for ( var i = 0; i < Points; i++ )
		{
			var f = i / (float)(Points - 1);
			var s = a0 + (b0 - a0) * f;
			var p = r.From + r.Dir * s;
			var u = MathX.Clamp( (s - ts) / len, 0f, 1f );
			var light = Intensity( r, s, flick, now );
			var taper = 0.3f + 0.7f * u;
			var px = PixelAt( p );

			// ⚠️ HELD AT A PIXEL MINIMUM, DIMMING A LITTLE WHEN HELD — a far round is a thin bright thread.
			var cw = w * taper;
			var cMin = CoreMinPx * px;
			var cDim = 1f;
			if ( cw < cMin ) { cDim = MathF.Pow( cw / cMin, CoreDim ); cw = cMin; }

			var gw = w * 5.2f * taper;
			var gMin = GlowMinPx * px;
			var gDim = 1f;
			if ( gw < gMin ) { gDim = MathF.Pow( gw / gMin, GlowDim ); gw = gMin; }

			var grad = MathF.Pow( u, 1.25f );
			var heat = MathF.Pow( u, 5f );
			var body = Body( r.Tint, u );

			// ⚠️ BRIGHTER THAN WHITE ON PURPOSE. That is what the bloom takes, and it is the round glowing — nothing is lit by it.
			var kc = light * cDim;
			r.CoreCol[i] = new Gradient.ColorFrame( f, new Color(
				(body.r * grad * CoreGain + WhiteHot.r * heat * HeatGain) * kc,
				(body.g * grad * CoreGain + WhiteHot.g * heat * HeatGain) * kc,
				(body.b * grad * CoreGain + WhiteHot.b * heat * HeatGain) * kc, 1f ) );

			var kg = light * GlowGain * gDim * grad;
			r.GlowCol[i] = new Gradient.ColorFrame( f, new Color( body.r * kg, body.g * kg, body.b * kg, 1f ) );

			r.CoreW[i] = new Curve.Frame { Time = f, Value = cw, Mode = Curve.HandleMode.Linear };
			r.GlowAW[i] = new Curve.Frame { Time = f, Value = gw, Mode = Curve.HandleMode.Linear };
			r.GlowBW[i] = new Curve.Frame { Time = f, Value = gw * 0.5f, Mode = Curve.HandleMode.Linear };

			r.CorePts.Add( p );
			r.GlowAPts.Add( p );
			r.GlowBPts.Add( p );
		}

		// ⚠️ EACH LINE GETS ITS OWN LIST, AND IS HANDED IT AGAIN so the renderer notices the move (FastTracer's note).
		Push( r.Core, r.CorePts, r.CoreCol, r.CoreW );
		Push( r.GlowA, r.GlowAPts, r.GlowCol, r.GlowAW );
		Push( r.GlowB, r.GlowBPts, r.GlowCol, r.GlowBW );
	}

	static void Push( LineRenderer line, List<Vector3> pts, Gradient.ColorFrame[] colours, Curve.Frame[] widths )
	{
		if ( !line.IsValid() ) return;
		line.VectorPoints = pts;
		line.Color = new Gradient( colours );
		line.Width = new Curve( widths );
		Show( line, true );
	}

	/// <summary>The head: a soft white-hot point, held at a pixel minimum.</summary>
	static void DrawHead( Round r, float hs, float flick, float now )
	{
		if ( !r.Head.IsValid() ) return;

		var p = r.From + r.Dir * hs;
		var light = Intensity( r, hs, flick, now ) * HeadGain;

		var size = MathF.Max( 0.01f, Width ) * 7f;
		var min = HeadMinPx * PixelAt( p );
		if ( size < min ) { light *= MathF.Pow( size / min, HeadDim ); size = min; }

		if ( light <= 0.002f ) { Show( r.Head, false ); return; }

		r.HeadGo.WorldPosition = p;
		r.Head.Size = new Vector2( size, size );
		r.Head.Color = new Color( r.Tint.r * light, r.Tint.g * light, r.Tint.b * light, 1f );
		Show( r.Head, true );
	}

	/// <summary>
	/// A faint smoke line along the path it has flown, spreading and thinning behind it. True while any of it is left.
	/// </summary>
	///
	/// ⚠️ CAPPED AT `SmokeLife`. The lab lets it thin out on its own over nearly three seconds; here that would keep three
	/// seconds of rounds alive in the pool for a line almost nobody can see by then.
	static bool DrawSmoke( Round r, float now, float hs )
	{
		if ( !r.SmokeLine.IsValid() ) return false;

		var sEnd = MathF.Min( hs, r.Total );
		var sStart = r.Ricochet ? 0f : MathF.Max( 0.6f * Metre, r.Ignite * 0.5f );
		if ( sEnd <= sStart + 1f ) return false;

		var any = false;
		r.SmokePts.Clear();

		for ( var j = 0; j < SmokePoints; j++ )
		{
			var f = j / (float)(SmokePoints - 1);
			var s = sStart + (sEnd - sStart) * f;
			var since = MathF.Max( 0f, now - (r.Born + s / r.Speed) );

			var alpha = 0f;
			if ( since < SmokeLife )
			{
				alpha = 0.085f * MathF.Exp( -since / 0.75f ) * Smooth( 0f, 2f * Metre, r.Ricochet ? s + 2f * Metre : s );
				alpha *= MathX.Clamp( (SmokeLife - since) / 0.5f, 0f, 1f );
			}

			if ( alpha > 0.002f ) any = true;

			r.SmokeCol[j] = new Gradient.ColorFrame( f, SmokeGrey.WithAlpha( alpha ) );
			r.SmokeW[j] = new Curve.Frame { Time = f, Value = (0.02f + 0.16f * since) * Metre, Mode = Curve.HandleMode.Linear };
			r.SmokePts.Add( r.From + r.Dir * s );
		}

		if ( !any ) return false;

		Push( r.SmokeLine, r.SmokePts, r.SmokeCol, r.SmokeW );
		return true;
	}

	// ── embers ───────────────────────────────────────────────────────────

	static void SpawnEmber( Scene scene, Vector3 at, Color tint )
	{
		if ( GlowSprite is null ) return;

		_emberPool ??= new List<Ember>();

		Ember e = null;
		for ( var i = 0; i < _emberPool.Count; i++ )
		{
			if ( !_emberPool[i].Go.IsValid() || _emberPool[i].Go.Scene != scene ) { _emberPool[i] = BuildEmber( scene ); e = _emberPool[i]; break; }
			if ( !_emberPool[i].Live ) { e = _emberPool[i]; break; }
		}

		if ( e is null )
		{
			if ( _emberPool.Count < 32 ) { e = BuildEmber( scene ); _emberPool.Add( e ); }
			else { _nextEmber = (_nextEmber + 1) % _emberPool.Count; e = _emberPool[_nextEmber]; }
		}

		e.Colour = new Color( 0.55f + 0.45f * tint.r, 0.5f + 0.5f * tint.g, 0.45f + 0.55f * tint.b );
		e.Born = Time.Now;
		e.Life = Game.Random.Float( 0.3f, 0.75f );
		e.Seed = Game.Random.Float( 0f, 100f );
		e.Live = true;
		e.Go.WorldPosition = at;
		e.Go.Enabled = true;
	}

	static Ember BuildEmber( Scene scene )
	{
		var go = scene.CreateObject();
		go.Name = "nz_tracer_ember";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;

		var sprite = go.Components.Create<SpriteRenderer>();
		sprite.Sprite = GlowSprite;
		sprite.Additive = true;
		sprite.Lighting = false;
		sprite.Shadows = false;
		sprite.DepthFeather = 2f;

		go.Enabled = false;
		return new Ember { Go = go, Sprite = sprite };
	}

	static void TickEmber( Ember e, float now )
	{
		if ( !e.Go.IsValid() ) { e.Live = false; return; }

		var age = now - e.Born;
		if ( age >= e.Life ) { e.Live = false; e.Go.Enabled = false; return; }

		var k = 1f - age / e.Life;
		var light = 1.4f * k * k * (0.8f + 0.2f * MathF.Sin( (now + e.Seed) * 55f ));
		var size = (0.05f + 0.05f * k) * Metre;

		e.Sprite.Size = new Vector2( size, size );
		e.Sprite.Color = new Color( e.Colour.r * light, e.Colour.g * light, e.Colour.b * light, 1f );
	}

	static void Show( Component c, bool on )
	{
		if ( c.IsValid() && c.Enabled != on ) c.Enabled = on;
	}

	/// <summary>Drop both pools — used when resizing.</summary>
	public static void Clear()
	{
		if ( _rounds is not null )
			foreach ( var r in _rounds )
				if ( r.Go.IsValid() ) r.Go.Destroy();

		if ( _emberPool is not null )
			foreach ( var e in _emberPool )
				if ( e.Go.IsValid() ) e.Go.Destroy();

		_rounds = null;
		_emberPool = null;
		_next = _nextEmber = 0;
	}

	/// <summary>
	/// Per-frame driver.
	/// </summary>
	///
	/// ⚠️ ITS OWN GAMEOBJECT, created on demand — `FastTracer.Driver`'s reasoning: hung off the player it would die with them.
	public sealed class Driver : Component
	{
		static Driver _instance;

		public static void Ensure( Scene scene )
		{
			if ( _instance.IsValid() && _instance.Scene == scene ) return;

			foreach ( var d in scene.GetAllComponents<Driver>() ) { _instance = d; return; }

			var go = scene.CreateObject();
			go.Name = "nz_travel_tracer_driver";
			go.Flags |= GameObjectFlags.NotSaved;
			go.NetworkMode = NetworkMode.Never;
			_instance = go.Components.Create<Driver>();
		}

		protected override void OnUpdate()
		{
			using var _cpu = CpuScope.Measure( "ui.tracer" );
			Tick();
		}
	}

	// ══ commands ═════════════════════════════════════════════════════════════

	/// <summary>`nz_tracer_style [travel|line|particle]` — which tracer shots draw.</summary>
	///
	/// ⚠️ ONE SWITCH FOR ALL THREE, because they are checked in order — this one, then `FastTracer`, then the particle prefab —
	/// and `nz_fasttracer 1` alone does nothing while this one is on.
	[ConCmd( "nz_tracer_style" )]
	public static void StyleCmd( string style = "" )
	{
		switch ( style.ToLowerInvariant() )
		{
			case "": break;
			case "travel": case "new": Enabled = true; break;
			case "line": case "fast": Enabled = false; FastTracer.Enabled = true; break;
			case "particle": case "old": Enabled = false; FastTracer.Enabled = false; break;
			default:
				Log.Info( "[nz-tracer] nz_tracer_style <travel|line|particle>" );
				return;
		}

		Log.Info( $"[nz-tracer] tracer style: {(Enabled ? "TRAVEL (the travelling round)" : FastTracer.Enabled ? "LINE (FastTracer)" : "PARTICLE (the prefab)")}"
			+ "   · nz_tracer_style travel|line|particle" );
		if ( Enabled ) Report();
	}

	/// <summary>`nz_travel_tracer` — the travelling tracer's settings, in the lab's units.</summary>
	[ConCmd( "nz_travel_tracer" )]
	public static void Report()
	{
		Log.Info( $"[nz-tracer] travel {(Enabled ? "ON" : "off")} · {Speed / Metre:0} m/s · streak {Length / Metre:0.#} m"
			+ $" · width {Width / Metre * 100f:0.##} cm · brightness {Brightness:0.##} · lights at {Ignite / Metre:0.##} m" );
		Log.Info( $"[nz-tracer]   smoke {(Smoke ? "on" : "off")} · embers {(Embers ? "on" : "off")}"
			+ $" · {Live} live of {(_rounds?.Count ?? 0)}/{PoolSize} · head sprite {(GlowSprite is null ? "MISSING" : "ok")}" );
	}

	/// <summary>
	/// `nz_travel_tracer_set &lt;key&gt; &lt;value&gt;` — speed (m/s), length (m), width (cm), brightness, ignite (m), smoke, embers, pool.
	/// </summary>
	///
	/// ⚠️ IN THE LAB'S UNITS, so a value read off `Docs/tracer_lab.html` goes straight in.
	[ConCmd( "nz_travel_tracer_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "speed": Speed = MathX.Clamp( value, 20f, 2000f ) * Metre; break;
			case "length": Length = MathX.Clamp( value, 0.5f, 40f ) * Metre; break;
			case "width": Width = MathX.Clamp( value, 0.2f, 10f ) / 100f * Metre; break;
			case "brightness": Brightness = MathX.Clamp( value, 0.1f, 5f ); break;
			case "ignite": Ignite = MathX.Clamp( value, 0f, 10f ) * Metre; break;
			case "smoke": Smoke = value > 0.5f; break;
			case "embers": Embers = value > 0.5f; break;
			case "pool": PoolSize = (int)MathX.Clamp( value, 4f, 512f ); Clear(); break;
			default:
				Log.Info( "[nz-tracer] nz_travel_tracer_set <speed m/s|length m|width cm|brightness|ignite m|smoke 0/1|embers 0/1|pool n> <value>" );
				return;
		}

		Report();
	}

	/// <summary>`nz_travel_tracer_test [metres] [tier]` — one round straight ahead from the eye, landing that far away.</summary>
	[ConCmd( "nz_travel_tracer_test" )]
	public static void TestCmd( float metres = 30f, int tier = 0 )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() || !scene.Camera.IsValid() ) { Log.Warning( "[nz-tracer] no scene or camera" ); return; }

		var cam = scene.Camera;
		var from = cam.WorldPosition + cam.WorldRotation.Forward * 24f + cam.WorldRotation.Down * 6f;
		var to = cam.WorldPosition + cam.WorldRotation.Forward * MathX.Clamp( metres, 1f, 500f ) * Metre;

		Fire( from, to, tier > 0 ? BulletTracers.PackedStreak( tier ) : null, true );
		Log.Info( $"[nz-tracer] test round {metres:0.#} m{(tier > 0 ? $", MK{tier}" : "")}" );
	}
}