Saltmire
Practical Godot 4 tutorials — game feel, saving, and shipping polish fast.

GPUParticles2D vs CPUParticles2D in Godot 4 — which and when

You open the Add Node dialog, type "particles", and Godot 4 offers you two nodes that look identical in the viewport: GPUParticles2D and CPUParticles2D. Both emit sprites, both have amount, lifetime, one_shot and explosiveness. The names tell you where the simulation runs, but not what that means for your game. Here's the practical difference, and the rule I use to pick.

GPUParticles2D: the simulation lives in a shader

GPUParticles2D moves every particle on the graphics card. Its behaviour comes from a ParticleProcessMaterial — velocity, gravity, scale curves, color ramps — which Godot compiles into a shader.

var p := GPUParticles2D.new()
var mat := ParticleProcessMaterial.new()
mat.direction = Vector3(0, -1, 0)
mat.spread = 45.0
mat.initial_velocity_min = 120.0
mat.initial_velocity_max = 220.0
mat.gravity = Vector3(0, 400, 0)
p.process_material = mat
p.amount = 16
p.lifetime = 0.5
p.one_shot = true
p.explosiveness = 1.0
add_child(p)
p.restart()

What it gives you that the CPU node doesn't: turbulence, collision against LightOccluder2D shapes, sub-emitters (a spark that spawns smoke when it dies), trails, and custom particle shaders. It also scales: thousands of particles cost the CPU almost nothing, because the CPU never touches them.

What it costs you:

  • A visibility_rect you must maintain. The node is culled by that rectangle, and the default is small. Particles that fly outside it simply vanish.
  • A first-use hitch. The process material is a shader, and shaders compile the first time they're used. On many machines the very first explosion stutters for a frame. You can hide it by firing each effect once off-screen during your loading screen.
  • Renderer quirks. GPU particles only work on the Compatibility renderer (the one web exports and many low-end Android devices use) since Godot 4.2, and that path is less battle-tested than Forward+. If you target the web, test there early.

CPUParticles2D: the simulation lives in a script loop

CPUParticles2D does the same job in engine code on the CPU. There is no process material — every setting is a property directly on the node:

var p := CPUParticles2D.new()
p.direction = Vector2(0, -1)
p.spread = 45.0
p.initial_velocity_min = 120.0
p.initial_velocity_max = 220.0
p.gravity = Vector2(0, 400)
p.amount = 16
p.lifetime = 0.5
p.one_shot = true
p.explosiveness = 1.0
add_child(p)
p.restart()

That's the whole point of it: it behaves the same everywhere. Forward+, Mobile, Compatibility, web, old integrated GPUs — the simulation is identical because it never depended on the graphics card. There's no shader to compile, so no first-burst hitch, and no visibility_rect to babysit — the bounds are computed for you.

The trade-off is a smaller feature set (no turbulence, collision, sub-emitters or trails) and a cost that grows with particle count. Every particle is updated on the main CPU every frame. Sixteen sparks per hit is nothing. Ten thousand snowflakes is not.

The comparison

GPUParticles2D CPUParticles2D
Where it simulates GPU (shader) CPU (engine loop)
Configuration ParticleProcessMaterial resource properties on the node
Cost of 10,000 particles low on CPU high on CPU
Cost of 15 small bursts a draw + a shader each tiny
Turbulence, collision, sub-emitters, trails yes no
First-use shader hitch yes (pre-warm it) no
visibility_rect culling manual, easy to get wrong automatic
Compatibility renderer / web supported since 4.2, test it works everywhere
restart(), one_shot, finished signal yes yes

Switching costs one click

You don't have to commit forever. Select a GPUParticles2D in the editor and the toolbar's GPUParticles2D menu has Convert to CPUParticles2D. The CPU node has the reverse option in its own toolbar menu. Features the CPU node lacks are dropped in the conversion, so commit the scene first and compare.

If you build effects in code and ship to several platforms, you can also pick at runtime:

func make_burst_node() -> Node2D:
    var method: String = ProjectSettings.get_setting("rendering/renderer/rendering_method")
    if method == "gl_compatibility" or OS.has_feature("web"):
        return CPUParticles2D.new()
    return GPUParticles2D.new()

If you go this route, keep your tuning values in one place (a dictionary or a custom resource) so both branches read the same numbers.

The rule that decides it

Ask two questions:

  1. Is it a big, long-lived or fancy effect? Rain, snow, a fog bank, a magic vortex with turbulence, sparks that bounce off walls. That's GPUParticles2D.
  2. Is it a small, short burst that fires a lot? Hit sparks, pickup glitter, dust from a footstep, a coin pop. That's CPUParticles2D. Especially if you export to the web.

Most 2D action games end up with both: a few big GPU systems for ambience, and many tiny CPU bursts for feedback. The bursts are where the work really piles up, though — every new enemy hit, pickup and death wants its own tuned node, its own one_shot setup, and its own cleanup when it's done.

That's the part Saltmire Spark takes off your plate. It's a free, MIT-licensed autoload that fires a tuned 2D burst in one line (Spark.burst(pos, "hit")) and frees itself afterwards. It doesn't use either particle node — the particles are drawn procedurally — so there's no process material, no visibility_rect and no renderer difference to think about.

Built this the long way once too many times. Saltmire Spark does it as a drop-in tool: https://saltmire.itch.io/saltmire-spark