Skip to content

Six abilities to one hundred, plus a program-cache bug in the shader patcher - #3

Open
majidmanzarpour wants to merge 9 commits into
achrefelouafi:mainfrom
majidmanzarpour:main
Open

Six abilities to one hundred, plus a program-cache bug in the shader patcher#3
majidmanzarpour wants to merge 9 commits into
achrefelouafi:mainfrom
majidmanzarpour:main

Conversation

@majidmanzarpour

Copy link
Copy Markdown

Your sandbox had six abilities. This takes it to one hundred, across fifteen schools, without changing the rules it was built under.

I started from your code because the discipline in it is unusual: no dimensions on the CPU, nothing is a texture, one technique per ability. Everything here obeys those rules, and there is now a headless test that enforces the first one mechanically instead of trusting it.

This is a big PR, and you should feel free to take part of it

318 files. I do not expect you to merge it as one lump, so the commits are ordered so each layer stands on its own and you can stop anywhere:

# Commit What it is Useful alone?
1 feat(core) Abilities become data: one registry, per-ability settings modules, schema-driven editor, spellbook UI, and the test harness Yes
2 feat(vfx) 26 shared VFX modules, and the distortion pass switched on Yes, depends on 1
3 feat(abilities) The 94 new abilities Depends on 1 and 2
4 fix Three rendering bugs, one of them pre-existing in your code Yes, standalone
5 docs README brought up to date, plus the design specs Yes

If only commit 4 is interesting to you, say so and I will open it as its own small PR against a clean branch and close this one. Genuinely no hard feelings.

The bug that was already there

Three abilities were blacking out the entire screen. Chasing it turned up something systemic in the original code:

patchOnBeforeCompile wraps every shader injection in an identically worded closure. three.js keys its program cache on onBeforeCompile.toString() by default, and the injected code lives in the closed-over function, so it never appears in that string. The result is that every patched material in the project shares one cache key, and each one after the first silently renders with a different material's compiled program: its own uniforms absent, the ones present holding another shader's values. Which ability broke depended on which was cast first, which is why it looked intermittent.

Fixed by deriving customProgramCacheKey from the injection's own source, composed down the chain. Materials that genuinely share an injection still share a program, so the cache keeps working.

The other two were pow() on a negative base. One was cos() of about 10,000 radians losing its mantissa to argument reduction and returning just outside [-1, 1]. The other was pow(sin(x), e), where float32 sin() returns roughly -1e-7 at its zeroes. Two of those sites are in SnareMaterial and predate this work. One NaN fragment is not one bad pixel: the bloom blur spreads it across its whole kernel and tone mapping NaN gives black, so a few bad fragments on one object turn the whole frame black. check.mjs now fails the build on pow() over a raw trig base, and I confirmed that check catches the bug by reintroducing it.

Also worth knowing: post.distortion shipped at 0.045, which was correct back when nothing wrote to that buffer. Now that emitters exist, every refraction effect was running at 4.5% of its authored strength.

Two things you flagged in the README, now closed

  • "The distortion pass runs with nothing writing to it." It has five emitter types now, and Layers.js counts its writers so the clear, the draw and the resample are all skipped on frames where nothing is refracting. Turning it on would have been a poor trade if it then ran unconditionally.
  • Adding an ability was four edits across four files. It is one registry descriptor now. ELEMENTS and ELEMENT_META still exist as derived views, which is why AimController, HUD and App needed no changes at all.

Verifying it

npm install
npm run check     # 100/100 abilities, 116 tech-library cases
npm run build
npm run dev       # Tab opens the spellbook

npm run check runs headless, with no WebGL. For each ability it cross-checks every settings.<id>.<key> reference against that ability's block, simulates the whole cast, and then mutates sliders on a zero-length frame and fails if nothing observable changes. That last one is your edit-while-paused invariant, turned into a test. It does not compile GLSL, so shader errors still need a browser; I verified all 100 cast in Chrome with zero console errors and no leaked scene state.

The six original abilities are untouched apart from the settings-module move, which I verified is byte-identical in value against HEAD.

A few of the new ones

  • Dawnbreak borrows the scene's directional light and swings it, so every object in the world throws a real sweeping shadow. The ability itself draws almost nothing.
  • Stasis Field draws nothing at all. It clamps other systems' clocks inside a radius, so a Storm Lance already in flight stops guttering.
  • Thunderclap is mostly the silence between the flash and the pressure front arriving.
  • Silence renders a hole in the frame. Not black, absent.
  • Mirage is the caster's own skinned rig, cloned and posed from a recorded track, drawn with nothing but refraction.

docs/ROSTER.md and docs/ROSTER-II.md list all 94 with the single technique each one exists to show off, since an ability that is another slot with a different palette should not exist.

Thanks for publishing the original. The bolt's piecewise-linear noise and the beam's inverted core weighting are both lovely, and most of the new work is built on ideas taken straight from them.

🤖 Generated with Claude Code

majidmanzarpour and others added 9 commits August 12, 2026 00:20
…s gate

The sandbox enumerated its six abilities in eight places. That does not scale to
a hundred, so abilities become data and every consumer derives from it.

- registry.js is the single declaration site. ELEMENTS/ELEMENT_META are now
  derived views over it, so AimController, HUD and App were untouched.
- Ability classes load lazily; settings modules stay eager, because the editor,
  the presets and DEFAULT_SETTINGS all need the whole tree at boot.
- Each ability owns src/config/abilities/<id>.js: its settings block and its
  editor schema. The six that shipped were moved verbatim and machine-verified
  byte-identical against HEAD.
- The editor is schema-driven and builds an ability's controls on first open.
  Eager construction at 100 abilities cost 597ms and 107k DOM nodes at boot;
  it is now 7.7ms and 1.4k, with search indexing the schemas rather than the
  controllers so unbuilt controls are still findable.
- Eight rebindable loadout slots over a Tab-key spellbook, persisted to
  localStorage. Fifty abilities do not fit on a keyboard.
- scripts/check.mjs is the gate: it statically cross-checks every settings
  reference against its block, simulates every cast headlessly, and runs a
  pause test that mutates sliders on a zero-length frame and fails if nothing
  observable changes. That invariant is the whole character of this project,
  so it is now enforced rather than trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… pass on

An ability should be beats and configuration, not a new renderer. These
generalise the techniques the original six invented — the bolt's ribbon strip,
the beam's three-layer tube, the crystal field, the raymarched wake — and add
the ones the new schools need.

GrowthField, FilamentPaths, VolumeHull, GroundField, Tube, Shell, Projectile,
Swarm, ArcNetwork, ShatterField, Distortion, Portal, LiquidSurface, Curtain,
Caustics, LightShaft, LensFlare, Mirror, HardSurface, BrushStroke, InkDiffusion,
FoldMesh, Dissolve, TimeControl, Colony, SceneHooks.

Two are worth calling out:

- Distortion. The README listed "the distortion pass runs with nothing writing
  to it" as a known rough edge, costing a half-res clear per frame for nothing.
  It now has five emitter types, and Layers.js counts its writers so the pass
  skips itself entirely when none are visible — turning it on would be a poor
  trade if it then ran unconditionally.

- SceneHooks. The first thing here that reaches outside its own group: the key
  light, the grade, the ground material, gravity. Four casts can be live at
  once and any of them can be destroyed mid-effect, so it is built on a
  borrow/restore discipline with tokens rather than direct mutation, and the
  world is restored exactly.

Caustics is not the usual difference-of-worley recipe. That gives blobs with
soft shoulders because worley is smooth except at its cell walls, and no
exponent fixes it; this draws the fold of the refraction map, so the singular
set is a curve and the filaments come out thin without being asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Frost, Flame, Storm, Stone, Verdant, Void, Arcane, Blood, Aether, plus six new
schools chosen because each forces a rendering technique the sandbox did not
have: Tide (caustics), Forge (hard surface — involute gear teeth, brushed
anisotropic specular, a real blackbody cooling locus), Lumen (light that
behaves like light), Ink (the anti-glow school — matte, no bloom anywhere),
Chrono (time as a driver), Hive (emergence).

Every one has a named trick and none is a recolour of another slot. A few that
show the range:

- Dawnbreak borrows the scene's directional light and swings it, so every
  object in the world throws a real sweeping shadow. The ability draws almost
  nothing; the world doing the work is the effect.
- Stasis Field draws nothing at all. It clamps other systems' clocks inside a
  radius, so a Storm Lance already in flight stops guttering.
- Thunderclap's entire effect is the silence between the flash and the
  pressure front.
- Silence renders a hole in the frame: not black, absent.
- Spellbreak is the one ability aware of the others.
- Mirage is the caster's own skinned rig, cloned and posed from a recorded
  track, drawn with nothing but refraction — you see it because the world
  bends into a human shape, and you lose it the moment it stops moving.

All 100 pass the headless gate, including the paused-slider test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three presented identically — some abilities black out the screen, but only
sometimes — and had nothing in common.

1. Program-cache collisions. patchOnBeforeCompile wraps every injection in an
   identically-worded closure, and three keys its program cache on
   onBeforeCompile.toString() by default. The injected code lives in the
   closed-over function, which never appears in that string, so ~60 patched
   materials shared one cache key and every one after the first silently
   rendered with another material's compiled program — its uniforms absent,
   the ones present holding someone else's values. Which ability broke
   depended on cast order. Fixed by deriving customProgramCacheKey from the
   injection's own source, composed down the chain; materials that genuinely
   share an injection still share a program.

2. pow() of a negative base. GroundField's SCOUR phase includes uTime, which
   grows without bound, so cos() was being asked for the cosine of ~10^4
   radians; float32 loses its mantissa to argument reduction and returns
   fractionally outside [-1,1], and pow() of the resulting tiny negative is
   NaN at every exponent — which is why zeroing the sharpness slider never
   masked it. Folded the phase with fract() (exact: the cosine has period 1
   there) and clamped the base.

3. The same class in six more places, pow(sin(x), e), where float32 sin()
   returns about -1e-7 at its zeroes. Two of them were in SnareMaterial and
   predate this expansion.

One NaN fragment is not one bad pixel: the next bloom blur spreads it over its
whole kernel and tone-mapping NaN gives black, so eighty-odd bad fragments on
one spear turned the entire frame black. check.mjs now fails on pow() over a
raw trig base, negative-controlled by reintroducing the bug.

Also: post.distortion shipped at 0.045, a trim from when nothing wrote to the
refraction buffer. Six abilities author their strength "in screen widths at
post.distortion = 1", so every refraction effect was running at 4.5% of what it
asked for. Set to 1.0, and the editor slider widened past its own default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…specs

The README described six abilities, 938 sliders, and a five-step "adding an
ability" list whose every step named a file that no longer holds a roster. All
four are now true again, including the slider count (~16,000) and the fact that
registering an ability is one descriptor rather than four edits.

docs/ROSTER.md and docs/ROSTER-II.md are the briefs the ninety-four were built
from — each entry names the single technique that makes it read, because an
ability that is another slot with a different palette should not exist.
docs/EXPANSION.md is the engineering contract: the invariants, the registry,
the schema format, and what "done" means. docs/VFX_API.md and VFX_API_II.md are
the tech library's signatures, verified against source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aims

- The repo is MIT, © mohamedachrefelouafi, and the README did not say so — it
  said "code is provided as-is", which is both wrong and uncredited. It now
  names the licence, links LICENSE, and says plainly whose work this is built
  on. The heading loses its British spelling because it is now naming the MIT
  License, which is a proper noun.
- The editor section still listed the old flat folder list. It is fifteen school
  folders now, built on first open, with a search box that indexes the schemas
  so it can find controls in folders that have never been opened.
- The performance notes did not mention that abilities load lazily, which
  changes when shaders compile: compileAsync at boot only sees what is in the
  scene at boot, so selection warms an ability rather than the cast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant