Weapons/FastTracer.cs

Client-side pooled line tracer system. It creates and reuses lightweight GameObjects with LineRenderer to draw two-point bullet streaks, manages a pool, ages/fades tracers each frame, and exposes console commands to toggle and resize the pool.

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

namespace NZombies;

/// <summary>
/// FAST TRACER — a bullet streak as a pooled two-point line, with no particle system at all.
///
/// ⛔ WHY THIS EXISTS. The particle tracer costs about 667us to SPAWN, and spawning was never the
/// worst of it: a 1200rpm weapon drawing two per shot fires 40 a second, which is only 0.68ms a
/// frame of clone cost, yet it still took 60fps down to 39. The rest is what a particle tracer does
/// while it is ALIVE — an emitter, a particle simulation, a sprite renderer and a trail mesh
/// rebuilt every frame, per tracer. For something that is geometrically a straight line that fades.
///
/// ⛔ SO THE FIX IS NOT A SMALLER PREFAB, IT IS NO PREFAB. Cutting the particle tracer's collision,
/// lifetime and trail points helped and did not solve it, because the per-frame simulation is
/// inherent to the component. A LineRenderer with two points has nothing to simulate.
///
/// ⚠️ POOLED, SO STEADY-STATE ALLOCATION IS ZERO. The objects are made once and then reused
/// forever: firing a tracer is two Vector3 writes, a colour and an enable. Nothing is cloned,
/// nothing is destroyed, and `particle.Clone()` — the 667us — never runs.
///
/// ⚠️ LineRenderer WITH `UseVectorPoints` IS THE CODEBASE'S OWN PATTERN, from LightningArc, whose
/// note records why: the GameObject-per-point mode "would churn objects at the rebuild rate".
/// Same reasoning, same settings — additive, unlit, no shadows, so it reads as light.
///
/// ⚠️ OFF BY DEFAULT. `nz_fasttracer 1` switches it on so it can be compared against the particle
/// tracer in one log rather than replacing it on a guess.
/// </summary>
public static class FastTracer
{
	/// <summary>Use the pooled line tracer instead of the particle prefab.</summary>
	public static bool Enabled
	{
		get => _enabled ?? false;
		set => _enabled = value;
	}

	static bool? _enabled;

	/// <summary>
	/// How many live tracers the pool holds. 48.
	///
	/// ⚠️ SIZED FOR THE WORST BURST, NOT THE AVERAGE, because the pool is the only thing standing
	/// between a shotgun and an allocation. 1200rpm x 2 per shot x 0.12s life is about 5 alive;
	/// a 48-pellet Olympia with the per-shot budget lifted would want far more. Overflow reuses
	/// the oldest rather than growing, so this is a ceiling on memory, not on shots.
	/// </summary>
	public static int PoolSize
	{
		get => _poolSize ?? 48;
		set => _poolSize = value;
	}

	static int? _poolSize;

	/// <summary>Seconds a streak is visible. 0.12.</summary>
	public static float Life
	{
		get => _life ?? 0.12f;
		set => _life = value;
	}

	static float? _life;

	/// <summary>Line thickness in world units. 0.35.</summary>
	public static float Width
	{
		get => _width ?? 0.35f;
		set => _width = value;
	}

	static float? _width;

	/// <summary>Default streak colour — warm tracer yellow.</summary>
	public static Color Tint
	{
		get => _tint ?? new Color( 1f, 0.85f, 0.35f );
		set => _tint = value;
	}

	static Color? _tint;

	sealed class Slot
	{
		public GameObject Go;
		public LineRenderer Line;
		public List<Vector3> Points;
		public Color Colour;
		public float Until;      // Time.Now when it dies; 0 = free
		public float Born;
	}

	static List<Slot> _slots;
	static int _next;

	/// <summary>Live tracers, for the report.</summary>
	public static int Live => _slots?.Count( s => s.Until > 0f ) ?? 0;

	/// <summary>
	/// Draw a streak from <paramref name="from"/> to <paramref name="to"/>.
	///
	/// ⚠️ TAKES BOTH ENDPOINTS, unlike the particle tracer which took a start and flew a particle
	/// toward the hit. There is nothing to fly: the line IS the whole path, drawn instantly and
	/// faded out. That is also why it cannot miss, arrive late, or be left behind by a fast bullet.
	/// </summary>
	public static void Draw( Vector3 from, Vector3 to, Color? colour = null )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

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

		// ⚠️ THE LIST IS REUSED, NOT REPLACED. `VectorPoints = new List<Vector3>{ a, b }` per tracer
		// is exactly the per-shot allocation this class exists to remove; LightningArc does that
		// because it rebuilds a whole bolt, this only ever has two points.
		slot.Points[0] = from;
		slot.Points[1] = to;

		// ⚠️ REASSIGNED SO THE RENDERER NOTICES. Mutating the list in place may not mark the mesh
		// dirty, and a tracer that silently keeps its previous endpoints is the kind of bug that
		// looks like "tracers point the wrong way sometimes".
		slot.Line.VectorPoints = slot.Points;

		slot.Colour = colour ?? Tint;
		slot.Line.Color = slot.Colour;
		slot.Line.Width = Width;

		slot.Born = Time.Now;
		slot.Until = Time.Now + MathF.Max( 0.02f, Life );
		slot.Go.Enabled = true;

		Driver.Ensure( scene );
	}

	static Slot Take( Scene scene )
	{
		_slots ??= new List<Slot>();

		// grow lazily up to PoolSize
		if ( _slots.Count < PoolSize )
		{
			var slot = Build( scene );
			if ( slot is null ) return null;
			_slots.Add( slot );
			return slot;
		}

		// ⚠️ ROUND-ROBIN STEAL WHEN FULL, oldest-ish first. A tracer cut short is invisible at these
		// lifetimes; growing the pool under fire is not.
		for ( int i = 0; i < _slots.Count; i++ )
		{
			_next = (_next + 1) % _slots.Count;
			var s = _slots[_next];
			if ( !s.Go.IsValid() ) { _slots[_next] = Build( scene ); return _slots[_next]; }
			if ( s.Until <= 0f ) return s;
		}

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

	static Slot Build( Scene scene )
	{
		var go = new GameObject( true, "nz_tracer" );

		// ⚠️ NEVER NETWORKED. Thirteen effect sites in this project already mark local-only objects
		// this way; a streak is client eye-candy and networking it would both cost and duplicate.
		go.NetworkMode = NetworkMode.Never;

		var line = go.Components.Create<LineRenderer>();
		line.UseVectorPoints = true;

		var pts = new List<Vector3> { Vector3.Zero, Vector3.Zero };
		line.VectorPoints = pts;

		// same three as LightningArc, for the same reason — it should read as light, not as cable
		line.Additive = true;
		line.Lighting = false;
		line.CastShadows = false;

		line.Color = Tint;
		line.Width = Width;

		go.Enabled = false;

		return new Slot { Go = go, Line = line, Points = pts, Until = 0f };
	}

	/// <summary>
	/// Age every live streak. Called once per frame by <see cref="Driver"/>.
	///
	/// ⚠️ ONE COLOUR WRITE PER LIVE TRACER, and that is the entire per-frame cost of this system.
	/// Compare the particle tracer: an emitter, a particle sim, a sprite renderer and a trail mesh
	/// rebuild, each per tracer per frame.
	/// </summary>
	public static void Tick()
	{
		if ( _slots is null ) return;

		var now = Time.Now;

		foreach ( var s in _slots )
		{
			if ( s.Until <= 0f ) continue;

			if ( now >= s.Until || !s.Go.IsValid() )
			{
				s.Until = 0f;
				if ( s.Go.IsValid() ) s.Go.Enabled = false;
				continue;
			}

			var life = MathF.Max( 0.001f, s.Until - s.Born );
			var t = (now - s.Born) / life;

			// ⚠️ FADES ON ALPHA ONLY. Shrinking the width instead would make a fast streak look
			// like it is receding rather than dimming.
			s.Line.Color = s.Colour.WithAlpha( 1f - t );
		}
	}

	/// <summary>Drop the pool — used by the console command when resizing.</summary>
	public static void Clear()
	{
		if ( _slots is null ) return;

		foreach ( var s in _slots )
			if ( s.Go.IsValid() ) s.Go.Destroy();

		_slots = null;
		_next = 0;
	}

	/// <summary>
	/// Per-frame driver.
	///
	/// ⚠️ ITS OWN GAMEOBJECT, created on demand — the same reasoning RoundCommands gives for the
	/// round manager: hanging it off the player means it dies with the player.
	/// </summary>
	public sealed class Driver : Component
	{
		static Driver _instance;

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

			_instance = scene.GetAllComponents<Driver>().FirstOrDefault();
			if ( _instance.IsValid() ) return;

			var go = new GameObject( true, "nz_tracer_driver" );
			go.NetworkMode = NetworkMode.Never;
			_instance = go.Components.Create<Driver>();
		}

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

	// ── commands ─────────────────────────────────────────────────────────────

	/// <summary>`nz_fasttracer [0/1] [life] [width]` — the pooled line tracer.</summary>
	[ConCmd( "nz_fasttracer" )]
	public static void Cmd( int on = -1, float life = -1f, float width = -1f )
	{
		if ( on >= 0 ) Enabled = on != 0;
		if ( life >= 0f ) Life = MathX.Clamp( life, 0.02f, 2f );
		if ( width >= 0f ) Width = MathX.Clamp( width, 0.02f, 8f );

		Log.Info( $"[nz-tracer] fast tracer {(Enabled ? "ON" : "off")}"
			+ $" · life {Life:0.###}s · width {Width:0.##}"
			+ $" · pool {(_slots?.Count ?? 0)}/{PoolSize}, {Live} live" );

		// ⚠️ THE TRAVELLING TRACER DRAWS FIRST (2026-09-30), so switching this one on changes nothing while that one is on.
		if ( TravelTracer.Enabled )
			Log.Info( "[nz-tracer]   ⚠ the travelling tracer is on and draws first — nz_tracer_style line uses this one" );
		else if ( !Enabled )
			Log.Info( "[nz-tracer]   the particle prefab is in use — nz_tracers / nz_tracers_max apply to that one" );
	}

	/// <summary>`nz_fasttracer_pool [n]` — resize the pool. Rebuilds it.</summary>
	[ConCmd( "nz_fasttracer_pool" )]
	public static void PoolCmd( int n = -1 )
	{
		if ( n > 0 )
		{
			PoolSize = Math.Clamp( n, 1, 512 );
			Clear();
		}

		Log.Info( $"[nz-tracer] pool size {PoolSize}" );
	}
}