A 3D HDR particle-effect design tool for Bevy Hanabi,
built for both humans and AI agents. Author GPU particle effects as plain
.effect RON files, see them live on a real vehicle model in an HDR
bloom-lit environment, and capture deterministic screenshots to compare
against reference photos.
Work is organized into projects. Four ship built in:
- falcon9 — SpaceX Falcon 9 flying an animated launch profile with four layered exhaust effects, tuned against real launch photography.
- apollo-lander — Apollo Lunar Module powered descent on an airless Moon: vacuum descent plume, pulsed RCS quads, and a ballistic regolith dust sheet, tuned against film and simulation references.
- rocket — 2 m model rocket, 6 s boost, motor core/flame plus launch smoke.
- satellite — OreSat in LEO against true-scale Earth (
earth_v5.glb). Stars, Milky Way, city lights, and airglow are Hanabi particles over an Elodin-format cubemap that fades at noon; a 90 s sim orbit is one compressed day/night cycle.
| Piece | Version | Why it matters |
|---|---|---|
| Bevy | 0.19 | HDR camera, bloom, procedural atmosphere, screenshot API |
| bevy_hanabi | 0.19 | GPU particles + first-class .effect RON serialization |
| bevy_egui | 0.40 | Editor panels |
| bevy_panorbit_camera | 0.35 | Viewport orbit controls |
A project is a name that resolves by convention:
assets/scenes/<project>.scene.ron the scene (model, emitters, flight, scenarios)
assets/effects/<project>/*.effect the project's tunable effect files
targets/<project>/ reference images scenarios compare against
shots/<project>/ capture + screenshot output (gitignored)
Shared across projects: assets/models/ (GLBs) and assets/textures/
(generated sprites).
To add a project: drop reference images in targets/<name>/, write
assets/scenes/<name>.scene.ron (copy an existing one), point its emitters at
effect files under assets/effects/<name>/, and wire each scenario's
reference: at the target image. It then appears in the editor's project
picker and works with --project <name>.
# Interactive editor (falcon9 by default)
cargo run
cargo run -- edit apollo-lander
# Deterministic capture, side-by-side vs the scenario's target image.
# Output defaults to shots/<project>/<scenario>.png
cargo run -- capture --scenario lift-off --compare auto
cargo run -- capture --project apollo-lander --scenario plume-side --compare auto
# Regenerate all built-in .effect files + sprite textures from Rust builders
cargo run -- gen-effectsScenarios — falcon9: lift-off, ascent, max-q, mid-flight,
smoke-trail. falcon9-orbit: those plus karman, leo-day-limb,
leo-night-cities, leo-airglow, zoom-2km / zoom-30km / zoom-150km
(pad → LEO on Florida Earth; see docs/falcon9-orbit.md). apollo-lander:
plume-side, plume-side-90, plume-closeup,
plume-top, rcs-far, rcs-close, ground-effect, touchdown. satellite:
day-limb, day-look-down, dusk-terminator, night-airglow,
night-cities, starboard, milky-way (cargo run -- edit satellite;
capture times are lighting: t=0 noon, 22.5 dusk, 45 midnight, 67.5 dawn).
This tool is built so an AI agent can iterate on effects without a GUI:
1. edit assets/effects/<project>/<name>.effect (plain RON — gradients, spawner, sizes)
and/or assets/scenes/<project>.scene.ron (emitters, cameras, flight, scenarios)
2. run cargo run -- capture --project <p> --scenario <s> --compare auto
3. look at shots/<p>/<s>_vs_target.png (capture left, reference right)
4. repeat
Captures are simulation-deterministic: fixed timestep (--fps), seeded CPU +
GPU RNGs (--seed), and the sim clock only starts once every asset is loaded.
(Pixel output can still vary slightly between runs from GPU blend ordering —
about 0.3% mean difference — which does not matter for visual comparison.)
For structural changes (new modifiers, expression graph changes), edit the
builders in src/effects/builders.rs and re-run gen-effects; for tuning
(colors, sizes, rates, lifetimes) edit the RON directly or use the editor UI.
- Top bar: project picker (switches projects live; selecting the open project re-reads its scene from disk), play/pause/restart, sim-time scrub, speed, scenario + camera preset dropdowns, screenshot, gizmo toggle.
- Left panel: emitter list with live intensity multipliers; reference image overlay controls.
- Right panel: inspector for the selected emitter's effect — spawner rate, alpha mode, HDR color gradient editor (unit color x intensity multiplier per key), size gradient.
- Auto-save: inspector edits write back to the
.effectfile ~0.6 s after you stop tweaking (and are flushed on project switch and exit). Reload from file discards unsaved edits. .effectfiles hot-reload when edited externally while the editor runs.- Restart despawns and respawns emitters, clearing world-space smoke.
Hanabi's canonical serialization (EffectAsset::serialize). Everything is
editable; the most rewarding knobs are:
spawner.count— particles/secondrender_modifiers->ColorOverLifetimeModifier.gradient— HDR RGBA keys (values far above 1.0 are intentional: they drive bloom)render_modifiers->SizeOverLifetimeModifier.gradient— metersinit_modifiersliterals live in themodule.expressionslist, referenced by"#N"handles from modifiers (e.g. lifetime/velocity constants)alpha_mode—Addfor flame cores (light),Blendfor smoke/dust (media)
Declares the model, environment (sun/exposure/bloom/atmosphere), emitters,
flight path, camera presets, and scenarios. Emitter fields deliberately mirror
Elodin's thruster KDL schema (position, direction, intensity) so tuned
results port straight back:
EmitterConfig(
name: "merlin_flame",
effect: "effects/falcon9/merlin_flame.effect",
position: (0.0, 0.2, 0.0), // rocket frame: base center origin, +Y up
direction: (0.0, -1.0, 0.0), // exhaust direction
intensity: 1.0, // spawn-rate multiplier
activity: [(0.0, 1.0), ...], // optional keyframes over flight time
attach: "rocket", // "rocket" | "world" | "earth" | "sky"
light: Some(LightConfig( // optional nozzle light (particles are
color: (1.0, 0.95, 0.88), // additive and emit no light themselves);
intensity_lm: 3000000.0, // peak lumens at intensity x activity = 1
range: 40.0, // meters
offset_m: 0.8, // down the exhaust axis, below the exit
shadows: true, // spot_angle_deg: Some(..) for a spotlight
)),
)Conventions: effects emit along local -Y; the emitter entity rotates -Y
onto direction. The GLB is auto-normalized (height = target_height, base
at origin, +Y up).
Scene flags for non-Earth, non-rocket, and LEO scenes:
environment.atmosphere: false— airless body: no sky scattering, black clear color (apollo-lander).flight.align_to_velocity: false— keep the vehicle upright instead of pitching +Y along the path tangent (landers, satellite).environment.ground_radius: 0— skip the pad disc (satellite).environment.camera_near/camera_far— LEO needsfar ≥ 2e7or Earth clips. Defaults 0.1 / 1000 leave falcon9/apollo unchanged.environment.earth— second GLB at true radius, not height-normalized.attach: "earth"/"sky"— parent emitters toEarthRootorSkyRoot(inertial frame that rotates once perorbit_period_s).environment.orbit_start_s— delay that spin so a pad/ascent keeps the authored sun (falcon9-orbitstarts the LEO lighting chapter at t=140).
| Project | Effect | Space | Blend | Role |
|---|---|---|---|---|
| falcon9 | merlin_core |
Local | Add | Blinding white-hot core, HDR ~30x, stretched along velocity |
| falcon9 | merlin_flame |
Local | Blend | Orange expanding flame column (diverging-cone velocity) |
| falcon9 | exhaust_smoke |
Global | Blend | Persistent world-space trail; 30-110 s lifetimes, grows to ~500 m |
| falcon9 | pad_smoke |
Global | Blend | Lift-off ground clouds; radial pad blast + buoyancy, world-attached |
| apollo-lander | descent_plume |
Local | Add | Solid cool-white vacuum column filling the bell mouth ("First Man" look); high-rate short streaks fuse into a tube |
| apollo-lander | descent_glow |
Local | Add | Camera-facing halo wrapping the full column; stacked on the same nozzle, it is what keeps the plume volumetric from every azimuth (stretched streaks foreshorten edge-on). Ports to Elodin as an effect layer child of the same thruster node |
| apollo-lander | rcs_puff |
Local | Add | Sharp white-blue attitude jets, pulsed via emitter activity |
| apollo-lander | ground_dust |
Global | Blend | Ballistic regolith streaks: flat radial sheet, lunar gravity, no drag |
| satellite | stars_dim / stars_bright / milky_way |
Local | Add | Once-burst star field on a 15,000 km sphere; intensity from orbit phase (0 at noon) |
| satellite | city_lights |
Local | Add | Black Marble on an R+8 km Earth shell; sun_dir + intensity kill the day side |
| satellite | airglow_green / airglow_red |
Local | Add | Night limb shells at ~95 km and ~150 km; intensity after terminator |
Techniques worth knowing (see src/effects/builders.rs):
- HDR gradients + bloom make flame read as blinding (firework-example recipe).
- Diverging cone: velocity radiates from a virtual center behind the nozzle -> plume expands downstream like a real underexpanded jet.
- Per-particle modulation: random brightness/alpha packed into
Attribute::COLOR, multiplied against the gradient withColorBlendMode::Modulate— turns uniform fog into billows (or dust into grainy spray). - Baked sprite shading: the smoke sprite carries top-lit lobes + creases
in RGB, sampled with
ImageSampleMapping::Modulate. - Wide lifetime spread desynchronizes size-over-life across a trail cross-section, breaking up the "uniform tube" look.
- Vacuum look: no drag, real gravity only,
AlongVelocityorientation on thin stretched sprites -> streaks instead of billows (apolloground_dust).
Elodin (Bevy/hanabi 0.19) loads these exact .effect files:
thruster effect="effects/<project>/<name>.effect" in a KDL schematic renders
them with intensity driven by live simulation telemetry, and the schematic
environment node + viewport hdr/ev100/bloom reproduce the lighting.
The apollo-lander and falcon9 examples (elodin/examples/apollo-lander,
elodin/examples/falcon9) run on the files from this repo. Porting checklist:
- Author here at the sim's rendered scale. The scene's
target_heightmust match the metric size Elodin renders the GLB at (apollo: 5.0 m, falcon9: 70.0 m). Effects are in meters; a scale mismatch reads immediately. - Never use
SimulationSpace::Global. Static emitters (ground dust, pad smoke) are plainLocal; moving-emitter trails use the anchored-trail contract —Localplusspawn_origin/spawn_axisvec3 properties (see AGENTS.md) — which both runtimes re-home onto a world-fixed anchor.exhaust_smokeis the reference. - Sim publishes viz channels (0..1 per effect: thrust fraction,
per-nozzle RCS, ground-dust/trail level). Normalization lives sim-side;
KDL
intensitystays a bare component reference. Effects that declare theintensityproperty additionally get the live signal as a shader uniform (throttle-driven plume length/brightness). - Copy files into the sim's asset tree:
.effectfiles underassets/effects/<project>/, plusassets/textures/{soft_circle,smoke_puff}.png. They are ingested into the Elodin DB and served like GLBs (db-centric). - Bind in KDL:
thruster effect="…"per nozzle (keep the sim's real nozzle geometry; omitemission_rateso the authored rate is used), anenvironment { sun / ambient / sky }block — plus anatmospherechild for Earth daylight scenes (environment.atmosphere: truehere) — and viewporthdr=#true ev100=<scene exposure> { bloom … }. Values transcribe 1:1 from the scene RON (sun azimuth/elevation/illuminance,exposure_ev100,bloom_intensity); for ECEF scenes convert the sun angles from the pad-local Y-up frame into the editor's world frame (the falcon9 KDL documents its conversion). - Verify:
ELODIN_SCREENSHOT=shot.png elodin editor <sim>at a matched moment vs the captures inshots/<project>/(the falcon9 example shipsscripts/capture.sh+scripts/compare_shots.pyfor this loop).
Design + implementation record: docs/design-thruster-effects-port.md in the
workspace root; Elodin-side internals: docs/crash-course-thruster-particles.md.
src/
├── main.rs CLI (edit | capture | gen-effects), project selection
├── app.rs shared app assembly, SimClock
├── project.rs Project resource, discovery, runtime project switching
├── render.rs HDR camera, atmosphere, sun, ground, Earth, SkyRoot
├── orbit.rs SkyRoot / Earth spin from orbit phase
├── earth_env.rs camera-driven Earth: density, disc, stars, EV
├── rocket.rs GLB load + normalization (RocketBounds)
├── flight.rs flight-path -> rocket transform
├── scene.rs scene RON schema + sampling helpers
├── capture.rs deterministic capture state machine + compare composite
├── ui.rs egui editor panels, project picker, auto-save
└── effects/
├── mod.rs emitter spawning, intensity, hot reload, gizmos
├── sphere_map.rs fragment equirect sample (city lights)
└── builders.rs Rust builders for the built-in effects + sprites
assets/
├── effects/<project>/ *.effect (Hanabi RON) — the tunable artifacts
├── scenes/ <project>.scene.ron (+ debug variants)
├── textures/ sprites + textures/earth/ (8K night for Hanabi)
├── skyboxes/ milky_way.cubemap.ktx2
└── models/ vehicle GLBs + earth_v5.glb
targets/<project>/ reference images
shots/<project>/ capture output (gitignored)


