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.
Contents
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).
# 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 = quadSeveral 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:
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 texThis 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.
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.
// 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:
# 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.
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.
# 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.REDWith 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:
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.
# 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 matWith 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.
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:
# 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 burstThe 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).
# 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 shapeSDF 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.
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.
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:
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)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 meshThis 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.