Course Unlocked — 5 Lessons
✨Particle Systems

GPU Particle Systems That Don't Tank FPS

By Ash Lindgren — VFX artist and Godot contributor. Obsessed with making pretty things run at 60fps on integrated GPUs.

1

GPUParticles3D vs CPUParticles3D

Godot 4 offers two particle systems with very different performance profiles. GPUParticles3D runs entirely on the GPU via compute shaders — it can handle tens of thousands of particles with near-zero CPU cost. CPUParticles3D processes particles on the CPU, which means it's slower at high counts but works on hardware without compute shader support (some mobile GPUs and WebGL 1).

Here's the decision framework: Use GPUParticles3D for anything over 100 particles, effects that need to look great (fire, explosions, magic), or when you're already CPU-bound and have GPU headroom. Use CPUParticles3D for small effects under 50 particles, when you need accurate physics interactions with CharacterBody or RigidBody nodes, or when targeting WebGL 1. In practice, 90% of your effects should be GPUParticles3D — the GPU is almost always underutilized in indie games while the CPU is bottlenecked on gameplay logic.

Let's set up a basic GPUParticles3D fire effect to understand the node structure. Create a new 3D scene and add a GPUParticles3D node. The key components are: the GPUParticles3D node itself (controls emission rate, lifetime, and transform), the ParticleProcessMaterial (controls how each particle moves over its lifetime), and the draw pass mesh (what each particle looks like).

gdscript
# Setting up fire particles entirely from code — useful for procedural VFX
class_name FireEffect
extends GPUParticles3D

func _ready() -> void:
    # Emission settings
    amount = 200
    lifetime = 1.2
    explosiveness = 0.0  # Continuous emission (1.0 = burst all at once)
    randomness = 0.3
    fixed_fps = 30  # Update particles at 30fps to save GPU cycles

    # Particle behavior via ParticleProcessMaterial
    var mat := ParticleProcessMaterial.new()
    mat.direction = Vector3(0, 1, 0)  # Particles move upward
    mat.spread = 15.0  # Cone angle in degrees
    mat.initial_velocity_min = 1.5
    mat.initial_velocity_max = 3.0
    mat.gravity = Vector3(0, -0.5, 0)  # Slight downward pull for realism
    mat.damping_min = 1.0  # Slow particles down over time
    mat.damping_max = 2.0

    # Scale: start large, shrink to nothing
    mat.scale_min = 0.8
    mat.scale_max = 1.2
    mat.scale_curve = _make_scale_curve()

    # Color: orange-yellow fading to transparent
    var gradient := Gradient.new()
    gradient.set_color(0, Color(1.0, 0.8, 0.2, 1.0))   # Bright yellow-orange
    gradient.add_point(0.3, Color(1.0, 0.4, 0.1, 0.9))  # Orange
    gradient.add_point(0.7, Color(0.8, 0.1, 0.0, 0.5))  # Dark red, fading
    gradient.set_color(1, Color(0.3, 0.05, 0.0, 0.0))    # Transparent
    var grad_tex := GradientTexture1D.new()
    grad_tex.gradient = gradient
    mat.color_ramp = grad_tex

    process_material = mat

    # Draw pass: simple quad mesh with additive blending
    var quad := QuadMesh.new()
    quad.size = Vector2(0.5, 0.5)
    var draw_mat := StandardMaterial3D.new()
    draw_mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
    draw_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
    draw_mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
    draw_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED  # No lighting calc
    draw_mat.vertex_color_use_as_albedo = true  # Use particle color from gradient
    quad.material = draw_mat
    draw_pass_1 = quad

Several performance-critical settings here: `fixed_fps = 30` means the particle simulation runs at 30fps instead of matching the game framerate. This halves GPU compute cost with negligible visual difference. `shading_mode = UNSHADED` skips lighting calculations for each particle quad — essential since fire emits light, it doesn't receive it. `BLEND_MODE_ADD` (additive blending) means particles brighten each other where they overlap, which looks natural for fire and avoids the sorting cost of alpha blending.

The scale curve makes particles shrink over their lifetime. Here's how to create it in code:

gdscript
func _make_scale_curve() -> CurveTexture:
    var curve := Curve.new()
    curve.add_point(Vector2(0.0, 0.3))   # Start small (just spawned)
    curve.add_point(Vector2(0.15, 1.0))  # Quickly grow to full size
    curve.add_point(Vector2(0.5, 0.7))   # Gradually shrink
    curve.add_point(Vector2(1.0, 0.0))   # Vanish at end of life
    var tex := CurveTexture.new()
    tex.curve = curve
    return tex

This curve creates the classic fire look: particles bloom outward quickly, then gently shrink and fade as they rise. The fast growth at 0.0-0.15 prevents the 'popping in from nothing' artifact that makes cheap fire effects look bad. Combined with the color gradient (bright yellow → orange → red → transparent), you get convincing stylized fire with just 200 particles.

Performance comparison: this fire effect with 200 particles at fixed_fps=30 uses approximately 0.15ms of GPU time on an integrated Intel GPU. The equivalent CPUParticles3D version uses 0.8ms of CPU time for the same visual. On a dedicated GPU, the GPUParticles3D cost drops to nearly unmeasurable. Always benchmark on your target minimum hardware — not your development machine.

2

Particle Materials and Custom Shaders

ParticleProcessMaterial handles 80% of particle behaviors through its inspector properties — velocity, gravity, damping, scale curves, color ramps, and turbulence. But for truly custom effects (particles that orbit a point, respond to gameplay events, or follow complex paths), you need a custom particle shader. Godot 4 lets you replace the process material with a raw shader that runs on the GPU for each particle every frame.

A particle shader has a special structure. Instead of `vertex()` and `fragment()` functions like a spatial shader, it uses a `start()` function (called once when a particle spawns) and a `process()` function (called every frame for each living particle). You write to built-in variables like VELOCITY, TRANSFORM, COLOR, and CUSTOM to control each particle.

glsl
// Custom particle shader: orbital motion around emitter
shader_type particles;

uniform float orbit_radius = 2.0;
uniform float orbit_speed = 3.0;
uniform float rise_speed = 0.5;
uniform float particle_scale = 0.15;
uniform vec4 color_start : source_color = vec4(0.5, 0.3, 1.0, 1.0);
uniform vec4 color_end : source_color = vec4(0.2, 0.1, 0.8, 0.0);

void start() {
    // Random starting angle for each particle
    float angle = float(INDEX) / float(AMOUNT) * TAU + RANDOM_SEED * TAU;
    CUSTOM.x = angle;  // Store angle in CUSTOM for process()
    CUSTOM.y = RANDOM_SEED;  // Store random value for variation

    // Initial position on the orbit circle
    float r = orbit_radius * (0.8 + CUSTOM.y * 0.4);  // Slight radius variation
    TRANSFORM[3].x = cos(angle) * r;
    TRANSFORM[3].z = sin(angle) * r;
    TRANSFORM[3].y = 0.0;
}

void process() {
    // Advance the orbit angle
    float angle = CUSTOM.x + TIME * orbit_speed * (0.8 + CUSTOM.y * 0.4);
    float r = orbit_radius * (0.8 + CUSTOM.y * 0.4);

    // Set position on orbit circle, rising over time
    TRANSFORM[3].x = cos(angle) * r;
    TRANSFORM[3].z = sin(angle) * r;
    TRANSFORM[3].y += rise_speed * DELTA;

    // Scale down over lifetime
    float life_ratio = LIFETIME - TIME;  // Remaining life
    float scale = particle_scale * smoothstep(0.0, 0.3, life_ratio);
    TRANSFORM[0].x = scale;
    TRANSFORM[1].y = scale;
    TRANSFORM[2].z = scale;

    // Fade color over lifetime
    float t = 1.0 - (life_ratio / LIFETIME);
    COLOR = mix(color_start, color_end, t);
}

The CUSTOM variable is a vec4 that persists per-particle between frames — it's your scratchpad for storing per-particle state. INDEX gives you the particle's index (0 to AMOUNT-1), which is great for distributing particles evenly. RANDOM_SEED is a per-particle random float between 0 and 1, regenerated each time the particle respawns.

To use this shader, create a ShaderMaterial, paste in the shader code, and assign it as the GPUParticles3D's process_material. You can then tweak uniforms in the inspector or from GDScript:

gdscript
# Applying a custom particle shader
func setup_orbital_particles() -> void:
    var particles := GPUParticles3D.new()
    particles.amount = 64
    particles.lifetime = 3.0

    # Load the custom shader
    var shader := Shader.new()
    shader.code = preload("res://shaders/orbital_particles.gdshader").code
    # Or load directly: shader = load("res://shaders/orbital_particles.gdshader")

    var shader_mat := ShaderMaterial.new()
    shader_mat.shader = shader
    shader_mat.set_shader_parameter("orbit_radius", 1.5)
    shader_mat.set_shader_parameter("orbit_speed", 2.0)
    shader_mat.set_shader_parameter("color_start", Color(0.4, 0.8, 1.0, 1.0))
    shader_mat.set_shader_parameter("color_end", Color(0.1, 0.3, 0.8, 0.0))

    particles.process_material = shader_mat

    # Draw pass — small billboard quads
    var quad := QuadMesh.new()
    quad.size = Vector2(0.3, 0.3)
    var draw_mat := StandardMaterial3D.new()
    draw_mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
    draw_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
    draw_mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
    draw_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
    draw_mat.vertex_color_use_as_albedo = true
    quad.material = draw_mat
    particles.draw_pass_1 = quad

    add_child(particles)

Custom shaders unlock effects that are impossible with ParticleProcessMaterial alone: particles that follow spline paths, react to game world data (via uniform textures), form shapes, or simulate fluid dynamics. The performance is excellent because all computation runs on the GPU in parallel — 10,000 particles with a custom shader costs about the same as 10,000 particles with ParticleProcessMaterial.

One important caveat: particle shaders cannot read back data to the CPU. You can't query a particle's position from GDScript or use particle positions for gameplay logic (like dealing damage where particles land). If you need that, either use CPUParticles3D, or use the GPU particles for visuals and run a separate CPU-side simulation for gameplay. This separation of 'visual particles' and 'gameplay hitboxes' is standard practice in production games.

3

Performance Profiling and Optimization

Before optimizing particles, you need to measure them. Guessing at performance is a recipe for wasted effort — you'll optimize things that don't matter and miss the actual bottleneck. Godot 4 provides two profiling tools: the built-in Performance monitor (Debugger → Monitors) and the RenderingServer's frame profiling data.

The key metrics to watch are: draw calls (each GPUParticles3D node is at least one draw call), GPU frame time (should be under 16.6ms for 60fps), and particle count. High draw calls cause CPU-side overhead even though particles themselves run on the GPU, because each draw call requires the CPU to set up GPU state. High particle counts increase GPU compute and fill-rate costs.

gdscript
# Runtime performance overlay for particle debugging
class_name ParticleProfiler
extends CanvasLayer

@onready var label: Label = $Label

func _process(_delta: float) -> void:
    var fps := Engine.get_frames_per_second()
    var gpu_time := RenderingServer.viewport_get_measured_render_time_gpu(
        get_viewport().get_viewport_rid()
    )
    var draw_calls := Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)
    var objects := Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)

    label.text = "FPS: %d | GPU: %.1fms | Draws: %d | Objects: %d" % [
        fps, gpu_time, draw_calls, objects
    ]

    # Color-code: green if under budget, yellow if close, red if over
    if gpu_time < 12.0:
        label.modulate = Color.GREEN
    elif gpu_time < 16.0:
        label.modulate = Color.YELLOW
    else:
        label.modulate = Color.RED

With profiling in place, here are the five highest-impact particle optimizations, ranked by typical improvement:

1. Reduce overdraw with additive blending. Alpha-blended particles require sorting (back-to-front), which is expensive, and overlapping transparent pixels are drawn multiple times. Additive blending (BLEND_MODE_ADD) needs no sorting and overlapping particles just get brighter — it's a natural fit for fire, magic, lasers, and glowing effects. Switch to additive blending wherever visually acceptable.

2. Use fixed_fps to decouple particle simulation from frame rate. Setting `fixed_fps = 30` on a GPUParticles3D node means particles update at 30fps regardless of game framerate. At 60fps game, this halves the particle compute cost. At 144fps, it's nearly 5x cheaper. The visual difference is negligible for most effects because the interpolation between fixed-rate updates looks smooth.

3. Implement LOD (Level of Detail) scaling based on camera distance. Far-away effects don't need as many particles because individual particles are subpixel-sized. Scale particle count down with distance:

gdscript
class_name LODParticles
extends GPUParticles3D

@export var base_amount := 200
@export var lod_distances: Array[float] = [10.0, 25.0, 50.0]
@export var lod_multipliers: Array[float] = [1.0, 0.5, 0.25]
@export var cull_distance := 80.0  # Stop rendering entirely beyond this

var camera: Camera3D

func _ready() -> void:
    amount = base_amount

func _process(_delta: float) -> void:
    if not camera:
        camera = get_viewport().get_camera_3d()
        if not camera:
            return

    var dist := global_position.distance_to(camera.global_position)

    # Cull entirely if too far
    if dist > cull_distance:
        emitting = false
        visible = false
        return

    visible = true
    emitting = true

    # Find the right LOD level
    var multiplier := lod_multipliers[0]
    for i in range(lod_distances.size()):
        if dist > lod_distances[i]:
            multiplier = lod_multipliers[min(i, lod_multipliers.size() - 1)]

    amount = max(1, int(base_amount * multiplier))

4. Batch similar particle systems. If you have 20 torches in a scene, each one being a separate GPUParticles3D node means 20 draw calls. Instead, use a single GPUParticles3D with a higher particle count and a custom shader that distributes particles across multiple emitter positions passed via a uniform texture. This reduces 20 draw calls to 1.

5. Use small meshes and disable unnecessary features. Every particle renders a mesh — smaller meshes mean less fill rate. A 0.2x0.2 quad costs 1/6th the fill rate of a 0.5x0.5 quad. Also disable features you don't need: `no_depth_test = true` skips depth buffer reads, `shading_mode = UNSHADED` skips lighting, and `cast_shadow = SHADOW_CASTING_SETTING_OFF` on the GPUParticles3D node prevents shadow map renders for particles.

gdscript
# Optimized draw material checklist
func create_optimized_particle_material() -> StandardMaterial3D:
    var mat := StandardMaterial3D.new()
    # Mandatory for particles
    mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
    mat.vertex_color_use_as_albedo = true
    mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA

    # Performance optimizations
    mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED  # Skip lighting
    mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD  # No sorting needed
    mat.no_depth_test = true  # Skip depth buffer read (particles don't occlude)
    mat.disable_receive_shadows = true  # Particles don't receive shadows

    return mat

With these five optimizations applied, a typical indie game can run 50+ simultaneous particle effects at 60fps on integrated graphics. The most impactful by far are fixed_fps and LOD culling — together they can reduce particle GPU cost by 80% with no visible quality loss.

4

Sub-Emitters and Collision

Sub-emitters let one particle system spawn secondary particles — sparks flying off a firework, embers falling from a torch, or debris scattering from an explosion. Godot 4's ParticleProcessMaterial supports sub-emitters natively: when a particle meets certain conditions (spawns, collides, or dies), it triggers a secondary GPUParticles3D node.

The setup requires two GPUParticles3D nodes: the primary emitter (parent) and the sub-emitter (child). The sub-emitter's `emitting` property should be set to false — the parent triggers it automatically. Configure the sub-emitter mode on the parent's ParticleProcessMaterial:

gdscript
# Firework: main burst + secondary sparkle trails
func create_firework() -> GPUParticles3D:
    # Primary burst
    var burst := GPUParticles3D.new()
    burst.amount = 50
    burst.lifetime = 1.5
    burst.one_shot = true  # Single burst, not continuous
    burst.explosiveness = 1.0  # All particles emit at once

    var burst_mat := ParticleProcessMaterial.new()
    burst_mat.direction = Vector3(0, 1, 0)
    burst_mat.spread = 180.0  # Full sphere
    burst_mat.initial_velocity_min = 5.0
    burst_mat.initial_velocity_max = 10.0
    burst_mat.gravity = Vector3(0, -4.0, 0)
    burst_mat.damping_min = 1.0
    burst_mat.damping_max = 3.0

    # Sub-emitter: sparkle trail on each particle
    burst_mat.sub_emitter_mode = ParticleProcessMaterial.SUB_EMITTER_CONSTANT
    burst_mat.sub_emitter_frequency = 15.0  # 15 sub-particles per second per parent
    burst_mat.sub_emitter_amount_at_end = 8  # Extra burst when parent dies
    burst_mat.sub_emitter_keep_velocity = true  # Inherit parent velocity

    burst.process_material = burst_mat

    # Secondary sparkle emitter
    var sparkle := GPUParticles3D.new()
    sparkle.amount = 500  # Pool size for all sub-particles
    sparkle.lifetime = 0.6
    sparkle.emitting = false  # Controlled by parent

    var sparkle_mat := ParticleProcessMaterial.new()
    sparkle_mat.direction = Vector3(0, -1, 0)
    sparkle_mat.spread = 45.0
    sparkle_mat.initial_velocity_min = 0.5
    sparkle_mat.initial_velocity_max = 2.0
    sparkle_mat.gravity = Vector3(0, -6.0, 0)
    sparkle_mat.scale_min = 0.3
    sparkle_mat.scale_max = 0.5
    sparkle.process_material = sparkle_mat

    # Draw passes (setup omitted for brevity — same pattern as Lesson 1)
    burst.draw_pass_1 = _make_billboard_quad(0.3)
    sparkle.draw_pass_1 = _make_billboard_quad(0.1)

    # Link sub-emitter
    burst.sub_emitter = sparkle.get_path()
    burst.add_child(sparkle)

    return burst

The three sub-emitter modes are: CONSTANT (continuously emits sub-particles from each living parent particle — great for trails), AT_END (emits a burst when a parent particle dies — great for impact sparks), and AT_COLLISION (emits when a parent particle hits a collision surface — great for splashes and debris). You can combine modes by using intermediate emitters: primary → sub-emitter (CONSTANT trails) → sub-sub-emitter (AT_END death sparks).

For particle collision, Godot 4 uses GPUParticlesCollision3D nodes. These are invisible shapes in the scene that deflect particles. The most common types are GPUParticlesCollisionSphere3D (fast, good for character shields), GPUParticlesCollisionBox3D (floors and walls), and GPUParticlesCollisionSDF3D (complex mesh shapes via Signed Distance Fields).

gdscript
# Ground collision for particles — they bounce off a floor plane
func setup_ground_collision() -> void:
    # Box collision representing the ground plane
    var floor_collision := GPUParticlesCollisionBox3D.new()
    floor_collision.size = Vector3(100, 1, 100)  # Wide, thin box
    floor_collision.position = Vector3(0, -0.5, 0)  # Top surface at y=0
    add_child(floor_collision)

    # Enable collision on the particle material
    var mat: ParticleProcessMaterial = particles.process_material
    mat.collision_mode = ParticleProcessMaterial.COLLISION_RIGID
    mat.collision_bounce = 0.3  # How much velocity is retained after bounce
    mat.collision_friction = 0.8  # How much horizontal velocity is lost
    mat.collision_use_scale = true  # Scale affects collision shape

SDF collision is the most powerful option for complex environments. A Signed Distance Field bakes your level geometry into a 3D texture that particles can read to detect surfaces. To set it up: add a GPUParticlesCollisionSDF3D node, set its size to cover your play area, add your level meshes as children (or reference them), and click 'Bake SDF' in the inspector. The resulting 3D texture lets particles collide with arbitrary geometry at almost zero runtime cost.

Performance note on sub-emitters and collision: sub-emitters multiply your particle count (50 parents × 15 sub-particles/second × 0.6 lifetime = up to 450 simultaneous sub-particles). Budget accordingly and always profile. Collision adds a texture lookup per particle per frame — negligible for SDF but box/sphere collisions are computed analytically and scale with the number of collision shapes. Keep collision shapes under 5 per scene for best performance.

5

Building a Complete VFX: Magic Projectile

Let's put everything together by building a production-quality magic projectile effect. This is the kind of VFX you'd see in a polished action RPG: a glowing core, a swirling particle trail, and impact sparks when it hits something. We'll use three GPUParticles3D nodes — one for each layer — to keep the effect modular and independently tunable.

The architecture: a root Node3D holds the three particle systems and a script that handles movement and collision detection. The projectile moves forward using `_physics_process`, and when a raycast detects a hit, we stop the trail, trigger the impact burst, and queue the projectile for deletion after the effects finish.

gdscript
class_name MagicProjectile
extends Node3D

@export var speed := 20.0
@export var max_distance := 50.0
@export var damage := 25

var distance_traveled := 0.0

@onready var core: GPUParticles3D = $CoreGlow
@onready var trail: GPUParticles3D = $Trail
@onready var impact: GPUParticles3D = $Impact
@onready var raycast: RayCast3D = $RayCast3D

func _ready() -> void:
    impact.emitting = false  # Only emits on hit
    raycast.target_position = Vector3(0, 0, -speed * 0.05)  # Look ahead

func _physics_process(delta: float) -> void:
    var move := -transform.basis.z * speed * delta
    global_translate(move)
    distance_traveled += move.length()

    if raycast.is_colliding():
        _on_impact(raycast.get_collision_point(), raycast.get_collision_normal())
        return

    if distance_traveled > max_distance:
        _fizzle_out()

func _on_impact(point: Vector3, normal: Vector3) -> void:
    # Stop moving
    set_physics_process(false)
    global_position = point

    # Stop trail emission, let existing trail particles finish their lifetime
    trail.emitting = false
    core.emitting = false

    # Trigger impact burst oriented along the surface normal
    impact.global_position = point
    impact.transform = impact.transform.looking_at(point + normal, Vector3.UP)
    impact.emitting = true
    impact.one_shot = true

    # Clean up after all particles have died
    var max_life := maxf(trail.lifetime, impact.lifetime)
    get_tree().create_timer(max_life + 0.1).timeout.connect(queue_free)

func _fizzle_out() -> void:
    trail.emitting = false
    core.emitting = false
    get_tree().create_timer(trail.lifetime + 0.1).timeout.connect(queue_free)

Now let's build each particle layer. The core glow is a small, bright cluster of particles that forms the 'body' of the projectile. It uses very few particles (8-12) with short lifetime and high emission energy for an intense glow:

gdscript
func _setup_core() -> void:
    core.amount = 10
    core.lifetime = 0.15
    core.fixed_fps = 30

    var mat := ParticleProcessMaterial.new()
    mat.direction = Vector3(0, 0, 0)
    mat.spread = 180.0
    mat.initial_velocity_min = 0.2
    mat.initial_velocity_max = 0.5
    mat.gravity = Vector3.ZERO
    mat.scale_min = 0.4
    mat.scale_max = 0.6
    core.process_material = mat

    core.draw_pass_1 = _make_billboard_quad(0.4)

func _setup_trail() -> void:
    trail.amount = 80
    trail.lifetime = 0.8
    trail.fixed_fps = 30

    var mat := ParticleProcessMaterial.new()
    mat.direction = Vector3(0, 0, 1)  # Emit backward (trail behind projectile)
    mat.spread = 25.0
    mat.initial_velocity_min = 0.5
    mat.initial_velocity_max = 1.5
    mat.gravity = Vector3(0, 0.3, 0)  # Slight upward drift for wispy look
    mat.damping_min = 2.0
    mat.damping_max = 4.0
    mat.scale_min = 0.2
    mat.scale_max = 0.4

    # Spiral motion via tangential acceleration
    mat.tangential_accel_min = 2.0
    mat.tangential_accel_max = 4.0

    var gradient := Gradient.new()
    gradient.set_color(0, Color(0.4, 0.6, 1.0, 0.9))
    gradient.add_point(0.5, Color(0.3, 0.4, 0.9, 0.5))
    gradient.set_color(1, Color(0.1, 0.2, 0.6, 0.0))
    var grad_tex := GradientTexture1D.new()
    grad_tex.gradient = gradient
    mat.color_ramp = grad_tex

    trail.process_material = mat
    trail.draw_pass_1 = _make_billboard_quad(0.3)

func _setup_impact() -> void:
    impact.amount = 30
    impact.lifetime = 0.5
    impact.one_shot = true
    impact.explosiveness = 0.9  # Almost all at once

    var mat := ParticleProcessMaterial.new()
    mat.direction = Vector3(0, 0, -1)  # Away from surface
    mat.spread = 60.0
    mat.initial_velocity_min = 3.0
    mat.initial_velocity_max = 8.0
    mat.gravity = Vector3(0, -8.0, 0)
    mat.damping_min = 3.0
    mat.damping_max = 6.0
    mat.scale_min = 0.1
    mat.scale_max = 0.3

    var gradient := Gradient.new()
    gradient.set_color(0, Color(0.8, 0.9, 1.0, 1.0))
    gradient.add_point(0.3, Color(0.4, 0.6, 1.0, 0.8))
    gradient.set_color(1, Color(0.2, 0.3, 0.8, 0.0))
    var grad_tex := GradientTexture1D.new()
    grad_tex.gradient = gradient
    mat.color_ramp = grad_tex

    impact.process_material = mat
    impact.draw_pass_1 = _make_billboard_quad(0.15)
gdscript
func _make_billboard_quad(size: float) -> QuadMesh:
    var mesh := QuadMesh.new()
    mesh.size = Vector2(size, size)
    var mat := StandardMaterial3D.new()
    mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
    mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
    mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
    mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
    mat.vertex_color_use_as_albedo = true
    mat.emission_enabled = true
    mat.emission = Color(0.4, 0.6, 1.0)
    mat.emission_energy_multiplier = 3.0
    mat.no_depth_test = true
    mesh.material = mat
    return mesh

This complete projectile uses 3 particle systems (3 draw calls), about 120 total particles during flight, and no CPU-side physics beyond the raycast. The total GPU cost is minimal — you could have 20+ of these on screen simultaneously at 60fps on integrated graphics. The key optimizations baked in: additive blending (no sorting), unshaded materials (no lighting), `no_depth_test` (skip depth buffer reads), small quad sizes (minimal overdraw), short lifetimes (particles die before they pile up), and `fixed_fps = 30` on the trail.

Performance checklist for any new particle effect: 1) Keep total particle count under 500 per system. 2) Use additive blending when possible. 3) Enable LOD scaling from Lesson 3. 4) Profile with the Visual Profiler — target under 2ms GPU time for all particles combined. 5) Sub-emitters: no more than 2 layers deep. 6) Use fixed_fps of 30 for trails and ambient effects. 7) Cull particles beyond camera view distance. Follow these rules and your particles will look great without tanking FPS.

🏆

Course Complete

You've finished all 5 lessons of GPU Particle Systems That Don't Tank FPS. Go build something amazing.