Scenario Godot Vfx
From skills by @scenario-labs · View on GitHub
Use when making or fixing particle effects and game feel in Godot 4.7: GPUParticles3D or GPUParticles2D explosion, fireball projectile with trail, impact, sparks, smoke, shockwave, ribbon trails, sub-emitters, particle collision, color curve does nothing, particles pop out or vanish, effect too heavy on mobile, CPUParticles fallback for Compatibility or web, overdraw, hit-stop, freeze frame, screen shake, juice.
This skill ships inside the skills package. Install the package to get this skill plus everything else in the bundle.
sv install scenario-labs/skillsGodot VFX
Target: Godot 4.7.2.stable, macOS Apple Silicon.
Expert level here means effects built as layers of small systems, every silent particle failure caught by a script before anyone looks, and every claim about look or cost backed by frames captured at exact times and by measured overdraw and GPU time per renderer. The agent builds effects in code, renders them windowed, and looks at contact sheets. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
REQUIRED BACKGROUND: scenario-godot-expert (gd_env, gd_run, gd_review, AgentKit, GD_MAX slots, windowed vs headless).
Stance (the expert delta)
- An effect is layers, and the head of a projectile is not particles. A projectile is head, tail and sparks; the head is a shader mesh with scrolling noise and HDR color above 1 (Le Lu 74XywaLGO5Q 00:00:34, 00:08:05). An explosion is flash, fire, smoke, sparks, debris, shockwave and light, each a small system made by duplicating a working one and changing a few values (Brackeys htRjt505sPg 00:02:30, 00:13:27). A one-particle system is fine for a shockwave ring (00:18:02).
- Particle settings fail silently, so audit them in code. Color and alpha curves do nothing without
vertex_color_use_as_albedo; scale curves do nothing on a particle billboard withoutbillboard_keep_scale; trails needtrail_enabled, a Ribbon or Tube trail mesh anduse_particle_trails(Godotneers cZ5Ang_Ji8E 00:09:44, 00:32:54; Brackeys 00:23:44). A sub-emitter's ownamountis a global cap (Godotneers yKoGuBGZatY 00:44:48) and its AABB is not inherited (cZ5Ang_Ji8E 01:06:19).vfx_audit.gdflags all of these. visibility_aabbis both culling and the collision area (Godotneers cZ5Ang_Ji8E 00:45:28). Measure it rather than guessing: on 4.7.2,capture_aabb()pads particle positions by the draw mesh's longest axis and ignores per-particle scale (measured: Flash quad 2 m gave a 4 m box; a ring scaled to 4.5 m still gave 2 m) [added].fit_aabbcorrects for scale and must run inside the level, because debris without its colliders falls 8 m through the world.- Tier by renderer, never by
amount_ratio.amount_ratiohides particles and saves nothing (GPUParticles3D docs; Bonkahe BUa-mKHEPUM 00:01:18). Loweramountper tier and drop the layers the renderer cannot draw. Compatibility drops decals with no warning, and logs warnings for trails and sub-emitters (measured) [added]. - Particles do not touch physics. Gameplay hits come from a ray swept every physics tick; particle collision needs GPUParticlesCollision nodes, and the step per tick is speed / fixed_fps, with fixed_fps 30 by default (Godotneers cZ5Ang_Ji8E 00:44:23, 00:47:17).
- Juice is code you can test. Hit-stop lowers
Engine.time_scaleand waits on a timer that ignores it, then restores 1.0 (Mostly Mad Jwv9t5zFlqI 00:00:32, 00:01:37). Shake comes from smooth noise with decay (Mostly Mad pG4KGyxQp40 00:00:39); trauma squared and a 3D camera offset are [added]. - Judge frames, not the inspector. Render windowed with
--fixed-fps 60, so frame k is t = k/60 s, at 0.03 to 2.2 s, and look at one contact sheet per renderer. Overdraw comes fromDEBUG_DRAW_OVERDRAWwith a measured per-layer calibration, and GPU time from the Vulkan driver (Metal reports 0 on macOS) [added]. - A trail that follows a node is a particle shader trick. Pin the particle to
EMISSION_TRANSFORM[3].xyzinstart()andprocess(), with no forces (Octodemy iPCzOe-S9EQ 00:05:42).
Establish first
| Input | Changes | Default |
|---|---|---|
| Target renderers | which layers exist (trails, decals, sub-emitters) | Forward+ desktop, Mobile phones, Compatibility web |
| 2D or 3D | node family, collision (LightOccluder2D vs GPUParticlesCollision3D) | 3D |
| Camera distance and screen share | quad sizes, overdraw budget | gameplay camera 4 to 10 m |
| Frame budget for VFX | amounts per tier | under 2.5 ms GPU for six bursts at 720p on the dev Mac [added] |
| Look | unshaded additive vs lit smoke (Le Lu e_6ZA-xa_DQ 00:07:23) | stylized, unshaded fire, alpha-blended smoke |
| Spawning model | free-on-done vs pooled | fx_burst frees itself; done signal for pools |
Workflow
- Project and kit. Clone Base3D,
gd_vfx.install(P)copies tooling toaddons/agentkit/vfx/, and shaders and runtime scripts tores://vfx/(they ship with the game). Readproject.godot. GATE:3d/physics_engine="Jolt Physics"and the intendedrenderer/rendering_methodare present as text;vfx_selftest.gd:compilereports 0 errors over 10 scripts (check_allskipsaddons/agentkit/). - Build per tier in code.
gd_vfx.build(P, "high"|"mobile"|"low")savesexplosion_,impact_,fireball_,shot_andburst_stage_<tier>.tscnfromvfx_recipes.gd. Specs are dictionaries (VB.emitter); curves and ramps become CurveTexture and GradientTexture1D (HDR when a channel exceeds 1); shared textures live inres://vfx/tex/*.tres(Brackeys 00:15:04: never embed). GATE: build ok, no captured errors.
Kit layers: fireball (stretched SphereMesh head with fx_fire_head, noise minus gradient, HDR; inverted-Fresnel fx_soft_core; world-space eroded puffs driven by INSTANCE_CUSTOM.y, onetupthree N9ilhL8JFes 00:05:38; velocity sparks; ribbon via fx_trail_follow and an OmniLight3D, both off on low); explosion (flash, fire, depth-sorted smoke, colliding sparks, Face-Y ring, debris with a 30 Hz smoke sub-emitter on high, light, scorch Decal except low); fx_projectile (ray swept every tick, impact on the hit normal, frees itself after its trail dies).
- Static audit.
gd_vfx.audit(P, scene, tier, level). GATE: 0 errors for the matching tier. Warnings are read and either fixed or written down. Prove the audit catches problems once onbroken.tscn(21 findings, 4 errors). - Fit AABBs in context.
gd_vfx.fit_aabb(P, "res://vfx/burst_stage_high.tscn", node="Explosion", play_method="Explosion:play", write=True, out=effect_scene). GATE:old_encloses_livetrue for every emitter, and the timelineaabb_checkreports nothing outside. - Look at it, per renderer.
gd_vfx.timeline(P, scene, times=[0.03,0.1,0.2,0.35,0.6,1.0,1.5,2.2], renderer=..., trigger="none", extra={"play_method": "Explosion:play", "aabb_check": True}); for the projectile,extra={"hold": "Fireball"}. Open the contact sheet. GATE: no all-black, all-white or uniform flags. By eye: the flash reads first, fire turns to smoke, nothing pops, the ring fades, sparks bounce on the floor, the scorch stays, and the head reads as fire with its tail behind it. - Fallback. For Compatibility, use the low tier (no ribbon, decal, light or sub-emitter), or
gd_vfx.to_cpufor a CPUParticles3D twin. GATE: read thelostlist (collision, sub-emitters, trails, particle shaders, alpha_curve baked into the ramp, align mode) and render the twin ingl_compatibility. - Budget.
gd_vfx.overdraw(...)per tier, andgd_run.profile_scene(stress scene, size=(1280,720), driver="vulkan"). GATE: p99 layers and GPU p95 inside the numbers below, low tier lighter than high. - Behavior. Run
vfx_selftest.gd:lifecycle(node count back to baseline after 5 spawns),projectile(one hit, explosion spawned, everything freed) andjuice(time_scale restored, shake bounded and back to 0). GATE: all ok.
Expert checklist (state these in the plan)
- Complete code in the deliverable: paste the
vfx_recipes.gdspecs andfx_fire_head.gdshader, not prose; readers may lack the kit. - Replay with
restart(), notemitting = true(docs). - Deterministic frames:
use_fixed_seed, thenspeed_scale = 0,restart(),request_particles_process(t). Two seeks to 0.4 s were pixel-identical; without a fixed seed they differed by 0.50 (P13). - Fire into smoke in one system: premultiplied blend (
BLEND_MODE_PREMULT_ALPHA, texture imported with Premultiply Alpha): low alpha adds, high alpha mixes; fade with black and alpha 0 (Brackeys 30:56 to 32:39). Measured over gray: alpha 0 brightened it, alpha 1 replaced it (P13). - Smoke LOD:
fx_smoke_lod.gdshadermaps a black-to-white color ramp to mip 0 to 8 of one noise texture, so the puff diffuses almost free; color moves to a uniform (Le Lu e_6ZA-xa_DQ 16:40 to 24:20). Detail spread fell from 0.10 to 0.0 over life on all three renderers (P13). - Duplicating a system: Brackeys' "Make Unique Recursive, but never the texture" is
duplicate(true)in 4.7.2: embedded curves become unique, external.trestextures stay shared (P13). Keep the mesh shared and the material inmaterial_override(Brackeys 13:59 to 15:04). - Billboard plus direction: Align Y conflicts with billboards; use
transform_alignZ Billboard + Y to Velocity (Brackeys 27:07). - Layered transparents flicker: offset the nodes first, then
sorting_offset(Brackeys 29:57). - TAA ghosting:
DRAW_ORDER_INDEX(the 3D default) is the only order with motion vectors; keep it for opaque particles (docs). - Preprocess runs the shader
fixed_fpstimes per preprocessed second and can stall the GPU; never on one-shots (docs, auditpreprocess_one_shot). - Near-camera quads:
distance_fade_modePixel Alpha fades by distance without a depth read; proximity fade softens intersections.
Numbers
| Value | Relative to | Source |
|---|---|---|
| Head HDR color (2, 1.3, 0.6); sparks (2, 1.3, 0.5); spark lifetime 0.3 s | glow threshold 1.0 | Le Lu 00:08:05, 00:28:15 |
| Head render priority 2 over the core's 1; inverted Fresnel power 4 to 5 | nested transparent meshes | Le Lu 00:18:25 |
| Shockwave: 1 particle, 0.4 to 0.5 s | one-shot burst | Brackeys 00:18:34 |
| Tunnel step = v_max / fixed_fps; fixed_fps 30 by default | thinnest collider | Godotneers 00:47:17, docs |
collision_base_size default 0.01 m | visible particle size | Godotneers 00:57:27 |
Child amount at least parent amount x per-event count (AT_END, AT_COLLISION, AT_START); CONSTANT mode: parent amount x Hz x child lifetime | sub-emitter cap | Godotneers 00:44:48 (per event); CONSTANT derived [added], both in link_sub_emitter |
sub_emitter_frequency is in Hz: dots 1/f m apart at 1 m/s | measured 4 and 10 Hz | live V10 |
| Overdraw per layer: 0.070 linear luma (Forward+, Mobile); 36.6/255 raw (Compatibility, saturates past 6) | DEBUG_DRAW_OVERDRAW PNG | live V11 [added] |
| Explosion p99 layers at the 0.6 s peak: high 4.9 to 5.9, low 2.3 to 3.3 (seeds vary) | 640x360 view at about 5 m | live V11, 3 runs |
| GPU p95, 1280x720, M5 Max via Vulkan, machine shared with other Godot jobs: stage alone 1.0 to 2.3 ms; six looping explosions: high 3.4 to 4.0, mobile tier 2.1 to 2.7, low 1.2 ms | the frame | live V12, 3 runs |
| Particle counts, explosion / fireball: high 208 / 57, mobile 48 / 35, low 34 / 22 | tier scale 1.0 / 0.6 / 0.4 | kit [added] |
| Hit-stop 0.05 time scale for 0.08 s; shake 0.30 x 0.22 m at trauma 1, decay 1.8/s | the camera | [added], typical range in Mostly Mad note |
| Smoke ramp about 0.06 linear for mid gray on Forward+; x3.6 on Compatibility | the same frame | live V5 [added] |
Quality gates
Measurable:
- Audit: 0 errors for the tier. Kit compile: 0 parse errors. Project file has Jolt and the chosen renderer.
- AABB: every emitter's live box inside
visibility_aabbat every captured time. - Lifecycle: node count equals the baseline after N spawns, and
donefires N times with no safety timeouts. - Projectile: one
hitat the expected time (12 m at 14 m/s gives 0.867 s, frame 52) and nothing left in the tree. - Juice:
Engine.time_scaleis 1.0 after single and overlapping stops, with no early restore; shake offset at most the maximum and exactly 0 at the end. - Overdraw p99 and GPU p95 per tier against the table. Compatibility logs show the expected warnings and no unexpected ones.
Visual (contact sheets of at most 1600 px; rubric in references/critique.md): flash then fire then smoke, no popping, readable silhouette at gameplay distance, ring fades, sparks bounce, the trail follows the head, the explosion is oriented to the surface normal, and each tier holds up on its renderer.
Common mistakes
| Mistake | What it looks like | Fix |
|---|---|---|
| Vertex color off | white quads, curves ignored | vertex_color_use_as_albedo |
| Keep scale off on a particle billboard | scale curve ignored | billboard_keep_scale |
Trail without use_particle_trails | chopped flat pieces | set it on the trail mesh material |
| Child amount under the cap | sub-emitter trails thin out | link_sub_emitter computes the cap |
| Default 8 m AABB | effect pops out, collisions stop | fit_aabb in the level |
collision_base_size larger than the spawn height | debris stuck at the origin (measured: 0.5 m of travel instead of 10 to 12 m) | spawn above the collider |
amount_ratio used as the mobile tier | no saving | lower amount |
emitting = true to replay | one-shot sometimes skipped | restart() (docs) |
| Freeing the projectile on hit | world-space trail vanishes at once | stop emitters, wait for the trail to die, then free |
convert_from_particles on view-depth sorting | "Index p_order = 3 is out of bounds", sorting lost | VB.to_cpu maps draw order |
| Same colors on every renderer | smoke near black on Compatibility | retune the low tier on Compatibility |
| Profiling on Metal | gpu_ms 0 | driver="vulkan" |
Handoffs
- Receives hit events (point, normal, pooling) from scenario-godot-gameplay, budgets from scenario-godot-performance-export, glow and environment from scenario-godot-rendering-lighting.
- Delivers
res://vfx/*.tscnper tier,res://vfx/shadersandruntime(fx_burst, fx_projectile, Juice), audit JSON, sheets, overdraw and GPU tables. - Asks scenario-godot-shaders for new surface shaders (dissolve, water, distortion), scenario-godot-audio for impact sounds, and scenario-godot-2d for 2D effect integration.
Godot 4.7 notes
capture_aabb()pads by the draw mesh's longest axis and ignores particle scale (4.7.2, measured).CPUParticles3D.convert_from_particlescopiesdraw_orderas a raw integer, but the enums differ: GPU view depth is 3, CPU view depth is 2. CPUParticles3D has noalpha_curve(4.7.2, measured).- Compatibility logs "Particle trails are only available when using the Forward+ or Mobile renderer" and "does not support particle sub-emitters"; decals disappear with no warning.
- Headless draws nothing, so captures need a windowed run. On macOS, Metal reports GPU time as 0; use
--rendering-driver vulkan. - Curve enforces
min_valueandmax_valuesince 4.4, so widen the range for scale curves above 1. AgX arrived in 4.4. Glow defaults changed in 4.6 (intensity 0.8 to 0.3): retune older effects. - Particle shaders use
start()andprocess(); a Godot 3vertex()writingVELOCITYfails. - The trail sections refresh bug (Octodemy, 4.7 era) and Godotneers' 4.2 trail and SDF bugs: not yet run on 4.7.2.
References
references/procedures.md(P1 to P13, calls, code, live results),expert-notes.md(principles with timestamps),critique.md(rubric),gui-paths.md(editor paths),sources.md(sources and timestamps).scripts/gd_vfx.py: install, build, audit, timeline, overdraw, fit_aabb, to_cpu, maths.scripts/agentkit/vfx/: vfx_build, vfx_recipes, vfx_audit, vfx_capture, vfx_jobs, vfx_selftest;shaders/(includingfx_smoke_lod) andruntime/ship with the game.