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.
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}" );
}
}