particles¶
a hit lands, a crate pops, a combo breaks: the moment wants sparks. ParticleField is a tiny CPU particle simulation for UI. you own it as a field on your panel, spawn into it from any handler, and mount it once with Hud.Particles(field). the overlay steps and renders itself from its paint callback, so there is no Tick override to write and no Rebuild() to call - a burst is an event, and the field animates on its own until the last particle dies.
every kind of particle is the same three knobs: where it spawns (Burst, Emit), how it moves (Gravity, Drag, Force), how it looks (Draw).
a burst on click¶
using System;
using Goo;
using Sandbox;
using Sandbox.UI;
public class FireworkUI : GooPanel<Container>
{
readonly ParticleField _fx = new() { Gravity = new Vector2( 0f, 320f ), Drag = 0.6f };
protected override Container Build() => new Container
{
Width = Length.Percent( 100 ), Height = Length.Percent( 100 ),
AlignItems = Align.Center, JustifyContent = Justify.Center,
OnClick = e => _fx.Burst( Mouse.Position, 90, 520f, new Color( 1f, 0.85f, 0.4f ), life: 1.6f, size: 4f ),
Children =
{
new Text( "click to launch" ) { FontSize = 20 },
Hud.Particles( _fx ),
},
};
}
this is Code/Demo/FireworkUI.cs trimmed to its skeleton; the full demo adds a color palette and a twinkling spark draw, both shown below. Burst throws count particles out of origin with random omnidirectional spread; speed, life, and size each get a random factor so the pop reads organic instead of mechanical. positions are screen pixels, which is why Mouse.Position works directly as a spawn origin.
Hud.Particles(field) returns the overlay: an absolute, full-screen, pointer-through container whose Draw callback steps the field and draws every live particle. a Draw-carrying panel self-dirties every frame, which is what makes the field self-driving - mount it and forget it. mount it once in Build, not per burst.
motion¶
three fields on ParticleField shape how everything in it moves:
| field | type | default | notes |
|---|---|---|---|
Gravity |
Vector2 |
(0, 900) |
acceleration in px/s^2. default(Vector2) means drifting particles, no fall |
Drag |
float |
0 |
per-second velocity damping; 0 is none |
Force |
ParticleForce? |
null |
custom per-particle force, run before gravity, drag, and integration |
gravity points down because y grows downward on screen. flip it negative and particles rise - embers, bubbles, praise. Force is a delegate over (ref ParticleField.Particle p, float dt): steer Vel, or set anything else on the particle. a sine sway keyed on Age plus Seed gives every particle its own phase:
// from Code/Demo/EmberFieldUI.cs
readonly ParticleField _fx = new()
{
Gravity = new Vector2( 0f, -55f ), // float upward
Drag = 0.4f,
Force = ( ref ParticleField.Particle p, float dt ) =>
p.Vel.x += MathF.Sin( ( p.Age + p.Seed * 6f ) * 2.2f ) * 18f * dt,
};
the look¶
Draw on the field is a ParticleDraw: a per-particle callback handed the Canvas and the particle. it is a named delegate rather than an Action because Canvas is a ref struct. the default draws a dot that fades and shrinks over its life; reassign it for sparks, confetti, smoke:
// from Code/Demo/FireworkUI.cs
static void Spark( Canvas c, ParticleField.Particle p )
{
float t = p.Age / p.Life;
float twinkle = 0.6f + 0.4f * MathF.Sin( ( p.Age + p.Seed * 6.28f ) * 18f );
c.Circle( p.Pos, p.Size * ( 1f - 0.3f * t ), new Color( p.Color.r, p.Color.g, p.Color.b, ( 1f - t ) * twinkle ) );
}
readonly ParticleField _fx = new() { Draw = Spark };
Seed is a stable per-particle random in 0..1, set at spawn. use it as a phase offset so twinkle, sway, and flicker desynchronize across particles instead of pulsing in lockstep. Hud.Particles(field, draw) also takes an optional draw override for a one-off look, so two overlays can render the same field differently.
continuous emission¶
Burst covers events. for a steady stream - rain, embers, snow - spawn at a rate from Tick with a fractional accumulator, and keep returning false: the overlay redraws itself, so your panel has nothing to rebuild.
// from Code/Demo/EmberFieldUI.cs
float _acc;
protected override bool Tick( float dt )
{
_acc += dt * 60f; // ~60 embers/sec
while ( _acc >= 1f )
{
_acc -= 1f;
_fx.Add( new ParticleField.Particle
{
Pos = new Vector2( (float)_rng.NextDouble() * Screen.Width, Screen.Height + 8f ),
Vel = new Vector2( 0f, -( 40f + (float)_rng.NextDouble() * 50f ) ),
Life = 2.5f + (float)_rng.NextDouble() * 1.5f,
Size = 3f + (float)_rng.NextDouble() * 3f,
Seed = (float)_rng.NextDouble(),
Color = new Color( 1f, 0.55f, 0.15f ),
} );
}
return false;
}
Add places one fully-specified particle. between Burst and Add sits Emit(count, make): it calls your factory count times with the index, so a row, a ring, a line, or a jet is one lambda.
the field surface¶
| member | signature | notes |
|---|---|---|
Burst |
Burst(Vector2 origin, int count, float speed, Color color, float life = 1.2f, float size = 6f) |
radial pop with randomized spread |
Emit |
Emit(int count, Func<int, Particle> make) |
any spawn pattern, one particle per factory call |
Add |
Add(Particle p) |
one fully-specified particle |
Step |
Step(float dt) |
advance the sim; Hud.Particles calls this for you |
Count |
int |
live particle count |
Live |
IReadOnlyList<Particle> |
read-only view of the live particles |
a Particle carries Pos, Vel, Age, Life, Size, Seed, and Color. a particle dies when Age reaches Life; dead particles are culled inside Step.
when not to reach for it¶
ParticleField is a CPU sim drawing one canvas primitive per particle per frame. that is the right tool for event-driven, bounded-lifetime effects that react to game state - a burst here, a stream there, a few hundred live particles at the high end. it is the wrong tool for a dense ambient field that never ends: thousands of always-alive particles want a shader. author a ui_*.shader that animates on the GPU (g_flTime is free in every UI shader) and mount it with a ShaderEffect on a full-bleed layer instead - see custom shaders.
see also¶
- overlay-layout -
Hud.Particlesand the other full-bleed HUD layers - animations - dampers and tweens for the panels the particles celebrate
- custom-material - the
Drawcallback the overlay is built on, and the shader route for ambient fields - events - the handlers you spawn from