Skip to content

Weapon FX v2: per-weapon shaders that grow with level and transform on evolution — 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.

  • Only two weapon visuals use shaders: the Plasma Nova and the Rift Bolt lance (AIM_LANCE_SHADER, main.gd). Everything else is draw_* primitives or the shared GlowTexture.
  • 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 SwarmRenderer with a single look; per-bullet weapon identity exists only as the QA string ProjPool.context.
  • Level state: PilotArsenal.weapon_levels[wid] counts level-up picks only; crystal-threshold rungs are tracked separately in thresholds_done["wid:i"]. Evolution is weapon.evolved: bool.
  1. Blade Tempest / Scythe Reaping (360°) still draw a ~97° crescent — _SlashNode.span is hardcoded 1.7 rad and the event’s arc is ignored.
  2. Beam Lance (width ×2.5) looks identical — the beam/railgun_lance handler ignores width.
  3. Wildfire Firestorm burn zones are invisible (sim/weapon_wildfire.gd _burn_zones, no renderer).
  4. Thunder Wake DoT puddles are invisible (weapon-local zones, no renderer).
  5. Chalice pools are always amber (ZoneRenderer CHALICE_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.

  • 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 of thresholds_done keys for that wid (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 — adds wid, power, evolved to an fx_event and returns it.
  • Evolution is a separate evolved flag, never folded into power, so the evolved look is a distinct form.
  • stamp also adds rungs: a bitmask of the WeaponThresholds.TABLE[wid] rule indices this pilot has reached (from thresholds_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_events are excluded from state_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.gd is the gate, not this reasoning.
  • Persistent visuals (orbit shards, turrets, zones): main.gd reads WeaponVfx.power() and evolved from each pilot’s arsenal per frame and hands them to the relevant renderer.
  • Bullets: ProjPool gains a render-only vfx: PackedFloat32Array column (power, +1.0 when evolved, so 0..2), rungs: PackedByteArray (crystal rung bitmask) and kind: PackedByteArray (a small weapon-look enum, mapped from the existing ctx string once at add_proj). add_proj gets a trailing optional vfx_v := 0.0; the 5 weapon call sites (blaster, scatter, momentum_core, turret, aim) pass WeaponVfx values; splits inherit the parent’s. state_checksum hashes only projectile pos/vel, so new columns don’t touch it (still gated by the determinism test). Swap-remove must move all three columns.
  • One small class + one shader per weapon look under fx/weapons/ (e.g. fx/weapons/blade_slash.gd
    • blade_slash.gdshader). FxManager stays a dispatcher + pool owner; it does not grow a new inner class per weapon. The existing _PlasmaNovaNode moves there as the first resident.
  • Same technique as the Plasma Nova: one blend_add quad per effect, all instances of a look share ONE ShaderMaterial, per-instance data rides in the draw colour (and, for bullets, MultiMesh INSTANCE_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 reading INSTANCE_CUSTOM = kind / vfx / age) replaces the player-bullet SwarmRenderer instance when v2 is on. Enemy bullets, enemies and gems keep SwarmRenderer untouched.
  • 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 QualityManager L0-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)”), matching plasma_nova. Off = today’s looks exactly. plasma_nova folds 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)”
  1. Beam + Railgun (bug 2: width) — plasma lance vs. hypersonic rail with a sonic ring.
  2. Electricity (pulse) + Thunder Wake + Wildfire (bugs 3-4: invisible zones).
  3. Turret, Scatter, Momentum Core, Rift Bolt bullets (per-kind bolt shapes on PlayerBoltRenderer).
  4. Chalice, Chronowell, Scar Tissue (bug 5: chalice element colour; Scar Tissue’s own discharge).
  5. 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_accents include. 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 + HitFlashTracker white 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.
  6. Evolution moment — a one-off flourish the first time any weapon evolves (reads evolved flipping false→true).
  • Unit (GUT): WeaponVfx.invested/power/stamp (levels + thresholds counted, curve values, stamped keys); each weapon’s emitted event carries wid/power/evolved; ProjPool swap-remove keeps vfx/rungs/kind aligned; rungs bitmask matches thresholds_done for each pilot; slash look honours arc (360° draws full ring); pools bounded, no leaks (same pattern as tests/test_plasma_nova.gd); v2 off = legacy node types used.
  • Determinism: tests/test_determinism_survival.gd passes unchanged — any movement is a bug in this work, not a re-pin.
  • Visual (bh-vfx-iteration tiers): tools/weapon_vfx/compare.gd renders 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 from main.gd. Then tools/weapon_vfx/ingame.gd (real main.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.sh green before merge.
  • 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).