Weapon FX v2: per-weapon shaders that grow with level and transform on evolution — design
Weapon FX v2 — design
Section titled “Weapon FX v2 — design”Date: 2026-09-28 · Status: approved in conversation (Chris), awaiting spec review
Release: phased. Foundation + bug fixes + batch 1 target 1.0.1; later batches follow in later
updates (docs/RELEASE-1.0.1.md §3).
Template: the Plasma Nova (0c59f86: fx/plasma_nova.gdshader, FxManager._PlasmaNovaNode,
tools/nova_vfx/). Workflow: skill bh-vfx-iteration (isolated grid → full roster → in-game → device).
Every player weapon gets a hand-built, shader-driven effect in the Plasma Nova’s style, and that effect grows as the weapon is upgraded and transforms (a new form, not just brighter) when it evolves. Chris picked “grow + transform” over discrete tiers or a purely continuous curve.
Current state (inventory 2026-09-28)
Section titled “Current state (inventory 2026-09-28)”- Only two weapon visuals use shaders: the Plasma Nova and the Rift Bolt lance (
AIM_LANCE_SHADER,main.gd). Everything else isdraw_*primitives or the sharedGlowTexture. - No weapon fx_event carries level or evolution info. Only Rift Bolt scales with upgrades
(
WeaponAim.visual_tier()). - All projectile weapons (Blaster, Scatter, Turret, Momentum Core, Rift Bolt, splits) share one
SwarmRendererwith a single look; per-bullet weapon identity exists only as the QA stringProjPool.context. - Level state:
PilotArsenal.weapon_levels[wid]counts level-up picks only; crystal-threshold rungs are tracked separately inthresholds_done["wid:i"]. Evolution isweapon.evolved: bool.
Existing visual bugs fixed by this work
Section titled “Existing visual bugs fixed by this work”- Blade Tempest / Scythe Reaping (360°) still draw a ~97° crescent —
_SlashNode.spanis hardcoded 1.7 rad and the event’sarcis ignored. - Beam Lance (width ×2.5) looks identical — the
beam/railgun_lancehandler ignoreswidth. - Wildfire Firestorm burn zones are invisible (
sim/weapon_wildfire.gd_burn_zones, no renderer). - Thunder Wake DoT puddles are invisible (weapon-local zones, no renderer).
- Chalice pools are always amber (
ZoneRendererCHALICE_COL), ignoring element.
1-2 land with batch 1/2 (the weapons that own them); 3-5 land with the batch that owns the weapon.
Scar Tissue’s discharge reuses the nova event and so currently shows the Plasma Nova fireball;
v2 keeps that (scaled by Scar Tissue’s own power) until its batch gives it its own look.
Architecture
Section titled “Architecture”Weapon power and evolution (the data)
Section titled “Weapon power and evolution (the data)”- New pure helper
sim/weapon_vfx.gd(class_name WeaponVfx extends RefCounted, no Node APIs):static func invested(arsenal: PilotArsenal, wid: String) -> int—weapon_levels[wid]+ the number ofthresholds_donekeys for thatwid(crystal rungs count as upgrades visually; today a crystal-built weapon would read as unlevelled).static func power(arsenal, wid) -> float—1.0 - pow(POWER_BASE, invested),POWER_BASE := 0.72(1 upgrade ≈ 0.28, 3 ≈ 0.63, 5 ≈ 0.81, 8 ≈ 0.93). Early upgrades read clearly, later ones taper. Tunable const.static func stamp(ev: Dictionary, arsenal, wid) -> Dictionary— addswid,power,evolvedto an fx_event and returns it.
- Evolution is a separate
evolvedflag, never folded intopower, so the evolved look is a distinct form. stampalso addsrungs: a bitmask of theWeaponThresholds.TABLE[wid]rule indices this pilot has reached (fromthresholds_done). See “Crystal accents” below.- Event-driven effects: each weapon stamps its existing fx_event emits via
WeaponVfx.stamp, using the pilot it already receives. Per-pilot for free (co-op pilots at different levels look different). - Determinism:
fx_events/render_fx_eventsare excluded fromstate_checksum(sim/sim.gd~555-567); stamping only reads arsenal state and draws no RNG. The pinned determinism baselines must not move —tests/test_determinism_survival.gdis the gate, not this reasoning. - Persistent visuals (orbit shards, turrets, zones):
main.gdreadsWeaponVfx.power()andevolvedfrom each pilot’s arsenal per frame and hands them to the relevant renderer. - Bullets:
ProjPoolgains a render-onlyvfx: PackedFloat32Arraycolumn (power, +1.0 when evolved, so 0..2),rungs: PackedByteArray(crystal rung bitmask) andkind: PackedByteArray(a small weapon-look enum, mapped from the existingctxstring once atadd_proj).add_projgets a trailing optionalvfx_v := 0.0; the 5 weapon call sites (blaster, scatter, momentum_core, turret, aim) passWeaponVfxvalues; splits inherit the parent’s.state_checksumhashes only projectilepos/vel, so new columns don’t touch it (still gated by the determinism test). Swap-remove must move all three columns.
Rendering (the look)
Section titled “Rendering (the look)”- One small class + one shader per weapon look under
fx/weapons/(e.g.fx/weapons/blade_slash.gdblade_slash.gdshader).FxManagerstays a dispatcher + pool owner; it does not grow a new inner class per weapon. The existing_PlasmaNovaNodemoves there as the first resident.
- Same technique as the Plasma Nova: one
blend_addquad per effect, all instances of a look share ONEShaderMaterial, per-instance data rides in the draw colour (and, for bullets, MultiMeshINSTANCE_CUSTOM), hot parts go > 1.0 so the existing HDR bloom (glow_hdr_threshold 1.0) haloes them — no new render passes. - Player bullets: a new
PlayerBoltRenderer(MultiMesh, one draw call, shader readingINSTANCE_CUSTOM= kind / vfx / age) replaces the player-bulletSwarmRendererinstance when v2 is on. Enemy bullets, enemies and gems keepSwarmRendereruntouched. - Grow budget: power scales size/density/brightness by at most ~1.5× the power-0 look, and every look keeps a fast-clearing interior, so a maxed or evolved weapon never hides enemy bullets.
- Element tint stays in every look (as the Plasma Nova blends it into its rim).
- Quality fallback: v2 looks run at
QualityManagerL0-L2; at L3+ each weapon falls back to its current primitive (the legacy path already exists and stays). Weapon feedback stays essential (never dropped), only simplified. - A/B:
VisualLook.weapon_fx_v2(default true once a batch is accepted) with a Debug Settings row (“Weapon FX v2 (A/B)”), matchingplasma_nova. Off = today’s looks exactly.plasma_novafolds into it once Nova’s grow/evolve is in.
Crystal accents (Chris, 2026-09-28: “the look should take into consideration the crystals used to upgrade it”)
Section titled “Crystal accents (Chris, 2026-09-28: “the look should take into consideration the crystals used to upgrade it”)”Weapons are upgraded by crystals via WeaponThresholds rungs (e.g. Blade: blood 5 → fire 10 → void 18
evolve). A weapon’s look shows which crystals it has absorbed, not only how much power it has:
- Three inputs per look:
power(how big/hot — level-up picks + rungs),rungs(which crystal elements have been absorbed),evolved(the transformed form). - One motif per crystal element, shared across all weapons in a shader include
fx/weapons/element_accents.gdshaderinc(+ a GDScript twin for non-shader parts like sparks): fire = embers/heat flicker, cold = frost facets/ice shards, lightning = crackle filaments, blood = dark-red droplets/smear, void = dark core with an inverted bright edge, light = prismatic flare, poison = bubbling haze, kinetic = shock rings, aether = shimmer. Each look decides where a motif appears (Blade’s smear edge, Orbit’s shard facets, a bolt’s tail) but the motif itself is shared, so every weapon’s “fire” reads as the same fire. - Each reached rung layers its element’s motif on at modest strength; repeat elements (Orbit’s cold twice) strengthen the motif rather than adding a new one. The weapon’s own base element tint stays dominant.
- The evolved form is coloured by its evolve rung’s element (Blade → void Tempest, Nova → light Supernova, Orbit → void Event Horizon, which already fits the singularity idea).
- Level-up picks without a crystal carry no element, so they only feed
power. - Motifs must also work on enemy shapes (Chris, 2026-09-28): each motif is written against a generic shape mask + element + intensity, not assumptions about a weapon effect’s geometry, so the enemy element reactions phase (below) reuses the same include and “fire” on your blade matches “fire” on the enemy it hit.
- The rung list is data-driven from
WeaponThresholds.TABLE, so a future design that lets players choose crystals (any crystal into any weapon, branching rungs) changes only the input, not the shaders.
Batch 1 (1.0.1) — the weapons every player sees
Section titled “Batch 1 (1.0.1) — the weapons every player sees”Blaster (starter), Orbit Shards and Nova are the only free weapons; Blade/Scythe carry bug 1.
| Weapon | Base look (power 0) | Grows with power | Evolved (transform) |
|---|---|---|---|
| Blaster (+ default for all bullet weapons) | Tapered plasma bolt: white-hot core, element sheath, short velocity-aligned tail. Small muzzle flash (render the existing audio-only blaster_fire event). |
Longer tail, hotter core, bigger muzzle flash | Auto-Cannon: twin-core tracer with a spiralling sheath, heavier muzzle bloom, small impact spark splash |
| Orbit Shards | Faceted rotating crystals with a travelling glint, each trailing a motion-blur arc along the orbit | Longer trails (tied to spin), stronger facet glow, faint ring linking the shards | Event Horizon: each shard becomes a tiny singularity (dark core, bright accretion rim) linked by a slowly swirling lensing ring |
| Blade | Shader crescent with swept motion-blur smear: white-hot leading edge, element-coloured decaying trail, a few sparks. Uses the event’s real arc (bug 1). |
Thicker, longer-lived smear; second afterimage crescent; more sparks | Tempest: full 360° whirlwind of spinning blade echoes and wind streaks |
| Scythe | Deliberately distinct from Blade: dark violet crescent body, pale ghost-green edge; faint wisps pulled from hit enemies toward the ship | More wisps, denser/darker crescent | Reaping: 360° eclipse ring (black body, corona edge) with an inward-spiralling wisp stream |
| Nova (look done) | Unchanged Plasma Nova | Denser fireball, more embers, longer flame tongues | Supernova: blue-white core, second delayed shock ring, briefly lingering corona |
Crystal accents per batch-1 weapon (from WeaponThresholds.TABLE today): Blaster kinetic, kinetic,
void-evolve · Orbit cold, lightning, cold, void-evolve · Blade blood, fire, void-evolve · Scythe blood,
void, blood-evolve · Nova fire, fire, light-evolve. Batch 1 builds the motifs these need (kinetic, cold,
lightning, blood, fire, void, light); the rest arrive with the batches that use them.
Scythe wisps need hit positions; the slash event already carries pos/dir/range/arc, and wisps are
spawned render-side at random points inside the swept sector (no new sim data), not at real hit
enemies. Acceptable for batch 1; revisit only if Chris wants wisps to originate from real hits.
Later batches (outline, each gets its own short design pass with Chris)
Section titled “Later batches (outline, each gets its own short design pass with Chris)”- Beam + Railgun (bug 2: width) — plasma lance vs. hypersonic rail with a sonic ring.
- Electricity (pulse) + Thunder Wake + Wildfire (bugs 3-4: invisible zones).
- Turret, Scatter, Momentum Core, Rift Bolt bullets (per-kind bolt shapes on
PlayerBoltRenderer). - Chalice, Chronowell, Scar Tissue (bug 5: chalice element colour; Scar Tissue’s own discharge).
- Enemy element reactions (after batch 1, own design pass): enemies show the motif of the
element currently on them (fire embers, frost rime, lightning crackle, void bite, …), strength
scaled by stack count, using the shared
element_accentsinclude. Optionally the death dissolve takes that element too (burning enemies crumble to embers, frozen ones shatter). Enemies draw in one batched MultiMesh today (aura tint +HitFlashTrackerwhite flash only), so this lives in that renderer’s shader, driven by per-instance element + intensity — the most perf-sensitive render path in the game, so it gets its own perf soak. - Evolution moment — a one-off flourish the first time any weapon evolves (reads
evolvedflipping false→true).
Verification
Section titled “Verification”- Unit (GUT):
WeaponVfx.invested/power/stamp(levels + thresholds counted, curve values, stamped keys); each weapon’s emitted event carrieswid/power/evolved;ProjPoolswap-remove keepsvfx/rungs/kindaligned;rungsbitmask matchesthresholds_donefor each pilot;slashlook honoursarc(360° draws full ring); pools bounded, no leaks (same pattern astests/test_plasma_nova.gd); v2 off = legacy node types used. - Determinism:
tests/test_determinism_survival.gdpasses unchanged — any movement is a bug in this work, not a re-pin. - Visual (bh-vfx-iteration tiers):
tools/weapon_vfx/compare.gdrenders each weapon as a grid — rows power 0 / each crystal rung reached in order / evolved, columns = frames through the effect’s life, v1 row for reference, real HDR/glow env copied frommain.gd. Thentools/weapon_vfx/ingame.gd(realmain.tscn, busy horde, forced weapon + power). Chris eyeballs each weapon’s grid before merge; device check via the Dev app after the batch. - Perf: soak on the existing perf harness with all batch-1 weapons evolved at quality L0; no regression beyond noise vs. v1 on the same seed. Check the fallback at L3.
scripts/gate.shgreen before merge.
Out of scope
Section titled “Out of scope”- Sound changes. Sim/balance changes (visual only; no mechanic changes, including no change to Scar Tissue’s event kind in batch 1). Enemy/boss FX. The evolution moment (batch 6).