Particle Gallery
Thirty-eight particle systems on a ring of stations, all built from code: the building blocks one at a time - spawners, shapes, initializers, updaters, materials, flipbooks, soft particles - then the showpieces that put them together: a campfire, fireworks with child emitters, a tornado, a swarm driven by an updater of our own, lasers, rain that splashes, a portal, a landing burn. The last six are thrusters: a kerosene engine, a cluster of nine, a methane engine with shock diamonds, a jet afterburner, a solid booster that leaves a trail, and small thrusters. Most stations have variations on a key, so what a setting does is a keypress away.
The Program.cs file shows how to:
- Building a ParticleSystemComponent from code - emitters, spawners, initializers, updaters, shapes, materials
- Textures, flipbooks and scrolling texture coordinates on particles
- Curves over a particle's life for size, colour and rotation
- Force fields, colliders and spawning by distance
- Child emitters spawned on a parent's death, distance or collision
- Soft particles against geometry
- Writing an updater and an initializer of your own
- A particle's own colour as a vertex stream node in the material
- An exhaust from layers - flare, core, body, shock diamonds, smoke - and a throttle that drives them
- A smoke trail spawned by distance behind an emitter that flies
- A ring of stations from the shared gallery frame in Example.Common, with variations per station

View on GitHub.
using E09_3D_Particles_Gallery;
using Example.Common;
using Example.Common.Galleries;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering.Text;
using Stride.CommunityToolkit.Skyboxes;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Games;
using Stride.Input;
// A gallery of the particle system, built entirely from code. Every station is one static method
// in Stations.*.cs that builds a particle system in its station's own coordinates and knows nothing
// about where it stands; the ring, the ground, the labels, the index board and the camera flights
// come from the shared gallery frame in Example.Common. Most stations have a few variations of
// their own parameter - press V - so "what does this setting do" is a keypress, not a rebuild.
//
// The first twenty-four stations are the building blocks one at a time: spawners, shapes,
// initializers, updaters, materials. The next eight put several together - a campfire, fireworks
// with child emitters, a tornado, a swarm driven by an updater of our own, lasers, rain that
// splashes, a portal from an initializer of our own, a landing burn - because that is what the
// system is for. The last six are thrusters, one fuel each, built from the same few layers.
//
// Keys: N and P fly to the next and previous station, Home flies home to the index board, Tab
// shows one station at a time, L widens the labels, V cycles the current station's variation,
// Space restarts the current station's simulation. "--station 25" starts at a station;
// "--station 25 --variation 2" at its third variation - handy for screenshots. "--clean" hides the
// overlay, for a screenshot or a recording of the particles alone.
var startStation = args.Length >= 2 && args[0] == "--station" && int.TryParse(args[1], out var number) ? number : 0;
var startVariation = args.Length >= 4 && args[2] == "--variation" && int.TryParse(args[3], out var variation) ? variation : 0;
var clean = args.Contains("--clean");
Gallery<ParticleStation>? gallery = null;
ParticleTextures? textures = null;
WindowsDpiManager.EnablePerMonitorV2();
using var game = new Game();
game.Run(new SceneSetup3D { Ground = null, Profiler = true }.UseSkybox(), start: Start, update: Update);
// The textures loaded in Start are this example's own: released once the game has ended
textures?.Dispose();
void Start(Scene rootScene)
{
game.Window.AllowUserResizing = true;
game.Window.Title = "Particle Gallery - Stride Community Toolkit";
game.AddParticleRenderer();
// The text renderers, appended after the camera renderer, so labels land on top of everything
game.AddWorldTextRenderer();
game.AddEntityTextRenderer();
textures = ParticleTextures.Load(game.GraphicsDevice);
// The frame comes from Example.Common; what a particle station needs on top of it is the textures
var loaded = textures;
gallery = new Gallery<ParticleStation>(game, rootScene, Stations.All, configure: station =>
{
station.Textures = loaded;
if (station.Number == startStation) station.Variation = startVariation;
});
gallery.UpdateLabels();
if (startStation > 0) gallery.GoTo(startStation - 1, instant: true);
else gallery.GoHome(instant: true);
var overlay = DebugOverlay.GetOrCreate(game);
overlay.AddSection("Gallery", BuildOverlayLines);
overlay.Visible = !clean;
}
void Update(Scene scene, GameTime gameTime)
{
if (gallery is null) return;
HandleInput(gallery);
gallery.Update(gameTime);
// One station at a time means one simulation at a time: the others pause where they are
foreach (var station in gallery.Stations)
{
if (station.Particles is { } particles)
{
particles.Enabled = !gallery.Solo || station.IsCurrent;
}
}
}
void HandleInput(Gallery<ParticleStation> gallery)
{
if (game.Input.IsKeyPressed(Keys.N)) gallery.Step(+1);
if (game.Input.IsKeyPressed(Keys.P)) gallery.Step(-1);
if (game.Input.IsKeyPressed(Keys.Home)) gallery.GoHome();
if (game.Input.IsKeyPressed(Keys.Tab)) gallery.Solo = !gallery.Solo;
if (game.Input.IsKeyPressed(Keys.L))
{
gallery.LabelDetail = (gallery.LabelDetail + 1) % 3;
gallery.UpdateLabels();
}
if (gallery.CurrentStation is not { } station) return;
// A variation is the same setup method with a different branch taken: bump and rebuild
if (game.Input.IsKeyPressed(Keys.V) && station.VariationNames.Count > 1)
{
station.Variation++;
Gallery<ParticleStation>.Guarded(station, () => Stations.All[gallery.Current].Setup?.Invoke(station));
}
if (game.Input.IsKeyPressed(Keys.Space)) station.Restart();
}
IReadOnlyList<TextElement> BuildOverlayLines()
{
if (gallery is null) return [];
var living = gallery.Stations.Sum(s => s.LivingParticles);
List<TextElement> lines =
[
new("N", "Next station", Color.Gold),
new("P", "Previous station", Color.Gold),
new("Home", "Home", Color.Gold),
new("Tab", gallery.Solo ? "One station at a time" : "Every station", Color.Gold),
new("L", gallery.LabelDetail switch { 0 => "Labels: the number", 1 => "Labels: the number and the method", _ => "Labels: everything" }, Color.Gold),
new("Space", "Restart the station", Color.Gold),
new(""),
new($"{living:N0} particles alive over {gallery.Stations.Count} stations", Color.LightGreen),
];
if (gallery.CurrentStation is not { } station)
{
lines.Add(gallery.Stations.Count == 0
? new("No stations in the registry", Color.OrangeRed)
: new($"Home - {gallery.Stations.Count} stations; N, P or a pad to visit", Color.White));
return lines;
}
lines.Add(new($"Station {station.Number} of {gallery.Stations.Count} - {station.Exhibit.Title}", Color.White));
lines.AddRange(OverlayText.Wrap($"{station.Exhibit.Method}: {station.Exhibit.Summary}", Color.LightGray));
if (station.VariationNames.Count > 1)
{
lines.Add(new("V", $"Variation {station.Variation + 1} of {station.VariationNames.Count}: {station.VariationNames[station.Variation]}", Color.Cyan));
}
// What each emitter holds, so a child emitter that never spawns shows as a zero
if (station.Particles is { } particles)
{
var emitters = particles.ParticleSystem.Emitters;
lines.Add(new(string.Join(" ", emitters.Select((e, i) => $"{(string.IsNullOrEmpty(e.EmitterName) ? $"emitter {i + 1}" : e.EmitterName)}: {e.LivingParticles}")), Color.LightGray));
}
return lines;
}