From godot-prompter
Guides implementation of particle effects in Godot 4.3+ using GPUParticles2D/3D, ParticleProcessMaterial, emission shapes, subemitters, trails, attractors, collision, and VFX recipes.
npx claudepluginhub jame581/godotprompter --plugin godot-prompterThis skill uses the workspace's default tool permissions.
All examples target Godot 4.3+ with no deprecated APIs. GDScript is shown first, then C#.
Creating visual effects using particle systems, physics simulation, and post-processing for polished, dynamic game graphics.
Covers Godot 4.3+ 2D systems including CanvasLayers, draw order, TileMaps, parallax scrolling, 2D lights/shadows, particles, custom drawing, and meshes with GDScript and C# examples.
Provides expert real-time VFX guidance for games using Niagara, VFX Graph, Godot GPU particles, shaders, flipbooks, and optimization for 60fps performance.
Share bugs, ideas, or general feedback.
All examples target Godot 4.3+ with no deprecated APIs. GDScript is shown first, then C#.
Related skills: shader-basics for custom particle shaders, 3d-essentials for lighting and environment that affect particles, 2d-essentials for 2D rendering context, tween-animation for code-driven VFX timing, godot-optimization for particle performance tuning.
| Node | Processing | Features | Use For |
|---|---|---|---|
GPUParticles2D | GPU | Full features, high counts, trails | Most 2D effects |
GPUParticles3D | GPU | Full features, attractors, collision | Most 3D effects |
CPUParticles2D | CPU | Simpler, no trails/attractors | Low-end devices, few particles |
CPUParticles3D | CPU | Simpler, no trails/attractors | Low-end devices, few particles |
Rule of thumb: Use GPU particles by default. Switch to CPU particles only for low-end/web targets or when you need CPU-side particle positions (e.g., spawning objects at particle locations).
You can convert between GPU and CPU particles in the editor: select the node → toolbar → Convert to CPUParticles2D/3D (or vice versa).
GPUParticles2D/3D
├── Process Material (ParticleProcessMaterial) ← physics, emission, color
├── Draw Pass 1 (Mesh) ← what each particle looks like
└── (Optional) Draw Pass 2-4 ← additional meshes
| Property | Type | Description |
|---|---|---|
emitting | bool | Start/stop emission |
amount | int | Total particles alive at once |
lifetime | float | Seconds each particle lives |
one_shot | bool | Emit once then stop |
preprocess | float | Simulate this many seconds before first frame |
speed_scale | float | Time multiplier for particle physics |
explosiveness | float | 0.0 = spread over lifetime, 1.0 = all at once |
fixed_fps | int | Lock particle update rate (0 = match render FPS) |
local_coords | bool | Particles move with the node (true) or stay in world (false) |
draw_order | enum | Index, Lifetime, or Reverse Lifetime |
amount_ratio | float | Fraction of particles to emit (0.0–1.0) |
# Continuous emitter (fire, smoke, ambient dust)
$GPUParticles2D.one_shot = false
$GPUParticles2D.emitting = true
# One-shot burst (explosion, impact splash)
$GPUParticles2D.one_shot = true
$GPUParticles2D.emitting = false # arm it
# Later, trigger:
$GPUParticles2D.restart()
$GPUParticles2D.emitting = true
// Continuous
var particles = GetNode<GpuParticles2D>("GPUParticles2D");
particles.OneShot = false;
particles.Emitting = true;
// One-shot burst
particles.OneShot = true;
particles.Emitting = false;
// Trigger:
particles.Restart();
particles.Emitting = true;
| Shape | Description |
|---|---|
Point | All particles spawn at origin |
Sphere | Random position within a sphere |
Sphere Surface | Random position on sphere surface only |
Box | Random position within a box |
Ring | Random position on a ring/torus |
Points | Spawn at positions from a texture/mesh |
Directed Points | Spawn at positions with normals from mesh |
var mat := ParticleProcessMaterial.new()
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
mat.emission_sphere_radius = 2.0
var mat = new ParticleProcessMaterial();
mat.EmissionShape = ParticleProcessMaterial.EmissionShapeEnum.Sphere;
mat.EmissionSphereRadius = 2.0f;
var mat := ParticleProcessMaterial.new()
# Direction particles move (normalized)
mat.direction = Vector3(0.0, 1.0, 0.0) # upward
mat.spread = 30.0 # degrees of randomness around direction
# Speed
mat.initial_velocity_min = 5.0
mat.initial_velocity_max = 10.0
# Gravity
mat.gravity = Vector3(0.0, -9.8, 0.0)
var mat = new ParticleProcessMaterial();
mat.Direction = new Vector3(0.0f, 1.0f, 0.0f);
mat.Spread = 30.0f;
mat.InitialVelocityMin = 5.0f;
mat.InitialVelocityMax = 10.0f;
mat.Gravity = new Vector3(0.0f, -9.8f, 0.0f);
mat.scale_min = 1.0
mat.scale_max = 1.5
# Scale curve — shrink over lifetime
var curve := CurveTexture.new()
var c := Curve.new()
c.add_point(Vector2(0.0, 1.0)) # full size at birth
c.add_point(Vector2(1.0, 0.0)) # zero at death
curve.curve = c
mat.scale_curve = curve
var mat = new ParticleProcessMaterial();
mat.ScaleMin = 1.0f;
mat.ScaleMax = 1.5f;
var curve = new CurveTexture();
var c = new Curve();
c.AddPoint(new Vector2(0.0f, 1.0f));
c.AddPoint(new Vector2(1.0f, 0.0f));
curve.Curve = c;
mat.ScaleCurve = curve;
# Gradient: white → orange → transparent
var grad := GradientTexture1D.new()
var g := Gradient.new()
g.set_color(0, Color(1.0, 1.0, 1.0, 1.0)) # start: white
g.add_point(0.5, Color(1.0, 0.5, 0.0, 0.8)) # middle: orange
g.set_color(1, Color(1.0, 0.2, 0.0, 0.0)) # end: transparent red
grad.gradient = g
mat.color_ramp = grad
var grad = new GradientTexture1D();
var g = new Gradient();
g.SetColor(0, new Color(1.0f, 1.0f, 1.0f, 1.0f));
g.AddPoint(0.5f, new Color(1.0f, 0.5f, 0.0f, 0.8f));
g.SetColor(1, new Color(1.0f, 0.2f, 0.0f, 0.0f));
grad.Gradient = g;
mat.ColorRamp = grad;
# Damping — slow particles down over time (smoke deceleration)
mat.damping_min = 2.0
mat.damping_max = 5.0
# Radial acceleration — push away from center (explosion) or pull in (implosion)
mat.radial_accel_min = 5.0 # positive = outward
mat.radial_accel_max = 8.0
# Tangential acceleration — orbit around center
mat.tangential_accel_min = 2.0
mat.tangential_accel_max = 3.0
mat.angular_velocity_min = -90.0 # degrees per second
mat.angular_velocity_max = 90.0
func create_fire() -> GPUParticles2D:
var fire := GPUParticles2D.new()
fire.amount = 50
fire.lifetime = 0.8
fire.local_coords = false
var mat := ParticleProcessMaterial.new()
mat.direction = Vector3(0, -1, 0) # upward in 2D (Y is inverted)
mat.spread = 15.0
mat.initial_velocity_min = 40.0
mat.initial_velocity_max = 80.0
mat.gravity = Vector3(0, 0, 0) # no gravity for fire
mat.scale_min = 0.5
mat.scale_max = 1.0
mat.damping_min = 3.0
mat.damping_max = 5.0
# Color: bright yellow → orange → transparent
var grad := GradientTexture1D.new()
var g := Gradient.new()
g.set_color(0, Color(1.0, 0.9, 0.3, 1.0))
g.add_point(0.4, Color(1.0, 0.4, 0.0, 0.8))
g.set_color(1, Color(0.5, 0.1, 0.0, 0.0))
grad.gradient = g
mat.color_ramp = grad
# Shrink over lifetime
var curve := CurveTexture.new()
var c := Curve.new()
c.add_point(Vector2(0.0, 1.0))
c.add_point(Vector2(1.0, 0.0))
curve.curve = c
mat.scale_curve = curve
fire.process_material = mat
fire.texture = preload("res://textures/particles/soft_circle.png")
return fire
func spawn_explosion(pos: Vector2) -> void:
var particles := GPUParticles2D.new()
particles.amount = 30
particles.lifetime = 0.5
particles.one_shot = true
particles.explosiveness = 1.0 # all at once
particles.position = pos
var mat := ParticleProcessMaterial.new()
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
mat.emission_sphere_radius = 5.0
mat.direction = Vector3(0, 0, 0)
mat.spread = 180.0 # full sphere
mat.initial_velocity_min = 100.0
mat.initial_velocity_max = 200.0
mat.damping_min = 5.0
mat.damping_max = 10.0
mat.scale_min = 0.5
mat.scale_max = 1.5
var grad := GradientTexture1D.new()
var g := Gradient.new()
g.set_color(0, Color(1.0, 1.0, 0.5, 1.0))
g.set_color(1, Color(1.0, 0.3, 0.0, 0.0))
grad.gradient = g
mat.color_ramp = grad
particles.process_material = mat
particles.texture = preload("res://textures/particles/soft_circle.png")
add_child(particles)
particles.emitting = true
# Auto-cleanup after particles die
get_tree().create_timer(particles.lifetime + 0.5).timeout.connect(particles.queue_free)
public void SpawnExplosion(Vector2 pos)
{
var particles = new GpuParticles2D();
particles.Amount = 30;
particles.Lifetime = 0.5f;
particles.OneShot = true;
particles.Explosiveness = 1.0f;
particles.Position = pos;
var mat = new ParticleProcessMaterial();
mat.EmissionShape = ParticleProcessMaterial.EmissionShapeEnum.Sphere;
mat.EmissionSphereRadius = 5.0f;
mat.Direction = Vector3.Zero;
mat.Spread = 180.0f;
mat.InitialVelocityMin = 100.0f;
mat.InitialVelocityMax = 200.0f;
mat.DampingMin = 5.0f;
mat.DampingMax = 10.0f;
mat.ScaleMin = 0.5f;
mat.ScaleMax = 1.5f;
var grad = new GradientTexture1D();
var g = new Gradient();
g.SetColor(0, new Color(1.0f, 1.0f, 0.5f, 1.0f));
g.SetColor(1, new Color(1.0f, 0.3f, 0.0f, 0.0f));
grad.Gradient = g;
mat.ColorRamp = grad;
particles.ProcessMaterial = mat;
particles.Texture = GD.Load<Texture2D>("res://textures/particles/soft_circle.png");
AddChild(particles);
particles.Emitting = true;
GetTree().CreateTimer(particles.Lifetime + 0.5f).Timeout += particles.QueueFree;
}
func spawn_dust(pos: Vector2) -> void:
var dust := GPUParticles2D.new()
dust.amount = 8
dust.lifetime = 0.4
dust.one_shot = true
dust.explosiveness = 0.8
dust.position = pos
var mat := ParticleProcessMaterial.new()
mat.direction = Vector3(0, -1, 0)
mat.spread = 60.0
mat.initial_velocity_min = 20.0
mat.initial_velocity_max = 40.0
mat.gravity = Vector3(0, 50, 0) # settle downward in 2D
mat.scale_min = 0.3
mat.scale_max = 0.6
mat.color = Color(0.7, 0.65, 0.55, 0.6)
var grad := GradientTexture1D.new()
var g := Gradient.new()
g.set_color(0, Color(1, 1, 1, 0.6))
g.set_color(1, Color(1, 1, 1, 0.0))
grad.gradient = g
mat.color_ramp = grad
dust.process_material = mat
dust.texture = preload("res://textures/particles/soft_circle.png")
add_child(dust)
dust.emitting = true
get_tree().create_timer(1.0).timeout.connect(dust.queue_free)
Trails create ribbon or tube shapes that follow each particle's path.
GPUParticles3D
├── Trail Enabled = true
├── Trail Lifetime = 0.3
├── Process Material (ParticleProcessMaterial)
└── Draw Pass 1 (mesh for particle head)
The trail mesh is auto-generated. Configure its appearance:
| Property | Description |
|---|---|
trail_enabled | Turn trails on/off |
trail_lifetime | How long the trail lingers (seconds) |
For Draw Pass 1, create a RibbonTrailMesh or TubeTrailMesh:
| Mesh Type | Shape | Best For |
|---|---|---|
RibbonTrailMesh | Flat quad strip | Sword swings, streaks |
TubeTrailMesh | Cylindrical | Lasers, energy trails |
var particles := GPUParticles3D.new()
particles.trail_enabled = true
particles.trail_lifetime = 0.3
particles.amount = 20
var trail_mesh := RibbonTrailMesh.new()
trail_mesh.size = 0.2 # trail width
trail_mesh.sections = 4
trail_mesh.section_length = 0.1
particles.draw_pass_1 = trail_mesh
var particles = new GpuParticles3D();
particles.TrailEnabled = true;
particles.TrailLifetime = 0.3f;
particles.Amount = 20;
var trailMesh = new RibbonTrailMesh();
trailMesh.Size = 0.2f;
trailMesh.Sections = 4;
trailMesh.SectionLength = 0.1f;
particles.DrawPass1 = trailMesh;
Important: Trail materials need Use Particle Trails enabled in the StandardMaterial3D's Transform section to render correctly.
Subemitters are child particle systems that spawn in response to parent particle events.
| Mode | Spawns When | Key Property |
|---|---|---|
Constant | At regular intervals per particle | frequency (per second) |
At End | When a parent particle dies | amount_at_end |
At Collision | When a particle hits a collision node | amount_at_collision |
# Parent material setup
var parent_mat := ParticleProcessMaterial.new()
parent_mat.sub_emitter_mode = ParticleProcessMaterial.SUB_EMITTER_AT_END
parent_mat.sub_emitter_amount_at_end = 8
parent_mat.sub_emitter_keep_velocity = true
# Assign child particle system as sub-emitter
$ParentParticles.process_material = parent_mat
parent_mat.sub_emitter_node = $ChildParticles.get_path()
public partial class Explosion : GpuParticles3D
{
[Export] public GpuParticles3D ChildParticles { get; set; }
public override void _Ready()
{
var parentMat = new ParticleProcessMaterial
{
SubEmitterMode = ParticleProcessMaterial.SubEmitterModeEnum.AtEnd,
SubEmitterAmountAtEnd = 8,
SubEmitterKeepVelocity = true,
};
ProcessMaterial = parentMat;
parentMat.SubEmitterNode = ChildParticles.GetPath();
}
}
Most projects configure subemitters in the Inspector (drag the child node onto the
SubEmitterNodeslot of theParticleProcessMaterial). The C# code above is only needed when building particle effects programmatically.
explosiveness has no effect on subemittersPull or repel particles. Enable Attractor Interaction on the ParticleProcessMaterial first.
| Node | Shape | Notes |
|---|---|---|
GPUParticlesAttractorBox3D | Box | size controls extents |
GPUParticlesAttractorSphere3D | Sphere | radius controls size |
GPUParticlesAttractorVectorField3D | Vector field | Uses 3D texture for directions |
# Create a sphere attractor that pulls particles in
var attractor := GPUParticlesAttractorSphere3D.new()
attractor.radius = 5.0
attractor.strength = 3.0 # positive = pull, negative = repel
attractor.attenuation = 1.0 # falloff curve
attractor.position = Vector3(0, 2, 0)
add_child(attractor)
# Don't forget to enable on the material!
var mat: ParticleProcessMaterial = $GPUParticles3D.process_material
mat.attractor_interaction_enabled = true
var attractor = new GpuParticlesAttractorSphere3D();
attractor.Radius = 5.0f;
attractor.Strength = 3.0f;
attractor.Attenuation = 1.0f;
attractor.Position = new Vector3(0, 2, 0);
AddChild(attractor);
var mat = GetNode<GpuParticles3D>("GPUParticles3D").ProcessMaterial as ParticleProcessMaterial;
mat.AttractorInteractionEnabled = true;
Make particles bounce or stop on surfaces.
| Node | Shape | Notes |
|---|---|---|
GPUParticlesCollisionBox3D | Box | Simple, fast |
GPUParticlesCollisionSphere3D | Sphere | Simple, fast |
GPUParticlesCollisionHeightField3D | Height field | Terrain — auto-updates |
GPUParticlesCollisionSDF3D | SDF (baked) | Complex geometry, Forward+ only |
Configure collision response on the ParticleProcessMaterial:
mat.collision_mode = ParticleProcessMaterial.COLLISION_RIGID # bounce off
mat.collision_bounce = 0.3 # bounciness
mat.collision_friction = 0.5 # sliding resistance
mat.CollisionMode = ParticleProcessMaterial.CollisionModeEnum.Rigid;
mat.CollisionBounce = 0.3f;
mat.CollisionFriction = 0.5f;
Noise-based movement that adds organic variation. Enable on ParticleProcessMaterial.
mat.turbulence_enabled = true
mat.turbulence_noise_strength = 2.0
mat.turbulence_noise_scale = 1.5
mat.turbulence_noise_speed = Vector3(0.5, 0.0, 0.0) # pan direction
mat.turbulence_influence_min = 0.3
mat.turbulence_influence_max = 0.8
mat.TurbulenceEnabled = true;
mat.TurbulenceNoiseStrength = 2.0f;
mat.TurbulenceNoiseScale = 1.5f;
mat.TurbulenceNoiseSpeed = new Vector3(0.5f, 0.0f, 0.0f);
mat.TurbulenceInfluenceMin = 0.3f;
mat.TurbulenceInfluenceMax = 0.8f;
Performance: 3D noise is GPU-intensive. Use sparingly on mobile/web targets. Higher
noise_scalevalues are cheaper but produce weaker turbulence.
Animate sprite sheet textures on particles.
anim_speed_min / anim_speed_max — playback speedanim_offset_min / anim_offset_max — random start frame# Process material animation settings
mat.anim_speed_min = 1.0
mat.anim_speed_max = 1.5
mat.anim_offset_min = 0.0
mat.anim_offset_max = 0.5 # random start position in the animation
public partial class HitSpark : GpuParticles2D
{
public override void _Ready()
{
// Process material — animation playback speed and start offset
var mat = (ParticleProcessMaterial)ProcessMaterial;
mat.AnimSpeedMin = 1.0f;
mat.AnimSpeedMax = 1.5f;
mat.AnimOffsetMin = 0.0f;
mat.AnimOffsetMax = 0.5f;
// CanvasItemMaterial — sheet layout and looping
var canvasMat = (CanvasItemMaterial)Material;
canvasMat.ParticlesAnimHFrames = 4;
canvasMat.ParticlesAnimVFrames = 4;
canvasMat.ParticlesAnimLoop = false;
canvasMat.BlendMode = CanvasItemMaterial.BlendModeEnum.Add; // good default for fire/sparks
}
}
Use Add blend mode on the CanvasItemMaterial for particles with black backgrounds (fire, sparks, magic).
| Technique | Savings | When to Use |
|---|---|---|
Lower amount | Linear GPU savings | Always — use minimum needed |
fixed_fps = 30 | Halves particle updates | Background particles, ambient |
amount_ratio < 1.0 | Scale down dynamically | Quality settings slider |
| Smaller textures | Less VRAM + bandwidth | Mobile, many particle systems |
local_coords = true | Cheaper transforms | When particles should move with node |
Disable turbulence | Removes 3D noise cost | Mobile/web targets |
Fewer trail_sections | Less trail geometry | When trail smoothness isn't critical |
visibility_rect (2D) | Skips off-screen | Always set for 2D particles |
# Adjust particle density based on quality setting
func set_particle_quality(level: float) -> void:
# level: 0.25 (low) to 1.0 (high)
for particles in get_tree().get_nodes_in_group("particles"):
if particles is GPUParticles2D or particles is GPUParticles3D:
particles.amount_ratio = level
public void SetParticleQuality(float level)
{
foreach (var node in GetTree().GetNodesInGroup("particles"))
{
if (node is GpuParticles2D p2d)
p2d.AmountRatio = level;
else if (node is GpuParticles3D p3d)
p3d.AmountRatio = level;
}
}
| Symptom | Cause | Fix |
|---|---|---|
| Particles invisible | No texture (2D) or no draw pass mesh (3D) | Set texture or assign a mesh to Draw Pass 1 |
| Particles appear then vanish immediately | lifetime too short | Increase lifetime (default 1.0s) |
| One-shot doesn't re-trigger | Need to call restart() before setting emitting = true | Call restart() then set emitting = true |
| Particles emit in wrong direction | direction or gravity misconfigured | In 2D, Y is inverted — upward is Vector3(0, -1, 0) |
| Particles don't follow the node | local_coords is false | Set local_coords = true for attached effects |
| Particles pop in (no pre-warming) | No preprocess time set | Set preprocess to 1–2x lifetime for ambient effects |
| Color ramp has no effect | Using color property which overrides ramp | Clear the base color (set to white) when using color_ramp |
| Trails not rendering | Missing trail material setup or wrong renderer | Enable "Use Particle Trails" on material; use Forward+ or Mobile |
| Attractors have no effect | attractor_interaction_enabled is false | Enable on the ParticleProcessMaterial |
| Subemitter not spawning | Child amount is too low to accommodate spawns | Increase child system's amount |
| Particles flicker on mobile | fixed_fps not set or too high | Set fixed_fps = 30 for consistency across devices |
amount is set to the minimum needed for the visual effectlifetime matches the visual duration — not too short or too longone_shot is enabled for burst effects (explosions, impacts)preprocess is set for always-visible ambient effects (fire, smoke, dust)color_ramp fades alpha to 0 at the end so particles don't vanish abruptlyscale_curve shrinks particles over lifetime for natural fadelocal_coords is set correctly — true for attached effects, false for world-spacequeue_free after lifetime + marginvisibility_rect (2D) is set to prevent particles from being culled prematurelyamount_ratio for player-accessible quality settings