2D particles: CPU, GPU and effect lifecycle
A complete example covering emission control, restart and simulation space.
Choose a system
| System | Motion configuration | Considerations |
|---|---|---|
| CpuParticles2D | Properties on the entity | CPU simulation; display still goes through the renderer |
| GpuParticles2D | ProcessMaterial | GPU simulation; advanced effects depend on the backend |
Both systems create visual particles rather than an entity for every point. Individual particles here have no C# callback, physics body or network ID. Gameplay projectiles with independent logic need a different approach.
The example's basic GPU particles work in Compatibility/OpenGL. This does not imply trails or sub-emitter support in that backend; those features have separate restrictions. Equal parameters and seeds do not guarantee matching CPU/GPU pixels. Choose according to required effects and measurements on target hardware, not a claim that GPU is always faster.
Run the complete example
- Create a C# project with Reckfell.NET.Sdk 1.0.0, net10.0 and Compatibility rendering.
- Save the two files below and run dotnet build.
- Create a level with an Entity2D root, attach Particle2DExample.cs, save particles.rscene and run it.
- The fountain gradually fills. ui_accept restarts it; ui_cancel stops new emission. Default bindings include Enter/Space and Escape; focus the game window.
UseGpu=false selects CPU and true selects GPU. OneShotBurst=true creates a single burst; false emits continuously. LocalSpace selects simulation space. Count accepts 1 through 2048; the upper bound protects this example and is not an engine limit. The root script reads these options at _Ready, so set them before running. Access its Cpu or Gpu to change the created system afterward.
The example creates its emitter and 8×8 texture, sets a logical 320×240 game window during execution and needs no third-party assets.
Particle2DPreset.cs
using System;
using Reckfell;
public static class Particle2DPreset
{
private static void Validate(int count)
{
if (count < 1 || count > 2048)
throw new ArgumentOutOfRangeException(nameof(count));
}
public static ParticleProcessMaterial Motion() => new()
{
ParticleFlagDisableZ = true,
Direction = new Vector3(0, -1, 0),
Spread = 30,
InitialVelocityMin = 60,
InitialVelocityMax = 100,
Gravity = new Vector3(0, 90, 0),
Color = new Color(0.55f, 0.72f, 1)
};
public static GpuParticles2D Gpu(int count, bool oneShot, bool local)
{
Validate(count);
return new GpuParticles2D
{
Emitting = false,
Amount = count,
Lifetime = 2,
OneShot = oneShot,
Explosiveness = oneShot ? 1 : 0,
LocalCoords = local,
UseFixedSeed = true,
Seed = 42,
FixedFps = 60,
ProcessMaterial = Motion(),
Texture = Texture(),
VisibilityRect = new Rect2(-140, -120, 280, 320)
};
}
public static CpuParticles2D Cpu(int count, bool oneShot, bool local)
{
Validate(count);
return new CpuParticles2D
{
Emitting = false,
Amount = count,
Lifetime = 2,
OneShot = oneShot,
Explosiveness = oneShot ? 1 : 0,
LocalCoords = local,
UseFixedSeed = true,
Seed = 42,
FixedFps = 60,
Direction = Vector2.Up,
Spread = 30,
InitialVelocityMin = 60,
InitialVelocityMax = 100,
Gravity = new Vector2(0, 90),
Color = new Color(0.55f, 0.72f, 1),
Texture = Texture()
};
}
private static ImageTexture Texture()
{
using var image = Image.CreateEmpty(8, 8, false, Image.Format.Rgba8);
image.Fill(Colors.Transparent);
for (int y = 0; y < 8; y++)
for (int x = 0; x < 8; x++)
if (new Vector2(x - 3.5f, y - 3.5f).LengthSquared() <= 12)
image.SetPixel(x, y, Colors.White);
return ImageTexture.CreateFromImage(image);
}
}Particle2DExample.cs
using Reckfell;
public partial class Particle2DExample : Entity2D
{
[Export] public bool UseGpu { get; set; } = true;
[Export] public bool OneShotBurst { get; set; }
[Export] public bool LocalSpace { get; set; } = true;
[Export] public int Count { get; set; } = 128;
public GpuParticles2D? Gpu { get; private set; }
public CpuParticles2D? Cpu { get; private set; }
public override void _Ready()
{
if (GetViewport() is Window window)
{
window.ContentScaleSize = new Vector2I(320, 240);
window.ContentScaleMode = Window.ContentScaleModeEnum.Viewport;
}
Vector2 position = new(160, 180);
if (UseGpu)
{
Gpu = Particle2DPreset.Gpu(Count, OneShotBurst, LocalSpace);
Gpu.Position = position;
AddChild(Gpu);
}
else
{
Cpu = Particle2DPreset.Cpu(Count, OneShotBurst, LocalSpace);
Cpu.Position = position;
AddChild(Cpu);
}
RestartEffect();
}
public void RestartEffect()
{
Gpu?.Restart(keepSeed: true);
Cpu?.Restart(keepSeed: true);
}
public void StopEmission()
{
if (Gpu != null) Gpu.Emitting = false;
if (Cpu != null) Cpu.Emitting = false;
}
public override void _UnhandledInput(InputEvent input)
{
if (input.IsActionPressed("ui_accept")) RestartEffect();
else if (input.IsActionPressed("ui_cancel")) StopEmission();
}
}Distinguish three operations
| Operation | Result |
|---|---|
| StopEmission / Emitting=false | No new particles; existing particles remain until their lifetimes end |
| RestartEffect / Restart(keepSeed:true) | Reset effect state and start emission again |
| Natural OneShot completion | Finished signals after the particles finish |
Emitting=false does not prove an empty screen. With staggered OneShot emission, the last particle can outlive emission itself. Observe Finished for completion; define owner lifetime separately if the entity and its resources should be removed.
Lifetime=2 in the sample. With continuous emission, Explosiveness=0 and the remaining defaults, Amount=128 gives approximately 64 births per second and up to 128 live particles after filling. OneShotBurst=true uses Explosiveness=1 to group births into one burst. Lifetime is lifespan rather than a delay before starting.
Restart retains the seed with keepSeed=true or UseFixedSeed=true. Passing keepSeed=false does not force a new seed when UseFixedSeed is enabled. This controls randomness; it does not guarantee cross-platform or CPU/GPU bit-identical replay.
Choose local or world coordinates
LocalCoords=true makes emitted particles follow emitter transforms. With false, moving the emitter does not move existing particles, which suits a trail left behind. Select the space before starting; changing it during simulation needs separate verification.
Do not multiply the velocity settings by delta in your own _Process: the particle system performs simulation. Change effect commands and parameters from C#, rather than the position of every visual point.
Compare actual output
These current-engine frames use Count=128, OneShotBurst=true and LocalSpace=true at roughly 0.6 seconds of simulation. They are illustrations, not an FPS comparison or a promise of matching CPU/GPU sequences.
CpuParticles2D
GpuParticles2D
Diagnose missing or incorrect effects
- Empty GPU system: check ProcessMaterial, Amount, Emitting, Texture and VisibilityRect. A drawing material does not replace a simulation material.
- Particles remain after StopEmission: expected; allow their Lifetime to expire instead of treating emission stop as clearing.
- The trail moves with its emitter: check LocalCoords.
- A burst does not repeat: call Restart rather than relying on another Emitting=true assignment during an unfinished cycle.
- UseGpu changed at runtime but the type stayed the same: this sample root selects its type only at _Ready.