diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 7d88eb8..0000000 --- a/.claude/launch.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "frost-sandbox", - "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], - "port": 5173, - "url": "http://127.0.0.1:5173" - }, - { - "name": "frost-verify", - "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev", "--", "--port", "5321", "--strictPort"], - "port": 5321, - "url": "http://127.0.0.1:5321" - } - ] -} diff --git a/.gitignore b/.gitignore index 7eea89b..d1a6632 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ dist # Local VFX capture scratch (dev tooling) .captures/ + +# Agent debug tooling and dev logs (not part of the project) +.scratch/ diff --git a/README.md b/README.md index a946052..bebcab2 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,16 @@ A skillshot VFX sandbox built with **Three.js**, **Vite** and hand-written **GLSL**. -Five abilities and two ways to aim them. Four are **line casts**: press the key to arm, a -League-of-Legends style arrow appears on the ground and swings with the mouse, click to fire. The -fifth is a **far cast**: the arrow is replaced by a circle with a deliberately thick boundary that -follows the cursor and answers the only question a ground-targeted AoE has to answer before you -commit — how much space is this going to take. +**One hundred abilities** across fifteen schools, and two ways to aim them. Fifty are **line +casts**: press the key to arm, a League-of-Legends style arrow appears on the ground and swings +with the mouse, click to fire. The other fifty are **far casts**: the arrow is replaced by a circle +with a deliberately thick boundary that follows the cursor and answers the only question a +ground-targeted AoE has to answer before you commit — how much space is this going to take. + +Eight of the hundred are on the bar at a time. **Tab** opens the spellbook; drag a card onto a slot +to bind it. The six below are the ones the sandbox shipped with and the ones the rest were built +to the standard of — see `docs/ROSTER.md` and `docs/ROSTER-II.md` for the other ninety-four, each +with the one technique that makes it read. **Q — Frost Lance.** A fracture front races out along the line while a field of ice crystals tears up out of the floor behind it — small and dense at your feet, opening into a wall of blades @@ -42,8 +47,8 @@ whole cage is that same ribbon strip threaded along four different parametric pa targeting circle, the rime, the burns and the molten cracks are signed-distance and noise shaders, and the mist, sparks, chips and glitter are GPU particles. -**Every parameter is a live slider** — 938 of them — and they stay live while the simulation is -paused. That is the point of the project: freeze a frame mid-eruption, mid-strike or mid-burn with +**Every parameter is a live slider** — a little over 16,000 of them — and they stay live while the +simulation is paused. That is the point of the project: freeze a frame mid-eruption, mid-strike or mid-burn with **P**, then reshape the silhouette, the palette and the timing against a still image. References for the look: `icecast.jpg`, `thundercast.jpg`, `superbeam.jpg` and @@ -106,6 +111,11 @@ shown as a visible sky. The stage keeps its flat dark backdrop. ## Controls +The bar is a **loadout**: eight slots over the whole roster, bound to `Q E R F V X Z T` with the +digits `1..8` mirroring them. The default is the six that shipped in slots 1–6 and two empty +slots; the binding is saved to `localStorage` and rebound from the spellbook. An empty slot's key +opens the spellbook ready to fill it. + | Input | Action | | --- | --- | | **Q** (or **1**) | Arm Frost Lance — press again to put it away | @@ -113,6 +123,9 @@ shown as a visible sky. The stage keeps its flat dark backdrop. | **R** (or **3**) | Arm Cinder Fall — press again to put it away | | **F** (or **4**) | Arm Nova Beam — press again to put it away | | **V** (or **5**) | Arm Voltaic Snare — the far cast, aimed with a circle | +| **X** (or **6**) | Arm Glacial Crown — the other far cast | +| **Z**, **T** (or **7**, **8**) | The two open slots — empty until you bind something | +| **Tab** or **B** | Open the spellbook: the whole roster, by school | | **Move the mouse** | Swing the aim arrow, or move the far-cast circle | | **Left click** | Cast along the arrow, or drop the circle where it is | | **Esc** / **right click** | Cancel an armed cast | @@ -122,6 +135,11 @@ shown as a visible sky. The stage keeps its flat dark backdrop. | **P** | Pause / resume — *the editor keeps applying* | | **C** | Clear all active effects | | **H** | Hide the controls panel | +| **Click a slot's name** | Open that ability's folder in the editor | + +In the spellbook: type to filter by name, school or description; arrow keys and **Enter** to +browse and cast; **shift-click** a card, or drag it onto a slot, to bind it; **Esc** to close. +The loadout bar stays live underneath the book so it can be dropped on. `range` and `minRange` are per ability, so the indicator's reach changes with the slot you have selected. Aiming closer than the selected ability's `minRange` tints it red and refuses the cast; @@ -135,24 +153,28 @@ spending one slot never locks the other out. ``` src/ - abilities/ Ability base class (the travelling front), IceAbility, ThunderAbility, - MeteorAbility, BeamAbility, SnareAbility, pooling manager + abilities/ Ability base class (the travelling front), registry.js (the one + declaration site), one directory per school, pooling manager animation/ FBX character loading, AnimationMixer, the per-ability cast clips, the procedural cast lunge assets/ Procedural crystal and asteroid geometry, the bolt ribbon strip, the beam tube and its shock discs - config/ settings.js — the single source of truth for every parameter + config/ settings.js (globals, aim, post, camera) and abilities/.js — + one settings block and editor schema per ability core/ App, Renderer, CameraRig, Time, Layers, shared frame uniforms effects/ Aim arrow, far-cast circle, ground decals, fissures, bursts, light pool, shake, flash input/ InputManager (events) and AimController (both targeting shapes) loaders/ AssetLoader with a shared LoadingManager - materials/ IceMaterial, LightningMaterial, MeteorMaterial, - VolumetricFireMaterial, BeamMaterial, SnareMaterial + materials/ Per-ability bespoke materials — ice, lightning, meteor, fire, + beam, snare, and the ones the new schools brought with them + vfx/ The shared tech library: 26 modules an ability is configured + against rather than re-implementing. See docs/VFX_API.md. particles/ GPU particle system + engine and rate emitters postprocessing/ Composer pipeline, grade shader, distortion shader shaders/lib/ Shared GLSL: noise library, common helpers - ui/ HUD, lil-gui editor, preset manager, styles + ui/ HUD + loadout bar, spellbook overlay, lil-gui editor, sigils, + preset manager, styles utils/ Maths, colour cache, pooling, disposal, shader patching world/ Environment (stage lighting), floor, dust, contact shadows archive/ The retired four-element sandbox — see archive/README.md @@ -391,22 +413,32 @@ first, then the air breaks down over it. ### Adding another ability -1. Add a settings block in `config/settings.js` and an entry in `ELEMENTS` / `ELEMENT_META`. -2. Subclass `Ability` and implement `createShaders`, `createParticles`, `onTravel`, `onImpact`, - `onFade`. -3. Register the class in `abilities/AbilityManager.js`. -4. Add an editor folder in `ui/Editor.js`, and a sigil in `ui/glyphs.js`. -5. Bind a key in `input/InputManager.js` — it emits `ability` with the 0-based slot index, which - `App` maps through `ELEMENTS`. +1. Add `config/abilities/.js` exporting its settings block and its editor schema, and import + it in `config/abilities/index.js`. The schema is the editor folder — there is no `ui/Editor.js` + edit any more, and no ability list in `config/settings.js`. +2. Subclass `Ability` in `abilities//Ability.js` and implement `createShaders`, + `createParticles`, `onTravel`, `onImpact`, `onFade`. +3. Add one descriptor to `abilities/registry.js`. That is the only registration: the manager, the + HUD, the spellbook, the presets and the aim controller all derive from it. +4. Draw a sigil in `ui/glyphs/.js`. +5. Nothing. It is in the spellbook the moment it is in the registry, and a key is a *binding* + rather than a property of the ability: eight slots, `Q E R F V X Z T` (digits `1..8` mirror + them), rebound by dragging a card out of the spellbook and saved to `localStorage`. Only the + two keyless default slots are named in code, in `DEFAULT_FILL` in `ui/Loadout.js`. To make it a **far cast** instead of a line cast, add two things and nothing else: `cast: -CastShape.ZONE` in its `ELEMENT_META` entry, and a `zoneRadius` in its settings block. The circle +CastShape.ZONE` on its registry descriptor, and a `zoneRadius` in its settings block. The circle indicator, the reach ring, the snap-out and the whole targeting loop come for free, and the ability reads its centre as `pointAt(1)`. Everything else — pooling, the travelling front, the local frame, lights, phases, per-ability -cooldowns, the aim reach and camera framing — is inherited or driven off `ELEMENTS`. The HUD -builds its slots from that array, so a new ability appears in the bar on its own. +cooldowns, the aim reach and camera framing — is inherited or driven off the registry. + +Then run `npm run check`. It parses your ability for every settings key it reads and fails on one +that does not exist, simulates the whole cast headlessly, and **mutates your sliders on a +zero-length frame and fails if nothing observable changes** — the invariant that the whole project +is built on, enforced rather than trusted. It does not compile GLSL, so put a new material on +screen once before you believe it. ### Particles @@ -440,8 +472,11 @@ Per frame: samples it for soft intersections, so nothing cuts a hard line into the ground. The crystals sit on `LAYER.WORLD`, so mist and glitter fade softly against them. 2. **Distortion pass** — meshes on the distortion layer write screen-space UV offsets into a second - half-res buffer. Nothing writes to it in the current build; the pass is kept because it is the - hook a refraction effect would use. + half-res buffer, and the composer resamples the frame by them. `vfx/Distortion.js` is what writes + there: heat shimmer, 1/r² gravity lensing, travelling shock fronts, vacuum blades and refracting + hulls. The layer is counted, so the clear, the draw and the resample are all skipped on frames + where nothing is refracting anything — `post.distortionEnabled` turns it off outright and + `post.distortionScale` sets the offset buffer's resolution. See `src/vfx/README.md`. 3. **Composer** — scene → refraction warp → bloom → tone map (ACES) → grade. The grade pass folds chromatic aberration, lift/gain/contrast/saturation/temperature, vignette, @@ -459,10 +494,21 @@ target, blurred twice and projected onto the ground. ## Editor and presets -Press **G** for the panel. Folders: Presets, Global, Aim indicator, Far-cast circle, Frost Lance, -Storm Lance, Cinder Fall, Nova Beam, Voltaic Snare, Environment, Post processing, Camera, -Character. Every folder starts collapsed — there are enough controls here that one open section -pushes the rest off the screen. +Press **G** for the panel. Folders: Presets, Global, Aim indicator, Far-cast circle, then **one +per school** — Frost, Flame, Storm, Stone, Verdant, Void, Arcane, Blood, Aether, Tide, Forge, +Lumen, Ink, Chrono, Hive — then Environment, Post processing, Camera, Character. Every folder +starts collapsed, so the panel opens as a list of nineteen rows rather than sixteen thousand +controls. + +An ability's folder is **built the first time it is opened**. Constructing all hundred eagerly +cost 597 ms and 107,000 DOM nodes at boot, which is a stalled start; on demand it is 7.7 ms and +1,400 nodes. The **search box** at the top still finds controls in folders that have never been +opened, because it indexes the schemas rather than the built controllers, and typing filters the +whole tree at once. + +Every ability's folder is generated from the `Schema` export beside its settings block, so a +new slider appears in the panel the moment it exists — filed if the schema mentions it, and in a +trailing **More** folder if it does not. A schema is never wrong, only incomplete. - **Global** multipliers scale everything at once (speed, glow, noise, particles, lights, impact intensity, camera shake, time scale…). @@ -471,6 +517,8 @@ pushes the rest off the screen. - **Far-cast circle** (40 controls) — the boundary band, the interior, the ticks, sweep and reticle, the reach ring, and the snap-out. Shared by every far cast, so it is filed with the targeting rather than with any one ability. +The four shipped abilities below are a sample of the shape every one of the hundred takes: + - **Frost Lance** (113 controls, 25 of them colours) — the cast, the footprint, the silhouette, the crystal itself, the eruption timing, the ice material, the frost on the ground, mist/chips/glitter, the impact and the dynamic light. @@ -538,8 +586,21 @@ Knobs worth knowing about, because they reshape their ability the most: - The six dynamic point lights are created at boot and parked at zero intensity rather than added and removed — changing the light count forces three to recompile every material. - Shadow maps update exactly once per frame even though the scene is rendered several times. -- `renderer.compileAsync()` runs during boot so the first cast never stutters on shader compile. -- Pixel ratio is capped at 1.75; the depth and distortion buffers are half resolution. +- **Abilities are loaded lazily.** A hundred classes are not constructed at boot; the manager + imports one the first time its id is selected or cast, and `App` calls `warm(id)` on selection + so the class is in memory long before the click. `renderer.compileAsync()` at boot therefore + only sees what is in the scene at boot — an ability's shaders compile the first time it is + warmed, which is why selection warms rather than the cast. +- The editor builds an ability's controls on first open, not at boot. See above. +- `Mirror` (Black Ice, Refraction Cascade) renders the world a second time from a mirrored camera + and is the most expensive thing here; it is capped, skipped when nothing is visible, and its + target resolution is a slider. `VolumeHull` is fill-rate bound and reads + `settings.global.volumeQuality`. +- Pixel ratio is capped at 1.75; the depth and distortion buffers are half resolution, and the + distortion pass skips its clear, draw and resample entirely when nothing is refracting. + +The figures below are for the six original abilities and still hold; the new ninety-four were +built to the same budget of roughly a dozen draw calls for a whole cast. Measured on a default cast: 32 draw calls idle, ~69 with a full ice field standing and ~49 with a bolt in the air, ~1150 live particles. A snare standing with its cage, field and rim burns is ~45 @@ -571,7 +632,12 @@ piece of it. sorting artefacts between overlapping spikes become visible. - The eruption front is a straight line on a flat floor. Both assumptions are baked in — the ground is a single plane at y = 0, and the aim raycast targets that plane. -- The distortion pass runs with nothing writing to it. It costs a half-res clear per frame. +- ~~The distortion pass runs with nothing writing to it.~~ Fixed — `vfx/Distortion.js` writes to it + (heat, gravity lensing, shock fronts, vacuum blades, refracting hulls), and the pass now skips its + clear, its draw and its resample entirely on frames where nothing on the layer is visible. What + remains is that overlapping distorters resolve by coverage rather than by depth: the offset buffer + has no depth attachment, so occlusion is a soft rejection against the opaque prepass and other + transparents cannot hide a warp. - The impact cluster is placed radially around the end point, so at very short cast distances it can overlap the band behind it more than it should. - The far cast inherits the flat-floor assumption twice over: the circle is drawn on a single quad @@ -583,7 +649,10 @@ piece of it. --- -## Licence +## License + +**MIT**, © mohamedachrefelouafi — see [`LICENSE`](LICENSE) for the full text. The original +sandbox, and every technique this project is built on top of, is his work. -Code is provided as-is for the purposes of this project. The bundled HDR probe and the character -FBX retain their original licences. +The bundled HDR probe and the character FBX are third-party assets and retain their own licences; +they are not covered by the MIT grant above. diff --git a/icecast.jpg b/icecast.jpg deleted file mode 100644 index d77d393..0000000 Binary files a/icecast.jpg and /dev/null differ diff --git a/index.html b/index.html index 5a3c924..48922eb 100644 --- a/index.html +++ b/index.html @@ -30,6 +30,13 @@

Elemental Sandbox

+ +
+ diff --git a/package-lock.json b/package-lock.json index 067eb95..d1d7573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,29 +15,6 @@ "vite": "^8.1.5" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -695,6 +672,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index a8f3342..79fb513 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "check": "node scripts/check.mjs", + "check:build": "npm run check && npm run build" }, "dependencies": { "lil-gui": "^0.21.0", diff --git a/src/abilities/Ability.js b/src/abilities/Ability.js index 90331f1..f529e9c 100644 --- a/src/abilities/Ability.js +++ b/src/abilities/Ability.js @@ -77,6 +77,18 @@ export class Ability { /** Transient additive light punch (impacts). Decays on its own. */ this.lightBoost = 0; + /** + * Handles this cast has borrowed from a **global** pool — a scene hook + * (`vfx/SceneHooks.js`), a time region (`vfx/TimeControl.js`), anything + * else whose `acquire()` takes a slice of the world away from everybody + * else. See `borrow()`. + * + * Allocated once, per instance, at construction; `destroy()` empties it in + * place. An ability is pooled, so this array is created a handful of times + * for the life of the app and never during a cast — I3 holds. + */ + this.borrowed = []; + this.createShaders(); this.createParticles(); } @@ -263,11 +275,55 @@ export class Ability { this.lightBoost = Math.max(0, this.lightBoost - this.lightBoost * 4.5 * dt - 0.5 * dt); } + /** + * Register a borrowed global handle so `destroy()` gives it back. + * + * ```js + * this.region = this.borrow(timeField.acquire()); // may be null + * this.grade = this.borrow(sceneHooks.acquire(Hook.GRADE, this)); + * ``` + * + * This exists because a cast can end in four different ways and only one of + * them is the ability's idea. It finishes normally; the player presses **C** + * and `AbilityManager#clear()` destroys it mid-flight; a fifth cast pushes it + * off the front of the concurrency cap; the app tears down. `onDestroy()` is + * called on all four, so an ability that releases there is already correct — + * and every one of the fifty is. The net is here for the same reason + * `ctx.lights.release(this.light)` is on the line below it rather than in + * fifty `onDestroy()` bodies: a light that leaks costs the next cast its + * light, but a **scene hook** that leaks holds the sun, the grade or the + * floor's material wrong for the rest of the session, and a leaked **time + * region** stops a sphere of the world permanently. Those are not failures + * anybody would trace back to the ability that caused them. + * + * `SceneHooks` does carry an eight-frame lease sweep as a second net, but it + * recovers with a console warning several frames late; this recovers exactly, + * on the frame, silently. `TimeField` has no sweep at all — it cannot have + * one, because nothing ticks it — so for time regions this *is* the net. + * + * Nothing is imported to make it work: every such handle knows its own pool + * (`token.hooks`, `region._field`) and every `release()` in the project is + * idempotent, so releasing here and again in `onDestroy()` is harmless and + * the order does not matter. + * + * @template T + * @param {T} handle anything with a `release()`; `null` passes through + * @returns {T} the same handle, so this wraps the acquisition inline + */ + borrow(handle) { + if (handle) this.borrowed.push(handle); + return handle; + } + /** Return to the pool. Must leave the instance reusable. */ destroy() { this.onDestroy(); this.ctx.lights.release(this.light); this.light = null; + // Backwards: a handle released inside onDestroy() is already inert, and + // popping from the end keeps this allocation-free. + for (let i = this.borrowed.length - 1; i >= 0; i--) this.borrowed[i]?.release?.(); + this.borrowed.length = 0; this.group.visible = false; this.phase = AbilityPhase.IDLE; } diff --git a/src/abilities/AbilityManager.js b/src/abilities/AbilityManager.js index ce69363..9e22b7c 100644 --- a/src/abilities/AbilityManager.js +++ b/src/abilities/AbilityManager.js @@ -1,33 +1,43 @@ -import { IceAbility } from './IceAbility.js'; -import { ThunderAbility } from './ThunderAbility.js'; -import { MeteorAbility } from './MeteorAbility.js'; -import { BeamAbility } from './BeamAbility.js'; -import { SnareAbility } from './SnareAbility.js'; -import { GlacierAbility } from './GlacierAbility.js'; -import { ELEMENTS } from '../config/settings.js'; +import { ABILITY_IDS, getAbility } from './registry.js'; import { ObjectPool } from '../utils/ObjectPool.js'; -/** Registry: adding an ability means adding one line here. */ -const ABILITY_TYPES = { - ice: IceAbility, - thunder: ThunderAbility, - meteor: MeteorAbility, - beam: BeamAbility, - snare: SnareAbility, - glacier: GlacierAbility -}; - const MAX_CONCURRENT = 4; /** * Spawns, updates and recycles abilities. * - * Instances are pooled per type: casting fifty times constructs at most a - * handful of objects per ability, and every one of them keeps its meshes and - * materials for the lifetime of the app. Nothing is built during a cast. + * Instances are pooled per id: casting fifty times constructs at most a handful + * of objects per ability, and every one of them keeps its meshes and materials + * for the lifetime of the app. Nothing is built during a cast. + * + * `MAX_CONCURRENT` is shared across ids, so mixing abilities retires the oldest + * cast whichever one it was. + * + * ## Laziness + * + * The manager used to import all six ability classes at the top of this file + * and build a pool for each in its constructor. At fifty that is fifty modules + * parsed and fifty sets of meshes, materials and particle systems constructed + * before the loading bar has finished — for the one ability the player is about + * to press Q on. * - * `MAX_CONCURRENT` is shared across types, so mixing abilities retires the - * oldest cast whichever element it was. + * So a pool is now built the first time an id is **selected or cast**, from the + * registry descriptor's `load()`. The awkward part is that an import is a + * promise and a cast is a click: the player is not going to wait a microtask + * for `Frost Lance` to arrive. Three things resolve that, in order of how much + * they matter: + * + * 1. `App` calls `warm(id)` the moment an ability is *selected*, which is + * always at least one frame — usually several seconds — before the click. + * By the time the arrow has swept out the class is in memory and the pool + * is primed with a live instance. + * 2. The constructor warms whatever is in slot one, so the very first cast of + * a session is ready too. + * 3. If a cast still arrives cold, `cast()` kicks off the import and returns + * `null` — nothing is drawn this frame, and the next click works. It never + * awaits, because an `async` cast would put an ability on screen a frame + * after the animation that threw it, and a spell that lags its own gesture + * reads as broken in a way a dropped first cast does not. */ export class AbilityManager { /** @@ -37,25 +47,101 @@ export class AbilityManager { constructor(context) { this.ctx = context; this.active = []; - this.selected = ELEMENTS[0]; + this.selected = ABILITY_IDS[0]; + /** id → ObjectPool, created on first warm. */ this.pools = new Map(); - for (const [element, Type] of Object.entries(ABILITY_TYPES)) { - this.pools.set( - element, - new ObjectPool(() => { - const ability = new Type(this.ctx); - this.ctx.scene.add(ability.group); - ability.group.visible = false; - return ability; - }) - ); - } + /** id → the loaded class, once its import has settled. */ + this._classes = new Map(); + /** id → the in-flight import, so a held key never fires two of them. */ + this._loading = new Map(); + + // Slot one is armed before the player has touched anything; have it ready. + this.warm(this.selected); + } + + /* ------------------------------------------------------------------ */ + /* Loading */ + /* ------------------------------------------------------------------ */ + + /** Whether this id can be cast on the current frame. */ + isReady(id) { + return this.pools.has(id); + } + + /** + * Load an ability's class and prime its pool, off the frame loop. + * + * Idempotent and cheap to call every time a slot is selected: an id that is + * already loaded resolves immediately, and an id that is mid-import returns + * the same promise rather than starting a second one. + * + * Priming means constructing one instance now, which is where the meshes, + * materials and particle systems get built — the expensive part, moved off + * the click and onto the selection. The instance goes into the scene + * invisible, exactly as the eager constructor used to leave it. + * + * @param {string} id + * @returns {Promise} true once the id is castable + */ + warm(id) { + if (this.pools.has(id)) return Promise.resolve(true); + + const existing = this._loading.get(id); + if (existing) return existing; + + const descriptor = getAbility(id); + if (!descriptor) return Promise.resolve(false); + + const pending = descriptor + .load() + .then((Type) => { + this._classes.set(id, Type); + // Two selections can land in the same tick; the first one to arrive + // wins and the second finds the pool already built. + if (!this.pools.has(id)) { + const pool = new ObjectPool(() => { + const ability = new Type(this.ctx); + this.ctx.scene.add(ability.group); + ability.group.visible = false; + return ability; + }); + // Prime with one, so the first cast of this id allocates nothing. + pool.release(pool.acquire()); + this.pools.set(id, pool); + } + return true; + }) + .catch((error) => { + // A failed import must not poison the slot forever — clearing the + // in-flight entry lets the next selection try again. + console.error(`AbilityManager: failed to load "${id}"`, error); + return false; + }) + .finally(() => { + this._loading.delete(id); + }); + + this._loading.set(id, pending); + return pending; } - select(element) { - if (!ABILITY_TYPES[element]) return; - this.selected = element; + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + /** + * Put an ability in the slot. + * + * Deliberately *only* state: warming is `App`'s call, made from + * `selectAbility()` at the same moment, and keeping the two separate means + * the spellbook can warm an ability it is merely hovering without selecting + * it. A caller that selects and forgets to warm loses one cast and no more — + * `cast()` self-heals. + */ + select(id) { + if (!getAbility(id)) return; + this.selected = id; } /** @@ -64,13 +150,23 @@ export class AbilityManager { * A far cast takes the same three arguments and simply works from the far end * of that line — which is why adding zone targeting needed nothing here. * + * Returns `null` if the id is unknown, or if its class has not finished + * loading; in the second case the import is kicked off and the next cast + * works. See the class header for why this does not await. + * * @param {THREE.Vector3} origin on the floor * @param {THREE.Vector3} direction unit, flat * @param {number} distance metres * @returns {import('./Ability.js').Ability|null} */ cast(origin, direction, distance, element = this.selected) { - if (!ABILITY_TYPES[element]) return null; + if (!getAbility(element)) return null; + + const pool = this.pools.get(element); + if (!pool) { + this.warm(element); + return null; + } // Retire the oldest cast rather than letting the scene grow without bound. if (this.active.length >= MAX_CONCURRENT) { @@ -79,7 +175,7 @@ export class AbilityManager { this.pools.get(oldest.element).release(oldest); } - const ability = this.pools.get(element).acquire(); + const ability = pool.acquire(); ability.spawn(origin, direction, distance); this.active.push(ability); return ability; @@ -118,5 +214,7 @@ export class AbilityManager { this.clear(); for (const pool of this.pools.values()) pool.dispose((ability) => ability.dispose()); this.pools.clear(); + this._classes.clear(); + this._loading.clear(); } } diff --git a/src/abilities/IceAbility.js b/src/abilities/IceAbility.js index 706064a..bd0862f 100644 --- a/src/abilities/IceAbility.js +++ b/src/abilities/IceAbility.js @@ -546,7 +546,7 @@ export class IceAbility extends Ability { _emit.spin = 7; _emit.tint = null; _emit.time = time; - this.shards.emit(Math.round(3 * g.particleCount), _emit); + this.shards.emit(Math.round(c.breachShards * g.particleCount), _emit); // Only some spikes puff: a few hundred smoking at once buries the field in // haze and hides the silhouette that is the whole point. diff --git a/src/abilities/aether/AuroraAbility.js b/src/abilities/aether/AuroraAbility.js new file mode 100644 index 0000000..9fa16ff --- /dev/null +++ b/src/abilities/aether/AuroraAbility.js @@ -0,0 +1,429 @@ +import { Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { Curtain, CurtainMode, CurtainLayout, curtainParams } from '../../vfx/Curtain.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, Easing } from '../../utils/math.js'; + +/** + * Hard ceiling on sheets. The editor's `veilCount` slider clamps here, and the + * instanced buffer is allocated once at this size — `Curtain` places every copy + * in its vertex shader, so twelve sheets and one sheet are the same draw call + * and the same buffer. + */ +const MAX_SHEETS = 12; + +/** + * Quads *across* a sheet. This is the one geometry number that matters: the + * travelling ripple is a vertex displacement along the sheet's length, so its + * shortest resolvable wavelength is two of these. At 48 a four-metre crest on a + * five-metre sheet is smooth; at the module's default of 32 the same crest has + * visible facets on its shoulders once `rippleAmp` goes past about a metre. + */ +const SEGMENTS_X = 48; +/** Quads up a sheet. Cheaper — nothing displaces vertically except the lean. */ +const SEGMENTS_Y = 20; + +const TAU = Math.PI * 2; + +/* ------------------------------------------------------------------ */ +/* Scratch — module scope (I3) */ +/* ------------------------------------------------------------------ */ + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/** Filled from `settings.aurora` every frame; never held between frames. */ +const _veil = curtainParams(); + +/** + * AURORA VEIL — sheets of light standing in the circle. + * + * **The trick is that two curves disagree.** A hanging ribbon and a curtain of + * aurora are the same geometry. What separates them is that on cloth, coverage + * and brightness die together — where the cloth thins it both stops hiding + * things and stops being bright. Light in air does not: a column of excited gas + * keeps radiating long after it has stopped occluding anything, which is why + * the head of a real aurora is bright *and* transparent at once. + * + * So the alpha and the emission ride independent exponents up the sheet: + * + * ``` + * alpha = mix(alphaTop, alphaBase, pow(1 - h, alphaCurve)) + * emission = mix(emissionTop, emissionBase, pow(1 - h, emissionCurve)) + * ``` + * + * `settings.aurora.alphaCurve` and `settings.aurora.emissionCurve` are **both + * sliders**, they are re-read every frame, and they are supposed to be + * different numbers. `Curtain`'s own defaults are 2.4 against 0.7; this ability + * ships 2.6 against 0.55, pushed further apart because the bloom chain here is + * generous enough that the top of the sheet can be almost pure radiance over a + * visible floor. Drag them together with the clock paused and the aurora turns + * into a rag hanging on a line, in real time — which is the single most + * convincing demonstration of the technique in the project. + * + * The second half of the read is `veilGraze`. A sheet has no thickness, so a + * ray crossing it face-on passes through nothing while a ray crossing it edge-on + * travels the length of a fold; both curves are therefore scaled by `1/|N·V|`, + * clamped at `veilGrazeFloor`. That is two lines in the library and most of the + * volume in the effect — and it only ever shows itself on a *folded* sheet seen + * from a *moving* camera, which is why `veilLean`, `rippleAmp` and + * `veilScatter` all ship well above zero. + * + * **Nothing in this ability hits anything.** There is no impact burst, no + * shockwave, no screen flash, no camera shake and no decal; `onImpact()` is + * empty on purpose and the floor companion's expanding-ring term — which is an + * impact term — is held at zero. It is the only slot in the sandbox with no + * violence in it and it should be a relief to cast. + * + * Three beats, all slow: + * + * 1. **rise** (travel) — `rise` follows the front out to the point, and + * `veilRiseSpread` staggers the sheets so the ring comes up as a wave + * rather than as a wall. + * 2. **ripple** (impact, `lifetime` long) — it just stands there. Two folds at + * different wavelengths and speeds, a third of fbm slop, and a three-way + * hue band drifting along the sheets at 0.03 Hz. + * 3. **fade** — the feet leave the floor and climb `fadeLift` metres while the + * substance drains out of the sheets. It does not sink and it does not + * blink out; it goes *up*, which is the only exit that does not read as the + * ability being switched off. + * + * Two draw calls: one instanced sheet mesh, one floor companion. The companion + * walks the same `sheetFrame()` the sheets do, sharing the layout uniforms by + * identity, so the coloured light on the floor cannot drift out from under the + * sheet casting it. + * + * **The rule that makes the editor work.** A cast captures one dice roll per + * sheet — nine unitless numbers apiece, rolled in `Curtain#roll()` and nowhere + * else — and the phase clock. Not one metre. Pause with **P** mid-hold and drag + * `veilSpread`: the ring re-lays itself around you with the clock stopped. + */ +export class AuroraAbility extends Ability { + constructor(context) { + super('aurora', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + // RING rather than LINE, because this is a far cast and the circle the + // indicator drew is the promise: the sheets stand *on* that boundary and + // you can walk into the middle of them. `layout` is a live uniform, though, + // so `veilLayout` can put them in a rank or scatter them across the disc + // without a recompile — that is the module's doing, not a special case. + this.veil = new Curtain({ + capacity: MAX_SHEETS, + segmentsX: SEGMENTS_X, + segmentsY: SEGMENTS_Y, + mode: CurtainMode.AURORA, + layout: CurtainLayout.RING, + floor: true, + renderOrder: 9, + name: 'Aurora:Veil' + }); + this.group.add(this.veil.object3D); + + /** The one number a cast rolls for the layout as a whole. */ + this._seed = 0; + } + + createParticles() { + // One system, and a small one. The veil is already light in air; motes are + // there to give the empty space between the sheets something to measure it + // by, and a cloud of them would be doing the curtain's job badly. + this.drift = this.ctx.particles.get('aurora.drift', { + capacity: 260, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.7 + }); + this.drift.uniforms.uDrag.value = 1.2; + this.drift.uniforms.uEndSize.value = 0.5; + this.drift.uniforms.uSizeIn.value = 0.22; + this.drift.uniforms.uFadeIn.value = 0.28; + this.drift.uniforms.uFadeOut.value = 0.4; + + this.driftEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Sheets drawn this frame. One draw call regardless. */ + get instanceCount() { + return this.veil.instanceCount; + } + + /** The hold is the ability. Everything else is getting into and out of it. */ + get impactDuration() { + return Math.max(0.05, settings.aurora.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.aurora.fadeTime); + } + + /** + * A very slow swell. + * + * The base class's default shimmer beats at 9.3 Hz against 3.7 Hz, which is a + * glint — right for ice and completely wrong here. One sine at + * `lightSwaySpeed` (a fifth of a hertz by default) is the veil breathing, and + * it is slow enough that you notice it only when you stop looking at it. + */ + lightShimmer() { + const c = settings.aurora; + return 1 + c.lightSway * Math.sin(this.age * c.lightSwaySpeed * TAU); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.driftEmitter.reset(); + + this._seed = Math.random() * 100; + // `reset()` is the pooling contract — it zeroes the instance count and + // hides the group — so the visibility has to come back on here, before the + // first `update()`. Rolling after resetting, because the roll writes the + // dice the very first frame reads. + this.veil.reset(); + this.veil.roll(this._seed); + this.veil.visible = true; + + this._sync(0, 0); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve every metre in the curtain from live settings. + * + * @param {number} rise 0..1 how far the sheets have come up + * @param {number} gone 0..1 how far through the climb-away they are + */ + _sync(rise, gone) { + const c = settings.aurora; + const g = settings.global; + + this.pointAt(1, _centre); + // `along` is the layout's local +X. For a ring it only decides where sheet + // zero starts, but it has to be *something* stable, and the cast heading is + // the only frame the player has any intuition about. + this.veil.setPlacement(_centre, this.direction, _up); + this.veil.layout = Math.round(c.veilLayout); + + /* --- the layout --- */ + _veil.count = Math.min(MAX_SHEETS, Math.round(c.veilCount)); + _veil.spacing = c.veilSpacing; + _veil.radius = c.zoneRadius * c.veilSpread; + _veil.scatter = c.veilScatter; + _veil.seed = this._seed; + + /* --- the sheet --- */ + _veil.width = c.veilWidth; + _veil.widthJitter = c.veilWidthJitter; + _veil.height = c.veilHeight; + _veil.heightJitter = c.veilHeightJitter; + // The exit. The feet climb rather than the heads sinking, because a curtain + // whose top edge comes down reads as a blind being drawn — the wrong idea + // entirely for something that is supposed to be leaving the room. + _veil.base = c.veilBase + c.fadeLift * gone; + _veil.taper = c.veilTaper; + _veil.lean = c.veilLean; + _veil.leanJitter = c.veilLeanJitter; + _veil.rise = rise; + _veil.riseSpread = c.veilRiseSpread; + + /* --- the ripple. Metres per second, so global.noiseSpeed belongs here. --- */ + const speed = g.noiseSpeed; + _veil.rippleAmp = c.rippleAmp * g.turbulence; + _veil.rippleLength = c.rippleLength; + _veil.rippleSpeed = c.rippleSpeed * speed; + _veil.rippleCurve = c.rippleCurve; + _veil.foldAmp = c.foldAmp * g.turbulence; + _veil.foldLength = c.foldLength; + _veil.foldSpeed = c.foldSpeed * speed; + _veil.rippleNoise = c.rippleNoise * g.noiseStrength; + _veil.rippleNoiseScale = c.rippleNoiseScale * g.noiseFrequency; + _veil.rippleNoiseSpeed = c.rippleNoiseSpeed * speed; + _veil.phaseSpread = c.phaseSpread; + + /* --- the two curves. Read the class header before touching these. --- */ + _veil.alphaBase = c.alphaBase; + _veil.alphaTop = c.alphaTop; + _veil.alphaCurve = c.alphaCurve; + _veil.emissionBase = c.emissionBase; + _veil.emissionTop = c.emissionTop; + _veil.emissionCurve = c.emissionCurve; + + /* --- the envelope --- */ + // The substance drains before the light does, which is the same idea as the + // two curves applied to the fade rather than to the height: a cloud of gas + // stops blocking the floor well before it stops glowing. + _veil.body = c.veilBody * (1 - Easing.inQuad(gone)); + _veil.footFade = c.veilFootFade; + _veil.headFade = c.veilHeadFade; + _veil.edgeFade = c.veilEdgeFade; + _veil.graze = c.veilGraze; + _veil.grazeFloor = c.veilGrazeFloor; + _veil.softFade = c.veilSoftFade; + _veil.opacity = c.veilOpacity * g.opacity * (1 - gone); + _veil.glow = c.veilGlow * g.glow; + _veil.tintSpread = c.veilTintSpread; + + /* --- the aurora --- */ + _veil.rayScale = c.rayScale * g.noiseFrequency; + _veil.raySpeed = c.raySpeed * speed; + _veil.raySharp = c.raySharp; + _veil.bandScale = c.bandScale * g.noiseFrequency; + _veil.bandSpeed = c.bandSpeed * speed; + _veil.hem = c.hem; + _veil.colorA = c.colorA; + _veil.colorB = c.colorB; + _veil.colorC = c.colorC; + _veil.colorHem = c.colorHem; + _veil.colorBody = c.colorBody; + + /* --- the wash on the floor --- */ + _veil.floorSize = c.zoneRadius * c.washSpread; + _veil.floorFade = c.washFade; + _veil.wet = c.washWet; + _veil.wetDark = c.washDark; + _veil.sheen = c.washSheen; + _veil.sheenRough = c.washSheenRough * g.noiseFrequency; + _veil.sheenSpeed = c.washSheenSpeed * speed; + _veil.floorFresnel = c.washFresnel * g.fresnel; + _veil.pool = c.washPool * rise; + _veil.poolWidth = c.washPoolWidth; + _veil.poolLength = c.washPoolLength; + _veil.poolSoft = c.washPoolSoft; + // Zero, and not a slider. The companion's ring term draws expanding impact + // rings on a hashed lattice — it is an impact effect, and this ability does + // not have an impact. Exposing it would be exposing a knob whose only + // setting is "spoil the one thing the slot is for". + _veil.rings = 0; + _veil.floorOpacity = c.washOpacity * g.opacity * (1 - gone); + _veil.colorWet = c.colorWash; + _veil.colorPool = c.colorPool; + _veil.colorRing = c.colorRing; + + // The clock argument is ignored — the sheets are driven by `frame.uTime` + // inside the shader — but it is passed honestly rather than as a zero, so a + // future version of the module that wants it gets the right number. + this.veil.update(this.age, _veil); + + /* --- the motes --- */ + this.drift.setGradient( + getColor(c.colorDriftA), + getColor(c.colorDriftB), + getColor(c.colorDriftC), + getColor(c.colorDriftD) + ); + this.drift.uniforms.uGravity.value.set(0, c.driftRise, 0); + this.drift.uniforms.uSizeScale.value = c.driftSize * g.particleSize * 7; + this.drift.uniforms.uLifeScale.value = c.driftLifetime * 0.5 * g.particleLifetime; + // Metres per second lives on the uniform rather than on the emit, so that + // dragging `driftSpeed` with the clock stopped re-speeds the motes that are + // already drifting instead of only the next ones out. + this.drift.uniforms.uSpeedScale.value = c.driftSpeed * g.particleSpeed; + this.drift.uniforms.uOpacity.value = g.opacity; + this.drift.uniforms.uGlow.value = c.veilGlow * g.glow; + this.drift.uniforms.uTurbulence.value = c.driftTurbulence * g.turbulence; + } + + /** Motes seeded through the volume the ring encloses. */ + _driftFx(dt, rise) { + const c = settings.aurora; + const g = settings.global; + const count = Math.round(this.driftEmitter.tick(dt, c.driftRate * rise) * g.particleCount); + if (count <= 0) return; + + this.pointAt(1, _centre); + _pos.set(_centre.x, c.veilHeight * c.driftCeiling * 0.5, _centre.z); + + _emit.position = _pos; + _emit.radius = c.zoneRadius * c.veilSpread * c.driftSpread; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = 1; // metres/second live on uSpeedScale — see _sync() + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.75; + _emit.life = c.driftLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.drift.emit(count, _emit); + } + + /** The light hangs over the ring rather than riding the floor under it. */ + _placeLight() { + const c = settings.aurora; + this.pointAt(1, this.position); + this.position.y = c.lightHeight; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + // The rise front *is* the cast's progress. Nothing crosses the floor — the + // sheets simply come up further the closer the cast gets to its point, + // which is why `speed` here is a rise rate dressed as a travel speed and is + // set slow enough to watch. + const rise = saturate(this.u); + this._sync(rise, 0); + this._driftFx(dt, rise * 0.4); + this._placeLight(); + } + + onImpact() { + // Deliberately empty. No burst, no ring, no flash, no shake, no decal. The + // veil finishes rising and that is the whole event; anything louder here + // and this stops being the one slot you cast to calm down. + } + + /** + * @param {number} dt seconds + * @param {number} t 0..1 through the hold, then 1..2 through the climb-away + */ + onFade(dt, t) { + const held = t <= 1; + // Cubic out on the way away: the veil hangs on almost to the end and then + // lets go quickly, which is how a light in air actually leaves — a linear + // ramp reads as a dimmer being turned down by hand. + const gone = held ? 0 : Easing.inCubic(saturate(t - 1)); + + this._sync(1, gone); + this._driftFx(dt, 1 - gone); + this._placeLight(); + } + + onDestroy() { + this.veil.reset(); + } + + dispose() { + this.veil.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/aether/CycloneAbility.js b/src/abilities/aether/CycloneAbility.js new file mode 100644 index 0000000..9b06e68 --- /dev/null +++ b/src/abilities/aether/CycloneAbility.js @@ -0,0 +1,785 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Tube, TubePath } from '../../vfx/Tube.js'; +import { FilamentPaths, filamentLook } from '../../vfx/FilamentPaths.js'; +import { Swarm, Silhouette, LeadPath, swarmParams } from '../../vfx/Swarm.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** Hard ceilings per ribbon role. The editor's sliders clamp here. */ +const MAX_WALL = 18; +const MAX_INTAKE = 12; +/** Samples along one ribbon. Above this a kink is finer than two nodes and only aliases. */ +const RIBBON_NODES = 64; +/** Agents the flock's buffer is built with. `swarmCount` clamps here. */ +const SWARM_CAPACITY = 192; + +/** + * How many bearings one frame's dust is split between. + * + * A vortex sheds off its whole circumference, and emitting a frame's worth from + * a single bearing reads as a puff being thrown out sideways — the same mistake + * the bolt makes if all its sparks leave from one point on the filament. + */ +const RING_BATCHES = 4; + +const TAU = Math.PI * 2; + +/* Module-scope scratch — I3. Nothing below allocates on a frame. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _a = new Vector3(); +const _b = new Vector3(); +/** Filled from settings every frame; the modules read canonical names off these. */ +const _look = filamentLook(); +const _flock = swarmParams(); +const _ground = groundFieldParams(); + +/** + * CYCLONE — a vortex touching down on the aimed circle. + * + * Four beats over three phases: + * + * 1. **travel** — a whorl of dust starts turning over on the target circle + * while the cast is still going out. The funnel does not exist yet; the + * scour is inking itself and the ground ribbons are already crawling in. + * 2. **touchdown** — the first `spinUp` seconds of the impact phase: the + * column stands up out of the whorl, from a thread to its full profile. + * 3. **hold** — the rest of `lifetime`: it grinds, precesses, throws debris + * up the wall and drags grit in across the floor. + * 4. **rope-out** — `fadeTime`: the foot *lifts*, climbing to `ropeLift` of + * the height while the column thins to a thread, so the thing dies from + * the bottom upward and the mouth is the last of it to go. Every dying + * tornado does this and no fade-to-zero has ever looked like it. + * + * **THE TRICK — the funnel profile is one function.** `vfx/Tube.js` in `FUNNEL` + * mode publishes `radiusAt(tau)`, the real vortex profile: a tight throat, a + * skirt flaring to the floor, a mouth flaring to the top. Nothing in this file + * writes down a radius. The wall ribbons spiral between `radiusAt(0)` and + * `radiusAt(ribbonTop)`; the flock orbits `radiusAt(swarmRide) × swarmHug`; the + * dust is emitted on a ring of `radiusAt(0)`; the debris leaves the wall at + * `radiusAt(u)` for whatever height it was picked up at; the ground scour is + * `radiusAt(0) × scourReach`. Drag `funnelSkirtFlare` with the clock stopped + * and all five move together, because there is only one of them. + * + * The first version had the scour on its own `scourRadius` slider and the dust + * ring on a third. They were never the same number twice — every profile tweak + * left the grooves either buried under the skirt or ringing clean floor a metre + * outside it, and the ability read as three effects standing in the same place. + * That is the snare's `zoneRadius` lesson applied to a *curve*: the shared value + * is not a scalar here, it is a function of height, and it is still shared. + * + * The one measurement that is deliberately **not** the profile is `zoneRadius`. + * That is the aim circle, and what the circle promises is the floor the vortex + * is drawing air off — so it drives the intake ribbons crawling in and the + * radius the grit is picked up from, both of which are wider than the foot. A + * tornado seen from above is a small dark disc inside a much larger disturbed + * one, and those are the two numbers. + * + * A cast captures exactly two things: `_seed`, a unitless dice roll so no two + * touchdowns draw the same spiral, and `_yawRoll`, a 0..1 fraction that turns + * the scour so the grooves do not always face the same way. Everything with a + * unit is resolved from `settings.cyclone` inside the update loop, on a + * zero-length frame included. + */ +export class CycloneAbility extends Ability { + constructor(context) { + super('cyclone', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the column: 3 draw calls, and the profile everything reads --- */ + this.funnel = new Tube({ + path: TubePath.FUNNEL, + prefix: 'funnel', + // A vortex is smooth along its length and detailed around it: the sway + // and the mouth flare are low-frequency, but the barrel needs facets or + // the silhouette polygonises the moment the camera comes close. + nodes: 72, + sides: 34, + renderOrder: 10 + }); + this.group.add(this.funnel.group); + + /* --- the ribbons: 2 draw calls for both roles --- */ + this.paths = new FilamentPaths(this.group, { + samples: RIBBON_NODES, + capacity: MAX_WALL + MAX_INTAKE, + renderOrder: 12 + }); + + /* --- the flock: 1 draw call --- */ + this.swarm = new Swarm(this.group, { + capacity: SWARM_CAPACITY, + // Leaves, not birds: torn debris tumbles and curls, it does not flap with + // intent. The silhouette is what stops the flock reading as wildlife. + silhouette: Silhouette.LEAF, + additive: false, + renderOrder: 13 + }); + + /* --- the floor: 1 draw call --- */ + this.scour = new GroundField(this.group, { + mode: GroundMode.SCOUR, + additive: false, + depthTest: true, + name: 'CycloneScour' + }); + + /** Unitless dice rolls, re-rolled per cast. The only things a cast keeps. */ + this._seed = 0; + this._yawRoll = 0; + + /** + * Scratch state handed to the tube each frame. One object, reused — syncing + * the column allocates nothing. + */ + this._state = { + origin: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 1, + fade: 1, + widthFade: 1, + seed: 0, + time: 0 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // The skirt. Non-additive so the vortex genuinely occludes the floor behind + // it — an additive dust cloud is a light source, and dust is not one. + // `swirl` is the reason this system exists rather than reusing shared.smoke: + // the particle shader orbits each grain about the anchor it was given, so + // the skirt turns with the funnel instead of drifting off it. + this.dust = particles.get('cyclone.dust', { + capacity: 2400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + swirl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 0.7; + this.dust.uniforms.uEndSize.value = 2.6; + this.dust.uniforms.uSizeIn.value = 0.14; + this.dust.uniforms.uFadeIn.value = 0.18; + this.dust.uniforms.uFadeOut.value = 0.42; + + // Chips carried up the wall. Lit rather than emissive — they are rock. + this.debris = particles.get('cyclone.debris', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + swirl: true, + softFade: 0.3 + }); + this.debris.uniforms.uDrag.value = 0.35; + this.debris.uniforms.uEndSize.value = 0.9; + this.debris.uniforms.uFadeOut.value = 0.7; + + // Grit streaming in across the floor. Velocity-stretched, so the intake + // reads as a direction rather than as a scatter. + this.grit = particles.get('cyclone.grit', { + capacity: 2000, + shape: ParticleShape.STREAK, + additive: false, + stretch: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 1.1; + this.grit.uniforms.uEndSize.value = 0.4; + this.grit.uniforms.uSizeIn.value = 0.04; + this.grit.uniforms.uFadeIn.value = 0.05; + this.grit.uniforms.uFadeOut.value = 0.45; + + this.dustEmitter = new RateEmitter(); + this.debrisEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.paths.liveCount + this.swarm.count; + } + + /** The funnel stands for `lifetime`, then ropes out over `fadeTime`. */ + get impactDuration() { + return Math.max(0.05, settings.cyclone.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.cyclone.fadeTime); + } + + /** + * Dust breathes; it does not gutter. + * + * A raised cosine rather than the base class's beat of two sines, because a + * vortex is one slow pressure cycle and the interference pattern reads as a + * flicker at this intensity. + */ + lightShimmer() { + const c = settings.cyclone; + return 1 - c.lightShimmer * 0.5 * (1 - Math.cos(this.age * c.lightShimmerSpeed * TAU)); + } + + /* ------------------------------------------------------------------ */ + /* The beats — pure functions of the clock against live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the vortex stands: the far end of the aimed line, on the floor. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** 0 while the cast travels, 1 once the column is fully up. */ + _standAmount() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.FADE) return 1; + return Easing.outCubic(saturate(this.impactTime / Math.max(0.01, settings.cyclone.spinUp))); + } + + /** 0 while it stands, 1 when it has roped out entirely. */ + _ropeAmount() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / this.fadeDuration); + } + + /** + * Metres the foot floats at. + * + * This is the whole of the dissipation. The column keeps its mouth where it + * was and lifts its *base*, so the profile's skirt travels up the shaft and + * the thing unhooks from the floor from the bottom. The first version faded + * `fade` to zero instead and the tornado simply went transparent, which reads + * as a bug rather than as weather. + */ + _baseHeight() { + const c = settings.cyclone; + return lerp(c.baseHeight, c.height * c.ropeLift, Easing.inQuad(this._ropeAmount())); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.debrisEmitter.reset(); + this.gritEmitter.reset(); + + // The two things a cast captures, both unitless. + this._seed = Math.random() * 100; + this._yawRoll = Math.random(); + + this.swarm.roll(this._seed); + this.scour.setVisible(true); + this._sync(1); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve every metre from `settings.cyclone` and push it into the four + * modules and the three particle systems. + * + * Order matters exactly once: the tube is synced **first**, because every + * `radiusAt()` and `pointAt()` below reads the dimensions that sync resolved. + * Query it before syncing it and you get last frame's vortex, which is only + * visible when a slider moves — the worst kind of stale. + * + * @param {number} fade 1 while the funnel stands, ramping to 0 as it ropes out + */ + _sync(fade) { + const c = settings.cyclone; + const g = settings.global; + const stand = this._standAmount(); + const rope = this._ropeAmount(); + const base = this._baseHeight(); + + this._centrePoint(_centre); + + /* --- 1 · the column ------------------------------------------------ */ + const state = this._state; + state.origin.set(_centre.x, base, _centre.z); + // The mouth stays where it was put; only the foot climbs. + state.target.set(_centre.x, Math.max(base + 0.25, c.height), _centre.z); + state.side.copy(this.side); + state.progress = 1; + state.fade = fade * stand; + // Two collapses on one number: the touchdown thread on the way up and the + // rope on the way out. Both are widths, so both belong here rather than in + // the profile — dragging the profile while it is roping out still works. + state.widthFade = lerp(c.touchTaper, 1, stand) * lerp(1, c.ropeTaper, Easing.inQuad(rope)); + state.seed = this._seed; + state.time = this.age; + this.funnel.sync(c, state, g); + this.funnel.visible = state.fade > 0.002; + + /* --- 2 · the ribbons, placed against the profile ------------------- */ + // Everything from here down asks the tube where it is. No radius below is + // computed from a settings key. + const wallTop = Math.max(0.05, c.ribbonTop); + const footRadius = this.funnel.radiusAt(0); + const wall = this.paths.role(0); + this.funnel.pointAt(0, _a); + this.funnel.pointAt(wallTop, _b); + wall.spiralIn( + _a, + _b, + footRadius * c.ribbonHug, + this.funnel.radiusAt(wallTop) * c.ribbonHug, + c.ribbonTurns, + c.ribbonSpin, + c.ribbonCurve, + c.ribbonPhase, + c.ribbonWobble + ); + wall.style(1, 1, c.ribbonDim, 1).ends(1, 1, 1, 1).draw(2, 0.12, -1e4, 0); + wall.count = Math.round(clampCount(c.wallRibbons, MAX_WALL) * stand); + + // The intake crawls in from the aim circle to the foot, and it is the one + // role that touches the floor, so it gets the ground damping and a clamp. + const intake = this.paths.role(1); + const floor = base + c.intakeFloor; + _a.set(_centre.x, floor + Math.max(0.05, c.intakeLift), _centre.z); + _b.set(_centre.x, floor, _centre.z); + intake.spiralIn( + _a, + _b, + Math.max(footRadius, c.zoneRadius), + footRadius, + c.intakeTurns, + c.intakeSpin, + c.intakeCurve, + c.intakePhase, + c.intakeWobble + ); + intake.style(1, 1, c.intakeDim, c.intakeDamp).ends(1, 1, 1, 1).draw(2, 0.2, floor, 0); + // The intake belongs to the *ground*: it is drawn while the whorl is still + // gathering and retired as the foot leaves the floor, which is the read + // that says the vortex has let go. + intake.count = Math.round(clampCount(c.intakeArms, MAX_INTAKE) * (1 - Easing.outQuad(rope))); + + _look.width = c.ribbonWidth; + _look.glowWidth = c.ribbonGlowWidth; + _look.glowOpacity = c.ribbonGlowOpacity; + _look.jitter = c.ribbonJitter; + _look.jitterScale = c.ribbonJitterScale; + _look.octaves = c.ribbonOctaves; + _look.jitterFalloff = c.ribbonJitterFalloff; + _look.crawl = c.ribbonCrawl; + _look.pinch = c.ribbonPinch; + _look.restrike = c.ribbonRestrike; + _look.flicker = c.ribbonFlicker; + _look.flickerSpeed = c.ribbonFlickerSpeed; + _look.strandFlash = c.ribbonStrandFlash; + _look.coreSharp = c.ribbonCoreSharp; + _look.glowFalloff = c.ribbonGlowFalloff; + _look.softFade = c.ribbonSoftFade; + _look.opacity = c.ribbonOpacity; + _look.glow = c.ribbonGlow; + _look.colorCore = c.colorRibbonCore; + _look.colorInner = c.colorRibbonInner; + _look.colorOuter = c.colorRibbonOuter; + _look.colorHalo = c.colorRibbonHalo; + _look.randomness = g.randomness; + _look.noiseStrength = g.noiseStrength; + _look.noiseFrequency = g.noiseFrequency; + _look.noiseSpeed = g.noiseSpeed; + _look.opacityScale = g.opacity; + _look.glowScale = g.glow; + this.paths.sync(_look, fade, this._seed); + + /* --- 3 · the flock, also placed against the profile ---------------- */ + const ride = saturate(c.swarmRide); + _flock.count = c.swarmCount; + _flock.leadMode = LeadPath.ORBIT; + // A parameter, not a position: `leadS` is the cast's own clock times a live + // rate, so dragging `swarmRate` while paused moves the flock round. + _flock.leadS = this.age * c.swarmRate; + _flock.leadRate = c.swarmRate; + _flock.orbitTurns = c.swarmTurns; + _flock.orbitRadius = this.funnel.radiusAt(ride) * c.swarmHug; + _flock.orbitHeight = this.funnel.pointAt(ride, _a).y; + _flock.endHeight = 0; + _flock.latticeX = c.swarmLatticeX; + _flock.latticeY = c.swarmLatticeY; + _flock.latticeZ = c.swarmLatticeZ; + _flock.spacingSide = c.swarmSpacingSide; + _flock.spacingUp = c.swarmSpacingUp; + _flock.lag = c.swarmLag; + _flock.jitter = c.swarmJitter; + _flock.churn = c.swarmChurn; + _flock.breathe = c.swarmBreathe; + _flock.breatheRate = c.swarmBreatheRate; + _flock.wander = c.swarmWander; + _flock.wanderScale = c.swarmWanderScale; + _flock.wanderSpeed = c.swarmWanderSpeed; + _flock.gather = c.swarmGather; + _flock.size = c.swarmSize; + _flock.aspect = c.swarmAspect; + _flock.sizeJitter = c.swarmSizeJitter; + _flock.billboard = c.swarmBillboard; + _flock.bank = c.swarmBank; + _flock.bankMax = c.swarmBankMax; + _flock.dihedral = c.swarmDihedral; + _flock.flapRate = c.swarmFlapRate; + _flock.curl = c.swarmCurl; + _flock.edgeStretch = c.swarmEdgeStretch; + _flock.edgeGain = c.swarmEdgeGain; + // Debris is picked up as the column stands and dropped as it lets go. + _flock.reveal = stand * (1 - Easing.inQuad(rope)); + _flock.revealSpread = c.swarmRevealSpread; + _flock.silhouette = Silhouette.LEAF; + _flock.lit = c.swarmLit; + _flock.tint = c.swarmTint; + _flock.tintJitter = c.swarmTintJitter; + _flock.tintAlong = c.swarmTintAlong; + _flock.opacity = c.swarmOpacity * g.opacity * fade; + _flock.glow = c.swarmGlow * g.glow; + _flock.softFade = c.swarmSoftFade; + this.swarm.setBasis(this.origin, this.direction, this.side, this.length); + this.swarm.setColors(c.colorSwarmA, c.colorSwarmB, c.colorSwarmC, c.colorSwarmD); + this.swarm.update(this.age, _flock); + + /* --- 4 · the floor, the last consumer of the same radius ----------- */ + _ground.centre = _centre; + _ground.yaw = this._yawRoll * TAU; + _ground.radius = footRadius * c.scourReach; + _ground.height = c.scourHeight; + // The grooves are cut as the whorl arrives and weather out from the outside + // in once the vortex has gone: `grow` is the front, `recede` eats it back. + _ground.grow = this.phase === AbilityPhase.TRAVEL ? Easing.outQuad(this.u) : 1; + _ground.recede = Easing.inQuad(rope) * 0.85; + _ground.fade = 1; + _ground.seed = this._seed; + _ground.edge = c.scourEdge; + _ground.ragged = c.scourRagged; + _ground.raggedScale = c.scourRaggedScale; + _ground.warp = c.scourWarp; + _ground.relief = c.scourRelief; + _ground.normalStep = c.scourNormalStep; + _ground.ambient = c.scourAmbient; + _ground.wrap = c.scourWrap; + _ground.specular = c.scourSpecular; + _ground.gloss = c.scourGloss; + _ground.parallax = c.scourParallax; + _ground.depth = c.scourDepth; + _ground.lift = c.scourLift; + _ground.sharp = c.scourSharp; + _ground.detail = c.scourDetail; + _ground.swirl = c.scourSwirl; + _ground.arms = c.scourArms; + _ground.speed = c.scourSpin; + _ground.additive = c.scourAdditive; + _ground.emissive = c.scourEmissive; + _ground.opacity = c.scourOpacity; + _ground.depthFade = c.scourDepthFade; + _ground.colorBase = c.colorScourBase; + _ground.colorEdge = c.colorScourEdge; + _ground.colorGlow = c.colorScourGlow; + _ground.colorDeep = c.colorScourDeep; + _ground.noiseStrength = g.noiseStrength; + _ground.noiseFrequency = g.noiseFrequency; + _ground.noiseSpeed = g.noiseSpeed; + _ground.opacityScale = g.opacity; + this.scour.update(_ground); + + /* --- 5 · the particle systems -------------------------------------- */ + const whorling = this.phase === AbilityPhase.TRAVEL; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, whorling ? c.whorlRise : c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + // The whorl is wound tighter and faster than the standing skirt — it is the + // air being spun up, and it has not been thrown outward yet. + this.dust.uniforms.uSwirl.value = whorling ? c.whorlSwirl : c.dustSwirl; + this.dust.uniforms.uSwirlExpand.value = c.dustSwirlExpand; + + this.debris.setGradient( + getColor(c.colorDebrisA), + getColor(c.colorDebrisB), + getColor(c.colorDebrisC), + getColor(c.colorDebrisD) + ); + this.debris.uniforms.uGravity.value.set(0, c.debrisGravity, 0); + this.debris.uniforms.uSizeScale.value = c.debrisSize * g.particleSize * 7; + this.debris.uniforms.uLifeScale.value = c.debrisLifetime * 0.5 * g.particleLifetime; + this.debris.uniforms.uSpeedScale.value = g.particleSpeed; + this.debris.uniforms.uOpacity.value = g.opacity; + this.debris.uniforms.uSwirl.value = c.debrisSwirl; + this.debris.uniforms.uSwirlExpand.value = c.debrisSwirlExpand; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritRise, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = c.gritLifetime * 0.5 * g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + this.grit.uniforms.uStretch.value = c.gritStretch; + this.grit.uniforms.uTurbulence.value = c.gritTurbulence * g.turbulence; + } + + /** + * A point on the circle of radius `radius` about the vortex axis, at world + * height `y`, on the bearing `angle`. + * + * The two basis vectors are the cast's own `side` and `direction`, so the + * ring is flat on the floor whatever way the caster is facing. + */ + _ringPoint(radius, y, angle, out) { + out.copy(_centre); + out.addScaledVector(this.side, Math.cos(angle) * radius); + out.addScaledVector(this.direction, Math.sin(angle) * radius); + out.y = y; + return out; + } + + /** + * Dust, debris and grit, all three emitted onto the profile. + * + * @param {number} dt + * @param {number} scale 0..1 — thinned while the column is still standing up + */ + _vortexFx(dt, scale) { + const c = settings.cyclone; + const g = settings.global; + const time = frame.uTime.value; + const base = this._baseHeight(); + const whorling = this.phase === AbilityPhase.TRAVEL; + const skirt = this.funnel.radiusAt(0); + + /* --- the skirt (or, while travelling, the whorl) --- */ + const dustRate = whorling ? c.whorlRate : c.dustRate; + let dustCount = Math.round(this.dustEmitter.tick(dt, dustRate * scale) * g.particleCount); + if (dustCount > 0) { + // The anchor is the point on the *axis* the grain orbits; the start + // position is out on the skirt. The particle shader rotates the offset + // between them, so the ring turns with the funnel without the CPU + // touching a single grain after it is emitted. + _emit.anchor = _a.set(_centre.x, base + 0.05, _centre.z); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = whorling ? c.dustSpeed * 0.35 : c.dustSpeed; + _emit.speedVariance = 0.6; + _emit.spread = 0.35; + _emit.inherit = null; + _emit.size = 1; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = time; + + const batches = Math.min(dustCount, RING_BATCHES); + const per = Math.ceil(dustCount / batches); + while (dustCount > 0) { + // Whorling: the dust is still being gathered, so it comes off the whole + // disc rather than off a ring that does not exist yet. + const r = whorling ? skirt * randRange(0.2, 1.15) : skirt * randRange(0.85, 1.15); + this._ringPoint(r, base + randRange(0.02, 0.5), randRange(0, TAU), _pos); + _emit.position = _pos; + _emit.radius = skirt * 0.12; + this.dust.emit(Math.min(per, dustCount), _emit); + dustCount -= per; + } + } + + if (whorling) return; + + /* --- chips torn off and carried up the wall --- */ + const debrisCount = Math.round(this.debrisEmitter.tick(dt, c.debrisRate * scale) * g.particleCount); + if (debrisCount > 0) { + // Picked up at a random height and thrown from the wall *at that height* + // — which is the profile again, sampled where the chip actually is. + const u = saturate(Math.random() * c.ribbonTop); + const wallRadius = this.funnel.radiusAt(u); + this.funnel.pointAt(u, _a); + this._ringPoint(wallRadius, _a.y, randRange(0, TAU), _pos); + _emit.position = _pos; + _emit.anchor = _a; + _emit.radius = wallRadius * 0.1; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.debrisSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.5; + _emit.inherit = null; + _emit.size = 1; + _emit.sizeVariance = 0.7; + _emit.life = c.debrisLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.debris.emit(debrisCount, _emit); + } + + /* --- grit dragged in across the floor, from the aim circle --- */ + let gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount); + if (gritCount > 0) { + _emit.anchor = null; + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.5; + _emit.spread = 0.22; + _emit.inherit = null; + _emit.size = 1; + _emit.sizeVariance = 0.6; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + + const batches = Math.min(gritCount, RING_BATCHES); + const per = Math.ceil(gritCount / batches); + while (gritCount > 0) { + const angle = randRange(0, TAU); + const r = randRange(skirt, Math.max(skirt + 0.1, c.zoneRadius)); + this._ringPoint(r, c.intakeFloor + randRange(0, 0.15), angle, _pos); + _emit.position = _pos; + _emit.radius = 0.2; + // Inward and slightly across, which is what gives the intake its curl + // rather than making it a set of spokes pointing at the middle. + _dir.copy(_centre).sub(_pos).setY(0.35).normalize(); + _dir.addScaledVector(this.side, -Math.sin(angle) * 0.55); + _dir.addScaledVector(this.direction, Math.cos(angle) * 0.55); + _emit.direction = _dir.normalize(); + this.grit.emit(Math.min(per, gritCount), _emit); + gritCount -= per; + } + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + this._vortexFx(dt, 1); + } + + onImpact() { + const c = settings.cyclone; + const g = settings.global; + + this._centrePoint(_centre); + _pos.set(_centre.x, c.baseHeight + this.funnel.radiusAt(0) * 0.35, _centre.z); + + // The pressure shell at touchdown. Sized off the foot, like everything + // else. `EARTH` rather than `AIR`: what leaves the ground here is a dense + // ball of dust, and the thin pressure shell reads as a shockwave, which is + // a different event happening a metre higher up. + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: this.funnel.radiusAt(0) * 0.5, + endRadius: c.touchSize * g.explosionIntensity, + life: 0.9, + intensity: c.touchIntensity, + opacity: 0.65, + fresnel: 1.4, + displace: 0.7, + squash: 0.55, + colorA: getColor(c.colorTouchA), + colorB: getColor(c.colorTouchB), + colorC: getColor(c.colorTouchC) + }); + + // A hard slug of grit thrown off the ring the instant the foot bites. + _emit.position = _pos; + _emit.anchor = null; + _emit.radius = this.funnel.radiusAt(0) * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed * 1.4; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.size = 1.3; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime * 1.6; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grit.emit(Math.round(140 * g.particleCount), _emit); + + this.ctx.shake.add( + c.touchShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorTouchFlash), c.touchFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.cyclone; + // `t` runs 0..1 while it stands, then 1..2 while it ropes out. The column's + // own collapse is in `widthFade` and the rising foot, so the master fade + // only has to take the last of the light out at the very end. + const rope = t <= 1 ? 0 : saturate(t - 1); + this._sync(1 - Easing.inCubic(rope) * 0.9); + + // The light rides the column rather than the floor, and climbs with the + // foot as it ropes out. + this.funnel.pointAt(saturate(c.lightRide), this.position); + + this._vortexFx(dt, this._standAmount() * (1 - rope * 0.7)); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake * (1 - rope), dt); + } + + onDestroy() { + this.paths.clear(); + this.swarm.reset(); + this.scour.setVisible(false); + this.funnel.visible = false; + } + + dispose() { + this.funnel.dispose(); + this.paths.dispose(); + this.swarm.dispose(); + this.scour.dispose(); + super.dispose(); + } +} + +/** Whole filaments, never more than the buffer holds. */ +function clampCount(value, max) { + return Math.max(0, Math.min(max, Math.round(value))); +} diff --git a/src/abilities/aether/FeatherfallAbility.js b/src/abilities/aether/FeatherfallAbility.js new file mode 100644 index 0000000..867d9a9 --- /dev/null +++ b/src/abilities/aether/FeatherfallAbility.js @@ -0,0 +1,342 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { createFeatherGeometry, createFeatherMaterial } from '../../materials/FeatherMaterial.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, Easing } from '../../utils/math.js'; + +/** + * Hard ceiling on feathers in one cast. The `count` slider clamps here. + * + * 256 is not a budget number, it is a legibility one: the flock covers a disc + * about ten metres across, and past roughly two hundred the individual + * descents stop being separable and the whole thing reads as snow. The trick + * of this ability is that you can pick one feather and watch it, so the cap is + * set where that is still true. + */ +const MAX_FEATHERS = 256; + +const TAU = Math.PI * 2; + +/* Module-scope scratch — I3. Nothing in the frame path allocates. */ +const _centre = new Vector3(); +const _pos = new Vector3(); +const _dir = new Vector3(); +const _emit = {}; + +/** + * FEATHERFALL — a slow rain of feathers over the circle. + * + * **THE TRICK: they do not fall, they *fly badly*.** A thin plate dropped + * through air is aerodynamically unstable, and it is unstable in two distinct + * ways: it either **flutters** — glide, stall, flip, glide back — or it + * **tumbles**, end over end, drifting steadily to the side it is rotating + * toward. Both regimes are implemented as real closed-form flight models in + * `materials/FeatherMaterial.js`, both are in the air at once, and which one a + * feather is in is a per-instance dice roll against `tumbleShare`. On top of + * that every feather gets exactly one **catch** — a gust, at its own moment, + * that lifts it and lets it go again. That is what "no two descend the same + * way" has to mean to be worth saying: not noise on one motion, two motions + * and an event. + * + * The physics that matters is one line, and it is the reason this looks + * different from anything else in the sandbox that falls: vertical speed is + * `sink · (1 − lift · cos²φ)`, where `cos φ` is the horizontal velocity of the + * glide. A plate makes lift when it is moving sideways, so it falls *slowest* + * in the middle of a glide and *fastest* at the stall where the sideways + * motion reverses. Watch one feather and it hesitates twice a second. Take + * `lift` to zero and the same feather drops like a wet leaflet, on the same + * path. + * + * ### Why this is not `Swarm(LEAF)` + * + * `vfx/Swarm.js` was the obvious answer and it is the wrong shape of thing. + * A swarm coheres to a moving lead, separates on a hash lattice and banks into + * its turns — it is a *flocking* model, and flocking is agents reacting to + * each other. Feathers do not react to each other. Every one of these is + * alone in the air with its own aerodynamics, and the interesting structure + * comes out of the fact that two identical feathers released a metre apart + * land four metres apart. Building that on top of a flock would mean turning + * off cohesion, separation and banking and then adding a flight model to what + * was left, which is a different module wearing a `Swarm`. + * + * ### There is no impact + * + * The base class still runs a front out to the circle, because that is what a + * zone cast does and it is what the aim indicator has promised. But nothing + * happens when it arrives: no burst, no decal, no shake, no flash, not even a + * change of rate. The feathers were already coming down before the front got + * there and they go on coming down for another eight seconds. This is the + * longest cast in the sandbox and the quietest, and both of those are the + * point. + * + * ### Invariants + * + * One draw call for the whole flock — one `InstancedBufferGeometry`, 190 + * instances, every vertex placed by the vertex shader. A cast captures exactly + * `count × 4` unitless dice and nothing else: not a metre, not a radian, not a + * second. Position is a closed-form function of `(age − release, seed)` + * against live uniforms, so pausing with **P** and dragging `lift`, `swing` or + * `cup` re-flies and re-shapes two hundred feathers that are already halfway + * down. An Euler integrator physically could not. + */ +export class FeatherfallAbility extends Ability { + constructor(context) { + super('featherfall', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.geometry = createFeatherGeometry(MAX_FEATHERS); + this.material = createFeatherMaterial(this.ctx.environment); + + this.mesh = new Mesh(this.geometry, this.material); + // Both the mesh and the ability's group are held at identity, because the + // vertex shader authors world positions directly off `uOrigin`. Moving + // either one would move the flock twice. + this.mesh.matrixAutoUpdate = false; + this.mesh.frustumCulled = false; + this.mesh.layers.set(LAYER.WORLD); + this.mesh.renderOrder = 4; + this.mesh.receiveShadow = true; + // No shadow casting. The depth pass draws the *unpatched* geometry — a + // flat unit sheet sitting at the world origin — so a shadow-casting flock + // is two hundred coincident squares of darkness under the caster's feet. + // Fixing that means patching the depth material too, and the feathers are + // thin, backlit and translucent: they have very little shadow to give. + this.mesh.castShadow = false; + this.group.add(this.mesh); + + /** The dice. `count × 4` unitless numbers, rolled once per cast. */ + this._seeds = this.geometry.getAttribute('aSeed'); + this._live = 0; + } + + createParticles() { + /** + * Down. Not feathers, and not many: the specks that come off a feather in + * the air. Non-additive, because nothing in this ability glows, and drawn + * with a slight *negative* rise so they sink alongside the flock rather + * than smoking up out of it. + */ + this.down = this.ctx.particles.get('featherfall.down', { + capacity: 900, + shape: ParticleShape.SOFT, + additive: false, + curl: true, + softFade: 0.5 + }); + this.down.uniforms.uDrag.value = 2.6; + this.down.uniforms.uEndSize.value = 1.1; + this.down.uniforms.uSizeIn.value = 0.12; + this.down.uniforms.uFadeIn.value = 0.2; + this.down.uniforms.uFadeOut.value = 0.55; + + this.downEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** + * The descent window. This is the one duration in the sandbox that is + * *derived from a physical model* rather than authored to taste — see the + * arithmetic in the settings block. Too short and feathers wink out in + * mid-air, which is the only way to break this slot. + */ + get impactDuration() { + return Math.max(0.1, settings.featherfall.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.1, settings.featherfall.fadeTime); + } + + get instanceCount() { + return this._live; + } + + /** Nothing here burns. A slow breath, well under the ice glint. */ + lightShimmer() { + return 0.92 + 0.08 * Math.sin(this.age * 1.7); + } + + /* ------------------------------------------------------------------ */ + /* Geometry of the flock — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The centre of the circle the flock comes down over. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** + * Seconds since the flock was released. + * + * The clock starts at the cast, not at the arrival of the front: the first + * feathers are already in the air over the circle while the cast is still + * travelling, which is what stops the zone reading as "something appeared". + */ + get _fallAge() { + return this.age; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.downEmitter.reset(); + + const count = Math.max(1, Math.min(MAX_FEATHERS, Math.round(settings.featherfall.count))); + this._live = count; + + // The only dice the cast rolls, and the only thing it captures. Four per + // feather; the shader hashes four more out of them, which is cheaper than + // uploading eight and just as decorrelated. + const seeds = this._seeds.array; + for (let i = 0; i < count * 4; i++) seeds[i] = Math.random(); + this._seeds.needsUpdate = true; + this.geometry.instanceCount = count; + + // Sync before the first frame is drawn, or the flock stands for one frame + // over last cast's circle. + this._sync(1); + } + + /* ------------------------------------------------------------------ */ + /* The one sync */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the descent clock into the flock and the down. + * Everything with a unit is read here and nowhere else. + * + * @param {number} fade 1 while the feathers are lit, ramping to 0 at the end + */ + _sync(fade) { + const c = settings.featherfall; + const g = settings.global; + + this._centrePoint(_centre); + this.material.userData.sync(_centre, this._fallAge, fade); + + // Clamped every frame rather than at spawn, so dragging `count` down while + // paused thins a flock that is already falling. Dragging it *up* cannot add + // feathers mid-cast — their dice were never rolled — so the cap here is the + // number that was. + const live = Math.max(1, Math.min(this._live, Math.round(c.count))); + this.geometry.instanceCount = live; + + this.down.setGradient( + getColor(c.colorDownA), + getColor(c.colorDownB), + getColor(c.colorDownC), + getColor(c.colorDownD) + ); + this.down.uniforms.uGravity.value.set(0, c.downRise, 0); + this.down.uniforms.uSizeScale.value = c.downSize * g.particleSize * 7; + this.down.uniforms.uLifeScale.value = c.downLifetime * 0.5 * g.particleLifetime; + this.down.uniforms.uSpeedScale.value = g.particleSpeed; + this.down.uniforms.uOpacity.value = c.opacity * g.opacity; + this.down.uniforms.uTurbulence.value = c.downTurbulence * g.turbulence; + } + + /** + * Specks shed into the column the flock is coming down through. + * @param {number} scale thinning multiplier as the cast ends + */ + _downFx(dt, scale) { + const c = settings.featherfall; + const g = settings.global; + const count = Math.round(this.downEmitter.tick(dt, c.downRate * scale) * g.particleCount); + if (count <= 0) return; + + this._centrePoint(_centre); + // Anywhere in the cylinder the feathers occupy: sqrt on the radius so the + // specks are uniform over the *area*, matching the flock's own placement. + const bearing = Math.random() * TAU; + const radius = c.zoneRadius * Math.sqrt(Math.random()); + _pos.set( + _centre.x + Math.cos(bearing) * radius, + c.floorHeight + Math.random() * c.ceiling, + _centre.z + Math.sin(bearing) * radius + ); + + _emit.position = _pos; + _emit.radius = c.featherSize; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.downSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.06; + _emit.sizeVariance = 0.7; + _emit.life = c.downLifetime; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.down.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + // The light sits over the middle of the circle at half the release height + // for the whole cast — it is lighting a volume, not following a front. + this._lightPoint(); + this._downFx(dt, 1); + } + + onImpact() { + // Nothing. There is no impact — see the class comment. The front reaching + // the circle is not an event in this ability and it deliberately does not + // become one. + } + + onFade(dt, t) { + // `t` runs 0..1 through the descent and 1..2 while the settled feathers go. + // The blow-out is quadratic rather than cubic: feathers on the floor should + // thin away, not snap out, and a cubic curve holds them at full opacity for + // most of the fade and then drops them. + const fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + this._sync(fade); + this._lightPoint(); + + // The down thins as the last feathers land: there is nothing left in the + // air to shed it. + this._downFx(dt, fade * (t <= 1 ? 0.8 : 0.2)); + } + + /** Park the dynamic light in the middle of the falling column. */ + _lightPoint() { + const c = settings.featherfall; + this._centrePoint(this.position); + this.position.y = c.ceiling * 0.45; + } + + onDestroy() { + this._live = 0; + this.geometry.instanceCount = 0; + this.material.userData.uniforms.uFade.value = 0; + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/aether/MirageAbility.js b/src/abilities/aether/MirageAbility.js new file mode 100644 index 0000000..fbe6ead --- /dev/null +++ b/src/abilities/aether/MirageAbility.js @@ -0,0 +1,337 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GhostRig, TimeRecorder, findCaster } from '../../vfx/TimeControl.js'; +import { createMirageSkinMaterial } from '../../materials/MirageSkinMaterial.js'; +import { LAYER, retainDistortion, releaseDistortion } from '../../core/Layers.js'; +import { settings } from '../../config/settings.js'; +import { saturate, Easing } from '../../utils/math.js'; + +/* ------------------------------------------------------------------ */ +/* Module-scope scratch — I3. Nothing below allocates in a frame. */ +/* ------------------------------------------------------------------ */ + +const _place = new Vector3(); + +/** + * MIRAGE — the caster, made of nothing but refraction. + * + * **THE TRICK: the silhouette is the scene, bent.** The double is not on + * `LAYER.VFX`. There is no emissive surface, no particle, no decal, no shell + * and no light anywhere in this ability; the one mesh it owns writes a + * screen-space offset into the half-resolution distortion target and nothing + * else. What you see is the floor, the caster and every mote behind the figure + * bending around a body-shaped solid. Switch the distortion pass off and the + * cast is an empty frame with a slight camera rumble. + * + * ### It is the caster, not an impression of him + * + * The first version built the figure out of ten `DistortionField(REFRACT)` + * capsules — head, torso, two upper arms, two forearms, two thighs, two shins + * — posed by a hand-written gait solver with sliders for stride, knee flex and + * elbow swing. It was a great deal of machinery for a poor answer. A capsule + * figure is a *mannequin*: its silhouette is smooth in all the places a + * person's is not, two spheres cannot make a shoulder, and a sine-driven arm + * carries none of the weight-shift a real stride does. The eye reads the miss + * immediately and cannot name it, which is the worst way for an effect to be + * wrong. + * + * So the double is the **caster's own skinned mesh**, cloned by `GhostRig` and + * posed from a `TimeRecorder` track — the same machinery Echo Step runs on. + * Every fold of the coat and every swing of the arms is the real animation, + * because it *is* the real animation, and `materials/MirageSkinMaterial.js` + * skins it through the skeleton so the refraction follows the body rather than + * a bind pose. Two hundred lines of gait solver went in the bin and the effect + * got better, which is usually how that trade goes. + * + * ### An adjusted ghost, not a copy + * + * A double that mirrors the caster frame-for-frame reads as a rendering fault + * — the eye pairs the two and sees double-vision, not a second figure. Three + * adjustments break the pairing, and all three are sliders: + * + * - **`poseDelay`** — the double replays the caster's pose from that many + * seconds ago, so when the caster is mid-throw the double is still winding + * up. This is the one that does most of the work. + * - **`figureScale`** — a per cent or two off life size is below the + * threshold of "that is a smaller person" and above the threshold of "those + * are the same person". + * - **the line itself** — the double leaves on the aimed heading and the + * caster does not follow it. + * + * ### And you lose it the moment it stops + * + * That is the second half of the trick and it is what stops this being "a + * ghost". Visibility is `motion^stopPower`, where `motion` is how fast the + * double is actually travelling — legible at a sprint, marginal at a jog, and + * *gone* while it is still coasting to a halt. It never arrives anywhere. + * There is no impact beat, no flash and no shake: the last thing you saw was + * already not there, and if you were tracking it you spend a second looking at + * the spot where it should have finished. + * + * The halt is one curve used twice, which is the only reason it does not slide + * its feet. Speed decays as `(1 − x)²` over `haltTime`; the distance that + * curve covers is its own integral, `1 − (1 − x)³`, which is `Easing.outCubic` + * — so `haltCoast` metres is *exactly* how far the double carries past the + * target. The pose is playback rather than a distance-driven gait, so the feet + * cannot skate no matter what the run does. + * + * ### Invariants + * + * A cast captures nothing but the clock. The clone is built once, on the first + * cast that finds a character in the scene (the FBX loads asynchronously, so + * construction is too early to look), and reused by every cast after it — + * `GhostRig#setSource` is the one allocating call in this file and it is not on + * the per-cast path. Every metre, radian and second is resolved from + * `settings.mirage` inside `_sync()`, which runs on a zero-length frame too: + * pause with **P** mid-run and drag `poseDelay`, and the standing double + * re-poses without the clock moving. + */ +export class MirageAbility extends Ability { + constructor(context) { + super('mirage', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * One material, one clone, one draw call per skinned mesh in the rig. + * + * `LAYER.DISTORTION` rather than `LAYER.VFX`: this never reaches the + * beauty pass, so there is no colour to grade and no bloom to guard + * against. `renderOrder` 0 because it is the only thing in the buffer. + */ + this.skin = createMirageSkinMaterial(); + this.rig = new GhostRig(this.group, { + material: this.skin, + layer: LAYER.DISTORTION, + renderOrder: 0 + }); + + /** + * The pose track. Attached to the caster the first time one is found and + * left attached: it records whenever a cast is live and is trimmed to + * `poseWindow`, so a long session does not grow it. + */ + this.recorder = new TimeRecorder(); + + /** True once the clone exists. The FBX may not have loaded yet. */ + this._bound = false; + /** True while this ability is counted against the distortion pass. */ + this._writing = false; + } + + createParticles() { + /** + * None. Deliberately, and it is the single most important line in the + * file: the moment this ability emits one mote it stops being a silhouette + * made of refraction and becomes a ghost with a dust trail, which is a + * different — and much more ordinary — slot. + */ + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** The halt. Long enough that you watch the double *fail* to arrive. */ + get impactDuration() { + return Math.max(0.05, settings.mirage.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.mirage.fadeTime); + } + + /** + * One draw call per skinned mesh in the caster's rig, and nothing instanced. + * The honest HUD readout for that is zero. + */ + get instanceCount() { + return 0; + } + + /* ------------------------------------------------------------------ */ + /* The run — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** + * Metres of ground the double has covered. + * + * During the run this is the base class's front. After it, the halt curve's + * own integral carries it exactly `haltCoast` metres further and stops — see + * the class comment for why the two curves have to be each other's integral. + */ + _travelled() { + const c = settings.mirage; + if (this.phase === AbilityPhase.TRAVEL) return this.front; + const x = saturate(this._sinceArrival() / Math.max(0.02, c.haltTime)); + return this.length + c.haltCoast * Easing.outCubic(x); + } + + /** Seconds since the double drew level with the target. */ + _sinceArrival() { + if (this.phase === AbilityPhase.IMPACT) return this.impactTime; + if (this.phase === AbilityPhase.FADE) return this.impactDuration + this.fadeTime; + return 0; + } + + /** 0..1 of running speed. The whole visibility of the ability hangs off it. */ + _motion() { + const c = settings.mirage; + if (this.phase === AbilityPhase.TRAVEL) return 1; + const x = saturate(this._sinceArrival() / Math.max(0.02, c.haltTime)); + const remaining = 1 - x; + return remaining * remaining; + } + + /** + * Count this ability against the distortion pass exactly once while it is up. + * + * `core/Layers.js#distortionWriters` is a "does anything need this pass" + * counter, not a mesh census, so one retain per live cast is the right + * granularity — and it must be balanced, or `PostProcessing` clears and + * resamples a half-resolution buffer over nothing for the rest of the + * session. + */ + _setWriting(on) { + if (on === this._writing) return; + this._writing = on; + this.rig.visible = on; + if (on) retainDistortion(); + else releaseDistortion(); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + // Build the clone on the first cast that finds a character. The rig is + // loaded asynchronously, so `createShaders` is too early to look, and an + // ability pooled before the FBX resolved would otherwise never see it. + if (!this._bound) { + const caster = findCaster(this.ctx?.scene ?? null); + if (caster && this.rig.setSource(caster)) { + this.recorder.attach(caster); + this._bound = true; + } + } + + // A cast starts with an empty track: the double's first frames should be + // the caster's *first* frames, not the tail of the previous cast. + this.recorder.clear(); + + this._setWriting(this._bound); + // Sync once before the first frame is drawn, or the double stands for one + // frame at last cast's end of the line. + this._sync(0); + } + + /* ------------------------------------------------------------------ */ + /* The one sync */ + /* ------------------------------------------------------------------ */ + + /** + * Record the caster, pose the double, place it, and push the live refraction + * settings. Everything with a unit is read here and nowhere else. + */ + _sync(dt) { + const c = settings.mirage; + const g = settings.global; + const now = this.age; + + // The track and the pose need a rig; the placement and the refraction + // settings do not, and are resolved either way. Gating the whole function + // on `_bound` would leave every slider in the block dead in a scene with + // no character in it — including the headless harness, which is exactly + // where a dead slider should be caught. + if (this._bound) { + /* --- the track --- */ + // Sampling is rate-limited inside the recorder, so calling it every + // frame costs one comparison on the frames it does not want. A + // zero-length frame writes nothing and reads the same instant, which is + // what keeps the paused editor honest. + if (dt > 0) { + this.recorder.sample(now, c); + this.recorder.trim(now, c); + } + + /* --- the pose: the caster, `poseDelay` seconds ago --- */ + // Clamped at zero rather than allowed to go negative: before the delay + // has elapsed there is no history to read, and `poseAt` holds the oldest + // sample, which is the caster standing as he was when the cast began. + this.recorder.poseAt(Math.max(0, now - c.poseDelay), this.rig); + } + + /* --- where it is --- */ + const along = this._travelled(); + _place + .copy(this.origin) + .addScaledVector(this.direction, c.figureLead + along) + .addScaledVector(this.side, c.figureSide); + _place.y = 0; + // Yaw from the cast's own heading, so the double runs the line it was + // aimed down rather than the way the caster happens to be facing. + this.rig.place(_place, Math.atan2(this.direction.x, this.direction.z)); + this.rig.setScale(Math.max(0.05, c.figureScale)); + + /* --- how much of it you get --- */ + const motion = this._motion(); + // The ability, in one line. `stillness` is the escape hatch and it ships + // at zero: at a dead stop there is nothing there at all. + const seen = c.stillness + (1 - c.stillness) * Math.pow(motion, Math.max(0.05, c.stopPower)); + + this.skin.userData.sync(seen); + // The two global multipliers the material's own sync cannot see, applied + // here so an emitter never folds a master gain into its authored strength + // twice. `post.distortion` is applied in the pass and nowhere else. + const u = this.skin.userData.uniforms; + u.uRippleScale.value = c.refractRippleScale * g.noiseFrequency; + u.uRippleSpeed.value = c.refractRippleSpeed * g.noiseSpeed; + + // The light — such as it is — rides the double's chest rather than the + // floor line the base class puts it on. + this.position.copy(_place); + this.position.y = c.figureScale * 1.35; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(dt); + this.ctx.shake.rumble(settings.mirage.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + // Nothing. There is no impact: the double does not arrive, it stops being + // visible while it is still moving and then stops. Every other slot in the + // sandbox spends its budget here; this one spends it on the absence. + } + + onFade(dt, _t) { + this._sync(dt); + // The rumble goes with the speed, so it dies on the same curve the + // silhouette does — you stop feeling it at about the moment you stop + // seeing it. + this.ctx.shake.rumble(settings.mirage.rumble * this._motion() * settings.global.cameraShake, dt); + } + + onDestroy() { + // Releases this ability's one writer against the distortion pass's counter. + // Hiding the group instead would leave it retained for the session. + this._setWriting(false); + this.recorder.clear(); + } + + dispose() { + this._setWriting(false); + this.rig.dispose(); + this.recorder.attach(null); + super.dispose(); + } +} diff --git a/src/abilities/aether/ResonanceAbility.js b/src/abilities/aether/ResonanceAbility.js new file mode 100644 index 0000000..4bf345e --- /dev/null +++ b/src/abilities/aether/ResonanceAbility.js @@ -0,0 +1,729 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Shell, ShellMode, shellKeys, BurstMode } from '../../vfx/Shell.js'; +import { DistortionField, DistortionMode } from '../../vfx/Distortion.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +/** + * Distortion emitters built at construction. Four is the useful number: the + * pockets are placed on every `stride`-th antinode, so four of them span an + * eight-antinode line, and a fifth costs a draw call to sit somewhere a + * neighbour is already shaking. + */ +const MAX_POCKETS = 4; + +/** Instance capacity of the ring train. `chordRings` clamps here. */ +const RING_CAPACITY = 24; + +/* Module-scope scratch — I3. Nothing below allocates on a frame. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _node = new Vector3(); +const _next = new Vector3(); +const _far = new Vector3(); +/** The params object every pocket is driven through, refilled per emitter. */ +const _shock = {}; + +/** + * RESONANCE — a chord struck down the aimed line. + * + * Three beats, one per phase: + * + * 1. **strike** — a front runs out from the caster's hand and a train of + * compression rings follows it. The reflecting end of the run *is* the + * front, so the train shortens and stiffens as the line is drawn: there is + * nothing to interfere with yet, and `reflect` is held at zero, which is + * simply the truth — no wave has come back. + * 2. **ring** — the long hold. The moment the front lands, a wavefront + * leaving the hand starts its journey to the far end; `establishTrips` + * crossings later the standing pattern is fully formed, and it builds over + * exactly that time rather than over a number somebody typed. + * 3. **decay** — the reflection damps. `reflect` falls to zero, so the + * envelope's grip on the train loosens and the nodes smear back into a + * plain outbound line before the whole thing goes out. + * + * **THE TRICK — the nodes are real.** `vfx/Shell.js#RING_TRAIN` folds each ring + * off the far end and multiplies it by the analytic superposition of the + * outbound wave with its reflection, `|sin(k(s − L))| · |cos(ωt − kL)|`. A ring + * standing on a node pinches to the axis and goes dark; a ring on an antinode + * blooms and swells. Nothing in this file draws a node marker. What it does + * instead is *ask*: + * + * - `nodePosition(i)` — where the still places are, so a pocket can be put + * halfway between two of them and nowhere near either; + * - `standingAt(s)` — how hard the air is being worked at that metre, which + * is the pockets' strength, the dust's emission rate and the light's pulse; + * - `resonantSpacing(n)` — the wavelength that fits `halfWaves` half-waves on + * *this* cast, fed straight back into the shell so the pattern stands still + * instead of sliding along the line. + * + * That last one is the difference between "some of the rings are dimmer" and + * "this line is resonating", and it is worth watching the `lockSpacing` slider + * to see it: at 0 the wavelength is whatever `chordSpacing` says and the whole + * pattern crawls; at 1 it snaps to the mode and the dark bands nail themselves + * to the floor. + * + * **The overlay.** Two of the shell's keys are computed rather than authored — + * the locked wavelength and the ring count that tiles it — and one is beaten by + * the ability's clock (`reflect`). The first version did what the library's own + * example does and wrote them straight back into `settings.resonance`; the + * editor's `chordSpacing` slider then snapped back on every frame and a saved + * preset recorded a number the user had never chosen. So the ability keeps one + * object whose prototype **is** the live settings block and writes only those + * three keys onto it: every other key resolves through the prototype, live, and + * the block itself is never written to. + * + * A cast captures a unitless seed and two timestamps (when the front landed, + * when the reflection arrived). Nothing with a unit survives a frame. + */ +export class ResonanceAbility extends Ability { + constructor(context) { + super('resonance', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the train: 1 draw call, however many rings --- */ + this.chord = new Shell({ + mode: ShellMode.RING_TRAIN, + prefix: 'chord', + rings: RING_CAPACITY, + // A compression ring is read entirely from its silhouette, so the facet + // count around it is the only tessellation that matters. + segments: 108, + renderOrder: 14 + }); + this.group.add(this.chord.group); + + /** The shell's unprefixed → prefixed key map, built once. */ + this.keys = shellKeys('chord'); + + /* --- the pockets: 1 draw call each, and nothing visible of their own --- */ + this.pockets = []; + for (let i = 0; i < MAX_POCKETS; i++) { + const field = new DistortionField({ + mode: DistortionMode.SHOCK, + name: `resonance.pocket${i}` + }); + field.visible = false; + this.group.add(field.object3D); + this.pockets.push(field); + } + + /** + * The overlay: an object whose prototype is the live settings block. + * + * `settings.resonance` is mutated in place by `applySettings` and by the + * editor — its identity never changes — so this stays welded to it for the + * life of the process, and the three own properties below are the only + * values the shell sees that the panel did not author directly. + */ + this._tuned = Object.create(settings.resonance); + + /** Scratch state handed to the shell each frame. One object, reused. */ + this._state = { + origin: new Vector3(), + axis: new Vector3(0, 0, 1), + side: new Vector3(1, 0, 0), + span: 1, + t: 0, + fade: 1, + seed: 0 + }; + + /** The one dice roll a cast keeps. */ + this._seed = 0; + /** Timestamps: seconds into the cast. Events, not dimensions. */ + this._struckAt = -1; + this._reflectedAt = -1; + /** Derived every frame and read by `lightShimmer()`; never carried over. */ + this._breath = 1; + this._establish = 0; + this._liveSpan = 1; + } + + createParticles() { + const particles = this.ctx.particles; + + // The motes lifted where the air is being compressed. Additive: this is + // charged air catching the light of the chord, not smoke. + this.dust = particles.get('resonance.dust', { + capacity: 2000, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.dust.uniforms.uDrag.value = 1.5; + this.dust.uniforms.uEndSize.value = 0.2; + this.dust.uniforms.uSizeIn.value = 0.05; + this.dust.uniforms.uFadeIn.value = 0.08; + this.dust.uniforms.uFadeOut.value = 0.4; + + // Grit hopping off the floor under an antinode. Non-additive and lit — the + // read is "the ground is being shaken here and not two metres along". + this.grit = particles.get('resonance.grit', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.3; + this.grit.uniforms.uEndSize.value = 0.7; + this.grit.uniforms.uFadeOut.value = 0.7; + + // Sparks for the two events, and for anyone who turns `sparkRate` up. + this.sparks = particles.get('resonance.sparks', { + capacity: 2000, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.3; + this.sparks.uniforms.uEndSize.value = 0.25; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.45; + + this.dustEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + this.sparkEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.chord.instanceCount; + } + + /** It rings for `lifetime`, then damps over `fadeTime`. */ + get impactDuration() { + return Math.max(0.05, settings.resonance.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.resonance.fadeTime); + } + + /** + * The light rides the standing wave's own temporal term. + * + * `_breath` is `standingAt()` sampled on an antinode, which is + * `|cos(ωt − kL)|` — the part of the superposition that pulses the whole line + * together. So the room brightens and darkens *in time with the pattern* + * rather than on a shimmer of its own, and that agreement is most of what + * sells the effect as one physical thing. + */ + lightShimmer() { + const c = settings.resonance; + return 1 - c.lightPulse + c.lightPulse * this._breath; + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the chord is struck: at the hand, on the aimed line. */ + _strikePoint(out) { + const c = settings.resonance; + out + .copy(this.origin) + .addScaledVector(this.direction, c.originForward) + .addScaledVector(this.side, c.originSide); + out.y = c.lineHeight; + return out; + } + + /** + * The antinode `i` half-wavelengths back from the reflecting end. + * + * Deliberately built out of two `nodePosition()` calls rather than out of + * arithmetic on `span` and `spacing`: an antinode is *by definition* halfway + * between two nodes, and asking the module where the nodes are means the + * pockets cannot drift away from the dark bands the shader is drawing. + */ + _antinodePoint(i, out) { + this.chord.nodePosition(i, out); + this.chord.nodePosition(i + 1, _next); + return out.lerp(_next, 0.5); + } + + /** How many antinodes the run currently holds — one fewer than the nodes. */ + get antinodeCount() { + return Math.max(1, this.chord.nodeCount - 1); + } + + /** Which antinode the `i`-th pocket sits on, so four of them span the line. */ + _pocketIndex(i, want) { + const stride = Math.max(1, Math.floor(this.antinodeCount / Math.max(1, want))); + return Math.min(i * stride, this.antinodeCount - 1); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.gritEmitter.reset(); + this.sparkEmitter.reset(); + + this._seed = Math.random() * 100; + this._struckAt = -1; + this._reflectedAt = -1; + this._breath = 1; + this._establish = 0; + + this.chord.visible = true; + this._sync(1); + this._strikeFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve everything and push it into the shell, the four pockets and the + * three particle systems. + * + * The shell is synced **first**, because `standingAt`, `nodePosition` and + * `nodeCount` all read the dimensions that sync resolved. Query before + * syncing and every pocket is placed on last frame's pattern — which is + * invisible until a slider moves, and then very visible indeed. + * + * @param {number} fade 1 while the chord rings, ramping to 0 as it damps + */ + _sync(fade) { + const c = settings.resonance; + const g = settings.global; + const K = this.keys; + const state = this._state; + const travelling = this.phase === AbilityPhase.TRAVEL; + + /* --- 1 · the run ---------------------------------------------------- */ + this._strikePoint(state.origin); + state.axis.copy(this.direction); + state.side.copy(this.side); + // While the front is still going out it *is* the reflecting end, so the + // run is only as long as the line that has been drawn. + const reach = (travelling ? this.u : 1) * this.length - c.originForward; + state.span = Math.max(0.3, reach); + this._liveSpan = state.span; + state.seed = this._seed; + state.fade = fade; + + /* --- 2 · how far the pattern has got ------------------------------- */ + // Metres a wavefront leaving the hand at the moment of the strike has + // covered. A live rate against a timestamp — no distance is integrated, + // so dragging the ring speed while paused re-answers the question. + const sinceStrike = this._struckAt >= 0 ? this.age - this._struckAt : 0; + const travelled = sinceStrike * c.chordRingSpeed * g.speed; + const trips = Math.max(0.05, c.establishTrips); + this._establish = travelling ? 0 : saturate(travelled / (state.span * trips)); + const decay = this.phase === AbilityPhase.FADE ? saturate(this.fadeTime / this.fadeDuration) : 0; + state.t = this._establish; + + /* --- 3 · the overlay: the three keys the ability computes ---------- */ + const tuned = this._tuned; + const lock = saturate(c.lockSpacing); + // `resonantSpacing` reads the span the shell was last given, so on the one + // frame the span changes the lock is a frame behind. That is only true + // while the front is travelling — and while it is travelling `reflect` is + // zero and there is no pattern to be behind on. + tuned[K.spacing] = lerp(c.chordSpacing, this.chord.resonantSpacing(c.halfWaves), lock); + // A wavelength that fits `n` half-waves needs exactly `n` rings to tile the + // 2L cycle it folds on. Fewer leaves gaps in the train, more stacks two + // rings on the same metre and reads as one bright one. + tuned[K.rings] = lerp(c.chordRings, Math.round(c.halfWaves), lock); + tuned[K.reflect] = c.chordReflect * this._establish * (1 - Easing.inQuad(decay)); + this.chord.sync(tuned, state, g); + + /* --- 4 · the pockets, on the antinodes and nowhere else ------------ */ + const want = clamp(Math.round(c.pockets), 0, MAX_POCKETS); + // A pocket is a *standing* compression, so it does not exist until there is + // a standing wave for it to stand in. Before the reflection the envelope is + // flat at 1 along the whole run — the honest answer for a travelling train, + // and exactly the wrong thing to hang four pockets of shaking air on. The + // first build did that, and the strike beat looked like the ring beat with + // the rings turned down. + const established = this._establish; + // The breath — the temporal half of the superposition, sampled on the first + // antinode. Read by `lightShimmer()` and by the rumble, and computed here + // whether or not a single pocket is drawn, so turning `pockets` down to + // zero does not stop the room pulsing. + this._antinodePoint(0, _pos); + this._breath = saturate(this.chord.standingAt(_pos.distanceTo(state.origin))); + + for (let i = 0; i < MAX_POCKETS; i++) { + const field = this.pockets[i]; + // Pocket 0 is the exception: it rides the strike front while the wave is + // still outbound, so the beat before the pattern exists is a *travelling* + // compression, which is the truth. It then glides onto its antinode as + // the reflection establishes. + const riding = i === 0; + if (i >= want || fade <= 0.01 || (established <= 0.02 && !riding)) { + // Toggling the field itself, never the parent: hiding the group would + // leak the pass's writer counter for the rest of the session. + field.visible = false; + continue; + } + + // Metres along the run — measured off the point the module placed, not + // recomputed from the spacing, so the two can never disagree. + this._antinodePoint(this._pocketIndex(i, want), _node); + let amp = saturate(this.chord.standingAt(_node.distanceTo(state.origin))); + + if (riding) { + // One continuous crossfade rather than a switch: position *and* + // amplitude are interpolated on `established`, because a pocket that + // pops from the wavefront onto a node is a cut, and the ear — well, + // the eye — hears it. + _pos.copy(state.origin).addScaledVector(state.axis, state.span).lerp(_node, established); + amp = lerp(1, amp, established); + } else { + _pos.copy(_node); + amp *= established; + } + + field.visible = true; + field.setAnchor(_pos); + _shock.width = c.pocketWidth; + _shock.height = c.pocketHeight; + _shock.radius = c.pocketRadius; + _shock.window = c.pocketWindow; + _shock.wave = c.pocketWave; + _shock.thickness = c.pocketThickness; + _shock.compression = c.pocketCompression; + _shock.rarefaction = c.pocketRarefaction; + _shock.rings = c.pocketRings; + _shock.ringGap = c.pocketRingGap; + _shock.ringDecay = c.pocketRingDecay; + // The amplitude is the superposition and nothing else. The mask is the + // radius falloff, which the module already owns — authoring the two + // separately is that file's one rule. + _shock.strength = c.pocketStrength * amp * fade; + _shock.maxOffset = c.pocketMaxOffset; + _shock.opacity = c.pocketOpacity; + _shock.depthReject = c.pocketDepthReject; + _shock.depthFade = c.pocketDepthFade; + _shock.perspective = c.pocketPerspective; + _shock.perspectiveRef = c.pocketPerspectiveRef; + _shock.seed = this._seed + i; + field.update(_shock); + } + + /* --- 5 · the particle systems -------------------------------------- */ + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize * 7; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = g.opacity; + this.dust.uniforms.uGlow.value = 1.1 * g.glow; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = c.gritLifetime * 0.5 * g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = 1.4 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + } + + /** + * Dust and grit, emitted at the antinodes with a count proportional to how + * hard the air is working there — so the nodes get literally nothing and the + * pattern is legible in the particles alone, with the rings turned off. + * + * @param {number} dt + * @param {number} scale 0..1 — how much of the pattern has established + */ + _antinodeFx(dt, scale) { + const c = settings.resonance; + const g = settings.global; + const time = frame.uTime.value; + const count = this.antinodeCount; + const origin = this._state.origin; + + let dustBudget = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + let gritBudget = Math.round(this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount); + const sparkBudget = Math.round(this.sparkEmitter.tick(dt, c.sparkRate * scale) * g.particleCount); + if (dustBudget <= 0 && gritBudget <= 0 && sparkBudget <= 0) return; + + // The budget is shared out over the antinodes, not multiplied by them: an + // eight-mode chord and a two-mode chord shed the same amount of air. + const perDust = Math.max(1, Math.round(dustBudget / count)); + const perGrit = Math.max(1, Math.round(gritBudget / count)); + + for (let i = 0; i < count; i++) { + if (dustBudget <= 0 && gritBudget <= 0) break; + this._antinodePoint(i, _pos); + const amp = saturate(this.chord.standingAt(_pos.distanceTo(origin))); + // Below a whisper the antinode is indistinguishable from a node and the + // dust would be the only thing telling them apart, wrongly. + if (amp < 0.15) continue; + + if (dustBudget > 0) { + const n = Math.min(dustBudget, Math.round(perDust * amp)); + if (n > 0) { + _emit.position = _pos; + _emit.radius = this.chord.radius * 0.85; + _emit.anchor = null; + _emit.inherit = null; + // Off the axis, not along it: the air is being squeezed sideways out + // of the compression, which is the direction that reads. + _emit.direction = _dir.copy(this.side).multiplyScalar(randRange(-1, 1)).setY(0.75).normalize(); + _emit.speed = c.dustSpeed * amp; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.size = 1; + _emit.sizeVariance = 0.6; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.dust.emit(n, _emit); + dustBudget -= n; + } + } + + if (gritBudget > 0) { + const n = Math.min(gritBudget, Math.round(perGrit * amp)); + if (n > 0) { + _pos.y = 0.05; + _emit.position = _pos; + _emit.radius = this.chord.radius * 0.7; + _emit.anchor = null; + _emit.inherit = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed * amp; + _emit.speedVariance = 0.6; + _emit.spread = 0.55; + _emit.size = 1; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 7; + _emit.tint = null; + _emit.time = time; + this.grit.emit(n, _emit); + gritBudget -= n; + } + } + } + + if (sparkBudget > 0) { + this._antinodePoint(Math.floor(Math.random() * count), _pos); + _emit.position = _pos; + _emit.radius = this.chord.radius; + _emit.anchor = null; + _emit.inherit = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1; + _emit.size = 1; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(sparkBudget, _emit); + } + } + + /** The strike at the caster's hand. */ + _strikeFx() { + const c = settings.resonance; + const g = settings.global; + + this._strikePoint(_pos); + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.strikeSize * 0.25, + endRadius: c.strikeSize * g.explosionIntensity, + life: 0.45, + intensity: c.strikeIntensity, + opacity: 0.8, + fresnel: 2.0, + displace: 0.3, + colorA: getColor(c.colorStrikeA), + colorB: getColor(c.colorStrikeB), + colorC: getColor(c.colorStrikeC) + }); + + _emit.position = _pos; + _emit.radius = 0.16; + _emit.anchor = null; + _emit.inherit = null; + _emit.direction = _dir.copy(this.direction); + _emit.speed = c.sparkSpeed * 1.3; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.size = 1; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.sparks.emit(Math.round(c.strikeSparks * g.particleCount), _emit); + + this.ctx.shake.add( + c.strikeShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 30 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.strikeFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.6 * g.explosionIntensity; + } + + /** + * The reflection, fired at the far end on the frame a wavefront that left the + * hand at the strike first gets there. + * + * This is an *event*, not a schedule: the predicate is re-evaluated every + * frame from the live ring speed and the live span, exactly as `Tube`'s whip + * crack is, so slowing the chord down while it is in flight moves the bang. + */ + _reflectFx() { + const c = settings.resonance; + const g = settings.global; + + this.pointAt(1, _far).setY(c.lineHeight); + this.ctx.bursts.spawn(BurstMode.AIR, _far, { + radius: c.reflectSize * 0.2, + endRadius: c.reflectSize * g.explosionIntensity, + life: 0.6, + intensity: c.reflectIntensity, + opacity: 0.85, + fresnel: 2.2, + displace: 0.35, + squash: 0.7, + colorA: getColor(c.colorStrikeA), + colorB: getColor(c.colorStrikeB), + colorC: getColor(c.colorStrikeC) + }); + + _emit.position = _far; + _emit.radius = this.chord.radius * 0.8; + _emit.anchor = null; + _emit.inherit = null; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.6).setY(0.55).normalize(); + _emit.speed = c.sparkSpeed * 1.8; + _emit.speedVariance = 0.85; + _emit.spread = 0.9; + _emit.size = 1; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.sparks.emit(Math.round(c.reflectSparks * g.particleCount), _emit); + + this.ctx.shake.add( + c.reflectShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 24 + ); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + // The train exists behind the front, so the antinodes do too — but there is + // no reflection yet and the pattern is a travelling one, so the dust is + // thinned right down until the chord has something to stand on. + this._antinodeFx(dt, 0.25); + } + + onImpact() { + // The only timestamp the strike beat leaves behind. Everything the ring + // phase does is a live function of the seconds since it. + this._struckAt = this.age; + this._sync(1); + } + + onFade(dt, t) { + const c = settings.resonance; + const decay = t <= 1 ? 0 : saturate(t - 1); + this._sync(1 - Easing.inCubic(decay)); + + // The reflection: the frame a wavefront that left the hand at the strike + // reaches the far end. Polled, never scheduled. + if (this._reflectedAt < 0 && this._struckAt >= 0) { + const travelled = (this.age - this._struckAt) * c.chordRingSpeed * settings.global.speed; + if (travelled >= this._liveSpan) { + this._reflectedAt = this.age; + this._reflectFx(); + } + } + + // The light stands on the antinode nearest the middle of the run, which is + // where the pattern is strongest and where the eye already is. + this._antinodePoint(Math.floor(this.antinodeCount / 2), this.position); + + this._antinodeFx(dt, this._establish * (1 - decay)); + this.ctx.shake.rumble( + c.rumble * settings.global.cameraShake * this._establish * this._breath * (1 - decay), + dt + ); + } + + onDestroy() { + for (let i = 0; i < this.pockets.length; i++) this.pockets[i].visible = false; + this.chord.visible = false; + this._struckAt = -1; + this._reflectedAt = -1; + } + + dispose() { + this.chord.dispose(); + for (let i = 0; i < this.pockets.length; i++) this.pockets[i].dispose(); + super.dispose(); + } +} diff --git a/src/abilities/aether/SkyfractureAbility.js b/src/abilities/aether/SkyfractureAbility.js new file mode 100644 index 0000000..3f4f28c --- /dev/null +++ b/src/abilities/aether/SkyfractureAbility.js @@ -0,0 +1,747 @@ +import { Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { FilamentPaths } from '../../vfx/FilamentPaths.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +/* ------------------------------------------------------------------ */ +/* Module-scope scratch — I3. Nothing below allocates in a frame. */ +/* ------------------------------------------------------------------ */ + +const _up = new Vector3(0, 1, 0); +const _from = new Vector3(); +const _to = new Vector3(); +const _mid = new Vector3(); +const _pos = new Vector3(); +const _dir = new Vector3(); + +/** + * The two `filamentLook()`-shaped bags, refilled and handed to `sync()`. + * Two rather than one because reading `_look` twice with different colours in + * it is the kind of thing that is correct today and wrong after one edit. + */ +const _skyLook = {}; +const _floorLook = {}; +/** `GroundField#update` params. */ +const _wet = {}; +/** `ParticleSystem#emit` params. */ +const _emit = {}; + +/** + * SKYFRACTURE — the effect arrives before the cause. + * + * **THE TRICK: reverse-ordered cause and effect.** A white fracture pattern + * inks itself across the floor along the cast line with *nothing above it* — + * no sound, no shake, no dust, no light in the sky. Four tenths of a second + * later the sky splits along the same shape. Three tenths after that the + * pressure finally arrives. You read the shadow, then the thing that casts it, + * then what it does to you, which is the wrong order and is the entire + * ability. `skyGap` and `pressureGap` are the two sliders that own it and they + * are the first two controls in the editor folder. + * + * ### The floor pattern and the sky pattern are the same pattern + * + * Not "similar", not "seeded alike" — the same one, in plan view, to the + * pixel. `FilamentPaths`' `CRACK` mode builds each branch as a pure function + * of the seed and the trunk's `from → to`: it takes the trunk direction, makes + * a frame off it, and rolls each fork's angle and roll out of `hash11(seed)`. + * Feed two instances the identical seed, the identical eight branch + * parameters and two trunks that are *parallel and the same length*, and they + * produce byte-identical geometry, one translated in y. + * + * The floor copy is then flattened, and the flattening is the part worth + * reading twice. `FilamentRole#draw`'s third argument clamps every vertex + * **above** a floor height — `world.y = max(world.y, floorY)`. Clamping does + * nothing to x or z. So if the whole copy is placed far *below* the floor, low + * enough that not one branch of it can reach back up, the clamp collapses + * every vertex onto exactly `groundHeight` and leaves the horizontal + * coordinates untouched. That is not an approximation of an orthographic + * projection; it *is* one, evaluated in the vertex shader for free. + * + * `_sinkDepth()` computes how far below is far enough, live, from the branch + * parameters — see the comment there. The first version used a fixed −50 m and + * broke the moment somebody dragged `crackLengthFrac` past 1: half the twigs + * came back through the floor and stood up out of it like weeds. + * + * ### Why two `FilamentPaths` and not two roles of one + * + * Two roles on one instance would have cost two draw calls instead of four, + * and it was the first thing tried. It draws two *different* fractures. The + * per-filament hash is `hash11(aStrand * 7.13 + uSeed + strike * 3.77)` and + * `aStrand` is the **global** instance index, not the index within the role — + * so role 1's filaments start at aStrand = count and every branch angle in the + * floor copy comes out different from the sky's. Two instances put both + * copies at aStrand 0..N−1 with the same `uSeed`, which is the only + * arrangement that makes them match. It also buys each copy its own four + * colour pickers, which a reflection wants anyway: it should be colder and + * dimmer than the thing it reflects. + * + * ### The shared parameters are shared on purpose + * + * Everything under **The fracture** in the settings block feeds both copies + * from one slider. That is the `snare.zoneRadius` exemption in + * `docs/EXPANSION.md` I5 — the sharing *is* the design. A second `crackAngle` + * would let the floor advertise a shape the sky never makes. + * + * ### Budget + * + * Six draw calls: two `FilamentPaths` at two each, one `GroundField(WET)`, one + * `Shell(PRESSURE)`. Two particle systems, both throttled by their own beat, so + * nothing is emitted at all until the beat that owns it has started. + */ +export class SkyfractureAbility extends Ability { + constructor(context) { + super('skyfracture', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The floor copy is built first and drawn first: it is beat one, and + * `renderOrder` should agree with the fiction as well as with the depth + * sort. Both instances get the same `samples` and the same `capacity`, so + * a filament index means the same thing in both — which is what keeps the + * seeds lined up. + */ + this.floorPaths = new FilamentPaths(this.group, { + samples: 96, + capacity: 24, + renderOrder: 8 + }); + this.skyPaths = new FilamentPaths(this.group, { + samples: 96, + capacity: 24, + renderOrder: 12 + }); + + /** + * The wet stone. Alpha-blended, not additive: the floor under the fracture + * has to go *darker* and glossier, because the white filaments only read as + * a reflection if they are lying in something. An additive patch brightens + * the floor and the filaments turn into chalk on it. + */ + this.wet = new GroundField(this.group, { + mode: GroundMode.WET, + additive: false, + name: 'skyfracture.wet' + }); + + /** Beat three. A near-invisible fresnel shell, centred above the line. */ + this.wave = new Shell({ + mode: ShellMode.PRESSURE, + prefix: 'wave', + renderOrder: 14 + }); + this.group.add(this.wave.group); + + /** The one thing a cast captures, besides timestamps. Unitless. */ + this._seed = 0; + /** `this.age` at the moment the front landed. A timestamp — allowed. */ + this._landedAt = 0; + /** Latched so the sky's one-shot fires on exactly one frame. */ + this._tore = false; + /** ... and the pressure's. */ + this._burst = false; + /** Filaments currently drawn, for the HUD readout. */ + this._live = 0; + + /** The shell's state bag. Instance-scope: four casts do not take turns. */ + this._shell = { + origin: new Vector3(), + axis: new Vector3(0, 1, 0), + side: new Vector3(1, 0, 0), + span: 0, + t: 0, + fade: 1, + seed: 0 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + /** Beat three, and only beat three. Nothing is emitted before it. */ + this.dust = particles.get('skyfracture.dust', { + capacity: 2000, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.dust.uniforms.uDrag.value = 1.7; + this.dust.uniforms.uEndSize.value = 3.4; + this.dust.uniforms.uSizeIn.value = 0.1; + this.dust.uniforms.uFadeIn.value = 0.14; + this.dust.uniforms.uFadeOut.value = 0.35; + + /** Beat two: what falls out of a hole in the sky. */ + this.glint = particles.get('skyfracture.glint', { + capacity: 1400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.glint.uniforms.uDrag.value = 0.9; + this.glint.uniforms.uEndSize.value = 0.2; + this.glint.uniforms.uSizeIn.value = 0.05; + this.glint.uniforms.uFadeIn.value = 0.05; + this.glint.uniforms.uFadeOut.value = 0.5; + + this.dustEmitter = new RateEmitter(); + this.glintEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — the impact phase has to be long enough to hold all three */ + /* ------------------------------------------------------------------ */ + + /** + * The whole reversed sequence happens inside the impact phase, so the phase + * has to be at least as long as the sequence. Resolved from the live gaps + * every frame, which means dragging `skyGap` on a standing cast lengthens the + * phase under it rather than truncating the sky. + */ + get impactDuration() { + const c = settings.skyfracture; + const total = c.skyGap + c.skyDraw + c.pressureGap + c.pressureRise + c.lifetime; + return Math.max(0.05, total * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.skyfracture.fadeTime); + } + + get instanceCount() { + // Both copies, both passes. + return this._live * 4; + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The trunk's start in plan, on the floor. */ + _planFrom(out) { + return out.copy(this.origin).addScaledVector(this.direction, settings.skyfracture.crackLead); + } + + /** The trunk's end in plan, on the floor. */ + _planTo(out) { + return this.pointAt(1, out); + } + + /** + * How far below the floor the flattened copy has to sit for the clamp in + * `FilamentRole#draw` to be an exact orthographic projection. + * + * `CRACK` walks two generations. Generation 0 puts a branch tip at + * `root + nd·len·lengthFrac`; generation 1 at + * `root + nd·(len·lengthFrac)·lengthFrac·depthFalloff`. `nd` is a unit + * vector, so the worst case in y is the sum of those two lengths, and `sag` + * adds one more bow on top. Anything below that and a twig pokes back up + * through the floor and stands there — which is exactly what the first + * version, with a hard-coded −50 m, did the first time `crackLengthFrac` went + * past 1. + * + * The kink cannot contribute: the floor copy runs with `groundDamp = 0`, so + * its kink offset has no y component at all. + */ + _sinkDepth() { + const c = settings.skyfracture; + const span = Math.max(0.1, this.length); + const f = Math.max(0, c.crackLengthFrac); + const reach = span * (f + f * f * Math.max(0, c.crackDepthFalloff)); + return reach + Math.abs(c.skySag) + Math.max(0, c.groundClearance); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.glintEmitter.reset(); + + // Unitless. Both copies read the *same* one, and that is the ability. + this._seed = Math.random() * 100; + this._landedAt = 0; + this._tore = false; + this._burst = false; + this._live = 0; + + this.wet.setVisible(true); + this.wave.visible = true; + this.floorPaths.visible = true; + this.skyPaths.visible = true; + + this._sync(0, 0, 0, 0); + } + + /* ------------------------------------------------------------------ */ + /* The one sync */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the three beats into everything. + * + * @param {number} floorT 0..1 — how much of the floor pattern has inked + * @param {number} skyT 0..1 — how much of the sky fracture has torn open + * @param {number} waveT 0..1 — the pressure front's own life + * @param {number} heal 0..1 — the way back to an undisturbed room + */ + _sync(floorT, skyT, waveT, heal) { + const c = settings.skyfracture; + const g = settings.global; + + const count = Math.max(1, Math.round(c.crackCount)); + this._live = count; + const master = 1 - Easing.inCubic(heal); + + this._planFrom(_from); + this._planTo(_to); + _mid.addVectors(_from, _to).multiplyScalar(0.5); + + /* ---------------------------------------------------------------- */ + /* Beat one — the floor copy */ + /* ---------------------------------------------------------------- */ + // Sunk, then clamped. See `_sinkDepth()`; this pair of lines is the trick. + const sink = c.groundHeight - this._sinkDepth(); + _from.y = sink; + _to.y = sink; + + const floorRole = this.floorPaths.role(0); + floorRole.count = count; + floorRole + .crack( + _from, + _to, + c.crackAngle, + c.crackLengthFrac, + c.crackDepthFalloff, + c.crackSpread, + c.crackStart, + // No bow on the floor copy. `sag` only moves y, so it could not change + // the plan view even if it wanted to — but a bow that is then clamped + // flat is two lines of shader doing nothing, so it is zero here. + 0, + c.crackForkBias + ) + // groundDamp 0: the kink keeps its plan-view offset and loses its y, which + // is what makes the two copies agree kink for kink as well as fork for + // fork. + .style(1, 1, 0, 0) + .ends(0, 1, 0, 1) + .draw(floorT * (1 + c.floorTipLength * 2), c.floorTipLength, c.groundHeight, c.floorTipGlow); + + this._fillLook(_floorLook, g, { + width: c.floorWidth, + glowWidth: c.floorGlowWidth, + glowOpacity: c.floorGlowOpacity, + flicker: c.floorFlicker, + flickerSpeed: c.floorFlickerSpeed, + strandFlash: c.floorStrandFlash, + coreSharp: c.floorCoreSharp, + glowFalloff: c.floorGlowFalloff, + softFade: c.floorSoftFade, + opacity: c.floorOpacity, + // The reflection brightens when there is finally something to reflect. + // Beat one is a shadow; from beat two on it is a mirror. + glow: c.floorGlow + c.floorSkyBoost * skyT, + colorCore: c.colorFloorCore, + colorInner: c.colorFloorInner, + colorOuter: c.colorFloorOuter, + colorHalo: c.colorFloorHalo + }); + this.floorPaths.sync(_floorLook, master, this._seed); + + /* ---------------------------------------------------------------- */ + /* Beat two — the sky copy, the same crack, lifted */ + /* ---------------------------------------------------------------- */ + // One height, both ends. A tilted trunk would give `CRACK` a different + // frame to branch off and the floor would stop showing this shape. + _from.y = c.skyHeight; + _to.y = c.skyHeight; + + const skyRole = this.skyPaths.role(0); + skyRole.count = count; + skyRole + .crack( + _from, + _to, + c.crackAngle, + c.crackLengthFrac, + c.crackDepthFalloff, + c.crackSpread, + c.crackStart, + c.skySag, + c.crackForkBias + ) + .style(1, 1, 0, 1) + .ends(0, 1, 0, 1) + .draw(skyT * (1 + c.skyTipLength * 2), c.skyTipLength, -1e4, c.skyTipGlow); + + this._fillLook(_skyLook, g, { + width: c.skyWidth, + glowWidth: c.skyGlowWidth, + glowOpacity: c.skyGlowOpacity, + flicker: c.skyFlicker, + flickerSpeed: c.skyFlickerSpeed, + strandFlash: c.skyStrandFlash, + coreSharp: c.skyCoreSharp, + glowFalloff: c.skyGlowFalloff, + softFade: c.skySoftFade, + opacity: c.skyOpacity, + glow: c.skyGlow, + colorCore: c.colorSkyCore, + colorInner: c.colorSkyInner, + colorOuter: c.colorSkyOuter, + colorHalo: c.colorSkyHalo + }); + // Nothing above the floor until the gap has run out: `skyT` gates the + // *draw*, and this gates the whole strip so its halo is not sitting in the + // sky at zero length giving the game away. + this.skyPaths.sync(_skyLook, master * (skyT > 0 ? 1 : 0), this._seed); + + /* ---------------------------------------------------------------- */ + /* The wet stone under all of it */ + /* ---------------------------------------------------------------- */ + _mid.y = 0; + _wet.centre = _mid; + // GroundField's local +Z is downrange, so the yaw is the cast's bearing. + _wet.yaw = Math.atan2(this.direction.x, this.direction.z); + _wet.height = c.groundHeight * 0.5; + _wet.radius = Math.max(0.5, this.length * 0.5 * c.wetSpan + c.wetMargin); + _wet.grow = floorT; + _wet.recede = heal; + _wet.fade = master; + _wet.seed = this._seed; + _wet.edge = c.wetEdge; + _wet.ragged = c.wetRagged; + _wet.raggedScale = c.wetRaggedScale; + _wet.warp = c.wetWarp; + _wet.relief = c.wetRelief; + _wet.normalStep = c.wetNormalStep; + _wet.ambient = c.wetAmbient; + _wet.wrap = c.wetWrap; + _wet.specular = c.wetSpecular; + _wet.gloss = c.wetGloss; + _wet.parallax = c.wetParallax; + _wet.cell = c.wetCell; + _wet.lift = c.wetLift; + _wet.depth = c.wetDepth; + _wet.detail = c.wetDetail; + _wet.speed = c.wetSpeed; + _wet.flow = c.wetFlow; + _wet.windAngle = c.wetWind; + _wet.additive = false; + _wet.emissive = c.wetEmissive; + _wet.opacity = c.wetOpacity; + _wet.depthFade = c.wetDepthFade; + _wet.colorBase = c.colorWetBase; + _wet.colorEdge = c.colorWetEdge; + _wet.colorGlow = c.colorWetGlow; + _wet.colorDeep = c.colorWetDeep; + _wet.noiseStrength = g.noiseStrength; + _wet.noiseFrequency = g.noiseFrequency; + _wet.noiseSpeed = g.noiseSpeed; + _wet.opacityScale = g.opacity; + this.wet.update(_wet); + + /* ---------------------------------------------------------------- */ + /* Beat three — the pressure front */ + /* ---------------------------------------------------------------- */ + const shell = this._shell; + shell.origin.copy(_mid); + shell.axis.copy(_up); + shell.side.copy(this.side); + shell.span = this.length; + shell.t = waveT; + shell.fade = master * (waveT > 0 ? 1 : 0); + shell.seed = this._seed; + this.wave.sync(c, shell, g); + + /* ---------------------------------------------------------------- */ + /* Particle uniforms */ + /* ---------------------------------------------------------------- */ + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.glint.setGradient( + getColor(c.colorGlintA), + getColor(c.colorGlintB), + getColor(c.colorGlintC), + getColor(c.colorGlintD) + ); + this.glint.uniforms.uGravity.value.set(0, c.glintFall, 0); + this.glint.uniforms.uSizeScale.value = c.glintSize * g.particleSize * 7; + this.glint.uniforms.uLifeScale.value = c.glintLifetime * 0.5 * g.particleLifetime; + this.glint.uniforms.uSpeedScale.value = g.particleSpeed; + this.glint.uniforms.uOpacity.value = g.opacity; + this.glint.uniforms.uGlow.value = 1.4 * g.glow; + this.glint.uniforms.uTurbulence.value = c.glintTurbulence * g.turbulence; + } + + /** + * Fill one `filamentLook()`-shaped bag. + * + * The kink group and the restrike clock are written here, from the *shared* + * settings, for both copies — that is not laziness, it is the constraint. The + * kink frame's first normal is horizontal and its second is vertical for a + * horizontal path, so `groundDamp = 0` on the floor copy throws away exactly + * the vertical half and leaves the plan-view kink identical to the sky's. + * Give the two copies different `jitterScale` values and that stops being + * true within one frame. + * + * @param {object} look the module-scope bag to fill + * @param {object} g `settings.global` + * @param {object} own the per-copy values, already resolved by the caller + */ + _fillLook(look, g, own) { + const c = settings.skyfracture; + + look.jitter = c.crackJitter; + look.jitterScale = c.crackJitterScale; + look.octaves = c.crackOctaves; + look.jitterFalloff = c.crackJitterFalloff; + look.crawl = c.crackCrawl; + look.pinch = c.crackPinch; + look.restrike = c.crackRestrike; + + look.width = own.width; + look.glowWidth = own.glowWidth; + look.glowOpacity = own.glowOpacity; + look.flicker = own.flicker; + look.flickerSpeed = own.flickerSpeed; + look.strandFlash = own.strandFlash; + look.coreSharp = own.coreSharp; + look.glowFalloff = own.glowFalloff; + look.softFade = own.softFade; + look.opacity = own.opacity; + look.glow = own.glow; + look.colorCore = own.colorCore; + look.colorInner = own.colorInner; + look.colorOuter = own.colorOuter; + look.colorHalo = own.colorHalo; + + look.randomness = g.randomness; + look.noiseStrength = g.noiseStrength; + look.noiseFrequency = g.noiseFrequency; + look.noiseSpeed = g.noiseSpeed; + look.opacityScale = g.opacity; + look.glowScale = g.glow; + } + + /* ------------------------------------------------------------------ */ + /* Beats */ + /* ------------------------------------------------------------------ */ + + /** + * Seconds since the floor pattern finished inking. + * + * `_landedAt` is a timestamp — the moment an event fired — which is the one + * kind of number a cast is allowed to keep. Everything measured against it is + * a live setting, so the gaps re-resolve on a paused frame and dragging + * `skyGap` moves a fracture that is standing. + */ + get _sinceLanding() { + return Math.max(0, this.age - this._landedAt); + } + + onTravel(dt) { + const c = settings.skyfracture; + + // Beat one, and nothing else. No shake, no dust, no light in the sky. + this._sync(this.u, 0, 0, 0); + + this.position.copy(this.pointAt(this.u, _pos)); + this.position.y = c.groundHeight; + + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + // The only thing that happens when the front lands is that a clock starts. + // Everything an impact normally does — flash, shake, burst — belongs to + // beats two and three, seconds later. + this._landedAt = this.age; + } + + onFade(dt, t) { + const c = settings.skyfracture; + const g = settings.global; + + const since = this._sinceLanding; + const heal = saturate(t - 1); + + const skyT = saturate((since - c.skyGap) / Math.max(0.01, c.skyDraw)); + const waveT = saturate( + (since - c.skyGap - c.skyDraw - c.pressureGap) / Math.max(0.01, c.pressureRise) + ); + + this._sync(1, skyT, waveT, heal); + + /* --- the light climbs to the fracture as it opens --- */ + this.pointAt(0.5, this.position); + this.position.y = lerp(c.groundHeight, c.skyHeight * 0.75, Easing.outQuad(skyT)); + + /* --- beat two's one-shot --- */ + if (!this._tore && skyT > 0) { + this._tore = true; + this.ctx.flash.trigger(getColor(c.colorSkyFlash), c.skyFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.9 * g.explosionIntensity; + this._glintBurst(Math.round(c.glintBurst * g.particleCount)); + } + + /* --- beat three's one-shot --- */ + if (!this._burst && waveT > 0) { + this._burst = true; + this.ctx.shake.add( + c.pressureShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.05, c.shakeDuration), + 22 + ); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /* --- the two continuous emitters, each gated by its own beat --- */ + if (skyT > 0) this._glintFx(dt, skyT * (1 - heal)); + if (waveT > 0) this._dustFx(dt, waveT, (1 - waveT * 0.4) * (1 - heal)); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** Motes shaken loose along the sky fracture and falling out of it. */ + _glintFx(dt, scale) { + const c = settings.skyfracture; + const g = settings.global; + const count = Math.round(this.glintEmitter.tick(dt, c.glintRate * scale) * g.particleCount); + if (count <= 0) return; + + this.pointAt(randRange(0.02, 1), _pos); + _pos.y = c.skyHeight; + _emit.position = _pos; + _emit.radius = this.length * 0.22; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.glintSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.glintLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.glint.emit(count, _emit); + } + + /** The one-shot on the frame the sky opens. */ + _glintBurst(count) { + if (count <= 0) return; + const c = settings.skyfracture; + + this.pointAt(0.5, _pos); + _pos.y = c.skyHeight; + _emit.position = _pos; + _emit.radius = this.length * 0.45; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.glintSpeed * 1.8; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.13; + _emit.sizeVariance = 0.8; + _emit.life = c.glintLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.glint.emit(count, _emit); + } + + /** + * Dust lifted off the floor by the pressure front. + * + * Emitted at the radius the shell has actually reached rather than at a + * captured distance, so the dust wave rides the front and dragging + * `waveRadiusEnd` while paused moves both of them together. + */ + _dustFx(dt, waveT, scale) { + const c = settings.skyfracture; + const g = settings.global; + const count = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (count <= 0) return; + + const radius = this.wave.radius; + // Around the ring, not inside it: a front that lifts dust from its own + // centre reads as a bonfire. + const theta = randRange(0, Math.PI * 2); + const r = radius * randRange(0.82, 1.0); + this.pointAt(0.5, _pos); + _pos.x += Math.cos(theta) * r; + _pos.z += Math.sin(theta) * r; + _pos.y = 0.1; + + _emit.position = _pos; + _emit.radius = clamp(radius * 0.1, 0.2, 2.5); + _emit.direction = _dir.set(Math.cos(theta) * 0.6, 1, Math.sin(theta) * 0.6).normalize(); + _emit.speed = c.dustSpeed * (1 - waveT * 0.5); + _emit.speedVariance = 0.75; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.1; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Teardown */ + /* ------------------------------------------------------------------ */ + + onDestroy() { + this._live = 0; + this.floorPaths.clear(); + this.skyPaths.clear(); + this.wet.setVisible(false); + this.wave.visible = false; + } + + dispose() { + this.floorPaths.dispose(); + this.skyPaths.dispose(); + this.wet.dispose(); + this.wave.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/aether/SlipstreamAbility.js b/src/abilities/aether/SlipstreamAbility.js new file mode 100644 index 0000000..4d1193a --- /dev/null +++ b/src/abilities/aether/SlipstreamAbility.js @@ -0,0 +1,453 @@ +import { Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { DistortionField, DistortionMode, DistortionFacing } from '../../vfx/Distortion.js'; +import { Tube, TubePath } from '../../vfx/Tube.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +/* ------------------------------------------------------------------ */ +/* Module-scope scratch — I3. Nothing below allocates in a frame. */ +/* ------------------------------------------------------------------ */ + +const _up = new Vector3(0, 1, 0); +const _from = new Vector3(); +const _to = new Vector3(); +const _axis = new Vector3(); +const _normal = new Vector3(); +const _plane = new Vector3(); +const _anchor = new Vector3(); +const _pos = new Vector3(); +const _dir = new Vector3(); +/** Refilled and handed to `DistortionField#update` on the same line. */ +const _warp = {}; +/** Refilled and handed to `ParticleSystem#emit` on the same line. */ +const _emit = {}; + +/** + * SLIPSTREAM — a blade of vacuum drawn along the aimed line. + * + * **THE TRICK: almost nothing is drawn.** Every other slot in this sandbox + * makes its mark by *adding* light — a bolt, a crystal, a shell, a burn. This + * one adds a thread 1.4 cm across and otherwise emits no photons at all. What + * you see is the world behind the blade sliding sideways: one + * `DistortionField(BLADE)` writing a screen-space UV offset into the + * refraction buffer, and the floor grid, the character and every particle + * behind the plane shearing along the blade's own surface normal. Take the + * distortion pass away and this ability is a white line. That is the point of + * it: it is the proof that the pass works, and it is the only slot whose + * silhouette is made of the *scene* rather than of itself. + * + * ### Why a plane of nothing is visible at all + * + * A razor-thin slab of a different refractive index shifts what is behind it + * along its own normal, and the shift grows as the view ray runs *along* the + * slab, because that is where the path through it is longest — + * `normal projected to screen × (1 − |N·V|)^grazing`, all of it inside + * `Distortion.js`. So the blade is invisible when you look squarely at its + * face and strongest when you are almost edge-on to it, which is exactly how a + * pane of glass behaves and exactly why you can never quite decide where its + * surface is. The sandbox's default camera sits behind the caster looking + * downrange, which is nearly edge-on to a blade drawn along the cast line — + * so the shipped view is the strong one, on purpose. + * + * ### The beats + * + * 1. **Travel.** `uCut` extends along the blade's own length rather than + * scaling it, so the sheet the front has already opened stays exactly where + * it was opened while the front runs on. The hairline thread is drawn to + * the same fraction. Fast: 88 m/s, a fifth of a second on a full-range + * cast. + * 2. **The cut.** For `lifetime` seconds the refraction spikes by + * `refractCut` and decays on `refractCutSharp`. This is the whole payoff + * beat and it is *shorter* than it feels — 180 ms — because a peak you can + * stare at reads as a bug in the render rather than as an event. + * 3. **The heal.** Strength, coverage, wake depth and the thread's radius all + * run down together over `fadeTime`, leaving a line of dust hanging in the + * air where the air used to be cut. + * + * ### What was tried and thrown away + * + * The first pass gave the blade its own bright hairline via + * `DistortionField({ edge: true })` *and* ran the `Tube` along the same line. + * Two coincident hairlines at slightly different widths do not read as one + * bright line; they read as a double exposure, and every time the camera moved + * one slid a pixel against the other. The blade's edge pass is off and the + * `Tube` is the only bright thing in the ability. + * + * The second pass drove the wake with `strength` instead of `wake`, which is + * the mistake `Distortion.js` warns about in its one rule: an emitter that + * carries its falloff in its amplitude cannot be faded out without changing + * its silhouette. Healing it made the blade get *shorter* from the bottom in + * lockstep with getting weaker, which looked like the sheet was being wound in + * on a reel. Mask and amplitude are separate here. + * + * ### Invariants + * + * A cast captures exactly one number — `_seed`, a unitless dice roll that + * decorrelates the surface ripple — plus the phase timestamps the base class + * already keeps. Every metre, every screen fraction and every colour is + * re-resolved from `settings.slipstream` inside `_sync()`, which runs on every + * frame including a zero-length one. Pause with **P** mid-cut and drag + * `bladeHeight`: the sheet gets deeper while the hairline stays exactly where + * it is, because the hairline is authored in metres and divided by the height + * here rather than being a fraction the module scales for you. + */ +export class SlipstreamAbility extends Ability { + constructor(context) { + super('slipstream', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The blade. `WORLD` facing, because the plane is placed by the cast's own + * basis and not by the camera; `edge: false`, because the thread below is + * the hairline and two of them fight (see the class comment). + * + * `renderOrder` matters more than it looks: emitters blend into the offset + * buffer by coverage and the last one drawn at near-full coverage takes the + * direction. Nothing else in this ability writes to the buffer, but a + * concurrent cast might, and a blade should lose to a lens rather than + * win — so it sits low. + */ + this.blade = new DistortionField({ + mode: DistortionMode.BLADE, + facing: DistortionFacing.WORLD, + edge: false, + renderOrder: 2, + name: 'slipstream.blade' + }); + this.group.add(this.blade.object3D); + + /** + * The hairline. `nodes`/`sides` are well below the module defaults (96/26) + * and that is a real saving rather than a cheat: a straight tube 1.6 cm + * across needs no tessellation along its length that a 48-node grid does + * not already give it, and eight facets around a barrel whose silhouette is + * under two pixels wide is more than the rasteriser can tell apart. Twelve + * thousand triangles down to about eight hundred, for no visible change. + */ + this.thread = new Tube({ + path: TubePath.STRAIGHT, + prefix: 'thread', + nodes: 48, + sides: 8, + renderOrder: 12 + }); + this.group.add(this.thread.group); + + /** The one thing a cast captures. Unitless. */ + this._seed = 0; + + /** + * The tube's state bag. Instance-scope rather than module-scope because + * four casts can be standing at once and they do not take turns. + */ + this._state = { + origin: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 0, + fade: 1, + widthFade: 1, + seed: 0, + time: 0 + }; + } + + createParticles() { + /** + * One system, and it is nearly empty by design. Air that has been opened + * pulls dust *into* the seam rather than blowing it away, so these are slow, + * small, non-additive and emitted in a hairline column along the part of + * the line already cut. A full cast lands under a hundred live particles + * against the 1500 budget — the restraint is the look. + */ + this.dust = this.ctx.particles.get('slipstream.dust', { + capacity: 900, + shape: ParticleShape.SOFT, + additive: false, + curl: true, + softFade: 0.5 + }); + this.dust.uniforms.uDrag.value = 2.2; + this.dust.uniforms.uEndSize.value = 1.6; + this.dust.uniforms.uSizeIn.value = 0.1; + this.dust.uniforms.uFadeIn.value = 0.12; + this.dust.uniforms.uFadeOut.value = 0.55; + + this.dustEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** The cut holds wide open for this long. Deliberately short. */ + get impactDuration() { + return Math.max(0.02, settings.slipstream.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.slipstream.fadeTime); + } + + /** + * Nothing here is instanced: the whole ability is one quad on the distortion + * layer and three barrels of a hairline tube. Reporting zero is honest, and + * the HUD readout says what it should — that this slot costs nothing. + */ + get instanceCount() { + return 0; + } + + /* ------------------------------------------------------------------ */ + /* Geometry of the cut — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** + * A point on the **cutting edge** at `s` along the cast, 0..1. + * + * The edge is the thing the player is aiming; the sheet of disturbed air + * hangs below it. It leaves the hand at `cutHeight` and arrives at + * `endHeight`, so a blade that drops as it travels is two sliders rather than + * a rotation nobody can reason about. + */ + _edgePoint(s, out) { + const c = settings.slipstream; + const t = saturate(s); + out + .copy(this.origin) + .addScaledVector(this.direction, lerp(c.bladeLead, this.length, t)) + .addScaledVector(this.side, c.bladeSide); + out.y = lerp(c.cutHeight, c.endHeight, t); + return out; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this._seed = Math.random() * 100; + + this.blade.visible = true; + this.thread.visible = true; + + // Sync once before the first frame is drawn, or the blade stands at last + // cast's length for one frame — which on a 22 m cast is very visible. + this._sync(0, 0, 0); + } + + /* ------------------------------------------------------------------ */ + /* The one sync */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current beat into the blade, the thread and + * the dust. Everything with a unit is read here and nowhere else. + * + * @param {number} cut 0..1 of the blade's length that has been opened + * @param {number} spike 0..1 of the cut beat's extra refraction + * @param {number} heal 0..1 of the way back to undisturbed air + */ + _sync(cut, spike, heal) { + const c = settings.slipstream; + const g = settings.global; + + /* --- the plane, in world space --- */ + this._edgePoint(0, _from); + this._edgePoint(1, _to); + + _axis.subVectors(_to, _from); + const span = Math.max(0.05, _axis.length()); + _axis.multiplyScalar(1 / span); + + // The same two crosses `DistortionField#setBasis` performs, done here as + // well because the anchor has to sit half a blade *below* the cutting edge + // and only the caller knows which way "below" ended up pointing once the + // plane was re-orthogonalised against a tilted axis. + _normal.crossVectors(_axis, _up); + if (_normal.lengthSq() < 1e-8) _normal.set(1, 0, 0); + else _normal.normalize(); + _plane.crossVectors(_normal, _axis).normalize(); + + const height = Math.max(0.05, c.bladeHeight); + _anchor.copy(_from).addScaledVector(_plane, -height * 0.5); + + this.blade.setBasis(_axis, _up); + this.blade.setAnchor(_anchor); + + /* --- the refraction --- */ + // Amplitude and mask are separate terms. `strength` is the amplitude and + // nothing else; the silhouette lives in `cut`, `wake` and `opacity`. + const open = 1 - Easing.inQuad(heal); + _warp.width = span; + _warp.height = height; + _warp.cut = clamp(cut, 0.001, 1); + _warp.strength = c.refractStrength * open + c.refractCut * spike; + // Metres in the settings, fractions of the height in the module — see the + // block comment on the settings module for why this division is here and + // not in the block. + _warp.edge = clamp(c.refractEdgeWidth / height, 0.002, 1); + _warp.edgeGain = c.refractEdgeGain; + _warp.wake = clamp((c.refractWakeDepth / height) * (1 - heal * 0.8), 0.02, 1); + _warp.grazing = Math.max(0.05, c.refractGrazing); + _warp.ripple = c.refractRipple; + _warp.rippleScale = c.refractRippleScale * g.noiseFrequency; + _warp.rippleSpeed = c.refractRippleSpeed * g.noiseSpeed; + _warp.opacity = c.refractOpacity * open; + _warp.depthReject = c.refractDepthReject; + _warp.depthFade = c.refractDepthFade; + _warp.perspective = c.refractPerspective; + _warp.perspectiveRef = c.refractPerspectiveRef; + _warp.seed = this._seed; + this.blade.update(_warp); + + /* --- the hairline --- */ + const state = this._state; + state.origin.copy(_from); + state.target.copy(_to); + state.side.copy(this.side); + state.progress = cut; + state.fade = 1 - Easing.inCubic(heal); + // The thread collapses to a filament before it goes out, rather than just + // dimming: a cut closing is the two faces meeting, not a lamp on a rheostat. + state.widthFade = 1 - Easing.outQuad(heal) * 0.92; + state.seed = this._seed; + state.time = this.age; + this.thread.sync(c, state, g); + + /* --- the dust --- */ + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize * 7; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + } + + /** + * Motes drawn into the seam behind the front. + * @param {number} cut 0..1 of the line that has been opened + * @param {number} scale thinning multiplier as the cut heals + */ + _dustFx(dt, cut, scale) { + const c = settings.slipstream; + const g = settings.global; + const count = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (count <= 0) return; + + this._edgePoint(randRange(0.04, 1) * Math.max(0.04, cut), _pos); + _emit.position = _pos; + _emit.radius = c.dustSpread; + // Toward the seam, not away from it — the sign here is the whole reason + // this reads as a low-pressure line rather than as a smoke trail. + _emit.direction = _dir.copy(this.side).multiplyScalar(-0.25).setY(0.9).normalize(); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.6; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(this.u, 0, 0); + + // The light rides the cutting edge, not the floor beneath it. + this._edgePoint(this.u, this.position); + + this._dustFx(dt, this.u, 1); + this.ctx.shake.rumble(settings.slipstream.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.slipstream; + const g = settings.global; + + // No shell, no burn, no shockwave decal. The event is a spike in the + // refraction — handled in `_sync` — plus the smallest possible amount of + // corroboration: a breath of dust, a flick of the camera, a flash you would + // not notice if you were not looking for it. + this._edgePoint(0.85, _pos); + _emit.position = _pos; + _emit.radius = this.length * 0.32; + _emit.direction = _dir.copy(this.side).multiplyScalar(-0.2).setY(1).normalize(); + _emit.speed = c.dustSpeed * 2.4; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.7; + _emit.life = c.dustLifetime * 1.3; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(Math.round(c.dustCut * g.particleCount), _emit); + + this.ctx.shake.add( + c.cutShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.05, c.shakeDuration), + 34 + ); + this.ctx.flash.trigger(getColor(c.colorCutFlash), c.cutFlash * g.explosionIntensity); + } + + onFade(dt, t) { + const c = settings.slipstream; + + // `t` runs 0..1 through the cut and 1..2 through the heal. + const held = saturate(t); + const heal = saturate(t - 1); + // The spike decays over the hold on its own exponent. Authored rather than + // eased so it can be dragged into a slam (0.4) or a sigh (6) while paused. + const spike = t <= 1 ? Math.pow(1 - held, Math.max(0.05, c.refractCutSharp)) : 0; + + this._sync(1, spike, heal); + this._edgePoint(1, this.position); + + this._dustFx(dt, 1, (1 - heal) * 0.45); + } + + onDestroy() { + // Releases the pass's writer counter. Hiding the parent group instead would + // leave the count retained and `PostProcessing` clearing and resampling a + // half-resolution buffer for the rest of the session over nothing. + this.blade.visible = false; + this.thread.visible = false; + } + + dispose() { + this.blade.dispose(); + this.thread.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/arcane/ArcaneVolleyAbility.js b/src/abilities/arcane/ArcaneVolleyAbility.js new file mode 100644 index 0000000..7af83f3 --- /dev/null +++ b/src/abilities/arcane/ArcaneVolleyAbility.js @@ -0,0 +1,668 @@ +import { + BufferAttribute, + InstancedBufferAttribute, + InstancedBufferGeometry, + Mesh, + Sphere, + Vector3 +} from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { + createVolleyTrailMaterial, + createVolleyHeadMaterial, + boltPointJS, + MAX_BOLTS +} from '../../materials/VolleyMaterial.js'; +import { createBoltRibbonGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, Easing } from '../../utils/math.js'; + +/** + * Samples along one trail. The ceiling on how sharp a crossing can look: a + * Lissajous figure at three turns needs about a dozen samples per turn before + * the ribbon stops cutting its own corners. + */ +const TRAIL_NODES = 48; + +const TAU = Math.PI * 2; + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hand = new Vector3(); +const _target = new Vector3(); +const _n1 = new Vector3(); +const _n2 = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/** + * The quad every bolt head is drawn on — parameter space, like the ribbon. + * + * Four vertices carrying `(±1, ±1)` and an instance index, and that is all: the + * vertex shader puts the quad in the world along the view basis, so a head has + * no model matrix, no billboard object and nothing on the CPU to keep in step + * with the trail it belongs to. + */ +function createHeadQuadGeometry(bolts) { + const count = Math.max(1, Math.round(bolts)); + const positions = new Float32Array([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]); + const indices = new Uint16Array([0, 1, 2, 0, 2, 3]); + const strands = new Float32Array(count); + for (let i = 0; i < count; i++) strands[i] = i; + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setAttribute('aStrand', new InstancedBufferAttribute(strands, 1)); + geometry.setIndex(new BufferAttribute(indices, 1)); + geometry.instanceCount = count; + // Placed in world space by the vertex shader; its own bounds mean nothing. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + return geometry; +} + +/** + * ARCANE VOLLEY — seven bolts, seven paths, one hit. + * + * Three beats. The bolts appear at the hand and circle it while the cast winds + * up; they leave together and weave apart, crossing each other the whole way + * down the line; and they arrive at one point on one frame, as a single impact. + * + * **THE TRICK — parametric homing.** Every bolt is a Lissajous figure whose + * amplitude is multiplied by `(1 − τ)^weaveDecay`. That factor is *identically + * zero* at τ = 1, so however wildly a bolt has been weaving it is at the target + * — exactly, to the float — the instant its clock runs out. Nothing steers, + * nothing is corrected, nothing is integrated. Drag `weaveSide` with the + * sandbox paused and all seven curves re-fly, trails included, and all seven + * still land on the same point, because there is nowhere else for the algebra + * to put them. + * + * **What makes the weave interesting rather than uniform** is a small table of + * per-bolt frequency ratios in `materials/VolleyMaterial.js` — 1:2, 2:3, 3:2, + * 2:1 and so on, near-coprime, so no two bolts trace the same figure and their + * crossings never fall into step. `vfx/Projectile`'s LISSAJOUS mode carries one + * ratio for the whole volley and separates the bodies by phase alone, which is + * a braid: seven congruent curves, all reaching their widest point on the same + * frame. That is the reason this ability owns its two materials instead of + * configuring that module — the per-instance ratio is not expressible there, + * and the ratio is the difference between a weave and a rope. + * + * **The wind-up is the same function.** During the charge a bolt's τ is pinned + * at zero and its weave *phase* is rotated instead, so it traces the τ = 0 + * slice of its own figure — a small loop around the hand — and at release the + * rotation stops and the identical offset simply starts being carried + * downrange. The two beats are continuous by construction, which is what lets + * one trail run through the launch without a seam in it. The first version had + * a separate orbit for the charge and lerped between the two at release; the + * bolts visibly jumped, because two curves that meet at a point still disagree + * about their tangent. + * + * The trails are the read and the heads are small. Both are one instanced draw + * — **two draw calls for the entire volley** — and neither records anything: a + * trail vertex asks the same flight function where its bolt *was*, in the + * bolt's own clock, which is why a slider can re-shape history. + * + * **What a cast captures.** One seed and one timestamp (when the volley + * landed). Not a metre. + */ +export class ArcaneVolleyAbility extends Ability { + constructor(context) { + super('arcanevolley', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the trails: one draw call for the whole volley --- */ + this.trailGeometry = createBoltRibbonGeometry(TRAIL_NODES, MAX_BOLTS); + this.trailMaterial = createVolleyTrailMaterial(); + this.trailMesh = new Mesh(this.trailGeometry, this.trailMaterial); + this.trailMesh.frustumCulled = false; + this.trailMesh.matrixAutoUpdate = false; + this.trailMesh.layers.set(LAYER.VFX); + this.trailMesh.renderOrder = 11; + this.group.add(this.trailMesh); + + /* --- the heads: the other one --- */ + this.headGeometry = createHeadQuadGeometry(MAX_BOLTS); + this.headMaterial = createVolleyHeadMaterial(); + this.headMesh = new Mesh(this.headGeometry, this.headMaterial); + this.headMesh.frustumCulled = false; + this.headMesh.matrixAutoUpdate = false; + this.headMesh.layers.set(LAYER.VFX); + this.headMesh.renderOrder = 13; + this.group.add(this.headMesh); + + /** The one dice roll a cast makes. */ + this._seed = 0; + /** Bolts drawn this frame — the HUD's instance count. */ + this._boltCount = 1; + /** When the volley landed, in the cast's own clock. `-1` for "not yet". */ + this._landedAt = -1; + + /* --- scratch handed to the two materials each frame --- */ + this._state = { + hand: new Vector3(), + target: new Vector3(), + side: new Vector3(), + seed: 0, + count: 7, + qHead: 0, + qTail: 0, + fade: 1 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // The gather. `swirl` is the one particle mode none of the shipped six use: + // the mote orbits a travelling anchor, and a negative `uSwirlExpand` walks + // that orbit inward as it ages, so the wind-up genuinely draws power in + // rather than puffing it out. + this.motes = particles.get('arcanevolley.motes', { + capacity: 2400, + shape: ParticleShape.SOFT, + additive: true, + swirl: true, + softFade: 0.35 + }); + this.motes.uniforms.uDrag.value = 1.4; + this.motes.uniforms.uEndSize.value = 0.1; + this.motes.uniforms.uSizeIn.value = 0.05; + this.motes.uniforms.uFadeIn.value = 0.08; + this.motes.uniforms.uFadeOut.value = 0.5; + + // The combined impact. + this.sparks = particles.get('arcanevolley.sparks', { + capacity: 2400, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.5; + this.sparks.uniforms.uEndSize.value = 0.25; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.45; + + // Floor dust under it. Non-additive so it occludes. + this.dust = particles.get('arcanevolley.dust', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 1.8; + this.dust.uniforms.uEndSize.value = 2.4; + this.dust.uniforms.uSizeIn.value = 0.12; + this.dust.uniforms.uFadeIn.value = 0.16; + this.dust.uniforms.uFadeOut.value = 0.32; + + this.intakeEmitter = new RateEmitter(); + this.shedEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + // Two passes over the same bolts. + return this._boltCount * 2; + } + + get impactDuration() { + return Math.max(0.05, settings.arcanevolley.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.arcanevolley.fadeTime); + } + + /** How far the gather has wound up, 0..1. */ + get charge() { + return saturate(this.age / Math.max(0.01, settings.arcanevolley.charge)); + } + + /** Seconds one bolt spends in the air, at the live speed. */ + get flightTime() { + const c = settings.arcanevolley; + return this.length / Math.max(0.01, c.speed * settings.global.speed); + } + + /** A slow breath, not a stutter: these are not electrical. */ + lightShimmer() { + const c = settings.arcanevolley; + return 1 - c.lightPulse * (0.5 - 0.5 * Math.cos(this.age * c.lightPulseSpeed * TAU)); + } + + /** + * Hold the volley at the hand until the gather is up to power. + * + * Bought exactly the way Nova Beam buys its fourth beat: the phase machine + * still runs travel → impact → fade, and the wind-up is a refusal to let the + * front leave the caster. + */ + advance(dt) { + const c = this.config; + const charge = Math.max(0, c.charge); + if (this.age < charge) return false; + + const speed = c.speed * settings.global.speed; + const since = this.age - charge; + this.front += speed * Easing.outQuad(saturate(since / 0.05)) * dt; + + const previousU = this.u; + this.u = saturate(this.front / this.length); + this.pointAt(this.u, this.position); + return this.u >= 1 && previousU < 1; + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the bolts gather, world space. */ + _handPoint(out) { + const c = settings.arcanevolley; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** Where all seven arrive, world space. */ + _impactPoint(out) { + this.pointAt(1, out); + out.y = settings.arcanevolley.endHeight; + return out; + } + + /** + * The perpendicular frame the weave lives in, into the module scratch. + * + * A line-for-line mirror of `volleyFrame()` in `VolleyMaterial.js`: the + * lateral reference is Gram-Schmidt'd against the flight direction, because + * the bolts leave a hand at chest height and land lower, so `Ability#side` is + * only approximately perpendicular to the path they actually fly. + */ + _frame(hand, target) { + _dir.copy(target).sub(hand); + const span = Math.max(_dir.length(), 0.01); + _dir.multiplyScalar(1 / span); + _n1.copy(this.side).addScaledVector(_dir, -this.side.dot(_dir)); + if (_n1.lengthSq() > 1e-8) _n1.normalize(); + else _n1.crossVectors(_dir, _up).normalize(); + _n2.crossVectors(_dir, _n1).normalize(); + } + + /** + * The head's place on its own clock. See `VolleyMaterial.js` for what `q` + * means; in short, `-1` is the start of the gather, `0` is release and `1` is + * the target. + */ + get _q() { + if (this.phase === AbilityPhase.TRAVEL) { + return this.u > 0 ? this.u : -(1 - this.charge); + } + return 1; + } + + /** Where bolt `index` is at `q`, in world space. The JS half of the mirror. */ + _boltPoint(index, q, out) { + const c = settings.arcanevolley; + this._handPoint(_hand); + this._impactPoint(_target); + this._frame(_hand, _target); + return boltPointJS(c, this._seed, index, this._boltCount, q, _hand, _target, _n1, _n2, out); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.intakeEmitter.reset(); + this.shedEmitter.reset(); + this._landedAt = -1; + + // The one thing a cast captures, and it is unitless. + this._seed = Math.random() * 100; + + this._syncUniforms(1); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push live settings and the cast state into both passes and all three + * particle systems. + * + * The only arithmetic worth reading is the tail. `trailSpan` is authored in + * *seconds*, and the shader wants a `q`, so it is divided by whichever clock + * the bolt is currently on — the wind-up before release, the flight after. + * Doing that conversion here rather than in the shader is what keeps the + * whole thing live: `speed`, `charge` and `trailSpan` are all re-read on the + * frame, so a paused volley grows a longer tail the moment you drag any of + * them. + * + * @param {number} fade 1 while the volley is lit, ramping to 0 as it dies + */ + _syncUniforms(fade) { + const c = settings.arcanevolley; + const g = settings.global; + + this._handPoint(_hand); + this._impactPoint(_target); + + const state = this._state; + state.hand.copy(_hand); + state.target.copy(_target); + state.side.copy(this.side); + state.seed = this._seed; + this._boltCount = Math.max(1, Math.min(MAX_BOLTS, Math.round(c.bolts))); + state.count = this._boltCount; + state.fade = fade; + + const qHead = this._q; + state.qHead = qHead; + + // Seconds of trail, in the units of whichever clock the head is on. + const clock = qHead <= 0 ? Math.max(0.01, c.charge) : Math.max(0.01, this.flightTime); + let spanQ = c.trailSpan / clock; + // Once it has landed the tail walks forward to meet a head that has + // stopped: the ribbon *shortens* rather than dimming, which reads as + // something that stopped being made instead of a light going out. + if (this._landedAt >= 0) { + const burn = saturate((this.age - this._landedAt) / Math.max(0.02, c.trailBurn)); + spanQ *= 1 - Easing.outQuad(burn); + } + state.qTail = clamp(qHead - spanQ, -1, 1); + + this.trailGeometry.instanceCount = this._boltCount; + this.headGeometry.instanceCount = this._boltCount; + this.trailMaterial.userData.sync(state); + this.headMaterial.userData.sync(state); + + /* --- the three particle systems --- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.trailGlow * 0.5 * g.glow; + this.motes.uniforms.uSwirl.value = c.moteSwirl * g.animationSpeed; + this.motes.uniforms.uSwirlExpand.value = c.moteSwirlExpand; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.headGlow * 0.5 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.25 * g.turbulence; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /** The flash at the hand as the seven let go of it. */ + _muzzleFx() { + const c = settings.arcanevolley; + const g = settings.global; + + this._handPoint(_pos); + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.muzzleSize * 0.2, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.34, + intensity: c.muzzleIntensity, + opacity: 0.85, + fresnel: 1.7, + displace: 0.4, + colorA: getColor(c.colorMuzzleA), + colorB: getColor(c.colorMuzzleB), + colorC: getColor(c.colorMuzzleC) + }); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /** + * The gather: motes falling in toward the hand on a shrinking orbit. + * + * `anchor` is the hand and the particle is *born on the shell*, which is what + * the swirl mode wants — the offset between the two is the orbit radius, so + * the shell slider is a real metre and not a speed dressed up as one. + */ + _intakeFx(dt) { + const c = settings.arcanevolley; + const g = settings.global; + + const count = Math.round(this.intakeEmitter.tick(dt, c.intakeRate * this.charge) * g.particleCount); + if (count <= 0) return; + + this._handPoint(_pos); + _emit.position = _pos; + _emit.radius = c.moteShell; + // The anchor is the orbit's centre and the velocity is what *drags* that + // centre, so the speed here is a drift and not an approach — the approach + // is `moteSwirlExpand`, which walks the radius in as the mote ages. + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed * 0.3; + _emit.speedVariance = 0.7; + _emit.spread = 0.6; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(count, _emit); + } + + /** Motes shed off the bolts in flight — one bolt per batch, chosen in turn. */ + _shedFx(dt, scale) { + const c = settings.arcanevolley; + const g = settings.global; + + const count = Math.round(this.shedEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (count <= 0) return; + + const q = this._q; + const per = Math.max(1, Math.ceil(count / this._boltCount)); + for (let i = 0; i < this._boltCount; i++) { + this._boltPoint(i, q, _pos); + _emit.position = _pos; + _emit.radius = 0.06; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = _pos; + _emit.size = 0.07; + _emit.sizeVariance = 0.5; + _emit.life = c.moteLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(per, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.arcanevolley; + const g = settings.global; + + this._syncUniforms(1); + + // The light rides the middle of the volley — the axis point the weave is + // measured against — rather than the floor under it. + this._boltPoint(0, this._q, _pos); + this.position.copy(_pos); + + if (this.u <= 0) { + this._intakeFx(dt); + this.ctx.shake.rumble(c.chargeShake * this.charge * g.cameraShake, dt); + } else { + this._shedFx(dt, 1); + this.ctx.shake.rumble(c.rumble * g.cameraShake, dt); + } + } + + onImpact() { + const c = settings.arcanevolley; + const g = settings.global; + const time = frame.uTime.value; + + // The timestamp the tail's catch-up is measured from. An event, not a + // dimension: every metre it feeds is resolved from settings afterwards. + this._landedAt = this.age; + + this._impactPoint(_pos); + this.pointAt(1, _target); + + /* one shell, because seven bolts landing together is one hit */ + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.burstSize * 0.18, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.6, + displace: 0.55, + squash: 0.9, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _target, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.5, + width: 0.05, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + this.ctx.decals.spawn(DecalType.SCORCH, _target, { + radius: c.scorchRadius * 2.2, + life: c.scorchLife, + intensity: c.scorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorEmber), + height: 0.015 + }); + + _emit.position = _pos; + _emit.radius = 0.25; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.4).setY(0.75).normalize(); + _emit.speed = c.sparkSpeed * 2.1; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.2; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(Math.round(c.burstSparks * g.particleCount), _emit); + + _emit.position = _target; + _emit.radius = c.scorchRadius * 2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 2.0; + _emit.spread = 1.0; + _emit.size = 1.3; + _emit.life = c.dustLifetime * 1.2; + _emit.spin = 0.4; + this.dust.emit(Math.round(c.burstDust * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 26 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.3 * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 while the spent trails hang, then 1..2 while they blow out. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._syncUniforms(fade); + + this._impactPoint(this.position); + this._shedFx(dt, fade * 0.25); + } + + onDestroy() { + this._boltCount = 1; + this._landedAt = -1; + this.trailGeometry.instanceCount = 1; + this.headGeometry.instanceCount = 1; + this.trailMaterial.uniforms.uFade.value = 0; + this.headMaterial.uniforms.uFade.value = 0; + } + + dispose() { + this.trailGeometry.dispose(); + this.trailMaterial.dispose(); + this.headGeometry.dispose(); + this.headMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/arcane/AstralgateAbility.js b/src/abilities/arcane/AstralgateAbility.js new file mode 100644 index 0000000..3a045d8 --- /dev/null +++ b/src/abilities/arcane/AstralgateAbility.js @@ -0,0 +1,901 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GrowthField, GrowthLayout, GrowthEmerge, growthParams } from '../../vfx/GrowthField.js'; +import { Portal } from '../../vfx/Portal.js'; +import { DistortionField, DistortionMode, DistortionFacing } from '../../vfx/Distortion.js'; +import { createAstralStoneMaterial } from '../../materials/AstralStoneMaterial.js'; +import { createCrystalGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Distinct obelisk silhouettes. Three, for facet variety — see `GrowthField`. */ +const BODY_VARIANTS = 3; + +/** Hard ceiling on bodies per cast. `bodyCount` clamps here. */ +const MAX_BODIES = 96; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _growth = growthParams(); +const _shape = { sides: 0, taper: 0, roughness: 0, bend: 0 }; +const _gate = {}; +const _lens = {}; +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hit = new Vector3(); +const _normal = new Vector3(0, 1, 0); +const _axisY = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/** + * ASTRAL GATE — geometry emerging *through* a plane. + * + * **The trick is the clip, and the clip is the whole ability.** A ring gate + * tears open in the air over the zone (`vfx/Portal.js`) and obelisks of astral + * stone climb out of it (`vfx/GrowthField.js`). Every fragment of every body + * is tested against the aperture's own plane in + * `materials/AstralStoneMaterial.js`, and anything on the far side of it is + * discarded — in the colour pass *and* in the shadow pass. Take that test out + * and the ability collapses into things popping into existence in front of a + * decal, which is the single most common way a portal effect fails. + * + * ## Why the gate floats + * + * `gateHeight` is the number that makes the clip necessary and it is a slider + * for exactly that reason. Put the aperture on the floor and the clip is free: + * the floor is opaque, the buried half of every body is under it, and nothing + * has to be clipped at all. Float it a metre and a half and the buried half is + * hanging in mid-air, in plain view, under a hole in space. + * + * The tempting shortcut is "the portal is opaque inside its aperture, so it + * hides them anyway". It hides *some* of them, from *one* angle, and only + * once: not during the tear, when most of the aperture is not drawn yet; not + * at the crown, where the material is additive and alpha is 0; not at any + * `gateOpacity` below 1; and not from a camera low enough to see the underside. + * Mostly hidden from one angle is not a clip. + * + * ## The half-space, and the part of it that is not an absence + * + * The test is a general half-space — a point on the plane and a unit normal, + * the same two vectors `Portal#setPlacement` is given — rather than + * `worldY > gateHeight`. One dot product instead of a subtraction, and the + * gate can be tilted later without the material changing. + * + * A clip on its own is a hole in the picture, and a hole is not an effect. The + * same signed distance drives an **emergence lip**: a hot band that sits + * exactly where a body crosses the plane and travels down its shaft as it + * climbs. That band is the only thing in the cast that says *through* rather + * than *in front of*. + * + * ## The shadow pass had to be taught the same thing + * + * The first version stopped at the fragment discard and was correct from every + * angle except downward. `Environment#registerShadowCasterWithPatch` patches + * the material it is handed; three renders shadows with its own generated + * `MeshDepthMaterial`, which had never heard of the gate. So every body that + * had not arrived yet was invisible *and laying a full shadow on the stone + * below it* — a ring of shadows with nothing above them, which is a worse + * artefact than the one the clip removed. The material now ships a + * `customDepthMaterial` carrying the identical test off the **same two uniform + * boxes**, and the ability hangs it on all three of the field's meshes. + * + * ## The beats + * + * 1. **travel** — the summoning front runs out. Nothing at the zone yet. + * 2. **tear** — the aperture opens over `openTime`. `Portal`'s `open` is a + * threshold on a noise field, not a scale, so it unzips raggedly and + * unzips in the same places on the way shut. + * 3. **pour** — `triggerRadial` walks a front outward from the centre of the + * aperture and the bodies come through in order, each firing a breach + * spark on the frame it crosses. The field's footprint is the + * **aperture's**, not the zone's: nothing may come out of a hole it does + * not fit through. + * 4. **hold**, then **withdraw** — the bodies sink back through on + * `retractCurve` and the hole shuts on `shutCurve`, which is the slower of + * the two on purpose. The gate must not close on something that is still + * half in it. + * + * **The rule that makes the editor work.** A cast captures one unitless seed + * and one clock. Every metre — the aperture's two radii, the annulus, every + * obelisk's height and lean, the plane the clip is measured against — is + * resolved against `settings.astralgate` inside the update loop, on a + * zero-length frame included. Pause with **P** mid-pour and drag `gateHeight`: + * the plane moves, the lip climbs every shaft, and bodies that were through + * are buried again. + */ +export class AstralgateAbility extends Ability { + constructor(context) { + super('astralgate', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The gate. Not billboarded and not depth-writing: it is a real plane in + * the world with a real orientation, which is the only way the clip and + * the picture can agree, and a transparent that must be hidden by nearer + * opaque geometry — walk in front of it and it is behind you. + */ + this.gate = new Portal({ renderOrder: 6, name: 'AstralGate' }); + this.group.add(this.gate.object3D); + + this.stone = createAstralStoneMaterial(this.ctx.environment); + + this._readShape(); + this.bodies = new GrowthField(this.group, { + geometry: (variant, shape) => + createCrystalGeometry({ + seed: 8.1 + variant * 27.3, + sides: shape.sides, + taper: shape.taper, + roughness: shape.roughness, + bend: shape.bend + }), + material: this.stone, + shape: _shape, + variants: BODY_VARIANTS, + capacity: MAX_BODIES, + renderOrder: 2 + }); + + // The shadow pass, taught the clip. Hung here rather than in the material + // factory because `customDepthMaterial` is a property of a *mesh* and the + // meshes belong to the field. + for (const mesh of this.bodies.meshes) { + mesh.customDepthMaterial = this.stone.userData.depthMaterial; + } + + /** + * Assigned once, at construction. A closure built inside the update loop + * would be an allocation per instance per frame, which is what I3 forbids. + */ + this.bodies.onBreach = (index, position, radius, height) => + this._breachFx(index, position, radius, height); + + /** + * The bent air at the rim. `Portal` deliberately writes no screen-space + * offsets — an ability that wants a hole does not always want the frame + * warped around it — so the lens is the ability's choice and its own draw + * call. `LENS` rather than `SHOCK`: this one does not travel, it sits. + */ + this.lens = new DistortionField({ + mode: DistortionMode.LENS, + facing: DistortionFacing.BILLBOARD, + name: 'AstralGate:lens' + }); + this.group.add(this.lens.object3D); + + // Note for whoever comes next: everything here is placed from the CPU in + // *world* space and the group's matrix is identity. The clip plane is in + // world space too, so moving or scaling `this.group` would put the picture + // and the clip in different places — which would look like the shader + // failing rather than like a transform being wrong. + + /* --- what a cast captures: one dice roll and one clock --- */ + /** Decorrelates the tear, the starfield and the vein offsets. Unitless. */ + this._seed = 0; + /** Seconds since the summoning front landed. A clock, not a dimension. */ + this._land = 0; + /** One-way: the tear only completes once, however the sliders move. */ + this._opened = false; + /** HUD readout only. */ + this._live = 1; + + /** The gate's anchor and normal, rewritten every frame from live settings. */ + this._anchor = new Vector3(); + this._planeNormal = new Vector3(0, 1, 0); + } + + createParticles() { + const particles = this.ctx.particles; + + // Star-stuff coming up out of the hole. Additive and curled, so it reads as + // light leaking from somewhere rather than as smoke. + this.motes = particles.get('astralgate.motes', { + capacity: 2600, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.5; + this.motes.uniforms.uEndSize.value = 0.16; + this.motes.uniforms.uSizeIn.value = 0.07; + this.motes.uniforms.uFadeIn.value = 0.1; + this.motes.uniforms.uFadeOut.value = 0.4; + + // Chips shaken off a body as it comes through. Lit, not additive: this is + // rock, and rock that glows is the fastest way to make debris read as + // confetti. + this.grit = particles.get('astralgate.grit', { + capacity: 1600, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.3; + this.grit.uniforms.uEndSize.value = 0.75; + this.grit.uniforms.uFadeOut.value = 0.65; + + // Cold haze spilling off the underside. The only thing in the cast that + // moves *downward*, and the reason the gate reads as having an underside at + // all from a camera that is mostly above it. + this.haze = particles.get('astralgate.haze', { + capacity: 1600, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.haze.uniforms.uDrag.value = 2.1; + this.haze.uniforms.uEndSize.value = 2.8; + this.haze.uniforms.uSizeIn.value = 0.14; + this.haze.uniforms.uFadeIn.value = 0.2; + this.haze.uniforms.uFadeOut.value = 0.32; + + this.moteEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + this.hazeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** A beat's length in seconds, under the global lifetime knob. */ + _span(seconds) { + return Math.max(0.01, seconds * settings.global.lifetime); + } + + /** The impact phase holds the tear, the pour and the hold. */ + get impactDuration() { + const c = settings.astralgate; + return this._span(c.openTime) + this._span(c.pourTime) + this._span(c.holdTime); + } + + /** The fade is the withdrawal: the bodies sink back and the hole shuts. */ + get fadeDuration() { + return Math.max(0.05, settings.astralgate.fadeTime); + } + + get instanceCount() { + return this._live; + } + + /** A gate breathes. It does not gutter, and it does not strobe. */ + lightShimmer() { + const c = settings.astralgate; + return 1 - saturate(c.lightPulse) * 0.5 * (1 - Math.cos(this.age * c.lightPulseSpeed)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The live footprint, metres. What the aim circle measured out. */ + get radius() { + return Math.max(0.2, settings.astralgate.zoneRadius); + } + + /** + * The aperture radius, metres. + * + * The growth field reads this too, not `zoneRadius`: nothing may come out of + * a hole it does not fit through, and deriving both from one number is what + * keeps that true while somebody is dragging `gateSpan`. + */ + get apertureRadius() { + return Math.max(0.1, this.radius * settings.astralgate.gateSpan); + } + + /** Where the gate hangs: over the far end of the aimed line. */ + _anchorPoint(out) { + return this.pointAt(1, out).setY(settings.astralgate.gateHeight); + } + + /** 0..1 — how far the aperture has torn open. */ + _openAmount() { + return saturate(this._land / this._span(settings.astralgate.openTime)); + } + + /** 0..1 — how far the pour front has crossed the aperture. */ + _pourAmount() { + const c = settings.astralgate; + return saturate((this._land - this._span(c.openTime)) / this._span(c.pourTime)); + } + + /** The obelisk silhouette, into module scratch. Returns it. */ + _readShape() { + const c = settings.astralgate; + _shape.sides = clamp(Math.round(c.bodySides), 3, 8); + _shape.taper = c.bodyTaper; + _shape.roughness = c.bodyRough; + _shape.bend = c.bodyBend; + return _shape; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.gritEmitter.reset(); + this.hazeEmitter.reset(); + + this._land = 0; + this._opened = false; + + // The only thing a cast captures with no unit on it. + this._seed = Math.random() * 100; + + const c = settings.astralgate; + this.bodies.clear(); + this.bodies.plant(Math.min(MAX_BODIES, Math.round(c.bodyCount)), c.bodyCluster); + + this._sync(1, 0); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current cast state into the gate, the + * bodies, the lens and the three particle systems. + * + * @param {number} fade 1 while the gate stands, ramping to 0 as it shuts + * @param {number} retract 0..1 — how far the bodies have sunk back through + */ + _sync(fade, retract) { + const c = settings.astralgate; + const g = settings.global; + + const travelling = this.phase === AbilityPhase.TRAVEL; + const aperture = this.apertureRadius; + this._anchorPoint(this._anchor); + const open = travelling ? 0 : this._openAmount(); + const pour = travelling ? 0 : this._pourAmount(); + + /* ---------------- 1 · the plane, before anything reads it ---------- */ + // The gate is horizontal today, so the normal is world up. It is carried as + // a vector rather than assumed, because the material's clip, the portal's + // placement and the lens all take it from here — three consumers of one + // definition is how a tilted gate stays a one-line change instead of a + // three-file hunt. + this._planeNormal.copy(_up); + // Portal wants (anchor, along, up) and builds its normal as along × up. To + // land a chosen normal on a plane whose local +X is the cast's side vector, + // the second axis is normal × side — which is what `setPlacement` will + // re-derive, so handing it that vector is a fixed point rather than a + // guess. + _normal.copy(this._planeNormal); + _axisY.crossVectors(_normal, this.side).normalize(); + if (_axisY.lengthSq() < 1e-8) _axisY.copy(this.direction); + this.gate.setPlacement(this._anchor, this.side, _axisY); + + // Hand the same two vectors to the stone. Written every frame, including a + // zero-length one: this is the line that makes dragging `gateHeight` on a + // paused field re-bury the obelisks. + this.stone.userData.sync(this._anchor, this._planeNormal); + + /* ---------------- 2 · the aperture --------------------------------- */ + const p = _gate; + p.radiusX = aperture; + p.radiusY = aperture; + p.margin = c.gateMargin; + // Shut on `shutCurve`, which is deliberately slower than the withdrawal: + // the hole must not close on something that is still half in it. A linear + // shut clipped the last two obelisks off at the knee. + p.open = fade >= 1 ? open : open * (1 - Math.pow(1 - fade, Math.max(0.05, c.shutCurve))); + p.seam = c.tearSeam; + p.tearJag = c.tearJag; + p.tearScale = c.tearScale; + p.tearCrawl = c.tearCrawl; + p.edgeSoft = c.edgeSoft; + p.seed = this._seed; + p.opacity = c.gateOpacity * g.opacity; + + p.rim = c.rim; + p.rimGlow = c.rimGlow * g.glow; + p.core = c.core; + p.coreGlow = c.coreGlow * g.glow; + p.throat = c.throat; + p.throatGlow = c.throatGlow * g.glow; + p.crackCount = c.crackCount; + p.crackWidth = c.crackWidth; + p.crackLength = c.crackLength; + p.crackGlow = c.crackGlow * g.glow; + + p.parallax = c.parallax; + p.swirl = c.swirl; + p.interiorFade = c.interiorFade; + + p.starSize = c.starSize; + p.starTwinkle = c.starTwinkle; + p.starGain = c.starGain; + p.starScaleA = c.starScaleA; + p.starScaleB = c.starScaleB; + p.starScaleC = c.starScaleC; + p.starDepthA = c.starDepthA; + p.starDepthB = c.starDepthB; + p.starDepthC = c.starDepthC; + p.starDriftA = c.starDriftA; + p.starDriftB = c.starDriftB; + p.starDriftC = c.starDriftC; + p.nebulaScale = c.nebulaScale; + p.nebulaSpeed = c.nebulaSpeed; + p.nebulaGain = c.nebulaGain; + p.nebulaDepth = c.nebulaDepth; + + p.colorVoid = c.colorVoid; + p.colorRim = c.colorGateRim; + p.colorCore = c.colorGateCore; + p.colorCrack = c.colorGateCrack; + p.colorThroat = c.colorGateThroat; + p.colorStarA = c.colorStarA; + p.colorStarB = c.colorStarB; + p.colorStarC = c.colorStarC; + p.colorNebulaA = c.colorNebulaA; + p.colorNebulaB = c.colorNebulaB; + this.gate.update(p); + this.gate.visible = p.open > 0.002; + + /* ---------------- 3 · what comes through --------------------------- */ + this.bodies.syncGeometry(this._readShape()); + + const f = _growth; + f.layout = GrowthLayout.ZONE; + // PUSH, not SCALE. An obelisk that accretes in place never crosses the + // plane and the whole ability evaporates — the clip only has anything to + // say about a body that is *moving through* it. + f.emerge = GrowthEmerge.PUSH; + f.origin = this.origin; + f.direction = this.direction; + f.side = this.side; + f.length = this.length; + f.centre = this._anchor; + f.radius = aperture; + f.innerRadius = aperture * saturate(c.bodyInner); + f.radialCurve = c.bodyRadialCurve; + f.radialJitter = c.bodyRadialJitter; + f.angleJitter = c.bodyAngleJitter; + f.clusterRadius = -1; + + f.heightNear = c.bodyHeightNear; + f.height = c.bodyHeight; + f.heightCurve = c.bodyHeightCurve; + f.heightJitter = c.bodyHeightJitter; + f.crown = c.bodyCrown; + f.crownPower = c.bodyCrownPower; + f.peak = c.bodyPeak; + f.peakWidth = c.bodyPeakWidth; + f.rubble = c.bodyRubble; + f.rubbleScale = c.bodyRubbleScale; + f.rubbleSpread = c.bodyRubbleSpread; + + f.radiusNear = c.bodyRadiusNear; + f.radius2 = c.bodyRadius; + f.radiusCurve = c.bodyRadiusCurve; + f.radiusJitter = c.bodyRadiusJitter; + + f.lean = c.bodyLean; + f.leanJitter = c.bodyLeanJitter; + f.leanRamp = c.bodyLeanRamp; + f.leanForward = c.bodyLeanForward; + f.leanOutward = c.bodyLeanOutward; + f.twist = c.bodyTwist; + f.tilt = c.bodyTilt; + + // The one line that puts the field in the air instead of on the floor. + // `GrowthField` measures `baseHeight` from y = 0, and the gate's height is + // the same number the clip plane is at, so a body's base is exactly on the + // plane by construction rather than by tuning. + f.baseHeight = c.gateHeight; + f.baseJitter = 0; + + f.riseTime = c.riseTime; + f.riseOvershoot = c.riseOvershoot; + f.settle = c.settle; + f.springRate = c.springRate; + f.emergeSink = c.emergeSink; + f.birthScale = c.birthScale; + f.birthFade = c.birthFade; + f.breachAt = c.breachAt; + f.sinkDepth = c.sinkDepth; + f.randomness = g.randomness; + + if (!travelling) { + // A front walking outward from the middle of the aperture. `includeCluster` + // is true so the group under the centre goes first, which is the read: + // something arrives, and then the ring of it does. + this.bodies.triggerRadial(this.age, pour, c.riseStagger * this._span(c.pourTime), false, true); + } + this.bodies.update(this.age, f, retract); + this._live = this.bodies.count; + + /* ---------------- 4 · the bent air --------------------------------- */ + const lensRadius = Math.max(0.05, aperture * c.lensSpan); + this.lens.setAnchorXYZ(this._anchor.x, this._anchor.y, this._anchor.z); + _lens.width = lensRadius * 2; + _lens.height = lensRadius * 2; + _lens.radius = lensRadius; + _lens.core = c.lensCore; + _lens.window = c.lensWindow; + _lens.maxOffset = c.lensMaxOffset; + _lens.fold = c.lensFold; + _lens.swirl = c.lensSwirl; + _lens.depthFade = c.lensDepthFade; + _lens.seed = this._seed; + // NEVER multiplied by global.distortion or post.distortion — the pass + // applies both, once, and doing it here squares them. + _lens.strength = c.lensStrength * p.open * fade; + _lens.opacity = c.lensOpacity; + this.lens.update(_lens); + this.lens.visible = p.open > 0.01 && fade > 0.01; + + /* ---------------- 5 · the three particle systems ------------------- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 1.0 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + + this.haze.setGradient( + getColor(c.colorHazeA), + getColor(c.colorHazeB), + getColor(c.colorHazeC), + getColor(c.colorHazeD) + ); + this.haze.uniforms.uGravity.value.set(0, c.hazeFall, 0); + this.haze.uniforms.uSizeScale.value = c.hazeSize * g.particleSize; + this.haze.uniforms.uLifeScale.value = c.hazeLifetime * 0.5 * g.particleLifetime; + this.haze.uniforms.uSpeedScale.value = c.hazeSpeed * g.particleSpeed; + this.haze.uniforms.uOpacity.value = c.hazeOpacity * g.opacity; + this.haze.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /** A small flare at the hand as the summoning front leaves it. */ + _castFx() { + const c = settings.astralgate; + const g = settings.global; + + _pos.copy(this.origin).addScaledVector(this.direction, 0.6).setY(1.3); + + _emit.position = _pos; + _emit.radius = 0.15; + _emit.direction = _dir.copy(this.direction); + _emit.speed = c.moteSpeed * 3.2; + _emit.speedVariance = 0.7; + _emit.spread = 0.6; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime * 0.6; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(Math.round(20 * g.particleCount), _emit); + + this.lightBoost = c.lightIntensity * 0.3 * g.explosionIntensity; + } + + /** + * One body breaks the plane. + * + * `GrowthField` calls this at `breachAt` of the emergence ramp with the + * instance's live world position, footprint and height — every one of them a + * metre resolved from settings on that frame, which is why the grit comes off + * at the right size when somebody drags `bodyRadiusNear` mid-pour. + */ + _breachFx(index, position, radius, height) { + const c = settings.astralgate; + const g = settings.global; + + _pos.copy(position); + // The spark belongs on the *plane*, not at the body's base — the base is + // buried, and grit thrown from under the gate falls out of the bottom of + // the world with nothing to explain it. + _pos.y = c.gateHeight; + + _emit.position = _pos; + _emit.radius = radius * 1.2 + 0.05; + _emit.direction = _dir.set( + position.x - this._anchor.x, + 0, + position.z - this._anchor.z + ); + if (_emit.direction.lengthSq() < 1e-6) _emit.direction.set(0, 1, 0); + _emit.direction.normalize().multiplyScalar(0.55).setY(1).normalize(); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.08; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grit.emit(Math.round(c.gritBreach * g.particleCount), _emit); + + // A puff of star-stuff pushed out of the hole by the body coming through. + // Scaled by the body's own height, so a tall obelisk displaces more than a + // shard does — one line, and it is the difference between a field where + // everything arrives with the same event and one where the big ones land. + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed * 1.6; + _emit.size = 0.07; + _emit.life = c.moteLifetime * 0.8; + _emit.spin = 0; + this.motes.emit(Math.round(4 * height * g.particleCount), _emit); + } + + /** + * Everything the standing gate sheds. + * + * @param {number} dt + * @param {number} open 0..1, how far the aperture is open + * @param {number} scale 0..1, thinned as the cast withdraws + */ + _gateFx(dt, open, scale) { + const c = settings.astralgate; + const g = settings.global; + const time = frame.uTime.value; + const aperture = this.apertureRadius; + const anchor = this._anchor; + + if (open <= 0.01) return; + + /* --- motes, up out of the aperture --- */ + const moteCount = Math.round( + this.moteEmitter.tick(dt, c.moteRate * open * scale) * g.particleCount + ); + if (moteCount > 0) { + // Inside the aperture and nowhere else: sqrt-uniform so a disc fills + // evenly rather than crowding the middle, and multiplied by `open` so the + // motes appear as the hole does instead of a moment before it. + const a = Math.random() * TAU; + const r = aperture * open * Math.sqrt(Math.random()); + _pos.set(anchor.x + Math.cos(a) * r, anchor.y + 0.05, anchor.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = aperture * 0.08; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.06; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + /* --- haze, down off the underside --- */ + const hazeCount = Math.round( + this.hazeEmitter.tick(dt, c.hazeRate * open * scale) * g.particleCount + ); + if (hazeCount > 0) { + const a = Math.random() * TAU; + const r = aperture * (0.5 + 0.5 * Math.random()); + _pos.set(anchor.x + Math.cos(a) * r, anchor.y - 0.1, anchor.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = aperture * 0.2; + _emit.direction = _dir.set(Math.cos(a) * 0.4, -1, Math.sin(a) * 0.4).normalize(); + _emit.speed = c.hazeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.75; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.hazeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.time = time; + this.haze.emit(hazeCount, _emit); + } + + /* --- grit, shaken off whatever is already standing --- */ + if (this.bodies.count === 0) return; + const gritCount = Math.round( + this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount + ); + if (gritCount > 0) { + const index = (Math.random() * this.bodies.count) | 0; + this.bodies.positionOf(index, _growth, _pos); + _pos.y = Math.max(_pos.y, c.gateHeight); + _emit.position = _pos; + _emit.radius = 0.2; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.gritSpeed * 0.4; + _emit.speedVariance = 0.7; + _emit.spread = 0.6; + _emit.size = 0.06; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 7; + _emit.time = time; + this.grit.emit(gritCount, _emit); + } + } + + /** + * The one-shot, tested against the live beat length every frame. + * + * One-way, like every other beat flag in the project: dragging `openTime` + * upward after the hole has torn would otherwise push the threshold back past + * the clock and fire the tear a second time. + */ + _checkBeats() { + if (this._opened) return; + if (this._land < this._span(settings.astralgate.openTime)) return; + this._opened = true; + this._openFx(); + } + + /** The hole finishes tearing: the shell, the floor ring, the flash. */ + _openFx() { + const c = settings.astralgate; + const g = settings.global; + const time = frame.uTime.value; + + this._anchorPoint(_hit); + + this.ctx.bursts.spawn(BurstMode.AIR, _hit, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.7, + fresnel: 1.7, + displace: 0.4, + squash: 0.55, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + // On the floor under the gate, not at it: this is the mark the pressure + // leaves on the stone, and a shockwave hanging in the air at 1.5 m reads as + // a second, smaller portal. + this.pointAt(1, _pos).setY(0); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: this.radius * c.ringSpan * g.explosionIntensity, + life: 0.75, + width: 0.05, + intensity: 1.0, + colorA: getColor(c.colorRingA), + colorB: getColor(c.colorRingB) + }); + + _emit.position = _hit; + _emit.radius = this.apertureRadius * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed * 2.4; + _emit.speedVariance = 0.85; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.75; + _emit.life = c.moteLifetime * 1.3; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(Math.round(c.moteOpen * g.particleCount), _emit); + + this.ctx.shake.add( + c.openShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.openFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.1 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1, 0); + + // The light rides the summoning front out to the point and then climbs. + this.pointAt(this.u, this.position).setY(lerp(1.2, settings.astralgate.gateHeight, this.u)); + + this.ctx.shake.rumble(settings.astralgate.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + // Nothing fires here. The tear runs on its own clock from the moment the + // front lands, and the landing is not one of them — firing the tear here + // opened a hole at three metres before the front had visibly got there. + this._land = 0; + this._anchorPoint(this.position); + } + + onFade(dt, t) { + const c = settings.astralgate; + this._land += dt; + this._checkBeats(); + + // `t` runs 0..1 while the gate stands, then 1..2 while it withdraws. + const out = saturate(t - 1); + const fade = t <= 1 ? 1 : 1 - out; + // The bodies sink back on their own curve, ahead of the hole shutting. At + // 1.5 they hold their height for the first third of the withdrawal and then + // drop, which reads as *being pulled back* rather than as deflating. + const retract = t <= 1 ? 0 : Math.pow(out, Math.max(0.05, c.retractCurve)); + + this._sync(fade, retract); + + // The light climbs out of the hole with the obelisks and sinks with them. + this._anchorPoint(this.position); + this.position.y += + c.bodyHeight * saturate(c.lightHeight) * this._pourAmount() * (1 - retract); + + this._gateFx(dt, this._openAmount() * (t <= 1 ? 1 : 1 - out), fade); + this.ctx.shake.rumble(c.rumble * fade * 0.6 * settings.global.cameraShake, dt); + } + + onDestroy() { + this._live = 1; + this._land = 0; + this._opened = false; + this.bodies.clear(); + this.gate.visible = false; + this.lens.visible = false; + } + + dispose() { + this.bodies.dispose(); + this.gate.dispose(); + this.lens.dispose(); + this.stone.userData.depthMaterial?.dispose(); + this.stone.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/arcane/ChronofractureAbility.js b/src/abilities/arcane/ChronofractureAbility.js new file mode 100644 index 0000000..ba336e3 --- /dev/null +++ b/src/abilities/arcane/ChronofractureAbility.js @@ -0,0 +1,1267 @@ +import { + BufferAttribute, + BufferGeometry, + Color, + DoubleSide, + InstancedBufferAttribute, + InstancedMesh, + Matrix4, + NormalBlending, + Object3D, + PlaneGeometry, + Quaternion, + ShaderMaterial, + Vector3 +} from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { ShatterField, ShatterLayout, shatterParams } from '../../vfx/ShatterField.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { DistortionField, DistortionMode } from '../../vfx/Distortion.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame, sharedUniforms } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, hash11 } from '../../utils/math.js'; +import { noiseGLSL } from '../../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../../shaders/lib/common.glsl.js'; + +/** + * Hard ceiling on panes. Twenty-eight is where the circle stops reading as + * "several sheets hanging in the air" and starts reading as a hedge; the + * editor's `paneCount` clamps here. + */ +const MAX_PANES = 28; +/** Fragment shapes in the break. Two InstancedMeshes, so two draw calls. */ +const SHARD_VARIANTS = 2; +/** Ring-allocated fragment slots. `paneCount × shardsPerPane` clamps into this. */ +const SHARD_CAPACITY = 224; +const TAU = Math.PI * 2; + +/* --- module-scope scratch: nothing in an update path allocates (I3) --- */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +/** Held by `_shatter.origin` across the frame, so nothing else may borrow it. */ +const _shatterOrigin = new Vector3(); +const _normal = new Vector3(); +const _right = new Vector3(); +const _lift = new Vector3(); +const _corner = new Vector3(); +const _up = new Vector3(0, 1, 0); +const _dummy = new Object3D(); +const _tilt = new Quaternion(); +const _basis = new Matrix4(); +const _shatter = shatterParams(); +const _ground = groundFieldParams(); +const _refract = {}; +const _look = { + colorA: new Color(), + colorB: new Color(), + colorEdge: new Color(), + colorScene: new Color(), + opacity: 1, + glow: 1, + rim: 1, + rimPower: 2, + shade: 1, + ambient: 0.4, + fadeStart: 0.6, + soft: 0.2, + sceneMix: 0.7, + refract: 0.05, + saturation: 0.25 +}; + +/* ---------------------------------------------------------------------- */ +/* The pane */ +/* ---------------------------------------------------------------------- */ + +/** + * One sheet of stopped time. + * + * The whole pane is measured in **metres from its own centre**, which is the + * same decision `GroundField` makes and for the same reason: a pane twice as + * wide should carry twice as much frost, not the same frost at twice the size. + * The metres are read back out of the instance matrix's columns, so there is no + * second attribute to keep in step with the transform. + */ +const PANE_VERTEX = /* glsl */ ` + attribute float aSeed; + attribute float aOpen; + + varying float vSeed; + varying float vOpen; + varying vec2 vLocal; + varying vec2 vHalf; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + + void main() { + vSeed = aSeed; + vOpen = aOpen; + + mat4 im = mat4(1.0); + #ifdef USE_INSTANCING + im = instanceMatrix; + #endif + + // The pane's size lives in the instance matrix and nowhere else, so its + // column lengths are where the metres are. Recovering them here is what + // lets every measurement below be a real centimetre count. + vHalf = vec2(length(im[0].xyz), length(im[1].xyz)) * 0.5; + vLocal = position.xy * vHalf * 2.0; + + vec4 world = modelMatrix * im * vec4(position, 1.0); + vNormalW = normalize(mat3(modelMatrix) * mat3(im) * normal); + vViewDir = cameraPosition - world.xyz; + + vec4 mv = viewMatrix * world; + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const PANE_FRAGMENT = /* glsl */ ` + uniform vec3 uColorGlass; + uniform vec3 uColorGhost; + uniform vec3 uColorRim; + uniform vec3 uColorSeam; + uniform vec3 uColorCrack; + + uniform float uOpacity; + uniform float uGlow; + uniform float uFrost; + uniform float uFrostScale; + uniform float uGrain; + uniform float uFringe; + uniform float uFringeOffset; + uniform float uRim; + uniform float uRimPower; + uniform float uBorder; + uniform float uStill; + uniform float uStillSpeed; + uniform float uSoft; + uniform float uSeamWidth; + uniform float uSeamGlow; + uniform float uCrack; + uniform float uCrackArms; + uniform float uCrackRings; + uniform float uCrackWidth; + uniform float uCrackGlow; + uniform float uFade; + + uniform float uTime; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + + varying float vSeed; + varying float vOpen; + varying vec2 vLocal; + varying vec2 vHalf; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + /* ---- the slit: a pane opens outward from its own centre line ---- */ + float openX = vOpen * vHalf.x; + float dx = abs(vLocal.x); + if (dx > openX) discard; + + // The hot edge of the opening has to die as vOpen reaches 1, or every pane + // keeps a bright vertical bar down each side for the whole hold. The first + // version did not, and twenty of them read as a rack of fluorescent tubes. + float seam = smoothstep(max(uSeamWidth, 1e-4), 0.0, openX - dx) + * (1.0 - smoothstep(0.985, 1.0, vOpen)); + + /* ---- the frozen image ---------------------------------------- */ + // There is no scene colour buffer in this build (frame.uSceneColor does not + // exist yet), so there is nothing behind the pane to resample and age. What + // gets aged instead is the pane's own grain: one fbm field in the pane's + // metres, with the red and blue taps pulled uFringeOffset metres apart. + // + // That is deliberately the *shape* of the three-tap scene sample this + // becomes the day somebody adds a colour copy to FrameUniforms — swap the + // fbm for a texture2D and the look is already authored. Written as a flat + // tint instead, it would have had to be thrown away. + // + // What is faked here and honestly cannot be: the blur. A blur needs + // neighbours, neighbours need a texture, and a fake blur with no texture is + // a wash of flat colour that reads as exactly that. The desaturation is + // real, though — the cold wash is alpha-blended over the frame, so it drags + // whatever is behind the pane toward uColorGlass, which is what "colder" + // means when you cannot touch the pixels. + float drift = uTime * uStillSpeed * uStill; + vec3 np = vec3(vLocal * uFrostScale, vSeed + drift); + vec3 tap = vec3(uFringeOffset * uFrostScale, 0.0, 0.0); + float g0 = fbm3(np); + float gr = fbm3(np + tap); + float gb = fbm3(np - tap); + vec3 ghost = mix(vec3(g0), vec3(gr, g0, gb), clamp(uFringe, 0.0, 2.0)) * 0.5 + 0.5; + + float dust = snoise(vec3(vLocal * uFrostScale * 9.0, vSeed * 3.1)) * 0.5 + 0.5; + ghost *= mix(1.0, dust, clamp(uGrain, 0.0, 1.0)); + + /* ---- the perimeter hairline ---------------------------------- */ + float border = smoothstep(max(uBorder, 1e-4), 0.0, min(vHalf.x - dx, vHalf.y - abs(vLocal.y))); + + /* ---- the fractures ------------------------------------------- */ + // Glass does not craze on a lattice, it fails from one stress point: + // radial arms out of it, and a ring or two around it. This is the single + // deliberately angular sample in the file. The house warning about + // atan(y, x) handing every radius along a bearing the same value and + // drawing dead-straight spokes is, here, a description of the thing we + // want — a fracture star genuinely is spokes. + vec2 stress = (hash21(vSeed * 17.3) - 0.5) * vHalf * 1.2; + vec2 rel = vLocal - stress; + float rd = length(rel); + float arms = max(uCrackArms, 1.0); + float spoke = abs(fract(atan(rel.y, rel.x) / 6.283185 * arms + hash11(vSeed * 5.7)) - 0.5) + * 6.283185 / arms * rd; + float ring = abs(fract(rd * uCrackRings + hash11(vSeed * 9.1)) - 0.5) / max(uCrackRings, 1e-3); + float crack = smoothstep(max(uCrackWidth, 1e-4), 0.0, min(spoke, ring)) + * clamp(uCrack, 0.0, 1.0) + * smoothstep(0.0, max(vHalf.x * 0.15, 1e-3), rd); + + /* ---- assemble ------------------------------------------------- */ + vec3 N = normalize(vNormalW); + vec3 V = normalize(vViewDir); + float fres = pow(1.0 - clamp(abs(dot(N, V)), 0.0, 1.0), uRimPower); + + // The wash is *not* scaled by the glow: it is the thing that makes the + // scene behind colder, and a cold wash that blooms is a lamp. + vec3 emit = uColorGhost * ghost * clamp(uFrost, 0.0, 2.0) + + uColorRim * fres * uRim + + uColorSeam * (seam * uSeamGlow + border) + + uColorCrack * crack * uCrackGlow; + vec3 color = uColorGlass + emit * uGlow * uGlobalGlow * mix(0.75, 1.0, uShaderIntensity); + + float alpha = uOpacity + + ghost.g * clamp(uFrost, 0.0, 2.0) * 0.16 + + seam * 0.9 + + border * 0.7 + + crack * 0.8; + alpha = clamp(alpha, 0.0, 1.0) * uFade; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoft); + + if (alpha < 0.004) discard; + gl_FragColor = vec4(color, alpha); + } +`; + +/** + * One fragment shape, in unit space, for the break. + * + * Flat polygons rather than solids, because a pane breaks into flakes and a + * chunky shard reads as rock. Deterministic — the vertices come off `hash11`, + * not `Math.random`, so two runs of the harness build the identical buffer and + * a diff in the geometry checksum means something actually changed. + */ +function createShardGeometry(variant) { + const sides = 3 + variant; // a sliver, then a chip + const positions = new Float32Array(sides * 3); + const normals = new Float32Array(sides * 3); + const indices = new Uint16Array((sides - 2) * 3); + + for (let i = 0; i < sides; i++) { + const angle = (i / sides) * TAU + variant * 0.73; + const radius = 0.34 + 0.66 * hash11(i * 3.71 + variant * 11.13); + positions[i * 3 + 0] = Math.cos(angle) * radius; + positions[i * 3 + 1] = Math.sin(angle) * radius; + positions[i * 3 + 2] = (hash11(i * 7.19 + variant * 5.31) - 0.5) * 0.12; + normals[i * 3 + 2] = 1; + } + for (let i = 1; i < sides - 1; i++) { + indices[(i - 1) * 3 + 0] = 0; + indices[(i - 1) * 3 + 1] = i; + indices[(i - 1) * 3 + 2] = i + 1; + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setAttribute('normal', new BufferAttribute(normals, 3)); + geometry.setIndex(new BufferAttribute(indices, 1)); + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * CHRONOFRACTURE — panes of frozen time hung over the circle. + * + * **The trick is stillness.** Everything else in this sandbox moves: the bolt + * gutters, the crystal glints, the snare's field crawls, the fire boils. This + * ability builds a thing out of nothing over half a second and then *stops*, + * completely, for a second and a half — no drift, no shimmer, no rotation, no + * scroll — and it is the only slot in the fifty that does. A dead-still object + * in a scene full of motion does not read as "cheap", it reads as *wrong*, and + * wrong in exactly the way a pocket of stopped time should. + * + * That is a surprisingly hostile thing to build, because almost every tool in + * the library has a clock in it. The three that had to be argued with: + * + * - `GroundField(RUNE)` rotates its rings by `uTime × spin`. Easing `spin` + * toward zero does not decelerate them, it teleports them — the rotation is + * absolute, not integrated, so the whole seal snaps back through however + * many seconds of wall clock have accumulated. `sealSpin` is therefore a + * constant for the life of a cast and it ships at **0**. + * - `DistortionField(REFRACT)` scrolls its ripple at `uRippleSpeed`. + * `refractRippleSpeed` ships at **0**; the ripple is still there, it just + * stands still, which is what a solid pane of glass does. + * - The pane's own grain would obviously animate. `stillness` is the slider + * that lets it, and it ships at **0** with the label "kills it", because + * the first build had it at 0.3 and the panes immediately became curtains. + * + * The one deliberate exception is `crackLead`: for the last fifth of a second + * hairline fractures ink themselves across every pane. A break with no tell + * reads as a cut in the edit rather than as a thing failing. Set it to 0 and + * the panes go from perfect to gone. + * + * **What is actually on screen.** Four systems, five draw calls: + * + * 1. one `InstancedMesh` of up to 28 panes, each with a bespoke shader that + * measures its frost, its hairline and its fractures in metres recovered + * from its own instance matrix; + * 2. one `DistortionField` in `REFRACT` **hull** mode, whose geometry is the + * same 28 quads rewritten in world space every frame — so all of the + * panes refract for a single draw call instead of one emitter each, which + * is the whole reason the hull path exists; + * 3. one `ShatterField` (2 variants) that is completely empty until the + * break, at which point every pane throws `shardsPerPane` flakes from its + * own footprint; + * 4. one `GroundField(RUNE)` seal, inked while the panes assemble and then, + * like everything else, frozen. + * + * **Why the panes are not the ShatterField.** They were, in the first build. + * `ShatterField`'s flight is a closed-form function of `now − born` — which is + * the right design, and the reason a paused slider can re-fly a fragment — but + * it means a fragment that has been hanging motionless with `speed = 0` cannot + * be told to start falling: raising gravity re-flies it from birth and the + * whole field teleports to where it *would* have been after two seconds of + * falling. So the panes are their own mesh, they hold, and the `ShatterField` + * is born at the instant of the break with `now = age`. The two agree on where + * a pane was because the burst passes that pane's own `along` / `lateral` + * dice, mapped through a LINE layout laid across the circle. + * + * The one thing that does not agree is height: `ShatterField` carries a single + * `spawnHeight` per burst, so a flake's *height* is the band's centre plus its + * own scatter rather than its pane's exact height. At the speed the break + * happens nobody has ever picked it out, and the alternative was 28 bursts + * with 28 params objects. + * + * **What a cast captures.** Per pane: a bearing, a radial fraction, four ±1 + * rolls and a delay fraction — nine unitless dice. Plus one seed and two + * timestamps (the moment the last pane locked, the moment they broke). Not one + * metre, radian, second or colour. Pause with **P** mid-hold and drag + * `paneWidth`, `paneHeight`, `zoneRadius`, `frostScale` or `fringeOffset`: the + * standing wall of glass re-forms around the new numbers without a frame + * passing. + */ +export class ChronofractureAbility extends Ability { + constructor(context) { + super('chronofracture', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the panes ------------------------------------------------- */ + this.paneGeometry = new PlaneGeometry(1, 1); + this.paneOpen = new InstancedBufferAttribute(new Float32Array(MAX_PANES), 1); + this.paneSeed = new InstancedBufferAttribute(new Float32Array(MAX_PANES), 1); + this.paneGeometry.setAttribute('aOpen', this.paneOpen); + this.paneGeometry.setAttribute('aSeed', this.paneSeed); + + this.paneMaterial = new ShaderMaterial({ + name: 'Chronofracture:pane', + transparent: true, + // Normal blending, not additive: the wash has to be able to make the + // frame *darker and greyer* behind it. An additive pane can only ever + // add light, and a pane of frozen time that brightens the room is a + // window into somewhere hotter. + blending: NormalBlending, + depthWrite: false, + depthTest: true, + // Two-sided, because a pane is a plane and you spend half the orbit + // looking at its back. Twenty of them in one InstancedMesh means the + // instances are not depth-sorted against each other — three sorts + // objects, not instances — but at a wash alpha of a third the stacking + // order is not something the eye can pick out, and the alternative was + // twenty draw calls to buy an artefact nobody can see. + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uColorGlass: { value: new Color() }, + uColorGhost: { value: new Color() }, + uColorRim: { value: new Color() }, + uColorSeam: { value: new Color() }, + uColorCrack: { value: new Color() }, + uOpacity: { value: 0.34 }, + uGlow: { value: 1.2 }, + uFrost: { value: 0.6 }, + uFrostScale: { value: 1.4 }, + uGrain: { value: 0.3 }, + uFringe: { value: 0.7 }, + uFringeOffset: { value: 0.05 }, + uRim: { value: 1.1 }, + uRimPower: { value: 2.6 }, + uBorder: { value: 0.045 }, + uStill: { value: 0 }, + uStillSpeed: { value: 0.35 }, + uSoft: { value: 0.35 }, + uSeamWidth: { value: 0.09 }, + uSeamGlow: { value: 3.4 }, + uCrack: { value: 0 }, + uCrackArms: { value: 7 }, + uCrackRings: { value: 1.6 }, + uCrackWidth: { value: 0.012 }, + uCrackGlow: { value: 2.2 }, + uFade: { value: 1 } + }), + vertexShader: PANE_VERTEX, + fragmentShader: PANE_FRAGMENT + }); + + this.panes = new InstancedMesh(this.paneGeometry, this.paneMaterial, MAX_PANES); + this.panes.name = 'Chronofracture:panes'; + this.panes.count = 0; + this.panes.frustumCulled = false; + this.panes.layers.set(LAYER.VFX); + this.panes.renderOrder = 9; + this.group.add(this.panes); + + /* --- the refraction hull -------------------------------------- */ + // Twenty-eight quads in one buffer, rewritten in world space every frame. + // A DistortionField per pane would be twenty-eight draw calls into the + // offset buffer; this is one, and the hull path gives it real per-pane + // normals so the REFRACT rim term bends hardest at each pane's silhouette. + this.hullGeometry = new BufferGeometry(); + this.hullPositions = new Float32Array(MAX_PANES * 4 * 3); + this.hullNormals = new Float32Array(MAX_PANES * 4 * 3); + const hullIndices = new Uint16Array(MAX_PANES * 6); + for (let i = 0; i < MAX_PANES; i++) { + const base = i * 4; + const o = i * 6; + hullIndices[o + 0] = base; + hullIndices[o + 1] = base + 1; + hullIndices[o + 2] = base + 2; + hullIndices[o + 3] = base + 2; + hullIndices[o + 4] = base + 1; + hullIndices[o + 5] = base + 3; + } + this.hullPositionAttribute = new BufferAttribute(this.hullPositions, 3); + this.hullNormalAttribute = new BufferAttribute(this.hullNormals, 3); + this.hullGeometry.setAttribute('position', this.hullPositionAttribute); + this.hullGeometry.setAttribute('normal', this.hullNormalAttribute); + this.hullGeometry.setIndex(new BufferAttribute(hullIndices, 1)); + this.hullGeometry.setDrawRange(0, 0); + + this.hull = new DistortionField({ + mode: DistortionMode.REFRACT, + geometry: this.hullGeometry, + renderOrder: 4, + name: 'chronofracture.panes' + }); + // Hull emitters default to FrontSide, which would drop every pane whose + // normal points away from the eye — and half of them always do, because + // they face radially outward. The REFRACT fragment already flips its + // normal on `gl_FrontFacing`, so two-sided is correct and costs nothing. + this.hull.material.side = DoubleSide; + this.group.add(this.hull.object3D); + + /* --- the break ------------------------------------------------- */ + this.shatter = new ShatterField(this.group, { + geometry: createShardGeometry, + variants: SHARD_VARIANTS, + capacity: SHARD_CAPACITY, + additive: false, + depthWrite: false, + renderOrder: 10 + }); + + /* --- the seal -------------------------------------------------- */ + this.seal = new GroundField(this.group, { + mode: GroundMode.RUNE, + additive: false, + renderOrder: 7, + name: 'Chronofracture:seal' + }); + + /* --- per-cast state: dice and timestamps, nothing with a unit --- */ + this._seed = 0; + this._lockedAt = -1; + this._shatterAt = -1; + this._panes = []; + for (let i = 0; i < MAX_PANES; i++) { + this._panes.push({ + angle: 0, // 0..1 of a turn about the circle + radial: 0, // 0..1, sqrt-uniform so the band fills evenly + heightRoll: 0, // -1..1 + yawRoll: 0, // -1..1 + tiltRoll: 0, // -1..1 + sizeRoll: 0, // -1..1 + delayRoll: 0, // 0..1 of the stagger + seed: 0 // 0..10, mirrored into aSeed + }); + } + this._liveCount = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Dust that stopped when time did. Emitted almost without speed, so it + // hangs — the only particles in the project that are meant to look static. + this.motes = particles.get('chronofracture.motes', { + capacity: 1400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.5 + }); + this.motes.uniforms.uDrag.value = 3.2; + this.motes.uniforms.uEndSize.value = 0.6; + this.motes.uniforms.uSizeIn.value = 0.25; + this.motes.uniforms.uFadeIn.value = 0.3; + this.motes.uniforms.uFadeOut.value = 0.45; + + // The sparks a pane throws as it locks, and the glitter of the break. + this.glints = particles.get('chronofracture.glints', { + capacity: 2200, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.glints.uniforms.uDrag.value = 1.6; + this.glints.uniforms.uEndSize.value = 0.2; + this.glints.uniforms.uSizeIn.value = 0.03; + this.glints.uniforms.uFadeIn.value = 0.04; + this.glints.uniforms.uFadeOut.value = 0.5; + + // The heavy debris. Lit rather than additive: falling glass is a solid. + this.chips = particles.get('chronofracture.chips', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.3; + this.chips.uniforms.uEndSize.value = 0.7; + this.chips.uniforms.uFadeOut.value = 0.65; + + this.moteEmitter = new RateEmitter(); + this.glintEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Panes drawn plus fragments in the air. HUD readout only. */ + get instanceCount() { + return this._liveCount + this.shatter.count; + } + + /** Assembly then hold. The break is the boundary into the fade. */ + get impactDuration() { + const c = settings.chronofracture; + return Math.max(0.05, (c.assembleTime + c.holdTime) * settings.global.lifetime); + } + + /** How long the fragments have to fall. */ + get fadeDuration() { + return Math.max(0.05, settings.chronofracture.fadeTime); + } + + /** + * Seconds the panes take to open, resolved live. + * + * Scaled by `global.lifetime` for the same reason `impactDuration` is: the + * two clocks have to agree or the panes finish opening halfway through a + * hold that has already ended. + */ + get _assembleSpan() { + return Math.max(0.02, settings.chronofracture.assembleTime * settings.global.lifetime); + } + + /** + * The light does not gutter — nothing here does. It sits at full while the + * panes are opening and drops to `lightHold` once they are still, which is a + * step, not a flicker, and it happens on the same frame the last pane locks. + */ + lightShimmer() { + const c = settings.chronofracture; + if (this.phase === AbilityPhase.TRAVEL) return 1; + if (this._shatterAt >= 0) return c.lightHold; + return this.impactTime >= this._assembleSpan ? c.lightHold : 1; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.glintEmitter.reset(); + this.shatter.clear(); + + this._seed = Math.random() * 100; + this._lockedAt = -1; + this._shatterAt = -1; + this._liveCount = 0; + + // Nine unitless dice per pane. Every metre they eventually stand at is + // resolved from `settings.chronofracture` in `_placePanes`, every frame. + for (let i = 0; i < MAX_PANES; i++) { + const pane = this._panes[i]; + pane.angle = Math.random(); + pane.radial = Math.sqrt(Math.random()); + pane.heightRoll = Math.random() * 2 - 1; + pane.yawRoll = Math.random() * 2 - 1; + pane.tiltRoll = Math.random() * 2 - 1; + pane.sizeRoll = Math.random() * 2 - 1; + pane.delayRoll = Math.random(); + pane.seed = Math.random() * 10; + this.paneSeed.array[i] = pane.seed; + } + this.paneSeed.needsUpdate = true; + + this._syncPaneLook(0, 1); + this._syncSeal(0, 0, 1); + this._syncShatterLook(); + } + + /* ------------------------------------------------------------------ */ + /* Live geometry — every dimension re-resolved, every frame */ + /* ------------------------------------------------------------------ */ + + /** The centre of the circle, on the floor. */ + _zoneCentre(out) { + return this.pointAt(1, out); + } + + /** + * Place every pane and rewrite the refraction hull to match. + * + * @param {number} age seconds since the panes started opening (< 0 before) + * @returns {number} panes drawn + */ + _placePanes(age) { + const c = settings.chronofracture; + const count = clamp(Math.round(c.paneCount), 0, MAX_PANES); + if (count <= 0 || age < 0) { + this.panes.count = 0; + this.hullGeometry.setDrawRange(0, 0); + // Release the pass's writer while there is nothing to refract. An + // emitter left visible with an empty draw range still costs the clear, + // the draw and the resample of a half-resolution buffer every frame, and + // `core/Layers.js` counts *visible* meshes, not drawn triangles. + this.hull.visible = false; + return 0; + } + this.hull.visible = true; + + this._zoneCentre(_centre); + + const span = this._assembleSpan; + const stagger = clamp(c.assembleStagger, 0, 0.98); + const openSpan = Math.max(0.02, span * (1 - stagger)); + const curve = Math.max(0.05, c.assembleCurve); + const jitter = settings.global.randomness; + + for (let i = 0; i < count; i++) { + const pane = this._panes[i]; + + /* --- how far open it is ----------------------------------- */ + // Ease-in, so a pane creeps apart and then snaps the last of the way. + // Ease-out looked like a curtain being drawn; the point is that the pane + // was always there and is only now becoming visible. + const raw = saturate((age - pane.delayRoll * stagger * span) / openSpan); + const open = Math.pow(raw, curve); + this.paneOpen.array[i] = open; + + /* --- where it hangs ---------------------------------------- */ + const bearing = pane.angle * TAU; + const cos = Math.cos(bearing); + const sin = Math.sin(bearing); + const radius = c.zoneRadius * lerp(c.paneInner, c.paneReach, pane.radial); + + _pos + .copy(_centre) + .addScaledVector(this.direction, cos * radius) + .addScaledVector(this.side, sin * radius); + _pos.y = c.paneHeight + pane.heightRoll * c.paneRise * jitter; + + /* --- which way it faces ------------------------------------ */ + // Radially outward blended toward the cast heading. All-outward reads as + // a cylinder wall; all-downrange reads as a stack of slides. The blend is + // the slider, because which one is right depends entirely on how wide + // `zoneRadius` is. + _normal + .copy(this.direction) + .multiplyScalar(cos) + .addScaledVector(this.side, sin); + if (_normal.lengthSq() < 1e-8) _normal.copy(this.direction); + _normal.normalize().lerp(this.direction, 1 - saturate(c.paneFaceOut)); + if (_normal.lengthSq() < 1e-8) _normal.copy(this.direction); + _normal.normalize(); + _normal.applyAxisAngle(_up, pane.yawRoll * c.paneYaw * jitter); + + _right.crossVectors(_up, _normal); + if (_right.lengthSq() < 1e-8) _right.set(1, 0, 0); + _right.normalize(); + + _tilt.setFromAxisAngle(_right, pane.tiltRoll * c.paneTilt * jitter); + _normal.applyQuaternion(_tilt).normalize(); + _lift.crossVectors(_normal, _right).normalize(); + + _basis.makeBasis(_right, _lift, _normal); + _dummy.quaternion.setFromRotationMatrix(_basis); + _dummy.position.copy(_pos); + + const scale = 1 + pane.sizeRoll * c.paneSizeJitter * jitter; + const width = Math.max(0.02, c.paneWidth * scale); + const height = Math.max(0.02, width * c.paneAspect); + _dummy.scale.set(width, height, 1); + _dummy.updateMatrix(); + this.panes.setMatrixAt(i, _dummy.matrix); + + /* --- the same quad, in the refraction hull ----------------- */ + // Only the *open* part refracts, so the warp grows with the pane rather + // than snapping on at full width the moment the slit appears. + const halfW = width * 0.5 * open; + const halfH = height * 0.5; + for (let corner = 0; corner < 4; corner++) { + const sx = corner & 1 ? 1 : -1; + const sy = corner & 2 ? 1 : -1; + _corner + .copy(_pos) + .addScaledVector(_right, sx * halfW) + .addScaledVector(_lift, sy * halfH); + const v = (i * 4 + corner) * 3; + this.hullPositions[v + 0] = _corner.x; + this.hullPositions[v + 1] = _corner.y; + this.hullPositions[v + 2] = _corner.z; + this.hullNormals[v + 0] = _normal.x; + this.hullNormals[v + 1] = _normal.y; + this.hullNormals[v + 2] = _normal.z; + } + } + + this.panes.count = count; + this.panes.instanceMatrix.needsUpdate = true; + this.paneOpen.needsUpdate = true; + this.hullPositionAttribute.needsUpdate = true; + this.hullNormalAttribute.needsUpdate = true; + this.hullGeometry.setDrawRange(0, count * 6); + return count; + } + + /** Push the pane look. Every field, every frame — including a dead one. */ + _syncPaneLook(crack, fade) { + const c = settings.chronofracture; + const g = settings.global; + const u = this.paneMaterial.uniforms; + + u.uColorGlass.value.copy(getColor(c.colorGlass)); + u.uColorGhost.value.copy(getColor(c.colorGhost)); + u.uColorRim.value.copy(getColor(c.colorRim)); + u.uColorSeam.value.copy(getColor(c.colorSeam)); + u.uColorCrack.value.copy(getColor(c.colorCrack)); + + u.uOpacity.value = c.paneOpacity * g.opacity; + u.uGlow.value = c.paneGlow * g.glow; + u.uFrost.value = c.frost * g.noiseStrength; + u.uFrostScale.value = c.frostScale * g.noiseFrequency; + u.uGrain.value = c.grain; + u.uFringe.value = c.fringe; + u.uFringeOffset.value = c.fringeOffset; + u.uRim.value = c.rim * g.fresnel; + u.uRimPower.value = c.rimPower; + u.uBorder.value = c.border; + u.uStill.value = c.stillness; + u.uStillSpeed.value = c.stillSpeed * g.noiseSpeed; + u.uSoft.value = c.paneSoft; + u.uSeamWidth.value = c.seamWidth; + u.uSeamGlow.value = c.seamGlow; + u.uCrack.value = crack; + u.uCrackArms.value = Math.max(1, Math.round(c.crackArms)); + u.uCrackRings.value = c.crackRings; + u.uCrackWidth.value = c.crackWidth; + u.uCrackGlow.value = c.crackGlow; + u.uFade.value = fade; + + /* --- the refraction hull rides the same beats --------------- */ + // NB: `global.distortion` and `post.distortion` are deliberately absent + // here. The pass applies both, once; folding them in would apply them + // twice for this ability and not at all for the next one. + _refract.strength = c.refractStrength * fade; + _refract.power = c.refractPower; + _refract.opacity = c.refractOpacity; + _refract.ripple = c.refractRipple; + _refract.rippleScale = c.refractRippleScale; + _refract.rippleSpeed = c.refractRippleSpeed; + _refract.depthReject = c.refractDepthReject; + _refract.depthFade = c.refractDepthFade; + _refract.seed = this._seed; + this.hull.update(_refract); + } + + /** + * The seal on the floor. + * + * @param {number} grow 0..1 the front spreading out to `sealScale` + * @param {number} inscribe 0..1 how much of the script has been inked + * @param {number} fade 0..1 master fade + */ + _syncSeal(grow, inscribe, fade) { + const c = settings.chronofracture; + const g = settings.global; + + this._zoneCentre(_centre); + _ground.centre = _centre; + _ground.yaw = Math.atan2(this.direction.x, this.direction.z); + _ground.height = c.sealHeight; + _ground.radius = Math.max(0.1, c.zoneRadius * c.sealScale); + + _ground.grow = grow; + _ground.recede = 0; + _ground.inscribe = inscribe; + // Ignition trails the inking, so the innermost ring is already alight while + // the outer one is still being written. + _ground.ignite = saturate(inscribe * 1.35 - 0.35); + _ground.fade = fade; + _ground.seed = this._seed; + + _ground.edge = c.sealEdge; + _ground.ragged = c.sealRagged; + _ground.raggedScale = c.sealRaggedScale; + _ground.warp = c.sealWarp; + + _ground.relief = c.sealRelief; + _ground.normalStep = c.sealNormalStep; + _ground.ambient = c.sealAmbient; + _ground.wrap = c.sealWrap; + _ground.specular = c.sealSpecular; + _ground.gloss = c.sealGloss; + _ground.parallax = c.sealParallax; + + _ground.cell = c.sealCell; + _ground.detail = c.sealDetail; + _ground.thickness = c.sealThickness; + _ground.depth = c.sealDepth; + + _ground.rings = Math.round(c.sealRings); + _ground.ringInner = c.sealRingInner; + _ground.glyphSize = c.sealGlyphSize; + _ground.glyphStroke = c.sealGlyphStroke; + _ground.glyphGap = c.sealGlyphGap; + _ground.spin = c.sealSpin; + _ground.spinFalloff = c.sealSpinFalloff; + _ground.rule = c.sealRule; + + _ground.additive = false; + _ground.emissive = c.sealEmissive; + _ground.opacity = c.sealOpacity; + _ground.depthFade = c.sealDepthFade; + _ground.colorBase = c.colorSealBase; + _ground.colorEdge = c.colorSealEdge; + _ground.colorGlow = c.colorSealGlow; + _ground.colorDeep = c.colorSealDeep; + + _ground.noiseStrength = g.noiseStrength; + _ground.noiseFrequency = g.noiseFrequency; + _ground.noiseSpeed = g.noiseSpeed; + _ground.opacityScale = g.opacity; + + this.seal.update(_ground); + } + + /** Colours and shading for the fragments. Pushed every frame, live. */ + _syncShatterLook() { + const c = settings.chronofracture; + const g = settings.global; + + _look.colorA.copy(getColor(c.colorShardA)); + _look.colorB.copy(getColor(c.colorShardB)); + _look.colorEdge.copy(getColor(c.colorShardEdge)); + _look.colorScene.copy(getColor(c.colorShardScene)); + _look.opacity = c.shardOpacity * g.opacity; + _look.glow = c.shardGlow * g.glow; + _look.rim = c.shardRim * g.fresnel; + _look.rimPower = c.shardRimPower; + _look.shade = c.shardShade; + _look.ambient = c.shardAmbient; + _look.fadeStart = c.shardFadeStart; + _look.soft = c.shardSoft; + _look.sceneMix = c.shardSceneMix; + _look.refract = c.shardRefract; + _look.saturation = c.shardSaturation; + this.shatter.sync(_look); + } + + /** + * The fragments' basis and flight, live. + * + * A LINE layout laid *across* the circle rather than a ZONE one, because + * `burst()` only lets the caller aim a fragment with `along` and `lateral` — + * a ZONE burst rolls its own bearing, and a pane that broke on the left has + * to leave its glass on the left. + */ + _syncShatterFlight() { + const c = settings.chronofracture; + const g = settings.global; + const reach = Math.max(0.01, Math.max(c.paneInner, c.paneReach)); + const half = c.zoneRadius * reach; + + this._zoneCentre(_centre); + _shatter.layout = ShatterLayout.LINE; + _shatter.origin = _shatterOrigin.copy(_centre).addScaledVector(this.direction, -half); + _shatter.direction = this.direction; + _shatter.side = this.side; + _shatter.length = half * 2; + _shatter.width = half; + _shatter.centre = null; + _shatter.radius = half; + + _shatter.spawnHeight = c.paneHeight; + _shatter.spawnRadius = c.shardScatter; + + _shatter.speed = c.shardSpeed; + _shatter.speedJitter = c.shardSpeedJitter; + _shatter.spread = c.shardSpread; + _shatter.upBias = c.shardUp; + _shatter.inherit = null; + _shatter.inheritScale = 1; + + _shatter.gravity = c.shardGravity; + _shatter.drag = c.shardDrag; + + _shatter.size = c.shardSize; + _shatter.sizeJitter = c.shardSizeJitter; + _shatter.shrink = c.shardShrink; + _shatter.shrinkPower = c.shardShrinkPower; + _shatter.spin = c.shardSpin; + _shatter.spinJitter = c.shardSpinJitter; + + _shatter.lifetime = c.shardLifetime; + _shatter.floor = c.shardFloor; + _shatter.floorSpin = c.shardFloorSpin; + _shatter.randomness = g.randomness; + + this.shatter.update(this.age, _shatter); + } + + /* ------------------------------------------------------------------ */ + /* Particles */ + /* ------------------------------------------------------------------ */ + + /** A random point inside the band the panes hang in. Emitters only. */ + _bandPoint(out) { + const c = settings.chronofracture; + const bearing = Math.random() * TAU; + const radius = c.zoneRadius * lerp(c.paneInner, c.paneReach, Math.sqrt(Math.random())); + this._zoneCentre(out); + out.addScaledVector(this.direction, Math.cos(bearing) * radius); + out.addScaledVector(this.side, Math.sin(bearing) * radius); + out.y = c.paneHeight + (Math.random() * 2 - 1) * c.paneRise; + return out; + } + + /** + * Motes and glints. + * + * @param {number} scale 0..1 — hard down during the hold, because dust that + * is visibly streaming is dust that has not stopped + */ + _airFx(dt, scale) { + const c = settings.chronofracture; + const g = settings.global; + const time = frame.uTime.value; + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + this._bandPoint(_pos); + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.12; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + const glintCount = Math.round(this.glintEmitter.tick(dt, c.glintRate * scale) * g.particleCount); + if (glintCount > 0) { + this._bandPoint(_pos); + _emit.position = _pos; + _emit.radius = c.paneWidth * 0.6; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.glintSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.7; + _emit.life = c.glintLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.glints.emit(glintCount, _emit); + } + } + + /** Particle gradients and scales. Pushed every frame so the pickers are live. */ + _syncParticles() { + const c = settings.chronofracture; + const g = settings.global; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteDrift, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 0.85 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.glints.setGradient( + getColor(c.colorGlintA), + getColor(c.colorGlintB), + getColor(c.colorGlintC), + getColor(c.colorGlintD) + ); + this.glints.uniforms.uGravity.value.set(0, c.glintGravity, 0); + this.glints.uniforms.uSizeScale.value = c.glintSize * g.particleSize * 7; + this.glints.uniforms.uLifeScale.value = c.glintLifetime * 0.5 * g.particleLifetime; + this.glints.uniforms.uSpeedScale.value = g.particleSpeed; + this.glints.uniforms.uOpacity.value = g.opacity; + this.glints.uniforms.uGlow.value = 1.1 * g.glow; + this.glints.uniforms.uStretch.value = c.glintStretch; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = c.chipLifetime * 0.5 * g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.chronofracture; + + this._syncParticles(); + // The seal is inked ahead of the panes: by the time the front lands, the + // script is already most of the way round. + this._syncSeal(this.u, saturate(this.u * 0.8), 1); + this._syncPaneLook(0, 1); + this._syncShatterLook(); + this._syncShatterFlight(); + this._liveCount = this._placePanes(-1); + + // The light rides the front out and then sits in the middle of the band. + this.position.y = lerp(0.2, c.paneHeight, saturate(this.u)); + } + + onImpact() { + // Nothing one-shot happens here: the impact *is* the assembly, and the two + // events worth marking (the last pane locking, the break) both fall inside + // the phase rather than at its boundaries. + this.ctx.shake.add( + settings.chronofracture.breakShake * 0.25 * settings.global.cameraShake, + 4, + 18 + ); + } + + onFade(dt, t) { + const c = settings.chronofracture; + + this._syncParticles(); + this._syncShatterLook(); + + if (t <= 1) { + /* ---- assembly, then the hold ------------------------------ */ + const age = this.impactTime; + const span = this._assembleSpan; + const remaining = Math.max(0, this.impactDuration - age); + const assembling = age < span; + + // The one moving thing in the hold, and it lasts `crackLead` seconds. + const crack = c.crackLead > 1e-4 ? saturate(1 - remaining / c.crackLead) : 0; + + this._syncPaneLook(crack, 1); + this._syncSeal(1, saturate(0.8 + age / Math.max(span, 1e-3) * 0.2), 1); + this._liveCount = this._placePanes(age); + this._syncShatterFlight(); + + if (!assembling && this._lockedAt < 0) { + this._lockedAt = this.age; + this.ctx.flash.trigger( + getColor(c.colorFreezeFlash), + c.freezeFlash * settings.global.explosionIntensity + ); + } + + // Emission collapses to almost nothing once the panes are still. Dust + // that is visibly streaming is dust that did not stop. + this._airFx(dt, assembling ? 1 : 0.12); + + this._zoneCentre(this.position); + this.position.y = c.paneHeight; + return; + } + + /* ---- the break, and the fall ------------------------------- */ + if (this._shatterAt < 0) this._break(); + + const fall = saturate(t - 1); + this._syncPaneLook(1, 0); + this._liveCount = this._placePanes(-1); + this._syncSeal(1, 1, 1 - fall * fall); + this._syncShatterFlight(); + this._airFx(dt, 0.25 * (1 - fall)); + + this._zoneCentre(this.position); + this.position.y = c.paneHeight * (1 - fall * 0.7); + } + + /** + * All at once. Every pane throws its glass from its own footprint on the + * same frame, because a staggered break is a collapse and this is a failure. + */ + _break() { + const c = settings.chronofracture; + const g = settings.global; + const time = frame.uTime.value; + + this._shatterAt = this.age; + + /* --- the fragments, pane by pane ---------------------------- */ + const count = clamp(Math.round(c.paneCount), 0, MAX_PANES); + const per = Math.max(0, Math.round(c.shardsPerPane)); + const reach = Math.max(0.01, Math.max(c.paneInner, c.paneReach)); + for (let i = 0; i < count; i++) { + const pane = this._panes[i]; + const bearing = pane.angle * TAU; + const fraction = lerp(c.paneInner, c.paneReach, pane.radial) / reach; + // `along` is saturated by the field, so the circle is mapped onto 0..1 + // with its centre at 0.5; `lateral` is signed and is not. + this.shatter.burst(this.age, per, 0.5 + 0.5 * Math.cos(bearing) * fraction, Math.sin(bearing) * fraction); + } + + /* --- the shell of released time ----------------------------- */ + this._zoneCentre(_pos); + _pos.y = c.paneHeight; + this.ctx.bursts.spawn(BurstMode.FROST, _pos, { + radius: c.breakBurst * 0.25, + endRadius: c.breakBurst * g.explosionIntensity, + life: 0.6, + intensity: c.breakBurstIntensity, + opacity: 0.85, + fresnel: 1.7, + displace: 0.4, + squash: 0.9, + colorA: getColor(c.colorBreakA), + colorB: getColor(c.colorBreakB), + colorC: getColor(c.colorBreakC) + }); + + /* --- glitter and heavy debris ------------------------------- */ + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.85; + _emit.direction = _dir.set(0, -0.35, 0).normalize(); + _emit.speed = c.glintSpeed * 2.4; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.24; + _emit.sizeVariance = 0.8; + _emit.life = c.glintLifetime * 1.6; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.glints.emit(Math.round(c.glintBurst * g.particleCount), _emit); + + _emit.speed = c.chipSpeed; + _emit.size = 0.16; + _emit.life = c.chipLifetime; + _emit.spin = 9; + this.chips.emit(Math.round(c.chipBurst * g.particleCount), _emit); + + /* --- the room notices --------------------------------------- */ + this.ctx.shake.add( + c.breakShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.05, c.breakShakeDuration), + 24 + ); + this.ctx.flash.trigger(getColor(c.colorBreakFlash), c.breakFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * c.lightBreak * 0.1 * g.explosionIntensity; + } + + onDestroy() { + this.shatter.clear(); + this.panes.count = 0; + this._liveCount = 0; + this.hullGeometry.setDrawRange(0, 0); + this.hull.visible = false; + this.paneMaterial.uniforms.uFade.value = 0; + } + + dispose() { + this.paneGeometry.dispose(); + this.paneMaterial.dispose(); + this.hullGeometry.dispose(); + this.hull.dispose(); + this.shatter.dispose(); + this.seal.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/arcane/GlyphstormAbility.js b/src/abilities/arcane/GlyphstormAbility.js new file mode 100644 index 0000000..c294076 --- /dev/null +++ b/src/abilities/arcane/GlyphstormAbility.js @@ -0,0 +1,618 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Swarm, Silhouette, LeadPath, swarmParams } from '../../vfx/Swarm.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing } from '../../utils/math.js'; + +/** + * Hard ceiling on cards. Three hundred and eighty-four is a little over the + * 13 × 8 × 4 lattice the block ships with, which is deliberate: the separation + * guarantee holds while `cards <= latticeX × latticeY × latticeZ`, and leaving + * headroom means dragging `cards` up finds the wall rather than the capacity. + */ +const MAX_CARDS = 384; + +/* --- module-scope scratch: nothing in an update path allocates (I3) --- */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _lead = new Vector3(); +const _swarm = swarmParams(); +const _ground = groundFieldParams(); + +/** + * GLYPHSTORM — a blizzard of legible marks thrown down the line. + * + * **The trick is that the cards keep their own facing.** `Swarm`'s `billboard` + * blends a card's basis between the agent's own frame and the camera's. At 1 it + * is a sprite and the storm is a flat wall of symbols that never changes; at 0 + * it is a plate in the world and the storm is a scatter of bright lines with no + * symbols in it at all. This ships at **0.22** — the card is very nearly its + * own thing — and the formation's `churn` then rolls every card through its own + * edge-on angle at its own moment. What you get is neither of the two static + * reads: it is a storm that *flickers* between them, hundreds of times a + * second, and the flicker comes free out of the billboard maths. + * + * It comes free but it does not survive on its own. A card going edge-on + * collapses toward a zero-width line, and a zero-width line at twenty metres + * is under a pixel: it aliases into nothing, and instead of a mark seen + * side-on you get a hole in the storm. Two sliders fix that and both are + * load-bearing — `edgeStretch` grows the card as it turns so its line always + * covers a pixel, and `edgeGain` lifts its emission so the moment it goes thin + * it also goes *hot*. Set either to its neutral value and the storm stops + * flickering and starts guttering. + * + * **Three beats, and the second one is not a phase.** + * + * 1. **Gather.** `gather` starts at `gatherTight`, which collapses every card + * onto the lead's own path — a knot of light at the hand — and `reveal` + * starts at zero, so the cards are *written* rather than switched on. Both + * open over the first third of the flight. + * 2. **Storm.** The lead runs down the cast line; the formation strings out + * behind it because cohesion in this module is a lag, not a force, and + * pours round the loft a beat late. + * 3. **Settle.** Through the impact phase the lead's end height drops to + * `settleHeight`, the vertical spacing collapses to `settleSpacing` of + * itself, the churn dies, and `reveal` runs *backwards* — so the cards go + * out in a wave rather than all at once — while a `GroundField(LATTICE)` + * charge crosses the floor underneath. The marks do not land on the + * lattice; they become it. + * + * **Two draw calls.** One `Swarm`, one `GroundField`. That is the whole + * ability, and it is worth saying out loud: three hundred separately oriented, + * separately banking, separately glyphed quads plus a propagating hex circuit + * cost less than the beam's tube. Nothing about a card exists on the CPU — it + * carries a seed and an index, and every metre it uses is a uniform re-read + * from `settings.glyphstorm` in the vertex shader each frame. + * + * **On the alphabet.** The roster asks whether `GroundField`'s RUNE letterforms + * could be shared here. They could not — `GLYPH_ALPHABET` is module-private — + * and after reading the argument in `src/vfx/README.md` I would not have used + * them anyway: a seal is a thing you pause and stare at and it earns sixteen + * authored letterforms; a storm is weather. `CARD`'s six-stroke walk between + * the points of a 3 × 5 lattice is cheaper *and* better here, because what + * makes three hundred marks read as writing is shared terminals and shared + * angles, which the lattice gives you and a legible font does not need. + * + * **What a cast captures.** One seed, handed to `Swarm.roll()`, plus the + * per-agent dice the module rolls behind it. No metre, radian, second or + * colour. Pause mid-flight with **P** and drag `spacingSide`, `cardSize`, + * `billboard` or `lag`: the storm re-forms around the new numbers with the + * clock stopped, because it is a closed-form function of time and always was. + */ +export class GlyphstormAbility extends Ability { + constructor(context) { + super('glyphstorm', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.swarm = new Swarm(this.group, { + capacity: MAX_CARDS, + silhouette: Silhouette.CARD, + // Additive: a mark is ink that is on fire, not ink on paper, and two + // overlapping cards should get brighter rather than flatter. It is also + // what lets the edge-on gain read at all — an alpha-blended card at + // triple emission just clips to white. + additive: true, + renderOrder: 12 + }); + + this.burn = new GroundField(this.group, { + mode: GroundMode.LATTICE, + // Shaded, not additive: the etch between the traces has to come out + // *darker* than the floor or the lattice reads as a projection. + additive: false, + renderOrder: 7, + name: 'Glyphstorm:lattice' + }); + + _swarm.leadMode = LeadPath.LINE; + _swarm.silhouette = Silhouette.CARD; + + /** Timestamp of the frame the lattice took. An event, not a dimension. */ + this._tookAt = -1; + this._seed = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Ink: the loose marks that never made it onto a card. Curl-driven, so + // they roll rather than drift, which is what keeps them reading as a + // medium rather than as dust. + this.ink = particles.get('glyphstorm.ink', { + capacity: 1200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.ink.uniforms.uDrag.value = 1.5; + this.ink.uniforms.uEndSize.value = 0.3; + this.ink.uniforms.uSizeIn.value = 0.08; + this.ink.uniforms.uFadeIn.value = 0.1; + this.ink.uniforms.uFadeOut.value = 0.45; + + // Sparks struck off a card as it touches the floor. + this.sparks = particles.get('glyphstorm.sparks', { + capacity: 1600, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.5; + this.sparks.uniforms.uEndSize.value = 0.22; + this.sparks.uniforms.uSizeIn.value = 0.03; + this.sparks.uniforms.uFadeIn.value = 0.04; + this.sparks.uniforms.uFadeOut.value = 0.5; + + this.inkEmitter = new RateEmitter(); + this.sparkEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.swarm.count; + } + + /** The settle, then the lattice holding. */ + get impactDuration() { + const c = settings.glyphstorm; + return Math.max(0.05, (c.settleTime + c.lifetime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.glyphstorm.fadeTime); + } + + /** Seconds the cards take to come down, on the same clock as the phase. */ + get _settleSpan() { + return Math.max(0.02, settings.glyphstorm.settleTime * settings.global.lifetime); + } + + /** + * The light drops to `lightSettle` once the storm is on the floor. + * + * A step rather than a flicker, and no shimmer at all: a hundred separately + * blinking cards already carry every bit of high-frequency brightness this + * ability can afford, and a guttering key light on top of that reads as a + * broken monitor. + */ + lightShimmer() { + const c = settings.glyphstorm; + if (this.phase === AbilityPhase.TRAVEL) return 1; + return this._tookAt >= 0 ? c.lightSettle : 1; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.inkEmitter.reset(); + this.sparkEmitter.reset(); + + // The one thing a cast captures: a seed. `Swarm.roll` shifts the whole + // separation lattice by it, so two casts do not deal the same hand of + // cards to the same cells. + this._seed = Math.random() * 100; + this.swarm.roll(this._seed); + this._tookAt = -1; + + this._syncParticles(); + this._syncSwarm(0, 0); + this._syncBurn(0, 0, 1); + this._muzzleFx(); + } + + /** Where the cards are written out of, in world space. */ + _handPoint(out) { + const c = settings.glyphstorm; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /* ------------------------------------------------------------------ */ + /* Live state — every dimension re-resolved, every frame */ + /* ------------------------------------------------------------------ */ + + /** + * Push the flock. + * + * @param {number} s 0..1 down the cast line the lead has reached + * @param {number} settle 0..1 through the landing + */ + _syncSwarm(s, settle) { + const c = settings.glyphstorm; + const g = settings.global; + + this.swarm.setBasis(this.origin, this.direction, this.side, this.length); + this.swarm.setColors(c.colorCardA, c.colorCardB, c.colorCardC, c.colorCardD); + + const ease = Easing.outCubic(settle); + + _swarm.count = Math.min(MAX_CARDS, Math.max(0, Math.round(c.cards))); + + /* --- the lead ------------------------------------------------- */ + _swarm.leadMode = LeadPath.LINE; + _swarm.leadS = s; + // d(s)/dt, so the shader can rewind the lead by each rank's own lag. Zero + // once the storm has arrived: a stationary lead with a live rate would keep + // stringing the formation out behind a point that is not moving. + _swarm.leadRate = + this.phase === AbilityPhase.TRAVEL ? (c.speed * g.speed) / Math.max(0.01, this.length) : 0; + _swarm.leadRise = c.leadRise * (1 - ease); + _swarm.handForward = c.handForward; + _swarm.handSide = c.handSide; + _swarm.handHeight = c.handHeight; + _swarm.endHeight = lerp(c.flightHeight, c.settleHeight, ease); + + /* --- the formation -------------------------------------------- */ + _swarm.latticeX = c.latticeX; + _swarm.latticeY = c.latticeY; + _swarm.latticeZ = c.latticeZ; + _swarm.spacingSide = c.spacingSide; + // The formation flattens as it lands rather than shrinking: the cards + // spread *out* across the floor, they do not pile up on it. + _swarm.spacingUp = c.spacingUp * lerp(1, c.settleSpacing, ease); + _swarm.lag = c.lag; + _swarm.jitter = c.formJitter * g.randomness; + _swarm.churn = c.churn * lerp(1, c.settleChurn, ease); + _swarm.breathe = c.breathe; + _swarm.breatheRate = c.breatheRate; + _swarm.wander = c.wander; + _swarm.wanderScale = c.wanderScale * g.noiseFrequency; + _swarm.wanderSpeed = c.wanderSpeed * g.noiseSpeed; + _swarm.gather = lerp( + lerp(c.gatherTight, c.gatherStorm, saturate(s / Math.max(1e-3, c.gatherRamp))), + c.settleGather, + ease + ); + + /* --- the body -------------------------------------------------- */ + _swarm.size = c.cardSize; + _swarm.aspect = c.cardAspect; + _swarm.sizeJitter = c.cardSizeJitter * g.randomness; + _swarm.billboard = c.billboard; + _swarm.bank = c.bank; + _swarm.bankMax = c.bankMax; + _swarm.dihedral = c.dihedral; + _swarm.flapRate = c.flapRate; + _swarm.curl = c.curl; + _swarm.edgeStretch = c.edgeStretch; + _swarm.edgeGain = c.edgeGain; + // Forward on the way out, backward on the way down: the same wave that + // wrote the cards in takes them away, so the storm goes out card by card + // instead of being switched off. + _swarm.reveal = + this.phase === AbilityPhase.TRAVEL + ? saturate(s / Math.max(1e-3, c.revealRamp)) + : 1 - saturate((settle - 0.45) / 0.55); + _swarm.revealSpread = c.revealSpread; + + /* --- the silhouette -------------------------------------------- */ + _swarm.silhouette = Silhouette.CARD; + _swarm.glyphWeight = c.glyphWeight; + _swarm.glyphStrokes = Math.round(c.glyphStrokes); + _swarm.cardFrame = c.cardFrame; + _swarm.lit = c.cardLit; + + _swarm.tint = c.tint; + _swarm.tintJitter = c.tintJitter; + _swarm.tintAlong = c.tintAlong; + _swarm.opacity = c.cardOpacity * g.opacity * (1 - c.settleDim * ease); + _swarm.glow = c.cardGlow * g.glow; + _swarm.softFade = c.cardSoft; + + this.swarm.update(this.age, _swarm); + } + + /** + * Push the lattice. + * + * @param {number} grow 0..1 the charge crossing the footprint + * @param {number} recede 0..1 the outside being eaten back + * @param {number} fade 0..1 master fade + */ + _syncBurn(grow, recede, fade) { + const c = settings.glyphstorm; + const g = settings.global; + + this.pointAt(1, _pos); + _ground.centre = _pos; + _ground.yaw = Math.atan2(this.direction.x, this.direction.z); + _ground.height = c.burnHeight; + _ground.radius = Math.max(0.1, c.burnRadius); + + _ground.grow = grow; + _ground.recede = recede; + _ground.fade = fade; + _ground.seed = this._seed; + + _ground.edge = c.burnEdge; + _ground.ragged = c.burnRagged; + _ground.raggedScale = c.burnRaggedScale; + _ground.warp = c.burnWarp; + + _ground.relief = c.burnRelief; + _ground.normalStep = c.burnNormalStep; + _ground.ambient = c.burnAmbient; + _ground.wrap = c.burnWrap; + _ground.specular = c.burnSpecular; + _ground.gloss = c.burnGloss; + _ground.parallax = c.burnParallax; + + _ground.cell = c.burnCell; + _ground.cellJitter = c.burnCellJitter; + _ground.seam = c.burnSeam; + _ground.thickness = c.burnThickness; + _ground.lift = c.burnLift; + _ground.depth = c.burnDepth; + _ground.speed = c.burnSpeed; + + _ground.additive = false; + _ground.emissive = c.burnEmissive; + _ground.opacity = c.burnOpacity; + _ground.depthFade = c.burnDepthFade; + _ground.colorBase = c.colorBurnBase; + _ground.colorEdge = c.colorBurnEdge; + _ground.colorGlow = c.colorBurnGlow; + _ground.colorDeep = c.colorBurnDeep; + + _ground.noiseStrength = g.noiseStrength; + _ground.noiseFrequency = g.noiseFrequency; + _ground.noiseSpeed = g.noiseSpeed; + _ground.opacityScale = g.opacity; + + this.burn.setVisible(grow > 0.001 && fade > 0.001); + this.burn.update(_ground); + } + + /** Gradients and scales. Pushed every frame so every picker stays live. */ + _syncParticles() { + const c = settings.glyphstorm; + const g = settings.global; + + this.ink.setGradient( + getColor(c.colorInkA), + getColor(c.colorInkB), + getColor(c.colorInkC), + getColor(c.colorInkD) + ); + this.ink.uniforms.uGravity.value.set(0, c.inkRise, 0); + this.ink.uniforms.uSizeScale.value = c.inkSize * g.particleSize * 7; + this.ink.uniforms.uLifeScale.value = c.inkLifetime * 0.5 * g.particleLifetime; + this.ink.uniforms.uSpeedScale.value = g.particleSpeed; + this.ink.uniforms.uOpacity.value = g.opacity; + this.ink.uniforms.uGlow.value = 1.0 * g.glow; + this.ink.uniforms.uTurbulence.value = c.inkTurbulence * g.turbulence; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = 1.2 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** The knot of light the cards are written out of. */ + _muzzleFx() { + const c = settings.glyphstorm; + const g = settings.global; + + this._handPoint(_pos); + + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.muzzleSize * 0.25, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.35, + intensity: c.muzzleIntensity, + opacity: 0.85, + fresnel: 1.4, + displace: 0.45, + colorA: getColor(c.colorMuzzleA), + colorB: getColor(c.colorMuzzleB), + colorC: getColor(c.colorMuzzleC) + }); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.35 * g.explosionIntensity; + } + + /** + * Ink shed by the storm, and sparks struck off the landing. + * + * @param {number} inkScale 0..1 on the ink rate + * @param {number} sparkScale 0..1 on the spark rate + */ + _stormFx(dt, inkScale, sparkScale) { + const c = settings.glyphstorm; + const g = settings.global; + const time = frame.uTime.value; + + const inkCount = Math.round(this.inkEmitter.tick(dt, c.inkRate * inkScale) * g.particleCount); + if (inkCount > 0) { + // Off the *lead*, not off the cast line: the storm is strung out behind a + // point that is ahead of most of it, and shedding from the line would put + // the ink in front of the cards it came off. + this.swarm.leadPoint(_lead); + _emit.position = _lead; + _emit.radius = c.spacingSide * c.latticeX * 0.5; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.4).setY(0.4).normalize(); + _emit.speed = c.inkSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.inkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.ink.emit(inkCount, _emit); + } + + const sparkCount = Math.round( + this.sparkEmitter.tick(dt, c.sparkRate * sparkScale) * g.particleCount + ); + if (sparkCount > 0) { + this.pointAt(1, _pos); + _pos.y = c.settleHeight; + _emit.position = _pos; + _emit.radius = c.burnRadius * 0.7; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(sparkCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.glyphstorm; + + this._syncParticles(); + this._syncSwarm(this.u, 0); + this._syncBurn(0, 0, 1); + this._stormFx(dt, saturate(this.u / Math.max(1e-3, c.revealRamp)), 0); + + // The camera frames the lead, not the point on the floor under it. + this.swarm.leadPoint(this.position); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + // Nothing one-shot: the arrival is the *start* of the settle, and the beat + // worth marking — the lattice taking — happens part-way through it. + } + + onFade(dt, t) { + const c = settings.glyphstorm; + + this._syncParticles(); + + if (t <= 1) { + /* ---- the cards come down, the lattice takes ---------------- */ + const settle = saturate(this.impactTime / this._settleSpan); + const started = this.impactTime - this._settleSpan * saturate(c.burnLead); + const grow = saturate(started / Math.max(0.02, c.burnGrow)); + + this._syncSwarm(1, settle); + this._syncBurn(grow, 0, 1); + this._stormFx(dt, 1 - settle * 0.8, settle < 1 ? 1 : 0.25); + + if (grow > 0 && this._tookAt < 0) { + this._tookAt = this.age; + this.ctx.flash.trigger( + getColor(c.colorSettleFlash), + c.settleFlash * settings.global.explosionIntensity + ); + this._landFx(); + } + + this.pointAt(1, this.position); + this.position.y = lerp(c.flightHeight, c.settleHeight, Easing.outCubic(settle)); + return; + } + + /* ---- the lattice burns out --------------------------------- */ + const out = saturate(t - 1); + // Eaten back from the rim rather than dimmed: a circuit that fades is a + // light going off, and a circuit that recedes is a charge running out. + this._syncSwarm(1, 1); + this._syncBurn(1, Easing.inQuad(out), 1 - Easing.inCubic(out)); + this._stormFx(dt, 0, 0.2 * (1 - out)); + + this.pointAt(1, this.position); + this.position.y = c.settleHeight; + } + + /** The moment the charge finds the floor. */ + _landFx() { + const c = settings.glyphstorm; + const g = settings.global; + + this.pointAt(1, _pos); + _pos.y = c.settleHeight; + + _emit.position = _pos; + _emit.radius = c.burnRadius * 0.5; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sparkSpeed * 2.1; + _emit.speedVariance = 0.9; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.22; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime * 1.5; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.sparks.emit(Math.round(c.sparkBurst * g.particleCount), _emit); + + this.ctx.shake.add( + c.settleShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.05, c.settleShakeDuration), + 20 + ); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + onDestroy() { + this.swarm.reset(); + this.burn.setVisible(false); + this._tookAt = -1; + } + + dispose() { + this.swarm.dispose(); + this.burn.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/arcane/PrismLanceAbility.js b/src/abilities/arcane/PrismLanceAbility.js new file mode 100644 index 0000000..ba4df79 --- /dev/null +++ b/src/abilities/arcane/PrismLanceAbility.js @@ -0,0 +1,894 @@ +import { BufferGeometry, Float32BufferAttribute, Mesh, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Tube, TubePath } from '../../vfx/Tube.js'; +import { ShatterField, ShatterLayout, shatterParams } from '../../vfx/ShatterField.js'; +import { createPrismSolidMaterial, createPrismFanMaterial, MAX_CHILDREN } from '../../materials/PrismMaterial.js'; +import { createBoltRibbonGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor, makeColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, hash11, Easing, randRange } from '../../utils/math.js'; + +/** Samples along one child beam. The ceiling on how fine its bow can be. */ +const FAN_NODES = 56; +/** Distinct chip shapes — and therefore draw calls the ShatterField costs. */ +const CHIP_VARIANTS = 2; +/** Hard ceiling on chips in the air at once. `chipCount` clamps here. */ +const CHIP_CAPACITY = 192; +/** How many points along the fan one frame's sparks are split between. */ +const SPARK_BATCHES = 4; + +const TAU = Math.PI * 2; + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hand = new Vector3(); +const _prism = new Vector3(); +const _target = new Vector3(); + +/* ------------------------------------------------------------------ */ +/* Procedural bodies */ +/* ------------------------------------------------------------------ */ + +/** + * A triangular bipyramid, in unit space: equator radius 1 at `y = 0`, apex at + * `y = ±1`. Six triangular faces and not one smooth normal on it. + * + * Non-indexed with per-face normals, the same way the crystals are built — a + * refracting solid whose facets have been averaged into a ball is a marble, and + * a marble does not disperse anything you can see. The three-sided form is + * deliberate: it is the silhouette everyone reads as *prism*, and with only six + * faces every one of them is large enough to carry a distinct piece of the room + * behind it. + */ +function createBipyramidGeometry() { + const positions = []; + const equator = []; + for (let i = 0; i < 3; i++) { + const a = (i / 3) * TAU + Math.PI * 0.5; + equator.push([Math.cos(a), 0, Math.sin(a)]); + } + + const push = (p) => positions.push(p[0], p[1], p[2]); + const apex = [0, 1, 0]; + const nadir = [0, -1, 0]; + + for (let i = 0; i < 3; i++) { + const a = equator[i]; + const b = equator[(i + 1) % 3]; + // Winding chosen so the face normal points away from the axis, not into it; + // with DoubleSide it would still draw, but the fresnel rim would be inside. + push(apex); + push(b); + push(a); + push(nadir); + push(a); + push(b); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + geometry.computeBoundingSphere(); + return geometry; +} + +/** + * One prism chip: a thin triangular plate, centred on its own origin because + * `ShatterField` tumbles a fragment about that point and a chip that spins + * about its corner reads as a thrown stick. + * + * Deterministic per variant — two shapes is enough for glass. Anything with + * more faces than this is invisible at the size these things fly at, and the + * only thing that reads is the *silhouette flicker* as a flat plate turns + * edge-on. + */ +function createChipGeometry(variant) { + const seed = variant * 13.7 + 3.1; + const corner = []; + for (let i = 0; i < 3; i++) { + const a = (i / 3) * TAU + hash11(seed + i) * 1.1; + const r = 0.55 + hash11(seed + i * 3.3) * 0.45; + corner.push([Math.cos(a) * r, Math.sin(a) * r]); + } + const half = 0.07 + hash11(seed + 9.4) * 0.1; + + const positions = []; + const v = (i, sign) => positions.push(corner[i][0], corner[i][1], sign * half); + + // Front and back faces, wound opposite ways. + v(0, 1); v(1, 1); v(2, 1); + v(2, -1); v(1, -1); v(0, -1); + // The three edges, two triangles each — the thickness is what catches a rim. + for (let i = 0; i < 3; i++) { + const j = (i + 1) % 3; + v(i, 1); v(j, -1); v(j, 1); + v(i, 1); v(i, -1); v(j, -1); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + geometry.computeBoundingSphere(); + return geometry; +} + +/** + * PRISM LANCE — white light in, spectrum out. + * + * Four beats. A lance of white light winds up in the hand (`charge`), is let + * out along the aimed line, **stops part-way** at a refracting solid hanging in + * the air, and continues from there as a fan of coloured child beams that bow + * apart and converge again exactly where the white lance was aimed. It holds, + * then the solid cracks into its own fragments and the fan whitens as it dies. + * + * **THE TRICK — dispersion, and the fact that it is one draw call.** The six + * children are not six tubes. They are six instances of a single ribbon strip, + * and a child's instance index picks its bearing around the axis, its hue out + * of eight pickers, *and* how hard it bends: `fanDispersion` ramps the lateral + * throw from the first child to the last, so the fan opens in spectral order + * rather than as six beams pointing six ways. Drag it to 1 and every child + * bends the same amount, which is a tube of colour; drag it to 0 and the whole + * fan collapses onto the axis, which is what a prism with no dispersion in it + * should do. Either one is the shader telling the truth. + * + * They converge because the bow is `sin(π·tᵇ)` — zero at the prism, zero at the + * target, with a real slope at the prism so the children *leave at an angle* + * instead of easing away from the axis. There is no convergence logic and no + * correction term anywhere in this file. A child cannot miss. + * + * The first version of the fan gave each child a constant lateral offset and + * lerped it to zero at the target. That draws six straight lines meeting at a + * point — a claw, not a refraction — because the *curvature* is what says the + * light was bent once, at one place, and then travelled straight-ish. The + * second version bowed them but shared one amplitude, and the fan read as a + * cage. Ordering the amplitude by index was the change that made it a prism. + * + * The solid is real: a triangular bipyramid with per-face normals, shaded by + * three `refract()` probes at three indices with one channel taken from each, + * so the fringes swing round the geometry behind it when you orbit. It blazes + * from the inside while the lance is striking it and dissolves along a + * world-space fracture field when it lets go, at which point a `ShatterField` + * throws ninety-odd chips that inherit nothing but the direction the lance was + * pointing. + * + * **What a cast captures.** One seed and three timestamps: when the lance was + * released, when it entered the solid, and when the solid cracked. Not one + * metre. `prismAt` is resolved every frame, so dragging it slides the split + * point along a cast that is already standing and the lance and the fan re-cut + * against each other — with the clock stopped. + */ +export class PrismLanceAbility extends Ability { + constructor(context) { + super('prismlance', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the white lance: three draw calls, all of the shape in the GPU --- */ + this.lance = new Tube({ path: TubePath.STRAIGHT, prefix: 'lance', nodes: 72, sides: 22, renderOrder: 11 }); + this.group.add(this.lance.group); + + /* --- the solid: one draw call, and the only thing here with a matrix --- */ + this.prismGeometry = createBipyramidGeometry(); + this.prismMaterial = createPrismSolidMaterial(); + this.prismMesh = new Mesh(this.prismGeometry, this.prismMaterial); + this.prismMesh.layers.set(LAYER.VFX); + this.prismMesh.renderOrder = 14; + this.prismMesh.frustumCulled = false; + this.group.add(this.prismMesh); + + /* --- the fan: one draw call for every child --- */ + this.fanGeometry = createBoltRibbonGeometry(FAN_NODES, MAX_CHILDREN); + this.fanMaterial = createPrismFanMaterial(); + this.fanMesh = new Mesh(this.fanGeometry, this.fanMaterial); + this.fanMesh.frustumCulled = false; + this.fanMesh.matrixAutoUpdate = false; + this.fanMesh.layers.set(LAYER.VFX); + this.fanMesh.renderOrder = 12; + this.group.add(this.fanMesh); + + /* --- the chips: two draw calls --- */ + this.chips = new ShatterField(this.group, { + geometry: createChipGeometry, + variants: CHIP_VARIANTS, + capacity: CHIP_CAPACITY, + additive: false, + depthWrite: false, + renderOrder: 8 + }); + + /** The one dice roll a cast makes. */ + this._seed = 0; + /** Children actually drawn this frame — the HUD's instance count. */ + this._childCount = 1; + /** Timestamps, in the cast's own clock. `-1` for "has not happened". */ + this._splitAt = -1; + this._crackAt = -1; + + /* --- scratch handed to the modules each frame. One each, reused. --- */ + this._lanceState = { + origin: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 0, + fade: 1, + widthFade: 1, + seed: 0, + time: 0 + }; + this._fanState = { + prism: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 0, + fade: 1, + seed: 0, + count: 6, + white: 0 + }; + this._prismState = { + fade: 0, + load: 0, + crack: 0, + seed: 0, + normalFix: new Vector3(1, 1, 1) + }; + this._chipParams = shatterParams(); + this._chipParams.layout = ShatterLayout.LINE; + this._chipLook = { + colorA: makeColor('#ffffff'), + colorB: makeColor('#ffffff'), + colorEdge: makeColor('#ffffff'), + colorScene: makeColor('#ffffff'), + opacity: 1, + glow: 1, + rim: 1, + rimPower: 2, + shade: 1, + ambient: 0.4, + fadeStart: 0.6, + soft: 0, + sceneMix: 0.75, + refract: 0.05, + saturation: 0.3 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Spectral motes shed by the solid — the thing that tells you it is *hot* + // rather than merely lit. + this.motes = particles.get('prismlance.motes', { + capacity: 2000, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.5; + this.motes.uniforms.uEndSize.value = 0.2; + this.motes.uniforms.uSizeIn.value = 0.06; + this.motes.uniforms.uFadeIn.value = 0.1; + this.motes.uniforms.uFadeOut.value = 0.45; + + // Sparks: the split, the crack and the impact. Velocity-stretched streaks. + this.sparks = particles.get('prismlance.sparks', { + capacity: 2400, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.5; + this.sparks.uniforms.uEndSize.value = 0.25; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.45; + + // Dust off the floor where the fan converges. Non-additive so it occludes. + this.dust = particles.get('prismlance.dust', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 1.8; + this.dust.uniforms.uEndSize.value = 2.6; + this.dust.uniforms.uSizeIn.value = 0.12; + this.dust.uniforms.uFadeIn.value = 0.16; + this.dust.uniforms.uFadeOut.value = 0.32; + + this.moteEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._childCount + this.chips.count; + } + + get impactDuration() { + return Math.max(0.05, settings.prismlance.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.prismlance.fadeTime); + } + + /** How far the lance has wound up in the hand, 0..1. */ + get charge() { + return saturate(this.age / Math.max(0.01, settings.prismlance.charge)); + } + + /** + * A prism breathes; it does not gutter. A stutter here would immediately read + * as the Storm Lance's light, and this ability is the opposite of electric. + */ + lightShimmer() { + const c = settings.prismlance; + return 1 - c.lightPulse * (0.5 - 0.5 * Math.cos(this.age * c.lightPulseSpeed * TAU)); + } + + /** + * Hold the front in the hand until the lance is up to power. + * + * The fourth beat, bought exactly the way Nova Beam buys it: the phase + * machine still runs travel → impact → fade, and the wind-up is simply a + * refusal to let `front` leave the caster. + */ + advance(dt) { + const c = this.config; + const charge = Math.max(0, c.charge); + if (this.age < charge) return false; + + const speed = c.speed * settings.global.speed; + const since = this.age - charge; + this.front += speed * Easing.outQuad(saturate(since / 0.06)) * dt; + + const previousU = this.u; + this.u = saturate(this.front / this.length); + this.pointAt(this.u, this.position); + return this.u >= 1 && previousU < 1; + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where along the span the solid floats, 0..1, clamped off both ends. */ + get _prismFraction() { + return clamp(settings.prismlance.prismAt, 0.02, 0.98); + } + + /** Where the lance leaves the caster, world space. */ + _handPoint(out) { + const c = settings.prismlance; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** + * Where the solid is hanging, world space. + * + * The bob is a live sine on the cast's own clock rather than an integrated + * position, so dragging `prismBob` with the sandbox paused moves the solid + * instead of changing where it will drift to next. + */ + _prismPoint(out) { + const c = settings.prismlance; + this.pointAt(this._prismFraction, out); + out.y = c.prismHeight + c.prismBob * Math.sin(this.age * c.prismBobSpeed * TAU); + return out; + } + + /** Where the fan converges, world space. */ + _impactPoint(out) { + this.pointAt(1, out); + out.y = settings.prismlance.endHeight; + return out; + } + + /** + * A point on the *visible* path at `s`, 0..1 — hand → prism → target. + * + * The cast line is straight along the floor, but the light is not: it leaves + * a hand at chest height, turns at a solid hanging above the line, and lands + * low. Sparks and the dynamic light are placed against this rather than + * against `pointAt()`, or they sit under the beam instead of on it. + */ + _pathPoint(s, out) { + const at = this._prismFraction; + const t = saturate(s); + if (t <= at) { + this._handPoint(_hand); + this._prismPoint(_prism); + return out.copy(_hand).lerp(_prism, at <= 1e-4 ? 1 : t / at); + } + this._prismPoint(_prism); + this._impactPoint(_target); + return out.copy(_prism).lerp(_target, (t - at) / Math.max(1e-4, 1 - at)); + } + + /** Half-width of the fan at `s` along the *fan*, 0..1 — metres. */ + _fanRadius(s) { + const c = settings.prismlance; + const t = saturate(s); + const bow = Math.pow(Math.sin(Math.PI * Math.pow(t, Math.max(0.05, c.fanBow))), Math.max(0.05, c.fanBowCurve)); + return c.fanSpread * Math.max(1, c.fanDispersion) * bow; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.chips.clear(); + this._splitAt = -1; + this._crackAt = -1; + + // The one thing a cast captures, and it is unitless. + this._seed = Math.random() * 100; + + this._syncUniforms(1); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push live settings and the current cast state into all four systems. + * + * @param {number} fade 1 while the cast is lit, ramping to 0 as it dies + */ + _syncUniforms(fade) { + const c = settings.prismlance; + const g = settings.global; + const at = this._prismFraction; + const charge = this.charge; + + this._handPoint(_hand); + this._prismPoint(_prism); + this._impactPoint(_target); + + /* --- how much of each half exists yet --- */ + // Before release the lance is a stub creeping out of the hand; after it, + // the front's fraction of the span is renormalised onto each half. + const travelling = this.phase === AbilityPhase.TRAVEL; + const lanceProgress = travelling + ? this.u <= 0 + ? Easing.outQuad(charge) * c.chargeStub + : saturate(this.u / at) + : 1; + const fanProgress = travelling ? saturate((this.u - at) / Math.max(1e-4, 1 - at)) : 1; + + /* --- the white lance --- */ + const lance = this._lanceState; + lance.origin.copy(_hand); + lance.target.copy(_prism); + lance.side.copy(this.side); + lance.progress = lanceProgress; + lance.fade = fade; + // The barrel is thin while it winds up and full width once it goes. + lance.widthFade = travelling && this.u <= 0 ? lerp(c.chargeSwell, 1, Easing.inQuad(charge)) : 1; + lance.seed = this._seed; + lance.time = this.age; + this.lance.sync(c, lance, g); + + /* --- the fan --- */ + const fan = this._fanState; + fan.prism.copy(_prism); + fan.target.copy(_target); + fan.side.copy(this.side); + fan.progress = fanProgress; + fan.fade = fade; + fan.seed = this._seed; + this._childCount = Math.max(1, Math.min(MAX_CHILDREN, Math.round(c.children))); + fan.count = this._childCount; + this.fanGeometry.instanceCount = this._childCount; + // The collapse: once the solid has cracked, the children are no longer + // being separated by anything, so they run back to white. + fan.white = this._crackAt < 0 ? 0 : Math.pow(saturate((this.age - this._crackAt) / Math.max(0.05, c.prismCrackTime)), Math.max(0.05, c.fanCollapse)); + this.fanMaterial.userData.sync(fan); + + /* --- the solid --- */ + const prism = this._prismState; + const crack = this._crackAt < 0 ? 0 : saturate((this.age - this._crackAt) / Math.max(0.05, c.prismCrackTime)); + // It fades in over the wind-up, blazes while the lance is inside it, and is + // eaten by its own fracture field on the way out. + prism.fade = Easing.outCubic(charge) * fade * (1 - crack); + prism.load = this._splitAt < 0 ? charge * 0.35 : 1 - crack; + prism.crack = crack; + prism.seed = this._seed; + + const swell = lerp(1, c.prismSwell, prism.load); + const radius = Math.max(0.01, c.prismRadius * swell); + const length = Math.max(0.01, c.prismLength * swell); + this.prismMesh.position.copy(_prism); + this.prismMesh.scale.set(radius, length, radius); + this.prismMesh.rotation.set(c.prismTilt, this.age * c.prismSpin * TAU + this._seed, 0); + // See PrismMaterial: mat3(modelMatrix) is R·S, and a normal wants R·S⁻¹. + prism.normalFix.set(1 / (radius * radius), 1 / (length * length), 1 / (radius * radius)); + this.prismMaterial.userData.sync(prism); + + /* --- the chips --- */ + const p = this._chipParams; + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + p.width = c.fanSpread; + p.spawnRadius = c.chipScatter; + p.spawnHeight = c.prismHeight; + p.speed = c.chipSpeed; + p.speedJitter = c.chipSpeedJitter; + p.spread = c.chipSpread; + p.upBias = c.chipUpBias; + p.gravity = c.chipGravity; + p.drag = c.chipDrag; + p.size = c.chipSize; + p.sizeJitter = c.chipSizeJitter; + p.shrink = c.chipShrink; + p.shrinkPower = c.chipShrinkPower; + p.spin = c.chipSpin * g.animationSpeed; + p.spinJitter = c.chipSpinJitter; + p.lifetime = c.chipLifetime * g.lifetime; + p.floorSpin = c.chipFloorSpin; + p.randomness = g.randomness; + + const look = this._chipLook; + look.colorA.copy(getColor(c.colorChipA)); + look.colorB.copy(getColor(c.colorChipB)); + look.colorEdge.copy(getColor(c.colorChipEdge)); + look.colorScene.copy(getColor(c.colorChipScene)); + look.opacity = c.chipOpacity * g.opacity * fade; + look.glow = c.chipGlow * g.glow; + look.rim = c.chipRim; + look.rimPower = c.chipRimPower; + look.shade = c.chipShade; + look.ambient = c.chipAmbient; + look.fadeStart = c.chipFadeStart; + this.chips.sync(look); + this.chips.update(this.age, p); + + /* --- the three particle systems --- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 1.1 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.fanGlow * 0.5 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.25 * g.turbulence; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /** The flash at the caster's hand as the lance leaves it. */ + _muzzleFx() { + const c = settings.prismlance; + const g = settings.global; + + this._handPoint(_pos); + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.muzzleSize * 0.2, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.32, + intensity: c.muzzleIntensity, + opacity: 0.85, + fresnel: 1.6, + displace: 0.4, + colorA: getColor(c.colorMuzzleA), + colorB: getColor(c.colorMuzzleB), + colorC: getColor(c.colorMuzzleC) + }); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /** + * The moment the lance reaches the solid. + * + * This is the beat the whole ability is named for, and it is a real event in + * the geometry rather than a scheduled one: it fires on the frame the front + * crosses `prismAt`, so moving the solid moves the moment. + */ + _splitFx() { + const c = settings.prismlance; + const g = settings.global; + + this._prismPoint(_pos); + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.splitSize * 0.15, + endRadius: c.splitSize * g.explosionIntensity, + life: 0.45, + intensity: c.splitIntensity, + opacity: 0.8, + fresnel: 2.0, + displace: 0.35, + squash: 0.7, + colorA: getColor(c.colorSplitA), + colorB: getColor(c.colorSplitB), + colorC: getColor(c.colorSplitC) + }); + + _emit.position = _pos; + _emit.radius = 0.16; + _emit.direction = _dir.copy(this.direction); + _emit.speed = c.sparkSpeed * 1.4; + _emit.speedVariance = 0.85; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.sparks.emit(Math.round(c.splitSparks * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorSplitC), c.splitFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.8 * g.explosionIntensity; + } + + /** The solid letting go: the chips, and the sparks that come with them. */ + _crackFx() { + const c = settings.prismlance; + const g = settings.global; + + this.chips.burst(this.age, Math.round(c.chipCount * g.particleCount), this._prismFraction, 0); + + this._prismPoint(_pos); + _emit.position = _pos; + _emit.radius = c.chipScatter; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.4).setY(0.6).normalize(); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.75; + _emit.life = c.sparkLifetime * 1.2; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.sparks.emit(Math.round(c.crackSparks * g.particleCount), _emit); + } + + /** Motes shed by the solid and along whichever half of the light exists. */ + _shedFx(dt, scale) { + const c = settings.prismlance; + const g = settings.global; + + const count = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (count <= 0) return; + + // Half off the solid itself, half strung along the light. A single origin + // makes every batch read as a puff pinned to the prism, which is very + // obviously wrong the moment the fan is long. + const reach = this.phase === AbilityPhase.TRAVEL ? Math.max(0.02, this.u) : 1; + const batches = Math.min(count, SPARK_BATCHES); + const per = Math.ceil(count / batches); + let left = count; + while (left > 0) { + const s = randRange(0.02, 1) * reach; + this._pathPoint(s, _pos); + _emit.position = _pos; + _emit.radius = 0.1 + this._fanRadius(saturate((s - this._prismFraction) / Math.max(1e-4, 1 - this._prismFraction))) * 0.6; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(Math.min(per, left), _emit); + left -= per; + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.prismlance; + const g = settings.global; + + // The split is polled against the live fraction, not scheduled off a + // timestamp taken at spawn — move the solid and the moment moves with it. + if (this._splitAt < 0 && this.u >= this._prismFraction && this.u > 0) { + this._splitAt = this.age; + this._splitFx(); + } + + this._syncUniforms(1); + + // The light rides the front along the *visible* path, so it turns the + // corner at the solid instead of sliding along the floor. + if (this.u <= 0) this._handPoint(this.position); + else this._pathPoint(this.u, this.position); + + this._shedFx(dt, this.u <= 0 ? 0.5 : 1); + + const shake = this.u <= 0 ? c.chargeShake * this.charge : c.rumble; + this.ctx.shake.rumble(shake * g.cameraShake, dt); + } + + onImpact() { + const c = settings.prismlance; + const g = settings.global; + const time = frame.uTime.value; + + this._impactPoint(_pos); + this.pointAt(1, _target); + + /* the shell where the children meet again */ + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.65, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.7, + displace: 0.5, + squash: 0.85, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring across the floor, and the pale burn under it */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _target, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.55, + width: 0.045, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + this.ctx.decals.spawn(DecalType.SCORCH, _target, { + radius: c.scorchRadius * randRange(1.6, 2.4), + life: c.scorchLife, + intensity: c.scorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorEmber), + height: 0.015 + }); + + /* sparks out of the convergence, dust off the floor under it */ + _emit.position = _pos; + _emit.radius = 0.28; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.45).setY(0.7).normalize(); + _emit.speed = c.sparkSpeed * 2.0; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.2; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(Math.round(c.burstSparks * g.particleCount), _emit); + + _emit.position = _target; + _emit.radius = c.scorchRadius * 2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 2.0; + _emit.spread = 1.0; + _emit.size = 1.3; + _emit.life = c.dustLifetime * 1.2; + _emit.spin = 0.4; + this.dust.emit(Math.round(c.burstDust * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 24 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 while the fan holds, then 1..2 while everything lets go. + // The crack is fired on the transition rather than at a fixed age, so + // stretching `lifetime` keeps the solid intact for longer. + if (t >= 1 && this._crackAt < 0) { + this._crackAt = this.age; + this._crackFx(); + } + + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._syncUniforms(fade); + + this._pathPoint(1, this.position); + this._shedFx(dt, fade * (t <= 1 ? 0.7 : 0.25)); + } + + onDestroy() { + this._childCount = 1; + this.fanGeometry.instanceCount = 1; + this.chips.clear(); + this.lance.materials.core.uniforms.uFade.value = 0; + this.lance.materials.sheath.uniforms.uFade.value = 0; + this.lance.materials.halo.uniforms.uFade.value = 0; + this.fanMaterial.uniforms.uFade.value = 0; + this.prismMaterial.uniforms.uFade.value = 0; + } + + dispose() { + this.lance.dispose(); + this.chips.dispose(); + this.prismGeometry.dispose(); + this.prismMaterial.dispose(); + this.fanGeometry.dispose(); + this.fanMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/arcane/RunesealAbility.js b/src/abilities/arcane/RunesealAbility.js new file mode 100644 index 0000000..8f3c1ce --- /dev/null +++ b/src/abilities/arcane/RunesealAbility.js @@ -0,0 +1,892 @@ +import { Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { Tube, TubePath } from '../../vfx/Tube.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** + * How many points one frame's embers are split between. A single origin makes + * every batch read as a starburst pinned to one spot on the rim; the ignition + * front is a *circle*, and a circle has to be sampled round. + */ +const EMBER_ARCS = 5; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _seal = groundFieldParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hit = new Vector3(); + +/** + * RUNESEAL — a seal inscribed on the floor, which then goes off. + * + * **The trick is that the writing is real.** Every mark on the floor is a + * signed-distance field evaluated in metres by one fragment shader — the + * `RUNE` mode of `vfx/GroundField.js`, which this ability extended to carry it. + * Three nested rings of *letterforms*, not decoration: strokes with swelling + * middles and tapered terminals, bowls, and closed counters, drawn from an + * alphabet of twenty-four stroke skeletons authored in JavaScript and unrolled + * into straight-line GLSL at module load. Between the rings, a chord armature + * whose seven lines are simultaneously an inscribed star and a set of tangents + * to an inner circle. Round the outside, a collar of sixty-four ticks with + * every eighth one long. In the middle, a five-fold rosette of the same + * alphabet at a larger em box, ruled inside its own circle, and **drawn last**. + * + * All of it inks itself stroke by stroke on one clock. `uInscribe` runs 0 → 1 + * and the seal spends it in three movements — the rings, then the armature and + * its collar, then the sigil — so the biggest mark on the floor is always the + * thing you are still watching when the drawing finishes. The rings + * counter-rotate at falling rates while they do it, because two rings turning + * the same way are one wheel and two turning against each other are machinery. + * + * Four beats: + * + * 1. **inscribe** — a scribe line of ink motes runs out to the point and the + * seal writes itself, ring by ring, glyph by glyph, stroke by stroke. + * 2. **ignite** — a front crosses the seal from the middle outward. The + * sigil catches first, then each ring in turn. Embers come off the front + * itself, so the fire is *where the front is* rather than everywhere. + * 3. **discharge** — a column of light stands up out of the writing + * (`vfx/Tube.js`, straight) and a pressure ring goes out across the floor + * (`vfx/Shell.js`), with a shell, a shockwave and a burn at the foot. + * 4. **burn out** — the fire comes back out of the ink, the strokes char to + * `colorChar`, and what is left on the floor is a scar in the shape of the + * writing plus a scorch decal that outlives the cast. + * + * **The rule that makes the editor work.** A cast captures two numbers — a seed + * so no two seals draw the same letters, and a dice roll for the armature's + * starting bearing — plus the timestamps of the beats it has already fired. + * Not one metre, radian or second is recorded. The em box, the nib width, the + * chord circle, the tick length, the sigil's arms and the depth of the incision + * are all resolved against `settings.runeseal` inside the update loop, on a + * zero-length frame included. Pause with **P** mid-inscription and drag + * `glyphSize`: the ring recomputes its slot count from its own circumference + * and fills with *more, smaller runes* rather than scaling the ones already + * there. That is the difference between working in metres and working in UV, + * and it is the reason this slot is the showpiece. + */ +export class RunesealAbility extends Ability { + constructor(context) { + super('runeseal', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The seal. Shaded rather than additive: the incision has to be genuinely + * *darker* than the floor it is cut into, and an additive mark can only ever + * brighten. The fire on top of it is the `emit` term, which adds anyway. + */ + this.field = new GroundField(this.group, { + mode: GroundMode.RUNE, + additive: false, + name: 'RunicSeal' + }); + + /** + * The discharge column. A straight tube rather than a bespoke shaft, + * because the three-layer core/sheath/halo weighting is the whole reason + * the Nova Beam reads as solid instead of as a bright fog, and a column + * standing on a seal has exactly the same problem to solve. + */ + this.column = new Tube({ + path: TubePath.STRAIGHT, + prefix: 'column', + nodes: 72, + sides: 26, + renderOrder: 12 + }); + this.group.add(this.column.group); + + /** + * The ring. A pressure shell squashed flat — `waveHeight` is a multiple of + * the radius, and at 0.22 the hemisphere is low enough that what you see is + * its rim travelling across the floor. A dome at full height put a glass + * bubble over the whole seal and hid the writing at the moment the writing + * was doing its most interesting thing. + */ + this.wave = new Shell({ + mode: ShellMode.PRESSURE, + prefix: 'wave', + nodes: 40, + sides: 48, + renderOrder: 13 + }); + this.group.add(this.wave.group); + + /* --- what a cast captures: two dice rolls and some timestamps --- */ + /** Decorrelates the glyph choice, so two seals do not read the same. */ + this._seed = 0; + /** 0..1. Resolved into the armature's starting bearing every frame. */ + this._armRoll = 0; + /** Beats already fired. One-way, so a slider drag cannot re-fire them. */ + this._ignited = false; + this._discharged = false; + /** Seconds since the discharge went off. A clock, not a dimension. */ + this._dischargeAge = 0; + /** Metres of scribe travel already paid out in ink motes. */ + this._scribeDistance = 0; + /** HUD readout only. */ + this._live = 1; + + /** The seal's anchor, rewritten every frame from the live cast. */ + this._centre = new Vector3(); + + // Scratch state handed to the tube and the shell. Two objects, reused. + this._tube = { + origin: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 1, + fade: 0, + widthFade: 1, + seed: 0, + time: 0 + }; + this._shell = { + origin: new Vector3(), + axis: new Vector3(0, 1, 0), + side: new Vector3(1, 0, 0), + span: 1, + t: 0, + fade: 0, + seed: 0 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Embers off the ignition front. Velocity-stretched so a rising ember reads + // as a streak of burning ink rather than as a dot. + this.embers = particles.get('runeseal.embers', { + capacity: 3200, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.3 + }); + this.embers.uniforms.uDrag.value = 1.1; + this.embers.uniforms.uEndSize.value = 0.2; + this.embers.uniforms.uSizeIn.value = 0.04; + this.embers.uniforms.uFadeIn.value = 0.05; + this.embers.uniforms.uFadeOut.value = 0.5; + + // The slow gold motes: the ink itself, lifting off the writing. Also the + // scribe line on the way out, which is the same substance arriving. + this.motes = particles.get('runeseal.motes', { + capacity: 2400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.45 + }); + this.motes.uniforms.uDrag.value = 1.5; + this.motes.uniforms.uEndSize.value = 0.18; + this.motes.uniforms.uSizeIn.value = 0.07; + this.motes.uniforms.uFadeIn.value = 0.1; + this.motes.uniforms.uFadeOut.value = 0.42; + + // Char smoke off the scar. Non-additive so it genuinely occludes the + // writing it is coming off — smoke that adds light reads as more fire. + this.smoke = particles.get('runeseal.smoke', { + capacity: 1800, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.smoke.uniforms.uDrag.value = 1.9; + this.smoke.uniforms.uEndSize.value = 3.2; + this.smoke.uniforms.uSizeIn.value = 0.14; + this.smoke.uniforms.uFadeIn.value = 0.2; + this.smoke.uniforms.uFadeOut.value = 0.3; + + this.emberEmitter = new RateEmitter(); + this.moteEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — all four beats resolved from settings, every frame */ + /* ------------------------------------------------------------------ */ + + /** + * A beat's length in seconds, under the global lifetime knob. + * + * Everything that reads a clock in this file goes through here, including + * `impactDuration`. Scaling the phase and not the beats — which is the + * obvious mistake, and the one the first version made — leaves the seal + * finishing its discharge a second before the phase machine notices, so + * `global.lifetime` above 1 buys nothing but a longer stare at a dead seal. + */ + _span(seconds) { + return Math.max(0.01, seconds * settings.global.lifetime); + } + + /** The impact phase holds all four beats. Re-derived, never stored. */ + get impactDuration() { + const c = settings.runeseal; + return ( + this._span(c.inscribeTime) + + this._span(c.igniteTime) + + this._span(c.dischargeTime) + + this._span(c.holdTime) + ); + } + + get fadeDuration() { + return Math.max(0.05, settings.runeseal.fadeTime); + } + + get instanceCount() { + return this._live; + } + + /** + * A seal swells rather than gutters. + * + * Lightning stutters because an arc genuinely is on and off; burning ink is + * continuous and only breathes. A hard flicker here made the whole slot look + * electrical, which is the wrong school. + */ + lightShimmer() { + const c = settings.runeseal; + return 1 - saturate(c.lightPulse) * 0.5 * (1 - Math.cos(this.age * c.lightPulseSpeed)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The live footprint, metres. What the aim circle measured out. */ + get radius() { + return Math.max(0.2, settings.runeseal.zoneRadius); + } + + /** Where the seal is: the far end of the aimed line, on the floor. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** Where the scribe line leaves the caster. */ + _handPoint(out) { + const c = settings.runeseal; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** + * A point on the scribe line at `s` along it, 0..1. + * + * The line drops from the hand to just above the floor at the seal, so the + * motes arrive lying down rather than falling in from head height. + */ + _scribePoint(s, out) { + const c = settings.runeseal; + const t = saturate(s); + out + .copy(this.origin) + .addScaledVector(this.direction, lerp(c.handForward, this.length, t)) + .addScaledVector(this.side, c.handSide * (1 - t)); + out.y = lerp(c.handHeight, c.scribeLift, t); + return out; + } + + /** 0..1 — how much of the seal has been inked. */ + _inscribeAmount() { + return saturate(this.age / this._span(settings.runeseal.inscribeTime)); + } + + /** 0..1 — the ignition front, measured outward from the middle. */ + _igniteAmount() { + const c = settings.runeseal; + return saturate((this.age - this._span(c.inscribeTime)) / this._span(c.igniteTime)); + } + + /** 0..1 — how far through the discharge the column and the ring are. */ + _dischargeAmount() { + return saturate(this._dischargeAge / this._span(settings.runeseal.dischargeTime)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.emberEmitter.reset(); + this.moteEmitter.reset(); + this.smokeEmitter.reset(); + + this._ignited = false; + this._discharged = false; + this._dischargeAge = 0; + this._scribeDistance = 0; + + // The only two things a cast captures. Both unitless. + this._seed = Math.random() * 100; + this._armRoll = Math.random(); + + this.field.setVisible(true); + this._sync(1, 0); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current cast state into the seal, the + * column, the ring and the three particle systems. + * + * @param {number} fade 1 while the seal stands, ramping to 0 as it burns down + * @param {number} scorch 0..1 how far the fire has come back out of the ink + */ + _sync(fade, scorch) { + const c = settings.runeseal; + const g = settings.global; + + this._centrePoint(this._centre); + const radius = this.radius; + const inscribe = this._inscribeAmount(); + const ignite = this._igniteAmount(); + + /* ---------------- the seal ---------------- */ + const s = _seal; + s.centre = this._centre; + // The quad carries the caster's heading, so "downrange" and "the aim + // direction" are the same axis and the relief lights from the right side. + s.yaw = Math.atan2(this.direction.x, this.direction.z); + s.height = c.sealHeight; + s.radius = radius; + + s.grow = 1; + s.recede = 0; + s.inscribe = inscribe; + s.ignite = ignite; + s.scorch = scorch; + s.fade = fade; + s.seed = this._seed; + + s.edge = c.sealEdge; + s.ragged = c.sealRagged; + s.raggedScale = c.sealRaggedScale; + s.warp = c.sealWarp; + + s.relief = c.relief; + s.ambient = c.ambient; + s.wrap = c.wrap; + s.specular = c.specular; + s.gloss = c.gloss; + + // In RUNE the shared vocabulary means: depth is how far the nib cuts, + // thickness is the bevel on the walls of that cut. Nothing else is read. + s.depth = c.incision; + s.thickness = c.incisionWidth; + + s.rings = c.rings; + s.ringInner = c.ringInner; + s.glyphSize = c.glyphSize; + s.glyphStroke = c.glyphStroke; + s.glyphGap = c.glyphGap; + s.spin = c.spin; + s.spinFalloff = c.spinFalloff; + s.rule = c.rule; + + s.armStart = c.armStart; + s.armRadius = c.armRadius; + s.armSides = c.armSides; + s.armTangent = c.armTangent; + s.armStroke = c.armStroke; + // The dice roll becomes a radian here and only here. + s.armPhase = c.armPhase + this._armRoll * TAU; + s.armSpin = c.armSpin; + + s.tickCount = c.tickCount; + s.tickRadius = c.tickRadius; + s.tickLength = c.tickLength; + s.tickStroke = c.tickStroke; + s.tickMajor = c.tickMajor; + s.tickMajorLen = c.tickMajorLen; + + s.sigilStart = c.sigilStart; + s.sigilRadius = c.sigilRadius; + s.sigilSize = c.sigilSize; + s.sigilArms = c.sigilArms; + s.sigilStroke = c.sigilStroke; + s.sigilRing = c.sigilRing; + s.sigilSpin = c.sigilSpin; + + s.additive = false; + s.emissive = c.sealEmissive * g.shaderIntensity; + s.opacity = c.sealOpacity; + s.depthFade = c.sealDepthFade; + s.colorBase = c.colorInk; + s.colorEdge = c.colorRule; + s.colorGlow = c.colorFire; + s.colorDeep = c.colorChar; + + s.noiseStrength = g.noiseStrength; + s.noiseFrequency = g.noiseFrequency; + s.noiseSpeed = g.noiseSpeed; + s.opacityScale = g.opacity; + + this.field.update(s); + + /* ---------------- the column ---------------- */ + // Re-resolved whether or not it is on screen. Syncing only the visible + // pieces is the tempting optimisation and it breaks the paused editor: + // drag `columnRadius` while the column is down and it must still be the + // width you chose when it comes up. + const dis = this._dischargeAmount(); + const rise = Easing.outCubic(saturate(this._dischargeAge / Math.max(0.01, c.columnRise))); + const collapse = 1 - Math.pow(dis, Math.max(0.2, c.columnCollapse)); + + const t = this._tube; + t.origin.copy(this._centre); + t.origin.y = c.sealHeight; + t.target.copy(this._centre); + t.target.y = c.sealHeight + Math.max(0.05, c.columnHeight) * rise; + t.side.copy(this.side); + t.progress = 1; + t.fade = this._discharged ? fade * collapse : 0; + // The column does not dim out, it thins out: the shaft narrows to a thread + // and lets go. Dimming a solid column reads as a lighting change. + t.widthFade = 1 - Math.pow(dis, 1.6) * 0.85; + t.seed = this._seed; + t.time = this._dischargeAge; + this.column.sync(c, t, g); + this.column.visible = t.fade > 0.002; + + /* ---------------- the ring ---------------- */ + const w = this._shell; + w.origin.copy(this._centre); + w.origin.y = c.sealHeight; + w.axis.set(0, 1, 0); + w.side.copy(this.side); + w.span = radius * 2; + w.t = dis; + w.fade = this._discharged ? fade : 0; + w.seed = this._seed; + this.wave.sync(c, w, g); + this.wave.visible = w.fade > 0.002 && dis < 0.999; + + this._live = 1 + (this.column.visible ? 3 : 0) + (this.wave.visible ? 1 : 0); + + /* ---------------- the three particle systems ---------------- */ + this.embers.setGradient( + getColor(c.colorEmberA), + getColor(c.colorEmberB), + getColor(c.colorEmberC), + getColor(c.colorEmberD) + ); + // Positive gravity: burning ink lifts off the floor, it does not fall onto it. + this.embers.uniforms.uGravity.value.set(0, c.emberRise, 0); + this.embers.uniforms.uSizeScale.value = c.emberSize * g.particleSize * 7; + this.embers.uniforms.uLifeScale.value = c.emberLifetime * 0.5 * g.particleLifetime; + this.embers.uniforms.uSpeedScale.value = g.particleSpeed; + this.embers.uniforms.uOpacity.value = g.opacity; + this.embers.uniforms.uGlow.value = 1.2 * g.glow; + this.embers.uniforms.uStretch.value = c.emberStretch; + this.embers.uniforms.uTurbulence.value = 0.3 * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 0.95 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.4 * g.turbulence; + } + + /** A small flare at the hand as the scribe line leaves it. */ + _castFx() { + const c = settings.runeseal; + const g = settings.global; + + this._handPoint(_pos); + + _emit.position = _pos; + _emit.radius = 0.14; + _emit.direction = _dir.copy(this.direction); + _emit.speed = c.moteSpeed * 3.5; + _emit.speedVariance = 0.7; + _emit.spread = 0.55; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(Math.round(26 * g.particleCount), _emit); + + this.lightBoost = c.lightIntensity * 0.35 * g.explosionIntensity; + } + + /** + * Ink motes paid out per metre of scribe travel, so the line arrives at the + * same density whether the seal was planted at four metres or at twenty. + * Keyed off distance rather than off time for exactly that reason. + */ + _scribeFx() { + const c = settings.runeseal; + const step = 1 / Math.max(0.05, c.scribeRate); + const time = frame.uTime.value; + + while (this.front - this._scribeDistance >= step) { + this._scribeDistance += step; + const s = saturate(this._scribeDistance / this.length); + this._scribePoint(s, _pos); + + _emit.position = _pos; + _emit.radius = 0.1; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.6; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime * 0.55; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(2, _emit); + } + } + + /** + * Everything the standing seal sheds. + * + * @param {number} dt + * @param {number} scale 0..1 — thinned as the seal burns down + * @param {number} ignite 0..1 — where the ignition front is + */ + _sealFx(dt, scale, ignite) { + const c = settings.runeseal; + const g = settings.global; + const time = frame.uTime.value; + const radius = this.radius; + const centre = this._centre; + + /* --- embers, thrown off the ignition front itself --- */ + const rate = this._discharged ? c.emberDischargeRate : c.emberRate; + let emberCount = Math.round( + this.emberEmitter.tick(dt, rate * scale * ignite) * g.particleCount + ); + if (emberCount > 0) { + _emit.speed = c.emberSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.75; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.13; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + + // The front is an annulus at `ignite × radius`, held a little inside the + // rim so the last of the fire does not fly off the edge of the drawing. + // Once the front has crossed, the whole seal is alight and the band opens + // out to cover it. + const inset = 1 - saturate(c.emberInset); + const band = lerp(0.14, 0.9, ignite) * radius; + const front = ignite * radius * inset; + + const per = Math.ceil(emberCount / Math.min(emberCount, EMBER_ARCS)); + while (emberCount > 0) { + const a = Math.random() * TAU; + const r = Math.max(0, front + randRange(-band, band) * 0.5); + _pos.set(centre.x + Math.cos(a) * r, 0.04, centre.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = radius * 0.06 + 0.05; + // Up and slightly outward: ink coming off a cut lifts along the bevel. + _emit.direction = _dir.set(Math.cos(a) * 0.3, 1, Math.sin(a) * 0.3).normalize(); + this.embers.emit(Math.min(per, emberCount), _emit); + emberCount -= per; + } + } + + /* --- the gold motes hanging over the writing --- */ + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + const a = Math.random() * TAU; + const r = radius * Math.sqrt(Math.random()); + _pos.set(centre.x + Math.cos(a) * r, randRange(0.05, 0.4), centre.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = radius * 0.12; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.06; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + /* --- char smoke, only once there is char to make it --- */ + const smokeCount = Math.round( + this.smokeEmitter.tick(dt, c.smokeRate * scale * ignite) * g.particleCount + ); + if (smokeCount > 0) { + const a = Math.random() * TAU; + const r = radius * Math.sqrt(Math.random()); + _pos.set(centre.x + Math.cos(a) * r, 0.12, centre.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = radius * 0.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + } + + /** + * The two one-shots, tested against the live beat lengths every frame. + * + * Both flags are one-way. Dragging `inscribeTime` upward after the seal has + * caught would otherwise push the threshold back past the clock and let the + * ignition fire a second time, which is a burst of embers out of nowhere and + * one of the more baffling things a paused editor can do to you. + */ + _checkBeats() { + const c = settings.runeseal; + + if (!this._ignited && this.age >= this._span(c.inscribeTime)) { + this._ignited = true; + this._igniteFx(); + } + + if ( + !this._discharged && + this.age >= this._span(c.inscribeTime) + this._span(c.igniteTime) + ) { + this._discharged = true; + this._dischargeAge = 0; + this._dischargeFx(); + } + } + + /** The seal catches. Quiet — the loud part is two beats away. */ + _igniteFx() { + const c = settings.runeseal; + const g = settings.global; + + this.ctx.flash.trigger(getColor(c.colorIgniteFlash), c.igniteFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.6 * g.explosionIntensity; + } + + /** The seal goes off: the shell, the shockwave, the burn and the embers. */ + _dischargeFx() { + const c = settings.runeseal; + const g = settings.global; + const time = frame.uTime.value; + const radius = this.radius; + + this._centrePoint(_hit); + + /* the shell of hot air over the writing */ + _pos.copy(_hit).setY(c.sealHeight + 0.2); + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.75, + fresnel: 1.5, + displace: 0.5, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring that snaps out across the floor, under the pressure shell */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _hit, { + radius: radius * 2.1 * g.explosionIntensity, + life: 0.65, + width: 0.05, + intensity: 1.0, + colorA: getColor(c.colorBurstB), + colorB: getColor(c.colorBurstC) + }); + + /* the burn the seal is standing in, which outlives the cast */ + this.ctx.decals.spawn(DecalType.SCORCH, _hit, { + radius: c.scorchRadius, + life: c.scorchLife, + intensity: c.scorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorSoot), + height: 0.012 + }); + + /* embers blown up out of every stroke at once */ + _emit.position = _hit; + _emit.radius = radius * 0.75; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.emberSpeed * 2.4; + _emit.speedVariance = 0.85; + _emit.spread = 0.5; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.8; + _emit.life = c.emberLifetime * 1.6; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.embers.emit(Math.round(c.burstEmbers * g.particleCount), _emit); + + _emit.radius = radius * 0.55; + _emit.speed = c.smokeSpeed * 2.4; + _emit.spread = 1.0; + _emit.size = 1.5; + _emit.life = c.smokeLifetime * 1.2; + _emit.spin = 0.5; + this.smoke.emit(Math.round(c.burstSmoke * g.particleCount), _emit); + + this.ctx.shake.add( + c.dischargeShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.dischargeFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.6 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1, 0); + // The beats run on the cast's own age, which starts at the muzzle — so a + // short cast can already be igniting while the front is still in the air. + this._checkBeats(); + + // The light rides the scribe line out to the point, then sits on the seal. + this._scribePoint(this.u, this.position); + + this._scribeFx(); + this._sealFx(dt, 1, this._igniteAmount()); + this.ctx.shake.rumble(settings.runeseal.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + // Nothing fires here. The seal's beats are on its own clock and the arrival + // of the scribe line is not one of them — putting the ignition here made a + // seal cast at four metres catch before it had finished being written. + this._centrePoint(this.position); + } + + onFade(dt, t) { + const c = settings.runeseal; + if (this._discharged) this._dischargeAge += dt; + this._checkBeats(); + + // `t` runs 0..1 while the seal stands, then 1..2 while it burns down. The + // burn-down is two things at once: the fire coming out of the ink (`scorch`, + // which is what leaves the scar) and the quad going out (`fade`, cubic, so + // the seal hangs on and then lets go rather than dimming evenly). + const out = saturate(t - 1); + // Charring is keyed to the discharge, not to the phase: the fire leaves the + // ink over the back half of the column's life, so by the time the quad + // starts to go the writing is already a scar and the fade is only taking + // away what is left of the glow. + const scorch = saturate(this._dischargeAmount() * 2 - 1); + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(out); + + this._sync(fade, scorch); + + // The light climbs into the column while it is up and settles back onto the + // scar when it has gone. + this._centrePoint(this.position); + this.position.y = + c.sealHeight + + Math.max(0.05, c.columnHeight) * + saturate(c.lightHeight) * + (this._discharged ? 1 - this._dischargeAmount() * 0.6 : 0); + + this._sealFx(dt, fade * (t <= 1 ? 1 : 0.45), this._igniteAmount()); + this.ctx.shake.rumble(c.rumble * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + this._live = 1; + this._ignited = false; + this._discharged = false; + this._dischargeAge = 0; + this.field.setVisible(false); + this.column.visible = false; + this.wave.visible = false; + } + + dispose() { + this.field.dispose(); + this.column.dispose(); + this.wave.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/arcane/SpellbreakAbility.js b/src/abilities/arcane/SpellbreakAbility.js new file mode 100644 index 0000000..9f0e485 --- /dev/null +++ b/src/abilities/arcane/SpellbreakAbility.js @@ -0,0 +1,1026 @@ +import { BufferGeometry, Float32BufferAttribute, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { ShatterField, ShatterLayout, shatterParams } from '../../vfx/ShatterField.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { DistortionField, DistortionMode, DistortionFacing } from '../../vfx/Distortion.js'; +import { sceneHooks, Hook } from '../../vfx/SceneHooks.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, clamp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** + * Distinct pane silhouettes. Two, not three. + * + * `GrowthField` wants three variants because a field of one silhouette scaled + * forty ways reads as a repeated prop. Debris does not have that problem — + * every fragment is tumbling on its own axis at its own rate, and the eye + * never gets a second look at the same outline. Two is enough to break the + * repeat and it is one draw call cheaper. + */ +const PANE_VARIANTS = 2; + +/** Hard ceiling on panes in the air. `paneCount` clamps here. */ +const MAX_PANES = 180; + +/** + * How many points round the break annulus one frame's glints are split + * between. A single origin makes every batch read as a starburst pinned to one + * spot on a bell that is four metres across. + */ +const GLINT_ARCS = 5; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _panes = shatterParams(); +const _look = {}; +const _push = {}; +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hit = new Vector3(); + +/** + * One pane of arcane glass, in `ShatterField`'s unit space. + * + * A body inside a unit sphere **centred on its own origin**, because the + * tumble rotates about that point — which is the one part of the contract that + * differs from `GrowthField`'s base-on-`y = 0` factory, and the one that is + * easy to get wrong. A pane built with its base at the floor tumbles about its + * foot and reads as a flag on a stick. + * + * The outline is an irregular convex polygon rather than a rectangle. Real + * glass breaks on conchoidal fractures and the tell is that no two edges are + * parallel; a field of rectangles reads as ceramic tiles no matter what the + * shading does. `ragged` pushes the corners off the regular n-gon in both + * radius and bearing, and it is a slider because a low value is stained glass + * (leaded panels, straight cames) and a high one is a windscreen. + * + * @param {number} variant 0..N, decorrelates the corner hash + * @param {object} shape `{ sides, thickness, ragged }` — live, see `_syncPanes` + */ +function createPaneGeometry(variant, shape) { + const sides = Math.max(3, Math.round(shape.sides)); + const half = Math.max(0.004, shape.thickness) * 0.5; + const ragged = saturate(shape.ragged); + + // A deterministic hash rather than Math.random: two casts of the same + // ability must build the same two panes, or the rebuild-on-slider-change + // below would reshuffle the whole field every time a number moved. + const hash = (n) => { + const s = Math.sin((n + variant * 37.13) * 12.9898) * 43758.5453; + return s - Math.floor(s); + }; + + const rim = []; + for (let i = 0; i < sides; i++) { + const bearing = (i / sides + (hash(i * 1.7) - 0.5) * (ragged * 0.7 / sides)) * TAU; + const reach = 0.5 * (1 - ragged * 0.55 * hash(i * 3.9 + 11)); + rim.push([Math.cos(bearing) * reach, Math.sin(bearing) * reach]); + } + + const positions = []; + const push = (x, y, z) => positions.push(x, y, z); + + // Two faces and a band of side quads. Non-indexed with face normals, so the + // edge band catches the key light separately from the faces — which is what + // makes a pane read as having thickness at all at this size. + for (let i = 0; i < sides; i++) { + const a = rim[i]; + const b = rim[(i + 1) % sides]; + push(0, half, 0); push(a[0], half, a[1]); push(b[0], half, b[1]); + push(0, -half, 0); push(b[0], -half, b[1]); push(a[0], -half, a[1]); + push(a[0], half, a[1]); push(a[0], -half, a[1]); push(b[0], half, b[1]); + push(b[0], half, b[1]); push(a[0], -half, a[1]); push(b[0], -half, b[1]); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + return geometry; +} + +/** + * SPELLBREAK — the one ability that is aware of the others. + * + * **The trick is that it reaches outside its own group.** Every other slot in + * this sandbox draws into `this.group` and is done. This one borrows + * `Hook.DISRUPT` from `vfx/SceneHooks.js` and publishes a sphere — a centre in + * world metres, a radius, and three 0..1 powers — that **other abilities' + * shaders read**. A bolt, a snare, a chain, a flock, any ground mark, any beam + * and any standing field of erupted geometry that happens to be inside that + * sphere is desaturated, dimmed, and eroded away in screen-space cells on the + * same grain the glass is breaking on. Cast it into an empty room and it is a + * pretty dome shattering. Cast it into a live Nova Beam and it is an + * interaction, and there is nothing else in the project that does that. + * + * ## What had to be extended to make it land + * + * The hook shipped with three opted-in consumers — `FilamentPaths`, + * `GroundField` and `Swarm` — and on paper that is "every bolt, every ground + * mark and every flock", which sounds like most of the stage. It is not. Cast + * this into a Nova Beam and **nothing happened**: a beam is a `Tube`, and a + * tube was not on the list. Cast it at a standing frost field and nothing + * happened either: those are `GrowthField` instances on patched + * `MeshStandardMaterial`s, also not on the list. Between them that is most of + * what a player actually has left standing in a zone when they reach for a + * dispel, so both were opted in as part of this ability: + * + * - `vfx/Tube.js` samples the field per vertex and shades **after** its own + * glow gain. Draining a rod of light before a gain of eight is applied + * does nothing: grey × 8 is white. + * - `vfx/GrowthField.js#patchGrowthMaterial` samples it per *fragment* — the + * opposite of the cheap route, because a growth instance is a handful of + * very large facets and a per-vertex sample is interpolated across half a + * spike — and **discards** the eroded cells rather than zeroing alpha, + * because an opaque material's alpha is written and then ignored. The + * first version zeroed alpha and a crystal field inside the zone went grey + * and stayed whole, which reads as a lighting change and not as a spell + * being torn apart. + * + * ## The beats + * + * 1. **warn** — the field is already on while the null front is still in the + * air, at `warnSpan` of its radius and `warnDrain` of its power. Colour + * goes out of the room *before* the glass arrives. This is the + * Thunderclap lesson: the anticipation carries the beat, and a dispel + * that starts at full strength on the frame it lands reads as a graphic + * rather than as an event. + * 2. **seal** — the bell closes over the zone (`vfx/Shell.js`, `DOME`) and + * the field opens out to full radius and full drain with it. + * 3. **break** — the bell shatters. `fracture` spikes to `fractureBite` for + * `fractureFall` seconds, so everything standing in the zone comes apart + * in cells at the same instant the glass does; a pressure ring goes out + * through the distortion buffer (`vfx/Distortion.js`, `SHOCK`) — that is + * the *push* — and the panes fly. + * 4. **let go** — `token.blend()` falls off on `releaseCurve`, which holds + * the field and then drops it rather than dimming it evenly. Colour comes + * back to the room slightly after the glass has finished falling, which + * is the right order: the room recovers, it is not un-dispelled. + * + * ## Two things that are easy to get wrong here + * + * **The token can be taken.** Two live Spellbreaks resolve LIFO: the second + * one drives, the first keeps a live token reading `driving === false` and + * takes the field back the frame the second releases. That is the library's + * behaviour and it is correct, but it means this ability must not assume its + * own writes are what the world sees. It reads `driving` and takes its own + * bell's rim down when it is not the one driving, so two overlapping casts + * tell you which of them owns the room. + * + * **The hook must be given back on all four exits.** A leaked `DISRUPT` holds + * the whole stage desaturated for the rest of the session with no cast on + * screen to blame, so the acquisition goes through `this.borrow(...)` and the + * base class returns it however the cast ends — including the player pressing + * **C** mid-bell. + * + * **The rule that makes the editor work.** A cast captures one unitless seed, + * one boolean (has it broken yet) and two clocks. Every metre, radian, second + * and 0..1 power — including everything the hook publishes — is resolved + * against `settings.spellbreak` inside the update loop, on a zero-length frame + * included. Pause with **P** while the bell stands and drag `drain`: the + * colour comes out of the standing beam next to it with the clock stopped. + */ +export class SpellbreakAbility extends Ability { + constructor(context) { + super('spellbreak', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The bell. `DOME` rather than a bespoke mesh because the seal band where + * a dome meets the floor is the single most useful thing `Shell` owns, and + * a bell of glass standing on stone is exactly that read: the brightest + * line on the whole shape is where it touches. + */ + this.bell = new Shell({ + mode: ShellMode.DOME, + prefix: 'glass', + nodes: 44, + sides: 52, + renderOrder: 13 + }); + this.group.add(this.bell.group); + + /** + * The overlay — an object whose prototype **is** the live settings block. + * + * `Shell` is a prefixed module: it reads `c[keys.radius]` off whatever + * object it is handed. The dome's two radii are the one pair of numbers + * this ability computes rather than authors, because a bell of glass whose + * size does not follow the aim circle is a bell standing in the wrong + * place. Writing them back into `settings.spellbreak` was the first + * version and it is wrong twice over: the editor's own slider snaps back + * every frame, and a saved preset records a metre value the user never + * chose. So two own properties are written here every frame and every + * other key resolves through the prototype, live, with the block itself + * never written to. `settings.spellbreak` is mutated in place by + * `applySettings` and by the editor — its identity never changes — so this + * stays welded to it for the life of the process. + * + * The block deliberately carries no `glassRadius` / `glassRadiusEnd` at + * all, so the panel cannot show two sliders that do nothing. `Shell`'s + * missing-key audit walks the prototype chain, so it stays quiet. + */ + this._glass = Object.create(settings.spellbreak); + this._glass.glassRadius = 1; + this._glass.glassRadiusEnd = 1; + + /** + * The panes. + * + * `ShatterField`'s factory takes only a variant index — it has no + * `syncGeometry(shape)` the way `GrowthField` does — so the ability owns + * the rebuild hash. Three numbers describe the silhouette and a change in + * any of them rebuilds two geometries; see `_syncPanes`. + */ + this._paneShape = { sides: 0, thickness: 0, ragged: 0 }; + this.panes = new ShatterField(this.group, { + geometry: (variant) => createPaneGeometry(variant, this._paneShape), + variants: PANE_VARIANTS, + capacity: MAX_PANES, + renderOrder: 7 + }); + + /** + * The push. A `SHOCK` emitter writing to the distortion buffer, billboarded + * so the front reads as a sphere of pressure rather than as a ring painted + * on the floor — the whole point is that things *above* the floor are moved + * too, and a `GROUND`-facing quad says the opposite. + */ + this.push = new DistortionField({ + mode: DistortionMode.SHOCK, + facing: DistortionFacing.BILLBOARD, + name: 'Spellbreak:push' + }); + this.group.add(this.push.object3D); + + /** + * Park the hook's live state where the harness's pause probe looks. + * + * An ability whose principal output is a scene hook owns no uniform that + * carries it, so without this the field's thirteen sliders read as a dead + * bank even while they are draining the colour out of the room. Nothing in + * any shader reads these boxes from here; they are here to be seen. + */ + sceneHooks.observe(this.bell.material); + + /* --- what a cast captures: one dice roll, one flag, two clocks --- */ + /** Decorrelates the bell's billow and the panes' seed. Unitless. */ + this._seed = 0; + /** One-way. A slider drag must not be able to re-break the bell. */ + this._broken = false; + /** Seconds since the null front landed. A clock, not a dimension. */ + this._land = 0; + /** Seconds since the bell broke. */ + this._since = 0; + /** The borrowed hook. Null between casts. */ + this._field = null; + /** HUD readout only. */ + this._live = 1; + + /** The zone's anchor, rewritten every frame from the live cast. */ + this._centre = new Vector3(); + + /** Scratch state handed to the shell each frame. One object, reused. */ + this._state = { + origin: new Vector3(), + axis: new Vector3(0, 1, 0), + side: new Vector3(1, 0, 0), + span: 1, + t: 0, + fade: 0, + seed: 0 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Glints off the glass. Velocity-stretched, because a chip of glass + // catching the key light is a streak and not a dot. + this.glints = particles.get('spellbreak.glints', { + capacity: 2600, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.28 + }); + this.glints.uniforms.uDrag.value = 1.3; + this.glints.uniforms.uEndSize.value = 0.2; + this.glints.uniforms.uSizeIn.value = 0.03; + this.glints.uniforms.uFadeIn.value = 0.04; + this.glints.uniforms.uFadeOut.value = 0.45; + + // The drained colour, leaving. Non-additive on purpose and the only + // non-additive haze in the school: this is meant to read as *pigment* + // coming out of the air, and a grey cloud that adds light is a grey cloud + // that makes the room brighter, which is the opposite of the sentence. + this.dust = particles.get('spellbreak.dust', { + capacity: 2200, + shape: ParticleShape.SOFT, + additive: false, + curl: true, + softFade: 0.6 + }); + this.dust.uniforms.uDrag.value = 1.7; + this.dust.uniforms.uEndSize.value = 2.2; + this.dust.uniforms.uSizeIn.value = 0.1; + this.dust.uniforms.uFadeIn.value = 0.15; + this.dust.uniforms.uFadeOut.value = 0.35; + + // Glass powder off the floor under the break. + this.grit = particles.get('spellbreak.grit', { + capacity: 1400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.3; + this.grit.uniforms.uEndSize.value = 0.7; + this.grit.uniforms.uFadeOut.value = 0.65; + + this.glintEmitter = new RateEmitter(); + this.dustEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — every beat re-derived from settings, every frame */ + /* ------------------------------------------------------------------ */ + + /** A beat's length in seconds, under the global lifetime knob. */ + _span(seconds) { + return Math.max(0.01, seconds * settings.global.lifetime); + } + + /** + * The impact phase holds all three beats: the bell closing, the bell + * standing, and the ring crossing the zone after it breaks. Scaling the + * phase without scaling the beats leaves the ring still travelling when the + * phase machine has already moved on. + */ + get impactDuration() { + const c = settings.spellbreak; + return this._span(c.sealTime) + this._span(c.holdTime) + this._span(c.ringTime); + } + + get fadeDuration() { + return Math.max(0.05, settings.spellbreak.fadeTime); + } + + get instanceCount() { + return this._live; + } + + /** + * Glass swells; it does not gutter. + * + * A hard stutter here made the slot read as electrical, which is Storm's + * sentence and not this one. What this light actually wants to say is that + * something is being held shut under pressure. + */ + lightShimmer() { + const c = settings.spellbreak; + return 1 - saturate(c.lightPulse) * 0.5 * (1 - Math.cos(this.age * c.lightPulseSpeed)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The live footprint, metres. What the aim circle measured out. */ + get radius() { + return Math.max(0.2, settings.spellbreak.zoneRadius); + } + + /** Where the zone is: the far end of the aimed line, on the floor. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** 0..1 — how far the bell has closed. */ + _sealAmount() { + return saturate(this._land / this._span(settings.spellbreak.sealTime)); + } + + /** 0..1 — how far the pressure ring has crossed the zone. */ + _ringAmount() { + if (!this._broken) return 0; + return saturate(this._since / this._span(settings.spellbreak.ringTime)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.glintEmitter.reset(); + this.dustEmitter.reset(); + this.gritEmitter.reset(); + + this._broken = false; + this._land = 0; + this._since = 0; + this.panes.clear(); + + // The only thing a cast captures with no unit attached to it. + this._seed = Math.random() * 100; + + // Borrowed through the base class so it comes back on all four exits — + // finishing, the concurrency cap, **C**, and teardown. See the class doc. + this._field = this.borrow(sceneHooks.acquire(Hook.DISRUPT, this)); + + this._syncPanes(); + this._sync(1); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* The pane silhouette */ + /* ------------------------------------------------------------------ */ + + /** + * Rebuild the two pane geometries when — and only when — one of the three + * numbers that describes their outline has moved. + * + * This is `GrowthField#syncGeometry` reimplemented in the ability, because + * `ShatterField`'s factory contract takes a variant index and nothing else. + * It allocates, which I3 forbids *during a cast* — and it does not happen + * during a cast: three numbers are compared and the common case is three + * float compares and a return. It fires when somebody drags `paneSides`, + * which is exactly the moment an allocation is affordable and exactly the + * moment the alternative — a corner count that only takes effect on the next + * cast — would be an I1 violation you could see. + */ + _syncPanes() { + const c = settings.spellbreak; + const shape = this._paneShape; + const sides = clamp(Math.round(c.paneSides), 3, 8); + if (shape.sides === sides && shape.thickness === c.paneThickness && shape.ragged === c.paneRagged) { + return false; + } + + shape.sides = sides; + shape.thickness = c.paneThickness; + shape.ragged = c.paneRagged; + + for (let v = 0; v < this.panes.meshes.length; v++) { + const mesh = this.panes.meshes[v]; + const built = createPaneGeometry(v, shape); + // The two instanced attributes are the field's, not the geometry's — the + // arrays outlive any rebuild and are carried across by reference. + built.setAttribute('aSeed', this.panes.seedAttributes[v]); + built.setAttribute('aLife', this.panes.lifeAttributes[v]); + mesh.geometry.dispose(); + mesh.geometry = built; + } + return true; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current cast state into the hook, the bell, + * the panes, the pressure ring and the three particle systems. + * + * @param {number} fade 1 while the cast stands, ramping to 0 as it lets go + */ + _sync(fade) { + const c = settings.spellbreak; + const g = settings.global; + + this._centrePoint(this._centre); + const radius = this.radius; + const travelling = this.phase === AbilityPhase.TRAVEL; + const seal = travelling ? 0 : this._sealAmount(); + const ring = this._ringAmount(); + + /* ---------------- 1 · the field, which is the ability -------------- */ + this._syncField(fade, travelling, seal); + + /* ---------------- 2 · the bell ------------------------------------- */ + // Written whether or not it is on screen. Syncing only the visible pieces + // is the tempting optimisation and it breaks the paused editor: drag + // `domeSpan` while the bell is down and it must still be the size you chose + // when it comes up. + const glass = this._glass; + glass.glassRadius = Math.max(0.01, radius * c.domeStart); + glass.glassRadiusEnd = Math.max(0.02, radius * c.domeSpan); + + const state = this._state; + state.origin.copy(this._centre); + state.axis.set(0, 1, 0); + state.side.copy(this.side); + state.span = radius * 2; + state.t = seal; + // The bell does not dim when it breaks, it *stops existing* — the panes are + // what is left of it. A cubic drop over the first fifth of the ring beat + // hands the read over cleanly; a linear fade left a ghost dome hanging over + // a field of falling glass for a third of a second and it looked like two + // separate effects. + const gone = this._broken ? Easing.inCubic(saturate(this._since / (this._span(c.ringTime) * 0.2))) : 0; + // Not driving the hook means another Spellbreak has the room. Say so on the + // rim rather than silently writing values nobody is reading. + const owned = this._field && !this._field.driving ? 0.45 : 1; + state.fade = travelling ? 0 : fade * (1 - gone) * owned; + state.seed = this._seed; + this.bell.sync(glass, state, g); + this.bell.visible = state.fade > 0.002; + + /* ---------------- 3 · the panes ------------------------------------ */ + this._syncPanes(); + + const p = _panes; + p.layout = ShatterLayout.ZONE; + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + p.centre = this._centre; + p.radius = radius * c.paneSpawnSpan; + p.width = radius; + + p.spawnRadius = c.paneSpawnRadius; + p.spawnHeight = c.paneSpawnHeight; + + p.speed = c.paneSpeed; + p.speedJitter = c.paneSpeedJitter; + p.spread = c.paneSpread; + p.upBias = c.paneUp; + p.inherit = null; + p.inheritScale = 1; + + p.gravity = c.paneGravity; + p.drag = c.paneDrag; + + p.size = c.paneSize; + p.sizeJitter = c.paneSizeJitter; + p.shrink = c.paneShrink; + p.shrinkPower = c.paneShrinkPower; + p.spin = c.paneSpin; + p.spinJitter = c.paneSpinJitter; + + p.lifetime = c.paneLifetime; + p.floor = 0; + p.floorSpin = c.paneFloorSpin; + p.randomness = g.randomness; + + this.panes.sync(this._paneLook()); + this._live = 1 + this.panes.update(this.age, p); + + /* ---------------- 4 · the push ------------------------------------- */ + const pushRadius = Math.max(0.05, radius * c.pushSpan); + this.push.setAnchorXYZ(this._centre.x, c.pushLift, this._centre.z); + _push.width = pushRadius * 2; + _push.height = pushRadius * 2; + _push.radius = pushRadius; + // `wave` is where the front *is*, in metres from the anchor. It is derived + // from the beat rather than integrated, so scrubbing `ringTime` on a paused + // frame moves the ring rather than changing how fast it will move next. + _push.wave = pushRadius * ring; + _push.thickness = c.pushThickness; + _push.compression = c.pushCompression; + _push.rarefaction = c.pushRarefaction; + _push.rings = c.pushRings; + _push.ringGap = c.pushRingGap; + _push.ringDecay = c.pushRingDecay; + _push.window = c.pushWindow; + _push.maxOffset = c.pushMaxOffset; + _push.depthFade = c.pushDepthFade; + _push.seed = this._seed; + // NEVER multiplied by global.distortion or post.distortion — the pass + // applies both, once, and doing it here squares them. + _push.strength = c.pushStrength * fade; + _push.opacity = c.pushOpacity; + this.push.update(_push); + this.push.visible = this._broken && ring < 0.999 && fade > 0.01; + + /* ---------------- 5 · the three particle systems ------------------- */ + this.glints.setGradient( + getColor(c.colorGlintA), + getColor(c.colorGlintB), + getColor(c.colorGlintC), + getColor(c.colorGlintD) + ); + this.glints.uniforms.uGravity.value.set(0, c.glintGravity, 0); + this.glints.uniforms.uSizeScale.value = c.glintSize * g.particleSize * 7; + this.glints.uniforms.uLifeScale.value = c.glintLifetime * 0.5 * g.particleLifetime; + this.glints.uniforms.uSpeedScale.value = g.particleSpeed; + this.glints.uniforms.uOpacity.value = g.opacity; + this.glints.uniforms.uGlow.value = 1.1 * g.glow; + this.glints.uniforms.uStretch.value = c.glintStretch; + this.glints.uniforms.uTurbulence.value = 0.22 * g.turbulence; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize * 7; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + } + + /** + * The published region — the whole reason this ability exists. + * + * Three regimes on one set of sliders, and the interesting one is the first: + * while the null front is still in the air the field is already open at + * `warnSpan` of its radius, ramped by how far the front has travelled. The + * room starts losing its colour before anything has arrived to take it. + * + * @param {number} fade 1 while the cast stands, → 0 as it lets go + * @param {number} travelling true while the null front is still in the air + * @param {number} seal 0..1, how far the bell has closed + */ + _syncField(fade, travelling, seal) { + const token = this._field; + if (!token) return; + const c = settings.spellbreak; + + const full = Math.max(0.05, this.radius * c.fieldSpan); + const warn = full * saturate(c.warnSpan); + + // Where the field is measured *from*. A sphere, so the lift matters: at 0 + // a bolt at chest height is half outside a five-metre field and the top of + // it survives the dispel, which looks like the shader is broken. + token.atPoint(this._centre); + token.centre.y += c.fieldLift; + + if (travelling) { + const reach = saturate(this.u); + token.region(lerp(full * 0.15, warn, reach), c.fieldEdge); + token.power(c.warnDrain * reach, 0, c.warnDim * reach); + } else { + token.region(lerp(warn, full, seal), c.fieldEdge); + + // The bite: the frame the bell breaks, the erosion spikes and then falls + // back to whatever the standing field is. Derived from a timestamp rather + // than decayed by hand, so pausing and dragging `fractureFall` re-shapes + // a decay that is already half over. + let fracture = c.fracture * seal; + if (this._broken) { + const bite = 1 - saturate(this._since / Math.max(0.02, c.fractureFall)); + fracture = lerp(c.fracture, c.fractureBite, Easing.outCubic(bite)); + } + token.power(c.drain * seal, fracture, c.dim * seal); + } + + token.shardSize(c.shardPixels); + token.blend(fade); + } + + /** + * The pane look, filled into module scratch. One object, reused — + * `ShatterField#sync` reads it and keeps nothing. + * + * It takes no arguments on purpose. Naming a parameter `c` or `g` in this + * file would make `npm run check`'s static settings-key pass treat *every* + * `c.something` read in the whole class as advisory rather than required, + * and that pass is the one that catches a typo before it becomes NaN + * geometry. Cheap to re-read the two blocks; not cheap to lose the check. + */ + _paneLook() { + const c = settings.spellbreak; + const g = settings.global; + const look = _look; + look.colorA = getColor(c.colorPaneA); + look.colorB = getColor(c.colorPaneB); + look.colorEdge = getColor(c.colorPaneEdge); + look.colorScene = getColor(c.colorPaneScene); + look.opacity = c.paneOpacity * g.opacity; + look.glow = c.paneGlow * g.glow; + look.rim = c.paneRim * g.fresnel; + look.rimPower = c.paneRimPower; + look.shade = c.paneShade; + look.ambient = c.paneAmbient; + look.fadeStart = c.paneFadeStart; + look.soft = c.paneSoft; + look.sceneMix = c.paneSceneMix; + look.refract = c.paneRefract; + look.saturation = c.paneSaturation; + return look; + } + + /** A small null flare at the hand as the front leaves it. */ + _castFx() { + const c = settings.spellbreak; + const g = settings.global; + + _pos.copy(this.origin).addScaledVector(this.direction, 0.6).setY(1.25); + + _emit.position = _pos; + _emit.radius = 0.16; + _emit.direction = _dir.copy(this.direction); + _emit.speed = c.glintSpeed * 1.4; + _emit.speedVariance = 0.7; + _emit.spread = 0.6; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.14; + _emit.sizeVariance = 0.6; + _emit.life = c.glintLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.glints.emit(Math.round(22 * g.particleCount), _emit); + + this.lightBoost = c.lightIntensity * 0.3 * g.explosionIntensity; + } + + /** + * Everything the standing cast sheds. + * + * @param {number} dt + * @param {number} scale 0..1 — thinned as the cast lets go + */ + _zoneFx(dt, scale) { + const c = settings.spellbreak; + const g = settings.global; + const time = frame.uTime.value; + const radius = this.radius; + const centre = this._centre; + + /* --- glints, round the rim of the bell --- */ + let glintCount = Math.round(this.glintEmitter.tick(dt, c.glintRate * scale) * g.particleCount); + if (glintCount > 0) { + _emit.speed = c.glintSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.7; + _emit.life = c.glintLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + + const shell = radius * Math.max(0.02, c.domeSpan); + const per = Math.ceil(glintCount / Math.min(glintCount, GLINT_ARCS)); + while (glintCount > 0) { + // On the dome's own surface, not inside it: a hashed bearing and a + // hashed elevation, then the sphere's own parametrisation. Sampling a + // disc and lifting it put every glint in the middle of the bell and the + // shape stopped reading. + const a = Math.random() * TAU; + const e = Math.random() * Math.PI * 0.5; + const ring = Math.cos(e) * shell; + _pos.set( + centre.x + Math.cos(a) * ring, + Math.sin(e) * shell * Math.max(0.02, c.glassHeight), + centre.z + Math.sin(a) * ring + ); + _emit.position = _pos; + _emit.radius = radius * 0.05 + 0.04; + _emit.direction = _dir.set(Math.cos(a) * 0.5, 0.7, Math.sin(a) * 0.5).normalize(); + this.glints.emit(Math.min(per, glintCount), _emit); + glintCount -= per; + } + } + + /* --- the drained colour, coming out of the whole volume --- */ + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (dustCount > 0) { + const a = Math.random() * TAU; + const r = radius * Math.sqrt(Math.random()); + _pos.set(centre.x + Math.cos(a) * r, randRange(0.1, c.fieldLift * 1.6), centre.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = radius * 0.16; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.5; + _emit.sizeVariance = 0.55; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0.25; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + /* --- grit, only once there is broken glass to make it --- */ + if (!this._broken) return; + const gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount); + if (gritCount > 0) { + const a = Math.random() * TAU; + const r = radius * Math.sqrt(Math.random()); + _pos.set(centre.x + Math.cos(a) * r, 0.08, centre.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = radius * 0.12; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.85; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.time = time; + this.grit.emit(gritCount, _emit); + } + } + + /** + * The one-shot, tested against the live beat lengths every frame. + * + * The flag is one-way. Dragging `holdTime` upward after the bell has gone + * would otherwise push the threshold back past the clock and let the break + * fire a second time — a hundred panes out of nowhere, and one of the more + * baffling things a paused editor can do to you. + */ + _checkBeats() { + const c = settings.spellbreak; + if (this._broken) return; + if (this._land < this._span(c.sealTime) + this._span(c.holdTime)) return; + this._broken = true; + this._since = 0; + this._breakFx(); + } + + /** The bell goes: the panes, the shell of dead air, the ring, the flash. */ + _breakFx() { + const c = settings.spellbreak; + const g = settings.global; + const time = frame.uTime.value; + const radius = this.radius; + + this._centrePoint(_hit); + + /* the panes themselves */ + this.panes.burst(this.age, Math.min(MAX_PANES, Math.round(c.paneCount * g.particleCount)), 1, 0); + + /* the shell of dead air over the zone */ + _pos.copy(_hit).setY(c.fieldLift * 0.7); + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.55, + intensity: c.burstIntensity, + opacity: 0.7, + fresnel: 1.8, + displace: 0.35, + squash: 0.8, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring on the floor, under the pressure front */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _hit, { + radius: radius * c.shockSpan * g.explosionIntensity, + life: 0.7, + width: 0.05, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* glints, grit and a hard slug of drained colour, all at once */ + _emit.position = _hit; + _emit.radius = radius * 0.7; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.glintSpeed * 2.1; + _emit.speedVariance = 0.85; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.8; + _emit.life = c.glintLifetime * 1.5; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.glints.emit(Math.round(c.glintBreak * g.particleCount), _emit); + + _emit.radius = radius * 0.55; + _emit.speed = c.dustSpeed * 2.6; + _emit.spread = 1.0; + _emit.size = 0.9; + _emit.life = c.dustLifetime * 1.2; + _emit.spin = 0.4; + this.dust.emit(Math.round(c.dustBreak * g.particleCount), _emit); + + _emit.radius = radius * 0.6; + _emit.speed = c.gritSpeed * 1.8; + _emit.spread = 0.85; + _emit.size = 0.11; + _emit.life = c.gritLifetime * 1.3; + _emit.spin = 11; + this.grit.emit(Math.round(c.gritBreak * g.particleCount), _emit); + + this.ctx.shake.add( + c.breakShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 24 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.breakFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.4 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + + // The light rides the null front out and then sits over the zone. + this.pointAt(this.u, this.position).setY(settings.spellbreak.fieldLift * 0.6); + + this._zoneFx(dt, 0.35); + this.ctx.shake.rumble(settings.spellbreak.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + // Nothing fires here. The bell's beats run on their own clock from the + // moment the front lands, and the landing is not one of them — putting the + // break here made a zone cast at three metres shatter before the glass had + // finished closing. + this._land = 0; + this._centrePoint(this.position); + } + + onFade(dt, t) { + const c = settings.spellbreak; + this._land += dt; + if (this._broken) this._since += dt; + this._checkBeats(); + + // `t` runs 0..1 while the cast stands, then 1..2 while it lets go. The + // let-go is `1 - out^releaseCurve` rather than a linear ramp: at 2.1 the + // field holds nearly full strength for most of the fade and then drops, + // so colour returns to the room *after* the last pane has landed. A linear + // ramp gave the opposite order — the room recovering while glass was still + // in the air — and read as the dispel wearing off rather than ending. + const out = saturate(t - 1); + const fade = t <= 1 ? 1 : 1 - Math.pow(out, Math.max(0.05, c.releaseCurve)); + + this._sync(fade); + + this._centrePoint(this.position); + this.position.y = c.fieldLift * (this._broken ? 0.45 : 0.9); + + this._zoneFx(dt, fade * (this._broken ? 0.55 : 1)); + this.ctx.shake.rumble(c.rumble * fade * 0.5 * settings.global.cameraShake, dt); + } + + onDestroy() { + this._live = 1; + this._broken = false; + this._land = 0; + this._since = 0; + this.panes.clear(); + this.bell.visible = false; + this.push.visible = false; + // Idempotent, and `Ability#destroy()` releases it again a line later. Doing + // it here as well means the world is exact on the frame the cast ends + // rather than on the frame after it. + this._field?.release(); + this._field = null; + } + + dispose() { + this.bell.dispose(); + this.panes.dispose(); + this.push.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/arcane/StarfallAbility.js b/src/abilities/arcane/StarfallAbility.js new file mode 100644 index 0000000..0f9e94a --- /dev/null +++ b/src/abilities/arcane/StarfallAbility.js @@ -0,0 +1,933 @@ +import { + AdditiveBlending, + BufferGeometry, + DoubleSide, + Float32BufferAttribute, + ShaderMaterial, + Vector3 +} from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { FlightMode, Projectile, Stagger, projectileParams } from '../../vfx/Projectile.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { noiseGLSL } from '../../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../../shaders/lib/common.glsl.js'; +import { frame, sharedUniforms } from '../../core/FrameUniforms.js'; +import { LAYER } from '../../core/Layers.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, hash11, Easing } from '../../utils/math.js'; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +/** Hard ceiling on stars in one call. The `stars` slider clamps here. */ +const MAX_STARS = 40; +/** + * How many impacts the floor quad carries at once. + * + * `GroundField(POCK)` walks this list five times per fragment — three height + * taps for the fake normal, plus the cover and heat masks — so the number is a + * real fill cost and not a capacity to be generous with. Sixteen holds every + * ring that is still visibly warm at the shipped `ringLife`; past that the ring + * buffer recycles oldest-first, which is exactly the mark you want to lose. + */ +const FLOOR_MARKS = 16; +/** Facets around the body. Five reads as a shard; eight reads as a pill. */ +const STAR_SIDES = 5; + +/* ------------------------------------------------------------------ */ +/* Scratch — module scope, reused, never allocated in a frame (I3) */ +/* ------------------------------------------------------------------ */ + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _hand = new Vector3(); +/** Filled from `settings.starfall` every frame and handed to the modules. */ +const _flight = projectileParams(); +const _ground = groundFieldParams(); + +/* ------------------------------------------------------------------ */ +/* The body */ +/* ------------------------------------------------------------------ */ + +/** + * One star: a faceted spindle in unit space, nose at `y = +1`, tail at `y = -1`, + * circumscribed radius 1 across. + * + * `Projectile` scales an instance `(radius, radius × stretch, radius)` and — at + * `align = 1` — lays local **+Y** along the heading, so this orientation is the + * contract, not a preference: the nose is the end that arrives first. The widest + * ring sits just *behind* the nose rather than in the middle, because a + * symmetrical bipyramid reads as a diamond tumbling and an asymmetric one reads + * as something moving in a direction. + * + * The first version of this was an icosahedron, on the theory that a star is a + * point of light and the silhouette does not matter. At 0.13 m with a bright + * additive shader it read as a *bead*, and twenty beads on strings is a curtain, + * not a fall. The spindle is what puts a direction into the body itself, which + * matters most in the frame before the impact, when the trail is behind the + * camera and the body is all you have. + */ +function createStarGeometry() { + const sides = STAR_SIDES; + // Waist heights and radii. Non-uniform on purpose — see above. + const rings = [ + [-1.0, 0.0], + [-0.55, 0.42], + [0.1, 1.0], + [0.45, 0.78], + [1.0, 0.0] + ]; + + const angles = []; + for (let i = 0; i < sides; i++) { + // Jittered once and shared by every ring, so the facets stay continuous + // edges down the body instead of twisting into a screw. + const jitter = (hash11(i * 7.31 + 3.7) - 0.5) * (Math.PI * 2 / sides) * 0.4; + angles.push((i / sides) * Math.PI * 2 + jitter); + } + + const ringPoints = rings.map(([y, r], ringIndex) => + angles.map((angle, i) => { + const wobble = 1 + (hash11(ringIndex * 13.9 + i * 5.1) - 0.5) * 0.22; + const rr = r * wobble; + return [Math.cos(angle) * rr, y, Math.sin(angle) * rr]; + }) + ); + + const positions = []; + const push = (p) => positions.push(p[0], p[1], p[2]); + for (let ring = 0; ring < ringPoints.length - 1; ring++) { + const lower = ringPoints[ring]; + const upper = ringPoints[ring + 1]; + for (let i = 0; i < sides; i++) { + const j = (i + 1) % sides; + push(lower[i]); push(lower[j]); push(upper[i]); + push(lower[j]); push(upper[j]); push(upper[i]); + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + // Non-indexed with per-face normals: this is what makes the facets crisp. + geometry.computeVertexNormals(); + return geometry; +} + +/* ------------------------------------------------------------------ */ +/* The body's material */ +/* ------------------------------------------------------------------ */ + +const STAR_VERTEX = /* glsl */ ` + attribute float aSeed; + attribute float aFlight; + attribute float aFlash; + + varying float vSeed; + varying float vFlight; + varying float vFlash; + varying vec3 vLocal; + varying vec3 vNormalW; + varying vec3 vViewDir; + + void main() { + vSeed = aSeed; + vFlight = aFlight; + vFlash = aFlash; + vLocal = position; + + mat4 im = mat4(1.0); + #ifdef USE_INSTANCING + im = instanceMatrix; + #endif + + // The body is scaled (r, r * stretch, r), which is anisotropic: pushing a + // normal straight through the instance matrix tips every one of them toward + // the long axis and the whole star lights as though it were a rod. Dividing + // by the squared column lengths is the inverse-transpose three itself uses. + mat3 rot = mat3(im); + vec3 sq = vec3(dot(rot[0], rot[0]), dot(rot[1], rot[1]), dot(rot[2], rot[2])); + vec3 objectNormal = rot * (normal / max(sq, vec3(1e-6))); + + vec4 world = modelMatrix * im * vec4(position, 1.0); + vNormalW = normalize(mat3(modelMatrix) * objectNormal); + vViewDir = cameraPosition - world.xyz; + + gl_Position = projectionMatrix * viewMatrix * world; + } +`; + +const STAR_FRAGMENT = /* glsl */ ` + uniform vec3 uColorCore; + uniform vec3 uColorEdge; + uniform vec3 uColorDeep; + uniform float uGlow; + uniform float uOpacity; + uniform float uRim; + uniform float uHeat; + uniform float uFacet; + uniform float uFacetScale; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + + varying float vSeed; + varying float vFlight; + varying float vFlash; + varying vec3 vLocal; + varying vec3 vNormalW; + varying vec3 vViewDir; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + float fres = fresnelTerm(vViewDir, vNormalW, uRim, 1.0); + + // The facet grain is sampled in the body's OWN space, not the world's. + // A world-space sample was the first thing tried and it is wrong for a + // reason worth writing down: a star crosses forty metres in half a second, + // so a world-space field slides across it far faster than the body moves + // and the grain reads as noise on the lens rather than as facets on the + // object. Local space nails the pattern to the star; the seed keeps two + // stars from wearing the same one. + float grain = snoise(vec3(vLocal * uFacetScale + vSeed * 37.0)) * 0.5 + 0.5; + float facet = mix(1.0, grain, clamp(uFacet, 0.0, 1.0)); + + // A star brightens as it comes down: the flight parameter is the only clock + // in here and it runs 0 at the vanishing point to 1 at the floor. + float heat = uHeat * pow(clamp(vFlight, 0.0, 1.0), 2.5) + vFlash * 2.0; + + vec3 body = mix(uColorDeep, uColorEdge, facet); + vec3 color = mix(body, uColorCore, clamp(fres * 0.8 + heat * 0.35, 0.0, 1.0)); + color *= uGlow * uGlobalGlow * uShaderIntensity * (1.0 + heat); + + float alpha = clamp(uOpacity * (0.45 + 0.55 * fres + heat * 0.25), 0.0, 1.0); + gl_FragColor = vec4(color, alpha); + } +`; + +function createStarMaterial() { + return new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uColorCore: { value: getColor('#ffffff').clone() }, + uColorEdge: { value: getColor('#c0d8ff').clone() }, + uColorDeep: { value: getColor('#3a5fd0').clone() }, + uGlow: { value: 2.6 }, + uOpacity: { value: 1 }, + uRim: { value: 2.2 }, + uHeat: { value: 1.6 }, + uFacet: { value: 0.55 }, + uFacetScale: { value: 9 } + }), + vertexShader: STAR_VERTEX, + fragmentShader: STAR_FRAGMENT + }); +} + +/* ------------------------------------------------------------------ */ +/* The ability */ +/* ------------------------------------------------------------------ */ + +/** + * STARFALL — a rain of cold light called onto a far circle. + * + * Three beats: the **call** sweeps out to the circle at `speed` and inscribes + * it on the floor; the **rain** drops stars into it on staggered timers; the + * **fade** lets the last rings weather off the stone. + * + * **THE TRICK — one vanishing point.** Every star in the call leaves the *same + * place*: `skyBack` metres behind the caster and `skyHeight` metres up. Nothing + * else in the ability is shared. They land all over the disc, so their trails + * radiate out of a single knot in the sky and fan apart on the way down, and + * that fan is a genuine perspective cue — the eye reads a common vanishing + * point as *depth above the camera*, which is the one thing a rain of parallel + * streaks can never say. Hailwrath drops stones straight down and reads as + * weather; this reads as sky. + * + * It was built the other way first, with each star given its own launch point + * scattered across a wide patch overhead, because a shared origin sounded like + * a fountain played backwards. What it actually looked like was twenty parallel + * white lines: with no common point there is nothing for the eye to converge + * on, and the whole thing flattened onto the screen. `skyScatter` is the + * survivor of that version — a few tens of centimetres of slop so the sky end + * is a small bright knot instead of a mathematical singularity, and past about + * a metre the read starts to go again. + * + * **The envelope.** The arrival rate ramps, peaks and tails, and nothing + * schedules it. `Projectile` derives a star's launch delay two ways — a radial + * ordering of the disc and a spatial hash of the floor — and `fillScatter` + * mixes them. The sum of two differently shaped random variables is a hump, so + * a mid-range mix gives few stars early, a crowd through the middle and a + * thinning tail, deterministically per seed and never twice in the same order. + * Dragging `fillScatter` on a standing cast re-times every star that has not + * landed yet. + * + * **What a cast captures.** One seed and a boolean ("has the first star landed + * yet"), plus one timestamp per impact posted into the floor quad's ring buffer. + * That is all. Every metre — the height of the vanishing point, the radius of + * the disc, the size of a star, the radius of a ring on the floor — is resolved + * from `settings.starfall` inside `_sync`, on a zero-length frame included. + * Pause mid-rain and drag `skyHeight`: the stars still in the air re-fly from + * the new point, the trails re-draw behind them, and the ones already down stay + * where they landed, because a landing is an event and a height is not. + * + * **Draw calls.** Three of its own — the instanced bodies, the one instanced + * strip that carries every trail, and the single ground quad that carries every + * ring — plus the three shared particle systems and a handful of pooled shells. + */ +export class StarfallAbility extends Ability { + constructor(context) { + super('starfall', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.starMaterial = createStarMaterial(); + + // `geometry` is a factory, which is what `Projectile` wants: it takes + // ownership of the buffer and disposes it, so handing it one we also drew + // with elsewhere would be a double free. + this.stars = new Projectile(this.group, { + geometry: createStarGeometry, + material: this.starMaterial, + capacity: MAX_STARS, + trail: true, + trailNodes: 32, + trailAdditive: true, + layer: LAYER.VFX, + renderOrder: 12 + }); + + // Every impact ring in one quad. Additive, because a star leaves light on + // the floor rather than a stain — the same mode Hemorrhage runs shaded to + // leave pools, which is most of why the two do not look alike. + this.floor = new GroundField(this.group, { + mode: GroundMode.POCK, + marks: FLOOR_MARKS, + additive: true, + depthTest: true, + name: 'Starfall:floor' + }); + + // The params object the ground quad reads is filled per frame; only the + // anchor is a reference, and it is bound once so no frame allocates. + _ground.centre = _centre; + + /** Re-rolled per cast so two calls never fill the circle in one order. */ + this._seed = 0; + /** Has any star landed yet? The screen flash fires once, on the first. */ + this._struck = false; + /** Stars drawn on the last sync — the HUD's instance readout. */ + this._live = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Chips of light thrown out of an impact. Velocity-stretched under gravity, + // which is what stops them reading as a spherical pop. + this.sparks = particles.get('starfall.sparks', { + capacity: 1400, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.3; + this.sparks.uniforms.uEndSize.value = 0.2; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.45; + + // Slow cold motes: released by an impact, and shed continuously by every + // star still in the air, which is where the falling glitter comes from. + this.motes = particles.get('starfall.motes', { + capacity: 1400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.5; + this.motes.uniforms.uEndSize.value = 0.18; + this.motes.uniforms.uSizeIn.value = 0.05; + this.motes.uniforms.uFadeIn.value = 0.07; + this.motes.uniforms.uFadeOut.value = 0.42; + + // Floor dust. Non-additive so it genuinely occludes — the one thing in the + // palette that is grey rather than blue, and the reason the impacts read as + // hitting stone instead of hanging over it. + this.dust = particles.get('starfall.dust', { + capacity: 900, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.dust.uniforms.uDrag.value = 2.0; + this.dust.uniforms.uEndSize.value = 2.6; + this.dust.uniforms.uSizeIn.value = 0.12; + this.dust.uniforms.uFadeIn.value = 0.18; + this.dust.uniforms.uFadeOut.value = 0.34; + + this.sparkleEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + /** The rain holds for `lifetime`; every arrival should land inside it. */ + get impactDuration() { + return Math.max(0.05, settings.starfall.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.starfall.fadeTime); + } + + /** + * A cold twinkle rather than a gutter. + * + * Two sines at incommensurate rates so the pattern does not repeat on a beat + * you can hear; the product keeps it mostly near full and occasionally dips, + * which is what a starlit floor does and a flickering torch does not. + */ + lightShimmer() { + const c = settings.starfall; + const rate = Math.max(0.05, c.lightTwinkleSpeed); + const wave = Math.sin(this.age * rate * 2.399) * Math.sin(this.age * rate * 1.111); + return 1 - saturate(c.lightTwinkle) * 0.5 * (1 - wave); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The centre of the circle the stars fall into. */ + _zoneCentre(out) { + return this.pointAt(1, out); + } + + /** Where the call leaves the caster, in world space. */ + _handPoint(out) { + const c = settings.starfall; + out.copy(this.origin).addScaledVector(this.direction, c.callForward); + out.y = c.callHeight; + return out; + } + + /** + * The floor quad's yaw: local **+Z** runs downrange, which is the frame + * `mark()` records its unitless hits in. + */ + _yaw() { + return Math.atan2(this.direction.x, this.direction.z); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sparkleEmitter.reset(); + this.floor.clearMarks(); + this._struck = false; + this._live = 0; + + // The two things a cast is allowed to capture: a dice roll and, later, + // timestamps. Neither is a dimension. + this._seed = Math.random() * 100; + this.stars.roll(this._seed); + + this._sync(1); + this._callFx(); + } + + /* ------------------------------------------------------------------ */ + /* Per-frame */ + /* ------------------------------------------------------------------ */ + + /** + * Fill the flight params from the live block. + * + * Every key `Projectile` understands is written, including the ones this + * ability does not use: the object is module scope and shared, and a stale + * `apex` left in it from some other read of the file would loft a star that + * is supposed to fall straight. + */ + _fillFlight(fade) { + const c = settings.starfall; + const g = settings.global; + const p = _flight; + const clock = Math.max(0.05, g.speed); + + p.mode = FlightMode.FALL; + // Named rather than left on AUTO. AUTO picks HASH for a FALL today; naming + // it means a change to that default cannot silently un-stagger this cast. + p.stagger = Stagger.HASH; + p.count = Math.max(0, Math.min(MAX_STARS, Math.round(c.stars))); + p.radius = c.starSize; + p.sizeJitter = c.starSizeJitter; + p.stretch = c.starStretch; + p.align = c.starAlign; + p.spin = c.starSpin; + p.flash = c.starFlash; + + /* the launch — FALL uses only the three sky keys, but the hand keys are + written anyway so a mode change in the editor cannot read stale metres */ + p.handForward = c.callForward; + p.handSide = 0; + p.handHeight = c.callHeight; + p.fanWidth = 0; + p.skyBack = c.skyBack; + p.skyHeight = c.skyHeight; + p.skyScatter = c.skyScatter; + + /* the landing */ + p.landHeight = c.spreadHeight; + p.spreadSide = 0; + p.spreadForward = 0; + p.landInZone = true; + p.zoneRadius = c.zoneRadius; + p.zoneBias = c.zoneBias; + + /* the curve — a star falls, it does not arc or weave */ + p.pathCurve = c.fallCurve; + p.apex = 0; + p.apexCurve = 1; + p.weaveSide = 0; + p.weaveUp = 0; + p.weaveTurns = 1; + p.weaveTurnsUp = 1; + p.weavePhase = 0; + p.weaveDecay = 1; + + /* the clock — divided by the global speed so the rain keeps pace with the + front, which is driven by the same multiplier inside `advance()` */ + p.flightTime = Math.max(0.02, c.fallTime / clock); + p.speedJitter = c.fallJitter; + p.lead = Math.max(0, c.starLead / clock); + p.window = Math.max(0.01, c.starWindow / clock); + p.fillBias = c.fillBias; + p.fillScatter = c.fillScatter; + p.hashCell = c.hashCell; + p.linger = 0; + p.sink = 0; + p.load = 0; + + /* the trail */ + p.trailSpan = c.trailSpan; + p.trailBurn = c.trailBurn; + p.trailWidth = c.trailWidth; + p.trailTaper = c.trailTaper; + p.trailLift = c.trailLift; + p.trailOpacity = c.trailOpacity * fade * g.opacity; + p.trailGlow = c.trailGlow * g.glow; + p.trailCore = c.trailCore; + p.trailHeadBias = c.trailHeadBias; + p.trailNoise = c.trailNoise * g.noiseStrength; + p.trailNoiseScale = c.trailNoiseScale * g.noiseFrequency; + p.trailNoiseSpeed = c.trailNoiseSpeed * g.noiseSpeed; + p.trailSoftFade = c.trailSoftFade; + return p; + } + + /** Fill the floor quad's params from the live block. */ + _fillGround(fade) { + const c = settings.starfall; + const g = settings.global; + const p = _ground; + + p.centre = _centre; + p.yaw = this._yaw(); + p.height = c.fieldHeight; + p.radius = c.zoneRadius; + p.length = c.zoneRadius * 2; + + // The circle inscribes itself as the call sweeps out to it, then holds. + p.grow = this.phase === AbilityPhase.TRAVEL ? saturate(this.u) : 1; + p.recede = 0; + p.progress = 1; + p.inscribe = 1; + p.ignite = 0; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.fieldEdge; + p.ragged = c.fieldRagged; + p.raggedScale = c.fieldRaggedScale; + p.warp = c.fieldWarp; + + p.relief = c.fieldRelief; + p.normalStep = 0.05; + p.ambient = 0.3; + p.wrap = 0.45; + p.specular = c.fieldSpecular; + p.gloss = c.fieldGloss; + p.parallax = 0.2; + + p.cell = 0.5; + p.cellJitter = 0.8; + p.seam = 0.05; + p.thickness = c.ringThickness; + p.lift = c.ringRim; + p.depth = c.ringDepth; + p.width = 0.5; + p.sharp = 0.5; + p.detail = c.ringDetail; + p.swirl = 0; + p.arms = 5; + // POCK reads `speed` as how fast a crater digs itself in — which, run slow, + // is a rim travelling outward. That is the ring. + p.speed = c.ringDig; + p.flow = 0; + p.windAngle = 0; + + p.markLife = c.ringLife; + p.markRadius = c.ringRadius; + + p.additive = true; + p.emissive = c.fieldEmissive * g.glow; + p.opacity = c.fieldOpacity; + p.depthFade = 0.4; + p.colorBase = c.colorFieldBase; + p.colorEdge = c.colorFieldEdge; + p.colorGlow = c.colorFieldGlow; + p.colorDeep = c.colorFieldDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + return p; + } + + /** Push the live block into the star material and the three particle systems. */ + _syncLook(fade) { + const c = settings.starfall; + const g = settings.global; + const u = this.starMaterial.uniforms; + + u.uColorCore.value.copy(getColor(c.colorStarCore)); + u.uColorEdge.value.copy(getColor(c.colorStarEdge)); + u.uColorDeep.value.copy(getColor(c.colorStarDeep)); + u.uGlow.value = c.starGlow * g.glow; + u.uOpacity.value = c.starOpacity * fade * g.opacity; + u.uRim.value = Math.max(0.05, c.starRim); + u.uHeat.value = c.starHeat; + u.uFacet.value = c.starFacet; + u.uFacetScale.value = c.starFacets; + + this.stars.setTrailColors(c.colorTrailA, c.colorTrailB, c.colorTrailC, c.colorTrailD); + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.starGlow * 0.5 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.2 * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 0.9 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /** + * One frame of the whole ability. + * + * Order matters in exactly one place: `arrivals` is raised by `update()` and + * not re-raised, so it is consumed on the line after. + * + * @param {number} fade 1 while the rain is lit, ramping to 0 as it lets go + */ + _sync(fade) { + const c = settings.starfall; + + this._zoneCentre(_centre); + this._syncLook(fade); + + this.stars.setBasis(this.origin, this.direction, this.side, this.length); + this.stars.update(this.age, this._fillFlight(fade)); + this._live = this.stars.count; + + for (let i = 0; i < this.stars.arrivalCount; i++) this._starImpact(this.stars.arrivals[i]); + + this.floor.update(this._fillGround(fade)); + + // The light sits over the circle until the first star lands, and then rides + // whichever one landed most recently — which is a cheat, and the reason the + // floor lights unevenly instead of glowing like a lamp. + if (!this._struck) { + this.position.copy(_centre); + this.position.y = Math.min(2, c.zoneRadius * 0.35); + } + } + + /* ------------------------------------------------------------------ */ + /* Events */ + /* ------------------------------------------------------------------ */ + + /** The flash at the caster's hand as the call goes up. */ + _callFx() { + const c = settings.starfall; + const g = settings.global; + + this._handPoint(_hand); + + this.ctx.bursts.spawn(BurstMode.AIR, _hand, { + radius: c.callSize * 0.25, + endRadius: c.callSize * g.explosionIntensity, + life: 0.4, + intensity: c.callIntensity, + opacity: 0.85, + fresnel: 1.7, + displace: 0.4, + colorA: getColor(c.colorCallA), + colorB: getColor(c.colorCallB), + colorC: getColor(c.colorCallC) + }); + + _emit.position = _hand; + _emit.radius = 0.16; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed * 2.2; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(Math.round(26 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + } + + /** + * One star has landed. + * + * Everything here is resolved from the live block at the moment of the event + * — an impact is allowed to *read* a dimension, it is just not allowed to + * write one down. The single thing recorded is the timestamp, which goes into + * the floor quad's ring buffer alongside two unitless fractions. + */ + _starImpact(index) { + const c = settings.starfall; + const g = settings.global; + const time = frame.uTime.value; + + this.stars.landPoint(index, _pos); + + /* --- the ring on the floor --- */ + // World offset from the anchor, rotated into the quad's own frame and + // divided by the radius: a hit is recorded as a fraction, never a metre, so + // dragging `zoneRadius` afterwards moves the rings out with the circle. + const yaw = this._yaw(); + const cos = Math.cos(yaw); + const sin = Math.sin(yaw); + const ox = _pos.x - _centre.x; + const oz = _pos.z - _centre.z; + const radius = Math.max(0.05, c.zoneRadius); + const strength = 0.7 + 0.3 * hash11(this._seed + index * 3.77); + this.floor.mark((cos * ox - sin * oz) / radius, (sin * ox + cos * oz) / radius, time, strength); + + /* --- the shell --- */ + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.shellSize * 0.2, + endRadius: c.shellSize * g.explosionIntensity, + life: Math.max(0.05, c.shellLife), + intensity: c.shellIntensity, + opacity: 0.8, + fresnel: 1.8, + displace: 0.35, + squash: 0.72, + colorA: getColor(c.colorShellA), + colorB: getColor(c.colorShellB), + colorC: getColor(c.colorShellC) + }); + + /* --- sparks, motes and dust --- */ + _emit.position = _pos; + _emit.radius = 0.1; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(Math.round(c.sparkBurst * g.particleCount), _emit); + + _emit.radius = 0.24; + _emit.speed = c.moteSpeed; + _emit.spread = 1.0; + _emit.size = 0.09; + _emit.life = c.moteLifetime; + _emit.spin = 0; + this.motes.emit(Math.round(c.moteBurst * g.particleCount), _emit); + + _emit.radius = c.ringRadius * 0.8; + _emit.speed = c.dustSpeed; + _emit.spread = 0.85; + _emit.size = 0.7; + _emit.life = c.dustLifetime; + _emit.spin = 0.35; + this.dust.emit(Math.round(c.dustBurst * g.particleCount), _emit); + + /* --- the room --- */ + this.position.copy(_pos); + this.lightBoost = Math.min(60, this.lightBoost + c.lightPunch * g.explosionIntensity); + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.05, c.shakeDuration), + 24 + ); + + // The screen flash fires once, for the first star. Twenty-two of them is a + // strobe, and a strobe is not a rain. + if (!this._struck) { + this._struck = true; + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + } + } + + /** Glitter shed by the stars that are still falling. */ + _sparkleFx(dt) { + const c = settings.starfall; + const g = settings.global; + if (this._live <= 0) return; + + // Rate per *star in the air*, so the glitter density is the arrival + // envelope: it ramps as the call fills the sky and tails as it empties. No + // second curve, and nothing to keep in step with the first. + const rate = c.sparkleRate * this._live; + const count = Math.round(this.sparkleEmitter.tick(dt, rate) * g.particleCount); + if (count <= 0) return; + + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed * 0.4; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.06; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime * 0.7; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + + for (let i = 0; i < count; i++) { + const slot = Math.floor(Math.random() * this._live); + this.stars.slotPosition(slot, _pos); + _emit.position = _pos; + _emit.radius = c.starSize * 1.4; + this.motes.emit(1, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + this._sparkleFx(dt); + this.ctx.shake.rumble(settings.starfall.rumble * settings.global.cameraShake * 0.4, dt); + } + + onImpact() { + // Deliberately empty. The call arriving is not an event on the floor — the + // stars are already falling and each one brings its own impact. Putting a + // bang here was the first thing tried and it stole the read from the first + // star, which is the beat the whole ability is built around. + } + + onFade(dt, t) { + // `t` runs 0..1 while the rain holds, then 1..2 while the light lets go. + // Cubic, so the last rings hang on and then go rather than dimming evenly. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._sync(fade); + this._sparkleFx(dt); + this.ctx.shake.rumble( + settings.starfall.rumble * settings.global.cameraShake * (t <= 1 ? 1 : 0), + dt + ); + } + + onDestroy() { + this.stars.reset(); + this.floor.clearMarks(); + this._live = 0; + this._struck = false; + } + + dispose() { + this.stars.dispose(); + this.floor.dispose(); + this.starMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/blood/BonecageAbility.js b/src/abilities/blood/BonecageAbility.js new file mode 100644 index 0000000..c671c9c --- /dev/null +++ b/src/abilities/blood/BonecageAbility.js @@ -0,0 +1,681 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GrowthField, GrowthLayout, GrowthEmerge, growthParams } from '../../vfx/GrowthField.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { createBoneRibGeometry, createBoneMaterial } from '../../materials/BoneRibMaterial.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, Easing } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hand = new Vector3(); + +/** + * BONE CAGE — the argument that a school is not a palette. + * + * **THE TRICK: it is a completely different material.** Crimson Tide is + * standing two metres away being viscous, wet, red and self-lit. This is the + * same school, and every one of those four properties is inverted on purpose: + * warm subsurface scatter instead of a specular sheen, chalky micro-roughness + * pinned near 0.95 instead of a wet groove, an emissive term that is **capped + * below the bloom threshold** instead of one that blooms, and a fresnel-shaped + * curve applied to the *albedo* instead of a fresnel rim on the silhouette. + * The contrast standing next to the tide is the slot. See + * `materials/BoneRibMaterial.js` for the three terms that carry it — the + * anti-fresnel is the one to take to zero if you want to see what chalk is. + * + * ## What it does + * + * A ring of ribs punches up out of the floor around the circle and lays over + * it. `GrowthField(ZONE)` scales its lean by each record's radial fraction, so + * the ribs at the rim go over hard and the ones near the middle stand nearly + * upright — which makes the field a **dome** rather than a fence, for free, out + * of one slider. + * + * The ribs themselves are straight, and that is a decision rather than an + * oversight; the arithmetic that forced it is written up on + * `createBoneRibGeometry`. The short version: `GrowthField` orients an instance + * by tipping its local +Y toward a lean vector and rolling it about its own + * axis, and no local axis maps to a consistent world bearing under that, so a + * curve baked into the geometry points inward at one bearing on the ring and + * sideways ninety degrees round from it. The first cage had a proper sickle in + * it and slewed off tangentially on two sides. + * + * ## The beats + * + * 1. **reach** — the cast runs out to the circle. One cough of dust, nothing + * standing. + * 2. **raise** — impact. `triggerRadial(..., invert)` fires the ribs from the + * **rim inward** over `raiseTime`, each punching a crater in the flagstone + * it comes through: `onBreach` posts a unitless mark to a + * `GroundField(POCK)`, which is the whole reason that module carries an + * event list. + * 3. **close** — the lean ramps from `ribLeanOpen` to `ribLeanShut` over + * `closeTime`. This is the only animation in the ability and it is a + * *transform*, not geometry: the shape cache would rebuild sixty times a + * second if the arch lived in the mesh. + * 4. **hold**, then **crumble** — the field retracts into the floor and the + * material withers. Bone does not blow out; it goes back under. + * + * ## What a cast captures + * + * One seed and one timestamp per rib, both handed to `GrowthField#plant`, which + * rolls nothing but unitless fractions. Every metre — rib length, rib girth, + * ring radius, crater size, the lean in radians — re-resolves from + * `settings.bonecage` inside `update()`, on a zero-length frame included. Pause + * mid-close and drag `ribLeanShut` and the standing cage opens. + * + * **Four draw calls**: three rib silhouettes and the cracked floor. + */ +export class BonecageAbility extends Ability { + constructor(context) { + super('bonecage', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.boneMaterial = createBoneMaterial(this.ctx.environment); + + /** + * Three silhouettes. Three is the house recommendation and it is right + * here for a reason the doc on `GrowthField` states well: per-instance + * scaling buys proportion variety and only distinct geometry buys *facet* + * variety. A ribcage of one bone scaled forty ways reads as a prop the + * moment the camera moves, and a ribcage is exactly the object where a + * viewer knows the bones should not match. + */ + /** + * The geometry sliders, held in one object and refilled every frame. + * `syncGeometry()` hashes it and only rebuilds when a number has actually + * moved, so this is a live shape control rather than sixty rebuilds a + * second — which is also why the *arch* cannot live in here. + */ + this._shape = { + ribSides: 7, + ribRings: 12, + ribFlatten: 0.44, + ribTwist: 0.18, + ribGroove: 0.3, + ribHead: 1.05, + ribNeck: 0.52, + ribShaft: 0.7, + ribTaper: 0.1, + ribKnuckle: 0.2, + ribWarp: 0.28 + }; + + this.ribs = new GrowthField(this.group, { + geometry: createBoneRibGeometry, + material: this.boneMaterial, + shape: this._shape, + variants: 3, + capacity: 288, + layer: LAYER.WORLD, + renderOrder: 2, + castShadow: true, + receiveShadow: true + }); + + /** Live params, allocated once and refilled every frame — I1. */ + this.ribParams = growthParams(); + this.ribParams.layout = GrowthLayout.ZONE; + this.ribParams.emerge = GrowthEmerge.PUSH; + this.ribParams.centre = new Vector3(); + this.ribParams.origin = new Vector3(); + this.ribParams.direction = new Vector3(0, 0, 1); + this.ribParams.side = new Vector3(1, 0, 0); + + /** + * The floor each rib came through. `POCK` exists for exactly this: an event + * list of *unitless* hits with timestamps, so the craters re-place and + * re-scale themselves when the zone radius slider moves under a standing + * cage. + */ + this.floor = new GroundField(this.group, { + mode: GroundMode.POCK, + marks: 16, + additive: false, + depthTest: true, + layer: LAYER.VFX, + renderOrder: 6, + name: 'BonecageFloor' + }); + this.floorParams = groundFieldParams(); + this.floorParams.centre = new Vector3(); + + /** The middle of the circle, refreshed every frame before the field updates. */ + this._centre = new Vector3(); + /** Seconds since the first rib was called. Drives the raise and the close. */ + this._riseTime = 0; + /** Re-rolled per cast so two cages are not the same skeleton. */ + this._seed = 0; + + /** + * Assigned **once**, at construction — I3. A rib breaking the surface + * cracks the flagstone it came through and throws the pieces. + */ + this.ribs.onBreach = (index, position, radius, height) => this._onBreach(position, radius, height); + } + + createParticles() { + const particles = this.ctx.particles; + + /** + * Floor chips. `lit` and non-additive, because everything about this slot + * is "no glow": these are lumps of the stage's own flagstone catching the + * key light, and an additive chip would be a spark. + */ + this.grit = particles.get('bonecage.grit', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.3; + this.grit.uniforms.uEndSize.value = 0.7; + this.grit.uniforms.uFadeOut.value = 0.65; + + /** Stone and bone dust. Non-additive so it genuinely occludes. */ + this.dust = particles.get('bonecage.dust', { + capacity: 1100, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 2.2; + this.dust.uniforms.uEndSize.value = 2.6; + this.dust.uniforms.uSizeIn.value = 0.16; + this.dust.uniforms.uFadeIn.value = 0.2; + this.dust.uniforms.uFadeOut.value = 0.35; + + this.dustEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.ribs.count; + } + + get impactDuration() { + const c = settings.bonecage; + return Math.max(0.2, (c.raiseTime + c.closeTime + c.holdTime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.2, settings.bonecage.crumbleTime); + } + + /** The live footprint, metres. What the indicator measured out. */ + get radius() { + return Math.max(0.1, settings.bonecage.zoneRadius); + } + + /** + * Bone does not flicker and it does not pulse. The light this cast owns is a + * lamp inside a closed cage, so all it is allowed to do is sit still and get + * a fraction dimmer as the ribs lay over and shut it in. + */ + lightShimmer() { + const c = settings.bonecage; + return 1 - saturate(c.lightSmother) * this._closure(); + } + + /* ------------------------------------------------------------------ */ + /* The clocks — pure functions of live settings */ + /* ------------------------------------------------------------------ */ + + /** The middle of the circle — the far end of the aimed line. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** Where the cast leaves the caster. */ + _handPoint(out) { + const c = settings.bonecage; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** How far through the raise the ring is, 0..1. */ + _raise() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + return saturate(this._riseTime / Math.max(0.05, settings.bonecage.raiseTime)); + } + + /** How far the cage has laid over, 0..1. Starts once the ring is up. */ + _closure() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + const c = settings.bonecage; + return Easing.outCubic(saturate((this._riseTime - c.raiseTime) / Math.max(0.05, c.closeTime))); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this._riseTime = 0; + this._seed = Math.random() * 100; + + this.ribs.clear(); + this.ribs.plant(Math.round(settings.bonecage.ribCount), 0); + this.floor.clearMarks(); + this.floor.setVisible(false); + + this.boneMaterial.userData.setWither(0); + this._sync(0); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * A rib breaking the surface. + * + * The crater is posted to the ground field as `(x, z)` **fractions of the + * zone radius** and a timestamp — nothing with a unit crosses this boundary, + * which is what lets the whole set of craters re-place themselves when the + * radius slider moves on a cage that is already standing. + * + * @param {THREE.Vector3} position world, where it came through + * @param {number} radius the rib's own base radius, metres + * @param {number} height its full height, metres + */ + _onBreach(position, radius, height) { + const c = settings.bonecage; + const g = settings.global; + const R = this.radius; + + this.floor.mark( + (position.x - this._centre.x) / R, + (position.z - this._centre.z) / R, + this.age, + saturate(0.4 + radius / Math.max(0.05, c.ribGirth) * 0.6) + ); + + const chips = Math.round(c.breachGrit * g.particleCount); + if (chips > 0) { + _emit.position = _pos.copy(position).setY(0.05); + _emit.radius = radius * 1.6; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grit.emit(chips, _emit); + } + + // A rib two metres long moves more floor than a stub does. Scaled by the + // instance's own height, which is itself resolved from settings, so this + // number is never written down. + const puff = Math.round(c.breachDust * g.particleCount * saturate(height / Math.max(0.1, c.ribLength))); + if (puff > 0) { + _emit.position = _pos.copy(position).setY(0.08); + _emit.radius = radius * 2.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.6; + _emit.spread = 0.9; + _emit.size = 0.55; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.time = frame.uTime.value; + this.dust.emit(puff, _emit); + } + } + + /** + * Push the live settings into the rib field, the cracked floor, the bone + * material and both particle systems. + * + * @param {number} rot 0..1 — how far through the crumble the cage is + */ + _sync(rot) { + const c = settings.bonecage; + const g = settings.global; + const R = this.radius; + const close = this._closure(); + + this._centrePoint(this._centre); + + /* --- the geometry sliders, rebuilt only when a number actually moves --- */ + const shape = this._shape; + shape.ribSides = Math.max(4, Math.round(c.ribSides)); + shape.ribRings = Math.max(5, Math.round(c.ribRings)); + shape.ribFlatten = c.ribFlatten; + shape.ribTwist = c.ribTwist; + shape.ribGroove = c.ribGroove; + shape.ribHead = c.ribHead; + shape.ribNeck = c.ribNeck; + shape.ribShaft = c.ribShaft; + shape.ribTaper = c.ribTaper; + shape.ribKnuckle = c.ribKnuckle; + shape.ribWarp = c.ribWarp; + this.ribs.syncGeometry(shape); + + /* --- the ring --------------------------------------------------------- */ + const p = this.ribParams; + p.centre.copy(this._centre); + p.origin.copy(this.origin); + p.direction.copy(this.direction); + p.side.copy(this.side); + p.radius = R * c.ribRing; + p.innerRadius = R * c.ribInner; + p.radialCurve = c.ribRadialCurve; + p.radialJitter = c.ribRadialJitter; + p.angleJitter = c.ribAngleJitter; + + p.heightNear = c.ribLengthInner; + p.height = c.ribLength; + p.heightCurve = c.ribLengthCurve; + p.heightJitter = c.ribLengthJitter; + p.minHeight = 0.05; + + p.radiusNear = c.ribGirthInner; + p.radius2 = c.ribGirth; + p.radiusCurve = c.ribGirthCurve; + p.radiusJitter = c.ribGirthJitter; + p.minRadius = 0.02; + + // Negative, and that is the whole cage: `lean` tips an instance's +Y toward + // the outward radial, so a negative angle lays it **inward** over the zone. + // `leanForward` is zero because a cage has no downrange — the fence that + // reading gives you was the first version and it looked like a palisade. + p.lean = -(c.ribLeanOpen + (c.ribLeanShut - c.ribLeanOpen) * close); + p.leanJitter = c.ribLeanJitter; + // High on purpose: the lean is already scaled by `record.radial` inside the + // module, and this compounds it, so the middle of the field stands up + // almost straight while the rim goes right over. That difference is what + // makes it a dome instead of a funnel. + p.leanRamp = c.ribLeanRamp; + p.leanForward = 0; + p.leanOutward = 1; + p.twist = c.ribRoll; + p.tilt = c.ribTilt; + + p.riseTime = c.ribRiseTime; + p.riseOvershoot = c.ribOvershoot; + p.settle = c.ribSettle; + p.springRate = c.ribSpring; + p.emergeSink = c.ribSink; + p.birthScale = c.ribBirthScale; + p.birthFade = c.ribBirthFade; + p.breachAt = c.ribBreachAt; + p.sinkDepth = c.ribSinkDepth; + p.randomness = g.randomness; + + this.ribs.update(this.age, p, rot); + + /* --- the material ----------------------------------------------------- */ + this.boneMaterial.userData.sync(); + this.boneMaterial.userData.setWither(rot * c.witherDepth); + this.boneMaterial.opacity = c.boneOpacity * g.opacity * (1 - Easing.inQuad(rot) * c.crumbleFade); + + /* --- the cracked floor ------------------------------------------------ */ + const f = this.floorParams; + f.centre.copy(this._centre); + f.yaw = 0; + f.height = c.floorHeight; + f.radius = R * c.floorRadius; + f.grow = 1; + f.recede = 0; + f.fade = 1 - Easing.inQuad(rot); + f.seed = this._seed; + + f.edge = c.floorEdge; + f.ragged = c.floorRagged; + f.raggedScale = c.floorRaggedScale; + f.warp = c.floorWarp; + f.relief = c.floorRelief; + f.normalStep = c.floorNormalStep; + f.ambient = c.floorAmbient; + f.specular = c.floorSpecular; + f.gloss = c.floorGloss; + f.parallax = c.floorParallax; + f.cell = c.floorCell; + f.lift = c.floorLift; + f.depth = c.floorDepth; + f.detail = c.floorDetail; + f.sharp = c.floorSharp; + f.speed = c.floorSpeed; + f.markLife = c.floorMarkLife; + f.markRadius = c.floorMarkRadius; + // Zero, and it is a picker with a black default rather than a missing + // uniform: POCK's glow channel is cooling ember, and nothing here is hot. + f.emissive = c.floorEmissive; + f.opacity = c.floorOpacity; + f.depthFade = c.floorDepthFade; + f.colorBase = c.colorFloorBase; + f.colorEdge = c.colorFloorEdge; + f.colorGlow = c.colorFloorGlow; + f.colorDeep = c.colorFloorDeep; + f.noiseStrength = g.noiseStrength; + f.noiseFrequency = g.noiseFrequency; + f.noiseSpeed = g.noiseSpeed; + f.opacityScale = g.opacity; + + this.floor.setVisible(this.floor.markCount > 0 && f.fade > 0.004); + this.floor.update(f); + + /* --- the two particle systems ----------------------------------------- */ + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /** The dry rattle at the caster's hand as the cage is called. */ + _muzzleFx() { + const c = settings.bonecage; + const g = settings.global; + + this._handPoint(_hand); + + this.ctx.bursts.spawn(BurstMode.EARTH, _hand, { + radius: c.muzzleSize * 0.3, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.45, + intensity: c.muzzleIntensity, + opacity: 0.4, + fresnel: 1.1, + displace: 0.35, + squash: 0.8, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _emit.position = _hand; + _emit.radius = 0.2; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.5).setY(0.4).normalize(); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.6; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.4; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(Math.round(12 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.3 * g.explosionIntensity; + } + + /** Dust drifting off a standing cage. */ + _cageFx(dt, scale) { + const c = settings.bonecage; + const g = settings.global; + const count = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (count <= 0) return; + + const bearing = Math.random() * TAU; + const r = this.radius * c.ribRing * Math.sqrt(Math.random()); + _pos.set( + this._centre.x + Math.cos(bearing) * r, + c.dustBirthHeight, + this._centre.z + Math.sin(bearing) * r + ); + + _emit.position = _pos; + _emit.radius = this.radius * 0.12; + _emit.direction = _dir.set(Math.cos(bearing) * 0.25, 1, Math.sin(bearing) * 0.25).normalize(); + _emit.speed = c.dustSpeed * 0.6; + _emit.speedVariance = 0.5; + _emit.spread = 0.6; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.6; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.2; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.bonecage; + + this._sync(0); + this.position.y = c.lightHeight * 0.5; + this._cageFx(dt, 0.25); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.bonecage; + const g = settings.global; + + this._riseTime = 0; + this._centrePoint(this._centre); + + // A low pressure shell as the ring comes up. AIR rather than EARTH: this is + // the air being shoved out from under a closing lid, and a dust ball here + // hides the one moment the ribs are most legible. + _pos.copy(this._centre).setY(c.ribLength * 0.2); + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.burstSize * 0.3, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.8, + intensity: c.burstIntensity, + opacity: 0.28, + fresnel: 1.4, + displace: 0.4, + squash: 0.35, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 15 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.bonecage; + + if (t <= 1) this._riseTime += dt; + + // The ring fires from the boundary **inward**. Outward is the obvious + // choice and it is wrong for this shape: the ribs that have furthest to + // travel are the ones at the rim, so starting them first is what lets the + // whole cage arrive at the top at the same moment. + this.ribs.triggerRadial(this.age, this._raise(), c.riseStagger, true, true); + + const rot = t <= 1 ? 0 : saturate(t - 1); + this._sync(rot); + + // The light sits inside the cage and is smothered by it. It never leaves + // the floor: a lamp at rib height would light the ribs from inside and + // make them look like lampshades, which is the one thing bone must not do. + this._centrePoint(this.position); + this.position.y = c.lightHeight; + + this._cageFx(dt, (1 - rot) * (t <= 1 ? 1 : 0.4)); + } + + onDestroy() { + this._riseTime = 0; + this.ribs.clear(); + this.floor.clearMarks(); + this.floor.setVisible(false); + this.boneMaterial.userData.setWither(0); + } + + dispose() { + this.ribs.dispose(); + this.boneMaterial.dispose(); + this.floor.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/blood/CrimsonTideAbility.js b/src/abilities/blood/CrimsonTideAbility.js new file mode 100644 index 0000000..51e538c --- /dev/null +++ b/src/abilities/blood/CrimsonTideAbility.js @@ -0,0 +1,889 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { LiquidSurface, LiquidMode, liquidParams } from '../../vfx/LiquidSurface.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { Swarm, Silhouette, LeadPath, swarmParams } from '../../vfx/Swarm.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** + * How many points along the lip one frame's spray is split between. + * + * A breaking wave sheds along the *whole* lip. Firing a frame's droplets from a + * single `across` reads as a hose pointed sideways, which is the same mistake + * the bolt's sparks made before they were batched. + */ +const SPRAY_BATCHES = 5; + +/** Hard ceiling on the droplet flock. The `dropCount` slider clamps here. */ +const MAX_DROPLETS = 256; + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope (I3). Filled and consumed inside one call. */ +/* ---------------------------------------------------------------- */ + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _lip = new Vector3(); +const _anchor = new Vector3(); +const _centre = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/** The three params blocks the shared modules read. Refilled every frame. */ +const _liquid = liquidParams(); +const _wet = groundFieldParams(); +const _drops = swarmParams(); +_wet.centre = _centre; + +/** + * CRIMSON TIDE — a real wave, thrown down the aimed line. + * + * Three beats: **surge**, **break**, **drain**. A heightfield wave races out + * from the caster at `speed`, curls, throws its lip forward past the end of the + * line, collapses, and soaks into the floor leaving wet flagstone that dries + * from the edges in. + * + * ## The trick — the sheet is re-cut every frame, so the crest is never sliding + * + * The obvious way to build this is a fixed plane the length of the cast with a + * `waveFront` uniform swept from 0 to 1 across it. It was the first version and + * it is wrong in a way that is hard to unsee: the water in front of the crest + * is *already there*, so the wave reads as a bulge travelling through a canal + * rather than as a mass of liquid arriving. A surge has nothing in front of it. + * + * So the sheet is cut to exactly the run the surge has covered. Every frame the + * plane's downrange extent is rebuilt as + * + * ``` + * tail = -sheetTail (metres behind the caster) + * head = u · length + sheetLead (metres past the crest) + * ``` + * + * and `waveFront` is then *solved* so the crest lands on the travelled distance + * in world space whatever those two numbers are. `sheetLead` is the only water + * ahead of the lip — a metre or two of it, so the curl has something to break + * into — and dragging it while the wave is in the air moves the leading edge + * without moving the crest. That is the whole design: the crest is a fixed + * world point that the sheet is stretched around, not a parameter sliding along + * a plane. + * + * ## What each module is doing + * + * - **`LiquidSurface(WAVE)`** — the wave. The curl is Gerstner pushed hard: + * horizontal throw proportional to *height*, so the top of the crest outruns + * its foot and the sheet genuinely folds over itself. Where it has folded the + * shader thins alpha and adds a backlight, which is the "front face thin + * enough to be translucent" the brief asks for — `translucency` is the slider + * that owns it and it does more for this cast than any colour picker. + * - **`Swarm(DROPLET)`** — the coherent sheet of spray the lip drags with it. + * Its lead is pinned to `lipPosition()`, and because cohesion in that module + * is a *lag* rather than a history buffer, the flock strings out behind the + * lip exactly as thrown water does. The droplets that *fall* are particles; + * these are the ones still travelling with the wave. + * - **`GroundField(WET)`** — the puddle left where the tide stood. Alpha + * blended, so soaked stone comes out genuinely darker than dry stone rather + * than brighter, and `recede` eats it back from the outside in. + * + * The first version tried to make that last field cover the whole run by + * setting its radius to half the cast length. `GroundField` measures its front + * radially, so on a 22 m line that is a 22 m-wide disc of wet floor and the + * cast read as a flood, not as a wave that had passed. The **strip** is carried + * by `FOAM` decals dropped under the crest as it travels — those are allowed to + * capture a radius, being transient — and the field is the standing puddle at + * the break. + * + * ## Low emission, on purpose + * + * `emissive` is 0 and `glow` sits below 1. Nothing here is a light source. The + * wave reads by silhouette and by specular off a curling face, which is why + * `specular`, `shininess` and `translucency` are the three sliders that matter + * and why the dynamic light is dim and deep red: it exists to give the wet + * stone and the folded lip something to catch, not to make the cast glow. + * + * ## What a cast captures + * + * One number — `_seed`, so two tides do not break identically — and timestamps. + * Not a metre, not a radian, not a second. The sheet, the crest, the puddle and + * the flock are all re-resolved from `settings.crimsontide` inside the update + * loop, on a zero-length frame included. Pause with **P** mid-surge and drag + * `crestFace`: the wave in front of you steepens. + */ +export class CrimsonTideAbility extends Ability { + constructor(context) { + super('crimsontide', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* + * 96 segments a side. Below about 48 the Gerstner cusps facet visibly and + * the curl turns into a chamfer; above 160 you are paying vertex cost for + * detail the fragment normal already carries for nothing. + */ + this.surface = new LiquidSurface({ + segments: 96, + mode: LiquidMode.WAVE, + depthWrite: true, // a heightfield is a solid; its crest must hide its own back + doubleSide: true, // required the moment `crestCurl` folds the sheet over + renderOrder: 8, + name: 'CrimsonTide:sheet' + }); + this.group.add(this.surface.object3D); + + /* + * The puddle. Non-additive is the entire point of this mode: soaked stone + * is *darker* than dry stone and shinier, and an additive mark can only + * ever be a lit stone. + */ + this.wet = new GroundField(this.group, { + mode: GroundMode.WET, + additive: false, + depthTest: true, + renderOrder: 5, + name: 'CrimsonTide:wet' + }); + + /* + * The spray flock. Non-additive and `lit` for the same reason: a blood + * droplet is a dark wet body, and additive droplets over a dark floor read + * as embers. + */ + this.droplets = new Swarm(this.group, { + capacity: MAX_DROPLETS, + silhouette: Silhouette.DROPLET, + additive: false, + renderOrder: 12 + }); + + /** Re-rolled per cast so no two tides break the same way. */ + this._seed = 0; + /** Metres of front travel already paid out in floor marks. */ + this._slickDistance = 0; + /** Agents drawn last frame — the HUD readout. */ + this._live = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Spray: the droplets that leave the lip and fall. Soft rounds rather than + // streaks — a blood droplet is a bead, not a spark, and stretching it along + // its velocity turns the whole plume into rain. + this.spray = particles.get('crimsontide.spray', { + capacity: 2400, + shape: ParticleShape.SOFT, + additive: false, + lit: true, + softFade: 0.3 + }); + this.spray.uniforms.uDrag.value = 0.6; + this.spray.uniforms.uEndSize.value = 0.55; + this.spray.uniforms.uSizeIn.value = 0.03; + this.spray.uniforms.uFadeIn.value = 0.04; + this.spray.uniforms.uFadeOut.value = 0.5; + + // The fine red haze standing over the wave. Non-additive so it genuinely + // occludes the sheet behind it — additive mist over dark blood is a pink fog. + this.mist = particles.get('crimsontide.mist', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.mist.uniforms.uDrag.value = 1.9; + this.mist.uniforms.uEndSize.value = 2.6; + this.mist.uniforms.uSizeIn.value = 0.14; + this.mist.uniforms.uFadeIn.value = 0.2; + this.mist.uniforms.uFadeOut.value = 0.35; + + // Clots: the heavy gobbets that roll off the back of the wave and land. + this.clots = particles.get('crimsontide.clots', { + capacity: 900, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.clots.uniforms.uDrag.value = 0.2; + this.clots.uniforms.uEndSize.value = 0.7; + this.clots.uniforms.uFadeOut.value = 0.65; + + this.sprayEmitter = new RateEmitter(); + this.mistEmitter = new RateEmitter(); + this.clotEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + /** The crest curls over and collapses. */ + get impactDuration() { + return Math.max(0.05, settings.crimsontide.breakTime * settings.global.lifetime); + } + + /** Then it soaks away. */ + get fadeDuration() { + return Math.max(0.05, settings.crimsontide.drainTime); + } + + /** + * Blood does not gutter and it does not glint. A slow swell on the light, at + * roughly the rate the biggest wave component runs at, so the wet floor + * breathes instead of sitting flat. + */ + lightShimmer() { + return 0.88 + 0.12 * Math.sin(this.age * 2.6); + } + + /* ------------------------------------------------------------------ */ + /* The beats — pure functions of the phase clock and live settings */ + /* ------------------------------------------------------------------ */ + + /** 0..1 how far the crest has run down the line. */ + _surge() { + return this.phase === AbilityPhase.TRAVEL ? this.u : 1; + } + + /** + * 0..1 through the break, and 0..1 through the drain. + * + * `onFade` is handed `t` running 0..1 while the crest breaks and 1..2 while + * the sheet drains; both are recovered from the phase clock here rather than + * stored, so a mid-cast change to `breakTime` re-times the beat it is in. + */ + _breakAmount() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.IMPACT) return saturate(this.impactTime / this.impactDuration); + return 1; + } + + _drainAmount() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / this.fadeDuration); + } + + /** + * The crest's height in metres, right now. + * + * Three regimes, and the middle one is the ability. On the way out the crest + * grows over the first `crestRise` of the run — a wave that starts at full + * height has no surge in it. At the break it swells to `crestPeak` and then + * falls away as it dumps. Through the drain it is a low, dying swell. + */ + _crestHeightNow() { + const c = settings.crimsontide; + const brk = this._breakAmount(); + const drain = this._drainAmount(); + if (drain > 0) return c.crestHeight * 0.28 * (1 - Easing.outQuad(drain)); + if (brk > 0) { + // Fast rise, slow dump: the lip is highest just as it pitches forward. + const bump = Math.sin(Math.PI * Math.pow(brk, 0.8)); + return c.crestHeight * lerp(1, c.crestPeak, bump) * (1 - Easing.inQuad(brk) * 0.72); + } + const rise = saturate(this._surge() / Math.max(0.02, c.crestRise)); + return c.crestHeight * Easing.outCubic(rise); + } + + /** Metres of forward throw per metre of height — the overhang. */ + _crestCurlNow() { + const c = settings.crimsontide; + const brk = this._breakAmount(); + return c.crestCurl * lerp(1, c.crestCurlPeak, Easing.outCubic(brk)); + } + + /** 0..1 how ragged the lip is — it tears itself apart as it breaks. */ + _crestBreakNow() { + const c = settings.crimsontide; + const brk = this._breakAmount(); + return Math.min(1, c.crestBreak * lerp(1, c.crestBreakPeak, Easing.outCubic(brk))); + } + + /** Metres down the line the crest stands, including its throw past the end. */ + _crestDistance() { + const c = settings.crimsontide; + return this._surge() * this.length + c.crestOvershoot * Easing.outCubic(this._breakAmount()); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sprayEmitter.reset(); + this.mistEmitter.reset(); + this.clotEmitter.reset(); + this._slickDistance = 0; + + this.surface.clearRipples(); + + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + this.droplets.roll(this._seed); + + this._sync(1); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve the sheet, the puddle and the flock from live settings and push + * them into their modules. + * + * @param {number} fade 1 while the tide stands, ramping to 0 as it drains + */ + _sync(fade) { + const c = settings.crimsontide; + const g = settings.global; + + /* ---------------- the sheet, re-cut around the crest ---------------- */ + // Everything here is metres, measured this frame. `tail` is negative: the + // sheet starts behind the caster so the surge does not appear to be poured + // out of a hole in the floor at his feet. + const crest = this._crestDistance(); + const tail = -c.sheetTail; + const head = crest + c.sheetLead; + const span = Math.max(0.2, head - tail); + + _anchor + .copy(this.origin) + .addScaledVector(this.direction, (head + tail) * 0.5) + .setY(c.sheetHeight); + this.surface.setPlacement(_anchor, this.direction, _up); + + _liquid.sizeX = span; + _liquid.sizeZ = Math.max(0.2, c.sheetWidth); + // Solved, not swept: whatever the sheet's extent, the crest lands on the + // distance the surge has actually covered. + _liquid.waveFront = saturate((crest - tail) / span); + _liquid.fill = c.sheetFill * lerp(1, 0.04, Easing.inQuad(this._drainAmount())); + _liquid.round = c.sheetRound; + _liquid.edgeSoft = c.sheetEdge; + _liquid.edgeNoise = c.sheetRagged; + _liquid.edgeScale = c.sheetRaggedScale; + _liquid.seed = this._seed; + _liquid.opacity = c.sheetOpacity * fade * g.opacity; + _liquid.contactFade = c.contactFade; + + _liquid.waveAmpA = c.swellAmpA; + _liquid.waveAmpB = c.swellAmpB; + _liquid.waveAmpC = c.swellAmpC; + _liquid.waveAmpD = c.swellAmpD; + _liquid.waveLengthA = c.swellLengthA; + _liquid.waveLengthB = c.swellLengthB; + _liquid.waveLengthC = c.swellLengthC; + _liquid.waveLengthD = c.swellLengthD; + _liquid.waveSpeedA = c.swellSpeedA; + _liquid.waveSpeedB = c.swellSpeedB; + _liquid.waveSpeedC = c.swellSpeedC; + _liquid.waveSpeedD = c.swellSpeedD; + _liquid.waveAngleA = c.swellAngleA; + _liquid.waveAngleB = c.swellAngleB; + _liquid.waveAngleC = c.swellAngleC; + _liquid.waveAngleD = c.swellAngleD; + _liquid.steepness = c.steepness; + + _liquid.chop = c.chop * g.noiseStrength; + _liquid.chopScale = c.chopScale * g.noiseFrequency; + _liquid.chopSpeed = c.chopSpeed * g.noiseSpeed; + _liquid.detail = c.detail * g.noiseStrength; + _liquid.detailScale = c.detailScale * g.noiseFrequency; + _liquid.detailSpeed = c.detailSpeed * g.noiseSpeed; + + _liquid.rippleAmp = c.rippleAmp; + _liquid.rippleSpeed = c.rippleSpeed; + _liquid.rippleLength = c.rippleLength; + _liquid.rippleWidth = c.rippleWidth; + _liquid.rippleDecay = c.rippleDecay; + _liquid.rippleSpread = c.rippleSpread; + + _liquid.flowAngle = c.flowAngle; + _liquid.flowSpeed = c.flowSpeed; + _liquid.flowRadial = c.flowRadial; + _liquid.flowRadialFall = c.flowRadialFall; + _liquid.flowEddy = c.flowEddy; + _liquid.flowEddyScale = c.flowEddyScale; + _liquid.flowEddySpeed = c.flowEddySpeed; + _liquid.flowGravity = c.flowGravity; + + // The skin only starts to matter once the sheet slows down, so it is faded + // in with the break rather than paid for during the surge — the crust block + // is a flow map plus two advected fbm phases, and it is the most expensive + // thing in this fragment shader. + _liquid.crust = c.crust * Easing.outQuad(this._breakAmount()); + _liquid.crustForm = c.crustForm; + _liquid.crustBreak = c.crustBreak; + _liquid.crustFormTime = c.crustFormTime; + _liquid.crackScale = c.crackScale * g.noiseFrequency; + _liquid.crackStretch = c.crackStretch; + _liquid.crackWidth = c.crackWidth; + _liquid.crustAdvect = c.crustAdvect; + _liquid.crustPeriod = c.crustPeriod; + _liquid.crustBump = c.crustBump; + _liquid.seamGlow = c.seamGlow; + _liquid.meltGlow = c.meltGlow; + + _liquid.foam = c.foam; + _liquid.foamScale = c.foamScale * g.noiseFrequency; + _liquid.foamSharp = c.foamSharp; + _liquid.foamCrest = c.foamCrest; + _liquid.foamSpeed = c.foamSpeed; + + _liquid.crestHeight = this._crestHeightNow(); + _liquid.crestBack = c.crestBack; + _liquid.crestFace = c.crestFace; + _liquid.crestCurl = this._crestCurlNow(); + _liquid.crestWidth = c.crestWidth; + _liquid.crestFeather = c.crestFeather; + _liquid.crestBreak = this._crestBreakNow(); + _liquid.crestBreakScale = c.crestBreakScale * g.noiseFrequency; + + _liquid.poolDepth = c.poolDepth; + _liquid.depthTint = c.depthTint; + _liquid.translucency = c.translucency; + _liquid.ambient = c.ambient; + _liquid.specular = c.specular; + _liquid.shininess = c.shininess; + _liquid.fresnel = c.fresnel * g.fresnel; + _liquid.envIntensity = c.envIntensity; + _liquid.skyIntensity = c.skyIntensity; + _liquid.emissive = c.emissive; + _liquid.glow = c.glow * g.glow; + _liquid.normalEps = c.normalEps; + + _liquid.colorDeep = c.colorDeep; + _liquid.colorShallow = c.colorShallow; + _liquid.colorCrust = c.colorCrust; + _liquid.colorSeam = c.colorSeam; + _liquid.colorHot = c.colorHot; + _liquid.colorFoam = c.colorFoam; + _liquid.colorSpec = c.colorSpec; + _liquid.colorSky = c.colorSky; + + this.surface.visible = _liquid.opacity > 0.002; + this.surface.update(this.age, _liquid); + + /* ---------------- the puddle ---------------- */ + _centre.copy(this.origin).addScaledVector(this.direction, c.wetAlong * this.length); + _wet.yaw = Math.atan2(this.direction.x, this.direction.z); + _wet.height = c.wetHeight; + _wet.radius = c.wetRadius; + // The tide mark fills as the surge reaches the middle of the puddle, then + // holds; drying eats it back from the outside once the sheet is draining. + _wet.grow = saturate(this._surge() / Math.max(0.05, c.wetAlong)); + const dry = saturate((this._drainAmount() - c.wetDryDelay) / Math.max(0.02, c.wetDryTime)); + _wet.recede = Easing.inQuad(dry); + _wet.fade = 1; + _wet.seed = this._seed; + _wet.edge = c.wetEdge; + _wet.ragged = c.wetRagged; + _wet.raggedScale = c.wetRaggedScale; + _wet.warp = c.wetWarp; + _wet.relief = c.wetRelief; + _wet.normalStep = c.wetNormalStep; + _wet.ambient = c.wetAmbient; + _wet.wrap = c.wetWrap; + _wet.specular = c.wetSpecular; + _wet.gloss = c.wetGloss; + _wet.parallax = c.wetParallax; + _wet.cell = c.wetCell; + _wet.lift = c.wetLift; + _wet.depth = c.wetDepth; + _wet.detail = c.wetDetail; + _wet.speed = c.wetSpeed; + _wet.flow = c.wetFlow; + _wet.windAngle = c.wetWindAngle; + _wet.additive = false; + _wet.emissive = c.wetEmissive; + _wet.opacity = c.wetOpacity; + _wet.depthFade = c.wetDepthFade; + _wet.colorBase = c.colorWetBase; + _wet.colorEdge = c.colorWetEdge; + _wet.colorGlow = c.colorWetGlow; + _wet.colorDeep = c.colorWetDeep; + _wet.noiseStrength = g.noiseStrength; + _wet.noiseFrequency = g.noiseFrequency; + _wet.noiseSpeed = g.noiseSpeed; + _wet.opacityScale = g.opacity; + this.wet.setVisible(_wet.grow > 0.002 && _wet.recede < 0.999); + this.wet.update(_wet); + + /* ---------------- the droplet flock ---------------- */ + // The lip in world space, this frame. `lipPosition` deliberately ignores + // the chop and the ripples: droplets want to leave a clean moving line, and + // sampling the full field jitters every emitter by the finest octave in it, + // which reads as a fault in the emitter rather than as detail in the wave. + this.surface.lipPosition(_liquid, _lip, 0); + + this.droplets.setBasis(this.origin, this.direction, this.side, Math.max(0.2, this.length)); + _drops.count = Math.min(MAX_DROPLETS, Math.round(c.dropCount * g.particleCount)); + _drops.leadMode = LeadPath.LINE; + // The lead is the lip: `leadS` is where along the cast line it stands, and + // the two heights are the lip's own height, so the flock's home is the + // breaking edge and not a line drawn through the air near it. + _drops.leadS = saturate(this._crestDistance() / Math.max(0.2, this.length)); + _drops.leadRate = 0; + _drops.leadRise = c.dropRise; + _drops.handForward = 0; + _drops.handSide = 0; + _drops.handHeight = _lip.y + c.dropLift; + _drops.endHeight = _lip.y + c.dropLift; + _drops.latticeX = c.dropLatticeX; + _drops.latticeY = c.dropLatticeY; + _drops.latticeZ = c.dropLatticeZ; + _drops.spacingSide = c.dropSpacingSide; + _drops.spacingUp = c.dropSpacingUp; + _drops.lag = c.dropLag; + _drops.jitter = c.dropJitter * g.randomness; + _drops.churn = c.dropChurn; + _drops.breathe = c.dropBreathe; + _drops.breatheRate = c.dropBreatheRate; + _drops.wander = c.dropWander * g.noiseStrength; + _drops.wanderScale = c.dropWanderScale * g.noiseFrequency; + _drops.wanderSpeed = c.dropWanderSpeed * g.noiseSpeed; + _drops.gather = c.dropGather; + _drops.size = c.dropSize * g.particleSize; + _drops.aspect = c.dropAspect; + _drops.sizeJitter = c.dropSizeJitter * g.randomness; + _drops.billboard = c.dropBillboard; + _drops.bank = c.dropBank; + _drops.bankMax = c.dropBankMax; + _drops.dihedral = 0; + _drops.flapRate = 0; + _drops.curl = c.dropCurl; + _drops.edgeStretch = c.dropEdgeStretch; + _drops.edgeGain = c.dropEdgeGain; + // Droplets exist while the wave does. The reveal wave takes them out from + // the back of the flock forward as the sheet drains, which reads as spray + // falling out of the air rather than as the whole plume dimming at once. + _drops.reveal = fade; + _drops.revealSpread = c.dropRevealSpread; + _drops.silhouette = Silhouette.DROPLET; + _drops.lit = c.dropLit; + _drops.tint = c.dropTint; + _drops.tintJitter = c.dropTintJitter; + _drops.tintAlong = c.dropTintAlong; + _drops.opacity = c.dropOpacity * g.opacity; + _drops.glow = c.dropGlow * g.glow; + _drops.softFade = c.dropSoftFade; + this.droplets.setColors(c.colorDropA, c.colorDropB, c.colorDropC, c.colorDropD); + this.droplets.update(this.age, _drops); + this._live = this.droplets.count; + + /* ---------------- the particle systems ---------------- */ + this.spray.setGradient( + getColor(c.colorSprayA), + getColor(c.colorSprayB), + getColor(c.colorSprayC), + getColor(c.colorSprayD) + ); + this.spray.uniforms.uGravity.value.set(0, c.sprayGravity, 0); + this.spray.uniforms.uSizeScale.value = c.spraySize * g.particleSize * 7; + this.spray.uniforms.uLifeScale.value = c.sprayLifetime * 0.5 * g.particleLifetime; + this.spray.uniforms.uSpeedScale.value = g.particleSpeed; + this.spray.uniforms.uOpacity.value = g.opacity; + this.spray.uniforms.uGlow.value = 0.5 * g.glow; + this.spray.uniforms.uTurbulence.value = 0.2 * g.turbulence; + + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = c.mistTurbulence * g.turbulence; + + this.clots.setGradient( + getColor(c.colorClotA), + getColor(c.colorClotB), + getColor(c.colorClotC), + getColor(c.colorClotD) + ); + this.clots.uniforms.uGravity.value.set(0, c.clotGravity, 0); + this.clots.uniforms.uSizeScale.value = c.clotSize * g.particleSize * 7; + this.clots.uniforms.uLifeScale.value = c.clotLifetime * 0.5 * g.particleLifetime; + this.clots.uniforms.uSpeedScale.value = g.particleSpeed; + this.clots.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Spray, mist and clots shed off the breaking lip. + * + * @param {number} dt seconds + * @param {number} scale 0..1 — thinned out once the wave is only draining + */ + _tideFx(dt, scale) { + const c = settings.crimsontide; + const g = settings.global; + const time = frame.uTime.value; + + let sprayCount = Math.round(this.sprayEmitter.tick(dt, c.sprayRate * scale) * g.particleCount); + if (sprayCount > 0) { + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.7).setY(0.7).normalize(); + _emit.speed = c.spraySpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.75; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + + // Split across the lip, never all from one point — see SPRAY_BATCHES. + const batches = Math.min(sprayCount, SPRAY_BATCHES); + const per = Math.ceil(sprayCount / batches); + while (sprayCount > 0) { + this.surface.lipPosition(_liquid, _pos, randRange(-1, 1)); + _emit.position = _pos; + _emit.radius = c.spraySize * 2.5 + 0.08; + this.spray.emit(Math.min(per, sprayCount), _emit); + sprayCount -= per; + } + } + + const mistCount = Math.round(this.mistEmitter.tick(dt, c.mistRate * scale) * g.particleCount); + if (mistCount > 0) { + this.surface.lipPosition(_liquid, _pos, randRange(-0.8, 0.8)); + _emit.position = _pos; + _emit.radius = c.sheetWidth * 0.22; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.time = time; + this.mist.emit(mistCount, _emit); + } + + const clotCount = Math.round(this.clotEmitter.tick(dt, c.clotRate * scale) * g.particleCount); + if (clotCount > 0) { + // Clots come off the *back* of the crest, where the sheet is slowest and + // the skin is already forming. Off the lip they read as more spray. + this.pointAt(saturate(this._surge() - 0.12), _pos).setY(c.sheetHeight + 0.1); + _emit.position = _pos; + _emit.radius = c.sheetWidth * 0.3; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.4).setY(1).normalize(); + _emit.speed = c.clotSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.clotLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 6; + _emit.time = time; + this.clots.emit(clotCount, _emit); + } + } + + /** + * The wet strip under the crest. + * + * Laid per *metre of front travel* rather than per second, so the trail has + * the same spacing whatever `speed` is set to — a mark rate in Hz gives a + * dotted line at 40 m/s and a solid smear at 4. + */ + _slickFx() { + const c = settings.crimsontide; + const step = 1 / Math.max(0.05, c.slickRate); + + while (this.front - this._slickDistance >= step) { + this._slickDistance += step; + const s = saturate(this._slickDistance / this.length); + this.pointAt(s, _pos); + // Jittered across the line so the marks do not read as a row of coins. + const wander = c.sheetWidth * 0.22; + _pos.x += this.side.x * randRange(-wander, wander); + _pos.z += this.side.z * randRange(-wander, wander); + + this.ctx.decals.spawn(DecalType.FOAM, _pos, { + radius: c.slickRadius * randRange(0.75, 1.25), + life: c.slickLife, + intensity: c.slickIntensity, + colorA: getColor(c.colorSlickA), + colorB: getColor(c.colorSlickB), + height: 0.014 + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + + // The light rides the lip, not the floor under it. + this.surface.lipPosition(_liquid, this.position, 0); + + this._tideFx(dt, 1); + this._slickFx(); + + this.ctx.shake.rumble(settings.crimsontide.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.crimsontide; + const g = settings.global; + const time = frame.uTime.value; + + this.surface.lipPosition(_liquid, _lip, 0); + this.pointAt(1, _pos); + + /* + * The sheet of blood thrown up where it dumps. `WATER` rather than `FIRE`: + * that mode is a fresnel-heavy splash dome with almost no body, which is + * the only one of the six that can be recoloured to blood without reading + * as a fireball someone painted red. + */ + this.ctx.bursts.spawn(BurstMode.WATER, _lip, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.4, + displace: 0.7, + squash: 0.55, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring that runs out across the floor */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.55, + width: 0.07, + intensity: 0.8, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* a wide slick where the tide dumped */ + this.ctx.decals.spawn(DecalType.FOAM, _pos, { + radius: c.slickRadius * 2.1, + life: c.slickLife * 1.5, + intensity: c.slickIntensity * 1.2, + colorA: getColor(c.colorSlickA), + colorB: getColor(c.colorSlickB), + height: 0.015 + }); + + /* + * Ring the sheet. The packets are stored as *fractions* of the half-extents + * plus a timestamp — never a metre — so a paused wave re-rings itself when + * `sheetWidth` moves, which is the observable proof I1 is being kept here. + */ + const packets = Math.max(0, Math.round(c.burstRipples)); + const frontU = _liquid.waveFront * 2 - 1; + for (let i = 0; i < packets; i++) { + this.surface.ripple(frontU, randRange(-0.8, 0.8), randRange(0.6, 1.2), this.age); + } + + /* spray and clots thrown out of the break */ + _emit.position = _lip; + _emit.radius = c.sheetWidth * 0.3; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.6).setY(0.8).normalize(); + _emit.speed = c.spraySpeed * 2.1; + _emit.speedVariance = 0.85; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.13; + _emit.sizeVariance = 0.8; + _emit.life = c.sprayLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spray.emit(Math.round(c.burstSpray * g.particleCount), _emit); + + _emit.position = _pos; + _emit.radius = c.sheetWidth * 0.35; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.35).setY(1).normalize(); + _emit.speed = c.clotSpeed * 1.7; + _emit.size = 0.12; + _emit.life = c.clotLifetime * 1.3; + _emit.spin = 9; + this.clots.emit(Math.round(c.burstClots * g.particleCount), _emit); + + _emit.speed = c.mistSpeed * 2.4; + _emit.spread = 1.0; + _emit.size = 1.2; + _emit.life = c.mistLifetime * 1.2; + _emit.spin = 0.4; + this.mist.emit(Math.round(34 * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = settings.crimsontide.lightIntensity * 0.5 * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 while the crest breaks, then 1..2 while the sheet drains. + // The drain is quadratic-in so the water hangs and then goes, rather than + // dissolving at a constant rate — liquid leaves a floor all at once. + const fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + this._sync(fade); + + this.surface.lipPosition(_liquid, this.position, 0); + + // The break is the loudest beat; the drain is nearly silent. + this._tideFx(dt, t <= 1 ? 1.35 * (1 - 0.4 * (t - 0)) : fade * 0.22); + } + + onDestroy() { + this._live = 0; + this.surface.reset(); + this.droplets.reset(); + this.wet.setVisible(false); + this.wet.clearMarks(); + } + + dispose() { + this.surface.dispose(); + this.wet.dispose(); + this.droplets.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/blood/HemolanceAbility.js b/src/abilities/blood/HemolanceAbility.js new file mode 100644 index 0000000..c700c8a --- /dev/null +++ b/src/abilities/blood/HemolanceAbility.js @@ -0,0 +1,1239 @@ +import { + BufferAttribute, + BufferGeometry, + DoubleSide, + Float32BufferAttribute, + InstancedBufferAttribute, + InstancedBufferGeometry, + Mesh, + NormalBlending, + ShaderMaterial, + Sphere, + Vector3, + DynamicDrawUsage +} from 'three'; +import { Ability } from '../Ability.js'; +import { FlightMode, Projectile, Stagger, projectileParams } from '../../vfx/Projectile.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { noiseGLSL } from '../../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../../shaders/lib/common.glsl.js'; +import { frame, sharedUniforms } from '../../core/FrameUniforms.js'; +import { LAYER } from '../../core/Layers.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, hash11, Easing } from '../../utils/math.js'; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +/** Hard ceiling on lances in one volley. The `needles` slider clamps here. */ +const MAX_NEEDLES = 16; +/** + * Samples along one mist thread. + * + * The thread is a straight line in space, so this is not about resolving a + * curve — it is about resolving the *age gradient* along it, which at the + * shipped `mistLife` runs the full colour ramp over a couple of metres. Twelve + * banded visibly. Twenty-eight does not, and a strip of twenty-eight by sixteen + * instances is 896 vertices, which is nothing. + */ +const MIST_NODES = 28; +/** Facets around a lance. Three: it is 3 cm wide and nobody counts them. */ +const NEEDLE_SIDES = 3; +/** How many impacts the floor quad carries. See `StarfallAbility` on the cost. */ +const FLOOR_MARKS = 12; + +/* ------------------------------------------------------------------ */ +/* Scratch — module scope, reused, never allocated in a frame (I3) */ +/* ------------------------------------------------------------------ */ + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _launch = new Vector3(); +const _land = new Vector3(); +const _centre = new Vector3(); +const _hand = new Vector3(); +const _flight = projectileParams(); +const _ground = groundFieldParams(); + +/* ------------------------------------------------------------------ */ +/* The lance */ +/* ------------------------------------------------------------------ */ + +/** + * One needle: a three-sided spike in unit space, point at `y = +1`, tail at + * `y = -1`, circumscribed radius 1 across. + * + * `Projectile` scales an instance `(radius, radius × stretch, radius)` and at + * `align = 1` lays local **+Y** along the heading, so +Y is the point. The + * widest ring is a third of the way back from the tip and the tail closes to a + * second, blunter point, which is what stops the lance reading as a cone with a + * flat disc hanging off the back of it when the volley crosses the camera. + */ +function createNeedleGeometry() { + const sides = NEEDLE_SIDES; + const rings = [ + [-1.0, 0.0], + [-0.62, 0.55], + [0.34, 1.0], + [1.0, 0.0] + ]; + + const angles = []; + for (let i = 0; i < sides; i++) angles.push((i / sides) * Math.PI * 2); + + const ringPoints = rings.map(([y, r]) => + angles.map((angle) => [Math.cos(angle) * r, y, Math.sin(angle) * r]) + ); + + const positions = []; + const push = (p) => positions.push(p[0], p[1], p[2]); + for (let ring = 0; ring < ringPoints.length - 1; ring++) { + const lower = ringPoints[ring]; + const upper = ringPoints[ring + 1]; + for (let i = 0; i < sides; i++) { + const j = (i + 1) % sides; + push(lower[i]); push(lower[j]); push(upper[i]); + push(lower[j]); push(upper[j]); push(upper[i]); + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + return geometry; +} + +const NEEDLE_VERTEX = /* glsl */ ` + attribute float aSeed; + attribute float aFlight; + attribute float aFlash; + + varying float vSeed; + varying float vFlight; + varying float vFlash; + varying float vAlong; + varying vec3 vNormalW; + varying vec3 vViewDir; + + void main() { + vSeed = aSeed; + vFlight = aFlight; + vFlash = aFlash; + // 0 at the tail, 1 at the point. The lance is scaled twenty to one, so this + // is the only coordinate on it worth shading against. + vAlong = position.y * 0.5 + 0.5; + + mat4 im = mat4(1.0); + #ifdef USE_INSTANCING + im = instanceMatrix; + #endif + + // Anisotropic instance scale — see the same note in StarfallAbility. At + // twenty to one the error is not subtle: every normal ends up pointing at + // the tip and the lance lights like a bar of soap. + mat3 rot = mat3(im); + vec3 sq = vec3(dot(rot[0], rot[0]), dot(rot[1], rot[1]), dot(rot[2], rot[2])); + vec3 objectNormal = rot * (normal / max(sq, vec3(1e-6))); + + vec4 world = modelMatrix * im * vec4(position, 1.0); + vNormalW = normalize(mat3(modelMatrix) * objectNormal); + vViewDir = cameraPosition - world.xyz; + + gl_Position = projectionMatrix * viewMatrix * world; + } +`; + +const NEEDLE_FRAGMENT = /* glsl */ ` + uniform vec3 uColorCore; + uniform vec3 uColorEdge; + uniform vec3 uColorDeep; + uniform float uGlow; + uniform float uOpacity; + uniform float uRim; + uniform float uTip; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + + varying float vSeed; + varying float vFlight; + varying float vFlash; + varying float vAlong; + varying vec3 vNormalW; + varying vec3 vViewDir; + + ${commonGLSL} + + void main() { + // A needle is three centimetres across at twenty metres. There is no room + // for surface detail and no point looking for any: the whole read is the + // silhouette, so the rim term does all the work and the body is a flat + // gradient behind it. Noise on this would be invisible and would cost a + // fragment program that has to be compiled. + float fres = fresnelTerm(vViewDir, vNormalW, uRim, 1.0); + float tip = uTip * pow(clamp(vAlong, 0.0, 1.0), 3.0); + float flash = vFlash * 1.5; + + vec3 body = mix(uColorDeep, uColorEdge, clamp(fres * 0.9 + 0.25, 0.0, 1.0)); + vec3 color = mix(body, uColorCore, clamp(tip * 0.4 + flash, 0.0, 1.0)); + color *= uGlow * uGlobalGlow * uShaderIntensity * (1.0 + tip * 0.35 + flash); + + float alpha = clamp(uOpacity * (0.6 + 0.4 * fres), 0.0, 1.0); + gl_FragColor = vec4(color, alpha); + } +`; + +function createNeedleMaterial() { + return new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: NormalBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uColorCore: { value: getColor('#ff8a92').clone() }, + uColorEdge: { value: getColor('#c01a28').clone() }, + uColorDeep: { value: getColor('#3a0509').clone() }, + uGlow: { value: 1.5 }, + uOpacity: { value: 1 }, + uRim: { value: 3 }, + uTip: { value: 2.2 } + }), + vertexShader: NEEDLE_VERTEX, + fragmentShader: NEEDLE_FRAGMENT + }); +} + +/* ------------------------------------------------------------------ */ +/* The mist threads — the trick */ +/* ------------------------------------------------------------------ */ + +/** + * A hairline of mist left hanging on the path a needle already flew. + * + * **What it draws.** One instanced camera-facing strip per needle, spanning the + * part of that needle's flight it has already covered. Sixteen threads, one + * draw call. + * + * **Why it is not a `RibbonTrail`.** A trail built by recording positions is a + * record of metres, and a record of metres cannot be re-shaped by a slider — + * which fails I1 outright, and also fails the *look*, because a recorded trail + * dies by dimming and mist does not die by dimming. It disperses. + * + * **The one idea.** A needle's flight is a closed form, so the *time it passed + * any given point on its own path* is also a closed form. Given the head's path + * fraction `sHead` and a vertex at fraction `s = v · sHead`, the needle was + * there at `delay + flight · s^(1/pathCurve)`, and the age of the mist at that + * vertex is now minus that. Everything else — how wide it is, how far it has + * drifted, how far it has wandered off the line, what colour it is and whether + * it is there at all — is a function of that one age. No history, no buffers, + * nothing captured. The consequence is the thing that makes the volley read: + * the ribbon *outlives the needle*, because `sHead` saturates at 1 while the + * ages keep climbing, and it goes by swelling and thinning rather than by + * fading out. + * + * The mirror obligation, stated once: `pathAt` here and `_pathPoint` in + * `Projectile` describe the same line. This one is only correct while the + * needles fly straight — no apex, no weave — which is why the ability sets both + * to zero and says so in its own comment. Give a needle a loft and the mist + * will hang in a straight line under the arc it actually flew. + */ +const MIST_VERTEX = /* glsl */ ` + uniform float uClock; // the ability's age, seconds + uniform float uPathCurve; // mirrors the projectile's, exactly + uniform float uLife; // seconds a point of mist survives + uniform float uWidth; // metres, half-width where it is laid + uniform float uSpread; // metres it swells by at full age + uniform float uDrift; // metres/second it rises + uniform float uWander; // metres of lateral wander at full age + uniform float uWanderScale; // wander features per metre + uniform float uWanderSpeed; + + attribute vec3 aLaunch; + attribute vec3 aLand; + attribute vec2 aTiming; // x = launch delay, y = flight time (seconds) + attribute vec2 aDice; // x = seed, y = handedness + + varying float vAge; // 0 just laid, 1 gone + varying float vSide; + varying float vSeed; + varying float vViewZ; + + ${noiseGLSL} + + void main() { + float v = position.x; // 0 at the tail of the thread, 1 at the head + float side = position.y; // -1 / +1 across the ribbon + float delay = aTiming.x; + float flight = max(aTiming.y, 1e-4); + float curve = max(uPathCurve, 0.05); + + // Where the needle is now, as a fraction of its own path. + float tauHead = clamp((uClock - delay) / flight, 0.0, 1.0); + float sHead = pow(tauHead, curve); + + // This vertex, and the moment the needle was standing on it. + float s = v * sHead; + float passedAt = delay + flight * pow(max(s, 0.0), 1.0 / curve); + float age = max(uClock - passedAt, 0.0); + vAge = clamp(age / max(uLife, 1e-3), 0.0, 1.0); + vSide = side; + vSeed = aDice.x; + + vec3 p = mix(aLaunch, aLand, s); + + // Dispersal. All three terms are driven by the age, not by the clock, so a + // thread laid a second ago is further gone than one laid this frame — which + // is what makes a ripple of twelve read as twelve and not as a fan. + p.y += uDrift * age; + float wob = uWander * vAge; + float ws = uWanderScale; + float wt = uClock * uWanderSpeed; + p.x += snoise(vec3(s * ws, aDice.x * 19.0, wt)) * wob; + p.y += snoise(vec3(s * ws + 13.0, aDice.x * 19.0, wt)) * wob * 0.6; + p.z += snoise(vec3(s * ws + 27.0, aDice.x * 19.0, wt)) * wob; + + // Camera-facing ribbon about the thread's own axis. The axis is the + // launch → land line and nothing else, so it is well defined even at s = 0 + // where a tangent would be. + vec3 axis = aLand - aLaunch; + float axisLen = length(axis); + axis = axisLen > 1e-4 ? axis / axisLen : vec3(0.0, 0.0, 1.0); + vec3 toCam = normalize(cameraPosition - p); + vec3 across = cross(axis, toCam); + float acrossLen = length(across); + // Dead-on: the thread is pointing at the camera and has no width to give. + across = acrossLen > 1e-4 ? across / acrossLen : vec3(1.0, 0.0, 0.0); + + float halfWidth = (uWidth + uSpread * vAge) * aDice.y; + p += across * side * halfWidth; + + vec4 mv = viewMatrix * vec4(p, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const MIST_FRAGMENT = /* glsl */ ` + uniform vec3 uColorA; + uniform vec3 uColorB; + uniform vec3 uColorC; + uniform vec3 uColorD; + uniform float uOpacity; + uniform float uCore; + uniform float uFalloff; + uniform float uSoftFade; + + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uShaderIntensity; + + varying float vAge; + varying float vSide; + varying float vSeed; + varying float vViewZ; + + ${commonGLSL} + + void main() { + float across = abs(vSide); + + // Two profiles across the ribbon, crossfaded by age. Fresh, the thread is + // all centre line — a hairline, which is what a needle leaves. Old, it is a + // soft lump with nothing sharp in it at all. Crossfading the *profile* + // rather than only the width is what stops an old thread reading as a + // wide wire. + float hair = pow(max(1.0 - across, 0.0), max(uCore, 0.05)); + float lump = (1.0 - across * across) * 0.55; + float profile = mix(hair, lump, vAge); + + float alive = pow(max(1.0 - vAge, 0.0), max(uFalloff, 0.05)); + // A little per-thread variation, so twelve ribbons laid a few hundredths of a + // second apart are not twelve identical objects. + float alpha = uOpacity * profile * alive * (0.82 + 0.36 * vSeed); + if (alpha < 0.002) discard; + + vec3 color = gradient4(uColorA, uColorB, uColorC, uColorD, vAge) * uShaderIntensity; + + float soft = softFade( + uSceneDepth, gl_FragCoord.xy / uResolution, vViewZ, uCameraNear, uCameraFar, uSoftFade + ); + + gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0) * soft); + } +`; + +/** + * The instanced strip that carries every thread. + * + * Parent-first like the library modules it sits next to, and deliberately not + * *in* the library: nothing else in the roster wants a trail that survives its + * own projectile, and a shared module written for one caller is a shared module + * with one caller's assumptions baked into it. + */ +class MistThreads { + constructor(parent, { capacity = MAX_NEEDLES, nodes = MIST_NODES } = {}) { + this.capacity = Math.max(1, Math.round(capacity)); + + // Parameter space only: (v, side). There is not one metre in the buffer, + // which is what lets one strip serve sixteen threads of any length. + const positions = new Float32Array(nodes * 2 * 3); + for (let i = 0; i < nodes; i++) { + const v = i / (nodes - 1); + const o = i * 6; + positions[o + 0] = v; + positions[o + 1] = -1; + positions[o + 3] = v; + positions[o + 4] = 1; + } + + const indices = new Uint16Array((nodes - 1) * 6); + for (let i = 0; i < nodes - 1; i++) { + const a = i * 2; + const o = i * 6; + indices[o + 0] = a; + indices[o + 1] = a + 1; + indices[o + 2] = a + 2; + indices[o + 3] = a + 1; + indices[o + 4] = a + 3; + indices[o + 5] = a + 2; + } + + this.launch = new InstancedBufferAttribute(new Float32Array(this.capacity * 3), 3); + this.land = new InstancedBufferAttribute(new Float32Array(this.capacity * 3), 3); + this.timing = new InstancedBufferAttribute(new Float32Array(this.capacity * 2), 2); + this.dice = new InstancedBufferAttribute(new Float32Array(this.capacity * 2), 2); + for (const attribute of [this.launch, this.land, this.timing, this.dice]) { + attribute.setUsage(DynamicDrawUsage); + } + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setAttribute('aLaunch', this.launch); + geometry.setAttribute('aLand', this.land); + geometry.setAttribute('aTiming', this.timing); + geometry.setAttribute('aDice', this.dice); + geometry.setIndex(new BufferAttribute(indices, 1)); + geometry.instanceCount = 0; + // Placed in world space by the vertex shader, so its own bounds are a lie + // and frustum culling has to be off. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.geometry = geometry; + + this.material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: NormalBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uClock: { value: 0 }, + uPathCurve: { value: 1 }, + uLife: { value: 0.85 }, + uWidth: { value: 0.022 }, + uSpread: { value: 0.17 }, + uDrift: { value: 0.34 }, + uWander: { value: 0.15 }, + uWanderScale: { value: 1.3 }, + uWanderSpeed: { value: 0.35 }, + uColorA: { value: getColor('#ff5a66').clone() }, + uColorB: { value: getColor('#c01a28').clone() }, + uColorC: { value: getColor('#6e0d16').clone() }, + uColorD: { value: getColor('#2a0508').clone() }, + uOpacity: { value: 0.9 }, + uCore: { value: 3.4 }, + uFalloff: { value: 1.7 }, + uSoftFade: { value: 0.35 } + }), + vertexShader: MIST_VERTEX, + fragmentShader: MIST_FRAGMENT + }); + + this.mesh = new Mesh(geometry, this.material); + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(LAYER.VFX); + // After the shaded smoke, before the additive particles, exactly as the + // library's own trail sits. + this.mesh.renderOrder = 11; + this.mesh.name = 'Hemolance:mist'; + parent?.add(this.mesh); + + this._live = 0; + } + + /** One. Always one, however many threads are hanging. */ + get drawCalls() { + return 1; + } + + get count() { + return this._live; + } + + /** + * Post one thread's endpoints and clock. Called once per needle per frame. + * + * `launch` and `land` are metres, and that is fine: they are written and read + * inside the same frame, resolved from the live block by the caller, and + * never survive to the next one. + */ + setThread(slot, launch, land, delay, flightTime, seed, hand) { + if (slot >= this.capacity) return; + const o3 = slot * 3; + this.launch.array[o3 + 0] = launch.x; + this.launch.array[o3 + 1] = launch.y; + this.launch.array[o3 + 2] = launch.z; + this.land.array[o3 + 0] = land.x; + this.land.array[o3 + 1] = land.y; + this.land.array[o3 + 2] = land.z; + this.timing.array[slot * 2 + 0] = delay; + this.timing.array[slot * 2 + 1] = flightTime; + this.dice.array[slot * 2 + 0] = seed; + this.dice.array[slot * 2 + 1] = hand; + } + + /** Publish the frame's threads. `count` of them are drawn. */ + commit(count) { + this._live = Math.max(0, Math.min(this.capacity, count)); + this.geometry.instanceCount = this._live; + this.launch.needsUpdate = true; + this.land.needsUpdate = true; + this.timing.needsUpdate = true; + this.dice.needsUpdate = true; + } + + reset() { + this._live = 0; + this.geometry.instanceCount = 0; + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + this.mesh.parent?.remove(this.mesh); + } +} + +/* ------------------------------------------------------------------ */ +/* The ability */ +/* ------------------------------------------------------------------ */ + +/** + * HEMOLANCE — a volley of blood needles, and what they leave behind. + * + * Three beats: the **volley** leaves the hand as a ripple, the needles + * **impact** and each one sprays and pools, and the mist **fades** off the + * threads in the order it was laid. + * + * **THE TRICK — the trail outlives the projectile.** A needle is three + * centimetres across and crosses sixteen metres in an eighth of a second. On + * its own that is one frame and a half of a bright line and the eye simply does + * not catch it — the first build looked like a stutter in the renderer. What + * makes it read is that each lance leaves a hairline of mist on the exact path + * it flew, and that mist hangs there for the best part of a second after the + * lance has hit and gone: twelve needles fired over two thirds of a second never + * have more than three in the air at once, but they stack up into twelve visible + * threads, and *that* is the volley. The evidence is the effect. + * + * The threads do not fade, they disperse. A ribbon whose alpha ramps down reads + * as a light being turned off; one that swells, softens, drifts upward and + * wanders off its own line reads as something in the air. All four of those are + * driven off a single number — the age of the mist at that point on the thread + * — and that age is a closed form, not a recording: see `MIST_VERTEX` above, + * which is where the actual work of this file is. + * + * **The ripple.** `ripplePhase` is the seconds between one needle and the next. + * `Projectile` spreads a `RIPPLE` stagger evenly across a `window`, so the + * ability hands it `window = ripplePhase × (needles − 1)` every frame, which + * means the signature control is a *per-needle* number rather than a total + * duration — drag `needles` and the rhythm stays put instead of compressing. + * The hold phase is stretched from the same numbers so the volley can never + * outlast its own cast. + * + * **What a cast captures.** One seed, a flag for "has the last needle landed", + * and one timestamp per impact in the floor quad's ring buffer. Nothing else. + * Pause mid-volley and drag `ripplePhase`: every thread re-lays itself, the + * ones that had not been fired yet appear, and the ones already down stay down. + * + * **Draw calls.** Three of its own — the instanced lances, the one strip that + * carries every thread of mist, and the ground quad that carries every pool — + * plus three shared particle systems and a pooled splash per impact. + */ +export class HemolanceAbility extends Ability { + constructor(context) { + super('hemolance', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.needleMaterial = createNeedleMaterial(); + + // `trail: false` — the library's trail is a good trail and it is the wrong + // one here: it catches its head up after landing and is gone, which is the + // opposite of the thing this ability is about. One draw call saved, and the + // mist strip below spends it better. + this.needles = new Projectile(this.group, { + geometry: createNeedleGeometry, + material: this.needleMaterial, + capacity: MAX_NEEDLES, + trail: false, + layer: LAYER.VFX, + renderOrder: 12 + }); + + this.mist = new MistThreads(this.group, { capacity: MAX_NEEDLES, nodes: MIST_NODES }); + + // Shaded, not additive: a pool of blood is darker than the stone it is on. + this.floor = new GroundField(this.group, { + mode: GroundMode.POCK, + marks: FLOOR_MARKS, + additive: false, + depthTest: true, + name: 'Hemolance:pools' + }); + + _ground.centre = _centre; + + /** Re-rolled per cast. Unitless, and the only dice this ability keeps. */ + this._seed = 0; + /** Has the last needle landed? The screen flash fires once, on it. */ + this._closed = false; + this._live = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Fat droplets that arc and fall. Lit rather than additive — the one place + // in the palette where a real surface normal is worth having, because a + // droplet that glows is a spark and this school has no sparks. + this.droplets = particles.get('hemolance.droplets', { + capacity: 900, + shape: ParticleShape.SOFT, + additive: false, + lit: true, + softFade: 0.3 + }); + this.droplets.uniforms.uDrag.value = 0.5; + this.droplets.uniforms.uEndSize.value = 0.7; + this.droplets.uniforms.uSizeIn.value = 0.04; + this.droplets.uniforms.uFadeIn.value = 0.05; + this.droplets.uniforms.uFadeOut.value = 0.6; + + // Fine spray: velocity-stretched, short-lived, thrown back along the lance. + this.spray = particles.get('hemolance.spray', { + capacity: 1000, + shape: ParticleShape.STREAK, + additive: false, + stretch: true, + softFade: 0.25 + }); + this.spray.uniforms.uDrag.value = 1.8; + this.spray.uniforms.uEndSize.value = 0.25; + this.spray.uniforms.uSizeIn.value = 0.02; + this.spray.uniforms.uFadeIn.value = 0.03; + this.spray.uniforms.uFadeOut.value = 0.4; + + // The red haze that hangs where the volley struck. Occludes, so it can sit + // in front of the mist threads and take some of the hardness off them. + this.haze = particles.get('hemolance.haze', { + capacity: 700, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.haze.uniforms.uDrag.value = 2.1; + this.haze.uniforms.uEndSize.value = 2.4; + this.haze.uniforms.uSizeIn.value = 0.14; + this.haze.uniforms.uFadeIn.value = 0.2; + this.haze.uniforms.uFadeOut.value = 0.35; + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live + this.mist.count; + } + + /** + * How long the volley takes, end to end, seconds. + * + * Resolved rather than stored, because every number in it is a slider: the + * last needle leaves at `volleyLead + ripplePhase × (needles − 1)` and lands + * `needleTime` later. + */ + get volleyDuration() { + const c = settings.hemolance; + const last = Math.max(0, Math.min(MAX_NEEDLES, Math.round(c.needles)) - 1); + return this._needleDelay(last) + this._needleFlight(); + } + + /** + * The hold, stretched so a slow ripple cannot outlast its own cast. + * + * `lifetime` is the authored floor; the volley's own length plus half the + * mist's is the floor below which the ability would visibly cut itself off. + * Taking the larger means dragging `ripplePhase` to its maximum lengthens the + * beat instead of truncating it, which is what you want from the one control + * the ability is named after. + */ + get impactDuration() { + const c = settings.hemolance; + const needed = this.volleyDuration + Math.max(0, c.mistLife) * 0.5; + return Math.max(0.05, Math.max(c.lifetime, needed) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.hemolance.fadeTime); + } + + /** An arterial pulse — two beats close together, then a gap. */ + lightShimmer() { + const c = settings.hemolance; + const beat = this.age * Math.max(0.05, c.lightPulseSpeed); + // `sin²` of a doubled rate under a slower envelope: a systole, a weaker + // diastole, and a rest. A single sine reads as a lamp on a dimmer. + const wave = Math.pow(Math.sin(beat * Math.PI), 2) * (0.65 + 0.35 * Math.sin(beat * Math.PI * 0.5)); + return 1 - saturate(c.lightPulse) * (1 - wave); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the volley leaves the caster. */ + _handPoint(out) { + const c = settings.hemolance; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** The floor quad's yaw; local +Z runs downrange. */ + _yaw() { + return Math.atan2(this.direction.x, this.direction.z); + } + + /** + * Needle `i`'s launch delay, seconds. + * + * A **mirror** of what `Projectile` computes internally for `Stagger.RIPPLE`, + * and it has to be, because the mist strip needs the same clock the bodies + * are on and the module does not publish it. The mirror is exact and it is + * one line: `RIPPLE` spreads `order = i / (count − 1)` across `window`, and + * this ability sets `window = ripplePhase × (count − 1)`, so the two + * `(count − 1)` terms cancel and the delay is simply `lead + phase × i`. The + * only thing that could break it is switching the stagger mode, which is why + * the mode is named explicitly in `_fillFlight` rather than left on AUTO. + */ + _needleDelay(index) { + const c = settings.hemolance; + return (Math.max(0, c.volleyLead) + Math.max(0, c.ripplePhase) * index) / this._clock(); + } + + /** + * Needle `i`'s flight time, seconds. Uniform across the volley, on purpose — + * `speedJitter` is held at zero (see the settings block), so this is exact + * rather than a mirror of the module's jitter maths. + */ + _needleFlight() { + return Math.max(0.02, settings.hemolance.needleTime) / this._clock(); + } + + /** + * The global speed multiplier, guarded. + * + * Every second this ability quotes is divided by it, in one place, because + * the volley's clock and the mist's clock have to be the *same* clock: the + * strip is timing itself against delays the bodies were flown on, and a + * factor applied to one and not the other detaches every thread from its own + * needle. That bug looks like the mist lagging, which is a plausible enough + * effect that it survived a session before anyone questioned it. + */ + _clock() { + return Math.max(0.05, settings.global.speed); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.floor.clearMarks(); + this.mist.reset(); + this._closed = false; + this._live = 0; + + this._seed = Math.random() * 100; + this.needles.roll(this._seed); + + this._sync(1); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Per-frame */ + /* ------------------------------------------------------------------ */ + + /** Fill the flight params from the live block. */ + _fillFlight() { + const c = settings.hemolance; + const p = _flight; + const clock = this._clock(); + const count = Math.max(0, Math.min(MAX_NEEDLES, Math.round(c.needles))); + + p.mode = FlightMode.VOLLEY; + // Named, not AUTO. `_needleDelay` mirrors RIPPLE and only RIPPLE. + p.stagger = Stagger.RIPPLE; + p.count = count; + p.radius = c.needleRadius; + p.sizeJitter = 0; + p.stretch = c.needleLength; + p.align = c.needleAlign; + p.spin = c.needleSpin; + p.flash = c.needleFlash; + + /* the launch */ + p.handForward = c.handForward; + p.handSide = c.handSide; + p.handHeight = c.handHeight; + p.fanWidth = c.fanWidth; + p.skyBack = 0; + p.skyHeight = 0; + p.skyScatter = 0; + + /* the landing */ + p.landHeight = c.landHeight; + p.spreadSide = c.spreadSide; + p.spreadForward = c.spreadForward; + p.landInZone = false; + p.zoneRadius = 1; + p.zoneBias = 0.5; + + /* the curve — dead straight, which is what lets the mist mirror it */ + p.pathCurve = c.pathCurve; + p.apex = 0; + p.apexCurve = 1; + p.weaveSide = 0; + p.weaveUp = 0; + p.weaveTurns = 1; + p.weaveTurnsUp = 1; + p.weavePhase = 0; + p.weaveDecay = 1; + + /* the clock — `window` is derived so `ripplePhase` is per needle, and every + second goes through `_clock()` so the strip and the bodies agree */ + p.flightTime = this._needleFlight(); + p.speedJitter = 0; + p.lead = this._needleDelay(0); + p.window = (Math.max(0, c.ripplePhase) * Math.max(0, count - 1)) / clock; + p.fillBias = 0; + p.fillScatter = 0; + p.hashCell = 1; + p.linger = 0; + p.sink = 0; + p.load = 0; + + /* no trail: the mist strip is the trail */ + p.trailWidth = 0; + p.trailOpacity = 0; + return p; + } + + /** Fill the floor quad's params from the live block. */ + _fillGround(fade) { + const c = settings.hemolance; + const g = settings.global; + const p = _ground; + + p.centre = _centre; + p.yaw = this._yaw(); + p.height = c.fieldHeight; + p.radius = c.fieldRadius; + p.length = c.fieldRadius * 2; + + p.grow = 1; + p.recede = 0; + p.progress = 1; + p.inscribe = 1; + p.ignite = 0; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.fieldEdge; + p.ragged = c.fieldRagged; + p.raggedScale = c.fieldRaggedScale; + p.warp = c.fieldWarp; + + p.relief = c.fieldRelief; + p.normalStep = 0.04; + p.ambient = 0.26; + p.wrap = 0.4; + p.specular = c.fieldSpecular; + p.gloss = c.fieldGloss; + p.parallax = 0.12; + + p.cell = 0.5; + p.cellJitter = 0.8; + p.seam = 0.05; + p.thickness = c.poolThickness; + p.lift = c.poolRim; + p.depth = c.poolDepth; + p.width = 0.5; + p.sharp = 0.5; + p.detail = c.poolDetail; + p.swirl = 0; + p.arms = 5; + // POCK's dig rate. Run fast here — blood arrives at the speed of the hit, + // where Starfall's ring grows slowly and reads as a shockwave. + p.speed = c.poolSpread; + p.flow = 0; + p.windAngle = 0; + + p.markLife = c.poolLife; + p.markRadius = c.poolRadius; + + p.additive = false; + p.emissive = c.fieldEmissive * g.glow; + p.opacity = c.fieldOpacity; + p.depthFade = 0.35; + p.colorBase = c.colorPoolBase; + p.colorEdge = c.colorPoolEdge; + p.colorGlow = c.colorPoolGlow; + p.colorDeep = c.colorPoolDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + return p; + } + + /** + * Lay the mist threads for this frame. + * + * Runs after `Projectile.update()`, because the endpoints it reads are the + * ones that call resolved from the live block. Every needle gets a thread, + * including the ones that have already landed — that is the entire point — + * and the strip drops one only when the *tail* of it has aged out, which the + * shader decides, not this method. + */ + _layThreads(count) { + const flight = this._needleFlight(); + for (let i = 0; i < count; i++) { + this.needles.pointAt(i, 0, _launch); + this.needles.landPoint(i, _land); + // Handedness: half the threads take their width to the other side of the + // axis, so twelve parallel ribbons do not all catch the light identically. + const hand = hash11(this._seed + i * 4.13) < 0.5 ? -1 : 1; + this.mist.setThread(i, _launch, _land, this._needleDelay(i), flight, hash11(this._seed + i * 9.7), hand); + } + this.mist.commit(count); + } + + /** Push the live block into the mist strip's uniforms. */ + _syncMist(fade) { + const c = settings.hemolance; + const g = settings.global; + const u = this.mist.material.uniforms; + + u.uClock.value = this.age; + u.uPathCurve.value = Math.max(0.05, c.pathCurve); + u.uLife.value = Math.max(0.02, c.mistLife * g.lifetime); + u.uWidth.value = c.mistWidth; + u.uSpread.value = c.mistSpread; + u.uDrift.value = c.mistDrift; + u.uWander.value = c.mistWander * g.turbulence; + u.uWanderScale.value = c.mistWanderScale * g.noiseFrequency; + u.uWanderSpeed.value = c.mistWanderSpeed * g.noiseSpeed; + u.uColorA.value.copy(getColor(c.colorMistA)); + u.uColorB.value.copy(getColor(c.colorMistB)); + u.uColorC.value.copy(getColor(c.colorMistC)); + u.uColorD.value.copy(getColor(c.colorMistD)); + u.uOpacity.value = c.mistOpacity * fade * g.opacity; + u.uCore.value = c.mistCore; + u.uFalloff.value = c.mistFalloff; + u.uSoftFade.value = c.mistSoftFade; + } + + /** Push the live block into the lance material and the three particle systems. */ + _syncLook(fade) { + const c = settings.hemolance; + const g = settings.global; + const u = this.needleMaterial.uniforms; + + u.uColorCore.value.copy(getColor(c.colorNeedleCore)); + u.uColorEdge.value.copy(getColor(c.colorNeedleEdge)); + u.uColorDeep.value.copy(getColor(c.colorNeedleDeep)); + u.uGlow.value = c.needleGlow * g.glow; + u.uOpacity.value = c.needleOpacity * fade * g.opacity; + u.uRim.value = Math.max(0.05, c.needleRim); + u.uTip.value = c.needleTip; + + this.droplets.setGradient( + getColor(c.colorDropletA), + getColor(c.colorDropletB), + getColor(c.colorDropletC), + getColor(c.colorDropletD) + ); + this.droplets.uniforms.uGravity.value.set(0, c.dropletGravity, 0); + this.droplets.uniforms.uSizeScale.value = c.dropletSize * g.particleSize * 7; + this.droplets.uniforms.uLifeScale.value = c.dropletLifetime * 0.5 * g.particleLifetime; + this.droplets.uniforms.uSpeedScale.value = g.particleSpeed; + this.droplets.uniforms.uOpacity.value = g.opacity; + this.droplets.uniforms.uTurbulence.value = 0.2 * g.turbulence; + + this.spray.setGradient( + getColor(c.colorSprayA), + getColor(c.colorSprayB), + getColor(c.colorSprayC), + getColor(c.colorSprayD) + ); + this.spray.uniforms.uGravity.value.set(0, c.sprayGravity, 0); + this.spray.uniforms.uSizeScale.value = c.spraySize * g.particleSize * 7; + this.spray.uniforms.uLifeScale.value = c.sprayLifetime * 0.5 * g.particleLifetime; + this.spray.uniforms.uSpeedScale.value = g.particleSpeed; + this.spray.uniforms.uOpacity.value = g.opacity; + this.spray.uniforms.uStretch.value = c.sprayStretch; + this.spray.uniforms.uTurbulence.value = 0.25 * g.turbulence; + + this.haze.setGradient( + getColor(c.colorHazeA), + getColor(c.colorHazeB), + getColor(c.colorHazeC), + getColor(c.colorHazeD) + ); + this.haze.uniforms.uGravity.value.set(0, c.hazeRise, 0); + this.haze.uniforms.uSizeScale.value = c.hazeSize * g.particleSize; + this.haze.uniforms.uLifeScale.value = c.hazeLifetime * 0.5 * g.particleLifetime; + this.haze.uniforms.uSpeedScale.value = c.hazeSpeed * g.particleSpeed; + this.haze.uniforms.uOpacity.value = c.hazeOpacity * g.opacity; + this.haze.uniforms.uTurbulence.value = 0.35 * g.turbulence; + } + + /** + * One frame of the whole ability. + * + * @param {number} fade 1 while the volley is lit, ramping to 0 as it lets go + */ + _sync(fade) { + // The pools are anchored where the needles converge, not at the caster. + this.pointAt(1, _centre); + + this._syncLook(fade); + + const p = this._fillFlight(); + this.needles.setBasis(this.origin, this.direction, this.side, this.length); + this.needles.update(this.age, p); + this._live = this.needles.count; + + for (let i = 0; i < this.needles.arrivalCount; i++) this._needleImpact(this.needles.arrivals[i]); + + this._layThreads(Math.max(0, Math.min(MAX_NEEDLES, Math.round(settings.hemolance.needles)))); + this._syncMist(fade); + this.floor.update(this._fillGround(fade)); + } + + /* ------------------------------------------------------------------ */ + /* Events */ + /* ------------------------------------------------------------------ */ + + /** The burst at the hand as the volley leaves it. */ + _muzzleFx() { + const c = settings.hemolance; + const g = settings.global; + + this._handPoint(_hand); + + this.ctx.bursts.spawn(BurstMode.WATER, _hand, { + radius: c.muzzleSize * 0.25, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.35, + intensity: c.muzzleIntensity, + opacity: 0.85, + fresnel: 1.5, + displace: 0.4, + colorA: getColor(c.colorMuzzleA), + colorB: getColor(c.colorMuzzleB), + colorC: getColor(c.colorMuzzleC) + }); + + _emit.position = _hand; + _emit.radius = 0.14; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.7).setY(0.4).normalize(); + _emit.speed = c.spraySpeed * 0.6; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.6; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.spray.emit(Math.round(18 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + } + + /** + * One needle has arrived. + * + * The pool goes on the floor *under* the impact rather than at it: the + * needles converge at chest height, and blood that pools where it was spilled + * rather than where it landed is the detail that says there is a body there. + */ + _needleImpact(index) { + const c = settings.hemolance; + const g = settings.global; + const time = frame.uTime.value; + + this.needles.landPoint(index, _pos); + + /* --- the pool, recorded as fractions of the footprint --- */ + const yaw = this._yaw(); + const cos = Math.cos(yaw); + const sin = Math.sin(yaw); + const ox = _pos.x - _centre.x; + const oz = _pos.z - _centre.z; + const radius = Math.max(0.05, c.fieldRadius); + const strength = 0.6 + 0.4 * hash11(this._seed + index * 5.31); + this.floor.mark((cos * ox - sin * oz) / radius, (sin * ox + cos * oz) / radius, time, strength); + + /* --- the splash --- */ + this.ctx.bursts.spawn(BurstMode.WATER, _pos, { + radius: c.shellSize * 0.2, + endRadius: c.shellSize * g.explosionIntensity, + life: Math.max(0.05, c.shellLife), + intensity: c.shellIntensity, + opacity: 0.8, + fresnel: 1.6, + displace: 0.5, + squash: 0.85, + colorA: getColor(c.colorShellA), + colorB: getColor(c.colorShellB), + colorC: getColor(c.colorShellC) + }); + + /* --- spray thrown back along the lance, droplets thrown up --- */ + _emit.position = _pos; + _emit.radius = 0.08; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.75).setY(0.5).normalize(); + _emit.speed = c.spraySpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spray.emit(Math.round(c.sprayBurst * g.particleCount), _emit); + + _emit.radius = 0.16; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dropletSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.95; + _emit.size = 0.14; + _emit.life = c.dropletLifetime; + _emit.spin = 0; + this.droplets.emit(Math.round(c.dropletBurst * g.particleCount), _emit); + + _emit.radius = 0.3; + _emit.speed = c.hazeSpeed; + _emit.spread = 1.0; + _emit.size = 0.7; + _emit.life = c.hazeLifetime; + _emit.spin = 0.3; + this.haze.emit(Math.round(c.hazeBurst * g.particleCount), _emit); + + /* --- the room --- */ + this.position.copy(_pos); + this.lightBoost = Math.min(50, this.lightBoost + c.lightPunch * g.explosionIntensity); + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.05, c.shakeDuration), + 28 + ); + + // The flash belongs to the *last* needle, not the first: the ripple builds + // and the punctuation goes on the end of it. Firing it on the first inverts + // the rhythm and the volley reads as a decay. + const last = Math.max(0, Math.min(MAX_NEEDLES, Math.round(c.needles)) - 1); + if (!this._closed && index >= last) { + this._closed = true; + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + + // The light rides the leading needle while any are still flying, so the + // floor lights up ahead of the volley rather than behind it. + if (this._live > 0) this.needles.slotPosition(0, this.position); + + this.ctx.shake.rumble(settings.hemolance.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + // Deliberately empty. The front reaching the end of the line is not an + // event here — the needles have their own clock and each one brings its own + // impact, and a bang on this beat lands between two of them. + } + + onFade(dt, t) { + // `t` runs 0..1 while the volley holds, then 1..2 while the mist lets go. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._sync(fade); + + if (t <= 1 && this._live > 0) { + this.needles.slotPosition(0, this.position); + this.ctx.shake.rumble(settings.hemolance.rumble * settings.global.cameraShake, dt); + } + } + + onDestroy() { + this.needles.reset(); + this.mist.reset(); + this.floor.clearMarks(); + this._live = 0; + this._closed = false; + } + + dispose() { + this.needles.dispose(); + this.mist.dispose(); + this.floor.dispose(); + this.needleMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/blood/PlaguebloomAbility.js b/src/abilities/blood/PlaguebloomAbility.js new file mode 100644 index 0000000..6cd742e --- /dev/null +++ b/src/abilities/blood/PlaguebloomAbility.js @@ -0,0 +1,787 @@ +import { Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing } from '../../utils/math.js'; + +/** + * The phase at which a blister lets go. + * + * `GroundField`'s PUSTULE shader bursts a cell over `smoothstep(0.76, 0.84)` of + * its own cycle, so 0.80 is the middle of that ramp — the frame the skin is + * actually open. Everything the CPU throws is keyed off this one number, and it + * is a *constant* rather than a slider precisely because it is not a dimension: + * it is the shader's own threshold, and a slider that could disagree with the + * fragment code would be a slider that breaks the sync. + */ +const BURST_PHASE = 0.8; + +/** + * How far out, in cells, the CPU is prepared to walk the blister lattice. + * + * The lattice pitch is a live slider, so a footprint of twelve metres at eight + * cells per metre is nearly two hundred cells across — thirty-eight thousand + * hashes a frame for events the eye cannot separate anyway. Past this the CPU + * simply stops looking; the *shader* still draws every blister out to the front, + * so what is lost is only the puff of gas off the ones near the rim. + */ +const MAX_CELL_SPAN = 14; + +/** + * Vents allowed to fire in one frame. + * + * The real ceiling on I7 is the particle systems' own capacities, which recycle + * oldest-first — but a frame that opened two hundred blisters at once would + * flush every one of those buffers in a single go and the cloud would visibly + * blink. Three per frame is 180 a second, which is more events than the eye + * resolves and is what the capacities below are sized against. + */ +const MAX_VENTS_PER_FRAME = 3; + +const TAU = Math.PI * 2; + +/* ------------------------------------------------------------------ */ +/* The two hashes, transcribed — and rounded to 32 bits at every step */ +/* ------------------------------------------------------------------ */ +/** + * **Every arithmetic step below goes through `Math.fround`, and that is not + * fussiness — it is the difference between the trick working and not.** + * + * These are `shaders/lib/noise.glsl.js`'s `hash11` and `hash21`, which the + * PUSTULE fragment shader uses to decide which blister is which and when it + * bursts. The CPU has to reach the *same* answers or the puff of gas comes out + * of the wrong hole at the wrong moment, and the whole ability is that it does + * not. + * + * The first version transcribed them straight, in JS doubles, on the reasoning + * that a hash is a hash and a 1e-7 difference could not matter. It matters + * enormously. `hash11` is `fract(p·(p+33.33) · 2·…)`: by the last line `p` has + * been amplified to around 2300, where one float32 ULP is already 1.2e-4, and + * the value is then wrapped by `fract`. Any cell whose intermediate happens to + * sit near an integer wraps on one side in `highp` and the other in a double. + * Measured over a 25 × 25 patch of the lattice, **24 cells out of 625 came back + * with a completely different id — up to a full unit apart** — and on those the + * puff fires at an unrelated time. Two per cent of the field visibly out of + * step is exactly the sort of thing the eye picks up and cannot name. + * + * Rounding through `fround` after every multiply, add and `fract` reproduces + * GLSL `highp` exactly for this code: there are no multiply-adds for a compiler + * to contract (`p *= p + 33.33` is add-then-multiply), and `fract` is defined as + * `x - floor(x)` in both languages. The float32 literals are hoisted so the + * constants are rounded once rather than 600 times a frame. + * + * An earlier attempt avoided all of this by hashing with `Math.sin`, which is + * the usual JS trick and is simply *not what the shader does*: the puffs landed + * in the right general area and on the wrong blisters, which reads as sloppy + * particle placement rather than as a bug, and cost an afternoon. + */ +const f32 = Math.fround; +const K_1031 = f32(0.1031); +const K_1030 = f32(0.103); +const K_0973 = f32(0.0973); +const K_3333 = f32(33.33); +/** `gfCells`' id key: `dot(cell, vec2(31.7, 57.1))`. */ +const K_ID_X = f32(31.7); +const K_ID_Y = f32(57.1); +/** `gfCells`' offset key: `dot(cell, vec2(7.13, 113.17))`. */ +const K_OFF_X = f32(7.13); +const K_OFF_Y = f32(113.17); + +/** `fract`, at 32 bits. */ +const fract32 = (x) => f32(x - Math.floor(x)); + +/** `dot(vec2(x, y), vec2(ax, ay))`, at 32 bits. */ +const dot2 = (x, y, ax, ay) => f32(f32(f32(x) * ax) + f32(f32(y) * ay)); + +/** `noise.glsl.js#hash11`, line for line, at 32 bits. */ +function hash11(p) { + p = fract32(f32(p * K_1031)); + p = f32(p * f32(p + K_3333)); + p = f32(p * f32(p + p)); + return fract32(p); +} + +/** `hash21`'s two components, written here rather than returned in a pair (I3). */ +let _h21x = 0; +let _h21y = 0; + +/** + * `noise.glsl.js#hash21`, line for line, at 32 bits. Writes `_h21x` / `_h21y`. + * + * This one amplifies far less than `hash11` — the intermediates top out around + * a hundred rather than two thousand — so it would have survived being written + * in doubles. It is rounded anyway, because "this hash needs it and that one + * does not" is a distinction nobody will remember in six months. + */ +function hash21(p) { + let x = fract32(f32(p * K_1031)); + let y = fract32(f32(p * K_1030)); + let z = fract32(f32(p * K_0973)); + // dot(p3, p3.yzx + 33.33) + const d = f32( + f32(f32(x * f32(y + K_3333)) + f32(y * f32(z + K_3333))) + f32(z * f32(x + K_3333)) + ); + x = f32(x + d); + y = f32(y + d); + z = f32(z + d); + // fract((p3.xx + p3.yz) * p3.zy) + _h21x = fract32(f32(f32(x + y) * z)); + _h21y = fract32(f32(f32(x + z) * y)); +} + +/* ------------------------------------------------------------------ */ +/* Scratch — module scope (I3) */ +/* ------------------------------------------------------------------ */ + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); + +/** Filled from `settings.plaguebloom` every frame; never held between frames. */ +const _pustule = groundFieldParams(); +const _stain = groundFieldParams(); + +/** + * PLAGUE BLOOM — a boiling cloud of gas over a circle of blistered ground. + * + * **The trick is the sync.** Two completely different renderers are drawing the + * same cellular field. Above the floor, a `Medium.GAS_BOIL` `VolumeHull` marches + * a lattice of bubbles that inflate, hold, then swell and thin — a pop — each on + * its own clock. On the floor, a `GroundMode.PUSTULE` `GroundField` grows + * blisters that inflate, hold, then burst into a crater — each on its own clock. + * Run those two as separate effects and you have a green cloud sitting on some + * green lumps. Run them as *one* field seen twice and the cloud acquires a floor + * and the floor acquires a sky. + * + * So they are one field, and it is one number in three places: + * + * - **pitch.** `gasBoilScale` is cells per metre. The cloud gets it directly; + * the floor gets `1 / gasBoilScale` as its cell size in metres. The two + * lattices have the same spacing at every moment, including while the slider + * is moving with the clock stopped. + * - **clock.** `gasBoilRate` is pops per second. It is the cloud's base rate + * and the floor's `speed`, and both modules multiply it by + * `global.noiseSpeed` on the way in, so slowing the sandbox down slows both + * by exactly the same factor. + * - **phase.** Both fields hash their cell to a 0..1 id and run + * `fract(t · rate · (0.55 + 0.9 · id) + …)` on `frame.uTime`. Same form, same + * clock, same seed. The cell *ids* cannot be literally identical — one field + * is a 2D Voronoi and the other a 3D warped lattice, and forcing them to + * share would mean editing a shared module every other ability also uses — + * but the distribution, the pitch and the rate are, which is what the eye is + * actually reading. + * + * And the floor quad is **never yawed with the cast**. It is the one ground + * field in the project that ignores `this.direction`, because the cloud's boil + * is sampled in *world* space: turn the floor and the two lattices slide out of + * register, and "that blister burst and that bubble popped" becomes "some things + * happened". + * + * **The bridge that makes it visible.** A raymarched pop and a shaded blister + * are both events the eye can miss. So the CPU walks the same lattice, with the + * same two hashes transcribed above, and asks each cell whether it crossed + * `BURST_PHASE` this frame. Where one did, a puff of gas leaves the floor and a + * few droplets are thrown — *at that cell's jittered site*, on that frame. That + * is not decoration; it is the only thing in the cast that says out loud that + * the two fields are the same field. + * + * Three beats, all of them slow: + * + * 1. **seep** (travel) — a `GroundMode.POOL` stain opens on the target ahead of + * the front and the first breath of gas creeps out of it. + * 2. **boil** (impact, `lifetime` long) — the dome inflates, the blisters come + * up through the stain, and it stands there having events in it. + * 3. **burst** (fade, `fadeTime` long) — the footprint is eaten back from its + * rim, and the dome climbs and spreads as it thins. + * + * Three draw calls: one raymarched dome, two ground quads. Everything else is + * shared GPU particles. + * + * **The rule that makes the editor work.** A cast captures one dice roll + * (`_seed`) and one timestamp (`_boilClock`, last frame's `frame.uTime`, which + * is how the burst test knows what "this frame" means). Not one metre, radian, + * second or colour. Pause with **P** mid-boil and drag `gasBoilScale`: the + * bubbles in the cloud and the blisters on the floor re-pitch together, because + * neither of them ever wrote the pitch down. + */ +export class PlaguebloomAbility extends Ability { + constructor(context) { + super('plaguebloom', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* ---- the stain, first because it is underneath everything ---- */ + // Alpha-blended, not additive: sick ground is *darker* than clean ground, + // and an additive pool lights the flagstones it is supposed to be soaking. + this.stain = new GroundField(this.group, { + mode: GroundMode.POOL, + additive: false, + renderOrder: 5, + name: 'Plaguebloom:Stain' + }); + + /* ---- the blisters ---- */ + this.pustules = new GroundField(this.group, { + mode: GroundMode.PUSTULE, + additive: false, + renderOrder: 6, + name: 'Plaguebloom:Pustules' + }); + + /* ---- the cloud ---- */ + // A DOME rather than a SPHERE because the gas sits *on* the floor: a sphere + // would have to be half-buried to look right, and every ray that entered + // through the buried half would spend its step budget under the ground. + this.cloud = new VolumeHull({ + hull: HullShape.DOME, + medium: Medium.GAS_BOIL, + prefix: 'gas', + maxSteps: 48, + renderOrder: 12 + }); + this.group.add(this.cloud.mesh); + + /** The one dice roll a cast makes. Decorrelates two blooms. */ + this._seed = 0; + /** Last frame's `frame.uTime`. A timestamp, not a duration — see the header. */ + this._boilClock = 0; + /** Blisters the CPU is currently watching. HUD readout only. */ + this._cells = 0; + /** Vents that fired this frame; drives the light punch. */ + this._vents = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + /** + * The capacities below **are** the I7 guarantee, not a guess at one. A + * particle system is a ring buffer: emitting past capacity recycles the + * oldest slot, so 720 + 480 + 300 is a hard ceiling of 1500 live particles + * for this cast no matter how far the vent sliders are pushed. + */ + + // The gas a blister lets go of. Non-additive, because it has to occlude the + // cloud behind it or it is not gas, it is a glow. + this.puffs = particles.get('plaguebloom.puff', { + capacity: 720, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 0.9 + }); + this.puffs.uniforms.uDrag.value = 1.9; + this.puffs.uniforms.uEndSize.value = 3.4; + this.puffs.uniforms.uSizeIn.value = 0.1; + this.puffs.uniforms.uFadeIn.value = 0.14; + this.puffs.uniforms.uFadeOut.value = 0.35; + + // What comes out with it. Lit chips rather than additive dots: this is wet + // matter, and the one thing it must not do is glow. + this.spatter = particles.get('plaguebloom.spatter', { + capacity: 480, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.2 + }); + this.spatter.uniforms.uDrag.value = 0.4; + this.spatter.uniforms.uEndSize.value = 0.55; + this.spatter.uniforms.uFadeOut.value = 0.62; + + // Spores lifting out of the cloud and dying at head height. + this.spores = particles.get('plaguebloom.spore', { + capacity: 300, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.5 + }); + this.spores.uniforms.uDrag.value = 1.5; + this.spores.uniforms.uEndSize.value = 0.4; + this.spores.uniforms.uSizeIn.value = 0.14; + this.spores.uniforms.uFadeIn.value = 0.2; + this.spores.uniforms.uFadeOut.value = 0.45; + + this.sporeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Blisters inside the CPU's window. Not a draw count — the field is one quad. */ + get instanceCount() { + return this._cells; + } + + /** The bloom stands for a long time. That is the ability. */ + get impactDuration() { + return Math.max(0.05, settings.plaguebloom.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.plaguebloom.fadeTime); + } + + /** + * A slow swell rather than a flicker. + * + * Nothing about this ability is fast, and the default shimmer's 9.3 Hz beat + * makes the light read as electrical. One sine at `lightBreathSpeed` is the + * cloud breathing; the pops arrive on top of it through `lightBoost`. + */ + lightShimmer() { + const c = settings.plaguebloom; + return 1 + c.lightBreath * Math.sin(this.age * c.lightBreathSpeed * TAU); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The footprint's centre: the aimed point, on the floor. */ + _footprint(out) { + return this.pointAt(1, out); + } + + /** + * Metres between blisters — and between bubbles. + * + * `gasBoilScale` is authored as *cells per metre* because that is what the + * volume's shader wants; the floor wants a pitch. One reciprocal, resolved + * here, every frame. This function existing at all is the sync. + */ + _cellPitch() { + return 1 / Math.max(0.05, settings.plaguebloom.gasBoilScale); + } + + /** Pops per second, after the global clock. Both fields are handed this. */ + _boilRate() { + return settings.plaguebloom.gasBoilRate * settings.global.noiseSpeed; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sporeEmitter.reset(); + this.stain.clearMarks(); + this.pustules.clearMarks(); + + this._seed = Math.random() * 100; + // Seeded to *now* so the first frame of a cast cannot discover four hundred + // blisters that "burst" while the ability was sitting in the pool. + this._boilClock = frame.uTime.value; + this._cells = 0; + this._vents = 0; + + this._sync(0, 0, 0); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into all three surfaces. + * + * @param {number} seep 0..1 the stain spreading, during the travel beat + * @param {number} boil 0..1 the dome inflated and the blisters up + * @param {number} disperse 0..1 the whole thing letting go + */ + _sync(seep, boil, disperse) { + const c = settings.plaguebloom; + const g = settings.global; + + this._footprint(_centre); + + // How far open the cloud is. The seep only ever buys it half, so the dome + // still visibly inflates when the cast lands even if the travel was long. + const open = Math.max(seep * 0.5, boil); + const gone = Easing.inQuad(saturate(disperse)); + const density = (c.seepDensity * seep * (1 - boil) + boil) * (1 - gone); + + /* ---- the cloud ---- */ + const spread = c.cloudSpread * (0.5 + 0.5 * open) + c.disperseSpread * gone; + const wide = Math.max(0.05, c.zoneRadius * spread); + const tall = Math.max(0.05, c.cloudHeight * (0.28 + 0.72 * open)); + _pos.set(_centre.x, c.cloudLift + c.disperseLift * gone, _centre.z); + this.cloud + .place(_pos) + .setSize(wide, tall, wide) + .setFade(density) + .sync(c, g); + + /* ---- the stain ---- */ + const stainGrow = Math.max(seep, boil); + _stain.centre = _centre; + // Deliberately world-aligned. See the class header: yaw the floor and the + // lattice above it no longer lines up with the lattice below it. + _stain.yaw = 0; + _stain.height = c.stainHeight; + _stain.radius = Math.max(0.05, c.zoneRadius * c.stainSpread); + _stain.grow = stainGrow; + _stain.recede = gone * c.stainRecede; + _stain.seed = this._seed; + _stain.edge = c.stainEdge; + _stain.ragged = c.stainRagged; + _stain.raggedScale = c.stainRaggedScale; + _stain.warp = c.stainWarp; + _stain.relief = c.stainRelief; + _stain.normalStep = c.stainNormalStep; + _stain.ambient = c.stainAmbient; + _stain.wrap = c.stainWrap; + _stain.specular = c.stainSpecular; + _stain.gloss = c.stainGloss; + _stain.parallax = c.stainParallax; + _stain.cell = c.stainCell; + _stain.thickness = c.stainThickness; + _stain.lift = c.stainLift; + _stain.depth = c.stainDepth; + _stain.sharp = c.stainSharp; + _stain.detail = c.stainDetail; + _stain.speed = c.stainSpeed; + _stain.flow = c.stainFlow; + _stain.windAngle = c.stainWindAngle; + _stain.additive = false; + _stain.emissive = c.stainEmissive; + _stain.opacity = c.stainOpacity; + _stain.depthFade = c.stainDepthFade; + _stain.fade = 1; + _stain.colorBase = c.stainColorBase; + _stain.colorEdge = c.stainColorEdge; + _stain.colorGlow = c.stainColorGlow; + _stain.colorDeep = c.stainColorDeep; + _stain.noiseStrength = g.noiseStrength; + _stain.noiseFrequency = g.noiseFrequency; + _stain.noiseSpeed = g.noiseSpeed; + _stain.opacityScale = g.opacity; + this.stain.update(_stain); + // At `grow` near zero the front sits at the origin and the ragged wander + // still pushes a patch of the field inside it — a smear of pool under the + // caster's aim before anything has happened. Hiding the quad outright is the + // honest fix; feathering the front to nothing just moves the smear. + this.stain.setVisible(stainGrow > 0.004); + + /* ---- the blisters ---- */ + _pustule.centre = _centre; + _pustule.yaw = 0; + _pustule.height = c.pustuleHeight; + _pustule.radius = Math.max(0.05, c.zoneRadius); + _pustule.grow = boil; + _pustule.recede = gone; + _pustule.seed = this._seed; + _pustule.edge = c.pustuleEdge; + _pustule.ragged = c.pustuleRagged; + _pustule.raggedScale = c.pustuleRaggedScale; + _pustule.warp = c.pustuleWarp; + _pustule.relief = c.pustuleRelief; + _pustule.normalStep = c.pustuleNormalStep; + _pustule.ambient = c.pustuleAmbient; + _pustule.wrap = c.pustuleWrap; + _pustule.specular = c.pustuleSpecular; + _pustule.gloss = c.pustuleGloss; + _pustule.parallax = c.pustuleParallax; + // The two shared numbers. Everything above this pair is the floor's own. + _pustule.cell = this._cellPitch(); + _pustule.speed = c.gasBoilRate; + _pustule.cellJitter = c.pustuleJitter; + _pustule.seam = c.pustuleSeam; + // PUSTULE ignores `thickness` in its height field; it survives only as one + // of the terms that pads the quad out past the mark, and the seam is the + // right scale for that. + _pustule.thickness = c.pustuleSeam; + _pustule.lift = c.pustuleLift; + _pustule.depth = c.pustuleDepth; + _pustule.sharp = c.pustuleSharp; + _pustule.detail = c.pustuleDetail; + _pustule.additive = false; + _pustule.emissive = c.pustuleEmissive; + _pustule.opacity = c.pustuleOpacity; + _pustule.depthFade = c.pustuleDepthFade; + _pustule.fade = 1; + _pustule.colorBase = c.pustuleColorBase; + _pustule.colorEdge = c.pustuleColorEdge; + _pustule.colorGlow = c.pustuleColorGlow; + _pustule.colorDeep = c.pustuleColorDeep; + _pustule.noiseStrength = g.noiseStrength; + _pustule.noiseFrequency = g.noiseFrequency; + _pustule.noiseSpeed = g.noiseSpeed; + _pustule.opacityScale = g.opacity; + this.pustules.update(_pustule); + this.pustules.setVisible(boil > 0.004); + + /* ---- the particle systems ---- */ + this.puffs.setGradient( + getColor(c.colorPuffA), + getColor(c.colorPuffB), + getColor(c.colorPuffC), + getColor(c.colorPuffD) + ); + this.puffs.uniforms.uGravity.value.set(0, c.puffRise, 0); + this.puffs.uniforms.uSizeScale.value = c.puffSize * g.particleSize; + this.puffs.uniforms.uLifeScale.value = c.puffLifetime * 0.5 * g.particleLifetime; + this.puffs.uniforms.uSpeedScale.value = c.puffSpeed * g.particleSpeed; + this.puffs.uniforms.uOpacity.value = c.puffOpacity * g.opacity; + this.puffs.uniforms.uTurbulence.value = c.puffTurbulence * g.turbulence; + + this.spatter.setGradient( + getColor(c.colorSpatterA), + getColor(c.colorSpatterB), + getColor(c.colorSpatterC), + getColor(c.colorSpatterD) + ); + this.spatter.uniforms.uGravity.value.set(0, c.spatterGravity, 0); + this.spatter.uniforms.uSizeScale.value = c.spatterSize * g.particleSize * 7; + this.spatter.uniforms.uLifeScale.value = c.spatterLifetime * 0.5 * g.particleLifetime; + // On the uniform rather than on the emit, so dragging the slider on a + // paused frame re-speeds the droplets that are already in the air. `emit` + // then hands over a unit speed and the gradient does the metres. + this.spatter.uniforms.uSpeedScale.value = c.spatterSpeed * g.particleSpeed; + this.spatter.uniforms.uOpacity.value = g.opacity; + + this.spores.setGradient( + getColor(c.colorSporeA), + getColor(c.colorSporeB), + getColor(c.colorSporeC), + getColor(c.colorSporeD) + ); + this.spores.uniforms.uGravity.value.set(0, c.sporeRise, 0); + this.spores.uniforms.uSizeScale.value = c.sporeSize * g.particleSize * 7; + this.spores.uniforms.uLifeScale.value = c.sporeLifetime * 0.5 * g.particleLifetime; + this.spores.uniforms.uSpeedScale.value = c.sporeSpeed * g.particleSpeed; + this.spores.uniforms.uOpacity.value = g.opacity; + this.spores.uniforms.uGlow.value = c.pustuleEmissive * g.glow; + this.spores.uniforms.uTurbulence.value = c.sporeTurbulence * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* The bridge: the CPU walking the shader's own lattice */ + /* ------------------------------------------------------------------ */ + + /** + * Fire a vent wherever a blister crossed `BURST_PHASE` since the last frame. + * + * No per-cell state is kept and none is needed. A cell's raw phase is + * `t · rate + bias`, strictly increasing, so it crossed `k + BURST_PHASE` in + * this interval exactly when `floor(raw - BURST_PHASE)` stepped — one integer + * compare per cell against last frame's timestamp, and a paused clock + * (`now === prev`) therefore fires nothing at all, which is correct: a paused + * blister has not burst. + * + * The cell walk mirrors `GroundField`'s `gfCells`: the jittered site of cell + * `n` is `n + mix(0.5, hash21(dot(n, (7.13, 113.17))), jitter)` and its id is + * `hash11(dot(n, (31.7, 57.1)))`. Because the offset is always inside the unit + * cell, the nearest site to a site is itself — so evaluating the Voronoi *at* + * the site is guaranteed to return that cell, and the CPU does not have to run + * the 3×3 neighbourhood the shader does. + * + * @param {number} boil 0..1 — nothing vents before the blisters are up + */ + _vent(boil) { + this._vents = 0; + this._cells = 0; + + const c = settings.plaguebloom; + const g = settings.global; + const now = frame.uTime.value; + const prev = this._boilClock; + // Advanced unconditionally, before either early exit. Leaving it behind + // while the blisters are down means the frame they come up discovers every + // burst that "happened" in the meantime and vents the whole field at once. + this._boilClock = now; + if (boil <= 0.02) return; + if (!(now > prev)) return; // a zero-length frame; nothing has happened + + const cell = this._cellPitch(); + const rateBase = this._boilRate(); + const jitter = c.pustuleJitter; + const share = c.ventShare; + // The blisters only exist inside the grown front, so neither does a vent. + const radius = Math.max(0.05, c.zoneRadius) * boil; + const radiusSq = (radius + cell) * (radius + cell); + const span = Math.min(MAX_CELL_SPAN, Math.ceil(radius / cell) + 1); + + this._footprint(_centre); + + const puffCount = Math.max(0, Math.round(c.ventPuffs * g.particleCount)); + const spatterCount = Math.max(0, Math.round(c.ventSpatter * g.particleCount)); + const time = now; + + for (let j = -span; j <= span; j++) { + for (let i = -span; i <= span; i++) { + // Integer reject first: it is two multiplies against two hashes. + const cx = i * cell; + const cz = j * cell; + if (cx * cx + cz * cz > radiusSq) continue; + this._cells++; + + const id = hash11(dot2(i, j, K_ID_X, K_ID_Y)); + // Only the big ones vent. Reading the gate off the cell's own id rather + // than off a die means the *same* blisters vent every cycle, which is + // what makes the field read as terrain rather than as static. + if (id > share) continue; + + const rate = rateBase * (0.55 + 0.9 * id); + const bias = id * 7.13 + this._seed - BURST_PHASE; + if (Math.floor(now * rate + bias) <= Math.floor(prev * rate + bias)) continue; + + // Past the frame's cap the walk keeps counting but stops emitting. The + // alternative — breaking out of both loops — truncates `_cells`, and a + // HUD readout that flickers with the vent rate is worse than useless. + if (this._vents >= MAX_VENTS_PER_FRAME) continue; + + hash21(dot2(i, j, K_OFF_X, K_OFF_Y)); + const sx = (i + lerp(0.5, _h21x, jitter)) * cell; + const sz = (j + lerp(0.5, _h21y, jitter)) * cell; + if (sx * sx + sz * sz > radius * radius) continue; + + _pos.set(_centre.x + sx, c.pustuleLift * 0.5, _centre.z + sz); + + if (puffCount > 0) { + _emit.position = _pos; + _emit.radius = c.ventRadius; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = 1; // metres/second live on uSpeedScale — see _sync() + _emit.speedVariance = 0.6; + _emit.spread = 0.75; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.6; + _emit.sizeVariance = 0.5; + _emit.life = c.puffLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.25; + _emit.tint = null; + _emit.time = time; + this.puffs.emit(puffCount, _emit); + } + + if (spatterCount > 0) { + _emit.position = _pos; + _emit.radius = c.ventRadius * 0.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = 1; // ... and the same for the droplets + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.spatterLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.spatter.emit(spatterCount, _emit); + } + + this._vents++; + } + } + + this.lightBoost = Math.min(this.lightBoost + this._vents * c.ventLight, c.lightIntensity); + } + + /** The spores lifting out of the standing cloud. */ + _sporeFx(dt, boil) { + const c = settings.plaguebloom; + const g = settings.global; + const count = Math.round(this.sporeEmitter.tick(dt, c.sporeRate * boil) * g.particleCount); + if (count <= 0) return; + + this._footprint(_centre); + _pos.set(_centre.x, c.cloudHeight * c.sporeCeiling * 0.5, _centre.z); + + _emit.position = _pos; + _emit.radius = c.zoneRadius * c.sporeSpread; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = 1; // ... and the spores + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.sporeLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.spores.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(_dt) { + const c = settings.plaguebloom; + this._sync(this.u, 0, 0); + // The light rides the front on its way out, at head height rather than on + // the floor — a cloud lights from inside itself. + this.position.y = c.lightHeight; + // The clock still has to be walked forward or the first frame of the boil + // discovers every burst that happened during the travel all at once. + this._boilClock = frame.uTime.value; + } + + onImpact() { + // Deliberately nothing loud. There is no shockwave, no flash and no shake in + // this ability: it is a disease, and the moment it lands should be quieter + // than the moment it starts working. The bloom's whole read is the six + // seconds after this. + this._boilClock = frame.uTime.value; + } + + /** + * @param {number} dt seconds + * @param {number} t 0..1 through the boil, then 1..2 through the disperse + */ + onFade(dt, t) { + const c = settings.plaguebloom; + const held = t <= 1; + + // Inflation is measured in *seconds* against `riseTime`, not as a fraction + // of the impact phase: dragging `lifetime` from two seconds to twelve should + // make the bloom last longer, not make it inflate six times more slowly. + const boil = held + ? Easing.outQuad(saturate((t * this.impactDuration) / Math.max(0.01, c.riseTime))) + : 1; + const disperse = held ? 0 : Math.pow(saturate(t - 1), Math.max(0.05, c.disperseCurve)); + + this._sync(1, boil, disperse); + this._vent(boil * (1 - disperse)); + this._sporeFx(dt, boil * (1 - disperse)); + + this._footprint(this.position); + this.position.y = c.lightHeight; + } + + onDestroy() { + this.cloud.setFade(0); + this.stain.setVisible(false); + this.pustules.setVisible(false); + this._cells = 0; + this._vents = 0; + } + + dispose() { + this.cloud.dispose(); + this.stain.dispose(); + this.pustules.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/blood/SanguinePactAbility.js b/src/abilities/blood/SanguinePactAbility.js new file mode 100644 index 0000000..872d2b1 --- /dev/null +++ b/src/abilities/blood/SanguinePactAbility.js @@ -0,0 +1,770 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { FilamentPaths, filamentLook } from '../../vfx/FilamentPaths.js'; +import { createBeadOrbitMaterial, createBeadGeometry, MAX_BEADS } from '../../materials/BeadOrbitMaterial.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing } from '../../utils/math.js'; + +/** Samples along one filament. The ceiling on how fine a kink can be. */ +const NODES = 64; + +/** Hard ceiling on filaments across all three roles. */ +const MAX_FILAMENTS = 44; + +/** The three role slots, in the order `FilamentPaths` truncates them. */ +const ROLE_THREADS = 0; +const ROLE_RIM = 1; +const ROLE_LEASH = 2; + +const TAU = Math.PI * 2; + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope (I3). Filled and consumed inside one call. */ +/* ---------------------------------------------------------------- */ + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _hand = new Vector3(); +const _a = new Vector3(); +const _b = new Vector3(); + +const _pool = groundFieldParams(); +const _look = filamentLook(); +/** Handed to the bead material each frame — beats and a seed, never a metre. */ +const _bead = { centre: new Vector3(), age: 0, seed: 0, count: 0, climb: 0, seal: 0, reveal: 0, fade: 1 }; +_pool.centre = _centre; + +/** + * SANGUINE PACT — a bargain sealed on a circle. + * + * Four beats: the pool **draws itself**, the column **rises**, the beads + * **climb**, and the seal **snaps**. + * + * ## The trick — the beads are on real orbits + * + * Around the pool, beads of blood climb in genuine three-dimensional orbits: + * instanced spheres on **inclined ellipses**, each with its own ascending node + * and its own tilt, so at any instant some of them are in front of the mist + * column and the rest are behind it, correctly occluded because they are solid + * bodies writing depth. The whole thing lives in + * `materials/BeadOrbitMaterial.js` and its header is worth reading: the version + * this replaced was a ring of billboards in the ground plane, and that version + * had no perspective in it at all — nothing ever passed behind anything, so the + * ring sat on the image instead of in the room and collapsed to a line the + * moment the camera dropped. + * + * When the pact seals, one uniform — `uSeal` — takes every tilt to zero, pulls + * every semi-major axis onto the rim radius, takes the eccentricity out of the + * ellipses and slides each bead onto an evenly spaced slot. Four changes on one + * clock, which is why it reads as a single event: the orbits *flatten into the + * ring plane* and the beads merge into a rim. + * + * ## The other three modules + * + * - **`GroundField(POOL)`** draws itself. Its growth front is a signed distance + * warped in the plane — never on `atan(y, x)`, which hands every radius along + * a bearing the same value and opens the pool as a star with dead-straight + * arms. The meniscus (`poolThickness`) is the read: surface tension pulls + * blood *up* the last few centimetres before the edge, and without that lip a + * pool is a coloured disc lying on a floor. + * - **`VolumeHull(CYLINDER, MIST)`** is the column, and the roster line that + * says *the mist is the only soft thing in it* is the art direction for the + * whole cast. It is barely absorbing and strongly forward-scattering; + * `mistAnisotropy` is what makes it mist rather than fog, and taking it to + * zero turns the column into a grey pipe. + * - **`FilamentPaths`** carries three roles on one instanced strip and costs + * two draw calls for all of them: `ORBIT` threads climbing with the beads, + * `RIM` arcs travelling round the sealed ring, and the `LINE` leash that runs + * out from the caster's hand while the pact is being offered. Setting a + * role's count to zero retires it outright, which is how the leash vanishes + * on the frame the circle takes over. + * + * ## `zoneRadius` is the promise + * + * The aim indicator measures out a circle before the click, and five things + * keep it: the pool's footprint, the column's radius, the beads' mean orbit, + * the sealed rim and the ring of arcs are each that radius times their own + * fraction. Drag it on a standing pact and all five re-scale together — the + * sanctioned kind of shared value, because the sharing *is* the design. + * + * ## What a cast captures + * + * One number — `_seed` — and timestamps. Every metre, radian and second is + * re-resolved from `settings.sanguinepact` inside the update loop, on a + * zero-length frame included. Pause with **P** mid-hold and drag `orbitTilt`: + * every orbital plane re-inclines under the paused clock. + */ +export class SanguinePactAbility extends Ability { + constructor(context) { + super('sanguinepact', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* ---- the pool ---- */ + // Non-additive: a pool of blood is darker than the floor it lies on, and an + // additive one can only ever be a lit patch of floor. + this.pool = new GroundField(this.group, { + mode: GroundMode.POOL, + additive: false, + depthTest: true, + renderOrder: 5, + name: 'SanguinePact:pool' + }); + + /* ---- the mist column ---- */ + // A CYLINDER stands on the floor with its local +Y up, which is what a + // column wants; `place()` only ever applies yaw, so it cannot tip off the + // ground when the caster is facing an odd direction. + this.column = new VolumeHull({ + hull: HullShape.CYLINDER, + medium: Medium.MIST, + prefix: 'mist', + maxSteps: 40, + renderOrder: 12, + seed: Math.random() * 97 + }); + this.group.add(this.column.mesh); + + /* ---- the beads ---- */ + this.beadGeometry = createBeadGeometry(MAX_BEADS, 1); + this.beadMaterial = createBeadOrbitMaterial(); + this.beads = new Mesh(this.beadGeometry, this.beadMaterial); + this.beads.name = 'SanguinePact:beads'; + this.beads.frustumCulled = false; + this.beads.matrixAutoUpdate = false; + this.beads.layers.set(LAYER.VFX); + this.beads.renderOrder = 10; + this.group.add(this.beads); + + /* ---- the filaments ---- */ + this.paths = new FilamentPaths(this.group, { + samples: NODES, + capacity: MAX_FILAMENTS, + renderOrder: 11 + }); + + /** Re-rolled per cast so no two pacts draw the same orbits. */ + this._seed = 0; + /** Filaments and beads drawn last frame — the HUD readout. */ + this._live = 0; + /** Whether the stain has been laid; one per cast, at the seal. */ + this._stained = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // Motes: hard flecks of blood carried up by the column. Not additive — + // additive motes over a dark red mist are pink sparks, and this cast has no + // sparks in it. + this.motes = particles.get('sanguinepact.motes', { + capacity: 2000, + shape: ParticleShape.SOFT, + additive: false, + curl: true, + lit: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.1; + this.motes.uniforms.uEndSize.value = 0.3; + this.motes.uniforms.uSizeIn.value = 0.08; + this.motes.uniforms.uFadeIn.value = 0.12; + this.motes.uniforms.uFadeOut.value = 0.4; + + // Drips: what the beads shed on the way round. Falls under gravity and + // lands back in the pool. + this.drips = particles.get('sanguinepact.drips', { + capacity: 1200, + shape: ParticleShape.SOFT, + additive: false, + lit: true, + softFade: 0.25 + }); + this.drips.uniforms.uDrag.value = 0.5; + this.drips.uniforms.uEndSize.value = 0.5; + this.drips.uniforms.uSizeIn.value = 0.03; + this.drips.uniforms.uFadeIn.value = 0.05; + this.drips.uniforms.uFadeOut.value = 0.45; + + this.moteEmitter = new RateEmitter(); + this.dripEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + /** The column stands and the beads climb. */ + get impactDuration() { + return Math.max(0.05, settings.sanguinepact.holdTime * settings.global.lifetime); + } + + /** Then the pact seals and goes out. */ + get fadeDuration() { + return Math.max(0.05, settings.sanguinepact.sealTime); + } + + /** + * A pact does not gutter. Two slow beats a second apart, so the light pulses + * like something breathing rather than like something electrical. + */ + lightShimmer() { + return 0.85 + 0.15 * Math.sin(this.age * 1.9) * Math.sin(this.age * 0.7); + } + + /* ------------------------------------------------------------------ */ + /* The beats — pure functions of the phase clock and live settings */ + /* ------------------------------------------------------------------ */ + + /** 0..1 how far the pact has run out to the circle. */ + _draw() { + return this.phase === AbilityPhase.TRAVEL ? this.u : 1; + } + + /** 0..1 through the hold. */ + _hold() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.IMPACT) return saturate(this.impactTime / this.impactDuration); + return 1; + } + + /** 0..1 through the seal phase. */ + _sealPhase() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / this.fadeDuration); + } + + /** + * 0..1 how flat the orbits are. + * + * Snaps over the first `sealSnap` of the seal phase and then holds at 1 — + * a fraction of the phase rather than a duration in seconds, so shortening + * `sealTime` tightens the snap along with everything else instead of leaving + * a beat that no longer fits inside its own phase. + */ + _seal() { + const c = settings.sanguinepact; + return Easing.outCubic(saturate(this._sealPhase() / Math.max(0.02, c.sealSnap))); + } + + /** 0..1 how far the beads have climbed the column. */ + _climb() { + const c = settings.sanguinepact; + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.IMPACT) { + return Easing.outCubic(saturate(this._hold() / Math.max(0.02, c.climbTime))); + } + return 1; + } + + /** 0..1 how far the mist column has risen. */ + _rise() { + const c = settings.sanguinepact; + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.IMPACT) { + return Easing.outQuad(saturate(this._hold() / Math.max(0.02, c.columnTime))); + } + return 1; + } + + /** The footprint the indicator measured out, metres. */ + get radius() { + return Math.max(0.05, settings.sanguinepact.zoneRadius); + } + + /** The centre of the pact — the far end of the aimed line, on the floor. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** Where the leash leaves the caster, in world space. */ + _handPoint(out) { + const c = settings.sanguinepact; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.dripEmitter.reset(); + this._stained = false; + + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + + this._sync(1); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve the pool, the column, the beads and the three filament roles + * from live settings and push them into their modules. + * + * @param {number} fade 1 while the pact stands, ramping to 0 as it goes out + */ + _sync(fade) { + const c = settings.sanguinepact; + const g = settings.global; + + const zone = this.radius; + const draw = this._draw(); + const climb = this._climb(); + const rise = this._rise(); + const seal = this._seal(); + + this._centrePoint(_centre); + + /* ---------------- the pool ---------------- */ + _pool.yaw = Math.atan2(this.direction.x, this.direction.z); + _pool.height = c.poolHeight; + _pool.radius = zone * c.poolScale; + // The pool draws itself: the growth front is the travel clock, so the SDF + // spreads outward on the same beat the cast is arriving on. + _pool.grow = draw; + // At the very end of the seal the pool is pulled back into the rim rather + // than dimmed — a pool that fades out reads as a projector being switched + // off, and one that shrinks reads as liquid going somewhere. + _pool.recede = Easing.inQuad(saturate((this._sealPhase() - c.sealHold) / Math.max(0.02, 1 - c.sealHold))); + _pool.fade = fade; + _pool.seed = this._seed; + _pool.edge = c.poolEdge; + _pool.ragged = c.poolRagged; + _pool.raggedScale = c.poolRaggedScale; + _pool.warp = c.poolWarp; + _pool.relief = c.poolRelief; + _pool.normalStep = c.poolNormalStep; + _pool.ambient = c.poolAmbient; + _pool.wrap = c.poolWrap; + _pool.specular = c.poolSpecular; + _pool.gloss = c.poolGloss; + _pool.parallax = c.poolParallax; + _pool.cell = c.poolCell; + _pool.lift = c.poolLift; + _pool.depth = c.poolDepth; + _pool.thickness = c.poolThickness; + _pool.detail = c.poolDetail; + _pool.speed = c.poolSpeed; + _pool.flow = c.poolFlow; + _pool.windAngle = c.poolWindAngle; + _pool.additive = false; + _pool.emissive = c.poolEmissive; + _pool.opacity = c.poolOpacity; + _pool.depthFade = c.poolDepthFade; + _pool.colorBase = c.colorPoolBase; + _pool.colorEdge = c.colorPoolEdge; + _pool.colorGlow = c.colorPoolGlow; + _pool.colorDeep = c.colorPoolDeep; + _pool.noiseStrength = g.noiseStrength; + _pool.noiseFrequency = g.noiseFrequency; + _pool.noiseSpeed = g.noiseSpeed; + _pool.opacityScale = g.opacity; + this.pool.setVisible(fade > 0.002 && _pool.grow > 0.002); + this.pool.update(_pool); + + /* ---------------- the mist column ---------------- */ + // The hull must be the smallest shape that still contains the field: the + // radius is the footprint's own fraction and the height is the rise beat, + // so the proxy grows with the smoke rather than standing at full size while + // an empty march crosses vacuum above it. + const columnRadius = Math.max(0.05, zone * c.mistScale); + this.column + .place(_centre, this.direction) + .setSize(columnRadius, Math.max(0.05, c.mistHeight * rise), columnRadius) + .setFade(fade * rise) + .sync(c, g); + + /* ---------------- the beads ---------------- */ + _bead.centre.copy(_centre); + _bead.age = this.age; + _bead.seed = this._seed; + _bead.count = Math.max(0, Math.min(MAX_BEADS, Math.round(c.beadCount * g.particleCount))); + _bead.climb = climb; + _bead.seal = seal; + // The beads climb out of the pool as it draws, and go out with the fade. + _bead.reveal = Math.min(draw, fade); + _bead.fade = fade; + this.beadMaterial.userData.sync(_bead); + this.beadGeometry.instanceCount = _bead.count; + this.beads.visible = _bead.count > 0 && fade > 0.002; + + /* ---------------- the filaments ---------------- */ + const threads = this.paths.role(ROLE_THREADS); + const rim = this.paths.role(ROLE_RIM); + const leash = this.paths.role(ROLE_LEASH); + + // Threads climb with the beads and are retired by the seal — once the ring + // is flat there is nothing left for them to wind around. + threads.count = Math.round(c.threadCount * (1 - seal)); + _a.copy(_centre).setY(c.beadClimbBase); + _b.copy(_a).setY(_a.y + c.threadPoleHeight); + threads + .orbit( + _a, + _b, + zone * c.threadRadiusScale, + c.threadArc, + c.threadSpin, + c.threadWobble, + c.threadTilt, + c.threadTiltSpread, + c.threadRadiusJitter + ) + .style(c.threadKink, c.threadWidthScale, c.threadDim * climb, 1) + .ends(1, 1, 1, 1) + .draw(2, 0.1, -1e4, 0); + + // Rim arcs are the seal's own signature: they do not exist until it lands. + rim.count = Math.round(c.rimArcs * seal); + _a.copy(_centre).setY(c.rimHeight); + _b.copy(_a).setY(_a.y + 1); + rim + .rim( + _a, + _b, + zone * c.rimScale, + c.rimSpan, + c.rimSpeed, + c.rimArcLift, + c.rimArcJitter, + c.rimArcHug, + this._seed + ) + // `groundDamp` at 0.3: a kink with a free y buries half of a filament + // running flat, and the effect reads as a broken dotted line. + .style(c.rimKink, c.rimWidthScale, c.rimDim, 0.3) + .ends(1, 1, 1, 1) + .draw(2, 0.08, c.rimHeight * 0.25, 0.6); + + // The leash exists only while the pact is being offered. + const offering = this.phase === AbilityPhase.TRAVEL ? 1 : 0; + leash.count = Math.round(c.leashCount * offering); + this._handPoint(_hand); + _a.copy(_centre).setY(c.poolHeight); + leash + .line( + _hand, + _a, + c.leashSag, + c.leashSpreadNear, + c.leashSpread, + c.leashSpreadCurve, + c.leashTwist, + c.leashTwistSpeed, + c.leashConverge + ) + .style(c.leashKink, c.leashWidthScale, c.leashDim, 0.6) + .ends(0, 1, 0, 1) + .draw(draw, 0.12, 0, 1.2); + + _look.width = c.threadWidth; + _look.glowWidth = c.threadGlowWidth; + _look.glowOpacity = c.threadGlowOpacity; + _look.jitter = c.threadJitter; + _look.jitterScale = c.threadJitterScale; + _look.octaves = c.threadOctaves; + _look.jitterFalloff = c.threadJitterFalloff; + _look.crawl = c.threadCrawl; + _look.pinch = c.threadPinch; + _look.restrike = c.threadRestrike; + _look.flicker = c.threadFlicker; + _look.flickerSpeed = c.threadFlickerSpeed; + _look.strandFlash = c.threadStrandFlash; + _look.coreSharp = c.threadCoreSharp; + _look.glowFalloff = c.threadGlowFalloff; + _look.softFade = c.threadSoftFade; + _look.opacity = c.threadOpacity; + _look.glow = c.threadGlow; + _look.colorCore = c.colorThreadCore; + _look.colorInner = c.colorThreadInner; + _look.colorOuter = c.colorThreadOuter; + _look.colorHalo = c.colorThreadHalo; + _look.randomness = g.randomness; + _look.noiseStrength = g.noiseStrength; + _look.noiseFrequency = g.noiseFrequency; + _look.noiseSpeed = g.noiseSpeed; + _look.opacityScale = g.opacity; + _look.glowScale = g.glow; + this.paths.sync(_look, fade, this._seed); + this._live = _bead.count + this.paths.liveCount; + + /* ---------------- the particle systems ---------------- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.drips.setGradient( + getColor(c.colorDripA), + getColor(c.colorDripB), + getColor(c.colorDripC), + getColor(c.colorDripD) + ); + this.drips.uniforms.uGravity.value.set(0, c.dripGravity, 0); + this.drips.uniforms.uSizeScale.value = c.dripSize * g.particleSize * 7; + this.drips.uniforms.uLifeScale.value = c.dripLifetime * 0.5 * g.particleLifetime; + this.drips.uniforms.uSpeedScale.value = g.particleSpeed; + this.drips.uniforms.uOpacity.value = g.opacity; + + /* ---------------- where the light stands ---------------- */ + // Inside the column, a third of the way up: at the floor it lights only the + // pool, and at the top it lights nothing but air. + this.position.copy(_centre).setY(c.mistHeight * rise * 0.34 + c.poolHeight); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Motes lifted by the column and drips shed by the beads. + * + * @param {number} dt seconds + * @param {number} scale 0..1 — thinned as the pact goes out + */ + _pactFx(dt, scale) { + const c = settings.sanguinepact; + const g = settings.global; + const time = frame.uTime.value; + const zone = this.radius; + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + // Off the pool's surface, anywhere inside the meniscus. Emitted from the + // floor rather than from the column's middle: the column is what carries + // them, not what makes them. + _pos.copy(_centre).setY(c.poolHeight + 0.05); + _emit.position = _pos; + _emit.radius = zone * c.poolScale * 0.85; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.35; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + const dripCount = Math.round(this.dripEmitter.tick(dt, c.dripRate * scale) * g.particleCount); + if (dripCount > 0) { + // From a point on the mean orbit, at a height inside the climbing band — + // the same shell the beads are on, so a drip looks shed rather than + // spawned. The angle is a dice roll, which is allowed; the radius and the + // height are metres and are resolved here, which is required. + const angle = Math.random() * TAU; + const orbit = zone * c.orbitScale; + _pos.copy(_centre); + _pos.x += Math.cos(angle) * orbit; + _pos.z += Math.sin(angle) * orbit; + _pos.y = c.beadClimbBase + c.beadClimbTop * Math.random() * this._climb(); + _emit.position = _pos; + _emit.radius = c.beadSize * 3; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.dripSpeed; + _emit.speedVariance = 0.6; + _emit.spread = 0.5; + _emit.size = 0.09; + _emit.sizeVariance = 0.6; + _emit.life = c.dripLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0; + _emit.time = time; + this.drips.emit(dripCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + // The pool is still drawing itself, so it is not shedding much yet. + this._pactFx(dt, this._draw() * 0.5); + } + + onImpact() { + // The pact is *offered* here, not sealed — the loud beat is at the seal, so + // this one is deliberately quiet: the leash lets go and the pool rings. + const c = settings.sanguinepact; + const g = settings.global; + + this._centrePoint(_pos); + this.ctx.decals.spawn(DecalType.RIPPLE, _pos, { + radius: this.radius * c.poolScale * 1.1, + life: 0.9, + width: 0.08, + intensity: 0.55, + colorA: getColor(c.colorPoolEdge), + colorB: getColor(c.colorPoolBase) + }); + + this.lightBoost = c.lightIntensity * 0.35 * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 through the hold, then 1..2 through the seal. The pact + // stands at full strength until `sealHold` of the seal phase has gone by, + // then goes out — a bargain does not dim while it is being struck. + const c = settings.sanguinepact; + const phase = t <= 1 ? 0 : saturate(t - 1); + const out = saturate((phase - c.sealHold) / Math.max(0.02, 1 - c.sealHold)); + const fade = 1 - Easing.inQuad(out); + + this._sync(fade); + + // The one-shot fires on the frame the flattening completes, not on entry to + // the phase: the snap is the *arrival* of the rim, and firing it early puts + // the bang a quarter of a second before the picture. + if (!this._stained && this._seal() > 0.85) { + this._stained = true; + this._sealFx(); + } + + // Loudest while the beads are still climbing, then thinned to nothing. + this._pactFx(dt, t <= 1 ? lerp(0.5, 1, this._climb()) : fade * 0.3); + + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + /** The snap: the rim arrives and the pact is sealed. */ + _sealFx() { + const c = settings.sanguinepact; + const g = settings.global; + const time = frame.uTime.value; + + this._centrePoint(_pos); + _pos.y = c.rimHeight; + + this.ctx.bursts.spawn(BurstMode.WATER, _pos, { + radius: c.sealBurstSize * 0.25, + endRadius: c.sealBurstSize * g.explosionIntensity, + life: 0.55, + intensity: c.sealBurstIntensity, + opacity: 0.8, + fresnel: 1.5, + displace: 0.4, + // Squashed hard: the seal is a ring closing in a plane, so the shell that + // marks it has to be a disc and not a ball. + squash: 0.3, + colorA: getColor(c.colorSealA), + colorB: getColor(c.colorSealB), + colorC: getColor(c.colorSealC) + }); + + this._centrePoint(_pos); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.sealShockRadius * g.explosionIntensity, + life: 0.5, + width: 0.06, + intensity: 0.9, + colorA: getColor(c.colorSealShockA), + colorB: getColor(c.colorSealShockB) + }); + + this.ctx.decals.spawn(DecalType.FOAM, _pos, { + radius: c.stainRadius, + life: c.stainLife, + intensity: c.stainIntensity, + colorA: getColor(c.colorStainA), + colorB: getColor(c.colorStainB), + height: 0.013 + }); + + /* drips flung outward off the closing rim */ + _pos.y = c.rimHeight; + _emit.position = _pos; + _emit.radius = this.radius * c.rimScale; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dripSpeed * 3.2; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.7; + _emit.life = c.dripLifetime * 1.4; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.drips.emit(Math.round(c.sealDrips * g.particleCount), _emit); + + _emit.speed = c.moteSpeed * 3.5; + _emit.size = 0.08; + _emit.life = c.moteLifetime; + this.motes.emit(Math.round(c.sealMotes * g.particleCount), _emit); + + this.ctx.shake.add( + c.sealShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.sealShakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.sealFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onDestroy() { + this._live = 0; + this._stained = false; + this.pool.setVisible(false); + this.pool.clearMarks(); + this.column.setFade(0); + this.paths.clear(); + this.beads.visible = false; + this.beadGeometry.instanceCount = 0; + } + + dispose() { + this.pool.dispose(); + this.column.dispose(); + this.paths.dispose(); + this.beadGeometry.dispose(); + this.beadMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/chrono/AfterimageAbility.js b/src/abilities/chrono/AfterimageAbility.js new file mode 100644 index 0000000..80242d7 --- /dev/null +++ b/src/abilities/chrono/AfterimageAbility.js @@ -0,0 +1,510 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { + createAfterimageGeometry, + createAfterimageMaterial, + MAX_FINS, + MAX_SNAPS +} from '../../materials/AfterimageMaterial.js'; +import { timeField, timeRegionParams } from '../../vfx/TimeControl.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing } from '../../utils/math.js'; + +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hold = new Vector3(); +const _emit = {}; + +/** + * AFTERIMAGE — N frozen copies of one cast, every one of them still live. + * + * **THE TRICK.** A fan of blades flies down the line, opening as it goes. Every + * `snapGap` seconds it sheds a copy of itself, which stops where it was and + * holds the shape it had at that instant. Behind the body there is then a row + * of frozen moments — a strobe photograph of a single flight, standing in the + * room. And *none of them is a photograph*: each copy is the same closed-form + * body evaluated at a different age, so pausing the sandbox with **P** and + * dragging `bladeLength`, `splay`, `speed` or `snapGap` reshapes and re-places + * all six frozen moments at once, on a frame of zero length. The roster entry + * calls it "invariant I1 turned into an effect", and that is exactly what it + * is: this ability has no content beyond the fact that nothing was captured. + * + * ### How the freeze is expressed + * + * Not as a stored timestamp. Copy `k`'s instant is `k × snapGap` — a **slider** + * — and the shader shows `min(age, k × snapGap)`, so a copy travels with the + * body until its instant arrives and holds thereafter. Nothing is written to a + * buffer during a cast, nothing is captured, and every frozen moment + * re-derives itself from scratch every frame. The version that stamped + * `frame.uTime.value` into an instance attribute at the moment of the freeze + * rendered identically and failed the only test that matters — a paused drag + * on `snapGap` moved nothing, because a captured second is a captured second + * however cheap it was. + * + * ### What a cast does capture + * + * `_seed` (a unitless dice roll for the flight's lateral wander), the count of + * copies that have already crossed their instant (so the shutter-click effects + * fire once each), and one borrowed `TimeRegion`. Nothing else. + * + * ### The stasis bubble + * + * The newest frozen copy carries a `timeField` region at rate 0, so the motes + * the body shed as it passed **stop in the air** around it. That is the only + * part of the ability that is not the ability: a frozen copy that nothing else + * in the frame reacts to reads as a decal of a spell, and one bubble of + * genuinely stopped particles around it is the cheapest possible proof that it + * is a piece of held time. `acquire()` returns `null` when all four slots are + * spoken for (**I6**), so every use of the handle is guarded, and it is taken + * through `this.borrow()` so the base class gives it back however the cast ends + * (**I9**). + * + * ### Draw calls + * + * **One.** Seven copies × four blades is twenty-eight instances of a + * 176-vertex sliver in a single draw, plus two shared particle systems and a + * pooled burst. Against I7's twelve, this is the cheapest slot in the school. + */ +export class AfterimageAbility extends Ability { + constructor(context) { + super('afterimage', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.geometry = createAfterimageGeometry(); + this.material = createAfterimageMaterial(); + + this.mesh = new Mesh(this.geometry, this.material); + // Every instance is placed by the vertex shader from world-space uniforms, + // so the mesh's own transform is meaningless and its bounds are a lie. A + // frustum test against them culls the entire row the moment the camera + // looks past the origin. + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(LAYER.VFX); + this.mesh.renderOrder = 12; + this.group.add(this.mesh); + + /* --- scratch, allocated once (I3) --- */ + this._state = { + origin: new Vector3(), + dir: new Vector3(0, 0, 1), + side: new Vector3(1, 0, 0), + length: 1, + age: 0, + seed: 0, + fade: 1 + }; + this._region = null; + this._regionParams = timeRegionParams(); + /** Copies whose instant has already passed. Counts shutter clicks, nothing else. */ + this._frozen = 0; + /** Copies drawn this frame, live head included — the HUD's readout. */ + this._live = 1; + this._seed = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // What the body sheds as it flies. These are the particles the stasis + // bubble catches, so they are slow and long-lived on purpose: a spark that + // is gone in 200 ms cannot be seen to stop. + this.motes = particles.get('afterimage.motes', { + capacity: 1800, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.45 + }); + this.motes.uniforms.uDrag.value = 1.5; + this.motes.uniforms.uEndSize.value = 0.2; + this.motes.uniforms.uSizeIn.value = 0.08; + this.motes.uniforms.uFadeIn.value = 0.1; + this.motes.uniforms.uFadeOut.value = 0.5; + + // The shutter click: a thin ring of stretched streaks thrown sideways at + // the instant a copy detaches. Perpendicular to travel, because the copy is + // being *left behind* and anything thrown forward reads as exhaust. + this.shear = particles.get('afterimage.shear', { + capacity: 1400, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.3 + }); + this.shear.uniforms.uDrag.value = 2.6; + this.shear.uniforms.uEndSize.value = 0.15; + this.shear.uniforms.uSizeIn.value = 0.02; + this.shear.uniforms.uFadeIn.value = 0.02; + this.shear.uniforms.uFadeOut.value = 0.5; + + this.moteEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + get impactDuration() { + return Math.max(0.05, settings.afterimage.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.afterimage.fadeTime); + } + + /** A held light. It steps rather than swells — one level per copy shed. */ + lightShimmer() { + const c = settings.afterimage; + const step = this._frozen % Math.max(1, Math.round(c.lightSteps)); + return 1 - c.lightStagger * (step / Math.max(1, Math.round(c.lightSteps))); + } + + /* ------------------------------------------------------------------ */ + /* The flight — the CPU's mirror of the vertex shader */ + /* ------------------------------------------------------------------ */ + + /** + * How far down the line the body had travelled at `age` seconds. + * + * This is the same closed form the vertex shader evaluates, written twice on + * purpose: the GPU needs it per instance per vertex, and the CPU needs it to + * put the light, the stasis bubble and the particle emitters *on* the copies + * rather than near them. `ThunderAbility#_axisPoint` is the precedent. If one + * of the two is ever edited, the other has to move with it — the tell is + * motes that trail half a metre behind the body they came off. + */ + _progressAt(age) { + const c = settings.afterimage; + const speed = Math.max(0.01, c.speed * settings.global.speed); + const travel = Math.max(this.length / speed, 1e-4); + return Math.pow(saturate(age / travel), Math.max(0.05, c.flightCurve)); + } + + /** The instant copy `k` is showing. `k = 0` is the live body. */ + _shownAgeOf(k) { + const c = settings.afterimage; + return k <= 0 ? this.age : Math.min(this.age, k * c.snapGap); + } + + /** + * Where copy `k` is, in world space. Returns its progress along the line. + * + * Mirrors the vertex shader's `centre` exactly, sway, bow and settling + * included. + */ + _bodyPoint(k, out) { + const c = settings.afterimage; + const shown = this._shownAgeOf(k); + const held = Math.max(this.age - shown, 0); + const s = this._progressAt(shown); + + this.pointAt(s, out); + out.addScaledVector(this.side, Math.sin((s * c.swayWaves + this._seed) * Math.PI * 2) * c.sway); + out.y += lerp(c.liftNear, c.lift, s) + c.arc * Math.sin(s * Math.PI) - held * c.holdSink; + return s; + } + + /** Copies currently standing behind the body, 0..MAX_SNAPS. */ + _snapCount() { + return clamp(Math.round(settings.afterimage.snaps), 0, MAX_SNAPS); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the cast's frame and clock into the shader, and re-count the row. + * + * Everything with a unit is resolved inside `material.userData.sync` from + * `settings.afterimage`; this hands over the frame, the age, the dice and the + * fade, and nothing else. + */ + _syncBody(fade) { + const state = this._state; + state.origin.copy(this.origin); + state.dir.copy(this.direction); + state.side.copy(this.side); + state.length = this.length; + state.age = this.age; + state.seed = this._seed; + state.fade = fade; + this.material.userData.sync(state); + + // Copy-major layout, so lowering `snaps` drops the oldest copies rather + // than half of the newest one's fan. + const copies = this._snapCount(); + this._live = copies + 1; + this.geometry.instanceCount = this._live * MAX_FINS; + } + + /** The stasis bubble, re-placed and re-resolved every frame. */ + _syncRegion() { + const region = this._region; + if (!region) return; // I6 — the four slots may all be spoken for + const c = settings.afterimage; + const p = this._regionParams; + + // On the newest copy that has actually frozen; on the body itself before + // any has. A bubble parked on a copy that does not exist yet would hold a + // patch of empty floor. + this._bodyPoint(Math.min(this._frozen, this._snapCount()), _hold); + region.place(_hold); + + p.radius = c.holdRadius; + p.core = c.holdCore; + p.strength = c.holdStrength; + p.rate = c.holdRate; + region.sync(p); + } + + _syncParticles(dt, fade) { + const c = settings.afterimage; + const g = settings.global; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = c.moteOpacity * g.opacity; + this.motes.uniforms.uGlow.value = c.moteGlow * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.shear.setGradient( + getColor(c.colorShearA), + getColor(c.colorShearB), + getColor(c.colorShearC), + getColor(c.colorShearD) + ); + this.shear.uniforms.uGravity.value.set(0, c.shearGravity, 0); + this.shear.uniforms.uSizeScale.value = c.shearSize * g.particleSize * 7; + this.shear.uniforms.uLifeScale.value = c.shearLifetime * 0.5 * g.particleLifetime; + this.shear.uniforms.uSpeedScale.value = g.particleSpeed; + this.shear.uniforms.uOpacity.value = g.opacity; + this.shear.uniforms.uGlow.value = c.shearGlow * g.glow; + this.shear.uniforms.uStretch.value = c.shearStretch; + + if (dt <= 0) return; + const count = Math.round(this.moteEmitter.tick(dt, c.moteRate * fade) * g.particleCount); + if (count <= 0) return; + + this._bodyPoint(0, _pos); + _emit.position = _pos; + _emit.radius = c.moteSpread; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.4).setY(0.35).normalize(); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(count, _emit); + } + + /** + * Fire the shutter for every copy whose instant has just passed. + * + * Gated on a real frame. A freeze is an *event*, and a zero-length frame has + * no events in it — without the gate, dragging `snapGap` down while paused + * would walk the crossing index up and dump six rings of sparks under the + * cursor. The index is monotone within a cast for the same reason: dragging + * `snapGap` back up does not un-fire a click that already happened. + */ + _pollFreezes(dt) { + if (dt <= 0) return; + const c = settings.afterimage; + const gap = Math.max(1e-3, c.snapGap); + const due = Math.min(this._snapCount(), Math.floor(this.age / gap)); + while (this._frozen < due) { + this._frozen++; + this._freezeFx(this._frozen); + } + } + + /** The click: a ring of shear, a mark on the floor, and the bubble re-locking. */ + _freezeFx(copy) { + const c = settings.afterimage; + const g = settings.global; + const now = frame.uTime.value; + + this._bodyPoint(copy, _pos); + + // Thrown sideways, in the plane the copy is being left behind through. + _emit.position = _pos; + _emit.radius = c.shearSpread; + _emit.direction = _dir.copy(this.side); + _emit.speed = c.shearSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.17; + _emit.sizeVariance = 0.8; + _emit.life = c.shearLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = now; + this.shear.emit(Math.round(c.shearPerSnap * g.particleCount), _emit); + + // A thin ring on the floor under the copy. A decal is the right tool here + // and not the wrong one: this mark is a *record of an instant* that is + // supposed to expire, unlike the copy above it, which is not. + _pos.y = 0; + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.snapRingRadius, + life: c.snapRingLife, + width: c.snapRingWidth, + intensity: c.snapRingIntensity, + colorA: getColor(c.colorSnapRingA), + colorB: getColor(c.colorSnapRingB) + }); + + // The bubble follows the newest held moment, and re-stamps the instant it + // is holding — otherwise it would keep clamping everything to the first + // copy's clock long after that copy stopped being the interesting one. + this._region?.lock(now); + this.lightBoost = c.lightIntensity * c.snapPunch * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this._frozen = 0; + this._live = 1; + this._seed = Math.random() * 10; + + // I6/I9: may be null, and is given back by the base class however the cast + // ends. `lock()` stamps the instant the bubble snapped shut, which is what + // every consumer of the field clamps to. + this._region = this.borrow(timeField.acquire()); + this._region?.lock(frame.uTime.value); + + this._syncBody(1); + this._syncRegion(); + this._syncParticles(0, 1); + } + + onTravel(dt) { + this._pollFreezes(dt); + this._syncBody(1); + this._syncRegion(); + this._syncParticles(dt, 1); + + // The light rides the live body, which is above the floor — `advance()` has + // already put `position` on the ground line, so lift it onto the flight. + this._bodyPoint(0, this.position); + this.ctx.shake.rumble(settings.afterimage.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.afterimage; + const g = settings.global; + + this._bodyPoint(0, _pos); + + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.arrivalSize * 0.25, + endRadius: c.arrivalSize * g.explosionIntensity, + life: 0.8, + intensity: c.arrivalIntensity, + opacity: 0.6, + fresnel: 2.0, + displace: 0.3, + squash: 0.9, + colorA: getColor(c.colorArrivalA), + colorB: getColor(c.colorArrivalB), + colorC: getColor(c.colorArrivalC) + }); + + _emit.position = _pos; + _emit.radius = c.shearSpread * 1.6; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.3).setY(0.5).normalize(); + _emit.speed = c.shearSpeed * 1.7; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.2; + _emit.sizeVariance = 0.8; + _emit.life = c.shearLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.shear.emit(Math.round(c.arrivalShear * g.particleCount), _emit); + + this.ctx.shake.add( + c.arrivalShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.arrivalFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onFade(dt, t) { + // The row holds through `t <= 1` and then lets go. Cubic, so the frozen + // moments hang on and then vanish together rather than dimming: a + // photograph does not dim, it is taken away. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + + this._pollFreezes(dt); + this._syncBody(fade); + this._syncRegion(); + this._syncParticles(dt, fade * 0.35); + + this._bodyPoint(0, this.position); + } + + onDestroy() { + // Idempotent — the base class releases every borrowed handle too. Doing it + // here as well means the slot is free on the frame the cast ends rather + // than at the end of `destroy()`, and a leaked time region stops a sphere + // of the world for the rest of the session. + this._region?.release(); + this._region = null; + this._frozen = 0; + this._live = 1; + this.geometry.instanceCount = MAX_FINS; + this.material.uniforms.uFade.value = 0; + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/chrono/EchostepAbility.js b/src/abilities/chrono/EchostepAbility.js new file mode 100644 index 0000000..a21c233 --- /dev/null +++ b/src/abilities/chrono/EchostepAbility.js @@ -0,0 +1,632 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { + GhostRig, + TimeRecorder, + findCaster, + ghostLook, + recorderParams +} from '../../vfx/TimeControl.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, Easing } from '../../utils/math.js'; + +/** + * Hard ceiling on echoes. The `ghosts` slider clamps here. + * + * Four, not eight. A ghost is a real clone of the caster's skinned meshes on + * its own `Skeleton` — one draw call and about seventy `Object3D`s each — and + * the roster asks for three. Four leaves one slot of headroom for somebody + * tuning, and stops the slider from quietly costing eight draw calls and eight + * skeleton updates a frame. + * + * The multiplier that makes the ceiling matter is the **pool**: instances are + * pooled per id up to the manager's four concurrent casts, so this number is + * really "clones per pooled instance". At four it is sixteen clones in the + * worst case, which is memory only — a hidden ghost is not traversed, so it + * costs neither a draw nor a `Skeleton.update()` — but at eight it would be + * thirty-two, and none of them would be on screen. + */ +const MAX_GHOSTS = 4; + +/** + * Events the rut carries. Each footfall posts one; past this the ring recycles + * oldest-first, which is exactly right — the floor forgets the far end of the + * line first. + */ +const TRACK_MARKS = 12; + +const _pos = new Vector3(); +const _dir = new Vector3(); +const _emit = {}; + +/** + * ECHO STEP — the caster's own recorded motion, run down the line by three + * copies of them. + * + * **THE TRICK.** Nothing here is a stylised proxy of a person. A `TimeRecorder` + * watches the real caster's rig and writes the world transform and every bone's + * *local* pose into a ring buffer at `sampleRate` samples a second; three + * `GhostRig`s — genuine clones of the character's skinned meshes, each on its + * own `Skeleton` — are then driven to three different instants of that + * recording, each further behind the present and each fainter, and walked down + * the aimed line. They are the character, doing what the character just did, + * late. That is what makes it unsettling, and it is the one effect in this + * sandbox that cannot be built out of noise and parametric paths. + * + * ### What a cast captures + * + * One dice roll (`_seed`, so two casts do not weave identically) and the + * timestamps in the recorder's ring. Every delay, stride, metre of lift, degree + * of yaw and step of erosion is re-resolved from `settings.echostep` on every + * frame, including a zero-length one. Pause with **P** mid-run and drag + * `ghostDelay`: the three echoes walk backwards and forwards through the + * recording while the world stands still, because playback is a *function of + * the delay* and the delay is a slider. That is the loudest possible pass of + * the pause test, on the one ability whose subject is time. + * + * ### Two things that were tried and thrown away + * + * **A silhouette proxy.** The first version drew each echo as a capsule-ish + * hull with a fresnel ramp, on the theory that three skeletons was extravagant. + * It reads as three coloured blobs jogging, and no amount of tuning gets it + * back — what makes a person recognisable at ten metres is not the outline, it + * is the shading across the shoulders and the terminator down the ribcage, and + * those arrive free from the character's own `MeshStandardMaterial`. + * `createGhostMaterial()` therefore bleaches the skin *by its own luminance* + * rather than tinting it, so the map's light and shade survive. + * + * **Decals for the footfalls.** The second version dropped a `DecalType` mark + * where each echo's step landed. `GroundDecals.spawn()` writes `uRadius` once, + * so a mark already on the floor cannot hear a slider move — on the school + * whose entire subject is that the past is still live, that is precisely the + * wrong thing to ship. It is a `GroundField` in `RUT` mode instead: the marks + * the cast posts are **unitless** (`s` along the track, a strength, a + * timestamp) and the rut's depth, width, spoil and wander all re-resolve every + * frame, so dragging `trackDepth` while paused re-cuts a track that was gouged + * a second ago. + * + * ### Draw calls + * + * 3 ghosts (1 each, geometry and skin shared with the character) + 1 rut, plus + * two shared particle systems. Six, against I7's twelve. + */ +export class EchostepAbility extends Ability { + constructor(context) { + super('echostep', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The caster, found once. `findCaster` is `getObjectByName('Character')` — + * a full traversal of the scene, and it has no business in a frame path. + * By the time a chrono slot is warmed on selection the character's FBX is + * long loaded; `_ensureSource()` covers the case where it is not. + */ + this._caster = findCaster(this.ctx.scene); + + /** + * Capacity is a structural ceiling, the way `MAX_STRANDS` is in + * `ThunderAbility`: a buffer cannot be resized by a slider drag, but how + * finely it is written (`sampleRate`) and how much of it is kept + * (`memory`) both can. 150 samples × 96 bones is 400 kB, allocated once + * for the life of the pool. + */ + this.recorder = new TimeRecorder({ capacity: 150, bones: 96 }); + this.recorder.attach(this._caster); + + /** + * `setSource()` is the only allocating call in `GhostRig` and it happens + * here — never on a cast. Each echo gets its own material because each one + * needs its own fade and its own erosion; sharing one would give three + * echoes one opacity and lose the whole read. + */ + this.ghosts = []; + for (let i = 0; i < MAX_GHOSTS; i++) { + const ghost = new GhostRig(this.group, { renderOrder: 4 + i }); + ghost.setSource(this._caster); + ghost.visible = false; + this.ghosts.push(ghost); + } + + /** + * The floor's memory of the run. `RUT` rather than `POCK` because a line + * cast wants a strip and not a disc: the quad is `length` long and only a + * couple of metres across, where `POCK` would have stood a twenty-metre + * square of fill over the arena to hold marks that only ever land on one + * axis. + */ + this.track = new GroundField(this.group, { + mode: GroundMode.RUT, + marks: TRACK_MARKS, + additive: false, + name: 'echostep.track' + }); + + /* --- scratch, allocated once (I3) --- */ + this._look = ghostLook(); + this._rec = recorderParams(); + this._ground = groundFieldParams(); + this._ground.centre = new Vector3(); + /** Which step slot each echo last printed. `-1` is "has not stepped yet". */ + this._stepIndex = new Int32Array(MAX_GHOSTS).fill(-1); + /** Where each echo is along the line, 0..1. Written every frame, read by the emitters. */ + this._ghostS = new Float32Array(MAX_GHOSTS); + /** Per-cast dice. */ + this._seed = 0; + /** Echoes currently drawn — the HUD's instance readout. */ + this._live = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The amber that comes off an echo. Additive and slow: this school is + // quiet, and a bright spark would make it a storm ability with the colours + // changed. + this.motes = particles.get('echostep.motes', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.5 + }); + this.motes.uniforms.uDrag.value = 1.9; + this.motes.uniforms.uEndSize.value = 0.18; + this.motes.uniforms.uSizeIn.value = 0.09; + this.motes.uniforms.uFadeIn.value = 0.12; + this.motes.uniforms.uFadeOut.value = 0.45; + + // Floor dust kicked by a footfall. Non-additive so it genuinely sits in + // front of the rut rather than lighting it. + this.dust = particles.get('echostep.dust', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 0.9 + }); + this.dust.uniforms.uDrag.value = 2.4; + this.dust.uniforms.uEndSize.value = 2.2; + this.dust.uniforms.uSizeIn.value = 0.14; + this.dust.uniforms.uFadeIn.value = 0.18; + this.dust.uniforms.uFadeOut.value = 0.35; + + this.moteEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + /** The echoes hold at the far end before they are let go. */ + get impactDuration() { + return Math.max(0.05, settings.echostep.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.echostep.fadeTime); + } + + /** + * A slow swell rather than a flicker. + * + * Storm gutters, ice glints; a memory *breathes*. Two beats an octave apart + * so the period is not obviously one sine. + */ + lightShimmer() { + const c = settings.echostep; + const t = this.age * c.lightPulseSpeed; + return 1 - c.lightPulse * (0.5 - 0.5 * Math.cos(t) * Math.cos(t * 0.37)); + } + + /* ------------------------------------------------------------------ */ + /* The run */ + /* ------------------------------------------------------------------ */ + + /** + * Re-attach to the caster if it was not in the scene when the pool was built. + * + * `setSource()` is idempotent — it returns immediately once the source + * matches — so this costs a pointer compare on every cast after the first, + * and buys the case where a chrono slot is cast before the character's + * asynchronous FBX has landed. Without it that pool is ghostless for the rest + * of the session. + */ + _ensureSource() { + if (this._caster) return; + this._caster = findCaster(this.ctx.scene); + if (!this._caster) return; + this.recorder.attach(this._caster); + for (const ghost of this.ghosts) ghost.setSource(this._caster); + } + + /** How far behind the front echo `i` runs, metres, before the catch-up. */ + _strideOf(i) { + const c = settings.echostep; + return c.ghostStride * (i + 1) * Math.pow(c.strideGrowth, i); + } + + /** Which instant echo `i` is replaying, in the recorder's own clock. */ + _instantOf(i, now) { + const c = settings.echostep; + return now - c.ghostDelay * (i + 1) * Math.pow(c.delayGrowth, i); + } + + /** + * Place, pose and dress every echo. + * + * @param {number} dt seconds; 0 on a paused frame, and the gate on + * everything irreversible below + * @param {number} fade 1 while the run holds, ramping to 0 on the blow-out + * @param {number} catchUp 0..1 — how far the echoes have closed on the far end + */ + _syncGhosts(dt, fade, catchUp) { + const c = settings.echostep; + const g = settings.global; + const now = frame.uTime.value; + const count = clamp(Math.round(c.ghosts), 1, MAX_GHOSTS); + this._live = 0; + + /* --- write the recording --- */ + // `sample()` refuses a frame that has not advanced far enough for the live + // rate, so a paused frame writes nothing and the track stops growing while + // the ghosts keep reading from it. That asymmetry is the pause test. + this._rec.rate = c.sampleRate; + this._rec.window = c.memory; + this.recorder.sample(now, this._rec); + this.recorder.trim(now, this._rec); + + const lead = this.u * this.length; + // The character model faces its own +Z, so the cast's heading is the yaw + // that takes +Z onto `direction`. + const yaw = Math.atan2(this.direction.x, this.direction.z); + const look = this._look; + + look.tint = c.colorGhost; + look.deep = c.colorGhostDeep; + look.rimColor = c.colorRim; + look.bleach = c.bleach; + look.facing = c.facing; + look.rimPower = c.rimPower; + look.bandScale = c.bandScale; + look.bandSpeed = c.bandSpeed; + look.erodeScale = c.erodeScale; + look.edge = c.erodeEdge; + look.edgeGlow = c.edgeGlow; + + for (let i = 0; i < MAX_GHOSTS; i++) { + const ghost = this.ghosts[i]; + if (i >= count) { + ghost.visible = false; + continue; + } + + /* --- where --- */ + const stride = this._strideOf(i) * (1 - catchUp); + const s = saturate((lead - stride) / this.length); + this._ghostS[i] = s; + + this.pointAt(s, _pos); + // A weave, so three echoes on one line are not one echo drawn three + // times. Sampled on `s` rather than on the clock: an echo's wander is a + // property of where on the line it is, so the whole train slides along a + // fixed serpentine instead of shimmying on the spot. + const wob = Math.sin((s * c.weaveWaves + this._seed + i * 0.37) * Math.PI * 2); + _pos.addScaledVector(this.side, wob * c.weave * (i + 1)); + _pos.y = c.ghostLift - c.ghostSink * i; + + // Older echoes are turned a little further off the line of travel. The + // fan is centred, so the newest is not the only one standing straight. + ghost.place(_pos, yaw + c.ghostYaw * (i - (count - 1) * 0.5)); + ghost.setScale(Math.max(0.02, c.ghostScale * (1 - c.scaleDecay * i))); + + /* --- when --- */ + const inTrack = this.recorder.poseAt(this._instantOf(i, now), ghost); + + /* --- how it looks --- */ + const dim = Math.pow(c.opacityDecay, i); + look.fade = saturate(c.ghostOpacity * dim * fade * g.opacity); + look.rim = c.rim * dim; + look.glow = c.bandGlow * dim; + look.erode = saturate(c.erode + c.erodeStep * i + (1 - fade) * c.erodeOut); + look.seed = this._seed + i * 3.7; + ghost.sync(look); + + // An echo with no recording behind it is not a faint echo, it is a + // rendering bug: the recorder clamps to the nearest sample rather than + // returning NaN, so the ghost would stand in the caster's *current* pose + // and read as a duplicate rather than a memory. It waits instead. + ghost.visible = inTrack && look.fade > 0.002; + if (ghost.visible) this._live++; + + /* --- the footfall --- */ + // Gated on a real frame: a footfall is an event, and a zero-length frame + // has no events in it. Without the gate, dragging `steps` while paused + // would print a line of steps under the cursor. + if (dt > 0) this._footfall(i, s, now); + } + } + + /** + * Post a contact force into the rut when echo `i` crosses a step boundary. + * + * The step *pattern* is unitless — `floor(s × steps + stagger)` — so the + * prints re-place themselves down the track when `range` or `steps` moves, + * and the mark itself carries nothing but a fraction, a weight and a + * timestamp. + */ + _footfall(i, s, now) { + const c = settings.echostep; + const steps = Math.max(1, Math.round(c.steps)); + const slot = Math.floor(s * steps + i * c.stepStagger); + if (slot === this._stepIndex[i]) return; + const first = this._stepIndex[i] < 0; + this._stepIndex[i] = slot; + if (first || s <= 0.0001) return; + + const weight = saturate(c.stepWeight * Math.pow(c.weightDecay, i)); + this.track.mark(0, s, now, weight); + + /* --- and the dust it lifts --- */ + const g = settings.global; + const count = Math.round(c.dustPerStep * weight * g.particleCount); + if (count <= 0) return; + + this.pointAt(s, _pos); + _pos.y = c.dustHeight; + _emit.position = _pos; + _emit.radius = c.dustSpread; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.35).setY(1).normalize(); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.5; + _emit.sizeVariance = 0.6; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = now; + this.dust.emit(count, _emit); + } + + /** The rut, re-cut from live settings every frame. */ + _syncTrack(fade) { + const c = settings.echostep; + const g = settings.global; + const p = this._ground; + + // Anchored at the *start* of the track — RUT slides its own quad downrange + // by half the length — so this is the caster's feet, not the mid-span. + p.centre.copy(this.origin); + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.trackHeight; + p.length = this.length; + // For RUT the radius only sets how wide the canvas is; the track's own + // half-width is `width`. Keeping it a small multiple of the width is what + // stops a twenty-metre cast from standing a twenty-metre square of fill. + p.radius = c.trackWidth * c.trackCanvas; + p.progress = this.phase === AbilityPhase.TRAVEL ? this.u : 1; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.trackEdge; + p.width = c.trackWidth; + p.depth = c.trackDepth; + p.lift = c.trackSpoil; + p.thickness = c.trackSpoilWidth; + p.sharp = c.trackSharp; + p.detail = c.trackChatterDepth; + p.cell = c.trackChatter; + p.swirl = c.trackWander; + p.raggedScale = c.trackWanderScale; + p.seam = c.stepBlur; + + p.relief = c.trackRelief; + p.normalStep = c.trackNormalStep; + p.ambient = c.trackAmbient; + p.wrap = c.trackWrap; + p.specular = c.trackSpecular; + p.gloss = c.trackGloss; + p.parallax = c.trackParallax; + + p.emissive = c.trackEmissive * g.glow; + p.opacity = c.trackOpacity * fade * g.opacity; + p.depthFade = c.trackDepthFade; + p.colorBase = c.colorTrack; + p.colorEdge = c.colorTrackEdge; + p.colorGlow = c.colorTrackGlow; + p.colorDeep = c.colorTrackDeep; + + p.noiseStrength = g.turbulence; + p.noiseFrequency = 1; + p.noiseSpeed = 1; + p.opacityScale = 1; + + this.track.update(p); + } + + /** Amber shed by the echoes themselves. */ + _syncMotes(dt, fade) { + const c = settings.echostep; + const g = settings.global; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = c.moteOpacity * g.opacity; + this.motes.uniforms.uGlow.value = c.moteGlow * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + if (dt <= 0 || this._live <= 0) return; + const total = Math.round(this.moteEmitter.tick(dt, c.moteRate * fade) * g.particleCount); + if (total <= 0) return; + + const per = Math.max(1, Math.round(total / this._live)); + const now = frame.uTime.value; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = now; + + for (let i = 0; i < MAX_GHOSTS; i++) { + if (!this.ghosts[i].visible) continue; + this.pointAt(this._ghostS[i], _pos); + _pos.y = c.moteHeight; + _emit.position = _pos; + _emit.radius = c.moteSpread; + this.motes.emit(per, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this._ensureSource(); + this.recorder.clear(); + this.track.clearMarks(); + this.moteEmitter.reset(); + this._stepIndex.fill(-1); + this._live = 0; + + // The one thing the cast keeps. Unitless. + this._seed = Math.random() * 10; + + this._syncGhosts(0, 1, 0); + this._syncTrack(1); + this._syncMotes(0, 1); + + const c = settings.echostep; + this.lightBoost = c.lightIntensity * 0.4 * settings.global.explosionIntensity; + } + + onTravel(dt) { + this._syncGhosts(dt, 1, 0); + this._syncTrack(1); + this._syncMotes(dt, 1); + + // The light rides the newest echo's chest, not the floor under the front — + // this is a figure running, and lighting the ground would read as a + // projector following it. + this.pointAt(this._ghostS[0], this.position); + this.position.y = settings.echostep.lightHeight; + } + + onImpact() { + const c = settings.echostep; + const g = settings.global; + + this.pointAt(1, _pos); + _pos.y = c.arrivalHeight; + + // A thin pressure shell, not a detonation. The whole school is quiet, and + // the arrival's job is only to say "this is where the run ended". + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.arrivalSize * 0.25, + endRadius: c.arrivalSize * g.explosionIntensity, + life: 0.75, + intensity: c.arrivalIntensity, + opacity: 0.55, + fresnel: 2.2, + displace: 0.25, + squash: 0.85, + colorA: getColor(c.colorArrivalA), + colorB: getColor(c.colorArrivalB), + colorC: getColor(c.colorArrivalC) + }); + + this.ctx.shake.add( + c.arrivalShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.arrivalFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.8 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.echostep; + // `t` is 0..1 while the echoes hold at the far end, then 1..2 as they go. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + + // The catch-up. Once the run is over the echoes close on the arrival point + // — the past catching up with the present is the beat the whole ability is + // built to pay off, and it is a closed form of `t`, so dragging `catchUp` + // with the clock stopped slides all three of them along the line at once. + const closing = Math.pow(saturate(t * 0.5), Math.max(0.05, c.catchCurve)); + const catchUp = saturate(c.catchUp * closing); + + this._syncGhosts(dt, fade, catchUp); + this._syncTrack(fade); + this._syncMotes(dt, fade * 0.55); + + this.pointAt(this._ghostS[0], this.position); + this.position.y = c.lightHeight; + } + + onDestroy() { + // The base class hides the whole group, so nothing here needs to hide the + // rut — but a pooled instance must come back empty: a second cast that + // inherited the first one's footfalls would draw a track it never walked. + for (const ghost of this.ghosts) ghost.visible = false; + this.track.clearMarks(); + this.recorder.clear(); + this._stepIndex.fill(-1); + this._live = 0; + } + + dispose() { + for (const ghost of this.ghosts) ghost.dispose(); + this.ghosts.length = 0; + this.track.dispose(); + this.recorder.detach(); + super.dispose(); + } +} diff --git a/src/abilities/chrono/EntropyAbility.js b/src/abilities/chrono/EntropyAbility.js new file mode 100644 index 0000000..8b225bf --- /dev/null +++ b/src/abilities/chrono/EntropyAbility.js @@ -0,0 +1,753 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { DistortionField, DistortionMode, DistortionFacing } from '../../vfx/Distortion.js'; +import { sceneHooks, Hook } from '../../vfx/SceneHooks.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _front = {}; + +/** + * ENTROPY WAVE — one number ages a material. + * + * A ring of decay crosses the floor, the whole circle stands rusted, mossed, + * pitted and bleached for a beat, and then the decay collapses back to the + * point it came from and the stone is exactly what it was. + * + * ## THE TRICK — the floor is a real PBR material, so this is a patch, not a decal + * + * The only thing this ability really does is hold **`Hook.AGE`** and write one + * 0..1 into it every frame. `vfx/SceneHooks.js` has already patched the ground's + * `MeshStandardMaterial` at ``, which is the single + * injection point in three's physical shader where `diffuseColor`, + * `roughnessFactor` *and* `metalnessFactor` are all in scope and all still + * mutable. Five terms come off that one field and each one moves a different + * channel: + * + * | term | what it moves | + * | --- | --- | + * | rust | albedo toward the picker, roughness up, **metalness up** | + * | dust | albedo, roughness hard up, metalness down | + * | moss | albedo in the low-frequency hollows, metalness down | + * | pit | albedo down in specks, roughness up | + * | bleach | desaturate and lift whatever survived the other four | + * + * Metalness is the whole reason this does not read as a coloured circle painted + * on clean stone. Rust that has gone metallic catches the key light at a + * different angle from the stone next to it, and the eye reads *a different + * substance* before it reads the colour. Drag `wearRust` to zero with the other + * four at full and the effect collapses back into a stain — that comparison is + * the fastest way to see what the ability is. + * + * ## The sweep, and why the retreat is hollow + * + * `lead` is the leading edge, a fraction of `fieldSpread × zoneRadius`. It runs + * out, holds, and comes back. One number: the age token's radius, the + * refraction ring's wavefront and the annulus the particles are seeded in are + * all it, so the shimmer, the flakes and the rust cannot end up a frame apart. + * + * The retreat also lifts the token's **inner cut** (`retreatTrail`), which turns + * the shrinking disc into a shrinking *ring*. The version without it is a blob + * that gets smaller, and a blob that gets smaller reads as a fade — the eye + * needs a trailing edge to see a wave going back the way it came. + * + * ## What is drawn: almost nothing, on purpose + * + * One draw call. A `DistortionField` in `SHOCK` mode lying flat on the floor is + * the front, and three particle systems shed off the ground behind it. There is + * deliberately no `GroundField` under this: a ground quad drawing its own rust + * would be a decal sitting on top of a material that is already rusting, and + * the two would disagree at the edges. The ability that ages the world should + * not also be carrying a picture of the world ageing. + * + * `frontDepthReject` ships at **0**, and it is the first thing to check if the + * ring vanishes. The quad lies on the surface it is bending, so the emitter's + * occlusion term — which rejects fragments that opaque geometry sits in front + * of — throws the entire ring away. This is the same trap `SingularityAbility` + * documents for its lens, in the one other place in the project where an + * emitter is coplanar with what it distorts. + * + * ## The particles are a read-back, not a second opinion + * + * A mote, a flake or a spore is seeded at a random point in the live annulus, + * and the *rate* at that point is `sceneHooks.ageAt(x, z)` — the CPU mirror of + * the same `sceneAgeField()` the floor's shader runs, smoothstep for smoothstep. + * So dust only lifts where the ground has actually pitted, and the shed thins + * out through the field's soft edge instead of stopping at a circle nobody drew. + * The flakes are additionally gated on `rustOnset` and the spores on + * `mossOnset`, because rust flakes cannot come off a floor that has not rusted. + * + * ## Restore is exact, and the ability does not do it + * + * Nothing here puts the floor back. `this.borrow()` hands the token to the + * ledger and the ledger writes the neutral on release, on every one of the four + * ways a cast can end — three of which are not this ability's idea. An earlier + * draft did it by hand in `onDestroy()` and it was correct right up until the + * concurrency cap destroyed a cast mid-sweep from a code path that had already + * been torn down, at which point the floor stayed rusted until reload. That is + * the failure `SceneHooks` exists to make unrepresentable, and the correct + * amount of restore code in this file is none. + * + * ## What a cast captures + * + * `_seed` and three fired-yet flags. Every metre, second and fraction — the + * radius, the five weights, the five onsets, the grain, the three durations — + * is resolved from `settings.entropy` inside the update loop, zero-length + * frames included. Pause with **P** at full spread and drag `wearMoss`: the + * floor greens under a stopped clock. + */ +export class EntropyAbility extends Ability { + constructor(context) { + super('entropy', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The front. `GROUND` facing rather than `BILLBOARD`: the wave is a thing + * happening *to the floor*, so its plane is the floor's, and a billboard + * would stand the ring up and turn it into a bubble as soon as you orbit. + */ + this.ring = new DistortionField({ + mode: DistortionMode.SHOCK, + facing: DistortionFacing.GROUND, + name: 'Entropy:front' + }); + this.group.add(this.ring.object3D); + + /** + * Park the ageing block where the harness's pause probe looks (I8, and + * `SceneHooks#observe`). This ability's main output is a hook, so without + * this it owns almost no uniforms and reads as a dead slider bank while it + * is rusting the stage. + */ + sceneHooks.observe(this.ring.material); + + /** Re-rolled per cast. The only dice roll. */ + this._seed = 0; + /** Fired-yet flags. Events, not dimensions. */ + this._peaked = false; + this._restored = false; + /** The live age token, or null between casts. */ + this._age = null; + + /** + * The cast's beats, all unitless, refilled every frame. One object, reused. + * + * lead 0..1 leading edge, as a fraction of the field radius + * inner 0..1 the token's inner cut — the retreat's trailing edge + * amount 0..1 strength of the whole field; the master + * age 0..1 how old the zone is, which is what the five onsets read + * move 0..1 1 while the front is travelling, 0 while it is parked + */ + this._b = { lead: 0, inner: 0, amount: 0, age: 0, move: 1 }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Fine dust lifting out of the pitting. Non-additive: this school does not + // glow, and dust that adds light reads as embers. + this.motes = particles.get('entropy.motes', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: false, + curl: true, + softFade: 0.5 + }); + this.motes.uniforms.uDrag.value = 1.7; + this.motes.uniforms.uEndSize.value = 1.6; + this.motes.uniforms.uSizeIn.value = 0.1; + this.motes.uniforms.uFadeIn.value = 0.15; + this.motes.uniforms.uFadeOut.value = 0.35; + + // Rust flakes peeling off. Lit chips — they are matter, and they have to + // be silhouetted against the floor rather than lighting it. + this.flakes = particles.get('entropy.flakes', { + capacity: 900, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.flakes.uniforms.uDrag.value = 1.1; + this.flakes.uniforms.uEndSize.value = 0.6; + this.flakes.uniforms.uSizeIn.value = 0.05; + this.flakes.uniforms.uFadeOut.value = 0.5; + + // Moss spores, once there is moss. LEAF rather than SOFT so they are not a + // third cloud of round blobs; the silhouette is the only thing separating + // them from the dust at this size. + this.spores = particles.get('entropy.spores', { + capacity: 900, + shape: ParticleShape.LEAF, + additive: false, + lit: true, + softFade: 0.4 + }); + this.spores.uniforms.uDrag.value = 1.9; + this.spores.uniforms.uEndSize.value = 0.9; + this.spores.uniforms.uSizeIn.value = 0.08; + this.spores.uniforms.uFadeIn.value = 0.2; + this.spores.uniforms.uFadeOut.value = 0.4; + + this.moteEmitter = new RateEmitter(); + this.flakeEmitter = new RateEmitter(); + this.sporeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Nothing here is instanced: one ground quad and the floor's own material. */ + get instanceCount() { + return 0; + } + + /** The spread and the hold share the impact phase. */ + get impactDuration() { + const c = settings.entropy; + return Math.max(0.05, (c.spreadTime + c.holdTime) * settings.global.lifetime); + } + + /** + * The retreat owns the whole fade phase. There is no fourth window: a + * "settle" after the retreat was a second of an empty floor with the cast + * still nominally live, and folding the wash-out into the back of the retreat + * (`restoreHold`) says the same thing while the ring is still moving. + */ + get fadeDuration() { + return Math.max(0.05, settings.entropy.retreatTime); + } + + /** + * A light cannot be negative, so the honest move for an ability whose subject + * is *loss* is to under-drive the light and take it further down as the field + * peaks. The floor's own darkening — pitting and dust both cut albedo — does + * the rest, and it does it in the material rather than in the lighting, which + * is the only place it can look like decay instead of like a dimmer. + */ + lightShimmer() { + return 1 - saturate(settings.entropy.lightDrain) * this._b.amount; + } + + /* ------------------------------------------------------------------ */ + /* The beats */ + /* ------------------------------------------------------------------ */ + + /** + * How far through its own onset a wear term is, 0..1. + * + * The five terms all read the same `age` beat and differ only in when they + * are allowed to start. Rescaling the remainder — rather than simply clamping + * — means a term with a late onset still reaches full strength by the time + * the zone is fully aged, so raising `mossOnset` makes the moss *arrive* + * later rather than end up weaker, which is what the slider says it does. + */ + _onset(delay) { + const d = saturate(delay); + return saturate((this._b.age - d) / Math.max(1 - d, 1e-3)); + } + + /** + * Refill `this._b`. + * + * @param {number} t 0..1 through the impact phase, then 1..2 through the fade + */ + _resolveBeats(t) { + const c = settings.entropy; + const b = this._b; + + if (this.phase === AbilityPhase.TRAVEL) { + const reach = saturate(c.seedReach) * Easing.outQuad(this.u); + b.lead = reach; + b.inner = 0; + b.amount = reach; + b.age = 0; + b.move = 1; + return; + } + + if (t <= 1) { + const total = Math.max(1e-3, c.spreadTime + c.holdTime); + const spread = saturate(c.spreadTime / total); + if (t < spread) { + const k = saturate(t / Math.max(spread, 1e-3)); + // A power rather than an easing curve: `spreadCurve` below 1 makes the + // front leave hard and arrive slowly, which is how a front that is + // running out of energy behaves, and it is a slider because the + // opposite reading — a wave that accelerates — is also a look. + b.lead = lerp(saturate(c.seedReach), 1, Math.pow(k, Math.max(0.05, c.spreadCurve))); + b.amount = Easing.outCubic(k); + b.move = 1; + } else { + b.lead = 1; + b.amount = 1; + b.move = 0; + } + b.inner = 0; + b.age = saturate(t); + return; + } + + const s = saturate(t - 1); + b.lead = 1 - Math.pow(s, Math.max(0.05, c.retreatCurve)); + // The trailing edge. Without it the aged disc shrinks as a blob, and a blob + // that shrinks reads as a fade rather than as a wave going home. + b.inner = clamp(c.retreatTrail * s, 0, 0.98); + const hold = saturate(c.restoreHold); + b.amount = 1 - Easing.inQuad(saturate((s - hold) / Math.max(1 - hold, 1e-3))); + b.age = 1; + b.move = 1; + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The centre of the circle. Rides the front while the cast is travelling. */ + _zoneCentre(out) { + const s = this.phase === AbilityPhase.TRAVEL ? this.u : 1; + return this.pointAt(s, out); + } + + /** The field's outer radius at full spread, metres. */ + _fieldRadius() { + const c = settings.entropy; + return Math.max(0.05, c.fieldSpread * c.zoneRadius); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.flakeEmitter.reset(); + this.sporeEmitter.reset(); + this._peaked = false; + this._restored = false; + this._seed = Math.random() * 100; + + // The world, borrowed. Released by the base class however the cast ends — + // see the class doc on why none of the restore lives in this file. + this._age = this.borrow(sceneHooks.acquire(Hook.AGE, this)); + + this.ring.visible = true; + + this._resolveBeats(0); + this._sync(); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Write the field, publish it, then draw everything that reads it. + * + * `sceneHooks.apply()` is idempotent — every hook blends from `settings` + * rather than from the live value — so calling it here and again from + * `App.frame` costs a pass over six short arrays and changes nothing. It is + * called here because the particles below read the *published* block through + * `sceneHooks.ageAt()`, and because the harness's pause probe cannot see a + * hook-only ability until something has actually written the uniforms. + */ + _sync() { + this._zoneCentre(_centre); + this._syncAge(); + sceneHooks.apply(); + this._syncFront(); + this._syncParticles(); + + // The light sits at the centre of the circle, a little off the floor. + this.position.copy(_centre); + this.position.y = settings.entropy.frontLift; + } + + /** The one field. Five weights, five onsets, one radius. */ + _syncAge() { + const c = settings.entropy; + const b = this._b; + if (!this._age) return; + + const radius = Math.max(0.05, this._fieldRadius() * Math.max(b.lead, 1e-3)); + + this._age + .atPoint(_centre) + .field( + radius, + clamp(c.fieldEdge, 0.01, 1), + saturate(c.fieldAmount) * saturate(b.amount), + clamp(b.inner, 0, 0.98) + ) + .wear( + c.wearRust * this._onset(c.rustOnset), + c.wearDust * this._onset(c.dustOnset), + c.wearMoss * this._onset(c.mossOnset), + c.wearPit * this._onset(c.pitOnset), + c.wearBleach * this._onset(c.bleachOnset) + ) + .scale(Math.max(0.05, c.fieldGrain)) + .colours(c.colorRust, c.colorDust, c.colorMoss) + .blend(1); + } + + /** + * The refraction ring riding the leading edge. + * + * Magnitudes are **screen fractions**; the post pass multiplies by + * `post.distortion × global.distortion` exactly once, so nothing in this + * method may touch either of them. + */ + _syncFront() { + const c = settings.entropy; + const b = this._b; + const extent = this._fieldRadius(); + + // The quad has to be exactly twice the falloff radius or the window is cut + // off square at the corners. + _front.width = extent * 2; + _front.height = extent * 2; + _front.radius = extent; + _front.wave = extent * saturate(b.lead); + _front.thickness = c.frontThickness; + _front.compression = c.frontCompression; + _front.rarefaction = c.frontRarefaction; + _front.rings = c.frontRings; + _front.ringGap = c.frontRingGap; + _front.ringDecay = c.frontRingDecay; + _front.window = c.frontWindow; + _front.maxOffset = c.frontMaxOffset; + + // A parked front still shimmers, but only a little: the read is that the + // damage is being *done* while the ring is moving. + _front.strength = + c.frontStrength * saturate(b.amount) * lerp(saturate(c.frontFalloff), 1, saturate(b.move)); + _front.opacity = c.frontOpacity * saturate(b.amount); + _front.depthReject = c.frontDepthReject; + _front.depthFade = c.frontDepthFade; + _front.perspective = c.frontPerspective; + _front.perspectiveRef = c.frontPerspectiveRef; + _front.seed = this._seed; + + this.ring.setAnchorXYZ(_centre.x, c.frontLift, _centre.z); + this.ring.update(_front); + } + + /** The three particle systems, re-coloured and re-scaled every frame. */ + _syncParticles() { + const c = settings.entropy; + const g = settings.global; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = c.moteOpacity * g.opacity; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.flakes.setGradient( + getColor(c.colorFlakeA), + getColor(c.colorFlakeB), + getColor(c.colorFlakeC), + getColor(c.colorFlakeD) + ); + this.flakes.uniforms.uGravity.value.set(0, c.flakeGravity, 0); + this.flakes.uniforms.uSizeScale.value = c.flakeSize * g.particleSize * 7; + this.flakes.uniforms.uLifeScale.value = c.flakeLifetime * 0.5 * g.particleLifetime; + this.flakes.uniforms.uSpeedScale.value = g.particleSpeed; + this.flakes.uniforms.uOpacity.value = g.opacity; + + this.spores.setGradient( + getColor(c.colorSporeA), + getColor(c.colorSporeB), + getColor(c.colorSporeC), + getColor(c.colorSporeD) + ); + this.spores.uniforms.uGravity.value.set(0, c.sporeRise, 0); + this.spores.uniforms.uSizeScale.value = c.sporeSize * g.particleSize * 7; + this.spores.uniforms.uLifeScale.value = c.sporeLifetime * 0.5 * g.particleLifetime; + this.spores.uniforms.uSpeedScale.value = g.particleSpeed; + this.spores.uniforms.uOpacity.value = g.opacity; + this.spores.uniforms.uGlow.value = c.sporeGlow * g.glow; + this.spores.uniforms.uTurbulence.value = c.sporeTurbulence * g.turbulence; + } + + /** + * A random point in the live annulus, on the floor. + * + * `sqrt` on the radial fraction, because a uniform fraction of the radius + * piles every sample into the middle: area goes as r², so the inverse-CDF is + * the square root. The first version did not, and the shed came off a + * bullseye at the centre of the zone with nothing at the rim, which is + * exactly backwards for a wave. + */ + _annulusPoint(out) { + const b = this._b; + const extent = this._fieldRadius(); + const outer = Math.max(0.05, extent * saturate(b.lead)); + const inner = outer * clamp(b.inner, 0, 0.98); + const bearing = Math.random() * TAU; + const u = Math.random(); + const r = Math.sqrt(inner * inner + u * (outer * outer - inner * inner)); + out.copy(_centre); + out.x += Math.cos(bearing) * r; + out.z += Math.sin(bearing) * r; + out.y = 0; + return out; + } + + /** + * What comes off the floor. + * + * Each system samples one point in the annulus and asks the **published** + * field how aged the ground is there; that answer scales the rate. So the + * shed thins out through the field's own soft edge instead of stopping at a + * circle, and it cannot appear anywhere the shader has not rusted. It is the + * CPU mirror of `sceneAgeField()`, smoothstep for smoothstep, which is what + * makes "cannot" true rather than approximately true. + * + * @param {number} scale 0..1, thinned once the wave is only holding + */ + _shedFx(dt, scale) { + const c = settings.entropy; + const g = settings.global; + const time = frame.uTime.value; + const bias = saturate(c.ageBias); + + /* --- dust out of the pitting --- */ + this._annulusPoint(_pos); + let bite = sceneHooks.ageAt(_pos.x, _pos.z); + let count = Math.round( + this.moteEmitter.tick(dt, c.moteRate * scale * (bite > bias ? bite : 0)) * g.particleCount + ); + if (count > 0) { + _pos.y = randRange(0.02, 0.22); + _emit.position = _pos; + _emit.radius = 0.35; + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.size = 0.5; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.2; + _emit.tint = null; + _emit.time = time; + this.motes.emit(count, _emit); + } + + /* --- rust flakes: gated on the rust term's own onset --- */ + this._annulusPoint(_pos); + bite = sceneHooks.ageAt(_pos.x, _pos.z) * this._onset(c.rustOnset); + count = Math.round( + this.flakeEmitter.tick(dt, c.flakeRate * scale * (bite > bias ? bite : 0)) * g.particleCount + ); + if (count > 0) { + _pos.y = randRange(0.02, 0.14); + // Flakes lift and blow outward from the centre — the direction the front + // that loosened them was going. + _dir.set(_pos.x - _centre.x, 0, _pos.z - _centre.z); + if (_dir.lengthSq() < 1e-6) _dir.set(0, 0, 1); + _dir.normalize().setY(0.85).normalize(); + + _emit.position = _pos; + _emit.radius = 0.25; + _emit.anchor = null; + _emit.direction = _dir; + _emit.speed = c.flakeSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.6; + _emit.inherit = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.flakeLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = c.flakeSpin; + _emit.tint = null; + _emit.time = time; + this.flakes.emit(count, _emit); + } + + /* --- moss spores: gated on the moss term's onset, so they are last --- */ + this._annulusPoint(_pos); + bite = sceneHooks.ageAt(_pos.x, _pos.z) * this._onset(c.mossOnset); + count = Math.round( + this.sporeEmitter.tick(dt, c.sporeRate * scale * (bite > bias ? bite : 0)) * g.particleCount + ); + if (count > 0) { + _pos.y = randRange(0.02, 0.3); + _emit.position = _pos; + _emit.radius = 0.4; + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sporeSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1; + _emit.inherit = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.75; + _emit.life = c.sporeLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 2.5; + _emit.tint = null; + _emit.time = time; + this.spores.emit(count, _emit); + } + } + + /** The seed leaving the caster's hand. */ + _castFx() { + const c = settings.entropy; + const g = settings.global; + + _pos + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .setY(c.handHeight); + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.castBurstSize * 0.3, + endRadius: c.castBurstSize * g.explosionIntensity, + life: 0.4, + intensity: c.castBurstIntensity, + opacity: 0.7, + fresnel: 2.0, + displace: 0.35, + colorA: getColor(c.colorCastA), + colorB: getColor(c.colorCastB), + colorC: getColor(c.colorCastC) + }); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._resolveBeats(0); + this._sync(); + this._shedFx(dt, saturate(this._b.amount)); + } + + /** The seed lands and the wave starts across the floor. */ + onImpact() { + const c = settings.entropy; + const g = settings.global; + + this._resolveBeats(0); + this._zoneCentre(_pos); + + this.ctx.decals.spawn(DecalType.DUSTRING, _pos, { + radius: this._fieldRadius() * 0.4 * g.explosionIntensity, + life: 1.3, + intensity: 0.8, + colorA: getColor(c.colorCastA), + colorB: getColor(c.colorCastB) + }); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 14 + ); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + onFade(dt, t) { + this._resolveBeats(t); + const b = this._b; + + // The wave reaching the rim. A flag on the beat rather than a timestamp: + // re-timing `spreadTime` mid-cast moves the moment with it. + if (!this._peaked && b.lead >= 0.995 && t <= 1) { + this._peaked = true; + this._peakFx(); + } + // The floor beginning to come back — the first frame the wear is falling. + if (!this._restored && t > 1 && b.amount < 0.999) { + this._restored = true; + this._restoreFx(); + } + + this._sync(); + this._shedFx(dt, t <= 1 ? 1 : saturate(b.amount)); + + if (t <= 1) { + this.ctx.shake.rumble(settings.entropy.rumble * settings.global.cameraShake, dt); + } + } + + /** The front arriving at the rim. */ + _peakFx() { + const c = settings.entropy; + const g = settings.global; + + this._zoneCentre(_pos); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.peakRingRadius * g.explosionIntensity, + life: 1.1, + width: 0.06, + intensity: c.peakRingIntensity, + colorA: getColor(c.colorRingA), + colorB: getColor(c.colorRingB) + }); + } + + /** The stone coming back. Deliberately the quietest beat in the ability. */ + _restoreFx() { + const c = settings.entropy; + const g = settings.global; + + this.ctx.flash.trigger(getColor(c.colorRestoreFlash), c.restoreFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.3 * g.explosionIntensity; + } + + onDestroy() { + // The token is given back by the base class through `borrow()`, which is + // what neutralises the floor exactly. Nothing is restored here on purpose — + // see the class doc. + this._age = null; + this._peaked = false; + this._restored = false; + this.ring.visible = false; + this._b.amount = 0; + this._b.lead = 0; + } + + dispose() { + this.ring.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/chrono/HourglassAbility.js b/src/abilities/chrono/HourglassAbility.js new file mode 100644 index 0000000..44efa6c --- /dev/null +++ b/src/abilities/chrono/HourglassAbility.js @@ -0,0 +1,1014 @@ +import { Quaternion, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { sceneHooks, Hook } from '../../vfx/SceneHooks.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; +const DEG = Math.PI / 180; + +/** + * Smallest value the shared app clock is allowed to be when it appears in a + * denominator. See `_syncHulls()`: the volumes' advection is authored as a + * *displacement* and divided by the clock, and the clock is zero on the frame + * the app boots and on the first frame of the headless harness. + */ +const CLOCK_FLOOR = 0.01; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _waist = new Vector3(); +const _apex = new Vector3(); +const _floor = new Vector3(); +const _ground = groundFieldParams(); + +/** The two cone orientations. Built once; neither ever changes. */ +const _zAxis = new Vector3(0, 0, 1); +const _qUp = new Quaternion().setFromUnitVectors(_zAxis, new Vector3(0, 1, 0)); +const _qDown = new Quaternion().setFromUnitVectors(_zAxis, new Vector3(0, -1, 0)); + +/** The run's three windows in seconds, refilled per call. */ +const _win = { drain: 0, stall: 0, rise: 0, total: 0 }; + +/** + * HOURGLASS — sand falls into a cone, and then it falls up. + * + * Two raymarched cones of `Medium.SAND` stand apex to apex over the aimed + * circle. The bulb above the neck drains; the heap below it grows on its own + * angle of repose; the zone goes weightless for a beat you can see coming; and + * then the same sand climbs back into the same bulb. + * + * ## THE TRICK — one sign, published, read back + * + * There is exactly one signed number in this ability. `flow` is unitless and + * lives on `this._b`: +1 while the glass runs down, easing through zero across + * `stallTime`, −1 while it runs back up. It is not used directly by anything + * that draws. It is multiplied by `gravityInside` and written into + * **`Hook.GRAVITY`**, `sceneHooks.apply()` publishes it, and every consumer + * then asks `sceneHooks.gravityAt()` for the multiplier at the neck. + * + * The round trip is the point and it is not ceremony. Going through the + * published field means (a) the grains, the two volumes and the dust cannot + * disagree about which way is down, because they are all reading one uniform + * block rather than four copies of a local; (b) anything else in the app that + * has opted into `gravityGLSL` — a shader that has never heard of this ability + * — falls upward inside the zone for free; and (c) the CPU mirror + * (`gravityAt`) and the GLSL (`gravityScaleAt`) are the same smoothstep, so a + * particle system integrating on the CPU and a shader integrating on the GPU + * agree at the wall of the well. Reading the local variable instead would look + * identical today and be wrong the first time somebody else's material joins in. + * + * ## Why the grains genuinely reverse, and why the stall is load-bearing + * + * `ParticleSystem`'s vertex shader is closed form in the particle's own age: + * `start + v·travel(age) + ½·g·age²`. Nothing has been integrated, so nothing + * has spent the old gravity — change `uGravity` and a grain half way down the + * neck re-flies its whole arc and climbs back out of it. That is the shot. + * + * It is also why the stall is not decoration. Position is a *function* of `g`, + * so a `g` that jumps teleports every live grain. Easing `flow` through zero + * makes the quadratic term pass through exactly zero, and at that instant every + * grain is sitting on its own ballistic path with nothing to jump from. The + * telegraph and the safety are the same curve; wind `stallTime` down to 0.02 + * and you can watch a thousand grains snap. + * + * ## The volumes: a displacement, not a rate + * + * The first version flipped the two hulls the obvious way — multiply + * `uJet`/`uRise` by the sign after `sync()` — and it does not work, in a way + * that is worth writing down because three modules in the library share the + * hazard. `VolumeHull` offsets its noise domain by `rate × frame.uTime`, and + * `frame.uTime` is the **app** clock, not the cast's. Changing the rate by Δv + * therefore moves the field by `t·Δv` on that one frame: five minutes into a + * session that is several hundred metres of domain, and the sand does not + * reverse, it re-scrambles. Easing the rate through zero does not help, because + * the offending term is `t·dv/dt` and `t` is enormous. + * + * So the ability integrates `flow` itself — `_b.travel`, closed form, in + * seconds — and hands the hull `displacement / clock`, which the shader + * multiplies straight back by the clock. What the volume sees is a metre count + * that is continuous in the beat and re-resolves from the sliders on a paused + * frame like everything else. The sand in the column visibly streams down, + * stops, and streams back up. + * + * The floor is the one consumer that does **not** flip, for the same reason + * without the fix: `GroundField`'s scour spiral turns on `uTime × uSpeed`, and + * a spiral is not worth a second closed-form integral. It keeps one rate all + * cast. Nobody has ever noticed which way the grooves were cut; everybody + * notices a floor that snaps ninety degrees. + * + * ## What a cast captures + * + * Three things, all of them events: `_seed` (so two glasses do not draw the + * same grain), `_travelSeconds` (the timestamp the front landed, which is what + * keeps the flow integral continuous across the phase change), and two + * fired-yet flags for the telegraph and the turn. Every metre, radian and + * second — the neck height, the repose angle, the well radius, the grain + * gravity, all three durations — is resolved from `settings.hourglass` inside + * the update loop, zero-length frames included. Pause with **P** at the top of + * the stall and drag `reposeAngle`: the heap re-slopes under a stopped clock. + */ +export class HourglassAbility extends Ability { + constructor(context) { + super('hourglass', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The falling column. A CONE with its apex at the neck and its mouth above + * it — which is the shape a conical bulb of sand actually is, and the + * reason it can empty by shrinking rather than by fading: length and mouth + * radius come down together and the silhouette stays a cone all the way. + * + * `maxSteps` is the compile-time loop cap, not the step count. `sandSteps` + * drives the real one and stays a slider underneath it. + */ + this.sand = new VolumeHull({ + hull: HullShape.CONE, + medium: Medium.SAND, + prefix: 'sand', + maxSteps: 40, + renderOrder: 12 + }); + // The cone's local +Z is its axis. Set once: the glass does not tumble, and + // `place()` only ever writes position when no direction is handed to it. + this.sand.mesh.quaternion.copy(_qUp); + this.group.add(this.sand.mesh); + + /** The heap, apex up. Same medium, same palette family, half the march. */ + this.dune = new VolumeHull({ + hull: HullShape.CONE, + medium: Medium.SAND, + prefix: 'dune', + maxSteps: 32, + renderOrder: 11 + }); + this.dune.mesh.quaternion.copy(_qDown); + this.group.add(this.dune.mesh); + + /** What the column cuts into the floor it is landing on. */ + this.scour = new GroundField(this.group, { + mode: GroundMode.SCOUR, + additive: false, + name: 'Hourglass:scour' + }); + this.scour.setVisible(false); + + /** + * Park the gravity block where the harness's pause probe looks (I8, and + * `SceneHooks#observe`). An ability whose output is partly a scene hook + * owns no uniform for that half of itself, and would otherwise read as a + * dead slider bank while it is inverting the world. + */ + sceneHooks.observe(this.sand.material); + + /** Re-rolled per cast. A dice roll — the only kind of number a cast keeps. */ + this._seed = 0; + /** Timestamp: `age` on the frame the front landed. Keeps the integral joined. */ + this._travelSeconds = 0; + /** Fired-yet flags for the two one-shots. Events, not dimensions. */ + this._warned = false; + this._turned = false; + /** The live gravity token, or null between casts. */ + this._grav = null; + /** The published multiplier read back at the neck. 1 when nothing is held. */ + this._gravity = 1; + + /** + * The cast's beats, all unitless, refilled every frame. One object, reused. + * + * seat 0..1 how much of the glass exists + * level 0..1 how much of the sand is in the heap + * flow -1..1 signed rate — THE number + * travel s the signed integral of `flow`, in seconds + * turn 0..1 1 − |flow|; the weightless beat, which is the telegraph + * fade 1..0 master + */ + this._b = { seat: 0, level: 0, flow: 1, travel: 0, turn: 0, fade: 1 }; + } + + createParticles() { + const particles = this.ctx.particles; + + // The grains. Lit chips rather than additive motes: sand is matter, it + // occludes, and it has to read as darker than the light behind it or the + // column looks like a beam. These are the particles the trick is about. + this.grains = particles.get('hourglass.grains', { + capacity: 2400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.2 + }); + this.grains.uniforms.uDrag.value = 0.35; + this.grains.uniforms.uEndSize.value = 0.7; + this.grains.uniforms.uSizeIn.value = 0.04; + this.grains.uniforms.uFadeIn.value = 0.05; + this.grains.uniforms.uFadeOut.value = 0.5; + + // The puff where the stream lands. Non-additive so it genuinely occludes + // the heap behind it. + this.dust = particles.get('hourglass.dust', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 0.9 + }); + this.dust.uniforms.uDrag.value = 1.9; + this.dust.uniforms.uEndSize.value = 2.4; + this.dust.uniforms.uSizeIn.value = 0.14; + this.dust.uniforms.uFadeIn.value = 0.18; + this.dust.uniforms.uFadeOut.value = 0.32; + + // The bone-amber time dust hanging around the glass. Additive, and the + // cheapest way to make the weightless beat legible in empty air — when the + // field turns, every mote in the shell stops and drifts back down. + this.motes = particles.get('hourglass.motes', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.5; + this.motes.uniforms.uEndSize.value = 0.2; + this.motes.uniforms.uSizeIn.value = 0.08; + this.motes.uniforms.uFadeIn.value = 0.12; + this.motes.uniforms.uFadeOut.value = 0.42; + + this.grainEmitter = new RateEmitter(); + this.dustEmitter = new RateEmitter(); + this.moteEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Nothing here is instanced: two raymarched hulls and one ground quad. */ + get instanceCount() { + return 0; + } + + /** + * The three windows of the run, in seconds, resolved live. + * + * `impactDuration` and `_resolveBeats()` both read this so the phase machine + * and the beat curve cannot drift apart when a duration slider moves under a + * standing cast. The floors are the same in both for the same reason. + */ + _windows() { + const c = settings.hourglass; + const life = Math.max(0.05, settings.global.lifetime); + _win.drain = Math.max(0.02, c.drainTime) * life; + _win.stall = Math.max(0.02, c.stallTime) * life; + _win.rise = Math.max(0.02, c.riseTime) * life; + _win.total = _win.drain + _win.stall + _win.rise; + return _win; + } + + /** Drain, stall and rise all live inside the impact phase. */ + get impactDuration() { + return this._windows().total; + } + + get fadeDuration() { + return Math.max(0.05, settings.hourglass.settleTime); + } + + /** + * A glass does not gutter. It breathes, and it **swells** as the zone loses + * its weight — which is the one channel that can say "something is about to + * happen" while every silhouette on screen is still doing what it was doing. + * + * The breathing rate is deliberately a constant rather than something that + * accelerates into the turn. `this.age` is a real clock, so a rate that moves + * slews the phase by `age·Δrate` — the same hazard the class doc describes for + * the volumes, in miniature. The swell carries the beat instead, and the swell + * is a multiplier, which has no phase to slew. + */ + lightShimmer() { + const c = settings.hourglass; + const breath = 0.5 - 0.5 * Math.cos(this.age * c.lightTickRate); + return 1 + c.lightSwell * this._b.turn - c.lightFlicker * breath; + } + + /* ------------------------------------------------------------------ */ + /* The beats — every one of them a closed form of the live durations */ + /* ------------------------------------------------------------------ */ + + /** + * Refill `this._b`. + * + * `flow` is piecewise: +1 through the drain, `cos(π·k)` through the stall, + * −1 from there on. The cosine is not a taste decision — it is the cheapest + * curve that is C¹ at both ends *and* passes through zero at a single + * identifiable instant, which is what the turn one-shot fires on and what + * keeps the grains continuous. + * + * `travel` is its integral, in seconds, and it is what the volumes advect on. + * The stall contributes exactly zero over its whole width (∫cos over a half + * period), so the sand ends the stall having moved as far as it had at the + * start of it — it hesitated, it did not creep. + * + * `level` is deliberately **not** that integral. Making the heap the integral + * of the flow would tie `riseTime` to `drainTime` through the area under the + * curve, and those two want to be independent: the fall is slow enough to be + * boring and the climb is fast enough to be wrong, and that gap is the joke. + * The two agree in *sign* by construction, which is the only thing the eye + * checks. + * + * @param {number} t 0..1 through the impact phase, then 1..2 through the fade + */ + _resolveBeats(t) { + const c = settings.hourglass; + const b = this._b; + const w = this._windows(); + const seatSec = Math.max(0.02, c.seatTime) * Math.max(0.05, settings.global.lifetime); + + if (this.phase === AbilityPhase.TRAVEL) { + // The glass is already pouring as it flies out — flow is +1 from the + // first frame, so the integral is simply the age and joins the run's own + // integral at the timestamp captured in `onImpact()`. + b.seat = saturate(c.seedFill) * Easing.outQuad(this.u); + b.level = 0; + b.flow = 1; + b.travel = this.age; + b.turn = 0; + b.fade = 1; + return; + } + + const s = t <= 1 ? saturate(t) * w.total : w.total + (t - 1) * this.fadeDuration; + const afterStall = w.drain + w.stall; + + if (s <= w.drain) { + b.flow = 1; + b.travel = s; + b.level = saturate(s / w.drain); + } else if (s <= afterStall) { + const k = (s - w.drain) / w.stall; + b.flow = Math.cos(Math.PI * k); + b.travel = w.drain + (w.stall / Math.PI) * Math.sin(Math.PI * k); + b.level = 1; + } else { + b.flow = -1; + b.travel = w.drain - (s - afterStall); + b.level = saturate(1 - (s - afterStall) / w.rise); + } + + // The travel-phase integral is carried across on a timestamp, so the + // volumes' domains do not jump on the frame the front lands. + b.travel += this._travelSeconds; + b.turn = 1 - Math.abs(b.flow); + b.seat = lerp(saturate(c.seedFill), 1, Easing.outCubic(saturate(s / seatSec))); + b.fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry of the glass — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The floor point under the glass. Rides the front while it travels. */ + _floorPoint(out) { + const s = this.phase === AbilityPhase.TRAVEL ? this.u : 1; + return this.pointAt(s, out); + } + + /** Metres from the floor to the neck. Both cones are measured from here. */ + _waistY() { + const c = settings.hourglass; + return c.waistHeight * c.zoneRadius * this._b.seat; + } + + /** The neck, in world space: the apex of both cones and the light's home. */ + _waistPoint(out) { + this._floorPoint(out); + out.y = this._waistY(); + return out; + } + + /** The heap's apex — where the stream lands, and where it leaves from. */ + _pileApex(out) { + this._floorPoint(out); + out.y = this._pileHeight(); + return out; + } + + /** Height of the heap, metres. At `level` 1 it reaches `pileFill` of the neck. */ + _pileHeight() { + const c = settings.hourglass; + return Math.max(0, this._waistY() * c.pileFill * saturate(this._b.level)); + } + + /** + * Base radius of the heap, metres. + * + * A heap of dry granular material has one shape: a cone at its angle of + * repose. Deriving the radius from the height rather than giving it a slider + * of its own is what makes the pile *pile* — it widens as it grows, at a + * fixed slope, and dragging `reposeAngle` re-slopes it under a paused clock. + * The cap exists because a very shallow repose angle would otherwise put the + * skirt outside the circle the indicator drew. + */ + _pileRadius(height) { + const c = settings.hourglass; + const slope = Math.tan(clamp(c.reposeAngle, 5, 85) * DEG); + return Math.min(height / Math.max(slope, 0.05), c.pileMax * c.zoneRadius); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.grainEmitter.reset(); + this.dustEmitter.reset(); + this.moteEmitter.reset(); + this._warned = false; + this._turned = false; + this._travelSeconds = 0; + this._seed = Math.random() * 100; + + // The world, borrowed. `borrow()` is what gives it back on all four of the + // ways a cast can end, three of which are not this ability's idea. + this._grav = this.borrow(sceneHooks.acquire(Hook.GRAVITY, this)); + + this.scour.setVisible(true); + this.scour.clearMarks(); + + this._resolveBeats(0); + this._sync(); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current beats into everything that draws. + * + * Order is load-bearing exactly once, and it is the first three lines: the + * hook is written, the ledger is applied, and only then does anything read + * the multiplier back. Swap the second and third and every consumer spends + * the frame on the *previous* frame's gravity, which is invisible for the + * whole cast except on the one frame that matters. + */ + _sync() { + this._waistPoint(_waist); + /** The published multiplier at the neck, this frame. Read once, used by all. */ + this._gravity = this._syncGravity(); + + this._syncHulls(); + this._syncScour(); + this._syncParticles(this._gravity); + + // The light lives at the neck, not on the floor line the base class walks. + this.position.copy(_waist); + } + + /** + * Write the well, publish it, and read the multiplier back out of it. + * + * `sceneHooks.apply()` is idempotent — every hook blends from `settings`, not + * from the live value — so calling it here and again from `App.frame` costs + * one pass over six short arrays and changes nothing. It is called here for + * two reasons: the read-back below needs the published block rather than the + * token, and an ability whose effect is partly a hook is invisible to the + * harness's pause probe until something has actually written the uniforms. + * + * @returns {number} the signed gravity multiplier at the neck; exactly 1 when + * nothing is held, so every caller can multiply unconditionally + */ + _syncGravity() { + const c = settings.hourglass; + const b = this._b; + + if (this._grav) { + this._grav + .atPoint(_waist) + .well(Math.max(0.05, c.gravityRadius * c.zoneRadius), clamp(c.gravityEdge, 0.01, 1)) + // The one sign in the ability. `gravityInside` is a magnitude; `flow` + // is what turns it over. + .scale(b.flow * c.gravityInside, c.gravityOutside) + .blend(saturate(b.seat) * saturate(b.fade)); + } + + sceneHooks.apply(); + return sceneHooks.gravityAt(_waist.x, _waist.y, _waist.z); + } + + /** + * The two cones. + * + * Margin compensation, as in Pyroclasm and for the same reason: `Margin` + * holds the medium's nominal surface that fraction of the way *inside* the + * proxy, so a hull sized at the radius you want draws sand at + * `radius × (1 − margin)`. Dividing here makes the numbers in the block mean + * the size of the sand rather than the size of the box around it. + * + * `setSize`, never `mesh.scale` — the march's parameter is in world metres + * only because the hull's matrix stays rigid. + * + * These two are the one pair that does **not** read the published field, and + * the reason is arithmetic rather than principle: the hulls advect on the + * *integral* of `flow`, and an instantaneous published multiplier cannot be + * integrated in closed form. Accumulating it on the CPU instead would be + * exactly the state invariant I1 forbids — a paused slider could not + * re-resolve a sum that had already been spent. They share `flow`'s sign by + * construction, which is what the eye is checking. + */ + _syncHulls() { + const c = settings.hourglass; + const g = settings.global; + const b = this._b; + const radius = c.zoneRadius; + + /* --- the falling column: a cone that empties by shrinking --- */ + const open = lerp(1, saturate(c.bulbEmpty), saturate(b.level)); + const bulbR = Math.max(1e-3, c.bulbRadius * radius * b.seat * open); + const bulbL = Math.max(1e-3, c.bulbHeight * radius * b.seat * open); + const kSand = Math.max(1, c.hullSlack) / Math.max(0.2, 1 - c.sandMargin); + + this.sand + .place(_waist) + .setSize(bulbR * kSand, bulbR * kSand, bulbL * kSand) + .setFade(b.fade * saturate(b.seat)) + .sync(c, g); + + /* --- the heap: a cone that grows on its own angle of repose --- */ + const pileH = this._pileHeight(); + const pileR = this._pileRadius(pileH); + const kDune = Math.max(1, c.hullSlack) / Math.max(0.2, 1 - c.duneMargin); + _apex.set(_waist.x, Math.max(1e-3, pileH), _waist.z); + + this.dune + .place(_apex) + .setSize(Math.max(1e-3, pileR) * kDune, Math.max(1e-3, pileR) * kDune, Math.max(1e-3, pileH) * kDune) + // A heap with nothing in it is not a very small heap, it is no heap. + .setFade(b.fade * saturate(b.level * 8)) + .sync(c, g); + + /* --- the advection, as a displacement. See the class doc. --- */ + // What the shader computes is `rate × frame.uTime`, so the rate handed over + // is the metres we want divided by the clock those metres will be + // multiplied by. Continuous in the beat, and re-resolved from the sliders + // on a zero-length frame like everything else. + const clock = Math.max(frame.uTime.value, CLOCK_FLOOR); + const carry = (b.travel * g.noiseSpeed) / clock; + const su = this.sand.material.uniforms; + // The column's local +Z points up, so a downward stream is a negative jet. + su.uJet.value = -c.streamJet * carry; + su.uRise.value = -c.streamFall * carry; + const du = this.dune.material.uniforms; + // The heap's local +Z points down, so settling *is* the positive direction. + du.uJet.value = c.settleJet * carry; + du.uRise.value = -c.settleFall * carry; + } + + /** The drift the column cuts into the floor. */ + _syncScour() { + const c = settings.hourglass; + const g = settings.global; + const b = this._b; + + _ground.centre = this._floorPoint(_floor); + _ground.yaw = 0; + _ground.height = c.scourHeight; + _ground.radius = Math.max(0.1, c.scourSpread * c.zoneRadius); + _ground.grow = Easing.outCubic(saturate(b.seat)); + _ground.recede = Easing.inQuad(saturate(1 - b.fade)); + _ground.fade = b.fade; + _ground.seed = this._seed; + + _ground.edge = c.scourEdge; + _ground.ragged = c.scourRagged; + _ground.raggedScale = c.scourRaggedScale; + _ground.warp = c.scourWarp; + + _ground.relief = c.scourRelief; + _ground.normalStep = c.scourNormalStep; + _ground.ambient = c.scourAmbient; + _ground.wrap = c.scourWrap; + _ground.specular = c.scourSpecular; + _ground.gloss = c.scourGloss; + _ground.parallax = c.scourParallax; + + _ground.depth = c.scourDepth; + _ground.lift = c.scourLift; + _ground.arms = c.scourArms; + _ground.swirl = c.scourSwirl; + _ground.sharp = c.scourSharp; + _ground.detail = c.scourDetail; + // One rate, all cast — the spiral turns on the shared clock and a rate that + // moves mid-cast snaps the whole pattern. See the class doc. + _ground.speed = c.scourTurn; + + _ground.additive = false; + // The floor is the third thing that says the turn is coming: the grooves + // light up as the zone loses its weight. + _ground.emissive = c.scourEmissive * lerp(1, c.scourTurnGlow, saturate(b.turn)); + _ground.opacity = c.scourOpacity; + _ground.depthFade = c.scourDepthFade; + _ground.colorBase = c.colorScourBase; + _ground.colorEdge = c.colorScourEdge; + _ground.colorGlow = c.colorScourGlow; + _ground.colorDeep = c.colorScourDeep; + + _ground.noiseStrength = g.noiseStrength; + _ground.noiseFrequency = g.noiseFrequency; + _ground.noiseSpeed = g.noiseSpeed; + _ground.opacityScale = g.opacity; + + this.scour.update(_ground); + } + + /** + * The three particle systems. + * + * All three gravities are the published multiplier times the system's own + * authored acceleration, which is exactly what `gravityGLSL` promises on the + * GPU side: the hook hands out a *scale*, never a replacement vector, so the + * grain's −13.5 m/s² stays the grain's slider and the zone only decides which + * way it points. + */ + _syncParticles(gravity) { + const c = settings.hourglass; + const g = settings.global; + + this.grains.setGradient( + getColor(c.colorGrainA), + getColor(c.colorGrainB), + getColor(c.colorGrainC), + getColor(c.colorGrainD) + ); + this.grains.uniforms.uGravity.value.set(0, c.grainGravity * gravity, 0); + this.grains.uniforms.uSizeScale.value = c.grainSize * g.particleSize * 7; + this.grains.uniforms.uLifeScale.value = c.grainLifetime * 0.5 * g.particleLifetime; + this.grains.uniforms.uSpeedScale.value = g.particleSpeed; + this.grains.uniforms.uOpacity.value = g.opacity; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise * gravity, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise * gravity, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.moteGlow * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + } + + /** The seed leaving the caster's hand. */ + _castFx() { + const c = settings.hourglass; + const g = settings.global; + + _pos + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .setY(c.handHeight); + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.seatBurstSize * 0.3, + endRadius: c.seatBurstSize * 0.6 * g.explosionIntensity, + life: 0.3, + intensity: c.seatBurstIntensity * 0.7, + opacity: 0.75, + fresnel: 1.8, + displace: 0.4, + colorA: getColor(c.colorSeatA), + colorB: getColor(c.colorSeatB), + colorC: getColor(c.colorSeatC) + }); + + this.lightBoost = c.lightIntensity * 0.3 * g.explosionIntensity; + } + + /** + * Grains through the throat, dust where they land, motes in the shell. + * + * Which end is the source is decided by the *published* multiplier, not by + * the local beat: falling sand is born at the neck and lands on the heap, + * rising sand is born off the heap and lands at the neck. Everything else + * about the emission is identical, which is the whole point — the same throat + * running the other way. + * + * @param {number} scale 0..1, thinned once the glass is only holding + */ + _glassFx(dt, scale, gravity) { + const c = settings.hourglass; + const g = settings.global; + const time = frame.uTime.value; + const b = this._b; + // No sand moves at the top of the stall, so nothing is emitted there — and + // that hole in the emission is a large part of why the beat reads. + const rate = Math.abs(b.flow) * scale; + const falling = gravity >= 0; + + this._waistPoint(_waist); + this._pileApex(_apex); + const source = falling ? _waist : _apex; + const sink = falling ? _apex : _waist; + + const grainCount = Math.round(this.grainEmitter.tick(dt, c.grainRate * rate) * g.particleCount); + if (grainCount > 0) { + _emit.position = source; + _emit.radius = c.neckRadius; + _emit.anchor = null; + _emit.direction = _dir.set(0, falling ? -1 : 1, 0); + _emit.speed = c.grainSpeed; + _emit.speedVariance = 0.7; + _emit.spread = c.grainSpread; + _emit.inherit = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.grainLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = c.grainSpin; + _emit.tint = null; + _emit.time = time; + this.grains.emit(grainCount, _emit); + } + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * rate) * g.particleCount); + if (dustCount > 0) { + _pos.copy(sink); + _emit.position = _pos; + _emit.radius = c.dustSpread; + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.35; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + // The motes do not care which way the sand is going; they hang in the shell + // and are turned over by the same published field the grains read. + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + const bearing = Math.random() * TAU; + const shell = c.bulbRadius * c.zoneRadius * c.moteShell * randRange(0.55, 1.1); + _pos.copy(_waist); + _pos.x += Math.cos(bearing) * shell; + _pos.z += Math.sin(bearing) * shell; + _pos.y = randRange(0.1, Math.max(0.2, _waist.y + c.bulbHeight * c.zoneRadius)); + + _emit.position = _pos; + _emit.radius = 0.25; + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1; + _emit.inherit = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._resolveBeats(0); + this._sync(); + this._glassFx(dt, saturate(this._b.seat), this._gravity); + } + + /** The glass lands and stands up. */ + onImpact() { + const c = settings.hourglass; + const g = settings.global; + + // A timestamp, and the only reason one is captured: the flow integral has + // to carry across the phase change or the volumes' noise domain jumps by + // however long the front was in the air. + this._travelSeconds = this.age; + + this._resolveBeats(0); + this._waistPoint(_pos); + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.seatBurstSize * 0.25, + endRadius: c.seatBurstSize * g.explosionIntensity, + life: 0.5, + intensity: c.seatBurstIntensity, + opacity: 0.85, + fresnel: 1.7, + displace: 0.45, + squash: 0.8, + colorA: getColor(c.colorSeatA), + colorB: getColor(c.colorSeatB), + colorC: getColor(c.colorSeatC) + }); + + this._floorPoint(_pos); + this.ctx.decals.spawn(DecalType.DUSTRING, _pos, { + radius: c.scourSpread * c.zoneRadius * g.explosionIntensity, + life: 1.1, + intensity: 0.9, + colorA: getColor(c.colorScourBase), + colorB: getColor(c.colorScourEdge) + }); + + this.ctx.shake.add( + c.seatShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + onFade(dt, t) { + this._resolveBeats(t); + const b = this._b; + + // The telegraph: fired on the way *into* the weightless beat, at a fraction + // of the envelope rather than at a wall-clock offset, so re-timing the + // stall re-times the warning with it. + if (!this._warned && b.turn >= saturate(settings.hourglass.warnAt)) { + this._warned = true; + this._warnFx(); + } + // The turn: the frame the sign changes. A flag, not a timestamp — the beats + // already know where in the phase we are. + if (!this._turned && b.flow < 0) { + this._turned = true; + this._turnFx(); + } + + this._sync(); + this._glassFx(dt, t <= 1 ? 1 : b.fade, this._gravity); + + if (t <= 1) { + this.ctx.shake.rumble(settings.hourglass.rumble * settings.global.cameraShake, dt); + } + } + + /** The telegraph — the zone announces that it is about to change its mind. */ + _warnFx() { + const c = settings.hourglass; + const g = settings.global; + + this._waistPoint(_pos); + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.warnBurstSize * 0.15, + endRadius: c.warnBurstSize * g.explosionIntensity, + life: 0.75, + intensity: c.warnBurstIntensity, + opacity: 0.7, + fresnel: 2.4, + displace: 0.25, + colorA: getColor(c.colorWarnA), + colorB: getColor(c.colorWarnB), + colorC: getColor(c.colorWarnC) + }); + + this._floorPoint(_pos); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.warnRingRadius * g.explosionIntensity, + life: 0.9, + width: 0.04, + intensity: c.warnRingIntensity, + colorA: getColor(c.colorRingA), + colorB: getColor(c.colorRingB) + }); + + this.ctx.flash.trigger(getColor(c.colorWarnFlash), c.warnFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + /** The turn — the sand lets go of the floor. */ + _turnFx() { + const c = settings.hourglass; + const g = settings.global; + const time = frame.uTime.value; + + this._waistPoint(_pos); + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.turnBurstSize * 0.2, + endRadius: c.turnBurstSize * g.explosionIntensity, + life: 0.85, + intensity: c.turnBurstIntensity, + opacity: 0.9, + fresnel: 1.6, + displace: 0.55, + squash: 0.85, + colorA: getColor(c.colorTurnA), + colorB: getColor(c.colorTurnB), + colorC: getColor(c.colorTurnC) + }); + + // The heap coming off the floor all at once. Emitted with an *upward* + // direction and no gravity of its own — the published field, which has just + // changed sign, is what carries them up. + this._pileApex(_pos); + _pos.y = Math.max(0.05, _pos.y * 0.4); + _emit.position = _pos; + _emit.radius = Math.max(0.2, this._pileRadius(this._pileHeight()) * 0.9); + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.grainSpeed * 2.2; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.75; + _emit.life = c.grainLifetime * 1.6; + _emit.lifeVariance = 0.5; + _emit.spin = c.grainSpin * 1.4; + _emit.tint = null; + _emit.time = time; + this.grains.emit(Math.round(c.grainBurst * g.particleCount), _emit); + + this._floorPoint(_pos); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.turnRingRadius * g.explosionIntensity, + life: 0.7, + width: 0.05, + intensity: c.turnRingIntensity, + colorA: getColor(c.colorRingA), + colorB: getColor(c.colorRingB) + }); + + this.ctx.shake.add( + c.turnShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 26 + ); + this.ctx.flash.trigger(getColor(c.colorTurnFlash), c.turnFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onDestroy() { + // The hook itself is given back by the base class through `borrow()`, on + // every path a cast can end by; dropping the reference here just stops this + // instance writing to a token it no longer owns if it is re-pooled. + this._grav = null; + this._warned = false; + this._turned = false; + this._travelSeconds = 0; + this.sand.setFade(0); + this.dune.setFade(0); + this.scour.setVisible(false); + this.scour.clearMarks(); + } + + dispose() { + this.sand.dispose(); + this.dune.dispose(); + this.scour.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/chrono/RewindAbility.js b/src/abilities/chrono/RewindAbility.js new file mode 100644 index 0000000..3556ca5 --- /dev/null +++ b/src/abilities/chrono/RewindAbility.js @@ -0,0 +1,832 @@ +import { BufferAttribute, BufferGeometry, Color, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ShatterField, ShatterLayout, shatterParams } from '../../vfx/ShatterField.js'; +import { + timeField, + timeRegionParams, + reverseParams, + reverseTime, + reverseRate, + RewindGate +} from '../../vfx/TimeControl.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, hash11, randRange } from '../../utils/math.js'; + +/** + * Places along the line where the floor gives way, and therefore how many + * `RewindGate`s the ability owns. A structural ceiling in the sense + * `MAX_STRANDS` is in `ThunderAbility`: the gates are allocated once, and + * `breakGap` decides how far apart in *time* they actually fire, so a cast can + * use anywhere between one and all six of them without the array changing size. + */ +const BREAK_STATIONS = 6; +/** Fragment silhouettes. Two is the module's default and is right for rubble. */ +const SHARD_VARIANTS = 2; +/** Ring size for the debris. Six stations × ~13 fragments, with headroom. */ +const SHARD_CAPACITY = 128; + +const TAU = Math.PI * 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _origin = new Vector3(); +const _ground = groundFieldParams(); +const _shatter = shatterParams(); +const _warp = timeRegionParams(); +const _beat = reverseParams(); +const _look = { + colorA: new Color(), + colorB: new Color(), + colorEdge: new Color(), + colorScene: new Color(), + opacity: 1, + glow: 0, + rim: 0, + rimPower: 2, + shade: 1, + ambient: 0.3, + fadeStart: 0.8, + soft: 0.3, + sceneMix: 0, + refract: 0, + saturation: 1 +}; + +/** + * One lump of floor, in unit space, for the break. + * + * Chunky and closed rather than the flat flakes `ChronofractureAbility` breaks + * its panes into — this is stone coming out of the ground, and a flake reads as + * glass at any size. Deterministic: every vertex comes off `hash11` rather than + * `Math.random`, so two runs of the harness build byte-identical buffers and a + * change in the geometry checksum means something actually changed. + * + * @param {number} variant 0 or 1 — a wedge and a block + */ +function createRubbleGeometry(variant) { + const sides = 5 + variant * 2; + const rings = 2; + const count = sides * rings + 2; + const positions = new Float32Array(count * 3); + const indices = []; + + // Two rings between a bottom and a top point: the cheapest closed solid that + // still has a silhouette. A box would be cheaper and reads as a crate. + const write = (i, x, y, z) => { + positions[i * 3 + 0] = x; + positions[i * 3 + 1] = y; + positions[i * 3 + 2] = z; + }; + + for (let r = 0; r < rings; r++) { + const v = (r + 1) / (rings + 1); + const y = (v - 0.5) * (0.7 + 0.5 * hash11(variant * 3.1 + r * 7.7)); + for (let i = 0; i < sides; i++) { + const angle = (i / sides) * TAU + r * 0.4 + variant * 1.13; + const radius = 0.32 + 0.5 * hash11(i * 5.19 + r * 11.7 + variant * 2.3); + write(r * sides + i, Math.cos(angle) * radius, y, Math.sin(angle) * radius); + } + } + const bottom = sides * rings; + const top = bottom + 1; + write(bottom, 0, -0.55 - 0.2 * hash11(variant * 9.7), 0); + write(top, 0, 0.5 + 0.25 * hash11(variant * 13.3), 0); + + for (let i = 0; i < sides; i++) { + const j = (i + 1) % sides; + indices.push(bottom, j, i); + indices.push(i, j, sides + j); + indices.push(i, sides + j, sides + i); + indices.push(sides + i, sides + j, top); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +/** + * REWIND — a gouge torn down the line, and then untorn. + * + * ## THE TRICK — there is no reverse code in this file + * + * Not one effect here is written twice. The floor is a `GroundField(RUT)` whose + * track is a function of `progress`; the debris is a `ShatterField` whose every + * fragment is a closed-form flight `p(t) = p₀ + (v₀ − g/k)(1 − e^{−kt})/k + + * (g/k)t`; the dust is the shared particle system, which is nine attributes and + * a trajectory evaluated fresh in the vertex shader. All three are pure + * functions of a clock, and all three are handed the *same* clock: + * + * reverseTime(age, p) // forward at `rate` to `turnAt`, held, then back + * + * A number that goes up and then comes down. That is the ability. The roster + * entry's "the reason it works is that every effect in this project is a + * closed-form function of time" is not a boast, it is a load-bearing property, + * and this slot exists to spend it. + * + * ## The closed form, not the integrator + * + * `vfx/TimeControl.js` ships both `reverseTime()` and `TimeWarpClock`, and this + * uses the former for a reason that is easy to see and hard to undo: **an + * accumulated clock cannot be re-derived.** Pause halfway through the return, + * drag `backRate`, and the closed form re-times the entire reversal + * retroactively — the debris in the air re-flies from scratch and the gouge + * re-closes to a different length, on a frame of zero length. The integrator + * would keep every second it had already spent, and the slider would only bite + * going forward. Invariant I1 says the first behaviour is the correct one. + * + * ## How one region reverses somebody else's particles + * + * The time region is locked at the instant of the cast and given a rate of + * `reverseTime(age) / age`. That looks like a strange thing to publish until + * you write out what the field does with it. A region maps the world clock to + * + * interior = hold + (clock − hold) × rate + * + * and with `hold` stamped at the cast, `clock − hold` **is** `age`, so + * `interior = castClock + reverseTime(age)`: exactly the bent clock this + * ability is already driving its own modules with, published for everybody + * else. Forward at `forwardRate`, flat through the hold, then negative. No + * discontinuity anywhere, because the rate is derived from the clock rather + * than switched at the beats — the first version switched it, and every beat + * jumped the interior by `holdTime × backRate` seconds in one frame. + * + * The consequence is the good bit. A particle inside the sphere has its own age + * driven back down to zero, reaches its emitter, and **stops existing there** — + * the kill test runs on the bent age, so it un-spawns properly rather than + * fading out. Somebody else's dust gathers up as readily as this ability's, + * which is the point of the school. + * + * ## What reverses, and what had to be left out + * + * | | | + * | --- | --- | + * | `GroundField(RUT)` | **yes** — the track is `progress`, and `progress` is the clock | + * | `ShatterField` | **yes**, with one caveat below | + * | the shared particles | **yes**, but only through the region — an ability clock cannot reach them | + * | `RateEmitter` | **no.** A spawn is a log entry. A negative step drives its fractional accumulator the wrong way, emits nothing, and banks credit that dumps in one lump on the next forward frame. Emission is gated on `reverseRate() > 0` | + * | decals, fissures, bursts | **no.** Pooled one-shots on the app's forward clock; nothing can un-spawn one. This ability lays **no decals at all** — see the settings block | + * | `Ability.advance()` | **no**, and it is not ours to reverse. The front and the phase machine stay monotone; the bent clock feeds only the modules | + * + * **The `ShatterField` caveat, which cost an afternoon.** `update()` retires a + * fragment permanently the first time its own age passes `lifetime` — it writes + * `record.born = -1` and no clock brings it back. So a shard that runs out its + * life on the forward leg is simply *not there* to reassemble, and the failure + * looks like the reversal working perfectly for the last two stations and not + * at all for the first. `shardLife` therefore has to outlast `turnAt − breakAt`, + * and the schema says so on the slider. A fragment whose age goes *negative*, by + * contrast, is handled correctly and beautifully: it parks out of view, which is + * precisely "it has not broken off yet". + * + * ## The one thing that does not run backwards + * + * The pressure shell at the turn. It is a pooled `BurstSphere` on the world's + * clock and it could not reverse if it wanted to — but it also *must not*, + * because a beat that runs backwards cannot announce anything. It, the flash and + * the shake are the cast telling you which way time is about to go, and they are + * the only three things in the file that are allowed to be one-way. + * + * ## The rule that makes the editor work + * + * A cast captures a seed, the instant it started, and six gate states. The seed + * and the timestamp are events; a `RewindGate` holds one sign and a count, which + * is why it takes its `mark` as an argument to `poll()` rather than keeping it — + * drag `breakAt` while paused and the stations fire under your cursor. Every + * metre, every second and every rate is re-resolved from `settings.rewind` + * inside the update loop, on a zero-length frame included. + */ +export class RewindAbility extends Ability { + constructor(context) { + super('rewind', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the gouge --- */ + this.rut = new GroundField(this.group, { + mode: GroundMode.RUT, + additive: false, + renderOrder: 3, + name: 'rewind.rut' + }); + this.rut.setVisible(false); + + /* --- what comes out of it --- */ + this.shards = new ShatterField(this.group, { + geometry: createRubbleGeometry, + variants: SHARD_VARIANTS, + capacity: SHARD_CAPACITY, + additive: false, + depthWrite: true, + renderOrder: 5 + }); + + /** The slot in the shared field. May be `null` — I6. */ + this._region = null; + + /** + * One crossing latch per station. Allocated here and never again: a gate + * is two numbers, and `poll()` takes the mark it is watching as an + * argument, so the *positions* of all six stations stay live sliders while + * the gates themselves hold nothing but which side of them the clock is on. + */ + this._gates = []; + for (let i = 0; i < BREAK_STATIONS; i++) this._gates.push(new RewindGate()); + /** The turn itself, watched the same way. */ + this._turnGate = new RewindGate(); + + /** Re-rolled per cast so two wakes do not tear identically. A dice roll. */ + this._seed = 0; + /** The instant the cast started, in `frame.uTime`'s clock. A timestamp. */ + this._spawnClock = 0; + /** Live fragments, for the HUD readout. */ + this._live = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The dust off the head of the gouge. Non-additive so it genuinely + // occludes, and it is the system the whole trick is easiest to read on: + // when the clock turns, this is what visibly *gathers*. + this.dust = particles.get('rewind.dust', { + capacity: 2000, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 1.9; + this.dust.uniforms.uEndSize.value = 3.0; + this.dust.uniforms.uSizeIn.value = 0.12; + this.dust.uniforms.uFadeIn.value = 0.14; + this.dust.uniforms.uFadeOut.value = 0.34; + + // Chips flicked out of the trough. Lit rather than additive: rock. + this.grit = particles.get('rewind.grit', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.35; + this.grit.uniforms.uEndSize.value = 0.8; + this.grit.uniforms.uFadeOut.value = 0.65; + + this.dustEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live + 1; + } + + /** Seconds of cast age at which the clock stops going forward. */ + get _turnAt() { + return Math.max(0.05, settings.rewind.turnAt * settings.global.lifetime); + } + + /** Seconds it is held there. */ + get _holdSpan() { + return Math.max(0, settings.rewind.holdTime * settings.global.lifetime); + } + + /** + * How long the reverse leg takes, in seconds — derived, never typed. + * + * The clock has `turnAt × forwardRate − reachBack` seconds to give back and + * gives them back at `backRate`, so this is exactly the moment the closed form + * flattens out against its floor. Typing it as its own slider instead is how + * you get a cast that finishes half a second before the reversal does, every + * time somebody drags `backRate`. + */ + get _rewindSpan() { + const c = settings.rewind; + const peak = this._turnAt * c.forwardRate; + return Math.max(0, peak - c.reachBack) / Math.max(0.05, c.backRate); + } + + /** + * The impact phase carries the forward leg that is left after the front has + * landed, plus the hold. + * + * `turnAt` is an age rather than a phase offset, so the travel time has to + * come out of it — and travel time is `length / speed`, re-resolved here + * every frame rather than captured, which is why dragging `speed` mid-cast + * still lands the turn at the age you asked for. + */ + get impactDuration() { + const c = settings.rewind; + const travel = this.length / Math.max(1e-3, c.speed * settings.global.speed); + return Math.max(0.05, this._turnAt + this._holdSpan - travel); + } + + /** The fade is the reverse leg plus the settle after it. */ + get fadeDuration() { + return Math.max(0.05, this._rewindSpan + settings.rewind.settleTime); + } + + /** + * The key dips once the clock has turned over. + * + * The one place in the ability where the *lighting* states which way time is + * going. It is a step rather than a ramp because the turn is a beat and the + * whole design of `holdTime` is that the beat should be visible; a light that + * eases into the reversal hides the moment the ability is about. + */ + lightShimmer() { + const c = settings.rewind; + const rate = reverseRate(this.age, this._fillBeat()); + return rate < 0 ? 1 - saturate(c.lightSag) : 1; + } + + /* ------------------------------------------------------------------ */ + /* The clock */ + /* ------------------------------------------------------------------ */ + + /** + * Refill the module-scope `reverseParams()` block from live settings. + * + * Returned rather than stored so nothing can hold a stale copy across a + * slider drag. Every caller reads it immediately. + */ + _fillBeat() { + const c = settings.rewind; + _beat.rate = c.forwardRate; + _beat.turnAt = this._turnAt; + _beat.hold = this._holdSpan; + _beat.back = c.backRate; + _beat.floor = c.reachBack; + return _beat; + } + + /** The bent clock, in seconds of cast age. Can go negative — that is the job. */ + _bentAge() { + return reverseTime(this.age, this._fillBeat()); + } + + /** + * How far the head of the gouge has got, 0..1. + * + * `min` with `u` while the front is still travelling, because the tear cannot + * outrun the thing making it — and once the phase machine has landed, `u` is + * pinned at 1 and the clock has the head to itself, forwards and back. + */ + _head(bent) { + const c = settings.rewind; + const drawn = saturate(bent / Math.max(0.02, c.rutTime)); + return this.phase === AbilityPhase.TRAVEL ? Math.min(drawn, this.u) : drawn; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.gritEmitter.reset(); + this.shards.clear(); + this.rut.clearMarks(); + this.rut.setVisible(true); + + this._seed = Math.random() * 100; + this._spawnClock = frame.uTime.value; + this._live = 0; + for (const gate of this._gates) gate.reset(); + this._turnGate.reset(); + + // I6 — may be null, and every use below is guarded. Locked at the cast so + // that `clock − hold` is the cast's own age; see the class doc for why the + // rate the region is then given is `reverseTime(age) / age`. + this._region = this.borrow(timeField.acquire(this._spawnClock)); + this._region?.lock(this._spawnClock); + + this._sync(0); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the bent clock into the region and both meshes. + * + * @param {number} bent the clock, in seconds of cast age + */ + _sync(bent) { + this._syncRegion(); + this._syncRut(bent); + this._syncShardLook(); + this._syncShardFlight(); + this._live = this.shards.update(bent, _shatter); + this._syncParticles(); + + // The light rides the head of the gouge — including on the way home, which + // is the cheapest way to say that the *place* things are happening is + // running backwards too. + this.pointAt(this._head(bent), this.position); + this.position.y = 0.5; + } + + /** + * The region. Zero draw calls, and everybody else's half of the ability. + * + * `rate` is `reverseTime(age) / age` rather than `reverseRate(age)`. Those are + * two different quantities and picking the wrong one is the mistake worth + * naming: `reverseRate` is the *derivative* of the bent clock, and a region + * carries a **linear map about its hold instant**, not a speed. Publishing the + * derivative gives an interior that agrees with this ability only at the + * instant the leg started and drifts further out for every second afterwards. + */ + _syncRegion() { + const c = settings.rewind; + const region = this._region; + if (!region) return; + + this.pointAt(saturate(c.fieldAlong), _centre); + _centre.y = c.fieldHeight; + region.place(_centre); + + const age = this.age; + _warp.radius = Math.max(0.05, c.fieldRadius); + _warp.core = saturate(c.fieldCore); + _warp.strength = saturate(c.fieldStrength); + // Below a frame's worth of age the quotient is 0/0; the answer there is the + // forward rate, which is what the closed form is doing anyway. + _warp.rate = age > 1e-4 ? reverseTime(age, this._fillBeat()) / age : c.forwardRate; + region.sync(_warp); + } + + /** The gouge. Its whole beat is `progress`, and `progress` is the clock. */ + _syncRut(bent) { + const c = settings.rewind; + const g = settings.global; + const head = this._head(bent); + + _ground.centre = _origin.copy(this.origin); + _ground.yaw = Math.atan2(this.direction.x, this.direction.z); + _ground.height = c.rutHeight; + // In RUT the radius only sizes the quad; the track's own width is `width`. + _ground.radius = Math.max(0.2, c.rutWidth * 3); + _ground.length = Math.max(0.5, this.length); + _ground.progress = head; + _ground.grow = 1; + _ground.recede = 0; + _ground.inscribe = 1; + _ground.ignite = 0; + // The track does not dim as it closes — it *shortens*, and then the last of + // it goes. Fading it out instead reads as the mark healing over, which is a + // different and much weaker idea. + _ground.fade = head > 0.001 ? 1 : 0; + _ground.seed = this._seed; + + _ground.edge = c.rutEdge; + _ground.ragged = c.rutRagged; + _ground.raggedScale = c.rutRaggedScale; + _ground.warp = c.rutWarp; + + _ground.relief = c.rutRelief; + _ground.normalStep = c.rutNormalStep; + _ground.ambient = c.rutAmbient; + _ground.wrap = c.rutWrap; + _ground.specular = c.rutSpecular; + _ground.gloss = c.rutGloss; + _ground.parallax = c.rutParallax; + + _ground.width = c.rutWidth; + _ground.depth = c.rutDepth; + _ground.sharp = c.rutSharp; + _ground.cell = Math.max(0.05, c.rutChatter); + _ground.detail = c.rutChatterDepth; + _ground.lift = c.rutSpoil; + _ground.thickness = c.rutSpoilWidth; + _ground.seam = 0.05; + _ground.swirl = c.rutDrift; + _ground.speed = 1; + _ground.flow = 0; + _ground.windAngle = 0; + + _ground.additive = false; + _ground.emissive = c.rutEmissive; + _ground.opacity = c.rutOpacity; + _ground.depthFade = c.rutDepthFade; + _ground.colorBase = c.colorRutBase; + _ground.colorEdge = c.colorRutEdge; + _ground.colorGlow = c.colorRutChurn; + _ground.colorDeep = c.colorRutDeep; + + _ground.noiseStrength = g.noiseStrength; + _ground.noiseFrequency = g.noiseFrequency; + _ground.noiseSpeed = g.noiseSpeed; + _ground.opacityScale = g.opacity; + + this.rut.update(_ground); + } + + /** Colours and shading for the rubble. Pushed every frame, live. */ + _syncShardLook() { + const c = settings.rewind; + const g = settings.global; + + _look.colorA.copy(getColor(c.colorShardA)); + _look.colorB.copy(getColor(c.colorShardB)); + _look.colorEdge.copy(getColor(c.colorShardEdge)); + _look.colorScene.copy(getColor(c.colorShardScene)); + _look.opacity = c.shardOpacity * g.opacity; + _look.glow = c.shardGlow * g.glow; + _look.rim = c.shardRim * g.fresnel; + _look.rimPower = c.shardRimPower; + _look.shade = c.shardShade; + _look.ambient = c.shardAmbient; + _look.fadeStart = c.shardFadeStart; + _look.soft = c.shardSoft; + _look.sceneMix = c.shardSceneMix; + _look.refract = c.shardRefract; + _look.saturation = c.shardSaturation; + this.shards.sync(_look); + } + + /** The rubble's basis and flight, re-resolved every frame. */ + _syncShardFlight() { + const c = settings.rewind; + const g = settings.global; + + _shatter.layout = ShatterLayout.LINE; + _shatter.origin = _origin.copy(this.origin); + _shatter.direction = this.direction; + _shatter.side = this.side; + _shatter.length = this.length; + _shatter.width = Math.max(0.05, c.rutWidth); + _shatter.centre = null; + _shatter.radius = Math.max(0.05, c.rutWidth); + + _shatter.spawnRadius = c.shardScatter; + _shatter.spawnHeight = c.shardHeight; + + _shatter.speed = c.shardSpeed; + _shatter.speedJitter = c.shardSpeedJitter; + _shatter.spread = c.shardSpread; + _shatter.upBias = c.shardUp; + _shatter.inherit = null; + _shatter.inheritScale = 1; + + _shatter.gravity = c.shardGravity; + _shatter.drag = c.shardDrag; + + _shatter.size = c.shardSize * g.particleSize; + _shatter.sizeJitter = c.shardSizeJitter; + _shatter.shrink = c.shardShrink; + _shatter.shrinkPower = c.shardShrinkPower; + _shatter.spin = c.shardSpin; + _shatter.spinJitter = c.shardSpinJitter; + + _shatter.lifetime = Math.max(0.05, c.shardLife); + _shatter.floor = c.shardFloor; + _shatter.floorSpin = c.shardFloorSpin; + _shatter.randomness = g.randomness; + } + + /** Both particle systems, re-coloured and re-scaled every frame. */ + _syncParticles() { + const c = settings.rewind; + const g = settings.global; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLife * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = c.gritLife * 0.5 * g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + } + + /** The front leaving the caster. */ + _castFx() { + const c = settings.rewind; + const g = settings.global; + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.3 * g.explosionIntensity; + } + + /** + * Dust and grit off the head of the gouge. + * + * Gated on the sign of the clock by the caller, not here, so that this method + * stays the ordinary emission body every other ability in the project has — + * which is the point of the whole slot. + */ + _wakeFx(dt, head) { + const c = settings.rewind; + const g = settings.global; + const time = frame.uTime.value; + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate) * g.particleCount); + if (dustCount > 0) { + this.pointAt(Math.max(0, head - Math.random() * 0.12), _pos); + _pos.x += this.side.x * randRange(-c.rutWidth, c.rutWidth); + _pos.z += this.side.z * randRange(-c.rutWidth, c.rutWidth); + _pos.y = 0.15; + + _emit.position = _pos; + _emit.radius = c.rutWidth * 0.8; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.35).setY(1).normalize(); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.75; + _emit.spread = c.dustSpread; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLife; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + const gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate) * g.particleCount); + if (gritCount > 0) { + this.pointAt(Math.max(0, head - Math.random() * 0.08), _pos); + _pos.y = 0.08; + + _emit.position = _pos; + _emit.radius = c.rutWidth * 0.9; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.45).setY(1).normalize(); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.7; + _emit.spread = c.gritSpread; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLife; + _emit.lifeVariance = 0.45; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.grit.emit(gritCount, _emit); + } + } + + /** + * Poll the six stations against the bent clock. + * + * `poll()` returns `+1` on a forward crossing and `-1` on a backward one. The + * forward crossing throws a handful of fragments; the backward crossing does + * **nothing at all**, and that is the correct answer rather than a shortcut — + * the fragments thrown at that station have already flown themselves back into + * the floor by the time the clock reaches the mark, because their own age went + * negative on the way. There is nothing left to undo. The gate still has to be + * polled so it re-arms, which is the whole reason it is a crossing rather than + * a boolean. + */ + _pollStations(bent) { + const c = settings.rewind; + const g = settings.global; + const count = Math.max(0, Math.round(c.breakShards * g.particleCount)); + + for (let i = 0; i < BREAK_STATIONS; i++) { + const mark = c.breakAt + i * c.breakGap; + if (this._gates[i].poll(bent, mark) <= 0) continue; + // Where the head was when this station gave way — the same expression the + // gouge uses, so the rubble comes out of the trough rather than near it. + const along = this._head(mark); + this.shards.burst(bent, count, along, randRange(-0.7, 0.7)); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const bent = this._bentAge(); + this._pollStations(bent); + this._sync(bent); + this._wakeFx(dt, this._head(bent)); + this.ctx.shake.rumble(settings.rewind.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + // Nothing. The front reaching the end of the line is not a beat in this + // ability — the turn is, and the turn is an instant on the clock rather + // than a phase transition, so it is polled in `onFade` like everything else. + // Putting a punch here as well gives the cast two climaxes half a second + // apart and neither of them lands. + } + + onFade(dt, _t) { + const c = settings.rewind; + const g = settings.global; + const bent = this._bentAge(); + const rate = reverseRate(this.age, this._fillBeat()); + + // The turn, watched as a *crossing* of the peak rather than as a phase. On + // the way up the clock crosses it forward and nothing happens; a moment + // later it crosses back and that is the frame the shell goes off. + if (this._turnGate.poll(bent, this._turnAt * c.forwardRate) < 0) this._turnFx(); + + this._pollStations(bent); + this._sync(bent); + + // Emission only while the clock is going forward. See the class doc: a + // `RateEmitter` handed a reversal banks credit and dumps it later. + if (rate > 0) this._wakeFx(dt, this._head(bent)); + + if (rate > 0) { + this.ctx.shake.rumble(c.rumble * g.cameraShake, dt); + } else if (rate < 0) { + // A different, finer rumble on the way home — the floor closing is not + // the floor opening played at the same volume. + this.ctx.shake.rumble(c.rumble * 0.45 * g.cameraShake, dt); + } + } + + /** THE TURN — the one beat in the cast that is allowed to be one-way. */ + _turnFx() { + const c = settings.rewind; + const g = settings.global; + + this.pointAt(saturate(c.fieldAlong), _pos); + _pos.y = c.fieldHeight; + + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.turnBurstSize * 0.2, + endRadius: c.turnBurstSize * g.explosionIntensity, + life: 0.5, + intensity: c.turnBurstIntensity, + opacity: 0.75, + fresnel: 2.4, + displace: 0.3, + squash: 0.85, + colorA: getColor(c.colorTurnA), + colorB: getColor(c.colorTurnB), + colorC: getColor(c.colorTurnC) + }); + + this.ctx.shake.add( + c.turnShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + this.ctx.flash.trigger(getColor(c.colorTurnFlash), c.turnFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.7 * g.explosionIntensity; + } + + onDestroy() { + // Give the slot back on the frame the cast ends. `Ability#destroy()` would + // do it anyway through `borrow()` and `release()` is idempotent, so the + // ordinary path never leans on the safety net. + this._region = this._region?.release() ?? null; + this.shards.clear(); + this.rut.clearMarks(); + this.rut.setVisible(false); + for (const gate of this._gates) gate.reset(); + this._turnGate.reset(); + this._live = 0; + } + + dispose() { + this.rut.dispose(); + this.shards.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/chrono/StasisfieldAbility.js b/src/abilities/chrono/StasisfieldAbility.js new file mode 100644 index 0000000..b17e3cd --- /dev/null +++ b/src/abilities/chrono/StasisfieldAbility.js @@ -0,0 +1,827 @@ +import { SphereGeometry, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { DistortionField, DistortionMode } from '../../vfx/Distortion.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { timeField, timeRegionParams } from '../../vfx/TimeControl.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** + * Segments on the refraction hull. High enough that the silhouette is a circle + * at five metres and the shading normal is smooth; a sphere is the one shape + * where a coarse tessellation shows up as a *faceted refraction*, which reads + * as a disco ball rather than as glass. + */ +const HULL_SEGMENTS = 30; +const HULL_RINGS = 20; + +const TAU = Math.PI * 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _floor = new Vector3(); +const _ground = groundFieldParams(); +const _warp = timeRegionParams(); +const _glass = {}; + +/** + * STASIS FIELD — a sphere of stopped time dropped over the aimed circle. + * + * ## THE TRICK — it is not the ability that you look at + * + * Every other slot in this project is a thing that is drawn. This one is a + * thing that is *done to everything else*. It claims a slot in + * `vfx/TimeControl.js`'s four-wide `timeField`, stamps the instant it snapped + * shut, and publishes a centre, a radius and a **clock rate of zero**. Every + * shader that injects `shaders/lib/timewarp.glsl.js` then evaluates itself at + * the moment the field closed rather than at now, for as long as it is inside + * the sphere: + * + * bent = mix(clock, hold + (clock - hold) * rate, weight) + * + * With `rate = 0` that is a stasis field; the roster entry's "Implemented by + * clamping age inside a region rather than by drawing anything" is that line + * and nothing else. The ability's own visible budget is **two draw calls** — a + * refractive hull and a hex lattice on the floor — and both of them exist to + * say *where* the boundary is, not to be the effect. + * + * ## What actually stops, today, and what does not + * + * This is the honest part, and it is worth stating rather than discovering. + * + * **Particles stop, completely.** `particles/ParticleSystem.js` is the one + * consumer that already injects the chunk, and it does it properly: it probes + * each region at the position the particle's body had *on the frame that slot + * locked* rather than at the particle's current position, which is the only + * formulation with a fixed point. (Probe at the current position and the freeze + * thaws itself: the particle stops, the world clock runs on, the probe drifts, + * and one step later it lets go.) So a Cinder Fall's embers, a Storm Lance's + * sparks and motes, a Rimewalker's mist and this ability's own dust all hang + * exactly where they were, mid-arc, and resume when the field lets go. Since + * particles are most of the visible mass of most casts, this alone carries the + * ability. + * + * **The bolt itself does not.** `materials/LightningMaterial.js` does not + * inject the chunk, so a Storm Lance's filaments keep guttering inside the + * radius while its sparks are frozen in the air around them. The same is true + * of every other bespoke material, of `Swarm`, `Curtain` and `VolumeHull`, and + * `src/vfx/README.md` says so in the same words. Fixing it is one line per + * material — read `warpedTime(uTime, worldPos)` instead of `uTime`, at a probe + * position that does not itself depend on the clock — and it is deliberately + * **not** done here: it is a change to six shipped abilities' shading, made + * from inside a seventh, and that is not a trade this slot gets to make on its + * own. What it does instead is document the boundary and hold up its end. + * + * ## The arming lag, and why a lag and a rate are the same thing + * + * A region whose hold instant *is* the current instant is exact identity + * whatever its rate — `hold + (clock - hold) × r` collapses to `clock` when + * `hold = clock` — so simply ramping `rate` down while the field forms does + * nothing at all. The first version did exactly that and the field snapped from + * "no effect" to "everything stopped" in one frame, which reads as a bug. + * + * What does work is to lag the hold instant. Put `hold = clock - L` and the + * interior clock becomes `clock - L(1 - rate)`: a **uniform delay**, continuous + * in both `L` and `rate`, and continuous again at the moment the field shuts, + * because that is the frame we stop advancing `hold` and the expression is + * already sitting at `clock - L`. So `armLag` grows from 0 to its slider value + * as the seed flies, the world inside the closing sphere falls further and + * further behind, and then it stops. Air going syrupy, from one subtraction. + * + * ## The rule that makes the editor work + * + * A cast captures three numbers and every one of them is an event: `_seed`, + * `_spawnClock` and `_holdClock`. A timestamp names a moment, not a quantity. + * Every metre, every second and every rate is resolved from + * `settings.stasisfield` inside the update loop on every frame, a zero-length + * one included — which for this ability is unusually easy to see. Drop a field + * over a standing cast, press **P**, and drag `fieldScale`: the boundary + * between the particles that are moving and the particles that are not moves + * under your cursor, with the clock stopped. + * + * ## I6 + * + * `timeField.acquire()` returns `null` when all four slots are taken, on + * exactly the `ctx.lights.acquire()` contract. Every use of the handle here is + * guarded, and the degradation is deliberately *total*: with no slot the hull + * and the lattice still draw, the lattice still spreads — and nothing stops, + * including the ability's own dust, because the interior clock is read back out + * of `timeField.clockAt()` rather than recomputed on the side. A stasis field + * that cannot hold anything should look like a stasis field that cannot hold + * anything. + */ +export class StasisfieldAbility extends Ability { + constructor(context) { + super('stasisfield', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the glass: the only thing standing in the air --- */ + // A hull emitter rather than a billboard. `REFRACT` on a real sphere + // refracts along the sphere's own normal, so the skin bends what is behind + // it hardest where you are looking along it and not at all through the + // middle — which is what makes it read as a closed volume of glass instead + // of a circular smudge. It writes to the offset buffer and emits no light + // whatsoever; see the settings block for the glowing dome this is not. + this.hullGeometry = new SphereGeometry(1, HULL_SEGMENTS, HULL_RINGS); + this.glass = new DistortionField({ + mode: DistortionMode.REFRACT, + geometry: this.hullGeometry, + renderOrder: 1, + name: 'stasisfield.glass' + }); + this.group.add(this.glass.object3D); + + /* --- the floor mark, on the field's own arrested clock --- */ + this.lattice = new GroundField(this.group, { + mode: GroundMode.LATTICE, + additive: false, + renderOrder: 4, + name: 'stasisfield.lattice' + }); + this.lattice.setVisible(false); + + /** The slot in the shared field. May be `null` — I6. */ + this._region = null; + + /** Re-rolled per cast so two fields do not crawl identically. A dice roll. */ + this._seed = 0; + /** The instant the cast started, in `frame.uTime`'s clock. A timestamp. */ + this._spawnClock = 0; + /** The instant the field snapped shut. A timestamp; `-1` before the snap. */ + this._holdClock = -1; + /** Has the catch-up one-shot fired yet? An event, not a dimension. */ + this._released = false; + + /** + * The cast's beats, all unitless, refilled every frame. One object, reused. + * + * arm 0..1 how far the field is from closed + * rate 0..1 the clock rate inside it, live + * release 0..1 how far through the catch-up it is + * fade 1..0 master presence + */ + this._b = { arm: 0, rate: 1, release: 0, fade: 0 }; + } + + createParticles() { + const particles = this.ctx.particles; + + // The motes the field catches. Soft and additive, and given a *long* + // lifetime on purpose: a held particle does not spend its life, so a mote + // caught at 10% of its life is still at 10% two seconds later and comes + // back with almost all of it left. Give them a short one and the field + // empties itself while nothing is moving, which is a very strange sight. + this.motes = particles.get('stasisfield.motes', { + capacity: 1400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 2.4; + this.motes.uniforms.uEndSize.value = 0.5; + this.motes.uniforms.uSizeIn.value = 0.14; + this.motes.uniforms.uFadeIn.value = 0.16; + this.motes.uniforms.uFadeOut.value = 0.5; + + // A thin haze off the floor. Non-additive so it genuinely occludes — the + // one thing in this ability allowed to hide something. + this.haze = particles.get('stasisfield.haze', { + capacity: 900, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.haze.uniforms.uDrag.value = 2.6; + this.haze.uniforms.uEndSize.value = 2.2; + this.haze.uniforms.uSizeIn.value = 0.14; + this.haze.uniforms.uFadeIn.value = 0.2; + this.haze.uniforms.uFadeOut.value = 0.35; + + this.moteEmitter = new RateEmitter(); + this.hazeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + // Two meshes, and they are both singular. The interesting count for this + // ability is the one it is holding, which belongs to somebody else. + return this.glass.visible ? 2 : 1; + } + + /** The field holds through the whole impact phase. */ + get impactDuration() { + return Math.max(0.05, settings.stasisfield.holdTime * settings.global.lifetime); + } + + /** The fade is the catch-up plus the settle after it. */ + get fadeDuration() { + const c = settings.stasisfield; + return Math.max(0.05, c.releaseTime + c.settleTime); + } + + /** + * Dead steady. Not an oversight. + * + * Every other ability in the project shimmers its light — ice glints, flame + * breathes, lightning gutters — and every one of those is a statement that + * time is passing. A light that flickers over a sphere in which nothing is + * allowed to move is the single detail that would break the whole read, and + * it would break it before anybody consciously noticed why. The base class's + * default is a sine of `age`, so this override is the entire fix. + */ + lightShimmer() { + return 1; + } + + /* ------------------------------------------------------------------ */ + /* Geometry of the field — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the sphere hangs. Rides the seed out, then sits over the circle. */ + _centrePoint(out) { + const c = settings.stasisfield; + const s = this.phase === AbilityPhase.TRAVEL ? this.u : 1; + this.pointAt(s, out); + // Off the hand at the start, at its standing height once it has landed. + out.y = lerp(c.handHeight, c.fieldHeight, Easing.outQuad(s)); + if (this.phase === AbilityPhase.TRAVEL) { + out.addScaledVector(this.direction, c.handForward * (1 - s)); + } + return out; + } + + /** The floor point under the sphere. */ + _floorPoint(out) { + const s = this.phase === AbilityPhase.TRAVEL ? this.u : 1; + return this.pointAt(s, out); + } + + /** The held sphere's outer radius, metres. */ + _fieldRadius() { + const c = settings.stasisfield; + return Math.max(0.05, c.zoneRadius * c.fieldScale); + } + + /** + * Refill `this._b` from the phase clock. + * + * Fractions only. The beats decide *how far through* something is; the + * settings block decides how big it is in metres and how long it is in + * seconds, and both are re-read on the frame they change. + * + * @param {number} t 0..1 through the impact phase, then 1..2 through the fade + */ + _resolveBeats(t) { + const c = settings.stasisfield; + const b = this._b; + + if (this.phase === AbilityPhase.TRAVEL) { + b.arm = Math.pow(saturate(this.u), Math.max(0.05, c.armCurve)); + b.rate = lerp(1, saturate(c.holdRate), b.arm); + b.release = 0; + b.fade = b.arm; + return; + } + + if (t <= 1) { + b.arm = 1; + b.rate = saturate(c.holdRate); + b.release = 0; + b.fade = 1; + return; + } + + // The catch-up. `releaseFrac` is where in the fade phase the interior has + // finished re-joining the world; everything after it is the hull fading out + // over a scene that is already running normally again. + const releaseFrac = saturate(c.releaseTime / Math.max(c.releaseTime + c.settleTime, 1e-3)); + const s = saturate(t - 1); + b.arm = 1; + b.release = saturate(s / Math.max(releaseFrac, 1e-3)); + // Rate, not strength. Ramping the *weight* back to zero instead would also + // let the interior go, but it would do it from the rim inward and the + // middle of the sphere would be the last thing to move — which is backwards + // from how anything lets go of anything. + b.rate = lerp(saturate(c.holdRate), 1, Easing.inOutCubic(b.release)); + b.fade = 1 - Easing.inCubic(saturate((s - releaseFrac) / Math.max(1 - releaseFrac, 1e-3))); + } + + /** + * The clock the inside of the sphere is living on, as an age since the cast. + * + * Read back out of `timeField.clockAt()` — the module's own CPU mirror of + * `warpedTime()`, arithmetic for arithmetic — rather than recomputed here + * from the same numbers. Two implementations of one formula is two + * implementations that can disagree, and the way you find out that they have + * is that the floor mark stops half a frame before the dust does. + * + * Must be called *after* `region.sync()`, because the mirror reads the same + * uniform boxes the shaders will. + * + * @param {import('three').Vector3} centre the sphere's centre this frame + */ + _interiorAge(centre) { + return timeField.clockAt(frame.uTime.value, centre) - this._spawnClock; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.hazeEmitter.reset(); + + this._seed = Math.random() * 100; + this._spawnClock = frame.uTime.value; + this._holdClock = -1; + this._released = false; + + // I6 — may be null, and every use below is guarded. `borrow()` is what + // gives it back on the three ways a cast can end that are not this + // ability's idea; a leaked region stops a sphere of the world for the rest + // of the session and nobody would trace it back here. + this._region = this.borrow(timeField.acquire()); + + this.glass.visible = true; + this.lattice.setVisible(true); + + this._resolveBeats(0); + this._sync(); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current beats into the region and the two + * things that draw. + * + * Order is load-bearing exactly once: the region is synced **first**, because + * `_interiorAge()` reads the field back through its CPU mirror and a mirror + * of last frame's uniforms is a floor mark one frame out of step with the + * dust standing on it. + */ + _sync() { + this._centrePoint(_centre); + this._syncRegion(_centre); + + this._syncGlass(_centre); + this._syncLattice(this._interiorAge(_centre)); + this._syncParticles(); + + // The light lives at the centre of the sphere rather than on the floor line + // the base class walks out along. + this.position.copy(_centre); + } + + /** The region. Zero draw calls, and the entire ability. */ + _syncRegion(centre) { + const c = settings.stasisfield; + const b = this._b; + const region = this._region; + if (!region) return; + + // Until the field shuts, the hold instant chases the world clock at a fixed + // lag — see the class doc. `armLag` is scaled by the same curve the grip + // is, so the delay and the strength arrive together. + if (this._holdClock < 0) { + region.lock(frame.uTime.value - Math.max(0, c.armLag) * b.arm); + } + + region.place(centre); + _warp.radius = this._fieldRadius(); + _warp.core = saturate(c.fieldCore); + _warp.strength = saturate(c.fieldStrength) * b.arm; + _warp.rate = b.rate; + region.sync(_warp); + } + + /** + * The hull. + * + * Hull-mode emitters are placed by their own matrix rather than by + * `setAnchor`, which is the one place this module behaves like ordinary + * geometry — so the radius arrives as a *scale*, re-resolved here every + * frame, and dragging `fieldScale` while paused visibly re-sizes the glass + * and the held volume together because both read the same expression. + */ + _syncGlass(centre) { + const c = settings.stasisfield; + const b = this._b; + const radius = this._fieldRadius() * Math.max(0.01, c.glassScale); + + const mesh = this.glass.object3D; + mesh.position.copy(centre); + mesh.position.y += c.glassLift; + mesh.scale.set(radius, radius * Math.max(0.05, c.glassSquash), radius); + // The anchor is still used by the shared perspective falloff, so it is kept + // in step with the matrix rather than left at the origin. + this.glass.setAnchor(mesh.position); + + _glass.strength = c.glassStrength * b.fade; + _glass.power = c.glassPower; + _glass.ripple = c.glassRipple; + _glass.rippleScale = c.glassRippleScale * settings.global.noiseFrequency; + _glass.rippleSpeed = c.glassRippleSpeed * settings.global.noiseSpeed; + _glass.opacity = c.glassOpacity * b.fade; + _glass.depthReject = c.glassDepthReject; + _glass.depthFade = c.glassDepthFade; + _glass.perspective = c.glassPerspective; + _glass.perspectiveRef = c.glassPerspectiveRef; + _glass.seed = this._seed; + this.glass.update(_glass); + } + + /** + * The lattice, driven by the interior clock rather than by `this.age`. + * + * This is the only thing the ability draws that *obeys* the ability. The + * front spreads at `latticeTime` seconds to the rim, the field shuts, and the + * front stops wherever it had got to — for as long as the field holds, and + * then finishes in a rush during the catch-up. Every other floor mark in the + * project would keep spreading, because every other floor mark is on the + * ability's own monotone clock. + * + * @param {number} interior seconds of the field's own clock since the cast + */ + _syncLattice(interior) { + const c = settings.stasisfield; + const g = settings.global; + const b = this._b; + + _ground.centre = this._floorPoint(_floor); + _ground.yaw = 0; + _ground.height = c.latticeHeight; + _ground.radius = this._fieldRadius() * Math.max(0.05, c.latticeScale); + _ground.length = _ground.radius * 2; + + _ground.grow = saturate(interior / Math.max(0.02, c.latticeTime)); + _ground.recede = 0; + _ground.progress = 1; + _ground.inscribe = 1; + _ground.ignite = 0; + _ground.fade = b.fade; + _ground.seed = this._seed; + + _ground.edge = c.latticeEdge; + _ground.ragged = c.latticeRagged; + _ground.raggedScale = c.latticeRaggedScale; + _ground.warp = c.latticeWarp; + + _ground.relief = c.latticeRelief; + _ground.normalStep = c.latticeNormalStep; + _ground.ambient = c.latticeAmbient; + _ground.wrap = c.latticeWrap; + _ground.specular = c.latticeSpecular; + _ground.gloss = c.latticeGloss; + _ground.parallax = c.latticeParallax; + + _ground.cell = c.latticeCell; + _ground.seam = c.latticeSeam; + _ground.thickness = c.latticeThickness; + _ground.lift = c.latticeLift; + _ground.depth = c.latticeDepth; + _ground.sharp = c.latticeSharp; + _ground.detail = c.latticeDetail; + // The cell shader's own animation rate, held to the same rate the field is + // holding everything else to: a lattice whose interior keeps crawling while + // the front is frozen is two clocks in one object. + _ground.speed = c.latticeSpeed * b.rate; + + _ground.additive = false; + _ground.emissive = c.latticeEmissive; + _ground.opacity = c.latticeOpacity; + _ground.depthFade = c.latticeDepthFade; + _ground.colorBase = c.colorLatticeBase; + _ground.colorEdge = c.colorLatticeEdge; + _ground.colorGlow = c.colorLatticeGlow; + _ground.colorDeep = c.colorLatticeDeep; + + _ground.noiseStrength = g.noiseStrength; + _ground.noiseFrequency = g.noiseFrequency; + _ground.noiseSpeed = g.noiseSpeed; + _ground.opacityScale = g.opacity; + + this.lattice.update(_ground); + } + + /** Both particle systems, re-coloured and re-scaled every frame. */ + _syncParticles() { + const c = settings.stasisfield; + const g = settings.global; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = c.moteOpacity * g.opacity; + this.motes.uniforms.uGlow.value = c.moteGlow * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.haze.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.haze.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.haze.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.haze.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.haze.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.haze.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.haze.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /** The seed leaving the caster's hand. Almost nothing, on purpose. */ + _castFx() { + const c = settings.stasisfield; + const g = settings.global; + this._centrePoint(_pos); + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.25 * g.explosionIntensity; + } + + /** + * The trickle of dust into the field, and the haze under it. + * + * Motes are seeded **in a shell at the rim**, between `1 - rimBand` and the + * radius, because the rim is the only part of a stasis field where anything + * is still allowed to move: `weight` there is partway between 0 and 1, so the + * interior clock runs at a fraction of the real one and those motes *crawl*. + * A band of crawling dust wrapped around a sphere of dust that is not moving + * at all is the time-dilation gradient made visible, and it costs one + * `randRange`. + * + * The first version emitted uniformly through the volume. It fills the sphere + * with specks that are all equally motionless, which does not read as stopped + * time — it reads as a particle system that has hung. + * + * @param {number} scale 0..1, thinned once the field is only holding + */ + _fieldFx(dt, scale) { + const c = settings.stasisfield; + const g = settings.global; + const time = frame.uTime.value; + const radius = this._fieldRadius(); + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + const bearing = Math.random() * TAU; + const pitch = Math.acos(randRange(-1, 1)); + const r = radius * randRange(1 - saturate(c.rimBand), 1); + this._centrePoint(_pos); + _pos.x += Math.sin(pitch) * Math.cos(bearing) * r; + _pos.y += Math.cos(pitch) * r; + _pos.z += Math.sin(pitch) * Math.sin(bearing) * r; + // Never below the floor: a mote frozen under the stone is a mote nobody + // will ever see thaw. + _pos.y = Math.max(0.05, _pos.y); + + _emit.position = _pos; + _emit.radius = radius * saturate(c.rimBand) * 0.35; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.65; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + const hazeCount = Math.round(this.hazeEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (hazeCount > 0) { + const bearing = Math.random() * TAU; + const r = radius * Math.sqrt(Math.random()); + this._floorPoint(_pos); + _pos.x += Math.cos(bearing) * r; + _pos.z += Math.sin(bearing) * r; + _pos.y = 0.12; + + _emit.position = _pos; + _emit.radius = radius * 0.15; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.25; + _emit.tint = null; + _emit.time = time; + this.haze.emit(hazeCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._resolveBeats(0); + this._sync(); + // Emission ramps in with the grip: the seed is not yet a field and should + // not yet be shedding like one. + this._fieldFx(dt, this._b.arm); + this.ctx.shake.rumble(settings.stasisfield.rumble * settings.global.cameraShake, dt); + } + + /** SNAP — the field shuts. The only frame in the cast that punches. */ + onImpact() { + const c = settings.stasisfield; + const g = settings.global; + const time = frame.uTime.value; + + // Stop chasing the world clock. Everything inside is now held at this + // instant, less whatever lag the arming had already built up — which is why + // this is a timestamp read back off the region rather than a fresh one: + // re-stamping here would jerk the interior forward by `armLag` on exactly + // the frame the eye is watching hardest. + this._holdClock = this._region ? this._region.hold : frame.uTime.value; + + this._resolveBeats(0); + this._centrePoint(_pos); + + // A pressure shell rather than a fireball: the field arrives by taking + // something away, and AIR is the burst mode with the least body in it. + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.snapBurstSize * 0.25, + endRadius: c.snapBurstSize * g.explosionIntensity, + life: 0.45, + intensity: c.snapBurstIntensity, + opacity: 0.7, + fresnel: 2.6, + displace: 0.25, + squash: 0.9, + colorA: getColor(c.colorSnapA), + colorB: getColor(c.colorSnapB), + colorC: getColor(c.colorSnapC) + }); + + // One ring on the floor, at the boundary, so the footprint is stated once + // and then never mentioned again. + this._floorPoint(_floor); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _floor, { + radius: c.snapRingRadius * g.explosionIntensity, + life: 0.5, + width: 0.04, + intensity: c.snapRingIntensity, + colorA: getColor(c.colorRingA), + colorB: getColor(c.colorRingB) + }); + + // A last inward gasp of dust — thrown *at* the centre, so what the field + // catches is matter that was on its way somewhere. + const radius = this._fieldRadius(); + this._centrePoint(_centre); + _emit.position = _centre; + _emit.radius = radius * 0.9; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed * 2.5; + _emit.speedVariance = 0.8; + _emit.spread = 1; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(Math.round(c.snapMotes * g.particleCount), _emit); + + _emit.position = this._floorPoint(_floor); + _emit.radius = radius * 0.7; + _emit.speed = c.dustSpeed * 1.6; + _emit.spread = 0.95; + _emit.size = 1.1; + _emit.life = c.dustLifetime; + _emit.spin = 0.3; + this.haze.emit(Math.round(c.snapDust * g.particleCount), _emit); + + this.ctx.shake.add( + c.snapShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorSnapFlash), c.snapFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.9 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.stasisfield; + const g = settings.global; + + // The catch-up one-shot, on the first frame of the fade phase — which is + // the frame the interior clock starts moving again. + if (t > 1 && !this._released) { + this._released = true; + this._releaseFx(); + } + + this._resolveBeats(t); + this._sync(); + + // While the field is shut it keeps drawing dust in — the rim is the only + // place that emission is visible, and it is exactly where it is emitted. + // Once it lets go there is nothing left to catch. + this._fieldFx(dt, t <= 1 ? 1 : 1 - this._b.release); + + if (t <= 1) { + this.ctx.shake.rumble(c.rumble * 0.5 * g.cameraShake, dt); + } + } + + /** RELEASE — the field lets go and two and a half seconds arrive at once. */ + _releaseFx() { + const c = settings.stasisfield; + const g = settings.global; + + this._centrePoint(_pos); + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.releaseBurstSize * 0.3, + endRadius: c.releaseBurstSize * g.explosionIntensity, + life: 0.6, + intensity: c.releaseBurstIntensity, + opacity: 0.6, + fresnel: 2.2, + displace: 0.35, + colorA: getColor(c.colorReleaseA), + colorB: getColor(c.colorReleaseB), + colorC: getColor(c.colorReleaseC) + }); + + this.ctx.shake.add( + c.releaseShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 14 + ); + this.ctx.flash.trigger(getColor(c.colorReleaseFlash), c.releaseFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + onDestroy() { + // Give the slot back on the frame the cast ends. `Ability#destroy()` would + // do it anyway through `borrow()`, and `release()` is idempotent, so doing + // it here as well costs nothing and means the *ordinary* path never leans + // on the safety net. + this._region = this._region?.release() ?? null; + this._holdClock = -1; + this._released = false; + // Releases the distortion pass's writer counter. Hiding the group instead + // leaks it for the session and the pass then runs every frame with nothing + // in it. + this.glass.visible = false; + this.lattice.setVisible(false); + this._b.arm = 0; + this._b.fade = 0; + this._b.rate = 1; + this._b.release = 0; + } + + dispose() { + this.glass.dispose(); + this.hullGeometry.dispose(); + this.lattice.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/flame/DragonbreathAbility.js b/src/abilities/flame/DragonbreathAbility.js new file mode 100644 index 0000000..779c521 --- /dev/null +++ b/src/abilities/flame/DragonbreathAbility.js @@ -0,0 +1,854 @@ +import { Matrix4, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { FilamentPaths, filamentLook } from '../../vfx/FilamentPaths.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** + * Contact samples the floor burn carries. + * + * Twenty, and the posting step is clamped so a full-length track always fits + * inside them — see `_postContacts()`. The list is a ring buffer, so overrunning + * it would quietly evict the samples nearest the caster, which are exactly the + * ones carrying the taper that stands in for a wedge. + */ +const CONTACTS = 20; + +/* Module-scope scratch. A sustained breath allocates nothing — I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _apex = new Vector3(); +const _mouth = new Vector3(); +const _from = new Vector3(); +const _to = new Vector3(); +const _fwd = new Vector3(); +const _right = new Vector3(); +const _localUp = new Vector3(); +const _up = new Vector3(0, 1, 0); +const _basis = new Matrix4(); +/** The ground field's params bag, refilled from settings every frame. */ +const _burn = groundFieldParams(); +/** The filament strip's shared look, likewise. */ +const _look = filamentLook(); + +/** + * WYRM'S BREATH — a **real** cone of flame, sustained. + * + * A line cast held open. The apex is pinned at the caster's hand, the mouth is + * out at the aimed point, and the gout between them is a raymarched volume + * inside a `CONE` hull — not a widening billboard, not a stack of camera-facing + * cards. The floor beneath it takes a burn that grows along the cone's own + * ground intersection while the breath is held, and a handful of loose + * filaments lick out past the front edge. + * + * ## THE TRICK — the density peak is off the axis + * + * `VolumeHull`'s cone silhouette is + * + * core = 1 − |r − hollow| / (1 − hollow) + * + * where `r` is normalised by the *local* radius, so it means the same thing at + * every station down the cone. With `flameHollow` at 0 that is `1 − r`: a solid + * cone, densest on the axis, and it reads as a cardboard megaphone from every + * single angle because there is nothing to see through. Push it to about 0.45 + * and the peak moves halfway out to the wall with the middle left empty — which + * is what a nozzle actually does, since fuel burns in the shear layer and not + * in the core. Orbit the cast and you look *through* the near wall of the + * tongue at the far one, and the thing stops being a shape and becomes a + * volume. That single number is the ability. + * + * The sustain is the second half of it. `Medium.FLAME` samples its field in + * `localDomain()` — hull space — and `flameJet` slides that domain backwards + * along the hull's own +Z, so the turbulence crawls *outward* at a real + * metres-per-second and yaws with the caster instead of being swum through. + * (Pyroclasm needs precisely the opposite and its comment says so; the two + * abilities are the two halves of the same argument.) + * + * ## Why the hull is not placed with `place()` + * + * `VolumeHull#place()` applies **yaw only**, and it is right to: a dome or a + * floor slab that pitched with the aim line would lift off the ground at one + * edge and bury itself at the other. A breath cone is the exception — its apex + * is at chest height and its mouth is nearly on the floor, so it has to pitch. + * `_placeCone()` builds the rigid basis itself. Rigid is the requirement, not + * axis-aligned: the march carries one parameter in world metres and only cares + * that the matrix has no scale in it. + * + * ## Reach, and why the burn follows it + * + * Every metre downstream comes from one number: `span`, the distance from the + * apex to the mouth, re-derived every frame from `this.length × reach`. The + * hull's three half-extents, the tongues' start and overshoot, the light's + * seat, the burn's length — all of them. So dragging `reach` on a paused, + * standing breath shortens the gout **and pulls the floor burn back in with + * it**, because the burn's `length` is that same number and `GroundField` + * re-scales its quad, its grain and its growth front together. A decal spawned + * at impact captures its radius on the frame it was born; this cannot. + * + * The half-extents are divided by `1 − flameMargin` before they reach + * `setSize()`. That is not a fudge factor: the margin holds the medium's + * nominal surface that fraction *inside* the proxy, so compensating for it is + * what makes `reach` mean the reach of the flame rather than the reach of an + * invisible box around it. Without the compensation, raising the margin to stop + * the erosion being sliced also silently shortened the gout, and the two + * sliders fought each other every time. + * + * ## What a cast captures + * + * `_seed`, one dice roll, and `_contactDistance`, a metres-of-front-travel + * counter that paces the contact samples — the same bookkeeping + * `ThunderAbility._burnDistance` does, and for the same reason. No dimension is + * stored: every metre, radian, second and colour is resolved against + * `settings.dragonbreath` inside the update loop, zero-length frames included. + * + * ## Cost + * + * Four draw calls: the volume, the floor burn, and the filament strip's two + * passes. One dynamic light. + */ +export class DragonbreathAbility extends Ability { + constructor(context) { + super('dragonbreath', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The gout. Emissive, so the shader is compiled without the self-shadow + * branch at all — `VolumeHull` does that for `Medium.FLAME` by default, and + * it is what pays for 38 steps on a hull this close to the eye. + */ + this.hull = new VolumeHull({ + hull: HullShape.CONE, + medium: Medium.FLAME, + prefix: 'flame', + maxSteps: 56, + renderOrder: 12 + }); + this.group.add(this.hull.mesh); + + /** + * The floor. `RUT` is the only `GroundField` mode whose front runs *down* a + * length rather than out from a centre, which is what a cone's ground + * intersection needs. It carries a live `length` and a live `progress`, and + * a list of contact samples that let the burn taper toward the caster. + */ + this.scorch = new GroundField(this.group, { + mode: GroundMode.RUT, + marks: CONTACTS, + additive: false, + name: 'Dragonbreath:burn' + }); + this.scorch.setVisible(false); + + /** + * The tongues. One role of the shared strip — two draw calls whatever the + * filament count, because the halo is drawn as real ribbon underneath the + * core rather than left to bloom, which is what keeps the glow attached to + * every kink. + */ + this.tongues = new FilamentPaths(this.group, { + samples: 56, + capacity: 24, + renderOrder: 13 + }); + this.tongues.visible = false; + + /** Re-rolled per cast so no two breaths draw the same flame. */ + this._seed = 0; + /** Metres of front travel already paid out in contact samples. */ + this._contactDistance = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Sparks torn out of the mouth. Velocity-stretched, so a fast one reads as + // a streak of burning fuel rather than as a dot. + this.embers = particles.get('dragonbreath.embers', { + capacity: 3000, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.embers.uniforms.uDrag.value = 1.5; + this.embers.uniforms.uEndSize.value = 0.22; + this.embers.uniforms.uSizeIn.value = 0.03; + this.embers.uniforms.uFadeIn.value = 0.04; + this.embers.uniforms.uFadeOut.value = 0.45; + + // Burning fuel that failed to burn, falling out of the underside of the + // cone. This is the system that tells you the gout has weight. + this.drips = particles.get('dragonbreath.drips', { + capacity: 1200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.35 + }); + this.drips.uniforms.uDrag.value = 0.9; + this.drips.uniforms.uEndSize.value = 0.28; + this.drips.uniforms.uSizeIn.value = 0.06; + this.drips.uniforms.uFadeIn.value = 0.07; + this.drips.uniforms.uFadeOut.value = 0.5; + + // Smoke off the burnt floor. Non-additive so it genuinely occludes. + this.smoke = particles.get('dragonbreath.smoke', { + capacity: 2000, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.smoke.uniforms.uDrag.value = 1.8; + this.smoke.uniforms.uEndSize.value = 3.1; + this.smoke.uniforms.uSizeIn.value = 0.12; + this.smoke.uniforms.uFadeIn.value = 0.16; + this.smoke.uniforms.uFadeOut.value = 0.3; + + this.emberEmitter = new RateEmitter(); + this.dripEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + this.shellEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.tongues.liveCount; + } + + /** The sustain. This is the beat the ability is for. */ + get impactDuration() { + return Math.max(0.1, settings.dragonbreath.sustainTime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.dragonbreath.fadeTime); + } + + /** Flame breathes rather than gutters: a fast, shallow, unquantised wobble. */ + lightShimmer() { + return 0.88 + 0.12 * Math.sin(this.age * 17.3) * Math.sin(this.age * 6.1); + } + + /* ------------------------------------------------------------------ */ + /* The beats */ + /* ------------------------------------------------------------------ */ + + /** 0..1 through the gutter-out. */ + _gutter() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / Math.max(0.05, settings.dragonbreath.fadeTime)); + } + + /** Master fade. Thins the medium as well as dimming it — see `setFade()`. */ + _fadeAmount() { + return 1 - Easing.inQuad(this._gutter()); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** + * How far down the cast line the mouth currently sits, as a fraction of + * `this.length`. + * + * `reach` is the live multiplier; `this.length` is the distance the cast was + * aimed at, which the base class owns. During the gutter the mouth walks back + * toward the caster, so the gout is *withdrawn* rather than merely faded — + * fading alone leaves a ghost cone hanging in the air at full length, which + * looked like the effect had been switched off rather than stopped. + */ + _reachFraction() { + const c = settings.dragonbreath; + if (this.phase === AbilityPhase.TRAVEL) return this.u * c.reach; + if (this.phase !== AbilityPhase.FADE) return c.reach; + return c.reach * lerp(1, c.dieback, Easing.inQuad(this._gutter())); + } + + /** The apex, at the caster's hand. */ + _apexPoint(out) { + const c = settings.dragonbreath; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** The centre of the mouth, out at the target. */ + _mouthPoint(out) { + this.pointAt(this._reachFraction(), out); + out.y = settings.dragonbreath.endHeight; + return out; + } + + /** + * A point on the cone's axis, `f` of the way from apex to mouth. + * + * `f` past 1 extrapolates, which is deliberate: the tongues aim past the + * mouth and that is the only reason they can outrun the volume. + */ + _axisPoint(f, out) { + this._apexPoint(_apex); + this._mouthPoint(_mouth); + return out.copy(_apex).lerp(_mouth, f); + } + + /** Apex-to-mouth distance, metres. */ + _span() { + this._apexPoint(_apex); + this._mouthPoint(_mouth); + return Math.max(0.05, _apex.distanceTo(_mouth)); + } + + /** Half-width of the mouth, metres. Every fan in the cast is scaled off it. */ + _mouthRadius() { + return this._span() * settings.dragonbreath.coneFlare; + } + + /** + * Pin the hull: apex at `_apex`, local +Z down the axis, local +Y as close to + * world up as the pitch allows. + * + * See the class comment for why this does not go through `place()`. The + * degenerate case — a cast aimed straight up or down — leaves `up × forward` + * at zero length, and a normalised zero is NaN geometry that the harness + * reports as a non-finite instance matrix rather than as anything you could + * look at, so it is guarded rather than trusted. + */ + _placeCone() { + const mesh = this.hull.mesh; + _fwd.subVectors(_mouth, _apex); + const span = _fwd.length(); + if (span < 1e-4) _fwd.copy(this.direction); + else _fwd.divideScalar(span); + + _right.crossVectors(_up, _fwd); + if (_right.lengthSq() < 1e-8) _right.copy(this.side); + _right.normalize(); + _localUp.crossVectors(_fwd, _right).normalize(); + + _basis.makeBasis(_right, _localUp, _fwd); + mesh.quaternion.setFromRotationMatrix(_basis); + mesh.position.copy(_apex); + mesh.updateMatrix(); + } + + /** Yaw of the cast, radians — the frame the floor burn is drawn in. */ + _yaw() { + return Math.atan2(this.direction.x, this.direction.z); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.emberEmitter.reset(); + this.dripEmitter.reset(); + this.smokeEmitter.reset(); + this.shellEmitter.reset(); + this._contactDistance = 0; + this.scorch.clearMarks(); + + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + + this.scorch.setVisible(false); + this._sync(1); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** @param {number} fade 1 while the breath is held, ramping to 0 as it gutters */ + _sync(fade) { + this._apexPoint(_apex); + this._mouthPoint(_mouth); + this._syncVolume(fade); + this._syncTongues(fade); + this._syncBurn(fade); + this._syncParticles(); + } + + /** The cone itself. */ + _syncVolume(fade) { + const c = settings.dragonbreath; + const g = settings.global; + const span = Math.max(0.05, _apex.distanceTo(_mouth)); + + // Margin compensation: the medium's nominal surface sits `flameMargin` of + // the way inside the proxy, so the proxy has to be that much bigger for the + // flame's own reach to equal `span`. `coneSlack` is the extra headroom the + // erosion spills into on top of that. + const k = Math.max(1, c.coneSlack) / Math.max(0.2, 1 - c.flameMargin); + const half = span * c.coneFlare; + + this._placeCone(); + this.hull + // CONE: x and y are the mouth's half-extents, z is the axial length. + .setSize(half * k, half * c.coneSquash * k, span * k) + .setFade(fade) + .sync(c, g); + } + + /** + * The tongues at the front edge. + * + * They are drawn *short* and their draw length oscillates about + * `tongueLickBase`, so on the up-swing they run past the volume's own tip and + * on the down-swing they fall back inside it. The first version oscillated + * their target point instead, which moves the whole filament including the + * end anchored in the cone — the tongues slid back and forth like pistons + * rather than licking, and the fault was not visible until they were slowed + * down. + */ + _syncTongues(fade) { + const c = settings.dragonbreath; + const g = settings.global; + const span = Math.max(0.05, _apex.distanceTo(_mouth)); + const role = this.tongues.role(0); + + _from.copy(_apex).lerp(_mouth, c.tongueStart); + _to.copy(_apex).lerp(_mouth, c.tongueOvershoot); + + role.count = Math.max(0, Math.round(c.tongueCount)); + role.line( + _from, + _to, + c.tongueSag, + c.tongueNear, + span * c.coneFlare * c.tongueFan, + c.tongueSpreadCurve, + c.tongueTwist, + c.tongueTwistSpeed, + c.tongueConverge + ); + role.style(c.tongueKink, c.tongueWidthScale, c.tongueDim, c.tongueGroundDamp); + role.ends(c.tongueFadeStart, c.tongueFadeEnd, c.tongueTaperStart, c.tongueTaperEnd); + + const lick = c.tongueLickBase + c.tongueLickDepth * Math.sin(this.age * c.tongueLickSpeed * TAU); + // While the gout is still on its way out the tongues cannot be ahead of a + // front that has not arrived, so the travel progress caps the lick. + const drawn = this.phase === AbilityPhase.TRAVEL ? Math.min(this.u, lick) : lick; + role.draw(drawn, c.tongueTipLength, c.tongueFloor, c.tongueTipGlow); + + _look.width = c.tongueWidth; + _look.glowWidth = c.tongueGlowWidth; + _look.glowOpacity = c.tongueGlowOpacity; + _look.jitter = c.tongueJitter; + _look.jitterScale = c.tongueJitterScale; + _look.octaves = c.tongueOctaves; + _look.jitterFalloff = c.tongueJitterFalloff; + _look.crawl = c.tongueCrawl; + _look.pinch = c.tonguePinch; + _look.restrike = c.tongueRestrike; + _look.flicker = c.tongueFlicker; + _look.flickerSpeed = c.tongueFlickerSpeed; + _look.strandFlash = c.tongueStrandFlash; + _look.coreSharp = c.tongueCoreSharp; + _look.glowFalloff = c.tongueGlowFalloff; + _look.softFade = c.tongueSoftFade; + _look.opacity = c.tongueOpacity; + _look.glow = c.tongueGlow; + _look.colorCore = c.colorTongueCore; + _look.colorInner = c.colorTongueInner; + _look.colorOuter = c.colorTongueOuter; + _look.colorHalo = c.colorTongueHalo; + _look.randomness = g.randomness; + _look.noiseStrength = g.noiseStrength; + _look.noiseFrequency = g.noiseFrequency; + _look.noiseSpeed = g.noiseSpeed; + _look.opacityScale = g.opacity; + _look.glowScale = g.glow; + + // `sync()` overwrites the counts, so they are set above, every frame. + this.tongues.sync(_look, fade, this._seed); + this.tongues.visible = fade > 0.01 && role.count > 0; + } + + /** The floor, burning along the cone's ground intersection. */ + _syncBurn(fade) { + const c = settings.dragonbreath; + const g = settings.global; + + // The horizontal run of the track, from under the apex to under the mouth. + // `full` is what the quad is sized to and `now` is how far the burn has + // been drawn — both live, so `reach` moves them together. + const full = Math.max(0.3, this.length * c.reach - c.handForward); + const now = Math.max(0, this.length * this._reachFraction() - c.handForward); + + _pos.set(_apex.x, 0, _apex.z); + _burn.centre = _pos; + _burn.yaw = this._yaw(); + _burn.height = c.scorchHeight; + _burn.length = full; + _burn.progress = saturate(now / full); + _burn.fade = fade; + _burn.seed = this._seed; + + _burn.edge = c.scorchEdge; + _burn.raggedScale = c.scorchRaggedScale; + + _burn.relief = c.scorchRelief; + _burn.normalStep = c.scorchNormalStep; + _burn.ambient = c.scorchAmbient; + _burn.wrap = c.scorchWrap; + _burn.specular = c.scorchSpecular; + _burn.gloss = c.scorchGloss; + _burn.parallax = c.scorchParallax; + + // The track is as wide as the mouth it came out of, so widening the cone + // widens the burn. A cone's footprint is really a wedge and none of + // GroundField's ten modes draws one — the taper is carried by the contact + // samples instead, which shade and thin the near end. + _burn.width = Math.max(0.05, this._mouthRadius() * c.scorchWidth); + _burn.depth = c.scorchDepth; + _burn.lift = c.scorchLift; + _burn.thickness = c.scorchThickness; + _burn.seam = c.scorchSeam; + _burn.cell = c.scorchCell; + _burn.swirl = c.scorchWander; + _burn.sharp = c.scorchSharp; + _burn.detail = c.scorchDetail; + + _burn.additive = false; + _burn.emissive = c.scorchEmissive; + _burn.opacity = c.scorchOpacity; + _burn.depthFade = c.scorchDepthFade; + _burn.colorBase = c.colorScorchBase; + _burn.colorEdge = c.colorScorchEdge; + _burn.colorGlow = c.colorScorchGlow; + _burn.colorDeep = c.colorScorchDeep; + + _burn.noiseStrength = g.noiseStrength; + _burn.noiseFrequency = g.noiseFrequency; + _burn.noiseSpeed = g.noiseSpeed; + _burn.opacityScale = g.opacity; + + this.scorch.update(_burn); + this.scorch.setVisible(fade > 0.004 && _burn.progress > 0.002); + } + + /** The three particle systems. */ + _syncParticles() { + const c = settings.dragonbreath; + const g = settings.global; + + this.embers.setGradient( + getColor(c.colorEmberA), + getColor(c.colorEmberB), + getColor(c.colorEmberC), + getColor(c.colorEmberD) + ); + this.embers.uniforms.uGravity.value.set(0, c.emberGravity, 0); + this.embers.uniforms.uSizeScale.value = c.emberSize * g.particleSize * 7; + this.embers.uniforms.uLifeScale.value = c.emberLifetime * 0.5 * g.particleLifetime; + this.embers.uniforms.uSpeedScale.value = g.particleSpeed; + this.embers.uniforms.uOpacity.value = g.opacity; + this.embers.uniforms.uGlow.value = c.emberGlow * g.glow; + this.embers.uniforms.uStretch.value = c.emberStretch; + this.embers.uniforms.uTurbulence.value = 0.3 * g.turbulence; + + this.drips.setGradient( + getColor(c.colorDripA), + getColor(c.colorDripB), + getColor(c.colorDripC), + getColor(c.colorDripD) + ); + this.drips.uniforms.uGravity.value.set(0, c.dripGravity, 0); + this.drips.uniforms.uSizeScale.value = c.dripSize * g.particleSize * 7; + this.drips.uniforms.uLifeScale.value = c.dripLifetime * 0.5 * g.particleLifetime; + this.drips.uniforms.uSpeedScale.value = g.particleSpeed; + this.drips.uniforms.uOpacity.value = g.opacity; + this.drips.uniforms.uGlow.value = c.dripGlow * g.glow; + this.drips.uniforms.uTurbulence.value = c.dripTurbulence * g.turbulence; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.35 * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Contact samples posted as the front runs out. + * + * Paced by *distance*, the way `ThunderAbility` paces its ground burns, so + * the samples are evenly spaced along the track however fast the front is + * moving. The step is floored at one-nineteenth of the full track: `markRate` + * is a slider and a generous one would otherwise overrun the ring buffer and + * evict the near-caster samples, which are the ones holding the taper. + */ + _postContacts() { + const c = settings.dragonbreath; + const full = Math.max(0.3, this.length * c.reach - c.handForward); + const step = Math.max(1 / Math.max(0.05, c.markRate), full / (CONTACTS - 1)); + const time = frame.uTime.value; + + while (this.front - this._contactDistance >= step) { + this._contactDistance += step; + const s = saturate((this._contactDistance - c.handForward) / full); + // x is unused by RUT; z is the fraction along the track and w the force. + this.scorch.mark(0, s, time, lerp(c.markNear, 1, s)); + } + } + + /** The flare at the hand as the breath starts. */ + _muzzleFx() { + const c = settings.dragonbreath; + const g = settings.global; + + this._apexPoint(_pos); + + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.muzzleSize * 0.25, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.4, + intensity: c.muzzleIntensity, + opacity: 0.8, + fresnel: 1.4, + displace: 0.6, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _emit.position = _pos; + _emit.radius = 0.16; + _emit.direction = _dir.copy(this.direction); + _emit.speed = c.emberSpeed * 1.2; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.embers.emit(Math.round(30 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /** + * Everything the gout sheds: embers out of the mouth, burning fuel off its + * underside, smoke off the track behind it, and shells of burning air at the + * mouth while it is held. + * + * @param {number} scale 0..1 — thinned out as the breath gutters + */ + _breathFx(dt, scale) { + const c = settings.dragonbreath; + const g = settings.global; + const time = frame.uTime.value; + const span = this._span(); + const mouthRadius = this._mouthRadius(); + + /* --- embers streaming out of the mouth, along the axis --- */ + const emberCount = Math.round(this.emberEmitter.tick(dt, c.emberRate * scale) * g.particleCount); + if (emberCount > 0) { + // Born a little short of the mouth so they are already inside the flame + // when they appear, rather than popping into being in clear air. + this._axisPoint(randRange(0.6, 1.0), _pos); + _emit.position = _pos; + _emit.radius = mouthRadius * 0.65; + _emit.direction = _dir.subVectors(_mouth, _apex).normalize(); + _emit.speed = c.emberSpeed; + _emit.speedVariance = 0.85; + _emit.spread = c.emberSpread; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.embers.emit(emberCount, _emit); + } + + /* --- burning fuel falling off the underside --- */ + const dripCount = Math.round(this.dripEmitter.tick(dt, c.dripRate * scale) * g.particleCount); + if (dripCount > 0) { + const f = randRange(c.dripSeat * 0.6, c.dripSeat * 1.25); + this._axisPoint(f, _pos); + // The cone's local radius grows linearly with f, so the drips come off + // the wall rather than out of the middle — the middle is hollow. + _pos.y -= span * c.coneFlare * f * c.coneSquash * 0.7; + _emit.position = _pos; + _emit.radius = mouthRadius * f * 0.5; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.dripSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.dripLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.drips.emit(dripCount, _emit); + } + + /* --- smoke off the track behind the front --- */ + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * scale) * g.particleCount); + if (smokeCount > 0) { + this.pointAt(randRange(0.05, 1) * this._reachFraction(), _pos).setY(0.18); + _emit.position = _pos; + _emit.radius = mouthRadius * c.scorchWidth; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.85; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + + /* --- shells of burning air shed at the mouth --- */ + const shells = this.shellEmitter.tick(dt, c.mouthBurstRate * scale); + for (let i = 0; i < shells; i++) { + this._axisPoint(randRange(0.8, 1.05), _pos); + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.mouthBurstSize * 0.2, + endRadius: c.mouthBurstSize * g.explosionIntensity, + life: 0.55, + intensity: c.mouthBurstIntensity, + opacity: 0.45, + fresnel: 1.5, + displace: 0.7, + squash: 0.75, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.dragonbreath; + this._sync(1); + this._postContacts(); + + // The light rides the cone rather than the floor line the base put it on. + this._axisPoint(saturate(c.lightSpan), this.position); + + this._breathFx(dt, this.u); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + /** The gout reaches the mouth. The sustain begins. */ + onImpact() { + const c = settings.dragonbreath; + const g = settings.global; + + // The last step of the front, which `onTravel` stopped one step short of. + this._postContacts(); + + this._apexPoint(_apex); + this._mouthPoint(_mouth); + _pos.copy(_mouth); + + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.mouthBurstSize * 0.25, + endRadius: c.mouthBurstSize * 1.6 * g.explosionIntensity, + life: 0.8, + intensity: c.mouthBurstIntensity * 1.4, + opacity: 0.8, + fresnel: 1.4, + displace: 0.75, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.8 * g.explosionIntensity; + } + + onFade(dt, _t) { + const c = settings.dragonbreath; + const fade = this._fadeAmount(); + + this._sync(fade); + this._axisPoint(saturate(c.lightSpan), this.position); + + this._breathFx(dt, fade); + this.ctx.shake.rumble(c.rumble * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + this._contactDistance = 0; + this.hull.setFade(0); + this.tongues.clear(); + this.tongues.visible = false; + this.scorch.clearMarks(); + this.scorch.setVisible(false); + } + + dispose() { + this.hull.dispose(); + this.tongues.dispose(); + this.scorch.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/flame/EmberflockAbility.js b/src/abilities/flame/EmberflockAbility.js new file mode 100644 index 0000000..1596b04 --- /dev/null +++ b/src/abilities/flame/EmberflockAbility.js @@ -0,0 +1,760 @@ +import { InstancedBufferAttribute, Mesh, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Swarm, Silhouette, LeadPath, swarmParams } from '../../vfx/Swarm.js'; +import { createEmberTrailMaterial, setTrailGradient } from '../../materials/EmberTrailMaterial.js'; +import { createBoltRibbonGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** + * Hard ceiling on birds. The editor's `birds` slider clamps here, and the + * trail strip is built for exactly this many instances so the two counts can + * never disagree. + */ +const MAX_BIRDS = 48; +/** + * Samples along one trail ribbon. Each sample is two evaluations of the flock + * function, so this is the knob that decides what the trails cost: 32 × 48 × 2 + * is a hair over three thousand evaluations for the whole cast, which is + * nothing next to the fill it draws. + */ +const TRAIL_NODES = 32; + +/* --- module-scope scratch: the frame allocates nothing (I3) --- */ +const _flock = swarmParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _lead = new Vector3(); +const _ground = new Vector3(); + +/** + * EMBERFLIGHT — two dozen ember birds thrown down the aimed line. + * + * They gather at the caster's hand, stream downrange in a loose skein that + * weaves and rolls as it goes, collapse onto a single point at the target, and + * go up as a column of fire. Their trails outlive them by a beat. + * + * **THE TRICK — flocking, and specifically the roll.** The three behaviours a + * boid solver spends its frame budget on are all closed-form here: *cohesion* + * is a lag (a bird's home is the lead point as it was `lag` seconds ago, which + * is one evaluation rather than a history buffer), *separation* is a bijection + * onto a `latticeX × latticeY × latticeZ` grid (so two birds cannot claim the + * same cell, and the n² neighbour term simply does not exist), and *banking* + * is the second derivative of that position, sampled at t, t−h and t−2h. + * + * The bank is the one that matters and it is the reason this slot is not a + * recolour of Cinder Fall's ember trail. A bird that turns without dropping the + * inside wing reads as a spark on a curved path; the same geometry with the + * roll switched on reads as a bird, immediately and unmistakably. `bank` is a + * slider precisely so you can watch that flip happen. Set it to zero and the + * ability stops being this ability. + * + * **The trails are not a recording.** The obvious implementation — a ring + * buffer of past positions per bird — is a record of *metres*, and the moment + * you drag `spacingSide` on a paused frame it leaves twenty-four ribbons + * hanging behind where the birds used to be. So the trail is a second instanced + * strip that evaluates the *same* flock function backwards in each bird's own + * clock: the ribbon passes through its bird by construction, and every slider + * that reshapes the flock reshapes the trails on the same frame. The two + * materials share the *same uniform objects*, so one write in `Swarm.update()` + * drives both and they cannot fall a frame out of step. + * + * The honest limitation, stated once. Because a trail is a probe of the + * *current* field and not of the field as it was, a slider that changes shape + * without changing time — `spacingSide`, `columnHeight` — moves the whole + * ribbon rigidly rather than leaving a kink in it where the change happened. + * Everything that is a function of the clock (`churn`, `wander`, `breathe`, the + * lead's own travel) does trail correctly. The first version tried to buy the + * column back by rewinding the lead past its endpoint; `leadAt()` clamps at + * t = 1 and the flock simply stopped dead at the target, which looked far worse + * than the rigid lift does. + * + * A cast captures exactly one number — the flock seed, which shifts the + * separation lattice so two casts do not lay out the same formation. Every + * metre, radian and second is resolved from `settings.emberflock` inside the + * update loop, zero-length frames included. + * + * Two draw calls: the flock and its trails. + */ +export class EmberflockAbility extends Ability { + constructor(context) { + super('emberflock', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.flock = new Swarm(this.group, { + capacity: MAX_BIRDS, + silhouette: Silhouette.BIRD, + additive: true, + renderOrder: 13 + }); + + /* + * The trail strip. `createBoltRibbonGeometry` already hands out exactly the + * attribute layout this wants — `position = (v, ±1, 0)` per vertex and one + * `aStrand` index per instance — so the lightning ribbon and the ember + * trail are the same buffer with two different vertex shaders over it. + * Only the per-bird seed has to be added. + */ + this.trailGeometry = createBoltRibbonGeometry(TRAIL_NODES, MAX_BIRDS); + this.trailSeeds = new InstancedBufferAttribute(new Float32Array(MAX_BIRDS), 1); + this.trailGeometry.setAttribute('aSeed', this.trailSeeds); + + this.trailMaterial = createEmberTrailMaterial(this.flock.uniforms); + this.trailMesh = new Mesh(this.trailGeometry, this.trailMaterial); + this.trailMesh.frustumCulled = false; + this.trailMesh.matrixAutoUpdate = false; + this.trailMesh.layers.set(LAYER.VFX); + // Under the birds, so a bird always adds on top of its own tail. + this.trailMesh.renderOrder = 11; + this.group.add(this.trailMesh); + + /** Re-rolled per cast. The only thing a cast captures. */ + this._seed = 0; + /** Birds drawn last frame — the HUD's instance readout. */ + this._live = 0; + /** Metres of lead travel already paid out in char marks. */ + this._charDistance = 0; + /** 0 while travelling, then `t` out of `onFade` — 0..1 hold, 1..2 blow-out. */ + this._beat = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The embers the flock sheds. Soft, buoyant, curling — this is the bulk of + // what makes the line read as *fire* rather than as a formation of shapes. + this.embers = particles.get('emberflock.embers', { + capacity: 3200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.embers.uniforms.uDrag.value = 1.5; + this.embers.uniforms.uEndSize.value = 0.2; + this.embers.uniforms.uSizeIn.value = 0.08; + this.embers.uniforms.uFadeIn.value = 0.1; + this.embers.uniforms.uFadeOut.value = 0.45; + + // Hard bright streaks off the wing tips. Velocity-stretched and under + // gravity, so they fall out of the flock rather than drifting with it — + // which is what stops the embers and the sparks reading as one system. + this.sparks = particles.get('emberflock.sparks', { + capacity: 2400, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.1; + this.sparks.uniforms.uEndSize.value = 0.2; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.5; + + // Soot hanging on the line behind the flock. Non-additive so it occludes. + this.smoke = particles.get('emberflock.smoke', { + capacity: 1600, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.smoke.uniforms.uDrag.value = 1.9; + this.smoke.uniforms.uEndSize.value = 2.6; + this.smoke.uniforms.uSizeIn.value = 0.14; + this.smoke.uniforms.uFadeIn.value = 0.2; + this.smoke.uniforms.uFadeOut.value = 0.32; + + this.emberEmitter = new RateEmitter(); + this.sparkEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + this.columnEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** The flock plus its trails — one instance each, per bird, per mesh. */ + get instanceCount() { + return this._live * 2; + } + + get impactDuration() { + return Math.max(0.05, settings.emberflock.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.emberflock.fadeTime); + } + + /** Fire gutters. Same quantised stutter the bolt uses, on a slower clock. */ + lightShimmer() { + const c = settings.emberflock; + const step = Math.floor(this.age * Math.max(1, c.lightFlickerSpeed)); + const noise = Math.abs(Math.sin(step * 127.1) * 43758.5453) % 1; + return 1 - saturate(c.lightFlicker) * noise; + } + + /* ------------------------------------------------------------------ */ + /* The beats, as unitless fractions of live settings */ + /* ------------------------------------------------------------------ */ + + /** + * Seconds since the flock arrived. Zero while it is still on its way. + * + * Both clocks come off the base class, which is why nothing here has to hold + * a stopwatch of its own: `impactTime` runs through the hold and `fadeTime` + * picks up where it stops. + */ + _sinceArrival() { + return this.phase === AbilityPhase.TRAVEL ? 0 : this.impactTime + this.fadeTime; + } + + /** 0..1 — how far the formation has squeezed onto its own lead point. */ + _collapse() { + const c = settings.emberflock; + return Easing.outCubic(saturate(this._sinceArrival() / Math.max(0.02, c.collapseTime))); + } + + /** 0..1 — how far up the column has gone. Starts once the squeeze is done. */ + _rise() { + const c = settings.emberflock; + const since = this._sinceArrival() - c.collapseTime; + return Easing.outCubic(saturate(since / Math.max(0.02, c.columnTime))); + } + + /** + * The reveal wave for the birds, 0..1. + * + * It ramps up as the flock forms at the hand and back down once the column + * has topped out; `Swarm`'s `reveal` compares it against each bird's own dice + * roll, so the flock appears and disappears bird by bird rather than as a + * block, which is the difference between a flock arriving and a decal + * switching on. + */ + _birdReveal() { + const c = settings.emberflock; + const inWave = saturate(this.age / Math.max(0.02, c.revealTime)); + const dying = this._sinceArrival() - c.collapseTime - c.columnTime; + const outWave = 1 - saturate(dying / Math.max(0.02, c.birdFade)); + return Math.min(inWave, outWave); + } + + /** The same for the trails, held open by `trailPersist` seconds. */ + _trailReveal() { + const c = settings.emberflock; + const inWave = saturate(this.age / Math.max(0.02, c.revealTime)); + const dying = this._sinceArrival() - c.collapseTime - c.columnTime - c.birdFade - c.trailPersist; + const outWave = 1 - saturate(dying / Math.max(0.02, c.trailFadeTime)); + return Math.min(inWave, outWave); + } + + /** + * Half the formation's lateral extent, metres — how far off the lead an + * emitter may throw something and still be inside the flock. + * + * Resolved from the live lattice rather than remembered, so opening the + * spacing on a paused frame widens the ember plume with it. + */ + _flockRadius(collapse) { + const c = settings.emberflock; + const side = lerp(c.spacingSide, c.columnRadius, collapse); + const cells = Math.max(1, Math.round(c.latticeX)) - 1; + return cells * side * 0.5 + c.jitter + c.wander + c.size; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.emberEmitter.reset(); + this.sparkEmitter.reset(); + this.smokeEmitter.reset(); + this.columnEmitter.reset(); + this._charDistance = 0; + this._beat = 0; + + this._seed = Math.random() * 100; + this.flock.roll(this._seed); + + /* + * Mirror the flock's per-bird seeds onto the trail strip. They have to be + * the *same* numbers or every ribbon is threaded through the bird next to + * the one it belongs to — which looks almost right, and is the sort of + * almost-right that survives a review. + */ + const source = this.flock.seeds.array; + for (let i = 0; i < MAX_BIRDS; i++) this.trailSeeds.array[i] = source[i]; + this.trailSeeds.needsUpdate = true; + + this._sync(); + this._releaseFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Resolve the whole flock — and its trails — from live settings. + * + * Everything with a unit is read here, every frame, including a zero-length + * one. The only inputs that are not settings are the phase clocks and the + * cast's own frame. + */ + _sync() { + const c = settings.emberflock; + const g = settings.global; + const p = _flock; + + const travelling = this.phase === AbilityPhase.TRAVEL; + const collapse = this._collapse(); + const rise = this._rise(); + // 1 through the hold, easing to 0 through the blow-out. Cubic so the last + // embers hang on and then go, rather than dimming evenly. + const master = this._beat <= 1 ? 1 : 1 - Easing.inCubic(saturate(this._beat - 1)); + // How much of the per-bird slop the squeeze takes out. One slider drives + // jitter, breathe and wander together because they are the same idea. + const loose = 1 - collapse * saturate(c.collapseTighten); + + this._live = Math.max(0, Math.min(MAX_BIRDS, Math.round(c.birds))); + + /* --- the lead ------------------------------------------------------ */ + p.count = this._live; + p.leadMode = LeadPath.LINE; + p.leadS = travelling ? this.u : 1; + // d(s)/dt, so the shader can rewind the lead for the lagged ranks and for + // every sample of every trail. Metres/second over metres — unitless, and + // resolved from the live speed rather than from whatever the front did. + p.leadRate = travelling ? (c.speed * g.speed) / Math.max(0.1, this.length) : 0; + // The mid-span loft is pointless once the flock has arrived, and leaving it + // in makes the collapse point sit above the target. + p.leadRise = c.leadRise * (1 - collapse); + p.handForward = c.handForward; + p.handSide = c.handSide; + p.handHeight = c.handHeight; + p.endHeight = c.endHeight + c.columnHeight * rise; + + /* --- the formation ------------------------------------------------- */ + p.latticeX = c.latticeX; + p.latticeY = c.latticeY; + p.latticeZ = c.latticeZ; + p.spacingSide = lerp(c.spacingSide, c.columnRadius, collapse); + p.spacingUp = lerp(c.spacingUp, c.columnSpacing, collapse); + p.lag = lerp(c.lag, c.columnLag, collapse); + p.jitter = c.jitter * loose; + p.churn = lerp(c.churn, c.columnChurn, collapse); + p.breathe = c.breathe * loose; + p.breatheRate = c.breatheRate; + p.wander = c.wander * loose * g.turbulence; + p.wanderScale = c.wanderScale * g.noiseFrequency; + p.wanderSpeed = c.wanderSpeed * g.noiseSpeed; + p.gather = c.gather; + + /* --- the body ------------------------------------------------------ */ + p.size = c.size; + p.aspect = c.aspect; + p.sizeJitter = c.sizeJitter * g.randomness; + p.billboard = c.billboard; + p.bank = c.bank; + p.bankMax = c.bankMax; + p.dihedral = c.dihedral; + p.flapRate = c.flapRate; + p.curl = c.wingCurl; + p.edgeStretch = c.edgeStretch; + p.reveal = this._birdReveal(); + p.revealSpread = c.revealSpread; + + /* --- the silhouette and its colour --------------------------------- */ + p.silhouette = Silhouette.BIRD; + p.sweep = c.sweep; + p.edgeGain = c.edgeGain; + p.lit = c.lit; + p.tint = c.tint; + p.tintJitter = c.tintJitter; + p.tintAlong = c.tintAlong; + p.opacity = c.opacity * g.opacity * master; + p.glow = c.glow * g.glow; + p.softFade = c.softFade; + + this.flock.setBasis(this.origin, this.direction, this.side, this.length); + this.flock.setColors(c.colorBirdA, c.colorBirdB, c.colorBirdC, c.colorBirdD); + // The clock argument is ignored by design — the flock's churn runs on the + // shared `uTime` so it does not restart every cast. + this.flock.update(this.age, p); + + /* --- the trails ---------------------------------------------------- */ + // Written after the flock, because the flock's update is what wrote the + // path uniforms these share. + const t = this.trailMaterial.uniforms; + t.uSpan.value = lerp(c.trailSpan, c.columnTrailSpan, collapse); + t.uWidth.value = c.trailWidth; + t.uWidthTaper.value = c.trailTaper; + t.uLift.value = c.trailLift; + t.uSag.value = c.trailSag; + t.uWobble.value = c.trailWobble * loose * g.turbulence; + t.uWobbleScale.value = c.trailWobbleScale * g.noiseFrequency; + t.uWobbleSpeed.value = c.trailWobbleSpeed * g.noiseSpeed; + t.uReveal.value = this._trailReveal(); + t.uRevealSpread.value = c.revealSpread; + t.uFade.value = master; + t.uTint.value = c.trailTint; + t.uTintAlong.value = c.trailTintAlong; + t.uTintJitter.value = c.trailTintJitter; + t.uCore.value = c.trailCore; + t.uHeadBias.value = c.trailHeadBias; + t.uOpacity.value = c.trailOpacity * g.opacity; + t.uGlow.value = c.trailGlow * g.glow; + t.uSoftFade.value = c.trailSoftFade; + setTrailGradient(this.trailMaterial, c.colorTrailA, c.colorTrailB, c.colorTrailC, c.colorTrailD); + this.trailGeometry.instanceCount = this._live; + this.trailMesh.visible = this._live > 0 && t.uReveal.value > 0.001; + + /* --- the particle systems ------------------------------------------ */ + this.embers.setGradient( + getColor(c.colorEmberA), + getColor(c.colorEmberB), + getColor(c.colorEmberC), + getColor(c.colorEmberD) + ); + this.embers.uniforms.uGravity.value.set(0, c.emberRise, 0); + this.embers.uniforms.uSizeScale.value = c.emberSize * g.particleSize * 7; + this.embers.uniforms.uLifeScale.value = c.emberLifetime * 0.5 * g.particleLifetime; + this.embers.uniforms.uSpeedScale.value = g.particleSpeed; + this.embers.uniforms.uOpacity.value = g.opacity; + this.embers.uniforms.uGlow.value = c.glow * 0.7 * g.glow; + this.embers.uniforms.uTurbulence.value = c.emberTurbulence * g.turbulence; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.glow * 0.9 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.2 * g.turbulence; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.4 * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** The pop of sparks as the flock forms at the hand and leaves it. */ + _releaseFx() { + const c = settings.emberflock; + const g = settings.global; + + this.flock.leadPoint(_pos); + + _emit.position = _pos; + _emit.radius = this._flockRadius(0) * 0.6; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.6).setY(0.5).normalize(); + _emit.speed = c.sparkSpeed * 1.3; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.sparks.emit(Math.round(c.gatherSparks * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /** + * Everything the flock sheds while it is in the air. + * + * @param {number} dt seconds + * @param {number} scale 0..1 — thinned once the flock is only holding + * @param {number} collapse 0..1, so the plume narrows with the formation + */ + _flockFx(dt, scale, collapse) { + const c = settings.emberflock; + const g = settings.global; + const time = frame.uTime.value; + const radius = this._flockRadius(collapse); + + this.flock.leadPoint(_lead); + + const emberCount = Math.round(this.emberEmitter.tick(dt, c.emberRate * scale) * g.particleCount); + if (emberCount > 0) { + // Behind the lead, not on it: the embers are what the flock has already + // burnt, and spawning them at the head puts a bright cap on the front of + // a formation that is supposed to be trailing fire. + _pos.copy(_lead).addScaledVector(this.direction, -randRange(0, radius)); + _emit.position = _pos; + _emit.radius = radius; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.emberSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.embers.emit(emberCount, _emit); + } + + const sparkCount = Math.round(this.sparkEmitter.tick(dt, c.sparkRate * scale) * g.particleCount); + if (sparkCount > 0) { + _emit.position = _lead; + _emit.radius = radius * 0.9; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.4).setY(0.35).normalize(); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.time = time; + this.sparks.emit(sparkCount, _emit); + } + + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * scale) * g.particleCount); + if (smokeCount > 0) { + _pos.copy(_lead).addScaledVector(this.direction, -randRange(0, radius * 2)); + _emit.position = _pos; + _emit.radius = radius * 1.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + } + + /** Char marks scorched into the floor under the flight path. */ + _groundFx() { + const c = settings.emberflock; + const step = 1 / Math.max(0.02, c.scorchRate); + + while (this.front - this._charDistance >= step) { + this._charDistance += step; + const s = saturate(this._charDistance / this.length); + this.pointAt(s, _ground); + // Jittered off the axis by the flock's own width, so the char reads as a + // band the birds passed over rather than as a dotted centre line. + const wander = this._flockRadius(0) * 0.7; + _ground.x += this.side.x * randRange(-wander, wander); + _ground.z += this.side.z * randRange(-wander, wander); + + this.ctx.decals.spawn(DecalType.SCORCH, _ground, { + radius: c.scorchRadius * randRange(0.6, 1.3), + life: c.scorchLife, + intensity: c.scorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorCharEmber), + height: 0.012 + }); + } + } + + /** Embers fed into the column while it climbs. */ + _columnFx(dt, rise) { + const c = settings.emberflock; + const g = settings.global; + // The feed dies as the column tops out; a chimney that keeps pumping after + // it has stopped rising reads as a bonfire, not as a flock going up. + const gate = rise > 0 ? 1 - rise * rise : 0; + const count = Math.round(this.columnEmitter.tick(dt, c.columnEmbers * gate) * g.particleCount); + if (count <= 0) return; + + this.pointAt(1, _pos); + _pos.y = c.endHeight; + _emit.position = _pos; + _emit.radius = this._flockRadius(1) * 1.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.columnSpeed; + _emit.speedVariance = 0.5; + // Tight, because the column's whole read is that it is a *column*. + _emit.spread = 0.16; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.13; + _emit.sizeVariance = 0.8; + _emit.life = c.emberLifetime * 1.4; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.embers.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._beat = 0; + this._sync(); + + // The light rides the flock, not the floor under it. `advance()` has + // already put `position` on the ground line, so lift it onto the lead. + this.flock.leadPoint(this.position); + + this._flockFx(dt, 1, 0); + this._groundFx(); + + this.ctx.shake.rumble(settings.emberflock.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.emberflock; + const g = settings.global; + const time = frame.uTime.value; + + this.pointAt(1, _pos); + _pos.y = c.endHeight; + + /* the fireball the flock collapses into */ + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.65, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.4, + displace: 0.75, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring across the floor under it */ + this.pointAt(1, _ground); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _ground, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.55, + width: 0.06, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + this.ctx.decals.spawn(DecalType.SCORCH, _ground, { + radius: c.scorchRadius * 3.2, + life: c.scorchLife * 1.4, + intensity: c.scorchIntensity * 1.4, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorCharEmber), + height: 0.012 + }); + + /* sparks and embers blown out of the collapse */ + _emit.position = _pos; + _emit.radius = this._flockRadius(0) * 0.5; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sparkSpeed * 2.0; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(Math.round(c.burstSparks * g.particleCount), _emit); + + _emit.speed = c.emberSpeed * 2.4; + _emit.spread = 0.85; + _emit.size = 0.12; + _emit.life = c.emberLifetime; + this.embers.emit(Math.round(c.burstEmbers * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onFade(dt, t) { + this._beat = t; + this._sync(); + + const rise = this._rise(); + // The light climbs with the column. + this.flock.leadPoint(this.position); + + // Thinned as the flock dies, but never cut dead — a bird that is winking + // out is still burning until the moment it does. + const master = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._flockFx(dt, master * (t <= 1 ? 0.7 : 0.35), this._collapse()); + this._columnFx(dt, rise); + } + + onDestroy() { + this._live = 0; + this._beat = 0; + this.flock.reset(); + this.trailGeometry.instanceCount = 0; + this.trailMesh.visible = false; + this.trailMaterial.uniforms.uFade.value = 0; + } + + dispose() { + this.flock.dispose(); + this.trailGeometry.dispose(); + this.trailMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/flame/FirewalkAbility.js b/src/abilities/flame/FirewalkAbility.js new file mode 100644 index 0000000..544c830 --- /dev/null +++ b/src/abilities/flame/FirewalkAbility.js @@ -0,0 +1,804 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { + createFirewalkPlacement, + createPrintAttributes, + createSoleGeometry, + createPillarGeometry, + createSoleMaterial, + createPillarMaterial, + setPillarGradient +} from '../../materials/FirewalkMaterial.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, Easing } from '../../utils/math.js'; + +/** + * Hard ceiling on footfalls in one cast. At the default `stepLength` of 1.15 m + * this covers thirty metres, which is past the longest cast the block allows; + * a shorter step runs out of prints before it runs out of line, and stopping + * early is the right failure — the trail just does not reach the end. + */ +const MAX_PRINTS = 26; + +/* Module-scope scratch. A burning trail allocates nothing — I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _print = new Vector3(); +/** The lane's params bag, filled from settings every frame and reused. */ +const _lane = groundFieldParams(); + +/** + * FIREWALK — a trail of burning footprints. + * + * A line cast. Something unseen walks the aimed line at `speed`, and each + * footfall ignites where it lands: a sole burnt into the floor heel-first, + * throwing a short pillar of flame as it lights. The prints hold, gutter, and + * burn down to scars. + * + * ## THE TRICK — a silhouette, in the school that has none + * + * Flame is fifty per cent of the sandbox's raymarched volume and none of its + * recognisable shapes. Every other slot in this school is a cloud, a jet or a + * front: the medium erodes itself into whatever it erodes into, and the + * silhouette is an accident of the noise on that frame. This one puts a + * **signed distance field of an actual foot** on the floor — heel, medial arch, + * ball, five separate toes — and lights them in sequence down the line, + * alternating left and right about the centre line by `stride`. + * + * Three things had to be true before it read as a foot rather than as a scorch + * blot, and all three cost a rewrite: + * + * 1. **The arch is an absence, not a pinch.** The first sole narrowed its waist + * symmetrically, which draws a dumbbell. A foot's waist is narrowed *and + * shifted laterally* — the arch is missing from the medial side only — and + * `archCut` is the slider that does it. Below about 0.25 the mark is a bean. + * Above it, it snaps into being a foot. There is no gradual middle. + * 2. **The toes are separate.** They union with a hard `min` while everything + * else unions smoothly. Merge them into the ball and you have drawn a + * mitten, which at trail scale reads as a paw. + * 3. **The alternation is the gait.** A single line of prints down the middle + * is a dotted line, whatever shape the dots are. Two lines taken in turn is + * a body. `stride` is a slider so you can watch that happen and un-happen. + * + * The ignition then runs **heel to toe** over `rollTime` inside each print, + * because a footfall is a roll and not a stamp. It is the detail that makes a + * still frame of the trail look walked rather than stencilled. + * + * ## What a cast captures + * + * Per print: two dice rolls (`aSeed`, `aWander`) and the timestamp the front + * crossed it. Per cast: one more dice roll for the lane and the volume. Nothing + * else. Every print's *position* is `origin + forward·(start + i·step) + + * side·(±stride)` evaluated in the vertex shader from live uniforms, so dragging + * `stepLength` on a paused trail re-spaces prints that are already burning and + * takes their pillars, their pyre and their light with them. + * + * ## Cost + * + * Five drawables: the soles (one instanced quad, every print), the pillars (one + * instanced billboard, every print), the pyre volume, the scorched lane, and + * three shared particle systems. One dynamic light, riding the newest print. + */ +export class FirewalkAbility extends Ability { + constructor(context) { + super('firewalk', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * One uniform box, two materials. The sole and the pillar resolve the same + * `printCentre()` from the same numbers, so a pillar cannot stand off its + * own footprint however the two are updated. See `FirewalkMaterial`. + */ + this.placement = createFirewalkPlacement(); + this.attributes = createPrintAttributes(MAX_PRINTS); + + this.soleGeometry = createSoleGeometry(this.attributes); + this.soleMaterial = createSoleMaterial(this.placement); + this.soleMesh = new Mesh(this.soleGeometry, this.soleMaterial); + this.soleMesh.frustumCulled = false; + this.soleMesh.matrixAutoUpdate = false; + this.soleMesh.layers.set(LAYER.VFX); + // Under the pillars, over the lane. + this.soleMesh.renderOrder = 7; + this.group.add(this.soleMesh); + + this.pillarGeometry = createPillarGeometry(this.attributes); + this.pillarMaterial = createPillarMaterial(this.placement); + this.pillarMesh = new Mesh(this.pillarGeometry, this.pillarMaterial); + this.pillarMesh.frustumCulled = false; + this.pillarMesh.matrixAutoUpdate = false; + this.pillarMesh.layers.set(LAYER.VFX); + this.pillarMesh.renderOrder = 12; + this.group.add(this.pillarMesh); + + /** + * The volume rides the print that just lit rather than covering the lane. + * Coverage is what a raymarch costs, and a box over twenty metres of floor + * spends its whole step budget crossing vacuum — see `VolumeHull`'s one + * rule. A column half a metre across at twenty-six steps is nearly free, + * and putting it on the newest print means the volumetric fire *walks*, + * which is the beat the ability is selling. + */ + this.pyre = new VolumeHull({ + hull: HullShape.CYLINDER, + medium: Medium.FLAME, + prefix: 'pyre', + maxSteps: 40, + renderOrder: 13 + }); + this.group.add(this.pyre.mesh); + + /** + * The scorched lane. `RUT` because a walk gouges a track down the line and + * each footfall is a contact sample on it: the burn is deeper and hotter + * under a print than between two, which is `gfForce()` doing exactly the + * job it was written for. A decal would have captured its length the frame + * it spawned. + */ + this.lane = new GroundField(this.group, { + mode: GroundMode.RUT, + marks: MAX_PRINTS, + additive: false, + name: 'Firewalk:lane' + }); + this.lane.setVisible(false); + + /** Prints currently placed on the line — a count, re-derived every frame. */ + this._live = 0; + /** The most recent print to have been stepped on, or -1. */ + this._newest = -1; + /** One dice roll per cast, for the lane and the volume. */ + this._seed = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The embers lifting off a burning print. Additive and curled — these are + // hot and they are going up. + this.motes = particles.get('firewalk.motes', { + capacity: 2200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.35 + }); + this.motes.uniforms.uDrag.value = 1.3; + this.motes.uniforms.uEndSize.value = 0.18; + this.motes.uniforms.uSizeIn.value = 0.05; + this.motes.uniforms.uFadeIn.value = 0.07; + this.motes.uniforms.uFadeOut.value = 0.4; + + // The haze off the lane. Non-additive so it genuinely occludes: smoke that + // adds light to the scene is a fog machine. + this.smoke = particles.get('firewalk.smoke', { + capacity: 1600, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.smoke.uniforms.uDrag.value = 1.8; + this.smoke.uniforms.uEndSize.value = 3.0; + this.smoke.uniforms.uSizeIn.value = 0.12; + this.smoke.uniforms.uFadeIn.value = 0.18; + this.smoke.uniforms.uFadeOut.value = 0.32; + + // Sparks kicked out sideways by the impact of a footfall. Velocity-stretched + // streaks under gravity, thrown once per print rather than at a rate — a + // step is an event. + this.sparks = particles.get('firewalk.sparks', { + capacity: 2000, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.5; + this.sparks.uniforms.uEndSize.value = 0.25; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.45; + + this.moteEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Two instanced meshes over the same prints. */ + get instanceCount() { + return this._live * 2; + } + + /** The trail burns after the last footfall... */ + get impactDuration() { + return Math.max(0.05, settings.firewalk.lifetime * settings.global.lifetime); + } + + /** ...and then burns down to a scar. */ + get fadeDuration() { + return Math.max(0.05, settings.firewalk.fadeTime); + } + + /** Fire gutters. A hard, quantised stutter on its own clock. */ + lightShimmer() { + const c = settings.firewalk; + const step = Math.floor(this.age * Math.max(1, c.lightFlickerSpeed)); + const noise = Math.abs(Math.sin(step * 91.7) * 43758.5453) % 1; + return 1 - saturate(c.lightFlicker) * noise; + } + + /* ------------------------------------------------------------------ */ + /* The gait — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Metres between footfalls, floored so a zeroed slider cannot divide by it. */ + _stepLength() { + return Math.max(0.05, settings.firewalk.stepLength); + } + + /** How many prints fit on this cast, right now. */ + _printCount() { + const c = settings.firewalk; + const reach = this.length - c.startOffset; + if (reach < 0) return 0; + return clamp(Math.floor(reach / this._stepLength()) + 1, 0, MAX_PRINTS); + } + + /** Metres down the line print `index` lands at. */ + _printDistance(index) { + return settings.firewalk.startOffset + index * this._stepLength(); + } + + /** + * Where print `index` is, in world space. + * + * This mirrors `printCentre()` in the vertex shader exactly — the same + * alternation, the same stride, the same dice roll out of the same attribute + * — so the sparks, the light and the pyre stand *on* the print the GPU drew + * rather than near it. The dice roll is an attribute and not a hash of the + * seed precisely so that this function can read it. + */ + _printPoint(index, out) { + const c = settings.firewalk; + const side = index % 2 === 0 ? -1 : 1; + const wander = this.attributes.aWander.array[index] ?? 0; + out + .copy(this.origin) + .addScaledVector(this.direction, this._printDistance(index)) + .addScaledVector(this.side, side * c.stride + wander * c.wander); + out.y = c.printLift; + return out; + } + + /** Yaw of the cast, radians — the frame the lane's grain is authored in. */ + _yaw() { + return Math.atan2(this.direction.x, this.direction.z); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.smokeEmitter.reset(); + this.lane.clearMarks(); + + this._newest = -1; + this._seed = Math.random() * 100; + + // Re-roll the per-print dice and blank every birth. Dice rolls and + // timestamps: the only two things a cast is allowed to write down. + const seed = this.attributes.aSeed.array; + const wander = this.attributes.aWander.array; + const birth = this.attributes.aBirth.array; + for (let i = 0; i < MAX_PRINTS; i++) { + seed[i] = Math.random() * 100; + wander[i] = Math.random() * 2 - 1; + birth[i] = -1; + } + this.attributes.aSeed.needsUpdate = true; + this.attributes.aWander.needsUpdate = true; + this.attributes.aBirth.needsUpdate = true; + + this._sync(1); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current beat into everything that draws. + * + * @param {number} fade 1 while the trail burns, ramping to 0 as it dies + */ + _sync(fade) { + this._live = this._printCount(); + this.soleGeometry.instanceCount = this._live; + this.pillarGeometry.instanceCount = this._live; + + this._syncPlacement(); + this._syncSole(fade); + this._syncPillars(fade); + this._syncPyre(fade); + this._syncLane(fade); + this._syncParticles(); + } + + /** The nine numbers both meshes place themselves from. Shared by identity. */ + _syncPlacement() { + const c = settings.firewalk; + const p = this.placement; + p.uOrigin.value.copy(this.origin); + p.uForward.value.copy(this.direction); + p.uSide.value.copy(this.side); + p.uStart.value = c.startOffset; + p.uStep.value = this._stepLength(); + p.uStride.value = c.stride; + p.uWander.value = c.wander; + p.uLift.value = c.printLift; + p.uNow.value = this.age; + } + + _syncSole(fade) { + const c = settings.firewalk; + const g = settings.global; + const u = this.soleMaterial.uniforms; + + u.uFootLength.value = c.footLength; + u.uFootWidth.value = c.footWidth; + u.uPad.value = c.solePad; + u.uToeOut.value = c.toeOut; + + u.uHeelWidth.value = c.heelWidth; + u.uBallWidth.value = c.ballWidth; + u.uArchCut.value = saturate(c.archCut); + u.uSoleRound.value = c.soleRound; + u.uToeSize.value = c.toeSize; + u.uToeSpread.value = c.toeSpread; + u.uToeGap.value = c.toeGap; + + u.uRim.value = c.soleRim; + u.uHalo.value = c.soleHalo; + u.uEdge.value = c.soleEdge; + u.uRollTime.value = c.rollTime; + u.uFlashTime.value = c.flashTime; + // The global lifetime multiplier belongs on anything that decides how long + // a thing is visible, and this is the sole's whole life. + u.uCoolTime.value = c.coolTime * g.particleLifetime; + u.uCharTime.value = c.charTime; + u.uGrain.value = c.soleGrain * g.noiseFrequency; + u.uVein.value = c.soleVein; + u.uRagged.value = c.soleRagged * g.noiseStrength; + u.uOpacity.value = c.soleOpacity * g.opacity; + u.uGlow.value = c.soleGlow * g.glow; + u.uFade.value = fade; + u.uSoftFade.value = c.soleSoftFade; + + u.uColorFlash.value.copy(getColor(c.colorFlash)); + u.uColorEmber.value.copy(getColor(c.colorEmber)); + u.uColorChar.value.copy(getColor(c.colorChar)); + u.uColorAsh.value.copy(getColor(c.colorAsh)); + u.uColorHalo.value.copy(getColor(c.colorHalo)); + } + + _syncPillars(fade) { + const c = settings.firewalk; + const g = settings.global; + const u = this.pillarMaterial.uniforms; + + u.uHeight.value = c.pillarHeight; + u.uWidth.value = c.pillarWidth; + u.uRiseTime.value = c.pillarRise; + u.uFallTime.value = c.pillarFall * g.lifetime; + u.uLean.value = c.pillarLean; + u.uWaver.value = c.pillarWaver; + u.uWaverRate.value = c.pillarWaverRate; + + u.uTaper.value = c.pillarTaper; + u.uBulge.value = c.pillarBulge; + u.uNoiseScale.value = c.pillarNoiseScale * g.noiseFrequency; + u.uNoiseSpeed.value = c.pillarNoiseSpeed * g.noiseSpeed; + u.uErosion.value = c.pillarErosion * g.noiseStrength; + u.uOpacity.value = c.pillarOpacity * g.opacity; + u.uGlow.value = c.pillarGlow * g.glow; + u.uFade.value = fade; + u.uSoftFade.value = c.pillarSoftFade; + + setPillarGradient( + this.pillarMaterial, + c.colorPillarA, + c.colorPillarB, + c.colorPillarC, + c.colorPillarD + ); + } + + /** The volume, standing on whichever print lit most recently. */ + _syncPyre(fade) { + const c = settings.firewalk; + const g = settings.global; + + if (this._newest < 0) { + this.pyre.setFade(0).sync(c, g); + return; + } + + this._printPoint(this._newest, _print); + const since = this.age - this.attributes.aBirth.array[this._newest]; + // It burns down on the print it is standing on, and jumps to the next when + // that one lights. `linger` is live, so a paused pyre re-times. + const heat = Math.exp(-Math.max(since, 0) / Math.max(0.05, c.pyreLinger)); + + // Margin holds the flame that fraction of the way inside the proxy, so the + // hull has to be bigger than the fire you want by exactly that much or the + // volume is sliced along a straight line at the wall. `pyreSlack` is the + // extra on top for the erosion to spill into. + const k = Math.max(1, c.pyreSlack) / Math.max(0.2, 1 - c.pyreMargin); + this.pyre + .place(_print) + .setSize(c.pyreWidth * k, c.pyreHeight * k, c.pyreWidth * k) + .setFade(heat * fade) + .sync(c, g); + } + + /** The scorched lane, drawn by the walk's own progress down the line. */ + _syncLane(fade) { + const c = settings.firewalk; + const g = settings.global; + + _lane.centre = this.origin; + _lane.yaw = this._yaw(); + _lane.height = c.laneHeight; + _lane.radius = c.laneReach; + _lane.length = this.length; + + // RUT ignores `grow` and draws everything behind `progress`, with a hot lip + // at the front — which is the walker, so it is `u` while travelling and + // pinned at the far end after. + _lane.grow = 1; + _lane.recede = 0; + _lane.progress = this.phase === AbilityPhase.TRAVEL ? saturate(this.u) : 1; + _lane.fade = fade; + _lane.seed = this._seed; + + _lane.edge = c.laneEdge; + _lane.ragged = c.laneRagged; + _lane.raggedScale = c.laneRaggedScale; + _lane.warp = c.laneWarp; + + _lane.relief = c.laneRelief; + _lane.normalStep = c.laneNormalStep; + _lane.ambient = c.laneAmbient; + _lane.wrap = c.laneWrap; + _lane.specular = c.laneSpecular; + _lane.gloss = c.laneGloss; + _lane.parallax = c.laneParallax; + + _lane.cell = c.laneCell; + _lane.seam = c.laneSeam; + _lane.thickness = c.laneThickness; + _lane.width = c.laneWidth; + _lane.depth = c.laneDepth; + _lane.lift = c.laneLift; + _lane.sharp = c.laneSharp; + _lane.detail = c.laneDetail; + _lane.swirl = c.laneSwirl; + + _lane.markLife = c.laneMarkLife; + _lane.markRadius = c.laneMarkRadius; + + _lane.additive = false; + _lane.emissive = c.laneEmissive; + _lane.opacity = c.laneOpacity; + _lane.depthFade = c.laneDepthFade; + _lane.colorBase = c.colorLaneBase; + _lane.colorEdge = c.colorLaneEdge; + _lane.colorGlow = c.colorLaneGlow; + _lane.colorDeep = c.colorLaneDeep; + + _lane.noiseStrength = g.noiseStrength; + _lane.noiseFrequency = g.noiseFrequency; + _lane.noiseSpeed = g.noiseSpeed; + _lane.opacityScale = g.opacity; + + this.lane.update(_lane); + this.lane.setVisible(fade > 0.004); + } + + _syncParticles() { + const c = settings.firewalk; + const g = settings.global; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.moteGlow * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.3 * g.turbulence; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.soleGlow * 0.5 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + } + + /* ------------------------------------------------------------------ */ + /* Events */ + /* ------------------------------------------------------------------ */ + + /** + * Light every print the front has now passed. + * + * Gated to the travel phase on purpose. Re-spacing the trail with + * `stepLength` mid-burn moves the prints that are already lit — which is I1 + * working — but it must not *ignite* new ones, or a paused editor session + * would keep firing footfalls at whatever point the slider happens to cross. + */ + _igniteReached() { + const count = this._printCount(); + const birth = this.attributes.aBirth.array; + for (let i = 0; i < count; i++) { + if (birth[i] >= 0) continue; + if (this._printDistance(i) > this.front) break; + birth[i] = this.age; + this.attributes.aBirth.needsUpdate = true; + this._newest = i; + this._footfall(i); + } + } + + /** + * One footfall: sparks kicked sideways, a contact sample posted to the lane, + * a small kick in the camera and a punch of light. + */ + _footfall(index) { + const c = settings.firewalk; + const g = settings.global; + + this._printPoint(index, _print); + + // The lane's contact sample. Unitless: a fraction along the track, a + // fraction of the radius across it, a timestamp and a strength — so the + // burn re-places itself when the lane's radius or the cast's length moves. + const across = ((index % 2 === 0 ? -1 : 1) * c.stride) / Math.max(0.05, c.laneReach); + this.lane.mark( + clamp(across, -1, 1), + saturate(this._printDistance(index) / this.length), + frame.uTime.value, + 1 + ); + + const sparks = Math.round(c.sparkStamp * g.particleCount); + if (sparks > 0) { + _emit.position = _pos.copy(_print).setY(c.printLift + 0.02); + _emit.radius = c.footWidth * 0.5; + // Kicked forward and out, the way grit goes when a boot lands on it. + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.55).setY(0.8).normalize(); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.sparks.emit(sparks, _emit); + } + + this.ctx.shake.add( + c.stampShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + this.lightBoost = c.lightIntensity * 0.35 * g.explosionIntensity; + } + + /** The flare as the first step is taken. */ + _castFx() { + const c = settings.firewalk; + const g = settings.global; + + this._printPoint(0, _pos); + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.footLength * 0.4, + endRadius: c.footLength * 2.2 * g.explosionIntensity, + life: 0.45, + intensity: 1.4, + opacity: 0.7, + fresnel: 1.5, + displace: 0.6, + squash: 0.55, + colorA: getColor(c.colorPillarC), + colorB: getColor(c.colorPillarB), + colorC: getColor(c.colorFlash) + }); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /** + * What the burning trail sheds: embers off the prints, haze off the lane. + * + * @param {number} scale 0..1 — thinned out as the trail dies + */ + _trailFx(dt, scale) { + const c = settings.firewalk; + const g = settings.global; + const time = frame.uTime.value; + const lit = this._newest + 1; + if (lit <= 0) return; + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + // Off a print chosen at random from the ones that are lit, weighted to + // the newest by squaring the roll: the front of the trail is the part + // that is really burning. + const pick = Math.min(lit - 1, Math.floor((1 - Math.random() * Math.random()) * lit)); + this._printPoint(pick, _pos); + _emit.position = _pos; + _emit.radius = c.footLength * 0.45; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * scale) * g.particleCount); + if (smokeCount > 0) { + // Smoke comes off the *lane*, not off a print: it is the floor burning. + const pick = Math.min(lit - 1, Math.floor(Math.random() * lit)); + this._printPoint(pick, _pos).setY(0.12); + _emit.position = _pos; + _emit.radius = c.laneWidth * 1.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + } + + /** Park the light on the newest print, at the height the settings ask for. */ + _placeLight() { + const c = settings.firewalk; + if (this._newest < 0) { + this.position.copy(this.origin); + this.position.y = c.lightHeight; + return; + } + this._printPoint(this._newest, this.position); + this.position.y = c.printLift + c.lightHeight; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._igniteReached(); + this._sync(1); + this._placeLight(); + this._trailFx(dt, 1); + this.ctx.shake.rumble(settings.firewalk.rumble * settings.global.cameraShake, dt); + } + + /** The walk reaches the end: light anything the front skipped past. */ + onImpact() { + const count = this._printCount(); + const birth = this.attributes.aBirth.array; + for (let i = 0; i < count; i++) { + if (birth[i] >= 0) continue; + birth[i] = this.age; + this.attributes.aBirth.needsUpdate = true; + this._newest = i; + this._footfall(i); + } + } + + onFade(dt, t) { + // `t` runs 0..1 while the trail burns, then 1..2 while it burns down. Cubic + // on the way out so the prints hang on as scars and then let go, rather + // than dimming evenly from the moment the walk finishes. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._sync(fade); + this._placeLight(); + this._trailFx(dt, fade * (t <= 1 ? 0.7 : 0.3)); + this.ctx.shake.rumble(settings.firewalk.rumble * 0.4 * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + this._newest = -1; + this._live = 0; + this.soleGeometry.instanceCount = 0; + this.pillarGeometry.instanceCount = 0; + this.soleMaterial.uniforms.uFade.value = 0; + this.pillarMaterial.uniforms.uFade.value = 0; + this.pyre.setFade(0); + this.lane.setVisible(false); + this.lane.clearMarks(); + } + + dispose() { + this.soleGeometry.dispose(); + this.pillarGeometry.dispose(); + this.soleMaterial.dispose(); + this.pillarMaterial.dispose(); + this.pyre.dispose(); + this.lane.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/flame/FirewhipAbility.js b/src/abilities/flame/FirewhipAbility.js new file mode 100644 index 0000000..54f962a --- /dev/null +++ b/src/abilities/flame/FirewhipAbility.js @@ -0,0 +1,802 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Tube, TubePath } from '../../vfx/Tube.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** + * Samples along the lash. A whip is one long curve with a *tight* Gaussian lobe + * running down it — at 96 nodes the lobe is about fifteen segments wide at the + * shipped `lashWaveWidth` and its shoulders visibly facet, which reads as a + * kink in the leather rather than as a loop. 128 is where that stops. + */ +const LASH_NODES = 128; +/** + * Facets around the barrel. The lash is ten centimetres thick and is never + * looked at from closer than a couple of metres, so the beam's 26 is money + * spent on nothing; 14 holds the silhouette and halves the vertex count. + */ +const LASH_SIDES = 14; +/** + * Points along the lash one frame's embers are split between. One origin makes + * every batch read as a starburst pinned to a spot — the same lesson the bolt's + * sparks learnt. + */ +const EMBER_BATCHES = 5; + +const _pos = new Vector3(); +const _dir = new Vector3(); +const _rel = new Vector3(); +const _emit = {}; + +/** + * FIREWHIP — Ashen Lash. + * + * A burning lash thrown along the aimed line in four beats: it winds up (a loop + * forms in the leather at the handle), it goes (the lash reaches full extension + * and the loop starts down it), it **cracks**, and then it falls and burns out, + * dropping embers along its own curve and shedding ash the whole way. + * + * **THE TRICK — the crack is a real event in the geometry, not a scheduled + * effect.** `vfx/Tube.js`'s WHIP path carries a Gaussian curvature lobe + * travelling handle → tip, and it conserves arc length: the lateral throw of + * the loop is paid for out of the *axial* extent, so the tip is hauled back + * while the loop is mid-whip and let go as the loop runs off the end. That is a + * speed spike, and it belongs to the curve rather than to a clock — the tube + * differentiates its own axis with respect to the wave phase, which is why + * `tipSpeed` is correct on a **zero-length frame**. This ability does exactly + * one thing with it: it calls `sync()`, polls `lash.crack.fired` on the very + * next line, and if it is set, fires the shock ring *there*, at + * `lash.crack.point`, with a strength taken from `crack.speed / waveSpeed`. + * There is no timer anywhere in this file. Drag `lashWaveRate` mid-cast and the + * bang moves; drag `lashWaveAmp` below about `0.6 × lashWaveWidth` and it stops + * cracking at all, which is correct, because a slack whip does not bang. + * + * The first version *did* schedule it — a `crackTime` slider, fired at + * `age > crackTime`, with the ring parented to `pointAt(1)`. It looked + * plausible in isolation and fell apart the moment anything was dragged: the + * bang arrived while the loop was still halfway down the lash, or after it had + * run off the end, and the ring sat at the far end of the *cast* rather than at + * the end of the *whip*, which are thirty-five centimetres apart at the worst + * of the arc-length shortening. The tube already knew all of this. Asking it + * was three lines. + * + * **What a cast captures.** One seed, one timestamp (`_crackAt`), and the crack + * position as three *fractions of the cast length* — along, lateral and lift. + * Not one metre. The ring's anchor is rebuilt from those fractions against the + * live `length` on every frame, so it stays welded to the lash when `range` is + * dragged with the clock stopped, and the ring's own radius, expansion and + * colour are re-resolved from `settings.firewhip` by `Shell#sync` the same way. + */ +export class FirewhipAbility extends Ability { + constructor(context) { + super('firewhip', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + // The lash. Three draw calls: halo, sheath, core — the inversion in the + // middle layer is what stops it reading as a lit pipe. + this.lash = new Tube({ + path: TubePath.WHIP, + prefix: 'lash', + nodes: LASH_NODES, + sides: LASH_SIDES, + renderOrder: 11 + }); + this.group.add(this.lash.group); + + // The shock ring. A PRESSURE shell squashed along its own axis is a lens, + // and a lens whose axis is the lash's heading is a ring standing across the + // whip — which is what a crack throws off. A DOME would have stood it on + // the floor, and the crack happens in the air. + this.ring = new Shell({ + mode: ShellMode.PRESSURE, + prefix: 'crack', + nodes: 28, + sides: 44, + renderOrder: 14 + }); + this.ring.visible = false; + this.group.add(this.ring.group); + + /* --- what a cast captures: a dice roll, a timestamp, three fractions --- */ + /** Re-rolled per cast so two lashes do not kink identically. */ + this._seed = 0; + /** Seconds since spawn at which the crack fired; < 0 for "not yet". */ + this._crackAt = -1; + /** Where it fired, as fractions of `length` in the cast's own frame. */ + this._crackAlong = 0; + this._crackSide = 0; + this._crackLift = 0; + /** `crack.speed / waveSpeed` — unitless, and how hard everything hits. */ + this._crackPower = 1; + /** How far the lash has fallen, 0..1. Rewritten every frame. */ + this._fall = 0; + + // Scratch handed to the two modules each frame. One object each, reused — + // syncing the whole ability allocates nothing (I3). + this._state = { + origin: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 0, + fade: 1, + widthFade: 1, + seed: 0, + time: 0 + }; + this._ringState = { + origin: new Vector3(), + axis: new Vector3(0, 0, 1), + side: new Vector3(1, 0, 0), + span: 1, + t: 0, + fade: 1, + seed: 0 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Embers: velocity-stretched streaks under a light gravity. Not sparks — + // an ember is a piece of the fire that came off, so it is slow, it is big, + // and it falls at a third of the rate a spark does. + this.embers = particles.get('firewhip.embers', { + capacity: 2600, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.3 + }); + this.embers.uniforms.uDrag.value = 1.1; + this.embers.uniforms.uEndSize.value = 0.15; + this.embers.uniforms.uSizeIn.value = 0.05; + this.embers.uniforms.uFadeIn.value = 0.06; + this.embers.uniforms.uFadeOut.value = 0.5; + + // Ash flakes. Lit rather than additive, because ash is the one thing in a + // fire effect that is *darker* than what is behind it — additive ash is + // just more fire, and the flakes stop reading the moment they cross the + // lash. `curl` gives them the flutter. + this.ash = particles.get('firewhip.ash', { + capacity: 1800, + shape: ParticleShape.LEAF, + additive: false, + lit: true, + curl: true, + softFade: 0.35 + }); + this.ash.uniforms.uDrag.value = 2.2; + this.ash.uniforms.uEndSize.value = 0.9; + this.ash.uniforms.uSizeIn.value = 0.1; + this.ash.uniforms.uFadeIn.value = 0.12; + this.ash.uniforms.uFadeOut.value = 0.45; + + // Thin haze off the burn. Non-additive so it genuinely occludes. + this.smoke = particles.get('firewhip.smoke', { + capacity: 1400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.smoke.uniforms.uDrag.value = 1.7; + this.smoke.uniforms.uEndSize.value = 3.2; + this.smoke.uniforms.uSizeIn.value = 0.14; + this.smoke.uniforms.uFadeIn.value = 0.2; + this.smoke.uniforms.uFadeOut.value = 0.35; + + this.emberEmitter = new RateEmitter(); + this.ashEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + this.scorchEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Halo + sheath + core, and the ring once it exists. HUD readout only. */ + get instanceCount() { + return this.lash.drawCalls + (this.ring.visible ? this.ring.drawCalls : 0); + } + + /** The lash hangs at full extension — the crack lands inside this beat. */ + get impactDuration() { + return Math.max(0.05, settings.firewhip.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.firewhip.fadeTime); + } + + /** + * Firelight gutters on two clocks at once: a slow breath from the body of the + * flame and a quantised stutter from the embers coming off it. A pure sine + * reads as a lamp on a dimmer, and the Storm Lance's hard hash reads as + * electricity — this wants to sit between them. + */ + lightShimmer() { + const c = settings.firewhip; + const depth = saturate(c.lightFlicker); + const step = Math.floor(this.age * Math.max(1, c.lightFlickerSpeed)); + const stutter = Math.abs(Math.sin(step * 91.7) * 43758.5453) % 1; + const breath = 0.5 - 0.5 * Math.cos(this.age * 6.1); + return 1 - depth * (0.65 * stutter + 0.35 * breath); + } + + /* ------------------------------------------------------------------ */ + /* The lash's geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** + * The handle, in world space. + * + * The base class puts `origin` on the floor because that is what the aim + * arrow targets. A whip comes out of a hand. + */ + _handPoint(out) { + const c = settings.firewhip; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** The far end of the lash — which comes down as it falls. */ + _endPoint(out) { + const c = settings.firewhip; + this.pointAt(1, out); + out.y = lerp(c.tipHeight, c.fallHeight, this._fall); + return out; + } + + /** + * How far the lash has come down, 0..1. + * + * Measured from the **crack**, not from the phase clock: the crack is what + * takes the energy out of the whip, so the fall has to start there or the + * lash reads as dropping on a schedule that has nothing to do with what you + * just watched. `phaseT` is a floor under it purely so that a whip tuned + * slack enough never to crack still comes down before the cast ends. + */ + _fallAmount(phaseT) { + const c = settings.firewhip; + let k = 0; + if (this._crackAt >= 0) k = saturate((this.age - this._crackAt) / Math.max(0.05, c.fallTime)); + if (phaseT > 1) k = Math.max(k, saturate(phaseT - 1)); + return Math.pow(k, Math.max(0.05, c.fallCurve)); + } + + /** + * How much of the lash exists, 0..1. + * + * During the wind-up only the first `windUpReach` of it is drawn, which is + * where the loop is: a whip being cocked is a curl of leather at the hand and + * nothing else. After release the front takes over, but never falls below the + * wind-up reach, because a lash that shortens at the moment you throw it + * looks like a bug and took an embarrassingly long time to spot. + */ + _progress() { + const c = settings.firewhip; + const wind = Math.max(0, c.windUp); + if (this.phase !== AbilityPhase.TRAVEL) return 1; + if (this.age < wind) { + return saturate(c.windUpReach * Easing.outQuad(saturate(this.age / Math.max(0.01, wind)))); + } + return Math.max(this.u, saturate(c.windUpReach)); + } + + /** + * Where the crack happened, rebuilt from the three fractions the cast + * captured against the live cast frame. + * + * This is the I1-clean half of "fire the ring at the tip". The tube hands + * back a world position in metres; keeping that would freeze the ring in + * space the moment anybody dragged `range`. Storing it as fractions of the + * span costs three dot products at the crack and buys a ring that follows the + * cast it belongs to. + */ + _crackPoint(out) { + out + .copy(this.origin) + .addScaledVector(this.direction, this._crackAlong * this.length) + .addScaledVector(this.side, this._crackSide * this.length); + out.y = this._crackLift * this.length; + return out; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + /** + * Hold the front at the hand while the loop forms. + * + * The wind-up beat, bought exactly the way Nova Beam buys its charge: by + * refusing to let `advance()` leave the caster. The base class's ease off the + * standstill is kept, but keyed off the moment of *release* rather than the + * moment of the cast. + */ + advance(dt) { + const c = this.config; + const wind = Math.max(0, c.windUp); + if (this.age < wind) { + this.front = 0; + this.u = 0; + this.pointAt(0, this.position); + return false; + } + + const speed = c.speed * settings.global.speed; + const since = this.age - wind; + this.front += speed * Easing.outQuad(saturate(since / 0.06)) * dt; + + const previousU = this.u; + this.u = saturate(this.front / this.length); + this.pointAt(this.u, this.position); + return this.u >= 1 && previousU < 1; + } + + onSpawn() { + this.emberEmitter.reset(); + this.ashEmitter.reset(); + this.smokeEmitter.reset(); + this.scorchEmitter.reset(); + + this._seed = Math.random() * 100; + this._crackAt = -1; + this._crackAlong = 1; + this._crackSide = 0; + this._crackLift = 0; + this._crackPower = 1; + this._fall = 0; + this.ring.visible = false; + + // One sync before anything else runs, so the tube's crack edge-detector is + // disarmed at wave phase zero rather than wherever the last cast left it. + this._syncLash(1, 1, 0); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the cast state into the lash — **and poll the + * crack on the very next line.** + * + * The poll belongs here and nowhere else. `Tube#sync` recomputes + * `tipSpeed`, `waveSpeed` and the crack edge every time it runs; anything + * that syncs the tube and then does something else first is one frame late, + * and one frame at these speeds is a metre and a half of lash. + * + * @param {number} fade 1 while the lash is lit, ramping to 0 as it dies + * @param {number} widthFade the collapse to a burning thread + * @param {number} phaseT 0..1 through the impact, then 1..2 through the fade + */ + _syncLash(fade, widthFade, phaseT) { + const c = settings.firewhip; + const g = settings.global; + const state = this._state; + + this._fall = this._fallAmount(phaseT); + + this._handPoint(state.origin); + this._endPoint(state.target); + state.side.copy(this.side); + state.progress = this._progress(); + state.fade = fade; + state.widthFade = widthFade; + state.seed = this._seed; + // Seconds since the lash was *released*, which is the clock the loop runs + // on. Derived, not stored: drag `windUp` while paused and the loop moves. + state.time = Math.max(0, this.age - Math.max(0, c.windUp)); + + this.lash.sync(c, state); + + // THE POLL. One cast, one crack: the tube re-arms and would fire a second + // time as the tip's velocity reverses through the threshold on its way + // back out, and two bangs 200 ms apart read as a stutter rather than as a + // whip. + if (this.lash.crack.fired && this._crackAt < 0) this._fireCrack(); + + this._syncRing(); + this._syncParticleLook(c, g); + } + + /** The shock ring, standing at the crack for `crackLife` seconds. */ + _syncRing() { + const c = settings.firewhip; + if (this._crackAt < 0) { + this.ring.visible = false; + return; + } + const t = (this.age - this._crackAt) / Math.max(0.02, c.crackLife); + if (t > 1) { + this.ring.visible = false; + return; + } + + const state = this._ringState; + this._crackPoint(state.origin); + // The ring stands across the lash, so its axis is the heading. Re-read from + // the cast rather than captured at the crack: a captured direction is a + // captured radian, and the tip is on the axis by the time it cracks anyway. + state.axis.copy(this.direction); + state.side.copy(this.side); + state.span = this.length; + state.t = saturate(t); + state.fade = (1 - saturate(t)) * Math.min(1.8, this._crackPower); + state.seed = this._seed; + + this.ring.visible = true; + this.ring.sync(c, state); + } + + /** The three particle systems, re-resolved every frame like everything else. */ + _syncParticleLook(c, g) { + this.embers.setGradient( + getColor(c.colorEmberA), + getColor(c.colorEmberB), + getColor(c.colorEmberC), + getColor(c.colorEmberD) + ); + this.embers.uniforms.uGravity.value.set(0, c.emberGravity, 0); + this.embers.uniforms.uSizeScale.value = c.emberSize * g.particleSize * 7; + this.embers.uniforms.uLifeScale.value = c.emberLifetime * 0.5 * g.particleLifetime; + this.embers.uniforms.uSpeedScale.value = g.particleSpeed; + this.embers.uniforms.uOpacity.value = g.opacity; + this.embers.uniforms.uGlow.value = 1.4 * g.glow; + this.embers.uniforms.uStretch.value = c.emberStretch; + this.embers.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.ash.setGradient( + getColor(c.colorAshA), + getColor(c.colorAshB), + getColor(c.colorAshC), + getColor(c.colorAshD) + ); + this.ash.uniforms.uGravity.value.set(0, c.ashGravity, 0); + this.ash.uniforms.uSizeScale.value = c.ashSize * g.particleSize * 7; + this.ash.uniforms.uLifeScale.value = c.ashLifetime * 0.5 * g.particleLifetime; + this.ash.uniforms.uSpeedScale.value = g.particleSpeed; + this.ash.uniforms.uOpacity.value = g.opacity; + this.ash.uniforms.uTurbulence.value = c.ashTurbulence * g.turbulence; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.4 * g.turbulence; + } + + /** The flare at the handle as the lash goes. */ + _muzzleFx() { + const c = settings.firewhip; + const g = settings.global; + + this._handPoint(_pos); + + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.muzzleSize * 0.25, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.35, + intensity: c.muzzleIntensity, + opacity: 0.85, + fresnel: 1.3, + displace: 0.7, + colorA: getColor(c.colorMuzzleA), + colorB: getColor(c.colorMuzzleB), + colorC: getColor(c.colorMuzzleC) + }); + + _emit.position = _pos; + _emit.radius = 0.16; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.6).setY(0.5).normalize(); + _emit.speed = c.emberSpeed * 2.2; + _emit.speedVariance = 0.8; + _emit.spread = c.emberSpread; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.2; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.embers.emit(Math.round(c.releaseSparks * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /** + * The crack. Called from inside `_syncLash`, on the frame the tip broke the + * wave speed, with the tube's own answer for where that happened. + */ + _fireCrack() { + const c = settings.firewhip; + const g = settings.global; + const crack = this.lash.crack; + + /* --- capture: a timestamp, three fractions and a unitless ratio --- */ + const inv = 1 / Math.max(this.length, 0.01); + _rel.subVectors(crack.point, this.origin); + this._crackAlong = _rel.dot(this.direction) * inv; + this._crackSide = _rel.dot(this.side) * inv; + this._crackLift = crack.point.y * inv; + this._crackPower = this.lash.waveSpeed > 0 ? crack.speed / this.lash.waveSpeed : 1; + this._crackAt = this.age; + + // Everything below is driven off that ratio, so a whip tuned to only just + // break the wave pops, and one tuned to blow through it bangs. + const power = Math.min(3, this._crackPower); + this._crackPoint(_pos); + + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.crackBurstSize * 0.2, + endRadius: c.crackBurstSize * power * g.explosionIntensity, + life: 0.45, + intensity: c.crackBurstIntensity, + opacity: 0.9, + fresnel: 1.5, + displace: 0.8, + colorA: getColor(c.colorCrackBurstA), + colorB: getColor(c.colorCrackBurstB), + colorC: getColor(c.colorCrackBurstC) + }); + + const time = frame.uTime.value; + _emit.position = _pos; + _emit.radius = 0.2; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.35).setY(0.6).normalize(); + _emit.speed = c.emberSpeed * 3.4 * power; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.22; + _emit.sizeVariance = 0.8; + _emit.life = c.emberLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.embers.emit(Math.round(c.crackSparks * g.particleCount), _emit); + + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.ashSpeed * 2.6 * power; + _emit.spread = 1.0; + _emit.size = 0.16; + _emit.life = c.ashLifetime; + _emit.spin = c.ashSpin; + this.ash.emit(Math.round(c.crackAshBurst * g.particleCount), _emit); + + this.ctx.shake.add( + c.crackShake * power * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.crackShakeTime), + 30 + ); + this.ctx.flash.trigger(getColor(c.colorCrackFlash), c.crackFlash * power * g.explosionIntensity); + this.lightBoost = c.lightIntensity * c.crackLight * power * g.explosionIntensity; + } + + /** + * Embers, ash and smoke shed along the lash. + * + * Every position comes off `lash.pointAt()` and every radius off + * `lash.radiusAt()` — the tube's own JS mirror of its vertex shader — so the + * particles sit on the curve the GPU is drawing rather than near it. The + * first version placed them on the straight line from hand to target, which + * is fine right up until the loop throws half a metre of lash sideways and + * the embers stay behind in a neat row. + * + * @param {number} scale 0..1, thinned as the lash dies + */ + _lashFx(dt, scale) { + const c = settings.firewhip; + const g = settings.global; + const time = frame.uTime.value; + const reach = Math.max(0.05, this._progress()); + // Once it is down and burning, the embers pour rather than shed. + const emberRate = lerp(c.emberRate, c.emberFallRate, this._fall) * scale; + + let emberCount = Math.round(this.emberEmitter.tick(dt, emberRate) * g.particleCount); + if (emberCount > 0) { + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.emberSpeed; + _emit.speedVariance = 0.8; + _emit.spread = c.emberSpread; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.14; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + + const batches = Math.min(emberCount, EMBER_BATCHES); + const per = Math.ceil(emberCount / batches); + while (emberCount > 0) { + const s = randRange(0.04, 1) * reach; + this.lash.pointAt(s, _pos); + _emit.position = _pos; + _emit.radius = this.lash.radiusAt(s) * 1.6; + this.embers.emit(Math.min(per, emberCount), _emit); + emberCount -= per; + } + } + + const ashCount = Math.round(this.ashEmitter.tick(dt, c.ashRate * scale) * g.particleCount); + if (ashCount > 0) { + const s = Math.random() * reach; + this.lash.pointAt(s, _pos); + _emit.position = _pos; + _emit.radius = this.lash.radiusAt(s) * 2.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.ashSpeed; + _emit.speedVariance = 0.85; + _emit.spread = c.ashSpread; + _emit.size = 0.12; + _emit.sizeVariance = 0.8; + _emit.life = c.ashLifetime; + _emit.lifeVariance = 0.6; + _emit.spin = c.ashSpin; + _emit.time = time; + this.ash.emit(ashCount, _emit); + } + + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * scale) * g.particleCount); + if (smokeCount > 0) { + const s = Math.random() * reach; + this.lash.pointAt(s, _pos); + _emit.position = _pos; + _emit.radius = this.lash.radiusAt(s) * 3.5; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + } + + /** + * Burn marks laid under the lash while it lies on the floor burning out. + * + * Rated per second rather than per metre of travel, because by this point + * nothing is travelling: the lash is down and the floor under it is cooking. + */ + _groundFx(dt) { + const c = settings.firewhip; + if (this._fall <= 0.02) return; + + const marks = this.scorchEmitter.tick(dt, c.scorchRate * this._fall); + for (let i = 0; i < marks; i++) { + this.lash.pointAt(Math.random(), _pos); + _pos.y = 0.015; + this.ctx.decals.spawn(DecalType.SCORCH, _pos, { + radius: c.scorchRadius * randRange(0.7, 1.35), + life: c.scorchLife, + intensity: c.scorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorScorchEmber), + height: 0.015 + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._syncLash(1, 1, 0); + + // The light rides the tip of the lash. `advance()` has already put + // `position` on the ground line, so lift it onto the curve — and the tube + // knows where the tip is far better than this file does. + this.position.copy(this.lash.tipPoint); + + // Nothing sheds off a lash that is still coiled in the hand. + const out = this.age < Math.max(0, settings.firewhip.windUp) ? 0.35 : 1; + this._lashFx(dt, out); + + this.ctx.shake.rumble(settings.firewhip.rumble * settings.global.cameraShake, dt); + } + + /** + * Full extension. Not the payoff — the crack is — so this is a thump and a + * handful of embers off the leather, and nothing that would upstage the bang + * still to come. + */ + onImpact() { + const c = settings.firewhip; + const g = settings.global; + + this.lash.pointAt(1, _pos); + + _emit.position = _pos; + _emit.radius = this.lash.radiusAt(1) * 2.5; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.5).setY(0.45).normalize(); + _emit.speed = c.emberSpeed * 2.4; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.8; + _emit.life = c.emberLifetime * 1.2; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.embers.emit(Math.round(c.snapSparks * g.particleCount), _emit); + + this.ctx.shake.add( + c.snapShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.snapShakeTime), + 22 + ); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 while the lash hangs, then 1..2 while it burns out. The + // brightness goes cubically — leather holds its heat and then lets go — and + // the width collapses on a separate, gentler curve, so what is left at the + // end is a thin burning thread rather than a faint fat one. + const dying = saturate(t - 1); + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(dying); + const widthFade = lerp(1, 0.22, Easing.outQuad(dying)); + + this._syncLash(fade, widthFade, t); + this.position.copy(this.lash.tipPoint); + + this._lashFx(dt, fade * (t <= 1 ? 0.85 : 0.5)); + this._groundFx(dt); + } + + onDestroy() { + this.ring.visible = false; + this._crackAt = -1; + this._fall = 0; + } + + dispose() { + this.lash.dispose(); + this.ring.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/flame/MagmaAbility.js b/src/abilities/flame/MagmaAbility.js new file mode 100644 index 0000000..4918989 --- /dev/null +++ b/src/abilities/flame/MagmaAbility.js @@ -0,0 +1,856 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { LiquidSurface, LiquidMode, liquidParams } from '../../vfx/LiquidSurface.js'; +import { Projectile, FlightMode, Stagger, projectileParams } from '../../vfx/Projectile.js'; +import { DistortionField, DistortionMode } from '../../vfx/Distortion.js'; +import { createMagmaBlobMaterial, setBlobColors } from '../../materials/MagmaBlobMaterial.js'; +import { createAsteroidGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** Hard ceiling on blobs. The editor's `blobs` slider clamps here. */ +const MAX_BLOBS = 48; +/** + * Grid resolution of the heightfield. Below about 48 the Gerstner cusps facet + * visibly; above about 160 you are paying vertex cost for detail the shading + * normal already carries for free, because the normal is four evaluations of + * the whole field rather than a difference of neighbouring vertices. + */ +const POOL_SEGMENTS = 108; + +/* --- module-scope scratch: the frame allocates nothing (I3) --- */ +const _liq = liquidParams(); +const _proj = projectileParams(); +const _haze = {}; +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _hit = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/** + * MAGMA FOUNT — a molten pool torn open in the floor, with a fount playing out + * of the middle of it. + * + * The floor swells and splits, the fount runs for a long hold, and then it + * cools: the crust closes over, the seams dim, and the last thing left on + * screen is the heat coming off a black slab. + * + * **THE TRICK — the pool is real, and the crust is a consequence rather than a + * decoration.** `vfx/LiquidSurface.js` is a live heightfield whose fragment + * shader computes an honest 2-D **surface speed** in metres per second: a bulk + * drift, a radial outflow that dies with distance from the feed, eddies taken + * as the *curl* of a scalar noise field (so they swirl without any point acting + * as a source or a sink), and downhill gravity read straight off the shading + * normal. Crust coverage is then `1 − smoothstep(crustForm, crustBreak, speed)` + * and the crack pattern is the zero crossing of a signed fbm evaluated in a + * frame built from the flow direction and squashed along it, so the seams run + * *with* the pour like pahoehoe instead of crazing like pottery. + * + * Which means: a blob lands, `rippleAtWorld()` puts an analytic packet into the + * heightfield, the packet steepens the local slope, the slope feeds + * `flowGravity`, the flow pushes the surface past `crustBreak`, and **the black + * skin tears open along the ripple front and glows** — then heals behind it as + * the packet decays. Nothing in this file says "crack when a blob lands". The + * coupling is real, and that is why it reads. + * + * The cool is the same mechanism run backwards. It does not paint crust on; it + * takes the *flow* away — `flowRadial`, `flowEddy` and `flowSpeed` all ramp to + * zero over `coolTime` — and coverage goes to one on its own, at which point + * the only thing still moving on screen is the `HEAT` emitter's shimmer. + * + * **Ordering matters and is easy to get wrong.** `rippleAtWorld()` converts a + * world point into a *fraction* of this frame's half-extents, so it must be + * called after `LiquidSurface.update()` on the frame it is measured against. + * The update order here is therefore: pool, then blobs, then arrivals. Do it + * the other way round while the pool is still swelling and every early ripple + * lands at the wrong radius — subtly, and only during the first half second, + * which is the worst kind of bug to notice. + * + * **Budget.** `LiquidSurface` is fill-heavy — one per screen — so everything + * else in this ability is deliberately cheap: the fount is one `Projectile` + * (two draw calls, bodies and trails, however many blobs), the shimmer is one + * emitter into a buffer nothing else reads, and there is no `GroundField` + * because a second full-footprint quad under the pool would double the most + * expensive thing on screen to add a mark you cannot see through the melt. The + * scorched rock outside the waterline is shared decals instead. + * + * A cast captures two numbers: a seed and the timestamp the floor split at. + * Everything with a unit is resolved from `settings.magma` every frame, + * zero-length frames included. + * + * Four draw calls: the pool, the blobs, their trails, the shimmer. + */ +export class MagmaAbility extends Ability { + constructor(context) { + super('magma', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the pool --------------------------------------------------- */ + this.pool = new LiquidSurface({ + segments: POOL_SEGMENTS, + mode: LiquidMode.POOL, + // A heightfield is a solid: its own crests must hide its far side, or a + // swell reads as a sheet of cellophane. + depthWrite: true, + doubleSide: true, + renderOrder: 3, + name: 'magma.pool' + }); + this.pool.object3D.layers.set(LAYER.VFX); + this.group.add(this.pool.object3D); + + /* --- the fount --------------------------------------------------- */ + this.blobMaterial = createMagmaBlobMaterial(); + this.blobs = new Projectile(this.group, { + capacity: MAX_BLOBS, + // A factory, not a geometry: `Projectile` takes ownership of what this + // returns and rebuilds it whenever `shapeKey()` moves, which is what + // makes the four shape sliders live. + geometry: () => { + const c = settings.magma; + return createAsteroidGeometry({ + seed: 7, + detail: c.blobDetail, + lumpiness: c.blobLumpiness, + noiseScale: c.blobNoiseScale, + roughness: c.blobRoughness, + // No cuts and no craters. A blob of melt has no flat faces and no + // impact scars — the first build reused Cinder Fall's rock wholesale + // and it read as a burning pebble, not as something poured. + cuts: 0, + craters: 0 + }); + }, + shapeKey: () => { + const c = settings.magma; + return `${Math.round(c.blobDetail)}|${c.blobLumpiness}|${c.blobNoiseScale}|${c.blobRoughness}`; + }, + material: this.blobMaterial, + trail: true, + trailNodes: 26, + trailAdditive: true, + layer: LAYER.VFX, + renderOrder: 6, + castShadow: false + }); + + /* --- the shimmer -------------------------------------------------- */ + // UPRIGHT rather than BILLBOARD: heat rises vertically whatever the camera + // is doing, and a full billboard makes the column lean over when you orbit, + // which the eye reads as wind rather than as heat. + this.haze = new DistortionField({ mode: DistortionMode.HEAT, name: 'magma.haze' }); + this.group.add(this.haze.object3D); + + /** Re-rolled per cast — decorrelates the pool's noise and the fount's dice. */ + this._seed = 0; + /** `this.age` at the moment the floor split. A timestamp, not a duration. */ + this._foundAt = 0; + /** Blobs drawn last frame, for the HUD's instance readout. */ + this._live = 0; + /** 0 while the cast is still travelling, then `t` out of `onFade`. */ + this._beat = 0; + /** + * 0..1 — is the fount still playing? Resolved once per frame in `_sync` + * and read by the emitters, so the one expression that decides it lives in + * one place rather than in three. + */ + this._fountGate = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Droplets thrown off the fount and off every landing. Additive and soft: + // this is the melt in the air, and it has to read as *light* rather than as + // grit or the pool stops looking hot. + this.spatter = particles.get('magma.spatter', { + capacity: 3000, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.35 + }); + this.spatter.uniforms.uDrag.value = 1.2; + this.spatter.uniforms.uEndSize.value = 0.25; + this.spatter.uniforms.uSizeIn.value = 0.05; + this.spatter.uniforms.uFadeIn.value = 0.05; + this.spatter.uniforms.uFadeOut.value = 0.5; + + // Chips that have already chilled. Lit and non-additive, so they read as + // solid rock against the glow — the contrast is the point. + this.cinders = particles.get('magma.cinders', { + capacity: 1400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.cinders.uniforms.uDrag.value = 0.35; + this.cinders.uniforms.uEndSize.value = 0.7; + this.cinders.uniforms.uFadeOut.value = 0.65; + + this.smoke = particles.get('magma.smoke', { + capacity: 1800, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.smoke.uniforms.uDrag.value = 2.0; + this.smoke.uniforms.uEndSize.value = 3.4; + this.smoke.uniforms.uSizeIn.value = 0.16; + this.smoke.uniforms.uFadeIn.value = 0.22; + this.smoke.uniforms.uFadeOut.value = 0.3; + + this.spatterEmitter = new RateEmitter(); + this.cinderEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + /** The long hold: the fount runs for the whole of it. */ + get impactDuration() { + return Math.max(0.1, settings.magma.lifetime * settings.global.lifetime); + } + + /** The cool. */ + get fadeDuration() { + return Math.max(0.1, settings.magma.fadeTime); + } + + /** A slow, heavy gutter — this is a pool breathing, not a bolt striking. */ + lightShimmer() { + const c = settings.magma; + const step = Math.floor(this.age * Math.max(1, c.lightFlickerSpeed)); + const noise = Math.abs(Math.sin(step * 127.1) * 43758.5453) % 1; + return 1 - saturate(c.lightFlicker) * noise; + } + + /* ------------------------------------------------------------------ */ + /* The beats */ + /* ------------------------------------------------------------------ */ + + /** The centre of the footprint — the far end of the aimed line. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** + * 0..1 — how far the pool has opened out. + * + * During travel it is only the swelling: the floor bulges to `preFill` of the + * footprint and no further. The rest arrives when the floor splits. + */ + _fill() { + const c = settings.magma; + if (this.phase === AbilityPhase.TRAVEL) return saturate(c.preFill) * this.u; + const open = Easing.outCubic(saturate(this.impactTime / Math.max(0.02, c.swellTime))); + return lerp(saturate(c.preFill), 1, open); + } + + /** 0..1 — how far the flow has died. Zero until the fade phase starts. */ + _cool() { + const c = settings.magma; + return Easing.inOutCubic(saturate(this.fadeTime / Math.max(0.05, c.coolTime))); + } + + /** 0..1 — how hard the fount is playing. */ + _fount() { + const c = settings.magma; + if (this.phase === AbilityPhase.TRAVEL) return 0; + const ramp = saturate((this.age - this._foundAt) / Math.max(0.02, c.fountRamp)); + return ramp * (1 - this._cool()); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.spatterEmitter.reset(); + this.cinderEmitter.reset(); + this.smokeEmitter.reset(); + this._foundAt = 0; + this._beat = 0; + this._live = 0; + + this._seed = Math.random() * 100; + this.blobs.reset(); + this.blobs.roll(this._seed); + this.pool.reset(); + this.pool.visible = true; + this.haze.visible = true; + + this._sync(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Resolve the pool, the fount and the shimmer from live settings. + * + * Every metre, radian and second below is read on the frame it is used, + * including a zero-length one. The order — pool, then blobs, then the + * arrivals the blobs raised — is load-bearing; see the class comment. + */ + _sync() { + const c = settings.magma; + const g = settings.global; + + const cool = this._cool(); + const fount = this._fount(); + // The flow is what everything else is a function of, so the cool is one + // multiplier applied to three sliders rather than a second set of + // "cooled" values that would have to be kept in step with the first. + const flowing = 1 - cool; + + this._centrePoint(_centre); + + /* ---------------- the pool ---------------- */ + _centre.y = c.poolHeight; + this.pool.setPlacement(_centre, this.direction, _up); + _centre.y = 0; + + _liq.sizeX = c.zoneRadius * 2; + _liq.sizeZ = c.zoneRadius * 2; + _liq.fill = this._fill(); + _liq.round = c.round; + _liq.edgeSoft = c.edgeSoft; + _liq.edgeNoise = c.edgeNoise; + _liq.edgeScale = c.edgeScale; + _liq.seed = this._seed; + _liq.opacity = c.poolOpacity * g.opacity; + _liq.contactFade = c.contactFade; + + // The swell dies with the flow but never entirely: a cooled slab still has + // the shape the last wave left in it, and flattening it to a plane is the + // single most obvious way to make the cool look like a fade-out. + const swell = lerp(1, 0.35, cool); + _liq.waveAmpA = c.waveAmpA * swell; + _liq.waveAmpB = c.waveAmpB * swell; + _liq.waveAmpC = c.waveAmpC * swell; + _liq.waveAmpD = c.waveAmpD * swell; + _liq.waveLengthA = c.waveLengthA; + _liq.waveLengthB = c.waveLengthB; + _liq.waveLengthC = c.waveLengthC; + _liq.waveLengthD = c.waveLengthD; + _liq.waveSpeedA = c.waveSpeedA * flowing; + _liq.waveSpeedB = c.waveSpeedB * flowing; + _liq.waveSpeedC = c.waveSpeedC * flowing; + _liq.waveSpeedD = c.waveSpeedD * flowing; + _liq.waveAngleA = c.waveAngleA; + _liq.waveAngleB = c.waveAngleB; + _liq.waveAngleC = c.waveAngleC; + _liq.waveAngleD = c.waveAngleD; + _liq.steepness = c.steepness; + + _liq.chop = c.chop * swell; + _liq.chopScale = c.chopScale * g.noiseFrequency; + _liq.chopSpeed = c.chopSpeed * g.noiseSpeed * flowing; + _liq.detail = c.detail; + _liq.detailScale = c.detailScale * g.noiseFrequency; + _liq.detailSpeed = c.detailSpeed * g.noiseSpeed; + + _liq.rippleAmp = c.rippleAmp; + _liq.rippleSpeed = c.rippleSpeed; + _liq.rippleLength = c.rippleLength; + _liq.rippleWidth = c.rippleWidth; + _liq.rippleDecay = c.rippleDecay; + _liq.rippleSpread = c.rippleSpread; + + _liq.flowAngle = c.flowAngle; + _liq.flowSpeed = c.flowSpeed * flowing; + _liq.flowRadial = c.flowRadial * flowing; + _liq.flowRadialFall = c.flowRadialFall; + _liq.flowEddy = c.flowEddy * flowing; + _liq.flowEddyScale = c.flowEddyScale * g.noiseFrequency; + _liq.flowEddySpeed = c.flowEddySpeed * g.noiseSpeed; + // Left alone by the cool on purpose. This is the term that lets a landing + // blob's ripple tear the skin, and it should keep doing that right up to + // the moment the surface stops moving at all. + _liq.flowGravity = c.flowGravity; + + _liq.crust = c.crust; + _liq.crustForm = c.crustForm; + _liq.crustBreak = c.crustBreak; + _liq.crustFormTime = c.crustFormTime; + _liq.crackScale = c.crackScale * g.noiseFrequency; + _liq.crackStretch = c.crackStretch; + _liq.crackWidth = c.crackWidth; + _liq.crustAdvect = c.crustAdvect; + _liq.crustPeriod = c.crustPeriod; + _liq.crustBump = c.crustBump; + _liq.seamGlow = c.seamGlow * flowing * g.glow; + _liq.meltGlow = c.meltGlow * flowing * g.glow; + + _liq.poolDepth = c.poolDepth; + _liq.depthTint = c.depthTint; + _liq.translucency = c.translucency; + _liq.ambient = c.ambient; + _liq.specular = c.specular; + _liq.shininess = c.shininess; + _liq.fresnel = c.fresnel * g.fresnel; + _liq.envIntensity = c.envIntensity; + _liq.skyIntensity = c.skyIntensity; + _liq.emissive = c.emissive * flowing; + _liq.glow = c.poolGlow * g.glow; + _liq.normalEps = c.normalEps; + + _liq.colorDeep = c.colorDeep; + _liq.colorShallow = c.colorShallow; + _liq.colorCrust = c.colorCrust; + _liq.colorSeam = c.colorSeam; + _liq.colorHot = c.colorHot; + _liq.colorSpec = c.colorSpec; + _liq.colorSky = c.colorSky; + + this.pool.update(this.age, _liq); + + /* ---------------- the fount ---------------- */ + // `setBasis` clamps the length to a centimetre, so the fount's landing disc + // is centred a centimetre downrange of the pool's centre. That is below the + // resolution of anything on screen and it buys a launch point that is + // genuinely the middle of the pool rather than the caster's hand. + this.blobs.setBasis(_centre, this.direction, this.side, 0.01); + + _proj.mode = FlightMode.ARC; + _proj.stagger = Stagger.HASH; + _proj.count = this.phase === AbilityPhase.TRAVEL ? 0 : Math.min(MAX_BLOBS, Math.round(c.blobs)); + _proj.radius = c.blobRadius; + _proj.sizeJitter = c.blobSizeJitter * g.randomness; + _proj.stretch = c.blobStretch; + _proj.align = c.blobAlign; + _proj.spin = c.blobSpin; + _proj.flash = c.blobFlash; + + _proj.handForward = c.fountForward; + _proj.handSide = c.fountSide; + _proj.handHeight = c.fountHeight; + + _proj.landHeight = c.poolHeight; + _proj.landInZone = true; + _proj.zoneRadius = c.zoneRadius * c.blobReach; + _proj.zoneBias = c.blobBias; + + _proj.pathCurve = c.blobPathCurve; + _proj.apex = c.blobApex; + _proj.apexCurve = c.blobApexCurve; + _proj.weaveSide = c.blobWeaveSide; + _proj.weaveUp = c.blobWeaveUp; + _proj.weaveTurns = c.blobWeaveTurns; + _proj.weaveTurnsUp = c.blobWeaveTurns; + _proj.weaveDecay = c.blobWeaveDecay; + + _proj.flightTime = c.blobFlight; + _proj.speedJitter = c.blobSpeedJitter; + _proj.lead = c.blobLead; + _proj.window = c.blobWindow; + _proj.fillBias = c.blobFillBias; + _proj.fillScatter = c.blobFillScatter; + _proj.hashCell = c.blobHashCell; + _proj.linger = c.blobLinger; + _proj.sink = c.blobSink; + + _proj.trailSpan = c.blobTrailSpan; + _proj.trailBurn = c.blobTrailBurn; + _proj.trailWidth = c.blobTrailWidth; + _proj.trailTaper = c.blobTrailTaper; + _proj.trailLift = c.blobTrailLift; + _proj.trailOpacity = c.blobTrailOpacity * g.opacity * flowing; + _proj.trailGlow = c.blobTrailGlow * g.glow; + _proj.trailCore = c.blobTrailCore; + _proj.trailHeadBias = c.blobTrailHeadBias; + _proj.trailNoise = c.blobTrailNoise * g.turbulence; + _proj.trailNoiseScale = c.blobTrailNoiseScale * g.noiseFrequency; + _proj.trailNoiseSpeed = c.blobTrailNoiseSpeed * g.noiseSpeed; + _proj.trailSoftFade = c.blobTrailSoftFade; + + setBlobColors(this.blobMaterial, c.colorBlobHot, c.colorBlobMelt, c.colorBlobCrust, c.colorBlobSeam); + const b = this.blobMaterial.uniforms; + b.uCrust.value = c.blobCrust; + b.uCrustGrow.value = c.blobCrustGrow; + b.uCrackScale.value = c.blobCrackScale * g.noiseFrequency; + b.uCrackWidth.value = c.blobCrackWidth; + b.uSeamGlow.value = c.blobSeamGlow; + b.uRim.value = c.blobRim; + b.uRimPower.value = c.blobRimPower; + b.uShade.value = c.blobShade; + b.uAmbient.value = c.blobAmbient; + b.uFlashGain.value = c.blobFlashGain; + b.uGlow.value = c.blobGlow * g.glow; + b.uOpacity.value = c.blobOpacity * g.opacity; + b.uSoftFade.value = c.blobSoftFade; + + this.blobs.setTrailColors( + getColor(c.colorBlobTrailA), + getColor(c.colorBlobTrailB), + getColor(c.colorBlobTrailC), + getColor(c.colorBlobTrailD) + ); + this.blobs.syncGeometry(); + // Blob time starts when the floor splits, not when the cast leaves the + // hand: the launch window is authored against the fount, and offsetting it + // by the travel time would make `blobWindow` mean different things at + // different cast ranges. + this.blobs.update(Math.max(0, this.age - this._foundAt), _proj); + this._live = this.blobs.count; + + /* ---------------- the ripples the fount punches ---------------- */ + // Straight after `update()`, which is where the module says to read them, + // and after the pool's own update, which is where `rippleAtWorld()` gets + // this frame's half-extents from. + for (let i = 0; i < this.blobs.arrivalCount; i++) { + const index = this.blobs.arrivals[i]; + this.blobs.landPoint(index, _hit); + this.pool.rippleAtWorld(_hit, c.rippleStrength, this.age); + this._splashFx(_hit); + } + + /* ---------------- the shimmer ---------------- */ + _pos.copy(_centre); + _pos.y = c.poolHeight; + this.haze.setAnchor(_pos); + + // Held open through the first part of the cool and then let go, so the last + // thing on screen is heat coming off a slab that has already gone black. + const hazeOut = saturate((this.fadeTime - c.hazeHold) / Math.max(0.02, c.hazeFade)); + _haze.width = c.zoneRadius * 2 * c.hazeWidth; + _haze.height = c.hazeHeight; + // NEVER multiply global.distortion or post.distortion in here — the pass + // applies both, once, and doing it twice squares them. + _haze.strength = c.hazeStrength * (1 - hazeOut) * this._fill(); + _haze.opacity = c.hazeOpacity; + _haze.frequency = c.hazeFrequency * g.noiseFrequency; + _haze.speed = c.hazeSpeed * g.noiseSpeed; + _haze.sourceBias = c.hazeSourceBias; + _haze.spread = c.hazeSpread; + _haze.vertical = c.hazeVertical; + _haze.flicker = c.hazeFlicker; + _haze.depthReject = c.hazeDepthReject; + _haze.depthFade = c.hazeDepthFade; + _haze.perspective = c.hazePerspective; + _haze.perspectiveRef = c.hazePerspectiveRef; + _haze.seed = this._seed; + this.haze.update(_haze); + + /* ---------------- the particle systems ---------------- */ + this.spatter.setGradient( + getColor(c.colorSpatterA), + getColor(c.colorSpatterB), + getColor(c.colorSpatterC), + getColor(c.colorSpatterD) + ); + this.spatter.uniforms.uGravity.value.set(0, c.spatterGravity, 0); + this.spatter.uniforms.uSizeScale.value = c.spatterSize * g.particleSize * 7; + this.spatter.uniforms.uLifeScale.value = c.spatterLifetime * 0.5 * g.particleLifetime; + this.spatter.uniforms.uSpeedScale.value = g.particleSpeed; + this.spatter.uniforms.uOpacity.value = g.opacity; + this.spatter.uniforms.uGlow.value = c.meltGlow * 0.8 * g.glow; + this.spatter.uniforms.uTurbulence.value = c.spatterTurbulence * g.turbulence; + + this.cinders.setGradient( + getColor(c.colorCinderA), + getColor(c.colorCinderB), + getColor(c.colorCinderC), + getColor(c.colorCinderD) + ); + this.cinders.uniforms.uGravity.value.set(0, c.cinderGravity, 0); + this.cinders.uniforms.uSizeScale.value = c.cinderSize * g.particleSize * 7; + this.cinders.uniforms.uLifeScale.value = c.cinderLifetime * 0.5 * g.particleLifetime; + this.cinders.uniforms.uSpeedScale.value = g.particleSpeed; + this.cinders.uniforms.uOpacity.value = g.opacity; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.45 * g.turbulence; + + /* ---------------- the light, sitting on the surface ---------------- */ + this.position.copy(_centre); + this.position.y = c.poolHeight + c.lightHeight; + + // Kept in step with the swell so a pool that has not opened yet is not + // already lighting the stage. + this.pool.visible = _liq.fill > 0.001 && _liq.opacity > 0.001; + + // `fount` is consumed by the emitters in `_surfaceFx`; resolving it here + // keeps the one expression that decides "is it still playing" in one place. + this._fountGate = fount; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** A point on the pool's surface, `r` being a 0..1 fraction of the radius. */ + _surfacePoint(r, out) { + const c = settings.magma; + const angle = Math.random() * Math.PI * 2; + const radius = c.zoneRadius * this._fill() * r; + out.set(_centre.x + Math.cos(angle) * radius, c.poolHeight, _centre.z + Math.sin(angle) * radius); + return out; + } + + /** Spatter, cinders and smoke coming off the surface while the fount runs. */ + _surfaceFx(dt) { + const c = settings.magma; + const g = settings.global; + const time = frame.uTime.value; + const gate = this._fountGate; + + const spatterCount = Math.round(this.spatterEmitter.tick(dt, c.spatterRate * gate) * g.particleCount); + if (spatterCount > 0) { + // Crowded toward the middle, where the feed is: the outflow is radial, so + // the surface is fastest at the centre and that is where it throws. + this._surfacePoint(Math.random() * Math.random(), _pos); + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.12; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spatterSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.6; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.14; + _emit.sizeVariance = 0.7; + _emit.life = c.spatterLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spatter.emit(spatterCount, _emit); + } + + const cinderCount = Math.round(this.cinderEmitter.tick(dt, c.cinderRate * gate) * g.particleCount); + if (cinderCount > 0) { + this._surfacePoint(randRange(0.4, 1), _pos); + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.cinderSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.cinderLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.time = time; + this.cinders.emit(cinderCount, _emit); + } + + // Smoke keeps coming off a cooling slab long after the fount has stopped, + // so it is gated on the pool being open rather than on the fount running. + const smokeGate = this._fill(); + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * smokeGate) * g.particleCount); + if (smokeCount > 0) { + this._surfacePoint(Math.sqrt(Math.random()), _pos); + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.35; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.size = 1.0; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + } + + /** The little crown of droplets a blob throws where it goes back in. */ + _splashFx(point) { + const c = settings.magma; + const g = settings.global; + const count = Math.round(c.spatterOnLanding * g.particleCount); + if (count <= 0) return; + + _emit.position = point; + _emit.radius = c.blobRadius * 1.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spatterSpeed * 0.85; + _emit.speedVariance = 0.9; + // Wide and shallow: a splash crown leaves sideways, and firing it straight + // up just puts a second small fount on top of the first one. + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.8; + _emit.life = c.spatterLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.spatter.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._beat = 0; + this._sync(); + this._surfaceFx(dt); + } + + onImpact() { + const c = settings.magma; + const g = settings.global; + const time = frame.uTime.value; + + // The one timestamp a cast captures. Everything the fount does is measured + // from here rather than from the moment the cast left the hand. + this._foundAt = this.age; + + this._centrePoint(_centre); + _pos.copy(_centre); + _pos.y = c.poolHeight; + + /* the shell of burning gas as the floor gives way */ + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.8, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.3, + displace: 0.85, + squash: 0.65, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring across the floor */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _centre, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.7, + width: 0.07, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* scorched rock outside the waterline */ + const marks = Math.max(0, Math.round(c.scorchMarks)); + for (let i = 0; i < marks; i++) { + // Evenly spaced with a jittered bearing rather than fully random: a + // handful of random angles clumps, and a clumped ring reads as a mistake + // rather than as heat. + const angle = ((i + randRange(-0.35, 0.35)) / marks) * Math.PI * 2; + const radius = c.zoneRadius * randRange(0.85, 1.25); + _pos.set(_centre.x + Math.cos(angle) * radius, 0, _centre.z + Math.sin(angle) * radius); + this.ctx.decals.spawn(DecalType.SCORCH, _pos, { + radius: c.scorchRadius * randRange(0.7, 1.3), + life: c.scorchLife, + intensity: c.scorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorScorchEmber), + height: 0.014 + }); + } + + /* the first throw */ + _pos.copy(_centre); + _pos.y = c.poolHeight + c.fountHeight; + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spatterSpeed * 1.8; + _emit.speedVariance = 0.9; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.8; + _emit.life = c.spatterLifetime * 1.3; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spatter.emit(Math.round(c.burstSpatter * g.particleCount), _emit); + + _emit.speed = c.cinderSpeed * 1.6; + _emit.spread = 0.95; + _emit.size = 0.12; + _emit.life = c.cinderLifetime; + _emit.spin = 11; + this.cinders.emit(Math.round(c.burstCinders * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.1 * g.explosionIntensity; + } + + onFade(dt, t) { + this._beat = t; + this._sync(); + this._surfaceFx(dt); + + // The ground keeps shaking while the fount plays, and stops when it does. + this.ctx.shake.rumble( + settings.magma.rumble * this._fountGate * settings.global.cameraShake, + dt + ); + } + + onDestroy() { + this._live = 0; + this._beat = 0; + this._fountGate = 0; + this.blobs.reset(); + this.pool.reset(); + this.pool.visible = false; + // Toggling the field itself, never the parent group: hiding the group would + // leak the distortion pass's writer counter for the rest of the session. + this.haze.visible = false; + } + + dispose() { + this.pool.dispose(); + this.blobs.dispose(); + this.blobMaterial.dispose(); + this.haze.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/flame/PyroclasmAbility.js b/src/abilities/flame/PyroclasmAbility.js new file mode 100644 index 0000000..6dba76f --- /dev/null +++ b/src/abilities/flame/PyroclasmAbility.js @@ -0,0 +1,921 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { DistortionField, DistortionMode, DistortionFacing } from '../../vfx/Distortion.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/* Module-scope scratch. A standing dome allocates nothing — I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +/** The ground field's params bag, filled from settings every frame and reused. */ +const _scour = groundFieldParams(); +/** The heat emitter's params bag, likewise. */ +const _haze = {}; + +/** + * PYROCLASM — a dome of ash that **collapses before it blows**. + * + * A far cast. An ignition front runs out across the floor to the aimed circle; + * a raymarched dome of ash snaps into being at the full footprint, contracts + * onto its own centre, stands compressed while embers rain inside it, and then + * inverts its density and blasts out past the boundary it started from. Heat + * stands over the whole thing from the moment the front leaves the caster, and + * the floor is scoured by the blast front as it passes over it. + * + * ## THE TRICK — the field is world-space, the hull is not + * + * `Medium.ASH` samples its fbm in `worldDomain()`: **world metres**, advected by + * a world flow, with no knowledge whatever of the proxy hull it happens to be + * marched inside. The hull, meanwhile, is resized every frame by `setSize()`. + * Those two facts are decoupled on purpose and the decoupling *is* the + * implosion. The grain of the ash is nailed to the room, so as the silhouette + * eats inward you watch individual clots of soot pass out of the volume and + * finer structure crowd into what is left. It reads as a mass being squeezed. + * + * The first version used `Medium.FLAME`, whose field is sampled in + * `localDomain()` — hull space — because a jet of flame has to carry its own + * turbulence when the caster turns. That is exactly wrong here: a local field + * shrinks *with* `uSize`, so the contracting dome rendered as the same cloud + * getting smaller, frame for frame. It looked like the camera pulling back. Not + * one thing about the beat read until the medium changed, and no amount of + * animating the density curve rescued it — the failure is at the level of which + * space the noise lives in, which is not something a slider can reach. + * + * ## The inversion + * + * The blast is not "the same dome, bigger". Five uniforms are lerped by one + * unitless beat, from the values that make a compressed mass to the values that + * make a surge, and the ability writes them **after** `VolumeHull#sync()` — sync + * resolves them from settings and applies the global multipliers, then this + * class applies its own beat on top, so nothing here caches a number and + * dragging `ashDensity` while paused still moves a blast that is already out: + * + * - `HeightBias` past 1 drives the field negative near the crown, so the + * middle of the dome genuinely empties rather than merely thinning; + * - `DensityCurve` below 1 lifts the wispy fringe to nearly the weight the + * core used to carry; + * - `NoiseStrength` up, and erosion is *quadratic* in the distance past the + * nominal surface, so the field's weight is thrown outside the silhouette; + * - `Margin` up to pay for that — see the note in the settings block, and see + * `VolumeHull`'s "one rule". Raise erosion without margin and the ash is + * sliced along a dead straight line at the proxy wall; + * - `Density` down, because a surge is thinner than the thing it came out of. + * + * ## The floor + * + * The scour is **drawn by the front**, not spawned whole. `GroundField`'s + * `grow` is the blast front's current radius over the mark's own radius, both + * re-resolved every frame, so the bright lip in the shader is the actual + * leading edge of the surge and shortening `blastSpread` mid-cast pulls the + * burn back in with it. Spawning a decal at impact would capture its radius on + * the frame it was born and none of that would be true. + * + * ## What a cast captures + * + * `_seed` — one dice roll so two domes are not the same cloud — and two + * booleans recording that a one-shot has already fired. Nothing else. Every + * metre, radian, second and colour is resolved against `settings.pyroclasm` + * inside the update loop, on a zero-length frame included. + * + * ## Cost + * + * Three draw calls: the volume, the heat emitter, the scour. One dynamic light. + */ +export class PyroclasmAbility extends Ability { + constructor(context) { + super('pyroclasm', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The dome. `maxSteps` is the compile-time loop cap, not the step count — + * `ashSteps` drives the real one and stays a slider under it, so the shader + * has headroom to be turned up without a recompile. + */ + this.hull = new VolumeHull({ + hull: HullShape.DOME, + medium: Medium.ASH, + prefix: 'ash', + maxSteps: 56, + renderOrder: 12 + }); + this.group.add(this.hull.mesh); + + /** + * The heat column. `UPRIGHT` rather than `BILLBOARD`: hot air rises along + * world up whatever the camera is doing, and a full billboard makes the + * plume lean over as you orbit, which reads as wind. + */ + this.haze = new DistortionField({ + mode: DistortionMode.HEAT, + facing: DistortionFacing.UPRIGHT, + name: 'Pyroclasm:heat' + }); + this.group.add(this.haze.object3D); + + /** + * The floor. `SCOUR` and not a decal, for the reason in the class comment: + * a decal captures its radius when it spawns and this mark has to be drawn + * by a front whose radius is re-resolved every frame. Shaded, not additive + * — a burn is *darker* than the stone it is on. + */ + this.scour = new GroundField(this.group, { + mode: GroundMode.SCOUR, + additive: false, + name: 'Pyroclasm:scour' + }); + this.scour.setVisible(false); + + /** Re-rolled per cast so no two domes draw the same cloud. */ + this._seed = 0; + /** One-shots already fired. Events, not dimensions. */ + this._collapsed = false; + this._blasted = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // The embers raining inside the dome. Additive and soft: these are lit + // motes falling through soot, and the medium's own `speck` embers are + // *inside* the volume — these are the ones that fall out of it. + this.embers = particles.get('pyroclasm.embers', { + capacity: 2400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.embers.uniforms.uDrag.value = 1.1; + this.embers.uniforms.uEndSize.value = 0.2; + this.embers.uniforms.uSizeIn.value = 0.05; + this.embers.uniforms.uFadeIn.value = 0.06; + this.embers.uniforms.uFadeOut.value = 0.42; + + // The pall that gets out of the dome. Non-additive so it genuinely + // occludes — an ash cloud that adds light to the scene is a fog machine. + this.pall = particles.get('pyroclasm.pall', { + capacity: 2000, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.pall.uniforms.uDrag.value = 1.9; + this.pall.uniforms.uEndSize.value = 3.4; + this.pall.uniforms.uSizeIn.value = 0.12; + this.pall.uniforms.uFadeIn.value = 0.18; + this.pall.uniforms.uFadeOut.value = 0.32; + + // Chips off the floor: under the front while it runs, and thrown wide by + // the blast. + this.grit = particles.get('pyroclasm.grit', { + capacity: 1600, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.28; + this.grit.uniforms.uEndSize.value = 0.75; + this.grit.uniforms.uFadeOut.value = 0.72; + + this.emberEmitter = new RateEmitter(); + this.pallEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Nothing here is instanced; the HUD readout counts the three shaders. */ + get instanceCount() { + return 3; + } + + /** The contraction, then the compressed hold. */ + get impactDuration() { + const c = settings.pyroclasm; + return Math.max(0.1, (c.collapseTime + c.holdTime) * settings.global.lifetime); + } + + /** The blast, then the settle. */ + get fadeDuration() { + const c = settings.pyroclasm; + return Math.max(0.1, c.blastTime + c.settleTime); + } + + /** Ash smothers the light rather than letting it strobe: a slow, deep breath. */ + lightShimmer() { + return 0.82 + 0.18 * Math.sin(this.age * 4.1) * Math.sin(this.age * 1.7); + } + + /* ------------------------------------------------------------------ */ + /* The beats — pure functions of the phase clocks and live settings */ + /* ------------------------------------------------------------------ */ + + /** + * Every one of these re-reads its own duration from settings, so re-timing a + * beat re-times a dome that is already standing. They are pure: nothing here + * writes state, which is what lets `onFade` call them four times a frame + * without worrying about the order. + */ + + /** 0..1 through the contraction. */ + _collapseBeat() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase !== AbilityPhase.IMPACT) return 1; + return saturate(this.impactTime / Math.max(0.02, settings.pyroclasm.collapseTime)); + } + + /** 0..1 through the blast front's run outward. */ + _blastBeat() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / Math.max(0.02, settings.pyroclasm.blastTime)); + } + + /** 0..1 through the settle that follows it. */ + _settleBeat() { + if (this.phase !== AbilityPhase.FADE) return 0; + const c = settings.pyroclasm; + return saturate((this.fadeTime - c.blastTime) / Math.max(0.05, c.settleTime)); + } + + /** + * How far the density has inverted, 0..1. + * + * Cubed off the front of the blast so the field flips *before* the silhouette + * has finished travelling — the ash has to already be coming apart when it + * crosses the old boundary, or the surge reads as a balloon. + */ + _invert() { + return Easing.outCubic(this._blastBeat()); + } + + /** Master fade. The settle thins the medium; `setFade` does the rest. */ + _fadeAmount() { + if (this.phase !== AbilityPhase.FADE) return 1; + return 1 - Easing.inQuad(this._settleBeat()); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The centre of the footprint — the far end of the aimed line. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** + * Where the front leaves the caster's hand, in world space. + * + * The base class puts `origin` on the floor because that is what the circle + * indicator is measured from; ash comes off a hand, so the offsets live here + * rather than being baked into the cast. + */ + _handPoint(out) { + const c = settings.pyroclasm; + out.copy(this.origin).addScaledVector(this.direction, c.handForward); + out.y = c.handHeight; + return out; + } + + /** The ash's ground radius right now, metres. */ + _ashRadius() { + const c = settings.pyroclasm; + const R = Math.max(0.05, c.zoneRadius); + if (this.phase === AbilityPhase.FADE) { + return R * lerp(c.collapseRadius, c.blastSpread, Easing.outCubic(this._blastBeat())); + } + // inOutCubic, not outCubic: the dome has to *hesitate* at full radius for a + // fraction of a second before it starts to go, or the collapse has no + // moment of stillness to collapse away from. + return R * lerp(c.formSpread, c.collapseRadius, Easing.inOutCubic(this._collapseBeat())); + } + + /** + * Its height, metres. + * + * It stands *up* as it narrows — the same mass squeezed into a smaller + * footprint has to go somewhere — and runs low and wide once it lets go. + */ + _ashHeight() { + const c = settings.pyroclasm; + const R = Math.max(0.05, c.zoneRadius); + if (this.phase === AbilityPhase.FADE) { + return R * lerp(c.compressHeight, c.blastHeight, Easing.outCubic(this._blastBeat())); + } + return R * lerp(c.formHeight, c.compressHeight, Easing.inOutCubic(this._collapseBeat())); + } + + /** + * How far the char reaches, metres. + * + * Before the blast it is a fixed seat under the compressed dome; during the + * blast it is the surge's own skirt. That is the whole "drawn by the front" + * claim, and it is one `Math.max`. + */ + _charFront() { + const c = settings.pyroclasm; + const seat = Math.max(0.05, c.zoneRadius) * c.charSeat; + return this.phase === AbilityPhase.FADE ? Math.max(seat, this._ashRadius()) : seat; + } + + /** Yaw of the cast, radians — the frame the scour's grain is authored in. */ + _yaw() { + return Math.atan2(this.direction.x, this.direction.z); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.emberEmitter.reset(); + this.pallEmitter.reset(); + this.gritEmitter.reset(); + + this._collapsed = false; + this._blasted = false; + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + + this.scour.setVisible(false); + this._sync(1); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current beat into the volume, the heat + * column, the scour and the three particle systems. + * + * @param {number} fade 1 while the ash holds, ramping to 0 as it settles + */ + _sync(fade) { + this._centrePoint(_centre); + this._syncVolume(fade); + this._syncHaze(fade); + this._syncScour(fade); + this._syncParticles(); + } + + /** The dome, and the five uniforms the inversion drives on top of `sync()`. */ + _syncVolume(fade) { + const c = settings.pyroclasm; + const g = settings.global; + const travelling = this.phase === AbilityPhase.TRAVEL; + + const radius = this._ashRadius(); + const height = this._ashHeight(); + const invert = this._invert(); + const margin = lerp(c.ashMargin, c.blastMargin, invert); + + /** + * Margin compensation, and it is not optional. + * + * `Margin` holds the medium's nominal surface that fraction of the way + * *inside* the proxy, so a hull sized at the radius you want draws ash at + * `radius × (1 − margin)`. That is fine until the beat drives the margin — + * and this one does, from 0.24 to 0.46, to pay for the erosion. Without the + * division the blast came out at 0.54 of its authored spread and never + * crossed the boundary it is supposed to blow through, so `blastSpread` was + * quietly a lie and turning it up to compensate broke the collapse instead. + * Dividing here makes `_ashRadius()` mean the radius of the *ash*. + * + * `hullSlack` is the small extra on top, for the erosion to spill into. + * Note `setSize`, never `mesh.scale`: the march's parameter is in world + * metres only because the hull's matrix stays rigid. + */ + const k = Math.max(1, c.hullSlack) / Math.max(0.2, 1 - margin); + + this.hull + .place(_centre) + .setSize(radius * k, height * k, radius * k) + .setFade(travelling ? 0 : fade) + .sync(c, g); + + // ---- the inversion, applied over the top of the resolved uniforms ---- + // Multiplicative where `sync()` already folded in a global multiplier or + // the fade (NoiseStrength, Density), absolute where it did not. Doing it + // this way rather than writing into `settings` keeps the block the single + // source of truth and keeps every one of these live under a paused clock. + const u = this.hull.material.uniforms; + u.uHeightBias.value = lerp(c.ashHeightBias, c.blastHeightBias, invert); + u.uDensityCurve.value = lerp(c.ashDensityCurve, c.blastDensityCurve, invert); + u.uMargin.value = margin; + u.uNoiseStrength.value *= lerp(1, c.blastErosion, invert); + u.uDensity.value *= lerp(1, c.blastDensity, invert); + u.uRise.value = lerp(c.ashRise, c.blastRise, invert) * g.noiseSpeed; + } + + /** The heat standing over it — on the front while it travels, on the dome after. */ + _syncHaze(fade) { + const c = settings.pyroclasm; + const g = settings.global; + const travelling = this.phase === AbilityPhase.TRAVEL; + + const radius = travelling ? Math.max(0.05, c.zoneRadius) * c.collapseRadius : this._ashRadius(); + const anchor = travelling ? this.position : _centre; + this.haze.setAnchorXYZ(anchor.x, c.hazeLift, anchor.z); + + _haze.width = radius * c.hazeWidth * (travelling ? c.hazeTravel : 1); + _haze.height = radius * c.hazeHeight; + // Screen fractions. `post.distortion` and `global.distortion` are applied + // once, by the pass — multiplying them in here would apply them twice. + _haze.strength = c.hazeStrength; + _haze.opacity = fade; + _haze.seed = this._seed; + _haze.frequency = c.hazeFrequency * g.noiseFrequency; + _haze.speed = c.hazeSpeed * g.noiseSpeed; + _haze.sourceBias = c.hazeSourceBias; + _haze.spread = c.hazeSpread; + _haze.vertical = c.hazeVertical; + _haze.flicker = c.hazeFlicker; + _haze.perspective = c.hazePerspective; + _haze.perspectiveRef = c.hazePerspectiveRef; + _haze.depthReject = c.hazeDepthReject; + _haze.depthFade = c.hazeDepthFade; + this.haze.update(_haze); + + // Toggle the *field*, never the parent group: `visible` is what retains and + // releases the pass's writer counter, and hiding the group behind its back + // leaks the counter for the session. + this.haze.visible = fade > 0.01; + } + + /** The floor, drawn by the front passing over it. */ + _syncScour(fade) { + const c = settings.pyroclasm; + const g = settings.global; + const radius = Math.max(0.1, Math.max(0.05, c.zoneRadius) * c.scorchSpread); + + _scour.centre = _centre; + _scour.yaw = this._yaw(); + _scour.height = c.scorchHeight; + _scour.radius = radius; + // The growth front, in the shader's own terms: how far out of its own + // radius the burn has been drawn. Both numbers are metres resolved this + // frame, so pulling `blastSpread` in pulls the burn in with it. + _scour.grow = saturate(this._charFront() / radius); + _scour.recede = 0; + _scour.fade = fade; + _scour.seed = this._seed; + + _scour.edge = c.scorchEdge; + _scour.ragged = c.scorchRagged; + _scour.raggedScale = c.scorchRaggedScale; + _scour.warp = c.scorchWarp; + + _scour.relief = c.scorchRelief; + _scour.normalStep = c.scorchNormalStep; + _scour.ambient = c.scorchAmbient; + _scour.wrap = c.scorchWrap; + _scour.specular = c.scorchSpecular; + _scour.gloss = c.scorchGloss; + _scour.parallax = c.scorchParallax; + + _scour.depth = c.scorchDepth; + _scour.lift = c.scorchLift; + _scour.arms = c.scorchArms; + _scour.swirl = c.scorchSwirl; + _scour.sharp = c.scorchSharp; + _scour.detail = c.scorchDetail; + _scour.speed = c.scorchTurn; + + _scour.additive = false; + _scour.emissive = c.scorchEmissive; + _scour.opacity = c.scorchOpacity; + _scour.depthFade = c.scorchDepthFade; + _scour.colorBase = c.colorScorchBase; + _scour.colorEdge = c.colorScorchEdge; + _scour.colorGlow = c.colorScorchGlow; + _scour.colorDeep = c.colorScorchDeep; + + _scour.noiseStrength = g.noiseStrength; + _scour.noiseFrequency = g.noiseFrequency; + _scour.noiseSpeed = g.noiseSpeed; + _scour.opacityScale = g.opacity; + + this.scour.update(_scour); + this.scour.setVisible(this.phase !== AbilityPhase.TRAVEL && fade > 0.004); + } + + /** The three particle systems. */ + _syncParticles() { + const c = settings.pyroclasm; + const g = settings.global; + + this.embers.setGradient( + getColor(c.colorEmberA), + getColor(c.colorEmberB), + getColor(c.colorEmberC), + getColor(c.colorEmberD) + ); + this.embers.uniforms.uGravity.value.set(0, c.emberFall, 0); + this.embers.uniforms.uSizeScale.value = c.emberSize * g.particleSize * 7; + this.embers.uniforms.uLifeScale.value = c.emberLifetime * 0.5 * g.particleLifetime; + this.embers.uniforms.uSpeedScale.value = g.particleSpeed; + this.embers.uniforms.uOpacity.value = g.opacity; + this.embers.uniforms.uGlow.value = c.emberGlow * g.glow; + this.embers.uniforms.uTurbulence.value = c.emberTurbulence * g.turbulence; + + this.pall.setGradient( + getColor(c.colorPallA), + getColor(c.colorPallB), + getColor(c.colorPallC), + getColor(c.colorPallD) + ); + this.pall.uniforms.uGravity.value.set(0, c.pallRise, 0); + this.pall.uniforms.uSizeScale.value = c.pallSize * g.particleSize; + this.pall.uniforms.uLifeScale.value = c.pallLifetime * 0.5 * g.particleLifetime; + this.pall.uniforms.uSpeedScale.value = c.pallSpeed * g.particleSpeed; + this.pall.uniforms.uOpacity.value = c.pallOpacity * g.opacity; + this.pall.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** The cough of hot ash at the caster's hand as the front leaves it. */ + _muzzleFx() { + const c = settings.pyroclasm; + const g = settings.global; + + this._handPoint(_pos); + + _emit.position = _pos; + _emit.radius = 0.24; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.7).setY(0.5).normalize(); + _emit.speed = c.pallSpeed * 2.4; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.6; + _emit.life = c.pallLifetime * 0.6; + _emit.lifeVariance = 0.4; + _emit.spin = 0.5; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.pall.emit(Math.round(14 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.35 * g.explosionIntensity; + } + + /** Grit and haze thrown up under the ignition front while it runs out. */ + _frontFx(dt) { + const c = settings.pyroclasm; + const g = settings.global; + const time = frame.uTime.value; + + const gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate) * g.particleCount); + if (gritCount > 0) { + _emit.position = _pos.copy(this.position).setY(0.06); + _emit.radius = Math.max(0.05, c.zoneRadius) * 0.12; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.45).setY(1).normalize(); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.grit.emit(gritCount, _emit); + } + + const pallCount = Math.round(this.pallEmitter.tick(dt, c.pallRate * 0.35) * g.particleCount); + if (pallCount > 0) { + _emit.position = _pos.copy(this.position).setY(0.2); + _emit.radius = Math.max(0.05, c.zoneRadius) * 0.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.pallSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.size = 0.6; + _emit.sizeVariance = 0.5; + _emit.life = c.pallLifetime * 0.7; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.time = time; + this.pall.emit(pallCount, _emit); + } + } + + /** + * What the standing dome sheds: embers raining down its inside, pall leaking + * off its skirt, grit skittering at its foot. + * + * @param {number} scale 0..1 — thinned out as the surge dies + */ + _domeFx(dt, scale) { + const c = settings.pyroclasm; + const g = settings.global; + const time = frame.uTime.value; + const radius = this._ashRadius(); + const height = this._ashHeight(); + + /* --- embers, born up inside the dome and falling --- */ + const emberCount = Math.round(this.emberEmitter.tick(dt, c.emberRate * scale) * g.particleCount); + if (emberCount > 0) { + const a = Math.random() * TAU; + // sqrt keeps the disc evenly dense; a bare random piles them on the axis. + const r = radius * c.emberInset * Math.sqrt(Math.random()); + _pos.set(_centre.x + Math.cos(a) * r, height * c.emberCeiling * Math.random(), _centre.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = radius * 0.1; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.emberSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.embers.emit(emberCount, _emit); + } + + /* --- pall leaking off the skirt, outward and barely up --- */ + const pallCount = Math.round(this.pallEmitter.tick(dt, c.pallRate * scale) * g.particleCount); + if (pallCount > 0) { + const a = Math.random() * TAU; + const r = radius * randRange(0.78, 1.04); + _pos.set(_centre.x + Math.cos(a) * r, randRange(0.05, 0.45) * height, _centre.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = radius * 0.16; + _emit.direction = _dir.set(Math.cos(a), 0.32, Math.sin(a)).normalize(); + _emit.speed = c.pallSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.75; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.95; + _emit.sizeVariance = 0.5; + _emit.life = c.pallLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = time; + this.pall.emit(pallCount, _emit); + } + + /* --- grit skittering at its foot --- */ + const gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount); + if (gritCount > 0) { + const a = Math.random() * TAU; + const r = radius * randRange(0.6, 1.02); + _pos.set(_centre.x + Math.cos(a) * r, 0.05, _centre.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = radius * 0.1; + _emit.direction = _dir.set(Math.cos(a) * 0.7, 1, Math.sin(a) * 0.7).normalize(); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.grit.emit(gritCount, _emit); + } + } + + /** The moment the contraction bottoms out: a compressed thump, no spread. */ + _collapseFx() { + const c = settings.pyroclasm; + const g = settings.global; + + _pos.copy(_centre).setY(this._ashHeight() * 0.4); + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.15, + endRadius: c.burstSize * 0.55 * g.explosionIntensity, + life: 0.5, + intensity: c.burstIntensity, + opacity: 0.6, + fresnel: 1.7, + displace: 0.7, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.collapseFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.7 * g.explosionIntensity; + } + + /** The moment it lets go. */ + _blastFx() { + const c = settings.pyroclasm; + const g = settings.global; + const time = frame.uTime.value; + const radius = Math.max(0.05, c.zoneRadius); + + _pos.copy(_centre).setY(radius * c.blastHeight * 0.5); + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.blastBurstSize * 0.2, + endRadius: c.blastBurstSize * g.explosionIntensity, + life: 0.9, + intensity: c.blastBurstIntensity, + opacity: 0.85, + fresnel: 1.4, + displace: 0.75, + squash: 0.55, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _centre, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.75, + width: 0.05, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* embers, pall and grit all thrown outward at once */ + _pos.copy(_centre).setY(radius * c.blastHeight * 0.45); + _emit.position = _pos; + _emit.radius = radius * c.collapseRadius; + _emit.direction = _dir.set(0, 0.55, 0); + _emit.speed = c.emberSpeed * 5.5; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.8; + _emit.life = c.emberLifetime * 1.3; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.embers.emit(Math.round(c.emberBurst * g.particleCount), _emit); + + _emit.speed = c.pallSpeed * 4.5; + _emit.spread = 1.0; + _emit.size = 1.4; + _emit.life = c.pallLifetime * 1.25; + _emit.spin = 0.6; + this.pall.emit(Math.round(c.pallBurst * g.particleCount), _emit); + + _pos.copy(_centre).setY(0.1); + _emit.position = _pos; + _emit.radius = radius * c.collapseRadius * 1.2; + _emit.direction = _dir.set(0, 0.75, 0); + _emit.speed = c.gritSpeed * 2.4; + _emit.spread = 1.0; + _emit.size = 0.13; + _emit.life = c.gritLifetime * 1.3; + _emit.spin = 11; + this.grit.emit(Math.round(c.gritBurst * g.particleCount), _emit); + + this.ctx.shake.add( + c.blastShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 24 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.blastFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.8 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + + // The light rides the front, just off the floor. + this.position.y = 0.4; + + this._frontFx(dt); + this.ctx.shake.rumble(settings.pyroclasm.rumble * 0.5 * settings.global.cameraShake, dt); + } + + /** The front arrives: the dome snaps into being at the full footprint. */ + onImpact() { + const c = settings.pyroclasm; + const g = settings.global; + + this._centrePoint(_centre); + + _pos.copy(_centre).setY(Math.max(0.05, c.zoneRadius) * c.formHeight * 0.35); + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.8, + intensity: c.burstIntensity, + opacity: 0.7, + fresnel: 1.5, + displace: 0.65, + squash: 0.6, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.lightBoost = c.lightIntensity * 0.9 * g.explosionIntensity; + } + + onFade(dt, _t) { + const c = settings.pyroclasm; + this._centrePoint(_centre); + + // Two one-shots, both gated on a boolean so a paused frame cannot re-fire + // them. The blast is the phase change itself; the collapse is an event + // *inside* the impact phase, which is why it is polled rather than hooked. + if (!this._collapsed && this._collapseBeat() >= 1) { + this._collapsed = true; + this._collapseFx(); + } + if (!this._blasted && this.phase === AbilityPhase.FADE) { + this._blasted = true; + this._blastFx(); + } + + const fade = this._fadeAmount(); + this._sync(fade); + + // The light sits inside the dome and rides it out. + this.position.copy(_centre); + this.position.y = this._ashHeight() * saturate(c.lightHeight); + + this._domeFx(dt, fade * (this.phase === AbilityPhase.FADE ? 0.55 : 1)); + this.ctx.shake.rumble(c.rumble * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + this._collapsed = false; + this._blasted = false; + // The emitter's own flag, not the group's: hiding the parent leaks the + // distortion pass's writer counter for the rest of the session. + this.haze.visible = false; + this.hull.setFade(0); + this.scour.setVisible(false); + } + + dispose() { + this.hull.dispose(); + this.haze.dispose(); + this.scour.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/flame/SunspearAbility.js b/src/abilities/flame/SunspearAbility.js new file mode 100644 index 0000000..3881e34 --- /dev/null +++ b/src/abilities/flame/SunspearAbility.js @@ -0,0 +1,808 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Projectile, FlightMode, Stagger } from '../../vfx/Projectile.js'; +import { DistortionField, DistortionMode, DistortionFacing } from '../../vfx/Distortion.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { FilamentPaths } from '../../vfx/FilamentPaths.js'; +import { createSunspearMaterial } from '../../materials/SunspearMaterial.js'; +import { createCrystalGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +/** + * Samples along one corona filament. A lick is a third of a turn of a 5 m rim — + * about 10 m of path — and 48 nodes puts a sample every 20 cm, which is finer + * than the kink can resolve anyway. + */ +const CORONA_SAMPLES = 48; +/** Hard ceiling on corona filaments across both roles. */ +const CORONA_CAPACITY = 48; + +/** Which role slot is which. Structural roles first: the strip fills in order. */ +const Role = Object.freeze({ + FLARE: 0, // the crowd of short arcs travelling round the rim + LICK: 1 // the handful of long ones going the other way +}); + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/** Scratch parameter blocks — refilled every frame, never replaced (I3). */ +const _flight = {}; +const _warp = {}; +const _look = {}; +const _state = { + origin: new Vector3(), + axis: new Vector3(0, 1, 0), + side: new Vector3(1, 0, 0), + span: 1, + t: 0, + fade: 1, + seed: 0 +}; + +/** + * SUNSPEAR — the slot that proves the distortion pass works. + * + * A white-fire javelin is thrown on a ballistic arc, and the air behind it is + * genuinely bent: a `DistortionField` in `HEAT` mode rides the spear and writes + * screen-space UV offsets into the refraction buffer, so the floor grid, the + * character and every particle standing behind the flight path warp as it goes + * past. There is no post effect keyed to this ability and nothing is faked in + * colour — turn `wakeStrength` to zero and the spear becomes a bright stick. + * + * On landing the spear is gone and a **sun disc** opens flat on the floor with + * corona filaments licking off its rim: a hard screen flash, then a long slow + * bloom-out over `discFade`. + * + * ## The trick, and the three things that make it land + * + * **The wake is one emitter with two jobs.** In flight it is anchored to the + * spear; on landing it walks — over `discWakeSettle` — out to the disc's width + * and stays there, so the floor keeps boiling around the sun. A second emitter + * for the disc would be a second draw call, a second coverage term arguing with + * the first over who owns the pixels where they overlap, and one more thing to + * keep in step. `HEAT` is sampled in **world** space by the module, which is + * what stops the shimmer sliding across the frame when the camera orbits — the + * single tell that gives a screen-space heat haze away instantly. + * + * **The javelin's τ is the ability's own front.** `Projectile` is handed + * `spearFlight` as its flight time and the ability's `u` as its clock, so + * τ ≡ u and the spear lands on the exact frame the impact beat fires, however + * the base class's ease-off-the-standstill curve behaves. The first version + * gave the projectile a flight time in seconds and let it run on `age`: it + * arrived a frame or two off the beat, every time, and the flash and the + * landing came apart just enough to look wrong without being obviously wrong. + * The cost is that `wakeSpan` is a fraction of the flight rather than a + * duration, which is the better slider anyway, and that the tail's burn-off + * needs its own real-seconds clock after the landing — `wakeBurn`. + * + * **The corona never learns the disc's radius.** `Shell` interpolates + * `discRadius → discRadiusEnd` itself on a live easing exponent from a + * normalised life, and the filament roles are placed against `Shell#radius` + * *this frame*. Drag `discRadiusEnd` while paused and the rim moves out and the + * corona goes with it, because the corona never had a copy of the number. + * + * ## What a cast captures + * + * One seed and one timestamp — the moment of landing. Not a metre, not a + * radian, not a colour. + * + * Six draw calls: the spear, its trail, the wake, the disc, and the corona's + * two passes. + */ +export class SunspearAbility extends Ability { + constructor(context) { + super('sunspear', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.material = createSunspearMaterial(); + + /** The javelin. One body, one trail. */ + this.spear = new Projectile(this.group, { + geometry: () => this._buildSpear(), + shapeKey: () => this._shapeKey(), + material: this.material, + capacity: 1, + trail: true, + trailNodes: 32, + trailAdditive: true, + layer: LAYER.VFX, + renderOrder: 12, + castShadow: false + }); + + /** + * The wake. Invisible to the main render — it lives on `LAYER.DISTORTION`, + * which the camera does not draw — and what you see is the rest of the + * frame moving. `UPRIGHT` rather than a full billboard: heat rises + * vertically whatever the camera is doing, and a billboarded plume leans + * over when you orbit, which reads as wind. + */ + this.wake = new DistortionField({ + mode: DistortionMode.HEAT, + facing: DistortionFacing.UPRIGHT, + name: 'sunspear.wake' + }); + this.group.add(this.wake.object3D); + + /** The sun on the floor. One instanced annulus with no hole in it. */ + this.disc = new Shell({ + mode: ShellMode.SUNDISC, + prefix: 'disc', + segments: 128, + renderOrder: 14 + }); + this.disc.visible = false; + this.group.add(this.disc.group); + + /** The corona. Two roles, one strip, two draw calls. */ + this.corona = new FilamentPaths(this.group, { + samples: CORONA_SAMPLES, + capacity: CORONA_CAPACITY, + renderOrder: 13, + layer: LAYER.VFX + }); + this.corona.visible = false; + + /** Re-rolled per cast so two throws do not draw the identical corona. */ + this._seed = 0; + /** Timestamp: the moment the spear landed. An event, not a duration. */ + this._impactAt = 0; + /** Signature of the silhouette controls, so a rebuild follows a change. */ + this._shapeHash = ''; + } + + createParticles() { + const particles = this.ctx.particles; + + // White fire shed off the shaft, and thrown out of the landing. + this.embers = particles.get('sunspear.embers', { + capacity: 4000, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.embers.uniforms.uDrag.value = 1.5; + this.embers.uniforms.uEndSize.value = 0.2; + this.embers.uniforms.uSizeIn.value = 0.02; + this.embers.uniforms.uFadeIn.value = 0.03; + this.embers.uniforms.uFadeOut.value = 0.4; + + // Haze off the scorched disc. Non-additive so it genuinely occludes. + this.smoke = particles.get('sunspear.smoke', { + capacity: 1600, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.smoke.uniforms.uDrag.value = 1.7; + this.smoke.uniforms.uEndSize.value = 3.2; + this.smoke.uniforms.uSizeIn.value = 0.12; + this.smoke.uniforms.uFadeIn.value = 0.18; + this.smoke.uniforms.uFadeOut.value = 0.32; + + // What drifts up out of the disc while it blooms out. + this.motes = particles.get('sunspear.motes', { + capacity: 1800, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.5; + this.motes.uniforms.uEndSize.value = 0.18; + this.motes.uniforms.uSizeIn.value = 0.06; + this.motes.uniforms.uFadeIn.value = 0.09; + this.motes.uniforms.uFadeOut.value = 0.42; + + this.emberEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + this.moteEmitter = new RateEmitter(); + } + + /** + * The javelin, in unit space. + * + * `createCrystalGeometry` already builds a tapered faceted prism with its + * base ring at y = 0 and its point at y = 1 — which is a javelin seen from + * the side. Sliding it down half a unit and doubling its height puts the + * butt at y = −1 and the point at +1, centred on the origin, which is where + * `Projectile` scales and aligns from. Writing a second spindle builder for + * this would have been thirty lines to arrive at the same solid. + */ + _buildSpear() { + const c = settings.sunspear; + const geometry = createCrystalGeometry({ + seed: 3.1, + sides: clamp(Math.round(c.spearSides), 3, 9), + taper: c.spearTaper, + roughness: c.spearRough, + bend: c.spearBend + }); + geometry.translate(0, -0.5, 0); + geometry.scale(1, 2, 1); + return geometry; + } + + /** Everything `_buildSpear` reads, as one string. A change rebuilds. */ + _shapeKey() { + const c = settings.sunspear; + return `${Math.round(c.spearSides)}|${c.spearTaper}|${c.spearRough}|${c.spearBend}`; + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.spear.count + this.corona.liveCount; + } + + /** The disc holds at full brightness. */ + get impactDuration() { + return Math.max(0.1, settings.sunspear.discHold * settings.global.lifetime); + } + + /** ...and then the long slow bloom-out, which is what the slot is for. */ + get fadeDuration() { + return Math.max(0.1, settings.sunspear.discFade); + } + + /** + * The spear's clock. + * + * Identical to the ability's own front while it is in the air, so τ ≡ u and + * the landing cannot drift off the beat. Afterwards it advances in units of + * `wakeBurn` seconds, which is what pulls the trail's tail up to its head — + * a trail that shortens reads as something that stopped being made, where a + * trail that dims reads as a light going out. + */ + _spearClock() { + const c = settings.sunspear; + if (this.phase === AbilityPhase.TRAVEL) return this.u * Math.max(0.05, c.spearFlight); + const since = this.age - this._impactAt; + return Math.max(0.05, c.spearFlight) * (1 + since / Math.max(0.05, c.wakeBurn)); + } + + /** 0..1 through the disc's whole life — what `Shell` expands against. */ + _discLife() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + const total = this.impactDuration + this.fadeDuration; + return saturate((this.age - this._impactAt) / Math.max(0.05, total)); + } + + /** Where the spear lands, and where the sun opens. */ + _impactPoint(out) { + return this.pointAt(1, out).setY(0); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.emberEmitter.reset(); + this.smokeEmitter.reset(); + this.moteEmitter.reset(); + this.spear.reset(); + this.corona.clear(); + + this._seed = Math.random() * 100; + this._impactAt = 0; + this.spear.roll(this._seed); + + this.disc.visible = false; + this.corona.visible = false; + // Retaining the writer counter here rather than at construction: the pass + // skips its clear, its draw and its resample when nothing is writing, and + // an emitter that is never released keeps it running for the session. + this.wake.visible = true; + + this._syncSpear(); + this._syncWake(0); + this._syncParticles(); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** Re-fly the javelin against the live block. */ + _syncSpear() { + const c = settings.sunspear; + const g = settings.global; + const p = _flight; + + p.mode = FlightMode.ARC; + p.stagger = Stagger.NONE; + p.count = 1; + p.radius = c.spearRadius; + p.sizeJitter = 0; + p.stretch = c.spearStretch; + p.align = c.spearAlign; + p.spin = c.spearSpin * g.randomness; + p.flash = c.spearFlash; + + p.handForward = c.handForward; + p.handSide = c.handSide; + p.handHeight = c.handHeight; + // The spear is scaled about its middle, so the landing point is its centre; + // lifting it by roughly its own half-length is what puts the *point* on the + // floor rather than a body length under it. + p.landHeight = c.landHeight; + p.landInZone = false; + p.spreadSide = 0; + p.spreadForward = 0; + + p.pathCurve = c.arcCurve; + p.apex = c.arcApex; + p.apexCurve = c.arcApexCurve; + p.weaveSide = 0; + p.weaveUp = 0; + + // Nominal, not seconds — the clock handed to `update()` is the cast's own + // front. See `_spearClock`. + p.flightTime = Math.max(0.05, c.spearFlight); + p.speedJitter = 0; + p.lead = 0; + p.window = 0; + p.linger = 0; + p.sink = 0; + + p.trailSpan = c.wakeSpan * Math.max(0.05, c.spearFlight); + p.trailBurn = Math.max(0.05, c.spearFlight); + p.trailWidth = c.wakeTrailWidth; + p.trailTaper = c.wakeTrailTaper; + p.trailLift = c.wakeTrailLift; + p.trailOpacity = c.wakeTrailOpacity * g.opacity; + p.trailGlow = c.wakeTrailGlow * g.glow; + p.trailCore = c.wakeTrailCore; + p.trailHeadBias = c.wakeTrailHeadBias; + p.trailNoise = c.wakeTrailNoise * g.noiseStrength; + p.trailNoiseScale = c.wakeTrailNoiseScale * g.noiseFrequency; + p.trailNoiseSpeed = c.wakeTrailNoiseSpeed * g.noiseSpeed; + p.trailSoftFade = c.wakeTrailSoftFade; + + this.spear.setBasis(this.origin, this.direction, this.side, this.length); + this.spear.setTrailColors(c.colorWakeA, c.colorWakeB, c.colorWakeC, c.colorWakeD); + this.spear.update(this._spearClock(), p); + + // The spear itself goes out the instant it lands; what is left is the disc. + this.material.userData.sync(this.phase === AbilityPhase.TRAVEL ? 1 : 0); + } + + /** + * The wake — the whole point of the slot. + * + * @param {number} settle 0 while it rides the spear, 1 once it owns the disc + */ + _syncWake(settle) { + const c = settings.sunspear; + const p = _warp; + const k = saturate(settle); + + if (k < 1) { + // On the spear. `slotPosition` is where the body was actually drawn this + // frame, which is a metre or two ahead of `pointAt(u)` on a lofted arc — + // and anchoring the shimmer to the flat cast line instead was the first + // version, which left the warp crawling along the floor under a javelin + // that was three metres up. + this.spear.slotPosition(0, _pos); + } else { + this._impactPoint(_pos); + } + if (k > 0) { + this._impactPoint(_centre); + _pos.lerp(_centre, k); + } + // A HEAT column's pivot is its *base*, so the anchor goes on the floor + // under the spear and the column stands up through it. + this.wake.setAnchorXYZ(_pos.x, Math.max(0, _pos.y - c.wakeHeight * 0.5), _pos.z); + + // Magnitudes here are screen fractions. `post.distortion` and + // `global.distortion` are applied by the pass, once — folding either of + // them in here means this ability applies them twice and the next one not + // at all. + p.width = lerp(c.wakeWidth, c.discWakeWidth, k); + p.height = lerp(c.wakeHeight, c.discWakeHeight, k); + // The wake cools behind the spear: a javelin that warps the room just as + // hard at the end of a thirty-metre throw as at the start reads as a bug. + const cooled = 1 - c.wakeFall * (this.phase === AbilityPhase.TRAVEL ? this.u : 1); + p.strength = lerp(c.wakeStrength * cooled, c.discWakeStrength, k); + p.opacity = c.wakeOpacity; + p.seed = this._seed; + p.frequency = c.wakeFrequency * settings.global.noiseFrequency; + p.speed = c.wakeSpeed * settings.global.noiseSpeed; + p.sourceBias = c.wakeSourceBias; + p.spread = c.wakeSpread; + p.vertical = c.wakeVertical; + p.flicker = c.wakeFlicker; + p.depthReject = c.wakeDepthReject; + p.depthFade = c.wakeDepthFade; + p.perspective = c.wakePerspective; + p.perspectiveRef = c.wakePerspectiveRef; + + this.wake.update(p); + } + + /** + * The disc and the corona standing on its rim. + * @param {number} fade 1 while it holds, ramping to 0 through the bloom-out + */ + _syncDisc(fade) { + const c = settings.sunspear; + + this._impactPoint(_centre); + _state.origin.copy(_centre); + _state.axis.set(0, 1, 0); + _state.side.copy(this.side); + _state.span = this.length; + _state.t = this._discLife(); + _state.fade = fade; + _state.seed = this._seed; + this.disc.sync(c, _state, settings.global); + + /* --- the corona, placed against the radius the disc resolved --- */ + const radius = Math.max(0.05, this.disc.radius); + _centre.y = c.discLift; + + const flare = this.corona.role(Role.FLARE); + flare.count = Math.round(c.flareCount) * (fade > 0.001 ? 1 : 0); + flare + .rim(_centre, _up, radius * c.flareRadius, c.flareSpan, c.flareSpeed, c.flareLift, c.flareJitter, c.flareHug, 0) + // `groundDamp` at 0.3 and a floor clamp: a kink with a free y buries half + // of every filament that runs flat, and the corona reads as a dotted line. + // The snare learnt this the hard way and it is the same lesson here. + .style(1, 1, 1, 0.3) + .ends(1, 1, 1, 1) + .draw(2, 0.12, 0.0, 0.6); + + const lick = this.corona.role(Role.LICK); + lick.count = Math.round(c.lickCount) * (fade > 0.001 ? 1 : 0); + lick + .rim(_centre, _up, radius * c.lickRadius, c.lickSpan, c.lickSpeed, c.lickLift, c.lickJitter, c.lickHug, c.lickPhase) + .style(1.4, 1.5, 0.85, 0.45) + .ends(1, 1, 1, 1) + .draw(2, 0.2, 0.0, 1.1); + + this._syncCoronaLook(fade); + } + + /** The shared look both corona roles are drawn with. */ + _syncCoronaLook(fade) { + const c = settings.sunspear; + const g = settings.global; + const look = _look; + + look.width = c.flareWidth; + look.glowWidth = c.flareGlowWidth; + look.glowOpacity = c.flareGlowOpacity; + look.jitter = c.flareKink; + look.jitterScale = c.flareKinkScale; + look.octaves = c.flareOctaves; + look.jitterFalloff = c.flareKinkFalloff; + look.crawl = c.flareCrawl; + look.pinch = c.flarePinch; + look.restrike = c.flareRestrike; + look.flicker = c.flareFlicker; + look.flickerSpeed = c.flareFlickerSpeed; + look.strandFlash = c.flareStrandFlash; + look.coreSharp = c.flareCoreSharp; + look.glowFalloff = c.flareFalloff; + look.softFade = c.flareSoftFade; + look.opacity = c.flareOpacity; + look.glow = c.flareGlow; + look.colorCore = c.colorFlareCore; + look.colorInner = c.colorFlareInner; + look.colorOuter = c.colorFlareOuter; + look.colorHalo = c.colorFlareHalo; + + look.randomness = g.randomness; + look.noiseStrength = g.noiseStrength; + look.noiseFrequency = g.noiseFrequency; + look.noiseSpeed = g.noiseSpeed; + look.opacityScale = g.opacity; + look.glowScale = g.glow; + + // `sync` overwrites the counts from the role blocks every frame, so the + // roles above have to be filled first — every frame, not at spawn. + this.corona.sync(look, fade, this._seed); + } + + /** The three particle systems. */ + _syncParticles() { + const c = settings.sunspear; + const g = settings.global; + + this.embers.setGradient( + getColor(c.colorEmberA), + getColor(c.colorEmberB), + getColor(c.colorEmberC), + getColor(c.colorEmberD) + ); + this.embers.uniforms.uGravity.value.set(0, c.emberGravity, 0); + this.embers.uniforms.uSizeScale.value = c.emberSize * g.particleSize * 7; + this.embers.uniforms.uLifeScale.value = c.emberLifetime * 0.5 * g.particleLifetime; + this.embers.uniforms.uSpeedScale.value = g.particleSpeed; + this.embers.uniforms.uOpacity.value = g.opacity; + this.embers.uniforms.uGlow.value = c.spearGlow * 0.4 * g.glow; + this.embers.uniforms.uStretch.value = c.emberStretch; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = c.moteSpeed * g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 1.2 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** The flash at the hand as the javelin leaves it. */ + _muzzleFx() { + const c = settings.sunspear; + const g = settings.global; + + _pos + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + _pos.y = c.handHeight; + + _emit.position = _pos; + _emit.radius = 0.2; + _emit.direction = _dir.copy(this.direction); + _emit.speed = c.emberSpeed * 1.4; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.emberSize * 1.2; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.embers.emit(Math.round(50 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + } + + /** White fire coming off the shaft in flight. */ + _flightFx(dt) { + const c = settings.sunspear; + const g = settings.global; + const count = Math.round(this.emberEmitter.tick(dt, c.emberRate) * g.particleCount); + if (count <= 0) return; + + this.spear.slotPosition(0, _pos); + _emit.position = _pos; + // The shed follows the shaft's half-length, so a longer spear sheds along + // more of the air it is passing through rather than out of one point. + _emit.radius = c.spearRadius * c.spearStretch * 0.6; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.6).setY(0.5).normalize(); + _emit.speed = c.emberSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.emberSize; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.embers.emit(count, _emit); + } + + /** Smoke and motes off the standing disc. */ + _discFx(dt, scale) { + const c = settings.sunspear; + const g = settings.global; + const time = frame.uTime.value; + const radius = Math.max(0.05, this.disc.radius); + + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * scale) * g.particleCount); + if (smokeCount > 0) { + this._impactPoint(_pos); + _pos.y = 0.2; + _emit.position = _pos; + _emit.radius = radius * 0.9; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.0; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + this._impactPoint(_pos); + _pos.y = 0.12; + _emit.position = _pos; + // Off the rim rather than the face: the corona is where the disc is + // actually losing material, and motes rising out of the middle read as a + // campfire. + _emit.radius = radius * 0.95; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.size = c.moteSize; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._syncParticles(); + this._syncSpear(); + this._syncWake(0); + this._flightFx(dt); + + // The light rides the javelin, not the floor under it — `advance()` has + // already put `position` on the ground line, so lift it onto the arc. + this.spear.slotPosition(0, this.position); + + this.ctx.shake.rumble(settings.sunspear.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.sunspear; + const g = settings.global; + const time = frame.uTime.value; + + this._impactAt = this.age; + this.disc.visible = true; + this.corona.visible = true; + + this._impactPoint(_pos); + _pos.y = 0.45; + + /* the shell of white fire */ + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.8, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.6, + displace: 0.55, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* embers thrown out of it */ + _emit.position = _pos; + _emit.radius = 0.4; + _emit.direction = _dir.set(randRange(-0.3, 0.3), 1, randRange(-0.3, 0.3)).normalize(); + _emit.speed = c.emberSpeed * 2.4; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.emberSize * 1.4; + _emit.sizeVariance = 0.8; + _emit.life = c.emberLifetime * 1.6; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.embers.emit(Math.round(c.emberBurst * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 28 + ); + // The hard one. This is the beat the whole cast is built around, and it is + // deliberately the brightest flash in the frost-and-flame half of the set. + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.6 * g.explosionIntensity; + } + + /** + * `t` runs 0..1 while the disc holds, then 1..2 through the bloom-out. + * + * The bloom-out is `outQuint` rather than linear: a sun does not dim evenly, + * it stays bright and then is suddenly not there. Linear read as a dimmer + * switch, which is the one thing this slot must not do after that flash. + */ + onFade(dt, t) { + const c = settings.sunspear; + const fade = t <= 1 ? 1 : 1 - Easing.outQuint(saturate(t - 1)); + + this._syncParticles(); + this._syncSpear(); + this._syncDisc(fade); + + // The emitter walks off the spear and onto the disc over `discWakeSettle`. + const settle = saturate((this.age - this._impactAt) / Math.max(0.02, c.discWakeSettle)); + this._syncWake(settle); + + this._discFx(dt, fade); + + this._impactPoint(this.position); + this.position.y = 0.5; + } + + onDestroy() { + this.spear.reset(); + this.corona.clear(); + this.corona.visible = false; + this.disc.visible = false; + // Releases the distortion pass's writer counter. Hiding the parent group + // instead leaks it, and the pass then runs for the rest of the session. + this.wake.visible = false; + } + + dispose() { + this.spear.dispose(); + this.corona.dispose(); + this.disc.dispose(); + this.wake.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/flame/WildfireAbility.js b/src/abilities/flame/WildfireAbility.js new file mode 100644 index 0000000..1416b8e --- /dev/null +++ b/src/abilities/flame/WildfireAbility.js @@ -0,0 +1,1023 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { + createWildfirePlacement, + createCellAttributes, + createCellGeometry, + createFlameGeometry, + createCellMaterial, + createFlameMaterial, + setFlameGradient +} from '../../materials/WildfireMaterial.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, hash11, Easing } from '../../utils/math.js'; + +/** The largest lattice the buffers are built for. 30 × 30 = 900 cells. */ +const MAX_GRID = 30; +const MAX_CELLS = MAX_GRID * MAX_GRID; + +/** Cell states. Three is all an automaton like this needs. */ +const UNBURNT = 0; +const BURNING = 1; +const SPENT = 2; + +/** + * The eight neighbours as `(dx, dy)` pairs, orthogonals first. + * + * Module scope and a typed array, because this is walked eight times per + * burning cell per generation and an array literal in there would be the + * allocation I3 forbids, several hundred times a second. + */ +const NEIGHBOURS = new Int8Array([1, 0, -1, 0, 0, 1, 0, -1, 1, 1, 1, -1, -1, 1, -1, -1]); +/** 1 / |offset|, so a diagonal's wind term is not √2 times an orthogonal's. */ +const NEIGHBOUR_NORM = [1, 1, 1, 1, 0.7071, 0.7071, 0.7071, 0.7071]; + +/* Module-scope scratch. A running fire allocates nothing — I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _front = new Vector3(); +/** The scorch field's params bag, filled from settings every frame and reused. */ +const _spot = groundFieldParams(); + +/** One lattice tap of value noise. A hash, so the map is reproducible per seed. */ +function latticeValue(x, y, seed) { + return hash11(x * 157.31 + y * 311.7 + seed * 13.7); +} + +/** + * Two-dimensional value noise on the fuel lattice. + * + * Written out here rather than borrowed from a shader chunk because the fuel + * map is CPU state — the automaton has to *read* it to decide whether a cell + * can catch — and a field that only the GPU can evaluate would leave the + * simulation and the picture disagreeing about where the fuel is. + */ +function fuelNoise(x, y, seed) { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const fx = x - x0; + const fy = y - y0; + const sx = fx * fx * (3 - 2 * fx); + const sy = fy * fy * (3 - 2 * fy); + // Named for their corners rather than a, b, c, d: `c` is the settings alias + // everywhere else in this file, and shadowing it here made the harness's + // static cross-check lose track of which block the file reads. + const n00 = latticeValue(x0, y0, seed); + const n10 = latticeValue(x0 + 1, y0, seed); + const n01 = latticeValue(x0, y0 + 1, seed); + const n11 = latticeValue(x0 + 1, y0 + 1, seed); + return lerp(lerp(n00, n10, sx), lerp(n01, n11, sx), sy); +} + +/** + * WILDFIRE — spread as a cellular automaton. + * + * A far cast. A dart of fire flies to the aimed circle, and where it lands a + * handful of cells catch. From there the fire **spreads**: generation by + * generation across a lattice, each burning cell rolling against its eight + * neighbours, skewed by a wind, refusing ground with no fuel in it, throwing + * the occasional ember over the top of whatever is in the way, and — because a + * burnt cell keeps what the burn did not eat — sometimes catching again behind + * its own front. + * + * ## THE TRICK — it is a simulation, not a curve + * + * Every other expanding effect in this sandbox is a radius with a ragged edge: + * `grow` goes from 0 to 1 and a smoothstep does the rest. That is the right + * answer for a shockwave and it is the wrong answer for fire, because the thing + * that makes a fire front read as a fire front is that it is **not a function + * of the radius**. It arrives at two points at different times for reasons that + * are visible on the floor: the fuel ran out here, the wind pushed there, an + * ember landed forty cells ahead and started a second fire that the first one + * later joined. + * + * So `_generation()` below is the ability. It is about sixty lines, it runs at + * `tickRate` generations a second, and everything the player sees is a *report* + * of the array it maintains: + * + * - the **scar** is one instanced quad per cell that has caught, so the + * unburnt islands are genuinely missing geometry rather than a darker patch + * of a texture; + * - the **flames** are one instanced billboard per cell, collapsed to nothing + * unless that cell is burning right now, so the front is wherever the state + * array says it is; + * - the **pall** is a single raymarched volume placed on the centroid of the + * burning cells and sized to their extent, so it follows the front around + * the islands instead of sitting over the middle of the circle; + * - a **spot fire** drops a scorch mark where its ember landed. + * + * ### What went wrong first + * + * The first version spread from a *queue* — every cell that caught pushed its + * neighbours — and it filled the circle in two seconds flat with a boundary + * that was, embarrassingly, a diamond. Two things were missing. There was no + * generation boundary, so a cell lit early in the loop lit its neighbour later + * in the same loop and the fire advanced at the speed of the iteration order; + * and with a per-neighbour probability of 1 the reachable set of a Moore + * neighbourhood *is* a diamond, no matter how the fuel is arranged. Both are + * fixed here: a cell whose ignition timestamp is this generation is skipped, + * and the catch is a roll against `spread × fuel`, so the boundary is a random + * process rather than a metric ball. + * + * The second version had fuel but no `fuelFloor`, which meant every cell caught + * eventually and the islands were merely *late* rather than unburnt. A late + * island is invisible. It needs to be a hole. + * + * ## What a cast captures + * + * The fuel map (unitless, rolled once — it is the terrain), each cell's state, + * and the timestamp it caught. The lattice *pitch* is + * `2 · zoneRadius / grid` re-resolved every frame, so dragging the radius over + * a paused fire rescales the whole burn with its pattern intact. + * + * The one thing that is deliberately not retroactive is the rule table itself: + * a generation that has already happened is history, and `spread` takes effect + * on the next one. Everything with a unit is live. + * + * ## Cost + * + * Four drawables — the scar, the flames, the pall, the scorch field — plus two + * shared particle systems, and one dynamic light riding the front. The two + * instanced meshes carry `grid²` instances each and cost one draw call each + * whatever that number is. + */ +export class WildfireAbility extends Ability { + constructor(context) { + super('wildfire', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.placement = createWildfirePlacement(); + this.attributes = createCellAttributes(MAX_CELLS, MAX_GRID); + + this.cellGeometry = createCellGeometry(this.attributes); + this.cellMaterial = createCellMaterial(this.placement); + this.cellMesh = new Mesh(this.cellGeometry, this.cellMaterial); + this.cellMesh.frustumCulled = false; + this.cellMesh.matrixAutoUpdate = false; + this.cellMesh.layers.set(LAYER.VFX); + this.cellMesh.renderOrder = 7; + this.group.add(this.cellMesh); + + this.flameGeometry = createFlameGeometry(this.attributes); + this.flameMaterial = createFlameMaterial(this.placement); + this.flameMesh = new Mesh(this.flameGeometry, this.flameMaterial); + this.flameMesh.frustumCulled = false; + this.flameMesh.matrixAutoUpdate = false; + this.flameMesh.layers.set(LAYER.VFX); + this.flameMesh.renderOrder = 12; + this.group.add(this.flameMesh); + + /** The haze over the front. Follows the burning cells — see `_syncPall()`. */ + this.pall = new VolumeHull({ + hull: HullShape.CYLINDER, + medium: Medium.FLAME, + prefix: 'pall', + maxSteps: 40, + renderOrder: 13 + }); + this.group.add(this.pall.mesh); + + /** + * Where the spot fires landed. `POCK` takes a list of unitless hits and + * unions a bowl and a rim for each, which is exactly what an ember dropped + * on stone leaves behind — and because the hits are fractions of the + * radius, they re-place themselves when the zone is resized. + */ + this.scorch = new GroundField(this.group, { + mode: GroundMode.POCK, + marks: 14, + additive: false, + name: 'Wildfire:scorch' + }); + this.scorch.setVisible(false); + + /* ---- the automaton's state. Allocated once, at construction ---- */ + /** 0 unburnt, 1 burning, 2 spent. */ + this._state = new Uint8Array(MAX_CELLS); + /** Fuel remaining in each cell, 0..1. The terrain, and what a burn eats. */ + this._fuel = new Float32Array(MAX_CELLS); + /** Lattice width for this cast — a count, captured at spawn. */ + this._grid = MAX_GRID; + /** Cells in play, `grid²`. */ + this._cells = 0; + /** Seconds of unspent simulation time. */ + this._clock = 0; + /** How many cells are burning right now, and where they are. */ + this._burning = 0; + this._centroidX = 0; + this._centroidY = 0; + this._extent = 0; + /** One dice roll per cast: the fuel map and the volume's grain. */ + this._seed = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Embers off the front. Additive and curled — hot, and going up. + this.motes = particles.get('wildfire.motes', { + capacity: 2400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.35 + }); + this.motes.uniforms.uDrag.value = 1.2; + this.motes.uniforms.uEndSize.value = 0.2; + this.motes.uniforms.uSizeIn.value = 0.05; + this.motes.uniforms.uFadeIn.value = 0.07; + this.motes.uniforms.uFadeOut.value = 0.4; + + // The pall that gets off the burnt ground. Non-additive so it occludes. + this.smoke = particles.get('wildfire.smoke', { + capacity: 2000, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.smoke.uniforms.uDrag.value = 1.9; + this.smoke.uniforms.uEndSize.value = 3.4; + this.smoke.uniforms.uSizeIn.value = 0.12; + this.smoke.uniforms.uFadeIn.value = 0.18; + this.smoke.uniforms.uFadeOut.value = 0.32; + + this.moteEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Cells currently burning — the honest readout of what this is drawing. */ + get instanceCount() { + return this._burning; + } + + /** The fire spreads freely... */ + get impactDuration() { + return Math.max(0.1, settings.wildfire.holdTime * settings.global.lifetime); + } + + /** ...and then runs out of anywhere to go. */ + get fadeDuration() { + return Math.max(0.1, settings.wildfire.smoulderTime); + } + + /** Fire gutters, on its own clock. */ + lightShimmer() { + const c = settings.wildfire; + const step = Math.floor(this.age * Math.max(1, c.lightFlickerSpeed)); + const noise = Math.abs(Math.sin(step * 78.233) * 43758.5453) % 1; + return 1 - saturate(c.lightFlicker) * noise; + } + + /* ------------------------------------------------------------------ */ + /* The lattice — counts here, metres only where they are resolved */ + /* ------------------------------------------------------------------ */ + + /** Metres between cell centres, right now. */ + _pitch() { + return (2 * Math.max(0.2, settings.wildfire.zoneRadius)) / this._grid; + } + + /** The middle of the zone: the far end of the aimed line. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** + * Where cell `index` is, in world space. + * + * A mirror of `cellCentre()` in the vertex shader, down to the jitter, which + * is why the jitter is an attribute of dice rolls rather than a hash the two + * sides would have to agree on to five decimal places. + */ + _cellPoint(index, out) { + const c = settings.wildfire; + const pitch = this._pitch(); + const jitter = this.attributes.aJitter.array; + const cell = this.attributes.aCell.array; + const half = (this._grid - 1) * 0.5; + const x = (cell[index * 2] - half) * pitch + jitter[index * 2] * pitch * c.cellJitter; + const y = (cell[index * 2 + 1] - half) * pitch + jitter[index * 2 + 1] * pitch * c.cellJitter; + this._centrePoint(out); + out.addScaledVector(this.side, x).addScaledVector(this.direction, y); + out.y = c.cellLift; + return out; + } + + /** The wind, in the lattice's own frame. `x` is lateral, `y` is downrange. */ + _windX() { + const c = settings.wildfire; + return Math.sin(c.windAngle) * c.windBias; + } + + _windY() { + const c = settings.wildfire; + return Math.cos(c.windAngle) * c.windBias; + } + + /* ------------------------------------------------------------------ */ + /* The automaton */ + /* ------------------------------------------------------------------ */ + + /** + * Roll the terrain. + * + * Two octaves of value noise on the lattice, clipped to the disc, biased and + * clamped. The result is *the map*: a cast that finds a rich patch downwind + * burns very differently from one that lands on gravel, and that difference + * is visible on the floor for the whole burn. + * + * Rolled once per cast rather than resolved per frame, and that is + * deliberate: this is a dice roll, not a dimension. `fuelScale` and + * `fuelBias` therefore apply to the *next* cast, which is the same contract + * a seed has. + */ + _rollFuel() { + const c = settings.wildfire; + const g = this._grid; + const scale = Math.max(0.2, c.fuelScale) / g; + for (let y = 0; y < g; y++) { + for (let x = 0; x < g; x++) { + const i = y * g + x; + const u = (x / (g - 1)) * 2 - 1; + const v = (y / (g - 1)) * 2 - 1; + if (u * u + v * v > 1) { + // Outside the circle there is nothing to burn, so the fire stops at + // the indicator without anything having to clamp it. + this._fuel[i] = 0; + continue; + } + const coarse = fuelNoise(x * scale, y * scale, this._seed); + const fine = fuelNoise(x * scale * 2.7 + 11.3, y * scale * 2.7 - 7.1, this._seed + 3); + this._fuel[i] = saturate(coarse * 0.68 + fine * 0.32 + c.fuelBias); + } + } + } + + /** Light a cell, now. The one place `aIgnite` is written. */ + _catch(index, now) { + const ignite = this.attributes.aIgnite.array; + const fuel = this.attributes.aFuel.array; + this._state[index] = BURNING; + fuel[index] = this._fuel[index]; + ignite[index] = now; + this.attributes.aIgnite.needsUpdate = true; + this.attributes.aFuel.needsUpdate = true; + } + + /** + * One generation. + * + * Reads the rule table live, so the *next* generation always obeys the + * sliders as they are now. Allocation-free: two integer loops and a typed + * array of neighbour offsets. + * + * @param {number} now the ability's age, the timestamp an ignition records + * @param {number} gate 0..1 — the fade closes the spread down without + * stopping the burn, so the fire runs out rather than + * being switched off + */ + _generation(now, gate) { + const c = settings.wildfire; + const g = this._grid; + const state = this._state; + const ignite = this.attributes.aIgnite.array; + const held = this.attributes.aFuel.array; + + // The same product the cell shader burns on — `settings.global.lifetime` + // included — or the simulation and the picture disagree about when a cell + // goes out, and the flames outlive their own embers. + const burnTime = Math.max(0.05, c.burnTime * settings.global.lifetime); + const spread = saturate(c.spread) * saturate(gate); + const windX = this._windX(); + const windY = this._windY(); + const floor = c.fuelFloor; + + for (let y = 0; y < g; y++) { + for (let x = 0; x < g; x++) { + const i = y * g + x; + if (state[i] !== BURNING) continue; + // The generation boundary. A cell lit during *this* generation carries + // this timestamp, and skipping it is what stops the fire advancing at + // the speed of the loop rather than at the speed of the rules. + if (ignite[i] === now) continue; + + /* ---- does it light its neighbours? ---- */ + for (let n = 0; n < 8; n++) { + const dx = NEIGHBOURS[n * 2]; + const dy = NEIGHBOURS[n * 2 + 1]; + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= g || ny >= g) continue; + + const j = ny * g + nx; + if (state[j] === BURNING) continue; + + const fuel = this._fuel[j]; + if (fuel < floor) continue; + + let p = spread * fuel; + if (n >= 4) p *= c.diagonalBias; + // The wind is a dot product, normalised so a diagonal step is not + // rewarded for being longer. Downwind gets a bonus and upwind pays + // for it, which is what turns the burn oval. + p *= 1 + (dx * windX + dy * windY) * NEIGHBOUR_NORM[n]; + if (state[j] === SPENT) { + if (fuel < c.reburnFloor) continue; + p *= c.reburnChance; + } + if (Math.random() < p) this._catch(j, now); + } + + /* ---- does it throw an ember over the top? ---- */ + if (Math.random() < c.spotChance * saturate(gate)) this._spotFire(x, y, now); + + /* ---- has it burnt out? ---- */ + const burn = Math.max(held[i], 0.05) * burnTime; + if (now - ignite[i] >= burn) { + state[i] = SPENT; + // What the burn did not eat is still there, and is what lets the + // front come back over its own ground later. + this._fuel[i] *= 1 - saturate(c.consume); + } + } + } + } + + /** + * An ember carried downwind, landing two to `spotRange` cells away. + * + * This is the rule that stops the burn being connected. A spot fire lands on + * the far side of an island, starts a second front, and the two fronts meet + * somewhere neither of them started — which is the shape nothing built out of + * a growing radius can make. + */ + _spotFire(x, y, now) { + const c = settings.wildfire; + const g = this._grid; + const windX = this._windX(); + const windY = this._windY(); + + // With no wind an ember goes anywhere; with wind it goes downwind, with a + // splay wide enough that a run of them does not draw a line. + const bearing = + Math.abs(windX) + Math.abs(windY) < 1e-3 + ? Math.random() * Math.PI * 2 + : Math.atan2(windY, windX) + (Math.random() - 0.5) * 1.7; + const reach = 2 + Math.floor(Math.random() * Math.max(1, Math.round(c.spotRange))); + const nx = Math.round(x + Math.cos(bearing) * reach); + const ny = Math.round(y + Math.sin(bearing) * reach); + if (nx < 0 || ny < 0 || nx >= g || ny >= g) return; + + const j = ny * g + nx; + if (this._state[j] === BURNING || this._fuel[j] < c.fuelFloor) return; + this._catch(j, now); + + // A scorch where it landed. Unitless: fractions of the radius, a timestamp + // and a strength, so the mark re-places itself when the zone is resized. + const half = (g - 1) * 0.5; + this.scorch.mark( + clamp((nx - half) / half, -1, 1), + clamp((ny - half) / half, -1, 1), + frame.uTime.value, + 1 + ); + } + + /** + * Advance the simulation by real time. + * + * **Only when the clock is running.** A zero-length frame does not step the + * automaton, which is what makes the pause honest: press **P** and the fire + * holds the generation it is on while every metre and colour stays live under + * the sliders. It also keeps the harness's stability probe meaningful — a + * simulation that ticked on a paused frame would report every slider as + * responsive for the wrong reason. + */ + _advance(dt, gate) { + if (dt <= 0) return; + const step = 1 / Math.max(1, settings.wildfire.tickRate); + this._clock += dt; + // Bounded: a huge dt (a tab coming back from the background) must not spend + // a second of CPU catching up on generations nobody watched. + let budget = 6; + while (this._clock >= step && budget-- > 0) { + this._clock -= step; + this._generation(this.age, gate); + } + if (this._clock > step) this._clock = 0; + } + + /** + * Where the fire is, as counts and lattice coordinates. + * + * Everything downstream of this — the pall, the light, the ember emission — + * asks the *state array* where to be, not the zone. That is what makes the + * front the subject of the shot. + */ + _measureFront() { + const g = this._grid; + const state = this._state; + let count = 0; + let sx = 0; + let sy = 0; + for (let i = 0; i < this._cells; i++) { + if (state[i] !== BURNING) continue; + count++; + sx += i % g; + sy += (i / g) | 0; + } + this._burning = count; + if (count === 0) { + this._extent = 0; + return; + } + this._centroidX = sx / count; + this._centroidY = sy / count; + + let extent = 0; + for (let i = 0; i < this._cells; i++) { + if (state[i] !== BURNING) continue; + const dx = (i % g) - this._centroidX; + const dy = ((i / g) | 0) - this._centroidY; + const d = Math.sqrt(dx * dx + dy * dy); + if (d > extent) extent = d; + } + this._extent = extent; + } + + /** A burning cell picked at random, or -1. The emitters' only question. */ + _randomBurning() { + if (this._burning <= 0) return -1; + let pick = Math.floor(Math.random() * this._burning); + for (let i = 0; i < this._cells; i++) { + if (this._state[i] !== BURNING) continue; + if (pick-- <= 0) return i; + } + return -1; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.wildfire; + + this.moteEmitter.reset(); + this.smokeEmitter.reset(); + this.scorch.clearMarks(); + this.scorch.setVisible(false); + + this._seed = Math.random() * 100; + // A count, not a dimension, and fixed for the cast: changing the lattice + // width mid-burn would renumber every cell the automaton is holding. + this._grid = clamp(Math.round(c.grid), 8, MAX_GRID); + this._cells = this._grid * this._grid; + this._clock = 0; + this._burning = 0; + this._centroidX = (this._grid - 1) * 0.5; + this._centroidY = (this._grid - 1) * 0.5; + this._extent = 0; + + // The lattice coordinate of a cell depends on the grid width, so it is + // rewritten per cast. Writes into the array built at construction. + const cell = this.attributes.aCell.array; + const ignite = this.attributes.aIgnite.array; + const fuel = this.attributes.aFuel.array; + for (let i = 0; i < MAX_CELLS; i++) { + cell[i * 2] = i % this._grid; + cell[i * 2 + 1] = (i / this._grid) | 0; + ignite[i] = -1; + fuel[i] = 0; + this._state[i] = UNBURNT; + this._fuel[i] = 0; + } + this.attributes.aCell.needsUpdate = true; + this.attributes.aIgnite.needsUpdate = true; + this.attributes.aFuel.needsUpdate = true; + + this._rollFuel(); + this._sync(1); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + _sync(fade) { + this._centrePoint(_centre); + this._measureFront(); + + this.cellGeometry.instanceCount = this._cells; + this.flameGeometry.instanceCount = this._cells; + + this._syncPlacement(); + this._syncCells(fade); + this._syncFlames(fade); + this._syncPall(fade); + this._syncScorch(fade); + this._syncParticles(); + } + + /** The lattice, the clock and where the zone is. Shared by both materials. */ + _syncPlacement() { + const c = settings.wildfire; + const p = this.placement; + const pitch = this._pitch(); + p.uCentre.value.copy(_centre); + p.uForward.value.copy(this.direction); + p.uSideAxis.value.copy(this.side); + p.uGrid.value = this._grid; + p.uPitch.value = pitch; + p.uJitter.value = pitch * c.cellJitter; + p.uLift.value = c.cellLift; + p.uNow.value = this.age; + } + + _syncCells(fade) { + const c = settings.wildfire; + const g = settings.global; + const u = this.cellMaterial.uniforms; + + u.uCellSize.value = this._pitch() * c.cellFill; + u.uBlock.value = saturate(c.blockiness); + u.uRough.value = c.cellRough * g.noiseStrength; + u.uRoughScale.value = c.cellRoughScale * g.noiseFrequency; + u.uCatchTime.value = c.catchTime; + u.uBurnTime.value = c.burnTime * g.lifetime; + u.uCharTime.value = c.charTime; + u.uAshTime.value = c.ashTime; + u.uVein.value = c.cellVein; + u.uOpacity.value = c.cellOpacity * g.opacity; + u.uGlow.value = c.cellGlow * g.glow; + u.uFade.value = fade; + u.uSoftFade.value = c.cellSoftFade; + + u.uColorFlash.value.copy(getColor(c.colorFlash)); + u.uColorEmber.value.copy(getColor(c.colorEmber)); + u.uColorChar.value.copy(getColor(c.colorChar)); + u.uColorAsh.value.copy(getColor(c.colorAsh)); + } + + _syncFlames(fade) { + const c = settings.wildfire; + const g = settings.global; + const u = this.flameMaterial.uniforms; + + u.uHeight.value = c.flameHeight; + u.uWidth.value = c.flameWidth; + // The same two numbers the cell shader burns on, so a tongue cannot outlive + // the ember under it. + u.uCatchTime.value = c.catchTime; + u.uBurnTime.value = c.burnTime * g.lifetime; + u.uLean.value = c.flameLean; + u.uWind.value.set(this._windX(), this._windY()); + u.uWaver.value = c.flameWaver; + u.uWaverRate.value = c.flameWaverRate; + + u.uTaper.value = c.flameTaper; + u.uBulge.value = c.flameBulge; + u.uNoiseScale.value = c.flameNoiseScale * g.noiseFrequency; + u.uNoiseSpeed.value = c.flameNoiseSpeed * g.noiseSpeed; + u.uErosion.value = c.flameErosion * g.noiseStrength; + u.uOpacity.value = c.flameOpacity * g.opacity; + u.uGlow.value = c.flameGlow * g.glow; + u.uFade.value = fade; + u.uSoftFade.value = c.flameSoftFade; + + setFlameGradient(this.flameMaterial, c.colorFlameA, c.colorFlameB, c.colorFlameC, c.colorFlameD); + } + + /** The volume, placed on the centroid of the burning cells and sized to them. */ + _syncPall(fade) { + const c = settings.wildfire; + const g = settings.global; + + if (this._burning <= 0) { + this.pall.setFade(0).sync(c, g); + return; + } + + const pitch = this._pitch(); + const half = (this._grid - 1) * 0.5; + _front + .copy(_centre) + .addScaledVector(this.side, (this._centroidX - half) * pitch) + .addScaledVector(this.direction, (this._centroidY - half) * pitch); + + // Margin holds the flame that far inside the proxy, so the hull is sized up + // by exactly that or the volume is sliced along a straight line at the wall. + const k = Math.max(1, c.pallSlack) / Math.max(0.2, 1 - c.pallMargin); + const radius = Math.max(pitch, (this._extent + 1) * pitch * c.pallSpread); + const weight = saturate(this._burning / Math.max(1, c.pallCells)); + + this.pall + .place(_front) + .setSize(radius * k, c.pallHeight * k, radius * k) + .setFade(fade * weight) + .sync(c, g); + } + + _syncScorch(fade) { + const c = settings.wildfire; + const g = settings.global; + + _spot.centre = _centre; + _spot.yaw = Math.atan2(this.direction.x, this.direction.z); + _spot.height = c.spotHeight; + _spot.radius = Math.max(0.2, c.zoneRadius); + _spot.grow = 1; + _spot.recede = 0; + _spot.fade = fade; + _spot.seed = this._seed; + + _spot.edge = c.spotEdge; + _spot.ragged = c.spotRagged; + _spot.raggedScale = c.spotRaggedScale; + _spot.warp = c.spotWarp; + + _spot.relief = c.spotRelief; + _spot.normalStep = c.spotNormalStep; + _spot.ambient = c.spotAmbient; + _spot.wrap = c.spotWrap; + _spot.specular = c.spotSpecular; + _spot.gloss = c.spotGloss; + _spot.parallax = c.spotParallax; + + _spot.depth = c.spotDepth; + _spot.lift = c.spotLift; + _spot.markLife = c.spotMarkLife; + _spot.markRadius = c.spotMarkRadius; + + _spot.additive = false; + _spot.emissive = c.spotEmissive; + _spot.opacity = c.spotOpacity; + _spot.depthFade = c.spotDepthFade; + _spot.colorBase = c.colorSpotBase; + _spot.colorEdge = c.colorSpotEdge; + _spot.colorGlow = c.colorSpotGlow; + _spot.colorDeep = c.colorSpotDeep; + + _spot.noiseStrength = g.noiseStrength; + _spot.noiseFrequency = g.noiseFrequency; + _spot.noiseSpeed = g.noiseSpeed; + _spot.opacityScale = g.opacity; + + this.scorch.update(_spot); + this.scorch.setVisible(this.phase !== AbilityPhase.TRAVEL && fade > 0.004); + } + + _syncParticles() { + const c = settings.wildfire; + const g = settings.global; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.moteGlow * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.35 * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** The dart leaving the hand. */ + _castFx() { + const c = settings.wildfire; + const g = settings.global; + + _pos.copy(this.origin).addScaledVector(this.direction, c.handForward); + _pos.y = c.handHeight; + + _emit.position = _pos; + _emit.radius = 0.18; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.8).setY(0.4).normalize(); + _emit.speed = c.moteSpeed * 3; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime * 0.6; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(Math.round(18 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.3 * g.explosionIntensity; + } + + /** Embers and smoke, born on cells the automaton says are burning. */ + _frontFx(dt, scale) { + const c = settings.wildfire; + const g = settings.global; + const time = frame.uTime.value; + if (this._burning <= 0) return; + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + const pick = this._randomBurning(); + if (pick >= 0) { + this._cellPoint(pick, _pos); + _emit.position = _pos; + _emit.radius = this._pitch() * 0.6; + _emit.direction = _dir.set(this._windX() * 0.3, 1, this._windY() * 0.3).normalize(); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + } + + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * scale) * g.particleCount); + if (smokeCount > 0) { + const pick = this._randomBurning(); + if (pick >= 0) { + this._cellPoint(pick, _pos).setY(0.2); + _emit.position = _pos; + _emit.radius = this._pitch() * 1.5; + _emit.direction = _dir.set(this._windX() * 0.4, 1, this._windY() * 0.4).normalize(); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + } + } + + /** The light rides the centroid of what is actually burning. */ + _placeLight() { + const c = settings.wildfire; + if (this._burning <= 0) { + this._centrePoint(this.position); + this.position.y = c.lightHeight; + return; + } + const pitch = this._pitch(); + const half = (this._grid - 1) * 0.5; + this._centrePoint(this.position) + .addScaledVector(this.side, (this._centroidX - half) * pitch) + .addScaledVector(this.direction, (this._centroidY - half) * pitch); + this.position.y = c.lightHeight; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + // Nothing is burning yet; the light rides the dart. + this.position.y = 0.6; + this.ctx.shake.rumble(settings.wildfire.rumble * 0.4 * settings.global.cameraShake, dt); + } + + /** The dart lands: a handful of cells catch, and the automaton takes over. */ + onImpact() { + const c = settings.wildfire; + const g = settings.global; + const grid = this._grid; + const half = (grid - 1) * 0.5; + + this._centrePoint(_centre); + + const seeds = Math.max(1, Math.round(c.seeds)); + for (let s = 0; s < seeds; s++) { + // The first seed lands dead centre; the rest scatter, so a cast that + // lands on a bare patch still has somewhere to start. + const angle = Math.random() * Math.PI * 2; + const reach = s === 0 ? 0 : Math.sqrt(Math.random()) * saturate(c.seedSpread) * half; + const x = clamp(Math.round(half + Math.cos(angle) * reach), 0, grid - 1); + const y = clamp(Math.round(half + Math.sin(angle) * reach), 0, grid - 1); + const i = y * grid + x; + // A seed ignores the fuel floor — something has to start, and a dart of + // fire landing on gravel would still light the gravel. + if (this._fuel[i] <= 0) this._fuel[i] = c.fuelFloor; + this._catch(i, this.age); + } + + _pos.copy(_centre).setY(0.35); + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.65, + intensity: c.burstIntensity, + opacity: 0.8, + fresnel: 1.4, + displace: 0.7, + squash: 0.6, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.shake.add( + c.landShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.landFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.8 * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 while the fire spreads freely, then 1..2 while it runs out. + // The spread is closed down over the second half rather than switched off: + // a fire that stops catching all at once reads as somebody turning a tap. + const gate = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1) * 0.85); + + this._advance(dt, gate); + this._sync(fade); + this._placeLight(); + this._frontFx(dt, fade); + this.ctx.shake.rumble(settings.wildfire.rumble * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + this._burning = 0; + this._cells = 0; + this._clock = 0; + this.cellGeometry.instanceCount = 0; + this.flameGeometry.instanceCount = 0; + this.cellMaterial.uniforms.uFade.value = 0; + this.flameMaterial.uniforms.uFade.value = 0; + this.pall.setFade(0); + this.scorch.setVisible(false); + this.scorch.clearMarks(); + } + + dispose() { + this.cellGeometry.dispose(); + this.flameGeometry.dispose(); + this.cellMaterial.dispose(); + this.flameMaterial.dispose(); + this.pall.dispose(); + this.scorch.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/forge/AnvilfallAbility.js b/src/abilities/forge/AnvilfallAbility.js new file mode 100644 index 0000000..df30479 --- /dev/null +++ b/src/abilities/forge/AnvilfallAbility.js @@ -0,0 +1,864 @@ +import { Mesh, Vector3, Color } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { + HardShape, + ShapeCache, + anvilShape, + createHardSurfaceMaterial, + syncHardSurfaceMaterial, + heatToKelvin, + blackbodyColor +} from '../../vfx/HardSurface.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing } from '../../utils/math.js'; + +const TAU = Math.PI * 2; +const HALF_PI = Math.PI * 0.5; + +/** + * Craters the dent is made of, posted in a line along the anvil's own base. + * + * Five, and the number is a judgement rather than a budget: three leaves two + * visible bowls with a bridge of untouched floor between them, and seven costs + * seven more slots in the shader's fixed trip count to add nothing the eye can + * separate at a 3.4 m radius. + */ +const FOOT_MARKS = 5; + +/** + * Points around the footprint the contact spray leaves from. + * + * A heavy flat-bottomed thing does not throw material *up*, it squeezes it out + * from underneath itself, radially. `ParticleSystem#emit` takes one direction + * and a cone, so one call from the centre can only ever produce a fountain — + * the first pass did exactly that and the anvil read as landing on a geyser. + * Six calls round the rim, each pointing outward along its own bearing, is a + * genuine radial sheet for the price of five extra emits on a one-shot beat. + */ +const CONTACT_BATCHES = 6; + +/** + * Tessellation of the anvil. Deliberately *not* sliders. + * + * `createAnvilGeometry` is a lathe: it smooths a key silhouette, lofts a + * rounded rectangle up it and lofts a second body out for the horn. Section + * counts are the one class of number in the shape that changes the *cost* of a + * rebuild rather than the result, and the shape is rebuilt from a `ShapeCache` + * whenever any live slider moves — so leaving them on the panel would let a + * drag of `sections` turn a 4 ms rebuild into a 40 ms one with nothing to show + * for it. Every number that changes the silhouette is a slider; these do not. + */ +const SECTIONS = 26; +const CORNER_STEPS = 3; +const FILLET_PASSES = 4; +const HORN_SECTIONS = 10; +const HORN_STEPS = 12; + +/* --- module-scope scratch: nothing below allocates on a frame (I3) --- */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _land = new Vector3(); +const _tint = new Color(); +/** Params objects, rewritten whole every frame and never cached between them. */ +const _hard = {}; +const _dish = { centre: new Vector3() }; +const _shock = { origin: new Vector3(), axis: new Vector3(0, 1, 0), side: new Vector3() }; + +/** + * ANVILFALL — a machined anvil is dropped on the circle. + * + * **THE TRICK — mass, and mass is a set of consequences that agree.** Nothing + * about an impact reads as heavy on its own. A big shake over a slow arrival + * reads as a bug in the camera; a deep crater under a light landing reads as + * a decal. What makes weight legible is that every consequence is the *same* + * event seen from a different angle, so this ability refuses to let any of + * them be typed in independently. It derives them all from one number: + * + * ``` + * h(u) = dropHeight · (1 − u^fallCurve) the height on screen + * v = −dh/dt |_{u=1} = dropHeight · fallCurve / T T = length / speed + * ``` + * + * `v` is the speed the anvil is *actually travelling at* on the frame it + * touches, read off the animation rather than asserted, and against `refSpeed` + * it drives six things at once: the camera kick, the depth of the dent, the + * speed of the pressure front, the chip speed, the dust speed and the light + * punch. Halve `dropHeight` with the clock stopped and all six get quieter + * together. That single coupling is the whole slot. + * + * The first version did the obvious thing and used the textbook free-fall + * speed, `√(2gh)`, with `g` as its own slider. It is wrong here for a reason + * worth writing down: the anvil is not falling under that `g`, it is falling + * down the cast's own normalised clock, so the number and the picture + * disagreed — a 20 m drop over the same travel time arrived at a *typed* speed + * that had nothing to do with how fast the mesh was moving, and shortening + * `range` made the anvil visibly faster while every derived read stayed put. + * Differentiating the curve that is actually on screen fixed all of it. + * + * Three things then carry the read on top of that: + * + * **The floor dishes.** `vfx/GroundField.js` in `POCK`, but not with one + * crater — with `FOOT_MARKS` craters posted in a line along the anvil's base, + * in a frame yawed to the anvil's own long axis, so the dent is the *shape of + * the thing that made it*. One central bowl was the first attempt and it reads + * as a cannonball landing. The marks are unitless fractions and the ability + * keeps the `Vector4`s the module handed back, rewriting their positions and + * strengths every frame, so `anvilSize`, `dishFootprint`, `dishRadius`, + * `dishDepth` and `dishCurve` all reshape a dent that is already in the floor + * with the clock stopped. (The pattern is `BoulderAbility`'s, and it is the + * only way a posted event stays a live slider.) + * + * **The shock is low and slow.** `vfx/Shell.js` in `PRESSURE` at + * `shockHeight = 0.1` is an oblate lens lying on the floor rather than a dome, + * `shockExpand = 1.15` is nearly linear so it keeps *travelling* instead of + * snapping out and easing, and it is dust-coloured at `shockGlow = 0.55`. Its + * clock is `shockRadiusEnd / (v · shockSpeedRatio)` — the front's speed is a + * third of the anvil's arrival speed, so it is fast when the drop is high and + * ponderous when it is low, and it never disagrees with the shake. + * + * **It stays.** The anvil does not fade, dissolve or dim. It is opaque steel + * from the frame it lands, it settles into its own dent by `dishSink` of the + * depth it made, and it leaves at the very end of the fade by *sinking* + * (`exitSink`) rather than by going transparent, because a lump of steel that + * fades out is a hologram. `fadeTime` ships at 2.2 s for the same reason. + * + * **The rule that makes the editor work.** A cast captures two numbers and a + * timestamp: a seed for the resting yaw, a boolean for whether it has landed, + * and the age it landed at. Every metre, radian and second is resolved from + * `settings.anvilfall` inside the update loop, on a zero-length frame + * included — including the anvil's own silhouette, which goes through a + * `ShapeCache` and rebuilds only when one of the eleven shape sliders actually + * moves. + */ +export class AnvilfallAbility extends Ability { + constructor(context) { + super('anvilfall', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* ---- the steel ---- */ + this.anvilMaterial = createHardSurfaceMaterial({ + environment: this.ctx.environment, + // Smooth shading. The generators already crease at `creaseAngle`, so + // flat shading here would only re-facet the fillets that are the entire + // reason the shape reads as forged rather than as boxes. + flatShading: false + }); + + /** + * One shape object, rewritten in place every frame and handed to the + * cache, which compares its values against a `Float64Array` and rebuilds + * only on a real change. Building it fresh each frame would be an object + * literal per frame (I3) *and* an eleven-key string compare. + */ + this._shape = anvilShape({ + sections: SECTIONS, + cornerSteps: CORNER_STEPS, + filletPasses: FILLET_PASSES, + hornSections: HORN_SECTIONS, + hornSteps: HORN_STEPS + }); + this.shapes = new ShapeCache({ capacity: 1 }); + + this.anvil = new Mesh(this.shapes.get(0, HardShape.ANVIL, this._shape), this.anvilMaterial); + this.anvil.castShadow = true; + this.anvil.receiveShadow = true; + this.anvil.frustumCulled = false; + this.anvil.layers.set(LAYER.WORLD); + this.anvil.renderOrder = 2; + this.group.add(this.anvil); + + /* ---- the dent ---- */ + this.dish = new GroundField(this.group, { + mode: GroundMode.POCK, + marks: FOOT_MARKS, + // A dent is a hole. It shades the floor; it never lights it. + additive: false, + depthTest: true, + layer: LAYER.VFX, + name: 'AnvilDent' + }); + this.dish.setVisible(false); + + /* ---- the pressure front ---- */ + this.shock = new Shell({ + mode: ShellMode.PRESSURE, + prefix: 'shock', + nodes: 40, + sides: 40, + renderOrder: 12 + }); + this.shock.visible = false; + this.group.add(this.shock.group); + + /** Has it touched yet. One boolean. */ + this._landed = false; + /** The age it touched at — a timestamp, which is an event, not a dimension. */ + this._landedAt = 0; + /** Resting yaw, 0..1 of a turn. The one dice roll a cast makes. */ + this._seed = 0; + + /** + * The crater slots, and why they are held rather than posted and forgotten. + * + * `GroundField#mark()` returns the `Vector4` it wrote into, and that vector + * *is* the uniform. Keeping the references lets every crater's position and + * strength be recomputed from the live sliders on every frame, including a + * zero-length one. Without it, `anvilSize`, `dishFootprint`, `dishRadius`, + * `dishBaseLoad` and `dishHornLoad` would all be baked into the floor at + * the instant of contact — five dead sliders on the one part of the + * ability that is supposed to prove the anvil was heavy. + */ + this._markSlots = new Array(FOOT_MARKS).fill(null); + } + + createParticles() { + const particles = this.ctx.particles; + + // The plume punched out from under it. Non-additive: dust thrown up by + // something heavy has to occlude, or the anvil reads as landing in fog. + this.dust = particles.get('anvilfall.dust', { + capacity: 2200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 2.2; + this.dust.uniforms.uEndSize.value = 3.2; + this.dust.uniforms.uSizeIn.value = 0.09; + this.dust.uniforms.uFadeIn.value = 0.1; + this.dust.uniforms.uFadeOut.value = 0.34; + + // Chips of floor. Lit, because they are stone and stone is not emissive. + this.chips = particles.get('anvilfall.chips', { + capacity: 1400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.24; + this.chips.uniforms.uEndSize.value = 0.8; + this.chips.uniforms.uFadeOut.value = 0.72; + + // Struck sparks. Steel on stone throws a few even stone cold, and if the + // anvil is up at forge heat they are the same temperature the metal is. + this.sparks = particles.get('anvilfall.sparks', { + capacity: 1200, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.5; + this.sparks.uniforms.uEndSize.value = 0.22; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.45; + + this.dustEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — everything below is derived, nothing is remembered */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return 1; + } + + /** Seconds the cast spends falling. The front's own travel time. */ + get travelTime() { + const c = settings.anvilfall; + return Math.max(0.05, this.length / Math.max(0.2, c.speed * settings.global.speed)); + } + + /** + * Metres/second the anvil is travelling at the instant it touches. + * + * `h(u) = dropHeight · (1 − u^fallCurve)`, and `u` runs 0→1 over + * `travelTime`, so `dh/dt = −dropHeight · fallCurve · u^(fallCurve−1) / T` + * and the arrival speed is that at `u = 1`. Differentiated from the curve + * that is genuinely on screen — see the class comment for what happens when + * it is not. + */ + get impactSpeed() { + const c = settings.anvilfall; + return (Math.max(c.dropHeight, 0) * Math.max(c.fallCurve, 0.05)) / this.travelTime; + } + + /** How hard the landing was, 0..~2, against `refSpeed`. Everything reads this. */ + get impactLoad() { + return clamp(this.impactSpeed / Math.max(settings.anvilfall.refSpeed, 0.5), 0, 2.5); + } + + /** Metres the floor actually gives way by, for this landing. */ + get dentDepth() { + const c = settings.anvilfall; + return c.dishDepth * Math.pow(this.impactLoad, Math.max(c.dishCurve, 0.05)); + } + + /** Seconds the pressure front takes to cross `shockRadiusEnd`. */ + get shockTime() { + const c = settings.anvilfall; + const speed = Math.max(this.impactSpeed * Math.max(c.shockSpeedRatio, 0.01), 0.5); + return Math.max(0.05, c.shockRadiusEnd / speed); + } + + /** The anvil sits there long enough for the front to cross and the dust to go. */ + get impactDuration() { + const c = settings.anvilfall; + const busy = Math.max(this.shockTime + c.shockDelay, c.settleTime, c.dustLifetime * 0.6); + return Math.max(0.3, (busy + c.holdTime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.2, settings.anvilfall.fadeTime); + } + + /** Steel does not gutter. A steady light, faintly breathing. */ + lightShimmer() { + return 0.94 + 0.06 * Math.sin(this.age * 2.1); + } + + /* ------------------------------------------------------------------ */ + /* Where the anvil is */ + /* ------------------------------------------------------------------ */ + + /** The point on the floor the anvil is aimed at. */ + _landingPoint(out) { + const c = settings.anvilfall; + this.pointAt(1, out); + out.addScaledVector(this.direction, c.driftForward); + out.addScaledVector(this.side, c.driftSide); + out.y = 0; + return out; + } + + /** + * Metres the anvil's seat is above (positive) or below (negative) the floor. + * + * Before contact this is the fall curve. After it, the anvil settles into + * the dent it just made — `dishSink` of the live dent depth plus a + * `settleDrop` of pure compression — and, right at the end of the fade, + * sinks out of sight rather than fading out. + * + * @param {number} fadePhase 0..1 through the *fade* only; 0 while it holds + */ + _seatHeight(fadePhase) { + const c = settings.anvilfall; + if (!this._landed) { + const u = this.phase === AbilityPhase.TRAVEL ? saturate(this.u) : 1; + return Math.max(c.dropHeight, 0) * (1 - Math.pow(u, Math.max(c.fallCurve, 0.05))); + } + const since = Math.max(0, this.age - this._landedAt); + const settle = Easing.outCubic(saturate(since / Math.max(c.settleTime, 0.02))); + const bed = (this.dentDepth * saturate(c.dishSink) + c.settleDrop) * settle; + const exit = c.exitSink * settings.anvilfall.anvilSize * Easing.inQuad(saturate(fadePhase)); + return -(bed + exit); + } + + /** The anvil's yaw, radians. It stops turning the moment it lands. */ + _yaw() { + const c = settings.anvilfall; + const spinFor = this._landed ? this._landedAt : this.age; + return this._seed * TAU + c.yawTurns * TAU * spinFor; + } + + /** How far it is still tipped over, radians. Levels out as it comes down. */ + _tilt() { + const c = settings.anvilfall; + if (this._landed) return 0; + const u = this.phase === AbilityPhase.TRAVEL ? saturate(this.u) : 1; + return c.tilt * Math.pow(1 - u, Math.max(c.tiltSettle, 0.05)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + + this._seed = Math.random(); + this._landed = false; + this._landedAt = 0; + this._markSlots.fill(null); + + this.dish.clearMarks(); + this.dish.setVisible(false); + this.shock.visible = false; + this.anvil.visible = true; + + this._sync(0, 1); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve everything and drive the three modules. + * + * @param {number} fade 1 while it is fresh, ramping to 0 as the cast goes + * @param {number} fadePhase 0..1 through the fade only — drives the exit sink + */ + _sync(fade, fadePhase) { + const c = settings.anvilfall; + const g = settings.global; + + /* ---- the anvil ---- */ + this._syncShape(c); + this._landingPoint(_land); + this.anvil.position.set(_land.x, this._seatHeight(fadePhase), _land.z); + this.anvil.scale.setScalar(Math.max(c.anvilSize, 0.02)); + // Tilt about the axis across the fall, so it comes down leaning on the + // horn and rolls flat rather than pitching forward and back. + const tilt = this._tilt(); + this.anvil.rotation.set(tilt, this._yaw(), tilt * 0.4); + + this._syncMetal(c, g); + this._syncDent(c, g, fade); + this._syncShock(c, g, fade); + this._syncParticles(c, g); + } + + /** + * The silhouette. Eleven live sliders through a `ShapeCache`. + * + * The cache rebuilds only when one of the numbers below actually moves, and + * the anvil is a lathe with two lofts in it — four-odd milliseconds a build. + * Doing it unconditionally would be four milliseconds a *frame* to produce a + * byte-identical buffer, which is the mistake `ShapeCache` exists to stop. + */ + _syncShape(c) { + const s = this._shape; + s.height = Math.max(c.bodyHeight, 0.15); + s.faceWidth = Math.max(c.faceWidth, 0.05); + s.faceDepth = Math.max(c.faceDepth, 0.05); + s.waistWidth = Math.max(c.waistWidth, 0.03); + s.waistDepth = Math.max(c.waistWidth * 0.92, 0.03); // the waist is very slightly oval + s.waistHeight = Math.max(c.waistHeight, 0.03); + s.baseWidth = Math.max(c.baseWidth, 0.05); + s.baseDepth = Math.max(c.baseWidth * 0.79, 0.05); // ... and so is the foot + s.horn = Math.max(c.hornReach, 0); + s.hornDroop = c.hornDroop; + s.fillet = saturate(c.fillet); + s.corner = clamp(c.corner, 0, 0.9); + const geometry = this.shapes.get(0, HardShape.ANVIL, s); + if (this.anvil.geometry !== geometry) this.anvil.geometry = geometry; + } + + /** The steel. `syncHardSurfaceMaterial` every frame, zero-length ones too. */ + _syncMetal(c, g) { + _hard.colorMetal = c.colorMetal; + _hard.colorDeep = c.colorDeep; + _hard.colorScale = c.colorScale; + _hard.colorPolish = c.colorPolish; + _hard.colorSpec = c.colorSpec; + _hard.roughness = c.roughness; + _hard.metalness = c.metalness; + _hard.envIntensity = c.envIntensity; + + _hard.brush = Math.round(clamp(c.brushMode, 0, 2)); + _hard.brushAxisX = c.brushAxisX; + _hard.brushAxisY = c.brushAxisY; + _hard.brushAxisZ = c.brushAxisZ; + _hard.anisotropy = c.anisotropy; + _hard.specular = c.specular; + _hard.grain = c.grain; + _hard.grainScale = c.grainScale; + _hard.grainStretch = c.grainStretch; + + _hard.scale = c.millScale; + _hard.scaleScale = c.millScaleSize; + _hard.scaleSharp = c.millScaleSharp; + _hard.pit = c.pit; + _hard.pitScale = c.pitScale; + _hard.wear = c.wear; + _hard.wearGrain = c.wearGrain; + + _hard.heat = c.heat; + _hard.heatCold = c.heatCold; + _hard.heatHot = c.heatHot; + _hard.heatRef = c.heatRef; + _hard.heatExponent = c.heatExponent; + _hard.heatGlow = c.heatGlow; + _hard.heatTint = c.heatTint; + _hard.heatEdge = c.heatEdge; + + _hard.glow = g.glow; + _hard.shaderIntensity = g.shaderIntensity; + _hard.noiseFrequency = g.noiseFrequency; + + syncHardSurfaceMaterial(this.anvilMaterial, _hard); + } + + /** + * The dent, and the five craters that make it anvil-shaped. + * + * The frame is yawed to `anvilYaw + π/2` so the field's local **+Z** lies + * along the anvil's own long axis — the generators seat the part with its + * axis on +Y and the horn on local +X, and `GroundField` puts local +Z + * downrange, so the two frames are a quarter turn apart. Getting that wrong + * lays the dent across the anvil instead of under it, which looks almost + * right until the yaw slider is touched. + */ + _syncDent(c, g, fade) { + const radius = Math.max(c.dishRadius, 0.2); + const depth = this.dentDepth; + + // Re-derive every crater already in the floor. Only the timestamp survives + // from the moment of contact — see `_markSlots`. + if (this._landed) { + const span = c.anvilSize * c.dishFootprint; + for (let i = 0; i < FOOT_MARKS; i++) { + const slot = this._markSlots[i]; + if (!slot) continue; + // −0.5 at the heavy end, +0.5 at the horn. + const s = FOOT_MARKS > 1 ? i / (FOOT_MARKS - 1) - 0.5 : 0; + slot.x = 0; + slot.y = (s * span) / radius; + slot.w = saturate(lerp(c.dishBaseLoad, c.dishHornLoad, i / Math.max(FOOT_MARKS - 1, 1))); + } + } + + _dish.centre.copy(_land); + _dish.yaw = this._yaw() + HALF_PI; + _dish.height = c.dishHeight; + _dish.radius = radius; + _dish.length = radius * 2; + _dish.grow = 1; + _dish.recede = 0; + _dish.progress = 1; + _dish.fade = fade; + _dish.seed = this._seed * 100; + + _dish.edge = c.dishEdge; + _dish.ragged = c.dishRagged; + _dish.raggedScale = c.dishRaggedScale; + _dish.warp = c.dishWarp; + + _dish.relief = c.dishRelief; + _dish.normalStep = c.dishNormalStep; + _dish.ambient = c.dishAmbient; + _dish.wrap = c.dishWrap; + _dish.specular = c.dishSpecular; + _dish.gloss = c.dishGloss; + _dish.parallax = c.dishParallax; + + _dish.depth = depth; + _dish.lift = c.dishLift; + _dish.thickness = c.dishRimWidth; + _dish.detail = c.dishGrain; + _dish.speed = c.dishDig; + _dish.markLife = c.dishLife; + _dish.markRadius = c.dishMarkRadius; + + _dish.additive = false; + _dish.emissive = c.dishEmissive; + _dish.opacity = c.dishOpacity; + _dish.depthFade = c.dishDepthFade; + _dish.colorBase = c.colorDishBase; + _dish.colorEdge = c.colorDishEdge; + _dish.colorGlow = c.colorDishGlow; + _dish.colorDeep = c.colorDishDeep; + + _dish.noiseStrength = g.noiseStrength; + _dish.noiseFrequency = g.noiseFrequency; + _dish.noiseSpeed = g.noiseSpeed; + _dish.opacityScale = g.opacity; + + this.dish.update(_dish); + this.dish.setVisible(this._landed && fade > 0.004); + } + + /** + * The pressure front. + * + * `t` is not `age / lifetime` — it is `since contact / shockTime`, and + * `shockTime` is the reach divided by a fraction of the arrival speed. That + * is the only reason the front and the camera shake ever agree with each + * other about how hard the thing hit. + */ + _syncShock(c, g, fade) { + if (!this._landed) { + this.shock.visible = false; + return; + } + const since = Math.max(0, this.age - this._landedAt - c.shockDelay); + const t = saturate(since / this.shockTime); + + _shock.origin.copy(_land); + _shock.axis.set(0, 1, 0); + _shock.side.copy(this.side); + _shock.span = c.shockSpan; + _shock.t = t; + // A pressure front dies as it runs out of energy, not when the cast does: + // squared so the last third of its travel is already almost gone. + _shock.fade = fade * (1 - t) * (1 - t); + _shock.seed = this._seed * 100; + + this.shock.sync(c, _shock, g); + this.shock.visible = _shock.fade > 0.004; + } + + /** Colours, sizes and the two globals every particle system folds in. */ + _syncParticles(c, g) { + const load = this.impactLoad; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + // The plume leaves faster off a harder landing, from the same slider. + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * load * g.particleSpeed * 0.2; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = 1.6 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + } + + /** + * The tint struck sparks leave at. + * + * Sparks off steel are the temperature of the steel, so the one slider that + * drives the metal drives them too. Hard-coding an orange here is how a + * cherry-red anvil ends up throwing lemon-yellow sparks — `HardSurface`'s own + * note, and it is right. At `heat = 0` the locus clamps at 700 K, which is + * black, so `sparkHeatTint` blends back toward the authored gradient and a + * cold strike keeps its own colour. + */ + _sparkTint(c) { + blackbodyColor(heatToKelvin(saturate(c.heat), _hard), _tint); + return _tint.lerp(getColor(c.colorSparkA), 1 - saturate(c.sparkHeatTint)); + } + + /** + * Fire one contact spray radially out of the footprint. + * + * The caller has already filled `_emit` with everything that is not + * positional; this only walks the bearings. `_emit.direction` points along + * the bearing with `up` folded in, so the whole sheet leaves the way the + * material under a falling flat face actually has to go — sideways. + * + * @param {object} system the particle system to emit from + * @param {number} total particles across all batches + * @param {number} radius metres from the landing point the batch sits at + * @param {number} height metres above the floor + * @param {number} up how much +Y is folded into the outward direction + * @param {number} scatter metres of emission radius per batch + */ + _ringEmit(system, total, radius, height, up, scatter) { + if (total <= 0) return; + const per = Math.max(1, Math.round(total / CONTACT_BATCHES)); + let left = total; + for (let i = 0; i < CONTACT_BATCHES && left > 0; i++) { + // Phased off the cast's own seed so two casts do not spray identically, + // and off nothing else — a Math.random() here would make the ability + // restless under the harness's paused probe for no gain. + const angle = (i / CONTACT_BATCHES + this._seed) * TAU; + const cx = Math.cos(angle); + const cz = Math.sin(angle); + _pos.copy(_land); + _pos.x += cx * radius; + _pos.z += cz * radius; + _pos.y = height; + _dir.set(cx, Math.max(up, 0.05), cz).normalize(); + _emit.position = _pos; + _emit.direction = _dir; + _emit.radius = scatter; + system.emit(Math.min(per, left), _emit); + left -= per; + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1, 0); + + // The light rides down with the anvil, so the floor brightens as it comes. + // Read straight off the mesh rather than through `getWorldPosition`: the + // ability's group carries an identity matrix it never updates, so the two + // agree and only one of them depends on traversal order. + this.position.copy(this.anvil.position); + this.position.y += settings.anvilfall.anvilSize * 0.4; + + this.ctx.shake.rumble(settings.anvilfall.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.anvilfall; + const g = settings.global; + const time = frame.uTime.value; + const load = this.impactLoad; + + this._landed = true; + this._landedAt = this.age; + this._landingPoint(_land); + + /* ---- the dent: five craters in a line under the base ---- */ + // Position and strength are rewritten every frame in `_syncDent`; the only + // thing that survives from here is the timestamp, which is the event. + for (let i = 0; i < FOOT_MARKS; i++) { + this._markSlots[i] = this.dish.mark(0, 0, time, 1); + } + this.dish.setVisible(true); + this._sync(1, 0); + + /* ---- what is squeezed out from under it ---- */ + const rim = Math.max(c.anvilSize * 0.5, 0.1); + + _emit.speed = c.dustSpeed * load; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.0; + _emit.sizeVariance = 0.55; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = time; + this._ringEmit(this.dust, Math.round(c.dustCount * g.particleCount), rim * 1.15, c.dustHeight, 0.55, rim * 0.6); + + _emit.speed = c.chipSpeed * load; + _emit.speedVariance = 0.85; + _emit.spread = 0.55; + _emit.size = 0.1; + _emit.sizeVariance = 0.75; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 11; + // `chipSpray` tips the outward direction from straight up (0) to flat + // along the floor (1). Flat is the heavy read. + this._ringEmit(this.chips, Math.round(c.chipCount * g.particleCount), rim, 0.05, 1 - saturate(c.chipSpray), rim * 0.4); + + _emit.speed = c.sparkSpeed * load; + _emit.speedVariance = 0.9; + _emit.spread = 0.5; + _emit.size = 0.16; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = this._sparkTint(c); + this._ringEmit(this.sparks, Math.round(c.sparkCount * g.particleCount), rim * 0.9, 0.08, 0.35, rim * 0.3); + _emit.tint = null; + + /* ---- the camera ---- */ + // Low frequency and a long decay. A heavy landing is a *slow* shake; the + // 26 Hz snap the bolt uses reads as something small and sharp, and using it + // here was the single worst frame of the first pass. + this.ctx.shake.add( + c.impactShake * load * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + Math.max(c.shakeFrequency, 1) + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * load * g.explosionIntensity); + this.lightBoost = c.lightIntensity * load * 0.9 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.anvilfall; + const g = settings.global; + // `t` runs 0..1 while it sits there, then 1..2 while the cast lets go. + const fadePhase = t <= 1 ? 0 : saturate((t - 1 - c.exitStart) / Math.max(1 - c.exitStart, 0.05)); + const fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + + this._sync(fade, fadePhase); + this.position.copy(_land).setY(c.lightHeight); + + // Dust keeps rolling off the dent for a moment, thinning as it goes. Gated + // on the emitter's own accumulator, so a paused frame emits nothing and a + // long frame emits the right amount. + const settle = this.phase === AbilityPhase.IMPACT ? 1 - saturate(t / 0.7) : 0; + const rate = c.dustRate * settle * this.impactLoad; + const count = Math.round(this.dustEmitter.tick(dt, rate) * g.particleCount); + if (count > 0) { + _pos.copy(_land); + _pos.y = c.dustHeight * 0.6; + _emit.position = _pos; + _emit.radius = Math.max(c.anvilSize * 0.8, 0.1); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 0.3; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime * 0.8; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(count, _emit); + } + + this.ctx.shake.rumble(c.settleRumble * fade * settle * g.cameraShake, dt); + } + + onDestroy() { + this._landed = false; + this._landedAt = 0; + this._markSlots.fill(null); + this.dish.clearMarks(); + this.dish.setVisible(false); + this.shock.visible = false; + } + + dispose() { + this.dish.dispose(); + this.shock.dispose(); + this.shapes.dispose(); + this.anvilMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/forge/GearlockAbility.js b/src/abilities/forge/GearlockAbility.js new file mode 100644 index 0000000..6bafa8f --- /dev/null +++ b/src/abilities/forge/GearlockAbility.js @@ -0,0 +1,1076 @@ +import { InstancedMesh, Object3D, InstancedBufferAttribute, DynamicDrawUsage, Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { + GearTrain, + gearTrainParams, + ShapeCache, + HardShape, + HardAxis, + gearShape, + gearPitchFraction, + createHardSurfaceMaterial, + syncHardSurfaceMaterial, + hardSurfaceParams, + GrindContact, + grindParams +} from '../../vfx/HardSurface.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing } from '../../utils/math.js'; + +/** + * Hard ceiling on gears in one train — `GearTrain`'s own default capacity, and + * the size of every per-instance array in this file. + */ +const MAX_GEARS = 12; +/** + * Distinct tooth counts the train may draw from. + * + * This is three because **a tooth count is a shape, not a transform**: two + * gears with different `teeth` cannot share an `InstancedMesh`, so every count + * on the menu costs a geometry, a rebuild whenever a profile slider moves, and + * a draw call. Three ratios is enough for a train to read as a mechanism — + * small driving large driving small — and three draw calls is a quarter of the + * budget. Four was tried and the only thing it changed was the build time. + */ +const SLOTS = 3; + +const _up = new Vector3(0, 1, 0); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _contact = new Vector3(); +const _hubA = new Vector3(); +const _hubB = new Vector3(); +const _tangent = new Vector3(); +const _velA = new Vector3(); +const _velB = new Vector3(); +const _slide = new Vector3(); +const _dummy = new Object3D(); + +/** Tooth counts resolved this frame; handed to `GearTrain` by reference. */ +const _teeth = [11, 17, 26]; +/** Per-contact sliding speed, metres/second. Filled in pass one of `_grindFx`. */ +const _slideSpeed = new Float64Array(MAX_GEARS); +/** How many gears landed in each `InstancedMesh` this frame. */ +const _slotCount = new Int32Array(SLOTS); +/** The shape object handed to `ShapeCache` — refilled per slot, never rebuilt. */ +const _shape = gearShape(); + +/** + * GEARLOCK — a train of meshing spur gears winds up out of the floor, runs, and + * seizes. + * + * ## THE TRICK — the teeth actually mesh, and they stay meshed while you drag + * + * Everything else in this file is dressing on one piece of arithmetic, and the + * arithmetic has two halves. Getting the first and skipping the second is the + * failure that ships: + * + * 1. **Rate.** `ω₂ = −(z₁/z₂)·ω₁`. Obvious, and every gear demo does it. + * 2. **Phase.** Two gears turning at the perfect ratio still grind straight + * through one another unless a *tooth* of one is aimed at a *gap* of the + * other along the line of centres — and that is a constraint on the + * absolute angles, not on their derivatives. `vfx/HardSurface.js` solves + * `θ₂ = β + π + (z₁/z₂)(β − θ₁) − π/z₂` for it, and differentiating that + * recovers (1) for free, which is the check that it is the right + * constraint. + * + * The first version of this ability skipped (2) and looked *correct*. The train + * counter-rotated, the ratios were right, the sizes were right, and the teeth + * passed through each other like smoke. It took a minute of staring to see, and + * once you see it you cannot unsee it — which is exactly why the roster names + * it as the thing to avoid. + * + * Because the phase is **solved rather than integrated**, the whole train is a + * pure function of `settings.gearlock` and one warped clock. Stop the world with + * **P** mid-cast and drag `teethB`: every gear that drew that count re-teeths, + * its pitch radius changes, its neighbours slide along their bearings to the + * new standard centre distance `m(z₁+z₂)/2`, and every angle downstream + * re-phases — in the frame you moved the slider, with `dt = 0`, still meshed. + * Nothing about the train survives a frame. + * + * ## Spacing is on the pitch circles, not the tips + * + * The tempting wrong answer is to butt the tip circles together, which leaves a + * gap of one whole tooth height and reads as two gears that happen to be near + * each other. Involute gears mesh at the sum of their **pitch** radii, and the + * pitch radius is `m·z/2` — which is why the module is the one number that + * matters and why it is resolved from `moduleFrac × zoneRadius` every frame + * rather than captured. Drag `moduleFrac` and the train re-spaces without a + * single vertex being rebuilt; drag `teeth` and it re-spaces *and* rebuilds. + * + * ## The clock is a closed-form warp, not an integrator + * + * `GearTrain#solve()` takes a time and produces an angle, so the run-up, the + * seize and the shudder are all one function `τ(age)`: + * + * ``` + * run-up τ = t − up·(1 − e^(−t/up)) dτ/dt → 1 + * seize τ = τ(L) + r(L)·s·(1 − e^(−(t−L)/s)) dτ/dt → 0 + * shudder += shudder·e^(−(t−L)/d)·sin(ω(t−L)) + * ``` + * + * Integrating a decaying rate on the CPU would have been three lines shorter + * and would have made every one of those five sliders dead the moment the clock + * stopped, because the angle would then be a number the ability was *holding* + * rather than a number it can *derive*. The seize is authored as a warp of time + * for the same reason the phase is solved rather than accumulated: I1 is not a + * style rule, it is what makes the editor work. + * + * The shudder is added to **τ**, not to each gear's angle. That matters: a + * per-gear wobble would break the mesh constraint on the frame it was applied, + * because the constraint is on the absolute angles. Wobbling the root gear's + * clock instead propagates through the ratio chain and the whole train judders + * as one rigid mechanism, which is what a seizing gearbox does. + * + * ## Where the sparks come from, and where they do not + * + * At the **pitch point** the two flanks are in pure rolling: the sliding + * velocity there is exactly zero, and a spark stream centred on it is a lie + * anybody who has watched a gearbox will feel without being able to name. The + * sliding speed grows linearly with distance from the pitch point along the + * common tangent, so the jets are struck at `slideOffset` metres either side of + * it and their speed comes from `v₁ − v₂` evaluated *there* — the real relative + * velocity of two points that are momentarily in contact. Set `slideOffset` to + * 0 and the sparks stop, which is the correct behaviour and a decent way to + * convince yourself the maths is doing something. + * + * ## Budget + * + * Three `InstancedMesh`es (one per tooth count) and one `GroundField(LATTICE)` + * — four draw calls, one material, three particle systems, one light. + */ +export class GearlockAbility extends Ability { + constructor(context) { + super('gearlock', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- one material for the whole train --- */ + this.metal = createHardSurfaceMaterial({ environment: this.ctx.environment }); + this._look = hardSurfaceParams(); + + /** + * The geometry cache is **this ability's**, never shared. A gear is the + * most expensive thing in `HardSurface` to build — an involute flank per + * tooth, a fillet per root, an Earcut over a blank with a bore and five + * lightening holes in it — so it is built when a number moves and not + * otherwise. `capacity: SLOTS` makes an accidental fourth slot throw here + * rather than quietly thrash. + */ + this.cache = new ShapeCache({ capacity: SLOTS }); + /** Pitch radius ÷ actual outer radius, per slot. Recomputed only on rebuild. */ + this._pitchFraction = new Float64Array(SLOTS); + /** The tooth count each slot is currently built for. */ + this._slotTeeth = new Int32Array(SLOTS); + + this.train = new GearTrain({ capacity: MAX_GEARS }); + this._trainParams = gearTrainParams(); + this.grind = new GrindContact(); + this._grindParams = grindParams(); + + /** + * One `InstancedMesh` per tooth count. + * + * They are built here, at construction, and never added to or removed from + * the group again — the pooling contract wants `spawn()` to allocate + * nothing, and the harness fails an ability that grows its group mid-cast. + * An empty slot sets `count = 0` and hides itself instead. + */ + this.meshes = []; + this.heat = []; + for (let slot = 0; slot < SLOTS; slot++) { + _shape.teeth = 10 + slot * 6; + const geometry = this.cache.get(slot, HardShape.GEAR, _shape); + const mesh = new InstancedMesh(geometry, this.metal, MAX_GEARS); + mesh.instanceMatrix.setUsage(DynamicDrawUsage); + mesh.frustumCulled = false; + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.layers.set(LAYER.WORLD); + mesh.renderOrder = 2; + mesh.count = 0; + mesh.visible = false; + mesh.name = `Gearlock:gears${slot}`; + + /** + * `aHeat` is an **offset** on the material's `heat`, one float per + * instance, so a small gear whipping round at eight metres a second at + * the rim comes up hotter than the big one it is driving — off one + * material and one draw call. It is an offset rather than an absolute + * because an unset attribute reads as 0 in WebGL, and an absolute would + * make every mesh that forgot one ice cold. + */ + const heat = new InstancedBufferAttribute(new Float32Array(MAX_GEARS), 1); + heat.setUsage(DynamicDrawUsage); + geometry.setAttribute('aHeat', heat); + + this.group.add(mesh); + this.meshes.push(mesh); + this.heat.push(heat); + this._pitchFraction[slot] = gearPitchFraction(_shape); + this._slotTeeth[slot] = _shape.teeth; + } + + /** + * The bed plate. `LATTICE` propagates along its own edges cell by cell, + * which is the one ground mode in the library that reads as *engineered* + * rather than as weathered — a machined grid growing outward under the + * train while the front is still running out to it. + */ + this.bed = new GroundField(this.group, { + mode: GroundMode.LATTICE, + layer: LAYER.VFX, + name: 'Gearlock:bed' + }); + this._bedParams = groundFieldParams(); + this.bed.setVisible(false); + + /* --- per-cast state. Dice and timestamps only. --- */ + this._seed = 0; + this._landed = false; + this._landAge = 0; + this._locked = false; + this._lockAge = 0; + this._live = 0; + /** dτ/dt this frame — the run-up ramp, then the seize decay. */ + this._rateScale = 0; + /** Handedness of the cast basis; recovered per frame, never assumed. */ + this._handed = -1; + /** Recentring offset so the train sits *in* the aim circle, not off its edge. */ + this._offX = 0; + this._offZ = 0; + this._fade = 1; + } + + createParticles() { + const particles = this.ctx.particles; + + // Grinding sparks: velocity-stretched streaks under gravity. + this.sparks = particles.get('gearlock.sparks', { + capacity: 2600, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.3; + this.sparks.uniforms.uEndSize.value = 0.22; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.5; + + // Swarf: lit chips of steel machined off the flanks. + this.swarf = particles.get('gearlock.swarf', { + capacity: 900, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.swarf.uniforms.uDrag.value = 0.3; + this.swarf.uniforms.uEndSize.value = 0.75; + this.swarf.uniforms.uFadeOut.value = 0.7; + + // Floor dust thrown up as each gear breaks the surface. + this.dust = particles.get('gearlock.dust', { + capacity: 1100, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.dust.uniforms.uDrag.value = 1.9; + this.dust.uniforms.uEndSize.value = 2.6; + this.dust.uniforms.uSizeIn.value = 0.12; + this.dust.uniforms.uFadeIn.value = 0.18; + this.dust.uniforms.uFadeOut.value = 0.32; + + this.sparkEmitter = new RateEmitter(); + this.swarfEmitter = new RateEmitter(); + this.dustEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + /** The train runs for `lifetime`, then the fade phase is the seize. */ + get impactDuration() { + return Math.max(0.05, settings.gearlock.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.gearlock.fadeTime); + } + + /** Steel does not gutter. A slow swell off the mesh points, and nothing else. */ + lightShimmer() { + return 0.92 + 0.08 * Math.sin(this.age * 5.1); + } + + /* ------------------------------------------------------------------ */ + /* The clock */ + /* ------------------------------------------------------------------ */ + + /** + * Warped time in seconds, and the instantaneous rate multiplier that goes + * with it. + * + * Returns τ and leaves `dτ/dt` in `this._rateScale`, because both are wanted + * by every caller and computing them separately would evaluate the same three + * exponentials twice. Both are pure functions of `age`, two timestamps and + * five sliders — nothing is carried between frames, which is what makes the + * seize re-time itself while the clock is stopped. + */ + _resolveClock() { + const c = settings.gearlock; + if (!this._landed) { + this._rateScale = 0; + return 0; + } + const up = Math.max(0.01, c.spinUp); + const t = Math.max(0, this.age - this._landAge); + + if (!this._locked) { + const ramp = Math.exp(-t / up); + this._rateScale = 1 - ramp; + return t - up * (1 - ramp); + } + + // Seconds of run before the lock fired, and seconds since. + const runFor = Math.max(0, this._lockAge - this._landAge); + const since = Math.max(0, this.age - this._lockAge); + const seize = Math.max(0.01, c.seizeTime); + const rateAtLock = 1 - Math.exp(-runFor / up); + const decay = Math.exp(-since / seize); + const ring = Math.exp(-since / Math.max(0.01, c.shudderDecay)); + const omega = Math.PI * 2 * Math.max(0.01, c.shudderRate); + + this._rateScale = rateAtLock * decay; + return ( + runFor - + up * (1 - Math.exp(-runFor / up)) + + rateAtLock * seize * (1 - decay) + + c.shudder * ring * Math.sin(omega * since) + ); + } + + /** 0..1 — how far gear `i` has broken the surface. */ + _emergenceOf(index) { + if (!this._landed) return 0; + const c = settings.gearlock; + const t = this.age - this._landAge - index * c.riseStagger; + return Easing.outCubic(saturate(t / Math.max(0.01, c.riseTime))); + } + + /** 0..1 — how far the seized train has sunk back into the floor. */ + _sink() { + if (!this._locked) return 0; + const c = settings.gearlock; + const after = this.age - this._lockAge - c.seizeTime; + const span = Math.max(0.05, c.fadeTime - c.seizeTime); + return Easing.inCubic(saturate(after / span)); + } + + /* ------------------------------------------------------------------ */ + /* The train */ + /* ------------------------------------------------------------------ */ + + /** The centre of the aimed circle. A zone cast works outward from `pointAt(1)`. */ + _centrePoint(out) { + return this.pointAt(1, out); + } + + /** + * Re-plant, re-solve, re-centre. Every frame, `dt = 0` included. + * + * `plant()` is re-run rather than called once at spawn, and that is not the + * waste it looks like: it is twelve hashes of a seed this cast captured once, + * so the *same* train comes back every frame — but `gearCount` is read on the + * way in, which is what lets the slider add and remove gears from a standing, + * paused mechanism. `thunder` re-reads `strands` every frame for the same + * reason. + */ + _solveTrain() { + const c = settings.gearlock; + const p = this._trainParams; + + _teeth[0] = Math.max(4, Math.round(c.teethA)); + _teeth[1] = Math.max(4, Math.round(c.teethB)); + _teeth[2] = Math.max(4, Math.round(c.teethC)); + + this._centrePoint(_centre); + p.origin = _centre; + p.direction = this.direction; + p.side = this.side; + // The one place a metre is made: millimetres of pitch diameter per tooth, + // scaled off the circle the player was shown while aiming. + p.module = Math.max(1e-3, c.moduleFrac * Math.max(0.2, c.zoneRadius)); + p.addendum = c.addendum; + p.teeth = _teeth; + p.spin = c.spin; + p.time = this._resolveClock(); + p.phase = c.phase; + p.bearingSpread = c.bearingSpread; + p.bearingBias = c.bearingBias; + p.lift = c.lift; + p.reverse = false; + + this.train.plant(c.gearCount, this._seed); + this.train.solve(p); + + // The solver lays gear 0 on the anchor and marches away from it, so an + // unaltered train hangs off the edge of the aim circle instead of filling + // it. Recentre on the train's own centroid — after the solve, because the + // extent depends on every ratio in it. + const count = this.train.count; + let sumX = 0; + let sumZ = 0; + for (let i = 0; i < count; i++) { + this.train.positionOf(i, p, _pos); + sumX += _pos.x; + sumZ += _pos.z; + } + if (count > 0) { + this._offX = _centre.x - sumX / count; + this._offZ = _centre.z - sumZ / count; + } else { + this._offX = 0; + this._offZ = 0; + } + + // Handedness of the cast basis, recovered the way `GearTrain` recovers it, + // so the world angular velocities the spark solver builds turn the same way + // the gears the player is looking at do. + this._handed = Math.sign(this.direction.z * this.side.x - this.direction.x * this.side.z) || -1; + } + + /** World centre of gear `i`, recentred. */ + _hubOf(index, out) { + this.train.positionOf(index, this._trainParams, out); + out.x += this._offX; + out.z += this._offZ; + return out; + } + + /** + * Rebuild the three profiles if a shape slider moved, then write every + * instance transform and every per-instance heat. + */ + _syncGears() { + const c = settings.gearlock; + + /* --- the profiles --- */ + _shape.pressureAngle = c.pressureAngle; + _shape.addendum = c.addendum; + _shape.dedendum = c.dedendum; + _shape.backlash = c.backlash; + _shape.rootFillet = c.rootFillet; + _shape.flankSteps = Math.round(c.flankSteps); + _shape.tipSteps = Math.round(c.tipSteps); + _shape.rootSteps = Math.round(c.rootSteps); + _shape.thickness = c.thickness; + _shape.chamfer = c.chamfer; + _shape.bore = c.bore; + _shape.boreSegments = Math.round(c.boreSegments); + _shape.boreChamfer = c.boreChamfer; + _shape.lightenHoles = Math.round(c.lightenHoles); + _shape.lightenRadius = c.lightenRadius; + _shape.lightenRing = c.lightenRing; + _shape.lightenSegments = Math.round(c.lightenSegments); + _shape.creaseAngle = c.creaseAngle; + _shape.axis = HardAxis.Y; + + for (let slot = 0; slot < SLOTS; slot++) { + _shape.teeth = _teeth[slot]; + const geometry = this.cache.get(slot, HardShape.GEAR, _shape); + if (this.cache.changed || this.meshes[slot].geometry !== geometry) { + this.meshes[slot].geometry = geometry; + // The instance attribute has to be reattached: the cache handed back a + // brand-new BufferGeometry and disposed the one that was carrying it. + geometry.setAttribute('aHeat', this.heat[slot]); + // Measured against the profile's *actual* outer radius, which is short + // of `pitch + addendum` whenever a tooth has gone pointed. Placing by + // the nominal radius there draws the gear slightly too large and puts + // its pitch circle outside where its neighbour thinks it is, which is + // interpenetration arriving by the back door. + this._pitchFraction[slot] = gearPitchFraction(_shape); + this._slotTeeth[slot] = _shape.teeth; + } + } + + /* --- the instances --- */ + const count = this.train.count; + const sink = this._sink(); + const since = this._locked ? this.age - this._lockAge : 0; + const seizeHeat = this._locked ? c.seizeHeat * Math.exp(-since / Math.max(0.01, c.seizeHeatDecay)) : 0; + + for (let slot = 0; slot < SLOTS; slot++) _slotCount[slot] = 0; + + for (let i = 0; i < count; i++) { + const teeth = this.train.teethOf(i); + let slot = 0; + for (let s = 0; s < SLOTS; s++) { + if (this._slotTeeth[s] === teeth) { + slot = s; + break; + } + } + const at = _slotCount[slot]; + if (at >= MAX_GEARS) continue; + + const diameter = this.train.scaleOf(i, this._pitchFraction[slot]); + const emerge = this._emergenceOf(i); + const buried = diameter * c.thickness + c.riseDepth; + + this._hubOf(i, _pos); + _pos.y = c.lift + lerp(-buried, 0, emerge) - sink * (buried + c.lift); + + _dummy.position.copy(_pos); + _dummy.rotation.set(0, this.train.yawOf(i), 0); + _dummy.scale.setScalar(diameter); + _dummy.updateMatrix(); + this.meshes[slot].setMatrixAt(at, _dummy.matrix); + + // Friction heat, from this gear's own rim speed. `rateOf` is the nominal + // rate; `_rateScale` is the run-up and the seize on top of it. + const rim = Math.abs(this.train.rateOf(i) * this._rateScale) * diameter * 0.5; + const heat = (c.heatGain * saturate(rim / Math.max(0.05, c.heatSpeed)) + seizeHeat) * emerge; + this.heat[slot].array[at] = saturate(heat); + + _slotCount[slot]++; + } + + this._live = 0; + for (let slot = 0; slot < SLOTS; slot++) { + const mesh = this.meshes[slot]; + mesh.count = _slotCount[slot]; + mesh.visible = _slotCount[slot] > 0; + mesh.instanceMatrix.needsUpdate = true; + this.heat[slot].needsUpdate = true; + this._live += _slotCount[slot]; + } + } + + /** Push the live steel settings into the one shared material. */ + _syncMetal() { + const c = settings.gearlock; + const g = settings.global; + const p = this._look; + + p.colorMetal = c.colorMetal; + p.colorDeep = c.colorDeep; + p.colorScale = c.colorScale; + p.colorPolish = c.colorPolish; + p.colorSpec = c.colorSpec; + p.roughness = c.roughness; + p.metalness = c.metalness; + p.envIntensity = c.envIntensity; + p.brush = Math.round(clamp(c.brush, 0, 2)); + p.brushAxisX = 0; + p.brushAxisY = 1; + p.brushAxisZ = 0; + p.anisotropy = c.anisotropy; + p.specular = c.specular; + p.grain = c.grain; + p.grainScale = c.grainScale; + p.grainStretch = c.grainStretch; + p.scale = c.scale; + p.scaleScale = c.scaleScale; + p.scaleSharp = c.scaleSharp; + p.pit = c.pit; + p.pitScale = c.pitScale; + p.wear = c.wear; + p.wearGrain = c.wearGrain; + // The base heat is zero and every gear carries its own in `aHeat`. + p.heat = 0; + p.heatCold = c.heatCold; + p.heatHot = c.heatHot; + p.heatRef = c.heatRef; + p.heatExponent = c.heatExponent; + p.heatGlow = c.heatGlow; + p.heatTint = c.heatTint; + p.heatEdge = c.heatEdge; + p.glow = g.glow; + p.shaderIntensity = g.shaderIntensity; + p.noiseFrequency = g.noiseFrequency; + + syncHardSurfaceMaterial(this.metal, p); + } + + /** The machined bed the train comes up through. */ + _syncBed(grow, fade) { + const c = settings.gearlock; + const g = settings.global; + const p = this._bedParams; + + this._centrePoint(_centre); + p.centre = _centre; + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = 0.015; + p.radius = Math.max(0.2, c.zoneRadius); + p.grow = grow; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.fieldEdge; + p.ragged = c.fieldRagged; + p.raggedScale = c.fieldRaggedScale; + p.warp = c.fieldWarp; + p.relief = c.fieldRelief; + p.cell = c.fieldCell; + p.cellJitter = c.fieldCellJitter; + p.seam = c.fieldSeam; + p.thickness = c.fieldThickness; + p.lift = c.fieldLift; + p.depth = c.fieldDepth; + p.sharp = c.fieldSharp; + p.detail = c.fieldDetail; + p.speed = c.fieldSpeed; + p.parallax = c.fieldParallax; + p.opacity = c.fieldOpacity; + p.emissive = c.fieldEmissive; + p.colorBase = c.colorFieldBase; + p.colorEdge = c.colorFieldEdge; + p.colorGlow = c.colorFieldGlow; + p.colorDeep = c.colorFieldDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + + this.bed.setVisible(fade > 0.001); + this.bed.update(p); + } + + /* ------------------------------------------------------------------ */ + /* The grind */ + /* ------------------------------------------------------------------ */ + + /** + * Solve one tooth contact into `_contact` (where) and `_slide` (the relative + * velocity of the two flanks there), and return the sliding speed. + * + * The point is deliberately **not** the pitch point. `contactOf()` hands back + * the pitch point — the place the pitch circles touch, which divides the + * centre distance in the ratio of the two pitch radii — and at that exact + * point the flanks roll on one another with no sliding at all. Real gear + * scuffing happens away from it, on the approach and recess flanks, and the + * sliding speed there is `(|ω₁| + |ω₂|)·δ` for an offset δ along the common + * tangent. So the jets are struck at `slideOffset` and the speed is measured, + * not assumed: `v₁ − v₂` from two genuine `ω × r` evaluations at the same + * world point. + */ + _contactSolve(index) { + const c = settings.gearlock; + const p = this._trainParams; + + this._hubOf(index - 1, _hubA); + this._hubOf(index, _hubB); + + this.train.contactOf(index, p, _contact); + _contact.x += this._offX; + _contact.z += this._offZ; + + // Common tangent: perpendicular to the line of centres, in the floor plane. + _dir.copy(_hubB).sub(_hubA).setY(0); + if (_dir.lengthSq() < 1e-10) _dir.copy(this.direction); + _dir.normalize(); + _tangent.set(_dir.z, 0, -_dir.x); + _contact.addScaledVector(_tangent, c.slideOffset); + + const rateA = this._handed * this.train.rateOf(index - 1) * this._rateScale; + const rateB = this._handed * this.train.rateOf(index) * this._rateScale; + GrindContact.rimVelocity(_velA, _up, rateA, _contact, _hubA); + GrindContact.rimVelocity(_velB, _up, rateB, _contact, _hubB); + _slide.copy(_velA).sub(_velB); + return _slide.length(); + } + + /** + * Sparks and swarf off every mesh point. + * @param {number} scale 0..1 — thinned as the train dies + */ + _grindFx(dt, scale) { + const c = settings.gearlock; + const g = settings.global; + const count = this.train.count; + if (count < 2) return; + + /* --- pass one: how hard is each contact working --- */ + const reference = Math.max(0.05, c.slideRef); + let duty = 0; + for (let i = 1; i < count; i++) { + _slideSpeed[i] = this._contactSolve(i) * Math.min(this._emergenceOf(i - 1), this._emergenceOf(i)); + duty += saturate(_slideSpeed[i] / reference); + } + const contacts = count - 1; + duty /= contacts; + if (duty <= 1e-4) return; + + const time = frame.uTime.value; + const jets = Math.max(1, Math.round(c.grindJets)); + const sparkTotal = Math.round(this.sparkEmitter.tick(dt, c.sparkRate * duty * scale) * g.particleCount); + const swarfTotal = Math.round(this.swarfEmitter.tick(dt, c.swarfRate * duty * scale) * g.particleCount); + + /* --- pass two: strike them --- */ + const gp = this._grindParams; + gp.lift = c.grindLift; + gp.bounce = c.grindBounce; + gp.rise = c.grindRise; + gp.speedGain = c.grindSpeedGain; + gp.speedFloor = c.grindSpeedFloor; + gp.speedCeiling = c.grindSpeedCeiling; + gp.fan = c.grindFan; + gp.swing = c.grindSwing; + gp.graze = c.grindGraze; + gp.jets = jets; + gp.spread = c.grindSpread; + gp.speedVariance = c.grindVariance; + gp.drift = c.grindDrift; + + for (let i = 1; i < count; i++) { + const share = saturate(_slideSpeed[i] / reference); + if (share <= 1e-4) continue; + const perContact = Math.round((sparkTotal * share) / Math.max(1e-4, duty * contacts)); + if (perContact <= 0 && swarfTotal <= 0) continue; + + this._contactSolve(i); + this.grind.solve(_contact, _up, _slide, gp); + + if (perContact > 0) { + const perJet = Math.max(1, Math.round(perContact / jets)); + for (let j = 0; j < jets; j++) { + this.grind.jet(j, _emit); + _emit.radius = 0.05; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(perJet, _emit); + } + } + + const swarfHere = Math.round((swarfTotal * share) / Math.max(1e-4, duty * contacts)); + if (swarfHere > 0) { + _emit.position = _contact; + _emit.radius = 0.09; + _emit.direction = _dir.copy(_slide).normalize().multiplyScalar(0.6).setY(0.5).normalize(); + _emit.speed = c.swarfSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.swarfLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.swarf.emit(swarfHere, _emit); + } + } + } + + /** Dust off the floor while the gears are still breaking through it. */ + _dustFx(dt) { + const c = settings.gearlock; + const g = settings.global; + const count = this.train.count; + + let breaking = 0; + for (let i = 0; i < count; i++) { + const e = this._emergenceOf(i); + if (e > 0.001 && e < 0.999) breaking++; + } + if (breaking === 0) return; + + const total = Math.round(this.dustEmitter.tick(dt, c.dustRate * breaking) * g.particleCount); + if (total <= 0) return; + + const per = Math.max(1, Math.round(total / breaking)); + const time = frame.uTime.value; + for (let i = 0; i < count; i++) { + const e = this._emergenceOf(i); + if (e <= 0.001 || e >= 0.999) continue; + this._hubOf(i, _pos); + _pos.y = 0.08; + _emit.position = _pos; + _emit.radius = this.train.tipRadiusOf(i) * 1.1; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = time; + this.dust.emit(per, _emit); + } + } + + /** The four particle systems' live gradients and scales. */ + _syncParticles() { + const c = settings.gearlock; + const g = settings.global; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.heatGlow * 0.5 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.2 * g.turbulence; + + this.swarf.setGradient( + getColor(c.colorSwarfA), + getColor(c.colorSwarfB), + getColor(c.colorSwarfC), + getColor(c.colorSwarfD) + ); + this.swarf.uniforms.uGravity.value.set(0, c.swarfGravity, 0); + this.swarf.uniforms.uSizeScale.value = c.swarfSize * g.particleSize * 7; + this.swarf.uniforms.uLifeScale.value = c.swarfLifetime * 0.5 * g.particleLifetime; + this.swarf.uniforms.uSpeedScale.value = g.particleSpeed; + this.swarf.uniforms.uOpacity.value = g.opacity; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sparkEmitter.reset(); + this.swarfEmitter.reset(); + this.dustEmitter.reset(); + + // The only dice this cast rolls, and the only two events it will record. + this._seed = Math.random() * 100; + this._landed = false; + this._landAge = 0; + this._locked = false; + this._lockAge = 0; + this._fade = 1; + this._live = 0; + + this._solveTrain(); + this._syncMetal(); + this._syncParticles(); + this._syncGears(); + this._syncBed(0, 1); + } + + onTravel(dt) { + this._fade = 1; + this._solveTrain(); + this._syncMetal(); + this._syncParticles(); + this._syncGears(); + // The lattice inks itself under the circle while the front is still on its + // way there, so the cast has something to say before the gears arrive. + this._syncBed(this.u, 1); + + // The light sits over the circle rather than on the front: this is a zone + // cast and the circle is where the player is looking. + this._centrePoint(this.position); + this.position.y = settings.gearlock.lift + 0.4; + } + + onImpact() { + const c = settings.gearlock; + const g = settings.global; + + this._landed = true; + this._landAge = this.age; + + this._centrePoint(_centre); + this.ctx.bursts.spawn(BurstMode.EARTH, _centre, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.55, + intensity: c.burstIntensity * 0.7, + opacity: 0.8, + fresnel: 1.2, + displace: 0.5, + squash: 0.5, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.shake.add( + c.lockShake * 0.4 * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.gearlock; + + // `t` runs 0..1 while the train is running and 1..2 while it seizes and + // sinks. The lock is the one-shot at the boundary — a timestamp and a flag, + // which is all a cast is ever allowed to keep. + if (t >= 1 && !this._locked) { + this._locked = true; + this._lockAge = this.age; + this._lockFx(); + } + + this._fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + + this._solveTrain(); + this._syncMetal(); + this._syncParticles(); + this._syncGears(); + this._syncBed(1, this._fade); + + this._grindFx(dt, t <= 1 ? 1 : this._fade); + this._dustFx(dt); + + this._centrePoint(this.position); + this.position.y = c.lift + 0.4; + + if (t <= 1) this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + /** The seize: every contact lets go of its heat at once. */ + _lockFx() { + const c = settings.gearlock; + const g = settings.global; + const count = this.train.count; + const time = frame.uTime.value; + + const jets = Math.max(1, Math.round(c.grindJets)); + const per = Math.max( + 1, + Math.round((c.lockSparks * g.particleCount) / Math.max(1, (count - 1) * jets)) + ); + + const gp = this._grindParams; + gp.lift = c.grindLift; + gp.bounce = c.grindBounce; + gp.rise = c.grindRise; + gp.speedGain = c.grindSpeedGain * 1.8; + gp.speedFloor = c.grindSpeedFloor; + gp.speedCeiling = c.grindSpeedCeiling; + gp.fan = c.grindFan * 1.4; + gp.swing = c.grindSwing * 1.6; + gp.graze = c.grindGraze; + gp.jets = jets; + gp.spread = c.grindSpread * 1.5; + gp.speedVariance = c.grindVariance; + gp.drift = c.grindDrift; + + for (let i = 1; i < count; i++) { + this._contactSolve(i); + this.grind.solve(_contact, _up, _slide, gp); + for (let j = 0; j < jets; j++) { + this.grind.jet(j, _emit); + _emit.radius = 0.1; + _emit.anchor = null; + _emit.size = 0.2; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime * 1.6; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(per, _emit); + } + } + + this._centrePoint(_centre); + this.ctx.bursts.spawn(BurstMode.AIR, _centre, { + radius: c.burstSize * 0.3, + endRadius: c.burstSize * 1.6 * g.explosionIntensity, + life: 0.5, + intensity: c.burstIntensity, + opacity: 0.7, + fresnel: 1.7, + displace: 0.35, + squash: 0.45, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.shake.add( + c.lockShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 28 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.lockFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.4 * g.explosionIntensity; + } + + onDestroy() { + this.train.clear(); + this._live = 0; + this._landed = false; + this._locked = false; + for (let slot = 0; slot < SLOTS; slot++) { + this.meshes[slot].count = 0; + this.meshes[slot].visible = false; + } + this.bed.setVisible(false); + } + + dispose() { + this.bed.dispose(); + // The cache owns every gear geometry; the meshes only borrow them, so this + // is the one place they are freed and no mesh disposes its own. + this.cache.dispose(); + this.metal.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/forge/PistondriveAbility.js b/src/abilities/forge/PistondriveAbility.js new file mode 100644 index 0000000..fe90008 --- /dev/null +++ b/src/abilities/forge/PistondriveAbility.js @@ -0,0 +1,1102 @@ +import { Color, InstancedBufferAttribute, InstancedMesh, Object3D, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { + HardShape, + HardAxis, + BrushMode, + ShapeCache, + pistonShape, + plateShape, + createHardSurfaceMaterial, + hardSurfaceParams, + blackbodyColor, + heatToKelvin +} from '../../vfx/HardSurface.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** + * Hard ceiling on stations. The `pistonCount` slider clamps here, and the + * `GroundField`'s mark ring is sized to match: one port per station, and the + * `POCK` fragment loops over every slot, so this is a fill cost as much as a + * draw-call one. + */ +const MAX_PISTONS = 16; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _station = new Vector3(); +const _crown = new Vector3(); +const _dummy = new Object3D(); +const _tint = new Color(); +const _bb = new Color(); + +/** Live param bags, filled from `settings.pistondrive` every frame. */ +const _hard = hardSurfaceParams(); +const _ground = groundFieldParams(); + +/** The two shapes. Unitless proportions only — refilled, never captured. */ +const _piston = pistonShape({ axis: HardAxis.Y }); +const _deck = plateShape({ axis: HardAxis.Y }); + +/** One sample of the cam: where the follower is, and how hot the work made it. */ +const _cam = { lift: 0, heat: 0 }; + +/** + * PISTON DRIVE — a battery of machined rams bolted into the aimed line. + * + * An arming front runs down the lane and twelve hydraulic pistons come live + * behind it, each one rising through its own bolted deck plate. Then they + * work: dwell, slam, hold, ring, drop, and again, with the strike travelling + * down the row as a wave. When the drive stops they withdraw flush and what is + * left is a line of ports punched into the floor. + * + * ## THE TRICK — a real cam curve, exposed as control points + * + * The roster line for this slot is a warning as much as a brief: *smooth + * easing makes them read as rising rock and kills the ability*. That is not a + * matter of taste, and it is worth being precise about why, because the first + * version of this file did exactly the wrong thing and looked exactly as + * predicted. + * + * The obvious implementation is `GrowthField` with a `PUSH` emergence: an + * eruption clock per instance, a rise time, an overshoot, a settle. Every + * ability in the stone school is built that way and they are all correct. Run + * a piston through it and you get a chrome-plated boulder. The reason is that + * a rise-and-settle curve spends *all* of its time moving — its displacement + * has support everywhere — and a machine's does not. A cam follower spends + * most of a turn doing **nothing at all**, and the nothing is what makes the + * something read as mechanical. + * + * So the motion here is a genuine dwell–rise–dwell–return cam, specified the + * way a cam is specified in a machine shop: as four **angular shares of one + * shaft turn** (`camLow`, `camRise`, `camHigh`, `camFall`), normalised at use + * so each one is an independent slider, plus a motion law inside each moving + * segment. Ship values give the rise 47° of a 360° turn — thirteen per cent of + * the cycle spent moving up, against forty per cent spent doing nothing at all + * at the bottom. See `camRise` in the settings block for why it is not the 22° + * a real pneumatic ram would want. + * + * ``` + * lift + * 1 | ╭────────────╮ + * | ╱ ╲ + * 0 |─────────╯ ╰────── + * └──────────┴──┴──────────────┴──┴───→ one shaft turn + * camLow camRise camHigh camFall + * ``` + * + * `camSnap` and `camDrop` blend between two textbook cam laws rather than + * between two ad-hoc eases, and both were chosen because they satisfy the + * boundary conditions a real follower needs — zero velocity at both ends of a + * segment, so the follower never leaves the cam and the join to the dwell is + * silent: + * + * - **cycloidal**, `s = τ − sin(2πτ)/2π`, the jerk-free law. Continuous + * acceleration, and at `camSnap = 0` the row is unmistakably being politely + * raised. + * - **constant acceleration**, the parabolic law, `2τ²` then `1 − 2(1−τ)²`. + * Its acceleration steps at the midpoint, which is a discontinuity you can + * genuinely see, and it is what makes the thing hit. + * + * On top of the rise sits `camRing`: a damped overshoot in **seconds**, not in + * shaft angle, because a follower on a return spring rings at its own natural + * frequency and does not care how fast the shaft is turning. Folding it into + * `camHigh` was the second version and it was subtly wrong in a way that only + * shows when you drag `camRate` — the ring sped up with the shaft, which no + * spring does. + * + * ### One shaft, twelve keyings + * + * The sequence down the line is **a phase offset on the same curve**, which is + * both the brief and the truthful mechanism: this is one camshaft with twelve + * lobes keyed `camStagger` turns apart. So `phase_i = camRate·age − + * i·camStagger + camPhase`, and there is no per-station clock anywhere in the + * file. Set `camStagger` to 0 and all twelve fire together; set it to + * `1/count` and exactly one station is up at any moment and the wave runs + * clean off the end. + * + * ### The heat is on the cam too + * + * A station's heat is not a timer. `_camSample` returns lift *and* heat from + * the same shaft angle: zero through the low dwell, up across the rise, then + * bleeding off on a real exponential in seconds through the hold and the + * return. It reaches the material as `aHeat`, the per-instance **offset** the + * `HardSurface` material adds to `uHeat` — which is why the attribute is an + * offset rather than an absolute, and why the deck plates, which have no such + * attribute, sit at their own cold value without anybody arranging it. + * + * ## What is on screen + * + * Six draw calls: one `InstancedMesh` of pistons, one of deck plates, a + * `GroundField(POCK)` for the ports, and three particle systems. Both meshes + * share one `ShapeCache` and each has its own patched `MeshStandardMaterial`, + * so the pistons can run at forging heat while the deck stays cold. + * + * ## The rule that makes the editor work + * + * A cast captures three things and none of them is a dimension: `_seed`, + * `_dice` (two unitless rolls per station, so a machine is nearly but not + * quite uniform), and `_up`, a bitfield of which stations are currently above + * the strike threshold — an event record, exactly like `Projectile#arrivals`, + * and re-derived from the live cam every frame rather than remembered. Pause + * mid-drive and drag `camRise`: the whole row re-poses on the new curve, and + * stations whose phase has moved across the threshold fire their strike again, + * which is the behaviour a live editor should have. + */ +export class PistondriveAbility extends Ability { + constructor(context) { + super('pistondrive', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + const environment = this.ctx.environment; + + /** Both shapes, one cache. Ours alone — see `ShapeCache`'s doc comment. */ + this.cache = new ShapeCache({ capacity: 4 }); + + /* --- the pistons: one InstancedMesh, one draw call --- */ + this.metal = createHardSurfaceMaterial({ environment }); + /** + * Per-station heat, as the **offset** the material adds to `uHeat`. + * + * It has to be re-attached after every geometry rebuild, because the cache + * hands back a whole new `BufferGeometry` when a shape slider moves and the + * attribute does not come with it. Cheap: one map write and a dirty flag. + */ + this.heatAttr = new InstancedBufferAttribute(new Float32Array(MAX_PISTONS), 1); + this.pistons = new InstancedMesh( + this.cache.get(0, HardShape.PISTON, this._pistonShape()), + this.metal, + MAX_PISTONS + ); + this.pistons.count = 0; + this.pistons.frustumCulled = false; + this.pistons.receiveShadow = true; + this.pistons.layers.set(LAYER.WORLD); + this.pistons.renderOrder = 2; + this.group.add(this.pistons); + + /* --- the deck plates each one comes up through --- */ + this.deckMetal = createHardSurfaceMaterial({ environment }); + this.decks = new InstancedMesh( + this.cache.get(1, HardShape.PLATE, this._deckShape()), + this.deckMetal, + MAX_PISTONS + ); + this.decks.count = 0; + this.decks.frustumCulled = false; + this.decks.receiveShadow = true; + this.decks.layers.set(LAYER.WORLD); + this.decks.renderOrder = 2; + this.group.add(this.decks); + + /* --- the ports punched in the floor --- */ + this.ports = new GroundField(this.group, { + mode: GroundMode.POCK, + marks: MAX_PISTONS, + additive: false, + name: 'Pistondrive:ports' + }); + this.ports.setVisible(false); + + /** Two unitless rolls per station: a general seed and a size jitter. */ + this._dice = new Float32Array(MAX_PISTONS * 2); + /** Which stations are above the strike threshold. An event record. */ + this._up = new Uint8Array(MAX_PISTONS); + /** How many stations have had their port posted. A cursor. */ + this._ported = 0; + /** The station that struck most recently, so the light has somewhere to be. */ + this._lastStrike = 0; + this._seed = 0; + + /** + * The cast's beats, unitless, refilled every frame. + * + * arm 0..1 how far the arming front has run + * drive 0..1 master on the cam's amplitude — 1 while working, 0 withdrawn + * fade 1..0 master on the ports + */ + this._b = { arm: 0, drive: 0, fade: 1 }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Dust punched out of a port. Non-additive: it has to occlude the piston + // behind it, which is most of what sells the row having depth. + this.dust = particles.get('pistondrive.dust', { + capacity: 1600, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.dust.uniforms.uDrag.value = 2.0; + this.dust.uniforms.uEndSize.value = 2.8; + this.dust.uniforms.uSizeIn.value = 0.1; + this.dust.uniforms.uFadeIn.value = 0.12; + this.dust.uniforms.uFadeOut.value = 0.35; + + // Floor broken out round the port. + this.chips = particles.get('pistondrive.chips', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.3; + this.chips.uniforms.uEndSize.value = 0.75; + this.chips.uniforms.uFadeOut.value = 0.6; + + // Sheared off the lip as the head punches through it. + this.sparks = particles.get('pistondrive.sparks', { + capacity: 1400, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.3; + this.sparks.uniforms.uEndSize.value = 0.2; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.02; + this.sparks.uniforms.uFadeOut.value = 0.45; + + this.ventEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.pistons.count + this.decks.count; + } + + /** The battery cycles for `lifetime`, then withdraws over `fadeTime`. */ + get impactDuration() { + return Math.max(0.05, settings.pistondrive.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.pistondrive.fadeTime); + } + + /** + * The light pulses on the shaft, not on a noise field. + * + * A machine's light is periodic because the machine is. `lightPulseRate` is + * its own slider rather than being wired to `camRate` — they usually want to + * be the same number and occasionally very much do not, and a derived value + * gives you no way to say so. + */ + lightShimmer() { + const c = settings.pistondrive; + const phase = this.age * c.lightPulseRate; + return 1 - saturate(c.lightPulse) * (0.5 - 0.5 * Math.cos(TAU * phase)); + } + + /* ------------------------------------------------------------------ */ + /* Shapes — proportions, refilled from the block every frame */ + /* ------------------------------------------------------------------ */ + + _pistonShape() { + const c = settings.pistondrive; + _piston.length = c.pistonLength; + _piston.segments = Math.max(6, Math.round(c.pistonSegments)); + _piston.baseRadius = c.baseRadius; + _piston.baseHeight = c.baseHeight; + _piston.baseChamfer = c.baseChamfer; + _piston.rodRadius = c.rodRadius; + _piston.collarAt = c.collarAt; + _piston.collarRadius = c.collarRadius; + _piston.collarHeight = c.collarHeight; + _piston.collarChamfer = c.collarChamfer; + _piston.headAt = c.headAt; + _piston.headRadius = c.headRadius; + _piston.headChamfer = c.headChamfer; + _piston.rings = Math.max(0, Math.round(c.rings)); + _piston.ringDepth = c.ringDepth; + _piston.ringHeight = c.ringHeight; + _piston.faceRecess = c.faceRecess; + _piston.creaseAngle = c.pistonCrease; + return _piston; + } + + _deckShape() { + const c = settings.pistondrive; + _deck.width = c.deckWidth; + _deck.depth = c.deckDepth; + _deck.thickness = c.deckThickness; + _deck.corner = c.deckCorner; + _deck.bevel = c.deckBevel; + _deck.bolts = Math.max(0, Math.round(c.deckBolts)); + _deck.boltRadius = c.deckBoltRadius; + _deck.boltInset = c.deckBoltInset; + _deck.counterSink = c.deckSink; + return _deck; + } + + /* ------------------------------------------------------------------ */ + /* THE CAM */ + /* ------------------------------------------------------------------ */ + + /** + * One cam motion law, blended. + * + * @param {number} x 0..1 through the segment + * @param {number} hard 0 cycloidal (jerk-free) … 1 constant acceleration + * @returns {number} displacement, 0..1, with zero velocity at both ends + */ + _camLaw(x, hard) { + const t = saturate(x); + // Cycloidal: s = τ − sin(2πτ)/2π. Continuous acceleration everywhere, + // which is why every high-speed cam in the world uses it and why it is + // useless here on its own. + const cyc = t - Math.sin(TAU * t) / TAU; + // Constant acceleration: two parabolas meeting at the midpoint. The + // acceleration steps there, and the step is the hit. + const par = t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t); + return lerp(cyc, par, saturate(hard)); + } + + /** + * Sample the cam at shaft angle `phase`, in turns. + * + * Fills `_cam` with the follower's displacement (0..1, and above 1 while it + * is ringing) and the heat the stroke put into the station (0..1). Both come + * off the same four control points, which is the point: there is no second + * curve anywhere in this file that could drift out of step with the first. + * + * The four shares are normalised here rather than in the block so that each + * one stays an independent slider — raising `camRise` on its own squeezes + * the other three rather than requiring three compensating drags. + */ + _camSample(phase) { + const c = settings.pistondrive; + + const low = Math.max(0, c.camLow); + const rise = Math.max(1e-4, c.camRise); + const high = Math.max(0, c.camHigh); + const fall = Math.max(1e-4, c.camFall); + const total = low + rise + high + fall; + + // `phase` can be large and negative once `camStagger` is wound up, so the + // wrap has to be a true modulo rather than `% 1`. + let a = (phase - Math.floor(phase)) * total; + + // Seconds per unit of `a`. One full turn is `total` of these units and + // takes `1 / camRate` seconds, so the conversion has to carry `total` — + // and getting that wrong is invisible at the ship values, where the four + // shares happen to sum to exactly 1, and obvious the moment anybody drags + // `camRise`. It cost a confused half-hour. + const perUnit = 1 / (Math.max(Math.abs(c.camRate), 1e-3) * total); + + if (a < low) { + _cam.lift = 0; + // Still cooling from the last stroke — the low dwell is the longest part + // of the cycle and a station that arrives at it cold has already lost the + // rhythm. + const since = (a + fall + high) * perUnit; + _cam.heat = Math.exp(-since * Math.max(c.heatBleed, 0.01)); + return _cam; + } + a -= low; + + if (a < rise) { + const x = a / rise; + _cam.lift = this._camLaw(x, c.camSnap); + // The work goes in on the way up, so the heat follows the displacement + // rather than leading or lagging it. + _cam.heat = _cam.lift; + return _cam; + } + a -= rise; + + if (a < high) { + const since = a * perUnit; + // The ring: a damped overshoot in seconds, on top of a flat dwell. It is + // in seconds and not in shaft angle because a return spring has its own + // natural frequency and does not know how fast the cam is turning. + _cam.lift = + 1 + c.camRing * Math.exp(-since * Math.max(c.camRingDecay, 0.01)) * Math.sin(TAU * c.camRingRate * since); + _cam.heat = Math.exp(-since * Math.max(c.heatBleed, 0.01)); + return _cam; + } + a -= high; + + const x = a / fall; + _cam.lift = 1 - this._camLaw(x, c.camDrop); + const since = (a + high) * perUnit; + _cam.heat = Math.exp(-since * Math.max(c.heatBleed, 0.01)); + return _cam; + } + + /** Shaft angle at station `i`, in turns. One shaft, `camStagger` keying. */ + _phaseOf(i) { + const c = settings.pistondrive; + return c.camRate * this.age - i * c.camStagger + c.camPhase; + } + + /* ------------------------------------------------------------------ */ + /* Layout — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** How many stations are drawn this frame. */ + _count() { + return Math.max(1, Math.min(MAX_PISTONS, Math.round(settings.pistondrive.pistonCount))); + } + + /** Fraction along the cast line of station `i`. */ + _stationAt(i, count) { + return (i + 0.5) / Math.max(1, count); + } + + /** Which rail station `i` sits on: −1 or +1. A machine alternates. */ + _railOf(i) { + return i % 2 === 0 ? -1 : 1; + } + + /** The floor point at the foot of station `i`. */ + _stationPoint(i, count, out) { + const c = settings.pistondrive; + this.pointAt(this._stationAt(i, count), out); + out.addScaledVector(this.side, this._railOf(i) * c.railOffset); + return out; + } + + /** This station's own height in metres, with its share of the jitter. */ + _heightOf(i) { + const c = settings.pistondrive; + return Math.max(0.05, c.pistonHeight * (1 + this._dice[i * 2 + 1] * c.sizeJitter)); + } + + /** + * 0..1 — how live station `i` is. + * + * The arming front is the cast's own front, so a station that the front has + * not reached is flush and inert; `armFeather` is how much of the line it + * takes to come up. The withdraw at the end multiplies straight into it, so + * a station retracting mid-stroke retracts from wherever it was rather than + * snapping down first. + */ + _armOf(i, count) { + const c = settings.pistondrive; + const reach = this._b.arm; + const s = this._stationAt(i, count); + return saturate((reach - s) / Math.max(c.armFeather, 1e-3)) * this._b.drive; + } + + /** + * @param {number} t 0..1 through the impact phase, then 1..2 through the fade + */ + _resolveBeats(t) { + const b = this._b; + if (this.phase === AbilityPhase.TRAVEL) { + b.arm = this.u; + b.drive = 1; + b.fade = 1; + return; + } + if (t <= 1) { + b.arm = 1; + b.drive = 1; + b.fade = 1; + return; + } + const s = saturate(t - 1); + b.arm = 1; + // Cubic in: the battery keeps working almost to the end and then drops + // together, which is a machine being switched off rather than one winding + // down. + b.drive = 1 - Easing.inCubic(s); + b.fade = 1 - Easing.inQuad(s); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.ventEmitter.reset(); + this._ported = 0; + this._lastStrike = 0; + this._up.fill(0); + + this._seed = Math.random() * 100; + for (let i = 0; i < MAX_PISTONS; i++) { + this._dice[i * 2] = Math.random(); + this._dice[i * 2 + 1] = Math.random() * 2 - 1; + } + + this.ports.clearMarks(); + this.ports.setVisible(true); + this.pistons.visible = true; + this.decks.visible = true; + + this._resolveBeats(0); + this._sync(0); + this._seatFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Rebuild every instance from the live block and the live cam. + * + * @param {number} dt seconds; 0 on a paused frame, which must still re-pose + * the row — the strikes it detects are gated on the cam having moved, not + * on the clock having. + */ + _sync(dt) { + const c = settings.pistondrive; + const count = this._count(); + + /* --- geometry, rebuilt only when a proportion actually moved --- */ + const pistonGeometry = this.cache.get(0, HardShape.PISTON, this._pistonShape()); + if (this.pistons.geometry !== pistonGeometry) { + this.pistons.geometry = pistonGeometry; + // The cache hands back a fresh BufferGeometry; the per-station heat is + // ours and has to be put back on it. + pistonGeometry.setAttribute('aHeat', this.heatAttr); + } else if (!pistonGeometry.getAttribute('aHeat')) { + pistonGeometry.setAttribute('aHeat', this.heatAttr); + } + const deckGeometry = this.cache.get(1, HardShape.PLATE, this._deckShape()); + if (this.decks.geometry !== deckGeometry) this.decks.geometry = deckGeometry; + + this.pistons.castShadow = c.pistonShadow === true; + this.decks.castShadow = c.pistonShadow === true; + + const yaw = Math.atan2(this.direction.x, this.direction.z) + c.pistonYaw; + const strikeAt = clamp(c.strikeAt, 0.02, 0.98); + let live = 0; + let deckLive = 0; + let load = 0; + + for (let i = 0; i < count; i++) { + const arm = this._armOf(i, count); + const height = this._heightOf(i); + this._stationPoint(i, count, _station); + + /* --- the deck plate: rises into its recess as the station arms --- */ + if (arm > 0.001) { + const size = Math.max(0.02, c.deckSize); + // Seated, the plate sits `deckLift` below the floor line; unarmed it is + // exactly one plate thickness lower again, which is flush. + const seat = -c.deckLift; + _dummy.position.set(_station.x, lerp(seat - size * c.deckThickness, seat, arm), _station.z); + _dummy.rotation.set(0, yaw, 0); + _dummy.scale.setScalar(size); + _dummy.updateMatrix(); + this.decks.setMatrixAt(deckLive++, _dummy.matrix); + } + + if (arm <= 0.001) { + this._up[i] = 0; + continue; + } + + /* --- the cam --- */ + const cam = this._camSample(this._phaseOf(i)); + const lift = cam.lift * arm; + load += Math.max(0, cam.heat) * arm; + + // A piston whose base is at −H has its crown exactly flush with the + // floor, so `stroke × lift` is literally how far the crown stands proud. + _dummy.position.set(_station.x, -height + c.stroke * lift, _station.z); + _dummy.rotation.set(0, yaw, this._railOf(i) * c.pistonSplay); + _dummy.scale.setScalar(height); + _dummy.updateMatrix(); + this.pistons.setMatrixAt(live, _dummy.matrix); + this.heatAttr.array[live] = c.pistonHeatDrive * saturate(cam.heat) * arm; + live++; + + /* --- the port, posted once per station as it comes live --- */ + if (i >= this._ported) this._postPort(i, count); + + /* --- the strike, re-derived rather than remembered --- */ + const up = cam.lift >= strikeAt ? 1 : 0; + if (up && !this._up[i]) { + this._lastStrike = i; + this._strikeFx(i, _station, height, lift); + } + this._up[i] = up; + } + + this.pistons.count = live; + this.decks.count = deckLive; + this.pistons.instanceMatrix.needsUpdate = true; + this.decks.instanceMatrix.needsUpdate = true; + this.heatAttr.needsUpdate = true; + + this._syncMaterials(); + this._syncPorts(); + this._syncParticles(); + this._ventFx(dt, count, load); + + // The light sits on the crown of whoever hit last. + this._crownOf(Math.min(this._lastStrike, count - 1), count, this.position); + } + + /** The crown of station `i`, in world space. */ + _crownOf(i, count, out) { + const c = settings.pistondrive; + const index = Math.max(0, i); + this._stationPoint(index, count, out); + const cam = this._camSample(this._phaseOf(index)); + out.y = Math.max(0, c.stroke * cam.lift * this._armOf(index, count)); + return out; + } + + /** Both patched materials, pushed from one params bag. */ + _syncMaterials() { + const c = settings.pistondrive; + const g = settings.global; + const p = _hard; + + p.colorMetal = c.colorMetal; + p.colorDeep = c.colorDeep; + p.colorScale = c.colorScale; + p.colorPolish = c.colorPolish; + p.colorSpec = c.colorSpec; + p.roughness = c.steelRough; + p.metalness = c.steelMetalness; + p.envIntensity = c.steelEnv; + + // Circumferential about the piston's own axis, which for `HardAxis.Y` is + // local +Y. A rod and a collar came off a lathe and the grain runs round + // them; brushed straight, the same silhouette is a painted dowel. + p.brush = BrushMode.CIRCUMFERENTIAL; + p.brushAxisX = 0; + p.brushAxisY = 1; + p.brushAxisZ = 0; + p.anisotropy = c.brushAniso; + p.specular = c.brushSpecular; + p.grain = c.brushGrain; + p.grainScale = c.brushGrainScale; + p.grainStretch = c.brushGrainStretch; + + p.scale = c.millScale; + p.scaleScale = c.millScalePatch; + p.scaleSharp = c.millScaleSharp; + p.pit = c.steelPit; + p.pitScale = c.steelPitScale; + p.wear = c.steelWear; + p.wearGrain = c.steelWearGrain; + + // The base of the ramp. `aHeat` adds each station's own stroke heat on top + // of this, which is the whole reason the attribute is an offset. + p.heat = c.pistonHeatIdle; + p.heatCold = c.heatCold; + p.heatHot = c.heatHot; + p.heatRef = c.heatRef; + p.heatExponent = c.heatExponent; + p.heatGlow = c.heatGlow; + p.heatTint = c.heatTint; + p.heatEdge = c.heatEdge; + + p.glow = g.glow; + p.shaderIntensity = g.shaderIntensity; + p.noiseFrequency = g.noiseFrequency; + this.metal.userData.sync(p); + + /* --- the deck: same bag, its own colours, and cold --- */ + p.colorMetal = c.colorDeckMetal; + p.colorDeep = c.colorDeckDeep; + p.colorScale = c.colorDeckScale; + p.colorPolish = c.colorDeckPolish; + p.colorSpec = c.colorDeckSpec; + p.roughness = c.deckRough; + p.metalness = c.deckMetalness; + p.scale = c.deckScale; + p.pit = c.deckPit; + // A rolled plate is brushed straight, along the plate rather than round it. + p.brush = BrushMode.LINEAR; + p.brushAxisX = 0; + p.brushAxisY = 0; + p.brushAxisZ = 1; + p.heat = c.deckHeat; + this.deckMetal.userData.sync(p); + } + + /** The ports: a `GroundField(POCK)` over the whole lane. */ + _syncPorts() { + const c = settings.pistondrive; + const g = settings.global; + const p = _ground; + + // POCK is a disc, and the lane is a line, so the field is anchored at the + // middle of the cast with a radius of half its length. Marks are fractions + // of that radius in the anchor's frame, which is exactly what a station's + // position along the line already is. + this.pointAt(0.5, _crown); + p.centre = _crown; + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.portHeight; + p.radius = Math.max(0.5, this.length * 0.5); + p.length = this.length; + + p.grow = 1; + p.recede = 0; + p.fade = this._b.fade; + p.seed = this._seed; + + p.edge = c.portEdge; + p.ragged = 0; + p.warp = 0; + + p.relief = c.portRelief; + p.normalStep = c.portNormalStep; + p.ambient = c.portAmbient; + p.wrap = c.portWrap; + p.specular = c.portSpecular; + p.gloss = c.portGloss; + p.parallax = c.portParallax; + + p.depth = c.portDepth; + p.lift = c.portLift; + p.thickness = c.portRim; + p.detail = c.portGrain; + // POCK reads `speed` as how fast a crater digs itself in once posted. + p.speed = c.portDig; + p.markLife = c.portLife; + p.markRadius = c.portRadius; + + p.additive = false; + p.emissive = c.portEmissive * g.glow; + p.opacity = c.portOpacity; + p.depthFade = c.portDepthFade; + p.colorBase = c.colorPortBase; + p.colorEdge = c.colorPortEdge; + p.colorGlow = c.colorPortGlow; + p.colorDeep = c.colorPortDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + + this.ports.update(p); + } + + /** + * Post station `i`'s port. + * + * The mark is unitless — a fraction of the radius across and downrange, a + * timestamp, and a strength — so dragging `range` after the fact re-places + * every port along the new lane instead of leaving them where the old one + * was. + */ + _postPort(i, count) { + const c = settings.pistondrive; + const radius = Math.max(0.5, this.length * 0.5); + const z = (this._stationAt(i, count) - 0.5) * this.length; + const x = this._railOf(i) * c.railOffset; + this.ports.mark(x / radius, z / radius, frame.uTime.value, 0.6 + 0.4 * this._dice[i * 2]); + this._ported = i + 1; + } + + /** The three particle systems, re-coloured and re-scaled every frame. */ + _syncParticles() { + const c = settings.pistondrive; + const g = settings.global; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.3 * g.turbulence; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = c.chipLifetime * 0.5 * g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.sparkGlow * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + } + + /** + * The spark tint: the blackbody colour of steel at `sparkHeat`. + * + * `heatToKelvin` reads `heatCold` / `heatHot` off whatever it is handed, and + * the block carries both under exactly those names — so the sparks off a + * port lip are on the same Planckian ramp as the piston that made them, + * without anybody remembering to keep two numbers in step. + */ + _sparkTint() { + const c = settings.pistondrive; + const kelvin = heatToKelvin(c.sparkHeat, c); + _tint.setRGB(1, 1, 1).lerp(blackbodyColor(kelvin, _bb), saturate(c.sparkTemper)); + return _tint; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * One station crossing the strike threshold. + * + * Everything leaves the **port**, not the crown: the dust and the chips are + * the floor being punched, and the sparks are the head shearing past the lip + * on its way through. Throwing them off the head instead was the first + * version and it read as the piston being on fire rather than as the floor + * losing. + */ + _strikeFx(i, point, height, lift) { + const c = settings.pistondrive; + const g = settings.global; + const time = frame.uTime.value; + const rim = Math.max(0.05, c.deckSize * 0.5); + + _pos.copy(point); + _pos.y = 0.06; + + const dustCount = Math.round(c.strikeDust * g.particleCount); + if (dustCount > 0) { + _emit.position = _pos; + _emit.radius = rim; + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + const chipCount = Math.round(c.strikeChips * g.particleCount); + if (chipCount > 0) { + _emit.position = _pos; + _emit.radius = rim * 0.8; + _emit.anchor = null; + // Out and up, away from the rail — the floor breaking outward round a + // punch rather than a fountain out of the middle of it. + _emit.direction = _dir + .copy(this.side) + .multiplyScalar(this._railOf(i) * 0.75) + .setY(0.9) + .normalize(); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = c.chipSpin; + _emit.tint = null; + _emit.time = time; + this.chips.emit(chipCount, _emit); + } + + const sparkCount = Math.round(c.strikeSparks * g.particleCount); + if (sparkCount > 0) { + _emit.position = _pos; + _emit.radius = rim * 0.6; + _emit.anchor = null; + // Along the lip, near-horizontal: the head is shearing past a collar, so + // the sparks leave in the plane of the seal rather than up the shaft. + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.35).setY(0.2).normalize(); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.size = 0.13; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = this._sparkTint(); + _emit.time = time; + this.sparks.emit(sparkCount, _emit); + } + + // The kick scales with how far the follower actually went, so a row set to + // a short stroke does not shake the camera as hard as one set to a long + // one — and `height` is here for the same reason, since a bigger ram hits + // harder. + this.ctx.shake.add( + c.strikeShake * saturate(lift) * (height / Math.max(0.05, c.pistonHeight)) * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + if (c.strikeFlash > 0) { + this.ctx.flash.trigger(getColor(c.colorFlash), c.strikeFlash * g.explosionIntensity); + } + this.lightBoost = c.lightIntensity * 0.45 * g.explosionIntensity; + } + + /** + * The continuous bleed off the whole battery. + * + * `load` is the summed cam heat over every live station, so a row that is + * mostly dwelling breathes and a row that is mostly stroking smokes. It is + * derived from the cam rather than from a rate slider on its own, which is + * what keeps the dust in rhythm with the machine when `camRate` moves. + */ + _ventFx(dt, count, load) { + if (dt <= 0) return; + const c = settings.pistondrive; + const g = settings.global; + const share = load / Math.max(1, count); + const n = Math.round(this.ventEmitter.tick(dt, c.ventRate * share) * g.particleCount); + if (n <= 0) return; + + const i = Math.min(count - 1, Math.floor(Math.random() * count)); + this._stationPoint(i, count, _pos); + _pos.y = 0.08; + + _emit.position = _pos; + _emit.radius = Math.max(0.05, c.deckSize * 0.6); + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 0.5; + _emit.speedVariance = 0.6; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.size = 0.5; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(n, _emit); + } + + /** The battery seating itself as the cast leaves. */ + _seatFx() { + const c = settings.pistondrive; + const g = settings.global; + + this.pointAt(0, _pos); + _pos.y = 0.1; + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.seatSize * 0.2, + endRadius: c.seatSize * g.explosionIntensity, + life: 0.45, + intensity: c.seatIntensity, + opacity: 0.85, + fresnel: 1.7, + displace: 0.5, + squash: 0.55, + colorA: getColor(c.colorSeatA), + colorB: getColor(c.colorSeatB), + colorC: getColor(c.colorSeatC) + }); + + this.ctx.shake.add( + c.castShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.6 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._resolveBeats(0); + this._sync(dt); + this.ctx.shake.rumble(settings.pistondrive.rumble * settings.global.cameraShake, dt); + } + + /** The far end is reached; the whole row is live and the drive settles in. */ + onImpact() { + this._resolveBeats(0); + this._sync(0); + } + + onFade(dt, t) { + this._resolveBeats(t); + this._sync(dt); + if (t <= 1) { + this.ctx.shake.rumble(settings.pistondrive.rumble * settings.global.cameraShake, dt); + } + } + + onDestroy() { + this.pistons.count = 0; + this.decks.count = 0; + this.pistons.visible = false; + this.decks.visible = false; + this.heatAttr.array.fill(0); + this.heatAttr.needsUpdate = true; + this.ports.clearMarks(); + this.ports.setVisible(false); + this._up.fill(0); + this._ported = 0; + this._lastStrike = 0; + } + + dispose() { + // Both geometries belong to the cache; disposing them here would free them + // twice. + this.cache.dispose(); + this.metal.dispose(); + this.deckMetal.dispose(); + this.pistons.dispose(); + this.decks.dispose(); + this.ports.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/forge/QuenchAbility.js b/src/abilities/forge/QuenchAbility.js new file mode 100644 index 0000000..92ea584 --- /dev/null +++ b/src/abilities/forge/QuenchAbility.js @@ -0,0 +1,1066 @@ +import { InstancedMesh, Object3D, InstancedBufferAttribute, DynamicDrawUsage, Vector3, Color } from 'three'; +import { Ability } from '../Ability.js'; +import { + ShapeCache, + HardShape, + HardAxis, + plateShape, + boltShape, + createHardSurfaceMaterial, + syncHardSurfaceMaterial, + hardSurfaceParams, + blackbodyColor +} from '../../vfx/HardSurface.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, hash11, Easing } from '../../utils/math.js'; + +/** Hard ceilings. The editor's counts clamp here; every array below is this long. */ +const MAX_PLATES = 8; +const MAX_BOLTS = 16; +const MAX_PARTS = MAX_PLATES + MAX_BOLTS; + +const TAU = Math.PI * 2; + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _dummy = new Object3D(); +const _hot = new Color(); +const _tint = new Color(); + +/** The two shape objects handed to `ShapeCache`. Refilled, never rebuilt. */ +const _plate = plateShape(); +const _bolt = boltShape(); + +/** + * One evaluation of the cooling curve. Module scope so `_resolveCooling()` can + * call it once per part per frame without allocating a return value. + */ +const _cool = { kelvin: 0, flux: 0, stage: 0 }; + +/** + * `T(t)` and `dT/dt` for one part, in kelvin and kelvin/second. + * + * Three Newton segments — vapour blanket, nucleate boiling, convection — whose + * boundaries are solved rather than authored: the first is a time (`filmTime`), + * the second is a *temperature* (`boilEnd`), and inverting the exponential is + * what turns that temperature back into a time. All closed form, so a paused + * billet re-cools under every slider in the folder. + * + * `scale` is the part's own mass factor. It multiplies all four times together, + * which is the lumped-capacitance result: τ = ρcV/hA, and V/A goes as the + * characteristic length. + * + * @param {object} c settings.quench + * @param {number} elapsed seconds since this part entered the water + * @param {number} scale (size / massRef)^massExponent + * @param {object} out `_cool` + */ +function coolingCurve(c, elapsed, scale, out) { + const bath = c.tempBath; + const start = Math.max(bath + 1, c.tempStart); + const film = Math.max(0, c.filmTime) * scale; + const filmTau = Math.max(0.02, c.filmTau) * scale; + const boilTau = Math.max(0.02, c.boilTau) * scale; + const convTau = Math.max(0.02, c.convectTau) * scale; + const boilEnd = clamp(c.boilEnd, bath + 1, start); + const t = Math.max(0, elapsed); + + /* 1 · the vapour blanket. Slow, because the film insulates. */ + if (t <= film) { + const kelvin = bath + (start - bath) * Math.exp(-t / filmTau); + out.kelvin = kelvin; + out.flux = (kelvin - bath) / filmTau; + out.stage = 0; + return out; + } + + const atCollapse = bath + (start - bath) * Math.exp(-film / filmTau); + + /* 2 · nucleate boiling. The film has gone; water is touching metal. */ + if (atCollapse > boilEnd) { + const boilFor = boilTau * Math.log((atCollapse - bath) / (boilEnd - bath)); + if (t <= film + boilFor) { + const kelvin = bath + (atCollapse - bath) * Math.exp(-(t - film) / boilTau); + out.kelvin = kelvin; + out.flux = (kelvin - bath) / boilTau; + out.stage = 1; + return out; + } + /* 3 · convection. */ + const kelvin = bath + (boilEnd - bath) * Math.exp(-(t - film - boilFor) / convTau); + out.kelvin = kelvin; + out.flux = (kelvin - bath) / convTau; + out.stage = 2; + return out; + } + + // The blanket outlasted the boiling range: straight to convection. + const kelvin = bath + (atCollapse - bath) * Math.exp(-(t - film) / convTau); + out.kelvin = kelvin; + out.flux = (kelvin - bath) / convTau; + out.stage = 2; + return out; +} + +/** + * QUENCH — white-hot stock plunged into a bath. + * + * ## THE TRICK — the cooling curve, and it is physical in two directions + * + * ### The colour comes off the Planckian locus + * + * `vfx/HardSurface.js` carries the real thing: Kim's cubic fit for CIE `x` + * above 1667 K, a locus-fitted quadratic in `1000/T` below it (Kim's runs off + * past the spectral locus down there and cools steel through *magenta*), then + * `xyY → XYZ → linear sRGB`. The emission on top of it is `(T/T_ref)⁴` from + * Stefan-Boltzmann. + * + * The tell that separates this from a four-stop gradient is the yellow, and it + * is worth knowing why. A straight line in RGB from white to orange passes + * through a yellow that is both too saturated and slightly green; the locus + * does not go that way. Steel falling through 1400 K goes **pale straw**. The + * first version of this ability *was* a four-stop gradient — `colorHotA..D` + * over the quench — and the stops always landed slightly wrong: it read as an + * object being lit by something orange rather than as an object making its own + * light. Deleting the four pickers and letting the physics place the yellow is + * the single change that made it convincing, and it is why there is no hot + * colour in `settings.quench` and there must not be one. + * + * The `T⁴` matters just as much as the hue. Because the brightness is a power + * of the temperature rather than a fade curve, **the metal stops glowing on its + * own**. Nothing tells it to. At 900 K it is 0.27 of the reference, at 640 K it + * is 0.07, and by the time the steam has cleared it is a grey lump lying in a + * tank — which is exactly what the long exposure shows. + * + * One honest note on the word *white*: a 1750 K blackbody is orange, with a + * blue channel about a thousandth of its red. The white in a foundry photograph + * is the sensor clipping, and that is how it is reproduced — entry emission is + * 3.8× the reference, which after `heatGlow` and the material's own Reinhard + * ceiling lands near `(3.6, 1.6, 0)` and tone-maps to a yellow-white core with + * an orange skirt. Raising `tempStart` to chase a bluer hue does not work and + * is the first thing anybody tries. + * + * ### The rate comes off the boiling curve + * + * A quench is not one exponential, and treating it as one is why most of them + * read as a dimmer switch. Water against metal that is hundreds of degrees + * above its boiling point cannot wet it: it flashes to vapour and the part + * wears a **film of steam that insulates it**. That is the Leidenfrost stage, + * and it is *slow*. When the surface finally drops below the Leidenfrost point + * the film collapses, water touches steel, and the heat comes out an order of + * magnitude faster — nucleate boiling, the violent stage, the loud one. Below + * the boiling point it is ordinary convection again, and slow. + * + * So the beat of the ability is not authored anywhere. The stock goes in and + * for the better part of a second **almost nothing happens** — it lies there + * white, steaming gently. Then the blanket goes and the bath detonates while + * the colour falls through yellow, orange and cherry inside about a second and + * a half. Then it is over, and the steam thins out while a dull red lump + * finishes going black. + * + * The steam is driven by the **heat flow** `Σ mass · dT/dt`, which is where all + * of that comes from: the same number that decides how fast the colour is + * falling decides how hard the water is boiling. Driving the steam off an + * envelope instead was the other thing the first version did, and it + * desynchronised from the colour the moment anybody touched a slider. See + * `_resolveCooling()` for why it is mass-weighted and not a mean rate — that + * distinction is the difference between a beat and a bump. + * + * ### Mass is the read + * + * Every part carries its own `τ`, scaled by `(size/massRef)^massExponent` — the + * lumped-capacitance result, because τ = ρcV/hA and V/A is a length. The little + * off-cuts are black before the big billet is orange. One material, one curve, + * one `aHeat` attribute; the sizes come out of the picture for free, and with + * `massExponent` at 0 you can watch that read disappear. + * + * It buys a second thing for free: the off-cuts' vapour blankets collapse + * *first*, because their τs are short, so the bath gets a small flurry of steam + * a third of a second before the billets go. Nobody authored that beat and it + * is the best part of the timing. + * + * ## What a cast captures + * + * Per part: five unitless dice rolls (where in the circle, how big, which way + * up, how much it tumbles). Per cast: a seed and the timestamp the stock hit + * the water. That is all. Every kelvin, metre and second is resolved from + * `settings.quench` inside the update loop on every frame, zero-length ones + * included. + * + * ## Budget + * + * Two `InstancedMesh`es (billets, off-cuts), one `VolumeHull(CYLINDER, SMOKE)` + * and one `GroundField(POOL)` — four draw calls, one material, three particle + * systems, one light. + */ +export class QuenchAbility extends Ability { + constructor(context) { + super('quench', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.metal = createHardSurfaceMaterial({ environment: this.ctx.environment }); + this._look = hardSurfaceParams(); + + /** Two slots: the billet and the off-cut. Rebuilt only when a number moves. */ + this.cache = new ShapeCache({ capacity: 2 }); + + this.plates = this._makeMesh(this.cache.get(0, HardShape.PLATE, _plate), MAX_PLATES, 'Quench:billets'); + this.bolts = this._makeMesh(this.cache.get(1, HardShape.BOLT, _bolt), MAX_BOLTS, 'Quench:offcuts'); + + /** + * The steam. A **cylinder**, not a dome: steam off a bath rises in a column + * with a boiling, flattish crown, and a dome arches it over into something + * that reads as an explosion. `steamHeightBias` is authored negative in the + * settings block for the same reason — smoke thins as it climbs and steam + * thickens. + */ + this.steamHull = new VolumeHull({ + hull: HullShape.CYLINDER, + medium: Medium.SMOKE, + prefix: 'steam', + maxSteps: 48, + renderOrder: 12 + }); + this.group.add(this.steamHull.mesh); + + /** The bath. */ + this.bath = new GroundField(this.group, { + mode: GroundMode.POOL, + layer: LAYER.VFX, + name: 'Quench:bath' + }); + this._bathParams = groundFieldParams(); + this.bath.setVisible(false); + + /* --- per-part dice. Unitless, rolled once per cast. --- */ + this._sizeRoll = new Float32Array(MAX_PARTS); + this._radiusRoll = new Float32Array(MAX_PARTS); + this._angleRoll = new Float32Array(MAX_PARTS); + this._yawRoll = new Float32Array(MAX_PARTS); + this._tiltRoll = new Float32Array(MAX_PARTS); + + /* --- resolved every frame --- */ + this._kelvin = new Float32Array(MAX_PARTS); + /** Mass-weighted heat flow per part — the roulette weight for the flakes. */ + this._partPower = new Float32Array(MAX_PARTS); + this._powerTotal = 0; + + this._seed = 0; + this._plunged = false; + this._plungeAge = 0; + /** 0..1 normalised boil rate across the whole bath, this frame. */ + this._boil = 0; + /** The temperature the light and the sparks take their colour from. */ + this._heroKelvin = 300; + this._fade = 1; + this._live = 0; + } + + /** One instanced body: same material, same conventions, different stock. */ + _makeMesh(geometry, capacity, name) { + const mesh = new InstancedMesh(geometry, this.metal, capacity); + mesh.instanceMatrix.setUsage(DynamicDrawUsage); + mesh.frustumCulled = false; + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.layers.set(LAYER.WORLD); + mesh.renderOrder = 2; + mesh.count = 0; + mesh.visible = false; + mesh.name = name; + + // `aHeat` is an OFFSET on the material's `heat`, per instance. An unset + // attribute reads as 0 in WebGL, so an absolute would make anything that + // forgot one ice cold; an offset makes the default harmless. + const heat = new InstancedBufferAttribute(new Float32Array(capacity), 1); + heat.setUsage(DynamicDrawUsage); + geometry.setAttribute('aHeat', heat); + + mesh.userData.heat = heat; + this.group.add(mesh); + return mesh; + } + + createParticles() { + const particles = this.ctx.particles; + + // Steam wisps. Non-additive: steam occludes, it does not add light. + this.steam = particles.get('quench.steam', { + capacity: 2200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.steam.uniforms.uDrag.value = 1.4; + this.steam.uniforms.uEndSize.value = 3.4; + this.steam.uniforms.uSizeIn.value = 0.1; + this.steam.uniforms.uFadeIn.value = 0.12; + this.steam.uniforms.uFadeOut.value = 0.34; + + // Scale flakes popping off the cooling surface. + this.sparks = particles.get('quench.sparks', { + capacity: 1400, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.7; + this.sparks.uniforms.uEndSize.value = 0.2; + this.sparks.uniforms.uSizeIn.value = 0.03; + this.sparks.uniforms.uFadeIn.value = 0.04; + this.sparks.uniforms.uFadeOut.value = 0.5; + + // Water thrown off the boil and out of the plunge. + this.drops = particles.get('quench.drops', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: false, + lit: true, + softFade: 0.3 + }); + this.drops.uniforms.uDrag.value = 0.6; + this.drops.uniforms.uEndSize.value = 0.5; + this.drops.uniforms.uFadeOut.value = 0.55; + + this.steamEmitter = new RateEmitter(); + this.sparkEmitter = new RateEmitter(); + this.dropEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + get impactDuration() { + return Math.max(0.05, settings.quench.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.quench.fadeTime); + } + + /** A quench does not flicker. The intensity is doing all the work. */ + lightShimmer() { + return 0.94 + 0.06 * Math.sin(this.age * 4.3) * Math.sin(this.age * 1.7); + } + + /* ------------------------------------------------------------------ */ + /* The parts */ + /* ------------------------------------------------------------------ */ + + _centrePoint(out) { + return this.pointAt(1, out); + } + + get _plateCount() { + return clamp(Math.round(settings.quench.plateCount), 0, MAX_PLATES); + } + + get _boltCount() { + return clamp(Math.round(settings.quench.boltCount), 0, MAX_BOLTS); + } + + /** Largest extent of part `index`, metres. Unit-space geometry scales by this. */ + _sizeOf(index) { + const c = settings.quench; + const plate = index < MAX_PLATES; + const base = plate ? c.plateSize : c.boltSize; + const jitter = saturate(plate ? c.plateSizeJitter : c.boltSizeJitter); + // The roll is unitless; the metre appears here, this frame, every frame. + return Math.max(0.02, base * (1 - jitter * this._sizeRoll[index])); + } + + /** + * Where part `index` lies in the bath, and how far it has fallen. + * + * `drop` is 0 in the air at `entryHeight` and 1 at rest. It is driven by the + * cast's own front — `this.u` — rather than by a timer, so the stock hits the + * water on the frame the front arrives however far the cast was thrown. + */ + _placeOf(index, drop, out) { + const c = settings.quench; + const radius = Math.max(0.2, c.zoneRadius) * saturate(c.scatter); + // sqrt on the radial roll, or everything piles up in the middle. + const r = radius * Math.sqrt(this._radiusRoll[index]); + const a = this._angleRoll[index] * TAU; + + this._centrePoint(out); + out.x += Math.cos(a) * r; + out.z += Math.sin(a) * r; + out.y = lerp(c.entryHeight, c.restDepth, Easing.inQuad(saturate(drop))); + return out; + } + + /* ------------------------------------------------------------------ */ + /* The curve */ + /* ------------------------------------------------------------------ */ + + /** + * Fill `_kelvin` and `_partPower` for every live part, and derive the three + * numbers the rest of the frame reads off them: `_powerTotal`, `_boil` and + * `_heroKelvin`. + * + * ### The steam is driven by **power**, not by rate + * + * The first version averaged each part's `dT/dt` and it was wrong in a way + * that took a plot to see: a small off-cut has a short τ, so its temperature + * falls fast, so the mean rate was already 0.38 of full steam *before the + * cast had even started boiling* and the collapse of the vapour blanket + * barely moved it. The nine bolts were shouting over the three billets. + * + * A bolt's temperature falls fast and it still boils almost no water, + * because the heat coming out of it is `mc·dT/dt` — mass times rate — and + * its mass is one-fiftieth of the billet's. So the driver is the + * **mass-weighted** sum `Σ (size/massRef)³ · dT/dt`, which is the real heat + * flow into the bath in units of one reference part cooling at one kelvin a + * second. That single change is what gives the ability its beat: the bath is + * quiet at 0.15 while the blanket holds, and pins at 1.0 the moment the + * billet's film collapses. + * + * It also means putting more metal in makes more steam, which is correct and + * which is why `steamRef` is not normalised by the part count. + * + * Called before anything that reads it, on every frame including a + * zero-length one — which is the whole reason `coolingCurve` is closed form. + */ + _resolveCooling() { + const c = settings.quench; + const elapsed = this._plunged ? Math.max(0, this.age - this._plungeAge) : 0; + const reference = Math.max(1, c.steamRef); + const massRef = Math.max(0.02, c.massRef); + const exponent = Math.max(0, c.massExponent); + + const plates = this._plateCount; + const bolts = this._boltCount; + let power = 0; + let hero = c.tempBath; + let heroSize = -1; + + for (let slot = 0; slot < 2; slot++) { + const first = slot === 0 ? 0 : MAX_PLATES; + const count = slot === 0 ? plates : bolts; + for (let n = 0; n < count; n++) { + const index = first + n; + const size = Math.max(0.02, this._sizeOf(index)); + const ratio = size / massRef; + // τ ∝ characteristic length (lumped capacitance, V/A). `massExponent` + // at 0 turns the whole effect off, which is the fastest way to see + // what it is worth. + const scale = Math.pow(ratio, exponent); + coolingCurve(c, elapsed, scale, _cool); + this._kelvin[index] = _cool.kelvin; + // Mass, not count: ratio³ is the volume against the reference part's. + const share = ratio * ratio * ratio * _cool.flux; + this._partPower[index] = share; + power += share; + // The biggest part lights the scene and sets the sparks' temperature. + if (size > heroSize) { + heroSize = size; + hero = _cool.kelvin; + } + } + } + + this._powerTotal = power; + this._boil = saturate(power / reference); + // Before the plunge the stock is still in the air at entry heat. + this._heroKelvin = this._plunged ? hero : Math.max(c.tempBath, c.tempStart); + } + + /** 0..1 heat for the material, from a temperature, matching the shader exactly. */ + _heatOf(kelvin) { + const c = settings.quench; + const cold = c.heatCold; + const hot = Math.max(cold + 1, c.heatHot); + return saturate((kelvin - cold) / (hot - cold)); + } + + /* ------------------------------------------------------------------ */ + /* Sync */ + /* ------------------------------------------------------------------ */ + + /** Rebuild the two profiles if a shape slider moved, then write the instances. */ + _syncParts(drop) { + const c = settings.quench; + + _plate.width = c.plateWidth; + _plate.depth = c.plateDepth; + _plate.thickness = c.plateThickness; + _plate.corner = c.plateCorner; + _plate.bevel = c.plateBevel; + _plate.bolts = Math.round(c.plateBolts); + _plate.boltRadius = c.plateBoltRadius; + _plate.boltInset = c.plateBoltInset; + _plate.counterSink = c.plateCounterSink; + _plate.counterDepth = c.plateCounterDepth; + _plate.creaseAngle = c.plateCrease; + _plate.axis = HardAxis.Y; + + _bolt.length = c.boltLength; + _bolt.headHeight = c.boltHeadHeight; + _bolt.headChamfer = c.boltHeadChamfer; + _bolt.washer = c.boltWasher; + _bolt.washerRadius = c.boltWasherRadius; + _bolt.shankRadius = c.boltShankRadius; + _bolt.threadTurns = Math.round(c.boltThreadTurns); + _bolt.threadDepth = c.boltThreadDepth; + _bolt.threadFrom = c.boltThreadFrom; + _bolt.tipTaper = c.boltTipTaper; + _bolt.creaseAngle = c.boltCrease; + // Off-cuts lie down in the bath rather than standing on end, so the bolt is + // seated across the cast instead of up it. This is a mode, not a dimension. + _bolt.axis = HardAxis.X; + + this._rebuild(this.plates, 0, HardShape.PLATE, _plate); + this._rebuild(this.bolts, 1, HardShape.BOLT, _bolt); + + const plates = this._plateCount; + const bolts = this._boltCount; + // In the air the stock tumbles; in the water it has come to rest. + const tumble = 1 - saturate(drop); + + this._writeInstances(this.plates, 0, plates, drop, tumble); + this._writeInstances(this.bolts, MAX_PLATES, bolts, drop, tumble); + this._live = plates + bolts; + } + + _rebuild(mesh, slot, kind, shape) { + const geometry = this.cache.get(slot, kind, shape); + if (this.cache.changed || mesh.geometry !== geometry) { + mesh.geometry = geometry; + // The cache handed back a fresh BufferGeometry and disposed the one that + // was carrying the instance attribute, so it has to be reattached. + geometry.setAttribute('aHeat', mesh.userData.heat); + } + } + + _writeInstances(mesh, first, count, drop, tumble) { + const c = settings.quench; + const heat = mesh.userData.heat; + + for (let n = 0; n < count; n++) { + const index = first + n; + const size = this._sizeOf(index); + + this._placeOf(index, drop, _pos); + _dummy.position.copy(_pos); + _dummy.rotation.set( + (this._tiltRoll[index] * 2 - 1) * c.entryTilt * tumble + (this._tiltRoll[index] - 0.5) * c.plateLean, + this._yawRoll[index] * TAU, + (this._yawRoll[index] * 2 - 1) * c.entryTilt * tumble + (this._yawRoll[index] - 0.5) * c.plateLean + ); + _dummy.scale.setScalar(size); + _dummy.updateMatrix(); + mesh.setMatrixAt(n, _dummy.matrix); + + heat.array[n] = this._heatOf(this._kelvin[index]); + } + + mesh.count = count; + mesh.visible = count > 0; + mesh.instanceMatrix.needsUpdate = true; + heat.needsUpdate = true; + } + + /** Push the live steel settings into the one shared material. */ + _syncMetal() { + const c = settings.quench; + const g = settings.global; + const p = this._look; + + p.colorMetal = c.colorMetal; + p.colorDeep = c.colorDeep; + p.colorScale = c.colorScale; + p.colorPolish = c.colorPolish; + p.colorSpec = c.colorSpec; + p.roughness = c.roughness; + p.metalness = c.metalness; + p.envIntensity = c.envIntensity; + p.brush = Math.round(clamp(c.brush, 0, 2)); + p.brushAxisX = 0; + p.brushAxisY = 1; + p.brushAxisZ = 0; + p.anisotropy = c.anisotropy; + p.specular = c.specular; + p.grain = c.grain; + p.grainScale = c.grainScale; + p.grainStretch = c.grainStretch; + p.scale = c.scale; + p.scaleScale = c.scaleScale; + p.scaleSharp = c.scaleSharp; + p.pit = c.pit; + p.pitScale = c.pitScale; + p.wear = c.wear; + p.wearGrain = c.wearGrain; + // Zero, because every part carries its own temperature in `aHeat` and the + // whole point is that they do not cool together. + p.heat = 0; + p.heatCold = c.heatCold; + p.heatHot = c.heatHot; + p.heatRef = c.heatRef; + p.heatExponent = c.heatExponent; + p.heatGlow = c.heatGlow; + p.heatTint = c.heatTint; + p.heatEdge = c.heatEdge; + p.glow = g.glow; + p.shaderIntensity = g.shaderIntensity; + p.noiseFrequency = g.noiseFrequency; + + syncHardSurfaceMaterial(this.metal, p); + } + + /** + * The steam column. + * + * `setSize` takes **half-extents in metres and is called every frame** — + * scaling the mesh instead would leave the raymarch's `t` meaning something + * other than metres and the whole medium would drift as the column grew. + */ + _syncSteam(fade) { + const c = settings.quench; + const g = settings.global; + const radius = Math.max(0.2, c.zoneRadius); + // The volume follows the boil, not the clock. `steamCurve` below 1 makes + // the column reach full body early in the nucleate stage and hang there, + // which is what a bath actually does; above 1 it tracks the flux exactly. + const body = Math.pow(saturate(this._boil), Math.max(0.05, c.steamCurve)); + + this._centrePoint(_centre); + _centre.y = c.steamLift; + + this.steamHull + .place(_centre, this.direction) + .setSize(radius * c.steamWidth, radius * c.steamHeight * 0.5, radius * c.steamWidth) + .setFade(body * fade * c.steamFade) + .sync(c, g); + } + + /** The bath. */ + _syncBath(grow, fade) { + const c = settings.quench; + const g = settings.global; + const p = this._bathParams; + + this._centrePoint(_centre); + p.centre = _centre; + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = 0.012; + p.radius = Math.max(0.2, c.zoneRadius); + p.grow = grow; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.fieldEdge; + p.ragged = c.fieldRagged; + p.raggedScale = c.fieldRaggedScale; + p.warp = c.fieldWarp; + p.relief = c.fieldRelief; + p.specular = c.fieldSpecular; + p.gloss = c.fieldGloss; + p.cell = c.fieldCell; + p.thickness = c.fieldThickness; + p.depth = c.fieldDepth; + p.sharp = c.fieldSharp; + p.detail = c.fieldDetail; + p.flow = c.fieldFlow; + p.speed = c.fieldSpeed; + p.windAngle = c.fieldWindAngle; + p.parallax = c.fieldParallax; + p.opacity = c.fieldOpacity; + // The firelight in the water is the boil's, not the clock's — a cold bath + // does not glow, and this is the term that says so. + p.emissive = c.fieldEmissive * (0.25 + 1.6 * saturate(this._boil)); + p.colorBase = c.colorFieldBase; + p.colorEdge = c.colorFieldEdge; + p.colorGlow = c.colorFieldGlow; + p.colorDeep = c.colorFieldDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + + this.bath.setVisible(fade > 0.001); + this.bath.update(p); + } + + _syncParticles() { + const c = settings.quench; + const g = settings.global; + + this.steam.setGradient( + getColor(c.colorSteamA), + getColor(c.colorSteamB), + getColor(c.colorSteamC), + getColor(c.colorSteamD) + ); + this.steam.uniforms.uGravity.value.set(0, c.steamRise, 0); + this.steam.uniforms.uSizeScale.value = c.steamSize * g.particleSize; + this.steam.uniforms.uLifeScale.value = c.steamLifetime * 0.5 * g.particleLifetime; + this.steam.uniforms.uSpeedScale.value = c.steamSpeed * g.particleSpeed; + this.steam.uniforms.uOpacity.value = c.steamOpacity * g.opacity; + this.steam.uniforms.uTurbulence.value = c.steamTurbulence * g.turbulence; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.heatGlow * 0.5 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.25 * g.turbulence; + + this.drops.setGradient( + getColor(c.colorDropA), + getColor(c.colorDropB), + getColor(c.colorDropC), + getColor(c.colorDropD) + ); + this.drops.uniforms.uGravity.value.set(0, c.dropGravity, 0); + this.drops.uniforms.uSizeScale.value = c.dropSize * g.particleSize * 7; + this.drops.uniforms.uLifeScale.value = c.dropLifetime * 0.5 * g.particleLifetime; + this.drops.uniforms.uSpeedScale.value = g.particleSpeed; + this.drops.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Emission */ + /* ------------------------------------------------------------------ */ + + /** + * Steam, scale and spatter, all of it rationed by the boil rate. + * + * Nothing here has an envelope of its own. Three systems, one driver — which + * is why turning `filmTime` up moves the whole eruption later rather than + * sliding the steam out from under the colour. + */ + _boilFx(dt, fade) { + const c = settings.quench; + const g = settings.global; + const boil = saturate(this._boil) * fade; + if (boil <= 1e-3) return; + + const time = frame.uTime.value; + const radius = Math.max(0.2, c.zoneRadius) * saturate(c.scatter); + const parts = this._plateCount + this._boltCount; + + const steamCount = Math.round(this.steamEmitter.tick(dt, c.steamRate * boil) * g.particleCount); + if (steamCount > 0) { + this._centrePoint(_pos); + _pos.y = Math.max(0.02, c.steamLift); + _emit.position = _pos; + _emit.radius = radius * 1.05; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.steamSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.75; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.0; + _emit.sizeVariance = 0.5; + _emit.life = c.steamLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.steam.emit(steamCount, _emit); + } + + const dropCount = Math.round(this.dropEmitter.tick(dt, c.dropRate * boil) * g.particleCount); + if (dropCount > 0) { + this._centrePoint(_pos); + _pos.y = 0.05; + _emit.position = _pos; + _emit.radius = radius; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dropSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.6; + _emit.size = 0.1; + _emit.sizeVariance = 0.8; + _emit.life = c.dropLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.drops.emit(dropCount, _emit); + } + + // Scale flakes. Tinted with the blackbody colour of the part that threw + // them — the same locus the metal is using, because a flake of scale is + // exactly as hot as the surface it came off. Hard-coding an orange here is + // how a cherry-red billet ends up throwing lemon-yellow sparks. + const sparkCount = Math.round(this.sparkEmitter.tick(dt, c.sparkRate * boil) * g.particleCount); + if (sparkCount > 0 && parts > 0) { + const index = this._pickPart(parts); + this._placeOf(index, 1, _pos); + _pos.y = Math.max(_pos.y, 0.03); + blackbodyColor(this._kelvin[index], _hot); + _emit.position = _pos; + _emit.radius = this._sizeOf(index) * 0.5; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.size = 0.14; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = _tint.copy(_hot); + _emit.time = time; + this.sparks.emit(sparkCount, _emit); + _emit.tint = null; + } + } + + /** + * A live part index, drawn in proportion to the heat it is currently putting + * into the water. + * + * Uniformly at random was the first version and it was quietly wrong: the + * off-cuts outnumber the billets three to one, so three-quarters of the + * flakes came off parts that had already gone black. Rouletting on + * `_partPower` puts the flakes on whatever is actually boiling — the off-cuts + * during the early flurry, the billets once their blanket goes — for the cost + * of one loop over at most twenty-four floats. + */ + _pickPart(parts) { + const plates = this._plateCount; + const bolts = this._boltCount; + let ticket = Math.random() * this._powerTotal; + let last = 0; + for (let slot = 0; slot < 2; slot++) { + const first = slot === 0 ? 0 : MAX_PLATES; + const count = slot === 0 ? plates : bolts; + for (let n = 0; n < count; n++) { + const index = first + n; + last = index; + ticket -= this._partPower[index]; + if (ticket <= 0) return index; + } + } + // Everything is stone cold and the sum was zero; any live part will do. + void parts; + return last; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.steamEmitter.reset(); + this.sparkEmitter.reset(); + this.dropEmitter.reset(); + + this._seed = Math.random() * 100; + this._plunged = false; + this._plungeAge = 0; + this._boil = 0; + this._heroKelvin = settings.quench.tempStart; + this._fade = 1; + this._live = 0; + + // Five unitless rolls per part. Not one of them is a metre; every metre + // they eventually become is multiplied on inside the update loop. + for (let i = 0; i < MAX_PARTS; i++) { + this._sizeRoll[i] = hash11(this._seed * 3.11 + i * 7.31); + this._radiusRoll[i] = hash11(this._seed * 5.77 + i * 2.19); + this._angleRoll[i] = hash11(this._seed * 1.93 + i * 9.07); + this._yawRoll[i] = hash11(this._seed * 8.41 + i * 4.53); + this._tiltRoll[i] = hash11(this._seed * 6.29 + i * 1.37); + } + + this._resolveCooling(); + this._syncMetal(); + this._syncParticles(); + this._syncParts(0); + this._syncSteam(0); + this._syncBath(0, 1); + } + + onTravel(_dt) { + this._fade = 1; + this._resolveCooling(); + this._syncMetal(); + this._syncParticles(); + // `this.u` is the fall: the stock is in the air until the front arrives. + this._syncParts(this.u); + this._syncSteam(0); + this._syncBath(this.u, 1); + + // The light rides the falling stock, which is the only thing lit yet. + this._placeOf(0, this.u, this.position); + } + + onImpact() { + const c = settings.quench; + const g = settings.global; + const time = frame.uTime.value; + + this._plunged = true; + this._plungeAge = this.age; + this._resolveCooling(); + + this._centrePoint(_centre); + + this.ctx.bursts.spawn(BurstMode.WATER, _centre, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.75, + fresnel: 1.8, + displace: 0.55, + squash: 0.6, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + const radius = Math.max(0.2, c.zoneRadius) * saturate(c.scatter); + _pos.copy(_centre); + _pos.y = 0.06; + _emit.position = _pos; + _emit.radius = radius * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dropSpeed * 2.4; + _emit.speedVariance = 0.85; + _emit.spread = 0.75; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.13; + _emit.sizeVariance = 0.8; + _emit.life = c.dropLifetime * 1.4; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.drops.emit(Math.round(c.splashDrops * g.particleCount), _emit); + + _emit.speed = c.steamSpeed * 1.6; + _emit.spread = 0.9; + _emit.size = 0.9; + _emit.life = c.steamLifetime; + _emit.spin = 0.4; + this.steam.emit(Math.round(c.splashSteam * g.particleCount), _emit); + + this.ctx.shake.add( + c.plungeShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.plungeFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.quench; + + this._fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + + this._resolveCooling(); + this._syncMetal(); + this._syncParticles(); + this._syncParts(1); + this._syncSteam(this._fade); + this._syncBath(1, this._fade); + this._boilFx(dt, this._fade); + + this._centrePoint(this.position); + this.position.y = Math.max(0.2, c.restDepth + 0.4); + } + + /** + * The dynamic light, overridden — the one place this ability leaves the base + * class's rails, and the reason is the trick. + * + * The base copies `lightColor` straight out of the block. Here the light is + * being thrown by a specific object at a specific temperature, so the colour + * comes off the **same Planckian locus the metal is using** — `blackbodyColor` + * is the CPU mirror of `hardBlackbody` and they agree to the last float. The + * intensity follows the same `(T/T_ref)ⁿ` too, which is what makes the light + * go out with the steel instead of on a fade curve nobody asked for. + * + * I5 is not broken by this: `lightColor` is still an authored picker and + * `lightBlackbody` says how far it is dragged onto the locus, so at 0 the + * behaviour is exactly the base class's. The `T⁴` ceiling exists because + * entry heat is 1660 K against a 1250 K reference and the unclamped term is + * 3.1 — enough to blow the exposure on the frame the stock appears. + */ + _updateLight(dt, scale) { + if (!this.light) return; + const c = settings.quench; + + blackbodyColor(this._heroKelvin, _hot); + this.lightColor.copy(getColor(c.lightColor)).lerp(_hot, saturate(c.lightBlackbody)); + + const emit = Math.pow( + Math.max(this._heroKelvin, 1) / Math.max(c.heatRef, 1), + Math.max(c.heatExponent, 0.1) + ); + const intensity = + c.lightIntensity * scale * Math.min(emit, Math.max(0.01, c.lightCeiling)) * this.lightShimmer() + + this.lightBoost; + + this.ctx.lights.set( + this.light, + this.position, + this.lightColor, + intensity, + c.lightRadius * (1 + this.lightBoost * 0.02), + dt + ); + this.lightBoost = Math.max(0, this.lightBoost - this.lightBoost * 4.5 * dt - 0.5 * dt); + } + + onDestroy() { + this._live = 0; + this._plunged = false; + this._boil = 0; + this._powerTotal = 0; + this.plates.count = 0; + this.plates.visible = false; + this.bolts.count = 0; + this.bolts.visible = false; + this.steamHull.setFade(0); + this.bath.setVisible(false); + } + + dispose() { + this.steamHull.dispose(); + this.bath.dispose(); + // The cache owns both geometries; the meshes only borrow them. + this.cache.dispose(); + this.metal.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/forge/SawlineAbility.js b/src/abilities/forge/SawlineAbility.js new file mode 100644 index 0000000..eff382f --- /dev/null +++ b/src/abilities/forge/SawlineAbility.js @@ -0,0 +1,1202 @@ +import { Color, Matrix4, Mesh, Quaternion, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { + HardShape, + HardAxis, + BrushMode, + ShapeCache, + GrindContact, + sawbladeShape, + plateShape, + createPlateGeometry, + createHardSurfaceMaterial, + hardSurfaceParams, + grindParams, + blackbodyColor, + heatToKelvin +} from '../../vfx/HardSurface.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { Projectile, FlightMode, Stagger, projectileParams } from '../../vfx/Projectile.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on slugs thrown by the last bite. The `offcutCount` slider clamps here. */ +const MAX_OFFCUTS = 24; +/** + * Tessellation of the blade, fixed rather than exposed. + * + * A `SAWBLADE` is about four milliseconds of contour work to build, and every + * *shape* slider rebuilds it through the `ShapeCache`. The tooth count is the + * silhouette and the flank sample count is not — three samples along a + * logarithmic-spiral face is already smooth at any size this ability draws at, + * and raising it only buys vertices and rebuild milliseconds. So these are + * constants with a reason rather than sliders without one. + */ +const FLANK_STEPS = 3; +const GULLET_STEPS = 3; +const SLOT_STEPS = 3; +const ARBOR_SEGMENTS = 20; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hub = new Vector3(); +const _contact = new Vector3(); +const _arc = new Vector3(); +const _rim = new Vector3(); +const _normal = new Vector3(0, 1, 0); +const _spinAxis = new Vector3(); +const _basisX = new Vector3(); +const _basisY = new Vector3(0, 1, 0); +const _basisZ = new Vector3(); +const _basis = new Matrix4(); +const _qBase = new Quaternion(); +const _qSpin = new Quaternion(); +const _qLean = new Quaternion(); +const _tint = new Color(); +const _bb = new Color(); + +/** Live param bags, filled from `settings.sawline` every frame and never kept. */ +const _hard = hardSurfaceParams(); +const _grind = grindParams(); +const _ground = groundFieldParams(); +const _flight = projectileParams(); + +/** + * The blade's shape. Unitless proportions only — `HardSurface` shapes carry no + * metres, so this object is safe to hold across frames: it is *refilled* from + * the block every frame and the `ShapeCache` rebuilds only when a number in it + * actually moved. + */ +const _saw = sawbladeShape({ + axis: HardAxis.X, + flankSteps: FLANK_STEPS, + gulletSteps: GULLET_STEPS, + slotSteps: SLOT_STEPS, + arborSegments: ARBOR_SEGMENTS +}); + +/** The offcut slug's shape. Same rules. */ +const _slug = plateShape({ bolts: 0, axis: HardAxis.Y }); + +/** + * SAWLINE — a machined blade dropped into the floor and run down the aimed line. + * + * A 1.4-metre circular saw lands at the caster's feet, buries a quarter of a + * metre of rim in the stone and tracks out to the far end of the cast, cutting + * a kerf and throwing a continuous sheaf of sparks. It grinds in place for + * `lifetime`, hurls a handful of glowing offcuts downrange on the last bite, + * then dives into its own cut and is gone. + * + * ## THE TRICK — the sparks leave at the contact tangent + * + * A saw that throws sparks radially is a firework. It is also what you get for + * free from every particle system in this project, because "emit in a cone + * away from the surface" is the default and it is wrong here in a way that is + * instantly readable: a dandelion instead of an angle grinder. + * + * Real grinding sparks are lumps of the workpiece that were travelling with + * the tooth at the moment it let go of them. They carry off the *tooth's* + * velocity, and a tooth at the rim is moving tangentially by definition. So + * the emission direction here is `ω × r`, evaluated at the contact: + * `GrindContact.rimVelocity(out, axis, rate, point, centre)` with the spin + * axis being the cast's own `side` vector, and the speed is + * `grindGain × |ω × r|` clamped between a floor and a ceiling rather than an + * authored metres-per-second. Every one of those is a live number, so + * doubling `bladeSpin` while paused genuinely doubles how hard the sparks + * leave, and flipping its **sign** throws the whole sheaf from behind the + * blade to in front of it. + * + * Two corrections sit on top of the bare cross product, both inside + * `GrindContact` and both sliders here: + * + * - the component of `ω × r` pointing *into* the floor is reflected back out + * with a restitution (`grindBounce`), because sparks do not tunnel; + * - the whole sheaf is tilted off the surface by `grindRise`, which is the + * rooster tail every photograph of a grinder shows. + * + * ### Where the contact is, and why it is not simply "under the hub" + * + * The first version struck the sparks from the point on the floor directly + * below the hub. That looks fine in a still and is wrong in motion, because at + * bottom dead centre `ω × r` is exactly horizontal: the sheaf came out flat, + * skated along the ground and never arced. The blade is a circle cutting a + * plane, so the real contact is a **chord** — the arc between where a tooth + * enters the stone and where it leaves — and the tangent has a genuine + * vertical component everywhere along it except the middle. + * + * So `contactPhase` walks the strike point along that chord: −1 at the entry + * edge, 0 at bottom dead centre, +1 at the exit edge. The half-chord is + * `√(R² − hubY²)`, which falls straight out of the blade radius and how deep + * it is buried — both live — and it goes to zero the instant the rim clears + * the floor, which is exactly when the sparks should stop. That is why the + * blade's exit works without a single "stop emitting" flag: it lifts out of + * the chord and there is nothing left to grind. + * + * The rim velocity is computed at the true point on the arc, but the sparks + * are *emitted* from that point raised to floor level: a spark born 20 cm down + * inside a kerf is a spark nobody sees, and the stream reads as thinning out + * for no reason. + * + * ### The blade is not a `Projectile`, and that was tried + * + * `Projectile`'s `ROLL` mode is real rolling — the contact point is + * instantaneously at rest, the angle is distance over radius and nothing else. + * It is the obviously right module and it is the wrong answer here, because + * rolling *ties the rim speed to the travel speed*. A blade advancing at 11 + * m/s would have a rim doing 11 m/s, the sparks would leave at walking pace, + * and the one number the whole slot is about would stop being a number at all. + * A cutting blade slips: it spins two orders of magnitude faster than it + * advances, and that decoupling is the ability. So the blade is a plain `Mesh` + * whose orientation this file owns — one draw call, and `bladeSpin` is free to + * be its own signed radians-per-second. + * + * `Projectile` still earns its place, on the offcuts: eight slugs on a + * parametric arc with vertex-shader trails, staggered launches, and an + * `arrivals` event per landing that fires its own spark burst and scorch. + * + * ## The brushing + * + * `HardSurface`'s material is set to `BrushMode.CIRCUMFERENTIAL` about the + * blade's own local axis, which for a `SAWBLADE` seated on `HardAxis.X` is + * local **+X**. That is not a detail: the grain on a surface-ground plate runs + * *round* it, and the anisotropic lobe smears the highlight across the grain + * into an arc that swings as the blade turns. With `LINEAR` brushing the same + * geometry, the same albedo and the same heat read as a painted disc. + * + * ## The rule that makes the editor work + * + * A cast captures two things and both are events: `_seed`, so two casts do not + * wander the same way, and `_markDistance`, a cursor over how much of the kerf + * has already been recorded. Every metre, radian and second — the blade's diameter, + * how deep it bites, where on the chord it strikes, how fast the rim is going, + * the spark speed that falls out of it, the depth of the kerf and the flight of + * the offcuts — is resolved from `settings.sawline` inside the update loop, on + * a zero-length frame included. Pause mid-cut and drag `contactPhase`: the + * sheaf swings from the back of the blade to the front, because the contact is + * a function of the slider rather than something that was integrated while you + * were watching. + * + * Seven draw calls: the blade, the kerf, the offcut bodies, their trails, and + * three particle systems. + */ +export class SawlineAbility extends Ability { + constructor(context) { + super('sawline', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + const environment = this.ctx.environment; + + /** + * The blade's geometry cache. One slot, and it is *ours* — a shared cache + * would have to reference-count, and the first thing that would happen is + * somebody else's rebuild freeing the geometry this mesh is drawing. + */ + this.cache = new ShapeCache({ capacity: 2 }); + + /* --- the blade: one Mesh, one draw call --- */ + this.metal = createHardSurfaceMaterial({ environment }); + this.blade = new Mesh(this.cache.get(0, HardShape.SAWBLADE, this._bladeShape()), this.metal); + this.blade.frustumCulled = false; + this.blade.layers.set(LAYER.WORLD); + this.blade.renderOrder = 2; + this.blade.receiveShadow = true; + this.group.add(this.blade); + + /* --- the kerf it cuts --- */ + this.kerf = new GroundField(this.group, { + mode: GroundMode.RUT, + marks: 16, + additive: false, + name: 'Sawline:kerf' + }); + this.kerf.setVisible(false); + + /* --- the offcuts thrown by the last bite --- */ + // Their own material, not the blade's: a slug is cut *floor*, and giving + // it the blade's five pickers would be deriving one colour from another + // (I5). It is also brushed LINEAR rather than circumferential, because a + // torn-off lump has a rolling direction and no lathe ever touched it. + this.slag = createHardSurfaceMaterial({ environment }); + this.offcuts = new Projectile(this.group, { + geometry: () => createPlateGeometry(this._slugShape()), + shapeKey: () => this._slugKey(), + material: this.slag, + capacity: MAX_OFFCUTS, + trail: true, + trailNodes: 24, + trailAdditive: true, + layer: LAYER.WORLD, + renderOrder: 2, + castShadow: false + }); + + /** The solver that turns a contact into spark jets. THE TRICK lives here. */ + this.grind = new GrindContact(); + /** True on the frames the rim is actually inside the floor. */ + this._cutting = false; + + /** Re-rolled per cast so two blades do not wander the same way. */ + this._seed = 0; + /** Metres of travel already recorded as kerf contact samples. A cursor. */ + this._markDistance = 0; + + /** + * The cast's beats, all unitless, refilled every frame. + * + * cut 0..1 how far the rim has sunk to its full depth + * dive 0..1 how far through the exit it is + * fade 1..0 master opacity on the kerf + */ + this._b = { cut: 0, dive: 0, fade: 1 }; + } + + createParticles() { + const particles = this.ctx.particles; + + // The sparks. Velocity-stretched streaks under a hard gravity, which is + // what turns a straight tangential jet into the drooping arc a grinder + // actually throws. + this.sparks = particles.get('sawline.sparks', { + capacity: 4000, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.1; + this.sparks.uniforms.uEndSize.value = 0.2; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.02; + this.sparks.uniforms.uFadeOut.value = 0.5; + + // Cold chips of floor kicked out of the kerf. Lit rather than additive: + // they have to read as rubble against the sparks, not as more sparks. + this.grit = particles.get('sawline.grit', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.35; + this.grit.uniforms.uEndSize.value = 0.7; + this.grit.uniforms.uFadeOut.value = 0.65; + + // Stone dust off the cut. Non-additive so it genuinely occludes the sparks + // behind it, which is most of what sells the depth of the kerf. + this.smoke = particles.get('sawline.smoke', { + capacity: 1400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.smoke.uniforms.uDrag.value = 1.8; + this.smoke.uniforms.uEndSize.value = 2.6; + this.smoke.uniforms.uSizeIn.value = 0.14; + this.smoke.uniforms.uFadeIn.value = 0.18; + this.smoke.uniforms.uFadeOut.value = 0.32; + + this.sparkEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return 1 + this.offcuts.count; + } + + /** The blade grinds in place at the far end, then dives out. */ + get impactDuration() { + return Math.max(0.05, settings.sawline.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.sawline.fadeTime); + } + + /** + * The light stutters at roughly tooth rate. + * + * A saw does not gutter the way a bolt does — the cut is continuous and the + * only thing modulating it is the teeth going past. So this is a quantised + * step rather than a smooth wave, hashed off the step index so it snaps + * between levels instead of oscillating between two of them. + */ + lightShimmer() { + const c = settings.sawline; + const step = Math.floor(this.age * Math.max(1, c.lightFlickerSpeed)); + const noise = Math.abs(Math.sin(step * 91.7) * 43758.5453) % 1; + return 1 - saturate(c.lightFlicker) * noise; + } + + /* ------------------------------------------------------------------ */ + /* Shapes — proportions, refilled from the block every frame */ + /* ------------------------------------------------------------------ */ + + /** The blade's outline. Not one metre in here; see the settings header. */ + _bladeShape() { + const c = settings.sawline; + _saw.teeth = Math.max(6, Math.round(c.teeth)); + _saw.rake = c.toothRake; + _saw.clearance = c.toothClearance; + _saw.gullet = c.toothGullet; + _saw.tipLand = c.toothLand; + _saw.thickness = c.bladeThickness; + _saw.chamfer = c.bladeChamfer; + _saw.arbor = c.bladeArbor; + _saw.slots = Math.max(0, Math.round(c.bladeSlots)); + _saw.slotDepth = c.slotDepth; + _saw.slotWidth = c.slotWidth; + _saw.creaseAngle = c.bladeCrease; + return _saw; + } + + /** One offcut slug's outline. */ + _slugShape() { + const c = settings.sawline; + _slug.width = c.slugWidth; + _slug.depth = c.slugDepth; + _slug.thickness = c.slugThickness; + _slug.corner = c.slugCorner; + _slug.bevel = c.slugBevel; + return _slug; + } + + /** + * The slug's rebuild key. + * + * `Projectile#syncGeometry` stringifies whatever this returns and compares + * it, so it has to fold every number that changes the outline into one + * value. A number rather than a template literal: at fifty abilities a + * per-frame template literal is fifty strings a frame for the collector. + */ + _slugKey() { + const c = settings.sawline; + return ( + c.slugWidth * 1e0 + c.slugDepth * 1e3 + c.slugThickness * 1e6 + c.slugCorner * 1e9 + c.slugBevel * 1e12 + ); + } + + /* ------------------------------------------------------------------ */ + /* Geometry of the cut — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Half the blade's diameter, metres. */ + _radius() { + return Math.max(0.05, settings.sawline.bladeDiameter * 0.5); + } + + /** + * How far off the cast line the blade has drifted at `distance` metres out. + * + * Two octaves, not one. A single sine is a slalom — a perfectly periodic + * left-right that reads as an animation curve the moment the camera settles. + * The second, incommensurate term turns it into a drift, which is what a + * heavy thing cutting into uneven stone actually does. + */ + _wanderAt(distance) { + const c = settings.sawline; + const a = distance * c.bladeWanderScale * TAU + this._seed; + return (Math.sin(a) * 0.65 + Math.sin(a * 2.3 + 1.7) * 0.35) * c.bladeWander; + } + + /** How far down the line the blade is, 0..1. */ + _reach() { + return this.phase === AbilityPhase.TRAVEL ? this.u : 1; + } + + /** + * The hub, in world space. + * + * The height is the whole state machine in one line: a blade whose rim sits + * `bite` below the floor has its hub at `R − bite`, and the exit simply keeps + * subtracting until the hub is more than a radius under and there is no + * intersection left to grind. + */ + _hubPoint(out) { + const c = settings.sawline; + const b = this._b; + const s = this._reach(); + this.pointAt(s, out); + out.addScaledVector(this.side, this._wanderAt(s * this.length)); + out.y = this._radius() - c.bladeBite * b.cut - c.bladeExit * b.dive; + return out; + } + + /** + * Resolve the contact and hand it to `GrindContact`. + * + * @returns {boolean} false when the rim is not in the floor at all — which is + * true before the blade has sunk and again once it has dived through. + */ + _solveGrind() { + const c = settings.sawline; + const R = this._radius(); + this._hubPoint(_hub); + const hubY = _hub.y; + + // No chord: the rim is clear of the floor, or the hub is a full radius + // under it and the blade has vanished into its own cut. + if (Math.abs(hubY) >= R) return false; + + // The engagement chord. Half of it is √(R² − hubY²) — the offset from the + // hub, along the heading, to where the circle crosses y = 0. + const half = Math.sqrt(Math.max(0, R * R - hubY * hubY)); + const phase = clamp(c.contactPhase, -1, 1); + const dx = half * phase; + + // The true point on the arc, which is what ω × r must be evaluated at. + _arc.copy(_hub).addScaledVector(this.direction, dx); + _arc.y = hubY - Math.sqrt(Math.max(0, R * R - dx * dx)); + + // The spin axis is the cast's side vector, rolled by the blade's lean. The + // chord above is measured on the upright circle; at the small leans this + // slider is for the difference is under a percent, and correcting it would + // cost an ellipse solve for something nobody can see. + _spinAxis.copy(this.side).applyAxisAngle(this.direction, c.bladeLean).normalize(); + GrindContact.rimVelocity(_rim, _spinAxis, c.bladeSpin, _arc, _hub); + + // Emit from the mouth of the kerf, not from the bottom of it. See the + // class doc: a spark born inside the slot is a spark nobody sees. + _contact.copy(_arc); + _contact.y = Math.max(_contact.y, 0); + + _normal.set(0, 1, 0); + this._fillGrind(); + this.grind.solve(_contact, _normal, _rim, _grind); + return true; + } + + /** `GrindContact`'s params, straight off the block. */ + _fillGrind() { + const c = settings.sawline; + const g = settings.global; + _grind.lift = c.grindLift; + _grind.bounce = c.grindBounce; + _grind.rise = c.grindRise; + _grind.speedGain = c.grindGain; + // Metres per second, and NOT scaled by `global.particleSpeed` — the + // particle shader multiplies that in once already, and doing it here as + // well makes the global a square. + _grind.speedFloor = c.grindFloor; + _grind.speedCeiling = c.grindCeiling; + _grind.fan = c.grindFan; + _grind.swing = c.grindSwing; + _grind.graze = c.grindGraze; + _grind.jets = Math.max(1, Math.round(c.grindJets)); + _grind.spread = c.grindSpread; + _grind.speedVariance = c.grindVariance * g.randomness; + _grind.drift = c.grindDrift; + } + + /** + * Refill the beats from the phase clock. Fractions only. + * + * @param {number} t 0..1 through the impact phase, then 1..2 through the fade + */ + _resolveBeats(t) { + const c = settings.sawline; + const b = this._b; + + if (this.phase === AbilityPhase.TRAVEL) { + b.cut = saturate(this.u / Math.max(c.bladeBiteIn, 1e-3)); + b.dive = 0; + b.fade = 1; + return; + } + if (t <= 1) { + b.cut = 1; + b.dive = 0; + b.fade = 1; + return; + } + const s = saturate(t - 1); + b.cut = 1; + // Quadratic in: the blade hangs in the cut for a beat and then goes, + // rather than sliding out at a constant rate like a lift. + b.dive = Easing.inQuad(s); + b.fade = 1 - Easing.inQuad(s); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sparkEmitter.reset(); + this.gritEmitter.reset(); + this.smokeEmitter.reset(); + this._markDistance = 0; + this._cutting = false; + + // The one dice roll a cast makes. + this._seed = Math.random() * 100; + + this.offcuts.reset(); + this.offcuts.roll(this._seed); + this.kerf.clearMarks(); + this.kerf.setVisible(true); + this.blade.visible = true; + + this._resolveBeats(0); + this._sync(); + this._dropFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live block into everything that draws. + * + * Order matters in one place: `_solveGrind()` runs first and everything + * downstream reads the hub and contact it left in the scratch vectors, so + * the blade, the sparks, the kerf's lit lip and the dynamic light cannot + * disagree about where the cut is by a frame. + */ + _sync() { + this._cutting = this._solveGrind(); + this._syncBlade(); + this._syncKerf(); + this._syncOffcuts(); + this._syncParticles(); + + // The light rides the contact while there is one, and the hub once the + // blade has left the floor. + this.position.copy(this._cutting ? _contact : _hub); + } + + /** + * The blade: geometry from the cache, orientation from three rotations. + * + * `q = lean · spin · base`, read right to left. `base` takes the blade out of + * its own frame (local +X is the arbor axis, seated by `HardAxis.X`) into the + * cast's frame; `spin` turns it about that axis; `lean` rolls the whole thing + * about the heading so it is not dead square to the floor. + * + * The spin angle is `rate × age` in closed form rather than an integrated + * accumulator, which is the difference between a slider that re-phases a + * blade already in the ground and one that only affects the next cast. + */ + _syncBlade() { + const c = settings.sawline; + const g = settings.global; + + this.blade.geometry = this.cache.get(0, HardShape.SAWBLADE, this._bladeShape()); + this.blade.castShadow = c.bladeShadow === true; + + const diameter = Math.max(0.1, c.bladeDiameter); + this.blade.position.copy(_hub); + // The unit blade is seated on y = 0 with the hub at half its diameter, so + // the mesh's origin belongs a radius below the hub. + this.blade.position.y -= diameter * 0.5; + this.blade.scale.setScalar(diameter); + + _basisX.copy(this.side); + _basisY.set(0, 1, 0); + _basisZ.crossVectors(_basisX, _basisY); + _basis.makeBasis(_basisX, _basisY, _basisZ); + _qBase.setFromRotationMatrix(_basis); + _qSpin.setFromAxisAngle(this.side, c.bladeSpin * this.age); + _qLean.setFromAxisAngle(this.direction, c.bladeLean); + this.blade.quaternion.copy(_qLean).multiply(_qSpin).multiply(_qBase); + + /* --- the steel --- */ + const p = _hard; + p.colorMetal = c.colorMetal; + p.colorDeep = c.colorDeep; + p.colorScale = c.colorScale; + p.colorPolish = c.colorPolish; + p.colorSpec = c.colorSpec; + p.roughness = c.steelRough; + p.metalness = c.steelMetalness; + p.envIntensity = c.steelEnv; + + // The one line that carries the machined read. See the class doc. + p.brush = BrushMode.CIRCUMFERENTIAL; + p.brushAxisX = 1; + p.brushAxisY = 0; + p.brushAxisZ = 0; + p.anisotropy = c.brushAniso; + p.specular = c.brushSpecular; + p.grain = c.brushGrain; + p.grainScale = c.brushGrainScale; + p.grainStretch = c.brushGrainStretch; + + p.scale = c.millScale; + p.scaleScale = c.millScalePatch; + p.scaleSharp = c.millScaleSharp; + p.pit = c.steelPit; + p.pitScale = c.steelPitScale; + p.wear = c.steelWear; + p.wearGrain = c.steelWearGrain; + + // Heat follows engagement, not the clock: a blade that is not touching + // anything is not making heat, and lifting it out cools it on the way. + p.heat = lerp(c.bladeHeatIdle, c.bladeHeat, saturate(this._b.cut) * (this._cutting ? 1 : 0)); + p.heatCold = c.heatCold; + p.heatHot = c.heatHot; + p.heatRef = c.heatRef; + p.heatExponent = c.heatExponent; + p.heatGlow = c.heatGlow; + p.heatTint = c.heatTint; + p.heatEdge = c.heatEdge; + + p.glow = g.glow; + p.shaderIntensity = g.shaderIntensity; + p.noiseFrequency = g.noiseFrequency; + this.metal.userData.sync(p); + + /* --- the slugs' steel: same bag, its own colours and its own heat --- */ + p.colorMetal = c.colorSlagMetal; + p.colorDeep = c.colorSlagDeep; + p.colorScale = c.colorSlagScale; + p.colorPolish = c.colorSlagPolish; + p.colorSpec = c.colorSlagSpec; + p.roughness = c.slagRough; + p.metalness = c.slagMetalness; + p.scale = c.slagScale; + p.pit = c.slagPit; + // A torn lump has a rolling direction, not a turned one. + p.brush = BrushMode.LINEAR; + p.brushAxisX = 0; + p.brushAxisY = 1; + p.brushAxisZ = 0; + p.heat = c.slagHeat; + p.heatGlow = c.slagHeatGlow; + p.heatTint = c.slagHeatTint; + this.slag.userData.sync(p); + } + + /** The kerf: a `GroundField(RUT)` running the length of the cast. */ + _syncKerf() { + const c = settings.sawline; + const g = settings.global; + const b = this._b; + const p = _ground; + + p.centre = this.origin; + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.kerfHeight; + // For `RUT` the radius sizes the quad *across* the track rather than + // drawing anything, so it has to cover the gouge, the spoil and the drift. + p.radius = c.kerfWidth + c.kerfThickness * 3 + c.kerfWander; + p.length = this.length; + + p.grow = 1; + p.recede = 0; + p.progress = this._reach(); + p.fade = b.fade; + p.seed = this._seed; + + p.edge = c.kerfEdge; + p.ragged = 0; + p.raggedScale = c.kerfWanderScale * g.noiseFrequency; + p.warp = 0; + p.swirl = c.kerfWander; + + p.relief = c.kerfRelief; + p.normalStep = c.kerfNormalStep; + p.ambient = c.kerfAmbient; + p.wrap = c.kerfWrap; + p.specular = c.kerfSpecular; + p.gloss = c.kerfGloss; + p.parallax = c.kerfParallax; + + p.width = c.kerfWidth; + p.depth = c.kerfDepth; + p.lift = c.kerfLift; + p.sharp = c.kerfSharp; + p.thickness = c.kerfThickness; + p.seam = c.kerfSeam; + p.detail = c.kerfDetail; + + p.markLife = c.kerfMarkLife; + p.markRadius = c.kerfMarkRadius; + + p.additive = false; + p.emissive = c.kerfEmissive * g.glow; + p.opacity = c.kerfOpacity; + p.depthFade = c.kerfDepthFade; + p.colorBase = c.colorKerfBase; + p.colorEdge = c.colorKerfEdge; + p.colorGlow = c.colorKerfGlow; + p.colorDeep = c.colorKerfDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + + this.kerf.update(p); + } + + /** + * The offcuts. + * + * The basis is pinned to the cast line and the launch point is the far end + * of it, which is where the blade is by the time any of these exist. That + * pinning is deliberate: `Projectile` re-resolves every body's launch and + * landing from the basis on every frame, so a basis that chased the moving + * blade would drag every slug already in the air along with it — the arcs + * would slide sideways and the whole volley would read as a smear. + */ + _syncOffcuts() { + const c = settings.sawline; + const g = settings.global; + const p = _flight; + + p.mode = FlightMode.ARC; + p.stagger = Stagger.RIPPLE; + p.count = this.phase === AbilityPhase.TRAVEL ? 0 : Math.min(MAX_OFFCUTS, Math.round(c.offcutCount)); + p.radius = c.offcutRadius; + p.sizeJitter = c.offcutJitter * g.randomness; + p.stretch = c.offcutStretch; + p.align = c.offcutAlign; + p.spin = c.offcutSpin * g.randomness; + p.flash = c.offcutFlash; + + p.handForward = this.length; + p.handSide = this._wanderAt(this.length); + p.handHeight = Math.max(0.06, this._radius() - c.bladeBite); + p.landHeight = c.offcutRadius * 0.5; + p.landInZone = false; + p.spreadSide = c.offcutSpreadSide; + p.spreadForward = c.offcutSpreadForward; + + p.pathCurve = c.offcutCurve; + p.apex = c.offcutApex; + p.apexCurve = c.offcutApexCurve; + p.weaveSide = 0; + p.weaveUp = 0; + + p.flightTime = c.offcutFlight; + p.speedJitter = c.offcutFlightJitter * g.randomness; + p.lead = c.offcutLead; + p.window = c.offcutWindow; + p.linger = c.offcutLinger; + p.sink = c.offcutSink; + + p.trailSpan = c.trailSpan; + p.trailBurn = c.trailBurn; + p.trailWidth = c.trailWidth; + p.trailTaper = c.trailTaper; + p.trailLift = c.trailLift; + p.trailOpacity = c.trailOpacity * g.opacity; + p.trailGlow = c.trailGlow * g.glow; + p.trailCore = c.trailCore; + p.trailHeadBias = c.trailHeadBias; + p.trailNoise = c.trailNoise * g.noiseStrength; + p.trailNoiseScale = c.trailNoiseScale * g.noiseFrequency; + p.trailNoiseSpeed = c.trailNoiseSpeed * g.noiseSpeed; + p.trailSoftFade = c.trailSoftFade; + + this.offcuts.setBasis(this.origin, this.direction, this.side, this.length + c.offcutThrow); + this.offcuts.setTrailColors(c.colorTrailA, c.colorTrailB, c.colorTrailC, c.colorTrailD); + // Seconds since the last bite. Both counters are the base class's own, so + // nothing here has to remember a timestamp. + this.offcuts.update(this.impactTime + this.fadeTime, p); + } + + /** The three particle systems, re-coloured and re-scaled every frame. */ + _syncParticles() { + const c = settings.sawline; + const g = settings.global; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.sparkGlow * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.18 * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = c.gritLifetime * 0.5 * g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /** + * The spark tint: the blackbody colour of steel at `sparkHeat`. + * + * Sparks off hot metal are the same temperature as the metal, so hard-coding + * an orange here would be the one thing in the whole slot that is not + * derived from the same physics as the blade. `sparkTemper` decides how far + * that tint is allowed to overrule the authored four-stop gradient — the + * gradient is the spark *cooling over its own life*, the tint is the + * temperature it left the wheel at, and both are wanted. + */ + _sparkTint() { + const c = settings.sawline; + // `heatToKelvin` reads `heatCold`/`heatHot` off whatever it is handed, and + // the block carries both under exactly those names — so the sparks and the + // blade are on one ramp by construction rather than by convention. + const kelvin = heatToKelvin(c.sparkHeat, c); + _tint.setRGB(1, 1, 1).lerp(blackbodyColor(kelvin, _bb), saturate(c.sparkTemper)); + return _tint; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * THE TRICK, emitted. + * + * Every jet the solver hands back is one `emit()` call with a direction that + * came out of `ω × r`. The fan is spread deterministically across the sheaf + * rather than randomly — a random fan re-rolls its shape every frame and + * shimmers, where a fixed fan full of random particles reads as one + * continuous stream, which is what a grinder throws. + * + * @param {number} scale 0..1 — thinned as the blade lifts out + */ + _grindFx(dt, scale) { + if (!this._cutting) return; + const c = settings.sawline; + const g = settings.global; + + const total = Math.round(this.sparkEmitter.tick(dt, c.sparkRate * scale) * g.particleCount); + if (total <= 0) return; + + const jets = this.grind.jets; + const per = Math.max(1, Math.round(total / jets)); + const tint = this._sparkTint(); + const time = frame.uTime.value; + + for (let j = 0; j < jets; j++) { + // `jet()` fills position, direction, speed, spread, speedVariance and + // inherit, and points its vectors at the solver's own scratch. The rest + // is ours, and every field is written because `_emit` is shared. + this.grind.jet(j, _emit); + _emit.radius = c.grindLift; + _emit.anchor = null; + _emit.size = 0.14; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = tint; + _emit.time = time; + this.sparks.emit(per, _emit); + } + } + + /** + * What the cut throws that is not on fire: chips and dust. + * + * The chips leave along the *reversed* tangent — they are the heavy end of + * the same stream, too massive to be flung and mostly shouldered aside — and + * the dust comes straight up out of the kerf mouth. + * + * @param {number} scale 0..1 + */ + _spoilFx(dt, scale) { + if (!this._cutting) return; + const c = settings.sawline; + const g = settings.global; + const time = frame.uTime.value; + + const gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount); + if (gritCount > 0) { + _dir.copy(this.grind.direction).multiplyScalar(-0.45).setY(0.85).normalize(); + _emit.position = _contact; + _emit.radius = c.kerfWidth * 1.6; + _emit.anchor = null; + _emit.direction = _dir; + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = c.gritSpin; + _emit.tint = null; + _emit.time = time; + this.grit.emit(gritCount, _emit); + } + + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * scale) * g.particleCount); + if (smokeCount > 0) { + _pos.copy(_contact); + _pos.y = 0.1; + _emit.position = _pos; + _emit.radius = c.kerfWidth * 2.5; + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.65; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + } + + /** Contact samples posted into the kerf as the blade advances. */ + _kerfFx() { + const c = settings.sawline; + const step = 1 / Math.max(0.05, c.biteRate); + while (this.front - this._markDistance >= step) { + this._markDistance += step; + const s = saturate(this._markDistance / this.length); + // Unitless: a fraction along the track, a timestamp, and a load. The + // gouge re-scales under `kerfDepth` because nothing here carries a metre. + this.kerf.mark(0, s, this.age, saturate(this._b.cut)); + } + } + + /** The blade landing and taking its first bite. */ + _dropFx() { + const c = settings.sawline; + const g = settings.global; + + this._hubPoint(_pos); + _pos.y = 0.05; + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.dropSize * 0.25, + endRadius: c.dropSize * g.explosionIntensity, + life: 0.35, + intensity: c.dropIntensity, + opacity: 0.8, + fresnel: 1.8, + displace: 0.45, + squash: 0.6, + colorA: getColor(c.colorDropA), + colorB: getColor(c.colorDropB), + colorC: getColor(c.colorDropC) + }); + + _emit.position = _pos; + _emit.radius = 0.14; + _emit.anchor = null; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.5).setY(0.6).normalize(); + _emit.speed = Math.max(c.grindFloor, Math.abs(c.bladeSpin) * this._radius() * c.grindGain); + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = this._sparkTint(); + _emit.time = frame.uTime.value; + this.sparks.emit(Math.round(c.dropSparks * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._resolveBeats(0); + this._sync(); + this._kerfFx(); + this._grindFx(dt, 1); + this._spoilFx(dt, 1); + this.ctx.shake.rumble(settings.sawline.rumble * settings.global.cameraShake, dt); + } + + /** The last bite: the blade stalls at the end of the line and throws slugs. */ + onImpact() { + const c = settings.sawline; + const g = settings.global; + + this._resolveBeats(0); + this._solveGrind(); + + this.ctx.bursts.spawn(BurstMode.EARTH, _contact, { + radius: c.biteSize * 0.2, + endRadius: c.biteSize * g.explosionIntensity, + life: 0.6, + intensity: c.biteIntensity, + opacity: 0.9, + fresnel: 1.6, + displace: 0.6, + squash: 0.7, + colorA: getColor(c.colorBiteA), + colorB: getColor(c.colorBiteB), + colorC: getColor(c.colorBiteC) + }); + + // The one-shot sheaf. Fired through the solver like every other spark, so + // the big burst leaves along the same tangent the stream has been using — + // a radial burst here would undo the whole slot in one frame. + if (this._cutting) { + const jets = this.grind.jets; + const per = Math.max(1, Math.round((c.biteSparks * g.particleCount) / jets)); + const tint = this._sparkTint(); + const time = frame.uTime.value; + for (let j = 0; j < jets; j++) { + this.grind.jet(j, _emit); + _emit.radius = c.grindLift * 2; + _emit.anchor = null; + _emit.size = 0.2; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime * 1.5; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = tint; + _emit.time = time; + this.sparks.emit(per, _emit); + } + } + + this.ctx.shake.add( + c.biteShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 24 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.biteFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onFade(dt, t) { + this._resolveBeats(t); + this._sync(); + + const b = this._b; + // Thinned as the blade withdraws. `_cutting` has already gone false by the + // time it is a radius under, so this only shapes the last few frames. + const scale = 1 - Easing.outQuad(b.dive); + this._grindFx(dt, scale); + this._spoilFx(dt, scale * 0.7); + + if (t <= 1) { + this._kerfFx(); + this.ctx.shake.rumble(settings.sawline.rumble * settings.global.cameraShake, dt); + } + + this._landOffcuts(); + } + + /** + * Slugs that touched down this frame. + * + * `arrivals` is re-derived from the flight rather than remembered, so + * dragging `offcutFlight` while paused can put a landed slug back in the air + * and land it again — which is exactly the behaviour a live editor wants, + * and the reason nothing here keeps a landed list of its own. + */ + _landOffcuts() { + const count = this.offcuts.arrivalCount; + if (count <= 0) return; + const c = settings.sawline; + const g = settings.global; + const tint = this._sparkTint(); + const time = frame.uTime.value; + + for (let i = 0; i < count; i++) { + this.offcuts.landPoint(this.offcuts.arrivals[i], _pos); + + this.ctx.decals.spawn(DecalType.SCORCH, _pos, { + radius: c.scorchRadius * randRange(0.7, 1.3), + life: c.scorchLife, + intensity: c.scorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorEmber), + height: 0.015 + }); + + _emit.position = _pos; + _emit.radius = c.offcutRadius; + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1; + _emit.inherit = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime * 0.8; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = tint; + _emit.time = time; + this.sparks.emit(Math.round(c.offcutLandSparks * g.particleCount), _emit); + } + } + + onDestroy() { + this.offcuts.reset(); + this.kerf.clearMarks(); + this.kerf.setVisible(false); + this.blade.visible = false; + this._cutting = false; + this._markDistance = 0; + } + + dispose() { + // The blade's geometry belongs to the cache; disposing it here would free + // it twice. + this.cache.dispose(); + this.metal.dispose(); + this.slag.dispose(); + this.offcuts.dispose(); + this.kerf.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/forge/ShrapnelAbility.js b/src/abilities/forge/ShrapnelAbility.js new file mode 100644 index 0000000..b8e17d0 --- /dev/null +++ b/src/abilities/forge/ShrapnelAbility.js @@ -0,0 +1,1102 @@ +import { Mesh, Vector3, Quaternion, Object3D, Color } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { ShatterField, ShatterLayout } from '../../vfx/ShatterField.js'; +import { + HardShape, + ShapeCache, + plateShape, + boltShape, + createHardSurfaceMaterial, + syncHardSurfaceMaterial, + heatToKelvin, + blackbodyColor +} from '../../vfx/HardSurface.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, Easing } from '../../utils/math.js'; + +/** Hard ceiling on fragments in the air. The editor's `fragCount` clamps here. */ +const CAPACITY = 96; +/** Two silhouettes — a torn plate and a bolt — so two draw calls, and no more. */ +const VARIANTS = 2; +/** + * Craters the floor carries. + * + * One per fragment would be ninety-six `vec4`s in a fixed trip count, which is + * the most expensive fragment shader in the project for a mark you cannot pick + * out of a crowd. Fourteen is enough that the ring of first bounces reads as a + * ring; the rest of the fragments bounce just as hard and simply do not sign + * the floor. + */ +const POCK_MARKS = 14; +/** `ShapeCache` slots: the two fragment kinds and the canister. */ +const SLOT_PLATE = 0; +const SLOT_BOLT = 1; +const SLOT_CANISTER = 2; + +/* --- module-scope scratch: nothing below allocates on a frame (I3) --- */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _land = new Vector3(); +const _contact = new Vector3(); +const _outgoing = new Vector3(); +const _tint = new Color(); +/** Params objects, rewritten whole every frame and never cached between them. */ +const _hard = {}; +const _pock = { centre: new Vector3() }; +const _throw = { origin: new Vector3(), direction: new Vector3(), side: new Vector3(), centre: new Vector3() }; + +/* --- scratch belonging to the field's own solver --- */ +const _dummy = new Object3D(); +const _vel = new Vector3(); +const _axis = new Vector3(); +const _spin = new Quaternion(); +const _up = new Vector3(0, 1, 0); +const _bearing = new Vector3(); +const _wild = new Vector3(); + +/** + * A `ShatterField` whose fragments **bounce**. + * + * ### Why this is a subclass and not a params flag + * + * `ShatterField`'s flight is a closed form — position is a pure function of + * `now − born` against the live params — and that is exactly right, because it + * is what lets a paused `gravity` re-fly everything already in the air. It also + * ends at a `Math.min` against the floor: a fragment reaches the ground, stops + * dead, and keeps `floorSpin` of its tumble. For ice and for stone that is the + * truth. For machined steel on flagstone it is the one thing that makes a + * burst read as a shatter, and the roster's brief for this slot is precisely + * that difference. + * + * The bounce cannot be a parameter of the existing solution, because a bounce + * is a *piecewise* trajectory and the analytic drag form + * `p(t) = p₀ + (v₀ − g/k)(1 − e^{−kt})/k + (g/k)t` does not survive being cut + * into segments — the exponential has to restart at every contact, and there + * is no closed form for the contact time once it does. So drag is dropped and + * the flight is piecewise ballistic instead, which *is* solvable exactly: + * + * ``` + * ½g·τ² + v_y·τ + (y − F) = 0 ⇒ τ = (−v_y − √(v_y² − 2g(y−F))) / g + * ``` + * + * with `g < 0`, `y ≥ F`, and therefore a discriminant that is never negative + * and a root that is never non-positive. At each contact the vertical speed + * flips and is scaled by `restitution`, the horizontal by `1 − friction`, and + * the tumble rate by `tumbleKeep`. Past `bounces` contacts the next one is + * treated as perfectly inelastic; below `stopSpeed` the piece settles and + * slides out its remaining speed on an exponential `slide`. + * + * Everything above is re-evaluated from the live params on every frame, so + * dragging `restitution` on a paused cast genuinely re-flies every fragment + * through a different set of bounces — which an integrator could not do, and + * which is the same reason the base class is written the way it is. + * + * ### The tumble, and why `update()` is overridden too + * + * The base computes the tumble as `rate · age`, with `rate` dropped to + * `floorSpin` on any frame the fragment is touching down. With a bounce in + * play the fragment touches down repeatedly, so that rate flickers between two + * values and the *angle* — being the rate times the whole age — jumps by + * several radians each time. The fragments visibly teleport their orientation + * at every contact. So the solver accumulates its own **effective spin time** + * (`_turns`), damped once per contact, and `update()` uses `rate · turns`, + * which is continuous across a bounce by construction. That is the roster's + * "a tumble that survives the bounce", and it is worth knowing it is not free. + */ +class RicochetField extends ShatterField { + constructor(parent, options) { + super(parent, options); + /** Effective spin seconds for the last fragment `_flight()` solved. */ + this._turns = 0; + } + + /** Un-shrunk body size of a fragment, metres. */ + _baseSizeOf(record, p) { + const jitter = p.randomness ?? 1; + return Math.max(0.001, (p.size ?? 0.25) * (1 + record.sizeRoll * (p.sizeJitter ?? 0) * jitter)); + } + + /** + * The height a resting fragment's **centre** sits at, metres above `floor`. + * + * The base class applies this as a clamp after the flight, which flattens the + * bottom of every bounce by the same amount and makes the low arcs skip + * rather than bounce. Folding it into the solver's floor instead means the + * contact happens where the fragment actually touches. + */ + _seatOf(record, p) { + return this._baseSizeOf(record, p) * (p.seat ?? 0.34); + } + + /** + * Launch velocity: radially outward from the burst, at `elevation`, at a + * speed **fitted to the zone radius**. + * + * The base throws every fragment along the cast direction, which is what a + * lance breaking wants. A canister does not have a direction; it has a + * bearing per fragment, and `record.angle` is already the bearing + * `_anchorOf` placed that fragment on. Ballistic range for a launch at `θ` + * is `R = v²·sin(2θ)/g`, so inverting it puts the first touchdown on the + * circle the player aimed at — see the class comment on the ability. + */ + _velocityOf(record, p, out) { + const jitter = p.randomness ?? 1; + const spread = saturate(p.spread ?? 0.3); + const elevation = clamp(p.elevation ?? 0.62, 0.03, 1.5); + + const angle = record.angle * Math.PI * 2; + _bearing.set(Math.cos(angle), 0, Math.sin(angle)); + out.copy(_bearing).multiplyScalar(Math.cos(elevation)).addScaledVector(_up, Math.sin(elevation)); + + _wild.set(record.dirX, record.dirY, record.dirZ); + out.lerp(_wild, spread); + if (out.lengthSq() < 1e-8) out.copy(_up); + out.normalize(); + + const g = Math.abs(p.gravity ?? -20); + const range = Math.max(p.range ?? 4, 0.05); + // sin(2θ) collapses at a vertical or a grazing launch; the floor keeps the + // fitted speed finite rather than letting the sheaf reach the horizon. + const fitted = Math.sqrt((range * g) / Math.max(Math.sin(2 * elevation), 0.06)); + const speed = fitted * (p.speedScale ?? 1) * (1 + record.speedRoll * (p.speedJitter ?? 0) * jitter); + return out.multiplyScalar(speed); + } + + /** + * The piecewise ballistic with a real restitution at every contact. + * + * Writes `out` and, as a side effect, `this._turns` — the effective spin + * seconds this fragment has accumulated, damped once per bounce. The side + * channel exists because the base's contract is `_flight(record, p, t, out)` + * and adding a fifth argument would break `positionOf()`, which the ability + * uses to hang sparks off a contact. + */ + _flight(record, p, t, out) { + this._anchorOf(record, p, out); + this._velocityOf(record, p, _vel); + + const floorY = (p.floor ?? 0) + this._seatOf(record, p); + const g = Math.min(p.gravity ?? -20, -0.01); + const bounce = saturate(p.restitution ?? 0.45); + const mu = saturate(p.friction ?? 0.3); + const keep = saturate(p.tumbleKeep ?? 0.82); + const maxBounces = Math.max(0, Math.round(p.bounces ?? 4)); + const stop = Math.max(p.stopSpeed ?? 0.6, 0.02); + const slide = Math.max(p.slide ?? 3, 0); + const ground = saturate(p.groundSpin ?? 0.24); + + let x = out.x; + let y = Math.max(out.y, floorY); + let z = out.z; + let vx = _vel.x; + let vy = _vel.y; + let vz = _vel.z; + let remain = Math.max(0, t); + let rate = 1; + let turns = 0; + let hits = 0; + + // Bounded: `maxBounces` contacts plus one dead landing plus the guard. + for (let step = 0; step <= maxBounces + 1; step++) { + const rel = Math.max(y - floorY, 0); + const disc = vy * vy - 2 * g * rel; + const hit = (-vy - Math.sqrt(Math.max(disc, 0))) / g; + if (!(hit > 1e-6)) break; // sitting on the floor with nothing left + + if (hit >= remain) { + x += vx * remain; + z += vz * remain; + y += vy * remain + 0.5 * g * remain * remain; + turns += rate * remain; + remain = 0; + break; + } + + x += vx * hit; + z += vz * hit; + y = floorY; + const into = vy + g * hit; // <= 0, the speed it arrives with + vx *= 1 - mu; + vz *= 1 - mu; + turns += rate * hit; + rate *= keep; + remain -= hit; + hits++; + + const rebound = hits > maxBounces ? 0 : -bounce * into; + if (rebound < stop) { + vy = 0; + break; + } + vy = rebound; + } + + if (remain > 0) { + // Settled: it slides out what is left of its along-floor speed. The + // exponential is the one place drag survives, and here it is exact + // because the segment has no contact in it. + const decay = slide > 1e-3 ? (1 - Math.exp(-slide * remain)) / slide : remain; + x += vx * decay; + z += vz * decay; + y = floorY; + turns += rate * ground * decay; + } + + this._turns = turns; + return out.set(x, y, z); + } + + /** + * Where and when a fragment first touches, and how it leaves. + * + * The first segment of the loop above, lifted out so the ability can hang a + * crater and a spark spray off the contact. Returns the time since birth, or + * `-1` for a dead slot or a fragment that never comes down. + * + * @param {number} index record index + * @param {object} p the same live params `update()` is given + * @param {THREE.Vector3} outPoint the contact, world + * @param {THREE.Vector3} outVel the velocity leaving it, world + */ + firstBounce(index, p, outPoint, outVel) { + const record = this.records[index]; + if (record.born < 0) return -1; + + this._anchorOf(record, p, outPoint); + this._velocityOf(record, p, outVel); + + const floorY = (p.floor ?? 0) + this._seatOf(record, p); + const g = Math.min(p.gravity ?? -20, -0.01); + const rel = Math.max(outPoint.y - floorY, 0); + const disc = outVel.y * outVel.y - 2 * g * rel; + const hit = (-outVel.y - Math.sqrt(Math.max(disc, 0))) / g; + if (!(hit > 1e-6)) return -1; + + outPoint.x += outVel.x * hit; + outPoint.z += outVel.z * hit; + outPoint.y = floorY; + + const into = outVel.y + g * hit; + const mu = saturate(p.friction ?? 0.3); + outVel.x *= 1 - mu; + outVel.z *= 1 - mu; + outVel.y = -saturate(p.restitution ?? 0.45) * into; + return hit; + } + + /** + * The base's frame loop, with two changes: the floor clamp is gone (the + * solver owns the floor now) and the tumble reads `_turns` instead of `age`. + * + * Everything else — the slot arithmetic, the parking of dead records, the + * contiguous `mesh.count` — is deliberately identical, because it is the + * part that has to agree with `burst()` and `clear()`. + */ + update(now, p) { + const variants = this.variants; + const used = this._used; + used.fill(0); + + const lifetime = Math.max(0.02, p.lifetime ?? 1.4); + const jitter = p.randomness ?? 1; + const shrink = saturate(p.shrink ?? 0); + const shrinkPower = p.shrinkPower ?? 1.6; + let live = 0; + + for (let i = 0; i < this.capacity; i++) { + const record = this.records[i]; + const variant = i % variants; + const slot = (i / variants) | 0; + const age = record.born < 0 ? -1 : now - record.born; + const life = age / lifetime; + + if (age < 0 || life >= 1) { + if (record.born >= 0 && life >= 1) record.born = -1; + _dummy.position.set(0, -999, 0); + _dummy.quaternion.identity(); + _dummy.scale.setScalar(0.0001); + _dummy.updateMatrix(); + this.meshes[variant].setMatrixAt(slot, _dummy.matrix); + this.lifeAttributes[variant].array[slot] = 1; + used[variant] = Math.max(used[variant], slot + 1); + continue; + } + + const size = this._baseSizeOf(record, p) * (1 - shrink * Math.pow(life, shrinkPower)); + + this._flight(record, p, age, _dummy.position); + + const rate = (p.spin ?? 0) * (1 + record.spinRoll * (p.spinJitter ?? 0) * jitter); + _axis.set(record.axisX, record.axisY, record.axisZ); + if (_axis.lengthSq() < 1e-8) _axis.copy(_up); + _axis.normalize(); + _spin.setFromAxisAngle(_axis, rate * this._turns); + + _dummy.quaternion.copy(_spin); + _dummy.scale.setScalar(Math.max(size, 0.0005)); + _dummy.updateMatrix(); + + this.meshes[variant].setMatrixAt(slot, _dummy.matrix); + this.lifeAttributes[variant].array[slot] = life; + used[variant] = Math.max(used[variant], slot + 1); + live++; + } + + for (let v = 0; v < variants; v++) { + this.meshes[v].count = used[v]; + this.meshes[v].instanceMatrix.needsUpdate = true; + this.lifeAttributes[v].needsUpdate = true; + } + + this._live = live; + return live; + } +} + +/** + * SHRAPNEL BLOOM — a machined canister is thrown at the circle and lets go. + * + * **THE TRICK — ricochet.** Every other shatter in this project ends with its + * fragments stopping dead where they land, and that is the tell: a burst and a + * shatter look identical up to the moment of contact, and completely different + * for the second after it. So the fragments here reflect about the floor + * normal with a real `restitution`, lose `friction` of their along-floor speed + * at every contact, keep `tumbleKeep` of their tumble *through* the bounce, + * and only settle once a rebound falls under `stopSpeed`. The whole flight is + * still closed-form and still re-flown from the live sliders every frame — + * see `RicochetField` above for why that meant dropping the base class's drag + * term and writing the piecewise solution instead. + * + * **The second half of the trick is where the bounces happen.** The throw + * speed is not authored: for a ballistic launch at `elevation`, the range is + * `R = v²·sin(2θ)/g`, so the ability inverts it with `R = zoneRadius` and the + * sheaf touches down on the ring the aim indicator drew. `GroundField(POCK)` + * then takes a crater at the first `POCK_MARKS` touchdowns, and — the part + * that matters — the ability keeps the `Vector4`s the module handed back and + * **recomputes their positions from the solver every frame**. Drag + * `restitution`, `elevation`, `zoneRadius`, `gravity` or `speedScale` on a + * paused cast and the ring of dents walks across the floor with the fragments + * that made it. A posted crater that cannot answer a slider is a captured + * dimension wearing a timestamp's clothes. + * + * Three supporting decisions worth writing down: + * + * - **The canister is a real bolt.** `HardSurface`'s `createBoltGeometry`, + * with a helical thread and a hex head, thrown on an arc and tumbling. It + * is what gives the travel phase something to look at and something to + * resolve from settings; the first version had nothing in the air at all + * and the cast read as a delay before an explosion. + * - **The fragments are hot and they cool.** `heat = heatStart · e^(−coolRate·t)` + * — Newton's law with the ambient at zero — driving `HardSurface`'s + * blackbody ramp, so the pieces go white, orange, cherry, grey as they + * skitter. The sparks are tinted off the *same* temperature rather than off + * an authored orange, which is the school's one physical conceit. + * - **Nothing fades.** Steel does not become transparent. At the end of the + * cast the floor under the fragments drops away (`exitSink`, fed straight + * into the solver's `floor`) and they sink with it, because that is one + * slider and it is honest about what is happening. + * + * **The rule that makes the editor work.** A cast captures a seed, a boolean + * (has it burst), the age it burst at, and one bit per fragment saying whether + * its first bounce has already been signed. Every metre, radian and second — + * including both fragment silhouettes and the canister's, which go through a + * `ShapeCache` — is resolved from `settings.shrapnel` inside the update loop, + * on a zero-length frame included. + */ +export class ShrapnelAbility extends Ability { + constructor(context) { + super('shrapnel', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* ---- the steel, shared by the canister and everything it becomes ---- */ + this.steel = createHardSurfaceMaterial({ + environment: this.ctx.environment, + flatShading: false + }); + + /** + * Three live shapes through one cache. Rebuilt only when a number moves — + * a bolt with a real helix is a couple of milliseconds to lathe, and doing + * it per frame to produce a byte-identical buffer is exactly what + * `ShapeCache` exists to stop. + */ + this.shapes = new ShapeCache({ capacity: 3 }); + this._plate = plateShape({ cornerSteps: 4, boltSegments: 12, boltInset: 0.2 }); + this._bolt = boltShape({ shankSegments: 14, threadSteps: 5 }); + this._canister = boltShape({ shankSegments: 18, threadSteps: 6 }); + + /* ---- the fragments ---- */ + this.frags = new RicochetField(this.group, { + geometry: (variant) => this._fragmentGeometry(variant), + variants: VARIANTS, + capacity: CAPACITY, + // The hard-surface material ignores `aSeed` / `aLife` — an unused + // attribute costs nothing — and in exchange the fragments are genuinely + // lit, shadowed, brushed steel rather than the built-in faceted tint. + // The price is that they cannot fade, which is why they sink instead. + material: this.steel, + layer: LAYER.WORLD, + renderOrder: 2, + castShadow: true, + receiveShadow: true + }); + + /* ---- the canister ---- */ + this.canister = new Mesh(this.shapes.get(SLOT_CANISTER, HardShape.BOLT, this._canister), this.steel); + this.canister.castShadow = true; + this.canister.frustumCulled = false; + this.canister.layers.set(LAYER.WORLD); + this.canister.renderOrder = 2; + this.group.add(this.canister); + + /* ---- the craters the first bounces leave ---- */ + this.pocks = new GroundField(this.group, { + mode: GroundMode.POCK, + marks: POCK_MARKS, + additive: false, // a nick in the floor shades it; it never lights it + depthTest: true, + layer: LAYER.VFX, + name: 'ShrapnelPocks' + }); + this.pocks.setVisible(false); + + /** Has the canister opened. */ + this._burst = false; + /** The age it opened at — a timestamp, which is an event. */ + this._burstAt = 0; + /** Resting phase of the sheaf, 0..1 of a turn. The one dice roll. */ + this._seed = 0; + + /** + * One bit per fragment: has its first contact already been signed? + * + * The contact time is derived, so it is re-answered every frame; the *fact + * that it has been answered once* is an event and has to be remembered, or + * a paused cast would post a crater and a spark spray on every frame. + */ + this._bounced = new Uint8Array(CAPACITY); + /** The `Vector4`s the ground field handed back, and whose fragment made them. */ + this._markSlots = new Array(POCK_MARKS).fill(null); + this._markFragment = new Int32Array(POCK_MARKS); + this._markCount = 0; + } + + /** + * One fragment silhouette. Slot 0 is a torn plate, slot 1 a bolt. + * + * `ShatterField` tumbles about the instance origin, and `HardSurface` seats + * its parts on `y = 0` — so an un-centred fragment orbits a point below + * itself and reads as being swung on a string. `center()` on the rebuild is + * the whole fix, and it only runs when the cache actually rebuilt. + */ + _fragmentGeometry(variant) { + const c = settings.shrapnel; + let geometry; + if (variant === 0) { + const s = this._plate; + s.width = Math.max(c.plateWidth, 0.1); + s.depth = Math.max(c.plateDepth, 0.1); + s.thickness = Math.max(c.plateThickness, 0.01); + s.bevel = Math.max(c.plateBevel, 0); + s.corner = clamp(c.plateCorner, 0, 0.5); + s.bolts = Math.max(0, Math.round(c.plateBolts)); + s.boltRadius = Math.max(c.plateBoltRadius, 0.005); + geometry = this.shapes.get(SLOT_PLATE, HardShape.PLATE, s); + } else { + const s = this._bolt; + s.length = Math.max(c.boltLength, 0.4); + s.headHeight = Math.max(c.boltHead, 0.1); + s.shankRadius = clamp(c.boltShank, 0.05, 0.48); + s.threadTurns = Math.max(c.boltThread, 0); + s.threadDepth = Math.max(c.boltThreadDepth, 0); + geometry = this.shapes.get(SLOT_BOLT, HardShape.BOLT, s); + } + if (this.shapes.changed) geometry.center(); + return geometry; + } + + createParticles() { + const particles = this.ctx.particles; + + // Velocity-stretched streaks. Additive, and tinted off the steel's own + // temperature rather than off a picker — see `_sparkTint`. + this.sparks = particles.get('shrapnel.sparks', { + capacity: 3000, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.5; + this.sparks.uniforms.uEndSize.value = 0.22; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.44; + + // The propellant smoke. Non-additive so it occludes the fragments crossing + // it, which is most of what tells you they are travelling outward. + this.smoke = particles.get('shrapnel.smoke', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.smoke.uniforms.uDrag.value = 1.9; + this.smoke.uniforms.uEndSize.value = 3.0; + this.smoke.uniforms.uSizeIn.value = 0.1; + this.smoke.uniforms.uFadeIn.value = 0.14; + this.smoke.uniforms.uFadeOut.value = 0.32; + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.frags.count + (this._burst ? 0 : 1); + } + + get impactDuration() { + const c = settings.shrapnel; + return Math.max(0.4, (c.holdTime + c.smokeLifetime * 0.5) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.2, settings.shrapnel.fadeTime); + } + + /** Seconds since the canister opened. Zero before it does. */ + get sinceBurst() { + return this._burst ? Math.max(0, this.age - this._burstAt) : 0; + } + + /** + * The steel's temperature right now, 0..1. + * + * Newton's law of cooling with the ambient at zero: `T = T₀·e^(−kt)`. It is + * a curve rather than a ramp because that is what cooling is, and because + * the visible difference is entirely in the first half-second — a linear + * ramp spends far too long in the orange and the fragments read as embers. + */ + get heatNow() { + const c = settings.shrapnel; + if (!this._burst) return saturate(c.heatStart); + return saturate(c.heatStart) * Math.exp(-Math.max(c.coolRate, 0) * this.sinceBurst); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this._seed = Math.random(); + this._burst = false; + this._burstAt = 0; + this._markCount = 0; + this._markSlots.fill(null); + this._bounced.fill(0); + + this.frags.clear(); + this.pocks.clearMarks(); + this.pocks.setVisible(false); + this.canister.visible = true; + + this._sync(1, 0); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve everything and drive the three modules. + * + * @param {number} fade 1 while it is fresh, ramping to 0 as the cast goes + * @param {number} fadePhase 0..1 through the fade only — drives the floor drop + */ + _sync(fade, fadePhase) { + const c = settings.shrapnel; + const g = settings.global; + + this._landingPoint(_land); + this._syncSteel(c, g); + this._syncCanister(c); + this._syncFragments(c, g, fadePhase); + this._syncPocks(c, g, fade); + this._syncParticles(c, g); + } + + /** Where the canister is aimed. */ + _landingPoint(out) { + this.pointAt(1, out); + out.y = 0; + return out; + } + + /** The steel. `syncHardSurfaceMaterial` every frame, zero-length ones too. */ + _syncSteel(c, g) { + _hard.colorMetal = c.colorMetal; + _hard.colorDeep = c.colorDeep; + _hard.colorScale = c.colorScale; + _hard.colorPolish = c.colorPolish; + _hard.colorSpec = c.colorSpec; + _hard.roughness = c.roughness; + _hard.metalness = c.metalness; + _hard.envIntensity = c.envIntensity; + + _hard.brush = Math.round(clamp(c.brushMode, 0, 2)); + _hard.brushAxisX = c.brushAxisX; + _hard.brushAxisY = c.brushAxisY; + _hard.brushAxisZ = c.brushAxisZ; + _hard.anisotropy = c.anisotropy; + _hard.specular = c.specular; + _hard.grain = c.grain; + _hard.grainScale = c.grainScale; + _hard.grainStretch = c.grainStretch; + + _hard.scale = c.millScale; + _hard.scaleScale = c.millScaleSize; + _hard.scaleSharp = c.millScaleSharp; + _hard.pit = c.pit; + _hard.pitScale = c.pitScale; + _hard.wear = c.wear; + _hard.wearGrain = c.wearGrain; + + _hard.heat = this.heatNow; + _hard.heatCold = c.heatCold; + _hard.heatHot = c.heatHot; + _hard.heatRef = c.heatRef; + _hard.heatExponent = c.heatExponent; + _hard.heatGlow = c.heatGlow; + _hard.heatTint = c.heatTint; + _hard.heatEdge = c.heatEdge; + + _hard.glow = g.glow; + _hard.shaderIntensity = g.shaderIntensity; + _hard.noiseFrequency = g.noiseFrequency; + + syncHardSurfaceMaterial(this.steel, _hard); + } + + /** + * The canister on its arc. + * + * The arc is `apex·sin(πu)` over the cast line, which is not a ballistic — + * it is a *throw*, and it has to arrive exactly when `advance()` says the + * front does or the burst happens next to the thing that caused it. A real + * parabola fitted to the same two ends and the same time is the same curve + * to within a few centimetres at these ranges and needs two more sliders. + */ + _syncCanister(c) { + if (this._burst) { + this.canister.visible = false; + return; + } + const geometry = this.shapes.get(SLOT_CANISTER, HardShape.BOLT, this._syncCanisterShape(c)); + if (this.canister.geometry !== geometry) this.canister.geometry = geometry; + + const u = this.phase === AbilityPhase.TRAVEL ? saturate(this.u) : 1; + this.pointAt(u, this.canister.position); + this.canister.position.y = c.canisterHeight * (1 - u) + c.chargeHeight * u + c.canisterApex * Math.sin(Math.PI * u); + this.canister.scale.setScalar(Math.max(c.canisterSize, 0.02)); + // End over end about the cast's lateral axis, plus a slow roll, so it never + // presents the same silhouette twice on the way out. + this.canister.rotation.set( + c.canisterSpin * this.age, + this._seed * Math.PI * 2, + Math.atan2(this.direction.x, this.direction.z) + ); + this.canister.visible = true; + } + + /** Live shape sliders for the canister. */ + _syncCanisterShape(c) { + const s = this._canister; + s.length = Math.max(c.canisterLength, 0.4); + s.headHeight = Math.max(c.canisterHead, 0.1); + s.shankRadius = clamp(c.canisterShank, 0.05, 0.48); + s.threadTurns = Math.max(c.canisterThread, 0); + s.washer = Math.max(c.canisterWasher, 0); + return s; + } + + /** The sheaf. `_throw` is the live params object the solver reads. */ + _syncFragments(c, g, fadePhase) { + // Both silhouettes are live sliders, so they are re-fetched every frame and + // swapped only on a real rebuild. The instanced attributes have to be + // carried across, because the cache hands back a fresh buffer geometry. + for (let v = 0; v < VARIANTS; v++) { + const geometry = this._fragmentGeometry(v); + const mesh = this.frags.meshes[v]; + if (mesh.geometry !== geometry) { + geometry.setAttribute('aSeed', this.frags.seedAttributes[v]); + geometry.setAttribute('aLife', this.frags.lifeAttributes[v]); + mesh.geometry = geometry; + } + } + + _throw.layout = ShatterLayout.ZONE; + _throw.origin.copy(this.origin); + _throw.direction.copy(this.direction); + _throw.side.copy(this.side); + _throw.centre.copy(_land); + _throw.length = this.length; + _throw.radius = c.chargeRadius; + _throw.width = c.chargeRadius; + _throw.spawnRadius = c.chargeRadius; + _throw.spawnHeight = c.chargeHeight; + + /* the launch, fitted to the aim indicator */ + _throw.range = Math.max(c.zoneRadius, 0.1); + _throw.elevation = c.elevation; + _throw.speedScale = c.speedScale; + _throw.speedJitter = c.speedJitter; + _throw.spread = c.spread; + _throw.gravity = c.gravity; + + /* the ricochet */ + _throw.restitution = c.restitution; + _throw.friction = c.friction; + _throw.tumbleKeep = c.tumbleKeep; + _throw.bounces = c.bounces; + _throw.stopSpeed = c.stopSpeed; + _throw.slide = c.slide; + _throw.groundSpin = c.groundSpin; + _throw.seat = c.seat; + // The exit: the floor itself drops away under the settled pieces. One + // slider, and it is the truth about what is on screen — nothing dissolves. + _throw.floor = -c.exitSink * Easing.inQuad(saturate(fadePhase)); + + /* the bodies */ + _throw.size = c.fragSize; + _throw.sizeJitter = c.fragSizeJitter; + _throw.shrink = c.fragShrink; + _throw.shrinkPower = c.fragShrinkPower; + _throw.spin = c.fragSpin; + _throw.spinJitter = c.fragSpinJitter; + _throw.lifetime = c.fragLifetime * g.lifetime; + _throw.randomness = g.randomness; + + // `sync()` is deliberately not called. It writes the built-in shader's + // uniform block, and this field carries a `MeshStandardMaterial` instead — + // `ShatterField` sets `this.uniforms = material.uniforms ?? null`, so the + // call would be a no-op with a params object built for nothing. The fade + // is spent on the ground field and the light; the steel does not fade, it + // sinks (see `_throw.floor`). + this.frags.update(this.age, _throw); + } + + /** + * The craters, re-derived. + * + * `yaw` is left at zero so the field's local axes are the world's, which is + * what lets a contact point in world metres become a mark fraction with one + * divide. The alternative — yawing the quad to the cast and rotating every + * contact into it — buys nothing here, because a bloom has no downrange. + */ + _syncPocks(c, g, fade) { + const radius = Math.max(c.pockRadius, 0.5); + + for (let i = 0; i < this._markCount; i++) { + const slot = this._markSlots[i]; + if (!slot) continue; + if (this.frags.firstBounce(this._markFragment[i], _throw, _contact, _outgoing) < 0) continue; + slot.x = (_contact.x - _land.x) / radius; + slot.y = (_contact.z - _land.z) / radius; + slot.w = saturate(c.pockLoad); + } + + _pock.centre.copy(_land); + _pock.yaw = 0; + _pock.height = c.pockHeight; + _pock.radius = radius; + _pock.length = radius * 2; + _pock.grow = 1; + _pock.recede = 0; + _pock.progress = 1; + _pock.fade = fade; + _pock.seed = this._seed * 100; + + _pock.edge = c.pockEdge; + _pock.ragged = c.pockRagged; + _pock.raggedScale = c.pockRaggedScale; + _pock.warp = c.pockWarp; + + _pock.relief = c.pockRelief; + _pock.normalStep = c.pockNormalStep; + _pock.ambient = c.pockAmbient; + _pock.wrap = c.pockWrap; + _pock.specular = c.pockSpecular; + _pock.gloss = c.pockGloss; + _pock.parallax = c.pockParallax; + + _pock.depth = c.pockDepth; + _pock.lift = c.pockLift; + _pock.thickness = c.pockRimWidth; + _pock.detail = c.pockGrain; + _pock.speed = c.pockDig; + _pock.markLife = c.pockLife; + _pock.markRadius = c.pockMarkRadius; + + _pock.additive = false; + // The scorch a fragment leaves is the fragment's own temperature, so the + // emissive term fades with the cooling curve rather than on a clock. + _pock.emissive = c.pockEmissive * (0.25 + 0.75 * this.heatNow); + _pock.opacity = c.pockOpacity; + _pock.depthFade = c.pockDepthFade; + _pock.colorBase = c.colorPockBase; + _pock.colorEdge = c.colorPockEdge; + _pock.colorGlow = c.colorPockGlow; + _pock.colorDeep = c.colorPockDeep; + + _pock.noiseStrength = g.noiseStrength; + _pock.noiseFrequency = g.noiseFrequency; + _pock.noiseSpeed = g.noiseSpeed; + _pock.opacityScale = g.opacity; + + this.pocks.update(_pock); + this.pocks.setVisible(this._markCount > 0 && fade > 0.004); + } + + /** Colours, sizes and the globals both particle systems fold in. */ + _syncParticles(c, g) { + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = 1.8 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = c.smokeTurbulence * g.turbulence; + } + + /** + * The tint sparks leave at: the blackbody colour of the steel that threw + * them, blended back toward the authored birth stop by `sparkHeatTint`. + * + * Below about 800 K the locus is black, so a cold fragment's sparks would + * vanish entirely — which is why the blend exists rather than the tint being + * absolute. `HardSurface`'s own note is the argument for the physical half: + * hard-coding an orange is how a cherry-red fragment ends up throwing + * lemon-yellow sparks. + */ + _sparkTint(c) { + blackbodyColor(heatToKelvin(this.heatNow, _hard), _tint); + return _tint.lerp(getColor(c.colorSparkA), 1 - saturate(c.sparkHeatTint)); + } + + /** + * Sign the floor and throw sparks wherever a fragment has just touched down + * for the first time. + * + * The contact *time* is derived and re-answered every frame; the fact that it + * has been answered is an event, and `_bounced` is the one bit per fragment + * that remembers it. Without that bit a paused cast would post a crater and + * a spray on every single frame, which is the failure mode this whole + * pattern exists to avoid. + */ + _pollContacts() { + const c = settings.shrapnel; + const g = settings.global; + const time = frame.uTime.value; + const count = Math.round(c.bounceSparks * g.particleCount); + + for (let i = 0; i < CAPACITY; i++) { + if (this._bounced[i]) continue; + const record = this.frags.records[i]; + if (record.born < 0) continue; + const hit = this.frags.firstBounce(i, _throw, _contact, _outgoing); + if (hit < 0 || this.age - record.born < hit) continue; + + this._bounced[i] = 1; + + if (this._markCount < POCK_MARKS) { + const index = this._markCount++; + this._markFragment[index] = i; + // Position and strength are rewritten in `_syncPocks` every frame; only + // the timestamp survives from here. They are also written *now*, from + // the contact this loop is already holding, because `_syncPocks` has + // already run this frame and a crater sitting at the anchor for one + // frame is a visible flick at the centre of the circle. + const radius = Math.max(c.pockRadius, 0.5); + this._markSlots[index] = this.pocks.mark( + (_contact.x - _land.x) / radius, + (_contact.z - _land.z) / radius, + time, + saturate(c.pockLoad) + ); + this.pocks.setVisible(true); + } + + if (count > 0) { + _pos.copy(_contact); + _emit.position = _pos; + _emit.radius = 0.05; + // Along the *outgoing* velocity: sparks struck off a ricochet leave the + // way the fragment did, which is the cheapest possible confirmation + // that a real reflection happened rather than a stop. + _emit.direction = _dir.copy(_outgoing).normalize(); + _emit.speed = c.sparkSpeed * 0.55; + _emit.speedVariance = 0.7; + _emit.spread = 0.35; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = this._sparkTint(c); + _emit.time = time; + this.sparks.emit(count, _emit); + _emit.tint = null; + } + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1, 0); + + // The light rides the canister, so the hot charge lights the floor it is + // crossing before it opens. + this.position.copy(this.canister.position); + + this.ctx.shake.rumble(settings.shrapnel.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.shrapnel; + const g = settings.global; + const time = frame.uTime.value; + + this._burst = true; + this._burstAt = this.age; + this._landingPoint(_land); + + // `along = 1` puts every fragment at the far end of the cast — the circle's + // centre — and `_anchorOf` then scatters them inside `chargeRadius`. + this.frags.burst(this.age, Math.min(Math.round(c.fragCount), CAPACITY), 1, 0); + this._sync(1, 0); + + _pos.copy(_land); + _pos.y = c.chargeHeight; + + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.5, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.4, + displace: 0.7, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _emit.position = _pos; + _emit.radius = Math.max(c.chargeRadius, 0.05); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = this._sparkTint(c); + _emit.time = time; + this.sparks.emit(Math.round(c.burstSparks * g.particleCount), _emit); + _emit.tint = null; + + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.95; + _emit.size = 0.85; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.5; + this.smoke.emit(Math.round(c.smokeCount * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + Math.max(c.shakeFrequency, 1) + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onFade(_dt, t) { + const c = settings.shrapnel; + // `t` runs 0..1 while the pieces are still moving, then 1..2 while the cast + // lets go and the floor drops out from under them. + const fadePhase = t <= 1 ? 0 : saturate(t - 1); + const fade = t <= 1 ? 1 : 1 - Easing.inQuad(fadePhase); + + this._sync(fade, fadePhase); + this._pollContacts(); + + this.position.copy(_land).setY(c.lightHeight); + } + + onDestroy() { + this._burst = false; + this._burstAt = 0; + this._markCount = 0; + this._markSlots.fill(null); + this._bounced.fill(0); + this.frags.clear(); + this.pocks.clearMarks(); + this.pocks.setVisible(false); + this.canister.visible = false; + } + + dispose() { + this.frags.dispose(); + this.pocks.dispose(); + this.shapes.dispose(); + this.steel.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/frost/AvalancheAbility.js b/src/abilities/frost/AvalancheAbility.js new file mode 100644 index 0000000..02d0f51 --- /dev/null +++ b/src/abilities/frost/AvalancheAbility.js @@ -0,0 +1,822 @@ +import { BufferGeometry, Float32BufferAttribute, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { createSnowSlabMaterial } from '../../materials/SnowSlabMaterial.js'; +import { DissolveField } from '../../vfx/Dissolve.js'; +import { GrowthField, GrowthLayout, GrowthEmerge } from '../../vfx/GrowthField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, hash11, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; +const HALF_PI = Math.PI * 0.5; + +/** Hard ceiling on slabs in one flow. The editor's `slabCount` clamps here. */ +const MAX_SLABS = 96; +/** Distinct slab silhouettes. Three, for the reason `GrowthField` documents. */ +const SLAB_VARIANTS = 3; +/** + * Grid resolution of the heap sheet, down the cast and across it. + * + * The lobes are metres across and the ridges where two of them meet are the + * texture of the whole thing, so this is what decides whether a crease is a + * crease or a staircase. 96 × 44 is one triangle every ~20 cm on a 19 m cast, + * which is about where the staircase stops being visible from the default + * camera; 64 × 32 is honest and noticeably blockier along the crest. + */ +const HEAP_ALONG = 96; +const HEAP_ACROSS = 44; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _target = new Vector3(); + +/* ---------------------------------------------------------------------- */ +/* Geometry */ +/* ---------------------------------------------------------------------- */ + +/** + * One block of hard slab, in `GrowthField`'s unit space. + * + * A prism on an irregular plan, tapered upward and with its top face **sheared + * off a plane** rather than cut flat — because that is what a slab avalanche + * leaves behind. The snowpack fails along a weak layer, the plate above it + * breaks into blocks, and each block is a wedge with one clean fracture plane + * on it. Footprint inside a circle of radius 0.5 on `y = 0`, tip at `y = 1`. + * + * The first version was a rounded lump with vertex noise on it. It read as + * rubble — as something that had been *eroded* — and rubble is exactly wrong + * here: the whole point of the slabs is that they are freshly broken and their + * faces are flat. Flat faces plus `flatShading` plus one sheared plane is the + * entire silhouette, and it needed less code than the noise did. + * + * `notch` chips the high corner of that plane away, so the blocks are not all + * the same wedge seen from different sides. + */ +function createSnowSlabGeometry(variant, shape) { + const seed = 7.3 + variant * 21.7; + const sides = clamp(Math.round(shape?.facets ?? 6), 4, 10); + const shear = clamp(shape?.shear ?? 0.55, 0, 1); + const taper = clamp(shape?.taper ?? 0.78, 0.15, 1.2); + const rough = clamp(shape?.rough ?? 0.3, 0, 0.95); + const notch = clamp(shape?.notch ?? 0.22, 0, 0.7); + + // The fracture plane, rolled once per variant: a bearing and a tip. + const planeAngle = hash11(seed * 1.7) * TAU; + const nx = Math.cos(planeAngle); + const nz = Math.sin(planeAngle); + + const bottom = []; + const top = []; + for (let i = 0; i < sides; i++) { + const angle = (i / sides) * TAU; + // One radius per bearing, shared by the base and the top ring, so the + // block keeps continuous vertical edges instead of twisting. + const radius = 0.5 * (1 - rough * hash11(seed * 3.1 + i * 5.9)); + const bx = Math.cos(angle) * radius; + const bz = Math.sin(angle) * radius; + const tx = bx * taper; + const tz = bz * taper; + // The sheared plane. Held clear of the floor so a steeply tipped block + // still has a wall on its low side rather than a zero-area sliver. + let ty = 1 - shear * (tx * nx + tz * nz) * 2; + // The chip out of the high corner, on the same plane's positive side. + const high = saturate((tx * nx + tz * nz) * 2 + 0.5); + ty -= notch * high * hash11(seed * 5.3 + i * 2.7); + bottom.push([bx, 0, bz]); + top.push([tx, clamp(ty, 0.18, 1), tz]); + } + + let capY = 0; + for (const point of top) capY += point[1]; + capY /= sides; + const centreTop = [0, capY, 0]; + const centreBottom = [0, 0, 0]; + + const positions = []; + // Deliberately not named `a`, `b`, `c`: the harness's static settings pass + // treats `const c = …` as poisoning the `c` alias for the whole file, and a + // geometry local called `c` would blind it to every `settings.avalanche` + // read in the ability below. + const tri = (p, q, r) => { + positions.push(p[0], p[1], p[2], q[0], q[1], q[2], r[0], r[1], r[2]); + }; + + for (let i = 0; i < sides; i++) { + const j = (i + 1) % sides; + // Winding is authored rather than left to chance — a block lit from below + // is the one thing `flatShading` makes impossible to miss. + tri(centreTop, top[j], top[i]); + tri(centreBottom, bottom[i], bottom[j]); + tri(top[i], top[j], bottom[j]); + tri(top[i], bottom[j], bottom[i]); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * AVALANCHE — snow that piles, slumps and finds its angle of repose. + * + * **The trick: the front is a heap, not a wave.** Everything else in the + * sandbox that moves along the floor is a *surface* being animated — a + * heightfield with a travelling profile, a growth front, a sweep. Those all + * share one tell, which is that the shape is authored and the motion is + * imposed on it. This one is the other way round: the shape is a *consequence*. + * + * A lobe of snow is released at the front every `1/lobeRate` seconds. It is + * born over-steep — `repose + excess` radians — and its slope relaxes back + * toward `repose` at `slump` per second. Its **volume is held**, so a slope + * that relaxes has nowhere to go but outward: with `V` fixed and a cone of + * half-angle φ, `H = ∛(3V·tan²φ/π)` and `radius = H/tanφ`. One exponential and + * one cube root, and the lobe collapses on its own. The surface you see is the + * **upper envelope** of the sixteen youngest lobes — a max, never a sum, because + * heaps merge by taking the higher of the two and summing them gives a smooth + * mound with no creases, which is a pudding. Every crease on the front is two + * lobes meeting. Each lobe's centroid also creeps forward at `creep` while it + * collapses, so new material lands *on top of* older material and slides over + * it, which is the "keeps falling forward over itself" of the brief and is one + * term in the shader. + * + * The envelope is `vfx/Dissolve.js`'s `DissolveField`, and this ability is its + * first caller. Everything above is closed-form in `now − birth`, which is why + * the repose angle and the slump rate are live: pull `repose` down on a + * **paused** avalanche and the whole standing heap flattens and spreads, which + * is worth doing once just to watch. + * + * ## Why the heap has its own clock + * + * An avalanche does not stop because it reached a line on the floor. It runs + * out: it decelerates over a few metres and the surface freezes where it lies. + * + * The heap's release point is `heapClock · frontSpeed`, so the honest way to + * stop it is to stop the clock — and because the lobes' collapse is a function + * of the *same* clock, throttling it slows the release of new material and the + * slumping of the standing material by exactly the same factor. That is a + * runout: the flow decelerates as one thing and comes to rest. So `heapClock` + * advances at `dt × throttle`, and `throttle` falls from 1 to 0 over `runout` + * seconds on the `runoutCurve` exponent once the cast front lands. + * + * Two alternatives were tried and both are worse. Clamping the front by driving + * `frontSpeed` down as `length/now` keeps the clock running but re-places every + * lobe (their centres are `birth · frontSpeed`), so the whole train visibly + * contracts backwards toward the caster — the heap slides *uphill*. Letting the + * front run on past the end of the sheet is worse still: the lobe train walks + * out of the drawn region within half a second and all that is left standing is + * the flat bed, so the avalanche appears to run off a cliff. + * + * ## The slabs + * + * A `GrowthField` of hard blocks shouldered up out of the flow behind the + * front. They are not decoration: a heightfield of snow has no hard edges + * anywhere in it and therefore no scale — the heap could be 30 cm deep or 3 m + * deep and nothing in the image says which. The blocks are the scale reference, + * they are the only thing in the cast that casts a real shadow, and their + * fracture faces are the only sharp specular in it. + * + * They also **lie back onto the repose slope**: their lean is + * `slabLean + (π/2 − repose) · slabRepose`, so dragging the repose angle tips + * the blocks over with the surface they are sitting in. That tie is the one + * derived value in the ability and it earns its place — with the blocks + * standing bolt upright in a flattened heap the illusion goes immediately. + * + * ## The rule that makes the editor work + * + * A cast captures one seed and two timestamps (the impact, and the clock the + * runout is measured from). Every metre, radian and second is resolved against + * `settings.avalanche` inside the update loop, on a zero-length frame included. + * + * Four draw calls: one heap, three slab meshes. + */ +export class AvalancheAbility extends Ability { + constructor(context) { + super('avalanche', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.heap = new DissolveField(this.group, { + along: HEAP_ALONG, + across: HEAP_ACROSS, + renderOrder: 3, + name: 'Avalanche:heap' + }); + + this.material = createSnowSlabMaterial(this.ctx.environment); + this.slabUniforms = this.material.userData.uniforms; + + /** + * Live shape controls, handed to the geometry factory. Mutated in place — + * an object literal per frame is the allocation I3 forbids — and compared + * numerically by `syncGeometry`, which rebuilds only when one has moved. + */ + this._shape = { facets: 6, shear: 0.55, taper: 0.78, rough: 0.3, notch: 0.22 }; + this._fillShape(); + + this.slabs = new GrowthField(this.group, { + geometry: createSnowSlabGeometry, + material: this.material, + shape: this._shape, + variants: SLAB_VARIANTS, + capacity: MAX_SLABS, + layer: LAYER.WORLD, + renderOrder: 2, + castShadow: true, + receiveShadow: true + }); + // Assigned once, at construction — a closure rebuilt per frame per instance + // is exactly what I3 forbids. + this.slabs.onBreach = (index, position, radius) => this._breachFx(position, radius); + + /* --- scratch parameter blocks, filled from settings every frame --- */ + this._heapPar = {}; + this._growth = { + layout: GrowthLayout.LINE, + emerge: GrowthEmerge.PUSH, + origin: new Vector3(), + direction: new Vector3(0, 0, 1), + side: new Vector3(1, 0, 0), + length: 1 + }; + + /** Re-rolled per cast so two avalanches do not pile in the same pattern. */ + this._seed = 0; + /** + * The heap's own clock, seconds. Integrated rather than read off `age` + * because the runout throttles it — see the class header. This is the same + * kind of quantity as the base class's own `front`, which is metres it has + * accumulated from `speed`; it is not a dimension the cast *captured*. + */ + this._heapNow = 0; + /** Timestamp the front landed, or -1. The runout is measured from it. */ + this._landedAt = -1; + } + + createParticles() { + const particles = this.ctx.particles; + + // The powder cloud. Non-additive, because the one thing that separates a + // real avalanche from a smoke effect is that the cloud is *opaque* — you + // lose the heap behind it, and an additive cloud never occludes anything. + this.powder = particles.get('avalanche.powder', { + capacity: 2600, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.powder.uniforms.uDrag.value = 1.7; + this.powder.uniforms.uEndSize.value = 3.4; + this.powder.uniforms.uSizeIn.value = 0.1; + this.powder.uniforms.uFadeIn.value = 0.14; + this.powder.uniforms.uFadeOut.value = 0.36; + + // Spindrift: individual crystals torn off the crest and lit from behind. + this.drift = particles.get('avalanche.drift', { + capacity: 2200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.drift.uniforms.uDrag.value = 1.5; + this.drift.uniforms.uEndSize.value = 0.2; + this.drift.uniforms.uSizeIn.value = 0.05; + this.drift.uniforms.uFadeIn.value = 0.08; + this.drift.uniforms.uFadeOut.value = 0.36; + + // Chunks knocked off a slab as it breaks the surface. + this.chunks = particles.get('avalanche.chunks', { + capacity: 1400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chunks.uniforms.uDrag.value = 0.28; + this.chunks.uniforms.uEndSize.value = 0.8; + this.chunks.uniforms.uFadeOut.value = 0.7; + + this.powderEmitter = new RateEmitter(); + this.driftEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.slabs.count; + } + + /** The deposit stands, then sinks and thins away. */ + get impactDuration() { + return Math.max(0.1, settings.avalanche.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.1, settings.avalanche.settleTime); + } + + /** Snow breathes; it does not gutter. A slow cosine on the bounced light. */ + lightShimmer() { + const c = settings.avalanche; + return 1 - c.lightPulse * (0.5 - 0.5 * Math.cos(this.age * c.lightPulseSpeed * TAU)); + } + + /* ------------------------------------------------------------------ */ + /* Live geometry — every metre resolved from settings, every frame */ + /* ------------------------------------------------------------------ */ + + /** The shape controls the slab geometry is baked against. */ + _fillShape() { + const c = settings.avalanche; + const shape = this._shape; + shape.facets = c.facets; + shape.shear = c.shear; + shape.taper = c.taper; + shape.rough = c.rough; + shape.notch = c.notch; + return shape; + } + + /** + * 0..1 through the runout — how much of the flow's momentum is left. + * + * Exponent rather than a smoothstep so the shape is authorable: at + * `runoutCurve = 1` the brake is linear and the front stops dead at the end + * of the window; above 1 it coasts and then stops, which is what a real + * deposit does as the internal friction wins. + */ + _throttle() { + const c = settings.avalanche; + if (this._landedAt < 0) return 1; + const k = saturate((this.age - this._landedAt) / Math.max(0.05, c.runout)); + return Math.pow(1 - k, Math.max(0.2, c.runoutCurve)); + } + + /** 0..1 through the fade — how far the deposit has sunk and thinned. */ + _settle() { + const c = settings.avalanche; + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / Math.max(0.05, c.settleTime)); + } + + /** + * The heap, in `heapParams()`'s vocabulary. + * + * `frontSpeed` is the one derived number: the heap has to keep station with + * the cast front the base class is advancing, so it reads the same `speed` + * and the same global multiplier and carries its own `heapLead` on top. Give + * it an independent speed slider and the two fronts drift apart within a + * metre, and the powder cloud ends up hanging over bare floor. + */ + _fillHeap() { + const c = settings.avalanche; + const g = settings.global; + const p = this._heapPar; + const settle = this._settle(); + + p.halfWidth = c.heapWidth; + p.floor = c.heapFloor - settle * c.settleSink; + p.heightGain = c.heapHeight * (1 - settle * c.settleFlatten); + + p.lobes = c.lobes; + p.rate = c.lobeRate; + p.frontSpeed = c.speed * g.speed * c.heapLead; + p.volume = c.lobeVolume; + p.volumeJitter = c.lobeJitter * g.randomness; + p.repose = c.repose; + p.excess = c.excess; + p.slump = c.slump; + p.creep = c.creep; + p.scatter = c.lobeScatter; + p.widen = c.widen; + + p.bed = c.bed; + p.bedRamp = c.bedRamp; + p.bedWidth = c.bedWidth; + p.bedCurve = c.bedCurve; + + p.ambient = c.snowAmbient; + p.wrap = c.snowWrap; + p.fresh = c.snowFresh; + p.grain = c.snowGrain * g.randomness; + p.grainScale = c.snowGrainScale * g.noiseFrequency; + p.glint = c.snowGlint * g.shaderIntensity; + p.glintScale = c.snowGlintScale * g.noiseFrequency; + p.toe = c.heapToe; + p.opacity = c.heapOpacity * g.opacity * (1 - settle * settle); + return p; + } + + /** + * The slab field: footprint, silhouette and how a block shoulders up. + * + * The lean is the one place the two halves of the ability are wired together. + * A block sitting in a surface at the angle of repose is lying back on that + * surface, so `(π/2 − repose)` is how far past vertical it should be tipped, + * and `slabRepose` is how much of that to believe. At 0 the blocks stand + * upright out of a flattened heap and the whole read collapses. + */ + _fillGrowth() { + const c = settings.avalanche; + const g = settings.global; + const p = this._growth; + + p.origin.copy(this.origin); + p.direction.copy(this.direction); + p.side.copy(this.side); + p.length = this.length; + + p.widthNear = c.slabWidthNear; + p.width = c.slabWidth; + p.widthCurve = c.slabWidthCurve; + p.frontBias = c.slabBias; + p.clumping = c.slabClump; + p.scatter = c.slabScatter; + p.clusterRadius = c.slabClusterRadius; + + p.heightNear = c.slabHeightNear; + p.height = c.slabHeight; + p.heightCurve = c.slabHeightCurve; + p.heightJitter = c.slabHeightJitter; + p.crown = c.slabCrown; + p.crownPower = c.slabCrownPower; + p.rubble = c.slabRubble; + p.rubbleScale = c.slabRubbleScale; + + p.radiusNear = c.slabRadiusNear; + p.radius2 = c.slabRadius; + p.radiusCurve = c.slabRadiusCurve; + p.radiusJitter = c.slabRadiusJitter; + + p.lean = c.slabLean + (HALF_PI - c.repose) * c.slabRepose; + p.leanRamp = c.slabLeanRamp; + p.twist = c.slabTwist; + p.tilt = c.slabTilt; + + p.riseTime = c.slabRise; + p.riseOvershoot = c.slabOvershoot; + p.settle = c.slabSettle; + p.springRate = c.slabSpring; + p.emergeSink = c.slabSink; + p.birthScale = c.slabBirthScale; + p.birthFade = c.slabBirthFade; + p.breachAt = c.slabBreach; + p.sinkDepth = c.slabRetractSink; + p.randomness = g.randomness; + return p; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.avalanche; + + this.powderEmitter.reset(); + this.driftEmitter.reset(); + this._heapNow = 0; + this._landedAt = -1; + this._seed = Math.random() * 100; + + this.heap.roll(this._seed); + this.slabs.plant(Math.min(MAX_SLABS, Math.round(c.slabCount)), c.slabCluster); + + this._syncUniforms(); + this._syncFields(); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** Push the live settings into the slab material and the three systems. */ + _syncUniforms() { + const c = settings.avalanche; + const g = settings.global; + + this.material.userData.sync(this.direction); + + this.powder.setGradient( + getColor(c.colorPowderA), + getColor(c.colorPowderB), + getColor(c.colorPowderC), + getColor(c.colorPowderD) + ); + this.powder.uniforms.uGravity.value.set(0, c.powderRise, 0); + this.powder.uniforms.uSizeScale.value = c.powderSize * g.particleSize; + this.powder.uniforms.uLifeScale.value = c.powderLifetime * 0.5 * g.particleLifetime; + this.powder.uniforms.uSpeedScale.value = c.powderSpeed * g.particleSpeed; + this.powder.uniforms.uOpacity.value = c.powderOpacity * g.opacity; + this.powder.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.drift.setGradient( + getColor(c.colorDriftA), + getColor(c.colorDriftB), + getColor(c.colorDriftC), + getColor(c.colorDriftD) + ); + this.drift.uniforms.uGravity.value.set(0, c.driftRise, 0); + this.drift.uniforms.uSizeScale.value = c.driftSize * g.particleSize * 7; + this.drift.uniforms.uLifeScale.value = c.driftLifetime * 0.5 * g.particleLifetime; + this.drift.uniforms.uSpeedScale.value = c.driftSpeed * g.particleSpeed; + this.drift.uniforms.uOpacity.value = g.opacity; + this.drift.uniforms.uGlow.value = 0.55 * g.glow; + this.drift.uniforms.uTurbulence.value = c.driftTurbulence * g.turbulence; + + this.chunks.setGradient( + getColor(c.colorChunkA), + getColor(c.colorChunkB), + getColor(c.colorChunkC), + getColor(c.colorChunkD) + ); + this.chunks.uniforms.uGravity.value.set(0, c.chunkGravity, 0); + this.chunks.uniforms.uSizeScale.value = c.chunkSize * g.particleSize * 7; + this.chunks.uniforms.uLifeScale.value = c.chunkLifetime * 0.5 * g.particleLifetime; + this.chunks.uniforms.uSpeedScale.value = g.particleSpeed; + this.chunks.uniforms.uOpacity.value = g.opacity; + } + + /** Rebuild the heap and the slab field against the live settings. */ + _syncFields() { + const c = settings.avalanche; + + this.heap.setBasis(this.origin, this.direction, this.side, this.length); + this.heap.setColors(c.colorFresh, c.colorSettled, c.colorFace, c.colorDeep); + this.heap.update(this._heapNow, this._fillHeap()); + + this.slabs.syncGeometry(this._fillShape()); + this.slabs.update(this.age, this._fillGrowth(), this._settle()); + } + + /** + * Where the crest of the flow is right now, world space. + * + * `frontPoint` is the module's own mirror of the release point, so the + * powder, the light and the shake all hang off the same number the vertex + * shader is using rather than off a second guess at it. + */ + _crestPoint(out) { + const p = this._heapPar; + this.heap.frontPoint(this._heapNow, p, out); + out.y += this.heap.crestHeight(this._heapNow, p) * 0.6; + return out; + } + + /** A breath of powder at the caster's feet as the slope lets go. */ + _muzzleFx() { + const c = settings.avalanche; + const g = settings.global; + + _pos.copy(this.origin).addScaledVector(this.direction, 0.6).setY(0.3); + + this.ctx.bursts.spawn(BurstMode.FROST, _pos, { + radius: c.burstSize * 0.15, + endRadius: c.burstSize * 0.5 * g.explosionIntensity, + life: 0.55, + intensity: c.burstIntensity * 0.6, + opacity: 0.75, + fresnel: 1.2, + displace: 0.55, + squash: 0.55, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.lightBoost = c.lightIntensity * 0.35 * g.explosionIntensity; + } + + /** + * Chunks thrown as one slab breaks the surface. + * Called by `GrowthField` the frame an instance passes its `breachAt`. + */ + _breachFx(position, radius) { + const c = settings.avalanche; + const g = settings.global; + + _pos.copy(position).setY(0.08); + + _emit.position = _pos; + _emit.radius = radius * 0.7; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.6).setY(1).normalize(); + _emit.speed = c.chunkSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.chunkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 7; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.chunks.emit(Math.round(c.breachChunks * g.particleCount), _emit); + } + + /** + * Powder off the front and spindrift off the crest. + * + * Both are emitted at the **heap's** front rather than at the base class's + * cast front. The two agree while the flow is running and part company during + * the runout, and the runout is precisely when it matters: the powder has to + * come to rest with the snow, not sail on to the end of the line. + * + * @param {number} scale 0..1 — thinned once the deposit is only standing + */ + _flowFx(dt, scale) { + const c = settings.avalanche; + const g = settings.global; + const time = frame.uTime.value; + + const powderCount = Math.round(this.powderEmitter.tick(dt, c.powderRate * scale) * g.particleCount); + if (powderCount > 0) { + this._crestPoint(_pos); + _emit.position = _pos; + _emit.radius = c.heapWidth * 0.55; + // Up and *forward*: a powder cloud is dragged along by the flow under it + // and outruns the heap, which is why the leading edge of a real avalanche + // is cloud and not snow. + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.65).setY(1).normalize(); + _emit.speed = c.powderSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.powderLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.powder.emit(powderCount, _emit); + } + + const driftCount = Math.round(this.driftEmitter.tick(dt, c.driftRate * scale) * g.particleCount); + if (driftCount > 0) { + // Spindrift is torn off the whole length of the crest behind the front, + // not just off its nose — the tail of an avalanche smokes too. + const s = randRange(0.15, 1); + this._crestPoint(_pos); + _pos.addScaledVector(this.direction, -(1 - s) * this.length * 0.35); + _emit.position = _pos; + _emit.radius = c.heapWidth * 0.7; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.35).setY(1).normalize(); + _emit.speed = c.driftSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.size = 0.07; + _emit.sizeVariance = 0.6; + _emit.life = c.driftLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.drift.emit(driftCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.avalanche; + + this._heapNow += dt * this._throttle(); + + this._syncUniforms(); + this.slabs.triggerUpTo(this.age, this.u, c.slabStagger, c.slabBias, false); + this._syncFields(); + this._flowFx(dt, 1); + + // The light rides the crest, not the floor line under it. + this._crestPoint(this.position); + + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.avalanche; + const g = settings.global; + const time = frame.uTime.value; + + this._landedAt = this.age; + // Everything still buried shoulders up now, the terminal pile-up included. + this.slabs.triggerUpTo(this.age, 1, c.slabStagger, c.slabBias, true); + + this.pointAt(1, _target); + _pos.copy(_target).setY(0.5); + + /* the wall of thrown powder where the flow piles into itself */ + this.ctx.bursts.spawn(BurstMode.FROST, _pos, { + radius: c.burstSize * 0.3, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.95, + intensity: c.burstIntensity, + opacity: 0.8, + fresnel: 1.2, + displace: 0.65, + squash: 0.6, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring of blown snow across the floor */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _target, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.8, + width: 0.06, + intensity: 0.7, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* powder and blocks off the pile-up */ + _emit.position = _pos; + _emit.radius = c.heapWidth * 0.7; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.4).setY(1).normalize(); + _emit.speed = c.powderSpeed * 2.4; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.5; + _emit.sizeVariance = 0.55; + _emit.life = c.powderLifetime * 1.35; + _emit.lifeVariance = 0.45; + _emit.spin = 0.45; + _emit.tint = null; + _emit.time = time; + this.powder.emit(Math.round(c.impactPowder * g.particleCount), _emit); + + _emit.radius = c.heapWidth * 0.45; + _emit.speed = c.chunkSpeed * 2.1; + _emit.size = 0.16; + _emit.life = c.chunkLifetime * 1.3; + _emit.spin = 10; + this.chunks.emit(Math.round(c.impactChunks * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 14 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.8 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.avalanche; + + this._heapNow += dt * this._throttle(); + + this._syncUniforms(); + this._syncFields(); + this._crestPoint(this.position); + + // The flow keeps smoking while it is still moving and stops when it stops: + // the throttle *is* the emission scale, so the cloud dies with the runout + // rather than on a second timer that has to be kept in step by hand. + const moving = this._throttle(); + this._flowFx(dt, moving * 0.85 + (t <= 1 ? 0.15 : 0.05)); + this.ctx.shake.rumble(c.rumble * moving * settings.global.cameraShake, dt); + } + + onDestroy() { + this.slabs.clear(); + this.heap.reset(); + this._heapNow = 0; + this._landedAt = -1; + } + + dispose() { + this.slabs.dispose(); + this.heap.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/frost/BlackIceAbility.js b/src/abilities/frost/BlackIceAbility.js new file mode 100644 index 0000000..af79f4b --- /dev/null +++ b/src/abilities/frost/BlackIceAbility.js @@ -0,0 +1,751 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Mirror, mirrorBudget } from '../../vfx/Mirror.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** World up. The sheet's normal, and it never changes. */ +const UP = new Vector3(0, 1, 0); + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); + +/** + * BLACK ICE — a sheet of meltwater that freezes into a mirror. + * + * **The trick: it is a real reflection, and it sharpens as it freezes.** This + * is the sandbox's first and only true reflective surface. A camera mirrored + * about the sheet's plane renders the `WORLD` layer into a small target and the + * surface samples that target projectively, with an oblique near plane skewed + * onto the sheet so nothing behind it can leak in. It is the actual room, the + * actual character, upside down and sliding under your feet as you orbit. + * + * Everything else in this project that looks reflective is a fresnel ramp over + * the HDR probe, and it was worth finding out why that is not good enough + * here. An environment map has **no parallax**: the reflection is a function of + * the view direction alone, so it does not move relative to the surface when + * the camera translates. Standing still, an env map and a planar reflection are + * hard to tell apart. Orbit for half a second and the env map reads as a + * painted-on shine, instantly and irreversibly. That is the entire argument for + * paying for the pass. + * + * ## The freeze is the animation, and it is three numbers + * + * The reflection lookup is blurred by a jittered disc of radius + * `roughness × blurRadius`, so the whole ability is a slider going from 1 to 0: + * + * - **`roughWet` → `roughIce`** takes it from a puddle you cannot read the room + * in to a black mirror you can; + * - **`ripple` × (1 − freeze)** stops the reflection *moving*. This one matters + * more than it sounds. An early version froze the roughness and left the + * ripple running, and the sheet read as polished wet stone rather than as + * ice, because a mirror whose image swims is by definition still liquid. It + * is one multiply and it is the difference between the two materials. + * - **`reflectWet` → `reflectIce`** is how much of the reflection survives at + * all. The `fresnel` term deliberately does *not* animate: water and ice have + * similar indices and the grazing falloff is roughly the same for both, so + * changing it would be a lie that nobody asked for. + * + * The thaw runs the whole thing backwards — `sharp = freeze × (1 − thaw)` — so + * the sheet un-resolves into slush before it goes. Watching a black mirror lose + * focus is a better exit than watching it fade out, and it costs one term. + * + * ## Respecting the budget + * + * A reflection is one extra full traversal of the world layer, and + * `vfx/Mirror.js` caps the whole frame at two of them. Two things are this + * ability's responsibility rather than the module's: + * + * - **the sheet is only `visible` when it is actually going to be seen.** The + * module renders from the mesh's own `onBeforeRender`, so an invisible + * mirror costs nothing at all — there is no pass to skip, because there is + * no pass. So `visible` is gated on the resolved opacity every frame, and it + * is false for the whole travel phase, false once the thaw has taken the + * sheet below a fiftieth of an alpha, and false the instant the cast is + * destroyed. The alternative — leaving it visible for the cast's whole life + * and letting the alpha do the work — costs a scene render a frame for a + * surface nobody can see. + * - **`mirrorPriority` is a slider, and `mirrorBudget.max` is not touched.** + * The cap is global state that belongs to whoever is composing the frame; an + * ability that raises it to get its own reflection back has taken the + * decision away from the app. Priority is the polite way to ask. + * + * ## The sandwich + * + * Three quads, in this order off the floor: a `GroundField(POOL)` of meltwater + * with a meniscus rim, the `Mirror`, and a `GroundField(PLATE)` crust of ice + * plates at a third of an alpha. The crust is the reason the mirror does not + * read as a hole in the floor: a perfect reflection with no surface *on* it has + * no thickness and no material, and the eye files it as a gap. Thirty per cent + * of plate seams over the top is enough to say "there is something here", and + * little enough to see the room through. + * + * The pool's surface motion (`poolSpeed`, `poolFlow`) is scaled by `1 − freeze` + * on the same term the ripple is, so all three layers stop being liquid + * together. + * + * ## The rule that makes the editor work + * + * A cast captures one seed and one timestamp — the moment the front landed, + * which every beat below is measured from. Every metre, radian and second is + * resolved against `settings.blackice` in the update loop, on a zero-length + * frame included. Pause a frozen sheet, drag `roughIce`, and the room in it + * dissolves. + * + * Three draw calls in the group, plus one nested `renderer.render()` while the + * sheet is visible and wins a slot. + */ +export class BlackIceAbility extends Ability { + constructor(context) { + super('blackice', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + // Bottom of the sandwich: the meltwater. + this.pool = new GroundField(this.group, { + mode: GroundMode.POOL, + additive: false, + renderOrder: 2, + name: 'BlackIce:pool' + }); + + this.sheet = new Mirror({ + resolution: 384, + layer: LAYER.VFX, + // The real scene and nothing else. Reflecting the VFX layer would let a + // mirror see a mirror, which `vfx/Mirror.js` refuses rather than resolves. + reflectLayer: LAYER.WORLD, + renderOrder: 4, + // A floor sheet is never seen from underneath, and the single-sided path + // buys a free rejection in `_wantsRender`. + doubleSided: false, + depthWrite: false, + name: 'BlackIce:sheet' + }); + this.group.add(this.sheet.object3D); + + // Top of the sandwich: the plate crust, at a third of an alpha. + this.crust = new GroundField(this.group, { + mode: GroundMode.PLATE, + additive: false, + renderOrder: 6, + name: 'BlackIce:crust' + }); + + /* --- scratch parameter blocks, filled from settings every frame --- */ + this._mirror = {}; + this._poolPar = { centre: new Vector3() }; + this._crustPar = { centre: new Vector3() }; + /** The zone centre, refreshed every frame — never captured. */ + this._centre = new Vector3(); + + /** Re-rolled per cast so two sheets do not freeze in the same pattern. */ + this._seed = 0; + /** Timestamp the front landed, or -1. Every beat is measured from it. */ + this._landedAt = -1; + /** One-shot latch on the crack the thaw opens with. */ + this._cracked = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // Cold fog crawling out over the sheet. Non-additive so it genuinely + // occludes: a reflection with additive haze over it is a reflection with + // the contrast washed out of it, and contrast is all this ability has. + this.mist = particles.get('blackice.mist', { + capacity: 2200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.mist.uniforms.uDrag.value = 2.4; + this.mist.uniforms.uEndSize.value = 3.2; + this.mist.uniforms.uSizeIn.value = 0.12; + this.mist.uniforms.uFadeIn.value = 0.2; + this.mist.uniforms.uFadeOut.value = 0.34; + + // Crystals lighting up along the freeze front. + this.glints = particles.get('blackice.glints', { + capacity: 1800, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.glints.uniforms.uDrag.value = 1.6; + this.glints.uniforms.uEndSize.value = 0.16; + this.glints.uniforms.uSizeIn.value = 0.05; + this.glints.uniforms.uFadeIn.value = 0.07; + this.glints.uniforms.uFadeOut.value = 0.36; + + // Water flecks on the landing, ice flecks on the thaw. + this.spray = particles.get('blackice.spray', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.spray.uniforms.uDrag.value = 0.35; + this.spray.uniforms.uEndSize.value = 0.6; + this.spray.uniforms.uFadeOut.value = 0.7; + + this.mistEmitter = new RateEmitter(); + this.glintEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + // Not instanced geometry — but the reflection is the cost worth reading, + // and the HUD's one number is the honest place to publish it. + return this.sheet.visible ? mirrorBudget.rendered : 0; + } + + get impactDuration() { + return Math.max(0.1, settings.blackice.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.1, settings.blackice.thawTime); + } + + /** Ice breathes. A slow cosine, never a stutter. */ + lightShimmer() { + const c = settings.blackice; + return 1 - c.lightPulse * (0.5 - 0.5 * Math.cos(this.age * c.lightPulseSpeed * TAU)); + } + + /* ------------------------------------------------------------------ */ + /* The beats — all measured from the landing, all resolved live */ + /* ------------------------------------------------------------------ */ + + /** Seconds since the front landed, or -1 while it is still travelling. */ + _since() { + return this._landedAt < 0 ? -1 : this.age - this._landedAt; + } + + /** 0..1 — the meltwater spreading to the full circle. */ + _flood() { + const since = this._since(); + if (since < 0) return 0; + return saturate(since / Math.max(0.02, settings.blackice.floodTime)); + } + + /** 0..1 — the freeze, from open water to a mirror finish. */ + _freeze() { + const c = settings.blackice; + const since = this._since(); + if (since < 0) return 0; + return saturate((since - c.freezeDelay) / Math.max(0.02, c.freezeTime)); + } + + /** 0..1 — the sheet going back to water and away. */ + _thaw() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / Math.max(0.05, settings.blackice.thawTime)); + } + + /** + * 0..1 — how *sharp* the reflection is right now. + * + * The single number the whole ability turns on, and the reason the thaw is a + * multiply rather than a second timeline: `freeze × (1 − thaw)` runs the + * freeze forwards and then backwards through exactly the same states, so a + * sheet halfway through thawing looks like a sheet halfway through freezing. + * Which is correct — it is the same amount of ice. + */ + _sharpness() { + return this._freeze() * (1 - this._thaw()); + } + + /** The centre of the footprint, world space. Refreshed, never stored. */ + _zoneCentre(out) { + return this.pointAt(1, out); + } + + /* ------------------------------------------------------------------ */ + /* Live parameter blocks */ + /* ------------------------------------------------------------------ */ + + /** The mirror. Every metre and every 0..1 comes from here. */ + _fillMirror() { + const c = settings.blackice; + const g = settings.global; + const p = this._mirror; + const sharp = this._sharpness(); + const thaw = this._thaw(); + + const span = c.zoneRadius * c.sheetSpan * 2; + p.width = span; + p.height = span; + // A disc, not a rectangle — the footprint the aim indicator drew. + p.corner = 1; + p.edgeFade = c.sheetEdgeFade; + p.seed = this._seed; + + p.roughness = lerp(c.roughWet, c.roughIce, sharp); + p.blurRadius = c.blurRadius; + p.blurTaps = c.blurTaps; + p.roughStretch = c.roughStretch; + + p.reflectivity = lerp(c.reflectWet, c.reflectIce, sharp); + p.fresnel = c.fresnel; + p.fresnelPower = c.fresnelPower; + + // Ice does not ripple. This is the term that decides which material the + // surface is, and it is why the freeze reads as a phase change. + p.ripple = c.ripple * (1 - sharp); + p.rippleScale = c.rippleScale * g.noiseFrequency; + p.rippleSpeed = c.rippleSpeed * g.noiseSpeed; + + p.opacity = c.sheetOpacity * g.opacity * this._flood() * (1 - thaw * thaw); + p.resolution = c.resolution; + p.priority = c.mirrorPriority; + p.colorTint = c.colorTint; + p.colorBase = c.colorSheet; + return p; + } + + /** The meltwater underneath. */ + _fillPool() { + const c = settings.blackice; + const g = settings.global; + const p = this._poolPar; + // Liquid while it is liquid: the surface stops moving on the same term the + // reflection does, or the three layers disagree about what they are made of. + const wet = 1 - this._sharpness(); + + p.centre.copy(this._centre); + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.poolHeight; + p.radius = c.zoneRadius * c.poolSpan; + p.seed = this._seed; + + p.grow = this._flood(); + p.recede = this._thaw(); + p.fade = saturate(this._flood() * 4); + + p.edge = c.poolEdge; + p.ragged = c.poolRagged; + p.raggedScale = c.poolRaggedScale; + p.warp = c.poolWarp; + + p.relief = c.poolRelief; + p.normalStep = c.poolNormalStep; + p.ambient = c.poolAmbient; + p.wrap = c.poolWrap; + p.specular = c.poolSpecular; + p.gloss = c.poolGloss; + p.parallax = c.poolParallax; + + p.cell = c.poolCell; + p.thickness = c.poolThickness; + p.lift = c.poolLift * (0.25 + 0.75 * wet); + p.depth = c.poolDepth; + p.detail = c.poolDetail; + p.speed = c.poolSpeed * wet; + p.flow = c.poolFlow * wet; + p.windAngle = c.poolWind; + + p.additive = false; + p.emissive = c.poolEmissive; + p.opacity = c.poolOpacity; + p.depthFade = c.poolDepthFade; + p.colorBase = c.colorPoolBase; + p.colorEdge = c.colorPoolEdge; + p.colorGlow = c.colorPoolGlow; + p.colorDeep = c.colorPoolDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + return p; + } + + /** The plate crust over the top. Its growth front *is* the freeze. */ + _fillCrust() { + const c = settings.blackice; + const g = settings.global; + const p = this._crustPar; + + p.centre.copy(this._centre); + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.crustHeight; + p.radius = c.zoneRadius * c.crustSpan; + p.seed = this._seed; + + p.grow = this._freeze(); + p.recede = this._thaw(); + p.fade = saturate(this._freeze() * 3); + + p.edge = c.crustEdge; + p.ragged = c.crustRagged; + p.raggedScale = c.crustRaggedScale; + p.warp = c.crustWarp; + + p.relief = c.crustRelief; + p.normalStep = c.crustNormalStep; + p.ambient = c.crustAmbient; + p.wrap = c.crustWrap; + p.specular = c.crustSpecular; + p.gloss = c.crustGloss; + p.parallax = c.crustParallax; + + p.cell = c.crustCell; + p.cellJitter = c.crustCellJitter; + p.seam = c.crustSeam; + p.thickness = c.crustThickness; + p.lift = c.crustLift; + p.detail = c.crustDetail; + // 0 because the quad is yawed onto the cast: in its own frame downrange is + // +Z, so the plates lift their downwind edge the way the cast is pointing. + p.windAngle = 0; + + p.additive = false; + p.emissive = c.crustEmissive; + p.opacity = c.crustOpacity; + p.depthFade = c.crustDepthFade; + p.colorBase = c.colorCrustBase; + p.colorEdge = c.colorCrustEdge; + p.colorGlow = c.colorCrustGlow; + p.colorDeep = c.colorCrustDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + return p; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.mistEmitter.reset(); + this.glintEmitter.reset(); + this._landedAt = -1; + this._cracked = false; + this._seed = Math.random() * 100; + + this.sheet.visible = false; + this._syncUniforms(); + this._syncFields(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** Push the live settings into the three particle systems. */ + _syncUniforms() { + const c = settings.blackice; + const g = settings.global; + + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = 0.3 * g.turbulence; + + this.glints.setGradient( + getColor(c.colorGlintA), + getColor(c.colorGlintB), + getColor(c.colorGlintC), + getColor(c.colorGlintD) + ); + this.glints.uniforms.uGravity.value.set(0, c.glintRise, 0); + this.glints.uniforms.uSizeScale.value = c.glintSize * g.particleSize * 7; + this.glints.uniforms.uLifeScale.value = c.glintLifetime * 0.5 * g.particleLifetime; + this.glints.uniforms.uSpeedScale.value = c.glintSpeed * g.particleSpeed; + this.glints.uniforms.uOpacity.value = g.opacity; + this.glints.uniforms.uGlow.value = 0.85 * g.glow; + this.glints.uniforms.uTurbulence.value = c.glintTurbulence * g.turbulence; + + this.spray.setGradient( + getColor(c.colorSprayA), + getColor(c.colorSprayB), + getColor(c.colorSprayC), + getColor(c.colorSprayD) + ); + this.spray.uniforms.uGravity.value.set(0, c.sprayGravity, 0); + this.spray.uniforms.uSizeScale.value = c.spraySize * g.particleSize * 7; + this.spray.uniforms.uLifeScale.value = c.sprayLifetime * 0.5 * g.particleLifetime; + this.spray.uniforms.uSpeedScale.value = g.particleSpeed; + this.spray.uniforms.uOpacity.value = g.opacity; + } + + /** + * Re-place and re-resolve all three layers of the sandwich. + * + * The visibility gate is here rather than in `onFade`, because it has to hold + * on every frame of every phase — including the ones where the ability is + * doing nothing else — and because the number it tests is the same resolved + * opacity the shader is about to use. + */ + _syncFields() { + const c = settings.blackice; + this._zoneCentre(this._centre); + + this.pool.update(this._fillPool()); + this.crust.update(this._fillCrust()); + + const mirror = this._fillMirror(); + // `along` is the cast heading: for a floor sheet the module re-orthogonalises + // it against the normal, so the disc's own axes line up with the cast. + _pos.copy(this._centre).setY(c.sheetHeight); + this.sheet.setPlacement(_pos, UP, this.direction); + this.sheet.update(mirror); + // No visible mirror, no reflection pass. See the class header. + this.sheet.visible = mirror.opacity > 0.02; + } + + /** + * Fog crawling over the sheet and crystals along the freeze front. + * + * The crystals are emitted **on the front itself** — at + * `freeze × radius` from the centre, in a band `crustEdge` wide — rather than + * anywhere in the circle. A freeze that sparkles uniformly across its whole + * area is a freeze with no direction in it, and the front is the only part of + * this ability that moves. + * + * @param {number} scale 0..1 — thinned once the sheet is only holding + */ + _sheetFx(dt, scale) { + const c = settings.blackice; + const g = settings.global; + const time = frame.uTime.value; + const radius = c.zoneRadius * c.poolSpan; + + const mistCount = Math.round(this.mistEmitter.tick(dt, c.mistRate * scale) * g.particleCount); + if (mistCount > 0) { + _pos.copy(this._centre).setY(0.1); + _emit.position = _pos; + _emit.radius = radius * this._flood(); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.8; + // Nearly flat: cold fog does not billow, it lies down and creeps. + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.25; + _emit.tint = null; + _emit.time = time; + this.mist.emit(mistCount, _emit); + } + + const glintCount = Math.round(this.glintEmitter.tick(dt, c.glintRate * scale) * g.particleCount); + if (glintCount > 0) { + const front = radius * this._freeze(); + const angle = Math.random() * TAU; + const at = Math.max(0, front + randRange(-c.crustEdge, c.crustEdge)); + _pos.copy(this._centre); + _pos.x += Math.cos(angle) * at; + _pos.z += Math.sin(angle) * at; + _pos.y = 0.08; + _emit.position = _pos; + _emit.radius = c.crustEdge * 0.6; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.glintSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.9; + _emit.size = 0.06; + _emit.sizeVariance = 0.6; + _emit.life = c.glintLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.glints.emit(glintCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.blackice; + + this._syncUniforms(); + this._syncFields(); + // The light rides the front on its way out, then sits on the sheet. + this.position.y = 0.35; + + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.blackice; + const g = settings.global; + const time = frame.uTime.value; + + this._landedAt = this.age; + this._zoneCentre(this._centre); + _pos.copy(this._centre).setY(0.3); + + /* the shell of freezing air over the landing */ + this.ctx.bursts.spawn(BurstMode.FROST, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.8, + intensity: c.burstIntensity, + opacity: 0.7, + fresnel: 1.5, + displace: 0.4, + squash: 0.45, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring that runs out across the stone */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, this._centre, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.7, + width: 0.05, + intensity: 0.75, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* water thrown off the landing */ + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spraySpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.7; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 6; + _emit.tint = null; + _emit.time = time; + this.spray.emit(Math.round(c.impactSpray * g.particleCount), _emit); + + _emit.radius = c.zoneRadius * 0.6; + _emit.speed = c.mistSpeed * 2.4; + _emit.spread = 1.0; + _emit.size = 1.3; + _emit.life = c.mistLifetime * 1.2; + _emit.spin = 0.4; + this.mist.emit(Math.round(c.impactMist * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 16 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.6 * g.explosionIntensity; + + this._syncUniforms(); + this._syncFields(); + } + + onFade(dt, t) { + const c = settings.blackice; + + // The thaw opens with the sheet cracking: one shot, on the frame the fade + // starts, so the reflection has something to go *wrong* with rather than + // simply getting blurry. + if (this.phase === AbilityPhase.FADE && !this._cracked) { + this._cracked = true; + this._crackFx(); + } + + this._syncUniforms(); + this._syncFields(); + this.position.copy(this._centre); + this.position.y = 0.25; + + // The fog thickens as the ice gives its cold up and thins out with it. + const boil = t <= 1 ? 0.75 : 1.1 * (1 - this._thaw()); + this._sheetFx(dt, boil); + } + + /** The sheet letting go: ice flecks off the whole disc. */ + _crackFx() { + const c = settings.blackice; + const g = settings.global; + + _pos.copy(this._centre).setY(0.1); + _emit.position = _pos; + _emit.radius = c.zoneRadius * c.crustSpan * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spraySpeed * 0.7; + _emit.speedVariance = 0.85; + _emit.spread = 0.75; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.8; + _emit.life = c.sprayLifetime * 1.3; + _emit.lifeVariance = 0.55; + _emit.spin = 8; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.spray.emit(Math.round(c.thawSpray * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * 0.5 * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + } + + onDestroy() { + // The one thing that must happen however the cast ends: an invisible + // mirror is deregistered from the schedule and costs nothing, and a + // visible one left behind is a scene render a frame, for ever. + this.sheet.visible = false; + this._landedAt = -1; + this._cracked = false; + this.pool.update(this._fillPool()); + this.crust.update(this._fillCrust()); + } + + dispose() { + this.sheet.dispose(); + this.pool.dispose(); + this.crust.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/frost/HailAbility.js b/src/abilities/frost/HailAbility.js new file mode 100644 index 0000000..2dc84bd --- /dev/null +++ b/src/abilities/frost/HailAbility.js @@ -0,0 +1,842 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Projectile, FlightMode, Stagger } from '../../vfx/Projectile.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { createHailMaterial } from '../../materials/HailMaterial.js'; +import { createAsteroidGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, hash11, randRange } from '../../utils/math.js'; + +/** + * How many craters the ground field's event list carries. + * + * One stone posts one crater, so this is also the ceiling on `stones` — past it + * the ring buffer recycles oldest-first and the earliest holes vanish while you + * are looking at them. The fragment walks the list three times (the bowl, the + * coverage mask and the cold), so this is the number that decides what the + * floor costs; 32 is about where a 5 m circle stops looking sparse and well + * before the quad becomes the most expensive thing on screen. + */ +const POCK_SLOTS = 32; + +/** Hard ceiling on stones in the air. `stones` clamps here. */ +const MAX_STONES = POCK_SLOTS; + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _land = new Vector3(); +const _centre = new Vector3(); +const _heading = new Vector3(); + +/** Scratch parameter blocks — refilled every frame, never replaced (I3). */ +const _flight = {}; +const _floor = {}; + +/** + * HAILWRATH — the far cast whose front is *vertical*. + * + * Every other zone slot in the sandbox answers a circle with something that + * spreads: a sheet that freezes outward, a ring of blades that closes. This one + * answers it with weather. A column of freezing air stands over the footprint + * and then it comes down — irregular stones that stretch along their own + * velocity, punch a white pock into the floor, throw chips, bounce once and are + * gone. The pocks stay, rime over during the hold, and thaw back from the rim. + * + * ## The trick + * + * **No stone knows when it falls; the floor does.** A stone's launch delay is a + * hash of *where it is going to land*, quantised onto a lattice and mixed with + * the cast's seed (`Projectile`'s `Stagger.HASH`). Ordering by index — the + * obvious `i / count` — correlates the fill order with the layout, because the + * same dice place the stones, and you can watch the loop run. Keying on the + * floor decorrelates them completely: neighbouring stones share a hash cell, so + * the circle fills in *patches that spread*, `fillBias` runs those patches + * inward from the boundary, and a different seed fills the same circle in a + * completely different order. + * + * ## The envelope, and what it cost + * + * Hail does not arrive at a constant rate. It gathers, hammers, and tails off. + * `Projectile` spreads its launches **uniformly** over `window`, and there is no + * per-body hook to change that — so the envelope is not applied to the stones + * at all. It is applied to the *clock they are read against*: + * `_stormClock()` integrates the rate curve `stormRamp`/`stormPeak`/`stormTail` + * into a monotone reparametrisation of the storm's own time, and a uniform + * spread read against a clock that runs fast at the peak and slow at the edges + * **is** a ramp-peak-tail. Four coefficients, one integral, no schedule and no + * captured seconds — which is why dragging `stormPeak` with the game paused + * re-times every stone still in the sky. + * + * The price is real and worth knowing: the stones' *fall* is measured in storm + * seconds too, so the first ones drift down and the ones at the peak hammer. + * `stormFloor` bounds the ratio. Set it to zero and the very first stone hangs + * motionless in the air — which is the mechanism made visible, and the reason + * it ships at 0.35. The alternative, dividing `fallTime` by the local rate to + * cancel it exactly, was written and thrown away: it makes a body's τ + * non-monotone wherever the rate is rising, so a stone that had landed goes + * back into the air, and the arrival re-fires and digs its crater twice. + * + * ## What a cast captures + * + * Two timestamps — the moment of the cast and the moment the storm started — + * one seed, and the dice `Projectile#roll()` holds. Not one metre. The craters + * on the floor are posted as **fractions of the radius**, so dragging + * `zoneRadius` on a floor that is already cratered grows the whole pattern with + * the circle instead of leaving it sitting in the middle of a bigger one. + * + * Three draw calls: the stones, their streaks, and the floor. + */ +export class HailAbility extends Ability { + constructor(context) { + super('hail', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.material = createHailMaterial(this.ctx.environment); + + /** + * The stones. `geometry` is a *factory* — the module owns the buffer, + * dresses it with its per-instance attributes and rebuilds it whenever + * `shapeKey()` moves, which is what keeps the silhouette sliders live. + */ + this.stones = new Projectile(this.group, { + geometry: () => this._buildStone(), + shapeKey: () => this._shapeKey(), + material: this.material, + capacity: MAX_STONES, + trail: true, + trailNodes: 18, + trailAdditive: true, + layer: LAYER.WORLD, + renderOrder: 2, + castShadow: true + }); + + /** + * The floor. `POCK` accumulates craters from a list of unitless hits: the + * bowls union with `min` and the lips sum, so two stones on the same spot + * dig one hole and pile two rims — which is what happens. + */ + this.pocks = new GroundField(this.group, { + mode: GroundMode.POCK, + marks: POCK_SLOTS, + additive: false, + depthTest: true, + name: 'hail.pocks' + }); + + /** Re-rolled per cast so no two storms fill the circle in the same order. */ + this._seed = 0; + /** Timestamp: the moment the storm was let go. An event, not a duration. */ + this._stormAt = 0; + /** Signature of the silhouette controls, so a rebuild only follows a change. */ + this._shapeHash = ''; + } + + createParticles() { + const particles = this.ctx.particles; + + // The freezing column, and the fog that pools in the crater field. Not + // additive: cold air that adds light is a spotlight. + this.mist = particles.get('hail.mist', { + capacity: 900, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.mist.uniforms.uDrag.value = 1.6; + this.mist.uniforms.uEndSize.value = 2.6; + this.mist.uniforms.uSizeIn.value = 0.14; + this.mist.uniforms.uFadeIn.value = 0.2; + this.mist.uniforms.uFadeOut.value = 0.35; + + // Ice knocked off a stone — and, at a different size and life, the stone's + // own single bounce. Same substance, so the same system and the same + // gradient; a second system for one hop would be a second draw call and a + // second set of colour pickers that must not disagree with these. + this.chips = particles.get('hail.chips', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.35; + this.chips.uniforms.uEndSize.value = 0.7; + this.chips.uniforms.uFadeOut.value = 0.65; + + // The bright shatter at the moment of contact. + this.spray = particles.get('hail.spray', { + capacity: 1400, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.2 + }); + this.spray.uniforms.uDrag.value = 2.2; + this.spray.uniforms.uEndSize.value = 0.2; + this.spray.uniforms.uSizeIn.value = 0.02; + this.spray.uniforms.uFadeIn.value = 0.02; + this.spray.uniforms.uFadeOut.value = 0.5; + + // What lifts off the rime while it closes over. + this.glitter = particles.get('hail.glitter', { + capacity: 700, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.glitter.uniforms.uDrag.value = 1.8; + this.glitter.uniforms.uEndSize.value = 0.12; + this.glitter.uniforms.uSizeIn.value = 0.05; + this.glitter.uniforms.uFadeIn.value = 0.1; + this.glitter.uniforms.uFadeOut.value = 0.45; + + this.mistEmitter = new RateEmitter(); + this.glitterEmitter = new RateEmitter(); + } + + /** + * One stone, in unit space — `Projectile` scales it by the body radius. + * + * An icosphere at one subdivision is eighty triangles, which is plenty: a + * hailstone is 17 cm across and what carries it at that size is the *cut + * faces*, not the tessellation. The cuts are what make it read as hail rather + * than as gravel — real stones are accreted in layers and shear along them. + */ + _buildStone() { + const c = settings.hail; + return createAsteroidGeometry({ + seed: 7.3, + detail: clamp(Math.round(c.stoneFacets), 0, 2), + lumpiness: c.stoneLumps, + noiseScale: c.stoneLumpScale, + roughness: c.stoneChip, + cuts: Math.max(0, Math.round(c.stoneCuts)), + cutDepth: c.stoneCutDepth, + craters: 0, + craterDepth: 0, + craterSize: 0.4 + }); + } + + /** Everything `_buildStone` reads, as one string. A change rebuilds. */ + _shapeKey() { + const c = settings.hail; + return `${Math.round(c.stoneFacets)}|${c.stoneLumps}|${c.stoneLumpScale}|${c.stoneChip}|${Math.round(c.stoneCuts)}|${c.stoneCutDepth}`; + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.stones.count; + } + + /** The storm: the lead-in, the launches, and the last stone's fall. */ + get impactDuration() { + const c = settings.hail; + return Math.max(0.2, (c.stormLead + c.stormTime + c.fallTime) * settings.global.lifetime); + } + + /** The hold: the pocks rime over, then the field thaws back from the rim. */ + get fadeDuration() { + return Math.max(0.2, settings.hail.holdTime); + } + + /** The live footprint, metres. What the indicator measured out. */ + get radius() { + return Math.max(0.2, settings.hail.zoneRadius); + } + + /* ------------------------------------------------------------------ */ + /* The storm clock — the envelope, integrated */ + /* ------------------------------------------------------------------ */ + + /** + * The rate envelope's own integral, normalised to 0..1 over the storm. + * + * The rate curve is a floor plus two power arms meeting at the peak: + * + * ``` + * e(x) = floor + (1 − floor) · (x/p)^ramp for x ≤ p + * e(x) = floor + (1 − floor) · ((1−x)/(1−p))^tail for x > p + * ``` + * + * Both arms integrate in closed form, which is the whole reason the envelope + * is shaped this way rather than as an fbm or a spline: a clock has to be + * evaluated, not marched, or it cannot be re-derived on a zero-length frame. + */ + _envelopeCdf(x) { + const c = settings.hail; + const p = clamp(c.stormPeak, 0.02, 0.98); + const ramp = Math.max(0.05, c.stormRamp); + const tail = Math.max(0.05, c.stormTail); + const floor = saturate(c.stormFloor); + const gain = 1 - floor; + + const rise = (gain * p) / (ramp + 1); // area under the rising arm + const fall = (gain * (1 - p)) / (tail + 1); // ... and the falling one + const total = floor + rise + fall; + + const t = saturate(x); + const area = + t <= p + ? floor * t + rise * Math.pow(t / p, ramp + 1) + : floor * t + rise + fall * (1 - Math.pow((1 - t) / (1 - p), tail + 1)); + + return area / Math.max(total, 1e-4); + } + + /** + * Seconds on the storm's own clock. + * + * Identity before the lead-in and after the last launch — only the launch + * window is warped, so a stone still in the air when the storm ends finishes + * its fall in real time rather than freezing. + */ + _stormClock() { + const c = settings.hail; + if (this.phase === AbilityPhase.TRAVEL) return 0; + + const lead = Math.max(0, c.stormLead); + const span = Math.max(0.05, c.stormTime); + const since = this.age - this._stormAt - lead; + + if (since <= 0) return Math.max(0, this.age - this._stormAt); + if (since >= span) return lead + since; + return lead + span * this._envelopeCdf(since / span); + } + + /** The centre of the storm — the far end of the aimed line, on the floor. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.mistEmitter.reset(); + this.glitterEmitter.reset(); + this.pocks.clearMarks(); + this.stones.reset(); + + // The two things a cast captures: one seed and, at impact, one timestamp. + this._seed = Math.random() * 100; + this._stormAt = 0; + this.stones.roll(this._seed); + + this._syncStones(0); + this._syncFloor(0, 0); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-fly every stone against the live block, and deal with whatever landed. + * @param {number} clock seconds on the storm's warped clock + */ + _syncStones(clock) { + const c = settings.hail; + const g = settings.global; + const p = _flight; + + p.mode = FlightMode.FALL; + p.stagger = Stagger.HASH; + p.count = Math.min(MAX_STONES, Math.round(c.stones)); + p.radius = c.stoneRadius; + p.sizeJitter = c.stoneJitter; + p.stretch = c.stoneStretch; + p.align = c.stoneAlign; + p.spin = c.stoneSpin * g.randomness; + p.flash = c.stoneFlash; + + // Where they come from. One point, high and behind — the trails converge in + // the sky and diverge on the ground, and that parallax is the whole reason + // the sky reads as being above you rather than as a wall of streaks. + p.skyBack = c.skyBack; + p.skyHeight = c.skyHeight; + p.skyScatter = c.skySpread; + + // ... and where they go. `landInZone` puts them on the far disc rather than + // at the end of the line; `zoneRadius` is shared with the ground field and + // the indicator, which is the one kind of sharing I5 allows because the + // sharing *is* the design. + p.landInZone = true; + p.zoneRadius = this.radius; + p.zoneBias = c.rimCrowd; + p.landHeight = 0; + + p.pathCurve = c.fallCurve; + p.apex = 0; // a fall has no loft; the cloud is already the high point + p.apexCurve = 1; + p.weaveSide = c.driftSide; + p.weaveUp = 0; + p.weaveTurns = c.driftTurns; + p.weaveTurnsUp = 1; + p.weaveDecay = c.driftDecay; + + p.flightTime = c.fallTime; + p.speedJitter = c.speedJitter; + p.lead = c.stormLead; + p.window = Math.max(0.05, c.stormTime); + p.fillBias = c.fillBias; + p.fillScatter = c.fillScatter; + p.hashCell = c.hashCell; + // A hailstone does not lie about on the floor. It shatters, and what is + // left is the pock, the chips and one bounce. + p.linger = 0; + p.sink = 0; + + p.trailSpan = c.trailSpan; + p.trailBurn = c.trailBurn; + p.trailWidth = c.trailWidth; + p.trailTaper = c.trailTaper; + p.trailLift = c.trailLift; + p.trailOpacity = c.trailOpacity * g.opacity; + p.trailGlow = c.trailGlow * g.glow; + p.trailCore = c.trailCore; + p.trailHeadBias = c.trailHeadBias; + p.trailNoise = c.trailNoise * g.noiseStrength; + p.trailNoiseScale = c.trailNoiseScale * g.noiseFrequency; + p.trailNoiseSpeed = c.trailNoiseSpeed * g.noiseSpeed; + p.trailSoftFade = c.trailSoftFade; + + this.stones.setBasis(this.origin, this.direction, this.side, this.length); + this.stones.setTrailColors(c.colorTrailA, c.colorTrailB, c.colorTrailC, c.colorTrailD); + this.stones.update(clock, p); + + // The mean heading of the fall, for the leading-face light on the stones: + // the cloud point to the middle of the circle. Taken once rather than per + // stone because thirty bodies out of one vanishing point are within a few + // degrees of each other, and a per-instance heading is a second instanced + // attribute bought for nothing. + this._centrePoint(_centre); + _heading + .copy(_centre) + .sub(this.origin) + .addScaledVector(this.direction, c.skyBack); + _heading.y = -Math.max(0.1, c.skyHeight); + _heading.normalize(); + this.material.userData.sync(_heading); + } + + /** + * Everything a stone does the instant it arrives. + * + * Read straight after `update()`: an arrival is *re-derived*, not remembered, + * so a slider that puts a body back in the air clears it again. + */ + _strikes() { + const c = settings.hail; + const g = settings.global; + const time = frame.uTime.value; + const count = this.stones.arrivalCount; + if (count <= 0) return; + + this._centrePoint(_centre); + const radius = this.radius; + + for (let i = 0; i < count; i++) { + const index = this.stones.arrivals[i]; + this.stones.landPoint(index, _land); + + /* --- the pock. Fractions of the radius, never metres. --- */ + _pos.copy(_land).sub(_centre); + // The field's local +Z is downrange and its local +X is `-side`; posting + // in that frame is what lets the whole crater pattern rotate with a + // re-aimed cast rather than being stamped in world axes. + const strength = 0.55 + 0.45 * hash11(index * 3.7 + this._seed); + this.pocks.mark( + -_pos.dot(this.side) / radius, + _pos.dot(this.direction) / radius, + time, + strength + ); + + /* --- chips off the floor --- */ + _land.y = 0.04; + _emit.position = _land; + _emit.radius = c.stoneRadius * 1.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chipSpeed * strength; + _emit.speedVariance = 0.8; + _emit.spread = c.chipSpread; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.chipSize; + _emit.sizeVariance = 0.7; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = c.chipSpin; + _emit.tint = null; + _emit.time = time; + this.chips.emit(Math.round(c.chipCount * g.particleCount), _emit); + + /* --- the bounce: one chip the size of the stone, thrown back up --- */ + _emit.radius = 0.02; + _emit.speed = c.bounceSpeed * strength; + _emit.speedVariance = 0.3; + _emit.spread = c.bounceSpread; + _emit.size = c.bounceSize; + _emit.sizeVariance = 0.35; + _emit.life = c.bounceLifetime; + _emit.lifeVariance = 0.3; + _emit.spin = c.bounceSpin; + this.chips.emit(1, _emit); + + /* --- the shatter --- */ + _emit.radius = c.stoneRadius; + _emit.direction = _dir + .set(randRange(-1, 1), c.sprayRise * 2.2, randRange(-1, 1)) + .normalize(); + _emit.speed = c.spraySpeed * strength; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.size = c.spraySize; + _emit.sizeVariance = 0.7; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + this.spray.emit(Math.round(c.sprayCount * g.particleCount), _emit); + + this.ctx.shake.add( + c.strikeShake * strength * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.05, c.strikeShakeDecay), + 24 + ); + this.lightBoost += c.strikeLight * strength * g.explosionIntensity; + // The light rides the freshest crater, which is what makes a storm read + // as a sequence of events rather than as one lit disc. + this.position.copy(_land).setY(0.4); + } + } + + /** + * The floor, and the frost closing over it. + * + * @param {number} grow 0..1 how far the chill has spread across the circle + * @param {number} rime 0..1 how far the frost has closed over the craters + * @param {number} thaw 0..1 how far the field is being eaten back from the rim + * @param {number} fade 0..1 master + */ + _syncFloor(grow, rime, thaw = 0, fade = 1) { + const c = settings.hail; + const g = settings.global; + const p = _floor; + + p.centre = this._centrePoint(_centre); + // Local +Z downrange, so a re-aimed cast turns the whole crater field with + // it. `Ability#side` is `direction × up`, which is local −X. + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.pockHeight; + p.radius = this.radius; + p.length = this.length; + + p.grow = grow; + p.recede = thaw; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.pockEdge; + p.ragged = c.pockRagged; + p.raggedScale = c.pockRaggedScale; + p.warp = c.pockWarp; + + p.relief = c.pockRelief; + p.normalStep = c.pockNormalStep; + p.ambient = c.pockAmbient; + p.wrap = c.pockWrap; + // Frost is glossier than the bruised stone under it, and it is the sheen + // rather than any colour change that says the craters have iced over. + p.specular = lerp(c.pockSpecular, c.rimeSpecular, rime); + p.gloss = lerp(c.pockGloss, c.rimeGloss, rime); + p.parallax = c.pockParallax; + + // `thickness` is the lip's half-width and `lift` its height. Walking both + // up over the hold spreads the white rim inward until it closes over the + // bowl — frost growing out of the broken edges, which is where it starts. + // The first version crossfaded `colorBase` toward a frost colour instead + // and read as somebody tinting the floor, not as ice forming on it. + p.thickness = lerp(c.pockRim, c.rimeRim, rime); + p.lift = lerp(c.pockLift, c.rimeLift, rime); + p.depth = c.pockDepth; + p.detail = lerp(c.pockGrain, c.rimeGrain, rime); + p.speed = c.pockDig; + p.markLife = c.pockLife; + p.markRadius = c.pockRadius; + + p.additive = false; + p.emissive = c.pockEmissive * g.shaderIntensity; + p.opacity = c.pockOpacity; + p.depthFade = c.pockDepthFade; + p.colorBase = c.colorPock; + p.colorEdge = c.colorPockRim; + p.colorGlow = c.colorPockGlow; + p.colorDeep = c.colorPockDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + + this.pocks.update(p); + } + + /** The particle systems, all four of them. */ + _syncParticles() { + const c = settings.hail; + const g = settings.global; + + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = c.mistTurbulence * g.turbulence; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + + this.spray.setGradient( + getColor(c.colorSprayA), + getColor(c.colorSprayB), + getColor(c.colorSprayC), + getColor(c.colorSprayD) + ); + this.spray.uniforms.uGravity.value.set(0, c.sprayGravity, 0); + this.spray.uniforms.uSizeScale.value = c.spraySize * g.particleSize * 7; + this.spray.uniforms.uLifeScale.value = c.sprayLifetime * 0.5 * g.particleLifetime; + this.spray.uniforms.uSpeedScale.value = g.particleSpeed; + this.spray.uniforms.uOpacity.value = g.opacity; + this.spray.uniforms.uGlow.value = c.iceGlow * 0.9 * g.glow; + this.spray.uniforms.uStretch.value = c.sprayStretch; + + this.glitter.setGradient( + getColor(c.colorGlitterA), + getColor(c.colorGlitterB), + getColor(c.colorGlitterC), + getColor(c.colorGlitterD) + ); + this.glitter.uniforms.uGravity.value.set(0, c.glitterRise, 0); + this.glitter.uniforms.uSizeScale.value = c.glitterSize * g.particleSize * 7; + this.glitter.uniforms.uLifeScale.value = c.glitterLifetime * 0.5 * g.particleLifetime; + this.glitter.uniforms.uSpeedScale.value = c.glitterSpeed * g.particleSpeed; + this.glitter.uniforms.uOpacity.value = g.opacity; + this.glitter.uniforms.uGlow.value = 1.1 * g.glow; + this.glitter.uniforms.uTurbulence.value = c.glitterTurbulence * g.turbulence; + } + + /** + * The column of freezing air standing over the circle. + * @param {number} scale 0..1 — thinned as the storm dies + */ + _mistFx(dt, scale) { + const c = settings.hail; + const g = settings.global; + const count = Math.round(this.mistEmitter.tick(dt, c.mistRate * scale) * g.particleCount); + if (count <= 0) return; + + this._centrePoint(_pos); + // Fog pools in the middle and pours off the rim, so the emission disc is a + // fraction of the footprint rather than a fixed radius — drag `zoneRadius` + // and the column widens with the circle. + _pos.y = randRange(0.1, Math.max(0.2, c.mistHeight)); + _emit.position = _pos; + _emit.radius = this.radius * c.mistSpread; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.0; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.25; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.mist.emit(count, _emit); + } + + /** Glitter lifting off the rime while it closes. */ + _glitterFx(dt, rime) { + const c = settings.hail; + const g = settings.global; + const count = Math.round(this.glitterEmitter.tick(dt, c.glitterRate * rime) * g.particleCount); + if (count <= 0) return; + + this._centrePoint(_pos); + _pos.y = 0.08; + _emit.position = _pos; + _emit.radius = this.radius; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.glitterSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.06; + _emit.sizeVariance = 0.6; + _emit.life = c.glitterLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.glitter.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + /** + * The chill runs out to the circle. There is no travelling front in the + * ability itself — this is the reach, and it is over in a fifth of a second. + */ + onTravel(dt) { + this._syncParticles(); + this._syncStones(this._stormClock()); + this._syncFloor(Easing.outCubic(this.u), 0); + this._mistFx(dt, this.u); + + // The light waits over the circle rather than riding the reach, because + // the reach is not what anybody is looking at. + this._centrePoint(this.position); + this.position.y = 1.2; + } + + onImpact() { + const c = settings.hail; + const g = settings.global; + + // The one timestamp a cast records past the seed. + this._stormAt = this.age; + + this._centrePoint(_pos); + _pos.y = 0.6; + this.ctx.bursts.spawn(BurstMode.FROST, _pos, { + radius: c.chillBurst * 0.3, + endRadius: c.chillBurst * g.explosionIntensity, + life: 0.8, + intensity: c.chillIntensity, + opacity: 0.7, + fresnel: 1.5, + displace: 0.5, + squash: 0.5, + colorA: getColor(c.colorChillA), + colorB: getColor(c.colorChillB), + colorC: getColor(c.colorChillC) + }); + + this.ctx.flash.trigger(getColor(c.colorChillFlash), c.chillFlash * g.explosionIntensity); + } + + /** + * `t` runs 0..1 through the storm, then 1..2 through the hold. + * + * Both halves share this method because both of them are the same three + * calls with different arguments — and because the last stone of a storm + * lands a frame or two into the hold, and a phase boundary that stops + * updating the projectile would leave it hanging in the air. + */ + onFade(dt, t) { + const c = settings.hail; + + if (t <= 1) { + /* --- the storm --- */ + this._syncParticles(); + this._syncStones(this._stormClock()); + this._strikes(); + this._syncFloor(1, 0); + + // The mist thins as the cloud empties, on the same envelope the stones + // arrive on — one curve driving two things is why the storm reads as a + // single event rather than as a rain of props over a fog machine. + const rate = this._envelopeRate(); + this._mistFx(dt, 0.35 + 0.65 * rate); + this.ctx.shake.rumble(c.rumble * rate * settings.global.cameraShake, dt); + return; + } + + /* --- the hold: the frost closes, then the field thaws back --- */ + const k = saturate(t - 1); + const rime = Easing.outCubic(saturate(k / Math.max(0.05, c.rimeShare))); + const thawStart = saturate(c.thawStart); + const thaw = Easing.inQuad(saturate((k - thawStart) / Math.max(0.05, 1 - thawStart))); + + this._syncParticles(); + this._syncStones(this._stormClock()); + this._strikes(); + this._syncFloor(1, rime, thaw, 1 - Easing.inCubic(thaw)); + + this._mistFx(dt, 0.3 * (1 - thaw)); + this._glitterFx(dt, rime * (1 - thaw)); + } + + /** + * The envelope's *rate* at the current moment, 0..1 of its own peak. + * + * The clock is the integral; this is the integrand, and the two are read on + * the same frame from the same four sliders — so the mist and the rumble + * cannot drift out of step with the stones. + */ + _envelopeRate() { + const c = settings.hail; + const span = Math.max(0.05, c.stormTime); + const x = saturate((this.age - this._stormAt - Math.max(0, c.stormLead)) / span); + const p = clamp(c.stormPeak, 0.02, 0.98); + const floor = saturate(c.stormFloor); + const shape = + x <= p + ? Math.pow(x / p, Math.max(0.05, c.stormRamp)) + : Math.pow((1 - x) / (1 - p), Math.max(0.05, c.stormTail)); + return floor + (1 - floor) * saturate(shape); + } + + onDestroy() { + this.stones.reset(); + this.pocks.clearMarks(); + } + + dispose() { + this.stones.dispose(); + this.pocks.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/frost/RimeAbility.js b/src/abilities/frost/RimeAbility.js new file mode 100644 index 0000000..6533cd1 --- /dev/null +++ b/src/abilities/frost/RimeAbility.js @@ -0,0 +1,969 @@ +import { BufferGeometry, Float32BufferAttribute, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { createSheetIceMaterial } from '../../materials/SheetIceMaterial.js'; +import { GrowthField, GrowthLayout, GrowthEmerge } from '../../vfx/GrowthField.js'; +import { ShatterField, ShatterLayout } from '../../vfx/ShatterField.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, hash11, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on plates in one corridor. The editor's `plateCount` clamps here. */ +const MAX_PLATES = 216; +/** Distinct plan outlines. Three, for the reason `GrowthField` documents. */ +const PLATE_VARIANTS = 3; +/** Distinct silhouettes for the snapped-off curls. */ +const CURL_VARIANTS = 2; +/** + * How many points along the corridor one snap's fragments are split between. + * `ShatterField#burst` takes one `along` for a whole batch, so a single call + * puts two hundred curls in one place — which reads as a grenade, not as a + * corridor letting go all at once. + */ +const SNAP_BATCHES = 10; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _target = new Vector3(); + +/* ---------------------------------------------------------------------- */ +/* Geometry */ +/* ---------------------------------------------------------------------- */ + +/** + * One plate of sheet ice, flat, in `GrowthField`'s unit space. + * + * An irregular disc — a polygon of `facets` sides with a jittered outline, + * subdivided into `rings` radial bands — extruded to `thickness` and gently + * buckled so a plate lying flat is not a perfect plane and still catches the + * key light somewhere. Footprint inside a circle of radius 0.5 on `y = 0`. + * + * **The curl is not here.** It is a constant-curvature roll applied in the + * vertex shader of `SheetIceMaterial`, which is why every plate can be at a + * different point in its own peel while they all share three meshes. What the + * ring count buys is the *resolution* of that roll: eight rings is eight + * segments across the sheet, which turns a π roll into a faceted scroll rather + * than a polygon with four corners in it. Drop it to three and you can count + * the bends. + * + * `thickness` arrives as a **ratio**, not as metres: the instance is scaled to + * `plateHeight` metres tall, so the ability divides the authored thickness by + * that height on the way in and the sheet comes out the right number of + * centimetres thick whatever the peel is set to. + */ +function createIcePlateGeometry(variant, shape) { + const seed = 3.1 + variant * 17.3; + const sides = clamp(Math.round(shape?.facets ?? 11), 5, 24); + const rings = clamp(Math.round(shape?.rings ?? 8), 2, 20); + const rim = clamp(shape?.rimJitter ?? 0.34, 0, 0.95); + const buckle = shape?.buckle ?? 0.055; + const thickness = Math.max(1e-4, shape?.thickness ?? 0.04); + + // The outline: one radius per bearing, rolled once and shared by every ring, + // so the plate keeps continuous edges from its centre out instead of + // wobbling ring by ring into a starfish. + const outline = []; + for (let i = 0; i < sides; i++) outline.push(0.5 * (1 - rim * hash11(seed * 3.1 + i * 7.7))); + + /** Buckle: smooth, deterministic, and zero at the centre so the plate sits down. */ + const lift = (x, z, radial) => + buckle * Math.sin(x * 6.1 + seed * 2.3) * Math.cos(z * 5.3 - seed * 1.7) * radial; + + // rings × sides positions for the underside and the top face. + const bottom = []; + const top = []; + for (let r = 0; r < rings; r++) { + const radial = 0.12 + 0.88 * (r / (rings - 1)); + const ringBottom = []; + const ringTop = []; + for (let i = 0; i < sides; i++) { + const angle = (i / sides) * TAU; + const radius = outline[i] * radial; + const x = Math.cos(angle) * radius; + const z = Math.sin(angle) * radius; + const y = lift(x, z, radial); + ringBottom.push([x, y, z]); + ringTop.push([x, y + thickness, z]); + } + bottom.push(ringBottom); + top.push(ringTop); + } + + const centreBottom = [0, 0, 0]; + const centreTop = [0, thickness, 0]; + + const positions = []; + // The locals here are deliberately not called `a`, `b`, `c`: the harness's + // static settings pass treats `const c = ...` as poisoning the `c` alias for + // the whole file, and a geometry helper called `c` blinded it to every + // `settings.rime` read in the ability below it. + const tri = (p, q, r) => { + positions.push(p[0], p[1], p[2], q[0], q[1], q[2], r[0], r[1], r[2]); + }; + + for (let i = 0; i < sides; i++) { + const j = (i + 1) % sides; + + // The middle of both faces. Winding is authored, not left to chance: the + // top face has to come out +Y or a plate lying flat is lit from below. + tri(centreTop, top[0][j], top[0][i]); + tri(centreBottom, bottom[0][i], bottom[0][j]); + + for (let r = 0; r < rings - 1; r++) { + tri(top[r][i], top[r][j], top[r + 1][i]); + tri(top[r][j], top[r + 1][j], top[r + 1][i]); + tri(bottom[r][j], bottom[r][i], bottom[r + 1][i]); + tri(bottom[r][j], bottom[r + 1][i], bottom[r + 1][j]); + } + + // The cut edge. Two triangles per side, and they are the whole reason the + // ice reads as *thin*: without a wall the plate is a surface, and a surface + // has no thickness to see when it stands up on its hinge. + const outer = rings - 1; + tri(top[outer][i], top[outer][j], bottom[outer][j]); + tri(top[outer][i], bottom[outer][j], bottom[outer][i]); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + return geometry; +} + +/** + * One curl that snapped off, for the `ShatterField`. + * + * A bent ribbon inside a unit sphere and centred on its own origin, because + * the tumble rotates about that point. It is deliberately the *silhouette of a + * curl* rather than a generic chip: what breaks off a peeled plate is the lip, + * and the lip is a strip of ice bent through most of a half-turn. + */ +function createCurlFragmentGeometry(variant) { + const seed = 5.7 + variant * 13.1; + const segments = 6; + const arc = 1.4 + hash11(seed) * 1.6; // radians of bend + const radius = 0.42; + const halfWidth = 0.11 + hash11(seed * 2.3) * 0.13; + + const yMid = radius * (Math.cos(arc * 0.5) + 1) * 0.5; + const positions = []; + const tri = (p, q, r) => { + positions.push(p[0], p[1], p[2], q[0], q[1], q[2], r[0], r[1], r[2]); + }; + + const point = (s, side) => { + const bend = (s / segments - 0.5) * arc; + const w = halfWidth * (0.45 + 0.55 * Math.sin(Math.PI * (s / segments))); + return [radius * Math.sin(bend), radius * Math.cos(bend) - yMid, w * side]; + }; + + for (let s = 0; s < segments; s++) { + const q0 = point(s, -1); + const q1 = point(s, 1); + const q2 = point(s + 1, 1); + const q3 = point(s + 1, -1); + tri(q0, q1, q2); + tri(q0, q2, q3); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * RIME — Rimewalker. A corridor of sheet ice that peels up off the floor. + * + * **The trick: the ice is thin.** Every other frost slot in the sandbox is + * made of solids — the Lance's crystals, the Crown's blades. This one is made + * of *sheets*: plates three centimetres thick that glaze the floor behind a + * freezing front, lock, and then lift their downwind edge and roll it back on + * itself like paper curling off a hot pan. You can see the plate behind the one + * in front, the light catches the rolled lip and nowhere else, and the whole + * corridor peels in a wave because every plate is running its own curl clock. + * + * Four beats inside the base class's three: + * + * 1. **the front** runs the line at `speed` and the plates lock behind it, + * flat, on a staggered clock (`lockStagger`); + * 2. **the peel** — each plate lies flat for `curlDelay` of `curlTime` and + * then rolls its rim through `curl` radians; + * 3. **the hold** — `lifetime` seconds of a standing corridor of shards; + * 4. **the snap and the sublimation** — the curls break off into a + * `ShatterField` and a front runs the corridor *from the caster's end + * forward*, taking the ice with it. + * + * ## Why the curl lives in the vertex shader + * + * `GrowthField.syncGeometry()` rebuilds every variant at once, so baking the + * curl in as a shape parameter — which was the first attempt — peels the entire + * field in lockstep and the corridor hinges like one sheet of paper. The roll + * is therefore a constant-curvature bend in `SheetIceMaterial`'s vertex shader, + * clocked per instance off `aBirth`, which is the only per-instance stopwatch + * the field publishes. The stagger *is* the effect; see that module's header. + * + * ## Why the corridor is not a GroundField + * + * The obvious reading of the brief is one `GroundField` in `PLATE` mode + * stretched down the line. It does not work, and the reason is worth writing + * down. A `GroundField` is a **disc**: its growth front is radial, so it fills + * from the middle outward, and `recede` eats it from the *outside* in. A + * corridor that fills from the caster's end and then empties from the caster's + * end needs a front that runs along an axis, and the only way to get one out of + * a radial quad is to squash the mesh anisotropically — which works right up + * until the radius changes, at which point the whole cell pattern swims + * sideways under the ice already on the floor. Two hours went into that before + * the obvious answer turned up: **the flat plates are the glaze.** A plate + * before it curls is exactly a sheet of ice lying on the stone, and a hundred + * and sixty of them interlocking is the corridor. So the `GroundField` draws + * the one thing it is genuinely shaped like — the pool of glaze where the front + * grounds out — and the directional sublimation is a front in the plates' own + * fragment shader, measured in metres downrange of the caster. + * + * ## The rule that makes the editor work + * + * A cast captures one seed, one instance count, and four timestamps. Every + * metre, radian and second is resolved against `settings.rime` inside the + * update loop, on a zero-length frame included. Drag `curl` on a standing + * corridor and it peels; drag `thickness` and the sheets regenerate; drag + * `sublimeEdge` mid-fade and the band eating the ice widens where it stands. + * + * Six draw calls: three plate meshes, one glaze quad, two fragment meshes. + */ +export class RimeAbility extends Ability { + constructor(context) { + super('rime', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.material = createSheetIceMaterial(this.ctx.environment); + this.plateUniforms = this.material.userData.uniforms; + + /** + * Live shape controls, handed to the geometry factory. Mutated in place — + * an object literal per frame is the allocation I3 forbids — and compared + * numerically by `syncGeometry`, which only rebuilds when one of them moved. + */ + this._shape = { facets: 11, rings: 8, rimJitter: 0.34, buckle: 0.055, thickness: 0.04 }; + this._fillShape(); + + this.plates = new GrowthField(this.group, { + geometry: createIcePlateGeometry, + material: this.material, + shape: this._shape, + variants: PLATE_VARIANTS, + capacity: MAX_PLATES, + layer: LAYER.WORLD, + renderOrder: 2, + // The roll is a vertex-shader displacement and the depth material does + // not carry the patch, so a cast shadow would be the shadow of a flat + // plate that is not there. They take the stage's shadows; they do not + // throw one. + castShadow: false, + receiveShadow: true + }); + // Assigned once, at construction — a closure rebuilt per frame per instance + // is exactly what I3 forbids. + this.plates.onBreach = (index, position, radius) => this._lockFx(position, radius); + + this.glaze = new GroundField(this.group, { + mode: GroundMode.PLATE, + additive: false, + name: 'Rime:glaze' + }); + + this.curls = new ShatterField(this.group, { + geometry: createCurlFragmentGeometry, + variants: CURL_VARIANTS, + capacity: 192, + renderOrder: 6 + }); + + /* --- scratch parameter blocks, filled from settings every frame --- */ + this._growth = { + layout: GrowthLayout.LINE, + emerge: GrowthEmerge.SCALE, + origin: new Vector3(), + direction: new Vector3(0, 0, 1), + side: new Vector3(1, 0, 0), + length: 1, + // Sheets are all the same thickness, and the thickness rides the height + // scale — so the size variety is in the footprint and nowhere else. + heightJitter: 0, + lean: 0, + baseHeight: 0 + }; + + this._ground = { centre: new Vector3() }; + this._shatter = { + layout: ShatterLayout.LINE, + origin: new Vector3(), + direction: new Vector3(0, 0, 1), + side: new Vector3(1, 0, 0), + length: 1 + }; + this._look = {}; + + /** Re-rolled per cast so two corridors do not freeze in the same pattern. */ + this._seed = 0; + /** Timestamp the glaze pool started freezing out, or -1. */ + this._poolTime = -1; + /** One-shot latch on the snap. */ + this._snapped = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // Vapour boiling off the freezing front. Non-additive so it genuinely + // occludes — a corridor of thin plates needs something opaque behind it or + // the silhouette has nothing to read against. + this.mist = particles.get('rime.mist', { + capacity: 2400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.mist.uniforms.uDrag.value = 2.1; + this.mist.uniforms.uEndSize.value = 2.8; + this.mist.uniforms.uSizeIn.value = 0.12; + this.mist.uniforms.uFadeIn.value = 0.18; + this.mist.uniforms.uFadeOut.value = 0.3; + + // Ice crystals hanging in the air over the corridor. + this.glints = particles.get('rime.glints', { + capacity: 2000, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.glints.uniforms.uDrag.value = 1.3; + this.glints.uniforms.uEndSize.value = 0.16; + this.glints.uniforms.uSizeIn.value = 0.05; + this.glints.uniforms.uFadeIn.value = 0.07; + this.glints.uniforms.uFadeOut.value = 0.34; + + // Chips knocked loose as a plate locks. + this.chips = particles.get('rime.chips', { + capacity: 1600, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.3; + this.chips.uniforms.uEndSize.value = 0.7; + this.chips.uniforms.uFadeOut.value = 0.7; + + this.mistEmitter = new RateEmitter(); + this.glintEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.plates.count + this.curls.count; + } + + /** The corridor stands, then the snap and the sublimation take it apart. */ + get impactDuration() { + return Math.max(0.1, settings.rime.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + const c = settings.rime; + return Math.max(0.1, c.snapDelay + c.sublimeTime); + } + + /** Ice breathes where lightning gutters — a slow cosine, never a stutter. */ + lightShimmer() { + const c = settings.rime; + return 1 - c.lightPulse * (0.5 - 0.5 * Math.cos(this.age * c.lightPulseSpeed * TAU)); + } + + /* ------------------------------------------------------------------ */ + /* Live geometry — every metre resolved from settings, every frame */ + /* ------------------------------------------------------------------ */ + + /** + * The shape controls the plate geometry is baked against. + * + * `thickness` goes in as a ratio of the plate's own vertical scale, because + * the instance matrix multiplies unit-space `y` by `plateHeight` metres. Divide + * here and the sheet is `thickness` metres thick however tall the peel is set + * to; forget to, and raising the peel thickens the ice with it, which was the + * first version and made the plates look like roof tiles. + */ + _fillShape() { + const c = settings.rime; + const shape = this._shape; + shape.facets = c.facets; + shape.rings = c.rings; + shape.rimJitter = c.rimJitter; + shape.buckle = c.buckle; + shape.thickness = c.thickness / Math.max(0.02, c.plateHeight); + return shape; + } + + /** Footprint, silhouette and eruption of the plate field. */ + _fillGrowth() { + const c = settings.rime; + const g = settings.global; + const p = this._growth; + + p.origin.copy(this.origin); + p.direction.copy(this.direction); + p.side.copy(this.side); + p.length = this.length; + + p.widthNear = c.widthNear; + p.width = c.width; + p.widthCurve = c.widthCurve; + p.frontBias = c.frontBias; + p.clumping = c.clumping; + p.scatter = c.scatter; + p.clusterRadius = c.clusterRadius; + + p.heightNear = c.plateHeight; + p.height = c.plateHeightFar; + p.heightCurve = c.heightCurve; + p.crown = c.crown; + p.crownPower = c.crownPower; + + p.radiusNear = c.plateRadius; + p.radius2 = c.plateRadiusFar; + p.radiusCurve = c.radiusCurve; + p.radiusJitter = c.radiusJitter; + + p.tilt = c.tilt; + p.twist = c.twist; + + p.riseTime = c.riseTime; + p.riseOvershoot = c.riseOvershoot; + p.settle = c.settle; + p.springRate = c.springRate; + p.birthScale = c.birthScale; + // The birth flash IS the curl clock — see `SheetIceMaterial`'s header. + p.birthFade = Math.max(0.05, c.curlTime); + p.randomness = g.randomness; + return p; + } + + /** + * The glaze pool where the front grounds out. + * + * `windAngle` is 0 because the quad is yawed onto the cast: in its own frame + * downrange *is* +Z, so the pool's plates lift their downwind edge in the + * same direction the corridor's do, and the two read as one freeze rather + * than as a mark with an effect standing on it. + */ + _fillGround(grow, recede, fade) { + const c = settings.rime; + const g = settings.global; + const p = this._ground; + + this.pointAt(1, p.centre); + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.poolHeight; + p.radius = c.poolRadius; + p.seed = this._seed; + + p.grow = grow; + p.recede = recede; + p.fade = fade; + + p.edge = c.poolEdge; + p.ragged = c.poolRagged; + p.raggedScale = c.poolRaggedScale; + p.warp = c.poolWarp; + + p.relief = c.poolRelief; + p.normalStep = c.poolNormalStep; + p.ambient = c.poolAmbient; + p.wrap = c.poolWrap; + p.specular = c.poolSpecular; + p.gloss = c.poolGloss; + p.parallax = c.poolParallax; + + p.cell = c.poolCell; + p.cellJitter = c.poolCellJitter; + p.seam = c.poolSeam; + p.thickness = c.poolThickness; + p.lift = c.poolLift; + p.detail = c.poolDetail; + p.windAngle = 0; + + p.additive = false; + p.emissive = c.poolEmissive; + p.opacity = c.poolOpacity; + p.depthFade = c.poolDepthFade; + p.colorBase = c.colorPoolBase; + p.colorEdge = c.colorPoolEdge; + p.colorGlow = c.colorPoolGlow; + p.colorDeep = c.colorPoolDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + return p; + } + + /** The flight of one snapped-off curl. */ + _fillShatter() { + const c = settings.rime; + const g = settings.global; + const p = this._shatter; + + p.origin.copy(this.origin); + p.direction.copy(this.direction); + p.side.copy(this.side); + p.length = this.length; + p.width = c.width; + + p.spawnRadius = c.shardSpawnRadius; + p.spawnHeight = c.shardSpawnHeight; + p.speed = c.shardSpeed; + p.speedJitter = c.shardSpeedJitter; + p.spread = c.shardSpread; + p.upBias = c.shardUpBias; + p.gravity = c.shardGravity; + p.drag = c.shardDrag; + p.size = c.shardSize; + p.sizeJitter = c.shardSizeJitter; + p.shrink = c.shardShrink; + p.shrinkPower = c.shardShrinkPower; + p.spin = c.shardSpin; + p.spinJitter = c.shardSpinJitter; + p.lifetime = c.shardLifetime; + p.floor = c.shardFloor; + p.floorSpin = c.shardFloorSpin; + p.randomness = g.randomness; + return p; + } + + /** Colours and shading of the fragments. Four pickers, none derived (I5). */ + _fillLook() { + const c = settings.rime; + const g = settings.global; + const look = this._look; + look.colorA = getColor(c.colorShardA); + look.colorB = getColor(c.colorShardB); + look.colorEdge = getColor(c.colorShardEdge); + look.opacity = c.shardOpacity * g.opacity; + look.glow = c.shardGlow * g.glow; + look.rim = c.shardRim; + look.rimPower = c.shardRimPower; + look.shade = c.shardShade; + look.ambient = c.shardAmbient; + look.fadeStart = c.shardFadeStart; + look.soft = c.shardSoft; + return look; + } + + /** + * How far the sublimation front has run, in metres downrange of the caster. + * + * Well below the corridor until the snap, so the plates' fragment shader + * leaves everything intact; then it sweeps from just behind the caster to + * just past the target. The band, not the front, is where the ice is actually + * turning to vapour, which is why it is the brightest thing in the fade. + */ + _sublimeFront() { + const c = settings.rime; + if (this.phase !== AbilityPhase.FADE) return -1e4; + const k = saturate((this.fadeTime - c.snapDelay) / Math.max(0.05, c.sublimeTime)); + return lerp(-c.sublimeEdge, this.length + c.sublimeEdge * 2, k); + } + + /** 0..1 — how far the glaze pool has frozen out. */ + _poolGrowth() { + if (this._poolTime < 0) return 0; + return saturate((this.age - this._poolTime) / Math.max(0.05, settings.rime.poolGrow)); + } + + /** 0..1 — how far the pool has been eaten back. It goes last, and from its rim. */ + _poolRecede() { + const c = settings.rime; + if (this.phase !== AbilityPhase.FADE) return 0; + const k = saturate((this.fadeTime - c.snapDelay) / Math.max(0.05, c.sublimeTime)); + const hold = Math.min(0.98, c.poolHold); + return saturate((k - hold) / (1 - hold)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.rime; + + this.mistEmitter.reset(); + this.glintEmitter.reset(); + this._poolTime = -1; + this._snapped = false; + this._seed = Math.random() * 100; + + this.curls.clear(); + this.plates.plant(Math.min(MAX_PLATES, Math.round(c.plateCount)), c.clusterShare); + + this._syncUniforms(); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into the sheet material, the glaze and the + * fragments. Everything with a unit is read here and nowhere else. + */ + _syncUniforms() { + const c = settings.rime; + const g = settings.global; + const u = this.plateUniforms; + + this.material.userData.sync(); + + // The cast's own frame, which the material cannot read for itself: the + // direction the plates peel toward, and the axis the sublimation front is + // measured along. + u.uCurlDir.value.set(this.direction.x, this.direction.z); + u.uSublimeOrigin.value.copy(this.origin); + u.uSublimeDir.value.copy(this.direction); + u.uSublimeFront.value = this._sublimeFront(); + + this.curls.sync(this._fillLook()); + + /* --- the three particle systems --- */ + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = 0.35 * g.turbulence; + + this.glints.setGradient( + getColor(c.colorGlintA), + getColor(c.colorGlintB), + getColor(c.colorGlintC), + getColor(c.colorGlintD) + ); + this.glints.uniforms.uGravity.value.set(0, c.glintRise, 0); + this.glints.uniforms.uSizeScale.value = c.glintSize * g.particleSize * 7; + this.glints.uniforms.uLifeScale.value = c.glintLifetime * 0.5 * g.particleLifetime; + this.glints.uniforms.uSpeedScale.value = c.glintSpeed * g.particleSpeed; + this.glints.uniforms.uOpacity.value = g.opacity; + this.glints.uniforms.uGlow.value = 0.9 * g.glow; + this.glints.uniforms.uTurbulence.value = c.glintTurbulence * g.turbulence; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = c.chipLifetime * 0.5 * g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + } + + /** Rebuild the field, the glaze and the fragments against the live settings. */ + _syncFields(grow, recede, fade) { + this.plates.syncGeometry(this._fillShape()); + this.plates.update(this.age, this._fillGrowth(), 0); + this.glaze.update(this._fillGround(grow, recede, fade)); + this.curls.update(this.age, this._fillShatter()); + } + + /** A breath of frost at the caster's hand as the front leaves it. */ + _muzzleFx() { + const c = settings.rime; + const g = settings.global; + + _pos.copy(this.origin).addScaledVector(this.direction, 0.5).setY(0.4); + + this.ctx.bursts.spawn(BurstMode.FROST, _pos, { + radius: c.burstSize * 0.15, + endRadius: c.burstSize * 0.55 * g.explosionIntensity, + life: 0.5, + intensity: c.burstIntensity * 0.7, + opacity: 0.8, + fresnel: 1.4, + displace: 0.4, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /** + * Chips and a puff of vapour where one plate locks onto the stone. + * Called by `GrowthField` the frame an instance breaks its `breachAt`. + */ + _lockFx(position, radius) { + const c = settings.rime; + const g = settings.global; + + _pos.copy(position).setY(0.05); + + _emit.position = _pos; + _emit.radius = radius * 0.5; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.45).setY(1).normalize(); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 8; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.chips.emit(Math.round(c.breachChips * g.particleCount), _emit); + } + + /** + * Vapour off the freezing front and crystals hanging over the corridor. + * @param {number} scale 0..1 — thinned once the corridor is only standing + */ + _corridorFx(dt, scale) { + const c = settings.rime; + const g = settings.global; + const time = frame.uTime.value; + const reach = this.phase === AbilityPhase.TRAVEL ? Math.max(0.03, this.u) : 1; + + const mistCount = Math.round(this.mistEmitter.tick(dt, c.mistRate * scale) * g.particleCount); + if (mistCount > 0) { + // The vapour comes off the freezing front itself while it is running, and + // off the whole corridor once it has landed. + const s = this.phase === AbilityPhase.TRAVEL ? reach : Math.random() * reach; + this.pointAt(s, _pos).setY(0.14); + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, s) * 1.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = time; + this.mist.emit(mistCount, _emit); + } + + const glintCount = Math.round(this.glintEmitter.tick(dt, c.glintRate * scale) * g.particleCount); + if (glintCount > 0) { + const s = Math.random() * reach; + this.pointAt(s, _pos).setY(0.35); + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, s); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.glintSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.size = 0.07; + _emit.sizeVariance = 0.6; + _emit.life = c.glintLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.glints.emit(glintCount, _emit); + } + } + + /** + * The curls letting go, all at once, along the whole corridor. + * + * `burst()` takes one `along` per call, so this walks the line in batches — + * one call would drop every fragment on the same spot and read as a grenade + * rather than as a hundred and sixty lips snapping at the same instant. + */ + _snapFx() { + const c = settings.rime; + const g = settings.global; + const total = Math.round(c.shardCount * g.particleCount); + const per = Math.max(1, Math.round(total / SNAP_BATCHES)); + + for (let batch = 0; batch < SNAP_BATCHES; batch++) { + this.curls.burst(this.age, per, (batch + 0.5) / SNAP_BATCHES, randRange(-0.85, 0.85)); + } + + this.ctx.shake.add( + c.snapShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.rime; + + this._syncUniforms(); + this.plates.triggerUpTo(this.age, this.u, c.lockStagger, c.frontBias, false); + this._syncFields(this._poolGrowth(), 0, saturate(this._poolGrowth() * 3)); + this._corridorFx(dt, 1); + + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.rime; + const g = settings.global; + const time = frame.uTime.value; + + // Everything still waiting locks now, the terminal ring included. + this.plates.triggerUpTo(this.age, 1, c.lockStagger, c.frontBias, true); + this._poolTime = this.age; + + this.pointAt(1, _target); + _pos.copy(_target).setY(0.35); + + /* the shell of freezing vapour where the front grounded out */ + this.ctx.bursts.spawn(BurstMode.FROST, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.85, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.4, + displace: 0.5, + squash: 0.68, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring that snaps outward across the floor */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _target, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.7, + width: 0.05, + intensity: 0.85, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* chips and vapour off the landing */ + _emit.position = _pos; + _emit.radius = c.poolRadius * 0.5; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chipSpeed * 1.8; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.13; + _emit.sizeVariance = 0.8; + _emit.life = c.chipLifetime * 1.3; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.chips.emit(Math.round(40 * g.particleCount), _emit); + + _emit.speed = c.mistSpeed * 2.6; + _emit.spread = 1.0; + _emit.size = 1.4; + _emit.life = c.mistLifetime * 1.3; + _emit.spin = 0.5; + this.mist.emit(Math.round(70 * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.rime; + + if (this.phase === AbilityPhase.FADE && !this._snapped && this.fadeTime >= c.snapDelay) { + this._snapped = true; + this._snapFx(); + } + + this._syncUniforms(); + this._syncFields(this._poolGrowth(), this._poolRecede(), saturate(this._poolGrowth() * 3)); + + // The light follows the interesting event: it rides the front out, sits on + // the pool through the hold, and then walks back down the corridor with the + // sublimation band. + const front = this._sublimeFront(); + if (front > 0) { + this.pointAt(saturate(front / Math.max(0.1, this.length)), this.position); + this.position.y = 0.3; + } + + // Vapour keeps coming off the standing corridor, and harder as the + // sublimation front eats it. + const boil = this.phase === AbilityPhase.FADE ? 1.4 : 0.5; + this._corridorFx(dt, boil * (t <= 1 ? 1 : 1 - saturate((t - 1) * 0.6))); + } + + onDestroy() { + this.plates.clear(); + this.curls.clear(); + this._poolTime = -1; + this._snapped = false; + this.plateUniforms.uSublimeFront.value = -1e4; + this.glaze.update(this._fillGround(0, 0, 0)); + } + + dispose() { + this.plates.dispose(); + this.curls.dispose(); + this.glaze.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/frost/ShatterlanceAbility.js b/src/abilities/frost/ShatterlanceAbility.js new file mode 100644 index 0000000..0759604 --- /dev/null +++ b/src/abilities/frost/ShatterlanceAbility.js @@ -0,0 +1,989 @@ +import { + BufferGeometry, + Float32BufferAttribute, + InstancedBufferGeometry, + InstancedBufferAttribute, + Mesh, + Vector3 +} from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { createLanceMaterial, createIntakeMaterial } from '../../materials/GlacialLanceMaterial.js'; +import { createCrystalGeometry } from '../../assets/ProceduralGeometry.js'; +import { GrowthField, GrowthLayout, GrowthEmerge } from '../../vfx/GrowthField.js'; +import { ShatterField, ShatterLayout } from '../../vfx/ShatterField.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, hash11, randRange, Easing } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on shards in the intake. The editor's `intakeCount` clamps here. */ +const MAX_INTAKE = 256; +/** Hard ceiling on fragments the lance breaks into. */ +const MAX_FRAGMENTS = 256; +/** Distinct fragment silhouettes — two, as `ShatterField` recommends. */ +const FRAGMENT_VARIANTS = 2; +/** + * How many points along the lance one break is split between. + * `ShatterField#burst` takes one `along` for a whole batch, so a single call + * drops two hundred fragments on one spot — which is a firework, not a lance + * that came apart along its own length. + */ +const BREAK_BATCHES = 12; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hub = new Vector3(); +const _target = new Vector3(); + +/* ---------------------------------------------------------------------- */ +/* Geometry */ +/* ---------------------------------------------------------------------- */ + +/** + * The lance, in `GrowthField`'s unit space: tail apex at `y = 0`, point at + * `y = 1`, footprint inside a circle of radius 0.5. + * + * A fluted double-ended spindle. The flutes are cut by giving every ring + * `2 × facets` vertices and pulling the odd ones in by `flute`, which is the + * one detail worth spelling out: the obvious way to groove a prism is to + * modulate alternate *facets*, and that only works for an even facet count — + * at seven facets the modulation wraps and two neighbouring faces come out the + * same depth, which reads as a dent rather than as fluting. Interleaving a + * pulled-in vertex between each pair alternates correctly at any count, and + * doubles the silhouette resolution for free. + * + * `fluteTwist` rotates those grooves as they climb, and the lance material's + * seam term follows the same helix, so the glow sits in the valleys rather + * than crawling across the ridges. + */ +function createLanceGeometry(variant, shape) { + const seed = 9.7 + variant * 31.3; + const facets = clamp(Math.round(shape?.facets ?? 7), 4, 20); + const swell = clamp(shape?.swell ?? 0.26, 0.05, 0.9); + const tailWidth = clamp(shape?.tailWidth ?? 0.22, 0.02, 1); + const tipCurve = Math.max(0.2, shape?.tipCurve ?? 1.35); + const flute = clamp(shape?.flute ?? 0.28, 0, 0.8); + const fluteTwist = shape?.fluteTwist ?? 0.5; + const jitter = clamp(shape?.bodyJitter ?? 0.12, 0, 0.8); + + const sides = facets * 2; + // Rings bunched where the silhouette turns: just off the tail, at the swell, + // and again as it runs out into the point. + const heights = [ + 0.03, + swell * 0.45, + swell, + lerp(swell, 1, 0.35), + lerp(swell, 1, 0.68), + lerp(swell, 1, 0.88) + ]; + + /** Radius of the body at `t`, as a fraction of the unit footprint. */ + const profile = (t) => { + const r = + t <= swell + ? tailWidth + (1 - tailWidth) * Math.pow(t / Math.max(swell, 1e-3), 0.65) + : Math.pow(1 - (t - swell) / Math.max(1 - swell, 1e-3), tipCurve); + return r * 0.5; + }; + + const rings = heights.map((t, ringIndex) => { + const base = profile(t); + const spin = fluteTwist * t * (TAU / facets); + const ring = []; + for (let j = 0; j < sides; j++) { + const angle = (j / sides) * TAU + spin; + const groove = j % 2 === 0 ? 1 : 1 - flute; + const wobble = 1 + (hash11(seed * 7.3 + ringIndex * 13.1 + j * 3.7) - 0.5) * jitter * 0.6; + const radius = Math.max(0.002, base * groove * wobble); + ring.push([Math.cos(angle) * radius, t, Math.sin(angle) * radius]); + } + return ring; + }); + + const tail = [0, 0, 0]; + const tip = [ + (hash11(seed * 17.3) - 0.5) * 0.04 * jitter, + 1, + (hash11(seed * 19.9) - 0.5) * 0.04 * jitter + ]; + + const positions = []; + const tri = (p, q, r) => { + positions.push(p[0], p[1], p[2], q[0], q[1], q[2], r[0], r[1], r[2]); + }; + + for (let j = 0; j < sides; j++) { + const k = (j + 1) % sides; + tri(tail, rings[0][j], rings[0][k]); + for (let r = 0; r < rings.length - 1; r++) { + tri(rings[r][j], rings[r + 1][j], rings[r][k]); + tri(rings[r][k], rings[r + 1][j], rings[r + 1][k]); + } + tri(rings[rings.length - 1][j], tip, rings[rings.length - 1][k]); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + return geometry; +} + +/** + * One fragment of the broken lance, centred on its own origin because the + * tumble rotates about that point. + * + * The reference crystal, re-centred and squashed: using the *same* generator + * the ice field uses is the point, because the fragments have to look like + * pieces of the thing that broke rather than like generic debris. + */ +function createLanceFragmentGeometry(variant) { + const geometry = createCrystalGeometry({ + seed: 11.3 + variant * 23.9, + sides: 5, + taper: 0.1, + roughness: 0.55, + bend: 0.3 + }); + geometry.translate(0, -0.5, 0); + geometry.scale(0.8, 1.0, 0.8); + return geometry; +} + +/** + * The intake: one instanced shard, `MAX_INTAKE` copies, and a single seed per + * copy. Everything else about the flight is in the vertex shader. + * + * A plain `Mesh` over an `InstancedBufferGeometry` rather than an + * `InstancedMesh`, deliberately: an `InstancedMesh` would multiply every vertex + * by an instance matrix this ability does not want to maintain on the CPU, and + * the whole point of the intake is that there is no CPU state per shard at all. + * The bolt's ribbon strip is built the same way for the same reason. + */ +function createIntakeGeometry() { + const shard = createCrystalGeometry({ + seed: 4.2, + sides: 5, + taper: 0.16, + roughness: 0.5, + bend: 0.25 + }); + shard.translate(0, -0.5, 0); + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', shard.getAttribute('position')); + geometry.setAttribute('normal', shard.getAttribute('normal')); + + const seeds = new Float32Array(MAX_INTAKE); + // Golden-ratio spacing: the hash in the vertex shader decorrelates them, and + // a stratified input keeps the shell evenly covered at any live count. + for (let i = 0; i < MAX_INTAKE; i++) seeds[i] = 0.13 + i * 0.6180339887; + geometry.setAttribute('aSeed', new InstancedBufferAttribute(seeds, 1)); + geometry.instanceCount = 0; + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * SHATTERLANCE — one enormous ice lance, assembled, thrown, and broken. + * + * **The trick: the wind-up is the ability.** Every other line cast in the + * sandbox leaves the caster on the frame you click. This one refuses to: for + * `assembly` seconds a hundred and twenty shards converge out of a shell in the + * air and merge into a single lance, which hangs there — bobbing, its flute + * seams filling with light from the tail forward — long enough for you to read + * it as **one object**. Then it goes, at sixty metres a second, and the impact + * breaks that one object into two hundred fragments that inherit its flight + * velocity, tumble and shrink. The contrast between the single silhouette and + * the two hundred is the whole slot; everything else is in service of it. + * + * Four beats out of the base class's three, and it costs nothing: `advance()` + * simply refuses to move the front until the lance is whole, exactly as + * `BeamAbility` buys its charge. The phase machine still runs travel → impact → + * fade and never has to know. + * + * ## The convergence is parametric, not simulated + * + * A shard's position is `mix(point on the shell, point on the lance, ease(t))` + * where `t` is the assembly clock less that shard's own stagger, evaluated in + * the vertex shader from eight uniforms and one seed. There is no per-shard + * state on the CPU, so pausing mid-wind-up and dragging `intakeSphere` re-flies + * all hundred and twenty from a wider shell — which a simulation physically + * could not do, because it has already spent the old radius. The lance itself + * is a `GrowthField` of exactly **one** instance, whose length and girth are + * driven off the same clock on two different curves so it extends before it + * thickens. + * + * A first version fed the intake with `Swarm`, which is the module that looks + * like it should own this: agents cohering to a lead with `gather` running to + * zero really does collapse a formation onto a point. It was wrong for two + * reasons — the formation is a lattice box rather than a shell, so the shards + * arrived in ranks, and the silhouettes on offer are birds, leaves, cards, + * droplets and motes, none of which is a piece of ice. The shards had to be + * real chips of the same crystal the lance is made of or the merge reads as a + * lance switching on inside a cloud of sparks. + * + * ## The break + * + * `ShatterField` in LINE layout, burst in twelve batches walking the lance's + * own span so the fragments come off its whole length at once, with `inherit` + * set to the flight velocity times `fragInherit`. That last number is the one + * that decides whether the impact reads as shrapnel or as confetti, and it is + * re-resolved every frame — drag it during the fade and the debris re-flies. + * + * ## The rule that makes the editor work + * + * A cast captures one seed and three timestamps. Five draw calls: the lance, + * the intake, two fragment meshes and the frost sheet. + */ +export class ShatterlanceAbility extends Ability { + constructor(context) { + super('shatterlance', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + const environment = this.ctx.environment; + + this.lanceMaterial = createLanceMaterial(environment); + this.lanceUniforms = this.lanceMaterial.userData.uniforms; + this.intakeMaterial = createIntakeMaterial(); + this.intakeUniforms = this.intakeMaterial.userData.uniforms; + + /** Live shape controls for the lance body. Mutated in place — I3. */ + this._shape = { + facets: 7, + swell: 0.26, + tailWidth: 0.22, + tipCurve: 1.35, + flute: 0.28, + fluteTwist: 0.5, + bodyJitter: 0.12 + }; + this._fillShape(); + + // One instance. `variants: 1` is what `GrowthField` asks for when the thing + // genuinely does not repeat, and it is the difference between one draw call + // and three for a field of exactly one object. + this.lance = new GrowthField(this.group, { + geometry: createLanceGeometry, + material: this.lanceMaterial, + shape: this._shape, + variants: 1, + capacity: 1, + layer: LAYER.WORLD, + renderOrder: 2, + castShadow: true, + receiveShadow: true + }); + + this.intakeGeometry = createIntakeGeometry(); + this.intake = new Mesh(this.intakeGeometry, this.intakeMaterial); + this.intake.frustumCulled = false; + this.intake.matrixAutoUpdate = false; + this.intake.layers.set(LAYER.VFX); + this.intake.renderOrder = 7; + this.group.add(this.intake); + + this.frags = new ShatterField(this.group, { + geometry: createLanceFragmentGeometry, + variants: FRAGMENT_VARIANTS, + capacity: MAX_FRAGMENTS, + renderOrder: 6 + }); + + this.sheet = new GroundField(this.group, { + mode: GroundMode.PLATE, + additive: false, + name: 'Shatterlance:sheet' + }); + + /* --- scratch parameter blocks, filled from settings every frame --- */ + this._growth = { + layout: GrowthLayout.LINE, + emerge: GrowthEmerge.SCALE, + origin: new Vector3(), + direction: new Vector3(0, 0, 1), + side: new Vector3(1, 0, 0), + length: 0, + // One object: no jitter of any kind, or it stops being one object. + clusterRadius: 0, + heightJitter: 0, + radiusJitter: 0, + leanJitter: 0, + leanRamp: 1, + leanForward: 1, + leanOutward: 0, + tilt: 0, + crown: 0, + peak: 1, + rubble: 0, + riseTime: 0.02, + riseOvershoot: 0, + birthScale: 1 + }; + + this._shatter = { + layout: ShatterLayout.LINE, + origin: new Vector3(), + direction: new Vector3(0, 0, 1), + side: new Vector3(1, 0, 0), + length: 1, + inherit: new Vector3(), + inheritScale: 1 + }; + this._ground = { centre: new Vector3() }; + this._look = {}; + + /** Re-rolled per cast so two lances do not assemble in the same order. */ + this._seed = 0; + /** Timestamp the frost sheet started spreading, or -1. */ + this._sheetTime = -1; + /** One-shot latch on the break. */ + this._broken = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // The intake motes. This is the only system in the project using the + // engine's `swirl` mode with a *negative* expansion: `uSwirlExpand` scales + // the particle's offset from its anchor by `1 + expand * t`, so at -0.92 the + // offset collapses to eight per cent of itself over the mote's life and the + // whole shell spirals in. Emitting them at radius `moteShell` about the + // lance's own centre then costs nothing on the CPU. + this.motes = particles.get('shatterlance.motes', { + capacity: 2400, + shape: ParticleShape.SOFT, + additive: true, + swirl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 2.2; + this.motes.uniforms.uEndSize.value = 0.1; + this.motes.uniforms.uSizeIn.value = 0.05; + this.motes.uniforms.uFadeIn.value = 0.1; + this.motes.uniforms.uFadeOut.value = 0.3; + + // Chips off the break. + this.chips = particles.get('shatterlance.chips', { + capacity: 2000, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.28; + this.chips.uniforms.uEndSize.value = 0.75; + this.chips.uniforms.uFadeOut.value = 0.72; + + // Vapour rolling off the frost sheet. Non-additive so it occludes. + this.mist = particles.get('shatterlance.mist', { + capacity: 2000, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.mist.uniforms.uDrag.value = 2.0; + this.mist.uniforms.uEndSize.value = 3.0; + this.mist.uniforms.uSizeIn.value = 0.12; + this.mist.uniforms.uFadeIn.value = 0.16; + this.mist.uniforms.uFadeOut.value = 0.3; + + this.moteEmitter = new RateEmitter(); + this.mistEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.lance.count + this.frags.count + this.intakeGeometry.instanceCount; + } + + get impactDuration() { + return Math.max(0.05, settings.shatterlance.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.shatterlance.fadeTime); + } + + /** It breathes as it charges and steadies once it is thrown. */ + lightShimmer() { + const c = settings.shatterlance; + const charging = 1 - saturate(this.assembly); + return 1 - c.lightPulse * charging * (0.5 - 0.5 * Math.cos(this.age * c.lightPulseSpeed * TAU)); + } + + /** How far the lance has assembled, 0..1. Past 1 it is whole. */ + get assembly() { + return saturate(this.age / Math.max(0.01, settings.shatterlance.assembly)); + } + + /** + * Hold the front at the caster until the lance is whole. + * + * The entire fourth beat, and it needs nothing from the base class. The ease + * off the standstill is keyed off the moment of *release* rather than the + * moment of the cast, exactly as `BeamAbility` does — key it off the cast and + * a long wind-up would let the lance leave at full speed on its first frame. + */ + advance(dt) { + const c = this.config; + const assembly = Math.max(0, c.assembly); + if (this.age < assembly) return false; + + const speed = c.speed * settings.global.speed; + const since = this.age - assembly; + this.front += speed * Easing.outQuad(saturate(since / 0.04)) * dt; + + const previousU = this.u; + this.u = saturate(this.front / this.length); + this.pointAt(this.u, this.position); + return this.u >= 1 && previousU < 1; + } + + /* ------------------------------------------------------------------ */ + /* Live geometry — every metre resolved from settings, every frame */ + /* ------------------------------------------------------------------ */ + + _fillShape() { + const c = settings.shatterlance; + const shape = this._shape; + shape.facets = c.facets; + shape.swell = c.swell; + shape.tailWidth = c.tailWidth; + shape.tipCurve = c.tipCurve; + shape.flute = c.flute; + shape.fluteTwist = c.fluteTwist; + shape.bodyJitter = c.bodyJitter; + return shape; + } + + /** Metres downrange the lance's *point* has reached. */ + _tipDistance() { + const c = settings.shatterlance; + // It hangs `hoverForward` metres out in front while it assembles and then + // runs to the target: interpolating on `u` rather than adding to it is what + // makes the point land exactly on the aim marker instead of overshooting it + // by the length of the hover. + return lerp(c.hoverForward, this.length, this.u); + } + + /** Metres above the floor the lance's axis sits at, bob included. */ + _lanceHeight() { + const c = settings.shatterlance; + return c.lanceHeight + Math.sin(this.age * c.hoverBobSpeed * TAU) * c.hoverBob * (1 - this.u); + } + + /** World centre of the lance. The intake converges on this. */ + _lanceHub(out) { + const c = settings.shatterlance; + const form = this._lengthForm(); + out + .copy(this.origin) + .addScaledVector(this.direction, this._tipDistance() - c.lanceLength * form * 0.5); + out.y = this._lanceHeight(); + return out; + } + + /** 0..1 — how much of its full length the lance has, on the assembly clock. */ + _lengthForm() { + const c = settings.shatterlance; + return lerp(c.formSeed, 1, Math.pow(this.assembly, Math.max(0.05, c.formLengthCurve))); + } + + /** 0..1 — and how much of its girth, on a different curve. */ + _radiusForm() { + const c = settings.shatterlance; + return lerp(c.formSeed, 1, Math.pow(this.assembly, Math.max(0.05, c.formRadiusCurve))); + } + + /** Placement and size of the one instance. */ + _fillGrowth() { + const c = settings.shatterlance; + const g = settings.global; + const p = this._growth; + const form = this._lengthForm(); + + p.origin.copy(this.origin); + p.direction.copy(this.direction); + p.side.copy(this.side); + // The cluster record sits at `origin + direction * length`, and the unit + // geometry runs tail-at-0 to point-at-1, so the anchor is the *tail*. + p.length = this._tipDistance() - c.lanceLength * form; + + p.heightNear = c.lanceLength * form; + p.height = c.lanceLength * form; + p.radiusNear = c.lanceRadius * this._radiusForm(); + p.radius2 = c.lanceRadius * this._radiusForm(); + p.baseHeight = this._lanceHeight(); + + p.lean = c.lanceLean; + p.twist = c.lanceRoll; + p.birthFade = Math.max(0.05, c.assembly * 0.12); + p.randomness = g.randomness; + return p; + } + + /** The frost sheet thrown flat across the stone by the break. */ + _fillGround(grow, recede, fade) { + const c = settings.shatterlance; + const g = settings.global; + const p = this._ground; + + this.pointAt(1, p.centre); + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.sheetHeight; + p.radius = c.sheetRadius; + p.seed = this._seed; + + p.grow = grow; + p.recede = recede; + p.fade = fade; + + p.edge = c.sheetEdge; + p.ragged = c.sheetRagged; + p.raggedScale = c.sheetRaggedScale; + p.warp = c.sheetWarp; + + p.relief = c.sheetRelief; + p.normalStep = c.sheetNormalStep; + p.ambient = c.sheetAmbient; + p.wrap = c.sheetWrap; + p.specular = c.sheetSpecular; + p.gloss = c.sheetGloss; + p.parallax = c.sheetParallax; + + p.cell = c.sheetCell; + p.cellJitter = c.sheetCellJitter; + p.seam = c.sheetSeam; + p.thickness = c.sheetThickness; + p.lift = c.sheetLift; + p.detail = c.sheetDetail; + // The plates lift downrange, which in the quad's own yawed frame is 0 — the + // direction is the cast's, not a number anybody should be authoring. + p.windAngle = 0; + + p.additive = false; + p.emissive = c.sheetEmissive; + p.opacity = c.sheetOpacity; + p.depthFade = c.sheetDepthFade; + p.colorBase = c.colorSheetBase; + p.colorEdge = c.colorSheetEdge; + p.colorGlow = c.colorSheetGlow; + p.colorDeep = c.colorSheetDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + return p; + } + + /** The flight of one fragment. */ + _fillShatter() { + const c = settings.shatterlance; + const g = settings.global; + const p = this._shatter; + + p.origin.copy(this.origin); + p.direction.copy(this.direction); + p.side.copy(this.side); + p.length = this.length; + p.width = c.lanceRadius * 2; + + // Debris has to have come from somewhere: the fragments leave with a share + // of the lance's own velocity, so the cloud keeps travelling downrange + // instead of blooming on the spot. + p.inherit.copy(this.direction).multiplyScalar(c.speed * g.speed * c.fragInherit); + + p.spawnRadius = c.fragSpawnRadius; + p.spawnHeight = c.fragSpawnHeight; + p.speed = c.fragSpeed; + p.speedJitter = c.fragSpeedJitter; + p.spread = c.fragSpread; + p.upBias = c.fragUpBias; + p.gravity = c.fragGravity; + p.drag = c.fragDrag; + p.size = c.fragSize; + p.sizeJitter = c.fragSizeJitter; + p.shrink = c.fragShrink; + p.shrinkPower = c.fragShrinkPower; + p.spin = c.fragSpin; + p.spinJitter = c.fragSpinJitter; + p.lifetime = c.fragLifetime; + p.floor = c.fragFloor; + p.floorSpin = c.fragFloorSpin; + p.randomness = g.randomness; + return p; + } + + /** Colours and shading of the fragments. Three pickers, none derived (I5). */ + _fillLook() { + const c = settings.shatterlance; + const g = settings.global; + const look = this._look; + look.colorA = getColor(c.colorFragA); + look.colorB = getColor(c.colorFragB); + look.colorEdge = getColor(c.colorFragEdge); + look.opacity = c.fragOpacity * g.opacity; + look.glow = c.fragGlow * g.glow; + look.rim = c.fragRim; + look.rimPower = c.fragRimPower; + look.shade = c.fragShade; + look.ambient = c.fragAmbient; + look.fadeStart = c.fragFadeStart; + look.soft = c.fragSoft; + return look; + } + + /** 0..1 — how far the frost sheet has spread. */ + _sheetGrowth() { + if (this._sheetTime < 0) return 0; + return saturate((this.age - this._sheetTime) / Math.max(0.02, settings.shatterlance.sheetGrow)); + } + + /** 0..1 — how far it has been eaten back from its rim. */ + _sheetRecede() { + const c = settings.shatterlance; + if (this.phase !== AbilityPhase.FADE) return 0; + const k = saturate(this.fadeTime / Math.max(0.05, c.fadeTime)); + const hold = Math.min(0.98, c.sheetHold); + return saturate((k - hold) / (1 - hold)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.mistEmitter.reset(); + this._sheetTime = -1; + this._broken = false; + this._seed = Math.random() * 100; + + this.frags.clear(); + this.lance.clear(); + // A cluster of one: `plant(1, 1)` puts the single record in the terminal + // group, whose position is the anchor exactly, rather than at a random + // fraction down the line — which is where `plant(1, 0)` would leave it. + this.lance.plant(1, 1); + this.lance.triggerIndex(0, 0, 0); + + this._syncUniforms(); + this._syncFields(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + _syncUniforms() { + const c = settings.shatterlance; + const g = settings.global; + + this.lanceMaterial.userData.sync(); + this.lanceUniforms.uCharge.value = this.assembly; + + this.intakeMaterial.userData.sync(); + const u = this.intakeUniforms; + this._lanceHub(_hub); + u.uHub.value.copy(_hub); + u.uAxis.value.copy(this.direction); + u.uSide.value.copy(this.side); + // The third leg of the frame. Recomputed rather than assumed to be +Y so a + // pitched lance still has its shards land on the body and not beside it. + u.uUp.value.crossVectors(this.direction, this.side).normalize(); + u.uAssembly.value = this.assembly; + u.uLanceHalf.value = c.lanceLength * this._lengthForm() * 0.5; + u.uLanceRadius.value = c.lanceRadius * this._radiusForm(); + u.uSeed.value = this._seed; + // Gone by the time it fires; the lance is what is left. + u.uFade.value = 1 - saturate((this.assembly - 0.92) / 0.08); + this.intakeGeometry.instanceCount = Math.min(MAX_INTAKE, Math.max(0, Math.round(c.intakeCount))); + + this.frags.sync(this._fillLook()); + + /* --- the three particle systems --- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 0.95 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + this.motes.uniforms.uSwirl.value = c.moteSwirl; + // Negative: the offset from the anchor *shrinks* over the mote's life. + this.motes.uniforms.uSwirlExpand.value = -Math.min(0.99, c.moteConverge); + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = c.chipLifetime * 0.5 * g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = 0.4 * g.turbulence; + } + + /** Rebuild the lance, the fragments and the sheet against live settings. */ + _syncFields() { + this.lance.syncGeometry(this._fillShape()); + this.lance.update(this.age, this._fillGrowth(), 0); + this.frags.update(this.age, this._fillShatter()); + this.sheet.update( + this._fillGround(this._sheetGrowth(), this._sheetRecede(), saturate(this._sheetGrowth() * 4)) + ); + } + + /** Frost drawn in out of the air while the lance assembles. */ + _chargeFx(dt) { + const c = settings.shatterlance; + const g = settings.global; + + const count = Math.round(this.moteEmitter.tick(dt, c.moteRate) * g.particleCount); + if (count > 0) { + this._lanceHub(_pos); + _emit.position = _pos; + _emit.radius = c.moteShell; + // The anchor is what the swirl collapses onto — the lance's own centre. + _emit.anchor = _pos; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = 0; + _emit.speedVariance = 0; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.35; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(count, _emit); + } + + this.ctx.shake.rumble(c.chargeShake * this.assembly * g.cameraShake, dt); + } + + /** Vapour rolling off the frost sheet. */ + _sheetFx(dt, scale) { + const c = settings.shatterlance; + const g = settings.global; + + const count = Math.round(this.mistEmitter.tick(dt, c.mistRate * scale) * g.particleCount); + if (count <= 0) return; + + const spread = c.sheetRadius * this._sheetGrowth(); + this.pointAt(1, _pos); + _pos.y = 0.15; + _emit.position = _pos; + _emit.radius = Math.max(0.1, spread); + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.size = 1.0; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.mist.emit(count, _emit); + } + + /** + * The lance coming apart, along its whole length, in one frame. + * + * Walking `along` across the span the lance actually occupies is the + * difference between two hundred fragments coming off a four-metre body and + * two hundred fragments coming out of a point. + */ + _breakFx() { + const c = settings.shatterlance; + const g = settings.global; + const total = Math.min(MAX_FRAGMENTS, Math.round(c.fragmentCount * g.particleCount)); + const per = Math.max(1, Math.round(total / BREAK_BATCHES)); + const span = saturate(c.lanceLength / Math.max(0.5, this.length)); + + for (let batch = 0; batch < BREAK_BATCHES; batch++) { + const along = 1 - span * (1 - (batch + 0.5) / BREAK_BATCHES); + this.frags.burst(this.age, per, along, randRange(-0.9, 0.9)); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.shatterlance; + + this._syncUniforms(); + this._syncFields(); + + if (this.age < c.assembly) { + // Still winding up. The camera frames the lance, not the empty line. + this._lanceHub(this.position); + this._chargeFx(dt); + return; + } + + // The light rides the point, not the floor under it. + this._lanceHub(this.position); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.shatterlance; + const g = settings.global; + const time = frame.uTime.value; + + this._broken = true; + this._sheetTime = this.age; + + this._breakFx(); + // The lance is gone in the same frame the fragments appear. That + // substitution is the whole point of the slot, so it happens on one frame + // and never as a cross-fade. + this.lance.clear(); + + this.pointAt(1, _target); + _pos.copy(_target); + _pos.y = c.fragSpawnHeight; + + /* the shell of pulverised ice */ + this.ctx.bursts.spawn(BurstMode.FROST, _pos, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.8, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.5, + displace: 0.6, + squash: 0.8, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the cold ring across the floor */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _target, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.75, + width: 0.045, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* chips and vapour */ + _emit.position = _pos; + _emit.radius = c.lanceRadius * 2; + _emit.anchor = null; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.55).setY(0.7).normalize(); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.8; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 11; + _emit.tint = null; + _emit.time = time; + this.chips.emit(Math.round(c.chipCount * g.particleCount), _emit); + + _emit.speed = c.mistSpeed * 3.0; + _emit.spread = 1.0; + _emit.size = 1.5; + _emit.life = c.mistLifetime * 1.2; + _emit.spin = 0.5; + this.mist.emit(Math.round(60 * g.particleCount), _emit); + + this.ctx.shake.add( + c.breakShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 26 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.breakFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.6 * g.explosionIntensity; + } + + onFade(dt, t) { + this._syncUniforms(); + this._syncFields(); + + // The light sits where the lance broke. + this.pointAt(1, this.position); + this.position.y = settings.shatterlance.fragSpawnHeight * 0.6; + + this._sheetFx(dt, t <= 1 ? 1 : 1 - saturate((t - 1) * 0.8)); + } + + onDestroy() { + this.lance.clear(); + this.frags.clear(); + this.intakeGeometry.instanceCount = 0; + this._sheetTime = -1; + this._broken = false; + this.sheet.update(this._fillGround(0, 0, 0)); + } + + dispose() { + this.lance.dispose(); + this.frags.dispose(); + this.sheet.dispose(); + this.intakeGeometry.dispose(); + this.intakeMaterial.dispose(); + this.lanceMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/hive/BroodburstAbility.js b/src/abilities/hive/BroodburstAbility.js new file mode 100644 index 0000000..6c5f93e --- /dev/null +++ b/src/abilities/hive/BroodburstAbility.js @@ -0,0 +1,1123 @@ +import { BufferGeometry, BufferAttribute, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GrowthField, GrowthLayout, GrowthEmerge, growthParams } from '../../vfx/GrowthField.js'; +import { ColonySwarm, ColonyShape, colonySwarmParams } from '../../vfx/Colony.js'; +import { LeadPath } from '../../vfx/Swarm.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { createBroodEggMaterial } from '../../materials/BroodEggMaterial.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on eggs per cast. The editor's `eggs` slider clamps here. */ +const MAX_EGGS = 72; +/** Hard ceiling on crawlers. The editor's `crawlers` slider clamps here. */ +const MAX_CRAWLERS = 256; +/** + * Distinct shell silhouettes. Three, for the reason `GrowthField` documents — + * scaling one mesh forty ways reads as a repeated prop the moment the camera + * moves. Here the variants differ in where their **seam** wanders, which is the + * one feature of an egg the eye actually tracks, and in nothing else. + */ +const VARIANTS = 3; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _hand = new Vector3(); + +/** + * The half-width of a half-shell at height `v`, in radians. + * + * Both halves ask this question with the same arguments and get the same + * answer, which is the entire reason the closed egg has no gap in it. The two + * seam edges wander independently — `edge = 0` is the one at bearing +π/2, + * `edge = 1` the one at −π/2 — so the tear is not symmetric and the shell does + * not read as a machined clamshell. + * + * Two sines rather than one: a single wave gives a smooth S-curve down the side + * of the egg, which looks *cut*. Beating two incommensurable frequencies gives a + * line that changes its mind, which looks torn. + */ +function seamBearing(v, tear, variant, edge) { + const k = variant * 3.77 + edge * 11.3; + const w = + Math.sin(v * 9.1 + k) * 0.62 + Math.sin(v * 17.7 + k * 1.7) * 0.38; + return (edge === 0 ? Math.PI * 0.5 : -Math.PI * 0.5) + tear * w; +} + +/** + * One egg, in `GrowthField`'s unit space: footprint inside a circle of radius + * 0.5 on `y = 0`, tip at `y = 1`. + * + * Built as **two separate half-shells** that meet along a torn seam, because + * that is the only way the split can be a rigid motion rather than a stretch. + * The halves are separate vertex blocks with duplicated vertices along the seam, + * so the closed egg shows no join and the open one has two real edges. + * + * Each half is closed at the bottom with a fan cap. Without it, the moment the + * halves swing outward you look straight up through the floor into a shell with + * no bottom, and the illusion of a thing that had *contained* something goes + * with it. There is no cap at the top: the profile already collapses to the axis + * at `v = 1`. + * + * The profile is `sin(π·(0.12 + 0.88·v))^0.7` tapered toward the crown, sampled + * from 0.12 rather than 0 so the base is a disc rather than a point — an egg + * bedded into the ground, which is what `eggSink` then pushes into it. + */ +function createEggGeometry(variant, shape) { + const rings = Math.max(6, Math.min(28, Math.round(shape.rings))); + const sides = Math.max(5, Math.min(24, Math.round(shape.sides))); + const tear = Math.max(0, Math.min(0.6, shape.tear)); + + // Per half: (rings + 1) × (sides + 1) shell vertices plus one cap centre. + const perHalf = (rings + 1) * (sides + 1) + 1; + const total = perHalf * 2; + // Per half: rings × sides quads, plus a fan of `sides` triangles at the base. + const triangles = (rings * sides * 2 + sides) * 2; + + const position = new Float32Array(total * 3); + const shell = new Float32Array(total * 4); + const shellSide = new Float32Array(total); + const index = new Uint16Array(triangles * 3); + + let vtx = 0; + let tri = 0; + + for (let h = 0; h < 2; h++) { + const sign = h === 0 ? 1 : -1; + const base = vtx; + + for (let j = 0; j <= rings; j++) { + const v = j / rings; + const profile = + Math.pow(Math.max(0, Math.sin(Math.PI * (0.12 + 0.88 * v))), 0.7) * (1 - 0.28 * v); + const r = 0.5 * profile; + + // Half A sweeps from the −π/2 seam up through 0 to the +π/2 seam; half B + // takes the rest of the turn. Both read the same two boundaries. + const a0 = h === 0 ? seamBearing(v, tear, variant, 1) : seamBearing(v, tear, variant, 0); + const a1 = + h === 0 ? seamBearing(v, tear, variant, 0) : seamBearing(v, tear, variant, 1) + TAU; + + for (let k = 0; k <= sides; k++) { + const f = k / sides; + const bearing = a0 + (a1 - a0) * f; + position[vtx * 3] = Math.cos(bearing) * r; + position[vtx * 3 + 1] = v; + position[vtx * 3 + 2] = Math.sin(bearing) * r; + // (cos, sin) rather than the bearing itself: an angle wraps from 1 back + // to 0 across one quad and every egg grows a bright seam down a side + // that has nothing to do with the real one. Same reasoning, same fix, as + // `VineBarkMaterial`'s ring coordinate. + shell[vtx * 4] = v; + shell[vtx * 4 + 1] = Math.cos(bearing); + shell[vtx * 4 + 2] = Math.sin(bearing); + // 1 exactly on the torn edge, 0 by the middle of the half. + shell[vtx * 4 + 3] = Math.abs(f * 2 - 1); + shellSide[vtx] = sign; + vtx++; + } + } + + const capCentre = vtx; + position[capCentre * 3] = 0; + position[capCentre * 3 + 1] = 0; + position[capCentre * 3 + 2] = 0; + shell[capCentre * 4] = 0; + shell[capCentre * 4 + 1] = 1; + shell[capCentre * 4 + 2] = 0; + shell[capCentre * 4 + 3] = 0; + shellSide[capCentre] = sign; + vtx++; + + const stride = sides + 1; + for (let j = 0; j < rings; j++) { + for (let k = 0; k < sides; k++) { + const n0 = base + j * stride + k; + const n1 = n0 + 1; + const n2 = n0 + stride; + const n3 = n2 + 1; + index[tri++] = n0; + index[tri++] = n2; + index[tri++] = n1; + index[tri++] = n1; + index[tri++] = n2; + index[tri++] = n3; + } + } + for (let k = 0; k < sides; k++) { + index[tri++] = base + k; + index[tri++] = base + k + 1; + index[tri++] = capCentre; + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(position, 3)); + geometry.setAttribute('aShell', new BufferAttribute(shell, 4)); + geometry.setAttribute('aSide', new BufferAttribute(shellSide, 1)); + geometry.setIndex(new BufferAttribute(index, 1)); + // `flatShading` recomputes the normal from screen derivatives, so these are + // only ever read by the depth material three generates for the shadow pass. + geometry.computeVertexNormals(); + return geometry; +} + +/** + * BROODBURST — a clutch that swells, splits, and lets something out. + * + * Four beats: + * + * 1. **travel** — the clutch is carried out along the aimed line to the + * circle, trailing spore haze. + * 2. **lay & swell** — the first part of the impact phase. Eggs accrete in + * place on a wave that runs from the middle of the circle outward, and each + * one immediately starts its **own** clock: it inflates, the shell thins + * until the thing inside is visible through it, and then the seam gives. + * 3. **crawl** — as each egg splits it lets its crawlers out. They pin to the + * floor, mill about the husks, and close into a ring that keeps widening. + * 4. **scatter** — the fade. The husks sink, the ring lets go, and the + * crawlers wink out one by one in the order they arrived. + * + * **THE TRICK, part one — every egg has its own timer, and the timer is the + * shell.** `GrowthField` writes one per-instance number, `aBirth`, counting 1 to + * 0 over its live `birthFade`; this ability hands `birthFade` the sum of its + * rise and its swell, so `1 - aBirth` is a complete per-egg hatch ramp that the + * field re-resolves every frame. `BroodEggMaterial` puts every beat of the + * hatch on thresholds of that one number — `swellAt`, `splitAt`, `splitSpan`, + * `huskAt` — so inflation, translucency and the split are three sliders over a + * clutch that is already standing. Because the eggs are laid on a *radial* wave + * and each carries a random stagger, the middle of the circle is hatching while + * the rim is still swelling, and nothing anywhere is synchronised. + * + * The first version drove the swell off the ability's own clock and multiplied + * it into the material as one uniform. Forty eggs pulsed and split on the same + * frame, which does not read as forty eggs; it reads as one mesh drawn forty + * times, which is exactly what it is and exactly what must not show. + * + * **THE TRICK, part two — the crawlers follow the floor.** `ColonySwarm`'s + * `cling` is applied *after* the flock and after the shape blend, in the same + * function that carries the density wave, so an agent's y is overwritten with + * `floorY + crawlHeight` at the very end and the finite difference that drives + * its bank is taken from the clung position. The result is a swarm whose + * separation lattice, cohesion lag and banking all still work but which is + * physically unable to leave the ground. Every other swarm in the sandbox is a + * cloud in the air; this one is a carpet, and the difference is one slider. + * + * `crawlHeight` is authored in centimetres for that reason. Take it past about + * 0.3 m and they are flying again, and the ability quietly becomes Emberflight + * with worse colours. + * + * **The hatch ledger.** The CPU has to know when an egg splits, because that is + * when it throws goo, sheds shell fragments and releases its crawlers. It does + * *not* keep a timer for it: `GrowthField.records[i].eruptTime` is a timestamp, + * the split offset is `(riseTime + swellTime) · splitAt` resolved live, and the + * comparison is redone every frame. Drag `splitAt` backwards on a paused frame + * and eggs that had hatched un-hatch — the ledger clears their flag and they + * hatch again when the clock passes them. That is not a bug being tolerated; it + * is what "no dimensions on the CPU" costs and it is cheaper than the + * alternative, which is an accumulator that goes stale the moment a slider + * moves. + * + * A cast captures the field's dice, the swarm's seed, and one byte per egg + * saying whether it has already been given its burst. Nothing else. Five draw + * calls: three egg variants, the crawlers, and the slick. + */ +export class BroodburstAbility extends Ability { + constructor(context) { + super('broodburst', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + const c = settings.broodburst; + + this.material = createBroodEggMaterial(this.ctx.environment); + + /** Live params, allocated once. Refilled from settings every frame — I1. */ + this.growth = growthParams(); + this.growth.layout = GrowthLayout.ZONE; + // SCALE, not PUSH. An egg is *laid*, it does not erupt: PUSH slides it up + // out of the floor from underneath, which reads as the ground giving birth + // rather than as something having been put there. + this.growth.emerge = GrowthEmerge.SCALE; + this.growth.centre = new Vector3(); + + /** The three numbers that cannot be a uniform, so they rebuild the mesh. */ + this.shape = { rings: c.eggRings, sides: c.eggSides, tear: c.seamTear }; + + this.field = new GrowthField(this.group, { + geometry: createEggGeometry, + material: this.material, + shape: this.shape, + variants: VARIANTS, + capacity: MAX_EGGS, + layer: LAYER.WORLD, + renderOrder: 2, + // The depth material three generates for the shadow pass is not patched, + // so it would cast the shadow of the *closed* egg over an open husk. A + // wrong shadow is louder than no shadow — the same call `BloomburstAbility` + // makes, for the same reason. + castShadow: false, + receiveShadow: true + }); + + /** The crawlers. One draw call, however many of them there are. */ + this.crawlers = new ColonySwarm(this.group, { + capacity: MAX_CRAWLERS, + // The silhouette is a compile-time choice in `Swarm`'s vertex shader only + // in the sense that it branches on a uniform; `crawlShape` moves it live. + additive: false, + renderOrder: 12 + }); + /** Live swarm params, allocated once. */ + this.flock = colonySwarmParams(); + + /** + * The wet the clutch is bedded in. WET rather than POOL because a slick is + * darkened, reflective stone that dries from the edges in — it stays *floor* + * — where POOL is standing liquid with a body and a meniscus, and crawlers + * scuttling across the surface of a pond is a different ability. + */ + this.slick = new GroundField(this.group, { + mode: GroundMode.WET, + additive: false, + renderOrder: 6, + name: 'BroodSlick' + }); + this.slickParams = groundFieldParams(); + this.slickParams.centre = new Vector3(); + + /** Re-rolled per cast so no two clutches lay the same ring. */ + this._seed = 0; + /** + * One byte per egg: has this one already been given its burst? + * + * A ledger of events, not of dimensions. Cleared on spawn, and cleared per + * egg whenever the live clock runs back behind that egg's split. + */ + this._hatched = new Uint8Array(MAX_EGGS); + /** How many of them are set. Recomputed from the ledger, never accumulated. */ + this._hatchedCount = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Brood fluid. Lit and non-additive: it is wet matter being thrown, and an + // additive version read as sparks coming off a firework. + this.goo = particles.get('broodburst.goo', { + capacity: 2000, + shape: ParticleShape.SOFT, + additive: false, + lit: true, + softFade: 0.3 + }); + this.goo.uniforms.uDrag.value = 1.1; + this.goo.uniforms.uEndSize.value = 0.35; + this.goo.uniforms.uSizeIn.value = 0.05; + this.goo.uniforms.uFadeIn.value = 0.05; + this.goo.uniforms.uFadeOut.value = 0.5; + + // Shell fragments. + this.chips = particles.get('broodburst.chips', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.35; + this.chips.uniforms.uEndSize.value = 0.6; + this.chips.uniforms.uFadeOut.value = 0.6; + + // Spore haze over the clutch. + this.haze = particles.get('broodburst.haze', { + capacity: 1400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.haze.uniforms.uDrag.value = 2.0; + this.haze.uniforms.uEndSize.value = 2.4; + this.haze.uniforms.uSizeIn.value = 0.14; + this.haze.uniforms.uFadeIn.value = 0.2; + this.haze.uniforms.uFadeOut.value = 0.34; + + this.gooEmitter = new RateEmitter(); + this.hazeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — every second resolved from live settings */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.field.count + this.crawlers.count; + } + + /** The live footprint, metres. What the indicator measured out. */ + get radius() { + return Math.max(0.05, settings.broodburst.zoneRadius); + } + + /** + * Seconds one egg spends between being laid and its seam giving. + * + * This is the number `birthFade` is fed, which makes it the denominator of + * every threshold in `BroodEggMaterial`. It has to cover the rise as well as + * the swell, because the ramp starts the instant the record is triggered and + * an egg is not allowed to start inflating before it has finished arriving. + */ + _eggRamp() { + const c = settings.broodburst; + return Math.max(0.05, c.riseTime + c.swellTime); + } + + /** Seconds from the clutch landing to the first egg splitting. */ + _firstSplit() { + return this._eggRamp() * saturate(settings.broodburst.splitAt); + } + + /** Lay the clutch, let it hatch, let them run. */ + get impactDuration() { + const c = settings.broodburst; + return Math.max( + 0.2, + (c.layTime + c.layStagger + this._eggRamp() + c.crawlTime) * settings.global.lifetime + ); + } + + get fadeDuration() { + return Math.max(0.2, settings.broodburst.scatterTime); + } + + /** Seconds since the clutch landed. Assembled from the base phase clocks. */ + _clutchAge() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.IMPACT) return this.impactTime; + return this.impactDuration + this.fadeTime; + } + + /** How far the laying wave has crossed the circle, 0..1. */ + _layFront() { + const c = settings.broodburst; + return Easing.outCubic(saturate(this._clutchAge() / Math.max(0.02, c.layTime))); + } + + /** + * 0..1 — how far into the crawling beat we are. + * + * Measured from the *first* split rather than from the landing, so the ring + * does not start widening before anything has come out of anything. + */ + _crawlBeat() { + const c = settings.broodburst; + const start = c.layTime + this._firstSplit(); + return saturate((this._clutchAge() - start) / Math.max(0.05, c.crawlTime)); + } + + /** 0..1 — how far the husks have sunk and the crawlers have let go. */ + _scatter() { + if (this.phase !== AbilityPhase.FADE) return 0; + const c = settings.broodburst; + return saturate(this.fadeTime / Math.max(0.05, c.scatterTime)); + } + + /** The middle of the circle — the far end of the aimed line. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** Where the cast leaves the caster, in world space. */ + _handPoint(out) { + const c = settings.broodburst; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** The brood light breathes rather than glints. */ + lightShimmer() { + const c = settings.broodburst; + return 1 - saturate(c.lightPulse) * 0.5 * (1 - Math.cos(this.age * c.lightPulseRate)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.broodburst; + + this.gooEmitter.reset(); + this.hazeEmitter.reset(); + this._hatched.fill(0); + this._hatchedCount = 0; + + // The one thing a cast captures beyond the field's own dice. + this._seed = Math.random() * 100; + + this.field.plant(Math.min(MAX_EGGS, Math.round(c.eggs)), c.clusterShare); + this.crawlers.roll(this._seed); + + this._sync(1); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Refill the growth params from live settings. + * + * Every number here is re-read on every call, including on a zero-length + * frame. Nothing is cached and nothing is captured at spawn — that is the + * whole of I1 for the clutch. + */ + _fillGrowth() { + const c = settings.broodburst; + const g = settings.global; + const p = this.growth; + const R = this.radius; + + this._centrePoint(p.centre); + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + + p.radius = R; + p.innerRadius = R * saturate(c.innerFrac); + p.radialCurve = c.radialCurve; + p.radialJitter = c.radialJitter; + p.angleJitter = c.angleJitter; + + p.heightNear = c.eggHeight; + p.height = c.eggHeightRim; + p.heightCurve = c.heightCurve; + p.heightJitter = c.heightJitter; + p.radiusNear = c.eggRadius; + p.radius2 = c.eggRadiusRim; + p.radiusCurve = c.radiusCurve; + p.radiusJitter = c.radiusJitter; + + // No lean: an egg tips, it does not lean away from the caster. `tilt` is a + // random tip on any bearing, which is exactly what a laid clutch has. + p.lean = 0; + p.tilt = c.eggTilt; + // A full turn of yaw, so each egg's seam faces somewhere different. This is + // the only per-egg variation in *where the split happens* and without it a + // clutch splits like a row of hinges. + p.twist = 1; + + p.riseTime = c.riseTime; + p.riseOvershoot = c.riseOvershoot; + p.settle = c.settleTime; + p.springRate = c.springRate; + p.emergeSink = c.eggSink; + p.birthScale = c.birthScale; + p.sinkDepth = c.sinkDepth; + // The coupling the whole ability hangs off: `GrowthField`'s birth ramp *is* + // Broodburst's hatch clock. See `_eggRamp()`. + p.birthFade = this._eggRamp(); + // Breach is not used for feedback here — the hatch ledger is — so it is + // parked past the end of the ramp rather than firing a second event nobody + // consumes. + p.breachAt = 2; + + p.randomness = g.randomness; + } + + /** Refill the crawlers' params, including the two that are the trick. */ + _fillFlock(scatter) { + const c = settings.broodburst; + const g = settings.global; + const p = this.flock; + const R = this.radius; + const beat = this._crawlBeat(); + const hatched = this.field.count > 0 ? this._hatchedCount / this.field.count : 0; + + p.count = clamp(Math.round(c.crawlers * g.particleCount), 0, MAX_CRAWLERS); + + /* --- the lead: a slow circuit of the clutch --- */ + p.leadMode = LeadPath.ORBIT; + p.leadS = 0; + p.leadRate = c.crawlLeadRate; + p.orbitRadius = c.crawlOrbitRadius; + p.orbitHeight = 0; + p.orbitTurns = c.crawlOrbitTurns; + p.endHeight = c.crawlHeight; + p.handForward = c.handForward; + p.handSide = c.handSide; + p.handHeight = c.handHeight; + p.leadRise = 0; + + /* --- the formation --- */ + p.latticeX = c.crawlLatticeX; + p.latticeY = c.crawlLatticeY; + p.latticeZ = c.crawlLatticeZ; + p.spacingSide = c.crawlSpacing; + p.spacingUp = c.crawlSpacingUp; + p.lag = c.crawlLag; + p.jitter = c.crawlJitter * g.randomness; + p.churn = c.crawlChurn; + p.breathe = c.crawlBreathe; + p.breatheRate = c.crawlBreatheRate; + p.wander = c.crawlWander; + p.wanderScale = c.crawlWanderScale * g.noiseFrequency; + p.wanderSpeed = c.crawlWanderSpeed * g.noiseSpeed; + p.gather = c.crawlGather; + + /* --- the body --- */ + p.size = c.crawlSize; + p.aspect = c.crawlAspect; + p.sizeJitter = c.crawlSizeJitter * g.randomness; + p.billboard = 0; + p.bank = c.crawlBank; + p.bankMax = c.crawlBankMax; + p.dihedral = c.crawlDihedral; + p.flapRate = c.crawlScuttle; + p.silhouette = clamp(Math.round(c.crawlShape), 0, 4); + p.sweep = c.crawlSweep; + p.edgeGain = c.crawlEdgeGain; + p.lit = c.crawlLit; + + // The reveal *is* the hatch. An agent appears when the wave passes its own + // dice, and the wave is the fraction of the clutch that has actually split — + // so the swarm grows egg by egg without anything counting agents. + p.reveal = saturate(hatched * (1 - scatter) - scatter * 0.15); + p.revealSpread = c.crawlRevealSpread; + + /* --- colour --- */ + p.tint = c.crawlTint; + p.tintJitter = c.crawlTintJitter * g.randomness; + p.tintAlong = c.crawlTintAlong; + p.opacity = c.crawlOpacity * g.opacity; + p.glow = c.crawlGlow * g.glow; + p.softFade = c.crawlSoftFade; + + /* --- the ring they close into, and the floor they are stuck to --- */ + p.shapeA = ColonyShape.RING; + p.shapeB = ColonyShape.RING; + p.shapeBlend = 0; + // They hold the ring hardest in the middle of the crawl beat and let go of + // it at both ends: arriving as a loose mass and dispersing as one is what + // the module's one rule asks for. + p.condense = c.crawlCondense * Easing.outQuad(saturate(beat * 3)) * (1 - scatter); + const ring = R * (c.crawlRing + c.crawlRingGrow * beat); + p.shapeWidth = ring; + p.shapeDepth = ring; + p.shapeHeight = R * c.crawlRingThick; + // The ring is centred on the *lead*, which is on a slow circuit of the + // clutch, so the ring's centre circles with it. That is deliberate and it is + // stated here rather than worked around: the CPU mirror of the lead is not + // the live one — the shader advances `leadS` by its own frame time — so any + // attempt to cancel the orbit would be a frame behind and would read as + // jitter. At `crawlOrbitRadius` well under `zoneRadius` the wander is a mass + // of insects surging round the husks, which is what a colony does anyway. + // The vertical offset is exact, because `orbitHeight` is zero and the lead + // already sits at `crawlHeight`. + p.shapeForward = 0; + p.shapeSide = 0; + p.shapeUp = 0; + p.shapeSpin = c.crawlSpin; + p.shapeFill = c.crawlFill; + p.shapeSteps = clamp(Math.round(c.crawlSteps), 1, 4); + p.shapeSlack = c.crawlSlack; + p.shapeRough = c.crawlRough; + + p.waveAmp = 0; + // THE floor. Applied last in the shader, after the flock and after the + // shape, so nothing downstream can lift them off it. + p.cling = 1; + p.floorY = 0; + p.crawlHeight = c.crawlHeight; + } + + /** Refill the slick's params. */ + _fillSlick(scatter) { + const c = settings.broodburst; + const g = settings.global; + const p = this.slickParams; + const R = this.radius * c.slickRadius; + + this._centrePoint(p.centre); + p.radius = R; + p.height = c.slickHeight; + p.seed = this._seed; + p.grow = this._layFront(); + // It dries from the edges in, which is what WET is for, and it only starts + // once the cast is letting go. + p.recede = saturate(c.slickDry) * scatter; + p.fade = 1; + + p.edge = c.slickEdge; + p.ragged = c.slickRagged; + p.raggedScale = c.slickRaggedScale; + p.warp = c.slickWarp; + + p.relief = c.slickRelief; + p.ambient = c.slickAmbient; + p.specular = c.slickSpecular; + p.gloss = c.slickGloss; + + p.cell = c.slickCell; + p.lift = c.slickLift; + p.depth = c.slickDepth; + p.detail = c.slickDetail; + p.sharp = c.slickSharp; + p.flow = c.slickFlow; + p.speed = c.slickSpeed; + + p.additive = false; + p.emissive = c.slickEmissive * g.shaderIntensity; + p.opacity = c.slickOpacity; + p.depthFade = c.slickDepthFade; + p.colorBase = c.colorSlickBase; + p.colorEdge = c.colorSlickEdge; + p.colorGlow = c.colorSlickGlow; + p.colorDeep = c.colorSlickDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + } + + /** + * Push the live settings into the shell material, the clutch, the crawlers, + * the slick and the three particle systems. + * + * @param {number} fade 1 while the clutch stands, ramping to 0 as it dies + */ + _sync(fade) { + const c = settings.broodburst; + const g = settings.global; + const scatter = this._scatter(); + + /* --- the clutch --- */ + this.shape.rings = c.eggRings; + this.shape.sides = c.eggSides; + this.shape.tear = c.seamTear; + this.field.syncGeometry(this.shape); + + this.material.userData.sync(fade); + this._fillGrowth(); + this.field.update(this.age, this.growth, scatter); + + /* --- the crawlers --- */ + this._fillFlock(scatter); + this.crawlers.setBasis(this.origin, this.direction, this.side, this.length); + // Strings, not resolved Colors: `Swarm#setColors` memoises on what it is + // handed, and `getColor` returns a shared instance whose contents change + // under it — the memo would then be comparing an object with itself. + this.crawlers.setColors(c.colorCrawlA, c.colorCrawlB, c.colorCrawlC, c.colorCrawlD); + this.crawlers.update(this.age, this.flock); + + /* --- the slick --- */ + this._fillSlick(scatter); + this.slick.setVisible(this.slickParams.grow > 0); + this.slick.update(this.slickParams); + + /* --- the three particle systems --- */ + this.goo.setGradient( + getColor(c.colorGooA), + getColor(c.colorGooB), + getColor(c.colorGooC), + getColor(c.colorGooD) + ); + this.goo.uniforms.uGravity.value.set(0, c.gooGravity, 0); + this.goo.uniforms.uSizeScale.value = c.gooSize * g.particleSize * 7; + this.goo.uniforms.uLifeScale.value = c.gooLifetime * 0.5 * g.particleLifetime; + this.goo.uniforms.uSpeedScale.value = g.particleSpeed; + this.goo.uniforms.uOpacity.value = g.opacity; + this.goo.uniforms.uGlow.value = c.gooGlow * g.glow; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = c.chipLifetime * 0.5 * g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + + this.haze.setGradient( + getColor(c.colorHazeA), + getColor(c.colorHazeB), + getColor(c.colorHazeC), + getColor(c.colorHazeD) + ); + this.haze.uniforms.uGravity.value.set(0, c.hazeRise, 0); + this.haze.uniforms.uSizeScale.value = c.hazeSize * g.particleSize; + this.haze.uniforms.uLifeScale.value = c.hazeLifetime * 0.5 * g.particleLifetime; + this.haze.uniforms.uSpeedScale.value = c.hazeSpeed * g.particleSpeed; + this.haze.uniforms.uOpacity.value = c.hazeOpacity * g.opacity; + this.haze.uniforms.uTurbulence.value = 0.4 * g.turbulence; + } + + /** + * Walk the hatch ledger. + * + * Nothing is accumulated. For every planted egg the split time is + * `eruptTime + eggRamp · splitAt`, recomputed from live settings, and the flag + * is set or cleared to match. Running a slider backwards therefore un-hatches + * eggs, and they hatch again — see the class header for why that is the right + * trade rather than a defect. + */ + _walkHatches() { + const c = settings.broodburst; + const now = this.age; + const delay = this._eggRamp() * saturate(c.splitAt); + const count = this.field.count; + let hatched = 0; + + for (let i = 0; i < count; i++) { + const erupt = this.field.records[i].eruptTime; + const due = erupt >= 0 && now >= erupt + delay; + if (due) { + hatched++; + if (!this._hatched[i]) { + this._hatched[i] = 1; + this._hatchFx(i); + } + } else if (this._hatched[i]) { + this._hatched[i] = 0; + } + } + this._hatchedCount = hatched; + } + + /** The puff at the caster's hand as the clutch leaves it. */ + _muzzleFx() { + const c = settings.broodburst; + const g = settings.global; + + this._handPoint(_hand); + + this.ctx.bursts.spawn(BurstMode.EARTH, _hand, { + radius: c.muzzleSize * 0.25, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.4, + intensity: c.muzzleIntensity, + opacity: 0.6, + fresnel: 1.3, + displace: 0.5, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _emit.position = _hand; + _emit.radius = 0.16; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.7).setY(0.4).normalize(); + _emit.speed = c.gooSpeed * 1.2; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.08; + _emit.sizeVariance = 0.7; + _emit.life = c.gooLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.goo.emit(Math.round(18 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + /** Haze dragged along under the clutch while it is carried out. */ + _carryFx(dt) { + const c = settings.broodburst; + const g = settings.global; + + const count = Math.round(this.hazeEmitter.tick(dt, c.hazeRate * 0.5) * g.particleCount); + if (count <= 0) return; + + _emit.position = _pos.copy(this.position).setY(0.12); + _emit.radius = 0.45; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.hazeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.55; + _emit.sizeVariance = 0.5; + _emit.life = c.hazeLifetime * 0.7; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.haze.emit(count, _emit); + } + + /** + * One egg's seam giving: goo out of the gape, fragments off the lip. + * + * The emitter reads the field's live placement rather than a remembered + * position, so the burst comes off the egg that is actually standing there and + * moves with it when `zoneRadius` or `eggHeight` is dragged. + */ + _hatchFx(index) { + const c = settings.broodburst; + const g = settings.global; + const time = frame.uTime.value; + + this.field.positionOf(index, this.growth, _pos); + const height = this.field.heightOf(index, this.growth); + const radius = this.field.radiusOf(index, this.growth); + _pos.y += height * 0.55; + + const goo = Math.round(c.gooBurst * g.particleCount); + if (goo > 0) { + _emit.position = _pos; + _emit.radius = radius * 0.6; + // Up and out, not spherical: what leaves a split egg leaves through the + // gape, and the gape faces the sky. + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gooSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.8; + _emit.life = c.gooLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.goo.emit(goo, _emit); + } + + const chips = Math.round(c.chipBurst * g.particleCount); + if (chips > 0) { + _emit.position = _pos; + _emit.radius = radius * 0.9; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.size = 0.08; + _emit.sizeVariance = 0.7; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = c.chipSpin; + _emit.time = time; + this.chips.emit(chips, _emit); + } + } + + /** + * Goo dripping off the standing clutch, and haze over it. + * + * @param {number} scale 0..1 — thinned out as the clutch dies + */ + _clutchFx(dt, scale) { + const c = settings.broodburst; + const g = settings.global; + const time = frame.uTime.value; + const count = this.field.count; + if (count <= 0) return; + + this._centrePoint(_centre); + + const goo = Math.round(this.gooEmitter.tick(dt, c.gooRate * scale) * g.particleCount); + if (goo > 0) { + const index = Math.min(count - 1, (Math.random() * count) | 0); + // Only an egg that has actually split is leaking. + if (this._hatched[index]) { + this.field.positionOf(index, this.growth, _pos); + _pos.y += this.field.heightOf(index, this.growth) * 0.4; + _emit.position = _pos; + _emit.radius = this.field.radiusOf(index, this.growth) * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gooSpeed * 0.35; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.7; + _emit.life = c.gooLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.goo.emit(goo, _emit); + } + } + + const haze = Math.round(this.hazeEmitter.tick(dt, c.hazeRate * scale) * g.particleCount); + if (haze > 0) { + const bearing = Math.random() * TAU; + const r = this.radius * Math.sqrt(Math.random()); + _pos.set(_centre.x + Math.cos(bearing) * r, 0.14, _centre.z + Math.sin(bearing) * r); + _emit.position = _pos; + _emit.radius = this.radius * 0.14; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.hazeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.65; + _emit.sizeVariance = 0.5; + _emit.life = c.hazeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.haze.emit(haze, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + this.position.y = 0.3; + this._carryFx(dt); + this.ctx.shake.rumble(settings.broodburst.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.broodburst; + const g = settings.global; + const time = frame.uTime.value; + + this._centrePoint(_centre); + _pos.copy(_centre).setY(c.eggHeight * 0.4); + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.7, + intensity: c.burstIntensity, + opacity: 0.5, + fresnel: 1.4, + displace: 0.5, + squash: 0.6, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.decals.spawn(DecalType.FOAM, _centre, { + radius: this.radius * c.slickDecalRadius, + life: c.slickDecalLife, + intensity: c.slickDecalIntensity, + colorA: getColor(c.colorMat), + colorB: getColor(c.colorMatEdge) + }); + + this.ctx.decals.spawn(DecalType.DUSTRING, _centre, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.8, + width: 0.05, + intensity: 0.6, + colorA: getColor(c.colorMatEdge), + colorB: getColor(c.colorMat) + }); + + _emit.position = _pos; + _emit.radius = this.radius * 0.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gooSpeed * 1.6; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.8; + _emit.life = c.gooLifetime * 1.2; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.goo.emit(Math.round(c.burstGoo * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 16 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.8 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.broodburst; + const scatter = this._scatter(); + + // The laying wave. `triggerRadial` fills from the middle outward, and the + // per-record stagger inside `layStagger` is what keeps neighbours from + // going on the same frame. + const front = t <= 1 ? this._layFront() : 1; + this.field.triggerRadial(this.age, front, c.layStagger, false, front >= 0.999); + + // The husks fade only once the crawlers are out and running; before that a + // master fade would take the eggs with the beat they are still playing. + const fade = 1 - Easing.inQuad(scatter); + // Growth first, ledger second, everything else third. `_walkHatches()` asks + // the field where each egg is standing, and `_fillFlock()` asks the ledger + // how many have gone — put them the other way round and the crawlers are a + // frame behind the eggs, which is invisible running and obvious paused. + this._fillGrowth(); + this._walkHatches(); + this._sync(fade); + + // The light sits low over the clutch and follows the crawler ring outward, + // which is the only reason the ring reads at all once the eggs have gone. + this._centrePoint(this.position); + this.position.y = lerp(c.eggHeight * 0.6, c.lightHeight, this._crawlBeat()); + + this._clutchFx(dt, t <= 1 ? 1 : 1 - scatter); + } + + onDestroy() { + this.field.clear(); + this.crawlers.reset(); + this.slick.setVisible(false); + this.material.userData.sync(1); + this._hatched.fill(0); + this._hatchedCount = 0; + } + + dispose() { + this.field.dispose(); + this.material.dispose(); + this.crawlers.dispose(); + this.slick.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/hive/CarapaceAbility.js b/src/abilities/hive/CarapaceAbility.js new file mode 100644 index 0000000..854c9e9 --- /dev/null +++ b/src/abilities/hive/CarapaceAbility.js @@ -0,0 +1,798 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { PlateShell, plateShellParams } from '../../vfx/Colony.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** + * Hard ceiling on plates. The `sites` slider clamps here, and the vertex buffer + * is sized for exactly this many at construction so the two can never disagree. + */ +const MAX_PLATES = 96; + +/** + * Landing plates a single frame is allowed to throw chips for. + * + * Normally one or two: the stagger is fourteen milliseconds and a frame is + * sixteen. It matters when a *slider* moves the clock instead — dragging + * `stagger` down on a paused frame can advance the landing front past thirty + * plates at once, and thirty simultaneous chip bursts is a gravel explosion out + * of nowhere. Capped, it is a handful of chips and the front catches up over + * the next few frames, which is what it looks like anyway. + */ +const CHIP_BATCH = 4; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _shell = plateShellParams(); +const _apron = groundFieldParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _plate = new Vector3(); + +/** + * CARAPACE — a dome of chitin plates that closes with no gaps. + * + * **THE TRICK — one tessellation, so the seams are exact.** Every plate is a + * cell of a *single* spherical Voronoi diagram over a jittered Fibonacci + * hemisphere. Two points on a unit sphere are equidistant from a third exactly + * when that third lies on the plane through the **origin** whose normal is + * their difference — the `|x|²` terms cancel because both sites are unit + * vectors — so a cell is an intersection of half-spaces through the origin and + * can be cut by ordinary polygon clipping. The consequence is the whole + * ability: the edge cell *i* gets from the bisector of *i* and *j* lies in the + * same plane cell *j* gets from bisecting *j* and *i*, and both cut it with the + * same third bisector, so two plates that meet at a seam are solving the same + * equation and share their boundary vertices to the last bit of the arithmetic + * that produced them. The dome closes with no gaps and no overlaps, at any + * `sites`, at any `jitter`, on the frame you drag either of them. + * + * That exactness is not decoration. The floor is lit and there is a light + * *inside* this dome, and the eye finds a sliver of daylight between two plates + * instantly — a noise-perturbed or rejection-sampled plate layout, which is + * what the first attempt at this slot was, leaves one on nearly every seam and + * reads as damage rather than as armour. + * + * **Nothing the fly-in does can leave a plate off the tessellation.** Every + * term of the arrival — the outward offset, the spin about the plate's own + * axis, the tip off the dome — is multiplied by `k = 1 − ease`, which is + * exactly zero once the plate has landed. No easing curve and no overshoot can + * be tuned into a gap. + * + * Three beats: + * + * 1. **summon** — a front runs out to the footprint, throwing amber motes. + * 2. **close** — plates arrive from outside and lock, from the ground up + * (`orderScatter`), each one flashing and knocking chips off its + * neighbours as it seats. The chips are fired at the plate that is + * landing *this frame*, read back out of the tessellation by index, so + * they come off the seam that is actually closing. + * 3. **open** — and this is the beat that proves the claim. The dome does not + * shatter: `seam` climbs, every plate is drawn in toward its own centre by + * the same fraction of the unit sphere, and the gaps that appear are the + * Voronoi edges themselves, lit from inside by the light the dome has been + * hiding. Then it retracts and is gone. + * + * **The rule that makes the editor work.** A cast captures one number — the + * tessellation seed — and the timestamps of its beats. The dome's own radius is + * `zoneRadius`, which is what the aim circle measured, and `domeHeight` is the + * only thing that makes it a dome rather than a hemisphere. Every metre, + * radian and second is resolved from `settings.carapace` inside the update + * loop, zero-length frames included: pause with **P** mid-close and drag + * `sites` and the clipper re-cuts the whole dome under the plates that have + * already landed. + * + * Two draw calls: the shell, and the apron of floor plate it grows out of. + */ +export class CarapaceAbility extends Ability { + constructor(context) { + super('carapace', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The dome. One draw call however many plates, because the vertex layout is + * fixed at `2 + 6 × 12` slots per plate whatever its real side count — + * padding slots collapse onto the last real vertex and draw as zero-area + * triangles — which is what lets the index buffer be written once at + * construction and never touched again. + */ + this.shell = new PlateShell(this.group, { + capacity: MAX_PLATES, + renderOrder: 2, + castShadow: true, + receiveShadow: true + }); + + /** + * The apron: the floor the dome grows out of, in the same interlocking + * plate vocabulary. Shaded rather than additive, because chitin laid over + * stone has to be able to be *darker* than the stone, and an additive mark + * can only ever brighten. Without it the dome sits on the floor like a prop + * on a table; with it, the two are the same animal. + */ + this.apron = new GroundField(this.group, { + mode: GroundMode.PLATE, + additive: false, + name: 'CarapaceApron' + }); + + /* --- what a cast captures: one dice roll and some timestamps --- */ + /** The tessellation seed. Two casts must not cut the same dome. */ + this._seed = 0; + /** One-way. A slider drag must not be able to re-fire the opening. */ + this._opened = false; + /** Index of the last plate that has been given its landing chips. */ + this._landed = 0; + /** HUD readout only. */ + this._live = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Chips knocked off a seam as a plate seats. Lit and non-additive: this is + // the one thing in the ability made of matter rather than of light. + this.chips = particles.get('carapace.chips', { + capacity: 1400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.4; + this.chips.uniforms.uEndSize.value = 0.75; + this.chips.uniforms.uFadeOut.value = 0.65; + + // Dust pushed out from under the rim as the dome grounds out. + this.dust = particles.get('carapace.dust', { + capacity: 1600, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 2.0; + this.dust.uniforms.uEndSize.value = 3.0; + this.dust.uniforms.uSizeIn.value = 0.14; + this.dust.uniforms.uFadeIn.value = 0.2; + this.dust.uniforms.uFadeOut.value = 0.32; + + // Amber motes leaking out of the seams. Additive, and the only reason the + // closed dome does not read as a solid lump: something is in there. + this.motes = particles.get('carapace.motes', { + capacity: 1800, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.6; + this.motes.uniforms.uEndSize.value = 0.16; + this.motes.uniforms.uSizeIn.value = 0.07; + this.motes.uniforms.uFadeIn.value = 0.09; + this.motes.uniforms.uFadeOut.value = 0.4; + + this.dustEmitter = new RateEmitter(); + this.moteEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — every beat resolved from settings, every frame */ + /* ------------------------------------------------------------------ */ + + /** A beat's length in seconds, under the global lifetime knob. */ + _span(seconds) { + return Math.max(0.01, seconds * settings.global.lifetime); + } + + /** + * Seconds the dome takes to close, from the live plate count. + * + * Derived rather than authored, because the alternative — a `buildTime` + * slider next to `stagger` and `lockTime` — is two numbers that describe the + * same thing and disagree the moment either moves. Dragging `stagger` here + * lengthens the impact phase to match on the same frame. + */ + _buildSpan() { + const c = settings.carapace; + const plates = Math.max(1, this.shell.plates); + return this._span((plates - 1) * c.stagger + c.lockTime); + } + + get impactDuration() { + return this._buildSpan() + this._span(settings.carapace.holdTime); + } + + get fadeDuration() { + return Math.max(0.05, settings.carapace.fadeTime); + } + + get instanceCount() { + return this._live; + } + + /** + * Something in the dome breathes. + * + * A quantised flicker was tried first and read as a fire inside a shell, + * which is the wrong idea — the light is a *living thing*, and the slow + * cosine is what makes the closed dome look occupied rather than lit. + */ + lightShimmer() { + const c = settings.carapace; + return 1 - saturate(c.lightPulse) * 0.5 * (1 - Math.cos(this.age * c.lightPulseSpeed)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The live footprint, metres. What the aim circle measured out. */ + get radius() { + return Math.max(0.2, settings.carapace.zoneRadius); + } + + /** The dome's crown height, metres. */ + get domeRise() { + return this.radius * Math.max(0.05, settings.carapace.domeHeight); + } + + /** Where the dome stands: the far end of the aimed line, on the floor. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** Seconds since the front arrived. Zero while it is still on its way. */ + _sinceLand() { + return this.phase === AbilityPhase.TRAVEL ? 0 : this.impactTime + this.fadeTime; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.moteEmitter.reset(); + + this._opened = false; + this._landed = 0; + + // The only thing a cast captures. Unitless: it seeds the Fibonacci phase + // and the per-plate dice, and nothing else. + this._seed = Math.random() * 100; + + this.apron.setVisible(true); + this._sync(1, 0); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Resolve the dome, the apron and the three particle systems from live + * settings. + * + * The dome is synced whether or not it has started to close, for the reason + * every ability in this project syncs its hidden pieces: drag `sites` while + * the front is still travelling and the dome that arrives must be the one you + * chose, not the one that was current when the cast left the hand. + * + * @param {number} fade 1 while the dome stands, ramping to 0 as it goes + * @param {number} open 0..1 how far the seams have come apart + */ + _sync(fade, open) { + const c = settings.carapace; + const g = settings.global; + const p = _shell; + const now = this._sinceLand(); + const radius = this.radius; + + this._centrePoint(_centre); + + /* ---------------- the tessellation ---------------- */ + // Only these three re-run the clipper, and `PlateShell` compares them field + // by field rather than hashing a string, so a frame that changes nothing + // costs three comparisons. + p.sites = clamp(Math.round(c.sites), 12, MAX_PLATES); + p.seed = this._seed; + p.jitter = Math.max(0, c.jitter * g.randomness); + + /* ---------------- the metres ---------------- */ + // The footprint is the aim circle, in both lateral axes. That sharing is + // the design: what the player drew on the floor is what the dome covers. + p.radiusX = radius; + p.radiusZ = radius; + p.radiusY = this.domeRise; + p.thickness = c.thickness; + // The seams open along the tessellation as the dome lets go. This is the + // whole exit: no shatter, no debris — the plates simply stop touching. + p.seam = c.seam + c.openSeam * open; + p.flyOut = c.flyOut; + p.tumbleSpin = c.tumbleSpin; + p.tumbleSwing = c.tumbleSwing; + + /* ---------------- the clock ---------------- */ + // Scaled by the global lifetime here *and* in `_buildSpan()`, from the same + // two keys, so the phase and the animation cannot drift apart. + p.stagger = c.stagger * g.lifetime; + p.lockTime = c.lockTime * g.lifetime; + p.orderScatter = c.orderScatter; + // Held at full size and then withdrawn late: `openRetract` above 1 keeps the + // dome its own size while the seams are doing the talking. + p.retract = 1 - Math.pow(saturate(open), Math.max(0.2, c.openRetract)); + + /* ---------------- the look ---------------- */ + p.tintHeight = c.tintHeight; + p.tintJitter = c.tintJitter * g.randomness; + p.wrap = c.wrap; + p.rimPow = c.rimPow; + p.rimGain = c.rimGain; + p.sheenPow = c.sheenPow; + p.sheenGain = c.sheenGain; + // The gaps are lit from inside, and they get brighter as they widen — which + // is the only honest way to say "there is something in here". + p.seamGlow = c.seamGlow * (1 + c.openSpill * open) * g.shaderIntensity; + p.arriveGain = c.arriveGain; + p.glow = c.glow * fade * g.glow; + p.plateA = c.colorPlateA; + p.plateB = c.colorPlateB; + p.plateC = c.colorPlateC; + p.plateD = c.colorPlateD; + p.rimColor = c.colorRim; + p.sheenColor = c.colorSheen; + p.seamColor = c.colorSeam; + p.arriveColor = c.colorArrive; + + this.shell.setPlacement(_centre, this.direction); + this.shell.update(now, p); + + /* ---------------- the apron ---------------- */ + const a = _apron; + a.centre = _centre; + // The quad carries the caster's heading, so the plate grain runs downrange + // and the relief lights from the same side the dome does. + a.yaw = Math.atan2(this.direction.x, this.direction.z); + a.height = c.apronHeight; + a.radius = radius * c.apronRadius; + // The apron spreads with the dome rather than ahead of it, so the floor and + // the shell are one event. Recede takes it back out from the rim inward. + a.grow = this.shell.progress(now, p); + a.recede = Easing.inQuad(saturate(open)); + a.fade = fade; + a.seed = this._seed; + a.edge = c.apronEdge; + a.ragged = c.apronRagged; + a.raggedScale = c.apronRaggedScale; + a.warp = c.apronWarp; + a.relief = c.apronRelief; + a.ambient = c.apronAmbient; + a.wrap = c.apronWrap; + a.specular = c.apronSpecular; + a.gloss = c.apronGloss; + a.cell = c.apronCell; + a.cellJitter = c.apronCellJitter; + a.seam = c.apronSeam; + a.thickness = c.apronThickness; + a.lift = c.apronLift; + a.additive = false; + a.emissive = c.apronEmissive * g.shaderIntensity; + a.opacity = c.apronOpacity; + a.depthFade = c.apronDepthFade; + a.colorBase = c.colorApronBase; + a.colorEdge = c.colorApronEdge; + a.colorGlow = c.colorApronGlow; + a.colorDeep = c.colorApronDeep; + a.noiseStrength = g.noiseStrength; + a.noiseFrequency = g.noiseFrequency; + a.noiseSpeed = g.noiseSpeed; + a.opacityScale = g.opacity; + this.apron.update(a); + + this._live = this.shell.count; + + this._syncParticles(); + } + + /** The three particle systems, re-coloured and re-scaled every frame. */ + _syncParticles() { + const c = settings.carapace; + const g = settings.global; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = c.chipLifetime * 0.5 * g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.35 * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 1.15 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** A breath of motes at the caster as the summons goes out. */ + _castFx() { + const c = settings.carapace; + const g = settings.global; + + _pos.copy(this.origin).addScaledVector(this.direction, 0.6); + _pos.y = 1.2; + + _emit.position = _pos; + _emit.radius = 0.16; + _emit.direction = _dir.copy(this.direction); + _emit.speed = c.moteSpeed * 4; + _emit.speedVariance = 0.7; + _emit.spread = 0.6; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(Math.round(22 * g.particleCount), _emit); + + this.lightBoost = c.lightIntensity * 0.3 * g.explosionIntensity; + } + + /** + * Chips off the plate that is landing *this* frame. + * + * The landing front is `PlateShell#progress()` — the same clock the vertex + * shader eases each plate on — turned into an index, and the plate's own + * locked centre is read back out of the tessellation rather than guessed at + * from a bearing. So the chips come off the seam that is actually closing, + * and they keep doing so when `sites`, `jitter` or `zoneRadius` move. + * + * The first version emitted from a random point on the dome at a fixed rate. + * It looked fine in a still and wrong in motion, because the sparkle was + * uncorrelated with the plates arriving — which is the only event on screen. + */ + _landingFx(progress) { + const c = settings.carapace; + const g = settings.global; + const plates = this.shell.plates; + if (plates <= 0) return; + + const front = Math.floor(saturate(progress) * plates); + const per = Math.round(c.chipLand * g.particleCount); + let budget = CHIP_BATCH; + + while (this._landed < front && budget-- > 0) { + const index = this._landed++; + if (per <= 0) continue; + + this.shell.plateCentre(index, _shell, _plate); + + _emit.position = _plate; + _emit.radius = 0.12; + // Outward along the dome's own normal at that plate: a chip flicks off + // the face it was struck against, not upward. + _emit.direction = _dir.copy(_plate).sub(_centre).normalize(); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.chips.emit(per, _emit); + } + } + + /** + * What the dome sheds while it stands: dust from under the rim, motes out of + * the seams. + * + * @param {number} dt + * @param {number} scale 0..1 — thinned as the dome goes + * @param {number} open 0..1 — wider seams leak more + */ + _domeFx(dt, scale, open) { + const c = settings.carapace; + const g = settings.global; + const time = frame.uTime.value; + const radius = this.radius; + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (dustCount > 0) { + // On the rim, on the floor: the dust is the dome pushing air out from + // under itself, so it belongs at the ground line and nowhere else. + const a = Math.random() * TAU; + _pos.set(_centre.x + Math.cos(a) * radius, 0.08, _centre.z + Math.sin(a) * radius); + _emit.position = _pos; + _emit.radius = radius * 0.14; + _emit.direction = _dir.set(Math.cos(a), 0.35, Math.sin(a)).normalize(); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + // Leakage scales with the gaps: a sealed dome is nearly tight, and an open + // one is pouring. `1 +` rather than `open` alone so the closed dome still + // has something coming out of it. + const leak = scale * (1 + c.openSpill * 0.35 * open); + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * leak) * g.particleCount); + if (moteCount > 0) { + // On the shell itself, at a random height — the motes come out through + // the seams, which are everywhere on it. + const a = Math.random() * TAU; + const h = Math.random(); + const r = radius * Math.sqrt(Math.max(0, 1 - h * h)); + _pos.set(_centre.x + Math.cos(a) * r, this.domeRise * h, _centre.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = radius * 0.08; + _emit.direction = _dir.set(Math.cos(a) * 0.5, 1, Math.sin(a) * 0.5).normalize(); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.size = 0.06; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + } + + /** The first ring of plates bites: dust, a ring, fractures under the rim. */ + _sealFx() { + const c = settings.carapace; + const g = settings.global; + const radius = this.radius; + + this._centrePoint(_centre); + + _pos.copy(_centre).setY(this.domeRise * 0.35); + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.7, + intensity: c.burstIntensity, + opacity: 0.7, + fresnel: 1.3, + displace: 0.55, + squash: 0.75, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _centre, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.6, + width: 0.06, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + // Fractures under the rim: the floor takes the weight of the dome landing + // on it, and this is what outlives the cast. + this.ctx.decals.spawn(DecalType.CRACK, _centre, { + radius: c.crackRadius, + life: c.crackLife, + intensity: c.crackIntensity, + width: 0.5, + colorA: getColor(c.colorCrackDeep), + colorB: getColor(c.colorCrack) + }); + + _emit.position = _centre; + _emit.radius = radius * 0.9; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 3; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.4; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime * 1.3; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(Math.round(40 * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + this.ctx.flash.trigger(getColor(c.colorSealFlash), c.sealFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.9 * g.explosionIntensity; + } + + /** The dome lets go: chips off every seam at once, and the light escapes. */ + _openFx() { + const c = settings.carapace; + const g = settings.global; + const radius = this.radius; + + this._centrePoint(_centre); + + _emit.position = _centre; + _emit.radius = radius * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chipSpeed * 1.5; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.8; + _emit.life = c.chipLifetime * 1.5; + _emit.lifeVariance = 0.6; + _emit.spin = 11; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.chips.emit(Math.round(c.openChips * g.particleCount), _emit); + + _emit.radius = radius * 0.5; + _emit.speed = c.moteSpeed * 3.5; + _emit.spread = 0.8; + _emit.size = 0.08; + _emit.life = c.moteLifetime * 1.4; + _emit.spin = 0; + this.motes.emit(Math.round(70 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorOpenFlash), c.openFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * c.lightSpill * 0.4 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1, 0); + + // A few motes run out ahead of the front, so the footprint is announced + // before anything lands on it. Jittered off the line so they do not read as + // a dotted trail. + const c = settings.carapace; + const g = settings.global; + const count = Math.round(this.moteEmitter.tick(dt, c.moteRate * 0.4) * g.particleCount); + if (count > 0) { + this.pointAt(Math.max(0.05, this.u), _pos); + _pos.y = randRange(0.1, 0.7); + _emit.position = _pos; + _emit.radius = 0.35; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.05; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime * 0.8; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(count, _emit); + } + + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + this._landed = 0; + this._sealFx(); + this._centrePoint(this.position); + this.position.y = this.domeRise * settings.carapace.lightHeight; + } + + onFade(dt, t) { + const c = settings.carapace; + // `t` runs 0..1 through the close and the hold, then 1..2 as the seams + // open. Cubic on the way out so the dome holds together and then lets go. + const open = saturate(t - 1); + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(open) * 0.85; + + if (!this._opened && open > 0) { + this._opened = true; + this._openFx(); + } + + this._sync(fade, open); + + const progress = this.shell.progress(this._sinceLand(), _shell); + this._landingFx(progress); + this._domeFx(dt, fade, open); + + // The light hangs inside the dome and climbs as the seams widen — it is + // getting out, and the shell is what was holding it in. + this._centrePoint(this.position); + this.position.y = this.domeRise * saturate(c.lightHeight); + this.lightBoost = Math.max(this.lightBoost, c.lightIntensity * 0.12 * c.lightSpill * open); + + // Still rumbling while plates are landing, and nothing after that. + if (progress < 1) this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onDestroy() { + this._opened = false; + this._landed = 0; + this._live = 0; + this.shell.reset(); + this.apron.setVisible(false); + } + + dispose() { + this.shell.dispose(); + this.apron.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/hive/HivecolumnAbility.js b/src/abilities/hive/HivecolumnAbility.js new file mode 100644 index 0000000..8540746 --- /dev/null +++ b/src/abilities/hive/HivecolumnAbility.js @@ -0,0 +1,948 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { LatticeGrowth, latticeGrowthParams } from '../../vfx/Colony.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, Easing, hash11 } from '../../utils/math.js'; + +/** + * Hard ceiling on comb cells. The editor's `cells` slider clamps here, and the + * instance buffers are sized for exactly this many at construction. + */ +const MAX_CELLS = 240; + +/** + * How many freshly-landed cells may be given their own puff of wax on a single + * frame. + * + * This is a *catch-up* cap, not a rate limit. At the shipped `stagger` roughly + * one cell lands per frame and the loop never sees more than two; the cap only + * bites when the clock jumps — the first frame after `stagger` is dragged from + * 0.06 to 0.002, say — where firing a hundred and sixty puffs in one frame + * would blow the particle budget on a single frame for no visible gain, since + * they would all be born on the same timestamp and read as one flash. + */ +const MAX_LANDINGS_PER_FRAME = 6; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _hand = new Vector3(); + +/** + * HIVE COLUMN — a tower of comb that is legibly *constructed*. + * + * Four beats: + * + * 1. **travel** — a scent runner races out along the aimed line to the circle, + * dragging dry dust off the floor behind it. Nothing of the tower exists + * yet. + * 2. **build** — the first half of the impact phase. One seed cell is laid at + * the centre and the colony works outward from it on a stagger, cell by + * cell, climbing into layers as the skirt fills. A mat of wax charges + * across the floor underneath, its own front stepping from hex to hex. + * 3. **hold** — the tower stands, the recesses lit, wax motes coming off it. + * 4. **collapse** — the fade. `retract` winds back to zero and the whole + * structure withdraws into the floor in the order it was built, throwing + * the chips it was made of. + * + * **THE TRICK — growth on a real lattice, and the refusals that shape it.** + * There is no hex grid here and nothing is filled by radius. Each cell carries + * *its own* lattice frame, inherited from its parent and turned by up to + * `drift` of a turn; a child buds one lattice unit along one of its parent's six + * directions, so locally the packing is exact and three cells in a row read as + * masonry. Two branches that left the seed around opposite sides of a void come + * back at each other out of register — the candidate lands within `refuse` of + * something already standing and is **thrown away**. That refusal is what puts + * the seams, the interior voids and the ragged perimeter there, and none of them + * is a noise function. `vfx/Colony.js#LatticeGrowth` owns the growth; this file + * owns the beats, the palette and the feedback. + * + * **What this ability adds on top of the module: the build is *audible*.** The + * first version let the comb rise silently and it read as a mesh being scaled — + * the staggered landing was there in the shader and nobody could see it, because + * a cell arriving is a two-frame event and the eye needs an edge to catch. So + * the CPU re-derives *which* cells have landed each frame — `floor((combAge − + * growTime·0.55) / stagger)`, resolved from live settings, never accumulated — + * and every newly landed cell throws its own puff of wax and, sometimes, a chip. + * Because the landing order is the colony's **breadth-first growth order** and + * not a radius, you watch the lobes fill and you watch a hole get skipped. That + * is the refusal, made visible, without the CPU knowing anything about the + * structure. + * + * The wax mat's front is not an independent animation either. `_reachUnits` is + * the largest **lattice radius** any landed cell has reached — unitless, so it + * is a legal thing for a cast to hold — and the mat's `grow` is that number + * times the live `pitch` over the live mat radius. Drag `pitch` on a paused + * frame and the comb and the mat re-scale together, because they are reading the + * same measurement. + * + * **The rule that makes the editor work.** A cast captures three things and all + * three are unitless: the growth seed, the count of landings already given + * feedback, and that lattice reach. Every metre, radian and second — the pitch, + * the cell height, the layer spacing, the stagger, the mat's trace width, the + * light's height above the tower — is resolved against `settings.hivecolumn` + * inside the update loop, on a zero-length frame included. Pause with **P** + * mid-build and drag `drift`: the colony regrows from the same seed with a + * different history, and the perimeter changes shape while the clock is stopped. + * + * Two draw calls for the structure — the comb and the mat — plus three particle + * systems. + */ +export class HivecolumnAbility extends Ability { + constructor(context) { + super('hivecolumn', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* + * `recess` is pushed past the module's default: a shallow cell reads as a + * tiled floor stood on its end, and what says *hive* is the rim being dark + * against a hole with something glowing at the bottom of it. `wall` stays at + * the default — thicker and the tower turns into a block of cheese. + */ + this.comb = new LatticeGrowth(this.group, { + capacity: MAX_CELLS, + sides: 6, + wall: 0.24, + recess: 0.66, + renderOrder: 2 + }); + + /** Live lattice params, allocated once. Refilled from settings every frame. */ + this.lattice = latticeGrowthParams(); + + /* + * The wax the tower is seated on. LATTICE rather than PLATE or POOL because + * its front crosses the *lattice* rather than the field — a cell on the floor + * lights when the front reaches that cell's own centre — which is the same + * idea as the comb above it, one dimension down. A POOL under a hex tower + * looked like the tower had been dropped in a puddle. + */ + this.mat = new GroundField(this.group, { + mode: GroundMode.LATTICE, + additive: false, + renderOrder: 6, + name: 'HiveWaxMat' + }); + + /** Live ground params, allocated once. `centre` is ours, written in place. */ + this.matParams = groundFieldParams(); + this.matParams.centre = new Vector3(); + + /** Casts so far, mod a power of two. Only ever used to roll a seed. */ + this._casts = 0; + /** The growth's dice. Unitless, re-rolled per cast. */ + this._seed = 0; + /** How many landings have already been given feedback. An event ledger. */ + this._landed = 0; + /** Largest lattice radius reached by a landed cell. Unitless — see the header. */ + this._reachUnits = 0; + /** One-shot latch for the collapse, which is a single event across the tower. */ + this._collapsed = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // Wax motes: the signature system. Additive and curl-driven, because what + // comes off fresh comb is warm air with flecks in it, not sparks. + this.motes = particles.get('hivecolumn.motes', { + capacity: 2200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.8; + this.motes.uniforms.uEndSize.value = 0.4; + this.motes.uniforms.uSizeIn.value = 0.08; + this.motes.uniforms.uFadeIn.value = 0.1; + this.motes.uniforms.uFadeOut.value = 0.42; + + // Dry dust off the floor the tower is pushing through. Non-additive so it + // genuinely occludes the comb behind it. + this.dust = particles.get('hivecolumn.dust', { + capacity: 1500, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.dust.uniforms.uDrag.value = 2.1; + this.dust.uniforms.uEndSize.value = 2.5; + this.dust.uniforms.uSizeIn.value = 0.13; + this.dust.uniforms.uFadeIn.value = 0.18; + this.dust.uniforms.uFadeOut.value = 0.32; + + // Chips of comb. Lit and opaque: they are the only solid matter in the cast + // and they are what makes the collapse read as a structure coming apart + // rather than as an opacity ramp. + this.chips = particles.get('hivecolumn.chips', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.3; + this.chips.uniforms.uEndSize.value = 0.7; + this.chips.uniforms.uFadeOut.value = 0.65; + + this.moteEmitter = new RateEmitter(); + this.dustEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — every second resolved from live settings */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.comb.count; + } + + /** Cells the colony is allowed to attempt this frame. */ + _cellCount() { + return clamp(Math.round(settings.hivecolumn.cells), 1, MAX_CELLS); + } + + /** + * Seconds from the seed cell being laid to the last cell settling. + * + * Derived rather than authored, because the build clock belongs to the + * *colony*: doubling `cells` has to buy a longer build, not a faster one, or + * the tail of the structure lands after the hold has already begun. + */ + _buildTime() { + const c = settings.hivecolumn; + return this._cellCount() * Math.max(0.001, c.stagger) + Math.max(0.03, c.growTime) + c.buildPad; + } + + /** Build, then stand. */ + get impactDuration() { + const c = settings.hivecolumn; + return Math.max(0.2, (this._buildTime() + c.holdTime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.2, settings.hivecolumn.collapseTime); + } + + /** The live footprint, metres. What the indicator measured out. */ + get radius() { + return Math.max(0.05, settings.hivecolumn.zoneRadius); + } + + /** + * Seconds since the seed cell was laid. + * + * Assembled from the base class's phase clocks rather than kept as its own + * accumulator, so it stays correct when `impactDuration` moves under it — and + * it does move, because `impactDuration` is derived from `cells` and `stagger` + * and both are sliders. + */ + _combAge() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.IMPACT) return this.impactTime; + return this.impactDuration + this.fadeTime; + } + + /** 1 while the tower stands, easing to 0 as it withdraws. */ + _retract() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase !== AbilityPhase.FADE) return 1; + const c = settings.hivecolumn; + return 1 - Easing.inCubic(saturate(this.fadeTime / Math.max(0.05, c.collapseTime))); + } + + /** + * How many cells have landed, 0..count. + * + * A pure function of the live clock — nothing is accumulated, so dragging + * `stagger` on a paused frame moves the whole build forward or backward and + * the feedback ledger below re-syncs to it in either direction. + * + * The `growTime · 0.55` bias is where a cell reads as having *arrived*: the + * shader takes `growTime` to slide one cell home and overshoot it, and a puff + * fired on the first frame of that ramp comes off a cell still buried in the + * floor. + */ + _landedCount() { + const c = settings.hivecolumn; + const t = this._combAge() - Math.max(0.03, c.growTime) * 0.55; + if (t < 0) return 0; + return clamp(Math.floor(t / Math.max(0.001, c.stagger)) + 1, 0, this.comb.count); + } + + /** The middle of the circle — the far end of the aimed line. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** Where the cast leaves the caster, in world space. */ + _handPoint(out) { + const c = settings.hivecolumn; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** Metres from the floor to the crown of the tower, live. */ + _towerHeight() { + const c = settings.hivecolumn; + return c.baseY + c.heightPeak + Math.max(0, Math.round(c.layers) - 1) * c.layerHeight; + } + + /** The comb gutters like something alive rather than glinting like ice. */ + lightShimmer() { + const c = settings.hivecolumn; + return 1 - saturate(c.lightPulse) * 0.5 * (1 - Math.cos(this.age * c.lightPulseRate)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.dustEmitter.reset(); + this._landed = 0; + this._reachUnits = 0; + this._collapsed = false; + + /* + * The one thing a cast captures, and it has to *change* every time. + * + * `LatticeGrowth` only regrows when its structure hash moves, and + * `onDestroy()` below calls `reset()`, which zeroes the instance count + * without touching that hash. Two casts that happened to draw the same seed + * would therefore leave the comb hidden and the ability silently empty. A + * counter times the golden ratio guarantees consecutive casts are at least + * 0.11 apart, which no float rounding is going to close, while the random + * term keeps two casts from ever laying out the identical tower. + */ + this._casts = (this._casts + 1) & 4095; + this._seed = this._casts * 0.6180339887 + Math.random() * 0.5; + + this._sync(); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Refill the lattice params from live settings. + * + * Every number is re-read on every call, including on a zero-length frame. + * Nothing is cached between frames and nothing but the seed is captured at + * spawn — that is the whole of I1 for the comb. + */ + _fillLattice() { + const c = settings.hivecolumn; + const g = settings.global; + const p = this.lattice; + + /* the structure — changing any of these regrows the colony */ + p.cells = this._cellCount(); + p.seed = this._seed; + p.drift = c.drift; + p.refuse = c.refuse; + p.climb = c.climb; + p.outward = c.outward; + p.layers = c.layers; + + /* the metres */ + p.pitch = c.pitch; + p.cellRadius = c.cellRadius; + p.sizeJitter = c.sizeJitter * g.randomness; + p.heightBase = c.heightBase; + p.heightPeak = c.heightPeak; + p.heightFalloff = c.heightFalloff; + p.layerHeight = c.layerHeight; + p.baseY = c.baseY; + p.rise = c.rise; + + /* the clock */ + p.stagger = c.stagger; + p.growTime = c.growTime; + p.overshoot = c.overshoot; + p.retract = this._retract(); + + /* the surface */ + p.tintRadius = c.tintRadius; + p.tintJitter = c.tintJitter * g.randomness; + p.wrap = c.combWrap; + p.rimPow = c.rimPow; + p.rimGain = c.rimGain * g.shaderIntensity; + p.sheenPow = c.sheenPow; + p.sheenGain = c.sheenGain * g.shaderIntensity; + p.coreGlow = c.coreGlow * g.shaderIntensity; + p.flashGain = c.flashGain; + p.glow = c.combGlow * g.glow; + + p.combA = c.colorCombA; + p.combB = c.colorCombB; + p.combC = c.colorCombC; + p.combD = c.colorCombD; + p.coreColor = c.colorCombCore; + p.rimColor = c.colorCombRim; + p.sheenColor = c.colorCombSheen; + p.flashColor = c.colorCombFlash; + } + + /** + * Refill the wax mat's params. + * + * The one interesting line is `grow`. It is not a clock — it is the colony's + * own reach in metres over the mat's own radius in metres, so the charge on + * the floor stops exactly where the comb above it stops, and both move + * together when `pitch`, `cells` or `zoneRadius` are dragged. + */ + _fillMat() { + const c = settings.hivecolumn; + const g = settings.global; + const p = this.matParams; + const R = this.radius * c.floorRadius; + + this._centrePoint(p.centre); + p.radius = R; + p.height = c.floorHeight; + p.seed = this._seed; + p.grow = saturate((this._reachUnits * c.pitch) / Math.max(0.05, R)); + p.recede = 0; + p.fade = this._retract(); + + p.edge = c.floorEdge; + p.ragged = c.floorRagged; + p.raggedScale = c.floorRaggedScale; + p.warp = c.floorWarp; + + p.relief = c.floorRelief; + p.ambient = c.floorAmbient; + p.wrap = c.floorWrap; + p.specular = c.floorSpecular; + p.gloss = c.floorGloss; + p.parallax = c.floorParallax; + + p.cell = c.floorCell; + p.seam = c.floorSeam; + p.thickness = c.floorThickness; + p.lift = c.floorLift; + p.depth = c.floorDepth; + p.detail = c.floorDetail; + p.sharp = c.floorSharp; + p.speed = c.floorSpeed; + + p.additive = false; + p.emissive = c.floorEmissive * g.shaderIntensity; + p.opacity = c.floorOpacity; + p.depthFade = c.floorDepthFade; + p.colorBase = c.colorFloorBase; + p.colorEdge = c.colorFloorEdge; + p.colorGlow = c.colorFloorGlow; + p.colorDeep = c.colorFloorDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + } + + /** + * Push everything into the comb, the mat and the three particle systems. + * + * The order is load-bearing and it is the one thing to be careful of when + * editing this method. The comb has to be grown before the ledger walks it, + * because `_landedCount()` clamps against `comb.count` and that count is zero + * until the first `update()`; and the ledger has to run before the mat is + * filled, because the mat's growth front is the reach the ledger just + * recorded. Filling the mat first cost a frame of lag on every slider drag — + * invisible while running, glaring while paused, which is exactly the wrong + * way round. + */ + _sync() { + const c = settings.hivecolumn; + const g = settings.global; + + this._centrePoint(_centre); + + this._fillLattice(); + this.comb.setPlacement(_centre, this.direction); + this.comb.update(this._combAge(), this.lattice); + + this._walkLandings(); + + this._fillMat(); + this.mat.setVisible(this.matParams.fade > 0 && this.matParams.grow > 0); + this.mat.update(this.matParams); + + /* --- the three particle systems --- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.moteGlow * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.35 * g.turbulence; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = c.chipLifetime * 0.5 * g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + } + + /** The puff at the caster's hand as the runner leaves it. */ + _muzzleFx() { + const c = settings.hivecolumn; + const g = settings.global; + + this._handPoint(_hand); + + this.ctx.bursts.spawn(BurstMode.EARTH, _hand, { + radius: c.muzzleSize * 0.25, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.4, + intensity: c.muzzleIntensity, + opacity: 0.6, + fresnel: 1.3, + displace: 0.5, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _emit.position = _hand; + _emit.radius = 0.16; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.65).setY(0.45).normalize(); + _emit.speed = c.moteSpeed * 3; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.08; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime * 0.6; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(Math.round(24 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + /** Dust kicked up under the scent runner while it crosses the floor. */ + _runnerFx(dt) { + const c = settings.hivecolumn; + const g = settings.global; + + const count = Math.round(this.dustEmitter.tick(dt, c.dustRate * 0.6) * g.particleCount); + if (count <= 0) return; + + _emit.position = _pos.copy(this.position).setY(0.1); + _emit.radius = 0.5; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.6; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime * 0.7; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(count, _emit); + } + + /** + * Walk the ledger of landed cells forward. + * + * Two jobs, and they are deliberately in one loop. The first is the lattice + * reach the wax mat's front reads — every landed cell, no matter how many + * arrived this frame. The second is the puff of wax each landing throws, which + * is capped, for the reason `MAX_LANDINGS_PER_FRAME` gives. + * + * The reach is recorded in **lattice units**, not metres: `cellPoint` gives a + * world position that already has the live `pitch` in it, so dividing it back + * out leaves a number that means "this many cells out from the seed" and + * survives a `pitch` drag. Storing the metres instead was the first version + * and it froze the mat's front the moment the clock stopped. + */ + _walkLandings() { + const c = settings.hivecolumn; + const landed = this._landedCount(); + + if (landed < this._landed) { + // A slider ran the build backwards. Drop the ledger and let it re-fill; + // the reach has to go with it or the mat keeps a front the comb has lost. + this._landed = landed; + this._reachUnits = 0; + for (let i = 0; i < landed; i++) this._recordReach(i, c); + return; + } + + let puffs = 0; + for (let i = this._landed; i < landed; i++) { + this._recordReach(i, c); + if (puffs < MAX_LANDINGS_PER_FRAME) { + this._landingFx(i); + puffs++; + } + } + this._landed = landed; + } + + /** Fold cell `index` into the unitless lattice reach. */ + _recordReach(index, c) { + this._centrePoint(_centre); + this.comb.cellPoint(index, this.lattice, _pos, 0); + const units = Math.hypot(_pos.x - _centre.x, _pos.z - _centre.z) / Math.max(0.01, c.pitch); + if (units > this._reachUnits) this._reachUnits = units; + } + + /** + * The wax a single cell throws as it snaps into place. + * + * Emitted at the cell's own rim — `cellPoint` reads the same floats the vertex + * shader reads, so the puff comes off the cell that is actually there and + * moves with it when `layerHeight` or `heightPeak` is dragged. + */ + _landingFx(index) { + const c = settings.hivecolumn; + const g = settings.global; + const time = frame.uTime.value; + + this.comb.cellPoint(index, this.lattice, _pos, 1); + + const motes = Math.round(c.cellMotes * g.particleCount); + if (motes > 0) { + _emit.position = _pos; + _emit.radius = c.cellRadius * 0.9; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime * 0.8; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(motes, _emit); + } + + // Only some cells shed. A chip off every one of a hundred and fifty is a + // continuous gravel fountain, and the point of the chips is that the tower + // is occasionally *breaking* something as it forces its way up. + if (hash11(index * 7.31 + this._seed) < c.cellChipChance) { + _emit.position = _pos; + _emit.radius = c.cellRadius; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chipSpeed * 0.5; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.chipLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = c.chipSpin; + _emit.time = time; + this.chips.emit(Math.max(1, Math.round(g.particleCount)), _emit); + } + } + + /** + * Wax motes off the standing tower and dust off the floor around it. + * + * @param {number} scale 0..1 — thinned out as the tower dies + */ + _towerFx(dt, scale) { + const c = settings.hivecolumn; + const g = settings.global; + const time = frame.uTime.value; + const count = this.comb.count; + if (count <= 0) return; + + this._centrePoint(_centre); + + const motes = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (motes > 0) { + // From a cell that has actually landed, so the motes climb the tower as + // it builds rather than filling the whole footprint from frame one. + const live = Math.max(1, this._landed); + const index = Math.min(live - 1, (Math.random() * live) | 0); + this.comb.cellPoint(index, this.lattice, _pos, 1); + _emit.position = _pos; + _emit.radius = c.cellRadius * 1.6; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(motes, _emit); + } + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (dustCount > 0) { + const bearing = Math.random() * Math.PI * 2; + const r = this.radius * Math.sqrt(Math.random()); + _pos.set(_centre.x + Math.cos(bearing) * r, 0.12, _centre.z + Math.sin(bearing) * r); + _emit.position = _pos; + _emit.radius = this.radius * 0.14; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + } + + /** The one-shot as the tower lets go of the floor. */ + _collapseFx() { + const c = settings.hivecolumn; + const g = settings.global; + const time = frame.uTime.value; + + this._centrePoint(_centre); + _pos.copy(_centre).setY(this._towerHeight() * 0.4); + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.3, + endRadius: c.burstSize * 0.9 * g.explosionIntensity, + life: 0.8, + intensity: c.burstIntensity * 0.8, + opacity: 0.5, + fresnel: 1.4, + displace: 0.55, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _emit.position = _pos; + _emit.radius = this.radius * 0.7; + _emit.direction = _dir.set(0, 0.5, 0).normalize(); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.8; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = c.chipSpin; + _emit.tint = null; + _emit.time = time; + this.chips.emit(Math.round(c.collapseChips * g.particleCount), _emit); + + _emit.position = _pos; + _emit.radius = this.radius * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 2.0; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.size = 1.3; + _emit.life = c.dustLifetime * 1.2; + _emit.spin = 0.5; + this.dust.emit(Math.round(40 * g.particleCount), _emit); + + this.ctx.decals.spawn(DecalType.DUSTRING, _centre, { + radius: c.shockRadius * 0.9 * g.explosionIntensity, + life: 0.9, + width: 0.06, + intensity: 0.6, + colorA: getColor(c.colorSeatEdge), + colorB: getColor(c.colorSeat) + }); + + this.ctx.shake.add( + c.impactShake * 0.6 * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 15 + ); + this.lightBoost = c.lightIntensity * 0.6 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(); + + // The light rides the runner, just off the floor. + this.position.y = 0.28; + + this._runnerFx(dt); + this.ctx.shake.rumble(settings.hivecolumn.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.hivecolumn; + const g = settings.global; + const time = frame.uTime.value; + + this._centrePoint(_centre); + + /* the shell as the seed cell is laid */ + _pos.copy(_centre).setY(c.heightPeak * 0.4); + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.7, + intensity: c.burstIntensity, + opacity: 0.55, + fresnel: 1.4, + displace: 0.5, + squash: 0.65, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the mat of wax the whole tower is seated on */ + this.ctx.decals.spawn(DecalType.FOAM, _centre, { + radius: this.radius * c.seatRadius, + life: c.seatLife, + intensity: c.seatIntensity, + colorA: getColor(c.colorSeat), + colorB: getColor(c.colorSeatEdge) + }); + + /* and the ring of dust pushed out as it takes hold */ + this.ctx.decals.spawn(DecalType.DUSTRING, _centre, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.8, + width: 0.05, + intensity: 0.65, + colorA: getColor(c.colorSeatEdge), + colorB: getColor(c.colorSeat) + }); + + _emit.position = _pos; + _emit.radius = this.radius * 0.35; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed * 2.2; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.08; + _emit.sizeVariance = 0.8; + _emit.life = c.moteLifetime * 1.2; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(Math.round(c.burstMotes * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.9 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.hivecolumn; + + this._sync(); + + if (!this._collapsed && this.phase === AbilityPhase.FADE) { + this._collapsed = true; + this._collapseFx(); + } + + // The light climbs with the tower and rides it back down. + const retract = this._retract(); + this._centrePoint(this.position); + this.position.y = this._towerHeight() * saturate(c.lightHeight) * retract; + + this._towerFx(dt, t <= 1 ? 1 : retract * 0.5); + } + + onDestroy() { + this.comb.reset(); + this.mat.setVisible(false); + this._landed = 0; + this._reachUnits = 0; + this._collapsed = false; + } + + dispose() { + this.comb.dispose(); + this.mat.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/hive/LocusttideAbility.js b/src/abilities/hive/LocusttideAbility.js new file mode 100644 index 0000000..c609d85 --- /dev/null +++ b/src/abilities/hive/LocusttideAbility.js @@ -0,0 +1,986 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { ColonySwarm, ColonyShape, colonySwarmParams } from '../../vfx/Colony.js'; +import { Silhouette, LeadPath } from '../../vfx/Swarm.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { clamp, saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** + * Hard ceiling on agents. The editor's `locusts` slider clamps here, and the + * instanced buffer is built for exactly this many, so the two counts can never + * disagree. + */ +const MAX_LOCUSTS = 640; + +/** + * Contact samples the stripped track carries. + * + * Sixteen, and it is a ring buffer inside `GroundField`, so a longer cast does + * not cost more — it spaces the same sixteen samples further apart. Below about + * ten the swathe reads as a row of discs rather than as a continuous strip; + * above twenty the samples blend into one another and the extra ones are + * arithmetic nobody can see. + */ +const TRACK_MARKS = 16; + +/** + * How many points along the mass one frame's chaff is split between. + * + * A tide sheds from its whole width. Emitting a frame's worth from one point + * reads as a puff being thrown out sideways — the same mistake the bolt makes + * if all its sparks leave from a single node on the filament. + */ +const CHAFF_BATCHES = 3; + +/** + * The unit-space half-extents of the three silhouettes in `Colony.js`'s + * `colonyShapeField()`, and the reason this file has to know them. + * + * `shapeWidth/Height/Depth` scale a field that is *not* a unit cube: the wall is + * a `0.92 × 0.90 × 0.10` box rounded by another 0.06, the fist is a `0.52 × + * 0.44 × 0.34` box rounded by 0.20 with knuckles reaching 0.63, and the spear's + * widest point is its 0.21-radius spindle head. Anything that has to answer + * *where the bottom of this shape actually is* — which is exactly what the + * ground bite does — has to go through these, or the wall appears to be dragging + * two metres of nothing along the floor. + * + * They are a mirror of another module's source. Change one, change the other. + */ +const FIST_UNIT_HALF = 0.64; +const FIST_UNIT_WIDE = 0.72; +const WALL_UNIT_HALF = 0.96; +const WALL_UNIT_WIDE = 0.98; +const SPEAR_UNIT_HALF = 0.21; +const SPEAR_UNIT_WIDE = 0.21; + +/* --- module-scope scratch: the frame allocates nothing (I3) --- */ +const _colony = colonySwarmParams(); +const _rut = groundFieldParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _ground = new Vector3(); + +/** + * Which pair of shapes the morph is between, and how far across it is. + * + * One object, written by `_stageAt()` and read immediately by its caller. It is + * module scope because `_sync()` asks for it four times a frame and a fresh + * `{ index, blend }` four hundred times a second is exactly the allocation I3 + * exists to forbid. + */ +const _stage = { index: 0, blend: 0 }; + +/** + * LOCUST TIDE — a colony thrown down the aimed line that forms shapes as it + * travels. + * + * Five beats and none of them is a new renderer: the colony boils out of the + * caster's hand as a loose cloud, closes into a **fist**, opens into a **wall** + * broadside to the line, draws itself into a **spear** as it reaches the + * target, drives that spear into the floor, and comes apart. Behind it the + * ground is stripped in a ragged swathe. + * + * **THE TRICK — the transition, not the shape.** Anybody can park four hundred + * agents inside a signed distance field; what makes this read is that the field + * *changes* underneath them while they hold their places in it. Two things make + * that work, and both of them are in `vfx/Colony.js` rather than here: + * + * 1. **The fields are blended, never the points.** `mix(pointInFist, + * pointInWall, 0.5)` is a crossfade, and its midpoint is every agent + * halfway between two unrelated places — a smear with no silhouette at all. + * `mix(fistField, wallField, 0.5)` is a *shape*: a fist growing broad, a + * wall drawing itself to a point. Every intermediate state of this ability + * is a real object, which is the only reason the morph is worth watching. + * 2. **An agent keeps its own place.** Each one draws a single point from its + * own unchanging dice and walks it onto the isosurface by gradient descent, + * so its target is a pure function of the shape uniforms. Nothing is + * re-assigned when the shape changes; the agent simply flows to where the + * same corner of the same field went. Re-rolling per shape — the obvious + * implementation — boils the whole cloud on every transition, and a boiling + * cloud does not read as a thing becoming another thing. + * + * What this file owns is the **schedule**, and the schedule is deliberately + * measured in fractions of the cast line rather than in seconds: `fistAt`, + * `wallAt` and `spearAt` are where along the throw each silhouette lands, so a + * fifteen-metre cast and a thirty-metre one both show all three, in the same + * places relative to the caster, at the same speed slider. Timing them in + * seconds was the first version and it was wrong at both ends of the range — + * short casts arrived mid-fist and long ones held the spear for a second and a + * half of dead air. + * + * **The size triple morphs on the same fraction as the field.** A fist is + * compact, a wall is broad and thin, a spear is long and narrow, so each stage + * carries its own half-extents and its own ride height and they are lerped on + * the very blend the fields are. Blending the fields but holding the size gives + * a wall that arrives fist-sized and then inflates, which looks like a bug + * because it is one. + * + * **The floor is a consequence, not a second authored effect.** `_biteAt()` + * asks how far the *bottom of the current shape* is off the ground and turns + * that into a contact strength for `GroundField(RUT)`, so the wall — low, broad, + * dragging its knuckles — scours the floor, and the spear, which flies, barely + * marks it. Each contact sample already lying in the floor is re-derived every + * frame from the unitless fraction of the track it was taken at, so dragging + * `wallUp` with **P** held re-carves a gouge that was cut two seconds ago. + * + * A cast captures one seed, one fraction-of-the-line per contact sample and one + * unitless lateral dice roll per sample. Everything with a unit is resolved from + * `settings.locusttide` inside the update loop, on a zero-length frame included. + * + * Two draw calls: the colony and the swathe. + */ +export class LocusttideAbility extends Ability { + constructor(context) { + super('locusttide', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* + * The colony: one draw call for every agent in it. + * + * `additive: false` is not a detail. An additive swarm is a cloud of light + * and its silhouette dissolves the moment two agents overlap — which, in a + * condensed shape, is constantly. Chitin occludes chitin, and a fist that + * gets *darker* where it is thick is the reason the shape reads at all. + */ + this.colony = new ColonySwarm(this.group, { + capacity: MAX_LOCUSTS, + // BIRD, with the aspect pulled in and the flap rate pushed up: an insect + // is a bird's silhouette at a third of the span and three times the beat. + silhouette: Silhouette.BIRD, + additive: false, + renderOrder: 13 + }); + + /* The stripped swathe: one draw call, however long the cast. */ + this.track = new GroundField(this.group, { + mode: GroundMode.RUT, + marks: TRACK_MARKS, + additive: false, + depthTest: true, + name: 'LocustTrack' + }); + + /** Re-rolled per cast. A dice roll, not a dimension. */ + this._seed = 0; + /** Agents drawn last frame — the HUD's instance readout. */ + this._live = 0; + /** 0 while travelling, then `t` out of `onFade` — 0..1 hold, 1..2 blow-out. */ + this._beat = 0; + + /* + * The contact samples, as events. `_markU[i]` is the fraction of the cast + * the sample was taken at and `_markSlots[i]` is the `Vector4` inside + * `GroundField`'s own ring buffer that carries it. Neither holds a metre: + * where that fraction is on the floor and how hard the colony was pressing + * there are both recomputed from live settings every frame. + */ + this._markSlots = new Array(TRACK_MARKS).fill(null); + this._markU = new Float32Array(TRACK_MARKS); + this._markCount = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Shredded stubble kicked up under the mass. Lit and non-additive, because + // this is the one system that has to read as *matter* — the tide is eating + // the ground and the evidence has to be opaque. + this.chaff = particles.get('locusttide.chaff', { + capacity: 2400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chaff.uniforms.uDrag.value = 0.9; + this.chaff.uniforms.uEndSize.value = 0.7; + this.chaff.uniforms.uSizeIn.value = 0.04; + this.chaff.uniforms.uFadeIn.value = 0.05; + this.chaff.uniforms.uFadeOut.value = 0.55; + + // The dry ground haze the colony drags along with it. + this.dust = particles.get('locusttide.dust', { + capacity: 1400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.dust.uniforms.uDrag.value = 2.0; + this.dust.uniforms.uEndSize.value = 2.8; + this.dust.uniforms.uSizeIn.value = 0.16; + this.dust.uniforms.uFadeIn.value = 0.22; + this.dust.uniforms.uFadeOut.value = 0.34; + + // Sunlight caught on a wing. Tiny, short and additive — this is the only + // thing in the ability allowed to be bright, and it is what sells the mass + // as thousands of individual bodies rather than as one moving volume. + this.glints = particles.get('locusttide.glints', { + capacity: 2200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.3 + }); + this.glints.uniforms.uDrag.value = 1.8; + this.glints.uniforms.uEndSize.value = 0.15; + this.glints.uniforms.uSizeIn.value = 0.03; + this.glints.uniforms.uFadeIn.value = 0.04; + this.glints.uniforms.uFadeOut.value = 0.5; + + this.chaffEmitter = new RateEmitter(); + this.dustEmitter = new RateEmitter(); + this.glintEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + get impactDuration() { + return Math.max(0.05, settings.locusttide.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.locusttide.fadeTime); + } + + /** + * The light shimmers on the wingbeat rather than guttering. + * + * A quantised step like the bolt's, but shallow and fast: what the eye reads + * off a swarm lit from one side is a *flutter* in the light it bounces, not a + * flicker in the source. Deep flicker here makes the colony read as fire. + */ + lightShimmer() { + const c = settings.locusttide; + const step = Math.floor(this.age * Math.max(1, c.lightFlickerSpeed)); + const noise = Math.abs(Math.sin(step * 127.1) * 43758.5453) % 1; + return 1 - saturate(c.lightFlicker) * noise; + } + + /* ------------------------------------------------------------------ */ + /* The beats, as unitless fractions of live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the colony is along the line, 0..1. Pinned at the target after it lands. */ + _leadU() { + return this.phase === AbilityPhase.TRAVEL ? this.u : 1; + } + + /** Seconds since the spear landed. Zero while it is still on its way. */ + _sinceArrival() { + return this.phase === AbilityPhase.TRAVEL ? 0 : this.impactTime + this.fadeTime; + } + + /** + * Which two shapes the morph is between at `u`, and how far across it is. + * + * Writes and returns the shared `_stage`. The four schedule fractions are + * nudged apart rather than trusted to be in order — the editor lets you drag + * `wallAt` past `spearAt`, and the honest answer to that is a degenerate + * transition, not a division by zero and a cloud of NaN. + * + * The blend is raised to `morphCurve` and *then* smoothstepped. The power is + * the authored part — above 1 it holds each silhouette and crosses late, + * which is what makes the shapes read as three distinct poses rather than as + * one continuous ooze — and the smoothstep is there because a power curve + * still arrives at each end with a non-zero slope, and the resulting kink is + * visible as a twitch in four hundred agents all changing direction at once. + */ + _stageAt(u) { + const c = settings.locusttide; + const fist = saturate(c.fistAt); + const wall = Math.max(fist + 1e-3, saturate(c.wallAt)); + const spear = Math.max(wall + 1e-3, saturate(c.spearAt)); + + let k; + if (u <= wall) { + _stage.index = 0; + k = saturate((u - fist) / (wall - fist)); + } else { + _stage.index = 1; + k = saturate((u - wall) / (spear - wall)); + } + k = Math.pow(k, Math.max(0.05, c.morphCurve)); + _stage.blend = k * k * (3 - 2 * k); + return _stage; + } + + /** + * One measurement, interpolated across whichever transition is live. + * + * Called with the three stages' values for the same quantity — half-width, + * ride height, whatever — so the size triple and the field are guaranteed to + * be crossing on the same fraction. That guarantee is the whole reason this + * is a helper rather than three lerps written out at the call site. + */ + _stageValue(u, fist, wall, spear) { + const stage = this._stageAt(u); + return stage.index === 0 ? lerp(fist, wall, stage.blend) : lerp(wall, spear, stage.blend); + } + + /** 0..1 — how completely the shape has taken the flock over at `u`. */ + _condenseAt(u) { + const c = settings.locusttide; + const close = saturate(c.closeAt); + const fist = Math.max(close + 1e-3, saturate(c.fistAt)); + return Easing.outCubic(saturate((u - close) / (fist - close))) * saturate(c.condenseMax); + } + + /** 0..1 — how far the shape has let the colony go again. */ + _disperse() { + const c = settings.locusttide; + const since = this._sinceArrival() - c.holdTime; + return Easing.outCubic(saturate(since / Math.max(0.02, c.disperseTime))); + } + + /** + * The reveal wave, 0..1. + * + * Ramps up as the colony gathers at the hand and back down as it scatters; + * `Swarm` compares it against each agent's own dice, so they appear and wink + * out one at a time rather than as a block. A block is a decal switching on. + */ + _reveal() { + const c = settings.locusttide; + const inWave = saturate(this.age / Math.max(0.02, c.revealTime)); + const dying = this._sinceArrival() - c.holdTime - c.vanishDelay; + const outWave = 1 - saturate(dying / Math.max(0.02, c.vanishTime)); + return Math.min(inWave, outWave); + } + + /** + * How high the shape's centre rides above the **floor** at `u`, metres. + * + * Note the frame this is in. `ColonySwarm` measures `shapeUp` from the lead, + * and the lead lofts over the middle of its throw, so authoring the three + * stages against the lead would have made every silhouette's height depend on + * `leadRise` — drag the loft and the wall climbs off the ground it is + * supposed to be standing on. These are floor heights, and `_sync()` converts + * by subtracting the lead's own height. The loft therefore shapes the loose + * cloud and nothing else, which is what it is for. + */ + _shapeCentreY(u) { + const c = settings.locusttide; + return this._stageValue(u, c.fistUp, c.wallUp, c.spearUp); + } + + /** + * The height of the lead at `u`, metres — the CPU mirror of `leadAt()` in + * `LINE` mode. Mirror, so if one changes the other has to. + */ + _leadHeight(u) { + const c = settings.locusttide; + const t = saturate(u); + return lerp(c.handHeight, c.endHeight, t) + c.leadRise * Math.sin(Math.PI * t); + } + + /** + * How hard the colony is pressing on the floor at `u`, 0..1. + * + * Derived from the shape's own geometry — its centre height less its real + * half-height, in metres above the floor — and weakened over `biteReach` + * metres of clearance. That is why the wall, which stands with its lower edge + * in the dirt, strips the ground, and the spear, which flies, barely marks + * it; and why raising `wallUp` on a paused frame lifts the gouge out of a + * swathe that was cut before you touched the slider. + */ + _biteAt(u) { + const c = settings.locusttide; + const half = this._stageValue( + u, + c.fistHeight * FIST_UNIT_HALF, + c.wallHeight * WALL_UNIT_HALF, + c.spearHeight * SPEAR_UNIT_HALF + ); + const clearance = Math.max(0, this._shapeCentreY(u) - half); + const contact = 1 - saturate(clearance / Math.max(0.05, c.biteReach)); + return saturate(contact * this._condenseAt(u) * c.biteGain); + } + + /** + * Half the mass's lateral extent at `u`, metres — how far off the centre an + * emitter may throw something and still be inside the colony. + * + * Through the unit widths for the same reason the bite goes through the unit + * heights: a `spearWidth` of 2 is a 0.42 m spindle, not a 2 m one, and an + * emitter that believes otherwise sprays chaff into clean air either side of + * a spear that is nowhere near it. + */ + _massRadius(u) { + const c = settings.locusttide; + return ( + this._stageValue( + u, + c.fistWidth * FIST_UNIT_WIDE, + c.wallWidth * WALL_UNIT_WIDE, + c.spearWidth * SPEAR_UNIT_WIDE + ) + c.size + ); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.chaffEmitter.reset(); + this.dustEmitter.reset(); + this.glintEmitter.reset(); + + this._beat = 0; + this._markCount = 0; + for (let i = 0; i < TRACK_MARKS; i++) this._markSlots[i] = null; + this.track.clearMarks(); + this.track.setVisible(true); + + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + this.colony.roll(this._seed); + + this._sync(1); + this._releaseFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Resolve the whole colony, its swathe and its particle systems from live + * settings. + * + * @param {number} fade 1 while the colony is up, ramping to 0 as it blows out + */ + _sync(fade) { + const c = settings.locusttide; + const g = settings.global; + const p = _colony; + + const travelling = this.phase === AbilityPhase.TRAVEL; + const u = this._leadU(); + const disperse = this._disperse(); + // The shape's grip: everything the schedule closed, less everything the + // dispersal has let go of. + const closed = this._condenseAt(u) * (1 - disperse); + + this._live = clamp(Math.round(c.locusts), 0, MAX_LOCUSTS); + + /* --- the lead -------------------------------------------------- */ + p.count = this._live; + p.leadMode = LeadPath.LINE; + p.leadS = u; + // d(s)/dt, so the shader can rewind the lead for the lagged ranks. Metres + // per second over metres — unitless, and read from the live speed rather + // than from whatever the front happened to do last frame. + p.leadRate = travelling ? (c.speed * g.speed) / Math.max(0.1, this.length) : 0; + p.leadRise = c.leadRise; + p.handForward = c.handForward; + p.handSide = c.handSide; + p.handHeight = c.handHeight; + p.endHeight = c.endHeight; + + /* --- the formation, opening out as the shape lets go ------------ */ + p.latticeX = c.latticeX; + p.latticeY = c.latticeY; + p.latticeZ = c.latticeZ; + p.spacingSide = c.spacingSide * lerp(1, c.disperseSpacing, disperse); + p.spacingUp = c.spacingUp * lerp(1, c.disperseSpacing, disperse); + p.lag = c.lag * lerp(1, c.disperseLag, disperse); + p.jitter = c.jitter * g.randomness; + p.churn = c.churn * lerp(1, c.disperseChurn, disperse); + p.breathe = c.breathe; + p.breatheRate = c.breatheRate; + p.wander = c.wander * lerp(1, c.disperseWander, disperse) * g.turbulence; + p.wanderScale = c.wanderScale * g.noiseFrequency; + p.wanderSpeed = c.wanderSpeed * g.noiseSpeed; + p.gather = c.gather; + + /* --- one locust -------------------------------------------------- */ + p.size = c.size; + p.aspect = c.aspect; + p.sizeJitter = c.sizeJitter * g.randomness; + p.billboard = c.billboard; + p.bank = c.bank; + p.bankMax = c.bankMax; + p.dihedral = c.dihedral; + p.flapRate = c.flapRate; + p.curl = c.wingCurl; + p.edgeStretch = c.edgeStretch; + p.reveal = this._reveal(); + p.revealSpread = c.revealSpread; + + /* --- the silhouette and its colour ------------------------------- */ + p.silhouette = Silhouette.BIRD; + p.sweep = c.sweep; + p.edgeGain = c.edgeGain; + p.lit = c.lit; + p.tint = c.tint; + p.tintJitter = c.tintJitter; + p.tintAlong = c.tintAlong; + p.opacity = c.opacity * g.opacity * fade; + p.glow = c.glow * g.glow; + p.softFade = c.softFade; + + /* --- THE TRICK: which two shapes, and how far across ------------- */ + const stage = this._stageAt(u); + p.shapeA = stage.index === 0 ? ColonyShape.FIST : ColonyShape.WALL; + p.shapeB = stage.index === 0 ? ColonyShape.WALL : ColonyShape.SPEAR; + p.shapeBlend = stage.blend; + p.condense = closed; + // The same fraction the fields are crossing on. See the class comment. + p.shapeWidth = this._stageValue(u, c.fistWidth, c.wallWidth, c.spearWidth); + p.shapeHeight = this._stageValue(u, c.fistHeight, c.wallHeight, c.spearHeight); + p.shapeDepth = this._stageValue(u, c.fistDepth, c.wallDepth, c.spearDepth); + p.shapeForward = c.shapeForward; + p.shapeSide = c.shapeSide; + // Authored against the floor, handed over in the lead's frame — see + // `_shapeCentreY()` for why that conversion is here rather than in the + // settings block. + p.shapeUp = this._shapeCentreY(u) - this._leadHeight(u); + p.shapeSpin = c.shapeSpin; + p.shapeFill = c.shapeFill; + p.shapeSteps = c.shapeSteps; + p.shapeSlack = c.shapeSlack; + p.shapeRough = c.shapeRough * g.randomness; + + /* --- the surge running down the mass ----------------------------- */ + p.waveAmp = c.waveAmp; + p.waveLength = c.waveLength; + p.waveSpeed = c.waveSpeed; + p.waveAlong = c.waveAlong; + + this.colony.setBasis(this.origin, this.direction, this.side, this.length); + this.colony.setColors(c.colorLocustA, c.colorLocustB, c.colorLocustC, c.colorLocustD); + // The clock argument is ignored by design: the churn, the shape's spin and + // the surge all run on the shared `uTime`, so a colony four seconds into + // its flight does not restart its motion when it condenses. + this.colony.update(this.age, p); + + this._syncTrack(c, g, fade); + this._syncParticles(c, g); + } + + /** + * The stripped swathe. + * + * Every sample already in the floor is re-derived here — where it sits along + * the track and how hard the colony was pressing on it are both consequences + * of the live schedule, not of what the schedule said when the sample was + * posted. This loop is the line that makes `wallAt`, `wallUp`, `wallHeight` + * and `biteReach` re-carve a gouge that is already lying in the ground. + */ + _syncTrack(c, g, fade) { + for (let i = 0; i < this._markCount; i++) { + const slot = this._markSlots[i]; + if (!slot) continue; + const u = this._markU[i]; + slot.y = u; + slot.w = this._biteAt(u); + } + + // The swathe holds at full strength and then weathers away, on its own + // clock rather than the colony's: the insects leave before the damage does. + const since = this._sinceArrival(); + const weather = 1 - Easing.inQuad(saturate((since - c.rutHold) / Math.max(0.05, c.rutFadeTime))); + + _rut.centre = this.origin; + _rut.yaw = Math.atan2(this.direction.x, this.direction.z); + _rut.height = c.rutHeight; + // RUT reads `radius` only to size its quad; the swathe's own measurement is + // `width`, and a quad three half-widths across leaves room for the spoil. + _rut.radius = Math.max(0.2, c.rutWidth * 3); + _rut.length = Math.max(0.5, this.length); + _rut.progress = this._leadU(); + _rut.grow = 1; + _rut.recede = 0; + _rut.fade = fade * weather; + _rut.seed = this._seed; + + _rut.edge = c.rutEdge; + _rut.ragged = c.rutRagged; + _rut.raggedScale = c.rutRaggedScale * g.noiseFrequency; + _rut.warp = c.rutWarp * g.noiseStrength; + + _rut.relief = c.rutRelief; + _rut.normalStep = c.rutNormalStep; + _rut.ambient = c.rutAmbient; + _rut.wrap = c.rutWrap; + _rut.specular = c.rutSpecular; + _rut.gloss = c.rutGloss; + _rut.parallax = c.rutParallax; + + _rut.width = c.rutWidth; + _rut.depth = c.rutDepth; + _rut.sharp = c.rutSharp; + _rut.cell = c.rutCell; + _rut.detail = c.rutBiteDepth; + _rut.lift = c.rutSpoil; + _rut.thickness = c.rutSpoilWidth; + _rut.seam = c.rutSampleBlend; + // The swathe follows the cast line; a lateral drift here would be a track + // belonging to something that walked off it. + _rut.swirl = 0; + _rut.speed = 1; + _rut.flow = 0; + _rut.windAngle = 0; + + _rut.additive = false; + _rut.emissive = c.rutEmissive; + _rut.opacity = c.rutOpacity; + _rut.depthFade = c.rutDepthFade; + _rut.colorBase = c.colorRutBase; + _rut.colorEdge = c.colorRutEdge; + _rut.colorGlow = c.colorRutGlow; + _rut.colorDeep = c.colorRutDeep; + + _rut.noiseStrength = g.noiseStrength; + _rut.noiseFrequency = g.noiseFrequency; + _rut.noiseSpeed = g.noiseSpeed; + _rut.opacityScale = g.opacity; + + this.track.update(_rut); + this.track.setVisible(_rut.fade > 0.001); + } + + /** Gradients and scales for the three systems. Live, every frame. */ + _syncParticles(c, g) { + this.chaff.setGradient( + getColor(c.colorChaffA), + getColor(c.colorChaffB), + getColor(c.colorChaffC), + getColor(c.colorChaffD) + ); + this.chaff.uniforms.uGravity.value.set(0, c.chaffGravity, 0); + this.chaff.uniforms.uSizeScale.value = c.chaffSize * g.particleSize * 7; + this.chaff.uniforms.uLifeScale.value = c.chaffLifetime * 0.5 * g.particleLifetime; + this.chaff.uniforms.uSpeedScale.value = g.particleSpeed; + this.chaff.uniforms.uOpacity.value = g.opacity; + this.chaff.uniforms.uTurbulence.value = 0.3 * g.turbulence; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.glints.setGradient( + getColor(c.colorGlintA), + getColor(c.colorGlintB), + getColor(c.colorGlintC), + getColor(c.colorGlintD) + ); + this.glints.uniforms.uGravity.value.set(0, c.glintRise, 0); + this.glints.uniforms.uSizeScale.value = c.glintSize * g.particleSize * 7; + this.glints.uniforms.uLifeScale.value = c.glintLifetime * 0.5 * g.particleLifetime; + this.glints.uniforms.uSpeedScale.value = g.particleSpeed; + this.glints.uniforms.uOpacity.value = g.opacity; + this.glints.uniforms.uGlow.value = c.glintGlow * g.glow; + this.glints.uniforms.uTurbulence.value = c.glintTurbulence * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** The boil at the caster's hand as the colony comes out of it. */ + _releaseFx() { + const c = settings.locusttide; + const g = settings.global; + + this.colony.leadPoint(_pos); + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.muzzleSize * 0.25, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.35, + intensity: c.muzzleIntensity, + opacity: 0.85, + fresnel: 1.3, + displace: 0.7, + colorA: getColor(c.colorMuzzleA), + colorB: getColor(c.colorMuzzleB), + colorC: getColor(c.colorMuzzleC) + }); + + _emit.position = _pos; + _emit.radius = this._massRadius(0) * 0.4; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.7).setY(0.4).normalize(); + _emit.speed = c.glintSpeed * 2.2; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.glintLifetime; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.glints.emit(Math.round(c.castGlints * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + /** + * Everything the tide sheds while it is up. + * + * @param {number} dt seconds + * @param {number} scale 0..1 — thinned once the colony is only holding + */ + _colonyFx(dt, scale) { + const c = settings.locusttide; + const g = settings.global; + const time = frame.uTime.value; + const u = this._leadU(); + const radius = this._massRadius(u); + // How much of the floor the mass is actually touching. The chaff and the + // dust are the *ground* answering, so they follow the bite rather than the + // colony: a spear flying overhead lifts nothing. + const bite = this._biteAt(u); + + this.colony.shapeCentre(_centre); + this.pointAt(u, _ground); + + let chaffCount = Math.round( + this.chaffEmitter.tick(dt, c.chaffRate * scale * bite) * g.particleCount + ); + if (chaffCount > 0) { + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.35).setY(1).normalize(); + _emit.speed = c.chaffSpeed; + _emit.speedVariance = 0.8; + _emit.spread = c.chaffSpread; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.75; + _emit.life = c.chaffLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = c.chaffSpin; + _emit.tint = null; + _emit.time = time; + + // Split along the swathe, not fired from one point — see CHAFF_BATCHES. + const batches = Math.min(chaffCount, CHAFF_BATCHES); + const per = Math.ceil(chaffCount / batches); + while (chaffCount > 0) { + this.pointAt(saturate(u - randRange(0, 0.1)), _pos); + _pos.y = 0.05; + _emit.position = _pos; + _emit.radius = c.rutWidth; + this.chaff.emit(Math.min(per, chaffCount), _emit); + chaffCount -= per; + } + } + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale * bite) * g.particleCount); + if (dustCount > 0) { + _ground.y = 0.12; + _emit.position = _ground; + _emit.radius = c.rutWidth * 1.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + // The glints come off the *mass*, at whatever height it is flying, and they + // are the one emitter that does not care about the floor. + const glintCount = Math.round(this.glintEmitter.tick(dt, c.glintRate * scale) * g.particleCount); + if (glintCount > 0) { + _emit.position = _centre; + _emit.radius = radius; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.glintSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.size = 0.07; + _emit.sizeVariance = 0.8; + _emit.life = c.glintLifetime; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.time = time; + this.glints.emit(glintCount, _emit); + } + } + + /** + * Post contact samples along the track as the front passes them. + * + * A sample is three unitless numbers and nothing else: the fraction of the + * cast it was taken at, a lateral dice roll so the swathe does not read as a + * ruled line, and the timestamp. Its depth is not recorded at all — it is + * re-derived from the live schedule every frame in `_syncTrack()`. + */ + _trackFx() { + const c = settings.locusttide; + const samples = Math.max(1, Math.min(TRACK_MARKS, Math.round(c.rutSamples))); + const u = this._leadU(); + + while (this._markCount < samples && u >= (this._markCount + 1) / samples) { + const at = (this._markCount + 1) / samples; + this._markU[this._markCount] = at; + this._markSlots[this._markCount] = this.track.mark( + randRange(-0.35, 0.35), + at, + frame.uTime.value, + this._biteAt(at) + ); + this._markCount++; + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._beat = 0; + this._sync(1); + + // The light rides the shape, not the floor under it. `advance()` has + // already put `position` on the ground line, so lift it onto the mass. + this.colony.shapeCentre(this.position); + + this._colonyFx(dt, 1); + this._trackFx(); + + this.ctx.shake.rumble(settings.locusttide.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.locusttide; + const g = settings.global; + const time = frame.uTime.value; + + this.colony.shapeCentre(_pos); + this.pointAt(1, _ground); + + /* the dust the spear drives out of the floor */ + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.7, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.3, + displace: 0.8, + squash: 0.85, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring of chaff blown out across the ground */ + this.ctx.decals.spawn(DecalType.DUSTRING, _ground, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.9, + width: 0.07, + intensity: 0.8, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB), + height: 0.02 + }); + + /* and the stubble it throws up */ + _emit.position = _ground; + _emit.radius = c.rutWidth * 1.6; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.25).setY(1).normalize(); + _emit.speed = c.chaffSpeed * 2.4; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.13; + _emit.sizeVariance = 0.8; + _emit.life = c.chaffLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = c.chaffSpin * 1.5; + _emit.tint = null; + _emit.time = time; + this.chaff.emit(Math.round(c.burstChaff * g.particleCount), _emit); + + _emit.position = _pos; + _emit.radius = this._massRadius(1); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.glintSpeed * 3.2; + _emit.spread = 1.0; + _emit.size = 0.09; + _emit.life = c.glintLifetime * 1.3; + _emit.spin = 0; + this.glints.emit(Math.round(c.burstGlints * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.1 * g.explosionIntensity; + } + + onFade(dt, t) { + this._beat = t; + // 1 through the hold, easing to 0 through the blow-out. Cubic, so the last + // of the colony hangs on and then goes rather than dimming evenly. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._sync(fade); + + this.colony.shapeCentre(this.position); + + // Thinned as it dies, but never cut dead: a colony coming apart is still + // shedding until the last agent winks out. + this._colonyFx(dt, fade * (t <= 1 ? 0.7 : 0.3)); + } + + onDestroy() { + this._live = 0; + this._beat = 0; + this._markCount = 0; + for (let i = 0; i < TRACK_MARKS; i++) this._markSlots[i] = null; + this.colony.reset(); + this.track.clearMarks(); + this.track.setVisible(false); + } + + dispose() { + this.colony.dispose(); + this.track.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/hive/WaspfunnelAbility.js b/src/abilities/hive/WaspfunnelAbility.js new file mode 100644 index 0000000..8ae76c2 --- /dev/null +++ b/src/abilities/hive/WaspfunnelAbility.js @@ -0,0 +1,966 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { ColonySwarm, ColonyShape, colonySwarmParams } from '../../vfx/Colony.js'; +import { Silhouette, LeadPath } from '../../vfx/Swarm.js'; +import { Tube, TubePath } from '../../vfx/Tube.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { clamp, saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceilings. The editor's `wasps` and `crawlers` sliders clamp here. */ +const MAX_WASPS = 760; +const MAX_CRAWLERS = 260; + +/** + * The two unit-space constants this file has to know about `ColonySwarm`'s + * shape library, and the reason they are named rather than folded into a fudge + * factor. + * + * `colonyShapeField` builds its column as `colonyCylinderY(p, 0.40, 0.90) - + * 0.06`, so the shape that actually appears has a radius of **0.46** and a + * half-height of **0.96** in unit space before `shapeSize` scales it; its ball + * is `length(p) - 0.90`. Dividing by these is what makes `columnHug = 1` mean + * "exactly the tube's own radius" rather than "about the tube's radius, near + * enough". They are a mirror of another module's source: change one, change + * the other. + */ +const COLUMN_UNIT_RADIUS = 0.46; +const COLUMN_UNIT_HALF = 0.96; +const BALL_UNIT_RADIUS = 0.9; + +/** + * How many bearings one frame's grit is split between. + * + * A nest is disturbed all the way round its mouth, and emitting a frame's worth + * from one bearing reads as a puff being thrown sideways rather than as ground + * being worked over. + */ +const GRIT_BATCHES = 3; + +/* --- module-scope scratch: the frame allocates nothing (I3) --- */ +const _column = colonySwarmParams(); +const _crawl = colonySwarmParams(); +const _pit = groundFieldParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _mouth = new Vector3(); +const _axis = new Vector3(); +const _flat = new Vector3(); +const _ride = new Vector3(); + +/** + * WASP FUNNEL — a nest opens on the aimed circle and a funnel of wasps stands + * up out of it. + * + * Four beats: the mouth chews itself open in the floor while the cast is still + * going out, the column stands up over `standTime`, it holds and surges for + * `lifetime`, and then it sinks — the mouth dropping and the column thinning + * while the ground carpet of crawlers swells with everything that has landed. + * + * **THE TRICK — density waves, and a wingbeat nobody is conducting.** + * + * The waves are *longitudinal*. Each agent is displaced along the wave axis by + * a sine of its own position on that axis, which crowds agents at the zero + * crossings and thins them at the extremes, so bands of high density travel up + * the funnel at `waveSpeed / waveLength` per second. No agent is told where a + * band is; the band is what happens when five hundred of them each answer the + * same question about where they are standing. The alternative — modulating + * per-agent brightness on the same sine — is in `vfx/Colony.js`'s history as + * the version that failed, and it failed for a reason worth keeping in mind: + * density you can see is agents *arriving*, not agents brightening. + * + * A consequence worth knowing, because it is free and it does a lot of work: + * the surge is applied to all three of the flock's time samples (`t`, `t − h`, + * `t − 2h`), so a condensed agent that would otherwise be motionless — and + * therefore unbanked and pointing nowhere — gets its heading and its roll from + * the wave. The wasps in a crest are visibly *climbing*. + * + * The wingbeat is the other half and costs a single sine: the fold runs on + * `sin(TAU · (flapRate · uTime + dice.y))` where `dice.y` is per agent, so + * every wasp beats on its own phase. With `edgeGain` up, a card going edge-on + * collapses to a bright line, and five hundred of those flickering + * independently is a mass that *shimmers* with nothing animated globally. Turn + * `flapRate` to zero and the funnel goes dead — it stops being a swarm and + * becomes a mesh with insects painted on it. + * + * **Everything is placed against `Tube.radiusAt()`.** `vfx/Tube.js` in FUNNEL + * mode publishes the real vortex profile — `throat + skirt(tau) + mouth(tau)` — + * and no radius in this file is computed any other way: the barrel of agents is + * `radiusAt(barrel centre) × columnHug`, the crawlers' disc is `radiusAt(0) × + * crawlReach`, the pit is `radiusAt(0) × pitReach`, the pollen leaves on a ring + * of `radiusAt(1)`. Drag `funnelSkirtFlare` with the clock stopped and the + * carpet, the pit and the dust all move together, because there is one of them. + * + * **Where the colony is, and the honest reason.** A funnel is a cone at both + * ends and a cylinder in the middle. `ColonySwarm` has a `COLUMN` and no cone, + * so the agents are placed over exactly the cylindrical section — `barrelFrom` + * to `barrelTo`, which want to sit inside `funnelSkirtHeight`… + * `funnelMouthStart` — and the two flares are carried by the tube's dust, the + * pit and the ejected pollen. The first attempt stretched the column across the + * whole profile and scaled it to the mouth: agents stood a metre outside the + * skirt at the bottom and a metre inside the mouth at the top, and the funnel + * read as a cylinder with a smear of haze around it. Sitting the mass where the + * profile really is a cylinder is not a compromise; it is the correct reading + * of the curve. + * + * `zoneRadius` is deliberately *not* the profile. It is the aim circle, and + * what it promises is the ground the nest disturbs — the grit comes off it and + * the haze spreads across it — which is wider than the mouth. A nest from above + * is a small dark hole inside a much larger scuffed patch. + * + * A cast captures two unitless dice rolls: `_seed` for the two colonies' cells + * and `_yawRoll` so the pit's lip does not calve the same way twice. + * + * Six draw calls: three for the tube, one per colony, one for the mouth. + */ +export class WaspfunnelAbility extends Ability { + constructor(context) { + super('waspfunnel', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the haze column: 3 draw calls, and the profile everything reads --- */ + this.funnel = new Tube({ + path: TubePath.FUNNEL, + prefix: 'funnel', + // Smooth along its length, detailed around it: the sway and the mouth + // flare are low-frequency, but the barrel needs facets or the silhouette + // polygonises as soon as the camera comes near it. + nodes: 64, + sides: 30, + renderOrder: 10 + }); + this.group.add(this.funnel.group); + + /* --- the colony in the barrel: 1 draw call --- */ + this.colony = new ColonySwarm(this.group, { + capacity: MAX_WASPS, + silhouette: Silhouette.BIRD, + // Not additive. An additive swarm is a cloud of light, and a cloud of + // light has no density — which would delete the one thing this ability + // is about. Wasps occlude wasps, so a crest reads as *thicker*. + additive: false, + renderOrder: 13 + }); + + /* --- the carpet on the mouth: 1 draw call --- */ + this.crawlers = new ColonySwarm(this.group, { + capacity: MAX_CRAWLERS, + silhouette: Silhouette.BIRD, + additive: false, + renderOrder: 12 + }); + + /* --- the mouth itself: 1 draw call --- */ + this.pit = new GroundField(this.group, { + mode: GroundMode.FUNNEL, + additive: false, + depthTest: true, + name: 'WaspNestMouth' + }); + + /** Unitless dice rolls, re-rolled per cast. The only things a cast keeps. */ + this._seed = 0; + this._yawRoll = 0; + /** Agents drawn last frame, both colonies — the HUD's instance readout. */ + this._live = 0; + /** 0 while travelling, then `t` out of `onFade` — 0..1 hold, 1..2 collapse. */ + this._beat = 0; + /** Set on the frame the collapse begins. An event, not a dimension. */ + this._sank = false; + + /** + * Scratch state handed to the tube each frame. One object, reused — syncing + * the column allocates nothing. + */ + this._state = { + origin: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 1, + fade: 1, + widthFade: 1, + seed: 0, + time: 0 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Pollen and wing-dust thrown out of the mouth. Additive and short: the one + // bright thing in the ability, and it fires on the crest of the surge. + this.motes = particles.get('waspfunnel.motes', { + capacity: 2200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.35 + }); + this.motes.uniforms.uDrag.value = 1.6; + this.motes.uniforms.uEndSize.value = 0.2; + this.motes.uniforms.uSizeIn.value = 0.05; + this.motes.uniforms.uFadeIn.value = 0.06; + this.motes.uniforms.uFadeOut.value = 0.45; + + // Chips of chewed ground whipped off the skirt. Lit and opaque, because + // this is the system that has to read as matter. + this.grit = particles.get('waspfunnel.grit', { + capacity: 1800, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 1.0; + this.grit.uniforms.uEndSize.value = 0.6; + this.grit.uniforms.uSizeIn.value = 0.04; + this.grit.uniforms.uFadeIn.value = 0.05; + this.grit.uniforms.uFadeOut.value = 0.5; + + // Dry haze standing around the foot. Non-additive so it genuinely occludes. + this.haze = particles.get('waspfunnel.haze', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.haze.uniforms.uDrag.value = 2.1; + this.haze.uniforms.uEndSize.value = 2.6; + this.haze.uniforms.uSizeIn.value = 0.18; + this.haze.uniforms.uFadeIn.value = 0.24; + this.haze.uniforms.uFadeOut.value = 0.3; + + this.moteEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + this.hazeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + get impactDuration() { + return Math.max(0.05, settings.waspfunnel.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.waspfunnel.fadeTime); + } + + /** + * The light carries the wingbeat *and* the surge. + * + * Two terms, and they are two different things. The quantised step is the + * flutter of a mass of wings passing in front of a light — shallow and fast, + * because a deep flicker here makes the nest read as fire. The second term is + * the **third consumer of the density wave**: the light brightens as a crest + * climbs past the height it is sitting at, so the column is lit in time with + * its own surge rather than on a clock of its own. + */ + lightShimmer() { + const c = settings.waspfunnel; + const step = Math.floor(this.age * Math.max(1, c.lightFlickerSpeed)); + const noise = Math.abs(Math.sin(step * 127.1) * 43758.5453) % 1; + const wing = 1 - saturate(c.lightFlicker) * noise; + this.funnel.pointAt(saturate(c.lightRide), _ride); + return Math.max(0, wing * (1 + saturate(c.lightSurge) * this._surgeAt(_ride))); + } + + /* ------------------------------------------------------------------ */ + /* The beats and the wave */ + /* ------------------------------------------------------------------ */ + + /** Seconds since the nest opened. Zero while the cast is still going out. */ + _sinceArrival() { + return this.phase === AbilityPhase.TRAVEL ? 0 : this.impactTime + this.fadeTime; + } + + /** 0..1 — how far the column has stood up out of the mouth. */ + _stand() { + const c = settings.waspfunnel; + return Easing.outCubic(saturate(this._sinceArrival() / Math.max(0.02, c.standTime))); + } + + /** 0..1 — how far it has sunk back into it. `_beat` runs 1..2 through the collapse. */ + _sink() { + return Easing.inQuad(saturate(this._beat - 1)); + } + + /** 0..1 — how far the mouth has chewed itself open. Runs from the cast, not the arrival. */ + _mouthOpen() { + const c = settings.waspfunnel; + return Easing.outCubic(saturate(this.age / Math.max(0.02, c.pitGrowTime))); + } + + /** + * The wave axis, as `ColonySwarm` computes it. + * + * A CPU mirror of four lines in `Colony.js#update`: `waveAlong` lerps world + * up toward the flattened cast heading and the result is normalised. It is a + * mirror, so if one changes the other has to — but the alternative is a + * second, subtly different idea of which way the crests are travelling, and + * the whole point of this ability is that there is only one wave. + */ + _surgeAxis(out) { + const c = settings.waspfunnel; + out.set(0, 1, 0).lerp(_flat.copy(this.direction).setY(0.0001), saturate(c.waveAlong)); + if (out.lengthSq() < 1e-8) out.set(0, 1, 0); + return out.normalize(); + } + + /** + * The surge at a world point, -1..1. + * + * The other half of the mirror — `colonySurge()`'s phase, evaluated on the + * CPU so an emitter and a light can fire on the crest the eye is watching. It + * runs on `frame.uTime`, not on the ability's age, for the same reason the + * shader does: a colony four seconds into its life should not restart its + * surge because something else happened. + */ + _surgeAt(point) { + const c = settings.waspfunnel; + const len = Math.max(0.05, c.waveLength); + this._surgeAxis(_axis); + const along = point.dot(_axis); + return Math.sin((TAU * (along - c.waveSpeed * frame.uTime.value)) / len); + } + + /** Where the nest is. A far cast lands on its circle, so this is the target. */ + _centrePoint(out) { + return this.pointAt(1, out); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.gritEmitter.reset(); + this.hazeEmitter.reset(); + + this._beat = 0; + this._sank = false; + + // The two things a cast captures, both unitless. + this._seed = Math.random() * 100; + this._yawRoll = Math.random(); + + this.colony.roll(this._seed); + this.crawlers.roll(this._seed + 41.7); + this.pit.setVisible(true); + + this._sync(1); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve everything from live settings and push it into the four modules + * and the three particle systems. + * + * Order matters exactly once: the tube is synced **first**, because every + * `radiusAt()` and `pointAt()` below reads the dimensions that sync resolved. + * Query it before syncing it and you are placing this frame's colony against + * last frame's funnel, which is invisible until a slider moves and then looks + * like lag. + * + * @param {number} fade 1 while the funnel stands, ramping to 0 as it sinks + */ + _sync(fade) { + const c = settings.waspfunnel; + const g = settings.global; + const stand = this._stand(); + const sink = this._sink(); + const base = c.baseHeight; + + this._centrePoint(_centre); + + /* --- 1 · the haze column ---------------------------------------- */ + const state = this._state; + state.origin.set(_centre.x, base, _centre.z); + // The mouth drops as the nest takes the colony back in: the column dies + // downward, which is the opposite of a fade and the only reason the + // collapse reads as the wasps *landing* rather than as the effect ending. + state.target.set(_centre.x, Math.max(base + 0.3, c.height * lerp(1, c.sinkDrop, sink)), _centre.z); + state.side.copy(this.side); + state.progress = 1; + state.fade = fade * stand; + // Two collapses on one number, because both of them are widths: the thread + // the column stands up out of, and the thread it sinks back to. + state.widthFade = lerp(c.standTaper, 1, stand) * lerp(1, c.sinkTaper, sink); + state.seed = this._seed; + state.time = this.age; + this.funnel.sync(c, state, g); + this.funnel.visible = state.fade > 0.002; + + /* --- 2 · the colony, placed against the profile ------------------ */ + // Nothing from here down computes a radius. Every one of them asks the tube. + const from = saturate(Math.min(c.barrelFrom, c.barrelTo)); + const to = Math.max(from + 1e-3, saturate(Math.max(c.barrelFrom, c.barrelTo))); + const mid = (from + to) * 0.5; + this.funnel.pointAt(from, _pos); + const barrelBottom = _pos.y; + this.funnel.pointAt(to, _pos); + const barrelTop = _pos.y; + const barrelMid = (barrelBottom + barrelTop) * 0.5; + const barrelRadius = this.funnel.radiusAt(mid) * c.columnHug; + + const p = _column; + const wasps = clamp(Math.round(c.wasps), 0, MAX_WASPS); + + p.count = wasps; + // POINT: the lead does not travel, it stands at the nest. The barrel's own + // mid height is handed to the lead rather than to `shapeUp`, so the flock + // the agents fall back into as `condense` drops is centred on the same + // place the shape is — otherwise every slider that softens the column also + // drags it toward the floor. + p.leadMode = LeadPath.POINT; + p.leadS = 0; + p.leadRate = 0; + p.endHeight = barrelMid; + + p.latticeX = c.latticeX; + p.latticeY = c.latticeY; + p.latticeZ = c.latticeZ; + p.spacingSide = c.spacingSide; + p.spacingUp = c.spacingUp; + p.lag = c.lag; + p.jitter = c.jitter * g.randomness; + p.churn = c.churn; + p.breathe = c.breathe; + p.breatheRate = c.breatheRate; + p.wander = c.wander * g.turbulence; + p.wanderScale = c.wanderScale * g.noiseFrequency; + p.wanderSpeed = c.wanderSpeed * g.noiseSpeed; + p.gather = c.gather; + + p.size = c.size; + p.aspect = c.aspect; + p.sizeJitter = c.sizeJitter * g.randomness; + p.billboard = c.billboard; + p.bank = c.bank; + p.bankMax = c.bankMax; + p.dihedral = c.dihedral; + p.flapRate = c.flapRate; + p.curl = c.wingCurl; + p.edgeStretch = c.edgeStretch; + // Populated on its own clock and emptied as the column sinks, agent by + // agent against their own dice rather than as a block. `revealTime` is + // deliberately not `standTime`: the funnel's *shape* standing up and the + // funnel *filling with wasps* are two beats, and setting the first short + // and the second long is the difference between a column that erupts and + // one that is boiled into being. + const appear = saturate(this._sinceArrival() / Math.max(0.02, c.revealTime)); + p.reveal = saturate(Math.min(appear, 1 - sink)); + p.revealSpread = c.revealSpread; + + p.silhouette = Silhouette.BIRD; + p.sweep = c.sweep; + p.edgeGain = c.edgeGain; + p.lit = c.lit; + p.tint = c.tint; + p.tintJitter = c.tintJitter; + p.tintAlong = c.tintAlong; + p.opacity = c.opacity * g.opacity * fade; + p.glow = c.glow * g.glow; + p.softFade = c.softFade; + + // The barrel. Both shapes are COLUMN and the blend is 0: this ability does + // not morph, it surges — see `locusttide` for the transition trick. + p.shapeA = ColonyShape.COLUMN; + p.shapeB = ColonyShape.COLUMN; + p.shapeBlend = 0; + p.condense = saturate(c.condense) * stand; + p.shapeWidth = barrelRadius / COLUMN_UNIT_RADIUS; + p.shapeDepth = barrelRadius / COLUMN_UNIT_RADIUS; + p.shapeHeight = Math.max(0.05, (barrelTop - barrelBottom) * 0.5) / COLUMN_UNIT_HALF; + p.shapeForward = 0; + p.shapeSide = 0; + p.shapeUp = 0; + p.shapeSpin = c.shapeSpin; + p.shapeFill = c.shapeFill; + p.shapeSteps = c.shapeSteps; + p.shapeSlack = c.shapeSlack; + p.shapeRough = c.shapeRough * g.randomness; + + /* THE TRICK. Four numbers, and the funnel is alive. */ + p.waveAmp = c.waveAmp; + p.waveLength = c.waveLength; + p.waveSpeed = c.waveSpeed; + p.waveAlong = c.waveAlong; + p.cling = 0; + p.floorY = 0; + p.crawlHeight = 0; + + this.colony.setBasis(this.origin, this.direction, this.side, this.length); + this.colony.setColors(c.colorWaspA, c.colorWaspB, c.colorWaspC, c.colorWaspD); + this.colony.update(this.age, p); + + /* --- 3 · the carpet, also placed against the profile ------------- */ + const skirt = this.funnel.radiusAt(0); + const q = _crawl; + const carpetRadius = skirt * c.crawlReach; + const crawlers = clamp(Math.round(c.crawlers * lerp(1, c.crawlSwell, sink)), 0, MAX_CRAWLERS); + + q.count = crawlers; + q.leadMode = LeadPath.POINT; + q.leadS = 0; + q.leadRate = 0; + q.endHeight = c.crawlHeight; + q.latticeX = c.crawlLatticeX; + q.latticeY = 1; + q.latticeZ = c.crawlLatticeZ; + q.spacingSide = c.crawlSpacing; + q.spacingUp = c.crawlSpacing; + q.lag = 0; + q.jitter = c.crawlJitter * g.randomness; + q.churn = c.crawlChurn; + q.breathe = 0; + q.breatheRate = c.breatheRate; + q.wander = c.wander * 0.5 * g.turbulence; + q.wanderScale = c.wanderScale * g.noiseFrequency; + q.wanderSpeed = c.wanderSpeed * g.noiseSpeed; + q.gather = c.gather; + + q.size = c.size * c.crawlSize; + q.aspect = c.aspect; + q.sizeJitter = c.sizeJitter * g.randomness; + q.billboard = c.billboard; + q.bank = c.bank; + q.bankMax = c.bankMax; + q.dihedral = c.dihedral; + q.flapRate = c.flapRate; + q.curl = c.wingCurl; + q.edgeStretch = c.edgeStretch; + // The carpet arrives with the mouth, not with the column: these are the + // wasps that never left, and they are what says the hole is a nest. + q.reveal = this._mouthOpen(); + q.revealSpread = c.revealSpread; + + q.silhouette = Silhouette.BIRD; + q.sweep = c.sweep; + q.edgeGain = c.edgeGain; + q.lit = c.lit; + q.tint = c.tint; + q.tintJitter = c.tintJitter; + q.tintAlong = c.tintAlong; + q.opacity = c.crawlOpacity * g.opacity * fade; + q.glow = c.glow * g.glow; + q.softFade = c.softFade; + + // A ball squashed flat is a disc, which is the shape a carpet of insects + // on the ground actually is. `cling` then pins them to the floor exactly, + // so the disc's thickness is slop rather than a hovering slab. + q.shapeA = ColonyShape.BALL; + q.shapeB = ColonyShape.BALL; + q.shapeBlend = 0; + q.condense = saturate(c.crawlCondense); + q.shapeWidth = carpetRadius / BALL_UNIT_RADIUS; + q.shapeDepth = carpetRadius / BALL_UNIT_RADIUS; + q.shapeHeight = c.crawlThickness / BALL_UNIT_RADIUS; + q.shapeForward = 0; + q.shapeSide = 0; + q.shapeUp = 0; + q.shapeSpin = c.crawlChurn * 0.3; + q.shapeFill = 1; + q.shapeSteps = c.shapeSteps; + q.shapeSlack = c.shapeSlack; + q.shapeRough = c.shapeRough * g.randomness; + + // The same wave runs through the carpet, and `cling` flattens it back onto + // the floor afterwards — so the crawlers bunch and thin in step with the + // column above them without ever leaving the ground. + q.waveAmp = c.waveAmp; + q.waveLength = c.waveLength; + q.waveSpeed = c.waveSpeed; + q.waveAlong = c.waveAlong; + q.cling = saturate(c.crawlCling); + q.floorY = 0; + q.crawlHeight = c.crawlHeight; + + this.crawlers.setBasis(this.origin, this.direction, this.side, this.length); + this.crawlers.setColors(c.colorWaspA, c.colorWaspB, c.colorWaspC, c.colorWaspD); + this.crawlers.update(this.age, q); + + this._live = wasps + crawlers; + + /* --- 4 · the mouth, the last consumer of the same radius --------- */ + _pit.centre = _centre; + _pit.yaw = this._yawRoll * TAU; + _pit.height = c.pitHeight; + _pit.radius = Math.max(0.2, skirt * c.pitReach); + _pit.length = 1; + _pit.grow = this._mouthOpen(); + _pit.recede = 0; + _pit.progress = 1; + _pit.fade = fade; + _pit.seed = this._seed; + + _pit.edge = c.pitEdge; + _pit.ragged = c.pitRagged; + _pit.raggedScale = c.pitRaggedScale * g.noiseFrequency; + _pit.warp = c.pitWarp * g.noiseStrength; + + _pit.relief = c.pitRelief; + _pit.normalStep = c.pitNormalStep; + _pit.ambient = c.pitAmbient; + _pit.wrap = c.pitWrap; + _pit.specular = c.pitSpecular; + _pit.gloss = c.pitGloss; + _pit.parallax = c.pitParallax; + + _pit.cell = c.pitCell; + _pit.cellJitter = c.pitCellJitter; + _pit.seam = c.pitSeam; + _pit.thickness = c.pitLipDrop; + _pit.lift = c.pitSpoil; + _pit.depth = c.pitDepth; + _pit.sharp = c.pitSharp; + _pit.detail = c.pitDetail; + // FUNNEL reads none of these; they are zeroed rather than left at their + // defaults so a reader of this file is not left wondering what a spiral + // pitch is doing on a hole in the ground. + _pit.swirl = 0; + _pit.arms = 0; + _pit.speed = 0; + _pit.flow = 0; + _pit.windAngle = 0; + + _pit.additive = false; + _pit.emissive = c.pitEmissive; + _pit.opacity = c.pitOpacity; + _pit.depthFade = c.pitDepthFade; + _pit.colorBase = c.colorPitBase; + _pit.colorEdge = c.colorPitEdge; + _pit.colorGlow = c.colorPitGlow; + _pit.colorDeep = c.colorPitDeep; + + _pit.noiseStrength = g.noiseStrength; + _pit.noiseFrequency = g.noiseFrequency; + _pit.noiseSpeed = g.noiseSpeed; + _pit.opacityScale = g.opacity; + + this.pit.update(_pit); + this.pit.setVisible(fade > 0.001); + + this._syncParticles(c, g); + } + + /** Gradients and scales for the three systems. Live, every frame. */ + _syncParticles(c, g) { + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.moteGlow * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = c.gritLifetime * 0.5 * g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + this.grit.uniforms.uTurbulence.value = 0.3 * g.turbulence; + + this.haze.setGradient( + getColor(c.colorHazeA), + getColor(c.colorHazeB), + getColor(c.colorHazeC), + getColor(c.colorHazeD) + ); + this.haze.uniforms.uGravity.value.set(0, c.hazeRise, 0); + this.haze.uniforms.uSizeScale.value = c.hazeSize * g.particleSize; + this.haze.uniforms.uLifeScale.value = c.hazeLifetime * 0.5 * g.particleLifetime; + this.haze.uniforms.uSpeedScale.value = c.hazeSpeed * g.particleSpeed; + this.haze.uniforms.uOpacity.value = c.hazeOpacity * g.opacity; + this.haze.uniforms.uTurbulence.value = c.hazeTurbulence * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Everything the nest sheds while it is up. + * + * The pollen is **the second consumer of the density wave**: its rate is + * scaled by the surge sampled at the mouth, so a puff leaves the top of the + * funnel each time a crest arrives there and nothing between crests. That is + * one line of arithmetic and it is the difference between "the swarm has a + * pulsing texture" and "the swarm is *pumping*". + * + * @param {number} dt seconds + * @param {number} scale 0..1 — thinned as the column sinks + */ + _nestFx(dt, scale) { + const c = settings.waspfunnel; + const g = settings.global; + const time = frame.uTime.value; + + this._centrePoint(_centre); + this.funnel.pointAt(1, _mouth); + const skirt = this.funnel.radiusAt(0); + const mouthRadius = this.funnel.radiusAt(1); + // 0 in the trough, 2 on the crest, and never negative — a rate is not a + // thing that can run backwards. + const crest = Math.max(0, 1 + saturate(c.surgeGain) * this._surgeAt(_mouth)); + + const moteCount = Math.round( + this.moteEmitter.tick(dt, c.moteRate * scale * crest) * g.particleCount + ); + if (moteCount > 0) { + _emit.position = _mouth; + _emit.radius = mouthRadius; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.08; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + let gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount); + if (gritCount > 0) { + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.75; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = c.gritSpin; + _emit.tint = null; + _emit.time = time; + + // Round the mouth rather than out of the middle of it — see GRIT_BATCHES. + const batches = Math.min(gritCount, GRIT_BATCHES); + const per = Math.ceil(gritCount / batches); + while (gritCount > 0) { + const a = Math.random() * TAU; + // Between the skirt the funnel actually has and the circle the aim + // indicator promised. Both are live; neither is written down here. + const r = randRange(skirt, Math.max(skirt, c.zoneRadius)); + _pos.set(_centre.x + Math.cos(a) * r, 0.06, _centre.z + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = skirt * 0.25; + this.grit.emit(Math.min(per, gritCount), _emit); + gritCount -= per; + } + } + + const hazeCount = Math.round(this.hazeEmitter.tick(dt, c.hazeRate * scale) * g.particleCount); + if (hazeCount > 0) { + _pos.set(_centre.x, 0.15, _centre.z); + _emit.position = _pos; + _emit.radius = Math.max(skirt, c.zoneRadius) * 0.9; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.hazeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.hazeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.time = time; + this.haze.emit(hazeCount, _emit); + } + } + + /** The one-shot as the mouth tears open and the column comes out of it. */ + _openFx() { + const c = settings.waspfunnel; + const g = settings.global; + const time = frame.uTime.value; + + this._centrePoint(_centre); + _pos.copy(_centre); + _pos.y = c.baseHeight + this.funnel.radiusAt(0) * 0.4; + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.openSize * 0.25, + endRadius: c.openSize * g.explosionIntensity, + life: 0.6, + intensity: c.openIntensity, + opacity: 0.9, + fresnel: 1.3, + displace: 0.75, + squash: 0.7, + colorA: getColor(c.colorOpenA), + colorB: getColor(c.colorOpenB), + colorC: getColor(c.colorOpenC) + }); + + this.ctx.decals.spawn(DecalType.DUSTRING, _centre, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.9, + width: 0.07, + intensity: 0.8, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB), + height: 0.02 + }); + + _emit.position = _centre; + _emit.radius = this.funnel.radiusAt(0); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed * 2.2; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.8; + _emit.life = c.gritLifetime * 1.3; + _emit.lifeVariance = 0.6; + _emit.spin = c.gritSpin * 1.4; + _emit.tint = null; + _emit.time = time; + this.grit.emit(Math.round(c.openGrit * g.particleCount), _emit); + + _emit.speed = c.moteSpeed * 2.6; + _emit.spread = 0.9; + _emit.size = 0.08; + _emit.life = c.moteLifetime; + _emit.spin = 0; + this.motes.emit(Math.round(c.openMotes * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.1 * g.explosionIntensity; + } + + /** The one-shot on the frame the column starts to sink. */ + _collapseFx() { + const c = settings.waspfunnel; + const g = settings.global; + + this._centrePoint(_centre); + _emit.position = _centre; + _emit.radius = this.funnel.radiusAt(0) * 1.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed * 1.4; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.8; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = c.gritSpin; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grit.emit(Math.round(c.collapseGrit * g.particleCount), _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._beat = 0; + this._sync(1); + + // The light sits at the nest from the moment the cast leaves: this is a far + // cast, and what the player is watching is the circle, not the throw. + this.funnel.pointAt(saturate(settings.waspfunnel.lightRide), this.position); + + // Only the ground is doing anything while the cast is still out — the mouth + // is chewing itself open and throwing grit, and there is no column yet. + this._nestFx(dt, this._mouthOpen() * 0.5); + } + + onImpact() { + this._sync(1); + this._openFx(); + } + + onFade(dt, t) { + this._beat = t; + // 1 through the stand, easing to 0 through the collapse. Cubic, so the last + // of the colony hangs on and then goes. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._sync(fade); + + if (t > 1 && !this._sank) { + this._sank = true; + this._collapseFx(); + } + + this.funnel.pointAt(saturate(settings.waspfunnel.lightRide), this.position); + + this._nestFx(dt, fade * (t <= 1 ? 1 : 0.4)); + this.ctx.shake.rumble(settings.waspfunnel.rumble * settings.global.cameraShake * fade, dt); + } + + onDestroy() { + this._live = 0; + this._beat = 0; + this._sank = false; + this.colony.reset(); + this.crawlers.reset(); + this.funnel.visible = false; + this.pit.setVisible(false); + } + + dispose() { + this.colony.dispose(); + this.crawlers.dispose(); + this.funnel.dispose(); + this.pit.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/hive/WeblineAbility.js b/src/abilities/hive/WeblineAbility.js new file mode 100644 index 0000000..b4dfd70 --- /dev/null +++ b/src/abilities/hive/WeblineAbility.js @@ -0,0 +1,889 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { WebGraph, webGraphParams } from '../../vfx/Colony.js'; +import { FilamentPaths, filamentLook, MAX_FILAMENT_ROLES } from '../../vfx/FilamentPaths.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, clamp, Easing } from '../../utils/math.js'; + +/** + * Guy-lines the web can hang from. One `FilamentPaths` role each, because a + * role carries one pair of anchors and these four go to four different places + * on the floor. Four is also the ceiling on roles, which is the reason the + * slider stops there rather than at some number chosen for taste. + */ +const MAX_GUYS = MAX_FILAMENT_ROLES; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _web = webGraphParams(); +const _look = filamentLook(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hub = new Vector3(); +const _node = new Vector3(); +const _anchor = new Vector3(); +const _normal = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/** + * WEB LINE — a drag-line thrown down the lane, and an orb web spun across it. + * + * **THE TRICK — the membrane.** A web is not strands. It is strands *plus the + * thin film between them*, and that film is the whole ability: it is invisible + * head-on, because a film a fraction of a micron thick transmits almost + * everything at normal incidence, and it flares at grazing angles where the + * path through it lengthens and the Fresnel reflectance climbs. So the alpha on + * a panel here has **no constant part at all** — it is a pure grazing term — + * and its colour walks a four-stop gradient with the same angle, which is what + * thin-film interference does with path length. Orbit the caster and panels of + * the web appear and vanish one at a time. Stand square-on and the web is + * thread and nothing else. + * + * The first version of this slot had `filmFill` at 1 with a small ambient floor + * on the alpha, on the reasonable-sounding grounds that a film you cannot see + * at all is a film nobody authored. It was a frosted disc. Every panel was lit + * at once, so nothing flared against anything, and the effect stopped being a + * web and started being a lampshade. `filmFill` now defaults to 0.76 and the + * ambient floor is gone; the holes are what say the thing was built by + * something and then walked through. + * + * **Sag is a fraction of a strand's own span**, not a fixed drop — a short + * chord near the hub hangs almost straight while a two-metre outer chord + * bellies, and that gradient is what the eye reads as tension. An absolute sag + * in metres made the inner rings look slack and the whole disc read as + * knitting. + * + * Four beats: + * + * 1. **pay out** — a drag-line of silk motes runs from the caster's hand to + * the hub point, at `speed`. + * 2. **bite** — the line catches. Four guy-lines snap out to floor anchors as + * real catenaries (`vfx/FilamentPaths.js`, `LINK`), and the disc begins to + * spin: radials from the hub outward first, then the spiral from the rim + * inward, which is the order the animal works in. Film fills in behind the + * threads that bound it. + * 3. **hang** — the web shivers from the catch (`snapSway`, decaying on + * `snapDecay`) and then breathes on `sway`. The shiver is worth the + * slider: it changes the grazing angle across the *whole* membrane at + * once, so the panels flash in sequence as the wave crosses them. + * 4. **tear** — the film goes first and from the rim inward (`tearBias`), the + * threads go slack (`tearSlack`), the guys let go (`guyTaut` → 0) and the + * whole disc sinks by `tearDroop` on its way out. + * + * **The rule that makes the editor work.** A node in this web is the pair of + * integers `(ring, spoke)` and nothing else — ring −1 is the hub, and it needs + * no special case because every per-node jitter is multiplied by the radius + * fraction, which is zero there. Every metre is a uniform. A cast captures one + * number, `_seed`, plus the timestamps of the beats it has already fired. Pause + * with **P** mid-spin and drag `radius`: the whole web re-spans, film included, + * because the film is a Coons patch over the same four strand curves the strand + * mesh draws and the two meshes hold the same uniform boxes by identity. + * + * Four draw calls: the strands, the film, and the guys' halo and core. + */ +export class WeblineAbility extends Ability { + constructor(context) { + super('webline', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The web: two draw calls, the strands and the membrane. + * + * `samples: 12` rather than the default ten because the sag on an outer + * chord at this radius is a genuine curve and ten segments show the chords + * on it; `filmSubdiv: 2` because a Coons patch drawn as two flat triangles + * cuts straight across its own belly and loses exactly the dish the grazing + * term is looking for. + */ + this.web = new WebGraph(this.group, { + maxRings: 8, + maxSpokes: 16, + samples: 12, + filmSubdiv: 2, + renderOrder: 11 + }); + + /** + * The guy-lines. `LINK` is a real catenary rather than a parabola, and the + * difference is all at the anchors, where a hanging thread leaves much + * steeper — which is most of what says the far end is *pinned* rather than + * merely ending there. + */ + this.anchors = new FilamentPaths(this.group, { + samples: 48, + capacity: MAX_GUYS * 4, + renderOrder: 12 + }); + + /* --- what a cast captures: one dice roll and some timestamps --- */ + /** Decorrelates the node jitter, so two webs are not the same web. */ + this._seed = 0; + /** One-way. A slider drag must not be able to re-fire a beat. */ + this._caught = false; + this._torn = false; + /** Metres of drag-line travel already paid out in motes. */ + this._dragDistance = 0; + /** HUD readout only. */ + this._live = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Silk: the drag-line on the way out, then loose fibre lifting off the + // finished web. Additive and soft, because what you are seeing is thread + // too fine to resolve catching the key light rather than anything glowing. + this.silk = particles.get('webline.silk', { + capacity: 2200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.silk.uniforms.uDrag.value = 1.7; + this.silk.uniforms.uEndSize.value = 0.2; + this.silk.uniforms.uSizeIn.value = 0.07; + this.silk.uniforms.uFadeIn.value = 0.1; + this.silk.uniforms.uFadeOut.value = 0.42; + + // Dust the web has already caught, shaken out of it. Non-additive so it + // genuinely occludes the film behind it — dust that adds light reads as + // more web, which is the opposite of the point. + this.dust = particles.get('webline.dust', { + capacity: 1400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.dust.uniforms.uDrag.value = 2.1; + this.dust.uniforms.uEndSize.value = 2.4; + this.dust.uniforms.uSizeIn.value = 0.16; + this.dust.uniforms.uFadeIn.value = 0.22; + this.dust.uniforms.uFadeOut.value = 0.3; + + // Torn fibre. `LEAF` rather than `CHIP`: a broken thread curls, and the + // tapered silhouette tumbling under a light gravity is the read. Lit, not + // additive — the tear is the one moment the web stops being luminous. + this.chaff = particles.get('webline.chaff', { + capacity: 1200, + shape: ParticleShape.LEAF, + additive: false, + lit: true, + softFade: 0.3 + }); + this.chaff.uniforms.uDrag.value = 1.3; + this.chaff.uniforms.uEndSize.value = 0.7; + this.chaff.uniforms.uFadeIn.value = 0.06; + this.chaff.uniforms.uFadeOut.value = 0.5; + + this.silkEmitter = new RateEmitter(); + this.dustEmitter = new RateEmitter(); + this.chaffEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — every beat resolved from settings, every frame */ + /* ------------------------------------------------------------------ */ + + /** A beat's length in seconds, under the global lifetime knob. */ + _span(seconds) { + return Math.max(0.01, seconds * settings.global.lifetime); + } + + /** The impact phase holds the spin and the hang. Re-derived, never stored. */ + get impactDuration() { + const c = settings.webline; + return this._span(c.spinTime) + this._span(c.holdTime); + } + + get fadeDuration() { + return Math.max(0.05, settings.webline.fadeTime); + } + + get instanceCount() { + return this._live; + } + + /** + * Silk breathes; it does not gutter. + * + * The bolt's quantised stutter was the first thing tried here and it made the + * web look electrified, which is a different school entirely. A slow cosine + * reads as the light moving across a surface that is barely there. + */ + lightShimmer() { + const c = settings.webline; + return 1 - saturate(c.lightPulse) * 0.5 * (1 - Math.cos(this.age * c.lightPulseSpeed)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the drag-line leaves the caster, in world space. */ + _handPoint(out) { + const c = settings.webline; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** + * The hub, in world space. + * + * Held `hubShort` metres this side of the aim point so the web stands *in* + * the lane rather than on top of whatever was aimed at — a web flush with its + * target reads as a decal on it. + */ + _hubPoint(out) { + const c = settings.webline; + this.pointAt(1, out).addScaledVector(this.direction, -c.hubShort); + out.y = c.hubHeight; + return out; + } + + /** A point on the drag-line at `s` along it, 0..1. */ + _dragPoint(s, out) { + const c = settings.webline; + const t = saturate(s); + out + .copy(this.origin) + .addScaledVector(this.direction, lerp(c.handForward, this.length - c.hubShort, t)) + .addScaledVector(this.side, c.handSide * (1 - t)); + out.y = lerp(c.handHeight, c.hubHeight, t); + return out; + } + + /** + * The web's plane normal: the cast direction, tipped back by `lean`. + * + * A web hung exactly upright is the one orientation where the membrane is + * least interesting, because the caster's own camera sits near its axis and + * the grazing term is at its weakest everywhere at once. Tipping it a fifth + * of a radian costs nothing and puts a band of flaring panels across the disc + * from the first frame. + */ + _webNormal(out) { + const c = settings.webline; + return out.copy(this.direction).multiplyScalar(Math.cos(c.lean)).setY(Math.sin(c.lean)); + } + + /** Seconds since the drag-line bit. Zero while it is still in the air. */ + _sinceCatch() { + return this.phase === AbilityPhase.TRAVEL ? 0 : this.impactTime + this.fadeTime; + } + + /** 0..1 — how much of the web has been spun. */ + _grow() { + return saturate(this._sinceCatch() / this._span(settings.webline.spinTime)); + } + + /** + * 0..1 — the shiver envelope after the catch. + * + * A decaying exponential of a *timestamp*, with the rate resolved from + * settings, so dragging `snapDecay` on a paused frame re-shapes a shiver that + * is already happening rather than leaving the old one running. + */ + _shiver() { + const c = settings.webline; + if (!this._caught) return 0; + return Math.exp(-this._sinceCatch() * Math.max(0.01, c.snapDecay)); + } + + /** Live rings and spokes, clamped exactly as `WebGraph` clamps them. */ + _ringCount() { + return clamp(Math.round(settings.webline.rings), 1, this.web.maxRings); + } + + _spokeCount() { + return clamp(Math.round(settings.webline.spokes), 3, this.web.maxSpokes); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.silkEmitter.reset(); + this.dustEmitter.reset(); + this.chaffEmitter.reset(); + + this._caught = false; + this._torn = false; + this._dragDistance = 0; + + // The only thing a cast captures. Unitless, and it only feeds hashes. + this._seed = Math.random() * 100; + this.web.roll(this._seed); + + this._sync(1, 0); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Resolve the web, its guys and the three particle systems from live + * settings. + * + * Everything with a unit is read here, every frame, on a zero-length frame + * included — and the web is synced whether or not it is drawn yet, because + * syncing only what is visible is the tempting optimisation that breaks the + * paused editor: drag `radius` while the web is still being carried out and + * it must be that radius when it arrives. + * + * @param {number} fade 1 while the web hangs, ramping to 0 as it comes down + * @param {number} tear 0..1 how far through letting go it is + */ + _sync(fade, tear) { + const c = settings.webline; + const g = settings.global; + const p = _web; + + const grow = this._grow(); + const shiver = this._shiver(); + // The film is gone by `tearFilm` of the way through the tear, well before + // the threads are: a real web loses its sheets first and hangs on as a + // frame, and reversing that order makes the whole thing read as a curtain. + const filmGone = saturate(tear / Math.max(0.01, c.tearFilm)); + + this._hubPoint(_hub); + this._webNormal(_normal); + + /* ---------------- the graph ---------------- */ + p.rings = c.rings; + p.spokes = c.spokes; + p.radius = c.radius; + p.squash = c.squash; + p.ringCurve = c.ringCurve; + p.ringJitter = c.ringJitter * g.randomness; + p.spokeJitter = c.spokeJitter * g.randomness; + p.twist = c.twist; + // The disc sinks as it lets go. Added here rather than by moving the hub, + // so the rim comes down and the hub does not — which is what a web tearing + // off its anchors actually does. + p.droop = c.droop + c.tearDroop * tear; + p.depth = c.depth; + + /* ---------------- the strands ---------------- */ + p.slack = c.slack + c.tearSlack * tear; + // One number carries both the breeze and the catch: the shiver is a wider, + // faster version of the same standing wave, so the transition between them + // is a lerp rather than a hand-off and nothing pops at the crossover. + p.sway = (c.sway + c.snapSway * shiver) * g.turbulence; + p.swayRate = lerp(c.swayRate, c.snapRate, shiver); + p.strandWidth = c.strandWidth; + p.widthJitter = c.widthJitter * g.randomness; + p.chordWidth = c.chordWidth; + p.strandOpacity = c.strandOpacity * fade * g.opacity; + p.strandGlow = c.strandGlow * g.glow; + p.silkPower = c.silkPower; + p.silkGain = c.silkGain; + p.coreBias = c.coreBias; + p.strandColor = c.colorStrand; + p.silkColor = c.colorSilk; + + /* ---------------- the membrane ---------------- */ + p.dish = c.dish; + p.grazePower = c.grazePower; + p.filmOpacity = c.filmOpacity * fade * g.opacity; + p.filmBands = c.filmBands; + p.filmShift = c.filmShift; + p.filmSheen = c.filmSheen; + p.filmSheenPower = c.filmSheenPower; + p.filmFill = c.filmFill * (1 - filmGone); + p.tearBias = c.tearBias; + // The shiver flexes the whole sheet at once, so it flashes at once. + p.filmGlow = c.filmGlow * g.glow * (1 + c.snapFlash * shiver); + p.filmA = c.colorFilmA; + p.filmB = c.colorFilmB; + p.filmC = c.colorFilmC; + p.filmD = c.colorFilmD; + + /* ---------------- the spinning ---------------- */ + p.grow = grow; + p.growFeather = c.growFeather; + p.orderScatter = c.orderScatter; + + this.web.setPlacement(_hub, _normal, _up); + // The clock argument is ignored by design: the breeze runs on the shared + // `uTime`, so a web four seconds old does not restart its sway on a re-cast. + this.web.update(this.age, p); + + this._syncGuys(fade, tear, grow); + this._syncParticles(); + + this._live = this.web.count + this.anchors.liveCount; + } + + /** + * The guy-lines. + * + * One role per guy, because a role owns one pair of anchors and these go to + * four different places. The **from** end is a real rim node read back out of + * the web — not a point computed alongside it — so a guy stays attached + * through every slider that moves the rim. The **to** end is that node's own + * horizontal offset from the hub, pushed `guyReach` further out and dropped + * to the floor, with alternate guys splayed fore and aft: unitless index + * arithmetic on one side, live metres on the other. + * + * The first version anchored every guy at a bearing computed from `i / guys` + * and a radius of its own. It looked almost right and was wrong in the way + * that survives review: the anchors did not move when `squash` or `twist` + * did, so the guys crossed their own rim at anything but the default. + */ + _syncGuys(fade, tear, grow) { + const c = settings.webline; + const g = settings.global; + + const count = clamp(Math.round(c.guys), 0, MAX_GUYS); + const threads = Math.max(1, Math.round(c.guyThreads)); + const rim = this._ringCount() - 1; + const spokes = this._spokeCount(); + // The guys are the frame threads and a frame goes up first, so they are + // drawn over the leading `guyLead` of the spin rather than alongside it. + const drawn = saturate(grow / Math.max(0.05, c.guyLead)); + + for (let i = 0; i < MAX_GUYS; i++) { + const role = this.anchors.role(i); + if (i >= count) { + role.retire(); + continue; + } + + const spoke = Math.round((spokes * (i + 0.5)) / count) % spokes; + this.web.nodePoint(rim, spoke, _web, _node); + + // Where it is pinned. The node's own horizontal reach from the hub is + // what decides how far out the anchor goes, so a squashed web pins its + // anchors closer in — which is the behaviour anybody dragging `squash` + // expects, and the reason this is not a bearing and a radius. + _anchor.set(_node.x - _hub.x, 0, _node.z - _hub.z); + _anchor.multiplyScalar(1 + c.guyReach).add(_node); + // Alternate guys go fore and aft. `sign` is index parity — unitless. + const sign = i % 2 === 0 ? 1 : -1; + _anchor.addScaledVector(this.direction, c.guySplay * sign); + _anchor.y = c.guyFloor; + + role.link( + _node, + _anchor, + c.guySlack, + c.guyCurve, + c.guySwing * g.turbulence, + c.guySwingSpeed, + // Taut once the web is up, slack again as it lets go. + c.guyTaut * grow * (1 - tear), + c.guySpread + ); + // Tapered at the rim end and square at the floor: a thread arriving at an + // anchor does not fade out, it stops. + role.style(1, 1, c.guyDim, 1).ends(1, 0, 1, 0).draw(drawn, 0.1, c.guyFloor, 0); + role.count = threads; + } + + _look.width = c.guyWidth; + _look.glowWidth = c.guyGlowWidth; + _look.glowOpacity = c.guyGlowOpacity; + _look.jitter = c.guyJitter; + _look.jitterScale = c.guyJitterScale; + _look.octaves = c.guyOctaves; + _look.jitterFalloff = c.guyJitterFalloff; + _look.crawl = c.guyCrawl; + _look.pinch = c.guyPinch; + _look.restrike = c.guyRestrike; + _look.flicker = c.guyFlicker; + _look.flickerSpeed = c.guyFlickerSpeed; + _look.strandFlash = c.guyStrandFlash; + _look.coreSharp = c.guyCoreSharp; + _look.glowFalloff = c.guyGlowFalloff; + _look.softFade = c.guySoftFade; + _look.opacity = c.guyOpacity; + _look.glow = c.guyGlow; + _look.colorCore = c.colorGuyCore; + _look.colorInner = c.colorGuyInner; + _look.colorOuter = c.colorGuyOuter; + _look.colorHalo = c.colorGuyHalo; + _look.randomness = g.randomness; + _look.noiseStrength = g.noiseStrength; + _look.noiseFrequency = g.noiseFrequency; + _look.noiseSpeed = g.noiseSpeed; + _look.opacityScale = g.opacity; + _look.glowScale = g.glow; + this.anchors.sync(_look, fade, this._seed); + this.anchors.visible = count > 0 && drawn > 0.002 && fade > 0.002; + } + + /** The three particle systems, re-coloured and re-scaled every frame. */ + _syncParticles() { + const c = settings.webline; + const g = settings.global; + + this.silk.setGradient( + getColor(c.colorSilkA), + getColor(c.colorSilkB), + getColor(c.colorSilkC), + getColor(c.colorSilkD) + ); + this.silk.uniforms.uGravity.value.set(0, c.silkRise, 0); + this.silk.uniforms.uSizeScale.value = c.silkSize * g.particleSize * 7; + this.silk.uniforms.uLifeScale.value = c.silkLifetime * 0.5 * g.particleLifetime; + this.silk.uniforms.uSpeedScale.value = g.particleSpeed; + this.silk.uniforms.uOpacity.value = g.opacity; + this.silk.uniforms.uGlow.value = 0.85 * g.glow; + this.silk.uniforms.uTurbulence.value = c.silkTurbulence * g.turbulence; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.35 * g.turbulence; + + this.chaff.setGradient( + getColor(c.colorChaffA), + getColor(c.colorChaffB), + getColor(c.colorChaffC), + getColor(c.colorChaffD) + ); + this.chaff.uniforms.uGravity.value.set(0, c.chaffGravity, 0); + this.chaff.uniforms.uSizeScale.value = c.chaffSize * g.particleSize * 7; + this.chaff.uniforms.uLifeScale.value = c.chaffLifetime * 0.5 * g.particleLifetime; + this.chaff.uniforms.uSpeedScale.value = g.particleSpeed; + this.chaff.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** A small puff of silk at the hand as the line leaves it. */ + _castFx() { + const c = settings.webline; + const g = settings.global; + + this._handPoint(_pos); + + _emit.position = _pos; + _emit.radius = 0.12; + _emit.direction = _dir.copy(this.direction); + _emit.speed = c.silkSpeed * 4; + _emit.speedVariance = 0.7; + _emit.spread = 0.5; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.6; + _emit.life = c.silkLifetime * 0.6; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.silk.emit(Math.round(18 * g.particleCount), _emit); + + this.lightBoost = c.lightIntensity * 0.25 * g.explosionIntensity; + } + + /** + * Motes paid out per *metre* of drag-line travel, so the line arrives at the + * same density whether the web was thrown at five metres or at twenty. Keyed + * off distance rather than off time for exactly that reason. + */ + _dragFx() { + const c = settings.webline; + const step = 1 / Math.max(0.05, c.dragRate); + const time = frame.uTime.value; + + while (this.front - this._dragDistance >= step) { + this._dragDistance += step; + this._dragPoint(saturate(this._dragDistance / this.length), _pos); + + _emit.position = _pos; + _emit.radius = 0.06; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.silkSpeed * 0.4; + _emit.speedVariance = 0.6; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.05; + _emit.sizeVariance = 0.6; + _emit.life = c.silkLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.silk.emit(2, _emit); + } + } + + /** + * What the standing web sheds. + * + * Every emitter picks a **real node** of the graph to fire from rather than a + * point on a disc of the right radius. It costs one call and it means the + * silk comes off the threads and the dust falls out of the panels, which + * survives every slider that reshapes the web — including the ones that make + * it an ellipse. + * + * @param {number} dt + * @param {number} scale 0..1 — thinned as the web comes down + * @param {number} tear 0..1 — no fibre comes off a web that is not tearing + */ + _webFx(dt, scale, tear) { + const c = settings.webline; + const g = settings.global; + const time = frame.uTime.value; + const rings = this._ringCount(); + const spokes = this._spokeCount(); + + const silkCount = Math.round(this.silkEmitter.tick(dt, c.silkRate * scale) * g.particleCount); + if (silkCount > 0) { + this.web.nodePoint( + Math.floor(Math.random() * rings), + Math.floor(Math.random() * spokes), + _web, + _pos + ); + _emit.position = _pos; + _emit.radius = c.radius * 0.12; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.silkSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.05; + _emit.sizeVariance = 0.6; + _emit.life = c.silkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.silk.emit(silkCount, _emit); + } + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (dustCount > 0) { + this.web.nodePoint( + Math.floor(Math.random() * rings), + Math.floor(Math.random() * spokes), + _web, + _pos + ); + _emit.position = _pos; + _emit.radius = c.radius * 0.25; + // Down and slightly downrange: this is dirt letting go of a thread, not + // smoke, and it has to fall or it reads as a haze round the web. + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.2).setY(-1).normalize(); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.8; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.25; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + const chaffCount = Math.round( + this.chaffEmitter.tick(dt, c.chaffRate * tear) * g.particleCount + ); + if (chaffCount > 0) { + this.web.nodePoint( + Math.floor(Math.random() * rings), + Math.floor(Math.random() * spokes), + _web, + _pos + ); + _emit.position = _pos; + _emit.radius = c.radius * 0.3; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chaffSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.chaffLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 5; + _emit.time = time; + this.chaff.emit(chaffCount, _emit); + } + } + + /** The line bites: a puff of air, dust off the floor, and the shiver. */ + _catchFx() { + const c = settings.webline; + const g = settings.global; + const time = frame.uTime.value; + + this._hubPoint(_hub); + + this.ctx.bursts.spawn(BurstMode.AIR, _hub, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.45, + intensity: c.burstIntensity, + opacity: 0.55, + fresnel: 1.8, + displace: 0.35, + squash: 0.85, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + // On the floor under the hub, where the guys are about to be pinned. + _pos.set(_hub.x, 0, _hub.z); + this.ctx.decals.spawn(DecalType.DUSTRING, _pos, { + radius: c.dustRingRadius * g.explosionIntensity, + life: c.dustRingLife, + intensity: c.dustRingIntensity, + colorA: getColor(c.colorDustRingA), + colorB: getColor(c.colorDustRingB) + }); + + _emit.position = _hub; + _emit.radius = 0.25; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.3).setY(0.6).normalize(); + _emit.speed = c.silkSpeed * 3; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.08; + _emit.sizeVariance = 0.7; + _emit.life = c.silkLifetime * 1.2; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.silk.emit(Math.round(30 * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorCatchFlash), c.catchFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.7 * g.explosionIntensity; + } + + /** The web lets go: one throw of fibre off the whole disc at once. */ + _tearFx() { + const c = settings.webline; + const g = settings.global; + const time = frame.uTime.value; + + this._hubPoint(_hub); + + _emit.position = _hub; + _emit.radius = c.radius * 0.75; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.chaffSpeed * 1.4; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.8; + _emit.life = c.chaffLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 7; + _emit.tint = null; + _emit.time = time; + this.chaff.emit(Math.round(c.chaffTear * g.particleCount), _emit); + + _emit.radius = c.radius * 0.6; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.dustSpeed * 2.2; + _emit.spread = 0.9; + _emit.size = 0.9; + _emit.life = c.dustLifetime; + _emit.spin = 0.4; + this.dust.emit(Math.round(28 * g.particleCount), _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1, 0); + + // The light rides the drag-line out to the hub. + this._dragPoint(this.u, this.position); + + this._dragFx(); + this.ctx.shake.rumble(settings.webline.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + this._caught = true; + this._catchFx(); + this._hubPoint(this.position); + } + + onFade(dt, t) { + // `t` runs 0..1 through the spin and the hang, then 1..2 as it comes down. + const tear = saturate(t - 1); + // Cubic, so the last threads hang on and then go rather than dimming evenly. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(tear); + + if (!this._torn && tear > 0) { + this._torn = true; + this._tearFx(); + } + + this._sync(fade, tear); + + // The light sits at the hub and comes down with the disc. + this._hubPoint(this.position); + this.position.y -= settings.webline.tearDroop * tear * 0.5; + + this._webFx(dt, fade, tear); + } + + onDestroy() { + this._caught = false; + this._torn = false; + this._dragDistance = 0; + this._live = 0; + this.web.reset(); + this.anchors.clear(); + this.anchors.visible = false; + } + + dispose() { + this.web.dispose(); + this.anchors.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/ink/InkbloomAbility.js b/src/abilities/ink/InkbloomAbility.js new file mode 100644 index 0000000..b2d13af --- /dev/null +++ b/src/abilities/ink/InkbloomAbility.js @@ -0,0 +1,662 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { InkDiffusion, InkMode, inkDiffusionParams } from '../../vfx/InkDiffusion.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _wash = inkDiffusionParams(); +const _bloom = inkDiffusionParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _nucleus = new Vector3(); +const _centre = new Vector3(); + +/** + * INKBLOOM — a bead of ink dropped into standing water. + * + * ## The trick: a fingering instability, not a mask over a disc + * + * The obvious way to draw this is to grow a circle and cut a wobbly silhouette + * out of it. That is wrong in a way you can see from across the room: a masked + * disc is **the same shape at every size**, so what you are watching is a + * picture being scaled rather than a front being pushed. Real ink opens because + * the interface between a thin fluid and a thick one is *unstable* — a bulge + * sees a steeper local gradient, moves faster, and becomes a finger — and, + * crucially, because the coarse modes are **physically inadmissible until the + * blob has grown into them**. A lobe two metres across cannot exist on a blob + * that is not yet two metres across. So a young bloom is a small crinkled disc + * and a mature one is a handful of enormous branching lobes with that same + * crinkle still riding on their tips. + * + * `vfx/InkDiffusion.js` owns that mechanism — five octaves admitted by front + * radius, each then growing on the linear instability's exponential and + * saturating at its own wavelength — and this ability's whole job is to aim it. + * There is no silhouette anywhere in this file. **One seed per cast** goes into + * `roll()` and the pattern that comes out has never existed before and will not + * again; the ability itself does not know what shape is on the floor and cannot + * ask. + * + * ## Two fronts, because ink in water is two substances + * + * The first version drew one field and it read as a stain rather than as + * something happening in a liquid. What was missing is the part of the + * phenomenon that has nothing to do with the instability: **the solvent runs + * ahead of the pigment.** Paper chromatography is exactly this effect and it is + * why a watercolour has a pale wet halo outside its ink with a hard deposition + * line at the outside of *that*. + * + * So there are two `InkDiffusion` fields on one clock: + * + * - the **wash**, `InkMode.WASH` — the water. Stable by construction: the + * instability term is not compiled into that mode at all. It spreads faster + * (`washSpread` ≫ `bloomSpread`), has a deliberately wide interface, and + * carries a strong ring at its edge. That ring is the cauliflower line. + * - the **bloom**, `InkMode.BLOOM` — the pigment. It starts `pigmentDelay` + * later, spreads sub-Fickian because pigment drags on the fibre, breaks into + * four nuclei on its way in, and fingers. + * + * Drag `washSpread` below `bloomSpread` with the clock stopped and the whole + * thing collapses into a decal with a halo painted round it. The *ordering* of + * the two fronts is doing more work here than any amount of noise. + * + * ## Wet in front, dry behind, with no history + * + * A fragment's ink arrived when the front radius equalled its distance, and + * `r = spread · t^power` inverts in closed form — so the film's age is known + * exactly everywhere, the gloss is `exp(-age / dryTime)`, and the wet leading + * edge dries backwards into a matte body with no buffer and no per-fragment + * state. Which means dragging `bloomDryTime` on a paused bloom re-dries the + * whole mark at once, including the parts that are already matte. + * + * ## The school with no bloom in it + * + * No flash, no burst shell, no additive particle, no decal, and the two fields + * hard-clamp their own luminance below `post.bloomThreshold`. The single + * specular term in the ability is the sheen on the wet edge, and it is inside + * that clamp: a reflection that cannot exceed the bloom threshold is an + * observation about a wet surface rather than an emission. Both particle + * systems are `additive: false` — pigment suspended in water does not emit, and + * the moment one of them adds light the slot becomes a purple explosion. + * + * ## What a cast captures + * + * One seed and one timestamp — the moment the bead broke the surface. Not one + * metre, radian or second. The pool radius, both spread laws, the finger + * wavelength, the nucleus stagger, the drying times and every colour are + * resolved against `settings.inkbloom` inside the update loop, on a zero-length + * frame included. + * + * **Two draw calls** for the mark — one quad each — plus two shared particle + * systems. + */ +export class InkbloomAbility extends Ability { + constructor(context) { + super('inkbloom', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The solvent. One nucleus, because the water goes in where the bead did + * and nowhere else — the *pigment* is the thing that breaks up. + * + * `satellites: 1` is the module's floor rather than a choice: the satellite + * loop is behind `#if INK_MODE == 1` and does not exist in this program at + * all, so the array is one slot of dead uniform and nothing else. + */ + this.wash = new InkDiffusion(this.group, { + mode: InkMode.WASH, + sources: 1, + satellites: 1, + layer: LAYER.VFX, + // Under the pigment, and lower in the world by `washHeight` against + // `bloomHeight` as well. Render order alone is not enough: these are two + // depth-tested quads and a millimetre of real separation is what stops + // them stitching when the camera drops to floor level. + renderOrder: 5, + name: 'inkbloom.wash' + }); + + /** + * The pigment. Four nuclei — a bead that hits water does not stay one bead, + * and a single nucleus gives a symmetric flower that reads as a stamp. + */ + this.pigment = new InkDiffusion(this.group, { + mode: InkMode.BLOOM, + sources: 4, + satellites: 1, + layer: LAYER.VFX, + renderOrder: 6, + name: 'inkbloom.pigment' + }); + + /* --- what a cast captures: one seed and one timestamp --- */ + /** Unitless. Decorrelates the fingers, the nuclei and the granulation. */ + this._seed = 0; + /** True once the bead has broken the surface. One-way. */ + this._landed = false; + /** The moment it did, on the cast's own clock. An event, not a duration. */ + this._landAt = 0; + /** Metres of bead travel already paid out in grains. */ + this._beadDistance = 0; + /** HUD readout only. */ + this._live = 2; + } + + createParticles() { + const particles = this.ctx.particles; + + /* + * Pigment grains riding the fingering front, and the same substance + * trailing off the bead on the way out. + * + * `additive: false` is the whole school in one flag. The first pass had + * these additive out of habit — every other particle system in the sandbox + * is — and a hundred glowing violet dots over a matte mark looked like a + * mana effect. Non-additive dark grains over a dark mark are nearly + * invisible individually, which is correct: what you see is the *texture* + * they give the edge. + */ + this.grains = particles.get('inkbloom.grains', { + capacity: 1400, + shape: ParticleShape.SOFT, + additive: false, + curl: true, + softFade: 0.3 + }); + this.grains.uniforms.uDrag.value = 2.4; // water, not air + this.grains.uniforms.uEndSize.value = 0.55; + this.grains.uniforms.uSizeIn.value = 0.08; + this.grains.uniforms.uFadeIn.value = 0.12; + this.grains.uniforms.uFadeOut.value = 0.5; + + // The slow tendril of colour standing off the drop. Non-additive so it + // genuinely occludes the mark under it — a veil that adds light is steam. + this.veil = particles.get('inkbloom.veil', { + capacity: 700, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.veil.uniforms.uDrag.value = 2.1; + this.veil.uniforms.uEndSize.value = 2.4; + this.veil.uniforms.uSizeIn.value = 0.2; + this.veil.uniforms.uFadeIn.value = 0.25; + this.veil.uniforms.uFadeOut.value = 0.4; + + this.grainEmitter = new RateEmitter(); + this.veilEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** + * A beat's length in seconds, under the global lifetime knob. + * + * `pigmentDelay` goes through here too, which is not obvious and is the + * point: a slow-motion bloom whose pigment still catches its water up in the + * same sixth of a second is a bloom whose two fronts have stopped being two. + */ + _span(seconds) { + return Math.max(0.005, seconds * settings.global.lifetime); + } + + get impactDuration() { + return this._span(settings.inkbloom.holdTime); + } + + get fadeDuration() { + return Math.max(0.05, settings.inkbloom.fadeTime); + } + + get instanceCount() { + return this._live; + } + + /** + * A pool swells; it does not gutter. + * + * The base class's default shimmer is a two-frequency beat meant for a + * crystal glinting. On something this dim it reads as a fault in the light + * rather than as anything on the floor, so this is one slow cosine and + * nothing else. + */ + lightShimmer() { + const c = settings.inkbloom; + return 1 - saturate(c.lightPulse) * 0.5 * (1 - Math.cos(this.age * c.lightPulseSpeed)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The live footprint, metres. What the aim circle measured out. */ + get radius() { + return Math.max(0.2, settings.inkbloom.zoneRadius); + } + + /** Where the pool is: the far end of the aimed line, on the floor. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** + * A point on the bead's flight at `s` along it, 0..1. + * + * It drops from the hand to just above the water, so the grains it sheds fall + * into the pool rather than being dusted in from head height. + */ + _beadPoint(s, out) { + const c = settings.inkbloom; + const t = saturate(s); + out + .copy(this.origin) + .addScaledVector(this.direction, lerp(c.handForward, this.length, t)) + .addScaledVector(this.side, c.handSide * (1 - t)); + out.y = lerp(c.handHeight, c.dropLift, t); + return out; + } + + /** Seconds since the bead broke the surface. The wash's whole clock. */ + _soak() { + return this._landed ? Math.max(0, this.age - this._landAt) : 0; + } + + /** Seconds the pigment has been spreading. Negative before it starts. */ + _pigmentAge() { + return this._soak() - this._span(settings.inkbloom.pigmentDelay); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.grainEmitter.reset(); + this.veilEmitter.reset(); + + this._landed = false; + this._landAt = 0; + this._beadDistance = 0; + + /* + * The one thing a cast captures, and it is unitless. + * + * Both fields are rolled from it, because the water and the ink went into + * the same pool in the same place — decorrelating their granulation phases + * put two different papers on top of each other and the wash stopped + * looking like the thing the pigment was spreading *through*. + */ + this._seed = Math.random() * 100; + this.wash.roll(this._seed); + this.pigment.roll(this._seed); + + this._sync(0); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into both fields and both particle systems. + * + * @param {number} fade 1 while the bloom stands, ramping to 0 as it soaks away + */ + _sync(fade) { + const c = settings.inkbloom; + const g = settings.global; + + this._centrePoint(_centre); + const radius = this.radius; + const soak = this._soak(); + + /* ---------------- the wash: the solvent ---------------- */ + const w = _wash; + w.radius = radius; + w.height = c.washHeight; + + w.spread = c.washSpread; + w.spreadPower = c.washSpreadPower; + w.edge = c.washEdge; + w.clipSoft = c.washClipSoft; + // One nucleus. The scatter and the stagger are the pigment's business. + w.sources = 1; + w.sourceScatter = 0; + w.sourceDelay = 0; + + // Not read in WASH — the instability is `#if`'d out of that program — but + // written anyway, because a params object filled in only where the current + // mode happens to look is a params object that breaks the day somebody + // changes the mode. + w.finger = 0; + w.fingerMax = 0; + + w.core = c.washCore; + w.falloff = c.washFalloff; + w.film = c.washFilm; + w.granulation = c.washGranulation; + w.granScale = c.washGranScale; + w.ring = c.washRing; + w.ringWidth = c.washRingWidth; + + w.dryTime = c.washDryTime; + w.wetDarken = c.washWetDarken; + w.gloss = c.washGloss; + w.glossPower = c.washGlossPower; + w.meniscus = c.washMeniscus; + + w.opacity = c.washOpacity * g.opacity; + w.fade = fade; + w.ceiling = c.washCeiling; + w.softFade = c.washSoftFade; + w.tint = c.washTint; + w.tintDensity = c.washTintDensity; + w.colorWash = c.washColorThin; + w.colorBody = c.washColorBody; + w.colorDeep = c.washColorDeep; + w.colorPool = c.washColorPool; + w.colorRing = c.washColorRing; + w.colorWet = c.washColorWet; + w.colorGloss = c.washColorGloss; + + this.wash.setPlacement(_centre, this.direction); + this.wash.update(soak, w); + + /* ---------------- the bloom: the pigment ---------------- */ + const b = _bloom; + b.radius = radius; + b.height = c.bloomHeight; + + b.spread = c.bloomSpread; + b.spreadPower = c.bloomSpreadPower; + b.edge = c.bloomEdge; + b.clipSoft = c.bloomClipSoft; + b.sources = c.bloomSources; + b.sourceScatter = c.bloomSourceScatter; + b.sourceDelay = c.bloomSourceDelay; + + b.finger = c.bloomFinger; + b.fingerMax = c.bloomFingerMax; + b.coarse = c.bloomCoarse; + b.onset = c.bloomOnset; + b.growth = c.bloomGrowth; + b.growthMax = c.bloomGrowthMax; + + b.core = c.bloomCore; + b.falloff = c.bloomFalloff; + b.film = c.bloomFilm; + b.granulation = c.bloomGranulation; + b.granScale = c.bloomGranScale; + b.ring = c.bloomRing; + b.ringWidth = c.bloomRingWidth; + + b.dryTime = c.bloomDryTime; + b.wetDarken = c.bloomWetDarken; + b.gloss = c.bloomGloss; + b.glossPower = c.bloomGlossPower; + b.meniscus = c.bloomMeniscus; + + b.opacity = c.bloomOpacity * g.opacity; + b.fade = fade; + b.ceiling = c.bloomCeiling; + b.softFade = c.bloomSoftFade; + b.tint = c.bloomTint; + b.tintDensity = c.bloomTintDensity; + b.colorWash = c.bloomColorThin; + b.colorBody = c.bloomColorBody; + b.colorDeep = c.bloomColorDeep; + b.colorPool = c.bloomColorPool; + b.colorRing = c.bloomColorRing; + b.colorWet = c.bloomColorWet; + b.colorGloss = c.bloomColorGloss; + + this.pigment.setPlacement(_centre, this.direction); + // Clamped at zero rather than skipped: the front law wants a time and the + // whole point of this module is that it re-resolves on any clock you hand + // it, including one that has not started. + this.pigment.update(Math.max(0, this._pigmentAge()), b); + + this._live = 1 + Math.max(1, Math.round(c.bloomSources)); + + /* ---------------- the two particle systems ---------------- */ + this.grains.setGradient( + getColor(c.colorGrainA), + getColor(c.colorGrainB), + getColor(c.colorGrainC), + getColor(c.colorGrainD) + ); + // Negative: pigment settles. Grains that rise are steam and this is not. + this.grains.uniforms.uGravity.value.set(0, c.grainSink, 0); + this.grains.uniforms.uSizeScale.value = c.grainSize * g.particleSize * 7; + this.grains.uniforms.uLifeScale.value = c.grainLifetime * 0.5 * g.particleLifetime; + this.grains.uniforms.uSpeedScale.value = g.particleSpeed; + this.grains.uniforms.uOpacity.value = g.opacity; + this.grains.uniforms.uTurbulence.value = c.grainTurbulence * g.turbulence; + + this.veil.setGradient( + getColor(c.colorVeilA), + getColor(c.colorVeilB), + getColor(c.colorVeilC), + getColor(c.colorVeilD) + ); + this.veil.uniforms.uGravity.value.set(0, c.veilRise, 0); + this.veil.uniforms.uSizeScale.value = c.veilSize * g.particleSize; + this.veil.uniforms.uLifeScale.value = c.veilLifetime * 0.5 * g.particleLifetime; + this.veil.uniforms.uSpeedScale.value = c.veilSpeed * g.particleSpeed; + this.veil.uniforms.uOpacity.value = c.veilOpacity * g.opacity; + this.veil.uniforms.uTurbulence.value = 0.5 * g.turbulence; + } + + /** + * Grains paid out per metre of the bead's flight, so the trail arrives at the + * same density whether the pool was chosen at four metres or at twenty. + * Keyed off distance rather than off time for exactly that reason. + */ + _beadFx() { + const c = settings.inkbloom; + const step = 1 / Math.max(0.05, c.dropRate); + const time = frame.uTime.value; + + while (this.front - this._beadDistance >= step) { + this._beadDistance += step; + const s = saturate(this._beadDistance / this.length); + this._beadPoint(s, _pos); + + _emit.position = _pos; + _emit.radius = 0.05; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.grainSpeed * 0.5; + _emit.speedVariance = 0.6; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.grainLifetime * 0.5; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.grains.emit(2, _emit); + } + } + + /** + * Everything the standing bloom sheds. + * + * The grains are emitted **on the front**, not over the disc: the field's own + * CPU mirror is asked where each nucleus's front is this frame, and the + * annulus follows it outward. Scattering them uniformly over the mark was the + * first attempt and it read as dust lying on a stain — the whole information + * in a grain is that it is being carried by an edge that is moving. + * + * @param {number} dt + * @param {number} scale 0..1 — thinned as the bloom soaks away + */ + _bloomFx(dt, scale) { + const c = settings.inkbloom; + const g = settings.global; + const time = frame.uTime.value; + const sources = Math.max(1, Math.round(c.bloomSources)); + + /* --- grains on the pigment front --- */ + const grainCount = Math.round(this.grainEmitter.tick(dt, c.grainRate * scale) * g.particleCount); + if (grainCount > 0) { + // Whichever nucleus the dice pick this frame, at its *own* front radius. + // The later nuclei are behind the first by `bloomSourceDelay`, so the + // band is genuinely several rings and not one. + const which = Math.min(sources - 1, Math.floor(Math.random() * sources)); + this.pigment.sourcePoint(which, _nucleus); + const front = this.pigment.frontRadius(which); + const band = front * saturate(c.grainBand); + const angle = Math.random() * TAU; + const r = Math.max(0, front + randRange(-band, band)); + + _pos.set(_nucleus.x + Math.cos(angle) * r, c.bloomHeight + 0.02, _nucleus.z + Math.sin(angle) * r); + _emit.position = _pos; + _emit.radius = band * 0.5 + 0.04; + // Outward along the front's own normal, which is what a grain carried by + // a spreading edge does. A vertical direction here made them puff. + _emit.direction = _dir.set(Math.cos(angle), 0.25, Math.sin(angle)).normalize(); + _emit.speed = c.grainSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.55; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1; + _emit.sizeVariance = 0.65; + _emit.life = c.grainLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.grains.emit(grainCount, _emit); + } + + /* --- the veil standing over the drop --- */ + const veilCount = Math.round(this.veilEmitter.tick(dt, c.veilRate * scale) * g.particleCount); + if (veilCount > 0) { + const front = Math.max(0.05, this.pigment.frontRadius(0)); + const angle = Math.random() * TAU; + // sqrt of a uniform draw is uniform over the disc; without it the veil + // crowds the middle and the tendril becomes a chimney. + const r = front * Math.sqrt(Math.random()) * 0.7; + this.pigment.sourcePoint(0, _nucleus); + + _pos.set(_nucleus.x + Math.cos(angle) * r, randRange(0.05, 0.35), _nucleus.z + Math.sin(angle) * r); + _emit.position = _pos; + _emit.radius = front * 0.12 + 0.05; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.veilSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1; + _emit.sizeVariance = 0.5; + _emit.life = c.veilLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.2; + _emit.tint = null; + _emit.time = time; + this.veil.emit(veilCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + // Both fields are synced every frame of the flight even though neither is + // drawing anything yet: their `fade` is zero, but their metres are not, and + // an author who drags `zoneRadius` while a bead is in the air must get the + // pool they chose when it lands. + this._sync(0); + + // The light rides the bead out to the water. + this._beadPoint(this.u, this.position); + + this._beadFx(); + this.ctx.shake.rumble(settings.inkbloom.rumble * settings.global.cameraShake * 0.5, dt); + } + + onImpact() { + const c = settings.inkbloom; + const g = settings.global; + + // The one timestamp a cast records past the seed. + this._landed = true; + this._landAt = this.age; + + this._centrePoint(this.position); + this.position.y = 0.15; + + // No flash and no burst: the two pieces of impact vocabulary this sandbox + // reaches for first are both emissive, and this school does not emit. A + // bead of ink hitting water is a small wet sound and a kick, and that is + // all the punctuation it gets. + this.ctx.shake.add( + c.dropShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this._sync(1); + } + + onFade(dt, t) { + /* + * `t` runs 0..1 while the bloom opens, then 1..2 while it soaks away. + * + * The emissive schools blow out on `inCubic` — hang on, then go, the way a + * light being switched off does. A stain does not do that. It thins evenly + * as the water leaves it, so this is `inQuad`, which is gentle enough to + * read as absorption and steep enough that the slot does not outstay the + * cooldown. + */ + const fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + this._sync(fade); + + this._centrePoint(this.position); + this.position.y = 0.15; + + // Thinned as it dies, and thinned again once the front has reached the + // boundary — a bloom that has stopped spreading has stopped carrying + // anything to its edge, so the grains should stop with it. + const front = this.pigment.frontRadius(0); + const spreading = 1 - saturate(front / Math.max(0.2, this.radius)); + this._bloomFx(dt, fade * (0.25 + 0.75 * spreading)); + + this.ctx.shake.rumble(settings.inkbloom.rumble * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + this._live = 2; + this._landed = false; + this._landAt = 0; + this.wash.reset(); + this.pigment.reset(); + } + + dispose() { + this.wash.dispose(); + this.pigment.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/ink/OrigamiAbility.js b/src/abilities/ink/OrigamiAbility.js new file mode 100644 index 0000000..b8a80ff --- /dev/null +++ b/src/abilities/ink/OrigamiAbility.js @@ -0,0 +1,720 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { FoldMesh, FoldPattern, FoldLayout, foldMeshParams } from '../../vfx/FoldMesh.js'; +import { Swarm, Silhouette, LeadPath, swarmParams } from '../../vfx/Swarm.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing } from '../../utils/math.js'; + +/** + * Hard ceiling on cranes. The editor's `sheets` slider clamps here. + * + * The vertex cost of a folded sheet is `segments² × MAX_CREASES` rotations, and + * the crane table is seven creases at 26×26 — about 4.7k rotations a bird. At + * twenty-eight that is 130k a frame, which is nothing, and the + * real ceiling is legibility: past about twenty birds the flock stops reading + * as individual objects opening and starts reading as confetti. + */ +const MAX_SHEETS = 28; +/** Hard ceiling on the scrap cloud. `Swarm` capacity; the slider clamps here. */ +const MAX_SCRAPS = 192; +/** + * Grid resolution across one sheet. + * + * The crease is only as sharp as the grid can resolve: a hinge narrower than a + * cell quantises into a visible kink along the fold. At 26 a cell is 13.8 mm on + * the shipped 36 cm sheet, just inside the 16 mm hinge — drop this to 12 and + * the wing breaks turn into a staircase. + * + * The one crease that is still under-resolved is the head, which carries + * `hinge: 0.6` in the crane table and so folds over 9.6 mm. It is knowingly + * left there: the head is a centimetre of paper at the end of a neck, the kink + * is a single cell wide, and paying 40 % more vertices on every sheet in the + * flock to round off a fold nobody can see at flock distance is the wrong + * trade. + */ +const SEGMENTS = 26; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _paper = foldMeshParams(); +const _flock = swarmParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +/** The mirrored side vector — see `_syncPaper`. Never `this.side` itself. */ +const _basis = new Vector3(); + +/** + * ORIGAMI — Paper Storm. A flight of cranes that comes apart in the air. + * + * A flock of folded paper is thrown down the aimed line. It flies, folded, for + * the length of the cast; on arrival the birds **unfold** — every crease runs + * backwards through the order it was folded in, the wings drop, the necks and + * tails straighten, and eighteen cranes become eighteen flat sheets of written + * paper hanging in the air. Then they sink and go. + * + * ## THE TRICK — real folding, and therefore no stretching + * + * The flock opens on **one number**. `FoldMesh` takes `progress`, walks the + * crane's seven-crease table backwards from the deepest fold, and applies each + * crease as a **rigid motion of the material point**. This ability drives that + * number from `1 + foldStagger` down to `0` across the impact phase and does + * nothing else to the geometry: no per-bird animation, no keyframes, no second + * copy of the crane in its open state. + * + * The reason that matters is the thing you notice before you notice anything + * else. The obvious implementation — model the crane, model the sheet, `mix()` + * between them on a slider — is four lines, and it is wrong in a way that + * cannot be unseen: a vertex travelling in a straight line between two + * positions that are *rotated* apart cuts the chord rather than walking the + * arc, so every span across a fold shortens on the way over. A half-open crane + * has lost thirty per cent of its width. It deflates as it opens and inflates + * as it closes, and it reads as rubber. Paper does not do that, and neither + * does this: a product of rigid motions preserves every distance on the sheet + * by construction, at *every* intermediate value and not just at the two ends. + * + * The stagger is the second half of the trick. One shared parameter minus a + * per-bird die means the flock opens as a **ripple** — the near birds first, + * the far ones a beat later — from a single slider, with no per-instance state + * on the CPU at all. Drag `foldStagger` to 0 while paused and eighteen cranes + * snap open in lockstep, which is worth doing once to see how much of the + * ability the stagger is carrying. + * + * ## What is not here + * + * **Nothing glows.** Ink is the matte school. There is no `ctx.flash`, no + * additive pass, no burst shell; the paper is the one tone-mapped material in + * `src/vfx/`, the scrap cloud is lit rather than emissive, and the dust is + * non-additive so it genuinely occludes. The only light is one warm lamp riding + * the flock so the birds catch an edge against the floor. The first pass had a + * bloom-threshold cream on the crease highlight, and it turned a paper crane + * into a neon one — the school exists precisely because everything else in this + * project is lit from inside. + * + * ## Two things the shared clock will not let you do + * + * `tumble` and `scrapChurn` are both multiplied by `frame.uTime` — seconds + * since the app booted — inside their shaders. Ramping either of them from zero + * as the flock opens, which is the obvious way to say "it starts spinning as it + * comes apart", slews every sheet by `t · Δω` on the frame it changes: hundreds + * of radians, in one frame, as a snap. Both are therefore **constant across a + * cast**, and the turning-over read is bought by `openRoll` and `openPitch`, + * which are closed-form *angles* in the beat and can be re-evaluated at any + * time — including on a zero-length frame. + * + * ## The rule that makes the editor work + * + * A cast captures exactly one thing: `_seed`, so two flocks do not lay out + * identically. It is an event and it carries no unit. Every metre, radian and + * second — the sheet size, the altitude, the crease radius, the coast past the + * end of the line, the fall — is resolved from `settings.origami` inside the + * update loop, on a zero-length frame included. Pause with **P** mid-unfold and drag `sheetWidth`: the paper + * gets bigger and the fold stays exactly as far through as it was, because the + * crease table is in fractions of the sheet and the metres are a uniform. + */ +export class OrigamiAbility extends Ability { + constructor(context) { + super('origami', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the flock. One draw call, every bird folded by the vertex stage. --- */ + this.paper = new FoldMesh(this.group, { + pattern: FoldPattern.CRANE, + layout: FoldLayout.LINE, + capacity: MAX_SHEETS, + segments: SEGMENTS, + renderOrder: 4, + name: 'Origami:cranes' + }); + + /* --- the offcuts flying with it. One more. --- */ + this.scraps = new Swarm(this.group, { + capacity: MAX_SCRAPS, + silhouette: Silhouette.LEAF, + // Not additive: a scrap of paper in front of a dark floor is *darker* + // than the floor, and an additive one can only ever be brighter. + additive: false, + renderOrder: 5 + }); + + /** Re-rolled per cast so no two flocks lay out the same way. */ + this._seed = 0; + + /** + * The cast's beats, all unitless, refilled every frame. One object, reused. + * + * fly 0..1 how far down the lane the flock has flown + * open 0..1 how far through the unfold it is + * coast 0..1 how far through the drift past the end it is + * fall 0..1 how far through the sink it is + * appear 0..1 the wave that brings the birds into existence + * fade 1..0 master opacity + */ + this._b = { fly: 0, open: 0, coast: 0, fall: 0, appear: 0, fade: 1 }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Paper dust: the fine stuff that comes off a sheet being handled. Not + // additive, because it has to sit *in front of* the paper and dim it. + this.dust = particles.get('origami.dust', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 0.6 + }); + this.dust.uniforms.uDrag.value = 2.4; + this.dust.uniforms.uEndSize.value = 2.2; + this.dust.uniforms.uSizeIn.value = 0.14; + this.dust.uniforms.uFadeIn.value = 0.18; + this.dust.uniforms.uFadeOut.value = 0.45; + + // Chaff: torn corners. `LEAF` rather than `CHIP` — a chip is a rock and + // tumbles like one; a leaf has a chord, catches air and flutters, and the + // difference is obvious at any distance you can see the flock from. + this.chaff = particles.get('origami.chaff', { + capacity: 1400, + shape: ParticleShape.LEAF, + additive: false, + lit: true, + softFade: 0.3 + }); + this.chaff.uniforms.uDrag.value = 1.9; + this.chaff.uniforms.uEndSize.value = 1.0; + this.chaff.uniforms.uSizeIn.value = 0.06; + this.chaff.uniforms.uFadeIn.value = 0.05; + this.chaff.uniforms.uFadeOut.value = 0.55; + + this.dustEmitter = new RateEmitter(); + this.chaffEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.paper.count + this.scraps.count; + } + + /** The unfold and the hold share the impact phase. */ + get impactDuration() { + const c = settings.origami; + return Math.max(0.05, (c.openTime + c.holdTime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.origami.sinkTime); + } + + /** + * Paper does not gutter and does not glint. The lamp riding the flock + * breathes on the same clock the birds bob on, which is the only thing that + * keeps it from reading as a spotlight bolted to the camera. + */ + lightShimmer() { + return 1 + 0.06 * Math.sin(this.age * settings.origami.bobRate); + } + + /* ------------------------------------------------------------------ */ + /* The beats — fractions only. Metres are resolved in the sync methods. */ + /* ------------------------------------------------------------------ */ + + /** + * @param {number} t 0..1 through the impact phase, then 1..2 through the fade + */ + _resolveBeats(t) { + const c = settings.origami; + const b = this._b; + + if (this.phase === AbilityPhase.TRAVEL) { + b.fly = this.u; + b.open = 0; + b.coast = 0; + b.fall = 0; + // The birds arrive as the front does, each on its own die. + b.appear = Easing.outQuad(this.u); + b.fade = 1; + return; + } + + b.fly = 1; + b.appear = 1; + // The coast runs across *both* remaining phases, so `overrun` is the total + // distance the flock drifts past the end of the line rather than a per- + // phase quantity that jumps when the phase changes. + b.coast = saturate(t * 0.5); + + if (t <= 1) { + const openFrac = saturate(c.openTime / Math.max(c.openTime + c.holdTime, 1e-3)); + b.open = saturate(t / Math.max(openFrac, 1e-3)); + b.fall = 0; + b.fade = 1; + return; + } + + b.open = 1; + const s = saturate(t - 1); + b.fall = s; + // Cubic, so the sheets hang and *then* go, rather than dimming from the + // first frame of the fade. Paper does not dissolve; it leaves the shot. + b.fade = 1 - Easing.inCubic(s); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.chaffEmitter.reset(); + + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + this.paper.roll(this._seed); + this.scraps.roll(this._seed); + this.paper.visible = true; + + this._resolveBeats(0); + this._sync(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** Everything that draws, re-resolved from the live block. */ + _sync() { + this._syncPaper(); + this._syncScraps(); + this._syncParticles(); + + // The lamp rides the flock at altitude, not the floor line the base class + // walks along. Resolved after the paper so both agree about the altitude. + this.pointAt(this._travelFraction(), this.position); + this.position.y = this._altitude(); + } + + /** + * How far along the cast the flock is, as a fraction — including the coast + * past the end. + * + * `overrun` is in metres and `travel` is a fraction, so the conversion has to + * happen against the *live* `this.length`. Doing it here rather than in the + * beats is what keeps `_b` free of dimensions. + */ + _travelFraction() { + const c = settings.origami; + return this._b.fly + (c.overrun * this._b.coast) / Math.max(this.length, 1e-3); + } + + /** + * The flock's altitude in metres: cruise, plus the pop of air as the wings + * come apart, minus the fall. + * + * The fall is `speed × duration × ease`, not an integration. Integrating it + * would put a metre on the CPU and freeze the sheets where they were the + * moment you paused; this way, dragging `sink` while paused moves a sheet + * that is already falling to where it *would* have been. + */ + _altitude() { + const c = settings.origami; + const b = this._b; + return ( + c.lift + c.openLift * Easing.outCubic(b.open) - c.sink * c.sinkTime * Easing.inQuad(b.fall) + ); + } + + /** + * The flock. + * + * One note that cost an hour: `FoldMesh.setBasis(origin, direction, side)` + * derives its own up as `direction × side`, and the base class's `side` is + * `direction × worldUp` — so the natural argument order hands it an up vector + * pointing at the **floor**, and the entire flock flies underground with its + * cranes upside down. The mirrored side is passed instead. The dice are + * symmetric about zero so mirroring costs nothing but the sign of the lateral + * scatter, which nobody can see. + */ + _syncPaper() { + const c = settings.origami; + const g = settings.global; + const b = this._b; + const p = _paper; + + p.count = Math.max(0, Math.min(MAX_SHEETS, Math.round(c.sheets))); + + /* --- the sheet --- */ + p.sheetWidth = c.sheetWidth; + p.sheetLength = c.sheetLength; + p.aspect = c.aspect; + p.sizeJitter = c.sizeJitter * g.randomness; + p.thickness = c.thickness; + + /* --- the fold: the one number --- */ + // From a stagger's worth above 1 down to 0. Above 1 so that the *earliest* + // bird in the stagger is still fully folded on the first frame of the + // unfold: `prog = progress − die × stagger`, so a die of 1 needs the shared + // parameter to start at 1 + stagger for that bird to start closed. + p.progress = lerp(1 + c.foldStagger, 0, Easing.inOutCubic(b.open)); + p.foldGain = c.foldGain; + p.hinge = c.hinge; + p.stageEase = c.stageEase; + p.foldStagger = c.foldStagger; + + /* --- where the flock is --- */ + p.travel = this._travelFraction(); + p.spread = c.spread; + p.stretch = c.stretch; + p.lift = this._altitude(); + p.liftJitter = c.liftJitter * g.randomness; + p.bob = c.bob; + p.bobRate = c.bobRate; + + /* --- attitude. Both open terms are angles, not rates. --- */ + const open = Easing.inOutCubic(b.open); + p.pitch = c.flyPitch + c.openPitch * open; + p.yaw = 0; + p.yawJitter = c.yawJitter * g.randomness; + p.roll = c.openRoll * open; + p.rollJitter = c.rollJitter * g.randomness; + p.tumble = c.tumble; + + /* --- appearing --- */ + p.reveal = b.appear; + p.revealSpread = c.revealSpread; + + /* --- the paper itself --- */ + p.ambient = c.ambient; + p.wrap = c.wrap; + p.transmit = c.transmit; + p.transmitPower = c.transmitPower; + p.sheen = c.sheen; + p.gloss = c.gloss; + + p.grain = c.grain; + p.grainScale = c.grainScale; + p.grainAngle = c.grainAngle; + p.grainAniso = c.grainAniso; + p.fleck = c.fleck; + p.laid = c.laid; + p.laidPitch = c.laidPitch; + p.chainPitch = c.chainPitch; + + p.creaseGlow = c.creaseGlow; + p.creaseDark = c.creaseDark; + p.creaseSharp = c.creaseSharp; + + p.ink = c.ink; + p.inkRows = c.inkRows; + p.inkCols = c.inkCols; + p.inkFill = c.inkFill; + p.inkWeight = c.inkWeight; + p.inkMargin = c.inkMargin; + p.inkSeed = c.inkSeed; + p.inkGhost = c.inkGhost; + + p.edge = c.edge; + p.tintSpread = c.tintSpread; + p.opacity = c.paperOpacity * g.opacity * b.fade; + + this.paper.setColors(c.colorPaper, c.colorShade, c.colorTransmit, c.colorInk, c.colorCrease); + _basis.copy(this.side).negate(); + this.paper.setBasis(this.origin, this.direction, _basis, this.length); + this.paper.update(this.age, p); + } + + /** + * The scrap cloud. + * + * `leadRate` is the flock's own speed expressed in lane-fractions per second, + * and it is not decoration: the lattice's third axis is *time*, so with a + * rate of zero every rank shares one home point and the cloud collapses into + * a flat card. It stays at the true rate for the whole cast — including after + * the flock has stopped — because it is what sets the cloud's depth, and a + * depth that changes as the birds arrive reads as the cloud being sucked + * forward. + * + * The `LINE` lead clamps at the target, so once the cranes coast past the end + * of the line the scraps stop with the target and the flock leaves them + * behind. That is correct and it is free: the paper is going somewhere, the + * paper it shed is not. + */ + _syncScraps() { + const c = settings.origami; + const g = settings.global; + const b = this._b; + const p = _flock; + + p.count = Math.round(Math.min(MAX_SCRAPS, c.scraps) * g.particleCount); + + p.leadMode = LeadPath.LINE; + p.leadS = this._travelFraction(); + p.leadRate = (c.speed * g.speed) / Math.max(this.length, 1e-3); + p.leadRise = c.scrapRise; + p.handForward = 0; + p.handSide = 0; + p.handHeight = c.lift; + p.endHeight = c.scrapEnd; + + p.latticeX = c.scrapLatticeX; + p.latticeY = c.scrapLatticeY; + p.latticeZ = c.scrapLatticeZ; + p.spacingSide = c.scrapSpacing; + p.spacingUp = c.scrapSpacingUp; + p.jitter = c.scrapJitter * g.randomness; + p.lag = c.scrapLag; + p.churn = c.scrapChurn; + p.breathe = c.scrapBreathe; + p.breatheRate = c.scrapBreatheRate; + p.wander = c.scrapWander * g.turbulence; + p.wanderScale = c.scrapWanderScale * g.noiseFrequency; + p.wanderSpeed = c.scrapWanderSpeed * g.noiseSpeed; + p.gather = c.scrapGather; + + p.size = c.scrapSize * g.particleSize; + p.aspect = c.scrapAspect; + p.sizeJitter = c.scrapSizeJitter * g.randomness; + p.billboard = 0; + p.bank = c.scrapBank; + p.bankMax = c.scrapBankMax; + p.dihedral = 0; + p.flapRate = 0; + p.curl = c.scrapCurl; + p.edgeStretch = c.scrapEdgeStretch; + + // A scrap exists exactly to the extent that a crane has come apart: one + // beat, two consumers, and the cloud cannot get ahead of the unfold. + p.reveal = Easing.outQuad(this._b.open); + p.revealSpread = c.scrapRevealSpread; + + p.silhouette = Silhouette.LEAF; + p.edgeGain = 1; + // Lit, not emissive. This is the school where that is the whole point. + p.lit = 1; + p.tint = c.scrapTint; + p.tintJitter = c.scrapTintJitter * g.randomness; + p.tintAlong = c.scrapTintAlong; + p.opacity = c.scrapOpacity * g.opacity * b.fade; + p.glow = c.scrapGlow * g.glow; + p.softFade = c.scrapSoftFade; + + this.scraps.setColors(c.colorScrapA, c.colorScrapB, c.colorScrapC, c.colorScrapD); + _basis.copy(this.side).negate(); + this.scraps.setBasis(this.origin, this.direction, _basis, this.length); + this.scraps.update(this.age, p); + } + + /** The two particle systems, re-coloured and re-scaled every frame. */ + _syncParticles() { + const c = settings.origami; + const g = settings.global; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.chaff.setGradient( + getColor(c.colorChaffA), + getColor(c.colorChaffB), + getColor(c.colorChaffC), + getColor(c.colorChaffD) + ); + this.chaff.uniforms.uGravity.value.set(0, c.chaffGravity, 0); + this.chaff.uniforms.uSizeScale.value = c.chaffSize * g.particleSize * 7; + this.chaff.uniforms.uLifeScale.value = c.chaffLifetime * 0.5 * g.particleLifetime; + this.chaff.uniforms.uSpeedScale.value = g.particleSpeed; + this.chaff.uniforms.uOpacity.value = g.opacity; + } + + /** + * A point on a live sheet, in world space. + * + * `sheetPoint` is the CPU mirror of the *placement* half of the vertex + * shader — it deliberately does not mirror the fold, so this is where the + * bird is rather than where its left wing got to, which is all an emitter + * needs. Handed the same params block the paper was synced with on this + * frame, so the two cannot disagree. + */ + _sheetPoint(out) { + const count = Math.max(1, Math.round(_paper.count)); + return this.paper.sheetPoint(Math.floor(Math.random() * count), _paper, out); + } + + /** + * Dust and chaff shed off the flock. + * + * @param {number} scale 0..1 — the emission ramp, so the shedding follows the + * unfold rather than running at a flat rate for the whole cast + */ + _flockFx(dt, scale) { + const c = settings.origami; + const g = settings.global; + const time = frame.uTime.value; + if (scale <= 0) return; + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (dustCount > 0) { + this._sheetPoint(_pos); + _emit.position = _pos; + _emit.radius = c.sheetWidth * 0.9; + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.8; + _emit.spread = c.dustSpread; + _emit.inherit = null; + _emit.size = 0.5; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + const chaffCount = Math.round(this.chaffEmitter.tick(dt, c.chaffRate * scale) * g.particleCount); + if (chaffCount > 0) { + this._sheetPoint(_pos); + _emit.position = _pos; + _emit.radius = c.sheetWidth * 0.7; + _emit.anchor = null; + // Thrown backwards and up: the flock is moving forward, so what comes off + // it goes the other way. Copying `direction` and negating is what makes + // the chaff read as *shed* rather than as fired. + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.5).setY(0.7).normalize(); + _emit.speed = c.chaffSpeed; + _emit.speedVariance = 0.7; + _emit.spread = c.chaffSpread; + _emit.inherit = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.65; + _emit.life = c.chaffLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = c.chaffSpin; + _emit.tint = null; + _emit.time = time; + this.chaff.emit(chaffCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._resolveBeats(0); + this._sync(); + // Almost nothing while the birds are still folded: a crane in flight sheds + // a little dust and no chaff at all. The ramp is the fly beat itself, so a + // long cast sheds more than a short one. + this._flockFx(dt, this._b.fly * 0.15); + this.ctx.shake.rumble(settings.origami.rumble * settings.global.cameraShake, dt); + } + + /** THE UNFOLD — the frame the flock starts to come apart. */ + onImpact() { + const c = settings.origami; + const g = settings.global; + const time = frame.uTime.value; + + this._resolveBeats(0); + this._sync(); + + /* the ring of dust the flock knocks off the floor beneath it */ + this.pointAt(1, _pos); + this.ctx.decals.spawn(DecalType.DUSTRING, _pos, { + radius: c.ringRadius * g.explosionIntensity, + life: c.ringLife, + intensity: c.ringIntensity, + growth: 0.6, + colorA: getColor(c.colorRingA), + colorB: getColor(c.colorRingB), + height: 0.012 + }); + + /* the torn corners, thrown from the flock rather than from the floor */ + this._sheetPoint(_pos); + _emit.position = _pos; + _emit.radius = c.stretch * 0.5; + _emit.anchor = null; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.35).setY(0.85).normalize(); + _emit.speed = c.chaffSpeed * 1.4; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.size = 0.14; + _emit.sizeVariance = 0.75; + _emit.life = c.chaffLifetime * 1.2; + _emit.lifeVariance = 0.5; + _emit.spin = c.chaffSpin * 1.3; + _emit.tint = null; + _emit.time = time; + this.chaff.emit(Math.round(c.chaffBurst * g.particleCount), _emit); + + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 1.6; + _emit.spread = 1.0; + _emit.size = 0.6; + _emit.life = c.dustLifetime * 1.15; + _emit.spin = 0.4; + this.dust.emit(Math.round(c.dustBurst * g.particleCount), _emit); + + this.ctx.shake.add( + c.openShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + // No `ctx.flash`. Deliberately: see the class header. Paper coming apart is + // not an explosion and the ink school does not get a screen flash. + this.lightBoost = c.lightIntensity * 0.25 * g.explosionIntensity; + } + + onFade(dt, t) { + this._resolveBeats(t); + this._sync(); + + const b = this._b; + // Shedding peaks while the creases are actually moving and stops once the + // sheets are flat: `1 − open` is the rate the fold is still changing at, to + // within an easing, and it costs nothing to reuse it. + const opening = t <= 1 ? 1 - Easing.inQuad(b.open) : 0; + this._flockFx(dt, opening); + + if (t <= 1) { + this.ctx.shake.rumble(settings.origami.rumble * settings.global.cameraShake, dt); + } + } + + onDestroy() { + this.paper.reset(); + this.scraps.reset(); + } + + dispose() { + this.paper.dispose(); + this.scraps.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/ink/ScrollwardAbility.js b/src/abilities/ink/ScrollwardAbility.js new file mode 100644 index 0000000..14c5992 --- /dev/null +++ b/src/abilities/ink/ScrollwardAbility.js @@ -0,0 +1,614 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { FoldMesh, FoldPattern, FoldLayout, foldMeshParams } from '../../vfx/FoldMesh.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing } from '../../utils/math.js'; + +/** Hard ceiling on scrolls. The editor's `scrolls` slider clamps here. */ +const MAX_SCROLLS = 20; +/** + * Grid resolution across a scroll, and along it. + * + * `UNROLL` spends almost everything on the second number. Across the sheet the + * paper is dead straight and ten quads is generous; *along* it the sheet has to + * resolve a spiral whose turns are a few centimetres across, and the roll is + * the thing the eye goes to. + * + * The number that matters is **segments per turn**, and because the module + * places by arc length it comes out constant for free: + * `SEGMENTS_ALONG × 2πr / sheetLength`. At 80, a 60 mm core and the shipped + * 3.2 m sheet that is a shade over ten all the way from a full roll to an empty + * one — a decagon, which at the size a 13 cm roll occupies on screen is round. + * Sixty-four gave eight and the roll read as a nut; the fix people reach for + * first is a smaller `core`, which makes it *worse*, because a tighter roll has + * more turns in the same length of paper and gets fewer segments each. + */ +const SEGMENTS_ACROSS = 10; +const SEGMENTS_ALONG = 80; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _paper = foldMeshParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +/** The mirrored side vector — see `_syncPaper`. Never `this.side` itself. */ +const _basis = new Vector3(); + +/** + * SCROLLWARD — a ring of scrolls that pays itself out into a wall of text. + * + * The cast lands on the aimed circle and a ring of rolled paper stands up + * around it, spools uppermost. The spools climb; the paper hangs behind them; + * a few seconds later there is a wall of writing at head height that you can + * orbit and read from either side. Then the whole thing winds back onto its + * rolls and goes. + * + * ## THE TRICK — cylindrical unrolling, placed by arc length + * + * The sheet is not a plane with a scrolling texture on it and it is not two + * meshes crossfading. It is one continuous bend, and `FoldMesh`'s `UNROLL` mode + * places every material point on it **by arc length measured from the free + * end**: + * + * - the paid-out run is an arc of constant curvature — paper remembers having + * been rolled — and this ability walks that curvature from `curlRoll` down + * to `curlFlat` as the payout grows, because a sheet with two metres still + * on the spool hangs with far more set in it than the same sheet fully out. + * That relaxation is why the wall straightens as it rises rather than + * keeping one lean the whole way up; + * - the wound part is a real Archimedean spiral, `r = √(r₀² + wt/π)` — the + * exact relation between wound length and radius for paper of thickness `t` + * on a core of radius `r₀`. It is **tightest at the spool** because that is + * what a square root does, not because anybody authored a taper. + * + * And then the part that makes it worth doing. Every mark on the paper — the + * fibre grain, the laid lines, the writing — is a function of the **sheet + * coordinate** and of nothing else, so the text is printed on the material + * rather than projected onto the surface. The foreshortening as a column of + * characters turns onto the roll is therefore free, and it is *right*: the + * shader never learns it is on a curve, and the writing compresses where it + * does because the paper it is on genuinely is compressed there in screen + * space. Place the sheet by *fraction* instead of by arc length — which is the + * obvious implementation, and it is one line shorter — and a tight inner turn + * and a flat metre of run are declared the same amount of material: the text + * bunches at the spool, stretches on the run, and the whole illusion is gone. + * + * ## The wall + * + * `FoldLayout.ZONE` stands the scrolls upright on a circle facing outward, so + * the ring reads as a wall from outside and as a room from inside, and `arc` + * turns it into a screen when a room is too much. They pay out **together**: + * the layout has no per-instance stagger on `payout` and it should not have + * one, because thirteen scrolls dropping raggedly reads as thirteen separate + * events, and this is one. The variety comes from where they stand and how big + * they are, all of it on unitless dice. + * + * ## What is not here + * + * **Nothing glows.** Ink is the matte school: the paper is the one tone-mapped + * material in `src/vfx/`, the dust is non-additive so it occludes the wall + * behind it, and there is no screen flash. The single lamp climbs inside the + * ring with the wall, and it is there so that the paper's translucency has + * something to be translucent *to* — the inside faces of the far scrolls glow + * through and their writing shows as a shadow inside the sheet, which is the + * one line in `FoldMesh`'s fragment shader that separates paper from card. + * + * ## The rule that makes the editor work + * + * A cast captures exactly one thing: `_seed`, so two rings do not stand in the + * same places. It is an event and it carries no unit. Every metre — the ring, + * the sheet, the core of the roll, the thickness of the paper — is resolved + * from `settings.scrollward` inside the update loop, on a zero-length frame + * included. Pause with **P** halfway + * through the payout and drag `paper`: the roll swells, the spiral re-solves + * with fatter turns, and the tangent point where the paper leaves it moves — + * live, because the spiral is evaluated in the vertex shader from a uniform and + * was never baked into anything. + */ +export class ScrollwardAbility extends Ability { + constructor(context) { + super('scrollward', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the wall. One draw call for every scroll in it. --- */ + this.paper = new FoldMesh(this.group, { + pattern: FoldPattern.UNROLL, + layout: FoldLayout.ZONE, + capacity: MAX_SCROLLS, + segments: SEGMENTS_ACROSS, + segmentsV: SEGMENTS_ALONG, + renderOrder: 4, + name: 'Scrollward:scrolls' + }); + + /** Re-rolled per cast so no two rings stand in the same places. */ + this._seed = 0; + + /** + * The cast's beats, all unitless, refilled every frame. One object, reused. + * + * pay 0..1 how far through the payout the wall is + * appear 0..1 the wave that brings the scrolls into being + * wind 0..1 how far through the wind-back it is + * fade 1..0 master opacity + */ + this._b = { pay: 0, appear: 0, wind: 0, fade: 1 }; + } + + createParticles() { + const particles = this.ctx.particles; + + // The dust that comes off a roll that has been somewhere for a long time. + // Non-additive: it has to sit in front of the wall and dim it, and an + // additive puff in front of bone-coloured paper is invisible anyway. + this.dust = particles.get('scrollward.dust', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 0.6 + }); + this.dust.uniforms.uDrag.value = 2.6; + this.dust.uniforms.uEndSize.value = 2.4; + this.dust.uniforms.uSizeIn.value = 0.16; + this.dust.uniforms.uFadeIn.value = 0.2; + this.dust.uniforms.uFadeOut.value = 0.45; + + // Flakes off the edge of an old sheet. `LEAF`, not `CHIP`: a flake of paper + // has a chord and flutters, and a chip is a rock and does not. + this.flakes = particles.get('scrollward.flakes', { + capacity: 1000, + shape: ParticleShape.LEAF, + additive: false, + lit: true, + softFade: 0.3 + }); + this.flakes.uniforms.uDrag.value = 2.1; + this.flakes.uniforms.uEndSize.value = 0.9; + this.flakes.uniforms.uSizeIn.value = 0.07; + this.flakes.uniforms.uFadeIn.value = 0.06; + this.flakes.uniforms.uFadeOut.value = 0.55; + + this.dustEmitter = new RateEmitter(); + this.flakeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.paper.count; + } + + /** The payout and the hold share the impact phase. */ + get impactDuration() { + const c = settings.scrollward; + return Math.max(0.05, (c.unrollTime + c.holdTime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.scrollward.windTime); + } + + /** A lamp in a room, not a flame. It breathes with the paper. */ + lightShimmer() { + return 1 + 0.05 * Math.sin(this.age * settings.scrollward.bobRate); + } + + /* ------------------------------------------------------------------ */ + /* The beats — fractions only. Metres are resolved in the sync methods. */ + /* ------------------------------------------------------------------ */ + + /** + * @param {number} t 0..1 through the impact phase, then 1..2 through the fade + */ + _resolveBeats(t) { + const c = settings.scrollward; + const b = this._b; + + if (this.phase === AbilityPhase.TRAVEL) { + // The rolls are already lying on the circle; the cast is what wakes them. + b.pay = 0; + b.appear = Easing.outQuad(this.u); + b.wind = 0; + b.fade = 1; + return; + } + + b.appear = 1; + + if (t <= 1) { + const payFrac = saturate(c.unrollTime / Math.max(c.unrollTime + c.holdTime, 1e-3)); + b.pay = saturate(t / Math.max(payFrac, 1e-3)); + b.wind = 0; + b.fade = 1; + return; + } + + b.pay = 1; + const s = saturate(t - 1); + b.wind = s; + // The wall has to be *seen* winding back, so it holds full opacity for the + // first `windHold` of the fade and only then leaves. The first version + // faded from the opening frame of the phase and the rewind — which is the + // payout run backwards and the best half-second in the ability — happened + // entirely inside a dissolve. + const hold = saturate(c.windHold); + b.fade = 1 - Easing.inCubic(saturate((s - hold) / Math.max(1 - hold, 1e-3))); + } + + /** + * The payout, 0..1 of the sheet off the roll — the module's one beat. + * + * Resolved here rather than in `_resolveBeats` because both ends of it are + * *sliders*, and a beat that has read a slider is no longer a fraction of a + * phase. Dragging `payoutEnd` while paused re-solves the spiral and moves the + * tangent point, which is the whole demonstration. + */ + _payout() { + const c = settings.scrollward; + const b = this._b; + if (this.phase === AbilityPhase.TRAVEL) return c.payoutSeed; + const out = lerp(c.payoutSeed, c.payoutEnd, Easing.outCubic(b.pay)); + return lerp(out, c.rewind, Easing.inOutCubic(b.wind)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.flakeEmitter.reset(); + + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + this.paper.roll(this._seed); + this.paper.visible = true; + + this._resolveBeats(0); + this._sync(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + _sync() { + this._syncPaper(); + this._syncParticles(); + + // The lamp climbs inside the ring with the wall, so the inside faces stay + // lit as they rise. `lightHeight` is a fraction of the paid-out run, which + // means it tracks `sheetLength` for free. + const c = settings.scrollward; + this.pointAt(1, this.position); + this.position.y = c.lift + c.lightHeight * this._payout() * c.sheetLength; + } + + /** + * The ring. + * + * Two things here are not obvious and both cost time to find. + * + * **The basis.** `FoldMesh.setBasis(origin, direction, side)` derives its own + * up as `direction × side`, and the base class's `side` is `direction × + * worldUp` — so the natural argument order hands it an up vector pointing at + * the **floor**, and thirteen scrolls stand up *through* it. The mirrored side + * is passed instead; the only visible consequence is which way round the ring + * the bearings run, and the dice are symmetric. + * + * **The origin is the circle, not the caster.** A `ZONE` cast's `origin` is + * where the player is standing and `pointAt(1)` is the footprint the + * indicator drew. Hand `FoldMesh` the former and the ring stands round the + * caster's ankles. + */ + _syncPaper() { + const c = settings.scrollward; + const g = settings.global; + const b = this._b; + const p = _paper; + const payout = this._payout(); + + p.count = Math.max(0, Math.min(MAX_SCROLLS, Math.round(c.scrolls))); + + /* --- the sheet --- */ + p.sheetWidth = c.sheetWidth; + p.sheetLength = c.sheetLength; + p.aspect = c.aspect; + p.sizeJitter = c.sizeJitter * g.randomness; + p.thickness = c.thickness; + + /* --- the roll: the trick --- */ + p.payout = payout; + p.core = c.core; + p.paper = c.paper; + // The run relaxes as it comes off: a sheet with most of its length still + // wound hangs with far more set in it than the same sheet fully paid out. + p.curl = lerp(c.curlRoll, c.curlFlat, saturate(payout)); + p.spoolClimb = c.spoolClimb; + p.spin = c.spin; + + /* --- where the scrolls stand --- */ + p.radius = c.ringRadius * c.zoneRadius; + p.radiusJitter = c.radiusJitter; + p.arc = c.arc; + p.arcPhase = c.arcPhase; + p.lift = c.lift; + p.liftJitter = c.liftJitter * g.randomness; + p.bob = c.bob; + p.bobRate = c.bobRate; + + /* --- attitude --- */ + p.pitch = c.pitch; + p.yaw = 0; + p.yawJitter = c.yawJitter * g.randomness; + p.roll = 0; + p.rollJitter = c.rollJitter * g.randomness; + p.tumble = c.tumble; + + /* --- appearing --- */ + p.reveal = b.appear; + p.revealSpread = c.revealSpread; + + /* --- the paper itself --- */ + p.ambient = c.ambient; + p.wrap = c.wrap; + p.transmit = c.transmit; + p.transmitPower = c.transmitPower; + p.sheen = c.sheen; + p.gloss = c.gloss; + + p.grain = c.grain; + p.grainScale = c.grainScale; + p.grainAngle = c.grainAngle; + p.grainAniso = c.grainAniso; + p.fleck = c.fleck; + p.laid = c.laid; + p.laidPitch = c.laidPitch; + p.chainPitch = c.chainPitch; + + p.creaseGlow = c.creaseGlow; + p.creaseDark = c.creaseDark; + p.creaseSharp = c.creaseSharp; + + p.ink = c.ink; + p.inkRows = c.inkRows; + p.inkCols = c.inkCols; + p.inkFill = c.inkFill; + p.inkWeight = c.inkWeight; + p.inkMargin = c.inkMargin; + p.inkSeed = c.inkSeed; + p.inkGhost = c.inkGhost; + + p.edge = c.edge; + p.tintSpread = c.tintSpread; + p.woundShade = c.woundShade; + p.opacity = c.paperOpacity * g.opacity * b.fade; + + this.paper.setColors(c.colorPaper, c.colorShade, c.colorTransmit, c.colorInk, c.colorCrease); + _basis.copy(this.side).negate(); + this.paper.setBasis(this.pointAt(1, _centre), this.direction, _basis, this.length); + this.paper.update(this.age, p); + } + + /** The two particle systems, re-coloured and re-scaled every frame. */ + _syncParticles() { + const c = settings.scrollward; + const g = settings.global; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.flakes.setGradient( + getColor(c.colorFlakeA), + getColor(c.colorFlakeB), + getColor(c.colorFlakeC), + getColor(c.colorFlakeD) + ); + this.flakes.uniforms.uGravity.value.set(0, c.flakeGravity, 0); + this.flakes.uniforms.uSizeScale.value = c.flakeSize * g.particleSize * 7; + this.flakes.uniforms.uLifeScale.value = c.flakeLifetime * 0.5 * g.particleLifetime; + this.flakes.uniforms.uSpeedScale.value = g.particleSpeed; + this.flakes.uniforms.uOpacity.value = g.opacity; + } + + /** + * Where a scroll's spool currently is, in world space. + * + * `spoolPoint` is the CPU mirror of the placement half of the vertex shader + * plus the arc the paid-out run makes, so it climbs as the wall does — which + * is exactly where the dust wants to come from, because the dust is what the + * roll is shedding as it turns. + */ + _spoolPoint(index, out) { + const count = Math.max(1, Math.round(_paper.count)); + return this.paper.spoolPoint(index % count, _paper, out); + } + + /** + * Dust and flakes off the turning spools. + * + * @param {number} scale 0..1 — the emission ramp; the rolls shed while they + * are turning and stop when they stop + */ + _wallFx(dt, scale) { + const c = settings.scrollward; + const g = settings.global; + const time = frame.uTime.value; + if (scale <= 0) return; + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (dustCount > 0) { + this._spoolPoint(Math.floor(Math.random() * MAX_SCROLLS), _pos); + _emit.position = _pos; + _emit.radius = c.sheetWidth * 0.6; + _emit.anchor = null; + // Downward and outward: dust falls off a turning roll, it is not blown + // off one. The first version fired it up and the ring looked like it was + // on fire, which in this school is the worst thing it could look like. + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.75; + _emit.spread = c.dustSpread; + _emit.inherit = null; + _emit.size = 0.45; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.25; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + const flakeCount = Math.round(this.flakeEmitter.tick(dt, c.flakeRate * scale) * g.particleCount); + if (flakeCount > 0) { + this._spoolPoint(Math.floor(Math.random() * MAX_SCROLLS), _pos); + _emit.position = _pos; + _emit.radius = c.sheetWidth * 0.5; + _emit.anchor = null; + _emit.direction = _dir.set(0, -0.35, 0).normalize(); + _emit.speed = c.flakeSpeed; + _emit.speedVariance = 0.8; + _emit.spread = c.flakeSpread; + _emit.inherit = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.6; + _emit.life = c.flakeLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = c.flakeSpin; + _emit.tint = null; + _emit.time = time; + this.flakes.emit(flakeCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._resolveBeats(0); + this._sync(); + // Barely anything: the rolls are lying there waiting, and a roll that is + // not turning does not shed. The ramp is the reveal, so a scroll that has + // not appeared yet is not making dust either. + this._wallFx(dt, this._b.appear * 0.12); + this.ctx.shake.rumble(settings.scrollward.rumble * settings.global.cameraShake, dt); + } + + /** THE STAND-UP — the frame the ring starts to pay out. */ + onImpact() { + const c = settings.scrollward; + const g = settings.global; + const time = frame.uTime.value; + + this._resolveBeats(0); + this._sync(); + + /* dust knocked off the floor at the foot of a few of the scrolls */ + const marks = Math.max(0, Math.min(MAX_SCROLLS, Math.round(c.markCount))); + const live = Math.max(1, Math.round(_paper.count)); + for (let i = 0; i < marks; i++) { + // Spread across the live scrolls rather than taking the first N, so a + // ring of thirteen with six marks gets them all the way round it. + this.paper.sheetPoint(Math.floor((i * live) / Math.max(marks, 1)), _paper, _pos); + this.ctx.decals.spawn(DecalType.DUSTRING, _pos, { + radius: c.markRadius * g.explosionIntensity, + life: c.markLife, + intensity: c.markIntensity, + growth: 0.35, + colorA: getColor(c.colorMarkA), + colorB: getColor(c.colorMarkB), + height: 0.011 + }); + } + + /* the puff of a room's worth of old paper being moved at once */ + this.pointAt(1, _pos); + _pos.y = c.lift; + _emit.position = _pos; + _emit.radius = c.ringRadius * c.zoneRadius; + _emit.anchor = null; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 1.5; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.size = 0.55; + _emit.sizeVariance = 0.55; + _emit.life = c.dustLifetime * 1.2; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = time; + this.dust.emit(Math.round(c.dustBurst * g.particleCount), _emit); + + _emit.direction = _dir.set(0, 0.6, 0).normalize(); + _emit.speed = c.flakeSpeed * 1.6; + _emit.spread = 1.0; + _emit.size = 0.11; + _emit.life = c.flakeLifetime; + _emit.spin = c.flakeSpin * 1.2; + this.flakes.emit(Math.round(c.flakeBurst * g.particleCount), _emit); + + this.ctx.shake.add( + c.standShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 16 + ); + // No `ctx.flash`. Paper standing up is not an explosion, and the ink school + // does not get a screen flash — see the class header. + this.lightBoost = c.lightIntensity * 0.2 * g.explosionIntensity; + } + + onFade(dt, t) { + this._resolveBeats(t); + this._sync(); + + const b = this._b; + // The rolls shed while they are turning, in either direction: hard through + // the payout, and again while they wind back up. + const turning = t <= 1 ? 1 - Easing.inQuad(b.pay) : Easing.outQuad(1 - b.wind) * 0.7; + this._wallFx(dt, turning * b.fade); + + if (t <= 1) { + this.ctx.shake.rumble( + settings.scrollward.rumble * (1 - Easing.inQuad(b.pay)) * settings.global.cameraShake, + dt + ); + } + } + + onDestroy() { + this.paper.reset(); + } + + dispose() { + this.paper.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/ink/SealscriptAbility.js b/src/abilities/ink/SealscriptAbility.js new file mode 100644 index 0000000..2b2042d --- /dev/null +++ b/src/abilities/ink/SealscriptAbility.js @@ -0,0 +1,868 @@ +import { Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { BrushStroke, BrushTip, brushStrokeParams } from '../../vfx/BrushStroke.js'; +import { InkDiffusion, InkMode, inkDiffusionParams } from '../../vfx/InkDiffusion.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, hash11 } from '../../utils/math.js'; + +/* ====================================================================== */ +/* The alphabet — authored once, in em space, with no units in it */ +/* ====================================================================== */ + +/** + * Ten seal-script skeletons. + * + * A character is an array of strokes; a stroke is nine numbers — + * `[x0,y0, x1,y1, x2,y2, x3,y3, weight]` — where the eight coordinates are the + * control points of a cubic in an **em box** running −0.5..0.5 on both axes, + * and `weight` is a unitless multiplier on the pressure curve. Not one of these + * numbers is a metre. `emSize` turns them into metres, every frame, which is + * why dragging it while paused re-sizes the writing rather than scaling a + * finished object. + * + * They are cubics rather than polylines because seal script (篆書) is defined by + * its turns: the corners of 日 are not corners, they are quarter-circles, and + * a polyline corner immediately reads as a stencil font. Every "straight" + * vertical here is very slightly bowed outward for the same reason — a truly + * straight stroke is the tell that a machine drew it. + * + * The forms are real characters (日 月 山 川 王 中 木 水 石 田) rather than + * invented marks. Inventing them was the first attempt and it does not work: + * made-up glyphs come out with even spacing and a consistent number of strokes, + * because that is what a person generating them reaches for, and the column + * then reads as ornament. Real characters have three strokes next to six, and + * that unevenness is most of what makes a column read as *writing*. + * + * Authored root-first, top-to-bottom, left-to-right within a character — + * because that is the order they are written in, and the drawing sequence walks + * this array directly. + */ +const GLYPHS = Object.freeze([ + /* 日 — sun. Five strokes; the two verticals bow outward at the waist. */ + [ + [-0.26, 0.44, -0.29, 0.26, -0.29, -0.26, -0.26, -0.44, 1.0], + [-0.26, 0.44, -0.09, 0.475, 0.09, 0.475, 0.26, 0.44, 0.92], + [0.26, 0.44, 0.29, 0.26, 0.29, -0.26, 0.26, -0.44, 1.0], + [-0.27, 0.01, -0.09, 0.035, 0.09, 0.035, 0.27, 0.01, 0.84], + [-0.26, -0.44, -0.09, -0.475, 0.09, -0.475, 0.26, -0.44, 0.92] + ], + + /* 月 — moon. Tall, and deliberately asymmetric: the left wall falls further. */ + [ + [-0.2, 0.46, -0.25, 0.2, -0.25, -0.2, -0.19, -0.5, 1.0], + [-0.2, 0.46, -0.06, 0.49, 0.08, 0.49, 0.22, 0.45, 0.9], + [0.22, 0.45, 0.27, 0.2, 0.27, -0.16, 0.22, -0.44, 1.0], + [-0.23, 0.12, -0.08, 0.15, 0.09, 0.15, 0.25, 0.12, 0.8], + [-0.24, -0.18, -0.08, -0.15, 0.09, -0.15, 0.25, -0.18, 0.8] + ], + + /* 山 — mountain. Four strokes, and the only glyph whose base is the last. */ + [ + [0.0, 0.46, 0.005, 0.2, -0.005, -0.1, 0.0, -0.4, 1.0], + [-0.3, 0.18, -0.325, 0.03, -0.325, -0.2, -0.3, -0.4, 0.9], + [0.3, 0.18, 0.325, 0.03, 0.325, -0.2, 0.3, -0.4, 0.9], + [-0.36, -0.4, -0.12, -0.445, 0.12, -0.445, 0.36, -0.4, 1.0] + ], + + /* 川 — river. Three strokes and nothing else. The column needs the rest. */ + [ + [-0.28, 0.44, -0.33, 0.18, -0.31, -0.18, -0.26, -0.44, 0.95], + [0.0, 0.42, -0.025, 0.14, 0.005, -0.16, 0.025, -0.44, 0.95], + [0.28, 0.44, 0.33, 0.18, 0.31, -0.18, 0.26, -0.44, 0.95] + ], + + /* 王 — king. Three bars through one stem; the bars are all different widths. */ + [ + [-0.3, 0.44, -0.1, 0.465, 0.1, 0.465, 0.3, 0.44, 0.9], + [-0.22, 0.02, -0.07, 0.04, 0.07, 0.04, 0.22, 0.02, 0.8], + [0.0, 0.46, 0.008, 0.16, -0.008, -0.14, 0.0, -0.44, 1.0], + [-0.35, -0.44, -0.11, -0.468, 0.11, -0.468, 0.35, -0.44, 0.95] + ], + + /* 中 — centre. The through-stroke overruns the box at both ends, which is + the whole character and the reason it is drawn last. */ + [ + [-0.24, 0.3, -0.27, 0.18, -0.27, -0.04, -0.24, -0.17, 0.9], + [-0.24, 0.3, -0.08, 0.335, 0.08, 0.335, 0.24, 0.3, 0.85], + [0.24, 0.3, 0.27, 0.18, 0.27, -0.04, 0.24, -0.17, 0.9], + [-0.24, -0.17, -0.08, -0.2, 0.08, -0.2, 0.24, -0.17, 0.85], + [0.0, 0.5, 0.008, 0.18, -0.008, -0.16, 0.0, -0.5, 1.05] + ], + + /* 木 — tree. Two splayed legs that leave the stem, not the bar. */ + [ + [-0.34, 0.2, -0.11, 0.235, 0.11, 0.235, 0.34, 0.2, 0.9], + [0.0, 0.46, 0.006, 0.18, -0.006, -0.12, 0.0, -0.46, 1.0], + [-0.02, 0.13, -0.11, 0.02, -0.21, -0.18, -0.32, -0.42, 0.78], + [0.02, 0.13, 0.11, 0.02, 0.21, -0.18, 0.32, -0.42, 0.78] + ], + + /* 水 — water. Five strokes, all curved; nothing in it is straight. */ + [ + [0.0, 0.46, 0.01, 0.16, -0.01, -0.14, 0.0, -0.46, 1.0], + [-0.03, 0.22, -0.16, 0.1, -0.25, -0.08, -0.28, -0.34, 0.78], + [0.03, 0.22, 0.16, 0.1, 0.25, -0.08, 0.28, -0.34, 0.78], + [-0.29, 0.3, -0.37, 0.16, -0.39, 0.0, -0.33, -0.13, 0.68], + [0.29, 0.3, 0.37, 0.16, 0.39, 0.0, 0.33, -0.13, 0.68] + ], + + /* 石 — stone. Six strokes, and the widest silhouette in the set. */ + [ + [-0.38, 0.32, -0.2, 0.42, 0.06, 0.46, 0.36, 0.45, 0.9], + [-0.08, 0.44, -0.2, 0.24, -0.32, 0.02, -0.4, -0.34, 0.9], + [-0.06, 0.0, -0.075, -0.14, -0.075, -0.3, -0.06, -0.44, 0.88], + [-0.06, 0.0, 0.06, 0.03, 0.19, 0.03, 0.31, 0.0, 0.84], + [0.31, 0.0, 0.325, -0.14, 0.325, -0.3, 0.31, -0.44, 0.88], + [-0.06, -0.44, 0.06, -0.472, 0.19, -0.472, 0.31, -0.44, 0.84] + ], + + /* 田 — field. Six strokes: the box, then the cross inside it. */ + [ + [-0.28, 0.42, -0.31, 0.2, -0.31, -0.2, -0.28, -0.42, 1.0], + [-0.28, 0.42, -0.09, 0.455, 0.09, 0.455, 0.28, 0.42, 0.9], + [0.28, 0.42, 0.31, 0.2, 0.31, -0.2, 0.28, -0.42, 1.0], + [0.0, 0.42, 0.008, 0.14, -0.008, -0.14, 0.0, -0.42, 0.85], + [-0.3, 0.0, -0.1, 0.025, 0.1, 0.025, 0.3, 0.0, 0.85], + [-0.28, -0.42, -0.09, -0.455, 0.09, -0.455, 0.28, -0.42, 0.9] + ] +]); + +/** Characters the column can hold. `chars` clamps here. */ +const MAX_CHARS = 6; + +/** + * Stroke slots the brush is built with. + * + * `MAX_CHARS × 6` because 石 and 田 are the longest characters in the alphabet. + * Sizing this to the *average* was the first attempt and it silently truncated + * the two six-stroke glyphs, which is a category of bug that looks like a + * badly-drawn character rather than like a missing one. + */ +const MAX_STROKES = MAX_CHARS * 6; + +/** Bristles per stroke. Ten is plenty at this scale — a stroke is 25 cm long. */ +const BRISTLES = 10; + +/** Samples down one spine. A character stroke turns once; twenty is smooth. */ +const SAMPLES = 20; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _brush = brushStrokeParams(); +const _wash = inkDiffusionParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _anchor = new Vector3(); +const _right = new Vector3(); +const _normal = new Vector3(); +const _charRight = new Vector3(); +const _charNormal = new Vector3(); +const _charCentre = new Vector3(); +const _p0 = new Vector3(); +const _p1 = new Vector3(); +const _p2 = new Vector3(); +const _p3 = new Vector3(); + +/** + * SEAL SCRIPT — a column of characters written top-to-bottom in the air over a + * wash of ink. + * + * **The trick is legible brush weight in three dimensions.** Ten seal-script + * skeletons live at the top of this file as unitless cubics in an em box; every + * control point becomes metres inside the update loop, and every stroke is + * drawn in sequence with a real entry, body and exit. + * + * The failure this ability is defined against is the flat one. Write a column + * of characters as camera-facing billboards and it looks perfect until somebody + * orbits, at which point the entire cast is a line of nothing — a plane seen + * edge-on is thinner than a pixel. Turning the billboard to follow the camera + * fixes the disappearance and breaks everything else: the writing is supposed + * to be an object hanging in the world, and an object that rotates to face you + * is a sprite. + * + * So the strokes are solid. `vfx/BrushStroke.js` sweeps an ellipse of + * half-width `strokeWidth` and half-depth `depth` along each spine, and at the + * defaults here (0.030 m × 0.024 m) that cross-section is very nearly round. + * Edge-on you get a column of ink bars; face-on the depth is invisible and it + * reads flat, which is what writing wants. Three further things make the column + * an object rather than a pane of glass with marks on it: + * + * - **`columnBow`** barrels each character toward the reader — the middle of + * the em box comes forward by nine centimetres and the edges recede, so the + * strokes at the sides of a character are genuinely further away. + * - **`charTwist`** yaws each character a little further round than the one + * above it, so the column fans instead of lying in one plane. It is the + * difference between a scroll and a mobile, and it is small on purpose. + * - **`BrushTip.ROUND`** lays the bristles on a golden-angle disc rather than + * in one rank, which is the only layout where `ferruleDepth` does anything. + * That puts grain *through* the stroke as well as across it, so the depth + * axis has structure in it and the bar you see edge-on is a brush mark + * rather than an extrusion. + * + * ### The sequence + * + * `uProgress` is one clock for the whole column and every stroke owns a window + * of it, built each frame from a timeline in *stroke units*: one unit per + * stroke, `charPause` units between characters, `strokeOverlap` of overrun so + * the brush never comes to a complete stop inside a character. The windows are + * therefore recomputed when you drag `chars` or `charPause` with the clock + * stopped, and the half-written column re-paces itself. + * + * Character order is top-first because that is the reading order, but the + * *height* of a character is measured up from `baseHeight` — the lowest + * character always stands the same distance off the floor, whatever `chars` + * says. Anchoring the top instead put a two-character column at head height and + * a six-character column through the floor. + * + * ### Two settings that are not what they look like + * + * **`speedRef` is 1.6, not 12.** `BrushStroke` measures the brush's dwell + * against this in metres per unit of curve parameter, and a seal-script stroke + * is a fifth of the length of a sumi gesture. Left at the module's default the + * dwell term is enormous everywhere, every stroke pools along its whole length, + * and the writing turns into a row of blobs. This is the one number that has to + * be re-scaled when the marks get small. + * + * **`poolCurve` is 0.05, not 0.35.** These strokes are *made* of turns — + * curvature is high by construction — so the module's default swells every + * corner of every character and the counters fill in. Pooling on curvature is + * right for a hairpin in a long gesture and wrong for a letterform. + * + * ### The rule that makes the editor work + * + * A cast captures one number, `_seed`, from which the character choices, the + * per-character lateral drift and the bristle dice are all derived — all + * unitless, all re-derived every frame. Not a metre, radian or second is + * recorded. Pause with **P** half way down the column and drag `emSize`: the + * characters resize about their own slots, the slot pitch follows because it is + * measured in em boxes, and the strokes still being written keep their place in + * the sequence. + */ +export class SealscriptAbility extends Ability { + constructor(context) { + super('sealscript', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * Thirty-six stroke slots and one draw call. + * + * `samples: 20` rather than the module default of 40 because a character + * stroke is a quarter of a metre long and turns once — forty samples down + * it is forty samples describing a shape that eight would carry. The + * module's own doc recommends exactly this for seal script. + */ + this.brush = new BrushStroke(this.group, { + strokes: MAX_STROKES, + bristles: BRISTLES, + samples: SAMPLES, + sides: 6, + tip: BrushTip.ROUND, + renderOrder: 8, + name: 'SealScript' + }); + + /** + * The wash the column is written over. + * + * `WASH` and not `BLOOM`: the fingering instability is `inkbloom`'s trick + * and it would be the loudest thing on screen here. A wash is a flat pool + * with a deposition line at its rim, which is what a stone slab of ink + * actually leaves, and it exists to give the column something to stand on — + * writing floating over bare floor reads as a hologram. + */ + this.wash = new InkDiffusion(this.group, { + mode: InkMode.WASH, + sources: 4, + satellites: 1, // SPLATTER-only; one slot so the uniform array is legal + renderOrder: 5, + name: 'SealWash' + }); + + /** The one thing a cast captures. Everything else resolves per frame. */ + this._seed = 0; + + /** Strokes written this frame. Recomputed in `_layout`; HUD readout too. */ + this._strokeTotal = 0; + + /** Fired once, when the last stroke lands. */ + this._finished = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // Pigment lifting off the wet strokes. Non-additive, like everything in + // this school: additive blending makes a light, and ink is not one. + this.motes = particles.get('sealscript.motes', { + capacity: 800, + shape: ParticleShape.SOFT, + additive: false, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 2.1; + this.motes.uniforms.uEndSize.value = 1.4; + this.motes.uniforms.uSizeIn.value = 0.1; + this.motes.uniforms.uFadeIn.value = 0.14; + this.motes.uniforms.uFadeOut.value = 0.42; + + // Ink that ran off the bottom of a stroke and fell into the wash. The one + // thing in the slot with real gravity on it, and the only reason the + // column and the pool read as the same substance. + this.drips = particles.get('sealscript.drips', { + capacity: 400, + shape: ParticleShape.SOFT, + additive: false, + softFade: 0.2 + }); + this.drips.uniforms.uDrag.value = 0.35; + this.drips.uniforms.uEndSize.value = 0.5; + this.drips.uniforms.uSizeIn.value = 0.04; + this.drips.uniforms.uFadeIn.value = 0.03; + this.drips.uniforms.uFadeOut.value = 0.65; + + this.moteEmitter = new RateEmitter(); + this.dripEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.brush.count; + } + + /** A beat's length in seconds under the global lifetime knob. */ + _span(seconds) { + return Math.max(0.01, seconds * settings.global.lifetime); + } + + /** The impact phase holds all three beats. Re-derived, never stored. */ + get impactDuration() { + const c = settings.sealscript; + return this._span(c.leadTime) + this._span(c.writeTime) + this._span(c.holdTime); + } + + get fadeDuration() { + return Math.max(0.05, settings.sealscript.fadeTime); + } + + /** Ink does not gutter. A slow breath, and nothing else. */ + lightShimmer() { + return 0.93 + 0.07 * Math.sin(this.age * 1.9); + } + + /* ------------------------------------------------------------------ */ + /* The column — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** How many characters the column is carrying. Clamped to the alphabet slots. */ + _charCount() { + return Math.max(1, Math.min(MAX_CHARS, Math.round(settings.sealscript.chars))); + } + + /** Which glyph sits in slot `k`. Derived from the seed, so a preset repeats. */ + _glyphOf(k) { + return GLYPHS[Math.floor(hash11(this._seed * 3.7 + k * 17.31) * GLYPHS.length) % GLYPHS.length]; + } + + /** Where the writing stands: the far end of the aimed line, on the floor. */ + _anchorPoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** 0..1 — how much of the whole column has been written. */ + _progress() { + const c = settings.sealscript; + return saturate((this.age - this._span(c.leadTime)) / this._span(c.writeTime)); + } + + /** + * Lay every stroke of every character out in world space, in drawing order. + * + * This is the whole ability. It runs every frame — including a zero-length + * one — because every number it touches is a live dimension, and it walks the + * alphabet directly rather than caching a resolved column, because a cached + * column is a column that stops answering the sliders. + */ + _layout(c) { + const chars = this._charCount(); + const em = Math.max(0.02, c.emSize); + const pitch = em * c.charPitch; + + /* --- the writing plane --------------------------------------------- + * The paper normal faces *back at the caster*: the column is written to be + * read from where it was cast. It is fixed in world space and it does not + * follow the camera — the moment it follows the camera it stops being an + * object, which is the failure this ability exists to avoid. + */ + this._anchorPoint(_anchor); + _normal.copy(this.direction).multiplyScalar(-1); + // `side` is the caster's right when looking downrange, so it is the + // character's +x and the writing does not come out mirrored. + _right.copy(this.side); + + let slot = 0; + let units = 0; + + /* --- pass one: the timeline, in stroke units ----------------------- */ + let total = 0; + for (let k = 0; k < chars; k++) total += this._glyphOf(k).length; + total += Math.max(0, chars - 1) * Math.max(0, c.charPause); + const invTotal = 1 / Math.max(total, 1e-4); + const span = (1 + Math.max(0, c.strokeOverlap)) * invTotal; + + /* --- pass two: the strokes ----------------------------------------- */ + for (let k = 0; k < chars; k++) { + const glyph = this._glyphOf(k); + + /* the character's own frame */ + // Twisted about world up, symmetrically about the middle of the column, + // so the fan opens both ways rather than winding off to one side. + const twist = c.charTwist * (k - (chars - 1) * 0.5); + const ct = Math.cos(twist); + const st = Math.sin(twist); + _charRight.set( + _right.x * ct + _right.z * st, + 0, + -_right.x * st + _right.z * ct + ); + _charNormal.set( + _normal.x * ct + _normal.z * st, + 0, + -_normal.x * st + _normal.z * ct + ); + + // Character 0 is written first and sits at the top; heights are measured + // up from `baseHeight` so the bottom of the column is where it was put. + const drift = (hash11(this._seed * 1.13 + k * 5.77) - 0.5) * 2 * c.columnDrift; + _charCentre + .copy(_anchor) + .addScaledVector(_charRight, drift) + .setY(c.baseHeight + (chars - 1 - k) * pitch); + + // A calligrapher's brush is drier at the bottom of a column than at the + // top, because nothing has been added to it since the first character. + const load = + c.strokeInk * (1 - saturate(c.inkFalloff) * (chars > 1 ? k / (chars - 1) : 0)); + + for (let s = 0; s < glyph.length && slot < MAX_STROKES; s++) { + const g = glyph[s]; + const stroke = this.brush.stroke(slot); + + this._place(g, 0, 1, _charCentre, _charRight, _charNormal, em, c, _p0); + this._place(g, 2, 3, _charCentre, _charRight, _charNormal, em, c, _p1); + this._place(g, 4, 5, _charCentre, _charRight, _charNormal, em, c, _p2); + this._place(g, 6, 7, _charCentre, _charRight, _charNormal, em, c, _p3); + stroke.curve(_p0, _p1, _p2, _p3); + + // Even weight is the legibility of seal script, so the four pressure + // control points come off four sliders and the alphabet only scales + // them. A per-stroke pressure *curve* in the table was the first + // attempt and it made every character a different hand. + const w = g[8]; + stroke.pressure( + c.pressEntry * w, + c.pressSwell * w, + c.pressHold * w, + c.pressExit * w + ); + stroke.ink(load); + stroke.timing(units * invTotal, span); + stroke.active = true; + + units += 1; + slot += 1; + } + units += Math.max(0, c.charPause); + } + + this._strokeTotal = slot; + this.brush.setStrokeCount(slot); + } + + /** + * One em-space control point into world space. + * + * `columnBow` is applied here rather than to the character as a whole because + * it is a function of the point's own `x`: the middle of the em box comes + * toward the reader and the edges fall away, which is a barrel. Bowing the + * character as a rigid body just moves it, and moving it is not depth. + */ + _place(g, ix, iy, centre, right, normal, em, c, out) { + const x = g[ix]; + const y = g[iy]; + const bow = c.columnBow * (1 - 4 * x * x); + out.copy(centre); + out.addScaledVector(right, x * em); + out.y += y * em; + out.addScaledVector(normal, bow); + return out; + } + + /** + * The stroke the brush is on right now, or −1 before it starts. + * + * Walks the same timeline `_layout` built, so the emitters hang off the tip + * of the brush rather than off "somewhere in the column". + */ + _activeStroke() { + const u = this._progress(); + if (u <= 0 || this._strokeTotal <= 0) return -1; + let best = -1; + for (let i = 0; i < this._strokeTotal; i++) { + if (this.brush.headOf(i) > 0.001) best = i; + } + return best; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.dripEmitter.reset(); + this._finished = false; + + // The one thing a cast captures. Every character choice, every drift and + // every bristle's load dice comes out of it, and all of them are unitless. + this._seed = Math.random() * 100; + this.brush.roll(this._seed); + this.wash.reset(); + this.wash.roll(this._seed); + this.wash.setVisible(true); + + this._sync(1); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into the brush, the wash and the two particle + * systems. + * + * @param {number} fade 1 while the column stands, ramping to 0 as it goes + */ + _sync(fade) { + const c = settings.sealscript; + const g = settings.global; + + this._layout(c); + + /* ---------------- the writing ---------------- */ + const b = _brush; + // Recomputed here rather than cached in `_layout`, because `setPaper` + // normalises into the module's own vector and the module is the only thing + // allowed to hold it. + _dir.copy(this.direction).multiplyScalar(-1); + b.paper = _dir; + + b.width = c.strokeWidth; + b.depth = c.depth; + b.ferruleDepth = c.ferruleDepth; + b.bristleWidth = c.bristleWidth; + b.splay = c.splay; + b.wobble = c.wobble; + b.fibreScale = c.fibreScale; + + b.inkLoad = c.inkLoad; + b.flowLength = c.flowLength; + b.flowDwell = c.flowDwell; + b.speedRef = c.speedRef; + b.edgeStarve = c.edgeStarve; + b.loadJitter = c.loadJitter; + b.dryBand = c.dryBand; + b.dryThin = c.dryThin; + b.skipScale = c.skipScale; + b.skipSoft = c.skipSoft; + b.skipContrast = c.skipContrast; + + b.poolSwell = c.poolSwell; + b.poolCurve = c.poolCurve; + b.pigment = c.pigment; + + b.progress = this._progress(); + b.headTaper = c.headTaper; + b.wetLength = c.wetLength; + + b.bleed = c.bleed; + b.fibreEdge = c.fibreEdge; + b.dryPigment = c.dryPigment; + b.wetGain = c.wetGain; + b.opacity = c.opacity * g.opacity; + b.fade = fade; + b.lit = c.lit; + b.backLit = c.backLit; + b.ceiling = c.ceiling; + b.softFade = c.softFade; + b.tint = c.tint; + b.tintDensity = c.tintDensity; + b.tintJitter = c.tintJitter; + + b.colorWash = c.colorWash; + b.colorBody = c.colorBody; + b.colorInk = c.colorInk; + b.colorPool = c.colorPool; + + this.brush.update(0, b); + + /* ---------------- the wash ---------------- */ + this._anchorPoint(_centre); + this.wash.setPlacement(_centre, this.direction); + + const w = _wash; + w.radius = Math.max(0.2, c.zoneRadius); + w.height = c.washHeight; + w.spread = c.washSpread; + w.spreadPower = c.washPower; + w.edge = c.washEdge; + w.clipSoft = c.washClip; + w.sources = c.washSources; + w.sourceScatter = c.washScatter; + w.sourceDelay = c.washDelay; + w.core = c.washCore; + w.falloff = c.washFalloff; + w.film = c.washFilm; + w.granulation = c.washGranulation; + w.granScale = c.washGranScale; + w.ring = c.washRing; + w.ringWidth = c.washRingWidth; + w.dryTime = c.washDryTime; + w.wetDarken = c.washWetDarken; + w.gloss = c.washGloss; + w.glossPower = c.washGlossPower; + w.meniscus = c.washMeniscus; + w.opacity = c.washOpacity * g.opacity; + w.fade = fade; + w.ceiling = c.washCeiling; + w.softFade = c.washSoftFade; + w.tint = c.washTint; + w.tintDensity = c.washTintDensity; + w.colorWash = c.colorFilmA; + w.colorBody = c.colorFilmB; + w.colorDeep = c.colorFilmC; + w.colorPool = c.colorFilmD; + w.colorRing = c.colorFilmRing; + w.colorWet = c.colorFilmWet; + w.colorGloss = c.colorFilmGloss; + this.wash.update(this.age, w); + + /* ---------------- the two particle systems ---------------- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = c.moteOpacity * g.opacity; + // Not `* g.glow`. The global glow knob brightens the emissive schools, and + // ink is the one that must not answer it. + this.motes.uniforms.uGlow.value = c.moteGlow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.drips.setGradient( + getColor(c.colorDripA), + getColor(c.colorDripB), + getColor(c.colorDripC), + getColor(c.colorDripD) + ); + this.drips.uniforms.uGravity.value.set(0, c.dripGravity, 0); + this.drips.uniforms.uSizeScale.value = c.dripSize * g.particleSize * 7; + this.drips.uniforms.uLifeScale.value = c.dripLifetime * 0.5 * g.particleLifetime; + this.drips.uniforms.uSpeedScale.value = g.particleSpeed; + this.drips.uniforms.uOpacity.value = c.dripOpacity * g.opacity; + this.drips.uniforms.uGlow.value = 0.2; + } + + /** + * Motes off the brush and drips off the strokes it has already written. + * + * @param {number} dt + * @param {number} scale 0..1 — thinned once the brush has stopped + */ + _writeFx(dt, scale) { + const c = settings.sealscript; + const g = settings.global; + const time = frame.uTime.value; + const active = this._activeStroke(); + + /* --- motes off the tip of the brush --- */ + if (active >= 0) { + const moteCount = Math.round( + this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount + ); + if (moteCount > 0) { + this.brush.tipPoint(active, _pos); + _emit.position = _pos; + _emit.radius = Math.max(0.02, c.strokeWidth * 2.5); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + } + + /* --- drips off a stroke that has already been laid down --- */ + const written = active >= 0 ? active + 1 : this._strokeTotal; + if (written > 0) { + const dripCount = Math.round( + this.dripEmitter.tick(dt, c.dripRate * scale) * g.particleCount + ); + if (dripCount > 0) { + // Off the *bottom* of a random written stroke, because that is where + // ink goes. Picking a uniform point along the stroke put droplets + // leaving the top of a horizontal bar and falling through it. + const i = Math.floor(Math.random() * written) % Math.max(1, this._strokeTotal); + const t = 0.35 + Math.random() * 0.6; + this.brush.pointAt(i, Math.min(t, this.brush.headOf(i)), _pos); + _emit.position = _pos; + _emit.radius = Math.max(0.015, c.strokeWidth * 1.5); + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.dripSpeed; + _emit.speedVariance = 0.6; + _emit.spread = 0.25; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.dripLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.drips.emit(dripCount, _emit); + } + } + } + + /** + * The last stroke lands. One shot, tested against the live beats every frame. + * + * One-way, like `runeseal`'s: dragging `writeTime` upward after the column + * has finished would otherwise push the threshold back past the clock and let + * the flourish fire again, which is a burst of droplets out of nowhere. + */ + _checkFinish() { + if (this._finished) return; + if (this._progress() < 0.999) return; + + this._finished = true; + const c = settings.sealscript; + const g = settings.global; + + this._anchorPoint(_pos); + _pos.y = c.baseHeight; + + _emit.position = _pos; + _emit.radius = Math.max(0.05, c.emSize * 0.5); + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.dripSpeed * 1.4; + _emit.speedVariance = 0.8; + _emit.spread = 0.6; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.85; + _emit.life = c.dripLifetime * 1.2; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.drips.emit(Math.round(c.dripBurst * g.particleCount), _emit); + + this.ctx.shake.add( + c.finishShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 13 + ); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + /** The lamp rides the brush, and settles at the foot of the column after. */ + _placeLight(c) { + const active = this._activeStroke(); + if (active >= 0) { + this.brush.tipPoint(active, this.position); + return; + } + this._anchorPoint(this.position); + this.position.y = c.baseHeight; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.sealscript; + this._sync(1); + // The beats run off the cast's own age, not off the phase machine, so a + // column planted at four metres writes at the same rate as one at sixteen. + this._checkFinish(); + this._placeLight(c); + this._writeFx(dt, 1); + } + + onImpact() { + // Nothing fires here. The brush arriving is not one of the column's beats — + // `leadTime` is, and it is measured from the muzzle. + this._sync(1); + } + + onFade(dt, t) { + const c = settings.sealscript; + this._checkFinish(); + + // `t` runs 0..1 while the column stands, then 1..2 while it goes. Cubic on + // the way out so it hangs on and then lets go: dimming evenly reads as a + // light being turned down, and there is no light in this. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._sync(fade); + + this._placeLight(c); + // The brush keeps shedding until the writing is done, and then only drips. + this._writeFx(dt, this._finished ? lerp(0.25, 0, saturate(t - 1)) : 1); + if (!this._finished) this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onDestroy() { + this._finished = false; + this._strokeTotal = 0; + this.brush.reset(); + this.wash.reset(); + } + + dispose() { + this.brush.dispose(); + this.wash.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/ink/SplatterbrandAbility.js b/src/abilities/ink/SplatterbrandAbility.js new file mode 100644 index 0000000..602ac3c --- /dev/null +++ b/src/abilities/ink/SplatterbrandAbility.js @@ -0,0 +1,825 @@ +import { MeshStandardMaterial, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { InkDiffusion, InkMode, inkDiffusionParams } from '../../vfx/InkDiffusion.js'; +import { Projectile, FlightMode, Stagger, projectileParams } from '../../vfx/Projectile.js'; +import { createAsteroidGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, Easing, randRange } from '../../utils/math.js'; + +/** + * Satellite slots the shader carries, and therefore the ceiling on the + * `satellites` slider. Sixteen teardrops is where the far field stops looking + * countable; the fragment walks the list once, so the cost is linear and the + * quad is not what decides this number — the *readability* is. Past about + * twenty the power law stops being visible because every size is present. + */ +const MAX_SATELLITES = 16; + +/** The mass plus its satellites. One `Projectile` body each. */ +const MAX_BODIES = MAX_SATELLITES + 1; + +/** Nuclei the mass is allowed to break into. */ +const MAX_SOURCES = 3; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _mark = inkDiffusionParams(); +const _flight = projectileParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hit = new Vector3(); +const _bead = new Vector3(); + +/** + * SPLATTERBRAND — a loaded brush flung down the line. + * + * ## The trick: splatter morphology + * + * A thrown blob does not make a circle, and the three ways it fails to are all + * separately identifiable: + * + * - **a directional main mass.** The anisotropy lives in the *metric* — the + * distance function is measured in a stretched frame — and never in a + * bearing-indexed radius. Writing the front as `f(bearing)` hands every + * point along a bearing the same answer and the mass grows flat facets down + * its flanks, which is the single most common way this effect is got wrong. + * The trailing edge is blunter than the leading one (`massRear`), because + * the trailing edge is where the sheet tore away rather than where it spread + * to. + * - **a crown of teeth on the leading arc.** These *are* bearing-indexed, and + * that is correct: a crown is a Rayleigh–Plateau breakup of an expanding rim, + * so it genuinely is periodic in bearing, its teeth genuinely are radial, and + * its tooth count genuinely does rise with the rim's radius. The rule was + * never "never index on bearing", it was "do not index on bearing when the + * physics is areal". + * - **satellite droplets**, thrown further along the travel vector, **sized by + * a bounded Pareto law**, with the *small* ones flying furthest because they + * detach last from the fastest part of the sheet and carry the least drag + * per unit mass. That last coupling is the recognition cue. Draw the sizes + * uniformly instead and you get a dozen identical dots that the eye files as + * a stencil; couple distance to the same draw and the far field goes fine + * while the near field stays coarse, which is what a splatter looks like. + * + * All three live in `vfx/InkDiffusion.js`'s `SPLATTER` mode. What lives *here* + * is the half of the idea the shader cannot do on its own. + * + * ## Every droplet is flown, and it lands in its own mark + * + * The obvious build is a splat decal plus a handful of projectiles thrown at + * roughly the same area. It was the first build, and it is broken in a way that + * is impossible to un-see once noticed: two independent scatters means drops + * landing where there is no mark and marks appearing where no drop went. + * + * `InkDiffusion` uploads its satellite dice as **uniform arrays** rather than + * hashing them in the shader precisely so this cannot happen — so the CPU can + * ask, exactly, where droplet *i* is going and when. This ability therefore + * flies `satellites + 1` bodies whose endpoints and clocks are *derived from + * the mark*: + * + * ``` + * offset_i = satellitePoint(i) − impact // metres, from the mark + * + * spreadForward = throwFar depth_i = offset_i · direction / throwFar + * spreadSide = throwFar · throwSpread lateral_i = offset_i · side / spreadSide + * radius = 1, sizeJitter = 1 size_i = R_i − 1 + * flightTime = length / speed speed_i = (landing lateness) / T + * ``` + * + * Every one of those right-hand sides is **unitless** — a ratio of two live + * metres or two live seconds — and every one is recomputed from settings after + * `InkDiffusion#update()` on the same frame, on a zero-length frame included. + * The `radius = 1, sizeJitter = 1` pair is the module's size law set to the + * identity on purpose: the power law belongs to the mark and there must be + * exactly one copy of it in the ability. Drag `satAlpha` with the clock stopped + * and the drops still in the air resize and re-aim along with the marks they + * are going to make. + * + * The handover is the point of the whole arrangement. A body's `linger` and + * `sink` are set so it goes *into* the floor over the same tenth of a second + * its mark pops in — the drop does not disappear and the splash does not appear + * from nowhere; the drop becomes the splash. + * + * ## One clock + * + * The projectile is never given the wall clock. During the travel phase it is + * given `u · T`, where `T = length / speed` — so the mass is exactly where the + * base class's front is, and it arrives on the frame the base class enters + * `IMPACT`. After that it is given `T + (age − landAt)`, which is continuous + * across the handover because `u = 1` there, and which keeps running while the + * satellites are still in the air. Two clocks in one ability is how a + * projectile ends up landing twice. + * + * ## The school with no bloom in it + * + * No flash, no burst shell, no additive particle, no decal. The mark clamps its + * own luminance below `post.bloomThreshold`; the trail is `NormalBlending` with + * pigment-dark stops, so even with `global.glow` pinned it stays an order of + * magnitude under that threshold. The one specular term is the wet gloss on the + * leading edge, and it is inside the clamp. + * + * ## What a cast captures + * + * One seed, one timestamp, and the unitless dice the two modules hold. Not one + * metre. + * + * **Three draw calls** — the mark, the bodies, the ligaments — plus two shared + * particle systems. + */ +export class SplatterbrandAbility extends Ability { + constructor(context) { + super('splatterbrand', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The mark. `SPLATTER` compiles the mass metric, the crown and the + * satellite loop into the program; a `BLOOM` would carry none of them. + */ + this.mark = new InkDiffusion(this.group, { + mode: InkMode.SPLATTER, + sources: MAX_SOURCES, + satellites: MAX_SATELLITES, + layer: LAYER.VFX, + renderOrder: 6, + name: 'splatterbrand.mark' + }); + + /** + * The bodies in the air. + * + * A plain `MeshStandardMaterial` rather than a bespoke shader, and that is + * a decision rather than a shortcut: ink is a *matte dielectric*, which is + * the one material the standard model is already exactly right about. Every + * bespoke material in this project exists because something about it could + * not be expressed as roughness and albedo — refraction, emission, a + * blackbody curve — and none of those is true of a blob of pigment. + * `metalness` ships at zero and stays there. + */ + this.blobMaterial = new MeshStandardMaterial({ + color: 0x0e0d13, + roughness: 0.36, + metalness: 0, + flatShading: false + }); + + this.body = new Projectile(this.group, { + // A factory, not a geometry: the module owns the buffer, dresses it with + // its per-instance attributes and rebuilds it when `shapeKey()` moves. + geometry: () => this._buildBlob(), + shapeKey: () => this._shapeKey(), + material: this.blobMaterial, + capacity: MAX_BODIES, + trail: true, + trailNodes: 20, + // The one flag that keeps this school honest in this module. Additive + // ligaments over a matte mark is the failure mode of the whole school. + trailAdditive: false, + layer: LAYER.WORLD, + renderOrder: 3, + castShadow: true + }); + + /* --- what a cast captures: one seed and one timestamp --- */ + /** Unitless. Decorrelates the fingers, the nuclei and the droplet draw. */ + this._seed = 0; + /** True once the mass has landed. One-way. */ + this._landed = false; + /** The moment it did, on the cast's own clock. An event, not a duration. */ + this._landAt = 0; + /** Signature of the silhouette controls, so a rebuild only follows a change. */ + this._shapeHash = ''; + } + + createParticles() { + const particles = this.ctx.particles; + + /* + * The spatter thrown off a landing, and the only thing in this ability that + * is emitted per event rather than per second. + * + * `additive: false`, like everything else here. The first pass had these + * additive out of habit and a landing droplet threw a small orange firework, + * which is precisely the reading this school exists to avoid. + */ + this.spatter = particles.get('splatterbrand.spatter', { + capacity: 1200, + shape: ParticleShape.SOFT, + additive: false, + softFade: 0.2 + }); + this.spatter.uniforms.uDrag.value = 1.1; + this.spatter.uniforms.uEndSize.value = 0.4; + this.spatter.uniforms.uSizeIn.value = 0.03; + this.spatter.uniforms.uFadeIn.value = 0.04; + this.spatter.uniforms.uFadeOut.value = 0.55; + + // Atomised ink over the mark. Non-additive so it genuinely occludes what it + // is standing on. + this.mist = particles.get('splatterbrand.mist', { + capacity: 700, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.mist.uniforms.uDrag.value = 2.0; + this.mist.uniforms.uEndSize.value = 2.6; + this.mist.uniforms.uSizeIn.value = 0.18; + this.mist.uniforms.uFadeIn.value = 0.22; + this.mist.uniforms.uFadeOut.value = 0.38; + + this.mistEmitter = new RateEmitter(); + } + + /** One blob, in unit space — `Projectile` scales it by the body radius. */ + _buildBlob() { + const c = settings.splatterbrand; + return createAsteroidGeometry({ + seed: 4.1, + detail: clamp(Math.round(c.blobFacets), 0, 2), + lumpiness: c.blobLumps, + noiseScale: c.blobLumpScale, + roughness: c.blobRough, + // No cuts and no craters: those are what make the same generator read as + // *rock*, and a blob of ink is the one thing in the sandbox with no + // fracture surfaces at all. + cuts: 0, + cutDepth: 0, + craters: 0, + craterDepth: 0, + craterSize: 0.4 + }); + } + + /** Everything `_buildBlob` reads, as one string. A change rebuilds. */ + _shapeKey() { + const c = settings.splatterbrand; + return `${Math.round(c.blobFacets)}|${c.blobLumps}|${c.blobLumpScale}|${c.blobRough}`; + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.body.count; + } + + /** + * A beat's length in seconds, under the global lifetime knob. + */ + _span(seconds) { + return Math.max(0.005, seconds * settings.global.lifetime); + } + + /** + * The impact phase has to outlast the *last droplet*, not just the mass. + * + * The first version returned `holdTime` alone and the furthest satellites + * were still half a second from the floor when the phase machine moved on — + * so the fade started eating the mark while a third of the pattern had not + * arrived. Adding the throw window here is not padding; it is the statement + * that the cast is not over until the far field is down. + */ + get impactDuration() { + const c = settings.splatterbrand; + return this._span(c.satDelay + c.satJitter + c.holdTime); + } + + get fadeDuration() { + return Math.max(0.05, settings.splatterbrand.fadeTime); + } + + /** A wet mark swells under a moving light; it does not gutter. */ + lightShimmer() { + const c = settings.splatterbrand; + return 1 - saturate(c.lightPulse) * 0.5 * (1 - Math.cos(this.age * c.lightPulseSpeed)); + } + + /* ------------------------------------------------------------------ */ + /* The one clock */ + /* ------------------------------------------------------------------ */ + + /** + * Seconds the mass spends in the air, resolved from the cast rather than + * carried as its own slider. + * + * Deriving it from `length / speed` is what keeps the base class's front and + * the projectile's body in the same place: `speed` already means metres per + * second everywhere else in the sandbox and a second, independent `flightTime` + * would be one more number that has to agree with it and eventually will not. + */ + _throwTime() { + const c = settings.splatterbrand; + return Math.max(0.05, this.length / Math.max(0.2, c.speed * settings.global.speed)); + } + + /** + * The projectile's clock. Monotone across the phase handover by construction: + * at the moment of impact `u` is 1 and `age − landAt` is 0, so both branches + * agree on `T`. + */ + _flightClock() { + const throwTime = this._throwTime(); + if (this.phase === AbilityPhase.TRAVEL) return this.u * throwTime; + return throwTime + this._soak(); + } + + /** Seconds since the mass landed. The mark's whole clock. */ + _soak() { + return this._landed ? Math.max(0, this.age - this._landAt) : 0; + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the mass lands: the far end of the aimed line, on the floor. */ + _impactPoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** How many droplets are live this frame. */ + _satelliteCount() { + return clamp(Math.round(settings.splatterbrand.satellites), 0, MAX_SATELLITES); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.mistEmitter.reset(); + + this._landed = false; + this._landAt = 0; + + // The one thing a cast captures, and it is unitless. Both modules are + // rolled from it: the mark's dice decide where the droplets go and the + // body's dice decide how they tumble on the way. + this._seed = Math.random() * 100; + this.mark.roll(this._seed); + this.body.roll(this._seed); + + this._sync(1); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into the mark, then derive the flight from the mark, + * then push both into the particle systems. + * + * **Order matters and is the whole mechanism.** `InkDiffusion`'s CPU mirrors + * (`satellitePoint`, `satelliteSize`, `satelliteReach`, `satelliteAge`) read + * the params it last resolved, so they are only correct *after* its + * `update()` on the same frame. Reading them first gives last frame's answer, + * which is invisible at sixty frames per second and catastrophic on a paused + * slider drag — the drops would trail the marks by exactly one edit. + * + * @param {number} fade 1 while the mark stands, ramping to 0 as it soaks away + */ + _sync(fade) { + const c = settings.splatterbrand; + const g = settings.global; + + this._impactPoint(_hit); + const satellites = this._satelliteCount(); + + /* ---------------- the mark ---------------- */ + const m = _mark; + m.radius = Math.max(0.2, c.markRadius); + m.height = c.markHeight; + + m.spread = c.spread; + m.spreadPower = c.spreadPower; + m.edge = c.edge; + m.clipSoft = c.clipSoft; + m.sources = c.sources; + m.sourceScatter = c.sourceScatter; + m.sourceDelay = c.sourceDelay; + + m.finger = c.finger; + m.fingerMax = c.fingerMax; + m.coarse = c.coarse; + m.onset = c.onset; + m.growth = c.growth; + m.growthMax = c.growthMax; + + m.core = c.core; + m.falloff = c.falloff; + m.film = c.film; + m.granulation = c.granulation; + m.granScale = c.granScale; + m.ring = c.ring; + m.ringWidth = c.ringWidth; + + m.dryTime = c.dryTime; + m.wetDarken = c.wetDarken; + m.gloss = c.gloss; + m.glossPower = c.glossPower; + m.meniscus = c.meniscus; + + m.massAlong = c.massAlong; + m.massAcross = c.massAcross; + m.massLead = c.massLead; + m.massRear = c.massRear; + m.crown = c.crown; + m.crownSpacing = c.crownSpacing; + m.crownSharp = c.crownSharp; + m.crownGate = c.crownGate; + + m.satellites = satellites; + m.satMin = c.satMin; + m.satMax = c.satMax; + m.satAlpha = c.satAlpha; + m.throwNear = c.throwNear; + m.throwFar = c.throwFar; + m.throwCurve = c.throwCurve; + m.throwSpread = c.throwSpread; + m.satTail = c.satTail; + m.satDelay = c.satDelay; + m.satJitter = c.satJitter; + m.satPop = c.satPop; + + m.opacity = c.markOpacity * g.opacity; + m.fade = fade; + m.ceiling = c.markCeiling; + m.softFade = c.markSoftFade; + m.tint = c.markTint; + m.tintDensity = c.markTintDensity; + m.colorWash = c.colorMarkThin; + m.colorBody = c.colorMarkBody; + m.colorDeep = c.colorMarkDeep; + m.colorPool = c.colorMarkPool; + m.colorRing = c.colorMarkRing; + m.colorWet = c.colorMarkWet; + m.colorGloss = c.colorMarkGloss; + + // The field's local +z is the travel vector: the axis the mass stretches + // down, the arc the crown is gated to, and the direction the droplets fly. + this.mark.setPlacement(_hit, this.direction); + this.mark.update(this._soak(), m); + + /* ---------------- the flight, derived from the mark ---------------- */ + this._syncFlight(satellites); + + /* ---------------- the two particle systems ---------------- */ + this.spatter.setGradient( + getColor(c.colorSpatterA), + getColor(c.colorSpatterB), + getColor(c.colorSpatterC), + getColor(c.colorSpatterD) + ); + this.spatter.uniforms.uGravity.value.set(0, c.spatterGravity, 0); + this.spatter.uniforms.uSizeScale.value = c.spatterSize * g.particleSize * 7; + this.spatter.uniforms.uLifeScale.value = c.spatterLifetime * 0.5 * g.particleLifetime; + this.spatter.uniforms.uSpeedScale.value = g.particleSpeed; + this.spatter.uniforms.uOpacity.value = g.opacity; + + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = 0.45 * g.turbulence; + } + + /** + * Fly the handful, with every endpoint and every clock taken off the mark. + * + * Body 0 is the mass and lands at the aim point. Bodies 1..n are the + * satellites, and the four dice written per body below are the whole of the + * coupling. They are **written every frame**, not at spawn: they are unitless + * ratios of live settings, and re-deriving them is what lets a paused + * `throwFar` drag move the drops in the air along with the marks. + * + * @param {number} satellites live droplets this frame + */ + _syncFlight(satellites) { + const c = settings.splatterbrand; + const g = settings.global; + const p = _flight; + const throwTime = this._throwTime(); + const throwFar = Math.max(0.05, c.throwFar); + // The lateral half-cone at the furthest droplet. Guarded, because + // `throwSpread` at zero is a legitimate authoring choice (a perfectly + // collimated throw) and a zero divisor here would NaN every body at once. + const spreadSide = Math.max(1e-3, throwFar * c.throwSpread); + + p.mode = FlightMode.ARC; + // NONE, because the arrival order is not this module's to decide: it comes + // out of the mark's own `satDelay` law, applied through the per-body flight + // times below. + p.stagger = Stagger.NONE; + p.count = 1 + satellites; + + // The size law set to the identity, so `dice.size` carries the metre. See + // the class comment — the power law belongs to the mark. + p.radius = 1; + p.sizeJitter = 1; + p.stretch = c.dropStretch; + p.align = 1; // lay the long axis along the heading + p.spin = c.dropSpin * g.randomness; + p.flash = c.dropFlash; + + p.handForward = c.handForward; + p.handSide = c.handSide; + p.handHeight = c.handHeight; + + p.landInZone = false; + p.landHeight = c.markHeight; + p.spreadForward = throwFar; + p.spreadSide = spreadSide; + + p.pathCurve = c.pathCurve; + p.apex = c.apex; + p.apexCurve = c.apexCurve; + p.weaveSide = 0; + p.weaveUp = 0; + p.weaveDecay = 1; + + // The flight-time law set to the identity in the same way: `dice.speed` + // carries "how many throw-times late this droplet is". + p.flightTime = throwTime; + p.speedJitter = 1; + p.lead = 0; + p.window = 0; + p.linger = c.dropLinger; + p.sink = c.dropSink; + + p.trailSpan = c.trailSpan; + p.trailBurn = c.trailBurn; + p.trailWidth = c.trailWidth; + p.trailTaper = c.trailTaper; + p.trailLift = c.trailLift; + p.trailOpacity = c.trailOpacity * g.opacity; + // Not a glow. The trail is `NormalBlending` here, so this only scales the + // pigment down; the stops are dark enough that `global.glow` at its maximum + // still leaves the ligaments an order of magnitude under the bloom + // threshold. The multiply is the module's and cannot be removed from here. + p.trailGlow = c.trailGlow * g.glow; + p.trailCore = c.trailCore; + p.trailHeadBias = c.trailHeadBias; + p.trailNoise = c.trailNoise * g.noiseStrength; + p.trailNoiseScale = c.trailNoiseScale * g.noiseFrequency; + p.trailNoiseSpeed = c.trailNoiseSpeed * g.noiseSpeed; + p.trailSoftFade = c.trailSoftFade; + + /* --- the coupling: four unitless dice per body --- */ + const dice = this.body.dice; + + // The mass. Dead centre of the aim point, on time, at its own radius. + const mass = dice[0]; + mass.depth = 0; + mass.lateral = 0; + mass.size = Math.max(0.005, c.massRadius) - 1; + mass.speed = 0; + + for (let i = 0; i < satellites; i++) { + const d = dice[i + 1]; + /* + * `satellitePoint` is the authority on where the mark is, so the offset + * is *projected out of it* rather than recomputed from the dice. There is + * then exactly one expression of the lateral cone in the ability, and no + * second copy of it to get the sign of wrong. + * + * Which is not hypothetical. The first version rebuilt the offset by hand + * as `-(dice.y − 0.5)·2·throwSpread·reach`, on the reasoning that the + * field's local +x is `-side`. It is — but the sign that matters here is + * the one on the *projection*, and negating a projection that has already + * absorbed the axis flip mirrors every droplet across the cast line. The + * marks and the drops were then in mirror-image scatters that agreed + * exactly on the middle one, which is the worst possible way for a bug + * like this to present. + */ + this.mark.satellitePoint(i, _bead); + _pos.copy(_bead).sub(_hit); + + d.depth = _pos.dot(this.direction) / throwFar; + d.lateral = _pos.dot(this.side) / spreadSide; + d.size = Math.max(0.005, this.mark.satelliteSize(i) * c.dropScale) - 1; + // `satelliteAge(i)` is seconds since the droplet's mark appeared, read + // against the mark's current clock — so `age − clock` is how late it is, + // and dividing by the throw time turns it into the module's jitter + // fraction. Positive means it lands after the mass, which all of them do. + d.speed = (this._soak() - this.mark.satelliteAge(i)) / throwTime; + } + + this.body.setBasis(this.origin, this.direction, this.side, this.length); + this.body.setTrailColors(c.colorTrailA, c.colorTrailB, c.colorTrailC, c.colorTrailD); + this.body.update(this._flightClock(), p); + + this.blobMaterial.color.copy(getColor(c.colorBlob)); + this.blobMaterial.roughness = c.blobRoughness; + this.blobMaterial.metalness = c.blobMetalness; + } + + /** + * Everything a body does the instant it arrives. + * + * Read straight after `update()`: an arrival is *re-derived*, not remembered, + * so a slider that puts a droplet back in the air clears its flag and it + * arrives again — which is the correct behaviour for a paused editor and is + * why the spatter is a burst rather than a latch. + */ + _landings() { + const c = settings.splatterbrand; + const g = settings.global; + const count = this.body.arrivalCount; + if (count <= 0) return; + const time = frame.uTime.value; + + for (let i = 0; i < count; i++) { + const index = this.body.arrivals[i]; + this.body.landPoint(index, _pos); + _pos.y = c.markHeight + 0.03; + + // The mass throws a lot; a 5 cm droplet throws almost nothing. Scaling by + // the body's own radius against the mass's is what keeps the far field + // from sounding as loud as the near field. + const scale = + index === 0 ? 1 : saturate(this.mark.satelliteSize(index - 1) / Math.max(0.02, c.satMax)); + const number = index === 0 ? c.spatterMassCount : c.spatterCount * (0.35 + 0.65 * scale); + + _emit.position = _pos; + _emit.radius = 0.04 + 0.12 * scale; + // Up and forward: a drop that lands moving forward throws its spatter + // forward. Straight up made every landing read as a stamp. + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.45).setY(1).normalize(); + _emit.speed = c.spatterSpeed * (0.5 + 0.5 * scale); + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.4 + 0.6 * scale; + _emit.sizeVariance = 0.7; + _emit.life = c.spatterLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spatter.emit(Math.round(number * g.particleCount), _emit); + } + } + + /** + * The mist over the mark, thinned as it dries. + * + * Keyed off the wetness rather than off the phase: `dryTime` is what decides + * how long a film stays glossy, so it is what should decide how long there is + * anything in the air above it. Dragging `dryTime` up on a paused mark starts + * the mist again. + * + * @param {number} dt + * @param {number} scale 0..1 — thinned as the mark soaks away + */ + _mistFx(dt, scale) { + const c = settings.splatterbrand; + const g = settings.global; + const wet = Math.exp(-this._soak() / Math.max(0.05, c.dryTime)); + const count = Math.round(this.mistEmitter.tick(dt, c.mistRate * scale * wet) * g.particleCount); + if (count <= 0) return; + + this._impactPoint(_pos); + const reach = Math.max(0.3, this.mark.frontRadius(0)); + _pos.x += randRange(-reach, reach) * 0.7; + _pos.z += randRange(-reach, reach) * 0.7; + _pos.y = randRange(0.08, 0.45); + + _emit.position = _pos; + _emit.radius = reach * 0.25; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.25; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.mist.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + // Nothing has landed yet, but the list is consumed anyway: a very short + // cast can put the mass across τ = 1 on the same frame the base class is + // still calling this, and an unconsumed arrival is one that never fires. + this._landings(); + + // The light rides the blob rather than the floor under it. Asked by τ + // rather than by draw slot: `slotPosition` indexes the *drawn* bodies and + // is undefined when none are, which on a one-frame cast is a crash. + // Body 0's τ is the ability's own `u` by construction — that is what + // `_flightClock` is for. + this.body.pointAt(0, this.u, this.position); + + this.ctx.shake.rumble(settings.splatterbrand.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.splatterbrand; + const g = settings.global; + + // The one timestamp a cast records past the seed. + this._landed = true; + this._landAt = this.age; + + this._impactPoint(_pos); + this.position.copy(_pos); + this.position.y = 0.2; + + // No flash and no burst. A brush hitting the floor is a wet slap and a + // kick, and the two pieces of impact vocabulary this sandbox reaches for + // first are both emissive. + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + + _pos.y = c.markHeight + 0.1; + _emit.position = _pos; + _emit.radius = 0.3; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.mistSpeed * 2.4; + _emit.speedVariance = 0.75; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.3; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime * 1.2; + _emit.lifeVariance = 0.45; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.mist.emit(Math.round(c.mistCount * g.particleCount), _emit); + + this._sync(1); + this._landings(); + } + + onFade(dt, t) { + /* + * `t` runs 0..1 while the far field is still arriving and the mark stands, + * then 1..2 while it soaks away. + * + * `inQuad` rather than the emissive schools' `inCubic`: a light being + * switched off hangs and then goes, and a stain does not — it thins evenly + * as the water leaves it. + */ + const fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + this._sync(fade); + this._landings(); + + this._impactPoint(this.position); + this.position.y = 0.2; + + this._mistFx(dt, fade); + this.ctx.shake.rumble( + settings.splatterbrand.rumble * fade * 0.5 * settings.global.cameraShake, + dt + ); + } + + onDestroy() { + this._landed = false; + this._landAt = 0; + this.mark.reset(); + this.body.reset(); + } + + dispose() { + this.mark.dispose(); + this.body.dispose(); + this.blobMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/ink/SumistrokeAbility.js b/src/abilities/ink/SumistrokeAbility.js new file mode 100644 index 0000000..d491810 --- /dev/null +++ b/src/abilities/ink/SumistrokeAbility.js @@ -0,0 +1,840 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { BrushStroke, BrushTip, brushStrokeParams } from '../../vfx/BrushStroke.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing } from '../../utils/math.js'; + +/* ---------------------------------------------------------------------- */ +/* Constants that are shapes, not dimensions */ +/* ---------------------------------------------------------------------- */ + +/** + * The three marks the brush makes, in the order the ferrule slots are laid out. + * + * They are slot indices rather than a loop because each one is authored + * differently — a tuck is not a scaled stroke — and because the slot is where a + * bristle's dice live. Compacting or reordering these would silently re-roll + * which bristle runs out where, which is the one thing about this ability that + * has to stay put between frames. + */ +const TUCK = 0; +const MAIN = 1; +const SHOULDER = 2; + +/** + * Bristles in the ferrule. + * + * This is the resolution of the fray and nothing else, and it wants to be high: + * at twelve the tail came apart into twelve obviously-parallel ribbons and read + * as a comb. At twenty-four the streaks are narrow enough that the eye stops + * counting them and starts reading fibre. Above about thirty-two the extra + * bristles are thinner than a pixel at any sensible camera distance and only + * cost fill. + */ +const BRISTLES = 24; + +/** + * Samples down one spine. The stroke is up to twenty-odd metres long and bends + * once, so this is set by how smooth the *swelling* has to be rather than by + * the curvature — a coarse strip makes the pool at the tuck polygonal. + */ +const SAMPLES = 56; + +/** + * Steps in the CPU runout integral. `BrushStroke` uses a 13-point trapezoid in + * the vertex shader; this is the same integral marched forward so it can be + * *inverted* — the shader asks "how much ink is left here", this asks "where + * did it run out" — and it needs a few more steps because a forward march that + * overshoots reports the fray a whole segment late. + */ +const RUNOUT_STEPS = 28; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _brush = brushStrokeParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _from = new Vector3(); +const _to = new Vector3(); +const _c1 = new Vector3(); +const _c2 = new Vector3(); +const _deriv = new Vector3(); +const _leg = new Vector3(); +const _paperUp = new Vector3(0, 1, 0); + +/** + * SUMI STROKE — one enormous brushstroke dragged down the aimed line, which + * runs out of ink before it gets to the end. + * + * **The trick is dry brush, and dry brush is a supply problem.** The ferrule is + * twenty-four separately-modelled bristles (`vfx/BrushStroke.js`), each holding + * its own load of pigment measured in *metre-pigment* — the length of nominal + * full-pressure stroke it can lay before it is empty. Each spends its own load + * against the pressure curve and against the brush's own speed, and once a + * bristle is nearly out it starts making intermittent contact with the paper. + * The tail therefore comes apart into separated fibre streaks that stop at + * twenty-four different places, with clean floor between them. + * + * The first version of this slot did the obvious thing and multiplied an even + * ribbon by a noise field. It is wrong in a way you can see from across the + * room. A mask puts *holes in a stroke*; dry brush is several marks that used + * to be one mark. The mask also breaks up uniformly across the width, because + * noise does not know where the middle of the brush is, whereas a real brush + * keeps ink in the core long after the outside bristles have given up — which + * is `edgeStarve`, and it is the reason the fray opens from the edges inward. + * + * ### The causal chain, which is the point + * + * Nothing in this ability says "put the fray here". The fray is where the ink + * ran out, and the ink runs out because of three things you can drag: + * + * 1. `mainLoad` × `inkLoad` — what is in the ferrule. + * 2. `entryDwell` — how close the spine's second control point sits to its + * first. Small means the brush *dwells* at the tuck: `|B'(t)|` collapses + * there, `BrushStroke`'s dwell term blows up, ink pools into a heavy head — + * and that pool is paid for out of the same ferrule. A fat entry buys you a + * short stroke, exactly as it does on paper. + * 3. `mainSwell` / `mainHold` — how hard the body presses. + * + * Reach for `dryBand` to move the fray and you are lying; `dryBand` is how + * *softly* a bristle lets go, not where. + * + * ### Three marks, one brush + * + * - **the tuck (起筆)** — the brush is set down and pushed *back* against the + * direction of travel before being dragged forward. That reversal is why a + * sumi stroke has a blunt, heavy head rather than a point, and because the + * tuck is drawn as its own short mark with its own ferrule slot it pools + * like one instead of being a bulge in a slider. + * - **the stroke** — the gesture, bowed, from the set-down point to a little + * past the target. + * - **the shoulder streak** — what the top edge of the ferrule left behind: a + * thin low-pressure mark offset laterally, carrying a third of the load, so + * it dies about a third of the way down. Individual ink loads running out at + * different points, at a scale you can see from the default camera. + * + * ### The CPU runout mirror + * + * `_measureRunout()` marches the same deposition integral the vertex shader + * runs and inverts it, giving two numbers every frame: `_frayT`, where the + * median bristle starts letting go, and `_dryT`, where it is bone dry. Those + * place the two particle systems — pigment mist only on the wet part, torn + * paper fibre (飛白, "flying white") only past the fray — so the particles are a + * *consequence* of the dry brush rather than something that happens to be near + * the end of the line. Change the integral in one place and it must change in + * the other; that is the price of a mirror and it is worth paying, because the + * alternative is an authored `frayStart` slider that goes out of sync with the + * mark the moment anybody touches the pressure curve. + * + * ### Two things that cost an evening + * + * **`softFade`.** The mark lies three centimetres above the floor and + * `BrushStroke`'s fragment feathers against the scene depth over + * `softFade` metres. At the module's shipped 0.12 m the entire stroke came out + * at about a quarter alpha and read as a grey smear; the default here is + * 0.035 m, which is enough to stop it z-fighting the floor's own relief and not + * enough to erase it. Anything lying *on* something wants this small. + * + * **No bloom, arithmetically.** Ink is the anti-glow school. `BrushStroke` + * clamps its output linear luminance to `ceiling` (0.62) against + * `post.bloomThreshold` (0.88), it is `toneMapped: true`, and it never + * multiplies by `uGlobalGlow`. The three particle systems here are all + * non-additive with `uGlow` held under 0.35 and dark gradients, because a + * particle system is the one part of this slot that could still halo. + * + * ### The rule that makes the editor work + * + * A cast captures one number — `_seed`, so no two strokes fray the same way — + * plus the timestamps the phase machine already keeps. Not one metre, radian or + * second is recorded. The ferrule width, the bow, the tuck's angle, the ink in + * the brush and the position of every control point re-resolve from + * `settings.sumistroke` inside the update loop, on a zero-length frame + * included. Pause with **P** half way down the stroke and drag `mainLoad`: the + * fray walks along the mark, and the flying white walks with it because the + * runout mirror re-ran too. + */ +export class SumistrokeAbility extends Ability { + constructor(context) { + super('sumistroke', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * One brush, three marks, one draw call. + * + * `FLAT` because a hake lays its bristles in a single rank across the + * width, and a single rank is what gives a dry tail clean parallel + * streaks. `ROUND` spreads them on a disc, which is right for seal script + * and wrong here: the streaks separate through the paper normal as well and + * on a mark lying flat on the floor that separation is invisible, so all it + * does is make the fray muddier. + */ + this.brush = new BrushStroke(this.group, { + strokes: 3, + bristles: BRISTLES, + samples: SAMPLES, + sides: 6, + tip: BrushTip.FLAT, + renderOrder: 6, + name: 'SumiStroke' + }); + this.brush.setPaper(_paperUp); + + /** The one thing a cast captures. Everything else resolves per frame. */ + this._seed = 0; + + /** + * Where the median bristle starts letting go, and where it is spent, as + * parameters along the main spine. Both are *outputs* of + * `_measureRunout()`, recomputed every frame from live settings — they are + * never authored and never survive a frame. + */ + this._frayT = 1; + this._dryT = 1; + + /** Fired once, on the frame the brush comes off the paper. */ + this._lifted = false; + } + + createParticles() { + const particles = this.ctx.particles; + + /* + * Every system here is non-additive. That is not a preference: additive + * blending cannot produce a pigment, only a light, and the whole argument + * of this school is that ink sits *on* the world rather than shining out of + * it. `uGlow` is driven from a slider that tops out at 1 and defaults to a + * third, so a curious editor can prove the point rather than being told it. + */ + + // Pigment mist off the wet part of the mark: the fine spray a loaded brush + // throws sideways when it is dragged fast. SOFT rather than SMOKE because + // smoke's fbm erosion reads as combustion, and this is water. + this.mist = particles.get('sumistroke.mist', { + capacity: 900, + shape: ParticleShape.SOFT, + additive: false, + curl: true, + softFade: 0.5 + }); + this.mist.uniforms.uDrag.value = 2.4; + this.mist.uniforms.uEndSize.value = 1.5; + this.mist.uniforms.uSizeIn.value = 0.1; + this.mist.uniforms.uFadeIn.value = 0.12; + this.mist.uniforms.uFadeOut.value = 0.4; + + // Flying white: paper fibre lifted by a brush that has nothing left to lay + // down. CHIP and lit, because these are flat flakes catching the key light + // and they are the only *pale* thing in the school. + this.flakes = particles.get('sumistroke.flakes', { + capacity: 700, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.2 + }); + this.flakes.uniforms.uDrag.value = 1.1; + this.flakes.uniforms.uEndSize.value = 0.7; + this.flakes.uniforms.uSizeIn.value = 0.05; + this.flakes.uniforms.uFadeIn.value = 0.04; + this.flakes.uniforms.uFadeOut.value = 0.55; + + // Droplets flung at the set-down and at the lift. The only ballistic thing + // in the slot, and the reason `spatterGravity` is allowed to be steep. + this.spatter = particles.get('sumistroke.spatter', { + capacity: 400, + shape: ParticleShape.SOFT, + additive: false, + softFade: 0.15 + }); + this.spatter.uniforms.uDrag.value = 0.5; + this.spatter.uniforms.uEndSize.value = 0.35; + this.spatter.uniforms.uSizeIn.value = 0.03; + this.spatter.uniforms.uFadeIn.value = 0.02; + this.spatter.uniforms.uFadeOut.value = 0.6; + + this.mistEmitter = new RateEmitter(); + this.flakeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.brush.count; + } + + /** + * The lift, then the soak, then the stand. + * + * Scaled by `global.lifetime` at the *beat* level rather than at the phase + * level, so turning the global knob up genuinely holds the wet mark longer + * instead of leaving the ability staring at a finished one. + */ + get impactDuration() { + const c = settings.sumistroke; + return Math.max(0.05, (c.liftTime + c.holdTime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.sumistroke.fadeTime); + } + + /** + * Ink does not gutter and it does not glint. The little lamp riding the wet + * head only breathes, and slowly — a flicker here would read as fire, which + * is the school this one is defined against. + */ + lightShimmer() { + return 0.92 + 0.08 * Math.sin(this.age * 2.1); + } + + /* ------------------------------------------------------------------ */ + /* The gesture — every control point resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** + * How much of the whole set of marks has been written, 0..1. + * + * Two regimes, and neither of them is a stored number. While the front is + * travelling the head is pinned to it — `front` is metres downrange and the + * spine spans `startInset` to `length + endOvershoot`, so the mapping is a + * division, and the brush arrives where the aim indicator said it would. Once + * the front has landed the remaining `endOvershoot` is paid out over + * `liftTime` as the brush is dragged past the target and comes off. + * + * Snapping straight to 1 at impact was the first attempt and it draws the + * last metre and a half of stroke in one frame — which, because that last + * metre and a half is exactly the frayed part, means the ability's whole + * point appears as a pop. + */ + _progress() { + const c = settings.sumistroke; + const span = Math.max(0.1, this.length + c.endOvershoot - c.startInset); + if (this.phase === AbilityPhase.TRAVEL) { + return saturate((this.front - c.startInset) / span); + } + const landed = saturate((this.length - c.startInset) / span); + const lift = Easing.outCubic(saturate(this.impactTime / Math.max(0.01, c.liftTime))); + return lerp(landed, 1, lift); + } + + /** + * Lay out the three spines and their pressure curves. + * + * Called every frame from `_sync()`. Everything here is a metre or a radian + * and every one of them comes off the block on this line, which is what makes + * the paused editor reshape a standing mark. + */ + _layout(c) { + const lift = c.paperLift; + + /* --- the span the stroke covers, in world space --- */ + _from.copy(this.origin).addScaledVector(this.direction, c.startInset); + _from.y = lift; + _to.copy(this.origin).addScaledVector(this.direction, this.length + c.endOvershoot); + _to.y = lift; + + /* ---------------- the stroke ---------------- */ + /* + * Built with `curve()` rather than `line()`, because `line()` puts the two + * inner control points at a fixed 1/3 and 2/3 and the *placement of those + * points is the brush's speed profile*. At 1/3 and 2/3 the brush travels at + * a constant rate, deposits evenly, and runs out with a horizontal cut. The + * dwell at the entry and the rush at the exit are the whole reason the head + * pools and the tail frays. + */ + _leg.copy(_to).sub(_from); + const skew = saturate(c.bowSkew); + _c1.copy(_from).addScaledVector(_leg, c.entryDwell); + _c2.copy(_to).addScaledVector(_leg, -c.exitRush); + // The bow is applied to the two inner points only, weighted so the belly of + // the arc sits where `bowSkew` puts it. Bowing the ends as well would move + // the set-down point off the aim line, and the aim line is a promise. + _c1.addScaledVector(this.side, c.bow * (1 - Math.abs(skew - 0.33) * 1.5)); + _c2.addScaledVector(this.side, c.bow * (1 - Math.abs(skew - 0.67) * 1.5)); + + const main = this.brush.stroke(MAIN); + main.curve(_from, _c1, _c2, _to); + main.pressure(c.mainEntry, c.mainSwell, c.mainHold, c.mainExit); + main.ink(c.mainLoad); + main.timing(c.mainStart, c.mainSpan); + + /* ---------------- the tuck (起筆) ---------------- */ + /* + * A short mark that starts *behind* the set-down point and travels forward + * into it, at `pressAngle` off the heading. Two control points bunched at + * its far end so the brush decelerates into the join: that deceleration is + * where the ink pools, and the pool is what gives the stroke a blunt head. + */ + const ta = c.pressAngle; + _dir.copy(this.direction).multiplyScalar(Math.cos(ta)).addScaledVector(this.side, Math.sin(ta)); + _c1.copy(_from).addScaledVector(_dir, c.pressLength); + _c1.y = lift; + + const tuck = this.brush.stroke(TUCK); + _c2.copy(_c1).lerp(_from, 0.45); + _pos.copy(_c1).lerp(_from, 0.88); + tuck.curve(_c1, _c2, _pos, _from); + tuck.pressure(c.pressEntry, c.pressSwell, c.pressHold, c.pressExit); + tuck.ink(c.pressLoad); + tuck.timing(c.pressStart, c.pressSpan); + + /* ---------------- the shoulder streak ---------------- */ + /* + * The same gesture, offset laterally, at a third of the load and half the + * pressure, covering `shoulderScale` of the span. It is not a copy of the + * main stroke's curve: it is evaluated *along* it, so it stays parallel + * through the bow instead of cutting the corner, which is what a real + * shoulder mark does. + */ + const shoulder = this.brush.stroke(SHOULDER); + const end = saturate(c.shoulderScale); + this.brush.pointAt(MAIN, 0, _from); + this.brush.pointAt(MAIN, end * 0.34, _c1); + this.brush.pointAt(MAIN, end * 0.67, _c2); + this.brush.pointAt(MAIN, end, _to); + _from.addScaledVector(this.side, c.shoulderOffset); + _c1.addScaledVector(this.side, c.shoulderOffset); + _c2.addScaledVector(this.side, c.shoulderOffset); + _to.addScaledVector(this.side, c.shoulderOffset); + shoulder.curve(_from, _c1, _c2, _to); + shoulder.pressure(c.shoulderEntry, c.shoulderSwell, c.shoulderHold, c.shoulderExit); + shoulder.ink(c.shoulderLoad); + shoulder.timing(c.shoulderStart, c.shoulderSpan); + } + + /** + * Speed along the main spine at `t`, metres per unit parameter. + * + * The cubic's own derivative, evaluated exactly as `bezierD` does in the + * vertex shader. It is the term that makes the dwell cost ink. + */ + _speedAt(stroke, t) { + const m = 1 - t; + _deriv.set(0, 0, 0); + _leg.copy(stroke.p1).sub(stroke.p0); + _deriv.addScaledVector(_leg, 3 * m * m); + _leg.copy(stroke.p2).sub(stroke.p1); + _deriv.addScaledVector(_leg, 6 * m * t); + _leg.copy(stroke.p3).sub(stroke.p2); + _deriv.addScaledVector(_leg, 3 * t * t); + return Math.max(_deriv.length(), 1e-3); + } + + /** + * March the deposition integral forward along the main stroke and record + * where the median bristle frays and where it is spent. + * + * This is the CPU half of `BrushStroke`'s `inkSpent()`. The shader integrates + * *to* a parameter; this inverts the same function, which cannot be done in + * closed form because the pigment rate carries a `1/|B'(t)|` dwell term. The + * median bristle is the one with no edge starvation and no load jitter, so + * these two numbers sit in the middle of the fray rather than at either end + * of it — which is what you want for placing particles, because the fray is a + * *region*. + */ + _measureRunout(c) { + const stroke = this.brush.stroke(MAIN); + const load = Math.max(0, stroke.load * c.inkLoad); + const fraySpend = Math.max(0, load - Math.max(c.dryBand, 1e-3)); + + const h = 1 / RUNOUT_STEPS; + let acc = 0; + let prev = this._depositionAt(stroke, 0, c); + this._frayT = 1; + this._dryT = 1; + let foundFray = fraySpend <= 0; + if (foundFray) this._frayT = 0; + + for (let i = 1; i <= RUNOUT_STEPS; i++) { + const t = i * h; + const rate = this._depositionAt(stroke, t, c); + const before = acc; + acc += 0.5 * (prev + rate) * h; + prev = rate; + const step = acc - before; + if (!foundFray && acc >= fraySpend) { + foundFray = true; + // Linear inside the segment. A whole segment of slop here is a fifth of + // a metre at a twenty-metre cast, which is enough to put the flying + // white visibly ahead of the streaks it is supposed to be coming off. + this._frayT = (t - h) + h * (step > 1e-9 ? (fraySpend - before) / step : 0); + } + if (acc >= load) { + this._dryT = (t - h) + h * (step > 1e-9 ? (load - before) / step : 0); + break; + } + } + if (this._frayT > this._dryT) this._frayT = this._dryT; + } + + /** Metre-pigment spent per unit of `t` at `t`. `pigmentAt(t) * |B'(t)|`. */ + _depositionAt(stroke, t, c) { + const sp = this._speedAt(stroke, t); + const pr = Math.max(this.brush.pressureOf(MAIN, t), 0); + const pig = pr * (c.flowLength + c.flowDwell * (c.speedRef / sp)) * c.pigment; + return pig * sp; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.mistEmitter.reset(); + this.flakeEmitter.reset(); + this._lifted = false; + + // The one thing a cast captures: which bristles started fuller than their + // neighbours, and where each one's contact noise sits. Both unitless. + this._seed = Math.random() * 100; + this.brush.roll(this._seed); + + this._sync(1, 0); + this._tuckFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into the brush and the three particle systems. + * + * @param {number} fade 1 while the mark stands, ramping to 0 as it goes + * @param {number} soak 0..1 how far the ink has wicked into the paper + */ + _sync(fade, soak) { + const c = settings.sumistroke; + const g = settings.global; + + this._layout(c); + this._measureRunout(c); + + const b = _brush; + b.paper = _paperUp; + + b.width = c.width; + b.depth = c.depth; + b.ferruleDepth = c.ferruleDepth; + b.bristleWidth = c.bristleWidth; + b.splay = c.splay; + b.wobble = c.wobble; + b.fibreScale = c.fibreScale; + + b.inkLoad = c.inkLoad; + b.flowLength = c.flowLength; + b.flowDwell = c.flowDwell; + b.speedRef = c.speedRef; + b.edgeStarve = c.edgeStarve; + b.loadJitter = c.loadJitter; + b.dryBand = c.dryBand; + b.dryThin = c.dryThin; + b.skipScale = c.skipScale; + b.skipSoft = c.skipSoft; + b.skipContrast = c.skipContrast; + + b.poolSwell = c.poolSwell; + b.poolCurve = c.poolCurve; + b.pigment = c.pigment; + + b.progress = this._progress(); + b.headTaper = c.headTaper; + b.wetLength = c.wetLength; + + // The soak is the only thing on the mark that changes after the brush has + // come off: the vehicle keeps travelling into the paper for a second or two + // after the pigment has stopped, so the edge softens while the body does + // not. Widening `bleed` is exactly that, and it is one lerp rather than a + // second material. + b.bleed = c.bleed + c.bleedSoak * saturate(soak); + b.fibreEdge = c.fibreEdge; + b.dryPigment = c.dryPigment; + b.wetGain = c.wetGain; + b.opacity = c.opacity * g.opacity; + b.fade = fade; + b.lit = c.lit; + b.backLit = c.backLit; + b.ceiling = c.ceiling; + b.softFade = c.softFade; + b.tint = c.tint; + b.tintDensity = c.tintDensity; + b.tintJitter = c.tintJitter; + + b.colorWash = c.colorWash; + b.colorBody = c.colorBody; + b.colorInk = c.colorInk; + b.colorPool = c.colorPool; + + this.brush.update(0, b); + + /* ---------------- the three particle systems ---------------- */ + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize * 7; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + // Not `* g.glow`. The global glow knob exists to make the emissive schools + // brighter, and ink is not one of them — see the note on `uGlobalGlow` in + // BrushStroke's fragment doc, which this follows for the same reason. + this.mist.uniforms.uGlow.value = c.mistGlow; + this.mist.uniforms.uTurbulence.value = c.mistTurbulence * g.turbulence; + + this.flakes.setGradient( + getColor(c.colorFlakeA), + getColor(c.colorFlakeB), + getColor(c.colorFlakeC), + getColor(c.colorFlakeD) + ); + this.flakes.uniforms.uGravity.value.set(0, c.flakeGravity, 0); + this.flakes.uniforms.uSizeScale.value = c.flakeSize * g.particleSize * 7; + this.flakes.uniforms.uLifeScale.value = c.flakeLifetime * 0.5 * g.particleLifetime; + this.flakes.uniforms.uSpeedScale.value = g.particleSpeed; + this.flakes.uniforms.uOpacity.value = c.flakeOpacity * g.opacity; + this.flakes.uniforms.uGlow.value = 0.25; + this.flakes.uniforms.uTurbulence.value = 0.2 * g.turbulence; + + this.spatter.setGradient( + getColor(c.colorSpatterA), + getColor(c.colorSpatterB), + getColor(c.colorSpatterC), + getColor(c.colorSpatterD) + ); + this.spatter.uniforms.uGravity.value.set(0, c.spatterGravity, 0); + this.spatter.uniforms.uSizeScale.value = c.spatterSize * g.particleSize * 7; + this.spatter.uniforms.uLifeScale.value = c.spatterLifetime * 0.5 * g.particleLifetime; + this.spatter.uniforms.uSpeedScale.value = g.particleSpeed; + this.spatter.uniforms.uOpacity.value = c.spatterOpacity * g.opacity; + this.spatter.uniforms.uGlow.value = 0.2; + } + + /** Droplets thrown as the loaded brush is set down. */ + _tuckFx() { + const c = settings.sumistroke; + const g = settings.global; + + this.brush.pointAt(TUCK, 1, _pos); + + _emit.position = _pos; + _emit.radius = Math.max(0.05, c.width * 0.8); + // Backward and up: the tuck runs against the heading, and a droplet leaves + // along the bristle, not along the gesture. + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.5).setY(0.85).normalize(); + _emit.speed = c.spatterSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.14; + _emit.sizeVariance = 0.85; + _emit.life = c.spatterLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.spatter.emit(Math.round(c.spatterTuck * g.particleCount), _emit); + + this.lightBoost = c.lightIntensity * 0.3 * g.explosionIntensity; + } + + /** + * Everything the moving brush sheds, split at the runout point. + * + * @param {number} dt + * @param {number} scale 0..1 — thinned once the brush has come off the paper + */ + _brushFx(dt, scale) { + const c = settings.sumistroke; + const g = settings.global; + const time = frame.uTime.value; + const head = saturate(this.brush.headOf(MAIN)); + if (head <= 0.001) return; + + /* --- pigment mist, on the wet part only --- */ + const wet = Math.min(head, this._frayT); + const mistCount = Math.round( + this.mistEmitter.tick(dt, c.mistRate * scale * (wet > 0.01 ? 1 : 0)) * g.particleCount + ); + if (mistCount > 0) { + // Biased toward the head: a brush throws mist where it is moving, not + // along the whole length of a mark it laid down a second ago. + const t = wet * (1 - Math.random() * Math.random()); + this.brush.pointAt(MAIN, t, _pos); + _pos.y += c.paperLift; + this.brush.tangentAt(MAIN, t, _dir); + // Sideways off the stroke, in the paper plane, with a little rise. + _dir.cross(_paperUp).setY(0.5).normalize(); + _emit.position = _pos; + _emit.radius = Math.max(0.04, this.brush.widthAt(MAIN, t) * 1.2); + _emit.direction = _dir; + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.mist.emit(mistCount, _emit); + } + + /* --- flying white, past the runout only --- */ + const dryRun = Math.max(0, head - this._frayT); + const flakeCount = Math.round( + this.flakeEmitter.tick(dt, c.flakeRate * scale * (dryRun > 0.005 ? 1 : 0)) * g.particleCount + ); + if (flakeCount > 0) { + // Biased toward the far end of the dry run — `max` of two draws — because + // a bristle that is merely thirsty still lays pigment and a bone-dry one + // is scraping. A uniform draw put as much fibre in the first centimetre + // of the fray as in the last, and the tail stopped reading as an ending. + const t = this._frayT + Math.max(Math.random(), Math.random()) * dryRun; + this.brush.pointAt(MAIN, t, _pos); + _pos.y += c.paperLift; + // Torn up along the direction of travel: a dry bristle catches the tooth + // of the paper and drags fibre forward, which is why this reads as + // scraping rather than as dust falling off. + this.brush.tangentAt(MAIN, t, _dir); + _dir.multiplyScalar(0.7).setY(0.7).normalize(); + _emit.position = _pos; + _emit.radius = Math.max(0.04, this.brush.widthAt(MAIN, t) * 1.4); + _emit.direction = _dir; + _emit.speed = c.flakeSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.75; + _emit.inherit = null; + _emit.anchor = null; + // Past `_dryT` the median bristle has nothing left at all and is dragging + // its tip over the tooth of the paper, so the fibre it lifts is coarser. + _emit.size = t > this._dryT ? 0.15 : 0.1; + _emit.sizeVariance = 0.9; + _emit.life = c.flakeLifetime; + _emit.lifeVariance = 0.6; + _emit.spin = c.flakeSpin; + _emit.tint = null; + _emit.time = time; + this.flakes.emit(flakeCount, _emit); + } + } + + /** The brush comes off the paper. One shot, tested against the live beat. */ + _checkLift() { + const c = settings.sumistroke; + if (this._lifted) return; + if (this.phase === AbilityPhase.TRAVEL) return; + if (this.impactTime < c.liftTime) return; + + this._lifted = true; + const g = settings.global; + const time = frame.uTime.value; + + this.brush.pointAt(MAIN, 1, _pos); + _pos.y += c.paperLift; + + _emit.position = _pos; + _emit.radius = Math.max(0.05, c.width * 1.2); + // Forward and up, along the gesture: the brush is still moving when it + // leaves the paper and the droplets keep going. + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.7).setY(0.7).normalize(); + _emit.speed = c.spatterSpeed * 1.3; + _emit.speedVariance = 0.85; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.9; + _emit.life = c.spatterLifetime * 1.2; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spatter.emit(Math.round(c.spatterLift * g.particleCount), _emit); + + this.ctx.shake.add( + c.liftShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 14 + ); + } + + /** Put the lamp on the wet head, or on the last wet place once it has gone. */ + _placeLight(c) { + const t = Math.min(saturate(this.brush.headOf(MAIN)), Math.max(this._frayT, 0.02)); + this.brush.pointAt(MAIN, t, this.position); + this.position.y = c.paperLift + c.lightHeight; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.sumistroke; + this._sync(1, 0); + this._placeLight(c); + this._brushFx(dt, 1); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + // Nothing fires here. The brush has not left the paper yet — it is still + // being dragged through `endOvershoot` — and firing the lift on arrival + // put the droplets a metre and a half behind the brush that threw them. + this._sync(1, 0); + } + + onFade(dt, t) { + const c = settings.sumistroke; + this._checkLift(); + + // `t` runs 0..1 while the mark stands, then 1..2 while it goes. The soak + // runs on its own clock from the moment the brush lifted, because it is a + // property of the paper rather than of the phase machine: a mark held for + // ten seconds by `global.lifetime` is not ten times wetter. + const soaked = saturate((this.impactTime - c.liftTime) / Math.max(0.05, c.soakTime)); + // Cubic on the way out so the mark hangs on and then goes, rather than + // dimming evenly — evenly reads as a light being turned down, and there is + // no light in this. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._sync(fade, soaked); + + this._placeLight(c); + // The brush keeps shedding until it is off the paper, and then stops. A + // standing mark that is still throwing mist is a mark somebody is still + // painting. + this._brushFx(dt, this._lifted ? 0 : 1); + if (!this._lifted) this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onDestroy() { + this._lifted = false; + this._frayT = 1; + this._dryT = 1; + this.brush.reset(); + } + + dispose() { + this.brush.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/lumen/DawnbreakAbility.js b/src/abilities/lumen/DawnbreakAbility.js new file mode 100644 index 0000000..08f5946 --- /dev/null +++ b/src/abilities/lumen/DawnbreakAbility.js @@ -0,0 +1,444 @@ +import { Mesh, Color, Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { createDaystarMaterial } from '../../materials/DaystarMaterial.js'; +import { uprightQuad } from '../../vfx/quads.js'; +import { sceneHooks, Hook } from '../../vfx/SceneHooks.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** + * How many points one frame's dust is split between. + * + * The circle is metres across and a frame's worth of motes is single digits, so + * firing them all from one spot makes each batch read as a little puff that + * pops somewhere random. Three arcs is enough to turn that into air. + */ +const DUST_BATCHES = 3; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _base = new Vector3(); +const _want = new Vector3(); +const _tint = new Color(); + +/** + * DAWNBREAK — the ability moves the sun. + * + * **The trick is that nothing here draws a shadow.** Dawnbreak takes hold of the + * scene's one directional light through `Hook.KEY_LIGHT` and swings it from just + * above the horizon, up over the top of the stage and back down the far side, so + * *every object in the world* — the character, the crystals of a Frost Lance + * still standing on the floor, the floor's own relief — throws a **real** shadow + * that sweeps across the ground. It is the same light three was already building + * its shadow camera from, so there is nothing to fake and nothing to match: the + * world does the work, and the ability's job is to move one direction vector + * honestly and give it back afterwards. + * + * The whole ability is therefore two draw calls: the daystar + * (`materials/DaystarMaterial.js`) and a dust system. The dust exists because + * grazing light is only visible if there is something in the air to catch it; + * the daystar exists because without a visible source the sweep reads as the + * lighting glitching rather than as a sunrise. Everything else you see happens + * to the rest of the scene. + * + * ### Three beats + * + * 1. **reach** (travel) — the hook blends in on `u^reachCurve` while the arc + * sits at `elevLow`, so the stage's own light rakes *down* to the horizon as + * the cast goes out. This is the anticipation, and it is free: the same + * blend that will later carry the sweep. + * 2. **the sweep** (impact) — `d` runs 0 → 1 over `sweepTime`. Elevation is + * `lerp(elevLow, elevHigh, sin(πd)^elevCurve)` and the bearing turns + * through `azSweep`, so the sun rises, crosses and sets on one clock. + * 3. **settle** (fade) — the weight rolls back to 0 and the sun slides home. + * + * ### Two things that were wrong first + * + * **The disc and the shadows disagreed.** `SceneHooks#_applyKeyLight` does not + * snap the light to the ability's angles: it lerps from the direction + * `settings.environment` asks for toward the ability's, by `weight`. The first + * version drew the daystar at the ability's *raw* aim, so at `sunWeight = 0.4` + * the disc sat at full dawn on the horizon while every shadow on the floor was + * still mostly the stage's own — and the disc read as a sprite pasted over the + * scene. `_sunDirection()` below is a deliberate mirror of the hook's blend, + * including the normalised lerp and the degenerate-length guard, and the two + * now move as one thing. + * + * **The sun went orange while the stage stayed noon-white.** The disc's colour + * and the key light's colour were driven from two curves. They are now driven + * from the *same* number, `warm` — 0 on the horizon, 1 at the top — which is + * the elevation curve itself. One number, two consumers, no way to desynchronise + * them. + * + * ### The rule that makes the editor work + * + * A cast captures exactly one number: `_seed`, so two dawns do not mottle + * identically. Every radian, metre and second is resolved against + * `settings.dawnbreak` inside the update loop, on a zero-length frame included — + * the hook holds nothing across a frame boundary either, by construction, so + * pausing with **P** mid-sweep and dragging `elevHigh` re-aims a sun that is + * already up and every shadow on the stage moves with it. `azSweep`, `discSize` + * and the whole dust bank do the same. + * + * The hook is borrowed through `this.borrow(...)`, so it comes back however the + * cast ends — normally, by **C**, by a fifth cast pushing this one off the + * concurrency cap, or by teardown. The restore is exact rather than + * approximate: `Environment.update()` re-authors the key light from settings + * every frame before the abilities run, and `apply()` blends from those + * settings and never from the live light. + * + * (`sceneHooks.observe()` is deliberately *not* called here. It parks the + * disrupt/gravity/age blocks where the harness's pause probe looks, and this + * ability drives none of them — the daystar's own uniforms carry every number + * the sweep is made of, which is both what the probe needs and what a debugger + * wants to see.) + */ +export class DawnbreakAbility extends Ability { + constructor(context) { + super('dawnbreak', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The daystar. One shared upright quad, billboarded in the vertex shader + * around a world-space centre uniform — so the mesh itself never moves and + * the group's `matrixAutoUpdate = false` costs nothing. `uprightQuad()` is + * module-lifetime and is never disposed; see `vfx/quads.js`. + */ + this.material = createDaystarMaterial(); + this.disc = new Mesh(uprightQuad(), this.material); + this.disc.frustumCulled = false; + this.disc.matrixAutoUpdate = false; + this.disc.layers.set(LAYER.VFX); + // Behind the rest of the VFX vocabulary: it is seventy metres away and + // anything an ability draws near the caster is in front of it. + this.disc.renderOrder = 8; + this.disc.name = 'Daystar'; + this.group.add(this.disc); + + /** The borrowed sun. Null until `onSpawn()`, and again after `destroy()`. */ + this.sun = null; + + /** The one thing a cast captures: a dice roll for the granulation. */ + this._seed = 0; + + /** + * How high the sun is right now, 0..1. Recomputed from settings every + * frame; `lightShimmer()` reads it back on the same frame `_sync()` wrote + * it, which is the only reason it is stored at all. + */ + this._warm = 0; + + /** Where the circle is. Rewritten every frame from the live cast. */ + this._centre = new Vector3(); + } + + createParticles() { + // Dust. Soft and additive, with curl turbulence, because the point of it is + // to be a volume the light passes through rather than a set of specks. + this.motes = this.ctx.particles.get('dawnbreak.motes', { + capacity: 1400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.5 + }); + this.motes.uniforms.uDrag.value = 1.8; + this.motes.uniforms.uEndSize.value = 0.5; + this.motes.uniforms.uSizeIn.value = 0.1; + this.motes.uniforms.uFadeIn.value = 0.18; + this.motes.uniforms.uFadeOut.value = 0.45; + + this.moteEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** The impact phase *is* the sweep. Re-derived, never stored. */ + get impactDuration() { + return Math.max(0.05, settings.dawnbreak.sweepTime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.dawnbreak.settleTime); + } + + /** One quad. The rest of the ability is other people's geometry. */ + get instanceCount() { + return 1; + } + + /** + * The dynamic light brightens as the sun climbs. + * + * Not a shimmer at all, which is why the default is overridden: a sun does not + * flicker, and the base class's slow sine made the one honest cue in the light + * — that noon is brighter than dawn — read as a wobble. + */ + lightShimmer() { + return lerp(1, Math.max(0.01, settings.dawnbreak.lightNoon), this._warm); + } + + /* ------------------------------------------------------------------ */ + /* The arc — every radian resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the circle is: the far end of the aimed line, on the floor. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** + * The direction the light *travels*, blended exactly the way the hook blends + * it — see the class comment. `elevation` is measured up from the horizon and + * the y component is negative, matching `Environment#_computeLightDirection`. + */ + _lightDirection(out, azimuth, elevation) { + const cosE = Math.cos(elevation); + out.set(-Math.cos(azimuth) * cosE, -Math.sin(elevation), -Math.sin(azimuth) * cosE); + return out.normalize(); + } + + /** + * The blended travel direction of the key light this frame. + * + * @param {Vector3} out + * @param {number} azimuth radians, the ability's aim + * @param {number} elevation radians, the ability's aim + * @param {number} weight 0..1 blend against `settings.environment` + */ + _sunDirection(out, azimuth, elevation, weight) { + const e = settings.environment; + this._lightDirection(_base, e.sunAzimuth, e.sunElevation); + this._lightDirection(_want, azimuth, elevation); + out.copy(_base).lerp(_want, saturate(weight)); + // A normalised lerp rather than a slerp: over the arc a sun sweep covers the + // two are within a degree, and this one cannot produce a NaN when the + // endpoints are antipodal. Same guard, same reason, as the hook. + if (out.lengthSq() < 1e-8) out.copy(_want); + return out.normalize(); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this._seed = Math.random() * 100; + this._warm = 0; + + // The sun. `acquire()` never returns null — there is no pool to run out of, + // because the world has exactly one key light and sharing it is a question + // of ordering rather than of availability. Borrowed, so it comes back + // however this cast ends. + this.sun = this.borrow(sceneHooks.acquire(Hook.KEY_LIGHT, this)); + + this._sync(0, 0, 1); + } + + /* ------------------------------------------------------------------ */ + /* The one frame update */ + /* ------------------------------------------------------------------ */ + + /** + * Resolve the arc, write it to the hook and to the daystar, and re-push every + * particle uniform. + * + * @param {number} d 0..1 through the sweep — the ability's clock + * @param {number} weight 0..1 how much of the sun the cast has taken + * @param {number} fade 1 while the daystar is lit, 0 as it lets go + */ + _sync(d, weight, fade) { + const c = settings.dawnbreak; + const g = settings.global; + const u = this.material.uniforms; + + /* ---------------- the arc ---------------- */ + const along = saturate(d); + // sin(πd) is the arc; the exponent is what decides whether the sun lingers + // near the top (a long noon) or near the horizon (a long raking dawn). + const climb = Math.pow(Math.sin(Math.PI * along), Math.max(0.05, c.elevCurve)); + const elevation = lerp(c.elevLow, c.elevHigh, climb); + const azimuth = c.azStart + c.azSweep * along; + const w = saturate(weight * c.sunWeight); + + // 0 on the horizon, 1 at the top. The key light's colour, the key light's + // intensity, the daystar's colour and the dust's emission rate all read + // this one number, which is what stops them drifting apart. + this._warm = saturate(climb); + _tint.copy(getColor(c.colorHorizon)).lerp(getColor(c.colorZenith), this._warm); + + if (this.sun) { + this.sun + .aim(azimuth, elevation) + .tint(_tint) + .brightness(lerp(c.intensityLow, c.intensityHigh, this._warm)) + .blend(w); + } + + /* ---------------- the daystar ---------------- */ + this._centrePoint(this._centre); + this._sunDirection(_dir, azimuth, elevation, w); + // Up-sun from the circle rather than from the shadow camera's focus: the + // circle is where the player is looking, and at this distance the parallax + // between the two anchors is a couple of degrees. + u.uCentre.value.copy(this._centre).addScaledVector(_dir, -c.discDistance); + + u.uSize.value = Math.max(0.01, c.discSize); + // The quad has to cover whatever the aureole and the streak ask for, or a + // raised `haloSize` crops against a hard square edge — which looks exactly + // like a broken sprite and took a while to recognise as a geometry problem. + u.uSpan.value = Math.max(c.discReach, c.haloSize * (1 + c.flareLength)); + u.uSeed.value = this._seed; + u.uFade.value = fade * w; + u.uWarm.value = this._warm; + + u.uSoft.value = c.discSoft; + u.uLimb.value = c.discLimb; + u.uHalo.value = c.haloSize; + u.uHaloFalloff.value = c.haloFalloff; + u.uFlare.value = c.flare; + u.uFlareLength.value = c.flareLength; + u.uFlareWidth.value = c.flareWidth; + u.uGranule.value = c.granule; + u.uGranuleScale.value = c.granuleScale * g.noiseFrequency; + u.uGranuleSpeed.value = c.granuleSpeed * g.noiseSpeed; + + u.uGlow.value = c.discGlow * g.glow; + u.uOpacity.value = c.discOpacity * g.opacity; + u.uColorCore.value.copy(getColor(c.colorDisc)); + u.uColorLow.value.copy(getColor(c.colorDiscLow)); + u.uColorHalo.value.copy(getColor(c.colorHalo)); + + /* ---------------- the dust ---------------- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = c.moteSpeed * g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.discGlow * 0.35 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + /* ---------------- the local light ---------------- */ + this._centrePoint(this.position); + this.position.y = c.lightHeight; + } + + /** + * Dust lifted into the column of air over the circle. + * + * @param {number} dt + * @param {number} scale 0..1 — thinned as the cast lets go + */ + _dustFx(dt, scale) { + const c = settings.dawnbreak; + const g = settings.global; + + // Grazing light picks dust out of the air and overhead light does not, so + // the rate falls as the sun climbs. Nothing is keyframed: it is the same + // `_warm` the colour ramp uses, read the other way round. + const graze = lerp(Math.max(0.01, c.moteGraze), 1, this._warm); + let count = Math.round(this.moteEmitter.tick(dt, c.moteRate * graze * scale) * g.particleCount); + if (count <= 0) return; + + const spread = Math.max(0.1, c.zoneRadius * c.moteSpread); + + _emit.radius = c.moteJitter; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + + const batches = Math.min(count, DUST_BATCHES); + const per = Math.ceil(count / batches); + while (count > 0) { + // A point in the disc: sqrt of the radial roll, or the middle fills up. + const angle = randRange(0, Math.PI * 2); + const radius = spread * Math.sqrt(Math.random()); + _pos.copy(this._centre); + _pos.x += Math.cos(angle) * radius; + _pos.z += Math.sin(angle) * radius; + _pos.y = randRange(0.05, Math.max(0.06, c.moteHeight)); + _emit.position = _pos; + this.motes.emit(Math.min(per, count), _emit); + count -= per; + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.dawnbreak; + // The hook blends in as the cast reaches out, with the arc parked at the + // horizon — so the stage's own light rakes down before anything rises. + const reach = Math.pow(saturate(this.u), Math.max(0.05, c.reachCurve)); + this._sync(0, reach, 1); + this._dustFx(dt, 0.6); + } + + onImpact() { + const c = settings.dawnbreak; + const g = settings.global; + this._sync(0, 1, 1); + // The moment the sun leaves the horizon. A short warm flash, not a bang: + // this ability has no impact, it has a sunrise. + this.ctx.flash.trigger(getColor(c.colorFlash), c.crestFlash * g.explosionIntensity); + } + + onFade(dt, t) { + // `t` is 0..1 across the sweep, then 1..2 as the sun slides home. + const along = saturate(t); + const letGo = t <= 1 ? 1 : 1 - Easing.inOutQuad(saturate(t - 1)); + this._sync(along, letGo, letGo); + this._dustFx(dt, letGo); + } + + onDestroy() { + // Explicit as well as borrowed: `destroy()` would reclaim it anyway, and + // both releases are idempotent, but the sun is the one thing in this project + // that stays visibly wrong for the rest of the session if it is missed. + this.sun?.release(); + this.sun = null; + this._warm = 0; + this.material.uniforms.uFade.value = 0; + } + + dispose() { + // The quad is `vfx/quads.js`'s module-lifetime upright unit plane and is + // deliberately not disposed here — it is shared with every other billboard + // in the project. + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/lumen/EclipseAbility.js b/src/abilities/lumen/EclipseAbility.js new file mode 100644 index 0000000..a80017b --- /dev/null +++ b/src/abilities/lumen/EclipseAbility.js @@ -0,0 +1,554 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { sceneHooks, Hook } from '../../vfx/SceneHooks.js'; +import { acquireGroundQuad, releaseGroundQuad } from '../../vfx/quads.js'; +import { createUmbraMaterial } from '../../materials/UmbraMaterial.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** + * How many points one frame's drain motes are split between. + * + * The dust is coming in off a whole *ring*, and a ring sampled at one bearing + * per frame reads as a single thin stream orbiting the disc rather than as the + * air closing in. Four is the fewest that reads as a circle. + */ +const DRAIN_ARCS = 4; + +/* --- module-scope scratch. Nothing below allocates during a cast (I3) --- */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); + +/** + * ECLIPSE — the light goes wrong before the disc appears. + * + * **The trick is the order.** Every other slot in the project opens with + * geometry; this one opens with the *frame* going quietly wrong and nothing on + * screen to explain it. Through the whole travel phase — a deliberately slow one, + * which is what `speed: 34` is buying — colour drains out of the picture, the + * corners close in, the black level is crushed, and the key light cools and + * dims. Only once that has landed does the umbra open, and by then the shot has + * already told you something is arriving. It is the Thunderclap lesson applied + * to light: the anticipation carries it, and the anticipation is made of two + * scene hooks and no triangles at all. + * + * ### The two hooks + * + * - **`Hook.GRADE`** — `saturate`, `temper`, `raise` and `darken`, blended from + * `settings.post` by a weight which *is* the anticipation curve. Weight, not + * a hand-rolled lerp: the hook already interpolates from whatever the post + * sliders say, so an editor that has turned saturation down keeps its own + * look and the ability still drains *relative to it*. + * - **`Hook.KEY_LIGHT`** — `tint()` and `brightness()` only. **`aim()` is never + * called**, and that is the whole difference between this and Dawnbreak next + * door: an eclipse does not move the sun, it stands in front of it, so every + * shadow on the stage has to stay exactly where it is and merely go cold and + * weak. The version that swung the light read as a second sunset, which is a + * completely different event. + * + * ### The disc, and why it takes two draw calls + * + * The corona is `vfx/Shell.js` in `SUNDISC`, with `fill`, `granule` and `rim` at + * zero so the shell contributes nothing but the filaments licking off the rim. + * The black middle cannot be the shell's: a `Shell` is additive and the darkest + * mark additive blending can make is *nothing*. So the umbra is its own + * `NormalBlending` quad (`materials/UmbraMaterial.js`) which genuinely removes + * the floor underneath it, and it is sized from `corona.radius` — the shell's + * live, eased radius, read back rather than recomputed — so the two can never + * drift apart while a slider is being dragged. + * + * **Baily's beads** flash across second contact, at `beadAt` through the + * opening: hashed bright spots pinned to the limb, unevenly spaced and unevenly + * bright, because nine identical dots on a circle read as a dial. They are the + * one moment the ability is bright, and they are what the corona sparks and the + * screen flash hang off. + * + * ### The rule that makes the editor work + * + * A cast captures one dice roll (`_seed`, for the corona filaments and the bead + * pattern) and one one-way flag (`_contact`, so a slider drag cannot re-fire + * second contact). Not one metre, radian or second is stored: the opening is + * `t × impactDuration / openTime` re-derived every frame, the umbra's radius is + * the shell's, and both hooks are re-authored from `settings.eclipse` on every + * frame including a zero-length one. Pause with **P** during totality and drag + * `gradeVignette` and the corners of the *stopped frame* close in; drag + * `discRadiusEnd` and the disc and its corona grow together. + * + * Both hooks are borrowed through `this.borrow(...)`, so they come back however + * the cast ends. That matters more here than almost anywhere: a leaked grade + * hook leaves the whole game desaturated for the rest of the session, with + * nothing on screen to say why. + */ +export class EclipseAbility extends Ability { + constructor(context) { + super('eclipse', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The corona. `SUNDISC` already draws domain-warped filaments in the plane + * rather than on `atan(y, x)` — see the long note in `vfx/Shell.js` about + * why the obvious version draws a firework — so this is configuration, not + * a new renderer. 128 segments because the limb is a hard edge held right + * next to the umbra's, and 96 shows facets at the radius this thing runs at. + */ + this.corona = new Shell({ + mode: ShellMode.SUNDISC, + prefix: 'disc', + segments: 128, + // Down at ground-effect order rather than the module's default 14. Both + // halves of the disc lie on the floor, so they belong *under* every + // particle system in the project (10 and 12) — otherwise the umbra paints + // over the ability's own drain motes hanging in the air above it, which + // reads as the dust being swallowed a metre early. + renderOrder: 5 + }); + this.group.add(this.corona.group); + + /** + * The umbra. Immediately under the corona in render order, so the filaments + * add on top of the black rather than being covered by it. + */ + this.umbraMaterial = createUmbraMaterial(); + this.umbra = new Mesh(acquireGroundQuad(), this.umbraMaterial); + this.umbra.frustumCulled = false; + this.umbra.matrixAutoUpdate = false; + this.umbra.layers.set(LAYER.VFX); + this.umbra.renderOrder = 4; + this.umbra.name = 'Umbra'; + this.group.add(this.umbra); + + /** The two borrowed pieces of the world. Null outside a cast. */ + this.grade = null; + this.key = null; + + /** The one dice roll: the corona's filaments and the bead pattern. */ + this._seed = 0; + /** One-way: second contact has fired. A slider drag must not re-fire it. */ + this._contact = false; + /** 0..1 bead flash, recomputed every frame; `lightShimmer()` reads it. */ + this._flare = 0; + + /** The circle, rewritten every frame from the live cast. */ + this._centre = new Vector3(); + + /** Scratch state handed to the shell. One object, reused. */ + this._shell = { + origin: new Vector3(), + axis: new Vector3(0, 1, 0), + side: new Vector3(1, 0, 0), + span: 1, + t: 0, + fade: 0, + seed: 0 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // The drain: dust hauled in toward the umbra while the world goes wrong. + // Non-additive, because it is the *colour leaving* and additive dust adds + // light — which is the opposite sentence. + this.drain = particles.get('eclipse.drain', { + capacity: 1100, + shape: ParticleShape.SOFT, + additive: false, + curl: true, + softFade: 0.5 + }); + this.drain.uniforms.uDrag.value = 0.9; + this.drain.uniforms.uEndSize.value = 0.35; + this.drain.uniforms.uSizeIn.value = 0.08; + this.drain.uniforms.uFadeIn.value = 0.14; + this.drain.uniforms.uFadeOut.value = 0.5; + + // The corona sparks: velocity-stretched streaks thrown off the limb. + this.sparks = particles.get('eclipse.sparks', { + capacity: 900, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.3 + }); + this.sparks.uniforms.uDrag.value = 1.3; + this.sparks.uniforms.uEndSize.value = 0.2; + this.sparks.uniforms.uSizeIn.value = 0.03; + this.sparks.uniforms.uFadeIn.value = 0.05; + this.sparks.uniforms.uFadeOut.value = 0.45; + + this.drainEmitter = new RateEmitter(); + this.sparkEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — every beat re-derived from settings */ + /* ------------------------------------------------------------------ */ + + /** + * A beat's length in seconds, under the global lifetime knob. + * + * Everything that reads a clock here goes through this, `impactDuration` + * included — scaling the phase without scaling the beats inside it leaves + * the disc finished a second before the phase machine notices. + */ + _span(seconds) { + return Math.max(0.01, seconds * settings.global.lifetime); + } + + /** The impact phase holds second contact and the whole of totality. */ + get impactDuration() { + const c = settings.eclipse; + return this._span(c.openTime) + this._span(c.holdTime); + } + + get fadeDuration() { + return Math.max(0.05, settings.eclipse.closeTime); + } + + /** The shell's one instance, plus the umbra. HUD readout. */ + get instanceCount() { + return 2; + } + + /** + * The light is the corona, so it is bright at the beads and almost gone at + * totality — not a flicker. `_flare` is written by `_sync()` on the same frame + * the base class reads this back. + */ + lightShimmer() { + return lerp(saturate(settings.eclipse.lightTotality), 1, this._flare); + } + + /* ------------------------------------------------------------------ */ + /* Geometry and beats */ + /* ------------------------------------------------------------------ */ + + /** Where the disc lands: the far end of the aimed line, on the floor. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** + * How far through the opening we are, 0..1. + * + * Derived from the phase's own normalised clock rather than from a counter, + * so `openTime` and `holdTime` can both be dragged mid-cast and the opening + * re-times itself against a phase whose length is changing underneath it. + */ + _openAmount(t) { + return saturate((Math.min(t, 1) * this.impactDuration) / this._span(settings.eclipse.openTime)); + } + + /** The bead flash, 0..1 — a window centred on `beadAt` through the opening. */ + _beadAmount(open) { + const c = settings.eclipse; + const d = Math.abs(open - saturate(c.beadAt)) / Math.max(1e-3, c.beadWindow); + const k = Math.max(0, 1 - d); + // Squared, so the flash has a point rather than a plateau. + return k * k; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.drainEmitter.reset(); + this.sparkEmitter.reset(); + this._contact = false; + this._flare = 0; + this._seed = Math.random() * 100; + + // Both hooks, taken at the start of the cast rather than at second contact, + // because the anticipation is the first beat and it starts now. + this.grade = this.borrow(sceneHooks.acquire(Hook.GRADE, this)); + this.key = this.borrow(sceneHooks.acquire(Hook.KEY_LIGHT, this)); + + this._sync(0, 0, 0); + } + + /* ------------------------------------------------------------------ */ + /* The one frame update */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into both hooks, the corona, the umbra and the two + * particle systems. + * + * @param {number} wrong 0..1 how far the world has been driven toward totality + * @param {number} open 0..1 how far the disc has opened + * @param {number} fade 1 while the disc stands, ramping to 0 as it lets go + */ + _sync(wrong, open, fade) { + const c = settings.eclipse; + const g = settings.global; + + this._centrePoint(this._centre); + const drive = saturate(wrong); + + /* ---------------- the two hooks ---------------- */ + // `blend()` is the anticipation: at weight 0 both hooks are transparent and + // the world is exactly what the environment and post sliders say. + if (this.grade) { + this.grade + .saturate(c.gradeSaturation) + .temper(c.gradeTemper) + .raise(c.gradeLift) + .darken(c.gradeVignette) + .blend(drive * saturate(c.gradeWeight)); + } + if (this.key) { + // No aim(). The sun does not move; something gets in front of it. + this.key.tint(getColor(c.colorCold)).brightness(c.keyDim).blend(drive * saturate(c.keyWeight)); + } + + /* ---------------- the corona ---------------- */ + const s = this._shell; + s.origin.copy(this._centre); + s.axis.set(0, 1, 0); + s.side.copy(this.side); + s.span = Math.max(0.05, c.zoneRadius); + s.t = saturate(open); + s.fade = fade; + s.seed = this._seed; + this.corona.sync(c, s); + + /* ---------------- the umbra ---------------- */ + // The shell's own eased radius, read back rather than recomputed. Two + // copies of the easing is two discs that disagree the moment `discExpand` + // is dragged, and the disagreement shows as a corona that has slipped off + // its own limb. + const radius = Math.max(0.01, this.corona.radius * c.umbraScale); + this._flare = this._beadAmount(saturate(open)) * fade; + + const u = this.umbraMaterial.uniforms; + u.uCentre.value.copy(this._centre); + u.uCentre.value.y = c.umbraLift; + u.uRadius.value = radius; + u.uReach.value = Math.max(1.02, c.umbraReach); + u.uSeed.value = this._seed; + u.uFade.value = fade; + u.uEdge.value = c.umbraEdge; + u.uShade.value = c.umbraShade; + u.uOpacity.value = c.umbraOpacity * g.opacity; + u.uRim.value = c.rimGlow * g.glow; + u.uRimWidth.value = c.rimWidth; + u.uBead.value = this._flare * c.beadFlash; + u.uBeadCount.value = c.beadCount; + u.uBeadSize.value = c.beadSize; + u.uBeadWidth.value = c.beadWidth; + u.uBeadSpin.value = c.beadSpin; + u.uColorUmbra.value.copy(getColor(c.colorUmbra)); + u.uColorLimb.value.copy(getColor(c.colorLimb)); + u.uColorBead.value.copy(getColor(c.colorBead)); + + /* ---------------- the particles ---------------- */ + this.drain.setGradient( + getColor(c.colorDrainA), + getColor(c.colorDrainB), + getColor(c.colorDrainC), + getColor(c.colorDrainD) + ); + this.drain.uniforms.uGravity.value.set(0, c.drainRise, 0); + this.drain.uniforms.uSizeScale.value = c.drainSize * g.particleSize * 7; + this.drain.uniforms.uLifeScale.value = c.drainLifetime * 0.5 * g.particleLifetime; + this.drain.uniforms.uSpeedScale.value = g.particleSpeed; + this.drain.uniforms.uOpacity.value = g.opacity; + this.drain.uniforms.uTurbulence.value = c.drainTurbulence * g.turbulence; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.rimGlow * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + + /* ---------------- the local light ---------------- */ + this.position.copy(this._centre); + this.position.y = c.lightHeight; + } + + /** + * Dust hauled in off a ring toward the umbra. + * + * @param {number} dt + * @param {number} scale 0..1 — how hard the world is being drained + */ + _drainFx(dt, scale) { + const c = settings.eclipse; + const g = settings.global; + let count = Math.round(this.drainEmitter.tick(dt, c.drainRate * scale) * g.particleCount); + if (count <= 0) return; + + const ring = Math.max(0.2, c.zoneRadius * c.drainRadius); + + _emit.radius = c.drainJitter; + _emit.speed = c.drainSpeed; + _emit.speedVariance = 0.5; + // A tight cone: these are being *pulled*, and a wide spread turns the whole + // thing back into ordinary drifting dust. + _emit.spread = 0.22; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.6; + _emit.life = c.drainLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + + const batches = Math.min(count, DRAIN_ARCS); + const per = Math.ceil(count / batches); + while (count > 0) { + const angle = randRange(0, TAU); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + _pos.set( + this._centre.x + cos * ring, + randRange(0.05, Math.max(0.06, c.drainHeight)), + this._centre.z + sin * ring + ); + _emit.position = _pos; + _emit.direction = _dir.set(-cos, 0, -sin).normalize(); + this.drain.emit(Math.min(per, count), _emit); + count -= per; + } + } + + /** + * Sparks off the limb. + * + * @param {number} count how many + * @param {number} speedScale × `sparkSpeed` + */ + _sparkFx(count, speedScale) { + if (count <= 0) return; + const c = settings.eclipse; + const radius = Math.max(0.01, this.corona.radius * c.umbraScale); + + _emit.radius = radius * 0.06; + _emit.speed = c.sparkSpeed * speedScale; + _emit.speedVariance = 0.7; + _emit.spread = 0.55; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + + // Spread round the limb rather than fired from one bearing: the beads are + // all over the circle and a single origin makes them one firework. + const batches = Math.min(count, DRAIN_ARCS); + const per = Math.ceil(count / batches); + let left = count; + while (left > 0) { + const angle = randRange(0, TAU); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + _pos.set( + this._centre.x + cos * radius, + c.umbraLift + radius * 0.04, + this._centre.z + sin * radius + ); + _emit.position = _pos; + // Outward along the limb and slightly up — a prominence, not a fountain. + _emit.direction = _dir.set(cos, 0.55, sin).normalize(); + this.sparks.emit(Math.min(per, left), _emit); + left -= per; + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.eclipse; + // The anticipation. `wrongCurve` above 1 holds the world normal for most of + // the reach and then commits, which is what makes the drain read as + // something arriving rather than as a slow dissolve. + const wrong = + Math.pow(saturate(this.u), Math.max(0.05, c.wrongCurve)) * saturate(c.anticipateWeight); + this._sync(wrong, 0, 0); + this._drainFx(dt, wrong); + } + + onImpact() { + // Nothing fires here. Second contact is `beadAt` through the *opening*, not + // the instant the front lands — the disc has to be most of the way open + // before the beads mean anything. + this._sync(settings.eclipse.anticipateWeight, 0, 0); + } + + onFade(dt, t) { + const c = settings.eclipse; + const g = settings.global; + const open = this._openAmount(t); + const letGo = t <= 1 ? 1 : 1 - Easing.inOutQuad(saturate(t - 1)); + + // The world keeps going wrong through the opening: the anticipation only + // spent `anticipateWeight` of the drain, and the rest is spent here. + const wrong = lerp(saturate(c.anticipateWeight), 1, open) * letGo; + this._sync(wrong, open, letGo); + + if (!this._contact && open >= saturate(c.beadAt)) { + this._contact = true; + this._sparkFx(Math.round(c.sparkBurst * g.particleCount), 1.6); + this.ctx.flash.trigger(getColor(c.colorFlash), c.contactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.9 * g.explosionIntensity; + } + + // Totality still throws the odd prominence, and the drain thins out but + // never quite stops until the disc lets go. + this._sparkFx(Math.round(this.sparkEmitter.tick(dt, c.sparkRate * open * letGo) * g.particleCount), 1); + this._drainFx(dt, wrong * 0.45); + } + + onDestroy() { + // Explicit as well as borrowed. A leaked grade hook leaves the whole game + // desaturated with nothing on screen to explain it, and both releases are + // idempotent, so belt and braces costs nothing. + this.grade?.release(); + this.key?.release(); + this.grade = null; + this.key = null; + this._contact = false; + this._flare = 0; + this.umbraMaterial.uniforms.uFade.value = 0; + this.corona.material.uniforms.uFade.value = 0; + } + + dispose() { + this.corona.dispose(); + this.umbraMaterial.dispose(); + // The quad is refcounted and shared with every other ground effect. + releaseGroundQuad(); + super.dispose(); + } +} diff --git a/src/abilities/lumen/GodspearAbility.js b/src/abilities/lumen/GodspearAbility.js new file mode 100644 index 0000000..68a1a9e --- /dev/null +++ b/src/abilities/lumen/GodspearAbility.js @@ -0,0 +1,858 @@ +import { Color, Vector3, Vector4 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { LightShaft, ShaftLayout, lightShaftParams } from '../../vfx/LightShaft.js'; +import { Caustics, CausticSource, CausticShape, causticsParams } from '../../vfx/Caustics.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { replaceChunk } from '../../utils/shaderPatch.js'; +import { saturate, lerp, smoothstep, Easing } from '../../utils/math.js'; + +/** + * Hard ceiling on shafts in the colonnade. The editor's `shaftCount` slider + * clamps here, `LightShaft` allocates its dice at this size once, and the dust + * coupling's uniform array is exactly this long — the three numbers have to + * agree or the dust lights a rank that is not the one being drawn. + */ +const MAX_SHAFTS = 8; + +/* ------------------------------------------------------------------ */ +/* Scratch — module scope (I3) */ +/* ------------------------------------------------------------------ */ + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _anchor = new Vector3(); +const _up = new Vector3(0, 1, 0); +const _axis = new Vector3(0, -1, 0); +const _sun = new Vector3(0, 1, 0); +const _probe = new Vector3(); +const _tint = new Color(); + +/** Filled from `settings.godspear` every frame; never held between frames. */ +const _shaft = lightShaftParams(); +const _net = causticsParams(); + +/* ------------------------------------------------------------------ */ +/* The scene's own dust, joined to the rank */ +/* ------------------------------------------------------------------ */ + +/** + * The GLSL the room's dust cloud gains while a Godspear is standing. + * + * This is `LightShaft#irradianceAt()` — the same gaussian across the cone, the + * same axial curve with its floor at the mouth, the same `exp(-extinct·a)` down + * the axis — moved onto the GPU and evaluated once per mote. + * + * It is a duplicate of that function and I would rather it were not. Two other + * routes were tried first: + * + * - **Call the CPU mirror per mote and upload the results.** This does not + * work, and not for the reason you would guess. It is not the cost (2600 + * motes × 8 shafts is nothing); it is that a mote's drawn position *does not + * exist on the CPU*. `DustMotes` displaces every point by `curlNoise` in its + * own vertex shader, so the buffer the CPU holds is where the mote was + * seeded, not where it is. Lighting the seed positions puts the bright motes + * somewhere between one and two metres from the shaft, which reads as the + * shaft having a halo of dust *around* it — the exact opposite of the effect. + * - **Emit a second, lit particle system along the rank and turn the room's + * dust down.** This is what the brief forbids and it is right to: the giveaway + * is that the ambient dust keeps drifting at its own speed on its own curl + * field while the "shaft dust" drifts on another, and the two clouds visibly + * slide through each other at the shaft's edge. + * + * So the ability publishes the *rank* — eight feet and one profile — and the + * dust evaluates the field itself, in the place where it already knows where it + * is. One field, two consumers, which is the shape `Caustics#bindSource()` uses + * for the same problem. + * + * `w` on each foot is that shaft's own sweep-window amplitude, so a mote only + * lights under a shaft that is actually lit. + */ +const DUST_SHAFT_GLSL = /* glsl */ ` + uniform vec4 uShaftFeet[${MAX_SHAFTS}]; // xyz = foot in world metres, w = 0..1 amplitude + uniform float uShaftCount; + uniform vec3 uShaftAxis; // unit, pointing DOWN the shafts + uniform float uShaftLength; // metres, mouth to foot + uniform float uShaftRMouth; // metres + uniform float uShaftRFoot; // metres + uniform float uShaftSoft; // widens the gaussian core + uniform float uShaftCurve; // axial exponent + uniform float uShaftMouthMin; // 0..1 density at the mouth + uniform float uShaftExtinct; // 1/metres + uniform float uShaftSwell; // extra point size at full irradiance + + varying float vShaftLit; + + /* The strongest shaft's irradiance at a world point, 0..1. Mirrors + LightShaft#irradianceAt term for term, and leaves the canopy gobo out for + the same reason it does: a mote crossing a leaf edge at four metres a + second would strobe. */ + float godspearIrradiance(vec3 wp) { + if (uShaftCount < 0.5) return 0.0; + float best = 0.0; + for (int i = 0; i < ${MAX_SHAFTS}; i++) { + if (float(i) >= uShaftCount) break; + vec4 foot = uShaftFeet[i]; + if (foot.w <= 0.0) continue; + + vec3 rel = wp - foot.xyz; + // The axis points down, so a point above the foot has a negative + // component along it, and a runs 0 at the mouth to uShaftLength at the + // foot. Same convention as the CPU mirror. + float along = dot(rel, uShaftAxis); + float a = uShaftLength + along; + if (a < 0.0 || a > uShaftLength) continue; + + float k = a / max(uShaftLength, 1e-3); + float rad = max(mix(uShaftRMouth, uShaftRFoot, k), 0.01); + float perp2 = max(dot(rel, rel) - along * along, 0.0); + float fall = exp(-perp2 / max(rad * rad * uShaftSoft, 1e-6)); + float axial = mix(uShaftMouthMin, 1.0, pow(k, max(uShaftCurve, 0.001))); + best = max(best, fall * axial * exp(-uShaftExtinct * a) * foot.w); + } + return clamp(best, 0.0, 1.0); + } +`; + +/** + * The ledger that joins `world/DustMotes.js` to whichever Godspear is standing. + * + * A module-scope singleton, because the dust cloud is one object in the world + * and the ability is pooled: four instances of `GodspearAbility` share one patch + * and one set of uniform boxes. Ownership is last-writer-wins per frame, and a + * cast only zeroes the field on the way out if it is still the owner — so a + * second Godspear thrown while the first is standing takes the dust over + * cleanly instead of switching it off when the first one dies. + * + * There is no `sceneHooks` entry for "the room's dust" and this is not the file + * to add one in: `vfx/SceneHooks.js` is shared and several agents are inside it. + * The patch is applied once, at ability construction, and is left installed with + * `uShaftCount` at zero afterwards — which costs the dust shader one float + * compare per mote for the rest of the session and nothing else. + */ +class DustCoupling { + constructor() { + this.material = null; + this.uniforms = null; + this.owner = null; + } + + /** + * Find the cloud and patch it, once per session. + * + * Returns `false` when there is no `DustMotes` in the scene — which is the + * case in `npm run check`'s mock context, and would be the case in any harness + * that stands an ability up on a bare `Scene`. Everything downstream guards on + * it. Finding the cloud by name is the `TimeControl#findCaster()` precedent. + */ + attach(scene) { + if (this.uniforms) return true; + const points = scene?.getObjectByName?.('DustMotes'); + const material = points?.material; + if (!material || !material.uniforms) return false; + + // Another instance of this ability got here first. + if (material.userData.godspearDust) { + this.material = material; + this.uniforms = material.userData.godspearDust; + return true; + } + + const feet = []; + for (let i = 0; i < MAX_SHAFTS; i++) feet.push(new Vector4(0, 0, 0, 0)); + + const added = { + uShaftFeet: { value: feet }, + uShaftCount: { value: 0 }, + uShaftAxis: { value: new Vector3(0, -1, 0) }, + uShaftLength: { value: 10 }, + uShaftRMouth: { value: 0.5 }, + uShaftRFoot: { value: 1.5 }, + uShaftSoft: { value: 0.5 }, + uShaftCurve: { value: 0.6 }, + uShaftMouthMin: { value: 0.4 }, + uShaftExtinct: { value: 0.05 }, + uShaftSwell: { value: 1.8 }, + uShaftGain: { value: 6 }, + uShaftTint: { value: 0.85 }, + uShaftColor: { value: new Color(1, 0.95, 0.81) } + }; + Object.assign(material.uniforms, added); + + /* ---- the vertex stage ---- */ + let vs = material.vertexShader; + vs = replaceChunk(vs, 'void main() {', `${DUST_SHAFT_GLSL}\n void main() {`); + // After the curl displacement, so the point being lit is the point being + // drawn. `modelMatrix` is there because the cloud is translated to follow + // the character and `position` is therefore local. + vs = replaceChunk( + vs, + 'vec4 mv = modelViewMatrix * vec4(p, 1.0);', + 'vShaftLit = godspearIrradiance((modelMatrix * vec4(p, 1.0)).xyz);\n' + + ' vec4 mv = modelViewMatrix * vec4(p, 1.0);' + ); + vs = replaceChunk( + vs, + 'gl_PointSize = uSize * uPixelRatio * (0.35 + aSeed * 0.9) / max(dist, 1.0);', + 'gl_PointSize = uSize * uPixelRatio * (0.35 + aSeed * 0.9) / max(dist, 1.0);\n' + + ' gl_PointSize *= 1.0 + uShaftSwell * vShaftLit;' + ); + material.vertexShader = vs; + + /* ---- the fragment stage ---- */ + let fs = material.fragmentShader; + fs = replaceChunk( + fs, + 'varying float vSeed;', + 'varying float vSeed;\n varying float vShaftLit;\n' + + ' uniform float uShaftGain;\n uniform float uShaftTint;\n' + + ' uniform vec3 uShaftColor;' + ); + fs = replaceChunk( + fs, + 'vec3 tint = mix(vec3(1.0, 0.93, 0.78), vec3(0.78, 0.9, 1.0), vSeed);', + 'vec3 tint = mix(vec3(1.0, 0.93, 0.78), vec3(0.78, 0.9, 1.0), vSeed);\n' + + ' tint = mix(tint, uShaftColor, clamp(vShaftLit * uShaftTint, 0.0, 1.0));' + ); + fs = replaceChunk( + fs, + 'float a = mask * vAlpha * uAmount * 0.3;', + 'float a = mask * vAlpha * uAmount * 0.3 * (1.0 + uShaftGain * vShaftLit);' + ); + material.fragmentShader = fs; + + material.needsUpdate = true; + material.userData.godspearDust = added; + + this.material = material; + this.uniforms = added; + return true; + } + + /** How many feet the array can hold — the ability clamps its rank to this. */ + get slots() { + return MAX_SHAFTS; + } + + /** Take the field over. Called every frame by the standing cast. */ + claim(owner) { + this.owner = owner; + return this.uniforms; + } + + /** Hand it back. A cast that is no longer the owner leaves the field alone. */ + release(owner) { + if (!this.uniforms || this.owner !== owner) return; + this.owner = null; + this.uniforms.uShaftCount.value = 0; + for (const foot of this.uniforms.uShaftFeet.value) foot.w = 0; + } +} + +const dustCoupling = new DustCoupling(); + +/* ------------------------------------------------------------------ */ +/* The ability */ +/* ------------------------------------------------------------------ */ + +/** + * GODSPEAR — a colonnade of lit air walked down the aimed line. + * + * **THE TRICK IS REAL IN-SCATTERING, AND THE READ IS THE ANISOTROPY.** Every + * fragment of every shaft solves an integral along the view ray through a cone + * of lit air, and the result is multiplied by the Henyey–Greenstein phase + * function of the angle between that ray and the *shaft's own axis*. Seen from + * the side a shaft is a soft pale cone that half the library could have drawn. + * Swing the camera round until you are looking up one — which `shaftTilt` makes + * possible by laying the whole rank over with the scene's key light — and it is + * roughly eight times brighter. That swing is the ability. `anisotropy` is the + * slider; at `g = 0` it is off and the effect immediately becomes a translucent + * traffic cone. + * + * Three things fall out of taking that seriously, and each of them is a thing + * this slot does that no other slot in the sandbox does: + * + * 1. **The band on the floor is the same integral.** The view ray terminates on + * the depth buffer; if it terminated inside a shaft's footprint and near its + * foot plane, the light that landed there is bouncing back, carrying the same + * radial gaussian, the same canopy gaps and the same axial extinction the air + * above it has. It is not a decal and it must not be one: drag `gobo` with + * the clock stopped and the leaf-shadows on the stone move with the gaps in + * the air, because they are one field. + * 2. **The dust in the shaft is the room's dust.** `world/DustMotes.js` — the + * 2600 motes that were already drifting past before the cast — gains the + * rank's irradiance function in its own vertex shader and each mote lights + * itself. Nothing is emitted for this and nothing is copied. See + * `DustCoupling` above for the two versions of it that did not work and why. + * 3. **The caustic net is the canopy, landed.** Light through a stirring canopy + * folds before it reaches the floor, and the fold is the net. It runs in a + * lane down the cast line and its front is locked to the same `sweep` the + * shafts are lit by, so the net and the band are the same event seen two + * ways rather than two effects that happen to be in the same place. + * + * Two draw calls for all of it: one instanced hull for every shaft in the rank, + * one quad for the net. The only particle system is `godspear.ash` — grit + * lifted off the stone by the band, tinted per batch by the CPU mirror + * `LightShaft#irradianceAt()`, which is the same field the motes read on the + * GPU and the reason ash that drifts up into a shaft brightens and ash that + * misses it does not. + * + * Four beats. **Sweep**: a lit window `sweepWidth` wide runs from the caster's + * feet to the far end at `speed`, dragging the caustic front with it. + * **Flood**: on landing the window opens to the whole rank over `floodTime` of + * the hold and the colonnade stands. **Retract**: over `fadeTime` the window + * closes back down onto the impact point by `retract`, so the ability leaves the + * way a light goes out rather than the way a dimmer is turned down. **Out.** + * + * **The rule that makes the editor work.** A cast captures one number — `_seed`, + * so two casts do not scatter their shafts identically — plus the phase clock. + * Not one metre. Pause with **P** mid-sweep and drag `shaftTilt`: the whole + * colonnade lies over, the feet stay where they are, the band on the floor + * stretches into an ellipse, the caustic net's refraction axis follows, and the + * motes standing in the air re-light against the new geometry. Nothing was + * cached, so nothing had to be told. + */ +export class GodspearAbility extends Ability { + constructor(context) { + super('godspear', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + // LINE, because the cast is a line and the rank *is* the line. `sweep` and + // `sweepWidth` then give the travelling window for free — which is the + // module's answer to "a shaft sweeping down the line", and a much better + // one than moving a single shaft, because a moving shaft has no colonnade + // behind it and the eye has nothing to measure the movement against. + this.shafts = new LightShaft(this.group, { + capacity: MAX_SHAFTS, + layout: ShaftLayout.LINE, + sides: 12, + maxSteps: 48, + renderOrder: 10, + name: 'Godspear:Shafts' + }); + + // SCROLL rather than WAVE: there is no `LiquidSurface` here to bind to, and + // the surface doing the folding is a canopy of stirring air rather than a + // heightfield anybody can see. LANE because the cast is a line and the net + // has to run down it with a front. + this.net = new Caustics(this.group, { + source: CausticSource.SCROLL, + shape: CausticShape.LANE, + additive: true, + renderOrder: 7, + name: 'Godspear:Net' + }); + + /** Re-rolled per cast, so two Godspears do not scatter identically. */ + this._seed = 0; + /** Shafts actually drawn this frame; drives the HUD readout. */ + this._live = 0; + /** Whether the room's dust cloud was found and patched. Guarded everywhere. */ + this._dust = false; + + // One shot at the cloud, at construction, which is inside `warm()` and + // therefore off the frame that casts. If the scene has no dust — the check + // harness's bare `Scene` — every dust path below no-ops and the rest of the + // ability is unaffected. + this._dust = dustCoupling.attach(this.ctx.scene); + } + + createParticles() { + // One system, and a small one. It is grit off the floor, not the shaft's + // dust: the shaft's dust is the room's and is not a particle system at all. + this.ash = this.ctx.particles.get('godspear.ash', { + capacity: 700, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.5 + }); + this.ash.uniforms.uDrag.value = 1.5; + this.ash.uniforms.uEndSize.value = 0.55; + this.ash.uniforms.uSizeIn.value = 0.1; + this.ash.uniforms.uFadeIn.value = 0.14; + this.ash.uniforms.uFadeOut.value = 0.45; + + this.ashEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Shafts drawn this frame. One draw call regardless of how many. */ + get instanceCount() { + return this._live; + } + + /** The colonnade stands after the sweep lands. */ + get impactDuration() { + return Math.max(0.05, settings.godspear.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.godspear.fadeTime); + } + + /** + * A slow breath, not a flicker. + * + * Sunlight through a canopy does not gutter; it swells as the canopy moves. + * The base class's default beats two sines at 9.3 and 3.7 Hz, which is a + * glint and belongs to ice. One sine at `lightFlickerSpeed` — well under a + * hertz — is a room brightening and dimming, and you notice it only when you + * stop looking at it. + */ + lightShimmer() { + const c = settings.godspear; + return 1 - c.lightFlicker * 0.5 * (1 - Math.cos(this.age * c.lightFlickerSpeed * Math.PI * 2)); + } + + /* ------------------------------------------------------------------ */ + /* The frame of the rank — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** + * The shafts' own up-vector. + * + * `shaftTilt` mixes from world +Y toward `frame.uLightDir`, which points + * **toward** the sun. The library's `setPlacement` takes `up` and derives + * `axis = -up`, so handing it the direction of the sun makes the light travel + * away from the sun and down onto the floor — the sign the module's own + * example gets backwards, and worth checking rather than copying: negate it + * and the mouths end up under the floor with the shafts pointing at the sky. + */ + _shaftUp(out) { + const c = settings.godspear; + _sun.copy(frame.uLightDir.value); + if (_sun.lengthSq() < 1e-8) _sun.set(0, 1, 0); + _sun.normalize(); + out.set(0, 1, 0).lerp(_sun, saturate(c.shaftTilt)); + if (out.lengthSq() < 1e-8) out.set(0, 1, 0); + return out.normalize(); + } + + /** Where along the line the lit window is, 0..1. The one beat everything reads. */ + _sweep() { + return this.phase === AbilityPhase.TRAVEL ? saturate(this.u) : 1; + } + + /** + * Push the live block into both modules and into the room's dust. + * + * @param {number} fade 1 while the colonnade is lit, ramping to 0 on the way out + * @param {number} width 0..1 of the rank lit at once + */ + _sync(fade, width) { + const c = settings.godspear; + const g = settings.global; + const sweep = this._sweep(); + + /* ---- the rank ---- */ + this._shaftUp(_up); + _axis.copy(_up).negate(); + // The rank is centred on the middle of the cast, so shaft 0 stands at the + // caster's feet and the last one stands on the target. + this.pointAt(0.5, _anchor); + this.shafts.setPlacement(_anchor, this.direction, _up); + + const count = Math.max(1, Math.min(MAX_SHAFTS, Math.round(c.shaftCount))); + _shaft.layout = ShaftLayout.LINE; + _shaft.count = count; + // Derived from the cast's own length rather than authored in metres, so a + // short cast gets a short colonnade instead of a rank that overshoots it. + // `shaftSpan` is the slider that decides how much of the line it covers. + _shaft.spacing = (this.length * c.shaftSpan) / Math.max(1, count - 1); + _shaft.ring = 0; + _shaft.scatter = c.shaftScatter; + _shaft.seed = this._seed; + + _shaft.length = c.shaftLength; + _shaft.radiusMouth = c.shaftRadiusMouth; + _shaft.radiusFoot = c.shaftRadiusFoot; + _shaft.lengthJitter = c.shaftLengthJitter; + _shaft.radiusJitter = c.shaftRadiusJitter; + _shaft.hullPad = c.shaftPad; + + /* ---- the medium ---- */ + _shaft.steps = c.steps; + _shaft.jitter = c.jitter; + _shaft.density = c.density; + _shaft.extinct = c.extinct; + _shaft.soft = c.soft; + _shaft.axialCurve = c.axialCurve; + _shaft.axialMouth = c.axialMouth; + _shaft.anisotropy = c.anisotropy; + _shaft.contact = c.contact; + + _shaft.gobo = c.gobo; + _shaft.goboScale = c.goboScale; + _shaft.goboBias = c.goboBias; + _shaft.goboDrift = c.goboDrift; + + // Zero by default, and the header says why: the library's hashed lattice is + // the *second* dust system, and the room already has the first one. + _shaft.mote = c.shaftMote; + + _shaft.bounce = c.bounce; + _shaft.poolSoft = c.poolSoft; + _shaft.landBand = c.landBand; + + _shaft.fade = fade; + _shaft.sweep = sweep; + _shaft.sweepWidth = width; + _shaft.intensity = c.intensity; + + _shaft.colorMouth = c.colorMouth; + _shaft.colorFoot = c.colorFoot; + _shaft.colorMote = c.colorMote; + _shaft.colorPool = c.colorPool; + + _shaft.noiseStrength = g.noiseStrength; + _shaft.noiseFrequency = g.noiseFrequency; + _shaft.noiseSpeed = g.noiseSpeed; + _shaft.opacityScale = g.opacity; + + this.shafts.update(_shaft); + this._live = this.shafts.instanceCount; + + this._syncNet(fade, sweep); + this._syncDust(fade, width, count); + } + + /** + * The caustic net, and why its lane is twice as long as the cast. + * + * `Caustics(LANE)` puts its front at `front × length` metres from the *quad's + * centre*, and sizes the quad from `length` plus the two spans. Anchoring the + * quad on the cast's origin and passing the cast's own length therefore runs + * the front off the far edge of its own canvas somewhere around the halfway + * mark — which shows up as the net simply stopping, and took a confused ten + * minutes to find because everything upstream was correct. Handing it twice + * the cast length and half the sweep puts the front exactly on the shaft that + * is lit, all the way to the end, and costs nothing but quad area that is + * discarded by the lane mask on the first instruction of the fragment. + */ + _syncNet(fade, sweep) { + const c = settings.godspear; + const g = settings.global; + + _net.centre = this.origin; + _net.lightAxis = _axis; + // The lane's local +Z is downrange. `atan2(x, z)` and not `atan2(z, x)`: + // a yaw of zero already points at +Z. + _net.yaw = Math.atan2(this.direction.x, this.direction.z); + _net.height = c.netHeight; + _net.radius = c.netWidth; + _net.length = this.length * 2; + _net.front = saturate(sweep * 0.5); + _net.fade = fade; + _net.now = this.age; + _net.seed = this._seed; + + _net.depth = c.netDepth; + _net.ior = c.netIor; + _net.dispersion = c.netDispersion; + _net.sampleStep = c.netStep; + _net.absorb = c.netAbsorb; + + _net.foldFloor = c.netFoldFloor; + _net.threshold = c.netThreshold; + _net.gain = c.netGain; + _net.sharpness = c.netSharp; + _net.rolloff = c.netRolloff; + + _net.sourceAmp = c.netAmp; + _net.cellScale = c.netCellScale; + _net.cellRatio = c.netCellRatio; + _net.cellJitter = c.netCellJitter; + _net.driftAngle = c.netDriftAngle; + _net.driftSpeed = c.netDriftSpeed; + _net.boil = c.netBoil; + _net.ridgeMix = c.netRidgeMix; + _net.ridgeScale = c.netRidgeScale; + _net.ridgePower = c.netRidgePower; + + _net.penumbra = c.netPenumbra; + _net.laneWidth = c.netWidth; + _net.laneFeather = c.netFeather; + _net.spanBack = c.netBack; + _net.spanFront = c.netAhead; + + _net.additive = true; + _net.emissive = c.netEmissive * g.glow; + _net.opacity = c.netOpacity; + _net.wash = c.netWash; + _net.fringeAt = c.netFringeAt; + _net.depthFade = c.netDepthFade; + _net.colorNet = c.colorNet; + _net.colorFringe = c.colorFringe; + _net.colorWash = c.colorWash; + + _net.noiseStrength = g.noiseStrength; + _net.noiseFrequency = g.noiseFrequency; + _net.noiseSpeed = g.noiseSpeed; + _net.opacityScale = g.opacity; + + this.net.update(_net); + } + + /** + * Publish the rank to the room's dust. + * + * Every number here is read straight back out of the live settings block and + * out of `LightShaft#footPoint()`, which reads the same uniforms the vertex + * shader reads — so the motes light against the rank that is on screen and + * not against a copy of it taken at spawn. The per-shaft `w` mirrors the + * sweep window in `SHAFT_VERTEX` exactly, including the `smoothstep(0.6, 1.0)` + * shoulder, because a mote that stays bright half a metre after its shaft has + * gone dark is the tell that the two are not the same thing. + */ + _syncDust(fade, width, count) { + if (!this._dust) return; + const c = settings.godspear; + const u = dustCoupling.claim(this); + const sweep = this._sweep(); + + u.uShaftCount.value = count; + u.uShaftAxis.value.copy(_axis); + u.uShaftLength.value = Math.max(0.05, c.shaftLength); + u.uShaftRMouth.value = Math.max(0.01, c.shaftRadiusMouth); + u.uShaftRFoot.value = Math.max(0.01, c.shaftRadiusFoot); + u.uShaftSoft.value = Math.max(0.02, c.soft); + u.uShaftCurve.value = Math.max(0.001, c.axialCurve); + u.uShaftMouthMin.value = saturate(c.axialMouth); + u.uShaftExtinct.value = Math.max(0, c.extinct); + u.uShaftSwell.value = Math.max(0, c.dustSwell); + u.uShaftGain.value = Math.max(0, c.dustGain); + u.uShaftTint.value = saturate(c.dustTint); + u.uShaftColor.value.copy(getColor(c.colorDust)); + + const feet = u.uShaftFeet.value; + for (let i = 0; i < MAX_SHAFTS; i++) { + const foot = feet[i]; + if (i >= count) { + foot.w = 0; + continue; + } + this.shafts.footPoint(i, _shaft, _pos); + const slot = count > 1 ? i / (count - 1) : 0.5; + let window = 1; + if (width < 0.999) { + const d = Math.abs(slot - sweep) / Math.max(width, 1e-3); + // `smoothstep(0.6, 1.0, d)` — the same two numbers and the same curve + // as `SHAFT_VERTEX`. Written with the shared helper rather than a + // hand-rolled ease, because an ease that is nearly a smoothstep leaves + // the motes lit a little longer than their shaft, and "a little" is + // exactly the size of error that reads as the dust being a separate + // effect that happens to be in the same place. + window = 1 - smoothstep(0.6, 1.0, d); + } + foot.set(_pos.x, _pos.y, _pos.z, fade * window); + } + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** Where the band is standing on the floor this frame. */ + _bandPoint(out) { + return this.pointAt(this._sweep(), out).setY(0); + } + + /** + * Ash off the stone under the band. + * + * The tint is the point of it. `irradianceAt()` is sampled at the height the + * grit is born at, and the batch is tinted between `colorAshD` (unlit dust + * off a dark floor) and `colorAshA` (dust standing in the beam) by what comes + * back — so the ash the band throws forward into the next shaft brightens as + * it crosses into it, and the ash behind the sweep does not. It is the CPU + * mirror of exactly the field the motes are reading on the GPU, which is why + * the two agree at the shaft's edge instead of nearly agreeing. + */ + _ashFx(dt, scale) { + const c = settings.godspear; + const g = settings.global; + const count = Math.round(this.ashEmitter.tick(dt, c.ashRate * scale) * g.particleCount); + if (count <= 0) return; + + this._bandPoint(_pos); + _pos.y = c.ashSize * 4; + + _probe.copy(_pos); + _probe.y = Math.max(_probe.y, c.shaftRadiusFoot * 0.5); + const lit = this.shafts.irradianceAt(_probe, _shaft); + _tint.copy(getColor(c.colorAshD)).lerp(getColor(c.colorAshA), saturate(lit)); + + _emit.position = _pos; + _emit.radius = c.ashSpread; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.3).setY(1).normalize(); + _emit.speed = c.ashSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.ashLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = _tint; + _emit.time = frame.uTime.value; + this.ash.emit(count, _emit); + } + + /** The ash system's own uniforms — metres per second live here, not on emit. */ + _syncAsh() { + const c = settings.godspear; + const g = settings.global; + this.ash.setGradient( + getColor(c.colorAshA), + getColor(c.colorAshB), + getColor(c.colorAshC), + getColor(c.colorAshD) + ); + this.ash.uniforms.uGravity.value.set(0, c.ashRise, 0); + this.ash.uniforms.uSizeScale.value = c.ashSize * g.particleSize * 7; + this.ash.uniforms.uLifeScale.value = c.ashLifetime * 0.5 * g.particleLifetime; + this.ash.uniforms.uSpeedScale.value = g.particleSpeed; + this.ash.uniforms.uOpacity.value = g.opacity; + this.ash.uniforms.uGlow.value = c.intensity * 0.8 * g.glow; + this.ash.uniforms.uTurbulence.value = c.ashTurbulence * g.turbulence; + } + + /** The light rides the band, lifted off the floor so it is not clipped by it. */ + _placeLight() { + this._bandPoint(this.position); + this.position.y = settings.godspear.lightHeight; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.godspear; + const g = settings.global; + + this.ashEmitter.reset(); + this._seed = Math.random() * 100; + + // `reset()` is the pooling contract: it zeroes the instance count and hides + // the mesh, so the roll goes after it and the first `update()` turns it back + // on. Rolling here and nowhere else is the whole of what a cast captures. + this.shafts.reset(); + this.shafts.roll(this._seed); + this.net.reset(); + this.net.setVisible(true); + + this._sync(1, c.sweepWidth); + this._syncAsh(); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this._placeLight(); + } + + onTravel(dt) { + const c = settings.godspear; + this._sync(1, c.sweepWidth); + this._syncAsh(); + this._ashFx(dt, 1); + this._placeLight(); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.godspear; + const g = settings.global; + + this.pointAt(1, _pos); + _pos.y = c.shaftRadiusFoot; + + // A flare of air where the spear plants, and nothing else. No shockwave + // decal, no scorch, no debris: the band on the floor is already there and + // it was produced by the light, which is the point of the school. Stamping + // a `DecalType.SHOCKWAVE` on top would be admitting the band is not enough. + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.55, + intensity: c.burstIntensity, + opacity: 0.8, + fresnel: 2.1, + displace: 0.3, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.6 * g.explosionIntensity; + } + + /** + * @param {number} dt seconds + * @param {number} t 0..1 through the flood-and-hold, then 1..2 through the retract + */ + onFade(dt, t) { + const c = settings.godspear; + const held = t <= 1; + + let width; + let fade; + if (held) { + // The window opens from the travelling slit to the whole rank over the + // first `floodTime` of the hold. Opening it instantly on the impact frame + // was the first version and it reads as a cut, not as a landing. + const open = Easing.outCubic(saturate(t / Math.max(0.01, c.floodTime))); + width = lerp(c.sweepWidth, 1, open); + fade = 1; + } else { + // ...and closes again by `retract`, so the colonnade withdraws into the + // point it landed on. A rank that just dims looks switched off; a rank + // that narrows looks like it is leaving. + const gone = saturate(t - 1); + width = lerp(1, lerp(1, c.sweepWidth, saturate(c.retract)), Easing.inOutQuad(gone)); + fade = 1 - Easing.inCubic(gone); + } + + this._sync(fade, width); + this._syncAsh(); + this._ashFx(dt, fade * (held ? 0.7 : 0.3)); + this._placeLight(); + } + + onDestroy() { + this._live = 0; + this.shafts.reset(); + this.net.clearRipples(); + this.net.setVisible(false); + // Give the room's dust back. Only if we are still the one driving it: a + // second Godspear thrown over this one has already taken the field, and + // switching it off here would darken a cast that is still standing. + dustCoupling.release(this); + } + + dispose() { + this.shafts.dispose(); + this.net.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/lumen/PhotonLatticeAbility.js b/src/abilities/lumen/PhotonLatticeAbility.js new file mode 100644 index 0000000..a033059 --- /dev/null +++ b/src/abilities/lumen/PhotonLatticeAbility.js @@ -0,0 +1,413 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { + createPhotonBeamGeometry, + createPhotonBeamMaterial, + photonBeamCount, + MAX_PHOTON_BEAMS +} from '../../materials/PhotonBeamMaterial.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing } from '../../utils/math.js'; + +/** + * Hard ceiling on nodes per axis. Five cubed is 75 beams against a buffer + * allocated for `MAX_PHOTON_BEAMS`; six would be 108 and would silently drop a + * third of the lattice, which is exactly the sort of failure that looks like a + * shader bug for an hour. + */ +const MAX_NODES = 5; + +const TAU = Math.PI * 2; + +/* ------------------------------------------------------------------ */ +/* Scratch — module scope (I3) */ +/* ------------------------------------------------------------------ */ + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/** + * PHOTON LATTICE — a grid of thin beams hanging over the circle. + * + * **THE TRICK IS THAT NOTHING IS DRAWN AT THE NODES.** Three families of beams + * — one running across the cast, one up, one downrange — cross at `gridX × + * gridY × gridZ` points, and every one of those points is a bright node for + * exactly one reason: two beams are being added there. There is no node sprite + * in this file, no billboard, no second pass over the crossings, and no slider + * anywhere in `settings.photonlattice` that lights them. Search the block for + * "node" and you will find the word only in comments. + * + * That is easy to claim and hard to earn, and two things had to be true for it + * to work at all: + * + * 1. **A beam has to be an integral, not a surface.** Drawing each beam as a + * thin cylinder with a radial gradient gives you two gradients overlapping + * at a crossing, which sums to a slightly brighter *lozenge* and reads as a + * join in a pipe. What is drawn instead is the closed-form line integral of + * a gaussian tube along the view ray — see `materials/PhotonBeamMaterial.js` + * for the algebra and for the marched version that came first and why it was + * thrown away. An integral has a sharp maximum on the axis, and two sharp + * maxima coinciding is what the eye reads as a point of light. + * 2. **Alpha has to be one.** With `AdditiveBlending` the destination gets + * `rgb × alpha` added, so writing a luminance into alpha squares everything + * and a node becomes four times a beam instead of twice. Four times looks + * better in a still and is a lie, and it falls apart the moment three beams + * meet at a corner and the corner is sixteen times a beam. + * + * The one rule for tuning it follows directly: **if the nodes are not bright + * enough, the beams are too dim.** `density`, `coreGain` and `intensity`. + * Nothing else, ever. + * + * `beamRadius` is the other slider worth the visit. Small — four or five + * centimetres of gaussian sigma — is what gives the maximum its sharpness. Drag + * it up with the clock paused and the nodes dissolve into a fog cube with lumps + * in it, which is a live demonstration of why the previous paragraph is true. + * + * Everything else follows from beams that are honestly the length they claim: + * they grow from their own **midpoints outward**, so the crossings near the + * middle of the volume light before the corners, and the fade retracts them the + * same way so the corners go out first. Neither of those was authored; they are + * what happens when the drawn extent is the integration limit. + * + * **One draw call** for the whole lattice — one instanced hull, every beam + * placed by the vertex shader from three node counts. One particle system, + * `photonlattice.mote`, seeded through the box: the motes are not node markers + * and nothing samples the lattice to place them; the ones that drift near a + * beam simply brighten, in the frame buffer, because both are additive. + * + * **The rule that makes the editor work.** A cast captures one number, `_seed`, + * which decorrelates the per-beam stagger and breath. Not one metre, and not + * even the beam count: `gridX`, `gridY` and `gridZ` are read every frame and + * the vertex shader re-derives which family each instance index belongs to, so + * dragging `gridY` on a paused, standing lattice moves beams between families + * in front of you. + */ +export class PhotonLatticeAbility extends Ability { + constructor(context) { + super('photonlattice', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.geometry = createPhotonBeamGeometry(MAX_PHOTON_BEAMS, 6); + this.material = createPhotonBeamMaterial(); + + this.mesh = new Mesh(this.geometry, this.material); + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(LAYER.VFX); + // Above the ground decals and below the HUD-space flare layers. Additive + // and depth-test-off, so the order between VFX only decides who is added + // first, which for a commutative blend is a tidiness choice. + this.mesh.renderOrder = 12; + this.mesh.visible = false; + this.group.add(this.mesh); + + /** Re-rolled per cast. The only state a cast captures. */ + this._seed = 0; + /** Beams drawn this frame — the HUD readout, and one draw call regardless. */ + this._live = 0; + } + + createParticles() { + // Small, and not a node marker. These hang in the box and drift; a mote + // that happens to pass close to a beam brightens because the frame buffer + // adds them together, which is the same mechanism the nodes use and the + // reason it is the right particle system for this ability rather than a + // sparkle pinned to each crossing. + this.motes = this.ctx.particles.get('photonlattice.mote', { + capacity: 640, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.5; + this.motes.uniforms.uEndSize.value = 0.3; + this.motes.uniforms.uSizeIn.value = 0.14; + this.motes.uniforms.uFadeIn.value = 0.2; + this.motes.uniforms.uFadeOut.value = 0.4; + + this.moteEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** Beams drawn this frame. One draw call for all of them. */ + get instanceCount() { + return this._live; + } + + /** The locked grid is the ability; the assemble is how you get to it. */ + get impactDuration() { + return Math.max(0.05, settings.photonlattice.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.photonlattice.fadeTime); + } + + /** + * A slow swell, not a flicker. + * + * The per-beam breath in the shader already carries whatever restlessness + * this ability wants, and at `flickerSpeed` 1.25 Hz across forty-eight beams + * with decorrelated phases it averages out to something very steady. The + * dynamic light therefore gets one slow sine of its own rather than the base + * class's two-sine glint, which would beat against the beams and read as a + * loose connection. + */ + lightShimmer() { + const c = settings.photonlattice; + return 1 + c.lightSway * Math.sin(this.age * c.lightSwaySpeed * TAU); + } + + /* ------------------------------------------------------------------ */ + /* The frame — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the grid hangs. The circle the indicator drew is the promise. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** + * @param {number} fade 0..1 master + * @param {number} assemble 0..1 how much of each beam is drawn + * @param {number} locked 0..1 how much of the standing-grid behaviour is on + */ + _sync(fade, assemble, locked) { + const c = settings.photonlattice; + const g = settings.global; + const u = this.material.uniforms; + + this._centrePoint(_centre); + u.uAnchor.value.copy(_centre); + u.uAlong.value.copy(this.direction); + u.uSide.value.copy(this.side); + u.uUp.value.copy(_up); + + /* --- the grid --- */ + const gx = Math.max(1, Math.min(MAX_NODES, Math.round(c.gridX))); + const gy = Math.max(1, Math.min(MAX_NODES, Math.round(c.gridY))); + const gz = Math.max(1, Math.min(MAX_NODES, Math.round(c.gridZ))); + u.uGrid.value.set(gx, gy, gz); + + // Across and downrange come off `zoneRadius`, because the circle the aim + // indicator drew is the footprint the player was promised and a lattice + // that overhangs it is a lattice that lands somewhere else. Height is its + // own metre: a box is not a sphere and there is no honest way to derive one + // from the other. + const reach = Math.max(0.05, c.zoneRadius * c.latticeSpread); + u.uSpan.value.set(reach, Math.max(0.05, c.latticeHeight), reach); + u.uLift.value = c.latticeLift; + u.uOverhang.value = Math.max(0, c.latticeOverhang); + // `age` is a timestamp, not a dimension — capturing it is allowed and + // resolving the *rate* against live settings every frame is what keeps the + // spin live under a paused slider drag. + u.uSpin.value = c.latticeSpin + this.age * c.latticeSpinSpeed; + u.uSeed.value = this._seed; + + /* --- the beam --- */ + u.uRadius.value = Math.max(0.001, c.beamRadius); + u.uHaloScale.value = Math.max(1, c.haloScale); + u.uHullPad.value = Math.max(1.2, c.hullPad); + u.uDensity.value = Math.max(0, c.density) * g.glow; + u.uCoreGain.value = Math.max(0, c.coreGain); + u.uHaloGain.value = Math.max(0, c.haloGain); + u.uEndTaper.value = Math.max(0.001, c.endTaper); + u.uEndTint.value = saturate(c.endTint); + u.uIntensity.value = Math.max(0, c.intensity); + + u.uColorCore.value.copy(getColor(c.colorCore)); + u.uColorHalo.value.copy(getColor(c.colorHalo)); + u.uColorEnd.value.copy(getColor(c.colorEnd)); + + /* --- the beats --- */ + u.uAssemble.value = saturate(assemble); + u.uStagger.value = Math.max(0, Math.min(0.95, c.stagger)); + u.uFlicker.value = saturate(c.flicker) * g.turbulence; + u.uFlickerSpeed.value = c.flickerSpeed * g.noiseSpeed; + u.uFade.value = saturate(fade) * g.opacity; + + // The travelling band, and the only place the lattice looks *driven*. It is + // held down to a third while the beams are still drawing in: a pulse racing + // along a beam that is two thirds built has nowhere to go and reads as a + // stutter. + u.uPulse.value = Math.max(0, c.pulse) * lerp(0.33, 1, saturate(locked)); + u.uPulseWidth.value = Math.max(0.005, c.pulseWidth); + const walk = this.age * c.pulseSpeed; + u.uPulseAt.value = walk - Math.floor(walk); + + /* --- how many of them --- */ + this._live = Math.min(MAX_PHOTON_BEAMS, photonBeamCount(gx, gy, gz)); + this.geometry.instanceCount = this._live; + this.mesh.visible = this._live > 0 && u.uFade.value > 0.0005; + + /* --- the motes --- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + // Metres per second lives on the uniform rather than on the emit, so + // dragging `moteSpeed` with the clock stopped re-speeds the motes that are + // already in the air instead of only the next ones out. + this.motes.uniforms.uSpeedScale.value = c.moteSpeed * g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity * fade; + this.motes.uniforms.uGlow.value = c.intensity * 1.1 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + } + + /** Motes seeded through the box the lattice occupies. */ + _moteFx(dt, scale) { + const c = settings.photonlattice; + const g = settings.global; + const count = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (count <= 0) return; + + this._centrePoint(_pos); + _pos.y = c.latticeLift; + + _emit.position = _pos; + // A ball, not a box — the corners of the lattice are the least interesting + // part of it and seeding a cube puts a quarter of the motes outside every + // beam. `moteSpread` scales it against the box's own half-extent. + _emit.radius = Math.max(c.zoneRadius * c.latticeSpread, c.latticeHeight) * c.moteSpread; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = 1; // metres/second lives on uSpeedScale — see _sync() + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(count, _emit); + } + + /** The light hangs in the middle of the grid, which is where the light is. */ + _placeLight() { + this._centrePoint(this.position); + this.position.y = settings.photonlattice.latticeLift; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.photonlattice; + const g = settings.global; + + this.moteEmitter.reset(); + this._seed = Math.random() * 100; + + this._sync(1, 0, 0); + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this._placeLight(); + } + + onTravel(dt) { + // The assemble front *is* the cast's progress. Nothing crosses the floor: + // the beams draw further in the closer the cast gets to its point, which is + // why `speed` here is a build rate wearing a travel speed's clothes. + const assemble = Easing.outQuad(saturate(this.u)); + this._sync(1, assemble, 0); + this._moteFx(dt, assemble * 0.5); + this._placeLight(); + } + + onImpact() { + const c = settings.photonlattice; + const g = settings.global; + + this._centrePoint(_pos); + + // A thin ring on the floor and a flash, and nothing else. No burst shell: + // a shell is a surface, this school's whole argument is that light is not + // one, and a fireball under a lattice of beams would be the loudest thing + // on screen for the wrong half-second. The ring is a decal, so it costs the + // ability no draw call of its own. + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.shockRadius * g.explosionIntensity, + life: c.shockLife, + width: 0.04, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + this.ctx.shake.add( + c.lockShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 24 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.lockFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /** + * @param {number} dt seconds + * @param {number} t 0..1 through the lock, then 1..2 through the retract + */ + onFade(dt, t) { + const c = settings.photonlattice; + const held = t <= 1; + + let assemble; + let fade; + if (held) { + assemble = 1; + fade = 1; + } else { + // The beams draw back into their own midpoints. Only the *extent* is + // eased; the brightness comes off on a cubic so the last thing left is a + // short bright stub at the centre of each beam rather than a whole + // lattice dimmed to nothing. A lattice that only dims looks switched off. + const gone = saturate(t - 1); + assemble = lerp(1, 1 - saturate(c.retract), Easing.inOutQuad(gone)); + fade = 1 - Easing.inCubic(gone); + } + + this._sync(fade, assemble, 1); + this._moteFx(dt, fade * (held ? 1 : 0.35)); + this._placeLight(); + } + + onDestroy() { + this._live = 0; + this.geometry.instanceCount = 0; + this.material.uniforms.uFade.value = 0; + this.material.uniforms.uAssemble.value = 0; + this.mesh.visible = false; + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/lumen/RefractCascadeAbility.js b/src/abilities/lumen/RefractCascadeAbility.js new file mode 100644 index 0000000..20f8157 --- /dev/null +++ b/src/abilities/lumen/RefractCascadeAbility.js @@ -0,0 +1,823 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Mirror, mirrorParams } from '../../vfx/Mirror.js'; +import { + createCascadeBeamGeometry, + createCascadeBeamMaterial, + MAX_BEAM_SEGMENTS +} from '../../materials/CascadeBeamMaterial.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; +const WORLD_UP = new Vector3(0, 1, 0); + +/** + * Panes the cascade may hang. Four, because five extra `WORLD` renders a frame + * is not a cast, and because the beam carries `MAX_BEAM_SEGMENTS` legs — one + * per pane plus the run to the floor. + */ +const MAX_PANES = MAX_BEAM_SEGMENTS - 1; +/** Samples along one leg. The ceiling on how smoothly a leg's profile bows. */ +const BEAM_NODES = 24; +/** Facets around a leg. Below about fourteen the silhouette polygonises. */ +const BEAM_SIDES = 18; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _in = new Vector3(); +const _out = new Vector3(); +const _normal = new Vector3(); +const _along = new Vector3(); +const _floor = new Vector3(); +const _mirror = mirrorParams(); +const _beamState = { progress: 1, fade: 1, seed: 0 }; + +/** + * REFRACTION CASCADE — a shot that gets to the far end by bouncing. + * + * Three or four panes of glass hang in a zig-zag down the aimed line. The shot + * leaves the hand, strikes the first, comes off it at the reflected angle, + * crosses to the next, and finally lands on the floor at the end of the cast, + * losing a slice of its width and its light at every bounce. + * + * ## THE TRICK — the panes reflect the actual scene + * + * Each pane is a `vfx/Mirror.js`: a camera mirrored about its own plane renders + * the `WORLD` layer into a small target and the surface samples that target + * projectively. What is in the glass is the room *behind the camera* — the + * character, the floor's relief, a Frost Lance still standing from the last + * cast — and it **slides across the pane as you orbit**. That parallax is the + * entire feature. An environment map costs a tenth as much and the brain files + * a reflection that does not move as painted-on shine within about half a + * second, which is the difference between glass and chrome the roster line is + * blunt about. + * + * The bill is not a draw call. Five draw calls draw this — four panes and one + * beam — but every pane that *renders* is another full traversal of the world: + * a scene walk, a render list, a sort, and every opaque draw again. The module + * caps that at two a frame and hands the slots out on `priority × apparent size + * × (1 + frames waited)`, so on four panes the two nearest and largest are + * always current and the others are showing a 30–50 ms old reflection. That is + * the right trade and it is why `paneCount` stops at four. + * + * ## The reflection law is the layout, backwards + * + * The obvious way round is to place the panes, aim them, and trace the beam + * off them. The first version did that and it is miserable: two sliders on a + * pane's rotation and the beam wanders off into the room, so the panes have to + * be aimed by hand for every value of every other slider, and nothing can be + * dragged while a cast is standing. + * + * So it is inverted. The **path** is authored — a zig-zag whose nodes are + * `paneOffset`, `paneAltitude` and the rest, resolved fresh each frame — and + * each pane's normal is then *derived* as the bisector of the two legs meeting + * on it: for an incoming heading `d` and an outgoing `r`, the plane that turns + * one into the other has normal `normalize(r − d)`, which already faces the + * incoming beam because `(r − d)·d ≤ 0` for two unit vectors. There is no + * aiming logic and no correction term anywhere in this file, and a pane cannot + * be wrong. Pause mid-flight, drag `paneOffset`, and the whole zig-zag opens + * out with every pane re-aiming itself to keep the reflection legal. + * + * ## One draw call for the legs + * + * `vfx/Tube.js` is three draw calls for one tube and does not instance; four + * legs would be twelve, which is the whole budget for one line of light. + * `materials/CascadeBeamMaterial.js` is the same parameter-space grid, + * instanced by leg, with each instance looking its own endpoints out of a + * uniform array by the unrolled-loop compare `FilamentPaths` uses — the direct + * `uFrom[int(aSegment)]` does not compile on ANGLE. One draw call, whatever + * `paneCount` says. + * + * The front crosses the *cast line* at `speed`; the beam's progress is that + * same fraction mapped onto the polyline, which is longer. The light therefore + * appears to travel faster than `speed` in proportion to how far the zig-zag + * detours, and that is deliberate: the alternative is a shot that visibly slows + * down every time somebody widens `paneOffset`. + * + * ## What a cast captures + * + * A seed and a four-entry flag array saying which panes have been struck. Both + * are events. Every metre — where a pane hangs, how big it is, how wide the + * beam is, how much it loses at a bounce — is resolved from + * `settings.refractcascade` inside the update loop, on a zero-length frame + * included. The bounce itself is polled against the live geometry rather than + * scheduled off a timestamp taken at spawn, so moving a pane moves the moment + * it is hit. + */ +export class RefractCascadeAbility extends Ability { + constructor(context) { + super('refractcascade', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the panes: one draw call each, plus a nested world render --- */ + this.panes = []; + for (let i = 0; i < MAX_PANES; i++) { + const pane = new Mirror({ + resolution: 320, + doubleSided: true, + renderOrder: 4, + name: `RefractCascade:pane${i}` + }); + pane.visible = false; + this.group.add(pane.object3D); + this.panes.push(pane); + } + + /* --- the beam: every leg, one draw call --- */ + this.beamGeometry = createCascadeBeamGeometry(BEAM_NODES, BEAM_SIDES, MAX_BEAM_SEGMENTS); + this.beamMaterial = createCascadeBeamMaterial(); + this.beamMesh = new Mesh(this.beamGeometry, this.beamMaterial); + this.beamMesh.frustumCulled = false; + this.beamMesh.matrixAutoUpdate = false; + this.beamMesh.layers.set(LAYER.VFX); + this.beamMesh.renderOrder = 12; + this.group.add(this.beamMesh); + + /** + * The polyline: muzzle, then every pane in order, then the floor. Allocated + * once, rewritten in place every frame — nothing here survives a frame. + */ + this._nodes = []; + for (let i = 0; i < MAX_BEAM_SEGMENTS + 1; i++) this._nodes.push(new Vector3()); + /** Each pane's arrival, as a fraction of the whole path. Re-derived too. */ + this._arrive = new Float32Array(MAX_PANES); + /** Which panes have been struck this cast. Events, not dimensions. */ + this._struck = new Uint8Array(MAX_PANES); + + /** The one dice roll a cast makes. */ + this._seed = 0; + /** Legs live this frame — `paneCount + 1`. */ + this._legs = 2; + } + + createParticles() { + const particles = this.ctx.particles; + + // Sparks off the bounces and along the beam. Velocity-stretched streaks. + this.sparks = particles.get('refractcascade.sparks', { + capacity: 2000, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.6; + this.sparks.uniforms.uEndSize.value = 0.24; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.04; + this.sparks.uniforms.uFadeOut.value = 0.45; + + // Glass dust hanging around the panes: the thing that says there is a solid + // object there, and the only reason a pane reads as suspended rather than + // as a hole in the frame. + this.motes = particles.get('refractcascade.motes', { + capacity: 1400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.6; + this.motes.uniforms.uEndSize.value = 0.2; + this.motes.uniforms.uSizeIn.value = 0.07; + this.motes.uniforms.uFadeIn.value = 0.1; + this.motes.uniforms.uFadeOut.value = 0.42; + + // Dust off the floor at the far end. Non-additive so it occludes. + this.dust = particles.get('refractcascade.dust', { + capacity: 1000, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 1.8; + this.dust.uniforms.uEndSize.value = 2.6; + this.dust.uniforms.uSizeIn.value = 0.12; + this.dust.uniforms.uFadeIn.value = 0.16; + this.dust.uniforms.uFadeOut.value = 0.32; + + this.sparkEmitter = new RateEmitter(); + this.moteEmitter = new RateEmitter(); + this.dustEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._legs + this._paneCount; + } + + get impactDuration() { + return Math.max(0.05, settings.refractcascade.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.refractcascade.fadeTime); + } + + /** + * Glass rings; it does not gutter. A quantised stutter here would read as the + * Storm Lance, and this is the opposite of electric. + */ + lightShimmer() { + const c = settings.refractcascade; + return 1 - c.lightPulse * (0.5 - 0.5 * Math.cos(this.age * c.lightPulseSpeed * TAU)); + } + + /** Panes hanging this cast, 1..4. */ + get _paneCount() { + return clamp(Math.round(settings.refractcascade.paneCount), 1, MAX_PANES); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the shot leaves the caster, world space. */ + _handPoint(out) { + const c = settings.refractcascade; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** Where pane `i` hangs, world space. */ + _panePoint(i, count, out) { + const c = settings.refractcascade; + const share = count <= 1 ? 0.5 : i / (count - 1); + const at = clamp(lerp(c.paneFirst, c.paneLast, share), 0.02, 0.99); + // Alternating sides, tapering: the zig-zag closes up downrange because a + // constant throw reads as a corridor rather than as a cascade. + const sign = (i % 2 === 0 ? 1 : -1) * (c.paneSide < 0 ? -1 : 1); + const throw_ = c.paneOffset * Math.pow(Math.max(0.01, c.paneOffsetTaper), i) * sign; + + this.pointAt(at, out); + out.addScaledVector(this.side, throw_); + // A live sine rather than an integrated drift, so dragging `paneBob` with + // the sandbox paused moves the pane instead of changing where it will + // drift to next. + out.y = c.paneAltitude + c.paneRise * i + c.paneBob * Math.sin(this.age * c.paneBobSpeed * TAU + i * 1.7 + this._seed); + return out; + } + + /** + * Rewrite the polyline from live settings, and with it each pane's arrival + * fraction. Called at the top of every sync — nothing it writes survives a + * frame. + * + * @returns {number} legs live this frame + */ + _resolveNodes() { + const c = settings.refractcascade; + const count = this._paneCount; + + this._handPoint(this._nodes[0]); + for (let i = 0; i < count; i++) this._panePoint(i, count, this._nodes[i + 1]); + this.pointAt(1, this._nodes[count + 1]); + this._nodes[count + 1].y = c.endHeight; + + // Arrival fractions, off the same nodes: the front has to reach a pane when + // it reaches the *pane*, not when a timer says so. + let total = 0; + for (let i = 0; i <= count; i++) total += this._nodes[i].distanceTo(this._nodes[i + 1]); + total = Math.max(total, 1e-4); + let cursor = 0; + for (let i = 0; i < count; i++) { + cursor += this._nodes[i].distanceTo(this._nodes[i + 1]); + this._arrive[i] = cursor / total; + } + for (let i = count; i < MAX_PANES; i++) this._arrive[i] = 2; // never reached + + this._legs = count + 1; + return this._legs; + } + + /** + * A point on the *drawn* path at `s`, 0..1 of the whole polyline. + * + * The dynamic light and the shed sparks are placed against this rather than + * against `pointAt()`, or they sit under the cascade in a straight line while + * the light is visibly somewhere else. + */ + _pathPoint(s, out) { + const legs = this._legs; + let total = 0; + for (let i = 0; i < legs; i++) total += this._nodes[i].distanceTo(this._nodes[i + 1]); + total = Math.max(total, 1e-4); + + const want = saturate(s) * total; + let cursor = 0; + for (let i = 0; i < legs; i++) { + const length = this._nodes[i].distanceTo(this._nodes[i + 1]); + if (want <= cursor + length || i === legs - 1) { + const local = length <= 1e-5 ? 0 : saturate((want - cursor) / length); + return out.copy(this._nodes[i]).lerp(this._nodes[i + 1], local); + } + cursor += length; + } + return out.copy(this._nodes[legs]); + } + + /** How far the light has got along the whole polyline, 0..1. */ + get _progress() { + return this.phase === AbilityPhase.TRAVEL ? this.u : 1; + } + + /** + * How far pane `i` has faded in, 0..1. + * + * The panes are not there at the start of the cast and do not all arrive at + * once: each one comes up over `paneStagger` of the span *before* the beam + * gets to it, so the eye has time to register a piece of glass standing in + * the air before the light hits it. + */ + _paneGrow(i) { + const c = settings.refractcascade; + if (this.phase !== AbilityPhase.TRAVEL) return 1; + const lead = Math.max(0.01, c.paneStagger); + return saturate((this.u - (this._arrive[i] - lead)) / lead); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sparkEmitter.reset(); + this.moteEmitter.reset(); + this.dustEmitter.reset(); + this._struck.fill(0); + + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + + this._syncUniforms(1); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and this frame's polyline into the panes and the + * beam. + * + * @param {number} fade 1 while the cascade is lit, ramping to 0 as it dies + */ + _syncUniforms(fade) { + const c = settings.refractcascade; + const g = settings.global; + const count = this._resolveNodes(); + const panes = count - 1; + + /* --- the panes ------------------------------------------------------ */ + for (let i = 0; i < MAX_PANES; i++) { + const pane = this.panes[i]; + if (i >= panes) { + pane.visible = false; + continue; + } + + const grow = this._paneGrow(i) * fade; + if (grow <= 0.01) { + pane.visible = false; + continue; + } + + /* the normal is the bisector — see the class comment */ + _in.subVectors(this._nodes[i + 1], this._nodes[i]).normalize(); + _out.subVectors(this._nodes[i + 2], this._nodes[i + 1]).normalize(); + _normal.subVectors(_out, _in); + if (_normal.lengthSq() < 1e-8) { + // The two legs are collinear: the pane is not turning the beam at all, + // so face it squarely at the incoming light rather than dividing by + // nothing. + _normal.copy(_in).multiplyScalar(-1); + } + _normal.normalize(); + + // An in-plane axis that keeps the pane standing up: the horizontal + // component first, world up as the fallback when the pane is lying flat. + _along.crossVectors(WORLD_UP, _normal); + if (_along.lengthSq() < 1e-8) _along.copy(this.side); + _along.normalize(); + // Rolling about the normal is the one rotation that cannot break the + // reflection, which is why it is the one the editor gets. + const roll = c.paneRoll + c.paneRollStep * i; + _along.applyAxisAngle(_normal, roll).normalize(); + + pane.visible = true; + pane.setPlacement(this._nodes[i + 1], _normal, _along); + + const scale = lerp(c.paneGrowScale, 1, Easing.outCubic(grow)); + const m = _mirror; + m.width = Math.max(0.05, c.paneWidth * scale); + m.height = Math.max(0.05, c.paneTall * scale); + m.opacity = c.paneOpacity * g.opacity * grow; + m.edgeFade = c.paneEdgeFade; + m.corner = c.paneCorner; + m.seed = this._seed + i * 3.7; + m.resolution = c.paneResolution; + m.reflectivity = c.paneReflectivity; + m.fresnel = c.paneFresnel * g.fresnel; + m.fresnelPower = c.paneFresnelPower; + m.roughness = c.paneRoughness; + m.blurRadius = c.paneBlurRadius; + m.blurTaps = c.paneBlurTaps; + m.roughStretch = c.paneRoughStretch; + m.ripple = c.paneRipple * g.noiseStrength; + m.rippleScale = c.paneRippleScale * g.noiseFrequency; + m.rippleSpeed = c.paneRippleSpeed * g.noiseSpeed; + // Nearer panes are struck first and are the ones the eye is on, so they + // get the budget's slots first. The starvation term in the module stops + // this from freezing the far ones out forever. + m.priority = c.panePriority * (1 + (panes - i) * 0.15); + m.colorTint = c.colorPaneTint; + m.colorBase = c.colorPaneBase; + pane.update(m); + } + + /* --- the beam ------------------------------------------------------- */ + const state = _beamState; + state.progress = this._progress; + state.fade = fade; + state.seed = this._seed; + this.beamGeometry.instanceCount = count; + this.beamMaterial.userData.sync(this._nodes, count, state); + + /* --- the three particle systems ------------------------------------- */ + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.beamGlow * 0.4 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.25 * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 0.9 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /** The flash at the caster's hand as the shot leaves it. */ + _muzzleFx() { + const c = settings.refractcascade; + const g = settings.global; + + this._handPoint(_pos); + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.muzzleSize * 0.2, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.3, + intensity: c.muzzleIntensity, + opacity: 0.85, + fresnel: 1.6, + displace: 0.4, + colorA: getColor(c.colorMuzzleA), + colorB: getColor(c.colorMuzzleB), + colorC: getColor(c.colorMuzzleC) + }); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /** + * One bounce. + * + * Fired on the frame the front crosses the pane's own arrival fraction, which + * is recomputed from the live node positions every frame — move a pane and + * the moment it is struck moves with it, in both directions. + */ + _bounceFx(index) { + const c = settings.refractcascade; + const g = settings.global; + + _pos.copy(this._nodes[index + 1]); + + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.bounceSize * 0.2, + endRadius: c.bounceSize * g.explosionIntensity, + life: 0.36, + intensity: c.bounceIntensity, + opacity: 0.8, + fresnel: 1.8, + displace: 0.3, + squash: 0.75, + colorA: getColor(c.colorBounceA), + colorB: getColor(c.colorBounceB), + colorC: getColor(c.colorBounceC) + }); + + // Sparks leave along the *outgoing* leg, not radially: light that has just + // been turned by a mirror is going somewhere, and a radial puff at a pane + // reads as the pane breaking. + _out.subVectors(this._nodes[index + 2], this._nodes[index + 1]).normalize(); + _emit.position = _pos; + _emit.radius = 0.14; + _emit.direction = _dir.copy(_out); + _emit.speed = c.sparkSpeed * 1.6; + _emit.speedVariance = 0.8; + _emit.spread = 0.5; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.15; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.sparks.emit(Math.round(c.bounceSparks * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorBounceC), c.bounceFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.55 * g.explosionIntensity; + } + + /** + * Sparks shed along the drawn part of the cascade, and dust around the panes. + * @param {number} scale 0..1 — thinned out once the beam is only holding + */ + _shedFx(dt, scale) { + const c = settings.refractcascade; + const g = settings.global; + const time = frame.uTime.value; + const reach = Math.max(0.02, this._progress); + + const sparkCount = Math.round(this.sparkEmitter.tick(dt, c.sparkRate * scale) * g.particleCount); + if (sparkCount > 0) { + this._pathPoint(randRange(0.02, 1) * reach, _pos); + _emit.position = _pos; + _emit.radius = c.beamRadiusNear * 2.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(sparkCount, _emit); + } + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + // Around a pane the beam has already reached — dust nobody has disturbed + // yet is not interesting. + const panes = this._legs - 1; + let pick = 0; + for (let i = panes - 1; i >= 0; i--) { + if (this._arrive[i] <= reach) { + pick = i; + break; + } + } + _pos.copy(this._nodes[pick + 1]); + _emit.position = _pos; + _emit.radius = c.moteScatter; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + // Dust only once the last leg is standing on the floor. There is nothing to + // lift before that, and a plume that starts with the cast gives away that + // the landing point was known in advance. + if (this._progress < 0.999) return; + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (dustCount > 0) { + this.pointAt(1, _pos).setY(0.1); + _emit.position = _pos; + _emit.radius = c.scorchRadius * 2.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.75; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.refractcascade; + this._syncUniforms(1); + + // Polled against the live geometry, never scheduled. + const panes = this._legs - 1; + for (let i = 0; i < panes; i++) { + if (!this._struck[i] && this.u >= this._arrive[i]) { + this._struck[i] = 1; + this._bounceFx(i); + } + } + + // The light rides the head of the beam along the *drawn* path, so it turns + // every corner instead of sliding down the floor. + this._pathPoint(this.u, this.position); + + this._shedFx(dt, 1); + this.ctx.shake.rumble(c.rumble * 0.5 * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.refractcascade; + const g = settings.global; + const time = frame.uTime.value; + + this._syncUniforms(1); + // Any pane the front skipped past inside one long frame still gets its + // bounce, or a low frame rate silently swallows the middle of the cascade. + const panes = this._legs - 1; + for (let i = 0; i < panes; i++) { + if (!this._struck[i]) { + this._struck[i] = 1; + this._bounceFx(i); + } + } + + _pos.copy(this._nodes[this._legs]); + // The last node is `endHeight` above the floor; the decals want the floor + // itself. Its own scratch rather than borrowing the emit direction's, which + // is the sort of reuse that survives review and then breaks the day + // somebody reorders two lines. + this.pointAt(1, _floor); + + /* the shell where the last leg lands */ + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.7, + displace: 0.5, + squash: 0.85, + colorA: getColor(c.colorBounceA), + colorB: getColor(c.colorBounceB), + colorC: getColor(c.colorBounceC) + }); + + /* the ring and the burn on the floor under it */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _floor, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.55, + width: 0.045, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + this.ctx.decals.spawn(DecalType.SCORCH, _floor, { + radius: c.scorchRadius * randRange(1.5, 2.3), + life: c.scorchLife, + intensity: c.scorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorEmber), + height: 0.015 + }); + + /* sparks out of the landing, dust off the floor under it */ + _emit.position = _pos; + _emit.radius = 0.24; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.4).setY(0.7).normalize(); + _emit.speed = c.sparkSpeed * 2.1; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(Math.round(c.burstSparks * g.particleCount), _emit); + + this.pointAt(1, _pos); + _emit.position = _pos; + _emit.radius = c.scorchRadius * 2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 2.0; + _emit.spread = 1.0; + _emit.size = 1.2; + _emit.life = c.dustLifetime * 1.2; + _emit.spin = 0.4; + this.dust.emit(Math.round(c.burstDust * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 while the cascade holds, then 1..2 while it lets go. Cubic + // on the way out: light does not dim, it stops. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._syncUniforms(fade); + this._pathPoint(1, this.position); + this._shedFx(dt, fade * (t <= 1 ? 0.55 : 0.2)); + this.ctx.shake.rumble(settings.refractcascade.rumble * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + // A visible mirror is registered against the two-reflections-a-frame + // budget, so a pooled cast that goes back to the pool still visible would + // hold a slot the next cast needs. Hiding is the release. + for (const pane of this.panes) pane.visible = false; + this._struck.fill(0); + this._legs = 2; + this.beamGeometry.instanceCount = 1; + this.beamMaterial.uniforms.uFade.value = 0; + } + + dispose() { + for (const pane of this.panes) pane.dispose(); + this.beamGeometry.dispose(); + this.beamMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/lumen/SolarLensAbility.js b/src/abilities/lumen/SolarLensAbility.js new file mode 100644 index 0000000..3061d5b --- /dev/null +++ b/src/abilities/lumen/SolarLensAbility.js @@ -0,0 +1,877 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { Caustics, CausticSource, CausticShape, causticsParams } from '../../vfx/Caustics.js'; +import { LensFlare, lensFlareParams } from '../../vfx/LensFlare.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** + * Pits the char field carries at once. The oldest is recycled, so this is how + * far back the burn trail is legible rather than how many are ever laid: at 34 + * pits a turn and a walk of a turn and a half, sixty-four covers the whole + * figure with a little history behind it. + */ +const CHAR_MARKS = 64; +/** + * Ghost instances built into the flare. `flareGhosts` clamps into this; eight + * is `MAX_FLARE_GHOSTS` and more than any real lens shows. + */ +const FLARE_GHOSTS = 8; +/** + * Pits laid in one frame, ceiling. Only ever reached when somebody drags + * `walkSpeed` or `charMarksPerTurn` a long way with the clock stopped — the + * walk's phase jumps, and without this the ring buffer would be rewritten + * several times over inside one paused frame for no visible gain. + */ +const MAX_MARKS_PER_FRAME = 6; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _lens = new Vector3(); +const _focus = new Vector3(); +const _walk = new Vector3(); +const _axis = new Vector3(); +const _centre = new Vector3(); +const _disc = { + origin: new Vector3(), + axis: new Vector3(0, -1, 0), + side: new Vector3(1, 0, 0), + span: 1, + t: 0, + fade: 1, + seed: 0 +}; +const _net = causticsParams(); +const _flare = lensFlareParams(); +const _char = groundFieldParams(); + +/** + * SOLAR LENS — a burning glass hung over the aimed circle. + * + * Three beats. An element **climbs** out to the zone as the front runs down the + * line; on arrival it **pulls focus**, the cone under it collapsing from a soft + * wash into a spot the size of a coin; and then the spot **walks** a rosette + * across the floor for `lifetime` seconds, charring a trail of pits behind it. + * + * ## THE TRICK — the flare is occlusion-tested, and that is the whole point + * + * The bright thing here is not the element in the air, it is the *burn on the + * floor*, and a burn on the floor is something the character can stand in front + * of. `vfx/LensFlare.js` is anchored to it, drawn after the scene with the + * depth test off — a ghost that vanishes behind a pillar is a decal, not a lens + * artefact — and buys the occlusion back by hand: the source's own screen + * position is sampled against `frame.uSceneDepth` with an area-uniform disc + * kernel, in the vertex shader, and every one of the eleven elements is scaled + * by the fraction of the source's disc that is showing. Walk the character + * across the burn and the streak, the ghosts and the iris ring all fall + * together and come back. Set `flareOcclusion` to 0 once to see what it is + * worth: the flare stops being a thing in the room and becomes a sticker on the + * monitor, which is exactly the twenty-year-old tell the roster line names. + * + * One thing to know before anybody "fixes" it: `npm run check` reports + * `flareOcclusion` as a **dead slider**, in both samples, and it is not. The + * module degrades by data rather than by branch — with nothing bound to + * `frame.uSceneDepth` it forces the occlusion term to 0 rather than sampling a + * buffer of zeroes and concluding the flare is buried in a wall — and the + * headless harness has no depth prepass. In the app the slider is the loudest + * control on this ability. + * + * `flareOccRadius` is the second half of the same trick and is easy to + * under-set. With a one-tap test the flare does not dim as a silhouette crosses + * it, it *switches*, on the frame the edge crosses one pixel, and eleven + * elements covering a third of the screen popping on and off is worse than no + * occlusion at all. The kernel spreads over the source's apparent size, and + * that size is this slider. + * + * ## What the light does on the way down + * + * `vfx/Caustics.js` in `CONE` mode is the light *arriving*. The apex is the + * element itself and the axis is the element→spot vector, so a spot out at the + * rim of the zone gives a correctly slanted ellipse on the floor for free — the + * cosine test against the axis does that, which is the reason the axis is a + * parameter at all. The pattern is a genuine fold: brightness is `1/|det J|` of + * the refraction map, so the filaments come out hairline without being told to, + * and the three channels fold in three slightly different places because they + * refract at three indices. The medium is glass rather than water — `netIor` + * 1.62, `netDispersion` 0.22 — which is why the fringes are as wide as they + * are. + * + * Pulling focus is one number, `coneAngle`, ramped from `focusWide` to + * `focusTight`. Nothing else about the ability is scheduled: the flare's + * intensity, the light's radius, the ember rate and the pit strength all read + * that same 0..1. + * + * ## The walk, and why the char trail survives a slider + * + * The spot's position is a rosette — carrier angle `walkSpeed × age`, radius + * `walkInner..walkOuter` on `sin²(petals·θ/2)` — evaluated **fresh every + * frame** from the settings and the cast's own clock. There is no integrator + * and no captured path. Pause mid-burn and drag `walkPetals` and the burning + * point *moves*, because where it is is a function of the slider. + * + * The pits it leaves are recorded in `vfx/GroundField.js` (`POCK`) as what the + * module asks for and nothing more: an `x`/`z` pair that are **fractions of the + * radius**, a timestamp, and a strength. Not one metre is stored, so dragging + * `zoneRadius` afterwards re-places *and* re-scales a burn that is already on + * the floor. A `DecalType.SCORCH` per pit would have been three lines shorter + * and would have captured its radius the moment it landed, which is the exact + * thing invariant I1 exists to forbid; the first version did that and the trail + * simply stopped listening the moment it was drawn. + * + * Laying them is unitless too. The frame asks "how many pits *should* exist by + * this phase" — `floor(phase × charMarksPerTurn)` — and lays the difference, + * so the count is derived rather than accumulated and dragging the walk + * backwards rewinds it instead of double-burning. + * + * ## Cost + * + * Four draw calls: the element (`Shell` `SUNDISC`), the net (`Caustics`), the + * flare (`LensFlare`) and the char field (`GroundField`). Three shared particle + * systems and one dynamic light. The expensive one is the caustic net, which is + * fill-bound rather than draw-bound — hence `netRadius`, which sizes the quad + * every frame and should stay honest. + * + * **What a cast captures:** a seed, one timestamp (`_igniteAt`, the moment + * focus was pulled) and an integer count of pits laid. Three unitless things. + */ +export class SolarLensAbility extends Ability { + constructor(context) { + super('solarlens', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the element: one instanced annulus with an inner radius of zero --- */ + this.disc = new Shell({ + mode: ShellMode.SUNDISC, + prefix: 'disc', + segments: 96, + renderOrder: 12 + }); + this.group.add(this.disc.group); + + /* --- the light arriving on the floor --- */ + this.net = new Caustics(this.group, { + source: CausticSource.SCROLL, + shape: CausticShape.CONE, + additive: true, + renderOrder: 7, + name: 'SolarLens:net' + }); + + /* --- the char, under everything --- */ + this.char = new GroundField(this.group, { + mode: GroundMode.POCK, + marks: CHAR_MARKS, + additive: false, + renderOrder: 3, + name: 'SolarLens:char' + }); + + /* --- the artefact on the glass. Added last: it draws at 3000. --- */ + this.flare = new LensFlare({ ghosts: FLARE_GHOSTS, name: 'SolarLens:flare' }); + this.group.add(this.flare.object3D); + + /** The one dice roll a cast makes. */ + this._seed = 0; + /** When focus was pulled, in the cast's own clock. `-1` = has not happened. */ + this._igniteAt = -1; + /** Pits laid so far. An integer, not a distance. */ + this._marksLaid = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Embers off the burning point: velocity-stretched streaks that *rise*, + // because a burn throws them up a thermal rather than out of an explosion. + this.embers = particles.get('solarlens.embers', { + capacity: 1600, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.embers.uniforms.uDrag.value = 1.9; + this.embers.uniforms.uEndSize.value = 0.2; + this.embers.uniforms.uSizeIn.value = 0.03; + this.embers.uniforms.uFadeIn.value = 0.05; + this.embers.uniforms.uFadeOut.value = 0.5; + + // The thread of smoke off the char. Non-additive so it genuinely occludes + // the net underneath it — smoke over a caustic is the whole reason to have + // both. + this.smoke = particles.get('solarlens.smoke', { + capacity: 1400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.smoke.uniforms.uDrag.value = 1.7; + this.smoke.uniforms.uEndSize.value = 3.2; + this.smoke.uniforms.uSizeIn.value = 0.14; + this.smoke.uniforms.uFadeIn.value = 0.2; + this.smoke.uniforms.uFadeOut.value = 0.32; + + // Dust turning over inside the cone. This is what makes the cone read as + // volume rather than as a decal on the floor. + this.motes = particles.get('solarlens.motes', { + capacity: 1200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.5; + this.motes.uniforms.uEndSize.value = 0.3; + this.motes.uniforms.uSizeIn.value = 0.08; + this.motes.uniforms.uFadeIn.value = 0.12; + this.motes.uniforms.uFadeOut.value = 0.42; + + this.emberEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + this.moteEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.char.markCount + this.disc.instanceCount; + } + + /** The burn is the long phase; the element only travels for a moment. */ + get impactDuration() { + return Math.max(0.05, settings.solarlens.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.solarlens.fadeTime); + } + + /** + * A lens breathes; it does not gutter. Anything quantised here would read as + * an arc lamp, and the whole school is about light behaving like light. + */ + lightShimmer() { + const c = settings.solarlens; + return 1 - c.lightPulse * (0.5 - 0.5 * Math.cos(this.age * c.lightPulseSpeed * TAU)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** How far focus has been pulled, 0..1. Everything else reads this. */ + get focus() { + const c = settings.solarlens; + if (this._igniteAt < 0) return 0; + const t = saturate((this.age - this._igniteAt) / Math.max(0.01, c.focusTime)); + return Math.pow(t, Math.max(0.05, c.focusCurve)); + } + + /** Turns of the carrier angle the walk has made. Unitless. */ + get walkPhase() { + const c = settings.solarlens; + if (this._igniteAt < 0) return 0; + return Math.max(0, this.age - this._igniteAt) * c.walkSpeed; + } + + /** + * The rosette, in **fractions of `zoneRadius`**, in the cast's own frame: + * `out.x` runs along `side`, `out.y` along `direction`. + * + * A rose rather than a circle because a circle is a brand and this is + * supposed to be somebody sweeping a burning glass about. `sin²(petals·θ/2)` + * rather than `|sin|` because the squared form has zero slope at both ends, + * so the spot decelerates into the tightest radius instead of pinging off it. + * + * Non-integer `walkPetals` never closes the figure, which is the default: two + * and a half lobes means the second pass over the floor misses the first. + */ + _walkOffset(phase, out) { + const c = settings.solarlens; + const theta = phase * TAU + c.walkPhase; + const lobe = 0.5 - 0.5 * Math.cos(c.walkPetals * theta); + let r = lerp(c.walkInner, c.walkOuter, lobe); + // A little secondary jitter so the trail is not a machined curve. On the + // *radius* rather than on the position, or the spot judders sideways and + // reads as frame drops. + r += c.walkWobble * Math.sin(theta * c.walkWobbleRate + this._seed); + out.x = Math.cos(theta) * r; + out.y = Math.sin(theta) * r; + return out; + } + + /** Centre of the aimed circle, on the floor. */ + _zoneCentre(out) { + return this.pointAt(1, out); + } + + /** + * Where the burning point is, world space. + * + * Before ignition it sits directly under the element, because an unfocused + * lens throws its wash straight down; the walk only exists once there is + * something worth walking. + */ + _focusPoint(out) { + const c = settings.solarlens; + this._zoneCentre(out); + if (this._igniteAt < 0) { + out.addScaledVector(this.direction, c.lensLead); + out.y = 0; + return out; + } + // `_walk` and not `_pos`: a caller is entitled to pass `_pos` itself as the + // output, and the first version used one scratch for both — which quietly + // overwrote the zone centre with the offset and threw the burn a zone's + // width away from where the flare was hanging, but only on the two frames + // that happened to call it that way. + this._walkOffset(this.walkPhase, _walk); + const radius = c.zoneRadius; + out.addScaledVector(this.side, _walk.x * radius).addScaledVector(this.direction, _walk.y * radius); + out.y = 0; + return out; + } + + /** + * Where the element is hanging, world space. + * + * It rides the front on the way out and climbs as it goes, so it is already + * overhead by the time the circle is reached rather than arriving and *then* + * getting up there. The bob is a live sine on the cast's clock rather than an + * integrated drift, so dragging `lensBob` with the sandbox paused moves the + * element instead of changing where it will drift to next. + */ + _lensPoint(out) { + const c = settings.solarlens; + const u = this.phase === AbilityPhase.TRAVEL ? this.u : 1; + const climb = Math.pow(u, Math.max(0.05, c.lensClimb)); + + this.pointAt(u, out); + out.addScaledVector(this.direction, c.lensLead * u); + out.addScaledVector(this.side, c.lensSway * Math.sin(this.age * c.lensSwaySpeed * TAU + this._seed) * u); + out.y = c.lensAltitude * climb + c.lensBob * Math.sin(this.age * c.lensBobSpeed * TAU + this._seed * 0.7) * u; + return out; + } + + /** Radius of the lit patch on the floor, metres. The spot. */ + _spotRadius() { + const c = settings.solarlens; + this._lensPoint(_lens); + this._focusPoint(_focus); + const drop = Math.max(0.2, _lens.distanceTo(_focus)); + return drop * Math.tan(lerp(c.focusWide, c.focusTight, this.focus)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.emberEmitter.reset(); + this.smokeEmitter.reset(); + this.moteEmitter.reset(); + this.char.clearMarks(); + this.net.reset(); + + this._igniteAt = -1; + this._marksLaid = 0; + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + + this.disc.visible = true; + this.net.setVisible(true); + this.char.setVisible(true); + this.flare.visible = false; // nothing is burning yet + + this._syncUniforms(1); + + const c = settings.solarlens; + const g = settings.global; + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.3 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current cast state into all four systems. + * @param {number} fade 1 while the lens burns, ramping to 0 as it lets go + */ + _syncUniforms(fade) { + const c = settings.solarlens; + const g = settings.global; + const focus = this.focus; + + this._lensPoint(_lens); + this._focusPoint(_focus); + this._zoneCentre(_centre); + _axis.subVectors(_focus, _lens); + const drop = Math.max(0.2, _axis.length()); + _axis.multiplyScalar(1 / drop); + + /* --- the element ---------------------------------------------------- */ + // `t` is the ability's own normalised life, which is what `Shell` wants: + // it interpolates `discRadius → discRadiusEnd` on a live easing exponent + // itself, so the bloom reshapes under the sliders while it is standing. + const disc = _disc; + disc.origin.copy(_lens); + disc.axis.copy(_axis); + disc.side.copy(this.side); + disc.span = Math.max(0.05, drop); + disc.t = focus; + disc.fade = fade; + disc.seed = this._seed; + this.disc.sync(c, disc, g); + + /* --- the light on the floor ----------------------------------------- */ + const net = _net; + net.centre = _focus; + net.lightAxis = _axis; + net.yaw = 0; + net.height = 0.016; + net.radius = Math.max(0.1, c.netRadius); + net.length = Math.max(0.2, c.netRadius * 2); + net.fade = fade * lerp(0.35, 1, focus); + net.front = 0.5; + net.now = this._igniteAt < 0 ? 0 : this._igniteAt; + net.seed = this._seed; + + net.depth = c.netDepth; + net.ior = c.netIor; + net.dispersion = c.netDispersion; + net.sampleStep = c.netSampleStep; + net.absorb = c.netAbsorb; + + net.foldFloor = c.netFoldFloor; + net.threshold = c.netThreshold; + net.gain = c.netGain; + net.sharpness = c.netSharpness; + net.rolloff = c.netRolloff; + + net.sourceAmp = c.netAmp; + net.cellScale = c.netCellScale; + net.cellRatio = c.netCellRatio; + net.cellJitter = c.netCellJitter; + net.driftAngle = c.netDriftAngle; + net.driftSpeed = c.netDriftSpeed; + net.boil = c.netBoil; + net.ridgeMix = c.netRidgeMix; + net.ridgeScale = c.netRidgeScale; + net.ridgePower = c.netRidgePower; + + // The projector. `coneAngle` is the ability: everything visible about + // pulling focus is this one number moving. + net.penumbra = c.netPenumbra; + net.coneAngle = clamp(lerp(c.focusWide, c.focusTight, focus), 0.01, 1.5); + net.projectorHeight = drop; + + net.additive = true; + net.emissive = c.netEmissive * g.glow; + net.opacity = c.netOpacity * g.opacity; + net.wash = c.netWash; + net.fringeAt = c.netFringeAt; + net.depthFade = c.netDepthFade; + net.colorNet = c.colorNet; + net.colorFringe = c.colorFringe; + net.colorWash = c.colorWash; + net.noiseStrength = g.noiseStrength; + net.noiseFrequency = g.noiseFrequency; + net.noiseSpeed = g.noiseSpeed; + net.opacityScale = 1; + this.net.update(net); + + /* --- the char ------------------------------------------------------- */ + const char = _char; + char.centre = _centre; + char.yaw = 0; + char.height = 0.012; + char.radius = Math.max(0.2, c.zoneRadius); + char.length = Math.max(0.2, c.zoneRadius * 2); + char.grow = 1; + char.recede = 0; + char.fade = fade; + char.seed = this._seed; + + char.edge = c.charEdge; + char.ragged = c.charRagged; + char.raggedScale = c.charRaggedScale; + char.warp = c.charWarp; + + char.relief = c.charRelief; + char.normalStep = c.charNormalStep; + char.ambient = c.charAmbient; + char.wrap = c.charWrap; + char.specular = c.charSpecular; + char.gloss = c.charGloss; + char.parallax = c.charParallax; + + char.depth = c.charDepth; + char.lift = c.charLift; + char.sharp = c.charSharp; + char.detail = c.charDetail; + + char.markLife = c.charMarkLife; + char.markRadius = c.charMarkRadius; + + char.additive = false; + char.emissive = c.charEmissive * g.glow; + char.opacity = c.charOpacity * g.opacity; + char.depthFade = c.charDepthFade; + char.colorBase = c.colorCharBase; + char.colorEdge = c.colorCharEdge; + char.colorGlow = c.colorCharGlow; + char.colorDeep = c.colorCharDeep; + char.noiseStrength = g.noiseStrength; + char.noiseFrequency = g.noiseFrequency; + char.noiseSpeed = g.noiseSpeed; + char.opacityScale = 1; + this.char.update(char); + + /* --- the flare ------------------------------------------------------ */ + // Anchored a few centimetres above the floor so the character's *body* + // crosses it rather than only their feet. That lift is a slider because it + // is the difference between an occlusion test that fires and one that never + // quite does. + this.flare.setAnchorXYZ(_focus.x, _focus.y + c.focusLift, _focus.z); + + const f = _flare; + f.intensity = c.flareIntensity * focus * fade * g.glow; + f.opacity = c.flareOpacity * g.opacity; + f.seed = this._seed; + f.headroom = c.flareHeadroom; + f.occlusion = c.flareOcclusion; + f.occRadius = c.flareOccRadius; + f.occTaps = c.flareOccTaps; + f.occFade = c.flareOccFade; + f.occSpin = c.flareOccSpin; + f.edgeStart = c.flareEdgeStart; + f.edgeEnd = c.flareEdgeEnd; + f.coreSize = c.flareCoreSize; + f.coreGlow = c.flareCoreGlow; + f.burstBlades = c.flareBurstBlades; + f.burstLength = c.flareBurstLength; + f.burstSharp = c.flareBurstSharp; + f.burstJitter = c.flareBurstJitter; + f.burstSpin = c.flareBurstSpin; + f.haloSize = c.flareHaloSize; + f.haloWidth = c.flareHaloWidth; + f.haloGlow = c.flareHaloGlow; + f.streakLength = c.flareStreakLength; + f.streakThickness = c.flareStreakThickness; + f.streakFalloff = c.flareStreakFalloff; + f.streakTight = c.flareStreakTight; + f.streakGlow = c.flareStreakGlow; + f.streakTilt = c.flareStreakTilt; + f.streakGrain = c.flareStreakGrain; + f.streakChroma = c.flareStreakChroma; + f.ghosts = c.flareGhosts; + f.ghostSpacing = c.flareGhostSpacing; + f.ghostStride = c.flareGhostStride; + f.ghostScatter = c.flareGhostScatter; + f.ghostSize = c.flareGhostSize; + f.ghostSizeStep = c.flareGhostSizeStep; + f.ghostSizeScatter = c.flareGhostSizeScatter; + f.ghostBlades = c.flareGhostBlades; + f.ghostRound = c.flareGhostRound; + f.ghostRoundStep = c.flareGhostRoundStep; + f.ghostSpin = c.flareGhostSpin; + f.ghostFill = c.flareGhostFill; + f.ghostRim = c.flareGhostRim; + f.ghostRimWidth = c.flareGhostRimWidth; + f.ghostSoft = c.flareGhostSoft; + f.ghostChroma = c.flareGhostChroma; + f.ghostGlow = c.flareGhostGlow; + f.ring = c.flareRing; + f.ringSpacing = c.flareRingSpacing; + f.ringSize = c.flareRingSize; + f.ringWidth = c.flareRingWidth; + f.ringBlades = c.flareRingBlades; + f.ringChroma = c.flareRingChroma; + f.ringGlow = c.flareRingGlow; + f.colorCore = c.colorFlareCore; + f.colorHalo = c.colorFlareHalo; + f.colorStreak = c.colorFlareStreak; + f.colorStreakEdge = c.colorFlareStreakEdge; + f.colorGhostA = c.colorFlareGhostA; + f.colorGhostB = c.colorFlareGhostB; + f.colorGhostC = c.colorFlareGhostC; + f.colorGhostD = c.colorFlareGhostD; + f.colorRing = c.colorFlareRing; + this.flare.update(f); + this.flare.visible = f.intensity > 0.005; + + /* --- the three particle systems ------------------------------------- */ + this.embers.setGradient( + getColor(c.colorEmberA), + getColor(c.colorEmberB), + getColor(c.colorEmberC), + getColor(c.colorEmberD) + ); + this.embers.uniforms.uGravity.value.set(0, c.emberGravity, 0); + this.embers.uniforms.uSizeScale.value = c.emberSize * g.particleSize * 7; + this.embers.uniforms.uLifeScale.value = c.emberLifetime * 0.5 * g.particleLifetime; + this.embers.uniforms.uSpeedScale.value = g.particleSpeed; + this.embers.uniforms.uOpacity.value = g.opacity; + this.embers.uniforms.uGlow.value = 1.2 * g.glow; + this.embers.uniforms.uStretch.value = c.emberStretch; + this.embers.uniforms.uTurbulence.value = 0.3 * g.turbulence; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 0.9 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + // The dynamic light lives on the burn, not on the element: the floor is + // where the energy ends up, and a light in the air over the circle lights + // the character's hair instead of their feet. + this.position.copy(_focus).setY(0.25); + } + + /** + * Lay whatever pits the walk's phase says should exist by now. + * + * Derived, not accumulated: `_marksLaid` is a count and the *want* is + * recomputed from the live phase, so dragging `walkSpeed` negative rewinds + * the trail instead of burning a second one on top of it. + */ + _burnFloor() { + const c = settings.solarlens; + if (this._igniteAt < 0) return; + + const perTurn = Math.max(0.1, c.charMarksPerTurn); + const want = Math.floor(Math.max(0, this.walkPhase) * perTurn); + if (want < this._marksLaid) { + this._marksLaid = want; + return; + } + + const now = frame.uTime.value; + let laid = 0; + while (this._marksLaid < want && laid < MAX_MARKS_PER_FRAME) { + this._marksLaid++; + laid++; + // Placed at the phase the pit *belongs* to rather than at the current + // one, so a long frame lays a line of pits along the path instead of a + // cluster at its end. + this._walkOffset(this._marksLaid / perTurn, _pos); + this.char.mark(_pos.x, _pos.y, now, saturate(this.focus)); + } + } + + /** + * Embers, smoke and motes. + * @param {number} scale 0..1 — thinned out as the lens lets go + */ + _burnFx(dt, scale) { + const c = settings.solarlens; + const g = settings.global; + const time = frame.uTime.value; + const focus = this.focus; + const spot = this._spotRadius(); + this._focusPoint(_focus); + + const emberCount = Math.round(this.emberEmitter.tick(dt, c.emberRate * focus * scale) * g.particleCount); + if (emberCount > 0) { + _emit.position = _focus; + _emit.radius = Math.max(0.02, spot * 0.8 + c.emberScatter * c.zoneRadius); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.emberSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.55; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.embers.emit(emberCount, _emit); + } + + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * focus * scale) * g.particleCount); + if (smokeCount > 0) { + _pos.copy(_focus).setY(0.1); + _emit.position = _pos; + _emit.radius = Math.max(0.05, spot * 1.2); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.5; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + // Seeded inside the cone rather than on the floor: pick a height up the + // axis and spread by the cone's own half-angle at that height, so the + // motes fill the shape the caustics say the light is coming down. + this._lensPoint(_lens); + const up = Math.random(); + _pos.copy(_focus).lerp(_lens, up); + _emit.position = _pos; + _emit.radius = Math.max(0.05, lerp(spot, spot * 6, up) * c.moteSpread); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.solarlens; + this._syncUniforms(1); + // The light rides the element while it is still climbing — there is nothing + // burning yet for it to sit on. + this._lensPoint(this.position); + this._burnFx(dt, 0.35); + this.ctx.shake.rumble(c.rumble * 0.4 * settings.global.cameraShake, dt); + } + + /** The element arrives and tips over: focus starts being pulled. */ + onImpact() { + const c = settings.solarlens; + const g = settings.global; + + this._igniteAt = this.age; + this._marksLaid = 0; + + this._focusPoint(_pos); + _pos.y = 0.2; + + this.ctx.bursts.spawn(BurstMode.FIRE, _pos, { + radius: c.igniteSize * 0.2, + endRadius: c.igniteSize * g.explosionIntensity, + life: 0.5, + intensity: c.igniteIntensity, + opacity: 0.75, + fresnel: 1.4, + displace: 0.45, + squash: 0.6, + colorA: getColor(c.colorIgniteA), + colorB: getColor(c.colorIgniteB), + colorC: getColor(c.colorIgniteC) + }); + + _emit.position = _pos; + _emit.radius = 0.18; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.emberSpeed * 2.2; + _emit.speedVariance = 0.85; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.8; + _emit.life = c.emberLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.embers.emit(Math.round(c.igniteEmbers * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.igniteFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.7 * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 while the lens burns, then 1..2 while it lets go. Cubic on + // the way out so the spot holds and then goes, rather than dimming for a + // second — a lens that fades slowly reads as a torch on a dimmer. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._syncUniforms(fade); + this._burnFloor(); + this._burnFx(dt, fade); + this.ctx.shake.rumble(settings.solarlens.rumble * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + this.flare.visible = false; + this.disc.visible = false; + this.net.setVisible(false); + this.char.setVisible(false); + this.char.clearMarks(); + this._igniteAt = -1; + this._marksLaid = 0; + } + + dispose() { + this.disc.dispose(); + this.net.dispose(); + this.char.dispose(); + this.flare.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/registry.js b/src/abilities/registry.js new file mode 100644 index 0000000..c481251 --- /dev/null +++ b/src/abilities/registry.js @@ -0,0 +1,1375 @@ +/** + * registry.js — abilities are data. + * + * This is the single place an ability is *declared*. Nothing else in the + * project enumerates abilities: the HUD builds its bar from here, the aim + * controller asks here whether it should draw an arrow or a circle, the editor + * groups its folders by the school named here, and `config/settings.js` derives + * `ELEMENTS` and `ELEMENT_META` from here rather than holding its own list. + * + * The two halves of a descriptor are loaded on very different schedules, and + * the split is the whole design: + * + * - **`load` is lazy.** A hundred ability classes are not constructed at boot; + * each one is a few hundred lines that build meshes, materials and particle + * systems the moment they are instantiated. `AbilityManager` builds a pool + * for an id the first time that id is selected or cast, and `App` calls + * `warm(id)` on selection so the class is in memory long before the click. + * - **`settings` is not lazy.** Every block is registered at module load, + * because the editor, the preset system, `DEFAULT_SETTINGS` and the aim + * controller all need the complete tree up front. Settings modules are plain + * numbers and `#rrggbb` strings with no imports — they must stay cheap. + * + * The `settings` field holds the **live block object**, not a copy and not a + * loader: `getAbility('ice').settings === settings.ice`. Mutating one mutates + * the other, which is what keeps a slider live on a standing effect. + * + * This module must never import `config/settings.js`. `settings.js` imports + * *this* to build its derived views, and closing that loop leaves the registry + * evaluating against a half-initialised module. `CastShape` lives in + * `config/castShape.js` for exactly that reason. + */ + +import { CastShape } from '../config/castShape.js'; +import { ABILITY_SETTINGS } from '../config/abilities/index.js'; + +/* ------------------------------------------------------------------ */ +/* Schools */ +/* ------------------------------------------------------------------ */ +/** + * The fifteen schools, declared once. + * + * A school is a grouping, not a mechanic — it decides which section of the + * spellbook an ability sits in, which top-level editor folder holds it, and + * which `ui/glyphs/.js` its sigil is authored in. `accent` is the + * school's own colour and is *not* inherited by its abilities: every ability + * carries its own `accent`, because two frost slots that are the same blue are + * two slots the player cannot tell apart at 34px. + * + * **Order is the reading order** of the spellbook rail, the editor's folder + * list and `abilitiesBySchool()`. The nine that shipped keep their positions — + * the rail is a shape people learn — and the six new schools are appended in + * the order `docs/ROSTER-II.md` introduces them. + * + * ───────────────────────────────────────────────────────────────────── + * ADDING A SCHOOL + * ───────────────────────────────────────────────────────────────────── + * 1. Append here. Never reorder. + * 2. Create `src/ui/glyphs/.js` from the stub shape the other fourteen + * use — the two append anchors included, because that is what lets several + * agents author sigils into one school without colliding — and fold it into + * `src/ui/glyphs/index.js`. `npm run check` fails a school with no glyph + * module, so a sixteenth school cannot land half-wired. + * 3. Give the editor a leading mark in `SCHOOL_MARK` (`ui/Editor.js`). Missing + * is survivable — it falls back to `◇` — but two schools sharing `◇` is a + * panel you cannot skim. + * Nothing else enumerates schools. + * + * **On the accents.** These are read as a 7–8px dot on near-black next to + * fourteen others, so "different enough" was decided by measuring rather than + * by eye: every pair was scored with the redmean approximation `check.mjs` now + * runs on every build, and the bar was set by the *tightest pair already + * shipping* — frost `#7ecbe0` against aether `#8fe8d8`, at 0.081. Anything + * closer than that is a pair the nine-school rail has never asked anyone to + * tell apart, and two of the six proposed accents were closer than that: + * + * - **Lumen** was proposed `#ffd98a`, which scores **0.033** against arcane's + * `#ffd27a` — a seven-unit difference in one channel, less than half the + * separation of the tightest shipped pair, and the worst collision in the + * set by a factor of two. Lumen is the school where light is the object + * rather than the ornament, so it took the bleached end of the warm ramp + * instead: `#ffeec2`, 0.144 from arcane. + * - **Chrono** was proposed `#e8c78a`, which scores **0.063** against the same + * arcane gold — still under the bar, and next to lumen's original it would + * have made three chips nobody could separate. It moved down and back to + * `#d5be8c`, a genuinely bone amber whose nearest neighbour is now 0.107 + * away. It also reads quieter, which is the school. + * + * **Forge stays as proposed.** `#d8763a` against flame's `#ff8a3c` looks like + * the obvious collision in this list and measures 0.097 — wider apart than + * frost and storm are today. Do not "fix" it. + * + * Tide, ink and hive landed as proposed: tide's saturated teal holds against + * frost's pale blue and aether's mint, ink's warm bone is far lighter and far + * flatter than stone's tan, and hive's olive is the only yellow-green in the + * set. + */ +export const SCHOOLS = Object.freeze([ + { id: 'frost', label: 'Frost', accent: '#7ecbe0' }, + { id: 'flame', label: 'Flame', accent: '#ff8a3c' }, + { id: 'storm', label: 'Storm', accent: '#7fb4ff' }, + { id: 'stone', label: 'Stone', accent: '#a89880' }, + { id: 'verdant', label: 'Verdant', accent: '#7fc85f' }, + { id: 'void', label: 'Void', accent: '#a98bff' }, + { id: 'arcane', label: 'Arcane', accent: '#ffd27a' }, + { id: 'blood', label: 'Blood', accent: '#e04a5a' }, + { id: 'aether', label: 'Aether', accent: '#8fe8d8' }, + { id: 'tide', label: 'Tide', accent: '#3fb9c8' }, // deep teal — wet, and lit from below + { id: 'forge', label: 'Forge', accent: '#d8763a' }, // scale orange — flame with the heat taken out + { id: 'lumen', label: 'Lumen', accent: '#ffeec2' }, // bleached warm white — light itself, not gold + { id: 'ink', label: 'Ink', accent: '#d8d2c4' }, // bone paper — the one matte school + { id: 'chrono', label: 'Chrono', accent: '#d5be8c' }, // bone amber — quiet, and slightly wrong + { id: 'hive', label: 'Hive', accent: '#b8a44a' } // chitin olive — the only yellow-green +]); + +/** school id → its descriptor. Built once; the list above is the source. */ +const SCHOOL_BY_ID = new Map(SCHOOLS.map((school) => [school.id, school])); + +/** Look up a school. Returns `undefined` for an unknown id — callers guard. */ +export function getSchool(id) { + return SCHOOL_BY_ID.get(id); +} + +/* ------------------------------------------------------------------ */ +/* The roster */ +/* ------------------------------------------------------------------ */ +/** + * One descriptor per ability. + * + * ``` + * { + * id, // settings key, particle-system prefix, glyph key, pool key + * label, // shown in the HUD slot, the spellbook card and the editor folder + * school, // one of SCHOOLS[].id + * accent, // this ability's own slot colour, #rrggbb + * key, // optional: the keyboard letter, if it holds a default loadout slot + * cast, // CastShape.LINE or CastShape.ZONE + * blurb, // one sentence, present tense, what the player sees + * load, // () => Promise — lazy, see the header + * settings // the live block from config/abilities/.js — NOT lazy + * } + * ``` + * + * Order is slot order: the first six hold the six keys the sandbox shipped + * with, and `ELEMENTS` is this array's ids in this order. + * + * ───────────────────────────────────────────────────────────────────── + * APPENDING TO THE ROSTER — read this before you add your entry + * ───────────────────────────────────────────────────────────────────── + * + * Seventeen agents at a time write into this array. To keep the diffs disjoint + * and the merges clean: + * + * 1. **Append.** Add your entry at the end of `ABILITIES`, below the marker + * comment. Never reorder or reformat the entries above yours — the six + * that shipped hold keyboard slots by array index. + * 2. **One entry, five lines.** Match the shape below exactly. No blank line + * inside an entry, one blank line between entries. + * 3. **`id` is the contract.** It is the settings key (`settings.rime`), the + * particle-system prefix (`'rime.mist'`), the glyph key + * (`ELEMENT_SIGILS.rime`), and the pool key. Lower case, no punctuation, + * the id printed in `docs/ROSTER.md`. + * 4. **`load` names the class, not the module.** `.then((m) => m.RimeAbility)` + * — the manager awaits a *class*, and a module namespace object thrown into + * `new` is a very confusing stack trace. + * 5. **`settings` is `ABILITY_SETTINGS.`**, which means your block must be + * imported in `config/abilities/index.js` first. Do that edit in the same + * commit or the registry entry throws on load. + * 6. **`accent` is yours to choose**, but check it against the school's other + * accents at 34px on the dark bar before you commit it. Two slots the same + * blue is a bug the compiler cannot see. + * 7. **No `key`.** Eight keyboard slots exist and a hundred abilities do not + * fit on them; the spellbook binds them at runtime and the binding is saved + * to `localStorage`. Omit the field entirely. + * + * The worked example, which is a real entry from `docs/ROSTER.md` and should be + * copied verbatim as a starting point: + * + * ```js + * { + * id: 'rime', + * label: 'Rimewalker', + * school: 'frost', + * accent: '#7ecbe0', + * cast: CastShape.LINE, + * blurb: 'Sheet ice glazes the floor and peels up behind the front.', + * load: () => import('./frost/RimeAbility.js').then((m) => m.RimeAbility), + * settings: ABILITY_SETTINGS.rime + * } + * ``` + * + * A `ZONE` entry is the same five lines with `cast: CastShape.ZONE`, plus a + * `zoneRadius` in its settings block — that is the entire difference, and the + * circle indicator, the reach ring and the snap-out come for free. + * + * New classes live in `src/abilities//Ability.js`. The six that + * shipped predate the school directories and stay where they are; do not move + * them, their relative imports are load-bearing and the churn buys nothing. + */ +export const ABILITIES = [ + { + id: 'ice', + label: 'Frost Lance', + school: 'frost', + accent: '#5fd0ff', + key: 'Q', + cast: CastShape.LINE, + blurb: 'A fracture front races out and a field of crystal tears up behind it.', + load: () => import('./IceAbility.js').then((m) => m.IceAbility), + settings: ABILITY_SETTINGS.ice + }, + + { + id: 'thunder', + label: 'Storm Lance', + school: 'storm', + accent: '#7fb4ff', + key: 'E', + cast: CastShape.LINE, + blurb: 'A bundle of lightning filaments snaps out, gutters, and blows out.', + load: () => import('./ThunderAbility.js').then((m) => m.ThunderAbility), + settings: ABILITY_SETTINGS.thunder + }, + + { + id: 'meteor', + label: 'Cinder Fall', + school: 'flame', + accent: '#ff8a3c', + key: 'R', + cast: CastShape.LINE, + blurb: 'A burning rock is lobbed downrange and tears the floor open where it lands.', + load: () => import('./MeteorAbility.js').then((m) => m.MeteorAbility), + settings: ABILITY_SETTINGS.meteor + }, + + { + id: 'beam', + label: 'Nova Beam', + school: 'arcane', + accent: '#7ff0ff', + key: 'F', + cast: CastShape.LINE, + blurb: 'A charged column of light is let out along the line, and holds there burning.', + load: () => import('./BeamAbility.js').then((m) => m.BeamAbility), + settings: ABILITY_SETTINGS.beam + }, + + { + id: 'snare', + label: 'Voltaic Snare', + school: 'storm', + accent: '#a98bff', + key: 'V', + cast: CastShape.ZONE, + blurb: 'A trap of current snaps open on the circle and hauls the air up into a pillar.', + load: () => import('./SnareAbility.js').then((m) => m.SnareAbility), + settings: ABILITY_SETTINGS.snare + }, + + { + id: 'glacier', + label: 'Glacial Crown', + school: 'frost', + accent: '#8ee8ff', + key: 'X', + cast: CastShape.ZONE, + blurb: 'A ring of blades closes around the footprint, stands, and breaks apart.', + load: () => import('./GlacierAbility.js').then((m) => m.GlacierAbility), + settings: ABILITY_SETTINGS.glacier + }, + + /* ---------------------------------------------------------------- */ + /* APPEND NEW ABILITIES BELOW THIS LINE — see the note above. */ + /* ---------------------------------------------------------------- */ + + { + id: 'geyser', + label: 'Geyser', + school: 'tide', + accent: '#1fd6e8', + cast: CastShape.ZONE, + blurb: 'A vent blows a column of water into the air, and the column comes back down as rain.', + load: () => import('./tide/GeyserAbility.js').then((m) => m.GeyserAbility), + settings: ABILITY_SETTINGS.geyser + }, + + { + id: 'pistondrive', + label: 'Piston Drive', + school: 'forge', + accent: '#9fb0bc', + cast: CastShape.LINE, + blurb: 'A battery of hydraulic rams bolts itself into the lane and slams down the line on a cam.', + load: () => import('./forge/PistondriveAbility.js').then((m) => m.PistondriveAbility), + settings: ABILITY_SETTINGS.pistondrive + }, + + { + id: 'astralgate', + label: 'Astral Gate', + school: 'arcane', + accent: '#7d5cff', + cast: CastShape.ZONE, + blurb: 'A ring gate tears open in the air and obelisks of astral stone climb out through it.', + load: () => import('./arcane/AstralgateAbility.js').then((m) => m.AstralgateAbility), + settings: ABILITY_SETTINGS.astralgate + }, + + { + id: 'entropy', + label: 'Entropy Wave', + school: 'chrono', + // Rusted bronze. Chrono is pale amber and bone, and every other slot in it + // takes the pale end; this is the one that is about corrosion, so it takes + // the ramp all the way down into the oxide. + accent: '#a8794e', + cast: CastShape.ZONE, + blurb: 'A wave of decay crosses the floor — rust, dust, moss and pitting — and then takes it all back.', + load: () => import('./chrono/EntropyAbility.js').then((m) => m.EntropyAbility), + settings: ABILITY_SETTINGS.entropy + }, + + { + id: 'rewind', + label: 'Rewind', + school: 'chrono', + accent: '#d99a2e', + cast: CastShape.LINE, + blurb: 'A gouge tears down the line, stops, and then unhappens — debris, dust and all.', + load: () => import('./chrono/RewindAbility.js').then((m) => m.RewindAbility), + settings: ABILITY_SETTINGS.rewind + }, + + { + id: 'undertow', + label: 'Undertow', + school: 'tide', + accent: '#3a7fd0', + cast: CastShape.ZONE, + blurb: 'A whirlpool opens on the circle and winds everything loose down into it.', + load: () => import('./tide/UndertowAbility.js').then((m) => m.UndertowAbility), + settings: ABILITY_SETTINGS.undertow + }, + + { + id: 'bonecage', + label: 'Bone Cage', + school: 'blood', + accent: '#f8c0b0', + cast: CastShape.ZONE, + blurb: 'Ribs of dry bone punch up around the circle and lay over into a dome.', + load: () => import('./blood/BonecageAbility.js').then((m) => m.BonecageAbility), + settings: ABILITY_SETTINGS.bonecage + }, + + { + id: 'torrent', + label: 'Torrent', + school: 'tide', + accent: '#0e7f8f', + cast: CastShape.LINE, + blurb: 'A cutting jet walks down the line and fans its spray out along the surface it hits.', + load: () => import('./tide/TorrentAbility.js').then((m) => m.TorrentAbility), + settings: ABILITY_SETTINGS.torrent + }, + + { + id: 'broodburst', + label: 'Broodburst', + school: 'hive', + accent: '#9bd14a', + cast: CastShape.ZONE, + blurb: 'A clutch of eggs swells, thins, splits, and lets the floor fill with crawlers.', + load: () => import('./hive/BroodburstAbility.js').then((m) => m.BroodburstAbility), + settings: ABILITY_SETTINGS.broodburst + }, + + { + id: 'obsidian', + label: 'Obsidian Bloom', + school: 'stone', + accent: '#7565a0', + cast: CastShape.ZONE, + blurb: 'Volcanic glass opens out of the floor in curved shells, stands, and goes to flakes.', + load: () => import('./stone/ObsidianAbility.js').then((m) => m.ObsidianAbility), + settings: ABILITY_SETTINGS.obsidian + }, + + { + id: 'waspfunnel', + label: 'Wasp Funnel', + school: 'hive', + accent: '#f0b429', + cast: CastShape.ZONE, + blurb: 'A nest opens on the circle and a funnel of wasps stands out of it, surging as it climbs.', + load: () => import('./hive/WaspfunnelAbility.js').then((m) => m.WaspfunnelAbility), + settings: ABILITY_SETTINGS.waspfunnel + }, + + { + id: 'silence', + label: 'Silence', + school: 'void', + accent: '#5c4b8c', + cast: CastShape.ZONE, + blurb: 'A sphere of the world stops being rendered. Not black — absent.', + load: () => import('./void/SilenceAbility.js').then((m) => m.SilenceAbility), + settings: ABILITY_SETTINGS.silence + }, + + { + id: 'refractcascade', + label: 'Refraction Cascade', + school: 'lumen', + accent: '#7fe0ff', + cast: CastShape.LINE, + blurb: 'The shot reaches the far end by bouncing off panes that reflect the real room.', + load: () => import('./lumen/RefractCascadeAbility.js').then((m) => m.RefractCascadeAbility), + settings: ABILITY_SETTINGS.refractcascade + }, + + { + id: 'afterimage', + label: 'Afterimage', + school: 'chrono', + // The pale end of the school's own bone amber. Echo Step took the gold and + // these two must not be one dot at 34px; `#efe4c8` against `#c9a86a` + // measures 0.240 apart on the redmean scale the school accents are judged + // by, which is three times the tightest pair that already ships. + accent: '#efe4c8', + cast: CastShape.LINE, + blurb: 'The cast leaves frozen copies of itself down the line, and every one of them is still live.', + load: () => import('./chrono/AfterimageAbility.js').then((m) => m.AfterimageAbility), + settings: ABILITY_SETTINGS.afterimage + }, + + { + id: 'shrapnel', + label: 'Shrapnel Bloom', + school: 'forge', + accent: '#e0913f', + cast: CastShape.ZONE, + blurb: 'A canister opens over the circle and throws machined steel that bounces off the floor.', + load: () => import('./forge/ShrapnelAbility.js').then((m) => m.ShrapnelAbility), + settings: ABILITY_SETTINGS.shrapnel + }, + + { + id: 'photonlattice', + label: 'Photon Lattice', + school: 'lumen', + accent: '#a8f0ff', + cast: CastShape.ZONE, + blurb: 'A grid of thin beams hangs over the circle; the crossings are bright because two beams add there.', + load: () => import('./lumen/PhotonLatticeAbility.js').then((m) => m.PhotonLatticeAbility), + settings: ABILITY_SETTINGS.photonlattice + }, + + { + id: 'splatterbrand', + label: 'Splatterbrand', + school: 'ink', + // Vermilion, the other pole of the school's palette from Ink Bloom's + // indigo. Warm against that slot's cool at 34px, which is the only + // separation that survives being shrunk. + accent: '#d1452c', + cast: CastShape.LINE, + blurb: 'A loaded brush is flung down the line: a directional mass, a crown of spikes, and droplets thrown past it.', + load: () => import('./ink/SplatterbrandAbility.js').then((m) => m.SplatterbrandAbility), + settings: ABILITY_SETTINGS.splatterbrand + }, + + { + id: 'sealscript', + label: 'Seal Script', + school: 'ink', + accent: '#8fa39a', // seal-stone jade — cool, so it never reads as cinnabar + cast: CastShape.ZONE, + blurb: 'A column of characters is written top-to-bottom in the air over a wash of ink.', + load: () => import('./ink/SealscriptAbility.js').then((m) => m.SealscriptAbility), + settings: ABILITY_SETTINGS.sealscript + }, + + { + id: 'hourglass', + label: 'Hourglass', + school: 'chrono', + accent: '#e8c27a', + cast: CastShape.ZONE, + blurb: 'Sand drains into a heap, the zone goes weightless, and then it all falls up.', + load: () => import('./chrono/HourglassAbility.js').then((m) => m.HourglassAbility), + settings: ABILITY_SETTINGS.hourglass + }, + + { + id: 'brinelock', + label: 'Brinelock', + school: 'tide', + accent: '#00d2b4', + cast: CastShape.LINE, + blurb: 'A lane of brine is thrown up in splash crowns and stopped mid-air as clear ice.', + load: () => import('./tide/BrinelockAbility.js').then((m) => m.BrinelockAbility), + settings: ABILITY_SETTINGS.brinelock + }, + + { + id: 'blackice', + label: 'Black Ice', + school: 'frost', + accent: '#4c8f96', + cast: CastShape.ZONE, + blurb: 'Meltwater floods the circle and freezes into a mirror that reflects the real room.', + load: () => import('./frost/BlackIceAbility.js').then((m) => m.BlackIceAbility), + settings: ABILITY_SETTINGS.blackice + }, + + { + id: 'bubblecage', + label: 'Abyssal Cage', + school: 'tide', + accent: '#a8ecf0', + cast: CastShape.ZONE, + blurb: 'A cage of soap film closes on the circle, drains, and bursts one bubble at a time.', + load: () => import('./tide/BubblecageAbility.js').then((m) => m.BubblecageAbility), + settings: ABILITY_SETTINGS.bubblecage + }, + + { + id: 'spellbreak', + label: 'Spellbreak', + school: 'arcane', + accent: '#6ee0c4', + cast: CastShape.ZONE, + blurb: 'A bell of arcane glass closes over the zone and breaks — and so does anything standing inside it.', + load: () => import('./arcane/SpellbreakAbility.js').then((m) => m.SpellbreakAbility), + settings: ABILITY_SETTINGS.spellbreak + }, + + { + id: 'sawline', + label: 'Sawline', + school: 'forge', + accent: '#ffae42', + cast: CastShape.LINE, + blurb: 'A machined blade buries its rim in the floor and runs the line, throwing sparks off the contact tangent.', + load: () => import('./forge/SawlineAbility.js').then((m) => m.SawlineAbility), + settings: ABILITY_SETTINGS.sawline + }, + + { + id: 'firewalk', + label: 'Firewalk', + school: 'flame', + accent: '#ff9e6a', + cast: CastShape.LINE, + blurb: 'Footprints ignite in sequence down the line, each throwing a short pillar of flame.', + load: () => import('./flame/FirewalkAbility.js').then((m) => m.FirewalkAbility), + settings: ABILITY_SETTINGS.firewalk + }, + + { + id: 'wildfire', + label: 'Wildfire', + school: 'flame', + accent: '#8f4a12', + cast: CastShape.ZONE, + blurb: 'Fire spreads across the circle cell by cell, jumping gaps and leaving unburnt islands.', + load: () => import('./flame/WildfireAbility.js').then((m) => m.WildfireAbility), + settings: ABILITY_SETTINGS.wildfire + }, + + { + id: 'scrollward', + label: 'Scrollward', + school: 'ink', + accent: '#bfa76a', + cast: CastShape.ZONE, + blurb: 'A ring of scrolls stands up and pays itself out into a wall of writing.', + load: () => import('./ink/ScrollwardAbility.js').then((m) => m.ScrollwardAbility), + settings: ABILITY_SETTINGS.scrollward + }, + + { + id: 'carapace', + label: 'Carapace', + school: 'hive', + accent: '#d2a02c', + cast: CastShape.ZONE, + blurb: 'Chitin plates fly in and lock into a dome that closes with no gaps.', + load: () => import('./hive/CarapaceAbility.js').then((m) => m.CarapaceAbility), + settings: ABILITY_SETTINGS.carapace + }, + + { + id: 'stasisfield', + label: 'Stasis Field', + school: 'chrono', + accent: '#8fb6a8', + cast: CastShape.ZONE, + blurb: 'A sphere of stopped time shuts over the circle and everything caught inside it holds.', + load: () => import('./chrono/StasisfieldAbility.js').then((m) => m.StasisfieldAbility), + settings: ABILITY_SETTINGS.stasisfield + }, + + { + id: 'gearlock', + label: 'Gearlock', + school: 'forge', + accent: '#69a2b6', + cast: CastShape.ZONE, + blurb: 'A train of involute gears winds up out of the floor, meshes, runs, and seizes.', + load: () => import('./forge/GearlockAbility.js').then((m) => m.GearlockAbility), + settings: ABILITY_SETTINGS.gearlock + }, + + { + id: 'quench', + label: 'Quench', + school: 'forge', + accent: '#db7b7a', + cast: CastShape.ZONE, + blurb: 'White-hot stock is plunged; the vapour blanket collapses and the steam is enormous.', + load: () => import('./forge/QuenchAbility.js').then((m) => m.QuenchAbility), + settings: ABILITY_SETTINGS.quench + }, + + { + id: 'unmake', + label: 'Unmake', + school: 'void', + accent: '#b84fc0', + cast: CastShape.LINE, + blurb: 'A lane of matter is heaved out of the floor and then stops existing, cube by growing cube.', + load: () => import('./void/UnmakeAbility.js').then((m) => m.UnmakeAbility), + settings: ABILITY_SETTINGS.unmake + }, + + { + id: 'solarlens', + label: 'Solar Lens', + school: 'lumen', + accent: '#fff0b0', + cast: CastShape.ZONE, + blurb: 'A burning glass hangs over the circle and walks its focus across the floor.', + load: () => import('./lumen/SolarLensAbility.js').then((m) => m.SolarLensAbility), + settings: ABILITY_SETTINGS.solarlens + }, + + { + id: 'anvilfall', + label: 'Anvilfall', + school: 'forge', + accent: '#b8b2a6', + cast: CastShape.ZONE, + blurb: 'A machined anvil falls out of nowhere, dishes the floor under itself, and stays there.', + load: () => import('./forge/AnvilfallAbility.js').then((m) => m.AnvilfallAbility), + settings: ABILITY_SETTINGS.anvilfall + }, + + { + id: 'tiderush', + label: 'Tiderush', + school: 'tide', + accent: '#36c9d8', + cast: CastShape.LINE, + blurb: 'A wave runs down the line and throws its own refracted light on the floor ahead of it.', + load: () => import('./tide/TiderushAbility.js').then((m) => m.TiderushAbility), + settings: ABILITY_SETTINGS.tiderush + }, + + { + id: 'godspear', + label: 'Godspear', + school: 'lumen', + accent: '#ffd94a', + cast: CastShape.LINE, + blurb: 'A colonnade of lit air walks down the line, and the room’s own dust burns as it drifts through.', + load: () => import('./lumen/GodspearAbility.js').then((m) => m.GodspearAbility), + settings: ABILITY_SETTINGS.godspear + }, + + { + id: 'locusttide', + label: 'Locust Tide', + school: 'hive', + accent: '#9dbf3c', + cast: CastShape.LINE, + blurb: 'A colony thrown down the line closes into a fist, opens into a wall and lands as a spear.', + load: () => import('./hive/LocusttideAbility.js').then((m) => m.LocusttideAbility), + settings: ABILITY_SETTINGS.locusttide + }, + + { + id: 'mycelium', + label: 'Mycelial Web', + school: 'verdant', + accent: '#4f9e86', + cast: CastShape.LINE, + blurb: 'A fungal mat runs out under the flagstones and shows only as light coming up through the mortar.', + load: () => import('./verdant/MyceliumAbility.js').then((m) => m.MyceliumAbility), + settings: ABILITY_SETTINGS.mycelium + }, + + { + id: 'featherfall', + label: 'Featherfall', + school: 'aether', + // Warm bone. Every other aether slot is a cool colour, and the one thing + // in the school that is not made of energy should not be lit like it is. + accent: '#ffe6bf', + cast: CastShape.ZONE, + blurb: 'Feathers come down over the circle — stalling, sideslipping, catching, and gliding.', + load: () => import('./aether/FeatherfallAbility.js').then((m) => m.FeatherfallAbility), + settings: ABILITY_SETTINGS.featherfall + }, + + { + id: 'mirage', + label: 'Mirage', + school: 'aether', + // Orchid. The five aether slots that already ship run blue, grey, cyan, + // mint and near-white; the one warm-shifted violet on the rail is the one + // that is not made of light at all. + accent: '#cfa8ff', + cast: CastShape.LINE, + blurb: 'A double of the caster sprints the line, drawn only in refraction, and is lost as it slows.', + load: () => import('./aether/MirageAbility.js').then((m) => m.MirageAbility), + settings: ABILITY_SETTINGS.mirage + }, + + { + id: 'echostep', + label: 'Echo Step', + school: 'chrono', + // Amber with the bone taken out of it. The school's own `#d5be8c` is the + // *pale* end of chrono and the snapshot slots want it; a run of echoes is + // the warmest thing this school does, so it takes the gold. + accent: '#c9a86a', + cast: CastShape.LINE, + blurb: 'Three copies of the caster run the line, each replaying what you just did, later and fainter.', + load: () => import('./chrono/EchostepAbility.js').then((m) => m.EchostepAbility), + settings: ABILITY_SETTINGS.echostep + }, + + { + id: 'origami', + label: 'Paper Storm', + school: 'ink', + accent: '#e0603c', + cast: CastShape.LINE, + blurb: 'A flight of folded cranes runs the line and comes apart into flat sheets of writing.', + load: () => import('./ink/OrigamiAbility.js').then((m) => m.OrigamiAbility), + settings: ABILITY_SETTINGS.origami + }, + + { + id: 'sheetlightning', + label: 'Sheet Lightning', + school: 'storm', + accent: '#d6b4ff', + cast: CastShape.ZONE, + blurb: 'A fracture buried in cloud strobes, and every real shadow on the stage snaps with it.', + load: () => import('./storm/SheetLightningAbility.js').then((m) => m.SheetLightningAbility), + settings: ABILITY_SETTINGS.sheetlightning + }, + + { + id: 'dawnbreak', + label: 'Dawnbreak', + school: 'lumen', + accent: '#ff9f70', + cast: CastShape.ZONE, + blurb: 'Takes hold of the sun and swings it overhead; every shadow on the stage sweeps with it.', + load: () => import('./lumen/DawnbreakAbility.js').then((m) => m.DawnbreakAbility), + settings: ABILITY_SETTINGS.dawnbreak + }, + + { + id: 'eclipse', + label: 'Eclipse', + school: 'lumen', + accent: '#d8c0f0', + cast: CastShape.ZONE, + blurb: 'The light goes wrong first — colour drains, the key cools — and only then does the black disc open.', + load: () => import('./lumen/EclipseAbility.js').then((m) => m.EclipseAbility), + settings: ABILITY_SETTINGS.eclipse + }, + + { + id: 'inkbloom', + label: 'Ink Bloom', + school: 'ink', + // Diluted indigo. The school's own accent is bone `#d8d2c4`, which the + // brush slots want; the two diffusion slots take the ink itself, one at + // each end of the palette — indigo here, vermilion on Splatterbrand. + accent: '#7f93b4', + cast: CastShape.ZONE, + blurb: 'A bead of ink opens on the water in branching fingers, wet at the edge and drying behind it.', + load: () => import('./ink/InkbloomAbility.js').then((m) => m.InkbloomAbility), + settings: ABILITY_SETTINGS.inkbloom + }, + + { + id: 'hivecolumn', + label: 'Hive Column', + school: 'hive', + accent: '#d8a828', + cast: CastShape.ZONE, + blurb: 'Comb cells bud outward from a seed, refusing each other, into a tower.', + load: () => import('./hive/HivecolumnAbility.js').then((m) => m.HivecolumnAbility), + settings: ABILITY_SETTINGS.hivecolumn + }, + + { + id: 'avalanche', + label: 'Avalanche', + school: 'frost', + accent: '#9ab0c0', + cast: CastShape.LINE, + blurb: 'Snow piles, slumps and finds its angle of repose, collapsing forward over itself.', + load: () => import('./frost/AvalancheAbility.js').then((m) => m.AvalancheAbility), + settings: ABILITY_SETTINGS.avalanche + }, + + { + id: 'sumistroke', + label: 'Sumi Stroke', + school: 'ink', + accent: '#c8492c', // cinnabar — the seal red, the only saturated thing in Ink + cast: CastShape.LINE, + blurb: 'One enormous brushstroke down the line, which runs out of ink before the end.', + load: () => import('./ink/SumistrokeAbility.js').then((m) => m.SumistrokeAbility), + settings: ABILITY_SETTINGS.sumistroke + }, + + { + id: 'webline', + label: 'Web Line', + school: 'hive', + accent: '#e2e6c4', + cast: CastShape.LINE, + blurb: 'A drag-line bites and an orb web spins across the lane, film and all.', + load: () => import('./hive/WeblineAbility.js').then((m) => m.WeblineAbility), + settings: ABILITY_SETTINGS.webline + }, + + { + id: 'shatterlance', + label: 'Shatterlance', + school: 'frost', + accent: '#3fa9e6', + cast: CastShape.LINE, + blurb: 'A single lance assembles in the air, hangs, fires, and breaks apart on arrival.', + load: () => import('./frost/ShatterlanceAbility.js').then((m) => m.ShatterlanceAbility), + settings: ABILITY_SETTINGS.shatterlance + }, + + { + id: 'soulchain', + label: 'Soul Tether', + school: 'void', + accent: '#8fe6c8', + cast: CastShape.LINE, + blurb: 'A chain of ghost-iron links is thrown out, snaps taut, and breaks link by link.', + load: () => import('./void/SoulchainAbility.js').then((m) => m.SoulchainAbility), + settings: ABILITY_SETTINGS.soulchain + }, + + { + id: 'skyfracture', + label: 'Sky Fracture', + school: 'aether', + accent: '#9fb4ff', + cast: CastShape.LINE, + blurb: 'The crack shows on the floor first, then the sky splits, then the pressure arrives.', + load: () => import('./aether/SkyfractureAbility.js').then((m) => m.SkyfractureAbility), + settings: ABILITY_SETTINGS.skyfracture + }, + + { + id: 'sanguinepact', + label: 'Sanguine Pact', + school: 'blood', + accent: '#e0505c', + cast: CastShape.ZONE, + blurb: 'A pool draws itself, sends up a mist column, and beads of blood climb around it until the pact seals.', + load: () => import('./blood/SanguinePactAbility.js').then((m) => m.SanguinePactAbility), + settings: ABILITY_SETTINGS.sanguinepact + }, + + { + id: 'magma', + label: 'Magma Fount', + school: 'flame', + accent: '#c9401a', + cast: CastShape.ZONE, + blurb: 'A molten pool splits the floor open and throws blobs back into itself.', + load: () => import('./flame/MagmaAbility.js').then((m) => m.MagmaAbility), + settings: ABILITY_SETTINGS.magma + }, + + { + id: 'hemolance', + label: 'Hemorrhage', + school: 'blood', + accent: '#ff5a66', + cast: CastShape.LINE, + blurb: 'Hair-thin lances ripple down the line and leave their mist hanging after them.', + load: () => import('./blood/HemolanceAbility.js').then((m) => m.HemolanceAbility), + settings: ABILITY_SETTINGS.hemolance + }, + + { + id: 'umbralspears', + label: 'Umbral Spears', + school: 'void', + accent: '#8a5fd0', + cast: CastShape.LINE, + blurb: 'Near-black spears rise out of the shadow, lit only along a violet rim.', + load: () => import('./void/UmbralSpearsAbility.js').then((m) => m.UmbralSpearsAbility), + settings: ABILITY_SETTINGS.umbralspears + }, + + { + id: 'nightfall', + label: 'Nightfall', + school: 'void', + accent: '#6478c8', + cast: CastShape.ZONE, + blurb: 'A dome of dark closes over the circle from the rim in, and the floor goes out under it.', + load: () => import('./void/NightfallAbility.js').then((m) => m.NightfallAbility), + settings: ABILITY_SETTINGS.nightfall + }, + + { + id: 'bloomburst', + label: 'Bloomburst', + school: 'verdant', + accent: '#e08ac8', + cast: CastShape.ZONE, + blurb: 'Flowers unfurl in a wave across the circle, then throw their petals at once.', + load: () => import('./verdant/BloomburstAbility.js').then((m) => m.BloomburstAbility), + settings: ABILITY_SETTINGS.bloomburst + }, + + { + id: 'sporefall', + label: 'Sporefall', + school: 'verdant', + accent: '#c8ff9a', + cast: CastShape.ZONE, + blurb: 'A slab of spores pours across the floor and glowing motes rise out of it.', + load: () => import('./verdant/SporefallAbility.js').then((m) => m.SporefallAbility), + settings: ABILITY_SETTINGS.sporefall + }, + + { + id: 'dragonbreath', + label: "Wyrm's Breath", + school: 'flame', + accent: '#ff3d14', + cast: CastShape.LINE, + blurb: 'A sustained cone of flame, hollow down the middle, burning a track into the floor.', + load: () => import('./flame/DragonbreathAbility.js').then((m) => m.DragonbreathAbility), + settings: ABILITY_SETTINGS.dragonbreath + }, + + { + id: 'grovecall', + label: 'Grovecall', + school: 'verdant', + accent: '#57c8a0', + cast: CastShape.ZONE, + blurb: 'Six trees rise on a wave around the circle and the sun comes through them.', + load: () => import('./verdant/GrovecallAbility.js').then((m) => m.GrovecallAbility), + settings: ABILITY_SETTINGS.grovecall + }, + + { + id: 'runeseal', + label: 'Runic Seal', + school: 'arcane', + accent: '#ff9d2e', + cast: CastShape.ZONE, + blurb: 'A seal writes itself on the floor, catches from the middle out, and goes off.', + load: () => import('./arcane/RunesealAbility.js').then((m) => m.RunesealAbility), + settings: ABILITY_SETTINGS.runeseal + }, + + { + id: 'glyphstorm', + label: 'Glyphstorm', + school: 'arcane', + accent: '#e8b45c', + cast: CastShape.LINE, + blurb: 'A blizzard of burning marks storms downrange and settles into a lattice.', + load: () => import('./arcane/GlyphstormAbility.js').then((m) => m.GlyphstormAbility), + settings: ABILITY_SETTINGS.glyphstorm + }, + + { + id: 'vinelash', + label: 'Verdant Lash', + school: 'verdant', + accent: '#4fc06a', + cast: CastShape.LINE, + blurb: 'A vine grows down the line, leafing as it goes, then snaps back and sheds.', + load: () => import('./verdant/VinelashAbility.js').then((m) => m.VinelashAbility), + settings: ABILITY_SETTINGS.vinelash + }, + + { + id: 'tectonic', + label: 'Tectonic Slam', + school: 'stone', + accent: '#c86a2a', + cast: CastShape.ZONE, + blurb: 'Five fissures race out to the boundary at different speeds, dust riding each tip.', + load: () => import('./stone/TectonicAbility.js').then((m) => m.TectonicAbility), + settings: ABILITY_SETTINGS.tectonic + }, + + { + id: 'boulder', + label: 'Rolling Ruin', + school: 'stone', + accent: '#8f7a4e', + cast: CastShape.LINE, + blurb: 'A boulder rolls down the line gouging a rut, and breaks apart on arrival.', + load: () => import('./stone/BoulderAbility.js').then((m) => m.BoulderAbility), + settings: ABILITY_SETTINGS.boulder + }, + + { + id: 'stonespine', + label: 'Stone Spine', + school: 'stone', + accent: '#b09a72', + cast: CastShape.LINE, + blurb: 'Slabs of floor tear loose and heave over on their hinges like river ice.', + load: () => import('./stone/StonespineAbility.js').then((m) => m.StonespineAbility), + settings: ABILITY_SETTINGS.stonespine + }, + + { + id: 'sinkhole', + label: 'Sinkhole', + school: 'stone', + accent: '#7a6a58', + cast: CastShape.ZONE, + blurb: 'The floor cracks, drops away into a funnel and swallows its own lip.', + load: () => import('./stone/SinkholeAbility.js').then((m) => m.SinkholeAbility), + settings: ABILITY_SETTINGS.sinkhole + }, + + { + id: 'singularity', + label: 'Singularity', + school: 'void', + accent: '#6f3bff', + cast: CastShape.ZONE, + blurb: 'A gravity well opens over the circle, bends the floor around it, and then inverts.', + load: () => import('./void/SingularityAbility.js').then((m) => m.SingularityAbility), + settings: ABILITY_SETTINGS.singularity + }, + + { + id: 'rime', + label: 'Rimewalker', + school: 'frost', + accent: '#7ecbe0', + cast: CastShape.LINE, + blurb: 'Sheet ice glazes the floor and peels up off it behind the front.', + load: () => import('./frost/RimeAbility.js').then((m) => m.RimeAbility), + settings: ABILITY_SETTINGS.rime + }, + + { + id: 'petrify', + label: 'Petrifying Gaze', + school: 'stone', + accent: '#8e9298', + cast: CastShape.LINE, + blurb: 'Grey facets gather out of the air into a stone column, then run out as sand.', + load: () => import('./stone/PetrifyAbility.js').then((m) => m.PetrifyAbility), + settings: ABILITY_SETTINGS.petrify + }, + + { + id: 'hail', + label: 'Hailwrath', + school: 'frost', + accent: '#bfe4f2', + cast: CastShape.ZONE, + blurb: 'Freezing air stands over the circle and then it comes down, pocking the floor white.', + load: () => import('./frost/HailAbility.js').then((m) => m.HailAbility), + settings: ABILITY_SETTINGS.hail + }, + + { + id: 'sunspear', + label: 'Sunspear', + school: 'flame', + accent: '#ffefa0', + cast: CastShape.LINE, + blurb: 'A white-fire javelin arcs downrange, bending the world behind it, and opens a sun on the floor.', + load: () => import('./flame/SunspearAbility.js').then((m) => m.SunspearAbility), + settings: ABILITY_SETTINGS.sunspear + }, + + { + id: 'cyclone', + label: 'Cyclone', + school: 'aether', + accent: '#aab6c2', + cast: CastShape.ZONE, + blurb: 'A vortex touches down on the circle, grinds the floor, and ropes out from the bottom up.', + load: () => import('./aether/CycloneAbility.js').then((m) => m.CycloneAbility), + settings: ABILITY_SETTINGS.cyclone + }, + + { + id: 'resonance', + label: 'Resonant Chord', + school: 'aether', + accent: '#6fd8ee', + cast: CastShape.LINE, + blurb: 'Rings run down the line and reflect, and the interference stands still where it lands.', + load: () => import('./aether/ResonanceAbility.js').then((m) => m.ResonanceAbility), + settings: ABILITY_SETTINGS.resonance + }, + + { + id: 'prismlance', + label: 'Prism Lance', + school: 'arcane', + accent: '#ff6bd6', + cast: CastShape.LINE, + blurb: 'A white lance breaks on a floating prism and finishes as a fan of colour.', + load: () => import('./arcane/PrismLanceAbility.js').then((m) => m.PrismLanceAbility), + settings: ABILITY_SETTINGS.prismlance + }, + + { + id: 'arcanevolley', + label: 'Arcane Volley', + school: 'arcane', + accent: '#b98cff', + cast: CastShape.LINE, + blurb: 'Seven bolts weave apart down the line and land on one point together.', + load: () => import('./arcane/ArcaneVolleyAbility.js').then((m) => m.ArcaneVolleyAbility), + settings: ABILITY_SETTINGS.arcanevolley + }, + + { + id: 'plaguebloom', + label: 'Plague Bloom', + school: 'blood', + accent: '#a8c04a', + cast: CastShape.ZONE, + blurb: 'A boiling cloud settles over the circle and the floor blisters and bursts beneath it.', + load: () => import('./blood/PlaguebloomAbility.js').then((m) => m.PlaguebloomAbility), + settings: ABILITY_SETTINGS.plaguebloom + }, + + { + id: 'aurora', + label: 'Aurora Veil', + school: 'aether', + accent: '#5fffc0', + cast: CastShape.ZONE, + blurb: 'Sheets of banded light rise around the circle, ripple for a long while, and climb away.', + load: () => import('./aether/AuroraAbility.js').then((m) => m.AuroraAbility), + settings: ABILITY_SETTINGS.aurora + }, + + { + id: 'starfall', + label: 'Starfall', + school: 'arcane', + accent: '#8fa8ff', + cast: CastShape.ZONE, + blurb: 'Cold stars fall out of one point in the sky and open rings across the circle.', + load: () => import('./arcane/StarfallAbility.js').then((m) => m.StarfallAbility), + settings: ABILITY_SETTINGS.starfall + }, + + { + id: 'chronofracture', + label: 'Chronofracture', + school: 'arcane', + accent: '#c8d4e0', + cast: CastShape.ZONE, + blurb: 'Panes of stopped time hang over the circle, hold dead still, and break all at once.', + load: () => import('./arcane/ChronofractureAbility.js').then((m) => m.ChronofractureAbility), + settings: ABILITY_SETTINGS.chronofracture + }, + + { + id: 'voidrift', + label: 'Void Rift', + school: 'void', + accent: '#c9a2ff', + cast: CastShape.LINE, + blurb: 'A slit tears open along the line and the stars behind it move at the wrong rate.', + load: () => import('./void/VoidriftAbility.js').then((m) => m.VoidriftAbility), + settings: ABILITY_SETTINGS.voidrift + }, + + { + id: 'crimsontide', + label: 'Crimson Tide', + school: 'blood', + accent: '#b0121f', + cast: CastShape.LINE, + blurb: 'A wave of blood surges down the line, curls, breaks and soaks away.', + load: () => import('./blood/CrimsonTideAbility.js').then((m) => m.CrimsonTideAbility), + settings: ABILITY_SETTINGS.crimsontide + }, + + { + id: 'thunderclap', + label: 'Thunderclap', + school: 'storm', + accent: '#cfe4ff', + cast: CastShape.ZONE, + blurb: 'A white flash and a dome, a beat of nothing, and then the pressure front arrives.', + load: () => import('./storm/ThunderclapAbility.js').then((m) => m.ThunderclapAbility), + settings: ABILITY_SETTINGS.thunderclap + }, + + { + id: 'stormwall', + label: 'Tempest Wall', + school: 'storm', + accent: '#5f7f9a', + cast: CastShape.LINE, + blurb: 'A curtain of rain rises across your heading, restrikes from inside, and drains away.', + load: () => import('./storm/StormwallAbility.js').then((m) => m.StormwallAbility), + settings: ABILITY_SETTINGS.stormwall + }, + + { + id: 'thornwake', + label: 'Thornwake', + school: 'verdant', + accent: '#96b83c', + cast: CastShape.LINE, + blurb: 'A barbed bramble tears up along the line and vines thread it into one mass.', + load: () => import('./verdant/ThornwakeAbility.js').then((m) => m.ThornwakeAbility), + settings: ABILITY_SETTINGS.thornwake + }, + + { + id: 'firewhip', + label: 'Ashen Lash', + school: 'flame', + accent: '#ff4a12', + cast: CastShape.LINE, + blurb: 'A burning lash is thrown down the line and cracks in mid-air where the loop runs off the tip.', + load: () => import('./flame/FirewhipAbility.js').then((m) => m.FirewhipAbility), + settings: ABILITY_SETTINGS.firewhip + }, + + { + id: 'railcoil', + label: 'Railcoil', + school: 'storm', + accent: '#4fd8ff', + cast: CastShape.LINE, + blurb: 'Coils collapse along the barrel and the shot is simply already there, cooling as it dies.', + load: () => import('./storm/RailcoilAbility.js').then((m) => m.RailcoilAbility), + settings: ABILITY_SETTINGS.railcoil + }, + + { + id: 'slipstream', + label: 'Slipstream', + school: 'aether', + accent: '#e2f2ff', + cast: CastShape.LINE, + blurb: 'A blade of vacuum opens along the line and the world behind it slides.', + load: () => import('./aether/SlipstreamAbility.js').then((m) => m.SlipstreamAbility), + settings: ABILITY_SETTINGS.slipstream + }, + + { + id: 'pyroclasm', + label: 'Pyroclasm', + school: 'flame', + accent: '#d2552e', + cast: CastShape.ZONE, + blurb: 'A dome of ash collapses onto its own centre, then blasts out past the boundary.', + load: () => import('./flame/PyroclasmAbility.js').then((m) => m.PyroclasmAbility), + settings: ABILITY_SETTINGS.pyroclasm + }, + + { + id: 'emberflock', + label: 'Emberflight', + school: 'flame', + accent: '#ffc24a', + cast: CastShape.LINE, + blurb: 'A flock of ember birds streams downrange, then collapses and goes up.', + load: () => import('./flame/EmberflockAbility.js').then((m) => m.EmberflockAbility), + settings: ABILITY_SETTINGS.emberflock + }, + + { + id: 'chainarc', + label: 'Chain Arc', + school: 'storm', + accent: '#00b4ff', + cast: CastShape.LINE, + blurb: 'The discharge hops from node to node down the line, lighting one segment at a time.', + load: () => import('./storm/ChainArcAbility.js').then((m) => m.ChainArcAbility), + settings: ABILITY_SETTINGS.chainarc + }, + + { + id: 'balllightning', + label: 'Fulminant Orb', + school: 'storm', + accent: '#5f6fff', + cast: CastShape.LINE, + blurb: 'A caged orb of current walks downrange at walking pace, then opens.', + load: () => import('./storm/BallLightningAbility.js').then((m) => m.BallLightningAbility), + settings: ABILITY_SETTINGS.balllightning + } + +]; + +/* ------------------------------------------------------------------ */ +/* Lookups */ +/* ------------------------------------------------------------------ */ + +/** id → descriptor. One map, built once; `getAbility` is called every frame. */ +const BY_ID = new Map(ABILITIES.map((ability) => [ability.id, ability])); + +/** + * Every registered id, in slot order. + * + * Frozen because `ELEMENTS` is this array — the HUD, `App` and the aim + * controller all hold on to it, and an ability list that can be spliced from + * anywhere is a class of bug nobody wants to chase. + */ +export const ABILITY_IDS = Object.freeze(ABILITIES.map((ability) => ability.id)); + +/** + * Look up one descriptor. Returns `undefined` for an unknown id. + * + * Callers guard rather than throw: the id can come from a `localStorage` + * loadout written by an older build, and a stale slot should quietly do + * nothing rather than take the frame loop down with it. + */ +export function getAbility(id) { + return BY_ID.get(id); +} + +/** + * The roster grouped for the spellbook: `[{ school, abilities }]`, in + * `SCHOOLS` order, with empty schools dropped. + * + * Built fresh on each call rather than cached — it is called when the + * spellbook opens, not per frame, and a cache here would silently go stale the + * first time somebody hot-reloads a registry entry in dev. + */ +export function abilitiesBySchool() { + const groups = []; + for (const school of SCHOOLS) { + const abilities = ABILITIES.filter((ability) => ability.school === school.id); + if (abilities.length) groups.push({ school, abilities }); + } + return groups; +} diff --git a/src/abilities/stone/BoulderAbility.js b/src/abilities/stone/BoulderAbility.js new file mode 100644 index 0000000..f11489b --- /dev/null +++ b/src/abilities/stone/BoulderAbility.js @@ -0,0 +1,870 @@ +import { MeshStandardMaterial, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Projectile, FlightMode, Stagger } from '../../vfx/Projectile.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { ShatterField, ShatterLayout } from '../../vfx/ShatterField.js'; +import { createAsteroidGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; +/** Contact samples the rut's fragment shader carries. A ring buffer. */ +const RUT_MARKS = 16; +/** Distinct fragment silhouettes when it breaks. Two draw calls. */ +const CHUNK_VARIANTS = 2; + +/* --- module-scope scratch: nothing below allocates on a frame (I3) --- */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _contact = new Vector3(); +const _anchor = new Vector3(); +const _inherit = new Vector3(); +/** Params objects, rewritten whole every frame and never cached between them. */ +const _flight = {}; +const _rut = { centre: new Vector3() }; +const _debris = { origin: new Vector3(), direction: new Vector3(), side: new Vector3() }; +const _look = {}; + +/** + * ROLLING RUIN — a boulder is put on the ground and shoved. + * + * It rolls the whole way. It gouges a rut behind it whose depth follows how + * hard it is pressing, throws chips and dust off the **contact point** rather + * than out of its middle, rumbles the floor in proportion to how fast it is + * going, and shatters into pieces of itself when it arrives. + * + * **THE TRICK — it never skates.** A rolling body's contact point is + * instantaneously at rest, so its angular velocity is fixed by its linear one: + * + * ``` + * v = ω × r ⇒ ω = v / r + * θ(t) = ∫ ω dt ⇒ θ = s / r (s = arc length travelled) + * ``` + * + * — the rotation is **distance over radius and nothing else**. No spin rate, no + * tumble slider, no `× 1.2` because it looked better. `vfx/Projectile.js` in + * `ROLL` mode does exactly that (`-travelled / radius` about the cast's `side` + * axis, negative because a body moving along `direction` turns *forward*), and + * the one thing this ability must not do is fight it: `spin` and `align` are + * left out of the params entirely. Get the factor wrong by any amount at all + * and the rock reads as a sphere being dragged, which is the first thing anyone + * notices and the only thing they will remember. + * + * Two smaller derivations hang off the same idea: + * + * - **The chatter pitch is the circumference.** The rut mode prints the body's + * own rim into the soft floor at a pitch of `cell` metres; that pitch is + * `2π · radius · rutChatter`, derived from the live body radius rather than + * typed in, so growing the boulder lengthens the marks it leaves behind it. + * - **The contact load is the speed.** `Projectile` publishes `contact` and + * `contactLoad` but deliberately keeps the load flat, because only the + * ability knows whether its body is accelerating. This one is: `pathCurve` + * is above 1, so the closed-form speed is + * `v = speed · pathCurve · u^(pathCurve−1)`, and the load posted to the rut + * is that speed against `loadSpeed`, curved. The gouge therefore *deepens* + * down the track, which is the whole reason `GroundField(RUT)` interpolates + * a force from posted samples instead of drawing a trench of constant depth. + * + * **One clock.** The projectile is not given the wall clock. It is given the + * ability's own front — `now = u · flightTime` — so the rock is exactly where + * `advance()` says the cast is and `arrivals` fires on precisely the frame the + * base class enters IMPACT. Two clocks in one ability is how a projectile ends + * up landing a frame before its own impact, and how a rut ends up with its head + * somewhere the boulder is not. + * + * **The rule that makes the editor work.** A cast captures a seed and a boolean + * (has it broken yet). Everything else — the radius, the track, the rut's + * width and depth, the load, the shatter — is resolved from `settings.boulder` + * inside the update loop, on a zero-length frame included. Pause it mid-roll + * and drag `radius`: the rock grows, the rut widens under it, the chatter marks + * lengthen because the circumference did, and the rotation re-derives itself + * from the new radius so it is still not skating. + */ +export class BoulderAbility extends Ability { + constructor(context) { + super('boulder', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* ---- the rock ---- */ + this.rockMaterial = new MeshStandardMaterial({ + color: 0x6e6455, + roughness: 0.95, + metalness: 0.02, + flatShading: true + }); + + this.body = new Projectile(this.group, { + capacity: 1, + geometry: () => this._rockGeometry(), + // The shape controls displace real vertices — the silhouette and the + // shadow have to see them — so a change rebuilds rather than being faked + // per instance. A few hundred triangles is cheap enough to regenerate, + // and that is what keeps them live sliders. + shapeKey: () => this._shapeKey(), + material: this.rockMaterial, + // No trail: a rock is not on fire. What it leaves behind is the rut, and + // that is a ground field, not a ribbon. + trail: false, + layer: LAYER.WORLD, + renderOrder: 2, + castShadow: true + }); + + /* ---- the rut it gouges ---- */ + this.rut = new GroundField(this.group, { + mode: GroundMode.RUT, + marks: RUT_MARKS, + additive: false, // a gouge is a hole: it shades the floor, never lights it + depthTest: true, + layer: LAYER.VFX, + name: 'BoulderRut' + }); + this.rut.setVisible(false); + + /* ---- what it breaks into ---- */ + // The same rock, cut. `createAsteroidGeometry` slices planar facets off a + // lumpy sphere; wind `cuts` up and `cutDepth` deep and what comes back is a + // wedge with flat fracture faces, which is what a boulder actually breaks + // into. The first version used tetrahedra and read as gravel. + this.chunks = new ShatterField(this.group, { + geometry: (variant) => this._chunkGeometry(variant), + variants: CHUNK_VARIANTS, + capacity: 160, + additive: false, + depthWrite: true, + layer: LAYER.WORLD, + renderOrder: 2, + castShadow: true, + receiveShadow: true + }); + + /** The one boolean a cast captures: has it come apart yet. */ + this._shattered = false; + this._seed = 0; + + /** + * The contact samples, and why the ability keeps hold of them. + * + * `GroundField#mark()` hands back the `Vector4` it wrote into — and that + * vector *is* the uniform, not a copy of it. Keeping the references lets + * the load and the along-track position of every sample already on the + * floor be **re-derived** from the live settings on every frame, including + * a zero-length one. Without that, `loadSpeed`, `loadCurve`, `loadPeak` and + * `pathCurve` would be baked into the rut at the instant each sample was + * posted, and dragging any of them on a paused boulder would do nothing to + * the trench behind it: three dead sliders and a fourth half-dead, all + * because the ability had written down a dimension. The *events* here are + * `_markU` — the unitless fraction of the cast each sample was taken at — + * and nothing else. + */ + this._markSlots = new Array(RUT_MARKS).fill(null); + this._markU = new Float32Array(RUT_MARKS); + this._markCount = 0; + /** The cast fraction the last sample was taken at, 0..1. */ + this._markedU = 0; + } + + /** The body, in unit space. Every number here is a live shape slider. */ + _rockGeometry() { + const c = settings.boulder; + return createAsteroidGeometry({ + seed: 6.1, + detail: clamp(Math.round(c.rockDetail), 0, 3), + lumpiness: c.rockLumps, + noiseScale: c.rockLumpScale, + roughness: c.rockRough, + cuts: Math.round(c.rockCuts), + cutDepth: c.rockCutDepth, + craters: Math.round(c.rockCraters), + craterDepth: c.rockCraterDepth + }); + } + + /** One fragment silhouette: the same stone, cut much harder. */ + _chunkGeometry(variant) { + const c = settings.boulder; + return createAsteroidGeometry({ + seed: 21.7 + variant * 13.3, + // Low subdivision on purpose: a heavily sliced rock at detail 2 rounds + // itself back off into a pebble, and these have to keep their corners. + detail: 1, + lumpiness: c.rockLumps * 0.8, + noiseScale: c.rockLumpScale * 1.6, + roughness: c.rockRough, + cuts: 9 + variant * 2, + cutDepth: 0.55, + craters: 1 + }); + } + + /** Hashed by `Projectile#syncGeometry`; a change rebuilds the body. */ + _shapeKey() { + const c = settings.boulder; + return `${Math.round(c.rockDetail)}|${c.rockLumps.toFixed(3)}|${c.rockLumpScale.toFixed(3)}|${c.rockRough.toFixed(3)}|${Math.round(c.rockCuts)}|${c.rockCutDepth.toFixed(3)}|${Math.round(c.rockCraters)}|${c.rockCraterDepth.toFixed(3)}`; + } + + createParticles() { + const particles = this.ctx.particles; + + // The plume off the contact patch. Non-additive: dust thrown up by + // something heavy has to occlude, or the rock reads as travelling through + // a fog rather than making one. + this.dust = particles.get('boulder.dust', { + capacity: 2600, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 2.0; + this.dust.uniforms.uEndSize.value = 3.0; + this.dust.uniforms.uSizeIn.value = 0.1; + this.dust.uniforms.uFadeIn.value = 0.12; + this.dust.uniforms.uFadeOut.value = 0.36; + + // Chips of floor spat backwards out from under it. + this.chips = particles.get('boulder.chips', { + capacity: 2200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.22; + this.chips.uniforms.uEndSize.value = 0.85; + this.chips.uniforms.uFadeOut.value = 0.74; + + this.dustEmitter = new RateEmitter(); + this.chipEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.body.count + this.chunks.count; + } + + /** The pieces have to have finished flying before the rut starts to go. */ + get impactDuration() { + const c = settings.boulder; + return Math.max(0.3, (c.debrisLifetime + c.holdTime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.2, settings.boulder.rutFadeTime); + } + + /** Body radius, metres. Read in six places, so it lives in one. */ + get rockRadius() { + return Math.max(0.05, settings.boulder.radius); + } + + /** + * Ground speed of the rock right now, metres/second. + * + * Closed form, not a difference of two positions. The flight is + * `s(u) = length · u^pathCurve` against a front that `advance()` moves at + * `speed · global.speed`, so `ds/dt = speed · pathCurve · u^(pathCurve−1)`. + * Differencing positions would work while the clock runs and return zero the + * moment it stops, which is exactly when the sliders are being dragged — the + * rut would flatten out under a paused boulder and nobody would understand + * why. (The base class's 80 ms ease-off-the-mark is not mirrored here; at + * `pathCurve > 1` the `u^(pathCurve−1)` term already starts the rock at + * zero, which is the part that has to be true.) + */ + get rollSpeed() { + return this._speedAt(this.u); + } + + /** Ground speed at any point of the cast, metres/second. */ + _speedAt(u) { + const c = settings.boulder; + const curve = Math.max(0.05, c.pathCurve); + const base = c.speed * settings.global.speed; + return base * curve * Math.pow(Math.max(u, 1e-4), curve - 1); + } + + /** 0..1 — how hard it is pressing on the floor, and how deep the rut goes. */ + get contactLoad() { + return this._loadAt(this.u); + } + + /** The load at any point of the cast. Re-evaluated, never remembered. */ + _loadAt(u) { + const c = settings.boulder; + const ratio = this._speedAt(u) / Math.max(0.1, c.loadSpeed); + return saturate(Math.pow(saturate(ratio), Math.max(0.05, c.loadCurve)) * c.loadPeak); + } + + /** How far down the track the body is, 0..1. The rut's head sits here. */ + get trackProgress() { + return this._trackAt(this.u); + } + + /** Where a cast fraction sits along the track, 0..1 — the flight's own `s`. */ + _trackAt(u) { + return saturate(Math.pow(saturate(u), Math.max(0.05, settings.boulder.pathCurve))); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.chipEmitter.reset(); + + this._seed = Math.random() * 100; + this._shattered = false; + this._markedU = 0; + this._markCount = 0; + this._markSlots.fill(null); + + this.body.reset(); + this.body.roll(this._seed); + this.chunks.clear(); + this.rut.clearMarks(); + this.rut.setVisible(false); + + this._sync(0, 1); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve everything and drive the three modules. + * + * @param {number} dt seconds; zero on a paused frame, which must still work + * @param {number} fade 1 while the rut is fresh, ramping to 0 as it goes + */ + _sync(dt, fade) { + const c = settings.boulder; + const g = settings.global; + + /* ---- the flight ---- */ + _flight.mode = FlightMode.ROLL; + _flight.stagger = Stagger.NONE; + _flight.count = this._shattered ? 0 : 1; + _flight.radius = this.rockRadius; + _flight.sizeJitter = 0; + _flight.stretch = 1; + // `align` and `spin` are deliberately absent. Either of them would put a + // second rotation on top of the rolling one and the contact point would + // stop being at rest — see the class comment. + _flight.flash = c.flash; + _flight.handForward = c.handForward; + _flight.handSide = c.handSide; + _flight.spreadSide = 0; + _flight.spreadForward = 0; + _flight.pathCurve = c.pathCurve; + _flight.apex = 0; // it is on the floor. A rolling body that lofts is a bounce. + _flight.load = this.contactLoad; + _flight.linger = 0; + _flight.sink = 0; + // Any positive number works — the clock below is the ability's own front, + // not the wall clock — but deriving it from the cast keeps `speed` meaning + // seconds if anything ever reads `flightTime` back. + const flightTime = Math.max(0.05, this.length / Math.max(0.2, c.speed * g.speed)); + _flight.flightTime = flightTime; + _flight.speedJitter = 0; + _flight.lead = 0; + _flight.window = 0; + + this.body.setBasis(this.origin, this.direction, this.side, this.length); + this.body.update(this.u * flightTime, _flight); + + /* ---- where it is pressing ---- */ + if (!this._shattered && this.body.count > 0) { + _contact.copy(this.body.contact); + } + + /* ---- the rut ---- */ + this._syncRut(c, g, fade); + + /* ---- the pieces ---- */ + this._syncChunks(c, g, fade); + + /* ---- the two particle systems ---- */ + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + + this.rockMaterial.color.copy(getColor(c.colorRock)); + } + + /** + * The gouge. + * + * `GroundField(RUT)` anchors at the **start** of the track and slides its own + * quad downrange, so the anchor is where the boulder was put down and the + * length is what is left of the cast in front of it. `progress` is the head: + * it is `u^pathCurve`, the same expression the body's own position uses, so + * the churn at the end of the rut is under the rock and not chasing it. + */ + _syncRut(c, g, fade) { + // Re-derive every sample already on the floor. `_markU` is the fraction of + // the cast it was taken at — an event; where that is along the track and + // how hard the body was pressing there are both consequences of the live + // sliders, so they are recomputed rather than remembered. This is the line + // that makes `loadSpeed`, `loadCurve`, `loadPeak` and `pathCurve` reshape a + // rut that is already lying in the floor with the clock stopped. + for (let i = 0; i < this._markCount; i++) { + const slot = this._markSlots[i]; + if (!slot) continue; + const u = this._markU[i]; + slot.y = this._trackAt(u); + slot.w = this._loadAt(u); + } + + _rut.centre.copy(this.origin).addScaledVector(this.direction, c.handForward); + _rut.yaw = Math.atan2(this.direction.x, this.direction.z); + _rut.height = c.rutHeight; + _rut.radius = Math.max(0.2, c.rutWidth * 3); // only used to size the quad in RUT + _rut.length = Math.max(0.5, this.length - c.handForward); + _rut.progress = this.trackProgress; + _rut.grow = 1; + _rut.recede = 0; + _rut.fade = fade; + _rut.seed = this._seed; + + _rut.edge = c.rutEdge; + _rut.ragged = c.rutRagged; + _rut.raggedScale = c.rutRaggedScale; + _rut.warp = c.rutWarp; + + _rut.relief = c.rutRelief; + _rut.normalStep = c.rutNormalStep; + _rut.ambient = c.rutAmbient; + _rut.wrap = c.rutWrap; + _rut.specular = c.rutSpecular; + _rut.gloss = c.rutGloss; + _rut.parallax = c.rutParallax; + + _rut.width = c.rutWidth; + _rut.depth = c.rutDepth; + _rut.sharp = c.rutSharp; + _rut.detail = c.rutChatterDepth; + // The pitch of the chatter is the body's own circumference. Derived, not + // typed: grow the boulder and the marks it prints get further apart, which + // is the whole reason they read as *its* marks. + _rut.cell = Math.max(0.05, TAU * this.rockRadius * c.rutChatter); + _rut.lift = c.rutSpoil; + _rut.thickness = c.rutSpoilWidth; + _rut.seam = c.rutSampleBlend; + // The track's own lateral drift, off by default: this boulder rolls down + // the cast line and a rut that wanders away from it is a rut belonging to + // something else. + _rut.swirl = c.rutDrift; + _rut.speed = 1; + _rut.flow = 0; + _rut.windAngle = 0; + + _rut.additive = false; + _rut.emissive = c.rutEmissive; + _rut.opacity = c.rutOpacity; + _rut.depthFade = c.rutDepthFade; + _rut.colorBase = c.colorRutBase; + _rut.colorEdge = c.colorRutEdge; + _rut.colorGlow = c.colorRutChurn; + _rut.colorDeep = c.colorRutDeep; + + _rut.noiseStrength = g.noiseStrength; + _rut.noiseFrequency = g.noiseFrequency; + _rut.noiseSpeed = g.noiseSpeed; + _rut.opacityScale = g.opacity; + + this.rut.update(_rut); + this.rut.setVisible(fade > 0.004 && this.trackProgress > 0.001); + } + + /** The pieces, once there are any. */ + _syncChunks(c, g, fade) { + _debris.layout = ShatterLayout.LINE; + _debris.origin.copy(this.origin); + _debris.direction.copy(this.direction); + _debris.side.copy(this.side); + _debris.length = this.length; + _debris.width = this.rockRadius; + _debris.spawnRadius = this.rockRadius * c.debrisScatter; + _debris.spawnHeight = this.rockRadius * c.debrisHeight; + + _debris.speed = c.debrisSpeed; + _debris.speedJitter = c.debrisSpeedJitter; + _debris.spread = c.debrisSpread; + _debris.upBias = c.debrisUp; + // What it was doing when it stopped. A burst with an inherited velocity and + // a low spread is a thing that shattered; the same burst at spread 1 is a + // firework, which is the note in the module's own docs and it is correct. + _debris.inherit = _inherit.copy(this.direction).multiplyScalar(this.rollSpeed); + _debris.inheritScale = c.debrisInherit; + + _debris.gravity = c.debrisGravity; + _debris.drag = c.debrisDrag; + _debris.size = c.debrisSize; + _debris.sizeJitter = c.debrisSizeJitter; + _debris.shrink = c.debrisShrink; + _debris.shrinkPower = c.debrisShrinkPower; + _debris.spin = c.debrisSpin; + _debris.spinJitter = c.debrisSpinJitter; + _debris.lifetime = c.debrisLifetime * settings.global.lifetime; + _debris.floor = 0; + _debris.floorSpin = c.debrisFloorSpin; + _debris.randomness = g.randomness; + + _look.colorA = getColor(c.colorChunkA); + _look.colorB = getColor(c.colorChunkB); + _look.colorEdge = getColor(c.colorChunkEdge); + _look.colorScene = getColor(c.colorChunkTint); + _look.opacity = fade * g.opacity; + _look.glow = c.chunkGlow * g.glow; + _look.rim = c.chunkRim * g.fresnel; + _look.rimPower = c.chunkRimPower; + _look.shade = c.chunkShade; + _look.ambient = c.chunkAmbient; + _look.fadeStart = c.chunkFadeStart; + _look.soft = c.chunkSoft; + _look.sceneMix = 0; + _look.refract = 0; + _look.saturation = 0; + + this.chunks.sync(_look); + this.chunks.update(this.age, _debris); + } + + /** + * Record that the body was in contact at cast fraction `u`. + * + * The ring buffer holds `RUT_MARKS`; posting past it would recycle a slot + * this ability is still holding a reference to, so it simply stops. Sixteen + * samples over a twenty-metre track is one every 1.3 m, and the shader blends + * between them over `rutSampleBlend` metres anyway. + */ + _postSample(u) { + if (this._markCount >= RUT_MARKS) return; + const index = this._markCount++; + this._markU[index] = saturate(u); + this._markSlots[index] = this.rut.mark(0, this._trackAt(u), this.age, this._loadAt(u)); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** The thud as it is put down and shoved. */ + _muzzleFx() { + const c = settings.boulder; + const g = settings.global; + + _pos.copy(this.origin).addScaledVector(this.direction, c.handForward); + _pos.y = this.rockRadius * 0.4; + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.muzzleSize * 0.3, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.5, + intensity: c.muzzleIntensity, + opacity: 0.7, + fresnel: 1.1, + displace: 0.6, + squash: 0.5, + colorA: getColor(c.colorDustB), + colorB: getColor(c.colorDustA), + colorC: getColor(c.colorRock) + }); + + _emit.position = _pos; + _emit.radius = this.rockRadius * 0.8; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.4).setY(1).normalize(); + _emit.speed = c.chipSpeed * 0.7; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.chipLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 8; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.chips.emit(Math.round(12 * g.particleCount), _emit); + + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + /** + * Everything thrown off the **contact point**. + * + * Not off the centre. A rolling body sheds from the patch where it is + * touching the ground — backwards and out to the sides, because that is where + * the material it is displacing has to go — and the difference between + * emitting there and emitting at the middle of the rock is the difference + * between a boulder and a sphere with a smoke emitter parented to it. + * `Projectile` publishes `contact` for exactly this. + */ + _contactFx(dt, load) { + const c = settings.boulder; + const g = settings.global; + const time = frame.uTime.value; + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * load) * g.particleCount); + if (dustCount > 0) { + _pos.copy(_contact); + _pos.y = c.dustHeight; + _emit.position = _pos; + _emit.radius = this.rockRadius * 0.9; + // Behind and up: the plume is what the rock has already run over. + _emit.direction = _dir.copy(this.direction).multiplyScalar(-c.dustBack).setY(1).normalize(); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.75; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.5; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + const chipCount = Math.round(this.chipEmitter.tick(dt, c.chipRate * load) * g.particleCount); + if (chipCount > 0) { + _pos.copy(_contact); + _pos.y = 0.04; + _emit.position = _pos; + _emit.radius = this.rockRadius * 0.7; + // Spat out sideways off the contact patch, alternating sides, with a + // little backspin — the spray a wheel throws. + const sign = Math.random() < 0.5 ? 1 : -1; + _emit.direction = _dir + .copy(this.side) + .multiplyScalar(sign * c.chipSpray) + .addScaledVector(this.direction, -c.chipBack) + .setY(1) + .normalize(); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.6; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.75; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 10; + _emit.tint = null; + _emit.time = time; + this.chips.emit(chipCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.boulder; + const g = settings.global; + + this._sync(dt, 1); + + // Straight off the module on the frame it resolved them: `Projectile` + // publishes the contact patch and how hard the body is pressing, and both + // are consumed here, immediately after `update()`, before anything can + // move them. + const load = this.body.contactLoad; + + /* + * Post a contact sample every `1/rutSamples` of the *cast*, not of the + * track: the two differ by `pathCurve`, and stepping in the cast fraction + * is what lets that curve stay a live slider (see `_syncRut`). `mark()` + * takes a fraction across, a fraction along, a timestamp and a 0..1 + * strength — four unitless numbers — so the samples re-place and re-scale + * themselves when the range or the width moves. + */ + const step = 1 / clamp(Math.round(c.rutSamples), 1, RUT_MARKS); + while (this.u - this._markedU >= step) { + this._markedU += step; + this._postSample(this._markedU); + } + + // The light rides with the rock, low, so the dust it is making catches it. + this.position.y = c.lightHeight; + + this._contactFx(dt, load); + + // Rumble the whole way, scaled by speed. A rolling boulder is a continuous + // event, so this is a continuous read of how fast it is going rather than + // anything scheduled. + this.ctx.shake.rumble( + c.rumble * saturate(this.rollSpeed / Math.max(0.1, c.loadSpeed)) * g.cameraShake, + dt + ); + } + + onImpact() { + const c = settings.boulder; + const g = settings.global; + const time = frame.uTime.value; + + // The last sample, at the very end of the track, so the rut's head is + // filled in rather than stopping one step short of the crater. + this._postSample(1); + this._markedU = 1; + + this.pointAt(1, _pos); + _contact.copy(_pos).setY(0); + _pos.y = this.rockRadius; + + /* + * It breaks. `along = 1` puts the fragments at the far end of the line and + * the inherited velocity — set in `_syncChunks` and re-read every frame — + * carries them on down it, so the pile ends up in front of where the rock + * stopped rather than around it. + */ + this.chunks.burst(this.age, Math.round(c.debrisCount * g.particleCount), 1, 0); + this._shattered = true; + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.3, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.8, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.1, + displace: 0.7, + squash: 0.55, + colorA: getColor(c.colorDustB), + colorB: getColor(c.colorDustA), + colorC: getColor(c.colorRock) + }); + + this.ctx.decals.spawn(DecalType.CRACK, _contact, { + radius: c.crackRadius, + life: c.crackLife, + width: c.crackWidth, + intensity: c.crackIntensity, + colorA: getColor(c.colorRutDeep), + colorB: getColor(c.colorRutChurn), + height: 0.014 + }); + + this.ctx.decals.spawn(DecalType.DUSTRING, _contact, { + radius: c.impactDust, + life: c.crackLife * 0.5, + intensity: 0.6, + colorA: getColor(c.colorDustA), + colorB: getColor(c.colorDustB), + height: 0.01 + }); + + _emit.position = _pos; + _emit.radius = this.rockRadius; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.5).setY(1).normalize(); + _emit.speed = c.chipSpeed * 1.8; + _emit.speedVariance = 0.85; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.13; + _emit.sizeVariance = 0.8; + _emit.life = c.chipLifetime * 1.3; + _emit.lifeVariance = 0.5; + _emit.spin = 12; + _emit.tint = null; + _emit.time = time; + this.chips.emit(Math.round(c.burstChips * g.particleCount), _emit); + + _emit.radius = this.rockRadius * 1.6; + _emit.speed = c.dustSpeed * 1.9; + _emit.spread = 1.0; + _emit.size = 1.1; + _emit.life = c.dustLifetime * 1.2; + _emit.spin = 0.6; + this.dust.emit(Math.round(c.burstDust * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 17 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.4 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.boulder; + // `t` runs 0..1 while the pieces are flying, then 1..2 while the rut goes. + const fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + + this._sync(dt, fade); + this.pointAt(1, this.position).setY(c.lightHeight); + + // Dust keeps rolling off the pile for a moment after it lands, then stops. + const settle = this.phase === AbilityPhase.IMPACT ? 1 - saturate(t / 0.6) : 0; + if (settle > 0.001) this._contactFx(dt, settle * c.settleDust); + + this.ctx.shake.rumble(c.settleRumble * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + this._shattered = false; + this._markedU = 0; + this._markCount = 0; + this._markSlots.fill(null); + this.body.reset(); + this.chunks.clear(); + this.rut.clearMarks(); + this.rut.setVisible(false); + } + + dispose() { + this.body.dispose(); + this.rut.dispose(); + this.chunks.dispose(); + this.rockMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/stone/ObsidianAbility.js b/src/abilities/stone/ObsidianAbility.js new file mode 100644 index 0000000..6eed192 --- /dev/null +++ b/src/abilities/stone/ObsidianAbility.js @@ -0,0 +1,1145 @@ +import { BufferGeometry, Color, Float32BufferAttribute, MeshStandardMaterial, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GrowthField, GrowthLayout, growthParams, patchGrowthMaterial } from '../../vfx/GrowthField.js'; +import { ShatterField, ShatterLayout, shatterParams } from '../../vfx/ShatterField.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { noiseGLSL } from '../../shaders/lib/noise.glsl.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on blades in one bloom. The `shards` slider clamps here. */ +const MAX_SHARDS = 120; +/** Distinct blade silhouettes — the number `GrowthField` argues for, and why. */ +const VARIANTS = 3; +/** Distinct flake silhouettes. Two is `ShatterField`'s own recommendation. */ +const FLAKE_VARIANTS = 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _growth = growthParams(); +const _shatter = shatterParams(); +const _glaze = groundFieldParams(); +const _flakeLook = { + colorA: new Color(), + colorB: new Color(), + colorEdge: new Color(), + colorScene: new Color(), + opacity: 1, + glow: 1, + rim: 1, + rimPower: 2.4, + shade: 1, + ambient: 0.3, + fadeStart: 0.6, + soft: 0.3, + sceneMix: 0, + refract: 0.4, + saturation: 0.35 +}; + +/** Deterministic 0..1 hash, so a rebuilt blade is the *same* blade. */ +function hash1(n) { + const s = Math.sin(n * 127.1) * 43758.5453; + return s - Math.floor(s); +} + +const clampInt = (v, lo, hi) => Math.max(lo, Math.min(hi, Math.round(v))); + +/* ---------------------------------------------------------------------- */ +/* One blade — conchoidal fracture, in the mesh */ +/* ---------------------------------------------------------------------- */ +/** + * A shard of volcanic glass, in `GrowthField`'s unit space: footprint inside a + * circle of radius 0.5 on `y = 0`, terminal point at `y = 1`. + * + * **This function is the ability.** Every other rock in this project is built + * the same way — a lofted prism, non-indexed, `computeVertexNormals()`, one flat + * normal per triangle — because every other rock in this project has a crystal + * structure to cleave along and flat is *right* for it. Obsidian has none. It is + * a supercooled liquid, so a fracture in it is a **conchoidal** one: a smooth + * curved shell, concave, with concentric rib marks radiating out from wherever + * the blow landed. That is the same fracture you see on a flint arrowhead or the + * lip of a broken bottle, and once you have seen it you cannot see a faceted + * black crystal as glass again. + * + * Four decisions make it, and three of them are about *where the vertices go* + * rather than about shading: + * + * 1. **A face is a grid, not a quad.** Each of the `sides` faces is subdivided + * `arc` × `rings` and each interior vertex is pushed *inward* by `dish`. The + * push is `sin(pi·u)`-weighted across the face so it vanishes exactly at both + * arrises, which keeps the shard watertight and keeps the edges razor sharp — + * curved faces meeting in hard edges is the whole silhouette of knapped + * glass, and it is why obsidian was worth carrying across a continent. + * 2. **The dish is deepest one radius from the initiation point, not at the + * centre of the face.** `d·e^(1−d)` peaks at `d = 1` and shallows past it, + * with `d` measured from `(u = 0.5, v = dishBias)` — the point of impact. A + * scar that is deepest in the middle of the face is a spoon; a scar that + * deepens away from a corner and then flattens out is a fracture. + * 3. **The rib marks are concentric about that same point.** + * `sin(d · ripplePitch · 2π)` and nothing else. They are small — four per cent + * of the radius — and they are the reason a highlight *travels* across a face + * as the camera moves instead of sitting still on it. This is the one part + * that could have been faked with a normal map, and invariant I2 forbids the + * texture anyway; doing it in the mesh also means it survives at the + * silhouette, where a normal map would give itself away instantly. + * 4. **Indexed per face, smooth-shaded.** Each face owns its own vertices, so + * `computeVertexNormals()` averages *within* a face and cannot average + * across one. Smooth inside, hard at every arris. The first version was + * non-indexed like `PetrifyAbility`'s plates and it came out as a perfectly + * ordinary faceted crystal: all the curvature was in the silhouette and none + * of it was in the shading, which is exactly backwards, because on glass the + * curvature *is* the shading. + * + * The radius at each height is precomputed **per bearing**, not per face, so the + * two faces meeting at an arris read the identical radius there. Jittering the + * radius per face instead was the first attempt and it split the shard open + * along every edge — a hairline crack you can see through, on every instance. + * + * @param {number} seed deterministic shape seed; the variant index moves it + * @param {object} shape live shape params — see `_fillShape()` + */ +function createConchoidalGeometry(seed, shape) { + const sides = clampInt(shape?.sides ?? 6, 4, 9); + const rings = clampInt(shape?.rings ?? 5, 2, 8); + const arc = clampInt(shape?.arc ?? 3, 2, 6); + const taper = Math.max(0.01, Math.min(0.9, shape?.taper ?? 0.14)); + const tipCurve = Math.max(0.2, shape?.tipCurve ?? 1.7); + const tipRise = Math.max(0.01, Math.min(0.6, shape?.tipRise ?? 0.15)); + const bulge = Math.max(0, Math.min(1, shape?.bulge ?? 0.22)); + const dish = Math.max(0, Math.min(0.95, shape?.dish ?? 0.3)); + const dishBias = Math.max(0, Math.min(1, shape?.dishBias ?? 0.18)); + const dishStretch = Math.max(0.1, shape?.dishStretch ?? 1.35); + const ripple = Math.max(0, Math.min(0.4, shape?.ripple ?? 0.045)); + const ripplePitch = Math.max(0, shape?.ripplePitch ?? 2.6); + const shear = shape?.shear ?? 0.32; + const chip = Math.max(0, Math.min(1, shape?.chip ?? 0.3)); + + /** Where the faces hand over to the terminal point. */ + const capBase = 1 - tipRise; + + /* --- the bearings, jittered once and shared by every ring --- */ + const angles = new Float64Array(sides + 1); + for (let k = 0; k < sides; k++) { + const wobble = (hash1(seed * 3.13 + k * 7.7) - 0.5) * (TAU / sides) * 0.55; + angles[k] = (k / sides) * TAU + wobble; + } + angles[sides] = angles[0] + TAU; + + /* --- the radius at every (ring, bearing). Per BEARING — see the header. --- */ + const radial = new Float64Array((rings + 1) * (sides + 1)); + let widest = 1e-6; + for (let j = 0; j <= rings; j++) { + const v = (j / rings) * capBase; + const body = taper + (1 - taper) * Math.pow(1 - v, tipCurve); + const swell = 1 + bulge * Math.sin(Math.PI * v); + const base = 0.5 * body * swell; + for (let k = 0; k < sides; k++) { + const r = base * (1 + (hash1(seed * 11.1 + k * 13.7 + j * 3.9) - 0.5) * chip); + radial[j * (sides + 1) + k] = r; + if (r > widest) widest = r; + } + radial[j * (sides + 1) + sides] = radial[j * (sides + 1)]; + } + // Normalise so the widest point of the blade is exactly the unit footprint. + // Without this, `bulge` pushes the mid-height past r = 0.5 and every + // `bladeRadius` metre the ability resolves is quietly a lie. + const norm = 0.5 / widest; + for (let i = 0; i < radial.length; i++) radial[i] *= norm; + + const positions = []; + const index = []; + + /* --- the faces --- */ + for (let f = 0; f < sides; f++) { + const first = positions.length / 3; + const phase = hash1(seed * 23.9 + f * 5.3); + + for (let j = 0; j <= rings; j++) { + const v = (j / rings) * capBase; + const rowA = radial[j * (sides + 1) + f]; + const rowB = radial[j * (sides + 1) + f + 1]; + const dv = (v - dishBias) * dishStretch; + + for (let i = 0; i <= arc; i++) { + const u = i / arc; + const across = Math.sin(Math.PI * u); + const du = (u - 0.5) * 2; + const d = Math.sqrt(du * du + dv * dv); + // Deepest one radius out from the blow, shallowing beyond it. + const bow = d * Math.exp(1 - d) * across; + const rib = ripple * Math.sin((d * ripplePitch + phase) * TAU) * across; + + const angle = lerp(angles[f], angles[f + 1], u) + shear * v; + const r = Math.max(0.004, lerp(rowA, rowB, u) * (1 - dish * bow - rib)); + positions.push(Math.cos(angle) * r, v, Math.sin(angle) * r); + } + } + + // Named `v0..v3` rather than the obvious `a, b, c, d`: `c` is this project's + // universal alias for `settings[id]`, and `npm run check` reads the source + // statically to cross-check every `c.` against the block. A local `c` + // anywhere in the file poisons that alias and the harness silently reports + // eleven of a hundred and seventy-six sliders as read. It cost a run. + const stride = arc + 1; + for (let j = 0; j < rings; j++) { + for (let i = 0; i < arc; i++) { + const v0 = first + j * stride + i; + const v1 = v0 + 1; + const v2 = v0 + stride; + const v3 = v2 + 1; + index.push(v0, v1, v2, v1, v3, v2); + } + } + } + + /* --- the terminal point --- */ + // Its own vertex set, so the join to the faces is a hard edge. A shard of + // glass ends in a point you would not want to press, not in a dome. + const apex = positions.length / 3; + positions.push( + (hash1(seed * 31.7) - 0.5) * taper * 0.5, + 1, + (hash1(seed * 37.1) - 0.5) * taper * 0.5 + ); + const capFirst = positions.length / 3; + let capCount = 0; + for (let f = 0; f < sides; f++) { + const phase = hash1(seed * 23.9 + f * 5.3); + const j = rings; + const v = capBase; + const rowA = radial[j * (sides + 1) + f]; + const rowB = radial[j * (sides + 1) + f + 1]; + const dv = (v - dishBias) * dishStretch; + // `arc` steps, not `arc + 1`: the last one is the next face's first, so the + // ring closes without a doubled vertex and the fan has no seam in it. + for (let i = 0; i < arc; i++) { + const u = i / arc; + const across = Math.sin(Math.PI * u); + const du = (u - 0.5) * 2; + const d = Math.sqrt(du * du + dv * dv); + const bow = d * Math.exp(1 - d) * across; + const rib = ripple * Math.sin((d * ripplePitch + phase) * TAU) * across; + const angle = lerp(angles[f], angles[f + 1], u) + shear * v; + const r = Math.max(0.004, lerp(rowA, rowB, u) * (1 - dish * bow - rib)); + positions.push(Math.cos(angle) * r, v, Math.sin(angle) * r); + capCount++; + } + } + for (let i = 0; i < capCount; i++) { + index.push(apex, capFirst + i, capFirst + ((i + 1) % capCount)); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.setIndex(index); + // Indexed, and each face owns its vertices: smooth across a shell, hard at + // every arris. See point 4 in the header — this one line is the trick. + geometry.computeVertexNormals(); + return geometry; +} + +/** + * A flake of the same glass, for the break. + * + * The same generator with the proportions of something that came off a face + * rather than out of the ground: two or three broad shells, almost no taper, a + * deep dish. The trick appears twice at two scales, which is the point — a + * shatter into flat triangles would say the blades were never glass. + * + * Its shape is the one thing in this ability that is not a live slider, because + * `ShatterField` has no `syncGeometry()` to rebuild through. `flakeSize` is, and + * that is the control anyone actually reaches for. + */ +function createFlakeGeometry(variant) { + return createConchoidalGeometry(71.3 + variant * 13.9, { + sides: 4 + variant, + rings: 3, + arc: 3, + taper: 0.55, + tipCurve: 0.9, + tipRise: 0.3, + bulge: 0.1, + dish: 0.55, + dishBias: 0.3, + dishStretch: 1.1, + ripple: 0.06, + ripplePitch: 2.2, + shear: 0.1, + chip: 0.5 + }); +} + +/* ---------------------------------------------------------------------- */ +/* The glass */ +/* ---------------------------------------------------------------------- */ +/** + * Near-black glass with one very tight highlight. + * + * Built on `MeshStandardMaterial` through `patchGrowthMaterial` so the blades + * take the stage's real shadows and its probe — the probe matters more here than + * anywhere else in the project, because a reflective surface with nothing to + * reflect is just a dark shape. + * + * Everything injected on top is about *not* being emissive. There are four + * additive terms and three of them are reflections rather than light: the + * grazing-angle fresnel (a dielectric goes to full reflectance at the + * silhouette, which is why the edge of a black bottle is white), the tight + * analytic specular lobe, and the transmission through the thin end. Obsidian is + * genuinely translucent where it is thin, and it is *red-brown* there rather + * than grey — that is iron in the glass, and it is the single detail that makes + * people say "volcanic" without being able to say why. + * + * The fourth is `uHeat`, weighted almost entirely onto `vGrowBirth` and onto the + * base of the blade, because the blade came out of the ground molten and cooled + * in about half a second. + * + * The one term that is not a light at all is the flow banding: fbm in **world** + * space, so two blades that ended up beside each other look poured from the same + * flow. In local space every blade carries its own copy of the same swirl and + * the bloom reads as seventy identical props, which is what the first pass was. + */ +function createObsidianMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.07, + metalness: 0.0, + // Emphatically NOT flatShading. See `createConchoidalGeometry`. + flatShading: false, + transparent: true, + depthWrite: true + }); + + const uniforms = { + uLightDir: frame.uLightDir, + uColorGlass: { value: new Color() }, + uColorDeep: { value: new Color() }, + uColorSheen: { value: new Color() }, + uColorBleed: { value: new Color() }, + uColorHeat: { value: new Color() }, + uFresnel: { value: 0.85 }, + uFresnelPower: { value: 4.5 }, + uGlint: { value: 2.4 }, + uGlintSharp: { value: 220 }, + uBleed: { value: 0.6 }, + uBleedPower: { value: 2.2 }, + uHeat: { value: 3.2 }, + uHeatBand: { value: 2.4 }, + uBanding: { value: 0.45 }, + uBandScale: { value: 2.2 }, + uEmission: { value: 0.04 } + }; + + patchGrowthMaterial(material, { + environment, + uniforms, + common: /* glsl */ ` + uniform vec3 uLightDir; + uniform vec3 uColorGlass; + uniform vec3 uColorDeep; + uniform vec3 uColorSheen; + uniform vec3 uColorBleed; + uniform vec3 uColorHeat; + uniform float uFresnel; + uniform float uFresnelPower; + uniform float uGlint; + uniform float uGlintSharp; + uniform float uBleed; + uniform float uBleedPower; + uniform float uHeat; + uniform float uHeatBand; + uniform float uBanding; + uniform float uBandScale; + uniform float uEmission; + ${noiseGLSL} + `, + fragment: /* glsl */ ` + vec3 gN = normalize(normal); + vec3 gV = normalize(vViewPosition); + float ndv = clamp(dot(gN, gV), 0.0, 1.0); + + // Flow banding, in WORLD space — see the header for why not local. + float band = fbm3(vGrowWorld * uBandScale + vGrowSeed * 5.0) * 0.5 + 0.5; + vec3 body = mix(uColorGlass, uColorDeep, clamp(band * uBanding, 0.0, 1.0)); + diffuseColor.rgb *= body; + + // Schlick, near enough. A dielectric at a grazing angle reflects + // everything, which is why the edge of black glass is the brightest part + // of it and why this term is doing more work than any colour picker. + float rim = pow(1.0 - ndv, uFresnelPower); + totalEmissiveRadiance += uColorSheen * rim * uFresnel; + + // One tight lobe against the same key direction the lit meshes use. A + // fake highlight that disagrees with the real sun reads as a sticker. + vec3 gH = normalize(uLightDir + gV); + float spec = pow(max(dot(gN, gH), 0.0), max(1.0, uGlintSharp)); + totalEmissiveRadiance += uColorSheen * spec * uGlint; + + // Transmission through the thin end. vGrowLocal.y is 0 at the base and + // 1 at the point, so this is strongest exactly where the glass is + // thinnest, and it only fires when the key is behind the blade. + // (No backticks in here. The shader is a JS template literal and one + // backtick in a comment ends it two hundred lines early.) + float thin = pow(clamp(vGrowLocal.y, 0.0, 1.0), uBleedPower); + float back = clamp(dot(-gN, uLightDir), 0.0, 1.0); + totalEmissiveRadiance += uColorBleed * uBleed * thin * back; + + // Molten, on the way out of the ground, low down and going fast. + float hot = vGrowBirth * pow(1.0 - clamp(vGrowLocal.y, 0.0, 1.0), uHeatBand); + totalEmissiveRadiance += uColorHeat * uHeat * hot; + + // The standing glow, which is almost nothing on purpose: glass does not + // emit, and every slot in this sandbox that forgets that reads as plastic. + totalEmissiveRadiance += uColorSheen * uEmission; + ` + }); + + material.userData.uniforms = uniforms; + + /** Pull the palette and every shading control from the live settings. */ + material.userData.sync = () => { + const c = settings.obsidian; + const g = settings.global; + + uniforms.uColorGlass.value.copy(getColor(c.colorGlass)); + uniforms.uColorDeep.value.copy(getColor(c.colorDeep)); + uniforms.uColorSheen.value.copy(getColor(c.colorSheen)); + uniforms.uColorBleed.value.copy(getColor(c.colorBleed)); + uniforms.uColorHeat.value.copy(getColor(c.colorHeat)); + + uniforms.uFresnel.value = c.fresnel * g.shaderIntensity; + uniforms.uFresnelPower.value = c.fresnelPower; + uniforms.uGlint.value = c.glint * g.glow; + uniforms.uGlintSharp.value = c.glintSharp; + uniforms.uBleed.value = c.bleed * g.shaderIntensity; + uniforms.uBleedPower.value = c.bleedPower; + uniforms.uHeat.value = c.heat * g.glow; + uniforms.uHeatBand.value = c.heatBand; + uniforms.uBanding.value = c.banding * g.shaderIntensity; + uniforms.uBandScale.value = c.bandScale * g.noiseFrequency; + uniforms.uEmission.value = c.emission * g.glow; + + material.roughness = c.glassRough; + material.metalness = c.glassMetal; + material.envMapIntensity = c.envIntensity; + }; + + material.userData.sync(); + return material; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ +/** + * OBSIDIAN BLOOM — a ring of volcanic glass opening out of the floor. + * + * **THE TRICK — conchoidal fracture.** The blades are not faceted. Their faces + * are smooth curved shells that meet in razor arrises, with concentric rib marks + * radiating from the point each face was struck at, because that is the only way + * glass breaks. All of it is in the mesh — see `createConchoidalGeometry` above, + * which is where the ability actually lives. The material is a supporting + * argument: near-black albedo, roughness 0.07, a fresnel that goes white at the + * silhouette and one very tight specular lobe. It reads as glass at a glance, + * from the silhouette and the way a highlight *slides*, before the specular has + * landed at all — and that was the brief. + * + * **The beats.** + * + * 1. **The pour** (`TRAVEL`). The heat front runs out to the zone and the floor + * vitrifies behind it — a `GroundField(WET)` in black and orange, growing + * with `this.u`. Nothing stands up yet. + * 2. **The bloom** (`IMPACT`, first `bloomTime`). `triggerRadial` releases the + * blades from the centre outward, each punching up on its own staggered + * clock, leaning out from the middle. Every breach throws chips, ash and a + * puff of orange light. + * 3. **The hold** (the rest of `IMPACT`). It stands there and glints. This is + * the beat the whole ability is for and it is the longest one; the glass has + * to be *looked at* or the geometry was a waste of everybody's time. + * 4. **The break** (`FADE`). One `ShatterField` burst of flakes cut from the + * same generator, and the blades go over `breakTime` — which is short. They + * drop rather than shrink, with the material's opacity taken out from under + * them on the same window, because glass does not dwindle. It is there and + * then it is flakes. + * + * **The rule that makes the editor work.** A blade record holds dice and one + * timestamp — `GrowthField`'s discipline, unchanged. Not one metre, radian or + * second is captured by this file. `zoneRadius`, `bladeHeight`, `lean` and + * `dish` all resolve against `settings.obsidian` inside the update loop, on a + * zero-length frame included. Pause mid-bloom with **P** and drag `dish` and the + * standing blades re-fracture, because `syncGeometry` hashes the twelve shape + * numbers and rebuilds the three meshes when one of them moves. That rebuild is + * the one expensive thing in here and it is deliberate: the curvature cannot be + * a per-instance scale, and the alternative is a texture, which invariant I2 + * does not allow. + */ +export class ObsidianAbility extends Ability { + constructor(context) { + super('obsidian', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.material = createObsidianMaterial(this.ctx.environment); + + /** The twelve numbers `syncGeometry` hashes. Filled from settings, live. */ + this._shape = { + sides: 6, + rings: 5, + arc: 3, + taper: 0.14, + tipCurve: 1.7, + tipRise: 0.15, + bulge: 0.22, + dish: 0.3, + dishBias: 0.18, + dishStretch: 1.35, + ripple: 0.045, + ripplePitch: 2.6, + shear: 0.32, + chip: 0.3 + }; + this._fillShape(); + + this.field = new GrowthField(this.group, { + geometry: (variant, shape) => createConchoidalGeometry(4.7 + variant * 19.3, shape), + material: this.material, + shape: this._shape, + variants: VARIANTS, + capacity: MAX_SHARDS, + renderOrder: 2 + }); + // Assigned once, at construction. A closure built in the update loop is an + // allocation per instance per frame, which is exactly what I3 forbids. + this.field.onBreach = (index, position, radius, height) => this._onBreach(index, position, radius, height); + + this.flakes = new ShatterField(this.group, { + geometry: createFlakeGeometry, + variants: FLAKE_VARIANTS, + capacity: 192, + renderOrder: 6 + }); + + // WET rather than POCK or SCOUR: the mode is "darkened, reflective stone", + // which is what a floor looks like after something poured glass over it. + // The heat is in the colours rather than in a second additive field. + this.glaze = new GroundField(this.group, { + mode: GroundMode.WET, + additive: false, + renderOrder: 5, + name: 'obsidian.glaze' + }); + + /** Re-rolled per cast so two blooms do not glaze the floor identically. */ + this._seed = 0; + /** Absolute age the bloom was released at, or -1. A timestamp. */ + this._bloomAt = -1; + /** Latched at the break, and re-armed if the clock goes back before it. */ + this._broke = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // Chips of glass. Lit and non-additive: they are matter, and they are the + // one part of this ability that reads dark against the flash rather than + // bright. + this.chips = particles.get('obsidian.chips', { + capacity: 1400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.3; + this.chips.uniforms.uEndSize.value = 0.7; + this.chips.uniforms.uFadeOut.value = 0.65; + + // Ash off the pour. + this.ash = particles.get('obsidian.ash', { + capacity: 1000, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.ash.uniforms.uDrag.value = 1.8; + this.ash.uniforms.uEndSize.value = 2.8; + this.ash.uniforms.uSizeIn.value = 0.12; + this.ash.uniforms.uFadeIn.value = 0.18; + this.ash.uniforms.uFadeOut.value = 0.35; + + // The one additive system: the key catching an arris. Short-lived and + // small, because a glint that lingers is a firefly. + this.glints = particles.get('obsidian.glints', { + capacity: 700, + shape: ParticleShape.SOFT, + additive: true, + softFade: 0.3 + }); + this.glints.uniforms.uDrag.value = 2.4; + this.glints.uniforms.uEndSize.value = 0.1; + this.glints.uniforms.uSizeIn.value = 0.03; + this.glints.uniforms.uFadeIn.value = 0.04; + this.glints.uniforms.uFadeOut.value = 0.5; + + this.glintEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.field.count + this.flakes.count; + } + + /** The bloom opening plus the hold. Both live. */ + get impactDuration() { + const c = settings.obsidian; + return Math.max(0.1, (c.bloomTime + c.holdTime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.1, settings.obsidian.fadeTime); + } + + /** The live footprint, metres. What the circle indicator measured out. */ + get radius() { + return Math.max(0.1, settings.obsidian.zoneRadius); + } + + /** 0..1 through the ring opening. */ + _bloom() { + if (this._bloomAt < 0) return 0; + const c = settings.obsidian; + return saturate((this.age - this._bloomAt) / Math.max(0.02, c.bloomTime)); + } + + /** 0..1 through the break, once the fade has started. */ + _break() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / Math.max(0.02, settings.obsidian.breakTime)); + } + + /** + * The dynamic light is the magma, not the glass. + * + * It comes up with the bloom and is gone well before the hold ends, which is + * what makes the standing blades read as *cold* — they are lit by the stage + * from then on, and being lit by the stage is the whole argument that they are + * a real surface. + */ + lightShimmer() { + const c = settings.obsidian; + if (this.phase === AbilityPhase.TRAVEL) return 0.25 + 0.35 * this.u; + const bloom = this._bloom(); + const cool = saturate((this.age - this._bloomAt - c.bloomTime) / Math.max(0.05, c.birthFade * 3)); + return Math.max(0.05, bloom * (1 - Easing.inQuad(cool))); + } + + /* ------------------------------------------------------------------ */ + /* Resolving — every metre comes from here, every frame */ + /* ------------------------------------------------------------------ */ + + _fillShape() { + const c = settings.obsidian; + const s = this._shape; + s.sides = c.faceSides; + s.rings = c.faceRings; + s.arc = c.faceArc; + s.taper = c.tipTaper; + s.tipCurve = c.tipCurve; + s.tipRise = c.tipRise; + s.bulge = c.bulge; + s.dish = c.dish; + s.dishBias = c.dishBias; + s.dishStretch = c.dishStretch; + s.ripple = c.ripple; + s.ripplePitch = c.ripplePitch; + s.shear = c.shear; + s.chip = c.chip; + return s; + } + + _fillGrowth() { + const c = settings.obsidian; + const p = _growth; + const R = this.radius; + + this._centrePoint(_centre); + p.layout = GrowthLayout.ZONE; + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + p.centre = _centre; + + p.radius = R * c.ringOuter; + p.innerRadius = Math.min(R * c.ringOuter, R * c.ringInner); + p.radialCurve = c.radialCurve; + p.radialJitter = c.radialJitter; + p.angleJitter = c.angleJitter; + p.clusterRadius = c.clusterRadius; + + p.heightNear = c.heightNear; + p.height = c.bladeHeight; + p.heightCurve = c.heightCurve; + p.heightJitter = c.heightJitter; + p.crown = c.crown; + p.crownPower = c.crownPower; + p.peak = c.peak; + p.rubble = c.rubble; + p.rubbleScale = c.rubbleScale; + p.rubbleSpread = c.rubbleSpread; + + p.radiusNear = c.bladeRadius; + p.radius2 = c.bladeRadius2; + p.radiusCurve = c.radiusCurve; + p.radiusJitter = c.radiusJitter; + + p.lean = c.lean; + p.leanJitter = c.leanJitter; + p.leanRamp = c.leanRamp; + p.leanOutward = c.leanOutward; + p.leanForward = c.leanForward; + p.twist = c.twist; + p.tilt = c.tilt; + + p.riseTime = c.riseTime; + p.riseOvershoot = c.riseOvershoot; + p.settle = c.settle; + p.springRate = c.springRate; + p.emergeSink = c.emergeSink; + p.birthScale = c.birthScale; + p.birthFade = c.birthFade; + p.breachAt = c.breachAt; + p.sinkDepth = c.sinkDepth; + + p.randomness = settings.global.randomness; + return p; + } + + _fillShatter() { + const c = settings.obsidian; + const p = _shatter; + + this._centrePoint(_centre); + p.layout = ShatterLayout.ZONE; + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + p.centre = _centre; + p.radius = this.radius * c.ringOuter; + + p.spawnRadius = c.flakeSpawnRadius; + p.spawnHeight = c.flakeSpawnHeight; + p.speed = c.flakeSpeed; + p.speedJitter = c.flakeSpeedJitter; + p.spread = c.flakeSpread; + p.upBias = c.flakeUp; + p.inherit = null; + p.inheritScale = 1; + + p.gravity = c.flakeGravity; + p.drag = c.flakeDrag; + + p.size = c.flakeSize; + p.sizeJitter = c.flakeJitter; + p.shrink = c.flakeShrink; + p.shrinkPower = c.flakeShrinkPower; + p.spin = c.flakeSpin; + p.spinJitter = c.flakeSpinJitter; + + p.lifetime = c.flakeLife; + p.floor = c.flakeFloor; + p.floorSpin = c.flakeFloorSpin; + + p.randomness = settings.global.randomness; + return p; + } + + /** + * @param {number} grow 0..1 the glaze spreading outward + * @param {number} fade 0..1 master + */ + _fillGlaze(grow, fade) { + const c = settings.obsidian; + const g = settings.global; + const p = _glaze; + + this._centrePoint(_centre); + p.centre = _centre; + // GroundField's local +Z is downrange, so the yaw is the cast's bearing. + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.glazeHeight; + p.radius = this.radius * Math.max(0.05, c.glazeSpan); + p.grow = grow; + p.recede = 0; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.glazeEdge; + p.ragged = c.glazeRagged; + p.raggedScale = c.glazeRaggedScale; + p.warp = c.glazeWarp; + + p.relief = c.glazeRelief; + p.normalStep = c.glazeNormalStep; + p.ambient = c.glazeAmbient; + p.wrap = c.glazeWrap; + p.specular = c.glazeSpecular; + p.gloss = c.glazeGloss; + p.parallax = c.glazeParallax; + + p.detail = c.glazeDetail; + p.flow = c.glazeFlow; + p.sharp = c.glazeSharp; + + p.additive = false; + p.emissive = c.glazeEmissive * g.glow; + p.opacity = c.glazeOpacity; + p.colorBase = c.colorGlazeBase; + p.colorEdge = c.colorGlazeEdge; + p.colorGlow = c.colorGlazeGlow; + p.colorDeep = c.colorGlazeDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + return p; + } + + /** The zone centre, on the floor. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** Colours and rates for the three particle systems, and the flake look. */ + _syncLooks(fade) { + const c = settings.obsidian; + const g = settings.global; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = c.chipLifetime * 0.5 * g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + + this.ash.setGradient( + getColor(c.colorAshA), + getColor(c.colorAshB), + getColor(c.colorAshC), + getColor(c.colorAshD) + ); + this.ash.uniforms.uGravity.value.set(0, c.ashRise, 0); + this.ash.uniforms.uSizeScale.value = c.ashSize * g.particleSize; + this.ash.uniforms.uLifeScale.value = c.ashLifetime * 0.5 * g.particleLifetime; + this.ash.uniforms.uSpeedScale.value = c.ashSpeed * g.particleSpeed; + this.ash.uniforms.uOpacity.value = c.ashOpacity * g.opacity; + this.ash.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.glints.setGradient( + getColor(c.colorGlintA), + getColor(c.colorGlintB), + getColor(c.colorGlintC), + getColor(c.colorGlintD) + ); + this.glints.uniforms.uGravity.value.set(0, c.glintRise, 0); + this.glints.uniforms.uSizeScale.value = c.glintSize * g.particleSize * 7; + this.glints.uniforms.uLifeScale.value = c.glintLifetime * 0.5 * g.particleLifetime; + this.glints.uniforms.uSpeedScale.value = g.particleSpeed; + this.glints.uniforms.uOpacity.value = g.opacity; + this.glints.uniforms.uGlow.value = c.glint * 0.4 * g.glow; + + _flakeLook.colorA.copy(getColor(c.colorFlakeA)); + _flakeLook.colorB.copy(getColor(c.colorFlakeB)); + _flakeLook.colorEdge.copy(getColor(c.colorFlakeEdge)); + _flakeLook.colorScene.copy(getColor(c.colorFlakeScene)); + _flakeLook.opacity = c.flakeOpacity * g.opacity; + _flakeLook.glow = c.flakeGlow * g.glow; + _flakeLook.rim = c.flakeRim; + _flakeLook.rimPower = c.flakeRimPower; + _flakeLook.shade = c.flakeShade; + _flakeLook.ambient = c.flakeAmbient; + _flakeLook.fadeStart = c.flakeFadeStart; + _flakeLook.soft = c.flakeSoft; + _flakeLook.sceneMix = c.flakeSceneMix; + _flakeLook.refract = c.flakeRefract; + _flakeLook.saturation = c.flakeSaturation; + this.flakes.sync(_flakeLook); + + this.material.userData.sync(); + this.material.opacity = c.glassOpacity * g.opacity * fade; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.obsidian; + + this.glintEmitter.reset(); + this._bloomAt = -1; + this._broke = false; + + // The one thing a cast captures beyond the field's own dice, and it is + // unitless. + this._seed = Math.random() * 100; + + this.field.clear(); + this.field.plant(Math.min(MAX_SHARDS, Math.round(c.shards)), c.clusterShare); + this.flakes.clear(); + + this.glaze.setVisible(true); + this._syncLooks(1); + this.glaze.update(this._fillGlaze(0, 1)); + this.field.update(this.age, this._fillGrowth(), 0); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * A blade breaking the surface. + * + * `radius` and `height` arrive already resolved from the live params, which is + * the reason this callback takes them rather than reading settings again: the + * chips have to come off the blade the field actually drew, not off the one + * the settings described three frames ago. + */ + _onBreach(index, position, radius, height) { + const c = settings.obsidian; + const g = settings.global; + const time = frame.uTime.value; + + _pos.copy(position); + _pos.y = 0.05; + + _emit.position = _pos; + _emit.radius = radius * 1.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.75; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.chips.emit(Math.round(c.breachChips * g.particleCount), _emit); + + _emit.radius = radius * 2; + _emit.speed = c.ashSpeed; + _emit.spread = 0.9; + _emit.size = 0.7; + _emit.life = c.ashLifetime; + _emit.spin = 0.3; + this.ash.emit(Math.round(c.breachAsh * g.particleCount), _emit); + + // One glint at the tip, on the frame it arrives. The blade is molten here + // and this is the last moment it is its own light source. + _pos.copy(position); + _pos.y = height * 0.85; + _emit.position = _pos; + _emit.radius = radius * 0.6; + _emit.speed = c.glintSpeed; + _emit.spread = 1.0; + _emit.size = 0.08; + _emit.life = c.glintLifetime; + _emit.spin = 0; + this.glints.emit(Math.max(1, Math.round(2 * g.particleCount)), _emit); + + void index; + } + + /** Sparkles off the standing glass, while it stands. */ + _glintFx(dt, scale) { + const c = settings.obsidian; + const g = settings.global; + const count = Math.round(this.glintEmitter.tick(dt, c.glintRate * scale) * g.particleCount); + if (count <= 0) return; + + // Seeded on the blades themselves, read back live from the field, so a + // paused drag on `bladeHeight` moves the sparkles with the glass. The params + // are re-filled rather than borrowed off the last `update()`: relying on the + // caller having filled the scratch first is the kind of coupling that breaks + // silently the day somebody reorders two lines in `onFade`. + const p = this._fillGrowth(); + const live = Math.max(1, this.field.count); + const pick = Math.floor(Math.random() * live) % live; + this.field.tipOf(pick, p, _pos); + _pos.y *= 0.55 + 0.45 * Math.random(); + + _emit.position = _pos; + _emit.radius = this.field.radiusOf(pick, p) * 1.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.glintSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.8; + _emit.life = c.glintLifetime; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.glints.emit(count, _emit); + } + + /** The break: one burst of flakes, one jolt, one cloud of ash. */ + _shatterFx() { + const c = settings.obsidian; + const g = settings.global; + const time = frame.uTime.value; + + this.flakes.burst(this.age, Math.round(c.flakes * g.particleCount), 1, 0); + + this._centrePoint(_pos); + _pos.y = Math.max(0.1, c.flakeSpawnHeight * 0.6); + _emit.position = _pos; + _emit.radius = this.radius * c.ringOuter; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chipSpeed * 1.4; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.8; + _emit.life = c.chipLifetime * 1.2; + _emit.lifeVariance = 0.55; + _emit.spin = 12; + _emit.tint = null; + _emit.time = time; + this.chips.emit(Math.round(c.breakChips * g.particleCount), _emit); + + _emit.speed = c.ashSpeed * 1.6; + _emit.size = 1.1; + _emit.life = c.ashLifetime; + _emit.spin = 0.4; + this.ash.emit(Math.round(c.breakAsh * g.particleCount), _emit); + + this.ctx.shake.add( + c.breakShake * g.explosionIntensity, + 1 / Math.max(0.05, c.breakShakeTime), + 28 + ); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.obsidian; + + this._syncLooks(1); + this.field.syncGeometry(this._fillShape()); + this.field.update(this.age, this._fillGrowth(), 0); + this.flakes.update(this.age, this._fillShatter()); + // The pour runs out ahead of the bloom. `this.u` is the front, so the glaze + // is exactly as far along as the cast is — no second clock to disagree. + this.glaze.update(this._fillGlaze(this.u, 1)); + + this.ctx.shake.rumble(c.rumble * this.u, dt); + } + + onImpact() { + const c = settings.obsidian; + const g = settings.global; + + this._bloomAt = this.age; + this._broke = false; + + this.ctx.shake.add( + c.bloomShake * g.explosionIntensity, + 1 / Math.max(0.05, c.bloomShakeTime), + 20 + ); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + onFade(dt, _t) { + const c = settings.obsidian; + + const bloom = this._bloom(); + const broke = this._break(); + // The blades keep their opacity until the break and then lose it fast. Glass + // does not dwindle: `1 - t³` hangs on and then goes. + const solid = broke <= 0 ? 1 : 1 - Easing.inCubic(broke); + + this._syncLooks(solid); + this.field.syncGeometry(this._fillShape()); + // Radially, from the centre out, on the same clock the ring opens on. + this.field.triggerRadial(this.age, bloom, c.riseStagger, false, true); + this.field.update(this.age, this._fillGrowth(), broke); + + // Tested against the *live* boundary and re-armed whenever the clock is back + // before it, so dragging `breakTime` on a paused cast walks the shatter + // backward and forward instead of firing once and never again. + if (this.phase !== AbilityPhase.FADE) { + this._broke = false; + } else if (!this._broke) { + this._broke = true; + this._shatterFx(); + } + + this.flakes.update(this.age, this._fillShatter()); + // The glaze outlives the glass by design — the floor stays vitrified for the + // whole fade and goes with the cast, which is what says something happened + // here rather than something was drawn here. + this.glaze.update(this._fillGlaze(1, this.phase === AbilityPhase.FADE ? 1 - saturate(this.fadeTime / this.fadeDuration) : 1)); + + // The light sits at the middle of the ring, at blade height. + this._centrePoint(this.position); + this.position.y = Math.max(0.2, c.bladeHeight * 0.5); + + this._glintFx(dt, solid * (0.35 + 0.65 * bloom)); + } + + onDestroy() { + this.field.clear(); + this.flakes.clear(); + this.glaze.clearMarks(); + this.glaze.setVisible(false); + this._bloomAt = -1; + this._broke = false; + } + + dispose() { + this.field.dispose(); + this.flakes.dispose(); + this.glaze.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/stone/PetrifyAbility.js b/src/abilities/stone/PetrifyAbility.js new file mode 100644 index 0000000..f88dfce --- /dev/null +++ b/src/abilities/stone/PetrifyAbility.js @@ -0,0 +1,1110 @@ +import { + BufferGeometry, + Color, + Float32BufferAttribute, + InstancedBufferAttribute, + InstancedMesh, + MeshStandardMaterial, + Object3D, + Quaternion, + Vector3 +} from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { patchGrowthMaterial } from '../../vfx/GrowthField.js'; +import { HullShape, Medium, VolumeHull } from '../../vfx/VolumeHull.js'; +import { noiseGLSL } from '../../shaders/lib/noise.glsl.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** Hard ceiling on plates in one column. The `facetCount` slider clamps here. */ +const MAX_FACETS = 264; +/** + * Distinct plate silhouettes. Three, for the reason `IceAbility` gives: scaling + * one shape forty ways buys proportion variety and not *facet* variety, and a + * column of forty copies of the same chip reads as a repeated prop the moment + * the camera swings round it. + */ +const VARIANTS = 3; +const SLOTS = Math.ceil(MAX_FACETS / VARIANTS); +const TAU = Math.PI * 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _radial = new Vector3(); +const _centre = new Vector3(); +const _up = new Vector3(0, 1, 0); +const _dummy = new Object3D(); +const _quat = new Quaternion(); +const _roll = new Quaternion(); + +/** Deterministic 0..1 hash, so a rebuilt plate is the *same* plate. */ +function hash1(n) { + const s = Math.sin(n * 127.1) * 43758.5453; + return s - Math.floor(s); +} + +/* ---------------------------------------------------------------------- */ +/* One facet */ +/* ---------------------------------------------------------------------- */ +/** + * A single stone plate: an irregular prism with three rings and two caps. + * + * Unit space, the same convention `GrowthField` uses — footprint inside a + * circle of radius 0.5 on `y = 0`, far face at `y = 1` — so an instance scales + * the plate's radius and its thickness independently and `vGrowLocal.y` reads + * straight off in the fragment shader as "how far *out* of the column am I". + * + * The plate's own +Y is its **normal**, not its length. That is the whole + * reason this is not `createCrystalGeometry` with different numbers: a crystal + * grows along +Y out of the ground, and a facet of a column lies flat against + * a surface with +Y pointing away from it. Orienting the two the same way and + * fixing it with a rotation was the first attempt, and it made every scale + * slider shear the plate, because the instance scale is applied after the + * rotation and `(radius, thickness, radius)` is not uniform. + * + * The shoulder ring is what keeps it from being a plain frustum: real broken + * stone is widest a little way off the face it was quarried from, and the two + * hard edges that gives you are most of what the flat-shaded normals have to + * work with. + * + * @param {number} seed deterministic shape seed + * @param {object} shape `{ sides, taper, rough, shoulder }`, live from settings + */ +function createFacetGeometry(seed, shape) { + const sides = Math.max(4, Math.min(9, Math.round(shape?.sides ?? 6))); + const taper = Math.max(0.05, Math.min(1.6, shape?.taper ?? 0.66)); + const rough = Math.max(0, Math.min(1, shape?.rough ?? 0.44)); + const shoulder = Math.max(0.05, Math.min(0.95, shape?.shoulder ?? 0.62)); + + // One jittered set of bearings, shared by all three rings, so the side faces + // stay continuous planes up the plate rather than twisting into a screw. + const angles = []; + for (let i = 0; i < sides; i++) { + const wobble = (hash1(seed * 3.13 + i * 7.7) - 0.5) * (TAU / sides) * 0.6 * rough * 2; + angles.push((i / sides) * TAU + wobble); + } + + const ringY = [0, shoulder, 1]; + const ringR = [0.5 * lerp(0.78, 1, taper * 0.5), 0.5, 0.5 * taper]; + + const rings = ringY.map((y, ring) => + angles.map((angle, i) => { + const chip = 1 + (hash1(seed * 11.1 + ring * 13.7 + i * 3.9) - 0.5) * rough * 1.15; + const r = Math.max(0.004, ringR[ring] * chip); + return [Math.cos(angle) * r, y, Math.sin(angle) * r]; + }) + ); + + const positions = []; + const push = (p) => positions.push(p[0], p[1], p[2]); + + for (let ring = 0; ring < rings.length - 1; ring++) { + const lower = rings[ring]; + const upper = rings[ring + 1]; + for (let i = 0; i < sides; i++) { + const j = (i + 1) % sides; + push(lower[i]); push(lower[j]); push(upper[i]); + push(lower[j]); push(upper[j]); push(upper[i]); + } + } + + // Both caps are fans off their own centre. The outer one is nudged off-axis + // so the face that catches the light is never perfectly flat. + const inner = [0, 0, 0]; + const outer = [ + (hash1(seed * 17.3) - 0.5) * 0.12 * rough, + 1, + (hash1(seed * 19.7) - 0.5) * 0.12 * rough + ]; + const base = rings[0]; + const top = rings[2]; + for (let i = 0; i < sides; i++) { + const j = (i + 1) % sides; + push(inner); push(base[j]); push(base[i]); + push(top[i]); push(top[j]); push(outer); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + // Non-indexed + per-face normals: this is what makes the facets crisp, and a + // facet that is not crisp is a pebble. + geometry.computeVertexNormals(); + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* The stone */ +/* ---------------------------------------------------------------------- */ +/** + * Grey stone, shaded to be *quiet*. + * + * Built on `MeshStandardMaterial` through `patchGrowthMaterial` so the plates + * take the stage's real shadows and its probe. Everything injected on top is + * subtractive in spirit: a key-facing term that darkens the faces turned away, + * world-space grain so neighbouring plates look quarried from one block, a rim + * darkening in local space so each plate keeps its own edge, and a pale dust + * film on a plate that has just landed. + * + * The one emissive term is `uEmission`, which ships at 0.06 and is mixed almost + * entirely into the arrival flash. There is no fresnel rim here at all. Every + * other slot in this sandbox is fighting the bloom pass for headroom; this one + * is trying not to be noticed, and taking the rim out was the single change + * that made it read as rock instead of as ceramic. + */ +function createPetrifyMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.88, + metalness: 0.0, + flatShading: true, + transparent: true, + depthWrite: true + }); + + const uniforms = { + uLightDir: frame.uLightDir, + uColorStone: { value: new Color() }, + uColorShade: { value: new Color() }, + uColorSeam: { value: new Color() }, + uColorPale: { value: new Color() }, + uGrain: { value: 0.75 }, + uGrainScale: { value: 5.5 }, + uSeamDepth: { value: 0.6 }, + uFacetSharp: { value: 0.6 }, + uBirthPale: { value: 0.55 }, + uEmission: { value: 0.06 } + }; + + patchGrowthMaterial(material, { + environment, + uniforms, + common: /* glsl */ ` + uniform vec3 uLightDir; + uniform vec3 uColorStone; + uniform vec3 uColorShade; + uniform vec3 uColorSeam; + uniform vec3 uColorPale; + uniform float uGrain; + uniform float uGrainScale; + uniform float uSeamDepth; + uniform float uFacetSharp; + uniform float uBirthPale; + uniform float uEmission; + ${noiseGLSL} + `, + fragment: /* glsl */ ` + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + + // The same key direction the lit meshes are using. A fake shading term + // that disagrees with the real sun reads as a sticker, every time. + float key = clamp(dot(N, uLightDir) * 0.5 + 0.5, 0.0, 1.0); + vec3 body = mix(uColorShade, uColorStone, key); + + // Grain in WORLD space, so two plates that ended up beside each other + // look cut from one block. In local space each plate carries its own + // copy of the same speckle and the column reads as forty identical + // pebbles glued together, which is exactly what the first pass looked + // like. + float grain = fbm3(vGrowWorld * uGrainScale + vGrowSeed * 13.0) * 0.5 + 0.5; + body = mix(body, uColorShade, (1.0 - grain) * uGrain * 0.55); + + // The seam runs in LOCAL space: it is this plate's own rim, and it has to + // follow it however the plate is scaled. + float rim = smoothstep(0.30, 0.5, length(vGrowLocal.xz)); + body = mix(body, uColorSeam, clamp(rim * uSeamDepth, 0.0, 1.0)); + + // Lift the faces pointing at the camera so the column reads as a bundle + // of planes rather than one smooth log. + body *= mix(1.0, 0.58 + 0.85 * ndv, uFacetSharp); + + // Rock dust on a plate that has just arrived — albedo, not emission. + body = mix(body, uColorPale, clamp(vGrowBirth * uBirthPale, 0.0, 1.0)); + + diffuseColor.rgb *= body; + + // The only light this ability makes. Deliberately tiny, and weighted onto + // the arrival so a standing column emits essentially nothing. + totalEmissiveRadiance += uColorPale * uEmission * (0.2 + 0.8 * vGrowBirth); + ` + }); + + material.userData.uniforms = uniforms; + + /** Pull the palette and every shading control from the live settings. */ + material.userData.sync = () => { + const c = settings.petrify; + const g = settings.global; + + uniforms.uColorStone.value.copy(getColor(c.colorStone)); + uniforms.uColorShade.value.copy(getColor(c.colorShade)); + uniforms.uColorSeam.value.copy(getColor(c.colorSeam)); + uniforms.uColorPale.value.copy(getColor(c.colorPale)); + + uniforms.uGrain.value = c.grain * g.shaderIntensity; + uniforms.uGrainScale.value = c.grainScale * g.noiseFrequency; + uniforms.uSeamDepth.value = c.seamDepth; + uniforms.uFacetSharp.value = c.facetSharp; + uniforms.uBirthPale.value = c.birthPale; + uniforms.uEmission.value = c.emission * g.glow; + + material.roughness = c.stoneRoughness; + material.envMapIntensity = c.envIntensity; + material.opacity = c.opacity * g.opacity; + }; + + material.userData.sync(); + return material; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * PETRIFY — a column of stone that gathers out of the air and then lets go. + * + * Three beats. The gaze runs the aimed line at `speed`; behind it grey plates + * appear out of nothing on a **cylindrical shell** around the cast axis and + * converge inward until they lock against the column's surface. The column + * stands for `holdTime`. Then it crumbles from the caster's end forward — the + * plates shrink to nothing while, in the same volume, a raymarched fall of sand + * takes over. + * + * **THE TRICK — accretion, then collapse, with an overlap in the middle.** + * + * *Accretion* is the placement, not the scaling. Each facet's world position is + * `axis(s) + radial(θ) · r`, and `r` is a live lerp from `shellRadius` down to + * the column's own surface radius driven by that facet's personal arrival + * clock. Everything else about the approach exists to stop it being a straight + * radial slide: `swirl` sweeps the bearing round the axis on the way in, + * `tumble` rolls the plate about its own normal, `shellDrift` lets it wander + * along the axis while it is still out there, and a damped spring carries it + * `overshoot` past the surface so it arrives with a knock. The first version + * just scaled the plates up where they would end up, and it read as a row of + * props fading in — no amount of colour fixed it, because the missing thing was + * motion toward a place. + * + * *Collapse* is a handoff, and the handoff is the part that can visibly break. + * `crumbleTime` shrinks the plates on a front that runs the line; the sand + * volume's clock starts `sandLead` seconds **earlier**, during the tail of the + * hold, so the sand is already at density before the first plate has finished + * going. Get that number to zero and there is one frame where the column is + * simply not there. + * + * **Why this field is written out longhand rather than handed to + * `GrowthField`.** It was tried first, and it cannot express the trick. That + * module places every instance in a horizontal *band* around the cast line and + * re-derives the position from global params only — there is no per-instance + * bearing about the axis and no way for an instance's own emergence clock to + * move it, so neither the shell nor the convergence survives the port. What did + * come across is the discipline: a record here holds dice rolls and one + * timestamp, `patchGrowthMaterial` does the shader wiring, and the plates carry + * the same `aSeed` / `aBirth` attributes every other growth field in the + * project uses. The module's material helper is doing real work; its placement + * simply is not the shape of this ability. + * + * **The rule that makes the editor work.** A facet record holds an along-line + * fraction, a bearing fraction, five signed jitters and one timestamp. Not one + * metre, radian or second is captured. `shellRadius`, `columnRadius`, `swirl` + * and `axisSag` are all resolved against `settings.petrify` inside the update + * loop, on a zero-length frame included — so pausing mid-accretion and dragging + * `shellRadius` re-throws the plates that are still in flight *and* re-seats the + * ones that have already landed. + */ +export class PetrifyAbility extends Ability { + constructor(context) { + super('petrify', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.material = createPetrifyMaterial(this.ctx.environment); + + /** + * Numeric signature of the last geometry build, compared value by value. + * `IceAbility` composes a template literal for this every frame; at one + * ability that is invisible and at fifty it is fifty strings a frame for + * the collector to sweep up for nothing. + */ + this._shapeSignature = new Float64Array(4).fill(NaN); + this._shape = { sides: 6, taper: 0.66, rough: 0.44, shoulder: 0.62 }; + + this.meshes = []; + this.seedAttributes = []; + this.birthAttributes = []; + + for (let v = 0; v < VARIANTS; v++) { + const seeds = new InstancedBufferAttribute(new Float32Array(SLOTS), 1); + const births = new InstancedBufferAttribute(new Float32Array(SLOTS), 1); + for (let i = 0; i < SLOTS; i++) seeds.array[i] = Math.random() * 10; + + const geometry = createFacetGeometry(4.7 + v * 19.3, this._shape); + geometry.setAttribute('aSeed', seeds); + geometry.setAttribute('aBirth', births); + + const mesh = new InstancedMesh(geometry, this.material, SLOTS); + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.frustumCulled = false; + mesh.count = 0; + // Solid world geometry: it belongs in the depth prepass, which is what + // lets the dust and the sand volume fade softly where they meet it. + mesh.layers.set(LAYER.WORLD); + mesh.renderOrder = 2; + this.group.add(mesh); + + this.meshes.push(mesh); + this.seedAttributes.push(seeds); + this.birthAttributes.push(births); + } + + /** + * The sand that replaces the column. + * + * BOX rather than CYLINDER, and the reason is that the column is horizontal + * and the sand is not: the volume the grains occupy is the column's + * footprint extruded straight *down* to the floor, which a box contains + * tightly and a cylinder standing on +Y does not contain at all. `maxSteps` + * is low because this hull is long and the camera sees a lot of it — + * coverage is the expensive axis, not resolution. + */ + this.sand = new VolumeHull({ + hull: HullShape.BOX, + medium: Medium.SAND, + prefix: 'sand', + maxSteps: 32, + renderOrder: 12 + }); + this.group.add(this.sand.mesh); + + /** + * Fixed-size record pool. Dice and one timestamp — see the class comment. + */ + this.records = []; + for (let i = 0; i < MAX_FACETS; i++) { + this.records.push({ + along: 0, // 0..1 down the cast line + angle: 0, // 0..1 of a turn about the cast axis + roll: 0, // 0..1 of a turn about the plate's own normal + shellRoll: 0, // -1..1, how far out this one starts + driftRoll: 0, // -1..1, along-axis wander while it is still out there + sizeRoll: 0, // -1..1 + swirlSign: 1, // ±1, which way round the axis it comes in + stagger: 0, // 0..1 of `accreteStagger` + accreteAt: -1, // absolute age it was released at, or -1 + landed: false // has its arrival puff fired + }); + } + + this._activeCount = 0; + /** Metres of crumble front already paid out in ground marks. */ + this._duneDistance = 0; + } + + /** + * Rebuild the plates when a *shape* control moves. + * + * Facet count, taper, roughness and shoulder cannot be expressed as a + * per-instance transform, so they are baked in — and one plate is under a + * hundred triangles, cheap enough to rebuild outright rather than approximate + * in a vertex shader. That is what keeps them live sliders with the clock + * stopped. + */ + _syncGeometry() { + const c = settings.petrify; + const shape = this._shape; + shape.sides = c.facetSides; + shape.taper = c.facetTaper; + shape.rough = c.facetRough; + shape.shoulder = c.facetShoulder; + + const signature = this._shapeSignature; + let changed = false; + if (signature[0] !== shape.sides) { signature[0] = shape.sides; changed = true; } + if (signature[1] !== shape.taper) { signature[1] = shape.taper; changed = true; } + if (signature[2] !== shape.rough) { signature[2] = shape.rough; changed = true; } + if (signature[3] !== shape.shoulder) { signature[3] = shape.shoulder; changed = true; } + if (!changed) return; + + for (let v = 0; v < VARIANTS; v++) { + const mesh = this.meshes[v]; + const previous = mesh.geometry; + const geometry = createFacetGeometry(4.7 + v * 19.3, shape); + // The per-instance attributes are state, not shape — carry them over. + geometry.setAttribute('aSeed', this.seedAttributes[v]); + geometry.setAttribute('aBirth', this.birthAttributes[v]); + mesh.geometry = geometry; + previous.dispose(); + } + } + + createParticles() { + const particles = this.ctx.particles; + + // Rock dust hanging around the column. Non-additive so it genuinely + // occludes: an additive haze on a grey ability lifts the whole thing off + // the floor and undoes the restraint. + this.dust = particles.get('petrify.dust', { + capacity: 2200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 2.1; + this.dust.uniforms.uEndSize.value = 3.0; + this.dust.uniforms.uSizeIn.value = 0.12; + this.dust.uniforms.uFadeIn.value = 0.18; + this.dust.uniforms.uFadeOut.value = 0.32; + + // Chips flicked off as a plate knocks into the column. + this.grit = particles.get('petrify.grit', { + capacity: 1600, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.3; + this.grit.uniforms.uEndSize.value = 0.75; + this.grit.uniforms.uFadeOut.value = 0.7; + + // The fall of sand, as discrete grains. Stretched along the velocity, + // which at these speeds is a two-centimetre smear — just enough that the + // fall reads as a fall rather than as a cloud of dots. + this.grains = particles.get('petrify.grains', { + capacity: 3000, + shape: ParticleShape.STREAK, + additive: false, + stretch: true, + softFade: 0.3 + }); + this.grains.uniforms.uDrag.value = 0.9; + this.grains.uniforms.uEndSize.value = 0.5; + this.grains.uniforms.uSizeIn.value = 0.04; + this.grains.uniforms.uFadeIn.value = 0.06; + this.grains.uniforms.uFadeOut.value = 0.45; + + this.dustEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + this.grainEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._activeCount; + } + + /** The column stands for `holdTime`, then the fade crumbles it. */ + get impactDuration() { + return Math.max(0.1, settings.petrify.holdTime * settings.global.lifetime); + } + + /** + * The crumble, plus the tail the sand keeps falling for once every plate has + * gone. Splitting the two is what lets the sand outlive the stone, which is + * how a collapse actually ends. + */ + get fadeDuration() { + const c = settings.petrify; + return Math.max(0.1, c.crumbleTime + c.sandTail); + } + + /** Stone does not gutter and it does not glint. It sits there. */ + lightShimmer() { + return 1; + } + + /* ------------------------------------------------------------------ */ + /* The axis — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Metres of column between the caster and the target, always positive. */ + _span() { + return Math.max(0.2, this.length - settings.petrify.axisForward); + } + + /** + * A point on the column's *axis* at `s` along it, 0..1. + * + * The sag is a half-sine rather than a parabola for one reason: it has to be + * exactly zero at both ends, or the column detaches from the caster's hand at + * one end and hangs below the impact point at the other. + */ + _axisPoint(s, out) { + const c = settings.petrify; + const t = saturate(s); + out.copy(this.origin).addScaledVector(this.direction, c.axisForward + t * this._span()); + out.y = lerp(c.axisHeight, c.endHeight, t) - c.axisSag * Math.sin(t * Math.PI); + return out; + } + + /** The column's surface radius at `s`, metres. */ + _surfaceRadius(s) { + const c = settings.petrify; + return lerp(c.columnRadiusNear, c.columnRadius, Math.pow(saturate(s), Math.max(0.05, c.columnCurve))); + } + + /* ------------------------------------------------------------------ */ + /* The clocks */ + /* ------------------------------------------------------------------ */ + + /** + * How far one facet is through its arrival: 0 → 1 with a damped overshoot + * past 1, negative while it has not been released yet. + */ + _arrival(record) { + if (record.accreteAt < 0) return -1; + const c = settings.petrify; + const elapsed = this.age - record.accreteAt; + if (elapsed < 0) return -1; + + const time = Math.max(0.02, c.accreteTime); + if (elapsed <= time) return Easing.outQuint(saturate(elapsed / time)); + + // Past the surface and back: the knock that says the plate hit something. + const after = elapsed - time; + const spring = Math.sin(after * c.springRate) * Math.exp(-after / Math.max(0.05, c.settle)); + return 1 + c.overshoot * spring; + } + + /** + * How far one facet is through its own collapse, 0..1. + * + * The front runs the line: `crumbleStagger` is how much of the crumble is + * spent travelling rather than collapsing, and `crumbleSpan` is how long one + * plate takes once its turn comes. Both are fractions of `crumbleTime`, so + * dragging that one slider re-times the whole collapse and the two shape + * controls keep meaning what they say. + */ + _crumbleOf(record) { + if (this.phase !== AbilityPhase.FADE) return 0; + const c = settings.petrify; + const k = saturate(this.fadeTime / Math.max(0.05, c.crumbleTime)); + const stagger = saturate(c.crumbleStagger); + const span = Math.max(0.05, c.crumbleSpan); + return saturate((k * (1 + stagger) - record.along * stagger) / span); + } + + /** Where the crumble front has reached, 0..1 along the line. */ + _crumbleFront() { + if (this.phase !== AbilityPhase.FADE) return 0; + const c = settings.petrify; + const k = saturate(this.fadeTime / Math.max(0.05, c.crumbleTime)); + const stagger = saturate(c.crumbleStagger); + return stagger > 1e-3 ? saturate((k * (1 + stagger)) / stagger) : k; + } + + /** + * Seconds the sand has been falling for — and the whole reason the handoff + * has no hole in it. + * + * It runs `sandLead` seconds ahead of the crumble by borrowing the tail of + * the hold: while the column is still standing and `sandLead` seconds remain + * on the impact clock, the sand has already started. Everything else about + * the collapse keys off `fadeTime`; this is the one clock that does not. + */ + _sandAge() { + const c = settings.petrify; + if (this.phase === AbilityPhase.FADE) return c.sandLead + this.fadeTime; + if (this.phase === AbilityPhase.IMPACT) { + return Math.max(0, c.sandLead - (this.impactDuration - this.impactTime)); + } + return 0; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.petrify; + + this.dustEmitter.reset(); + this.gritEmitter.reset(); + this.grainEmitter.reset(); + this._duneDistance = 0; + + const wanted = Math.min(MAX_FACETS, Math.max(1, Math.round(c.facetCount))); + this._activeCount = wanted; + + for (let i = 0; i < wanted; i++) { + const record = this.records[i]; + record.accreteAt = -1; + record.landed = false; + // Stratified along the line rather than uniform, so a sparse column still + // covers its whole length instead of leaving a gap somebody has to see. + record.along = (i + Math.random()) / wanted; + record.angle = Math.random(); + record.roll = Math.random(); + record.shellRoll = randRange(-1, 1); + record.driftRoll = randRange(-1, 1); + record.sizeRoll = randRange(-1, 1); + record.swirlSign = Math.random() < 0.5 ? -1 : 1; + record.stagger = Math.random(); + } + + for (let i = wanted; i < MAX_FACETS; i++) this.records[i].accreteAt = -1; + for (let v = 0; v < VARIANTS; v++) this.meshes[v].count = 0; + + this._sync(0); + } + + /** Release every facet the gaze has now reached. `limit` is 0..1. */ + _triggerUpTo(limit) { + const c = settings.petrify; + for (let i = 0; i < this._activeCount; i++) { + const record = this.records[i]; + if (record.accreteAt >= 0) continue; + if (record.along > limit) continue; + record.accreteAt = this.age + record.stagger * c.accreteStagger; + } + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Rebuild every plate's matrix from the live settings, and re-place the sand. + * Allocation-free, and correct on a zero-length frame. + */ + _sync(dt) { + const c = settings.petrify; + const g = settings.global; + + this._syncGeometry(); + this.material.userData.sync(); + this._syncParticles(c, g); + + const birthFade = Math.max(0.02, c.birthFade); + const jitter = g.randomness; + let used0 = 0; + let used1 = 0; + let used2 = 0; + + for (let i = 0; i < this._activeCount; i++) { + const record = this.records[i]; + const variant = i % VARIANTS; + const slot = (i / VARIANTS) | 0; + const arrival = this._arrival(record); + + if (arrival < 0) { + // Not released yet. Park it out of the view rather than drawing a + // degenerate matrix at the origin, which shows as a speck on the floor. + _dummy.position.set(0, -999, 0); + _dummy.quaternion.identity(); + _dummy.scale.setScalar(0.0001); + _dummy.updateMatrix(); + this.meshes[variant].setMatrixAt(slot, _dummy.matrix); + this.birthAttributes[variant].array[slot] = 0; + if (variant === 0) used0 = Math.max(used0, slot + 1); + else if (variant === 1) used1 = Math.max(used1, slot + 1); + else used2 = Math.max(used2, slot + 1); + continue; + } + + const lock = Math.max(0, Math.min(1.3, arrival)); + const crumble = this._crumbleOf(record); + const s = record.along; + + /* --- where it is: a point on the shell, sliding onto the surface --- */ + const shell = c.shellRadius * (1 + record.shellRoll * c.shellJitter * jitter); + const radius = lerp(shell, this._surfaceRadius(s), lock); + const theta = record.angle * TAU + c.swirl * (1 - lock) * record.swirlSign; + + _radial.copy(this.side).multiplyScalar(Math.cos(theta)); + _radial.addScaledVector(_up, Math.sin(theta)); + + this._axisPoint(s, _dummy.position); + _dummy.position.addScaledVector(_radial, radius); + _dummy.position.addScaledVector( + this.direction, + c.shellDrift * record.driftRoll * (1 - lock) * jitter + ); + // It falls as it goes to pieces, quadratically, because it is falling. + if (crumble > 0) _dummy.position.y -= c.crumbleDrop * crumble * crumble; + + /* --- the arrival puff, once, as it touches the column --- */ + if (!record.landed && arrival > 0.82) { + record.landed = true; + this._landFx(c, g, _dummy.position); + } + + /* --- which way it faces: +Y is the plate's normal --- */ + _quat.setFromUnitVectors(_up, _radial); + _roll.setFromAxisAngle( + _up, + record.roll * TAU + c.tumble * (1 - lock) * record.swirlSign + c.crumbleSpin * crumble + ); + _quat.multiply(_roll); + + /* --- how big --- */ + const plate = + lerp(c.facetSizeNear, c.facetSize, s) * (1 + record.sizeRoll * c.facetSizeJitter * jitter); + const grow = Math.min(1, arrival) * (1 - crumble); + + _dummy.quaternion.copy(_quat); + _dummy.scale.set(plate, Math.max(0.004, plate * c.facetThickness), plate); + _dummy.scale.multiplyScalar(Math.max(0.0001, grow)); + _dummy.updateMatrix(); + + this.meshes[variant].setMatrixAt(slot, _dummy.matrix); + this.birthAttributes[variant].array[slot] = saturate( + 1 - (this.age - record.accreteAt) / birthFade + ); + if (variant === 0) used0 = Math.max(used0, slot + 1); + else if (variant === 1) used1 = Math.max(used1, slot + 1); + else used2 = Math.max(used2, slot + 1); + } + + this.meshes[0].count = used0; + this.meshes[1].count = used1; + this.meshes[2].count = used2; + for (let v = 0; v < VARIANTS; v++) { + this.meshes[v].instanceMatrix.needsUpdate = true; + this.birthAttributes[v].needsUpdate = true; + } + + this._syncSand(c, g); + if (dt > 0) this._grainFx(dt, c, g); + } + + /** + * Place, size and thin the sand. + * + * The box is the column's footprint extruded to the floor: half-width from + * the column radius, height from the axis plus a plate's worth of headroom, + * half-length from the span. `VolumeHull`'s one rule is that the hull must be + * the smallest shape that still contains the field — too small and the sand + * is sliced off along a dead straight line, too big and every ray spends its + * step budget crossing vacuum. `sandGirth` and `sandHead` are the two knobs + * that let you find that edge with the clock stopped. + */ + _syncSand(c, g) { + const age = this._sandAge(); + + let fade = Easing.outQuad(saturate(age / Math.max(0.05, c.sandOnset))); + if (this.phase === AbilityPhase.FADE) { + fade *= 1 - Easing.inQuad(saturate((this.fadeTime - c.crumbleTime) / Math.max(0.05, c.sandTail))); + } + fade = saturate(fade * c.sandDust); + + const span = this._span(); + this._axisPoint(0.5, _centre); + // BOX stands *on* the floor, so the anchor is the midpoint's ground shadow + // and the height is measured up from there. + _centre.y = 0; + + const half = Math.max(c.columnRadius, c.columnRadiusNear) * c.sandGirth; + const top = Math.max(c.axisHeight, c.endHeight) + c.columnRadius + c.sandHead; + + this.sand + .place(_centre, this.direction) + .setSize(half, top, span * 0.5) + .setFade(fade) + .sync(c, g); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + _syncParticles(c, g) { + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.3 * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + + this.grains.setGradient( + getColor(c.colorGrainA), + getColor(c.colorGrainB), + getColor(c.colorGrainC), + getColor(c.colorGrainD) + ); + this.grains.uniforms.uGravity.value.set(0, c.grainGravity, 0); + this.grains.uniforms.uSizeScale.value = c.grainSize * g.particleSize * 7; + this.grains.uniforms.uLifeScale.value = c.grainLifetime * 0.5 * g.particleLifetime; + this.grains.uniforms.uSpeedScale.value = g.particleSpeed; + this.grains.uniforms.uOpacity.value = c.grainOpacity * g.opacity; + this.grains.uniforms.uStretch.value = c.grainStretch; + } + + /** A few chips and a breath of dust as a plate knocks into the column. */ + _landFx(c, g, position) { + // Only some plates throw anything. Two hundred puffs at once buries the + // column in haze and hides the silhouette, which is the whole ability. + if (Math.random() > 0.35) return; + const time = frame.uTime.value; + + _emit.position = _pos.copy(position); + _emit.radius = c.columnRadius * 0.5; + _emit.direction = _dir.copy(_radial); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.08; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 6; + _emit.tint = null; + _emit.time = time; + this.grit.emit(Math.round(3 * g.particleCount), _emit); + } + + /** The haze the accretion sheds, along whatever part of the column exists. */ + _dustFx(dt, reach) { + const c = settings.petrify; + const g = settings.global; + const count = Math.round(this.dustEmitter.tick(dt, c.dustRate) * g.particleCount); + if (count <= 0) return; + + const s = Math.random() * Math.max(0.02, reach); + this._axisPoint(s, _pos); + _emit.position = _pos; + _emit.radius = c.shellRadius * 0.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(count, _emit); + } + + /** + * The grains, and the dust rings where they land. + * + * Both are keyed off the crumble *front* rather than off the whole column, so + * the fall travels the line exactly as the collapse does. Emitting over the + * whole span instead was the obvious first version and it dropped the entire + * column's worth of sand on frame one. + */ + _grainFx(dt, c, g) { + if (this.phase !== AbilityPhase.FADE) return; + const front = this._crumbleFront(); + if (front <= 0) return; + + const count = Math.round(this.grainEmitter.tick(dt, c.grainRate) * g.particleCount); + if (count > 0) { + const s = Math.random() * front; + this._axisPoint(s, _pos); + _emit.position = _pos; + _emit.radius = this._surfaceRadius(s) * 1.1; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.grainSpeed; + _emit.speedVariance = 0.6; + _emit.spread = 0.35; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.6; + _emit.life = c.grainLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grains.emit(count, _emit); + } + + // Chunks as well as grains: a column does not dissolve, it sheds lumps and + // *then* dissolves. Without these the fall is uniformly fine and reads as + // a smoke effect that happens to be brown. + const chips = Math.round(this.gritEmitter.tick(dt, c.gritRate) * g.particleCount); + if (chips > 0) { + const s = Math.random() * front; + this._axisPoint(s, _pos); + _emit.position = _pos; + _emit.radius = this._surfaceRadius(s); + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.gritSpeed * 0.5; + _emit.speedVariance = 0.7; + _emit.spread = 0.5; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 5; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grit.emit(chips, _emit); + } + + /* the dust rings the fall leaves on the floor */ + const step = 1 / Math.max(0.05, c.duneRate); + const reached = front * this._span(); + while (reached - this._duneDistance >= step) { + this._duneDistance += step; + const s = saturate(this._duneDistance / this._span()); + this._axisPoint(s, _pos); + _pos.y = 0; + _pos.x += this.side.x * randRange(-0.5, 0.5) * c.columnRadius; + _pos.z += this.side.z * randRange(-0.5, 0.5) * c.columnRadius; + + this.ctx.decals.spawn(DecalType.DUSTRING, _pos, { + radius: c.duneRadius * randRange(0.7, 1.3), + life: c.duneLife, + intensity: c.duneIntensity, + colorA: getColor(c.colorDune), + colorB: getColor(c.colorDuneEdge), + height: 0.014 + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._triggerUpTo(this.u); + this._sync(dt); + this._dustFx(dt, this.u); + + // The light rides the front, on the axis rather than on the floor. + this._axisPoint(this.u, this.position); + + this.ctx.shake.rumble(settings.petrify.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.petrify; + const g = settings.global; + + // Everything still waiting goes now: the far end of the column closes. + this._triggerUpTo(1); + + this._axisPoint(1, _pos); + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.35, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.9, + intensity: c.burstIntensity, + opacity: 0.7, + fresnel: 0.9, + displace: 0.5, + squash: 0.85, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the floor under the far end takes the weight */ + this.pointAt(1, _pos); + this.ctx.decals.spawn(DecalType.CRACK, _pos, { + radius: c.crackRadius, + life: c.crackLife, + width: 0.5, + intensity: c.crackIntensity, + colorA: getColor(c.colorCrack), + colorB: getColor(c.colorCrackEdge) + }); + + _emit.position = _pos; + _emit.radius = c.crackRadius * 0.6; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed * 1.8; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime * 1.3; + _emit.lifeVariance = 0.5; + _emit.spin = 8; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grit.emit(Math.round(c.burstGrit * g.particleCount), _emit); + + this.ctx.shake.add( + c.lockShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 14 + ); + this.lightBoost = c.lightIntensity * 0.6 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.petrify; + this._sync(dt); + + // The light sits on the middle of the column while it stands, and follows + // the collapse down the line once it lets go. + if (this.phase === AbilityPhase.FADE) this._axisPoint(this._crumbleFront(), this.position); + else this._axisPoint(0.5, this.position); + + // The column keeps shedding while it stands; the crumble is the loud part, + // and "loud" here is a fifth of what the storm slots do. + this._dustFx(dt, 1); + + if (this.phase === AbilityPhase.FADE && t < 1.4) { + this.ctx.shake.rumble(c.crumbleShake * settings.global.cameraShake * 0.3, dt); + } + } + + onDestroy() { + this._activeCount = 0; + for (let v = 0; v < VARIANTS; v++) this.meshes[v].count = 0; + this.sand.setFade(0); + } + + dispose() { + for (const mesh of this.meshes) { + mesh.geometry.dispose(); + mesh.dispose(); + } + this.material.dispose(); + this.sand.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/stone/SinkholeAbility.js b/src/abilities/stone/SinkholeAbility.js new file mode 100644 index 0000000..b781e5d --- /dev/null +++ b/src/abilities/stone/SinkholeAbility.js @@ -0,0 +1,984 @@ +import { + AlwaysDepth, + BufferAttribute, + BufferGeometry, + Color, + DoubleSide, + Mesh, + MeshStandardMaterial, + ShaderMaterial, + Vector3 +} from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { Projectile, FlightMode, Stagger, projectileParams } from '../../vfx/Projectile.js'; +import { createAsteroidGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { noiseGLSL } from '../../shaders/lib/noise.glsl.js'; +import { sharedUniforms, frame } from '../../core/FrameUniforms.js'; +import { LAYER } from '../../core/Layers.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, Easing } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on calving chunks. `debrisCount` clamps here. */ +const MAX_CHUNKS = 48; + +/* Module-scope scratch. Nothing in a frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); + +/* ---------------------------------------------------------------------- */ +/* The funnel */ +/* ---------------------------------------------------------------------- */ + +/** + * A radial parameter grid. **There are no metres in this buffer.** + * + * `position` carries `(angleFraction, radialFraction, 0)` and nothing else; the + * vertex shader turns that pair into a world point every frame off the live + * uniforms. That is the whole reason `depth`, `wallCurve`, `lip` and `calve` + * remain sliders on a hole that has already finished opening — there is no + * baked shape anywhere to go stale. The only thing `spokes` and `rings` decide + * is how finely the profile is sampled, which is why moving *those* two is the + * one change that has to rebuild the buffer. + * + * Ring 0 is a full ring of vertices that all resolve to the same point at the + * throat. That is deliberately wasteful — a proper fan would save 55 vertices — + * and it buys a uniform grid the finite-difference normal can walk without a + * special case at the centre, which is worth more than 55 vertices. + */ +function createFunnelGeometry(spokes, rings) { + const cols = spokes + 1; + const positions = new Float32Array((rings + 1) * cols * 3); + let o = 0; + for (let r = 0; r <= rings; r++) { + const u = r / rings; + for (let s = 0; s <= spokes; s++) { + positions[o++] = s / spokes; + positions[o++] = u; + positions[o++] = 0; + } + } + + // Uint16 is deliberate: the grid is capped at 64 rings x 96 spokes = 6305 + // vertices, comfortably inside 16 bits, and a 32-bit index buffer needs an + // extension on WebGL1 that this project has no other reason to require. + const indices = new Uint16Array(rings * spokes * 6); + let i = 0; + for (let r = 0; r < rings; r++) { + for (let s = 0; s < spokes; s++) { + // Named for their corner, not `a b c d`: a bare `const c` in this file + // would look like a settings alias to the harness's static cross-check + // and blind it to every real `c.` read in the class below it. + const inner0 = r * cols + s; + const inner1 = inner0 + 1; + const outer0 = inner0 + cols; + const outer1 = outer0 + 1; + indices[i++] = inner0; + indices[i++] = outer0; + indices[i++] = outer1; + indices[i++] = inner0; + indices[i++] = outer1; + indices[i++] = inner1; + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setIndex(new BufferAttribute(indices, 1)); + return geometry; +} + +/** + * The profile, evaluated in the vertex shader, in metres. + * + * Shared between the position and the two finite-difference taps that build the + * normal, so the shading can never disagree with the silhouette however hard + * the sliders are pulled. Writing the normal out by hand instead was the first + * attempt and it survived about ten minutes: every one of `wallCurve`, `lip`, + * `calveDrop` and `rough` contributes a term, and a hand-differentiated version + * quietly stops matching the moment any of them is touched. + */ +const FUNNEL_VERTEX = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform vec3 uCentre; + uniform float uRadius; + uniform float uApron; + uniform float uDepth; + uniform float uDrop; + uniform float uWallCurve; + uniform float uLip; + uniform float uRough; + uniform float uRoughScale; + uniform float uCalve; + uniform float uCalveCells; + uniform float uCalveDrop; + uniform float uSpokes; + uniform float uRings; + uniform float uSeed; + + varying vec3 vWorld; + varying vec3 vNrm; + varying float vRadial; + varying float vDrop; + + float holeHeight(float r, float ang) { + float x = r / max(uRadius, 0.05); + + // The bowl. One exponent takes it from a saucer to a bell-mouthed shaft. + float bowl = pow(max(0.0, 1.0 - min(x, 1.0)), max(uWallCurve, 0.05)); + float h = -uDepth * bowl; + + // Wedges. The rim does not break on a circle: it breaks into blocks, and + // each block waits for its own threshold before it goes. + float wedge = floor(ang / TAU * max(uCalveCells, 1.0) + uSeed * 7.0); + float roll = hash11(wedge * 1.37 + uSeed); + + // Spoil heaped just outside the rim, in the same blocks. + float apronBand = smoothstep(1.0, 1.1, x) * (1.0 - smoothstep(1.1, 1.55, x)); + h += uLip * apronBand * (0.45 + 1.1 * roll); + + float band = smoothstep(1.12, 0.86, x) * smoothstep(0.5, 0.82, x); + h -= uCalveDrop * band * (0.4 + 0.9 * roll) * + smoothstep(roll * 0.7, roll * 0.7 + 0.3, uCalve); + + // Scree grain, sampled in the plane and not on the bearing: an angular + // lookup hands every radius along a bearing the same value and draws + // dead-straight gullies from the rim to the throat, which is a firework + // rather than a landslide. + vec2 q = vec2(cos(ang), sin(ang)) * r; + h += snoise(vec3(q * uRoughScale, uSeed * 3.1)) * uRough * uDepth * 0.075 * + (1.0 - min(x, 1.0)); + + return h * uDrop; + } + + vec3 holePoint(float u, float angFrac) { + float ang = angFrac * TAU; + float r = uRadius * uApron * clamp(u, 0.0, 1.0); + return vec3(cos(ang) * r, holeHeight(r, ang), sin(ang) * r); + } + + void main() { + float u = position.y; + float a = position.x; + + vec3 p = holePoint(u, a); + + float du = 1.2 / max(uRings, 2.0); + float da = 1.2 / max(uSpokes, 3.0); + float uu = max(u, du); + vec3 p0 = holePoint(uu, a); + vec3 pa = holePoint(uu, a + da); + vec3 pu = holePoint(uu + du, a); + // cross(tangential, radial-outward) is +Y on flat ground, which is the + // orientation the fragment stage assumes for the lip catch. + vec3 n = cross(pa - p0, pu - p0); + vNrm = vec3(0.0, 1.0, 0.0); + if (dot(n, n) > 1e-10) vNrm = normalize(n); + + vec4 world = modelMatrix * vec4(p + uCentre, 1.0); + vWorld = world.xyz; + vRadial = length(p.xz) / max(uRadius, 0.05); + vDrop = -p.y; + + gl_Position = projectionMatrix * viewMatrix * world; + } +`; + +const FUNNEL_FRAGMENT = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform vec3 uLightDir; + uniform vec3 uColorGround; + uniform vec3 uColorWall; + uniform vec3 uColorDeep; + uniform vec3 uColorVoid; + uniform vec3 uColorLip; + uniform float uAmbient; + uniform float uWrap; + uniform float uSpecular; + uniform float uGloss; + uniform float uDepthTint; + uniform float uStrata; + uniform float uStrataScale; + uniform float uGrit; + uniform float uGritScale; + uniform float uThroat; + uniform float uRimLight; + uniform float uOpacity; + uniform float uFade; + uniform float uApron; + uniform float uApronFade; + uniform float uDepth; + uniform float uSeed; + + varying vec3 vWorld; + varying vec3 vNrm; + varying float vRadial; + varying float vDrop; + + void main() { + vec3 N = normalize(vNrm); + vec3 L = normalize(uLightDir); + + // Wrapped diffuse. A hole is mostly surfaces facing away from the key, and + // an unwrapped lambert puts three quarters of the interior at pure ambient, + // which flattens the one thing the mesh exists to show. + float ndl = dot(N, L); + float shade = uAmbient + (1.0 - uAmbient) * + clamp((ndl + uWrap) / (1.0 + uWrap), 0.0, 1.0); + + vec3 V = normalize(cameraPosition - vWorld); + vec3 H = normalize(L + V); + float spec = pow(max(dot(N, H), 0.0), max(uGloss, 1.0)) * uSpecular * step(0.0, ndl); + + float deep = clamp(vDrop / max(uDepth, 0.05), 0.0, 1.0); + + // Bedding runs level, because these walls are a cut through ground that is + // level, so the bands belong in WORLD y and nowhere else. The first version + // sampled them on the wall's own parameter; they followed the profile and + // the hole looked turned on a lathe. + float bed = 0.5 + 0.5 * sin(vWorld.y * uStrataScale * TAU + + snoise(vec3(vWorld.xz * 0.35, uSeed)) * 1.6); + float scree = snoise01(vec3(vWorld.xz * uGritScale, vWorld.y * uGritScale * 0.5 + uSeed)); + + vec3 body = mix(uColorGround, uColorWall, smoothstep(0.0, 0.18, deep)); + body = mix(body, uColorDeep, pow(deep, max(uDepthTint, 0.05))); + body *= mix(1.0, 0.72 + 0.56 * bed, clamp(uStrata, 0.0, 1.0)); + body *= mix(1.0, 0.7 + 0.6 * scree, clamp(uGrit, 0.0, 2.0)); + + vec3 col = body * shade + uColorLip * spec; + + // The broken lip is the only bright thing in the frame: stone standing + // proud of the floor with the key running along its edge. + float lipBand = smoothstep(1.14, 0.94, vRadial) * smoothstep(0.7, 0.95, vRadial); + col += uColorLip * uRimLight * lipBand * clamp(N.y, 0.0, 1.0); + + // The throat. Not shaded, not lit, not grained. The point is that there is + // nothing down there to measure a distance against, and every term above + // would give the eye one. + col = mix(col, uColorVoid, smoothstep(uThroat * 1.8, uThroat * 0.25, vRadial)); + + float alpha = uOpacity * uFade * + (1.0 - smoothstep(uApronFade, max(uApron, uApronFade + 0.01), vRadial)); + if (alpha < 0.004) discard; + gl_FragColor = vec4(col, alpha); + } +`; + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * SINKHOLE — the ground goes down. + * + * Fractures spread across the circle, the floor inside them lets go and drops + * into a funnel, the lip calves inward in blocks that fall and are never seen + * to land, a ring of dust blows out over the rim, and then a long settle in + * which pebbles are still trickling in off the walls. + * + * **THE TRICK is that the funnel is real geometry displaced in the vertex + * shader.** `GroundField` ships a `FUNNEL` mode and it is a good mode, but it + * is a *flat quad* that fakes depth off a height field and a parallax offset — + * and a flat fake survives exactly one camera orbit. So the library's FUNNEL is + * used here for the job it is genuinely right for, the cracked apron of spoil + * and fracture lines on the ground *outside* the rim, which really is flat; and + * the pit itself is a radial mesh whose every vertex is placed in metres by + * `holeHeight()` and whose normal comes from two finite differences on that + * same function. Orbit it and the near wall goes dark while the far wall lights + * up, which is the entire read. + * + * **The floor of the hole is never drawn.** The mesh converges on one vertex at + * the throat and the last fraction of the radius is flat `colorVoid` with no + * shading, no bedding and no grain, so there is no surface down there to judge + * a distance against. The chunks fall to a point well below the throat and the + * near wall eats them on the way past. + * + * **How a hole is painted onto a solid floor.** The stage floor is one opaque + * plane at `y = 0` and there is no stencil budget here, so a mesh below it + * would simply be occluded. The funnel is therefore drawn with `depthFunc: + * AlwaysDepth` — the depth *test* stays enabled, which is what lets it keep + * writing depth (a disabled depth test disables the write with it, which cost + * an hour), but it always passes, so the hole paints over the floor and then + * re-establishes the depth buffer for the chunks and the dust drawn after it. + * The cost is honest and small: something between the camera and the rim would + * be painted over, and the hole is always downrange of the caster. + * + * **The rule that makes the editor work.** A cast captures one seed, the + * chunks' unitless dice and a handful of timestamps. Not a metre: `depth`, + * `zoneRadius`, `wallCurve`, `calveDrop` and every colour are uniforms + * refilled from `settings.sinkhole` on the frame they are drawn, a zero-length + * frame included. Drag `depth` on a hole that has already opened and it opens + * further, with the clock stopped. + */ +export class SinkholeAbility extends Ability { + constructor(context) { + super('sinkhole', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.funnelUniforms = sharedUniforms({ + uCentre: { value: new Vector3() }, + uRadius: { value: 5 }, + uApron: { value: 1.45 }, + uApronFade: { value: 1.08 }, + uDepth: { value: 6.5 }, + uDrop: { value: 0 }, + uWallCurve: { value: 2.4 }, + uLip: { value: 0.3 }, + uRough: { value: 0.75 }, + uRoughScale: { value: 1.1 }, + uCalve: { value: 0 }, + uCalveCells: { value: 14 }, + uCalveDrop: { value: 0.95 }, + uSpokes: { value: 56 }, + uRings: { value: 36 }, + uSeed: { value: 0 }, + uColorGround: { value: new Color() }, + uColorWall: { value: new Color() }, + uColorDeep: { value: new Color() }, + uColorVoid: { value: new Color() }, + uColorLip: { value: new Color() }, + uAmbient: { value: 0.28 }, + uWrap: { value: 0.42 }, + uSpecular: { value: 0.25 }, + uGloss: { value: 18 }, + uDepthTint: { value: 1.4 }, + uStrata: { value: 0.5 }, + uStrataScale: { value: 1.4 }, + uGrit: { value: 0.5 }, + uGritScale: { value: 9 }, + uThroat: { value: 0.16 }, + uRimLight: { value: 0.6 }, + uOpacity: { value: 1 }, + uFade: { value: 0 } + }); + + this.funnelMaterial = new ShaderMaterial({ + transparent: true, + // See the class comment: the test stays on (AlwaysDepth) precisely so the + // write stays on. `depthTest: false` would silently disable the write and + // the chunks would fall straight through the near wall. + depthTest: true, + depthFunc: AlwaysDepth, + depthWrite: true, + // The camera can end up below the rim plane looking along a wall; culling + // there costs nothing to keep and shows a hole in the hole when it goes. + side: DoubleSide, + toneMapped: false, + uniforms: this.funnelUniforms, + vertexShader: `${noiseGLSL}\n${FUNNEL_VERTEX}`, + fragmentShader: `${noiseGLSL}\n${FUNNEL_FRAGMENT}` + }); + + /** Mesh resolution the buffer was last built at. See `_syncMesh`. */ + this._spokes = 0; + this._rings = 0; + this.funnelGeometry = createFunnelGeometry(56, 36); + this._spokes = 56; + this._rings = 36; + + this.funnel = new Mesh(this.funnelGeometry, this.funnelMaterial); + this.funnel.name = 'SinkholeFunnel'; + this.funnel.frustumCulled = false; + this.funnel.matrixAutoUpdate = false; + this.funnel.layers.set(LAYER.VFX); + // After the apron (6) so the pit paints over its interior, before the dust + // (10) and the chunks (12) so both of them test against the walls. + this.funnel.renderOrder = 9; + this.funnel.visible = false; + this.group.add(this.funnel); + + /* --- the cracked apron on the ground that has not fallen --- */ + this.apron = new GroundField(this.group, { + mode: GroundMode.FUNNEL, + additive: false, + name: 'SinkholeApron' + }); + this._apronParams = groundFieldParams(); + this._apronParams.centre = _centre; + + /* --- the chunks --- */ + this.chunkMaterial = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.95, + metalness: 0, + flatShading: true, + // Transparent so it sorts *after* the funnel and is occluded by the wall + // it falls behind. Opaque geometry is drawn before every transparent, and + // the funnel would then paint over every chunk on the frame it is drawn. + transparent: true, + depthWrite: true + }); + + this.chunks = new Projectile(this.group, { + geometry: createAsteroidGeometry({ + seed: 12.7, + detail: 1, + lumpiness: 0.34, + noiseScale: 1.8, + roughness: 0.22, + cuts: 8, + cutDepth: 0.3, + craters: 3, + craterDepth: 0.14, + craterSize: 0.45 + }), + material: this.chunkMaterial, + capacity: MAX_CHUNKS, + trail: false, + layer: LAYER.VFX, + renderOrder: 12, + castShadow: false + }); + this._chunkParams = projectileParams(); + this._chunkParams.mode = FlightMode.FALL; + this._chunkParams.stagger = Stagger.HASH; + + /** Re-rolled per cast so no two holes break the same way. Unitless. */ + this._seed = 0; + /** Set on the frame the floor lets go, so the blast fires exactly once. */ + this._blown = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // The dust. Non-additive: the ring blowing out over the rim has to occlude + // the apron behind it or the hole reads as a decal with smoke over it. + this.dust = particles.get('sinkhole.dust', { + capacity: 3000, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.3 + }); + this.dust.uniforms.uDrag.value = 2.0; + this.dust.uniforms.uEndSize.value = 4.0; + this.dust.uniforms.uSizeIn.value = 0.1; + this.dust.uniforms.uFadeIn.value = 0.12; + this.dust.uniforms.uFadeOut.value = 0.34; + + // Pebbles running off the walls. Lit, and deliberately short-lived: they + // have to be gone before they reach anything that could read as a bottom. + this.pebbles = particles.get('sinkhole.pebbles', { + capacity: 1400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.2 + }); + this.pebbles.uniforms.uDrag.value = 0.2; + this.pebbles.uniforms.uEndSize.value = 0.7; + this.pebbles.uniforms.uFadeOut.value = 0.55; + + this.dustEmitter = new RateEmitter(); + this.pebbleEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.chunks.count; + } + + /** Cracking, then the drop, then the settle — all one phase. */ + get impactDuration() { + const c = settings.sinkhole; + return Math.max(0.2, (c.crackTime + c.dropTime + c.lifetime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.2, settings.sinkhole.sinkTime); + } + + /** A hole does not flicker. The light is a dust glow and it only breathes. */ + lightShimmer() { + return 0.9 + 0.1 * Math.sin(this.age * 2.7); + } + + /** The centre of the circle, on the floor. One number drives everything. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /* ------------------------------------------------------------------ */ + /* Live resolution — every metre comes from here */ + /* ------------------------------------------------------------------ */ + + /** + * Rebuild the parameter grid when the resolution sliders move. + * + * The buffer carries no metres, so nothing else in the ability cares that it + * changed — which is exactly why `spokes` and `rings` can be live controls at + * all rather than boot-time constants. + */ + _syncMesh() { + const c = settings.sinkhole; + const spokes = Math.max(12, Math.min(96, Math.round(c.spokes))); + const rings = Math.max(8, Math.min(64, Math.round(c.rings))); + if (spokes === this._spokes && rings === this._rings) return; + + const previous = this.funnelGeometry; + this.funnelGeometry = createFunnelGeometry(spokes, rings); + this.funnel.geometry = this.funnelGeometry; + previous.dispose(); + this._spokes = spokes; + this._rings = rings; + } + + /** + * Push the whole live block into the funnel, the apron and the two gradients. + * + * @param {number} drop 0..1 how far the floor has fallen + * @param {number} calve 0..1 how far the lip has broken inward + * @param {number} fade 0..1 master opacity on the pit + */ + _syncUniforms(drop, calve, fade) { + const c = settings.sinkhole; + const g = settings.global; + const u = this.funnelUniforms; + + this._syncMesh(); + this._centrePoint(_centre); + + u.uCentre.value.copy(_centre); + u.uRadius.value = Math.max(0.2, c.zoneRadius); + u.uApron.value = c.apron; + u.uApronFade.value = c.apronFade; + u.uDepth.value = c.depth; + u.uDrop.value = drop; + u.uWallCurve.value = c.wallCurve; + u.uLip.value = c.lip; + u.uRough.value = c.rough * g.shaderIntensity; + u.uRoughScale.value = c.roughScale * g.noiseFrequency; + u.uCalve.value = calve; + u.uCalveCells.value = Math.max(3, Math.round(c.calveCells)); + u.uCalveDrop.value = c.calveDrop; + u.uSpokes.value = this._spokes; + u.uRings.value = this._rings; + u.uSeed.value = this._seed; + + u.uColorGround.value.copy(getColor(c.colorGround)); + u.uColorWall.value.copy(getColor(c.colorWall)); + u.uColorDeep.value.copy(getColor(c.colorDeep)); + u.uColorVoid.value.copy(getColor(c.colorVoid)); + u.uColorLip.value.copy(getColor(c.colorLip)); + u.uAmbient.value = c.ambient; + u.uWrap.value = c.wrap; + u.uSpecular.value = c.specular; + u.uGloss.value = c.gloss; + u.uDepthTint.value = c.depthTint; + u.uStrata.value = c.strata * g.shaderIntensity; + u.uStrataScale.value = c.strataScale * g.noiseFrequency; + u.uGrit.value = c.grit * g.shaderIntensity; + u.uGritScale.value = c.gritScale * g.noiseFrequency; + u.uThroat.value = c.throat; + u.uRimLight.value = c.rimLight * g.glow; + u.uOpacity.value = c.opacity * g.opacity; + u.uFade.value = fade; + + this.funnel.visible = fade > 0.003; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.pebbles.setGradient( + getColor(c.colorPebbleA), + getColor(c.colorPebbleB), + getColor(c.colorPebbleC), + getColor(c.colorPebbleD) + ); + this.pebbles.uniforms.uGravity.value.set(0, c.pebbleGravity, 0); + this.pebbles.uniforms.uSizeScale.value = c.pebbleSize * g.particleSize * 7; + this.pebbles.uniforms.uLifeScale.value = c.pebbleLifetime * 0.5 * g.particleLifetime; + this.pebbles.uniforms.uSpeedScale.value = c.pebbleSpeed * g.particleSpeed; + this.pebbles.uniforms.uOpacity.value = g.opacity; + + this.chunkMaterial.color.copy(getColor(c.colorDebris)); + this.chunkMaterial.roughness = c.debrisRoughness; + } + + /** + * The apron: fracture lines and spoil on the ground that has *not* fallen. + * + * `GroundField` re-resolves every metre from this object on the frame it is + * drawn, so the object is refilled here and never cached — including the + * radius, because re-scaling live is the whole reason the module exists. + * + * @param {number} grow 0..1 the fracture front spreading outward + * @param {number} fade 0..1 + */ + _syncApron(grow, fade) { + const c = settings.sinkhole; + const g = settings.global; + const p = this._apronParams; + + this._centrePoint(_centre); + p.centre = _centre; + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = 0.02; + p.radius = Math.max(0.2, c.zoneRadius); + p.grow = grow; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.fieldEdge; + p.ragged = c.fieldRagged; + p.raggedScale = c.fieldRaggedScale; + p.warp = c.fieldWarp; + p.relief = c.fieldRelief; + p.cell = c.fieldCell; + p.cellJitter = c.fieldCellJitter; + p.seam = c.fieldSeam; + p.thickness = c.fieldThickness; + p.lift = c.fieldLift; + p.depth = c.fieldDepth; + p.sharp = c.fieldSharp; + p.detail = c.fieldDetail; + p.parallax = c.fieldParallax; + p.opacity = c.fieldOpacity; + p.colorBase = c.colorFieldBase; + p.colorEdge = c.colorFieldEdge; + p.colorGlow = c.colorFieldGlow; + p.colorDeep = c.colorFieldDeep; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + + this.apron.update(p); + } + + /** + * The chunks. FALL, used upside down. + * + * `FlightMode.FALL` exists to bring hail out of one shared vanishing point in + * the sky; every metre of it is a slider, so putting that point *on the rim + * plane* and the landing point *below the floor* turns the same mode into a + * shared sink. What is actually being borrowed is the part that matters — the + * launch order is a spatial hash of the *landing* point, so the lip calves in + * spreading patches rather than in the order the dice happened to fall, and + * `fillBias` at -1 starts that spread at the rim and works inward. + */ + _syncChunks() { + const c = settings.sinkhole; + const g = settings.global; + const p = this._chunkParams; + + this.chunks.setBasis(this.origin, this.direction, this.side, this.length); + + p.mode = FlightMode.FALL; + p.stagger = Stagger.HASH; + p.count = Math.min(MAX_CHUNKS, Math.round(c.debrisCount * g.particleCount)); + p.radius = c.debrisRadius; + p.sizeJitter = c.debrisSizeJitter; + p.spin = c.debrisSpin; + p.align = 0; + p.stretch = 1; + p.flash = c.debrisFlash; + + // The "sky" point is dropped onto the rim plane and pushed downrange to the + // centre of the circle: `skyBack` is measured backward along the heading, so + // negating the cast length puts it exactly where the hole is. + p.skyBack = c.debrisBack - this.length; + p.skyHeight = c.debrisLaunchHeight; + p.skyScatter = c.debrisSpread * c.zoneRadius; + + p.zoneRadius = c.debrisSinkSpread * c.zoneRadius; + p.zoneBias = 0.5; + p.landHeight = -c.debrisSinkDepth; + p.landInZone = true; + p.pathCurve = c.debrisPathCurve; + p.apex = 0; + p.weaveSide = 0; + p.weaveUp = 0; + + p.flightTime = c.debrisFlightTime; + p.speedJitter = c.debrisSpeedJitter; + p.lead = c.debrisLead; + p.window = c.debrisWindow; + p.fillBias = c.debrisFillBias; + p.fillScatter = c.debrisFillScatter; + p.hashCell = c.debrisHashCell; + p.linger = 0; + p.sink = 0; + + this.chunks.update(this.age, p); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.pebbleEmitter.reset(); + this._blown = false; + + // The one thing a cast captures beyond timestamps: which wedge of the rim + // breaks first and which way the grain runs. + this._seed = Math.random() * 100; + this.chunks.roll(this._seed); + this.chunks.reset(); + this.apron.setVisible(true); + + this._syncUniforms(0, 0, 0); + this._syncApron(0, 1); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** The ring of dust that blows out over the rim as the floor lets go. */ + _blast() { + const c = settings.sinkhole; + const g = settings.global; + const time = frame.uTime.value; + + this._centrePoint(_pos); + _pos.y = 0.35; + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.3, + endRadius: c.burstSize * g.explosionIntensity, + life: 1.2, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.0, + displace: 0.75, + squash: 0.45, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this._centrePoint(_pos); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.8, + width: 0.06, + intensity: 0.8, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + // Emitted in a shell the size of the rim rather than from the middle: the + // dust does not come out of the hole, it is shoved off the lip by the air + // the falling floor displaced, so it starts at the edge and goes outward. + _pos.y = 0.25; + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.95; + _emit.direction = _dir.set(0, saturate(c.blastLift), 0); + _emit.speed = c.blastSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.5; + _emit.sizeVariance = 0.6; + _emit.life = c.dustLifetime * 1.4; + _emit.lifeVariance = 0.45; + _emit.spin = 0.5; + _emit.tint = null; + _emit.time = time; + this.dust.emit(Math.round(c.blastDust * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 12 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.6 * g.explosionIntensity; + } + + /** + * The settle: an updraft of dust out of the mouth, and pebbles letting go of + * the walls just inside the rim and running down out of sight. + * + * @param {number} scale 0..1 + * @param {number} drop 0..1, so nothing is shed off a hole that is not open + */ + _settleFx(dt, scale, drop) { + const c = settings.sinkhole; + const g = settings.global; + const time = frame.uTime.value; + if (drop < 0.05) return; + + this._centrePoint(_centre); + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (dustCount > 0) { + _pos.copy(_centre); + _pos.y = 0.2; + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.1; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + const pebbleCount = Math.round( + this.pebbleEmitter.tick(dt, c.pebbleRate * scale) * g.particleCount + ); + if (pebbleCount > 0) { + // Just inside the lip, on the wall, aimed down the slope. The band is a + // fraction of the radius rather than a metre so it stays on the wall when + // `zoneRadius` moves. + const band = 1 - saturate(c.pebbleBand) * Math.random(); + const angle = Math.random() * TAU; + const r = c.zoneRadius * band; + _pos.set(_centre.x + Math.cos(angle) * r, 0, _centre.z + Math.sin(angle) * r); + // The wall at that radius, off the same profile the shader uses. + _pos.y = -c.depth * Math.pow(Math.max(0, 1 - band), Math.max(0.05, c.wallCurve)) * drop; + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.06; + _emit.direction = _dir + .set(_centre.x - _pos.x, 0, _centre.z - _pos.z) + .normalize() + .multiplyScalar(0.45) + .setY(-1) + .normalize(); + _emit.speed = c.pebbleSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.4; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.pebbleLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.time = time; + this.pebbles.emit(pebbleCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.sinkhole; + + // Nothing has fallen yet. The fractures run out across an intact floor, + // which is exactly the case the library's flat FUNNEL mark is right for. + this._syncUniforms(0, 0, 0); + this._syncApron(this.u * 0.75, 1); + this._syncChunks(); + + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + this._centrePoint(this.position); + } + + onImpact() { + // The drop is a curve through the impact phase, not a one-shot; all this + // does is put the light where the hole is. `_blast` fires from `onFade` on + // the frame the floor actually lets go, `crackTime` seconds from now. + this._centrePoint(this.position); + } + + onFade(dt, t) { + const c = settings.sinkhole; + + /* --- where we are in the four beats --- */ + const crackTime = Math.max(0.02, c.crackTime); + const dropTime = Math.max(0.02, c.dropTime); + const held = this.phase === AbilityPhase.IMPACT ? this.impactTime : this.impactDuration; + + const crack = saturate(held / crackTime); + // inOutCubic, so the floor hesitates, goes, and stops. outQuint made it + // snap open on frame one, which reads as a trapdoor rather than a collapse. + const drop = Easing.inOutCubic(saturate((held - crackTime) / dropTime)); + // `calve` is the *ceiling* on how far the wedges come in, not the clock: + // at 0 the rim stays a clean circle and the hole reads as bored rather than + // collapsed, which is worth being able to dial all the way out. + const calve = saturate(c.calve) * saturate((held - crackTime * 0.55) / (dropTime * 1.5)); + + let fade = 1; + let open = drop; + if (this.phase === AbilityPhase.FADE) { + // The sandbox has to give the floor back. The hole eases shut first and + // only then goes translucent — fading a full-depth pit out in place looks + // like the renderer lost it. + const shut = saturate(this.fadeTime / Math.max(0.05, c.sinkTime)); + open = drop * (1 - Easing.inCubic(shut)); + fade = 1 - Easing.inQuad(saturate((shut - 0.55) / 0.45)); + } + + this._syncUniforms(open, calve, fade); + this._syncApron(1, fade); + this._syncChunks(); + + if (!this._blown && drop > 0.06) { + this._blown = true; + this._blast(); + } + + // Cracking gets a rising rumble; the settle gets a low one that dies out. + const shake = this.phase === AbilityPhase.IMPACT ? c.settleShake : c.settleShake * (2 - t); + this.ctx.shake.rumble( + shake * (0.4 + 0.6 * crack) * open * settings.global.cameraShake, + dt + ); + + this._settleFx(dt, fade * (t <= 1 ? 1 : 0.35), open); + this._centrePoint(this.position); + } + + onDestroy() { + this.chunks.reset(); + this.apron.setVisible(false); + this.funnel.visible = false; + this.funnelUniforms.uFade.value = 0; + this._blown = false; + } + + dispose() { + this.funnelGeometry.dispose(); + this.funnelMaterial.dispose(); + this.apron.dispose(); + this.chunks.dispose(); + this.chunkMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/stone/StonespineAbility.js b/src/abilities/stone/StonespineAbility.js new file mode 100644 index 0000000..ee42cbf --- /dev/null +++ b/src/abilities/stone/StonespineAbility.js @@ -0,0 +1,1200 @@ +import { + BufferGeometry, + Float32BufferAttribute, + MeshStandardMaterial, + Color, + Object3D, + Quaternion, + Vector3 +} from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GrowthField, GrowthLayout, growthParams, patchGrowthMaterial } from '../../vfx/GrowthField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { noiseGLSL } from '../../shaders/lib/noise.glsl.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, hash11, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on plates per cast. `slabCount` clamps here. */ +const MAX_SLABS = 96; +/** + * Distinct slab silhouettes. Three, for the reason `GrowthField` documents: + * per-instance scaling buys proportion variety, only distinct geometry buys + * *outline* variety, and a field of one plate scaled forty ways reads as a + * repeated prop the moment the camera moves. Three draw calls is the price. + */ +const SLAB_VARIANTS = 3; + +/** How many points one frame's sand is split between, so it is not a hose. */ +const SAND_BATCHES = 4; + +/* Module-scope scratch. Nothing in a frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hinge = new Vector3(); +const _tip = new Vector3(); +const _axis = new Vector3(); +const _up = new Vector3(0, 1, 0); +const _yaw = new Quaternion(); +const _swing = new Quaternion(); +/** Composes one plate's matrix. One object, reused for every instance. */ +const _slabDummy = new Object3D(); +/** + * The transform a buried plate wears: far below the floor and all but scaled + * out. Composed once, at module load, because it never changes — writing a + * degenerate matrix at the origin instead leaves a speck sitting on the floor + * for the whole of the stagger window. + */ +const _parked = new Object3D(); +_parked.position.set(0, -999, 0); +_parked.scale.setScalar(0.0001); +_parked.updateMatrix(); + +/* ---------------------------------------------------------------------- */ +/* The slab */ +/* ---------------------------------------------------------------------- */ + +/** + * One chunky irregular quad prism, hinged on its own origin. + * + * **This geometry deliberately does not use `GrowthField`'s unit space**, and + * the departure is the trick rather than a shortcut. The library's contract is + * "footprint inside radius 0.5 on `y = 0`, tip at `y = 1`", which is the right + * space for something that *grows upward*. A plate does not grow upward: it + * pivots. So the space here is + * + * | axis | 0 | 1 | scaled by | + * | --- | --- | --- | --- | + * | `x` | the hinge edge | the far edge | `reach`, metres | + * | `y` | the torn underside | the dressed top face | `thickness`, metres | + * | `z` | one end of the hinge | the other (`±0.5`) | `width`, metres | + * + * The hinge line is therefore exactly the segment `x = 0, y = 0`, which passes + * through the instance origin — and *that* is what lets the swing be an + * ordinary rotation in the instance's own quaternion instead of a + * pivot-about-an-offset-point that would have to be unpicked in a shader. A + * quaternion applied to a non-uniformly scaled body shears unless the rotation + * comes after the scale, and `Object3D.compose` builds `T · R · S`, so it does. + * Putting the pivot at the origin is the one decision that makes the whole + * ability fall out of the existing matrix path. + * + * The second dividend is free and better than it deserves to be: `local.y` + * now runs *through the thickness*, from the face that was underground to the + * face that was the floor. The soil line in the fragment shader is one + * `smoothstep` on that varying, and it needs no normal, no world-up and no + * knowledge of how far the plate has swung. + * + * The hinge edge is a single straight segment because it is a *fracture*, not + * a weathered edge — the first version sampled it with the same raggedness as + * the other three and the plates rocked visibly on their corners as they went + * over, which reads as a bug rather than as detail. + * + * @param {number} variant which of the three silhouettes; only perturbs the seed + * @param {object} shape live shape sliders — see `stonespine.edgeSamples` &c. + */ +function createSlabGeometry(variant, shape) { + const s = shape ?? {}; + const samples = Math.max(6, Math.min(22, Math.round(s.edgeSamples ?? 12))); + const ragged = Math.max(0, s.ragged ?? 0.3); + const chamfer = clamp(s.chamfer ?? 0.18, 0, 0.6); + const inset = clamp(s.topInset ?? 0.16, 0, 0.5); + const tear = clamp(s.tear ?? 0.36, 0, 0.9); + const seed = 4.1 + variant * 19.7; + + /* --- the outline, walked from one hinge corner round to the other --- */ + const px = new Float64Array(samples); + const pz = new Float64Array(samples); + for (let i = 0; i < samples; i++) { + const t = i / (samples - 1); + let x; + let z; + if (t < 0.36) { + x = t / 0.36; + z = -0.5; + } else if (t < 0.64) { + x = 1; + z = -0.5 + (t - 0.36) / 0.28; + } else { + x = 1 - (t - 0.64) / 0.36; + z = 0.5; + } + + // Independent jitter on both axes rather than a radial push: a radial push + // off the centroid rounds the plate into a lozenge and loses the corners, + // and the corners are most of what says "this was broken, not cast". + x += (hash11(seed * 3.7 + i * 5.31) - 0.5) * ragged * 0.34; + z += (hash11(seed * 9.13 + i * 2.87) - 0.5) * ragged * 0.3; + + // The two hinge corners are pinned: the fracture is a straight line. + if (i === 0 || i === samples - 1) x = 0; + px[i] = Math.max(0, x); + pz[i] = z; + } + + let cx = 0; + let cz = 0; + for (let i = 0; i < samples; i++) { + cx += px[i]; + cz += pz[i]; + } + cx /= samples; + cz /= samples; + + /* --- three rings: torn bottom, shoulder, inset top --- */ + const shoulderY = 1 - chamfer; + const shrink = Math.min(0.45, inset * chamfer * 1.8); + + const bx = new Float64Array(samples); + const bz = new Float64Array(samples); + const by = new Float64Array(samples); + const tx = new Float64Array(samples); + const tz = new Float64Array(samples); + for (let i = 0; i < samples; i++) { + bx[i] = px[i]; + bz[i] = pz[i]; + // The underside was torn out of the ground, so it is ragged — except along + // the hinge, which has to stay flat on the floor or the plate rocks. + const hold = Math.min(1, px[i] / 0.3); + by[i] = tear * hash11(seed * 13.9 + i * 7.13) * hold; + tx[i] = lerp(px[i], cx, shrink); + tz[i] = lerp(pz[i], cz, shrink); + } + + const positions = []; + const push = (x, y, z) => positions.push(x, y, z); + /** + * Winding note, because getting it wrong costs an hour of black facets: + * `computeVertexNormals` derives the normal from `(b−a)×(c−a)`, and the + * outline above has a *positive* shoelace area in (x, z), which means a fan + * taken in outline order faces **−Y**. The top face is therefore wound + * backwards and the walls are wound `bottom → top → top → bottom`. + */ + for (let i = 0; i < samples; i++) { + const j = (i + 1) % samples; + // the flank: torn underside up to the shoulder + push(bx[i], by[i], bz[i]); + push(bx[i], shoulderY, bz[i]); + push(bx[j], shoulderY, bz[j]); + push(bx[i], by[i], bz[i]); + push(bx[j], shoulderY, bz[j]); + push(bx[j], by[j], bz[j]); + // the bevel: shoulder out to the inset top rim + push(bx[i], shoulderY, bz[i]); + push(tx[i], 1, tz[i]); + push(tx[j], 1, tz[j]); + push(bx[i], shoulderY, bz[i]); + push(tx[j], 1, tz[j]); + push(bx[j], shoulderY, bz[j]); + } + + const bottomY = tear * 0.5; + for (let i = 0; i < samples; i++) { + const j = (i + 1) % samples; + // top face — reversed, see the winding note + push(cx, 1, cz); + push(tx[j], 1, tz[j]); + push(tx[i], 1, tz[i]); + // underside + push(cx, bottomY, cz); + push(bx[i], by[i], bz[i]); + push(bx[j], by[j], bz[j]); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + // Non-indexed with per-face normals: this is what keeps the facets crisp and + // what makes a tilted plate catch the key light on one flank and not the other. + geometry.computeVertexNormals(); + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* The material */ +/* ---------------------------------------------------------------------- */ + +/** + * Dusty quarried stone with a dirty underside. + * + * A patched `MeshStandardMaterial` rather than a `ShaderMaterial`, for the + * reason `IceMaterial` is: the plates have to take the stage's real shadows and + * its HDR probe, because a slab of floor that does not sit in the same light as + * the floor it came out of reads as a prop dropped into the scene. + * + * Three terms carry it: + * + * - **the soil line** — `vGrowLocal.y` runs from the torn underside to the + * dressed top face, so "was this underground thirty frames ago" is one + * `smoothstep`. Everything below the line gets earth, deep crevice shadow + * and a rougher surface; everything above it stays the pale dressed floor. + * - **the quarry mottle** — sampled in **world** space, deliberately. The + * first version sampled it in local space and every plate wore the identical + * birthmark scaled to its own size, which reads as forty copies of one prop + * the instant two of them sit side by side. + * - **the dust film** — keyed off the *world* up-component of the resolved + * normal, which needs the view→world rotation because `normal` in a standard + * fragment shader is in view space. A plate that has gone past vertical + * loses its film, which is a surprisingly strong cue for how far over it is. + */ +function createSlabMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.88, + metalness: 0.0, + flatShading: true, + // Opaque: stone occludes, writes depth, and lets the dust fade softly + // against it. Transparency here would only cost sorting. + transparent: false, + depthWrite: true + }); + + const uniforms = { + uColorFace: { value: new Color() }, + uColorFlank: { value: new Color() }, + uColorSoil: { value: new Color() }, + uColorSoilDeep: { value: new Color() }, + uColorDust: { value: new Color() }, + uSoilLine: { value: 0.74 }, + uSoilBlur: { value: 0.2 }, + uSoilSmear: { value: 0.3 }, + uGrain: { value: 0.55 }, + uGrainScale: { value: 3.2 }, + uSpeckle: { value: 0.5 }, + uSpeckleScale: { value: 24 }, + uDamp: { value: 0.45 }, + uDustFilm: { value: 0.4 }, + uRim: { value: 0.5 }, + uRimPower: { value: 2.6 }, + uRoughFace: { value: 0.82 }, + uRoughSoil: { value: 0.98 }, + uBirthDust: { value: 0.9 }, + uGlow: { value: 1.0 } + }; + + patchGrowthMaterial(material, { + environment, + uniforms, + common: /* glsl */ ` + uniform vec3 uColorFace; + uniform vec3 uColorFlank; + uniform vec3 uColorSoil; + uniform vec3 uColorSoilDeep; + uniform vec3 uColorDust; + uniform float uSoilLine; + uniform float uSoilBlur; + uniform float uSoilSmear; + uniform float uGrain; + uniform float uGrainScale; + uniform float uSpeckle; + uniform float uSpeckleScale; + uniform float uDamp; + uniform float uDustFilm; + uniform float uRim; + uniform float uRimPower; + uniform float uRoughFace; + uniform float uRoughSoil; + uniform float uBirthDust; + uniform float uGlow; + ${noiseGLSL} + `, + fragment: /* glsl */ ` + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + + // normal is in VIEW space here. The view matrix is orthonormal in its + // rotation block, so its transpose is its inverse and each world + // component is a dot with one column. + vec3 wN = normalize(vec3( + dot(viewMatrix[0].xyz, N), + dot(viewMatrix[1].xyz, N), + dot(viewMatrix[2].xyz, N) + )); + + // The soil line. Local y is 0 on the torn underside and 1 on the face + // that was the floor, so this one term is the whole conceit. + float blur = max(uSoilBlur, 0.002); + float dressed = smoothstep(uSoilLine - blur, uSoilLine + blur, vGrowLocal.y); + + // Earth slopped over the hinge and onto the top face as the plate swung. + float smear = (1.0 - smoothstep(0.0, max(uSoilSmear, 0.001), vGrowLocal.x)) * + smoothstep(0.3, 0.8, snoise01(vGrowLocal * 7.0 + vGrowSeed * 4.0)); + dressed *= 1.0 - clamp(smear, 0.0, 1.0); + + float mottle = fbm3(vGrowWorld * uGrainScale + vGrowSeed * 13.0) * 0.5 + 0.5; + float crevice = smoothstep(0.6, 0.0, vGrowLocal.y) * (0.35 + 0.65 * (1.0 - mottle)); + + vec3 body = mix(uColorSoil, uColorFace, dressed); + body = mix(body, uColorFlank, (1.0 - dressed) * smoothstep(0.2, 0.85, vGrowLocal.y)); + body = mix(body, uColorSoilDeep, clamp(crevice * uDamp, 0.0, 1.0)); + body *= mix(1.0, 0.62 + 0.76 * mottle, clamp(uGrain, 0.0, 2.0)); + + float sky = clamp(wN.y, 0.0, 1.0); + body = mix(body, uColorDust, sky * sky * clamp(uDustFilm, 0.0, 1.0) * 0.6); + + diffuseColor.rgb *= body; + roughnessFactor = mix(uRoughSoil, uRoughFace, dressed); + + float fleck = snoise01(vGrowWorld * uSpeckleScale + vGrowSeed * 29.0); + fleck = pow(clamp(fleck, 0.0, 1.0), 16.0); + + vec3 glow = uColorDust * fleck * uSpeckle * sky; + glow += uColorDust * pow(1.0 - ndv, uRimPower) * uRim * dressed; + glow += uColorDust * vGrowBirth * uBirthDust; + glow *= uGlow; + // Reinhard ceiling, as the ice does: the three terms above all peak on the + // silhouette and without this a corner facet sums past white and the bloom + // pass smears the whole plate. + glow /= 1.0 + glow * 0.3; + totalEmissiveRadiance += glow; + ` + }); + + // I8 — the pause test reads uniforms from here, because a patched standard + // material has no `material.uniforms` until a GL context compiles it. + material.userData.uniforms = uniforms; + + /** Pull the palette and every shading control from the live settings. */ + material.userData.sync = () => { + const c = settings.stonespine; + const g = settings.global; + + uniforms.uColorFace.value.copy(getColor(c.colorFace)); + uniforms.uColorFlank.value.copy(getColor(c.colorFlank)); + uniforms.uColorSoil.value.copy(getColor(c.colorSoil)); + uniforms.uColorSoilDeep.value.copy(getColor(c.colorSoilDeep)); + uniforms.uColorDust.value.copy(getColor(c.colorDust)); + + uniforms.uSoilLine.value = c.soilLine; + uniforms.uSoilBlur.value = c.soilBlur; + uniforms.uSoilSmear.value = c.soilSmear; + uniforms.uGrain.value = c.grain * g.shaderIntensity; + uniforms.uGrainScale.value = c.grainScale * g.noiseFrequency; + uniforms.uSpeckle.value = c.speckle * g.shaderIntensity; + uniforms.uSpeckleScale.value = c.speckleScale * g.noiseFrequency; + uniforms.uDamp.value = c.damp; + uniforms.uDustFilm.value = c.dustFilm; + uniforms.uRim.value = c.rim * g.fresnel; + uniforms.uRimPower.value = c.rimPower; + uniforms.uRoughFace.value = c.roughFace; + uniforms.uRoughSoil.value = c.roughSoil; + uniforms.uBirthDust.value = c.birthDust; + uniforms.uGlow.value = c.glow * g.glow; + + material.opacity = c.opacity * g.opacity; + material.envMapIntensity = c.envIntensity; + }; + + material.userData.sync(); + return material; +} + +/* ---------------------------------------------------------------------- */ +/* SlabField — GrowthField with a hinge instead of a push */ +/* ---------------------------------------------------------------------- */ + +/** + * `GrowthField` with its emergence re-read as a **swing**. + * + * The library ships two emerge modes, `PUSH` (slide up out of the floor) and + * `SCALE` (accrete in place), and neither of them is a hinge. Rather than add a + * third mode to a file eleven other abilities are being written against, this + * subclass keeps every line of the library that is about *bookkeeping* — + * planting, the front trigger, the stagger, the springy overshoot, the record + * pool, the shape-rebuild hash, the per-instance birth attribute — and replaces + * exactly one method: `update()`, the one that decides what an instance's + * matrix is. + * + * It is written entirely against the base class's **public** read-back API + * (`positionOf` / `heightOf` / `radiusOf` / `emergenceOf`), so every dimension + * it composes is still resolved from the live params on the frame it is drawn, + * including a zero-length one. Nothing here caches a metre. + * + * The mapping from library vocabulary to plate vocabulary: + * + * | library | here | + * | --- | --- | + * | `heightOf` | `reach` — hinge to far edge, and so how tall the plate stands | + * | `radiusOf` | half the plate's width across the hinge | + * | `emergenceOf` | the swing, 0 → 1 with an overshoot that rocks it | + * | `positionOf` | the plate's *site*; the hinge sits `hingeShift` reaches behind it | + */ +class SlabField extends GrowthField { + constructor(parent, options) { + super(parent, options); + /** Swing of the last plate resolved, radians. Read by `sampleTop`. */ + this._theta = 0; + /** Reach, thickness and half-width of the last plate resolved, metres. */ + this._reach = 1; + this._thick = 0.2; + this._half = 0.5; + } + + /** + * Resolve one plate's world frame into the module scratch. + * + * Writes `_hinge` (the world point the plate pivots about), `_tip` (the flat + * unit bearing the far edge points along before the swing) and `_axis` (the + * world hinge line). Returns the swing in radians. + * + * The bearing is the ability's one interesting dice roll: `record.yaw` is a + * unitless 0..1 that never changes for the life of a cast, and `hingeAlign` + * — a live slider — blends it toward the cast heading. At 0 every plate picks + * its own way to fall over and the field reads as broken river ice; at 1 they + * all comb downrange and it reads as a flight of stairs. Blending the two + * *vectors* rather than the two angles matters: angles wrap at ±π and a plate + * whose random bearing landed just the wrong side of the seam would spin the + * long way round as the slider moved. + */ + _resolveFrame(record, index, now, p) { + const jitter = p.randomness ?? 1; + const direction = p.direction ?? _up; + + const bearing = record.yaw * TAU; + _tip.set(Math.cos(bearing), 0, Math.sin(bearing)); + const align = saturate(p.hingeAlign ?? 0); + if (align > 0) { + _tip.x = lerp(_tip.x, direction.x, align); + _tip.z = lerp(_tip.z, direction.z, align); + _tip.y = 0; + } + if (_tip.lengthSq() < 1e-8) _tip.copy(direction); + _tip.normalize(); + + // −(up × tip): the sign that makes a positive swing lift the far edge + // rather than drive it into the floor. Getting it the other way round is + // invisible on a still frame and unmistakable in motion. + _axis.set(-_tip.z, 0, _tip.x); + + this._reach = this.heightOf(index, p); + this._half = this.radiusOf(index, p); + this._thick = Math.max( + 0.01, + (p.slabThickness ?? 0.2) * (1 + record.radiusRoll * (p.slabThicknessJitter ?? 0) * jitter) + ); + + const emerge = this.emergenceOf(index, now, p); + const settled = Math.min(1, Math.max(0, emerge)); + + // The swing rides the raw emergence, overshoot included, so the plate rocks + // past its final angle and back as it slams home. Driving it off the + // clamped value instead gave a plate that arrived and simply stopped, which + // is the one thing a two-tonne slab does not do. + let theta = + (p.hingeAngle ?? 0.9) * + Math.max(0, emerge) * + (1 + record.leanRoll * (p.hingeJitter ?? 0) * jitter); + + const retract = saturate(p.slabRetract ?? 0); + if (retract > 0) theta *= 1 - saturate(retract * 1.6); + + this.positionOf(index, p, _hinge); + _hinge.addScaledVector(_tip, -this._reach * (p.hingeShift ?? 0)); + _hinge.y += + (p.heave ?? 0) * Math.max(0, emerge) - + (1 - settled) * (this._thick + (p.burial ?? 0.4)); + if (retract > 0) { + _hinge.y -= Easing.inCubic(retract) * (this._thick + (p.sinkDepth ?? 0.4) + (p.heave ?? 0)); + } + + this._theta = Math.max(0, theta); + return this._theta; + } + + /** + * A world point on a plate's top face, and the direction rubble slides in. + * + * `u` runs 0 at the hinge to 1 at the far edge. The slide direction is the + * downhill tangent of the tilted face, which points *back toward the hinge* — + * the far edge is the high one — so grit shed off a plate falls into the seam + * it came out of rather than off the end, which is what it does in life. + * + * @returns {number} the plate's swing in radians, or -1 if it is still buried + */ + sampleTop(index, u, now, p, outPoint, outSlide) { + if (index < 0 || index >= this.count) return -1; + if (this.emergenceOf(index, now, p) < 0) return -1; + + const theta = this._resolveFrame(this.records[index], index, now, p); + const cos = Math.cos(theta); + const sin = Math.sin(theta); + + // The unswung offset from the hinge is `tip · reach·u + up · thickness`; + // the swing sends `tip → tip cosθ + up sinθ` and `up → up cosθ − tip sinθ`. + const along = this._reach * saturate(u); + outPoint.copy(_hinge); + outPoint.addScaledVector(_tip, along * cos - this._thick * sin); + outPoint.y += along * sin + this._thick * cos; + + outSlide.copy(_tip).multiplyScalar(-cos); + outSlide.y = -sin; + if (outSlide.lengthSq() < 1e-8) outSlide.set(0, -1, 0); + outSlide.normalize(); + return theta; + } + + /** + * Rebuild every plate's matrix from the live params. Allocation-free. + * + * The composition is `T · R_axis(θ) · R_up(φ) · S`. Reading it right to left: + * scale the unit prism to metres, yaw it so its local `+X` lies along the + * plate's bearing (and so its local `+Z`, the hinge edge, lies along the hinge + * line), swing it about that world hinge line, and put the hinge where it + * belongs. Because `Object3D.compose` multiplies in exactly that order, the + * non-uniform scale never sees the rotation and a metre-wide plate stays a + * rectangle however far over it goes. + */ + update(now, p, retract = 0) { + const variants = this.variants; + const used = this._used; + used.fill(0); + + const birthFade = Math.max(0.02, p.birthFade ?? 0.18); + p.slabRetract = retract; + + for (let i = 0; i < this.count; i++) { + const record = this.records[i]; + const variant = i % variants; + const slot = (i / variants) | 0; + const emerge = this.emergenceOf(i, now, p); + + if (emerge < 0) { + // Still buried. Parked far below rather than drawn degenerate at the + // origin, which shows up as a speck on the floor. + this.meshes[variant].setMatrixAt(slot, _parked.matrix); + this.birthAttributes[variant].array[slot] = 0; + used[variant] = Math.max(used[variant], slot + 1); + continue; + } + + const theta = this._resolveFrame(record, i, now, p); + const settled = Math.min(1, emerge); + const scale = lerp(p.birthScale ?? 0.86, 1, settled); + + _yaw.setFromAxisAngle(_up, Math.atan2(-_tip.z, _tip.x)); + _swing.setFromAxisAngle(_axis, theta); + _swing.multiply(_yaw); + + _slabDummy.position.copy(_hinge); + _slabDummy.quaternion.copy(_swing); + _slabDummy.scale.set(this._reach, this._thick, this._half * 2).multiplyScalar(scale); + _slabDummy.updateMatrix(); + this.meshes[variant].setMatrixAt(slot, _slabDummy.matrix); + this.birthAttributes[variant].array[slot] = saturate(1 - (now - record.eruptTime) / birthFade); + used[variant] = Math.max(used[variant], slot + 1); + + /* --- the moment the plate cracks the floor --- */ + if (!record.breached && emerge > (p.breachAt ?? 0.25)) { + record.breached = true; + if (this.onBreach) this.onBreach(i, _hinge, this._half, this._reach); + } + } + + for (let v = 0; v < variants; v++) { + this.meshes[v].count = used[v]; + this.meshes[v].instanceMatrix.needsUpdate = true; + this.birthAttributes[v].needsUpdate = true; + } + } +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * STONESPINE — plates, not spikes. + * + * A heave front runs the aimed line and slabs of floor tear loose behind it. + * Each one is **hinged along a single edge**: it does not rise, it swings, so + * the face that comes up is the one that was underground — filthy, torn, and + * with a hard dirt line where it used to be flush with your feet. Grit that was + * sitting on top slides off toward the hinge as the plate goes over, and a puff + * of dust breaks out of the seam it left. The field holds, then the plates drop + * back flat and sink. + * + * **THE TRICK is the hinge, and it is a matrix, not a shader.** The slab + * geometry puts its pivot edge on the instance origin (see + * `createSlabGeometry`), which turns "rotate this plate about a world-space + * line" into an ordinary quaternion in the instance transform — and because + * `Object3D.compose` builds `T · R · S`, the rotation is applied *after* the + * non-uniform scale, so a two-metre plate a hand's width thick stays a + * rectangle at any angle. The first attempt did the swing in the vertex shader + * on top of `GrowthField`'s PUSH mode, and it sheared: a rotation applied + * before a non-uniform scale turns a rectangle into a parallelogram, and the + * plates looked like they were made of rubber. + * + * The hinge *bearing* is a per-instance dice roll (`record.yaw`, unitless, 0..1, + * rolled once per cast) and the hinge *angle* is a live curve along the cast + * (`hingeAngle` × the emergence, overshoot included). `hingeAlign` blends the + * bearing toward the cast heading, which is the single control that takes the + * field from broken river ice to a flight of stairs. + * + * **The rule that makes the editor work.** A cast captures a count, one bearing + * roll and a handful of unitless jitters per plate, and one timestamp each. + * Not a metre, not a radian, not a second. `hingeAngle`, `reach`, `thickness`, + * `heave` and every colour are resolved against `settings.stonespine` inside + * `SlabField.update()`, which runs on a zero-length frame too — so dragging the + * swing on a field that has already finished standing swings all forty-four + * plates with the clock stopped. + */ +export class StonespineAbility extends Ability { + constructor(context) { + super('stonespine', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.material = createSlabMaterial(this.ctx.environment); + + /** Live shape params for the geometry factory. Mutated, never replaced. */ + this._shape = { + edgeSamples: 12, + ragged: 0.3, + chamfer: 0.18, + topInset: 0.16, + tear: 0.36 + }; + this._readShape(); + + /** Live growth params. One object, refilled every frame — invariant I3. */ + this._growth = growthParams(); + this._growth.layout = GrowthLayout.LINE; + + this.field = new SlabField(this.group, { + geometry: createSlabGeometry, + material: this.material, + shape: this._shape, + variants: SLAB_VARIANTS, + capacity: MAX_SLABS, + renderOrder: 2 + }); + + // Assigned once, at construction: a closure built inside the update loop is + // an allocation per plate per frame, which is exactly what I3 forbids. + this.field.onBreach = (index, position, half, reach) => + this._breachFx(index, position, half, reach); + + /** Where the last sand batch was sampled. Scratch, not state. */ + this._sandPoint = new Vector3(); + this._sandSlide = new Vector3(); + } + + createParticles() { + const particles = this.ctx.particles; + + // The rolling ground dust. Non-additive so it genuinely occludes — half of + // what sells a stone effect is that you cannot see through the cloud. + this.dust = particles.get('stonespine.dust', { + capacity: 2600, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.dust.uniforms.uDrag.value = 2.1; + this.dust.uniforms.uEndSize.value = 3.6; + this.dust.uniforms.uSizeIn.value = 0.12; + this.dust.uniforms.uFadeIn.value = 0.15; + this.dust.uniforms.uFadeOut.value = 0.32; + + // Chips thrown out of the seam. Lit, because a rock fragment that does not + // take the key light reads as a floating decal. + this.grit = particles.get('stonespine.grit', { + capacity: 2000, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.24; + this.grit.uniforms.uEndSize.value = 0.85; + this.grit.uniforms.uFadeOut.value = 0.7; + + // The fine stuff that slides off the tilted top faces. Soft and small: this + // is the particle system that does the most for the trick, because rubble + // running downhill off a plate is only possible if the plate has a slope. + this.sand = particles.get('stonespine.sand', { + capacity: 2200, + shape: ParticleShape.SOFT, + additive: false, + curl: true, + softFade: 0.5 + }); + this.sand.uniforms.uDrag.value = 1.2; + this.sand.uniforms.uEndSize.value = 0.5; + this.sand.uniforms.uSizeIn.value = 0.06; + this.sand.uniforms.uFadeIn.value = 0.05; + this.sand.uniforms.uFadeOut.value = 0.45; + + this.dustEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + this.sandEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.field.count; + } + + get impactDuration() { + return Math.max(0.2, settings.stonespine.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + const c = settings.stonespine; + return Math.max(0.2, c.sinkDelay + c.sinkTime); + } + + /** Stone does not gutter. A slow settle on the dust light is all it gets. */ + lightShimmer() { + return 0.92 + 0.08 * Math.sin(this.age * 4.1); + } + + /* ------------------------------------------------------------------ */ + /* Live resolution — every metre comes from here */ + /* ------------------------------------------------------------------ */ + + /** Shape sliders → the geometry factory's params. Rebuilds only on a change. */ + _readShape() { + const c = settings.stonespine; + const s = this._shape; + s.edgeSamples = c.edgeSamples; + s.ragged = c.ragged; + s.chamfer = c.chamfer; + s.topInset = c.topInset; + s.tear = c.tear; + return s; + } + + /** + * Refill the growth params from the live block. + * + * Called before every `update()`, including on a paused frame. Note the two + * renamings: the library's `height` is this ability's `reach` (the plate + * pivots, so its "height" is the dimension that swings up) and its `radius` is + * half a plate's width across the hinge. + */ + _readGrowth() { + const c = settings.stonespine; + const g = settings.global; + const p = this._growth; + + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + + p.widthNear = c.widthNear; + p.width = c.width; + p.widthCurve = c.widthCurve; + p.frontBias = c.frontBias; + p.clumping = c.clumping; + p.scatter = c.scatter; + p.clusterRadius = c.clusterRadius; + + p.heightNear = c.reachNear; + p.height = c.reach; + p.heightCurve = c.reachCurve; + p.heightJitter = c.reachJitter; + p.minHeight = c.minReach; + p.crown = c.crown; + p.crownPower = c.crownPower; + p.peak = c.peak; + p.peakWidth = c.peakWidth; + p.rubble = c.rubble; + p.rubbleScale = c.rubbleScale; + p.rubbleSpread = c.rubbleSpread; + + p.radiusNear = c.plateWidth; + p.radius2 = c.plateWidthFar; + p.radiusCurve = c.plateWidthCurve; + p.radiusJitter = c.plateWidthJitter; + p.minRadius = c.minWidth; + + p.riseTime = c.riseTime; + p.riseOvershoot = c.riseOvershoot; + p.settle = c.settle; + p.springRate = c.springRate; + p.birthScale = c.birthScale; + p.birthFade = c.birthFade; + p.breachAt = c.breachAt; + p.sinkDepth = c.sinkDepth; + p.randomness = g.randomness; + + /* --- the hinge: keys the library has never heard of --- */ + p.slabThickness = c.thickness; + p.slabThicknessJitter = c.thicknessJitter; + p.hingeAngle = c.hingeAngle; + p.hingeJitter = c.hingeJitter; + p.hingeAlign = c.hingeAlign; + p.hingeShift = c.hingeShift; + p.heave = c.heave; + p.burial = c.burial; + + return p; + } + + /** Push the live palette into the slab material and the three gradients. */ + _syncUniforms() { + const c = settings.stonespine; + const g = settings.global; + + this.material.userData.sync(); + this.field.syncGeometry(this._readShape()); + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + + this.sand.setGradient( + getColor(c.colorSandA), + getColor(c.colorSandB), + getColor(c.colorSandC), + getColor(c.colorSandD) + ); + this.sand.uniforms.uGravity.value.set(0, c.sandFall, 0); + this.sand.uniforms.uSizeScale.value = c.sandSize * g.particleSize * 7; + this.sand.uniforms.uLifeScale.value = c.sandLifetime * 0.5 * g.particleLifetime; + this.sand.uniforms.uSpeedScale.value = c.sandSpeed * g.particleSpeed; + this.sand.uniforms.uOpacity.value = g.opacity; + this.sand.uniforms.uTurbulence.value = c.sandTurbulence * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.stonespine; + + this.dustEmitter.reset(); + this.gritEmitter.reset(); + this.sandEmitter.reset(); + + // The only dice roll in the ability, and the only thing a cast captures + // beyond timestamps: which plate goes where and which way each one falls. + this.field.plant(Math.round(c.slabCount), c.clusterShare); + + this._syncUniforms(); + this.field.update(this.age, this._readGrowth(), 0); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * The seam opening: a puff of dust out of the crack, chips off the lip, a + * fracture mark and a ground-hugging ring. + * + * Fired by `SlabField` the frame a plate passes `breachAt`, so it lands on the + * hinge line rather than under the plate's centre — the dust comes out of the + * gap the plate left, which is the only place it can come from. + */ + _breachFx(index, position, half, reach) { + const c = settings.stonespine; + const g = settings.global; + const time = frame.uTime.value; + + _pos.copy(position); + _pos.y = 0.06; + + _emit.position = _pos; + _emit.radius = half * 0.9; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.25).setY(1).normalize(); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.11; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 8; + _emit.tint = null; + _emit.time = time; + this.grit.emit(Math.round(c.breachGrit * g.particleCount), _emit); + + _emit.radius = half * 1.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.size = 0.75; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime * 0.8; + _emit.spin = 0.4; + this.dust.emit(Math.round(c.breachDust * g.particleCount), _emit); + + // Only a share of the plates mark the floor. Forty fracture decals stacked + // on one another turns the whole band into a flat black smear and buries the + // silhouette, which is the thing worth looking at. + if (hash11(index * 7.31 + 1.7) < 0.55) { + this.ctx.decals.spawn(DecalType.CRACK, _pos, { + radius: c.crackRadius * (0.7 + reach * 0.12), + life: c.crackLife, + width: c.crackWidth, + intensity: c.crackIntensity, + colorA: getColor(c.colorCrack), + colorB: getColor(c.colorCrackEdge), + height: 0.014 + }); + } + if (hash11(index * 3.97 + 11.3) < 0.4) { + this.ctx.decals.spawn(DecalType.DUSTRING, _pos, { + radius: c.ringRadius, + life: c.ringLife, + intensity: 0.8, + colorA: getColor(c.colorRingA), + colorB: getColor(c.colorRingB), + height: 0.018 + }); + } + } + + /** + * Continuous shedding: dust and chips along the travelling front, and sand + * running downhill off the plates that are already over. + * + * @param {number} scale 0..1 — thinned out once the field is only standing + */ + _fieldFx(dt, scale) { + const c = settings.stonespine; + const g = settings.global; + const time = frame.uTime.value; + const reach = this.phase === AbilityPhase.TRAVEL ? Math.max(0.02, this.u) : 1; + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (dustCount > 0) { + this.pointAt(Math.random() * reach, _pos).setY(0.12); + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, reach) * 0.9; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + const gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount); + if (gritCount > 0) { + this.pointAt(Math.random() * reach, _pos).setY(0.08); + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, reach) * 0.7; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.3).setY(1).normalize(); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.85; + _emit.size = 0.1; + _emit.life = c.gritLifetime; + _emit.spin = 9; + _emit.time = time; + this.grit.emit(gritCount, _emit); + } + + /* --- rubble sliding off the top faces --- */ + let sandCount = Math.round(this.sandEmitter.tick(dt, c.sandRate * scale) * g.particleCount); + const live = this.field.count; + if (sandCount > 0 && live > 0) { + const p = this._growth; + const batches = Math.min(sandCount, SAND_BATCHES); + const per = Math.ceil(sandCount / batches); + _emit.speedVariance = 0.6; + _emit.spread = 0.45; + _emit.size = 0.09; + _emit.sizeVariance = 0.6; + _emit.life = c.sandLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + while (sandCount > 0) { + const index = Math.floor(Math.random() * live); + const theta = this.field.sampleTop( + index, + randRange(0.25, 1), + this.age, + p, + this._sandPoint, + this._sandSlide + ); + // A plate that has barely moved has nothing to shed: the slope *is* the + // permission. Without this gate the sand poured off flat floor plates + // and the effect read as a leak rather than as a slide. + if (theta > 0.12) { + _emit.position = this._sandPoint; + _emit.radius = 0.12; + _emit.direction = this._sandSlide; + _emit.speed = c.sandSpeed * (0.4 + Math.sin(Math.min(theta, Math.PI * 0.5))); + this.sand.emit(Math.min(per, sandCount), _emit); + } + sandCount -= per; + } + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.stonespine; + this._syncUniforms(); + + const p = this._readGrowth(); + this.field.triggerUpTo(this.age, this.u, c.riseStagger, c.frontBias, false); + this.field.update(this.age, p, 0); + + this._fieldFx(dt, 1); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.stonespine; + const g = settings.global; + const time = frame.uTime.value; + + // Everything still buried goes now, the terminal ring included. + this.field.triggerUpTo(this.age, 1, c.riseStagger, c.frontBias, true); + + this.pointAt(1, _pos).setY(0.5); + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.3, + endRadius: c.burstSize * g.explosionIntensity, + life: 1.0, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.1, + displace: 0.7, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.pointAt(1, _pos); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.7, + width: 0.07, + intensity: 0.85, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + this.ctx.decals.spawn(DecalType.CRACK, _pos, { + radius: c.crackRadius * 2.6, + life: c.crackLife * 1.4, + width: c.crackWidth, + intensity: c.crackIntensity * 1.3, + colorA: getColor(c.colorCrack), + colorB: getColor(c.colorCrackEdge), + height: 0.014 + }); + + _pos.y = 0.4; + _emit.position = _pos; + _emit.radius = c.clusterRadius * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed * 1.8; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.15; + _emit.sizeVariance = 0.8; + _emit.life = c.gritLifetime * 1.4; + _emit.lifeVariance = 0.5; + _emit.spin = 11; + _emit.tint = null; + _emit.time = time; + this.grit.emit(Math.round(c.burstGrit * g.particleCount), _emit); + + _emit.speed = c.dustSpeed * 2.6; + _emit.spread = 1.0; + _emit.size = 1.6; + _emit.life = c.dustLifetime * 1.4; + _emit.spin = 0.5; + this.dust.emit(Math.round(c.burstDust * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 15 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.4 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.stonespine; + this._syncUniforms(); + + // `t` runs 0..1 while the field stands, then 1..2 while it falls back. + let retract = 0; + if (this.phase === AbilityPhase.FADE) { + retract = saturate((this.fadeTime - c.sinkDelay) / Math.max(0.05, c.sinkTime)); + } + + const p = this._readGrowth(); + this.field.update(this.age, p, retract); + + // The field keeps trickling while it stands and stops as it drops back: + // plates that are already flat have no slope for anything to run down. + if (retract < 0.7) this._fieldFx(dt, (t <= 1 ? 0.45 : 0.2) * (1 - retract)); + + this.pointAt(1, this.position); + } + + onDestroy() { + this.field.clear(); + } + + dispose() { + this.field.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/stone/TectonicAbility.js b/src/abilities/stone/TectonicAbility.js new file mode 100644 index 0000000..3766a07 --- /dev/null +++ b/src/abilities/stone/TectonicAbility.js @@ -0,0 +1,1122 @@ +import { + BufferGeometry, + BufferAttribute, + Mesh, + MeshStandardMaterial, + InstancedMesh, + Object3D, + Sphere, + Vector3, + DynamicDrawUsage +} from 'three'; +import { Ability } from '../Ability.js'; +import { + createFissureMaterial, + FissurePass, + FISSURE_LAYER, + MAX_FISSURE_ARMS +} from '../../materials/TectonicMaterial.js'; +import { Shell, ShellMode, shellDefaults } from '../../vfx/Shell.js'; +import { createAsteroidGeometry } from '../../assets/ProceduralGeometry.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +/* ---------------------------------------------------------------- */ +/* Fixed sizes — buffers are allocated once and rewritten per cast */ +/* ---------------------------------------------------------------- */ + +/** Samples along one main arm, centre to boundary. */ +const ARM_NODES = 64; +/** Samples along one fork. */ +const FORK_NODES = 18; +/** Forks hung off each arm. The `forks` slider culls them live. */ +const FORKS_PER_ARM = 2; +const NODES_PER_ARM = ARM_NODES + FORKS_PER_ARM * FORK_NODES; +const MAX_NODES = MAX_FISSURE_ARMS * NODES_PER_ARM; +/** Basalt shouldered up along the lips. One InstancedMesh, one draw call. */ +const MAX_LIPS = 40; +/** Where an arm leaves the middle, as a fraction of the footprint. */ +const START_REACH = 0.05; +const TAU = Math.PI * 2; + +/** + * Which arm gets which speed, as a unitless dice in 0..1. + * + * A **table**, not a roll, and the order matters: index 0 is always the fastest + * and index 1 always the slowest, so however far the `arms` slider is dragged — + * and it is a live slider, so it can be dragged mid-slam — the network still + * contains an arm running at exactly `fissureSpeed · (1 + speedSpread)` and one + * running at exactly `fissureSpeed`. The shockwave ring is timed against the + * first of those, and a derivation that only holds for *some* arm counts is not + * a derivation. What the cast randomises is the **bearing** (`_spin`), so the + * fast crack does not leave on the same heading twice. + * + * The remaining six are a low-discrepancy spread rather than an even ramp: an + * even ramp fanned around the circle reads as a rotating sweep, which is a + * different (and much more mechanical) effect from five cracks racing. + */ +const SPEED_ORDER = [1, 0, 0.62, 0.28, 0.85, 0.14, 0.46, 0.74]; + +const _emit = {}; +const _pos = new Vector3(); +const _tip = new Vector3(); +const _dir = new Vector3(); +const _tangent = new Vector3(); +const _centre = new Vector3(); +const _dummy = new Object3D(); + +/** + * TECTONIC SLAM — the ground is hit once, and then it tears. + * + * A far cast. A shock front runs out along the floor to the aimed circle, the + * caster brings it down, and five fissures whip outward from that point to the + * boundary **at different speeds**, each with a wave of dust riding just behind + * its own tip. The floor of every crack opens white, cools through ember to + * dead basalt on its own local clock, and blocks of crust are shouldered up + * along the lips as each tip passes them. A pressure ring goes out with the + * fastest crack and touches the boundary on the same frame it does. + * + * **THE TRICK — the footprint is drawn by motion, not revealed.** Every other + * far cast in the sandbox tells you where it lands by lighting the disc up: the + * Snare snaps its field open, the Crown freezes its sheet outward. This one + * never draws the disc at all. What you get is five racing lines with different + * arrival times, and the circle only exists in your head, assembled out of + * where the tips went. That is why the speeds must genuinely differ — five arms + * on one clock is a star, and a star is revealed all at once whatever it is + * made of. + * + * **The derivation.** The ring is not tuned to match the cracks. The fastest + * arm runs at `fissureSpeed · (1 + speedSpread)`, so it reaches the boundary at + * `t = zoneRadius / that`, and the shell is handed `t_norm = slamAge / t` with + * an end radius of `zoneRadius · ringReach`. `Shell` interpolates + * `radius → radiusEnd` and lands exactly on `radiusEnd` at `t_norm = 1`, so the + * two events coincide by construction — drag `zoneRadius`, `fissureSpeed` or + * `speedSpread` mid-cast and they *stay* coincident. `ringRadiusEnd` is + * therefore deliberately **not** a slider: it is the one number in the block + * that is a consequence rather than a taste, and it is assigned onto a proxy + * that inherits from the live block so `Shell` reads it without anything ever + * being written back into settings. + * + * **The shake ramps.** `shake.add()` at the slam is a spike, and a spike says + * "an event happened here" — which is the wrong sentence, because the event is + * still happening for the next second. So the slam gets a small punch and the + * rumble then *climbs* with the propagation, peaking as the tips hit the rim, + * and decays with the embers. The first version spiked once and the cracks + * afterwards felt like decoration. + * + * **The rule that makes the editor work.** A cast captures four things: a seed, + * a spin fraction (0..1, turned into a bearing by the shader), a per-arm + * unitless angular walk, and the timestamp of the slam. Not one metre, radian + * or second. The footprint, the crack widths, the propagation speeds, the ember + * cooling and the ring are all resolved against `settings.tectonic` every + * frame, on a zero-length frame included — so pausing mid-propagation and + * dragging `zoneRadius` re-scales a network that is already halfway across it, + * with the tips carrying on from where they now are. + */ +export class TectonicAbility extends Ability { + constructor(context) { + super('tectonic', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* ---- the crack network: one ribbon strip, two passes ---- */ + this._buildFissureGeometry(); + + this.gashMaterial = createFissureMaterial(FissurePass.GASH); + this.glowMaterial = createFissureMaterial(FissurePass.UNDERGLOW); + this.fissureMaterials = [this.glowMaterial, this.gashMaterial]; + + this.fissureMeshes = []; + for (const [index, material] of this.fissureMaterials.entries()) { + const mesh = new Mesh(this.geometry, material); + mesh.frustumCulled = false; + mesh.matrixAutoUpdate = false; + mesh.layers.set(FISSURE_LAYER); + // Underglow under the gash: the ember light spills onto the stone either + // side, so it has to be behind the hole it is coming out of. + mesh.renderOrder = index === 0 ? 7 : 9; + mesh.visible = false; + this.group.add(mesh); + this.fissureMeshes.push(mesh); + } + + /* ---- basalt shouldered up along the lips ---- */ + // Few cuts on purpose: at higher subdivision a heavily sliced rock rounds + // off into a pebble, and these have to read as blocks of crust levered out + // of a floor rather than as gravel dropped on it. + this.lipGeometry = createAsteroidGeometry({ + seed: 8.3, + detail: 1, + lumpiness: 0.3, + noiseScale: 1.7, + roughness: 0.2, + cuts: 4, + cutDepth: 0.22, + craters: 2 + }); + this.lipMaterial = new MeshStandardMaterial({ + color: 0x8a7f6b, + roughness: 0.96, + metalness: 0.02, + flatShading: true + }); + this.lips = new InstancedMesh(this.lipGeometry, this.lipMaterial, MAX_LIPS); + this.lips.castShadow = true; + this.lips.receiveShadow = true; + this.lips.frustumCulled = false; + this.lips.count = 0; + this.lips.layers.set(LAYER.WORLD); + this.group.add(this.lips); + + /* ---- the pressure ring ---- */ + // A DOME squashed almost flat is a ring standing on the floor with a bright + // seal where it meets it, which is what a pressure front looks like from + // above. PRESSURE mode is a *sphere* and reads as a bubble over the zone. + this.ring = new Shell({ mode: ShellMode.DOME, prefix: 'ring', nodes: 36, sides: 72, renderOrder: 13 }); + this.ring.visible = false; + this.group.add(this.ring.group); + + /** + * The object `Shell` is handed instead of the settings block. + * + * It **inherits** from the live block, so every `ringX` slider the block + * does own is read straight off it with no copying and no staleness, and + * the only own properties on it are the handful this ability derives + * (`ringRadiusEnd`) plus fallbacks for the corners of the shell vocabulary + * a flattened dome has no use for — the ring train and the sun disc. Those + * are filled once, here, so `Shell`'s missing-key audit has nothing to say + * and the editor is not asked to carry twenty-four controls that do + * nothing in this mode. + */ + this._ringConfig = Object.create(settings.tectonic); + const ringFallback = shellDefaults('ring', ShellMode.DOME); + for (const key of Object.keys(ringFallback)) { + if (!(key in settings.tectonic)) this._ringConfig[key] = ringFallback[key]; + } + + /* ---- per-cast state: dice and timestamps only ---- */ + /** Unitless 0..1; the shader turns it into the fan's bearing. */ + this._spin = 0; + this._seed = 0; + /** The moment the slam landed, or -1 while the front is still travelling. */ + this._slamTime = -1; + this._lipsDrawn = 0; + this._lipCount = 0; + this._markArm = 0; + + /** Per-arm dice, re-rolled per cast. Nothing here has a unit. */ + this._bearJitter = new Float32Array(MAX_FISSURE_ARMS); + /** The baked angular walk, and its derivative — the CPU's copy of it. */ + this._wander = new Float32Array(MAX_FISSURE_ARMS * ARM_NODES); + this._wanderRate = new Float32Array(MAX_FISSURE_ARMS * ARM_NODES); + + this.lipRecords = []; + for (let i = 0; i < MAX_LIPS; i++) { + this.lipRecords.push({ + arm: 0, + reach: 0, // 0..1 out along its arm + side: 1, // which lip it was levered onto + yaw: 0, // 0..1 of a turn + size: 0, // 0..1 + flat: 0, // 0..1 + offset: 0 // 0..1 how far outside the crack edge + }); + } + + // Scratch state handed to the materials and the shell each frame. One + // object apiece, reused — syncing a standing slam allocates nothing. + this._state = { centre: new Vector3(), age: 0, fade: 1, seed: 0, spin: 0 }; + this._ringState = { + origin: new Vector3(), + axis: new Vector3(0, 1, 0), + side: new Vector3(1, 0, 0), + span: 0, + t: 0, + fade: 1, + seed: 0 + }; + } + + /** Allocate the ribbon strip. Filled by `_generate()`, never grown. */ + _buildFissureGeometry() { + const vertices = MAX_NODES * 2; + this.positions = new Float32Array(vertices * 3); + this.shapes = new Float32Array(vertices * 4); + this.roles = new Float32Array(vertices * 4); + this.edges = new Float32Array(vertices * 2); + this.indices = new Uint16Array(MAX_NODES * 6); + + // The two edges of the ribbon never change hands. + for (let i = 0; i < vertices; i++) this.edges[i * 2] = i % 2 === 0 ? -1 : 1; + + this.geometry = new BufferGeometry(); + const attribute = (array, size) => new BufferAttribute(array, size).setUsage(DynamicDrawUsage); + this.geometry.setAttribute('position', attribute(this.positions, 3)); + this.geometry.setAttribute('aShape', attribute(this.shapes, 4)); + this.geometry.setAttribute('aRole', attribute(this.roles, 4)); + this.geometry.setAttribute('aEdge', attribute(this.edges, 2)); + this.geometry.setIndex(new BufferAttribute(this.indices, 1)); + this.geometry.setDrawRange(0, 0); + // The vertex shader places everything in world space off `uCentre`, so the + // local bounds are meaningless. Frustum culling is off; this is only here + // so three never tries to compute them from the attribute. + this.geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + } + + createParticles() { + const particles = this.ctx.particles; + + // The wave that rides behind each tip. Non-additive, because it has to + // *occlude* — a dust front you can see the floor through is a haze, and a + // haze does not read as a tonne of stone being displaced. + this.dust = particles.get('tectonic.dust', { + capacity: 3000, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.dust.uniforms.uDrag.value = 2.2; + this.dust.uniforms.uEndSize.value = 3.4; + this.dust.uniforms.uSizeIn.value = 0.1; + this.dust.uniforms.uFadeIn.value = 0.12; + this.dust.uniforms.uFadeOut.value = 0.34; + + // Chips of floor thrown up by the tearing tip. + this.grit = particles.get('tectonic.grit', { + capacity: 2000, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.2; + this.grit.uniforms.uEndSize.value = 0.8; + this.grit.uniforms.uFadeOut.value = 0.72; + + // Embers lifting out of the open crack. These are the only additive thing + // the ability owns besides the underglow, and they die at knee height. + this.embers = particles.get('tectonic.embers', { + capacity: 2200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.embers.uniforms.uDrag.value = 1.5; + this.embers.uniforms.uEndSize.value = 0.16; + this.embers.uniforms.uSizeIn.value = 0.05; + this.embers.uniforms.uFadeIn.value = 0.07; + this.embers.uniforms.uFadeOut.value = 0.42; + + this.dustEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + this.emberEmitter = new RateEmitter(); + this.markEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — every one of these is a live derivation */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._lipsDrawn + this.ring.instanceCount; + } + + /** The live footprint, metres. What the circle indicator measured out. */ + get radius() { + return Math.max(0.2, settings.tectonic.zoneRadius); + } + + /** Metres/second of reach for one arm, given its unitless speed dice. */ + _speedOf(dice) { + const c = settings.tectonic; + const base = Math.max(0.2, c.fissureSpeed) * Math.max(0.05, settings.global.speed); + return base * (1 + Math.max(0, c.speedSpread) * dice); + } + + /** Seconds the fastest arm — and therefore the ring — takes to reach the rim. */ + _fastArrival() { + return this.radius / this._speedOf(1); + } + + /** Seconds the slowest arm takes. The hold does not start until it lands. */ + _slowArrival() { + return this.radius / this._speedOf(0); + } + + /** Seconds since the slam. A clock off a timestamp, not a captured second. */ + get slamAge() { + return this._slamTime < 0 ? 0 : Math.max(0, this.age - this._slamTime); + } + + /** The propagation, 0..1, measured against the *slowest* arm. */ + get propagation() { + return saturate(this.slamAge / Math.max(0.02, this._slowArrival())); + } + + get impactDuration() { + return Math.max(0.2, this._slowArrival() + settings.tectonic.holdTime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.2, settings.tectonic.settleTime); + } + + /** + * The light cools with the ground rather than shimmering. + * + * Ice glints and lightning gutters; a crack full of ember does neither. It + * has one slow breath in it and then it goes out, on the same `emberCool` + * constant the fissure floors use — which is why dragging that slider dims + * the room as well as the crack. + */ + lightShimmer() { + const c = settings.tectonic; + const breathe = 0.86 + 0.14 * Math.sin(this.age * c.lightBreath); + if (this._slamTime < 0) return breathe; + return breathe * (c.lightFloor + (1 - c.lightFloor) * Math.exp(-this.slamAge / Math.max(0.05, c.emberCool))); + } + + /* ------------------------------------------------------------------ */ + /* The network — generated once per cast, in dice */ + /* ------------------------------------------------------------------ */ + + /** + * Roll a fresh crack network into the buffers. + * + * Everything written here is **unitless**: a fraction of the footprint, an + * angular walk in units of `wander`, a width jitter, an index. The metres and + * the radians are applied in the vertex shader, every frame, which is what + * makes `zoneRadius`, `wander`, `arms` and `fissureWidth` live sliders on a + * network that is already open. `effects/GroundFissures.js` bakes `x, z` and + * therefore cannot do that; this is the one thing this ability does that its + * ancestor could not. + * + * All `MAX_FISSURE_ARMS` arms are always generated. The `arms` slider culls + * them in the shader instead of deciding how many exist, so it can be dragged + * mid-slam and the fan re-spaces itself. + */ + _generate() { + let node = 0; + let quad = 0; + const dr = (1 - START_REACH) / (ARM_NODES - 1); + + for (let arm = 0; arm < MAX_FISSURE_ARMS; arm++) { + this._bearJitter[arm] = randRange(-1, 1); + const speed01 = SPEED_ORDER[arm]; + const base = arm * ARM_NODES; + + /* --- the angular walk, in units of `wander` --- */ + // A steady curvature plus a smoothed stagger, pulled back toward the + // launch bearing each step. Without the pull-back a constant veer curls + // an arm into a circle instead of driving it out of the impact, which is + // the mistake `GroundFissures` documents and this inherits the fix for. + const curvature = randRange(-1, 1); + let rate = curvature; + let walk = 0; + for (let i = 0; i < ARM_NODES; i++) { + this._wander[base + i] = walk; + rate = clamp(rate + randRange(-0.45, 0.45), -3, 3); + rate = lerp(rate, curvature, 0.08); + walk = walk * 0.94 + rate * dr; + } + // The rate the ribbon is squared against is the *actual* derivative of + // the walk, taken by central difference. Storing the integrator's `rate` + // instead left the ribbon leaning, because the pull-back had bent the + // centreline underneath it. + for (let i = 0; i < ARM_NODES; i++) { + const a = this._wander[base + Math.max(0, i - 1)]; + const b = this._wander[base + Math.min(ARM_NODES - 1, i + 1)]; + const span = (Math.min(ARM_NODES - 1, i + 1) - Math.max(0, i - 1)) * dr; + this._wanderRate[base + i] = span > 1e-6 ? (b - a) / span : 0; + } + + /* --- the arm itself --- */ + let jitter = 1; + const armFirst = node; + for (let i = 0; i < ARM_NODES; i++) { + const reach = START_REACH + i * dr; + jitter = clamp(jitter + randRange(-0.16, 0.16), 0.6, 1.45); + // A crack terminates in a needle at the rim and is pinched where it + // leaves the middle, so the network has no sawn-off ends. + const tip = Math.pow(saturate((1 - reach) / 0.16), 0.55); + const root = Math.pow(saturate((reach - START_REACH) / 0.07), 0.5); + node = this._pushNode( + node, + reach, 0, 0, + this._wander[base + i], this._wanderRate[base + i], reach, jitter * tip * root, + arm, this._bearJitter[arm], 0, speed01, + 0 + ); + } + quad = this._stitch(armFirst, node, quad); + + /* --- forks, hung off it in its own frame --- */ + for (let f = 0; f < FORKS_PER_ARM; f++) { + const anchorIndex = Math.floor(randRange(ARM_NODES * 0.28, ARM_NODES * 0.82)); + const anchorReach = START_REACH + anchorIndex * dr; + // 30°–70° off the parent. Baked, unlike the arm's veer: `forks` and + // `forkLength` are the two fork controls worth having live, and a + // per-fork bearing uniform would need an array indexed by something + // that is not a loop counter, which GLSL ES 1.00 will not compile. + const phi = (Math.random() < 0.5 ? 1 : -1) * randRange(0.52, 1.22); + const cos = Math.cos(phi); + const sin = Math.sin(phi); + const span = randRange(0.16, 0.34); + const rank = (f * MAX_FISSURE_ARMS + arm + 1) / (FORKS_PER_ARM * MAX_FISSURE_ARMS); + const forkFirst = node; + + for (let j = 0; j < FORK_NODES; j++) { + const t = j / (FORK_NODES - 1); + const travelled = t * span; + node = this._pushNode( + node, + anchorReach, cos * travelled, sin * travelled, + this._wander[base + anchorIndex], this._wanderRate[base + anchorIndex], + anchorReach + travelled, 0.62 * (1 - t * 0.25) * randRange(0.8, 1.2), + arm, this._bearJitter[arm], Math.max(t, 1e-3), speed01, + rank + ); + } + quad = this._stitch(forkFirst, node, quad); + } + } + + this.geometry.setDrawRange(0, quad * 6); + for (const name of ['position', 'aShape', 'aRole', 'aEdge']) { + this.geometry.attributes[name].needsUpdate = true; + } + this.geometry.index.needsUpdate = true; + + /* --- where the crust gets levered up --- */ + let lip = 0; + const stride = Math.max(3, Math.floor((ARM_NODES - 10) / Math.ceil(MAX_LIPS / MAX_FISSURE_ARMS))); + for (let arm = 0; arm < MAX_FISSURE_ARMS && lip < MAX_LIPS; arm++) { + for (let i = 6; i < ARM_NODES - 2 && lip < MAX_LIPS; i += stride) { + const record = this.lipRecords[lip++]; + record.arm = arm; + record.reach = START_REACH + i * dr; + record.side = i % (stride * 2) === 6 % (stride * 2) ? 1 : -1; + record.yaw = Math.random(); + record.size = Math.random(); + record.flat = Math.random(); + record.offset = Math.random(); + } + } + this._lipCount = lip; + } + + /** Write one ribbon node — two vertices, both edges. Returns the next index. */ + _pushNode(node, baseReach, along, lateral, wander, rate, prop, width, arm, bearJit, fork, speed01, rank) { + if (node >= MAX_NODES) return node; + const v = node * 2; + for (let k = 0; k < 2; k++) { + const i = v + k; + this.positions[i * 3 + 0] = baseReach; + this.positions[i * 3 + 1] = along; + this.positions[i * 3 + 2] = lateral; + this.shapes[i * 4 + 0] = wander; + this.shapes[i * 4 + 1] = rate; + this.shapes[i * 4 + 2] = prop; + this.shapes[i * 4 + 3] = width; + this.roles[i * 4 + 0] = arm; + this.roles[i * 4 + 1] = bearJit; + this.roles[i * 4 + 2] = fork; + this.roles[i * 4 + 3] = speed01; + this.edges[i * 2 + 1] = rank; + } + return node + 1; + } + + /** Stitch one run of nodes into the index buffer. Runs are never joined. */ + _stitch(first, last, quad) { + for (let i = first; i < last - 1; i++) { + const a = i * 2; + this.indices[quad * 6 + 0] = a; + this.indices[quad * 6 + 1] = a + 1; + this.indices[quad * 6 + 2] = a + 2; + this.indices[quad * 6 + 3] = a + 1; + this.indices[quad * 6 + 4] = a + 3; + this.indices[quad * 6 + 5] = a + 2; + quad++; + } + return quad; + } + + /* ------------------------------------------------------------------ */ + /* The JS mirror of the vertex shader */ + /* ------------------------------------------------------------------ */ + + /** + * Where arm `arm` is at `reach` (0..1 of the footprint), in world space, and + * optionally which way it is heading there. + * + * A line-for-line mirror of the first half of `FISSURE_VERTEX`, and it has to + * stay one: this is what puts the dust wave on the crack instead of near it. + * `Tube#radiusAt` and `Projectile#_pathPoint` are the same pattern — if you + * edit the bearing in one, edit it in the other. + */ + _armFrame(arm, reach, out, tangent = null) { + const c = settings.tectonic; + const g = settings.global; + const arms = clamp(Math.round(c.arms), 1, MAX_FISSURE_ARMS); + const radius = this.radius; + + const f = saturate((reach - START_REACH) / (1 - START_REACH)) * (ARM_NODES - 1); + const i0 = Math.min(ARM_NODES - 1, Math.floor(f)); + const i1 = Math.min(ARM_NODES - 1, i0 + 1); + const k = f - i0; + const base = arm * ARM_NODES; + const walk = lerp(this._wander[base + i0], this._wander[base + i1], k); + const rate = lerp(this._wanderRate[base + i0], this._wanderRate[base + i1], k); + + const wander = c.wander * g.randomness; + const bearing = + this._spin * TAU + ((arm + 0.5) / arms) * TAU + this._bearJitter[arm] * c.armJitter; + const theta = bearing + walk * wander; + const dTheta = rate * wander; + const cos = Math.cos(theta); + const sin = Math.sin(theta); + + out.set( + this._state.centre.x + cos * reach * radius, + c.fissureHeight, + this._state.centre.z + sin * reach * radius + ); + + if (tangent) { + tangent.set(cos - reach * dTheta * sin, 0, sin + reach * dTheta * cos); + if (tangent.lengthSq() < 1e-8) tangent.set(cos, 0, sin); + tangent.normalize(); + } + return out; + } + + /** How far arm `arm`'s tip has got, 0..1 of the footprint. */ + _tipReach(arm) { + const front = this.slamAge * this._speedOf(SPEED_ORDER[arm]); + return saturate(front / this.radius); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.gritEmitter.reset(); + this.emberEmitter.reset(); + this.markEmitter.reset(); + + // The four things a cast captures, and all four are dice or clocks. + this._seed = Math.random() * 100; + this._spin = Math.random(); + this._slamTime = -1; + this._markArm = 0; + + this._generate(); + + this._lipsDrawn = 0; + this.lips.count = 0; + this.ring.visible = false; + for (const mesh of this.fissureMeshes) mesh.visible = false; + + this._sync(1); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the cast's dice into both fissure passes, the + * rubble, the ring and the three particle systems. + * + * @param {number} fade 1 while the ground is open, ramping to 0 as it settles + */ + _sync(fade) { + const c = settings.tectonic; + const g = settings.global; + const state = this._state; + + this.pointAt(1, state.centre).setY(0); + state.age = this.slamAge; + state.fade = fade; + state.seed = this._seed; + state.spin = this._spin; + + for (const material of this.fissureMaterials) material.userData.sync(state); + + const slammed = this._slamTime >= 0; + for (const mesh of this.fissureMeshes) mesh.visible = slammed && fade > 0.002; + + this._poseLips(fade); + this._syncRing(fade); + + /* --- the three particle systems --- */ + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = c.dustSpeed * g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + + this.embers.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.embers.uniforms.uGravity.value.set(0, c.emberRise, 0); + this.embers.uniforms.uSizeScale.value = c.emberSize * g.particleSize * 7; + this.embers.uniforms.uLifeScale.value = c.emberLifetime * 0.5 * g.particleLifetime; + this.embers.uniforms.uSpeedScale.value = g.particleSpeed; + this.embers.uniforms.uOpacity.value = g.opacity; + this.embers.uniforms.uGlow.value = c.emberGlow * g.glow; + this.embers.uniforms.uTurbulence.value = c.emberTurbulence * g.turbulence; + } + + /** + * Place every block of crust from the live settings. + * + * A record holds an arm, a reach and four dice. Where that is in metres, how + * big the block is and how far it has been shouldered out of the lip are all + * resolved here, so dragging `zoneRadius` walks the rubble outward along the + * cracks it belongs to rather than leaving it stranded. + */ + _poseLips(fade) { + const c = settings.tectonic; + const g = settings.global; + // The blocks drop back into the lip as the ability lets go, on the same + // fade the cracks close on. + const sink = Easing.inCubic(saturate(1 - fade)); + let used = 0; + + for (let i = 0; i < this._lipCount; i++) { + const record = this.lipRecords[i]; + const arm = record.arm; + // Rubble belonging to an arm the slider has culled is not drawn. + const live = arm < clamp(Math.round(c.arms), 1, MAX_FISSURE_ARMS); + const behind = this.slamAge * this._speedOf(SPEED_ORDER[arm]) - record.reach * this.radius; + const emerge = live ? saturate(behind / Math.max(0.05, c.rubbleEmerge)) : 0; + + if (emerge <= 0.001) { + _dummy.position.set(0, -999, 0); + _dummy.quaternion.identity(); + _dummy.scale.setScalar(0.0001); + } else { + const size = c.rubbleSize * lerp(0.55, 1.35, record.size) * (1 + record.flat * 0.2 * g.randomness); + this._armFrame(arm, record.reach, _pos, _tangent); + const out = c.fissureWidth * 0.55 + record.offset * c.rubbleSpread + size * 0.15; + // The lateral of the crack is the tangent turned a quarter turn in the + // floor plane — the same rotation the vertex shader does. + _dummy.position.set( + _pos.x - _tangent.z * out * record.side, + // Sunk well in, so only the top ridge breaks the surface: crust that + // was levered up, not pebbles resting on a floor. + -size * lerp(0.3, 0.6, record.flat) - sink * (size * 2 + 0.4), + _pos.z + _tangent.x * out * record.side + ); + _dummy.rotation.set(0, record.yaw * TAU, 0); + const grow = Easing.outBack(Math.min(1, emerge)); + _dummy.scale.set(size * grow, size * lerp(0.45, 0.85, record.flat) * grow, size * 0.8 * grow); + } + + _dummy.updateMatrix(); + this.lips.setMatrixAt(i, _dummy.matrix); + used = i + 1; + } + + this._lipsDrawn = used; + this.lips.count = used; + this.lips.instanceMatrix.needsUpdate = true; + this.lipMaterial.color.copy(getColor(c.colorRubble)); + } + + /** + * The pressure ring. + * + * The whole derivation lives in these five lines: `t` is the slam clock over + * the fastest arm's arrival time, and the end radius is the footprint. The + * shell lands on `radiusEnd` at `t = 1` and the crack lands on the boundary + * at the same instant, whatever anyone does to the sliders in between. + */ + _syncRing(fade) { + const c = settings.tectonic; + const arrival = Math.max(0.02, this._fastArrival()); + const t = saturate(this.slamAge / arrival); + // Not a slider: see the class comment. Assigned onto the proxy so `Shell` + // reads a derived metre without anything being written back into settings. + this._ringConfig[this.ring.keys.radiusEnd] = this.radius * c.ringReach; + + const state = this._ringState; + state.origin.copy(this._state.centre); + state.side.copy(this.side); + state.t = t; + // The ring dies the moment it lands on the rim; holding it there turns a + // front into a fence. + state.fade = fade * (1 - saturate((this.slamAge - arrival) / Math.max(0.05, c.ringFade))); + state.seed = this._seed; + + this.ring.sync(this._ringConfig, state); + this.ring.visible = this._slamTime >= 0 && state.fade > 0.004; + } + + /** The stamp at the caster's feet as the shock front leaves. */ + _muzzleFx() { + const c = settings.tectonic; + const g = settings.global; + + _pos.copy(this.origin).addScaledVector(this.direction, c.handForward); + _pos.y = 0.1; + + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.muzzleSize * 0.25, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.5, + intensity: c.muzzleIntensity, + opacity: 0.7, + fresnel: 1.2, + displace: 0.6, + squash: 0.45, + colorA: getColor(c.colorDustB), + colorB: getColor(c.colorDustA), + colorC: getColor(c.colorRubble) + }); + + _emit.position = _pos; + _emit.radius = 0.3; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.5).setY(0.7).normalize(); + _emit.speed = c.gritSpeed * 0.7; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 8; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grit.emit(Math.round(14 * g.particleCount), _emit); + + this.lightBoost = c.lightIntensity * 0.35 * g.explosionIntensity; + } + + /** Dust kicked up under the shock front while it runs out to the circle. */ + _frontFx(dt) { + const c = settings.tectonic; + const g = settings.global; + const count = Math.round(this.dustEmitter.tick(dt, c.dustRate * 0.25) * g.particleCount); + if (count <= 0) return; + + _emit.position = _pos.copy(this.position).setY(0.1); + _emit.radius = 0.45; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 0.6; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.6; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime * 0.7; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(count, _emit); + } + + /** + * The wave that rides behind each tip. + * + * This is the half of the trick the shader cannot do. Each arm's tip is asked + * for by `_armFrame` at `tipReach − dustLag/zoneRadius` — a wave that sits a + * fixed number of *metres* behind the tearing edge, so it does not slide + * forward when the footprint grows — and the dust is thrown sideways off the + * crack rather than up out of it, because what is being displaced is a lip, + * not a chimney. + * + * @param {number} scale 0..1, thinned once the arms have all landed + */ + _tipFx(dt, scale) { + const c = settings.tectonic; + const g = settings.global; + const time = frame.uTime.value; + const arms = clamp(Math.round(c.arms), 1, MAX_FISSURE_ARMS); + const radius = this.radius; + const lag = c.dustLag / radius; + + const dustTotal = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + const gritTotal = Math.round(this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount); + const emberTotal = Math.round(this.emberEmitter.tick(dt, c.emberRate * scale) * g.particleCount); + + for (let arm = 0; arm < arms; arm++) { + const tip = this._tipReach(arm); + // A tip that has already reached the boundary stops throwing anything — + // which is exactly what makes the fast arms go quiet while the slow ones + // are still tearing, and is most of why the speeds read as different. + const running = tip < 1 ? 1 : 0; + const behind = Math.max(START_REACH, tip - lag); + + const dust = Math.floor(dustTotal / arms) * running; + if (dust > 0) { + this._armFrame(arm, behind, _tip, _tangent); + _tip.y = c.dustHeight; + _emit.position = _tip; + _emit.radius = c.fissureWidth * 1.6 + 0.1; + // Off the lip, both ways, with a little lift: a bow wave, not a plume. + _emit.direction = _dir + .set(-_tangent.z * (arm % 2 === 0 ? 1 : -1), c.dustLift, _tangent.x * (arm % 2 === 0 ? 1 : -1)) + .normalize(); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.5; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dust, _emit); + } + + const grit = Math.floor(gritTotal / arms) * running; + if (grit > 0) { + this._armFrame(arm, tip, _tip, _tangent); + _tip.y = 0.05; + _emit.position = _tip; + _emit.radius = c.fissureWidth * 0.8; + _emit.direction = _dir.set(_tangent.x * 0.35, 1, _tangent.z * 0.35).normalize(); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.size = 0.1; + _emit.sizeVariance = 0.75; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.time = time; + this.grit.emit(grit, _emit); + } + + const ember = Math.floor(emberTotal / arms); + if (ember > 0) { + // Embers come off the *open* crack behind the tip, not the tip itself. + this._armFrame(arm, Math.max(START_REACH, tip * (0.15 + 0.7 * Math.random())), _tip, null); + _tip.y = 0.08; + _emit.position = _tip; + _emit.radius = c.fissureWidth * 0.7; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.emberSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.size = 0.07; + _emit.sizeVariance = 0.6; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.embers.emit(ember, _emit); + } + } + + /* --- ground dust rings dropped behind the tips --- */ + const marks = this.markEmitter.tick(dt, c.markRate * scale); + for (let i = 0; i < marks; i++) { + this._markArm = (this._markArm + 1) % arms; + const tip = this._tipReach(this._markArm); + if (tip >= 1) continue; + this._armFrame(this._markArm, Math.max(START_REACH, tip - lag * 0.5), _tip, null); + this.ctx.decals.spawn(DecalType.DUSTRING, _tip, { + radius: c.markRadius * randRange(0.7, 1.3), + life: c.markLife, + intensity: c.markIntensity, + colorA: getColor(c.colorDustB), + colorB: getColor(c.colorDustA), + height: 0.012 + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + this.position.y = 0.3; + this._frontFx(dt); + this.ctx.shake.rumble(settings.tectonic.travelRumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.tectonic; + const g = settings.global; + const time = frame.uTime.value; + + // The one timestamp the propagation runs off. + this._slamTime = this.age; + this._centreFx(c, g, time); + } + + /** The slam itself: one punch, and everything the impact throws. */ + _centreFx(c, g, time) { + this.pointAt(1, _centre).setY(0); + + /* the ball of dust the slam punches out of the floor */ + _pos.copy(_centre).setY(0.3); + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.slamSize * 0.3, + endRadius: c.slamSize * g.explosionIntensity, + life: 0.85, + intensity: c.slamIntensity, + opacity: 0.85, + fresnel: 1.1, + displace: 0.7, + squash: 0.45, + colorA: getColor(c.colorDustB), + colorB: getColor(c.colorDustA), + colorC: getColor(c.colorEmber) + }); + + /* the star of cracks under the point of impact, where the arms leave */ + this.ctx.decals.spawn(DecalType.CRACK, _centre, { + radius: this.radius * c.starRadius, + life: c.starLife, + width: c.starWidth, + intensity: c.starIntensity, + colorA: getColor(c.colorSeam), + colorB: getColor(c.colorEmber), + height: 0.014 + }); + + /* the dust it sits in */ + this.ctx.decals.spawn(DecalType.DUSTRING, _centre, { + radius: this.radius * c.slamDust, + life: c.markLife * 1.6, + intensity: c.markIntensity, + colorA: getColor(c.colorDustA), + colorB: getColor(c.colorDustB), + height: 0.01 + }); + + /* chips and dust thrown straight up out of the middle */ + _emit.position = _pos; + _emit.radius = this.radius * 0.18; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed * 1.6; + _emit.speedVariance = 0.85; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.14; + _emit.sizeVariance = 0.8; + _emit.life = c.gritLifetime * 1.3; + _emit.lifeVariance = 0.5; + _emit.spin = 11; + _emit.tint = null; + _emit.time = time; + this.grit.emit(Math.round(c.slamGrit * g.particleCount), _emit); + + _emit.radius = this.radius * 0.3; + _emit.speed = c.dustSpeed * 1.8; + _emit.spread = 1.0; + _emit.size = 1.2; + _emit.life = c.dustLifetime * 1.2; + _emit.spin = 0.6; + this.dust.emit(Math.round(c.slamDustCount * g.particleCount), _emit); + + // A punch, and only a punch. The rest of the shake climbs with the cracks — + // see `onFade`. + this.ctx.shake.add( + c.slamShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.slamShakeTime), + 19 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.slamFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.tectonic; + // `t` runs 0..1 while the ground is open, then 1..2 while it settles. + const fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + + this._sync(fade); + + // The light sits in the middle of the footprint, just off the floor, where + // the ember is brightest. + this.pointAt(1, this.position).setY(c.lightHeight); + + this._tipFx(dt, fade * (t <= 1 ? 1 : 0.3)); + + /* + * The ramp. Amplitude climbs with the propagation and peaks as the last + * tip reaches the boundary, then decays on the ember clock. `rumble()` + * takes an amplitude and a dt, so this is a continuous read of "how much + * ground is currently coming apart" rather than a scheduled sequence. + */ + const propagation = this.propagation; + const ramp = + propagation < 1 + ? Math.pow(propagation, Math.max(0.05, c.shakeRamp)) + : Math.exp(-(this.slamAge - this._slowArrival()) / Math.max(0.05, c.shakeDecay)); + this.ctx.shake.rumble(c.slamRumble * ramp * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + this._slamTime = -1; + this._lipsDrawn = 0; + this.lips.count = 0; + this.ring.visible = false; + for (const mesh of this.fissureMeshes) mesh.visible = false; + for (const material of this.fissureMaterials) material.uniforms.uFade.value = 0; + } + + dispose() { + this.geometry.dispose(); + for (const material of this.fissureMaterials) material.dispose(); + this.lipGeometry.dispose(); + this.lipMaterial.dispose(); + this.lips.dispose(); + this.ring.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/storm/BallLightningAbility.js b/src/abilities/storm/BallLightningAbility.js new file mode 100644 index 0000000..9f510c5 --- /dev/null +++ b/src/abilities/storm/BallLightningAbility.js @@ -0,0 +1,819 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { FilamentPaths, filamentLook } from '../../vfx/FilamentPaths.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange, hash11 } from '../../utils/math.js'; + +/** Turns to radians. Named because it appears in three places and is not a dimension. */ +const TAU = Math.PI * 2; + +/** + * Samples along one filament. Seventy-two, not the chain's ninety-six: an orbit + * is a smooth curve with no corners to resolve, and the extra samples bought + * nothing but vertices. + */ +const SAMPLES = 72; + +/** Filament ceiling across the cage, the spike and the ring. */ +const CAPACITY = 48; + +/* Role slots on the shared strip, structural first — capacity is handed out in + * role order, so the cage can never be starved by a ring wound up to sixteen. */ +const ROLE_CAGE = 0; +const ROLE_SPIKE = 1; +const ROLE_RING = 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _orb = new Vector3(); +const _pole = new Vector3(); +const _from = new Vector3(); +const _to = new Vector3(); +const _pos = new Vector3(); +const _dir = new Vector3(); +const _emit = {}; + +/** + * FULMINANT ORB — a caged ball of current that walks downrange. + * + * **The trick is that the filaments orbit rather than radiate.** Every other + * storm slot draws a fan: filaments leaving a point and going outward. This one + * draws great slow loops *around* a point, each on its own inclined plane with + * its own ascending node, so from any angle you see filaments passing in front + * of the orb and behind it. That is the whole read, and it is one `PathMode`: + * `ORBIT`, on a `FilamentPaths` role, with `orbitTiltSpread` doing the work + * that stops nine coplanar rings looking like one flat hoop. + * + * Inside the cage is a `Shell` in `PRESSURE` mode — ninety-five per cent + * fresnel rim and almost no body. You are not really meant to see it; you are + * meant to see that the filaments are going *around something*, and the rim is + * what supplies the something. Winding `orbFill` up to 0.4 to check turns the + * orb into a ball of fog and loses the cage entirely, which is how I know the + * near-invisibility is load-bearing rather than shy. + * + * **The cage is sized against the shell, deliberately.** `orbitRadius` is a + * multiple of `shell.radius`, not a metre count, which is the one derivation + * invariant I5 allows — where the sharing *is* the design. It buys the impact + * for free: the shell interpolates `orbRadius → orbRadiusEnd` on its own live + * easing as `t` runs 0→1, and the cage opens with it, in step. The first + * version authored both in metres and every time the two disagreed the + * filaments ended up *inside* the shell, which looks like a bug because it is. + * + * **It travels at six and a half metres a second**, which is walking pace and + * roughly a fifth of the slowest thing else in the sandbox. That is the point: + * this is the slot where the travel is the spectacle. It bobs, it drifts off + * the aim line on a rate deliberately not commensurate with the bob, and it + * lays a light wake of burns on the floor as it goes. + * + * **It hums.** `lightShimmer()` is two sines beating against each other rather + * than Storm Lance's hash quantised onto a step clock: a continuous wobble + * instead of a hard stutter. Set `humBeat` to exactly 1 or 2 and the period + * becomes countable and it reads as a pulse; the shipped 1.47 never quite + * repeats, which is what a hum is. + * + * **The earthing spike holds no state at all.** A second `LINE` role stabs from + * the orb down to the floor now and then, and it is retired — `count = 0` — on + * every frame it is not wanted. Whether it is wanted is decided by quantising + * the clock into `earthRate` slots a second and hashing the slot index against + * the live `earthChance`; how long it lives inside its slot is `earthDuty`. + * Nothing is captured, so all three are sliders that work on a standing effect. + * The version before this kept a fired-at timestamp and a boolean and neither + * of them could be moved by dragging anything. + * + * Three roles, one strip, plus the shell: **three draw calls** for the whole + * cast. + */ +export class BallLightningAbility extends Ability { + constructor(context) { + super('balllightning', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.paths = new FilamentPaths(this.group, { + samples: SAMPLES, + capacity: CAPACITY, + renderOrder: 11 + }); + this.cageRole = this.paths.role(ROLE_CAGE); + this.spikeRole = this.paths.role(ROLE_SPIKE); + this.ringRole = this.paths.role(ROLE_RING); + + // PRESSURE sweeps its polar angle a full half-turn, so it is a ball rather + // than the dome the same grid draws at a quarter turn. + this.shell = new Shell({ + mode: ShellMode.PRESSURE, + prefix: 'orb', + nodes: 48, + sides: 48, + renderOrder: 14 + }); + this.group.add(this.shell.group); + + /** One look scratch, refilled from settings every frame. */ + this._look = filamentLook(); + + /** The shell's per-frame state. Dice rolls and normalised time only. */ + this._state = { + origin: new Vector3(), + axis: new Vector3(0, 1, 0), + side: new Vector3(1, 0, 0), + span: 0, + t: 0, + fade: 1, + seed: 0 + }; + + /** Re-rolled per cast so no two orbs wear the same cage. */ + this._seed = 0; + /** 0..1 of the discharge. Set from the phase clock, never accumulated. */ + this._discharge = 0; + /** When the orb landed, seconds of `age`. −1 until it has. */ + this._impactAt = -1; + /** Metres of travel already paid out in wake burns. */ + this._wakeDistance = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The ionised air the orb drags with it. This is the system that does most + // of the work while it travels — the cage is thin, and the motes are what + // give the orb a volume to read against. + this.motes = particles.get('balllightning.motes', { + capacity: 2400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 2.2; + this.motes.uniforms.uEndSize.value = 0.18; + this.motes.uniforms.uSizeIn.value = 0.06; + this.motes.uniforms.uFadeIn.value = 0.1; + this.motes.uniforms.uFadeOut.value = 0.45; + + // Sparks shed by the cage where a loop passes closest to the shell. + this.sparks = particles.get('balllightning.sparks', { + capacity: 2400, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.5; + this.sparks.uniforms.uEndSize.value = 0.25; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.45; + + // Haze off the wake. Non-additive so it genuinely occludes. + this.smoke = particles.get('balllightning.smoke', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.smoke.uniforms.uDrag.value = 1.8; + this.smoke.uniforms.uEndSize.value = 3.0; + this.smoke.uniforms.uSizeIn.value = 0.12; + this.smoke.uniforms.uFadeIn.value = 0.16; + this.smoke.uniforms.uFadeOut.value = 0.3; + + // Chips, at the discharge only. + this.debris = particles.get('balllightning.debris', { + capacity: 1000, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.debris.uniforms.uDrag.value = 0.25; + this.debris.uniforms.uEndSize.value = 0.8; + this.debris.uniforms.uFadeOut.value = 0.7; + + this.moteEmitter = new RateEmitter(); + this.sparkEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.paths.liveCount + this.shell.instanceCount; + } + + get impactDuration() { + return Math.max(0.05, settings.balllightning.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.balllightning.fadeTime); + } + + /** + * The hum. + * + * Two sines whose rates are in a deliberately awkward ratio, summed and + * folded into 0..1. The bolt's `lightShimmer()` floors a hash onto a step + * clock and snaps between levels, which is right for something discharging + * and wrong for something *sustaining*: the first attempt here reused that + * code with a lower rate and the orb read as a strobe running slowly rather + * than as a thing under continuous load. + */ + lightShimmer() { + const c = settings.balllightning; + const first = Math.sin(this.age * c.humRate * TAU); + const second = Math.sin(this.age * c.humRate * c.humBeat * TAU + 1.7); + return 1 - saturate(c.humDepth) * (0.5 - 0.25 * (first + second)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** + * Where the orb is, in world space. + * + * The bob and the drift are evaluated from `age` rather than integrated, so + * they are exact on a zero-length frame and dragging `bobAmp` moves a + * standing orb instead of only affecting it from here on. + */ + _orbPoint(out) { + const c = settings.balllightning; + const u = saturate(this.u); + this.pointAt(u, out); + // The forward offset and the lateral offset both ease out of the hand, so + // the orb leaves the caster rather than appearing beside them. + out.addScaledVector(this.direction, c.handForward * (1 - u)); + out.addScaledVector(this.side, c.handSide * (1 - u)); + out.addScaledVector(this.side, Math.sin(this.age * c.wanderRate * TAU) * c.wanderAmp * u); + out.y = lerp(c.handHeight, c.endHeight, u) + Math.sin(this.age * c.bobRate * TAU) * c.bobAmp; + return out; + } + + /** + * Brightness of the earthing spike, 0 when there is not one. + * + * Stateless by construction — see the class header. Once the orb has landed + * the spike is permanent: it is no longer "occasionally earthing", it is + * grounded and pouring. + */ + _spikeLevel(c) { + if (this.phase !== AbilityPhase.TRAVEL) return 1; + const rate = Math.max(0.05, c.earthRate); + const slot = Math.floor(this.age * rate); + if (hash11(this._seed * 3.17 + slot * 61.7) >= saturate(c.earthChance)) return 0; + const within = this.age * rate - slot; + return 1 - saturate(within / Math.max(0.02, c.earthDuty)); + } + + /** Where on the shell the current spike leaves. A hash of the slot index. */ + _spikeFrom(c, out) { + const rate = Math.max(0.05, c.earthRate); + const slot = Math.floor(this.age * rate); + const angle = hash11(this._seed + slot * 17.31) * TAU; + const r = this.shell.radius; + this._orbPoint(out); + out.addScaledVector(this.side, Math.cos(angle) * r * c.spikeExit); + out.addScaledVector(this.direction, Math.sin(angle) * r * c.spikeExit); + out.y -= r * c.spikeDrop; + return out; + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** Refill the shared ribbon look from live settings. */ + _fillLook() { + const c = settings.balllightning; + const g = settings.global; + const l = this._look; + + l.width = c.width; + l.glowWidth = c.glowWidth; + l.glowOpacity = c.glowOpacity; + l.jitter = c.jitter; + l.jitterScale = c.jitterScale; + l.octaves = c.octaves; + l.jitterFalloff = c.jitterFalloff; + l.crawl = c.crawl; + l.pinch = c.pinch; + l.restrike = c.restrike; + l.flicker = c.flicker; + l.flickerSpeed = c.flickerSpeed; + l.strandFlash = c.strandFlash; + l.coreSharp = c.coreSharp; + l.glowFalloff = c.glowFalloff; + l.softFade = c.softFade; + l.opacity = c.opacity; + l.glow = c.glow; + l.colorCore = c.colorCore; + l.colorInner = c.colorInner; + l.colorOuter = c.colorOuter; + l.colorHalo = c.colorHalo; + + l.randomness = g.randomness; + l.noiseStrength = g.noiseStrength; + l.noiseFrequency = g.noiseFrequency; + l.noiseSpeed = g.noiseSpeed; + l.opacityScale = g.opacity; + l.glowScale = g.glow; + } + + /** + * One whole frame of the orb: the shell, then the three roles, then the sync. + * + * The shell goes first and it has to. Two of the three roles are sized in + * multiples of `shell.radius`, and that getter only means anything after + * `sync()` has re-resolved `orbRadius → orbRadiusEnd` against this frame's + * `t`. Configuring the cage first gets you last frame's radius, which during + * the discharge — when the radius is moving fastest — reads as the filaments + * lagging a frame behind the thing they are supposed to be caging. + * + * @param {number} fade 1 while lit, ramping to 0 as it dies + */ + _sync(fade) { + const c = settings.balllightning; + const g = settings.global; + const state = this._state; + + this._orbPoint(state.origin); + state.axis.set(0, 1, 0); + state.side.copy(this.side); + state.span = this.length; + state.t = this._discharge; + state.fade = fade; + state.seed = this._seed; + this.shell.sync(c, state, g); + + this._fillLook(); + this._syncCage(c, fade); + this._syncSpike(c, fade); + this._syncRing(c, fade); + this.paths.sync(this._look, fade, this._seed); + } + + /** + * The cage: `ORBIT`, the one mode in the library whose filaments circle a + * point instead of leaving it. + * + * `pole` is a *point* along the mean orbital axis, not a direction, so it is + * built each frame from the orb's own position — leaning along the heading by + * `orbitLean` metres, which tips the whole cage into the direction of travel + * and is most of why it reads as being carried rather than as a decoration + * hung in place. + */ + _syncCage(c, fade) { + const role = this.cageRole; + const count = Math.round(c.orbitCount * (this._impactAt < 0 ? 1 : c.orbitBurst)); + if (count < 1) return void role.retire(); + + this._orbPoint(_orb); + _pole.copy(_orb).addScaledVector(this.direction, c.orbitLean); + _pole.y += 1; + + role.count = count; + role.orbit( + _orb, + _pole, + this.shell.radius * c.orbitRadius, + c.orbitArc, + c.orbitSpin, + c.orbitWobble, + c.orbitTilt, + c.orbitTiltSpread, + c.orbitJitter + ); + role.style(c.orbitKink, c.orbitWidth, c.orbitDim * fade, c.orbitGroundDamp); + // Both ends loose and tapered: a loop with square ends reads as a cut pipe. + role.ends(1, 1, 1, 1); + role.draw(2, 0.1, -1e4, 0); + } + + /** The occasional earthing spike, and the permanent one after it lands. */ + _syncSpike(c, fade) { + const role = this.spikeRole; + const level = this._spikeLevel(c); + if (level <= 0) return void role.retire(); + + const grounded = this._impactAt >= 0; + role.count = Math.max(1, Math.round(c.spikeStrands * (grounded ? c.spikeBurst : 1))); + + this._spikeFrom(c, _from); + _to.copy(_from); + // Guard the degenerate case where the orb has sunk to the clamp height: a + // zero-length LINE gives the shader a null tangent and the ribbon folds. + _to.y = Math.min(c.spikeFloor, _from.y - 0.05); + _to.addScaledVector(this.side, (hash11(this._seed + 4.9) - 0.5) * 2 * c.spikeWander); + + role.line( + _from, + _to, + c.spikeSag, + c.spikeNear, + c.spikeSpread, + c.spikeCurve, + c.spikeTwist, + c.spikeTwistSpeed, + c.spikeConverge + ); + role.style(c.spikeKink, c.spikeWidth, c.spikeDim * level * fade, c.spikeGroundDamp); + role.ends(0, 1, 0, 1); + role.draw(2, 0.12, c.spikeFloor, c.spikeTipGlow); + } + + /** + * The ring of ground current the discharge lays down. + * + * `RIM` rather than a radial fan: arcs *travelling around* the footprint, + * hopping at mid-span, which keeps the discharge circular the way the cage + * was circular. A fan out of the centre would have been the obvious thing and + * it made the landing look like a completely different ability. + */ + _syncRing(c, fade) { + const role = this.ringRole; + if (this._impactAt < 0 || c.ringCount < 1) return void role.retire(); + + const since = this.age - this._impactAt; + const grow = saturate(since / Math.max(0.02, c.ringGrow)); + + this.pointAt(1, _from); + _from.y = c.ringFloor; + _pole.set(_from.x, _from.y + 1, _from.z); + + role.count = Math.round(c.ringCount); + role.rim( + _from, + _pole, + this.shell.radius * c.ringRadius, + c.ringSpan, + c.ringSpeed, + c.ringLift, + c.ringJitter, + c.ringHug, + c.ringPhase + ); + role.style(c.ringKink, c.ringWidth, c.ringDim * fade, c.ringGroundDamp); + role.ends(1, 1, 1, 1); + role.draw(grow + c.ringTip, c.ringTip, c.ringFloor, c.ringTipGlow); + } + + /** Push the live gradients and scales into all four particle systems. */ + _syncParticles() { + const c = settings.balllightning; + const g = settings.global; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.glow * 0.5 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.glow * 0.6 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.25 * g.turbulence; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.35 * g.turbulence; + + this.debris.setGradient( + getColor(c.colorDebrisA), + getColor(c.colorDebrisB), + getColor(c.colorDebrisC), + getColor(c.colorDebrisD) + ); + this.debris.uniforms.uGravity.value.set(0, c.debrisGravity, 0); + this.debris.uniforms.uSizeScale.value = c.debrisSize * g.particleSize * 7; + this.debris.uniforms.uLifeScale.value = c.debrisLifetime * 0.5 * g.particleLifetime; + this.debris.uniforms.uSpeedScale.value = g.particleSpeed; + this.debris.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Motes, sparks and smoke shed while the orb is alive. + * + * Motes are born on the shell rather than at its centre — `moteShell` is a + * multiple of the live radius — so they fall away from the surface the cage + * is wrapped around instead of squirting out of a point inside it. + * + * @param {number} scale 0..1 — thinned once the orb is only discharging + */ + _orbFx(dt, scale) { + const c = settings.balllightning; + const g = settings.global; + const time = frame.uTime.value; + const radius = this.shell.radius; + + this._orbPoint(_orb); + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + _emit.position = _orb; + _emit.radius = radius * c.moteShell; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.08; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + const sparkCount = Math.round(this.sparkEmitter.tick(dt, c.sparkRate * scale) * g.particleCount); + if (sparkCount > 0) { + _emit.position = _orb; + _emit.radius = radius * c.orbitRadius; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.size = 0.14; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.time = time; + this.sparks.emit(sparkCount, _emit); + } + + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * scale) * g.particleCount); + if (smokeCount > 0) { + // Off the floor under the orb — it is the wake smouldering, not the orb. + _pos.set(_orb.x, 0.15, _orb.z); + _emit.position = _pos; + _emit.radius = c.scorchRadius * 2.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + } + + /** The wake of burns the orb leaves on the floor as it passes over it. */ + _wakeFx() { + const c = settings.balllightning; + const step = 1 / Math.max(0.05, c.wakeRate); + + while (this.front - this._wakeDistance >= step) { + this._wakeDistance += step; + const s = saturate(this._wakeDistance / this.length); + this.pointAt(s, _pos); + + this.ctx.decals.spawn(DecalType.ARC, _pos, { + radius: c.wakeRadius * randRange(0.7, 1.2), + life: c.wakeLife, + width: c.wakeBranches, + intensity: c.wakeIntensity, + colorA: getColor(c.colorEmber), + colorB: getColor(c.colorArc) + }); + this.ctx.decals.spawn(DecalType.SCORCH, _pos, { + radius: c.scorchRadius * randRange(0.7, 1.3), + life: c.scorchLife, + intensity: c.scorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorEmber), + height: 0.015 + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.sparkEmitter.reset(); + this.smokeEmitter.reset(); + + // The one thing a cast captures. + this._seed = Math.random() * 100; + this._discharge = 0; + this._impactAt = -1; + this._wakeDistance = 0; + + this._syncParticles(); + this._sync(1); + this._orbPoint(this.position); + + const c = settings.balllightning; + const g = settings.global; + this._orbPoint(_pos); + + this.ctx.bursts.spawn(BurstMode.STORM, _pos, { + radius: c.muzzleSize * 0.2, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.35, + intensity: c.muzzleIntensity, + opacity: 0.9, + fresnel: 1.5, + displace: 0.4, + colorA: getColor(c.colorMuzzleA), + colorB: getColor(c.colorMuzzleB), + colorC: getColor(c.colorMuzzleC) + }); + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + } + + onTravel(dt) { + this._sync(1); + this._syncParticles(); + // The light rides the orb, not the floor line the base class put it on. + this._orbPoint(this.position); + + this._orbFx(dt, 1); + this._wakeFx(); + this.ctx.shake.rumble(settings.balllightning.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.balllightning; + const g = settings.global; + const time = frame.uTime.value; + + this._impactAt = this.age; + this._orbPoint(_orb); + this.pointAt(1, _pos); + + /* the shell of ionised air the cage becomes */ + this.ctx.bursts.spawn(BurstMode.STORM, _orb, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.8, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.6, + displace: 0.55, + squash: 0.85, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring that snaps outward across the floor */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.7, + width: 0.05, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* a wide burn where it grounded out — the wake burn, scaled up */ + this.ctx.decals.spawn(DecalType.ARC, _pos, { + radius: c.wakeRadius * 3.4, + life: c.wakeLife * 1.8, + width: c.wakeBranches, + intensity: c.wakeIntensity * 1.6, + colorA: getColor(c.colorEmber), + colorB: getColor(c.colorArc) + }); + this.ctx.decals.spawn(DecalType.SCORCH, _pos, { + radius: c.scorchRadius * 3.0, + life: c.scorchLife * 1.4, + intensity: c.scorchIntensity * 1.4, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorEmber), + height: 0.015 + }); + + /* the cage letting go */ + _emit.position = _orb; + _emit.radius = 0.35; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.4).setY(0.7).normalize(); + _emit.speed = c.sparkSpeed * 2.6; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.2; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime * 1.6; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(Math.round(c.burstSparks * g.particleCount), _emit); + + _emit.radius = this.shell.radius; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed * 3.0; + _emit.spread = 1.0; + _emit.size = 0.1; + _emit.life = c.moteLifetime * 1.4; + this.motes.emit(Math.round(c.burstMotes * g.particleCount), _emit); + + _emit.position = _pos; + _emit.radius = c.scorchRadius * 2.4; + _emit.speed = c.debrisSpeed * 1.8; + _emit.spread = 0.85; + _emit.size = 0.14; + _emit.life = c.debrisLifetime * 1.3; + _emit.spin = 10; + this.debris.emit(Math.round(c.burstDebris * g.particleCount), _emit); + + _emit.speed = c.smokeSpeed * 2.4; + _emit.spread = 1.0; + _emit.size = 1.4; + _emit.life = c.smokeLifetime * 1.3; + _emit.spin = 0.5; + this.smoke.emit(Math.round(38 * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 24 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.impactLightPunch * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 through the discharge, then 1..2 as it dies. The shell's + // own expansion is driven by the first half and holds through the second, + // so the discharge opens and then goes out rather than opening twice. + this._discharge = saturate(t); + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + + this._sync(fade); + this._syncParticles(); + this._orbPoint(this.position); + this._orbFx(dt, fade * lerp(0.7, 0.2, saturate(t - 1))); + } + + onDestroy() { + this.paths.clear(); + this._discharge = 0; + this._impactAt = -1; + this._wakeDistance = 0; + } + + dispose() { + this.paths.dispose(); + this.shell.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/storm/ChainArcAbility.js b/src/abilities/storm/ChainArcAbility.js new file mode 100644 index 0000000..3218ba8 --- /dev/null +++ b/src/abilities/storm/ChainArcAbility.js @@ -0,0 +1,904 @@ +import { Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { FilamentPaths, filamentLook, MAX_CHAIN_NODES } from '../../vfx/FilamentPaths.js'; +import { ArcNetwork, arcNetworkParams } from '../../vfx/ArcNetwork.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange, hash11 } from '../../utils/math.js'; + +/** + * Samples along one filament. Ninety-six rather than the bolt's seventy-two + * because a polyline has **corners**, and a corner that lands between two + * samples rounds itself off — which is the one thing a chain must not do. The + * first pass at this ran at 72 and the hops read as a wobbly bolt; the fix was + * not more kink, it was more samples. + */ +const SAMPLES = 96; + +/** + * Filament ceiling across all three roles. Chain (3) + spike (2) + crawl (7) is + * the shipped total; the headroom is there so every one of those can be wound + * up in the editor without a role silently truncating. + */ +const CAPACITY = 40; + +/* Role slots on the shared strip. Structural roles first — `FilamentPaths` + * hands out the capacity in role order, so the chain can never be starved by a + * crawl somebody wound up to sixteen. */ +const ROLE_CHAIN = 0; +const ROLE_SPIKE = 1; +const ROLE_CRAWL = 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _target = new Vector3(); +const _from = new Vector3(); +const _to = new Vector3(); +const _pole = new Vector3(); +const _pos = new Vector3(); +const _dir = new Vector3(); +const _emit = {}; + +/** + * CHAIN ARC — a discharge that hops down the aimed line instead of travelling it. + * + * **The trick is graph pathing.** Every other line cast in the sandbox moves a + * front at metres per second and draws the effect behind it. This one does not + * travel at all: five to nine nodes are scattered off the line, and the + * discharge is at the first, and then at the second, and the space in between + * was never crossed so much as *skipped*. One segment is lit at a time, with a + * configurable hold and a guttering overlap so the passed hops linger dim + * behind the front, and a burst, a spark spray and a small floor burn fire at + * each node as it is reached. The last node gets the full impact treatment. + * + * **Re-rolling the scatter re-routes a chain that is already in the air.** That + * is the roster's actual ask, and it falls straight out of obeying I1: the + * graph is stored as unitless fractions and `ArcNetwork` resolves them against + * live `scatter` / `lift` / `nodes` every frame, so dragging any of those bends + * a standing discharge. `route` goes further — it re-rolls the fractions + * outright via `reseed()`, which leaves the hop clock alone, so the chain takes + * a different path from where it currently is rather than starting over. + * + * **Three roles, one strip, two draw calls.** `ArcNetwork` is constructed with + * `{ paths, role }` against a `FilamentPaths` this class owns, so the chain + * (`CHAIN`), the earthing spike (`LINE`) and the crawl that opens at the last + * node (`MEANDER`) all come out of the same instanced ribbon — drawn once as a + * halo and once as a core. Six draw calls became two, and the halo stays + * attached to every kink because it is real ribbon rather than bloom. + * + * Two things about the earthing spike are worth writing down, because both were + * wrong first. It uses a single `LINE` role, so only one node earths at a time + * — the first version tried to give every node its own spike and there is no + * way to do that in one role, and four roles is the uniform budget. And whether + * the current node earths is decided *per frame*, by testing a per-node dice + * roll against the live `earthChance`, not once when the node fires: winding + * the slider up with the clock stopped has to make the spike appear, or it is + * not a slider. + * + * The ground marks follow the same logic as the hop: **a burn under each node, + * not a continuous line.** The bolt was never between the nodes and the floor + * should not claim it was. Laying a burn per metre of front travel, the way + * Storm Lance does, drew a dotted line down the aim arrow and threw the whole + * read away. + * + * What a cast captures: one seed, and three timestamps (the node that last lit, + * and the moment the last node arrived). Everything with a unit is re-resolved + * from `settings.chainarc` each frame, on a zero-length frame included. + */ +export class ChainArcAbility extends Ability { + constructor(context) { + super('chainarc', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.paths = new FilamentPaths(this.group, { + samples: SAMPLES, + capacity: CAPACITY, + renderOrder: 11 + }); + + // The network draws *through* the strip above rather than building its own. + // This is the single cheapest saving in the tech library and the reason the + // whole ability is two draw calls. + this.net = new ArcNetwork(this.group, { paths: this.paths, role: ROLE_CHAIN }); + this.spikeRole = this.paths.role(ROLE_SPIKE); + this.crawlRole = this.paths.role(ROLE_CRAWL); + + // Bound once, here. A closure rebuilt per frame is exactly the allocation + // invariant I3 exists to forbid, and `onNode` is read every frame. + this.net.onNode = this._nodeFired.bind(this); + + /** + * One scratch object handed to `ArcNetwork.update()` and, through it, + * straight to `FilamentPaths.sync()` — the params block doubles as the + * look, so a chained ability keeps one scratch rather than two. Built once + * with every key it will ever hold, so the shape never changes under V8. + */ + this._p = Object.assign(arcNetworkParams(), filamentLook()); + + /** Re-rolled per cast so no two chains take the same route. */ + this._seed = 0; + /** The composite seed currently applied, so `route` only reseeds on change. */ + this._appliedRoute = Number.NaN; + /** Index of the node that lit most recently, and when. Events, not metres. */ + this._earthNode = -1; + this._earthAt = -1; + /** When the last node arrived. −1 until it has. */ + this._impactAt = -1; + } + + createParticles() { + const particles = this.ctx.particles; + + // Sparks: velocity-stretched streaks under gravity, thrown at every node + // and shed along the lit hops in between. + this.sparks = particles.get('chainarc.sparks', { + capacity: 3200, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.4; + this.sparks.uniforms.uEndSize.value = 0.25; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.45; + + // The slow ionised motes hanging in the air where the chain has been. These + // are what leaves the *shape* of the route visible after the hop has passed. + this.motes = particles.get('chainarc.motes', { + capacity: 2000, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.6; + this.motes.uniforms.uEndSize.value = 0.15; + this.motes.uniforms.uSizeIn.value = 0.06; + this.motes.uniforms.uFadeIn.value = 0.08; + this.motes.uniforms.uFadeOut.value = 0.4; + + // Haze off the burnt floor. Non-additive so it genuinely occludes. + this.smoke = particles.get('chainarc.smoke', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.smoke.uniforms.uDrag.value = 1.8; + this.smoke.uniforms.uEndSize.value = 3.0; + this.smoke.uniforms.uSizeIn.value = 0.12; + this.smoke.uniforms.uFadeIn.value = 0.16; + this.smoke.uniforms.uFadeOut.value = 0.3; + + // Chips blown off the floor under a node. + this.debris = particles.get('chainarc.debris', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.debris.uniforms.uDrag.value = 0.25; + this.debris.uniforms.uEndSize.value = 0.8; + this.debris.uniforms.uFadeOut.value = 0.7; + + this.sparkEmitter = new RateEmitter(); + this.moteEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.paths.liveCount; + } + + get impactDuration() { + return Math.max(0.05, settings.chainarc.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.chainarc.fadeTime); + } + + /** A chain gutters where ice glints — the same hard, quantised stutter the bolt uses. */ + lightShimmer() { + const c = settings.chainarc; + const step = Math.floor(this.age * Math.max(1, c.lightFlickerSpeed)); + const noise = Math.abs(Math.sin(step * 127.1) * 43758.5453) % 1; + return 1 - saturate(c.lightFlicker) * noise; + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Node 0: where the chain leaves the caster's hand, in world space. */ + _handPoint(out) { + const c = settings.chainarc; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** The last node: the thing that was aimed at. */ + _impactPoint(out) { + this.pointAt(1, out); + out.y = settings.chainarc.endHeight; + return out; + } + + /** + * Where the discharge currently *is* — the node the cursor has most recently + * reached, re-derived through `nodePoint()` rather than remembered. + * + * This is what the dynamic light rides, and it is why the light hops rather + * than sliding: a chain whose light travels smoothly down the line puts the + * travel back in and undoes the entire effect. + */ + _frontPoint(out) { + const i = clamp(Math.floor(this.net.cursor), 0, this.net.nodeCount - 1); + return this.net.nodePoint(i, out); + } + + /** + * Seconds one hop takes, resolved fresh. + * + * Expressed as hop length over `speed` rather than as a `hopTime` slider: see + * the note in `config/abilities/chainarc.js`. Dropping the node count then + * lengthens each hop *and* slows it, which is what propagation through a + * medium actually does, and it keeps the total flight time tracking the cast + * length instead of the node count. + */ + _hopTime(c, g) { + const segments = Math.max(1, clamp(Math.round(c.nodes), 2, MAX_CHAIN_NODES) - 1); + const speed = Math.max(0.01, c.speed * g.speed); + return Math.max(1e-4, (this.length / segments / speed) * Math.max(0.05, c.hopStretch)); + } + + /** Does node `i` stab down to the floor? A fixed dice roll against a live threshold. */ + _earths(i, c) { + if (i < 0) return false; + return hash11(this._seed * 7.31 + i * 13.71) < saturate(c.earthChance); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Refill the single params/look scratch from live settings. + * + * Every line here is a re-resolution, not a copy of something captured. The + * five `global.*` multipliers at the bottom are what make the master sliders + * bite on the ribbon; `FilamentPaths.sync()` folds them into the kinks and the + * output, once. + */ + _fillParams() { + const c = settings.chainarc; + const g = settings.global; + const p = this._p; + + /* --- the graph --- */ + p.nodes = c.nodes; + p.filaments = c.filaments; + p.scatter = c.scatter; + p.lift = c.lift; + p.alongJitter = c.alongJitter; + p.sag = c.hopSag; + p.bow = c.hopBow; + p.floorY = c.floorY; + + /* --- the clock --- */ + p.hopTime = this._hopTime(c, g); + p.hold = c.hold; + p.overlap = c.overlap; + p.tip = c.tip; + + /* --- the chain role's share of the shared look --- */ + p.kink = c.kink; + p.chainWidth = c.chainWidth; + p.dim = c.dim; + p.groundDamp = c.groundDamp; + + /* --- the ribbon, shared by all three roles --- */ + p.width = c.width; + p.glowWidth = c.glowWidth; + p.glowOpacity = c.glowOpacity; + p.jitter = c.jitter; + p.jitterScale = c.jitterScale; + p.octaves = c.octaves; + p.jitterFalloff = c.jitterFalloff; + p.crawl = c.crawl; + p.pinch = c.pinch; + p.restrike = c.restrike; + p.flicker = c.flicker; + p.flickerSpeed = c.flickerSpeed; + p.strandFlash = c.strandFlash; + p.coreSharp = c.coreSharp; + p.glowFalloff = c.glowFalloff; + p.softFade = c.softFade; + p.opacity = c.opacity; + p.glow = c.glow; + p.colorCore = c.colorCore; + p.colorInner = c.colorInner; + p.colorOuter = c.colorOuter; + p.colorHalo = c.colorHalo; + + /* --- the master sliders --- */ + p.randomness = g.randomness; + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + p.glowScale = g.glow; + } + + /** + * One whole frame of the discharge: clock, graph, hooks, the two secondary + * roles, and the sync. + * + * The order matters and is not obvious. `ArcNetwork.update()` ends by calling + * `FilamentPaths.sync()`, which is where the role counts pass through the + * capacity clamp and become `geometry.instanceCount`. The spike and the crawl + * cannot be configured *before* that call because both need node positions the + * network has not resolved yet, so they are configured after it and the strip + * is synced a second time. A second `sync()` is a few dozen uniform writes and + * no allocation; getting it wrong is a role that draws one frame late, which + * on a 0.3 s cast is visible. + * + * @param {number} dt seconds — `ArcNetwork` takes dt, not now + * @param {number} fade 1 while lit, ramping to 0 as it gutters out + */ + _step(dt, fade) { + const c = settings.chainarc; + + this._fillParams(); + this._reroute(c); + + this._handPoint(this.net.from); + this._impactPoint(this.net.to); + this.net.update(dt, this._p, fade); + + this._syncSpike(c, fade); + this._syncCrawl(c, fade); + this.paths.sync(this._p, fade, this._seed); + } + + /** + * The live re-route. + * + * `route` is a plain integer dial and the whole of the roster's "re-rolling + * the node scatter live re-routes a chain already in the air". `reseed()` + * touches the dice and nothing else — the hop cursor keeps its place — so the + * discharge changes course from where it currently is rather than restarting. + * Compared rather than applied unconditionally because reseeding every frame + * would be twelve pointless hashes a frame for the ninety-nine frames in a + * hundred where the dial has not moved. + */ + _reroute(c) { + const wanted = this._seed + c.route; + if (wanted === this._appliedRoute) return; + this.net.reseed(wanted); + this._appliedRoute = wanted; + } + + /** + * The earthing spike: a short `LINE` role from the node currently lit down to + * the floor beneath it. + * + * Retired outright — `count = 0` — on every frame it is not wanted, which is + * how a role vanishes without costing anything. The node position comes from + * `nodePoint()`, which mirrors the vertex shader's own arithmetic, so the + * spike leaves the chain exactly where the chain is rather than near it. + */ + _syncSpike(c, fade) { + const role = this.spikeRole; + if (this._earthNode < 0 || !this._earths(this._earthNode, c)) return void role.retire(); + + const k = 1 - saturate((this.age - this._earthAt) / Math.max(0.02, c.spikeLife)); + if (k <= 0) return void role.retire(); + + // Clamped against the *live* node count: winding `nodes` down mid-cast can + // leave the remembered index past the end of the graph, and a spike hanging + // off a node that no longer exists is a stale position, not a crash. + this.net.nodePoint(Math.min(this._earthNode, this.net.nodeCount - 1), _from); + // Guard the degenerate case where a node has sunk to the clamp height: a + // zero-length LINE gives the shader a null tangent and the ribbon folds. + _to.set(_from.x, Math.min(c.spikeFloor, _from.y - 0.05), _from.z); + + role.count = Math.max(1, Math.round(c.spikeStrands)); + role.line( + _from, + _to, + c.spikeSag, + c.spikeNear, + c.spikeSpread, + c.spikeCurve, + c.spikeTwist, + c.spikeTwistSpeed, + c.spikeConverge + ); + role.style(c.spikeKink, c.spikeWidth, c.spikeDim * k * fade, c.spikeGroundDamp); + role.ends(0, 1, 0, 1); + role.draw(2, 0.12, c.spikeFloor, c.spikeTipGlow); + } + + /** + * The crawl at the last node: a `MEANDER` role running out across the floor. + * + * `MEANDER` rather than a radial fan because a discharge that has chosen a + * direction keeps going that way — the veer is a per-filament constant, not + * noise — and a fan of straight spokes out of one point reads as a firework. + * The growth is `draw(progress, …)`, so the tendrils are *drawn* outward + * rather than scaled, and `crawlGrow` stays a live slider. + */ + _syncCrawl(c, fade) { + const role = this.crawlRole; + if (this._impactAt < 0) return void role.retire(); + + const since = this.age - this._impactAt; + const k = 1 - saturate(since / Math.max(0.05, c.crawlLife)); + if (k <= 0 || c.crawlStrands < 1) return void role.retire(); + + this.pointAt(1, _from); + _from.y = c.crawlFloor; + _pole.set(_from.x, _from.y + 1, _from.z); + + const grow = saturate(since / Math.max(0.02, c.crawlGrow)); + + role.count = Math.round(c.crawlStrands); + role.meander( + _from, + _pole, + c.crawlInner, + c.crawlReach, + c.crawlCurve, + c.crawlWander, + c.crawlArch, + c.crawlHug, + c.crawlSpin + ); + role.style(c.crawlKink, c.crawlWidth, c.crawlDim * k * fade, c.crawlGroundDamp); + role.ends(0, 1, 0, 1); + role.draw(grow + c.crawlTip, c.crawlTip, c.crawlFloor, c.crawlTipGlow); + } + + /* ------------------------------------------------------------------ */ + /* The node hook */ + /* ------------------------------------------------------------------ */ + + /** + * Fired exactly once per node, on the frame the cursor reaches it. + * + * Node 0 is the caster's hand, so the muzzle flash is not a special case in + * this ability — it is simply the first node lighting, which is both less code + * and more honest about what is happening. + * + * `position` is `ArcNetwork`'s module scratch. It is read here and never kept. + * + * @param {number} index + * @param {THREE.Vector3} position resolved world position of the node + * @param {number} count total nodes this frame + */ + _nodeFired(index, position, count) { + const c = settings.chainarc; + const g = settings.global; + const time = frame.uTime.value; + + this._earthNode = index; + this._earthAt = this.age; + + /* --- the shell of ionised air --- */ + this.ctx.bursts.spawn(BurstMode.STORM, position, { + radius: c.nodeBurstSize * 0.2, + endRadius: c.nodeBurstSize * g.explosionIntensity, + life: 0.28, + intensity: c.nodeBurstIntensity, + opacity: 0.9, + fresnel: 1.5, + displace: 0.5, + colorA: getColor(c.colorNodeA), + colorB: getColor(c.colorNodeB), + colorC: getColor(c.colorNodeC) + }); + + /* --- the spark spray, thrown along the heading of the next hop --- */ + _emit.position = position; + _emit.radius = 0.14; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.4).setY(0.6).normalize(); + _emit.speed = c.sparkSpeed * 1.4; + _emit.speedVariance = 0.85; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(Math.round(c.nodeSparks * g.particleCount), _emit); + + /* --- the burn under this node, and only under this node --- */ + _pos.set(position.x, 0, position.z); + + this.ctx.decals.spawn(DecalType.ARC, _pos, { + radius: c.nodeArcRadius * randRange(0.7, 1.2), + life: c.nodeArcLife, + width: c.nodeArcBranches, + intensity: c.nodeArcIntensity, + colorA: getColor(c.colorEmber), + colorB: getColor(c.colorArc) + }); + this.ctx.decals.spawn(DecalType.SCORCH, _pos, { + radius: c.nodeScorchRadius * randRange(0.7, 1.3), + life: c.nodeScorchLife, + intensity: c.nodeScorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorEmber), + height: 0.015 + }); + + /* --- chips off the floor beneath it --- */ + _emit.position = _pos; + _emit.radius = c.nodeScorchRadius * 1.6; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.debrisSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.8; + _emit.size = 0.1; + _emit.life = c.debrisLifetime; + _emit.spin = 8; + this.debris.emit(Math.round(c.nodeDebris * g.particleCount), _emit); + + // A punch per node is what makes the hop read in the lighting as well as in + // the geometry — the last node gets a bigger one from `onImpact`. + const last = index >= count - 1; + this.lightBoost = c.nodeLightPunch * (last ? 1.6 : 1) * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Shed along the lit chain */ + /* ------------------------------------------------------------------ */ + + /** + * Sparks, motes and smoke shed by the part of the chain that is currently lit. + * + * Emission points come from `nodePoint()` on a node the cursor has already + * passed, jittered by a fraction of the live `scatter`, so the shed material + * sits on the route rather than on the aim line the route departs from. + * + * @param {number} scale 0..1 — thinned once the chain is only holding + */ + _chainFx(dt, scale) { + const c = settings.chainarc; + const g = settings.global; + const time = frame.uTime.value; + const lit = clamp(Math.floor(this.net.cursor), 0, this.net.nodeCount - 1); + + let sparkCount = Math.round(this.sparkEmitter.tick(dt, c.sparkRate * scale) * g.particleCount); + if (sparkCount > 0) { + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.4).setY(0.55).normalize(); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.15; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + + // Split the frame's sparks over several nodes. Firing them all from one + // point makes every batch read as a starburst pinned to that node, which + // is very obviously wrong the moment you look at it — the same lesson the + // bolt learnt along its length. + const batches = Math.min(sparkCount, lit + 1); + const per = Math.ceil(sparkCount / Math.max(1, batches)); + while (sparkCount > 0) { + const node = Math.floor(Math.random() * (lit + 1)); + this.net.nodePoint(node, _pos); + _emit.position = _pos; + _emit.radius = c.scatter * 0.22 + 0.05; + this.sparks.emit(Math.min(per, sparkCount), _emit); + sparkCount -= per; + } + } + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + this.net.nodePoint(Math.floor(Math.random() * (lit + 1)), _pos); + _emit.position = _pos; + _emit.radius = c.scatter * 0.35 + 0.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.size = 0.08; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate * scale) * g.particleCount); + if (smokeCount > 0) { + // Smoke comes off the *floor* under a node, not off the chain — it is the + // burn smouldering, and there is no burn between the nodes. + this.net.nodePoint(Math.floor(Math.random() * (lit + 1)), _pos); + _pos.y = 0.15; + _emit.position = _pos; + _emit.radius = c.nodeScorchRadius * 2.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + } + + /** Push the live gradients and scales into all four particle systems. */ + _syncParticles() { + const c = settings.chainarc; + const g = settings.global; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.glow * 0.6 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.25 * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 0.9 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.35 * g.turbulence; + + this.debris.setGradient( + getColor(c.colorDebrisA), + getColor(c.colorDebrisB), + getColor(c.colorDebrisC), + getColor(c.colorDebrisD) + ); + this.debris.uniforms.uGravity.value.set(0, c.debrisGravity, 0); + this.debris.uniforms.uSizeScale.value = c.debrisSize * g.particleSize * 7; + this.debris.uniforms.uLifeScale.value = c.debrisLifetime * 0.5 * g.particleLifetime; + this.debris.uniforms.uSpeedScale.value = g.particleSpeed; + this.debris.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sparkEmitter.reset(); + this.moteEmitter.reset(); + this.smokeEmitter.reset(); + + // The one thing a cast captures. Everything else is a timestamp. + this._seed = Math.random() * 100; + this._appliedRoute = Number.NaN; + this._earthNode = -1; + this._earthAt = -1; + this._impactAt = -1; + + this.net.reset(this._seed); + this._syncParticles(); + // A zero-length first frame, which lights node 0 and therefore fires the + // hand flash through the ordinary node hook. + this._step(0, 1); + this._frontPoint(this.position); + + const c = settings.chainarc; + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * settings.global.explosionIntensity); + } + + /** + * The chain does not travel, so the base class's metres-per-second front is + * replaced outright. + * + * `u` becomes the graph's own progress — hops completed over hops total — and + * the frame the last node lights is the frame `advance()` reports the end of + * the line. Everything downstream (the phase machine, the HUD's progress + * readout, the camera's framing point) then works unchanged, which is the + * whole reason for expressing it this way rather than overriding `update()`. + */ + advance(dt) { + this._step(dt, 1); + const previous = this.u; + this.u = this.net.progress; + this.front = this.u * this.length; + this._frontPoint(this.position); + return this.u >= 1 && previous < 1; + } + + onTravel(dt) { + // `advance()` has already stepped the network this frame. + this._syncParticles(); + this._chainFx(dt, 1); + this.ctx.shake.rumble(settings.chainarc.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.chainarc; + const g = settings.global; + const time = frame.uTime.value; + + this._impactAt = this.age; + this._impactPoint(_target); + this.pointAt(1, _pos); + + /* the shell at the last node */ + this.ctx.bursts.spawn(BurstMode.STORM, _target, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.7, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 1.6, + displace: 0.6, + squash: 0.8, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring that snaps outward across the floor */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.6, + width: 0.05, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* a wide burn where it grounded out — the node burn, scaled up */ + this.ctx.decals.spawn(DecalType.ARC, _pos, { + radius: c.nodeArcRadius * 2.4, + life: c.nodeArcLife * 1.6, + width: c.nodeArcBranches, + intensity: c.nodeArcIntensity, + colorA: getColor(c.colorEmber), + colorB: getColor(c.colorArc) + }); + this.ctx.decals.spawn(DecalType.SCORCH, _pos, { + radius: c.nodeScorchRadius * 2.6, + life: c.nodeScorchLife * 1.4, + intensity: c.nodeScorchIntensity * 1.3, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorEmber), + height: 0.015 + }); + + /* sparks and chips blown out of the strike */ + _emit.position = _target; + _emit.radius = 0.3; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.5).setY(0.7).normalize(); + _emit.speed = c.sparkSpeed * 2.2; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.22; + _emit.sizeVariance = 0.8; + _emit.life = c.sparkLifetime * 1.5; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(Math.round(c.burstSparks * g.particleCount), _emit); + + _emit.position = _pos; + _emit.radius = c.nodeScorchRadius * 2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.debrisSpeed * 1.8; + _emit.spread = 0.85; + _emit.size = 0.14; + _emit.life = c.debrisLifetime * 1.3; + _emit.spin = 10; + this.debris.emit(Math.round(c.burstDebris * g.particleCount), _emit); + + _emit.speed = c.smokeSpeed * 2.2; + _emit.spread = 1.0; + _emit.size = 1.5; + _emit.life = c.smokeLifetime * 1.3; + _emit.spin = 0.5; + this.smoke.emit(Math.round(40 * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 26 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + } + + onFade(dt, t) { + // `t` runs 0..1 while the chain holds, then 1..2 while it gutters out. The + // blow-out is cubic so it hangs on and then lets go, rather than dimming. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._step(dt, fade); + this._syncParticles(); + this._frontPoint(this.position); + // Thinned as it dies. A guttering chain still throws sparks; it just throws + // fewer of them, and `lerp` here keeps the two rates one number apart. + this._chainFx(dt, fade * lerp(0.5, 0.25, saturate(t - 1))); + } + + onDestroy() { + this.net.clear(); + this.spikeRole.retire(); + this.crawlRole.retire(); + this.paths.clear(); + this._earthNode = -1; + this._impactAt = -1; + } + + dispose() { + // The network does not own the strip, so disposing the strip is enough. + this.paths.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/storm/RailcoilAbility.js b/src/abilities/storm/RailcoilAbility.js new file mode 100644 index 0000000..6cf4dce --- /dev/null +++ b/src/abilities/storm/RailcoilAbility.js @@ -0,0 +1,898 @@ +import { Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { Tube, TubePath } from '../../vfx/Tube.js'; +import { FilamentPaths } from '../../vfx/FilamentPaths.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, hash11, Easing, randRange } from '../../utils/math.js'; + +/** Samples along one filament. A 26 m segment at 80 nodes is a node every 32 cm. */ +const FILAMENT_SAMPLES = 80; +/** Ceiling on filaments across all four roles. 4 coils × 3 + 4 ring-down = 16 at worst. */ +const FILAMENT_CAPACITY = 40; +/** Role 0 is the coil that survives the shot; roles 1..3 are channel segments. */ +const RINGDOWN_ROLE = 0; +/** How many roles are left for the break-up. `segments` clamps against this. */ +const MAX_SEGMENTS = 3; + +const _pos = new Vector3(); +const _dir = new Vector3(); +const _from = new Vector3(); +const _to = new Vector3(); +const _emit = {}; + +/** + * The canonical look handed to `FilamentPaths#sync` — filled from + * `settings.railcoil` every frame, never kept. Module scope because an object + * literal per frame is exactly the allocation I3 forbids. + */ +const _look = {}; + +/** + * RAILCOIL — the shot that is already there. + * + * **THE TRICK — zero travel time.** Every other line cast in the sandbox is a + * front racing down the aim line; this one refuses to have a front at all. + * `advance()` is overridden to hold `u` at 0 for the whole wind-up while four + * helical coils collapse inward along the barrel, and on the frame they meet it + * writes `u = 1` in a single step. There is no interpolation, no ease, and + * `dt` is never read — a 26 m channel exists in the frame after a 0 m one, and + * the eye reads that as a railgun rather than as a fast beam. Everything after + * that is **decay**. + * + * The decay is the other half of the slot, and it is three things at once over + * about a second and a half: + * + * 1. **cooling** — the hot palette on the tube and the filaments is blended + * toward a cold one on `coolCurve`, white → blue → nothing. Both consumers + * read the *same* blend, deliberately: they are one channel drawn as a core + * and its threads, and letting them cool at different rates immediately + * reads as two effects that happen to overlap; + * 2. **sagging** — the channel droops under its own weight. The segments are + * placed on the sagged curve rather than each sagging locally, so the break + * stays part of one line; + * 3. **breaking** — the tube's width collapses early, leaving the filaments, + * and those are drawn as up to three *disconnected* `LINE` roles spanning + * consecutive thirds of the span. Each one is eaten from one end by its own + * `draw(progress)` on a clock offset by a noise sample taken at its own + * position along `t`, so the channel comes apart in a different order every + * cast and always in an order that reads as physical rather than as a + * sequence. The eaten end flips with the same noise, so segments do not all + * retreat the same way. + * + * The first version dissolved the channel with a single global alpha and a + * fading tube. It looked like somebody turning a dimmer down. The disconnection + * is what makes it read as *ionisation recombining*, and getting it needed + * nothing new in the tech library: `role.draw()` already carries a front and + * `role.style()` already carries a per-role dim, and three roles is enough, + * because the eye counts "broken" long before it counts "into how many". + * + * **What a cast captures.** One seed, and two booleans that mark events (the + * shot has been fired; the front has been handed to the impact phase). Not one + * metre and not one second: the decay clock is + * `(age − windUp − flashHold) / (lifetime + fadeTime)`, re-derived every frame, + * so dragging `windUp` while paused slides the entire decay through itself. + */ +export class RailcoilAbility extends Ability { + constructor(context) { + super('railcoil', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + // The channel: three draw calls, and the only part of the ability with a + // solid silhouette. STRAIGHT because a rail shot is a chord and nothing + // else; the sag lives on the filaments, which is where it can be seen. + this.channel = new Tube({ + path: TubePath.STRAIGHT, + prefix: 'channel', + nodes: 72, + sides: 18, + renderOrder: 11 + }); + this.group.add(this.channel.group); + // Built once so the cooling pass does not walk an object every frame. + this._channelLayers = [ + this.channel.materials.core, + this.channel.materials.sheath, + this.channel.materials.halo + ]; + + // Two more draw calls, and both beats live in them: four collapsing coils + // during the wind-up, then one ring-down coil and three channel segments. + // The role slots are re-filled every frame, so the same four uniform blocks + // are a helix in one beat and a broken line in the next — which is the + // whole reason this module has roles rather than instances. + this.paths = new FilamentPaths(this.group, { + samples: FILAMENT_SAMPLES, + capacity: FILAMENT_CAPACITY, + renderOrder: 12 + }); + + /* --- what a cast captures: one dice roll and two event flags --- */ + /** Re-rolled per cast so two shots do not break up in the same order. */ + this._seed = 0; + /** The one-shot at the muzzle has run. */ + this._fired = false; + /** `advance()` has handed the front to the impact phase. */ + this._handedOver = false; + + this._state = { + origin: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 0, + fade: 1, + widthFade: 1, + seed: 0, + time: 0 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Sparks: rail metal, thrown out of the muzzle and shed off the channel + // while it is still hot enough to be doing damage to itself. + this.sparks = particles.get('railcoil.sparks', { + capacity: 3000, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.5; + this.sparks.uniforms.uEndSize.value = 0.22; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.45; + + // The ionised air the channel leaves behind. These outlive the channel by + // design: what is left when the filaments have gone is a corridor of motes, + // which is the only evidence that anything was there. + this.motes = particles.get('railcoil.motes', { + capacity: 2600, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.7; + this.motes.uniforms.uEndSize.value = 0.18; + this.motes.uniforms.uSizeIn.value = 0.05; + this.motes.uniforms.uFadeIn.value = 0.07; + this.motes.uniforms.uFadeOut.value = 0.4; + + this.smoke = particles.get('railcoil.smoke', { + capacity: 1400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.smoke.uniforms.uDrag.value = 1.8; + this.smoke.uniforms.uEndSize.value = 3.0; + this.smoke.uniforms.uSizeIn.value = 0.12; + this.smoke.uniforms.uFadeIn.value = 0.18; + this.smoke.uniforms.uFadeOut.value = 0.32; + + this.sparkEmitter = new RateEmitter(); + this.moteEmitter = new RateEmitter(); + this.smokeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — every one of these is derived, none of it is stored */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + // Filaments are drawn twice (halo and core); the channel is three layers. + return this.paths.liveCount * 2 + this.channel.drawCalls; + } + + get impactDuration() { + return Math.max(0.05, settings.railcoil.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.railcoil.fadeTime); + } + + /** Seconds the coils take to close. */ + get windUp() { + return Math.max(0.01, settings.railcoil.windUp); + } + + /** How far the coils have collapsed, 0..1. */ + get charge() { + return saturate(this.age / this.windUp); + } + + /** True once the coils have met and the shot exists. */ + get armed() { + return this.age >= this.windUp; + } + + /** + * How far through the decay the channel is, 0..1. + * + * Derived from the live sliders and the age, with no timestamp anywhere in + * it: the shot begins when the coils meet (`windUp`), stands at full white + * for `flashHold`, and then dies over `lifetime + fadeTime` — the same two + * numbers that set the phase durations, so the ramp and the phases can never + * drift apart. Drag `windUp` with the clock stopped and the whole decay + * slides, which is as direct a demonstration of I1 as this project has. + */ + get decay() { + const c = settings.railcoil; + const span = Math.max(0.05, c.lifetime * settings.global.lifetime + c.fadeTime); + return saturate((this.age - this.windUp - Math.max(0, c.flashHold)) / span); + } + + /** + * The channel gutters harder the deader it is. A steady light over a cooling + * plasma reads as a lamp somebody left on. + */ + lightShimmer() { + const c = settings.railcoil; + const depth = saturate(c.lightFlicker) * (0.3 + 0.7 * this.decay); + const step = Math.floor(this.age * Math.max(1, c.lightFlickerSpeed)); + const noise = hash11(step * 1.7 + 3.1); + return 1 - depth * noise; + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The breech, at the caster's hip. */ + _breechPoint(out) { + const c = settings.railcoil; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** The muzzle — the far end of the barrel, and where the coils meet. */ + _muzzlePoint(out) { + const c = settings.railcoil; + this._breechPoint(out); + return out.addScaledVector(this.direction, c.barrelLength); + } + + /** A point `f` of the way along the barrel, 0 = breech, 1 = muzzle. */ + _barrelPoint(f, out) { + const c = settings.railcoil; + this._breechPoint(out); + return out.addScaledVector(this.direction, c.barrelLength * f); + } + + /** + * A point `s` of the way along the shot itself, muzzle → target, with the + * sag applied. + * + * The droop is one arch over the whole span rather than one per segment: a + * segment that sags in isolation is a sausage, and three of them in a row are + * a string of sausages. Placing the endpoints on the shared curve and giving + * each role only the *chord deviation* of its own stretch keeps the break-up + * part of a single sagging line. + */ + _shotPoint(s, droop, out) { + const c = settings.railcoil; + const t = saturate(s); + this._muzzlePoint(out); + // The far end of the shot is the end of the cast line, not the end of the + // barrel, so the axis is muzzle → target and `t` runs across that. + _dir.copy(this.origin).addScaledVector(this.direction, this.length).sub(out); + _dir.y = c.endHeight - out.y; + out.addScaledVector(_dir, t); + out.y += droop * Math.sin(t * Math.PI); + return out; + } + + /** Metres the dead channel has drooped by, at this point in the decay. */ + _droop() { + const c = settings.railcoil; + return -c.channelSag * Math.pow(this.decay, Math.max(0.05, c.channelSagCurve)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + /** + * The trick, in nine lines: **`dt` is not read.** + * + * The front is at the caster or at the target, with nothing in between and no + * frame in which it is anywhere else. The hand-over to the impact phase is + * held back by `flashHold` so the full-length shot gets one beat of TRAVEL to + * itself at full white before the decay begins — and because the hand-over is + * a pure function of `age` rather than an accumulated step, a zero-length + * frame cannot advance it. That matters more than it sounds: the pause test + * drives this ability with `dt = 0` for hundreds of frames, and an edge + * detector fed by `dt` would fire the impact somewhere inside that. + */ + advance(_dt) { + const c = this.config; + const armed = this.age >= Math.max(0.01, c.windUp); + + this.front = armed ? this.length : 0; + this.u = armed ? 1 : 0; + this.pointAt(this.u, this.position); + + const done = this.age >= Math.max(0.01, c.windUp) + Math.max(0, c.flashHold); + const handOver = done && !this._handedOver; + this._handedOver = done; + return handOver; + } + + onSpawn() { + this.sparkEmitter.reset(); + this.moteEmitter.reset(); + this.smokeEmitter.reset(); + + this._seed = Math.random() * 100; + this._fired = false; + this._handedOver = false; + + this.paths.clear(); + this._sync(1); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Everything the ability draws, re-resolved and pushed. One entry point, so + * the wind-up and the decay cannot get out of step with each other. + * + * @param {number} phaseFade 1 while the cast is lit, ramping to 0 at the end + */ + _sync(phaseFade) { + const c = settings.railcoil; + const g = settings.global; + const decay = this.decay; + // The cooling ramp. One number, read by the tube and the filaments alike. + const cool = Math.pow(decay, Math.max(0.05, c.coolCurve)); + + this._syncChannel(c, g, phaseFade, decay, cool); + this._syncFilaments(c, g, phaseFade, decay, cool); + this._syncParticleLook(c, g); + } + + /** The tube: drawn only once the shot exists, and collapsing as it dies. */ + _syncChannel(c, g, phaseFade, decay, cool) { + const state = this._state; + + this._muzzlePoint(state.origin); + // The tube is straight, so its far end goes on the *sagged* curve at t = 1; + // by the time the sag is deep enough to matter the tube is a thread. + this._shotPoint(1, this._droop(), state.target); + state.side.copy(this.side); + state.progress = this.armed ? 1 : 0; + state.widthFade = lerp(1, 0.08, saturate(decay / Math.max(0.02, c.channelCollapse))); + state.fade = phaseFade * (1 - Easing.inQuad(decay)); + state.seed = this._seed; + state.time = this.age; + + this.channel.sync(c, state, g); + for (const material of this._channelLayers) this._cool(material.uniforms, c, cool); + } + + /** + * Blend a set of four colour uniforms from the hot palette toward the cold + * one, in place, immediately after whoever owns them wrote the hot values. + * + * The obvious alternative — hand `sync()` a proxy settings object carrying + * pre-blended colours — needs a `#rrggbb` string per layer per frame, and + * `getColor()` memoises by string, so a fresh string every frame allocates a + * `Color` *and* leaks it into a cache that never shrinks. Two allocations per + * frame per layer to avoid four `Color.lerp` calls is the wrong trade, and + * the cache leak took a while to spot the first time. + */ + _cool(uniforms, c, k) { + uniforms.uColorCore.value.lerp(getColor(c.colorCoolCore), k); + uniforms.uColorInner.value.lerp(getColor(c.colorCoolInner), k); + uniforms.uColorOuter.value.lerp(getColor(c.colorCoolOuter), k); + uniforms.uColorHalo.value.lerp(getColor(c.colorCoolHalo), k); + } + + /** Fill the canonical look the shared strip reads. Never kept. */ + _fillLook(c, g) { + _look.width = c.width; + _look.glowWidth = c.glowWidth; + _look.glowOpacity = c.glowOpacity; + _look.jitter = c.jitter; + _look.jitterScale = c.jitterScale; + _look.octaves = c.octaves; + _look.jitterFalloff = c.jitterFalloff; + _look.crawl = c.crawl; + _look.pinch = c.pinch; + _look.restrike = c.restrike; + _look.flicker = c.flicker; + _look.flickerSpeed = c.flickerSpeed; + _look.strandFlash = c.strandFlash; + _look.coreSharp = c.coreSharp; + _look.glowFalloff = c.glowFalloff; + _look.softFade = c.softFade; + _look.opacity = c.opacity; + _look.glow = c.glow; + // The hot palette. The cold end is applied to the uniforms afterwards, for + // the reason given on `_cool`. + _look.colorCore = c.channelColorCore; + _look.colorInner = c.channelColorInner; + _look.colorOuter = c.channelColorOuter; + _look.colorHalo = c.channelColorHalo; + _look.randomness = g.randomness; + _look.noiseStrength = g.noiseStrength; + _look.noiseFrequency = g.noiseFrequency; + _look.noiseSpeed = g.noiseSpeed; + _look.opacityScale = g.opacity; + _look.glowScale = g.glow; + } + + /** + * The four role slots — coils before the shot, the ring-down and the broken + * channel after it. + */ + _syncFilaments(c, g, phaseFade, decay, cool) { + if (this.armed) this._layDecay(c, decay); + else this._layCoils(c); + + this._fillLook(c, g); + this.paths.sync(_look, phaseFade, this._seed); + this._cool(this.paths.uniforms, c, cool); + } + + /** + * The wind-up: `coilCount` helices collapsing inward along the barrel. + * + * Each coil is a role, each role is a short helix, and the collapse is one + * number — `gather` — driving three things at once: the coils' centres slide + * to the muzzle, their spans shorten to nothing, and their radii close. When + * `gather` reaches 1 all of them are zero-length rings sitting on the same + * point, which is what "they meet" looks like, and the frame after that the + * shot is out. + * + * The first version moved the centres and left the radii alone. Four coils + * of the same diameter converging on a point read as a concertina rather + * than as a charge; it is the *closing* that says energy is being forced + * somewhere smaller. + */ + _layCoils(c) { + const count = clamp(Math.round(c.coilCount), 1, 4); + const gather = Math.pow(this.charge, Math.max(0.05, c.coilGather)); + const radius = lerp(c.coilRadius, c.coilRadiusEnd, gather); + // The coils brighten as they close — the only cue that the wind-up is + // going somewhere, since nothing about it moves toward the target. + const bright = c.coilDim * (0.3 + 0.7 * this.charge); + const half = Math.max(0.5 * c.coilSpan * (1 - gather), 0.004); + + for (let i = 0; i < 4; i++) { + const role = this.paths.role(i); + if (i >= count) { + role.retire(); + continue; + } + const base = (i + 0.5) / count; + const centre = lerp(base, 1, gather); + this._barrelPoint(saturate(centre - half), _from); + this._barrelPoint(saturate(centre + half), _to); + + role.count = Math.max(1, Math.round(c.coilFilaments)); + role + .helix( + _from, + _to, + radius, + radius * 0.82, + c.coilTurns, + c.coilSpin, + c.coilLift, + c.coilSpread, + c.coilTaper + ) + .style(c.coilKink, c.coilWidth, bright, 1) + .ends(0.7, 0.7, 0.85, 0.85) + .draw(2, 0.08, -1e4, 0); + } + } + + /** + * The decay: one ring-down coil, and the channel broken into segments. + * + * Role 0 keeps being a coil — the current in the rail does not stop the + * instant the shot leaves — and it deliberately re-reads `coilRadiusEnd`, + * `coilKink`, `coilWidth`, `coilSpread` and `coilTaper` rather than owning + * copies of them. That sharing *is* the design: it is the same coil, so it + * has to answer to the same sliders, and it is also the only reason those + * five stay live once the wind-up is over. + */ + _layDecay(c, decay) { + /* --- role 0: the coil that survives --- */ + const alive = saturate(1 - decay / Math.max(0.02, c.ringdownLife)); + const ring = this.paths.role(RINGDOWN_ROLE); + const filaments = Math.round(c.ringdownFilaments); + if (alive <= 0 || filaments <= 0) { + ring.retire(); + } else { + const droop = this._droop(); + this._shotPoint(0, droop, _from); + this._shotPoint(1, droop, _to); + ring.count = filaments; + ring + .helix( + _from, + _to, + // It starts at the radius the coils closed to and blooms outward as + // it unwinds, which is what a collapsing field does on the way out. + lerp(c.coilRadiusEnd, c.ringdownRadius, 1 - alive), + lerp(c.coilRadiusEnd, c.ringdownRadius, 1 - alive) * 0.6, + c.ringdownTurns, + c.ringdownSpin, + 0, + c.coilSpread, + c.coilTaper + ) + .style(c.coilKink, c.coilWidth, c.ringdownDim * alive, 1) + .ends(0.8, 0.8, 0.9, 0.9) + .draw(2, 0.1, -1e4, 0); + } + + /* --- roles 1..3: the channel, coming apart --- */ + const segments = clamp(Math.round(c.segments), 1, MAX_SEGMENTS); + const droop = this._droop(); + const gap = Math.max(0, c.segmentGap) * 0.5; + const tip = Math.max(0.01, c.dissolveTip); + const stagger = clamp(c.dissolveStagger, 0, 0.95); + const spread = Math.max(0, c.segmentSpread); + + for (let i = 0; i < MAX_SEGMENTS; i++) { + const role = this.paths.role(i + 1); + if (i >= segments) { + role.retire(); + continue; + } + + const a = saturate(i / segments + gap); + const b = saturate((i + 1) / segments - gap); + const mid = (a + b) * 0.5; + + // The dissolve clock, sampled *along t* rather than off the index: the + // noise is a property of the position on the channel, so widening + // `dissolveNoise` re-shuffles which part of it dies first, and doing it + // this way means the order never depends on how many segments there are. + const noise = hash11(mid * c.dissolveNoise + this._seed * 0.137); + const k = saturate((decay - noise * stagger) / Math.max(0.05, 1 - stagger)); + if (k >= 1) { + role.retire(); + continue; + } + + this._shotPoint(a, droop, _from); + this._shotPoint(b, droop, _to); + // Half of them are eaten from the far end. `draw()` only ever clips from + // the top of `t`, so the flip is done by swapping the anchors — which + // costs nothing and is the difference between a channel dissolving and a + // channel being wiped by an invisible cursor. + const reverse = noise > 0.5; + const from = reverse ? _to : _from; + const to = reverse ? _from : _to; + + // The chord deviation of this stretch of the shared sag arch — see + // `_shotPoint`. Positive `sag` bows the path up in the shader, and the + // droop is already negative, so this comes out the right way round. + const localSag = + droop * (Math.sin(mid * Math.PI) - 0.5 * (Math.sin(a * Math.PI) + Math.sin(b * Math.PI))); + + role.count = Math.max(1, Math.round(c.segmentFilaments)); + role + .line(from, to, localSag, spread * 0.35, spread, 1.0, 0.2, 0.25, 1) + // It frays as it goes: a dying segment kinks harder and thins out, so + // the last thing on screen is a thread with a lot of noise in it. + .style(c.segmentKink * (1 + k * c.segmentFray), c.segmentWidth * (1 - 0.55 * k), 1 - k, 1) + .ends(0.7, 0.7, 0.9, 0.9) + .draw((1 + tip) - k * (1 + 2 * tip), tip, -1e4, 0.5); + } + } + + /** The three particle systems, re-resolved every frame. */ + _syncParticleLook(c, g) { + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = 0.7 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + this.sparks.uniforms.uTurbulence.value = 0.25 * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 0.9 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.smoke.setGradient( + getColor(c.colorSmokeA), + getColor(c.colorSmokeB), + getColor(c.colorSmokeC), + getColor(c.colorSmokeD) + ); + this.smoke.uniforms.uGravity.value.set(0, c.smokeRise, 0); + this.smoke.uniforms.uSizeScale.value = c.smokeSize * g.particleSize; + this.smoke.uniforms.uLifeScale.value = c.smokeLifetime * 0.5 * g.particleLifetime; + this.smoke.uniforms.uSpeedScale.value = c.smokeSpeed * g.particleSpeed; + this.smoke.uniforms.uOpacity.value = c.smokeOpacity * g.opacity; + this.smoke.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* One-shots */ + /* ------------------------------------------------------------------ */ + + /** + * The shot. Fired on the frame the coils meet, from `onTravel`, because that + * is the frame `advance()` wrote `u = 1` on — the impact phase does not start + * until `flashHold` later and this cannot wait for it. + */ + _fire() { + const c = settings.railcoil; + const g = settings.global; + const time = frame.uTime.value; + + this._muzzlePoint(_pos); + + this.ctx.bursts.spawn(BurstMode.STORM, _pos, { + radius: c.muzzleSize * 0.15, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.4, + intensity: c.muzzleIntensity, + opacity: 0.9, + fresnel: 1.6, + displace: 0.5, + squash: 0.7, + colorA: getColor(c.colorMuzzleA), + colorB: getColor(c.colorMuzzleB), + colorC: getColor(c.colorMuzzleC) + }); + + _emit.position = _pos; + _emit.radius = 0.18; + _emit.direction = _dir.copy(this.direction); + _emit.speed = c.sparkSpeed * 2.4; + _emit.speedVariance = 0.7; + // Tight: this is metal coming off a rail, not a firework. A wide cone here + // was the single thing that most made the shot read as an explosion. + _emit.spread = 0.32; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.24; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(Math.round(c.fireSparks * g.particleCount), _emit); + + /* --- the scorch line, laid whole because the shot arrived whole --- */ + const marks = Math.max(0, Math.round(c.scorchMarks)); + for (let i = 0; i < marks; i++) { + const s = marks > 1 ? i / (marks - 1) : 0.5; + this._shotPoint(s, 0, _pos); + _pos.y = 0.012; + _pos.addScaledVector(this.side, randRange(-c.scorchJitter, c.scorchJitter)); + this.ctx.decals.spawn(DecalType.SCORCH, _pos, { + radius: c.scorchRadius * randRange(0.75, 1.3), + life: c.scorchLife, + intensity: c.scorchIntensity, + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorScorchEmber), + height: 0.012 + }); + } + + this.ctx.shake.add( + c.recoilShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.05, c.recoilTime), + c.recoilFreq + ); + this.ctx.flash.trigger(getColor(c.colorFireFlash), c.fireFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * c.fireLight * g.explosionIntensity; + } + + /** Sparks, motes and haze off the channel while it dies. */ + _channelFx(dt) { + const c = settings.railcoil; + const g = settings.global; + const time = frame.uTime.value; + const decay = this.decay; + const droop = this._droop(); + // Sparks only while it is hot; motes all the way, and hardest at the end, + // because recombination is what makes them. + const hot = 1 - saturate(decay / Math.max(0.02, c.channelCollapse)); + + const sparkCount = Math.round(this.sparkEmitter.tick(dt, c.sparkRate * hot) * g.particleCount); + if (sparkCount > 0) { + this._shotPoint(Math.random(), droop, _pos); + _emit.position = _pos; + _emit.radius = 0.14; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sparkSpeed * 0.5; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.14; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(sparkCount, _emit); + } + + const moteCount = Math.round( + this.moteEmitter.tick(dt, c.moteRate * (0.35 + 0.65 * decay)) * g.particleCount + ); + if (moteCount > 0) { + this._shotPoint(Math.random(), droop, _pos); + _emit.position = _pos; + _emit.radius = 0.3; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.size = 0.08; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + const smokeCount = Math.round(this.smokeEmitter.tick(dt, c.smokeRate) * g.particleCount); + if (smokeCount > 0) { + this._shotPoint(Math.random(), droop, _pos); + _emit.position = _pos; + _emit.radius = 0.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.smokeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.smokeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.time = time; + this.smoke.emit(smokeCount, _emit); + } + } + + /** Sparks spat out of the barrel while the coils close. */ + _chargeFx(dt) { + const c = settings.railcoil; + const g = settings.global; + const charge = this.charge; + + const count = Math.round( + this.sparkEmitter.tick(dt, c.sparkRate * 0.35 * charge) * g.particleCount + ); + if (count <= 0) return; + + this._barrelPoint(randRange(0.2, 1), _pos); + _emit.position = _pos; + _emit.radius = c.coilRadius * (1 - charge) + 0.06; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.4).setY(0.5).normalize(); + _emit.speed = c.sparkSpeed * 0.5; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime * 0.7; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.sparks.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.railcoil; + const g = settings.global; + + // The one-shot goes on the frame the front jumped, not on the phase change: + // the impact phase is `flashHold` behind, and the bang belongs to the jump. + if (this.armed && !this._fired) { + this._fired = true; + this._fire(); + } + + this._sync(1); + + if (this.armed) { + this._lightAt(); + this._channelFx(dt); + } else { + this._muzzlePoint(this.position); + this._chargeFx(dt); + this.ctx.shake.rumble(c.chargeShake * this.charge * g.cameraShake, dt); + } + } + + /** Nothing loud here — the shot already happened, `flashHold` ago. */ + onImpact() { + this._lightAt(); + } + + onFade(dt, t) { + // `t` runs 0..1 through the hot half and 1..2 through the cold one, but the + // look is driven by `decay`, which spans both — so all this does is take + // the last of it off at the very end, where the phase clock and the decay + // clock agree anyway. + const fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + this._sync(fade); + this._lightAt(); + this._channelFx(dt); + } + + /** + * Put the light on the channel rather than on the floor at the far end. + * + * It starts near the muzzle and drifts downrange as the channel cools, which + * is roughly where the brightest surviving section is once the near end has + * dissolved — and, more practically, it stops a 26 m shot being lit entirely + * from behind the camera. + */ + _lightAt() { + this._shotPoint(lerp(0.15, 0.5, this.decay), this._droop(), this.position); + } + + onDestroy() { + this.paths.clear(); + this._fired = false; + this._handedOver = false; + } + + dispose() { + this.channel.dispose(); + this.paths.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/storm/SheetLightningAbility.js b/src/abilities/storm/SheetLightningAbility.js new file mode 100644 index 0000000..8c80052 --- /dev/null +++ b/src/abilities/storm/SheetLightningAbility.js @@ -0,0 +1,763 @@ +import { Color, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { FilamentPaths, filamentLook } from '../../vfx/FilamentPaths.js'; +import { HullShape, Medium, VolumeHull } from '../../vfx/VolumeHull.js'; +import { Hook, sceneHooks } from '../../vfx/SceneHooks.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp } from '../../utils/math.js'; + +/** + * Filaments this ability may draw at once, across all three roles. + * + * `FilamentPaths` hard-caps at its `capacity`, and the three sliders together + * can ask for more than that, so the roles are filled in priority order and the + * last one takes whatever is left. The trunk always gets its share: a sheet with + * no main fracture is nothing, a sheet with no feelers is still a sheet. + */ +const CAPACITY = 48; + +/** Nodes along one filament. The ceiling on how fine a kink can be. */ +const SAMPLES = 72; + +/** + * Points across the zone one frame's motes are split between. + * + * The same lesson `ThunderclapAbility` writes down about its ring: emit a whole + * frame's batch from one point and it reads as a hose, not as air. + */ +const MOTE_BATCHES = 4; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _from = new Vector3(); +const _to = new Vector3(); +const _tint = new Color(); +const _look = filamentLook(); + +/** Deterministic 0..1 hash. A pulse must look the same every time it is drawn. */ +function hash1(n) { + const s = Math.sin(n * 127.1) * 43758.5453; + return s - Math.floor(s); +} + +/** + * SHEET LIGHTNING — a far cast whose whole output is somebody else's lighting. + * + * **THE TRICK — the shadows strobe.** The pulse train is written to the scene's + * *key light* through `SceneHooks`' `KEY_LIGHT` hook, so it is not this + * ability's shader that flashes: it is the sun. The character's shadow, the + * relief on the floor, the crystals of a Frost Lance still standing from the + * previous cast — every real shadow on the stage snaps out and back on the same + * clock, five times in three-quarters of a second, and the ability itself draws + * almost nothing. It is the cheapest effect in the project by a distance and the + * first time it fires it is startling, which is the only review that matters. + * + * **What it deliberately does not do.** + * + * - **It never calls `token.aim()`.** Swinging the sun is Dawnbreak's sentence + * — a *travelling* light, shadows sweeping across the stage. Sheet lightning + * is diffuse: a whole cloud base lights at once and the shadows keep their + * bearings and change only their depth. `brightness()` and `tint()`, nothing + * else. Aiming it was tried and it read as a searchlight, which is a + * completely different weather event. + * - **There is no `ctx.flash.trigger()` anywhere in this file.** A white + * full-screen overlay is what everybody reaches for when they draw lightning + * and it is the single thing that would hide the trick, because it washes out + * the shadows at exactly the frame they snap. The screen goes bright here + * only because the *world* got bright. + * - **Nothing lands on the floor.** No scorch, no arc decal, no debris. This is + * intracloud lightning; the discharge stays in the mist and the feelers stop + * well above head height. The floor's contribution is the character's shadow + * lying on it, and a burn mark drawn on top of that competes with the read. + * + * **The three beats.** + * + * 1. **Charge** (`TRAVEL`). The front runs out to the zone and the sun is + * pulled *down* toward `keyCharge`, blending in over `armTime`. The stage + * dims before anything appears. That is the Thunderclap lesson — the + * anticipation carries it — and without it the first pulse has no cause. + * 2. **The train** (`IMPACT`). `flashes` pulses at `strobeRate`, each with a + * hashed amplitude and width and a coin-flip second stroke at + * `doubleGap` widths. Real lightning strokes come in trains and no two are + * the same brightness; an even train is a fluorescent tube failing. + * 3. **Afterglow** (the tail of `IMPACT`, then `FADE`). One dim decay, no more + * snaps, and the hook blends back to `settings.environment` over + * `releaseTime` — to zero weight, which is *exactly* the environment again + * rather than approximately, because `SceneHooks.apply()` blends from + * settings and not from the live light. + * + * **What a cast captures.** One unitless seed and the timestamps the base class + * already keeps. The pulse train is re-derived from `settings.sheetlightning` + * on every frame, zero-length ones included: pause mid-train with **P**, drag + * `strobeRate`, and the pulse boundaries move underneath the standing cast — the + * world goes light or dark with the clock stopped. Drag `keyFlash` and the + * frozen flash gets brighter. That is invariant I1 with the ability's entire + * output on the far side of it. + * + * **A note on the pause harness.** The probe snapshots the uniforms an ability + * *owns*, and the sun is not one of them — a hook-only slot reads as thirty dead + * sliders even while it is driving the world. `SceneHooks.observe()` exists for + * exactly this and covers the four published fields; the key light is not among + * them, so this ability parks its own mirror of what it wrote to the sun on the + * cloud material's `userData.uniforms`. Nothing in any shader reads those five + * numbers. They are there to be seen. + */ +export class SheetLightningAbility extends Ability { + constructor(context) { + super('sheetlightning', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the discharge: three roles, two draw calls, always --- */ + this.paths = new FilamentPaths(this.group, { + samples: SAMPLES, + capacity: CAPACITY, + renderOrder: 11 + }); + + /* --- the slab of mist it is buried in --- */ + // CYLINDER rather than BOX because a cloud base has a round footprint and + // the zone indicator drew a circle; the hull's y runs 0..Sy from where it is + // placed, so it is seated at the underside of the slab rather than at its + // centre. MIST because it is the one medium in the library that scatters + // more than it absorbs, which is what lets a light inside it read *through* + // it — and the light inside it is the whole ability. + this.cloud = new VolumeHull({ + hull: HullShape.CYLINDER, + medium: Medium.MIST, + prefix: 'cloud', + maxSteps: 32, + additive: false, + renderOrder: 12 + }); + this.group.add(this.cloud.mesh); + + /** + * A live *view* of the settings block, made once. + * + * `VolumeHull.sync(c, g)` reads its forty keys straight off the block, which + * is exactly right for forty sliders and no use at all for the one key this + * ability has to modulate per frame — the cloud's emission *is* the sheet, + * so it has to carry the pulse. The two obvious alternatives are both worse: + * writing `settings.sheetlightning.cloudEmission` back would have the + * ability fighting the editor for a number the user owns (and a preset saved + * mid-cast would keep whatever the strobe happened to be doing), and copying + * forty keys into a scratch object every frame is forty property reads to + * avoid one. + * + * A prototype view costs one object for the life of the instance. Reads of + * the other thirty-nine keys fall through to the live block, so every one of + * them stays a live slider; `cloudEmission` is shadowed on the view and + * recomputed from the live block value each frame, so it stays live too. + */ + this._cloudView = Object.create(settings.sheetlightning); + + /** + * What this ability last told the sun, mirrored where the pause probe can + * see it. See the class comment. Parked on the cloud material because that + * is a material this ability already owns; nothing samples them. + */ + this._probe = { + uKeyBrightness: { value: 0 }, + uKeyWeight: { value: 0 }, + uKeyTint: { value: new Color() }, + uPulse: { value: 0 }, + uCharge: { value: 0 } + }; + const material = this.cloud.material; + material.userData = material.userData ?? {}; + material.userData.uniforms = Object.assign(material.userData.uniforms ?? {}, this._probe); + sceneHooks.observe(material); + + /** The token driving the world's key light, or null between casts. */ + this._sun = null; + /** Re-rolled per cast so two storms do not strobe the identical train. */ + this._seed = 0; + /** Latched once, so the opening jolt fires on the frame the sheet does. */ + this._opened = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // The aerosol the flash catches. Additive and short-lived: between pulses + // the air has nothing in it, because between pulses there is no light to + // find anything with. + this.motes = particles.get('sheetlightning.motes', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.5 + }); + this.motes.uniforms.uDrag.value = 1.9; + this.motes.uniforms.uEndSize.value = 0.2; + this.motes.uniforms.uSizeIn.value = 0.06; + this.motes.uniforms.uFadeIn.value = 0.05; + this.motes.uniforms.uFadeOut.value = 0.45; + + // The cloud's ragged underside. Non-additive so it genuinely occludes — + // a cloud that adds light is a fire. + this.haze = particles.get('sheetlightning.haze', { + capacity: 900, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.3 + }); + this.haze.uniforms.uDrag.value = 1.4; + this.haze.uniforms.uEndSize.value = 2.6; + this.haze.uniforms.uSizeIn.value = 0.16; + this.haze.uniforms.uFadeIn.value = 0.2; + this.haze.uniforms.uFadeOut.value = 0.4; + + this.moteEmitter = new RateEmitter(); + this.hazeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — every span re-resolved, every frame */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.paths.liveCount; + } + + /** Seconds between pulses. */ + _period() { + return 1 / Math.max(0.5, settings.sheetlightning.strobeRate); + } + + /** Pulses in this train, clamped to something a slider can reach. */ + _flashCount() { + return Math.max(1, Math.round(settings.sheetlightning.flashes)); + } + + /** + * The strobe train plus its tail. + * + * Derived rather than authored, so `flashes` and `strobeRate` move the length + * of the impact phase itself. Drag either while paused and the beat boundaries + * move under the standing cast — which is the point, and is why there is no + * `holdTime` slider in the block. + */ + get impactDuration() { + const c = settings.sheetlightning; + const train = this._flashCount() * this._period(); + return Math.max(0.05, (train + Math.max(0.01, c.afterglow)) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.sheetlightning.fadeTime); + } + + /** + * Seconds since the sheet fired, negative while it is still charging. + * + * Rebuilt from `impactTime` and the live `impactDuration` rather than taken + * from the `t` the base class hands `onFade`, for the reason Thunderclap gives: + * that `t` was divided by a duration one frame ago and this ability's duration + * is two sliders that may move underneath it. + */ + _clock() { + if (this.phase === AbilityPhase.FADE) return this.impactDuration + this.fadeTime; + if (this.phase === AbilityPhase.IMPACT) return this.impactTime; + return -1; + } + + /* ------------------------------------------------------------------ */ + /* The strobe */ + /* ------------------------------------------------------------------ */ + + /** + * One pulse of the train, 0..1. + * + * Hard attack, `flashDecay` fall. The attack is a discontinuity on purpose: + * every eased version of this reads as a lamp coming up, and the thing that + * says *lightning* is that the world is simply already bright on the next + * frame. `hash1` off the pulse index and the cast seed gives each stroke its + * own amplitude and width, and a coin flip against `doubleChance` gives it a + * second stroke — real flashes come in strokes down the same channel, which is + * the flicker everybody has seen and nobody draws. + * + * @param {number} index which pulse of the train + * @param {number} local seconds since that pulse began + */ + _pulseAt(index, local) { + const c = settings.sheetlightning; + const decay = Math.max(0.2, c.flashDecay); + const amp = 1 - saturate(c.flashVary) * hash1(index * 3.7 + this._seed); + const width = Math.max(0.005, c.flashWidth) * (0.55 + 0.9 * hash1(index * 7.1 + this._seed + 4.3)); + + let env = local >= 0 && local < width ? Math.pow(1 - local / width, decay) : 0; + + if (hash1(index * 11.9 + this._seed + 9.7) < saturate(c.doubleChance)) { + const at = local - width * Math.max(0.2, c.doubleGap); + if (at >= 0 && at < width) { + env = Math.max(env, Math.pow(1 - at / width, decay) * Math.max(0, c.doubleLevel)); + } + } + return env * amp; + } + + /** + * How lit the world is right now, 0..1. + * + * @param {number} t seconds since the sheet fired; negative is the charge + */ + _pulse(t) { + if (t < 0) return 0; + const c = settings.sheetlightning; + const period = this._period(); + const flashes = this._flashCount(); + const index = Math.floor(t / period); + + if (index >= flashes) { + // The tail. One decay, no more snaps: a train that keeps stuttering as it + // dies reads as a fault rather than as a storm moving away. + const over = t - flashes * period; + const tail = Math.max(0.01, c.afterglow); + const k = saturate(1 - over / tail); + return k * k * Math.max(0, c.afterglowLevel); + } + return this._pulseAt(index, t - index * period); + } + + /** The pooled point light rides the same envelope as the sun. */ + lightShimmer() { + const c = settings.sheetlightning; + const t = this._clock(); + if (t < 0) return saturate(c.keyCharge / Math.max(0.01, c.keyFlash)); + return Math.max(saturate(c.sheetIdle), this._pulse(t)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The zone centre, on the floor. What the circle indicator measured out. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** The live footprint, metres. */ + get radius() { + return Math.max(0.1, settings.sheetlightning.zoneRadius); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.hazeEmitter.reset(); + this._opened = false; + + // The one thing a cast captures, and it is unitless. + this._seed = Math.random() * 100; + + // The world's sun, borrowed. `borrow()` is what gives it back on the three + // paths that are not this ability's idea — the player pressing C, a fifth + // cast pushing this one off the concurrency cap, and teardown. Leaving the + // stage lit from a lightning flash for the rest of the session is the exact + // failure `SceneHooks` was written around. + this._sun = this.borrow(sceneHooks.acquire(Hook.KEY_LIGHT, this)); + + this.paths.visible = true; + this.cloud.setFade(0); + this._sync(0, 0); + } + + /* ------------------------------------------------------------------ */ + /* The one sync */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current beat into everything. + * + * @param {number} env 0..1 how lit this frame is + * @param {number} master 0..1 the cast's own life — 1 while it is up, 0 gone + */ + _sync(env, master) { + const c = settings.sheetlightning; + const g = settings.global; + + this._centrePoint(_centre); + const R = this.radius; + const span = R * Math.max(0.05, c.sheetSpan); + + /* ---------------------------------------------------------------- */ + /* The world's key light — the ability */ + /* ---------------------------------------------------------------- */ + this._syncKey(env, master); + + /* ---------------------------------------------------------------- */ + /* The discharge */ + /* ---------------------------------------------------------------- */ + // Every role is `draw(2, …)` — "drawn whole". There is no travelling front + // anywhere in this ability, and that is not a shortcut: a leader racing + // along the channel is what a *bolt* does, and drawing one here would make + // this a very slow ground strike. A sheet is already everywhere by the time + // you see it. + const main = Math.min(20, Math.max(0, Math.round(c.strands))); + const cross = Math.min(CAPACITY - main, Math.max(0, Math.round(c.crossStrands))); + const feelers = Math.min(CAPACITY - main - cross, Math.max(0, Math.round(c.feelerCount))); + + /* the main fracture, running downrange through the cloud */ + _from.copy(_centre).addScaledVector(this.direction, -span); + _to.copy(_centre).addScaledVector(this.direction, span); + _from.y = c.sheetHeight; + _to.y = c.sheetHeight; + this.paths + .role(0) + .crack(_from, _to, c.crackAngle, c.crackLength, c.crackFalloff, c.crackSpread, + c.crackStart, c.sheetSag, c.crackForkBias) + .style(1, 1, 1, 1) + .ends(0, 1, 0.35, 0.35) + .draw(2, 0.08, -1e4, 0); + this.paths.role(0).count = main; + + /* and the same fracture across it, lower down */ + // Across the *side* vector, not at some rolled bearing: two cuts at right + // angles is what turns a line of light into a sheet, and rolling the second + // one produced a pair of crossing bolts instead — the eye reads two objects + // as soon as they are not obviously the same event seen twice. + _from.copy(_centre).addScaledVector(this.side, -span); + _to.copy(_centre).addScaledVector(this.side, span); + _from.y = c.sheetHeight - c.sheetDrop; + _to.y = c.sheetHeight - c.sheetDrop; + this.paths + .role(1) + .crack(_from, _to, c.crackAngle, c.crackLength, c.crackFalloff, c.crackSpread, + c.crackStart, c.sheetSag * 0.6, c.crackForkBias) + .style(1, 0.85, 1, 1) + .ends(0, 1, 0.35, 0.35) + .draw(2, 0.08, -1e4, 0); + this.paths.role(1).count = cross; + + /* the feelers hanging out of the belly */ + // `floorY` is `feelerFloor` rather than the ground, which is the whole + // reason these read as reaching *into* the haze rather than as a strike that + // missed: a filament clamped at 5 m looks like it lost itself in cloud, and + // one clamped at 0 m looks like it hit the stage and did nothing. + _from.copy(_centre); + _from.y = c.sheetHeight - c.sheetDrop * 0.5; + _to.copy(_centre); + _to.y = Math.max(c.feelerFloor, c.sheetHeight - c.feelerDrop); + this.paths + .role(2) + .crack(_from, _to, c.crackAngle * 1.35, c.crackLength, c.crackFalloff, c.crackSpread * 1.4, + c.crackStart, c.feelerSag, c.crackForkBias) + .style(1, 0.7, 0.55, 1) + .ends(0.1, 1, 0.2, 0.9) + .draw(2, 0.1, c.feelerFloor, 0); + this.paths.role(2).count = feelers; + + // The fourth slot is retired every frame. `sync()` overwrites `uCount`, so a + // role that is not written is a role that keeps whatever the last ability to + // use this strip left in it. + this.paths.role(3).count = 0; + + this._fillLook(env, g); + this.paths.sync(_look, master, this._seed); + + /* ---------------------------------------------------------------- */ + /* The cloud it is buried in */ + /* ---------------------------------------------------------------- */ + const thickness = Math.max(0.1, c.cloudThickness); + const view = this._cloudView; + // Cheap insurance: if a preset load ever swapped the block object out from + // under us the view would silently freeze at the old numbers, which is the + // sort of bug that is only ever found by someone else. + if (Object.getPrototypeOf(view) !== c) Object.setPrototypeOf(view, c); + // The emission is the sheet. Between pulses it sits at `cloudIdle` rather + // than zero, because ionised air keeps glowing for a moment and because a + // cloud that goes completely flat between strokes reads as a prop being + // switched off. + const idle = saturate(c.cloudIdle); + view.cloudEmission = c.cloudEmission * (idle + (1 - idle) * env); + + _pos.copy(_centre); + _pos.y = Math.max(0.2, c.cloudLift) - thickness * 0.5; + this.cloud + .place(_pos, this.direction) + .setSize(R * Math.max(0.1, c.cloudSpan), thickness, R * Math.max(0.1, c.cloudSpan)) + .setFade(master) + .sync(view, g); + + /* ---------------------------------------------------------------- */ + /* Particles */ + /* ---------------------------------------------------------------- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.sheetGlow * 0.5 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.haze.setGradient( + getColor(c.colorHazeA), + getColor(c.colorHazeB), + getColor(c.colorHazeC), + getColor(c.colorHazeD) + ); + this.haze.uniforms.uGravity.value.set(0, c.hazeRise, 0); + this.haze.uniforms.uSizeScale.value = c.hazeSize * g.particleSize; + this.haze.uniforms.uLifeScale.value = c.hazeLifetime * 0.5 * g.particleLifetime; + this.haze.uniforms.uSpeedScale.value = c.hazeSpeed * g.particleSpeed; + this.haze.uniforms.uOpacity.value = c.hazeOpacity * g.opacity * master; + this.haze.uniforms.uTurbulence.value = 0.5 * g.turbulence; + } + + /** + * Write the sun. + * + * Four calls and one of them is the whole ability. `brightness()` is an + * absolute intensity in `environment.sunIntensity`'s units, `tint()` decides + * whether the flash is the blue-white of a real stroke or the sodium orange of + * a storm over a town, and `blend()` decides how much of the environment's own + * sun is still showing through. `aim()` is conspicuously absent — see the + * class comment. + * + * The mirror written at the bottom is for the pause harness, not for a shader. + */ + _syncKey(env, master) { + const c = settings.sheetlightning; + const charging = this.phase === AbilityPhase.TRAVEL; + + // The charge ramps the *weight* in rather than the intensity: at weight 0 + // the environment's sun is untouched, so the dim arrives as the world's own + // light being taken away rather than as a grey wash being added over it. + const arm = saturate(this.age / Math.max(0.02, c.armTime)); + const level = charging ? Math.max(0, c.keyCharge) : lerp(Math.max(0, c.keyDark), Math.max(0, c.keyFlash), env); + const weight = saturate(c.keyWeight) * (charging ? arm : master); + + _tint.copy(getColor(c.colorKeyDark)).lerp(getColor(c.colorKeyFlash), env); + + if (this._sun) this._sun.brightness(level).tint(_tint).blend(weight); + + const probe = this._probe; + probe.uKeyBrightness.value = level; + probe.uKeyWeight.value = weight; + probe.uKeyTint.value.copy(_tint); + probe.uPulse.value = env; + probe.uCharge.value = charging ? arm : 0; + } + + /** + * The filament look, filled from the block every frame. + * + * `glow` is the only term the pulse touches, and it carries `sheetIdle` as its + * floor for the same reason the cloud's emission does. Everything else is + * constant across the train: a fracture that changed *shape* with the + * brightness would read as a different bolt each stroke, and real strokes go + * down the same channel — which is precisely why they flicker instead of + * moving. + */ + _fillLook(env, g) { + const c = settings.sheetlightning; + const idle = saturate(c.sheetIdle); + + _look.width = c.sheetWidth; + _look.glowWidth = c.sheetGlowWidth; + _look.glowOpacity = c.sheetGlowOpacity; + + _look.jitter = c.sheetJitter; + _look.jitterScale = c.sheetJitterScale; + _look.octaves = c.sheetOctaves; + _look.jitterFalloff = c.sheetJitterFalloff; + _look.crawl = c.sheetCrawl; + _look.pinch = c.sheetPinch; + _look.restrike = c.sheetRestrike; + + _look.flicker = c.sheetFlicker; + _look.flickerSpeed = c.sheetFlickerSpeed; + _look.strandFlash = c.sheetStrandFlash; + + _look.coreSharp = c.sheetCoreSharp; + _look.glowFalloff = c.sheetGlowFalloff; + _look.softFade = c.sheetSoftFade; + + _look.opacity = c.sheetOpacity; + _look.glow = c.sheetGlow * (idle + (1 - idle) * env); + _look.colorCore = c.colorSheetCore; + _look.colorInner = c.colorSheetInner; + _look.colorOuter = c.colorSheetOuter; + _look.colorHalo = c.colorSheetHalo; + + _look.randomness = g.randomness; + _look.noiseStrength = g.noiseStrength; + _look.noiseFrequency = g.noiseFrequency; + _look.noiseSpeed = g.noiseSpeed; + _look.opacityScale = g.opacity; + _look.glowScale = g.glow; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Motes and haze. + * + * The mote rate is multiplied by the pulse, so the air is only ever seeded + * while there is light to find it with. The first version seeded continuously + * and the motes hung there between strokes as a haze of glowing dots, which + * says "magic" rather than "a flash went off in fog". + */ + _airFx(dt, env, master) { + const c = settings.sheetlightning; + const g = settings.global; + const time = frame.uTime.value; + const R = this.radius; + + this._centrePoint(_centre); + + let moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * env) * g.particleCount); + if (moteCount > 0) { + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + + const batches = Math.min(moteCount, MOTE_BATCHES); + const per = Math.ceil(moteCount / batches); + while (moteCount > 0) { + // Seeded through the whole column of air under the cloud rather than in + // a shell at its base: the flash lights the volume, and a shell reads as + // a ring of sparks hanging at one altitude. + _pos.copy(_centre); + _pos.y = Math.max(0.4, c.cloudLift) * (0.15 + 0.75 * Math.random()); + _emit.position = _pos; + _emit.radius = R * 0.9; + this.motes.emit(Math.min(per, moteCount), _emit); + moteCount -= per; + } + } + + const hazeCount = Math.round(this.hazeEmitter.tick(dt, c.hazeRate * master) * g.particleCount); + if (hazeCount > 0) { + _pos.copy(_centre); + _pos.y = Math.max(0.2, c.cloudLift) - Math.max(0.1, c.cloudThickness) * 0.5; + _emit.position = _pos; + _emit.radius = R * Math.max(0.1, c.cloudSpan) * 0.8; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.hazeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.size = 1.4; + _emit.sizeVariance = 0.6; + _emit.life = c.hazeLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.25; + _emit.time = time; + this.haze.emit(hazeCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.sheetlightning; + + // The cloud fades in with the charge; the fracture stays dark. There is + // nothing to see yet and that is the beat. + const arm = saturate(this.age / Math.max(0.02, c.armTime)); + this._sync(0, arm); + + // The pooled light sits at the sheet's own height from the first frame, so + // when the train fires it rakes down rather than lighting the floor flat. + this._centrePoint(this.position); + this.position.y = Math.max(0.2, c.sheetHeight); + + this._airFx(dt, 0, arm); + } + + onImpact() { + // Nothing here fires the flash — the train is derived from `impactTime` and + // is already running on the next frame. This is only the jolt, and it is + // small: a sheet is felt as a roll arriving late, not as a punch. + this._opened = false; + } + + onFade(dt, _t) { + const c = settings.sheetlightning; + + const t = this._clock(); + const env = this._pulse(t); + // The hook and the cloud let go together over `releaseTime`, measured from + // the end of the impact phase rather than from the base class's `t`, so the + // release is its own slider and not a fraction of `fadeTime`. + const master = + this.phase === AbilityPhase.FADE + ? 1 - saturate(this.fadeTime / Math.max(0.05, c.releaseTime)) + : 1; + + if (!this._opened && t >= 0) { + this._opened = true; + this.ctx.shake.add( + c.openShake * settings.global.explosionIntensity, + 1 / Math.max(0.05, c.openShakeTime), + 14 + ); + } + + this._sync(env, master); + + this._centrePoint(this.position); + this.position.y = Math.max(0.2, c.sheetHeight); + + this._airFx(dt, env * master, master); + // A low roll rather than a crack: the shake is weighted by the pulse but + // never punched, because the sound of sheet lightning arrives from a long + // way off and everybody knows what that feels like. + this.ctx.shake.rumble(c.rollRumble * env * master, dt); + } + + onDestroy() { + // The one line in `SceneHooks`' "one rule". `borrow()` in `onSpawn` is the + // net; this is the reflex, and it runs first, on the frame, silently. + sceneHooks.reclaim(this); + this._sun = null; + this._opened = false; + this.paths.clear(); + this.paths.visible = false; + this.cloud.setFade(0); + } + + dispose() { + this.paths.dispose(); + this.cloud.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/storm/StormwallAbility.js b/src/abilities/storm/StormwallAbility.js new file mode 100644 index 0000000..a93e1f3 --- /dev/null +++ b/src/abilities/storm/StormwallAbility.js @@ -0,0 +1,809 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Curtain, CurtainMode, CurtainLayout } from '../../vfx/Curtain.js'; +import { FilamentPaths } from '../../vfx/FilamentPaths.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, Easing, randRange } from '../../utils/math.js'; + +/** Role slots on the one filament strip. Structural role first — see `sync()`. */ +const ROLE_BOLT = 0; +const ROLE_FOOT = 1; + +/** Hard ceilings. Their sum is the strip's instance capacity, allocated once. */ +const MAX_BOLT = 20; +const MAX_FOOT = 12; + +/** + * Samples along one filament. The ceiling on kink detail: anything + * higher-frequency than one kink per two samples only aliases. + */ +const NODES = 64; + +const UP = new Vector3(0, 1, 0); + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _anchor = new Vector3(); +const _from = new Vector3(); +const _to = new Vector3(); +const _wetCentre = new Vector3(); + +/** Params objects, filled from settings every frame and never re-created (I3). */ +const _cloth = {}; +const _wet = {}; +const _look = {}; + +/** + * STORMWALL — the aimed line is the wall's **normal**. + * + * Every other line cast in the project builds *along* the line: the bolt runs + * down it, the beam lies on it, the lash whips along it. This one turns ninety + * degrees. A rain curtain stands **across** the heading at the target point, + * and the arrow indicator is measuring out where the wall goes and how far away + * it is — never how long it is. Its length is `wallWidth` and it is measured + * sideways. + * + * Mechanically the whole trick is one argument: + * + * ```js + * this.cloth.setPlacement(anchor, this.side, UP); // side, NOT direction + * ``` + * + * `Curtain` lays a `LINE` rank along whatever vector it is handed and gives + * each sheet a normal perpendicular to it. Hand it `this.direction` and you get + * a corridor of rain you walk down; hand it `this.side` and you get a wall you + * hide behind. The module's own doc names this slot as the reason the choice + * exists, and it is the only line in the file that could not be written any + * other way. + * + * **Three beats, and the third one is the interesting one.** + * + * 1. `riseTime` — the wall comes up out of the floor, staggered across the + * rank by `riseSpread`. + * 2. `holdTime` — it stands. Rain streaks down the face, lightning restrikes + * *inside the wall's own plane* every `1 / strikeRate` seconds, and current + * crawls along the soaked floor at its foot. + * 3. `fallTime` — it **drains**. `rise` runs back to zero, which collapses + * every sheet onto its own foot rather than dimming it, and `fallSink` + * takes the foot below the floor. The first version faded `opacity` to zero + * and the wall read as a decal being switched off — all the weight the rise + * had bought was thrown away in the last second and a half. + * + * **The lightning is inside the water, not in front of it.** Both ends of the + * `CRACK` trunk are placed in the plane spanned by the wall's own width and + * height — `side` and world up — so the discharge is occluded by the sheets in + * front of it and lights the ones behind. Placing it on the cast's *direction* + * axis, which is what you get if you copy any other storm slot, puts it through + * the wall at right angles and the whole thing reads as two effects that happen + * to be in the same place. + * + * **What a cast captures.** A seed, and three unitless dice per strike: where + * each end of the trunk sits across the wall, and a decorrelation seed. Not a + * metre. The rank pitch, the sheet width, the strike's endpoints, the crawl + * along the foot and the wet patch are resolved from `settings.stormwall` on + * every frame, zero-length ones included — pause a standing wall, drag + * `wallWidth`, and the rank re-lays itself, the lightning re-spans it and the + * puddle re-scales under it together. + */ +export class StormwallAbility extends Ability { + constructor(context) { + super('stormwall', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the water --- */ + // segmentsX is the one that matters: the travelling ripple lives on the + // across-axis tessellation, and a wall at 12 quads across folds in visible + // facets the moment the camera moves along it. + this.cloth = new Curtain({ + capacity: 16, + segmentsX: 40, + segmentsY: 18, + mode: CurtainMode.RAIN, + layout: CurtainLayout.LINE, + floor: false, // the floor is a GroundField(WET) instead — see below + renderOrder: 9, + name: 'stormwall.cloth' + }); + this.group.add(this.cloth.object3D); + + /* --- the lightning: two roles, one strip, two draw calls --- */ + this.paths = new FilamentPaths(this.group, { + samples: NODES, + capacity: MAX_BOLT + MAX_FOOT, + renderOrder: 11 + }); + this.bolt = this.paths.role(ROLE_BOLT); + this.foot = this.paths.role(ROLE_FOOT); + + /* --- the floor --- */ + // `Curtain` ships a floor companion that walks the same sheet frame, and it + // is very good; it is not used here because it draws puddles *per sheet* and + // what this slot wants is one continuous soaked patch that spreads while the + // squall runs out and dries from the edges in while the wall drains. That is + // GroundField(WET)'s grow/recede pair, and it is one draw call either way. + this.wet = new GroundField(this.group, { + mode: GroundMode.WET, + additive: false, // a wet stone that ADDS light is a lit stone + depthTest: true, + name: 'stormwall.wet' + }); + + /** Re-rolled per cast. Unitless. */ + this._seed = 0; + /** Timestamp of the last lightning strike, seconds into the cast. */ + this._strikeAt = -1e4; + /** Where the current strike's two ends sit across the wall, −1..1. */ + this._strikeX0 = 0; + this._strikeX1 = 0; + /** Decorrelates the strike's kinks from the last one. */ + this._strikeSeed = 0; + this._boltCount = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Droplets bouncing off the foot of the wall. Non-additive: water is not + // light, and an additive spray turns the base into a bloom smear. + this.spray = particles.get('stormwall.spray', { + capacity: 1200, + shape: ParticleShape.SOFT, + additive: false, + softFade: 0.2 + }); + this.spray.uniforms.uDrag.value = 1.1; + this.spray.uniforms.uEndSize.value = 0.4; + this.spray.uniforms.uSizeIn.value = 0.05; + this.spray.uniforms.uFadeIn.value = 0.05; + this.spray.uniforms.uFadeOut.value = 0.5; + + // The haze rolling off the face and out along the floor. + this.mist = particles.get('stormwall.mist', { + capacity: 1400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.mist.uniforms.uDrag.value = 1.7; + this.mist.uniforms.uEndSize.value = 2.6; + this.mist.uniforms.uSizeIn.value = 0.14; + this.mist.uniforms.uFadeIn.value = 0.2; + this.mist.uniforms.uFadeOut.value = 0.35; + + // Sparks off a strike. The only additive system in the ability. + this.sparks = particles.get('stormwall.sparks', { + capacity: 1400, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.5; + this.sparks.uniforms.uEndSize.value = 0.25; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.45; + + this.sprayEmitter = new RateEmitter(); + this.mistEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + _riseSpan() { + return Math.max(0.02, settings.stormwall.riseTime * settings.global.lifetime); + } + + /** Rise plus hold: the wall is standing for the whole impact phase. */ + get impactDuration() { + const c = settings.stormwall; + return this._riseSpan() + Math.max(0.05, c.holdTime * settings.global.lifetime); + } + + /** The drain. */ + get fadeDuration() { + return Math.max(0.05, settings.stormwall.fallTime); + } + + get instanceCount() { + return this.cloth.instanceCount + this.paths.liveCount; + } + + /** Seconds into the drain, 0..1. Zero until the fade phase starts. */ + _drain() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / this.fadeDuration); + } + + /** + * How far up the wall is, 0..1. + * + * Rebuilt from `riseTime` every frame rather than taken from the base class's + * `t`, which was divided by an impact duration that two sliders can move + * underneath a standing cast. + */ + _rise() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.FADE) { + // The drain accelerates. A linear collapse looks like a lift descending; + // water lets go slowly and then all at once. + return 1 - Easing.inQuad(this._drain()); + } + return saturate(this.impactTime / this._riseSpan()); + } + + /** A gutter with a strike punched into it. */ + lightShimmer() { + return 0.82 + 0.18 * Math.sin(this.age * 2.7) * Math.sin(this.age * 1.1) + this._strikeLit(); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the wall stands, on the floor. */ + _wallPoint(out) { + return this.pointAt(1, out); + } + + /** Half the wall's length, metres — measured across the heading. */ + _halfWidth() { + return Math.max(0.05, settings.stormwall.wallWidth) * 0.5; + } + + /** + * The rank pitch, metres. + * + * `wallWidth` is the promise, so the pitch is read off it rather than being + * its own slider: sizing sheets independently and hoping they add up to a + * wall is how you get a wall with a hole in it. `sheetOverlap` then decides + * how much wider than the pitch one sheet is — below about 1.2 you can see + * between them and it reads as a fence. + */ + _pitch() { + const c = settings.stormwall; + return Math.max(0.05, c.wallWidth) / Math.max(1, Math.round(c.sheets)); + } + + /** 0..1 how much of the strike is left. */ + _strikeLit() { + const c = settings.stormwall; + const since = this.age - this._strikeAt; + if (since < 0) return 0; + return saturate(1 - since / Math.max(0.02, c.strikeHold)); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sprayEmitter.reset(); + this.mistEmitter.reset(); + + this._seed = Math.random() * 100; + this._strikeAt = -1e4; + this._strikeX0 = 0; + this._strikeX1 = 0; + this._strikeSeed = 0; + this._boltCount = 0; + + this.cloth.reset(); + this.cloth.roll(this._seed); + this.cloth.visible = false; // the wall does not exist until the squall lands + this.wet.setVisible(true); + this.paths.clear(); + this.paths.visible = true; + + this._syncCloth(0, 0); + this._syncWet(0, 0, 0); + this._syncBolt(0, 0); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * The curtain: the rank, the cloth, the two curves and the rain. + * + * Called on every frame including those where the wall is not drawn, so the + * whole folder stays live under the editor while the squall is still on its + * way out. An invisible curtain costs a few dozen uniform writes. + * + * @param {number} rise 0..1 how far up it is + * @param {number} live 0..1 master on opacity — the cast's own aliveness + */ + _syncCloth(rise, live) { + const c = settings.stormwall; + const g = settings.global; + + this._wallPoint(_anchor); + // THE TRICK, and it is this one argument: the rank is laid along the cast's + // SIDE vector, so the line you aimed is the wall's normal. + this.cloth.setPlacement(_anchor, this.side, UP); + + const pitch = this._pitch(); + + _cloth.count = Math.round(c.sheets); + _cloth.spacing = pitch; + _cloth.scatter = c.sheetScatter; + _cloth.seed = this._seed; + + _cloth.width = pitch * Math.max(0.05, c.sheetOverlap); + _cloth.widthJitter = c.sheetWidthJitter; + _cloth.height = Math.max(0.05, c.wallHeight); + _cloth.heightJitter = c.sheetHeightJitter; + // The drain takes the foot under the floor as well as the head down to it, + // which is what stops the last few centimetres reading as a puddle of cloth. + _cloth.base = -c.fallSink * this._drain(); + _cloth.taper = c.sheetTaper; + _cloth.lean = c.sheetLean; + _cloth.leanJitter = c.sheetLeanJitter; + _cloth.rise = rise; + _cloth.riseSpread = c.riseSpread; + + _cloth.rippleAmp = c.rippleAmp * g.turbulence; + _cloth.rippleLength = c.rippleLength; + _cloth.rippleSpeed = c.rippleSpeed * g.speed; + _cloth.rippleCurve = c.rippleCurve; + _cloth.foldAmp = c.foldAmp * g.turbulence; + _cloth.foldLength = c.foldLength; + _cloth.foldSpeed = c.foldSpeed * g.speed; + _cloth.rippleNoise = c.rippleNoise * g.noiseStrength; + _cloth.rippleNoiseScale = c.rippleNoiseScale * g.noiseFrequency; + _cloth.rippleNoiseSpeed = c.rippleNoiseSpeed * g.noiseSpeed; + _cloth.phaseSpread = c.phaseSpread; + + // These two are meant to disagree — see the module's "the mismatch is the + // effect". Set them equal once and the wall becomes a hanging ribbon. + _cloth.alphaBase = c.alphaBase; + _cloth.alphaTop = c.alphaTop; + _cloth.alphaCurve = c.alphaCurve; + _cloth.emissionBase = c.emissionBase; + _cloth.emissionTop = c.emissionTop; + _cloth.emissionCurve = c.emissionCurve; + + _cloth.body = c.body; + _cloth.footFade = c.footFade; + _cloth.headFade = c.headFade; + _cloth.edgeFade = c.edgeFade; + _cloth.graze = c.graze; + _cloth.grazeFloor = c.grazeFloor; + _cloth.softFade = c.curtainSoftFade; + _cloth.opacity = c.curtainOpacity * g.opacity * live; + _cloth.glow = c.curtainGlow * g.glow; + _cloth.tintSpread = c.tintSpread; + + _cloth.streakDensity = c.streakDensity; + _cloth.streakRepeat = c.streakRepeat; + _cloth.streakSpeed = c.streakSpeed * g.speed; + _cloth.streakSpeedJitter = c.streakSpeedJitter; + _cloth.streakWidth = c.streakWidth; + _cloth.streakTail = c.streakTail; + _cloth.streakDuty = c.streakDuty; + _cloth.haze = c.haze; + + _cloth.colorA = c.colorRainTail; + _cloth.colorB = c.colorRainHead; + _cloth.colorBody = c.colorRainBody; + + // The clock argument is ignored — the sheets run off frame.uTime inside the + // shader — but it is passed honestly rather than as a zero, because the + // signature will grow a use for it before the module does. + this.cloth.update(this.age, _cloth); + } + + /** + * The soaked floor. + * + * @param {number} grow 0..1 the front spreading out + * @param {number} recede 0..1 the outside drying back in + * @param {number} fade 0..1 master + */ + _syncWet(grow, recede, fade) { + const c = settings.stormwall; + const g = settings.global; + + this._wallPoint(_anchor); + // Under the wall *and in front of it*, so the sheen reads from the caster's + // side as well as through the curtain. + _wetCentre.copy(_anchor).addScaledVector(this.direction, c.wetForward); + + _wet.centre = _wetCentre; + _wet.yaw = Math.atan2(this.direction.x, this.direction.z); + _wet.height = c.wetHeight; + _wet.radius = Math.max(0.1, c.wetRadius); + + _wet.grow = grow; + _wet.recede = recede; + _wet.fade = fade; + _wet.seed = this._seed; + + _wet.edge = c.wetEdge; + _wet.ragged = c.wetRagged; + _wet.raggedScale = c.wetRaggedScale; + _wet.warp = c.wetWarp; + + _wet.relief = c.wetRelief; + _wet.normalStep = c.wetNormalStep; + _wet.ambient = c.wetAmbient; + _wet.wrap = c.wetWrap; + _wet.specular = c.wetSpecular; + _wet.gloss = c.wetGloss; + _wet.parallax = c.wetParallax; + + _wet.cell = c.wetCell; + _wet.cellJitter = c.wetCellJitter; + _wet.lift = c.wetLift; + _wet.depth = c.wetDepth; + _wet.sharp = c.wetSharp; + _wet.detail = c.wetDetail; + _wet.speed = c.wetSpeed; + _wet.flow = c.wetFlow; + _wet.windAngle = c.wetWind; + + _wet.additive = false; + _wet.emissive = c.wetEmissive; + _wet.opacity = c.wetOpacity; + _wet.depthFade = c.wetDepthFade; + _wet.colorBase = c.colorWetBase; + _wet.colorEdge = c.colorWetEdge; + _wet.colorGlow = c.colorWetGlow; + _wet.colorDeep = c.colorWetDeep; + + _wet.noiseStrength = g.noiseStrength; + _wet.noiseFrequency = g.noiseFrequency; + _wet.noiseSpeed = g.noiseSpeed; + _wet.opacityScale = g.opacity; + + this.wet.update(_wet); + } + + /** + * The lightning: a `CRACK` inside the wall's plane, and a crawl along its + * foot. + * + * Both roles are filled from settings every frame, which is the module's one + * rule — a role setter takes resolved metres, so the call belongs next to the + * settings read that produced them and never in `onSpawn`. + * + * @param {number} lit 0..1 how much of the current strike is left + * @param {number} live 0..1 the wall's own aliveness + */ + _syncBolt(lit, live) { + const c = settings.stormwall; + const g = settings.global; + + this._wallPoint(_anchor); + const half = this._halfWidth(); + const height = Math.max(0.05, c.wallHeight); + + /* --- the strike, in the plane of the wall --- */ + // `side` and world up span the wall. Nothing here touches `direction`, + // which is what keeps the discharge inside the water. + _from + .copy(_anchor) + .addScaledVector(this.side, this._strikeX0 * half) + .addScaledVector(UP, c.boltTop * height); + _to + .copy(_anchor) + .addScaledVector(this.side, this._strikeX1 * half) + .addScaledVector(UP, c.boltBottom * height); + + this._boltCount = lit > 0 ? Math.min(MAX_BOLT, Math.round(c.boltFilaments)) : 0; + this.bolt.count = this._boltCount; + this.bolt + .crack(_from, _to, c.crackAngle, c.crackLength, c.crackFalloff, c.crackSpread, + c.crackStart, c.crackSag, c.crackFork) + .style(1, 1, Math.pow(lit, 0.6), 1) + .ends(c.boltTaper, c.boltTaper, c.boltTaper, c.boltTaper) + .draw(2, 0.08, 0, 1.6); + + /* --- and the current crawling along the soaked foot --- */ + _from.copy(_anchor).addScaledVector(this.side, -half).setY(c.footFloor); + _to.copy(_anchor).addScaledVector(this.side, half).setY(c.footFloor); + + this.foot.count = Math.min(MAX_FOOT, Math.round(c.footFilaments * live)); + this.foot + .line(_from, _to, c.footSag, c.footSpread * 0.35, c.footSpread, 1.0, 0.25, 0.35, 0.85) + // groundDamp below ~0.4 on anything running flat, or half of every + // filament buries itself in the floor and the crawl reads as a dotted + // line. The snare learnt that the hard way; `footFloor` clamps the rest. + .style(c.footKink, 0.7, c.footDim * live, c.footDamp) + .ends(1, 1, 1, 1) + .draw(2, 0.06, c.footFloor, 0.4); + + /* --- the shared look --- */ + _look.width = c.filWidth; + _look.glowWidth = c.filGlowWidth; + _look.glowOpacity = c.filGlowOpacity; + _look.jitter = c.filJitter; + _look.jitterScale = c.filJitterScale; + _look.octaves = c.filOctaves; + _look.jitterFalloff = c.filJitterFalloff; + _look.crawl = c.filCrawl; + _look.pinch = c.filPinch; + _look.restrike = c.filRestrike; + _look.flicker = c.filFlicker; + _look.flickerSpeed = c.filFlickerSpeed; + _look.strandFlash = c.filStrandFlash; + _look.coreSharp = c.filCoreSharp; + _look.glowFalloff = c.filGlowFalloff; + _look.softFade = c.filSoftFade; + _look.opacity = c.filOpacity; + _look.glow = c.filGlow; + _look.colorCore = c.colorBoltCore; + _look.colorInner = c.colorBoltInner; + _look.colorOuter = c.colorBoltOuter; + _look.colorHalo = c.colorBoltHalo; + + _look.randomness = g.randomness; + _look.noiseStrength = g.noiseStrength; + _look.noiseFrequency = g.noiseFrequency; + _look.noiseSpeed = g.noiseSpeed; + _look.opacityScale = g.opacity; + _look.glowScale = g.glow; + + this.paths.sync(_look, 1, this._seed + this._strikeSeed); + } + + /** + * Roll a new strike. + * + * The three numbers a cast writes down are all here and all unitless: where + * each end of the trunk sits across the wall as a fraction of the half-width, + * and a seed. What that becomes in metres is decided by `_syncBolt` on the + * frame it is drawn. + */ + _strike() { + const c = settings.stormwall; + const g = settings.global; + + this._strikeAt = this.age; + this._strikeSeed = Math.random() * 40; + this._strikeX0 = randRange(-0.85, 0.85); + this._strikeX1 = clamp(this._strikeX0 + randRange(-c.boltDrift, c.boltDrift), -1, 1); + + this._wallPoint(_anchor); + _pos + .copy(_anchor) + .addScaledVector(this.side, this._strikeX1 * this._halfWidth()) + .setY(Math.max(0.05, c.wallHeight) * c.boltBottom + 0.1); + + _emit.position = _pos; + _emit.radius = 0.25; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.7; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.sparks.emit(Math.round(c.strikeSparks * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorStrikeFlash), c.strikeFlash * g.explosionIntensity); + this.lightBoost = c.strikeLight * g.explosionIntensity; + } + + /** Push the live gradients and scales into the three particle systems. */ + _syncParticles() { + const c = settings.stormwall; + const g = settings.global; + + this.spray.setGradient( + getColor(c.colorSprayA), + getColor(c.colorSprayB), + getColor(c.colorSprayC), + getColor(c.colorSprayD) + ); + this.spray.uniforms.uGravity.value.set(0, c.sprayGravity, 0); + this.spray.uniforms.uSizeScale.value = c.spraySize * g.particleSize * 7; + this.spray.uniforms.uLifeScale.value = c.sprayLifetime * 0.5 * g.particleLifetime; + this.spray.uniforms.uSpeedScale.value = g.particleSpeed; + this.spray.uniforms.uOpacity.value = g.opacity; + + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.filGlow * 0.5 * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + } + + /** + * Spray off the wall's foot, hung on the sheets themselves. + * + * `sheetPoint()` reads the same dice the vertex shader reads, so a droplet + * leaves the sheet it is meant to leave rather than the average position of + * the rank — which is what you get from emitting at the anchor with a radius, + * and it shows the moment `sheetScatter` is anything but zero. + */ + _wallFx(dt, live) { + const c = settings.stormwall; + const g = settings.global; + const time = frame.uTime.value; + const count = Math.max(1, Math.round(c.sheets)); + + const sprayCount = Math.round(this.sprayEmitter.tick(dt, c.sprayRate * live) * g.particleCount); + if (sprayCount > 0) { + const sheet = Math.floor(Math.random() * count); + this.cloth.sheetPoint(sheet, _cloth, _pos, randRange(-0.9, 0.9), 0); + _pos.y = 0.04; + _emit.position = _pos; + _emit.radius = this._pitch() * 0.5; + // Off the face rather than straight up: the drops are bouncing, and the + // face they bounce off is the one whose normal is the cast heading. + _emit.direction = _dir.copy(this.direction).multiplyScalar(randRange(-1, 1) * 0.5).setY(1).normalize(); + _emit.speed = c.spraySpeed; + _emit.speedVariance = 0.8; + _emit.spread = saturate(c.spraySpread); + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spray.emit(sprayCount, _emit); + } + + const mistCount = Math.round(this.mistEmitter.tick(dt, c.mistRate * live) * g.particleCount); + if (mistCount > 0) { + const sheet = Math.floor(Math.random() * count); + this.cloth.sheetPoint(sheet, _cloth, _pos, randRange(-1, 1), randRange(0, 0.35)); + _emit.position = _pos; + _emit.radius = this._pitch(); + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.4).setY(1).normalize(); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.size = 1.2; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.25; + _emit.time = time; + this.mist.emit(mistCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.stormwall; + const g = settings.global; + + // Nothing stands yet. The squall runs out along the aimed line and the + // floor at the target darkens ahead of it, so the wall has somewhere to + // come up out of. + this.cloth.visible = false; + this._syncCloth(0, 0); + this._syncWet(this.u, 0, this.u); + this._syncBolt(0, 0); + this._syncParticles(); + + const mistCount = Math.round(this.mistEmitter.tick(dt, c.mistRate * 0.6) * g.particleCount); + if (mistCount > 0) { + _pos.copy(this.position).setY(0.2); + _emit.position = _pos; + _emit.radius = 0.6; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.7).setY(0.5).normalize(); + _emit.speed = c.mistSpeed * 1.6; + _emit.speedVariance = 0.7; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime * 0.6; + _emit.lifeVariance = 0.4; + _emit.spin = 0.2; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.mist.emit(mistCount, _emit); + } + } + + onImpact() { + const c = settings.stormwall; + const g = settings.global; + + this.cloth.visible = true; + // `CameraShake#add` already folds in `global.cameraShake`, so this does not + // apply it a second time. + this.ctx.shake.add(c.riseShake * g.explosionIntensity, 1 / Math.max(0.05, c.riseShakeTime), 18); + // The first strike lands as the wall tops out rather than on a timer, so + // the rise has a punctuation mark on it. + this._strikeAt = -1e4; + } + + onFade(dt, _t) { + const c = settings.stormwall; + + const drain = this._drain(); + const rise = this._rise(); + const standing = this.phase === AbilityPhase.FADE ? 1 - drain : 1; + + this._wallPoint(_anchor); + this.position.copy(_anchor); + this.position.y = Math.max(0.1, c.wallHeight * 0.4); + + /* --- the lightning restrikes on its own clock --- */ + // Gated on a real frame: a strike is an *event*, and a paused frame has no + // events in it. Without the guard, dragging `strikeRate` with the clock + // stopped would fire a new bolt on every probe. + const period = 1 / Math.max(0.05, c.strikeRate); + const alive = rise > 0.35 && standing > 0.15; + if (dt > 0 && alive && this.age - this._strikeAt >= period) this._strike(); + const lit = alive ? this._strikeLit() : 0; + + this.cloth.visible = rise > 0.001; + this._syncCloth(rise, standing); + this._syncWet(1, drain * saturate(c.wetDry), 1 - Easing.inQuad(drain)); + this._syncBolt(lit, standing); + this._syncParticles(); + + this._wallFx(dt, rise * standing); + this.ctx.shake.rumble(c.holdRumble * standing, dt); + } + + onDestroy() { + this.cloth.reset(); + this.paths.clear(); + this.wet.setVisible(false); + this._boltCount = 0; + this._strikeAt = -1e4; + } + + dispose() { + this.cloth.dispose(); + this.paths.dispose(); + this.wet.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/storm/ThunderclapAbility.js b/src/abilities/storm/ThunderclapAbility.js new file mode 100644 index 0000000..a5f9bc7 --- /dev/null +++ b/src/abilities/storm/ThunderclapAbility.js @@ -0,0 +1,655 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { DistortionField, DistortionMode, DistortionFacing } from '../../vfx/Distortion.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** + * How many points around the wavefront one frame's dust is split between. + * + * The first version emitted the whole frame's batch from a single bearing and + * the "expanding ring of dust" came out as a rotating hose — a jet of dust + * sweeping round the circle rather than a ring leaving it. Six is enough that + * the ring closes at any sane rate; below three you can still see the spokes. + */ +const RING_BATCHES = 6; + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _anchor = new Vector3(); +const _air = {}; +const _floorParams = {}; + +/** + * THUNDERCLAP — a far cast whose whole read is the **delay**. + * + * Every other impact in the project is simultaneous: the flash, the shell, the + * shake and the debris all land on the same frame, because that is what an + * explosion is. A thunderclap is not an explosion, it is a *report*, and the + * one thing everybody has actually experienced about it is that the light and + * the pressure arrive at different times. + * + * So the ability is three beats and the middle one is empty: + * + * 1. **`clapTime`** — a hard white flash and a `Shell(DOME)` that is fully out + * within a tenth of a second and then gone. No dust. No debris. No shake. + * 2. **`gapTime`** — *nothing*. The dome is not drawn, both refraction + * emitters are released, no emitter ticks, and `lightShimmer()` pulls the + * dynamic light down to `gapGlow`. Deliberately, conspicuously empty. + * 3. **`frontTime`** — the pressure front arrives. `frontRings` concentric + * `SHOCK` fronts cross out to `zoneRadius` through **two** distortion + * emitters — one billboarded for the air, one lying flat for the stone — + * and the camera shake, the dust and the grit are all on *this* beat. + * + * Take the gap out and this is another shockwave slot. That is why `gapTime` is + * the first slider in the editor folder and why nothing else in the ability is + * allowed to bleed across it: the emptiness is load-bearing. + * + * **Two things about the refraction, both of which cost somebody a day + * elsewhere.** `DistortionField` magnitudes are *screen fractions*, not metres, + * and the post pass already multiplies `post.distortion × global.distortion` + * into them — so this file never does. And the pass counts *visible* emitters + * to decide whether to run at all, so the beats toggle `field.visible` rather + * than hiding the parent group, which would leak the writer for the session. + * + * **What a cast captures.** One seed and a handful of timestamps. The beat + * boundaries are recomputed from `settings.thunderclap` on every frame, + * zero-length ones included, so pausing mid-gap and dragging `gapTime` down + * makes the front arrive with the clock stopped — and dragging it back up + * re-arms the boom, because the crossing is tested against the live boundary + * rather than latched forever. + */ +export class ThunderclapAbility extends Ability { + constructor(context) { + super('thunderclap', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- beat 1: the dome, and the knot that carries it downrange --- */ + this.dome = new Shell({ mode: ShellMode.DOME, prefix: 'dome', nodes: 40, sides: 56 }); + this.group.add(this.dome.group); + + /* --- beat 3: the same wavefront, written twice --- */ + // Two emitters rather than one because "it shoves the dust" and "it shoves + // the floor" are different pictures. A billboard ring warps whatever is + // behind it at any height, which is the air; a ground ring warps only the + // stone, and reads as the front running out under your feet. One of them + // alone always looks like half of the effect. + this.air = new DistortionField({ + mode: DistortionMode.SHOCK, + facing: DistortionFacing.BILLBOARD, + renderOrder: 2, + name: 'thunderclap.air' + }); + this.floor = new DistortionField({ + mode: DistortionMode.SHOCK, + facing: DistortionFacing.GROUND, + renderOrder: 1, + name: 'thunderclap.floor' + }); + this.group.add(this.air.object3D); + this.group.add(this.floor.object3D); + + /** Re-rolled per cast so two claps do not billow identically. */ + this._seed = 0; + /** Latch on beat 3. Re-armed whenever the clock is back before the boom. */ + this._boomed = false; + /** Metres of wavefront travel already paid out in dust puffs. */ + this._frontDistance = 0; + + // Scratch handed to the shell each frame. One object, reused. + this._state = { + origin: new Vector3(), + axis: new Vector3(0, 1, 0), + side: new Vector3(1, 0, 0), + span: 0, + t: 0, + fade: 1, + seed: 0 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // The dust the front shoves. Non-additive so it genuinely occludes — a + // pressure front that adds light reads as fire. + this.dust = particles.get('thunderclap.dust', { + capacity: 2200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.dust.uniforms.uDrag.value = 2.2; + this.dust.uniforms.uEndSize.value = 3.4; + this.dust.uniforms.uSizeIn.value = 0.1; + this.dust.uniforms.uFadeIn.value = 0.12; + this.dust.uniforms.uFadeOut.value = 0.35; + + // Chips lifted off the floor as the front passes over them. + this.grit = particles.get('thunderclap.grit', { + capacity: 1400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.3; + this.grit.uniforms.uEndSize.value = 0.8; + this.grit.uniforms.uFadeOut.value = 0.65; + + this.dustEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — the three beats, re-resolved every frame */ + /* ------------------------------------------------------------------ */ + + /** Beat 1, seconds. */ + _clapSpan() { + return Math.max(0.01, settings.thunderclap.clapTime * settings.global.lifetime); + } + + /** Beat 2 — the gap — seconds. May legitimately be zero. */ + _gapSpan() { + return Math.max(0, settings.thunderclap.gapTime * settings.global.lifetime); + } + + /** Beat 3, seconds. */ + _frontSpan() { + return Math.max(0.02, settings.thunderclap.frontTime * settings.global.lifetime); + } + + /** The three beats end to end. */ + get impactDuration() { + return this._clapSpan() + this._gapSpan() + this._frontSpan(); + } + + get fadeDuration() { + return Math.max(0.05, settings.thunderclap.fadeTime); + } + + get instanceCount() { + return this.dome.visible ? 1 : 0; + } + + /** + * Seconds since the clap landed. + * + * Deliberately *not* the `t` the base class hands `onFade`: that was divided + * by the impact duration one frame ago, and the whole point of this ability + * is that the duration is three sliders which may move underneath a standing + * cast. Rebuilding the local clock from `impactTime` and the live + * `impactDuration` is what lets a paused `gapTime` drag walk the effect + * forward and backward through its own beats. + */ + _clock() { + if (this.phase === AbilityPhase.FADE) return this.impactDuration + this.fadeTime; + return this.impactTime; + } + + /** + * The dynamic light, beat by beat. + * + * This is where the gap is enforced hardest. A clap that keeps a glow burning + * through beat 2 reads as one continuous event with a lull in it; a clap that + * goes properly dark reads as two events, which is the truth. + */ + lightShimmer() { + const c = settings.thunderclap; + if (this.phase === AbilityPhase.TRAVEL) return saturate(c.knotFade); + + const local = this._clock(); + const clap = this._clapSpan(); + const boomAt = clap + this._gapSpan(); + const gap = saturate(c.gapGlow); + + if (local < clap) return 1; + if (local < boomAt) return gap; // BEAT 2 — nothing + const t3 = saturate((local - boomAt) / this._frontSpan()); + return gap + (1 - gap) * Math.pow(1 - t3, Math.max(0.05, c.frontDecay)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the clap lands, on the floor. */ + _impactPoint(out) { + return this.pointAt(1, out); + } + + /** + * The wavefront radius at `t3`, metres. + * + * `zoneRadius` is where it stops, which is the circle the aim indicator drew + * before the click — the promise and the payoff are one number, exactly as + * they are for the snare. The easing is `1 − (1 − t)^expand`, the same curve + * `Shell` expands on, so `frontExpand` above 1 means the front leaves fast + * and eases into its final radius rather than crawling out linearly. + */ + _wave(t3) { + const c = settings.thunderclap; + const eased = 1 - Math.pow(1 - saturate(t3), Math.max(0.05, c.frontExpand)); + return Math.max(0.01, c.zoneRadius) * eased; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.gritEmitter.reset(); + this._boomed = false; + this._frontDistance = 0; + + // The one thing a cast captures, and it is unitless. + this._seed = Math.random() * 100; + + this.dome.visible = true; + this.air.visible = false; + this.floor.visible = false; + + this._sync(0); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current beat into the dome and both + * emitters. + * + * Both refraction fields are updated on *every* frame whether or not they are + * drawn, and only their amplitude is gated by the beat. That costs nothing — + * an invisible emitter is not rasterised — and it means `airThickness`, + * `floorSpan` and the rest are live sliders during the travel and the gap as + * well as during the front, which is what you want when you are tuning a + * quarter-second event by dragging it. + * + * @param {number} amp 0..1 how much of beat 3 is happening + * @param {number} t3 0..1 through the front's crossing + */ + _sync(t3, amp = 0) { + const c = settings.thunderclap; + const g = settings.global; + + this._impactPoint(_anchor); + const wave = this._wave(t3); + const radius = Math.max(0.05, c.zoneRadius); + + /* --- the front in the air --- */ + this.air.setAnchorXYZ(_anchor.x, radius * Math.max(0, c.airHeight) * 0.5, _anchor.z); + _air.width = radius * Math.max(0.2, c.airSpan) * 2; + _air.height = radius * Math.max(0.05, c.airHeight); + // NEVER multiply global.distortion or post.distortion in here — the pass + // applies both, once, and folding them in doubles them for this one slot. + _air.strength = c.airStrength * amp; + _air.radius = radius; + _air.window = c.airWindow; + _air.maxOffset = c.airMaxOffset; + _air.wave = wave; + _air.thickness = c.airThickness; + _air.compression = c.airCompression; + _air.rarefaction = c.airRarefaction; + _air.rings = c.frontRings; + _air.ringGap = c.frontRingGap; + _air.ringDecay = c.frontRingDecay; + _air.depthReject = c.frontDepthReject; + _air.depthFade = c.frontDepthFade; + _air.perspective = c.airPerspective; + _air.perspectiveRef = c.airPerspectiveRef; + _air.seed = this._seed; + this.air.update(_air); + + /* --- and the same front in the floor --- */ + this.floor.setAnchorXYZ(_anchor.x, Math.max(0.002, c.floorHeight), _anchor.z); + _floorParams.width = radius * Math.max(0.2, c.floorSpan) * 2; + _floorParams.height = radius * Math.max(0.2, c.floorSpan) * 2; + _floorParams.strength = c.floorStrength * amp; + _floorParams.radius = radius; + _floorParams.window = c.floorWindow; + _floorParams.maxOffset = c.floorMaxOffset; + _floorParams.wave = wave; + _floorParams.thickness = c.floorThickness; + _floorParams.compression = c.floorCompression; + _floorParams.rarefaction = c.floorRarefaction; + _floorParams.rings = c.frontRings; + _floorParams.ringGap = c.frontRingGap; + _floorParams.ringDecay = c.frontRingDecay; + _floorParams.depthReject = c.frontDepthReject; + _floorParams.depthFade = c.frontDepthFade; + _floorParams.seed = this._seed + 11; + this.floor.update(_floorParams); + + /* --- the particle systems --- */ + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = c.gritLifetime * 0.5 * g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + } + + /** + * The dome, wherever it currently is and however far through beat 1 it is. + * + * @param {THREE.Vector3} at where the hemisphere is seated + * @param {number} t 0..1 through the dome's expansion + * @param {number} fade 0..1 master + */ + _syncDome(at, t, fade) { + const state = this._state; + state.origin.copy(at); + state.origin.y = 0; + state.axis.set(0, 1, 0); + state.side.copy(this.side); + state.t = saturate(t); + state.fade = saturate(fade); + state.seed = this._seed; + this.dome.sync(settings.thunderclap, state, settings.global); + } + + /* ------------------------------------------------------------------ */ + /* Beat 3 — the one-shot and the crossing */ + /* ------------------------------------------------------------------ */ + + /** The frame the pressure front launches. Everything that punches is here. */ + _boom() { + const c = settings.thunderclap; + const g = settings.global; + const time = frame.uTime.value; + + this._frontDistance = 0; + this._impactPoint(_anchor); + + /* the ring it leaves on the floor */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _anchor, { + radius: Math.max(0.05, c.zoneRadius), + life: c.shockLife, + width: c.shockWidth, + intensity: c.shockIntensity, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* the dust and the chips it lifts as it goes */ + _emit.position = _anchor; + _emit.radius = Math.max(0.05, c.zoneRadius) * 0.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dustSpeed * 1.4; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.1; + _emit.sizeVariance = 0.6; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.dust.emit(Math.round(c.boomDust * g.particleCount), _emit); + + _emit.speed = c.gritSpeed * 1.6; + _emit.size = 0.12; + _emit.life = c.gritLifetime; + _emit.spin = 9; + this.grit.emit(Math.round(c.boomGrit * g.particleCount), _emit); + + // `CameraShake#add` already multiplies `global.cameraShake` in, so this + // does not — the shipped six fold it in a second time and end up with the + // square of the master slider. + this.ctx.shake.add( + c.boomShake * g.explosionIntensity, + 1 / Math.max(0.05, c.boomShakeTime), + 24 + ); + this.ctx.flash.trigger(getColor(c.colorBoomFlash), c.boomFlash * g.explosionIntensity); + this.lightBoost = c.boomLight * g.explosionIntensity; + } + + /** + * Dust and grit thrown off the wavefront itself, and the puffs it leaves. + * + * Emitted *on the ring*, not at the centre: a bearing is rolled per batch and + * the outward normal is built from the cast's own flat frame, so the particles + * leave along the front rather than being sprayed from the middle and + * happening to arrive. + */ + _frontFx(dt, wave, amp) { + const c = settings.thunderclap; + const g = settings.global; + const time = frame.uTime.value; + + this._impactPoint(_anchor); + + let dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * amp) * g.particleCount); + if (dustCount > 0) { + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.7; + _emit.spread = saturate(c.dustSpread); + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.9; + _emit.sizeVariance = 0.6; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = time; + + const batches = Math.min(dustCount, RING_BATCHES); + const per = Math.ceil(dustCount / batches); + while (dustCount > 0) { + const bearing = Math.random() * TAU; + this._ringPoint(bearing, wave, _pos, _dir); + _pos.y = 0.12; + _emit.position = _pos; + _emit.radius = c.dustSize * 0.6; + _dir.y = 0.45; + _dir.normalize(); + _emit.direction = _dir; + this.dust.emit(Math.min(per, dustCount), _emit); + dustCount -= per; + } + } + + const gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate * amp) * g.particleCount); + if (gritCount > 0) { + const bearing = Math.random() * TAU; + this._ringPoint(bearing, wave, _pos, _dir); + _pos.y = 0.05; + _emit.position = _pos; + _emit.radius = c.ringRadius * 0.4; + _dir.y = 1.1; + _dir.normalize(); + _emit.direction = _dir; + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.7; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 8; + _emit.time = time; + this.grit.emit(gritCount, _emit); + } + + /* --- puffs paid out per metre of front travel, not per second --- */ + // Keyed off distance so the ring of marks stays evenly spaced whether the + // front is snapping out in a fifth of a second or rolling out over two. + const step = 1 / Math.max(0.05, c.ringRate); + let guard = 24; + while (wave - this._frontDistance >= step && guard-- > 0) { + this._frontDistance += step; + const bearing = Math.random() * TAU; + this._ringPoint(bearing, this._frontDistance, _pos, _dir); + this.ctx.decals.spawn(DecalType.DUSTRING, _pos, { + radius: c.ringRadius * randRange(0.7, 1.3), + life: c.ringLife, + intensity: c.ringIntensity, + colorA: getColor(c.colorRingA), + colorB: getColor(c.colorRingB) + }); + } + } + + /** + * A point on the wavefront at `bearing`, plus the outward normal there. + * + * Built from the cast's own `side` and `direction` — both flat and + * orthonormal already — so no basis is constructed and nothing allocates. + */ + _ringPoint(bearing, radius, outPoint, outNormal) { + const cos = Math.cos(bearing); + const sin = Math.sin(bearing); + outNormal.copy(this.side).multiplyScalar(cos).addScaledVector(this.direction, sin); + outPoint.copy(_anchor).addScaledVector(outNormal, radius); + return outPoint; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.thunderclap; + + // The knot of compressed air riding the front. Held at the shell's start + // radius (t = 0) so `domeRadius` is what you are looking at, and dimmed to + // `knotFade` — it is on screen for a tenth of a second and it is not the + // point, but a clap that is switched on at the target has no cause. + this.dome.visible = true; + this._syncDome(this.position, 0, saturate(c.knotFade)); + this._sync(0, 0); + + // Nothing else happens on the way out. No dust, no light punch, no shake: + // beat 1 has not fired yet, and everything this ability owns belongs to a + // beat. + void dt; + } + + onImpact() { + const c = settings.thunderclap; + const g = settings.global; + + this._boomed = false; + this._frontDistance = 0; + this._impactPoint(_anchor); + + this.ctx.flash.trigger(getColor(c.colorClapFlash), c.clapFlash * g.explosionIntensity); + // Almost nothing. The punch is beat 3's and moving it here is precisely the + // mistake this ability exists to avoid. + this.ctx.shake.add(c.clapShake * g.explosionIntensity, 8, 30); + this.lightBoost = c.clapLight * g.explosionIntensity; + } + + onFade(dt, _t) { + const c = settings.thunderclap; + const g = settings.global; + + const local = this._clock(); + const clap = this._clapSpan(); + const boomAt = clap + this._gapSpan(); + const front = this._frontSpan(); + + this._impactPoint(_anchor); + // The light sits at the height of the dome's shoulder rather than on the + // floor, so the flash throws shadows down and outward. + this.position.copy(_anchor); + this.position.y = Math.max(0.1, c.domeRadiusEnd * 0.35); + + /* ---------------- beat 1 ---------------- */ + const t1 = saturate(local / clap); + const inClap = local <= clap; + this.dome.visible = inClap; + // The dome does not dim, it *goes*: cubic on the way out so it hangs at + // full brightness and then is simply not there. A linear fade turns the + // first beat into a soft glow, which reads as an explosion starting. + this._syncDome(_anchor, t1, inClap ? 1 - t1 * t1 * t1 : 0); + + /* ---------------- beat 3, and the latch ---------------- */ + // The crossing is tested against the *live* boundary, and the latch is + // re-armed whenever the clock is back before it. That is what makes a + // paused `gapTime` drag walk the boom backward and forward instead of + // firing once and never again. + const t3 = saturate((local - boomAt) / front); + if (local < boomAt) { + this._boomed = false; + } else if (!this._boomed) { + this._boomed = true; + this._boom(); + } + + const crossing = local >= boomAt && t3 < 1; + const amp = crossing ? Math.pow(1 - t3, Math.max(0.05, c.frontDecay)) : 0; + + this.air.visible = crossing; + this.floor.visible = crossing; + this._sync(t3, amp); + + if (crossing) { + this._frontFx(dt, this._wave(t3), amp); + this.ctx.shake.rumble(c.frontRumble * amp, dt); + } + // ... and between beat 1 and beat 3 this method deliberately does nothing + // at all. That silence is the ability. + void g; + } + + onDestroy() { + // Both of these release the distortion pass's writer counter. Hiding the + // group instead would leave the pass running for the rest of the session. + this.air.visible = false; + this.floor.visible = false; + this.dome.visible = false; + this._boomed = false; + this._frontDistance = 0; + } + + dispose() { + this.air.visible = false; + this.floor.visible = false; + this.dome.dispose(); + this.air.dispose(); + this.floor.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/tide/BrinelockAbility.js b/src/abilities/tide/BrinelockAbility.js new file mode 100644 index 0000000..2003e77 --- /dev/null +++ b/src/abilities/tide/BrinelockAbility.js @@ -0,0 +1,1207 @@ +import { BufferGeometry, Float32BufferAttribute, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { createBrineIceMaterial } from '../../materials/BrineIceMaterial.js'; +import { LiquidSurface, LiquidMode, liquidParams } from '../../vfx/LiquidSurface.js'; +import { GrowthField, GrowthLayout, GrowthEmerge } from '../../vfx/GrowthField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, hash11, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** + * Crowns held at once. This is **not** an arbitrary number: it is + * `RIPPLE_SLOTS` in `vfx/LiquidSurface.js`, and the two move together or the + * ability is writing off the end of the module's uniform array. + */ +const CROWN_SLOTS = 8; +/** Hard ceiling on fingers of ice. The editor's `blades` slider clamps here. */ +const MAX_BLADES = 216; +/** Distinct finger silhouettes — one `InstancedMesh` each, so one draw call each. */ +const BLADE_VARIANTS = 3; +/** + * Grid resolution of the lane. Below about 64 the Gerstner cusps facet on a + * sheet this long and thin; above about 160 the vertex cost buys detail the + * shading normal already carries, because the normal is four evaluations of the + * whole heightfield rather than a difference of neighbouring vertices. + */ +const LANE_SEGMENTS = 128; + +/* --- module-scope scratch: the frame allocates nothing (I3) --- */ +const _liq = liquidParams(); +/* The three lava terms the module ships on by default. Brine has no skin, is + not self-lit, and has no glowing seams; set once, here, rather than burned as + sliders nobody would ever move off zero. */ +_liq.crust = 0; +_liq.emissive = 0; +_liq.seamGlow = 0; +_liq.meltGlow = 0; + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _rel = new Vector3(); +const _par = new Vector3(); // parametric position on the sheet: (x, 0, z) metres +const _up = new Vector3(0, 1, 0); + +/* ---------------------------------------------------------------------- */ +/* One finger of a splash crown */ +/* ---------------------------------------------------------------------- */ + +/** + * A compact-support lobe — 1 at `at`, 0 outside `at ± w`, smooth at the join. + * + * A gaussian was the first thing tried and it is subtly wrong here: it never + * reaches zero, so a bead authored near the top of the finger leaves a + * millimetre of radius at `v = 1` and the tip is a flat disc rather than a + * point. This closes, exactly, which is what lets the profile end in a needle + * without a special case at the last ring. + */ +function lobe(v, at, w) { + const x = (v - at) / Math.max(w, 1e-3); + const k = 1 - x * x; + return k <= 0 ? 0 : k * Math.sqrt(k); +} + +/** + * Unit-space geometry for one frozen splash finger. + * + * The contract `GrowthField` imposes: footprint inside a circle of radius 0.5 + * on `y = 0`, tip at `y = 1`. An instance then scales footprint and height + * independently and `vGrowLocal.y` reads off in the fragment shader as "how far + * up this finger am I", which is what the aeration gradient keys off. + * + * The silhouette is a **Worthington finger**: a tapering stalk, necked in just + * below the top, carrying the bead of water it was about to shed. That bead is + * the whole reason this reads as a splash rather than as a spike — a plain cone + * is a stalagmite and a plain cone is what the first version drew. + * + * @param {number} variant 0..2 — three different histories of the same finger + * @param {object} shape live numbers from `settings.brinelock` + */ +function createCrownFingerGeometry(variant, shape) { + const facets = clamp(Math.round(shape.facets), 4, 24); + const rings = clamp(Math.round(shape.rings), 3, 32); + const taper = Math.max(0.05, shape.taper); + const waist = saturate(shape.waist); + const twist = shape.shapeTwist; + const jitter = Math.max(0, shape.facetJitter); + const width = clamp(shape.beadWidth, 0.03, 0.45); + + /* The three histories. + 0 — one bead, still attached: the common case. + 1 — already pinched once, so a big bead under a small one. + 2 — the bead has gone; a blunt, waisted stub left behind. */ + let beadA = clamp(shape.beadAt, 0.2, 1 - width); + let sizeA = Math.max(0, shape.bead); + let beadB = 0; + let sizeB = 0; + let stalk = taper; + let neck = waist; + if (variant === 1) { + beadA = clamp(shape.beadAt * 0.74, 0.2, 1 - width); + sizeA *= 0.82; + beadB = clamp(shape.beadAt * 1.16, beadA + width * 0.6, 1 - width * 0.65); + sizeB = sizeA * 0.5; + } else if (variant === 2) { + sizeA *= 0.14; + stalk = taper * 0.72; + neck = Math.min(0.85, waist * 1.5); + } + + // Everything is authored against a unit base radius and then divided by the + // worst case of the facet jitter, so the footprint is exactly 0.5 whatever + // the jitter slider is doing. + const scale = 0.5 / (1 + jitter); + + const levels = rings + 1; + const positions = new Float32Array(levels * facets * 3); + const indices = []; + + let write = 0; + for (let i = 0; i < levels; i++) { + const v = i / rings; + + let r = Math.pow(Math.max(0, 1 - v), stalk); + // The neck. A finger is thinnest just under the drop it is shedding, and + // that pinch is the single most recognisable thing about the silhouette. + const pinch = lobe(v, beadA * 0.6, 0.34); + r *= 1 - neck * pinch; + r += sizeA * lobe(v, beadA, width); + if (sizeB > 0) r += sizeB * lobe(v, beadB, width * 0.65); + // Never exactly zero: a degenerate last ring gives `computeVertexNormals` + // two zero-area triangles per facet to normalise and the tip goes to NaN. + r = Math.max(r, 0.006); + + const spin = twist * v * TAU; + for (let f = 0; f < facets; f++) { + const theta = (f / facets) * TAU + spin; + // Deterministic per (variant, facet, level band) so the finger is + // irregular but does not shimmer when the geometry is rebuilt. + const wobble = 1 + (hash11(variant * 91.7 + f * 13.3 + Math.floor(v * 3) * 5.1) - 0.5) * 2 * jitter; + const rr = r * wobble * scale; + positions[write++] = Math.cos(theta) * rr; + positions[write++] = v; + positions[write++] = Math.sin(theta) * rr; + } + } + + for (let i = 0; i < rings; i++) { + for (let f = 0; f < facets; f++) { + const next = (f + 1) % facets; + // Named for their corners rather than a/b/c/d: `c` is bound to + // `settings.brinelock` everywhere else in this file, and the static + // settings cross-check in `scripts/check.mjs` drops an alias that is ever + // bound to something else — one index variable here would blind it to the + // whole file. + const lowNear = i * facets + f; + const lowFar = i * facets + next; + const highNear = (i + 1) * facets + f; + const highFar = (i + 1) * facets + next; + indices.push(lowNear, highNear, lowFar, lowFar, highNear, highFar); + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +/** + * BRINELOCK — a lane of brine thrown up in splash crowns, and stopped. + * + * Four beats. A splash front runs down the aimed line at `speed`, punching a + * crown into the brine every `crownSpacing` metres. It lands, throws the big + * one, and the lane rings for `lockDelay` seconds. Then the water goes glassy + * still, and **locks**: a glaze front sweeps the lane over `glazeTime` and every + * crown in it stops mid-air as glass-clear ice. It stands for the rest of + * `lifetime` and melts back over `fadeTime`. + * + * **THE TRICK — the ice is the water, evaluated at the frame it stopped.** + * + * A crown here is not a mesh and not a particle burst. It is one analytic wave + * packet in `vfx/LiquidSurface.js` — a gaussian-enveloped cosine riding out at + * `rippleSpeed`, decaying as `e^{−age/rippleDecay}` and thinning as + * `1/(1 + r/rippleSpread)` — and its whole history is a closed-form function of + * its own age. Which means the height of the water at any point of the lane, at + * any moment, is a number this file can *ask for* rather than remember. + * + * So the handover is: freeze the water's clock at `_lockAt`, and give every + * finger of ice the height the water has at that finger's own footprint on that + * frozen frame. `_surfaceAt()` is a line-for-line mirror of `ripplesAt()` in + * `LIQUID_FIELD`, reading the *same* `uRipples` array the shader reads, so the + * two cannot disagree about where the crest is. A finger standing on a ring + * wall is tall; one standing in the trough behind it is `iceFloor` and + * invisible. The frozen lane is therefore not a row of spikes — it is the + * *record* of a splash, with tight rings at the far end where the crowns are + * young and broad transverse bars near the caster where they have spread. + * + * Three consequences worth stating, because each one was a bug first: + * + * **1 — the height is re-derived every frame, not captured.** `GrowthField` + * resolves an instance's height as `lerp(heightNear, height, …) × (1 + + * heightRoll × heightJitter × randomness)`, and `heightRoll` is documented as a + * unitless dice roll. Here it is not a dice roll: `heightNear`, `height` and + * `heightJitter` are pinned to 1 and the roll is rewritten every frame as + * `waterHeight × iceGain − 1`, so the field's own resolver hands back exactly + * the sampled metres. Nothing dimensional survives a frame boundary, and + * dragging `rippleAmp` or `rippleWidth` with the clock stopped **re-carves a + * standing sheet of ice**. That is the observable proof the handover is real + * rather than a look-alike. + * + * **2 — `randomness` is pinned to 1 in the growth params, and the jitters carry + * the global multiplier themselves.** They are the same number in the module's + * resolver, so `settings.global.randomness` would otherwise scale the *water's + * silhouette* along with the lean and the radius scatter. A global slider is + * not allowed to change what the water did. + * + * **3 — the lane is drawn at full length from the first frame, and this ability + * owns the ripple ring buffer.** `LiquidSurface.ripple()` stores a fraction of + * the *sheet*, which is exactly right for a pool of fixed size and exactly + * wrong here: growing the sheet behind the travelling front would slide every + * standing crown downrange as it grew, a metre a frame, and it looks like the + * whole lane is sliding away from you. The eight records here are fractions of + * the **cast** — `along` down the line, `across` the lane — plus a timestamp + * and a strength, and they are converted against this frame's half-extents and + * written straight into `uRipples` every frame. Same eight slots, same eviction + * (oldest, never newest), one frame of indirection, and the crowns stay where + * they were punched. + * + * **The one honest seam.** The swell rides `frame.uTime` and the ripples ride + * the ability's own clock, so freezing the second does not freeze the first. + * Rather than snap four sine waves off on the lock frame — a few centimetres of + * pop across the whole lane, small but visible on the silhouette the ice is + * about to claim — the swell and the chop are ramped to zero over `stillTime` + * *before* the lock, and the brine goes glassy still first. It also reads + * better: the lane stops moving, holds, and then goes hard. + * + * A cast captures a seed, four crown dice per crown, and two timestamps + * (`_lockAt`, and the crowns' births). Everything with a unit is resolved from + * `settings.brinelock` inside the update loop, zero-length frames included. + * + * **Four draw calls**: the lane, and three finger silhouettes. + */ +export class BrinelockAbility extends Ability { + constructor(context) { + super('brinelock', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the brine ---------------------------------------------------- */ + this.pool = new LiquidSurface({ + segments: LANE_SEGMENTS, + mode: LiquidMode.POOL, + // A heightfield is a solid: a crown must hide the lane behind it, or the + // whole thing reads as a sheet of cellophane laid on the floor. + depthWrite: true, + doubleSide: true, + renderOrder: 3, + name: 'brinelock.lane' + }); + this.pool.object3D.layers.set(LAYER.VFX); + this.group.add(this.pool.object3D); + + /* --- the ice ------------------------------------------------------ */ + this.iceMaterial = createBrineIceMaterial(this.ctx.environment); + + /** + * Live shape controls handed to the geometry factory. Mutated in place — an + * object literal per frame is the allocation I3 forbids — and compared + * numerically by `syncGeometry`, which rebuilds only when one of them moved. + */ + this._shape = { + facets: 9, + rings: 13, + taper: 1.35, + waist: 0.34, + bead: 0.38, + beadAt: 0.82, + beadWidth: 0.15, + shapeTwist: 0.22, + facetJitter: 0.16 + }; + this._fillShape(); + + this.ice = new GrowthField(this.group, { + geometry: createCrownFingerGeometry, + material: this.iceMaterial, + shape: this._shape, + variants: BLADE_VARIANTS, + capacity: MAX_BLADES, + layer: LAYER.WORLD, + renderOrder: 2, + // Ice this clear casting a hard opaque shadow of a needle reads as a + // black hair on the floor. It takes the stage's shadows; it does not + // throw one. + castShadow: false, + receiveShadow: true + }); + // Assigned once, at construction: a closure rebuilt per instance per frame + // is exactly the allocation I3 forbids. + this.ice.onBreach = (index, position, radius, height) => this._frostFx(position, radius, height); + + /** + * The crown ring buffer this ability owns. Four numbers per crown, three of + * them unitless and one a timestamp — see the class comment for why these + * are fractions of the *cast* and not of the sheet. + */ + this._crowns = []; + for (let i = 0; i < CROWN_SLOTS; i++) { + this._crowns.push({ along: 0, across: 0, born: -1, strength: 0 }); + } + /** Parametric centre of every crown on the sheet, (x, z) pairs, metres. */ + this._crownParam = new Float32Array(CROWN_SLOTS * 2); + this._nextCrown = 0; + + /* --- scratch parameter block, refilled from settings every frame --- */ + this._growth = { + layout: GrowthLayout.LINE, + // Ice does not push out of the floor: it arrives where the water already + // was. PUSH would bury every finger below the lane and slide it up. + emerge: GrowthEmerge.SCALE, + origin: new Vector3(), + direction: new Vector3(0, 0, 1), + side: new Vector3(1, 0, 0), + length: 1, + // Pinned. See consequence 1 in the class comment: the height ramp is + // neutral so `heightRoll` alone carries the sampled water height. + heightNear: 1, + height: 1, + heightCurve: 1, + heightJitter: 1, + crown: 0, + peak: 1, + rubble: 0, + randomness: 1 + }; + + /** Re-rolled per cast so two lanes do not freeze in the same pattern. */ + this._seed = 0; + /** `this.age` at the moment the brine locked, or −1. A timestamp. */ + this._lockAt = -1; + /** One-shot latch on the lock. */ + this._locked = false; + /** Fraction of the lane already paid out in crowns. Unitless. */ + this._crownAt = 0; + /** Fingers drawn last frame, for the HUD's instance readout. */ + this._live = 0; + /** The frozen water clock, resolved once per frame in `_sync`. */ + this._waterNow = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The brine in the air while the lane is live. Additive and soft: this is + // spray lit from every side, not grit. + this.spray = particles.get('brinelock.spray', { + capacity: 3000, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.3 + }); + this.spray.uniforms.uDrag.value = 1.5; + this.spray.uniforms.uEndSize.value = 0.3; + this.spray.uniforms.uSizeIn.value = 0.05; + this.spray.uniforms.uFadeIn.value = 0.05; + this.spray.uniforms.uFadeOut.value = 0.45; + + // What comes off a finger the instant it locks. Slow, rising, and it hangs + // — the cold coming out of the water, not the water itself. + this.frost = particles.get('brinelock.frost', { + capacity: 2200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.5 + }); + this.frost.uniforms.uDrag.value = 2.2; + this.frost.uniforms.uEndSize.value = 0.5; + this.frost.uniforms.uSizeIn.value = 0.08; + this.frost.uniforms.uFadeIn.value = 0.14; + this.frost.uniforms.uFadeOut.value = 0.5; + + // Chips off the melting ice. Lit and non-additive, so they read as solid + // against the glow — the contrast is what says "this was a solid". + this.shards = particles.get('brinelock.shards', { + capacity: 1400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.shards.uniforms.uDrag.value = 0.4; + this.shards.uniforms.uEndSize.value = 0.55; + this.shards.uniforms.uFadeOut.value = 0.6; + + this.sprayEmitter = new RateEmitter(); + this.shardEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + /** The lane rings, locks, and stands — all inside the impact phase. */ + get impactDuration() { + return Math.max(0.1, settings.brinelock.lifetime * settings.global.lifetime); + } + + /** The melt. */ + get fadeDuration() { + return Math.max(0.1, settings.brinelock.fadeTime); + } + + /* ------------------------------------------------------------------ */ + /* The beats */ + /* ------------------------------------------------------------------ */ + + /** + * Seconds since the lock beat should have started. + * + * Measured across the phase boundary rather than off `impactTime` alone, + * because `lockDelay` is a slider and nothing stops it being dragged past + * `lifetime` — at which point `impactTime` has already stopped accumulating + * and the lane would ring for ever. + */ + _lockElapsed() { + if (this.phase === AbilityPhase.TRAVEL) return -1; + if (this.phase === AbilityPhase.IMPACT) return this.impactTime; + return this.impactDuration + this.fadeTime; + } + + /** 0..1 — how far the swell has died on its way to the lock. */ + _still() { + if (this._locked) return 1; + const c = settings.brinelock; + const window = Math.max(0.02, c.stillTime); + return saturate((this._lockElapsed() - (c.lockDelay - window)) / window); + } + + /** 0..1 — how far the water has handed over to the ice. */ + _handover() { + if (!this._locked) return 0; + return saturate((this.age - this._lockAt) / Math.max(0.02, settings.brinelock.waterFade)); + } + + /** 0..1 — the glaze front, sweeping the lane from the caster outward. */ + _glaze() { + if (!this._locked) return 0; + return saturate((this.age - this._lockAt) / Math.max(0.02, settings.brinelock.glazeTime)); + } + + /** 0..1 — how far the ice has melted back into the lane. */ + _melt() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / this.fadeDuration); + } + + /* ------------------------------------------------------------------ */ + /* The crowns — the ring buffer this ability owns */ + /* ------------------------------------------------------------------ */ + + /** + * Take a slot for a new crown: a free one, else the oldest. + * + * The same policy `LiquidSurface` uses, and for the same reason — the newest + * crown is the one the player is looking at, so it is never the one dropped. + */ + _takeSlot() { + let best = 0; + let bestAge = -1; + for (let i = 0; i < CROWN_SLOTS; i++) { + const record = this._crowns[i]; + if (record.strength <= 0) return i; + const age = this.age - record.born; + if (age > bestAge) { + bestAge = age; + best = i; + } + } + const slot = this._nextCrown; + this._nextCrown = (this._nextCrown + 1) % CROWN_SLOTS; + return bestAge > 0 ? best : slot; + } + + /** @param {number} along 0..1 down the cast @param {number} across −1..1 */ + _pushCrown(along, across, strength) { + const record = this._crowns[this._takeSlot()]; + record.along = along; + record.across = across; + record.born = this.age; + record.strength = Math.max(0, strength); + return record; + } + + /** Where a crown was punched, in world metres. Live — nothing is stored. */ + _crownWorld(record, out) { + const c = settings.brinelock; + out + .copy(this.origin) + .addScaledVector(this.direction, record.along * this.length) + .addScaledVector(this.side, record.across * c.laneWidth); + out.y = c.poolHeight; + return out; + } + + /** A world point in the sheet's own parametric frame: (x, 0, z), metres. */ + _paramOf(point, out) { + const u = this.pool.uniforms; + _rel.copy(point).sub(u.uAnchor.value); + return out.set(_rel.dot(u.uAxisX.value), 0, _rel.dot(u.uAxisZ.value)); + } + + /** + * Convert every crown into this frame's sheet fractions and write them + * straight into the module's uniform array. + * + * Called after `pool.update()`, which is where the live half-extents come + * from — the same ordering rule `rippleAtWorld()` documents, for the same + * reason. The parametric centres are cached into `_crownParam` on the way + * past because `_surfaceAt()` is about to ask for them a hundred and sixty + * times. + */ + _writeCrowns(halfX, halfZ) { + const slots = this.pool.uniforms.uRipples.value; + for (let i = 0; i < CROWN_SLOTS; i++) { + const record = this._crowns[i]; + if (record.strength <= 0) { + slots[i].set(0, 0, 0, 0); + this._crownParam[i * 2] = 0; + this._crownParam[i * 2 + 1] = 0; + continue; + } + this._crownWorld(record, _pos); + this._paramOf(_pos, _par); + this._crownParam[i * 2] = _par.x; + this._crownParam[i * 2 + 1] = _par.z; + slots[i].set(_par.x / halfX, _par.z / halfZ, record.born, record.strength); + } + } + + /** + * The height of the brine above its mean plane at a world point, metres. + * + * **A line-for-line mirror of `ripplesAt()` in `LIQUID_FIELD`.** If one of + * them changes the other must, and the pair is called out in both files. It + * reads the crown records this ability owns and the same six live sliders the + * shader is handed, at the same frozen clock, so the ice cannot land anywhere + * the water was not. + * + * The swell, the chop and the fragment-only detail octave are deliberately + * *not* mirrored — by the lock they are all zero (see `stillTime`), and + * mirroring an fbm on the CPU is not a mirror, it is a second implementation + * that would drift the first time either side was tuned. + */ + _surfaceAt(point) { + const c = settings.brinelock; + this._paramOf(point, _par); + const px = _par.x; + const pz = _par.z; + + const speed = c.rippleSpeed; + const width = Math.max(c.rippleWidth * c.rippleWidth, 1e-4); + const decayTime = Math.max(c.rippleDecay, 0.02); + const spread = Math.max(c.rippleSpread, 0.05); + const wavelength = TAU / Math.max(c.rippleLength, 0.05); + + let sum = 0; + for (let i = 0; i < CROWN_SLOTS; i++) { + const record = this._crowns[i]; + if (record.strength <= 0) continue; + const age = this._waterNow - record.born; + if (age < 0) continue; + const dx = px - this._crownParam[i * 2]; + const dz = pz - this._crownParam[i * 2 + 1]; + const d = Math.sqrt(dx * dx + dz * dz); + const x = d - age * speed; + const env = Math.exp(-(x * x) / width); + const decay = Math.exp(-age / decayTime) / (1 + d / spread); + sum += record.strength * c.rippleAmp * env * decay * Math.cos(x * wavelength); + } + return sum; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.brinelock; + + this.sprayEmitter.reset(); + this.shardEmitter.reset(); + + this._seed = Math.random() * 100; + this._lockAt = -1; + this._locked = false; + this._crownAt = 0; + this._live = 0; + this._waterNow = 0; + this._nextCrown = 0; + for (const record of this._crowns) { + record.along = 0; + record.across = 0; + record.born = -1; + record.strength = 0; + } + + this.pool.reset(); + this.pool.visible = true; + + this.ice.clear(); + // The dice are rolled once, here, and never again: the fingers stand where + // the cast put them and only their *heights* answer to the water. + this.ice.plant(Math.max(1, Math.min(MAX_BLADES, Math.round(c.blades)))); + + // The lane starts with one crown at the caster's feet, so the very first + // frame already has water in the air rather than a flat sheet. + this._pushCrown(0, 0, c.crownStrength * 0.7); + + this._sync(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** Copy the live shape sliders into the factory's params. */ + _fillShape() { + const c = settings.brinelock; + const s = this._shape; + s.facets = c.facets; + s.rings = c.rings; + s.taper = c.taper; + s.waist = c.waist; + s.bead = c.bead; + s.beadAt = c.beadAt; + s.beadWidth = c.beadWidth; + s.shapeTwist = c.shapeTwist; + s.facetJitter = c.facetJitter; + } + + /** + * Resolve the lane, the crowns and the ice from live settings. + * + * Every metre, radian and second below is read on the frame it is used, + * including a zero-length one. The order is load-bearing: the sheet is + * resolved first, then the crowns are converted against its half-extents, + * then the ice samples the crowns. + */ + _sync() { + const c = settings.brinelock; + const g = settings.global; + + const still = this._still(); + const handover = this._handover(); + const alive = 1 - still; + + // The water's clock stops at the lock; the ice keeps running on `this.age`. + // Two clocks, and confusing them is the one mistake that breaks the trick: + // freeze both and the fingers never emerge, freeze neither and the ice is + // carved from a splash that has moved on. + this._waterNow = this._locked ? this._lockAt : this.age; + + /* ---------------- the lane ---------------- */ + this.pointAt(0.5, _centre); + _centre.y = c.poolHeight; + this.pool.setPlacement(_centre, this.direction, _up); + + const halfX = Math.max(0.05, this.length * c.laneLength * 0.5); + const halfZ = Math.max(0.05, c.laneWidth); + + _liq.sizeX = halfX * 2; + _liq.sizeZ = halfZ * 2; + _liq.fill = 1; + _liq.round = c.round; + _liq.edgeSoft = c.edgeSoft; + _liq.edgeNoise = c.edgeNoise; + _liq.edgeScale = c.edgeScale; + _liq.seed = this._seed; + _liq.opacity = lerp(c.poolOpacity, c.lockedOpacity, handover) * g.opacity; + _liq.contactFade = c.contactFade; + + // The swell dies before the lock, not on it. See the class comment. + _liq.waveAmpA = c.waveAmpA * alive; + _liq.waveAmpB = c.waveAmpB * alive; + _liq.waveAmpC = c.waveAmpC * alive; + _liq.waveAmpD = c.waveAmpD * alive; + _liq.waveLengthA = c.waveLengthA; + _liq.waveLengthB = c.waveLengthB; + _liq.waveLengthC = c.waveLengthC; + _liq.waveLengthD = c.waveLengthD; + _liq.waveSpeedA = c.waveSpeedA; + _liq.waveSpeedB = c.waveSpeedB; + _liq.waveSpeedC = c.waveSpeedC; + _liq.waveSpeedD = c.waveSpeedD; + _liq.waveAngleA = c.waveAngleA; + _liq.waveAngleB = c.waveAngleB; + _liq.waveAngleC = c.waveAngleC; + _liq.waveAngleD = c.waveAngleD; + _liq.steepness = c.steepness; + + _liq.chop = c.chop * alive; + _liq.chopScale = c.chopScale * g.noiseFrequency; + _liq.chopSpeed = c.chopSpeed * g.noiseSpeed; + _liq.detail = c.detail * alive; + _liq.detailScale = c.detailScale * g.noiseFrequency; + _liq.detailSpeed = c.detailSpeed * g.noiseSpeed; + + // The crowns themselves are NOT faded by the lock — they are the shape the + // ice is about to claim, and the water is taken away by its opacity alone. + _liq.rippleAmp = c.rippleAmp; + _liq.rippleSpeed = c.rippleSpeed; + _liq.rippleLength = c.rippleLength; + _liq.rippleWidth = c.rippleWidth; + _liq.rippleDecay = c.rippleDecay; + _liq.rippleSpread = c.rippleSpread; + + _liq.flowAngle = c.flowAngle; + _liq.flowSpeed = c.flowSpeed * alive; + _liq.flowRadial = c.flowRadial * alive; + _liq.flowRadialFall = c.flowRadialFall; + _liq.flowEddy = c.flowEddy * alive; + _liq.flowEddyScale = c.flowEddyScale * g.noiseFrequency; + _liq.flowEddySpeed = c.flowEddySpeed * g.noiseSpeed; + _liq.flowGravity = c.flowGravity; + + _liq.foam = c.foam * (1 - handover); + _liq.foamScale = c.foamScale * g.noiseFrequency; + _liq.foamSharp = c.foamSharp; + _liq.foamCrest = c.foamCrest; + _liq.foamSpeed = c.foamSpeed; + + _liq.poolDepth = c.poolDepth; + _liq.depthTint = c.depthTint; + _liq.translucency = c.translucency; + _liq.ambient = c.ambient; + _liq.specular = c.specular; + _liq.shininess = c.shininess; + _liq.fresnel = c.fresnel * g.fresnel; + _liq.envIntensity = c.envIntensity; + _liq.skyIntensity = c.skyIntensity; + _liq.glow = c.poolGlow * g.glow; + _liq.normalEps = c.normalEps; + + _liq.colorDeep = c.colorDeep; + _liq.colorShallow = c.colorShallow; + _liq.colorFoam = c.colorFoam; + _liq.colorSpec = c.colorSpec; + _liq.colorSky = c.colorSky; + + this.pool.update(this._waterNow, _liq); + this._writeCrowns(halfX, halfZ); + this.pool.visible = _liq.opacity > 0.002; + + /* ---------------- the ice ---------------- */ + this._syncIce(); + + /* ---------------- the particle systems ---------------- */ + this.spray.setGradient( + getColor(c.colorSprayA), + getColor(c.colorSprayB), + getColor(c.colorSprayC), + getColor(c.colorSprayD) + ); + this.spray.uniforms.uGravity.value.set(0, c.sprayGravity, 0); + this.spray.uniforms.uSizeScale.value = c.spraySize * g.particleSize * 7; + this.spray.uniforms.uLifeScale.value = c.sprayLifetime * 0.5 * g.particleLifetime; + this.spray.uniforms.uSpeedScale.value = g.particleSpeed; + this.spray.uniforms.uOpacity.value = g.opacity; + this.spray.uniforms.uGlow.value = c.poolGlow * 0.7 * g.glow; + this.spray.uniforms.uTurbulence.value = c.sprayTurbulence * g.turbulence; + + this.frost.setGradient( + getColor(c.colorFrostA), + getColor(c.colorFrostB), + getColor(c.colorFrostC), + getColor(c.colorFrostD) + ); + this.frost.uniforms.uGravity.value.set(0, c.frostRise, 0); + this.frost.uniforms.uSizeScale.value = c.frostSize * g.particleSize * 7; + this.frost.uniforms.uLifeScale.value = c.frostLifetime * 0.5 * g.particleLifetime; + this.frost.uniforms.uSpeedScale.value = g.particleSpeed; + this.frost.uniforms.uOpacity.value = g.opacity; + this.frost.uniforms.uGlow.value = c.iceGlow * 0.8 * g.glow; + this.frost.uniforms.uTurbulence.value = c.frostTurbulence * g.turbulence; + + this.shards.setGradient( + getColor(c.colorShardA), + getColor(c.colorShardB), + getColor(c.colorShardC), + getColor(c.colorShardD) + ); + this.shards.uniforms.uGravity.value.set(0, c.shardGravity, 0); + this.shards.uniforms.uSizeScale.value = c.shardSize * g.particleSize * 7; + this.shards.uniforms.uLifeScale.value = c.shardLifetime * 0.5 * g.particleLifetime; + this.shards.uniforms.uSpeedScale.value = g.particleSpeed; + this.shards.uniforms.uOpacity.value = g.opacity; + + /* ---------------- the light, riding the lane ---------------- */ + this.pointAt(this.phase === AbilityPhase.TRAVEL ? this.u : 0.6, this.position); + this.position.y = c.poolHeight + c.lightHeight; + } + + /** + * Re-resolve the field of ice, including the height every finger takes from + * the water underneath it. + */ + _syncIce() { + const c = settings.brinelock; + const g = settings.global; + const p = this._growth; + + p.origin.copy(this.origin); + p.direction.copy(this.direction); + p.side.copy(this.side); + p.length = this.length; + + p.widthNear = c.laneWidth * c.bladeSpreadNear; + p.width = c.laneWidth * c.bladeSpread; + p.widthCurve = 1; + p.clumping = c.bladeClumping; + p.frontBias = c.bladeFrontBias; + // The global randomness multiplier is folded into the jitters here rather + // than into `p.randomness`, because the module applies that same number to + // `heightJitter` — and `heightJitter` is carrying the water's silhouette. + p.scatter = c.bladeScatter * g.randomness; + + p.minHeight = c.iceFloor; + p.radiusNear = c.bladeRadius; + p.radius2 = c.bladeRadiusTip; + p.radiusCurve = c.bladeRadiusCurve; + p.radiusJitter = c.bladeRadiusJitter * g.randomness; + p.minRadius = 0.004; + + p.lean = c.bladeLean; + p.leanJitter = c.bladeLeanJitter * g.randomness; + p.leanRamp = c.bladeLeanRamp; + p.leanForward = c.bladeLeanForward; + p.leanOutward = c.bladeLeanOutward; + p.twist = c.bladeTwist; + p.tilt = c.bladeTilt * g.randomness; + + p.baseHeight = c.poolHeight; + p.baseJitter = 0; + + p.riseTime = c.riseTime; + p.riseOvershoot = c.riseOvershoot; + p.settle = c.settle; + p.springRate = c.springRate; + p.birthScale = c.birthScale; + p.birthFade = c.birthFade; + p.emergeSink = 0; + p.sinkDepth = c.sinkDepth; + + this._fillShape(); + this.ice.syncGeometry(this._shape); + + // The glaze front. Nothing is triggered before the lock, so the whole field + // sits buried and costs three empty draw calls while the lane is water. + if (this._locked) { + this.ice.triggerUpTo(this.age, this._glaze(), c.glazeStagger, c.bladeFrontBias); + } + + /* --- THE HANDOVER -------------------------------------------------- + Every finger takes the height the water has at its own footprint on the + frozen frame. Resolved here, every frame, from the live sliders — never + captured. See consequence 1 in the class comment. */ + const records = this.ice.records; + const gain = c.iceGain; + for (let i = 0; i < this.ice.count; i++) { + this.ice.positionOf(i, p, _pos); + records[i].heightRoll = this._surfaceAt(_pos) * gain - 1; + } + + this.iceMaterial.userData.sync(); + this.ice.update(this.age, p, this._melt()); + this._live = this._locked ? this.ice.count : 0; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** Crowns punched into the lane as the splash front runs down it. */ + _layCrowns() { + const c = settings.brinelock; + // Kept as a fraction of the cast rather than as metres travelled, so + // nothing dimensional survives between frames. + const step = Math.max(0.02, c.crownSpacing / Math.max(this.length, 0.1)); + + while (this.u - this._crownAt >= step) { + this._crownAt += step; + const record = this._pushCrown( + saturate(this._crownAt), + randRange(-1, 1) * c.crownWander, + c.crownStrength * (1 + randRange(-1, 1) * c.crownJitter) + ); + this._crownWorld(record, _pos); + this._sprayBurst(_pos, c.sprayPerCrown, 1); + } + } + + /** A handful of droplets thrown out of one crown. */ + _sprayBurst(point, count, scale) { + const c = settings.brinelock; + const g = settings.global; + const total = Math.round(count * g.particleCount); + if (total <= 0) return; + + _emit.position = point; + _emit.radius = c.rippleWidth * 0.6; + // Wide and shallow: a crown leaves sideways. Firing it straight up just + // puts a small fountain in the lane and the ring never reads. + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spraySpeed * scale; + _emit.speedVariance = 0.85; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.spraySize; + _emit.sizeVariance = 0.7; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.spray.emit(total, _emit); + } + + /** The continuous shed off the standing water. */ + _laneFx(dt) { + const c = settings.brinelock; + const g = settings.global; + const gate = 1 - this._handover(); + const count = Math.round(this.sprayEmitter.tick(dt, c.sprayRate * gate) * g.particleCount); + if (count <= 0) return; + + // Somewhere on the drawn part of the lane, on the crown wall rather than in + // the flat between crowns: `crownWander` is the same fraction the crowns + // themselves were thrown off the centre line by. + const reach = this.phase === AbilityPhase.TRAVEL ? Math.max(0.05, this.u) : 1; + this.pointAt(Math.random() * reach, _pos); + _pos.addScaledVector(this.side, randRange(-1, 1) * c.laneWidth * 0.85); + _pos.y = c.poolHeight; + + _emit.position = _pos; + _emit.radius = c.laneWidth * 0.3; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spraySpeed * 0.6; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.spraySize * 0.8; + _emit.sizeVariance = 0.7; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.spray.emit(count, _emit); + } + + /** + * One finger has locked. Fired by `GrowthField.onBreach` on the frame it + * breaks through, which is what makes the frost trail the glaze front down + * the lane without a single line of per-instance bookkeeping here. + */ + _frostFx(position, radius, height) { + const c = settings.brinelock; + const g = settings.global; + const count = Math.round(c.frostPerBlade * g.particleCount); + if (count <= 0) return; + + _pos.copy(position); + _pos.y += height * 0.6; + + _emit.position = _pos; + _emit.radius = radius * 2.5; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.frostSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.frostSize; + _emit.sizeVariance = 0.6; + _emit.life = c.frostLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.frost.emit(count, _emit); + } + + /** Chips coming off the ice as it goes back to water. */ + _meltFx(dt) { + const c = settings.brinelock; + const g = settings.global; + const gate = this._melt(); + if (gate <= 0) return; + + const count = Math.round(this.shardEmitter.tick(dt, c.shardRate * gate) * g.particleCount); + if (count <= 0) return; + + this.pointAt(Math.random(), _pos); + _pos.addScaledVector(this.side, randRange(-1, 1) * c.laneWidth * c.bladeSpread); + _pos.y = c.poolHeight + c.rippleAmp * 0.5; + + _emit.position = _pos; + _emit.radius = c.laneWidth * 0.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.shardSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.shardSize; + _emit.sizeVariance = 0.7; + _emit.life = c.shardLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 7; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.shards.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._layCrowns(); + this._sync(); + this._laneFx(dt); + this.ctx.shake.rumble(settings.brinelock.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.brinelock; + const g = settings.global; + + // The big one, at the far end, dead centre. + const record = this._pushCrown(1, 0, c.crownStrength * c.impactCrown); + this._crownWorld(record, _pos); + + /* the sheet of spray thrown off it */ + this.ctx.bursts.spawn(BurstMode.WATER, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.8, + displace: 0.55, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring across the floor */ + this.pointAt(1, _centre); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _centre, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.55, + width: 0.06, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* brine thrown outside the waterline */ + const marks = Math.max(0, Math.round(c.brineMarks)); + for (let i = 0; i < marks; i++) { + // Spaced along the lane with a jittered offset rather than scattered at + // random: a handful of random points clumps, and a clumped set of stains + // reads as a mistake rather than as a spill. + const along = (i + randRange(-0.35, 0.35)) / Math.max(1, marks); + this.pointAt(saturate(along), _pos); + _pos.addScaledVector(this.side, randRange(1, 1.9) * c.laneWidth * (i % 2 === 0 ? 1 : -1)); + this.ctx.decals.spawn(DecalType.FOAM, _pos, { + radius: c.brineRadius * randRange(0.7, 1.3), + life: c.brineLife, + intensity: c.brineIntensity, + colorA: getColor(c.colorBrine), + colorB: getColor(c.colorBrineEdge), + height: 0.012 + }); + } + + this._sprayBurst(_pos, c.sprayPerCrown * 3, 1.6); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.7 * g.explosionIntensity; + } + + /** + * The lock. One-shot: the frame the brine stops being water. + * + * All it captures is a timestamp. Everything the ice does afterwards — the + * ring positions, the finger heights, the glaze front — is re-derived from + * that timestamp and the live sliders on every frame that follows. + */ + _lock() { + const c = settings.brinelock; + const g = settings.global; + + this._locked = true; + this._lockAt = this.age; + + this.pointAt(0.5, _centre); + _centre.y = c.poolHeight; + + this.ctx.bursts.spawn(BurstMode.FROST, _centre, { + radius: c.burstSize * 0.3, + endRadius: c.burstSize * 1.6 * g.explosionIntensity, + life: 0.75, + intensity: c.burstIntensity * 0.8, + opacity: 0.7, + fresnel: 2.0, + displace: 0.35, + squash: 0.45, + colorA: getColor(c.colorLockFlash), + colorB: getColor(c.colorIce), + colorC: getColor(c.colorSeam) + }); + + /* rime creeping out from under the frozen lane */ + const marks = Math.max(0, Math.round(c.brineMarks)); + for (let i = 0; i < marks; i++) { + this.pointAt(saturate((i + 0.5) / Math.max(1, marks)), _pos); + _pos.addScaledVector(this.side, randRange(-1, 1) * c.laneWidth); + this.ctx.decals.spawn(DecalType.FROST, _pos, { + radius: c.brineRadius * randRange(1.0, 1.8), + life: c.brineLife * 1.4, + intensity: c.brineIntensity * 1.3, + colorA: getColor(c.colorIce), + colorB: getColor(c.colorSeam), + height: 0.014 + }); + } + + this.ctx.shake.add( + c.lockShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 30 + ); + this.ctx.flash.trigger(getColor(c.colorLockFlash), c.lockFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.1 * g.explosionIntensity; + } + + onFade(dt, t) { + if (!this._locked && this._lockElapsed() >= settings.brinelock.lockDelay) this._lock(); + + this._sync(); + this._laneFx(dt); + this._meltFx(dt); + + // The lane keeps rumbling while it is still water and goes quiet the + // instant it locks — the silence is half of what sells the state change. + if (!this._locked && t <= 1) { + this.ctx.shake.rumble(settings.brinelock.rumble * settings.global.cameraShake, dt); + } + } + + onDestroy() { + this._live = 0; + this._locked = false; + this._lockAt = -1; + this._crownAt = 0; + this.ice.clear(); + this.pool.reset(); + this.pool.visible = false; + } + + dispose() { + this.pool.dispose(); + this.ice.dispose(); + this.iceMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/tide/BubblecageAbility.js b/src/abilities/tide/BubblecageAbility.js new file mode 100644 index 0000000..da6b630 --- /dev/null +++ b/src/abilities/tide/BubblecageAbility.js @@ -0,0 +1,778 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { createThinFilmMaterial, createBubbleGeometry } from '../../materials/ThinFilmMaterial.js'; +import { Shell, ShellMode } from '../../vfx/Shell.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; +/** + * The golden angle, which is the whole reason the cage looks *woven* rather + * than gridded. A Fibonacci lattice spaces N points on a sphere with no seam, + * no pole and no repeating row; a latitude/longitude grid — the first version — + * piles half the bubbles onto the two poles and leaves a visible ladder of + * meridians down the sides. + */ +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); + +/** Hard ceiling on bubbles. The editor's `bubbles` slider clamps here. */ +const MAX_BUBBLES = 64; + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _ground = groundFieldParams(); + +/** + * ABYSSAL CAGE — a far cast whose colour is a **measurement**. + * + * A bell of black water heaves out of the circle, and what it leaves standing + * is a cage of soap film: thirty-odd bubbles interlocked on a squashed sphere + * around the footprint, draining, marbling, and bursting one at a time from + * their own crowns. + * + * ## THE TRICK — thin-film interference, actually computed + * + * Every other iridescent surface in this project — and every one I have seen + * shipped anywhere — gets its rainbow from a fresnel term indexing a hand-drawn + * gradient. That is a perfectly good effect and it is not this. Here the hue of + * a pixel of film comes from that film's **thickness** against the **view + * angle**: the two reflections (front face, back face) differ in optical path + * by `Δ = 2·n·d·cos θₜ`, plus the half-wave the hard front reflection adds, and + * the resulting spectrum `I(λ) = ½ − ½cos(2πΔ/λ)` is integrated across the + * visible band against the CIE colour matching functions and converted to sRGB. + * `materials/ThinFilmMaterial.js` has the derivation. + * + * Three things fall out of that which no ramp gives you, and they are the + * reason it was worth doing: + * + * - **the bands are in the right order.** Gold, magenta, blue, silver, going + * up a draining bubble. Nobody authored the sequence; it is what a film that + * thickness does. + * - **the bands crowd toward the silhouette**, because a grazing ray travels + * further through the film. A fresnel ramp does the opposite — it puts one + * band *on* the silhouette and nothing anywhere else. + * - **the crown goes black before it bursts.** Below a quarter-wave every + * wavelength cancels, because of the half-wave shift. The first version of + * the shader left the `π` out and a thinning film went brilliant white, + * which turns out to be the single most obviously wrong thing a bubble can + * do. + * + * ## What the rest of it is + * + * `vfx/Shell.js` in `DOME` mode is the water bell (1 draw call); one instanced + * sphere carries every bubble (1); `vfx/GroundField.js` in `POOL` mode is the + * standing water underneath (1). Three draw calls for the cast. The bubbles are + * placed **entirely in the vertex shader** from uniforms — there is no + * `instanceMatrix` anywhere in this ability — so pausing and dragging + * `cageRadius` genuinely re-hangs a standing cage. + * + * ## The rupture, and why it is not a fade + * + * A film does not dim. A hole opens at the thinnest point and its rim retracts + * across the bubble in milliseconds, dragging the film into itself. So the + * fragment shader cuts an angular cap around a per-bubble rupture direction and + * lights the band just outside it. Fading alpha instead — the first attempt — + * reads as thirty bubbles being switched off, which is somehow *less* + * convincing than one bubble popping. + * + * ## What a cast captures + * + * One seed, and four unitless dice per bubble: a direction on the cage, a size + * fraction, a decorrelation seed and a pop roll. Not one metre, radian, second + * or nanometre. The pop **latch** is re-armable exactly the way Thunderclap's + * boom is — the deadline is recomputed from live settings every frame, so + * dragging `popTime` backward with the clock stopped un-pops bubbles and + * dragging it forward pops them, rather than firing once and never again. + */ +export class BubblecageAbility extends Ability { + constructor(context) { + super('bubblecage', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- beat 1: the water bell --- */ + this.bell = new Shell({ mode: ShellMode.DOME, prefix: 'bell', nodes: 40, sides: 52 }); + this.group.add(this.bell.group); + + /* --- the cage itself: one instanced sphere, one draw call --- */ + this.filmGeometry = createBubbleGeometry(MAX_BUBBLES, 26, 18); + this.filmMaterial = createThinFilmMaterial(); + this.filmMesh = new Mesh(this.filmGeometry, this.filmMaterial); + this.filmMesh.frustumCulled = false; + this.filmMesh.matrixAutoUpdate = false; + this.filmMesh.layers.set(LAYER.VFX); + this.filmMesh.renderOrder = 13; + this.group.add(this.filmMesh); + + /* --- the standing water it is all sitting in --- */ + this.pool = new GroundField(this.group, { + mode: GroundMode.POOL, + depthTest: true, + name: 'bubblecage.pool' + }); + + /** Re-rolled per cast so two cages are not the same weave. */ + this._seed = 0; + /** How many bubbles this cast planted. */ + this._count = 0; + + /* --- the per-bubble dice, allocated once (I3) --- */ + /** Unit directions on the cage sphere, packed xyz. Unitless. */ + this._dirs = new Float32Array(MAX_BUBBLES * 3); + /** Decorrelation seeds, 0..1. */ + this._seeds = new Float32Array(MAX_BUBBLES); + /** Size fractions. Unitless — the metre is `bubbleRadius`. */ + this._scales = new Float32Array(MAX_BUBBLES); + /** Where in `popSpread` each bubble's deadline sits, 0..1. */ + this._pops = new Float32Array(MAX_BUBBLES); + /** Re-armable latch: has this one burst *as of this frame*. */ + this._burst = new Uint8Array(MAX_BUBBLES); + + // Scratch handed to the bell each frame. One object, reused. + this._state = { + origin: new Vector3(), + axis: new Vector3(0, 1, 0), + side: new Vector3(1, 0, 0), + span: 0, + t: 0, + fade: 1, + seed: 0 + }; + // ...and to the film. + this._film = { centre: new Vector3(), age: 0, seed: 0, fade: 1, collapse: 0 }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Droplets flung off a bursting film. Lit rather than additive: water + // catches the key light, it does not emit. An additive droplet is a spark, + // and thirty sparks coming off a bubble reads as an explosion. + this.drops = particles.get('bubblecage.drops', { + capacity: 2400, + shape: ParticleShape.SOFT, + additive: false, + lit: true, + softFade: 0.2 + }); + this.drops.uniforms.uDrag.value = 0.9; + this.drops.uniforms.uEndSize.value = 0.5; + this.drops.uniforms.uSizeIn.value = 0.04; + this.drops.uniforms.uFadeIn.value = 0.05; + this.drops.uniforms.uFadeOut.value = 0.5; + + // The haze the bell throws up and the bursts keep topping up. + this.mist = particles.get('bubblecage.mist', { + capacity: 1800, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.mist.uniforms.uDrag.value = 2.4; + this.mist.uniforms.uEndSize.value = 2.6; + this.mist.uniforms.uSizeIn.value = 0.12; + this.mist.uniforms.uFadeIn.value = 0.18; + this.mist.uniforms.uFadeOut.value = 0.35; + + // Fizz: the small bubbles running up the inside of the cage. Additive, + // because these *are* little films and the whole school is lit from below. + this.fizz = particles.get('bubblecage.fizz', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.3 + }); + this.fizz.uniforms.uDrag.value = 1.1; + this.fizz.uniforms.uEndSize.value = 1.4; + this.fizz.uniforms.uSizeIn.value = 0.1; + this.fizz.uniforms.uFadeIn.value = 0.1; + this.fizz.uniforms.uFadeOut.value = 0.45; + + this.dropEmitter = new RateEmitter(); + this.mistEmitter = new RateEmitter(); + this.fizzEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing — every beat re-resolved every frame */ + /* ------------------------------------------------------------------ */ + + /** Beat 1, seconds: the water bell heaving up and going again. */ + _bellSpan() { + return Math.max(0.02, settings.bubblecage.bellTime * settings.global.lifetime); + } + + /** Beat 2, seconds: the cage standing. */ + _holdSpan() { + return Math.max(0.05, settings.bubblecage.holdTime * settings.global.lifetime); + } + + get impactDuration() { + return this._bellSpan() + this._holdSpan(); + } + + get fadeDuration() { + return Math.max(0.05, settings.bubblecage.fadeTime); + } + + /** Bubbles still standing. HUD readout only. */ + get instanceCount() { + let live = 0; + for (let i = 0; i < this._count; i++) if (!this._burst[i]) live++; + return live; + } + + /** + * Seconds since the bell landed. + * + * Deliberately not the `t` the base class hands `onFade`: that was divided by + * an `impactDuration` which is two sliders, either of which may move under a + * standing cage. Rebuilding the local clock from `impactTime` and the live + * spans is what lets a paused `holdTime` drag walk the cage through its own + * life, forward and backward. + */ + _clock() { + if (this.phase === AbilityPhase.FADE) return this.impactDuration + this.fadeTime; + if (this.phase === AbilityPhase.TRAVEL) return 0; + return this.impactTime; + } + + /** 0 while the cage stands, ramping to 1 as the fade hauls the deadlines in. */ + _collapse() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / this.fadeDuration); + } + + /** + * A soft swell rather than a flicker: water does not gutter, it heaves. Keyed + * off two incommensurate rates so it never settles into a visible period. + */ + lightShimmer() { + const c = settings.bubblecage; + return 1 - saturate(c.cageBreathe * 4) * (0.5 - 0.5 * Math.cos(this.age * TAU * c.cageBreatheSpeed)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the cage hangs, world space. */ + _cageCentre(out) { + const c = settings.bubblecage; + this.pointAt(1, out); + // Seated so the bottom of the squashed sphere just touches the stone. + out.y = c.zoneRadius * c.cageRadius * c.cageSquash + c.cageLift; + return out; + } + + /** + * The centre of bubble `i`, world space. + * + * A deliberate duplication of the placement in `FILM_VERTEX`, for the same + * reason `Tube` mirrors its own axis in JS: reading it back off the GPU is a + * pipeline stall, and this is queried once per burst. What is **not** + * mirrored is the jostle noise — the JS side returns the mean position. A + * droplet spray placed on the noisy position jitters against the bubble it + * came off, and the wobble is centimetres. + */ + _bubblePoint(index, out) { + const c = settings.bubblecage; + const radius = c.zoneRadius * c.cageRadius; + const seed = this._seeds[index]; + const breathe = 1 + c.cageBreathe * Math.sin(this._clock() * TAU * c.cageBreatheSpeed + seed * TAU + this._seed); + + this._cageCentre(out); + const k = radius * breathe; + out.x += this._dirs[index * 3] * k; + out.y += this._dirs[index * 3 + 1] * k * c.cageSquash; + out.z += this._dirs[index * 3 + 2] * k; + return out; + } + + /** Seconds after the cage was raised at which bubble `i` gives way. */ + _popDeadline(index) { + const c = settings.bubblecage; + const born = this._seeds[index] * Math.max(0, c.bubbleStagger); + const collapse = this._collapse(); + const life = Math.max(0.05, c.popTime * (1 - c.popCollapse * collapse)); + return born + life + this._pops[index] * c.popSpread * (1 - collapse); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + /** + * Roll the cage. The only dice this ability throws, and none of them has a + * unit: a direction, a size fraction, a decorrelation seed and a pop roll. + */ + _plant() { + const c = settings.bubblecage; + const count = Math.max(1, Math.min(MAX_BUBBLES, Math.round(c.bubbles))); + const scatter = saturate(c.bubbleScatter) * settings.global.randomness; + // One rotation for the whole lattice, so two casts do not weave identically + // while each one stays evenly spaced. + const phase = Math.random() * TAU; + + for (let i = 0; i < count; i++) { + const y = 1 - ((i + 0.5) * 2) / count; + const ring = Math.sqrt(Math.max(0, 1 - y * y)); + const theta = i * GOLDEN_ANGLE + phase; + this._dirs[i * 3] = Math.cos(theta) * ring; + this._dirs[i * 3 + 1] = y; + this._dirs[i * 3 + 2] = Math.sin(theta) * ring; + + this._seeds[i] = Math.random(); + this._scales[i] = 1 + randRange(-scatter, scatter); + this._pops[i] = Math.random(); + this._burst[i] = 0; + } + + const attributes = this.filmGeometry.attributes; + attributes.aDir.array.set(this._dirs.subarray(0, count * 3)); + attributes.aSeed.array.set(this._seeds.subarray(0, count)); + attributes.aScale.array.set(this._scales.subarray(0, count)); + attributes.aPop.array.set(this._pops.subarray(0, count)); + attributes.aDir.needsUpdate = true; + attributes.aSeed.needsUpdate = true; + attributes.aScale.needsUpdate = true; + attributes.aPop.needsUpdate = true; + + this._count = count; + this.filmGeometry.instanceCount = 0; // nothing is drawn until the bell lands + } + + onSpawn() { + this.dropEmitter.reset(); + this.mistEmitter.reset(); + this.fizzEmitter.reset(); + + // The one thing a cast captures that is not per-bubble, and it is unitless. + this._seed = Math.random() * 100; + + this._plant(); + this.bell.visible = true; + this.pool.setVisible(false); + this._sync(0); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into all three renderers. + * + * The film's uniforms are written on *every* frame, travel included, even + * though nothing is drawn yet — an instance count of zero costs nothing to + * update, and it means `filmThickness` is a live slider during the wind-up as + * well as during the hold, which is what you want when you are tuning a + * quarter-second beat by dragging it. + * + * @param {number} fade 0..1 master on everything the cast draws + */ + _sync(fade) { + const c = settings.bubblecage; + const g = settings.global; + + /* --- the cage --- */ + this._cageCentre(this._film.centre); + this._film.age = this._clock(); + this._film.seed = this._seed; + this._film.fade = fade; + this._film.collapse = this._collapse(); + this.filmMaterial.userData.sync(this._film); + + /* --- the pool it stands in --- */ + const local = this._clock(); + _ground.centre = this.pointAt(1, _pos); + _ground.yaw = Math.atan2(this.direction.x, this.direction.z); + _ground.height = c.poolHeight; + _ground.radius = Math.max(0.05, c.zoneRadius * c.poolRadius); + _ground.grow = saturate(local / Math.max(0.02, c.poolGrow)); + _ground.recede = this._collapse() * saturate(c.poolDry); + _ground.fade = fade; + _ground.seed = this._seed; + _ground.edge = c.poolEdge; + _ground.ragged = c.poolRagged; + _ground.raggedScale = c.poolRaggedScale * g.noiseFrequency; + _ground.warp = c.poolWarp * g.noiseStrength; + _ground.relief = c.poolRelief; + _ground.normalStep = c.poolNormalStep; + _ground.ambient = c.poolAmbient; + _ground.wrap = c.poolWrap; + _ground.specular = c.poolSpecular; + _ground.gloss = c.poolGloss; + _ground.cell = c.poolCell * g.noiseFrequency; + _ground.depth = c.poolDepth; + _ground.lift = c.poolLift; + _ground.thickness = c.poolThickness; + _ground.detail = c.poolDetail; + _ground.flow = c.poolFlow; + _ground.speed = c.poolSpeed * g.noiseSpeed; + _ground.windAngle = c.poolWind; + _ground.emissive = c.poolEmissive; + _ground.opacity = c.poolOpacity; + _ground.opacityScale = g.opacity; + _ground.noiseStrength = g.noiseStrength; + _ground.noiseFrequency = g.noiseFrequency; + _ground.noiseSpeed = g.noiseSpeed; + _ground.colorBase = c.colorPoolBase; + _ground.colorEdge = c.colorPoolEdge; + _ground.colorGlow = c.colorPoolGlow; + _ground.colorDeep = c.colorPoolDeep; + this.pool.update(_ground); + + /* --- the three particle systems --- */ + this.drops.setGradient( + getColor(c.colorDropA), + getColor(c.colorDropB), + getColor(c.colorDropC), + getColor(c.colorDropD) + ); + this.drops.uniforms.uGravity.value.set(0, c.dropGravity, 0); + this.drops.uniforms.uSizeScale.value = c.dropSize * g.particleSize * 7; + this.drops.uniforms.uLifeScale.value = c.dropLifetime * 0.5 * g.particleLifetime; + this.drops.uniforms.uSpeedScale.value = g.particleSpeed; + this.drops.uniforms.uOpacity.value = g.opacity; + + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = 0.3 * g.turbulence; + + this.fizz.setGradient( + getColor(c.colorFizzA), + getColor(c.colorFizzB), + getColor(c.colorFizzC), + getColor(c.colorFizzD) + ); + this.fizz.uniforms.uGravity.value.set(0, c.fizzRise, 0); + this.fizz.uniforms.uSizeScale.value = c.fizzSize * g.particleSize * 7; + this.fizz.uniforms.uLifeScale.value = c.fizzLifetime * 0.5 * g.particleLifetime; + this.fizz.uniforms.uSpeedScale.value = g.particleSpeed; + this.fizz.uniforms.uOpacity.value = g.opacity; + this.fizz.uniforms.uGlow.value = 1.1 * g.glow; + this.fizz.uniforms.uTurbulence.value = c.fizzTurbulence * g.turbulence; + } + + /** + * The bell, wherever it currently is and however far through beat 1 it is. + * + * @param {THREE.Vector3} at where the hemisphere is seated + * @param {number} t 0..1 through its heave + * @param {number} fade 0..1 master + */ + _syncBell(at, t, fade) { + const state = this._state; + state.origin.copy(at); + state.origin.y = 0; + state.axis.set(0, 1, 0); + state.side.copy(this.side); + state.t = saturate(t); + state.fade = saturate(fade); + state.seed = this._seed; + this.bell.sync(settings.bubblecage, state, settings.global); + } + + /* ------------------------------------------------------------------ */ + /* The rupture */ + /* ------------------------------------------------------------------ */ + + /** + * Walk the cage and fire whatever has crossed its deadline this frame. + * + * The latch is re-armed on the way *back*, which is the whole reason the + * deadline is recomputed rather than stored: with the clock stopped, dragging + * `popTime` up un-bursts the bubbles that had gone and dragging it down + * bursts the ones that had not. The shader is reading the same numbers from + * the same uniforms, so the two never disagree about which bubbles are + * standing. + */ + _rupture() { + for (let i = 0; i < this._count; i++) { + const gone = this._clock() >= this._popDeadline(i); + if (gone === Boolean(this._burst[i])) continue; + if (!gone) { + this._burst[i] = 0; + continue; + } + this._burst[i] = 1; + this._burstFx(i); + } + } + + /** One bubble giving way: droplets, a puff, and a foam mark under it. */ + _burstFx(index) { + const c = settings.bubblecage; + const g = settings.global; + const time = frame.uTime.value; + const radius = c.zoneRadius * c.bubbleRadius * this._scales[index]; + + this._bubblePoint(index, _pos); + + _emit.position = _pos; + _emit.radius = radius * 0.85; + // Outward from the cage centre — a film retracts into its own rim and + // throws what it was carrying along the surface it used to be. + _emit.direction = _dir.copy(_pos).sub(this._cageCentre(_centre)).normalize(); + _emit.speed = c.dropSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.dropLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.drops.emit(Math.round(c.popDroplets * g.particleCount), _emit); + + _emit.speed = c.mistSpeed * 1.6; + _emit.spread = 1.0; + _emit.size = 0.5; + _emit.life = c.mistLifetime; + _emit.spin = 0.3; + this.mist.emit(Math.round(c.popMist * g.particleCount), _emit); + + // The foam it leaves on the water directly underneath. + _pos.y = 0; + this.ctx.decals.spawn(DecalType.FOAM, _pos, { + radius: c.popFoamRadius * randRange(0.75, 1.3), + life: c.popFoamLife, + intensity: c.popFoamIntensity, + colorA: getColor(c.colorFoamA), + colorB: getColor(c.colorFoamB) + }); + } + + /* ------------------------------------------------------------------ */ + /* Ambient emission */ + /* ------------------------------------------------------------------ */ + + /** + * What the standing cage sheds: droplets running off the films, mist inside + * it, and fizz climbing the inside of the wall. + * + * @param {number} scale 0..1 — thinned as the cage gives out + */ + _cageFx(dt, scale) { + const c = settings.bubblecage; + const g = settings.global; + const time = frame.uTime.value; + if (this._count === 0) return; + + this._cageCentre(_centre); + const radius = c.zoneRadius * c.cageRadius; + + const dropCount = Math.round(this.dropEmitter.tick(dt, c.dropRate * scale) * g.particleCount); + if (dropCount > 0) { + // Off a bubble, not off the middle: the drips come from the films. + this._bubblePoint(Math.floor(Math.random() * this._count), _pos); + _emit.position = _pos; + _emit.radius = c.zoneRadius * c.bubbleRadius; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.dropSpeed * 0.35; + _emit.speedVariance = 0.6; + _emit.spread = 0.5; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.dropLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.drops.emit(dropCount, _emit); + } + + const mistCount = Math.round(this.mistEmitter.tick(dt, c.mistRate * scale) * g.particleCount); + if (mistCount > 0) { + _emit.position = _centre; + _emit.radius = radius * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.25; + _emit.time = time; + this.mist.emit(mistCount, _emit); + } + + const fizzCount = Math.round(this.fizzEmitter.tick(dt, c.fizzRate * scale) * g.particleCount); + if (fizzCount > 0) { + this.pointAt(1, _pos); + _pos.y = 0.05; + _emit.position = _pos; + _emit.radius = c.zoneRadius * c.poolRadius * 0.7; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.fizzSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.35; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.fizzLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.fizz.emit(fizzCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(_dt) { + const c = settings.bubblecage; + + // A bead of black water on its way out — the bell held at its start radius, + // dimmed, so the cage arrives from somewhere rather than being switched on. + this.bell.visible = true; + this._syncBell(this.position, 0, saturate(c.bellOpacity) * 0.5); + this.filmGeometry.instanceCount = 0; + this.pool.setVisible(false); + this._sync(1); + } + + onImpact() { + const c = settings.bubblecage; + const g = settings.global; + const time = frame.uTime.value; + + for (let i = 0; i < this._count; i++) this._burst[i] = 0; + this.filmGeometry.instanceCount = this._count; + this.pool.setVisible(true); + + this.pointAt(1, _pos); + + this.ctx.bursts.spawn(BurstMode.WATER, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.65, + intensity: c.burstIntensity, + opacity: 0.9, + fresnel: 2.0, + displace: 0.55, + squash: 0.75, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.decals.spawn(DecalType.RIPPLE, _pos, { + radius: c.rippleRadius * g.explosionIntensity, + life: c.rippleLife, + intensity: c.rippleIntensity, + colorA: getColor(c.colorRippleA), + colorB: getColor(c.colorRippleB) + }); + + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.3; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.dropSpeed * 2.4; + _emit.speedVariance = 0.85; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.8; + _emit.life = c.dropLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.drops.emit(Math.round(c.popDroplets * 3 * g.particleCount), _emit); + + _emit.speed = c.mistSpeed * 3.0; + _emit.spread = 1.0; + _emit.size = 1.2; + _emit.life = c.mistLifetime * 1.2; + _emit.spin = 0.4; + this.mist.emit(Math.round(c.mistRate * 0.8 * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity, + 1 / Math.max(0.05, c.shakeDuration), + 20 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onFade(dt, _t) { + const c = settings.bubblecage; + + const local = this._clock(); + const bell = this._bellSpan(); + const collapse = this._collapse(); + + // The light sits at the cage's own height, so the bubbles throw shadow + // outward across the wet stone rather than up at nothing. + this._cageCentre(this.position); + + /* --- beat 1: the bell, which does not dim but goes --- */ + const inBell = local <= bell; + this.bell.visible = inBell; + const t1 = saturate(local / bell); + this._syncBell(_pos.copy(this.position).setY(0), t1, inBell ? 1 - t1 * t1 * t1 : 0); + + /* --- beat 2: the cage --- */ + this.filmGeometry.instanceCount = this._count; + this._sync(1 - collapse * 0.35); + this._rupture(); + this._cageFx(dt, (1 - collapse) * (inBell ? 0.5 : 1)); + + // A cage that is coming apart rumbles as its pool drains; almost nothing, + // but the silence at this point reads as the effect having already ended. + this.ctx.shake.rumble(c.impactShake * 0.06 * (1 - collapse) * settings.global.cameraShake, dt); + } + + onDestroy() { + this.filmGeometry.instanceCount = 0; + this.bell.visible = false; + this.pool.setVisible(false); + this._count = 0; + this._burst.fill(0); + } + + dispose() { + this.bell.dispose(); + this.pool.dispose(); + this.filmGeometry.dispose(); + this.filmMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/tide/GeyserAbility.js b/src/abilities/tide/GeyserAbility.js new file mode 100644 index 0000000..1295c98 --- /dev/null +++ b/src/abilities/tide/GeyserAbility.js @@ -0,0 +1,1060 @@ +import { BufferGeometry, Float32BufferAttribute, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { createGeyserDropMaterial, setDropColors } from '../../materials/GeyserDropMaterial.js'; +import { LiquidSurface, LiquidMode, liquidParams } from '../../vfx/LiquidSurface.js'; +import { Projectile, FlightMode, Stagger, projectileParams } from '../../vfx/Projectile.js'; +import { Tube, TubePath } from '../../vfx/Tube.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on drops. The editor's `droplets` slider clamps here. */ +const MAX_DROPS = 128; +/** Samples along the column. FUNNEL wants enough to keep the flare smooth. */ +const COLUMN_NODES = 80; +/** Facets around the column's barrel. */ +const COLUMN_SIDES = 26; +/** + * Grid resolution of the vent pool. It is a small disc, so this is generous; + * the cost that matters here is fill, not vertices. + */ +const POOL_SEGMENTS = 96; + +/* --- module-scope scratch: the frame allocates nothing (I3) --- */ +const _liq = liquidParams(); +/* The lava terms the module ships on by default. Spring water has no skin, is + not self-lit, and has no glowing seams. */ +_liq.crust = 0; +_liq.emissive = 0; +_liq.seamGlow = 0; +_liq.meltGlow = 0; + +const _proj = projectileParams(); +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _hit = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/* ---------------------------------------------------------------------- */ +/* One drop */ +/* ---------------------------------------------------------------------- */ + +/** + * Unit-radius geometry for one falling drop. + * + * `Projectile` scales this by `dropRadius` and, with `align` at 1, lays its + * **+Y** along the drop's own heading — so +Y is the *leading* end and −Y is + * the trailing one. A falling drop is blunt where it is pushing air and drawn + * out to a point behind, which is the way round this profile is built. Getting + * it backwards makes every drop look like it is flying tail-first, and it is + * surprisingly obvious once a hundred of them are on screen. + * + * @param {object} shape live numbers from `settings.geyser` + */ +function createDropletGeometry(shape) { + const facets = clamp(Math.round(shape.facets), 4, 24); + const rings = clamp(Math.round(shape.rings), 3, 24); + const taper = Math.max(0, shape.taper); + const pinch = clamp(shape.pinch, 0, 0.9); + + const levels = rings + 1; + const radii = new Float32Array(levels); + let widest = 1e-4; + for (let i = 0; i < levels; i++) { + // `u` runs 0 at the trailing point to 1 at the leading, blunt end. + const u = i / rings; + const y = -1 + 2 * u; + // An ellipsoid of revolution, dragged to a point behind and necked just + // aft of the shoulder. Two multiplications, and between them they cover + // everything from a bead (taper 0, pinch 0) to a comet. + let r = Math.sqrt(Math.max(0, 1 - y * y)); + r *= Math.pow(u, taper); + r *= 1 - pinch * Math.exp(-Math.pow((u - 0.34) / 0.22, 2)); + radii[i] = Math.max(r, 0.004); + if (radii[i] > widest) widest = radii[i]; + } + // Normalised so `dropRadius` means the drop's actual half-width in metres + // rather than "the half-width of the ellipsoid this was carved out of". + const scale = 1 / widest; + + const positions = new Float32Array(levels * facets * 3); + const indices = []; + let write = 0; + for (let i = 0; i < levels; i++) { + const y = -1 + (2 * i) / rings; + const r = radii[i] * scale; + for (let f = 0; f < facets; f++) { + const theta = (f / facets) * TAU; + positions[write++] = Math.cos(theta) * r; + positions[write++] = y; + positions[write++] = Math.sin(theta) * r; + } + } + for (let i = 0; i < rings; i++) { + for (let f = 0; f < facets; f++) { + const next = (f + 1) % facets; + const lowNear = i * facets + f; + const lowFar = i * facets + next; + const highNear = (i + 1) * facets + f; + const highFar = (i + 1) * facets + next; + indices.push(lowNear, highNear, lowFar, lowFar, highNear, highFar); + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +/** + * GEYSER — a vent opens, a column stands out of it, and the column comes back + * down as rain. + * + * Four beats. The cast reaches the circle and a pool wells up. The vent blows: + * a column of water stands to `jetHeight` over `chargeTime` and holds at full + * pressure for `holdTime`, breathing. Then the pressure **fails** — the column + * collapses over `collapseTime` and, on that one frame, the water that was in + * the air is handed to a hundred drops. They arc over, come down, and each one + * puts its own ripple into the pool it came out of. + * + * **THE TRICK — the rain is the column, and every number it flies on is derived + * from the column rather than authored beside it.** + * + * `vfx/Tube.js` in `FUNNEL` mode publishes `radiusAt(t)` and `pointAt(t)`, the + * real profile and the real axis. At the moment of the pressure loss this file + * asks it four questions and answers the whole flight with them: + * + * | asked | used for | + * | --- | --- | + * | `pointAt(seedAt).y` | the drops' launch altitude — `skyHeight` | + * | `radiusAt(seedAt)` | how wide the cloud they leave from is — `skyScatter` | + * | `columnSpeed × pressure` | the upward velocity they inherit → the apex | + * | `2π · jetSpin · radiusAt(seedAt) · swirlCarry` | the tangential velocity they inherit → the ring | + * + * From those, the ballistics: they still climb `v²/2g`, they are in the air for + * `v/g + sqrt(2(h + v²/2g)/g)` seconds, and they land at + * `radiusAt(seedAt) + (swirl + spreadSpeed) × flightTime` metres from the vent. + * **There is no `rainRadius` slider and no `dropFlightTime` slider.** The first + * version had both, and they were never the same as the column twice: every + * retune of the plume left the rain either falling inside the pool or out on + * dry floor, with nothing in the panel to explain it. Drag `jetMouthFlare` now, + * with the clock stopped, and the launch cloud widens *and* the landing ring + * moves out with it, because both of them are `radiusAt()`. + * + * **The funnel is inverted, and the inversion is a profile and not a + * transform.** `FUNNEL` is written for a tornado: a wide intake at the top, a + * waist, and a skirt flaring where it touches the floor. This ability authors + * `jetSkirtFlare` tiny — a collar around the vent bore — and `jetMouthFlare` + * large with a low `jetMouthStart`, which is the same silhouette upside down. + * Rotating the tube end for end was tried first and is worse in a way that is + * easy to miss: `jetSwayCurve` weights the precession toward `t = 1`, so a + * flipped tube whips at the *vent* and stands rigid at the head. Left the right + * way up, the head whips and the foot stays planted, which is what a geyser + * does. + * + * **The pool is the same water at the other end.** Every drop that crosses + * τ = 1 raises an arrival, and the frame it does, `rippleAtWorld()` puts an + * analytic packet into the heightfield at exactly where it landed. Nothing + * approximates the rain as a "wet" decal: a hundred drops make a hundred + * ripples, eight at a time in the module's ring buffer, and the pool is visibly + * still ringing after the last one is down. + * + * **Ordering matters.** `rippleAtWorld()` converts a world point against *this + * frame's* half-extents, so it must be called after `LiquidSurface.update()`. + * The order here is therefore: column, then pool, then drops, then arrivals. + * Column first because the drops read their launch geometry off it; pool before + * drops because the arrivals are consumed the instant `Projectile.update()` + * returns. + * + * A cast captures a seed and two timestamps — `_ventAt` (the vent opening) and + * `_blowAt` (the pressure failing). Everything with a unit is resolved from + * `settings.geyser` inside the update loop, zero-length frames included. + * + * **Six draw calls**: the pool, three column layers, the drops, their trails. + */ +export class GeyserAbility extends Ability { + constructor(context) { + super('geyser', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the vent pool ------------------------------------------------ */ + this.pool = new LiquidSurface({ + segments: POOL_SEGMENTS, + mode: LiquidMode.POOL, + depthWrite: true, + doubleSide: true, + renderOrder: 3, + name: 'geyser.pool' + }); + this.pool.object3D.layers.set(LAYER.VFX); + this.group.add(this.pool.object3D); + + /* --- the column --------------------------------------------------- */ + this.column = new Tube({ + path: TubePath.FUNNEL, + prefix: 'jet', + nodes: COLUMN_NODES, + sides: COLUMN_SIDES, + renderOrder: 11 + }); + this.group.add(this.column.group); + + /* --- the rain ------------------------------------------------------ */ + /** + * Live shape controls for the drop factory. Mutated in place — an object + * literal per frame is the allocation I3 forbids. Declared *before* the + * `Projectile` below, because its constructor calls the geometry factory + * immediately and the factory reads this. + */ + this._shape = { facets: 9, rings: 7, taper: 0.55, pinch: 0.28 }; + + this.dropMaterial = createGeyserDropMaterial(); + this.drops = new Projectile(this.group, { + capacity: MAX_DROPS, + // A factory, not a geometry: `Projectile` takes ownership of what this + // returns and rebuilds it whenever `shapeKey()` moves, which is what + // makes the four shape sliders live. + geometry: () => createDropletGeometry(this._shape), + shapeKey: () => { + const c = settings.geyser; + return `${Math.round(c.dropFacets)}|${Math.round(c.dropRings)}|${c.dropTaper}|${c.dropPinch}`; + }, + material: this.dropMaterial, + trail: true, + trailNodes: 22, + trailAdditive: true, + layer: LAYER.VFX, + renderOrder: 6, + castShadow: false + }); + + /** Scratch state handed to the column each frame. Dice and timestamps only. */ + this._state = { + origin: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 0, + fade: 0, + widthFade: 1, + seed: 0, + time: 0 + }; + + /** Re-rolled per cast so two vents do not spit the same pattern. */ + this._seed = 0; + /** `this.age` when the vent opened, or −1. A timestamp. */ + this._ventAt = -1; + /** `this.age` when the pressure failed, or −1. A timestamp. */ + this._blowAt = -1; + /** One-shot latch on the pressure loss. */ + this._blown = false; + /** Drops drawn last frame, for the HUD's instance readout. */ + this._live = 0; + /** 0..1 the column's pressure, resolved once per frame in `_sync`. */ + this._gate = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The water shed off the column, and the splash under every landing. + this.spray = particles.get('geyser.spray', { + capacity: 3200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.3 + }); + this.spray.uniforms.uDrag.value = 1.4; + this.spray.uniforms.uEndSize.value = 0.28; + this.spray.uniforms.uSizeIn.value = 0.05; + this.spray.uniforms.uFadeIn.value = 0.05; + this.spray.uniforms.uFadeOut.value = 0.45; + + // The boil. Non-additive, so it genuinely occludes the column behind it — + // steam that adds reads as light and the vent stops looking hot and wet. + this.steam = particles.get('geyser.steam', { + capacity: 1800, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.1 + }); + this.steam.uniforms.uDrag.value = 1.9; + this.steam.uniforms.uEndSize.value = 3.0; + this.steam.uniforms.uSizeIn.value = 0.14; + this.steam.uniforms.uFadeIn.value = 0.2; + this.steam.uniforms.uFadeOut.value = 0.32; + + // What the vent tears out of the floor with the water. + this.grit = particles.get('geyser.grit', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.35; + this.grit.uniforms.uEndSize.value = 0.6; + this.grit.uniforms.uFadeOut.value = 0.65; + + this.sprayEmitter = new RateEmitter(); + this.steamEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + /** The vent runs for the whole of the impact phase. */ + get impactDuration() { + return Math.max(0.1, settings.geyser.lifetime * settings.global.lifetime); + } + + /** The pool settling once the rain is down. */ + get fadeDuration() { + return Math.max(0.1, settings.geyser.fadeTime); + } + + /* ------------------------------------------------------------------ */ + /* The beats */ + /* ------------------------------------------------------------------ */ + + /** The centre of the footprint — the far end of the aimed line. */ + _ventPoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** + * Seconds since the vent opened. + * + * Measured across the phase boundary rather than off `impactTime` alone, + * because `chargeTime + holdTime` is two sliders and nothing stops them being + * dragged past `lifetime` — at which point `impactTime` has stopped + * accumulating and the column would never lose pressure. + */ + _runElapsed() { + if (this._ventAt < 0) return -1; + return this.age - this._ventAt; + } + + /** 0..1 — how far the column has stood up. */ + _rise() { + const c = settings.geyser; + const elapsed = this._runElapsed(); + if (elapsed < 0) return 0; + return Easing.outCubic(saturate(elapsed / Math.max(0.02, c.chargeTime))); + } + + /** 0..1 — how far the column has fallen back in since it lost pressure. */ + _collapse() { + if (!this._blown) return 0; + const c = settings.geyser; + return saturate((this.age - this._blowAt) / Math.max(0.05, c.collapseTime)); + } + + /** + * 0..1 — the pressure in the column right now. + * + * The surge is a real term rather than a decoration: a geyser does not run at + * a constant head, and the column's whole silhouette is a function of this + * number, so breathing it also breathes the height, the width and the rate of + * everything the vent throws. + */ + _pressure() { + const c = settings.geyser; + const elapsed = this._runElapsed(); + if (elapsed < 0) return 0; + const breathe = 1 + c.surge * Math.sin(elapsed * TAU * c.surgeRate); + return saturate(this._rise() * (1 - Easing.inCubic(this._collapse())) * breathe); + } + + /** + * 0..1 — the pressure the column had on the frame it failed. + * + * Recomputed from two timestamps and the live `chargeTime` rather than + * captured at the blow, so dragging the charge with the clock stopped + * re-throws the rain that is already in the air. + */ + _blowPressure() { + if (!this._blown) return 0; + const c = settings.geyser; + const elapsed = Math.max(0, this._blowAt - this._ventAt); + return Easing.outCubic(saturate(elapsed / Math.max(0.02, c.chargeTime))); + } + + /** 0..1 — how far the pool has welled up. */ + _fill() { + const c = settings.geyser; + if (this.phase === AbilityPhase.TRAVEL) return saturate(c.preFill) * this.u; + const open = Easing.outCubic(saturate(this._runElapsed() / Math.max(0.02, c.ventRise))); + return lerp(saturate(c.preFill), 1, open); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sprayEmitter.reset(); + this.steamEmitter.reset(); + this.gritEmitter.reset(); + + this._seed = Math.random() * 100; + this._ventAt = -1; + this._blowAt = -1; + this._blown = false; + this._live = 0; + this._gate = 0; + + this.pool.reset(); + this.pool.visible = true; + this.drops.reset(); + this.drops.roll(this._seed); + this.column.visible = false; + + this._sync(); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Resolve the column, the pool and the rain from live settings. + * + * Every metre, radian and second below is read on the frame it is used, + * including a zero-length one. The order — column, pool, drops, arrivals — is + * load-bearing; see the class comment. + */ + _sync() { + const c = settings.geyser; + const g = settings.global; + + const pressure = this._pressure(); + this._gate = pressure; + + this._ventPoint(_centre); + + /* ---------------- the column ---------------- */ + const state = this._state; + state.origin.copy(_centre); + state.origin.y = c.poolHeight + c.jetBase; + state.target.copy(state.origin); + state.target.y = state.origin.y + Math.max(0.1, c.jetHeight); + state.side.copy(this.side); + // The column draws from the vent up to whatever it has reached, and + // retracts from the head down as it falls in — which is right, because the + // water that used to be up there is now the rain. + state.progress = this._rise() * (1 - Easing.inCubic(this._collapse())); + state.fade = this.phase === AbilityPhase.TRAVEL ? 0 : saturate(pressure * 1.4); + state.widthFade = lerp(1, Math.max(0.01, c.jetTaper), this._collapse()); + state.seed = this._seed; + state.time = Math.max(0, this._runElapsed()); + + this.column.sync(c, state, g); + this.column.visible = state.fade > 0.002 && state.progress > 0.002; + + /* ---------------- the pool ---------------- */ + _pos.copy(_centre); + _pos.y = c.poolHeight; + this.pool.setPlacement(_pos, this.direction, _up); + + _liq.sizeX = c.zoneRadius * 2; + _liq.sizeZ = c.zoneRadius * 2; + _liq.fill = this._fill(); + _liq.round = c.round; + _liq.edgeSoft = c.edgeSoft; + _liq.edgeNoise = c.edgeNoise; + _liq.edgeScale = c.edgeScale; + _liq.seed = this._seed; + _liq.opacity = c.poolOpacity * g.opacity; + _liq.contactFade = c.contactFade; + + _liq.waveAmpA = c.waveAmpA; + _liq.waveAmpB = c.waveAmpB; + _liq.waveAmpC = c.waveAmpC; + _liq.waveAmpD = c.waveAmpD; + _liq.waveLengthA = c.waveLengthA; + _liq.waveLengthB = c.waveLengthB; + _liq.waveLengthC = c.waveLengthC; + _liq.waveLengthD = c.waveLengthD; + _liq.waveSpeedA = c.waveSpeedA; + _liq.waveSpeedB = c.waveSpeedB; + _liq.waveSpeedC = c.waveSpeedC; + _liq.waveSpeedD = c.waveSpeedD; + _liq.waveAngleA = c.waveAngleA; + _liq.waveAngleB = c.waveAngleB; + _liq.waveAngleC = c.waveAngleC; + _liq.waveAngleD = c.waveAngleD; + _liq.steepness = c.steepness; + + _liq.chop = c.chop; + _liq.chopScale = c.chopScale * g.noiseFrequency; + _liq.chopSpeed = c.chopSpeed * g.noiseSpeed; + _liq.detail = c.detail; + _liq.detailScale = c.detailScale * g.noiseFrequency; + _liq.detailSpeed = c.detailSpeed * g.noiseSpeed; + + _liq.rippleAmp = c.rippleAmp; + _liq.rippleSpeed = c.rippleSpeed; + _liq.rippleLength = c.rippleLength; + _liq.rippleWidth = c.rippleWidth; + _liq.rippleDecay = c.rippleDecay; + _liq.rippleSpread = c.rippleSpread; + + _liq.flowAngle = c.flowAngle; + _liq.flowSpeed = c.flowSpeed; + // The boil is the vent's own outflow, so it lives and dies with the + // pressure rather than running for the whole cast. + _liq.flowRadial = c.flowRadial * (0.25 + 0.75 * pressure); + _liq.flowRadialFall = c.flowRadialFall; + _liq.flowEddy = c.flowEddy; + _liq.flowEddyScale = c.flowEddyScale * g.noiseFrequency; + _liq.flowEddySpeed = c.flowEddySpeed * g.noiseSpeed; + _liq.flowGravity = c.flowGravity; + + _liq.foam = c.foam; + _liq.foamScale = c.foamScale * g.noiseFrequency; + _liq.foamSharp = c.foamSharp; + _liq.foamCrest = c.foamCrest; + _liq.foamSpeed = c.foamSpeed; + + _liq.poolDepth = c.poolDepth; + _liq.depthTint = c.depthTint; + _liq.translucency = c.translucency; + _liq.ambient = c.ambient; + _liq.specular = c.specular; + _liq.shininess = c.shininess; + _liq.fresnel = c.fresnel * g.fresnel; + _liq.envIntensity = c.envIntensity; + _liq.skyIntensity = c.skyIntensity; + _liq.glow = c.poolGlow * g.glow; + _liq.normalEps = c.normalEps; + + _liq.colorDeep = c.colorDeep; + _liq.colorShallow = c.colorShallow; + _liq.colorFoam = c.colorFoam; + _liq.colorSpec = c.colorSpec; + _liq.colorSky = c.colorSky; + + this.pool.update(this.age, _liq); + this.pool.visible = _liq.fill > 0.001 && _liq.opacity > 0.001; + + /* ---------------- the rain ---------------- */ + this._syncRain(); + + /* ---------------- the particle systems ---------------- */ + this.spray.setGradient( + getColor(c.colorSprayA), + getColor(c.colorSprayB), + getColor(c.colorSprayC), + getColor(c.colorSprayD) + ); + this.spray.uniforms.uGravity.value.set(0, c.sprayGravity, 0); + this.spray.uniforms.uSizeScale.value = c.spraySize * g.particleSize * 7; + this.spray.uniforms.uLifeScale.value = c.sprayLifetime * 0.5 * g.particleLifetime; + this.spray.uniforms.uSpeedScale.value = g.particleSpeed; + this.spray.uniforms.uOpacity.value = g.opacity; + this.spray.uniforms.uGlow.value = c.poolGlow * 0.7 * g.glow; + this.spray.uniforms.uTurbulence.value = c.sprayTurbulence * g.turbulence; + + this.steam.setGradient( + getColor(c.colorSteamA), + getColor(c.colorSteamB), + getColor(c.colorSteamC), + getColor(c.colorSteamD) + ); + this.steam.uniforms.uGravity.value.set(0, c.steamRise, 0); + this.steam.uniforms.uSizeScale.value = c.steamSize * g.particleSize; + this.steam.uniforms.uLifeScale.value = c.steamLifetime * 0.5 * g.particleLifetime; + this.steam.uniforms.uSpeedScale.value = c.steamSpeed * g.particleSpeed; + this.steam.uniforms.uOpacity.value = c.steamOpacity * g.opacity; + this.steam.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = c.gritLifetime * 0.5 * g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + + /* ---------------- the light ---------------- */ + if (this.phase === AbilityPhase.TRAVEL) { + this.pointAt(this.u, this.position); + this.position.y = c.poolHeight; + } else { + this.column.pointAt(saturate(c.lightRide), this.position); + this.position.y += c.lightHeight; + } + } + + /** + * The whole flight, derived from the column. + * + * Nothing in here is authored twice: the launch geometry is `pointAt()` and + * `radiusAt()`, and the ballistics are the inherited velocity against + * `gravity`. See the table in the class comment. + */ + _syncRain() { + const c = settings.geyser; + const g = settings.global; + + const gravity = Math.max(0.05, c.gravity); + const seedAt = saturate(c.seedAt); + + // Where the water tears off, and how wide the column is there. + // + // `radiusAt()` already carries this frame's collapse taper (`widthFade`), + // and letting that into the ballistics would haul drops that are *already + // in the air* back toward the axis as the column died — every landing point + // is re-derived every frame, so a shrinking profile drags all of them at + // once. Divide it back out: the rain left a column that was still at full + // width, and that is the column it has to be flying off. + this.column.pointAt(seedAt, _pos); + const headY = _pos.y; + const seedRadius = this.column.radiusAt(seedAt) / Math.max(this._state.widthFade, 1e-3); + + // What it is doing at that moment: up, and around. + const rise = Math.max(0, c.columnSpeed * this._blowPressure()); + const apex = (rise * rise) / (2 * gravity); + const drop = Math.max(0.05, headY - c.poolHeight + apex); + const flight = Math.max(0.05, rise / gravity + Math.sqrt((2 * drop) / gravity)); + const swirl = TAU * Math.abs(c.jetSpin) * seedRadius * c.swirlCarry; + + _proj.mode = FlightMode.FALL; + _proj.stagger = Stagger.HASH; + _proj.count = this._blown ? Math.min(MAX_DROPS, Math.round(c.droplets)) : 0; + _proj.radius = c.dropRadius; + _proj.sizeJitter = c.dropSizeJitter * g.randomness; + _proj.stretch = c.dropStretch; + _proj.align = c.dropAlign; + _proj.spin = c.dropSpin; + _proj.flash = c.dropFlash; + + // FALL's launch is `origin − direction × skyBack ± scatter`, at `skyHeight`. + // Zero the setback: this plume is standing over the vent, not thrown in + // from behind the caster the way a meteor shower is. + _proj.skyBack = 0; + _proj.skyHeight = headY; + _proj.skyScatter = seedRadius; + + _proj.landHeight = c.poolHeight; + _proj.landInZone = true; + // The ring. Water on the axis falls nearly straight down and water on the + // rim is thrown outward for the whole flight, so the landing is + // rim-crowded rather than uniform — that crowding is `dropRingBias`. + _proj.zoneRadius = seedRadius + (swirl + c.spreadSpeed) * flight; + _proj.zoneBias = Math.max(0.05, c.dropRingBias); + + _proj.pathCurve = c.dropPathCurve; + _proj.apex = apex; + _proj.apexCurve = c.dropApexCurve; + // A ballistic drop does not weave. Zeroed rather than left to the module's + // defaults, which are tuned for homing bolts. + _proj.weaveSide = 0; + _proj.weaveUp = 0; + + _proj.flightTime = flight; + _proj.speedJitter = c.dropSpeedJitter; + _proj.lead = c.dropLead; + _proj.window = c.dropWindow; + _proj.fillBias = c.dropFillBias; + _proj.fillScatter = c.dropFillScatter; + _proj.hashCell = c.dropHashCell; + _proj.linger = c.dropLinger; + _proj.sink = c.dropSink; + + _proj.trailSpan = c.trailSpan; + _proj.trailBurn = c.trailBurn; + _proj.trailWidth = c.trailWidth; + _proj.trailTaper = c.trailTaper; + _proj.trailLift = c.trailLift; + _proj.trailOpacity = c.trailOpacity * g.opacity; + _proj.trailGlow = c.trailGlow * g.glow; + _proj.trailCore = c.trailCore; + _proj.trailHeadBias = c.trailHeadBias; + _proj.trailNoise = c.trailNoise * g.turbulence; + _proj.trailNoiseScale = c.trailNoiseScale * g.noiseFrequency; + _proj.trailNoiseSpeed = c.trailNoiseSpeed * g.noiseSpeed; + _proj.trailSoftFade = c.trailSoftFade; + + setDropColors(this.dropMaterial, c.colorDropClear, c.colorDropFroth, c.colorDropSpot, c.colorDropRim); + const d = this.dropMaterial.uniforms; + d.uIor.value = Math.max(1.001, c.dropIor); + d.uSpot.value = c.dropSpot; + d.uSpotPower.value = c.dropSpotPower; + d.uRim.value = c.dropRim * g.fresnel; + d.uRimPower.value = c.dropRimPower; + d.uFroth.value = c.dropFroth; + d.uFrothFade.value = c.dropFrothFade; + d.uAmbient.value = c.dropAmbient; + d.uShade.value = c.dropShade; + d.uFlashGain.value = c.dropFlashGain; + d.uGlow.value = c.dropGlow * g.glow; + d.uOpacity.value = c.dropOpacity * g.opacity; + d.uSoftFade.value = c.dropSoftFade; + + this.drops.setTrailColors( + getColor(c.colorTrailA), + getColor(c.colorTrailB), + getColor(c.colorTrailC), + getColor(c.colorTrailD) + ); + + this._shape.facets = c.dropFacets; + this._shape.rings = c.dropRings; + this._shape.taper = c.dropTaper; + this._shape.pinch = c.dropPinch; + this.drops.syncGeometry(); + + _pos.copy(_centre); + _pos.y = c.poolHeight; + // A metre of basis length would put the landing disc a metre downrange of + // the vent; a centimetre is below the resolution of anything on screen and + // keeps the disc centred on the water it came out of. + this.drops.setBasis(_pos, this.direction, this.side, 0.01); + // Drop time starts when the pressure fails, not when the cast left the + // hand: `dropWindow` is authored against the tear, and offsetting it by the + // travel time would make it mean different things at different ranges. + this.drops.update(this._blown ? Math.max(0, this.age - this._blowAt) : 0, _proj); + this._live = this.drops.count; + + /* --- the arrivals, straight after update() as the module says --- */ + for (let i = 0; i < this.drops.arrivalCount; i++) { + const index = this.drops.arrivals[i]; + this.drops.landPoint(index, _hit); + this.pool.rippleAtWorld(_hit, c.rippleStrength, this.age); + this._splashFx(_hit); + } + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** A point on the column's surface at height fraction `t`, world metres. */ + _columnPoint(t, out) { + const radius = this.column.radiusAt(t); + const bearing = Math.random() * TAU; + this.column.pointAt(t, out); + out.x += Math.cos(bearing) * radius; + out.z += Math.sin(bearing) * radius; + return out; + } + + /** Spray, steam and grit while the vent is running. */ + _ventFx(dt) { + const c = settings.geyser; + const g = settings.global; + const time = frame.uTime.value; + const gate = this._gate; + + const sprayCount = Math.round(this.sprayEmitter.tick(dt, c.sprayRate * gate) * g.particleCount); + if (sprayCount > 0) { + // Off the barrel, anywhere up it, crowded toward the head where the + // column is widest and slowest — the same `radiusAt()` the rain reads. + const t = Math.sqrt(Math.random()); + this._columnPoint(t, _pos); + _emit.position = _pos; + _emit.radius = this.column.radiusAt(t) * 0.35; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spraySpeed * lerp(1.2, 0.45, t); + _emit.speedVariance = 0.8; + _emit.spread = lerp(0.25, 0.9, t); + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.spraySize; + _emit.sizeVariance = 0.7; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spray.emit(sprayCount, _emit); + } + + // Steam keeps coming off the pool long after the column has gone, so it is + // gated on the pool being open rather than on the pressure. + const steamGate = this._fill(); + const steamCount = Math.round(this.steamEmitter.tick(dt, c.steamRate * steamGate) * g.particleCount); + if (steamCount > 0) { + const bearing = Math.random() * TAU; + const radius = c.zoneRadius * steamGate * Math.sqrt(Math.random()); + _pos.set(_centre.x + Math.cos(bearing) * radius, c.poolHeight, _centre.z + Math.sin(bearing) * radius); + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.3; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.steamSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.steamSize; + _emit.sizeVariance = 0.5; + _emit.life = c.steamLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = time; + this.steam.emit(steamCount, _emit); + } + + const gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate * gate) * g.particleCount); + if (gritCount > 0) { + this._columnPoint(0.04, _pos); + _emit.position = _pos; + _emit.radius = this.column.skirtRadius * 0.6; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.35; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.gritSize; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.grit.emit(gritCount, _emit); + } + } + + /** The little crown one drop throws where it goes back in. */ + _splashFx(point) { + const c = settings.geyser; + const g = settings.global; + const count = Math.round(c.splashPerDrop * g.particleCount); + if (count <= 0) return; + + _emit.position = point; + _emit.radius = c.dropRadius * 1.6; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spraySpeed * 0.5; + _emit.speedVariance = 0.9; + // Wide and shallow: a splash leaves sideways. Straight up is a fountain. + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.spraySize * 0.7; + _emit.sizeVariance = 0.8; + _emit.life = c.sprayLifetime * 0.6; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.spray.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(); + this._ventFx(dt); + } + + onImpact() { + const c = settings.geyser; + const g = settings.global; + const time = frame.uTime.value; + + // The first of the two timestamps a cast captures. + this._ventAt = this.age; + + this._ventPoint(_centre); + _pos.copy(_centre); + _pos.y = c.poolHeight; + + /* the shell of spray as the vent lets go */ + this.ctx.bursts.spawn(BurstMode.WATER, _pos, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.7, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.7, + displace: 0.6, + squash: 0.6, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring across the floor */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _centre, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.6, + width: 0.06, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* wet stone outside the waterline */ + const marks = Math.max(0, Math.round(c.wetMarks)); + for (let i = 0; i < marks; i++) { + // Evenly spaced with a jittered bearing rather than fully random: a + // handful of random angles clumps, and a clumped ring reads as a mistake. + const bearing = ((i + randRange(-0.35, 0.35)) / marks) * TAU; + const radius = c.zoneRadius * randRange(0.9, 1.3); + _pos.set(_centre.x + Math.cos(bearing) * radius, 0, _centre.z + Math.sin(bearing) * radius); + this.ctx.decals.spawn(DecalType.FOAM, _pos, { + radius: c.wetRadius * randRange(0.7, 1.3), + life: c.wetLife, + intensity: c.wetIntensity, + colorA: getColor(c.colorWet), + colorB: getColor(c.colorWetEdge), + height: 0.012 + }); + } + + /* the first throw out of the bore */ + _pos.copy(_centre); + _pos.y = c.poolHeight; + _emit.position = _pos; + _emit.radius = c.zoneRadius * 0.18; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spraySpeed * 2.2; + _emit.speedVariance = 0.9; + _emit.spread = 0.5; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.spraySize * 1.2; + _emit.sizeVariance = 0.8; + _emit.life = c.sprayLifetime * 1.3; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spray.emit(Math.round(70 * g.particleCount), _emit); + + _emit.speed = c.gritSpeed * 1.5; + _emit.spread = 0.6; + _emit.size = c.gritSize * 1.3; + _emit.life = c.gritLifetime; + _emit.spin = 11; + this.grit.emit(Math.round(40 * g.particleCount), _emit); + + this.ctx.shake.add( + c.ventShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.ventFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.9 * g.explosionIntensity; + } + + /** + * The pressure fails. One-shot, and all it captures is a timestamp — every + * number the rain flies on is re-derived from the column every frame after. + */ + _blow() { + const c = settings.geyser; + const g = settings.global; + + this._blown = true; + this._blowAt = this.age; + + // The head of the column bursting as it stops being held up. + this.column.pointAt(saturate(c.seedAt), _pos); + this.ctx.bursts.spawn(BurstMode.WATER, _pos, { + radius: this.column.radiusAt(saturate(c.seedAt)), + endRadius: c.burstSize * 1.3 * g.explosionIntensity, + life: 0.65, + intensity: c.burstIntensity * 0.8, + opacity: 0.75, + fresnel: 1.9, + displace: 0.7, + squash: 0.75, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _emit.position = _pos; + _emit.radius = this.column.radiusAt(saturate(c.seedAt)); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spraySpeed * 1.4; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.spraySize; + _emit.sizeVariance = 0.8; + _emit.life = c.sprayLifetime * 1.2; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.spray.emit(Math.round(60 * g.particleCount), _emit); + } + + /** + * The impact and fade phases are one beat here, and `t` is deliberately not + * read: every clock in this ability is measured off `_ventAt` and `_blowAt`, + * so dragging `lifetime` or `chargeTime` mid-cast moves the beats instead of + * re-scaling a normalised progress that has already been spent. + */ + onFade(dt) { + const c = settings.geyser; + if (!this._blown && this._runElapsed() >= c.chargeTime + c.holdTime) this._blow(); + + this._sync(); + this._ventFx(dt); + + // The ground shakes while the column is standing on it and stops when the + // pressure does — `_gate` is the same number the emitters are using. + this.ctx.shake.rumble(c.rumble * this._gate * settings.global.cameraShake, dt); + } + + onDestroy() { + this._live = 0; + this._gate = 0; + this._blown = false; + this._ventAt = -1; + this._blowAt = -1; + this.drops.reset(); + this.pool.reset(); + this.pool.visible = false; + this.column.visible = false; + } + + dispose() { + this.pool.dispose(); + this.column.dispose(); + this.drops.dispose(); + this.dropMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/tide/TiderushAbility.js b/src/abilities/tide/TiderushAbility.js new file mode 100644 index 0000000..9d22475 --- /dev/null +++ b/src/abilities/tide/TiderushAbility.js @@ -0,0 +1,866 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { LiquidSurface, LiquidMode, liquidParams } from '../../vfx/LiquidSurface.js'; +import { Caustics, CausticSource, CausticShape, causticsParams } from '../../vfx/Caustics.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** + * How many points along the lip one frame's droplets are split between. + * + * A breaking wave sheds along the *whole* lip. Firing a frame's droplets from + * one `across` reads as a hose pointed sideways — the same mistake the bolt's + * sparks made before they were batched, and it is worth paying five emits a + * frame to avoid it. + */ +const SPRAY_BATCHES = 5; + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope (I3). Filled and consumed inside one call. */ +/* ---------------------------------------------------------------- */ + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _lip = new Vector3(); +const _anchor = new Vector3(); +const _netCentre = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/** The two params blocks the shared modules read. Refilled every frame. */ +const _liquid = liquidParams(); +const _net = causticsParams(); +_net.centre = _netCentre; + +/** + * TIDERUSH — a breaking wave that lights the floor through itself. + * + * Three beats: **surge**, **break**, **run-off**. A heightfield wave travels + * down the aimed line, curls, dumps past the end of it and drains away. That + * much is a wave. What makes it this ability is what happens on the *ground*. + * + * ## THE TRICK — the light on the floor is the wave, seen a second time + * + * `vfx/Caustics.js` is built to be driven rather than to animate itself, and + * `bindSource()` is the whole hook: it swaps the net's uniform boxes for + * `LiquidSurface`'s, **by identity**, so the swell, the chop, the crest profile + * and every live ripple packet are one set of numbers with one author. The net + * on the floor is then not a decal that happens to look wet — it is the image + * of the surface above it, computed from the same height field the vertex + * shader displaces the water with. + * + * The net is not drawn as a pattern. Light entering the surface at `xz` lands + * on the floor at `A(xz) = xz − (1 − 1/ior)·D·∇h`, and its brightness there is + * `1/|det J|` of that map: where the map **folds**, `det J` crosses zero and a + * hairline filament appears. Two consequences fall out for free and they are + * the reason this cast is worth casting: + * + * - **The crest's face is the brightest thing on the floor.** The face is an + * exponential with a `crestFace`-metre decay length, so its second + * derivative is enormous and the fold term detonates along a line a metre or + * so *ahead* of the lip. Nothing tells it to be there. + * - **You read the water's thickness off the ground.** The net is attenuated + * by `exp(−absorb · (depth + h))` — the column of water over that floor + * point. Under the body of the wave that column is a metre deeper than it is + * on the face, so the ground goes dark under the mass and flares in the thin + * water in front of it. Set `netAbsorb` to 0 and the cast still renders + * perfectly; the ability is gone. + * + * `netDepthCrest` closes the loop by feeding the crest's *current* height back + * into `D`, the lever arm of the fold, so the net sharpens as the wave stands + * up and slackens as it collapses. That is one number and it does more for the + * cast than any colour picker in the block. + * + * ## The handedness, which cost an afternoon + * + * The caustic quad is `vfx/quads.js`'s ground quad and its local frame + * `(+X, +Y, +Z)` is right-handed. `LiquidSurface`'s frame is `(axisX, up, + * cross(up, axisX))`, and `axisX × up = −axisZ` — it is **left**-handed. No + * rotation about `Y` can align both, which means the fragment at surface + * coordinate `(x, z)` is handed `(x, −z)` by the net, and the light on the + * floor is the water's mirror image across the lane. + * + * The first version ignored this and it is not invisible: the grain of the net + * leaned one way and the grain of the water leaned the other, and the whole + * point of binding the two together quietly evaporated. The fix is not code, it + * is authoring. The swell is written in **mirror pairs** — A/B and C/D at + * `±angle` with equal amplitude, length and speed — and a wave set symmetric + * about the lane is mapped onto itself by that mirror, so the net and the water + * agree exactly. The crest is symmetric already. What is left is the chop + * (isotropic noise: statistically identical either way, no systematic lean) and + * the ripple packets, which is why `rippleSpan` defaults to a quarter and is + * documented as the one term where the mirror is visible: a packet posted a + * metre to the left of the lane rings the floor a metre to its right. + * + * ## The sheet is a window, not a canal + * + * `CrimsonTideAbility` re-cuts its plane to the whole run the surge has covered. + * This one carries a **fixed-length window** of water, `sheetSpan` metres of it, + * with the crest seated at a constant `crestSeat` fraction along it. The + * consequence is worth stating: `waveFront` is a *constant*. The crest never + * moves in the sheet's own coordinates — the sheet moves under the world — so + * there is exactly `sheetSpan · (1 − crestSeat)` metres of water in front of + * the lip at all times, which is the floor the caustics play on and the reason + * the bright line ahead of the wave never runs out of water to be in. + * + * The tail is pinned `sheetTail` metres behind the caster until the window has + * travelled far enough to unpin it, so the surge grows out of his feet instead + * of appearing behind him. While it is pinned, `waveFront` is solved from the + * window's real extent — the same solve `CrimsonTideAbility` does every frame, + * for the same reason, and it is only interesting here for the first metre. + * + * ## Two things `LiquidSurface` does that water must be told not to do + * + * `crust` is never written, so the whole crust block — a flow field plus two + * advected fbm phases, the most expensive thing in that fragment shader — is + * skipped. But `meltGlow` and `seamGlow` are **not** gated by `crust`, and + * `meltGlow` defaults to 1.2: with foam on (which computes a surface speed) + * that term lights the entire sheet with `colorHot`, and a warm emissive haze + * over cold water was the first thing this cast got wrong. Both are pinned to + * zero by hand, in `_sync`, with this comment attached. + * + * ## What a cast captures + * + * One number — `_seed`, so two waves do not break identically — and timestamps. + * Not a metre, not a radian, not a second. Pause with **P** mid-surge and drag + * `netDepth`: the filaments on the floor tighten and separate into their red + * and blue folds while the water above them does not move, because the net is + * solved from the height field every frame rather than advanced. + */ +export class TiderushAbility extends Ability { + constructor(context) { + super('tiderush', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* + * 96 segments a side. Below about 48 the Gerstner cusps facet and the curl + * turns into a chamfer; above 160 you are paying vertex cost for detail the + * fragment normal already carries for nothing. + */ + this.surface = new LiquidSurface({ + segments: 96, + mode: LiquidMode.WAVE, + depthWrite: true, // a heightfield is a solid; its crest must hide its own back + doubleSide: true, // required the moment `crestCurl` folds the sheet over + renderOrder: 8, + name: 'Tiderush:sheet' + }); + this.group.add(this.surface.object3D); + + /* + * The net. `DISC` rather than `LANE`, and that is forced rather than + * chosen: `LANE`'s envelope measures its front along the quad's local `+Z`, + * while the bound height field has to be sampled with the quad's local `+X` + * pointing downrange — the two cannot both be true, and sampling the wave + * transposed draws the crest as a wall running *across* the lane. A disc is + * rotationally symmetric, so it does not care which way the quad is turned, + * and it is sized and centred on the travelling window every frame. + */ + this.net = new Caustics(this.group, { + source: CausticSource.WAVE, + shape: CausticShape.DISC, + additive: true, + depthTest: true, + renderOrder: 7, + name: 'Tiderush:net' + }); + // One height field, two consumers. Everything in `CAUSTIC_BOUND_KEYS` now + // belongs to the surface and `Caustics#update` skips it. + this.net.bindSource(this.surface.uniforms); + + /** Re-rolled per cast so no two waves break the same way. */ + this._seed = 0; + /** Metres of front travel already paid out in floor marks. */ + this._wetDistance = 0; + /** Live ripple packets, for the HUD readout. */ + this._packets = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The droplets thrown off the lip. Soft rounds, not streaks: a water bead + // stretched along its velocity turns the whole plume into rain, and this + // plume is being thrown rather than falling. + this.spray = particles.get('tiderush.spray', { + capacity: 2400, + shape: ParticleShape.SOFT, + additive: false, + lit: true, + softFade: 0.3 + }); + this.spray.uniforms.uDrag.value = 0.6; + this.spray.uniforms.uEndSize.value = 0.5; + this.spray.uniforms.uSizeIn.value = 0.03; + this.spray.uniforms.uFadeIn.value = 0.04; + this.spray.uniforms.uFadeOut.value = 0.5; + + // The haze standing over the break. Non-additive so it genuinely occludes + // the sheet behind it — additive mist over dark water is a blue fog. + this.mist = particles.get('tiderush.mist', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.mist.uniforms.uDrag.value = 1.9; + this.mist.uniforms.uEndSize.value = 2.4; + this.mist.uniforms.uSizeIn.value = 0.14; + this.mist.uniforms.uFadeIn.value = 0.2; + this.mist.uniforms.uFadeOut.value = 0.35; + + // Glints: the airborne droplets that catch the same refracted light the + // floor is catching. The only additive system in the cast, deliberately — + // they are the one thing here that *is* light rather than water. + this.glints = particles.get('tiderush.glints', { + capacity: 1400, + shape: ParticleShape.SOFT, + additive: true, + softFade: 0.2 + }); + this.glints.uniforms.uDrag.value = 0.9; + this.glints.uniforms.uEndSize.value = 0.3; + this.glints.uniforms.uSizeIn.value = 0.02; + this.glints.uniforms.uFadeIn.value = 0.03; + this.glints.uniforms.uFadeOut.value = 0.55; + + this.sprayEmitter = new RateEmitter(); + this.mistEmitter = new RateEmitter(); + this.glintEmitter = new RateEmitter(); + this.rippleEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + /** + * Nothing here is instanced — a subdivided plane, a ground quad and three + * particle systems. The honest readout is how many of the sheet's eight + * ripple slots this cast has posted into, because that is the only thing in + * the cast with a count, and it is also the number that says how much of the + * net is being driven by impacts rather than by the swell. + */ + get instanceCount() { + return this._packets; + } + + /** The crest pitches over and collapses. */ + get impactDuration() { + return Math.max(0.05, settings.tiderush.breakTime * settings.global.lifetime); + } + + /** Then it runs off the floor. */ + get fadeDuration() { + return Math.max(0.05, settings.tiderush.drainTime); + } + + /** + * Water does not gutter. A slow swell at roughly the rate the longest wave + * component runs at, so the wet floor breathes instead of sitting flat. + */ + lightShimmer() { + return 0.87 + 0.13 * Math.sin(this.age * 2.3); + } + + /* ------------------------------------------------------------------ */ + /* The beats — pure functions of the phase clock and live settings */ + /* ------------------------------------------------------------------ */ + + /** 0..1 how far the crest has run down the line. */ + _surge() { + return this.phase === AbilityPhase.TRAVEL ? this.u : 1; + } + + /** + * 0..1 through the break, and 0..1 through the run-off. + * + * Recovered from the phase clock rather than stored, so a mid-cast change to + * `breakTime` re-times the beat it is in. + */ + _breakAmount() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.IMPACT) return saturate(this.impactTime / this.impactDuration); + return 1; + } + + _drainAmount() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / this.fadeDuration); + } + + /** + * The crest's height in metres, right now. + * + * Three regimes and the middle one is the ability: on the way out it grows + * over the first `crestRise` of the run, at the break it swells to + * `crestPeak` and dumps, and through the run-off it is a dying swell. The + * net reads this number twice — once as the height field it folds light + * through, and once through `netDepthCrest` as the depth of water that light + * is crossing. + */ + _crestHeightNow() { + const c = settings.tiderush; + const brk = this._breakAmount(); + const drain = this._drainAmount(); + if (drain > 0) return c.crestHeight * 0.24 * (1 - Easing.outQuad(drain)); + if (brk > 0) { + // Fast rise, slow dump: the lip is highest just as it pitches forward. + const bump = Math.sin(Math.PI * Math.pow(brk, 0.8)); + return c.crestHeight * lerp(1, c.crestPeak, bump) * (1 - Easing.inQuad(brk) * 0.7); + } + const rise = saturate(this._surge() / Math.max(0.02, c.crestRise)); + return c.crestHeight * Easing.outCubic(rise); + } + + /** Metres of forward throw per metre of height — the overhang. */ + _crestCurlNow() { + const c = settings.tiderush; + return c.crestCurl * lerp(1, c.crestCurlPeak, Easing.outCubic(this._breakAmount())); + } + + /** 0..1 how ragged the lip is — it tears itself apart as it goes over. */ + _crestBreakNow() { + const c = settings.tiderush; + return Math.min(1, c.crestBreak * lerp(1, c.crestBreakPeak, Easing.outCubic(this._breakAmount()))); + } + + /** Metres down the line the crest stands, including its throw past the end. */ + _crestDistance() { + const c = settings.tiderush; + return this._surge() * this.length + c.crestOvershoot * Easing.outCubic(this._breakAmount()); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sprayEmitter.reset(); + this.mistEmitter.reset(); + this.glintEmitter.reset(); + this.rippleEmitter.reset(); + this._wetDistance = 0; + this._packets = 0; + + this.surface.clearRipples(); + this.net.reset(); + + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + + this._sync(1); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve the window, the sheet and the net from live settings. + * + * Order matters in exactly one place: the surface is updated before the net, + * because the net's params are read *after* the bound boxes have this frame's + * numbers in them and because `_netCentre` has to be the window's centre for + * this frame, not the last one. Everything the net is handed here is about + * the light; everything about the water went in above it. + * + * @param {number} fade 1 while the wave stands, ramping to 0 as it runs off + */ + _sync(fade) { + const c = settings.tiderush; + const g = settings.global; + + /* ---------------- the travelling window ---------------- */ + // Metres, measured this frame. `tail` is pinned behind the caster until the + // window has run far enough for its own length to take over. + const crest = this._crestDistance(); + const head = crest + c.sheetSpan * (1 - c.crestSeat); + const tail = Math.max(crest - c.sheetSpan * c.crestSeat, -c.sheetTail); + const span = Math.max(0.2, head - tail); + + _anchor + .copy(this.origin) + .addScaledVector(this.direction, (head + tail) * 0.5) + .setY(c.sheetHeight); + this.surface.setPlacement(_anchor, this.direction, _up); + + _liquid.sizeX = span; + _liquid.sizeZ = Math.max(0.2, c.sheetWidth); + // Solved rather than swept. Once the tail unpins this is exactly + // `crestSeat` and stays there for the rest of the cast — the crest does not + // move in the sheet's own coordinates, the sheet moves under the world. + _liquid.waveFront = saturate((crest - tail) / span); + _liquid.fill = c.sheetFill * lerp(1, 0.05, Easing.inQuad(this._drainAmount())); + _liquid.round = c.sheetRound; + _liquid.edgeSoft = c.sheetEdge; + _liquid.edgeNoise = c.sheetRagged; + _liquid.edgeScale = c.sheetRaggedScale; + _liquid.seed = this._seed; + _liquid.opacity = c.sheetOpacity * fade * g.opacity; + _liquid.contactFade = c.contactFade; + + /* ---------------- the swell, in mirror pairs ---------------- */ + _liquid.waveAmpA = c.swellAmpA; + _liquid.waveAmpB = c.swellAmpB; + _liquid.waveAmpC = c.swellAmpC; + _liquid.waveAmpD = c.swellAmpD; + _liquid.waveLengthA = c.swellLengthA; + _liquid.waveLengthB = c.swellLengthB; + _liquid.waveLengthC = c.swellLengthC; + _liquid.waveLengthD = c.swellLengthD; + _liquid.waveSpeedA = c.swellSpeedA; + _liquid.waveSpeedB = c.swellSpeedB; + _liquid.waveSpeedC = c.swellSpeedC; + _liquid.waveSpeedD = c.swellSpeedD; + _liquid.waveAngleA = c.swellAngleA; + _liquid.waveAngleB = c.swellAngleB; + _liquid.waveAngleC = c.swellAngleC; + _liquid.waveAngleD = c.swellAngleD; + _liquid.steepness = c.steepness; + + _liquid.chop = c.chop * g.noiseStrength; + _liquid.chopScale = c.chopScale * g.noiseFrequency; + _liquid.chopSpeed = c.chopSpeed * g.noiseSpeed; + _liquid.detail = c.detail * g.noiseStrength; + _liquid.detailScale = c.detailScale * g.noiseFrequency; + _liquid.detailSpeed = c.detailSpeed * g.noiseSpeed; + + _liquid.rippleAmp = c.rippleAmp; + _liquid.rippleSpeed = c.rippleSpeed; + _liquid.rippleLength = c.rippleLength; + _liquid.rippleWidth = c.rippleWidth; + _liquid.rippleDecay = c.rippleDecay; + _liquid.rippleSpread = c.rippleSpread; + + /* ---------------- the flow: foam, and nothing else ---------------- */ + _liquid.flowAngle = c.flowAngle; + _liquid.flowSpeed = c.flowSpeed; + _liquid.flowRadial = c.flowRadial; + _liquid.flowRadialFall = c.flowRadialFall; + _liquid.flowEddy = c.flowEddy; + _liquid.flowEddyScale = c.flowEddyScale; + _liquid.flowEddySpeed = c.flowEddySpeed; + _liquid.flowGravity = c.flowGravity; + + _liquid.foam = c.foam; + _liquid.foamScale = c.foamScale * g.noiseFrequency; + _liquid.foamSharp = c.foamSharp; + _liquid.foamCrest = c.foamCrest; + _liquid.foamSpeed = c.foamSpeed; + // The froth's speed gate. These two boxes are the crust's as well, and the + // crust is off — see the header. + _liquid.crustForm = c.foamGateLow; + _liquid.crustBreak = Math.max(c.foamGateHigh, c.foamGateLow + 0.01); + // Neither of these is gated by `crust`, and `meltGlow` defaults to 1.2. + // Leave them alone and the whole sheet picks up a warm emissive haze the + // moment foam asks for a surface speed. Water is not lit from inside. + _liquid.meltGlow = 0; + _liquid.seamGlow = 0; + + /* ---------------- the crest ---------------- */ + const crestHeight = this._crestHeightNow(); + _liquid.crestHeight = crestHeight; + _liquid.crestBack = c.crestBack; + _liquid.crestFace = c.crestFace; + _liquid.crestCurl = this._crestCurlNow(); + _liquid.crestWidth = c.crestWidth; + _liquid.crestFeather = c.crestFeather; + _liquid.crestBreak = this._crestBreakNow(); + _liquid.crestBreakScale = c.crestBreakScale * g.noiseFrequency; + + /* ---------------- shading ---------------- */ + _liquid.poolDepth = c.poolDepth; + _liquid.depthTint = c.depthTint; + _liquid.translucency = c.translucency; + _liquid.ambient = c.ambient; + _liquid.specular = c.specular; + _liquid.shininess = c.shininess; + _liquid.fresnel = c.fresnel * g.fresnel; + _liquid.envIntensity = c.envIntensity; + _liquid.skyIntensity = c.skyIntensity; + _liquid.glow = c.glow * g.glow; + _liquid.normalEps = c.normalEps; + _liquid.colorDeep = c.colorDeep; + _liquid.colorShallow = c.colorShallow; + _liquid.colorFoam = c.colorFoam; + _liquid.colorSpec = c.colorSpec; + _liquid.colorSky = c.colorSky; + + this.surface.visible = _liquid.opacity > 0.002; + this.surface.update(this.age, _liquid); + + /* ---------------- the net ---------------- */ + // Centred on the window, in the window's own frame. The yaw is *not* the + // usual `atan2(direction.x, direction.z)`: it is chosen so the quad's local + // +X lies along the sheet's `axisX`, because the bound height field is + // sampled in the sheet's parametric metres and nothing else lines those two + // up. See the handedness note in the class comment for what the remaining + // sign costs and how it is paid for. + _netCentre.copy(_anchor).setY(0); + _net.yaw = Math.atan2(-this.direction.z, this.direction.x); + _net.height = c.netHeight; + // A disc measured in the sheet's frame, sized off the water's half-width so + // the pool of light stops where the water does. See the settings note. + _net.radius = Math.max(0.2, c.sheetWidth * 0.5 * c.netReach); + _net.fade = fade; + // `depth` is the lever arm of the fold, and the crest is the water the + // light actually crosses — so the net sharpens as the wave stands up. + _net.depth = c.netDepth + c.netDepthCrest * crestHeight; + _net.ior = c.netIor; + _net.dispersion = c.netDispersion; + _net.sampleStep = c.netStep; + _net.absorb = c.netAbsorb; + _net.foldFloor = c.netFoldFloor; + _net.threshold = c.netThreshold; + // The collapse folds the surface harder than anything else in the cast, so + // the floor is allowed to flare with it. + _net.gain = c.netGain * lerp(1, c.netBreakGain, Easing.outQuad(this._breakAmount()) * (1 - this._drainAmount())); + _net.sharpness = c.netSharp; + _net.rolloff = c.netRolloff; + _net.penumbra = c.netPenumbra; + _net.wash = c.netWash; + _net.fringeAt = c.netFringeAt; + _net.emissive = c.netEmissive; + _net.opacity = c.netOpacity; + _net.depthFade = c.netDepthFade; + _net.additive = true; + _net.colorNet = c.colorNet; + _net.colorFringe = c.colorFringe; + _net.colorWash = c.colorWash; + _net.noiseStrength = g.noiseStrength; + _net.noiseFrequency = g.noiseFrequency; + _net.noiseSpeed = g.noiseSpeed; + _net.opacityScale = g.opacity; + this.net.setVisible(fade > 0.002); + this.net.update(_net); + + /* ---------------- the particle systems ---------------- */ + this.spray.setGradient( + getColor(c.colorSprayA), + getColor(c.colorSprayB), + getColor(c.colorSprayC), + getColor(c.colorSprayD) + ); + this.spray.uniforms.uGravity.value.set(0, c.sprayGravity, 0); + this.spray.uniforms.uSizeScale.value = c.spraySize * g.particleSize * 7; + this.spray.uniforms.uLifeScale.value = c.sprayLifetime * 0.5 * g.particleLifetime; + this.spray.uniforms.uSpeedScale.value = g.particleSpeed; + this.spray.uniforms.uOpacity.value = g.opacity; + this.spray.uniforms.uGlow.value = 0.4 * g.glow; + this.spray.uniforms.uTurbulence.value = 0.2 * g.turbulence; + + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = c.mistTurbulence * g.turbulence; + + this.glints.setGradient( + getColor(c.colorGlintA), + getColor(c.colorGlintB), + getColor(c.colorGlintC), + getColor(c.colorGlintD) + ); + this.glints.uniforms.uGravity.value.set(0, c.glintGravity, 0); + this.glints.uniforms.uSizeScale.value = c.glintSize * g.particleSize * 7; + this.glints.uniforms.uLifeScale.value = c.glintLifetime * 0.5 * g.particleLifetime; + this.glints.uniforms.uSpeedScale.value = g.particleSpeed; + this.glints.uniforms.uOpacity.value = g.opacity; + this.glints.uniforms.uGlow.value = c.glintGlow * g.glow; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Droplets, mist and glints off the lip, and the ripple packets the lip + * leaves in the water behind it. + * + * The packets are the one place the cast writes into a box the net is also + * reading: `LiquidSurface#ripple` stores a *fraction, a timestamp and a + * strength*, `Caustics` has bound that array, and the ring that appears on + * the water and the ring that appears on the floor are therefore the same + * eight numbers. Posting them from here — rather than only at the break — is + * what keeps the net alive while the wave is still travelling. + * + * @param {number} dt seconds + * @param {number} scale 0..1 — thinned out once the wave is only running off + */ + _tideFx(dt, scale) { + const c = settings.tiderush; + const g = settings.global; + const time = frame.uTime.value; + + let sprayCount = Math.round(this.sprayEmitter.tick(dt, c.sprayRate * scale) * g.particleCount); + if (sprayCount > 0) { + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.75).setY(0.65).normalize(); + _emit.speed = c.spraySpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.75; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + + // Split along the lip, never all from one point — see SPRAY_BATCHES. + const batches = Math.min(sprayCount, SPRAY_BATCHES); + const per = Math.ceil(sprayCount / batches); + while (sprayCount > 0) { + this.surface.lipPosition(_liquid, _pos, randRange(-1, 1)); + _emit.position = _pos; + _emit.radius = c.spraySize * 2.5 + 0.08; + this.spray.emit(Math.min(per, sprayCount), _emit); + sprayCount -= per; + } + } + + const glintCount = Math.round(this.glintEmitter.tick(dt, c.glintRate * scale) * g.particleCount); + if (glintCount > 0) { + this.surface.lipPosition(_liquid, _pos, randRange(-1, 1)); + _emit.position = _pos; + _emit.radius = c.sheetWidth * 0.12; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.5).setY(0.85).normalize(); + _emit.speed = c.glintSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 0.9; + _emit.size = 0.07; + _emit.sizeVariance = 0.8; + _emit.life = c.glintLifetime; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.time = time; + this.glints.emit(glintCount, _emit); + } + + const mistCount = Math.round(this.mistEmitter.tick(dt, c.mistRate * scale) * g.particleCount); + if (mistCount > 0) { + this.surface.lipPosition(_liquid, _pos, randRange(-0.8, 0.8)); + _emit.position = _pos; + _emit.radius = c.sheetWidth * 0.22; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.time = time; + this.mist.emit(mistCount, _emit); + } + + // The lip smacking the water behind it. `waveFront` is already a 0..1 + // fraction of the sheet, so the packet's `u` is one subtraction away and no + // metre is involved anywhere. + const packets = this.rippleEmitter.tick(dt, c.rippleRate * scale); + for (let i = 0; i < packets; i++) { + const u = _liquid.waveFront * 2 - 1; + this.surface.ripple(u, randRange(-c.rippleSpan, c.rippleSpan), randRange(0.5, 1.1), this.age); + } + if (packets > 0) this._packets = Math.min(8, this._packets + packets); + } + + /** + * The wet strip the wave leaves behind it. + * + * Laid per *metre of front travel* rather than per second, so the trail has + * the same spacing whatever `speed` is set to — a mark rate in Hz gives a + * dotted line at 40 m/s and a solid smear at 4. + */ + _wetFx() { + const c = settings.tiderush; + const step = 1 / Math.max(0.05, c.wetRate); + + while (this.front - this._wetDistance >= step) { + this._wetDistance += step; + const s = saturate(this._wetDistance / this.length); + this.pointAt(s, _pos); + // Jittered across the line so the marks do not read as a row of coins. + const wander = c.sheetWidth * 0.2; + _pos.x += this.side.x * randRange(-wander, wander); + _pos.z += this.side.z * randRange(-wander, wander); + + this.ctx.decals.spawn(DecalType.FOAM, _pos, { + radius: c.wetRadius * randRange(0.75, 1.25), + life: c.wetLife, + intensity: c.wetIntensity, + colorA: getColor(c.colorWetA), + colorB: getColor(c.colorWetB), + height: 0.013 + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + + // The light rides the lip, not the floor under it. + this.surface.lipPosition(_liquid, this.position, 0); + + this._tideFx(dt, 1); + this._wetFx(); + + this.ctx.shake.rumble(settings.tiderush.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.tiderush; + const g = settings.global; + const time = frame.uTime.value; + + this.surface.lipPosition(_liquid, _lip, 0); + this.pointAt(1, _pos); + + /* + * The sheet of water thrown up where it dumps. `WATER` rather than `FIRE`: + * that mode is a fresnel-heavy splash dome with almost no body, which is + * the only one of the six that does not read as a fireball someone has + * painted blue. + */ + this.ctx.bursts.spawn(BurstMode.WATER, _lip, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.5, + displace: 0.7, + squash: 0.55, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring that runs out across the floor */ + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.55, + width: 0.06, + intensity: 0.9, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* the tide line where it dumped */ + this.ctx.decals.spawn(DecalType.FOAM, _pos, { + radius: c.wetRadius * 2.1, + life: c.wetLife * 1.5, + intensity: c.wetIntensity * 1.2, + colorA: getColor(c.colorWetA), + colorB: getColor(c.colorWetB), + height: 0.014 + }); + + /* + * Ring the sheet hard. The packets are fractions plus a timestamp, never a + * metre, and the net has bound the same array — so the rings that spread + * across the water and the rings that spread across the floor are one set + * of eight numbers arriving in both shaders at once. This is the frame the + * trick is most obvious on. + */ + const packets = Math.max(0, Math.round(c.burstRipples)); + const frontU = _liquid.waveFront * 2 - 1; + for (let i = 0; i < packets; i++) { + this.surface.ripple(frontU, randRange(-c.rippleSpan, c.rippleSpan), randRange(0.7, 1.3), this.age); + } + this._packets = Math.min(8, this._packets + packets); + + /* droplets and glints thrown out of the collapse */ + _emit.position = _lip; + _emit.radius = c.sheetWidth * 0.28; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.6).setY(0.8).normalize(); + _emit.speed = c.spraySpeed * 2.1; + _emit.speedVariance = 0.85; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.8; + _emit.life = c.sprayLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spray.emit(Math.round(c.burstSpray * g.particleCount), _emit); + + _emit.speed = c.glintSpeed * 2.4; + _emit.size = 0.08; + _emit.life = c.glintLifetime * 1.3; + this.glints.emit(Math.round(c.burstGlints * g.particleCount), _emit); + + _emit.position = _pos; + _emit.radius = c.sheetWidth * 0.32; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.mistSpeed * 2.4; + _emit.spread = 1.0; + _emit.size = 1.1; + _emit.life = c.mistLifetime * 1.2; + _emit.spin = 0.4; + this.mist.emit(Math.round(36 * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 18 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 while the crest breaks, then 1..2 while the sheet runs off. + // Quadratic-in, so the water hangs and then goes rather than dissolving at + // a constant rate — liquid leaves a floor all at once. + const fade = t <= 1 ? 1 : 1 - Easing.inQuad(saturate(t - 1)); + this._sync(fade); + + this.surface.lipPosition(_liquid, this.position, 0); + + // The break is the loudest beat; the run-off is nearly silent. + this._tideFx(dt, t <= 1 ? 1.3 - 0.4 * t : fade * 0.2); + } + + onDestroy() { + this._packets = 0; + this.surface.reset(); + this.net.clearRipples(); + this.net.setVisible(false); + } + + dispose() { + this.surface.dispose(); + this.net.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/tide/TorrentAbility.js b/src/abilities/tide/TorrentAbility.js new file mode 100644 index 0000000..8945dff --- /dev/null +++ b/src/abilities/tide/TorrentAbility.js @@ -0,0 +1,800 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { createDeflectionSheetMaterial } from '../../materials/DeflectionSheetMaterial.js'; +import { createBeamTubeGeometry } from '../../assets/ProceduralGeometry.js'; +import { Tube, TubePath } from '../../vfx/Tube.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; +const HALF_PI = Math.PI * 0.5; + +/** + * Tessellation of the sheet. `SHEET_RINGS` is out from the contact point and + * `SHEET_BEARINGS` is around it — and the second one is the number that + * matters, because the outline is a function of azimuth and 32 bearings puts a + * visible polygon on the rim of a wide fan. 72 is where it stops showing. + */ +const SHEET_RINGS = 22; +const SHEET_BEARINGS = 72; + +/** + * How many bearings one frame's spray is split between. + * + * The first version drew a single bearing per frame for the whole batch, and a + * fan emitted that way is a hose sweeping back and forth rather than a sheet + * shedding along its whole edge. Five is enough that the rim reads as + * continuous at any sane rate. + */ +const SPRAY_BATCHES = 5; + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _mouth = new Vector3(); +const _contact = new Vector3(); +const _normal = new Vector3(); +const _axis = new Vector3(); +const _binormal = new Vector3(); +const _reflect = new Vector3(); +const _jet = new Vector3(); +const _up = new Vector3(0, 1, 0); +const _ground = groundFieldParams(); + +/** + * TORRENT — a cutting jet, and the sheet it throws when it lands. + * + * ## THE TRICK — deflection + * + * The spray knows which way the surface is facing. + * + * A jet arriving at a plane does not splash radially. It splits, and the split + * is decided by one vector identity: reflect the jet's direction about the + * surface normal, `r = d − 2(d·n)n`, and notice that the reflection leaves the + * component **in** the surface untouched and reverses the component **along the + * normal**. So + * + * - the in-plane part of `r` is the axis the sheet runs down, and + * - the reversed normal part is the only thing that can lift a crown. + * + * The flux is then spread around the azimuth by the Poisson kernel — the + * unique distribution on a circle that conserves mass and in-plane momentum and + * contains nothing else — and `materials/DeflectionSheetMaterial.js` derives it + * properly. The sheet draws that density; the droplets are drawn from it by its + * exact inverse CDF, because the Poisson kernel *is* the wrapped Cauchy and a + * wrapped Cauchy can be sampled in one line with no rejection loop. One uniform + * random number in, one correctly-distributed bearing out. + * + * That the two agree is not a coincidence to be maintained by hand — they are + * the same three lines of algebra read from opposite ends, which is why + * dragging `surfaceTilt` swings the sheet, the crown and every droplet + * together. + * + * ## Why the contact point walks + * + * The cast is a LINE, so the jet's contact point travels down it. That is not + * decoration: as the contact walks away from the caster the incidence angle + * goes from steep to grazing, so **you watch the fan narrow and lie down** over + * the length of one cast. It is the clearest demonstration of the trick the + * ability can give without anybody touching a slider, and it is free — the only + * thing the CPU does is aim the tube at `pointAt(u)` instead of `pointAt(1)`. + * + * ## What is drawing + * + * `vfx/Tube.js` on the `STRAIGHT` path is the column (3 draw calls); one + * instanced-free `(s, φ)` grid carries the sheet (1); `vfx/GroundField.js` in + * `WET` mode is the soak (1). Five for the cast. + * + * ## What a cast captures + * + * One seed and the timestamps. Not the normal, not the reflection, not the + * concentration, not a metre of reach — the whole surface frame is rebuilt from + * `settings.torrent` every frame, on a zero-length frame included, which is the + * only reason a paused `surfaceRoll` drag does anything at all. + */ +export class TorrentAbility extends Ability { + constructor(context) { + super('torrent', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the column --- */ + this.jet = new Tube({ path: TubePath.STRAIGHT, prefix: 'jet', nodes: 72, sides: 22 }); + this.group.add(this.jet.group); + + /* --- the sheet --- */ + // The beam tube's (t, a) grid, reinterpreted: t is distance out from the + // contact point as a fraction of that bearing's own reach, a is the bearing. + // Reusing it rather than building a fan on the CPU is not tidiness — the + // outline moves every frame as the fan swings, and a CPU fan would be a + // buffer upload per frame and a captured metre besides. + this.sheetGeometry = createBeamTubeGeometry(SHEET_RINGS, SHEET_BEARINGS); + this.sheetMaterial = createDeflectionSheetMaterial(); + this.sheetMesh = new Mesh(this.sheetGeometry, this.sheetMaterial); + this.sheetMesh.frustumCulled = false; + this.sheetMesh.matrixAutoUpdate = false; + this.sheetMesh.layers.set(LAYER.VFX); + this.sheetMesh.renderOrder = 9; + this.group.add(this.sheetMesh); + + /* --- the stone it is soaking --- */ + this.wet = new GroundField(this.group, { + mode: GroundMode.WET, + depthTest: true, + name: 'torrent.wet' + }); + + /** Re-rolled per cast. */ + this._seed = 0; + /** Metres of contact-point travel already paid out in foam marks. */ + this._markDistance = 0; + /** Seconds since the jet first touched down. Re-derived, never integrated. */ + this._contactAge = 0; + /** The in-plane momentum fraction this frame, 0..0.985. Readout for the fx. */ + this._k = 0; + /** The reversed normal momentum this frame, 0..1. */ + this._crown = 0; + + this._tubeState = { + origin: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 1, + fade: 1, + widthFade: 1, + seed: 0, + time: 0 + }; + this._sheetState = { + contact: new Vector3(), + normal: new Vector3(0, 1, 0), + axis: new Vector3(0, 0, 1), + binormal: new Vector3(1, 0, 0), + k: 0, + crown: 0, + open: 0, + age: 0, + seed: 0, + fade: 1 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Spray: velocity-stretched streaks. Lit rather than additive — water is + // not a light source, and the moment the spray glows the jet reads as a + // plasma cutter, which is a different and much more common effect. + this.spray = particles.get('torrent.spray', { + capacity: 4000, + shape: ParticleShape.STREAK, + additive: false, + lit: true, + stretch: true, + softFade: 0.2 + }); + this.spray.uniforms.uDrag.value = 1.9; + this.spray.uniforms.uEndSize.value = 0.4; + this.spray.uniforms.uSizeIn.value = 0.02; + this.spray.uniforms.uFadeIn.value = 0.03; + this.spray.uniforms.uFadeOut.value = 0.45; + + // The heavy drops that survive the rim and arc away. + this.drops = particles.get('torrent.drops', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: false, + lit: true, + softFade: 0.2 + }); + this.drops.uniforms.uDrag.value = 0.6; + this.drops.uniforms.uEndSize.value = 0.7; + this.drops.uniforms.uSizeIn.value = 0.05; + this.drops.uniforms.uFadeOut.value = 0.55; + + // The atomised haze standing over the contact point. + this.mist = particles.get('torrent.mist', { + capacity: 1800, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 0.9 + }); + this.mist.uniforms.uDrag.value = 2.6; + this.mist.uniforms.uEndSize.value = 2.4; + this.mist.uniforms.uSizeIn.value = 0.1; + this.mist.uniforms.uFadeIn.value = 0.14; + this.mist.uniforms.uFadeOut.value = 0.35; + + this.sprayEmitter = new RateEmitter(); + this.dropEmitter = new RateEmitter(); + this.mistEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get impactDuration() { + return Math.max(0.05, settings.torrent.holdTime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.torrent.fadeTime); + } + + get instanceCount() { + // Three tube layers plus the sheet, when the sheet is up. + return this.jet.drawCalls + (this._contactAge > 0 ? 1 : 0); + } + + /** + * A pressure hum rather than a flicker: a pump does not gutter. Two + * incommensurate rates so it never settles into a visible beat. + */ + lightShimmer() { + const c = settings.torrent; + return 0.88 + 0.12 * Math.sin(this.age * c.jetThrobSpeed * TAU) * Math.sin(this.age * 3.1); + } + + /* ------------------------------------------------------------------ */ + /* The surface frame — rebuilt every frame, captured never */ + /* ------------------------------------------------------------------ */ + + /** Where the jet leaves the caster, world space. */ + _mouthPoint(out) { + const c = settings.torrent; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** Where the column is currently landing, on the floor. */ + _contactPoint(out) { + const s = this.phase === AbilityPhase.TRAVEL ? Math.max(0.04, this.u) : 1; + this.pointAt(s, out); + out.y = settings.torrent.surfaceHeight; + return out; + } + + /** + * The impact surface's unit normal. + * + * Tilted off vertical by `surfaceTilt` in the bearing `surfaceRoll`, measured + * in the cast's own flat frame so the two sliders mean the same thing however + * the player is facing. Default is dead vertical, i.e. the floor — the whole + * point of the sliders is to be able to *move* it and watch everything else + * answer. + */ + _surfaceNormal(out) { + const c = settings.torrent; + const tilt = c.surfaceTilt; + const roll = c.surfaceRoll; + const lean = Math.sin(tilt); + out + .copy(_up) + .multiplyScalar(Math.cos(tilt)) + .addScaledVector(this.direction, lean * Math.cos(roll)) + .addScaledVector(this.side, lean * Math.sin(roll)); + return out.normalize(); + } + + /** + * Build the whole deflection frame into the module scratch. + * + * Writes `_contact`, `_normal`, `_axis`, `_binormal`, `_k` and `_crown`, and + * this is the one place in the ability where the trick actually happens: + * + * ``` + * r = d − 2(d·n)n + * axis = normalise(r − (r·n)n) // in-plane: the fan's heading + * crown = |r·n| // reversed normal: the bell's height + * k = |d − (d·n)n| · concentration + * ``` + * + * Two degenerate cases have to be handled or the fan NaNs the frame it is + * pointed straight down. If the jet is normal to the surface the in-plane + * component is zero and the axis is undefined — it falls back to the cast's + * own heading, and `k = 0` makes the choice irrelevant because the + * distribution is uniform there anyway, which is correct: a jet straight down + * really does splash in a ring. + */ + _frame() { + const c = settings.torrent; + + this._mouthPoint(_mouth); + this._contactPoint(_contact); + this._surfaceNormal(_normal); + + _jet.copy(_contact).sub(_mouth); + if (_jet.lengthSq() < 1e-8) _jet.copy(this.direction); + _jet.normalize(); + + const along = _jet.dot(_normal); + // The reflection. Everything below is one of its two halves. + _reflect.copy(_jet).addScaledVector(_normal, -2 * along); + + // In-plane part of the reflection. (It is also the in-plane part of the + // incoming jet — the reflection does not touch it — which is exactly the + // statement that the sheet carries on the way the jet was going.) + _axis.copy(_reflect).addScaledVector(_normal, -_reflect.dot(_normal)); + const inPlane = _axis.length(); + if (inPlane < 1e-4) { + // Straight down the normal: no preferred bearing exists. Any in-plane + // vector will do, and k is about to be zero anyway. + _axis.copy(this.direction).addScaledVector(_normal, -this.direction.dot(_normal)); + if (_axis.lengthSq() < 1e-8) _axis.copy(this.side); + } + _axis.normalize(); + _binormal.crossVectors(_normal, _axis).normalize(); + + this._k = Math.min(0.985, Math.max(0, inPlane * saturate(c.fanConcentration))); + this._crown = Math.abs(along); + } + + /** + * A bearing drawn from the fan, in radians from the axis. + * + * The exact inverse CDF of the wrapped Cauchy — which is the same function + * the sheet's vertex shader is drawing as a density. One uniform in, one + * correctly-distributed bearing out, no rejection loop, no table, and no + * possibility of the particles and the mesh disagreeing about where the water + * went. + * + * @param {number} u uniform 0..1 + */ + _fanBearing(u) { + const k = this._k; + if (k < 1e-4) return (u - 0.5) * TAU; // uniform: a ring, and correctly so + return 2 * Math.atan(((1 - k) / (1 + k)) * Math.tan(Math.PI * (u - 0.5))); + } + + /** + * The mean reach of the fan, metres. The sheet's own outline is this times + * the density raised to `fanPower`; this is the number everything else is + * placed against so nothing can drift from the mesh. + */ + _fanReach() { + return Math.max(0.05, settings.torrent.fanReach); + } + + /** Reach along one bearing, metres — the same expression the shader uses. */ + _reachAt(phi) { + const c = settings.torrent; + const k = this._k; + const q = (1 - k * k) / Math.max(1 + k * k - 2 * k * Math.cos(phi), 1e-4); + return this._fanReach() * Math.pow(Math.max(q, 1e-4), c.fanPower); + } + + /** + * A launch direction for one droplet: in the surface plane at `phi`, plus + * whatever fraction of the reversed normal momentum `sprayLift` lets it keep. + * + * `sprayLift` at 0 puts the whole spray on the deck, which is the wall-jet + * answer; at 1 it is a specular reflection, which is what a jet does off + * glass. Neither extreme is right for stone and the slider is between them. + */ + _fanDirection(phi, out) { + const c = settings.torrent; + out + .copy(_axis) + .multiplyScalar(Math.cos(phi)) + .addScaledVector(_binormal, Math.sin(phi)) + .addScaledVector(_normal, this._crown * saturate(c.sprayLift)); + return out.normalize(); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sprayEmitter.reset(); + this.dropEmitter.reset(); + this.mistEmitter.reset(); + this._markDistance = 0; + this._contactAge = 0; + + // The one thing a cast captures, and it is unitless. + this._seed = Math.random() * 100; + + this.jet.visible = true; + this.wet.setVisible(true); + this._sync(1, 1); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current surface frame into all three + * renderers. + * + * @param {number} fade 0..1 master alpha + * @param {number} widthFade 0..1 the column's collapse to a thread + */ + _sync(fade, widthFade) { + const c = settings.torrent; + const g = settings.global; + + this._frame(); + + /* --- the column --- */ + const state = this._tubeState; + state.origin.copy(_mouth); + state.target.copy(_contact); + state.side.copy(this.side); + // A jet is not a bolt: the whole column exists from the first frame, + // because the water is already leaving the nozzle. What travels is where it + // *lands*, and that is carried by `target`. + state.progress = 1; + state.fade = fade; + state.widthFade = widthFade; + state.seed = this._seed; + state.time = this.age; + this.jet.sync(c, state, g); + + /* --- the sheet --- */ + const sheet = this._sheetState; + sheet.contact.copy(_contact); + sheet.normal.copy(_normal); + sheet.axis.copy(_axis); + sheet.binormal.copy(_binormal); + sheet.k = this._k; + sheet.crown = this._crown; + sheet.open = saturate(this._contactAge / Math.max(0.01, c.openTime)) * widthFade; + sheet.age = this.age; + sheet.seed = this._seed; + sheet.fade = fade; + this.sheetMaterial.userData.sync(sheet); + + /* --- the wet stone --- */ + // Centred downstream along the fan axis rather than on the contact point: + // water soaks into stone the same way in every direction, so the *shape* + // stays a disc, but where the disc sits is not arbitrary — it sits where + // the water actually went. + const reach = this._fanReach(); + _pos.copy(_contact).addScaledVector(_axis, reach * c.wetBias); + _pos.y = 0; + _ground.centre = _pos; + _ground.yaw = Math.atan2(_axis.x, _axis.z); + _ground.height = c.wetHeight; + _ground.radius = Math.max(0.05, reach * c.wetRadius); + _ground.grow = saturate(this._contactAge / Math.max(0.02, c.wetGrow)); + _ground.recede = this.phase === AbilityPhase.FADE + ? saturate(this.fadeTime / this.fadeDuration) * saturate(c.wetDry) + : 0; + _ground.fade = fade; + _ground.seed = this._seed; + _ground.edge = c.wetEdge; + _ground.ragged = c.wetRagged; + _ground.raggedScale = c.wetRaggedScale * g.noiseFrequency; + _ground.warp = c.wetWarp * g.noiseStrength; + _ground.relief = c.wetRelief; + _ground.normalStep = c.wetNormalStep; + _ground.ambient = c.wetAmbient; + _ground.wrap = c.wetWrap; + _ground.specular = c.wetSpecular; + _ground.gloss = c.wetGloss; + _ground.cell = c.wetCell * g.noiseFrequency; + _ground.depth = c.wetDepth; + _ground.lift = c.wetLift; + _ground.detail = c.wetDetail; + _ground.flow = c.wetFlow; + _ground.speed = c.wetSpeed * g.noiseSpeed; + _ground.windAngle = 0; + _ground.emissive = c.wetEmissive; + _ground.opacity = c.wetOpacity; + _ground.opacityScale = g.opacity; + _ground.noiseStrength = g.noiseStrength; + _ground.noiseFrequency = g.noiseFrequency; + _ground.noiseSpeed = g.noiseSpeed; + _ground.colorBase = c.colorWetBase; + _ground.colorEdge = c.colorWetEdge; + _ground.colorGlow = c.colorWetGlow; + _ground.colorDeep = c.colorWetDeep; + this.wet.update(_ground); + + /* --- the three particle systems --- */ + this.spray.setGradient( + getColor(c.colorSprayA), + getColor(c.colorSprayB), + getColor(c.colorSprayC), + getColor(c.colorSprayD) + ); + this.spray.uniforms.uGravity.value.set(0, c.sprayGravity, 0); + this.spray.uniforms.uSizeScale.value = c.spraySize * g.particleSize * 7; + this.spray.uniforms.uLifeScale.value = c.sprayLifetime * 0.5 * g.particleLifetime; + this.spray.uniforms.uSpeedScale.value = g.particleSpeed; + this.spray.uniforms.uOpacity.value = g.opacity; + this.spray.uniforms.uStretch.value = c.sprayStretch; + this.spray.uniforms.uTurbulence.value = 0.2 * g.turbulence; + + this.drops.setGradient( + getColor(c.colorDropA), + getColor(c.colorDropB), + getColor(c.colorDropC), + getColor(c.colorDropD) + ); + this.drops.uniforms.uGravity.value.set(0, c.dropGravity, 0); + this.drops.uniforms.uSizeScale.value = c.dropSize * g.particleSize * 7; + this.drops.uniforms.uLifeScale.value = c.dropLifetime * 0.5 * g.particleLifetime; + this.drops.uniforms.uSpeedScale.value = g.particleSpeed; + this.drops.uniforms.uOpacity.value = g.opacity; + + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = 0.35 * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* What comes off the sheet */ + /* ------------------------------------------------------------------ */ + + /** + * Spray, drops and mist, all emitted **into the fan**. + * + * Every bearing here comes out of `_fanBearing()`, which is the sheet's own + * density inverted, so the particle field and the mesh are the same + * distribution. Placing them on the rim rather than at the contact point + * matters as much: a sheet sheds from its unstable edge, and spraying from + * the middle puts the droplets *inside* the water they are supposed to be + * leaving. + * + * @param {number} scale 0..1 — thinned as the pressure drops + */ + _fanFx(dt, scale) { + const c = settings.torrent; + const g = settings.global; + const time = frame.uTime.value; + + let sprayCount = Math.round(this.sprayEmitter.tick(dt, c.sprayRate * scale) * g.particleCount); + if (sprayCount > 0) { + _emit.speed = c.spraySpeed; + _emit.speedVariance = c.spraySpeedVariance; + _emit.spread = 0.18; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.65; + _emit.life = c.sprayLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + + const batches = Math.min(sprayCount, SPRAY_BATCHES); + const per = Math.ceil(sprayCount / batches); + while (sprayCount > 0) { + const phi = this._fanBearing(Math.random()) + randRange(-c.sprayJitter, c.sprayJitter); + // Somewhere along that bearing, biased outward — the sheet is thinnest + // and least stable near the rim, which is where it comes apart. + const along = Math.sqrt(Math.random()); + this._fanDirection(phi, _dir); + _pos + .copy(_contact) + .addScaledVector(_axis, Math.cos(phi) * this._reachAt(phi) * along) + .addScaledVector(_binormal, Math.sin(phi) * this._reachAt(phi) * along); + _emit.position = _pos; + _emit.radius = 0.06; + _emit.direction = _dir; + this.spray.emit(Math.min(per, sprayCount), _emit); + sprayCount -= per; + } + } + + const dropCount = Math.round(this.dropEmitter.tick(dt, c.dropRate * scale) * g.particleCount); + if (dropCount > 0) { + const phi = this._fanBearing(Math.random()); + const reach = this._reachAt(phi); + this._fanDirection(phi, _dir); + _pos + .copy(_contact) + .addScaledVector(_axis, Math.cos(phi) * reach) + .addScaledVector(_binormal, Math.sin(phi) * reach); + _emit.position = _pos; + _emit.radius = 0.12; + _emit.direction = _dir; + _emit.speed = c.dropSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.3; + _emit.size = 0.16; + _emit.sizeVariance = 0.7; + _emit.life = c.dropLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.drops.emit(dropCount, _emit); + } + + const mistCount = Math.round(this.mistEmitter.tick(dt, c.mistRate * scale) * g.particleCount); + if (mistCount > 0) { + // The haze does not care which way it was thrown — it is what is left + // after the droplets have lost their momentum, so it stands over the + // contact point and rises. + _emit.position = _pos.copy(_contact).addScaledVector(_axis, this._fanReach() * 0.35); + _emit.radius = this._fanReach() * 0.5; + _emit.direction = _dir.copy(_normal); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.9; + _emit.size = 0.6; + _emit.sizeVariance = 0.55; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0.25; + _emit.time = time; + this.mist.emit(mistCount, _emit); + } + } + + /** + * Foam left on the stone, paid out per metre of contact travel rather than + * per second, so the trail stays evenly spaced whether the jet is walking out + * in a fifth of a second or crawling. + */ + _markFx() { + const c = settings.torrent; + const step = 1 / Math.max(0.05, c.foamRate); + let guard = 24; + + while (this.front - this._markDistance >= step && guard-- > 0) { + this._markDistance += step; + const s = saturate(this._markDistance / this.length); + this.pointAt(s, _pos); + // Offset downstream along the fan, and jittered across it, so the trail + // is the wash rather than a dotted centre line. + _pos.addScaledVector(_axis, this._fanReach() * randRange(0.1, 0.6)); + _pos.addScaledVector(_binormal, randRange(-0.5, 0.5) * this._fanReach() * 0.35); + + this.ctx.decals.spawn(DecalType.FOAM, _pos, { + radius: c.foamRadius * randRange(0.7, 1.35), + life: c.foamLife, + intensity: c.foamIntensity, + colorA: getColor(c.colorFoamA), + colorB: getColor(c.colorFoamB) + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.torrent; + + // The contact age is the age, because the jet is touching down from the + // first frame — it is where it touches that travels. + this._contactAge = this.age; + this._sync(1, 1); + + // The dynamic light rides the contact point, not the front on the floor: + // the fan is what is lit. + this.position.copy(_contact); + + this._fanFx(dt, 1); + this._markFx(); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.torrent; + const g = settings.global; + const time = frame.uTime.value; + + this._frame(); + + this.ctx.bursts.spawn(BurstMode.WATER, _contact, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.5, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 2.2, + displace: 0.45, + squash: 0.6, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + // A hard slug of spray, still into the fan and not around it. + _emit.speed = c.spraySpeed * 1.6; + _emit.speedVariance = 0.8; + _emit.spread = 0.22; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.13; + _emit.sizeVariance = 0.7; + _emit.life = c.sprayLifetime * 1.3; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + for (let i = 0; i < SPRAY_BATCHES; i++) { + const phi = this._fanBearing(Math.random()); + const reach = this._reachAt(phi) * 0.6; + this._fanDirection(phi, _dir); + _pos + .copy(_contact) + .addScaledVector(_axis, Math.cos(phi) * reach) + .addScaledVector(_binormal, Math.sin(phi) * reach); + _emit.position = _pos; + _emit.radius = 0.08; + _emit.direction = _dir; + this.spray.emit(Math.round((c.sprayRate * 0.12) * g.particleCount), _emit); + } + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity, + 1 / Math.max(0.05, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.9 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.torrent; + + this._contactAge = this.age; + + // `t` runs 0..1 while the jet holds at full pressure, then 1..2 as the pump + // dies. The column collapses to a thread before it dims — a jet that fades + // evenly reads as a light being turned down, and a jet that necks first + // reads as pressure being lost, which is the thing that is happening. + const dying = saturate(t - 1); + const width = 1 - dying * dying; + const fade = 1 - dying * dying * dying; + + this._sync(fade, width); + this.position.copy(_contact); + + this._fanFx(dt, fade * width); + this.ctx.shake.rumble(c.rumble * fade * settings.global.cameraShake, dt); + } + + onDestroy() { + this.jet.visible = false; + this.wet.setVisible(false); + this._contactAge = 0; + this._markDistance = 0; + this._k = 0; + this._crown = 0; + this.sheetMaterial.uniforms.uOpen.value = 0; + } + + dispose() { + this.jet.dispose(); + this.wet.dispose(); + this.sheetGeometry.dispose(); + this.sheetMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/tide/UndertowAbility.js b/src/abilities/tide/UndertowAbility.js new file mode 100644 index 0000000..460adba --- /dev/null +++ b/src/abilities/tide/UndertowAbility.js @@ -0,0 +1,1030 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { LiquidSurface, LiquidMode, liquidParams } from '../../vfx/LiquidSurface.js'; +import { Caustics, CausticSource, CausticShape, causticsParams } from '../../vfx/Caustics.js'; +import { Swarm, Silhouette, LeadPath, swarmParams } from '../../vfx/Swarm.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on the debris raft. The `debrisCount` slider clamps here. */ +const MAX_DEBRIS = 320; + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope (I3). Filled and consumed inside one call. */ +/* ---------------------------------------------------------------- */ + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _vel = new Vector3(); +const _lead = new Vector3(); +const _centre = new Vector3(); +const _anchor = new Vector3(); +const _netCentre = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/** + * The flow field's two components at one radius, metres per second. + * + * One object, refilled — `flowAt()` writes into whatever it is handed, and the + * only caller that needs two answers at once takes the second into a local + * copy of the numbers rather than a second object. + */ +const _flow = { radial: 0, tangential: 0, speed: 0 }; + +/** The three params blocks the shared modules read. Refilled every frame. */ +const _liquid = liquidParams(); +const _net = causticsParams(); +const _drops = swarmParams(); +_net.centre = _netCentre; + +/** + * UNDERTOW — a whirlpool, and one flow field driving everything in it. + * + * Three beats: the circle **opens** as the cast lands, the vortex **pulls** for + * a long couple of seconds while everything loose winds inward and goes under, + * and then it **shuts**. + * + * ## THE TRICK — one function, four consumers + * + * `flowAt(radius, out)` is the whole ability. It is an ideal **vortex-sink**: + * + * ``` + * v_θ(r) = swirl / r circulation — how hard it turns + * v_r(r) = −drain / r the sink — how hard it pulls in + * ``` + * + * and it is the only place in this file that reads either number. The surface's + * drift, the caustic net, the debris raft and the spume all ask *it* where they + * are going. Drag `swirl` with the game paused and the water's drift bearing, + * the arm of debris and the flecks on the froth all re-aim together, because + * there is only one thing to re-aim. This is the same contract `Tube.radiusAt` + * has and it is kept for the same reason: the moment two consumers carry their + * own copy of a shape, they start disagreeing about it in the third decimal + * place and the picture stops holding together. + * + * **It is a real logarithmic spiral, and that is a consequence rather than a + * choice.** A streamline satisfies `dr/dθ = v_r·r/v_θ = −(drain/swirl)·r`, so + * `r(θ) = r₀·e^{−bθ}` with `b = drain/swirl`: constant pitch angle, which is + * the definition. Two more things fall out in closed form, and having them in + * closed form is what keeps invariant **I1**: + * + * ``` + * r(t) = √(r₀² − 2·drain·t) integrate dr/dt = −drain/r + * θ(t) = (swirl/drain)·ln(r₀ / r(t)) which is the spiral law again + * ``` + * + * Nothing about the raft is integrated frame by frame. `radiusAt(t)` and + * `angleAt(t)` are functions of the phase clock and the live sliders, so a + * paused vortex re-solves its whole streamline when you move `drain` instead of + * carrying on from wherever an accumulator had got to. + * + * **The spin-up is a clock, not a fudge.** Scaling the field by a ramp `k(t)` + * would break those closed forms. Instead the ramp is *integrated* — + * `flowTime()` returns `∫k dt`, itself closed form for a linear ramp — and the + * same exact laws are evaluated against it. The field is never anything but + * the field; only the clock it is read at is bent. + * + * ## Debris goes under, and is seen through the water + * + * The brief for this cast is explicit that the debris must be *pulled under* + * rather than deleted, and that is a render-order problem before it is an art + * problem. Two things make it work and both were wrong in the first version: + * + * - **The pool stands `poolHeight` metres proud of the floor.** A sheet of + * water five centimetres off the flagstones has five centimetres of "under", + * and debris sinking into it just clips the ground. The pool is a body of + * water with a feathered waterline, the caustics are on the floor most of a + * metre below it, and there is somewhere for things to go. + * - **The raft draws *before* the surface.** `Swarm`'s renderOrder is put + * below `LiquidSurface`'s, so the water blends over the debris and tints it + * with its own depth colour. Drawn after — which is the module default, and + * which is what happened first — the chips paint on top of the water and + * read as flotsam sitting on it, or, with the surface writing depth, vanish + * entirely. That is the "deleted" failure the brief names. + * + * The raft itself is `Swarm` in `ORBIT`, fed the streamline: the lead's radius, + * angle and depth are `radiusAt` / `angleAt` / a lerp between `debrisSinkRim` + * and `debrisSinkCore`. One honest approximation is worth stating — `Swarm` + * rewinds its trailing ranks at a *constant* rate, so the arm behind the lead + * is a first-order estimate of where the streamline was, and its radius is the + * lead's rather than the larger radius the tail really had. What it costs is a + * raft that is an arc at one radius instead of an arm sweeping across several; + * what it buys is that the whole raft is one draw call and nothing about it + * exists on the CPU. The over-winding near the eye, where `ω = swirl/r²` is + * large, is an error in the direction of the truth. + * + * ## The net + * + * `Caustics(WAVE)` bound to the pool with `bindSource()`, exactly as `tiderush` + * does — the swell, the chop and every ripple packet are one set of uniform + * boxes with one author, so the light on the floor is the image of the water + * above it. The pool's `poolHeight` *is* the depth the light crosses, which is + * the one place in this block where sharing a number between two consumers is + * the design rather than a shortcut. + * + * The same handedness note as `tiderush` applies and is written out there: the + * ground quad's frame is right-handed, `LiquidSurface`'s is not, so the net is + * the water's mirror across the pool's own +X axis. The swell is authored in + * mirror pairs so that reflection maps the wave set onto itself. **The one + * thing that must never go into the height field here is a chiral term** — a + * spiral ridge in the water would come out on the floor winding the other way + * from the debris, and that is the single error in this cast a viewer would + * actually catch. The spiral lives in the flow field, the debris and the + * particles, all of which are in world space and none of which is mirrored. + * + * ## What a cast captures + * + * `_seed`, so two vortices do not lay their debris out identically, and + * timestamps. Nothing else. Pause with **P** mid-pull and drag `drain`: the + * raft jumps to where that streamline actually puts it, the froth ring around + * the eye widens, and the net on the floor re-folds — three consumers, one + * number. + */ +export class UndertowAbility extends Ability { + constructor(context) { + super('undertow', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* + * The raft is added FIRST and given the lowest render order of the three, + * so the painter's order is floor-light, then debris, then water. See the + * class comment: this is the whole of "under the surface, not deleted". + */ + this.debris = new Swarm(this.group, { + capacity: MAX_DEBRIS, + silhouette: Silhouette.DROPLET, + additive: false, // a wet chip is a dark body, not a spark + renderOrder: 7 + }); + + /* + * The net, on the floor under everything. Bound to the pool below, so it is + * built before the surface only because it must be added to the group + * before it — the binding happens after both exist. + */ + this.net = new Caustics(this.group, { + source: CausticSource.WAVE, + shape: CausticShape.DISC, + additive: true, + depthTest: true, + renderOrder: 6, + name: 'Undertow:net' + }); + + /* + * The pool. 96 segments a side: below about 48 the froth ring around the + * eye facets, above 160 the fragment normal is already carrying the detail + * for nothing. `depthWrite` stays on — the water is in front of the debris + * from every camera angle the game allows, so it is free to occlude what is + * behind it, and turning it off costs the surface its own self-sorting. + */ + this.surface = new LiquidSurface({ + segments: 96, + mode: LiquidMode.POOL, + depthWrite: true, + doubleSide: true, + renderOrder: 9, + name: 'Undertow:pool' + }); + this.group.add(this.surface.object3D); + + // One height field, two consumers. + this.net.bindSource(this.surface.uniforms); + + /** Re-rolled per cast so no two vortices lay their debris out the same. */ + this._seed = 0; + /** Agents drawn last frame — the HUD readout. */ + this._live = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Spume: froth flecks torn off the surface. They are emitted *with the flow + // velocity at the radius they were born at*, which is the cheapest of the + // four consumers of the field and the one that sells the rotation, because + // a fleck that is thrown along the streamline reads as being carried. + this.spume = particles.get('undertow.spume', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: false, + lit: true, + softFade: 0.3 + }); + this.spume.uniforms.uDrag.value = 1.1; + this.spume.uniforms.uEndSize.value = 0.55; + this.spume.uniforms.uSizeIn.value = 0.03; + this.spume.uniforms.uFadeIn.value = 0.05; + this.spume.uniforms.uFadeOut.value = 0.5; + + // The haze standing over the eye. Non-additive so it occludes. + this.mist = particles.get('undertow.mist', { + capacity: 1100, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.mist.uniforms.uDrag.value = 1.9; + this.mist.uniforms.uEndSize.value = 2.5; + this.mist.uniforms.uSizeIn.value = 0.14; + this.mist.uniforms.uFadeIn.value = 0.2; + this.mist.uniforms.uFadeOut.value = 0.35; + + // Grit: the stuff too small to be worth a `Swarm` agent, riding the same + // streamlines a metre under the surface. + this.grit = particles.get('undertow.grit', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.9; + this.grit.uniforms.uEndSize.value = 0.7; + this.grit.uniforms.uFadeOut.value = 0.6; + + this.spumeEmitter = new RateEmitter(); + this.mistEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + this.rippleEmitter = new RateEmitter(); + this.rimEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._live; + } + + /** The vortex holds open and pulls. */ + get impactDuration() { + return Math.max(0.05, settings.undertow.pullTime * settings.global.lifetime); + } + + /** Then it shuts. */ + get fadeDuration() { + return Math.max(0.05, settings.undertow.closeTime); + } + + /** Water does not gutter; the eye breathes. */ + lightShimmer() { + return 0.86 + 0.14 * Math.sin(this.age * 2.1); + } + + /* ------------------------------------------------------------------ */ + /* THE FLOW FIELD — one function, and its two closed-form integrals */ + /* ------------------------------------------------------------------ */ + + /** + * The vortex-sink's velocity at a radius, in metres per second. + * + * This is the ability. Everything below it in this file is this function + * evaluated somewhere, and nothing else in the file reads `swirl` or `drain`. + * + * Both terms go as `1/r`, which is what an inviscid vortex and an inviscid + * sink actually do, and which is why the streamline is a logarithmic spiral — + * their *ratio* is independent of `r`, so the angle between the flow and the + * radius is the same everywhere. `coreRadius` is where that stops: inside it + * the field would be a singularity, so it is clamped, and the clamp is the + * eye of the whirlpool rather than a numerical guard bolted on afterwards. + * + * The value returned is the field at **full strength**. Consumers that want + * the field as it is *right now* multiply by `spin()`; the raft instead reads + * it through `flowTime()`, which is exact — see the class comment. + * + * @param {number} radius metres from the eye + * @param {object} out `{ radial, tangential, speed }`, written in place + */ + flowAt(radius, out) { + const c = settings.undertow; + const r = Math.max(radius, Math.max(c.coreRadius, 0.01)); + out.radial = -c.drain / r; // negative: inward + out.tangential = c.swirl / r; + out.speed = Math.hypot(out.radial, out.tangential); + return out; + } + + /** + * The same field as a world-space vector at a world point. + * + * The tangential basis vector is the radial one turned a quarter turn about + * `+Y` — `(−r̂.z, 0, r̂.x)` — which is the same sense `Swarm`'s `ORBIT` path + * sweeps in, so a positive `swirl` turns the debris and the spume the same + * way. Get that sign backwards and the froth runs against the raft, which is + * exactly the kind of error nobody can name and everybody can see. + */ + flowVectorAt(point, centre, out) { + let dx = point.x - centre.x; + let dz = point.z - centre.z; + const r = Math.hypot(dx, dz); + if (r < 1e-4) { + dx = 1; + dz = 0; + } else { + dx /= r; + dz /= r; + } + this.flowAt(r, _flow); + const k = this.spin(); + out.set( + (dx * _flow.radial - dz * _flow.tangential) * k, + 0, + (dz * _flow.radial + dx * _flow.tangential) * k + ); + return out; + } + + /** + * Seconds the vortex has been open. Zero while the cast is still travelling. + * + * Recovered from the phase clock rather than accumulated, so changing + * `pullTime` mid-cast re-times the beat instead of sliding the raft. + */ + vortexClock() { + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.IMPACT) return this.impactTime; + return this.impactDuration + this.fadeTime; + } + + /** 0..1 — how much of the field is switched on right now. */ + spin() { + const c = settings.undertow; + const ramp = Math.max(c.spinUp, 1e-3) * this.impactDuration; + return saturate(this.vortexClock() / ramp); + } + + /** + * The vortex clock with the spin-up ramp **integrated out**. + * + * A field scaled by `k(t)` moves a particle exactly as the unscaled field + * does when read against `∫k dt`, so this is the substitution that keeps + * `radiusAt` and `angleAt` closed-form through the ramp rather than only + * after it. For a linear ramp to 1 over `T` the integral is `t²/2T` while the + * ramp lasts and `t − T/2` afterwards, which is two lines and exact. + */ + flowTime() { + const c = settings.undertow; + const t = this.vortexClock(); + const T = Math.max(c.spinUp, 1e-3) * this.impactDuration; + return t < T ? (t * t) / (2 * T) : t - T * 0.5; + } + + /** The waterline's radius in metres — where a streamline is handed in. */ + rimRadius() { + const c = settings.undertow; + return Math.max(c.zoneRadius * this._fillNow(), Math.max(c.coreRadius, 0.01) * 1.5); + } + + /** + * Seconds one streamline takes to fall from the rim to the eye. + * + * `r² = r₀² − 2·drain·t` reaches zero at exactly this time, so it is the + * natural period of the raft and there is no separate "debris lifetime" + * slider: the fall time is what the field says it is. + */ + fallTime() { + const c = settings.undertow; + const r0 = this.rimRadius(); + return Math.max((r0 * r0) / (2 * Math.max(c.drain, 1e-3)), 0.05); + } + + /** Radius in metres, `flowAt` integrated once. */ + radiusAt(t) { + const c = settings.undertow; + const r0 = this.rimRadius(); + const core = Math.max(c.coreRadius, 0.01); + return Math.sqrt(Math.max(r0 * r0 - 2 * Math.max(c.drain, 1e-3) * t, core * core)); + } + + /** + * Bearing in radians, `flowAt` integrated once more. + * + * `(swirl/drain)·ln(r₀/r)` is the logarithmic spiral law written the other + * way round, which is the tidiest possible demonstration that the streamline + * really is one: no spiral was ever authored, only two velocities. + */ + angleAt(t) { + const c = settings.undertow; + const b = c.swirl / Math.max(c.drain, 1e-3); + return b * Math.log(this.rimRadius() / this.radiusAt(t)); + } + + /* ------------------------------------------------------------------ */ + /* The beats */ + /* ------------------------------------------------------------------ */ + + /** 0..1 how open the pool is on the way in. */ + _openNow() { + const c = settings.undertow; + if (this.phase === AbilityPhase.TRAVEL) return Math.pow(saturate(this.u), Math.max(c.openCurve, 0.05)); + return 1; + } + + /** 0..1 how far through shutting it is. */ + _closeNow() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / this.fadeDuration); + } + + /** The water's reach as a fraction of the half-extent, this frame. */ + _fillNow() { + const c = settings.undertow; + return saturate(c.poolFill * this._openNow() * (1 - Easing.inQuad(this._closeNow()))); + } + + /** Where the eye is, on the floor. */ + _eyePoint(out) { + const s = this.phase === AbilityPhase.TRAVEL ? this.u : 1; + return this.pointAt(s, out); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.spumeEmitter.reset(); + this.mistEmitter.reset(); + this.gritEmitter.reset(); + this.rippleEmitter.reset(); + this.rimEmitter.reset(); + this._live = 0; + + this.surface.clearRipples(); + this.net.reset(); + + // The one thing a cast captures. Everything else is resolved per frame. + this._seed = Math.random() * 100; + this.debris.roll(this._seed); + + this._sync(1); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve the pool, the net and the raft from live settings. + * + * The order is the painter's order and it is also the dependency order: the + * pool is placed and updated first because the net's quad has to sit on the + * same anchor and the raft's ripples are posted against this frame's + * half-extents. + * + * @param {number} fade 1 while the vortex holds, ramping to 0 as it shuts + */ + _sync(fade) { + const c = settings.undertow; + const g = settings.global; + + const fill = this._fillNow(); + const rim = this.rimRadius(); + const gate = this.spin(); + + /* ---------------- the pool ---------------- */ + this._eyePoint(_anchor); + _anchor.y = c.poolHeight; + this.surface.setPlacement(_anchor, this.direction, _up); + + // A disc: both half-extents are the aimed circle, and `fill` opens it. + _liquid.sizeX = Math.max(0.4, c.zoneRadius * 2); + _liquid.sizeZ = _liquid.sizeX; + _liquid.fill = fill; + _liquid.round = c.poolRound; + _liquid.edgeSoft = c.poolEdge; + _liquid.edgeNoise = c.poolRagged; + _liquid.edgeScale = c.poolRaggedScale; + _liquid.seed = this._seed; + _liquid.opacity = c.poolOpacity * fade * g.opacity; + _liquid.contactFade = c.contactFade; + // POOL mode ignores the crest, but the caustics' WAVE height field does not + // know that — it reads the bound `uCrestHeight` whatever the mode is. Left + // at the module's 1.1 m default it draws a wall of light across a pool that + // has no wave in it at all. + _liquid.crestHeight = 0; + + /* ---------------- the swell, in mirror pairs ---------------- */ + _liquid.waveAmpA = c.swellAmpA; + _liquid.waveAmpB = c.swellAmpB; + _liquid.waveAmpC = c.swellAmpC; + _liquid.waveAmpD = c.swellAmpD; + _liquid.waveLengthA = c.swellLengthA; + _liquid.waveLengthB = c.swellLengthB; + _liquid.waveLengthC = c.swellLengthC; + _liquid.waveLengthD = c.swellLengthD; + _liquid.waveSpeedA = c.swellSpeedA; + _liquid.waveSpeedB = c.swellSpeedB; + _liquid.waveSpeedC = c.swellSpeedC; + _liquid.waveSpeedD = c.swellSpeedD; + _liquid.waveAngleA = c.swellAngleA; + _liquid.waveAngleB = c.swellAngleB; + _liquid.waveAngleC = c.swellAngleC; + _liquid.waveAngleD = c.swellAngleD; + _liquid.steepness = c.steepness; + + _liquid.chop = c.chop * g.noiseStrength; + _liquid.chopScale = c.chopScale * g.noiseFrequency; + _liquid.chopSpeed = c.chopSpeed * g.noiseSpeed; + _liquid.detail = c.detail * g.noiseStrength; + _liquid.detailScale = c.detailScale * g.noiseFrequency; + _liquid.detailSpeed = c.detailSpeed * g.noiseSpeed; + + _liquid.rippleAmp = c.rippleAmp; + _liquid.rippleSpeed = c.rippleSpeed; + _liquid.rippleLength = c.rippleLength; + _liquid.rippleWidth = c.rippleWidth; + _liquid.rippleDecay = c.rippleDecay; + _liquid.rippleSpread = c.rippleSpread; + + /* ---------------- consumer one: the surface's own flow ---------------- */ + // `LiquidSurface` carries one bulk drift vector, a radial term and a curl + // term, and no coherent rotation at all — so the field is handed to it in + // the three pieces it can hold. The drift is the flow at ONE radius, which + // is the honest limitation of the module rather than a simplification of + // the vortex: everything else here still comes from `flowAt`. + this.flowAt(rim * c.flowSampleAt, _flow); + // In the sheet's parametric frame the sample point on +X has its radial + // direction along +x and its tangential direction along −z, because + // `axisZ = up × axisX` makes that frame left-handed. Hence the minus. + _liquid.flowAngle = Math.atan2(-_flow.tangential, _flow.radial); + _liquid.flowSpeed = _flow.speed * gate; + _liquid.flowRadial = _flow.radial * gate; + _liquid.flowRadialFall = c.radialFall; + _liquid.flowEddy = Math.abs(_flow.tangential) * c.eddyShare * gate; + // One eddy across the pool. Deliberately not a slider: the swirl has one + // length scale and it is the pool's own radius, so an independent control + // could only ever put the two out of step. + _liquid.flowEddyScale = g.noiseFrequency / Math.max(rim, 0.2); + // The churn of the curl noise is the vortex's own angular rate in turns per + // second, not a second clock running next to it. + _liquid.flowEddySpeed = + (Math.abs(_flow.tangential) / Math.max(rim * c.flowSampleAt, 0.05) / TAU) * gate * g.noiseSpeed; + _liquid.flowGravity = 0; // a pool has no slope to run down; the vortex is the flow + + _liquid.foam = c.foam * gate; + _liquid.foamScale = c.foamScale * g.noiseFrequency; + _liquid.foamSharp = c.foamSharp; + _liquid.foamCrest = c.foamCrest; + _liquid.foamSpeed = c.foamSpeed; + // The froth's speed gate. These are the crust's boxes and the crust is off; + // see `tiderush` for the same note. + _liquid.crustForm = c.foamGateLow; + _liquid.crustBreak = Math.max(c.foamGateHigh, c.foamGateLow + 0.01); + // Neither is gated by `crust`, and `meltGlow` defaults to 1.2 — leave it + // alone and the whole pool picks up a warm emissive haze the moment foam + // asks for a surface speed. + _liquid.meltGlow = 0; + _liquid.seamGlow = 0; + + /* ---------------- shading ---------------- */ + _liquid.poolDepth = c.poolDepth; + _liquid.depthTint = c.depthTint; + _liquid.translucency = c.translucency; + _liquid.ambient = c.ambient; + _liquid.specular = c.specular; + _liquid.shininess = c.shininess; + _liquid.fresnel = c.fresnel * g.fresnel; + _liquid.envIntensity = c.envIntensity; + _liquid.skyIntensity = c.skyIntensity; + _liquid.glow = c.glow * g.glow; + _liquid.normalEps = c.normalEps; + _liquid.colorDeep = c.colorDeep; + _liquid.colorShallow = c.colorShallow; + _liquid.colorFoam = c.colorFoam; + _liquid.colorSpec = c.colorSpec; + _liquid.colorSky = c.colorSky; + + this.surface.visible = _liquid.opacity > 0.002 && fill > 0.002; + this.surface.update(this.age, _liquid); + + /* ---------------- consumer two: the net ---------------- */ + // The yaw is chosen so the quad's local +X lies along the pool's `axisX`, + // because the bound height field is sampled in the pool's parametric + // metres. See the handedness note in the class comment. + _netCentre.copy(_anchor).setY(0); + _net.yaw = Math.atan2(-this.direction.z, this.direction.x); + _net.height = c.netHeight; + _net.radius = Math.max(0.2, rim * c.netReach); + _net.fade = fade; + // The light crosses exactly the water that is standing there. + _net.depth = Math.max(0, c.poolHeight + c.netDepthBias); + _net.ior = c.netIor; + _net.dispersion = c.netDispersion; + _net.sampleStep = c.netStep; + _net.absorb = c.netAbsorb; + _net.foldFloor = c.netFoldFloor; + _net.threshold = c.netThreshold; + _net.gain = c.netGain; + _net.sharpness = c.netSharp; + _net.rolloff = c.netRolloff; + _net.penumbra = c.netPenumbra; + _net.wash = c.netWash; + _net.fringeAt = c.netFringeAt; + _net.emissive = c.netEmissive; + _net.opacity = c.netOpacity; + _net.depthFade = c.netDepthFade; + _net.additive = true; + _net.colorNet = c.colorNet; + _net.colorFringe = c.colorFringe; + _net.colorWash = c.colorWash; + _net.noiseStrength = g.noiseStrength; + _net.noiseFrequency = g.noiseFrequency; + _net.noiseSpeed = g.noiseSpeed; + _net.opacityScale = g.opacity; + this.net.setVisible(fade > 0.002 && fill > 0.002); + this.net.update(_net); + + /* ---------------- consumer three: the raft ---------------- */ + const fall = this.fallTime(); + // The streamline is cyclic: an agent that reaches the eye is handed back in + // at the rim. `fall` is not a lifetime slider — it is what the field says + // the fall takes — so the loop re-times itself when `drain` moves. + const phase = this.flowTime() % fall; + const along = saturate(phase / fall); + const radius = this.radiusAt(phase); + const angle = this.angleAt(phase); + // Angular rate at the radius the lead is at now. `Swarm` rewinds its + // trailing ranks with this, so the arm behind the lead is where the + // streamline was — to first order. See the class comment. + this.flowAt(radius, _flow); + // Signed, because `tangential` already carries the sign of `swirl` — flip + // that slider and the raft, the froth and the drift all turn the other way + // together, which is the point of there being one field. + const omega = _flow.tangential / Math.max(radius, 0.01); + + this.debris.setBasis(this.origin, this.direction, this.side, Math.max(0.2, this.length)); + _drops.count = Math.min(MAX_DEBRIS, Math.round(c.debrisCount * g.particleCount)); + _drops.leadMode = LeadPath.ORBIT; + _drops.orbitTurns = 1; // so `leadS` is turns, and `angle` is radians / TAU + _drops.leadS = angle / TAU; + _drops.leadRate = (omega / TAU) * gate; + _drops.orbitRadius = radius; + // The eye's floor point is where `Swarm` puts an ORBIT's centre, and the + // depth is measured down from the water's mean plane: at the rim the raft + // is barely wet, at the eye it is most of a metre under. + _drops.endHeight = c.poolHeight; + _drops.orbitHeight = -lerp(c.debrisSinkRim, c.debrisSinkCore, saturate(1 - radius / Math.max(rim, 0.05))); + _drops.handForward = 0; + _drops.handSide = 0; + _drops.handHeight = c.poolHeight; + _drops.leadRise = 0; + _drops.latticeX = c.debrisLatticeX; + _drops.latticeY = c.debrisLatticeY; + _drops.latticeZ = c.debrisLatticeZ; + _drops.spacingSide = c.debrisSpacingSide; + _drops.spacingUp = c.debrisSpacingUp; + _drops.lag = c.debrisLag; + _drops.jitter = c.debrisJitter * g.randomness; + _drops.churn = c.debrisChurn; + _drops.breathe = c.debrisBreathe; + _drops.breatheRate = c.debrisBreatheRate; + _drops.wander = c.debrisWander * g.noiseStrength; + _drops.wanderScale = c.debrisWanderScale * g.noiseFrequency; + _drops.wanderSpeed = c.debrisWanderSpeed * g.noiseSpeed; + _drops.gather = c.debrisGather; + _drops.size = c.debrisSize * g.particleSize; + _drops.aspect = c.debrisAspect; + _drops.sizeJitter = c.debrisSizeJitter * g.randomness; + _drops.billboard = c.debrisBillboard; + _drops.bank = c.debrisBank; + _drops.bankMax = c.debrisBankMax; + _drops.dihedral = 0; + _drops.flapRate = 0; + _drops.curl = c.debrisCurl; + _drops.edgeStretch = c.debrisEdgeStretch; + _drops.edgeGain = c.debrisEdgeGain; + // Agents appear at the rim and are swallowed at the eye. `revealSpread` + // staggers that per agent off its own dice, so the raft does not blink in + // and out as one object at the seam of the cycle. + _drops.reveal = + saturate(along / Math.max(c.debrisFadeIn, 0.01)) * + (1 - saturate((along - (1 - c.debrisFadeOut)) / Math.max(c.debrisFadeOut, 0.01))) * + fade * + gate; + _drops.revealSpread = c.debrisRevealSpread; + _drops.silhouette = Silhouette.DROPLET; + _drops.lit = c.debrisLit; + _drops.tint = c.debrisTint; + _drops.tintJitter = c.debrisTintJitter; + _drops.tintAlong = c.debrisTintAlong; + _drops.opacity = c.debrisOpacity * g.opacity; + _drops.glow = c.debrisGlow * g.glow; + _drops.softFade = c.debrisSoftFade; + this.debris.setColors(c.colorDebrisA, c.colorDebrisB, c.colorDebrisC, c.colorDebrisD); + this.debris.update(this.age, _drops); + this._live = this.debris.count; + + /* ---------------- the particle systems ---------------- */ + this.spume.setGradient( + getColor(c.colorSpumeA), + getColor(c.colorSpumeB), + getColor(c.colorSpumeC), + getColor(c.colorSpumeD) + ); + this.spume.uniforms.uGravity.value.set(0, c.spumeGravity, 0); + this.spume.uniforms.uSizeScale.value = c.spumeSize * g.particleSize * 7; + this.spume.uniforms.uLifeScale.value = c.spumeLifetime * 0.5 * g.particleLifetime; + this.spume.uniforms.uSpeedScale.value = g.particleSpeed; + this.spume.uniforms.uOpacity.value = g.opacity; + this.spume.uniforms.uGlow.value = 0.45 * g.glow; + this.spume.uniforms.uTurbulence.value = 0.25 * g.turbulence; + + this.mist.setGradient( + getColor(c.colorMistA), + getColor(c.colorMistB), + getColor(c.colorMistC), + getColor(c.colorMistD) + ); + this.mist.uniforms.uGravity.value.set(0, c.mistRise, 0); + this.mist.uniforms.uSizeScale.value = c.mistSize * g.particleSize; + this.mist.uniforms.uLifeScale.value = c.mistLifetime * 0.5 * g.particleLifetime; + this.mist.uniforms.uSpeedScale.value = c.mistSpeed * g.particleSpeed; + this.mist.uniforms.uOpacity.value = c.mistOpacity * g.opacity; + this.mist.uniforms.uTurbulence.value = c.mistTurbulence * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = c.gritLifetime * 0.5 * g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Consumer four: everything that is emitted is emitted **along the flow**. + * + * A fleck of froth is given the field's own velocity at the radius it was + * born at, plus a little of its own. That is what makes the surface read as + * turning: `LiquidSurface` cannot draw a coherent rotation, and a hundred + * flecks travelling along the streamline can. + * + * @param {number} dt seconds + * @param {number} scale 0..1 — thinned out once the vortex is shutting + */ + _vortexFx(dt, scale) { + const c = settings.undertow; + const g = settings.global; + const time = frame.uTime.value; + const rim = this.rimRadius(); + + // The eye, held for the whole method: every emitter below asks the field + // where it is going, and the field is measured from here. + this._eyePoint(_centre).setY(c.poolHeight); + const eyeX = _centre.x; + const eyeZ = _centre.z; + + let spumeCount = Math.round(this.spumeEmitter.tick(dt, c.spumeRate * scale) * g.particleCount); + if (spumeCount > 0) { + _emit.speedVariance = 0.6; + _emit.spread = 0.35; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.spumeLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + _emit.radius = 0.25; + + // Split around the waterline. One emission point a frame puts the whole + // frame's froth in one place and the ring reads as a sprinkler. + const batches = Math.min(spumeCount, 4); + const per = Math.ceil(spumeCount / batches); + while (spumeCount > 0) { + const a = Math.random() * TAU; + const r = rim * c.spumeAt * randRange(0.85, 1.05); + _pos.set(eyeX + Math.cos(a) * r, c.poolHeight + 0.05, eyeZ + Math.sin(a) * r); + _emit.position = _pos; + this.flowVectorAt(_pos, _centre, _vel); + const carried = _vel.length(); + _emit.direction = _dir.copy(_vel).setY(carried * 0.25 + 0.4).normalize(); + _emit.speed = carried + c.spumeSpeed; + this.spume.emit(Math.min(per, spumeCount), _emit); + spumeCount -= per; + } + } + + const gritCount = Math.round(this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount); + if (gritCount > 0) { + const a = Math.random() * TAU; + const r = lerp(c.coreRadius, rim, Math.random()); + // A metre under, so the grit is seen through the water like the raft is. + _pos.set(eyeX + Math.cos(a) * r, c.poolHeight - c.debrisSinkRim, eyeZ + Math.sin(a) * r); + _emit.position = _pos; + _emit.radius = 0.3; + this.flowVectorAt(_pos, _centre, _vel); + const carried = _vel.length(); + _emit.direction = _dir.copy(_vel).setY(-0.15).normalize(); + _emit.speed = carried + c.gritSpeed; + _emit.speedVariance = 0.5; + _emit.spread = 0.3; + _emit.size = 0.08; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 5; + _emit.time = time; + this.grit.emit(gritCount, _emit); + } + + const mistCount = Math.round(this.mistEmitter.tick(dt, c.mistRate * scale) * g.particleCount); + if (mistCount > 0) { + this._eyePoint(_pos).setY(c.poolHeight + 0.1); + _emit.position = _pos; + _emit.radius = rim * 0.45; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.mistSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.mistLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.time = time; + this.mist.emit(mistCount, _emit); + } + + // The dimple where the raft is being taken down. `rippleAtWorld` converts + // the point against this frame's half-extents and stores only the fraction, + // and the net has bound the same array — so the ring on the water and the + // ring on the floor are one record. + const packets = this.rippleEmitter.tick(dt, c.rippleRate * scale); + if (packets > 0) { + this.debris.leadPoint(_lead); + for (let i = 0; i < packets; i++) { + this.surface.rippleAtWorld(_lead, randRange(0.5, 1.1), this.age); + } + } + + // The waterline soaking into the floor. + const marks = this.rimEmitter.tick(dt, c.rimRate * scale); + for (let i = 0; i < marks; i++) { + const a = Math.random() * TAU; + const r = rim * randRange(0.9, 1.12); + _pos.set(eyeX + Math.cos(a) * r, 0, eyeZ + Math.sin(a) * r); + this.ctx.decals.spawn(DecalType.FOAM, _pos, { + radius: c.rimRadius * randRange(0.7, 1.3), + life: c.rimLife, + intensity: c.rimIntensity, + colorA: getColor(c.colorRimA), + colorB: getColor(c.colorRimB), + height: 0.012 + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(1); + // The light sits over the eye rather than on the travelling front. + this._eyePoint(this.position).setY(settings.undertow.poolHeight + settings.undertow.lightHeight); + this._vortexFx(dt, this._openNow()); + } + + onImpact() { + const c = settings.undertow; + const g = settings.global; + const time = frame.uTime.value; + + this._eyePoint(_pos).setY(c.poolHeight); + + /* the gulp of water as the surface breaks open */ + this.ctx.bursts.spawn(BurstMode.WATER, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.5, + displace: 0.7, + squash: 0.5, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this._eyePoint(_pos); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.55, + width: 0.06, + intensity: 0.9, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + this.ctx.shake.add( + c.openShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 16 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.openFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + + _emit.position = _pos; + _emit.radius = this.rimRadius() * 0.5; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.spumeSpeed * 3.5; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.8; + _emit.life = c.spumeLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.spume.emit(Math.round(c.burstSpume * g.particleCount), _emit); + + /* and the loose grit the opening pulls straight down with it */ + _emit.direction = _dir.set(0, -0.4, 0).normalize(); + _emit.speed = c.gritSpeed * 3; + _emit.spread = 1.0; + _emit.size = 0.09; + _emit.life = c.gritLifetime * 1.2; + _emit.spin = 6; + this.grit.emit(Math.round(c.burstGrit * g.particleCount), _emit); + + /* + * Ring the new surface. The packets are fractions of the half-extents plus + * a timestamp — never a metre — and the net has bound the same array, so + * these rings arrive in the water and in the light on the floor as one + * record. Posted in mirror-symmetric pairs of angle where they can be, for + * the reason the handedness note gives: a lone off-axis packet is drawn by + * the net on the far side of the pool from the one on the water. + */ + const packets = Math.max(0, Math.round(c.burstRipples)); + for (let i = 0; i < packets; i++) { + const a = (i / Math.max(packets, 1)) * TAU; + const r = randRange(0.25, 0.7); + this.surface.ripple(Math.cos(a) * r, Math.sin(a) * r, randRange(0.7, 1.2), this.age); + } + } + + onFade(dt, t) { + // `t` runs 0..1 while the vortex pulls, then 1..2 while it shuts. Cubic-out + // on the way down so the water lets go all at once rather than dimming. + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(saturate(t - 1)); + this._sync(fade); + + this._eyePoint(this.position).setY(settings.undertow.poolHeight + settings.undertow.lightHeight); + this._vortexFx(dt, t <= 1 ? 1 : fade * 0.5); + + if (t <= 1) { + this.ctx.shake.rumble(settings.undertow.rumble * settings.global.cameraShake, dt); + } + } + + onDestroy() { + // Nothing is emitted here on purpose. `destroy()` is also how the player + // pressing **C** ends a cast and how a fifth cast pushes this one off the + // concurrency cap, and a whirlpool that coughs up a burst of grit when the + // player cancels it is telling the player something that did not happen. + this._live = 0; + this.surface.reset(); + this.debris.reset(); + this.net.setVisible(false); + } + + dispose() { + this.surface.dispose(); + this.net.dispose(); + this.debris.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/verdant/BloomburstAbility.js b/src/abilities/verdant/BloomburstAbility.js new file mode 100644 index 0000000..d6a29af --- /dev/null +++ b/src/abilities/verdant/BloomburstAbility.js @@ -0,0 +1,951 @@ +import { BufferGeometry, BufferAttribute, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GrowthField, GrowthLayout, GrowthEmerge, growthParams } from '../../vfx/GrowthField.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { createPetalMaterial } from '../../materials/PetalMaterial.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, Easing, randRange, hash11 } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on flowers per cast. The editor's `flowers` slider clamps here. */ +const MAX_FLOWERS = 96; +/** + * Distinct flower silhouettes. Three, for the reason `GrowthField` documents: + * per-instance scaling buys proportion variety, but only distinct geometry + * buys *facet* variety, and a meadow of one bloom scaled forty ways reads as a + * repeated prop the moment the camera moves. Here the variants differ in petal + * count and in whether the ring of blades is phase-offset, which is enough. + */ +const VARIANTS = 3; + +/** + * The pose baked into the vertex buffer. + * + * Nothing reads these at run time — `PetalMaterial` rewrites every vertex from + * live uniforms. They exist because three needs *a* position attribute to + * compute a bounding sphere from and because the shadow depth material is + * generated by three and is therefore **not** patched, so an unposed buffer + * would cast the shadow of a flat star. Baking the closed bud means the worst + * case is a bud-shaped shadow under an open flower, which is why this field + * turns `castShadow` off and takes only the receive. + */ +const NOMINAL = { + stemTop: 0.44, + stemRadius: 0.05, + heartRadius: 0.09, + width: 0.3, + widthBias: 0.78, + taper: 0.7, + budMidOut: 0.02, + budMidUp: 0.34, + budTipOut: 0.05, + budTipUp: 0.7 +}; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _hand = new Vector3(); + +/** + * One flower, in `GrowthField`'s unit space: footprint inside a circle of + * radius 0.5 on `y = 0`, tip at `y = 1`. + * + * A tapered stalk prism with a cap, and a ring of `petals` blades hinged on the + * rim of the cap. Every vertex carries `aPetal = (along, across, bearing, + * blade dice)` and `aPart` (0 stalk, 1 petal) — those four numbers plus the two + * `GrowthField` attributes are the entire input to the pose, which is built in + * the vertex shader. See `materials/PetalMaterial.js`. + * + * A blade is three columns wide (`across` = -1, 0, +1) rather than two, + * because the middle column is the **midrib**: the lengthwise crease is a tent + * fold about it, and a two-column blade has nothing to fold about. That is the + * single reason the tessellation is not the obvious quad strip. + */ +function createFlowerGeometry(variant, shape) { + const blades = Math.max( + 3, + Math.min(14, Math.round(shape.petals) + (variant === 1 ? 1 : variant === 2 ? -1 : 0)) + ); + const segments = Math.max(2, Math.min(24, Math.round(shape.segments))); + const sides = Math.max(3, Math.min(16, Math.round(shape.sides))); + + const stalkVerts = sides * 2 + 1; + const total = stalkVerts + blades * (segments + 1) * 3; + const triangles = sides * 3 + blades * segments * 4; + + const position = new Float32Array(total * 3); + const petal = new Float32Array(total * 4); + const part = new Float32Array(total); + const index = new Uint16Array(triangles * 3); + + let v = 0; + let t = 0; + + /* --- the stalk: two rings and a cap fan --- */ + for (let ring = 0; ring < 2; ring++) { + const along = ring; + const r = NOMINAL.stemRadius + (NOMINAL.heartRadius - NOMINAL.stemRadius) * along; + for (let s = 0; s < sides; s++) { + const bearing = (s / sides) * TAU; + position[v * 3] = Math.cos(bearing) * r; + position[v * 3 + 1] = NOMINAL.stemTop * along; + position[v * 3 + 2] = Math.sin(bearing) * r; + // `across` doubles as a 0..1 radial scale on the stalk, which is how the + // cap gets a centre vertex without a fourth attribute. + petal[v * 4] = along; + petal[v * 4 + 1] = 1; + petal[v * 4 + 2] = bearing; + petal[v * 4 + 3] = 0; + part[v] = 0; + v++; + } + } + const capCentre = v; + position[v * 3 + 1] = NOMINAL.stemTop; + petal[v * 4] = 1; + petal[v * 4 + 1] = 0; + petal[v * 4 + 2] = 0; + petal[v * 4 + 3] = 0; + part[v] = 0; + v++; + + // The index names below are deliberately not `a`/`b`/`c`/`d`: `c` is the + // conventional alias for a settings block everywhere in this project, and + // `scripts/check.mjs` drops every `c.` read in a file that also binds + // `c` to something that is not a settings block. Four characters of loop + // variable is not worth losing the whole file's static cross-check over. + for (let s = 0; s < sides; s++) { + const n0 = s; + const n1 = (s + 1) % sides; + const n2 = sides + s; + const n3 = sides + ((s + 1) % sides); + index[t++] = n0; + index[t++] = n2; + index[t++] = n3; + index[t++] = n0; + index[t++] = n3; + index[t++] = n1; + index[t++] = n2; + index[t++] = capCentre; + index[t++] = n3; + } + + /* --- the ring of blades --- */ + for (let k = 0; k < blades; k++) { + // Variant 2 is offset half a slot so two flowers standing side by side do + // not present their petals on the same bearings. + const bearing = ((k + (variant === 2 ? 0.5 : 0)) / blades) * TAU; + // A per-blade dice roll, deterministic in the geometry rather than random, + // so the same variant always tessellates identically and `syncGeometry`'s + // change test stays honest. + const dice = hash11(k * 12.9898 + variant * 7.13 + blades * 3.77); + const base = v; + + for (let j = 0; j <= segments; j++) { + const along = j / segments; + const ms = 1 - along; + const cx = + ms * ms * NOMINAL.heartRadius + + 2 * ms * along * NOMINAL.budMidOut + + along * along * NOMINAL.budTipOut; + const cy = 2 * ms * along * NOMINAL.budMidUp + along * along * NOMINAL.budTipUp; + const halfWidth = + NOMINAL.width * + Math.pow(Math.max(Math.sin(Math.PI * Math.pow(along, NOMINAL.widthBias)), 0), NOMINAL.taper); + + for (let i = 0; i < 3; i++) { + const across = i - 1; + position[v * 3] = Math.cos(bearing) * cx - Math.sin(bearing) * (across * halfWidth); + position[v * 3 + 1] = NOMINAL.stemTop + cy; + position[v * 3 + 2] = Math.sin(bearing) * cx + Math.cos(bearing) * (across * halfWidth); + petal[v * 4] = along; + petal[v * 4 + 1] = across; + petal[v * 4 + 2] = bearing; + petal[v * 4 + 3] = dice; + part[v] = 1; + v++; + } + } + + for (let j = 0; j < segments; j++) { + for (let i = 0; i < 2; i++) { + const n0 = base + j * 3 + i; + const n1 = n0 + 1; + const n2 = n0 + 3; + const n3 = n2 + 1; + index[t++] = n0; + index[t++] = n2; + index[t++] = n1; + index[t++] = n1; + index[t++] = n2; + index[t++] = n3; + } + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(position, 3)); + geometry.setAttribute('aPetal', new BufferAttribute(petal, 4)); + geometry.setAttribute('aPart', new BufferAttribute(part, 1)); + geometry.setIndex(new BufferAttribute(index, 1)); + geometry.computeVertexNormals(); + return geometry; +} + +/** + * BLOOMBURST — the meadow that is forced open and then thrown away. + * + * Four beats over three phases: + * + * 1. **travel** — a growth front runs out across the floor to the circle the + * indicator drew, laying a mat of moss behind it. + * 2. **grow & open** — the first part of the impact phase. Stalks push up out + * of the footprint each carrying a closed bud, and the buds **unfurl in a + * wave** that starts in the middle of the circle and runs out to the + * boundary. The flowers in the centre are fully open before the ones on + * the rim have cleared the ground. + * 3. **hold** — the field stands open, breathing pollen off its hearts. + * 4. **release** — the fade phase. Every petal in the circle lets go on the + * *same frame*, tumbling outward, and the pollen they were carrying swells + * into a raymarched volume that drifts and settles while the bare stalks + * fold back into the floor behind it. + * + * **The trick is the unfurl, and none of it is on the CPU.** Each petal is + * stored once, unbent, as `(along, across, bearing, dice)`; the vertex shader + * poses it from a quadratic Bezier whose two free control points lerp from a + * bud pose to an open pose. The lerp parameter is `1 - aBirth` — the + * per-instance value `GrowthField` already writes and already re-derives from + * its live `birthFade`, which this ability feeds `unfurlTime`. So the wave, the + * opening and the shape of the opening are three separate live controls over a + * field that is already standing, and `unfurlCurve` — the exponent on that + * lerp — is the one that carries the character of the whole cast. + * + * The first version tried to bake the bend into the geometry and let the field + * scale it. That cannot work and it is worth writing down why: `GrowthField` + * shares **one** geometry per variant across every instance of it, and the bend + * is a function of time *per instance*. Baking it gives the entire meadow a + * single pose, which is a photograph of a flower bed, not a bloom. + * + * **The rule that makes the editor work.** A flower record holds nothing but + * dice and one timestamp — `GrowthField` owns them and this class never adds + * to them. Not one metre, radian or second is captured: the seat of the ring, + * the height of a stalk, the pose of a petal, the size of the pollen hull and + * the death of the last mote are all resolved against `settings.bloomburst` + * inside the update loop, which runs on a zero-length frame too. + */ +export class BloomburstAbility extends Ability { + constructor(context) { + super('bloomburst', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + const c = settings.bloomburst; + + this.material = createPetalMaterial(this.ctx.environment); + + /** Live params, allocated once. Refilled from settings every frame — I1. */ + this.growth = growthParams(); + this.growth.layout = GrowthLayout.ZONE; + this.growth.emerge = GrowthEmerge.PUSH; + this.growth.centre = new Vector3(); + /** The three numbers that cannot be a uniform, so they rebuild the mesh. */ + this.shape = { petals: c.petals, segments: c.petalSegments, sides: c.stalkSides }; + + this.field = new GrowthField(this.group, { + geometry: createFlowerGeometry, + material: this.material, + shape: this.shape, + variants: VARIANTS, + capacity: MAX_FLOWERS, + layer: LAYER.WORLD, + renderOrder: 2, + // See NOMINAL: the depth material three generates for the shadow pass is + // not patched, so it would cast the *bud* pose of an open flower. A wrong + // shadow is louder than no shadow. + castShadow: false, + receiveShadow: true + }); + // Assigned once, at construction. A closure built inside the update loop is + // an allocation per instance per frame, which is exactly what I3 forbids. + this.field.onBreach = (index, position, radius, height) => + this._breachFx(index, position, radius, height); + + /** + * The pollen. A SPORE cylinder standing over the circle — barely there + * while the flowers merely hold, and the entire read for the second after + * they let go. + * + * CYLINDER rather than DOME because the pollen belongs to the *footprint*: + * a dome puts its mass over the middle and tapers to nothing at the + * boundary, which reads as a puff sitting on a meadow rather than as the + * meadow itself having gone to dust. + */ + this.pollen = new VolumeHull({ + hull: HullShape.CYLINDER, + medium: Medium.SPORE, + prefix: 'pollen', + maxSteps: 40, + renderOrder: 12 + }); + this.group.add(this.pollen.mesh); + + /** Re-rolled per cast so no two meadows draw the same ring. */ + this._seed = 0; + /** Seconds since the field took hold. Drives the opening wave, nothing else. */ + this._bloomTime = 0; + /** One-shot latch for the release, which is a single event across the field. */ + this._released = false; + } + + createParticles() { + const particles = this.ctx.particles; + + // Pollen: the signature system. Slow, additive, curl-driven — it has to + // hang in the air rather than fly, because the flowers are giving it up, + // not throwing it. + this.pollenMotes = particles.get('bloomburst.pollen', { + capacity: 2600, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.45 + }); + this.pollenMotes.uniforms.uDrag.value = 1.9; + this.pollenMotes.uniforms.uEndSize.value = 0.5; + this.pollenMotes.uniforms.uSizeIn.value = 0.1; + this.pollenMotes.uniforms.uFadeIn.value = 0.12; + this.pollenMotes.uniforms.uFadeOut.value = 0.45; + + // Torn petal fragments. LEAF, lit and non-additive: they are the only + // opaque thing in the release and they are what makes it read as *matter* + // being shed rather than as a light effect. + this.confetti = particles.get('bloomburst.confetti', { + capacity: 1600, + shape: ParticleShape.LEAF, + additive: false, + lit: true, + softFade: 0.3 + }); + this.confetti.uniforms.uDrag.value = 1.15; + this.confetti.uniforms.uEndSize.value = 0.9; + this.confetti.uniforms.uFadeIn.value = 0.05; + this.confetti.uniforms.uFadeOut.value = 0.55; + + // A low green haze off the ground the field is growing out of. + this.haze = particles.get('bloomburst.haze', { + capacity: 1400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.haze.uniforms.uDrag.value = 2.2; + this.haze.uniforms.uEndSize.value = 2.6; + this.haze.uniforms.uSizeIn.value = 0.14; + this.haze.uniforms.uFadeIn.value = 0.2; + this.haze.uniforms.uFadeOut.value = 0.32; + + this.pollenEmitter = new RateEmitter(); + this.hazeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.field.count; + } + + /** Grow, open on the wave, then stand. */ + get impactDuration() { + const c = settings.bloomburst; + return Math.max( + 0.2, + (c.riseTime + c.waveTime + c.unfurlTime + c.holdTime) * settings.global.lifetime + ); + } + + /** The petals let go, then the stalks fold back in behind them. */ + get fadeDuration() { + const c = settings.bloomburst; + return Math.max(0.2, c.releaseTime + c.witherTime); + } + + /** The live footprint, metres. What the indicator measured out. */ + get radius() { + return Math.max(0.05, settings.bloomburst.zoneRadius); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** The middle of the circle — the far end of the aimed line. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** Where the cast leaves the caster, in world space. */ + _handPoint(out) { + const c = settings.bloomburst; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** + * How far the opening wave has crossed the circle, 0..1. + * + * A pure function of `_bloomTime` against the live `waveTime`, so the wave + * re-times itself if the slider moves mid-cast. + */ + _waveFront() { + const c = settings.bloomburst; + return Easing.outCubic(saturate(this._bloomTime / Math.max(0.02, c.waveTime))); + } + + /** 0..1 — how far the petals have let go. Only ever non-zero in the fade. */ + _releaseAmount() { + if (this.phase !== AbilityPhase.FADE) return 0; + const c = settings.bloomburst; + return Easing.outCubic(saturate(this.fadeTime / Math.max(0.02, c.releaseTime))); + } + + /** 0..1 — how far the bare stalks have folded back into the floor. */ + _witherAmount() { + if (this.phase !== AbilityPhase.FADE) return 0; + const c = settings.bloomburst; + return saturate((this.fadeTime - c.releaseTime) / Math.max(0.05, c.witherTime)); + } + + /** + * Refill the growth params from live settings. + * + * Every number here is re-read on every call, including on a zero-length + * frame. Nothing is cached between frames and nothing is captured at spawn — + * that is the whole of I1 for this ability. + */ + _fillGrowth() { + const c = settings.bloomburst; + const g = settings.global; + const p = this.growth; + const R = this.radius; + + this._centrePoint(p.centre); + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + + p.radius = R; + p.innerRadius = R * saturate(c.innerFrac); + p.radialCurve = c.radialCurve; + p.radialJitter = c.radialJitter; + p.angleJitter = c.angleJitter; + + p.heightNear = c.heightNear; + p.height = c.height; + p.heightCurve = c.heightCurve; + p.heightJitter = c.heightJitter; + p.radiusNear = c.stalkRadius; + p.radius2 = c.stalkRadiusTop; + p.radiusCurve = c.radiusCurve; + p.radiusJitter = c.radiusJitter; + + p.lean = c.lean; + p.leanJitter = c.leanJitter; + p.leanRamp = c.leanRamp; + // Both, and both non-zero: GrowthField normalises the blend of them, so a + // single non-zero weight would be scaled away and its slider would be dead. + p.leanOutward = c.leanOutward; + p.leanForward = c.leanForward; + p.tilt = c.tilt; + p.twist = c.twist; + + p.riseTime = c.riseTime; + p.riseOvershoot = c.riseOvershoot; + p.settle = c.settle; + p.springRate = c.springRate; + p.emergeSink = c.emergeSink; + p.birthScale = c.birthScale; + p.breachAt = c.breachAt; + p.sinkDepth = c.sinkDepth; + // The coupling that makes the whole ability work: GrowthField's birth flash + // *is* Bloomburst's unfurl clock. It has to span the rise as well as the + // opening, because the ramp starts the instant the stalk is triggered and + // the bud is not allowed to split until the stalk is up — `PetalMaterial` + // splits it at `uOpenDelay`, which is derived from the same two numbers. + p.birthFade = Math.max(0.02, c.riseTime + c.unfurlTime); + + p.randomness = g.randomness; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.bloomburst; + + this.pollenEmitter.reset(); + this.hazeEmitter.reset(); + this._bloomTime = 0; + this._released = false; + + // The one thing a cast captures, besides the field's own dice and the + // timestamps it hands each flower. + this._seed = Math.random() * 100; + + this.field.plant(Math.min(MAX_FLOWERS, Math.round(c.flowers)), c.coreShare); + this.pollen.setFade(0); + + this._sync(0, 0); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into the petal material, the pollen hull and the + * three particle systems, and rebuild every flower matrix. + * + * @param {number} release 0..1 — how far the petals have let go + * @param {number} wither 0..1 — how far the bare stalks have folded back + */ + _sync(release, wither) { + const c = settings.bloomburst; + const g = settings.global; + + /* --- the flowers --- */ + this.shape.petals = c.petals; + this.shape.segments = c.petalSegments; + this.shape.sides = c.stalkSides; + this.field.syncGeometry(this.shape); + + this.material.userData.sync(release); + this._fillGrowth(); + this.field.update(this.age, this.growth, wither); + + /* --- the pollen volume --- */ + // Its footprint is the circle's, swelling as the petals are given up; its + // base sinks as it settles. Both re-resolved here, never captured. + const R = this.radius * c.pollenRadius * (1 + (c.pollenSwell - 1) * release); + const lift = Math.max(0.05, c.pollenLift * (1 - c.pollenSettle * wither)); + const idle = saturate(c.pollenHaze); + const fade = Math.max(idle * (1 - wither), release * (1 - wither)); + + this._centrePoint(_pos); + _pos.y = c.pollenBase * (1 - c.pollenSettle * wither); + this.pollen.place(_pos, this.direction).setSize(R, lift, R).setFade(fade).sync(c, g); + + /* --- the three particle systems --- */ + this.pollenMotes.setGradient( + getColor(c.colorPollenA), + getColor(c.colorPollenB), + getColor(c.colorPollenC), + getColor(c.colorPollenD) + ); + this.pollenMotes.uniforms.uGravity.value.set(0, c.pollenDrift, 0); + this.pollenMotes.uniforms.uSizeScale.value = c.pollenSize * g.particleSize * 7; + this.pollenMotes.uniforms.uLifeScale.value = c.pollenLifetime * 0.5 * g.particleLifetime; + this.pollenMotes.uniforms.uSpeedScale.value = g.particleSpeed; + this.pollenMotes.uniforms.uOpacity.value = g.opacity; + this.pollenMotes.uniforms.uGlow.value = c.pollenGlow * g.glow; + this.pollenMotes.uniforms.uTurbulence.value = c.pollenTurbulence * g.turbulence; + + this.confetti.setGradient( + getColor(c.colorConfettiA), + getColor(c.colorConfettiB), + getColor(c.colorConfettiC), + getColor(c.colorConfettiD) + ); + this.confetti.uniforms.uGravity.value.set(0, c.confettiGravity, 0); + this.confetti.uniforms.uSizeScale.value = c.confettiSize * g.particleSize * 7; + this.confetti.uniforms.uLifeScale.value = c.confettiLifetime * 0.5 * g.particleLifetime; + this.confetti.uniforms.uSpeedScale.value = g.particleSpeed; + this.confetti.uniforms.uOpacity.value = g.opacity; + + this.haze.setGradient( + getColor(c.colorHazeA), + getColor(c.colorHazeB), + getColor(c.colorHazeC), + getColor(c.colorHazeD) + ); + this.haze.uniforms.uGravity.value.set(0, c.hazeRise, 0); + this.haze.uniforms.uSizeScale.value = c.hazeSize * g.particleSize; + this.haze.uniforms.uLifeScale.value = c.hazeLifetime * 0.5 * g.particleLifetime; + this.haze.uniforms.uSpeedScale.value = c.hazeSpeed * g.particleSpeed; + this.haze.uniforms.uOpacity.value = c.hazeOpacity * g.opacity; + this.haze.uniforms.uTurbulence.value = 0.4 * g.turbulence; + } + + /** The puff of spores at the caster's hand as the growth front leaves it. */ + _muzzleFx() { + const c = settings.bloomburst; + const g = settings.global; + + this._handPoint(_hand); + + this.ctx.bursts.spawn(BurstMode.EARTH, _hand, { + radius: c.muzzleSize * 0.25, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.45, + intensity: c.muzzleIntensity, + opacity: 0.6, + fresnel: 1.3, + displace: 0.55, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _emit.position = _hand; + _emit.radius = 0.16; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.6).setY(0.5).normalize(); + _emit.speed = c.pollenSpeed * 3; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.pollenLifetime * 0.6; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.pollenMotes.emit(Math.round(28 * g.particleCount), _emit); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + /** Moss and haze laid under the growth front while it races out. */ + _frontFx(dt) { + const c = settings.bloomburst; + const g = settings.global; + + const hazeCount = Math.round(this.hazeEmitter.tick(dt, c.hazeRate * 0.55) * g.particleCount); + if (hazeCount > 0) { + _emit.position = _pos.copy(this.position).setY(0.1); + _emit.radius = 0.55; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.hazeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = c.hazeLifetime * 0.8; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.haze.emit(hazeCount, _emit); + } + } + + /** + * A puff of spores and a mat of moss where a stalk breaks the surface. + * + * `GrowthField` hands us the index, the live world position and the live + * radius and height, so nothing here has to re-derive a metre the field has + * already resolved. + */ + _breachFx(index, position, radius, height) { + const c = settings.bloomburst; + const g = settings.global; + + _emit.position = _pos.copy(position).setY(0.05); + _emit.radius = radius * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.hazeSpeed * 1.4; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.45; + _emit.sizeVariance = 0.5; + _emit.life = c.hazeLifetime * 0.6; + _emit.lifeVariance = 0.4; + _emit.spin = 0.5; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.haze.emit(Math.round(2 * g.particleCount), _emit); + + // Only some stalks get a mat. Thirty-eight overlapping decals is a green + // disc, and the point of the mat is that the ground is *patchy* under a + // meadow that grew in half a second. + if (hash11(index * 3.7 + this._seed) < 0.45) { + this.ctx.decals.spawn(DecalType.FOAM, position, { + radius: c.mossRadius * randRange(0.6, 1.25), + life: c.mossLife, + intensity: c.mossIntensity, + colorA: getColor(c.colorMoss), + colorB: getColor(c.colorMossEdge) + }); + } + } + + /** + * Pollen off the open hearts, and haze off the ground between them. + * + * The pollen is emitted at a *flower*, not at a random point in the circle: + * `positionOf`/`heightOf` read the live placement straight back out of the + * field, so a mote leaves the head of a bloom that is actually there and + * moves with it when `zoneRadius` is dragged. + * + * @param {number} scale 0..1 — thinned out as the field dies + */ + _fieldFx(dt, scale) { + const c = settings.bloomburst; + const g = settings.global; + const time = frame.uTime.value; + const count = this.field.count; + if (count <= 0) return; + + const pollenCount = Math.round(this.pollenEmitter.tick(dt, c.pollenRate * scale) * g.particleCount); + if (pollenCount > 0) { + const index = Math.min(count - 1, (Math.random() * count) | 0); + const open = this.field.emergenceOf(index, this.age, this.growth); + if (open > 0.2) { + this.field.positionOf(index, this.growth, _pos); + _pos.y += this.field.heightOf(index, this.growth) * c.stemFrac; + _emit.position = _pos; + _emit.radius = this.field.radiusOf(index, this.growth) * c.petalWidth * 2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.pollenSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.7; + _emit.life = c.pollenLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.pollenMotes.emit(pollenCount, _emit); + } + } + + const hazeCount = Math.round(this.hazeEmitter.tick(dt, c.hazeRate * scale) * g.particleCount); + if (hazeCount > 0) { + const bearing = Math.random() * TAU; + const r = this.radius * Math.sqrt(Math.random()); + this._centrePoint(_centre); + _pos.set(_centre.x + Math.cos(bearing) * r, 0.12, _centre.z + Math.sin(bearing) * r); + _emit.position = _pos; + _emit.radius = this.radius * 0.15; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.hazeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.8; + _emit.sizeVariance = 0.5; + _emit.life = c.hazeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = time; + this.haze.emit(hazeCount, _emit); + } + } + + /** The one-shot when every petal in the circle lets go at once. */ + _releaseFx() { + const c = settings.bloomburst; + const g = settings.global; + const time = frame.uTime.value; + const R = this.radius; + + this._centrePoint(_centre); + _pos.copy(_centre).setY(c.heightNear * 0.5); + + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.burstSize * 0.3, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.9, + intensity: c.burstIntensity, + opacity: 0.45, + fresnel: 1.5, + displace: 0.5, + squash: 0.55, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the pollen the field was holding, all of it at once */ + _emit.position = _pos; + _emit.radius = R * 0.85; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.pollenSpeed * 2.4; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.08; + _emit.sizeVariance = 0.8; + _emit.life = c.pollenLifetime * 1.4; + _emit.lifeVariance = 0.55; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.pollenMotes.emit(Math.round(c.burstPollen * g.particleCount), _emit); + + /* and the petals themselves */ + _emit.radius = R * 0.9; + _emit.direction = _dir.set(0, 0.55, 0).normalize(); + _emit.speed = c.confettiSpeed; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.confettiLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = c.confettiSpin; + this.confetti.emit(Math.round(c.burstConfetti * g.particleCount), _emit); + + this.ctx.decals.spawn(DecalType.DUSTRING, _centre, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.9, + width: 0.06, + intensity: 0.7, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 16 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(0, 0); + + // The light rides the front, just off the floor. + this.position.y = 0.3; + + this._frontFx(dt); + this.ctx.shake.rumble(settings.bloomburst.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.bloomburst; + const g = settings.global; + + this._bloomTime = 0; + this._centrePoint(_centre); + + /* the mat of moss the whole field is seated on */ + this.ctx.decals.spawn(DecalType.FOAM, _centre, { + radius: this.radius * c.mossSpread, + life: c.mossLife * 1.5, + intensity: c.mossIntensity * 0.8, + colorA: getColor(c.colorMoss), + colorB: getColor(c.colorMossEdge) + }); + + /* and the ring pushed out as it takes hold */ + this.ctx.decals.spawn(DecalType.DUSTRING, _centre, { + radius: c.shockRadius * 0.8 * g.explosionIntensity, + life: 0.8, + width: 0.05, + intensity: 0.6, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + this.ctx.shake.add( + c.impactShake * 0.5 * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 14 + ); + this.lightBoost = c.lightIntensity * 0.7 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.bloomburst; + + if (t <= 1) this._bloomTime += dt; + + // The wave. `triggerRadial` fills from the middle outward; `waveInvert` + // runs it the other way, in which case the core group has to be held until + // the front has actually reached the middle or it would open first and + // give the reversal away. + const invert = !!c.waveInvert; + const front = t <= 1 ? this._waveFront() : 1; + this.field.triggerRadial( + this.age, + front, + c.waveStagger, + invert, + !invert || front >= 0.999 + ); + + const release = this._releaseAmount(); + const wither = this._witherAmount(); + + if (!this._released && release > 0) { + this._released = true; + this._releaseFx(); + } + + this._sync(release, wither); + + // The light climbs into the field and stays there. + this._centrePoint(this.position); + this.position.y = c.heightNear * saturate(c.lightHeight) * (1 - wither); + + this._fieldFx(dt, t <= 1 ? 1 : (1 - release) * 0.5); + } + + onDestroy() { + this.field.clear(); + this.pollen.setFade(0); + this.material.userData.sync(0); + this._released = false; + this._bloomTime = 0; + } + + dispose() { + this.field.dispose(); + this.material.dispose(); + this.pollen.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/verdant/GrovecallAbility.js b/src/abilities/verdant/GrovecallAbility.js new file mode 100644 index 0000000..d0b3a25 --- /dev/null +++ b/src/abilities/verdant/GrovecallAbility.js @@ -0,0 +1,1141 @@ +import { + BufferGeometry, + Float32BufferAttribute, + MeshStandardMaterial, + DoubleSide, + Color, + Vector3 +} from 'three'; +import { Ability } from '../Ability.js'; +import { GrowthField, GrowthLayout, GrowthEmerge, patchGrowthMaterial } from '../../vfx/GrowthField.js'; +import { Curtain, CurtainMode, CurtainLayout } from '../../vfx/Curtain.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, hash11, Easing, randRange } from '../../utils/math.js'; + +/* ---------------------------------------------------------------------- */ +/* Constants */ +/* ---------------------------------------------------------------------- */ + +const TAU = Math.PI * 2; + +/** Hard ceiling on trunks per cast. The editor's `trees` slider clamps here. */ +const MAX_TREES = 12; + +/** Distinct tree silhouettes — one InstancedMesh each. Three draw calls. */ +const VARIANTS = 3; + +/** Hard ceiling on light shafts. The `shafts` slider clamps here. */ +const MAX_SHAFTS = 16; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _up = new Vector3(0, 1, 0); +const _shaftUp = new Vector3(); +const _shaftAlong = new Vector3(); +const _shaftAnchor = new Vector3(); + +/** The curtain's live params. One object, refilled — never rebuilt. */ +const _shaft = {}; + +/* ---------------------------------------------------------------------- */ +/* The tree */ +/* ---------------------------------------------------------------------- */ + +/** Ring heights up the trunk, as fractions of the trunk's own length. */ +const TRUNK_T = [0, 0.2, 0.42, 0.66, 0.86, 1]; + +/** Facets around one canopy disc. Seven is ragged; eight reads as a wheel. */ +const DISC_SIDES = 7; + +/** + * A tree in `GrowthField`'s unit space: footprint inside a circle of radius 0.5 + * on `y = 0`, tip at `y = 1`. + * + * The brief for this geometry was "cheap, readable in silhouette", and those two + * pull in opposite directions until you give up on the canopy being a *volume*. + * A sphere of leaves is the obvious answer and it is wrong twice: it costs + * hundreds of triangles, and it reads as a lollipop, because a smooth outline is + * exactly what a tree does not have. What is here instead is **five shallow + * cones at random bearings** — `canopyDiscs` fans of `DISC_SIDES` triangles, + * each with a jittered rim and a lifted middle, scattered off the axis by + * `canopySpread`. Ninety triangles, and the outline is ragged from every angle + * because no two discs present the same edge. + * + * The discs are single-sided fans drawn with a `DoubleSide` material, which is + * the cheap half of the trick: from below you see the underside of the crown lit + * by the backlight term rather than a hole. + * + * The trunk is the same swept n-gon the thorn uses, taper reversed — thick at + * the ground, narrowing into the crown — with `branchStubs` short pyramids + * angled out of it. The stubs are load-bearing for the read: without them the + * crown floats, and the eye reads a mushroom. + */ +function createTreeGeometry({ + seed = 1, + sides = 5, + trunkTaper = 0.55, + trunkBase = 0.19, + trunkLean = 0.14, + canopyBase = 0.52, + branchStubs = 4, + branchLength = 0.3, + branchTilt = 0.45, + canopyDiscs = 5, + canopyRadius = 0.34, + canopySpread = 0.16, + canopyDome = 0.16, + rough = 0.4 +} = {}) { + const faces = Math.max(3, Math.min(8, Math.round(sides))); + const stubs = Math.max(0, Math.min(6, Math.round(branchStubs))); + const discs = Math.max(1, Math.min(8, Math.round(canopyDiscs))); + + // The trunk runs a little way into the crown so the two are never seen to be + // separate objects, which is the failure a canopy sitting on a pole has. + const trunkTop = Math.min(0.96, canopyBase + 0.28); + const bendAngle = hash11(seed * 1.77) * TAU; + const bendX = Math.cos(bendAngle); + const bendZ = Math.sin(bendAngle); + + const trunkRadius = (u) => Math.max(0.006, trunkBase * Math.pow(Math.max(0.02, 1 - u * 0.9), trunkTaper)); + const drift = (u) => trunkLean * Math.pow(u, 1.4); + + const positions = []; + const push = (p) => positions.push(p[0], p[1], p[2]); + + /* --- the trunk ---------------------------------------------------- */ + const angles = []; + for (let i = 0; i < faces; i++) { + const jitter = (hash11(seed * 3.13 + i * 7.7) - 0.5) * (TAU / faces) * 0.5 * rough; + angles.push((i / faces) * TAU + jitter); + } + + const rings = TRUNK_T.map((u, ringIndex) => { + const r0 = trunkRadius(u); + const dx = bendX * drift(u); + const dz = bendZ * drift(u); + const y = u * trunkTop; + return angles.map((angle, i) => { + const wobble = 1 + (hash11(seed * 11.1 + ringIndex * 13.7 + i * 3.9) - 0.5) * rough * 0.9; + const r = Math.max(0.004, r0 * wobble); + return [Math.cos(angle) * r + dx, y, Math.sin(angle) * r + dz]; + }); + }); + + for (let ring = 0; ring < rings.length - 1; ring++) { + const lower = rings[ring]; + const upper = rings[ring + 1]; + for (let i = 0; i < faces; i++) { + const j = (i + 1) % faces; + push(lower[i]); push(lower[j]); push(upper[i]); + push(lower[j]); push(upper[j]); push(upper[i]); + } + } + + // Cap both ends: the top is buried in the crown but an open tube shows as a + // black notch the moment a shaft passes behind it. + const top = rings[rings.length - 1]; + const base = rings[0]; + const topCentre = [bendX * drift(1), trunkTop, bendZ * drift(1)]; + const floorCentre = [0, 0, 0]; + for (let i = 0; i < faces; i++) { + const j = (i + 1) % faces; + push(top[i]); push(top[j]); push(topCentre); + push(floorCentre); push(base[j]); push(base[i]); + } + + /* --- the branch stubs --------------------------------------------- */ + for (let k = 0; k < stubs; k++) { + const roll = hash11(seed * 23.7 + k * 5.13); + const u = 0.45 + 0.45 * ((k + 0.4 * roll) / Math.max(1, stubs)); + const bearing = (k * 0.61803 + 0.35 * hash11(seed * 31.1 + k * 9.7)) * TAU; + const cos = Math.cos(bearing); + const sin = Math.sin(bearing); + + const r = trunkRadius(u); + const cx = bendX * drift(u) + cos * r * 0.7; + const cy = u * trunkTop; + const cz = bendZ * drift(u) + sin * r * 0.7; + + let dxx = cos; + let dyy = branchTilt; + let dzz = sin; + const dl = Math.hypot(dxx, dyy, dzz) || 1; + dxx /= dl; dyy /= dl; dzz /= dl; + + const length = branchLength * (0.7 + 0.6 * hash11(seed * 41.3 + k * 3.7)); + const tip = [cx + dxx * length, cy + dyy * length, cz + dzz * length]; + + let ux = -dzz; + let uy = 0; + let uz = dxx; + const ul = Math.hypot(ux, uy, uz) || 1; + ux /= ul; uy /= ul; uz /= ul; + const vx = dyy * uz - dzz * uy; + const vy = dzz * ux - dxx * uz; + const vz = dxx * uy - dyy * ux; + + const root = Math.max(0.01, r * 0.5); + const ring = []; + for (let i = 0; i < 3; i++) { + const a = (i / 3) * TAU + roll * TAU; + const ca = Math.cos(a) * root; + const sa = Math.sin(a) * root; + ring.push([cx + ux * ca + vx * sa, cy + uy * ca + vy * sa, cz + uz * ca + vz * sa]); + } + for (let i = 0; i < 3; i++) { + const j = (i + 1) % 3; + push(ring[i]); push(ring[j]); push(tip); + } + } + + /* --- the canopy ---------------------------------------------------- */ + for (let k = 0; k < discs; k++) { + const h1 = hash11(seed * 53.1 + k * 7.9); + const h2 = hash11(seed * 61.7 + k * 11.3); + const h3 = hash11(seed * 71.9 + k * 13.1); + + const offAngle = h1 * TAU; + const offR = canopySpread * Math.sqrt(h2); + let radius = canopyRadius * (0.7 + 0.6 * h3); + // Keep the crown inside the unit footprint: an instance is scaled by its + // own radius, and a disc poking outside 0.5 makes that radius a lie for + // every consumer that reads it back (the leaf emitter, the shafts). + radius = Math.min(radius, Math.max(0.06, 0.5 - offR)); + + const cx = bendX * drift(1) + Math.cos(offAngle) * offR; + const cy = lerp(canopyBase, 0.99, hash11(seed * 83.3 + k * 3.3)); + const cz = bendZ * drift(1) + Math.sin(offAngle) * offR; + + // A normal mostly up but tipped, so no two discs present the same edge. + const tipBearing = hash11(seed * 97.1 + k * 5.7) * TAU; + const tipAngle = (0.15 + 0.45 * hash11(seed * 101.3 + k * 2.9)) * (0.3 + 0.7 * rough); + const nx = Math.cos(tipBearing) * Math.sin(tipAngle); + const ny = Math.cos(tipAngle); + const nz = Math.sin(tipBearing) * Math.sin(tipAngle); + + let ux = -nz; + let uy = 0; + let uz = nx; + const ul = Math.hypot(ux, uy, uz) || 1; + ux /= ul; uy /= ul; uz /= ul; + const vx = ny * uz - nz * uy; + const vy = nz * ux - nx * uz; + const vz = nx * uy - ny * ux; + + const apex = [cx + nx * canopyDome, cy + ny * canopyDome, cz + nz * canopyDome]; + const rim = []; + for (let i = 0; i < DISC_SIDES; i++) { + const a = (i / DISC_SIDES) * TAU + h1 * TAU; + const rr = radius * (1 + (hash11(seed * 113.7 + k * 17.3 + i * 4.1) - 0.5) * rough * 0.8); + const ca = Math.cos(a) * rr; + const sa = Math.sin(a) * rr; + rim.push([cx + ux * ca + vx * sa, cy + uy * ca + vy * sa, cz + uz * ca + vz * sa]); + } + for (let i = 0; i < DISC_SIDES; i++) { + const j = (i + 1) % DISC_SIDES; + push(apex); push(rim[i]); push(rim[j]); + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* Bark and leaf */ +/* ---------------------------------------------------------------------- */ + +/** + * One material for the whole tree, split by height. + * + * Two materials would mean two `GrowthField`s over the same records, which is + * six draw calls for one grove. Instead `canopySplit` is a threshold on + * `vGrowLocal.y`: below it the fragment shades furrowed bark, above it mottled + * leaf. The branch stubs sit under the line and come out as bark, which is + * correct by accident and is why the line defaults just under `canopyBase`. + * + * The term that pays for the ability is **backlight**. A leaf is thin enough to + * transmit, so a canopy with the sun behind it is brighter than a canopy with + * the sun in front of it, and that inversion is most of what says "leaves" + * rather than "green plastic". It is `pow(max(dot(−N, L), 0), backlightSharp)` + * against the stage's own `frame.uLightDir` — the same vector the shafts are + * hung from, so the two effects agree about where the sun is without either of + * them being told. + */ +function createGroveMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.82, + metalness: 0.0, + flatShading: true, + // The canopy discs are single-sided fans; without this the crown is hollow + // from underneath, which is the one angle a three-metre tree is seen from. + side: DoubleSide, + transparent: true, + depthWrite: true + }); + + const uniforms = { + uTime: frame.uTime, + uLightDir: frame.uLightDir, + uColorBark: { value: new Color() }, + uColorBarkDeep: { value: new Color() }, + uColorLeaf: { value: new Color() }, + uColorLeafDeep: { value: new Color() }, + uColorLeafGlow: { value: new Color() }, + uColorSap: { value: new Color() }, + uSplit: { value: 0.5 }, + uSplitSoft: { value: 0.06 }, + uBarkGrain: { value: 0.6 }, + uBarkScale: { value: 7.0 }, + uLeafMottle: { value: 0.65 }, + uLeafScale: { value: 3.4 }, + uBacklight: { value: 1.6 }, + uBacklightSharp: { value: 2.4 }, + uSapGlow: { value: 2.2 }, + uGlow: { value: 1.0 } + }; + + patchGrowthMaterial(material, { + environment, + uniforms, + common: /* glsl */ ` + uniform float uTime; + uniform vec3 uLightDir; + uniform vec3 uColorBark; + uniform vec3 uColorBarkDeep; + uniform vec3 uColorLeaf; + uniform vec3 uColorLeafDeep; + uniform vec3 uColorLeafGlow; + uniform vec3 uColorSap; + uniform float uSplit; + uniform float uSplitSoft; + uniform float uBarkGrain; + uniform float uBarkScale; + uniform float uLeafMottle; + uniform float uLeafScale; + uniform float uBacklight; + uniform float uBacklightSharp; + uniform float uSapGlow; + uniform float uGlow; + `, + fragment: /* glsl */ ` + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + float y = clamp(vGrowLocal.y, 0.0, 1.0); + + float leafy = smoothstep(uSplit - max(uSplitSoft, 1e-3), + uSplit + max(uSplitSoft, 1e-3), y); + + // Bark furrows run *up* the trunk, so the noise is squashed hard in y and + // sampled in world space: two trunks side by side then look cut from the + // same wood instead of each carrying its own copy of the pattern. + float furrow = fbm3(vec3(vGrowWorld.xz * uBarkScale, vGrowWorld.y * uBarkScale * 0.18) + + vGrowSeed * 3.0); + furrow = furrow * 0.5 + 0.5; + vec3 bark = mix(uColorBarkDeep, uColorBark, mix(1.0, furrow, uBarkGrain)); + + // Local space for the canopy mottle, so it follows each crown's own axis + // however the instance is scaled and tipped. + float clump = fbm3(vGrowLocal * uLeafScale * 3.0 + vGrowSeed * 17.0) * 0.5 + 0.5; + vec3 leaf = mix(uColorLeafDeep, uColorLeaf, mix(1.0, smoothstep(0.3, 0.85, clump), uLeafMottle)); + + vec3 body = mix(bark, leaf, leafy); + body *= 0.66 + 0.5 * ndv; + diffuseColor.rgb *= body; + + // Transmission through a leaf: the canopy is brighter with the sun behind + // it than in front of it, which is most of what says 'leaf'. + float through = pow(clamp(dot(-N, normalize(uLightDir)), 0.0, 1.0), + max(uBacklightSharp, 0.05)); + vec3 glow = uColorLeafGlow * through * uBacklight * leafy; + glow += uColorSap * vGrowBirth * uSapGlow; + glow *= uGlow; + glow /= 1.0 + glow * 0.35; + + totalEmissiveRadiance += glow; + ` + }); + + // Where the harness's pause test looks for a patched standard material's + // live boxes — `material.uniforms` does not exist without a GL context. + material.userData.uniforms = uniforms; + return material; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * GROVECALL — a far cast that answers the circle with six trees. + * + * The call runs out along the floor to the aimed point, cracking roots as it + * goes. When it lands, a wave runs around the ring and six trunks come up out of + * the ground — three metres in half a second, canopies unfolding at the top of + * the climb. Light comes through them onto the floor, leaves shed for as long as + * the grove stands, and then it sinks back and takes the light with it. + * + * **THE TRICK — scale contrast, and the shafts are real geometry.** + * + * *Scale.* Everything else in this sandbox is knee-high or made of light. Six + * three-metre trunks arriving in half a second is the only moment in the set + * where the stage suddenly has something in it taller than the caster, and the + * whole ability is tuned around that read: `growTime` at 0.5 s so the climb is + * fast enough to be violent, `sweepTime` at 0.55 s so they arrive as a wave + * rather than a pop, `riseOvershoot` low because three metres of tree does not + * bounce the way an ice spike does, and the leaves deliberately small so the + * canopy has a sense of distance. + * + * *The shafts.* Not a post-process, not a billboard: a `Curtain` in SHAFT mode, + * anchored at `shaftTop` metres above the circle with **`-frame.uLightDir` as + * its up axis**, so each sheet is a tapered translucent volume running down the + * stage's real sun direction and ending by intersecting the floor. Two details + * make that work rather than nearly work: + * + * 1. The `along` axis handed to `setPlacement` is `lightDir × up`, not the + * cast direction. `setPlacement` re-orthogonalises the up axis against + * `along`, so handing it the cast's heading silently projects the light + * direction into a plane and the shafts come out about 25° off the sun. + * Passing an `along` that is already perpendicular to the light makes the + * orthogonalisation a no-op and the sheets slant *exactly* with the key. + * 2. The length is `shaftTop / lightDir.y`, times `shaftOvershoot`. That is the + * geometric drop to the floor, resolved every frame from the live light — so + * the shafts reach the ground and stop by intersecting it, at any sun angle, + * with no floor decal faking the pool. Wind `shaftOvershoot` under 1 and you + * can watch them hang in the air, which is the version this replaced. + * + * A cast captures the field's record dice, the curtain's per-sheet dice, one + * seed, and two timestamps — the moment the call landed and, per tree, the + * moment the bearing wave released it. Every metre, radian and second is + * re-resolved from `settings.grovecall` inside the update loop, on zero-length + * frames included: drag `zoneRadius` and the grove re-seats, drag `treeHeight` + * and the trunks grow while the clock is stopped, drag `shaftTop` and the light + * re-hangs itself from the new canopy line. + * + * **Cost.** Three instanced tree meshes + one curtain sheet mesh = **4 draw + * calls**, three shared particle systems, one dynamic light. + */ +export class GrovecallAbility extends Ability { + constructor(context) { + super('grovecall', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + const environment = this.ctx.environment; + this.material = createGroveMaterial(environment); + + /** Geometry controls, compared each frame by `syncGeometry`. */ + this._shape = { + sides: 5, + trunkTaper: 0.55, + trunkBase: 0.19, + trunkLean: 0.14, + canopyBase: 0.52, + branchStubs: 4, + branchLength: 0.3, + branchTilt: 0.45, + canopyDiscs: 5, + canopyRadius: 0.34, + canopySpread: 0.16, + canopyDome: 0.16, + rough: 0.4 + }; + this._fillShape(); + + this.field = new GrowthField(this.group, { + geometry: (variant, shape) => createTreeGeometry({ seed: 11.3 + variant * 27.1, ...shape }), + material: this.material, + shape: this._shape, + variants: VARIANTS, + capacity: MAX_TREES, + renderOrder: 2 + }); + this.field.onBreach = (index, position, radius, height) => + this._onBreach(index, position, radius, height); + + this.curtain = new Curtain({ + capacity: MAX_SHAFTS, + segmentsX: 20, + segmentsY: 10, + mode: CurtainMode.SHAFT, + layout: CurtainLayout.SCATTER, + floor: false, + renderOrder: 9, + name: 'Grovecall:shafts' + }); + this.group.add(this.curtain.object3D); + + /** The centre of the circle. A Vector3 the field holds by reference. */ + this._centre = new Vector3(); + + /** Live params handed to the field every frame. */ + this._params = { + layout: GrowthLayout.ZONE, + emerge: GrowthEmerge.PUSH, + origin: this.origin, + direction: this.direction, + side: this.side, + centre: this._centre, + length: 1 + }; + + /* --- the cast's dice and timestamps --- */ + this._seed = 0; + /** Age the call landed at. −1 until it does. */ + this._callTime = -1; + /** Metres of the run-out already paid out in root cracks. */ + this._callDistance = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Leaves. Lit and non-additive, because a leaf falling through a shaft has + // to be *lit by* the shaft rather than added on top of it — the whole point + // of the light being real is that things can be in it. + this.leaves = particles.get('grovecall.leaves', { + capacity: 2400, + shape: ParticleShape.LEAF, + additive: false, + lit: true, + curl: true, + softFade: 0.3 + }); + this.leaves.uniforms.uDrag.value = 2.4; + this.leaves.uniforms.uEndSize.value = 0.95; + this.leaves.uniforms.uSizeIn.value = 0.03; + this.leaves.uniforms.uFadeIn.value = 0.05; + this.leaves.uniforms.uFadeOut.value = 0.35; + + // Pollen hanging in the beams. Additive: this one *is* light. + this.pollen = particles.get('grovecall.pollen', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.5 + }); + this.pollen.uniforms.uDrag.value = 1.7; + this.pollen.uniforms.uEndSize.value = 0.25; + this.pollen.uniforms.uSizeIn.value = 0.1; + this.pollen.uniforms.uFadeIn.value = 0.2; + this.pollen.uniforms.uFadeOut.value = 0.55; + + // Soil off the roots. + this.soil = particles.get('grovecall.soil', { + capacity: 1400, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.soil.uniforms.uDrag.value = 0.4; + this.soil.uniforms.uEndSize.value = 0.7; + this.soil.uniforms.uFadeOut.value = 0.6; + + this.leafEmitter = new RateEmitter(); + this.pollenEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.field.count + this.curtain.instanceCount; + } + + get impactDuration() { + return Math.max(0.2, settings.grovecall.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.2, settings.grovecall.fadeTime); + } + + /** The live footprint, metres. What the circle indicator measured out. */ + get radius() { + return Math.max(0.1, settings.grovecall.zoneRadius); + } + + /** Dappled, not flickering: a slow two-rate breath, like light through leaves. */ + lightShimmer() { + return 0.86 + 0.14 * Math.sin(this.age * 2.6) * Math.sin(this.age * 5.9); + } + + /* ------------------------------------------------------------------ */ + /* Resolving — every metre comes from here, every frame */ + /* ------------------------------------------------------------------ */ + + _fillShape() { + const c = settings.grovecall; + const s = this._shape; + s.sides = c.treeSides; + s.trunkTaper = c.trunkTaper; + s.trunkBase = c.trunkBase; + s.trunkLean = c.trunkLean; + s.canopyBase = c.canopyBase; + s.branchStubs = c.branchStubs; + s.branchLength = c.branchLength; + s.branchTilt = c.branchTilt; + s.canopyDiscs = c.canopyDiscs; + s.canopyRadius = c.canopyRadius; + s.canopySpread = c.canopySpread; + s.canopyDome = c.canopyDome; + s.rough = c.treeRough; + } + + _fillParams() { + const c = settings.grovecall; + const p = this._params; + const R = this.radius; + + this.pointAt(1, this._centre).setY(0); + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + p.centre = this._centre; + + // The band the trunks stand in, both ends driven off the one radius the + // indicator drew — that sharing *is* the design (invariant I5's exception). + p.radius = R * c.ringOuter; + p.innerRadius = Math.min(R * c.ringOuter, R * c.ringInner); + p.radialCurve = c.radialCurve; + p.radialJitter = c.radialJitter; + p.angleJitter = c.angleJitter; + + p.heightNear = c.heightNear; + p.height = c.treeHeight; + p.heightCurve = c.heightCurve; + p.heightJitter = c.heightJitter; + p.crown = 0; + p.peak = 1; + p.rubble = 0; + + p.radiusNear = c.treeRadius; + p.radius2 = c.treeRadius2; + p.radiusCurve = c.radiusCurve; + p.radiusJitter = c.radiusJitter; + + p.lean = c.lean; + p.leanJitter = c.leanJitter; + p.leanRamp = 1; + p.leanForward = c.leanForward; + p.leanOutward = c.leanOutward; + p.twist = c.twist; + p.tilt = c.tilt; + + p.riseTime = c.growTime; + p.riseOvershoot = c.riseOvershoot; + p.settle = c.settle; + p.springRate = c.springRate; + p.emergeSink = c.emergeSink; + p.birthScale = c.birthScale; + p.birthFade = c.birthFade; + p.breachAt = c.breachAt; + p.sinkDepth = c.sinkDepth; + + p.randomness = settings.global.randomness; + return p; + } + + /** Push the palette and every shading control into the bark and leaf. */ + _syncMaterial() { + const c = settings.grovecall; + const g = settings.global; + const u = this.material.userData.uniforms; + + u.uColorBark.value.copy(getColor(c.colorBark)); + u.uColorBarkDeep.value.copy(getColor(c.colorBarkDeep)); + u.uColorLeaf.value.copy(getColor(c.colorLeaf)); + u.uColorLeafDeep.value.copy(getColor(c.colorLeafDeep)); + u.uColorLeafGlow.value.copy(getColor(c.colorLeafGlow)); + u.uColorSap.value.copy(getColor(c.colorSap)); + + u.uSplit.value = c.canopySplit; + u.uSplitSoft.value = c.canopySoft; + u.uBarkGrain.value = c.barkGrain * g.shaderIntensity; + u.uBarkScale.value = c.barkScale * g.noiseFrequency; + u.uLeafMottle.value = c.leafMottle * g.shaderIntensity; + u.uLeafScale.value = c.leafScale * g.noiseFrequency; + u.uBacklight.value = c.backlight * g.shaderIntensity; + u.uBacklightSharp.value = c.backlightSharp; + u.uSapGlow.value = c.sapGlow; + u.uGlow.value = c.groveGlow * g.glow; + + this.material.roughness = c.leafRough; + this.material.opacity = c.groveOpacity * g.opacity; + } + + /** + * How far the grove has arrived, 0..1 — the clock the shafts ride. + * + * Measured from the call, over the wave plus one tree's climb, so the light is + * fully up the moment the last canopy finishes opening rather than a beat + * before or a second after. + */ + _grown() { + if (this._callTime < 0) return 0; + const c = settings.grovecall; + const span = Math.max(0.05, c.sweepTime + c.growTime); + return saturate((this.age - this._callTime) / span); + } + + /** + * Hang the shafts off the stage's own sun and re-resolve every metre. + * + * See the class header for why `along` is `lightDir × up` rather than the cast + * heading, and why the length is a geometric drop rather than a slider. + * + * @param {number} rise 0..1 how far the shafts have come in + * @param {number} fade 0..1 master, taken to zero as the grove sinks + */ + _syncShafts(rise, fade) { + const c = settings.grovecall; + const g = settings.global; + + _shaftUp.copy(frame.uLightDir.value).normalize(); + const vertical = Math.max(0.25, _shaftUp.y); + // Down the light, not up it: the sheet grows from its anchor along this. + _shaftUp.negate(); + + // Perpendicular to the light by construction, so `setPlacement`'s + // re-orthogonalisation leaves the up axis exactly on the sun. + _shaftAlong.crossVectors(_shaftUp, _up); + if (_shaftAlong.lengthSq() < 1e-6) _shaftAlong.copy(this.direction); + _shaftAlong.normalize(); + + _shaftAnchor.copy(this._centre); + _shaftAnchor.y = c.shaftTop; + this.curtain.setPlacement(_shaftAnchor, _shaftAlong, _shaftUp); + + const drop = (c.shaftTop / vertical) * c.shaftOvershoot; + + _shaft.count = fade > 0.002 ? Math.min(MAX_SHAFTS, Math.round(c.shafts)) : 0; + _shaft.radius = this.radius * c.shaftSpread; + _shaft.scatter = c.shaftScatter; + + _shaft.width = c.shaftWidth; + _shaft.widthJitter = c.shaftWidthJitter; + _shaft.height = drop; + _shaft.heightJitter = c.shaftHeightJitter; + _shaft.base = 0; + _shaft.taper = c.shaftTaper; + _shaft.lean = c.shaftLean; + _shaft.leanJitter = c.shaftLeanJitter; + _shaft.rise = rise; + _shaft.riseSpread = c.shaftRiseSpread; + + _shaft.rippleAmp = c.shaftRipple; + _shaft.rippleLength = c.shaftRippleLength; + _shaft.rippleSpeed = c.shaftRippleSpeed * g.noiseSpeed; + _shaft.rippleCurve = 1.0; + _shaft.foldAmp = c.shaftFold; + _shaft.foldLength = c.shaftFoldLength; + _shaft.foldSpeed = c.shaftFoldSpeed * g.noiseSpeed; + _shaft.rippleNoise = 0; + _shaft.phaseSpread = 1; + + _shaft.alphaBase = c.shaftAlphaBase; + _shaft.alphaTop = c.shaftAlphaTop; + _shaft.alphaCurve = c.shaftAlphaCurve; + _shaft.emissionBase = c.shaftEmissionBase; + _shaft.emissionTop = c.shaftEmissionTop; + _shaft.emissionCurve = c.shaftEmissionCurve; + + _shaft.body = c.shaftBody; + _shaft.footFade = c.shaftFootFade; + _shaft.headFade = c.shaftHeadFade; + _shaft.edgeFade = c.shaftEdgeFade; + _shaft.graze = c.shaftGraze; + _shaft.grazeFloor = c.shaftGrazeFloor; + _shaft.softFade = c.shaftSoftFade; + _shaft.tintSpread = c.shaftTintSpread; + _shaft.opacity = c.shaftOpacity * fade * g.opacity; + _shaft.glow = c.shaftGlow * g.glow; + + _shaft.coreWidth = c.shaftCore; + _shaft.canopy = c.shaftCanopy; + _shaft.canopySoft = c.shaftCanopySoft; + _shaft.canopyScale = c.shaftCanopyScale * g.noiseFrequency; + _shaft.mote = c.shaftMote; + _shaft.moteScale = c.shaftMoteScale; + _shaft.moteSize = c.shaftMoteSize; + _shaft.moteDrift = c.shaftMoteDrift * g.noiseSpeed; + + _shaft.colorCore = c.colorShaft; + _shaft.colorA = c.colorShaftEdge; + _shaft.colorMote = c.colorShaftMote; + _shaft.colorBody = c.colorShaftBody; + + this.curtain.update(this.age, _shaft); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.grovecall; + + this.leafEmitter.reset(); + this.pollenEmitter.reset(); + this._seed = Math.random() * 100; + this._callTime = -1; + this._callDistance = 0; + + this.field.plant(c.trees, 0); + this.curtain.roll(this._seed); + this.curtain.visible = true; + + this._fillShape(); + this.field.syncGeometry(this._shape); + this._syncMaterial(); + this.field.update(this.age, this._fillParams(), 0); + this._syncShafts(0, 0); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** One trunk breaking the surface: roots split the floor and soil goes up. */ + _onBreach(index, position, radius, _height) { + const c = settings.grovecall; + const g = settings.global; + const time = frame.uTime.value; + + _emit.position = position; + _emit.radius = radius * 1.2; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.soilSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.75; + _emit.life = c.soilLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 10; + _emit.tint = null; + _emit.time = time; + this.soil.emit(Math.round(c.soilBreach * g.particleCount), _emit); + + this.ctx.decals.spawn(DecalType.CRACK, position, { + radius: c.rootRadius * randRange(0.8, 1.25), + life: c.rootLife, + width: c.rootWidth, + intensity: c.rootIntensity, + colorA: getColor(c.colorRootA), + colorB: getColor(c.colorRootB) + }); + this.ctx.decals.spawn(DecalType.DUSTRING, position, { + radius: c.duffRadius * randRange(0.8, 1.2), + life: c.duffLife, + intensity: c.duffIntensity, + colorA: getColor(c.colorDuffA), + colorB: getColor(c.colorDuffB) + }); + + // The canopy opening throws its own litter, from the crown rather than the + // roots — so the two events read as one tree rather than as a floor effect + // and a separate leaf effect. + _pos.copy(position); + _pos.y += this.field.heightOf(index, this._params) * 0.8; + _emit.position = _pos; + _emit.radius = this.field.radiusOf(index, this._params) * 1.1; + _emit.direction = _dir.set(0, -0.2, 0); + _emit.speed = c.leafSpeed * 1.6; + _emit.spread = 1.0; + _emit.size = 0.16; + _emit.life = c.leafLifetime; + _emit.spin = c.leafSpin; + this.leaves.emit(Math.round(c.leafBurst * g.particleCount), _emit); + } + + /** Root cracks laid along the floor as the call runs out to the circle. */ + _callFx() { + const c = settings.grovecall; + const step = 1 / Math.max(0.05, c.callRate); + + while (this.front - this._callDistance >= step) { + this._callDistance += step; + const s = saturate(this._callDistance / this.length); + this.pointAt(s, _pos); + const wander = 0.4 + s * 0.6; + _pos.x += this.side.x * randRange(-wander, wander); + _pos.z += this.side.z * randRange(-wander, wander); + + this.ctx.decals.spawn(DecalType.CRACK, _pos, { + radius: c.callRadius * randRange(0.7, 1.4), + life: c.callLife, + width: c.rootWidth, + intensity: c.rootIntensity * 0.7, + colorA: getColor(c.colorRootA), + colorB: getColor(c.colorRootB) + }); + } + } + + /** Leaves shedding off the canopies and pollen hanging in the beams. */ + _groveFx(dt, scale) { + const c = settings.grovecall; + const g = settings.global; + const time = frame.uTime.value; + const count = this.field.count; + if (count < 1) return; + + const leafCount = Math.round(this.leafEmitter.tick(dt, c.leafRate * scale) * g.particleCount); + if (leafCount > 0) { + // Off a canopy, not off the ring: a leaf that starts at ground level and + // rises is a spark, and the read collapses. + const i = Math.min(count - 1, (Math.random() * count) | 0); + const emerge = this.field.emergenceOf(i, this.age, this._params); + if (emerge > 0.4) { + this.field.positionOf(i, this._params, _pos); + _pos.y += this.field.heightOf(i, this._params) * randRange(0.62, 0.98); + _emit.position = _pos; + _emit.radius = this.field.radiusOf(i, this._params); + _emit.direction = _dir.set(0, -0.4, 0); + _emit.speed = c.leafSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.15; + _emit.sizeVariance = 0.7; + _emit.life = c.leafLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = c.leafSpin; + _emit.tint = null; + _emit.time = time; + this.leaves.emit(leafCount, _emit); + } + } + + const pollenCount = Math.round(this.pollenEmitter.tick(dt, c.pollenRate * scale) * g.particleCount); + if (pollenCount > 0) { + const angle = Math.random() * TAU; + const r = this.radius * c.shaftSpread * Math.sqrt(Math.random()); + _pos.copy(this._centre); + _pos.x += Math.cos(angle) * r; + _pos.z += Math.sin(angle) * r; + _pos.y = c.shaftTop * randRange(0.15, 0.85); + _emit.position = _pos; + _emit.radius = c.shaftWidth * 0.5; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.pollenSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.size = 0.08; + _emit.sizeVariance = 0.6; + _emit.life = c.pollenLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.pollen.emit(pollenCount, _emit); + } + } + + _syncParticles() { + const c = settings.grovecall; + const g = settings.global; + + this.leaves.setGradient( + getColor(c.colorLeafA), + getColor(c.colorLeafB), + getColor(c.colorLeafC), + getColor(c.colorLeafD) + ); + this.leaves.uniforms.uGravity.value.set(0, c.leafGravity, 0); + this.leaves.uniforms.uSizeScale.value = c.leafSize * g.particleSize * 7; + this.leaves.uniforms.uLifeScale.value = c.leafLifetime * 0.5 * g.particleLifetime; + this.leaves.uniforms.uSpeedScale.value = g.particleSpeed; + this.leaves.uniforms.uOpacity.value = g.opacity; + this.leaves.uniforms.uTurbulence.value = c.leafDrift * g.turbulence; + + this.pollen.setGradient( + getColor(c.colorPollenA), + getColor(c.colorPollenB), + getColor(c.colorPollenC), + getColor(c.colorPollenD) + ); + this.pollen.uniforms.uGravity.value.set(0, c.pollenRise, 0); + this.pollen.uniforms.uSizeScale.value = c.pollenSize * g.particleSize * 7; + this.pollen.uniforms.uLifeScale.value = c.pollenLifetime * 0.5 * g.particleLifetime; + this.pollen.uniforms.uSpeedScale.value = g.particleSpeed; + this.pollen.uniforms.uOpacity.value = g.opacity; + this.pollen.uniforms.uGlow.value = 0.9 * g.glow; + this.pollen.uniforms.uTurbulence.value = c.pollenTurbulence * g.turbulence; + + this.soil.setGradient( + getColor(c.colorSoilA), + getColor(c.colorSoilB), + getColor(c.colorSoilC), + getColor(c.colorSoilD) + ); + this.soil.uniforms.uGravity.value.set(0, c.soilGravity, 0); + this.soil.uniforms.uSizeScale.value = c.soilSize * g.particleSize * 7; + this.soil.uniforms.uLifeScale.value = g.particleLifetime; + this.soil.uniforms.uSpeedScale.value = g.particleSpeed; + this.soil.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.grovecall; + const p = this._fillParams(); + + this._fillShape(); + this.field.syncGeometry(this._shape); + this._syncMaterial(); + this._syncParticles(); + + // Nothing is triggered yet; the field is entirely below the floor and its + // matrices are parked. It is still updated every frame so a slider dragged + // during the run-out reshapes what is about to arrive. + this.field.update(this.age, p, 0); + this._syncShafts(0, 0); + + this._callFx(); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + /** + * The call lands: a wave runs around the ring and releases the trunks. + * + * The bearing is taken from each tree's **live** position rather than from its + * record's angle die, so the wave starts on the side of the ring nearest the + * caster whatever `angleJitter` and `radialJitter` did to the layout. The + * delays are stamped once, here, because they are events — after this frame + * `sweepTime` no longer moves a wave that has already been dealt. + */ + onImpact() { + const c = settings.grovecall; + const g = settings.global; + const time = frame.uTime.value; + const p = this._fillParams(); + + this._callTime = this.age; + + const count = this.field.count; + for (let i = 0; i < count; i++) { + this.field.positionOf(i, p, _pos); + // Bearing measured against the heading, so 0 is the far side and the + // near side is half a turn away; the wave then breaks toward the camera. + const bearing = Math.atan2(_pos.x - this._centre.x, _pos.z - this._centre.z); + const heading = Math.atan2(this.direction.x, this.direction.z); + let turn = (bearing - heading) / TAU; + turn -= Math.floor(turn); // 0..1 + const delay = turn * c.sweepTime + hash11(i * 7.31 + this._seed) * c.sweepStagger; + this.field.triggerIndex(this.age, i, delay); + } + + _centre.copy(this._centre); + _pos.copy(this._centre).setY(c.treeHeight * 0.35); + + this.ctx.bursts.spawn(BurstMode.STORM, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.8, + intensity: c.burstIntensity, + opacity: 0.8, + fresnel: 1.4, + displace: 0.6, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _emit.position = _pos; + _emit.radius = this.radius * 0.9; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.leafSpeed * 2.4; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.8; + _emit.life = c.leafLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = c.leafSpin * 1.5; + _emit.tint = null; + _emit.time = time; + this.leaves.emit(Math.round(c.burstLeaves * g.particleCount), _emit); + + _emit.position = _centre; + _emit.radius = this.radius * 0.7; + _emit.speed = c.soilSpeed * 1.5; + _emit.size = 0.13; + _emit.life = c.soilLifetime * 1.2; + _emit.spin = 12; + this.soil.emit(Math.round(c.burstSoil * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 16 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + onFade(dt, t) { + const p = this._fillParams(); + + this._fillShape(); + this.field.syncGeometry(this._shape); + this._syncMaterial(); + this._syncParticles(); + + const retract = t <= 1 ? 0 : saturate(t - 1); + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(retract); + + this.field.update(this.age, p, retract); + // The shafts hold at full while the grove stands and die with the canopies + // that were gating them — a beam that outlives its tree reads as a bug. + this._syncShafts(this._grown(), fade); + + this._groveFx(dt, fade * (t <= 1 ? 1 : 0.35)); + + // The light sits in the middle of the grove once it has arrived. + this.position.copy(this._centre).setY(settings.grovecall.treeHeight * 0.45); + } + + onDestroy() { + this.field.clear(); + this.curtain.reset(); + this._callTime = -1; + } + + dispose() { + this.field.dispose(); + this.curtain.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/verdant/MyceliumAbility.js b/src/abilities/verdant/MyceliumAbility.js new file mode 100644 index 0000000..772636d --- /dev/null +++ b/src/abilities/verdant/MyceliumAbility.js @@ -0,0 +1,618 @@ +import { Mesh, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { createMyceliumSeepMaterial } from '../../materials/MyceliumSeepMaterial.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { acquireGroundQuad, releaseGroundQuad } from '../../vfx/quads.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _mid = new Vector3(); +const _far = new Vector3(); +const _hand = new Vector3(); + +/** + * MYCELIAL WEB — the ability that is under the floor. + * + * **THE TRICK: nothing here draws a network.** A fungal mat runs out beneath + * the flagstones and the only thing on screen is light *escaping* — through the + * mortar courses, through the open pores, and nowhere else. The visible pattern + * belongs to the stone. The mycelium only decides how brightly each part of the + * stone is lit from underneath. + * + * That inversion is the whole slot, and it is one multiply: + * + * ``` + * alpha = cover * openness * lit * slotted; + * ``` + * + * `lit` is the buried network's radiance and `openness` is a mask built out of + * `world/Ground.js`'s **own expressions** — its `fbm3(wp * 0.018)` macro + * variation, its `fbm3(wp * 0.06 + 3.0)` sheen patches, its `snoise01(wp * 0.7)` + * grain, at those frequencies and those phase offsets. The mortar course is the + * *level set* of the first field, measured into metres by dividing the contour + * distance by the field's own gradient; the polished patches close the light + * off entirely, because polished stone is sound stone. + * + * The first version did not do that. It drew its own perfectly respectable + * crack field — a ridged fbm at about two cycles a metre — and it was wrong in + * a way that took a long time to name: the cracks did not line up with + * anything. The floor has structure, the glow had *different* structure, and + * two uncorrelated patterns on one surface read as two surfaces. That is a + * decal on a floor, which is precisely what the brief forbids. Sharing the + * floor's actual numbers is a real coupling — change the floor's macro + * frequency and someone has to come back here — and it is worth having, because + * the alternative is the decal. + * + * The second thing that sells "under" is **depth**, and it is three terms, all + * physical and all sliders: a strand buried deeper is blurred wider + * (`webCore + depth * webSpread`), dimmer (`exp(-depth * webAbsorb)`), and + * **parallaxed** — sampled at `lane - viewXZ * depth / viewY`, so the web + * slides against the cracks it is seen through as the camera orbits. Take + * `webParallax` to zero and the whole cast flattens into a sticker on the + * frame; it is the cheapest convincing term in the file. + * + * ## The beats + * + * 1. **run** — the growth front travels the line. The lane behind it lights. + * 2. **knot** — impact. The network keeps creeping past the target for + * `creepTime`, reaching `creepReach` further than the cast did, and a patch + * of stone at the far end goes visibly damp: a `GroundField(WET)`, which is + * alpha-blended and therefore genuinely *darker* than the floor. The glow + * quad cannot do that — one mesh cannot both add and subtract — and the + * split is why the wet stone reads as wet instead of as lit. + * 3. **rot** — the fade. The mat is abandoned from the caster outward, so the + * near end goes dark first and the far end lingers, and the damp patch + * dries from its rim inward. The two fronts run in opposite directions, + * which is what stops the aftermath reading as one dimmer switch. + * + * ## What a cast captures + * + * One seed and one timestamp. Not one metre: the lane's half-width, the hyphal + * pitch, every burial depth, the pore frequency and the mortar width are all + * resolved from `settings.mycelium` inside the update loop, on a zero-length + * frame included. Pause mid-cast and drag `seamWidth` and the mortar the light + * comes out of gets wider under a standing effect. + * + * **Two draw calls** — the seep quad and the damp — plus two shared particle + * systems. + */ +export class MyceliumAbility extends Ability { + constructor(context) { + super('mycelium', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The seep quad. One mesh, one draw call, and the only thing in the cast + * that carries the trick. + * + * It is `LAYER.VFX` and `renderOrder` under the damp deliberately: the wet + * stone is a shaded mark and has to be composited before light is added on + * top of it, or the glow gets multiplied down by the very stain it caused. + */ + this.seepGeometry = acquireGroundQuad(); + this.seepMaterial = createMyceliumSeepMaterial(); + this.seep = new Mesh(this.seepGeometry, this.seepMaterial); + this.seep.name = 'MyceliumSeep'; + this.seep.layers.set(LAYER.VFX); + this.seep.renderOrder = 8; + this.seep.frustumCulled = false; + this.group.add(this.seep); + + /** + * The damp. A disc rather than a lane, and that is a decision rather than a + * limitation of the module: the network is a lane, but the *water* it + * pushes ahead of itself pools where the network stops. A soaked stripe the + * whole length of the cast made the ability read as a spill; a soaked patch + * at the far end reads as something arriving. + */ + this.damp = new GroundField(this.group, { + mode: GroundMode.WET, + additive: false, + depthTest: true, + layer: LAYER.VFX, + renderOrder: 6, + name: 'MyceliumDamp' + }); + /** Live params, allocated once and refilled every frame — I1. */ + this.dampParams = groundFieldParams(); + this.dampParams.centre = new Vector3(); + + /** Handed to the seep material every frame. One object, reused. */ + this._state = { + length: 1, + grow: 0, + retreat: 0, + fade: 1, + seed: 0 + }; + + /** Re-rolled per cast so two mats do not partition the floor the same way. */ + this._seed = 0; + /** Seconds since the front landed. Drives the creep and nothing else. */ + this._knotTime = 0; + /** Metres of front travel already paid out in spore puffs. */ + this._puffDistance = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + /** + * Spores. Additive and curled, emitted at the floor and drifting up out of + * the seams — the only part of the ability that is above the stone, and + * kept deliberately sparse so it reads as *leakage* rather than as a plume. + */ + this.spores = particles.get('mycelium.spores', { + capacity: 1400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.35 + }); + this.spores.uniforms.uEndSize.value = 0.4; + this.spores.uniforms.uSizeIn.value = 0.18; + this.spores.uniforms.uFadeIn.value = 0.22; + this.spores.uniforms.uFadeOut.value = 0.5; + + /** + * Vapour off the damp stone. Non-additive so it genuinely occludes, and + * heavy — this is cold air over a wet flag, not smoke off a fire. + */ + this.vapour = particles.get('mycelium.vapour', { + capacity: 900, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.vapour.uniforms.uDrag.value = 2.6; + this.vapour.uniforms.uEndSize.value = 2.8; + this.vapour.uniforms.uSizeIn.value = 0.2; + this.vapour.uniforms.uFadeIn.value = 0.25; + this.vapour.uniforms.uFadeOut.value = 0.35; + + this.sporeEmitter = new RateEmitter(); + this.vapourEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + // Two meshes, neither instanced. The HUD readout is honest about it. + return 2; + } + + get impactDuration() { + const c = settings.mycelium; + return Math.max(0.2, (c.creepTime + c.holdTime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.2, settings.mycelium.rotTime); + } + + /** + * A slow breathe, not a flicker. + * + * The light this ability owns is under the floor as far as the fiction is + * concerned, so it has no business snapping: anything above about a hertz + * reads as electrical, and this is the one school where that is wrong. + */ + lightShimmer() { + const c = settings.mycelium; + return 1 - saturate(c.lightBreathe) * 0.5 * (1 - Math.cos(this.age * c.lightBreatheRate * Math.PI * 2)); + } + + /* ------------------------------------------------------------------ */ + /* The clocks — pure functions of live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the cast leaves the caster. */ + _handPoint(out) { + const c = settings.mycelium; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** How far past the target the mat has crept, 0..1. */ + _creep() { + const c = settings.mycelium; + if (this.phase === AbilityPhase.TRAVEL) return 0; + return Easing.outCubic(saturate(this._knotTime / Math.max(0.05, c.creepTime))); + } + + /** The mat's full length in metres, creep included. Never stored. */ + _reach() { + const c = settings.mycelium; + return this.length * (1 + Math.max(0, c.creepReach) * this._creep()); + } + + /** + * Half-width of the lane at `s` along it, metres. + * + * Mirrors the shader's taper exactly, so the spores the CPU seeds land inside + * the band the GPU is drawing rather than near it. + */ + _laneHalf(s) { + const c = settings.mycelium; + return Math.max(0.02, c.laneHalfWidth * (1 - saturate(c.laneTaper) * saturate(s))); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.sporeEmitter.reset(); + this.vapourEmitter.reset(); + this._knotTime = 0; + this._puffDistance = 0; + + // The one thing a cast captures, besides the timestamp the front landed on. + this._seed = Math.random() * 100; + + this.damp.clearMarks(); + this.damp.setVisible(false); + this.seep.visible = true; + + this._sync(0, 1); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into the seep quad, the damp and both particle + * systems. + * + * @param {number} retreat 0..1 — how far the mat has been abandoned from the + * caster outward + * @param {number} fade 0..1 — the master dim + */ + _sync(retreat, fade) { + const c = settings.mycelium; + const g = settings.global; + const creep = this._creep(); + const reach = this._reach(); + + /* --- the seep quad -------------------------------------------------- */ + const state = this._state; + state.length = reach; + // Divided by the creep, so the *drawn* front sits at the same world point + // during travel however far the mat is eventually going to reach. + state.grow = + this.phase === AbilityPhase.TRAVEL ? saturate((this.u * this.length) / Math.max(reach, 0.01)) : 1; + state.retreat = retreat; + state.fade = fade; + state.seed = this._seed; + + const quad = this.seepMaterial.userData.sync(state); + + // The quad is centred on the middle of the mat and yawed onto the heading — + // both re-derived here, because `reach` grows while the mat creeps and a + // captured centre would leave the far end outside the canvas. + this.pointAt(0.5 * (reach / Math.max(this.length, 0.01)), _mid); + this.seep.position.set(_mid.x, c.seepHeight, _mid.z); + this.seep.rotation.set(0, Math.atan2(this.direction.x, this.direction.z), 0); + this.seep.scale.set(quad.x, 1, quad.y); + this.seep.visible = fade > 0.004; + + /* --- the damp ------------------------------------------------------- */ + this.pointAt(1, _far); + const p = this.dampParams; + p.centre.copy(_far); + p.yaw = 0; + p.height = c.dampHeight; + p.radius = Math.max(0.1, c.dampRadius); + p.grow = creep; + // Dries from the rim inward while the glow dies back from the caster + // outward. Two fronts in opposite directions; one dimmer switch is what it + // looks like without that. + p.recede = retreat; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.dampEdge; + p.ragged = c.dampRagged; + p.raggedScale = c.dampRaggedScale; + p.warp = c.dampWarp; + p.relief = c.dampRelief; + p.ambient = c.dampAmbient; + p.specular = c.dampSpecular; + p.gloss = c.dampGloss; + p.cell = c.dampCell; + p.lift = c.dampLift; + p.depth = c.dampDepth; + p.flow = c.dampFlow; + p.detail = c.dampDetail; + p.speed = c.dampSpeed; + p.windAngle = c.dampWindAngle; + p.emissive = c.dampEmissive * g.glow; + p.opacity = c.dampOpacity; + p.depthFade = c.dampDepthFade; + p.colorBase = c.colorDampBase; + p.colorEdge = c.colorDampEdge; + p.colorGlow = c.colorDampGlow; + p.colorDeep = c.colorDampDeep; + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + + this.damp.setVisible(creep > 0.001 && fade > 0.004); + this.damp.update(p); + + /* --- the two particle systems --------------------------------------- */ + this.spores.setGradient( + getColor(c.colorSporeA), + getColor(c.colorSporeB), + getColor(c.colorSporeC), + getColor(c.colorSporeD) + ); + this.spores.uniforms.uGravity.value.set(0, c.sporeSag, 0); + this.spores.uniforms.uDrag.value = Math.max(0.05, c.sporeDrag); + this.spores.uniforms.uSizeScale.value = c.sporeSize * g.particleSize * 7; + this.spores.uniforms.uLifeScale.value = c.sporeLifetime * 0.5 * g.particleLifetime; + this.spores.uniforms.uSpeedScale.value = c.sporeRise * g.particleSpeed; + this.spores.uniforms.uOpacity.value = g.opacity; + this.spores.uniforms.uGlow.value = c.sporeGlow * g.glow; + this.spores.uniforms.uTurbulence.value = c.sporeTurbulence * g.turbulence; + + this.vapour.setGradient( + getColor(c.colorVapourA), + getColor(c.colorVapourB), + getColor(c.colorVapourC), + getColor(c.colorVapourD) + ); + this.vapour.uniforms.uGravity.value.set(0, c.vapourRise, 0); + this.vapour.uniforms.uSizeScale.value = c.vapourSize * g.particleSize; + this.vapour.uniforms.uLifeScale.value = c.vapourLifetime * 0.5 * g.particleLifetime; + this.vapour.uniforms.uSpeedScale.value = c.vapourSpeed * g.particleSpeed; + this.vapour.uniforms.uOpacity.value = c.vapourOpacity * g.opacity; + this.vapour.uniforms.uTurbulence.value = 0.25 * g.turbulence; + } + + /** The cough of spores at the hand as the mat is put into the ground. */ + _muzzleFx() { + const c = settings.mycelium; + const g = settings.global; + + this._handPoint(_hand); + + this.ctx.bursts.spawn(BurstMode.EARTH, _hand, { + radius: c.muzzleSize * 0.3, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.55, + intensity: c.muzzleIntensity, + opacity: 0.35, + fresnel: 1.2, + displace: 0.4, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this._emitSpores(Math.round(14 * g.particleCount), _hand, 0.2); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.35 * g.explosionIntensity; + } + + /** Release `count` spores in a ball of `spread` metres about `at`. */ + _emitSpores(count, at, spread) { + if (count <= 0) return; + const c = settings.mycelium; + + _emit.position = at; + _emit.radius = spread; + _emit.direction = _dir.set(0, 1, 0); + // Unit speed: the metres per second live in `uSpeedScale`, rewritten every + // frame, which is what keeps `sporeRise` a live slider on standing spores. + _emit.speed = 1; + _emit.speedVariance = c.sporeSpeedVariance; + _emit.spread = c.sporeSpread; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.65; + _emit.life = 1; + _emit.lifeVariance = 0.45; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.spores.emit(count, _emit); + } + + /** + * Spores leaking out along the lit part of the mat. + * + * @param {number} scale 0..1 — thinned out once the mat is only holding + */ + _laneFx(dt, scale) { + const c = settings.mycelium; + const g = settings.global; + const reach = this.phase === AbilityPhase.TRAVEL ? Math.max(0.02, this.u) : 1; + + const sporeCount = Math.round(this.sporeEmitter.tick(dt, c.sporeRate * scale) * g.particleCount); + if (sporeCount > 0) { + const s = Math.random() * reach; + this.pointAt(s, _pos); + // Off the spine by a real fraction of the lane, so the leak follows the + // band rather than dribbling down the centre line. + const half = this._laneHalf(s); + _pos.addScaledVector(this.side, randRange(-half, half)); + _pos.y = c.sporeBirthHeight; + this._emitSpores(sporeCount, _pos, half * 0.25 + 0.05); + } + + const vapourCount = Math.round(this.vapourEmitter.tick(dt, c.vapourRate * scale) * g.particleCount); + if (vapourCount > 0) { + // Vapour comes off the *damp*, which only exists once the front has + // landed; before that it trickles off the growth front instead. + const at = this.phase === AbilityPhase.TRAVEL ? this.u : 1; + this.pointAt(at, _pos); + const bearing = Math.random() * Math.PI * 2; + const spill = Math.max(0.1, c.dampRadius) * 0.75 * Math.sqrt(Math.random()); + _pos.x += Math.cos(bearing) * spill; + _pos.z += Math.sin(bearing) * spill; + _pos.y = c.vapourBirthHeight; + + _emit.position = _pos; + _emit.radius = Math.max(0.1, c.dampRadius) * 0.2; + _emit.direction = _dir.set(Math.cos(bearing) * 0.4, 1, Math.sin(bearing) * 0.4).normalize(); + _emit.speed = 1; + _emit.speedVariance = 0.55; + _emit.spread = 0.5; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.7; + _emit.sizeVariance = 0.5; + _emit.life = 1; + _emit.lifeVariance = 0.4; + _emit.spin = 0.2; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.vapour.emit(vapourCount, _emit); + } + } + + /** + * A puff of spores every `puffRate` metres of front travel. + * + * Distance-driven rather than time-driven, so the trail of puffs is evenly + * spaced along the floor whatever `speed` is set to. The bolt's ground burns + * use the same accumulator for the same reason. + */ + _frontFx() { + const c = settings.mycelium; + const g = settings.global; + const step = 1 / Math.max(0.05, c.puffRate); + + while (this.front - this._puffDistance >= step) { + this._puffDistance += step; + const s = saturate(this._puffDistance / this.length); + this.pointAt(s, _pos); + const half = this._laneHalf(s); + _pos.addScaledVector(this.side, randRange(-half, half)); + _pos.y = c.sporeBirthHeight; + this._emitSpores(Math.round(c.puffSpores * g.particleCount), _pos, half * 0.3 + 0.06); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.mycelium; + + this._sync(0, 1); + + // The light rides the growth front, and it rides it *low* — this is light + // coming out of the floor, so a lamp at chest height would give the whole + // thing away. + this.position.y = c.lightHeight; + + this._frontFx(); + this._laneFx(dt, 1); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.mycelium; + const g = settings.global; + + this._knotTime = 0; + this.pointAt(1, _far); + + /* the mat knotting up — a low, wide, almost transparent shell */ + _pos.copy(_far).setY(c.dampHeight + 0.05); + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 1.0, + intensity: c.burstIntensity, + opacity: 0.3, + fresnel: 1.3, + displace: 0.45, + squash: 0.22, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _pos.copy(_far).setY(c.sporeBirthHeight); + this._emitSpores(Math.round(c.burstSpores * g.particleCount), _pos, Math.max(0.1, c.dampRadius) * 0.6); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 9 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.mycelium; + + if (t <= 1) this._knotTime += dt; + + // `t` runs 0..1 while the mat holds, then 1..2 while it rots. The die-back + // is eased *out* and the dim eased *in*: the mat lets go of the near end + // straight away and then hangs on to whatever is left, which is what a + // living thing being starved looks like and a linear ramp is not. + const rot = t <= 1 ? 0 : saturate(t - 1); + const retreat = Easing.outQuad(rot) * saturate(c.dieBack); + const fade = 1 - Easing.inQuad(rot); + + this._sync(retreat, fade); + + // The light settles into the knot at the far end and sinks with it. + this.pointAt(lerp(1, 1 + Math.max(0, c.creepReach) * 0.5, this._creep()), this.position); + this.position.y = c.lightHeight * (1 - 0.5 * rot); + + this._laneFx(dt, fade * (t <= 1 ? 0.7 : 0.3)); + } + + onDestroy() { + this._knotTime = 0; + this._puffDistance = 0; + this.seep.visible = false; + this.seepMaterial.uniforms.uFade.value = 0; + this.damp.setVisible(false); + this.damp.clearMarks(); + } + + dispose() { + this.seepMaterial.dispose(); + releaseGroundQuad(); + this.seepGeometry = null; + this.damp.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/verdant/SporefallAbility.js b/src/abilities/verdant/SporefallAbility.js new file mode 100644 index 0000000..c90ea61 --- /dev/null +++ b/src/abilities/verdant/SporefallAbility.js @@ -0,0 +1,619 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, Easing } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _hand = new Vector3(); + +/** + * SPOREFALL — a volume that hugs the floor. + * + * Every other raymarched thing in the expansion goes **up**: Pyroclasm's dome, + * Wyrm's Breath's cone, Sanguine Pact's column. This one goes sideways. A slab + * of spore-laden air lands on the circle and pours outward across the ground, + * pooling in the middle rather than billowing out of it, and then thins and + * sinks. Out of it, bioluminescent motes drift up and die at head height. + * + * Three beats over three phases: + * + * 1. **seep** — a low front runs out across the floor to the circle. + * 2. **spread & hold** — the impact phase. The slab lands gathered and thick + * and spreads to the boundary over `seepTime`, losing height as it gains + * footprint; a pool opens on the floor beneath it; motes start leaving. + * 3. **disperse** — the fade. The slab loses its thickness, keeps creeping + * outward as it goes, and the pool dries back from its rim. + * + * **The trick, part one: the field is flattened, not the hull.** Squashing the + * proxy alone gives a low cloud made of round blobs, which reads exactly like a + * cloud somebody sat on. `sporeFlatten` stretches the *noise domain's* Y, so + * every eddy in the field is a pancake, and it is the eddies rather than the + * silhouette that say which way this substance wants to move. Take that one + * slider to zero and the whole cast reverts to fog with a low ceiling, with + * every other number unchanged. + * + * The outward pour is the **footprint growing**, not a flow vector. The first + * version bought it from `sporeFlowX`/`sporeFlowZ`, which is the obvious + * reading of "advected outward" and is wrong: those are world-constant, so + * what they actually produce is a wind — the whole cloud slides off downrange + * and leaves the circle behind. A radially outward advection is not something + * a single flow uniform can express, and it does not need to be: a flattened + * field inside a footprint that is widening while it thins *is* a spill. + * + * **The trick, part two: the motes stop.** A mote that keeps rising turns a + * zone cast into weather. So `moteDeathHeight` is a real metre and it is + * enforced analytically: the particle system integrates + * `rise(t) = v·(1 − e^(−k·t))/k`, so the time at which a mote crosses a given + * height has a closed form, and `_moteLifetime()` solves it every frame and + * writes the answer to the system's `uLifeScale`. That is what makes the + * ceiling a live slider — drag it with the clock stopped and every mote + * already in the air is re-timed, and the layer they wink out in moves. + * + * **The rule that makes the editor work.** A cast captures one seed and the + * moment the slab landed. Not one metre, radian or second: the slab's + * half-extents, the pool's radius, the mote ceiling and the light's height are + * all resolved against `settings.sporefall` inside the update loop, which runs + * on a zero-length frame too. + * + * Two draw calls for the whole cast, which is the other half of "the quietest + * zone cast in the sandbox". + */ +export class SporefallAbility extends Ability { + constructor(context) { + super('sporefall', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /** + * The slab. + * + * BOX rather than CYLINDER, and the choice is not arbitrary: with + * `sporeRound` near 1 a box's footprint rounds off into the same disc for + * the same money, and turning the rounding *down* is then available as a + * look — a spill with a straight leading edge. A cylinder can never be + * anything but a cylinder. + */ + this.slab = new VolumeHull({ + hull: HullShape.BOX, + medium: Medium.SPORE, + prefix: 'spore', + // Deliberately low. This volume covers a lot of screen and is nearly + // uniform inside; steps buy nothing here and coverage is the cost driver. + maxSteps: 36, + renderOrder: 12 + }); + this.group.add(this.slab.mesh); + + /** The standing pool the slab is lying on. One draw call, live in metres. */ + this.pool = new GroundField(this.group, { + mode: GroundMode.POOL, + additive: false, + depthTest: true, + layer: LAYER.VFX, + renderOrder: 7, + name: 'SporePool' + }); + /** Live params, allocated once and refilled every frame — I1. */ + this.poolParams = groundFieldParams(); + this.poolParams.centre = new Vector3(); + + /** Re-rolled per cast so two slabs do not draw the same grain. */ + this._seed = 0; + /** Seconds since the slab landed. Drives the spread, nothing else. */ + this._spreadTime = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + /** + * The motes. Additive, curled, and emitted with a *unit* life and a *unit* + * speed: the real metres-per-second lives in `uSpeedScale` and the real + * lifetime in `uLifeScale`, both rewritten every frame. That is what makes + * `moteRise` and `moteDeathHeight` live sliders rather than numbers baked + * into each particle at the instant it was emitted. + */ + this.motes = particles.get('sporefall.motes', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uEndSize.value = 0.35; + this.motes.uniforms.uSizeIn.value = 0.14; + this.motes.uniforms.uFadeIn.value = 0.16; + // A long fade-out, because the death is meant to read as a mote *going + // out* at a height rather than as one being deleted at a height. + this.motes.uniforms.uFadeOut.value = 0.55; + + // Heavy air rolling off the slab's rim. Non-additive so it genuinely + // occludes, which is what gives the slab an edge you can see past. + this.drift = particles.get('sporefall.drift', { + capacity: 1200, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.drift.uniforms.uDrag.value = 2.4; + this.drift.uniforms.uEndSize.value = 3.2; + this.drift.uniforms.uSizeIn.value = 0.16; + this.drift.uniforms.uFadeIn.value = 0.22; + this.drift.uniforms.uFadeOut.value = 0.3; + + this.moteEmitter = new RateEmitter(); + this.driftEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + // Two meshes, and neither is instanced. The HUD readout is honest about it. + return 2; + } + + /** Spread out to the boundary, then lie there. */ + get impactDuration() { + const c = settings.sporefall; + return Math.max(0.2, (c.seepTime + c.holdTime) * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.2, settings.sporefall.disperseTime); + } + + /** The live footprint, metres. What the indicator measured out. */ + get radius() { + return Math.max(0.05, settings.sporefall.zoneRadius); + } + + /** + * A slow swell rather than a flicker. + * + * Everything else in this ability is deliberately still, so the light is the + * only thing with a pulse in it, and it has to be under the frequency where + * a viewer reads "flicker" — half a hertz, not thirty. + */ + lightShimmer() { + const c = settings.sporefall; + return 1 - saturate(c.lightBreathe) * 0.5 * (1 - Math.cos(this.age * c.lightBreatheRate * TAU)); + } + + /* ------------------------------------------------------------------ */ + /* The clocks — pure functions of live settings */ + /* ------------------------------------------------------------------ */ + + /** The middle of the circle — the far end of the aimed line. */ + _centrePoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** Where the cast leaves the caster, in world space. */ + _handPoint(out) { + const c = settings.sporefall; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** How far the slab has spread toward the boundary, 0..1. */ + _spread() { + const c = settings.sporefall; + return Easing.outCubic(saturate(this._spreadTime / Math.max(0.05, c.seepTime))); + } + + /** How far it has thinned out and sunk, 0..1. */ + _disperse() { + if (this.phase !== AbilityPhase.FADE) return 0; + const c = settings.sporefall; + return saturate(this.fadeTime / Math.max(0.05, c.disperseTime)); + } + + /** + * Seconds a mote lives, solved from the height it is required to die at. + * + * The particle system integrates drag analytically: + * + * rise(t) = v · (1 − e^(−k·t)) / k + * + * so the time at which a mote crosses `h` metres is + * `−ln(1 − h·k/v) / k`, exactly. Past `v/k` the mote never gets there at all + * — drag stalls it below the ceiling — so the ratio is clamped just short of + * one and it simply lives out the asymptote instead. The alternative was to + * kill motes on the CPU by testing their height, which needs the CPU to know + * where they are, and the whole point of this particle system is that it + * does not. + * + * `global.particleLifetime` is deliberately **not** folded in here: it would + * scale the answer and move the ceiling off the slider that names it. It is + * the one global multiplier this system ignores, and this comment is the + * reason. + */ + _moteLifetime() { + const c = settings.sporefall; + const g = settings.global; + const k = Math.max(0.05, c.moteDrag); + const v = Math.max(0.01, c.moteRise * g.particleSpeed); + const h = Math.max(0.02, c.moteDeathHeight - c.moteBirthHeight); + const ratio = Math.min(0.97, (h * k) / v); + return -Math.log(1 - ratio) / k; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.driftEmitter.reset(); + this._spreadTime = 0; + + // The one thing a cast captures, besides the timestamp the slab landed on. + this._seed = Math.random() * 100; + + this.pool.clearMarks(); + this.slab.setFade(0); + this.pool.setVisible(false); + + this._sync(0, 0); + this._muzzleFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings into the slab, the pool and the two particle + * systems. + * + * @param {number} spread 0..1 — how far the slab has reached the boundary + * @param {number} disperse 0..1 — how far it has thinned out and sunk + */ + _sync(spread, disperse) { + const c = settings.sporefall; + const g = settings.global; + const R = this.radius; + + this._centrePoint(_centre); + + /* --- the slab ---------------------------------------------------- */ + // It lands gathered and thick and ends up wide and thin, and it keeps + // creeping outward the whole time it is dying, because a spill does not + // stop at the edge of its own puddle. + const reach = + R * c.sporeRadius * (c.sporeGather + (1 - c.sporeGather) * spread) * (1 + c.sporeCreep * disperse); + const thickness = + Math.max(0.02, c.sporeThickness * (c.sporeHeap + (1 - c.sporeHeap) * spread)) * + (1 - saturate(c.sporeSink) * disperse); + + // Fade in over the first fifth of the spread — the slab arriving instantly + // at full density reads as a cut, and this is a substance that seeps. + const arrive = saturate(spread * 5); + const fade = arrive * (1 - Easing.inQuad(disperse)); + + _pos.set(_centre.x, c.sporeBase, _centre.z); + this.slab + .place(_pos, this.direction) + .setSize(reach, thickness, reach) + .setFade(fade) + .sync(c, g); + + /* --- the pool ---------------------------------------------------- */ + const p = this.poolParams; + p.centre.copy(_centre); + p.yaw = 0; + p.height = c.poolHeight; + p.radius = R * c.poolRadius; + p.grow = spread; + // The pool dries from its rim inward while the slab above it is still + // spreading — the two fronts run in opposite directions, which is what + // stops the ground and the air reading as one flat sticker. + p.recede = disperse; + p.fade = 1 - Easing.inQuad(disperse); + p.seed = this._seed; + + p.edge = c.poolEdge; + p.ragged = c.poolRagged; + p.raggedScale = c.poolRaggedScale; + p.warp = c.poolWarp; + p.relief = c.poolRelief; + p.ambient = c.poolAmbient; + p.specular = c.poolSpecular; + p.gloss = c.poolGloss; + p.cell = c.poolCell; + p.depth = c.poolDepth; + p.flow = c.poolFlow; + p.swirl = c.poolSwirl; + p.detail = c.poolDetail; + p.sharp = c.poolSharp; + p.speed = c.poolSpeed; + p.emissive = c.poolEmissive * g.glow; + p.opacity = c.poolOpacity; + p.depthFade = c.poolDepthFade; + p.colorBase = c.colorPoolBase; + p.colorEdge = c.colorPoolEdge; + p.colorGlow = c.colorPoolGlow; + p.colorDeep = c.colorPoolDeep; + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + + this.pool.setVisible(spread > 0.001 && p.fade > 0.004); + this.pool.update(p); + + /* --- the two particle systems ------------------------------------ */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + // Unit velocity and unit life at emit; the metres and the seconds live + // here, where they are re-read every frame. See `_moteLifetime()`. + this.motes.uniforms.uSpeedScale.value = c.moteRise * g.particleSpeed; + this.motes.uniforms.uLifeScale.value = this._moteLifetime(); + this.motes.uniforms.uDrag.value = Math.max(0.05, c.moteDrag); + this.motes.uniforms.uGravity.value.set(0, c.moteSag, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.moteGlow * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.drift.setGradient( + getColor(c.colorDriftA), + getColor(c.colorDriftB), + getColor(c.colorDriftC), + getColor(c.colorDriftD) + ); + this.drift.uniforms.uGravity.value.set(0, c.driftRise, 0); + this.drift.uniforms.uSizeScale.value = c.driftSize * g.particleSize; + this.drift.uniforms.uLifeScale.value = c.driftLifetime * 0.5 * g.particleLifetime; + this.drift.uniforms.uSpeedScale.value = c.driftSpeed * g.particleSpeed; + this.drift.uniforms.uOpacity.value = c.driftOpacity * g.opacity; + this.drift.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /** The soft cough of spores at the caster's hand as the seep leaves it. */ + _muzzleFx() { + const c = settings.sporefall; + const g = settings.global; + + this._handPoint(_hand); + + this.ctx.bursts.spawn(BurstMode.EARTH, _hand, { + radius: c.muzzleSize * 0.3, + endRadius: c.muzzleSize * g.explosionIntensity, + life: 0.6, + intensity: c.muzzleIntensity, + opacity: 0.4, + fresnel: 1.2, + displace: 0.45, + squash: 0.7, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this._emitMotes(Math.round(18 * g.particleCount), _hand, 0.18); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + /** + * Release `count` motes in a ball of `spread` metres about `at`. + * + * Speed and life go out as **1**. Both are scaled by uniforms that are + * rewritten every frame, which is the whole reason the ceiling is live. + */ + _emitMotes(count, at, spread) { + if (count <= 0) return; + const c = settings.sporefall; + + _emit.position = at; + _emit.radius = spread; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = 1; + _emit.speedVariance = c.moteSpeedVariance; + _emit.spread = c.moteSpread; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.08; + _emit.sizeVariance = 0.6; + _emit.life = 1; + // Zero, and this is the point of the ability: a lifetime variance here + // would smear the ceiling into a gradient and there would be nothing left + // for `moteDeathHeight` to mean. The softening comes from + // `moteSpeedVariance` instead, which widens the *band* without breaking + // the relationship between the rise and the height. + _emit.lifeVariance = 0; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(count, _emit); + } + + /** A thin haze laid under the seep front while it runs out. */ + _frontFx(dt) { + const c = settings.sporefall; + const g = settings.global; + + const driftCount = Math.round(this.driftEmitter.tick(dt, c.driftRate * 0.5) * g.particleCount); + if (driftCount <= 0) return; + + _emit.position = _pos.copy(this.position).setY(0.08); + _emit.radius = 0.4; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.7).setY(0.25).normalize(); + _emit.speed = 1; + _emit.speedVariance = 0.6; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.6; + _emit.sizeVariance = 0.5; + _emit.life = 1; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.drift.emit(driftCount, _emit); + } + + /** + * Motes out of the slab and drift off its rim. + * + * @param {number} scale 0..1 — thinned out as the slab disperses + */ + _fieldFx(dt, scale, spread) { + const c = settings.sporefall; + const g = settings.global; + const R = this.radius * spread; + + this._centrePoint(_centre); + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + const bearing = Math.random() * TAU; + // sqrt keeps the disc evenly dense instead of crowding the middle, which + // matters here because the middle is where the pool already is and a + // second concentration on top of it reads as a chimney. + const r = R * c.moteInset * Math.sqrt(Math.random()); + _pos.set(_centre.x + Math.cos(bearing) * r, c.moteBirthHeight, _centre.z + Math.sin(bearing) * r); + this._emitMotes(moteCount, _pos, R * 0.06 + 0.05); + } + + const driftCount = Math.round(this.driftEmitter.tick(dt, c.driftRate * scale) * g.particleCount); + if (driftCount > 0) { + const bearing = Math.random() * TAU; + const r = R * c.driftInset; + _pos.set( + _centre.x + Math.cos(bearing) * r, + Math.max(0.04, settings.sporefall.sporeThickness * 0.35), + _centre.z + Math.sin(bearing) * r + ); + _emit.position = _pos; + _emit.radius = R * 0.1; + // Outward and barely up. This is heavy air falling off the edge of a + // spill, not smoke leaving a fire. + _emit.direction = _dir.set(Math.cos(bearing), 0.1, Math.sin(bearing)).normalize(); + _emit.speed = 1; + _emit.speedVariance = 0.55; + _emit.spread = 0.4; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.75; + _emit.sizeVariance = 0.5; + _emit.life = 1; + _emit.lifeVariance = 0.35; + _emit.spin = 0.25; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.drift.emit(driftCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._sync(0, 0); + + // The light rides the seep front, right down on the floor. + this.position.y = settings.sporefall.lightHeight * 0.4; + + this._frontFx(dt); + this.ctx.shake.rumble(settings.sporefall.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.sporefall; + const g = settings.global; + + this._spreadTime = 0; + this._centrePoint(_centre); + + /* the low shell as the slab lands — squashed hard, because it is a slab */ + _pos.copy(_centre).setY(c.sporeThickness * 0.5); + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.3, + endRadius: c.burstSize * g.explosionIntensity, + life: 1.1, + intensity: c.burstIntensity, + opacity: 0.35, + fresnel: 1.3, + displace: 0.5, + squash: 0.25, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + _pos.copy(_centre).setY(c.moteBirthHeight); + this._emitMotes(Math.round(c.burstMotes * g.particleCount), _pos, this.radius * 0.5); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 11 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.6 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.sporefall; + + if (t <= 1) this._spreadTime += dt; + + const spread = this._spread(); + const disperse = this._disperse(); + + this._sync(spread, disperse); + + // The light sits in the slab, and sinks with it. + this._centrePoint(this.position); + this.position.y = c.lightHeight * (1 - saturate(c.sporeSink) * disperse); + + this._fieldFx(dt, (1 - disperse) * (t <= 1 ? 1 : 0.6), Math.max(0.05, spread)); + } + + onDestroy() { + this._spreadTime = 0; + this.slab.setFade(0); + this.pool.setVisible(false); + this.pool.clearMarks(); + } + + dispose() { + this.slab.dispose(); + this.pool.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/verdant/ThornwakeAbility.js b/src/abilities/verdant/ThornwakeAbility.js new file mode 100644 index 0000000..d69f26f --- /dev/null +++ b/src/abilities/verdant/ThornwakeAbility.js @@ -0,0 +1,1168 @@ +import { BufferGeometry, Float32BufferAttribute, MeshStandardMaterial, Color, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GrowthField, GrowthLayout, GrowthEmerge, patchGrowthMaterial } from '../../vfx/GrowthField.js'; +import { FilamentPaths, MAX_FILAMENT_ROLES } from '../../vfx/FilamentPaths.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, hash11, Easing, randRange } from '../../utils/math.js'; + +/* ---------------------------------------------------------------------- */ +/* Constants */ +/* ---------------------------------------------------------------------- */ + +const TAU = Math.PI * 2; + +/** Hard ceiling on thorns per cast. The editor's `thorns` slider clamps here. */ +const MAX_THORNS = 96; + +/** Distinct thorn silhouettes — one InstancedMesh each. Three draw calls. */ +const VARIANTS = 3; + +/** + * Vine slots. + * + * `FilamentPaths` holds four role slots and **one anchor pair per role**, which + * is the constraint that shapes this whole ability: a role's `from`/`to` are + * uniforms, so a role can draw a dozen parallel strands between two points but + * cannot draw a dozen strands between a dozen different pairs. Twelve vines is + * therefore three strips at two draw calls each. The alternative — one strip and + * four vines — was the first thing tried, and four strands across forty thorns + * reads as four strands across forty thorns. Twelve reads as a bramble. + */ +const WEAVES = 3; +const MAX_VINES = WEAVES * MAX_FILAMENT_ROLES; + +/** Parallel filaments one vine may carry. Four is already a rope. */ +const MAX_VINE_STRANDS = 4; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _target = new Vector3(); +const _va = new Vector3(); +const _vb = new Vector3(); +const _probe = new Vector3(); + +/* ---------------------------------------------------------------------- */ +/* The thorn */ +/* ---------------------------------------------------------------------- */ + +/** Ring heights up the shaft. Crowded low, where the taper is doing its work. */ +const RING_T = [0, 0.1, 0.24, 0.42, 0.62, 0.82]; + +/** + * A tapered, swept, barbed spike in `GrowthField`'s unit space: footprint inside + * a circle of radius 0.5 on `y = 0`, tip at `y = 1`. + * + * Three things had to be true before this read as a bramble rather than as a + * cone with lumps on it. + * + * **The shaft sweeps.** A straight spike is a stalagmite. The axis drifts + * sideways by `curve · t^1.7`, in one bearing chosen per variant, so the whole + * thorn hooks over — and because the bearing is per *variant* rather than per + * instance, `GrowthField`'s random yaw then scatters the hooks in every + * direction anyway. + * + * **The taper is an exponent, not a slope.** `r = 0.5 · (1 − t)^taper`. At + * `taper = 1` you get a cone; at 2.3 you get a needle with a thick heel, which + * is the profile that says "this grew" rather than "this was machined". The + * first version lerped the radius linearly and the whole field looked like a bed + * of tent pegs. + * + * **The barbs point back down the stem.** `barbTilt` is negative by default. A + * barb angled forward reads as a fir branch, and the entire silhouette flips + * from hostile to festive with one sign change; it is worth flipping the slider + * once to see how completely the read depends on it. + * + * The barbs are what push the widest point of the footprint slightly past 0.5 at + * their attach height. That is accepted rather than clamped: the contract is + * about the scale convention (so `local.y` reads as "how far up am I" in the + * fragment stage), and a barb trimmed to keep a bounding circle honest is a barb + * you cannot see. + */ +function createThornGeometry({ + seed = 1, + sides = 5, + taper = 2.3, + curve = 0.48, + barbs = 3, + barbLength = 0.19, + barbTilt = -0.5, + barbSpread = 0.62, + rough = 0.34 +} = {}) { + const faces = Math.max(3, Math.min(8, Math.round(sides))); + const barbCount = Math.max(0, Math.min(5, Math.round(barbs))); + const power = Math.max(0.2, taper); + + const bendAngle = hash11(seed * 1.77) * TAU; + const bendX = Math.cos(bendAngle); + const bendZ = Math.sin(bendAngle); + + const shaftRadius = (t) => 0.5 * Math.pow(Math.max(1e-4, 1 - t), power); + const drift = (t) => curve * 0.5 * Math.pow(t, 1.7); + + // Angles are jittered once and shared by every ring, so the facets stay + // continuous edges up the shaft instead of twisting into a screw. + const angles = []; + for (let i = 0; i < faces; i++) { + const jitter = (hash11(seed * 3.13 + i * 7.7) - 0.5) * (TAU / faces) * 0.5 * rough; + angles.push((i / faces) * TAU + jitter); + } + + const rings = RING_T.map((t, ringIndex) => { + const baseR = shaftRadius(t); + const dx = bendX * drift(t); + const dz = bendZ * drift(t); + const y = t + (hash11(seed * 5.9 + ringIndex * 2.3) - 0.5) * 0.05 * rough * (t > 0 ? 1 : 0); + return angles.map((angle, i) => { + const wobble = 1 + (hash11(seed * 11.1 + ringIndex * 13.7 + i * 3.9) - 0.5) * rough * 1.2; + const r = Math.max(0.002, baseR * wobble); + return [Math.cos(angle) * r + dx, y, Math.sin(angle) * r + dz]; + }); + }); + + const apex = [ + bendX * drift(1) + (hash11(seed * 17.3) - 0.5) * 0.05 * rough, + 1, + bendZ * drift(1) + (hash11(seed * 19.7) - 0.5) * 0.05 * rough + ]; + const floorCentre = [0, 0, 0]; + + const positions = []; + const push = (p) => positions.push(p[0], p[1], p[2]); + + for (let ring = 0; ring < rings.length - 1; ring++) { + const lower = rings[ring]; + const upper = rings[ring + 1]; + for (let i = 0; i < faces; i++) { + const j = (i + 1) % faces; + push(lower[i]); push(lower[j]); push(upper[i]); + push(lower[j]); push(upper[j]); push(upper[i]); + } + } + + const top = rings[rings.length - 1]; + const base = rings[0]; + for (let i = 0; i < faces; i++) { + const j = (i + 1) % faces; + push(top[i]); push(top[j]); push(apex); // the point + push(floorCentre); push(base[j]); push(base[i]); // the underside + } + + /* --- the barbs ---------------------------------------------------- */ + for (let k = 0; k < barbCount; k++) { + // Golden-ratio bearings plus a scatter: evenly spaced barbs stack into a + // visible helix, and identical bearings stack into a fin. + const roll = hash11(seed * 23.7 + k * 5.13); + const t = 0.16 + 0.64 * ((k + 0.3 + 0.4 * roll) / Math.max(1, barbCount)); + const bearing = (k * 0.61803 + barbSpread * hash11(seed * 31.1 + k * 9.7)) * TAU; + const cos = Math.cos(bearing); + const sin = Math.sin(bearing); + + const shaft = shaftRadius(t); + const cx = bendX * drift(t) + cos * shaft * 0.75; + const cy = t; + const cz = bendZ * drift(t) + sin * shaft * 0.75; + + // Outward, then tipped toward the floor by `barbTilt`. + let dxx = cos; + let dyy = barbTilt; + let dzz = sin; + const dl = Math.hypot(dxx, dyy, dzz) || 1; + dxx /= dl; dyy /= dl; dzz /= dl; + + const length = barbLength * (0.75 + 0.5 * hash11(seed * 41.3 + k * 3.7)); + const tip = [cx + dxx * length, cy + dyy * length, cz + dzz * length]; + + // Two vectors perpendicular to the barb's axis, for its three-sided root. + let ux = -dzz; + let uy = 0; + let uz = dxx; + const ul = Math.hypot(ux, uy, uz) || 1; + ux /= ul; uy /= ul; uz /= ul; + const vx = dyy * uz - dzz * uy; + const vy = dzz * ux - dxx * uz; + const vz = dxx * uy - dyy * ux; + + const root = Math.max(0.012, shaft * 0.55); + const ring = []; + for (let i = 0; i < 3; i++) { + const a = (i / 3) * TAU + roll * TAU; + const ca = Math.cos(a) * root; + const sa = Math.sin(a) * root; + ring.push([cx + ux * ca + vx * sa, cy + uy * ca + vy * sa, cz + uz * ca + vz * sa]); + } + for (let i = 0; i < 3; i++) { + const j = (i + 1) % 3; + push(ring[i]); push(ring[j]); push(tip); + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + // Non-indexed with per-face normals: this is what keeps the facets crisp under + // `flatShading`, and it is the same trick the crystal field uses. + geometry.computeVertexNormals(); + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* Bark */ +/* ---------------------------------------------------------------------- */ + +/** + * Bark on a `MeshStandardMaterial`, so the field takes the stage's real shadows + * and probe and only the stylisation is injected. + * + * The one term that took work is the tip highlight. A thorn's *point* is easy — + * it is `vGrowLocal.y` near 1 — but the barbs' points are halfway up the shaft + * and would have stayed dull, which is exactly backwards: the barbs are the + * thing a bramble threatens you with. There is no room for a second vertex + * attribute here (`patchGrowthMaterial` injects fragment declarations only), so + * "am I on a point" is reconstructed in the fragment from the geometry's own + * profile: anything further from the local axis than the shaft radius at that + * height, `0.5 · (1 − y)^taper`, must be a barb. The reconstruction ignores the + * shaft's sweep, so the outer flank of a strongly swept thorn picks up a little + * of the same highlight — which turned out to be what light does to a curved + * stem anyway, and is left in. + */ +function createBarkMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.74, + metalness: 0.0, + flatShading: true, + // Transparent from the start and left that way. `barkOpacity` has to be a + // live slider, and toggling `transparent` from the update loop moves the + // meshes between render lists every frame the value crosses 1 — which shows + // up as the field flickering behind the particles rather than as a bug you + // would look for in a material. + transparent: true, + depthWrite: true + }); + + const uniforms = { + uTime: frame.uTime, + uColorBark: { value: new Color() }, + uColorHeart: { value: new Color() }, + uColorMoss: { value: new Color() }, + uColorTip: { value: new Color() }, + uColorSap: { value: new Color() }, + uGrain: { value: 0.6 }, + uGrainScale: { value: 5.5 }, + uMoss: { value: 0.42 }, + uMossScale: { value: 2.4 }, + uTipStart: { value: 0.6 }, + uTipSharp: { value: 1.8 }, + uTipGlow: { value: 1.5 }, + uTaper: { value: 2.3 }, + uBarbEdge: { value: 0.055 }, + uBarbSpan: { value: 0.075 }, + uSapGlow: { value: 2.4 }, + uGlow: { value: 1.0 } + }; + + patchGrowthMaterial(material, { + environment, + uniforms, + common: /* glsl */ ` + uniform float uTime; + uniform vec3 uColorBark; + uniform vec3 uColorHeart; + uniform vec3 uColorMoss; + uniform vec3 uColorTip; + uniform vec3 uColorSap; + uniform float uGrain; + uniform float uGrainScale; + uniform float uMoss; + uniform float uMossScale; + uniform float uTipStart; + uniform float uTipSharp; + uniform float uTipGlow; + uniform float uTaper; + uniform float uBarbEdge; + uniform float uBarbSpan; + uniform float uSapGlow; + uniform float uGlow; + `, + fragment: /* glsl */ ` + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + float y = clamp(vGrowLocal.y, 0.0, 1.0); + + // World space for the fibre, so a knee-high shoot and a two-metre thorn + // are visibly cut from the same wood instead of each carrying a copy of + // the pattern scaled to its own size. + float fibre = fbm3(vec3(vGrowWorld.xz * uGrainScale, vGrowWorld.y * uGrainScale * 0.35) + + vGrowSeed * 7.0); + float grain = (fibre * 0.5 + 0.5); + + // Local space for the mottle, so the lichen follows each thorn's own axis + // however it is scaled, leaned or tipped. + float mottle = fbm3(vGrowLocal * uMossScale * 3.0 + vGrowSeed * 13.0) * 0.5 + 0.5; + mottle = smoothstep(0.42, 0.86, mottle); + + vec3 body = mix(uColorHeart, uColorBark, mix(1.0, grain, uGrain)); + body = mix(body, uColorMoss, mottle * uMoss); + + // 'Am I on a point?' — see the function header. The shaft profile is + // reconstructed, not sampled, because there is nowhere to put an + // attribute that would carry it. + float shaft = 0.5 * pow(max(1.0 - y, 0.0), max(uTaper, 0.2)); + float offAxis = length(vGrowLocal.xz); + float barb = smoothstep(shaft + uBarbEdge, shaft + uBarbEdge + max(uBarbSpan, 1e-3), offAxis); + float point = pow(smoothstep(clamp(uTipStart, 0.0, 0.99), 1.0, y), max(uTipSharp, 0.05)); + float sharp = clamp(max(point, barb), 0.0, 1.0); + + body = mix(body, uColorTip, sharp * 0.75); + + // Facets pointing at the camera lift, so the field reads as a bundle of + // planes rather than one green mass. + body *= 0.62 + 0.55 * ndv; + + diffuseColor.rgb *= body; + + vec3 glow = uColorTip * sharp * uTipGlow * (0.35 + 0.65 * pow(1.0 - ndv, 2.0)); + glow += uColorSap * vGrowBirth * uSapGlow; + glow *= uGlow; + // Reinhard ceiling: the two terms are independent and both peak at a + // grazing angle, and without this a barb on the silhouette sums past 6 + // and the bloom pass smears the whole bramble into a green cloud. + glow /= 1.0 + glow * 0.3; + + totalEmissiveRadiance += glow; + ` + }); + + // The pause test reads a patched standard material's live boxes from here — + // `material.uniforms` does not exist until a GL context compiles the shader. + material.userData.uniforms = uniforms; + return material; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * THORNWAKE — a bramble erupts along the aimed line. + * + * A growth front runs the cast at `speed`; curved, barbed thorns punch up out of + * the floor behind it on staggered clocks, throwing soil and leaf; a knot of + * them closes over the impact point; the whole bed stands, and then withers back + * into the ground. + * + * **THE TRICK — the brambles interlace.** A field of forty separate spikes reads + * as forty props however good the spike is. What turns it into one tangled mass + * is the vines: sagging strands threaded *between* thorn instances, each one + * picking two indices by a deterministic function of its own slot and threading + * a catenary between where those two thorns actually are. + * + * The endpoints are not stored. Every frame, for every vine, the ability asks + * the `GrowthField` — `positionOf`, `heightOf`, `emergenceOf` — where its two + * thorns currently stand *at the current sliders*, lifts the anchors to + * `vineGripLow`/`vineGripHigh` of their live heights, scales that by how far out + * of the ground each one is, and hands the two points to a `FilamentPaths` LINK + * role. So the weave is a live query rather than a captured mesh: drag + * `clumping` with the clock stopped and the thorns crowd inward and every vine + * slackens with them; drag `height` and the anchors climb; drag `vineReach` and + * the whole thing re-routes onto different neighbours mid-air. + * + * The partner search is what makes it read as *tangle* rather than *cat's + * cradle*. `GrowthField.plant` lays its records stratified along the line — one + * per slot plus a jitter inside it — so index distance is distance down the + * cast. A vine starting at instance `a` walks forward through the next + * `vineReach` indices and takes the first partner whose live span fits inside + * `vineMaxSpan`, which means the pairs are always spatial neighbours but their + * lateral offsets are uncorrelated: strands cross the corridor in both + * directions and the field closes over. + * + * The first version of this threaded `tipOf(a)` to `tipOf(b)` and it was wrong + * in a way worth recording — tip to tip drapes every strand *over* the field + * like bunting, because there is nothing above a thorn's point to interrupt it. + * Tying one end low on the shaft and the other high is what pushes the strands + * down *into* the bed, where they read as caught on the barbs. + * + * A cast captures dice and timestamps and nothing else: two unitless rolls per + * vine (where to start, how far to look), the `GrowthField`'s own record dice, + * one seed for the filament noise, and the moment each vine's two ends were both + * clear of the ground. Every metre, radian and second is re-resolved from + * `settings.thornwake` inside the update loop, on zero-length frames included. + * + * **Cost.** Three instanced thorn meshes + three filament strips at two passes + * each = **9 draw calls**, three shared particle systems, one dynamic light. + */ +export class ThornwakeAbility extends Ability { + constructor(context) { + super('thornwake', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + const environment = this.ctx.environment; + this.material = createBarkMaterial(environment); + + /** Geometry controls, compared each frame by `syncGeometry`. */ + this._shape = { + sides: 5, + taper: 2.3, + curve: 0.48, + barbs: 3, + barbLength: 0.19, + barbTilt: -0.5, + barbSpread: 0.62, + rough: 0.34 + }; + this._fillShape(); + + this.field = new GrowthField(this.group, { + geometry: (variant, shape) => createThornGeometry({ seed: 3.7 + variant * 19.3, ...shape }), + material: this.material, + shape: this._shape, + variants: VARIANTS, + capacity: MAX_THORNS, + renderOrder: 2 + }); + // Assigned once, at construction. A closure built inside the update loop is + // an allocation per instance per frame, which is what I3 forbids. + this.field.onBreach = (index, position, radius, height) => + this._onBreach(index, position, radius, height); + + /** + * Three strips, four LINK roles each. See `WEAVES`. + * `samples` is deliberately low: a vine is a metre or two of smooth sag, and + * 36 nodes is already finer than `vineKinkScale` can resolve. + */ + this.weaves = []; + for (let w = 0; w < WEAVES; w++) { + this.weaves.push( + new FilamentPaths(this.group, { + samples: 36, + capacity: MAX_FILAMENT_ROLES * MAX_VINE_STRANDS, + renderOrder: 10 + }) + ); + } + + /** Live params handed to the field every frame. Never holds a stale metre. */ + this._params = { + layout: GrowthLayout.LINE, + emerge: GrowthEmerge.PUSH, + origin: this.origin, + direction: this.direction, + side: this.side, + length: 1 + }; + + /** Live look handed to every strip every frame. */ + this._look = {}; + + /* --- the cast's dice, all unitless --- */ + this._seed = 0; + /** Which instance vine `v` starts from, 0..1. */ + this._vineStart = new Float32Array(MAX_VINES); + /** How far ahead vine `v` begins looking for a partner, 0..1. */ + this._vineStep = new Float32Array(MAX_VINES); + /** The age at which vine `v` first had both ends clear. −1 for unthreaded. */ + this._vineTime = new Float32Array(MAX_VINES); + this._liveVines = 0; + this._liveStrands = 0; + /** Age the cinch is measured from — a timestamp, re-stamped at impact. */ + this._cinchFrom = 1e9; + } + + createParticles() { + const particles = this.ctx.particles; + + // Leaf and husk torn off the growth. Lit and non-additive: this is matter, + // not light, and additive chaff over a green field disappears into it. + this.chaff = particles.get('thornwake.chaff', { + capacity: 2400, + shape: ParticleShape.LEAF, + additive: false, + lit: true, + softFade: 0.3 + }); + this.chaff.uniforms.uDrag.value = 1.9; + this.chaff.uniforms.uEndSize.value = 0.85; + this.chaff.uniforms.uSizeIn.value = 0.05; + this.chaff.uniforms.uFadeIn.value = 0.06; + this.chaff.uniforms.uFadeOut.value = 0.45; + + // Spores lifting off the bed. Additive, curled, slow. + this.motes = particles.get('thornwake.motes', { + capacity: 1800, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.5 + }); + this.motes.uniforms.uDrag.value = 1.5; + this.motes.uniforms.uEndSize.value = 0.2; + this.motes.uniforms.uSizeIn.value = 0.08; + this.motes.uniforms.uFadeIn.value = 0.12; + this.motes.uniforms.uFadeOut.value = 0.5; + + // Soil kicked out of the hole a thorn came through. + this.soil = particles.get('thornwake.soil', { + capacity: 1600, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.soil.uniforms.uDrag.value = 0.4; + this.soil.uniforms.uEndSize.value = 0.7; + this.soil.uniforms.uFadeOut.value = 0.6; + + this.chaffEmitter = new RateEmitter(); + this.moteEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.field.count + this._liveStrands; + } + + /** The bed stands after the front lands. */ + get impactDuration() { + return Math.max(0.1, settings.thornwake.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.1, settings.thornwake.fadeTime); + } + + /** Growth does not gutter. A slow breath, slightly slower than the default. */ + lightShimmer() { + return 0.88 + 0.12 * Math.sin(this.age * 4.1) * Math.sin(this.age * 1.7); + } + + /* ------------------------------------------------------------------ */ + /* Resolving — every metre comes from here, every frame */ + /* ------------------------------------------------------------------ */ + + /** The eight numbers baked into the thorn geometry. */ + _fillShape() { + const c = settings.thornwake; + const s = this._shape; + s.sides = c.thornSides; + s.taper = c.thornTaper; + s.curve = c.thornCurve; + s.barbs = c.thornBarbs; + s.barbLength = c.thornBarbLength; + s.barbTilt = c.thornBarbTilt; + s.barbSpread = c.thornBarbSpread; + s.rough = c.thornRough; + } + + /** Everything the field resolves an instance's transform from. */ + _fillParams() { + const c = settings.thornwake; + const p = this._params; + + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + + p.widthNear = c.widthNear; + p.width = c.width; + p.widthCurve = c.widthCurve; + p.frontBias = c.frontBias; + p.clumping = c.clumping; + p.scatter = c.scatter; + p.clusterRadius = c.clusterRadius; + + p.heightNear = c.heightNear; + p.height = c.height; + p.heightCurve = c.heightCurve; + p.heightJitter = c.heightJitter; + p.crown = c.crown; + p.crownPower = c.crownPower; + p.peak = c.peak; + p.peakWidth = c.peakWidth; + p.rubble = c.rubble; + p.rubbleScale = c.rubbleScale; + p.rubbleSpread = c.rubbleSpread; + + p.radiusNear = c.radiusNear; + p.radius2 = c.radius2; + p.radiusCurve = c.radiusCurve; + p.radiusJitter = c.radiusJitter; + + p.lean = c.lean; + p.leanJitter = c.leanJitter; + p.leanRamp = c.leanRamp; + p.leanForward = c.leanForward; + p.leanOutward = c.leanOutward; + p.twist = c.twist; + p.tilt = c.tilt; + + p.riseTime = c.riseTime; + p.riseOvershoot = c.riseOvershoot; + p.settle = c.settle; + p.springRate = c.springRate; + p.emergeSink = c.emergeSink; + p.birthScale = c.birthScale; + p.birthFade = c.birthFade; + p.breachAt = c.breachAt; + p.sinkDepth = c.sinkDepth; + + p.randomness = settings.global.randomness; + return p; + } + + /** The shared filament look, pulled out of the `vine*` sliders. */ + _fillLook() { + const c = settings.thornwake; + const g = settings.global; + const look = this._look; + + look.width = c.vineWidth; + look.glowWidth = c.vineGlowWidth; + look.glowOpacity = c.vineGlowOpacity; + + look.jitter = c.vineKink; + look.jitterScale = c.vineKinkScale; + look.octaves = c.vineOctaves; + look.jitterFalloff = c.vineKinkFalloff; + look.crawl = c.vineCrawl; + look.pinch = c.vinePinch; + look.restrike = c.vineRestrike; + + look.flicker = c.vineFlicker; + look.flickerSpeed = c.vineFlickerSpeed; + look.strandFlash = c.vineStrandFlash; + + look.coreSharp = c.vineCoreSharp; + look.glowFalloff = c.vineGlowFalloff; + look.softFade = c.vineSoftFade; + + look.opacity = c.vineOpacity; + look.glow = c.vineGlow; + look.colorCore = c.colorVineCore; + look.colorInner = c.colorVineInner; + look.colorOuter = c.colorVineOuter; + look.colorHalo = c.colorVineHalo; + + look.randomness = g.randomness; + look.noiseStrength = g.noiseStrength; + look.noiseFrequency = g.noiseFrequency; + look.noiseSpeed = g.noiseSpeed; + look.opacityScale = g.opacity; + look.glowScale = g.glow; + return look; + } + + /** Push the palette and every shading control into the bark. */ + _syncMaterial() { + const c = settings.thornwake; + const g = settings.global; + const u = this.material.userData.uniforms; + + u.uColorBark.value.copy(getColor(c.colorBark)); + u.uColorHeart.value.copy(getColor(c.colorHeart)); + u.uColorMoss.value.copy(getColor(c.colorMoss)); + u.uColorTip.value.copy(getColor(c.colorTip)); + u.uColorSap.value.copy(getColor(c.colorSap)); + + u.uGrain.value = c.barkGrain * g.shaderIntensity; + u.uGrainScale.value = c.barkGrainScale * g.noiseFrequency; + u.uMoss.value = c.mossAmount * g.shaderIntensity; + u.uMossScale.value = c.mossScale * g.noiseFrequency; + u.uTipStart.value = c.tipStart; + u.uTipSharp.value = c.tipSharp; + u.uTipGlow.value = c.tipGlow; + // The fragment reconstructs the shaft profile, so it needs the same + // exponent the geometry factory was built with — one uniform, not a guess. + u.uTaper.value = c.thornTaper; + u.uBarbEdge.value = c.barbEdge; + u.uBarbSpan.value = c.barbSpan; + u.uSapGlow.value = c.sapGlow; + u.uGlow.value = c.barkGlow * g.glow; + + this.material.roughness = c.barkRough; + this.material.opacity = c.barkOpacity * g.opacity; + } + + /** + * World height of a point `grip` of the way up thorn `index`, right now. + * + * This mirrors `GrowthField#update`'s PUSH placement rather than guessing at + * it: an instance still coming out of the ground has its base pushed down by + * `(emerge − 1) · height · emergeSink`, so a vine tied a third of the way up a + * half-risen thorn has to ride that down or it floats in the air above one and + * clips through the other. + */ + _gripHeight(index, emerge, grip, p) { + const height = this.field.heightOf(index, p); + const settled = Math.min(1, Math.max(0, emerge)); + return height * ((settled - 1) * (p.emergeSink ?? 0.85) + grip * settled); + } + + /* ------------------------------------------------------------------ */ + /* The weave */ + /* ------------------------------------------------------------------ */ + + /** + * Re-thread every vine from the live placement. + * + * The whole trick is in this method and it is deliberately dumb: no cached + * pairs, no incremental update, no early-out on "nothing moved". Twelve vines + * times a `vineReach` of five is sixty position reads a frame, which is + * nothing next to being able to say that a paused field re-weaves itself under + * any slider in the block. + * + * @param {number} fade 1 while the bramble stands, ramping to 0 as it withers + */ + _syncVines(fade) { + const c = settings.thornwake; + const p = this._params; + const look = this._fillLook(); + + const count = this.field.count; + const wanted = Math.max(0, Math.min(MAX_VINES, Math.round(c.vines))); + const strands = Math.max(1, Math.min(MAX_VINE_STRANDS, Math.round(c.vineStrands))); + const reach = Math.max(1, Math.min(MAX_THORNS - 1, Math.round(c.vineReach))); + const maxSpan = Math.max(0.05, c.vineMaxSpan); + const gate = c.vineBirth; + const grow = Math.max(0.02, c.vineGrow); + + // How hard the weave is pulled. It slackens while the field is still coming + // up and cinches once the front has landed, which is what makes the impact + // read as the bramble *closing* rather than as one more thorn appearing. + const cinch = + this.phase === AbilityPhase.TRAVEL + ? c.vineTaut + : lerp( + c.vineTaut, + c.vineCinch, + Easing.outCubic(saturate((this.age - this._cinchFrom) / Math.max(0.02, c.cinchTime))) + ); + + let live = 0; + let strandTotal = 0; + + for (let v = 0; v < MAX_VINES; v++) { + const role = this.weaves[(v / MAX_FILAMENT_ROLES) | 0].role(v % MAX_FILAMENT_ROLES); + + if (v >= wanted || count < 2) { + role.retire(); + this._vineTime[v] = -1; + continue; + } + + // Where this vine roots. If its die lands on a thorn that is still buried + // the search walks *backwards* down the index order, which is backwards + // down the cast, because `plant` lays its records stratified along the + // line and the field fills from the caster forward. So the fallback is + // always the nearest thorn already standing *behind* the front, and the + // weave chases the front out instead of waiting for the whole bed. The + // first version simply retired a vine whose die landed ahead of the front, + // and the result was a corridor of bare thorns that suddenly laced + // together a third of a second after the cast landed. + let a = Math.min(count - 1, Math.floor(this._vineStart[v] * count)); + let emergeA = this.field.emergenceOf(a, this.age, p); + for (let k = 1; k < count && emergeA < gate; k++) { + a = (a - 1 + count) % count; + emergeA = this.field.emergenceOf(a, this.age, p); + } + if (emergeA < gate) { + role.retire(); + this._vineTime[v] = -1; + continue; + } + + /* --- find a partner: the first neighbour whose live span fits --- */ + // The offset walk starts at a per-vine die so twelve vines rooted near + // each other do not all reach for the same neighbour. + this.field.positionOf(a, p, _va); + _va.y += this._gripHeight(a, emergeA, c.vineGripLow, p); + + const first = 1 + Math.floor(this._vineStep[v] * reach); + let b = -1; + for (let k = 0; k < reach; k++) { + const offset = 1 + ((first - 1 + k) % reach); + const candidate = (a + offset) % count; + if (candidate === a) continue; + const e = this.field.emergenceOf(candidate, this.age, p); + if (e < gate) continue; + this.field.positionOf(candidate, p, _probe); + _probe.y += this._gripHeight(candidate, e, c.vineGripHigh, p); + if (_probe.distanceTo(_va) > maxSpan) continue; + b = candidate; + _vb.copy(_probe); + break; + } + + if (b < 0) { + role.retire(); + this._vineTime[v] = -1; + continue; + } + + // The timestamp — an event, not a dimension. Only set on the frame the + // pair first became threadable. + if (this._vineTime[v] < 0) this._vineTime[v] = this.age; + const drawn = saturate((this.age - this._vineTime[v]) / grow); + + role.count = strands; + role.link( + _va, + _vb, + c.vineSlack, // metres of droop at mid-span when fully slack + c.vineCurve, // 1 rope, 3 heavy chain + c.vineSwing, // metres of lateral sway + c.vineSwingSpeed, // radians/second of that sway + saturate(cinch), // 0..1 tension + c.vineSpread // metres between the parallel strands + ); + role.style(1, 1, 1, c.vineGroundDamp); + role.ends(c.vineEndFade, c.vineEndFade, c.vineEndTaper, c.vineEndTaper); + role.draw(drawn, c.vineTipLength, c.vineFloor, c.vineTipGlow * (1 - drawn)); + + live++; + strandTotal += strands; + } + + for (let w = 0; w < WEAVES; w++) this.weaves[w].sync(look, fade, this._seed + w * 3.1); + + this._liveVines = live; + this._liveStrands = strandTotal; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.thornwake; + + this.chaffEmitter.reset(); + this.moteEmitter.reset(); + this._seed = Math.random() * 100; + this._cinchFrom = 1e9; + + for (let v = 0; v < MAX_VINES; v++) { + this._vineStart[v] = Math.random(); + this._vineStep[v] = Math.random(); + this._vineTime[v] = -1; + } + + this.field.plant(c.thorns, c.clusterShare); + + this._fillShape(); + this.field.syncGeometry(this._shape); + this._syncMaterial(); + this._fillParams(); + this.field.update(this.age, this._params, 0); + this._syncVines(1); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** One thorn breaking the surface: chips, litter and a split in the floor. */ + _onBreach(index, position, radius, _height) { + const c = settings.thornwake; + const g = settings.global; + const time = frame.uTime.value; + + _emit.position = position; + _emit.radius = radius * 1.4; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.soilSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.85; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.soilLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.soil.emit(Math.round(c.soilBreach * g.particleCount), _emit); + + _emit.speed = c.chaffSpeed; + _emit.spread = 1.0; + _emit.size = 0.18; + _emit.life = c.chaffLifetime; + _emit.spin = c.chaffSpin; + this.chaff.emit(Math.round(c.chaffBreach * g.particleCount), _emit); + + this.ctx.decals.spawn(DecalType.DUSTRING, position, { + radius: c.dustRadius * randRange(0.7, 1.3), + life: c.dustLife, + intensity: c.dustIntensity, + colorA: getColor(c.colorDustA), + colorB: getColor(c.colorDustB) + }); + + // Only some breaches split the floor. Every one of them doing it turns the + // corridor into a solid mat of cracks and the individual events stop + // reading — the same reason the arc burns under the bolt are rate-limited. + if (hash11(index * 3.77 + this._seed) < c.crackChance) { + this.ctx.decals.spawn(DecalType.CRACK, position, { + radius: c.crackRadius * randRange(0.7, 1.4), + life: c.crackLife, + width: c.crackWidth, + intensity: c.crackIntensity, + colorA: getColor(c.colorCrackA), + colorB: getColor(c.colorCrackB) + }); + } + } + + /** Continuous shed off the standing bed. */ + _fieldFx(dt, scale) { + const c = settings.thornwake; + const g = settings.global; + const time = frame.uTime.value; + // Only the part of the corridor the front has passed may shed. + const reach = this.phase === AbilityPhase.TRAVEL ? Math.max(0.03, this.u) : 1; + + const chaffCount = Math.round(this.chaffEmitter.tick(dt, c.chaffRate * scale) * g.particleCount); + if (chaffCount > 0) { + const s = Math.random() * reach; + this.pointAt(s, _pos); + _pos.y = lerp(c.heightNear, c.height, s) * 0.55; + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, s) * 1.1; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chaffSpeed * 0.55; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.16; + _emit.sizeVariance = 0.7; + _emit.life = c.chaffLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = c.chaffSpin; + _emit.tint = null; + _emit.time = time; + this.chaff.emit(chaffCount, _emit); + } + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + const s = Math.random() * reach; + this.pointAt(s, _pos); + _pos.y = lerp(c.heightNear, c.height, s) * 0.4; + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, s) * 1.3; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 1.0; + _emit.size = 0.09; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + } + + /** Push the live gradients and scales into the three systems. */ + _syncParticles() { + const c = settings.thornwake; + const g = settings.global; + + this.chaff.setGradient( + getColor(c.colorChaffA), + getColor(c.colorChaffB), + getColor(c.colorChaffC), + getColor(c.colorChaffD) + ); + this.chaff.uniforms.uGravity.value.set(0, c.chaffGravity, 0); + this.chaff.uniforms.uSizeScale.value = c.chaffSize * g.particleSize * 7; + this.chaff.uniforms.uLifeScale.value = c.chaffLifetime * 0.5 * g.particleLifetime; + this.chaff.uniforms.uSpeedScale.value = g.particleSpeed; + this.chaff.uniforms.uOpacity.value = g.opacity; + this.chaff.uniforms.uTurbulence.value = 0.5 * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 0.8 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.soil.setGradient( + getColor(c.colorSoilA), + getColor(c.colorSoilB), + getColor(c.colorSoilC), + getColor(c.colorSoilD) + ); + this.soil.uniforms.uGravity.value.set(0, c.soilGravity, 0); + this.soil.uniforms.uSizeScale.value = c.soilSize * g.particleSize * 7; + this.soil.uniforms.uLifeScale.value = g.particleLifetime; + this.soil.uniforms.uSpeedScale.value = g.particleSpeed; + this.soil.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.thornwake; + const p = this._fillParams(); + + this._fillShape(); + this.field.syncGeometry(this._shape); + this._syncMaterial(); + this._syncParticles(); + + this.field.triggerUpTo(this.age, this.u, c.riseStagger, c.frontBias, false); + this.field.update(this.age, p, 0); + this._syncVines(1); + + this._fieldFx(dt, 1); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.thornwake; + const g = settings.global; + const time = frame.uTime.value; + + // The knot at the end goes up now, and the weave starts pulling taut. + this.field.triggerUpTo(this.age, 1, c.riseStagger, c.frontBias, true); + this._cinchFrom = this.age; + + this.pointAt(1, _target); + _pos.copy(_target).setY(c.height * 0.4); + + this.ctx.bursts.spawn(BurstMode.STORM, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.5, + displace: 0.7, + squash: 0.85, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.ctx.decals.spawn(DecalType.CRACK, _target, { + radius: c.crackRadius * 3.2, + life: c.crackLife * 1.3, + width: c.crackWidth * 1.4, + intensity: c.crackIntensity * 1.3, + colorA: getColor(c.colorCrackA), + colorB: getColor(c.colorCrackB) + }); + this.ctx.decals.spawn(DecalType.DUSTRING, _target, { + radius: c.dustRadius * 3.0, + life: c.dustLife * 1.4, + intensity: c.dustIntensity * 1.2, + colorA: getColor(c.colorDustA), + colorB: getColor(c.colorDustB) + }); + + _emit.position = _pos; + _emit.radius = c.clusterRadius; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chaffSpeed * 1.8; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.2; + _emit.sizeVariance = 0.8; + _emit.life = c.chaffLifetime * 1.3; + _emit.lifeVariance = 0.6; + _emit.spin = c.chaffSpin * 1.4; + _emit.tint = null; + _emit.time = time; + this.chaff.emit(Math.round(c.burstChaff * g.particleCount), _emit); + + _emit.position = _target; + _emit.radius = c.clusterRadius * 0.7; + _emit.speed = c.soilSpeed * 1.6; + _emit.size = 0.11; + _emit.life = c.soilLifetime * 1.2; + _emit.spin = 11; + this.soil.emit(Math.round(c.burstSoil * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 20 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.6 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.thornwake; + const p = this._fillParams(); + + this._fillShape(); + this.field.syncGeometry(this._shape); + this._syncMaterial(); + this._syncParticles(); + + // `t` runs 0..1 while the bramble stands, then 1..2 while it withers. The + // field sinks on the second half; the vines go with it and fade out cubic, + // so the weave lets go a moment before the thorns are gone rather than being + // clipped off flat at the floor. + const retract = t <= 1 ? 0 : saturate(t - 1); + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(retract); + + // Anything the front never reached still comes up, so a cast at short range + // does not leave half its bed buried. + this.field.triggerUpTo(this.age, 1, c.riseStagger, c.frontBias, true); + this.field.update(this.age, p, retract); + this._syncVines(fade); + + this._fieldFx(dt, fade * (t <= 1 ? 0.55 : 0.2)); + + // The light settles onto the knot at the far end. + this.pointAt(1, this.position).setY(c.height * 0.35); + } + + onDestroy() { + this.field.clear(); + for (let w = 0; w < WEAVES; w++) this.weaves[w].clear(); + this._liveVines = 0; + this._liveStrands = 0; + } + + dispose() { + this.field.dispose(); + for (let w = 0; w < WEAVES; w++) this.weaves[w].dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/verdant/VinelashAbility.js b/src/abilities/verdant/VinelashAbility.js new file mode 100644 index 0000000..a0f08e4 --- /dev/null +++ b/src/abilities/verdant/VinelashAbility.js @@ -0,0 +1,895 @@ +import { BufferAttribute, BufferGeometry, Mesh, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Tube, TubePath } from '../../vfx/Tube.js'; +import { Swarm, Silhouette, LeadPath } from '../../vfx/Swarm.js'; +import { createVineBarkMaterial } from '../../materials/VineBarkMaterial.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, Easing, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** + * Rings along the bark sweep. Fifty-six is the point where the meander helix + * stops faceting at the default `vineMeanderTurns`; below about forty the stem + * develops visible flat spots wherever the curve turns fastest, which on a vine + * reads as damage rather than as low tessellation. + */ +const BARK_NODES = 56; +/** + * Facets around the section. Ten, not twenty: a stem eighteen centimetres + * across never fills enough pixels for the extra ten to be worth the vertex + * writes, and the slight angularity is *good* on something with bark. + */ +const BARK_SIDES = 10; + +/** Hard ceiling on the leaves. `leafCount` clamps here. */ +const LEAF_CAPACITY = 224; + +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _tan = new Vector3(); +const _n1 = new Vector3(); +const _n2 = new Vector3(); +const _prev = new Vector3(); + +/** + * VINELASH — a vine that grows down the line and then snaps back. + * + * **The trick is that it grows rather than flies.** Every other line cast in + * the sandbox draws a whole object and slides it, or draws a whole object and + * clips it; this one has no far end until it gets there. The stem is a + * `Tube` on the `VINE` path, where the front *is* the length — `t` is + * renormalised against `state.grow`, so the radius profile `radius × (1−s)^ + * tipTaper` puts its zero at the growing tip rather than at the target, and the + * vine genuinely tapers to nothing instead of being cut off. Leaves unfurl + * behind that front because the leaf flock's lag is resolved as a fraction of + * the *grown* stem each frame, so the formation always covers exactly [0, grow] + * however far along the cast is and whatever the sliders say. + * + * At full extension it snaps. `state.snapAge` starts a damped cosine — + * `1 − recoilAmp·e^(−recoilDamp·τ)·cos(2π·recoilFreq·τ)` — that hauls the whole + * curve back to 58% of its length in one frame and lets it ring past 100% on + * the way out. A cosine, not an exponential: an exponential-only return is a + * vine on a lift, and the overshoot is the part that reads as elastic. + * + * **The leaves come off because the geometry moves, not because a timer fired.** + * `Tube#tipSpeed` differentiates the curve with respect to the spring's own + * clock — so it is correct on a zero-length frame — and the strip rate is + * `max(0, tipSpeed − stripThreshold) × stripRate`. Pause the sandbox mid-recoil + * and drag `vineRecoilFreq`: the reported tip speed changes because the shape + * did, and the vine sheds harder. + * + * ### The bark is a swept mesh, and that is deliberate + * + * `Tube` draws three additive layers and nothing else, which is right for a + * beam and wrong for a plant: a vine has to be a solid that the sun lands on. + * So the body is one `Mesh` — one draw call — whose 560 vertices are rewritten + * every frame from the tube's own `pointAt()` / `radiusAt()` / `tangentAt()`. + * Nothing is cached: pause, drag `vineMeander`, and the bark re-sweeps onto the + * new curve on a zero-length frame, because those three functions are live and + * the sweep is the only thing between them and the vertex buffer. The tube + * itself is then only the sap: additive, depth-tested against the bark it sits + * inside, so it shows as a rim around the silhouette and blazes out of the + * tapering tip where the bark has run out of radius. + * + * The alternative was to run the bark as a `GrowthField` of short segments + * instanced along the curve. It draws in one call too, but the joints between + * segments are visible the moment the stem bends — which is all the time, this + * being a vine — and no amount of overlap hides them on the outside of a turn. + * + * A cast captures three things and all three are legal: a seed, the timestamp + * the snap fired, and the dice the particle systems roll for themselves. Every + * metre, radian and second is re-read from `settings.vinelash`. + */ +export class VinelashAbility extends Ability { + constructor(context) { + super('vinelash', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the sap column, and the authority on the curve --- */ + this.vine = new Tube({ + path: TubePath.VINE, + prefix: 'vine', + nodes: 96, + sides: 18, + renderOrder: 11 + }); + this.group.add(this.vine.group); + + /* --- the bark: one mesh, re-swept every frame --- */ + this._buildBark(); + + /* --- the leaves --- */ + this.leaves = new Swarm(this.group, { + capacity: LEAF_CAPACITY, + silhouette: Silhouette.LEAF, + // Leaves are lit foliage, not embers. Additive would make the far side of + // the flock brighter than the near side, which is exactly backwards. + additive: false, + renderOrder: 12 + }); + + /** Re-rolled per cast so no two vines writhe the same way. */ + this._seed = 0; + /** `age` at which the snap fired, or −1 while the vine is still growing. */ + this._snapAt = -1; + /** Metres of growth already paid out in bark chips. */ + this._chipDistance = 0; + + // Scratch handed to the tube each frame. One object, reused. + this._state = { + origin: new Vector3(), + target: new Vector3(), + side: new Vector3(), + progress: 1, + fade: 1, + widthFade: 1, + seed: 0, + time: 0, + grow: 0, + snapAge: -1 + }; + + // Scratch handed to the flock each frame. `Swarm#_resolve` falls back to + // `swarmParams()` for every key this does not carry, which is how the + // BIRD- and CARD-only controls stay out of the settings block. + this._flock = { + count: 0, + leadMode: LeadPath.LINE, + leadS: 0, + leadRate: 1, + leadRise: 0, + handForward: 0, + handSide: 0, + handHeight: 0, + endHeight: 0, + latticeX: 5, + latticeY: 3, + latticeZ: 12, + spacingSide: 0.28, + spacingUp: 0.24, + lag: 0.5, + jitter: 0.1, + churn: 0.5, + breathe: 0.16, + breatheRate: 1.9, + wander: 0.09, + wanderScale: 0.8, + wanderSpeed: 0.6, + gather: 0.9, + size: 0.29, + aspect: 0.78, + sizeJitter: 0.45, + billboard: 0.25, + bank: 0.05, + bankMax: 1.4, + dihedral: 0.22, + flapRate: 2.6, + curl: 0.42, + edgeStretch: 1.5, + edgeGain: 1.8, + reveal: 0, + revealSpread: 0.3, + silhouette: Silhouette.LEAF, + lit: 0.72, + tint: 0.35, + tintJitter: 0.32, + tintAlong: 0.4, + opacity: 1, + glow: 0.85, + softFade: 0.3 + }; + } + + /** + * The bark mesh. + * + * Two attribute sets, and the split is the whole design. `position` and + * `normal` are rewritten every frame from the live curve. `aBarkRing` — the + * cosine and sine of each vertex's angle around the section — never changes, + * because it is a *material* coordinate: it says where on the bark this + * vertex is, not where in the world. `aBarkAlong` sits between the two: it is + * metres from the root, which does move when `range` moves, but not when the + * vine recoils, so the grooves stay painted on the stem while the stem + * whips. See `materials/VineBarkMaterial.js` for what the shader does with + * them and why the ring is passed as a cosine and a sine rather than as an + * angle. + */ + _buildBark() { + const vertices = BARK_NODES * BARK_SIDES; + + this._barkPosition = new Float32Array(vertices * 3); + this._barkNormal = new Float32Array(vertices * 3); + this._barkAlong = new Float32Array(vertices); + const ring = new Float32Array(vertices * 2); + + for (let i = 0; i < BARK_NODES; i++) { + for (let j = 0; j < BARK_SIDES; j++) { + const k = i * BARK_SIDES + j; + const angle = (j / BARK_SIDES) * TAU; + ring[k * 2] = Math.cos(angle); + ring[k * 2 + 1] = Math.sin(angle); + } + } + + // Winding checked by hand rather than by flipping `side` until it looked + // right: with the outward normal at n1 and the section swept toward n2, + // (v00, v01, v10) has (v01−v00)×(v10−v00) along +n1, and (v01, v11, v10) + // likewise. Backwards renders a vine lit from inside that shadows nothing. + // + // The vertices are named by their lattice cell rather than a/b/c/d for a + // duller reason: `scripts/check.mjs` drops the whole `c = settings.vinelash` + // alias if the letter `c` is ever bound to something else in the file, and + // it then reports a hundred and seventy live sliders as unread. + const quads = (BARK_NODES - 1) * BARK_SIDES; + const index = new Uint16Array(quads * 6); + let w = 0; + for (let i = 0; i < BARK_NODES - 1; i++) { + for (let j = 0; j < BARK_SIDES; j++) { + const jn = (j + 1) % BARK_SIDES; + const v00 = i * BARK_SIDES + j; + const v01 = i * BARK_SIDES + jn; + const v10 = (i + 1) * BARK_SIDES + j; + const v11 = (i + 1) * BARK_SIDES + jn; + index[w++] = v00; + index[w++] = v01; + index[w++] = v10; + index[w++] = v01; + index[w++] = v11; + index[w++] = v10; + } + } + + this.barkGeometry = new BufferGeometry(); + this.barkGeometry.setAttribute('position', new BufferAttribute(this._barkPosition, 3)); + this.barkGeometry.setAttribute('normal', new BufferAttribute(this._barkNormal, 3)); + this.barkGeometry.setAttribute('aBarkAlong', new BufferAttribute(this._barkAlong, 1)); + this.barkGeometry.setAttribute('aBarkRing', new BufferAttribute(ring, 2)); + this.barkGeometry.setIndex(new BufferAttribute(index, 1)); + + this.barkMaterial = createVineBarkMaterial(this.ctx.environment); + + this.bark = new Mesh(this.barkGeometry, this.barkMaterial); + this.bark.castShadow = true; + this.bark.receiveShadow = true; + // The vertices move every frame and the bounds with them; culling against a + // stale sphere pops the whole vine out on the frame the camera swings. + this.bark.frustumCulled = false; + this.bark.matrixAutoUpdate = false; + this.bark.layers.set(LAYER.WORLD); + this.bark.renderOrder = 2; + this.bark.visible = false; + this.group.add(this.bark); + + /* Per-ring scratch for the sweep. Allocated once — I3. */ + this._ringPoint = new Float32Array(BARK_NODES * 3); + this._ringTangent = new Float32Array(BARK_NODES * 3); + this._ringRadius = new Float32Array(BARK_NODES); + this._ringArc = new Float32Array(BARK_NODES); + } + + createParticles() { + const particles = this.ctx.particles; + + // The leaves that come off in the snap. Real particles rather than more + // flock, because a stripped leaf has to stop obeying the stem — and the + // flock's whole contract is that every agent is a function of the lead. + this.torn = particles.get('vinelash.torn', { + capacity: 1200, + shape: ParticleShape.LEAF, + additive: false, + lit: true, + softFade: 0.3 + }); + this.torn.uniforms.uDrag.value = 1.9; + this.torn.uniforms.uEndSize.value = 0.9; + this.torn.uniforms.uSizeIn.value = 0.05; + this.torn.uniforms.uFadeIn.value = 0.06; + this.torn.uniforms.uFadeOut.value = 0.5; + + // Pollen and spore light shaken off the stem as it grows. + this.pollen = particles.get('vinelash.pollen', { + capacity: 1400, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.45 + }); + this.pollen.uniforms.uDrag.value = 1.5; + this.pollen.uniforms.uEndSize.value = 0.2; + this.pollen.uniforms.uSizeIn.value = 0.08; + this.pollen.uniforms.uFadeIn.value = 0.1; + this.pollen.uniforms.uFadeOut.value = 0.45; + + // Bark shed by the stem thickening behind the front. + this.chips = particles.get('vinelash.chips', { + capacity: 800, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.5; + this.chips.uniforms.uEndSize.value = 0.7; + this.chips.uniforms.uFadeOut.value = 0.6; + + this.tornEmitter = new RateEmitter(); + this.pollenEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.leaves.count; + } + + /** The vine holds and rings after the snap, then withers. */ + get impactDuration() { + return Math.max(0.05, settings.vinelash.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.vinelash.fadeTime); + } + + /** + * Sap light does not gutter and it does not glint — it *pulses*, on the same + * clock the bark's sap wave runs on, so the light and the surface agree about + * where the bolus of sap is. + */ + lightShimmer() { + const c = settings.vinelash; + return 0.82 + 0.18 * Math.sin(this.age * c.sapPulseSpeed * Math.PI); + } + + /* ------------------------------------------------------------------ */ + /* Geometry of the cast — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** Where the stem sprouts, in world space. */ + _rootPoint(out) { + const c = settings.vinelash; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** Where it would reach at full extension. */ + _reachPoint(out) { + this.pointAt(1, out); + out.y = settings.vinelash.endHeight; + return out; + } + + /** How far the vine has grown, 0..1 of the cast line. */ + get _grow() { + return this.phase === AbilityPhase.TRAVEL ? Math.max(0.001, this.u) : 1; + } + + /** Seconds since the recoil was triggered; negative while it is still growing. */ + get _snapAge() { + return this._snapAt < 0 ? -1 : Math.max(0, this.age - this._snapAt); + } + + /* ------------------------------------------------------------------ */ + /* Per-frame sync */ + /* ------------------------------------------------------------------ */ + + /** + * Push the cast state and the live block into the sap column. + * + * @param {number} fade 1 while the vine is alive, ramping to 0 as it dies + * @param {number} widthFade master radius multiplier — the collapse to a thread + */ + _syncVine(fade, widthFade) { + const state = this._state; + this._rootPoint(state.origin); + this._reachPoint(state.target); + state.side.copy(this.side); + state.fade = fade; + state.widthFade = widthFade; + state.seed = this._seed; + state.time = this.age; + state.grow = this._grow; + state.snapAge = this._snapAge; + // `progress` is overridden by `grow` inside Tube for the VINE path; it is + // set anyway so the state object reads the same as every other tube's. + state.progress = state.grow; + + this.vine.sync(settings.vinelash, state, settings.global); + } + + /** + * Re-sweep the bark onto whatever curve the tube is currently describing. + * + * Two passes. The first walks the rings and records centre, tangent, radius + * and cumulative arc length; the second needs the neighbours' radii to get + * the normal right, which is why it cannot be folded into the first. + * + * The normal is *not* the radial direction. On a stem that tapers to nothing + * over its last few per cent the radial normal is wrong by nearly ninety + * degrees at the tip, and the tip then lights as though it were facing the + * camera whatever the sun is doing. Tilting it back by `dr/ds` costs two + * subtractions and fixes it. + */ + _syncBark(fade) { + const c = settings.vinelash; + const grow = this._grow; + const span = this.vine.span; + const scale = c.barkScale; + const swell = c.barkSwell; + const nodeRate = c.barkNodes; + + const position = this._barkPosition; + const normal = this._barkNormal; + const along = this._barkAlong; + const rp = this._ringPoint; + const rt = this._ringTangent; + const rr = this._ringRadius; + const ra = this._ringArc; + + /* --- pass one: the spine --- */ + let arc = 0; + for (let i = 0; i < BARK_NODES; i++) { + const t = (i / (BARK_NODES - 1)) * grow; + this.vine.pointAt(t, _pos); + this.vine.tangentAt(t, _tan); + + if (i > 0) arc += _pos.distanceTo(_prev); + _prev.copy(_pos); + + // Node swellings: a vine is thicker where a leaf came out of it. Keyed + // off metres of stem rather than off `t`, so they keep their physical + // spacing when the cast is longer. + const material = t * span; + const bulge = 1 + swell * (0.5 + 0.5 * Math.sin(material * nodeRate * TAU)); + + rp[i * 3] = _pos.x; + rp[i * 3 + 1] = _pos.y; + rp[i * 3 + 2] = _pos.z; + rt[i * 3] = _tan.x; + rt[i * 3 + 1] = _tan.y; + rt[i * 3 + 2] = _tan.z; + rr[i] = this.vine.radiusAt(t) * scale * bulge; + ra[i] = arc; + } + + /* --- pass two: the skin --- */ + for (let i = 0; i < BARK_NODES; i++) { + _tan.set(rt[i * 3], rt[i * 3 + 1], rt[i * 3 + 2]); + + // The section frame, Gram-Schmidted off the cast's own side vector so the + // seam in the bark stays on the same side of the stem as it bends. + _n1.copy(this.side).addScaledVector(_tan, -this.side.dot(_tan)); + if (_n1.lengthSq() > 1e-8) _n1.normalize(); + else _n1.set(0, 1, 0).cross(_tan).normalize(); + _n2.crossVectors(_tan, _n1).normalize(); + + // dr/ds by central difference on the two neighbours. At the ends the + // one-sided difference is used; a zero there would leave the root and the + // tip lit as flat discs. + const lo = i > 0 ? i - 1 : i; + const hi = i < BARK_NODES - 1 ? i + 1 : i; + const ds = ra[hi] - ra[lo]; + const slope = ds > 1e-5 ? (rr[hi] - rr[lo]) / ds : 0; + const nScale = 1 / Math.sqrt(1 + slope * slope); + + const r = rr[i]; + const t = (i / (BARK_NODES - 1)) * grow; + const material = t * span; + + for (let j = 0; j < BARK_SIDES; j++) { + const k = i * BARK_SIDES + j; + const angle = (j / BARK_SIDES) * TAU; + const ca = Math.cos(angle); + const sa = Math.sin(angle); + + const nx = _n1.x * ca + _n2.x * sa; + const ny = _n1.y * ca + _n2.y * sa; + const nz = _n1.z * ca + _n2.z * sa; + + position[k * 3] = rp[i * 3] + nx * r; + position[k * 3 + 1] = rp[i * 3 + 1] + ny * r; + position[k * 3 + 2] = rp[i * 3 + 2] + nz * r; + + normal[k * 3] = (nx - _tan.x * slope) * nScale; + normal[k * 3 + 1] = (ny - _tan.y * slope) * nScale; + normal[k * 3 + 2] = (nz - _tan.z * slope) * nScale; + + along[k] = material; + } + } + + this.barkGeometry.attributes.position.needsUpdate = true; + this.barkGeometry.attributes.normal.needsUpdate = true; + this.barkGeometry.attributes.aBarkAlong.needsUpdate = true; + + this.barkMaterial.userData.sync(); + this.barkMaterial.opacity = saturate(fade) * settings.global.opacity; + this.bark.visible = grow > 0.01 && fade > 0.02; + } + + /** + * The leaf flock. + * + * `lag` is the one derived number in the ability, and the derivation *is* the + * design: the back rank has to sit at the root and the front rank at the + * growing tip, whatever the cast length and whatever the growth speed. Ranks + * are placed at `leadS − lagFraction · lag · leadRate`, so setting + * `lag = leafSpan · grow / leadRate` puts them at `grow · (1 − lagFraction · + * leafSpan)` — the grown stem, exactly, on every frame of the growth. + * + * Making `leafSpan` seconds instead was the first attempt. It works for one + * cast length and one growth speed and is wrong for every other: the flock + * either bunches at the tip or trails out behind the caster's shoulder. + * + * @param {number} strip 0..1 — how far through the stripping we are + * @param {number} fade master alpha + */ + _syncLeaves(strip, fade) { + const c = settings.vinelash; + const g = settings.global; + const p = this._flock; + const grow = this._grow; + + this.leaves.setBasis(this.origin, this.direction, this.side, this.length); + this.leaves.setColors( + getColor(c.colorLeafA), + getColor(c.colorLeafB), + getColor(c.colorLeafC), + getColor(c.colorLeafD) + ); + + const rate = (c.speed * g.speed) / Math.max(0.1, this.length); + + p.count = Math.min(LEAF_CAPACITY, Math.round(c.leafCount * g.particleCount)); + p.leadMode = LeadPath.LINE; + p.leadS = grow; + p.leadRate = rate; + p.leadRise = c.leafLeadRise; + p.handForward = c.handForward; + p.handSide = c.handSide; + p.handHeight = c.handHeight; + p.endHeight = c.endHeight; + + p.latticeX = c.leafLatticeX; + p.latticeY = c.leafLatticeY; + p.latticeZ = c.leafLatticeZ; + p.spacingSide = c.leafSpacingSide; + p.spacingUp = c.leafSpacingUp; + p.lag = (c.leafSpan * grow) / Math.max(0.01, rate); + p.jitter = c.leafJitter * g.randomness; + p.churn = c.leafChurn * g.animationSpeed; + p.breathe = c.leafBreathe; + p.breatheRate = c.leafBreatheRate * g.animationSpeed; + p.wander = c.leafWander * g.turbulence; + p.wanderScale = c.leafWanderScale * g.noiseFrequency; + p.wanderSpeed = c.leafWanderSpeed * g.noiseSpeed; + p.gather = c.leafGather; + + p.size = c.leafSize * g.particleSize; + p.aspect = c.leafAspect; + p.sizeJitter = c.leafSizeJitter * g.randomness; + p.billboard = c.leafBillboard; + p.bank = c.leafBank; + p.bankMax = c.leafBankMax; + p.dihedral = c.leafFold; + p.flapRate = c.leafFlapRate * g.animationSpeed; + p.curl = c.leafCurl; + p.edgeStretch = c.leafEdgeStretch; + p.edgeGain = c.leafEdgeGain; + // Unfurling and stripping are the same control read from both ends: the + // wave runs up the dice as the vine grows and back down them as it sheds. + p.reveal = saturate(grow) * (1 - saturate(strip)); + p.revealSpread = c.leafRevealSpread; + + p.silhouette = Silhouette.LEAF; + p.lit = c.leafLit; + p.tint = c.leafTint; + p.tintJitter = c.leafTintJitter * g.randomness; + p.tintAlong = c.leafTintAlong; + p.opacity = c.leafOpacity * fade * g.opacity; + p.glow = c.leafGlow * g.glow; + p.softFade = c.leafSoftFade; + + this.leaves.update(this.age, p); + } + + /** Push the live gradients and scales into the three particle systems. */ + _syncParticles() { + const c = settings.vinelash; + const g = settings.global; + + this.torn.setGradient( + getColor(c.colorTornA), + getColor(c.colorTornB), + getColor(c.colorTornC), + getColor(c.colorTornD) + ); + this.torn.uniforms.uGravity.value.set(0, c.tornGravity, 0); + this.torn.uniforms.uSizeScale.value = c.tornSize * g.particleSize * 7; + this.torn.uniforms.uLifeScale.value = g.particleLifetime; + this.torn.uniforms.uSpeedScale.value = g.particleSpeed; + this.torn.uniforms.uOpacity.value = g.opacity; + this.torn.uniforms.uTurbulence.value = 0.5 * g.turbulence; + + this.pollen.setGradient( + getColor(c.colorPollenA), + getColor(c.colorPollenB), + getColor(c.colorPollenC), + getColor(c.colorPollenD) + ); + this.pollen.uniforms.uGravity.value.set(0, c.pollenRise, 0); + this.pollen.uniforms.uSizeScale.value = c.pollenSize * g.particleSize * 7; + this.pollen.uniforms.uLifeScale.value = c.pollenLifetime * 0.5 * g.particleLifetime; + this.pollen.uniforms.uSpeedScale.value = g.particleSpeed; + this.pollen.uniforms.uOpacity.value = g.opacity; + this.pollen.uniforms.uGlow.value = 1.1 * g.glow; + this.pollen.uniforms.uTurbulence.value = c.pollenTurbulence * g.turbulence; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + this.chips.uniforms.uSizeScale.value = c.chipSize * g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** Pollen off the length of the stem, and bark chips off the front. */ + _growthFx(dt, scale) { + const c = settings.vinelash; + const g = settings.global; + const time = frame.uTime.value; + const grow = this._grow; + + const pollenCount = Math.round(this.pollenEmitter.tick(dt, c.pollenRate * scale) * g.particleCount); + if (pollenCount > 0) { + const s = Math.random() * grow; + this.vine.pointAt(s, _pos); + _emit.position = _pos; + _emit.radius = this.vine.radiusAt(s) * 2.2 + 0.06; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.pollenSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.6; + _emit.life = c.pollenLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.pollen.emit(pollenCount, _emit); + } + + // Chips are paid out per *metre* of front travel rather than per second, so + // a slow vine does not lay a denser trail than a fast one. + const step = 1 / Math.max(0.05, c.chipRate); + let guard = 0; + while (this.front - this._chipDistance >= step && guard++ < 24) { + this._chipDistance += step; + const s = saturate(this._chipDistance / Math.max(0.1, this.length)); + this.vine.pointAt(s, _pos); + _emit.position = _pos; + _emit.radius = this.vine.radiusAt(s) * 1.4 + 0.04; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.3).setY(-0.5).normalize(); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.chips.emit(Math.max(1, Math.round(g.particleCount)), _emit); + } + + this.ctx.shake.rumble(c.rumble * g.cameraShake, dt); + } + + /** + * Leaves torn off by the recoil. + * + * The rate is a function of the tip's speed, which `Tube` differentiates from + * the curve rather than from a frame delta. That is the whole point: on the + * frame the spring is at its fastest the vine sheds hardest, and on a paused + * frame with `vineRecoilAmp` dragged upward it sheds harder still. + */ + _stripFx(dt) { + const c = settings.vinelash; + const g = settings.global; + const over = Math.max(0, this.vine.tipSpeed - c.stripThreshold); + if (over <= 0) { + this.tornEmitter.tick(dt, 0); + return; + } + + const count = Math.round(this.tornEmitter.tick(dt, over * c.stripRate) * g.particleCount); + if (count <= 0) return; + + const s = randRange(0.15, 1); + this.vine.pointAt(s, _pos); + this._tornEmit(_pos, this.vine.radiusAt(s) * 2.5 + 0.1, count, 1); + } + + /** One batch of torn leaves. Shared by the trickle and the snap's burst. */ + _tornEmit(position, radius, count, force) { + const c = settings.vinelash; + _emit.position = position; + _emit.radius = radius; + _emit.direction = _dir.copy(this.direction).multiplyScalar(-0.35).setY(0.7).normalize(); + _emit.speed = c.tornSpeed * force; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.14; + _emit.sizeVariance = 0.8; + _emit.life = c.tornLifetime; + _emit.lifeVariance = 0.55; + _emit.spin = c.tornSpin; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.torn.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.tornEmitter.reset(); + this.pollenEmitter.reset(); + this._chipDistance = 0; + this._snapAt = -1; + + // The two things a cast is allowed to keep: a unitless dice roll, and (from + // `onImpact`) the moment an event fired. + this._seed = Math.random() * 100; + this.leaves.roll(this._seed); + this.vine.visible = true; + this.barkMaterial.userData.uniforms.uWither.value = 0; + + this._syncVine(1, 1); + this._syncBark(1); + this._syncLeaves(0, 1); + this._syncParticles(); + } + + onTravel(dt) { + this._syncVine(1, 1); + this._syncBark(1); + this._syncLeaves(0, 1); + this._syncParticles(); + this._growthFx(dt, 1); + + // The light rides the growing tip, which is the tube's own tip point rather + // than the ground line `advance()` left in `position`. + this.position.copy(this.vine.tipPoint); + } + + onImpact() { + const c = settings.vinelash; + const g = settings.global; + + // The one timestamp. Everything the recoil does is a function of + // `age − snapAt` against live sliders, so dragging `vineRecoilDamp` with + // the clock stopped re-rings a spring that has already been struck. + this._snapAt = this.age; + this._syncVine(1, 1); + + _pos.copy(this.vine.tipPoint); + + /* the burst of leaf litter where the tip was */ + this.ctx.bursts.spawn(BurstMode.EARTH, _pos, { + radius: c.burstSize * 0.25, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.4, + displace: 0.7, + squash: 0.85, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* dust kicked off the floor beneath it */ + this.pointAt(1, _dir); + this.ctx.decals.spawn(DecalType.DUSTRING, _dir, { + radius: c.dustRadius * g.explosionIntensity, + life: c.dustLife, + intensity: c.dustIntensity, + colorA: getColor(c.colorDust), + colorB: getColor(c.colorBurstA) + }); + + /* the leaves that leave all at once */ + this.vine.pointAt(0.6, _pos); + this._tornEmit(_pos, Math.max(0.4, this.length * 0.3), Math.round(c.stripBurst * g.particleCount), 1.6); + + this.ctx.shake.add( + c.snapShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorSnapFlash), c.snapFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.9 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.vinelash; + + // `t` runs 0..1 while the vine holds and the spring rings out, then 1..2 + // while it withers. The wither collapses the radius as well as the alpha: + // a dying vine goes thin before it goes away, and a straight opacity ramp + // reads as somebody turning the ability off. + const dying = saturate(t - 1); + const fade = t <= 1 ? 1 : 1 - Easing.inCubic(dying); + const widthFade = Math.max(0.04, 1 - 0.9 * Easing.outQuad(dying)); + + this._syncVine(fade, widthFade); + this._syncBark(fade); + + const strip = saturate(this._snapAge / Math.max(0.05, c.stripFade)); + this._syncLeaves(strip, fade); + this._syncParticles(); + + this.barkMaterial.userData.uniforms.uWither.value = c.witherDarken * Easing.outQuad(dying); + + this._stripFx(dt); + this._growthFx(dt, fade * (t <= 1 ? 0.5 : 0.2)); + + this.position.copy(this.vine.tipPoint); + } + + onDestroy() { + this.leaves.reset(); + this.bark.visible = false; + this.vine.visible = false; + this._snapAt = -1; + this.barkMaterial.userData.uniforms.uWither.value = 0; + } + + dispose() { + this.vine.dispose(); + this.leaves.dispose(); + this.barkGeometry.dispose(); + this.barkMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/void/NightfallAbility.js b/src/abilities/void/NightfallAbility.js new file mode 100644 index 0000000..93ecb5f --- /dev/null +++ b/src/abilities/void/NightfallAbility.js @@ -0,0 +1,531 @@ +import { Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { VolumeHull, HullShape, Medium } from '../../vfx/VolumeHull.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope (I3). Nothing below allocates per frame. */ +/* ---------------------------------------------------------------- */ + +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hub = new Vector3(); +const _emit = {}; +/** Refilled every frame and handed to `GroundField.update`. Never rebuilt. */ +const _floor = {}; + +const TAU = Math.PI * 2; + +/** + * NIGHTFALL — the only ability in the set that makes the frame darker. + * + * A far cast. A dome of `Medium.VOID` closes over the footprint from the rim + * inward, stands sealed, and opens again. The floor under it goes to near + * black, a slow starfield turns inside it, and the caster's own dynamic light + * is visibly losing. + * + * ## THE TRICK — subtraction, three times over + * + * **1 · The volume multiplies.** `VolumeHull` draws a non-additive medium with + * premultiplied "over" — `ONE, ONE_MINUS_SRC_ALPHA` — and premultiplied over + * with a near-black premultiplied colour *is* multiplication: the destination + * is scaled by `1 - alpha` and almost nothing is added back. That is why the + * palette bottoms out at `#000000` and why `nightScatter` is kept an order + * under `nightAbsorption`; a medium whose scatter approaches its absorption + * hands light back, and this one may not. + * + * **What tone mapping did to it, and what fixed it.** The dome draws into the + * linear HDR buffer and ACES runs later, in `OutputPass`. ACES is monotonic, so + * scaling the input down always scales the output down — but it is compressive + * at the top, so taking a bloomed highlight to a fifth of itself buys far less + * than a fifth on screen, and the first build read as "slightly hazy" over + * anything bright. The fix is *not* more density: absorption high enough to + * black the dome out on its own kills the transmittance in the first + * half-metre and every star behind that integrates at `T = 0` and never + * appears. It is `nightVoidBite`, which lifts the *final alpha* after the + * emission has been gathered — so the dome reaches genuine black and keeps its + * stars. Bloom is the residual: `UnrealBloomPass` runs before tone mapping and + * after the dome, so bright geometry just outside the footprint still bleeds + * energy back over it. Nothing here can stop that; what it can do is not add to + * it, which is why there is no burst shell, no additive decal, and no additive + * particle system except the stars. + * + * **2 · The floor shades.** `GroundField(WET)`, alpha-blended, is the one mode + * in the library built to come out *darker* than the stone it lies on. + * + * **3 · The flash is negative.** `GradeShader` mixes the frame *toward* the + * flash colour rather than adding it, so `ctx.flash.trigger(black, castDim)` is + * a screen-wide dim. Nothing else in the project uses `ScreenFlash` that way. + * + * ## The lid, and the uniform that is really an aperture + * + * `HullShape.DOME` has no aperture parameter — `Hollow` and `Throat` are + * cone-only — and a second hull would mean a second raymarch over the same + * pixels. So the lid is `heightBias` driven past its nominal `0..1`: the + * silhouette term is `(1 - r) · (1 - bias·yn)`, which goes **negative** above + * `yn = 1/bias`, and negative silhouette is zero density. The medium therefore + * fills only the part of the dome below that latitude, and the circle where it + * meets the dome's surface has radius `R·sqrt(1 - (1/bias)²)`. Walking that + * circle from the boundary up to the apex closes the hole from the rim inward, + * in one uniform. See `_lidBias` for why the beat authors the *circle* and + * inverts for the uniform rather than the other way round. + * + * It closes *raggedly* for free, too. Erosion in `mediumDensity` scales with + * `clamp(1 - shape, 0, 2)²`, which peaks exactly where the silhouette crosses + * zero — the lip of the aperture. A hard analytic edge would have been the tell + * and nothing had to be written to avoid it. + * + * The value is poked into `uHeightBias` immediately after `sync()`, not written + * into the settings block: `nightHeightBias` stays the author's base and the + * beat is added on top, so both are live under a paused clock. + * + * Two draw calls for the whole cast, plus two shared particle systems. + */ +export class NightfallAbility extends Ability { + constructor(context) { + super('nightfall', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + // 30 steps and no shadow taps. A dome this wide covers a large slice of the + // frame and there is nothing inside it to light, so the taps would be paid + // for and thrown away — see the cost table on `VolumeHull`. + this.dome = new VolumeHull({ + hull: HullShape.DOME, + medium: Medium.VOID, + prefix: 'night', + maxSteps: 48, + shadow: false, + renderOrder: 12, + seed: 0 + }); + this.group.add(this.dome.mesh); + + // Non-additive, because the whole point is that it shades. `depthTest` is + // left on: the character standing in the footprint should occlude the mark + // rather than be painted over by it. + this.floor = new GroundField(this.group, { + mode: GroundMode.WET, + additive: false, + depthTest: true, + name: 'nightfall.floor' + }); + this.floor.setVisible(false); + + /** Re-rolled per cast: decorrelates the medium and the floor grain. */ + this._seed = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The veil. Non-additive — the only particle system in the project whose + // job is to *occlude*. Additive smoke around a subtractive dome lights its + // own outline, which is precisely backwards, and that is what the first + // build looked like: a black dome wearing a grey halo. + this.veil = particles.get('nightfall.veil', { + capacity: 900, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.veil.uniforms.uDrag.value = 1.9; + this.veil.uniforms.uEndSize.value = 2.4; + this.veil.uniforms.uSizeIn.value = 0.18; + this.veil.uniforms.uFadeIn.value = 0.24; + this.veil.uniforms.uFadeOut.value = 0.45; + + // The stars that lift inside it. The only additive thing the ability owns, + // and deliberately tiny: they are the same read as the volume's own specks, + // carried out past the medium so the dome does not look like a shell with + // a painting inside it. + this.stars = particles.get('nightfall.stars', { + capacity: 500, + shape: ParticleShape.SOFT, + additive: true, + softFade: 0.5 + }); + this.stars.uniforms.uDrag.value = 1.1; + this.stars.uniforms.uEndSize.value = 0.45; + this.stars.uniforms.uSizeIn.value = 0.16; + this.stars.uniforms.uFadeIn.value = 0.2; + this.stars.uniforms.uFadeOut.value = 0.5; + + this.veilEmitter = new RateEmitter(); + this.starEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return (this.dome.mesh.visible ? 1 : 0) + (this.floor.object3D.visible ? 1 : 0); + } + + /** The lid closes, then it stands sealed. */ + get impactDuration() { + const c = settings.nightfall; + return Math.max(0.2, (c.closeTime + c.holdTime) * settings.global.lifetime); + } + + /** The lid opens, then the last of the medium thins out. */ + get fadeDuration() { + const c = settings.nightfall; + return Math.max(0.2, c.openTime + c.settleTime); + } + + /** The live footprint, metres. What the indicator measured out. */ + get radius() { + return Math.max(0.1, settings.nightfall.zoneRadius); + } + + /* ------------------------------------------------------------------ */ + /* The beats — all unitless, all resolved against live times */ + /* ------------------------------------------------------------------ */ + + /** Centre of the footprint: the far end of the aimed line. */ + _hubPoint(out) { + return this.pointAt(1, out).setY(0); + } + + /** + * 0 while the sky is open, 1 when the lid has met at the apex. + * + * A pure function of the phase clocks against the *live* `closeTime` and + * `openTime`, so dragging either re-times a dome that is already moving. + */ + _seal() { + const c = settings.nightfall; + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.FADE) { + return 1 - Easing.inOutCubic(saturate(this.fadeTime / Math.max(0.05, c.openTime))); + } + return Easing.inOutCubic(saturate(this.impactTime / Math.max(0.05, c.closeTime))); + } + + /** 0..1 through the tail of the fade, where the medium thins out entirely. */ + _settle() { + const c = settings.nightfall; + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate((this.fadeTime - c.openTime) / Math.max(0.05, c.settleTime)); + } + + /** + * The lid, expressed as a `heightBias` — see the class doc for why that + * uniform is an aperture at all. + * + * The beat is authored as the **rim of the hole**, `rho`, a fraction of the + * dome's radius, and the uniform is *inverted out of it*: + * `rho = sqrt(1 - 1/bias²)`, so `bias = 1/sqrt(1 - rho²)`. That inversion is + * not decoration. The relation is violently non-linear near `bias = 1` — bias + * 5.4 puts the rim at 0.98 R and bias 1.15 puts it at 0.49 R — so sweeping + * the *uniform* linearly leaves the closing circle loitering near the + * boundary for two thirds of the close and then snapping shut in the last + * few frames. It reads as a dropped frame, not as a lid. Sweeping `rho` + * linearly and solving for the uniform makes the circle travel evenly. + * + * Two stages. `lidRimShare` of the close walks the rim from `lidOpenRim` to + * the apex; the rest takes the bias on down to `lidSeal`, which stops the + * density falling with height at all and settles the sealed dome into solid + * black. Splitting them matters: blending toward `lidSeal` while the rim is + * still travelling collapses the aperture early, because `lidSeal` is a much + * smaller number than any bias the aperture is passing through. + */ + _lidBias(seal) { + const c = settings.nightfall; + const share = Math.min(0.99, Math.max(0.01, c.lidRimShare)); + const rimPhase = saturate(seal / share); + const fillPhase = saturate((seal - share) / (1 - share)); + + const rho = saturate(c.lidOpenRim) * (1 - Math.pow(rimPhase, Math.max(0.05, c.lidCurve))); + // Clamped away from 1: rho = 1 is a hole the size of the dome, which is no + // dome at all, and the reciprocal goes to infinity on the way there. + const aperture = 1 / Math.sqrt(Math.max(0.004, 1 - rho * rho)); + return lerp(aperture, c.lidSeal, Easing.inOutCubic(fillPhase)); + } + + /** Master density envelope. 0 hides the hull outright — a real cost control. */ + _domeFade() { + const c = settings.nightfall; + if (this.phase === AbilityPhase.TRAVEL) return 0; + if (this.phase === AbilityPhase.FADE) return 1 - Easing.inQuad(this._settle()); + return Easing.outQuad(saturate(this.impactTime / Math.max(0.02, c.formTime))); + } + + /** + * The caster's light, losing. + * + * Two terms: a flutter it fights with, and a straight scaling by the seal. + * The second one is the read — a light that merely flickers inside a black + * dome looks like a light in fog, and a light that visibly gives ground looks + * like a light being *taken*. + */ + lightShimmer() { + const c = settings.nightfall; + const w = c.lightStruggle * this.age; + const flutter = 0.84 + 0.16 * Math.sin(w) * Math.sin(w * 0.41); + return flutter * (1 - saturate(c.lightSmother) * this._seal()); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** Push live settings and the current beat into both draw calls. */ + _syncNight() { + const c = settings.nightfall; + const g = settings.global; + + const R = this.radius; + const seal = this._seal(); + const fade = this._domeFade(); + + /* ---------------- the dome ---------------- */ + this._hubPoint(_hub); + this.dome + .place(_hub, this.direction) + .setSize(R * c.domeSpread, R * c.domeHeight, R * c.domeSpread) + .setFade(fade) + .sync(c, g); + + this.dome.material.uniforms.uHeightBias.value = c.nightHeightBias + this._lidBias(seal); + + /* ---------------- the floor ---------------- */ + const opening = + this.phase === AbilityPhase.FADE + ? Easing.inQuad(saturate(this.fadeTime / Math.max(0.05, c.openTime))) + : 0; + + _floor.centre = _hub; + _floor.yaw = Math.atan2(this.direction.x, this.direction.z); + _floor.height = c.floorHeight; + _floor.radius = R * c.floorSpread; + // The footprint snaps out with the cast and then *deepens in place*. The + // first build grew it outward from the centre while the lid closed inward, + // and two fronts running opposite ways read as two effects rather than one. + _floor.grow = + this.phase === AbilityPhase.IMPACT + ? Easing.outCubic(saturate(this.impactTime / Math.max(0.02, c.floorGrow))) + : 1; + // Dries back from the edges as the lid reopens — which is the one direction + // `recede` runs, and happily the one the reopening wants. + _floor.recede = opening; + _floor.fade = fade; + _floor.seed = this._seed; + _floor.edge = c.floorEdge; + _floor.ragged = c.floorRagged; + _floor.raggedScale = c.floorRaggedScale; + _floor.warp = c.floorWarp; + _floor.relief = c.floorRelief; + _floor.normalStep = c.floorNormalStep; + _floor.ambient = c.floorAmbient; + _floor.wrap = c.floorWrap; + _floor.specular = c.floorSpecular; + _floor.gloss = c.floorGloss; + _floor.parallax = c.floorParallax; + _floor.cell = c.floorCell; + _floor.depth = c.floorDepth; + _floor.lift = c.floorLift; + _floor.detail = c.floorDetail; + _floor.speed = c.floorSpeed; + _floor.flow = c.floorFlow; + _floor.windAngle = c.floorWind; + _floor.additive = false; + _floor.emissive = c.floorEmissive * g.glow; + _floor.opacity = c.floorOpacity * lerp(saturate(c.floorShallow), 1, seal); + _floor.opacityScale = g.opacity; + _floor.depthFade = c.floorDepthFade; + _floor.colorBase = c.colorFloor; + _floor.colorEdge = c.colorFloorEdge; + _floor.colorGlow = c.colorFloorGlow; + _floor.colorDeep = c.colorFloorDeep; + _floor.noiseStrength = g.noiseStrength; + _floor.noiseFrequency = g.noiseFrequency; + _floor.noiseSpeed = g.noiseSpeed; + this.floor.update(_floor); + + /* ---------------- the two particle systems ---------------- */ + this.veil.setGradient( + getColor(c.colorVeilA), + getColor(c.colorVeilB), + getColor(c.colorVeilC), + getColor(c.colorVeilD) + ); + this.veil.uniforms.uGravity.value.set(0, c.veilRise, 0); + this.veil.uniforms.uSizeScale.value = c.veilSize * g.particleSize; + this.veil.uniforms.uLifeScale.value = c.veilLifetime * 0.5 * g.particleLifetime; + this.veil.uniforms.uSpeedScale.value = c.veilSpeed * g.particleSpeed; + this.veil.uniforms.uOpacity.value = c.veilOpacity * g.opacity; + this.veil.uniforms.uTurbulence.value = c.veilTurbulence * g.turbulence; + + this.stars.setGradient( + getColor(c.colorStarA), + getColor(c.colorStarB), + getColor(c.colorStarC), + getColor(c.colorStarD) + ); + this.stars.uniforms.uGravity.value.set(0, c.starRise, 0); + this.stars.uniforms.uSizeScale.value = c.starSize * g.particleSize * 7; + this.stars.uniforms.uLifeScale.value = c.starLifetime * 0.5 * g.particleLifetime; + this.stars.uniforms.uSpeedScale.value = c.starSpeed * g.particleSpeed; + this.stars.uniforms.uOpacity.value = g.opacity; + this.stars.uniforms.uGlow.value = 1.6 * g.glow; + this.stars.uniforms.uTurbulence.value = c.starTurbulence * g.turbulence; + } + + /** + * The veil boiling off the dome's skin, and the stars lifting inside it. + * + * @param {number} scale 0..1 — thinned as the medium forms and again as it goes + */ + _nightFx(dt, scale) { + const c = settings.nightfall; + const g = settings.global; + const time = frame.uTime.value; + const R = this.radius; + + this._hubPoint(_hub); + + const veilCount = Math.round(this.veilEmitter.tick(dt, c.veilRate * scale) * g.particleCount); + if (veilCount > 0) { + // Seated on a ring, not scattered through the disc: the veil is the + // dome's *skin* coming off, and a disc of it fills the interior with grey + // — which is the one colour the interior may not be. + const a = Math.random() * TAU; + const seat = R * c.veilSeat; + _pos.set(_hub.x + Math.cos(a) * seat, 0.1, _hub.z + Math.sin(a) * seat); + _emit.position = _pos; + _emit.radius = R * 0.16; + _emit.direction = _dir.set(Math.cos(a) * 0.25, 1, Math.sin(a) * 0.25).normalize(); + _emit.speed = c.veilSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.75; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 1.0; + _emit.sizeVariance = 0.5; + _emit.life = c.veilLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.25; + _emit.tint = null; + _emit.time = time; + this.veil.emit(veilCount, _emit); + } + + const starCount = Math.round(this.starEmitter.tick(dt, c.starRate * scale) * g.particleCount); + if (starCount > 0) { + const a = Math.random() * TAU; + const seat = R * c.starSeat * Math.sqrt(Math.random()); + _pos.set(_hub.x + Math.cos(a) * seat, randRange(0.1, R * c.domeHeight * 0.7), _hub.z + Math.sin(a) * seat); + _emit.position = _pos; + _emit.radius = R * 0.1; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.starSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.75; + _emit.life = c.starLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.stars.emit(starCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.veilEmitter.reset(); + this.starEmitter.reset(); + // The one thing this cast captures, besides its own timestamps. + this._seed = Math.random() * 100; + + this.floor.setVisible(true); + this._syncNight(); + } + + onTravel(dt) { + // Nothing has formed yet — `_domeFade()` is 0 through TRAVEL, so the hull + // is hidden and the floor mark has not grown. Syncing anyway is not waste: + // it keeps every uniform resolved from live settings on every frame, + // including the paused ones, which is the whole of I1. + this._syncNight(); + this.pointAt(this.u, this.position); + this.position.y = settings.nightfall.lightHeight * 0.5; + } + + onImpact() { + const c = settings.nightfall; + const g = settings.global; + + // The negative flash. `GradeShader` mixes the frame toward this colour, so + // a near-black one dims the whole screen rather than blowing it out. It is + // the only place in the project where `ScreenFlash` subtracts, and it is + // why the dome does not need to fight the tone curve on its own. + this.ctx.flash.trigger(getColor(c.colorFall), c.castDim * g.explosionIntensity); + + this.ctx.shake.add( + c.sealShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 9 + ); + } + + /** + * Both the sealed hold and the reopening run through here — the base class + * calls it for `IMPACT` (`t` 0..1) and `FADE` (`t` 1..2). Nothing here reads + * `t`: every beat is a pure function of `impactTime` / `fadeTime` against the + * live durations, which is what lets all four of them be dragged mid-cast. + */ + onFade(dt, _t) { + const c = settings.nightfall; + this._syncNight(); + + // The light climbs to its working height and sits in the middle of the + // dome, where it can be seen to lose. + this._hubPoint(this.position); + this.position.y = c.lightHeight; + + const seal = this._seal(); + this._nightFx(dt, this._domeFade() * (0.4 + 0.6 * seal)); + // The rumble belongs to the lid *moving*, not to the phase: it is loud + // while the aperture is travelling in either direction and drops to almost + // nothing while the dome stands. Keying it off `t` instead put the loud + // rumble across the whole hold, which read as an engine rather than a seal. + this.ctx.shake.rumble( + (seal > 0.995 ? c.holdRumble : c.rumble) * settings.global.cameraShake, + dt + ); + } + + onDestroy() { + // `setFade(0)` hides the hull outright. `frustumCulled` is off on a volume, + // so an invisible-but-drawn dome would keep paying full fill rate. + this.dome.setFade(0); + this.floor.setVisible(false); + } + + dispose() { + this.dome.dispose(); + this.floor.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/void/SilenceAbility.js b/src/abilities/void/SilenceAbility.js new file mode 100644 index 0000000..6a3a07d --- /dev/null +++ b/src/abilities/void/SilenceAbility.js @@ -0,0 +1,558 @@ +import { Color, Mesh, ShaderMaterial, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { Hook, sceneHooks } from '../../vfx/SceneHooks.js'; +import { uprightQuad } from '../../vfx/quads.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { frame, sharedUniforms } from '../../core/FrameUniforms.js'; +import { LAYER } from '../../core/Layers.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _hub = new Vector3(); + +/* ---------------------------------------------------------------------- */ +/* The rim */ +/* ---------------------------------------------------------------------- */ +/** + * One billboarded annulus, and it is the entire visible half of this ability. + * + * ## Why a billboard and not a ring on the floor + * + * The hole is a *sphere*, so its boundary is a screen-space circle at the + * sphere's silhouette — not a circle on the ground. A ground ring only agrees + * with the void where the two happen to meet and disagrees with it everywhere + * else, which reads as a decal lying near a hole rather than as the edge of + * one. A quad turned to face the camera in the vertex shader, centred on the + * anchor and scaled to the radius, *is* that silhouette, to within the + * perspective term — hence `rimSwell`, which nudges the ring a per cent or two + * outward so the band centre never lands exactly on the proxy's own depth. + * + * ## The half of the ring you never see, and why that is the good part + * + * `depthTest` is **on**. The band is drawn at the depth of the hole's centre, + * so every fragment of it that falls inside the silhouette is behind the depth + * proxy's front surface and is discarded by the same test that erased the + * world there. The rim erases its own inner half. Nothing in this file asks for + * that and there is no code for it: it is the one mechanism, applied twice. + * + * ## Why it is nearly black + * + * The hole is punched in the *scene* pass, so `UnrealBloomPass` runs after it + * and anything bright beside the void bleeds across the rim — `SceneHooks` + * states that caveat plainly and it is not fixable from here. So the rim is a + * dark band with one lit term (`lipGain`) on its outer edge, rolled off by a + * Reinhard curve that asymptotes at `lipCeiling` and therefore provably cannot + * reach `post.bloomThreshold`. The first version had a hot cyan rim; it looked + * superb standing still and put a glowing halo *over* the absence the instant + * anybody raised the bloom, which is the one failure this ability cannot + * survive. + * + * ## The `atan` in here is not the trap + * + * The README's warning is about sampling a *field* on `atan(y, x)`: every + * radius along a bearing gets the same value and you draw spokes. This samples + * the bearing to modulate the ring's **radius**, which is the one thing an + * angular coordinate is actually for — it makes the circle breathe instead of + * sitting there like a UI element. + */ +function createRimMaterial() { + const uniforms = sharedUniforms({ + uAnchor: { value: new Vector3() }, + /** Metres — half the billboard's width. Re-resolved every frame. */ + uExtent: { value: 1 }, + /** The hole's own vertical squash, applied to the offset in world Y. */ + uSquash: { value: 1 }, + /** Ring radius as a fraction of the half-extent. */ + uRingAt: { value: 0.77 }, + /** Band half-width and wander, as fractions of the half-extent. */ + uRimWidth: { value: 0.07 }, + uWaver: { value: 0.03 }, + uWaverSpeed: { value: 0.5 }, + /** Per-cast phase offset, so two concurrent voids do not breathe in step. */ + uSeed: { value: 0 }, + uRimOpacity: { value: 0.9 }, + uInnerBias: { value: 0.6 }, + uLipGain: { value: 0.5 }, + uLipCeiling: { value: 0.6 }, + uFade: { value: 0 }, + uColorRim: { value: new Color() }, + uColorLip: { value: new Color() } + }); + + const material = new ShaderMaterial({ + name: 'SilenceRim', + transparent: true, + // Normal blending, never additive: see the doc comment. An additive rim on + // a hole punched before the bloom pass is the one way to break this. + depthWrite: false, + depthTest: true, + toneMapped: true, + uniforms, + vertexShader: /* glsl */ ` + uniform vec3 uAnchor; + uniform float uExtent; + uniform float uSquash; + varying vec2 vRimUv; + + void main() { + // Camera right and up, straight off the view matrix — no camera object + // is needed on the CPU and the quad never has a matrix to update. + vec3 right = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]); + vec3 up = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]); + + vec2 q = position.xy; // the shared upright quad, -0.5 .. 0.5 + vRimUv = q * 2.0; // -1 .. 1, so length() is the radius + + vec3 offset = (right * q.x + up * q.y) * (uExtent * 2.0); + // The hole is squashed in WORLD y, so the ring is too. Squashing the + // billboard's own up-vector instead only agreed with the void when the + // camera happened to be level. + offset.y *= uSquash; + gl_Position = projectionMatrix * viewMatrix * vec4(uAnchor + offset, 1.0); + } + `, + fragmentShader: /* glsl */ ` + uniform float uTime; + uniform float uRingAt; + uniform float uRimWidth; + uniform float uWaver; + uniform float uWaverSpeed; + uniform float uSeed; + uniform float uRimOpacity; + uniform float uInnerBias; + uniform float uLipGain; + uniform float uLipCeiling; + uniform float uFade; + uniform vec3 uColorRim; + uniform vec3 uColorLip; + varying vec2 vRimUv; + + void main() { + if (uFade <= 0.001) discard; + + float r = length(vRimUv); + float bearing = atan(vRimUv.y, vRimUv.x); + + // Two incommensurate harmonics on the RADIUS. One alone reads as an + // ellipse; three or more reads as a wobbling blob and the boundary + // stops being a boundary. + float wander = (sin(bearing * 3.0 + uSeed + uTime * uWaverSpeed) * 0.6 + + sin(bearing * 5.0 - uSeed - uTime * uWaverSpeed * 0.7) * 0.4) * uWaver; + float ring = uRingAt + wander; + + float w = max(uRimWidth, 1e-4); + float signed = r - ring; + float band = 1.0 - smoothstep(0.0, w, abs(signed)); + if (band <= 0.002) discard; + + // The one lit term, and only on the OUTSIDE half. Light on the inside + // half would be light inside the absence, which is the whole mistake. + float lip = band * smoothstep(0.0, w * 0.9, signed) * uLipGain; + lip = lip / (1.0 + lip / max(uLipCeiling, 1e-3)); + + // The inside half is denser instead. A boundary should shade toward + // what it is bounding. + float inner = 1.0 - smoothstep(-w, 0.0, signed); + + vec3 colour = uColorRim + uColorLip * lip; + float alpha = band * uRimOpacity * uFade * (1.0 + uInnerBias * inner); + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } + ` + }); + + material.userData.uniforms = uniforms; + return material; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * SILENCE — a sphere of the world stops being rendered. + * + * One beat. A front runs to the circle, the void snaps open over `openTime`, + * stands for `holdTime`, and closes. + * + * **THE TRICK — a hole in the frame, and it is achieved by drawing less than + * anything else in the sandbox.** This ability borrows `Hook.HOLE` from + * `vfx/SceneHooks.js` and writes a centre, a radius and a squash into it every + * frame. That is the whole of it. The hook parks one invisible sphere with + * `colorWrite` off at `renderOrder: -1000` on `LAYER.WORLD`, so it lands in the + * depth buffer — and in the depth *prepass* — before any opaque in the frame + * has shaded a pixel. Everything further away than its front surface fails the + * depth test and is never shaded at all. What is left in those pixels is the + * clear colour: the same flat void the floor already fades into at the edges of + * the stage. + * + * The distinction the roster line insists on is real and it is worth being + * precise about. A black disc drawn in front of the world is a *thing*: it has + * an order, it can be behind the character, it can be bloomed, it can be + * fogged. This is not a thing. The character walking between the camera and the + * zone is nearer than the proxy, passes the test, and is drawn over the void + * with correct occlusion; walk them behind it and they are gone mid-stride. A + * `ShatterField` shard flying through the volume disappears while it is inside + * and is back the instant it clears the front surface. **Nothing in the project + * had to be told the hole exists**, and no shader in this file knows about it + * either. + * + * **Two consequences the ability is built to show off.** The dust falls inward + * and is erased at the boundary with no code asking it to — particles depth + * test, and the proxy is in the prepass, so each mote also *soft-fades* into + * the surface rather than clipping at it. And the rim erases its own inner + * half, because the annulus is drawn at the anchor's depth and half of it is + * inside the silhouette. Both of those are the same one mechanism showing up + * twice, which is why the ability needs so little of its own. + * + * **What is deliberately absent.** No shell, no volume, no ground mark, no + * distortion, no additive anything, and a dynamic light turned nearly off. The + * brief for this slot was to keep the rim minimal and it is one draw call of + * nearly-black annulus; the hook's depth proxy costs two more (the prepass and + * the main pass, both counted). Four with the dust system. Adding a glow at the + * boundary is the one change that would break it — see `createRimMaterial`. + * + * **The rule that makes the editor work.** Nothing is captured but `_seed` and + * the phase machine's timestamps. `SceneHooks` holds no dimension across a + * frame boundary by design — every value in the hook arrives through a token + * setter this class calls each frame from `settings.silence` — so pausing with + * **P** and dragging `zoneRadius` re-sizes a standing void, and dragging + * `holeLift` slides the bite it has taken out of the floor. + * + * **The borrow.** `this.borrow(sceneHooks.acquire(Hook.HOLE, this))`, and + * `sceneHooks.reclaim(this)` in `onDestroy`. Both, belt and braces: a leaked + * `HOLE` leaves a sphere of the world unrendered for the rest of the session + * and there is no way for a player to work out why. + */ +export class SilenceAbility extends Ability { + constructor(context) { + super('silence', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.rimMaterial = createRimMaterial(); + // The shared upright quad — 1 × 1 in XY. It is not ours and is never + // disposed; the vertex shader does all the placement, so the mesh keeps an + // identity matrix for its whole life and there is nothing to update. + this.rim = new Mesh(uprightQuad(), this.rimMaterial); + this.rim.name = 'SilenceRim'; + this.rim.frustumCulled = false; + this.rim.matrixAutoUpdate = false; + this.rim.layers.set(LAYER.VFX); + this.rim.renderOrder = 14; + this.rim.visible = false; + this.group.add(this.rim); + + /** The borrowed hook, or null between casts. */ + this.hole = null; + /** A dice roll, so two casts do not spawn their dust identically. */ + this._seed = 0; + } + + createParticles() { + // One system, and it is here to *demonstrate* the hole rather than to + // decorate it: these motes are erased at the boundary by the depth test, + // which is the trick made visible on something small and moving. + this.dust = this.ctx.particles.get('silence.dust', { + capacity: 1200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.5 + }); + this.dust.uniforms.uDrag.value = 0.6; + this.dust.uniforms.uEndSize.value = 0.3; + this.dust.uniforms.uSizeIn.value = 0.08; + this.dust.uniforms.uFadeIn.value = 0.12; + this.dust.uniforms.uFadeOut.value = 0.45; + + this.dustEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + // One annulus, plus the hook's depth proxy while it is held. The proxy is + // not in this ability's group and never will be — it belongs to the world. + return (this.rim.visible ? 1 : 0) + (this.hole ? 1 : 0); + } + + /** It opens, then it stands open. */ + get impactDuration() { + const c = settings.silence; + return Math.max(0.1, (c.openTime + c.holdTime) * settings.global.lifetime); + } + + /** Then it closes, and the rim lets go. */ + get fadeDuration() { + const c = settings.silence; + return Math.max(0.1, c.closeTime + c.settleTime); + } + + /** An absence does not flicker. */ + lightShimmer() { + return 1; + } + + /** Centre of the footprint: the far end of the aimed line. */ + _hubPoint(out) { + return this.pointAt(1, out).setY(settings.silence.holeLift); + } + + /* ------------------------------------------------------------------ */ + /* The beats — unitless, resolved against live durations */ + /* ------------------------------------------------------------------ */ + + /** + * The hole's radius in metres, right now. + * + * A pure function of the phase clocks against the *live* times and + * `zoneRadius`, so all four of `openTime`, `holdTime`, `closeTime` and the + * radius itself can be dragged on a void that is already standing. + * + * The bounce is a half-sine added on top of an `outCubic`, not an `outBack`: + * `outBack` undershoots on the way in, and a hole that starts by getting + * *smaller than nothing* is a hole that pops. + */ + _radius() { + const c = settings.silence; + const full = Math.max(0.01, c.zoneRadius); + + if (this.phase === AbilityPhase.TRAVEL) return 0; + + if (this.phase === AbilityPhase.FADE) { + const k = saturate(this.fadeTime / Math.max(0.02, c.closeTime)); + return full * (1 - Easing.inCubic(k)); + } + + const k = saturate(this.impactTime / Math.max(0.02, c.openTime)); + return full * Easing.outCubic(k) * (1 + c.openBounce * Math.sin(Math.PI * k)); + } + + /** 0..1 on the rim's own opacity. It arrives before the hole and outlives it. */ + _rimFade() { + const c = settings.silence; + if (this.phase === AbilityPhase.TRAVEL) return c.rimTravel * saturate(this.u); + if (this.phase !== AbilityPhase.FADE) return 1; + return 1 - saturate((this.fadeTime - c.closeTime) / Math.max(0.02, c.settleTime)); + } + + /** + * Ring radius in metres. + * + * While the front is still travelling there is no hole to bound, so the ring + * stands at `gatherRadius` of the footprint and closes onto it — the one + * piece of anticipation the ability allows itself, and the reason the void + * does not simply appear out of an empty floor. + */ + _ringRadius() { + const c = settings.silence; + if (this.phase === AbilityPhase.TRAVEL) { + const full = Math.max(0.01, c.zoneRadius); + return full * lerp(c.gatherRadius, c.rimSwell, Easing.outQuad(saturate(this.u))); + } + return Math.max(0.01, this._radius() * c.rimSwell); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Write the hook and the rim from live settings. + * + * Every setter on the token also renews its lease, so there is no separate + * `hold()` to remember: a frame that writes is a frame that keeps the hook. + */ + _sync() { + const c = settings.silence; + const g = settings.global; + + this._hubPoint(_hub); + const radius = this._radius(); + + // I6 in spirit: `acquire()` on a real hook never returns null, but a typo'd + // hook id does, and an ability that assumes otherwise takes the frame loop + // down with it. + if (this.hole) { + this.hole + .atPoint(_hub) + .size(radius, c.holeSquash) + .blend(saturate(c.holeWeight)); + } + + /* --- the rim --- */ + const u = this.rimMaterial.uniforms; + const ring = this._ringRadius(); + const extent = Math.max(0.02, ring * Math.max(1.02, c.rimPad)); + const fade = this._rimFade(); + + u.uAnchor.value.copy(_hub); + u.uExtent.value = extent; + u.uSquash.value = c.holeSquash; + // The ring, the band and the wander are all authored in metres and divided + // by the extent here, so a rim stays 9 cm wide whatever the hole is doing. + u.uRingAt.value = ring / extent; + u.uRimWidth.value = c.rimWidth / extent; + u.uWaver.value = c.rimWaver / extent; + u.uWaverSpeed.value = c.rimWaverSpeed * g.noiseSpeed; + u.uSeed.value = this._seed; + u.uRimOpacity.value = c.rimOpacity * g.opacity; + u.uInnerBias.value = c.rimInnerBias; + u.uLipGain.value = c.lipGain * g.glow; + u.uLipCeiling.value = c.lipCeiling; + u.uFade.value = fade; + u.uColorRim.value.copy(getColor(c.colorRim)); + u.uColorLip.value.copy(getColor(c.colorLip)); + + this.rim.visible = fade > 0.001; + + /* --- the dust --- */ + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize * 7; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + } + + /** + * Motes released on a shell around the void, travelling inward. + * + * The bearing is the only random number here and the metres are all live: the + * shell sits at `dustSpawn` × the *current* radius, so while the hole is + * opening the dust is released further out every frame and the whole fall + * widens with it. + */ + _dustFx(dt, scale) { + const c = settings.silence; + const g = settings.global; + const count = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (count <= 0) return; + + const radius = Math.max(0.05, this._radius() || c.zoneRadius * 0.6); + const bearing = Math.random() * TAU; + const shell = radius * Math.max(1.05, c.dustSpawn); + + this._hubPoint(_hub); + _dir.set(Math.cos(bearing), 0, Math.sin(bearing)); + _pos.copy(_hub).addScaledVector(_dir, shell); + // Released across the height of the void rather than on a flat ring: a ring + // of dust round a sphere reads as a planet with an accretion disc, which is + // the wrong ability entirely. + _pos.y = _hub.y + (Math.random() - 0.5) * radius * 1.4; + + _emit.position = _pos; + _emit.radius = radius * 0.25; + // Inward. The hole eats them; nothing tells them to stop. + _emit.direction = _dir.multiplyScalar(-1); + _emit.speed = c.dustSpeed; + _emit.speedVariance = 0.5; + _emit.spread = 0.35; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.dustSize; + _emit.sizeVariance = 0.6; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.dust.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this._seed = Math.random() * 100; + + // Borrowed, not taken. `Ability#destroy()` returns it however the cast ends + // — including the player pressing C mid-cast and a fifth cast pushing this + // one off the concurrency cap, which are the two paths that used to leave + // the world broken until reload. + this.hole = this.borrow(sceneHooks.acquire(Hook.HOLE, this)); + + this._sync(); + } + + onTravel(dt) { + // The hole has no radius yet and the rim is barely there. Syncing anyway is + // not waste: it keeps every value resolved from live settings on every + // frame, paused ones included, and it renews the hook's lease. + this._sync(); + this.pointAt(this.u, this.position); + this.position.y = settings.silence.holeLift * 0.5; + + this.ctx.shake.rumble(settings.silence.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.silence; + const g = settings.global; + + // A *negative* flash: `GradeShader` mixes the frame toward this colour, so a + // near-black one dims the screen instead of blowing it out. It is the only + // punctuation this ability gets, and the Nightfall precedent for it. + this.ctx.flash.trigger(getColor(c.colorDim), c.castDim * g.explosionIntensity); + + this.ctx.shake.add( + c.openShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 11 + ); + } + + /** + * Both the open hold and the close run through here — the base class calls it + * for `IMPACT` (`t` 0..1) and `FADE` (`t` 1..2). Nothing reads `t`: every beat + * is a function of `impactTime` / `fadeTime` against the live durations, which + * is what lets all four of them be dragged mid-cast. + */ + onFade(dt, _t) { + this._sync(); + + this._hubPoint(this.position); + // The dust falls hardest while the void is at its widest and stops the + // moment there is nothing left to fall into. + const open = saturate(this._radius() / Math.max(0.01, settings.silence.zoneRadius)); + this._dustFx(dt, open); + } + + onDestroy() { + // Belt and braces. `destroy()` already releases everything `borrow()` was + // handed; this is the line the README asks for and it is idempotent. + sceneHooks.reclaim(this); + this.hole = null; + this.rim.visible = false; + this.rimMaterial.uniforms.uFade.value = 0; + } + + dispose() { + // `uprightQuad()` is module-lifetime and shared — never disposed here. + this.rimMaterial.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/void/SingularityAbility.js b/src/abilities/void/SingularityAbility.js new file mode 100644 index 0000000..66e024f --- /dev/null +++ b/src/abilities/void/SingularityAbility.js @@ -0,0 +1,908 @@ +import { Mesh, PlaneGeometry, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { createInfallMaterial, createHorizonMaterial } from '../../materials/SingularityMaterial.js'; +import { createBoltRibbonGeometry } from '../../assets/ProceduralGeometry.js'; +import { DistortionField, DistortionMode, DistortionFacing } from '../../vfx/Distortion.js'; +import { GroundField, GroundMode, groundFieldParams } from '../../vfx/GroundField.js'; +import { Swarm, Silhouette, LeadPath, swarmParams } from '../../vfx/Swarm.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/** Hard ceiling on infall streams. The editor's `streams` slider clamps here. */ +const MAX_STREAMS = 160; +/** + * Samples along one stream's tail. The tail is a *time* window, so near the + * horizon it covers a long arc and this is the ceiling on how smoothly that arc + * bends: below about sixteen the last turn visibly polygonises. + */ +const TRAIL_NODES = 22; +/** Hard ceiling on dust motes. Sized to the lattice in the settings block. */ +const MAX_MOTES = 288; + +const TAU = Math.PI * 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _floor = new Vector3(); +const _lens = {}; +const _ground = groundFieldParams(); +const _flock = swarmParams(); + +/** + * SINGULARITY — a gravity well opened over the aimed circle. + * + * Three beats: it **forms** as a seed thrown downrange, it **pulls** for a long + * couple of seconds while everything nearby winds inward, and then it + * **inverts** — the lens flips sign for a sixth of a second and the whole thing + * is thrown back out. + * + * ## THE TRICK — the lens *is* the ability + * + * `LAYER.DISTORTION` and its half-resolution offset buffer have existed since + * the first build with nothing writing to them; the README has carried "the + * distortion pass runs with nothing writing to it" as a known rough edge for as + * long. This is the ability that earns it back. A `DistortionField` in `LENS` + * mode writes a radial screen-space displacement whose magnitude goes as 1/r² + * inside a falloff, so the floor grid, the character, the aim indicator and + * every particle *behind* the well bend around it. Nothing else in the project + * is a real screen-space effect: this is not a shell with a fresnel on it, it + * is the composed frame being resampled. + * + * Two details are the whole difference between a lens and a smudge. + * + * **`lensDepthReject` ships at 0, and it is the first thing to check if the + * floor stops bending.** The emitter's occlusion term rejects fragments that + * opaque geometry sits in front of. The lens is a billboard standing at the + * well's own height, so the floor in the lower half of that billboard is + * *nearer the camera than the emitter plane* and the depth test throws it away + * — the first version warped the sky and left the ground flat, which is exactly + * the shape of the bug that makes people think the pass is not reaching them. + * A gravity well bends the ground it is standing over. The slider is kept + * because a well behind a pillar should not warp the pillar, and that is the + * case the term was written for. + * + * **The centre is clamped and the image is not inverted.** `lensCore` clamps + * the 1/r² denominator so the middle is a finite, very hard smear instead of a + * NaN; `lensFold` at 0 stops the sample point ever crossing the centre and + * flipping the picture. Both are sliders, because both are also a *look* — but + * neither happens by accident. The one place the sign genuinely flips is the + * collapse, and that is authored (`lensFlips`) rather than emergent. + * + * ## What falls in + * + * `materials/SingularityMaterial.js` draws the accretion streams on a real + * angular-momentum orbit — `r²·dθ/dt` conserved, `r` closing linearly on the + * horizon, `θ` in closed form so the inside genuinely laps the outside — and + * the event horizon disc, which is the only object in the project darker than + * what it covers. `vfx/Swarm.js` supplies the dust as `MOTE` agents on a + * lattice that contracts as the pull tightens; `vfx/GroundField.js` in `FUNNEL` + * mode opens the floor under it. + * + * ## The rule that makes the editor work + * + * A cast captures exactly two things: `_seed`, so two wells do not draw the + * same disc, and `_inverted`, the flag that says the collapse one-shot has + * already fired. Both are events. Every metre, radian and second — the orbit + * radii, the fall time, the horizon, the lens falloff, the mote spacing, the + * depth of the hole — is resolved from `settings.singularity` inside the update + * loop, on a zero-length frame included. Pause with **P** mid-pull and drag + * `orbitRate`: the streams re-phase along their orbits, because θ is a function + * of the slider rather than something that was integrated while you were + * watching. + */ +export class SingularityAbility extends Ability { + constructor(context) { + super('singularity', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the infall: one instanced ribbon, one draw call --- */ + this.streamGeometry = createBoltRibbonGeometry(TRAIL_NODES, MAX_STREAMS); + this.streamMaterial = createInfallMaterial(); + this.streamMesh = new Mesh(this.streamGeometry, this.streamMaterial); + this.streamMesh.frustumCulled = false; + this.streamMesh.matrixAutoUpdate = false; + this.streamMesh.layers.set(LAYER.VFX); + this.streamMesh.renderOrder = 11; + this.group.add(this.streamMesh); + + /* --- the horizon, drawn *over* the streams so they end at it --- */ + this.horizonGeometry = new PlaneGeometry(1, 1, 1, 1); + this.horizonMaterial = createHorizonMaterial(); + this.horizonMesh = new Mesh(this.horizonGeometry, this.horizonMaterial); + this.horizonMesh.frustumCulled = false; + this.horizonMesh.matrixAutoUpdate = false; + this.horizonMesh.layers.set(LAYER.VFX); + this.horizonMesh.renderOrder = 13; + this.group.add(this.horizonMesh); + + /* --- the lens. The ability. --- */ + this.lens = new DistortionField({ + mode: DistortionMode.LENS, + facing: DistortionFacing.BILLBOARD, + name: 'Singularity:lens' + }); + this.group.add(this.lens.object3D); + + /* --- the floor being drawn into it --- */ + this.funnel = new GroundField(this.group, { + mode: GroundMode.FUNNEL, + additive: false, + name: 'Singularity:funnel' + }); + this.funnel.setVisible(false); + + /* --- the dust --- */ + this.motes = new Swarm(this.group, { + capacity: MAX_MOTES, + silhouette: Silhouette.MOTE, + additive: true, + renderOrder: 12 + }); + + /** Re-rolled per cast so no two wells draw the same disc. */ + this._seed = 0; + /** Has the collapse one-shot fired yet? An event, not a dimension. */ + this._inverted = false; + this._streamCount = 1; + + /** + * The cast's beats, all unitless, refilled every frame. One object, reused. + * + * open 0..1 how much of the well exists + * pull 0..1 how far through the long haul it is + * flip 1..0 the collapse impulse, decaying over `invertTime` + * blow 0..1 the outward throw + * fade 1..0 master opacity + */ + this._b = { open: 0, pull: 0, flip: 0, blow: 0, fade: 1 }; + + /** Scratch state handed to both bespoke materials. */ + this._state = { + centre: new Vector3(), + age: 0, + seed: 0, + open: 0, + pull: 0, + blow: 0, + fade: 1 + }; + } + + createParticles() { + const particles = this.ctx.particles; + + // Dust: the one system in the project that uses the particle shader's + // SWIRL path, and it is here because a well needs matter *orbiting* it + // rather than drifting past. The trick is that `uSwirlExpand` is negative: + // the offset from the anchor contracts over the particle's life, so each + // mote runs a slow inward spiral about the well without a single line of + // simulation. Emitted with zero speed so the anchor does not drift. + this.dust = particles.get('singularity.dust', { + capacity: 2400, + shape: ParticleShape.SOFT, + additive: true, + swirl: true, + softFade: 0.4 + }); + this.dust.uniforms.uDrag.value = 1.2; + this.dust.uniforms.uEndSize.value = 0.35; + this.dust.uniforms.uSizeIn.value = 0.1; + this.dust.uniforms.uFadeIn.value = 0.12; + this.dust.uniforms.uFadeOut.value = 0.45; + + // Chips torn off the floor. Lit rather than additive: the debris around a + // black hole should be silhouetted against the glow, not part of it. + this.shards = particles.get('singularity.shards', { + capacity: 1200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.shards.uniforms.uDrag.value = 0.4; + this.shards.uniforms.uEndSize.value = 0.7; + this.shards.uniforms.uFadeOut.value = 0.6; + + // What the collapse spits back out. + this.sparks = particles.get('singularity.sparks', { + capacity: 1600, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.sparks.uniforms.uDrag.value = 1.1; + this.sparks.uniforms.uEndSize.value = 0.2; + this.sparks.uniforms.uSizeIn.value = 0.02; + this.sparks.uniforms.uFadeIn.value = 0.03; + this.sparks.uniforms.uFadeOut.value = 0.4; + + this.dustEmitter = new RateEmitter(); + this.shardEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._streamCount + this.motes.count; + } + + /** Form and pull share the impact phase; the collapse owns the fade. */ + get impactDuration() { + const c = settings.singularity; + return Math.max(0.05, (c.formTime + c.pullTime) * settings.global.lifetime); + } + + get fadeDuration() { + const c = settings.singularity; + return Math.max(0.05, c.invertTime + c.throwTime); + } + + /** + * A well does not gutter. It breathes, slowly, on the same clock the accretion + * disc turns on — and the light is the only thing that can say "this is + * getting worse" while the geometry stays the same size. + */ + lightShimmer() { + const c = settings.singularity; + return 1 - c.lightBreathe * (0.5 - 0.5 * Math.cos(this.age * c.lightBreatheRate)); + } + + /* ------------------------------------------------------------------ */ + /* Geometry of the well — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** + * Where the well hangs, in world space. + * + * While the front is still travelling the well is a *seed* riding it, low and + * moving; once it lands it sits at `wellHeight` over the aimed circle. Both + * ends of that lerp are sliders, so dragging `wellHeight` while paused lifts + * a well that is already standing. + */ + _wellPoint(out) { + const c = settings.singularity; + const s = this.phase === AbilityPhase.TRAVEL ? this.u : 1; + this.pointAt(s, out); + out.y = lerp(c.launchHeight, c.wellHeight, Easing.outQuad(s)); + return out; + } + + /** The floor point under the well. */ + _floorPoint(out) { + const s = this.phase === AbilityPhase.TRAVEL ? this.u : 1; + return this.pointAt(s, out); + } + + /** + * Refill `this._b` from the phase clock. Fractions only — the beats decide + * *how far through* something is, and the settings block decides how big it + * is in metres. + * + * The two splits are worked out as fractions of their own phase rather than + * in seconds, so `global.lifetime` stretches the pull without sliding the + * form out from under it. + * + * @param {number} t 0..1 through the impact phase, then 1..2 through the fade + */ + _resolveBeats(t) { + const c = settings.singularity; + const b = this._b; + + if (this.phase === AbilityPhase.TRAVEL) { + b.open = c.seedOpen * Easing.outQuad(this.u); + b.pull = 0; + b.flip = 0; + b.blow = 0; + b.fade = 1; + return; + } + + if (t <= 1) { + const formFrac = saturate(c.formTime / Math.max(c.formTime + c.pullTime, 1e-3)); + const f = saturate(t / Math.max(formFrac, 1e-3)); + b.open = lerp(c.seedOpen, 1, Easing.outCubic(f)); + b.pull = saturate((t - formFrac) / Math.max(1 - formFrac, 1e-3)); + b.flip = 0; + b.blow = 0; + b.fade = 1; + return; + } + + const flipFrac = saturate(c.invertTime / Math.max(c.invertTime + c.throwTime, 1e-3)); + const s = saturate(t - 1); + // The impulse: 1 on the frame the well lets go, gone `invertTime` later. + b.flip = 1 - saturate(s / Math.max(flipFrac, 1e-3)); + b.blow = Easing.outCubic(s); + b.open = 1; + b.pull = 1; + // The well holds through the whole flip and *then* dies, so the frame the + // lens is at its most violent is a frame at full opacity. + b.fade = 1 - Easing.inCubic(saturate((s - flipFrac) / Math.max(1 - flipFrac, 1e-3))); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.dustEmitter.reset(); + this.shardEmitter.reset(); + this._inverted = false; + + // The one thing a cast captures, besides the fired-yet flag. + this._seed = Math.random() * 100; + this.motes.roll(this._seed); + + this.lens.visible = true; + this.funnel.setVisible(true); + this.funnel.clearMarks(); + + this._resolveBeats(0); + this._sync(); + this._castFx(); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push the live settings and the current beats into everything that draws. + * + * Order matters in one place only: `_wellPoint` is resolved first and every + * consumer is handed the same vector, so the lens, the horizon, the disc and + * the light cannot disagree about where the hole is by a frame. + */ + _sync() { + const c = settings.singularity; + const b = this._b; + const state = this._state; + + this._wellPoint(state.centre); + state.age = this.age; + state.seed = this._seed; + state.open = b.open; + state.pull = b.pull; + state.blow = b.blow; + state.fade = b.fade; + + /* --- the infall --- */ + this._streamCount = Math.max(1, Math.min(MAX_STREAMS, Math.round(c.streams))); + this.streamGeometry.instanceCount = this._streamCount; + this.streamMaterial.userData.sync(state); + + /* --- the horizon --- */ + this.horizonMaterial.userData.sync(state); + + this._syncLens(); + this._syncFunnel(); + this._syncMotes(); + this._syncParticles(); + + // The light lives on the well, not on the floor line the base class walks. + this.position.copy(state.centre); + } + + /** + * The lens. + * + * Magnitudes here are **screen fractions**, not metres — the pass multiplies + * by `post.distortion × global.distortion` exactly once, so nothing in this + * method may touch either of them. `strength` is what a fragment at the + * falloff edge displaces by; the 1/r² inside the emitter does the rest. + */ + _syncLens() { + const c = settings.singularity; + const b = this._b; + + // The falloff is measured in metres in the emitter's own plane, so it keeps + // its physical size as the camera moves — and the quad has to be exactly + // twice it or the window is cut off square at the corners. + const radius = c.lensRadius * c.zoneRadius * lerp(c.lensSeed, 1, b.open); + _lens.width = radius * 2; + _lens.height = radius * 2; + _lens.radius = radius; + _lens.window = c.lensWindow; + _lens.core = c.lensCore; + _lens.maxOffset = c.lensMax; + + // Ramps through the pull, then spikes on the flip. `Math.pow` on the open + // beat rather than a lerp: a lens that fades in linearly reads as a + // dissolve, and the whole point is that it *deepens*. + const ramp = Math.pow(saturate(b.open), Math.max(0.05, c.lensCurve)); + _lens.strength = + c.lensStrength * ramp * (1 + c.lensPull * b.pull) * (1 + c.lensBurst * b.flip) * b.fade; + _lens.swirl = c.lensSwirl * (1 + c.lensSwirlPull * b.pull); + + // The sign flip. Sampling inward magnifies instead of compressing, which is + // the frame everything is thrown out on; `lensFold` still refuses to let + // the sample cross the centre unless somebody has asked it to. + const flipping = c.lensFlips && b.flip > 0.001; + _lens.invert = flipping || c.lensInverted ? 1 : 0; + _lens.fold = c.lensFold; + + // See the class doc: 0 is deliberate, and 1 is why the floor stops bending. + _lens.depthReject = c.lensDepthReject; + _lens.depthFade = c.lensDepthFade; + _lens.perspective = c.lensPerspective; + _lens.perspectiveRef = c.lensPerspectiveRef; + _lens.opacity = c.lensOpacity * b.fade; + _lens.seed = this._seed; + + this.lens.setAnchor(this._state.centre); + this.lens.update(_lens); + } + + /** The hole the well is pulling the floor into. */ + _syncFunnel() { + const c = settings.singularity; + const b = this._b; + + _ground.centre = this._floorPoint(_floor); + _ground.yaw = 0; + _ground.height = c.funnelHeight; + _ground.radius = c.funnelRadius * c.zoneRadius * lerp(c.funnelSeed, 1, b.open); + _ground.grow = Easing.outCubic(saturate(b.open)); + _ground.recede = Easing.inQuad(saturate(1 - b.fade)); + _ground.fade = b.fade; + _ground.seed = this._seed; + + _ground.edge = c.funnelEdge; + _ground.ragged = c.funnelRagged; + _ground.raggedScale = c.funnelRaggedScale; + _ground.warp = c.funnelWarp; + + _ground.relief = c.funnelRelief; + _ground.parallax = c.funnelParallax; + _ground.cell = c.funnelCell; + _ground.cellJitter = c.funnelCellJitter; + _ground.seam = c.funnelSeam; + _ground.thickness = c.funnelThickness; + _ground.lift = c.funnelLift; + // The pit deepens as the pull tightens — the one place the floor gets to + // say how bad this is getting without moving a vertex. + _ground.depth = c.funnelDepth * lerp(1, c.funnelDeepen, b.pull); + _ground.sharp = c.funnelSharp; + _ground.detail = c.funnelDetail; + + _ground.additive = false; + _ground.emissive = c.funnelEmissive; + _ground.opacity = c.funnelOpacity; + _ground.depthFade = c.funnelDepthFade; + _ground.colorBase = c.colorFunnelBase; + _ground.colorEdge = c.colorFunnelEdge; + _ground.colorGlow = c.colorFunnelGlow; + _ground.colorDeep = c.colorFunnelDeep; + + const g = settings.global; + _ground.noiseStrength = g.noiseStrength; + _ground.noiseFrequency = g.noiseFrequency; + _ground.noiseSpeed = g.noiseSpeed; + _ground.opacityScale = g.opacity; + + this.funnel.update(_ground); + } + + /** + * The dust. + * + * `Swarm`'s formation is a lattice in the plane of the cast's side vector and + * world up, centred on the lead point — which for `LeadPath.POINT` is the far + * end of the cast line, i.e. exactly the well. Contracting the cell spacing + * as the pull tightens is what winds the cloud in. + * + * `churn` is deliberately *not* ramped. It is a rate the shader multiplies by + * the shared clock, so raising it mid-cast slews the whole formation by + * `t · Δω` — with `t` being seconds since the app booted, that is hundreds of + * radians in one frame. The angular acceleration in this ability lives in the + * infall streams, where θ is closed form and can be re-evaluated at will. + */ + _syncMotes() { + const c = settings.singularity; + const g = settings.global; + const b = this._b; + const p = _flock; + + p.count = Math.round(c.moteCount * g.particleCount); + p.leadMode = LeadPath.POINT; + p.endHeight = c.wellHeight; + + p.latticeX = c.moteLatticeX; + p.latticeY = c.moteLatticeY; + p.latticeZ = c.moteLatticeZ; + + // Contract on the pull, then thrown out on the collapse. One expression, + // both beats, every metre from the block. + const squeeze = lerp(1, c.moteCollapse, Easing.inOutCubic(b.pull)); + const eject = 1 + c.moteEject * b.blow; + p.spacingSide = c.moteSpacing * squeeze * eject; + p.spacingUp = c.moteSpacingUp * squeeze * eject; + p.jitter = c.moteJitter * squeeze * g.randomness; + p.lag = c.moteLag; + p.churn = c.moteChurn; + p.breathe = c.moteBreathe; + p.breatheRate = c.moteBreatheRate; + p.wander = c.moteWander * g.turbulence; + p.wanderScale = c.moteWanderScale * g.noiseFrequency; + p.wanderSpeed = c.moteWanderSpeed * g.noiseSpeed; + p.gather = c.moteGather; + + p.size = c.moteSize * g.particleSize; + p.aspect = c.moteAspect; + p.sizeJitter = c.moteSizeJitter * g.randomness; + p.billboard = 1; + p.bank = 0; + p.bankMax = 0; + p.dihedral = 0; + p.flapRate = 0; + p.curl = 0; + p.edgeStretch = c.moteEdgeStretch; + p.reveal = saturate(b.open); + p.revealSpread = c.moteRevealSpread; + + p.silhouette = Silhouette.MOTE; + p.edgeGain = 1; + p.lit = 0; + p.tint = c.moteTint; + p.tintJitter = c.moteTintJitter; + p.tintAlong = c.moteTintAlong; + p.opacity = c.moteOpacity * g.opacity * b.fade; + p.glow = c.moteGlow * g.glow; + p.softFade = c.moteSoftFade; + + this.motes.setColors(c.colorMoteA, c.colorMoteB, c.colorMoteC, c.colorMoteD); + this.motes.setBasis(this.origin, this.direction, this.side, this.length); + this.motes.update(this.age, p); + } + + /** The three particle systems, re-coloured and re-scaled every frame. */ + _syncParticles() { + const c = settings.singularity; + const g = settings.global; + + this.dust.setGradient( + getColor(c.colorDustA), + getColor(c.colorDustB), + getColor(c.colorDustC), + getColor(c.colorDustD) + ); + this.dust.uniforms.uGravity.value.set(0, c.dustRise, 0); + this.dust.uniforms.uSizeScale.value = c.dustSize * g.particleSize * 7; + this.dust.uniforms.uLifeScale.value = c.dustLifetime * 0.5 * g.particleLifetime; + this.dust.uniforms.uSpeedScale.value = g.particleSpeed; + this.dust.uniforms.uOpacity.value = c.dustOpacity * g.opacity; + this.dust.uniforms.uGlow.value = c.dustGlow * g.glow; + this.dust.uniforms.uTurbulence.value = c.dustTurbulence * g.turbulence; + // The swirl pair: radians/second about the well, and a *negative* expansion + // so the offset closes on the anchor instead of opening away from it. + this.dust.uniforms.uSwirl.value = c.dustSwirl * (1 + c.dustSwirlPull * this._b.pull); + this.dust.uniforms.uSwirlExpand.value = c.dustContract; + + this.shards.setGradient( + getColor(c.colorShardA), + getColor(c.colorShardB), + getColor(c.colorShardC), + getColor(c.colorShardD) + ); + this.shards.uniforms.uGravity.value.set(0, c.shardGravity, 0); + this.shards.uniforms.uSizeScale.value = c.shardSize * g.particleSize * 7; + this.shards.uniforms.uLifeScale.value = c.shardLifetime * 0.5 * g.particleLifetime; + this.shards.uniforms.uSpeedScale.value = g.particleSpeed; + this.shards.uniforms.uOpacity.value = g.opacity; + + this.sparks.setGradient( + getColor(c.colorSparkA), + getColor(c.colorSparkB), + getColor(c.colorSparkC), + getColor(c.colorSparkD) + ); + this.sparks.uniforms.uGravity.value.set(0, c.sparkGravity, 0); + this.sparks.uniforms.uSizeScale.value = c.sparkSize * g.particleSize * 7; + this.sparks.uniforms.uLifeScale.value = c.sparkLifetime * 0.5 * g.particleLifetime; + this.sparks.uniforms.uSpeedScale.value = g.particleSpeed; + this.sparks.uniforms.uOpacity.value = g.opacity; + this.sparks.uniforms.uGlow.value = c.sparkGlow * g.glow; + this.sparks.uniforms.uStretch.value = c.sparkStretch; + } + + /** The seed leaving the caster's hand. */ + _castFx() { + const c = settings.singularity; + const g = settings.global; + + this._wellPoint(_pos); + + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.castBurstSize * 0.9, + endRadius: c.castBurstSize * 0.15, + life: 0.35, + intensity: c.castBurstIntensity, + opacity: 0.85, + fresnel: 2.2, + displace: 0.35, + colorA: getColor(c.colorCastA), + colorB: getColor(c.colorCastB), + colorC: getColor(c.colorCastC) + }); + + this.ctx.flash.trigger(getColor(c.colorCastFlash), c.castFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.4 * g.explosionIntensity; + } + + /** + * Dust drawn off the floor and chips torn out of it. + * + * Both are seeded on a ring at the *disc's outer radius*, resolved from the + * same numbers the streams use, so the particles arrive on the same circle + * the ribbons are riding rather than near it. + * + * @param {number} scale 0..1, thinned once the well is only holding + */ + _wellFx(dt, scale) { + const c = settings.singularity; + const g = settings.global; + const time = frame.uTime.value; + const b = this._b; + const rim = c.discOuter * c.zoneRadius * lerp(c.discSeed, 1, b.open); + + const dustCount = Math.round(this.dustEmitter.tick(dt, c.dustRate * scale) * g.particleCount); + if (dustCount > 0) { + const bearing = Math.random() * TAU; + const r = rim * randRange(c.dustRing, 1.15); + _pos.copy(this._state.centre); + _pos.x += Math.cos(bearing) * r; + _pos.z += Math.sin(bearing) * r; + _pos.y += randRange(-1, 1) * c.dustSpread; + + _emit.position = _pos; + _emit.radius = c.dustSpread; + // The anchor is the well itself: the swirl path rotates the particle's + // offset from *this* point, which is what makes the orbit an orbit. + _emit.anchor = this._state.centre; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = 0; + _emit.speedVariance = 0; + _emit.spread = 1; + _emit.inherit = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.7; + _emit.life = c.dustLifetime; + _emit.lifeVariance = 0.35; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.dust.emit(dustCount, _emit); + } + + const shardCount = Math.round(this.shardEmitter.tick(dt, c.shardRate * scale) * g.particleCount); + if (shardCount > 0) { + const bearing = Math.random() * TAU; + const r = rim * randRange(0.5, 1.3); + this._floorPoint(_pos); + _pos.x += Math.cos(bearing) * r; + _pos.z += Math.sin(bearing) * r; + _pos.y += 0.08; + + // Thrown *at* the well rather than away from it: the direction is the + // vector to the centre, which is the only place in this ability a + // particle system gets to look like it is being pulled. + _dir.copy(this._state.centre).sub(_pos).normalize(); + + _emit.position = _pos; + _emit.radius = c.shardSpread; + _emit.anchor = null; + _emit.direction = _dir; + _emit.speed = c.shardSpeed; + _emit.speedVariance = 0.5; + _emit.spread = 0.35; + _emit.inherit = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.65; + _emit.life = c.shardLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.shards.emit(shardCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + this._resolveBeats(0); + this._sync(); + this._wellFx(dt, settings.singularity.travelEmission); + this.ctx.shake.rumble(settings.singularity.rumble * settings.global.cameraShake, dt); + } + + /** The well opens. */ + onImpact() { + const c = settings.singularity; + const g = settings.global; + + this._resolveBeats(0); + this._wellPoint(_pos); + + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.formBurstSize * g.explosionIntensity, + endRadius: c.formBurstSize * 0.12, + life: 0.55, + intensity: c.formBurstIntensity, + opacity: 0.9, + fresnel: 2.4, + displace: 0.4, + squash: 0.85, + colorA: getColor(c.colorFormA), + colorB: getColor(c.colorFormB), + colorC: getColor(c.colorFormC) + }); + + // A ring that runs *inward* is not something the decal system draws, so the + // shockwave here is the honest one: the ground snapping as the well seats. + this._floorPoint(_pos); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.formRingRadius * g.explosionIntensity, + life: 0.55, + width: 0.07, + intensity: c.formRingIntensity, + colorA: getColor(c.colorRingA), + colorB: getColor(c.colorRingB) + }); + + this.ctx.shake.add( + c.formShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 22 + ); + this.ctx.flash.trigger(getColor(c.colorFormFlash), c.formFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.7 * g.explosionIntensity; + } + + onFade(dt, t) { + // The collapse one-shot fires on the first frame of the fade phase, which + // is the frame the lens changes sign. A flag, not a timestamp: the beats + // below already know where in the phase we are. + if (t > 1 && !this._inverted) { + this._inverted = true; + this._invertFx(); + } + + this._resolveBeats(t); + this._sync(); + + const b = this._b; + // Dust keeps being drawn in right up to the flip, and then stops dead — + // there is nothing left to fall in once the well has turned inside out. + this._wellFx(dt, t <= 1 ? lerp(1, settings.singularity.holdEmission, b.pull) : 0); + + if (t <= 1) { + this.ctx.shake.rumble( + settings.singularity.rumble * (1 + b.pull * settings.singularity.rumblePull) * + settings.global.cameraShake, + dt + ); + } + } + + /** INVERT — the well lets go and throws everything back out. */ + _invertFx() { + const c = settings.singularity; + const g = settings.global; + const time = frame.uTime.value; + + this._wellPoint(_pos); + + this.ctx.bursts.spawn(BurstMode.STORM, _pos, { + radius: c.collapseSize * 0.15, + endRadius: c.collapseSize * g.explosionIntensity, + life: 0.8, + intensity: c.collapseIntensity, + opacity: 0.95, + fresnel: 1.5, + displace: 0.7, + colorA: getColor(c.colorCollapseA), + colorB: getColor(c.colorCollapseB), + colorC: getColor(c.colorCollapseC) + }); + + _emit.position = _pos; + _emit.radius = c.horizonRadius * 1.5; + _emit.anchor = null; + _emit.direction = _dir.set(0, 0.25, 0); + _emit.speed = c.sparkSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1; + _emit.inherit = null; + _emit.size = 0.2; + _emit.sizeVariance = 0.75; + _emit.life = c.sparkLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.sparks.emit(Math.round(c.collapseSparks * g.particleCount), _emit); + + this._floorPoint(_pos); + _emit.position = _pos; + _emit.radius = c.discOuter * c.zoneRadius * 0.7; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.shardSpeed * c.collapseThrow; + _emit.speedVariance = 0.7; + _emit.spread = 0.9; + _emit.size = 0.15; + _emit.life = c.shardLifetime * 1.4; + _emit.spin = 12; + this.shards.emit(Math.round(c.collapseShards * g.particleCount), _emit); + + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.collapseRingRadius * g.explosionIntensity, + life: 0.7, + width: 0.05, + intensity: c.collapseRingIntensity, + colorA: getColor(c.colorRingA), + colorB: getColor(c.colorRingB) + }); + this.ctx.decals.spawn(DecalType.CRACK, _pos, { + radius: c.crackRadius, + life: c.crackLife, + width: c.crackBranches, + intensity: c.crackIntensity, + colorA: getColor(c.colorCrackA), + colorB: getColor(c.colorCrackB) + }); + + this.ctx.shake.add( + c.collapseShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 30 + ); + this.ctx.flash.trigger(getColor(c.colorCollapseFlash), c.collapseFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 2 * g.explosionIntensity; + } + + onDestroy() { + // Release the distortion writer. Hiding the group instead leaks it for the + // session and the pass runs every frame with nothing in it. + this.lens.visible = false; + this.funnel.setVisible(false); + this.funnel.clearMarks(); + this.motes.reset(); + this._inverted = false; + this._streamCount = 1; + this.streamGeometry.instanceCount = 1; + this.streamMaterial.uniforms.uFade.value = 0; + this.streamMaterial.uniforms.uOpen.value = 0; + this.horizonMaterial.uniforms.uFade.value = 0; + } + + dispose() { + this.streamGeometry.dispose(); + this.streamMaterial.dispose(); + this.horizonGeometry.dispose(); + this.horizonMaterial.dispose(); + this.lens.dispose(); + this.funnel.dispose(); + this.motes.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/void/SoulchainAbility.js b/src/abilities/void/SoulchainAbility.js new file mode 100644 index 0000000..0c5453b --- /dev/null +++ b/src/abilities/void/SoulchainAbility.js @@ -0,0 +1,1061 @@ +import { + BufferAttribute, + BufferGeometry, + DynamicDrawUsage, + InstancedBufferAttribute, + InstancedMesh, + Matrix4, + Vector3 +} from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { FilamentPaths } from '../../vfx/FilamentPaths.js'; +import { createGhostIronMaterial } from '../../materials/GhostIronMaterial.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, clamp, lerp, Easing, hash11, randRange } from '../../utils/math.js'; + +const TAU = Math.PI * 2; + +/** Hard ceiling on the links. The live count clamps here. */ +const MAX_LINKS = 56; +/** + * Samples in the arc-length table. Forty-eight over a twenty-metre catenary is + * a station every 40 cm, and the links are re-spaced by linear interpolation + * between two samples, so the error in a link's position is well under a + * millimetre — far below the width of the bar it is made of. + */ +const ARC_SAMPLES = 48; +/** Steps around one link's stadium centreline. */ +const LINK_RINGS = 44; +/** Facets around the bar the link is bent from. */ +const LINK_SIDES = 8; +/** Iron fragments a break may throw in one frame, whatever the sliders say. */ +const MAX_BREAKS_PER_FRAME = 8; + +const _emit = {}; +const _pos = new Vector3(); +const _prev = new Vector3(); +const _dir = new Vector3(); +const _from = new Vector3(); +const _to = new Vector3(); +const _n1 = new Vector3(); +const _n2 = new Vector3(); +const _sa = new Vector3(); +const _sb = new Vector3(); +const _tan = new Vector3(); +const _ax = new Vector3(); +const _ay = new Vector3(); +const _az = new Vector3(); +const _axis = new Vector3(); +const _mat = new Matrix4(); +const _rot = new Matrix4(); +const _frayA = new Vector3(); +const _frayB = new Vector3(); + +/** + * SOULCHAIN — a tether of discrete iron links thrown down the line. + * + * **The trick is that the chain is not a filament.** Every other line cast that + * hangs something between two points in this sandbox draws a ribbon and calls + * it done; this one threads *objects* along a curve, and the objects have to + * agree with the curve exactly or the illusion is gone on the first frame. + * + * The curve is `FilamentPaths`' `LINK` path — a real catenary, + * `y −= slack · (cosh k − cosh(k(2t−1))) / (cosh k − 1)`, not a parabola. The + * difference is entirely at the anchors, where a hanging chain leaves much + * steeper than a parabola does, and that steepness is most of what says + * "heavy". `_chainPoint()` is a deliberate JS mirror of that branch of the + * vertex shader, the same duplication `Tube` makes for `pointAt()` and for the + * same reason: reading the shape back off the GPU is a pipeline stall, and it + * happens fifty-odd times a frame here. + * + * ### Spacing comes from the arc length, and that is the whole ability + * + * A link sits at a fixed number of **metres** along the chain, not at a fixed + * fraction of it. So every frame the curve is walked with `ARC_SAMPLES` + * samples, its length is accumulated, and the link count falls straight out of + * `floor(arcLength / spacing)`. Drag `chainSag` with the clock stopped and the + * chain visibly *grows more links* as the droop deepens, because a deeper droop + * is a longer chain. Nothing else in this project responds to a slider by + * changing how many of something there are. + * + * The first version placed links at even `t` and it was subtly, permanently + * wrong: a catenary's `t` is not arc length, so links bunched up at the bottom + * of the sag and stretched apart at the anchors — which is precisely backwards + * from a real chain, and reads as the links sliding along a wire. + * + * Each link is rotated 90° (`linkTwist`) from its neighbour about the chain's + * own tangent, because that is how chain is made. It matters more than it + * sounds: with every link coplanar the chain reads as a strip of cut-outs, and + * the alternation is the only thing that makes it look forged. + * + * ### The beats + * + * | beat | what moves | + * | --- | --- | + * | throw | the far anchor travels; the sag scales with the span, so the chain pays out | + * | snap | `chainSag → chainSagHeld` on `Easing.outBack`, which **overshoots** — the sag passes its target and the chain bows briefly *upward* before settling. An ease that only converged looked like a winch | + * | hold | a per-link shiver, `rattleAmp · e^(−rattleDecay·τ) · sin(rattleRate·t + phase)`, on a per-link dice phase so no two links jangle together | + * | break | links part one at a time from the far end back at `breakRate`, and a freed link flies on a **closed form** of the time since it broke | + * + * Nothing integrates. A freed link's position is + * `base + v₀·τ + ½gτ²` evaluated against live sliders, so pausing mid-collapse + * and dragging `breakGravity` re-flies every link that has already let go — + * which an Euler integrator physically cannot do, having already spent the old + * gravity. `ShatterField` makes the same argument at greater length. + * + * A cast captures a seed, four unitless dice per link slot, and two timestamps + * (the frame it went taut, the frame it began to break). No metres, no radians, + * no seconds of duration. + */ +export class SoulchainAbility extends Ability { + constructor(context) { + super('soulchain', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + /* --- the spirit thread and the fray, two roles on one strip --- */ + this.paths = new FilamentPaths(this.group, { + samples: 80, + capacity: 24, + renderOrder: 11 + }); + + /* --- the links --- */ + this.linkMaterial = createGhostIronMaterial(this.ctx.environment); + + // Per-instance state. These attribute objects outlive every geometry + // rebuild and are re-attached to the new one, so the buffers are uploaded + // once per frame however often the link *shape* changes. + this._aStation = new InstancedBufferAttribute(new Float32Array(MAX_LINKS), 1); + this._aSeed = new InstancedBufferAttribute(new Float32Array(MAX_LINKS), 1); + this._aBreak = new InstancedBufferAttribute(new Float32Array(MAX_LINKS), 1); + this._aStation.setUsage(DynamicDrawUsage); + this._aBreak.setUsage(DynamicDrawUsage); + + /** The shape the current link geometry was built at. See `_syncLinkGeometry`. */ + this._builtLength = -1; + this._builtWidth = -1; + this._builtBar = -1; + + this.linkGeometry = this._buildLinkGeometry(); + this.links = new InstancedMesh(this.linkGeometry, this.linkMaterial, MAX_LINKS); + this.links.instanceMatrix.setUsage(DynamicDrawUsage); + this.links.count = 0; + this.links.castShadow = true; + this.links.receiveShadow = true; + this.links.frustumCulled = false; + this.links.matrixAutoUpdate = false; + this.links.layers.set(LAYER.WORLD); + this.links.renderOrder = 2; + this.links.visible = false; + this.group.add(this.links); + + /* --- per-cast state: dice rolls and timestamps only --- */ + this._seed = 0; + /** Four unitless dice per link slot: throw bearing, pitch, spin axis, phase. */ + this._dice = new Float32Array(MAX_LINKS * 4); + /** `age` at which the chain went taut, or −1. */ + this._tautAt = -1; + /** `age` at which links began to part, or −1. */ + this._breakAt = -1; + /** How many breaks have already thrown their shards. An event counter. */ + this._brokenSeen = 0; + /** Links standing this frame, and how many of them have let go. */ + this._count = 0; + this._broken = 0; + /** Metres of sag resolved this frame — read by both the CPU and the strip. */ + this._sag = 0; + + /** Cumulative arc length at each sample. Rewritten every frame. */ + this._arc = new Float32Array(ARC_SAMPLES + 1); + + // The look handed to the strip. One object, reused; `FilamentPaths` reads + // canonical names, so this is the scratch the prefixed settings fill. + this._look = { + width: 0.024, + glowWidth: 6, + glowOpacity: 0.42, + jitter: 0.11, + jitterScale: 1.1, + octaves: 3, + jitterFalloff: 0.5, + crawl: 1.1, + pinch: 0.12, + restrike: 13, + flicker: 0.18, + flickerSpeed: 22, + strandFlash: 0.4, + coreSharp: 4.2, + glowFalloff: 2.2, + softFade: 0.6, + opacity: 0.9, + glow: 2.1, + colorCore: '#ffffff', + colorInner: '#c8fff0', + colorOuter: '#4ec8a8', + colorHalo: '#0d3a48', + randomness: 1, + noiseStrength: 1, + noiseFrequency: 1, + noiseSpeed: 1, + opacityScale: 1, + glowScale: 1 + }; + } + + /** + * One link, swept in metres. + * + * The centreline is a **stadium** — two straights joined by two semicircular + * caps — rather than an ellipse. An ellipse is one line of code shorter and + * looks like a washer: real chain links have parallel flanks, and the + * parallel flanks are what let two of them slide against each other, which is + * the shape the eye is checking for. + * + * Sized so `linkLength` and `linkWidth` are the link's **outer** extents, + * which is what anyone dragging a slider called "link length" means. The bar + * radius is subtracted out of the centreline to get there. + * + * `aLinkSection` — the cosine and sine of each vertex's angle around the bar, + * with the cosine measured against the ring's outward in-plane direction — is + * how `GhostIronMaterial` finds the inside of the hole. See its header. + */ + _buildLinkGeometry() { + const c = settings.soulchain; + const length = Math.max(0.02, c.linkLength); + const width = clamp(c.linkWidth, 0.01, length * 0.98); + const bar = clamp(c.linkThickness, 0.002, width * 0.9); + + this._builtLength = length; + this._builtWidth = width; + this._builtBar = bar; + + const cap = Math.max(0.002, (width - bar) * 0.5); // cap radius on the centreline + const straight = Math.max(0, (length - width) * 0.5); // half-length of one flank + const bore = bar * 0.5; // the swept tube's radius + const perimeter = 4 * straight + TAU * cap; + + const vertices = LINK_RINGS * LINK_SIDES; + const position = new Float32Array(vertices * 3); + const normal = new Float32Array(vertices * 3); + const section = new Float32Array(vertices * 2); + + for (let i = 0; i < LINK_RINGS; i++) { + const s = (i / LINK_RINGS) * perimeter; + + // Walked counter-clockwise from the bottom-left of the bottom flank, so + // (t.y, −t.x) is the outward in-plane normal the whole way round. + let cx; + let cy; + let tx; + let ty; + if (s < 2 * straight) { + cx = -straight + s; + cy = -cap; + tx = 1; + ty = 0; + } else if (s < 2 * straight + Math.PI * cap) { + const phi = -Math.PI * 0.5 + (s - 2 * straight) / cap; + cx = straight + cap * Math.cos(phi); + cy = cap * Math.sin(phi); + tx = -Math.sin(phi); + ty = Math.cos(phi); + } else if (s < 4 * straight + Math.PI * cap) { + cx = straight - (s - 2 * straight - Math.PI * cap); + cy = cap; + tx = -1; + ty = 0; + } else { + const phi = Math.PI * 0.5 + (s - 4 * straight - Math.PI * cap) / cap; + cx = -straight + cap * Math.cos(phi); + cy = cap * Math.sin(phi); + tx = -Math.sin(phi); + ty = Math.cos(phi); + } + + const ox = ty; + const oy = -tx; + + for (let j = 0; j < LINK_SIDES; j++) { + const k = i * LINK_SIDES + j; + const alpha = (j / LINK_SIDES) * TAU; + const ca = Math.cos(alpha); + const sn = Math.sin(alpha); + + position[k * 3] = cx + ox * ca * bore; + position[k * 3 + 1] = cy + oy * ca * bore; + position[k * 3 + 2] = sn * bore; + normal[k * 3] = ox * ca; + normal[k * 3 + 1] = oy * ca; + normal[k * 3 + 2] = sn; + section[k * 2] = ca; + section[k * 2 + 1] = sn; + } + } + + // Closed in both directions. Winding derived rather than guessed: + // cross(d/di, d/dj) works out to +N with (v00, v10, v01), which is why the + // second triangle is (v01, v10, v11) and not the obvious (v01, v11, v10). + const index = new Uint16Array(LINK_RINGS * LINK_SIDES * 6); + let w = 0; + for (let i = 0; i < LINK_RINGS; i++) { + const iNext = (i + 1) % LINK_RINGS; + for (let j = 0; j < LINK_SIDES; j++) { + const jNext = (j + 1) % LINK_SIDES; + const v00 = i * LINK_SIDES + j; + const v01 = i * LINK_SIDES + jNext; + const v10 = iNext * LINK_SIDES + j; + const v11 = iNext * LINK_SIDES + jNext; + index[w++] = v00; + index[w++] = v10; + index[w++] = v01; + index[w++] = v01; + index[w++] = v10; + index[w++] = v11; + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(position, 3)); + geometry.setAttribute('normal', new BufferAttribute(normal, 3)); + geometry.setAttribute('aLinkSection', new BufferAttribute(section, 2)); + geometry.setAttribute('aLinkStation', this._aStation); + geometry.setAttribute('aLinkSeed', this._aSeed); + geometry.setAttribute('aLinkBreak', this._aBreak); + geometry.setIndex(new BufferAttribute(index, 1)); + return geometry; + } + + /** + * Rebuild the link if one of its three dimensions has moved. + * + * `GrowthField#syncGeometry` sets the precedent: a shape that is authored in + * metres cannot be a uniform on a shared geometry, so it is rebuilt when — and + * only when — a number moves. The guard is what keeps it off the frame path; + * the rebuild is what makes `linkLength` live under a paused drag. + */ + _syncLinkGeometry() { + const c = settings.soulchain; + const length = Math.max(0.02, c.linkLength); + const width = clamp(c.linkWidth, 0.01, length * 0.98); + const bar = clamp(c.linkThickness, 0.002, width * 0.9); + + if ( + Math.abs(length - this._builtLength) < 1e-5 && + Math.abs(width - this._builtWidth) < 1e-5 && + Math.abs(bar - this._builtBar) < 1e-6 + ) { + return false; + } + + const old = this.linkGeometry; + this.linkGeometry = this._buildLinkGeometry(); + this.links.geometry = this.linkGeometry; + old.dispose(); + return true; + } + + createParticles() { + const particles = this.ctx.particles; + + // Iron off a parting link. + this.shards = particles.get('soulchain.shards', { + capacity: 900, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.shards.uniforms.uDrag.value = 0.7; + this.shards.uniforms.uEndSize.value = 0.6; + this.shards.uniforms.uFadeOut.value = 0.55; + + // What was bound in the iron, leaving it. + this.motes = particles.get('soulchain.motes', { + capacity: 1200, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.7; + this.motes.uniforms.uEndSize.value = 0.18; + this.motes.uniforms.uSizeIn.value = 0.07; + this.motes.uniforms.uFadeIn.value = 0.09; + this.motes.uniforms.uFadeOut.value = 0.42; + + // Cold breath sinking off the chain. Non-additive so it genuinely occludes + // the links behind it — an additive haze around grey iron only ever makes + // the iron paler. + this.haze = particles.get('soulchain.haze', { + capacity: 900, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.0 + }); + this.haze.uniforms.uDrag.value = 2.1; + this.haze.uniforms.uEndSize.value = 2.4; + this.haze.uniforms.uSizeIn.value = 0.14; + this.haze.uniforms.uFadeIn.value = 0.2; + this.haze.uniforms.uFadeOut.value = 0.35; + + this.moteEmitter = new RateEmitter(); + this.hazeEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this._count + this.paths.liveCount; + } + + get impactDuration() { + return Math.max(0.05, settings.soulchain.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + return Math.max(0.05, settings.soulchain.fadeTime); + } + + /** + * The soul light does not flicker like fire; it *breathes*, on the same clock + * as the pulse running along the links, so the light and the iron agree about + * where the soul currently is. + */ + lightShimmer() { + const c = settings.soulchain; + return 0.78 + 0.22 * Math.sin(this.age * c.soulPulseSpeed * Math.PI); + } + + /* ------------------------------------------------------------------ */ + /* The catenary — the JS mirror of FilamentPaths' LINK branch */ + /* ------------------------------------------------------------------ */ + + /** Where the chain leaves the caster's hand. */ + _handPoint(out) { + const c = settings.soulchain; + out + .copy(this.origin) + .addScaledVector(this.direction, c.handForward) + .addScaledVector(this.side, c.handSide); + out.y = c.handHeight; + return out; + } + + /** How much of the throw has been paid out, 0..1. */ + get _throw() { + return this.phase === AbilityPhase.TRAVEL ? Math.max(0.001, this.u) : 1; + } + + /** + * Resolve both anchors, the chain's own frame and this frame's sag. + * + * The sag is where the snap lives. `Easing.outBack` runs 0 → ~1.1 → 1, so + * interpolating `chainSag → chainSagHeld` with it takes the droop *past* its + * resting value and lets it come back: for a few frames the chain is tauter + * than taut and bows upward. `role.link()`'s own `taut` argument is clamped + * to 0..1 in the vertex shader and could not express that, which is why this + * drives the `slack` metres directly and passes `taut = 0` — the strip and + * the links then read the same number and cannot disagree. + */ + _resolveChain() { + const c = settings.soulchain; + const paid = this._throw; + + this._handPoint(_from); + this.pointAt(paid, _to); + _to.y = lerp(c.handHeight, c.endHeight, paid); + + // The frame `FilamentPaths#axisFrame` builds, mirrored exactly: world up + // unless the chord is near-vertical, which a thrown chain never is but a + // paused drag on `endHeight` can make it. + _dir.subVectors(_to, _from); + const span = Math.max(_dir.length(), 1e-4); + _dir.multiplyScalar(1 / span); + if (Math.abs(_dir.y) > 0.9) _n1.set(1, 0, 0); + else _n1.set(0, 1, 0); + _n1.crossVectors(_dir, _n1).normalize(); + _n2.crossVectors(_dir, _n1).normalize(); + + const taut = this._tautAt < 0 ? 0 : Easing.outBack(saturate((this.age - this._tautAt) / Math.max(0.02, c.tautTime))); + // Scaled by the throw so a chain that is one metre out does not droop two + // and a half. The droop is a property of how much chain is in the air. + this._sag = paid * (c.chainSag + (c.chainSagHeld - c.chainSag) * taut); + } + + /** + * A point on the chain at `t`, 0 at the hand and 1 at the far anchor. + * + * `_resolveChain()` must have run this frame. Mirrors the `LINK` branch of + * the vertex shader term for term, including the sway, so a link and the + * filament threaded through it are never a centimetre apart. + */ + _chainPoint(t, out) { + const c = settings.soulchain; + out.lerpVectors(_from, _to, t); + + const k = Math.max(c.chainCurve, 0.01); + const ck = Math.cosh(k); + const bow = (ck - Math.cosh(k * (2 * t - 1))) / Math.max(ck - 1, 1e-4); + out.y -= this._sag * bow; + + const sway = + Math.sin(frame.uTime.value * c.chainSwingSpeed + this._seed) * c.chainSwing * Math.sin(t * Math.PI); + return out.addScaledVector(_n1, sway); + } + + /** Unit tangent at `t`. Central difference on the same curve. */ + _chainTangent(t, out) { + const h = 0.004; + this._chainPoint(clamp(t - h, 0, 1), _sa); + this._chainPoint(clamp(t + h, 0, 1), _sb); + out.subVectors(_sb, _sa); + return out.lengthSq() > 1e-12 ? out.normalize() : out.copy(_dir); + } + + /** Walk the curve and fill the arc-length table. Returns its total, metres. */ + _measureChain() { + const arc = this._arc; + this._chainPoint(0, _prev); + arc[0] = 0; + for (let i = 1; i <= ARC_SAMPLES; i++) { + this._chainPoint(i / ARC_SAMPLES, _pos); + arc[i] = arc[i - 1] + _pos.distanceTo(_prev); + _prev.copy(_pos); + } + return arc[ARC_SAMPLES]; + } + + /** + * The curve parameter at `s` metres along the chain. + * + * A linear scan rather than a bisection: the table is forty-eight entries and + * the caller walks `s` upward, so the scan is a handful of comparisons and + * the bisection would be more code than it saves. + */ + _tAtArc(s) { + const arc = this._arc; + const total = arc[ARC_SAMPLES]; + if (!(s > 0) || !(total > 1e-5)) return 0; + if (s >= total) return 1; + let i = 1; + while (i < ARC_SAMPLES && arc[i] < s) i++; + const lo = arc[i - 1]; + const hi = arc[i]; + const f = hi > lo ? (s - lo) / (hi - lo) : 0; + return (i - 1 + f) / ARC_SAMPLES; + } + + /* ------------------------------------------------------------------ */ + /* Per-frame sync */ + /* ------------------------------------------------------------------ */ + + /** + * Thread the links along the curve. + * + * @param {number} fade master alpha + * @returns {number} metres of chain, for whoever wants to place something else + */ + _syncLinks(fade) { + const c = settings.soulchain; + const g = settings.global; + + this._syncLinkGeometry(); + const arcLength = this._measureChain(); + + // The one derivation the ability is built on. `linkOverlap` is clamped + // because the pause test scales every slider by 1.37 at once and an overlap + // above 1 would make the spacing negative and the count meaningless. + const spacing = Math.max(0.02, this._builtLength * (1 - clamp(c.linkOverlap, 0, 0.9))); + const count = clamp(Math.floor(arcLength / spacing), 1, MAX_LINKS); + this._count = count; + + const breakAge = this._breakAt < 0 ? -1 : Math.max(0, this.age - this._breakAt); + const breakStep = 1 / Math.max(0.1, c.breakRate); + this._broken = breakAge < 0 ? 0 : clamp(Math.floor(breakAge / breakStep), 0, count); + + // The rattle dies away once the chain is hanging still. Full while it is + // still being thrown, because a chain in flight is not settled. + const settled = this._tautAt < 0 ? 0 : Math.max(0, this.age - this._tautAt); + const jangle = this._tautAt < 0 ? 1 : Math.exp(-c.rattleDecay * settled); + const rattle = c.rattleAmp * jangle * g.randomness; + const shiver = c.rattleSway * jangle * g.randomness; + + const time = frame.uTime.value; + const dice = this._dice; + const station = this._aStation.array; + const broke = this._aBreak.array; + const life = Math.max(0.05, c.breakLife); + + for (let i = 0; i < count; i++) { + const s = (i + 0.5) * spacing; + const t = this._tAtArc(s); + this._chainPoint(t, _pos); + this._chainTangent(t, _tan); + + // The section frame, Gram-Schmidted off the chain's own n1 so the twist + // sequence keeps its phase as the curve bends. + _ax.copy(_n1).addScaledVector(_tan, -_n1.dot(_tan)); + if (_ax.lengthSq() > 1e-8) _ax.normalize(); + else _ax.set(0, 1, 0).cross(_tan).normalize(); + _ay.crossVectors(_tan, _ax).normalize(); + + const phase = dice[i * 4 + 3] * TAU; + const roll = i * c.linkTwist + rattle * Math.sin(time * c.rattleRate + phase); + + // z is the hole's normal, x is the long axis lying along the chain, and y + // closes the basis right-handed. Getting x and z the other way round + // threads every link side-on and the chain reads as a row of coins. + _az.copy(_ax).multiplyScalar(Math.cos(roll)).addScaledVector(_ay, Math.sin(roll)); + _ax.copy(_tan); + _ay.crossVectors(_az, _ax).normalize(); + + _pos.addScaledVector(_az, shiver * Math.sin(time * c.rattleRate * 0.63 + phase * 1.7)); + + const j = count - 1 - i; // 0 is the far end: the first to let go + const freeFor = breakAge < 0 ? -1 : breakAge - j * breakStep; + + if (freeFor <= 0) { + broke[i] = -1; + _mat.makeBasis(_ax, _ay, _az); + _mat.setPosition(_pos); + } else if (freeFor >= life) { + // Gone. A zero basis collapses the instance to a point rather than + // shuffling the live links down, which would restripe every station. + broke[i] = 1; + _mat.makeScale(0, 0, 0); + _mat.setPosition(_pos); + } else { + broke[i] = freeFor / life; + + // Closed form, evaluated against live sliders — see the class header. + const bearing = dice[i * 4] * TAU; + const pitch = (dice[i * 4 + 1] - 0.5) * c.breakSpread; + _axis + .copy(_az) + .multiplyScalar(Math.cos(bearing)) + .addScaledVector(_ay, Math.sin(bearing)) + .addScaledVector(_ax, pitch); + if (_axis.lengthSq() > 1e-8) _axis.normalize(); + else _axis.copy(_az); + + _pos.addScaledVector(_axis, c.breakSpeed * freeFor); + _pos.y += 0.5 * c.breakGravity * freeFor * freeFor; + + _rot.makeRotationAxis(_axis, c.breakSpin * freeFor); + _mat.makeBasis(_ax, _ay, _az); + _mat.premultiply(_rot); + _mat.setPosition(_pos); + } + + station[i] = count > 1 ? i / (count - 1) : 0; + this.links.setMatrixAt(i, _mat); + } + + this.links.count = count; + this.links.instanceMatrix.needsUpdate = true; + this._aStation.needsUpdate = true; + this._aBreak.needsUpdate = true; + + this.linkMaterial.userData.sync(); + this.linkMaterial.opacity = saturate(c.linkOpacity * fade * g.opacity); + this.links.visible = fade > 0.02 && c.linkOpacity > 0.001; + + return arcLength; + } + + /** + * The spirit thread the links are strung on, and the fray at the broken end. + * + * Two roles, one strip, two draw calls total — the cheapest saving in the + * library and the reason the whole ability draws in three. + * + * @param {number} fade master alpha + */ + _syncTether(fade) { + const c = settings.soulchain; + const g = settings.global; + const look = this._look; + + look.width = c.tetherWidth; + look.glowWidth = c.tetherGlowWidth; + look.glowOpacity = c.tetherGlowOpacity; + look.jitter = c.tetherJitter; + look.jitterScale = c.tetherJitterScale; + look.octaves = c.tetherOctaves; + look.jitterFalloff = c.tetherJitterFalloff; + look.crawl = c.tetherCrawl; + look.pinch = c.tetherPinch; + look.restrike = c.tetherRestrike; + look.flicker = c.tetherFlicker; + look.flickerSpeed = c.tetherFlickerSpeed; + look.strandFlash = c.tetherStrandFlash; + look.coreSharp = c.tetherCoreSharp; + look.glowFalloff = c.tetherGlowFalloff; + look.softFade = c.tetherSoftFade; + look.opacity = c.tetherOpacity; + look.glow = c.tetherGlow; + look.colorCore = c.colorTetherCore; + look.colorInner = c.colorTetherInner; + look.colorOuter = c.colorTetherOuter; + look.colorHalo = c.colorTetherHalo; + look.randomness = g.randomness; + look.noiseStrength = g.noiseStrength; + look.noiseFrequency = g.noiseFrequency; + look.noiseSpeed = g.noiseSpeed; + look.opacityScale = g.opacity; + look.glowScale = g.glow; + + // How much of the chain still has iron on it. The thread retracts with the + // links rather than hanging on past them, which is what makes the break-up + // read as the chain coming apart instead of the links falling off a wire. + const remaining = this._count > 0 ? saturate((this._count - this._broken) / this._count) : 0; + + const tether = this.paths.role(0); + tether.count = Math.max(0, Math.round(c.tetherCount)); + tether.style(c.tetherKink, 1, 1, 1); + tether.ends(0.02, 0.04, 0.12, 0.12); + // `taut` is 0 on purpose: the collapse is already inside `this._sag`. + tether.link(_from, _to, this._sag, c.chainCurve, c.chainSwing, c.chainSwingSpeed, 0, c.tetherSpread); + tether.draw(remaining, c.tetherTipLength, -1e4, c.tetherTipGlow); + + const fray = this.paths.role(1); + if (this._broken > 0 && remaining > 0.001) { + const t = this._tAtArc(this._arc[ARC_SAMPLES] * remaining); + this._chainPoint(t, _frayA); + this._chainTangent(t, _frayB); + _frayB.multiplyScalar(c.frayReach).add(_frayA); + + fray.count = Math.max(1, Math.round(c.frayCount)); + fray.style(c.frayKink, c.frayWidth, c.frayDim, 1); + fray.ends(0.02, 0.6, 0.05, 0.9); + fray.crack( + _frayA, + _frayB, + c.frayAngle, + c.frayLength, + c.frayFalloff, + c.fraySpread, + c.frayStart, + c.fraySag, + c.frayForkBias + ); + fray.draw(2, c.tetherTipLength, -1e4, c.tetherTipGlow); + } else { + fray.retire(); + } + + this.paths.sync(look, fade, this._seed); + } + + /** Push the live gradients and scales into the three particle systems. */ + _syncParticles() { + const c = settings.soulchain; + const g = settings.global; + + this.shards.setGradient( + getColor(c.colorShardA), + getColor(c.colorShardB), + getColor(c.colorShardC), + getColor(c.colorShardD) + ); + this.shards.uniforms.uGravity.value.set(0, c.shardGravity, 0); + this.shards.uniforms.uSizeScale.value = c.shardSize * g.particleSize * 7; + this.shards.uniforms.uLifeScale.value = g.particleLifetime; + this.shards.uniforms.uSpeedScale.value = g.particleSpeed; + this.shards.uniforms.uOpacity.value = g.opacity; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 1.2 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.haze.setGradient( + getColor(c.colorHazeA), + getColor(c.colorHazeB), + getColor(c.colorHazeC), + getColor(c.colorHazeD) + ); + this.haze.uniforms.uGravity.value.set(0, c.hazeRise, 0); + this.haze.uniforms.uSizeScale.value = c.hazeSize * g.particleSize; + this.haze.uniforms.uLifeScale.value = c.hazeLifetime * 0.5 * g.particleLifetime; + this.haze.uniforms.uSpeedScale.value = c.hazeSpeed * g.particleSpeed; + this.haze.uniforms.uOpacity.value = c.hazeOpacity * g.opacity; + this.haze.uniforms.uTurbulence.value = 0.3 * g.turbulence; + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** Soul motes off the length of the chain, and cold haze under it. */ + _chainFx(dt, scale) { + const c = settings.soulchain; + const g = settings.global; + const time = frame.uTime.value; + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + this._chainPoint(Math.random(), _pos); + _emit.position = _pos; + _emit.radius = this._builtWidth * 1.5 + 0.05; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + const hazeCount = Math.round(this.hazeEmitter.tick(dt, c.hazeRate * scale) * g.particleCount); + if (hazeCount > 0) { + this._chainPoint(Math.random(), _pos); + _emit.position = _pos; + _emit.radius = this._builtLength * 2 + 0.2; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.hazeSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 0.95; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.hazeLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.3; + _emit.tint = null; + _emit.time = time; + this.haze.emit(hazeCount, _emit); + } + } + + /** + * Shards and a mote burst for every link that has parted since last frame. + * + * `_brokenSeen` is an event counter, not a dimension: it records how many + * breaks have already been paid for, so a paused drag on `breakRate` that + * suddenly declares six more links broken pays out six bursts at once rather + * than silently swallowing them. + */ + _breakFx() { + const c = settings.soulchain; + const g = settings.global; + if (this._broken <= this._brokenSeen) return; + + let budget = MAX_BREAKS_PER_FRAME; + while (this._brokenSeen < this._broken && budget-- > 0) { + const index = Math.max(0, this._count - 1 - this._brokenSeen); + this._brokenSeen++; + + const s = (index + 0.5) * Math.max(0.02, this._builtLength * (1 - clamp(c.linkOverlap, 0, 0.9))); + this._chainPoint(this._tAtArc(s), _pos); + + _emit.position = _pos; + _emit.radius = this._builtWidth * 0.6; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.2).setY(0.8).normalize(); + _emit.speed = c.shardSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.shardLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 11; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.shards.emit(Math.round(c.shardPerBreak * g.particleCount), _emit); + + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed * 3; + _emit.size = 0.11; + _emit.life = c.moteLifetime; + _emit.spin = 0; + this.motes.emit(Math.round(6 * g.particleCount), _emit); + } + this._brokenSeen = Math.min(this._brokenSeen, this._count); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.hazeEmitter.reset(); + this._tautAt = -1; + this._breakAt = -1; + this._brokenSeen = 0; + this._broken = 0; + + // The dice. Unitless, one set per link slot, so a link's throw bearing and + // its jangle phase are stable for the whole cast however many links the + // sag decides there are. + this._seed = Math.random() * 100; + for (let i = 0; i < MAX_LINKS; i++) { + this._dice[i * 4] = Math.random(); + this._dice[i * 4 + 1] = Math.random(); + this._dice[i * 4 + 2] = Math.random(); + this._dice[i * 4 + 3] = Math.random(); + this._aSeed.array[i] = hash11(this._seed + i * 3.77); + } + this._aSeed.needsUpdate = true; + + this.paths.visible = true; + this._resolveChain(); + this._syncLinks(1); + this._syncTether(1); + this._syncParticles(); + } + + onTravel(dt) { + const c = settings.soulchain; + this._resolveChain(); + this._syncLinks(1); + this._syncTether(1); + this._syncParticles(); + this._chainFx(dt, 1); + + // The light rides the travelling end, which is the thing the eye follows. + this.position.copy(_to); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.soulchain; + const g = settings.global; + + // Timestamp one of two. Everything the snap does is a function of + // `age − tautAt` against live sliders, so dragging `tautTime` with the + // clock stopped re-times a collapse that has already happened. + this._tautAt = this.age; + this._resolveChain(); + + this.pointAt(1, _pos); + _pos.y = c.endHeight; + + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.55, + intensity: c.burstIntensity, + opacity: 0.8, + fresnel: 1.8, + displace: 0.4, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + this.pointAt(1, _dir); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _dir, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.55, + width: 0.05, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + // The jangle: every link is thrown a mote at the instant the slack goes. + this._chainPoint(randRange(0.3, 0.9), _pos); + _emit.position = _pos; + _emit.radius = Math.max(0.5, this.length * 0.28); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed * 4; + _emit.speedVariance = 0.9; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.7; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.motes.emit(Math.round(70 * g.particleCount), _emit); + + this.ctx.shake.add( + c.snapShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 28 + ); + this.ctx.flash.trigger(getColor(c.colorSnapFlash), c.snapFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 0.8 * g.explosionIntensity; + } + + onFade(dt, t) { + // `t` runs 0..1 while the chain hangs taut and rattles, then 1..2 while it + // comes apart. The second timestamp is taken on the frame the break-up + // begins; everything after is a function of `age − breakAt`. + if (t > 1 && this._breakAt < 0) this._breakAt = this.age; + + const dying = saturate(t - 1); + // The chain does not dim while it is breaking — the *links* leave, one at a + // time, and that is the effect. Only the last fifth is a fade, and it is + // there to catch whatever the break front did not reach. + const fade = 1 - Easing.inCubic(saturate((dying - 0.8) / 0.2)); + + this._resolveChain(); + this._syncLinks(fade); + this._syncTether(fade); + this._syncParticles(); + this._breakFx(); + this._chainFx(dt, fade * (t <= 1 ? 0.7 : 0.35)); + + this.position.copy(_to); + } + + onDestroy() { + this.links.count = 0; + this.links.visible = false; + this.paths.clear(); + this.paths.visible = false; + this._count = 0; + this._broken = 0; + this._brokenSeen = 0; + this._tautAt = -1; + this._breakAt = -1; + } + + dispose() { + this.paths.dispose(); + this.linkGeometry.dispose(); + this.linkMaterial.dispose(); + this.links.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/void/UmbralSpearsAbility.js b/src/abilities/void/UmbralSpearsAbility.js new file mode 100644 index 0000000..f9288fe --- /dev/null +++ b/src/abilities/void/UmbralSpearsAbility.js @@ -0,0 +1,893 @@ +import { + BufferGeometry, + Color, + Float32BufferAttribute, + MeshStandardMaterial, + Vector3 +} from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GrowthField, GrowthEmerge, GrowthLayout, patchGrowthMaterial } from '../../vfx/GrowthField.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { noiseGLSL } from '../../shaders/lib/noise.glsl.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing } from '../../utils/math.js'; + +/** Hard ceiling on spears per cast. The `spearCount` slider clamps here. */ +const MAX_SPEARS = 240; +const TAU = Math.PI * 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); +const _shadowDir = new Vector3(); +const _anchor = new Vector3(); + +/** + * Live parameter objects for the two shared modules. + * + * Filled from `settings.umbralspears` every frame and handed straight over. + * They are module scope rather than per-instance because four concurrent casts + * of this ability fill and consume them inside their own `update()` call and + * never across one — and one object per ability instance is three objects that + * exist only to be identical. + */ +const _growth = {}; +const _shape = {}; +const _shadow = {}; + +/* ---------------------------------------------------------------------- */ +/* One spear */ +/* ---------------------------------------------------------------------- */ +/** + * A barbed blade, in `GrowthField`'s unit space: footprint inside a circle of + * radius 0.5 on `y = 0`, tip at `y = 1`. + * + * Five rings and an apex. The ring that matters is the **barb** — a single ring + * pushed back out past the shoulder a third of the way up. Without it the + * silhouette is a cone, and a field of cones reads as a fence; with it there is + * a hard shoulder line that catches the rim and the whole thing reads as a + * weapon. It is also the only part of the profile that is not monotonic, which + * is why it survives being seen as pure outline: a black shape against a lit + * floor has nothing but its outline to say anything with. + * + * `twist` rotates each ring's bearings a little further round than the last, so + * the facets run up the blade as flutes rather than as straight prism edges. At + * zero it is a plain faceted spike, and that is a perfectly good look too. + * + * @param {number} variant which of the three silhouettes + * @param {object} shape `{ sides, taper, barb, barbAt, rough, twist }`, live + */ +function createSpearGeometry(variant, shape) { + const seed = 3.1 + variant * 27.7; + const sides = Math.max(4, Math.min(8, Math.round(shape?.sides ?? 5))); + const taper = Math.max(0.02, Math.min(0.8, shape?.taper ?? 0.16)); + const barb = Math.max(0.2, Math.min(2, shape?.barb ?? 1.25)); + const barbAt = Math.max(0.05, Math.min(0.8, shape?.barbAt ?? 0.3)); + const rough = Math.max(0, Math.min(1, shape?.rough ?? 0.34)); + const twist = shape?.twist ?? 0.35; + + const hash = (n) => { + const s = Math.sin(n * 127.1) * 43758.5453; + return s - Math.floor(s); + }; + + // Rings: floor, shoulder, barb, waist, neck — then the apex. + const ringY = [0, barbAt * 0.35, barbAt, lerp(barbAt, 1, 0.45), lerp(barbAt, 1, 0.8)]; + const ringR = [0.5 * 0.72, 0.5, 0.5 * barb, 0.5 * Math.max(taper, 0.03) * 1.6, 0.5 * Math.max(taper, 0.03) * 0.55]; + + const rings = ringY.map((y, ring) => { + const spin = twist * TAU * y; + const points = []; + for (let i = 0; i < sides; i++) { + const wobble = (hash(seed * 3.13 + i * 7.7) - 0.5) * (TAU / sides) * 0.5 * rough * 2; + const angle = (i / sides) * TAU + wobble + spin; + const chip = 1 + (hash(seed * 11.1 + ring * 13.7 + i * 3.9) - 0.5) * rough * 1.2 * (0.3 + 0.7 * y); + const r = Math.max(0.003, ringR[ring] * chip); + points.push([Math.cos(angle) * r, y, Math.sin(angle) * r]); + } + return points; + }); + + const positions = []; + const push = (p) => positions.push(p[0], p[1], p[2]); + + for (let ring = 0; ring < rings.length - 1; ring++) { + const lower = rings[ring]; + const upper = rings[ring + 1]; + for (let i = 0; i < sides; i++) { + const j = (i + 1) % sides; + push(lower[i]); push(lower[j]); push(upper[i]); + push(lower[j]); push(upper[j]); push(upper[i]); + } + } + + const apex = [ + (hash(seed * 17.3) - 0.5) * 0.06 * rough, + 1, + (hash(seed * 19.7) - 0.5) * 0.06 * rough + ]; + const floor = [0, 0, 0]; + const neck = rings[rings.length - 1]; + const base = rings[0]; + for (let i = 0; i < sides; i++) { + const j = (i + 1) % sides; + push(neck[i]); push(neck[j]); push(apex); + push(floor); push(base[j]); push(base[i]); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + // Non-indexed + per-face normals. Flat facets are what give a pure-silhouette + // object its only interior line: the rim breaks along every hard edge. + geometry.computeVertexNormals(); + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* The rim-only shading model */ +/* ---------------------------------------------------------------------- */ +/** + * A material with **no diffuse, no specular and no emission except a rim**. + * + * The trick is one line of setup rather than a pile of shader code: + * `metalness: 1` with a near-black colour. In the standard BRDF a metal has no + * diffuse lobe at all (`diffuse = albedo × (1 − metalness)`) and its specular + * F0 *is* its albedo — so a black metal reflects nothing and scatters nothing, + * and the only thing left in the sum is `totalEmissiveRadiance`. The first + * version tried to get there with `metalness: 0` and a black colour instead, + * and it did not work: a dielectric keeps an F0 of 0.04 whatever its albedo, so + * every spear had a soft white sheen sliding over it and the field read as wet + * plastic. The probe is switched off outright for the same reason — an + * anti-glow object that picks up the sky is a dark object with a bright sky + * on it. + * + * That leaves the rim carrying the entire read, and the rim is authored as a + * **line, not a wash**: a tight fresnel with a wider, softer copy of itself + * subtracted back off, biased toward the face turned away from + * `frame.uLightDir`, weighted toward the tip in the spear's own local space, + * and eaten into by world-space grain so it is pitted rather than drawn. + * + * **The bloom guard.** `PostProcessing` runs `UnrealBloomPass` on the linear + * HDR buffer *before* `OutputPass` tone maps, at `post.bloomThreshold` = 0.88. + * Anything this material emits above that number gets smeared across the + * silhouette the ability exists to protect — and a rim is exactly the + * high-frequency, high-contrast edge bloom is worst on. So the assembled rim is + * run through a Reinhard rolloff whose asymptote is `uRimCeiling` + * (`x / (1 + x/c)` → `c`), which means it *provably* cannot reach the + * threshold, while the bottom of `rimGain`'s range is left essentially + * untouched so the slider still does what it looks like it does. A hard + * `min()` was the first attempt and it flattened the top half of the slider + * into a dead zone. + */ +function createUmbralMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0x050308, + roughness: 0.95, + metalness: 1.0, + flatShading: true, + transparent: false, + depthWrite: true + }); + material.envMapIntensity = 0; + + const uniforms = { + uLightDir: frame.uLightDir, + uColorBody: { value: new Color() }, + uColorRim: { value: new Color() }, + uColorBirth: { value: new Color() }, + uRimPower: { value: 4.2 }, + uRimInner: { value: 0.55 }, + uRimGain: { value: 1.35 }, + uRimCeiling: { value: 0.82 }, + uRimShadowBias: { value: 0.6 }, + uRimTip: { value: 0.45 }, + uRimGrain: { value: 0.5 }, + uRimGrainScale: { value: 7.5 }, + uBirthRim: { value: 0.9 }, + /** Whole-field rim punch when the front lands. Decays on the ability's clock. */ + uImpactRim: { value: 0 } + }; + + patchGrowthMaterial(material, { + environment, + uniforms, + common: /* glsl */ ` + uniform vec3 uLightDir; + uniform vec3 uColorBody; + uniform vec3 uColorRim; + uniform vec3 uColorBirth; + uniform float uRimPower; + uniform float uRimInner; + uniform float uRimGain; + uniform float uRimCeiling; + uniform float uRimShadowBias; + uniform float uRimTip; + uniform float uRimGrain; + uniform float uRimGrainScale; + uniform float uBirthRim; + uniform float uImpactRim; + ${noiseGLSL} + `, + fragment: /* glsl */ ` + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + + // A line, not a wash. Subtracting a wider, softer copy of the same + // fresnel is what pushes the band out to the very edge of the + // silhouette; a bare pow() covers half the spear and reads as a glow. + float wide = pow(1.0 - ndv, max(uRimPower * 0.45, 0.05)); + float rim = clamp(pow(1.0 - ndv, uRimPower) - uRimInner * wide, 0.0, 1.0); + + // Brightest on the side the key light is not, because these come up out + // of the caster's shadow and the lit face is the one that stays black. + float away = clamp(0.5 - 0.5 * dot(N, uLightDir), 0.0, 1.0); + rim *= mix(1.0, away, uRimShadowBias); + + // Tip weighting in LOCAL space, so it follows each spear's own axis + // however that spear is scaled and leaned. In world space this became a + // horizontal band across the whole field at one height, which is a very + // fast way to make forty objects look like one object. + rim *= mix(1.0, clamp(vGrowLocal.y, 0.0, 1.0), uRimTip); + + // Erosion in WORLD space, so neighbouring spears are pitted out of the + // same block of darkness rather than each carrying an identical copy. + float pit = fbm3(vGrowWorld * uRimGrainScale + vGrowSeed * 7.0) * 0.5 + 0.5; + rim *= mix(1.0, pit, uRimGrain); + + vec3 glow = uColorRim * rim * (uRimGain + uImpactRim); + glow += uColorBirth * vGrowBirth * uBirthRim * (0.25 + 0.75 * rim); + + // THE BLOOM GUARD — see the doc comment. Reinhard with its asymptote at + // uRimCeiling: it can never reach post.bloomThreshold, and it leaves the + // bottom of the range alone so the gain slider stays honest. + glow = glow / (1.0 + glow / max(uRimCeiling, 1e-3)); + + // metalness is 1, so this is F0 and nothing else: the body reflects + // almost nothing and scatters nothing at all. + diffuseColor.rgb = uColorBody; + + totalEmissiveRadiance += glow; + ` + }); + + material.userData.uniforms = uniforms; + + /** Pull the palette and every rim control from the live settings. */ + material.userData.sync = (impactRim) => { + const c = settings.umbralspears; + const g = settings.global; + + uniforms.uColorBody.value.copy(getColor(c.colorBody)); + uniforms.uColorRim.value.copy(getColor(c.colorRim)); + uniforms.uColorBirth.value.copy(getColor(c.colorBirth)); + + uniforms.uRimPower.value = c.rimPower; + uniforms.uRimInner.value = c.rimInner; + uniforms.uRimGain.value = c.rimGain * g.glow; + uniforms.uRimCeiling.value = c.rimCeiling; + uniforms.uRimShadowBias.value = c.rimShadowBias * g.fresnel; + uniforms.uRimTip.value = c.rimTip; + uniforms.uRimGrain.value = c.rimGrain * g.shaderIntensity; + uniforms.uRimGrainScale.value = c.rimGrainScale * g.noiseFrequency; + uniforms.uBirthRim.value = c.birthRim; + uniforms.uImpactRim.value = impactRim; + }; + + material.userData.sync(0); + return material; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * UMBRAL SPEARS — a line of blades that comes up out of the caster's shadow. + * + * One beat, short: a front runs the aimed line at `speed` and near-black spears + * punch out of the floor behind it, a ring of them thrown up around the impact + * point. They stand for `lifetime` and withdraw. + * + * **THE TRICK — anti-glow.** Every other slot in this sandbox is competing for + * the bloom pass's attention. This one is the only thing on screen *darker than + * the floor*, and it is built out of three decisions that all point the same + * way: + * + * 1. **No shading model.** `metalness: 1` on a near-black colour removes the + * diffuse lobe and drops F0 to the body colour, so there is no diffuse, no + * specular and no probe. What is left is a silhouette. See + * `createUmbralMaterial` for why `metalness: 0` does not get you there. + * 2. **The rim is the only bright thing in the ability.** Both particle + * systems are non-additive, there is no burst shell, no screen flash and no + * additive ground mark; the impact is expressed as a punch of *rim* that + * decays over `impactRimTime`, which is the one lever this slot allows + * itself. The rim itself is rolled off so the bloom pass can never reach + * it — the guard is documented on the material. + * 3. **The shadow is subtractive.** The band pooled under the field is a + * `GroundField` in RUT mode with `additive: false`, so it genuinely shades + * the flagstones. An additive mark would have brightened the floor under a + * black object, which is the exact inverse of the effect and is what the + * first version did. + * + * **Where `frame.uLightDir` comes in.** The scene's key direction decides which + * way a shadow points, so it decides three things here: the band on the floor + * is offset `shadowOffset` metres along the horizontal projection of + * `-uLightDir`, the spears' forward lean is steered by how much of that + * direction lies along the cast (`shadowLean`), and in the fragment shader the + * rim prefers the face turned away from the light. All three are re-read every + * frame; none of them is captured. + * + * **The rule that makes the editor work.** `GrowthField` holds the field, and a + * record in it is dice rolls and one timestamp — the whole point of that + * module. Every metre, radian and second lands in `_growth` from + * `settings.umbralspears` inside the update loop, on a zero-length frame + * included, so pausing mid-cast and dragging `height` re-grows a field that is + * already standing and dragging `shadowOffset` slides its shadow out from under + * it. + */ +export class UmbralSpearsAbility extends Ability { + constructor(context) { + super('umbralspears', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.material = createUmbralMaterial(this.ctx.environment); + + this._fillShape(); + this.field = new GrowthField(this.group, { + geometry: createSpearGeometry, + material: this.material, + shape: _shape, + variants: 3, + capacity: MAX_SPEARS, + layer: LAYER.WORLD, + renderOrder: 2, + // castShadow is doing real work here and is half of the pooled shadow: + // CSM already draws these silhouettes onto the floor in the scene's own + // key direction, for free, and the authored band below only has to + // deepen and unify what the shadow map is already putting there. + // receiveShadow is off because there is nothing to receive onto — the + // body has no diffuse lobe at all. + castShadow: true, + receiveShadow: false + }); + // Assigned once, here, and never rebuilt: a closure created inside the + // update loop is an allocation per instance per frame (I3). + this.field.onBreach = (index, position, radius, height) => + this._onBreach(index, position, radius, height); + + /** + * The shadow pooled at the bases. + * + * RUT rather than POOL, and the reason is the shape of a line cast: POOL is + * a disc about one centre, and a disc wide enough to cover an eighteen-metre + * band darkens half the arena. RUT is a *track* — it has a length, a + * half-width and a `progress` that follows the front — and it carries a list + * of contact marks, which is exactly the mechanism needed to pool the + * darkness under each spear instead of laying an even stripe. + */ + this.shadow = new GroundField(this.group, { + mode: GroundMode.RUT, + marks: 16, + additive: false, + depthTest: true, + name: 'UmbralShadow' + }); + + /** Seconds since the front landed, or -1. Drives the rim punch. */ + this._impactAt = -1; + /** A dice roll, so two casts do not wobble their shadow identically. */ + this._seed = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // Dark haze pooling around the bases. Non-additive, and that is not a + // detail: an additive haze around a black object is a grey object. + this.gloom = particles.get('umbralspears.gloom', { + capacity: 2400, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.2 + }); + this.gloom.uniforms.uDrag.value = 2.0; + this.gloom.uniforms.uEndSize.value = 2.8; + this.gloom.uniforms.uSizeIn.value = 0.14; + this.gloom.uniforms.uFadeIn.value = 0.2; + this.gloom.uniforms.uFadeOut.value = 0.35; + + // Chips kicked off the floor as a spear breaks through. Lit, so they take + // the stage's own key — they are the one part of this ability that is + // allowed to look like it is in the room rather than cut out of it. + this.grit = particles.get('umbralspears.grit', { + capacity: 1800, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.grit.uniforms.uDrag.value = 0.28; + this.grit.uniforms.uEndSize.value = 0.8; + this.grit.uniforms.uFadeOut.value = 0.7; + + this.gloomEmitter = new RateEmitter(); + this.gritEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.field.count; + } + + get impactDuration() { + return Math.max(0.1, settings.umbralspears.lifetime * settings.global.lifetime); + } + + get fadeDuration() { + const c = settings.umbralspears; + return Math.max(0.1, c.sinkDelay + c.sinkTime); + } + + /** Nothing about this ability flickers. Darkness is steady. */ + lightShimmer() { + return 1; + } + + /* ------------------------------------------------------------------ */ + /* The shadow direction */ + /* ------------------------------------------------------------------ */ + + /** + * Where the caster's shadow points, flat and unit — the horizontal projection + * of `-frame.uLightDir`. + * + * Read fresh every frame rather than captured. It is the environment's key + * direction, so if the sun moves the spears re-lean and their shadow band + * slides round with it, paused included. + */ + _shadowDirection(out) { + out.copy(frame.uLightDir.value).multiplyScalar(-1).setY(0); + // Sun directly overhead: there is no shadow bearing to have, so fall back + // to the cast heading rather than normalising a zero vector into NaN. + if (out.lengthSq() < 1e-6) out.copy(this.direction); + return out.normalize(); + } + + /* ------------------------------------------------------------------ */ + /* Live parameters */ + /* ------------------------------------------------------------------ */ + + /** The geometry factory's shape controls. Handed to `syncGeometry`. */ + _fillShape() { + const c = settings.umbralspears; + _shape.sides = c.spearSides; + _shape.taper = c.spearTaper; + _shape.barb = c.spearBarb; + _shape.barbAt = c.spearBarbAt; + _shape.rough = c.spearRough; + _shape.twist = c.spearTwist; + return _shape; + } + + /** + * Everything `GrowthField` needs, re-resolved. This is the whole of invariant + * I1 for this ability: not one number below survives a frame. + */ + _fillGrowth() { + const c = settings.umbralspears; + const g = settings.global; + const p = _growth; + + p.layout = GrowthLayout.LINE; + p.emerge = GrowthEmerge.PUSH; + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + + p.widthNear = c.widthNear; + p.width = c.width; + p.widthCurve = c.widthCurve; + p.frontBias = c.frontBias; + p.clumping = c.clumping; + p.scatter = c.scatter; + p.clusterRadius = c.clusterRadius; + + p.heightNear = c.heightNear; + p.height = c.height; + p.heightCurve = c.heightCurve; + p.heightJitter = c.heightJitter; + p.crown = c.crown; + p.crownPower = c.crownPower; + p.peak = c.peak; + p.peakWidth = c.peakWidth; + p.rubble = c.rubble; + p.rubbleScale = c.rubbleScale; + p.rubbleSpread = c.rubbleSpread; + p.minHeight = c.minHeight; + + p.radiusNear = c.radiusNear; + p.radius2 = c.radius2; + p.radiusCurve = c.radiusCurve; + p.radiusJitter = c.radiusJitter; + p.minRadius = c.minRadius; + + // The one derived number in the block, and the derivation is the point: + // how much of the shadow's bearing lies along the cast decides how far + // downrange the blades tip. Cast into the light and they lean back at you. + const along = this._shadowDirection(_shadowDir).dot(this.direction); + p.lean = c.lean; + p.leanJitter = c.leanJitter; + p.leanRamp = c.leanRamp; + p.leanForward = c.leanForward + along * c.shadowLean; + p.leanOutward = c.leanOutward; + p.twist = c.twist; + p.tilt = c.tilt; + + p.baseHeight = 0; + p.baseJitter = 0; + + p.riseTime = c.riseTime; + p.riseOvershoot = c.riseOvershoot; + p.settle = c.settle; + p.springRate = c.springRate; + p.emergeSink = c.emergeSink; + p.birthScale = c.birthScale; + p.birthFade = c.birthFade; + p.breachAt = c.breachAt; + p.sinkDepth = c.sinkDepth; + + p.randomness = g.randomness; + return p; + } + + /** + * The shadow band. Offset along the shadow bearing, `progress` chasing the + * front, and a `fade` that follows the field down as it withdraws. + */ + _fillShadow(front, fade) { + const c = settings.umbralspears; + const g = settings.global; + const p = _shadow; + + this._shadowDirection(_shadowDir); + _anchor.copy(this.origin).addScaledVector(_shadowDir, c.shadowOffset); + + p.centre = _anchor; + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.shadowHeight; + p.length = this.length; + // RUT does not use the radius for its own coverage, but it *does* pad the + // quad with it — so it has to be the band, not the cast, or the shadow + // arrives on a canvas four times the size it needs. + p.radius = c.shadowWidth * 2.2; + p.width = c.shadowWidth; + p.progress = front; + p.grow = 1; + p.recede = 0; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.shadowEdge; + p.ragged = c.shadowRagged; + p.raggedScale = c.shadowRaggedScale; + p.warp = c.shadowWarp; + + // A shadow is flat. `relief` is left barely on rather than at zero purely + // so the pooled patches have an edge you can find; turned up it becomes a + // trench, which is a different ability. + p.relief = c.shadowRelief; + p.normalStep = 0.08; + p.ambient = c.shadowAmbient; + p.wrap = 0.6; + p.specular = 0; + p.gloss = 24; + p.parallax = 0; + + p.depth = c.shadowDepth; + // No spoil ridges: earth thrown out of a rut has a lip and a shadow does + // not, so `lift` is pinned at zero and `thickness` only feeds the padding. + p.lift = 0; + p.thickness = 0.02; + p.seam = c.shadowPool; + p.cell = 1; + p.sharp = c.shadowSharp; + p.detail = 0; + p.swirl = c.shadowWander; + p.speed = 1; + p.flow = 0; + p.windAngle = 0; + + p.markLife = c.shadowMarkLife; + p.markRadius = c.shadowWidth; + + p.additive = false; + p.emissive = c.shadowEmissive * g.glow; + p.opacity = c.shadowOpacity; + p.depthFade = 0.4; + p.colorBase = c.colorShadow; + p.colorDeep = c.colorShadowDeep; + p.colorEdge = c.colorShadowEdge; + p.colorGlow = c.colorShadowFront; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + return p; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.umbralspears; + + this.gloomEmitter.reset(); + this.gritEmitter.reset(); + this._impactAt = -1; + this._seed = Math.random() * 100; + + this.shadow.clearMarks(); + this.shadow.setVisible(true); + this.field.plant(Math.min(MAX_SPEARS, Math.max(1, Math.round(c.spearCount))), c.clusterShare); + + this._sync(0, 0, 1); + } + + /** + * Chips and a pool of darkness where a spear breaks the surface. + * + * The mark is the point: `z` is where this spear is along the track and + * `strength` is how big it is, both unitless, so the pooling re-places *and* + * re-scales itself when `shadowWidth` or the cast length moves. Nothing here + * writes down a metre. + */ + _onBreach(index, position, radius, height) { + const c = settings.umbralspears; + const g = settings.global; + + const s = saturate(_pos.copy(position).sub(this.origin).dot(this.direction) / this.length); + this.shadow.mark(0, s, this.age, saturate(0.35 + height / Math.max(0.1, c.height))); + + _emit.position = _pos.copy(position).setY(0.05); + _emit.radius = radius * 1.4; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.25).setY(1).normalize(); + _emit.speed = c.gritSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.09; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 7; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grit.emit(Math.round(c.breachGrit * g.particleCount), _emit); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** How much extra rim the impact punch is still worth, 0 when it is spent. */ + _impactRim() { + const c = settings.umbralspears; + if (this._impactAt < 0) return 0; + const k = saturate((this.age - this._impactAt) / Math.max(0.05, c.impactRimTime)); + return c.impactRim * (1 - Easing.outQuad(k)); + } + + /** + * Re-resolve everything and rebuild the field. + * @param {number} front 0..1, how far the front has travelled + * @param {number} retract 0..1, the field withdrawing into the floor + * @param {number} fade 0..1 on the shadow band + */ + _sync(front, retract, fade) { + this.material.userData.sync(this._impactRim()); + + this.field.syncGeometry(this._fillShape()); + this.field.update(this.age, this._fillGrowth(), retract); + this.shadow.update(this._fillShadow(front, fade)); + + this._syncParticles(); + } + + _syncParticles() { + const c = settings.umbralspears; + const g = settings.global; + + this.gloom.setGradient( + getColor(c.colorGloomA), + getColor(c.colorGloomB), + getColor(c.colorGloomC), + getColor(c.colorGloomD) + ); + this.gloom.uniforms.uGravity.value.set(0, c.gloomRise, 0); + this.gloom.uniforms.uSizeScale.value = c.gloomSize * g.particleSize; + this.gloom.uniforms.uLifeScale.value = c.gloomLifetime * 0.5 * g.particleLifetime; + this.gloom.uniforms.uSpeedScale.value = c.gloomSpeed * g.particleSpeed; + this.gloom.uniforms.uOpacity.value = c.gloomOpacity * g.opacity; + this.gloom.uniforms.uTurbulence.value = 0.35 * g.turbulence; + + this.grit.setGradient( + getColor(c.colorGritA), + getColor(c.colorGritB), + getColor(c.colorGritC), + getColor(c.colorGritD) + ); + this.grit.uniforms.uGravity.value.set(0, c.gritGravity, 0); + this.grit.uniforms.uSizeScale.value = c.gritSize * g.particleSize * 7; + this.grit.uniforms.uLifeScale.value = g.particleLifetime; + this.grit.uniforms.uSpeedScale.value = g.particleSpeed; + this.grit.uniforms.uOpacity.value = g.opacity; + } + + /** Haze rolling off whatever part of the field exists. */ + _gloomFx(dt, reach, scale) { + const c = settings.umbralspears; + const g = settings.global; + const count = Math.round(this.gloomEmitter.tick(dt, c.gloomRate * scale) * g.particleCount); + if (count <= 0) return; + + const s = Math.random() * Math.max(0.02, reach); + this.pointAt(s, _pos).setY(0.16); + // Pushed toward the shadow side, because that is where a shadow's own murk + // would collect. Drifting it up the middle read as fog, not as gloom. + _pos.addScaledVector(this._shadowDirection(_shadowDir), c.shadowOffset * 0.5); + + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, s); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gloomSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.9; + _emit.sizeVariance = 0.5; + _emit.life = c.gloomLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.4; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.gloom.emit(count, _emit); + } + + /** + * The trickle of grit still coming off the blades after they have arrived. + * + * Separate from the breach burst on purpose: the burst is an *event* and this + * is a state, and running both off `breachGrit` meant that turning the arrival + * down also turned off the settling, which is the part that keeps a standing + * field from looking like it was placed there rather than pushed up. + */ + _gritFx(dt, reach, scale) { + const c = settings.umbralspears; + const g = settings.global; + const count = Math.round(this.gritEmitter.tick(dt, c.gritRate * scale) * g.particleCount); + if (count <= 0) return; + + const s = Math.random() * Math.max(0.02, reach); + this.pointAt(s, _pos).setY(0.05); + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, s); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gritSpeed * 0.4; + _emit.speedVariance = 0.8; + _emit.spread = 0.7; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.07; + _emit.sizeVariance = 0.7; + _emit.life = c.gritLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 6; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grit.emit(count, _emit); + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.umbralspears; + this.field.triggerUpTo(this.age, this.u, c.riseStagger, c.frontBias, false); + this._sync(this.u, 0, 1); + this._gloomFx(dt, this.u, 1); + this._gritFx(dt, this.u, 1); + + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.umbralspears; + const g = settings.global; + + // Everything still buried goes up now, the terminal ring included. + this.field.triggerUpTo(this.age, 1, c.riseStagger, c.frontBias, true); + this._impactAt = this.age; + + // No burst shell, no screen flash. The impact is a punch of *rim* — the + // only bright thing this ability owns — plus chips and a knock. Every + // version of this that added a shell put a bright ball in the middle of a + // field of black spears and lost the field. + this.pointAt(1, _pos).setY(0.08); + _emit.position = _pos; + _emit.radius = c.width * 0.8; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.35).setY(1).normalize(); + _emit.speed = c.gritSpeed * 1.7; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.12; + _emit.sizeVariance = 0.75; + _emit.life = c.gritLifetime * 1.3; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.grit.emit(Math.round(c.impactGrit * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 21 + ); + this.lightBoost = c.lightIntensity * 0.7 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.umbralspears; + + let retract = 0; + if (this.phase === AbilityPhase.FADE) { + retract = saturate((this.fadeTime - c.sinkDelay) / Math.max(0.05, c.sinkTime)); + } + // The band goes with the spears rather than before them: a shadow that + // outlives the thing casting it is a stain. + this._sync(1, retract, 1 - Easing.inQuad(retract)); + + this.pointAt(1, this.position); + if (retract < 0.7) { + this._gloomFx(dt, 1, t <= 1 ? 0.7 : 0.35); + this._gritFx(dt, 1, t <= 1 ? 0.4 : 0.15); + } + } + + onDestroy() { + this.field.clear(); + this.shadow.clearMarks(); + this.shadow.setVisible(false); + this._impactAt = -1; + } + + dispose() { + this.field.dispose(); + this.shadow.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/void/UnmakeAbility.js b/src/abilities/void/UnmakeAbility.js new file mode 100644 index 0000000..90f8b57 --- /dev/null +++ b/src/abilities/void/UnmakeAbility.js @@ -0,0 +1,1055 @@ +import { BufferAttribute, BufferGeometry, Color, MeshStandardMaterial, Vector3 } from 'three'; +import { Ability, AbilityPhase } from '../Ability.js'; +import { GrowthField, GrowthEmerge, GrowthLayout, patchGrowthMaterial } from '../../vfx/GrowthField.js'; +import { GroundField, GroundMode } from '../../vfx/GroundField.js'; +import { + DissolveMode, + DissolveSpace, + MAX_RUNGS, + dissolveParams, + dissolveUniforms, + patchDissolveMaterial, + syncDissolve +} from '../../vfx/Dissolve.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { LAYER } from '../../core/Layers.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp } from '../../utils/math.js'; + +/** Hard ceiling on blocks per cast. The `blockCount` slider clamps here. */ +const MAX_BLOCKS = 160; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _emit = {}; +const _pos = new Vector3(); +const _dir = new Vector3(); + +/** + * Live parameter objects for the three shared modules. + * + * Filled from `settings.unmake` every frame and handed straight over. Module + * scope rather than per-instance because four concurrent casts each fill and + * consume them inside their own `update()` and never across one — the + * `UmbralSpearsAbility` precedent. + */ +const _growth = {}; +const _shape = {}; +const _trench = {}; +const _dissolve = dissolveParams(); + +/* ---------------------------------------------------------------------- */ +/* One block */ +/* ---------------------------------------------------------------------- */ +/** + * A quarried cube, in `GrowthField`'s unit space: footprint inside a circle of + * radius 0.5 on `y = 0`, top at `y = 1`. + * + * It is a **subdivided** cube, and the subdivision is the whole reason this + * function is not four lines. The voxel ladder claims *vertices*, not faces: a + * lattice cell with no vertex inside it cannot become a cube, it can only + * stretch the triangle that happens to cross it. So the segment count and + * `cellSize` are a pair, and the block header in `config/abilities/unmake.js` + * says which way round to move them. Twelve segments against a cell of 0.2 + * puts two and a half vertices along each cell edge, which is the cheapest grid + * that still lets a chunk carry a whole face away with it. + * + * Two deformations turn the grid into stone, and both are pure functions of the + * rest position — which matters more than it looks. The six faces are built + * independently and their shared edges are *duplicate* vertices, so anything + * that displaced them by index, or by face, or by a per-vertex die, would tear + * the block open along all twelve edges. A function of position alone gives + * coincident vertices identical answers and the block stays watertight. + * + * - **`round`** blends the cube toward the sphere of the same radius. At 0.22 + * it is a chamfer: the corners come in, the faces bulge a little, and the + * silhouette stops being a programmer's cube. + * - **`chip`** knocks the corners off, displacing along the outward direction + * by a hash of a *coarse lattice cell* rather than per vertex. Per-vertex + * noise was the first version and it read as a crumpled paper bag; a hashed + * lattice moves whole patches of the surface together, which is what a + * chisel does. + * + * @param {number} variant which of the three silhouettes + * @param {object} shape `{ segments, round, chip, chipScale }`, live + */ +function createBlockGeometry(variant, shape) { + const n = Math.max(3, Math.min(18, Math.round(shape?.segments ?? 12))); + const roundness = Math.max(0, Math.min(1, shape?.round ?? 0.22)); + const chip = Math.max(0, Math.min(1, shape?.chip ?? 0.3)); + const chipScale = Math.max(0.2, shape?.chipScale ?? 3.2); + const seed = 5.7 + variant * 31.3; + + const hash3 = (x, y, z) => { + const s = Math.sin(x * 127.1 + y * 311.7 + z * 74.7 + seed) * 43758.5453; + return s - Math.floor(s); + }; + + // (normal, U, V) per face, chosen so U × V points outward — the winding below + // depends on it and `computeVertexNormals` would otherwise light the block + // from inside. + const FACES = [ + [1, 0, 0, 0, 0, -1, 0, 1, 0], + [-1, 0, 0, 0, 0, 1, 0, 1, 0], + [0, 1, 0, 1, 0, 0, 0, 0, -1], + [0, -1, 0, 1, 0, 0, 0, 0, 1], + [0, 0, 1, 1, 0, 0, 0, 1, 0], + [0, 0, -1, -1, 0, 0, 0, 1, 0] + ]; + + const side = n + 1; + const perFace = side * side; + const positions = new Float32Array(perFace * FACES.length * 3); + const indices = new Uint16Array(n * n * 6 * FACES.length); + + let write = 0; + let index = 0; + for (let f = 0; f < FACES.length; f++) { + const [nx, ny, nz, ux, uy, uz, vx, vy, vz] = FACES[f]; + const base = f * perFace; + + for (let j = 0; j <= n; j++) { + const v = j / n - 0.5; + for (let i = 0; i <= n; i++) { + const u = i / n - 0.5; + let x = nx * 0.5 + ux * u + vx * v; + let y = ny * 0.5 + uy * u + vy * v; + let z = nz * 0.5 + uz * u + vz * v; + + // Round: blend toward the sphere of radius 0.5 through the same point. + const len = Math.max(1e-5, Math.hypot(x, y, z)); + x = lerp(x, (x / len) * 0.5, roundness); + y = lerp(y, (y / len) * 0.5, roundness); + z = lerp(z, (z / len) * 0.5, roundness); + + // Chip: one hash per coarse cell, pushed along the outward direction. + const cx = Math.floor(x * chipScale); + const cy = Math.floor(y * chipScale); + const cz = Math.floor(z * chipScale); + const knock = (hash3(cx, cy, cz) - 0.62) * chip * 0.16; + const out = Math.max(1e-5, Math.hypot(x, y, z)); + x += (x / out) * knock; + y += (y / out) * knock; + z += (z / out) * knock; + + positions[write++] = x; + // Unit space seats the block on the floor: y = 0 at the base, 1 at the top. + positions[write++] = y + 0.5; + positions[write++] = z; + } + } + + for (let j = 0; j < n; j++) { + for (let i = 0; i < n; i++) { + const k = base + j * side + i; + indices[index++] = k; + indices[index++] = k + 1; + indices[index++] = k + side + 1; + indices[index++] = k; + indices[index++] = k + side + 1; + indices[index++] = k + side; + } + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setIndex(new BufferAttribute(indices, 1)); + geometry.computeVertexNormals(); + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* The matter */ +/* ---------------------------------------------------------------------- */ +/** + * Dark quarried stone with **the lattice printed on it**. + * + * The seams are drawn in the block's own object space on exactly the cell size + * the voxel ladder's first rung uses, so what you are looking at before + * anything moves is a map of where the block is about to come apart. That + * agreement is not decoration: it is the only warning the ability gives, and it + * is why the first cube letting go reads as the block *doing what it was going + * to do* rather than as geometry glitching. Drag `cellSize` on a standing lane + * and the printing re-rules itself, still in step with the dissolve. + * + * The first version drew the seams in **world** space, on the theory that + * neighbouring blocks should look quarried out of one grid. They did — and then + * every cube that let go slid through the printing on its way out, because a + * world-space pattern is nailed to the room and the chunk is not. Object space, + * and the seam travels with the piece it belongs to. + * + * ## The one ordering that is load-bearing + * + * `patchOnBeforeCompile` composes in **registration** order, and both patches + * here replace `#include ` — so whichever registers *second* + * lands its code between the include and the first one's. Dissolve is + * registered first on purpose, which puts `patchGrowthMaterial`'s + * `vGrowLocal = transformed` **before** the displacement and hands this shader + * the block's rest position. The other way round, `vGrowLocal` is the drifting + * chunk's position and the seams crawl across the surface as it goes. + * + * `flatShading` rather than smooth normals, and it earns its keep twice: a + * chamfered cube reads as cut stone with hard facets, and three derives the + * normal from screen-space derivatives, so a chunk that has tumbled away is lit + * correctly with no help from anybody. `dissolveNormal()` still compiles and + * still runs; under flat shading it simply has nothing left to fix. + */ +function createMatterMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0x191521, + roughness: 0.85, + metalness: 0.06, + flatShading: true, + transparent: false, + depthWrite: true + }); + + // Registered FIRST. See the doc comment — this decides what `vGrowLocal` is. + const dissolve = dissolveUniforms({ + voxel: 1, + erode: 0, + space: DissolveSpace.LOCAL + }); + patchDissolveMaterial(material, { + mode: DissolveMode.VOXEL, + space: DissolveSpace.LOCAL, + uniforms: dissolve, + environment + }); + + const uniforms = { + uColorStone: { value: new Color() }, + uColorSeam: { value: new Color() }, + uColorBirth: { value: new Color() }, + /** The lattice pitch. Shared with `uDisCell` by design, not by accident. */ + uSeamCell: { value: 0.18 }, + uSeamWidth: { value: 0.06 }, + uSeamGlow: { value: 0.85 }, + uSeamCeiling: { value: 0.8 }, + uSeamFade: { value: 0.7 }, + uSeamGrain: { value: 0.35 }, + uGrainScale: { value: 5.5 }, + uBirthFlash: { value: 1.1 }, + uStoneRoughness: { value: 0.85 }, + uStoneMetalness: { value: 0.06 } + }; + + patchGrowthMaterial(material, { + environment, + uniforms, + // Declarations only. This chunk lands *above* the noise library that the + // dissolve patch injected, so nothing in here may call `fbm3` — the + // fragment block below can, because it runs inside main(). + common: /* glsl */ ` + uniform vec3 uColorStone; + uniform vec3 uColorSeam; + uniform vec3 uColorBirth; + uniform float uSeamCell; + uniform float uSeamWidth; + uniform float uSeamGlow; + uniform float uSeamCeiling; + uniform float uSeamFade; + uniform float uSeamGrain; + uniform float uGrainScale; + uniform float uBirthFlash; + uniform float uStoneRoughness; + uniform float uStoneMetalness; + `, + fragment: /* glsl */ ` + // Distance to the nearest lattice plane, on all three axes at once. The + // obvious fract() - 0.5 gives distance to the cell CENTRE; the seam is + // the boundary, so it is the complement. + vec3 cellQ = vGrowLocal / max(uSeamCell, 1e-3); + vec3 toPlane = 0.5 - abs(fract(cellQ) - 0.5); + float nearest = min(min(toPlane.x, toPlane.y), toPlane.z); + float seam = 1.0 - smoothstep(0.0, max(uSeamWidth, 1e-4), nearest); + + // Grain in WORLD space, so neighbouring blocks look cut out of one bed of + // stone rather than each carrying an identical copy of the same mottling. + float bed = fbm3(vGrowWorld * uGrainScale) * 0.5 + 0.5; + seam *= mix(1.0, bed, uSeamGrain); + + // A cube that is already moving has stopped being part of a lattice. + seam *= 1.0 - clamp(vDisK, 0.0, 1.0) * uSeamFade; + + vec3 lit = uColorSeam * seam * uSeamGlow; + lit += uColorBirth * vGrowBirth * uBirthFlash * (0.3 + 0.7 * seam); + + // THE BLOOM GUARD, on the UmbralSpears pattern. Reinhard with its + // asymptote at uSeamCeiling: the seams provably cannot reach + // post.bloomThreshold, so a lattice of thin bright lines never smears + // into a glowing box, and the bottom of the gain slider is untouched. + lit = lit / (1.0 + lit / max(uSeamCeiling, 1e-3)); + + diffuseColor.rgb = uColorStone * (0.75 + 0.5 * bed); + roughnessFactor = uStoneRoughness; + metalnessFactor = uStoneMetalness; + totalEmissiveRadiance += lit; + ` + }); + + // I8, and the merge is not optional: `patchDissolveMaterial` has already + // parked its own twenty-six boxes here, and assigning over the top would + // hide every one of them from the harness's pause probe. + material.userData.uniforms = Object.assign(material.userData.uniforms ?? {}, uniforms); + material.userData.dissolve = dissolve; + + /** Pull the palette and the lattice from the live settings. */ + material.userData.sync = () => { + const c = settings.unmake; + const g = settings.global; + + uniforms.uColorStone.value.copy(getColor(c.colorStone)); + uniforms.uColorSeam.value.copy(getColor(c.colorSeam)); + uniforms.uColorBirth.value.copy(getColor(c.colorBirth)); + + // The one shared number in the material, and the sharing is the design: + // the printed seam and the first rung of the ladder are the same lattice. + uniforms.uSeamCell.value = Math.max(0.01, c.cellSize); + uniforms.uSeamWidth.value = c.seamWidth; + uniforms.uSeamGlow.value = c.seamGlow * g.glow; + uniforms.uSeamCeiling.value = c.seamCeiling; + uniforms.uSeamFade.value = c.seamFade; + uniforms.uSeamGrain.value = c.grain * g.shaderIntensity; + uniforms.uGrainScale.value = c.grainScale * g.noiseFrequency; + uniforms.uBirthFlash.value = c.birthFlash; + uniforms.uStoneRoughness.value = c.stoneRoughness; + uniforms.uStoneMetalness.value = c.stoneMetalness; + }; + + material.userData.sync(); + return material; +} + +/* ---------------------------------------------------------------------- */ +/* The ability */ +/* ---------------------------------------------------------------------- */ + +/** + * UNMAKE — a lane of matter that stops existing. + * + * Two beats. A heave front runs the aimed line at `speed` and near-cubic blocks + * of the room's own substance punch out of the floor behind it, leaving a + * gouged trench; the lane stands whole for `standTime`; and then it is + * **unmade**, over `unmakeTime`, into cubes that tumble, drift and wink out. + * + * **THE TRICK — the cubes get bigger as it goes.** This is `DissolveMode.VOXEL` + * from `vfx/Dissolve.js` and the entire acceleration is a **power-of-two ladder + * walked once per vertex in the vertex shader**. Rung 0 claims `take` of the + * cells at `cellSize`; whatever it did not claim is offered to rung 1 at twice + * the size, and rung 2 at twice that, up to `rungs`. Every vertex is claimed + * exactly once, by the finest rung that wants it, from nothing but its rest + * position and a hash — so the partition never changes, never re-shuffles, and + * needs no state on either side of the bus. There is **no CPU rebuild**: the + * geometry the blocks were drawn with at the start is the geometry they are + * drawn with at the end. + * + * The reason that reads as *acceleration* rather than as scale is stated in the + * module it came from and is worth repeating: losing eight small cubes and + * losing one cube eight times the size are the same volume, but the eye counts + * events, not litres. Drop `rungs` to 1 with the clock stopped mid-dissolve and + * the lane goes back to a uniform crumble; that comparison is the ability. + * + * **What the CPU does about it.** Two things, and both are agreements rather + * than duplications. The material prints the lattice on the stone at the same + * `cellSize`, so you can see the grid before it is used. And the chips thrown + * off are sized by the ladder's *current rung* — `chipSize × 2^(rung × + * chipRungGain)` — so the particles get coarser at exactly the moments the + * geometry does. The first version emitted one chip size throughout and the + * dissolve visibly disagreed with its own debris; the debris won, and the + * acceleration disappeared. + * + * **Why one progress for the whole lane and not a travelling front.** Tempting, + * and wrong. `Dissolve.js` argues it directly: a dissolve driven per material + * with per-material progress does not look like one thing being unmade, it + * looks like several things being unmade near each other. All three of + * `GrowthField`'s variant meshes share **one material and one uniform box by + * identity**, so a single `syncDissolve()` drives every block in the cast and + * the lane goes as one event. The direction the cast has is carried by the + * heave, which does travel, and by `driftUp` leaning the chunks off the floor. + * + * **The rule that makes the editor work.** A `GrowthField` record is dice rolls + * and one timestamp; this ability adds exactly one number of its own, `_seed`, + * plus the timestamps the phase machine already keeps. Every metre, radian and + * second lands in `_growth`, `_trench` and `_dissolve` from `settings.unmake` + * inside the update loop, on a zero-length frame included. Pause mid-dissolve + * and drag `drift`, `tumble` or `hold` and the standing cloud of cubes + * re-arranges itself around the same partition. + */ +export class UnmakeAbility extends Ability { + constructor(context) { + super('unmake', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + this.material = createMatterMaterial(this.ctx.environment); + /** The shared box every block dissolves out of. One event, one write. */ + this.dissolveBox = this.material.userData.dissolve; + + this._fillShape(); + this.field = new GrowthField(this.group, { + geometry: createBlockGeometry, + material: this.material, + shape: _shape, + variants: 3, + capacity: MAX_BLOCKS, + layer: LAYER.WORLD, + renderOrder: 2, + // The blocks are real matter for as long as they exist, so they take the + // stage's key light and throw a real shadow — and the shadow dissolves + // with them, because `patchDissolveMaterial` was handed the environment + // and registered the same displacement on the depth material. + castShadow: true, + receiveShadow: true + }); + // Assigned once, here, and never rebuilt: a closure created inside the + // update loop is an allocation per instance per frame (I3). + this.field.onBreach = (index, position, radius, height) => + this._onBreach(index, position, radius, height); + + /** + * The trench the matter came out of. + * + * RUT rather than a disc mode, for the reason `UmbralSpears` gives: RUT is + * the one mode with a *length*, a half-width and a `progress` that follows + * the front, which is the shape a line cast actually has. It outlives the + * blocks on purpose — the matter is gone, the hole it came out of is not, + * and a floor that forgets is a floor nothing happened to. + */ + this.trench = new GroundField(this.group, { + mode: GroundMode.RUT, + marks: 16, + additive: false, + depthTest: true, + name: 'UnmakeTrench' + }); + + /** A dice roll, so two casts do not partition their blocks identically. */ + this._seed = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The cubes too small to be geometry. Lit and non-additive: they are still + // stone right up until they are not, and an additive chip is an ember. + this.chips = particles.get('unmake.chips', { + capacity: 2200, + shape: ParticleShape.CHIP, + additive: false, + lit: true, + softFade: 0.25 + }); + this.chips.uniforms.uDrag.value = 0.3; + this.chips.uniforms.uEndSize.value = 0.7; + this.chips.uniforms.uFadeOut.value = 0.62; + + // What is left in the space the matter used to occupy. Non-additive, so it + // genuinely occludes rather than brightening the hole. + this.gloom = particles.get('unmake.gloom', { + capacity: 1800, + shape: ParticleShape.SMOKE, + additive: false, + curl: true, + softFade: 1.15 + }); + this.gloom.uniforms.uDrag.value = 2.1; + this.gloom.uniforms.uEndSize.value = 2.6; + this.gloom.uniforms.uSizeIn.value = 0.14; + this.gloom.uniforms.uFadeIn.value = 0.2; + this.gloom.uniforms.uFadeOut.value = 0.36; + + // The flare a cube throws as it stops existing — the particle half of the + // ember the dissolve patch already adds to the geometry. + this.motes = particles.get('unmake.motes', { + capacity: 1600, + shape: ParticleShape.SOFT, + additive: true, + curl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 1.7; + this.motes.uniforms.uEndSize.value = 0.12; + this.motes.uniforms.uSizeIn.value = 0.05; + this.motes.uniforms.uFadeIn.value = 0.07; + this.motes.uniforms.uFadeOut.value = 0.45; + + this.chipEmitter = new RateEmitter(); + this.gloomEmitter = new RateEmitter(); + this.moteEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + return this.field.count; + } + + /** The lane stands whole. */ + get impactDuration() { + return Math.max(0.1, settings.unmake.standTime * settings.global.lifetime); + } + + /** Then it is unmade, and then the last of it clears. */ + get fadeDuration() { + const c = settings.unmake; + return Math.max(0.1, c.unmakeTime + c.settleTime); + } + + /** Matter does not gutter. The light steadies and then loses its subject. */ + lightShimmer() { + return 1; + } + + /* ------------------------------------------------------------------ */ + /* The beats — unitless, resolved against live durations */ + /* ------------------------------------------------------------------ */ + + /** + * The one number the whole dissolve runs on: 0 intact, 1 gone. + * + * A pure function of the phase clock against the *live* `unmakeTime`, so + * dragging that slider on a paused lane re-times a dissolve that is already + * half done rather than restarting it. + */ + _progress() { + if (this.phase !== AbilityPhase.FADE) return 0; + return saturate(this.fadeTime / Math.max(0.05, settings.unmake.unmakeTime)); + } + + /** + * Which rung of the ladder is letting go right now, 0..rungs-1. + * + * The shader walks the ladder per vertex and never tells anybody; this is the + * CPU's copy of the same arithmetic, and it exists so the chips can be the + * size of the cubes they came off. Mirror, so if one changes the other must — + * the claim time in `dissolveResolve()` is `(rung + hash) / rungs`. + */ + _rung() { + const c = settings.unmake; + const rungs = Math.max(1, Math.min(MAX_RUNGS, Math.round(c.rungs))); + return Math.min(rungs - 1, Math.floor(this._progress() * rungs)); + } + + /** Metres, the size of a chip coming off right now. Grows with the ladder. */ + _chipSize() { + const c = settings.unmake; + return c.chipSize * Math.pow(2, this._rung() * saturate(c.chipRungGain)); + } + + /* ------------------------------------------------------------------ */ + /* Live parameters */ + /* ------------------------------------------------------------------ */ + + /** The geometry factory's shape controls. Handed to `syncGeometry`. */ + _fillShape() { + const c = settings.unmake; + _shape.segments = c.blockSegments; + _shape.round = c.blockRound; + _shape.chip = c.blockChip; + _shape.chipScale = c.blockChipScale; + return _shape; + } + + /** + * Everything `GrowthField` needs, re-resolved. Note that `heightNear` and + * `radiusNear` are fed from **one** slider, and so are `height` and + * `radius2`: the lattice is object space, so a block that is not a cube comes + * apart into things that are not cubes. + */ + _fillGrowth() { + const c = settings.unmake; + const g = settings.global; + const p = _growth; + + p.layout = GrowthLayout.LINE; + p.emerge = GrowthEmerge.PUSH; + p.origin = this.origin; + p.direction = this.direction; + p.side = this.side; + p.length = this.length; + + p.widthNear = c.widthNear; + p.width = c.width; + p.widthCurve = c.widthCurve; + p.frontBias = c.frontBias; + p.clumping = c.clumping; + p.scatter = c.scatter; + p.clusterRadius = c.clusterRadius; + + p.heightNear = c.blockNear; + p.height = c.blockFar; + p.heightCurve = c.blockCurve; + p.heightJitter = c.blockJitter; + p.crown = c.crown; + p.crownPower = c.crownPower; + p.peak = c.peak; + p.peakWidth = c.peakWidth; + p.rubble = c.rubble; + p.rubbleScale = c.rubbleScale; + p.rubbleSpread = c.rubbleSpread; + p.minHeight = c.minBlock; + + p.radiusNear = c.blockNear; + p.radius2 = c.blockFar; + p.radiusCurve = c.blockCurve; + p.radiusJitter = c.blockJitter; + p.minRadius = c.minBlock; + + p.lean = c.lean; + p.leanJitter = c.leanJitter; + p.leanRamp = c.leanRamp; + p.leanForward = c.leanForward; + p.leanOutward = c.leanOutward; + p.twist = c.twist; + p.tilt = c.tilt; + + p.baseHeight = 0; + p.baseJitter = 0; + + p.riseTime = c.riseTime; + p.riseOvershoot = c.riseOvershoot; + p.settle = c.settle; + p.springRate = c.springRate; + p.emergeSink = c.emergeSink; + p.birthScale = c.birthScale; + p.birthFade = c.birthFade; + p.breachAt = c.breachAt; + // Nothing withdraws: the blocks are not taken back into the floor, they + // stop existing. `retract` is passed as 0 for the whole cast. + p.sinkDepth = 0; + + p.randomness = g.randomness; + return p; + } + + /** + * The `VOXEL` half of the dissolve patch, re-resolved. + * + * `drift`, `lift`, `gravity` and `wobble` are applied in the block's own + * object space and then carried through the instance scale, so they are + * *block units* rather than metres: at the shipped `blockFar` of about a + * metre the two coincide, and on a lane of half-metre blocks everything + * halves with them. That is the right behaviour — a small cube should not + * travel as far as a big one — and it is why they are not named in metres. + */ + _fillDissolve() { + const c = settings.unmake; + const g = settings.global; + const p = _dissolve; + + p.progress = this._progress(); + p.seed = this._seed; + p.space = DissolveSpace.LOCAL; + + p.voxel = 1; + p.cell = c.cellSize; + p.rungs = c.rungs; + p.take = c.take; + p.span = c.span; + p.block = c.blockiness; + p.facet = c.facet; + p.hold = c.hold; + p.drift = c.drift; + p.driftBiasX = 0; + p.driftBiasY = c.driftUp; + p.driftBiasZ = 0; + p.lift = c.lift; + p.gravity = c.gravity; + p.tumble = c.tumble; + p.wobble = c.wobble; + p.wobbleRate = c.wobbleRate; + + p.erode = c.erode; + p.noiseScale = c.noiseScale; + p.warp = c.warp; + p.edge = c.edgeWidth; + p.biasX = 0; + p.biasY = 0; + p.biasZ = 0; + p.biasAmount = 0; + + p.colorEdge = c.colorEdge; + p.colorEmber = c.colorEmber; + p.glow = c.unmakeGlow * g.glow; + return p; + } + + /** + * The trench. `progress` chases the heave front; `fade` follows the lane down + * only at the very end, because the gouge is the one thing that stays. + */ + _fillTrench(front, fade) { + const c = settings.unmake; + const g = settings.global; + const p = _trench; + + p.centre = this.origin; + p.yaw = Math.atan2(this.direction.x, this.direction.z); + p.height = c.trenchHeight; + p.length = this.length; + // RUT does not cover with its radius, but it does pad the quad with it, so + // it has to be the track's half-width and not the cast's length. + p.radius = c.trenchWidth * 2.2; + p.width = c.trenchWidth; + p.progress = front; + p.grow = 1; + p.recede = 0; + p.fade = fade; + p.seed = this._seed; + + p.edge = c.trenchEdge; + p.ragged = c.trenchRagged; + p.raggedScale = c.trenchRaggedScale; + p.warp = c.trenchWarp; + + p.relief = c.trenchRelief; + p.normalStep = 0.07; + p.ambient = c.trenchAmbient; + p.wrap = 0.5; + p.specular = 0.2; + p.gloss = 20; + p.parallax = 0.25; + + p.depth = c.trenchDepth; + p.lift = c.trenchLift; + p.thickness = c.trenchThickness; + p.seam = 1.2; + p.cell = 1; + p.sharp = c.trenchSharp; + p.detail = c.trenchDetail; + p.swirl = 0.2; + p.speed = 1; + p.flow = 0; + p.windAngle = 0; + + p.markLife = c.trenchMarkLife; + p.markRadius = c.trenchWidth; + + p.additive = false; + p.emissive = c.trenchEmissive * g.glow; + p.opacity = c.trenchOpacity; + p.depthFade = 0.4; + p.colorBase = c.colorTrench; + p.colorDeep = c.colorTrenchDeep; + p.colorEdge = c.colorTrenchEdge; + p.colorGlow = c.colorTrenchGlow; + + p.noiseStrength = g.noiseStrength; + p.noiseFrequency = g.noiseFrequency; + p.noiseSpeed = g.noiseSpeed; + p.opacityScale = g.opacity; + return p; + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + const c = settings.unmake; + + this.chipEmitter.reset(); + this.gloomEmitter.reset(); + this.moteEmitter.reset(); + + // The one thing this cast captures, besides its own timestamps. + this._seed = Math.random() * 100; + + this.trench.clearMarks(); + this.trench.setVisible(true); + this.field.plant(Math.min(MAX_BLOCKS, Math.max(1, Math.round(c.blockCount))), c.clusterShare); + + this._sync(0, 1); + } + + /** Chips and a deepening of the trench where a block breaks the surface. */ + _onBreach(index, position, radius, height) { + const c = settings.unmake; + const g = settings.global; + + const s = saturate(_pos.copy(position).sub(this.origin).dot(this.direction) / this.length); + // Unitless: `s` is how far along the track this block is and `strength` is + // how big it was against the lane's own far size, so the pooling re-places + // *and* re-scales itself when `blockFar` or the cast length moves. + this.trench.mark(0, s, this.age, saturate(0.35 + height / Math.max(0.1, c.blockFar))); + + _emit.position = _pos.copy(position).setY(0.05); + _emit.radius = radius * 1.3; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.25).setY(1).normalize(); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.75; + _emit.spread = 0.8; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.chipSize; + _emit.sizeVariance = 0.7; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 8; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.chips.emit(Math.round(c.breachChips * g.particleCount), _emit); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve everything. + * @param {number} front 0..1, how far the heave front has travelled + * @param {number} fade 0..1 on the trench + */ + _sync(front, fade) { + this.material.userData.sync(); + syncDissolve(this.dissolveBox, this._fillDissolve()); + + this.field.syncGeometry(this._fillShape()); + this.field.update(this.age, this._fillGrowth(), 0); + this.trench.update(this._fillTrench(front, fade)); + + this._syncParticles(); + } + + _syncParticles() { + const c = settings.unmake; + const g = settings.global; + + this.chips.setGradient( + getColor(c.colorChipA), + getColor(c.colorChipB), + getColor(c.colorChipC), + getColor(c.colorChipD) + ); + this.chips.uniforms.uGravity.value.set(0, c.chipGravity, 0); + // The `× 7` is the CHIP shape's own convention, shared with every other + // chip system in the project; the ladder's growth rides on top of it. + this.chips.uniforms.uSizeScale.value = g.particleSize * 7; + this.chips.uniforms.uLifeScale.value = g.particleLifetime; + this.chips.uniforms.uSpeedScale.value = g.particleSpeed; + this.chips.uniforms.uOpacity.value = g.opacity; + + this.gloom.setGradient( + getColor(c.colorGloomA), + getColor(c.colorGloomB), + getColor(c.colorGloomC), + getColor(c.colorGloomD) + ); + this.gloom.uniforms.uGravity.value.set(0, c.gloomRise, 0); + this.gloom.uniforms.uSizeScale.value = c.gloomSize * g.particleSize; + this.gloom.uniforms.uLifeScale.value = c.gloomLifetime * 0.5 * g.particleLifetime; + this.gloom.uniforms.uSpeedScale.value = c.gloomSpeed * g.particleSpeed; + this.gloom.uniforms.uOpacity.value = c.gloomOpacity * g.opacity; + this.gloom.uniforms.uTurbulence.value = 0.4 * g.turbulence; + + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteRise, 0); + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = c.unmakeGlow * 0.7 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + } + + /** Haze rolling off whatever part of the lane exists. */ + _gloomFx(dt, reach, scale) { + const c = settings.unmake; + const g = settings.global; + const count = Math.round(this.gloomEmitter.tick(dt, c.gloomRate * scale) * g.particleCount); + if (count <= 0) return; + + const s = Math.random() * Math.max(0.02, reach); + this.pointAt(s, _pos).setY(lerp(c.blockNear, c.blockFar, s) * 0.5); + + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, s); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.gloomSpeed; + _emit.speedVariance = 0.7; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.85; + _emit.sizeVariance = 0.5; + _emit.life = c.gloomLifetime; + _emit.lifeVariance = 0.4; + _emit.spin = 0.35; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.gloom.emit(count, _emit); + } + + /** + * The debris of the unmaking: chips at the ladder's current cube size, and + * the flare that goes with them. + * + * Both are gated on the dissolve actually running, and the chip *size* is + * where the trick reaches the particle engine — see `_chipSize()`. + * + * @param {number} scale 0..1 on the rate + */ + _unmakeFx(dt, scale) { + const c = settings.unmake; + const g = settings.global; + const time = frame.uTime.value; + + const chipCount = Math.round(this.chipEmitter.tick(dt, c.chipRate * scale) * g.particleCount); + if (chipCount > 0) { + const s = Math.random(); + this.pointAt(s, _pos); + const size = lerp(c.blockNear, c.blockFar, s); + _pos.y = size * 0.55; + + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, s); + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.chipSpeed; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = this._chipSize(); + _emit.sizeVariance = 0.5; + _emit.life = c.chipLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 9; + _emit.tint = null; + _emit.time = time; + this.chips.emit(chipCount, _emit); + } + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + const s = Math.random(); + this.pointAt(s, _pos); + _pos.y = lerp(c.blockNear, c.blockFar, s) * 0.6; + + _emit.position = _pos; + _emit.radius = lerp(c.widthNear, c.width, s) * 1.1; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.moteSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.moteSize; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onTravel(dt) { + const c = settings.unmake; + this.field.triggerUpTo(this.age, this.u, c.riseStagger, c.frontBias, false); + this._sync(this.u, 1); + + // The light rides the heave front, lifted onto the top of the blocks it is + // pushing up rather than left on the floor under them. + this.pointAt(this.u, this.position); + this.position.y = lerp(c.blockNear, c.blockFar, this.u) * 0.7; + + this._gloomFx(dt, this.u, 0.7); + this.ctx.shake.rumble(c.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.unmake; + const g = settings.global; + + // Everything still buried comes up now, the far pile included. + this.field.triggerUpTo(this.age, 1, c.riseStagger, c.frontBias, true); + + this.pointAt(1, _pos).setY(c.blockFar * 0.4); + _emit.position = _pos; + _emit.radius = c.width * 0.9; + _emit.direction = _dir.copy(this.direction).multiplyScalar(0.3).setY(1).normalize(); + _emit.speed = c.chipSpeed * 1.8; + _emit.speedVariance = 0.8; + _emit.spread = 0.9; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = c.chipSize * 1.4; + _emit.sizeVariance = 0.75; + _emit.life = c.chipLifetime * 1.3; + _emit.lifeVariance = 0.5; + _emit.spin = 11; + _emit.tint = null; + _emit.time = frame.uTime.value; + this.chips.emit(Math.round(c.impactChips * g.particleCount), _emit); + + this.ctx.shake.add( + c.heaveShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 19 + ); + this.lightBoost = c.lightIntensity * 0.5 * g.explosionIntensity; + } + + onFade(dt, _t) { + const c = settings.unmake; + const progress = this._progress(); + // The gouge only starts to go once the last cube has, and even then it + // takes the whole settle to do it. + const fade = 1 - saturate((this.fadeTime - c.unmakeTime) / Math.max(0.05, c.settleTime)) * 0.85; + + this._sync(1, fade); + + // The light sits over the middle of the lane, where the loss is, and drops + // toward the floor as there is less and less left to light. + this.pointAt(0.6, this.position); + this.position.y = c.blockFar * (0.7 - 0.5 * progress); + + // Emission ramps with the dissolve: the ladder is taking more volume per + // second as it climbs, and the debris has to say so. + const running = this.phase === AbilityPhase.FADE && progress < 1 ? 0.4 + 0.6 * progress : 0; + this._unmakeFx(dt, running); + this._gloomFx(dt, 1, 0.35 + 0.65 * running); + + this.ctx.shake.rumble( + (running > 0 ? c.unmakeRumble : c.rumble) * settings.global.cameraShake, + dt + ); + } + + onDestroy() { + this.field.clear(); + this.trench.clearMarks(); + this.trench.setVisible(false); + // Leave the box intact and the lane whole, or the next cast out of this + // pooled instance draws one frame of a finished dissolve before its own + // `onSpawn` runs. + this.dissolveBox.uDisProgress.value = 0; + } + + dispose() { + this.field.dispose(); + this.trench.dispose(); + this.material.dispose(); + super.dispose(); + } +} diff --git a/src/abilities/void/VoidriftAbility.js b/src/abilities/void/VoidriftAbility.js new file mode 100644 index 0000000..5b49e61 --- /dev/null +++ b/src/abilities/void/VoidriftAbility.js @@ -0,0 +1,613 @@ +import { Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { Portal } from '../../vfx/Portal.js'; +import { DistortionField, DistortionMode } from '../../vfx/Distortion.js'; +import { ParticleShape } from '../../particles/ParticleSystem.js'; +import { RateEmitter } from '../../particles/ParticleEngine.js'; +import { DecalType } from '../../effects/GroundDecals.js'; +import { BurstMode } from '../../effects/BurstSphere.js'; +import { frame } from '../../core/FrameUniforms.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { saturate, lerp, Easing, randRange } from '../../utils/math.js'; + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope (I3). Nothing below allocates per frame. */ +/* ---------------------------------------------------------------- */ + +const _pos = new Vector3(); +const _dir = new Vector3(); +const _centre = new Vector3(); +const _axis = new Vector3(); +const _emit = {}; +/** Refilled every frame and handed to `Portal.update`. Never rebuilt. */ +const _rift = {}; +/** Refilled every frame and handed to `DistortionField.update`. */ +const _lens = {}; + +/** + * VOID RIFT — a hole, and the two fronts that open it. + * + * A slit torn along the aimed line and lying **in the floor plane**, so the + * camera looks down *into* it: pure black interior, three shells of stars + * parallaxing against the camera at a deliberately wrong rate, a white-hot + * fracture rim with a crown of radial cracks, and a screen-space lens at the + * same anchor bending the floor around the lip. Motes near it spiral in and are + * eaten at the rim. Closing runs the tear backwards and leaves a hairline. + * + * ## THE TRICK — it opens by tearing, on two fronts at once + * + * `Portal` already refuses to open by scaling: `open` is a threshold on a + * ragged field sampled in *metres*, so different bearings cross it at different + * times and the boundary is ragged in the same places on the way back. That + * gives the wound its **width** — the seam unzips from the long centreline + * (`seam` ≈ 0.92) over `openTime`. + * + * It does not give the wound its **length**, and length is what a slit is. So + * the second front is the ability's own: `radiusX` marches outward from a + * `tearSeed` nick at `tearSpeed` metres per second while `radiusY` — the + * half-width across the line — never moves. The two ends propagate along the + * cast line and the slit stays exactly as wide as it started. Grow both radii + * together and you have a sprite scaling up, which is what the first build did: + * it read as a decal fading in, not as space being forced apart. The give-away + * was the crack crown, which grew *with* the aperture instead of being outrun + * by it. + * + * Both fronts are resolved from live settings against the cast's own clock + * (`age`, a timestamp — the one thing a cast may keep), never accumulated. Drag + * `tearSpeed` with the clock paused and the ends jump to where that speed says + * they should be. + * + * ## The second illusion, and the number that owns it + * + * `parallax` must not be 1. One is geometrically honest and geometric honesty + * reads as a hole in a wall — you can feel the plane. 1.75 slides the interior + * against the camera three quarters faster than the aperture says it should, + * and the mismatch is what makes it a hole in *space*. It is the single most + * important number in the block and it is a slider for exactly that reason. + * + * ## What each of the two draw calls is for + * + * `Portal` occludes and glows in one pass, because it writes premultiplied + * alpha: the interior is `rgb ≈ 0, a = 1` (it genuinely removes the floor) and + * the rim is `rgb = hot, a = 0` (it genuinely adds over whatever is behind). + * `DistortionField(LENS)` writes nothing you can see — it puts screen-space + * offsets in the refraction buffer, and what you see is the floor bending. + * Portal deliberately writes no offsets of its own, so the two are authored + * independently: `lensRadius` sits a little past the rift's live half-length so + * the bend starts *outside* the fracture rather than inside it. + * + * Two draw calls for the whole cast, plus two shared particle systems. + */ +export class VoidriftAbility extends Ability { + constructor(context) { + super('voidrift', context); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + createShaders() { + // No `writeDepth`: the void must not punch a hole in the transparent queue, + // because its own motes are transparents drawn after it and the read is + // that they *reach* the rim before they vanish. Alpha-testing them out a + // frame early is the difference between "eaten" and "clipped". + this.rift = new Portal({ renderOrder: 7, name: 'voidrift.rift' }); + this.group.add(this.rift.object3D); + + // BILLBOARD facing, not GROUND. A ground-facing lens is coplanar with the + // floor it is meant to bend, so `depthReject` sees the floor at its own + // depth and rejects most of the quad — the warp survives only where the + // ground happens to be a centimetre lower. A billboard at the same anchor + // has the floor comfortably behind it everywhere. + this.lens = new DistortionField({ + mode: DistortionMode.LENS, + renderOrder: 4, + name: 'voidrift.lens' + }); + this.group.add(this.lens.object3D); + + /** Re-rolled per cast: decorrelates the tear grain and the starfield. */ + this._seed = 0; + /** How many floor stains the racing ends have already paid out. A count. */ + this._stains = 0; + } + + createParticles() { + const particles = this.ctx.particles; + + // The motes. `swirl` is what makes this system worth its own gradient: the + // shader orbits each particle about a *travelling anchor* and scales its + // offset by `1 + uSwirlExpand * t`, so a negative expansion drives the + // offset to zero at the end of the mote's life. It converges on the rift + // and is gone. Nothing here fades out and pretends. + this.motes = particles.get('voidrift.motes', { + capacity: 1200, + shape: ParticleShape.SOFT, + additive: true, + swirl: true, + softFade: 0.4 + }); + this.motes.uniforms.uDrag.value = 0.9; + this.motes.uniforms.uEndSize.value = 0.1; + this.motes.uniforms.uSizeIn.value = 0.1; + this.motes.uniforms.uFadeIn.value = 0.14; + this.motes.uniforms.uFadeOut.value = 0.62; + + // Embers shed by the fracture as it opens. Velocity-stretched, additive, + // and the only warm-bright thing the ability owns besides the rim itself. + this.embers = particles.get('voidrift.embers', { + capacity: 1000, + shape: ParticleShape.STREAK, + additive: true, + stretch: true, + softFade: 0.25 + }); + this.embers.uniforms.uDrag.value = 1.6; + this.embers.uniforms.uEndSize.value = 0.2; + this.embers.uniforms.uSizeIn.value = 0.03; + this.embers.uniforms.uFadeIn.value = 0.04; + this.embers.uniforms.uFadeOut.value = 0.42; + + this.moteEmitter = new RateEmitter(); + this.emberEmitter = new RateEmitter(); + } + + /* ------------------------------------------------------------------ */ + /* Timing */ + /* ------------------------------------------------------------------ */ + + get instanceCount() { + // Two meshes, and the lens only counts while it is retaining the pass. + return 1 + (this.lens.visible ? 1 : 0); + } + + /** The rift stands open. */ + get impactDuration() { + return Math.max(0.05, settings.voidrift.holdTime * settings.global.lifetime); + } + + /** The tear runs backwards, then the hairline burns off. */ + get fadeDuration() { + const c = settings.voidrift; + return Math.max(0.1, c.closeTime + c.afterTime); + } + + /* ------------------------------------------------------------------ */ + /* Geometry — every metre resolved from live settings */ + /* ------------------------------------------------------------------ */ + + /** World centre of the aperture, on the cast line and off the floor. */ + _riftCentre(out) { + const c = settings.voidrift; + this.pointAt(saturate(c.centreBias), out); + out.y = c.riftHeight; + return out; + } + + /** + * The aperture's local +Y, in world space. + * + * At `tilt` = 0 this is `-side`, which puts the plane's normal (`along × up`) + * straight up: the rift lies in the floor plane and you look down into it. + * `tilt` rolls it about the cast line, and `riftHeight` is the clearance that + * stops the low edge dipping under the floor and being depth-rejected. + */ + _riftAxis(out) { + const c = settings.voidrift; + out.copy(this.side).multiplyScalar(-Math.cos(c.tilt)); + out.y += Math.sin(c.tilt); + return out.normalize(); + } + + /** Full half-length the tear is allowed to reach, metres. */ + _fullHalf() { + const c = settings.voidrift; + return Math.max(0.05, this.length * 0.5 * c.riftSpan); + } + + /** + * Half-length of the slit right now, metres. + * + * The ends race at `tearSpeed` off the cast's own clock rather than off an + * accumulated distance, which is the difference between a slider that works + * while paused and one that does not: `age` is a timestamp (allowed), the + * metres are multiplied out fresh every frame (required). + * + * @param {number} retract 0..1 through the close + */ + _halfLength(retract) { + const c = settings.voidrift; + const full = this._fullHalf(); + const seed = Math.min(full, Math.max(0.01, c.tearSeed)); + const open = Math.min(full, seed + c.tearSpeed * this.age); + return lerp(open, seed, saturate(c.closeDraw) * Easing.inCubic(retract)); + } + + /** How much of the full length the ends have paid out, 0..1. */ + _tearReach() { + const full = this._fullHalf(); + return saturate(this._halfLength(0) / Math.max(1e-3, full)); + } + + /** + * The aperture threshold handed to `Portal`. + * + * Ascending it unzips the wound across its width; descending it un-tears in + * the same places, because the field it is thresholding is the same noise. + * The floor at `afterOpen` is what leaves a hairline behind rather than a + * clean disappearance. + */ + _openAmount(retract, after) { + const c = settings.voidrift; + const born = saturate(this.age / Math.max(0.02, c.openTime)); + const shut = 1 - Easing.inCubic(retract); + return Math.max(c.afterOpen * (1 - after), Math.min(born, shut)); + } + + /* ------------------------------------------------------------------ */ + /* Feedback */ + /* ------------------------------------------------------------------ */ + + /** + * Push live settings and the current beat into both draw calls and both + * particle systems. + * + * @param {number} retract 0..1 — the ends pulling back in + * @param {number} after 0..1 — the hairline burning off + */ + _syncRift(retract, after) { + const c = settings.voidrift; + const g = settings.global; + + const half = this._halfLength(retract); + const open = this._openAmount(retract, after); + // The fracture is lifted while the hairline burns, then taken to nothing. + // Emissive in `Portal` is not scaled by `opacity` — it is additive output, + // deliberately — so fading the afterimage means fading the four glows. + const glow = lerp(1, c.afterGlow, after) * (1 - Easing.inQuad(after)); + + this._riftCentre(_centre); + this._riftAxis(_axis); + this.rift.setPlacement(_centre, this.direction, _axis); + + _rift.radiusX = half; + _rift.radiusY = Math.max(0.02, c.riftWidth); + _rift.margin = c.margin; + _rift.open = open; + _rift.seam = c.seam; + _rift.tearJag = c.tearJag; + _rift.tearScale = c.tearScale * g.noiseFrequency; + _rift.tearCrawl = c.tearCrawl * g.noiseSpeed; + _rift.edgeSoft = c.edgeSoft; + _rift.seed = this._seed; + _rift.opacity = c.opacity * g.opacity * (1 - after); + + _rift.rim = c.rim; + _rift.rimGlow = c.rimGlow * glow * g.glow; + _rift.core = c.core; + _rift.coreGlow = c.coreGlow * glow * g.glow; + _rift.throat = c.throat; + _rift.throatGlow = c.throatGlow * glow * g.glow; + _rift.crackCount = c.crackCount; + _rift.crackWidth = c.crackWidth; + _rift.crackLength = c.crackLength; + _rift.crackGlow = c.crackGlow * glow * g.glow; + + _rift.parallax = c.parallax; + _rift.swirl = c.swirl * g.noiseSpeed; + _rift.interiorFade = c.interiorFade; + _rift.starSize = c.starSize; + _rift.starTwinkle = c.starTwinkle; + _rift.starGain = c.starGain * g.glow; + _rift.starScaleA = c.starScaleA; + _rift.starScaleB = c.starScaleB; + _rift.starScaleC = c.starScaleC; + _rift.starDepthA = c.starDepthA; + _rift.starDepthB = c.starDepthB; + _rift.starDepthC = c.starDepthC; + _rift.starDriftA = c.starDriftA * g.noiseSpeed; + _rift.starDriftB = c.starDriftB * g.noiseSpeed; + _rift.starDriftC = c.starDriftC * g.noiseSpeed; + _rift.nebulaScale = c.nebulaScale * g.noiseFrequency; + _rift.nebulaSpeed = c.nebulaSpeed * g.noiseSpeed; + _rift.nebulaGain = c.nebulaGain; + _rift.nebulaDepth = c.nebulaDepth; + + _rift.colorVoid = c.colorVoid; + _rift.colorRim = c.colorRim; + _rift.colorCore = c.colorCore; + _rift.colorCrack = c.colorCrack; + _rift.colorThroat = c.colorThroat; + _rift.colorStarA = c.colorStarA; + _rift.colorStarB = c.colorStarB; + _rift.colorStarC = c.colorStarC; + _rift.colorNebulaA = c.colorNebulaA; + _rift.colorNebulaB = c.colorNebulaB; + this.rift.update(_rift); + + /* --- the lens, at the same anchor, a little wider than the hole --- */ + const lensR = Math.max(0.05, half * c.lensRadius); + this.lens.setAnchorXYZ(_centre.x, c.lensLift, _centre.z); + _lens.radius = lensR; + _lens.width = lensR * c.lensQuad; + _lens.height = lensR * c.lensQuad; + // Never multiplied by `post.distortion` or `global.distortion`: the pass + // applies both, once, and folding them in here would apply them twice. + _lens.strength = c.lensStrength * (1 - after) * Easing.outQuad(saturate(this.age / 0.18)); + _lens.window = c.lensWindow; + _lens.core = c.lensCore; + _lens.swirl = c.lensSwirl; + _lens.maxOffset = c.lensMax; + _lens.opacity = 1 - after; + _lens.seed = this._seed; + _lens.depthReject = c.lensDepthReject; + _lens.depthFade = c.lensDepthFade; + _lens.perspective = c.lensPerspective; + _lens.perspectiveRef = c.lensPerspectiveRef; + this.lens.update(_lens); + + /* --- the two particle systems --- */ + this.motes.setGradient( + getColor(c.colorMoteA), + getColor(c.colorMoteB), + getColor(c.colorMoteC), + getColor(c.colorMoteD) + ); + this.motes.uniforms.uGravity.value.set(0, c.moteSink, 0); + this.motes.uniforms.uSwirl.value = c.moteSpin; + this.motes.uniforms.uSwirlExpand.value = c.motePull; + this.motes.uniforms.uSizeScale.value = c.moteSize * g.particleSize * 7; + this.motes.uniforms.uLifeScale.value = c.moteLifetime * 0.5 * g.particleLifetime; + this.motes.uniforms.uSpeedScale.value = g.particleSpeed; + this.motes.uniforms.uOpacity.value = g.opacity; + this.motes.uniforms.uGlow.value = 1.1 * g.glow; + this.motes.uniforms.uTurbulence.value = c.moteTurbulence * g.turbulence; + + this.embers.setGradient( + getColor(c.colorEmberA), + getColor(c.colorEmberB), + getColor(c.colorEmberC), + getColor(c.colorEmberD) + ); + this.embers.uniforms.uGravity.value.set(0, c.emberGravity, 0); + this.embers.uniforms.uSizeScale.value = c.emberSize * g.particleSize * 7; + this.embers.uniforms.uLifeScale.value = c.emberLifetime * 0.5 * g.particleLifetime; + this.embers.uniforms.uSpeedScale.value = g.particleSpeed; + this.embers.uniforms.uOpacity.value = g.opacity; + this.embers.uniforms.uGlow.value = 1.4 * g.glow; + this.embers.uniforms.uStretch.value = c.emberStretch; + this.embers.uniforms.uTurbulence.value = 0.2 * g.turbulence; + } + + /** + * A point on the aperture's own plane, `u` along it (-1..1) and `v` across. + * Mirrors the shader's `uAnchor + ax * x + ay * y` exactly, so a mote born + * "at the rim" is born at the rim the GPU is drawing and not near it. + */ + _riftPoint(u, v, retract, out) { + const c = settings.voidrift; + this._riftCentre(out); + this._riftAxis(_axis); + out.addScaledVector(this.direction, u * this._halfLength(retract)); + out.addScaledVector(_axis, v * c.riftWidth); + return out; + } + + /** + * Motes drawn in, embers shed, and the stain the racing ends leave. + * + * @param {number} scale 0..1 — thinned once the rift is only holding + */ + _riftFx(dt, scale, retract) { + const c = settings.voidrift; + const g = settings.global; + const time = frame.uTime.value; + + const moteCount = Math.round(this.moteEmitter.tick(dt, c.moteRate * scale) * g.particleCount); + if (moteCount > 0) { + // A mote is born in a ball around a point on the rift, and the swirl + // shader treats that same point as its anchor — so it orbits the rift + // rather than orbiting wherever it happens to have drifted to. + this._riftPoint(randRange(-1, 1), 0, retract, _pos); + _emit.position = _pos; + _emit.radius = c.moteSpread; + _emit.direction = _dir.set(0, -1, 0); + _emit.speed = c.moteDrift; + _emit.speedVariance = 0.5; + _emit.spread = 0.4; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.1; + _emit.sizeVariance = 0.6; + _emit.life = c.moteLifetime; + _emit.lifeVariance = 0.45; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.motes.emit(moteCount, _emit); + } + + const emberCount = Math.round(this.emberEmitter.tick(dt, c.emberRate * scale) * g.particleCount); + if (emberCount > 0) { + const v = randRange(-1, 1); + this._riftPoint(randRange(-1, 1), v, retract, _pos); + _emit.position = _pos; + _emit.radius = 0.06; + // Off the lip, along the aperture's own normal-ish: away from the plane + // on whichever side the ember was born. + _emit.direction = _dir.copy(_axis).multiplyScalar(v >= 0 ? 0.35 : -0.35).setY(0.9).normalize(); + _emit.speed = c.emberSpeed; + _emit.speedVariance = 0.8; + _emit.spread = 0.75; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.14; + _emit.sizeVariance = 0.7; + _emit.life = c.emberLifetime; + _emit.lifeVariance = 0.5; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.embers.emit(emberCount, _emit); + } + } + + /** + * Stains laid on the floor as the ends race past. + * + * The counter is a count of events, not a distance: the metres are + * re-resolved on the frame each stain is spawned, and a decal is allowed to + * capture its radius because a decal *is* an event (`GroundDecals.spawn` + * writes `uRadius` once, by design). + */ + _stainFx() { + const c = settings.voidrift; + const full = this._fullHalf(); + const half = this._halfLength(0); + const step = 1 / Math.max(0.05, c.scorchRate); + const wanted = Math.min(64, Math.floor((half * 2) / step)); + + while (this._stains < wanted) { + // Alternate ends, so the pair of fronts lay their own marks. + const side = this._stains % 2 === 0 ? 1 : -1; + const u = ((this._stains + 1) / Math.max(1, wanted)) * side; + this._riftPoint(u, randRange(-0.9, 0.9), 0, _pos); + _pos.y = 0; + this._stains++; + + this.ctx.decals.spawn(DecalType.SCORCH, _pos, { + radius: c.scorchRadius * randRange(0.75, 1.3), + life: c.scorchLife, + intensity: c.scorchIntensity * saturate(half / Math.max(0.05, full)), + colorA: getColor(c.colorScorch), + colorB: getColor(c.colorScorchEdge), + height: 0.012 + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Phases */ + /* ------------------------------------------------------------------ */ + + onSpawn() { + this.moteEmitter.reset(); + this.emberEmitter.reset(); + this._stains = 0; + // The one thing this cast captures, besides its own timestamps. + this._seed = Math.random() * 100; + + this.rift.visible = true; + this.lens.visible = true; + this._syncRift(0, 0); + } + + onTravel(dt) { + this._syncRift(0, 0); + this._riftFx(dt, this._tearReach(), 0); + this._stainFx(); + + // The light sits in the rift, not on the cast front: the hole is what is + // lighting the floor and there is nothing travelling to follow. + this._riftCentre(this.position); + + this.ctx.shake.rumble(settings.voidrift.rumble * settings.global.cameraShake, dt); + } + + onImpact() { + const c = settings.voidrift; + const g = settings.global; + const time = frame.uTime.value; + + this._riftCentre(_pos); + + /* the shell of displaced air over the middle of the rift */ + this.ctx.bursts.spawn(BurstMode.AIR, _pos, { + radius: c.burstSize * 0.2, + endRadius: c.burstSize * g.explosionIntensity, + life: 0.6, + intensity: c.burstIntensity, + opacity: 0.85, + fresnel: 1.7, + displace: 0.55, + squash: 0.55, + colorA: getColor(c.colorBurstA), + colorB: getColor(c.colorBurstB), + colorC: getColor(c.colorBurstC) + }); + + /* the ring that snaps out across the floor as the ends stop */ + this._riftCentre(_pos).setY(0); + this.ctx.decals.spawn(DecalType.SHOCKWAVE, _pos, { + radius: c.shockRadius * g.explosionIntensity, + life: 0.7, + width: 0.05, + intensity: 1.0, + colorA: getColor(c.colorShockA), + colorB: getColor(c.colorShockB) + }); + + /* embers off the whole length at once */ + this._riftCentre(_pos); + _emit.position = _pos; + _emit.radius = this._halfLength(0) * 0.8; + _emit.direction = _dir.set(0, 1, 0); + _emit.speed = c.emberSpeed * 2.0; + _emit.speedVariance = 0.85; + _emit.spread = 1.0; + _emit.inherit = null; + _emit.anchor = null; + _emit.size = 0.18; + _emit.sizeVariance = 0.8; + _emit.life = c.emberLifetime * 1.4; + _emit.lifeVariance = 0.6; + _emit.spin = 0; + _emit.tint = null; + _emit.time = time; + this.embers.emit(Math.round(c.burstEmbers * g.particleCount), _emit); + + this.ctx.shake.add( + c.impactShake * g.explosionIntensity * g.cameraShake, + 1 / Math.max(0.1, c.shakeDuration), + 19 + ); + this.ctx.flash.trigger(getColor(c.colorFlash), c.impactFlash * g.explosionIntensity); + this.lightBoost = c.lightIntensity * 1.2 * g.explosionIntensity; + } + + onFade(dt, t) { + const c = settings.voidrift; + + // `t` runs 0..1 while the rift stands, then 1..2 while it shuts. Inside the + // second half the two sub-beats are split by the live pair of times, so + // re-timing the close re-times a rift that is already shutting. + const k = saturate(t - 1); + const share = c.closeTime / Math.max(0.05, c.closeTime + c.afterTime); + const retract = saturate(k / Math.max(1e-3, share)); + const after = saturate((k - share) / Math.max(1e-3, 1 - share)); + + this._syncRift(retract, after); + this._riftCentre(this.position); + + // The rift keeps eating while it is open and stops the moment it starts to + // shut: nothing should be drawn into a hole that is no longer there. + this._riftFx(dt, (t <= 1 ? 1 : 1 - retract) * 0.8, retract); + } + + onDestroy() { + // Both of these release something: the portal's mesh, and — the one that + // matters — the distortion pass's writer counter. An emitter left visible + // keeps the whole refraction pass running for the rest of the session. + this.rift.visible = false; + this.lens.visible = false; + this._stains = 0; + } + + dispose() { + this.rift.dispose(); + this.lens.dispose(); + super.dispose(); + } +} diff --git a/src/archive/materials/OceanWaterMaterial.js b/src/archive/materials/OceanWaterMaterial.js index c4d9f6a..59bed9b 100644 --- a/src/archive/materials/OceanWaterMaterial.js +++ b/src/archive/materials/OceanWaterMaterial.js @@ -265,10 +265,9 @@ export class OceanWaterMaterial extends ShaderMaterial { return v / norm; } - vec2 equirectUv(vec3 dir) { - return vec2(atan(dir.z, dir.x) * 0.15915494 + 0.5, - asin(clamp(dir.y, -1.0, 1.0)) * 0.31830989 + 0.5); - } + // equirectUv used to be defined here. It now lives in commonGLSL, which + // this shader already injects above, and declaring it twice is a compile + // error — so if you revive this material, it works as-is. /** * Floor under the reflected environment. diff --git a/src/assets/ProceduralGeometry.js b/src/assets/ProceduralGeometry.js index aefd1a9..27a6c03 100644 --- a/src/assets/ProceduralGeometry.js +++ b/src/assets/ProceduralGeometry.js @@ -259,7 +259,19 @@ export function createAsteroidGeometry({ craterDepth = 0.18, craterSize = 0.5 } = {}) { - const geometry = new IcosahedronGeometry(1, clamp(Math.round(detail), 0, 3)).toNonIndexed(); + // Every vertex is displaced on its own below, so shared vertices would weld + // the cuts and craters back together — the rock has to be non-indexed. It + // already is: `PolyhedronGeometry` expands its faces as it builds them, and + // calling `toNonIndexed()` on that only earns a console warning, forty-five + // of them in one harness run because Cinder Fall rebuilds its rock live when + // a shape slider moves. Convert only if three ever starts indexing it, and + // drop the original on the floor when we do. + const base = new IcosahedronGeometry(1, clamp(Math.round(detail), 0, 3)); + let geometry = base; + if (base.index) { + geometry = base.toNonIndexed(); + base.dispose(); + } const array = geometry.attributes.position.array; /** A deterministic point on the unit sphere. */ diff --git a/src/config/abilities/afterimage.js b/src/config/abilities/afterimage.js new file mode 100644 index 0000000..bd91040 --- /dev/null +++ b/src/config/abilities/afterimage.js @@ -0,0 +1,276 @@ +/* ================================================================== */ +/* AFTERIMAGE — chrono */ +/* ================================================================== */ +/** + * A fan of blades flies the line, opening as it goes, and every `snapGap` + * seconds it sheds a copy of itself that stops where it was. + * + * There is no captured second anywhere in this block's ability. Copy `k` is + * the cast shown at age `k × snapGap`, and `snapGap` is the slider directly + * below — so with the sandbox paused, dragging it re-ages every frozen moment + * at once, slides each of them to where it would have been at its new age, and + * re-opens it to the shape it had there. Dragging `speed`, `range`, + * `bladeLength`, `splay` or `openCurve` does the same to all of them + * simultaneously. That is the ability; the rest of this block is dressing. + * + * Open **The row** first, put `snaps` at 6 and pull `snapGap` back and forth. + */ +export const afterimage = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused + speed: 17.0, // how fast the body flies, metres/second + lifetime: 1.0, // seconds the row stands after the body lands + fadeTime: 1.2, // seconds it takes to be taken away + cooldown: 0.8, + castAnim: 'cast2', + + /* --- the row --- */ + snaps: 6, // frozen copies standing behind the body (capped at 8) + snapGap: 0.115, // seconds of cast age between one copy's instant and the next + flightCurve: 1.15, // >1 leaves slowly and arrives fast, so the row bunches at the muzzle + + /* --- the flight the whole row sits on --- */ + liftNear: 1.25, // metres above the floor at the muzzle + lift: 0.95, // ... and at the far end + arc: 0.3, // metres the flight bows upward at mid-span + sway: 0.22, // metres of lateral wander along the line + swayWaves: 1.15, // wavelengths of that wander over the whole line + + /* --- one body --- */ + // The form has to change strongly with age or six copies of it read as six + // of the same object: a bud at the muzzle, a blade at mid-range and a + // splayed fan at the end is three legibly different silhouettes off one + // parameter, and that is what makes the row read as one thing photographed + // six times. + fins: 3, // blades in the fan (capped at 4) + splay: 0.42, // radians a blade tilts off the axis, fully open + finGap: 0.075, // metres a blade steps off the axis, fully open + roll: 0.0, // radians the fan is rolled at birth + rollSpeed: 1.3, // radians/second it keeps rolling as it flies + holdSpin: 0.12, // radians/second a *frozen* copy keeps turning — it is held, not dead + lengthNear: 0.45, // metres — the bud at the muzzle + bladeLength: 2.0, // metres — the blade, fully open + radiusNear: 0.085, // metres + bladeRadius: 0.15, // metres + flatten: 0.42, // 0..1 squashes the sliver into a blade rather than a spindle + openCurve: 0.75, // <1 opens it early, >1 keeps it shut and opens it late + + /* --- what being held does to a copy --- */ + holdLife: 2.6, // seconds a frozen copy takes to give up + holdShrink: 0.045, // × per second it contracts + holdSink: 0.045, // metres/second it settles toward the floor + holdDim: 0.6, // 0..1 how far a fully-held copy has faded out + erode: 0.9, // 0..1 how much of a held copy the dissolve eventually takes + erodeScale: 2.4, // dissolve noise features per metre + erodeEdge: 0.17, // 0..1 width of the burning edge on that dissolve + edgeGlow: 1.5, // emission on that edge + + /* --- colour and shading --- */ + colorLive: '#f6e3b4', // the copy that is still the present + colorHeld: '#8d8570', // ... and one that has been standing a while + colorRim: '#ffeec8', // the fresnel edge and the dissolve's burn + colorCore: '#fffdf6', // the spine down the middle of a blade + rim: 1.15, // fresnel emission + rimPower: 2.5, // fresnel tightness + core: 0.9, // brightness of the spine + bandScale: 3.2, // bands per metre of world height + bandSpeed: 0.55, // metres/second those bands travel + bandGlow: 0.32, // their emission + glow: 1.6, // overall emissive gain + opacity: 1.0, + softFade: 0.5, // metres of soft fade where a copy meets geometry + + /* --- the stasis bubble on the newest held copy --- */ + holdRadius: 1.5, // metres of the region that stops time around it + holdCore: 0.4, // 0..1 of that radius that is fully stopped + holdStrength: 0.95, // 0..1 how completely + holdRate: 0.0, // clock rate inside — 0 stasis, -1 rewind, 0.25 slow motion + + /* --- what the body sheds --- */ + moteRate: 110, // motes/second off the live body + moteSize: 0.055, + moteSpeed: 1.1, + moteLifetime: 2.6, // long, because a mote that dies in 200 ms cannot be seen to stop + moteRise: 0.3, // upward drift, metres/second + moteTurbulence: 0.5, + moteSpread: 0.3, // metres of scatter around the body + moteOpacity: 0.8, + moteGlow: 1.0, + colorMoteA: '#fff6df', + colorMoteB: '#f0d8a0', + colorMoteC: '#a3906a', + colorMoteD: '#2b2519', + + /* --- the shutter click, when a copy detaches --- */ + shearPerSnap: 26, // streaks thrown sideways per freeze + shearSize: 0.13, + shearSpeed: 5.5, + shearLifetime: 0.55, + shearGravity: -3.0, + shearStretch: 0.22, // how far a streak smears along its velocity + shearSpread: 0.28, // metres of scatter at the freeze + shearGlow: 1.4, + colorShearA: '#ffffff', + colorShearB: '#ffeec8', + colorShearC: '#c9a86a', + colorShearD: '#3a2f1c', + snapRingRadius: 0.9, // the thin ring left on the floor under a freeze, metres + snapRingLife: 0.7, + snapRingWidth: 0.04, + snapRingIntensity: 0.55, + snapPunch: 0.35, // × light intensity added on each freeze + colorSnapRingA: '#d8c395', + colorSnapRingB: '#fff4d8', + + /* --- the arrival --- */ + arrivalSize: 2.1, // radius of the pressure shell at the far end, metres + arrivalIntensity: 1.1, + arrivalShear: 90, // streaks thrown on arrival + arrivalShake: 0.3, + shakeDuration: 0.45, + arrivalFlash: 0.09, + rumble: 0.012, // continuous shake while the body flies + colorArrivalA: '#c9a86a', + colorArrivalB: '#f4e9cd', + colorArrivalC: '#fffaf0', + colorFlash: '#efe2c0', + + /* --- dynamic light --- */ + lightIntensity: 14, + lightRadius: 13, + lightColor: '#f0d8a0', + lightSteps: 4, // levels the light quantises to — it steps, one per copy shed + lightStagger: 0.3 // 0..1 how far it drops between those levels +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Afterimage. + * + * Every control in **The row**, **The flight** and **One body** is evaluated + * per instance in the vertex shader against each copy's own age, so all of them + * reshape the whole standing row and not just the body that is still moving. + * That is worth doing at least once with **P** held down: it is the clearest + * demonstration of invariant I1 anywhere in the sandbox. + */ +export const afterimageSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 80, 0.5, 'flight speed'], + ['lifetime', 0.05, 8, 0.01, 'row hold'], + ['fadeTime', 0.05, 5, 0.01, 'taken-away time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The row': [ + ['snaps', 0, 8, 1, 'frozen copies'], + ['snapGap', 0.02, 0.8, 0.005, 'seconds between copies'], + ['flightCurve', 0.2, 4, 0.01, 'flight curve'] + ], + 'The flight': [ + ['liftNear', 0, 3, 0.01, 'height at muzzle'], + ['lift', 0, 3, 0.01, 'height at target'], + ['arc', -2, 3, 0.01, 'mid-span bow'], + ['sway', 0, 2, 0.01, 'lateral wander'], + ['swayWaves', 0, 6, 0.05, 'wander wavelengths'] + ], + 'One body': [ + ['fins', 1, 4, 1, 'blades'], + ['splay', 0, 1.6, 0.01, 'splay'], + ['finGap', 0, 0.6, 0.005, 'blade offset'], + ['roll', -3.2, 3.2, 0.01, 'roll at birth'], + ['rollSpeed', -8, 8, 0.05, 'roll speed'], + ['holdSpin', -3, 3, 0.01, 'held spin'], + ['lengthNear', 0.05, 3, 0.01, 'length at muzzle'], + ['bladeLength', 0.1, 6, 0.01, 'length open'], + ['radiusNear', 0.01, 0.8, 0.005, 'radius at muzzle'], + ['bladeRadius', 0.01, 0.8, 0.005, 'radius open'], + ['flatten', 0.02, 1, 0.01, 'blade flatness'], + ['openCurve', 0.1, 4, 0.01, 'opening curve'] + ], + 'Being held': [ + ['holdLife', 0.1, 10, 0.05, 'hold lifetime'], + ['holdShrink', 0, 0.6, 0.005, 'shrink / second'], + ['holdSink', -0.3, 0.6, 0.005, 'settle / second'], + ['holdDim', 0, 1, 0.01, 'dim when held'], + ['erode', 0, 1.5, 0.01, 'dissolve'], + ['erodeScale', 0.2, 10, 0.05, 'dissolve scale'], + ['erodeEdge', 0.01, 0.6, 0.005, 'burning edge'], + ['edgeGlow', 0, 6, 0.05, 'edge glow'] + ], + 'Being held/Stasis bubble': [ + ['holdRadius', 0, 8, 0.05, 'bubble radius'], + ['holdCore', 0, 1, 0.01, 'stopped core'], + ['holdStrength', 0, 1, 0.01, 'strength'], + ['holdRate', -2, 2, 0.05, 'clock rate inside'] + ], + Colour: [ + ['colorLive', 'the present'], + ['colorHeld', 'held'], + ['colorRim', 'rim & burn'], + ['colorCore', 'spine'], + ['rim', 0, 5, 0.01, 'rim glow'], + ['rimPower', 0.2, 8, 0.05, 'rim tightness'], + ['core', 0, 4, 0.01, 'spine brightness'], + ['bandScale', 0.2, 14, 0.1, 'bands / metre'], + ['bandSpeed', -4, 4, 0.01, 'band speed'], + ['bandGlow', 0, 2, 0.01, 'band glow'], + ['glow', 0, 6, 0.01, 'glow'], + ['opacity', 0, 2, 0.01, 'opacity'], + ['softFade', 0.02, 3, 0.01, 'soft intersection'] + ], + Motes: [ + ['moteRate', 0, 600, 1, 'motes / second'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 10, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 4, 0.01, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['moteSpread', 0.02, 2, 0.01, 'mote spread'], + ['moteOpacity', 0, 2, 0.01, 'mote opacity'], + ['moteGlow', 0, 4, 0.01, 'mote glow'], + ['colorMote*', 'Mote colour'] + ], + 'The shutter': [ + ['shearPerSnap', 0, 200, 1, 'streaks / freeze'], + ['shearSize', 0.005, 0.6, 0.005, 'streak size'], + ['shearSpeed', 0, 25, 0.1, 'streak speed'], + ['shearLifetime', 0.05, 4, 0.01, 'streak lifetime'], + ['shearGravity', -30, 5, 0.1, 'streak gravity'], + ['shearStretch', 0, 3, 0.01, 'streak stretch'], + ['shearSpread', 0.02, 2, 0.01, 'streak spread'], + ['shearGlow', 0, 4, 0.01, 'streak glow'], + ['colorShear*', 'Streak colour'], + ['snapRingRadius', 0.05, 6, 0.05, 'ring radius'], + ['snapRingLife', 0.05, 4, 0.05, 'ring lifetime'], + ['snapRingWidth', 0.005, 0.4, 0.005, 'ring width'], + ['snapRingIntensity', 0, 3, 0.01, 'ring intensity'], + ['snapPunch', 0, 3, 0.01, 'light punch / freeze'], + ['colorSnapRingA', 'ring body'], + ['colorSnapRingB', 'ring crest'] + ], + 'The arrival': [ + ['arrivalSize', 0.1, 12, 0.05, 'shell size'], + ['arrivalIntensity', 0, 5, 0.01, 'shell intensity'], + ['arrivalShear', 0, 400, 1, 'arrival streaks'], + ['arrivalShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 3, 0.01, 'shake duration'], + ['arrivalFlash', 0, 1, 0.005, 'screen flash'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['colorArrivalA', 'shell'], + ['colorArrivalB', 'shell body'], + ['colorArrivalC', 'shell filaments'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightSteps', 1, 10, 1, 'quantise levels'], + ['lightStagger', 0, 1, 0.01, 'step depth'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/anvilfall.js b/src/config/abilities/anvilfall.js new file mode 100644 index 0000000..1e6f7b9 --- /dev/null +++ b/src/config/abilities/anvilfall.js @@ -0,0 +1,478 @@ +/* ================================================================== */ +/* ANVILFALL — forge, zone cast */ +/* ================================================================== */ +/** + * A machined anvil falls out of the ceiling and lands on the circle. + * + * Everything in this block exists to sell **mass**, and mass is not a look — + * it is a set of consequences that have to agree with one another. The three + * that carry the read are: + * + * 1. **The floor dishes under it.** `vfx/GroundField.js` in `POCK` mode, with + * five craters posted in a line along the anvil's own base, so the dent is + * the shape of the thing that made it rather than a circle. The marks are + * unitless fractions and their positions are **re-derived every frame** — + * drag `anvilSize` with the clock stopped and the dent under the anvil + * changes length. + * 2. **The shock is low and slow.** `vfx/Shell.js` in `PRESSURE` mode, at + * `shockHeight = 0.1` (a flat lens hugging the floor, not a dome), + * `shockExpand = 1.15` (nearly linear, so it keeps travelling instead of + * snapping out and easing) and `shockGlow = 0.55` in dust colours. The + * first pass had it at the shipped `PRESSURE` defaults — a bright fast + * hemisphere — and it read as a grenade going off under the anvil. + * 3. **It stays.** Nothing about the anvil fades. It is opaque steel from the + * frame it lands to the frame the slot is reclaimed, and it leaves by + * sinking (`exitSink`), because a lump of steel that dissolves is a + * hologram. `fadeTime` is long on purpose: the whole point of the slot is + * that you get to walk round it afterwards. + * + * **The one number the landing is not allowed to invent.** Every consequence + * of the impact is a function of the speed the anvil is *actually travelling + * at* when it arrives, and that speed is read off the animation rather than + * typed in: the height above the floor is `dropHeight · (1 − u^fallCurve)`, so + * `v = dropHeight · fallCurve / T` where `T` is the cast's own travel time. + * The shake, the chip speed, the dust speed, the light punch, the depth of the + * dent and the speed of the pressure front are all derived from it against + * `refSpeed`. Halve `dropHeight` on a paused cast and six things get quieter + * together — which is the only way a landing ever reads as heavy, because a + * hard shake over a slow arrival reads as a bug in the camera. + * + * The steel is `vfx/HardSurface.js`: brushed anisotropic specular along the + * face, mill scale, casting pits, machined-edge wear and a real blackbody ramp + * on `heat`. `heat` ships at 0.08 — visually stone cold, because a forge that + * only ever produces glowing props is a fireworks shop — but it is a live + * slider, and at 0.6 the same anvil comes out of the fire. + */ +export const anvilfall = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 26.0, // how fast the cast reaches the circle, metres/second — also the fall clock + zoneRadius: 3.4, // the circle the anvil lands inside, metres + cooldown: 2.4, // seconds before the slot re-arms + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + holdTime: 2.8, // seconds the anvil simply sits there after everything has settled + fadeTime: 2.2, // seconds the dust, the ring and the light take to go + + /* --- the fall --- */ + dropHeight: 9.5, // metres above the floor the anvil starts + fallCurve: 2.0, // 2 is constant gravity; 1 is a lift descending; 3 is cartoon + driftForward: 0.0, // metres downrange of the circle's centre it lands + driftSide: 0.0, // metres to the side (+ follows `Ability#side`) + yawTurns: 0.28, // turns/second it rotates on the way down — slow, because it is heavy + tilt: 0.14, // radians it is tipped over at the top of the fall + tiltSettle: 2.6, // how fast that tilt levels out; >1 lands it flat + refSpeed: 28.0, // metres/second the derived reads are normalised against + settleDrop: 0.05, // metres it compresses into the floor after landing + settleTime: 0.42, // seconds that compression takes + exitStart: 0.55, // 0..1 of the fade before it starts sinking out of sight + exitSink: 1.5, // metres it sinks by, × anvilSize + + /* --- the anvil, as a live shape (fractions of its own length) --- */ + anvilSize: 1.75, // metres — the geometry's longest dimension. The only metre here + bodyHeight: 0.66, // overall height + faceWidth: 0.5, // the working slab across + faceDepth: 0.42, // ... and front to back + waistWidth: 0.24, // the pinch under the face + waistHeight: 0.3, // how much of the height the waist takes + baseWidth: 0.56, // the foot it stands on + hornReach: 0.46, // how far the horn projects past the face + hornDroop: 0.07, // how far the horn's tip falls + fillet: 0.62, // 0..1 smoothing on the silhouette — the forged look + corner: 0.11, // 0..1 rounding on every cross-section + + /* --- the steel (vfx/HardSurface.js, canonical names, `anvil`-free) --- */ + brushMode: 0, // 0 LINEAR, 1 CIRCUMFERENTIAL, 2 RADIAL — an anvil is drawn out lengthways + brushAxisX: 1.0, // the grain direction in the part's own space; +X is the horn + brushAxisY: 0.0, + brushAxisZ: 0.0, + anisotropy: 0.82, // 0 round highlight, 1 fully smeared along the grain + specular: 1.35, // gain on the anisotropic lobe + grain: 0.5, // how hard the brushing cuts into roughness + grainScale: 70.0, // grain cycles per unit of local space + grainStretch: 30.0, // how far a streak runs along the brush direction + roughness: 0.42, // base, before grain / pitting / wear + metalness: 0.93, + envIntensity: 1.0, // HDR probe gain + millScale: 0.42, // the blue-black oxide off the forge, 0..1 coverage + millScaleSize: 5.5, // its patch size + millScaleSharp: 0.6, // 0 a smear, 1 a hard flake edge + pit: 0.4, // casting pits and corrosion + pitScale: 48.0, + wear: 0.66, // how bright the machined edges come up + wearGrain: 0.4, // how much the grain breaks that wear up + heat: 0.08, // 0..1 — cold, but the slider is here and it is the forge's one knob + heatCold: 300.0, // kelvin at heat = 0 + heatHot: 1900.0, // kelvin at heat = 1 + heatRef: 1250.0, // kelvin at which the emission term reaches 1 + heatExponent: 4.0, // Stefan-Boltzmann; 4 is the physical value + heatGlow: 2.2, // gain on the emission + heatTint: 0.75, // how far the albedo washes toward the hot colour + heatEdge: 0.24, // how much cooler an edge reads — thin sections radiate faster + colorMetal: '#8d949c', // clean steel + colorDeep: '#33383e', // the bottom of a pit + colorScale: '#2a2521', // mill scale + colorPolish: '#e2e9f2', // a worn edge, where the hammer has been + colorSpec: '#fff1de', // the anisotropic highlight's own colour + + /* --- the dent (vfx/GroundField.js, POCK) --- */ + dishRadius: 3.4, // metres — the quad and the frame the marks are fractions of + dishDepth: 0.34, // metres the floor gives way by at refSpeed + dishCurve: 0.8, // how the depth answers the impact speed; 1 is linear + dishSink: 0.55, // 0..1 of that depth the anvil itself settles into + dishFootprint: 0.72, // the line of craters, × anvilSize + dishLift: 0.075, // metres of rim heaved up around each crater + dishRimWidth: 0.2, // metres — how wide that rim band is + dishMarkRadius: 0.72, // metres — radius of one full-strength crater + dishDig: 9.0, // craters/second — how fast one digs itself in + dishLife: 26.0, // seconds a crater weathers away over. Longer than the cast, on purpose + dishBaseLoad: 1.0, // 0..1 crater strength under the heavy end + dishHornLoad: 0.34, // ... and under the horn, which barely touches + dishGrain: 0.55, // 0..1 grit across the whole dent + dishHeight: 0.016, // metres the quad floats above the floor + dishEdge: 0.45, // metres of feather on the growth front + dishRagged: 0.22, // how far that front wanders, as a fraction of the radius + dishRaggedScale: 0.6, // lobes per metre + dishWarp: 0.55, // metres of domain warp on those lobes + dishRelief: 0.95, // how hard the height field tilts the fake normal + dishNormalStep: 0.05, // metres between the height taps + dishAmbient: 0.3, // floor on the diffuse term + dishWrap: 0.42, // 0..1 wraps the terminator round the back + dishSpecular: 0.22, // stone dust is not glossy + dishGloss: 16.0, // Blinn exponent + dishParallax: 0.3, // metres of view-driven offset on interior detail + dishEmissive: 0.35, // multiplier on the glowing term — near zero; this is a hole + dishOpacity: 0.95, + dishDepthFade: 0.45, // metres of soft fade against standing geometry + colorDishBase: '#6b6155', // the churned floor + colorDishEdge: '#a1968a', // the heaved rim + colorDishGlow: '#c0703a', // the only warm thing in the dent — grit off hot steel + colorDishDeep: '#241f1b', // the bottom of the bowl + + /* --- the pressure front (vfx/Shell.js, prefix `shock`, mode PRESSURE) --- */ + shockRadius: 1.4, // metres, at the instant of contact + shockRadiusEnd: 9.5, // metres it reaches. Also the reach the front's speed is fitted to + shockExpand: 1.15, // easing exponent — near 1 keeps it *travelling* rather than easing out + shockHeight: 0.1, // axial extent, × radius. A flat lens on the floor, not a dome + shockSpan: 6.0, // unused by PRESSURE; kept so the module's audit stays quiet + shockLift: 0.05, // hover above the floor, metres + shockDisplace: 0.07, // billow along the normal, × radius + shockNoiseScale: 1.5, // billow features per unit radius + shockNoiseSpeed: 0.35, // Hz the billow crawls at + shockTurbulence: 1.0, // master on the billow + shockFill: 0.04, // how much body it keeps — almost none, it is a pressure edge + shockRim: 1.9, // strength of the fresnel rim + shockRimPower: 4.0, // how tight that rim is + shockSeal: 1.4, // DOME only; kept for the audit + shockSealWidth: 0.12, // DOME only + shockEdge: 1.2, // CONE only + shockEdgeWidth: 0.16, // CONE only + shockConeCurve: 1.0, // CONE only + shockDissolve: 0.8, // how hard the age dissolve bites + shockRings: 10, // RING_TRAIN only + shockSpacing: 1.6, // RING_TRAIN only + shockRingSpeed: 7.0, // RING_TRAIN only + shockRingThickness: 0.16, // RING_TRAIN only + shockRingSharp: 1.6, // RING_TRAIN only + shockReflect: 1.0, // RING_TRAIN only + shockStanding: 1.0, // RING_TRAIN only + shockSwell: 0.45, // RING_TRAIN only + shockCoronaReach: 1.8, // SUNDISC only + shockCorona: 1.3, // SUNDISC only + shockCoronaLength: 0.55, // SUNDISC only + shockCoronaScale: 5.0, // SUNDISC only + shockCoronaWarp: 0.45, // SUNDISC only + shockCoronaSpeed: 0.7, // SUNDISC only + shockCoronaSharp: 0.72, // SUNDISC only + shockGranule: 0.45, // SUNDISC only + shockGranuleScale: 6.0, // SUNDISC only + shockRimWidth: 0.18, // SUNDISC only + shockOpacity: 0.5, // a pressure front you can see through + shockGlow: 0.55, // deliberately dim: this is displaced air and dust, not light + shockSoftFade: 0.7, // metres of depth fade against the opaque scene + shockColorBody: '#3b332a', // the body, such as it is + shockColorRim: '#c9b69c', // the rim — dust colour, never white + shockColorEdge: '#efe3ce', // the hottest mark it has + shockColorCorona: '#ffb44a', // SUNDISC only + shockSpeedRatio: 0.34, // the front travels at this fraction of the anvil's arrival speed + shockDelay: 0.0, // seconds after contact the front starts, if you want a beat + + /* --- dust, chips and sparks --- */ + /** + * Three systems, each with its own four-stop lifetime gradient (I5). The + * dust is non-additive because a plume thrown up by something heavy has to + * *occlude* — an additive plume reads as the anvil landing in fog. + */ + dustCount: 190, // puffed out of the rim on contact + dustRate: 55, // particles/second still rolling off the dent afterwards + dustSize: 1.15, + dustSpeed: 5.5, // metres/second at refSpeed; scaled by the real arrival speed + dustLifetime: 2.8, + dustRise: 0.5, // upward drift, metres/second + dustOpacity: 0.2, + dustTurbulence: 0.6, + dustHeight: 0.22, // metres above the floor it is emitted from + colorDustA: '#8f8477', + colorDustB: '#6d6459', + colorDustC: '#4c4640', + colorDustD: '#2b2723', + chipCount: 120, // floor chips spat out from under it + chipSize: 0.07, + chipSpeed: 7.5, // metres/second at refSpeed + chipLifetime: 1.5, + chipGravity: -22.0, + chipSpray: 0.85, // 0 straight up, 1 flat out along the floor + colorChipA: '#5c534a', + colorChipB: '#443d36', + colorChipC: '#332e29', + colorChipD: '#221f1c', + sparkCount: 60, // struck sparks — steel on stone, and hotter if `heat` is up + sparkSize: 0.1, + sparkSpeed: 6.0, + sparkLifetime: 0.55, + sparkGravity: -16.0, + sparkStretch: 0.16, // how far a spark smears along its velocity + sparkHeatTint: 0.85, // 0..1 how far the blackbody colour of the steel tints them + colorSparkA: '#ffd9a8', + colorSparkB: '#ff9e42', + colorSparkC: '#c2521c', + colorSparkD: '#3a1a0c', + + /* --- feedback --- */ + impactShake: 1.6, // camera kick at refSpeed + shakeDuration: 0.95, // seconds it decays over — long and low, not a snap + shakeFrequency: 11.0, // Hz. A heavy landing is a low frequency, and this is the tell + impactFlash: 0.05, // screen flash. Almost nothing: steel does not flash + colorFlash: '#d8c8ae', + rumble: 0.02, // continuous shake while the anvil is still falling + settleRumble: 0.05, // ... and while the dust is rolling off it + + /* --- the dynamic light --- */ + lightIntensity: 9.0, + lightRadius: 12.0, + lightHeight: 0.9, // metres above the floor the light sits once it has landed + lightColor: '#c08a52' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Anvilfall. + * + * Reach for **The fall** first. `dropHeight` and `fallCurve` are the two + * controls the whole ability hangs off, because the arrival speed derived from + * them drives the shake, the dent, the front and both particle speeds at once + * — and `refSpeed` is what "hard enough" means, so lowering it makes every + * landing count as heavy. After that, **The dent** (`dishDepth`, + * `dishFootprint`) is where the mass actually reads, and **The steel** is a + * whole material editor: `heat` alone takes the same anvil from cold grey to + * welding orange. + */ +export const anvilfallSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 4, 120, 0.5, 'cast speed'], + ['zoneRadius', 1, 12, 0.05, 'zone radius'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['holdTime', 0.2, 10, 0.05, 'hold after landing'], + ['fadeTime', 0.2, 8, 0.05, 'fade time'], + ['castAnim', 'cast animation'] + ], + 'The fall': [ + ['dropHeight', 1, 30, 0.1, 'drop height (m)'], + ['fallCurve', 0.6, 4, 0.01, 'fall curve'], + ['driftForward', -6, 6, 0.05, 'drift downrange (m)'], + ['driftSide', -6, 6, 0.05, 'drift sideways (m)'], + ['yawTurns', -2, 2, 0.01, 'yaw (turns/s)'], + ['tilt', 0, 1, 0.005, 'tilt at the top (rad)'], + ['tiltSettle', 0.2, 8, 0.05, 'tilt settle'], + ['refSpeed', 4, 90, 0.5, 'reference speed (m/s)'], + ['settleDrop', 0, 0.5, 0.005, 'compression (m)'], + ['settleTime', 0.05, 3, 0.01, 'compression time'], + ['exitStart', 0, 1, 0.01, 'sink starts at'], + ['exitSink', 0, 6, 0.05, 'sink depth × size'] + ], + 'The anvil': [ + ['anvilSize', 0.3, 6, 0.01, 'length (m)'], + ['bodyHeight', 0.2, 1.6, 0.01, 'height'], + ['faceWidth', 0.1, 1.2, 0.01, 'face width'], + ['faceDepth', 0.1, 1.2, 0.01, 'face depth'], + ['waistWidth', 0.05, 0.9, 0.01, 'waist width'], + ['waistHeight', 0.05, 0.8, 0.01, 'waist height'], + ['baseWidth', 0.1, 1.4, 0.01, 'base width'], + ['hornReach', 0, 1.2, 0.01, 'horn reach'], + ['hornDroop', 0, 0.4, 0.005, 'horn droop'], + ['fillet', 0, 1, 0.01, 'forge fillet'], + ['corner', 0, 0.9, 0.01, 'section rounding'] + ], + 'The steel/Brushing': [ + ['brushMode', 0, 2, 1, 'grain mode'], + ['brushAxisX', -1, 1, 0.01, 'grain axis X'], + ['brushAxisY', -1, 1, 0.01, 'grain axis Y'], + ['brushAxisZ', -1, 1, 0.01, 'grain axis Z'], + ['anisotropy', 0, 1, 0.01, 'anisotropy'], + ['specular', 0, 5, 0.01, 'specular gain'], + ['grain', 0, 2, 0.01, 'grain depth'], + ['grainScale', 4, 300, 1, 'grain scale'], + ['grainStretch', 1, 120, 0.5, 'grain stretch'] + ], + 'The steel/Surface': [ + ['roughness', 0.02, 1, 0.01, 'roughness'], + ['metalness', 0, 1, 0.01, 'metalness'], + ['envIntensity', 0, 3, 0.01, 'probe gain'], + ['millScale', 0, 1, 0.01, 'mill scale'], + ['millScaleSize', 0.5, 30, 0.1, 'scale patch size'], + ['millScaleSharp', 0, 1, 0.01, 'scale flake edge'], + ['pit', 0, 1, 0.01, 'pitting'], + ['pitScale', 4, 240, 1, 'pit scale'], + ['wear', 0, 1, 0.01, 'edge wear'], + ['wearGrain', 0, 1, 0.01, 'wear break-up'], + ['colorMetal', 'steel'], + ['colorDeep', 'pit floor'], + ['colorScale', 'mill scale'], + ['colorPolish', 'worn edge'], + ['colorSpec', 'highlight'] + ], + 'The steel/Heat': [ + ['heat', 0, 1, 0.005, 'heat'], + ['heatCold', 100, 900, 5, 'cold (K)'], + ['heatHot', 900, 2600, 5, 'hot (K)'], + ['heatRef', 400, 2400, 5, 'emission ref (K)'], + ['heatExponent', 0.5, 8, 0.05, 'emission exponent'], + ['heatGlow', 0, 8, 0.01, 'emission gain'], + ['heatTint', 0, 1, 0.01, 'albedo wash'], + ['heatEdge', 0, 1, 0.01, 'edge cooling'] + ], + 'The dent': [ + ['dishRadius', 0.5, 14, 0.05, 'dent radius (m)'], + ['dishDepth', 0, 2, 0.005, 'depth at ref (m)'], + ['dishCurve', 0.2, 3, 0.01, 'depth vs speed'], + ['dishSink', 0, 1, 0.01, 'anvil settles into'], + ['dishFootprint', 0, 2, 0.01, 'footprint × size'], + ['dishLift', 0, 0.5, 0.005, 'rim height (m)'], + ['dishRimWidth', 0.02, 1, 0.005, 'rim width (m)'], + ['dishMarkRadius', 0.05, 4, 0.01, 'crater radius (m)'], + ['dishDig', 0.5, 40, 0.1, 'dig rate'], + ['dishLife', 1, 60, 0.5, 'weathering (s)'], + ['dishBaseLoad', 0, 1, 0.01, 'load, heavy end'], + ['dishHornLoad', 0, 1, 0.01, 'load, horn end'], + ['dishGrain', 0, 1, 0.01, 'grit'] + ], + 'The dent/Shading': [ + ['dishHeight', 0, 0.2, 0.001, 'float above floor (m)'], + ['dishEdge', 0.02, 3, 0.01, 'front feather (m)'], + ['dishRagged', 0, 1, 0.01, 'front wander'], + ['dishRaggedScale', 0.05, 4, 0.01, 'lobes / m'], + ['dishWarp', 0, 3, 0.01, 'domain warp (m)'], + ['dishRelief', 0, 3, 0.01, 'relief'], + ['dishNormalStep', 0.005, 0.4, 0.005, 'normal step (m)'], + ['dishAmbient', 0, 1, 0.01, 'ambient'], + ['dishWrap', 0, 1, 0.01, 'terminator wrap'], + ['dishSpecular', 0, 2, 0.01, 'specular'], + ['dishGloss', 1, 96, 1, 'gloss'], + ['dishParallax', 0, 1.5, 0.01, 'parallax (m)'], + ['dishEmissive', 0, 3, 0.01, 'emissive'], + ['dishOpacity', 0, 1, 0.01, 'opacity'], + ['dishDepthFade', 0, 3, 0.01, 'depth fade (m)'], + ['colorDishBase', 'churned floor'], + ['colorDishEdge', 'heaved rim'], + ['colorDishGlow', 'hot grit'], + ['colorDishDeep', 'bowl'] + ], + 'The pressure front': [ + ['shockRadius', 0.05, 20, 0.01, 'start radius (m)'], + ['shockRadiusEnd', 0.5, 40, 0.05, 'reach (m)'], + ['shockSpeedRatio', 0.02, 2, 0.005, 'front speed / arrival'], + ['shockDelay', 0, 1.5, 0.01, 'delay after contact (s)'], + ['shockExpand', 0.6, 8, 0.01, 'expansion curve'], + ['shockHeight', 0.02, 2, 0.005, 'height × radius'], + ['shockLift', -1, 2, 0.005, 'lift (m)'], + ['shockDisplace', 0, 1.5, 0.005, 'billow'], + ['shockNoiseScale', 0.1, 10, 0.01, 'billow scale'], + ['shockNoiseSpeed', 0, 4, 0.01, 'billow Hz'], + ['shockTurbulence', 0, 3, 0.01, 'turbulence'], + ['shockFill', 0, 1, 0.005, 'body fill'], + ['shockRim', 0, 4, 0.01, 'rim'], + ['shockRimPower', 0.1, 10, 0.01, 'rim power'], + ['shockDissolve', 0, 2, 0.01, 'dissolve'], + ['shockOpacity', 0, 1, 0.01, 'opacity'], + ['shockGlow', 0, 6, 0.01, 'glow'], + ['shockSoftFade', 0, 3, 0.01, 'soft fade (m)'], + ['shockColorBody', 'front body'], + ['shockColorRim', 'front rim'], + ['shockColorEdge', 'front edge'] + ], + 'Dust & chips': [ + ['dustCount', 0, 600, 1, 'dust on contact'], + ['dustRate', 0, 400, 1, 'dust rate after'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 25, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustRise', -2, 5, 0.01, 'dust rise'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['dustHeight', 0, 2, 0.01, 'dust height (m)'], + ['chipCount', 0, 400, 1, 'chips'], + ['chipSize', 0.005, 0.4, 0.005, 'chip size'], + ['chipSpeed', 0, 30, 0.1, 'chip speed'], + ['chipLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['chipGravity', -60, 0, 0.5, 'chip gravity'], + ['chipSpray', 0, 1, 0.01, 'chip flatness'], + ['colorDust*', 'Dust colour'], + ['colorChip*', 'Chip colour'] + ], + 'Sparks': [ + ['sparkCount', 0, 400, 1, 'sparks'], + ['sparkSize', 0.005, 0.6, 0.005, 'spark size'], + ['sparkSpeed', 0, 30, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 3, 0.01, 'spark lifetime'], + ['sparkGravity', -60, 5, 0.5, 'spark gravity'], + ['sparkStretch', 0, 2, 0.01, 'spark stretch'], + ['sparkHeatTint', 0, 1, 0.01, 'blackbody tint'], + ['colorSpark*', 'Spark colour'] + ], + 'Feedback': [ + ['impactShake', 0, 5, 0.01, 'shake at ref'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['shakeFrequency', 2, 40, 0.5, 'shake Hz'], + ['impactFlash', 0, 1, 0.005, 'screen flash'], + ['rumble', 0, 0.4, 0.002, 'fall rumble'], + ['settleRumble', 0, 0.4, 0.002, 'settle rumble'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 5, 0.01, 'light height (m)'], + ['lightColor', 'light colour'] + ], + 'Unused modes (kept for the Shell audit)': [ + ['shockSpan', 0.1, 40, 0.05, 'span'], + ['shockSeal', 0, 4, 0.01, 'seal'], + ['shockSealWidth', 0.01, 0.6, 0.01, 'seal width'], + ['shockEdge', 0, 4, 0.01, 'cone lip'], + ['shockEdgeWidth', 0.01, 0.8, 0.01, 'cone lip width'], + ['shockConeCurve', 0.1, 4, 0.01, 'cone curve'], + ['shockRings', 1, 48, 1, 'rings'], + ['shockSpacing', 0.1, 12, 0.01, 'spacing'], + ['shockRingSpeed', 0, 40, 0.05, 'ring speed'], + ['shockRingThickness', 0.01, 2, 0.01, 'ring thickness'], + ['shockRingSharp', 0.05, 8, 0.01, 'ring profile'], + ['shockReflect', 0, 1, 0.01, 'reflection'], + ['shockStanding', 0, 1, 0.01, 'standing wave'], + ['shockSwell', 0, 2, 0.01, 'antinode swell'], + ['shockCoronaReach', 1, 4, 0.01, 'corona reach'], + ['shockCorona', 0, 4, 0.01, 'corona'], + ['shockCoronaLength', 0, 3, 0.01, 'corona length'], + ['shockCoronaScale', 0.5, 20, 0.1, 'corona scale'], + ['shockCoronaWarp', 0, 2, 0.01, 'corona warp'], + ['shockCoronaSpeed', 0, 4, 0.01, 'corona Hz'], + ['shockCoronaSharp', 0, 0.98, 0.01, 'corona threshold'], + ['shockGranule', 0, 2, 0.01, 'granulation'], + ['shockGranuleScale', 0.5, 24, 0.1, 'granule scale'], + ['shockRimWidth', 0.01, 0.6, 0.01, 'rim band'], + ['shockColorCorona', 'corona'] + ] +}; diff --git a/src/config/abilities/arcanevolley.js b/src/config/abilities/arcanevolley.js new file mode 100644 index 0000000..76a3e65 --- /dev/null +++ b/src/config/abilities/arcanevolley.js @@ -0,0 +1,311 @@ +/* ================================================================== */ +/* ARCANE VOLLEY — arcane, line */ +/* ================================================================== */ +/** + * Seven bolts that weave apart and land on one point at one instant. + * + * They gather at the hand first, circling it while the cast winds up; then they + * go, each on its own Lissajous figure, crossing each other the whole way; then + * every one of them arrives at the same place on the same frame and the volley + * lands as a single hit. + * + * **Nothing about that is simulated.** The convergence is `(1 − τ)^weaveDecay` + * multiplying the weave: identically zero at the target, so no bolt has + * anywhere else to be. There is no homing, no steering and no correction, which + * is why the whole flight re-shapes under a slider with the clock stopped. + * + * The four numbers that carry it: + * + * - `weaveSide` / `weaveUp` — how far apart the bolts get. Unequal on purpose: + * equal amplitudes on a 1:1-ish ratio draw circles, and a volley of circles + * reads as a drill bit. + * - `weaveTurns` — a multiplier on every bolt's frequency *pair*. The ratios + * themselves are a fixed table in `materials/VolleyMaterial.js`; this scales + * all seven together, so the weave gets busier without becoming uniform. + * - `weaveDecay` — where the bolts give up their spread. Below 1 they hold it + * late and snap in at the end; above 2 they are practically converged + * half-way and the last third is a straight line. + * - `trailSpan` — seconds of flight a trail reaches back over. The trails are + * the read; the heads are deliberately tiny. + */ +export const arcanevolley = { + /* --- the cast --- */ + range: 24.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 34.0, // how fast the volley crosses the span, metres/second + charge: 0.5, // seconds the bolts circle the hand before they go + lifetime: 0.35, // seconds the spent trails hang after the volley lands + fadeTime: 0.55, // seconds they blow out over + cooldown: 0.75, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the bolts gather --- */ + handHeight: 1.32, // metres above the floor + handForward: 0.55, // metres in front of the caster + handSide: 0.2, // metres to the side (+ follows `Ability#side`) + endHeight: 0.85, // metres above the floor where all seven arrive + + /* --- the wind-up --- */ + // While the cast is charging, a bolt's τ is pinned at zero and its weave + // phase is *rotated* instead — so it traces the τ = 0 slice of its own + // Lissajous figure, which is a small loop around the hand. At release the + // rotation stops and the identical offset simply starts travelling. + orbitTurns: 1.25, // turns each bolt makes around the hand over the whole wind-up + orbitStart: 0.22, // how tight the ring is when the bolts appear, × full spread + chargeCurve: 0.8, // <1 opens the ring early, >1 keeps it tight then flings it out + chargeShake: 0.02, // continuous camera shake while it winds up + + /* --- the weave --- */ + bolts: 7, // bolts in the volley (capped at 8) + // These two are also the radius of the ring the bolts gather on, because the + // gather *is* the flight at tau = 0 — see the wind-up note above. At 2.1 m the + // ring was wider than the caster and read as an orbit of the whole body + // rather than of a hand, so they came down until the ring was about an arm's + // length and the weave still crossed itself four or five times on the way out. + weaveSide: 1.15, // metres of lateral throw at launch + weaveUp: 0.75, // metres of vertical throw at launch + weaveTurns: 1.0, // multiplier on every bolt's frequency pair + weavePhase: 1.5708, // radians the vertical weave leads the lateral by + weaveDecay: 1.45, // >0 pulls the weave to exactly zero at the target + pathCurve: 1.0, // easing on launch → land; >1 accelerates into the hit + + /* --- the trails --- */ + trailSpan: 0.34, // seconds of flight one trail reaches back over + trailBurn: 0.3, // seconds the tail takes to catch the head up after landing + trailWidth: 0.14, // half-width at the head, metres + trailTaper: 1.5, // >1 sharpens the tail to a point + trailCoreSharp: 2.4, // how hard the hot core falls off across the ribbon + trailCoreWidth: 0.36, // fraction of the ribbon the core occupies + trailHaloFalloff: 2.4, // how fast the halo fades across the rest of it + trailHaloOpacity: 0.6, + trailFlicker: 0.09, // depth of the per-bolt brightness blink + trailFlickerSpeed: 20, // blinks/second + trailGlow: 2.1, // emissive gain into bloom + trailOpacity: 1.0, + trailSoftFade: 0.5, // metres of soft fade where a trail meets geometry + colorTrailA: '#2a1a4a', // the tail — the four stops run tail → head, not + colorTrailB: '#8a5fd0', // birth → death, because a ribbon *is* the history + colorTrailC: '#e0c0ff', + colorTrailD: '#ffffff', // the head + + /* --- the heads --- */ + headSize: 0.13, // radius of the billboard, metres. Small: the trails are the read + headCoreSharp: 3.6, // how tight the white centre is + headSoft: 1.1, // falloff of the bloom around it + headGlow: 2.4, // emissive gain + headOpacity: 1.0, + headSoftFade: 0.35, // metres of soft fade against geometry + colorHeadCore: '#ffffff', + colorHeadRim: '#a274ff', + + /* --- motes, sparks and dust --- */ + /** + * Each system is coloured by a four-stop gradient over the particle's own + * lifetime, `A` at birth through `D` as it dies. The motes get their own + * palette rather than the trail's, so the gather can be made colder than the + * bolts it is feeding. + */ + intakeRate: 220, // motes drawn into the hand while it winds up, particles/second + moteRate: 90, // motes shed along the trails in flight, particles/second + moteShell: 1.5, // radius the intake motes appear at, metres + moteSize: 0.055, + moteSpeed: 1.1, + moteLifetime: 1.1, + moteRise: 0.35, // upward drift, metres/second + moteSwirl: 3.4, // radians/second the intake orbits the hand + moteSwirlExpand: -0.55, // <0 draws the orbit inward as the mote ages + moteTurbulence: 0.45, + colorMoteA: '#ffffff', + colorMoteB: '#d9c0ff', + colorMoteC: '#8a5fd0', + colorMoteD: '#170e2e', + sparkSize: 0.13, + sparkSpeed: 8.5, + sparkLifetime: 0.5, + sparkGravity: -12.0, + sparkStretch: 0.22, // how far a spark smears along its velocity + burstSparks: 190, // sparks thrown by the combined impact + colorSparkA: '#ffffff', + colorSparkB: '#efe0ff', + colorSparkC: '#9a6fe0', + colorSparkD: '#241a52', + dustSize: 1.0, + dustSpeed: 1.3, + dustLifetime: 1.9, + dustRise: 0.45, + dustOpacity: 0.07, + burstDust: 45, // puffs kicked off the floor by the impact + colorDustA: '#463a5e', + colorDustB: '#38304c', + colorDustC: '#2c263c', + colorDustD: '#191524', + + /* --- what the ground does --- */ + scorchRadius: 0.65, // burn under the convergence point, metres + scorchLife: 5.0, // seconds it lingers + scorchIntensity: 0.38, + shockRadius: 5.5, // impact shockwave ring, metres + colorScorch: '#151024', + colorEmber: '#a86bff', + colorShockA: '#d9c0ff', // body of the shockwave ring + colorShockB: '#ffffff', // its crest + + /* --- the muzzle and the impact --- */ + muzzleSize: 0.6, // the flash at the hand as the volley leaves, metres + muzzleIntensity: 1.6, + castFlash: 0.08, // screen flash on release + colorMuzzleA: '#8a5fd0', + colorMuzzleB: '#d9c0ff', + colorMuzzleC: '#ffffff', + colorCastFlash: '#d9c0ff', + burstSize: 3.0, // the shell where all seven land, metres + burstIntensity: 1.6, + impactShake: 0.75, + shakeDuration: 0.5, + impactFlash: 0.26, + rumble: 0.015, // continuous shake while the volley is in the air + colorBurstA: '#8a5fd0', + colorBurstB: '#c9a4ff', + colorBurstC: '#ffffff', + colorFlash: '#e6d5ff', // the full-screen flash on impact + + /* --- dynamic light --- */ + lightIntensity: 20, + lightRadius: 14, + lightColor: '#b98cff', + lightPulse: 0.22, // depth of the light's breathing, 0 = steady + lightPulseSpeed: 3.1 // Hz +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Arcane Volley. + * + * `The weave` is the folder. Everything in it is a uniform on both the trail + * and the head shader, so dragging any of it re-flies seven bolts that are + * already in the air — including at `dt = 0`, which is the point. The one to + * try first is `weaveDecay`: it is the difference between a volley that snaps + * together at the last moment and one that has already given up by half-way. + */ +export const arcanevolleySchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 0.5, 'volley speed'], + ['charge', 0, 3, 0.01, 'wind-up'], + ['lifetime', 0.05, 6, 0.01, 'hold after landing'], + ['fadeTime', 0.05, 4, 0.01, 'blow-out time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where they gather': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['endHeight', 0, 4, 0.01, 'height at target'], + ['orbitTurns', 0, 6, 0.01, 'turns while held'], + ['orbitStart', 0, 1, 0.01, 'ring at spawn'], + ['chargeCurve', 0.1, 4, 0.01, 'ring opening curve'], + ['chargeShake', 0, 0.3, 0.005, 'wind-up shake'] + ], + 'The weave': [ + ['bolts', 1, 8, 1, 'bolts'], + ['weaveSide', 0, 8, 0.01, 'lateral throw (m)'], + ['weaveUp', 0, 8, 0.01, 'vertical throw (m)'], + ['weaveTurns', 0, 4, 0.01, 'frequency multiplier'], + ['weavePhase', 0, 6.29, 0.01, 'vertical lead (rad)'], + ['weaveDecay', 0.1, 6, 0.01, 'convergence curve'], + ['pathCurve', 0.2, 4, 0.01, 'approach easing'] + ], + 'The trails': [ + ['trailSpan', 0.02, 1.5, 0.01, 'reach back (s)'], + ['trailBurn', 0.02, 2, 0.01, 'catch-up (s)'], + ['trailWidth', 0.005, 0.8, 0.005, 'width at head'], + ['trailTaper', 0.1, 6, 0.01, 'tail taper'], + ['trailCoreSharp', 0.2, 10, 0.05, 'core sharpness'], + ['trailCoreWidth', 0.02, 1, 0.01, 'core width'], + ['trailHaloFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['trailHaloOpacity', 0, 2, 0.01, 'halo opacity'], + ['trailFlicker', 0, 1, 0.01, 'bolt blink'], + ['trailFlickerSpeed', 1, 90, 1, 'blink rate'], + ['trailGlow', 0, 8, 0.01, 'glow'], + ['trailOpacity', 0, 2, 0.01, 'opacity'], + ['trailSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorTrail*', 'Trail colour (tail → head)'] + ], + 'The heads': [ + ['headSize', 0.01, 1, 0.005, 'head radius (m)'], + ['headCoreSharp', 0.2, 10, 0.05, 'core sharpness'], + ['headSoft', 0.1, 6, 0.05, 'bloom falloff'], + ['headGlow', 0, 8, 0.01, 'glow'], + ['headOpacity', 0, 2, 0.01, 'opacity'], + ['headSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorHeadCore', 'head core'], + ['colorHeadRim', 'head bloom'] + ], + 'Motes, sparks & dust': [ + ['intakeRate', 0, 900, 1, 'intake rate'], + ['moteRate', 0, 600, 1, 'shed rate'], + ['moteShell', 0.1, 6, 0.05, 'intake shell (m)'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 12, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 8, 0.05, 'mote rise'], + ['moteSwirl', -12, 12, 0.05, 'intake swirl (rad/s)'], + ['moteSwirlExpand', -2, 2, 0.01, 'intake in/out'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['burstSparks', 0, 600, 1, 'impact sparks'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 8, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['burstDust', 0, 300, 1, 'impact dust'], + ['colorMote*', 'Mote colour'], + ['colorSpark*', 'Spark colour'], + ['colorDust*', 'Dust colour'] + ], + 'The ground': [ + ['scorchRadius', 0.05, 4, 0.05, 'scorch radius'], + ['scorchLife', 0.5, 20, 0.1, 'scorch lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + 'colorScorch', + 'colorEmber', + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'Muzzle & impact': [ + ['muzzleSize', 0.05, 6, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorMuzzleA', 'muzzle shell'], + ['colorMuzzleB', 'muzzle body'], + ['colorMuzzleC', 'muzzle arcs'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst arcs'], + ['colorFlash', 'impact flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'light breathing'], + ['lightPulseSpeed', 0, 12, 0.01, 'breathing Hz'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/astralgate.js b/src/config/abilities/astralgate.js new file mode 100644 index 0000000..a64e31a --- /dev/null +++ b/src/config/abilities/astralgate.js @@ -0,0 +1,473 @@ +/* ================================================================== */ +/* ASTRAL GATE — arcane, zone cast */ +/* ================================================================== */ +/** + * A ring gate tears open in the air over the zone and obelisks of astral stone + * climb out of it. + * + * **The one number that carries the ability is `gateHeight`.** Put the + * aperture on the floor and the whole thing is free: the floor hides + * everything underneath it and there is nothing to clip. Float it a metre and + * a half up and every body that is only half-way through has the other half + * hanging in mid-air under a hole in space — which is what + * `materials/AstralStoneMaterial.js` exists to prevent, and what `lipWidth` + * and `lipGlow` exist to turn from an absence into an effect. + * + * Two keys are computed rather than authored, from `zoneRadius` — the portal's + * two half-extents — because a gate whose size does not follow the aim circle + * is a gate standing in the wrong place. There is therefore no `gateRadiusX` + * slider; there is `gateSpan`, a fraction, and the ability resolves the metres + * every frame. Same for the growth field's footprint, which is the aperture's + * and not the zone's: nothing may come out of a hole it does not fit through. + * + * The `star*` and `nebula*` families are three parallax shells and one fbm + * cloud inside the aperture, in **metres behind the plane**. `parallax` is the + * single most important number in the portal and it must not be 1 — at 1 the + * interior is a geometrically honest window into a room, and a window is not a + * gate. See `vfx/Portal.js`. + */ +export const astralgate = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 46.0, // how fast the summoning front travels, metres/second + cooldown: 1.8, // seconds + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 4.6, // the footprint the aim circle measures out, metres + + openTime: 0.55, // seconds the aperture takes to tear open + pourTime: 0.95, // seconds the bodies take to finish coming through + holdTime: 1.5, // seconds everything stands + fadeTime: 1.6, // seconds the bodies sink back and the hole shuts + retractCurve: 1.5, // >1 holds them up, then drops them back through + shutCurve: 3.0, // >1 keeps the hole open until the last body is gone + + /* ------------------------------------------------------------------ */ + /* The gate — vfx/Portal.js */ + /* ------------------------------------------------------------------ */ + gateHeight: 1.55, // metres the aperture floats above the floor. See above. + gateSpan: 0.78, // aperture radius, × zoneRadius + gateMargin: 0.5, // 0..1 extra quad so the crown of cracks has somewhere to go + gateOpacity: 0.98, + tearSeam: 0.0, // 0 unzips from the centre, 1 from the long centreline + tearJag: 0.72, // 0..1 how uneven the tear front is + tearScale: 1.5, // cycles per metre of the crack grain + tearCrawl: 0.16, // Hz the grain creeps while the tear is held + edgeSoft: 0.04, // 0..1 of the field — how hard the aperture cuts + + rim: 0.04, // 0..1 of the field — the fracture band's width + rimGlow: 2.6, + core: 0.012, // 0..1 — the white-hot line inside the band + coreGlow: 5.5, + throat: 0.2, // 0..1 — the soft inner glow past the rim + throatGlow: 0.75, + crackCount: 16, // radial fractures around the rim + crackWidth: 0.08, // 0..1 of one angular cell + crackLength: 0.26, // 0..1 of the field they reach out to + crackGlow: 2.0, + + parallax: 1.65, // 1 is geometrically honest. Do not ship 1. + swirl: 0.06, // rad/s the whole interior turns at + interiorFade: 0.38, // 0..1 how much the void darkens toward the rim + + starSize: 0.042, + starTwinkle: 0.4, + starGain: 1.1, + starScaleA: 2.4, // stars per metre, near shell + starScaleB: 5.0, + starScaleC: 9.0, // ... far shell + starDepthA: 2.2, // metres behind the plane, near shell + starDepthB: 6.5, + starDepthC: 17.0, + starDriftA: 0.035, // rad/s, near shell + starDriftB: 0.019, + starDriftC: 0.009, + nebulaScale: 0.24, // cycles per metre + nebulaSpeed: 0.045, + nebulaGain: 0.6, + nebulaDepth: 12.0, // metres behind the plane + + colorVoid: '#000000', + colorGateRim: '#8f7dff', + colorGateCore: '#ffffff', + colorGateCrack: '#6f52d8', + colorGateThroat: '#2a1560', + colorStarA: '#ffffff', + colorStarB: '#c4d2ff', + colorStarC: '#8f7dff', + colorNebulaA: '#100a24', + colorNebulaB: '#4b2a9c', + + /* ------------------------------------------------------------------ */ + /* What comes through — vfx/GrowthField.js */ + /* ------------------------------------------------------------------ */ + bodyCount: 34, // obelisks planted per cast (capped at 96) + bodyCluster: 0.2, // 0..1 held back for the group under the middle + bodyInner: 0.06, // inner radius of the annulus, × the aperture radius + bodyRadialCurve: 0.85, // <1 pushes the ring toward the rim + bodyRadialJitter: 0.22, // metres of radial wander + bodyAngleJitter: 0.3, // radians of bearing wander + riseStagger: 0.5, // 0..1 of the pour beat spent staggering the arrivals + + bodyHeightNear: 1.5, // metres at the centre of the aperture + bodyHeight: 2.9, // ... and at its rim + bodyHeightCurve: 1.0, // how late that ramp climbs + bodyHeightJitter: 0.42, // ± fraction + bodyCrown: 0.3, // 0..1 how much shorter the flank bodies are + bodyCrownPower: 1.5, + bodyPeak: 1.0, // extra height multiplier at the rim + bodyPeakWidth: 0.25, + bodyRubble: 0.16, // 0..1 chance a body is demoted to a shard + bodyRubbleScale: 0.34, + bodyRubbleSpread: 1.3, + + bodyRadiusNear: 0.3, // metres, base radius at the centre + bodyRadius: 0.24, // ... and at the rim (< 0 would track the near value) + bodyRadiusCurve: 0.7, + bodyRadiusJitter: 0.4, + + bodyLean: 0.16, // radians away from the centre + bodyLeanJitter: 0.55, // ± fraction + bodyLeanRamp: 0.7, // 0 leans everything, 1 only the rim + bodyLeanOutward: 0.9, // weight of "out across the annulus" in the lean + bodyLeanForward: 0.2, // weight of "away from the caster" + bodyTwist: 1.0, // 0..1 of a full turn of random yaw + bodyTilt: 0.07, // radians of extra random tip + + riseTime: 0.42, // seconds one body takes to come all the way through + riseOvershoot: 0.18, // how far past full height the arrival carries + settle: 0.55, // seconds that overshoot damps out over + springRate: 12.0, // radians/second of the overshoot ring + emergeSink: 1.15, // fraction of its height a body is buried at emerge = 0. + // Above 1 on purpose: at 0.85 the tip of an untriggered + // body pokes through the plane and the field starts as a + // ring of stubs already standing in the hole. + birthScale: 0.9, // footprint scale on the frame it breaks the plane + birthFade: 0.3, // seconds the arrival flash decays over + breachAt: 0.12, // emergence fraction that fires the breach spark + sinkDepth: 0.6, // extra metres a retracting body drops beyond its height + + /* --- the body's silhouette (geometry; rebuilt when one of these moves) --- */ + bodySides: 5, // facets round one obelisk, 3–8 + bodyTaper: 0.16, // tip radius as a fraction of the base + bodyRough: 0.34, // how far the facets are pushed off a clean prism + bodyBend: 0.14, // sideways curve from base to tip + + /* --- astral stone (materials/AstralStoneMaterial.js) --- */ + colorStoneDeep: '#0d1024', // the base, in shadow + colorStoneFace: '#3a3f66', // the lit faces near the tip + colorStoneVein: '#b9a6ff', // the starlight in the cracks + colorLip: '#e6dcff', // the band where a body crosses the plane + colorBirth: '#ffffff', // the flash as it breaks through + stoneFaceCurve: 1.4, // >1 keeps the base dark and opens out late + stoneVeins: 0.85, + stoneVeinScale: 1.6, // cycles per metre + stoneVeinSharp: 0.62, + stoneSpecks: 0.7, + stoneSpeckScale: 11.0, // cells per metre + stoneGrain: 0.35, + stoneGrainScale: 5.5, // cycles per metre + stoneSeedOffset: 3.1, // how far a per-instance seed shifts the vein field + stoneFresnel: 0.9, + stoneFresnelPower: 3.0, + stoneGlow: 1.4, + stoneRoughness: 0.72, + stoneMetalness: 0.08, + stoneEnv: 0.9, + birthGlow: 2.2, // gain on the arrival flash + lipWidth: 0.22, // metres the emergence band falls off over + lipGlow: 2.6, // gain on that band + + /* ------------------------------------------------------------------ */ + /* The bent air at the rim — vfx/Distortion.js, LENS */ + /* ------------------------------------------------------------------ */ + lensSpan: 1.35, // the emitter quad's half-extent, × the aperture radius + lensStrength: 0.035, // screen widths at post.distortion = 1. Not metres. + lensCore: 0.28, // 0..1 of the radius the 1/r² is clamped inside + lensWindow: 0.95, // 0..1 of the radius the effect is windowed into + lensMaxOffset: 0.09, // hard clamp on the offset, screen widths + lensFold: 0.6, // how hard the guard against a folded lookup bites + lensSwirl: 0.35, // tangential component — the lens turning + lensDepthFade: 0.5, // metres of depth fade at the silhouette + lensOpacity: 1.0, + + /* ------------------------------------------------------------------ */ + /* Particles */ + /* ------------------------------------------------------------------ */ + moteRate: 150, // star-stuff drifting up out of the aperture, /second + moteOpen: 90, // extra thrown on the frame the tear completes + moteSize: 0.07, + moteSpeed: 1.6, + moteLifetime: 2.3, + moteRise: 0.9, // upward drift, metres/second + moteTurbulence: 0.8, + colorMoteA: '#ffffff', + colorMoteB: '#c4d2ff', + colorMoteC: '#8f7dff', + colorMoteD: '#241548', + + gritRate: 20, // chips shaken off the standing obelisks, /second + gritBreach: 14, // chips thrown by one body breaking the plane + gritSize: 0.055, + gritSpeed: 3.6, + gritLifetime: 1.4, + gritGravity: -18.0, + colorGritA: '#6a6f96', + colorGritB: '#45496b', + colorGritC: '#2a2d47', + colorGritD: '#171a2c', + + hazeRate: 26, // cold haze pouring off the underside of the gate, /second + hazeSize: 0.9, + hazeSpeed: 0.9, + hazeLifetime: 2.6, + hazeOpacity: 0.09, + hazeFall: -0.35, // drift, metres/second — negative, it spills downward + colorHazeA: '#3b3560', + colorHazeB: '#2c2848', + colorHazeC: '#211e36', + colorHazeD: '#131223', + + /* --- dynamic light --- */ + lightIntensity: 18.0, + lightRadius: 16.0, + lightColor: '#9d86ff', + lightPulse: 0.3, // depth of the light's swell, 0 = steady + lightPulseSpeed: 5.5, + lightHeight: 0.65, // 0..1 of the tallest body the light climbs to + + /* --- the tear and the arrival --- */ + burstSize: 2.4, // the shell of displaced air as the hole opens, metres + burstIntensity: 1.3, + colorBurstA: '#2a1560', + colorBurstB: '#8f7dff', + colorBurstC: '#ffffff', + ringSpan: 1.5, // the floor ring under the gate at the tear, × zoneRadius + colorRingA: '#8f7dff', + colorRingB: '#e6dcff', + openFlash: 0.16, // screen flash as the hole finishes tearing + colorFlash: '#c4b2ff', + openShake: 0.55, + shakeDuration: 0.7, + rumble: 0.03 // continuous shake while the front travels +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Astral Gate. + * + * Drag `gateHeight` first, with the field standing and the clock paused. At 0 + * the ability is a hole in the floor with things coming out of it and the clip + * is doing nothing; every centimetre above that is a centimetre of buried + * obelisk the clip is holding back, and the emergence lip climbs each shaft as + * you drag. That one slider is the whole demonstration. + */ +export const astralgateSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 200, 1, 'summon-front speed'], + ['zoneRadius', 1, 12, 0.1, 'zone radius'], + ['openTime', 0.05, 3, 0.01, 'tear open'], + ['pourTime', 0.05, 4, 0.01, 'pour through'], + ['holdTime', 0.05, 6, 0.01, 'hold'], + ['fadeTime', 0.05, 6, 0.01, 'sink back & shut'], + ['retractCurve', 0.3, 6, 0.05, 'withdrawal curve'], + ['shutCurve', 0.3, 8, 0.05, 'hole-shut curve'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The gate': [ + ['gateHeight', 0, 5, 0.01, 'aperture height (m)'], + ['gateSpan', 0.1, 1.5, 0.01, 'aperture radius × zone'], + ['gateMargin', 0, 1.5, 0.01, 'crack margin'], + ['gateOpacity', 0, 1, 0.01, 'opacity'], + ['tearSeam', 0, 1, 0.01, 'seam (0 disc, 1 slit)'], + ['tearJag', 0, 1, 0.01, 'tear raggedness'], + ['tearScale', 0.1, 8, 0.05, 'crack grain (cyc/m)'], + ['tearCrawl', 0, 2, 0.01, 'grain crawl (Hz)'], + ['edgeSoft', 0.002, 0.4, 0.002, 'aperture edge'] + ], + 'The gate/Fracture': [ + ['rim', 0.002, 0.3, 0.002, 'rim width'], + ['rimGlow', 0, 8, 0.05, 'rim glow'], + ['core', 0.001, 0.1, 0.001, 'core line'], + ['coreGlow', 0, 12, 0.05, 'core glow'], + ['throat', 0.01, 1, 0.005, 'throat'], + ['throatGlow', 0, 4, 0.02, 'throat glow'], + ['crackCount', 1, 40, 1, 'radial cracks'], + ['crackWidth', 0.005, 0.4, 0.005, 'crack width'], + ['crackLength', 0.01, 1, 0.01, 'crack reach'], + ['crackGlow', 0, 6, 0.05, 'crack glow'], + ['colorGateRim', 'rim'], + ['colorGateCore', 'core'], + ['colorGateCrack', 'cracks'], + ['colorGateThroat', 'throat'] + ], + 'The gate/Interior': [ + ['parallax', 0.2, 4, 0.01, 'parallax (never 1)'], + ['swirl', -1, 1, 0.005, 'swirl (rad/s)'], + ['interiorFade', 0, 1, 0.01, 'darken toward rim'], + ['starSize', 0.005, 0.3, 0.001, 'star size'], + ['starTwinkle', 0, 1, 0.01, 'twinkle'], + ['starGain', 0, 4, 0.01, 'star gain'], + ['starScaleA', 0.2, 20, 0.1, 'stars/m near'], + ['starScaleB', 0.2, 20, 0.1, 'stars/m mid'], + ['starScaleC', 0.2, 30, 0.1, 'stars/m far'], + ['starDepthA', 0.2, 20, 0.1, 'depth near (m)'], + ['starDepthB', 0.5, 40, 0.1, 'depth mid (m)'], + ['starDepthC', 1, 80, 0.5, 'depth far (m)'], + ['starDriftA', -0.3, 0.3, 0.001, 'drift near'], + ['starDriftB', -0.3, 0.3, 0.001, 'drift mid'], + ['starDriftC', -0.3, 0.3, 0.001, 'drift far'], + ['nebulaScale', 0.02, 2, 0.01, 'nebula scale'], + ['nebulaSpeed', 0, 1, 0.005, 'nebula speed'], + ['nebulaGain', 0, 3, 0.01, 'nebula gain'], + ['nebulaDepth', 1, 60, 0.5, 'nebula depth (m)'], + ['colorVoid', 'void'], + ['colorStarA', 'stars near'], + ['colorStarB', 'stars mid'], + ['colorStarC', 'stars far'], + ['colorNebulaA', 'nebula low'], + ['colorNebulaB', 'nebula high'] + ], + 'What comes through': [ + ['bodyCount', 1, 96, 1, 'obelisks'], + ['bodyCluster', 0, 1, 0.01, 'centre cluster'], + ['bodyInner', 0, 1, 0.01, 'inner radius × aperture'], + ['bodyRadialCurve', 0.2, 3, 0.01, 'radial curve'], + ['bodyRadialJitter', 0, 3, 0.01, 'radial wander (m)'], + ['bodyAngleJitter', 0, 1.5, 0.01, 'bearing wander (rad)'], + ['riseStagger', 0, 1, 0.01, 'arrival stagger'], + ['riseTime', 0.02, 2, 0.01, 'one body rises in (s)'], + ['riseOvershoot', 0, 1, 0.01, 'overshoot'], + ['settle', 0.02, 2, 0.01, 'settle (s)'], + ['springRate', 1, 40, 0.5, 'spring rate'], + ['emergeSink', 0.2, 2, 0.01, 'buried depth × height'], + ['birthScale', 0.1, 1.5, 0.01, 'birth footprint'], + ['birthFade', 0.02, 2, 0.01, 'arrival flash (s)'], + ['breachAt', 0, 1, 0.01, 'breach fires at'], + ['sinkDepth', 0, 4, 0.05, 'extra sink (m)'] + ], + 'What comes through/Silhouette': [ + ['bodyHeightNear', 0.05, 8, 0.05, 'height at centre'], + ['bodyHeight', 0.05, 12, 0.05, 'height at rim'], + ['bodyHeightCurve', 0.2, 4, 0.01, 'height curve'], + ['bodyHeightJitter', 0, 1, 0.01, 'height jitter'], + ['bodyCrown', 0, 1, 0.01, 'crown'], + ['bodyCrownPower', 0.2, 4, 0.01, 'crown falloff'], + ['bodyPeak', 0.2, 3, 0.01, 'rim peak'], + ['bodyPeakWidth', 0.02, 1, 0.01, 'peak width'], + ['bodyRubble', 0, 1, 0.01, 'shard share'], + ['bodyRubbleScale', 0.05, 1, 0.01, 'shard height'], + ['bodyRubbleSpread', 0.5, 3, 0.01, 'shard footprint'], + ['bodyRadiusNear', 0.02, 2, 0.01, 'base radius, centre'], + ['bodyRadius', 0.02, 2, 0.01, 'base radius, rim'], + ['bodyRadiusCurve', 0.1, 3, 0.01, 'radius curve'], + ['bodyRadiusJitter', 0, 1, 0.01, 'radius jitter'], + ['bodyLean', -1, 1, 0.01, 'lean (rad)'], + ['bodyLeanJitter', 0, 1, 0.01, 'lean jitter'], + ['bodyLeanRamp', 0, 1, 0.01, 'lean ramp'], + ['bodyLeanOutward', 0, 1, 0.01, 'lean outward'], + ['bodyLeanForward', 0, 1, 0.01, 'lean downrange'], + ['bodyTwist', 0, 1, 0.01, 'random yaw'], + ['bodyTilt', 0, 0.6, 0.005, 'random tip (rad)'], + ['bodySides', 3, 8, 1, 'facets'], + ['bodyTaper', 0.02, 0.9, 0.01, 'tip taper'], + ['bodyRough', 0, 1, 0.01, 'facet roughness'], + ['bodyBend', 0, 1, 0.01, 'shaft bend'] + ], + 'Astral stone': [ + ['stoneFaceCurve', 0.2, 4, 0.01, 'base→tip curve'], + ['stoneVeins', 0, 3, 0.01, 'vein strength'], + ['stoneVeinScale', 0.1, 8, 0.05, 'vein scale (cyc/m)'], + ['stoneVeinSharp', 0, 1, 0.01, 'vein threshold'], + ['stoneSpecks', 0, 3, 0.01, 'speck strength'], + ['stoneSpeckScale', 1, 40, 0.5, 'specks (cells/m)'], + ['stoneGrain', 0, 1, 0.01, 'grain'], + ['stoneGrainScale', 0.5, 20, 0.1, 'grain scale'], + ['stoneSeedOffset', 0, 20, 0.1, 'per-body seed shift'], + ['stoneFresnel', 0, 3, 0.01, 'fresnel'], + ['stoneFresnelPower', 0.2, 8, 0.05, 'fresnel power'], + ['stoneGlow', 0, 6, 0.01, 'vein glow'], + ['stoneRoughness', 0, 1, 0.01, 'roughness'], + ['stoneMetalness', 0, 1, 0.01, 'metalness'], + ['stoneEnv', 0, 3, 0.01, 'env intensity'], + ['birthGlow', 0, 8, 0.05, 'arrival flash glow'], + ['colorStoneDeep', 'stone, base'], + ['colorStoneFace', 'stone, tip'], + ['colorStoneVein', 'veins'], + ['colorBirth', 'arrival flash'] + ], + // `gateHeight` belongs here as much as it belongs to the gate, and it is + // filed under "The gate" rather than in both places: two lil-gui controllers + // bound to one property do not keep each other's readouts in step, and a + // slider showing a stale number is worse than a slider in the wrong folder. + 'The clip (the ability)': [ + ['lipWidth', 0.01, 1.5, 0.005, 'emergence band (m)'], + ['lipGlow', 0, 8, 0.05, 'emergence glow'], + ['colorLip', 'emergence band'] + ], + 'The bent air': [ + ['lensSpan', 0.2, 4, 0.01, 'radius × aperture'], + ['lensStrength', 0, 0.25, 0.001, 'strength (screen)'], + ['lensCore', 0.01, 1, 0.01, 'core clamp'], + ['lensWindow', 0.1, 1, 0.01, 'window'], + ['lensMaxOffset', 0.01, 0.4, 0.005, 'max offset'], + ['lensFold', 0, 1, 0.01, 'fold guard'], + ['lensSwirl', -2, 2, 0.01, 'swirl'], + ['lensDepthFade', 0, 3, 0.01, 'depth fade (m)'], + ['lensOpacity', 0, 1, 0.01, 'opacity'] + ], + 'Motes & grit': [ + ['moteRate', 0, 700, 1, 'mote rate'], + ['moteOpen', 0, 500, 1, 'motes on opening'], + ['moteSize', 0.005, 0.5, 0.005, 'mote size'], + ['moteSpeed', 0, 12, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 6, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['gritRate', 0, 300, 1, 'grit rate'], + ['gritBreach', 0, 80, 1, 'grit per breach'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 25, 0.1, 'grit speed'], + ['gritLifetime', 0.1, 5, 0.05, 'grit lifetime'], + ['gritGravity', -50, 0, 0.1, 'grit gravity'], + ['colorMote*', 'Mote colour'], + ['colorGrit*', 'Grit colour'] + ], + Haze: [ + ['hazeRate', 0, 300, 1, 'haze rate'], + ['hazeSize', 0.05, 4, 0.01, 'haze size'], + ['hazeSpeed', 0, 8, 0.05, 'haze speed'], + ['hazeLifetime', 0.2, 8, 0.05, 'haze lifetime'], + ['hazeOpacity', 0, 1, 0.005, 'haze opacity'], + ['hazeFall', -3, 2, 0.01, 'haze drift'], + ['colorHaze*', 'Haze colour'] + ], + 'The tear': [ + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['ringSpan', 0.2, 5, 0.01, 'floor ring × zone'], + ['openFlash', 0, 2, 0.01, 'screen flash'], + ['openShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst core'], + ['colorRingA', 'floor ring'], + ['colorRingB', 'floor ring crest'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'light swell'], + ['lightPulseSpeed', 0.5, 40, 0.1, 'swell rate'], + ['lightHeight', 0, 1, 0.01, 'light climb'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/aurora.js b/src/config/abilities/aurora.js new file mode 100644 index 0000000..113ca8e --- /dev/null +++ b/src/config/abilities/aurora.js @@ -0,0 +1,299 @@ +/* ================================================================== */ +/* AURORA — Aurora Veil, the aether school's quiet far cast */ +/* ================================================================== */ +/** + * A ring of vertical sheets standing in the circle, rippling, banded in three + * hues, and washing the floor underneath with their own colour. + * + * **The trick is the mismatch between two curves.** A hanging ribbon and a + * curtain of aurora are the same geometry; the only thing that separates them + * is that on cloth, coverage and brightness fall off *together* — where it + * thins it both stops hiding things and stops being bright. Light in air does + * not do that. A column of excited gas keeps radiating long after it has + * stopped occluding anything, so the top of a real aurora is bright and + * transparent at the same time. + * + * So `Curtain` runs two independent curves up the sheet: + * + * ``` + * alpha = mix(alphaTop, alphaBase, pow(1 - h, alphaCurve)) + * emission = mix(emissionTop, emissionBase, pow(1 - h, emissionCurve)) + * ``` + * + * and **both exponents are sliders below, and they are meant to disagree.** + * The library's own defaults are `alphaCurve` 2.4 against `emissionCurve` 0.7; + * this block pushes them further apart still, to 2.6 against 0.55, because the + * sandbox's bloom chain is generous and the head of the sheet can afford to be + * almost pure radiance. Set the two equal, look at it once, and the hanging + * ribbon you were trying not to make is exactly what is standing there. + * + * The second reason it reads as air is `veilGraze`: a sheet has no thickness, + * so a ray crossing it face-on passes through nothing while a ray crossing it + * edge-on travels the length of a fold. Both curves are scaled by `1/|N·V|`, + * floored at `veilGrazeFloor`. Turn `veilGraze` to zero and the curtain becomes + * a decal that looks identical from every angle. + * + * **There is no violence in this slot.** No impact, no screen flash, no camera + * shake, no shockwave, no burst, and the floor companion's impact-ring term is + * held at zero in the ability rather than exposed here. It is the one ability + * in the sandbox that is a relief to cast, and every knob below was chosen to + * keep it that way — `speed` is deliberately slow enough that you watch it + * cross, and `lifetime` is deliberately long enough that you stop waiting for + * something to happen. + * + * Three beats: **rise** (travel — the sheets come up out of the floor on a + * staggered wave), **ripple** (impact, and it is a very long hold), **fade** + * (the feet leave the ground and the whole veil climbs away over `fadeLift`). + * + * A cast captures nothing but a seed and the phase clock. Every metre below is + * re-read inside the update loop, on a zero-length frame included — pause and + * drag `veilSpread` and the ring re-lays itself around you. + */ +export const aurora = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + zoneRadius: 6.0, // the footprint — what the circle indicator measures out + speed: 18.0, // how fast the veil's front crosses to the point, metres/second + lifetime: 7.0, // seconds the veil stands and ripples. This is the ability + fadeTime: 3.4, // seconds it takes to climb away + cooldown: 2.0, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the layout --- */ + veilLayout: 1, // 0 line · 1 ring · 2 scatter — a live uniform, no recompile + veilCount: 9, // sheets drawn (hard ceiling 12) + veilSpacing: 2.2, // metres between sheets, LINE layout only + veilSpread: 1.0, // ring / scatter radius as a multiple of `zoneRadius` + veilScatter: 0.7, // metres of hashed slop off each sheet's nominal place + veilRiseSpread: 0.6, // 0..1 stagger of the rise wave across the sheets + + /* --- the body of a sheet --- */ + veilWidth: 5.2, // metres along the sheet + veilWidthJitter: 0.3, // ±fraction + veilHeight: 7.5, // metres tall + veilHeightJitter: 0.28, // ±fraction + veilBase: 0.15, // metres the foot sits above the floor + veilTaper: 1.15, // width multiplier at the head; >1 flares + veilLean: 1.3, // metres the head is pushed along the sheet's own normal + veilLeanJitter: 0.6, // ±fraction + fadeLift: 4.5, // metres the feet climb over the fade — how it lets go + + /* --- the travelling ripple --- */ + rippleAmp: 0.9, // metres of the fast fold + rippleLength: 4.2, // metres along the sheet, crest to crest + rippleSpeed: 0.85, // metres/second the crest travels + rippleCurve: 1.15, // exponent on height: 0 rigid, >0 pins the foot + foldAmp: 1.6, // metres of the second, much longer fold + foldLength: 12.0, // metres + foldSpeed: 0.3, // metres/second + rippleNoise: 0.3, // metres of fbm slop on top of both + rippleNoiseScale: 0.3, // cycles per metre + rippleNoiseSpeed: 0.11, // Hz + phaseSpread: 1.2, // turns of per-sheet phase offset + + /* --- the two curves. The whole ability is in these six numbers. --- */ + alphaBase: 0.95, // coverage at the foot + alphaTop: 0.0, // coverage at the head — nothing, it is only light up there + alphaCurve: 2.6, // how fast coverage dies with height + emissionBase: 0.8, // radiance at the foot + emissionTop: 0.55, // radiance at the head. Deliberately NOT alphaTop + emissionCurve: 0.55, // ... and deliberately NOT alphaCurve. See the header + + /* --- the envelope --- */ + veilBody: 0.22, // 0..1 how much alpha the sheet is allowed at all + veilFootFade: 0.05, // 0..1 of the height + veilHeadFade: 0.32, // 0..1 of the height + veilEdgeFade: 0.36, // 0..1 across the sheet + veilGraze: 1.0, // 0..1 how much of the 1/|N·V| path term is applied + veilGrazeFloor: 0.1, // clamp on |N·V| — 0.1 gives a 10× ceiling + veilSoftFade: 0.7, // metres of depth fade against opaque geometry + veilOpacity: 1.0, + veilGlow: 1.45, // emissive gain into the bloom chain + veilTintSpread: 0.55, // 0..1 how far apart neighbouring sheets are tinted + + /* --- the aurora itself --- */ + rayScale: 0.75, // cycles per metre of the vertical striations + raySpeed: 0.05, // Hz — the striations drift along the sheet + raySharp: 0.5, // 0..1 how hard a ray's edge is + bandScale: 0.1, // cycles per metre of the three-way hue band + bandSpeed: 0.03, // Hz + hem: 0.14, // 0..1 of the height — the coloured band along the bottom edge + + colorA: '#5fffc0', // the green body + colorB: '#3a9aff', // the blue band + colorC: '#c05fff', // the violet band + colorHem: '#ff7ab0', // the hem. Real aurora has one; leaving it out is why + colorBody: '#0e1a26', // what little substance the sheet has + + /* --- the wash on the floor --- */ + // `Curtain`'s floor companion: one extra quad walking the same sheet frame, + // so the light under a sheet cannot drift out from under it. Impact rings are + // part of that companion and are held at zero by the ability — this slot has + // no impacts to ring. + washSpread: 2.6, // quad extent as a multiple of `zoneRadius` + washFade: 0.4, // 0..1 of the half-extent where it dies + washWet: 0.3, // 0..1 how reflective the stone reads + washDark: 0.18, // 0..1 how much that darkens it. Low: this is light, not rain + washSheen: 1.1, // env reflection gain + washSheenRough: 0.8, // cycles per metre of the surface ripple + washSheenSpeed: 0.25, // Hz + washFresnel: 1.6, + washPool: 1.0, // 0..1 master on the per-sheet footprints + washPoolWidth: 2.6, // metres either side of a sheet + washPoolLength: 1.15, // 0..1 of the sheet's own half-width + washPoolSoft: 2.2, // metres of feather + washOpacity: 0.7, + colorWash: '#101c2a', // the damp stone + colorPool: '#2f6a5c', // the coloured light lying under a sheet + colorRing: '#8fe8d8', // the ring term's colour; unused, see above + + /* --- drift: the motes that hang in the air under the veil --- */ + driftRate: 26, // particles/second + driftSize: 0.05, + driftSpeed: 0.35, // metres/second + driftLifetime: 4.5, // seconds + driftRise: 0.22, // metres/second of buoyancy + driftTurbulence: 0.4, + driftSpread: 1.05, // 0..1 of the footprint radius they are born across + driftCeiling: 0.55, // × `veilHeight`, how high they are seeded + colorDriftA: '#e8fff4', + colorDriftB: '#5fffc0', + colorDriftC: '#3a9aff', + colorDriftD: '#101c2a', + + /* --- dynamic light --- */ + lightIntensity: 7.0, // low. The veil is its own light; this is the spill + lightRadius: 16.0, + lightColor: '#5fe8c0', + lightHeight: 3.4, // metres above the ring the light hangs at + lightSway: 0.18, // depth of the slow swell + lightSwaySpeed: 0.22 // swells per second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Aurora Veil. + * + * Start in **The two curves**. `alphaCurve` and `emissionCurve` are the ability; + * drag them together and watch it turn into a hanging rag, then drag them apart + * again. After that, `veilLean` and `rippleAmp` decide whether the sheets fold + * (they must — the graze term only shows on a fold), and `veilTintSpread` + * decides whether the ring is one colour or three. + */ +export const auroraSchema = { + 'The cast': [ + ['range', 4, 45, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['zoneRadius', 1, 14, 0.05, 'footprint radius'], + ['speed', 3, 80, 0.5, 'rise-front speed'], + ['lifetime', 0.5, 20, 0.05, 'hold'], + ['fadeTime', 0.2, 10, 0.05, 'fade time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The two curves': [ + ['alphaBase', 0, 2, 0.01, 'coverage at the foot'], + ['alphaTop', 0, 2, 0.01, 'coverage at the head'], + ['alphaCurve', 0.1, 6, 0.01, 'alpha curve'], + ['emissionBase', 0, 3, 0.01, 'radiance at the foot'], + ['emissionTop', 0, 3, 0.01, 'radiance at the head'], + ['emissionCurve', 0.1, 6, 0.01, 'emission curve'] + ], + 'The layout': [ + ['veilLayout', 0, 2, 1, 'line / ring / scatter'], + ['veilCount', 1, 12, 1, 'sheets'], + ['veilSpacing', 0.1, 8, 0.05, 'sheet spacing (m)'], + ['veilSpread', 0.1, 3, 0.01, 'ring radius × zone'], + ['veilScatter', 0, 4, 0.01, 'placement slop (m)'], + ['veilRiseSpread', 0, 0.95, 0.01, 'rise stagger'] + ], + 'The sheet': [ + ['veilWidth', 0.2, 16, 0.05, 'width (m)'], + ['veilWidthJitter', 0, 1, 0.01, 'width jitter'], + ['veilHeight', 0.5, 20, 0.05, 'height (m)'], + ['veilHeightJitter', 0, 1, 0.01, 'height jitter'], + ['veilBase', -1, 4, 0.01, 'foot height (m)'], + ['veilTaper', 0.05, 3, 0.01, 'head width ×'], + ['veilLean', -4, 4, 0.01, 'lean (m)'], + ['veilLeanJitter', 0, 1.5, 0.01, 'lean jitter'], + ['fadeLift', 0, 14, 0.05, 'climb on fade (m)'] + ], + 'The ripple': [ + ['rippleAmp', 0, 4, 0.01, 'ripple amplitude (m)'], + ['rippleLength', 0.2, 20, 0.05, 'ripple length (m)'], + ['rippleSpeed', -6, 6, 0.01, 'ripple speed (m/s)'], + ['rippleCurve', 0, 4, 0.01, 'ripple height curve'], + ['foldAmp', 0, 6, 0.01, 'fold amplitude (m)'], + ['foldLength', 0.5, 40, 0.1, 'fold length (m)'], + ['foldSpeed', -4, 4, 0.01, 'fold speed (m/s)'], + ['rippleNoise', 0, 2, 0.01, 'noise slop (m)'], + ['rippleNoiseScale', 0.02, 3, 0.01, 'noise cycles / m'], + ['rippleNoiseSpeed', 0, 2, 0.01, 'noise speed (Hz)'], + ['phaseSpread', 0, 4, 0.01, 'per-sheet phase'] + ], + 'The envelope': [ + ['veilBody', 0, 1, 0.005, 'substance'], + ['veilFootFade', 0, 0.5, 0.005, 'foot fade'], + ['veilHeadFade', 0, 0.9, 0.005, 'head fade'], + ['veilEdgeFade', 0, 0.9, 0.005, 'edge fade'], + ['veilGraze', 0, 1, 0.01, 'grazing path term'], + ['veilGrazeFloor', 0.02, 1, 0.005, 'graze clamp'], + ['veilSoftFade', 0.02, 4, 0.01, 'soft intersection (m)'], + ['veilOpacity', 0, 2, 0.01, 'opacity'], + ['veilGlow', 0, 6, 0.01, 'glow'], + ['veilTintSpread', 0, 1, 0.01, 'tint spread'] + ], + 'The aurora': [ + ['rayScale', 0.02, 4, 0.01, 'ray cycles / m'], + ['raySpeed', -1, 1, 0.005, 'ray drift (Hz)'], + ['raySharp', 0, 1, 0.01, 'ray sharpness'], + ['bandScale', 0.005, 1, 0.005, 'band cycles / m'], + ['bandSpeed', -0.5, 0.5, 0.005, 'band drift (Hz)'], + ['hem', 0, 0.6, 0.005, 'hem height'], + ['colorA', 'green body'], + ['colorB', 'blue band'], + ['colorC', 'violet band'], + ['colorHem', 'hem'], + ['colorBody', 'substance'] + ], + 'The floor wash': [ + ['washSpread', 0.5, 6, 0.05, 'quad extent × zone'], + ['washFade', 0.02, 1, 0.01, 'edge fade'], + ['washWet', 0, 1, 0.01, 'wetness'], + ['washDark', 0, 1, 0.01, 'darkening'], + ['washSheen', 0, 3, 0.01, 'reflection gain'], + ['washSheenRough', 0.05, 4, 0.01, 'ripple cycles / m'], + ['washSheenSpeed', 0, 3, 0.01, 'ripple speed (Hz)'], + ['washFresnel', 0.2, 6, 0.01, 'fresnel'], + ['washPool', 0, 2, 0.01, 'footprint light'], + ['washPoolWidth', 0.05, 8, 0.05, 'footprint width (m)'], + ['washPoolLength', 0, 3, 0.01, 'footprint length ×'], + ['washPoolSoft', 0.05, 6, 0.05, 'footprint feather (m)'], + ['washOpacity', 0, 2, 0.01, 'wash opacity'], + ['colorWash', 'damp stone'], + ['colorPool', 'light on the floor'], + ['colorRing', 'ring (unused)'] + ], + Drift: [ + ['driftRate', 0, 300, 1, 'motes / second'], + ['driftSize', 0.005, 0.4, 0.005, 'mote size'], + ['driftSpeed', 0, 4, 0.01, 'mote speed (m/s)'], + ['driftLifetime', 0.2, 12, 0.05, 'mote lifetime (s)'], + ['driftRise', -2, 3, 0.01, 'mote rise'], + ['driftTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['driftSpread', 0, 2, 0.01, 'seeded across × radius'], + ['driftCeiling', 0, 2, 0.01, 'seeded up to × height'], + ['colorDrift*', 'Mote colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 40, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 12, 0.05, 'light height (m)'], + ['lightSway', 0, 1, 0.01, 'sway depth'], + ['lightSwaySpeed', 0, 3, 0.01, 'sways / second'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/avalanche.js b/src/config/abilities/avalanche.js new file mode 100644 index 0000000..707a28d --- /dev/null +++ b/src/config/abilities/avalanche.js @@ -0,0 +1,395 @@ +/* ================================================================== */ +/* AVALANCHE — granular flow */ +/* ================================================================== */ +/** + * Snow that piles, slumps and finds its angle of repose. + * + * The rest of the frost school throws solids — the Lance's crystals, the + * Crown's blades, Rimewalker's sheets. This slot throws *material*, and the + * only reason it reads as material rather than as a moving wave is that the + * surface obeys a repose angle. Four numbers are the ability and the rest is + * dressing: + * + * - **`repose`** — radians. The slope the heap refuses to exceed. Dry snow + * sits between 30° and 38° (0.52–0.66 rad); wet snow holds steeper and sand + * a little shallower. Pull it down on a *paused* cast and the whole + * avalanche flattens and spreads outward, because every lobe holds its + * volume while its slope relaxes. That is the one control worth reaching + * for first, and it is the reason this slot exists. + * - **`excess`** — radians of over-steepening at release. A lobe is born + * steeper than repose and is therefore *unstable*, which is what gives the + * front its slip faces. At 0 the heap is a tidy pile and nothing collapses. + * - **`slump`** — 1/s, the rate that excess decays at. It is the collapse + * speed. Low and the front is a slow, shouldering wall; high and each lobe + * splats the instant it lands. + * - **`creep`** — m/s the centroid of a collapsing lobe crawls downhill, which + * is the "keeps falling forward over itself" in one term. At 0 the lobes + * stack in place and the avalanche is a growing wall rather than a flow. + * + * `runout` is the second idea. An avalanche does not stop because it reached a + * line on the floor; it *decelerates* and the surface freezes where it lies. So + * the heap has its own clock, and after the cast front lands that clock is + * throttled to zero over `runout` seconds on the `runoutCurve` exponent — which + * slows the release of new lobes and the collapse of the standing ones by the + * same factor, because both are functions of the same clock. The deposit stops + * moving; it does not fade out mid-slump. + * + * Everything with a unit below is resolved against this block inside the update + * loop on every frame, zero-length frames included. A cast captures one seed + * and two timestamps. + */ +export const avalanche = { + /* --- the cast --- */ + range: 19.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 11.0, // how fast the front runs the line, metres/second + cooldown: 1.4, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + lifetime: 1.5, // seconds the deposit stands once the front has landed + settleTime: 1.9, // seconds the deposit takes to sink and thin away + + /* --- the runout: how the flow stops --- */ + runout: 1.15, // seconds the heap's clock takes to throttle to a standstill + runoutCurve: 2.0, // >1 lets it coast and then stop; 1 is a linear brake + heapLead: 1.05, // heap front speed as a multiple of the cast speed, unitless + + /* --- the heap: the lobe train (see the header) --- */ + heapWidth: 3.6, // metres either side of the line the sheet covers + heapFloor: 0.006, // metres the heap sits above the stone, to beat z-fighting + heapHeight: 1.05, // multiplier on the whole envelope + lobes: 16, // lobes alive at once — this is the fragment cost + lobeRate: 6.0, // lobes released per second + lobeVolume: 1.7, // cubic metres of snow per lobe + lobeJitter: 0.5, // ± fraction on that volume + repose: 0.58, // radians — 33°, the angle the surface refuses to exceed + excess: 0.24, // radians of over-steepening at release — the slip faces + slump: 2.6, // 1/s the excess decays at — the collapse rate + creep: 1.9, // m/s a collapsing lobe's centroid crawls forward + lobeScatter: 1.05, // metres of lateral scatter on release + widen: 1.55, // >1 stretches each lobe across the line rather than along it + bed: 0.26, // metres of settled deposit left in the tail + bedRamp: 2.8, // metres behind the front it takes to reach that depth + bedWidth: 3.0, // metres, half-width of that deposit + bedCurve: 0.62, // how sharply the deposit thins toward its edges + settleSink: 0.5, // metres the whole deposit sinks over the fade + settleFlatten: 0.6, // 0..1 of the height it loses over the fade + + /* --- the snow, as a substance --- */ + snowAmbient: 0.52, // floor under the diffuse term + snowWrap: 0.55, // how far light bends round a translucent grain + snowFresh: 0.8, // seconds a lobe counts as freshly turned over for + snowGrain: 0.13, // speckle depth + snowGrainScale: 74.0, // grains per metre + snowGlint: 0.55, // pinpoint sparkle — snow, not sand + snowGlintScale: 130.0, // glints per metre, on its own finer lattice + heapToe: 0.07, // metres of height the heap fades out over at its edge + heapOpacity: 1.0, + colorFresh: '#f4f8fd', // material that has just been turned over at the front + colorSettled: '#c9d5e3', // the tail, hours old in avalanche seconds + colorFace: '#ffffff', // the slip face — the part still above repose + colorDeep: '#54687e', // shadow inside the body of the heap + + /* --- the slabs: hard blocks the flow shoulders up --- */ + slabCount: 54, // instances in the field, capped at the field's capacity + slabCluster: 0.22, // 0..1 of them held back for the pile-up at the far end + slabClusterRadius: 2.4, // metres, radius of that pile-up + slabWidthNear: 0.9, // half-width of the slab band at the caster, metres + slabWidth: 2.9, // ... and at the far end, metres + slabWidthCurve: 0.9, // <1 flares the band early + slabBias: 1.15, // <1 crowds the slabs toward the far end, unitless + slabClump: 1.1, // >1 pulls them onto the centre line, unitless + slabScatter: 0.75, // extra lateral jitter, fraction of the local half-width + slabStagger: 0.22, // seconds of random delay between neighbouring slabs + slabHeightNear: 0.72, // metres tall at the caster + slabHeight: 1.45, // ... and at the far end + slabHeightCurve: 0.95, // how late that ramp climbs + slabHeightJitter: 0.42, // ± fraction + slabCrown: 0.35, // 0..1 — how much shorter the slabs on the flanks are + slabCrownPower: 1.5, // how sharply that dome falls off + slabRubble: 0.3, // 0..1 chance a slab is demoted to wreckage + slabRubbleScale: 0.34, // height multiplier for those + slabRadiusNear: 0.5, // metres, plan radius at the caster + slabRadius: 0.86, // metres, plan radius at the far end + slabRadiusCurve: 0.7, // how the plan size ramps along the cast + slabRadiusJitter: 0.45, // ± fraction + slabLean: 0.14, // radians of lean away from the caster, before the tie below + slabRepose: 0.55, // 0..1 — how far the slabs lie back onto the repose slope + slabLeanRamp: 0.55, // 0 leans everything equally, 1 only the far end + slabTwist: 1.0, // 0..1 of a full turn of random yaw + slabTilt: 0.16, // radians of extra random tip, any bearing + slabRise: 0.22, // seconds from buried to full height + slabOvershoot: 0.28, // how far past full height the shoulder-up carries + slabSettle: 0.45, // seconds that overshoot damps out over + slabSpring: 15.0, // radians/second of the overshoot ring + slabSink: 0.92, // fraction of its height a slab is buried at emergence 0 + slabBirthScale: 0.7, // footprint scale at the moment it breaks the surface + slabBirthFade: 0.3, // seconds the birth flash decays over + slabBreach: 0.28, // emergence fraction that throws the chunk burst + slabRetractSink: 0.6, // extra metres a slab drops as the deposit melts out + + /* --- the shape of one slab (these rebuild the geometry when they move) --- */ + facets: 6, // sides of the plan polygon; 5–8 read best + shear: 0.55, // 0..1 — how far the top face is tipped off horizontal + taper: 0.78, // plan radius at the top as a fraction of the base + rough: 0.3, // 0..1 outline irregularity — a slab is a fracture, not a prism + notch: 0.22, // 0..1 how deeply the top face is chipped at its high corner + + /* --- the slab material --- */ + slabRoughness: 0.86, // MeshStandardMaterial roughness — snow is not glossy + strata: 0.4, // depth of the wind-packed layering up the slab + strataScale: 9.0, // layers per metre + grain: 0.22, // hashed speckle over the crust + grainScale: 34.0, // speckles per metre + fracture: 1.1, // how bright the raw broken face is + fractureSharp: 1.9, // how tightly that face is confined to the up-slope side + slabRim: 0.7, // rim light on the silhouette + slabRimPower: 2.6, // how tight that rim is + slabGlow: 0.85, // overall emissive gain on a slab + slabBirthGlow: 1.6, // flash as a slab breaks the surface + colorCrust: '#dbe6f0', // the wind-packed outside of a slab + colorFracture: '#ffffff', // the raw face where the slab broke + colorShade: '#5b7086', // shadow inside the block + colorSlabGlow: '#bcd8ec', // everything emissive on a slab + + /* --- the powder cloud --- */ + powderRate: 70.0, // particles/second off the front + powderSpeed: 3.6, // metres/second + powderRise: 1.5, // metres/second² of buoyancy + powderSize: 1.5, + powderLifetime: 2.0, // seconds + powderOpacity: 0.72, + impactPowder: 90, // particles thrown at the pile-up + colorPowderA: '#ffffff', + colorPowderB: '#dfe9f4', + colorPowderC: '#b3c3d4', + colorPowderD: '#8496a8', + + /* --- spindrift: fine crystals off the crest --- */ + driftRate: 90.0, // particles/second + driftSpeed: 2.6, // metres/second + driftRise: 1.1, // metres/second² of buoyancy + driftSize: 0.09, + driftLifetime: 1.3, // seconds + driftTurbulence: 0.9, + colorDriftA: '#ffffff', + colorDriftB: '#e2f2ff', + colorDriftC: '#a8ccea', + colorDriftD: '#5d7f9e', + + /* --- chunks knocked loose as a slab shoulders up --- */ + breachChunks: 12, // particles per slab breaking the surface + impactChunks: 60, // ... and at the pile-up + chunkSpeed: 4.2, // metres/second + chunkGravity: -12.0, // metres/second² + chunkSize: 0.15, + chunkLifetime: 1.4, // seconds + colorChunkA: '#ffffff', + colorChunkB: '#d6e2ee', + colorChunkC: '#93a7ba', + colorChunkD: '#5a6c7d', + + /* --- the pile-up at the far end --- */ + burstSize: 2.6, // metres, the shell of thrown powder + burstIntensity: 0.8, + colorBurstA: '#ffffff', + colorBurstB: '#cfe0ef', + colorBurstC: '#7d93a8', + shockRadius: 4.4, // metres, the ring across the floor + colorShockA: '#eef6ff', + colorShockB: '#7fa0bc', + impactShake: 0.5, + shakeDuration: 0.75, // seconds + impactFlash: 0.12, // an avalanche is not bright; this is nearly off + colorFlash: '#dbe8f4', + rumble: 0.55, // continuous shake while the front is running + + /* --- the light --- */ + lightColor: '#cfe2f2', + lightIntensity: 5.5, + lightRadius: 13.0, + lightPulse: 0.22, // depth of the slow breathing on the light + lightPulseSpeed: 0.7 // cycles/second of it +}; + +/** Editor layout. */ +export const avalancheSchema = { + 'The cast': [ + ['range', 2, 48, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 40, 0.5, 'front speed'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['lifetime', 0.1, 8, 0.05, 'deposit lifetime'], + ['settleTime', 0.1, 6, 0.05, 'settle time'], + ['castAnim', 'cast animation'] + ], + 'The runout': [ + ['runout', 0.05, 5, 0.05, 'runout (s)'], + ['runoutCurve', 0.2, 6, 0.05, 'runout curve'], + ['heapLead', 0.2, 3, 0.01, 'heap lead'] + ], + 'The heap': [ + ['repose', 0.15, 1.2, 0.005, 'repose angle (rad)'], + ['excess', 0, 0.8, 0.005, 'over-steep (rad)'], + ['slump', 0.1, 12, 0.05, 'slump rate (1/s)'], + ['creep', 0, 8, 0.05, 'creep (m/s)'], + ['lobes', 1, 24, 1, 'live lobes'], + ['lobeRate', 0.5, 20, 0.1, 'lobes/s'], + ['lobeVolume', 0.05, 12, 0.05, 'lobe volume (m³)'], + ['lobeJitter', 0, 1, 0.01, 'lobe jitter'], + ['lobeScatter', 0, 6, 0.05, 'lateral scatter (m)'], + ['widen', 0.2, 4, 0.05, 'lobe widening'], + ['heapWidth', 0.5, 12, 0.1, 'sheet half-width (m)'], + ['heapHeight', 0, 3, 0.01, 'height gain'], + ['heapFloor', 0, 0.2, 0.001, 'sits at (m)'] + ], + 'The heap/Deposit': [ + ['bed', 0, 2, 0.01, 'deposit depth (m)'], + ['bedRamp', 0.1, 12, 0.1, 'deposit ramp (m)'], + ['bedWidth', 0.2, 12, 0.1, 'deposit half-width (m)'], + ['bedCurve', 0.05, 4, 0.05, 'deposit edge'], + ['settleSink', 0, 3, 0.01, 'sink over the fade (m)'], + ['settleFlatten', 0, 1, 0.01, 'flatten over the fade'] + ], + 'The snow': [ + ['snowAmbient', 0, 1, 0.01, 'ambient'], + ['snowWrap', 0, 1, 0.01, 'light wrap'], + ['snowFresh', 0.05, 4, 0.05, 'fresh (s)'], + ['snowGrain', 0, 0.6, 0.01, 'grain'], + ['snowGrainScale', 5, 300, 1, 'grains/m'], + ['snowGlint', 0, 3, 0.01, 'glint'], + ['snowGlintScale', 10, 400, 1, 'glints/m'], + ['heapToe', 0.005, 0.4, 0.005, 'toe (m)'], + ['heapOpacity', 0, 1, 0.01, 'opacity'], + 'colorFresh', + 'colorSettled', + 'colorFace', + 'colorDeep' + ], + 'The slabs': [ + ['slabCount', 0, 96, 1, 'slabs'], + ['slabCluster', 0, 0.6, 0.01, 'held for the pile-up'], + ['slabClusterRadius', 0.2, 8, 0.05, 'pile-up radius'], + ['slabWidthNear', 0.05, 6, 0.01, 'band half-width at the caster'], + ['slabWidth', 0.05, 8, 0.01, 'band half-width at the target'], + ['slabWidthCurve', 0.2, 4, 0.01, 'band curve'], + ['slabBias', 0.3, 3, 0.01, 'crowd toward the target'], + ['slabClump', 0.2, 4, 0.01, 'pull onto the centre line'], + ['slabScatter', 0, 2, 0.01, 'lateral scatter'], + ['slabStagger', 0, 1.5, 0.01, 'stagger between slabs'] + ], + 'The slabs/Size': [ + ['slabHeightNear', 0.05, 4, 0.01, 'height at the caster'], + ['slabHeight', 0.05, 5, 0.01, 'height at the target'], + ['slabHeightCurve', 0.1, 4, 0.01, 'height curve'], + ['slabHeightJitter', 0, 1, 0.01, 'height jitter'], + ['slabCrown', 0, 1, 0.01, 'flank falloff'], + ['slabCrownPower', 0.2, 5, 0.01, 'falloff sharpness'], + ['slabRubble', 0, 1, 0.01, 'demoted to wreckage'], + ['slabRubbleScale', 0.05, 1, 0.01, 'wreckage height'], + ['slabRadiusNear', 0.05, 3, 0.01, 'plan radius at the caster'], + ['slabRadius', 0.05, 3, 0.01, 'plan radius at the target'], + ['slabRadiusCurve', 0.1, 4, 0.01, 'plan curve'], + ['slabRadiusJitter', 0, 1, 0.01, 'plan jitter'] + ], + 'The slabs/Attitude': [ + ['slabLean', 0, 1.2, 0.01, 'lean (rad)'], + ['slabRepose', 0, 1, 0.01, 'lie back onto the slope'], + ['slabLeanRamp', 0, 1, 0.01, 'lean ramp'], + ['slabTwist', 0, 1, 0.01, 'random yaw'], + ['slabTilt', 0, 0.8, 0.005, 'random tip'] + ], + 'The slabs/Shouldering up': [ + ['slabRise', 0.02, 1.5, 0.01, 'rise time'], + ['slabOvershoot', 0, 1, 0.01, 'overshoot'], + ['slabSettle', 0.05, 2, 0.01, 'settle'], + ['slabSpring', 2, 60, 0.5, 'overshoot ring'], + ['slabSink', 0, 1, 0.01, 'buried at emergence 0'], + ['slabBirthScale', 0.05, 1, 0.01, 'size at the surface'], + ['slabBirthFade', 0.02, 2, 0.01, 'birth flash decay'], + ['slabBreach', 0.02, 1, 0.01, 'breach fraction'], + ['slabRetractSink', 0, 3, 0.01, 'extra sink on retract (m)'] + ], + 'The slab shape': [ + ['facets', 4, 10, 1, 'plan sides'], + ['shear', 0, 1, 0.01, 'top face tip'], + ['taper', 0.2, 1.2, 0.01, 'top plan fraction'], + ['rough', 0, 0.9, 0.01, 'outline irregularity'], + ['notch', 0, 0.7, 0.01, 'chipped corner'] + ], + 'The slab surface': [ + ['slabRoughness', 0.05, 1, 0.01, 'roughness'], + ['strata', 0, 1.2, 0.01, 'wind layering'], + ['strataScale', 1, 40, 0.5, 'layers/m'], + ['grain', 0, 1, 0.01, 'speckle'], + ['grainScale', 4, 120, 1, 'speckles/m'], + ['fracture', 0, 3, 0.01, 'broken face'], + ['fractureSharp', 0.2, 8, 0.05, 'broken face tightness'], + ['slabRim', 0, 3, 0.01, 'rim'], + ['slabRimPower', 0.2, 8, 0.05, 'rim tightness'], + ['slabGlow', 0, 3, 0.01, 'glow'], + ['slabBirthGlow', 0, 5, 0.01, 'birth flash'], + 'colorCrust', + 'colorFracture', + 'colorShade', + 'colorSlabGlow' + ], + 'Powder': [ + ['powderRate', 0, 400, 1, 'rate'], + ['powderSpeed', 0, 20, 0.1, 'speed'], + ['powderRise', -5, 8, 0.05, 'buoyancy'], + ['powderSize', 0.05, 5, 0.01, 'size'], + ['powderLifetime', 0.1, 6, 0.05, 'lifetime'], + ['powderOpacity', 0, 1, 0.01, 'opacity'], + ['impactPowder', 0, 400, 1, 'at the pile-up'], + 'colorPowderA', + 'colorPowderB', + 'colorPowderC', + 'colorPowderD' + ], + 'Spindrift': [ + ['driftRate', 0, 400, 1, 'rate'], + ['driftSpeed', 0, 20, 0.1, 'speed'], + ['driftRise', -5, 8, 0.05, 'buoyancy'], + ['driftSize', 0.01, 1, 0.005, 'size'], + ['driftLifetime', 0.1, 6, 0.05, 'lifetime'], + ['driftTurbulence', 0, 4, 0.01, 'turbulence'], + 'colorDriftA', + 'colorDriftB', + 'colorDriftC', + 'colorDriftD' + ], + 'Chunks': [ + ['breachChunks', 0, 60, 1, 'per slab'], + ['impactChunks', 0, 300, 1, 'at the pile-up'], + ['chunkSpeed', 0, 20, 0.1, 'speed'], + ['chunkGravity', -40, 0, 0.5, 'gravity'], + ['chunkSize', 0.02, 1, 0.01, 'size'], + ['chunkLifetime', 0.1, 6, 0.05, 'lifetime'], + 'colorChunkA', + 'colorChunkB', + 'colorChunkC', + 'colorChunkD' + ], + 'The pile-up': [ + ['burstSize', 0.2, 12, 0.05, 'shell size'], + ['burstIntensity', 0, 4, 0.01, 'shell intensity'], + ['shockRadius', 0.5, 16, 0.1, 'ring radius'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration'], + ['impactFlash', 0, 1.5, 0.01, 'flash'], + ['rumble', 0, 3, 0.01, 'rumble'], + 'colorBurstA', + 'colorBurstB', + 'colorBurstC', + 'colorShockA', + 'colorShockB', + 'colorFlash' + ], + 'The light': [ + ['lightIntensity', 0, 40, 0.1, 'intensity'], + ['lightRadius', 1, 40, 0.5, 'radius'], + ['lightPulse', 0, 1, 0.01, 'breath depth'], + ['lightPulseSpeed', 0, 6, 0.05, 'breath speed'], + 'lightColor' + ] +}; diff --git a/src/config/abilities/balllightning.js b/src/config/abilities/balllightning.js new file mode 100644 index 0000000..0737a0d --- /dev/null +++ b/src/config/abilities/balllightning.js @@ -0,0 +1,533 @@ +/* ================================================================== */ +/* FULMINANT ORB — storm, line */ +/* ================================================================== */ +/** + * A caged orb that travels slowly enough that you watch it come. + * + * Two things make it, and neither is a bolt. The first is that the filaments + * **orbit**: great slow loops on inclined planes around a near-invisible + * pressure shell, rather than the radiating fan every other storm slot draws. + * The second is the speed — six and a half metres a second, which is walking + * pace, and the only slot in the sandbox where the travel itself is the + * spectacle rather than a delay before the impact. + * + * **The cage is measured against the shell, on purpose.** `orbitRadius` is a + * multiple of the shell's live radius, not a metre count of its own. This is + * the one derivation invariant I5 permits — the kind where the sharing *is* the + * design, as `snare.zoneRadius` drives five consumers at once — and it is what + * makes the impact work: the shell interpolates `orbRadius → orbRadiusEnd` on + * its own easing as `t` runs 0→1, and the cage opens with it, in step, from one + * slider. Authoring the cage in metres meant tuning the burst twice and getting + * filaments *inside* the shell every time the two disagreed. + * + * **The earthing spikes are stateless.** A second `LINE` role stabs from the + * orb to the floor now and then, and nothing remembers when. The clock is + * quantised into `earthRate` slots per second, a hash of the slot index decides + * whether that slot earths at all, and `earthDuty` says how much of the slot the + * spike lives for. Nothing is captured, so dragging any of the three with the + * clock stopped changes whether there is a spike standing in front of you — the + * first version kept a fired-at timestamp and a bool, and neither of them could + * be moved by a slider. + * + * The hum is the other half of the character. `humDepth` / `humRate` / + * `humBeat` drive `lightShimmer()` as two sines beating against each other, + * which is a *continuous* wobble; Storm Lance quantises a hash onto a step + * clock and gets a hard stutter. Put the two side by side in the sandbox and + * the difference is the whole personality of the slot. + */ +export const balllightning = { + /* --- the cast --- */ + range: 18.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 6.5, // metres/second — deliberately slow; you watch it come + lifetime: 0.9, // seconds the discharge holds after it lands + fadeTime: 0.9, // seconds it takes to die away + cooldown: 1.3, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the orb rides --- */ + handHeight: 1.24, // metres above the floor as it leaves the caster + handForward: 0.6, // metres in front of the caster + handSide: 0.18, // metres to the side (+ follows `Ability#side`) + endHeight: 0.85, // metres above the floor where it arrives + bobAmp: 0.09, // metres it rises and falls as it travels + bobRate: 0.55, // bobs per second + wanderAmp: 0.22, // metres it drifts off the aim line + wanderRate: 0.31, // drifts per second — deliberately not a multiple of `bobRate` + + /* --- the shell (vfx/Shell.js, ShellMode.PRESSURE, prefix "orb") --- */ + // A pressure shell is ninety-five per cent fresnel rim and almost no body, + // which is exactly what "near-invisible" wants. Hand-authored rather than + // spread from `shellDefaults()` so every field can carry its unit. + orbRadius: 0.55, // shell radius while travelling, metres + orbRadiusEnd: 4.2, // shell radius at full discharge, metres + orbExpand: 3.2, // easing exponent on that expansion: 1 − (1−t)^n + orbHeight: 1.0, // axial extent, × radius — 1 is a ball + orbSpan: 6.0, // CONE/RING_TRAIN run length, metres — inert in PRESSURE + orbLift: 0.02, // hover above the anchor along the axis, metres + orbDisplace: 0.09, // billow along the normal, × radius + orbNoiseScale: 2.4, // billow features per unit radius + orbNoiseSpeed: 0.9, // Hz the billow crawls at + orbTurbulence: 1.0, // master on the billow + orbFill: 0.03, // how much body the shell keeps, 0 = rim only + orbRim: 1.7, // strength of the fresnel rim — this is the shell + orbRimPower: 3.0, // how tight that rim is + orbSeal: 1.4, // DOME floor seal — inert in PRESSURE + orbSealWidth: 0.12, // width of that seal band — inert in PRESSURE + orbEdge: 1.2, // CONE leading lip — inert in PRESSURE + orbEdgeWidth: 0.16, // width of that lip — inert in PRESSURE + orbConeCurve: 1.0, // CONE flare — inert in PRESSURE + orbDissolve: 1.1, // how hard the age dissolve bites, 0 = fade evenly + orbRings: 10, // RING_TRAIN instances — inert in PRESSURE + orbSpacing: 1.6, // RING_TRAIN wavelength, metres — inert in PRESSURE + orbRingSpeed: 7.0, // RING_TRAIN speed, metres/second — inert in PRESSURE + orbRingThickness: 0.16, // RING_TRAIN ring thickness, metres — inert in PRESSURE + orbRingSharp: 1.6, // RING_TRAIN profile — inert in PRESSURE + orbReflect: 1.0, // RING_TRAIN end reflection — inert in PRESSURE + orbStanding: 1.0, // RING_TRAIN standing envelope — inert in PRESSURE + orbSwell: 0.45, // RING_TRAIN antinode swell, × radius — inert in PRESSURE + orbCoronaReach: 1.8, // SUNDISC drawn reach, × radius — inert in PRESSURE + orbCorona: 1.3, // SUNDISC corona brightness — inert in PRESSURE + orbCoronaLength: 0.55, // SUNDISC filament reach, × radius — inert in PRESSURE + orbCoronaScale: 5.0, // SUNDISC filaments per radius — inert in PRESSURE + orbCoronaWarp: 0.45, // SUNDISC domain warp — inert in PRESSURE + orbCoronaSpeed: 0.7, // SUNDISC corona Hz — inert in PRESSURE + orbCoronaSharp: 0.72, // SUNDISC corona threshold — inert in PRESSURE + orbGranule: 0.45, // SUNDISC convection cells — inert in PRESSURE + orbGranuleScale: 6.0, // SUNDISC cells per radius — inert in PRESSURE + orbRimWidth: 0.18, // SUNDISC hot band, × radius — inert in PRESSURE + orbOpacity: 1.0, + orbGlow: 1.4, // emissive gain into bloom + orbSoftFade: 0.6, // metres of depth fade against the opaque scene + orbColorBody: '#2f3fd0', // the little body the shell has + orbColorRim: '#9fd0ff', // the fresnel rim — almost all of what you see + orbColorEdge: '#ffffff', // the hottest mark it has + orbColorCorona: '#9fd0ff', // SUNDISC filaments — inert in PRESSURE + + /* --- the cage: filaments in ORBIT around the shell --- */ + orbitCount: 9, // filaments looping around the orb + orbitRadius: 1.45, // loop radius, × the shell's live radius (see the header) + orbitArc: 0.62, // turns one filament covers; below 1 the loop is open + orbitSpin: 0.42, // turns/second the loops travel — slow, on purpose + orbitWobble: 0.35, // 0..1 how far from a clean circle + orbitTilt: 0.5, // radians the mean orbital plane is tipped by + orbitTiltSpread: 1.5, // radians of per-filament variation on that tilt + orbitJitter: 0.22, // ± fraction of the radius, per filament + orbitLean: 0.45, // metres the orbital pole leans along the heading + orbitKink: 0.55, // multiplier on `jitter` for the cage role + orbitWidth: 1.0, // multiplier on `width` for the cage role + orbitDim: 1.0, // 0..1 alpha on the cage role + orbitGroundDamp: 1.0, // 1 = the cage is in the air and wants no damping + orbitBurst: 1.9, // multiplier on `orbitCount` once it has landed + + /* --- the ribbon, shared by all three roles --- */ + width: 0.024, // half-width of the core ribbon, metres + glowWidth: 6.4, // halo half-width, × `width` + glowOpacity: 0.46, // halo alpha relative to the core + jitter: 0.24, // metres of lateral kink at the coarsest octave + jitterScale: 2.2, // kinks per metre + octaves: 3, // 1–5; each halves the amplitude and doubles the rate + jitterFalloff: 0.5, // amplitude kept per octave + crawl: 1.6, // how fast the kinks slide along, per second + pinch: 0.2, // 0..1 of the path the kink is eased in over at each end + restrike: 14, // whole re-shapes per second — half the bolt's, it is calmer + flicker: 0.18, // 0..1 depth of the whole-cage brightness wobble + flickerSpeed: 12, // steps/second that wobble is quantised to + strandFlash: 0.3, // 0..1 depth of the per-filament blink + coreSharp: 4.0, // exponent on the core's edge falloff + glowFalloff: 2.2, // the same for the halo + softFade: 0.7, // metres of depth fade against the opaque scene + opacity: 1.0, + glow: 2.1, // emissive gain into bloom + + /* --- colour of the ribbon --- */ + colorCore: '#ffffff', // the white-hot centre line + colorInner: '#dcefff', + colorOuter: '#9fd0ff', + colorHalo: '#2f3fd0', // the wide, dim atmosphere + + /* --- the earthing spike --- */ + earthRate: 3.4, // slots per second in which a spike may happen + earthChance: 0.42, // 0..1 of those slots that actually earth + earthDuty: 0.45, // 0..1 of a slot the spike is alive for + spikeStrands: 2, // filaments in one spike + spikeBurst: 3.0, // multiplier on that count once it has landed + spikeExit: 0.55, // where on the shell the spike leaves, × shell radius + spikeDrop: 0.4, // how far below the centre it leaves, × shell radius + spikeWander: 0.7, // metres the floor end may stray sideways + spikeFloor: 0.04, // metres the spike is clamped above the floor + spikeSag: 0.06, // metres it bows at mid-span + spikeNear: 0.02, // metres it is fanned at the orb + spikeSpread: 0.3, // ... and at the floor + spikeCurve: 1.5, // how late that fan opens + spikeTwist: 0.25, // turns of roll from orb to floor + spikeTwistSpeed: 1.4, // turns/second the fan rolls + spikeConverge: 0.8, // 0..1 — how hard the far end is pinned to the floor point + spikeKink: 1.1, // multiplier on `jitter` for the spike role + spikeWidth: 0.85, // multiplier on `width` for the spike role + spikeDim: 0.9, // 0..1 alpha on the spike role + spikeGroundDamp: 0.35, // 0..1 on the kink's world y near the floor + spikeTipGlow: 1.8, // extra core colour where it hits the floor + + /* --- the ring of ground current the discharge lays down --- */ + ringCount: 8, // travelling arcs around the footprint + ringRadius: 0.8, // footprint radius, × the shell's live radius + ringSpan: 0.21, // turns one arc covers + ringSpeed: 0.55, // turns/second the arcs travel around + ringLift: 0.3, // metres an arc hops at mid-span + ringJitter: 0.35, // 0..1 radial wobble + ringHug: 0.05, // metres above the floor + ringPhase: 0.13, // turns of constant offset between the two halves + ringKink: 0.8, // multiplier on `jitter` for the ring role + ringWidth: 0.9, // multiplier on `width` for the ring role + ringDim: 0.85, // 0..1 alpha on the ring role + ringGroundDamp: 0.28, // 0..1 on the kink's world y — flat filaments bury without this + ringFloor: 0.02, // metres the ring is clamped above the floor + ringGrow: 0.24, // seconds the ring takes to be drawn out + ringTip: 0.16, // 0..1 of the path the growing front is smeared over + ringTipGlow: 1.1, // extra core colour at that front + + /* --- what the ground does --- */ + wakeRate: 1.1, // burns laid per metre of travel — the orb's wake + wakeRadius: 0.55, // radius of one wake burn, metres + wakeLife: 0.7, // seconds a wake burn lingers + wakeIntensity: 0.7, + wakeBranches: 0.5, // how finely a wake burn splits into filaments + scorchRadius: 0.34, // dark mark under the orb, metres + scorchLife: 5.0, // seconds + scorchIntensity: 0.3, + colorArc: '#9fd0ff', // the branching burn + colorEmber: '#4a7aff', // its hot centre + colorScorch: '#080a14', // the dark mark + shockRadius: 6.0, // impact shockwave ring, metres + colorShockA: '#c9e4ff', // body of the shockwave ring + colorShockB: '#ffffff', // its crest + + /* --- motes, sparks, smoke and debris --- */ + /** + * Four-stop lifetime gradients, `A` at birth through `D` as it dies, spelled + * out per system rather than derived from the ribbon palette — the motes want + * to cool into the shell's indigo while the sparks stay white. + */ + moteRate: 130, // ionised motes drawn around the orb, particles/second + moteSize: 0.055, + moteSpeed: 0.7, // metres/second + moteLifetime: 1.1, // seconds + moteRise: 0.35, // upward drift, metres/second + moteTurbulence: 0.9, + moteShell: 1.15, // where they are born, × shell radius + colorMoteA: '#ffffff', + colorMoteB: '#dcefff', + colorMoteC: '#6f8fff', + colorMoteD: '#0b1550', + sparkRate: 60, // sparks shed by the cage, particles/second + sparkSize: 0.11, + sparkSpeed: 4.5, // metres/second + sparkLifetime: 0.4, // seconds + sparkGravity: -11.0, // metres/second² + sparkStretch: 0.16, // how far a spark smears along its velocity + colorSparkA: '#ffffff', + colorSparkB: '#dcefff', + colorSparkC: '#9fd0ff', + colorSparkD: '#233a8a', + smokeRate: 14, // thin haze off the scorched floor, particles/second + smokeSize: 0.85, + smokeSpeed: 0.9, // metres/second + smokeLifetime: 2.2, // seconds + smokeOpacity: 0.05, + smokeRise: 0.45, // metres/second + colorSmokeA: '#3a4a6e', + colorSmokeB: '#30405e', + colorSmokeC: '#283652', + colorSmokeD: '#1a2338', + debrisSize: 0.05, + debrisSpeed: 5.0, // metres/second + debrisLifetime: 1.2, // seconds + debrisGravity: -17.0, // metres/second² + colorDebrisA: '#252c36', + colorDebrisB: '#1c222a', + colorDebrisC: '#1c222a', + colorDebrisD: '#141920', + + /* --- dynamic light: the hum --- */ + lightIntensity: 20, // the orb is its own lamp for the whole travel + lightRadius: 13, // metres + lightColor: '#7f9fff', + humDepth: 0.3, // 0..1 depth of the hum, 0 = a steady lamp + humRate: 6.5, // Hz of the first sine + humBeat: 1.47, // the second sine's rate, × the first — keep it irrational-ish + + /* --- the release and the discharge --- */ + castFlash: 0.07, // screen flash as the orb leaves the hand + colorCastFlash: '#c9e4ff', + muzzleSize: 0.5, // the shell at the hand, metres + muzzleIntensity: 1.5, + burstSize: 4.0, // the shell at the discharge, metres + burstIntensity: 1.7, + burstSparks: 190, // sparks thrown by the discharge + burstDebris: 55, + burstMotes: 120, // motes released by the cage as it opens + impactShake: 0.85, + shakeDuration: 0.7, // seconds + impactFlash: 0.3, // screen flash at the discharge + impactLightPunch: 22.0, // additive light punch as it opens + rumble: 0.012, // continuous shake while it travels + colorMuzzleA: '#2f3fd0', // muzzle shell + colorMuzzleB: '#9fd0ff', // muzzle body + colorMuzzleC: '#ffffff', // muzzle arcs + colorBurstA: '#2f3fd0', // discharge shell + colorBurstB: '#9fd0ff', // discharge body + colorBurstC: '#ffffff', // discharge arcs + colorFlash: '#c9e4ff' // the full-screen flash at the discharge +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Fulminant Orb. + * + * **The cage** is the folder to open first. `orbitArc` below 1 is what makes a + * filament a loop with two loose ends rather than a closed ring, `orbitSpin` + * decides whether the orb is idling or furious, and `orbitTiltSpread` is the + * one that turns nine coplanar rings into something that actually reads as a + * cage from any angle. `orbitRadius` is a *multiple of the shell radius*, not + * metres — see the header. + * + * **The hum** is three sliders and worth a minute. `humBeat` at exactly 1 or 2 + * gives a period you can count, which reads as a pulse; leave it somewhere + * awkward and the level never quite repeats, which is what a hum is. + * + * The shell's mode-specific fields for `RING_TRAIN` and `SUNDISC` are still in + * the block — `Shell` audits the whole contract and warns about a short one — + * so they are filed together at the bottom rather than left to fall into + * "More". They are pushed into uniforms the PRESSURE branch never reads. + */ +export const balllightningSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 0.5, 40, 0.1, 'travel speed (m/s)'], + ['lifetime', 0.05, 6, 0.01, 'discharge lifetime'], + ['fadeTime', 0.05, 4, 0.01, 'fade time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where the orb rides': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['endHeight', 0, 4, 0.01, 'height at target'], + ['bobAmp', 0, 1, 0.005, 'bob amplitude (m)'], + ['bobRate', 0, 6, 0.01, 'bobs / sec'], + ['wanderAmp', 0, 2, 0.01, 'drift amplitude (m)'], + ['wanderRate', 0, 4, 0.01, 'drifts / sec'] + ], + 'The shell': [ + ['orbRadius', 0.05, 6, 0.01, 'radius travelling (m)'], + ['orbRadiusEnd', 0.05, 20, 0.01, 'radius discharging (m)'], + ['orbExpand', 0.2, 12, 0.01, 'expansion curve'], + ['orbHeight', 0.02, 4, 0.01, 'height × radius'], + ['orbLift', -2, 2, 0.001, 'lift (m)'], + ['orbDisplace', 0, 1.5, 0.01, 'billow'], + ['orbNoiseScale', 0.1, 10, 0.01, 'billow scale'], + ['orbNoiseSpeed', 0, 4, 0.01, 'billow Hz'], + ['orbTurbulence', 0, 3, 0.01, 'turbulence'], + ['orbFill', 0, 1, 0.01, 'body fill'], + ['orbRim', 0, 3, 0.01, 'fresnel rim'], + ['orbRimPower', 0.1, 8, 0.01, 'rim power'], + ['orbDissolve', 0, 2, 0.01, 'dissolve'], + ['orbOpacity', 0, 1, 0.01, 'opacity'], + ['orbGlow', 0, 8, 0.01, 'glow'], + ['orbSoftFade', 0, 3, 0.01, 'soft fade (m)'], + ['orbColorBody', 'shell body'], + ['orbColorRim', 'shell rim'], + ['orbColorEdge', 'shell edge'] + ], + 'The cage': [ + ['orbitCount', 0, 24, 1, 'filaments'], + ['orbitRadius', 0.2, 4, 0.01, 'loop radius × shell'], + ['orbitArc', 0.05, 3, 0.01, 'turns per filament'], + ['orbitSpin', -4, 4, 0.01, 'spin (turns/sec)'], + ['orbitWobble', 0, 2, 0.01, 'wobble'], + ['orbitTilt', -3.2, 3.2, 0.01, 'plane tilt (rad)'], + ['orbitTiltSpread', 0, 3.2, 0.01, 'tilt spread (rad)'], + ['orbitJitter', 0, 1, 0.01, 'radius jitter'], + ['orbitLean', -3, 3, 0.01, 'pole lean (m)'], + ['orbitKink', 0, 3, 0.01, 'kink ×'], + ['orbitWidth', 0.1, 4, 0.01, 'width ×'], + ['orbitDim', 0, 1, 0.01, 'alpha'], + ['orbitGroundDamp', 0, 1, 0.01, 'ground damp'], + ['orbitBurst', 1, 5, 0.01, 'count × on discharge'] + ], + 'The ribbon': [ + ['width', 0.005, 0.4, 0.001, 'core width (m)'], + ['glowWidth', 1, 20, 0.1, 'halo width'], + ['glowOpacity', 0, 2, 0.01, 'halo opacity'], + ['jitter', 0, 3, 0.01, 'kink amplitude (m)'], + ['jitterScale', 0.05, 6, 0.01, 'kinks / metre'], + ['octaves', 1, 5, 1, 'octaves'], + ['jitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['crawl', -20, 20, 0.1, 'kink crawl'], + ['pinch', 0.01, 0.5, 0.005, 'end pinch'], + ['restrike', 0.5, 90, 0.5, 'restrikes / sec'], + ['flicker', 0, 1, 0.01, 'brightness wobble'], + ['flickerSpeed', 1, 120, 1, 'wobble rate'], + ['strandFlash', 0, 1, 0.01, 'filament blink'], + ['coreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['glowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['softFade', 0.02, 3, 0.01, 'soft intersection'], + ['opacity', 0, 2, 0.01, 'opacity'], + ['glow', 0, 8, 0.01, 'glow'], + ['colorCore', 'core'], + ['colorInner', 'inner'], + ['colorOuter', 'outer'], + ['colorHalo', 'halo'] + ], + 'The earthing spike': [ + ['earthRate', 0.1, 20, 0.05, 'slots / sec'], + ['earthChance', 0, 1, 0.01, 'slots that earth'], + ['earthDuty', 0.02, 1, 0.01, 'slot duty'], + ['spikeStrands', 1, 8, 1, 'spike filaments'], + ['spikeBurst', 1, 6, 0.01, 'count × on discharge'], + ['spikeExit', 0, 2, 0.01, 'exit radius × shell'], + ['spikeDrop', -1, 2, 0.01, 'exit drop × shell'], + ['spikeWander', 0, 4, 0.01, 'floor wander (m)'], + ['spikeFloor', 0, 1, 0.005, 'floor clamp (m)'], + ['spikeSag', -1, 1, 0.01, 'spike sag (m)'], + ['spikeNear', 0, 1, 0.005, 'fan at orb (m)'], + ['spikeSpread', 0, 3, 0.01, 'fan at floor (m)'], + ['spikeCurve', 0.2, 5, 0.01, 'fan curve'], + ['spikeTwist', -4, 4, 0.01, 'twist'], + ['spikeTwistSpeed', -6, 6, 0.01, 'twist speed'], + ['spikeConverge', 0, 1, 0.01, 'lock onto floor'], + ['spikeKink', 0, 3, 0.01, 'kink ×'], + ['spikeWidth', 0.1, 4, 0.01, 'width ×'], + ['spikeDim', 0, 1, 0.01, 'alpha'], + ['spikeGroundDamp', 0, 1, 0.01, 'ground damp'], + ['spikeTipGlow', 0, 6, 0.05, 'floor-strike glow'] + ], + 'The ring of ground current': [ + ['ringCount', 0, 16, 1, 'arcs'], + ['ringRadius', 0.1, 3, 0.01, 'radius × shell'], + ['ringSpan', 0.02, 1, 0.01, 'turns per arc'], + ['ringSpeed', -3, 3, 0.01, 'travel (turns/sec)'], + ['ringLift', 0, 2, 0.01, 'mid-span hop (m)'], + ['ringJitter', 0, 2, 0.01, 'radial wobble'], + ['ringHug', 0, 1, 0.005, 'float above floor (m)'], + ['ringPhase', 0, 1, 0.01, 'phase offset'], + ['ringKink', 0, 3, 0.01, 'kink ×'], + ['ringWidth', 0.1, 4, 0.01, 'width ×'], + ['ringDim', 0, 1, 0.01, 'alpha'], + ['ringGroundDamp', 0, 1, 0.01, 'ground damp'], + ['ringFloor', 0, 1, 0.005, 'floor clamp (m)'], + ['ringGrow', 0.02, 2, 0.01, 'grow time'], + ['ringTip', 0.01, 1, 0.01, 'front smear'], + ['ringTipGlow', 0, 6, 0.05, 'front glow'] + ], + 'The wake on the ground': [ + ['wakeRate', 0.05, 8, 0.05, 'burns / metre'], + ['wakeRadius', 0.1, 8, 0.05, 'burn radius'], + ['wakeLife', 0.05, 5, 0.05, 'burn lifetime'], + ['wakeIntensity', 0, 3, 0.01, 'burn intensity'], + ['wakeBranches', 0, 3, 0.01, 'branch detail'], + ['scorchRadius', 0.05, 4, 0.05, 'scorch radius'], + ['scorchLife', 0.5, 20, 0.1, 'scorch lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['colorArc', 'burn'], + ['colorEmber', 'ember'], + ['colorScorch', 'scorch'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'Motes & sparks': [ + ['moteRate', 0, 600, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 12, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 8, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['moteShell', 0.2, 4, 0.01, 'birth radius × shell'], + ['sparkRate', 0, 1200, 1, 'spark rate'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['colorMote*', 'Mote colour'], + ['colorSpark*', 'Spark colour'] + ], + 'Smoke & debris': [ + ['smokeRate', 0, 500, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['debrisSize', 0.005, 0.4, 0.005, 'debris size'], + ['debrisSpeed', 0, 25, 0.1, 'debris speed'], + ['debrisLifetime', 0.1, 5, 0.05, 'debris lifetime'], + ['debrisGravity', -50, 0, 0.1, 'debris gravity'], + ['colorSmoke*', 'Smoke colour'], + ['colorDebris*', 'Debris colour'] + ], + 'The hum': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['humDepth', 0, 1, 0.01, 'hum depth'], + ['humRate', 0.1, 30, 0.05, 'hum rate (Hz)'], + ['humBeat', 0.05, 4, 0.01, 'beat ratio'], + ['lightColor', 'light colour'] + ], + 'Release & discharge': [ + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['muzzleSize', 0.05, 6, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['colorMuzzleA', 'muzzle shell'], + ['colorMuzzleB', 'muzzle body'], + ['colorMuzzleC', 'muzzle arcs'], + ['burstSize', 0.2, 14, 0.05, 'discharge size'], + ['burstIntensity', 0, 5, 0.01, 'discharge intensity'], + ['burstSparks', 0, 600, 1, 'discharge sparks'], + ['burstDebris', 0, 300, 1, 'discharge debris'], + ['burstMotes', 0, 600, 1, 'discharge motes'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['impactLightPunch', 0, 120, 0.5, 'light punch'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'discharge shell'], + ['colorBurstB', 'discharge body'], + ['colorBurstC', 'discharge arcs'], + ['colorFlash', 'discharge flash colour'] + ], + 'The shell/Inert in PRESSURE mode': [ + ['orbSpan', 0.1, 40, 0.05, 'CONE / train length (m)'], + ['orbSeal', 0, 4, 0.01, 'DOME floor seal'], + ['orbSealWidth', 0.01, 0.6, 0.01, 'DOME seal width'], + ['orbEdge', 0, 4, 0.01, 'CONE leading lip'], + ['orbEdgeWidth', 0.01, 0.8, 0.01, 'CONE lip width'], + ['orbConeCurve', 0.1, 4, 0.01, 'CONE flare curve'], + ['orbRings', 1, 48, 1, 'train rings'], + ['orbSpacing', 0.1, 12, 0.01, 'train wavelength (m)'], + ['orbRingSpeed', 0, 40, 0.05, 'train speed (m/s)'], + ['orbRingThickness', 0.01, 2, 0.01, 'train thickness (m)'], + ['orbRingSharp', 0.05, 8, 0.01, 'train profile'], + ['orbReflect', 0, 1, 0.01, 'train reflection'], + ['orbStanding', 0, 1, 0.01, 'train standing wave'], + ['orbSwell', 0, 2, 0.01, 'train antinode swell'], + ['orbCoronaReach', 1, 4, 0.01, 'disc drawn reach'], + ['orbCorona', 0, 4, 0.01, 'disc corona'], + ['orbCoronaLength', 0, 3, 0.01, 'disc corona length'], + ['orbCoronaScale', 0.5, 20, 0.1, 'disc corona scale'], + ['orbCoronaWarp', 0, 2, 0.01, 'disc corona warp'], + ['orbCoronaSpeed', 0, 4, 0.01, 'disc corona Hz'], + ['orbCoronaSharp', 0, 0.98, 0.01, 'disc corona threshold'], + ['orbGranule', 0, 2, 0.01, 'disc granulation'], + ['orbGranuleScale', 0.5, 24, 0.1, 'disc granule scale'], + ['orbRimWidth', 0.01, 0.6, 0.01, 'disc rim band'], + ['orbColorCorona', 'disc corona colour'] + ] +}; diff --git a/src/config/abilities/beam.js b/src/config/abilities/beam.js new file mode 100644 index 0000000..c804980 --- /dev/null +++ b/src/config/abilities/beam.js @@ -0,0 +1,495 @@ +/* ================================================================== */ +/* BEAM — ability four */ +/* ================================================================== */ +/** + * A sustained super beam: the caster winds up a ball of light in both hands, + * then lets a column of it out along the aimed line, where it *stays* — + * burning into the floor for `lifetime` before it collapses back to a thread + * and blinks out. Reference for the look: `superbeam.jpg`. + * + * This is the ability with a **fourth beat**. Ice, thunder and meteor all run + * travel → impact → fade; the beam puts a `charge` in front of that, so the + * shot is something you watch arrive *and* something that lands and holds. + * Nothing in the base class needed changing for it — `BeamAbility` simply + * refuses to let the front leave the hand until the orb is up to power. + * + * The column is **one tube** — see `assets/ProceduralGeometry.js` — drawn + * three times at three radii by `materials/BeamMaterial.js`: a wide halo, a + * hollow rim-weighted sheath and, inside it, a core weighted the *opposite* + * way, brightest where the view ray runs down the barrel. That inversion is + * what makes the middle read as a solid rod of light instead of as a lit + * pipe. The coils spiralling around it and the shock discs racing down it are + * two more instanced passes placed against the same radius profile, so all + * five stay welded together when the shape is dragged. + * + * Deliberately *not* electric: no kinks anywhere. The bolt's noise is + * piecewise-linear so it keeps its corners; every noise term here is smooth + * and stretched hard along the flow, because a beam that kinks is a bolt. + * + * As in every other block, a cast captures nothing but one seed and a few + * timestamps. The barrel, the flare, the coil pitch and the disc train are all + * resolved against these numbers each frame — which is why dragging `radius` + * re-bores a beam that is already burning, with the clock stopped. + */ +export const beam = { + /* --- the cast --- */ + range: 26.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + charge: 0.42, // seconds the orb winds up before the beam is let out + speed: 150.0, // how fast the leading edge races downrange, metres/second + lifetime: 1.15, // seconds it burns once it lands + fadeTime: 0.4, // seconds it takes to collapse + cooldown: 1.6, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where it leaves the caster --- */ + // Both hands, so this one sits on the centre line rather than off a + // shoulder like the bolt and the rock. + handHeight: 1.3, // metres above the floor + handForward: 0.72, // metres in front of the caster + handSide: 0.0, // metres to the side (+ follows `Ability#side`) + endHeight: 1.0, // height of the beam where it lands, metres + + /* --- the column --- */ + // A narrow throat that stays tight (`radiusCurve` above 1) and then opens + // hard over the last tenth of the span: the beam reads as a jet with a bell + // on the end rather than as a cone, which is what puts the weight at the + // impact instead of spreading it down the whole line. + radiusNear: 0.16, // half-width at the muzzle, metres + radius: 0.77, // half-width at the target + radiusCurve: 1.27, // <1 opens out early, >1 stays tight then flares late + flare: 1.74, // extra swell where it lands + flareWidth: 0.09, // how much of the span that swell covers, 0..1 + // Both wobbles ship at zero. The column reads cleaner with a hard, still + // silhouette — the coils already give the eye something moving to follow — + // but the rates below are tuned, so raising either one is a single drag. + throb: 0.0, // pressure waves travelling out along it + throbScale: 4.8, // waves over the length + throbSpeed: 2.6, // waves/second + wander: 0.0, // metres the axis drifts, pinned at both ends + wanderScale: 0.9, // drift features per unit length + wanderSpeed: 0.7, + + /* --- the three tube passes --- */ + // The core is deliberately narrow and not fully opaque. Widen it or push + // `coreFill` up and the three layers stack into one white rod: the cyan + // sheath and the gold coils are only readable because the middle leaves + // them room. + coreWidth: 0.2, // the hot rod, × the column radius + coreSharp: 1.55, // how tightly the core hugs the axis + coreFill: 0.6, // how solid it reads + shellWidth: 1.0, // the sheath + shellRim: 1.15, // brightness of its silhouette edges + shellFill: 0.18, // how much body it has between them + shellOpacity: 0.95, + // Wide and faint: the halo is atmosphere, not a second beam. Pushing its + // opacity up fogs the sheath's silhouette edges, which are the read. + haloWidth: 2.75, // the outer bloom + haloRim: 4.3, // how tightly that hugs the silhouette + haloOpacity: 0.14, + edgePower: 2.2, // rim exponent shared by the sheath + + /* --- the surface --- */ + ripple: 0.2, // how far the noise pushes the barrel off round + rippleBands: 2.2, // ripple features around the barrel + rippleScale: 4.25, // ... and along it + rippleSpeed: 2.0, // how fast they crawl downrange + streak: 1.1, // filaments streaming along the flow + streakSharp: 0.45, // 0 = a wash, 1 = hard threads + streakScale: 4.2, // threads per unit length + streakBands: 1.8, // ... and around the barrel + // Kept low: the threads carry heat into the *sheath*, and pushing this up + // whitens it out until the beam is one colour from axis to rim. + streakGlow: 0.55, // how hot a thread burns in the sheath + flowSpeed: 7.0, // how fast the whole field streams downrange + mouthGlow: 1.6, // heat where the column leaves the orb + mouthLength: 0.1, // how far that reaches, fraction of the span + // Kept below the muzzle's: the flare and the impact shell already carry the + // far end, and stacking a hot cap on top of them blows it out to a disc. + tipGlow: 0.6, // heat on the leading edge / the burning end + tipLength: 0.09, // length of that edge, fraction of the span + softFade: 0.62, // metres of soft fade where it meets geometry + + /* --- colour --- */ + colorCore: '#ffffff', // the axis + colorInner: '#d3f4ff', + colorOuter: '#3ec6ff', // the outside of the sheath + colorHalo: '#0d3ce0', // the wide bloom around it + // The column is deliberately held *back*. Three additive tube passes at full + // strength clip to white and the beam becomes a flat plank; dropping the + // gain and the opacity keeps it glassy and hands the read to the coils. + glow: 0.74, // overall emissive gain + opacity: 0.29, + + /* --- the coils --- */ + /** + * Ribbons spiralling around the column, on the same strip the bolt is drawn + * on. Warm on purpose: the reference frames a white-hot beam with gold + * coils, and the colour split is what stops them dissolving into the sheath. + */ + coils: 4, // ribbons (capped at 8) + coilTurns: 1.45, // turns each one makes over the length + // Negative, so the ribbons roll *against* the direction the charge pulse + // runs. The two motions reading differently is what keeps a held beam from + // looking like a single rotating screw. + coilSpeed: -0.69, // turns/second they roll on top of that + coilRadius: 1.88, // how far out they ride, × the column radius + coilFlare: 0.57, // extra opening at the far end + coilWidth: 0.1, // half-width at the muzzle, metres + coilWidthTip: 1.9, // that width at the target, as a multiple + coilSharp: 2.2, // how hard the ribbon falls off across its width + coilPulse: 0.65, // depth of the charge running along it + coilPulseFreq: 3.0, // pulses over the length + coilPulseSpeed: 1.6, // pulses/second + // Driven hard on purpose. With the column dialled back above, the ribbons + // are what the eye actually follows down the beam. + coilGlow: 8.0, + coilOpacity: 2.0, + colorCoil: '#ffdc8c', + colorCoilEdge: '#ff6a12', + + /* --- the shock discs --- */ + rings: 10, // discs in flight (capped at 12) + ringSpeed: 1.31, // trips down the beam per second + // Both lips well clear of the sheath, and close together: the discs read as + // thin hoops orbiting the column rather than as plates growing out of it. + ringInner: 2.42, // inner lip, × the local column radius + ringOuter: 2.73, // outer lip + ringSwell: 0.55, // how much they open out as they travel + ringFade: 0.18, // how much is left of one by the time it lands + ringSharp: 1.6, // how thin the band reads + ringGlow: 2.4, + ringOpacity: 0.7, + colorRing: '#9ceeff', + + /* --- the charge orb --- */ + orbSize: 0.39, // radius once it is up to power, metres + orbThrob: 0.11, // how hard it pulses + orbThrobSpeed: 6.9, + orbTurbulence: 0.24, // how far the noise eats into its surface + orbScale: 2.2, // features over the surface + orbFlow: 0.9, // how fast they crawl + orbBands: 5.0, // filament frequency + orbRim: 1.8, // rim exponent + orbGlow: 2.8, + orbOpacity: 1.0, + + /* --- what the ground does --- */ + scorchRate: 1.1, // burns laid per metre of front travel + scorchRadius: 0.7, // radius of one, metres + scorchLife: 7.0, // seconds it lingers + scorchIntensity: 0.55, + colorScorch: '#0a0d14', + colorEmber: '#4ad6ff', + dustRate: 7.0, // dust rings thrown off the burning end, per second + dustRadius: 2.4, // radius of one, metres + dustLife: 0.9, + colorDustA: '#3d5c74', + colorDustB: '#9ceeff', + shockRate: 3.5, // pressure rings snapped across the floor, per second + shockRadius: 7.0, // radius of the one at the impact, metres + colorShockA: '#3ec6ff', // body of the shockwave ring + colorShockB: '#ffffff', // its crest + + /* --- sparks, motes, smoke and debris --- */ + /** + * As in `ice`, `thunder` and `meteor`: each system is coloured by a four-stop + * gradient sampled over the particle's own lifetime, `A` at birth through + * `D` as it dies. The motes do double duty — they are the intake spiralling + * *into* the orb while it charges, and the drift shed off the column once it + * is firing. + */ + sparkRate: 300, // sparks shed off the column, particles/second + sparkSize: 0.15, + sparkSpeed: 8.0, + sparkLifetime: 0.55, + sparkGravity: -9.0, + sparkStretch: 0.22, // how far a spark smears along its velocity + sparkForward: 0.9, // how hard the spray is dragged downrange + colorSparkA: '#ffffff', + colorSparkB: '#d3f4ff', + colorSparkC: '#3ec6ff', + colorSparkD: '#0b2f7a', + moteRate: 120, // the drift hanging around the column + moteSize: 0.06, + moteSpeed: 1.6, + moteLifetime: 1.5, + moteRise: 0.9, // upward drift, metres/second + moteTurbulence: 0.8, + colorMoteA: '#ffffff', + colorMoteB: '#9ceeff', + colorMoteC: '#3ec6ff', + colorMoteD: '#06205e', + intakeRate: 260, // motes pulled into the orb while it charges + intakeRadius: 2.6, // how far out they are drawn from, metres + intakeSpeed: 7.5, // how fast they fall in + smokeRate: 90, // steam scoured off the floor under the beam + smokeSize: 1.1, + smokeSpeed: 1.4, + smokeLifetime: 2.4, + smokeOpacity: 0.07, + smokeRise: 0.7, + colorSmokeA: '#41566d', + colorSmokeB: '#35485e', + colorSmokeC: '#2a3949', + colorSmokeD: '#1a2430', + debrisRate: 34, // chips torn off the floor along the burn line + debrisSize: 0.06, + debrisSpeed: 6.0, + debrisLifetime: 1.4, + debrisGravity: -18.0, + colorDebrisA: '#2b323c', + colorDebrisB: '#1f252d', + colorDebrisC: '#1a1f26', + colorDebrisD: '#1a1f26', + + /* --- dynamic light --- */ + // Two lights: one rides the beam, one sits in the caster's hands so the + // charge actually lights the body that is holding it. + lightIntensity: 30, + lightRadius: 20, + lightColor: '#7fdcff', + lightPulse: 0.18, // depth of the hum, 0 = steady + lightPulseSpeed: 5.0, // pulses/second + muzzleLightIntensity: 16, + muzzleLightRadius: 9, + + /* --- the wind-up, the release and the burn --- */ + chargeShake: 0.045, // rumble while the orb spools up + castFlash: 0.22, // screen flash as it is released + muzzleSize: 1.1, // the pressure shell thrown off the hands, metres + muzzleIntensity: 2.0, + colorCastFlash: '#d3f4ff', + burstSize: 4.2, // the shell at the impact point, metres + burstIntensity: 1.6, + burstSparks: 220, // extra sparks thrown when it lands + burstDebris: 70, + pulseRate: 2.6, // pressure shells off the burning end, per second + pulseSize: 2.2, // radius of one, metres + pulseIntensity: 1.1, + splashRate: 260, // sparks kicked back up the beam while it burns + impactShake: 0.9, + shakeDuration: 0.7, + burnShake: 0.09, // continuous rumble while the beam is standing + impactFlash: 0.3, + rumble: 0.05, // rumble while the leading edge travels + colorBurstA: '#3ec6ff', + colorBurstB: '#d3f4ff', + colorBurstC: '#ffffff', + colorFlash: '#d3f4ff' // the full-screen flash on impact +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Nova Beam. + * + * Every control here is read by a shader on the frame it changes, so the whole + * folder reshapes a beam that is already burning — pause with **P** halfway + * through the hold and the entire panel stays live. The ones worth reaching + * for first are `radius` and `flare` (how heavy the column reads), `charge` + * and `lifetime` (the wind-up and the hold, which are what make this ability + * different from the other three), `coils` / `coilTurns` (the ribbons around + * it) and `streak` / `flowSpeed` (how hard the energy streams downrange). + */ +export const beamSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['charge', 0, 3, 0.01, 'wind-up time'], + ['speed', 5, 400, 1, 'travel speed'], + ['lifetime', 0.05, 8, 0.01, 'burn time'], + ['fadeTime', 0.05, 4, 0.01, 'collapse time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where it leaves the hands': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['endHeight', 0, 4, 0.01, 'height at target'] + ], + 'The column': [ + ['radiusNear', 0.01, 3, 0.01, 'radius at hands'], + ['radius', 0.02, 5, 0.01, 'radius at target'], + ['radiusCurve', 0.1, 4, 0.01, 'radius curve'], + ['flare', 0, 4, 0.01, 'flare at target'], + ['flareWidth', 0.02, 1, 0.01, 'flare width'], + ['throb', 0, 0.6, 0.005, 'pressure waves'], + ['throbScale', 0, 12, 0.1, 'waves / length'], + ['throbSpeed', 0, 10, 0.05, 'wave speed'], + ['wander', 0, 1, 0.005, 'axis drift'], + ['wanderScale', 0.1, 6, 0.05, 'drift scale'], + ['wanderSpeed', 0, 5, 0.01, 'drift speed'] + ], + // The three tube passes. `coreSharp` and `shellRim` are the pair that decide + // whether the beam reads as a solid rod or as a lit pipe — see + // `materials/BeamMaterial.js`. + 'Core, sheath & halo': [ + ['coreWidth', 0.05, 1.5, 0.01, 'core width'], + ['coreSharp', 0.1, 8, 0.05, 'core focus'], + ['coreFill', 0, 3, 0.01, 'core fill'], + ['shellWidth', 0.2, 3, 0.01, 'sheath width'], + ['shellRim', 0, 3, 0.01, 'sheath rim'], + ['shellFill', 0, 1.5, 0.01, 'sheath fill'], + ['shellOpacity', 0, 2, 0.01, 'sheath opacity'], + ['edgePower', 0.2, 8, 0.05, 'rim falloff'], + ['haloWidth', 0.5, 8, 0.05, 'halo width'], + ['haloRim', 0.5, 10, 0.05, 'halo falloff'], + ['haloOpacity', 0, 2, 0.01, 'halo opacity'] + ], + 'Surface & flow': [ + ['ripple', 0, 1, 0.005, 'surface ripple'], + ['rippleBands', 0.1, 8, 0.05, 'ripples around'], + ['rippleScale', 0.1, 12, 0.05, 'ripples along'], + ['rippleSpeed', 0, 12, 0.05, 'ripple crawl'], + ['streak', 0, 3, 0.01, 'filaments'], + ['streakSharp', 0, 1, 0.01, 'filament sharpness'], + ['streakScale', 0.2, 20, 0.1, 'filaments / length'], + ['streakBands', 0.2, 10, 0.05, 'filaments around'], + ['streakGlow', 0, 4, 0.01, 'filament heat'], + ['flowSpeed', 0, 30, 0.1, 'flow speed'], + ['mouthGlow', 0, 6, 0.05, 'muzzle heat'], + ['mouthLength', 0.005, 0.5, 0.005, 'muzzle length'], + ['tipGlow', 0, 6, 0.05, 'burning-end heat'], + ['tipLength', 0.005, 0.5, 0.005, 'burning-end length'], + ['softFade', 0.02, 3, 0.01, 'soft intersection'] + ], + 'Beam colour': [ + ['colorCore', 'axis'], + ['colorInner', 'inner'], + ['colorOuter', 'sheath'], + ['colorHalo', 'halo'], + ['glow', 0, 8, 0.01, 'glow'], + ['opacity', 0, 2, 0.01, 'opacity'] + ], + 'The coils': [ + ['coils', 0, 8, 1, 'ribbons'], + ['coilTurns', -8, 8, 0.05, 'turns over length'], + ['coilSpeed', -6, 6, 0.01, 'roll speed'], + ['coilRadius', 0.2, 4, 0.01, 'ride radius'], + ['coilFlare', 0, 4, 0.01, 'flare at target'], + ['coilWidth', 0.005, 0.6, 0.005, 'width at hands'], + ['coilWidthTip', 0.05, 6, 0.01, 'width at target'], + ['coilSharp', 0.2, 8, 0.05, 'edge falloff'], + ['coilPulse', 0, 1, 0.01, 'charge pulse'], + ['coilPulseFreq', 0, 12, 0.05, 'pulses / length'], + ['coilPulseSpeed', -8, 8, 0.05, 'pulse speed'], + // Headroom above the shipped values on purpose — they sit high, and a + // control that starts pinned to its own maximum can only ever come down. + ['coilGlow', 0, 14, 0.01, 'glow'], + ['coilOpacity', 0, 3, 0.01, 'opacity'], + ['colorCoil', 'ribbon core'], + ['colorCoilEdge', 'ribbon edge'] + ], + 'Shock discs': [ + ['rings', 0, 12, 1, 'discs'], + ['ringSpeed', 0, 6, 0.01, 'trips / second'], + ['ringInner', 0.2, 4, 0.01, 'inner lip'], + ['ringOuter', 0.3, 6, 0.01, 'outer lip'], + ['ringSwell', 0, 3, 0.01, 'swell downrange'], + ['ringFade', 0, 1, 0.01, 'fade downrange'], + ['ringSharp', 0.2, 8, 0.05, 'band sharpness'], + ['ringGlow', 0, 8, 0.01, 'glow'], + ['ringOpacity', 0, 2, 0.01, 'opacity'], + ['colorRing', 'disc colour'] + ], + 'The charge': [ + ['orbSize', 0.02, 2, 0.01, 'orb radius'], + ['orbThrob', 0, 0.6, 0.005, 'orb pulse'], + ['orbThrobSpeed', 0, 20, 0.1, 'pulse rate'], + ['orbTurbulence', 0, 1, 0.01, 'surface turbulence'], + ['orbScale', 0.2, 8, 0.05, 'surface scale'], + ['orbFlow', 0, 5, 0.01, 'surface crawl'], + ['orbBands', 0.5, 15, 0.1, 'filament scale'], + ['orbRim', 0.2, 6, 0.05, 'rim falloff'], + ['orbGlow', 0, 8, 0.01, 'glow'], + ['orbOpacity', 0, 2, 0.01, 'opacity'], + ['intakeRate', 0, 900, 1, 'intake rate'], + ['intakeRadius', 0.2, 8, 0.05, 'intake radius'], + ['intakeSpeed', 0.5, 25, 0.1, 'intake speed'], + ['chargeShake', 0, 0.5, 0.005, 'wind-up rumble'] + ], + 'What the floor does': [ + ['scorchRate', 0.05, 8, 0.05, 'burns / metre'], + ['scorchRadius', 0.05, 4, 0.05, 'burn radius'], + ['scorchLife', 0.5, 20, 0.1, 'burn lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'burn intensity'], + ['dustRate', 0, 20, 0.1, 'dust rings / sec'], + ['dustRadius', 0.2, 10, 0.05, 'dust ring radius'], + ['dustLife', 0.1, 5, 0.05, 'dust ring lifetime'], + ['shockRate', 0, 20, 0.1, 'shock rings / sec'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['colorScorch', 'scorch'], + ['colorEmber', 'ember'], + ['colorDustA', 'dust'], + ['colorDustB', 'dust crest'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'Sparks & motes': [ + ['sparkRate', 0, 1200, 1, 'spark rate'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['sparkForward', 0, 4, 0.01, 'downrange drag'], + ['moteRate', 0, 600, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 12, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 8, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorSpark*', 'Spark colour'], + ['colorMote*', 'Mote colour'] + ], + 'Steam & debris': [ + ['smokeRate', 0, 500, 1, 'steam rate'], + ['smokeSize', 0.05, 4, 0.01, 'steam size'], + ['smokeSpeed', 0, 8, 0.05, 'steam speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'steam lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'steam opacity'], + ['smokeRise', -2, 4, 0.01, 'steam rise'], + ['debrisRate', 0, 300, 1, 'debris rate'], + ['debrisSize', 0.005, 0.4, 0.005, 'debris size'], + ['debrisSpeed', 0, 25, 0.1, 'debris speed'], + ['debrisLifetime', 0.1, 5, 0.05, 'debris lifetime'], + ['debrisGravity', -50, 0, 0.1, 'debris gravity'], + ['colorSmoke*', 'Steam colour'], + ['colorDebris*', 'Debris colour'] + ], + 'Release, impact & burn': [ + ['muzzleSize', 0.05, 8, 0.05, 'release shell'], + ['muzzleIntensity', 0, 5, 0.01, 'release intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 18, 0.05, 'impact shell'], + ['burstIntensity', 0, 5, 0.01, 'impact intensity'], + ['burstSparks', 0, 800, 1, 'impact sparks'], + ['burstDebris', 0, 400, 1, 'impact debris'], + ['pulseRate', 0, 12, 0.1, 'burn shells / sec'], + ['pulseSize', 0.1, 10, 0.05, 'burn shell size'], + ['pulseIntensity', 0, 5, 0.01, 'burn shell intensity'], + ['splashRate', 0, 900, 1, 'back-splash rate'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['burnShake', 0, 0.5, 0.005, 'burn rumble'], + ['colorBurstA', 'impact shell'], + ['colorBurstB', 'impact body'], + ['colorBurstC', 'impact arcs'], + ['colorFlash', 'impact flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'beam intensity'], + ['lightRadius', 0.5, 60, 0.1, 'beam radius'], + ['lightPulse', 0, 1, 0.01, 'hum depth'], + ['lightPulseSpeed', 0, 30, 0.1, 'hum rate'], + ['muzzleLightIntensity', 0, 120, 0.5, 'hand intensity'], + ['muzzleLightRadius', 0.5, 40, 0.1, 'hand radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/blackice.js b/src/config/abilities/blackice.js new file mode 100644 index 0000000..d4e3ef6 --- /dev/null +++ b/src/config/abilities/blackice.js @@ -0,0 +1,360 @@ +/* ================================================================== */ +/* BLACKICE — Black Ice */ +/* ================================================================== */ +/** + * A sheet of meltwater that freezes into a mirror. + * + * This is the only slot in the sandbox that reflects the **real scene**: a + * camera mirrored about the sheet's plane renders the world layer into a small + * target and the surface samples it projectively. Everything else in the + * project that looks reflective is a fresnel ramp over an environment probe, + * and the difference is parallax — an env map's reflection does not slide + * across the surface as you orbit, and the eye reads a static reflection as + * painted-on shine within about half a second of camera movement. + * + * Three numbers are the ability: + * + * - **`roughWet` → `roughIce`** — the reflection's blur radius is + * `roughness × blurRadius` in reflection-UV, so a sheet at `roughWet = 1` is + * a scuffed puddle you can barely read the room in and a sheet at + * `roughIce = 0.02` is a black mirror. The freeze drives one to the other, + * and *that transition is the ability*. Drag `roughIce` up on a paused, + * fully frozen sheet and the room in it dissolves back into slush. + * - **`ripple`** — the surface disturbance, in reflection-UV. It is multiplied + * by `1 − freeze`, so the reflection stops moving at exactly the moment the + * water stops being water. Freezing a sheet whose reflection still swims is + * the tell that gives the whole thing away, and it is one multiply. + * - **`reflectWet` → `reflectIce`** — how much of the reflection survives at + * all. Water reflects hard at a grazing angle and weakly face-on (that is + * `fresnel`); ice reflects more evenly, so the fresnel term is not the thing + * that changes — the base weight is. + * + * **`resolution` is the cost slider.** The reflection is one extra + * `renderer.render()` of the world layer, and halving this quarters its pixels + * and changes its draw-call count not at all. 384² is 1.13 MB of half-float + * colour and 16% of a 720p frame's pixels; 256 is plenty while the sheet is + * still rough, and 512 is worth it for a black mirror you are going to stand + * and look at. `mirrorPriority` is this sheet's claim on the two-mirrors-a-frame + * budget, and matters only when something else reflective is on screen. + * + * The pool underneath and the plate crust over the top are `GroundField`s and + * carry no reflection at all — they are the silhouette and the texture, and the + * mirror is the middle of the sandwich. + */ +export const blackice = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 26.0, // how fast the front reaches the circle, metres/second + cooldown: 1.6, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 5.0, // metres — the footprint the aim indicator draws + lifetime: 2.6, // seconds the sheet holds once it has landed + thawTime: 1.8, // seconds the sheet takes to go back to water and vanish + + /* --- the beats, all measured from the moment the front lands --- */ + floodTime: 0.45, // seconds the meltwater takes to reach the full circle + freezeDelay: 0.18, // seconds of open water before the freeze starts + freezeTime: 1.15, // seconds the freeze takes to reach a mirror finish + + /* --- the mirror --- */ + sheetSpan: 0.92, // the reflective disc as a fraction of `zoneRadius` + sheetHeight: 0.014, // metres above the floor the mirror plane sits at + sheetOpacity: 0.95, + sheetEdgeFade: 0.22, // 0..1 of the half-extent the disc feathers over + roughWet: 1.0, // 0..1 blur at open water — barely a reflection at all + roughIce: 0.02, // ... and at a full freeze. This is the black mirror. + blurRadius: 0.045, // reflection-UV radius at roughness 1 + blurTaps: 7, // 1..12 taps in the roughness disc + roughStretch: 1.4, // elongation of that disc along the normal's screen dir + reflectWet: 0.55, // 0..1 reflection weight at open water + reflectIce: 0.94, // ... and at a full freeze + fresnel: 0.4, // 0..1 how much grazing angle shapes the reflection + fresnelPower: 2.2, + ripple: 0.02, // reflection-UV amplitude of the surface disturbance + rippleScale: 1.6, // cycles per metre + rippleSpeed: 0.7, // metres per second + resolution: 384, // reflection target edge, pixels. The cost slider. + mirrorPriority: 1.4, // claim on the two-reflections-a-frame budget + colorTint: '#c6d8e2', // multiplies the reflection — black ice is not neutral + colorSheet: '#0a1218', // what shows where the surface does not reflect + + /* --- the meltwater pool underneath --- */ + poolSpan: 1.0, // pool radius as a fraction of `zoneRadius` + poolHeight: 0.006, // metres above the floor + poolEdge: 0.4, // metres of feather on the spreading front + poolRagged: 0.3, // how far that front wanders, fraction of the radius + poolRaggedScale: 0.55, // lobes per metre + poolWarp: 0.6, // metres of domain warp on those lobes + poolRelief: 0.45, // how hard the height field tilts the fake normal + poolNormalStep: 0.07, // metres between the height taps + poolAmbient: 0.22, // floor on the diffuse term — this is dark water + poolWrap: 0.4, + poolSpecular: 1.15, // wet stone is mostly specular + poolGloss: 46.0, // Blinn exponent + poolParallax: 0.28, // metres of view-driven offset on the interior + poolCell: 0.9, // ripple scale, metres + poolThickness: 0.16, // metres of meniscus at the rim + poolLift: 0.035, // metres of wave height + poolDepth: 0.05, // metres of body depth + poolDetail: 0.75, + poolSpeed: 1.0, // ripple rate — multiplied by (1 − freeze) + poolFlow: 0.22, // metres/second of drift — likewise + poolWind: 0.9, // radians, the drift bearing in the quad's frame + poolEmissive: 0.5, + poolOpacity: 0.9, + poolDepthFade: 0.4, // metres of soft fade against standing geometry + colorPoolBase: '#131b21', // the water body + colorPoolEdge: '#9fc4d6', // the meniscus and the sheen + colorPoolGlow: '#4c8f96', // anything emissive in it + colorPoolDeep: '#05090c', // the interior + + /* --- the plate crust over the top --- */ + crustSpan: 0.98, // crust radius as a fraction of `zoneRadius` + crustHeight: 0.022, // metres above the floor — over the mirror + crustEdge: 0.55, // metres of feather on the freezing front + crustRagged: 0.42, // how far that front wanders — ice fronts are ragged + crustRaggedScale: 0.75, // lobes per metre + crustWarp: 0.7, // metres of domain warp + crustRelief: 0.35, + crustNormalStep: 0.05, + crustAmbient: 0.3, + crustWrap: 0.5, + crustSpecular: 0.85, + crustGloss: 34.0, + crustParallax: 0.12, + crustCell: 1.5, // metres — plate size. Big plates: this is one sheet. + crustCellJitter: 0.9, // 0..1 irregularity of the plate tessellation + crustSeam: 0.035, // metres of gap between plates + crustThickness: 0.02, // metres of sheet + crustLift: 0.006, // metres of curl — nearly none. Black ice lies flat. + crustDetail: 0.5, + crustEmissive: 0.7, + crustOpacity: 0.34, // low: the crust is a veil of seams over the reflection + crustDepthFade: 0.35, + colorCrustBase: '#243440', // the ice sheet itself + colorCrustEdge: '#dcefff', // the plate lips + colorCrustGlow: '#7ecbe0', // anything emissive + colorCrustDeep: '#0a1016', // the seams + + /* --- the cold fog crawling over the sheet --- */ + mistRate: 42.0, // particles/second + mistSpeed: 1.1, // metres/second + mistRise: 0.28, // metres/second² — it hugs the floor + mistSize: 1.7, + mistLifetime: 2.4, // seconds + mistOpacity: 0.42, + impactMist: 55, // particles thrown as the water lands + colorMistA: '#e6f2fb', + colorMistB: '#b7ccdd', + colorMistC: '#7b93a8', + colorMistD: '#3f4f5e', + + /* --- crystals lighting up along the freeze front --- */ + glintRate: 64.0, // particles/second + glintSpeed: 0.9, // metres/second + glintRise: 0.55, // metres/second² + glintSize: 0.075, + glintLifetime: 1.1, // seconds + glintTurbulence: 0.7, + colorGlintA: '#ffffff', + colorGlintB: '#bfe8ff', + colorGlintC: '#5aa6c4', + colorGlintD: '#1f3a4a', + + /* --- flecks of water and shattered ice --- */ + impactSpray: 46, // particles as the water lands + thawSpray: 34, // ... and as the sheet lets go + spraySpeed: 4.0, // metres/second + sprayGravity: -13.0, // metres/second² + spraySize: 0.11, + sprayLifetime: 1.2, // seconds + colorSprayA: '#eaf6ff', + colorSprayB: '#a9c9dd', + colorSprayC: '#5c7a8e', + colorSprayD: '#243440', + + /* --- landing and letting go --- */ + burstSize: 2.2, // metres, the shell of freezing air + burstIntensity: 0.7, + colorBurstA: '#dff0ff', + colorBurstB: '#8fc0d8', + colorBurstC: '#2d4a5c', + shockRadius: 5.2, // metres, the ring across the floor + colorShockA: '#e6f4ff', + colorShockB: '#4c8f96', + impactShake: 0.34, + shakeDuration: 0.6, // seconds + impactFlash: 0.14, // black ice is dark; this is nearly off + colorFlash: '#cfe4f0', + rumble: 0.2, // continuous shake while the front is running + + /* --- the light --- */ + lightColor: '#8fc0d8', + lightIntensity: 4.0, + lightRadius: 11.0, + lightPulse: 0.18, // depth of the slow breathing on the light + lightPulseSpeed: 0.55 // cycles/second of it +}; + +/** Editor layout. */ +export const blackiceSchema = { + 'The cast': [ + ['range', 2, 48, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 80, 0.5, 'front speed'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['zoneRadius', 1, 14, 0.1, 'footprint radius'], + ['lifetime', 0.1, 10, 0.05, 'sheet lifetime'], + ['thawTime', 0.1, 6, 0.05, 'thaw time'], + ['castAnim', 'cast animation'] + ], + 'The freeze': [ + ['floodTime', 0.05, 3, 0.01, 'flood (s)'], + ['freezeDelay', 0, 2, 0.01, 'open water first (s)'], + ['freezeTime', 0.05, 6, 0.05, 'freeze (s)'] + ], + 'The mirror': [ + ['roughWet', 0, 1, 0.005, 'roughness — water'], + ['roughIce', 0, 1, 0.005, 'roughness — ice'], + ['reflectWet', 0, 1, 0.01, 'reflectivity — water'], + ['reflectIce', 0, 1, 0.01, 'reflectivity — ice'], + ['blurRadius', 0, 0.2, 0.001, 'blur radius (uv)'], + ['blurTaps', 1, 12, 1, 'blur taps'], + ['roughStretch', 0, 4, 0.05, 'blur stretch'], + ['fresnel', 0, 1, 0.01, 'fresnel'], + ['fresnelPower', 0.2, 8, 0.05, 'fresnel curve'], + ['ripple', 0, 0.15, 0.001, 'ripple (uv)'], + ['rippleScale', 0.1, 8, 0.05, 'ripple cycles/m'], + ['rippleSpeed', 0, 5, 0.05, 'ripple speed'], + ['sheetSpan', 0.2, 1.4, 0.01, 'disc span'], + ['sheetHeight', 0, 0.2, 0.001, 'plane height (m)'], + ['sheetOpacity', 0, 1, 0.01, 'opacity'], + ['sheetEdgeFade', 0.01, 1, 0.01, 'edge fade'], + ['resolution', 64, 1024, 32, 'target edge (px)'], + ['mirrorPriority', 0, 6, 0.05, 'budget priority'], + 'colorTint', + 'colorSheet' + ], + 'The pool': [ + ['poolSpan', 0.2, 1.6, 0.01, 'span'], + ['poolHeight', 0, 0.2, 0.001, 'height (m)'], + ['poolEdge', 0.02, 3, 0.01, 'front feather (m)'], + ['poolRagged', 0, 1, 0.01, 'front wander'], + ['poolRaggedScale', 0.05, 4, 0.01, 'lobes/m'], + ['poolWarp', 0, 3, 0.01, 'domain warp (m)'], + ['poolCell', 0.05, 4, 0.01, 'ripple scale (m)'], + ['poolThickness', 0, 1, 0.005, 'meniscus (m)'], + ['poolLift', 0, 0.5, 0.001, 'wave height (m)'], + ['poolDepth', 0, 1, 0.005, 'body depth (m)'], + ['poolDetail', 0, 1, 0.01, 'detail'], + ['poolSpeed', 0, 6, 0.05, 'ripple rate'], + ['poolFlow', 0, 3, 0.01, 'drift (m/s)'], + ['poolWind', 0, 6.28, 0.01, 'drift bearing'] + ], + 'The pool/Surface': [ + ['poolRelief', 0, 3, 0.01, 'relief'], + ['poolNormalStep', 0.01, 0.4, 0.005, 'normal step (m)'], + ['poolAmbient', 0, 1, 0.01, 'ambient'], + ['poolWrap', 0, 1, 0.01, 'light wrap'], + ['poolSpecular', 0, 3, 0.01, 'specular'], + ['poolGloss', 1, 128, 1, 'gloss'], + ['poolParallax', 0, 1, 0.01, 'parallax'], + ['poolEmissive', 0, 3, 0.01, 'emissive'], + ['poolOpacity', 0, 1, 0.01, 'opacity'], + ['poolDepthFade', 0, 2, 0.01, 'depth fade (m)'], + 'colorPoolBase', + 'colorPoolEdge', + 'colorPoolGlow', + 'colorPoolDeep' + ], + 'The crust': [ + ['crustSpan', 0.2, 1.6, 0.01, 'span'], + ['crustHeight', 0, 0.2, 0.001, 'height (m)'], + ['crustEdge', 0.02, 3, 0.01, 'front feather (m)'], + ['crustRagged', 0, 1, 0.01, 'front wander'], + ['crustRaggedScale', 0.05, 4, 0.01, 'lobes/m'], + ['crustWarp', 0, 3, 0.01, 'domain warp (m)'], + ['crustCell', 0.1, 5, 0.01, 'plate size (m)'], + ['crustCellJitter', 0, 1, 0.01, 'plate irregularity'], + ['crustSeam', 0, 0.4, 0.001, 'seam (m)'], + ['crustThickness', 0, 0.3, 0.001, 'sheet (m)'], + ['crustLift', 0, 0.3, 0.001, 'curl (m)'], + ['crustDetail', 0, 1, 0.01, 'detail'] + ], + 'The crust/Surface': [ + ['crustRelief', 0, 3, 0.01, 'relief'], + ['crustNormalStep', 0.01, 0.4, 0.005, 'normal step (m)'], + ['crustAmbient', 0, 1, 0.01, 'ambient'], + ['crustWrap', 0, 1, 0.01, 'light wrap'], + ['crustSpecular', 0, 3, 0.01, 'specular'], + ['crustGloss', 1, 128, 1, 'gloss'], + ['crustParallax', 0, 1, 0.01, 'parallax'], + ['crustEmissive', 0, 3, 0.01, 'emissive'], + ['crustOpacity', 0, 1, 0.01, 'opacity'], + ['crustDepthFade', 0, 2, 0.01, 'depth fade (m)'], + 'colorCrustBase', + 'colorCrustEdge', + 'colorCrustGlow', + 'colorCrustDeep' + ], + 'Fog': [ + ['mistRate', 0, 300, 1, 'rate'], + ['mistSpeed', 0, 10, 0.05, 'speed'], + ['mistRise', -3, 4, 0.02, 'buoyancy'], + ['mistSize', 0.05, 5, 0.01, 'size'], + ['mistLifetime', 0.1, 6, 0.05, 'lifetime'], + ['mistOpacity', 0, 1, 0.01, 'opacity'], + ['impactMist', 0, 300, 1, 'at the landing'], + 'colorMistA', + 'colorMistB', + 'colorMistC', + 'colorMistD' + ], + 'Crystals': [ + ['glintRate', 0, 300, 1, 'rate'], + ['glintSpeed', 0, 10, 0.05, 'speed'], + ['glintRise', -3, 4, 0.02, 'buoyancy'], + ['glintSize', 0.01, 1, 0.005, 'size'], + ['glintLifetime', 0.1, 6, 0.05, 'lifetime'], + ['glintTurbulence', 0, 4, 0.01, 'turbulence'], + 'colorGlintA', + 'colorGlintB', + 'colorGlintC', + 'colorGlintD' + ], + 'Spray': [ + ['impactSpray', 0, 300, 1, 'at the landing'], + ['thawSpray', 0, 300, 1, 'at the thaw'], + ['spraySpeed', 0, 20, 0.1, 'speed'], + ['sprayGravity', -40, 0, 0.5, 'gravity'], + ['spraySize', 0.02, 1, 0.005, 'size'], + ['sprayLifetime', 0.1, 6, 0.05, 'lifetime'], + 'colorSprayA', + 'colorSprayB', + 'colorSprayC', + 'colorSprayD' + ], + 'Landing': [ + ['burstSize', 0.2, 12, 0.05, 'shell size'], + ['burstIntensity', 0, 4, 0.01, 'shell intensity'], + ['shockRadius', 0.5, 16, 0.1, 'ring radius'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration'], + ['impactFlash', 0, 1.5, 0.01, 'flash'], + ['rumble', 0, 3, 0.01, 'rumble'], + 'colorBurstA', + 'colorBurstB', + 'colorBurstC', + 'colorShockA', + 'colorShockB', + 'colorFlash' + ], + 'The light': [ + ['lightIntensity', 0, 40, 0.1, 'intensity'], + ['lightRadius', 1, 40, 0.5, 'radius'], + ['lightPulse', 0, 1, 0.01, 'breath depth'], + ['lightPulseSpeed', 0, 6, 0.05, 'breath speed'], + 'lightColor' + ] +}; diff --git a/src/config/abilities/bloomburst.js b/src/config/abilities/bloomburst.js new file mode 100644 index 0000000..aec4a92 --- /dev/null +++ b/src/config/abilities/bloomburst.js @@ -0,0 +1,457 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* BLOOMBURST — verdant, far cast */ +/* ================================================================== */ +/** + * A meadow forced open on a clock. + * + * Stalks push up out of the circle, each carrying a closed bud; the buds + * unfurl in a wave that runs from the middle of the footprint out to the + * boundary; the field holds, breathing pollen; and then **every petal in the + * circle lets go on the same frame** and the whole thing becomes a low volume + * of pollen that drifts and settles. + * + * The trick is the unfurl, and it lives entirely in the vertex shader. Each + * petal is stored once, unbent, and posed per frame from a quadratic Bezier + * whose control points lerp from a bud pose to an open pose — so the closed + * shape, the open shape, and the *shape of the journey between them* + * (`unfurlCurve`, the signature slider) are three independent things you can + * drag on a field that is already standing, with the clock stopped. See + * `materials/PetalMaterial.js` for why nothing about the pose can live on the + * CPU: `GrowthField` shares one geometry across every instance of a variant, + * and the bend is a function of time *per instance*. + * + * The wave is `GrowthField.triggerRadial`, and the opening clock is the + * `aBirth` attribute the field already writes — this ability hands `birthFade` + * its own `unfurlTime`, which is what couples the two without a second + * attribute or a second clock. + */ +export const bloomburst = { + /* --- the cast --- */ + range: 21.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 32.0, // how fast the growth front runs to the circle, metres/second + cooldown: 1.4, // seconds + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 4.6, // the footprint the indicator draws, metres + + /* --- the beats, all seconds --- */ + riseTime: 0.34, // stalk from buried to full height + waveTime: 0.72, // for the opening wave to cross the circle, centre to rim + waveStagger: 0.14, // random scatter on top of the wave, per flower + waveInvert: false, // true runs the wave from the boundary inward instead + unfurlTime: 0.62, // one bud from closed to fully open + holdTime: 1.15, // the field standing open before it lets go + releaseTime: 0.55, // every petal leaving at once + witherTime: 1.35, // the stalks folding back into the floor after them + + /* --- the field on the ground --- */ + flowers: 38, // stalks planted per cast (hard ceiling 96) + coreShare: 0.18, // 0..1 of them held back for the middle of the circle + innerFrac: 0.16, // inner edge of the ring, as a fraction of zoneRadius + radialCurve: 0.85, // <1 pushes the ring outward, >1 crowds it inward + radialJitter: 0.42, // metres of radial wander per flower + angleJitter: 0.35, // radians of bearing wander per flower + + /* --- one flower's proportions --- */ + heightNear: 1.28, // metres tall in the middle of the circle + height: 0.94, // ...and out at the boundary + heightCurve: 0.9, // how quickly the height ramps from middle to rim + heightJitter: 0.26, // ± fraction + // The footprint radius scales the *whole* flower sideways — stalk, hinge and + // blade half-width are all fractions of it — so this is really "how big is + // one bloom", and 0.42 m gives an open head about eighty centimetres across. + stalkRadius: 0.42, // metres, footprint radius of a flower in the middle + stalkRadiusTop: 0.34, // metres, the same out at the rim + radiusCurve: 0.75, // how that ramps from middle to rim + radiusJitter: 0.24, // ± fraction + lean: 0.16, // radians a flower leans away from the middle + leanJitter: 0.7, // ± fraction + leanRamp: 0.7, // 0 leans everything, 1 only the boundary + // The lean direction is a normalised blend of these two, so one of them on + // its own does nothing at all — scaling a vector before normalising it is a + // no-op, and `leanOutward` read as a dead slider until `leanForward` was + // given a non-zero default to blend against. Together they aim the whole + // meadow: all-outward is a rosette, all-forward is a field bent by wind. + leanOutward: 0.95, // how much of that lean is radially outward + leanForward: 0.18, // ...and how much is downrange, along the cast + tilt: 0.14, // radians of extra random tip, any bearing + twist: 1.0, // 0..1 of a full turn of random yaw per flower + + /* --- how a stalk arrives --- */ + riseOvershoot: 0.2, // how far past full height the push carries + settle: 0.4, // seconds that overshoot damps out over + springRate: 12.0, // radians/second of the overshoot ring + emergeSink: 0.9, // fraction of its height a stalk is buried at emerge = 0 + birthScale: 0.7, // footprint scale at the moment it breaks the surface + breachAt: 0.22, // emergence fraction that fires the breach puff + sinkDepth: 0.5, // extra metres a withering stalk drops beyond its height + + /* --- the flower, as geometry (these rebuild the mesh) --- */ + petals: 6, // blades per flower; variants carry ±1 around this + petalSegments: 8, // tessellation along one blade + stalkSides: 6, // sides of the stalk prism + + /* --- the flower, as a pose (these are live uniforms, no rebuild) --- */ + // Every length here is a fraction of the flower's own height, so a short + // flower and a tall one are the same shape. + stemFrac: 0.44, // how much of the height is bare stalk + stemRadius: 0.05, // stalk thickness at the floor + heartRadius: 0.09, // radius the petals are hinged on + petalWidth: 0.3, // half-width of a blade at its widest + petalWidthBias: 0.78, // <1 puts the widest point nearer the hinge + petalTaper: 0.7, // >1 sharpens the tip, <1 rounds it + petalCrease: 0.44, // the lengthwise fold, as a fraction of the half-width + petalCup: 0.3, // cross-sectional curl toward the axis, same units + + /* --- the two poses the unfurl runs between --- */ + budMidOut: 0.02, // bud: the mid control point, outward + budMidUp: 0.34, // ...and upward + budTipOut: 0.05, // bud: the tip, outward — near zero is a closed bud + budTipUp: 0.7, // ...and upward + openMidOut: 0.3, // open: the mid control point, outward + openMidUp: 0.44, // ...and upward + openTipOut: 0.64, // open: the tip thrown out past the footprint + openTipUp: 0.14, // ...and dropped, which is what makes it a flower + unfurlCurve: 1.55, // THE slider: >1 holds the bud shut then throws it open + petalStagger: 0.28, // 0..1 ripple between the petals of one flower + + /* --- the release --- */ + releaseThrow: 0.95, // outward travel of a freed petal, in flower heights + releaseLift: 0.6, // ...and upward + releaseSpin: 1.45, // radians it tumbles about its own hinge + releaseShrink: 0.85, // how far it closes to nothing on the way out + stemWilt: 0.6, // how far the stalk folds once the petals have gone + + /* --- petal colour and shading --- */ + colorStem: '#4f7a34', // the stalk + colorPetalBase: '#f7e6f2', // the blade at the hinge + colorPetalMid: '#f2d0e8', // ...across the body + colorPetalTip: '#a84f8a', // ...at the tip + colorVein: '#8a3a6e', // the midrib and the vein fan + colorHeart: '#ffe89a', // the middle of the flower, and the birth flash + veins: 0.5, // strength of the vein pattern + veinCount: 5.0, // ribs across the blade + grain: 0.2, // fibrous world-space variation + grainScale: 7.5, // features per metre of that grain + fresnel: 1.0, // rim strength + fresnelPower: 2.6, // how tight the rim is + translucency: 1.9, // backlit glow through the blade + translucencyPower: 4.0, // how narrowly that faces the sun + petalGlow: 1.0, // overall emissive gain on the flowers + edgeGlow: 0.65, // gain on the rim term alone + heartGlow: 2.6, // gain on the middle of the flower + birthGlow: 1.4, // flash as a bud starts to open + petalOpacity: 1.0, + petalRoughness: 0.6, + envIntensity: 0.7, + + /* --- the pollen volume --- */ + // A raymarched SPORE cylinder standing over the circle. It is barely there + // while the field is only holding, and it is the whole ability for the + // second and a half after the petals let go. + pollenRadius: 1.05, // hull footprint, × zoneRadius + pollenSwell: 1.35, // extra footprint at the peak of the release + pollenLift: 1.9, // hull height, metres + pollenBase: 0.35, // metres above the floor the hull starts + pollenHaze: 0.16, // 0..1 fade while the flowers are merely standing + pollenSettle: 0.55, // 0..1 how far the hull sinks back as it dies + ...volumeHullDefaults('pollen', Medium.SPORE, { + pollenSteps: 30, + pollenDensity: 1.1, + pollenMargin: 0.24, + pollenHeightBias: 0.22, + pollenFlatten: 0.32, + pollenRise: 0.5, + pollenFlowY: -0.1, + pollenNoiseFrequency: 1.5, + pollenSpeckDensity: 0.2, + pollenSpeckScale: 3.4, + pollenSpeckGlow: 9.0, + pollenEmission: 2.6, + pollenColorCore: '#ffe89a', + pollenColorMid: '#e8c878', + pollenColorEdge: '#8a7a3a', + pollenColorDeep: '#241f10', + pollenColorLight: '#fff4c8', + pollenColorSpeck: '#fff0b0' + }), + + /* --- pollen motes, petal confetti and the green haze --- */ + /** + * As everywhere else in the project: each system is coloured by a four-stop + * gradient sampled over the particle's own lifetime, `A` at birth through + * `D` as it dies, spelled out rather than derived from the flower palette so + * the pollen can be made to cool to brown while the petals stay pink. + */ + pollenRate: 62, // motes drifting off the open flowers, particles/second + pollenSize: 0.055, + pollenSpeed: 0.75, // metres/second off the heart + pollenLifetime: 2.1, + pollenDrift: 0.34, // upward drift, metres/second² + pollenTurbulence: 0.8, + pollenGlow: 1.5, + colorPollenA: '#fff6cc', + colorPollenB: '#ffe89a', + colorPollenC: '#d8b45a', + colorPollenD: '#4a3c14', + confettiSize: 0.11, // torn petal fragments + confettiSpeed: 3.4, + confettiLifetime: 2.6, + confettiGravity: -2.6, + confettiSpin: 5.0, + colorConfettiA: '#f7e6f2', + colorConfettiB: '#f2d0e8', + colorConfettiC: '#a84f8a', + colorConfettiD: '#3a1830', + hazeRate: 26, // low green haze off the growing ground + hazeSize: 0.85, + hazeSpeed: 0.6, + hazeLifetime: 2.4, + hazeOpacity: 0.09, + hazeRise: 0.3, + colorHazeA: '#7fc85f', + colorHazeB: '#5f9a44', + colorHazeC: '#3d6b2c', + colorHazeD: '#1e3418', + + /* --- what the ground does --- */ + mossRadius: 0.95, // moss mat under one flower, metres + mossLife: 7.0, // seconds it lingers + mossIntensity: 0.7, + mossSpread: 1.15, // the wide mat under the whole circle, × zoneRadius + colorMoss: '#4a7a2e', + colorMossEdge: '#9fd06a', + shockRadius: 5.5, // the ring pushed out when the field takes hold, metres + colorShockA: '#c8e89a', + colorShockB: '#ffe89a', + + /* --- the muzzle and the release --- */ + muzzleSize: 0.5, // the puff at the caster's hand, metres + muzzleIntensity: 1.4, + handHeight: 1.2, // metres above the floor the cast leaves from + handForward: 0.5, // metres in front of the caster + handSide: 0.18, // metres to the side + castFlash: 0.08, // screen flash on release + colorCastFlash: '#d8f0a0', + burstSize: 3.4, // the shell thrown when the petals let go, metres + burstIntensity: 1.1, + burstPollen: 320, // extra pollen motes at that moment + burstConfetti: 150, // ...and petal fragments + impactShake: 0.35, + shakeDuration: 0.7, + impactFlash: 0.14, + rumble: 0.018, // continuous shake while the growth front travels + colorBurstA: '#e8d09a', + colorBurstB: '#f2d0e8', + colorBurstC: '#ffe89a', + colorFlash: '#ffe89a', + + /* --- dynamic light --- */ + lightIntensity: 12, + lightRadius: 11, + lightColor: '#c8e07a', + lightHeight: 0.75 // 0..1 of a flower's height the light sits at +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Bloomburst. + * + * The folder to reach for first is **The unfurl**. `unfurlCurve` is the + * ability: at 0.4 the buds sag open the moment they clear the ground and the + * field reads as wilting in reverse; at 1.0 they open linearly, which is the + * dullest setting on the panel; at 2.5 they stay shut, hold, and then snap + * open in the last third, which is what a real bud does and what the cast was + * tuned around. The eight `bud*`/`open*` numbers are the two poses that curve + * runs between, and they are worth dragging with **P** held. + */ +export const bloomburstSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'growth front speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['zoneRadius', 1, 14, 0.05, 'footprint radius'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['riseTime', 0.05, 2, 0.01, 'stalk rise'], + ['waveTime', 0.05, 4, 0.01, 'wave across the circle'], + ['waveStagger', 0, 1.5, 0.01, 'per-flower scatter'], + ['waveInvert', 'run the wave rim-inward'], + ['unfurlTime', 0.05, 4, 0.01, 'one bud opening'], + ['holdTime', 0, 8, 0.05, 'hold open'], + ['releaseTime', 0.05, 3, 0.01, 'petals letting go'], + ['witherTime', 0.05, 5, 0.05, 'stalks folding back'] + ], + 'The field': [ + ['flowers', 1, 96, 1, 'flowers'], + ['coreShare', 0, 1, 0.01, 'held for the middle'], + ['innerFrac', 0, 1, 0.01, 'inner edge'], + ['radialCurve', 0.2, 4, 0.01, 'radial crowding'], + ['radialJitter', 0, 3, 0.01, 'radial wander'], + ['angleJitter', 0, 1.5, 0.01, 'bearing wander'] + ], + 'One flower': [ + ['heightNear', 0.1, 5, 0.01, 'height, middle'], + ['height', 0.1, 5, 0.01, 'height, rim'], + ['heightCurve', 0.1, 4, 0.01, 'height curve'], + ['heightJitter', 0, 1, 0.01, 'height jitter'], + ['stalkRadius', 0.02, 2, 0.005, 'footprint, middle'], + ['stalkRadiusTop', 0.02, 2, 0.005, 'footprint, rim'], + ['radiusCurve', 0.1, 4, 0.01, 'footprint curve'], + ['radiusJitter', 0, 1, 0.01, 'footprint jitter'], + ['lean', -1, 1, 0.01, 'lean'], + ['leanJitter', 0, 2, 0.01, 'lean jitter'], + ['leanRamp', 0, 1, 0.01, 'lean ramp'], + ['leanOutward', 0, 2, 0.01, 'lean outward'], + ['leanForward', 0, 2, 0.01, 'lean downrange'], + ['tilt', 0, 1, 0.01, 'random tip'], + ['twist', 0, 1, 0.01, 'random yaw'] + ], + 'How it arrives': [ + ['riseOvershoot', 0, 1.5, 0.01, 'overshoot'], + ['settle', 0.05, 2, 0.01, 'settle'], + ['springRate', 1, 40, 0.5, 'spring rate'], + ['emergeSink', 0, 1.5, 0.01, 'buried depth'], + ['birthScale', 0.1, 1.5, 0.01, 'scale at breach'], + ['breachAt', 0.02, 0.9, 0.01, 'breach point'], + ['sinkDepth', 0, 3, 0.05, 'wither depth'] + ], + 'The flower/Geometry': [ + ['petals', 3, 12, 1, 'petals'], + ['petalSegments', 2, 16, 1, 'blade segments'], + ['stalkSides', 3, 12, 1, 'stalk sides'] + ], + 'The flower/Shape': [ + ['stemFrac', 0.05, 0.9, 0.01, 'bare stalk'], + ['stemRadius', 0.005, 0.3, 0.005, 'stalk thickness'], + ['heartRadius', 0.01, 0.4, 0.005, 'hinge radius'], + ['petalWidth', 0.02, 1, 0.005, 'blade half-width'], + ['petalWidthBias', 0.2, 3, 0.01, 'widest point'], + ['petalTaper', 0.1, 3, 0.01, 'tip taper'], + ['petalCrease', 0, 2, 0.01, 'lengthwise crease'], + ['petalCup', 0, 2, 0.01, 'cross-section cup'] + ], + 'The unfurl': [ + ['unfurlCurve', 0.2, 4, 0.01, 'unfurl curve'], + ['petalStagger', 0, 0.9, 0.01, 'petal ripple'], + ['budMidOut', -0.4, 1, 0.005, 'bud mid, out'], + ['budMidUp', 0, 1.2, 0.005, 'bud mid, up'], + ['budTipOut', -0.4, 1, 0.005, 'bud tip, out'], + ['budTipUp', 0, 1.4, 0.005, 'bud tip, up'], + ['openMidOut', -0.4, 1.5, 0.005, 'open mid, out'], + ['openMidUp', -0.5, 1.2, 0.005, 'open mid, up'], + ['openTipOut', -0.4, 2, 0.005, 'open tip, out'], + ['openTipUp', -1, 1.4, 0.005, 'open tip, up'] + ], + 'The release': [ + ['releaseThrow', 0, 4, 0.01, 'thrown outward'], + ['releaseLift', -2, 4, 0.01, 'thrown upward'], + ['releaseSpin', -6, 6, 0.01, 'tumble'], + ['releaseShrink', 0, 1, 0.01, 'closes to nothing'], + ['stemWilt', 0, 1, 0.01, 'stalk wilt'] + ], + 'Petal colour': [ + ['colorStem', 'stalk'], + ['colorPetalBase', 'blade, hinge'], + ['colorPetalMid', 'blade, body'], + ['colorPetalTip', 'blade, tip'], + ['colorVein', 'veins'], + ['colorHeart', 'heart'], + ['veins', 0, 2, 0.01, 'vein strength'], + ['veinCount', 1, 16, 0.5, 'ribs across'], + ['grain', 0, 1, 0.01, 'fibre grain'], + ['grainScale', 0.5, 30, 0.1, 'grain scale'], + ['fresnel', 0, 4, 0.01, 'rim'], + ['fresnelPower', 0.5, 8, 0.05, 'rim tightness'], + ['translucency', 0, 6, 0.01, 'backlit glow'], + ['translucencyPower', 0.5, 16, 0.1, 'backlit tightness'], + ['petalGlow', 0, 4, 0.01, 'glow'], + ['edgeGlow', 0, 3, 0.01, 'rim glow'], + ['heartGlow', 0, 8, 0.01, 'heart glow'], + ['birthGlow', 0, 6, 0.01, 'opening flash'], + ['petalOpacity', 0, 1, 0.01, 'opacity'], + ['petalRoughness', 0.02, 1, 0.01, 'roughness'], + ['envIntensity', 0, 3, 0.01, 'env intensity'] + ], + 'The pollen volume': [ + ['pollenRadius', 0.2, 3, 0.01, 'hull footprint'], + ['pollenSwell', 1, 3, 0.01, 'swell at release'], + ['pollenLift', 0.2, 8, 0.05, 'hull height'], + ['pollenBase', 0, 4, 0.01, 'hull base height'], + ['pollenHaze', 0, 1, 0.01, 'idle fade'], + ['pollenSettle', 0, 1, 0.01, 'settle as it dies'] + ], + ...volumeHullSchema('pollen', { + label: 'The pollen volume', + only: ['march', 'shape', 'field', 'flow', 'optics', 'speck', 'colour'] + }), + 'Pollen & confetti': [ + ['pollenRate', 0, 600, 1, 'pollen rate'], + ['pollenSize', 0.005, 0.4, 0.005, 'pollen size'], + ['pollenSpeed', 0, 8, 0.05, 'pollen speed'], + ['pollenLifetime', 0.1, 8, 0.05, 'pollen lifetime'], + ['pollenDrift', -2, 4, 0.01, 'pollen drift'], + ['pollenTurbulence', 0, 3, 0.01, 'pollen turbulence'], + ['pollenGlow', 0, 4, 0.01, 'pollen glow'], + ['colorPollen*', 'Pollen colour'], + ['confettiSize', 0.005, 0.5, 0.005, 'confetti size'], + ['confettiSpeed', 0, 20, 0.1, 'confetti speed'], + ['confettiLifetime', 0.1, 8, 0.05, 'confetti lifetime'], + ['confettiGravity', -20, 2, 0.1, 'confetti gravity'], + ['confettiSpin', 0, 20, 0.1, 'confetti spin'], + ['colorConfetti*', 'Confetti colour'] + ], + 'Green haze': [ + ['hazeRate', 0, 300, 1, 'haze rate'], + ['hazeSize', 0.05, 4, 0.01, 'haze size'], + ['hazeSpeed', 0, 6, 0.05, 'haze speed'], + ['hazeLifetime', 0.2, 8, 0.05, 'haze lifetime'], + ['hazeOpacity', 0, 1, 0.005, 'haze opacity'], + ['hazeRise', -2, 4, 0.01, 'haze rise'], + ['colorHaze*', 'Haze colour'] + ], + 'The ground': [ + ['mossRadius', 0.05, 4, 0.05, 'moss under a flower'], + ['mossLife', 0.5, 20, 0.1, 'moss lifetime'], + ['mossIntensity', 0, 2, 0.01, 'moss intensity'], + ['mossSpread', 0.2, 3, 0.01, 'moss under the circle'], + ['shockRadius', 0.5, 20, 0.1, 'ring radius'], + ['colorMoss', 'moss'], + ['colorMossEdge', 'moss edge'], + ['colorShockA', 'ring body'], + ['colorShockB', 'ring crest'] + ], + 'Muzzle & release': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['muzzleSize', 0.05, 5, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstPollen', 0, 900, 1, 'burst pollen'], + ['burstConfetti', 0, 600, 1, 'burst confetti'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst motes'], + ['colorFlash', 'burst flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 2, 0.01, 'light height'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/bonecage.js b/src/config/abilities/bonecage.js new file mode 100644 index 0000000..08b58c0 --- /dev/null +++ b/src/config/abilities/bonecage.js @@ -0,0 +1,392 @@ +/* ================================================================== */ +/* BONE CAGE — blood, far cast */ +/* ================================================================== */ +/** + * The slot that exists to prove a school is not a palette. + * + * Crimson Tide is the other blood zone cast and it is viscous, wet, red and + * self-lit. This is bone, and every one of those four is inverted deliberately: + * + * | Crimson Tide | Bone Cage | + * | --- | --- | + * | a specular sheen keyed off the surface normal | no sheen term at all | + * | roughness pulled *down* in the wet grooves | `boneRoughness` pinned at 0.94 and pushed **up** in the pits | + * | emissive sap glowing in the dark | scatter clamped by `sssCeiling`, below the bloom threshold | + * | a fresnel rim brightening the silhouette | `boneChalk`, a fresnel-shaped curve on the **albedo** | + * + * The last of those is the one worth understanding, because it is the whole + * difference between chalk and matte plastic. A dielectric gets shinier at + * grazing incidence. A porous mineral gets *lighter*: light that would have + * refracted into a smooth surface is scattered straight back out of the pores a + * fraction of a millimetre away instead. So the fresnel curve is applied to + * `diffuseColor` and nothing at all is added to the specular lobe. Take + * `boneChalk` to zero under the same lights and the ribs turn into grey rubber. + * + * `sssStrength` is the warmth — the cheap back-scatter transmission term, + * modulated by a thickness field that is genuinely thin near the tips, near the + * silhouettes and wherever the trabecular noise says the interior is open. It + * is the one warm thing in a bleached object, and it is what stops the cage + * reading as plaster. It is also **capped**, in the shader, by `sssCeiling`, + * because bone is lit and bone does not glow — and that is enforced rather than + * trusted to the tuning staying sensible. Turning `global.glow` up cannot make + * this material bloom. + * + * The shape of the cage is one slider: `GrowthField` scales an instance's lean + * by its own radial fraction, so `ribLeanShut` lays the rim over hard and + * leaves the middle standing nearly upright, and the ring becomes a dome. The + * ribs are straight — see `createBoneRibGeometry` for the rotation arithmetic + * that forced that, and for what a baked-in sickle actually looked like. + * + * Four draw calls: three rib silhouettes and a `GroundField(POCK)` carrying one + * crater per rib. + */ +export const bonecage = { + /* --- the cast --- */ + range: 16.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 26.0, // how fast the call runs to the circle, metres/second + cooldown: 2.0, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 5.0, // the footprint the indicator draws, metres + + /* --- the beats, all seconds --- */ + raiseTime: 0.75, // the ring coming up, rim first + closeTime: 1.2, // it laying over into a dome + holdTime: 4.2, // it standing + crumbleTime: 2.6, // it going back under + + /* --- the ring --- */ + ribCount: 60, // how many ribs. 288 is the field's hard ceiling + ribRing: 1.0, // outer radius, × zoneRadius + ribInner: 0.34, // inner radius, × zoneRadius — 0 fills the disc with spikes + ribRadialCurve: 0.85, // <1 pushes the band toward the rim + ribRadialJitter: 0.22, // metres of radial wander + ribAngleJitter: 0.5, // radians of bearing wander + riseStagger: 0.35, // seconds of random delay between neighbours + + /* --- one rib, in metres --- */ + ribLength: 3.4, // at the rim + ribLengthInner: 1.5, // at the inner radius + ribLengthCurve: 1.0, // how late the ramp climbs + ribLengthJitter: 0.22, // ± fraction + ribGirth: 0.34, // base radius at the rim + ribGirthInner: 0.2, // at the inner radius + ribGirthCurve: 0.7, + ribGirthJitter: 0.28, // ± fraction + + /* --- how it lays over --- */ + ribLeanOpen: 0.16, // radians inward at the moment it breaks the floor + ribLeanShut: 1.15, // radians inward once the cage is closed + ribLeanJitter: 0.16, // ± fraction + ribLeanRamp: 0.82, // 0 leans everything alike, 1 only leans the rim + ribRoll: 0.55, // 0..1 of a full turn of random roll about its own axis + ribTilt: 0.13, // radians of extra random tip, any bearing + + /* --- the eruption --- */ + ribRiseTime: 0.26, // seconds from buried to full height + ribOvershoot: 0.2, // how far past full height the punch carries + ribSettle: 0.45, // seconds the overshoot damps out over + ribSpring: 15.0, // radians/second of that overshoot ring + ribSink: 0.9, // fraction of its height a rib is buried at emergence 0 + ribBirthScale: 0.88, // footprint scale the moment it breaks through + ribBirthFade: 0.55, // seconds the grime on a fresh rib takes to shed + ribBreachAt: 0.22, // emergence fraction that cracks the floor + ribSinkDepth: 0.5, // extra metres it drops as the cage goes back under + + /* --- the rib's silhouette (rebuilt only when one of these moves) --- */ + ribSides: 7, // facets around the section + ribRings: 12, // rings up its length + ribFlatten: 0.44, // 0..1 how much the section is a blade rather than a rod + ribTwist: 0.18, // turns of the section along the length + ribGroove: 0.3, // 0..1 depth of the costal groove down one face + ribHead: 1.05, // the articular bulb, × the shaft + ribNeck: 0.52, // the pinch above it — the stroke that says "jointed" + ribShaft: 0.7, // the blade + ribTaper: 0.1, // the tip + ribKnuckle: 0.2, // the sternal swell before the end + ribWarp: 0.28, // 0..1 surface irregularity. 0 is a lathe + + /* --- the bone itself --- */ + boneRoughness: 0.94, // chalk. There is deliberately no wet value to mix to + boneRoughnessPit: 0.4, // extra roughness inside a pit + boneEnv: 0.25, // envMapIntensity — low, because bone reflects almost nothing + boneOpacity: 1.0, + boneGrain: 0.35, // Haversian grain, running up the bone + boneGrainScale: 6.5, // along-frequency + boneGrainBands: 2.2, // around-frequency — must stay well under the along one + bonePit: 0.45, // trabecular pitting + bonePitScale: 24.0, // cycles per metre, world space, so ribs match each other + bonePitCut: 0.55, // 0..1 how sparse the pits are + boneChalk: 0.55, // the anti-fresnel. Zero makes it grey rubber + boneChalkPower: 2.6, // how tight to the silhouette that lift sits + stainAmount: 0.7, // 0..1 how dark the earth stain at the buried end is + stainHeight: 0.26, // 0..1 of the rib it reaches. A rib stained end to end is painted + grimeAmount: 0.85, // 0..1 how filthy a rib is the moment it breaks the floor + colorBone: '#e8dcc4', // cortex + colorBoneShade: '#a8977c', // grain and the inside of a pit + colorMarrow: '#e0745a', // the warm thing the scatter is made of + colorStain: '#6b5a44', // earth, at the buried end only + colorGrime: '#4a4238', // the floor a fresh rib comes up wearing + + /* --- subsurface scatter --- */ + sssStrength: 0.9, + sssPower: 3.2, // tightness of the back-scatter lobe + sssDistort: 0.35, // how much the normal bends the transmission direction + sssAmbient: 0.18, // the part that survives with no key behind it + sssCeiling: 0.5, // hard clamp. This is the anti-glow contract for the slot + sssThinBase: 0.15, // thickness at the buried end, 0..1 (1 = paper) + sssThinEdge: 0.65, // how much the silhouette counts as thin + + /* --- the crumble --- */ + witherDepth: 1.0, // 0..1 how far the scatter is drained as it dies + crumbleFade: 0.6, // 0..1 of the opacity lost on the way down + + /* --- the cracked floor --- */ + floorRadius: 1.08, // × zoneRadius + floorHeight: 0.014, // metres above the floor the quad sits at + floorEdge: 0.5, // metres of feather at the boundary + floorRagged: 0.3, // how far that boundary wanders, × the radius + floorRaggedScale: 0.55, // lobes per metre + floorWarp: 0.6, // metres of domain warp on those lobes + floorRelief: 0.85, // how hard the height field tilts the fake normal + floorNormalStep: 0.05, // metres between the height taps + floorAmbient: 0.3, + floorSpecular: 0.18, // dry. A shiny crater is a wet crater + floorGloss: 16, + floorParallax: 0.3, // metres of view-driven offset inside a crater + floorCell: 0.4, // metres — the grain + floorLift: 0.06, // metres — rim height + floorDepth: 0.16, // metres — crater depth + floorDetail: 0.6, + floorSharp: 0.55, + floorSpeed: 1.0, // dig rate + floorMarkLife: 9.0, // seconds a crater weathers away over + floorMarkRadius: 0.55, // metres, a full-strength crater + floorEmissive: 0.0, // zero, and it stays zero: POCK's glow channel is ember + floorOpacity: 0.9, + floorDepthFade: 0.5, // metres of soft fade against standing geometry + colorFloorBase: '#4a4640', // broken flagstone + colorFloorEdge: '#8f887c', // the rim thrown up around a hole + colorFloorGlow: '#000000', // nothing here is hot + colorFloorDeep: '#100e0c', // the bottom of the hole + + /* --- the chips a rib throws as it comes through --- */ + breachGrit: 9, // chips per rib + gritSpeed: 3.4, // metres/second + gritLifetime: 1.5, // seconds + gritGravity: -13.0, // metres/second² + gritSize: 0.075, + colorGritA: '#8f877a', // birth + colorGritB: '#615b52', // early + colorGritC: '#3a3630', // late + colorGritD: '#181614', // death + + /* --- stone and bone dust --- */ + breachDust: 5, // puffs per rib + dustRate: 14.0, // per second off a standing cage + dustBirthHeight: 0.06, // metres + dustSpeed: 1.1, // metres/second + dustLifetime: 2.8, // seconds + dustSize: 0.6, + dustRise: 0.35, // metres/second² + dustOpacity: 0.24, + colorDustA: '#b8ae9c', // birth + colorDustB: '#877e70', // early + colorDustC: '#4e483f', // late + colorDustD: '#1c1a17', // death + + /* --- the hand, and the one-shot --- */ + handHeight: 1.2, // metres + handForward: 0.5, // metres down the heading + handSide: 0.32, // metres lateral + muzzleSize: 0.6, + muzzleIntensity: 0.7, + castFlash: 0.04, // screen flash on release + colorCastFlash: '#c8b89c', + burstSize: 3.2, // the pressure shell as the ring comes up + burstIntensity: 0.6, + impactShake: 0.4, + shakeDuration: 0.8, // seconds + impactFlash: 0.06, + rumble: 0.03, // travel rumble + colorBurstA: '#d8ccb4', + colorBurstB: '#6b6252', + colorBurstC: '#f0e4cc', + colorFlash: '#d8c4a8', + + /* --- the dynamic light --- */ + lightIntensity: 7.0, + lightRadius: 9.0, // metres + lightHeight: 0.35, // metres — on the floor, inside the cage + lightSmother: 0.5, // 0..1 how much the closing cage puts it out + lightColor: '#f8c0b0' +}; + +/** Editor layout. */ +export const bonecageSchema = { + 'The cast': ['range', 'minRange', 'speed', 'cooldown', 'castAnim', ['zoneRadius', 1, 12, 0.1, 'zone radius (m)']], + 'The beats': [ + ['raiseTime', 0.1, 4, 0.05, 'raise (s)'], + ['closeTime', 0.1, 6, 0.05, 'close (s)'], + ['holdTime', 0.2, 16, 0.1, 'hold (s)'], + ['crumbleTime', 0.2, 12, 0.1, 'crumble (s)'] + ], + 'The ring': [ + ['ribCount', 4, 240, 1, 'ribs'], + ['ribRing', 0.2, 2, 0.01, 'outer radius (× zone)'], + ['ribInner', 0, 1, 0.01, 'inner radius (× zone)'], + ['ribRadialCurve', 0.2, 3, 0.01, 'radial bunching'], + ['ribRadialJitter', 0, 2, 0.01, 'radial wander (m)'], + ['ribAngleJitter', 0, 2, 0.01, 'bearing wander (rad)'], + ['riseStagger', 0, 2, 0.01, 'stagger (s)'] + ], + 'One rib': [ + ['ribLength', 0.2, 8, 0.05, 'length at rim (m)'], + ['ribLengthInner', 0.1, 6, 0.05, 'length inside (m)'], + ['ribLengthCurve', 0.2, 3, 0.01, 'length ramp'], + ['ribLengthJitter', 0, 1, 0.01, 'length jitter'], + ['ribGirth', 0.02, 1.5, 0.01, 'girth at rim (m)'], + ['ribGirthInner', 0.02, 1.5, 0.01, 'girth inside (m)'], + ['ribGirthCurve', 0.2, 3, 0.01, 'girth ramp'], + ['ribGirthJitter', 0, 1, 0.01, 'girth jitter'] + ], + 'The close': [ + ['ribLeanOpen', -0.5, 1.6, 0.01, 'lean at breach (rad)'], + ['ribLeanShut', -0.5, 1.8, 0.01, 'lean when shut (rad)'], + ['ribLeanJitter', 0, 1, 0.01, 'lean jitter'], + ['ribLeanRamp', 0, 1, 0.01, 'rim-only lean'], + ['ribRoll', 0, 1, 0.01, 'random roll'], + ['ribTilt', 0, 1, 0.01, 'random tip (rad)'] + ], + 'The eruption': [ + ['ribRiseTime', 0.02, 2, 0.01, 'rise (s)'], + ['ribOvershoot', 0, 1, 0.01, 'overshoot'], + ['ribSettle', 0.05, 3, 0.01, 'settle (s)'], + ['ribSpring', 2, 40, 0.5, 'spring rate'], + ['ribSink', 0, 1.5, 0.01, 'buried depth'], + ['ribBirthScale', 0.2, 1.2, 0.01, 'birth scale'], + ['ribBirthFade', 0.05, 3, 0.01, 'grime shed (s)'], + ['ribBreachAt', 0.02, 1, 0.01, 'breach at'], + ['ribSinkDepth', 0, 3, 0.01, 'sink depth (m)'] + ], + 'The rib silhouette': [ + ['ribSides', 4, 14, 1, 'facets'], + ['ribRings', 5, 24, 1, 'rings'], + ['ribFlatten', 0, 0.9, 0.01, 'blade flatten'], + ['ribTwist', -1, 1, 0.01, 'section twist'], + ['ribGroove', 0, 0.8, 0.01, 'costal groove'], + ['ribHead', 0.2, 2, 0.01, 'articular head'], + ['ribNeck', 0.05, 1.5, 0.01, 'neck'], + ['ribShaft', 0.1, 1.5, 0.01, 'shaft'], + ['ribTaper', 0.02, 0.8, 0.01, 'tip'], + ['ribKnuckle', 0, 0.8, 0.01, 'sternal knuckle'], + ['ribWarp', 0, 1, 0.01, 'irregularity'] + ], + 'The bone': [ + ['boneRoughness', 0.2, 1, 0.01, 'roughness'], + ['boneRoughnessPit', 0, 0.6, 0.01, 'pit roughness'], + ['boneEnv', 0, 2, 0.01, 'env intensity'], + ['boneOpacity', 0, 1, 0.01, 'opacity'], + ['boneChalk', 0, 2, 0.01, 'chalk (anti-fresnel)'], + ['boneChalkPower', 0.2, 8, 0.05, 'chalk tightness'], + ['boneGrain', 0, 1, 0.01, 'grain'], + ['boneGrainScale', 0.5, 24, 0.1, 'grain along'], + ['boneGrainBands', 0.2, 12, 0.1, 'grain around'], + ['bonePit', 0, 1, 0.01, 'pitting'], + ['bonePitScale', 2, 80, 0.5, 'pit pitch (cycles/m)'], + ['bonePitCut', 0, 1, 0.01, 'pit sparsity'], + ['stainAmount', 0, 1, 0.01, 'earth stain'], + ['stainHeight', 0, 1, 0.01, 'stain reach'], + ['grimeAmount', 0, 1, 0.01, 'grime on breach'], + ['colorBone', 'cortex'], + ['colorBoneShade', 'grain / pit'], + ['colorMarrow', 'subsurface warmth'], + ['colorStain', 'earth stain'], + ['colorGrime', 'grime on a fresh rib'] + ], + 'Subsurface scatter': [ + ['sssStrength', 0, 3, 0.01, 'strength'], + ['sssPower', 0.2, 12, 0.1, 'lobe tightness'], + ['sssDistort', 0, 1.5, 0.01, 'normal distortion'], + ['sssAmbient', 0, 1, 0.01, 'ambient term'], + ['sssCeiling', 0, 1.5, 0.01, 'hard ceiling (anti-glow)'], + ['sssThinBase', 0, 1, 0.01, 'thickness at base'], + ['sssThinEdge', 0, 1, 0.01, 'silhouette thinness'] + ], + 'The crumble': [ + ['witherDepth', 0, 1, 0.01, 'wither'], + ['crumbleFade', 0, 1, 0.01, 'opacity lost'] + ], + 'The cracked floor': [ + ['floorRadius', 0.2, 3, 0.01, 'radius (× zone)'], + ['floorHeight', 0, 0.2, 0.002, 'hover height (m)'], + ['floorEdge', 0.02, 3, 0.01, 'boundary feather (m)'], + ['floorRagged', 0, 1, 0.01, 'boundary raggedness'], + ['floorRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['floorWarp', 0, 3, 0.01, 'domain warp (m)'], + ['floorRelief', 0, 2, 0.01, 'relief'], + ['floorNormalStep', 0.005, 0.4, 0.005, 'normal tap (m)'], + ['floorAmbient', 0, 1, 0.01, 'ambient'], + ['floorSpecular', 0, 2, 0.01, 'specular'], + ['floorGloss', 2, 128, 1, 'gloss'], + ['floorParallax', 0, 2, 0.01, 'parallax (m)'], + ['floorCell', 0.05, 3, 0.01, 'grain (m)'], + ['floorLift', 0, 0.5, 0.005, 'rim height (m)'], + ['floorDepth', 0, 1, 0.005, 'crater depth (m)'], + ['floorDetail', 0, 1, 0.01, 'detail'], + ['floorSharp', 0, 1, 0.01, 'sharpness'], + ['floorSpeed', 0, 6, 0.05, 'dig rate'], + ['floorMarkLife', 0.5, 30, 0.1, 'crater life (s)'], + ['floorMarkRadius', 0.05, 3, 0.01, 'crater radius (m)'], + ['floorEmissive', 0, 2, 0.01, 'emissive'], + ['floorOpacity', 0, 1, 0.01, 'opacity'], + ['floorDepthFade', 0.02, 3, 0.01, 'soft intersection (m)'], + ['colorFloorBase', 'broken flagstone'], + ['colorFloorEdge', 'crater rim'], + ['colorFloorGlow', 'ember (kept black)'], + ['colorFloorDeep', 'the hole'] + ], + 'Chips & dust': [ + ['breachGrit', 0, 60, 1, 'chips per rib'], + ['gritSpeed', 0, 12, 0.1, 'chip speed (m/s)'], + ['gritLifetime', 0.1, 6, 0.05, 'chip life (s)'], + ['gritGravity', -30, 0, 0.5, 'chip gravity'], + ['gritSize', 0.01, 0.4, 0.005, 'chip size'], + ['colorGrit*', 'Chip colour'], + ['breachDust', 0, 40, 1, 'dust per rib'], + ['dustRate', 0, 120, 1, 'dust rate'], + ['dustBirthHeight', 0, 1, 0.005, 'dust birth height (m)'], + ['dustSpeed', 0, 6, 0.05, 'dust speed (m/s)'], + ['dustLifetime', 0.2, 10, 0.05, 'dust life (s)'], + ['dustSize', 0.05, 3, 0.01, 'dust size'], + ['dustRise', -1, 2, 0.01, 'dust rise'], + ['dustOpacity', 0, 0.6, 0.005, 'dust opacity'], + ['colorDust*', 'Dust colour'] + ], + 'Hand & shell': [ + ['handHeight', 0, 3, 0.01, 'hand height (m)'], + ['handForward', -1, 3, 0.01, 'hand forward (m)'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral (m)'], + ['muzzleSize', 0.05, 4, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 4, 0.01, 'muzzle intensity'], + ['castFlash', 0, 1, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 12, 0.05, 'pressure shell'], + ['burstIntensity', 0, 4, 0.01, 'shell intensity'], + ['impactShake', 0, 2, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration (s)'], + ['impactFlash', 0, 1, 0.01, 'screen flash'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['colorBurstA', 'shell'], + ['colorBurstB', 'shell body'], + ['colorBurstC', 'shell motes'], + ['colorFlash', 'impact flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius (m)'], + ['lightHeight', 0, 4, 0.01, 'light height (m)'], + ['lightSmother', 0, 1, 0.01, 'smothered by the cage'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/boulder.js b/src/config/abilities/boulder.js new file mode 100644 index 0000000..032833a --- /dev/null +++ b/src/config/abilities/boulder.js @@ -0,0 +1,340 @@ +/* ================================================================== */ +/* ROLLING RUIN — stone, line cast */ +/* ================================================================== */ +/** + * A boulder is put on the floor and shoved down the aimed line. It rolls, + * gouges, throws chips out from under itself, rumbles, and breaks into pieces + * of itself when it arrives. + * + * Three numbers in this block are **not** here, and their absence is the + * design: + * + * - **no spin rate.** A rolling body's rotation is `distance / radius` and + * nothing else — see the class comment on `BoulderAbility`. A slider for it + * would be a slider for how badly the rock skates. + * - **no rut depth curve.** The rut's depth follows the contact load, and the + * contact load is the boulder's own speed against `loadSpeed`. `pathCurve` + * is what makes it accelerate, so the gouge deepens down the track for a + * physical reason rather than a scripted one. + * - **no chatter pitch.** The marks the rim prints into the floor are spaced + * at the body's own circumference, `2π · radius · rutChatter`. `rutChatter` + * is a fudge on that (1 = exactly one print per turn), not a length. + * + * The `rut*` family drives `vfx/GroundField.js` in `RUT` mode through a params + * object the ability fills every frame, and the `debris*`/`chunk*` families + * drive `vfx/ShatterField.js` the same way — canonical names on the module + * side, prefixed names here, so two consumers of "width" cannot collide. + */ +export const boulder = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 12.0, // how fast the *front* runs down the line, metres/second + holdTime: 0.5, // seconds the pile is held after the pieces stop flying + rutFadeTime: 1.4, // seconds the gouge takes to fade out of the floor + cooldown: 1.4, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the rock --- */ + radius: 0.62, // metres. Drives the roll, the rut width and the chatter pitch + handForward: 1.1, // metres in front of the caster it is put down + handSide: 0.0, // metres to the side (+ follows `Ability#side`) + pathCurve: 1.4, // >1 accelerates down the track — this is what deepens the rut + flash: 0.14, // seconds the birth pop on the body decays over + rockDetail: 2, // icosphere subdivisions, 0–3 + rockLumps: 0.28, // low-frequency deformation, × the radius + rockLumpScale: 1.5, // lumps per unit radius + rockRough: 0.2, // high-frequency chipping + rockCuts: 6, // planar fracture faces sliced off it + rockCutDepth: 0.22, // how far in those planes bite, × the radius + rockCraters: 4, // impact bowls punched into it + rockCraterDepth: 0.16, // how deep those bowls go, × the radius + colorRock: '#6e6455', // the stone itself + + /* --- how hard it is pressing --- */ + loadSpeed: 16.5, // metres/second at which the rut is at full depth + loadCurve: 1.3, // >1 keeps the gouge shallow until it is really moving + loadPeak: 1.0, // ceiling on the load, 0..1 + + /* --- the rut it leaves --- */ + rutWidth: 0.5, // half-width of the gouged floor, metres + rutDepth: 0.3, // metres at full contact load + rutSharp: 0.55, // 0..1 how squarely the gouge's walls fall away + rutChatter: 1.0, // prints per turn of the rim — 1 is the true circumference + rutChatterDepth: 0.55, // 0..1 how deeply those prints show + rutSpoil: 0.13, // metres of spoil ridge heaped either side + rutSpoilWidth: 0.15, // metres wide that ridge is + rutSampleBlend: 0.9, // metres over which two contact samples blend together + rutSamples: 14, // contact samples posted along the track (capped at 16) + rutDrift: 0.0, // the track's own lateral wander — off; this rock rolls straight + rutEdge: 0.35, // metres of feather at the head of the gouge + rutRagged: 0.18, // how far the edge wanders, as a fraction of the radius + rutRaggedScale: 0.7, // lobes per metre + rutWarp: 0.35, // metres of domain warp on those lobes + rutRelief: 0.75, // how hard the height field tilts the fake normal + rutNormalStep: 0.05, // metres between the height taps + rutAmbient: 0.3, // floor on the diffuse term + rutWrap: 0.45, // 0..1 wraps the terminator round the back + rutSpecular: 0.35, + rutGloss: 20, // Blinn exponent + rutParallax: 0.25, // metres of view-driven offset on the interior detail + rutEmissive: 1.0, // multiplier on the churn under the body + rutOpacity: 1.0, + rutDepthFade: 0.5, // metres of soft fade against standing geometry + rutHeight: 0.012, // metres above the floor the quad sits at + colorRutBase: '#6e6455', // the churned floor + colorRutEdge: '#a89880', // the spoil ridges either side + colorRutChurn: '#c8a878', // the ground still moving right under the body + colorRutDeep: '#241f1a', // the bottom of the gouge + + /* --- what it breaks into --- */ + debrisCount: 80, // fragments thrown on arrival + debrisSize: 0.19, // metres, a full-size fragment + debrisSizeJitter: 0.6, // ± fraction + debrisSpeed: 5.5, // metres/second + debrisSpeedJitter: 0.55, // ± fraction + debrisSpread: 0.34, // 0 throws everything downrange, 1 is fully random + debrisUp: 0.45, // how much +Y is folded into the throw, 0..1 + debrisInherit: 0.45, // how much of the boulder's own velocity a piece keeps + debrisScatter: 0.9, // where a piece starts, × the body radius + debrisHeight: 0.7, // how high it starts, × the body radius + debrisGravity: -21.0, // metres/second², signed + debrisDrag: 0.6, // 1/second; 0 is pure ballistics + debrisShrink: 0.35, // 0..1 of its size lost by the end of life + debrisShrinkPower: 2.0, // how late that shrink bites + debrisSpin: 7.0, // radians/second of tumble + debrisSpinJitter: 0.8, // ± fraction + debrisFloorSpin: 0.2, // fraction of the tumble kept once it is on the floor + debrisLifetime: 1.7, // seconds a fragment lives + chunkGlow: 1.0, // emissive gain on a fragment + chunkRim: 0.5, // strength of its rim light + chunkRimPower: 2.6, // how tight that rim is + chunkShade: 1.0, // how hard the key light shades it + chunkAmbient: 0.32, // floor on that shading + chunkFadeStart: 0.72, // 0..1 of its life before it starts to fade + chunkSoft: 0.2, // metres of depth feather against the floor + colorChunkA: '#6e6455', // a lit face + colorChunkB: '#39332b', // a shaded one + colorChunkEdge: '#a89880', // the fresh fracture edge + colorChunkTint: '#4a4239', // the tint behind it, if a scene sample ever exists + + /* --- the plume off the contact patch --- */ + /** + * As everywhere else, each system is coloured by a four-stop gradient over + * the particle's own lifetime, `A` at birth through `D` as it dies. Spelled + * out rather than derived from the stone palette, so the dust can be made to + * settle browner than the rock that threw it. + */ + dustRate: 150, // particles/second at full contact load + dustSize: 0.95, + dustSpeed: 2.6, + dustLifetime: 2.2, + dustOpacity: 0.28, + dustRise: 0.55, // upward drift, metres/second + dustTurbulence: 0.7, + dustHeight: 0.14, // metres above the floor it leaves the contact patch at + dustBack: 0.8, // how far behind the rock the plume is thrown, 0..1 + colorDustA: '#a89880', + colorDustB: '#8a7f6b', + colorDustC: '#5d554a', + colorDustD: '#39332b', + + /* --- chips spat out from under it --- */ + chipRate: 70, // particles/second at full contact load + chipSize: 0.055, + chipSpeed: 5.5, + chipLifetime: 1.2, + chipGravity: -19.0, + chipSpray: 0.6, // how far out to the side they are thrown, 0..1 + chipBack: 0.5, // and how far behind, 0..1 + colorChipA: '#6e6455', + colorChipB: '#4a4239', + colorChipC: '#39332b', + colorChipD: '#241f1a', + + /* --- the shove and the arrival --- */ + muzzleSize: 0.9, // the thud where it is set down, metres + muzzleIntensity: 1.0, + burstSize: 2.6, // the ball of dust where it breaks, metres + burstIntensity: 1.2, + burstChips: 90, // extra chips thrown on arrival + burstDust: 45, // and dust + crackRadius: 2.4, // the star of cracks under the impact, metres + crackWidth: 0.5, // how finely it splits into filaments + crackLife: 6.0, // seconds it lingers + crackIntensity: 0.85, + impactDust: 3.0, // the dust ring it lands in, metres + impactShake: 0.9, + shakeDuration: 0.5, // seconds that punch decays over + impactFlash: 0.08, // screen flash on arrival — small; this is a rock, not a bomb + colorFlash: '#c8a878', + rumble: 0.11, // continuous shake at `loadSpeed`, scaled by actual speed + settleRumble: 0.02, // what is left of it while the pile settles + settleDust: 0.5, // dust still rolling off the pile, × the travelling rate + + /* --- dynamic light --- */ + // Low on purpose. A boulder emits nothing; this is a soft warm bounce that + // gives the dust plume something to catch, and it rides with the rock. + lightIntensity: 7, + lightRadius: 9, + lightColor: '#c8a878', + lightHeight: 0.6 // metres above the floor the light sits at +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Rolling Ruin. + * + * Start with `radius` — it is the only control in the block that moves five + * other things at once, on purpose: the rock, the rotation rate that keeps it + * from skating, the width of what it throws, where the pieces start, and the + * pitch of the chatter marks in the rut. Then `pathCurve` and `loadSpeed`, + * which together decide whether the gouge is a scratch that deepens into a + * trench or a trench the whole way. + */ +export const boulderSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 2, 60, 0.1, 'roll speed'], + ['holdTime', 0.05, 4, 0.01, 'hold after landing'], + ['rutFadeTime', 0.1, 6, 0.01, 'rut fade'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The rock': [ + ['radius', 0.1, 2.5, 0.01, 'radius (m)'], + ['handForward', -1, 4, 0.01, 'set down at (m)'], + ['handSide', -2, 2, 0.01, 'lateral offset (m)'], + ['pathCurve', 0.4, 3, 0.01, 'acceleration curve'], + ['flash', 0.01, 1, 0.01, 'birth pop (s)'], + ['rockDetail', 0, 3, 1, 'subdivisions'], + ['rockLumps', 0, 0.8, 0.01, 'lumpiness'], + ['rockLumpScale', 0.2, 5, 0.01, 'lumps / radius'], + ['rockRough', 0, 0.8, 0.01, 'surface chipping'], + ['rockCuts', 0, 14, 1, 'fracture facets'], + ['rockCutDepth', 0, 0.6, 0.01, 'facet depth'], + ['rockCraters', 0, 10, 1, 'craters'], + ['rockCraterDepth', 0, 0.5, 0.01, 'crater depth'], + ['colorRock', 'stone'] + ], + 'Contact load': [ + ['loadSpeed', 1, 60, 0.1, 'full-depth speed (m/s)'], + ['loadCurve', 0.1, 4, 0.01, 'load curve'], + ['loadPeak', 0, 1, 0.01, 'load ceiling'], + ['rumble', 0, 0.6, 0.005, 'rumble at full speed'], + ['settleRumble', 0, 0.3, 0.005, 'settling rumble'] + ], + 'The rut': [ + ['rutWidth', 0.05, 3, 0.01, 'half-width (m)'], + ['rutDepth', 0, 1.5, 0.01, 'depth (m)'], + ['rutSharp', 0, 1, 0.01, 'wall sharpness'], + ['rutChatter', 0, 4, 0.01, 'prints per turn'], + ['rutChatterDepth', 0, 1, 0.01, 'print depth'], + ['rutSpoil', 0, 0.8, 0.005, 'spoil ridge (m)'], + ['rutSpoilWidth', 0.02, 1, 0.01, 'ridge width (m)'], + ['rutSampleBlend', 0.05, 4, 0.01, 'sample blend (m)'], + ['rutSamples', 1, 16, 1, 'contact samples'], + ['rutDrift', 0, 2, 0.01, 'track drift'], + ['rutEdge', 0.02, 2, 0.01, 'head feather (m)'], + ['rutRagged', 0, 1, 0.01, 'ragged edge'], + ['rutRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['rutWarp', 0, 3, 0.01, 'edge warp'], + ['rutHeight', 0, 0.2, 0.001, 'height above floor (m)'] + ], + 'The rut/Lighting': [ + ['rutRelief', 0, 3, 0.01, 'relief'], + ['rutNormalStep', 0.005, 0.3, 0.005, 'normal step (m)'], + ['rutAmbient', 0, 1, 0.01, 'ambient'], + ['rutWrap', 0, 1, 0.01, 'terminator wrap'], + ['rutSpecular', 0, 2, 0.01, 'specular'], + ['rutGloss', 1, 80, 1, 'gloss'], + ['rutParallax', 0, 1.5, 0.01, 'parallax (m)'], + ['rutEmissive', 0, 3, 0.01, 'churn glow'], + ['rutOpacity', 0, 1, 0.01, 'opacity'], + ['rutDepthFade', 0.01, 3, 0.01, 'soft fade (m)'], + ['colorRutBase', 'churned floor'], + ['colorRutEdge', 'spoil ridge'], + ['colorRutChurn', 'churn under the body'], + ['colorRutDeep', 'bottom of the gouge'] + ], + 'The shatter': [ + ['debrisCount', 0, 160, 1, 'fragments'], + ['debrisSize', 0.02, 1, 0.01, 'fragment size (m)'], + ['debrisSizeJitter', 0, 1, 0.01, 'size jitter'], + ['debrisSpeed', 0, 25, 0.1, 'throw speed'], + ['debrisSpeedJitter', 0, 1, 0.01, 'speed jitter'], + ['debrisSpread', 0, 1, 0.01, 'spread'], + ['debrisUp', 0, 1, 0.01, 'up bias'], + ['debrisInherit', 0, 2, 0.01, 'inherited velocity'], + ['debrisScatter', 0, 3, 0.01, 'start scatter × radius'], + ['debrisHeight', 0, 3, 0.01, 'start height × radius'], + ['debrisGravity', -60, 0, 0.5, 'gravity'], + ['debrisDrag', 0, 4, 0.01, 'drag'], + ['debrisShrink', 0, 1, 0.01, 'shrink'], + ['debrisShrinkPower', 0.2, 6, 0.01, 'shrink curve'], + ['debrisSpin', 0, 30, 0.1, 'tumble'], + ['debrisSpinJitter', 0, 1, 0.01, 'tumble jitter'], + ['debrisFloorSpin', 0, 1, 0.01, 'tumble once grounded'], + ['debrisLifetime', 0.1, 6, 0.05, 'lifetime'] + ], + 'The shatter/Shading': [ + ['chunkGlow', 0, 4, 0.01, 'glow'], + ['chunkRim', 0, 3, 0.01, 'rim'], + ['chunkRimPower', 0.1, 8, 0.01, 'rim power'], + ['chunkShade', 0, 2, 0.01, 'shading'], + ['chunkAmbient', 0, 1, 0.01, 'ambient'], + ['chunkFadeStart', 0, 1, 0.01, 'fade start'], + ['chunkSoft', 0, 2, 0.01, 'soft fade (m)'], + ['colorChunkA', 'lit face'], + ['colorChunkB', 'shaded face'], + ['colorChunkEdge', 'fracture edge'], + ['colorChunkTint', 'scene tint'] + ], + 'Dust & chips': [ + ['dustRate', 0, 600, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 14, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['dustHeight', 0, 2, 0.01, 'emit height (m)'], + ['dustBack', 0, 2, 0.01, 'thrown behind'], + ['chipRate', 0, 400, 1, 'chip rate'], + ['chipSize', 0.005, 0.4, 0.005, 'chip size'], + ['chipSpeed', 0, 25, 0.1, 'chip speed'], + ['chipLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['chipGravity', -50, 0, 0.1, 'chip gravity'], + ['chipSpray', 0, 3, 0.01, 'chip sideways'], + ['chipBack', 0, 3, 0.01, 'chip backwards'], + ['settleDust', 0, 2, 0.01, 'settling dust'], + ['colorDust*', 'Dust colour'], + ['colorChip*', 'Chip colour'] + ], + 'The shove & the arrival': [ + ['muzzleSize', 0.05, 6, 0.05, 'set-down thud'], + ['muzzleIntensity', 0, 5, 0.01, 'thud intensity'], + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstChips', 0, 400, 1, 'burst chips'], + ['burstDust', 0, 300, 1, 'burst dust'], + ['crackRadius', 0.1, 10, 0.05, 'crack star radius'], + ['crackWidth', 0, 3, 0.01, 'crack detail'], + ['crackLife', 0.5, 20, 0.1, 'crack lifetime'], + ['crackIntensity', 0, 3, 0.01, 'crack intensity'], + ['impactDust', 0.1, 10, 0.05, 'impact dust ring'], + ['impactShake', 0, 3, 0.01, 'impact shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 4, 0.01, 'light height'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/brinelock.js b/src/config/abilities/brinelock.js new file mode 100644 index 0000000..c2c0ccb --- /dev/null +++ b/src/config/abilities/brinelock.js @@ -0,0 +1,526 @@ +/* ================================================================== */ +/* BRINELOCK — Brinelock */ +/* ================================================================== */ +/** + * A lane of brine thrown up in a train of splash crowns, held for a beat, and + * then stopped dead as glass-clear ice in exactly the silhouette the water had. + * + * **The block is arranged around the handover, because the handover is the + * ability.** Two systems draw the same shape at two different moments: + * `vfx/LiquidSurface.js` draws it as water while the lane is live, and + * `vfx/GrowthField.js` draws it as ice once it has locked. Every crown is an + * analytic wave packet — a gaussian-enveloped cosine riding out at + * `rippleSpeed`, decaying over `rippleDecay` and thinning over `rippleSpread` — + * and the *ice* is that same packet, sampled at each blade's own footprint on + * the frozen frame. So the sliders under **The crowns** are not water sliders. + * They are the shape of both states at once, and dragging `rippleAmp` on a + * standing sheet of ice re-carves the ice. + * + * Three groups therefore have to be read together: + * + * - **The crowns** decide the silhouette. `rippleWidth` is the thickness of + * the ring wall, `rippleLength` the spacing of the ripples inside the + * packet, `rippleSpeed` how far out the ring has travelled by the time it + * freezes. A crown frozen young is a tight ring of fingers around its + * impact; one frozen late is a broad transverse bar across the lane. The + * lane ends up with both, because the crowns are laid down as the front + * passes and they all freeze on the same frame — which is why the frozen + * lane reads as a *record* of the splash rather than as a row of props. + * - **The lock** decides when. `stillTime` is the beat before it where the + * swell is taken to zero — see the note on `stillTime` — and `glazeTime` is + * how long the glaze front takes to sweep the lane. + * - **The ice** decides the body. `iceGain` is the one number that can break + * the trick: at 1 the fingers reach exactly the height the water reached, + * and anything else is a different splash. It is a slider because being able + * to see it break is worth more than pretending it cannot. + * + * The lane is drawn at full length from the first frame. It has to be: a ripple + * record inside `LiquidSurface` is a fraction of the *sheet*, so growing the + * sheet behind the front would slide every standing crown downrange as it grew. + * The ability keeps its own eight-slot ring of crowns in fractions of the + * **cast** instead and rewrites the module's slots every frame. See the class + * comment in `abilities/tide/BrinelockAbility.js`. + */ +export const brinelock = { + /* --- the cast --- */ + range: 19.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 27.0, // how fast the splash front runs down the lane, metres/second + lifetime: 2.6, // seconds from arrival to the end of the standing beat + fadeTime: 2.2, // seconds the ice takes to melt back into the lane + cooldown: 1.9, // seconds before the slot re-arms + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the lane --------------------------------------------------- */ + laneLength: 1.12, // the sheet's extent, × the cast distance + laneWidth: 1.75, // half-width of the sheet, metres + poolHeight: 0.05, // metres the mean plane sits above the floor + poolOpacity: 0.96, // opacity while the brine is still water + lockedOpacity: 0.0, // opacity it is taken to once the ice has replaced it + round: 0.15, // 0 rectangular footprint, 1 elliptical — a lane wants low + edgeSoft: 0.16, // 0..1 of the field over which the waterline fades + edgeNoise: 0.45, // 0..1 how ragged that line is + edgeScale: 1.3, // cycles per metre of the raggedness + contactFade: 0.28, // metres of soft fade against opaque geometry + + /* --- the swell ---------------------------------------------------- */ + // Short and quick, unlike lava: brine is light, and its swell is a chop. All + // four go to zero over `stillTime` before the lock, because the swell rides + // the global clock rather than the ability's and freezing the crowns does not + // freeze it. + waveAmpA: 0.055, // metres + waveAmpB: 0.034, + waveAmpC: 0.021, + waveAmpD: 0.013, + waveLengthA: 3.1, // metres, crest to crest + waveLengthB: 1.9, + waveLengthC: 1.15, + waveLengthD: 0.62, + waveSpeedA: 1.5, // metres/second + waveSpeedB: 1.15, + waveSpeedC: 0.85, + waveSpeedD: 0.6, + waveAngleA: 0.2, // radians, bearing in the surface plane + waveAngleB: 1.25, + waveAngleC: 2.55, + waveAngleD: 4.1, + steepness: 0.55, // 0 sine, 1 Gerstner cusps. Above ~1 the mesh self-folds. + chop: 0.028, // metres + chopScale: 2.4, // cycles per metre + chopSpeed: 0.6, // metres/second the field drifts + detail: 0.01, // metres — fragment-only; lives entirely in the normal + detailScale: 9.0, // cycles per metre + detailSpeed: 1.2, // metres/second + + /* --- the crowns --------------------------------------------------- */ + // The shape of the water AND the shape of the ice. See the header. + rippleAmp: 0.46, // metres of crown at strength 1 — the height of a finger + rippleSpeed: 1.7, // metres/second the ring wall travels outward + rippleLength: 1.05, // metres, crest to crest inside the packet + rippleWidth: 0.62, // metres of the gaussian envelope — the wall's thickness + rippleDecay: 3.2, // seconds to 1/e; long, so the first crown survives the lock + rippleSpread: 3.6, // metres over which it also thins with radius + crownSpacing: 2.6, // metres of front travel between crowns + crownWander: 0.55, // 0..1 of the half-width a crown is thrown off the centre line + crownStrength: 1.0, // multiplier on `rippleAmp` for a running crown + crownJitter: 0.35, // ± fraction of that strength + impactCrown: 1.7, // strength multiplier for the crown at the far end + + /* --- the flow field ------------------------------------------------ */ + // No crust here — brine has no skin — so the flow is only read by the foam. + flowAngle: 0.0, // radians, the bulk drift's bearing + flowSpeed: 0.55, // metres/second + flowRadial: 0.35, // metres/second outward at the centre + flowRadialFall: 4.5, // metres to 1/e + flowEddy: 0.7, // metres/second of curl-noise swirl + flowEddyScale: 0.4, // cycles per metre + flowEddySpeed: 0.25, // Hz the eddies churn + flowGravity: 2.8, // metres/second per unit of surface slope + + /* --- foam ----------------------------------------------------------- */ + foam: 0.85, // 0..1 master + foamScale: 6.5, // cycles per metre of the speckle + foamSharp: 1.3, + foamCrest: 1.0, // how much a rising crest seeds it + foamSpeed: 0.75, // how much surface speed seeds it + + /* --- the water's shading -------------------------------------------- */ + poolDepth: 0.35, // metres of liquid under the mean plane + depthTint: 1.7, // Beer-Lambert density, per metre + translucency: 1.1, // backlight through the thin parts + ambient: 0.32, + specular: 1.6, + shininess: 90, // Blinn-Phong exponent + fresnel: 1.25, + envIntensity: 0.85, + skyIntensity: 0.5, + poolGlow: 1.0, + normalEps: 0.04, // metres — the finite-difference step + colorDeep: '#062a33', // the body seen through its own thickness + colorShallow: '#1f8f9c', // the body seen thin + colorFoam: '#e8fbfa', + colorSpec: '#ffffff', + colorSky: '#2f4a5c', // the floor under the reflected probe + + /* --- the lock ------------------------------------------------------- */ + lockDelay: 0.55, // seconds after arrival that the brine stops + // The swell rides `frame.uTime`, not the ability's clock, so freezing the + // crowns does not freeze it. Rather than snapping it off on the lock frame — + // a visible centimetre-scale pop across the whole lane — it is ramped out + // over this window before the lock, and the brine goes glassy-still first. + stillTime: 0.3, // seconds the swell takes to die before the lock + glazeTime: 0.42, // seconds the glaze front takes to sweep the lane + glazeStagger: 0.1, // seconds of extra per-blade scatter inside that sweep + waterFade: 0.34, // seconds the water takes to hand over once it has locked + + /* --- the ice -------------------------------------------------------- */ + // Consumed at spawn — `GrowthField.plant()` is the one dice roll a cast + // makes, and re-planting a standing field mid-cast would re-scatter every + // finger rather than add one. It is the only number in this block that a + // paused slider cannot move, and it takes effect on the next cast. + blades: 168, // fingers of ice in the lane + iceGain: 1.0, // × the sampled water height. 1 is the trick; anything else is not. + iceFloor: 0.02, // metres — shortest a finger may be, where the water was in a trough + bladeSpread: 0.94, // 0..1 of the lane's half-width the fingers are laid across + bladeSpreadNear: 0.72, // the same at the caster's end + bladeClumping: 0.9, // >1 pulls them toward the centre line + bladeScatter: 0.3, // extra lateral jitter, fraction of the local half-width + bladeFrontBias: 1.0, // <1 crowds fingers toward the far end + bladeRadius: 0.075, // metres, base radius of a finger at the caster's end + bladeRadiusTip: 0.062, // metres, the same at the far end + bladeRadiusCurve: 0.7, // how the radius ramps down the lane + bladeRadiusJitter: 0.42, // ± fraction + // Small on purpose: every radian of lean takes a finger's tip off the point + // the water was standing at, and the whole claim of this ability is that it + // does not. + bladeLean: 0.16, // radians away from the centre line + bladeLeanJitter: 0.5, // ± fraction + bladeLeanRamp: 0.4, // 0 leans everything equally, 1 only the far end + bladeLeanForward: 0.25, // weight of "away from the caster" in the lean + bladeLeanOutward: 0.95, // weight of "out across the lane" + bladeTwist: 1.0, // 0..1 of a full turn of random yaw + bladeTilt: 0.07, // radians of extra random tip, any bearing + riseTime: 0.055, // seconds a finger takes to arrive. Short: it does not grow, it locks. + riseOvershoot: 0.07, // how far past full height the lock carries + settle: 0.22, // seconds that overshoot damps out over + springRate: 26, // radians/second of the overshoot ring + birthScale: 0.7, // footprint scale at the instant it locks + birthFade: 0.26, // seconds the freeze flash decays over + sinkDepth: 0.35, // extra metres a melting finger drops beyond its own height + + /* --- the shape of one finger ---------------------------------------- */ + facets: 9, // sides around the lathe + rings: 13, // levels up it + taper: 1.35, // >1 pinches it to a needle, <1 leaves a stump + waist: 0.34, // 0..1 how far the stalk necks in below the bead + bead: 0.38, // the droplet caught at the top, × the base radius + beadAt: 0.82, // 0..1 up the finger where it sits + beadWidth: 0.15, // 0..1 of the finger's length it spans + shapeTwist: 0.22, // turns of helical twist from foot to tip + facetJitter: 0.16, // ± fraction of the radius, per facet — breaks the circle + + /* --- the ice's shading ---------------------------------------------- */ + colorIce: '#c8f6ff', // the clear glass + colorAerated: '#e6fbff', // the milky, air-filled foot + colorSeam: '#7fe4e0', // the expansion cracks + colorIceFlash: '#f2ffff', // the crack of light as a finger locks + aerate: 0.8, // 0..1 how milky the foot gets + aeratePower: 2.2, // how fast that clears going up + iceDepthTint: 0.85, // how much the body deepens across its own axis + icePipe: 1.7, // brightness looking down a finger's axis + icePipePower: 3.6, // how sharply that falls off + iceTranslucency: 0.9, // how much light comes through it + seamScale: 3.4, // cycles per metre across the crack field + seamStretch: 3.8, // how many times longer a seam is vertically + seamWidth: 0.15, // 0..1 of the field — the seam's width + seamGlow: 1.4, + iceFresnel: 1.5, + iceFresnelPower: 2.6, + iceGlint: 1.15, + iceGlintScale: 24, // cycles per metre of the pinpoints + iceGlintSpeed: 0.32, // metres/second they crawl at + iceGlow: 1.15, + lockGlow: 3.6, // brightness of the freeze flash + lockGlowPower: 2.2, // how fast it goes + iceOpacity: 0.95, + iceRoughness: 0.08, + iceEnvIntensity: 1.25, + + /* --- spray: the water in the air while the lane is live -------------- */ + sprayRate: 90, // particles/second along the lane + sprayPerCrown: 26, // extra, thrown when a crown is punched in + spraySpeed: 3.4, // metres/second + spraySize: 0.09, + sprayLifetime: 0.85, // seconds + sprayGravity: -7.5, // metres/second² + sprayTurbulence: 0.4, + colorSprayA: '#eafeff', + colorSprayB: '#9fe8f0', + colorSprayC: '#3f9fb4', + colorSprayD: '#123c4a', + + /* --- frost: what comes off the ice as it locks ----------------------- */ + frostPerBlade: 1.4, // motes released per finger as it locks + frostSpeed: 0.75, // metres/second + frostSize: 0.06, + frostLifetime: 1.6, // seconds + frostRise: 0.55, // metres/second² upward + frostTurbulence: 0.7, + colorFrostA: '#ffffff', + colorFrostB: '#c8f4ff', + colorFrostC: '#5fb8cc', + colorFrostD: '#183848', + + /* --- shards: the ice coming apart as it melts back ------------------- */ + shardRate: 34, // chips/second while the lane melts + shardSpeed: 1.9, // metres/second + shardSize: 0.07, + shardLifetime: 1.1, // seconds + shardGravity: -9.0, // metres/second² + colorShardA: '#e8fdff', + colorShardB: '#a4e0ea', + colorShardC: '#4d8fa0', + colorShardD: '#12303c', + + /* --- feedback --------------------------------------------------------- */ + burstSize: 1.5, // metres, the sheet of spray where the front lands + burstIntensity: 1.1, + colorBurstA: '#ffffff', + colorBurstB: '#8fe4f0', + colorBurstC: '#155f74', + shockRadius: 3.4, // metres, the ring that runs out across the floor + colorShockA: '#d8fbff', + colorShockB: '#1e7a90', + brineMarks: 7, // wet stains left outside the waterline + brineRadius: 1.1, // metres each + brineLife: 5.5, // seconds + brineIntensity: 0.75, + colorBrine: '#0f3440', + colorBrineEdge: '#63c8cf', + impactShake: 0.35, // camera punch when the front lands + lockShake: 0.22, // a second, smaller punch on the frame it freezes + shakeDuration: 0.45, // seconds either decays over + rumble: 0.11, // continuous shake while the lane is running + impactFlash: 0.2, + colorFlash: '#a8ecf6', + lockFlash: 0.34, + colorLockFlash: '#e8ffff', + + /* --- the light -------------------------------------------------------- */ + lightColor: '#4fd4e4', + lightIntensity: 5.5, + lightRadius: 13.0, // metres + lightHeight: 0.9 // metres above the lane the light rides +}; + +/** Editor layout. See the schema notes at the top of `config/abilities/index.js`. */ +export const brinelockSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 120, 0.5, 'front speed'], + ['lifetime', 0.4, 8, 0.05, 'standing beat'], + ['fadeTime', 0.2, 8, 0.05, 'melt duration'], + ['cooldown', 0, 10, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The lane': [ + ['laneLength', 0.8, 1.6, 0.01, 'sheet length × cast'], + ['laneWidth', 0.4, 6, 0.05, 'sheet half-width'], + ['poolHeight', 0, 0.6, 0.005, 'surface height'], + ['poolOpacity', 0, 1, 0.01, 'water opacity'], + ['lockedOpacity', 0, 1, 0.01, 'opacity once locked'], + ['round', 0, 1, 0.01, 'elliptical footprint'], + ['edgeSoft', 0.01, 0.6, 0.005, 'waterline softness'], + ['edgeNoise', 0, 1, 0.01, 'waterline raggedness'], + ['edgeScale', 0.1, 5, 0.01, 'raggedness / metre'], + ['contactFade', 0.02, 2, 0.01, 'soft intersection'] + ], + 'The swell': [ + ['waveAmpA', 0, 0.5, 0.002, 'amp A'], + ['waveAmpB', 0, 0.5, 0.002, 'amp B'], + ['waveAmpC', 0, 0.5, 0.002, 'amp C'], + ['waveAmpD', 0, 0.5, 0.002, 'amp D'], + ['waveLengthA', 0.3, 12, 0.05, 'length A'], + ['waveLengthB', 0.3, 12, 0.05, 'length B'], + ['waveLengthC', 0.3, 12, 0.05, 'length C'], + ['waveLengthD', 0.3, 12, 0.05, 'length D'], + ['waveSpeedA', -4, 4, 0.01, 'speed A'], + ['waveSpeedB', -4, 4, 0.01, 'speed B'], + ['waveSpeedC', -4, 4, 0.01, 'speed C'], + ['waveSpeedD', -4, 4, 0.01, 'speed D'], + ['waveAngleA', 0, 6.29, 0.01, 'bearing A'], + ['waveAngleB', 0, 6.29, 0.01, 'bearing B'], + ['waveAngleC', 0, 6.29, 0.01, 'bearing C'], + ['waveAngleD', 0, 6.29, 0.01, 'bearing D'], + ['steepness', 0, 1.2, 0.01, 'gerstner steepness'], + ['chop', 0, 0.3, 0.002, 'chop'], + ['chopScale', 0.1, 8, 0.01, 'chop / metre'], + ['chopSpeed', 0, 4, 0.01, 'chop drift'], + ['detail', 0, 0.1, 0.001, 'normal detail'], + ['detailScale', 0.5, 24, 0.1, 'detail / metre'], + ['detailSpeed', 0, 4, 0.01, 'detail drift'] + ], + 'The crowns': [ + ['rippleAmp', 0, 1.5, 0.005, 'crown height'], + ['rippleSpeed', 0.1, 8, 0.02, 'ring speed'], + ['rippleLength', 0.1, 4, 0.01, 'wavelength'], + ['rippleWidth', 0.05, 3, 0.01, 'wall thickness'], + ['rippleDecay', 0.1, 10, 0.05, 'decay to 1/e'], + ['rippleSpread', 0.2, 12, 0.05, 'radial thinning'], + ['crownSpacing', 0.4, 12, 0.05, 'metres between crowns'], + ['crownWander', 0, 1, 0.01, 'lateral wander'], + ['crownStrength', 0, 3, 0.01, 'running crown strength'], + ['crownJitter', 0, 1, 0.01, 'strength jitter'], + ['impactCrown', 0, 4, 0.01, 'final crown strength'] + ], + 'The flow field': [ + ['flowAngle', 0, 6.29, 0.01, 'drift bearing'], + ['flowSpeed', 0, 4, 0.01, 'drift speed'], + ['flowRadial', 0, 4, 0.01, 'outflow at centre'], + ['flowRadialFall', 0.2, 12, 0.05, 'outflow falloff'], + ['flowEddy', 0, 4, 0.01, 'eddy speed'], + ['flowEddyScale', 0.02, 2, 0.01, 'eddies / metre'], + ['flowEddySpeed', 0, 2, 0.01, 'eddy churn'], + ['flowGravity', 0, 8, 0.05, 'downhill flow'] + ], + 'Foam': [ + ['foam', 0, 1, 0.01, 'foam'], + ['foamScale', 0.5, 20, 0.1, 'speckle / metre'], + ['foamSharp', 0.2, 4, 0.01, 'speckle sharpness'], + ['foamCrest', 0, 3, 0.01, 'seeded by crests'], + ['foamSpeed', 0, 3, 0.01, 'seeded by speed'] + ], + 'The water': [ + ['poolDepth', 0, 3, 0.01, 'depth under the plane'], + ['depthTint', 0, 6, 0.01, 'beer-lambert density'], + ['translucency', 0, 3, 0.01, 'backlight'], + ['ambient', 0, 1, 0.01, 'ambient'], + ['specular', 0, 4, 0.01, 'specular'], + ['shininess', 4, 256, 1, 'shininess'], + ['fresnel', 0, 3, 0.01, 'fresnel'], + ['envIntensity', 0, 3, 0.01, 'probe reflection'], + ['skyIntensity', 0, 2, 0.01, 'sky floor'], + ['poolGlow', 0, 3, 0.01, 'water glow'], + ['normalEps', 0.005, 0.2, 0.001, 'normal step'], + ['colorDeep', 'deep'], + ['colorShallow', 'shallow'], + ['colorFoam', 'foam'], + ['colorSpec', 'specular'], + ['colorSky', 'sky floor'] + ], + 'The lock': [ + ['lockDelay', 0.05, 4, 0.01, 'seconds until it freezes'], + ['stillTime', 0.02, 2, 0.01, 'swell dies over'], + ['glazeTime', 0.02, 3, 0.01, 'glaze sweep'], + ['glazeStagger', 0, 1, 0.005, 'per-blade scatter'], + ['waterFade', 0.02, 3, 0.01, 'water hands over'] + ], + 'The ice/Placement': [ + ['blades', 8, 216, 1, 'fingers'], + ['iceGain', 0, 2, 0.01, 'height × the water'], + ['iceFloor', 0.005, 0.4, 0.005, 'shortest finger'], + ['bladeSpread', 0, 1.4, 0.01, 'lane coverage'], + ['bladeSpreadNear', 0, 1.4, 0.01, 'coverage at the caster'], + ['bladeClumping', 0.2, 3, 0.01, 'pull to the centre line'], + ['bladeScatter', 0, 1.5, 0.01, 'lateral jitter'], + ['bladeFrontBias', 0.2, 3, 0.01, 'crowd toward the far end'] + ], + 'The ice/Body': [ + ['bladeRadius', 0.01, 0.4, 0.002, 'base radius (near)'], + ['bladeRadiusTip', 0.01, 0.4, 0.002, 'base radius (far)'], + ['bladeRadiusCurve', 0.1, 3, 0.01, 'radius ramp'], + ['bladeRadiusJitter', 0, 1, 0.01, 'radius jitter'], + ['bladeLean', 0, 1.2, 0.01, 'lean'], + ['bladeLeanJitter', 0, 1, 0.01, 'lean jitter'], + ['bladeLeanRamp', 0, 1, 0.01, 'lean ramp'], + ['bladeLeanForward', 0, 2, 0.01, 'lean downrange'], + ['bladeLeanOutward', 0, 2, 0.01, 'lean across'], + ['bladeTwist', 0, 1, 0.01, 'random yaw'], + ['bladeTilt', 0, 0.6, 0.005, 'random tip'] + ], + 'The ice/The lock beat': [ + ['riseTime', 0.01, 1, 0.005, 'time to arrive'], + ['riseOvershoot', 0, 1, 0.01, 'overshoot'], + ['settle', 0.02, 2, 0.01, 'overshoot decay'], + ['springRate', 2, 60, 0.5, 'overshoot ring'], + ['birthScale', 0.1, 1, 0.01, 'scale as it locks'], + ['birthFade', 0.02, 2, 0.01, 'freeze flash decay'], + ['sinkDepth', 0, 2, 0.01, 'extra melt depth'] + ], + 'The ice/Shape': [ + ['facets', 5, 20, 1, 'sides'], + ['rings', 4, 24, 1, 'levels'], + ['taper', 0.3, 4, 0.01, 'taper to the tip'], + ['waist', 0, 0.9, 0.01, 'neck below the bead'], + ['bead', 0, 1.2, 0.01, 'droplet size'], + ['beadAt', 0.2, 1, 0.01, 'droplet height'], + ['beadWidth', 0.03, 0.6, 0.005, 'droplet spread'], + ['shapeTwist', 0, 1.5, 0.01, 'helical twist'], + ['facetJitter', 0, 0.6, 0.005, 'facet jitter'] + ], + 'The ice/Shading': [ + ['colorIce', 'clear glass'], + ['colorAerated', 'milky foot'], + ['colorSeam', 'expansion cracks'], + ['colorIceFlash', 'freeze flash'], + ['aerate', 0, 1, 0.01, 'aeration'], + ['aeratePower', 0.2, 6, 0.01, 'aeration falloff'], + ['iceDepthTint', 0, 3, 0.01, 'cross-axis density'], + ['icePipe', 0, 4, 0.01, 'down-the-axis glow'], + ['icePipePower', 0.5, 10, 0.05, 'axis falloff'], + ['iceTranslucency', 0, 3, 0.01, 'translucency'], + ['seamScale', 0.2, 12, 0.05, 'seams / metre'], + ['seamStretch', 0.5, 12, 0.05, 'vertical stretch'], + ['seamWidth', 0.01, 0.6, 0.005, 'seam width'], + ['seamGlow', 0, 4, 0.01, 'seam glow'], + ['iceFresnel', 0, 4, 0.01, 'fresnel'], + ['iceFresnelPower', 0.5, 8, 0.05, 'fresnel power'], + ['iceGlint', 0, 4, 0.01, 'glints'], + ['iceGlintScale', 2, 80, 0.5, 'glints / metre'], + ['iceGlintSpeed', 0, 3, 0.01, 'glint crawl'], + ['iceGlow', 0, 4, 0.01, 'ice glow'], + ['lockGlow', 0, 10, 0.05, 'freeze flash'], + ['lockGlowPower', 0.5, 10, 0.05, 'flash falloff'], + ['iceOpacity', 0, 1, 0.01, 'ice opacity'], + ['iceRoughness', 0, 1, 0.01, 'roughness'], + ['iceEnvIntensity', 0, 4, 0.01, 'probe reflection'] + ], + 'Spray': [ + ['sprayRate', 0, 400, 1, 'per second'], + ['sprayPerCrown', 0, 160, 1, 'per crown'], + ['spraySpeed', 0, 14, 0.05, 'speed'], + ['spraySize', 0.01, 0.5, 0.005, 'size'], + ['sprayLifetime', 0.1, 4, 0.02, 'lifetime'], + ['sprayGravity', -30, 10, 0.1, 'gravity'], + ['sprayTurbulence', 0, 3, 0.01, 'turbulence'], + ['colorSpray*', 'Spray gradient'] + ], + 'Frost': [ + ['frostPerBlade', 0, 8, 0.05, 'per finger'], + ['frostSpeed', 0, 6, 0.05, 'speed'], + ['frostSize', 0.01, 0.4, 0.005, 'size'], + ['frostLifetime', 0.1, 6, 0.02, 'lifetime'], + ['frostRise', -4, 4, 0.05, 'rise'], + ['frostTurbulence', 0, 3, 0.01, 'turbulence'], + ['colorFrost*', 'Frost gradient'] + ], + 'Shards': [ + ['shardRate', 0, 200, 1, 'per second'], + ['shardSpeed', 0, 10, 0.05, 'speed'], + ['shardSize', 0.01, 0.4, 0.005, 'size'], + ['shardLifetime', 0.1, 4, 0.02, 'lifetime'], + ['shardGravity', -30, 5, 0.1, 'gravity'], + ['colorShard*', 'Shard gradient'] + ], + 'Feedback': [ + ['burstSize', 0.1, 8, 0.05, 'spray sheet'], + ['burstIntensity', 0, 4, 0.01, 'spray sheet glow'], + ['colorBurstA', 'burst core'], + ['colorBurstB', 'burst mid'], + ['colorBurstC', 'burst rim'], + ['shockRadius', 0.5, 12, 0.05, 'shock ring'], + ['colorShockA', 'shock inner'], + ['colorShockB', 'shock outer'], + ['brineMarks', 0, 24, 1, 'wet stains'], + ['brineRadius', 0.1, 5, 0.05, 'stain radius'], + ['brineLife', 0.5, 20, 0.1, 'stain life'], + ['brineIntensity', 0, 3, 0.01, 'stain strength'], + ['colorBrine', 'stain'], + ['colorBrineEdge', 'stain edge'], + ['impactShake', 0, 2, 0.01, 'arrival shake'], + ['lockShake', 0, 2, 0.01, 'freeze shake'], + ['shakeDuration', 0.05, 2, 0.01, 'shake decay'], + ['rumble', 0, 1, 0.005, 'running rumble'], + ['impactFlash', 0, 2, 0.01, 'arrival flash'], + ['colorFlash', 'arrival flash'], + ['lockFlash', 0, 2, 0.01, 'freeze flash'], + ['colorLockFlash', 'freeze flash'] + ], + 'The light': [ + ['lightColor', 'colour'], + ['lightIntensity', 0, 30, 0.1, 'intensity'], + ['lightRadius', 1, 40, 0.5, 'radius'], + ['lightHeight', 0, 6, 0.05, 'height above the lane'] + ] +}; diff --git a/src/config/abilities/broodburst.js b/src/config/abilities/broodburst.js new file mode 100644 index 0000000..0419204 --- /dev/null +++ b/src/config/abilities/broodburst.js @@ -0,0 +1,503 @@ +/* ================================================================== */ +/* BROODBURST — hive, far cast */ +/* ================================================================== */ +/** + * A clutch of eggs, and then what was in them. + * + * Every egg keeps **its own clock**. It is laid, it inflates, the shell thins + * until the thing inside is visible through it, and then the seam gives and two + * half-shells swing apart. Nothing about that is global: the ramp is + * `GrowthField`'s per-instance `aBirth`, the throb's phase is the egg's own + * dice, and the clutch is laid on a radial wave, so at any moment the middle of + * the circle is hatching while the rim is still swelling. A clutch that popped + * in unison was the first version and it read as one object with forty copies. + * + * What comes out **follows the floor**. `ColonySwarm`'s `cling` pins every agent + * to `floorY + crawlHeight` after the flock and the shape have both had their + * say, so the crawlers bank into turns, separate on the lattice and condense + * into a spreading ring — all of it in the plane of the ground. That is the one + * thing separating this from every other swarm in the sandbox, and it is why + * `crawlHeight` is measured in centimetres rather than metres: the moment they + * are more than a hand off the floor they are flying, and the illusion is gone. + * + * Five draw calls: three egg variants, the crawlers, and the wet slick they run + * across. See `src/abilities/hive/BroodburstAbility.js` and + * `src/materials/BroodEggMaterial.js`. + */ +export const broodburst = { + /* --- the cast --- */ + range: 18.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 25.0, // how fast the clutch is carried to the circle, metres/second + cooldown: 2.2, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 4.8, // the footprint the indicator draws, metres + + /* --- the beats, all seconds --- */ + layTime: 0.9, // the wave of eggs being laid, centre to rim + layStagger: 0.35, // extra random delay between neighbours in that wave + swellTime: 2.6, // one egg from laid to split — this is the per-egg clock + crawlTime: 3.6, // the crawlers running about after the last egg goes + scatterTime: 1.8, // them dispersing and the husks sinking + + /* --- the clutch --- */ + eggs: 44, // eggs in the circle, whole number (capped at 72) + clusterShare: 0.18, // 0..1 of them heaped in the middle instead of scattered + innerFrac: 0.06, // × zoneRadius — the hole in the middle of the ring + radialCurve: 0.85, // <1 pushes the clutch toward the rim + radialJitter: 0.22, // metres of radial wander + angleJitter: 0.3, // radians of bearing wander + + /* --- one egg, in metres --- */ + eggHeight: 0.62, // an egg at the centre + eggHeightRim: 0.46, // an egg at the boundary + heightCurve: 1.0, // how the ramp between them runs + heightJitter: 0.26, // ± fraction + eggRadius: 0.26, // footprint at the centre + eggRadiusRim: 0.21, // footprint at the boundary + radiusCurve: 0.8, + radiusJitter: 0.24, // ± fraction + eggTilt: 0.22, // radians of random tip — eggs are not planted upright; the + // per-egg roll and settings.global.randomness scale it inside + // GrowthField, so it needs no jitter slider of its own + eggSink: 0.24, // fraction of its height an egg is bedded into the floor + riseTime: 0.3, // seconds an egg takes to accrete in place + riseOvershoot: 0.2, + settleTime: 0.4, // seconds that overshoot damps out over + springRate: 15.0, // radians/second of the overshoot ring + birthScale: 0.35, // footprint at the moment it appears + sinkDepth: 0.5, // extra metres a husk drops as it withdraws + + /* --- the shape of the shell (rebuilds the geometry when changed) --- */ + eggRings: 16, // rings up the shell, whole number + eggSides: 12, // bearings per half-shell, whole number + seamTear: 0.16, // radians the seam wanders off the meridian — the torn edge + + /* --- the hatch, all fractions of one egg's own ramp --- */ + swellAt: 0.08, // where inflation starts + splitAt: 0.62, // where the seam gives + splitSpan: 0.22, // how long the seam takes to open + huskAt: 0.9, // where what is left of the shell stops being there + swell: 0.45, // fraction the shell inflates by + swellCurve: 1.8, // >1 holds it small and then goes late + bulge: 0.32, // extra width at the waist + stretch: 0.24, // extra height, as a fraction of the swell + throb: 0.055, // fraction of a per-egg pulse on top of the inflation + throbRate: 7.0, // pulses across the whole ramp + gape: 1.3, // radians each half-shell swings through + gapeSlide: 0.13, // unit-space, how far the halves also slide apart + gapeLift: 0.06, // unit-space, how far the husk lifts as it opens + gapeCurve: 0.6, // <1 snaps open then eases + + /* --- the shell's surface --- */ + veins: 0.9, // strength of the vein network + veinScale: 5.5, // features around the shell + veinBands: 2.6, // features up the shell + veinSharp: 0.62, // 0 soft mottle, 1 hard filaments + mottle: 0.35, // broad blotching under the veins + mottleScale: 2.2, + rimWidth: 0.55, // how far in from the torn edge the lip reaches + rimDark: 0.7, // how much darker that lip is + rimGlow: 1.8, // and how hot it is once the seam has given + shellFresnel: 1.2, + shellFresnelPower: 2.4, + thin: 2.4, // how much light the stretched shell passes + thinPower: 3.0, + broodGlow: 2.8, // the thing inside, showing through + broodRise: 1.7, // >1 keeps it hidden until the shell is tight + interiorGlow: 0.9, // the wet inside of an opened husk + shellGlow: 1.0, // master multiplier on every emissive term + shellRoughness: 0.48, + shellEnv: 0.8, + colorShell: '#c9c06a', // the crown of the shell + colorBelly: '#8e9440', // where it sits on the floor + colorVein: '#d8ff72', // the vein network + colorBrood: '#a8ff3c', // the thing inside + colorRim: '#ffe08a', // the torn lip once the seam gives + colorInterior: '#2a2410', // the wet inside of an opened husk + + /* --- the crawlers --- */ + crawlers: 200, // agents at full strength, whole number (capped at 256) + crawlShape: 0, // Silhouette: 0 bird, 1 leaf, 2 card, 3 droplet, 4 mote + crawlHeight: 0.07, // metres off the floor a crawler rides — keep this small + crawlSize: 0.2, // metres, nose to tail + crawlAspect: 1.25, // span / length + crawlSizeJitter: 0.35, // ± fraction + crawlSweep: 0.55, // rake on the legs + crawlDihedral: 0.12, // leg fold, fraction of the size + crawlScuttle: 16.0, // leg beats per second + crawlBank: 0.05, // radians of roll per m/s² of lateral acceleration + crawlBankMax: 0.7, // radians + crawlLit: 0.55, // 0 emissive, 1 wrapped diffuse + crawlEdgeGain: 1.8, // emission multiplier edge-on + crawlGlow: 1.1, + crawlOpacity: 1.0, + crawlSoftFade: 0.2, // metres of depth feather against standing geometry + + /* --- how they move --- */ + crawlSpacing: 0.34, // metres between lateral cells on the separation lattice + crawlSpacingUp: 0.12, // metres between vertical cells — flat, they are on a floor + crawlLatticeX: 9, // cells across + crawlLatticeY: 2, // cells up + crawlLatticeZ: 12, // ranks behind the lead + crawlLag: 0.35, // seconds the back rank trails the lead by + crawlJitter: 0.1, // metres of slop off the cell + crawlChurn: 0.9, // radians/second the formation rolls + crawlBreathe: 0.16, // fraction it swells by + crawlBreatheRate: 2.2, // radians/second + crawlWander: 0.12, // metres of curl drift — keep under half the spacing + crawlWanderScale: 0.8, // features per metre + crawlWanderSpeed: 0.9, + crawlGather: 0.85, // 0 collapses every agent onto the lead's own path + crawlOrbitRadius: 1.6, // metres the lead circles the clutch at + crawlOrbitTurns: 0.9, // turns per unit of the lead's parameter + crawlLeadRate: 0.28, // how fast that parameter advances, per second + crawlRevealSpread: 0.3, // width of the wave that brings agents in + crawlCondense: 0.72, // 0..1 how hard they hold the ring + crawlRing: 0.72, // ring radius, × zoneRadius + crawlRingGrow: 0.55, // extra ring radius by the end, × zoneRadius + crawlRingThick: 0.34, // ring section, × zoneRadius + crawlFill: 0.75, // 0 a shell of agents, 1 a solid body + crawlSteps: 3, // descent steps onto the ring, 1..4 + crawlSlack: 0.85, // fraction of each step taken + crawlRough: 0.12, // unitless slop off the isosurface — a crowd, not a membrane + crawlSpin: 0.5, // radians/second the ring turns + crawlTint: 0.25, // where in the gradient the swarm sits + crawlTintJitter: 0.3, // ± per-agent walk along it + crawlTintAlong: 0.3, // extra walk from head to tail + colorCrawlA: '#e2ff96', // birth + colorCrawlB: '#9ad03c', // early + colorCrawlC: '#5a7018', // late + colorCrawlD: '#1a1e06', // death + + /* --- the slick they run across --- */ + slickRadius: 1.1, // × zoneRadius + slickHeight: 0.014, // metres above the floor the quad sits at + slickEdge: 0.55, // metres of feather on the spreading front + slickRagged: 0.4, // how far that front wanders, as a fraction of the radius + slickRaggedScale: 0.6, // lobes per metre + slickWarp: 0.7, // metres of domain warp on those lobes + slickRelief: 0.4, // how hard the height field tilts the fake normal + slickCell: 0.75, // metres — the puddle scale + slickLift: 0.03, // metres of ripple + slickDepth: 0.1, // metres of apparent puddle depth + slickFlow: 0.16, // metres/second the wet drifts + slickSpeed: 0.7, // ripple events per second + slickAmbient: 0.24, + slickSpecular: 0.85, // wet, so this is high on purpose + slickGloss: 42, + slickDetail: 0.55, + slickSharp: 0.4, + slickEmissive: 0.7, + slickOpacity: 0.85, + slickDepthFade: 0.45, // metres of soft fade against standing geometry + slickDry: 0.8, // 0..1 how far the slick dries back in as the cast ends + colorSlickBase: '#3a3a18', // the wet stone + colorSlickEdge: '#c9c06a', // the meniscus + colorSlickGlow: '#a8ff3c', // whatever is glowing in it + colorSlickDeep: '#0c0f04', // the bottom + + /* --- the goo an egg lets go of --- */ + gooRate: 12, // per second per standing clutch + gooBurst: 16, // per egg at the moment its seam gives + gooSize: 0.08, // metres + gooSpeed: 2.4, // metres/second + gooLifetime: 1.3, // seconds + gooGravity: -9.0, // metres/second² + gooGlow: 1.6, + colorGooA: '#eaffb0', // birth + colorGooB: '#a8ff3c', // early + colorGooC: '#6a8a1c', // late + colorGooD: '#1e2408', // death + + /* --- shell fragments --- */ + chipBurst: 5, // per egg at the moment its seam gives + chipSize: 0.07, // metres + chipSpeed: 2.0, // metres/second + chipLifetime: 1.6, // seconds + chipGravity: -12.0, // metres/second² + chipSpin: 8.0, // radians/second + colorChipA: '#d8cf82', // birth + colorChipB: '#9a9448', // early + colorChipC: '#5c5824', // late + colorChipD: '#1a180a', // death + + /* --- the spore haze over the clutch --- */ + hazeRate: 16, // per second + hazeSize: 0.65, // metres + hazeSpeed: 0.7, // metres/second + hazeLifetime: 2.4, // seconds + hazeRise: 0.42, // metres/second² of buoyancy + hazeOpacity: 0.32, + colorHazeA: '#b8c078', // birth + colorHazeB: '#7e8848', // early + colorHazeC: '#4a5028', // late + colorHazeD: '#14170c', // death + + /* --- the muzzle, the landing and the scatter --- */ + handForward: 0.65, // metres downrange of the caster + handSide: 0.32, // metres to the caster's side + handHeight: 1.3, // metres off the floor + muzzleSize: 0.9, // metres, the shell at the hand + muzzleIntensity: 1.0, + castFlash: 0.05, // screen flash on release + colorCastFlash: '#c9c06a', + burstSize: 2.4, // metres, the shell as the clutch lands + burstIntensity: 1.0, + burstGoo: 60, // goo thrown at that moment + shockRadius: 3.4, // metres, the ring pushed out under it + impactShake: 0.34, + shakeDuration: 1.0, // seconds + impactFlash: 0.06, + rumble: 0.012, // continuous shake while the clutch is carried out + colorBurstA: '#d8e8a0', + colorBurstB: '#9ad03c', + colorBurstC: '#5a7018', + colorFlash: '#c8ff6a', + slickDecalRadius: 1.0, // × zoneRadius — the wet mat decal under the clutch + slickDecalLife: 6.0, // seconds it weathers away over + slickDecalIntensity: 0.8, + colorMat: '#3a3a18', + colorMatEdge: '#9ad03c', + + /* --- the dynamic light --- */ + lightIntensity: 8.0, + lightRadius: 10.0, // metres + lightHeight: 0.5, // metres above the floor + lightPulse: 0.28, // 0..1 depth of the slow breathing + lightPulseRate: 2.4, // radians/second + lightColor: '#a8d848' +}; + +/** Editor layout: which folders exist and what goes in them. */ +export const broodburstSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 150, 1, 'carry speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['zoneRadius', 1, 16, 0.05, 'footprint radius'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['layTime', 0.05, 5, 0.05, 'laying wave'], + ['layStagger', 0, 2, 0.01, 'neighbour stagger'], + ['swellTime', 0.2, 10, 0.05, 'one egg, laid to split'], + ['crawlTime', 0.2, 14, 0.1, 'crawlers running'], + ['scatterTime', 0.2, 8, 0.05, 'dispersing'] + ], + 'The clutch': [ + ['eggs', 1, 72, 1, 'eggs'], + ['clusterShare', 0, 1, 0.01, 'heaped in the middle'], + ['innerFrac', 0, 1, 0.01, 'inner hole × zone'], + ['radialCurve', 0.2, 3, 0.01, 'radial curve'], + ['radialJitter', 0, 2, 0.01, 'radial wander (m)'], + ['angleJitter', 0, 1.6, 0.01, 'bearing wander (rad)'] + ], + 'One egg': [ + ['eggHeight', 0.05, 2.5, 0.01, 'height at the centre (m)'], + ['eggHeightRim', 0.05, 2.5, 0.01, 'height at the rim (m)'], + ['heightCurve', 0.2, 3, 0.01, 'height curve'], + ['heightJitter', 0, 1, 0.01, 'height jitter'], + ['eggRadius', 0.02, 1.2, 0.005, 'radius at the centre (m)'], + ['eggRadiusRim', 0.02, 1.2, 0.005, 'radius at the rim (m)'], + ['radiusCurve', 0.2, 3, 0.01, 'radius curve'], + ['radiusJitter', 0, 1, 0.01, 'radius jitter'], + ['eggTilt', 0, 1.2, 0.01, 'tilt (rad)'], + ['eggSink', 0, 1, 0.01, 'bedded into the floor'], + ['riseTime', 0.02, 2, 0.01, 'accretion time'], + ['riseOvershoot', 0, 1.5, 0.01, 'overshoot'], + ['settleTime', 0.05, 3, 0.01, 'settle'], + ['springRate', 1, 40, 0.5, 'spring rate'], + ['birthScale', 0.02, 1, 0.01, 'scale on appearing'], + ['sinkDepth', 0, 3, 0.01, 'extra sink (m)'] + ], + 'One egg/Shell shape': [ + ['eggRings', 6, 28, 1, 'rings'], + ['eggSides', 5, 24, 1, 'sides per half'], + ['seamTear', 0, 0.6, 0.005, 'seam tear (rad)'] + ], + 'The hatch': [ + ['swellAt', 0, 0.9, 0.01, 'inflation starts'], + ['splitAt', 0.05, 0.98, 0.01, 'seam gives'], + ['splitSpan', 0.01, 0.8, 0.01, 'seam opening'], + ['huskAt', 0.1, 1, 0.01, 'husk gone'], + ['swell', 0, 2, 0.01, 'inflation'], + ['swellCurve', 0.2, 5, 0.05, 'inflation curve'], + ['bulge', 0, 1.5, 0.01, 'waist bulge'], + ['stretch', 0, 1.5, 0.01, 'lengthening'], + ['throb', 0, 0.4, 0.005, 'throb'], + ['throbRate', 0, 24, 0.5, 'throb rate'], + ['gape', 0, 3, 0.01, 'gape (rad)'], + ['gapeSlide', 0, 0.8, 0.005, 'halves sliding apart'], + ['gapeLift', 0, 0.6, 0.005, 'husk lift'], + ['gapeCurve', 0.1, 3, 0.05, 'gape curve'] + ], + 'The shell': [ + ['veins', 0, 3, 0.01, 'veins'], + ['veinScale', 0.5, 20, 0.1, 'vein features around'], + ['veinBands', 0.2, 12, 0.1, 'vein features up'], + ['veinSharp', 0, 1, 0.01, 'vein sharpness'], + ['mottle', 0, 1.5, 0.01, 'mottle'], + ['mottleScale', 0.2, 10, 0.1, 'mottle scale'], + ['rimWidth', 0.02, 1, 0.01, 'torn lip width'], + ['rimDark', 0, 1, 0.01, 'lip darkening'], + ['rimGlow', 0, 6, 0.01, 'lip glow'], + ['shellFresnel', 0, 4, 0.01, 'fresnel'], + ['shellFresnelPower', 0.2, 8, 0.05, 'fresnel power'], + ['thin', 0, 8, 0.05, 'translucency'], + ['thinPower', 0.2, 10, 0.05, 'translucency power'], + ['broodGlow', 0, 8, 0.05, 'brood glow'], + ['broodRise', 0.2, 6, 0.05, 'brood reveal curve'], + ['interiorGlow', 0, 4, 0.01, 'husk interior'], + ['shellGlow', 0, 4, 0.01, 'master glow'], + ['shellRoughness', 0, 1, 0.01, 'roughness'], + ['shellEnv', 0, 3, 0.01, 'env intensity'], + ['colorShell', 'crown'], + ['colorBelly', 'belly'], + ['colorVein', 'veins'], + ['colorBrood', 'the thing inside'], + ['colorRim', 'torn lip'], + ['colorInterior', 'husk interior'] + ], + 'The crawlers': [ + ['crawlers', 0, 256, 1, 'agents'], + ['crawlShape', 0, 4, 1, 'silhouette'], + ['crawlHeight', 0, 0.6, 0.005, 'height off the floor (m)'], + ['crawlSize', 0.02, 1, 0.005, 'size (m)'], + ['crawlAspect', 0.3, 4, 0.01, 'aspect'], + ['crawlSizeJitter', 0, 1, 0.01, 'size jitter'], + ['crawlSweep', 0, 2, 0.01, 'leg rake'], + ['crawlDihedral', 0, 1, 0.01, 'leg fold'], + ['crawlScuttle', 0, 40, 0.5, 'leg beats / second'], + ['crawlBank', 0, 0.4, 0.005, 'bank'], + ['crawlBankMax', 0, 2, 0.01, 'bank ceiling'], + ['crawlLit', 0, 1, 0.01, 'lit vs emissive'], + ['crawlEdgeGain', 0, 6, 0.05, 'edge-on gain'], + ['crawlGlow', 0, 4, 0.01, 'glow'], + ['crawlOpacity', 0, 1, 0.01, 'opacity'], + ['crawlSoftFade', 0.02, 2, 0.01, 'soft intersection'] + ], + 'The crawlers/Movement': [ + ['crawlSpacing', 0.05, 2, 0.01, 'lateral spacing (m)'], + ['crawlSpacingUp', 0.01, 1, 0.005, 'vertical spacing (m)'], + ['crawlLatticeX', 1, 20, 1, 'cells across'], + ['crawlLatticeY', 1, 8, 1, 'cells up'], + ['crawlLatticeZ', 1, 24, 1, 'ranks behind'], + ['crawlLag', 0, 2, 0.01, 'cohesion lag (s)'], + ['crawlJitter', 0, 0.6, 0.005, 'slop (m)'], + ['crawlChurn', 0, 4, 0.01, 'churn'], + ['crawlBreathe', 0, 1, 0.01, 'breathe'], + ['crawlBreatheRate', 0, 8, 0.05, 'breathe rate'], + ['crawlWander', 0, 0.6, 0.005, 'wander (m)'], + ['crawlWanderScale', 0.05, 4, 0.01, 'wander scale'], + ['crawlWanderSpeed', 0, 4, 0.01, 'wander speed'], + ['crawlGather', 0, 1, 0.01, 'gather'], + ['crawlOrbitRadius', 0, 8, 0.05, 'lead orbit (m)'], + ['crawlOrbitTurns', 0, 4, 0.01, 'lead turns'], + ['crawlLeadRate', 0, 3, 0.01, 'lead rate'], + ['crawlRevealSpread', 0.02, 1, 0.01, 'reveal spread'], + ['crawlCondense', 0, 1, 0.01, 'hold on the ring'], + ['crawlRing', 0.05, 2, 0.01, 'ring radius × zone'], + ['crawlRingGrow', 0, 2, 0.01, 'ring growth × zone'], + ['crawlRingThick', 0.02, 1.5, 0.01, 'ring section × zone'], + ['crawlFill', 0, 1, 0.01, 'fill'], + ['crawlSteps', 1, 4, 1, 'descent steps'], + ['crawlSlack', 0.2, 1, 0.01, 'step slack'], + ['crawlRough', 0, 0.6, 0.005, 'isosurface slop'], + ['crawlSpin', -3, 3, 0.01, 'ring spin'], + ['crawlTint', 0, 1, 0.01, 'gradient position'], + ['crawlTintJitter', 0, 1, 0.01, 'gradient jitter'], + ['crawlTintAlong', 0, 1, 0.01, 'head-to-tail walk'], + ['colorCrawl*', 'Crawler colour'] + ], + 'The slick': [ + ['slickRadius', 0.2, 3, 0.01, 'radius × zone'], + ['slickHeight', 0, 0.3, 0.002, 'hover height'], + ['slickEdge', 0.02, 3, 0.01, 'front feather'], + ['slickRagged', 0, 1, 0.01, 'front raggedness'], + ['slickRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['slickWarp', 0, 3, 0.01, 'domain warp'], + ['slickRelief', 0, 2, 0.01, 'relief'], + ['slickCell', 0.05, 4, 0.01, 'puddle scale (m)'], + ['slickLift', 0, 0.4, 0.002, 'ripple (m)'], + ['slickDepth', 0, 1, 0.005, 'apparent depth (m)'], + ['slickFlow', 0, 2, 0.01, 'drift'], + ['slickSpeed', 0, 4, 0.01, 'ripple rate'], + ['slickAmbient', 0, 1, 0.01, 'ambient'], + ['slickSpecular', 0, 2, 0.01, 'specular'], + ['slickGloss', 2, 128, 1, 'gloss'], + ['slickDetail', 0, 1, 0.01, 'detail'], + ['slickSharp', 0, 1, 0.01, 'sharpness'], + ['slickEmissive', 0, 3, 0.01, 'emissive'], + ['slickOpacity', 0, 1, 0.01, 'opacity'], + ['slickDepthFade', 0.02, 3, 0.01, 'soft intersection'], + ['slickDry', 0, 1, 0.01, 'drying back'], + ['colorSlickBase', 'wet stone'], + ['colorSlickEdge', 'meniscus'], + ['colorSlickGlow', 'glow'], + ['colorSlickDeep', 'bottom'] + ], + 'The goo': [ + ['gooRate', 0, 200, 1, 'rate'], + ['gooBurst', 0, 120, 1, 'per hatch'], + ['gooSize', 0.005, 0.4, 0.005, 'size'], + ['gooSpeed', 0, 10, 0.05, 'speed'], + ['gooLifetime', 0.1, 6, 0.05, 'lifetime'], + ['gooGravity', -40, 0, 0.5, 'gravity'], + ['gooGlow', 0, 6, 0.01, 'glow'], + ['colorGoo*', 'Goo colour'] + ], + 'The shell fragments': [ + ['chipBurst', 0, 40, 1, 'per hatch'], + ['chipSize', 0.005, 0.4, 0.005, 'size'], + ['chipSpeed', 0, 10, 0.05, 'speed'], + ['chipLifetime', 0.1, 6, 0.05, 'lifetime'], + ['chipGravity', -40, 0, 0.5, 'gravity'], + ['chipSpin', 0, 30, 0.5, 'spin'], + ['colorChip*', 'Fragment colour'] + ], + 'The haze': [ + ['hazeRate', 0, 200, 1, 'rate'], + ['hazeSize', 0.05, 4, 0.01, 'size'], + ['hazeSpeed', 0, 5, 0.05, 'speed'], + ['hazeLifetime', 0.2, 8, 0.05, 'lifetime'], + ['hazeRise', -2, 3, 0.01, 'buoyancy'], + ['hazeOpacity', 0, 1, 0.01, 'opacity'], + ['colorHaze*', 'Haze colour'] + ], + 'Muzzle & landing': [ + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['handHeight', 0, 3, 0.01, 'hand height'], + ['muzzleSize', 0.05, 4, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 4, 0.01, 'muzzle intensity'], + ['castFlash', 0, 1, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 10, 0.05, 'landing shell'], + ['burstIntensity', 0, 4, 0.01, 'shell intensity'], + ['burstGoo', 0, 400, 1, 'goo on landing'], + ['shockRadius', 0.2, 14, 0.05, 'ring'], + ['impactShake', 0, 2, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 1, 0.01, 'screen flash'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['slickDecalRadius', 0.2, 3, 0.01, 'mat radius × zone'], + ['slickDecalLife', 0.5, 20, 0.1, 'mat life'], + ['slickDecalIntensity', 0, 3, 0.01, 'mat intensity'], + ['colorBurstA', 'shell'], + ['colorBurstB', 'shell body'], + ['colorBurstC', 'shell motes'], + ['colorFlash', 'landing flash colour'], + ['colorMat', 'mat'], + ['colorMatEdge', 'mat rim'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 4, 0.01, 'light height (m)'], + ['lightPulse', 0, 1, 0.01, 'pulse depth'], + ['lightPulseRate', 0.05, 8, 0.01, 'pulse rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/bubblecage.js b/src/config/abilities/bubblecage.js new file mode 100644 index 0000000..5241f5d --- /dev/null +++ b/src/config/abilities/bubblecage.js @@ -0,0 +1,408 @@ +/* ================================================================== */ +/* ABYSSAL CAGE — the slot where colour is a measurement */ +/* ================================================================== */ +/** + * A far cast. A bell of black water heaves out of the floor, and what it leaves + * standing is a **cage of soap film** — thirty-odd bubbles interlocked on a + * sphere around the circle, each one draining, marbling and eventually + * bursting from its own crown. + * + * **The trick is thin-film interference, done properly.** The hue of every + * pixel of film is computed from that film's *thickness* against the *view + * angle* — `Δ = 2·n·d·cos θₜ`, plus the half-wave the hard front reflection + * adds, integrated across the visible band against the CIE colour matching + * functions and converted to sRGB. See `materials/ThinFilmMaterial.js` for the + * derivation and for what the two wrong versions looked like. + * + * **There is therefore no film colour in this block, and that is deliberate.** + * If you have come here looking for `colorFilm`, the slider you want is + * `filmThickness` — 320 nm is gold and magenta, 480 nm is blue and silver, 180 + * nm is a bruise, and 60 nm is nearly black. That is not a mapping anybody + * authored; it is what a film of that thickness does. The four pickers below + * cover the things that genuinely are authored: the water's body tint under the + * interference, the glint, the silhouette wash, and what little light a film + * thinner than a quarter-wave still returns. + * + * **The black crown is the tell.** Gravity drains a real film downward, so the + * top thins first, and when it drops below a quarter-wave every wavelength + * cancels and the crown goes *black* — the last thing a bubble does before it + * bursts. Drive `filmDrain` up with a cage standing and you can watch the + * ceiling of every bubble lose its colour in order. + * + * A cast captures one seed and four unitless dice rolls per bubble (a direction + * on the cage, a size fraction, a decorrelation seed, a pop roll). Every + * nanometre, metre and second is resolved inside the update loop, zero-length + * frames included: pause with a cage standing, drag `filmThickness`, and every + * band on every bubble marches. + */ + +import { ShellMode, shellDefaults, shellSchema } from '../../vfx/Shell.js'; + +export const bubblecage = { + /* --- the cast --- */ + range: 18.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + zoneRadius: 4.2, // the circle the indicator draws — and the cage's own scale, metres + speed: 26.0, // how fast the bead of water reaches the point, metres/second + cooldown: 1.4, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats (seconds; scaled by global.lifetime) --------------- */ + bellTime: 0.55, // the water bell heaving up and vanishing again + holdTime: 3.4, // the cage standing, start to first collapse + fadeTime: 1.6, // what is left of the cage giving out + + /* --- the cage ------------------------------------------------------ */ + bubbles: 34, // how many bubbles the cage is built from, 1..64 + cageRadius: 0.86, // bubble-centre sphere, × zoneRadius + bubbleRadius: 0.27, // one bubble, × zoneRadius. Above ~0.3 they interlock, + // which is what makes it read as a cage rather than as + // a handful of balloons hanging in the air + bubbleScatter: 0.4, // 0..1 spread of the per-bubble size dice + cageBreathe: 0.05, // radial pulse of the whole cage, × cageRadius + cageBreatheSpeed: 0.35, // Hz + cageJostle: 0.18, // tangential slop on one bubble, metres + cageJostleSpeed: 0.4, // Hz + cageSquash: 0.72, // vertical scale on the cage sphere. 1 is a ball; below 1 + // it settles into a dome resting on the stone, which is + // what stops it reading as a beach ball hanging in the air + cageLift: 0.12, // metres the whole cage floats above its own footprint + + /* --- one bubble's life --------------------------------------------- */ + bubbleStagger: 0.55, // seconds between the first bubble inflating and the last + bubbleInflate: 0.34, // seconds one bubble takes to reach full size + bubbleOvershoot: 0.24, // how far past that size it snaps on the way + bubbleSwell: 0.1, // extra radius as the film gives out, fraction + + /* --- the film (the trick) ------------------------------------------- */ + filmThickness: 420.0, // NANOMETRES at the foot of the bubble. THE slider. + filmIor: 1.34, // refractive index of the film. Water is 1.333; pushing it + // toward 2.4 crowds the bands the way an oil slick does + filmDrain: 0.6, // 0..1 how much thinner the crown is than the foot + filmDrainSeed: 0.28, // 0..1 of that gradient already present at birth + filmThinRate: 0.74, // fraction of the whole film lost by the moment it pops + filmMarble: 0.32, // 0..1 thickness variation from convection in the film + filmMarbleScale: 2.1, // marbling features per bubble + filmFlow: 0.28, // Hz the marbling creeps around the film + filmReflect: 0.06, // normal-incidence reflectance. Water is really 0.02 and + // at 0.02 the film is invisible against the stage; 0.06 is + // the smallest number that reads at twenty metres + filmFresnel: 3.4, // Schlick exponent — amplitude only, never hue + filmGain: 9.0, // master on the interference term + filmSaturation: 1.0, // 0 renders the same physics in grey, which is a + // genuinely useful A/B when tuning the thickness + filmRim: 0.35, // wash on the silhouette so the cage keeps its shape + filmRimPower: 3.0, + filmSheen: 1.5, // the single specular glint + filmSheenSharp: 90.0, // its tightness. Broad readings turn it to plastic + filmOpacity: 1.0, + filmGlow: 1.5, // emissive gain into bloom + filmSoftFade: 0.35, // metres of depth fade against the opaque scene + colorFilmBody: '#bfe8ff', // the water's own tint, under the interference + colorFilmBlack: '#0a1420', // what a film below a quarter-wave still returns + colorFilmSheen: '#ffffff', // the glint and the retracting rupture rim + colorFilmRim: '#4fd8d0', // the silhouette wash + + /* --- the rupture ---------------------------------------------------- */ + popTime: 2.2, // seconds after its own birth a bubble's film gives way + popSpread: 1.8, // seconds of scatter on that, so they go one at a time + popCollapse: 0.9, // 0..1 how far the fade phase hauls every deadline in + popBurstTime: 0.16, // seconds the hole takes to eat one bubble + popJitter: 0.7, // radians the rupture wanders off the crown + popRimWidth: 0.12, // radians of retracting rim behind the hole + popFlash: 1.7, // brightness of that rim + popDroplets: 26, // droplets thrown by one bubble bursting + popMist: 7, // mist puffs from the same + popFoamLife: 2.4, // seconds the foam mark under a burst lasts + popFoamRadius: 0.5, // metres + popFoamIntensity: 0.8, + colorFoamA: '#cfeef0', // foam body + colorFoamB: '#6fbfc8', // foam edge + + /* --- the impact ------------------------------------------------------ */ + burstSize: 2.6, // the water burst on the frame the bell lands, metres + burstIntensity: 1.2, + colorBurstA: '#2c6a86', + colorBurstB: '#8fdcea', + colorBurstC: '#ffffff', + impactShake: 0.5, + shakeDuration: 0.6, // seconds it decays over + impactFlash: 0.12, // full-screen flash, 0..2 + colorFlash: '#a8e4f0', + rippleRadius: 3.4, // the ring on the floor, metres + rippleLife: 1.6, // seconds + rippleIntensity: 1.0, + colorRippleA: '#3a86a0', + colorRippleB: '#d8f6ff', + + /* --- the pool the cage stands in (vfx/GroundField.js, POOL) ---------- */ + poolRadius: 1.05, // × zoneRadius + poolHeight: 0.02, // metres above the floor the quad lies at + poolEdge: 0.3, // metres of feather on the front + poolRagged: 0.24, // how far the rim wanders, fraction of the radius + poolRaggedScale: 0.8, // lobes per metre + poolWarp: 0.45, // metres of domain warp on those lobes + poolDepth: 0.16, // metres — how deep the standing water reads + poolLift: 0.05, // metres — the meniscus at the rim + poolThickness: 0.05, // metres — that rim's width + poolFlow: 0.22, // metres/second the surface drifts at + poolWind: 0.6, // radians — the bearing that drift runs along + poolCell: 0.55, // surface features per metre + poolSpeed: 1.0, // Hz the surface noise crawls at + poolDetail: 0.6, // 0..1 how much of that surface is drawn + poolNormalStep: 0.06, // metres between the height taps that fake the normal + poolWrap: 0.45, // 0..1 wraps the terminator round the back + poolSpecular: 0.75, + poolGloss: 42.0, // Blinn exponent + poolRelief: 0.5, + poolAmbient: 0.3, + poolOpacity: 0.85, + poolEmissive: 0.6, + poolGrow: 0.28, // seconds the pool takes to spread to full radius + poolDry: 1.0, // 0..1 how far the fade dries it back from the rim + colorPoolBase: '#0d3242', // the water itself + colorPoolEdge: '#a9e8f2', // the meniscus and the sheen + colorPoolGlow: '#3fd0c8', // anything emissive in it + colorPoolDeep: '#04141c', // the bottom + + /* --- droplets thrown by a burst -------------------------------------- */ + dropRate: 40, // droplets/second shed by the standing cage + dropSize: 0.06, + dropSpeed: 3.4, + dropLifetime: 1.3, + dropGravity: -14.0, // metres/second² + colorDropA: '#eafbff', + colorDropB: '#9fe0f0', + colorDropC: '#4f9fc0', + colorDropD: '#1c4a60', + + /* --- the mist that hangs inside the cage ----------------------------- */ + mistRate: 26, // puffs/second + mistSize: 0.75, + mistSpeed: 0.6, + mistLifetime: 2.2, + mistRise: 0.35, // metres/second² + mistOpacity: 0.4, + colorMistA: '#bfe4ec', + colorMistB: '#7fb4c4', + colorMistC: '#40707f', + colorMistD: '#1b3540', + + /* --- fizz: the small bubbles rising inside the cage ------------------ */ + fizzRate: 55, // motes/second + fizzSize: 0.05, + fizzSpeed: 1.1, + fizzLifetime: 1.8, + fizzRise: 2.4, // metres/second² — these go up, hard + fizzTurbulence: 0.6, + colorFizzA: '#ffffff', + colorFizzB: '#b6f0ff', + colorFizzC: '#57c0d8', + colorFizzD: '#1d5a72', + + /* --- dynamic light --------------------------------------------------- */ + lightIntensity: 6.0, // the standing level; the beats punch it with `lightBoost` + lightRadius: 15.0, + lightColor: '#7fd8e8', + + /* --- the bell (vfx/Shell.js, DOME mode, prefix `bell`) --------------- */ + // 44 keys. The RING_TRAIN and SUNDISC members are inert in DOME mode and are + // left out of the schema on purpose — they land in the editor's trailing + // "More" folder, which is what it is for. + ...shellDefaults('bell', ShellMode.DOME, { + bellRadius: 0.5, // the travelling bead of water, metres + bellRadiusEnd: 4.6, // the bell at the top of its heave, metres + bellExpand: 3.2, + bellHeight: 1.15, // taller than it is wide — water thrown up, not out + bellLift: 0.02, + bellDisplace: 0.24, + bellNoiseScale: 2.2, + bellNoiseSpeed: 1.1, + bellFill: 0.22, + bellRim: 1.5, + bellRimPower: 2.2, + bellSeal: 1.8, + bellSealWidth: 0.14, + bellDissolve: 1.05, + bellOpacity: 0.8, + bellGlow: 1.3, + bellSoftFade: 0.5, + bellColorBody: '#10485f', + bellColorRim: '#8fe0ee', + bellColorEdge: '#ffffff', + bellColorCorona: '#3fd0c8' + }) +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Abyssal Cage. + * + * Open **The film** and drag `filmThickness` first. Nothing else in this + * project answers a slider the way that one does — the whole cage changes hue, + * in the right order, because you moved a distance in nanometres. Then + * `filmDrain`, which is the black crown, and `filmIor`, which is how tightly + * the bands crowd. + * + * `filmSaturation` at 0 is the honest A/B: the same physics with the colour + * taken out, so you can see that the *pattern* is doing the work and the hue is + * only what the pattern is made of. + * + * The `bell*` folders come from `shellSchema('bell', ShellMode.DOME)`; the + * ring-train and sun-disc keys `shellDefaults` also brings in do nothing to a + * dome and are deliberately unfiled. + */ +export const bubblecageSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['zoneRadius', 1, 12, 0.05, 'cage scale (m)'], + ['speed', 5, 120, 0.5, 'travel speed'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['bellTime', 0.05, 2, 0.01, 'water bell (s)'], + ['holdTime', 0.3, 10, 0.05, 'cage standing (s)'], + ['fadeTime', 0.1, 6, 0.05, 'collapse (s)'] + ], + 'The film': [ + ['filmThickness', 40, 900, 1, 'thickness (nm) — THE slider'], + ['filmIor', 1.02, 2.6, 0.005, 'refractive index'], + ['filmDrain', 0, 1, 0.005, 'crown thinning'], + ['filmDrainSeed', 0, 1, 0.005, 'drainage at birth'], + ['filmThinRate', 0, 1, 0.005, 'thinning over life'], + ['filmMarble', 0, 1, 0.005, 'marbling'], + ['filmMarbleScale', 0.2, 8, 0.05, 'marbling scale'], + ['filmFlow', 0, 2, 0.01, 'marbling drift (Hz)'], + ['filmSaturation', 0, 2, 0.01, 'saturation (0 = grey A/B)'] + ], + 'The film/How much light': [ + ['filmReflect', 0.005, 0.4, 0.001, 'normal reflectance'], + ['filmFresnel', 0.5, 8, 0.05, 'Schlick exponent'], + ['filmGain', 0, 30, 0.1, 'interference gain'], + ['filmRim', 0, 2, 0.01, 'silhouette wash'], + ['filmRimPower', 0.5, 8, 0.05, 'wash tightness'], + ['filmSheen', 0, 5, 0.01, 'glint'], + ['filmSheenSharp', 4, 400, 1, 'glint tightness'], + ['filmOpacity', 0, 1, 0.005, 'opacity'], + ['filmGlow', 0, 5, 0.01, 'glow'], + ['filmSoftFade', 0.02, 2, 0.01, 'depth fade (m)'] + ], + 'The film/Colour (the hue is NOT here)': [ + ['colorFilmBody', 'water body tint'], + ['colorFilmBlack', 'below a quarter-wave'], + ['colorFilmSheen', 'glint'], + ['colorFilmRim', 'silhouette wash'] + ], + 'The cage': [ + ['bubbles', 1, 64, 1, 'bubbles'], + ['cageRadius', 0.2, 1.6, 0.005, 'cage sphere × zone'], + ['bubbleRadius', 0.05, 0.6, 0.005, 'one bubble × zone'], + ['bubbleScatter', 0, 1, 0.005, 'size scatter'], + ['cageSquash', 0.1, 1.4, 0.005, 'squash onto the floor'], + ['cageLift', -1, 3, 0.01, 'float above the floor (m)'], + ['cageBreathe', 0, 0.5, 0.005, 'breathe × radius'], + ['cageBreatheSpeed', 0, 3, 0.01, 'breathe (Hz)'], + ['cageJostle', 0, 1.5, 0.005, 'jostle (m)'], + ['cageJostleSpeed', 0, 3, 0.01, 'jostle (Hz)'] + ], + 'The cage/One bubble': [ + ['bubbleStagger', 0, 3, 0.01, 'stagger (s)'], + ['bubbleInflate', 0.02, 2, 0.01, 'inflate (s)'], + ['bubbleOvershoot', 0, 1.5, 0.01, 'overshoot'], + ['bubbleSwell', 0, 0.8, 0.005, 'swell before the pop'] + ], + 'The rupture': [ + ['popTime', 0.1, 8, 0.05, 'film lifetime (s)'], + ['popSpread', 0, 6, 0.05, 'scatter (s)'], + ['popCollapse', 0, 1, 0.005, 'collapse on fade'], + ['popBurstTime', 0.02, 1, 0.005, 'hole opens over (s)'], + ['popJitter', 0, 3.14, 0.01, 'rupture wander (rad)'], + ['popRimWidth', 0.01, 1, 0.005, 'retracting rim (rad)'], + ['popFlash', 0, 6, 0.01, 'rim brightness'], + ['popDroplets', 0, 120, 1, 'droplets per burst'], + ['popMist', 0, 60, 1, 'mist per burst'], + ['popFoamLife', 0.1, 8, 0.05, 'foam mark (s)'], + ['popFoamRadius', 0.05, 3, 0.01, 'foam radius (m)'], + ['popFoamIntensity', 0, 2, 0.01, 'foam intensity'], + ['colorFoamA', 'foam body'], + ['colorFoamB', 'foam edge'] + ], + 'The impact': [ + ['burstSize', 0.2, 10, 0.05, 'water burst (m)'], + ['burstIntensity', 0, 3, 0.01, 'burst intensity'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake decay (s)'], + ['impactFlash', 0, 2, 0.005, 'screen flash'], + ['rippleRadius', 0.2, 12, 0.05, 'ring radius (m)'], + ['rippleLife', 0.1, 6, 0.05, 'ring lifetime (s)'], + ['rippleIntensity', 0, 3, 0.01, 'ring intensity'], + ['colorBurstA', 'burst body'], + ['colorBurstB', 'burst rim'], + ['colorBurstC', 'burst core'], + ['colorFlash', 'flash colour'], + ['colorRippleA', 'ring body'], + ['colorRippleB', 'ring crest'] + ], + 'The pool': [ + ['poolRadius', 0.2, 2.5, 0.01, 'pool × zone'], + ['poolGrow', 0.02, 3, 0.01, 'spread (s)'], + ['poolDry', 0, 1, 0.005, 'drying on the fade'], + ['poolHeight', 0.002, 0.3, 0.002, 'height above floor (m)'], + ['poolEdge', 0.02, 2, 0.01, 'rim feather (m)'], + ['poolRagged', 0, 1, 0.005, 'rim wander'], + ['poolRaggedScale', 0.1, 4, 0.05, 'rim lobes / m'], + ['poolWarp', 0, 3, 0.01, 'rim warp (m)'], + ['poolDepth', 0.01, 1.5, 0.005, 'depth (m)'], + ['poolLift', 0, 0.5, 0.005, 'meniscus (m)'], + ['poolThickness', 0.005, 0.4, 0.005, 'meniscus width (m)'], + ['poolFlow', 0, 2, 0.01, 'surface drift (m/s)'], + ['poolWind', 0, 6.28, 0.01, 'drift bearing (rad)'], + ['poolCell', 0.05, 3, 0.01, 'surface features / m'], + ['poolSpeed', 0, 4, 0.01, 'surface crawl (Hz)'], + ['poolDetail', 0, 1, 0.005, 'detail'], + ['poolNormalStep', 0.01, 0.4, 0.005, 'normal tap (m)'], + ['poolWrap', 0, 1, 0.005, 'terminator wrap'], + ['poolSpecular', 0, 3, 0.01, 'specular'], + ['poolGloss', 2, 128, 1, 'gloss'], + ['poolRelief', 0, 2, 0.01, 'relief'], + ['poolAmbient', 0, 1, 0.005, 'ambient'], + ['poolOpacity', 0, 1, 0.005, 'opacity'], + ['poolEmissive', 0, 3, 0.01, 'emissive'], + ['colorPoolBase', 'water'], + ['colorPoolEdge', 'meniscus'], + ['colorPoolGlow', 'emissive'], + ['colorPoolDeep', 'bottom'] + ], + 'Droplets, mist & fizz': [ + ['dropRate', 0, 400, 1, 'droplet rate'], + ['dropSize', 0.005, 0.4, 0.005, 'droplet size'], + ['dropSpeed', 0, 20, 0.1, 'droplet speed'], + ['dropLifetime', 0.1, 5, 0.05, 'droplet lifetime'], + ['dropGravity', -40, 0, 0.1, 'droplet gravity'], + ['mistRate', 0, 300, 1, 'mist rate'], + ['mistSize', 0.05, 4, 0.01, 'mist size'], + ['mistSpeed', 0, 10, 0.05, 'mist speed'], + ['mistLifetime', 0.2, 8, 0.05, 'mist lifetime'], + ['mistRise', -2, 4, 0.01, 'mist rise'], + ['mistOpacity', 0, 1, 0.005, 'mist opacity'], + ['fizzRate', 0, 400, 1, 'fizz rate'], + ['fizzSize', 0.005, 0.4, 0.005, 'fizz size'], + ['fizzSpeed', 0, 10, 0.05, 'fizz speed'], + ['fizzLifetime', 0.1, 6, 0.05, 'fizz lifetime'], + ['fizzRise', -2, 8, 0.01, 'fizz rise'], + ['fizzTurbulence', 0, 3, 0.01, 'fizz turbulence'], + ['colorDrop*', 'Droplet colour'], + ['colorMist*', 'Mist colour'], + ['colorFizz*', 'Fizz colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'standing intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ], + ...shellSchema('bell', ShellMode.DOME) +}; diff --git a/src/config/abilities/carapace.js b/src/config/abilities/carapace.js new file mode 100644 index 0000000..93e2c63 --- /dev/null +++ b/src/config/abilities/carapace.js @@ -0,0 +1,312 @@ +/* ================================================================== */ +/* CARAPACE — a dome of interlocking chitin plates */ +/* ================================================================== */ +/** + * One spherical Voronoi tessellation, cut by `sites` and `jitter`, whose cells + * fly in from outside and lock against their neighbours' edges. + * + * **The two structural keys.** `sites` and `jitter` are the only numbers in + * here that re-run the clipper — everything else is a uniform, live on a + * zero-length frame. `sites` is how many plates the dome is made of; `jitter` + * wobbles the Fibonacci lattice they sit on, and at 0 the dome is a perfectly + * regular honeycomb that reads as a manufactured object rather than as armour. + * + * **The dome's footprint is `zoneRadius`.** Not a radius of its own: the aim + * circle measured the dome out, and `domeHeight` is the only thing that makes + * it a dome rather than a hemisphere. That sharing *is* the design, in the + * sense invariant I5 allows. + * + * **`seam` is the one to be careful with.** It draws every plate's rim in + * toward its own centre by a fraction *of the unit sphere*, before any metres + * arrive, so two neighbours inset by the same amount and the gap between them + * stays even all the way round. Small, it is the dark line between two plates. + * Large, the dome is a scatter of debris — which is what the fade uses it for, + * and what `openSeam` is. + */ +export const carapace = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 36.0, // how fast the summoning front travels, metres/second + cooldown: 1.6, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 3.6, // metres — the aim circle, and the dome's own footprint + holdTime: 2.2, // seconds the closed dome stands + fadeTime: 1.4, // seconds the seams take to open and the dome to go + + /* --- the tessellation: only these two re-run the clipper --- */ + sites: 52, // plates, whole number, at least 12 + jitter: 0.36, // unitless wobble on the Fibonacci lattice — 0 is too regular + + /* --- the dome --- */ + domeHeight: 0.74, // dome height as a fraction of `zoneRadius` + thickness: 0.13, // metres a plate is extruded inward + seam: 0.042, // 0..1 the rim is drawn in toward the plate centre + flyOut: 3.0, // metres a plate starts out from its locked place + tumbleSpin: 2.0, // radians it starts rotated about its own axis + tumbleSwing: 1.15, // radians it starts tipped off the dome + stagger: 0.014, // seconds between one plate locking and the next + lockTime: 0.34, // seconds one plate takes to arrive + orderScatter: 0.26, // 0 they lock from the ground up, 1 at random + + /* --- the shell's look --- */ + tintHeight: 0.9, // how much of the gradient the dome's height walks + tintJitter: 0.17, + wrap: 0.38, // 0 hard terminator, 1 light bleeding through the plate + rimPow: 3.0, // fresnel exponent — the grazing shine that says chitin + rimGain: 0.7, + sheenPow: 56.0, // the oily highlight that slides across a carapace + sheenGain: 0.95, + seamGlow: 0.45, // how hot the side walls are — the only evidence of seams + arriveGain: 1.7, // flash as a plate locks + glow: 1.0, + colorPlateA: '#4a3c1c', // at the foot of the dome + colorPlateB: '#8a6f26', + colorPlateC: '#c9a63a', + colorPlateD: '#efdf92', // at the crown + colorRim: '#fff0bc', // the fresnel rim + colorSheen: '#ffffff', // the sheen lobe + colorSeam: '#ffab2e', // light from inside the shell, out along a seam + colorArrive: '#fff2c4', // the flash as a plate locks + + /* --- the opening: the seams come apart along the tessellation --- */ + openSeam: 0.4, // 0..1 extra inset at full open — the gaps ARE the Voronoi + openRetract: 2.4, // exponent on the collapse: high holds full size, then goes + openSpill: 3.2, // how much the seam light climbs as the gaps widen + openChips: 120, // chips thrown as the dome lets go + + /* --- the apron: the chitin floor the dome grows out of --- */ + apronRadius: 1.16, // × zoneRadius + apronHeight: 0.02, // metres above the floor the quad sits at + apronEdge: 0.3, // metres of feather on the front + apronRagged: 0.26, // how far the front wanders, fraction of the radius + apronRaggedScale: 0.7, // lobes per metre + apronWarp: 0.42, // metres of domain warp on those lobes + apronCell: 0.6, // metres — plate size on the floor + apronCellJitter: 0.85, + apronSeam: 0.055, // metres — gap between floor plates + apronThickness: 0.05, // metres — sheet thickness + apronLift: 0.11, // metres — how far a downwind edge curls up + apronRelief: 0.65, // how hard the height field tilts the fake normal + apronAmbient: 0.3, + apronWrap: 0.45, + apronSpecular: 0.5, + apronGloss: 24.0, + apronEmissive: 0.9, + apronOpacity: 0.9, + apronDepthFade: 0.5, // metres of soft fade where it meets the dome + colorApronBase: '#6a5626', + colorApronEdge: '#d9c274', + colorApronGlow: '#ffab2e', + colorApronDeep: '#1a1408', + + /* --- chips knocked off as each plate slams home --- */ + chipLand: 5, // chips thrown by one landing plate + chipSize: 0.075, + chipSpeed: 3.4, + chipLifetime: 1.2, + chipGravity: -14.0, + colorChipA: '#e8cf7c', + colorChipB: '#b08f34', + colorChipC: '#6d5820', + colorChipD: '#2e2510', + + /* --- dust pushed out from under the rim --- */ + dustRate: 40.0, // particles/second + dustSize: 0.8, + dustSpeed: 1.4, + dustLifetime: 2.4, + dustRise: 0.3, + dustOpacity: 0.11, + colorDustA: '#7d7048', + colorDustB: '#6a6042', + colorDustC: '#4c4530', + colorDustD: '#26221a', + + /* --- amber motes leaking out of the seams --- */ + moteRate: 55.0, // particles/second + moteSize: 0.05, + moteSpeed: 0.9, + moteLifetime: 1.7, + moteRise: 0.8, // upward drift, metres/second + moteTurbulence: 0.6, + colorMoteA: '#fff0c2', + colorMoteB: '#ffb63c', + colorMoteC: '#c07414', + colorMoteD: '#3a2205', + + /* --- the seal: the moment the ring of first plates bites --- */ + burstSize: 2.4, // the dust shell where the dome grounds out, metres + burstIntensity: 1.1, + shockRadius: 5.5, // impact ring across the floor, metres + crackRadius: 3.0, // fractures under the rim, metres + crackLife: 5.0, + crackIntensity: 0.6, + sealFlash: 0.12, // screen flash as the dome closes + openFlash: 0.16, // and as it comes apart + impactShake: 0.7, + shakeDuration: 0.5, + rumble: 0.02, // continuous shake while the plates are still arriving + colorBurstA: '#7d6c3a', + colorBurstB: '#c9a63a', + colorBurstC: '#efdf92', + colorShockA: '#c9a63a', + colorShockB: '#fff2c4', + colorCrack: '#ffab2e', + colorCrackDeep: '#241a06', + colorSealFlash: '#e8cf7c', + colorOpenFlash: '#ffc457', + + /* --- dynamic light: it lives INSIDE the dome --- */ + lightIntensity: 16.0, + lightRadius: 11.0, + lightColor: '#ffb040', + lightHeight: 0.45, // fraction of the dome's height the light hangs at + lightPulse: 0.3, // depth of the light's breathing, 0 = steady + lightPulseSpeed: 3.4, + lightSpill: 2.4 // how far the light climbs as the seams open +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Carapace. + * + * The folder to open first is **The tessellation**: drag `sites` and watch the + * dome re-cut itself, plate for plate, with no gaps at any count. Then take + * `seam` up from 0.04 to 0.4 with the clock paused — every gap that opens is a + * real Voronoi edge shared exactly by the two plates either side of it, which + * is the entire claim this slot is making. + * + * `orderScatter` decides whether the dome is *built* or *dropped on you*: at 0 + * the plates lock from the ground up, at 1 they arrive in no order at all. + */ +export const carapaceSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 200, 1, 'front speed'], + ['zoneRadius', 0.5, 12, 0.05, 'footprint radius'], + ['holdTime', 0.05, 8, 0.01, 'hold time'], + ['fadeTime', 0.05, 5, 0.01, 'open time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The tessellation': [ + ['sites', 12, 96, 1, 'plates'], + ['jitter', 0, 1.5, 0.01, 'lattice wobble'], + ['domeHeight', 0.1, 2, 0.01, 'height / radius'], + ['thickness', 0.01, 0.8, 0.005, 'plate thickness'], + ['seam', 0, 0.4, 0.002, 'seam inset'] + ], + 'The fly-in': [ + ['flyOut', 0, 12, 0.05, 'start distance'], + ['tumbleSpin', 0, 8, 0.05, 'start spin'], + ['tumbleSwing', 0, 3, 0.01, 'start tip'], + ['stagger', 0, 0.12, 0.001, 'plate to plate'], + ['lockTime', 0.02, 2, 0.01, 'one plate lands in'], + ['orderScatter', 0, 1, 0.01, 'order scatter'], + ['arriveGain', 0, 6, 0.01, 'landing flash'] + ], + 'The shell': [ + ['tintHeight', 0, 2, 0.01, 'height tint'], + ['tintJitter', 0, 1, 0.01, 'per-plate tint'], + ['wrap', 0, 1, 0.01, 'light wrap'], + ['rimPow', 0.2, 10, 0.05, 'rim exponent'], + ['rimGain', 0, 3, 0.01, 'rim gain'], + ['sheenPow', 2, 160, 1, 'sheen tightness'], + ['sheenGain', 0, 4, 0.01, 'sheen gain'], + ['seamGlow', 0, 3, 0.01, 'seam glow'], + ['glow', 0, 4, 0.01, 'glow'], + ['colorPlate*', 'Plate body'], + ['colorRim', 'rim'], + ['colorSheen', 'sheen'], + ['colorSeam', 'seam light'], + ['colorArrive', 'landing flash'] + ], + 'The opening': [ + ['openSeam', 0, 1, 0.01, 'seam at full open'], + ['openRetract', 0.2, 8, 0.05, 'collapse curve'], + ['openSpill', 0, 10, 0.05, 'seam light spill'], + ['openChips', 0, 400, 1, 'chips at the open'], + ['openFlash', 0, 1, 0.005, 'screen flash'], + ['colorOpenFlash', 'open flash'] + ], + 'The apron': [ + ['apronRadius', 0.2, 3, 0.01, 'radius × zone'], + ['apronHeight', 0, 0.3, 0.005, 'height off floor'], + ['apronEdge', 0.02, 2, 0.01, 'front feather'], + ['apronRagged', 0, 1, 0.01, 'front raggedness'], + ['apronRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['apronWarp', 0, 3, 0.01, 'domain warp'], + ['apronCell', 0.05, 3, 0.01, 'plate size'], + ['apronCellJitter', 0, 1, 0.01, 'plate jitter'], + ['apronSeam', 0, 0.4, 0.005, 'plate gap'], + ['apronThickness', 0, 0.4, 0.005, 'sheet thickness'], + ['apronLift', 0, 0.6, 0.005, 'edge curl'], + ['apronRelief', 0, 2, 0.01, 'relief'], + ['apronAmbient', 0, 1, 0.01, 'ambient'], + ['apronWrap', 0, 1, 0.01, 'light wrap'], + ['apronSpecular', 0, 2, 0.01, 'specular'], + ['apronGloss', 2, 120, 1, 'gloss'], + ['apronEmissive', 0, 3, 0.01, 'emissive'], + ['apronOpacity', 0, 2, 0.01, 'opacity'], + ['apronDepthFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorApronBase', 'apron body'], + ['colorApronEdge', 'apron rim'], + ['colorApronGlow', 'apron glow'], + ['colorApronDeep', 'apron interior'] + ], + 'Chips, dust & motes': [ + ['chipLand', 0, 40, 1, 'chips / plate'], + ['chipSize', 0.005, 0.4, 0.005, 'chip size'], + ['chipSpeed', 0, 20, 0.1, 'chip speed'], + ['chipLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['chipGravity', -50, 0, 0.1, 'chip gravity'], + ['dustRate', 0, 400, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 8, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['moteRate', 0, 400, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 8, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -2, 6, 0.01, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorChip*', 'Chip colour'], + ['colorDust*', 'Dust colour'], + ['colorMote*', 'Mote colour'] + ], + 'The seal': [ + ['burstSize', 0.1, 10, 0.05, 'dust shell'], + ['burstIntensity', 0, 3, 0.01, 'shell intensity'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['crackRadius', 0.1, 10, 0.05, 'fracture radius'], + ['crackLife', 0.5, 20, 0.1, 'fracture lifetime'], + ['crackIntensity', 0, 2, 0.01, 'fracture intensity'], + ['sealFlash', 0, 1, 0.005, 'screen flash'], + ['impactShake', 0, 3, 0.01, 'seal shake'], + ['shakeDuration', 0.05, 2, 0.01, 'shake decay'], + ['rumble', 0, 0.3, 0.001, 'build rumble'], + ['colorBurstA', 'shell inner'], + ['colorBurstB', 'shell mid'], + ['colorBurstC', 'shell rim'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorCrack', 'fracture'], + ['colorCrackDeep', 'fracture interior'], + ['colorSealFlash', 'seal flash'] + ], + 'Light': [ + ['lightIntensity', 0, 60, 0.5, 'intensity'], + ['lightRadius', 1, 40, 0.5, 'radius'], + ['lightHeight', 0, 1.5, 0.01, 'height in the dome'], + ['lightPulse', 0, 1, 0.01, 'breathing depth'], + ['lightPulseSpeed', 0, 20, 0.1, 'breathing rate'], + ['lightSpill', 0, 8, 0.05, 'spill on opening'], + ['lightColor', 'colour'] + ] +}; diff --git a/src/config/abilities/chainarc.js b/src/config/abilities/chainarc.js new file mode 100644 index 0000000..cfc4304 --- /dev/null +++ b/src/config/abilities/chainarc.js @@ -0,0 +1,435 @@ +/* ================================================================== */ +/* CHAIN ARC — storm, line */ +/* ================================================================== */ +/** + * A discharge that **hops**. Everything else in the sandbox that crosses a + * distance travels: a front moves at metres per second and the effect is drawn + * behind it. This one picks five to nine points down the aimed line and is + * simply *at* the first, and then *at* the second — the space between two nodes + * was never crossed so much as skipped, and that discontinuity is the whole + * read. + * + * Nothing below is a position. The graph is stored as unitless fractions + * (`along`, `lateral`, `lift`) rolled once per cast; `scatter` and `lift` are + * the metres those fractions are multiplied by, *in the vertex shader*, on the + * frame they are read. That is why dragging `scatter` re-routes a chain already + * in the air, and why `route` — a plain integer dial — re-rolls the whole graph + * under a discharge mid-flight without restarting it. The roster asks Chain Arc + * for exactly that trick and it is the one thing this block exists to make + * possible. + * + * The hop clock is expressed in **metres per second**, not in seconds per hop. + * The first version had a `hopTime` slider and it was wrong in a way that took a + * while to name: dropping the node count from nine to five made every hop + * longer but left the cadence identical, so a long chain and a short one took + * the same time to arrive and the discharge stopped feeling like it was + * propagating through anything. `speed` divided by the hop's own length fixes + * it — a short hop is quick, a long one is not, and the total flight time + * tracks the cast length the way it should. + * + * Three roles share one `FilamentPaths` strip: the chain itself (driven by + * `ArcNetwork`), the earthing spike that stabs down from the node currently + * lit, and the crawl that opens across the floor at the last node. Two draw + * calls for all three — the strip is drawn once as a halo and once as a core, + * and that is the entire mesh cost of the ability. + */ +export const chainarc = { + /* --- the cast --- */ + range: 26.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused + speed: 62.0, // metres/second the discharge propagates through the graph + lifetime: 0.5, // seconds the chain holds lit after the last node + fadeTime: 0.55, // seconds it takes to gutter out + cooldown: 0.6, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the chain leaves the caster --- */ + // Node 0 is the hand, so these place the first node rather than an origin. + handHeight: 1.3, // metres above the floor + handForward: 0.5, // metres in front of the caster + handSide: 0.14, // metres to the side (+ follows `Ability#side`) + endHeight: 0.4, // height of the last node, metres + + /* --- the graph --- */ + nodes: 7, // points including both ends, 2..12 + route: 1.0, // re-roll dial: any change re-routes the chain mid-flight + filaments: 3, // whole polylines drawn over the same nodes + scatter: 2.1, // metres the lateral fractions are scaled by + lift: 0.75, // metres the lift fractions are scaled by + alongJitter: 0.55, // 0..1 of a hop a node may slide down the line + hopSag: 0.14, // metres a hop bows downward at mid-hop + hopBow: 0.35, // metres a hop bows sideways, alternating per hop + floorY: 0.08, // metres — a hop's filaments are clamped above this + + /* --- the hop clock --- */ + hopStretch: 1.0, // unitless multiplier on the derived hop time + hold: 1.3, // hops a lit segment stays at full + overlap: 2.8, // hops it then decays over — this is the guttering tail + tip: 0.35, // how much of a hop the front is smeared over + + /* --- the chain's share of the shared look --- */ + kink: 1.0, // multiplier on `jitter` for the chain role + chainWidth: 1.0, // multiplier on `width` for the chain role + dim: 1.0, // 0..1 alpha on the chain role + groundDamp: 0.5, // 0..1 on the kink's world y — a chain skimming the floor wants < 1 + + /* --- the ribbon, shared by all three roles --- */ + width: 0.03, // half-width of the core ribbon, metres + glowWidth: 6.0, // halo half-width, × `width` + glowOpacity: 0.5, // halo alpha relative to the core + jitter: 0.5, // metres of lateral kink at the coarsest octave + jitterScale: 1.5, // kinks per metre + octaves: 4, // 1–5; each halves the amplitude and doubles the rate + jitterFalloff: 0.55, // amplitude kept per octave + crawl: 3.0, // how fast the kinks slide along, per second + pinch: 0.12, // 0..1 of the path the kink is eased in over at each end + restrike: 26, // whole re-shapes per second + flicker: 0.28, // 0..1 depth of the whole-bundle brightness stutter + flickerSpeed: 32, // steps/second that stutter is quantised to + strandFlash: 0.5, // 0..1 depth of the per-filament blink + coreSharp: 4.4, // exponent on the core's edge falloff — higher is thinner + glowFalloff: 2.4, // the same for the halo + softFade: 0.7, // metres of depth fade against the opaque scene + opacity: 1.0, + glow: 2.4, // emissive gain into bloom + + /* --- colour of the ribbon --- */ + colorCore: '#ffffff', // the white-hot centre line + colorInner: '#eaf6ff', + colorOuter: '#5fb0ff', + colorHalo: '#0b2f7a', // the wide, dim atmosphere + + /* --- the earthing spikes --- */ + // One node at a time earths, and only if its dice roll passes `earthChance`. + // The roll is fixed per cast; the *threshold* is read every frame, so winding + // the slider up while paused makes the current node earth on the spot. + earthChance: 0.55, // 0..1 — how many nodes stab down to the floor + spikeStrands: 2, // filaments in one spike + spikeLife: 0.22, // seconds a spike lingers after its node lit + spikeFloor: 0.05, // metres the spike is clamped above the floor + spikeSag: 0.05, // metres the spike bows at mid-span + spikeNear: 0.03, // metres the spike is fanned at the node + spikeSpread: 0.35, // ... and at the floor + spikeCurve: 1.4, // how late that fan opens + spikeTwist: 0.3, // turns of roll from node to floor + spikeTwistSpeed: 1.2, // turns/second the fan rolls + spikeConverge: 0.75, // 0..1 — how hard the far end is pinned to the floor point + spikeKink: 0.7, // multiplier on `jitter` for the spike role + spikeWidth: 0.8, // multiplier on `width` for the spike role + spikeDim: 0.8, // 0..1 alpha on the spike role + spikeGroundDamp: 0.35, // 0..1 on the kink's world y near the floor + spikeTipGlow: 1.6, // extra core colour where it hits the floor + + /* --- the crawl that opens at the last node --- */ + crawlStrands: 7, // tendrils radiating across the floor + crawlLife: 0.85, // seconds the crawl lingers + crawlGrow: 0.28, // seconds it takes to reach full extension + crawlInner: 0.25, // metres from the impact point a tendril starts + crawlReach: 3.4, // metres it reaches + crawlCurve: 0.85, // <1 sprints out early, >1 covers the ground late + crawlWander: 1.5, // radians of per-tendril veer + crawlArch: 0.25, // metres it lifts at mid-span + crawlHug: 0.06, // metres it floats above the floor throughout + crawlSpin: 0.15, // turns/second the whole crawl rotates + crawlKink: 0.9, // multiplier on `jitter` for the crawl role + crawlWidth: 0.9, // multiplier on `width` for the crawl role + crawlDim: 0.9, // 0..1 alpha on the crawl role + crawlGroundDamp: 0.3, // 0..1 on the kink's world y — flat filaments bury without this + crawlFloor: 0.03, // metres the crawl is clamped above the floor + crawlTip: 0.14, // 0..1 of the path the growing front is smeared over + crawlTipGlow: 0.9, // extra core colour at that front + + /* --- what the ground does under each node --- */ + // A burn per node, deliberately *not* a continuous line: the bolt was never + // between the nodes, so the floor should not claim it was. + nodeArcRadius: 1.05, // radius of one electric burn, metres + nodeArcLife: 0.55, // seconds a burn lingers + nodeArcIntensity: 1.0, + nodeArcBranches: 0.75, // how finely a burn splits into filaments + nodeScorchRadius: 0.4, // dark mark under a node, metres + nodeScorchLife: 5.5, // seconds + nodeScorchIntensity: 0.4, + colorArc: '#9fdcff', // the branching burn + colorEmber: '#4aa8ff', // its hot centre + colorScorch: '#080b11', // the dark mark + shockRadius: 5.5, // impact shockwave ring, metres + colorShockA: '#c9ecff', // body of the shockwave ring + colorShockB: '#ffffff', // its crest + + /* --- the flash at each node --- */ + nodeBurstSize: 0.55, // shell of ionised air at a node, metres + nodeBurstIntensity: 1.6, + nodeSparks: 26, // sparks thrown at a node + nodeLightPunch: 9.0, // additive light punch as a node lights + colorNodeA: '#2f7fff', // node shell + colorNodeB: '#c9ecff', // node body + colorNodeC: '#ffffff', // node arcs + + /* --- sparks, motes, smoke and debris --- */ + /** + * As in `thunder`: each system is coloured by a four-stop gradient sampled + * over the particle's own lifetime, `A` at birth through `D` as it dies. + * Spelled out rather than derived from the ribbon palette, so the sparks can + * be cooled to orange while the chain stays blue. + */ + sparkRate: 150, // sparks shed along the lit chain, particles/second + sparkSize: 0.14, + sparkSpeed: 8.5, // metres/second + sparkLifetime: 0.45, // seconds + sparkGravity: -13.0, // metres/second² + sparkStretch: 0.2, // how far a spark smears along its velocity + colorSparkA: '#ffffff', + colorSparkB: '#eaf6ff', + colorSparkC: '#5fb0ff', + colorSparkD: '#1a4a8a', + moteRate: 70, // ionised motes drifting off the chain, particles/second + moteSize: 0.05, + moteSpeed: 1.3, // metres/second + moteLifetime: 1.4, // seconds + moteRise: 0.9, // upward drift, metres/second + moteTurbulence: 0.7, + colorMoteA: '#ffffff', + colorMoteB: '#c9ecff', + colorMoteC: '#5fb0ff', + colorMoteD: '#04205e', + smokeRate: 26, // thin haze off the burnt floor, particles/second + smokeSize: 0.9, + smokeSpeed: 1.0, // metres/second + smokeLifetime: 2.0, // seconds + smokeOpacity: 0.06, + smokeRise: 0.5, // metres/second + colorSmokeA: '#3d546e', + colorSmokeB: '#33475e', + colorSmokeC: '#2a3b50', + colorSmokeD: '#1c2938', + debrisSize: 0.05, + debrisSpeed: 4.5, // metres/second + debrisLifetime: 1.2, // seconds + debrisGravity: -17.0, // metres/second² + nodeDebris: 5, // chips kicked off the floor at one node + colorDebrisA: '#252c36', + colorDebrisB: '#1c222a', + colorDebrisC: '#1c222a', + colorDebrisD: '#141920', + + /* --- dynamic light --- */ + lightIntensity: 24, // the light hops from node to node with the discharge + lightRadius: 16, // metres + lightColor: '#5fb0ff', + lightFlicker: 0.45, // depth of the light's gutter, 0 = steady + lightFlickerSpeed: 30, // steps/second + + /* --- the release and the last node --- */ + castFlash: 0.1, // screen flash as the chain leaves the hand + colorCastFlash: '#eaf6ff', + burstSize: 2.6, // the shell at the last node, metres + burstIntensity: 1.5, + burstSparks: 150, // extra sparks thrown at the last node + burstDebris: 40, + impactShake: 0.75, + shakeDuration: 0.5, // seconds + impactFlash: 0.26, // screen flash at the last node + rumble: 0.025, // continuous shake while the chain is arriving + colorBurstA: '#2f7fff', // burst shell + colorBurstB: '#c9ecff', // burst body + colorBurstC: '#ffffff', // burst arcs + colorFlash: '#c9ecff' // the full-screen flash at the last node +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Chain Arc. + * + * Reach for **The graph** first. `nodes`, `scatter` and `route` are the three + * that carry the ability: the first decides how many times it skips, the second + * how far off the aimed line it is willing to go, and the third re-rolls the + * whole route under a discharge that is already in the air. Drag `route` with + * the clock stopped and watch a standing chain take a different path — that is + * the trick, and it is one slider. + * + * **The hop clock** is next. `hold` at 8 lights the whole chain and leaves it + * lit; at 0 with `overlap` low it is a single spark running the line. The + * shipped values sit between the two, which is what makes the passed segments + * linger dim and gutter rather than switching off. + */ +export const chainarcSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 300, 1, 'propagation speed'], + ['lifetime', 0.05, 6, 0.01, 'chain lifetime'], + ['fadeTime', 0.05, 4, 0.01, 'gutter-out time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where it leaves the hand': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['endHeight', 0, 4, 0.01, 'height at target'] + ], + 'The graph': [ + ['nodes', 2, 12, 1, 'nodes'], + ['route', 0, 40, 1, 'route (re-roll)'], + ['filaments', 1, 8, 1, 'filaments'], + ['scatter', 0, 8, 0.01, 'lateral scatter (m)'], + ['lift', 0, 5, 0.01, 'lift (m)'], + ['alongJitter', 0, 1, 0.01, 'along jitter'], + ['hopSag', -1, 1, 0.01, 'hop sag (m)'], + ['hopBow', -2, 2, 0.01, 'hop bow (m)'], + ['floorY', 0, 1, 0.005, 'floor clamp (m)'] + ], + 'The hop clock': [ + ['hopStretch', 0.05, 6, 0.01, 'hop stretch'], + ['hold', 0, 10, 0.05, 'hold (hops)'], + ['overlap', 0.05, 12, 0.05, 'gutter (hops)'], + ['tip', 0.01, 1, 0.01, 'front smear'] + ], + 'The ribbon': [ + ['width', 0.005, 0.4, 0.001, 'core width (m)'], + ['glowWidth', 1, 20, 0.1, 'halo width'], + ['glowOpacity', 0, 2, 0.01, 'halo opacity'], + ['jitter', 0, 3, 0.01, 'kink amplitude (m)'], + ['jitterScale', 0.05, 6, 0.01, 'kinks / metre'], + ['octaves', 1, 5, 1, 'octaves'], + ['jitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['crawl', -20, 20, 0.1, 'kink crawl'], + ['pinch', 0.01, 0.5, 0.005, 'end pinch'], + ['restrike', 0.5, 90, 0.5, 'restrikes / sec'], + ['flicker', 0, 1, 0.01, 'brightness stutter'], + ['flickerSpeed', 1, 120, 1, 'stutter rate'], + ['strandFlash', 0, 1, 0.01, 'filament blink'], + ['coreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['glowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['softFade', 0.02, 3, 0.01, 'soft intersection'], + ['opacity', 0, 2, 0.01, 'opacity'], + ['glow', 0, 8, 0.01, 'glow'], + ['colorCore', 'core'], + ['colorInner', 'inner'], + ['colorOuter', 'outer'], + ['colorHalo', 'halo'] + ], + 'The chain role': [ + ['kink', 0, 3, 0.01, 'kink ×'], + ['chainWidth', 0.1, 4, 0.01, 'width ×'], + ['dim', 0, 1, 0.01, 'alpha'], + ['groundDamp', 0, 1, 0.01, 'ground damp'] + ], + 'The earthing spikes': [ + ['earthChance', 0, 1, 0.01, 'nodes that earth'], + ['spikeStrands', 1, 8, 1, 'spike filaments'], + ['spikeLife', 0.02, 2, 0.01, 'spike lifetime'], + ['spikeFloor', 0, 1, 0.005, 'floor clamp (m)'], + ['spikeSag', -1, 1, 0.01, 'spike sag (m)'], + ['spikeNear', 0, 1, 0.005, 'fan at node (m)'], + ['spikeSpread', 0, 3, 0.01, 'fan at floor (m)'], + ['spikeCurve', 0.2, 5, 0.01, 'fan curve'], + ['spikeTwist', -4, 4, 0.01, 'twist'], + ['spikeTwistSpeed', -6, 6, 0.01, 'twist speed'], + ['spikeConverge', 0, 1, 0.01, 'lock onto floor'], + ['spikeKink', 0, 3, 0.01, 'kink ×'], + ['spikeWidth', 0.1, 4, 0.01, 'width ×'], + ['spikeDim', 0, 1, 0.01, 'alpha'], + ['spikeGroundDamp', 0, 1, 0.01, 'ground damp'], + ['spikeTipGlow', 0, 6, 0.05, 'floor-strike glow'] + ], + 'The crawl at the last node': [ + ['crawlStrands', 0, 16, 1, 'tendrils'], + ['crawlLife', 0.05, 4, 0.01, 'crawl lifetime'], + ['crawlGrow', 0.02, 2, 0.01, 'grow time'], + ['crawlInner', 0, 3, 0.01, 'inner radius (m)'], + ['crawlReach', 0.2, 14, 0.05, 'reach (m)'], + ['crawlCurve', 0.2, 4, 0.01, 'reach curve'], + ['crawlWander', 0, 4, 0.01, 'veer (rad)'], + ['crawlArch', 0, 2, 0.01, 'mid-span lift (m)'], + ['crawlHug', 0, 1, 0.005, 'float above floor (m)'], + ['crawlSpin', -3, 3, 0.01, 'spin'], + ['crawlKink', 0, 3, 0.01, 'kink ×'], + ['crawlWidth', 0.1, 4, 0.01, 'width ×'], + ['crawlDim', 0, 1, 0.01, 'alpha'], + ['crawlGroundDamp', 0, 1, 0.01, 'ground damp'], + ['crawlFloor', 0, 1, 0.005, 'floor clamp (m)'], + ['crawlTip', 0.01, 1, 0.01, 'front smear'], + ['crawlTipGlow', 0, 6, 0.05, 'front glow'] + ], + 'Burns under each node': [ + ['nodeArcRadius', 0.1, 8, 0.05, 'burn radius'], + ['nodeArcLife', 0.05, 5, 0.05, 'burn lifetime'], + ['nodeArcIntensity', 0, 3, 0.01, 'burn intensity'], + ['nodeArcBranches', 0, 3, 0.01, 'branch detail'], + ['nodeScorchRadius', 0.05, 4, 0.05, 'scorch radius'], + ['nodeScorchLife', 0.5, 20, 0.1, 'scorch lifetime'], + ['nodeScorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['colorArc', 'burn'], + ['colorEmber', 'ember'], + ['colorScorch', 'scorch'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'The flash at each node': [ + ['nodeBurstSize', 0.05, 6, 0.05, 'node shell size'], + ['nodeBurstIntensity', 0, 5, 0.01, 'node shell intensity'], + ['nodeSparks', 0, 200, 1, 'sparks per node'], + ['nodeDebris', 0, 60, 1, 'chips per node'], + ['nodeLightPunch', 0, 60, 0.5, 'light punch'], + ['colorNodeA', 'node shell'], + ['colorNodeB', 'node body'], + ['colorNodeC', 'node arcs'] + ], + 'Sparks & motes': [ + ['sparkRate', 0, 1200, 1, 'spark rate'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['moteRate', 0, 600, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 12, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 8, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorSpark*', 'Spark colour'], + ['colorMote*', 'Mote colour'] + ], + 'Smoke & debris': [ + ['smokeRate', 0, 500, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['debrisSize', 0.005, 0.4, 0.005, 'debris size'], + ['debrisSpeed', 0, 25, 0.1, 'debris speed'], + ['debrisLifetime', 0.1, 5, 0.05, 'debris lifetime'], + ['debrisGravity', -50, 0, 0.1, 'debris gravity'], + ['colorSmoke*', 'Smoke colour'], + ['colorDebris*', 'Debris colour'] + ], + 'The last node': [ + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstSparks', 0, 600, 1, 'burst sparks'], + ['burstDebris', 0, 300, 1, 'burst debris'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst arcs'], + ['colorFlash', 'impact flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightFlicker', 0, 1, 0.01, 'light gutter'], + ['lightFlickerSpeed', 1, 90, 1, 'gutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/chronofracture.js b/src/config/abilities/chronofracture.js new file mode 100644 index 0000000..75ce63d --- /dev/null +++ b/src/config/abilities/chronofracture.js @@ -0,0 +1,435 @@ +/* ================================================================== */ +/* CHRONOFRACTURE — arcane, far cast */ +/* ================================================================== */ +/** + * Panes of frozen time hung in the air over the circle. + * + * The trick is **stillness**, and stillness is expensive in a sandbox where + * everything else crawls, gutters, breathes and drifts. Almost every number + * below that could have been a rate ships at zero on purpose — `stillness`, + * `stillSpeed`'s effect, `sealSpin`, `refractRippleSpeed` — because the moment + * one of them moves, the panes stop being frozen time and become glass. + * + * Two of those deserve their own note. + * + * `sealSpin` is the one that cannot be animated. `GroundField`'s RUNE rings + * rotate by `uTime × spin`, so easing the slider toward zero does not slow the + * rings down, it *teleports* them — the whole seal snaps back to bearing zero + * over however many seconds of wall clock have elapsed. It is therefore a + * constant for the life of a cast and it ships at 0. + * + * `crackLead` is the deliberate exception. For the last fraction of a second + * before the break, hairline fractures ink themselves across every pane. That + * *is* motion, and it is there because a break with no tell reads as a cut. + * Set it to 0 for the purist reading; the panes then go from perfect to gone. + * + * On the refraction: the panes ask `ShatterField`/`DistortionField` for a real + * screen-space sample and the repo does not have one yet (`frame.uSceneColor` + * does not exist). Until it does, "an older, colder copy of what is behind it" + * is faked three ways — a low-saturation cold wash alpha-blended over the + * scene, which genuinely desaturates it; a chromatic split of the pane's own + * frozen grain (`fringe` / `fringeOffset`), which is what a three-tap scene + * sample would look like; and a real `DistortionField` REFRACT hull, which + * genuinely offsets the frame behind each pane. What is missing is the blur. + * Nothing here fakes it, because a fake blur without a texture is a smear of + * flat colour and it looks like exactly that. + */ +export const chronofracture = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + zoneRadius: 5.0, // the far-cast circle the indicator draws, metres + speed: 52.0, // how fast the freeze front runs to the circle, metres/second + assembleTime: 0.55, // seconds the panes take to open + holdTime: 1.5, // seconds they hang dead still afterwards + fadeTime: 1.15, // seconds the fragments have to fall + cooldown: 1.4, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the panes hang --- */ + paneCount: 20, // panes in the air (capped at 28) + paneInner: 0.18, // innermost pane, as a fraction of `zoneRadius` + paneReach: 1.05, // outermost pane, as a fraction of `zoneRadius` + paneHeight: 1.9, // metres above the floor the band is centred on + paneRise: 1.15, // ± metres of height scatter across the band + paneWidth: 1.55, // metres, a pane's width before jitter + paneAspect: 1.35, // height / width + paneSizeJitter: 0.4, // ± fraction on both dimensions + paneFaceOut: 0.78, // 0 every pane faces downrange, 1 each faces radially out + paneYaw: 0.5, // ± radians of random yaw on top of that + paneTilt: 0.22, // ± radians of random lean out of vertical + + /* --- the assembly --- */ + assembleStagger: 0.72, // fraction of `assembleTime` the last pane waits for + assembleCurve: 2.1, // >1 opens slowly then snaps the last of the way + seamWidth: 0.09, // metres of bright edge on the opening slit + seamGlow: 3.4, // how hard that edge burns + + /* --- the glass --- */ + paneOpacity: 0.34, // the cold wash — this is what desaturates the scene + paneGlow: 1.25, // emissive gain on every glowing term in the pane + frost: 0.62, // amount of suspended frozen grain, 0..1 + frostScale: 1.35, // grain features per metre + grain: 0.3, // fine dust on top of it, 0..1 + fringe: 0.75, // chromatic split of that grain, 0..1 + fringeOffset: 0.055, // metres the red and blue taps sit either side + rim: 1.1, // fresnel brightness at grazing angles + rimPower: 2.6, // how tight that rim is + border: 0.045, // metres of bright hairline around a pane's perimeter + stillness: 0.0, // 0 is frozen; above 0 the grain drifts and the trick dies + stillSpeed: 0.35, // cycles/second of that drift, if you enable it + paneSoft: 0.35, // metres of soft fade where a pane meets solid geometry + + /* --- the pre-cracks --- */ + crackLead: 0.18, // seconds before the break the fractures ink themselves + crackArms: 7, // radial fractures out of each pane's stress point + crackRings: 1.6, // concentric fractures per metre + crackWidth: 0.012, // metres, half-width of a fracture + crackGlow: 2.2, // how hard a fracture burns + + /* --- pane colour --- */ + colorGlass: '#6a8ab0', // the cold wash laid over the scene behind + colorGhost: '#d0e8ff', // the frozen grain — the "older" image + colorRim: '#d0e8ff', // the fresnel edge + colorSeam: '#ffffff', // the opening slit and the perimeter hairline + colorCrack: '#ffffff', // the fractures + + /* --- the refraction hull (one DistortionField over all the panes) --- */ + refractStrength: 0.42, // screen widths at post.distortion = 1 + refractPower: 1.1, // rim exponent; 0 flattens it to a uniform pane + refractOpacity: 0.95, // coverage against other distorters + refractRipple: 0.06, // normal perturbation on the hull + refractRippleScale: 1.6, // cycles per metre + refractRippleSpeed: 0.0, // metres/second — ships at zero, see the header + refractDepthReject: 1.0, // how hard the character occludes the warp, 0..1 + refractDepthFade: 0.35, // metres of feather on that occlusion + + /* --- the break --- */ + shardsPerPane: 9, // fragments thrown by each pane (capped by the field) + shardSize: 0.36, // metres, a fragment's unit scale + shardSizeJitter: 0.6, // ± fraction + shardSpeed: 7.5, // metres/second off the pane + shardSpeedJitter: 0.7, // ± fraction + shardSpread: 0.55, // 0 throws every fragment the same way, 1 is random + shardUp: 0.12, // how much +Y is folded into the throw + shardScatter: 0.32, // metres of scatter about the pane it came off + shardGravity: -26.0, // metres/second², signed — fast, they are falling glass + shardDrag: 0.55, // 1/second + shardSpin: 11.0, // radians/second of tumble + shardSpinJitter: 0.85, // ± fraction + shardLifetime: 1.05, // seconds a fragment lives + shardShrink: 0.35, // fraction of its size lost by the end of that + shardShrinkPower: 2.2, // how late the shrink bites + shardFloor: 0.02, // metres; fragments do not sink below this + shardFloorSpin: 0.18, // fraction of the tumble kept once grounded + + /* --- fragment shading --- */ + shardOpacity: 0.85, + shardGlow: 1.6, + shardRim: 1.3, // fresnel gain on a fragment's edge + shardRimPower: 2.2, + shardShade: 0.75, // how much the key light shades the body + shardAmbient: 0.4, // floor on that + shardFadeStart: 0.55, // fraction of life the fade-out begins at + shardSoft: 0.2, // metres of soft fade against geometry + shardSceneMix: 0.7, // how much of the scene sample a shard shows, if bound + shardRefract: 0.045, // screen fraction the scene sample is pushed by + shardSaturation: 0.22, // how much colour survives in that sample + colorShardA: '#d0e8ff', // fragment body at birth + colorShardB: '#6a8ab0', // ... and as it dies + colorShardEdge: '#ffffff', // its fresnel rim + colorShardScene: '#b9d4ea', // tint on the scene sample, if one is ever bound + + /* --- the seal burnt into the floor --- */ + sealScale: 1.25, // × zoneRadius — the seal is wider than the pane band + sealHeight: 0.02, // metres the quad floats above the floor + sealEdge: 0.3, // metres of feather on the growth front + sealRagged: 0.14, // how far that front wanders, fraction of the radius + sealRaggedScale: 0.55, // lobes per metre + sealWarp: 0.35, // metres of domain warp on those lobes + sealRelief: 0.55, // how hard the incision tilts the fake normal + sealNormalStep: 0.05, // metres between the height taps + sealAmbient: 0.3, // floor on the diffuse term + sealWrap: 0.5, // wraps the terminator round the back, 0..1 + sealSpecular: 0.65, + sealGloss: 30, // Blinn exponent + sealParallax: 0.2, // metres of view-driven offset on the incision + sealRings: 3, // 1..4 nested rings of glyphs + sealRingInner: 0.34, // innermost ring as a fraction of the radius + sealGlyphSize: 0.6, // metres — the em box of one glyph + sealGlyphStroke: 0.05, // metres — half-width of a stroke + sealGlyphGap: 1.3, // slot pitch, in glyph widths + sealSpin: 0.0, // radians/second, ring 0 — see the header, it must be constant + sealSpinFalloff: 0.55, // how much slower each ring out turns + sealRule: 0.014, // metres — half-width of the compass circles + sealThickness: 0.05, // metres of stroke depth + sealDepth: 0.2, // metres the incision is cut to + sealCell: 0.5, // metres — grain pitch under the seal + sealDetail: 0.5, // 0..1 of fine detail in the substrate + sealEmissive: 1.4, // multiplier on the seal's glowing terms + sealOpacity: 0.95, + sealDepthFade: 0.4, // metres of soft fade against standing geometry + colorSealBase: '#2a3a4c', // the stone the seal is cut into + colorSealEdge: '#d0e8ff', // its lips and highlights + colorSealGlow: '#8fc4ff', // the ignition inside the strokes + colorSealDeep: '#0a1018', // the bottom of the incision + + /* --- motes: dust that stopped when time did --- */ + moteRate: 70, // particles/second while the panes are up + moteSize: 0.05, + moteSpeed: 0.22, // metres/second — near zero, they hang + moteLifetime: 2.6, + moteDrift: 0.05, // upward drift, metres/second + moteTurbulence: 0.06, // how much curl noise is allowed to move them + colorMoteA: '#ffffff', + colorMoteB: '#d0e8ff', + colorMoteC: '#6a8ab0', + colorMoteD: '#1a2a3c', + + /* --- glints: the sparks that come off a pane locking, and the break --- */ + glintRate: 40, // particles/second during the assembly + glintSize: 0.11, + glintSpeed: 3.4, + glintLifetime: 0.45, + glintGravity: -6.0, + glintStretch: 0.22, // how far a glint smears along its velocity + glintBurst: 220, // extra glints thrown at the break + colorGlintA: '#ffffff', + colorGlintB: '#d0e8ff', + colorGlintC: '#8fc4ff', + colorGlintD: '#2a4a70', + + /* --- chips: the heavy debris of the break --- */ + chipBurst: 90, // chips thrown at the break + chipSize: 0.075, + chipSpeed: 6.5, + chipLifetime: 1.4, + chipGravity: -22.0, + colorChipA: '#b9d4ea', + colorChipB: '#6a8ab0', + colorChipC: '#3c5570', + colorChipD: '#1a2a3c', + + /* --- dynamic light --- */ + lightIntensity: 15, // the pale light inside the panes + lightRadius: 14, + lightColor: '#a8cdf0', + lightHold: 0.7, // multiplier while the panes are simply hanging + lightBreak: 2.4, // multiplier on the punch at the break + + /* --- feedback --- */ + freezeFlash: 0.1, // screen flash as the last pane locks + colorFreezeFlash: '#d0e8ff', + breakFlash: 0.26, // screen flash at the break + colorBreakFlash: '#ffffff', + breakShake: 0.55, + breakShakeDuration: 0.5, // seconds that shake decays over + breakBurst: 3.6, // metres, the shell of released time at the break + breakBurstIntensity: 1.3, + colorBreakA: '#6a8ab0', // burst shell + colorBreakB: '#d0e8ff', // burst body + colorBreakC: '#ffffff' // burst filaments +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Chronofracture. + * + * The four controls that carry the character, in the order worth reaching for: + * `paneCount` and `paneWidth` (how much of the circle is wall), `paneOpacity` + * (how cold the scene behind goes), `fringe` (how *wrong* the frozen image + * looks) and `holdTime` (how long you are made to stare at something that is + * not moving). Turn `stillness` up if you want to see why it ships at zero. + */ +export const chronofractureSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['zoneRadius', 1, 16, 0.1, 'circle radius'], + ['speed', 5, 200, 1, 'freeze-front speed'], + ['assembleTime', 0.05, 3, 0.01, 'assembly time'], + ['holdTime', 0, 8, 0.01, 'hold (dead still)'], + ['fadeTime', 0.1, 5, 0.01, 'fall time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where the panes hang': [ + ['paneCount', 1, 28, 1, 'panes'], + ['paneInner', 0, 1.5, 0.01, 'inner radius ×'], + ['paneReach', 0, 2, 0.01, 'outer radius ×'], + ['paneHeight', 0, 8, 0.01, 'band height'], + ['paneRise', 0, 5, 0.01, 'height scatter ±'], + ['paneWidth', 0.1, 6, 0.01, 'pane width'], + ['paneAspect', 0.2, 4, 0.01, 'height / width'], + ['paneSizeJitter', 0, 1.5, 0.01, 'size jitter ±'], + ['paneFaceOut', 0, 1, 0.01, 'face outward'], + ['paneYaw', 0, 3.2, 0.01, 'yaw scatter ±'], + ['paneTilt', 0, 1.6, 0.01, 'lean scatter ±'] + ], + 'The assembly': [ + ['assembleStagger', 0, 0.98, 0.01, 'stagger'], + ['assembleCurve', 0.2, 6, 0.05, 'open curve'], + ['seamWidth', 0.002, 0.5, 0.002, 'seam width'], + ['seamGlow', 0, 10, 0.05, 'seam glow'] + ], + 'The glass': [ + ['paneOpacity', 0, 1, 0.005, 'cold wash'], + ['paneGlow', 0, 5, 0.01, 'pane glow'], + ['frost', 0, 2, 0.01, 'frozen grain'], + ['frostScale', 0.05, 8, 0.01, 'grain / metre'], + ['grain', 0, 1, 0.01, 'fine dust'], + ['fringe', 0, 2, 0.01, 'chromatic split'], + ['fringeOffset', 0, 0.4, 0.002, 'split offset'], + ['rim', 0, 4, 0.01, 'fresnel rim'], + ['rimPower', 0.2, 8, 0.05, 'rim tightness'], + ['border', 0, 0.3, 0.002, 'perimeter hairline'], + ['stillness', 0, 1, 0.01, 'grain drift (kills it)'], + ['stillSpeed', 0, 4, 0.01, 'drift rate'], + ['paneSoft', 0.01, 2, 0.01, 'soft intersection'] + ], + 'The pre-cracks': [ + ['crackLead', 0, 1.5, 0.005, 'lead before break'], + ['crackArms', 0, 16, 1, 'radial fractures'], + ['crackRings', 0, 8, 0.05, 'rings / metre'], + ['crackWidth', 0.001, 0.08, 0.001, 'fracture width'], + ['crackGlow', 0, 8, 0.05, 'fracture glow'] + ], + 'Pane colour': [ + ['colorGlass', 'cold wash'], + ['colorGhost', 'frozen grain'], + ['colorRim', 'fresnel rim'], + ['colorSeam', 'seam & hairline'], + ['colorCrack', 'fractures'] + ], + 'Refraction': [ + ['refractStrength', 0, 2, 0.005, 'strength'], + ['refractPower', 0, 6, 0.05, 'rim exponent'], + ['refractOpacity', 0, 1, 0.01, 'coverage'], + ['refractRipple', 0, 1, 0.005, 'ripple'], + ['refractRippleScale', 0.05, 8, 0.05, 'ripple / metre'], + ['refractRippleSpeed', 0, 4, 0.01, 'ripple speed (kills it)'], + ['refractDepthReject', 0, 1, 0.01, 'occlusion'], + ['refractDepthFade', 0.02, 3, 0.01, 'occlusion feather'] + ], + 'The break': [ + ['shardsPerPane', 0, 24, 1, 'fragments / pane'], + ['shardSize', 0.02, 1.5, 0.01, 'fragment size'], + ['shardSizeJitter', 0, 1.5, 0.01, 'size jitter ±'], + ['shardSpeed', 0, 30, 0.1, 'throw speed'], + ['shardSpeedJitter', 0, 1.5, 0.01, 'speed jitter ±'], + ['shardSpread', 0, 1, 0.01, 'throw spread'], + ['shardUp', -1, 1, 0.01, 'upward bias'], + ['shardScatter', 0, 2, 0.01, 'spawn scatter'], + ['shardGravity', -60, 0, 0.5, 'gravity'], + ['shardDrag', 0, 4, 0.01, 'drag'], + ['shardSpin', 0, 30, 0.1, 'tumble rate'], + ['shardSpinJitter', 0, 1.5, 0.01, 'tumble jitter ±'], + ['shardLifetime', 0.1, 4, 0.01, 'fragment lifetime'], + ['shardShrink', 0, 1, 0.01, 'shrink'], + ['shardShrinkPower', 0.2, 6, 0.05, 'shrink curve'], + ['shardFloor', -1, 2, 0.01, 'floor height'], + ['shardFloorSpin', 0, 1, 0.01, 'grounded tumble'] + ], + 'The break/Fragment shading': [ + ['shardOpacity', 0, 2, 0.01, 'opacity'], + ['shardGlow', 0, 5, 0.01, 'glow'], + ['shardRim', 0, 4, 0.01, 'rim'], + ['shardRimPower', 0.2, 8, 0.05, 'rim tightness'], + ['shardShade', 0, 1, 0.01, 'key shading'], + ['shardAmbient', 0, 1, 0.01, 'ambient floor'], + ['shardFadeStart', 0, 1, 0.01, 'fade start'], + ['shardSoft', 0, 2, 0.01, 'soft intersection'], + ['shardSceneMix', 0, 1, 0.01, 'scene mix'], + ['shardRefract', 0, 0.3, 0.002, 'scene refraction'], + ['shardSaturation', 0, 1, 0.01, 'scene saturation'], + ['colorShardA', 'fragment birth'], + ['colorShardB', 'fragment death'], + ['colorShardEdge', 'fragment rim'], + ['colorShardScene', 'scene tint'] + ], + 'The seal': [ + ['sealScale', 0.2, 3, 0.01, 'radius × zone'], + ['sealHeight', 0, 0.3, 0.005, 'float above floor'], + ['sealEdge', 0.02, 2, 0.01, 'front feather'], + ['sealRagged', 0, 1, 0.01, 'front wander'], + ['sealRaggedScale', 0.05, 4, 0.01, 'wander / metre'], + ['sealWarp', 0, 3, 0.01, 'domain warp'], + ['sealRelief', 0, 3, 0.01, 'relief'], + ['sealNormalStep', 0.005, 0.4, 0.005, 'normal step'], + ['sealAmbient', 0, 1, 0.01, 'ambient'], + ['sealWrap', 0, 1, 0.01, 'terminator wrap'], + ['sealSpecular', 0, 3, 0.01, 'specular'], + ['sealGloss', 1, 120, 1, 'gloss'], + ['sealParallax', 0, 2, 0.01, 'parallax'], + ['sealThickness', 0.005, 0.4, 0.005, 'stroke depth'], + ['sealDepth', 0, 1, 0.005, 'incision depth'], + ['sealCell', 0.05, 3, 0.01, 'substrate grain'], + ['sealDetail', 0, 1, 0.01, 'substrate detail'], + ['sealEmissive', 0, 6, 0.01, 'emissive'], + ['sealOpacity', 0, 2, 0.01, 'opacity'], + ['sealDepthFade', 0.02, 3, 0.01, 'soft intersection'] + ], + 'The seal/Glyphs': [ + ['sealRings', 1, 4, 1, 'rings'], + ['sealRingInner', 0.05, 0.9, 0.01, 'inner ring ×'], + ['sealGlyphSize', 0.05, 2, 0.01, 'glyph em box'], + ['sealGlyphStroke', 0.005, 0.3, 0.002, 'stroke half-width'], + ['sealGlyphGap', 0.6, 3, 0.01, 'slot pitch'], + ['sealSpin', -1, 1, 0.005, 'ring spin (constant!)'], + ['sealSpinFalloff', 0, 3, 0.01, 'spin falloff'], + ['sealRule', 0.002, 0.1, 0.001, 'compass rule'] + ], + 'The seal/Colour': [ + ['colorSealBase', 'stone'], + ['colorSealEdge', 'lips'], + ['colorSealGlow', 'ignition'], + ['colorSealDeep', 'incision'] + ], + 'Motes & glints': [ + ['moteRate', 0, 400, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 6, 0.01, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteDrift', -2, 2, 0.01, 'mote drift'], + ['moteTurbulence', 0, 2, 0.01, 'mote turbulence'], + ['glintRate', 0, 400, 1, 'glint rate'], + ['glintSize', 0.005, 0.6, 0.005, 'glint size'], + ['glintSpeed', 0, 20, 0.1, 'glint speed'], + ['glintLifetime', 0.05, 3, 0.01, 'glint lifetime'], + ['glintGravity', -40, 5, 0.1, 'glint gravity'], + ['glintStretch', 0, 2, 0.01, 'glint stretch'], + ['glintBurst', 0, 700, 1, 'glints at the break'], + ['colorMote*', 'Mote colour'], + ['colorGlint*', 'Glint colour'] + ], + 'Chips': [ + ['chipBurst', 0, 400, 1, 'chips at the break'], + ['chipSize', 0.005, 0.5, 0.005, 'chip size'], + ['chipSpeed', 0, 25, 0.1, 'chip speed'], + ['chipLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['chipGravity', -60, 0, 0.5, 'chip gravity'], + ['colorChip*', 'Chip colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightHold', 0, 3, 0.01, 'hold multiplier'], + ['lightBreak', 0, 8, 0.05, 'break punch'], + ['lightColor', 'light colour'] + ], + 'Feedback': [ + ['freezeFlash', 0, 2, 0.01, 'flash on lock'], + ['colorFreezeFlash', 'lock flash colour'], + ['breakFlash', 0, 2, 0.01, 'flash on break'], + ['colorBreakFlash', 'break flash colour'], + ['breakShake', 0, 3, 0.01, 'break shake'], + ['breakShakeDuration', 0.05, 3, 0.01, 'shake duration'], + ['breakBurst', 0.1, 14, 0.05, 'break shell size'], + ['breakBurstIntensity', 0, 5, 0.01, 'break shell intensity'], + ['colorBreakA', 'shell'], + ['colorBreakB', 'body'], + ['colorBreakC', 'filaments'] + ] +}; diff --git a/src/config/abilities/crimsontide.js b/src/config/abilities/crimsontide.js new file mode 100644 index 0000000..94a1058 --- /dev/null +++ b/src/config/abilities/crimsontide.js @@ -0,0 +1,577 @@ +/* ================================================================== */ +/* CRIMSON TIDE — blood · line */ +/* ================================================================== */ +/** + * A wave of blood surges down the aimed line, curls, breaks, and soaks away. + * + * The block is long because the ability is a **heightfield**, and a heightfield + * has genuinely more dimensions than a bolt does: four directional swells, a + * chop octave, a crest profile, a flow field, a congealing skin and a froth + * threshold are all separate measurements and every one of them is authored + * here (I5). Nothing below is derived from anything else above it. + * + * Three groups are worth reaching for first: + * + * - **the crest** (`crestHeight`, `crestBack`, `crestFace`, `crestCurl`) — the + * silhouette. `crestBack / crestFace` is the whole read: a symmetric bump is + * a swell, and a bump whose face is an eighth as long as its back is a wave + * about to break. Every other slider in the file is decoration next to that + * ratio. + * - **the flow band** (`crustForm`, `crustBreak`) — the two surface speeds in + * metres/second between which the black skin dies and the froth is seeded. + * Both the crust and the foam read them, which is the one place in this + * block where sharing *is* the design. + * - **the sheet** (`sheetLead`, `sheetTail`, `sheetWidth`) — how much water + * exists in front of, behind and either side of the crest. The wave is not + * a fixed plane the crest slides across: the sheet is re-cut every frame to + * exactly the run the surge has covered, so the crest is always at its own + * leading edge. + * + * **Low emission on purpose.** `emissive` is 0 and `glow` sits below 1. Blood + * is not a light source; if this cast reads at night it reads by specular off a + * curling silhouette, which is why `specular` and `shininess` are high and why + * `translucency` matters more than any colour picker here. + */ +export const crimsontide = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 15.0, // how fast the surge travels, metres/second — deliberately slow + breakTime: 0.95, // seconds the crest spends curling over and collapsing + drainTime: 2.6, // seconds the sheet takes to soak into the floor + cooldown: 1.1, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the sheet ---------------------------------------------------- */ + // The plane is re-cut every frame from these three numbers plus the distance + // the surge has covered, so a paused wave re-cuts itself when they move. + sheetWidth: 7.2, // metres across the line the water spans + sheetLead: 2.6, // metres of water standing in FRONT of the crest + sheetTail: 2.0, // metres of water standing behind the caster + sheetHeight: 0.06, // metres the mean plane floats above the floor + sheetFill: 1.0, // 0..1 of the half-extent the water reaches; the drain rides this + sheetRound: 0.35, // 0 rectangular footprint, 1 elliptical + sheetEdge: 0.16, // 0..1 of the field over which the waterline fades out + sheetRagged: 0.42, // 0..1 how ragged that waterline is + sheetRaggedScale: 1.1, // cycles per metre of the raggedness + sheetOpacity: 1.0, + contactFade: 0.22, // metres of soft fade where the sheet meets standing geometry + + /* --- the swell: four directional waves ---------------------------- */ + // A, B and C run roughly downrange; D crosses them, which is what stops the + // surface reading as corduroy. + swellAmpA: 0.13, // metres + swellAmpB: 0.075, + swellAmpC: 0.04, + swellAmpD: 0.028, + swellLengthA: 6.4, // metres, crest to crest + swellLengthB: 3.4, + swellLengthC: 1.9, + swellLengthD: 1.05, + swellSpeedA: 2.4, // metres/second + swellSpeedB: 1.7, + swellSpeedC: 1.1, + swellSpeedD: 0.7, + swellAngleA: 0.0, // radians, in the sheet's own frame (0 = downrange) + swellAngleB: 0.42, + swellAngleC: -0.55, + swellAngleD: 1.9, + steepness: 0.62, // 0 sine, 1 Gerstner cusps; blood is thick, so it cusps + + /* --- chop and grain ----------------------------------------------- */ + chop: 0.05, // metres of high-frequency displacement + chopScale: 1.9, // cycles per metre + chopSpeed: 0.5, // metres/second the chop field drifts + detail: 0.02, // metres — fragment-only; lives entirely in the normal + detailScale: 8.5, // cycles per metre + detailSpeed: 1.1, + + /* --- ripples (impacts ring the sheet) ------------------------------ */ + rippleAmp: 0.22, // metres at strength 1 + rippleSpeed: 3.6, // metres/second the front travels + rippleLength: 1.15, // metres, crest to crest inside the packet + rippleWidth: 0.85, // metres of the gaussian envelope + rippleDecay: 1.1, // seconds to 1/e + rippleSpread: 3.0, // metres over which it also thins with radius + + /* --- the flow field ------------------------------------------------ */ + // Give the surface somewhere to flow or the crust never breaks and the froth + // never seeds — both of them are functions of speed and nothing else. + flowAngle: 0.0, // radians, the bulk drift's bearing in the sheet's frame + flowSpeed: 1.6, // metres/second of bulk drift + flowRadial: 0.9, // metres/second outward at the centre + flowRadialFall: 5.0, // metres to 1/e + flowEddy: 0.7, // metres/second of curl swirl + flowEddyScale: 0.34, // cycles per metre + flowEddySpeed: 0.2, // Hz the eddies churn + flowGravity: 3.4, // metres/second per unit of surface slope — the coupling + + /* --- the congealing skin ------------------------------------------- */ + // Blood clots where it stops moving. Coverage is 1 − smoothstep(crustForm, + // crustBreak, speed), so the back of the sheet skins over black while the + // crest, which is the fastest thing on it, stays wet and open. + crust: 0.55, // 0..1 master; 0 skips the whole block and its fill cost + crustForm: 0.55, // m/s below which the skin is unbroken + crustBreak: 2.4, // m/s above which there is none — also the foam's speed gate + crustFormTime: 0.55, // seconds before the first skin has chilled + crackScale: 1.6, // cycles per metre across the flow + crackStretch: 5.5, // how many times longer features are along it + crackWidth: 0.22, // 0..1 of the field — the seam's width + crustAdvect: 1.0, // 0..1 how strongly the pattern is carried by the flow + crustPeriod: 2.2, // seconds before the flow map resets + crustBump: 0.55, // 0..1 how much the skin roughens the normal + seamGlow: 0.35, // how bright a crack between two clots is — kept low + meltGlow: 0.2, // glow of the bare liquid between the plates + + /* --- froth ---------------------------------------------------------- */ + foam: 0.6, // 0..1 master + foamScale: 7.0, // cycles per metre of the speckle + foamSharp: 1.7, + foamCrest: 1.4, // how much the breaking lip seeds it + foamSpeed: 0.45, // how much surface speed seeds it + + /* --- the crest ------------------------------------------------------ */ + crestHeight: 1.35, // metres at the peak of the surge + crestBack: 3.0, // metres — the long back slope's 1/e length + crestFace: 0.24, // metres — the short front face's 1/e length + crestCurl: 0.72, // metres of forward throw per metre of height + crestWidth: 0.78, // 0..1 of the half-extent across the wave + crestFeather: 0.34, // 0..1 of that, over which the ends die away + crestBreak: 0.4, // 0..1 how ragged the lip is + crestBreakScale: 1.5, // cycles per metre along the lip + crestRise: 0.42, // 0..1 of the run over which the crest reaches full height + crestPeak: 1.45, // × crestHeight at the moment it curls over + crestCurlPeak: 2.1, // × crestCurl at that same moment + crestBreakPeak: 2.2, // × crestBreak at that same moment + crestOvershoot: 1.4, // metres the lip throws past the end of the line + + /* --- shading -------------------------------------------------------- */ + poolDepth: 0.35, // metres of liquid under the mean plane + depthTint: 3.2, // Beer-Lambert density, per metre — blood is opaque fast + translucency: 1.6, // backlight through the folded lip — the read of the cast + ambient: 0.16, // floor on the wrapped diffuse + specular: 2.4, // the other read: a wave you see by its highlight + shininess: 96, // Blinn-Phong exponent + fresnel: 1.25, + envIntensity: 0.55, + skyIntensity: 0.3, + emissive: 0.0, // blood is not a light source. Leave this at zero. + glow: 0.85, // sub-unity on purpose — see the block comment + normalEps: 0.04, // metres — the finite-difference step for the normal + + colorDeep: '#24030a', // the body, looking straight down through it + colorShallow: '#8a0f18', // the thin water at the waterline and the lip + colorCrust: '#180207', // the congealed skin + colorSeam: '#5a0710', // a crack between two clots + colorHot: '#3a050a', // the bare liquid showing through + colorFoam: '#ff4a4a', // the froth on the breaking lip + colorSpec: '#ffbcbc', // the highlight + colorSky: '#2a1418', // the fallback reflection where the env probe is empty + + /* --- the wet stone the tide leaves --------------------------------- */ + // A GroundField(WET) disc: soaked flagstone, alpha-blended so it comes out + // genuinely darker than the floor, drying from the edges in. + wetAlong: 0.62, // 0..1 down the line the puddle is centred + wetRadius: 4.2, // metres + wetHeight: 0.012, // metres above the floor + wetEdge: 0.5, // metres of feather on the tide line + wetRagged: 0.4, // fraction of the radius the tide line wanders by + wetRaggedScale: 0.55, // lobes per metre + wetWarp: 0.9, // metres of domain warp on those lobes + wetRelief: 0.55, // how hard the puddle field tilts the fake normal + wetNormalStep: 0.05, // metres between the height taps + wetAmbient: 0.2, + wetWrap: 0.5, // 0..1 wraps the terminator round the back + wetSpecular: 1.6, // soaked stone is shinier than dry stone + wetGloss: 48, // Blinn exponent + wetParallax: 0.2, // metres of view-driven offset on the interior detail + wetCell: 0.7, // metres — the pitch of the standing puddles + wetLift: 0.02, // metres the film stands proud + wetDepth: 0.05, // metres the low spots sit down + wetDetail: 0.7, // 0..1 fine grain + wetSpeed: 0.5, // radians/second the film ripples + wetFlow: 0.15, // metres/second the film drifts + wetWindAngle: 0.0, // radians, in the quad's frame + wetOpacity: 0.95, + wetEmissive: 0.5, // the sheen term only — there is nothing glowing here + wetDepthFade: 0.4, // metres of soft fade against standing geometry + wetDryDelay: 0.18, // 0..1 of the drain before the stone starts drying + wetDryTime: 0.72, // 0..1 of the drain the drying takes + colorWetBase: '#3a3438', // dry flagstone, as this mark sees it + colorWetEdge: '#c07a80', // the sheen, and the rim of the tide line + colorWetGlow: '#7a1a22', // the pale tide mark the last of it leaves + colorWetDeep: '#120a0c', // soaked stone + + /* --- the droplet flock off the lip ---------------------------------- */ + // A Swarm, not a particle system: these are the coherent sheet of spray the + // lip throws forward and drags along with it, and a flock is the only thing + // that keeps them together. The ones that fall are particles. + dropCount: 150, // live agents + dropSize: 0.13, // metres, nose to tail + dropAspect: 0.85, // span / length + dropSizeJitter: 0.55, // ±fraction + dropLift: 0.35, // metres above the lip the flock's lead sits + dropRise: 0.9, // metres the lead lofts at mid-run + dropLatticeX: 9, // cells across the lip + dropLatticeY: 4, // cells up + dropLatticeZ: 6, // ranks strung out behind it + dropSpacingSide: 0.42, // metres between lateral cells + dropSpacingUp: 0.3, // metres between vertical cells + dropLag: 0.34, // seconds the back rank trails the lip by + dropJitter: 0.22, // metres of slop off the cell + dropChurn: 0.5, // radians/second the formation rolls + dropBreathe: 0.3, // fraction it swells by + dropBreatheRate: 2.4, // radians/second + dropWander: 0.14, // metres of curl drift — keep under half the spacing + dropWanderScale: 0.7, // features per metre + dropWanderSpeed: 0.9, + dropGather: 0.85, // 0 collapses every droplet onto the lip's own path + dropBank: 0.05, // radians of roll per m/s² of lateral acceleration + dropBankMax: 1.0, // radians + dropCurl: 0.25, // how far a droplet bends across its own chord + dropBillboard: 0.35, // 0 world plate, 1 camera-facing sprite + dropEdgeStretch: 1.4, // how much an edge-on droplet grows so it stays visible + dropEdgeGain: 1.2, // emission multiplier when it does + dropRevealSpread: 0.4, // width of the appear/disappear wave + dropLit: 1.0, // 1 = wrapped diffuse. Blood droplets are lit, not emissive + dropTint: 0.25, // where in the gradient the flock sits + dropTintJitter: 0.3, // ±per-agent walk along it + dropTintAlong: 0.4, // extra walk from the lip to the tail + dropOpacity: 1.0, + dropGlow: 0.5, // sub-unity: these must not bloom + dropSoftFade: 0.3, // metres of depth feather against solid geometry + colorDropA: '#c81a28', // at the lip + colorDropB: '#8a0f18', + colorDropC: '#4a060d', + colorDropD: '#24030a', // by the time it is falling behind + + /* --- spray, mist and clots ------------------------------------------ */ + /** + * Four-stop lifetime gradients, `A` at birth through `D` as it dies, spelled + * out per system rather than derived from the sheet's palette — the spray is + * allowed to stay bright while the mist goes brown. + */ + sprayRate: 220, // droplets thrown off the lip, particles/second + spraySize: 0.085, + spraySpeed: 5.5, + sprayLifetime: 1.0, + sprayGravity: -16.0, // metres/second² — heavier than water, and it shows + colorSprayA: '#ff4a4a', + colorSprayB: '#c81a28', + colorSprayC: '#7a0a14', + colorSprayD: '#2a040a', + mistRate: 46, // fine red haze standing over the wave + mistSize: 0.9, + mistSpeed: 1.0, + mistLifetime: 2.3, + mistRise: 0.35, // upward drift, metres/second + mistOpacity: 0.1, + mistTurbulence: 0.8, + colorMistA: '#6e1018', + colorMistB: '#4a0a12', + colorMistC: '#2e060c', + colorMistD: '#180307', + clotRate: 16, // heavy gobbets rolling off the back of the wave + clotSize: 0.075, + clotSpeed: 3.4, + clotLifetime: 1.5, + clotGravity: -20.0, + colorClotA: '#7a0a14', + colorClotB: '#4a060d', + colorClotC: '#2a040a', + colorClotD: '#160205', + + /* --- what the tide leaves on the floor ------------------------------ */ + slickRate: 0.55, // wet marks laid per metre of front travel + slickRadius: 2.3, // radius of one mark, metres + slickLife: 5.5, // seconds it lingers + slickIntensity: 0.85, + colorSlickA: '#2a0a10', // the soaked stone under the mark + colorSlickB: '#8a2028', // the froth drying back into a ring + + /* --- the break ------------------------------------------------------- */ + burstSize: 2.4, // the sheet of blood thrown up where it breaks, metres + burstIntensity: 0.9, + burstSpray: 220, // extra droplets thrown at the break + burstClots: 70, + burstRipples: 5, // ripple packets injected into the sheet at the break + shockRadius: 5.0, // the ring that runs out across the floor, metres + impactShake: 0.55, + shakeDuration: 0.7, + impactFlash: 0.05, // barely anything — this cast does not flash + rumble: 0.05, // continuous shake while the surge travels + colorBurstA: '#4a060d', + colorBurstB: '#8a0f18', + colorBurstC: '#ff4a4a', + colorShockA: '#7a0a14', + colorShockB: '#c81a28', + colorFlash: '#5a0810', + + /* --- dynamic light ---------------------------------------------------- */ + // Low and deep. It is here so the wet stone and the curling face have + // something to specular against, not so the cast glows. + lightIntensity: 7.5, + lightRadius: 13.0, + lightColor: '#c8323c' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Crimson Tide. + * + * Reach for `The crest` first — it owns the silhouette, and the silhouette is + * the ability. `The sheet` decides how much water there is to make it out of; + * `The flow` decides whether the skin ever cracks. Everything in `Shading` is + * a second-order refinement on a wave that already reads. + */ +export const crimsontideSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 2, 60, 0.5, 'surge speed'], + ['breakTime', 0.1, 5, 0.01, 'break duration'], + ['drainTime', 0.2, 8, 0.01, 'drain duration'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The sheet': [ + ['sheetWidth', 1, 24, 0.1, 'width'], + ['sheetLead', 0, 10, 0.05, 'water ahead of the crest'], + ['sheetTail', 0, 10, 0.05, 'water behind the caster'], + ['sheetHeight', 0, 0.5, 0.005, 'mean plane height'], + ['sheetFill', 0.05, 1, 0.01, 'waterline'], + ['sheetRound', 0, 1, 0.01, 'footprint rounding'], + ['sheetEdge', 0.01, 0.6, 0.005, 'waterline feather'], + ['sheetRagged', 0, 1, 0.01, 'waterline raggedness'], + ['sheetRaggedScale', 0.1, 5, 0.01, 'raggedness scale'], + ['sheetOpacity', 0, 1, 0.01, 'opacity'], + ['contactFade', 0.02, 2, 0.01, 'soft intersection'] + ], + 'The swell': [ + ['swellAmpA', 0, 1.2, 0.005, 'amplitude A'], + ['swellAmpB', 0, 1.2, 0.005, 'amplitude B'], + ['swellAmpC', 0, 1.2, 0.005, 'amplitude C'], + ['swellAmpD', 0, 1.2, 0.005, 'amplitude D'], + ['swellLengthA', 0.2, 20, 0.05, 'wavelength A'], + ['swellLengthB', 0.2, 20, 0.05, 'wavelength B'], + ['swellLengthC', 0.2, 20, 0.05, 'wavelength C'], + ['swellLengthD', 0.2, 20, 0.05, 'wavelength D'], + ['swellSpeedA', -8, 8, 0.05, 'speed A'], + ['swellSpeedB', -8, 8, 0.05, 'speed B'], + ['swellSpeedC', -8, 8, 0.05, 'speed C'], + ['swellSpeedD', -8, 8, 0.05, 'speed D'], + ['swellAngleA', -3.2, 3.2, 0.01, 'bearing A'], + ['swellAngleB', -3.2, 3.2, 0.01, 'bearing B'], + ['swellAngleC', -3.2, 3.2, 0.01, 'bearing C'], + ['swellAngleD', -3.2, 3.2, 0.01, 'bearing D'], + ['steepness', 0, 1.2, 0.01, 'Gerstner cusping'] + ], + 'Chop & grain': [ + ['chop', 0, 0.5, 0.005, 'chop'], + ['chopScale', 0.1, 8, 0.01, 'chop scale'], + ['chopSpeed', 0, 4, 0.01, 'chop drift'], + ['detail', 0, 0.15, 0.001, 'normal detail'], + ['detailScale', 1, 24, 0.1, 'detail scale'], + ['detailSpeed', 0, 6, 0.01, 'detail drift'] + ], + 'Ripples': [ + ['rippleAmp', 0, 1.5, 0.005, 'amplitude'], + ['rippleSpeed', 0.2, 14, 0.05, 'front speed'], + ['rippleLength', 0.1, 6, 0.01, 'wavelength'], + ['rippleWidth', 0.1, 5, 0.01, 'packet width'], + ['rippleDecay', 0.05, 6, 0.01, 'decay to 1/e'], + ['rippleSpread', 0.2, 14, 0.05, 'radial thinning'] + ], + 'The flow': [ + ['flowAngle', -3.2, 3.2, 0.01, 'drift bearing'], + ['flowSpeed', 0, 8, 0.01, 'drift speed'], + ['flowRadial', 0, 8, 0.01, 'radial outflow'], + ['flowRadialFall', 0.2, 20, 0.1, 'outflow falloff'], + ['flowEddy', 0, 6, 0.01, 'eddy speed'], + ['flowEddyScale', 0.02, 3, 0.01, 'eddy scale'], + ['flowEddySpeed', 0, 3, 0.01, 'eddy churn'], + ['flowGravity', 0, 12, 0.01, 'downhill flow'] + ], + 'The congealing skin': [ + ['crust', 0, 1, 0.01, 'skin coverage'], + ['crustForm', 0, 6, 0.01, 'clots below (m/s)'], + ['crustBreak', 0.05, 12, 0.01, 'melts above (m/s)'], + ['crustFormTime', 0.05, 6, 0.01, 'first skin (s)'], + ['crackScale', 0.1, 6, 0.01, 'seams / metre'], + ['crackStretch', 0.5, 20, 0.1, 'seam stretch'], + ['crackWidth', 0.01, 0.8, 0.005, 'seam width'], + ['crustAdvect', 0, 2, 0.01, 'carried by the flow'], + ['crustPeriod', 0.2, 8, 0.05, 'flow-map period'], + ['crustBump', 0, 2, 0.01, 'skin relief'], + ['seamGlow', 0, 4, 0.01, 'seam glow'], + ['meltGlow', 0, 4, 0.01, 'bare-liquid glow'] + ], + 'Froth': [ + ['foam', 0, 1, 0.01, 'froth'], + ['foamScale', 0.5, 20, 0.1, 'speckle scale'], + ['foamSharp', 0.2, 5, 0.01, 'speckle hardness'], + ['foamCrest', 0, 4, 0.01, 'seeded by the lip'], + ['foamSpeed', 0, 4, 0.01, 'seeded by speed'] + ], + 'The crest': [ + ['crestHeight', 0, 5, 0.01, 'height'], + ['crestBack', 0.1, 12, 0.05, 'back slope'], + ['crestFace', 0.02, 3, 0.01, 'front face'], + ['crestCurl', 0, 3, 0.01, 'forward throw'], + ['crestWidth', 0.05, 1, 0.01, 'width across'], + ['crestFeather', 0.01, 1, 0.01, 'end feather'], + ['crestBreak', 0, 1, 0.01, 'lip raggedness'], + ['crestBreakScale', 0.1, 6, 0.01, 'raggedness scale'], + ['crestRise', 0.02, 1, 0.01, 'run to full height'], + ['crestPeak', 0.2, 4, 0.01, 'height at the break'], + ['crestCurlPeak', 0.2, 6, 0.01, 'curl at the break'], + ['crestBreakPeak', 0.2, 6, 0.01, 'raggedness at the break'], + ['crestOvershoot', 0, 8, 0.05, 'throw past the end'] + ], + 'Shading': [ + ['poolDepth', 0, 3, 0.01, 'depth under the plane'], + ['depthTint', 0, 10, 0.01, 'absorption / metre'], + ['translucency', 0, 4, 0.01, 'backlit front face'], + ['ambient', 0, 1, 0.01, 'ambient floor'], + ['specular', 0, 6, 0.01, 'specular'], + ['shininess', 4, 256, 1, 'gloss'], + ['fresnel', 0, 3, 0.01, 'fresnel'], + ['envIntensity', 0, 3, 0.01, 'reflection'], + ['skyIntensity', 0, 3, 0.01, 'sky fallback'], + ['emissive', 0, 3, 0.01, 'self-lit body'], + ['glow', 0, 4, 0.01, 'glow'], + ['normalEps', 0.005, 0.3, 0.001, 'normal step'], + ['colorDeep', 'body'], + ['colorShallow', 'thin water'], + ['colorCrust', 'congealed skin'], + ['colorSeam', 'seam'], + ['colorHot', 'bare liquid'], + ['colorFoam', 'froth'], + ['colorSpec', 'highlight'], + ['colorSky', 'sky fallback'] + ], + 'Wet stone': [ + ['wetAlong', 0, 1, 0.01, 'where down the line'], + ['wetRadius', 0.3, 14, 0.05, 'radius'], + ['wetHeight', 0, 0.2, 0.001, 'height off the floor'], + ['wetEdge', 0.02, 3, 0.01, 'tide-line feather'], + ['wetRagged', 0, 1, 0.01, 'tide-line raggedness'], + ['wetRaggedScale', 0.05, 4, 0.01, 'raggedness scale'], + ['wetWarp', 0, 4, 0.01, 'domain warp'], + ['wetRelief', 0, 3, 0.01, 'relief'], + ['wetNormalStep', 0.005, 0.4, 0.005, 'normal step'], + ['wetAmbient', 0, 1, 0.01, 'ambient floor'], + ['wetWrap', 0, 1, 0.01, 'terminator wrap'], + ['wetSpecular', 0, 5, 0.01, 'sheen'], + ['wetGloss', 1, 160, 1, 'gloss'], + ['wetParallax', 0, 2, 0.01, 'parallax'], + ['wetCell', 0.05, 4, 0.01, 'puddle pitch'], + ['wetLift', 0, 0.3, 0.001, 'film relief'], + ['wetDepth', 0, 0.6, 0.005, 'low-spot depth'], + ['wetDetail', 0, 1, 0.01, 'grain'], + ['wetSpeed', 0, 6, 0.01, 'film ripple rate'], + ['wetFlow', 0, 3, 0.01, 'film drift'], + ['wetWindAngle', -3.2, 3.2, 0.01, 'drift bearing'], + ['wetOpacity', 0, 1, 0.01, 'opacity'], + ['wetEmissive', 0, 3, 0.01, 'sheen gain'], + ['wetDepthFade', 0.02, 3, 0.01, 'soft intersection'], + ['wetDryDelay', 0, 1, 0.01, 'drying starts at'], + ['wetDryTime', 0.05, 1.5, 0.01, 'drying takes'], + ['colorWetBase', 'dry stone'], + ['colorWetEdge', 'sheen'], + ['colorWetGlow', 'tide mark'], + ['colorWetDeep', 'soaked stone'] + ], + 'Droplets off the lip': [ + ['dropCount', 0, 256, 1, 'droplets'], + ['dropSize', 0.01, 0.8, 0.005, 'size'], + ['dropAspect', 0.2, 4, 0.01, 'aspect'], + ['dropSizeJitter', 0, 1, 0.01, 'size jitter'], + ['dropLift', -1, 3, 0.01, 'lift above the lip'], + ['dropRise', -2, 6, 0.01, 'loft at mid-run'], + ['dropLatticeX', 1, 16, 1, 'cells across'], + ['dropLatticeY', 1, 12, 1, 'cells up'], + ['dropLatticeZ', 1, 20, 1, 'ranks behind'], + ['dropSpacingSide', 0.02, 2, 0.01, 'lateral spacing'], + ['dropSpacingUp', 0.02, 2, 0.01, 'vertical spacing'], + ['dropLag', 0.02, 2, 0.01, 'rank lag (s)'], + ['dropJitter', 0, 1, 0.01, 'cell slop'], + ['dropChurn', -4, 4, 0.01, 'formation roll'], + ['dropBreathe', 0, 1, 0.01, 'swell'], + ['dropBreatheRate', 0, 8, 0.01, 'swell rate'], + ['dropWander', 0, 1, 0.01, 'curl drift'], + ['dropWanderScale', 0.05, 4, 0.01, 'drift scale'], + ['dropWanderSpeed', 0, 4, 0.01, 'drift speed'], + ['dropGather', 0, 1, 0.01, 'gather onto the lip'], + ['dropBank', 0, 0.5, 0.005, 'bank'], + ['dropBankMax', 0, 3, 0.01, 'bank limit'], + ['dropCurl', 0, 1, 0.01, 'droplet curl'], + ['dropBillboard', 0, 1, 0.01, 'camera facing'], + ['dropEdgeStretch', 1, 4, 0.01, 'edge-on stretch'], + ['dropEdgeGain', 0, 6, 0.01, 'edge-on gain'], + ['dropRevealSpread', 0.02, 1, 0.01, 'reveal width'], + ['dropLit', 0, 1, 0.01, 'lit vs emissive'], + ['dropTint', 0, 1, 0.01, 'gradient position'], + ['dropTintJitter', 0, 1, 0.01, 'gradient jitter'], + ['dropTintAlong', 0, 1, 0.01, 'gradient along'], + ['dropOpacity', 0, 1, 0.01, 'opacity'], + ['dropGlow', 0, 4, 0.01, 'glow'], + ['dropSoftFade', 0.02, 2, 0.01, 'soft intersection'], + ['colorDrop*', 'Droplet colour'] + ], + 'Spray, mist & clots': [ + ['sprayRate', 0, 900, 1, 'spray rate'], + ['spraySize', 0.005, 0.5, 0.005, 'spray size'], + ['spraySpeed', 0, 20, 0.1, 'spray speed'], + ['sprayLifetime', 0.05, 4, 0.01, 'spray lifetime'], + ['sprayGravity', -50, 0, 0.1, 'spray gravity'], + ['mistRate', 0, 400, 1, 'mist rate'], + ['mistSize', 0.05, 4, 0.01, 'mist size'], + ['mistSpeed', 0, 8, 0.05, 'mist speed'], + ['mistLifetime', 0.2, 8, 0.05, 'mist lifetime'], + ['mistRise', -2, 4, 0.01, 'mist rise'], + ['mistOpacity', 0, 1, 0.005, 'mist opacity'], + ['mistTurbulence', 0, 3, 0.01, 'mist turbulence'], + ['clotRate', 0, 200, 1, 'clot rate'], + ['clotSize', 0.005, 0.4, 0.005, 'clot size'], + ['clotSpeed', 0, 20, 0.1, 'clot speed'], + ['clotLifetime', 0.1, 5, 0.05, 'clot lifetime'], + ['clotGravity', -50, 0, 0.1, 'clot gravity'], + ['colorSpray*', 'Spray colour'], + ['colorMist*', 'Mist colour'], + ['colorClot*', 'Clot colour'] + ], + 'Marks on the floor': [ + ['slickRate', 0.05, 4, 0.05, 'marks / metre'], + ['slickRadius', 0.2, 8, 0.05, 'mark radius'], + ['slickLife', 0.5, 20, 0.1, 'mark lifetime'], + ['slickIntensity', 0, 3, 0.01, 'mark intensity'], + ['colorSlickA', 'soaked stone'], + ['colorSlickB', 'drying froth'] + ], + 'The break': [ + ['burstSize', 0.2, 12, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstSpray', 0, 800, 1, 'burst spray'], + ['burstClots', 0, 400, 1, 'burst clots'], + ['burstRipples', 0, 8, 1, 'ripple packets'], + ['shockRadius', 0.5, 20, 0.1, 'shockwave radius'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 1, 0.005, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst crest'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorFlash', 'screen flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/cyclone.js b/src/config/abilities/cyclone.js new file mode 100644 index 0000000..5d043d1 --- /dev/null +++ b/src/config/abilities/cyclone.js @@ -0,0 +1,643 @@ +/* ================================================================== */ +/* CYCLONE — aether, far cast */ +/* ================================================================== */ +/** + * A vortex touching down on the aimed circle: a dust whorl gathers, a funnel + * stands up out of it, holds while it grinds the floor, and then ropes out from + * the bottom upward. + * + * **The profile is one function.** `vfx/Tube.js` in `FUNNEL` mode publishes + * `radiusAt(tau)` — `throat + skirt(tau) + mouth(tau)` — and *everything* in + * this ability places itself against it: the debris ribbons ride + * `radiusAt(ribbonTop)`, the flock circles `radiusAt(swarmRide)`, the dust skirt + * is emitted on `radiusAt(0)`, and the ground scour is `radiusAt(0) × scourReach`. + * There is no second copy of the vortex's shape anywhere in the ability, which + * is why dragging `funnelSkirtFlare` moves the grooves in the floor, the dust + * ring and the bottom of every ribbon at the same time — with the clock paused. + * + * The first version of this block had `skirtRadius` as its own slider on the + * ability and `scourRadius` as another, exactly as the `Tube` docs warn. They + * were never the same number twice: every time the profile was tuned the + * grooves ended up either inside the dust or out in clean floor. + * + * `zoneRadius` is the one measurement that is *not* the profile, deliberately. + * It is the aim circle, and what it promises is the floor the vortex is drawing + * air off — so it drives the intake ribbons crawling in across the ground and + * the radius the dust is picked up from. The funnel's own foot is smaller than + * that, which is what a tornado looks like from above. + * + * Keys prefixed `funnel*` are the `Tube` contract (79 of them, spread verbatim + * from `tubeDefaults('funnel', TubePath.FUNNEL)` so the module's audit stays + * quiet); the ones belonging to `WHIP`, `VINE` and `ARC` are inert on this path + * and are filed together at the bottom of the editor folder rather than hidden, + * because a key the panel cannot reach is a key nobody can rule out. + */ +export const cyclone = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 26.0, // how fast the whorl races to the touchdown point, metres/second + zoneRadius: 5.0, // the aim circle: floor the vortex draws air off, metres + lifetime: 3.4, // seconds the funnel stands after touchdown + fadeTime: 1.8, // seconds it takes to rope out + cooldown: 2.0, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the column --- */ + height: 7.6, // metres from the floor to the mouth + baseHeight: 0.04, // metres the foot floats above the floor while standing + spinUp: 0.6, // seconds the funnel takes to stand up out of the whorl + touchTaper: 0.1, // width multiplier at the instant of touchdown, 0..1 + ropeLift: 0.78, // fraction of the height the foot climbs to as it dies + ropeTaper: 0.22, // width multiplier at the end of the rope-out, 0..1 + lightRide: 0.35, // where on the column the dynamic light sits, 0..1 + + /* ------------------------------------------------------------------ */ + /* The funnel — vfx/Tube.js, prefix `funnel`, path FUNNEL */ + /* ------------------------------------------------------------------ */ + /** + * `funnelThroat + funnelSkirtFlare` is the foot (3.5 m shipped) and + * `funnelThroat + funnelMouthFlare` is the mouth (4.3 m). Those two sums are + * the whole silhouette, and nothing else in the ability writes them down. + */ + funnelThroat: 0.9, // the vortex waist, metres + funnelSkirtFlare: 2.6, // extra radius at the floor, metres + funnelSkirtHeight: 0.22, // how far up the skirt reaches, fraction of height + funnelSkirtCurve: 1.8, // how abruptly it flares, >1 = tighter to the floor + funnelMouthFlare: 3.4, // extra radius at the top, metres + funnelMouthStart: 0.58, // where the mouth begins to open, fraction of height + funnelMouthCurve: 1.5, // how abruptly it opens + funnelSpin: 1.15, // revolutions per second the surface rotates + funnelSpinTwist: 1.9, // extra revolutions from floor to mouth + funnelSway: 0.55, // how far the axis precesses, metres + funnelSwayScale: 0.4, // twist of the precession along the height + funnelSwaySpeed: 0.18, // revolutions per second it precesses + funnelSwayCurve: 2.0, // how much of the sway is at the top, >1 = only the top + + /* --- the surface --- */ + funnelThrob: 0.035, // breathing amplitude, × radius + funnelThrobScale: 2.1, // pressure waves along the column, cycles per length + funnelThrobSpeed: 1.1, // Hz they travel at + funnelWander: 0.07, // smooth low-frequency drift of the axis, metres + funnelWanderScale: 0.9, // drift features per length + funnelWanderSpeed: 0.55, // Hz the drift crawls at + funnelRipple: 0.16, // radial break-up of the barrel, × radius + funnelRippleBands: 1.8, // break-up features around the barrel + funnelRippleScale: 2.2, // break-up features along it + funnelRippleSpeed: 2.0, // Hz it crawls downrange at + funnelStreak: 1.1, // dust filaments streaming around the surface + funnelStreakSharp: 0.62, // 0 = soft wash, 1 = hard threads + funnelStreakScale: 2.6, // filament features per length + funnelStreakBands: 3.4, // filament features around the barrel + funnelStreakGlow: 0.7, // how hard the sheath's filaments burn to core colour + funnelFlowSpeed: -3.2, // metres-of-parameter per second the filaments run (negative = up) + funnelBands: 0.0, // rings along the length, cycles per length (0 = off) + funnelBandSharp: 2.0, // how tight each ring is + funnelBandDepth: 0.5, // how much they modulate alpha, 0..1 + funnelBandSpeed: 0.6, // Hz they travel at + + /* --- the three layers --- */ + funnelCoreWidth: 0.94, // core radius, × the profile + funnelCoreFill: 0.14, // how solid the core reads — low, so the throat is a hole + funnelCoreSharp: 1.4, // axis-weighting exponent — the inversion + funnelEdgePower: 2.2, // rim-weighting exponent for the sheath + funnelSheathWidth: 1.0, // sheath radius, × the profile + funnelSheathRim: 1.05, // strength of the sheath's silhouette + funnelSheathFill: 0.22, // how much body the sheath keeps + funnelSheathOpacity: 0.92, + funnelHaloWidth: 1.26, // halo radius, × the profile + funnelHaloRim: 2.2, // rim exponent — high, so it is only a silhouette + funnelHaloOpacity: 0.55, + + /* --- the ends --- */ + funnelMuzzleGlow: 0.9, // brightness where the column meets the floor + funnelMuzzleLength: 0.14, // how far that glow reaches, fraction of length + funnelTipGlow: 0.7, // brightness at the mouth + funnelTipLength: 0.12, // how soft that edge is, fraction of length + + /* --- funnel colour & render --- */ + funnelColorCore: '#e8eef4', // the axis-weighted middle + funnelColorInner: '#c0c8d0', // just off the middle — the lit dust + funnelColorOuter: '#6a7480', // the sheath body + funnelColorHalo: '#20262c', // the outer bloom + funnelOpacity: 0.92, + funnelGlow: 1.0, // emissive gain into bloom — dust does not burn + funnelSoftFade: 0.7, // metres of depth fade against the opaque scene + + /* --- inert on FUNNEL: the profile, whip, vine and arc groups --- */ + // `Tube` reads the whole contract whatever its path, so these are here to + // keep the module's audit quiet and to stay reachable in the panel. Dragging + // one of them does nothing on this path, and that is the honest answer. + funnelRadius: 0.62, // far half-width, metres (STRAIGHT/WHIP/ARC only) + funnelRadiusNear: 0.22, // near half-width, metres + funnelRadiusCurve: 0.7, // how late the radius opens + funnelFlare: 0.0, // extra half-width where it lands, × radius + funnelFlareWidth: 0.1, // how much of the far end flares, fraction of length + funnelWaveRate: 1.35, // WHIP: loops per second + funnelWaveWidth: 0.16, // WHIP: loop width, fraction of length + funnelWaveAmp: 0.3, // WHIP: lateral throw, fraction of length + funnelWaveGain: 2.2, // WHIP: growth of the loop toward the tip, × + funnelWaveCurve: 1.6, // WHIP: when that growth happens + funnelWaveRoll: 0.0, // WHIP: crack plane, radians + funnelSag: 0.12, // WHIP: hang under its own weight, metres + funnelCrackRatio: 1.0, // WHIP: tip ÷ wave speed the crack fires at + funnelTipTaper: 1.3, // VINE: how fast the radius falls to zero at the front + funnelMeander: 0.18, // VINE: helical wander of the stem, metres + funnelMeanderTurns: 1.4, // VINE: turns of that helix over the length + funnelRecoilAmp: 0.35, // VINE: spring pull-back, fraction + funnelRecoilFreq: 2.6, // VINE: Hz the spring rings at + funnelRecoilDamp: 3.4, // VINE: s⁻¹ it dies at + funnelRecoilSway: 0.6, // VINE: lateral bow while recoiling, metres + funnelArcHeight: 2.6, // ARC: apex height above the chord, metres + funnelArcLateral: 0.0, // ARC: apex offset across the chord, metres + funnelArcBias: 0.5, // ARC: where the apex sits, 0..1 + funnelArcCurve: 1.0, // ARC: apex sharpness + + /* ------------------------------------------------------------------ */ + /* The ribbons — vfx/FilamentPaths.js, two SPIRAL_IN roles */ + /* ------------------------------------------------------------------ */ + /** + * Role 0 rides the wall: it spirals from `radiusAt(0)` at the foot up to + * `radiusAt(ribbonTop)`, so it is welded to the profile at both ends. Role 1 + * is the intake, crawling in across the floor from the aim circle to the foot. + */ + wallRibbons: 9, // debris ribbons riding the funnel wall (capped at 18) + ribbonTop: 0.74, // how far up the wall they reach, fraction of height + ribbonHug: 1.06, // how far off the wall they ride, × radiusAt at that height + ribbonTurns: 2.6, // full turns from the foot to the top + ribbonSpin: 0.85, // turns per second the whole spiral rotates + ribbonCurve: 1.15, // >1 lingers wide near the floor before climbing + ribbonPhase: 1.0, // how evenly the ribbons are spread around, 1 = evenly + ribbonWobble: 0.22, // 0..1 radial wobble on the spiral + intakeArms: 6, // ground ribbons crawling in from the aim circle (capped at 12) + intakeLift: 0.45, // metres the intake spiral starts above the floor + intakeFloor: 0.03, // metres the intake is clamped above the floor + intakeTurns: 1.5, // full turns on the way in + intakeSpin: 0.5, // turns per second they sweep round + intakeCurve: 1.3, // >1 lingers out at the rim before diving in + intakePhase: 1.0, // how evenly the arms are spread around + intakeWobble: 0.34, // 0..1 radial wobble + intakeDamp: 0.25, // how much the kink's world-y is damped, 0..1 (flat roles want 0.3) + + /* --- the ribbon itself --- */ + ribbonWidth: 0.028, // half-width of the core ribbon, metres + ribbonGlowWidth: 5.4, // halo half-width, × the core width + ribbonGlowOpacity: 0.4, // halo alpha relative to the core + ribbonJitter: 0.28, // metres of lateral kink at the coarsest octave + ribbonJitterScale: 1.1, // kinks per metre of path + ribbonOctaves: 3, // 1–5; each halves the amplitude and doubles the rate + ribbonJitterFalloff: 0.58, // amplitude kept per octave + ribbonCrawl: 2.2, // how fast the kinks slide along, per second + ribbonPinch: 0.14, // fraction of the path the ends are pulled straight over + ribbonRestrike: 9, // whole re-shapes per second — dust, not lightning + ribbonFlicker: 0.16, // 0..1 depth of the whole-bundle brightness stutter + ribbonFlickerSpeed: 14, // steps per second that stutter is quantised to + ribbonStrandFlash: 0.3, // 0..1 depth of the per-ribbon blink + ribbonCoreSharp: 3.2, // exponent on the core's edge falloff + ribbonGlowFalloff: 2.1, // the same for the halo + ribbonSoftFade: 0.6, // metres of depth fade against the opaque scene + ribbonOpacity: 0.8, + ribbonGlow: 0.85, // emissive multiplier fed into bloom + ribbonDim: 0.75, // how secondary the wall ribbons read, 0..1 + intakeDim: 0.5, // ... and the ground ones + colorRibbonCore: '#e4eaf0', // the bright centre line of a ribbon + colorRibbonInner: '#b3bdc8', + colorRibbonOuter: '#6a7480', + colorRibbonHalo: '#20262c', // the wide, dim atmosphere around it + + /* ------------------------------------------------------------------ */ + /* The flock — vfx/Swarm.js, LEAF silhouette on an ORBIT lead */ + /* ------------------------------------------------------------------ */ + swarmCount: 110, // live agents (capped at 192) + swarmRide: 0.42, // where the flock circles, fraction of the height + swarmHug: 1.55, // its orbit radius, × radiusAt at that height + swarmRate: 0.42, // orbits per second the lead makes + swarmTurns: 1.0, // turns of the lead path per unit of its parameter + swarmLatticeX: 6, // cells across the formation + swarmLatticeY: 4, // cells up it + swarmLatticeZ: 8, // ranks strung out behind the lead + swarmSpacingSide: 0.75, // metres between lateral cells + swarmSpacingUp: 0.9, // metres between vertical cells + swarmLag: 1.5, // seconds the back rank trails the lead by + swarmJitter: 0.35, // metres of slop off the cell + swarmChurn: 1.6, // radians/second the formation rolls + swarmBreathe: 0.3, // fraction the formation swells by + swarmBreatheRate: 1.1, // radians/second it breathes at + swarmWander: 0.3, // metres of curl drift — keep under half the spacing + swarmWanderScale: 0.45, // drift features per metre + swarmWanderSpeed: 0.7, // Hz the drift crawls at + swarmGather: 0.9, // 0 collapses every agent onto the lead's own path + swarmSize: 0.34, // metres, nose to tail + swarmAspect: 0.85, // span ÷ length + swarmSizeJitter: 0.55, // ± fraction + swarmBillboard: 0.25, // 0 agent frame, 1 camera facing + swarmBank: 0.09, // radians of roll per m/s² of lateral acceleration + swarmBankMax: 1.6, // radians + swarmDihedral: 0.22, // fold across the card, fraction of size + swarmFlapRate: 2.2, // beats/second the fold works at + swarmCurl: 0.5, // leaf curl across the chord, fraction of size + swarmEdgeStretch: 1.5, // how much an edge-on card grows, ≥1 + swarmEdgeGain: 1.6, // emission multiplier when it is edge-on + swarmRevealSpread: 0.45, // width of the wave that brings agents in + swarmLit: 1.0, // 0 emissive, 1 wrapped diffuse — debris is lit, not glowing + swarmTint: 0.35, // where in the gradient the flock sits + swarmTintJitter: 0.35, // ± per-agent walk along it + swarmTintAlong: 0.4, // extra walk from head to tail + swarmOpacity: 1.0, + swarmGlow: 0.5, + swarmSoftFade: 0.4, // metres of depth feather against solid geometry + colorSwarmA: '#8d97a2', // gradient the flock is tinted along, near end + colorSwarmB: '#6a7480', + colorSwarmC: '#3e454d', + colorSwarmD: '#20262c', // ... and far end + + /* ------------------------------------------------------------------ */ + /* The scour — vfx/GroundField.js, SCOUR mode */ + /* ------------------------------------------------------------------ */ + scourReach: 1.5, // the scoured radius, × the funnel's own foot radius + scourHeight: 0.02, // metres the quad sits above the floor + scourEdge: 0.55, // metres of feather on the growth front + scourRagged: 0.3, // how far that front wanders, fraction of the radius + scourRaggedScale: 0.6, // lobes per metre + scourWarp: 0.7, // metres of domain warp on those lobes + scourRelief: 0.85, // how hard the height field tilts the fake normal + scourNormalStep: 0.05, // metres between the height taps + scourAmbient: 0.3, // floor on the diffuse term + scourWrap: 0.5, // 0..1 wraps the terminator round the back + scourSpecular: 0.25, + scourGloss: 16, // Blinn exponent + scourParallax: 0.3, // metres of view-driven offset on interior detail + scourDepth: 0.32, // metres the grooves are cut + scourLift: 0.14, // metres the spoil ridges stand proud + scourSharp: 0.62, // 0..1 how hard-edged a groove is + scourDetail: 0.7, // 0..1 grain in the floor of the groove + scourSwirl: 0.45, // spiral pitch — how fast a groove winds inward + scourArms: 7, // grooves; whole numbers only, the phase tears otherwise + scourSpin: 0.9, // radians/second the whole pattern turns + scourEmissive: 0.5, // multiplier on every glowing term + scourOpacity: 0.95, + scourDepthFade: 0.5, // metres of soft fade against standing geometry + scourAdditive: false, // false shades the floor, which is what dust does + colorScourBase: '#4a5058', // the scoured stone + colorScourEdge: '#9aa4ae', // the spoil ridges between the grooves + colorScourGlow: '#79838d', // the dust caught in the bottom of a groove + colorScourDeep: '#171b1f', // the deepest cut + + /* ------------------------------------------------------------------ */ + /* Dust, debris and grit */ + /* ------------------------------------------------------------------ */ + /** + * As in `thunder`: each system is coloured by a four-stop gradient sampled + * over the particle's own lifetime, `A` at birth through `D` as it dies. The + * dust is emitted on `radiusAt(0)` and swirled about the axis by the particle + * shader's own orbit term, so it is the same one function again. + */ + dustRate: 90, // dust lifted off the skirt, particles/second + dustSize: 1.5, + dustSpeed: 2.2, // metres/second outward off the foot + dustLifetime: 2.6, + dustRise: 1.4, // upward drift, metres/second + dustSwirl: 3.4, // radians/second the dust orbits the axis + dustSwirlExpand: 0.5, // how far the orbit opens out over a life, × + dustOpacity: 0.3, + dustTurbulence: 0.55, + colorDustA: '#8e98a2', + colorDustB: '#6a7480', + colorDustC: '#464e56', + colorDustD: '#20262c', + debrisRate: 34, // chips torn off the floor and carried up the wall + debrisSize: 0.09, + debrisSpeed: 5.5, + debrisLifetime: 2.2, + debrisGravity: -6.5, // metres/second² — the funnel is holding them up + debrisSwirl: 4.2, // radians/second they orbit the axis + debrisSwirlExpand: 0.25, + colorDebrisA: '#8a8f96', + colorDebrisB: '#5d646c', + colorDebrisC: '#33393f', + colorDebrisD: '#20262c', + gritRate: 150, // fine grit streaming in across the floor + gritSize: 0.1, + gritSpeed: 8.0, // metres/second inward along the intake + gritLifetime: 0.9, + gritRise: 3.0, // metres/second² of lift once it reaches the foot + gritStretch: 0.22, // how far a grain smears along its velocity + gritTurbulence: 0.4, + colorGritA: '#c8d0d8', + colorGritB: '#98a2ac', + colorGritC: '#5c646c', + colorGritD: '#2a3036', + + /* --- the whorl, before the funnel stands --- */ + whorlRate: 120, // dust turned over on the floor while the cast travels, /second + whorlRise: 0.4, // metres/second it lifts while it is only a whorl + whorlSwirl: 5.0, // radians/second — tight and fast, it is winding up + + /* --- dynamic light --- */ + lightIntensity: 7.0, + lightRadius: 14.0, + lightColor: '#8e99a4', + lightShimmer: 0.12, // depth of the light's breathing, 0 = steady + lightShimmerSpeed: 2.4, // breaths per second + + /* --- touchdown and the ground it stands on --- */ + touchSize: 3.2, // the pressure shell at the moment of touchdown, metres + touchIntensity: 1.1, + touchFlash: 0.06, // screen flash on touchdown + touchShake: 0.55, + shakeDuration: 0.7, + rumble: 0.05, // continuous shake while the funnel stands + colorTouchA: '#8e98a2', // touchdown shell body + colorTouchB: '#c0c8d0', // its billow + colorTouchC: '#e8eef4', // the filaments racing across it + colorTouchFlash: '#b6c0ca' // the full-screen flash +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Cyclone. + * + * Reach for **The vortex profile** first: `funnelThroat`, `funnelSkirtFlare` + * and `funnelMouthFlare` are the three numbers the whole ability is hung off, + * and every other consumer moves when they do. `scourReach` and `ribbonHug` are + * *multiples* of that profile rather than metres, on purpose — they cannot + * drift away from it. + */ +export const cycloneSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 4, 120, 0.5, 'whorl speed'], + ['zoneRadius', 1, 16, 0.1, 'aim circle (m)'], + ['lifetime', 0.2, 12, 0.05, 'hold'], + ['fadeTime', 0.1, 8, 0.05, 'rope-out time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The column': [ + ['height', 1, 24, 0.1, 'funnel height (m)'], + ['baseHeight', 0, 1, 0.005, 'foot clearance (m)'], + ['spinUp', 0.05, 4, 0.01, 'spin-up (s)'], + ['touchTaper', 0.01, 1, 0.01, 'width at touchdown'], + ['ropeLift', 0, 1, 0.01, 'foot lift on death'], + ['ropeTaper', 0.01, 1, 0.01, 'width at death'], + ['lightRide', 0, 1, 0.01, 'light height on column'] + ], + 'The vortex profile': [ + ['funnelThroat', 0.02, 6, 0.01, 'throat (m)'], + ['funnelSkirtFlare', 0, 10, 0.01, 'skirt flare (m)'], + ['funnelSkirtHeight', 0.01, 1, 0.01, 'skirt height'], + ['funnelSkirtCurve', 0.1, 6, 0.01, 'skirt curve'], + ['funnelMouthFlare', 0, 14, 0.01, 'mouth flare (m)'], + ['funnelMouthStart', 0, 0.99, 0.01, 'mouth start'], + ['funnelMouthCurve', 0.1, 6, 0.01, 'mouth curve'], + ['funnelSpin', -6, 6, 0.01, 'spin (rev/s)'], + ['funnelSpinTwist', -8, 8, 0.01, 'twist'], + ['funnelSway', 0, 4, 0.01, 'precession (m)'], + ['funnelSwayScale', 0, 3, 0.01, 'precession twist'], + ['funnelSwaySpeed', -3, 3, 0.01, 'precession (rev/s)'], + ['funnelSwayCurve', 0.1, 6, 0.01, 'precession curve'] + ], + 'The vortex profile/Surface': [ + ['funnelThrob', 0, 0.5, 0.001, 'throb'], + ['funnelThrobScale', 0, 12, 0.1, 'throb bands'], + ['funnelThrobSpeed', 0, 8, 0.01, 'throb Hz'], + ['funnelWander', 0, 1, 0.001, 'axis drift (m)'], + ['funnelWanderScale', 0, 6, 0.01, 'drift scale'], + ['funnelWanderSpeed', 0, 4, 0.01, 'drift Hz'], + ['funnelRipple', 0, 1, 0.01, 'ripple'], + ['funnelRippleBands', 0, 8, 0.01, 'ripple bands'], + ['funnelRippleScale', 0, 12, 0.01, 'ripple scale'], + ['funnelRippleSpeed', 0, 10, 0.01, 'ripple Hz'], + ['funnelStreak', 0, 2, 0.01, 'streaks'], + ['funnelStreakSharp', 0, 1, 0.01, 'streak sharpness'], + ['funnelStreakScale', 0, 20, 0.1, 'streak scale'], + ['funnelStreakBands', 0, 8, 0.01, 'streak bands'], + ['funnelStreakGlow', 0, 3, 0.01, 'streak glow'], + ['funnelFlowSpeed', -24, 24, 0.1, 'flow speed'], + ['funnelBands', 0, 24, 0.1, 'rings/length'], + ['funnelBandSharp', 0.05, 8, 0.01, 'ring sharpness'], + ['funnelBandDepth', 0, 1, 0.01, 'ring depth'], + ['funnelBandSpeed', -6, 6, 0.01, 'ring Hz'] + ], + 'The vortex profile/Core, sheath, halo': [ + ['funnelCoreWidth', 0.02, 2, 0.01, 'core width'], + ['funnelCoreFill', 0, 2, 0.01, 'core fill'], + ['funnelCoreSharp', 0.05, 8, 0.01, 'core axis power'], + ['funnelEdgePower', 0.05, 8, 0.01, 'sheath rim power'], + ['funnelSheathWidth', 0.05, 3, 0.01, 'sheath width'], + ['funnelSheathRim', 0, 2, 0.01, 'sheath rim'], + ['funnelSheathFill', 0, 1, 0.01, 'sheath fill'], + ['funnelSheathOpacity', 0, 1, 0.01, 'sheath opacity'], + ['funnelHaloWidth', 0.05, 6, 0.01, 'halo width'], + ['funnelHaloRim', 0.05, 10, 0.01, 'halo rim power'], + ['funnelHaloOpacity', 0, 1, 0.01, 'halo opacity'], + ['funnelMuzzleGlow', 0, 5, 0.01, 'foot glow'], + ['funnelMuzzleLength', 0, 0.6, 0.001, 'foot glow length'], + ['funnelTipGlow', 0, 5, 0.01, 'mouth glow'], + ['funnelTipLength', 0.001, 0.4, 0.001, 'mouth glow length'] + ], + 'The vortex profile/Colour': [ + 'funnelColorCore', + 'funnelColorInner', + 'funnelColorOuter', + 'funnelColorHalo', + ['funnelOpacity', 0, 1, 0.01, 'opacity'], + ['funnelGlow', 0, 8, 0.01, 'glow'], + ['funnelSoftFade', 0, 3, 0.01, 'soft fade (m)'] + ], + 'The vortex profile/Inert on this path': [ + ['funnelRadius', 0.01, 4, 0.01, 'far radius (m)'], + ['funnelRadiusNear', 0.01, 4, 0.01, 'near radius (m)'], + ['funnelRadiusCurve', 0.05, 4, 0.01, 'radius curve'], + ['funnelFlare', 0, 4, 0.01, 'flare'], + ['funnelFlareWidth', 0.01, 1, 0.01, 'flare width'], + ['funnelWaveRate', 0, 6, 0.01, 'loops/second'], + ['funnelWaveWidth', 0.02, 0.6, 0.001, 'loop width'], + ['funnelWaveAmp', 0, 1, 0.001, 'loop throw'], + ['funnelWaveGain', 0.2, 6, 0.01, 'loop gain'], + ['funnelWaveCurve', 0.1, 6, 0.01, 'gain curve'], + ['funnelWaveRoll', 0, 6.284, 0.01, 'crack plane (rad)'], + ['funnelSag', 0, 2, 0.01, 'sag (m)'], + ['funnelCrackRatio', 0.2, 4, 0.01, 'crack ratio'], + ['funnelTipTaper', 0.05, 6, 0.01, 'tip taper'], + ['funnelMeander', 0, 2, 0.01, 'meander (m)'], + ['funnelMeanderTurns', 0, 8, 0.01, 'meander turns'], + ['funnelRecoilAmp', 0, 1, 0.01, 'recoil'], + ['funnelRecoilFreq', 0, 10, 0.01, 'recoil Hz'], + ['funnelRecoilDamp', 0.1, 16, 0.01, 'recoil damping'], + ['funnelRecoilSway', 0, 4, 0.01, 'recoil bow (m)'], + ['funnelArcHeight', -12, 12, 0.01, 'apex height (m)'], + ['funnelArcLateral', -12, 12, 0.01, 'apex offset (m)'], + ['funnelArcBias', 0.05, 0.95, 0.01, 'apex position'], + ['funnelArcCurve', 0.1, 4, 0.01, 'apex curve'] + ], + 'The ribbons': [ + ['wallRibbons', 0, 18, 1, 'wall ribbons'], + ['ribbonTop', 0.05, 1, 0.01, 'wall reach (× height)'], + ['ribbonHug', 0.2, 3, 0.01, 'wall hug (× radiusAt)'], + ['ribbonTurns', 0, 8, 0.05, 'wall turns'], + ['ribbonSpin', -6, 6, 0.01, 'wall spin (turns/s)'], + ['ribbonCurve', 0.1, 4, 0.01, 'wall climb curve'], + ['ribbonPhase', 0, 2, 0.01, 'wall phase spread'], + ['ribbonWobble', 0, 1, 0.01, 'wall wobble'], + ['intakeArms', 0, 12, 1, 'intake arms'], + ['intakeLift', 0, 3, 0.01, 'intake start height (m)'], + ['intakeFloor', 0, 0.5, 0.005, 'intake floor clamp (m)'], + ['intakeTurns', 0, 8, 0.05, 'intake turns'], + ['intakeSpin', -6, 6, 0.01, 'intake spin (turns/s)'], + ['intakeCurve', 0.1, 4, 0.01, 'intake curve'], + ['intakePhase', 0, 2, 0.01, 'intake phase spread'], + ['intakeWobble', 0, 1, 0.01, 'intake wobble'], + ['intakeDamp', 0, 1, 0.01, 'intake ground damping'] + ], + 'The ribbons/Look': [ + ['ribbonWidth', 0.002, 0.3, 0.001, 'width (m)'], + ['ribbonGlowWidth', 1, 20, 0.1, 'halo width'], + ['ribbonGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['ribbonJitter', 0, 2, 0.01, 'kink (m)'], + ['ribbonJitterScale', 0.05, 6, 0.01, 'kinks / metre'], + ['ribbonOctaves', 1, 5, 1, 'octaves'], + ['ribbonJitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['ribbonCrawl', -12, 12, 0.05, 'kink crawl'], + ['ribbonPinch', 0.01, 0.5, 0.005, 'end pinch'], + ['ribbonRestrike', 0.5, 60, 0.5, 'restrikes / sec'], + ['ribbonFlicker', 0, 1, 0.01, 'brightness stutter'], + ['ribbonFlickerSpeed', 1, 90, 1, 'stutter rate'], + ['ribbonStrandFlash', 0, 1, 0.01, 'ribbon blink'], + ['ribbonCoreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['ribbonGlowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['ribbonSoftFade', 0.02, 3, 0.01, 'soft fade (m)'], + ['ribbonOpacity', 0, 2, 0.01, 'opacity'], + ['ribbonGlow', 0, 6, 0.01, 'glow'], + ['ribbonDim', 0, 1, 0.01, 'wall dim'], + ['intakeDim', 0, 1, 0.01, 'intake dim'], + 'colorRibbonCore', + 'colorRibbonInner', + 'colorRibbonOuter', + 'colorRibbonHalo' + ], + 'The flock': [ + ['swarmCount', 0, 192, 1, 'agents'], + ['swarmRide', 0, 1, 0.01, 'ride height (× height)'], + ['swarmHug', 0.2, 4, 0.01, 'orbit (× radiusAt)'], + ['swarmRate', -3, 3, 0.01, 'orbits / second'], + ['swarmTurns', 0.1, 4, 0.05, 'lead turns'], + ['swarmLatticeX', 1, 16, 1, 'cells across'], + ['swarmLatticeY', 1, 12, 1, 'cells up'], + ['swarmLatticeZ', 1, 24, 1, 'ranks'], + ['swarmSpacingSide', 0.05, 3, 0.01, 'side spacing (m)'], + ['swarmSpacingUp', 0.05, 3, 0.01, 'up spacing (m)'], + ['swarmLag', 0, 6, 0.01, 'tail lag (s)'], + ['swarmJitter', 0, 2, 0.01, 'cell slop (m)'], + ['swarmChurn', -6, 6, 0.01, 'churn (rad/s)'], + ['swarmBreathe', 0, 1, 0.01, 'breathe'], + ['swarmBreatheRate', 0, 6, 0.01, 'breathe rate'], + ['swarmWander', 0, 2, 0.01, 'drift (m)'], + ['swarmWanderScale', 0, 3, 0.01, 'drift scale'], + ['swarmWanderSpeed', 0, 4, 0.01, 'drift Hz'], + ['swarmGather', 0, 1, 0.01, 'gather'] + ], + 'The flock/Body': [ + ['swarmSize', 0.02, 2, 0.01, 'size (m)'], + ['swarmAspect', 0.2, 4, 0.01, 'aspect'], + ['swarmSizeJitter', 0, 1, 0.01, 'size jitter'], + ['swarmBillboard', 0, 1, 0.01, 'billboard'], + ['swarmBank', 0, 0.4, 0.005, 'bank / accel'], + ['swarmBankMax', 0, 3.2, 0.01, 'bank limit (rad)'], + ['swarmDihedral', 0, 1, 0.01, 'fold'], + ['swarmFlapRate', 0, 12, 0.1, 'fold rate'], + ['swarmCurl', 0, 1, 0.01, 'curl'], + ['swarmEdgeStretch', 1, 4, 0.01, 'edge stretch'], + ['swarmEdgeGain', 0, 6, 0.01, 'edge gain'], + ['swarmRevealSpread', 0.01, 1, 0.01, 'reveal spread'], + ['swarmLit', 0, 1, 0.01, 'lit'], + ['swarmTint', 0, 1, 0.01, 'tint'], + ['swarmTintJitter', 0, 1, 0.01, 'tint jitter'], + ['swarmTintAlong', 0, 1, 0.01, 'tint along'], + ['swarmOpacity', 0, 2, 0.01, 'opacity'], + ['swarmGlow', 0, 4, 0.01, 'glow'], + ['swarmSoftFade', 0.02, 3, 0.01, 'soft fade (m)'], + ['colorSwarm*', 'Debris colour'] + ], + 'The scour': [ + ['scourReach', 0.2, 4, 0.01, 'reach (× foot radius)'], + ['scourHeight', 0, 0.3, 0.005, 'height (m)'], + ['scourEdge', 0.01, 3, 0.01, 'front feather (m)'], + ['scourRagged', 0, 1, 0.01, 'front wander'], + ['scourRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['scourWarp', 0, 3, 0.01, 'domain warp (m)'], + ['scourRelief', 0, 3, 0.01, 'relief'], + ['scourNormalStep', 0.005, 0.3, 0.005, 'normal step (m)'], + ['scourAmbient', 0, 1, 0.01, 'ambient'], + ['scourWrap', 0, 1, 0.01, 'terminator wrap'], + ['scourSpecular', 0, 2, 0.01, 'specular'], + ['scourGloss', 1, 80, 1, 'gloss'], + ['scourParallax', 0, 1, 0.01, 'parallax (m)'], + ['scourDepth', 0, 1.5, 0.01, 'groove depth (m)'], + ['scourLift', 0, 1, 0.01, 'ridge height (m)'], + ['scourSharp', 0, 1, 0.01, 'groove sharpness'], + ['scourDetail', 0, 1, 0.01, 'grain'], + ['scourSwirl', 0, 2, 0.01, 'spiral pitch'], + ['scourArms', 1, 16, 1, 'grooves'], + ['scourSpin', -4, 4, 0.01, 'rotation (rad/s)'], + ['scourEmissive', 0, 3, 0.01, 'emissive'], + ['scourOpacity', 0, 1, 0.01, 'opacity'], + ['scourDepthFade', 0.01, 3, 0.01, 'depth fade (m)'], + ['scourAdditive', 'additive'], + 'colorScourBase', + 'colorScourEdge', + 'colorScourGlow', + 'colorScourDeep' + ], + 'Dust & debris': [ + ['dustRate', 0, 500, 1, 'dust rate'], + ['dustSize', 0.05, 6, 0.01, 'dust size'], + ['dustSpeed', 0, 20, 0.1, 'dust speed'], + ['dustLifetime', 0.1, 8, 0.05, 'dust lifetime'], + ['dustRise', -3, 8, 0.05, 'dust rise'], + ['dustSwirl', -12, 12, 0.05, 'dust orbit (rad/s)'], + ['dustSwirlExpand', 0, 3, 0.01, 'dust orbit growth'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['debrisRate', 0, 300, 1, 'debris rate'], + ['debrisSize', 0.005, 0.6, 0.005, 'debris size'], + ['debrisSpeed', 0, 25, 0.1, 'debris speed'], + ['debrisLifetime', 0.1, 6, 0.05, 'debris lifetime'], + ['debrisGravity', -40, 5, 0.1, 'debris gravity'], + ['debrisSwirl', -12, 12, 0.05, 'debris orbit (rad/s)'], + ['debrisSwirlExpand', 0, 3, 0.01, 'debris orbit growth'], + ['gritRate', 0, 800, 1, 'grit rate'], + ['gritSize', 0.005, 0.6, 0.005, 'grit size'], + ['gritSpeed', 0, 30, 0.1, 'grit speed'], + ['gritLifetime', 0.05, 4, 0.01, 'grit lifetime'], + ['gritRise', -5, 15, 0.05, 'grit lift'], + ['gritStretch', 0, 3, 0.01, 'grit stretch'], + ['gritTurbulence', 0, 3, 0.01, 'grit turbulence'], + ['whorlRate', 0, 600, 1, 'whorl rate'], + ['whorlRise', -2, 6, 0.05, 'whorl rise'], + ['whorlSwirl', -16, 16, 0.05, 'whorl orbit (rad/s)'], + ['colorDust*', 'Dust colour'], + ['colorDebris*', 'Debris colour'], + ['colorGrit*', 'Grit colour'] + ], + 'Touchdown & light': [ + ['touchSize', 0.2, 14, 0.05, 'touchdown shell (m)'], + ['touchIntensity', 0, 5, 0.01, 'shell intensity'], + ['touchFlash', 0, 2, 0.01, 'screen flash'], + ['touchShake', 0, 3, 0.01, 'touchdown shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.5, 0.005, 'standing rumble'], + 'colorTouchA', + 'colorTouchB', + 'colorTouchC', + 'colorTouchFlash', + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightShimmer', 0, 1, 0.01, 'light breathing'], + ['lightShimmerSpeed', 0, 20, 0.05, 'breathing rate'], + 'lightColor' + ] +}; diff --git a/src/config/abilities/dawnbreak.js b/src/config/abilities/dawnbreak.js new file mode 100644 index 0000000..5e14858 --- /dev/null +++ b/src/config/abilities/dawnbreak.js @@ -0,0 +1,213 @@ +/* ================================================================== */ +/* DAWNBREAK — the ability that moves the sun */ +/* ================================================================== */ +/** + * The far cast whose effect is almost entirely **the rest of the scene**. + * + * Dawnbreak borrows `Hook.KEY_LIGHT` from `vfx/SceneHooks.js` and swings the + * stage's one directional light from the horizon, up over the top and back down + * the other side. Because it is the *same* light that was already casting every + * shadow in the world, the character's shadow, the crystals of a Frost Lance + * still standing on the floor and the floor's own relief all sweep round with + * it. Nothing in this block describes a shadow, because nothing has to: the + * world was always drawing them. + * + * Three beats, and they map onto the phase machine like this: + * + * | phase | what it is | how long | + * | --- | --- | --- | + * | travel | the stage's light rakes down toward the horizon as the cast reaches out | `range / speed` | + * | impact | **the sweep** — horizon, overhead, horizon | `sweepTime × global.lifetime` | + * | fade | the sun slides back to wherever `settings.environment` says it lives | `settleTime` | + * + * The three controls worth reaching for first are `sunWeight` (how much of the + * sun the ability takes — at 0 the hook is transparent and the editor's own + * `environment.sunAzimuth` reads straight through), `sweepTime` (a slow sweep + * is a sunrise, a fast one is a searchlight) and `elevHigh` (how close to true + * noon the arc gets, which is the difference between long raking shadows all + * the way through and a moment of flat overhead light in the middle). + * + * **On putting it back.** The restore is free and it is exact. + * `Environment.update()` re-authors the key light from `settings.environment` + * on every frame *before* the abilities run, and `SceneHooks.apply()` blends + * from those settings rather than from the live light — so the frame after the + * token is released, the sun is bit-for-bit where the sliders say, whether the + * cast ended normally, was cleared with **C**, or was pushed off the four-cast + * concurrency cap mid-sweep. + * + * **What the sweep costs: nothing.** `App.frame` already sets + * `shadowMap.needsUpdate` unconditionally every frame, so the 4096² directional + * map is re-rendered whether the sun moves or not — 1 draw call and 9,578 + * triangles, measured. Moving the sun changes what lands in those texels and + * nothing else. The cost that *is* real is a look cost: the map's contents now + * change every frame, so PCF's temporal stability goes and a shadow edge that + * was rock-steady crawls slightly. Slow the sweep and it reads as softness. + * + * Nothing here is captured by a cast. A cast rolls one seed for the daystar's + * granulation; every metre, radian and second below is re-read on every frame, + * zero-length ones included. + */ +export const dawnbreak = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 44.0, // how fast the cast reaches the circle, metres/second + cooldown: 9.0, // seconds — the longest in the school; this one takes the world + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 6.5, // the footprint — what the circle indicator measures out + + /* ------------------------------------------------------------------ */ + /* The beats */ + /* ------------------------------------------------------------------ */ + sweepTime: 4.2, // seconds for the whole arc: horizon → overhead → horizon + settleTime: 1.2, // seconds the sun takes to slide back to the stage's own aim + reachCurve: 1.4, // exponent on the travel-phase ramp — >1 holds off, then commits + + /* ------------------------------------------------------------------ */ + /* The arc — every one of these is a radian the hook re-reads each frame */ + /* ------------------------------------------------------------------ */ + /** + * `sunWeight` is the blend against `settings.environment`, not an opacity. At + * 0 the hook is *transparent*: the light is exactly where the environment + * sliders put it, and the ability is still holding the hook. At 1 the arc + * below is the whole truth. + */ + sunWeight: 1.0, // 0..1 — how much of the sun the cast takes + elevLow: 0.09, // radians above the horizon the arc starts and ends at + elevHigh: 1.36, // radians at the top of the arc (π/2 would be true noon) + elevCurve: 0.85, // exponent on sin(πd) — <1 lingers high, >1 lingers low + azStart: 2.15, // radians — the bearing the sun rises on + azSweep: 1.75, // radians the bearing turns through over the whole arc + intensityLow: 1.5, // key intensity at the horizon, same units as environment.sunIntensity + intensityHigh: 4.6, // ... and at the top of the arc + colorHorizon: '#ff9a44', // the key light's colour on the horizon + colorZenith: '#fff4dc', // ... and overhead + + /* ------------------------------------------------------------------ */ + /* The daystar — the one thing the ability actually draws */ + /* ------------------------------------------------------------------ */ + /** + * A single additive billboard placed up-sun from the circle, built in view + * space around `uCentre` (see `materials/DaystarMaterial.js`). It exists so + * the sweep reads as *the sun moving* rather than as the lighting glitching. + */ + discDistance: 74.0, // metres up-sun from the circle the disc hangs + discSize: 3.4, // radius of the body, metres at that distance + discReach: 4.0, // how far past the body the quad is drawn, × discSize + discSoft: 0.12, // how soft the limb is, 0..1 of the radius + discLimb: 0.4, // limb-darkening exponent — low is a flatter, hotter face + haloSize: 3.2, // aureole reach, × discSize (the quad grows to cover it) + haloFalloff: 2.4, // how fast the aureole dies off; high is a tight glow + flare: 0.6, // the anamorphic streak — the one deliberately artificial term + flareLength: 0.9, // its half-length, × the aureole reach + flareWidth: 0.16, // its half-height, × discSize + granule: 0.35, // convection mottling across the face, 0..1 + granuleScale: 1.5, // cells per disc radius + granuleSpeed: 0.12, // Hz the mottling crawls at + discGlow: 2.6, // emissive gain into bloom + discOpacity: 1.0, // master coverage of the disc + colorDisc: '#fff6e2', // the body at the top of the arc + colorDiscLow: '#ff7a2e', // the body on the horizon + colorHalo: '#ffc27a', // the aureole and the streak + + /* ------------------------------------------------------------------ */ + /* The dust — how you see that the light has a direction */ + /* ------------------------------------------------------------------ */ + /** + * Grazing light picks dust out of the air; overhead light does not. That is + * what `moteGraze` is: the emission rate is multiplied by it at the horizon + * and by 1 at the top, so the air thickens at both ends of the arc and + * clears in the middle without anything being keyframed. + */ + moteRate: 26.0, // motes per second at the top of the arc + moteGraze: 1.9, // × that rate on the horizon + moteSize: 0.055, // metres + moteLifetime: 3.4, // seconds + moteRise: 0.22, // metres/second of buoyancy + moteSpeed: 0.35, // metres/second of initial drift + moteTurbulence: 0.5, // curl-noise strength on the drift + moteHeight: 2.6, // metres of air above the circle the dust fills + moteSpread: 0.95, // × zoneRadius — how much of the circle it fills + moteJitter: 0.24, // metres of slop on one emission puff + colorMoteA: '#fff0d2', // birth + colorMoteB: '#ffcf94', // early + colorMoteC: '#c98f52', // late + colorMoteD: '#3b2c22', // death + + /* ------------------------------------------------------------------ */ + /* The local light, the flash and the readout */ + /* ------------------------------------------------------------------ */ + lightColor: '#ffce93', // the dynamic light standing in the circle + lightIntensity: 2.2, + lightRadius: 13.0, // metres + lightHeight: 1.4, // metres above the floor it hangs + lightNoon: 1.7, // × its intensity at the top of the arc — the shimmer curve + crestFlash: 0.35, // screen flash as the sun leaves the horizon + colorFlash: '#ffd9a6' +}; + +/** Editor layout. */ +export const dawnbreakSchema = { + 'The cast': ['range', 'minRange', 'speed', 'cooldown', 'castAnim', ['zoneRadius', 1, 14, 0.1, 'circle radius (m)']], + 'The beats': [ + ['sweepTime', 0.4, 14, 0.05, 'sweep (s)'], + ['settleTime', 0.1, 6, 0.05, 'settle back (s)'], + ['reachCurve', 0.2, 4, 0.01, 'reach curve'] + ], + 'The arc': [ + ['sunWeight', 0, 1, 0.01, 'how much sun it takes'], + ['elevLow', -0.2, 1.2, 0.005, 'horizon elevation (rad)'], + ['elevHigh', 0.1, 1.5708, 0.005, 'top elevation (rad)'], + ['elevCurve', 0.2, 3, 0.01, 'arc curve'], + ['azStart', -3.1416, 6.2832, 0.005, 'rise bearing (rad)'], + ['azSweep', -6.2832, 6.2832, 0.005, 'bearing swept (rad)'], + ['intensityLow', 0, 10, 0.05, 'key at the horizon'], + ['intensityHigh', 0, 10, 0.05, 'key overhead'], + 'colorHorizon', + 'colorZenith' + ], + 'The daystar': [ + ['discDistance', 12, 180, 0.5, 'distance (m)'], + ['discSize', 0.2, 20, 0.05, 'body radius (m)'], + ['discReach', 1.2, 10, 0.05, 'drawn reach × radius'], + ['discSoft', 0.005, 0.9, 0.005, 'limb softness'], + ['discLimb', 0.05, 3, 0.01, 'limb darkening'], + ['haloSize', 1, 8, 0.05, 'aureole × radius'], + ['haloFalloff', 0.2, 8, 0.05, 'aureole falloff'], + ['discGlow', 0, 8, 0.05, 'glow'], + ['discOpacity', 0, 1, 0.01, 'opacity'], + 'colorDisc', + 'colorDiscLow', + 'colorHalo' + ], + 'The daystar/Flare and face': [ + ['flare', 0, 3, 0.01, 'streak'], + ['flareLength', 0.05, 3, 0.01, 'streak length'], + ['flareWidth', 0.01, 1, 0.005, 'streak width'], + ['granule', 0, 1, 0.01, 'granulation'], + ['granuleScale', 0.1, 8, 0.05, 'granule scale'], + ['granuleSpeed', 0, 2, 0.01, 'granule Hz'] + ], + 'The dust': [ + ['moteRate', 0, 200, 1, 'motes/second'], + ['moteGraze', 0.1, 6, 0.05, 'grazing multiplier'], + ['moteSize', 0.005, 0.4, 0.005, 'size (m)'], + ['moteLifetime', 0.2, 10, 0.05, 'life (s)'], + ['moteRise', -1, 2, 0.01, 'buoyancy (m/s)'], + ['moteSpeed', 0, 4, 0.01, 'drift (m/s)'], + ['moteTurbulence', 0, 3, 0.01, 'turbulence'], + ['moteHeight', 0.2, 12, 0.05, 'column height (m)'], + ['moteSpread', 0.05, 1.5, 0.01, 'fill × zoneRadius'], + ['moteJitter', 0.01, 2, 0.01, 'puff radius (m)'], + ['colorMote*', 'Dust gradient'] + ], + 'The light': [ + 'lightColor', + ['lightIntensity', 0, 12, 0.05, 'intensity'], + ['lightRadius', 1, 40, 0.5, 'radius (m)'], + ['lightHeight', 0, 6, 0.05, 'height (m)'], + ['lightNoon', 0.2, 4, 0.01, 'overhead multiplier'], + ['crestFlash', 0, 2, 0.01, 'crest flash'], + 'colorFlash' + ] +}; diff --git a/src/config/abilities/dragonbreath.js b/src/config/abilities/dragonbreath.js new file mode 100644 index 0000000..331c17f --- /dev/null +++ b/src/config/abilities/dragonbreath.js @@ -0,0 +1,425 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* DRAGONBREATH — Wyrm's Breath, a real cone volume */ +/* ================================================================== */ +/** + * A line cast, sustained. The apex is at the caster's hand, the mouth is out at + * the target, and what is in between is a **raymarched cone** rather than a + * widening billboard: `flameHollow` moves the density peak off the axis, so the + * gout is a sheath with unburnt air down the middle and orbiting the cast shows + * you the far wall of the tongue through the near one. + * + * ### The two numbers that make or break it + * + * `flameHollow` is the trick. At 0 the cone is solid and reads as a cardboard + * megaphone from every angle — there is nothing to see *through*, so no amount + * of turbulence stops it looking like a decal. Around 0.45 the peak sits + * halfway out to the wall and the middle goes empty, which is what a real + * nozzle does: fuel burns at the shear layer, not in the core. + * + * `flameJet` is the sustain. It slides the noise domain backwards along the + * hull's own +Z, so features travel *outward* at that many metres per second. + * `Medium.FLAME` samples in `localDomain()` — hull space — precisely so this + * works: the turbulence belongs to the jet and yaws with the caster instead of + * being swum through. (Pyroclasm needs the exact opposite and says so.) + * + * ### Reach + * + * `reach` is a live multiplier on the cast's own distance, and it is the number + * every downstream metre is derived from: the hull's three half-extents, the + * scorch's length, the tongues' start and overshoot, where the light sits. + * Dragging it mid-cast shortens the gout **and pulls the floor burn back in + * with it**, which is the one thing a spawned decal could never do. + * + * The eight `flameBoil*` / one `flameVoid*` keys, and the four `flameSpeck*`, + * belong to other media and are inert here — see the note above + * `dragonbreathSchema`. + */ +export const dragonbreath = { + /* --- the cast --- */ + range: 17.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 96.0, // how fast the gout reaches the mouth, metres/second + cooldown: 1.6, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the cone is pinned --- */ + handForward: 0.7, // metres in front of the caster the apex sits + handSide: 0.18, // metres to the side (+ follows `Ability#side`) + handHeight: 1.35, // metres above the floor + endHeight: 0.55, // height of the mouth's centre at the target, metres + + /* --- the beats --- */ + sustainTime: 1.15, // seconds the gout is held at full reach + fadeTime: 0.75, // seconds it takes to gutter out + + /* --- the cone --- */ + reach: 1.0, // × the cast distance — the live length of the gout + dieback: 0.34, // × reach — how far it pulls back in as it guts out + coneFlare: 0.24, // mouth half-width ÷ span; 0.24 is roughly a 27° cone + coneSquash: 0.82, // mouth half-height ÷ half-width; a breath is wider than tall + coneSlack: 1.06, // extra proxy-hull headroom past the margin compensation + + /* --- the flame volume (Medium.FLAME, prefix `flame`) --- */ + // Emissive, so no self-shadow taps: the medium radiates and there is nothing + // for a key light to give it. That halves the march cost, which is what pays + // for 38 steps on a hull this close to the camera. + ...volumeHullDefaults('flame', Medium.FLAME, { + flameSteps: 38, // march steps + flameJitter: 1.0, // step dither; 0 only ever shows you the banding + flameContact: 0.8, // metres of fade where the flame meets the floor + flameMargin: 0.3, // headroom inside the hull — paired with NoiseStrength + flameHollow: 0.46, // THE TRICK: how far off-axis the density peak sits, 0..0.9 + flameThroat: 0.16, // fraction of the span the cone opens over at the apex + flameFeather: 0.3, // feather on the mouth + flameHeightBias: 0.0, // unused on a cone; the hull is not standing on the floor + flameDensity: 2.6, // density + flameDensityCurve: 1.15, // density curve + flameSoftness: 0.34, // edge softness + flameNoiseFrequency: 2.2, // features per metre + flameNoiseStrength: 1.0, // erosion — raise this and you owe flameMargin + flameNoiseWarp: 0.38, // domain warp + flameOctaves: 4, // octaves + flameJet: 7.5, // metres/second the flow crawls along the hull axis, outward + flameRise: 0.9, // buoyant rise, metres/second + flameSwirl: 0.6, // roll about the cone axis, radians/second + flameAbsorption: 0.85, // absorption, 1/metre + flameScatter: 0.35, // scattering + flameAmbient: 0.1, // multi-scatter floor + flameAnisotropy: 0.45, // forward scatter + flameEmission: 7.5, // emission — this is what a flame lives on + flameEmissionCurve: 1.4, // emission by density + flameShadowTaps: 0, // emissive media need none, and the shader is built without them + flameOpacity: 1.0 + }), + + /* --- the floor burn, growing along the cone's ground intersection --- */ + // GroundMode.RUT: a track with a live `length` and a live `progress`, which is + // the only mode in `GroundField` whose front runs *down* something rather than + // out from a centre. A cone's ground intersection is a wedge and none of the + // ten modes draws a wedge, so the widening is carried by the contact-force + // samples instead — see `markNear`. + scorchWidth: 0.72, // track half-width, × the mouth half-width + scorchEdge: 0.55, // metres the ends of the track are capped over + scorchDepth: 0.05, // metres — a burn barely digs; the colour does the work + scorchLift: 0.03, // metres of spoil piled either side + scorchThickness: 0.22, // metres — width of that spoil band + scorchSeam: 0.9, // metres over which one contact sample blends into the next + scorchCell: 0.85, // metres — pitch of the chatter along the track + scorchWander: 0.18, // metres the track drifts laterally; a breath does not slalom + scorchRaggedScale: 0.5, // lobes per metre on that drift + scorchSharp: 0.35, // 0 soft-edged burn, 1 knife-edged + scorchDetail: 0.55, // grain + scorchRelief: 0.5, // how hard the height field tilts the fake normal + scorchNormalStep: 0.06, // metres between the height taps + scorchAmbient: 0.24, // floor on the diffuse term + scorchWrap: 0.55, // wraps the terminator round the back + scorchSpecular: 0.3, // vitrified stone has a sheen + scorchGloss: 26.0, // Blinn exponent + scorchParallax: 0.12, // metres of view-driven offset on the interior detail + scorchEmissive: 1.25, // multiplier on every glowing term + scorchOpacity: 0.96, + scorchHeight: 0.02, // metres the quad floats above the floor + scorchDepthFade: 0.4, // metres of soft fade against anything standing in it + markRate: 1.1, // contact samples posted per metre of front travel + markNear: 0.28, // contact force at the caster's feet, 0..1 — the wedge, faked + colorScorchBase: '#2c211a', // the burnt stone + colorScorchEdge: '#8a5a2e', // the spoil either side + colorScorchGlow: '#ff7a1e', // the burning front + colorScorchDeep: '#0e0705', // the bottom of the track + + /* --- the licking tongues at the front edge --- */ + // One FilamentPaths role, two draw calls whatever the count. They are pinned + // loosely (`tongueConverge` well below 1) so their far ends flail past the + // mouth instead of arriving on it — that looseness is the entire read. + tongueCount: 7, // filaments (the strip's capacity caps at 48) + tongueStart: 0.52, // where they leave the axis, × the span + tongueOvershoot: 1.22, // where they aim, × the span — past the volume + tongueNear: 0.35, // metres they are fanned at `tongueStart` + tongueFan: 1.35, // × the mouth half-width — how far they are fanned at the far end + tongueSpreadCurve: 1.5, // >1 keeps them tight then flares them late + tongueSag: 0.18, // metres of upward bow at mid-span; negative droops + tongueTwist: 0.35, // turns of roll from end to end + tongueTwistSpeed: 0.55, // turns/second the whole fan rolls + tongueConverge: 0.18, // 0..1 — how hard the far end is pinned to the target + tongueLickBase: 0.86, // mean fraction of the path that is drawn + tongueLickDepth: 0.26, // ± on that, so they periodically outrun the volume + tongueLickSpeed: 5.4, // licks per second + tongueTipLength: 0.16, // fraction of the path the front is smeared over + tongueTipGlow: 2.6, // extra heat on that front + tongueFadeStart: 0.9, // 0 square, 1 faded to nothing, at the apex end + tongueFadeEnd: 1.0, // ... and at the tip + tongueTaperStart: 0.85, // the same for the ribbon's width + tongueTaperEnd: 1.0, + tongueKink: 1.2, // per-role multiplier on the shared jitter + tongueWidthScale: 1.0, // per-role multiplier on the shared width + tongueDim: 1.0, // 0..1 how secondary the role is + tongueGroundDamp: 1.0, // 1 in the air; drop it toward 0.3 for anything flat + tongueFloor: 0.06, // metres the tongues are clamped above the floor + /* the shared ribbon look (`filamentLook()`'s vocabulary, all of it authored) */ + tongueWidth: 0.05, // half-width of the core ribbon, metres + tongueGlowWidth: 5.4, // halo half-width, × the core width + tongueGlowOpacity: 0.5, // halo alpha relative to the core + tongueJitter: 0.42, // metres of lateral kink at the coarsest octave + tongueJitterScale: 1.1, // kinks per metre + tongueOctaves: 4, // 1–5 + tongueJitterFalloff: 0.55, // amplitude kept per octave + tongueCrawl: 4.2, // how fast the kinks slide along, per second + tonguePinch: 0.2, // fraction of the path the kink eases in over at each end + tongueRestrike: 13, // whole re-shapes per second + tongueFlicker: 0.3, // depth of the whole-bundle brightness stutter + tongueFlickerSpeed: 22, // stutters/second + tongueStrandFlash: 0.55, // depth of the per-filament blink + tongueCoreSharp: 3.4, // exponent on the core's edge falloff + tongueGlowFalloff: 2.2, // the same for the halo + tongueSoftFade: 0.6, // metres of depth fade against the opaque scene + tongueOpacity: 1.0, + tongueGlow: 2.6, // emissive gain fed into bloom + colorTongueCore: '#fff6d8', // the white-hot centre line + colorTongueInner: '#ffc65a', + colorTongueOuter: '#ff5a14', + colorTongueHalo: '#5a1404', // the wide, dim atmosphere + + /* --- embers streaming out of the mouth --- */ + emberRate: 260, // particles/second + emberSize: 0.13, + emberSpeed: 13.0, // metres/second + emberLifetime: 0.85, // seconds + emberGravity: -6.0, // metres/second² + emberStretch: 0.22, // how far a spark smears along its velocity + emberSpread: 0.55, // 0..1 cone of the initial scatter + emberGlow: 2.2, + colorEmberA: '#fff2c0', + colorEmberB: '#ffb03a', + colorEmberC: '#e0400f', + colorEmberD: '#2a0a04', + + /* --- burning fuel falling out of the cone --- */ + dripRate: 34, // particles/second + dripSize: 0.12, + dripSpeed: 2.2, // metres/second + dripLifetime: 1.3, // seconds + dripGravity: -9.5, // metres/second² + dripTurbulence: 0.5, + dripGlow: 2.8, + dripSeat: 0.65, // where along the span they come off, as a fraction + colorDripA: '#fff2c0', + colorDripB: '#ffb03a', + colorDripC: '#c03408', + colorDripD: '#2a0a04', + + /* --- smoke off the burnt floor --- */ + smokeRate: 58, // particles/second + smokeSize: 1.05, + smokeSpeed: 1.6, // metres/second + smokeLifetime: 2.6, // seconds + smokeRise: 1.0, // metres/second² + smokeOpacity: 0.16, + colorSmokeA: '#4a3b32', + colorSmokeB: '#332721', + colorSmokeC: '#221a16', + colorSmokeD: '#140f0c', + + /* --- the punctuation --- */ + muzzleSize: 1.1, // the flare at the hand as the breath starts, metres + muzzleIntensity: 2.0, + castFlash: 0.13, // screen flash on release + impactFlash: 0.16, // ... as the gout reaches the mouth + mouthBurstSize: 3.2, // the shell of burning air at the mouth, metres + mouthBurstIntensity: 1.6, + mouthBurstRate: 3.4, // shells shed per second while the breath is sustained + impactShake: 0.35, + shakeDuration: 0.5, // seconds + rumble: 0.055, // continuous shake while the breath is held + colorBurstA: '#e0400f', + colorBurstB: '#ffb03a', + colorBurstC: '#fff2c0', + colorCastFlash: '#ffb03a', + colorFlash: '#fff2c0', // the full-screen flash when the gout lands + + /* --- dynamic light --- */ + lightIntensity: 30, // the gout is the brightest thing in the room + lightRadius: 20, // metres + lightSpan: 0.62, // where the light sits along the cone, 0 apex .. 1 mouth + lightColor: '#ff8a20' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Wyrm's Breath. + * + * Reach for `flameHollow` first — it is the difference between a cone you can + * see through and a painted megaphone — and then `flameJet`, which is how fast + * the sustain crawls. `coneFlare` and `reach` between them decide the whole + * silhouette. If the flame ends on a dead straight line, that is + * `flameNoiseStrength` against `flameMargin`, as always. + * + * The `flameBoil*`, `flameVoid*` and `flameSpeck*` keys are not filed here on + * purpose. `volumeHullDefaults` emits the whole vocabulary so the hull's own + * audit stays quiet, but bubbles, stars and extra occlusion do nothing to a + * FLAME medium, and thirteen inert rows is worse than a "More" folder nobody + * opens. + */ +export const dragonbreathSchema = { + 'The cast': [ + ['range', 3, 50, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 300, 1, 'gout speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'], + ['sustainTime', 0.05, 6, 0.01, 'sustain'], + ['fadeTime', 0.05, 4, 0.01, 'gutter out'] + ], + 'Where the cone is pinned': [ + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['handHeight', 0, 3, 0.01, 'hand height'], + ['endHeight', 0, 4, 0.01, 'mouth height'] + ], + 'The cone': [ + ['reach', 0.1, 1.6, 0.01, 'reach ×cast distance'], + ['dieback', 0.05, 1, 0.01, 'die-back ×reach'], + ['coneFlare', 0.02, 0.8, 0.005, 'flare (half-width ÷ span)'], + ['coneSquash', 0.2, 2, 0.01, 'mouth squash'], + ['coneSlack', 1, 2, 0.01, 'proxy hull slack'] + ], + ...volumeHullSchema('flame', { + label: 'Flame', + only: ['march', 'shape', 'field', 'flow', 'optics', 'colour'] + }), + 'Floor burn': [ + ['scorchWidth', 0.1, 3, 0.01, 'half-width ×mouth'], + ['scorchEdge', 0.05, 3, 0.01, 'end cap (m)'], + ['scorchDepth', 0, 0.6, 0.005, 'gouge depth (m)'], + ['scorchLift', 0, 0.3, 0.005, 'spoil height (m)'], + ['scorchThickness', 0.01, 1, 0.005, 'spoil width (m)'], + ['scorchSeam', 0.05, 4, 0.01, 'force blend (m)'], + ['scorchCell', 0.05, 4, 0.01, 'chatter pitch (m)'], + ['scorchWander', 0, 2, 0.01, 'track wander'], + ['scorchRaggedScale', 0.05, 3, 0.01, 'wander scale'], + ['scorchSharp', 0, 1, 0.01, 'edge sharpness'], + ['scorchDetail', 0, 1, 0.01, 'grain'], + ['scorchRelief', 0, 3, 0.01, 'relief'], + ['scorchNormalStep', 0.005, 0.4, 0.005, 'normal step (m)'], + ['scorchAmbient', 0, 1, 0.01, 'ambient'], + ['scorchWrap', 0, 1, 0.01, 'terminator wrap'], + ['scorchSpecular', 0, 2, 0.01, 'specular'], + ['scorchGloss', 1, 90, 1, 'gloss'], + ['scorchParallax', 0, 2, 0.01, 'parallax (m)'], + ['scorchEmissive', 0, 4, 0.01, 'emissive'], + ['scorchOpacity', 0, 1, 0.01, 'opacity'], + ['scorchHeight', 0, 0.3, 0.005, 'float above floor (m)'], + ['scorchDepthFade', 0.05, 3, 0.01, 'soft intersection (m)'], + ['markRate', 0.05, 6, 0.05, 'contact samples / metre'], + ['markNear', 0, 1, 0.01, 'force at the caster'], + ['colorScorchBase', 'burnt stone'], + ['colorScorchEdge', 'spoil'], + ['colorScorchGlow', 'burning front'], + ['colorScorchDeep', 'track floor'] + ], + 'Licking tongues': [ + ['tongueCount', 0, 24, 1, 'filaments'], + ['tongueStart', 0, 1, 0.01, 'start ×span'], + ['tongueOvershoot', 0.2, 2, 0.01, 'aim ×span'], + ['tongueNear', 0, 2, 0.01, 'fan at the start (m)'], + ['tongueFan', 0, 4, 0.01, 'fan at the tip ×mouth'], + ['tongueSpreadCurve', 0.2, 5, 0.01, 'fan curve'], + ['tongueSag', -2, 2, 0.01, 'mid-span bow (m)'], + ['tongueTwist', -4, 4, 0.01, 'twist over length'], + ['tongueTwistSpeed', -6, 6, 0.01, 'twist speed'], + ['tongueConverge', 0, 1, 0.01, 'lock onto the target'], + ['tongueLickBase', 0.1, 1.6, 0.01, 'mean draw'], + ['tongueLickDepth', 0, 1, 0.01, 'lick depth'], + ['tongueLickSpeed', 0, 20, 0.1, 'licks / second'], + ['tongueTipLength', 0.005, 0.6, 0.005, 'tip smear'], + ['tongueTipGlow', 0, 8, 0.05, 'tip glow'], + ['tongueFadeStart', 0, 1, 0.01, 'fade at the apex'], + ['tongueFadeEnd', 0, 1, 0.01, 'fade at the tip'], + ['tongueTaperStart', 0, 1, 0.01, 'taper at the apex'], + ['tongueTaperEnd', 0, 1, 0.01, 'taper at the tip'], + ['tongueKink', 0, 4, 0.01, 'kink ×'], + ['tongueWidthScale', 0, 4, 0.01, 'width ×'], + ['tongueDim', 0, 1, 0.01, 'role dim'], + ['tongueGroundDamp', 0, 1, 0.01, 'ground damp'], + ['tongueFloor', -1, 2, 0.01, 'floor clamp (m)'] + ], + 'Licking tongues/Ribbon': [ + ['tongueWidth', 0.005, 0.6, 0.005, 'core half-width (m)'], + ['tongueGlowWidth', 1, 30, 0.1, 'halo width ×'], + ['tongueGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['tongueJitter', 0, 3, 0.01, 'kink amplitude (m)'], + ['tongueJitterScale', 0.05, 6, 0.01, 'kinks / metre'], + ['tongueOctaves', 1, 5, 1, 'octaves'], + ['tongueJitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['tongueCrawl', -20, 20, 0.1, 'kink crawl'], + ['tonguePinch', 0.01, 0.5, 0.005, 'end pinch'], + ['tongueRestrike', 0.5, 90, 0.5, 'restrikes / sec'], + ['tongueFlicker', 0, 1, 0.01, 'brightness stutter'], + ['tongueFlickerSpeed', 1, 120, 1, 'stutter rate'], + ['tongueStrandFlash', 0, 1, 0.01, 'filament blink'], + ['tongueCoreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['tongueGlowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['tongueSoftFade', 0.02, 3, 0.01, 'soft intersection (m)'], + ['tongueOpacity', 0, 2, 0.01, 'opacity'], + ['tongueGlow', 0, 8, 0.01, 'glow'], + ['colorTongueCore', 'core'], + ['colorTongueInner', 'inner'], + ['colorTongueOuter', 'outer'], + ['colorTongueHalo', 'halo'] + ], + 'Embers & drips': [ + ['emberRate', 0, 1200, 1, 'ember rate'], + ['emberSize', 0.005, 0.8, 0.005, 'ember size'], + ['emberSpeed', 0, 40, 0.1, 'ember speed'], + ['emberLifetime', 0.05, 4, 0.01, 'ember lifetime'], + ['emberGravity', -50, 5, 0.1, 'ember gravity'], + ['emberStretch', 0, 3, 0.01, 'ember stretch'], + ['emberSpread', 0, 1, 0.01, 'ember spread'], + ['emberGlow', 0, 8, 0.01, 'ember glow'], + ['colorEmber*', 'Ember colour'], + ['dripRate', 0, 300, 1, 'drip rate'], + ['dripSize', 0.005, 0.6, 0.005, 'drip size'], + ['dripSpeed', 0, 12, 0.05, 'drip speed'], + ['dripLifetime', 0.1, 6, 0.05, 'drip lifetime'], + ['dripGravity', -40, 0, 0.1, 'drip gravity'], + ['dripTurbulence', 0, 3, 0.01, 'drip turbulence'], + ['dripGlow', 0, 8, 0.01, 'drip glow'], + ['dripSeat', 0, 1.2, 0.01, 'drip seat ×span'], + ['colorDrip*', 'Drip colour'] + ], + 'Smoke': [ + ['smokeRate', 0, 400, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeRise', -2, 5, 0.01, 'smoke rise'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['colorSmoke*', 'Smoke colour'] + ], + 'Muzzle & mouth': [ + ['muzzleSize', 0.05, 8, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['impactFlash', 0, 2, 0.01, 'flash on arrival'], + ['mouthBurstSize', 0.2, 14, 0.05, 'mouth shell size'], + ['mouthBurstIntensity', 0, 5, 0.01, 'mouth shell intensity'], + ['mouthBurstRate', 0, 20, 0.1, 'mouth shells / sec'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.5, 0.005, 'sustain rumble'], + ['colorBurstA', 'shell body'], + ['colorBurstB', 'shell mid'], + ['colorBurstC', 'shell arcs'], + ['colorCastFlash', 'release flash colour'], + ['colorFlash', 'arrival flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 140, 0.5, 'light intensity'], + ['lightRadius', 0.5, 60, 0.1, 'light radius'], + ['lightSpan', 0, 1.4, 0.01, 'light along the cone'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/echostep.js b/src/config/abilities/echostep.js new file mode 100644 index 0000000..5dbfcb1 --- /dev/null +++ b/src/config/abilities/echostep.js @@ -0,0 +1,312 @@ +/* ================================================================== */ +/* ECHO STEP — chrono */ +/* ================================================================== */ +/** + * Three copies of the caster run the aimed line, each replaying the caster's + * own recorded motion further behind the present and each fainter. + * + * Nothing below is baked into a cast. The recording holds timestamps and the + * cast holds one dice roll; every delay, stride, metre of lift and step of + * erosion is resolved from this block on every frame, including a zero-length + * one. The controls worth reaching for first are `ghostDelay` (how far back in + * the recording the first echo is reading — drag it while paused and the three + * figures walk through the past), `ghostStride` (how strung out down the line + * they are) and `catchUp` (how hard the past closes on the present once the + * run is over). + * + * British spelling in the prose, `color` in the keys — the editor detects a + * colour picker by the key prefix, and this block is not the place to break + * that. + */ +export const echostep = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused + speed: 15.0, // how fast the run travels down the line, metres/second + lifetime: 0.85, // seconds the echoes hold at the far end + fadeTime: 1.15, // seconds they take to come apart + cooldown: 0.9, + castAnim: 'cast1', // the clip the real caster throws — and therefore the one every echo replays + + /* --- the recording --- */ + // `sampleRate` is how finely the caster's skeleton is written into the ring; + // `memory` is how much of it is kept. Both are live: the ring's *capacity* is + // a structural ceiling in the class (150 samples), the same way `MAX_STRANDS` + // is in Storm Lance, but how much of it is used is a slider. + sampleRate: 45, // pose samples per second + memory: 3.0, // seconds of the caster's past kept on the track + + /* --- the echoes --- */ + ghosts: 3, // how many copies run the line (capped at 4) + ghostDelay: 0.24, // seconds the first echo lags the caster + delayGrowth: 1.3, // × further back each echo after that reads + ghostStride: 2.6, // metres the first echo trails the front + strideGrowth: 1.18, // × further back each echo after that runs + ghostLift: 0.0, // metres the newest echo floats above the floor + ghostSink: 0.055, // metres each older echo sinks below that + ghostScale: 1.0, // × the caster's own size + scaleDecay: 0.05, // × smaller per echo — a shrinking copy reads as a receding one + ghostYaw: 0.09, // radians each echo is turned off the line of travel + weave: 0.14, // metres of lateral serpentine, × the echo's rank + weaveWaves: 1.4, // wavelengths of that serpentine over the whole line + catchUp: 0.85, // 0..1 how far the echoes close on the arrival point as they die + catchCurve: 2.2, // >1 holds them back and then pulls them in late + + /* --- what an echo looks like --- */ + // These drive `createGhostMaterial`, which bleaches the character's own skin + // by its luminance rather than tinting it — so the map's light and shade + // survive and the figure stays a person instead of a coloured blob. + colorGhost: '#d5be8c', // where the caster's skin was bright + colorGhostDeep: '#2f2a1e', // where it was dark + colorRim: '#f4e9cd', // the fresnel edge + bleach: 0.72, // 0..1 how far the skin is washed toward those two + ghostOpacity: 0.62, // master opacity of the newest echo + opacityDecay: 0.66, // × per echo — the decreasing-opacity half of the trick + facing: 0.42, // 0..1 how much thinner an echo is head-on than edge-on + rim: 1.5, // fresnel emission + rimPower: 2.8, // fresnel tightness + bandGlow: 0.3, // emission of the bands travelling down the body + bandScale: 4.2, // bands per metre of world height + bandSpeed: 0.8, // metres/second those bands travel + erode: 0.04, // 0..1 dissolve on the newest echo + erodeStep: 0.1, // + per echo — the oldest is the most eaten + erodeScale: 3.1, // dissolve noise features per metre + erodeEdge: 0.16, // 0..1 width of the burning edge on the dissolve + edgeGlow: 2.0, // emission on that edge + erodeOut: 0.9, // extra dissolve applied over the blow-out + + /* --- the floor's memory of the run --- */ + // A `GroundField` in RUT mode: a strip down the line whose depth follows the + // contact forces the footfalls posted. The marks are unitless, so the whole + // track re-cuts itself under a paused slider. + trackWidth: 0.34, // half-width of the track, metres + trackCanvas: 6.0, // × that width — how much quad is stood up to hold the spoil + trackDepth: 0.075, // metres the track is pressed into the floor + trackSpoil: 0.02, // metres of ridge thrown up either side + trackSpoilWidth: 0.09, // metres that ridge is spread over + trackSharp: 0.55, // 0..1 how squared-off the track's floor is + trackChatter: 0.62, // metres between the prints the run leaves + trackChatterDepth: 0.5, // 0..1 how deeply those prints read + trackWander: 0.16, // metres the track drifts off the line + trackWanderScale: 0.45, // wanders per metre + stepBlur: 0.55, // metres a single footfall's weight is felt over + trackEdge: 0.28, // metres of feather on the leading edge + trackHeight: 0.017, // metres above the floor the quad sits + trackRelief: 0.55, // how hard the height field tilts the fake normal + trackNormalStep: 0.05, // metres between the height taps + trackAmbient: 0.36, // floor on the diffuse term + trackWrap: 0.5, // 0..1 wraps the terminator round the back + trackSpecular: 0.3, + trackGloss: 20, // Blinn exponent + trackParallax: 0.22, // metres of view-driven offset on interior detail + trackEmissive: 0.85, // multiplier on the glow at the leading edge + trackOpacity: 0.8, + trackDepthFade: 0.4, // metres of soft fade against standing geometry + colorTrack: '#4a4438', // the disturbed floor itself + colorTrackEdge: '#b9ab8c', // the spoil ridges and the sheen + colorTrackGlow: '#e6c98e', // the amber at the leading edge + colorTrackDeep: '#181510', // the bottom of the print + + /* --- footfalls --- */ + steps: 11, // prints an echo leaves over the whole line + stepStagger: 0.31, // fraction of a step each echo's pattern is offset by + stepWeight: 0.9, // 0..1 contact force of the newest echo's step + weightDecay: 0.7, // × per echo — an older memory presses less hard + + /* --- amber shed by the echoes --- */ + moteRate: 70, // motes/second across all live echoes + moteSize: 0.05, + moteSpeed: 0.7, + moteLifetime: 2.2, + moteRise: 0.35, // upward drift, metres/second + moteTurbulence: 0.55, + moteHeight: 1.0, // metres above the floor they leave the body at + moteSpread: 0.42, // metres of scatter around it + moteOpacity: 0.75, + moteGlow: 0.8, + colorMoteA: '#fff3d8', + colorMoteB: '#e6c98e', + colorMoteC: '#a98a52', + colorMoteD: '#2a2116', + + /* --- dust lifted by a footfall --- */ + dustPerStep: 7, // particles per print, × its contact force + dustSize: 0.55, + dustSpeed: 0.8, + dustLifetime: 1.9, + dustRise: 0.28, // upward drift, metres/second + dustTurbulence: 0.4, + dustHeight: 0.07, // metres above the floor a print's dust starts + dustSpread: 0.3, // metres of scatter around the print + dustOpacity: 0.14, + colorDustA: '#8e8571', + colorDustB: '#6f6754', + colorDustC: '#4e4839', + colorDustD: '#2b2820', + + /* --- the arrival --- */ + arrivalSize: 1.9, // radius of the pressure shell at the far end, metres + arrivalIntensity: 1.0, + arrivalHeight: 0.95, // metres above the floor it opens at + arrivalShake: 0.22, + shakeDuration: 0.5, + arrivalFlash: 0.06, // screen flash — deliberately almost nothing + colorArrivalA: '#d5be8c', // the shell + colorArrivalB: '#f4e9cd', // its body + colorArrivalC: '#fffaf0', // the filaments racing over it + colorFlash: '#e8dcbc', + + /* --- dynamic light --- */ + lightIntensity: 9, + lightRadius: 11, + lightColor: '#e6c98e', + lightHeight: 1.1, // metres above the floor the light rides at + lightPulse: 0.25, // depth of the slow swell, 0 = steady + lightPulseSpeed: 2.4 // radians/second of that swell +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Echo Step. + * + * The folder to open first is **The echoes**. `ghostDelay` and `delayGrowth` + * decide *when* each figure is; `ghostStride` and `strideGrowth` decide where. + * Those two pairs are independent on purpose — an echo that is far back in the + * recording but close in space reads completely differently from one that is + * the reverse, and both are worth having. + */ +export const echostepSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 60, 0.5, 'run speed'], + ['lifetime', 0.05, 6, 0.01, 'hold at the end'], + ['fadeTime', 0.05, 5, 0.01, 'come-apart time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The recording': [ + ['sampleRate', 5, 120, 1, 'samples / second'], + ['memory', 0.25, 5, 0.05, 'seconds remembered'] + ], + 'The echoes': [ + ['ghosts', 1, 4, 1, 'echoes'], + ['ghostDelay', 0.02, 1.5, 0.01, 'first echo delay'], + ['delayGrowth', 1, 3, 0.01, 'delay growth'], + ['ghostStride', 0, 12, 0.05, 'first echo stride'], + ['strideGrowth', 0.5, 3, 0.01, 'stride growth'], + ['ghostLift', -0.5, 2, 0.01, 'lift'], + ['ghostSink', -0.3, 0.4, 0.005, 'sink per echo'], + ['ghostScale', 0.2, 2, 0.01, 'scale'], + ['scaleDecay', -0.2, 0.3, 0.005, 'scale decay'], + ['ghostYaw', -1, 1, 0.01, 'yaw fan'], + ['weave', 0, 1.5, 0.01, 'lateral weave'], + ['weaveWaves', 0, 6, 0.05, 'weave wavelengths'], + ['catchUp', 0, 1, 0.01, 'catch-up'], + ['catchCurve', 0.2, 6, 0.05, 'catch-up curve'] + ], + 'What an echo looks like': [ + ['colorGhost', 'skin, lit'], + ['colorGhostDeep', 'skin, shaded'], + ['colorRim', 'fresnel rim'], + ['bleach', 0, 1, 0.01, 'bleach'], + ['ghostOpacity', 0, 1, 0.01, 'opacity'], + ['opacityDecay', 0.1, 1, 0.01, 'opacity decay'], + ['facing', 0, 1, 0.01, 'facing falloff'], + ['rim', 0, 5, 0.01, 'rim glow'], + ['rimPower', 0.2, 8, 0.05, 'rim tightness'], + ['bandGlow', 0, 2, 0.01, 'band glow'], + ['bandScale', 0.2, 16, 0.1, 'bands / metre'], + ['bandSpeed', -4, 4, 0.01, 'band speed'] + ], + 'What an echo looks like/Coming apart': [ + ['erode', 0, 1, 0.01, 'dissolve'], + ['erodeStep', 0, 0.5, 0.005, 'dissolve per echo'], + ['erodeScale', 0.2, 12, 0.05, 'dissolve scale'], + ['erodeEdge', 0.01, 0.6, 0.005, 'burning edge'], + ['edgeGlow', 0, 6, 0.05, 'edge glow'], + ['erodeOut', 0, 1, 0.01, 'dissolve on blow-out'] + ], + 'The track': [ + ['trackWidth', 0.05, 2, 0.01, 'half-width'], + ['trackCanvas', 2, 14, 0.1, 'canvas × width'], + ['trackDepth', 0, 0.6, 0.005, 'depth'], + ['trackSpoil', 0, 0.3, 0.005, 'spoil height'], + ['trackSpoilWidth', 0.01, 0.5, 0.005, 'spoil width'], + ['trackSharp', 0, 1, 0.01, 'floor sharpness'], + ['trackChatter', 0.05, 3, 0.01, 'print pitch'], + ['trackChatterDepth', 0, 1, 0.01, 'print depth'], + ['trackWander', 0, 1.5, 0.01, 'wander'], + ['trackWanderScale', 0.05, 3, 0.01, 'wanders / metre'], + ['stepBlur', 0.05, 3, 0.01, 'footfall blur'], + ['trackEdge', 0.02, 2, 0.01, 'front feather'], + ['trackHeight', 0.002, 0.2, 0.001, 'height above floor'] + ], + 'The track/Lighting': [ + ['trackRelief', 0, 3, 0.01, 'relief'], + ['trackNormalStep', 0.005, 0.3, 0.005, 'normal step'], + ['trackAmbient', 0, 1, 0.01, 'ambient'], + ['trackWrap', 0, 1, 0.01, 'terminator wrap'], + ['trackSpecular', 0, 2, 0.01, 'specular'], + ['trackGloss', 1, 80, 1, 'gloss'], + ['trackParallax', 0, 1.5, 0.01, 'parallax'], + ['trackEmissive', 0, 4, 0.01, 'emissive'], + ['trackOpacity', 0, 2, 0.01, 'opacity'], + ['trackDepthFade', 0.02, 2, 0.01, 'soft intersection'], + ['colorTrack', 'track body'], + ['colorTrackEdge', 'spoil & sheen'], + ['colorTrackGlow', 'leading edge'], + ['colorTrackDeep', 'print interior'] + ], + Footfalls: [ + ['steps', 1, 40, 1, 'prints / line'], + ['stepStagger', 0, 1, 0.01, 'stagger per echo'], + ['stepWeight', 0, 1, 0.01, 'contact force'], + ['weightDecay', 0.1, 1, 0.01, 'force decay'], + ['dustPerStep', 0, 40, 1, 'dust / print'], + ['dustSize', 0.05, 3, 0.01, 'dust size'], + ['dustSpeed', 0, 6, 0.05, 'dust speed'], + ['dustLifetime', 0.1, 6, 0.05, 'dust lifetime'], + ['dustRise', -2, 3, 0.01, 'dust rise'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['dustHeight', 0, 1, 0.01, 'dust height'], + ['dustSpread', 0.02, 2, 0.01, 'dust spread'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['colorDust*', 'Dust colour'] + ], + 'Amber motes': [ + ['moteRate', 0, 400, 1, 'motes / second'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 8, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -2, 4, 0.01, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['moteHeight', 0, 2.5, 0.01, 'mote height'], + ['moteSpread', 0.02, 2, 0.01, 'mote spread'], + ['moteOpacity', 0, 2, 0.01, 'mote opacity'], + ['moteGlow', 0, 4, 0.01, 'mote glow'], + ['colorMote*', 'Mote colour'] + ], + 'The arrival': [ + ['arrivalSize', 0.1, 10, 0.05, 'shell size'], + ['arrivalIntensity', 0, 4, 0.01, 'shell intensity'], + ['arrivalHeight', 0, 3, 0.01, 'shell height'], + ['arrivalShake', 0, 2, 0.01, 'shake'], + ['shakeDuration', 0.1, 3, 0.01, 'shake duration'], + ['arrivalFlash', 0, 1, 0.005, 'screen flash'], + ['colorArrivalA', 'shell'], + ['colorArrivalB', 'shell body'], + ['colorArrivalC', 'shell filaments'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 3, 0.01, 'light height'], + ['lightPulse', 0, 1, 0.01, 'swell depth'], + ['lightPulseSpeed', 0.1, 12, 0.05, 'swell rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/eclipse.js b/src/config/abilities/eclipse.js new file mode 100644 index 0000000..dc47675 --- /dev/null +++ b/src/config/abilities/eclipse.js @@ -0,0 +1,275 @@ +import { shellDefaults, shellSchema, ShellMode } from '../../vfx/Shell.js'; + +/* ================================================================== */ +/* ECLIPSE — the light goes wrong before anything appears */ +/* ================================================================== */ +/** + * The far cast whose first beat has no geometry in it at all. + * + * This is the Thunderclap lesson applied to light. Thunderclap buys its bang by + * spending half a second *not* banging; Eclipse buys its disc by spending the + * whole travel phase making the world quietly wrong — colour draining out of + * the frame toward the umbra, the corners closing in, the key light cooling and + * dimming — with nothing on screen to explain why. Only then does the black disc + * open, and by then the frame has already told you something is coming. + * + * Two hooks from `vfx/SceneHooks.js` carry that first beat and neither of them + * draws anything: + * + * - **`Hook.GRADE`** — saturation, temperature, lift and vignette, blended + * from `settings.post` by a weight that is the anticipation curve itself. + * - **`Hook.KEY_LIGHT`** — `tint()` and `brightness()` only. `aim()` is never + * called, which is deliberate: an eclipse does not move the sun, it stands in + * front of it, so every shadow on the stage must stay exactly where it is + * while it goes cold and soft. Moving the light was the first version and it + * read as a second sunset rather than as an occultation. + * + * Three beats: + * + * | phase | what it is | how long | + * | --- | --- | --- | + * | travel | **the anticipation** — the grade drains and the key cools, nothing is drawn | `range / speed` | + * | impact | second contact and totality — the disc opens, the corona lights | `(openTime + holdTime) × global.lifetime` | + * | fade | third contact — the disc lets go and the world comes back | `closeTime` | + * + * The four controls worth reaching for first are `wrongCurve` (how late the + * anticipation commits — the single biggest lever on whether the slot lands), + * `anticipateWeight` (how wrong the world gets *before* the disc, which must + * stay below 1 or the disc has nothing left to add), `discRadiusEnd` (the size + * of the umbra, and the one radius that matters — the black disc is derived + * from the corona's live radius, not authored separately) and `beadAt`, which + * places Baily's beads in the opening. + * + * Nothing here is captured by a cast. A cast rolls one seed for the corona and + * the beads, and remembers whether second contact has fired; every metre, + * radian and second below is re-read on every frame, zero-length ones included. + */ +export const eclipse = { + /* --- the cast --- */ + range: 21.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 34.0, // deliberately slow: the travel phase is the anticipation + cooldown: 8.5, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 5.6, // the footprint — what the circle indicator measures out + + /* ------------------------------------------------------------------ */ + /* The beats */ + /* ------------------------------------------------------------------ */ + openTime: 0.85, // seconds the disc takes to open — second contact + holdTime: 2.2, // seconds of totality + closeTime: 1.3, // seconds the disc takes to let go — third contact + wrongCurve: 1.7, // exponent on the anticipation ramp; >1 holds off, then commits + + /* ------------------------------------------------------------------ */ + /* The anticipation — four numbers, no geometry */ + /* ------------------------------------------------------------------ */ + /** + * `anticipateWeight` is how far the grade is driven by the time the disc + * opens; the remainder is spent during the opening, so the world keeps going + * wrong right through second contact. At 1 the anticipation arrives at + * totality early and the disc adds nothing. + */ + gradeWeight: 1.0, // master on the whole grade hook, 0 = transparent + anticipateWeight: 0.55, // 0..1 of the drain spent before the disc opens + gradeSaturation: 0.15, // 0 is monochrome, 1 untouched + gradeTemper: -0.4, // + warm, − cool; matches post.temperature + gradeLift: -0.06, // black level, negative crushes toward the umbra + gradeVignette: 0.55, // how much darker the corners get, 0..1 + + keyWeight: 1.0, // master on the key-light hook, 0 = transparent + keyDim: 0.35, // key intensity at totality, in environment.sunIntensity units + colorCold: '#7f93c8', // what the key cools to — never warmer than the stage + + /* ------------------------------------------------------------------ */ + /* The disc — vfx/Shell.js in SUNDISC, carrying the corona only */ + /* ------------------------------------------------------------------ */ + /** + * The shell draws the corona and nothing else: `discFill`, `discGranule` and + * `discRim` are all zero, because a sun disc's own face is exactly the thing + * an eclipse does not have. The black middle is the umbra quad, which is not + * additive and therefore can be black — a `Shell` is additive and the darkest + * mark it can make is "nothing". + * + * `discRadiusEnd` is the ability's real radius. Keep it near `zoneRadius`, or + * the aim circle will measure out a footprint the disc does not fill. + */ + ...shellDefaults('disc', ShellMode.SUNDISC, { + discRadius: 0.35, // radius at t = 0, metres + discRadiusEnd: 5.6, // radius at totality, metres + discExpand: 3.2, // opening curve — fast, then easing into place + discHeight: 0.02, + discLift: 0.09, // metres above the floor the corona plane sits + discFill: 0.0, // no face: the umbra owns the middle + discGranule: 0.0, // ... and no convection cells on it + discRim: 0.0, // the fresnel rim is meaningless on a flat annulus + discCorona: 1.7, // brightness of the filaments licking off the rim + discCoronaReach: 2.1, // how far past the rim the shell is drawn, × radius + discCoronaLength: 0.85, // how far the filaments reach, × radius + discCoronaScale: 6.5, // filament features per radius + discCoronaWarp: 0.55, // domain warp — the thing that stops them being spokes + discCoronaSpeed: 0.5, // Hz they crawl at + discCoronaSharp: 0.66, // threshold: low fills the corona in and it reads as fog + discRimWidth: 0.1, // hot band just inside the rim, × radius + discDissolve: 0.35, + discGlow: 2.8, + discSoftFade: 0.4, + discColorBody: '#120e18', // barely there — the shell is additive + discColorRim: '#c8b8ff', // the pale ring at the limb + discColorEdge: '#ffffff', // the hottest mark it has + discColorCorona: '#e8dcff' // the filaments + }), + + /* ------------------------------------------------------------------ */ + /* The umbra — the only non-additive thing in the ability */ + /* ------------------------------------------------------------------ */ + umbraScale: 0.98, // × the corona's live radius — slightly inside its rim + umbraReach: 1.4, // how far past the rim the quad is drawn, × radius + umbraEdge: 0.05, // softness of the limb, fraction of the radius + umbraShade: 1.0, // how much of colorUmbra survives; 0 crushes the disc to absolute black + umbraOpacity: 0.96, // coverage over the floor + umbraLift: 0.045, // metres above the floor + rimGlow: 1.5, // brightness of the ring at the limb + rimWidth: 0.09, // its width, fraction of the radius + beadCount: 9.0, // Baily's beads around the limb + beadSize: 0.13, // angular half-width of one bead, fraction of its cell + beadWidth: 0.07, // radial half-width, fraction of the radius + beadSpin: 0.1, // turns/second the bead pattern rotates + beadAt: 0.88, // where in the opening the beads flash, 0..1 + beadWindow: 0.16, // how long that flash lasts, 0..1 of the opening + beadFlash: 2.0, // brightness of a bead at its peak + colorUmbra: '#05040a', // the disc itself + colorLimb: '#d8c8ff', // the ring at its edge + colorBead: '#fff4e0', // the beads + + /* ------------------------------------------------------------------ */ + /* The drain — dust drawn inward while the world goes wrong */ + /* ------------------------------------------------------------------ */ + drainRate: 55.0, // motes per second during the anticipation + drainRadius: 1.9, // where they start, × zoneRadius + drainSpeed: 2.4, // metres/second inward + drainSize: 0.06, // metres + drainLifetime: 1.9, // seconds + drainRise: 0.12, // metres/second of buoyancy + drainHeight: 1.6, // metres of air they are lifted out of + drainTurbulence: 0.35, // curl-noise strength + drainJitter: 0.5, // metres of slop on one emission puff + colorDrainA: '#b8c8e8', // birth — the colour the world still has + colorDrainB: '#8f8fb8', // early + colorDrainC: '#4a4260', // late + colorDrainD: '#0d0a14', // death — it arrives at the umbra as nothing + + /* ------------------------------------------------------------------ */ + /* The corona sparks — thrown off the limb at second contact */ + /* ------------------------------------------------------------------ */ + sparkBurst: 90.0, // how many go up on the bead flash + sparkRate: 14.0, // per second through totality + sparkSpeed: 3.4, // metres/second + sparkSize: 0.09, // metres + sparkLifetime: 1.1, // seconds + sparkStretch: 0.55, // velocity stretching + sparkGravity: -0.5, // metres/second² — they fall back into the disc + colorSparkA: '#ffffff', // birth + colorSparkB: '#e6d8ff', // early + colorSparkC: '#9a86d8', // late + colorSparkD: '#241c38', // death + + /* ------------------------------------------------------------------ */ + /* The light and the flash */ + /* ------------------------------------------------------------------ */ + lightColor: '#c9b6ff', // the corona's own glow on the floor + lightIntensity: 2.6, + lightRadius: 11.0, // metres + lightHeight: 0.9, // metres above the floor + lightTotality: 0.22, // × its intensity once the beads have gone + contactFlash: 0.5, // screen flash at second contact + colorFlash: '#efe4ff' +}; + +/** + * Editor layout. + * + * The `disc*` folders come from `shellSchema('disc', ShellMode.SUNDISC)`; the + * dome, cone and ring-train keys `shellDefaults` also brings in do nothing to a + * sun disc and are deliberately unfiled — they land in the trailing "More" + * folder, exactly as `thunderclap`'s spare shell keys do. + * + * The grade and key-light rows are the four numbers of the anticipation, and + * they are the ones to drag first with the simulation **paused**: they drive the + * scene rather than anything in this ability's own group, so the frame changes + * while nothing in the ability moves. + */ +export const eclipseSchema = { + 'The cast': ['range', 'minRange', 'speed', 'cooldown', 'castAnim', ['zoneRadius', 1, 14, 0.1, 'circle radius (m)']], + 'The beats': [ + ['openTime', 0.05, 5, 0.01, 'second contact (s)'], + ['holdTime', 0.1, 10, 0.05, 'totality (s)'], + ['closeTime', 0.1, 6, 0.05, 'third contact (s)'], + ['wrongCurve', 0.2, 5, 0.01, 'anticipation curve'] + ], + 'The anticipation': [ + ['gradeWeight', 0, 1, 0.01, 'grade taken'], + ['anticipateWeight', 0, 1, 0.01, 'drain before the disc'], + ['gradeSaturation', 0, 1.5, 0.01, 'saturation'], + ['gradeTemper', -1, 1, 0.01, 'temperature'], + ['gradeLift', -0.3, 0.3, 0.005, 'lift'], + ['gradeVignette', 0, 1.5, 0.01, 'vignette'], + ['keyWeight', 0, 1, 0.01, 'key light taken'], + ['keyDim', 0, 4, 0.01, 'key at totality'], + 'colorCold' + ], + ...shellSchema('disc', ShellMode.SUNDISC), + 'The umbra': [ + ['umbraScale', 0.4, 1.4, 0.01, '× corona radius'], + ['umbraReach', 1, 3, 0.01, 'drawn reach × radius'], + ['umbraEdge', 0.002, 0.4, 0.002, 'limb softness'], + ['umbraShade', 0, 1, 0.01, 'shade kept'], + ['umbraOpacity', 0, 1, 0.01, 'opacity'], + ['umbraLift', 0.005, 0.5, 0.005, 'height (m)'], + ['rimGlow', 0, 4, 0.01, 'limb ring'], + ['rimWidth', 0.005, 0.4, 0.005, 'limb width'], + 'colorUmbra', + 'colorLimb' + ], + "The umbra/Baily's beads": [ + ['beadCount', 1, 32, 1, 'beads'], + ['beadSize', 0.01, 0.5, 0.005, 'angular size'], + ['beadWidth', 0.005, 0.4, 0.005, 'radial size'], + ['beadSpin', -2, 2, 0.01, 'spin (turns/s)'], + ['beadAt', 0, 1, 0.01, 'when they flash'], + ['beadWindow', 0.02, 1, 0.01, 'how long'], + ['beadFlash', 0, 6, 0.01, 'brightness'], + 'colorBead' + ], + 'The drain': [ + ['drainRate', 0, 300, 1, 'motes/second'], + ['drainRadius', 0.5, 4, 0.05, 'start × zoneRadius'], + ['drainSpeed', 0, 12, 0.05, 'inward (m/s)'], + ['drainSize', 0.005, 0.4, 0.005, 'size (m)'], + ['drainLifetime', 0.2, 8, 0.05, 'life (s)'], + ['drainRise', -1, 2, 0.01, 'buoyancy (m/s)'], + ['drainHeight', 0.1, 8, 0.05, 'column (m)'], + ['drainTurbulence', 0, 3, 0.01, 'turbulence'], + ['drainJitter', 0.01, 3, 0.01, 'puff radius (m)'], + ['colorDrain*', 'Drain gradient'] + ], + 'The corona sparks': [ + ['sparkBurst', 0, 400, 1, 'burst count'], + ['sparkRate', 0, 200, 1, 'per second'], + ['sparkSpeed', 0, 20, 0.05, 'speed (m/s)'], + ['sparkSize', 0.005, 0.5, 0.005, 'size (m)'], + ['sparkLifetime', 0.1, 6, 0.05, 'life (s)'], + ['sparkStretch', 0, 3, 0.01, 'stretch'], + ['sparkGravity', -8, 8, 0.05, 'gravity (m/s²)'], + ['colorSpark*', 'Spark gradient'] + ], + 'The light': [ + 'lightColor', + ['lightIntensity', 0, 12, 0.05, 'intensity'], + ['lightRadius', 1, 40, 0.5, 'radius (m)'], + ['lightHeight', 0, 6, 0.05, 'height (m)'], + ['lightTotality', 0, 1, 0.01, 'totality multiplier'], + ['contactFlash', 0, 2, 0.01, 'contact flash'], + 'colorFlash' + ] +}; diff --git a/src/config/abilities/emberflock.js b/src/config/abilities/emberflock.js new file mode 100644 index 0000000..a6a3235 --- /dev/null +++ b/src/config/abilities/emberflock.js @@ -0,0 +1,392 @@ +/* ================================================================== */ +/* EMBERFLOCK — Emberflight */ +/* ================================================================== */ +/** + * Two dozen ember birds thrown down the aimed line. + * + * The whole flock is **one draw call and one closed-form function of time**. + * Nothing about a bird exists on the CPU — no velocity, no neighbour list, no + * history — so every number below reshapes a flock that is already in the air, + * with the clock stopped. `spacingSide` opens the formation out mid-flight; + * `lag` strings it out behind the lead; `bank` changes how hard the birds roll + * into their turns, and *that* one is the difference between a flock and a + * handful of sparks. + * + * The trails are a second instanced strip that evaluates the same flock + * function backwards in each bird's own clock, so a ribbon is threaded through + * its bird by construction rather than by a buffer that has to be kept in step. + * That is why `trailSpan` is measured in **seconds** and not in metres: the + * tail is not a length, it is how far back in time the ribbon is allowed to + * look. + * + * The one thing a cast captures is the flock seed, which shifts the separation + * lattice so two casts do not lay the same formation out. That is a dice roll, + * not a dimension. + */ +export const emberflock = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 17.0, // how fast the lead point runs the line, metres/second + lifetime: 1.0, // seconds the flock holds at the target after it arrives + fadeTime: 0.85, // seconds the last of it takes to go out + cooldown: 0.9, // seconds before the slot re-arms + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the flock gathers --- */ + // Measured from the caster's origin in the cast's own frame, the way the + // bolt's hand point is: birds leave a hand, not a pair of feet. + handForward: 0.7, // metres in front of the caster + handSide: 0.3, // metres to the side (+ follows `Ability#side`) + handHeight: 1.45, // metres above the floor + endHeight: 1.05, // metres above the floor where the line ends + leadRise: 1.9, // metres the lead lofts at mid-span + + /* --- the formation (the separation lattice) --- */ + /** + * `latticeX × latticeY × latticeZ` is the number of *distinct* slots a bird + * can claim. Ask for more birds than that and two of them share a cell, which + * is the one way the separation guarantee breaks — keep the product above + * `birds`. The third axis is not a distance, it is the **lag**: latticeZ is + * how many ranks are strung out behind the lead and `lag` is how far back in + * seconds the last of them sits. + */ + birds: 24, // live agents (capped at 48) + latticeX: 6, // cells across + latticeY: 3, // cells up + latticeZ: 6, // ranks strung out behind the lead + spacingSide: 0.72, // metres between lateral cells + spacingUp: 0.46, // metres between vertical cells + lag: 0.42, // seconds the back rank trails the lead by + jitter: 0.18, // metres of per-bird slop off its own cell + churn: 0.85, // radians/second the whole formation rolls + breathe: 0.24, // fraction the formation swells by + breatheRate: 1.9, // radians/second of that swell + wander: 0.26, // metres of curl-noise drift — keep under half the spacing + wanderScale: 0.55, // features per metre of that drift + wanderSpeed: 0.65, // how fast the drift field moves + gather: 1.0, // 0 collapses every bird onto the lead's own path + + /* --- the bird --- */ + size: 0.34, // metres, nose to tail + aspect: 1.65, // wingspan / length + sizeJitter: 0.34, // ±fraction of size + sweep: 0.9, // how far the wings rake back + dihedral: 0.42, // wing fold out of the card's plane, fraction of size + wingCurl: 0.07, // static camber across the wing, fraction of size + flapRate: 7.5, // wing-beats/second (each bird runs on its own phase) + bank: 0.075, // radians of roll per m/s² of lateral acceleration + bankMax: 1.15, // radians — the hard ceiling on that roll + billboard: 0.12, // 0 the bird's own frame, 1 camera-facing + edgeStretch: 1.7, // how much an edge-on bird grows so it stays a line + edgeGain: 2.2, // how much brighter it gets while it is edge-on + lit: 0.3, // 0 pure emissive, 1 wrapped diffuse + revealTime: 0.22, // seconds the flock takes to appear at the hand + revealSpread: 0.4, // 0..1 width of that appearance wave + + /* --- the bird's colour --- */ + colorBirdA: '#fff0c8', // birth end of the gradient + colorBirdB: '#ffd27a', + colorBirdC: '#ff6a1f', + colorBirdD: '#3d0d04', // death end + tint: 0.22, // where in that gradient the flock sits + tintJitter: 0.3, // ±per-bird walk along it + tintAlong: 0.4, // extra walk from nose to tail + opacity: 1.0, + glow: 1.7, // emissive gain on the birds + softFade: 0.3, // metres of soft fade where a bird meets geometry + + /* --- the trails --- */ + trailSpan: 0.38, // SECONDS of flight the tail reaches back over + trailWidth: 0.1, // metres, half-width at the bird + trailTaper: 1.6, // >1 sharpens the tail to a point + trailLift: 0.42, // metres/second the tail floats above the flown path + trailSag: 0.55, // metres/second² it then slumps back down + trailWobble: 0.55, // metres of curl drift per second of tail age + trailWobbleScale: 0.7, // features per metre of that drift + trailWobbleSpeed: 0.6, // how fast the drift field moves + trailCore: 1.7, // how tightly light crowds the ribbon's centre line + trailHeadBias: 0.5, // >0 keeps the brightness near the bird + trailOpacity: 0.85, + trailGlow: 1.5, + trailSoftFade: 0.4, // metres of soft fade against solid geometry + trailTint: 0.12, // where in the trail gradient the head sits + trailTintAlong: 0.72, // how far down it the tail walks + trailTintJitter: 0.18, // ±per-bird walk along it + trailPersist: 0.25, // SECONDS the trails outlive the birds + trailFadeTime: 0.4, // seconds they then take to go out + colorTrailA: '#ffe6b0', + colorTrailB: '#ff9a3c', + colorTrailC: '#c02c06', + colorTrailD: '#2a0803', + + /* --- the collapse and the column --- */ + /** + * The last beat. On arrival the formation squeezes onto its own lead point + * over `collapseTime`, and then the point itself lifts by `columnHeight` over + * `columnTime` while the lattice's vertical spacing opens back out — a wide + * flock becomes a narrow chimney of birds going up. + * + * `columnTrailSpan` is shortened deliberately. A long tail on a bird that is + * climbing straight up draws a rope, not a rising ember; cutting the span to + * a fifth of a second turns the ribbons into flecks and the column reads. + */ + collapseTime: 0.22, // seconds the squeeze onto the point takes + collapseTighten: 0.85, // 0..1 how much of the slop the squeeze removes + columnTime: 0.42, // seconds the column takes to rise + columnHeight: 4.6, // metres it rises by + columnRadius: 0.16, // metres between lateral cells once collapsed + columnSpacing: 0.62, // metres between vertical cells once collapsed + columnLag: 0.16, // seconds the back rank trails by once collapsed + columnChurn: 2.6, // radians/second the column twists at + columnTrailSpan: 0.16, // seconds of tail while the column climbs + birdFade: 0.25, // seconds the birds take to wink out at the top + + /* --- embers, sparks and smoke --- */ + /** + * As everywhere else in this project, each system is coloured by a four-stop + * gradient sampled over the particle's own lifetime, `A` at birth through `D` + * as it dies. Spelled out rather than derived from the bird palette, so the + * smoke can be made cold while the flock stays gold. + */ + emberRate: 260, // embers shed by the flock, particles/second + emberSize: 0.1, + emberSpeed: 1.6, // metres/second off the birds + emberLifetime: 1.5, // seconds + emberRise: 1.4, // buoyancy, metres/second + emberTurbulence: 0.85, + colorEmberA: '#fff2d0', + colorEmberB: '#ffb14a', + colorEmberC: '#ff5a12', + colorEmberD: '#3d0d04', + sparkRate: 130, // hard bright streaks off the wings, particles/second + sparkSize: 0.11, + sparkSpeed: 5.5, + sparkLifetime: 0.6, + sparkGravity: -9.5, // metres/second² + sparkStretch: 0.24, // how far a spark smears along its velocity + colorSparkA: '#ffffff', + colorSparkB: '#ffe08a', + colorSparkC: '#ff7a1f', + colorSparkD: '#5a1604', + smokeRate: 42, // thin soot left hanging on the line, particles/second + smokeSize: 0.9, + smokeSpeed: 0.9, + smokeLifetime: 2.4, + smokeRise: 0.7, + smokeOpacity: 0.09, + colorSmokeA: '#4a3a30', + colorSmokeB: '#3a2c24', + colorSmokeC: '#2a201a', + colorSmokeD: '#181210', + + /* --- what the ground gets --- */ + scorchRate: 0.55, // char marks laid per metre of lead travel + scorchRadius: 0.55, // radius of one mark, metres + scorchLife: 5.0, // seconds a mark lingers + scorchIntensity: 0.5, + colorScorch: '#140a06', // the burn itself + colorCharEmber: '#ff6a1f', // the embers still cooling in it + + /* --- the release and the arrival --- */ + gatherSparks: 26, // sparks thrown as the flock forms at the hand + castFlash: 0.08, // screen flash on release + colorCastFlash: '#ffd27a', + burstSize: 2.2, // the fireball where the flock collapses, metres + burstIntensity: 1.5, + burstSparks: 150, // extra sparks thrown at the collapse + burstEmbers: 120, + columnEmbers: 320, // embers/second fed into the rising column + columnSpeed: 6.5, // how fast those embers climb, metres/second + shockRadius: 4.5, // ring snapped across the floor on arrival, metres + colorShockA: '#ffd27a', // body of that ring + colorShockB: '#fff4d8', // its crest + colorBurstA: '#ff6a1f', // burst shell + colorBurstB: '#ffb14a', // burst body + colorBurstC: '#fff0c8', // the filaments racing over it + colorFlash: '#ffb14a', // the full-screen flash on arrival + impactShake: 0.42, + shakeDuration: 0.5, // seconds that shake decays over + impactFlash: 0.16, + rumble: 0.012, // continuous shake while the flock is downrange + + /* --- dynamic light --- */ + lightIntensity: 17, // the flock carries its own light + lightRadius: 13, + lightColor: '#ff9a3c', + lightFlicker: 0.26, // depth of the flame gutter, 0 = steady + lightFlickerSpeed: 13 // gutters/second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Emberflight. + * + * The four controls that carry the character, in the order worth reaching for: + * **`bank`** (the roll out of a turn — drop it to zero and the flock instantly + * reads as sparks), **`lag`** and **`latticeZ`** (how strung out the skein is), + * **`churn`** (how much the formation rolls, which is what generates the + * lateral acceleration the bank feeds on) and **`trailSpan`** (how much of the + * screen the flock's history occupies). + * + * `wander` has a trap in it: past about half of `spacingSide` the curl drift is + * bigger than a lattice cell and birds start passing through one another, at + * which point the whole thing reads as smoke with wings. The slider's range + * lets you go there; the default does not. + */ +export const emberflockSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 80, 0.5, 'flock speed'], + ['lifetime', 0.1, 4, 0.01, 'hold at target'], + ['fadeTime', 0.05, 4, 0.01, 'fade time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where they gather': [ + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['handHeight', 0, 3, 0.01, 'hand height'], + ['endHeight', 0, 5, 0.01, 'height at target'], + ['leadRise', -2, 8, 0.05, 'mid-span loft'] + ], + 'The formation': [ + ['birds', 1, 48, 1, 'birds'], + ['latticeX', 1, 16, 1, 'cells across'], + ['latticeY', 1, 10, 1, 'cells up'], + ['latticeZ', 1, 24, 1, 'ranks back'], + ['spacingSide', 0.02, 3, 0.01, 'lateral spacing'], + ['spacingUp', 0.02, 3, 0.01, 'vertical spacing'], + ['lag', 0, 2.5, 0.01, 'rank lag'], + ['jitter', 0, 1.5, 0.01, 'cell slop'], + ['churn', -6, 6, 0.01, 'formation roll'], + ['breathe', 0, 1.5, 0.01, 'swell'], + ['breatheRate', 0, 8, 0.05, 'swell rate'], + ['wander', 0, 1.5, 0.01, 'curl drift'], + ['wanderScale', 0.05, 3, 0.01, 'drift features / m'], + ['wanderSpeed', 0, 4, 0.01, 'drift speed'], + ['gather', 0, 1, 0.01, 'collapse onto lead'] + ], + 'The bird': [ + ['size', 0.02, 2, 0.01, 'size'], + ['aspect', 0.3, 4, 0.01, 'span / length'], + ['sizeJitter', 0, 1, 0.01, 'size jitter'], + ['sweep', 0, 2, 0.01, 'wing rake'], + ['dihedral', 0, 1.5, 0.01, 'wing fold'], + ['wingCurl', -1, 1, 0.01, 'wing camber'], + ['flapRate', 0, 20, 0.1, 'wing-beats / sec'], + ['bank', 0, 0.5, 0.001, 'bank per m/s²'], + ['bankMax', 0, 2, 0.01, 'max bank'], + ['billboard', 0, 1, 0.01, 'camera facing'], + ['edgeStretch', 1, 5, 0.01, 'edge-on stretch'], + ['edgeGain', 0, 6, 0.01, 'edge-on gain'], + ['lit', 0, 1, 0.01, 'diffuse mix'], + ['revealTime', 0.01, 2, 0.01, 'gather time'], + ['revealSpread', 0.01, 1, 0.01, 'gather spread'] + ], + 'Bird colour': [ + ['colorBird*', 'Bird gradient'], + ['tint', 0, 1, 0.01, 'gradient position'], + ['tintJitter', 0, 1, 0.01, 'per-bird walk'], + ['tintAlong', 0, 1, 0.01, 'nose-to-tail walk'], + ['opacity', 0, 2, 0.01, 'opacity'], + ['glow', 0, 6, 0.01, 'glow'], + ['softFade', 0.02, 2, 0.01, 'soft intersection'] + ], + 'The trails': [ + ['trailSpan', 0.02, 2, 0.01, 'tail length (seconds)'], + ['trailWidth', 0.005, 0.6, 0.005, 'tail width'], + ['trailTaper', 0.1, 5, 0.01, 'tail taper'], + ['trailLift', -2, 4, 0.01, 'tail lift'], + ['trailSag', -2, 4, 0.01, 'tail sag'], + ['trailWobble', 0, 3, 0.01, 'tail drift'], + ['trailWobbleScale', 0.05, 3, 0.01, 'drift features / m'], + ['trailWobbleSpeed', 0, 4, 0.01, 'drift speed'], + ['trailCore', 0.1, 6, 0.01, 'core tightness'], + ['trailHeadBias', 0, 1, 0.01, 'head bias'], + ['trailOpacity', 0, 2, 0.01, 'opacity'], + ['trailGlow', 0, 6, 0.01, 'glow'], + ['trailSoftFade', 0.02, 2, 0.01, 'soft intersection'], + ['trailTint', 0, 1, 0.01, 'gradient position'], + ['trailTintAlong', 0, 1, 0.01, 'head-to-tail walk'], + ['trailTintJitter', 0, 1, 0.01, 'per-bird walk'], + ['trailPersist', 0, 2, 0.01, 'outlive the birds'], + ['trailFadeTime', 0.05, 3, 0.01, 'trail fade'], + ['colorTrail*', 'Trail gradient'] + ], + 'The collapse & column': [ + ['collapseTime', 0.05, 2, 0.01, 'collapse time'], + ['collapseTighten', 0, 1, 0.01, 'slop removed'], + ['columnTime', 0.05, 3, 0.01, 'rise time'], + ['columnHeight', 0, 20, 0.1, 'rise height'], + ['columnRadius', 0.01, 1.5, 0.01, 'column lateral spacing'], + ['columnSpacing', 0.02, 3, 0.01, 'column vertical spacing'], + ['columnLag', 0, 1.5, 0.01, 'column rank lag'], + ['columnChurn', -8, 8, 0.01, 'column twist'], + ['columnTrailSpan', 0.02, 1, 0.01, 'column tail (seconds)'], + ['birdFade', 0.05, 2, 0.01, 'birds wink out over'], + ['columnEmbers', 0, 900, 1, 'column embers / sec'], + ['columnSpeed', 0, 20, 0.1, 'column ember speed'] + ], + 'Embers & sparks': [ + ['emberRate', 0, 900, 1, 'ember rate'], + ['emberSize', 0.005, 0.5, 0.005, 'ember size'], + ['emberSpeed', 0, 12, 0.05, 'ember speed'], + ['emberLifetime', 0.1, 6, 0.05, 'ember lifetime'], + ['emberRise', -2, 8, 0.05, 'ember rise'], + ['emberTurbulence', 0, 3, 0.01, 'ember turbulence'], + ['sparkRate', 0, 900, 1, 'spark rate'], + ['sparkSize', 0.005, 0.5, 0.005, 'spark size'], + ['sparkSpeed', 0, 30, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 3, 0.01, 'spark lifetime'], + ['sparkGravity', -40, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['colorEmber*', 'Ember colour'], + ['colorSpark*', 'Spark colour'] + ], + 'Smoke & ground': [ + ['smokeRate', 0, 400, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['colorSmoke*', 'Smoke colour'], + ['scorchRate', 0.02, 4, 0.02, 'char marks / metre'], + ['scorchRadius', 0.05, 4, 0.05, 'char radius'], + ['scorchLife', 0.5, 20, 0.1, 'char lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'char intensity'], + ['colorScorch', 'char'], + ['colorCharEmber', 'char embers'] + ], + 'Release & arrival': [ + ['gatherSparks', 0, 200, 1, 'sparks on release'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 10, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstSparks', 0, 600, 1, 'burst sparks'], + ['burstEmbers', 0, 600, 1, 'burst embers'], + ['shockRadius', 0.5, 20, 0.1, 'shockwave radius'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst filaments'], + ['colorFlash', 'arrival flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightFlicker', 0, 1, 0.01, 'light gutter'], + ['lightFlickerSpeed', 1, 60, 1, 'gutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/entropy.js b/src/config/abilities/entropy.js new file mode 100644 index 0000000..054c00d --- /dev/null +++ b/src/config/abilities/entropy.js @@ -0,0 +1,321 @@ +/* ================================================================== */ +/* ENTROPY WAVE — one number ages a material */ +/* ================================================================== */ +/** + * A far cast, and the quietest thing in the sandbox. Almost nothing is drawn: + * a ring of refraction runs out across the floor, three thin particle systems + * shed off the ground behind it, and the **real** work is a single 0..1 handed + * to `Hook.AGE`, which the floor's own `MeshStandardMaterial` reads and turns + * into rust, dust, moss, pitting and bleaching. + * + * ### One field, five substances + * + * `wearRust` … `wearBleach` are five independent weights on one field, exactly + * as invariant I5 asks, and they are not five tints. Each one moves a different + * channel of the surface — rust takes metalness *up*, moss and dust take it + * down, pitting darkens and roughens, bleaching desaturates and lifts — which + * is the difference between a material that has aged and a decal that has been + * laid on top of one. The maths is in `vfx/SceneHooks.js`; what lives here is + * the mix. + * + * The five `*Onset` fractions are the only thing this block adds to that. They + * stagger the terms along the zone's own age so the sequence is legible: the + * stone pits and dusts almost at once, rust needs a moment, and moss is last + * because moss is the slowest thing that happens to a floor. Set all five to + * zero and the zone ages in one step, which looks like a filter. + * + * ### The sweep is one number too + * + * `lead` is the leading edge as a fraction of `fieldSpread × zoneRadius`. It + * runs 0 → 1 while the wave crosses the floor, holds, and comes **back** to 0. + * The same number places the refraction ring, seeds the particles and sets the + * age token's radius, so the shimmer, the flakes and the rust cannot disagree + * about where the front is. `retreatTrail` opens the token's inner cut as the + * disc collapses, which is what gives the retreat a trailing edge instead of + * making it a blob that shrinks. + * + * ### Restore is exact, and it is free + * + * The ability never puts anything back. `Ability#borrow()` hands the token to + * the ledger, the ledger's `_restore(Hook.AGE)` writes the documented neutral + * on release, and the floor is bit-for-bit the material it was — on a normal + * finish, on **C**, on a fifth cast pushing this one off the concurrency cap, + * and on teardown. That is the whole reason `SceneHooks` is a ledger rather + * than a pair of functions, and this is the ability it was written for. + */ +export const entropy = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 40.0, // how fast the seed runs out to the circle, metres/second + zoneRadius: 5.5, // the footprint the circle indicator draws, metres + cooldown: 1.8, // seconds + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the cast leaves the caster --- */ + handForward: 0.5, // metres in front of the caster the seed leaves + handHeight: 1.22, // metres above the floor + + /* --- the three beats --- */ + // impactDuration = spreadTime + holdTime; fadeDuration = retreatTime. There + // is no fourth "settle" window: the restore *is* the retreat, and giving it a + // tail of its own only ever produced a second of nothing at the end. + spreadTime: 1.25, // seconds the wave takes to cross the circle + holdTime: 0.85, // seconds the whole zone stands aged + retreatTime: 1.6, // seconds the decay takes to come back in + restoreHold: 0.55, // 0..1 of the retreat the wear holds at full before it washes out + + /* --- the field --- */ + fieldSpread: 1.0, // outer radius of the sweep, × zoneRadius + fieldEdge: 0.4, // softness of both edges, as a fraction of the radius + // Deliberately short of 1. The hook saturates this, so a default sitting on + // the ceiling is a control that can only ever come down — and the harness + // duly reports it as a dead slider, which is exactly what it is. + fieldAmount: 0.92, // peak strength of the whole field, 0..1 + fieldGrain: 1.5, // metres — the feature size of the ageing patches + spreadCurve: 0.75, // < 1 makes the front leave fast and arrive slowly + retreatCurve: 1.35, // > 1 makes it hesitate before it lets go + retreatTrail: 0.72, // how hollow the collapsing disc is, 0..1 + seedReach: 0.12, // how far the field has come while the cast is still travelling + + /* --- the five substances --- */ + wearRust: 0.85, // toward `colorRust`, patchy, and the only term that adds metal + wearDust: 0.55, // an even settling that flattens everything under it + wearMoss: 0.7, // in the low-frequency hollows, matte, kills metalness + wearPit: 0.62, // darkened specks; the stone itself going + wearBleach: 0.4, // desaturates and lifts whatever survived the other four + rustOnset: 0.18, // 0..1 of the zone's age before this term starts + dustOnset: 0.04, + mossOnset: 0.45, // last, because moss is the slowest thing that happens to a floor + pitOnset: 0.0, + bleachOnset: 0.6, + colorRust: '#7a3b1c', // iron oxide + colorDust: '#8a8375', // settled grey + colorMoss: '#3d5a20', // the green in the hollows + + /* --- the front: a ring of refraction lying on the floor --- */ + // Magnitudes are SCREEN FRACTIONS, not metres, and the post pass applies + // `post.distortion × global.distortion` on top — never multiply either in + // here. `frontDepthReject` ships at 0 for the reason Singularity's lens does: + // this quad lies *on* the surface it is bending, so occlusion-testing it + // against that surface throws the entire ring away and the pass looks broken. + frontStrength: 0.22, // screen widths of offset at post.distortion = 1 + frontThickness: 0.85, // metres — how thick the wavefront is + frontCompression: 1.15, // the leading half of the front + frontRarefaction: 0.8, // the trailing half + frontRings: 2, // 1..4 concentric fronts + frontRingGap: 1.4, // metres between them + frontRingDecay: 0.62, // how much weaker each ring behind the first is + frontWindow: 0.9, // 0..1 of the radius the effect is windowed into + frontMaxOffset: 1.2, // hard clamp on the offset, screen widths + frontOpacity: 1.0, + frontFalloff: 0.35, // × strength while the front is parked at full spread + frontLift: 0.05, // metres the quad floats above the floor + frontDepthReject: 0.0, // see the note above + frontDepthFade: 0.5, // metres over which standing geometry cuts the ring + frontPerspective: 0.4, // 0 = a fixed screen fraction, 1 = shrinks with distance + frontPerspectiveRef: 16.0, // metres at which perspective = 1 + + /* --- what comes off the floor --- */ + // All three are seeded by rejection against `sceneHooks.ageAt()`, so they can + // only be born where the published field says the ground has actually aged. + ageBias: 0.12, // minimum field strength at a point before it can shed anything + + /* --- the fine dust lifting off the pitting --- */ + moteRate: 90, // particles/second at full field + moteSize: 0.045, + moteSpeed: 0.4, // metres/second + moteLifetime: 2.4, // seconds + moteRise: 0.55, // metres/second² of lift + moteOpacity: 0.5, + moteTurbulence: 0.8, + colorMoteA: '#b8ab95', + colorMoteB: '#8a8375', + colorMoteC: '#575046', + colorMoteD: '#26221d', + + /* --- rust flakes peeling and blowing --- */ + flakeRate: 34, // particles/second at full field + flakeSize: 0.05, + flakeSpeed: 1.6, // metres/second + flakeLifetime: 1.9, // seconds + flakeGravity: -5.5, // metres/second² — flakes are light, they do not drop like chips + flakeSpin: 7.5, // radians/second of tumble + colorFlakeA: '#b85a26', + colorFlakeB: '#7a3b1c', + colorFlakeC: '#4a2513', + colorFlakeD: '#241409', + + /* --- moss spores, once the moss has arrived --- */ + sporeRate: 26, // particles/second at full field + sporeSize: 0.06, + sporeSpeed: 0.55, // metres/second + sporeLifetime: 3.2, // seconds + sporeRise: 0.35, // metres/second² of lift + sporeGlow: 0.6, // barely — this school does not glow, it rots + sporeTurbulence: 1.0, + colorSporeA: '#8fae5c', + colorSporeB: '#5c7a34', + colorSporeC: '#3d5a20', + colorSporeD: '#16220c', + + /* --- the punctuation --- */ + castBurstSize: 1.6, // the puff at the caster's hand, metres + castBurstIntensity: 0.9, + castFlash: 0.04, // screen flash on release — small; nothing here is bright + peakRingRadius: 7.5, // metres — the ring at the moment the wave reaches the rim + peakRingIntensity: 0.7, + restoreFlash: 0.05, // screen flash as the floor comes back + impactShake: 0.22, // the wave landing + shakeDuration: 0.9, // seconds + rumble: 0.018, // continuous shake while the zone is aged + colorCastA: '#4a3f30', + colorCastB: '#8a8375', + colorCastC: '#b8ab95', + colorCastFlash: '#8a8375', + colorRestoreFlash: '#cdc3ae', + colorRingA: '#4a2513', // body of the floor ring + colorRingB: '#b8ab95', // its crest + + /* --- dynamic light --- */ + // A light that *removes* light is not a thing a renderer will sell you, so + // this one is under-driven and drains further as the field peaks; the floor's + // own darkening does the rest of the work. + lightIntensity: 7, // deliberately low + lightRadius: 13, // metres + lightDrain: 0.55, // × intensity removed at full field + lightColor: '#9c8f74' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Entropy Wave. + * + * Everything worth reaching for is in "The five substances", and the one to + * drag first is `wearRust` — it is the only term that moves metalness, and + * metalness is what makes an aged patch read as a different *substance* rather + * than a different colour. Take it to zero with the other four at full and the + * whole zone collapses back into a stain; that comparison is the fastest way to + * see what this ability actually is. + * + * After that, `fieldGrain`. The five terms are sampled at three deliberately + * incommensurate multiples of it, so it is the size of the story: at 0.4 m the + * floor looks corroded, at 4 m it looks like weather. + * + * `retreatTrail` is the retreat's only real control. At 0 the aged disc shrinks + * as a blob; at 0.7 it collapses as a ring, which is the only version that + * reads as a wave going back where it came from. + */ +export const entropySchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'travel speed'], + ['zoneRadius', 1, 16, 0.1, 'footprint radius'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handHeight', 0, 3, 0.01, 'hand height'] + ], + 'The beats': [ + ['spreadTime', 0.1, 6, 0.01, 'spread time'], + ['holdTime', 0, 6, 0.01, 'hold time'], + ['retreatTime', 0.1, 8, 0.01, 'retreat time'], + ['restoreHold', 0, 0.95, 0.01, 'wear holds through'] + ], + 'The sweep': [ + ['fieldSpread', 0.1, 3, 0.01, 'radius ×R'], + ['fieldEdge', 0.01, 1, 0.01, 'edge softness'], + ['fieldAmount', 0, 1, 0.01, 'peak strength'], + ['fieldGrain', 0.1, 6, 0.05, 'patch size (m)'], + ['spreadCurve', 0.2, 4, 0.01, 'spread curve'], + ['retreatCurve', 0.2, 4, 0.01, 'retreat curve'], + ['retreatTrail', 0, 0.99, 0.01, 'retreat hollow'], + ['seedReach', 0, 1, 0.01, 'field while travelling'] + ], + 'The five substances': [ + ['wearRust', 0, 1, 0.01, 'rust'], + ['wearDust', 0, 1, 0.01, 'dust'], + ['wearMoss', 0, 1, 0.01, 'moss'], + ['wearPit', 0, 1, 0.01, 'pitting'], + ['wearBleach', 0, 1, 0.01, 'bleaching'], + ['rustOnset', 0, 1, 0.01, 'rust onset'], + ['dustOnset', 0, 1, 0.01, 'dust onset'], + ['mossOnset', 0, 1, 0.01, 'moss onset'], + ['pitOnset', 0, 1, 0.01, 'pit onset'], + ['bleachOnset', 0, 1, 0.01, 'bleach onset'], + ['colorRust', 'rust'], + ['colorDust', 'dust'], + ['colorMoss', 'moss'] + ], + 'The front': [ + ['frontStrength', 0, 1.5, 0.01, 'strength (screen widths)'], + ['frontThickness', 0.05, 4, 0.01, 'front thickness (m)'], + ['frontCompression', 0, 3, 0.01, 'compression'], + ['frontRarefaction', 0, 3, 0.01, 'rarefaction'], + ['frontRings', 1, 4, 1, 'rings'], + ['frontRingGap', 0.1, 6, 0.05, 'ring gap (m)'], + ['frontRingDecay', 0, 1, 0.01, 'ring decay'], + ['frontWindow', 0.05, 1, 0.01, 'window'], + ['frontMaxOffset', 0.1, 4, 0.01, 'max offset'], + ['frontOpacity', 0, 1, 0.01, 'opacity'], + ['frontFalloff', 0, 1, 0.01, 'strength while parked'], + ['frontLift', 0, 1, 0.005, 'float above floor (m)'], + ['frontDepthReject', 0, 1, 0.01, 'occlusion strength'], + ['frontDepthFade', 0.05, 3, 0.01, 'occlusion fade (m)'], + ['frontPerspective', 0, 1, 0.01, 'perspective'], + ['frontPerspectiveRef', 1, 40, 0.5, 'perspective ref (m)'] + ], + 'What comes off the floor': [ + ['ageBias', 0, 0.9, 0.01, 'minimum age to shed'], + ['moteRate', 0, 500, 1, 'dust rate'], + ['moteSize', 0.005, 0.4, 0.005, 'dust size'], + ['moteSpeed', 0, 6, 0.05, 'dust speed'], + ['moteLifetime', 0.1, 8, 0.05, 'dust lifetime'], + ['moteRise', -3, 5, 0.01, 'dust lift'], + ['moteOpacity', 0, 1, 0.01, 'dust opacity'], + ['moteTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['flakeRate', 0, 300, 1, 'flake rate'], + ['flakeSize', 0.005, 0.4, 0.005, 'flake size'], + ['flakeSpeed', 0, 10, 0.05, 'flake speed'], + ['flakeLifetime', 0.1, 6, 0.05, 'flake lifetime'], + ['flakeGravity', -25, 2, 0.1, 'flake gravity'], + ['flakeSpin', 0, 20, 0.1, 'flake tumble'], + ['sporeRate', 0, 300, 1, 'spore rate'], + ['sporeSize', 0.005, 0.4, 0.005, 'spore size'], + ['sporeSpeed', 0, 6, 0.05, 'spore speed'], + ['sporeLifetime', 0.1, 8, 0.05, 'spore lifetime'], + ['sporeRise', -3, 5, 0.01, 'spore lift'], + ['sporeGlow', 0, 4, 0.01, 'spore glow'], + ['sporeTurbulence', 0, 3, 0.01, 'spore turbulence'], + ['colorMote*', 'Dust colour'], + ['colorFlake*', 'Flake colour'], + ['colorSpore*', 'Spore colour'] + ], + 'Punctuation': [ + ['castBurstSize', 0.1, 6, 0.05, 'cast puff (m)'], + ['castBurstIntensity', 0, 3, 0.01, 'cast intensity'], + ['castFlash', 0, 1, 0.01, 'cast flash'], + ['peakRingRadius', 0.5, 24, 0.1, 'peak ring (m)'], + ['peakRingIntensity', 0, 3, 0.01, 'peak ring intensity'], + ['restoreFlash', 0, 1, 0.01, 'restore flash'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.5, 0.005, 'running rumble'], + ['colorCastA', 'cast shell'], + ['colorCastB', 'cast body'], + ['colorCastC', 'cast crest'], + ['colorCastFlash', 'cast flash colour'], + ['colorRestoreFlash', 'restore flash colour'], + ['colorRingA', 'floor ring'], + ['colorRingB', 'floor ring crest'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightDrain', 0, 1, 0.01, 'drain at full field'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/featherfall.js b/src/config/abilities/featherfall.js new file mode 100644 index 0000000..af40547 --- /dev/null +++ b/src/config/abilities/featherfall.js @@ -0,0 +1,269 @@ +/* ================================================================== */ +/* FEATHERFALL — two hundred feathers, and two ways to fall */ +/* ================================================================== */ +/** + * A slow rain of feathers over the circle. It has no impact beat, no burst, no + * shockwave and no burn: the whole cast is the descent, and the descent is the + * ability. + * + * ### The block is a flight model, not a look + * + * Most of what follows are the coefficients of a real falling-plate model, + * evaluated in closed form per feather in `materials/FeatherMaterial.js`. A + * thin plate dropped through air does not sink; it goes unstable, and it goes + * unstable in one of two ways depending on its aspect ratio. This block + * carries both, and `tumbleShare` is the split between them: + * + * - **Fluttering** (`sink`, `lift`, `swing`, `flutterRate`, `pitch`) — the + * leaf motion. The plate glides to one side, stalls, flips, and glides + * back. It falls *slowest* in the middle of each glide, because that is + * where it is fastest sideways and therefore making the most lift, and + * fastest at each stall. + * - **Tumbling** (`tumbleRate`, `tumbleDrift`, `tumbleSink`, `tumbleBob`) — + * end over end, steadily, drifting in the direction of rotation, and + * noticeably quicker down. Tumblers are the ones your eye follows. + * + * The three numbers that matter, in order: **`lift`**, which is how much of + * the sink a glide cancels (0 is a sheet of paper in a vacuum, 0.95 is a + * sycamore seed and takes twenty seconds to land); **`swing`**, the peak + * sideways speed; and **`tumbleShare`**. Nothing else changes the character + * of the fall nearly as much. + * + * ### Why the cast is so long + * + * `lifetime` has to cover the whole descent or feathers vanish in mid-air, + * which is the one failure this slot cannot survive. The arithmetic is worth + * writing down because the two are coupled and neither is obvious: + * + * ``` + * mean sink = sink · (1 − lift/2) = 1.05 · 0.70 = 0.735 m/s + * longest fall = ceiling / mean sink = 4.6 / 0.735 ≈ 6.3 s + * last release = stagger = 1.9 s + * descent ≈ 8.2 s → lifetime 8.4 s, with a margin + * ``` + * + * Raise `ceiling` or `lift`, or lower `sink`, and `lifetime` has to follow. + * The check harness stops a cast at fifteen seconds, which is the real + * ceiling on all of this. + * + * ### Everything with a unit is here, including the outline + * + * The feather's silhouette is built in the vertex shader out of `uv` and the + * dozen shape keys below, so `vaneWidth`, `taper`, `cup` and the rest are live + * sliders with no geometry behind them to rebuild. Drag `cup` on a flock that + * is already in the air and two hundred feathers re-curl on the spot. + */ +export const featherfall = { + /* --- the cast --- */ + range: 17.0, // maximum cast distance, metres + minRange: 2.0, // closer than this and the cast is refused + zoneRadius: 5.2, // the circle the flock is released over, metres — HEADLINE + speed: 26.0, // how fast the cast reaches the circle, metres/second + lifetime: 8.4, // seconds the descent is given — see the arithmetic above + fadeTime: 2.6, // seconds the settled feathers take to go + cooldown: 1.6, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the flock --- */ + count: 190, // feathers in one cast; the cap in the ability is 256 + ceiling: 4.6, // metres above the floor the highest feather is released + ceilingVary: 0.4, // 0..1 of that height the lowest one starts at instead + stagger: 1.9, // seconds between the first release and the last + floorHeight: 0.012, // metres above the floor a landed feather rests at + windX: 0.16, // ambient drift, metres/second (world +X) + windZ: -0.09, // ambient drift, metres/second (world +Z) + + /* --- the feather's outline, in units of its own length --- */ + featherSize: 0.26, // length of a feather, metres + featherSizeVary: 0.45, // ± fraction of that, per feather + vaneWidth: 0.19, // half-width of the vane at its widest + widthBias: 0.85, // <1 pushes the widest point toward the tip + taper: 0.65, // >1 sharpens the tip, <1 rounds it + leadFrac: 0.62, // the leading vane is narrower — a feather is not symmetric + quill: 0.16, // fraction of the length that is bare calamus + cup: 0.55, // cross-sectional curl toward the underside — this is what catches air + arch: 0.1, // lengthwise bow of the whole feather + barb: 0.06, // ripple on the outline, × the half-width + barbFreq: 22.0, // ripple waves along the length + pivot: 0.42, // fraction along the feather the motion is anchored at + + /* --- the flutter regime --- */ + sink: 1.05, // still-air sink rate, metres/second — HEADLINE + lift: 0.6, // 0..1 of the sink a glide cancels — HEADLINE + swing: 0.95, // peak sideways speed of the glide, metres/second + flutterRate: 4.2, // radians/second of the flutter oscillation + flutterVary: 0.55, // ± fraction of that rate, per feather + pitch: 0.95, // radians the plate tilts at the extreme of a glide + spin: 0.35, // radians/second of slow roll about the rachis + + /* --- the tumbling regime --- */ + tumbleShare: 0.28, // 0..1 of the flock that tumbles instead of fluttering + tumbleRate: 7.5, // radians/second, end over end + tumbleDrift: 0.55, // metres/second it slides, in the direction of rotation + tumbleSink: 1.15, // metres/second — a tumbler makes almost no lift + tumbleBob: 0.06, // metres of vertical wobble per revolution + + /* --- the catch: one gust each, at its own moment --- */ + catchGain: 0.55, // metres a feather rises when it catches — HEADLINE + catchWidth: 0.55, // seconds the gust lasts + catchWindow: 3.2, // seconds into the fall the gust may happen within + + /* --- the ends of the descent --- */ + birthFade: 0.35, // seconds a feather takes to fade in at its release + settleTime: 0.9, // seconds before landing over which it lies flat + + /* --- shading --- */ + rachis: 0.55, // strength of the spine down the middle + barbLines: 0.35, // strength of the barb striations + barbCount: 26.0, // barbs across one vane + grain: 0.18, // world-space fibre variation between feathers + grainScale: 6.0, // grain features per metre + fresnel: 0.7, // rim light on the silhouette + fresnelPower: 2.4, + translucency: 1.6, // backlit transmission — the read of a feather in a low sun + translucencyPower: 3.2, // how tightly that hugs the anti-sun direction + glow: 0.8, // emissive gain into bloom + opacity: 1.0, + colorQuill: '#b9a88f', // the shaft and the base + colorVane: '#f2ece2', // the body of the vane + colorTip: '#cfe3ee', // the tip, cooled off + colorGlow: '#ffd9a8', // what the sun looks like coming through one + + /* --- the down --- */ + /** + * The only particle system, and it is nearly empty. Down is what comes off a + * feather, not what a feather is: a handful of specks a second, drifting + * with almost no speed. At the shipped rate the whole cast is under two + * hundred live particles against the 1500 budget. + */ + downRate: 26, // specks per second + downSize: 0.05, // metres + downSpeed: 0.28, // metres/second + downLifetime: 3.2, // seconds + downRise: -0.12, // gentle sink, metres/second + downTurbulence: 0.7, // curl-noise strength + colorDownA: '#fffaf0', // birth + colorDownB: '#efe6d6', + colorDownC: '#c8beb0', + colorDownD: '#2b2823', // death + + /* --- dynamic light --- */ + // Warm and wide and very soft. There is nothing hot in this ability; the + // light exists so the flock has a direction to be lit from as it comes down. + lightIntensity: 7.0, + lightRadius: 11.0, + lightColor: '#ffe3bd' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Featherfall. + * + * Two folders do all the work and they are the two regimes. Start in **The + * flutter** with `lift`: at 0 the feathers drop like wet paper, at 0.9 they + * hang and glide and the cast outlasts its own `lifetime` — which is the one + * way to break this slot, and the reason the two folders sit next to each + * other. Then **The tumble**, and specifically `tumbleShare`: at 0 the flock + * is beautiful and slightly monotonous, at 1 it is a shower of leaves, and + * somewhere near a quarter it stops looking like a system at all. + * + * `catchGain` in **The catch** is the cheapest trick in the ability and worth + * knowing about: each feather gets exactly one gust, at its own moment, + * somewhere in `catchWindow`. It is what stops the descent reading as a loop. + * + * The shape folder is in fractions of the feather's own length, so `featherSize` + * resizes the whole flock and nothing about the outline changes. + */ +export const featherfallSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['zoneRadius', 1, 16, 0.1, 'zone radius (m)'], + ['speed', 5, 120, 0.5, 'cast speed'], + ['lifetime', 1, 13, 0.1, 'descent window (s)'], + ['fadeTime', 0.2, 6, 0.05, 'fade time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The flock': [ + ['count', 1, 256, 1, 'feathers'], + ['ceiling', 0.5, 14, 0.1, 'release height (m)'], + ['ceilingVary', 0, 0.95, 0.01, 'height spread'], + ['stagger', 0, 6, 0.05, 'release spread (s)'], + ['floorHeight', 0, 0.3, 0.001, 'resting height (m)'], + ['windX', -3, 3, 0.01, 'wind X (m/s)'], + ['windZ', -3, 3, 0.01, 'wind Z (m/s)'] + ], + 'The feather': [ + ['featherSize', 0.03, 1.2, 0.005, 'length (m)'], + ['featherSizeVary', 0, 1.2, 0.01, 'length spread'], + ['vaneWidth', 0.02, 0.6, 0.005, 'vane half-width'], + ['widthBias', 0.2, 2.5, 0.01, 'widest point'], + ['taper', 0.1, 3, 0.01, 'tip taper'], + ['leadFrac', 0.1, 1.4, 0.01, 'leading vane ×'], + ['quill', 0, 0.6, 0.005, 'bare calamus'], + ['cup', 0, 2.5, 0.01, 'cross-section curl'], + ['arch', -1, 1, 0.005, 'lengthwise bow'], + ['barb', 0, 0.4, 0.005, 'outline ripple'], + ['barbFreq', 0, 60, 0.5, 'ripple waves'], + ['pivot', 0, 1, 0.01, 'motion anchor'] + ], + 'The flutter': [ + ['sink', 0.05, 6, 0.01, 'sink rate (m/s)'], + ['lift', 0, 0.95, 0.01, 'lift fraction'], + ['swing', 0, 5, 0.01, 'glide speed (m/s)'], + ['flutterRate', 0.2, 16, 0.05, 'flutter (rad/s)'], + ['flutterVary', 0, 1, 0.01, 'flutter spread'], + ['pitch', 0, 1.8, 0.01, 'stall tilt (rad)'], + ['spin', -3, 3, 0.01, 'axial roll (rad/s)'] + ], + 'The tumble': [ + ['tumbleShare', 0, 1, 0.01, 'share that tumbles'], + ['tumbleRate', 0, 24, 0.1, 'tumble (rad/s)'], + ['tumbleDrift', 0, 4, 0.01, 'drift (m/s)'], + ['tumbleSink', 0.05, 6, 0.01, 'tumble sink (m/s)'], + ['tumbleBob', 0, 0.5, 0.005, 'tumble bob (m)'] + ], + 'The catch': [ + ['catchGain', 0, 3, 0.01, 'gust rise (m)'], + ['catchWidth', 0.05, 3, 0.01, 'gust length (s)'], + ['catchWindow', 0.1, 10, 0.05, 'gust window (s)'] + ], + 'The landing': [ + ['birthFade', 0.02, 2, 0.01, 'fade in (s)'], + ['settleTime', 0.05, 4, 0.01, 'lie flat over (s)'] + ], + Shading: [ + ['rachis', 0, 2, 0.01, 'spine'], + ['barbLines', 0, 1.5, 0.01, 'barb striations'], + ['barbCount', 2, 80, 1, 'barbs per vane'], + ['grain', 0, 1, 0.01, 'fibre grain'], + ['grainScale', 0.2, 24, 0.1, 'grain per metre'], + ['fresnel', 0, 3, 0.01, 'rim light'], + ['fresnelPower', 0.2, 8, 0.05, 'rim tightness'], + ['translucency', 0, 6, 0.01, 'backlight'], + ['translucencyPower', 0.2, 12, 0.05, 'backlight tightness'], + ['glow', 0, 4, 0.01, 'glow'], + ['opacity', 0, 1, 0.01, 'opacity'], + 'colorQuill', + 'colorVane', + 'colorTip', + 'colorGlow' + ], + 'The down': [ + ['downRate', 0, 300, 1, 'down rate'], + ['downSize', 0.005, 0.3, 0.005, 'down size'], + ['downSpeed', 0, 4, 0.01, 'down speed'], + ['downLifetime', 0.2, 10, 0.05, 'down lifetime'], + ['downRise', -2, 2, 0.01, 'down rise'], + ['downTurbulence', 0, 3, 0.01, 'down turbulence'], + ['colorDown*', 'Down colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/firewalk.js b/src/config/abilities/firewalk.js new file mode 100644 index 0000000..f43bfe6 --- /dev/null +++ b/src/config/abilities/firewalk.js @@ -0,0 +1,390 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* FIREWALK — the burning trail of footprints */ +/* ================================================================== */ +/** + * A line cast. Something walks the aimed line and each footfall ignites where + * it lands: an actual sole — heel, arch, ball, five toes — burnt into the floor, + * heel first, throwing a short pillar of flame as it lights. + * + * ### Why the numbers below are anatomy + * + * Flame is the school with no silhouette. Everything in it is a cloud, a jet or + * a front, and the only shapes are the ones the medium happens to erode itself + * into that frame. The trick here is to put one recognisable object in it, and a + * footprint is the cheapest recognisable object there is — three ellipses and + * five dots, and the eye completes a body walking away from you. + * + * That only works if the proportions are right, so they are sliders and they are + * named after feet rather than after shapes. `archCut` is the one worth + * dragging: at 0 the sole is a bean and reads as a scorch blot, and by about + * 0.4 the medial arch is eaten in far enough that the mark reads as a **foot** + * from any angle. There is no gradual improvement in between — it snaps. + * + * `stride` is the other one. A walk is two lines of marks either side of the + * centre line, taken in turn; the alternation is what the eye reconstructs a + * gait from. At `stride: 0` the whole trail collapses into a dotted line down + * the middle and the silhouette stops working even though every print is still + * a perfect foot. + * + * ### What a cast captures + * + * Per print: a seed, a lateral dice roll and the timestamp the front crossed + * it. Nothing else. Every metre, radian and second below is re-resolved in the + * vertex or fragment shader on the frame it is read, so dragging `stepLength` + * on a paused trail re-spaces prints that are already burning and takes their + * pillars, their lane and their light with them. + */ +export const firewalk = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 11.0, // how fast the walk runs down the line, metres/second + lifetime: 1.5, // seconds the trail burns after the last footfall + fadeTime: 1.8, // seconds it takes to burn down to a scar + cooldown: 1.2, // seconds + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the gait --- */ + startOffset: 1.1, // metres in front of the caster the first print lands + stepLength: 1.15, // metres between successive footfalls + stride: 0.3, // metres from the centre line to a print — 0 kills the silhouette + wander: 0.05, // metres of per-print lateral slop, × a unitless dice roll + toeOut: 0.14, // radians each print splays away from the line of travel + printLift: 0.014, // metres the sole quad floats above the floor + + /* --- the sole --- */ + // Roughly a size 43 boot at the defaults: 30 cm by 11.5 cm. + footLength: 0.3, // metres, heel to the end of the toes + footWidth: 0.115, // metres, across the ball + heelWidth: 0.78, // heel width as a fraction of the ball's + ballWidth: 1.0, // ball width as a fraction of `footWidth` + archCut: 0.42, // 0..1 how far the medial arch is eaten in — see above + soleRound: 0.02, // metres of smooth union between heel, waist and ball + toeSize: 0.03, // metres, the big toe; the others taper off it + toeSpread: 0.92, // how far the toe row fans across the width + toeGap: 0.016, // metres the toe row sits ahead of the ball + solePad: 0.13, // metres of quad outside the sole, for the scorch halo to live in + + /* --- how a print burns --- */ + rollTime: 0.17, // seconds the ignition takes to run heel to toe + flashTime: 0.09, // seconds a freshly lit point stays white + coolTime: 1.7, // seconds that point takes to go dark + charTime: 0.5, // seconds the char takes to blacken + soleRim: 0.016, // metres of ember rim inside the outline + soleHalo: 0.1, // metres of scorch outside it + soleEdge: 0.006, // metres of feather on the outline + soleGrain: 5.5, // ember-vein features per metre + soleVein: 0.6, // 0..1 how much of the sole is vein rather than flat char + soleRagged: 0.03, // metres the halo wanders + soleOpacity: 1.0, + soleGlow: 2.2, // emissive gain on the embers + soleSoftFade: 0.3, // metres of depth feather where the print meets geometry + colorFlash: '#fff4d6', // the ignition line running heel to toe + colorEmber: '#ff6a18', // the veins that stay hot + colorChar: '#100b09', // burnt stone + colorAsh: '#4a423c', // the print before the char takes + colorHalo: '#241813', // the scorch outside the sole + + /* --- the pillar each print throws --- */ + pillarHeight: 1.35, // metres at full stretch + pillarWidth: 0.22, // metres, half-width at the base + pillarRise: 0.2, // seconds to full height + pillarFall: 0.95, // seconds to gutter out + pillarLean: 0.18, // metres the crown leans downrange + pillarWaver: 0.07, // metres of sideways sway at the crown + pillarWaverRate: 3.1, // sways per second + pillarTaper: 1.35, // >1 pulls the flame to a point + pillarBulge: 0.55, // 0..1 how much it swells above the base + pillarNoiseScale: 1.6, // features per metre + pillarNoiseSpeed: 2.4, // metres/second the field climbs + pillarErosion: 0.9, // how hard the noise eats the silhouette + pillarOpacity: 1.0, + pillarGlow: 1.7, + pillarSoftFade: 0.4, // metres of depth feather + colorPillarA: '#fff0c0', // at the foot + colorPillarB: '#ff9a2a', + colorPillarC: '#d43c0a', + colorPillarD: '#241a1c', // the smoke at the crown + + /* --- the pyre standing on the newest print (Medium.FLAME, prefix `pyre`) --- */ + // One raymarched volume, and it rides the print that just lit rather than + // covering the whole lane: coverage is the dominant cost in this module and a + // box over twenty metres of floor spends its entire step budget in vacuum. + pyreWidth: 0.42, // metres, half-width of the hull + pyreHeight: 0.95, // metres, half-height + pyreSlack: 1.12, // proxy hull slack, × — headroom for the erosion to spill into + pyreLinger: 1.1, // seconds the volume keeps burning on a print after it lit + ...volumeHullDefaults('pyre', Medium.FLAME, { + pyreSteps: 26, // march steps — the cost knob; the hull is small, so this is cheap + pyreJitter: 1.0, // step dither; 0 only ever shows you the banding + pyreContact: 0.22, // metres of fade where the flame meets the floor + pyreMargin: 0.2, // headroom inside the hull before the erosion + pyreDensity: 2.4, + pyreNoiseFrequency: 3.2, // features per metre — a small fire has a fine grain + pyreNoiseStrength: 0.85, + pyreRise: 1.6, // buoyant rise, metres/second + pyreEmission: 3.2, + pyreShadowTaps: 0, // an emissive medium does not need a lit side + pyreSpeckDensity: 0.07, + pyreSpeckGlow: 9.0 + }), + + /* --- the lane the walk scorches (GroundMode.RUT, prefix `lane`) --- */ + // RUT and not a decal: the char is drawn by a front whose progress is + // re-resolved every frame, and each footfall posts a contact sample so the + // burn is deeper under a print than between two. + laneReach: 1.4, // metres — the mark's own radius, i.e. how wide the canvas is + laneWidth: 0.42, // metres, half-width of the scorched lane + laneDepth: 0.05, // metres of gouge — a scorch, not a trench + laneLift: 0.02, // metres of ash heaped along the edges + laneThickness: 0.14, // metres the heaped edge spreads over + laneEdge: 0.3, // metres of feather on the front + laneRagged: 0.35, // how far the front wanders, as a fraction of the radius + laneRaggedScale: 1.1, // lobes per metre + laneWarp: 0.4, // metres of domain warp on those lobes + laneRelief: 0.55, // how hard the height field tilts the fake normal + laneNormalStep: 0.05, // metres between the height taps + laneAmbient: 0.3, // floor on the diffuse term + laneWrap: 0.45, // wraps the terminator round the back + laneSpecular: 0.15, // burnt stone is not shiny + laneGloss: 18, // Blinn exponent + laneParallax: 0.2, // metres of view-driven offset on the interior detail + laneCell: 0.8, // metres — the pitch of the chatter along the track + laneSeam: 0.55, // metres a footfall's contact sample reaches along the track + laneSharp: 0.55, // 0..1 how hard the lane's edge is + laneDetail: 0.5, // 0..1 chatter depth + laneSwirl: 0.22, // how much the lane drifts off the dead-straight line + laneMarkLife: 7.0, // seconds a footfall's contact sample weathers away over + laneMarkRadius: 0.5, // metres, a full-strength contact sample + laneHeight: 0.012, // metres above the floor the lane quad sits + laneEmissive: 1.0, // multiplier on the glowing terms + laneOpacity: 0.95, + laneDepthFade: 0.4, // metres of soft fade against standing geometry + colorLaneBase: '#2b211c', // the scorched stone + colorLaneEdge: '#6e5a4a', // the ash heaped at its lip + colorLaneGlow: '#ff5a12', // what is still hot in the gouge + colorLaneDeep: '#0b0706', // the bottom of the burn + + /* --- embers, smoke and the sparks a footfall kicks --- */ + /** + * Three systems, each with its own four-stop lifetime gradient sampled `A` at + * birth through `D` as it dies. Spelled out rather than derived from the sole + * palette, so the embers can be made to cool to soot while the prints stay + * orange. + */ + moteRate: 130, // embers lifting off the burning prints, particles/second + moteSize: 0.07, + moteSpeed: 1.3, + moteLifetime: 1.5, + moteRise: 1.7, // upward drift, metres/second + moteTurbulence: 0.85, + moteGlow: 2.0, + colorMoteA: '#fff0c8', + colorMoteB: '#ff9226', + colorMoteC: '#c8340a', + colorMoteD: '#2a1410', + smokeRate: 34, // haze off the lane, particles/second + smokeSize: 0.8, + smokeSpeed: 1.0, + smokeLifetime: 2.6, + smokeRise: 0.6, + smokeOpacity: 0.075, + colorSmokeA: '#4a4038', + colorSmokeB: '#3a322c', + colorSmokeC: '#2c2622', + colorSmokeD: '#1a1614', + sparkStamp: 26, // sparks kicked out by one footfall + sparkSize: 0.11, + sparkSpeed: 4.6, + sparkLifetime: 0.65, + sparkGravity: -14.0, + sparkStretch: 0.2, // how far a spark smears along its velocity + colorSparkA: '#ffffff', + colorSparkB: '#ffc25a', + colorSparkC: '#ff5c14', + colorSparkD: '#5a1c08', + + /* --- feedback --- */ + stampShake: 0.09, // camera kick per footfall + shakeDuration: 0.35, // seconds that kick takes to die + castFlash: 0.05, // screen flash as the first print lights + colorCastFlash: '#ffb268', + rumble: 0.012, // continuous shake while the walk runs out + + /* --- dynamic light --- */ + lightIntensity: 15, // it rides the newest print + lightRadius: 9, + lightHeight: 0.55, // metres above the print the light sits + lightColor: '#ff8434', + lightFlicker: 0.28, // depth of the gutter, 0 = steady + lightFlickerSpeed: 13 // gutters per second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Firewalk. + * + * Reach for **The sole** first — `archCut`, `toeSpread` and `footWidth` are + * what decide whether the mark reads as a foot at all — and then **The gait**, + * where `stride` and `stepLength` decide whether a row of feet reads as a walk. + * Everything in both folders is live on a paused trail. + * + * The `pyreBoil*` and `pyreVoid*` keys `volumeHullDefaults` emits belong to + * other media and do nothing to a FLAME hull, so they are not filed here; they + * still appear in the trailing "More" folder for anyone who wants them. + */ +export const firewalkSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 2, 60, 0.5, 'walking speed'], + ['lifetime', 0.1, 8, 0.05, 'burn time'], + ['fadeTime', 0.1, 8, 0.05, 'burn-down time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The gait': [ + ['startOffset', -1, 6, 0.05, 'first print (m)'], + ['stepLength', 0.2, 4, 0.01, 'step length (m)'], + ['stride', 0, 1.5, 0.01, 'stride width (m)'], + ['wander', 0, 0.5, 0.005, 'per-print slop (m)'], + ['toeOut', -0.8, 0.8, 0.01, 'toe-out (rad)'], + ['printLift', 0, 0.2, 0.001, 'print height (m)'] + ], + 'The sole': [ + ['footLength', 0.08, 1.2, 0.005, 'foot length (m)'], + ['footWidth', 0.03, 0.6, 0.005, 'foot width (m)'], + ['heelWidth', 0.2, 1.4, 0.01, 'heel × ball'], + ['ballWidth', 0.3, 1.6, 0.01, 'ball × width'], + ['archCut', 0, 0.85, 0.01, 'arch cut'], + ['soleRound', 0.002, 0.12, 0.002, 'union radius (m)'], + ['toeSize', 0.005, 0.12, 0.001, 'big toe (m)'], + ['toeSpread', 0, 2, 0.01, 'toe spread'], + ['toeGap', -0.02, 0.1, 0.001, 'toe gap (m)'], + ['solePad', 0.02, 0.6, 0.005, 'quad margin (m)'] + ], + 'How a print burns': [ + ['rollTime', 0.01, 1.5, 0.005, 'heel-to-toe roll (s)'], + ['flashTime', 0.01, 1, 0.005, 'flash (s)'], + ['coolTime', 0.05, 8, 0.05, 'cool (s)'], + ['charTime', 0.05, 5, 0.01, 'char (s)'], + ['soleRim', 0.002, 0.1, 0.001, 'ember rim (m)'], + ['soleHalo', 0.01, 0.6, 0.005, 'scorch halo (m)'], + ['soleEdge', 0.001, 0.08, 0.001, 'outline feather (m)'], + ['soleGrain', 0.5, 20, 0.1, 'veins / metre'], + ['soleVein', 0, 1, 0.01, 'vein share'], + ['soleRagged', 0, 0.2, 0.002, 'halo wander (m)'], + ['soleOpacity', 0, 2, 0.01, 'opacity'], + ['soleGlow', 0, 8, 0.05, 'ember glow'], + ['soleSoftFade', 0.02, 2, 0.01, 'soft intersection (m)'], + ['colorFlash', 'ignition line'], + ['colorEmber', 'ember veins'], + ['colorChar', 'burnt stone'], + ['colorAsh', 'fresh print'], + ['colorHalo', 'scorch halo'] + ], + 'The pillars': [ + ['pillarHeight', 0, 6, 0.01, 'height (m)'], + ['pillarWidth', 0.02, 1.5, 0.01, 'half-width (m)'], + ['pillarRise', 0.01, 2, 0.01, 'rise (s)'], + ['pillarFall', 0.05, 6, 0.05, 'gutter (s)'], + ['pillarLean', -1, 1.5, 0.01, 'downrange lean (m)'], + ['pillarWaver', 0, 0.6, 0.005, 'crown sway (m)'], + ['pillarWaverRate', 0, 12, 0.05, 'sways / sec'], + ['pillarTaper', 0.1, 4, 0.01, 'taper'], + ['pillarBulge', 0, 2, 0.01, 'bulge'], + ['pillarNoiseScale', 0.1, 8, 0.05, 'features / metre'], + ['pillarNoiseSpeed', 0, 12, 0.05, 'climb (m/s)'], + ['pillarErosion', 0, 3, 0.01, 'erosion'], + ['pillarOpacity', 0, 2, 0.01, 'opacity'], + ['pillarGlow', 0, 6, 0.05, 'glow'], + ['pillarSoftFade', 0.02, 2, 0.01, 'soft intersection (m)'], + ['colorPillar*', 'Pillar colour'] + ], + 'The pyre': [ + ['pyreWidth', 0.05, 3, 0.01, 'hull half-width (m)'], + ['pyreHeight', 0.05, 4, 0.01, 'hull half-height (m)'], + ['pyreSlack', 1, 2, 0.01, 'proxy hull slack'], + ['pyreLinger', 0.05, 6, 0.05, 'linger on a print (s)'] + ], + ...volumeHullSchema('pyre', { + label: 'Pyre', + only: ['march', 'shape', 'field', 'flow', 'optics', 'speck', 'colour'] + }), + 'The lane': [ + ['laneReach', 0.2, 8, 0.05, 'canvas radius (m)'], + ['laneWidth', 0.05, 3, 0.01, 'lane half-width (m)'], + ['laneDepth', 0, 0.6, 0.005, 'gouge depth (m)'], + ['laneLift', 0, 0.3, 0.002, 'ash lip (m)'], + ['laneThickness', 0.02, 0.8, 0.005, 'lip spread (m)'], + ['laneEdge', 0.02, 2, 0.01, 'front feather (m)'], + ['laneRagged', 0, 1, 0.01, 'front wander'], + ['laneRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['laneWarp', 0, 3, 0.01, 'lobe warp (m)'], + ['laneRelief', 0, 2, 0.01, 'relief'], + ['laneNormalStep', 0.01, 0.3, 0.005, 'normal step (m)'], + ['laneAmbient', 0, 1, 0.01, 'ambient'], + ['laneWrap', 0, 1, 0.01, 'terminator wrap'], + ['laneSpecular', 0, 2, 0.01, 'specular'], + ['laneGloss', 1, 80, 1, 'gloss'], + ['laneParallax', 0, 1, 0.01, 'parallax (m)'], + ['laneCell', 0.05, 3, 0.01, 'chatter pitch (m)'], + ['laneSeam', 0.05, 3, 0.01, 'contact reach (m)'], + ['laneSharp', 0, 1, 0.01, 'edge sharpness'], + ['laneDetail', 0, 1, 0.01, 'chatter depth'], + ['laneSwirl', -2, 2, 0.01, 'lane drift'], + ['laneMarkLife', 0.5, 20, 0.1, 'contact life (s)'], + ['laneMarkRadius', 0.05, 3, 0.01, 'contact radius (m)'], + ['laneHeight', 0, 0.2, 0.001, 'quad height (m)'], + ['laneEmissive', 0, 4, 0.01, 'emissive'], + ['laneOpacity', 0, 2, 0.01, 'opacity'], + ['laneDepthFade', 0.05, 3, 0.01, 'soft fade (m)'], + ['colorLaneBase', 'scorched stone'], + ['colorLaneEdge', 'ash lip'], + ['colorLaneGlow', 'hot gouge'], + ['colorLaneDeep', 'deep burn'] + ], + 'Embers & smoke': [ + ['moteRate', 0, 800, 1, 'ember rate'], + ['moteSize', 0.005, 0.5, 0.005, 'ember size'], + ['moteSpeed', 0, 12, 0.05, 'ember speed'], + ['moteLifetime', 0.1, 8, 0.05, 'ember lifetime'], + ['moteRise', -2, 8, 0.05, 'ember rise'], + ['moteTurbulence', 0, 3, 0.01, 'ember turbulence'], + ['moteGlow', 0, 6, 0.05, 'ember glow'], + ['smokeRate', 0, 400, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 10, 0.05, 'smoke lifetime'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['colorMote*', 'Ember colour'], + ['colorSmoke*', 'Smoke colour'] + ], + 'Sparks & feedback': [ + ['sparkStamp', 0, 300, 1, 'sparks / footfall'], + ['sparkSize', 0.005, 0.6, 0.005, 'spark size'], + ['sparkSpeed', 0, 30, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['colorSpark*', 'Spark colour'], + ['stampShake', 0, 1, 0.005, 'footfall kick'], + ['shakeDuration', 0.05, 3, 0.01, 'kick decay (s)'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 3, 0.01, 'light height (m)'], + ['lightFlicker', 0, 1, 0.01, 'light gutter'], + ['lightFlickerSpeed', 1, 60, 1, 'gutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/firewhip.js b/src/config/abilities/firewhip.js new file mode 100644 index 0000000..cf21653 --- /dev/null +++ b/src/config/abilities/firewhip.js @@ -0,0 +1,328 @@ +/* ================================================================== */ +/* FIREWHIP — Ashen Lash */ +/* ================================================================== */ +/** + * A burning lash thrown along the aimed line, which **cracks**. + * + * The whole slot is built on one idea, and it is worth stating before the + * numbers: the crack is not scheduled. `vfx/Tube.js`'s WHIP path carries a + * Gaussian curvature lobe travelling handle → tip, and it conserves arc length + * — the lateral throw of the loop is paid for out of the axial extent, so the + * tip is hauled *back* while the loop is mid-whip and let go as the loop runs + * off the end. Differentiating that curve with respect to its own driver gives + * a tip speed that spikes, and when the spike crosses `lashCrackRatio × the + * wave speed` the tube reports `crack.fired` for exactly one frame. The shock + * ring is fired there, at the tip, on that frame. + * + * Which means **the three whip sliders below are the ability**. `lashWaveAmp`, + * `lashWaveWidth` and `lashWaveGain` decide whether it cracks, how hard, and + * where along the lash the bang happens; drag them mid-cast and the crack + * moves. Two ranges are worth knowing: + * + * - the loop eats `0.6267 × amp² / width` of the span while it travels, so + * `amp²/width` much above `0.55` yanks the tip back behind the caster and + * the lash reads as a rubber band rather than as leather. The shipped + * numbers peak at about a third of the span, which is roughly what a real + * whip does; + * - below about `amp = 0.6 × width` the tip never beats the wave and it stops + * cracking altogether. That is correct — a slack whip does not bang — but if + * the ring has gone missing, that is the first place to look. + * + * Every dimension here is resolved inside the update loop. The cast captures + * one seed, the timestamp of the crack, and the crack's position as three + * *fractions of the cast length* — never metres. + */ + +import { TubePath, tubeDefaults, tubeSchema } from '../../vfx/Tube.js'; +import { ShellMode, shellDefaults, shellSchema } from '../../vfx/Shell.js'; + +export const firewhip = { + /* --- the cast --- */ + range: 17.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused + speed: 64.0, // how fast the lash reaches full extension, metres/second + windUp: 0.24, // seconds the loop spends forming at the handle before release + windUpReach: 0.26, // how much of the lash exists while it winds up, 0..1 of the span + lifetime: 1.0, // seconds the lash hangs at full extension — the crack lands in here + fadeTime: 1.05, // seconds it takes to fall and burn out + cooldown: 0.85, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the lash leaves the caster --- */ + // The base class puts the cast on the floor because that is what the aim + // arrow targets; a whip comes out of a hand, so the handle is offset here. + handHeight: 1.34, // metres above the floor + handForward: 0.55, // metres in front of the caster + handSide: 0.24, // metres to the side (+ follows `Ability#side`) + tipHeight: 1.15, // height of the far end while the lash is up, metres + fallHeight: 0.09, // ...and where it comes to rest once it has fallen, metres + fallTime: 0.9, // seconds the fall takes, measured from the crack + fallCurve: 2.1, // >1 holds the lash up and then drops it + + /* --- the lash itself: Tube(WHIP), prefix `lash` --- */ + ...tubeDefaults('lash', TubePath.WHIP, { + lashRadius: 0.105, // half-width at the tip, metres — a whip is thin + lashRadiusNear: 0.17, // ...and thicker in the hand + lashRadiusCurve: 1.25, // stays fat out of the hand, then tapers late + lashFlare: 0.0, // nothing blooms at the end of a whip + lashFlareWidth: 0.1, + lashThrob: 0.05, // the burning breathes a little + lashThrobScale: 3.4, + lashThrobSpeed: 2.2, + lashWander: 0.025, // metres of low-frequency drift on the axis + lashRipple: 0.18, // radial break-up — the lash is burning, not extruded + lashRippleScale: 4.2, + lashStreak: 1.15, // fire running down the leather + lashStreakScale: 11.0, + lashStreakSharp: 0.55, + lashFlowSpeed: 9.0, // metres-of-parameter/second the fire runs at + lashCoreWidth: 0.42, + lashCoreFill: 0.9, + lashSheathWidth: 1.0, + lashHaloWidth: 2.1, + lashHaloRim: 3.2, + lashMuzzleGlow: 1.8, // the handle is the hottest part while it winds up + lashMuzzleLength: 0.14, + lashTipGlow: 2.6, + lashTipLength: 0.05, + /* the three that decide whether it cracks — see the header */ + lashWaveRate: 1.5, // loops per second travelling handle → tip + lashWaveWidth: 0.2, // how tight the loop is, fraction of the span + lashWaveAmp: 0.17, // lateral throw of the loop, fraction of the span + lashWaveGain: 2.1, // how much the loop grows on its way to the tip, × + lashWaveCurve: 1.5, // when that growth happens, >1 = late + lashWaveRoll: 0.85, // the plane it cracks in, radians (0 = vertical) + lashSag: 0.38, // metres the lash hangs under its own weight + lashCrackRatio: 1.0, // tip speed ÷ wave speed at which the crack fires + lashGlow: 2.4, + lashSoftFade: 0.5, + lashColorCore: '#fff3d2', // the white-hot centre of the lash + lashColorInner: '#ffc46a', + lashColorOuter: '#ff7a1e', + lashColorHalo: '#8a1c05' // the deep burnt bloom around it + }), + + /* --- the shock ring: Shell(PRESSURE), prefix `crack` --- */ + // A squashed pressure shell whose axis is the lash's own heading, so it + // reads as a ring standing perpendicular to the whip rather than as a ball. + ...shellDefaults('crack', ShellMode.PRESSURE, { + crackRadius: 0.14, // metres at the instant it fires + crackRadiusEnd: 2.9, // ...and where it has got to when it dies + crackExpand: 4.6, // fast, then easing out — a pressure front, not a balloon + crackHeight: 0.16, // axial extent × radius: 0.16 is a lens, 1 is a ball + crackLift: 0.0, + crackDisplace: 0.06, + crackNoiseScale: 2.4, + crackFill: 0.03, // almost nothing but rim + crackRim: 1.9, + crackRimPower: 3.2, + crackDissolve: 1.3, + crackOpacity: 0.85, + crackGlow: 1.7, + crackSoftFade: 0.4, + crackColorBody: '#8a1c05', + crackColorRim: '#ffc46a', + crackColorEdge: '#ffe9b0' + }), + crackLife: 0.45, // seconds the shock ring lives after it fires + crackShake: 1.15, // camera shake at the crack, × the speed ratio it fired at + crackShakeTime: 0.4, // seconds that shake decays over + crackFlash: 0.24, // screen flash at the crack + crackLight: 1.8, // light punch at the crack, × lightIntensity + crackSparks: 130, // embers thrown out of the crack + crackAshBurst: 55, // ash flakes blown off it + crackBurstSize: 1.15, // the little fireball at the crack point, metres + crackBurstIntensity: 1.7, + colorCrackBurstA: '#8a1c05', // burst shell + colorCrackBurstB: '#ff8a2a', // burst body + colorCrackBurstC: '#ffe9b0', // burst filaments — the one carrying the read + colorCrackFlash: '#ffe9b0', // the full-screen flash + + /* --- the release, and the snap at full extension --- */ + muzzleSize: 0.5, // the flare at the hand as the lash goes, metres + muzzleIntensity: 1.6, + castFlash: 0.09, // screen flash on release + releaseSparks: 40, // embers thrown off the handle + colorMuzzleA: '#8a1c05', + colorMuzzleB: '#ff8a2a', + colorMuzzleC: '#ffe9b0', + colorCastFlash: '#ffb26a', + snapShake: 0.45, // the thump as the lash reaches full extension + snapShakeTime: 0.3, + snapSparks: 70, + rumble: 0.022, // continuous shake while the lash is out + + /* --- embers --- */ + /** + * As in `thunder`: every system is coloured by a four-stop gradient sampled + * over the particle's own lifetime, `A` at birth through `D` as it dies, and + * spelled out rather than derived from the lash palette — so the embers can + * be made to cool to red while the lash itself stays straw-white. + */ + emberRate: 110, // embers shed off the lash, particles/second + emberFallRate: 340, // ...and while it lies on the floor burning out + emberSize: 0.1, + emberSpeed: 2.2, // metres/second they leave the lash at + emberSpread: 0.85, // how wide that cone is, 0..1 + emberLifetime: 1.5, + emberGravity: -3.4, // metres/second² — embers are light + emberStretch: 0.14, // how far one smears along its velocity + colorEmberA: '#fff3d2', + colorEmberB: '#ffb04a', + colorEmberC: '#ff5a12', + colorEmberD: '#5a1204', + + /* --- ash flakes, shed the whole way --- */ + ashRate: 70, // flakes/second off the lash + ashSize: 0.11, + ashSpeed: 1.5, + ashSpread: 1.0, + ashLifetime: 2.8, + ashGravity: -1.0, // they flutter down rather than fall + ashSpin: 4.2, // radians/second they tumble at + ashTurbulence: 1.3, // how much the curl field pushes them about + colorAshA: '#8a6a52', + colorAshB: '#5d5048', + colorAshC: '#3a332e', + colorAshD: '#231f1c', + + /* --- smoke off the burn --- */ + smokeRate: 28, // puffs/second + smokeSize: 0.9, + smokeSpeed: 0.9, + smokeLifetime: 2.4, + smokeOpacity: 0.07, + smokeRise: 0.7, // metres/second of buoyancy + colorSmokeA: '#5a4a40', + colorSmokeB: '#43382f', + colorSmokeC: '#332c26', + colorSmokeD: '#1e1a17', + + /* --- what the ground gets --- */ + scorchRate: 7.0, // burn marks laid per second while the lash lies burning + scorchRadius: 0.42, // radius of one mark, metres + scorchLife: 5.5, // seconds a mark lingers + scorchIntensity: 0.5, + colorScorch: '#120a06', // the burnt floor + colorScorchEmber: '#ff6a1f', // the embers still alive in it + + /* --- dynamic light --- */ + lightIntensity: 20, + lightRadius: 13, + lightColor: '#ff8a3a', + lightFlicker: 0.28, // depth of the ember gutter, 0 = steady + lightFlickerSpeed: 17 +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Ashen Lash. + * + * Reach for **The whip** first. Everything else on this ability decorates a + * curve; those eight sliders *are* the curve, and the crack is a property of + * it. `loop throw` and `loop width` against each other decide whether it bangs + * at all; `loops/second` decides where along the beat it does. + * + * The tube and shell folders below are generated by the tech library from the + * `lash` and `crack` prefixes, so they carry the same labels here as they do on + * every other ability that mounts one. + */ +export const firewhipSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 200, 1, 'lash speed'], + ['windUp', 0, 1.5, 0.01, 'wind-up (s)'], + ['windUpReach', 0.02, 1, 0.01, 'wind-up reach'], + ['lifetime', 0.05, 4, 0.01, 'hang time'], + ['fadeTime', 0.05, 4, 0.01, 'burn-out time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where it leaves the hand': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['tipHeight', 0, 4, 0.01, 'tip height'], + ['fallHeight', 0, 3, 0.01, 'resting height'], + ['fallTime', 0.05, 4, 0.01, 'fall time'], + ['fallCurve', 0.2, 6, 0.01, 'fall curve'] + ], + ...tubeSchema('lash', TubePath.WHIP), + ...shellSchema('crack', ShellMode.PRESSURE), + 'The crack': [ + ['crackLife', 0.05, 3, 0.01, 'ring lifetime'], + ['crackShake', 0, 4, 0.01, 'crack shake'], + ['crackShakeTime', 0.05, 2, 0.01, 'shake duration'], + ['crackFlash', 0, 2, 0.01, 'screen flash'], + ['crackLight', 0, 6, 0.01, 'light punch'], + ['crackSparks', 0, 600, 1, 'crack embers'], + ['crackAshBurst', 0, 300, 1, 'crack ash'], + ['crackBurstSize', 0.05, 6, 0.05, 'fireball size'], + ['crackBurstIntensity', 0, 5, 0.01, 'fireball intensity'], + ['colorCrackBurstA', 'fireball shell'], + ['colorCrackBurstB', 'fireball body'], + ['colorCrackBurstC', 'fireball filaments'], + ['colorCrackFlash', 'crack flash colour'] + ], + 'Release & snap': [ + ['muzzleSize', 0.05, 4, 0.05, 'handle flare size'], + ['muzzleIntensity', 0, 5, 0.01, 'handle flare intensity'], + ['castFlash', 0, 2, 0.01, 'release flash'], + ['releaseSparks', 0, 400, 1, 'release embers'], + ['snapShake', 0, 3, 0.01, 'extension thump'], + ['snapShakeTime', 0.05, 2, 0.01, 'thump duration'], + ['snapSparks', 0, 400, 1, 'extension embers'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['colorMuzzleA', 'handle shell'], + ['colorMuzzleB', 'handle body'], + ['colorMuzzleC', 'handle arcs'], + ['colorCastFlash', 'release flash colour'] + ], + 'Embers': [ + ['emberRate', 0, 800, 1, 'ember rate'], + ['emberFallRate', 0, 1200, 1, 'ember rate (burn-out)'], + ['emberSize', 0.005, 0.6, 0.005, 'ember size'], + ['emberSpeed', 0, 20, 0.05, 'ember speed'], + ['emberSpread', 0, 1, 0.01, 'ember spread'], + ['emberLifetime', 0.1, 5, 0.05, 'ember lifetime'], + ['emberGravity', -30, 5, 0.1, 'ember gravity'], + ['emberStretch', 0, 2, 0.01, 'ember stretch'], + ['colorEmber*', 'Ember colour'] + ], + 'Ash & smoke': [ + ['ashRate', 0, 500, 1, 'ash rate'], + ['ashSize', 0.005, 0.6, 0.005, 'ash size'], + ['ashSpeed', 0, 10, 0.05, 'ash speed'], + ['ashSpread', 0, 1, 0.01, 'ash spread'], + ['ashLifetime', 0.1, 8, 0.05, 'ash lifetime'], + ['ashGravity', -12, 4, 0.05, 'ash gravity'], + ['ashSpin', 0, 20, 0.1, 'ash tumble'], + ['ashTurbulence', 0, 4, 0.01, 'ash turbulence'], + ['smokeRate', 0, 300, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['colorAsh*', 'Ash colour'], + ['colorSmoke*', 'Smoke colour'] + ], + 'Burns on the ground': [ + ['scorchRate', 0, 40, 0.1, 'marks / second'], + ['scorchRadius', 0.05, 4, 0.05, 'mark radius'], + ['scorchLife', 0.5, 20, 0.1, 'mark lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'mark intensity'], + ['colorScorch', 'scorch'], + ['colorScorchEmber', 'scorch embers'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightFlicker', 0, 1, 0.01, 'ember gutter'], + ['lightFlickerSpeed', 1, 60, 1, 'gutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/gearlock.js b/src/config/abilities/gearlock.js new file mode 100644 index 0000000..0d8c716 --- /dev/null +++ b/src/config/abilities/gearlock.js @@ -0,0 +1,427 @@ +/* ================================================================== */ +/* GEARLOCK — forge · far cast */ +/* ================================================================== */ +/** + * A train of machined spur gears winds up out of the floor inside the aimed + * circle, meshes, spins up, and then **locks** — the whole train seizes at once + * with a shudder and a sheet of sparks. + * + * ## THE TRICK — the teeth actually mesh + * + * Every gear's angular rate is `−(z_prev / z) · ω_prev`, and — the half + * everybody forgets — its absolute *phase* is solved against its neighbour so + * that a tooth of one is aimed at a gap of the other along the line of centres. + * Rate alone gives you a train that counter-rotates beautifully and grinds + * straight through itself. `vfx/HardSurface.js` §6 does the arithmetic; this + * block supplies the numbers, and the reason none of them is baked at spawn is + * so that dragging `teethA/B/C` or `moduleFrac` **with the clock stopped** + * re-teeths, re-spaces and re-phases the standing train in the same frame, + * still meshed. + * + * ## Why the module is a fraction here and metres in the solver + * + * A gear's *module* is millimetres of pitch diameter per tooth, and it is the + * one number that decides whether two gears mesh: same module, and the standard + * centre distance `m(z₁+z₂)/2` puts their pitch circles exactly in contact. + * `GearTrain` takes it in metres. This block stores it as a fraction of + * `zoneRadius` instead, because the aim circle is a promise about how much + * floor the cast is going to take and a train whose size ignored it would break + * that promise. The metre is produced inside the update loop, every frame: + * `module = moduleFrac × zoneRadius`. + * + * ## `addendum` appears once and is used twice + * + * It is the tooth's height above the pitch circle, in modules, and it goes both + * into the *profile* (`gearShape`) and into the *spacing* (`GearTrain`). Two + * sliders here would be two ways to say the same thing and one way to make the + * teeth miss, so there is one and it feeds both. + */ +export const gearlock = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 46.0, // how fast the front runs out to the circle, metres/second + zoneRadius: 5.2, // the aimed circle, metres — also the train's own scale + lifetime: 2.2, // seconds the train runs at speed before it locks + fadeTime: 1.6, // seconds the seize and the sink-back take + cooldown: 1.2, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the train --- */ + gearCount: 7, // gears in the chain, 2..12 (GearTrain's capacity) + teethA: 11, // the three tooth counts the train draws from. THE ratios — + teethB: 17, // drag them while paused and the whole train re-meshes + teethC: 26, // around the new numbers + moduleFrac: 0.0165, // module as a fraction of zoneRadius (see the header) + addendum: 1.0, // × module, tooth height above the pitch circle — profile AND spacing + bearingSpread: 0.115, // ± turns each link may wander off the previous bearing + bearingBias: 0.045, // turns added to every link — positive curls the train round + spin: 2.35, // radians/second of the root gear; negative runs it backwards + phase: 0.0, // turns of the root gear at the moment it lands + lift: 0.035, // metres the gears float above the floor + + /* --- the gear profile (unitless proportions; see `gearShape()`) --- */ + pressureAngle: 20.0, // degrees; 20 is the modern standard, 14.5 the old one + dedendum: 1.25, // × module below the pitch circle — the 0.25 is root clearance + backlash: 0.045, // 0..1 of the tooth thickness taken off both flanks + rootFillet: 0.5, // 0..1 of the root gap blended into the flank + flankSteps: 5, // involute samples per flank; 3 is visibly polygonal + tipSteps: 2, // arc samples across the tooth tip + rootSteps: 2, // arc samples along the root circle between teeth + thickness: 0.2, // face to face, as a fraction of the gear's own diameter + chamfer: 0.02, // 45° break on both faces, same units — the highlight catcher + bore: 0.3, // the axle hole, as a fraction of the outer radius + boreSegments: 26, // facets round the bore + boreChamfer: 0.014, // countersink on the bore, in unit lengths + lightenHoles: 5, // lightening holes; 0 for a solid blank + lightenRadius: 0.13, // one hole's radius, fraction of the outer radius + lightenRing: 0.58, // the circle they sit on, fraction of the outer radius + lightenSegments: 14, // facets round one hole + creaseAngle: 30.0, // degrees; above this a joint shades hard and counts as an edge + + /* --- coming up out of the floor --- */ + riseTime: 0.34, // seconds one gear takes to break the surface + riseStagger: 0.075, // seconds between one gear starting and the next + riseDepth: 0.22, // extra metres below the floor a gear starts from + + /* --- the clock --- */ + // Nothing here is integrated. `tau(t)` is a closed-form warp of the ability's + // own age, so a paused train re-phases under every one of these. + spinUp: 0.45, // seconds of exponential run-up before the rate is nominal + seizeTime: 0.34, // seconds the seize takes to bring the rate to zero + shudder: 0.055, // seconds of clock wobble at the lock — × spin = radians + shudderRate: 13.0, // wobbles/second + shudderDecay: 0.4, // seconds the wobble decays over + + /* --- friction heat --- */ + // Written per instance into `aHeat`, so a small gear spinning fast comes up + // hotter than the big slow one it is driving, off one shared material. + heatGain: 0.34, // 0..1 heat at the reference rim speed + heatSpeed: 9.0, // metres/second of rim speed at which `heatGain` is reached + seizeHeat: 0.42, // extra heat dumped in at the lock + seizeHeatDecay: 0.9, // seconds that extra heat bleeds off over + + /* --- the steel --- */ + // `hardSurfaceParams()` by name. There is deliberately no hot-colour picker: + // the blackbody ramp is on the real Planckian locus and nobody gets to place + // the yellow. See `vfx/HardSurface.js` §7. + colorMetal: '#96a0aa', // clean steel + colorDeep: '#363c44', // the bottom of a casting pit + colorScale: '#2a2622', // mill scale, the blue-black oxide off the forge + colorPolish: '#e8eef6', // a worn edge, where the file has been + colorSpec: '#fff2e0', // the anisotropic highlight's own colour + roughness: 0.34, // base, before grain / pitting / wear + metalness: 0.95, + envIntensity: 1.0, // HDR probe gain + brush: 1, // BrushMode: 0 LINEAR, 1 CIRCUMFERENTIAL, 2 RADIAL — a gear is turned + anisotropy: 0.82, // 0 round highlight, 1 fully smeared along the grain + specular: 1.6, // gain on the anisotropic lobe + grain: 0.6, // how hard the brushing cuts into roughness + grainScale: 96.0, // grain frequency, cycles per unit of local space + grainStretch: 30.0, // how far a streak runs along the brush direction + scale: 0.24, // mill scale coverage, 0..1 + scaleScale: 7.0, // its patch size + scaleSharp: 0.6, // 0 a smear, 1 a hard flake edge + pit: 0.3, // casting pits and corrosion + pitScale: 58.0, + wear: 0.66, // how bright the machined edges come up + wearGrain: 0.42, // how much the grain breaks that wear up + heatCold: 300.0, // kelvin at heat = 0 — a cold workshop + heatHot: 1500.0, // kelvin at heat = 1 — forging heat; a gear never gets past it + heatRef: 1250.0, // kelvin at which the emission term reaches 1 + heatExponent: 4.0, // Stefan-Boltzmann; 4 is the physical value + heatGlow: 2.2, // gain on the emission + heatTint: 0.8, // how far the albedo washes toward the hot colour + heatEdge: 0.24, // how much cooler an edge is — thin sections radiate faster + + /* --- the bed plate (GroundField, LATTICE) --- */ + fieldEdge: 0.5, // metres of feather on the front + fieldRagged: 0.1, // how far the front wanders, fraction of the radius + fieldRaggedScale: 0.9, // lobes per metre + fieldWarp: 0.2, // metres of domain warp on those lobes + fieldRelief: 0.7, // how hard the height field tilts the fake normal + fieldCell: 0.62, // metres — the lattice pitch + fieldCellJitter: 0.12, // 0..1; a machined bed is nearly regular + fieldSeam: 0.045, // metres of gap between cells + fieldThickness: 0.05, // metres — the rib + fieldLift: 0.05, // metres the ribs stand proud + fieldDepth: 0.14, // metres the recesses drop + fieldSharp: 0.8, // 0..1 — machined, so hard + fieldDetail: 0.35, // 0..1 fine breakup + fieldSpeed: 2.6, // cells per second the lattice propagates along its edges + fieldParallax: 0.25, // metres of view-driven offset on the interior + fieldOpacity: 0.85, + fieldEmissive: 1.0, // multiplier on the glowing terms + colorFieldBase: '#5e6670', // the plate itself + colorFieldEdge: '#aeb9c4', // rib crowns + colorFieldGlow: '#ff9c46', // anything emissive — the heat in the recesses + colorFieldDeep: '#14171b', // the recesses + + /* --- the grind at the tooth contacts --- */ + // At the pitch point the two flanks are in pure rolling and the sliding speed + // is exactly zero, which is why the jets are struck a little either side of + // it — see `_grindAt()`. + slideOffset: 0.42, // metres along the common tangent from the pitch point + slideRef: 7.0, // metres/second of sliding at which the sparks run flat out + grindLift: 0.06, // metres the jets start off the surface + grindBounce: 0.45, // 0..1 of the into-surface velocity that comes back out + grindRise: 0.38, // 0..1 extra tilt away from the floor — the rooster tail + grindSpeedGain: 0.5, // spark speed as a fraction of the sliding speed + grindSpeedFloor: 1.4, // metres/second, so a stalled train still ticks over + grindSpeedCeiling: 22.0, // metres/second + grindFan: 0.7, // radians the jets fan through, in the tangent/normal plane + grindSwing: 0.24, // radians of fan across it + grindGraze: 0.05, // sine of the shallowest angle a jet may leave at + grindJets: 4, // sub-directions per contact per frame + grindSpread: 0.11, // handed to the particle system, per jet + grindVariance: 0.5, // ditto + grindDrift: 0.14, // fraction of the sliding velocity added as `inherit` + + /* --- sparks, swarf and dust --- */ + /** + * Four-stop lifetime gradients, `A` at birth through `D` as the particle + * dies. Spelled out rather than derived from the steel, because a spark is a + * burning chip of iron and cools on its own schedule. + */ + sparkRate: 260, // sparks/second at `slideRef` sliding speed + sparkSize: 0.13, + sparkLifetime: 0.5, + sparkGravity: -15.0, + sparkStretch: 0.22, // how far a spark smears along its velocity + colorSparkA: '#fff6e2', + colorSparkB: '#ffc44a', + colorSparkC: '#ff7a22', + colorSparkD: '#5d1a05', + swarfRate: 34, // machined chips spat out of the mesh, particles/second + swarfSize: 0.05, + swarfSpeed: 4.4, + swarfLifetime: 1.3, + swarfGravity: -19.0, + colorSwarfA: '#c9b49a', + colorSwarfB: '#8a7a68', + colorSwarfC: '#4a443e', + colorSwarfD: '#2a2724', + dustRate: 46, // floor dust as a gear breaks the surface, particles/second + dustSize: 0.9, + dustSpeed: 1.2, + dustLifetime: 2.0, + dustOpacity: 0.16, + dustRise: 0.5, // upward drift, metres/second + dustTurbulence: 0.6, + colorDustA: '#8d8478', + colorDustB: '#6e675d', + colorDustC: '#4c473f', + colorDustD: '#2c2925', + + /* --- the lock --- */ + lockSparks: 320, // extra sparks thrown at every contact when it seizes + lockShake: 0.85, + shakeDuration: 0.6, + lockFlash: 0.14, // full-screen flash on the seize + colorFlash: '#ffcf9a', + burstSize: 2.1, // the pressure shell at the seize, metres + burstIntensity: 1.2, + colorBurstA: '#6a4a34', + colorBurstB: '#ffae5c', + colorBurstC: '#fff0d2', + rumble: 0.035, // continuous shake while the train is running + + /* --- dynamic light --- */ + lightIntensity: 12.0, + lightRadius: 13.0, + lightColor: '#ff9a4e' // warm workshop light off the hot mesh points +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Gearlock. + * + * Reach for **`teethA/B/C`** and **`moduleFrac`** first, with the clock stopped + * — they are the ability. Every other folder dresses a train that is already + * correct; those four decide whether it is a train at all, and watching the + * whole thing re-space and re-phase around a new ratio while nothing is moving + * is the demonstration this slot exists to give. + * + * If a tooth ever appears to pass through its neighbour, it is one of exactly + * two things and never anything else: `addendum` disagreeing with itself (it + * cannot — there is one slider, which is the point), or `dedendum` wound so far + * down that the root circle has eaten the flank, which `gearRadii()` clamps but + * cannot make pretty. Everything else is spacing, and spacing comes from the + * pitch radii, which come from the module. + */ +export const gearlockSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'front speed'], + ['zoneRadius', 1.5, 14, 0.1, 'footprint radius'], + ['lifetime', 0.2, 8, 0.05, 'run time'], + ['fadeTime', 0.2, 6, 0.05, 'seize + sink'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The train (the ratios)': [ + ['gearCount', 2, 12, 1, 'gears'], + ['teethA', 6, 40, 1, 'tooth count A'], + ['teethB', 6, 40, 1, 'tooth count B'], + ['teethC', 6, 40, 1, 'tooth count C'], + ['moduleFrac', 0.004, 0.06, 0.0005, 'module ÷ radius'], + ['addendum', 0.4, 1.6, 0.01, 'addendum ×module'], + ['bearingSpread', 0, 0.4, 0.005, 'bearing wander (turns)'], + ['bearingBias', -0.25, 0.25, 0.005, 'bearing bias (turns)'], + ['spin', -8, 8, 0.01, 'root spin (rad/s)'], + ['phase', 0, 1, 0.005, 'root phase (turns)'], + ['lift', 0, 0.4, 0.005, 'float above floor (m)'] + ], + 'The gear profile': [ + ['pressureAngle', 8, 32, 0.5, 'pressure angle (°)'], + ['dedendum', 0.6, 2, 0.01, 'dedendum ×module'], + ['backlash', 0, 0.3, 0.005, 'backlash'], + ['rootFillet', 0, 1, 0.01, 'root fillet'], + ['flankSteps', 2, 10, 1, 'flank samples'], + ['tipSteps', 1, 6, 1, 'tip samples'], + ['rootSteps', 1, 6, 1, 'root samples'], + ['thickness', 0.05, 0.6, 0.005, 'face width ÷diameter'], + ['chamfer', 0, 0.06, 0.001, 'face chamfer'], + ['bore', 0, 0.6, 0.005, 'bore ÷radius'], + ['boreSegments', 8, 48, 1, 'bore facets'], + ['boreChamfer', 0, 0.05, 0.001, 'bore countersink'], + ['lightenHoles', 0, 8, 1, 'lightening holes'], + ['lightenRadius', 0.02, 0.3, 0.005, 'hole radius ÷radius'], + ['lightenRing', 0.2, 0.85, 0.005, 'hole circle ÷radius'], + ['lightenSegments', 6, 32, 1, 'hole facets'], + ['creaseAngle', 5, 80, 1, 'crease angle (°)'] + ], + 'Coming up out of the floor': [ + ['riseTime', 0.05, 2, 0.01, 'rise time (s)'], + ['riseStagger', 0, 0.6, 0.005, 'stagger (s)'], + ['riseDepth', 0, 1.5, 0.01, 'start depth (m)'] + ], + 'The clock': [ + ['spinUp', 0.02, 3, 0.01, 'run-up (s)'], + ['seizeTime', 0.02, 3, 0.01, 'seize (s)'], + ['shudder', 0, 0.4, 0.001, 'clock shudder (s)'], + ['shudderRate', 1, 40, 0.5, 'shudder rate (Hz)'], + ['shudderDecay', 0.05, 3, 0.01, 'shudder decay (s)'] + ], + 'Friction heat': [ + ['heatGain', 0, 1, 0.01, 'heat at ref speed'], + ['heatSpeed', 0.5, 30, 0.1, 'ref rim speed (m/s)'], + ['seizeHeat', 0, 1, 0.01, 'heat at the lock'], + ['seizeHeatDecay', 0.05, 4, 0.01, 'lock heat decay (s)'] + ], + 'The steel': [ + ['colorMetal', 'steel'], + ['colorDeep', 'pit bottom'], + ['colorScale', 'mill scale'], + ['colorPolish', 'worn edge'], + ['colorSpec', 'highlight'], + ['roughness', 0.02, 1, 0.005, 'roughness'], + ['metalness', 0, 1, 0.005, 'metalness'], + ['envIntensity', 0, 3, 0.01, 'probe gain'], + ['brush', 0, 2, 1, 'brush mode'], + ['anisotropy', 0, 1, 0.01, 'anisotropy'], + ['specular', 0, 6, 0.01, 'specular gain'], + ['grain', 0, 2, 0.01, 'grain depth'], + ['grainScale', 4, 300, 1, 'grain frequency'], + ['grainStretch', 1, 120, 1, 'grain stretch'], + ['scale', 0, 1, 0.01, 'mill scale'], + ['scaleScale', 0.5, 30, 0.1, 'scale patch size'], + ['scaleSharp', 0, 1, 0.01, 'scale edge'], + ['pit', 0, 1, 0.01, 'pitting'], + ['pitScale', 4, 200, 1, 'pit frequency'], + ['wear', 0, 1, 0.01, 'edge wear'], + ['wearGrain', 0, 1, 0.01, 'wear breakup'] + ], + 'The blackbody ramp': [ + ['heatCold', 200, 900, 5, 'cold (K)'], + ['heatHot', 900, 2400, 5, 'hot (K)'], + ['heatRef', 400, 2400, 5, 'emission ref (K)'], + ['heatExponent', 1, 8, 0.1, 'emission exponent'], + ['heatGlow', 0, 8, 0.01, 'emission gain'], + ['heatTint', 0, 1, 0.01, 'albedo wash'], + ['heatEdge', 0, 1, 0.01, 'edge cooling'] + ], + 'The bed plate': [ + ['fieldEdge', 0.02, 3, 0.01, 'front feather (m)'], + ['fieldRagged', 0, 1, 0.01, 'front wander'], + ['fieldRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['fieldWarp', 0, 3, 0.01, 'domain warp (m)'], + ['fieldRelief', 0, 2, 0.01, 'relief'], + ['fieldCell', 0.1, 3, 0.01, 'lattice pitch (m)'], + ['fieldCellJitter', 0, 1, 0.01, 'cell jitter'], + ['fieldSeam', 0, 0.4, 0.005, 'seam (m)'], + ['fieldThickness', 0.005, 0.5, 0.005, 'rib width (m)'], + ['fieldLift', 0, 0.6, 0.005, 'rib height (m)'], + ['fieldDepth', 0, 1, 0.005, 'recess depth (m)'], + ['fieldSharp', 0, 1, 0.01, 'edge hardness'], + ['fieldDetail', 0, 1, 0.01, 'fine detail'], + ['fieldSpeed', 0, 12, 0.05, 'propagation (cells/s)'], + ['fieldParallax', 0, 1.5, 0.01, 'parallax (m)'], + ['fieldOpacity', 0, 1.5, 0.01, 'opacity'], + ['fieldEmissive', 0, 4, 0.01, 'emissive'], + ['colorFieldBase', 'plate'], + ['colorFieldEdge', 'rib crown'], + ['colorFieldGlow', 'recess glow'], + ['colorFieldDeep', 'recess'] + ], + 'The grind': [ + ['slideOffset', 0, 2, 0.01, 'offset from pitch point (m)'], + ['slideRef', 0.5, 30, 0.1, 'ref sliding speed (m/s)'], + ['grindLift', 0, 0.5, 0.005, 'jet lift (m)'], + ['grindBounce', 0, 1, 0.01, 'restitution'], + ['grindRise', 0, 1, 0.01, 'rooster tail'], + ['grindSpeedGain', 0, 2, 0.01, 'speed ÷ sliding'], + ['grindSpeedFloor', 0, 10, 0.1, 'speed floor (m/s)'], + ['grindSpeedCeiling', 1, 60, 0.5, 'speed ceiling (m/s)'], + ['grindFan', 0, 3, 0.01, 'fan (rad)'], + ['grindSwing', 0, 2, 0.01, 'swing (rad)'], + ['grindGraze', 0, 0.6, 0.005, 'grazing floor'], + ['grindJets', 1, 8, 1, 'jets / contact'], + ['grindSpread', 0, 1, 0.01, 'jet spread'], + ['grindVariance', 0, 2, 0.01, 'speed variance'], + ['grindDrift', 0, 1, 0.01, 'inherited drift'] + ], + 'Sparks, swarf & dust': [ + ['sparkRate', 0, 1200, 1, 'spark rate'], + ['sparkSize', 0.005, 0.6, 0.005, 'spark size'], + ['sparkLifetime', 0.05, 3, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['swarfRate', 0, 300, 1, 'swarf rate'], + ['swarfSize', 0.005, 0.4, 0.005, 'swarf size'], + ['swarfSpeed', 0, 20, 0.1, 'swarf speed'], + ['swarfLifetime', 0.1, 5, 0.05, 'swarf lifetime'], + ['swarfGravity', -50, 0, 0.1, 'swarf gravity'], + ['dustRate', 0, 400, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 8, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['colorSpark*', 'Spark colour'], + ['colorSwarf*', 'Swarf colour'], + ['colorDust*', 'Dust colour'] + ], + 'The lock': [ + ['lockSparks', 0, 900, 1, 'seize sparks'], + ['lockShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['lockFlash', 0, 2, 0.01, 'screen flash'], + ['burstSize', 0.1, 10, 0.05, 'shell size'], + ['burstIntensity', 0, 5, 0.01, 'shell intensity'], + ['rumble', 0, 0.4, 0.005, 'running rumble'], + ['colorFlash', 'flash colour'], + ['colorBurstA', 'shell'], + ['colorBurstB', 'shell body'], + ['colorBurstC', 'shell filaments'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/geyser.js b/src/config/abilities/geyser.js new file mode 100644 index 0000000..0b8437f --- /dev/null +++ b/src/config/abilities/geyser.js @@ -0,0 +1,590 @@ +/* ================================================================== */ +/* GEYSER — Geyser */ +/* ================================================================== */ +/** + * A vent opens on the aimed circle, a column of water stands out of it, and + * when the pressure fails the column comes back down as rain. + * + * **The block is arranged around one identity: the rain is the column.** Every + * number the droplets fly on is *derived* from the column's own geometry and + * its own speed, resolved on the frame it is used: + * + * - they are born at `jetHeight × seedAt` metres, which is where + * `Tube.pointAt(seedAt)` puts the column's axis; + * - they are scattered over `Tube.radiusAt(seedAt)` metres, which is how wide + * the column *is* at that height — so widening `jetMouthFlare` widens the + * cloud they leave from; + * - they carry `columnSpeed × pressure` metres per second of upward velocity, + * so they climb `v²/2g` further before they turn over; + * - they carry `2π × jetSpin × radiusAt(seedAt) × swirlCarry` metres per + * second of tangential velocity, so the ring they land in is the swirl the + * column had, thrown outward for the whole of the fall. + * + * There is **no `rainRadius` slider and no `dropFlightTime` slider**, and there + * must not be. Both are computed — the ring from the inherited speed and the + * fall time from `sqrt(2h/g)` — because the first version of this block had + * them as their own numbers and they were never the same as the column twice: + * every time the plume was retuned the rain landed either inside the pool or + * out in dry floor, and nobody could see why. `gravity` is the slider they are + * both a function of, and it is the one to reach for. + * + * **The funnel is inverted, and that is a profile rather than a transform.** + * `vfx/Tube.js` in `FUNNEL` mode is written for a tornado: a wide intake mouth + * at the top, a waist, and a skirt flaring out where it touches the floor. A + * geyser is that silhouette upside down, so `jetSkirtFlare` is tiny (a tight + * collar around the vent bore) and `jetMouthFlare` is large with a low + * `jetMouthStart` (a trumpet opening almost from the ground). Everything else + * about the module then works in the ability's favour without being fought: + * `jetSwayCurve` above 1 weights the precession to the *top*, so the head of + * the plume whips while the vent stays planted, which is exactly right. + * Rotating the tube end-for-end instead would have put the sway at the vent. + */ +import { TubePath, tubeDefaults, tubeSchema } from '../../vfx/Tube.js'; + +export const geyser = { + /* --- the cast --- */ + range: 21.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + speed: 34.0, // how fast the cast reaches the circle, metres/second + zoneRadius: 6.0, // the vent pool, metres — the rain lands inside it + lifetime: 3.4, // seconds from the vent opening to the end of the beat + fadeTime: 2.6, // seconds the pool takes to settle once the rain is down + cooldown: 2.2, // seconds before the slot re-arms + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats --- */ + preFill: 0.35, // 0..1 of the pool that has welled up before the vent blows + ventRise: 0.4, // seconds the pool takes to open out once it has + chargeTime: 0.4, // seconds the column takes to reach full height + holdTime: 0.7, // seconds it holds at full pressure before it fails + collapseTime: 0.9, // seconds the column takes to fall back into the vent + surge: 0.12, // ± fraction the pressure breathes by while it holds + surgeRate: 2.3, // Hz it breathes at + + /* --- the column --- */ + jetHeight: 7.6, // metres from the pool surface to the head of the plume + jetBase: 0.02, // metres the foot floats above the pool surface + jetTaper: 0.22, // width multiplier at the end of the collapse, 0..1 + lightRide: 0.3, // where on the column the dynamic light sits, 0..1 + lightHeight: 0.0, // extra metres above that + + /* ------------------------------------------------------------------ */ + /* The column — vfx/Tube.js, prefix `jet`, path FUNNEL (inverted) */ + /* ------------------------------------------------------------------ */ + /** + * `jetThroat + jetSkirtFlare` is the vent bore (0.70 m shipped) and + * `jetThroat + jetMouthFlare` is the head of the plume (2.32 m). The ratio of + * those two sums *is* the inversion; make them equal and this is a pipe. + * + * `jetThrob` is deliberately small. It is a real pressure pulse and it moves + * `radiusAt()`, which the rain's launch scatter and landing ring are both + * read off — so at the shipped 0.035 the ring breathes by about eleven + * centimetres out of four and a half metres and nobody sees it, and at 0.2 + * ninety-six drops visibly pump in and out together. That lockstep is the + * tell; the pulse itself is fine. + */ + ...tubeDefaults('jet', TubePath.FUNNEL, { + jetThroat: 0.42, // the bore, metres + jetSkirtFlare: 0.28, // the collar at the vent, metres — small: this is the inversion + jetSkirtHeight: 0.12, // how far up that collar reaches, fraction of height + jetSkirtCurve: 2.4, // >1 keeps it tight to the vent + jetMouthFlare: 1.9, // the plume at the top, metres + jetMouthStart: 0.18, // it opens almost from the ground — a trumpet, not a funnel + jetMouthCurve: 1.15, // how abruptly it opens + jetSpin: 0.45, // revolutions/second the surface rotates — the swirl the rain inherits + jetSpinTwist: 0.9, // extra revolutions from vent to head + jetSway: 0.28, // how far the axis precesses, metres + jetSwayScale: 0.35, // twist of the precession along the height + jetSwaySpeed: 0.22, // revolutions/second it precesses + jetSwayCurve: 2.4, // >1 whips the head and plants the vent + jetThrob: 0.035, // pressure pulses, × radius — small, and see the note below + jetThrobScale: 3.2, // waves along the column, cycles per length + jetThrobSpeed: 2.4, // Hz they travel at + jetWander: 0.06, // smooth drift of the axis, metres + jetWanderScale: 1.1, + jetWanderSpeed: 0.7, + jetRipple: 0.2, // radial break-up of the barrel, × radius + jetRippleBands: 1.8, + jetRippleScale: 3.0, + jetRippleSpeed: 2.0, + jetStreak: 1.1, // filaments streaming up the surface + jetStreakSharp: 0.55, + jetStreakScale: 4.5, + jetStreakBands: 3.0, + jetStreakGlow: 1.3, + jetFlowSpeed: 6.5, // positive: the surface runs UP, which is the whole read + jetCoreWidth: 0.7, // a column of water is nearly all body + jetCoreFill: 0.35, + jetSheathWidth: 1.0, + jetSheathRim: 1.0, + jetHaloWidth: 1.35, + jetHaloRim: 2.4, + jetHaloOpacity: 0.45, + jetMuzzleGlow: 2.0, // the vent is the brightest part of it + jetMuzzleLength: 0.14, + jetTipGlow: 1.4, + jetTipLength: 0.12, + jetGlow: 1.1, + jetSoftFade: 0.5, + jetColorCore: '#ffffff', + jetColorInner: '#d8f8ff', + jetColorOuter: '#4fc4dc', + jetColorHalo: '#0d3a4e' + }), + + /* ------------------------------------------------------------------ */ + /* The rain — the column, in the air */ + /* ------------------------------------------------------------------ */ + droplets: 96, // bodies seeded when the pressure fails (capped at 128) + seedAt: 0.8, // 0..1 up the column — where the water tears off + gravity: 9.81, // metres/second². Drives the apex, the fall time and the ring. + columnSpeed: 5.0, // metres/second the water is going up at full pressure + swirlCarry: 0.22, // 0..1 of the column's tangential speed that survives the tear + spreadSpeed: 0.3, // metres/second of extra outward push at the head + dropRadius: 0.085, // metres + dropSizeJitter: 0.4, // ± fraction + dropStretch: 1.8, // scale along the heading — a falling drop is not a sphere + // At 1.0 the alignment is total — `Projectile` slerps the tumble all the way + // to the heading, which is right for a drop (a falling drop does not tumble, + // it points where it is going) and which makes `dropSpin` below inert. Back + // `dropAlign` off to about 0.8 and the tumble comes back. + dropAlign: 1.0, // 1 lays the body's +Y along its own heading + dropSpin: 1.2, // radians/second of tumble — only visible below dropAlign 1 + dropFlash: 0.16, // seconds the birth pop decays over + dropPathCurve: 1.0, // 1 is constant horizontal speed, which is what ballistics does + dropApexCurve: 0.85, // <1 rounds the top of the arc toward a parabola + dropWindow: 0.22, // seconds the tear is spread over — short: it lets go at once + dropLead: 0.02, // seconds before the first drop leaves + dropSpeedJitter: 0.18, // ± fraction of the computed fall time + dropFillBias: -0.6, // <0 fills the ring inward from the rim + dropFillScatter: 0.7, // 0 clean radial order, 1 pure spatial hash + dropHashCell: 1.0, // metres of the hash lattice + dropRingBias: 0.28, // <0.5 crowds the landing ring's rim: axis water falls straight down + dropLinger: 0.05, // seconds a landed drop stays before it goes under + dropSink: 1.4, // body radii it sinks over that linger + rippleStrength: 0.9, // strength of the ripple each landing puts in the pool + splashPerDrop: 5, // spray particles thrown at each landing + + /* --- the drop's trail --- */ + trailSpan: 0.18, // seconds of flight the tail reaches back over + trailBurn: 0.14, // seconds the tail takes to catch up after landing + trailWidth: 0.055, // metres at the head + trailTaper: 1.7, // >1 sharpens the tail to a point + trailLift: 0.0, // metres the tail floats above the flown path + trailOpacity: 0.7, + trailGlow: 1.1, + trailCore: 2.4, // how tightly light crowds the centre line + trailHeadBias: 0.55, // >0 keeps the brightness near the body + trailNoise: 0.25, + trailNoiseScale: 2.2, // features per metre + trailNoiseSpeed: 0.8, + trailSoftFade: 0.3, // metres of depth feather + colorTrailA: '#ffffff', + colorTrailB: '#c4f0fa', + colorTrailC: '#3f9fb8', + colorTrailD: '#0d2c38', + + /* --- the drop's body --- */ + dropFacets: 9, // sides around the lathe + dropRings: 7, // levels up it + dropTaper: 0.55, // 0 a sphere, 1 a full teardrop point + dropPinch: 0.28, // how far the trailing end necks in + colorDropClear: '#bfeef6', // the body once the air has come out of it + colorDropFroth: '#ffffff', // the same water, white with entrained air + colorDropSpot: '#eafcff', // the refracted hot spot on the far side + colorDropRim: '#67d4e6', // the silhouette + dropIor: 1.333, // refractive index. Water. Move it and watch the spot slide. + dropSpot: 2.2, // brightness of that refracted point + dropSpotPower: 14, // how tight it is + dropRim: 0.9, + dropRimPower: 2.2, + dropFroth: 0.85, // 0..1 aeration where it tears off + dropFrothFade: 1.3, // exponent on tau — >1 clears late + dropAmbient: 0.35, + dropShade: 0.7, + dropFlashGain: 1.8, + dropGlow: 1.2, + dropOpacity: 1.0, + dropSoftFade: 0.18, // metres of depth feather + + /* ------------------------------------------------------------------ */ + /* The vent pool — vfx/LiquidSurface.js */ + /* ------------------------------------------------------------------ */ + poolHeight: 0.05, // metres the mean plane sits above the floor + poolOpacity: 0.95, + round: 1.0, // 0 rectangular footprint, 1 elliptical + edgeSoft: 0.18, // 0..1 of the field over which the waterline fades + edgeNoise: 0.4, // 0..1 how ragged that line is + edgeScale: 1.2, // cycles per metre of the raggedness + contactFade: 0.28, // metres of soft fade against opaque geometry + + waveAmpA: 0.07, // metres + waveAmpB: 0.042, + waveAmpC: 0.026, + waveAmpD: 0.015, + waveLengthA: 2.9, // metres, crest to crest + waveLengthB: 1.8, + waveLengthC: 1.05, + waveLengthD: 0.58, + waveSpeedA: 1.3, // metres/second + waveSpeedB: 1.0, + waveSpeedC: 0.75, + waveSpeedD: 0.5, + waveAngleA: 0.4, // radians + waveAngleB: 1.5, + waveAngleC: 2.8, + waveAngleD: 4.4, + steepness: 0.5, // 0 sine, 1 Gerstner cusps + chop: 0.03, // metres + chopScale: 2.2, // cycles per metre + chopSpeed: 0.7, // metres/second the field drifts + detail: 0.011, // metres — fragment-only; lives in the normal + detailScale: 8.5, // cycles per metre + detailSpeed: 1.1, // metres/second + + rippleAmp: 0.16, // metres per unit of `rippleStrength` + rippleSpeed: 2.9, // metres/second the front travels + rippleLength: 0.8, // metres, crest to crest inside the packet + rippleWidth: 0.45, // metres of the gaussian envelope + rippleDecay: 1.1, // seconds to 1/e + rippleSpread: 2.8, // metres over which it also thins with radius + + flowAngle: 0.0, // radians, the bulk drift's bearing + flowSpeed: 0.15, // metres/second + flowRadial: 1.4, // metres/second outward at the vent — this is the boil + flowRadialFall: 2.2, // metres to 1/e + flowEddy: 0.9, // metres/second of curl-noise swirl + flowEddyScale: 0.5, // cycles per metre + flowEddySpeed: 0.35, // Hz the eddies churn + flowGravity: 2.4, // metres/second per unit of surface slope + + foam: 0.95, // 0..1 master — a vent pool is mostly foam + foamScale: 7.0, // cycles per metre of the speckle + foamSharp: 1.2, + foamCrest: 1.0, // how much a rising crest seeds it + foamSpeed: 0.9, // how much surface speed seeds it + + poolDepth: 0.4, // metres of liquid under the mean plane + depthTint: 1.5, // Beer-Lambert density, per metre + translucency: 1.0, + ambient: 0.34, + specular: 1.5, + shininess: 80, // Blinn-Phong exponent + fresnel: 1.2, + envIntensity: 0.8, + skyIntensity: 0.5, + poolGlow: 1.0, + normalEps: 0.042, // metres — the finite-difference step + colorDeep: '#08303c', // the body seen through its own thickness + colorShallow: '#2497a8', // the body seen thin + colorFoam: '#eafcfb', + colorSpec: '#ffffff', + colorSky: '#33526a', // the floor under the reflected probe + + /* --- spray: the water shed off the column while it stands ----------- */ + sprayRate: 130, // particles/second up the column + spraySpeed: 4.2, // metres/second + spraySize: 0.085, + sprayLifetime: 1.1, // seconds + sprayGravity: -8.5, // metres/second² + sprayTurbulence: 0.5, + colorSprayA: '#ffffff', + colorSprayB: '#b8eef8', + colorSprayC: '#3f9cb4', + colorSprayD: '#0f2f3c', + + /* --- steam: the boil, and what the vent breathes out ---------------- */ + steamRate: 46, // puffs/second + steamSpeed: 1.6, // metres/second + steamSize: 0.9, + steamLifetime: 2.4, // seconds + steamRise: 1.1, // metres/second² upward + steamOpacity: 0.4, + colorSteamA: '#ffffff', + colorSteamB: '#cfe6ec', + colorSteamC: '#7e9aa6', + colorSteamD: '#2a3a44', + + /* --- grit: what the vent throws up with the water ------------------- */ + gritRate: 22, // chips/second + gritSpeed: 5.5, // metres/second + gritSize: 0.06, + gritLifetime: 1.5, // seconds + gritGravity: -11.0, // metres/second² + colorGritA: '#8f9aa0', + colorGritB: '#6a767e', + colorGritC: '#3e4850', + colorGritD: '#171d22', + + /* --- feedback -------------------------------------------------------- */ + burstSize: 2.4, // metres, the shell of spray as the vent opens + burstIntensity: 1.2, + colorBurstA: '#ffffff', + colorBurstB: '#9fe8f4', + colorBurstC: '#12586e', + shockRadius: 5.2, // metres, the ring across the floor + colorShockA: '#dcfbff', + colorShockB: '#1c7288', + wetMarks: 9, // wet stains left outside the waterline + wetRadius: 1.3, // metres each + wetLife: 6.5, // seconds + wetIntensity: 0.7, + colorWet: '#0d3540', + colorWetEdge: '#5fc4d0', + ventShake: 0.4, // camera punch as the vent opens + shakeDuration: 0.5, // seconds it decays over + rumble: 0.17, // continuous shake while the column stands + ventFlash: 0.22, + colorFlash: '#a4ecf8', + + /* --- the light -------------------------------------------------------- */ + lightColor: '#5fd8ec', + lightIntensity: 6.2, + lightRadius: 15.0 // metres +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * The `jet*` keys come straight out of `tubeSchema`, so the folders the module + * publishes are the folders the panel shows and the pair cannot drift apart. + * The keys belonging to `STRAIGHT`, `WHIP`, `VINE` and `ARC` are inert on this + * path; they are filed together at the bottom rather than hidden, because a key + * the panel cannot reach is a key nobody can rule out. + */ +const jetFolders = tubeSchema('jet', TubePath.FUNNEL); +const jetCovered = new Set( + Object.values(jetFolders) + .flat() + .map((entry) => (Array.isArray(entry) ? entry[0] : entry)) +); +const jetInactive = Object.keys(geyser).filter( + (key) => key.startsWith('jet') && key in tubeDefaults('jet') && !jetCovered.has(key) +); + +export const geyserSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 120, 0.5, 'cast speed'], + ['zoneRadius', 1, 14, 0.1, 'vent pool radius'], + ['lifetime', 0.4, 10, 0.05, 'vent duration'], + ['fadeTime', 0.2, 8, 0.05, 'settle duration'], + ['cooldown', 0, 10, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['preFill', 0, 1, 0.01, 'pool before the vent blows'], + ['ventRise', 0.02, 3, 0.01, 'pool swell'], + ['chargeTime', 0.02, 3, 0.01, 'column rise'], + ['holdTime', 0.02, 6, 0.01, 'full pressure hold'], + ['collapseTime', 0.05, 5, 0.01, 'column collapse'], + ['surge', 0, 0.6, 0.005, 'pressure breathing'], + ['surgeRate', 0, 10, 0.01, 'breathing Hz'] + ], + 'The plume': [ + ['jetHeight', 1, 30, 0.1, 'column height (m)'], + ['jetBase', 0, 1, 0.005, 'foot above the pool (m)'], + ['jetTaper', 0.01, 1, 0.01, 'width at collapse'], + ['lightRide', 0, 1, 0.01, 'light height on the column'], + ['lightHeight', -2, 6, 0.05, 'extra light height (m)'] + ], + ...jetFolders, + 'Prefixed keys this mode does not read': jetInactive, + 'The rain/Ballistics': [ + ['droplets', 1, 128, 1, 'drops'], + ['seedAt', 0.05, 1, 0.01, 'where it tears off'], + ['gravity', 0.5, 30, 0.05, 'gravity (m/s²)'], + ['columnSpeed', 0, 30, 0.1, 'column speed (m/s)'], + ['swirlCarry', 0, 2, 0.01, 'swirl carried'], + ['spreadSpeed', 0, 8, 0.05, 'outward push (m/s)'], + ['dropWindow', 0.01, 2, 0.005, 'tear window'], + ['dropLead', 0, 1, 0.005, 'lead-in'], + ['dropSpeedJitter', 0, 1, 0.01, 'fall-time jitter'], + ['dropPathCurve', 0.2, 3, 0.01, 'horizontal easing'], + ['dropApexCurve', 0.1, 3, 0.01, 'arc roundness'], + ['dropFillBias', -1, 1, 0.01, 'ring fill order'], + ['dropFillScatter', 0, 1, 0.01, 'fill scatter'], + ['dropHashCell', 0.1, 6, 0.05, 'hash cell (m)'], + ['dropRingBias', 0.05, 1.5, 0.01, 'ring crowding'], + ['dropLinger', 0, 2, 0.01, 'linger on landing'], + ['dropSink', 0, 6, 0.05, 'sink depth'], + ['rippleStrength', 0, 4, 0.01, 'ripple per landing'], + ['splashPerDrop', 0, 60, 1, 'spray per landing'] + ], + 'The rain/Body': [ + ['dropRadius', 0.01, 0.5, 0.002, 'radius (m)'], + ['dropSizeJitter', 0, 1, 0.01, 'size jitter'], + ['dropStretch', 0.5, 5, 0.01, 'stretch along heading'], + ['dropAlign', 0, 1, 0.01, 'align to heading'], + ['dropSpin', 0, 20, 0.05, 'tumble'], + ['dropFlash', 0.01, 1, 0.005, 'birth pop'], + ['dropFacets', 5, 20, 1, 'sides'], + ['dropRings', 3, 16, 1, 'levels'], + ['dropTaper', 0, 1, 0.01, 'teardrop'], + ['dropPinch', 0, 0.9, 0.01, 'trailing neck'] + ], + 'The rain/Shading': [ + ['colorDropClear', 'clear body'], + ['colorDropFroth', 'aerated body'], + ['colorDropSpot', 'refracted spot'], + ['colorDropRim', 'silhouette'], + ['dropIor', 1.01, 2.5, 0.001, 'refractive index'], + ['dropSpot', 0, 8, 0.01, 'spot brightness'], + ['dropSpotPower', 1, 60, 0.5, 'spot tightness'], + ['dropRim', 0, 4, 0.01, 'rim'], + ['dropRimPower', 0.2, 8, 0.05, 'rim power'], + ['dropFroth', 0, 1, 0.01, 'aeration'], + ['dropFrothFade', 0.05, 4, 0.01, 'aeration clears'], + ['dropAmbient', 0, 1, 0.01, 'ambient'], + ['dropShade', 0, 2, 0.01, 'key light'], + ['dropFlashGain', 0, 6, 0.01, 'birth flash'], + ['dropGlow', 0, 4, 0.01, 'glow'], + ['dropOpacity', 0, 1, 0.01, 'opacity'], + ['dropSoftFade', 0.01, 2, 0.01, 'soft intersection'] + ], + 'The rain/Trail': [ + ['trailSpan', 0.01, 2, 0.005, 'tail reach (s)'], + ['trailBurn', 0.01, 2, 0.005, 'tail catch-up (s)'], + ['trailWidth', 0.005, 0.6, 0.002, 'width (m)'], + ['trailTaper', 0.2, 5, 0.01, 'taper'], + ['trailLift', -0.5, 0.5, 0.005, 'lift (m)'], + ['trailOpacity', 0, 1, 0.01, 'opacity'], + ['trailGlow', 0, 5, 0.01, 'glow'], + ['trailCore', 0.2, 8, 0.01, 'core tightness'], + ['trailHeadBias', -1, 2, 0.01, 'head bias'], + ['trailNoise', 0, 3, 0.01, 'noise'], + ['trailNoiseScale', 0.1, 10, 0.05, 'noise / metre'], + ['trailNoiseSpeed', 0, 4, 0.01, 'noise speed'], + ['trailSoftFade', 0.01, 2, 0.01, 'soft intersection'], + ['colorTrail*', 'Trail gradient'] + ], + 'The pool/The sheet': [ + ['poolHeight', 0, 0.6, 0.005, 'surface height'], + ['poolOpacity', 0, 1, 0.01, 'opacity'], + ['round', 0, 1, 0.01, 'elliptical footprint'], + ['edgeSoft', 0.01, 0.6, 0.005, 'waterline softness'], + ['edgeNoise', 0, 1, 0.01, 'waterline raggedness'], + ['edgeScale', 0.1, 5, 0.01, 'raggedness / metre'], + ['contactFade', 0.02, 2, 0.01, 'soft intersection'] + ], + 'The pool/The swell': [ + ['waveAmpA', 0, 0.5, 0.002, 'amp A'], + ['waveAmpB', 0, 0.5, 0.002, 'amp B'], + ['waveAmpC', 0, 0.5, 0.002, 'amp C'], + ['waveAmpD', 0, 0.5, 0.002, 'amp D'], + ['waveLengthA', 0.3, 12, 0.05, 'length A'], + ['waveLengthB', 0.3, 12, 0.05, 'length B'], + ['waveLengthC', 0.3, 12, 0.05, 'length C'], + ['waveLengthD', 0.3, 12, 0.05, 'length D'], + ['waveSpeedA', -4, 4, 0.01, 'speed A'], + ['waveSpeedB', -4, 4, 0.01, 'speed B'], + ['waveSpeedC', -4, 4, 0.01, 'speed C'], + ['waveSpeedD', -4, 4, 0.01, 'speed D'], + ['waveAngleA', 0, 6.29, 0.01, 'bearing A'], + ['waveAngleB', 0, 6.29, 0.01, 'bearing B'], + ['waveAngleC', 0, 6.29, 0.01, 'bearing C'], + ['waveAngleD', 0, 6.29, 0.01, 'bearing D'], + ['steepness', 0, 1.2, 0.01, 'gerstner steepness'], + ['chop', 0, 0.3, 0.002, 'chop'], + ['chopScale', 0.1, 8, 0.01, 'chop / metre'], + ['chopSpeed', 0, 4, 0.01, 'chop drift'], + ['detail', 0, 0.1, 0.001, 'normal detail'], + ['detailScale', 0.5, 24, 0.1, 'detail / metre'], + ['detailSpeed', 0, 4, 0.01, 'detail drift'] + ], + 'The pool/Ripples': [ + ['rippleAmp', 0, 1, 0.005, 'ripple height'], + ['rippleSpeed', 0.1, 8, 0.02, 'front speed'], + ['rippleLength', 0.1, 4, 0.01, 'wavelength'], + ['rippleWidth', 0.05, 3, 0.01, 'packet width'], + ['rippleDecay', 0.1, 8, 0.05, 'decay to 1/e'], + ['rippleSpread', 0.2, 12, 0.05, 'radial thinning'] + ], + 'The pool/The flow field': [ + ['flowAngle', 0, 6.29, 0.01, 'drift bearing'], + ['flowSpeed', 0, 4, 0.01, 'drift speed'], + ['flowRadial', 0, 6, 0.01, 'boil at the vent'], + ['flowRadialFall', 0.2, 12, 0.05, 'boil falloff'], + ['flowEddy', 0, 4, 0.01, 'eddy speed'], + ['flowEddyScale', 0.02, 2, 0.01, 'eddies / metre'], + ['flowEddySpeed', 0, 2, 0.01, 'eddy churn'], + ['flowGravity', 0, 8, 0.05, 'downhill flow'] + ], + 'The pool/Foam & shading': [ + ['foam', 0, 1, 0.01, 'foam'], + ['foamScale', 0.5, 20, 0.1, 'speckle / metre'], + ['foamSharp', 0.2, 4, 0.01, 'speckle sharpness'], + ['foamCrest', 0, 3, 0.01, 'seeded by crests'], + ['foamSpeed', 0, 3, 0.01, 'seeded by speed'], + ['poolDepth', 0, 3, 0.01, 'depth under the plane'], + ['depthTint', 0, 6, 0.01, 'beer-lambert density'], + ['translucency', 0, 3, 0.01, 'backlight'], + ['ambient', 0, 1, 0.01, 'ambient'], + ['specular', 0, 4, 0.01, 'specular'], + ['shininess', 4, 256, 1, 'shininess'], + ['fresnel', 0, 3, 0.01, 'fresnel'], + ['envIntensity', 0, 3, 0.01, 'probe reflection'], + ['skyIntensity', 0, 2, 0.01, 'sky floor'], + ['poolGlow', 0, 3, 0.01, 'pool glow'], + ['normalEps', 0.005, 0.2, 0.001, 'normal step'], + ['colorDeep', 'deep'], + ['colorShallow', 'shallow'], + ['colorFoam', 'foam'], + ['colorSpec', 'specular'], + ['colorSky', 'sky floor'] + ], + 'Spray': [ + ['sprayRate', 0, 600, 1, 'per second'], + ['spraySpeed', 0, 16, 0.05, 'speed'], + ['spraySize', 0.01, 0.5, 0.005, 'size'], + ['sprayLifetime', 0.1, 5, 0.02, 'lifetime'], + ['sprayGravity', -30, 10, 0.1, 'gravity'], + ['sprayTurbulence', 0, 3, 0.01, 'turbulence'], + ['colorSpray*', 'Spray gradient'] + ], + 'Steam': [ + ['steamRate', 0, 300, 1, 'per second'], + ['steamSpeed', 0, 8, 0.05, 'speed'], + ['steamSize', 0.05, 4, 0.01, 'size'], + ['steamLifetime', 0.1, 8, 0.02, 'lifetime'], + ['steamRise', -4, 6, 0.05, 'rise'], + ['steamOpacity', 0, 1, 0.01, 'opacity'], + ['colorSteam*', 'Steam gradient'] + ], + 'Grit': [ + ['gritRate', 0, 200, 1, 'per second'], + ['gritSpeed', 0, 20, 0.05, 'speed'], + ['gritSize', 0.01, 0.4, 0.005, 'size'], + ['gritLifetime', 0.1, 5, 0.02, 'lifetime'], + ['gritGravity', -30, 5, 0.1, 'gravity'], + ['colorGrit*', 'Grit gradient'] + ], + 'Feedback': [ + ['burstSize', 0.1, 10, 0.05, 'vent shell'], + ['burstIntensity', 0, 4, 0.01, 'shell glow'], + ['colorBurstA', 'burst core'], + ['colorBurstB', 'burst mid'], + ['colorBurstC', 'burst rim'], + ['shockRadius', 0.5, 14, 0.05, 'shock ring'], + ['colorShockA', 'shock inner'], + ['colorShockB', 'shock outer'], + ['wetMarks', 0, 24, 1, 'wet stains'], + ['wetRadius', 0.1, 5, 0.05, 'stain radius'], + ['wetLife', 0.5, 20, 0.1, 'stain life'], + ['wetIntensity', 0, 3, 0.01, 'stain strength'], + ['colorWet', 'stain'], + ['colorWetEdge', 'stain edge'], + ['ventShake', 0, 2, 0.01, 'vent shake'], + ['shakeDuration', 0.05, 2, 0.01, 'shake decay'], + ['rumble', 0, 1, 0.005, 'running rumble'], + ['ventFlash', 0, 2, 0.01, 'vent flash'], + ['colorFlash', 'vent flash'] + ], + 'The light': [ + ['lightColor', 'colour'], + ['lightIntensity', 0, 30, 0.1, 'intensity'], + ['lightRadius', 1, 40, 0.5, 'radius'] + ] +}; diff --git a/src/config/abilities/glacier.js b/src/config/abilities/glacier.js new file mode 100644 index 0000000..57114f2 --- /dev/null +++ b/src/config/abilities/glacier.js @@ -0,0 +1,571 @@ +/* ================================================================== */ +/* GLACIER — ability six, and the far cast that comes out of the floor */ +/* ================================================================== */ +/** + * A cold front races along the floor to the aimed point, the disc freezes out + * to the boundary the circle drew, and a wall of crystal tears up out of the + * ground around it: a ring of blades leaning outward with a skirt of wreckage + * banked against their feet. It stands, glints, breathes cold off its rim — + * and then breaks into plates and sinks back into the floor. Reference for the + * look: `Hud7Xfg3LH.jpg`. + * + * The **middle stays open**: every shard is seated in a band about + * `zoneRadius` and nothing is planted in the centre, because the read is a + * wall you are looking into and filling the disc stops it being a ring. What + * lives inside it is air and frozen ground. + * + * The second **far cast**, and the counterpart to the Voltaic Snare: same + * circle, same promise, opposite answer. The snare fills the footprint with + * current standing in the air; this one fills it with geometry standing on the + * ground, so `zoneRadius` is again the one number that matters — it is where + * the ring of blades is seated, where the sheet's boundary band burns, where + * the curtain of cold air stands and where the rime creeps. + * + * Three things carry it, and each has its own group below: + * + * - **the sweep.** The ring does not appear; it *closes*. The blade nearest + * the caster goes up first and the wave runs around both sides to meet + * behind the crown (`sweepTime`), with the skirt banking up behind the wave + * (`skirtDelay`, `skirtWave`). + * - **the freeze front.** Every shard crystallises upward along its own axis + * while it rises (`frontRough`, `frontWidth`, `frontGlow` — see + * `materials/GlacierMaterial.js`), so the ice *forms* rather than sliding + * out of a hole. + * - **the shatter.** It leaves the same way it arrived, in pieces: a + * per-shard ramp against a chunk id made of voronoi cells and flat facets, + * so plates and wedges come away one at a time (`shatterScale`, + * `shatterEdge`, `shatterGlow`). + * + * As in every other block, a cast captures nothing but a seed and a handful of + * timestamps. Every metre, radian and second is resolved against these numbers + * each frame — including a zero-length one, which is why the crown reshapes + * under the sliders with the clock stopped. + */ +export const glacier = { + /* --- the cast --- */ + range: 18.0, // maximum cast distance, metres + minRange: 0.0, // a wall of ice around your own feet is a legitimate play + zoneRadius: 4.6, // the footprint — what the circle indicator measures out + speed: 44.0, // how fast the front races to the point, metres/second + snapTime: 0.22, // seconds the sheet takes to freeze out to the boundary + lifetime: 4.2, // seconds the crown stands + shatterDelay: 0.5, // seconds after `lifetime` before the ice starts to break + shatterStagger: 0.45, // seconds of random delay between neighbours + sinkTime: 1.15, // seconds one shard takes to crumble and withdraw + cooldown: 1.6, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the front leaves the caster --- */ + // Thrown from a hand, so these are measured from the caster's origin in the + // cast's own frame, exactly as the bolt, the rock and the leash are. + handHeight: 1.22, // metres above the floor + handForward: 0.6, // metres in front of the caster + handSide: 0.18, // metres to the side (+ follows `Ability#side`) + + /* --- how the footprint is filled --- */ + /** + * Everything is seated in a band about `zoneRadius`; the middle of the + * circle is left empty on purpose, because the read of the ability is a wall + * you are looking *into* and filling the disc stops it being a ring. The + * spire in the middle is kept as a control and ships at zero. + */ + spikeCount: 220, // instances spent on one cast (capped at 320) + density: 1.0, // multiplier on that count + ringShare: 0.6, // fraction of them spent on the wall at the boundary + coreShare: 0.0, // ... on the spire in the middle (0 = the middle stays open) + lateShare: 0.12, // ... held back to push up during the hold + ringSeat: 0.94, // where the wall stands, × zoneRadius + ringScatter: 0.16, // radial jitter of the wall, × zoneRadius + skirtSeat: 0.74, // inner lip of the wreckage banked against it, × zoneRadius + skirtBand: 0.42, // how wide that band is, × zoneRadius + skirtBias: 0.9, // <1 pushes the skirt outward, >1 crowds it inward + coreSpread: 0.16, // radius of the cluster in the middle, × zoneRadius + + /* --- the silhouette --- */ + /** + * The reference is a *starburst*, not a fence: long needles thrown outward + * from the rim at a steep angle, fanned off the radius so they cross, with + * wildly uneven lengths. `ringLean` is the single control that decides + * whether this reads as a crown or a picket line — at 0 it is a fence, and + * the higher it goes the further the blades are thrown out over the floor. + */ + ringHeight: 1.4, // length of a blade on the wall, metres + ringWave: 0.61, // how uneven the crest of that wall is, 0..1 + skirtHeight: 1.7, // length of a shard in the skirt, metres + coreHeight: 5.2, // length of the spire, metres + heightJitter: 0.65, + ringLean: 0.33, // radians the wall is thrown outward (≈19°) + skirtLean: 0.3, // ... and the skirt + coreLean: 0.2, // the spire stands nearly upright + leanJitter: 1.3, + fan: 1.16, // radians a blade is splayed off its own radius, ± — the crossing + twist: 1.0, // random yaw, 0..1 of a full turn + rubble: 0.53, // fraction of the skirt demoted to ankle-height wreckage + rubbleScale: 0.34, + + /* --- an individual crystal --- */ + // Blunt wedges rather than needles: a thick base that only narrows to about + // a third at the tip, so each facet stays wide enough to catch a flash. + radius: 0.375, // base radius, metres + radiusJitter: 0.94, + taper: 0.36, // tip radius as a fraction of the base + facets: 7, // sides of the prism — fewer, so each facet is a broad flash + roughness: 0.0, // how far the facets are pushed off a clean prism + bend: 0.0, // sideways curve from base to tip — nearly straight + + /* --- the bloom: when each shard goes up --- */ + riseTime: 0.2, // seconds from buried to full height + riseOvershoot: 0.3, // how far past full height the punch carries + settle: 0.5, // seconds the overshoot takes to damp out + sweepTime: 0.42, // seconds the wave takes to run around the ring + skirtDelay: 0.1, // seconds before the skirt starts + skirtWave: 0.26, // ... and how long it takes to cross the band + coreDelay: 0.2, // seconds before the spire comes up + stagger: 0.07, // seconds of random delay on top of all of it + bloomSpread: 0.7, // fraction of the hold the late shards are scattered over + + /* --- the ice: prismatic glass, not the Lance's quarried crystal --- */ + /** + * Deliberately the *opposite* treatment to `ice`. Two frost abilities on one + * bar have to be told apart before the silhouette does it, and a recolour is + * not enough — so where the Frost Lance is milky, diffuse and tinted deeper + * the thicker it gets, these blades are near-empty glass carried entirely by + * their edges: a chromatically split fresnel (`dispersion`), light piped up + * the body to an incandescent point (`pipe`, `tipBias`, `tipGlow`), flow + * lines instead of feather frost (`stria`) and one real reflection of the + * stage off every facet (`envIntensity`, `specular`). + * See `materials/GlacierMaterial.js`. + */ + colorGlass: '#0e4a66', // the little body it has + colorEdge: '#ffffff', // the silhouette, the flow lines and the glint + colorPrismA: '#57f0ff', // one end of the dispersion split + colorPrismB: '#8f9bff', // ... and the other + colorCore: '#a8f4ff', // the light piped up the blade + colorTip: '#ffffff', // the incandescent point + body: 1.37, // how much of a body it has at all, 0 = pure edges + edgePower: 1.14, // how tightly the silhouette hugs the rim + edgeGain: 0.81, // how hard it burns + dispersion: 0.73, // how far the red, green and blue fresnels come apart + pipe: 1.09, // light piped along the blade + tipBias: 1.6, // how hard that light crowds toward the point + bands: 1.4, // slow waves travelling up it + pulseSpeed: 0.6, + tipStart: 0.6, // where the incandescent tip begins, 0..1 up the blade + tipGlow: 1.5, + stria: 0.75, // flow lines running the blade's length + striaScale: 6.0, + envIntensity: 0.6, // how much of the HDR probe the facets catch + specular: 2.0, // the tight sun lobe off them + glow: 1.0, // overall emissive gain + opacity: 1.0, + birthGlow: 2.2, // extra glow on a shard that has just erupted + birthFade: 0.5, // seconds that birth flash lasts + + /* --- the freeze front and the shatter --- */ + /** + * The two things that make this ability's ice *arrive* and *leave* rather + * than fade in and out. Both are per-instance ramps the ability drives; what + * lives here is only their look. + */ + frontRough: 0.35, // how ragged the crystallising edge is + frontWidth: 0.12, // how much of the shard is lit behind that edge + frontGlow: 2.4, // how hard it burns + shatterScale: 7.0, // break-up cells per unit of the crystal + shatterEdge: 0.08, // width of the lit rim on a fresh break + shatterGlow: 3.0, + + /* --- the sheet of ice on the floor --- */ + /** + * The indicator's promise, made real: the same circle and the same thick + * boundary, now a frozen sheet instead of a targeting aid. An ability-owned + * mesh rather than a decal precisely because a decal captures its radius + * when it spawns — this one has to re-scale under `zoneRadius` while the + * crown is standing, and to run its own front outward and back. + */ + fieldBoundary: 0.4, // thickness of the band at the edge, metres + fieldBoundaryGlow: 2.4, + fieldFill: 0.26, // the wash inside it + fieldFalloff: 1.4, // how hard that wash crowds to the rim + fieldPlates: 1.0, // tonal break-up between plates + fieldPlateScale: 2.2, // plates per metre + fieldSeam: 0.8, // rime piled in the seams between them + fieldFingers: 0.9, // frost fingers crawling over the sheet + fieldFingerScale: 1.6, // fingers per metre + fieldWarp: 0.5, // domain warp — what stops them reading as spokes + fieldCrawl: 0.12, // how fast they writhe + fieldRings: 2.6, // pressure rings travelling in toward the spire + fieldRingSpeed: -0.5, // rings/second (negative travels inward) + fieldSweep: 0.4, // slow cold sweep around the disc + fieldSweepSpeed: 0.12, // revolutions/second + fieldCore: 1.0, // brightness of the pool the spire stands in + fieldCoreSize: 0.2, // its radius, × zoneRadius + fieldPulse: 0.18, // brightness breathing + fieldPulseSpeed: 1.6, + fieldOpacity: 1.0, + fieldHeight: 0.03, // hover distance above the floor, metres + colorField: '#a7e6ff', // the wash, the plates and the fingers + colorFieldEdge: '#ffffff', // the boundary band, the seams and the pool + + /* --- the curtain of cold air standing on the ring --- */ + /** + * An open cylinder seated on the boundary, eroded by ridged noise stretched + * hard vertically and scrolled downward. This is the piece that frames the + * crown from the outside: without it the wall of blades ends at its own + * silhouette, and a wall of ice that is not shedding cold reads as glass. + * Set `veil` to 0 to take it off. + */ + veil: 0.5, // master opacity of the curtain, 0 hides it + veilHeight: 1.9, // how high it stands, metres + veilRadius: 1.02, // where it stands, × zoneRadius + veilFlare: 0.32, // how far it leans outward at the top + veilBillow: 0.22, // metre-scale lobes pushing its silhouette off round + veilScale: 1.4, // noise features per metre + veilStretch: 0.5, // <1 draws the structures out into vertical falls + veilFlow: 0.4, // how fast they pour downward + veilErode: 0.55, // how much harder the top is eaten away than the base + veilFalloff: 1.8, // how fast it thins with height + veilSpin: 0.02, // revolutions/second the whole curtain turns + veilSoftFade: 0.8, // metres of soft fade where it meets geometry + colorVeil: '#8cd2ff', + colorVeilCrest: '#ffffff', + + /* --- what the ground does --- */ + trailFrostRate: 2.2, // rime patches laid per metre of front travel + trailFrostRadius: 1.0, // radius of one, metres + frostSpread: 1.5, // the rime sheet under the crown, × zoneRadius + frostLife: 7.5, // seconds a rime patch lingers + frostIntensity: 0.85, + frostCrystals: 1.5, // grain of the packed snow + frostCollar: 2.6, // rime around the foot of a blade, × its own radius + rimeRate: 3.0, // rime patches creeping around the boundary, per second + rimeRadius: 1.0, // radius of one, metres + colorFrost: '#f0f9ff', // the lit face of the snow + colorFrostEdge: '#79b6dd', // what it goes in its own shadow + shockRadius: 7.5, // the ring that snaps out when the crown blooms, metres + ringRate: 0.9, // pressure rings pushed out while it stands, per second + colorShockA: '#8ee8ff', // body of the shockwave ring + colorShockB: '#ffffff', // its crest + + /* --- mist, chips, glitter and snow --- */ + /** + * As in every other block: a four-stop gradient sampled over the particle's + * own lifetime, `A` at birth through `D` as it dies. The **snow** is this + * ability's signature system — ice dust spawned *above* the crown and left + * to fall back down through it. Everything else in the project is thrown + * upward, and a slow fall inside the ring is what says the air over it is + * freezing rather than burning. + */ + mistRate: 240, // cold air pouring off the rim, particles/second + mistSize: 1.1, + mistSpeed: 1.6, + mistLifetime: 3.0, + mistOpacity: 0.055, + mistRise: -0.12, // negative: cold air is heavy, it falls and spreads + mistTurbulence: 0.4, + colorMistA: '#f2feff', + colorMistB: '#cdefff', + colorMistC: '#8ec9e8', + colorMistD: '#0a2c42', + shardSize: 0.07, // ice chips + shardSpeed: 6.5, + shardLifetime: 1.6, + shardGravity: -15.0, + breachShards: 3, // chips thrown as one shard breaks the surface + shatterShards: 5, // ... and as it comes apart + colorShardA: '#ffffff', + colorShardB: '#cdefff', + colorShardC: '#8ee8ff', + colorShardD: '#0a3c55', + glitterRate: 150, // the sparkle lifting off the sheet + glitterSize: 0.05, + glitterSpeed: 2.6, + glitterLifetime: 2.4, + glitterRise: 1.3, // upward drift, metres/second + glitterTurbulence: 0.6, + glitterGlow: 1.0, + colorGlitterA: '#ffffff', + colorGlitterB: '#6fe0ff', + colorGlitterC: '#bdeeff', + colorGlitterD: '#062434', + snowRate: 110, // ice dust falling back through the crown + snowSize: 0.045, + snowSpeed: 0.9, // how hard it is pushed downward to start with + snowLifetime: 3.2, + snowFall: -1.1, // gravity on it, metres/second² + snowTurbulence: 0.85, // what turns the fall into a drift + snowGlow: 0.9, + snowInset: 0.85, // how far inside the boundary it falls, × zoneRadius + snowHeight: 1.35, // where it starts, × the height of the wall + colorSnowA: '#ffffff', + colorSnowB: '#e4f9ff', + colorSnowC: '#a7e6ff', + colorSnowD: '#0c3348', + + /* --- dynamic light --- */ + lightIntensity: 14, + lightRadius: 16, + lightHeight: 0.45, // how far up the crown the light sits, 0..1 + lightColor: '#8ee8ff', + + /* --- the throw, the bloom and the hold --- */ + muzzleSize: 0.55, // the puff at the hand as the front leaves it + muzzleIntensity: 1.5, + castFlash: 0.08, // screen flash on release + colorCastFlash: '#cdefff', + burstSize: 4.0, // the vapour shell thrown off at the bloom, metres + burstIntensity: 1.1, + burstShards: 120, // extra chips at the bloom + burstMist: 70, + burstGlitter: 140, + vapourRate: 1.6, // vapour shells shed off the wall while it stands, /s + vapourSize: 1.4, // radius of one, metres + vapourIntensity: 0.7, + impactShake: 0.85, + shakeDuration: 0.85, + holdShake: 0.05, // continuous rumble while the crown stands + impactFlash: 0.2, + rumble: 0.045, // rumble while the front races out + colorBurstA: '#a7e6ff', + colorBurstB: '#cdefff', + colorBurstC: '#ffffff', + colorFlash: '#cdefff' // the full-screen flash when it blooms +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Glacial Crown — the far cast that comes out of the floor. + * + * `zoneRadius` is again the control that reaches outside the ability: it is + * read by the circle indicator *and* by the ring of blades, the sheet and the + * curtain, so dragging it re-scales what you aim with and what you get + * together. After that the two groups that carry the cast are **The bloom**, + * where `sweepTime` decides how the ring closes, and **Freeze front & + * shatter**, which is how the ice arrives and how it leaves. + */ +export const glacierSchema = { + 'The cast': [ + ['zoneRadius', 0.5, 14, 0.05, 'footprint radius'], + ['range', 2, 50, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 200, 1, 'front speed'], + ['snapTime', 0.02, 1.5, 0.01, 'freeze-out time'], + ['lifetime', 0.2, 14, 0.05, 'hold time'], + ['shatterDelay', 0, 4, 0.01, 'delay before it breaks'], + ['shatterStagger', 0, 3, 0.01, 'break stagger'], + ['sinkTime', 0.05, 5, 0.01, 'crumble time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where the front leaves the hand': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['muzzleSize', 0.05, 6, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'] + ], + 'Filling the footprint': [ + ['spikeCount', 1, 320, 1, 'shards'], + ['density', 0.1, 2, 0.01, 'density'], + ['ringShare', 0, 1, 0.01, 'share on the wall'], + ['coreShare', 0, 0.5, 0.01, 'share on the spire'], + ['lateShare', 0, 0.5, 0.01, 'share held back'], + ['ringSeat', 0.2, 1.4, 0.01, 'wall seat, × footprint'], + ['ringScatter', 0, 0.6, 0.005, 'wall jitter, × footprint'], + ['skirtSeat', 0, 1.4, 0.01, 'skirt inner lip, × footprint'], + ['skirtBand', 0.02, 1.4, 0.01, 'skirt width, × footprint'], + ['skirtBias', 0.2, 3, 0.01, 'skirt crowding'], + ['coreSpread', 0.01, 0.6, 0.005, 'spire cluster, × footprint'] + ], + Silhouette: [ + ['ringHeight', 0.2, 12, 0.05, 'wall height'], + ['ringWave', 0, 1, 0.01, 'crest unevenness'], + ['skirtHeight', 0.05, 6, 0.05, 'skirt height'], + ['coreHeight', 0.2, 12, 0.05, 'spire height'], + ['heightJitter', 0, 1.5, 0.01, 'height jitter'], + ['ringLean', -1.5, 1.5, 0.01, 'wall lean (0 = a fence)'], + ['skirtLean', -1.5, 1.5, 0.01, 'skirt lean'], + ['coreLean', -1.5, 1.5, 0.01, 'spire lean'], + ['leanJitter', 0, 3, 0.01, 'lean jitter'], + ['fan', 0, 1.6, 0.01, 'splay off the radius'], + ['twist', 0, 1, 0.01, 'random yaw'], + ['rubble', 0, 1, 0.01, 'rubble fraction'], + ['rubbleScale', 0.05, 1, 0.01, 'rubble height'] + ], + 'The crystal': [ + ['radius', 0.05, 1.2, 0.005, 'base radius'], + ['radiusJitter', 0, 1.5, 0.01, 'radius jitter'], + ['taper', 0.01, 0.9, 0.01, 'tip taper'], + ['facets', 3, 12, 1, 'facets'], + ['roughness', 0, 1, 0.01, 'facet roughness'], + ['bend', 0, 1.5, 0.01, 'bend'] + ], + 'The bloom': [ + ['sweepTime', 0, 3, 0.01, 'sweep around the ring'], + ['skirtDelay', 0, 2, 0.01, 'skirt delay'], + ['skirtWave', 0, 2, 0.01, 'skirt wave'], + ['coreDelay', 0, 2, 0.01, 'spire delay'], + ['stagger', 0, 1, 0.005, 'random stagger'], + ['bloomSpread', 0, 1, 0.01, 'late shards spread'], + ['riseTime', 0.02, 1.5, 0.01, 'rise time'], + ['riseOvershoot', 0, 1.5, 0.01, 'punch overshoot'], + ['settle', 0.05, 2, 0.01, 'settle'] + ], + 'Prismatic glass': [ + ['opacity', 0, 1, 0.01, 'opacity'], + ['body', 0, 2, 0.01, 'body (0 = pure edges)'], + ['edgePower', 0.5, 8, 0.01, 'edge tightness'], + ['edgeGain', 0, 6, 0.01, 'edge gain'], + ['dispersion', 0, 1, 0.01, 'chromatic split'], + ['pipe', 0, 5, 0.01, 'piped light'], + ['tipBias', 0.2, 6, 0.01, 'crowding to the point'], + ['bands', 0, 8, 0.05, 'travelling bands'], + ['pulseSpeed', -4, 4, 0.01, 'band speed'], + ['tipStart', 0, 1, 0.01, 'tip start'], + ['tipGlow', 0, 6, 0.01, 'tip glow'], + ['stria', 0, 3, 0.01, 'flow lines'], + ['striaScale', 0.5, 24, 0.1, 'flow line scale'], + ['envIntensity', 0, 3, 0.01, 'env reflection'], + ['specular', 0, 8, 0.05, 'sun glint'], + ['glow', 0, 4, 0.01, 'glow'], + ['birthGlow', 0, 6, 0.01, 'birth flash'], + ['birthFade', 0.02, 3, 0.01, 'birth fade'], + ['colorGlass', 'body'], + ['colorEdge', 'edge & glint'], + ['colorPrismA', 'dispersion A'], + ['colorPrismB', 'dispersion B'], + ['colorCore', 'piped light'], + ['colorTip', 'tip'] + ], + 'Freeze front & shatter': [ + ['frontRough', 0, 1.5, 0.01, 'front raggedness'], + ['frontWidth', 0.01, 0.8, 0.01, 'front width'], + ['frontGlow', 0, 8, 0.05, 'front glow'], + ['shatterScale', 1, 24, 0.1, 'break-up cells'], + ['shatterEdge', 0.005, 0.4, 0.005, 'break edge width'], + ['shatterGlow', 0, 8, 0.05, 'break glow'] + ], + 'The sheet on the floor': [ + ['fieldBoundary', 0.02, 2, 0.01, 'band thickness'], + ['fieldBoundaryGlow', 0, 8, 0.05, 'band glow'], + ['fieldFill', 0, 2, 0.01, 'interior fill'], + ['fieldFalloff', 0.1, 5, 0.05, 'fill falloff'], + ['fieldPlates', 0, 3, 0.01, 'plate break-up'], + ['fieldPlateScale', 0.2, 10, 0.05, 'plates / metre'], + ['fieldSeam', 0, 3, 0.01, 'seam rime'], + ['fieldFingers', 0, 3, 0.01, 'frost fingers'], + ['fieldFingerScale', 0.1, 8, 0.05, 'fingers / metre'], + ['fieldWarp', 0, 2, 0.01, 'domain warp'], + ['fieldCrawl', -4, 4, 0.01, 'finger crawl'], + ['fieldRings', 0, 12, 0.1, 'pressure rings'], + ['fieldRingSpeed', -6, 6, 0.01, 'ring speed'], + ['fieldSweep', 0, 3, 0.01, 'cold sweep'], + ['fieldSweepSpeed', -2, 2, 0.01, 'sweep speed'], + ['fieldCore', 0, 4, 0.01, 'centre pool'], + ['fieldCoreSize', 0.02, 1, 0.005, 'pool size, × footprint'], + ['fieldPulse', 0, 1, 0.01, 'pulse'], + ['fieldPulseSpeed', 0, 10, 0.05, 'pulse speed'], + ['fieldOpacity', 0, 2, 0.01, 'opacity'], + ['fieldHeight', 0.005, 0.4, 0.005, 'hover height'], + ['colorField', 'sheet'], + ['colorFieldEdge', 'band & seams'] + ], + 'The curtain of cold': [ + ['veil', 0, 2, 0.01, 'opacity (0 hides it)'], + ['veilHeight', 0.1, 8, 0.05, 'height'], + ['veilRadius', 0.5, 1.6, 0.005, 'seat, × footprint'], + ['veilFlare', -0.5, 1.5, 0.01, 'outward lean'], + ['veilBillow', 0, 1.5, 0.01, 'silhouette lobes'], + ['veilScale', 0.1, 6, 0.05, 'noise / metre'], + ['veilStretch', 0.05, 3, 0.01, 'vertical stretch'], + ['veilFlow', -4, 4, 0.01, 'fall speed'], + ['veilErode', 0, 1, 0.01, 'erosion with height'], + ['veilFalloff', 0.2, 6, 0.05, 'thinning with height'], + ['veilSpin', -1, 1, 0.005, 'rotation'], + ['veilSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorVeil', 'curtain'], + ['colorVeilCrest', 'crest'] + ], + Rime: [ + ['trailFrostRate', 0.05, 10, 0.05, 'trail rime / metre'], + ['trailFrostRadius', 0.05, 6, 0.05, 'trail rime radius'], + ['frostSpread', 0.2, 4, 0.05, 'impact rime, × footprint'], + ['frostLife', 0.5, 20, 0.1, 'rime lifetime'], + ['frostIntensity', 0, 2, 0.01, 'rime intensity'], + ['frostCrystals', 0, 4, 0.01, 'snow grain'], + ['frostCollar', 0, 8, 0.05, 'collar, × shard radius'], + ['rimeRate', 0, 20, 0.1, 'rim rime / sec'], + ['rimeRadius', 0.05, 6, 0.05, 'rim rime radius'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['ringRate', 0, 12, 0.1, 'pressure rings / sec'], + ['colorFrost', 'snow'], + ['colorFrostEdge', 'snow shadow'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'Mist, glitter & snow': [ + ['mistRate', 0, 900, 1, 'mist rate'], + ['mistSize', 0.05, 4, 0.01, 'mist size'], + ['mistSpeed', 0, 8, 0.05, 'mist speed'], + ['mistLifetime', 0.2, 8, 0.05, 'mist lifetime'], + ['mistOpacity', 0, 1, 0.005, 'mist opacity'], + ['mistRise', -3, 3, 0.01, 'mist rise (− falls)'], + ['mistTurbulence', 0, 3, 0.01, 'mist swirl'], + ['glitterRate', 0, 900, 1, 'glitter rate'], + ['glitterSize', 0.005, 0.4, 0.005, 'glitter size'], + ['glitterSpeed', 0, 20, 0.1, 'glitter speed'], + ['glitterLifetime', 0.1, 8, 0.05, 'glitter lifetime'], + ['glitterRise', -3, 8, 0.01, 'glitter lift'], + ['glitterTurbulence', 0, 3, 0.01, 'glitter swirl'], + ['glitterGlow', 0, 4, 0.01, 'glitter glow'], + ['snowRate', 0, 600, 1, 'snow rate'], + ['snowSize', 0.005, 0.4, 0.005, 'snow size'], + ['snowSpeed', 0, 10, 0.05, 'initial push'], + ['snowLifetime', 0.2, 10, 0.05, 'snow lifetime'], + ['snowFall', -12, 2, 0.05, 'snow gravity'], + ['snowTurbulence', 0, 3, 0.01, 'snow drift'], + ['snowGlow', 0, 4, 0.01, 'snow glow'], + ['snowInset', 0.05, 1.4, 0.01, 'fall inset, × footprint'], + ['snowHeight', 0.2, 4, 0.05, 'fall height, × wall'], + ['colorMist*', 'Mist colour'], + ['colorGlitter*', 'Glitter colour'], + ['colorSnow*', 'Snow colour'] + ], + 'Ice chips': [ + ['shardSize', 0.005, 0.5, 0.005, 'chip size'], + ['shardSpeed', 0, 30, 0.1, 'chip speed'], + ['shardLifetime', 0.1, 6, 0.05, 'chip lifetime'], + ['shardGravity', -50, 0, 0.1, 'chip gravity'], + ['breachShards', 0, 30, 1, 'chips on breach'], + ['shatterShards', 0, 30, 1, 'chips on break-up'], + ['colorShard*', 'Chip colour'] + ], + 'Bloom & hold': [ + ['burstSize', 0.2, 14, 0.05, 'vapour shell size'], + ['burstIntensity', 0, 5, 0.01, 'vapour shell intensity'], + ['burstShards', 0, 600, 1, 'bloom chips'], + ['burstMist', 0, 400, 1, 'bloom mist'], + ['burstGlitter', 0, 600, 1, 'bloom glitter'], + ['vapourRate', 0, 12, 0.05, 'hold shells / sec'], + ['vapourSize', 0.1, 10, 0.05, 'hold shell size'], + ['vapourIntensity', 0, 5, 0.01, 'hold shell intensity'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['holdShake', 0, 0.5, 0.005, 'hold rumble'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'shell'], + ['colorBurstB', 'shell body'], + ['colorBurstC', 'shell plates'], + ['colorFlash', 'bloom flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightHeight', 0, 1, 0.01, 'height up the crown'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/glyphstorm.js b/src/config/abilities/glyphstorm.js new file mode 100644 index 0000000..a04eb59 --- /dev/null +++ b/src/config/abilities/glyphstorm.js @@ -0,0 +1,338 @@ +/* ================================================================== */ +/* GLYPHSTORM — arcane, line cast */ +/* ================================================================== */ +/** + * Several hundred instanced quads, each carrying one procedural mark, thrown + * down the aimed line and settling into a burning hex lattice on the floor. + * + * The whole ability is two draw calls — one `Swarm` and one `GroundField` — + * and the character of it lives in exactly one number. + * + * **`billboard`.** At 1 every card is a sprite: it faces the camera whatever + * it is doing, and the storm is a flat wall of legible symbols that never + * changes. At 0 every card is a plate in the world: it turns as the formation + * churns and it vanishes for the part of the turn where it is edge-on, and the + * storm is a scatter of bright lines with no symbols in it at all. Neither is + * the effect. It ships at **0.22**, which is where a card keeps its own facing + * and the storm *flickers* between the two, and that flicker is the ability. + * + * The two sliders next to it exist to stop the edge-on half of that flicker + * disappearing: `edgeStretch` grows a card as it turns so its line never falls + * under a pixel and aliases into nothing, and `edgeGain` lifts its emission, + * because a card that collapses without getting brighter reads as a hole in + * the storm rather than as a mark seen side-on. + * + * On the alphabet: `Swarm`'s `CARD` walks up to six strokes between the points + * of a 3 × 5 lattice, seeded off the agent. `GroundField`'s `RUNE` mode has a + * genuinely legible sixteen-letterform alphabet, and it is *not* what this + * wants and it is not exported anyway — `src/vfx/README.md` makes the argument + * directly: a seal is something you pause and stare at, and a storm is + * weather. Three hundred cheap marks read as a script you were never taught; + * three hundred correct ones read as a font specimen. + */ +export const glyphstorm = { + /* --- the cast --- */ + range: 26.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 21.0, // how fast the storm's lead travels, metres/second + settleTime: 0.7, // seconds the cards take to come down onto the floor + lifetime: 1.5, // seconds the lattice holds after they land + fadeTime: 1.3, // seconds it takes to burn out + cooldown: 1.1, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the storm comes from and where it goes --- */ + handForward: 0.7, // metres in front of the caster the cards gather + handSide: 0.3, // metres to the side (+ follows `Ability#side`) + handHeight: 1.35, // metres above the floor + flightHeight: 1.5, // metres above the floor at the far end, in flight + leadRise: 1.1, // metres the lead lofts at mid-span + + /* --- the formation --- */ + cards: 300, // live agents (capped at 384) + latticeX: 13, // separation cells across + latticeY: 8, // ... up + latticeZ: 4, // ... and ranks strung out behind the lead + spacingSide: 0.46, // metres between lateral cells + spacingUp: 0.34, // metres between vertical cells + lag: 0.42, // seconds the back rank trails the lead by + formJitter: 0.14, // metres of slop off a cell + churn: 1.15, // radians/second the whole formation rolls + breathe: 0.22, // fraction the formation swells by + breatheRate: 1.9, // radians/second + wander: 0.13, // metres of curl drift — keep under half the spacing + wanderScale: 0.55, // features per metre + wanderSpeed: 0.7, + gatherTight: 0.06, // `gather` at the hand: 0 puts every card on the lead + gatherStorm: 1.0, // ... and once the storm is open + gatherRamp: 0.34, // fraction of the flight the storm opens over + revealRamp: 0.26, // fraction of the flight the cards are written in over + revealSpread: 0.4, // width of that wave, 0..1 + + /* --- the card --- */ + cardSize: 0.36, // metres, nose to tail + cardAspect: 0.78, // span / length + cardSizeJitter: 0.35, // ± fraction + billboard: 0.22, // 0 the card's own frame, 1 camera-facing — THE slider + edgeStretch: 2.1, // how much an edge-on card grows, ≥ 1 + edgeGain: 3.2, // emission multiplier when it is edge-on + bank: 0.075, // radians of roll per m/s² of lateral acceleration + bankMax: 1.5, // radians + dihedral: 0.18, // fold across the card, fraction of its size + flapRate: 2.6, // folds/second + curl: 0.12, // quadratic bend across the chord, fraction of size + glyphWeight: 0.075, // stroke half-width, in card units + glyphStrokes: 4, // strokes in the walk, 1..6 + cardFrame: 0.3, // brightness of the card's border + cardLit: 0.0, // 0 emissive, 1 wrapped diffuse + + /* --- card colour --- */ + tint: 0.25, // where in the gradient the storm sits, 0..1 + tintJitter: 0.3, // ± per-card walk along it + tintAlong: 0.4, // extra walk from the head of the storm to its tail + cardOpacity: 1.0, + cardGlow: 1.5, + cardSoft: 0.4, // metres of depth feather against solid geometry + colorCardA: '#fff4de', // the hottest cards, at the head + colorCardB: '#ffe8c0', + colorCardC: '#c08a3a', + colorCardD: '#2a1a0a', // the coldest, trailing + + /* --- the settling --- */ + settleHeight: 0.22, // metres the cards come down to before they go out + settleSpacing: 0.18, // fraction the vertical spacing collapses to + settleChurn: 0.12, // fraction the churn collapses to + settleGather: 1.35, // `gather` opens further as the storm spills outward + settleDim: 0.9, // fraction of the card opacity given up as they land + + /* --- the lattice burnt into the floor --- */ + burnRadius: 5.2, // metres — the footprint the lattice fills + burnGrow: 0.85, // seconds the charge takes to cross it + burnLead: 0.25, // fraction of the settle that passes before it starts + burnHeight: 0.02, // metres the quad floats above the floor + burnCell: 0.62, // metres — one hex + burnCellJitter: 0.7, // 0..1 — how unevenly the front steps cell to cell + burnSeam: 0.075, // metres — the node at each hex corner + burnThickness: 0.045, // metres — trace half-width + burnLift: 0.05, // metres the trace stands proud + burnDepth: 0.1, // metres the substrate is etched to + burnSpeed: 1.6, // charge runs per second along the traces + burnEdge: 0.4, // metres of feather on the growth front + burnRagged: 0.3, // how far the front wanders, fraction of the radius + burnRaggedScale: 0.5, // lobes per metre + burnWarp: 0.45, // metres of domain warp on those lobes + burnRelief: 0.7, // how hard the trace tilts the fake normal + burnNormalStep: 0.05, // metres between the height taps + burnAmbient: 0.28, // floor on the diffuse term + burnWrap: 0.45, // wraps the terminator round the back, 0..1 + burnSpecular: 0.4, + burnGloss: 22, // Blinn exponent + burnParallax: 0.25, // metres of view-driven offset on the etch + burnEmissive: 1.7, // multiplier on the glowing terms + burnOpacity: 1.0, + burnDepthFade: 0.4, // metres of soft fade against standing geometry + colorBurnBase: '#5a4322', // the trace itself + colorBurnEdge: '#fff4de', // the nodes + colorBurnGlow: '#ffb54a', // the charge running along it + colorBurnDeep: '#2a1a0a', // the etched substrate between traces + + /* --- ink: the motes shed by the storm --- */ + inkRate: 150, // particles/second + inkSize: 0.055, + inkSpeed: 1.2, + inkLifetime: 1.5, + inkRise: 0.35, // upward drift, metres/second + inkTurbulence: 0.85, + colorInkA: '#fff4de', + colorInkB: '#ffe8c0', + colorInkC: '#c08a3a', + colorInkD: '#2a1a0a', + + /* --- sparks: struck off a card as it lands --- */ + sparkRate: 60, // particles/second while the storm is settling + sparkSize: 0.1, + sparkSpeed: 5.5, + sparkLifetime: 0.6, + sparkGravity: -14.0, + sparkStretch: 0.2, // how far a spark smears along its velocity + sparkBurst: 180, // extra sparks thrown as the lattice takes + colorSparkA: '#ffffff', + colorSparkB: '#ffe8c0', + colorSparkC: '#c08a3a', + colorSparkD: '#3a2510', + + /* --- dynamic light --- */ + lightIntensity: 19, + lightRadius: 13, + lightColor: '#ffbf72', + lightSettle: 0.6, // multiplier once the storm is on the floor + + /* --- the hand, and the landing --- */ + muzzleSize: 0.65, // the knot of light the cards are written out of, metres + muzzleIntensity: 1.7, + castFlash: 0.09, // screen flash on release + colorMuzzleA: '#c08a3a', + colorMuzzleB: '#ffe8c0', + colorMuzzleC: '#fff4de', + colorCastFlash: '#ffe8c0', + settleFlash: 0.16, // screen flash as the lattice takes + colorSettleFlash: '#ffd79a', + settleShake: 0.42, + settleShakeDuration: 0.55, // seconds that shake decays over + rumble: 0.022 // continuous shake while the storm is in the air +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Glyphstorm. + * + * Reach for `billboard` first and drag it end to end — it is the ability, and + * everything else in "The card" is there to keep both ends of it legible. Then + * `latticeX/Y/Z` against `cards`: the product of the three is the number of + * distinct slots the separation lattice has, and asking for more cards than + * that is the one way agents start sharing a cell and the formation stops + * reading as a formation. + */ +export const glyphstormSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 2, 120, 0.5, 'storm speed'], + ['settleTime', 0.05, 4, 0.01, 'settle time'], + ['lifetime', 0.05, 8, 0.01, 'lattice hold'], + ['fadeTime', 0.1, 6, 0.01, 'burn-out time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The flight': [ + ['handForward', -1, 4, 0.01, 'hand forward'], + ['handSide', -2, 2, 0.01, 'hand lateral'], + ['handHeight', 0, 3, 0.01, 'hand height'], + ['flightHeight', 0, 8, 0.01, 'height at target'], + ['leadRise', -3, 6, 0.01, 'mid-span loft'] + ], + 'The formation': [ + ['cards', 1, 384, 1, 'cards'], + ['latticeX', 1, 24, 1, 'cells across'], + ['latticeY', 1, 24, 1, 'cells up'], + ['latticeZ', 1, 32, 1, 'ranks back'], + ['spacingSide', 0.02, 3, 0.01, 'lateral spacing'], + ['spacingUp', 0.02, 3, 0.01, 'vertical spacing'], + ['lag', 0, 3, 0.01, 'rank lag'], + ['formJitter', 0, 1, 0.005, 'cell slop'], + ['churn', -6, 6, 0.01, 'formation roll'], + ['breathe', 0, 1.5, 0.01, 'breathe depth'], + ['breatheRate', 0, 8, 0.01, 'breathe rate'], + ['wander', 0, 1, 0.005, 'curl drift'], + ['wanderScale', 0.05, 4, 0.01, 'drift / metre'], + ['wanderSpeed', 0, 4, 0.01, 'drift speed'], + ['gatherTight', 0, 1.5, 0.01, 'gather at hand'], + ['gatherStorm', 0, 2, 0.01, 'gather in flight'], + ['gatherRamp', 0.02, 1, 0.01, 'open over'], + ['revealRamp', 0.02, 1, 0.01, 'write in over'], + ['revealSpread', 0.02, 1, 0.01, 'write-in wave'] + ], + 'The card': [ + ['cardSize', 0.02, 2, 0.01, 'card size'], + ['cardAspect', 0.1, 4, 0.01, 'span / length'], + ['cardSizeJitter', 0, 1.5, 0.01, 'size jitter ±'], + ['billboard', 0, 1, 0.01, 'camera facing — THE one'], + ['edgeStretch', 1, 6, 0.01, 'edge-on stretch'], + ['edgeGain', 0, 10, 0.05, 'edge-on gain'], + ['bank', 0, 0.5, 0.005, 'bank / lateral g'], + ['bankMax', 0, 3.2, 0.01, 'bank limit'], + ['dihedral', 0, 1, 0.01, 'fold'], + ['flapRate', 0, 12, 0.05, 'fold rate'], + ['curl', 0, 1, 0.01, 'curl'], + ['glyphWeight', 0.005, 0.3, 0.005, 'stroke width'], + ['glyphStrokes', 1, 6, 1, 'strokes / mark'], + ['cardFrame', 0, 2, 0.01, 'border brightness'], + ['cardLit', 0, 1, 0.01, 'lit vs emissive'] + ], + 'Card colour': [ + ['tint', 0, 1, 0.01, 'gradient position'], + ['tintJitter', 0, 1, 0.01, 'per-card walk ±'], + ['tintAlong', 0, 1, 0.01, 'head-to-tail walk'], + ['cardOpacity', 0, 2, 0.01, 'opacity'], + ['cardGlow', 0, 6, 0.01, 'glow'], + ['cardSoft', 0, 2, 0.01, 'soft intersection'], + ['colorCard*', 'Card colour'] + ], + 'The settling': [ + ['settleHeight', 0, 3, 0.01, 'settle height'], + ['settleSpacing', 0, 1, 0.01, 'vertical collapse'], + ['settleChurn', 0, 1, 0.01, 'roll collapse'], + ['settleGather', 0, 3, 0.01, 'spill outward'], + ['settleDim', 0, 1, 0.01, 'cards give up'] + ], + 'The lattice': [ + ['burnRadius', 0.5, 20, 0.1, 'footprint radius'], + ['burnGrow', 0.05, 5, 0.01, 'charge crossing time'], + ['burnLead', 0, 1, 0.01, 'starts after'], + ['burnHeight', 0, 0.3, 0.005, 'float above floor'], + ['burnCell', 0.05, 3, 0.01, 'hex size'], + ['burnCellJitter', 0, 2, 0.01, 'front unevenness'], + ['burnSeam', 0.002, 0.4, 0.002, 'node size'], + ['burnThickness', 0.002, 0.4, 0.002, 'trace width'], + ['burnLift', 0, 0.5, 0.005, 'trace height'], + ['burnDepth', 0, 1, 0.005, 'etch depth'], + ['burnSpeed', 0, 8, 0.05, 'charge rate'], + ['burnEdge', 0.02, 3, 0.01, 'front feather'], + ['burnRagged', 0, 1, 0.01, 'front wander'], + ['burnRaggedScale', 0.05, 4, 0.01, 'wander / metre'], + ['burnWarp', 0, 3, 0.01, 'domain warp'], + ['burnRelief', 0, 3, 0.01, 'relief'], + ['burnNormalStep', 0.005, 0.4, 0.005, 'normal step'], + ['burnAmbient', 0, 1, 0.01, 'ambient'], + ['burnWrap', 0, 1, 0.01, 'terminator wrap'], + ['burnSpecular', 0, 3, 0.01, 'specular'], + ['burnGloss', 1, 120, 1, 'gloss'], + ['burnParallax', 0, 2, 0.01, 'parallax'], + ['burnEmissive', 0, 6, 0.01, 'emissive'], + ['burnOpacity', 0, 2, 0.01, 'opacity'], + ['burnDepthFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorBurnBase', 'trace'], + ['colorBurnEdge', 'nodes'], + ['colorBurnGlow', 'charge'], + ['colorBurnDeep', 'etched substrate'] + ], + 'Ink & sparks': [ + ['inkRate', 0, 800, 1, 'ink rate'], + ['inkSize', 0.005, 0.4, 0.005, 'ink size'], + ['inkSpeed', 0, 12, 0.05, 'ink speed'], + ['inkLifetime', 0.1, 6, 0.05, 'ink lifetime'], + ['inkRise', -3, 5, 0.01, 'ink rise'], + ['inkTurbulence', 0, 3, 0.01, 'ink turbulence'], + ['sparkRate', 0, 600, 1, 'spark rate'], + ['sparkSize', 0.005, 0.6, 0.005, 'spark size'], + ['sparkSpeed', 0, 30, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 3, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 2, 0.01, 'spark stretch'], + ['sparkBurst', 0, 700, 1, 'sparks at the landing'], + ['colorInk*', 'Ink colour'], + ['colorSpark*', 'Spark colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightSettle', 0, 3, 0.01, 'settled multiplier'], + ['lightColor', 'light colour'] + ], + 'Hand & landing': [ + ['muzzleSize', 0.05, 6, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorMuzzleA', 'muzzle shell'], + ['colorMuzzleB', 'muzzle body'], + ['colorMuzzleC', 'muzzle arcs'], + ['colorCastFlash', 'release flash colour'], + ['settleFlash', 0, 2, 0.01, 'flash on landing'], + ['colorSettleFlash', 'landing flash colour'], + ['settleShake', 0, 3, 0.01, 'landing shake'], + ['settleShakeDuration', 0.05, 3, 0.01, 'shake duration'], + ['rumble', 0, 0.5, 0.005, 'flight rumble'] + ] +}; diff --git a/src/config/abilities/godspear.js b/src/config/abilities/godspear.js new file mode 100644 index 0000000..ed67780 --- /dev/null +++ b/src/config/abilities/godspear.js @@ -0,0 +1,356 @@ +/* ================================================================== */ +/* GODSPEAR — the lumen school's line cast, and the school's thesis */ +/* ================================================================== */ +/** + * A colonnade of slanted volumetric shafts standing along the aimed line, lit + * one after another as a window sweeps down the rank, with the scene's own dust + * catching fire as it drifts through them and a caustic net running along the + * floor underneath the burning end. + * + * **The trick is real in-scattering, and the read is the anisotropy.** The + * brightness of a shaft is the Henyey–Greenstein phase function of the angle + * between the view ray and the *shaft's own axis*, integrated over the segment + * of that ray which lies inside the cone. Look across a shaft and it is a soft + * pale cone; walk round until you are looking up one of them and it is roughly + * eight times brighter. Nothing else in the sandbox has that behaviour, and it + * is the single thing that makes light read as *light in air* rather than as a + * translucent solid someone has left standing in the room. The slider is + * `anisotropy`, it is the first thing to drag, and it is the ability. + * + * The two consequences that follow from that, and which are the reason the + * block below is shaped the way it is: + * + * 1. **The shafts must slant.** `shaftTilt` mixes the shaft's own up-vector + * from world +Y toward the scene's key-light direction. At 0 the rank is a + * row of vertical spotlights, the camera never gets near their axes, and + * the phase function never pays out. At 1 they lie over with the stage's + * own sun, and the orbit camera crosses their axes twice a lap. Ship it + * high. + * 2. **The band on the floor is not a decal.** `bounce` is the fraction of the + * light that lands which comes back at the eye, and it is evaluated by the + * same integral, at the point where the view ray actually terminated, with + * the same radial falloff, the same canopy gaps and the same axial + * extinction the air above it has. Drag `gobo` with the clock stopped and + * the leaf-shadows on the floor change with the gaps in the air, because + * there is one field and not two. A `DecalType.SCORCH` under the foot would + * have been three lines and it would have been a sticker. + * + * **The dust is the scene's dust.** `dustGain`, `dustTint` and `dustSwell` + * drive the `world/DustMotes.js` cloud that is already in the room — the same + * 2600 motes that drift past when nothing is being cast. They are not copied, + * re-emitted or shadowed by a second system; the ability hands the dust shader + * the rank's feet and the shaft profile, and each mote evaluates the same + * irradiance function `LightShaft#irradianceAt()` evaluates on the CPU. Turn + * `dustGain` to zero mid-cast and the shafts empty out in front of you. + * + * `shaftMote` is therefore **zero on purpose**: it is the library's own hashed + * dust lattice inside the shaft, it is very good, and using it here would be + * the second dust system the brief exists to forbid. It is left as a slider + * because a shaft cast somewhere the scene's dust does not reach still wants + * something in it. + * + * Four beats: **sweep** (travel — the lit window runs from the caster's feet to + * the far end, the caustic front running with it), **flood** (impact — the + * whole colonnade lights at once and holds for `lifetime`), **retract** (the + * window closes back down onto the impact point) and **out**. + * + * A cast captures one seed. Every metre below is re-resolved inside the update + * loop, on a zero-length frame included — pause with **P** mid-sweep and drag + * `shaftLength` and the whole rank grows out of the floor around you. + */ +export const godspear = { + /* --- the cast --- */ + range: 24.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + speed: 21.0, // metres/second the lit window sweeps down the rank + lifetime: 2.4, // seconds the flooded colonnade holds + fadeTime: 1.9, // seconds the window takes to close onto the impact point + cooldown: 1.7, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the rank --- */ + shaftCount: 7, // shafts in the colonnade (hard ceiling 8) + shaftSpan: 1.0, // how much of the cast line the rank covers, as a fraction + shaftScatter: 0.32, // metres of hashed slop off each shaft's nominal place + shaftLengthJitter: 0.16, // ±fraction on a shaft's length + shaftRadiusJitter: 0.22, // ±fraction on both of its radii + + /* --- one shaft --- */ + shaftLength: 11.5, // metres, mouth to foot + shaftRadiusMouth: 0.42, // metres where it enters — narrow, it is a source + shaftRadiusFoot: 1.45, // metres where it lands. Differs, so it is a cone + shaftTilt: 0.88, // 0 vertical · 1 lying over with the scene's key light + shaftPad: 1.4, // how much wider than the shaft its bounding hull is + + /* --- the medium. `anisotropy` is the ability; read the header. --- */ + steps: 30, // marching samples along the view ray (compile-time cap 48) + jitter: 0.9, // 0..1 dither on the first sample — this is what kills banding + density: 0.66, // per metre, the in-scattering coefficient + extinct: 0.052, // 1/metres down the shaft: the beam loses energy on the way + soft: 0.5, // widens the gaussian core. A falloff, NOT an edge + axialCurve: 0.6, // how the medium thins toward the mouth + axialMouth: 0.44, // 0..1 density at the mouth. Zero cuts the shaft off flat + anisotropy: 0.74, // -0.95..0.95 Henyey-Greenstein g. THE slider + contact: 0.8, // metres of feather where the shaft meets standing geometry + + /* --- the canopy the light came through --- */ + gobo: 0.46, // 0..1 how much of the shaft the gaps eat + goboScale: 0.52, // cycles per metre, across the shaft + goboBias: 0.2, // bigger = more open sky + goboDrift: 0.09, // radians/second the canopy stirs + + /* --- the shaft's own dust lattice --- */ + shaftMote: 0.0, // ZERO ON PURPOSE — the scene's own motes do this job + + /* --- the band on the floor, produced by the same integral --- */ + bounce: 1.15, // how much of the landed light comes back at the eye + poolSoft: 0.82, // widens the band's gaussian + landBand: 0.62, // metres either side of the foot plane that count + + /* --- the beats --- */ + intensity: 1.2, // master gain on the shafts + sweepWidth: 0.3, // 0..1 of the rank lit at once while the window travels + floodTime: 0.22, // 0..1 of the hold spent opening the window to the whole rank + retract: 0.72, // 0..1 how far the window closes again over the fade + + colorMouth: '#fff6dc', // where the shaft enters + colorFoot: '#ffd48a', // where it lands — warmer, it has lost its blue + colorMote: '#fffdf2', // the in-shaft lattice (unused while `shaftMote` is 0) + colorPool: '#ffe4ac', // the band on the ground + + /* --- the dust the room already has --- */ + // These three drive `world/DustMotes.js` itself. Nothing is emitted, nothing + // is copied: the motes already drifting past evaluate the rank's irradiance + // per-mote in their own vertex shader. + dustGain: 6.5, // alpha multiplier on a mote sitting on a shaft's centre line + dustTint: 0.85, // 0..1 how far a lit mote's colour is pulled to `colorDust` + dustSwell: 1.9, // extra point size at full irradiance + colorDust: '#fff2cf', // what a mote inside the shaft burns + + /* --- the caustic net on the floor --- */ + // Light that came through a stirring canopy is not evenly distributed once it + // lands: it folds, and the folds are the net. It runs in a lane down the cast + // line with its front locked to the same sweep the shafts are lit by. + netWidth: 2.5, // metres, half-width of the lane + netFeather: 1.0, // metres of soft edge on the lane + netBack: 2.2, // metres behind the front the net survives + netAhead: 3.6, // metres ahead of it + netDepth: 1.25, // metres of medium between the folding surface and the floor + netIor: 1.34, // refractive index of that medium; the shader gets 1 - 1/n + netDispersion: 0.07, // 0..1 how far R and B sit either side of G + netStep: 0.085, // metres between the Hessian taps — the net's finest detail + netAbsorb: 0.12, // 1/metres of extinction down the column + netFoldFloor: 0.24, // keeps 1/|det| finite; the widest a filament may get + netThreshold: 1.08, // compression under this is flat, and black + netGain: 0.62, + netSharp: 1.3, // exponent on the surviving compression + netRolloff: 0.22, // soft clip on the peak + netAmp: 0.15, // metres of relief in the folding surface + netCellScale: 0.6, // cells per metre + netCellRatio: 1.63, // the second lattice's scale, as a multiple of the first + netCellJitter: 0.85, // 0..1 how far a feature point wanders in its cell + netDriftAngle: 0.65, // radians, the bearing the lattice drifts on + netDriftSpeed: 0.14, // cells per second + netBoil: 0.85, // radians per second the feature points orbit + netRidgeMix: 0.13, // 0..1 of the direct worley-difference net + netRidgeScale: 2.2, // how tight a direct vein is + netRidgePower: 6.0, // the exponent on it + netPenumbra: 0.4, // 0..1 of the reach over which the projector's edge dies + netEmissive: 1.15, + netOpacity: 1.0, + netWash: 0.13, // the lit ground between the filaments + netFringeAt: 1.6, // where on the fold the colour hands over to the fringe + netDepthFade: 0.5, // metres of soft fade against standing geometry + netHeight: 0.02, // metres the quad floats above the floor + colorNet: '#ffe6b0', // the filaments + colorFringe: '#ffffff', // the very top of a fold + colorWash: '#5c4a2c', // the general light between them + + /* --- ash: floor dust kicked up where the band lands --- */ + // The one particle system, and it is *not* the shaft's dust. It is grit + // lifted off the stone by the band, and each batch is tinted by + // `LightShaft#irradianceAt()` sampled at the point it leaves from — the CPU + // mirror of the field the motes read on the GPU. + ashRate: 34, // particles/second + ashSize: 0.055, + ashSpeed: 0.9, // metres/second + ashLifetime: 2.6, // seconds + ashRise: 0.55, // metres/second of buoyancy + ashTurbulence: 0.5, + ashSpread: 1.5, // metres across the band they are born over + colorAshA: '#fff4d8', + colorAshB: '#ffd68e', + colorAshC: '#8a6a3c', + colorAshD: '#1a140c', + + /* --- the landing --- */ + burstSize: 2.4, // metres — the flare of air where the spear plants + burstIntensity: 2.6, + colorBurstA: '#ffffff', + colorBurstB: '#ffe6ae', + colorBurstC: '#c98a3a', + castFlash: 0.16, // screen flash as the sweep leaves the caster + colorCastFlash: '#fff3d6', + impactFlash: 0.34, // screen flash on landing + colorFlash: '#fff8e4', + impactShake: 0.22, // camera shake amplitude on landing. Light is not heavy + shakeDuration: 0.5, // seconds + rumble: 0.05, // continuous shake while the window sweeps + + /* --- dynamic light --- */ + lightIntensity: 16.0, + lightRadius: 15.0, + lightColor: '#ffe3ab', + lightHeight: 1.5, // metres above the band the light rides at + lightFlicker: 0.1, // 0..1 depth of the slow breath + lightFlickerSpeed: 0.6 // breaths per second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Godspear. + * + * Start in **The medium** and drag `anisotropy` from -0.9 to +0.9 while + * orbiting. That is the ability; everything else is staging for it. After that, + * `shaftTilt` in **The rank** decides whether the camera can ever get near an + * axis, and `dustGain` in **The room's dust** decides whether the shafts have + * anything in them. + * + * `shaftMote` is in **The medium** and ships at zero. Turning it up gives the + * shaft the library's own hashed dust lattice as well as the room's — which is + * two dust systems, and looks it. + */ +export const godspearSchema = { + 'The cast': [ + ['range', 5, 45, 0.1, 'max range'], + ['minRange', 0, 14, 0.1, 'min range'], + ['speed', 4, 90, 0.5, 'sweep speed'], + ['lifetime', 0.2, 10, 0.05, 'flood hold'], + ['fadeTime', 0.2, 8, 0.05, 'retract time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The rank': [ + ['shaftCount', 1, 8, 1, 'shafts'], + ['shaftSpan', 0.05, 2, 0.01, 'rank span × cast'], + ['shaftScatter', 0, 3, 0.01, 'placement slop (m)'], + ['shaftLengthJitter', 0, 1, 0.01, 'length jitter'], + ['shaftRadiusJitter', 0, 1, 0.01, 'radius jitter'], + ['shaftTilt', 0, 1, 0.01, 'tilt toward the sun'], + ['shaftPad', 1, 3, 0.01, 'hull padding'] + ], + 'The shaft': [ + ['shaftLength', 1, 30, 0.05, 'length (m)'], + ['shaftRadiusMouth', 0.02, 6, 0.01, 'mouth radius (m)'], + ['shaftRadiusFoot', 0.02, 8, 0.01, 'foot radius (m)'], + ['intensity', 0, 5, 0.01, 'intensity'], + ['colorMouth', 'at the mouth'], + ['colorFoot', 'at the foot'] + ], + 'The medium': [ + ['anisotropy', -0.95, 0.95, 0.005, 'anisotropy (g)'], + ['density', 0, 3, 0.01, 'in-scattering / m'], + ['extinct', 0, 0.6, 0.001, 'extinction (1/m)'], + ['soft', 0.02, 3, 0.01, 'core width'], + ['axialCurve', 0.05, 4, 0.01, 'axial curve'], + ['axialMouth', 0, 1, 0.01, 'density at the mouth'], + ['contact', 0.02, 4, 0.01, 'soft intersection (m)'], + ['steps', 4, 48, 1, 'march samples'], + ['jitter', 0, 2, 0.01, 'sample dither'], + ['shaftMote', 0, 2, 0.01, 'in-shaft lattice (0 = off)'], + ['colorMote', 'in-shaft lattice'] + ], + 'The canopy': [ + ['gobo', 0, 1, 0.01, 'gap depth'], + ['goboScale', 0.02, 3, 0.01, 'gap cycles / m'], + ['goboBias', -1, 1, 0.005, 'open sky'], + ['goboDrift', -1, 1, 0.005, 'canopy stir (rad/s)'] + ], + 'The band on the floor': [ + ['bounce', 0, 4, 0.01, 'bounce back'], + ['poolSoft', 0.02, 3, 0.01, 'band width'], + ['landBand', 0.02, 4, 0.01, 'band depth (m)'], + ['colorPool', 'band colour'] + ], + 'The beats': [ + ['sweepWidth', 0.02, 1, 0.005, 'lit window'], + ['floodTime', 0.01, 1, 0.005, 'flood-open share'], + ['retract', 0, 1, 0.01, 'retract depth'] + ], + "The room's dust": [ + ['dustGain', 0, 20, 0.05, 'brightness in a shaft'], + ['dustTint', 0, 1, 0.01, 'colour pull'], + ['dustSwell', 0, 6, 0.01, 'size in a shaft'], + ['colorDust', 'lit mote colour'] + ], + 'The caustic net': [ + ['netWidth', 0.1, 10, 0.05, 'lane half-width (m)'], + ['netFeather', 0.02, 5, 0.01, 'lane feather (m)'], + ['netBack', 0, 20, 0.05, 'survives behind (m)'], + ['netAhead', 0, 20, 0.05, 'survives ahead (m)'], + ['netDepth', 0, 6, 0.01, 'medium depth (m)'], + ['netIor', 1.001, 2.4, 0.001, 'refractive index'], + ['netDispersion', 0, 1, 0.005, 'dispersion'], + ['netStep', 0.005, 0.5, 0.001, 'tap spacing (m)'], + ['netAbsorb', 0, 2, 0.005, 'absorption (1/m)'], + ['netFoldFloor', 0.005, 1, 0.005, 'fold floor'], + ['netThreshold', 0, 4, 0.01, 'fold threshold'], + ['netGain', 0, 3, 0.01, 'fold gain'], + ['netSharp', 0.05, 4, 0.01, 'fold sharpness'], + ['netRolloff', 0, 2, 0.01, 'peak rolloff'], + ['netAmp', 0, 1, 0.005, 'surface relief (m)'], + ['netCellScale', 0.02, 3, 0.01, 'cells / m'], + ['netCellRatio', 0.1, 4, 0.01, 'second lattice ×'], + ['netCellJitter', 0, 1, 0.01, 'cell jitter'], + ['netDriftAngle', -3.2, 3.2, 0.01, 'drift bearing (rad)'], + ['netDriftSpeed', -2, 2, 0.005, 'drift (cells/s)'], + ['netBoil', 0, 4, 0.01, 'boil (rad/s)'], + ['netRidgeMix', 0, 1, 0.005, 'direct vein mix'], + ['netRidgeScale', 0.05, 8, 0.01, 'vein tightness'], + ['netRidgePower', 0.1, 16, 0.1, 'vein exponent'], + ['netPenumbra', 0.02, 1, 0.01, 'edge falloff'], + ['netEmissive', 0, 4, 0.01, 'emissive'], + ['netOpacity', 0, 2, 0.01, 'opacity'], + ['netWash', 0, 1, 0.005, 'wash between filaments'], + ['netFringeAt', 0, 5, 0.01, 'fringe handover'], + ['netDepthFade', 0.01, 3, 0.01, 'depth fade (m)'], + ['netHeight', 0.002, 0.3, 0.001, 'float above floor (m)'], + ['colorNet', 'filaments'], + ['colorFringe', 'fold fringe'], + ['colorWash', 'wash'] + ], + Ash: [ + ['ashRate', 0, 300, 1, 'ash / second'], + ['ashSize', 0.005, 0.4, 0.005, 'ash size'], + ['ashSpeed', 0, 6, 0.01, 'ash speed (m/s)'], + ['ashLifetime', 0.2, 10, 0.05, 'ash lifetime (s)'], + ['ashRise', -2, 4, 0.01, 'ash rise'], + ['ashTurbulence', 0, 3, 0.01, 'ash turbulence'], + ['ashSpread', 0, 6, 0.05, 'born across (m)'], + ['colorAsh*', 'Ash colour'] + ], + 'The landing': [ + ['burstSize', 0.1, 10, 0.05, 'flare radius (m)'], + ['burstIntensity', 0, 8, 0.05, 'flare intensity'], + ['castFlash', 0, 1.5, 0.01, 'cast flash'], + ['impactFlash', 0, 1.5, 0.01, 'impact flash'], + ['impactShake', 0, 2, 0.01, 'impact shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration (s)'], + ['rumble', 0, 1, 0.005, 'sweep rumble'], + ['colorBurstA', 'flare core'], + ['colorBurstB', 'flare body'], + ['colorBurstC', 'flare rim'], + ['colorCastFlash', 'cast flash'], + ['colorFlash', 'impact flash'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 10, 0.05, 'light height (m)'], + ['lightFlicker', 0, 1, 0.01, 'breath depth'], + ['lightFlickerSpeed', 0, 6, 0.01, 'breaths / second'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/grovecall.js b/src/config/abilities/grovecall.js new file mode 100644 index 0000000..47c5750 --- /dev/null +++ b/src/config/abilities/grovecall.js @@ -0,0 +1,468 @@ +/* ================================================================== */ +/* GROVECALL — verdant, far cast */ +/* ================================================================== */ +/** + * Six trees are called up out of the floor on the circle, and the sun comes + * through them. + * + * The ability is about **scale contrast**, which is a specific and slightly + * awkward thing to build: everything else in this sandbox is either ankle-high + * or made of light, and a three-metre trunk only reads as three metres if + * something in frame is obviously not. So the numbers below are deliberately + * lopsided — `treeHeight` is an order of magnitude above the crystal field's + * spikes, `riseTime` is half a second for all of it, and the leaves that shed + * off the canopies are sized so that a leaf near the top of a trunk is a few + * pixels. Wind `treeHeight` down to 1 and the whole cast stops working, which + * is the clearest possible demonstration of what it is for. + * + * The **shafts are geometry**. Not a post-process god-ray, not a billboard: a + * `Curtain` in SHAFT mode, anchored up at canopy height with `-frame.uLightDir` + * as its up axis, so each sheet is a tapered translucent volume descending along + * the stage's actual sun direction and terminating by *intersecting the floor*. + * That is why they hold up when the camera orbits: you can walk round one and + * see it edge-on, and the grazing term in the curtain shader flares it exactly + * as a real column of lit dust would. Moving the stage's key light moves them. + * + * Everything with a unit below is resolved inside the update loop. A cast + * captures the field's own record dice, the curtain's per-sheet dice, one seed, + * and one timestamp per tree — the moment the bearing wave released it. + */ +export const grovecall = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + zoneRadius: 5.0, // radius of the far-cast circle, metres + speed: 34.0, // how fast the call runs out to the circle, metres/second + lifetime: 3.2, // seconds the grove stands + fadeTime: 2.0, // seconds it takes to sink back + cooldown: 2.0, // seconds before it can be cast again + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the grove --- */ + trees: 6, // trunks called up (hard ceiling 12) + ringInner: 0.55, // 0..1 of `zoneRadius` — the inside of the band trees stand in + ringOuter: 1.0, // ... and the outside + radialCurve: 1.0, // <1 pushes the band toward the rim + radialJitter: 0.35, // metres of radial wander + angleJitter: 0.3, // radians of bearing wander + sweepTime: 0.55, // seconds the wave takes to run all the way round the ring + sweepStagger: 0.06, // extra seconds of random slop on that wave + growTime: 0.5, // seconds a trunk takes to reach full height — THE number + riseOvershoot: 0.18, // how far past full height the growth carries + settle: 0.6, // seconds that overshoot damps out over + springRate: 9.0, // radians/second of the overshoot ring + emergeSink: 0.95, // fraction of its height a trunk is buried at emergence 0 + birthScale: 0.55, // footprint scale the instant it breaks the surface + birthFade: 0.5, // seconds the sap flash decays over + breachAt: 0.12, // emergence fraction that fires the roots and the soil + sinkDepth: 1.2, // extra metres a sinking trunk drops beyond its own height + + /* --- how big they are --- */ + treeHeight: 3.0, // metres, the far side of the ring + heightNear: 2.6, // metres, the inner side of the ring + heightCurve: 1.0, // how the height ramps across the band + heightJitter: 0.22, // ± fraction per tree + treeRadius: 0.62, // metres — footprint radius of a canopy at the inner side + treeRadius2: 0.72, // ... and at the rim + radiusCurve: 1.0, // how the radius ramps across the band + radiusJitter: 0.18, // ± fraction per tree + lean: 0.1, // radians the trunks lean outward from the centre + leanJitter: 0.8, // ± fraction + leanOutward: 1.0, // weight of "out from the centre" in the lean direction + // Small but not zero. A grove has no heading, so the obvious value here is 0 + // — but the two weights are normalised before use, and with one of them at + // zero the other stops meaning anything at all: dragging `leanOutward` on a + // standing grove does nothing, because a vector of any length pointing + // radially outward normalises to the same vector. A twelfth of a turn's worth + // of downrange bias makes the pair a real ratio again, and it reads as the + // trees having grown away from whatever called them. + leanForward: 0.12, // weight of "downrange" in the lean direction + twist: 1.0, // 0..1 of a full turn of random yaw + tilt: 0.06, // radians of extra random tip, any bearing + + /* --- the shape of one tree (rebuilds the geometry when it moves) --- */ + /** + * A tapered trunk with branch stubs and a canopy of overlapping irregular + * discs. Cheap and readable in silhouette is the whole brief: the canopy is + * five shallow cones, not a sphere and not a billboard, because five cones at + * random bearings have a ragged outline from every angle for about ninety + * triangles, and that outline is the only thing that says "tree" at forty + * metres. + */ + treeSides: 5, // faces around the trunk, 3..8 + trunkTaper: 0.55, // >0 narrows the trunk toward the crown + trunkBase: 0.19, // trunk radius at the ground, unit space (× the instance radius) + trunkLean: 0.14, // 0..1 of a unit height the trunk's own axis wanders + canopyBase: 0.52, // 0..1 up the tree where the crown starts + branchStubs: 4, // branch stubs, 0..6 + branchLength: 0.3, // stub length, fraction of the unit height + branchTilt: 0.45, // -1 points the stubs down, +1 straight up + canopyDiscs: 5, // overlapping discs in the crown, 1..8 + canopyRadius: 0.34, // disc radius, unit space + canopySpread: 0.16, // how far the discs are scattered off the axis, unit space + canopyDome: 0.16, // how far a disc's middle lifts above its rim, unit space + treeRough: 0.4, // 0..1 irregularity of the trunk facets and the disc rims + + /* --- bark and leaf --- */ + colorBark: '#4a3a28', // the lit face of the trunk + colorBarkDeep: '#241b12', // the shadow in a bark furrow + colorLeaf: '#5f8a2a', // the canopy in direct light + colorLeafDeep: '#223a12', // the canopy in its own shade + colorLeafGlow: '#a8d84a', // a leaf lit from behind — the translucency term + colorSap: '#c8ff9a', // the flash as a trunk breaks the surface + canopySplit: 0.5, // 0..1 up the tree where bark becomes leaf + canopySoft: 0.06, // 0..1 feather on that boundary + barkGrain: 0.6, // 0..1 depth of the furrows + barkScale: 7.0, // furrow cycles per metre, world space + leafMottle: 0.65, // 0..1 how broken up the canopy colour is + leafScale: 3.4, // mottle cycles per unit height, local space + backlight: 1.6, // gain on the leaf translucency + backlightSharp: 2.4, // exponent on it — higher keeps it to the rim of the sun + leafRough: 0.82, // surface roughness of the standard material + sapGlow: 2.2, // emissive gain on the birth flash + groveGlow: 1.0, // master emissive gain + groveOpacity: 1.0, + + /* --- the light shafts --- */ + /** + * `shaftTop` is where the sheets are anchored — canopy height. They then run + * **down** the sun direction for `shaftTop / lightDir.y` metres, times + * `shaftOvershoot`, which is how they arrive at the floor no matter where the + * stage's key light is pointing. `shaftOvershoot` above 1 pushes them through + * the floor so they terminate on the intersection rather than in mid-air; that + * is the single most important number here and it is why the ability does not + * need a fake pool decal on the ground. + */ + shafts: 9, // sheets (hard ceiling 16) + shaftTop: 3.1, // metres above the floor the shafts start at + shaftOvershoot: 1.18, // × the geometric drop to the floor + shaftSpread: 0.9, // 0..1 of `zoneRadius` the shafts are scattered over + shaftScatter: 0.5, // metres of extra hashed slop off that scatter + shaftWidth: 1.5, // metres across a sheet at the canopy + shaftWidthJitter: 0.35, // ± fraction + shaftHeightJitter: 0.12, // ± fraction of the computed drop + shaftTaper: 1.45, // width multiplier where it meets the floor — >1 splays + shaftLean: 0.35, // metres the foot of the shaft is pushed sideways + shaftLeanJitter: 0.7, // ± fraction + shaftRiseSpread: 0.5, // 0..1 stagger of the shafts appearing + shaftCore: 0.42, // 0..1 of the half-width the bright core covers + shaftCanopy: 0.38, // 0..1 threshold — how much light gets past the leaves + shaftCanopySoft: 0.3, // 0..1 feather on that gate + shaftCanopyScale: 0.7, // gap cycles per metre along the sheet + shaftMote: 0.85, // 0..1 the dust hanging in the shaft + shaftMoteScale: 2.0, // motes per metre + shaftMoteSize: 0.055, // metres + shaftMoteDrift: 0.05, // metres/second the motes settle + shaftRipple: 0.1, // metres of travelling ripple across a shaft + shaftRippleLength: 3.0, // metres, crest to crest + shaftRippleSpeed: 0.35, // metres/second + shaftFold: 0.16, // metres of the second, longer fold + shaftFoldLength: 7.0, // metres + shaftFoldSpeed: 0.2, // metres/second + shaftBody: 0.1, // 0..1 how much α a shaft is allowed at all — keep it low + shaftAlphaBase: 1.0, // coverage at the canopy end + shaftAlphaTop: 0.15, // coverage at the floor end + shaftAlphaCurve: 2.4, // exponent between them + shaftEmissionBase: 1.1, // radiance at the canopy end + shaftEmissionTop: 0.4, // radiance at the floor end + shaftEmissionCurve: 0.7, // DELIBERATELY not `shaftAlphaCurve` — see Curtain.js + shaftFootFade: 0.04, // 0..1 of the length + shaftHeadFade: 0.18, // 0..1 of the length + shaftEdgeFade: 0.3, // 0..1 across a sheet + shaftGraze: 1.0, // 0..1 how much of the 1/|N·V| path term is applied + shaftGrazeFloor: 0.1, // clamp on |N·V| + shaftSoftFade: 0.4, // metres of depth fade against opaque geometry + shaftGlow: 1.35, // emissive gain + shaftOpacity: 1.0, + shaftTintSpread: 0.3, // 0..1 how far apart neighbouring shafts are tinted + colorShaft: '#e8f0c0', // the core of a shaft + colorShaftEdge: '#8aa85f', // its outside + colorShaftMote: '#fffbe0', // the dust in it + colorShaftBody: '#2a3418', // what little it occludes with + + /* --- leaves shed off the canopies --- */ + /** + * Four-stop lifetime gradient, `A` at birth through `D` as it dies, spelled + * out rather than derived from the canopy palette — the whole point is that + * the litter browns off while the grove stays green. + */ + leafRate: 34, // leaves shed per second across the whole grove + leafBurst: 14, // extra shed by each tree as its canopy opens + leafSize: 0.14, + leafSpeed: 1.1, // metres/second + leafLifetime: 3.4, // seconds — long: they have three metres to fall + leafGravity: -1.5, // metres/second²; light enough to flutter + leafSpin: 3.2, // radians/second of tumble + leafDrift: 0.7, // turbulence multiplier + colorLeafA: '#a8d84a', + colorLeafB: '#6f9a32', + colorLeafC: '#8a7a2a', + colorLeafD: '#3a2e14', + + /* --- pollen hanging in the shafts --- */ + pollenRate: 40, // particles/second + pollenSize: 0.045, + pollenSpeed: 0.5, // metres/second + pollenLifetime: 4.0, // seconds + pollenRise: 0.28, // upward drift, metres/second + pollenTurbulence: 0.6, + colorPollenA: '#fffbe0', + colorPollenB: '#e8f0c0', + colorPollenC: '#a8b86a', + colorPollenD: '#3a4020', + + /* --- soil thrown up by the roots --- */ + soilBreach: 26, // chips per trunk breaking the surface + soilSize: 0.09, + soilSpeed: 4.5, // metres/second + soilLifetime: 1.3, // seconds + soilGravity: -16.0, // metres/second² + colorSoilA: '#4a3a26', + colorSoilB: '#33281a', + colorSoilC: '#241c12', + colorSoilD: '#171009', + + /* --- what the floor does --- */ + rootRadius: 1.5, // metres — the crack the roots open at a trunk + rootLife: 7.0, // seconds it lingers + rootWidth: 0.14, // thickness of the split + rootIntensity: 0.85, + colorRootA: '#1a1408', // the dark of the split + colorRootB: '#6f9a32', // the growth glowing out of it + duffRadius: 1.7, // metres — the litter ring around a trunk + duffLife: 6.0, // seconds + duffIntensity: 0.5, + colorDuffA: '#3a2e18', + colorDuffB: '#5f6a34', + callRate: 0.5, // root cracks laid per metre while the call runs out + callRadius: 0.55, // metres — one of those + callLife: 2.4, // seconds + + /* --- the moment the grove arrives --- */ + burstSize: 3.4, // the shell of thrown growth at the centre, metres + burstIntensity: 1.0, + burstLeaves: 120, // extra leaves thrown at the call + burstSoil: 60, // extra soil thrown at the call + impactShake: 0.75, + shakeDuration: 0.8, // seconds + impactFlash: 0.12, // screen flash as the grove lands + rumble: 0.05, // continuous shake while the call runs out + colorBurstA: '#3a5a1a', // shell body + colorBurstB: '#6f9a32', + colorBurstC: '#c8ff9a', // the filaments and the fresnel rim — this carries it + colorFlash: '#c8ff9a', // the full-screen flash + + /* --- dynamic light --- */ + lightIntensity: 14.0, + lightRadius: 13.0, // metres + lightColor: '#a8d060' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Grovecall. + * + * Four controls carry it. `treeHeight` and `growTime` are the scale contrast — + * three metres in half a second, and both of them are wrong in an interesting + * way the moment you move either. `sweepTime` is whether the grove arrives as a + * wave or as an event. `shaftOvershoot` is whether the light reaches the ground. + * + * Everything in *The tree's shape* rebuilds three instanced meshes when it + * moves. A tree is about four hundred triangles and there are three of them; the + * rebuild is cheaper than the branch that would avoid it, and it is what keeps + * `canopyDiscs` a slider you can drag rather than a constant you have to reload + * the page to change. + */ +export const grovecallSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['zoneRadius', 1, 16, 0.1, 'circle radius'], + ['speed', 4, 140, 0.5, 'call speed'], + ['lifetime', 0.2, 12, 0.05, 'standing time'], + ['fadeTime', 0.2, 8, 0.05, 'sink time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The grove': [ + ['trees', 1, 12, 1, 'trunks'], + ['ringInner', 0, 1.2, 0.01, 'band, inner'], + ['ringOuter', 0.1, 1.4, 0.01, 'band, outer'], + ['radialCurve', 0.2, 4, 0.01, 'band curve'], + ['radialJitter', 0, 3, 0.05, 'radial wander'], + ['angleJitter', 0, 1.5, 0.01, 'bearing wander'], + ['sweepTime', 0, 3, 0.01, 'wave around the ring'], + ['sweepStagger', 0, 1, 0.01, 'wave slop'], + ['growTime', 0.05, 3, 0.01, 'time to full height'], + ['riseOvershoot', 0, 1, 0.01, 'overshoot'], + ['settle', 0.05, 3, 0.01, 'settle time'], + ['springRate', 1, 30, 0.5, 'spring rate'], + ['emergeSink', 0, 1.5, 0.01, 'buried depth'], + ['birthScale', 0.05, 1, 0.01, 'birth scale'], + ['birthFade', 0.05, 2, 0.01, 'sap flash time'], + ['breachAt', 0.02, 1, 0.01, 'breach point'], + ['sinkDepth', 0, 6, 0.05, 'sink depth'] + ], + 'How big they are': [ + ['treeHeight', 0.3, 8, 0.05, 'height at the rim'], + ['heightNear', 0.3, 8, 0.05, 'height inside'], + ['heightCurve', 0.2, 4, 0.01, 'height curve'], + ['heightJitter', 0, 1, 0.01, 'height jitter'], + ['treeRadius', 0.05, 2.5, 0.01, 'spread inside'], + ['treeRadius2', 0.05, 2.5, 0.01, 'spread at the rim'], + ['radiusCurve', 0.1, 3, 0.01, 'spread curve'], + ['radiusJitter', 0, 1, 0.01, 'spread jitter'], + ['lean', 0, 1, 0.01, 'lean'], + ['leanJitter', 0, 2, 0.01, 'lean jitter'], + ['leanOutward', -1, 2, 0.01, 'lean outward'], + ['leanForward', -1, 2, 0.01, 'lean downrange'], + ['twist', 0, 1, 0.01, 'random yaw'], + ['tilt', 0, 0.6, 0.01, 'random tip'] + ], + "The tree's shape": [ + ['treeSides', 3, 8, 1, 'trunk faces'], + ['trunkTaper', 0, 2, 0.01, 'trunk taper'], + ['trunkBase', 0.03, 0.45, 0.005, 'trunk thickness'], + ['trunkLean', 0, 0.5, 0.005, 'trunk wander'], + ['canopyBase', 0.1, 0.95, 0.01, 'crown starts at'], + ['branchStubs', 0, 6, 1, 'branch stubs'], + ['branchLength', 0.02, 0.6, 0.005, 'stub length'], + ['branchTilt', -1, 1, 0.01, 'stub tilt'], + ['canopyDiscs', 1, 8, 1, 'canopy discs'], + ['canopyRadius', 0.05, 0.5, 0.005, 'disc radius'], + ['canopySpread', 0, 0.3, 0.005, 'disc scatter'], + ['canopyDome', 0, 0.4, 0.005, 'disc dome'], + ['treeRough', 0, 1, 0.01, 'roughness'] + ], + 'Bark & leaf': [ + ['colorBark', 'bark'], + ['colorBarkDeep', 'bark furrow'], + ['colorLeaf', 'canopy, lit'], + ['colorLeafDeep', 'canopy, shaded'], + ['colorLeafGlow', 'canopy, backlit'], + ['colorSap', 'sap flash'], + ['canopySplit', 0.1, 0.95, 0.01, 'bark / leaf line'], + ['canopySoft', 0.005, 0.4, 0.005, 'line feather'], + ['barkGrain', 0, 1.5, 0.01, 'furrow depth'], + ['barkScale', 0.5, 24, 0.1, 'furrows / metre'], + ['leafMottle', 0, 1.5, 0.01, 'canopy mottle'], + ['leafScale', 0.2, 12, 0.05, 'mottle scale'], + ['backlight', 0, 6, 0.05, 'backlight'], + ['backlightSharp', 0.2, 8, 0.05, 'backlight sharpness'], + ['leafRough', 0.05, 1, 0.01, 'surface roughness'], + ['sapGlow', 0, 8, 0.05, 'sap glow'], + ['groveGlow', 0, 4, 0.01, 'grove glow'], + ['groveOpacity', 0.1, 1, 0.01, 'grove opacity'] + ], + 'The light shafts': [ + ['shafts', 0, 16, 1, 'shafts'], + ['shaftTop', 0.2, 8, 0.05, 'anchored at'], + ['shaftOvershoot', 0.2, 2.5, 0.01, 'reach past the floor'], + ['shaftSpread', 0, 1.6, 0.01, 'scatter radius'], + ['shaftScatter', 0, 3, 0.05, 'scatter slop'], + ['shaftWidth', 0.1, 8, 0.05, 'width'], + ['shaftWidthJitter', 0, 1, 0.01, 'width jitter'], + ['shaftHeightJitter', 0, 1, 0.01, 'length jitter'], + ['shaftTaper', 0.1, 3, 0.01, 'splay at the floor'], + ['shaftLean', -3, 3, 0.05, 'lean'], + ['shaftLeanJitter', 0, 2, 0.01, 'lean jitter'], + ['shaftRiseSpread', 0, 0.95, 0.01, 'arrival stagger'], + ['shaftCore', 0.05, 1.5, 0.01, 'core width'], + ['shaftCanopy', 0, 1, 0.01, 'canopy gate'], + ['shaftCanopySoft', 0.01, 1, 0.01, 'gate feather'], + ['shaftCanopyScale', 0.05, 4, 0.01, 'gaps / metre'], + ['shaftMote', 0, 3, 0.01, 'dust'], + ['shaftMoteScale', 0.2, 8, 0.05, 'motes / metre'], + ['shaftMoteSize', 0.005, 0.3, 0.005, 'mote size'], + ['shaftMoteDrift', -1, 1, 0.005, 'mote drift'] + ], + 'The light shafts/Drawing': [ + ['shaftRipple', 0, 2, 0.01, 'ripple'], + ['shaftRippleLength', 0.2, 12, 0.05, 'ripple length'], + ['shaftRippleSpeed', -4, 4, 0.01, 'ripple speed'], + ['shaftFold', 0, 2, 0.01, 'fold'], + ['shaftFoldLength', 0.5, 24, 0.1, 'fold length'], + ['shaftFoldSpeed', -4, 4, 0.01, 'fold speed'], + ['shaftBody', 0, 1, 0.005, 'occlusion'], + ['shaftAlphaBase', 0, 2, 0.01, 'coverage at canopy'], + ['shaftAlphaTop', 0, 2, 0.01, 'coverage at floor'], + ['shaftAlphaCurve', 0.1, 6, 0.05, 'coverage curve'], + ['shaftEmissionBase', 0, 3, 0.01, 'radiance at canopy'], + ['shaftEmissionTop', 0, 3, 0.01, 'radiance at floor'], + ['shaftEmissionCurve', 0.1, 6, 0.05, 'radiance curve'], + ['shaftFootFade', 0, 0.5, 0.005, 'canopy-end fade'], + ['shaftHeadFade', 0, 0.8, 0.005, 'floor-end fade'], + ['shaftEdgeFade', 0, 1, 0.01, 'edge fade'], + ['shaftGraze', 0, 1, 0.01, 'grazing term'], + ['shaftGrazeFloor', 0.02, 1, 0.005, 'grazing clamp'], + ['shaftSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['shaftGlow', 0, 5, 0.01, 'glow'], + ['shaftOpacity', 0, 2, 0.01, 'opacity'], + ['shaftTintSpread', 0, 1, 0.01, 'tint spread'], + ['colorShaft', 'shaft core'], + ['colorShaftEdge', 'shaft edge'], + ['colorShaftMote', 'shaft dust'], + ['colorShaftBody', 'shaft occlusion'] + ], + 'Leaves & pollen': [ + ['leafRate', 0, 300, 1, 'leaf rate'], + ['leafBurst', 0, 120, 1, 'leaves / canopy'], + ['leafSize', 0.005, 0.6, 0.005, 'leaf size'], + ['leafSpeed', 0, 8, 0.05, 'leaf speed'], + ['leafLifetime', 0.2, 10, 0.05, 'leaf lifetime'], + ['leafGravity', -12, 0, 0.05, 'leaf gravity'], + ['leafSpin', 0, 15, 0.05, 'leaf tumble'], + ['leafDrift', 0, 3, 0.01, 'leaf drift'], + ['pollenRate', 0, 300, 1, 'pollen rate'], + ['pollenSize', 0.005, 0.4, 0.005, 'pollen size'], + ['pollenSpeed', 0, 6, 0.05, 'pollen speed'], + ['pollenLifetime', 0.2, 10, 0.05, 'pollen lifetime'], + ['pollenRise', -2, 4, 0.01, 'pollen rise'], + ['pollenTurbulence', 0, 3, 0.01, 'pollen turbulence'], + ['colorLeaf*', 'Leaf colour'], + ['colorPollen*', 'Pollen colour'] + ], + 'Roots & soil': [ + ['soilBreach', 0, 120, 1, 'soil / trunk'], + ['soilSize', 0.005, 0.4, 0.005, 'soil size'], + ['soilSpeed', 0, 18, 0.05, 'soil speed'], + ['soilLifetime', 0.1, 5, 0.05, 'soil lifetime'], + ['soilGravity', -40, 0, 0.1, 'soil gravity'], + ['rootRadius', 0.1, 6, 0.05, 'root crack radius'], + ['rootLife', 0.5, 20, 0.1, 'root crack lifetime'], + ['rootWidth', 0.01, 0.6, 0.005, 'root crack width'], + ['rootIntensity', 0, 3, 0.01, 'root crack intensity'], + ['duffRadius', 0.1, 6, 0.05, 'litter radius'], + ['duffLife', 0.2, 15, 0.1, 'litter lifetime'], + ['duffIntensity', 0, 3, 0.01, 'litter intensity'], + ['callRate', 0.05, 5, 0.05, 'cracks / metre'], + ['callRadius', 0.05, 3, 0.05, 'call crack radius'], + ['callLife', 0.2, 10, 0.1, 'call crack lifetime'], + ['colorRootA', 'root dark'], + ['colorRootB', 'root growth'], + ['colorDuffA', 'litter body'], + ['colorDuffB', 'litter rim'], + ['colorSoil*', 'Soil colour'] + ], + 'The arrival': [ + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstLeaves', 0, 500, 1, 'burst leaves'], + ['burstSoil', 0, 400, 1, 'burst soil'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'call rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst filaments'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/hail.js b/src/config/abilities/hail.js new file mode 100644 index 0000000..0136209 --- /dev/null +++ b/src/config/abilities/hail.js @@ -0,0 +1,452 @@ +/* ================================================================== */ +/* HAILWRATH — frost, far cast */ +/* ================================================================== */ +/** + * A column of freezing air over the circle, and then it comes down. + * + * There is no travelling front here. The front is **vertical**: every stone + * leaves one vanishing point high above and behind the caster and arrives on + * its own timer, and that timer is a hash of *where it lands* rather than of + * its index in an array — so the circle fills in patches that spread inward + * from the boundary and no two seeds fill it in the same order. + * + * Two things in this block are worth understanding before you drag anything. + * + * **The envelope.** `stormRamp` / `stormPeak` / `stormTail` / `stormFloor` + * shape how the arrival *rate* runs over the storm: up, over the top, and away. + * They are not a schedule the ability walks — they are the four coefficients of + * a curve whose integral is inverted into a clock, so moving any of them + * re-times every stone that is still in the sky, including with the game + * paused. `HailAbility`'s doc comment explains the mechanism and what it costs. + * + * **The pocks are unitless.** A stone that lands posts a *fraction of the + * radius* into the ground field's event list, never a metre. That is what lets + * `zoneRadius` re-scale a floor that is already cratered: drag it while paused + * and the whole pattern of holes grows with the circle rather than sitting + * inside a bigger one. + * + * Everything else obeys the usual rule — every dimension below is resolved + * against this block inside the update loop, on a zero-length frame included. + */ +export const hail = { + /* --- the cast --- */ + range: 26.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 120.0, // how fast the chill runs out to the circle, metres/second + cooldown: 1.4, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 5.0, // the footprint the indicator draws, metres + + /* --- the storm clock --- */ + // `stormLead` + `stormTime` + `fallTime` is the IMPACT phase; `holdTime` is + // the fade, during which the pocks rime over and the field thaws back. + stormLead: 0.14, // seconds before the first stone leaves the cloud + stormTime: 1.45, // seconds the launches are spread over + fallTime: 0.45, // seconds one stone spends in the air, at the storm's peak rate + holdTime: 2.8, // seconds the cratered floor stands after the last stone + + /* --- the arrival envelope --- */ + // The rate profile, integrated into a clock. See `HailAbility#_stormClock`. + stormRamp: 1.4, // exponent on the rise; >1 starts slow and gathers + stormPeak: 0.45, // where the peak sits, 0..1 of the storm + stormTail: 2.0, // exponent on the fall; >1 tails off rather than stopping + stormFloor: 0.35, // 0..1 rate floor — how much hail falls away from the peak + + /* --- how the circle fills --- */ + stones: 30, // stones in one cast (the pock list holds 32; see the class) + fillBias: -1.0, // +1 fills outward from the middle, −1 inward from the rim + fillScatter: 0.62, // 0 = a clean radial order, 1 = the spatial hash alone + hashCell: 1.35, // the hash lattice, metres — about one stone's spacing + rimCrowd: 0.62, // <0.5 crowds the boundary, >0.5 crowds the middle + speedJitter: 0.22, // ±fraction on one stone's fall time + + /* --- one stone --- */ + stoneRadius: 0.17, // metres + stoneJitter: 0.45, // ±fraction of that radius + stoneStretch: 2.2, // scale along the velocity — a falling stone is a streak + stoneAlign: 0.94, // 0 tumbles freely, 1 lays the long axis on the heading + stoneSpin: 5.0, // residual tumble, radians/second + stoneFlash: 0.1, // seconds the birth pop on a stone decays over + skyHeight: 22.0, // metres above the floor the cloud sits at + skyBack: 3.5, // metres behind the caster + skySpread: 1.2, // ±metres of scatter around that one point + fallCurve: 1.35, // easing on launch → land; >1 accelerates downward + driftSide: 0.55, // metres of lateral drift on the way down + driftTurns: 0.6, // cycles of that drift over the fall + driftDecay: 1.8, // >0 pulls the drift to exactly zero at the floor + + /* --- the stone's silhouette (moving these rebuilds the geometry) --- */ + stoneFacets: 1, // icosphere subdivisions, 0–2. 1 is 80 triangles + stoneLumps: 0.34, // low-frequency deformation, × the radius + stoneLumpScale: 1.7, // lumps per unit radius + stoneChip: 0.24, // high-frequency chipping + stoneCuts: 6, // planar fracture faces sliced off it + stoneCutDepth: 0.26, // how far those planes bite, × the radius + + /* --- the stone's shading --- */ + iceRoughness: 0.42, // surface roughness of the standard material + iceMilk: 0.72, // how much cloudy core shows through the clear ice + iceMilkScale: 3.1, // milk features per unit radius, sampled in LOCAL space + iceFacetTint: 0.3, // per-facet value break-up + iceRim: 1.3, // fresnel edge light + iceRimPower: 2.4, // how tight that edge is + iceGlint: 1.0, // the hard specks that catch the sun + iceGlintScale: 7.5, // specks per unit radius + iceLead: 0.75, // heat, or rather cold, on the leading facets + iceLeadSharp: 2.6, // how tightly that hugs the heading + iceGlow: 1.15, // emissive gain + colorIce: '#f2fbff', // clear ice + colorMilk: '#9fd8ee', // the cloudy core + colorRim: '#dff4ff', // the fresnel edge + colorGlint: '#ffffff', // the specks and the leading face + + /* --- the fall streak (one instanced strip, all thirty of them) --- */ + trailSpan: 0.16, // seconds of flight the streak reaches back over + trailBurn: 0.1, // seconds the tail takes to catch the head after landing + trailWidth: 0.055, // metres at the head + trailTaper: 1.7, // >1 sharpens the tail to a point + trailLift: 0.0, // metres the tail floats off the flown path + trailOpacity: 0.85, + trailGlow: 1.1, + trailCore: 2.6, // how tightly light crowds the centre line + trailHeadBias: 0.6, // >0 keeps the brightness near the stone + trailNoise: 0.35, + trailNoiseScale: 2.2, // features per metre + trailNoiseSpeed: 1.1, + trailSoftFade: 0.3, // metres of depth feather against solid geometry + colorTrailA: '#ffffff', + colorTrailB: '#dff4ff', + colorTrailC: '#9fd8ee', + colorTrailD: '#2a5d75', + + /* --- the pocked floor --- */ + // A GroundField in POCK mode. `pockRadius` is the size of one full-strength + // crater in metres; everything the stones themselves post is a fraction. + pockHeight: 0.02, // metres the quad floats above the floor + pockRadius: 0.52, // metres, a full-strength crater + pockDepth: 0.18, // metres it digs + pockRim: 0.05, // metres of piled lip around it + pockLift: 0.06, // metres that lip stands proud + pockGrain: 0.4, // frost grain over the whole field, 0..1 + pockDig: 11.0, // how fast a crater digs itself in, events/second + pockLife: 9.0, // seconds a crater weathers away over + pockEdge: 0.55, // metres of feather on the field's own boundary + pockRagged: 0.22, // how far that boundary wanders, × the radius + pockRaggedScale: 0.55, // lobes per metre + pockWarp: 0.6, // metres of domain warp on those lobes + pockRelief: 0.85, // how hard the height field tilts the fake normal + pockNormalStep: 0.05, // metres between the height taps + pockAmbient: 0.36, // floor on the diffuse term + pockWrap: 0.5, // 0..1 wraps the terminator round the back + pockSpecular: 0.5, + pockGloss: 30.0, // Blinn exponent + pockParallax: 0.2, // metres of view-driven offset on the interior + pockOpacity: 0.95, + pockEmissive: 0.75, // multiplier on the cold glow in a fresh crater + pockDepthFade: 0.4, // metres of soft fade against standing geometry + colorPock: '#2a5d75', // the bruised stone the crater is cut into + colorPockDeep: '#0d2530', // the bottom of the hole + colorPockRim: '#f2fbff', // the white lip — the mark the ability is named for + colorPockGlow: '#9fd8ee', // the cold that comes off a fresh one + + /* --- the rime-over --- */ + // Frost does not repaint the craters, it *spreads their lips*: `rimeRim` is + // the same `thickness` the pocks already use, walked up over the hold, which + // widens the white band until it closes over the bowl. Trying to do it by + // crossfading the base colour looked like the floor was being tinted. + rimeShare: 0.55, // 0..1 of the hold the frost takes to close over + rimeRim: 0.17, // metres the lip has spread to at full rime + rimeGrain: 0.95, // grain at full rime + rimeSpecular: 1.35, // frost is glossier than wet stone + rimeGloss: 70.0, + rimeLift: 0.11, // metres the rimed lip stands proud + thawStart: 0.62, // 0..1 of the hold before the field starts eating back + + /* --- the freezing column --- */ + mistRate: 60, // particles/second + mistSize: 1.5, + mistSpeed: 1.2, // metres/second + mistLifetime: 2.4, // seconds + mistRise: -0.5, // metres/second — this fog sinks + mistOpacity: 0.1, + mistTurbulence: 0.5, + mistSpread: 0.85, // 0..1 of `zoneRadius` the column occupies + mistHeight: 3.4, // metres of column above the floor + colorMistA: '#dff4ff', + colorMistB: '#9fd8ee', + colorMistC: '#4d7f96', + colorMistD: '#22404f', + + /* --- what one stone throws --- */ + chipCount: 9, // ice chips knocked off per stone + chipSize: 0.06, + chipSpeed: 5.5, // metres/second + chipLifetime: 0.9, // seconds + chipGravity: -19.0, // metres/second² + chipSpread: 0.85, // 0..1 cone width + chipSpin: 11.0, // radians/second + colorChipA: '#ffffff', + colorChipB: '#dff4ff', + colorChipC: '#9fd8ee', + colorChipD: '#3d6c82', + sprayCount: 16, // the bright shatter spray + spraySize: 0.1, + spraySpeed: 8.5, // metres/second + sprayLifetime: 0.35, // seconds + sprayGravity: -13.0, + sprayStretch: 0.22, // how far a streak smears along its velocity + sprayRise: 0.55, // 0..1 how much of the spray goes up rather than out + colorSprayA: '#ffffff', + colorSprayB: '#ffffff', + colorSprayC: '#9fd8ee', + colorSprayD: '#2a5d75', + + /* --- the bounce --- */ + // One lit chip the size of the stone itself, thrown back up off the crater. + // A second Projectile pass for one hop is two more draw calls and a whole + // flight to keep in step with the first for something on screen for 200 ms. + // It comes out of the chip system, so it falls under `chipGravity` — a + // separate gravity slider here would be a control that does nothing, which is + // worse than not having one. + bounceSize: 0.13, // metres + bounceSpeed: 4.2, // metres/second + bounceLifetime: 0.45, // seconds + bounceSpread: 0.35, // 0..1 how far off vertical it comes back + bounceSpin: 7.0, // radians/second + + /* --- the glitter that lifts off the rime --- */ + glitterRate: 34, // particles/second, during the hold only + glitterSize: 0.05, + glitterSpeed: 0.7, + glitterLifetime: 1.7, + glitterRise: 0.8, // metres/second + glitterTurbulence: 0.6, + colorGlitterA: '#ffffff', + colorGlitterB: '#dff4ff', + colorGlitterC: '#9fd8ee', + colorGlitterD: '#1d3f52', + + /* --- the shock of one stone landing --- */ + strikeShake: 0.1, // camera kick per stone + strikeShakeDecay: 0.42, // seconds it decays over + strikeLight: 1.4, // additive punch on the dynamic light per stone + + /* --- the cast itself --- */ + chillBurst: 2.6, // the shell of freezing vapour over the circle, metres + chillIntensity: 1.1, + chillFlash: 0.1, // screen flash when the column closes + colorChillA: '#4d8fa8', + colorChillB: '#9fd8ee', + colorChillC: '#f2fbff', + colorChillFlash: '#dff4ff', + rumble: 0.035, // continuous shake while the storm falls + + /* --- dynamic light --- */ + lightIntensity: 9.0, + lightRadius: 14.0, + lightColor: '#9fd8ee' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Hailwrath. + * + * The four controls that carry the character of the slot are `stones`, + * `stormTime`, `hashCell` and `fillScatter` — how much hail, over how long, in + * how big a patch, and how strictly it works inward from the rim. After those, + * "The arrival envelope" is where the drama lives: pull `stormFloor` to zero + * and you can watch the mechanism, because the first stones stop falling. + */ +export const hailSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 20, 400, 1, 'chill speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['zoneRadius', 1, 14, 0.1, 'zone radius'], + ['castAnim', 'cast animation'] + ], + 'The storm clock': [ + ['stormLead', 0, 2, 0.01, 'lead-in (s)'], + ['stormTime', 0.2, 8, 0.01, 'storm length (s)'], + ['fallTime', 0.1, 3, 0.01, 'fall time (s)'], + ['holdTime', 0.3, 10, 0.05, 'hold (s)'] + ], + 'The arrival envelope': [ + ['stormRamp', 0.1, 6, 0.01, 'rise exponent'], + ['stormPeak', 0.02, 0.98, 0.01, 'peak position'], + ['stormTail', 0.1, 6, 0.01, 'fall exponent'], + ['stormFloor', 0, 1, 0.01, 'rate floor'] + ], + 'How the circle fills': [ + ['stones', 1, 32, 1, 'stones'], + ['fillBias', -1, 1, 0.01, 'radial order'], + ['fillScatter', 0, 1, 0.01, 'hash vs radial'], + ['hashCell', 0.05, 8, 0.05, 'hash cell (m)'], + ['rimCrowd', 0.05, 3, 0.01, 'rim crowding'], + ['speedJitter', 0, 1, 0.01, 'fall-time jitter'] + ], + 'One stone': [ + ['stoneRadius', 0.02, 1, 0.005, 'radius (m)'], + ['stoneJitter', 0, 1, 0.01, 'size jitter'], + ['stoneStretch', 0.2, 6, 0.01, 'stretch along velocity'], + ['stoneAlign', 0, 1, 0.01, 'align to heading'], + ['stoneSpin', 0, 30, 0.1, 'tumble (rad/s)'], + ['stoneFlash', 0.01, 1, 0.01, 'birth pop (s)'] + ], + 'One stone/The fall': [ + ['skyHeight', 4, 60, 0.5, 'cloud height (m)'], + ['skyBack', -10, 30, 0.1, 'cloud behind (m)'], + ['skySpread', 0, 8, 0.05, 'cloud scatter (m)'], + ['fallCurve', 0.3, 4, 0.01, 'fall easing'], + ['driftSide', 0, 4, 0.01, 'lateral drift (m)'], + ['driftTurns', 0, 4, 0.01, 'drift cycles'], + ['driftDecay', 0, 5, 0.01, 'drift decay'] + ], + 'One stone/Silhouette': [ + ['stoneFacets', 0, 2, 1, 'subdivisions'], + ['stoneLumps', 0, 1, 0.01, 'lumpiness'], + ['stoneLumpScale', 0.2, 6, 0.05, 'lumps / radius'], + ['stoneChip', 0, 0.6, 0.005, 'chipping'], + ['stoneCuts', 0, 14, 1, 'cut faces'], + ['stoneCutDepth', 0, 0.6, 0.005, 'cut depth'] + ], + 'One stone/Shading': [ + ['iceRoughness', 0.02, 1, 0.01, 'roughness'], + ['iceMilk', 0, 1, 0.01, 'cloudy core'], + ['iceMilkScale', 0.2, 12, 0.05, 'core scale'], + ['iceFacetTint', 0, 1, 0.01, 'facet break-up'], + ['iceRim', 0, 4, 0.01, 'fresnel edge'], + ['iceRimPower', 0.2, 8, 0.05, 'edge tightness'], + ['iceGlint', 0, 4, 0.01, 'glints'], + ['iceGlintScale', 0.5, 24, 0.1, 'glints / radius'], + ['iceLead', 0, 4, 0.01, 'leading face'], + ['iceLeadSharp', 0.2, 8, 0.05, 'leading tightness'], + ['iceGlow', 0, 4, 0.01, 'glow'], + ['colorIce', 'clear ice'], + ['colorMilk', 'cloudy core'], + ['colorRim', 'fresnel edge'], + ['colorGlint', 'glints'] + ], + 'The fall streak': [ + ['trailSpan', 0.01, 1.5, 0.005, 'streak length (s)'], + ['trailBurn', 0.01, 1.5, 0.005, 'burn-off (s)'], + ['trailWidth', 0.005, 0.5, 0.005, 'width (m)'], + ['trailTaper', 0.2, 5, 0.01, 'taper'], + ['trailLift', -0.5, 0.5, 0.005, 'lift (m)'], + ['trailOpacity', 0, 2, 0.01, 'opacity'], + ['trailGlow', 0, 5, 0.01, 'glow'], + ['trailCore', 0.2, 8, 0.01, 'core tightness'], + ['trailHeadBias', -1, 2, 0.01, 'head bias'], + ['trailNoise', 0, 2, 0.01, 'noise'], + ['trailNoiseScale', 0.1, 8, 0.05, 'noise scale'], + ['trailNoiseSpeed', 0, 6, 0.05, 'noise speed'], + ['trailSoftFade', 0.02, 2, 0.01, 'soft fade (m)'], + ['colorTrail*', 'Streak colour'] + ], + 'The pocked floor': [ + ['pockHeight', 0, 0.3, 0.005, 'quad height (m)'], + ['pockRadius', 0.05, 3, 0.01, 'crater radius (m)'], + ['pockDepth', 0, 1.5, 0.005, 'crater depth (m)'], + ['pockRim', 0.002, 0.5, 0.002, 'lip width (m)'], + ['pockLift', 0, 0.5, 0.005, 'lip height (m)'], + ['pockGrain', 0, 1, 0.01, 'grain'], + ['pockDig', 0.5, 40, 0.5, 'dig rate'], + ['pockLife', 0.5, 30, 0.1, 'crater lifetime (s)'], + ['pockEdge', 0.02, 3, 0.01, 'field feather (m)'], + ['pockRagged', 0, 1, 0.01, 'field raggedness'], + ['pockRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['pockWarp', 0, 3, 0.01, 'domain warp (m)'], + ['pockOpacity', 0, 1, 0.01, 'opacity'], + ['pockEmissive', 0, 3, 0.01, 'cold glow'], + ['pockDepthFade', 0.02, 2, 0.01, 'soft fade (m)'], + ['colorPock', 'bruised stone'], + ['colorPockDeep', 'hole'], + ['colorPockRim', 'white lip'], + ['colorPockGlow', 'cold glow'] + ], + 'The pocked floor/Lighting': [ + ['pockRelief', 0, 3, 0.01, 'relief'], + ['pockNormalStep', 0.005, 0.3, 0.005, 'normal step (m)'], + ['pockAmbient', 0, 1, 0.01, 'ambient'], + ['pockWrap', 0, 1, 0.01, 'terminator wrap'], + ['pockSpecular', 0, 3, 0.01, 'specular'], + ['pockGloss', 1, 160, 1, 'gloss'], + ['pockParallax', 0, 1, 0.01, 'parallax (m)'] + ], + 'The rime-over': [ + ['rimeShare', 0.05, 1, 0.01, 'frost share of hold'], + ['rimeRim', 0.002, 0.6, 0.002, 'rimed lip width (m)'], + ['rimeGrain', 0, 1, 0.01, 'rimed grain'], + ['rimeSpecular', 0, 3, 0.01, 'rimed specular'], + ['rimeGloss', 1, 200, 1, 'rimed gloss'], + ['rimeLift', 0, 0.6, 0.005, 'rimed lip height (m)'], + ['thawStart', 0, 0.98, 0.01, 'thaw starts at'] + ], + 'The freezing column': [ + ['mistRate', 0, 400, 1, 'mist rate'], + ['mistSize', 0.05, 6, 0.05, 'mist size'], + ['mistSpeed', 0, 8, 0.05, 'mist speed'], + ['mistLifetime', 0.1, 8, 0.05, 'mist lifetime'], + ['mistRise', -4, 4, 0.05, 'mist rise'], + ['mistOpacity', 0, 1, 0.005, 'mist opacity'], + ['mistTurbulence', 0, 3, 0.01, 'mist turbulence'], + ['mistSpread', 0, 1.5, 0.01, 'column width'], + ['mistHeight', 0.2, 14, 0.1, 'column height (m)'], + ['colorMist*', 'Mist colour'] + ], + 'What a stone throws': [ + ['chipCount', 0, 60, 1, 'chips / stone'], + ['chipSize', 0.005, 0.4, 0.005, 'chip size'], + ['chipSpeed', 0, 25, 0.1, 'chip speed'], + ['chipLifetime', 0.05, 4, 0.01, 'chip lifetime'], + ['chipGravity', -60, 0, 0.5, 'chip gravity'], + ['chipSpread', 0, 1, 0.01, 'chip cone'], + ['chipSpin', 0, 30, 0.5, 'chip spin'], + ['colorChip*', 'Chip colour'], + ['sprayCount', 0, 80, 1, 'spray / stone'], + ['spraySize', 0.005, 0.6, 0.005, 'spray size'], + ['spraySpeed', 0, 30, 0.1, 'spray speed'], + ['sprayLifetime', 0.05, 3, 0.01, 'spray lifetime'], + ['sprayGravity', -60, 5, 0.5, 'spray gravity'], + ['sprayStretch', 0, 2, 0.01, 'spray stretch'], + ['sprayRise', 0, 1, 0.01, 'spray rise'], + ['colorSpray*', 'Spray colour'] + ], + 'The bounce': [ + ['bounceSize', 0.01, 0.6, 0.005, 'bounce size (m)'], + ['bounceSpeed', 0, 20, 0.1, 'bounce speed'], + ['bounceLifetime', 0.05, 3, 0.01, 'bounce lifetime'], + ['bounceSpread', 0, 1, 0.01, 'bounce cone'], + ['bounceSpin', 0, 30, 0.5, 'bounce spin'] + ], + 'The glitter': [ + ['glitterRate', 0, 300, 1, 'glitter rate'], + ['glitterSize', 0.005, 0.3, 0.005, 'glitter size'], + ['glitterSpeed', 0, 8, 0.05, 'glitter speed'], + ['glitterLifetime', 0.1, 6, 0.05, 'glitter lifetime'], + ['glitterRise', -2, 6, 0.05, 'glitter rise'], + ['glitterTurbulence', 0, 3, 0.01, 'glitter turbulence'], + ['colorGlitter*', 'Glitter colour'] + ], + 'Impact & the cast': [ + ['strikeShake', 0, 1, 0.005, 'shake / stone'], + ['strikeShakeDecay', 0.05, 2, 0.01, 'shake decay (s)'], + ['strikeLight', 0, 10, 0.05, 'light punch / stone'], + ['chillBurst', 0.1, 12, 0.05, 'chill shell (m)'], + ['chillIntensity', 0, 5, 0.01, 'chill intensity'], + ['chillFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'storm rumble'], + ['colorChillA', 'chill shell'], + ['colorChillB', 'chill body'], + ['colorChillC', 'chill crest'], + ['colorChillFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/hemolance.js b/src/config/abilities/hemolance.js new file mode 100644 index 0000000..48f2717 --- /dev/null +++ b/src/config/abilities/hemolance.js @@ -0,0 +1,334 @@ +/* ================================================================== */ +/* HEMOLANCE — blood, line cast */ +/* ================================================================== */ +/** + * Hemorrhage. A volley of needles, and the mist they leave behind. + * + * Two numbers carry this whole ability and they are worth finding before + * anything else in the block: + * + * - **`ripplePhase`** — the seconds between one needle leaving the hand and + * the next. It is the signature. At 0 the volley is a shotgun: twelve lances + * on one frame, which reads as a single wide object and is over before the + * eye resolves it. At 0.06 it is a *ripple* — you see each needle + * individually, in order, and the volley reads as twelve things instead of + * one thing. Past about 0.15 it stops being a volley and becomes a queue. + * - **`mistLife`** — how long a metre of mist survives after the needle that + * laid it has already gone. This is the other half of the trick. A needle at + * a hundred and forty metres a second crosses the whole cast in a tenth of a + * second; on its own it is a flicker you are not sure you saw. The trail + * outliving the projectile is what leaves the *evidence* on screen, so twelve + * needles fired over two thirds of a second stack up into twelve visible + * threads even though never more than three are in the air at once. + * + * The needles fly dead straight on purpose: no loft, no weave. Everything else + * in the sandbox that travels does something on the way, and a lance that + * curves is a bolt. `pathCurve` is the only shaping control, and it only + * changes how the speed is distributed along a line that stays a line — which + * is also what lets the mist shader mirror the flight exactly with one `pow`. + * + * `speedJitter` is deliberately *absent*. Varying the flight times on top of + * the ripple muddies the one thing the volley exists to show, and the first + * build had it at 0.2 and read as a mess rather than as a rhythm. + */ +export const hemolance = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused, metres + speed: 130.0, // how fast the cast front runs the line, metres/second + lifetime: 0.9, // seconds the volley holds — stretched to cover the ripple + fadeTime: 1.0, // seconds the last mist bleeds off + cooldown: 0.7, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the volley --- */ + needles: 12, // lances in one volley (capped at 16) + ripplePhase: 0.06, // SECONDS BETWEEN NEEDLES — the signature slider + volleyLead: 0.02, // seconds before the first needle leaves + needleTime: 0.13, // seconds one needle is in the air + fanWidth: 0.42, // metres — half-width of the fan at the hand + handHeight: 1.3, // metres above the floor the volley leaves from + handForward: 0.55, // metres in front of the caster + handSide: 0.2, // metres to the side (+ follows `Ability#side`) + spreadSide: 0.55, // ± metres across the line where they converge + spreadForward: 0.4, // ± metres along it + landHeight: 0.55, // metres above the floor they converge at — chest height + pathCurve: 1.25, // >1 accelerates the needle over its flight + + /* --- the needle --- */ + needleRadius: 0.03, // half-width of a lance, metres + needleLength: 20.0, // how far it is drawn out along its heading, × the radius + needleAlign: 0.94, // 0 tumbles freely, 1 lays the lance along its flight + needleSpin: 6.0, // roll about the long axis, radians/second + needleFlash: 0.05, // seconds of birth flash as a lance appears + needleGlow: 1.5, // emissive gain on the body + needleOpacity: 1.0, + needleRim: 3.0, // fresnel exponent — this is most of the silhouette + needleTip: 2.2, // extra heat crowded into the leading half + colorNeedleCore: '#ff8a92', // the wet highlight along the lance + colorNeedleEdge: '#c01a28', // its body + colorNeedleDeep: '#3a0509', // the side turned away from you + + /* --- the mist that outlives the needle --- */ + /** + * One instanced strip carries every thread and it holds no history at all. + * The vertex shader knows when the needle passed each point on its own path — + * that is a closed form, not a recording — and shades the ribbon by how long + * ago that was. Which is why `mistLife`, `mistSpread` and `mistDrift` reshape + * threads that are already hanging in the air with the clock stopped, and why + * dragging `ripplePhase` re-lays all twelve of them at once. + */ + mistLife: 0.85, // seconds a point of mist survives after the needle passed + mistWidth: 0.022, // metres — the hairline at the instant it is laid + mistSpread: 0.17, // metres it swells to by the time it dies + mistDrift: 0.34, // metres/second the thread rises as it ages + mistWander: 0.15, // metres of lateral wander at full age + mistWanderScale: 1.3, // wander features per metre + mistWanderSpeed: 0.35, // how fast the wander field itself moves + mistCore: 3.4, // how hard a fresh thread crowds its own centre line + mistFalloff: 1.7, // >1 holds the thread bright then drops it + mistOpacity: 0.9, + mistSoftFade: 0.35, // metres of depth feather against solid geometry + colorMistA: '#ff5a66', // the instant it is laid + colorMistB: '#c01a28', + colorMistC: '#6e0d16', + colorMistD: '#2a0508', // as it dies + + /* --- the pools --- */ + /** + * `GroundField(POCK)` again, and shaded rather than additive — which is the + * whole difference between this and Starfall's rings. A shaded POCK with a + * shallow bowl, a low wet rim and a tight specular is a puddle; the same mode + * additive with a tall rim and a hot glow is a ring of light. One shader, two + * substances, one draw call either way. + */ + fieldRadius: 3.2, // metres — the footprint the pools may spread over + poolRadius: 0.5, // metres one impact's pool reaches + poolSpread: 5.5, // how fast it spreads to that radius, 1/second + poolLife: 9.0, // seconds a pool takes to soak away + poolRim: 0.02, // metres the wet lip stands proud + poolDepth: 0.06, // metres the pool sinks + poolThickness: 0.05, // metres — how wide the lip is + poolDetail: 0.45, // grain across the wet stone + fieldEdge: 0.35, // metres of feather on the footprint boundary + fieldRagged: 0.3, // how far that boundary wanders, fraction of the radius + fieldRaggedScale: 0.9, // lobes per metre + fieldWarp: 0.45, // metres of domain warp on those lobes + fieldRelief: 0.8, // how hard the height field tilts the fake normal + fieldSpecular: 1.4, // pools are mostly specular — this is the wet read + fieldGloss: 60, // Blinn exponent — tight, like standing liquid + fieldEmissive: 0.5, // multiplier on the glowing terms; blood barely glows + fieldOpacity: 1.0, + fieldHeight: 0.018, // metres the quad floats above the floor + colorPoolBase: '#5a0a10', // the blood itself + colorPoolEdge: '#e8737d', // the wet lip and the sheen + colorPoolGlow: '#c01a28', // the little heat left in a fresh hit + colorPoolDeep: '#1a0205', // the middle of a deep one + + /* --- droplets, spray and haze --- */ + /** + * Three shared systems, each with its own four-stop lifetime gradient (A at + * birth through D as it dies). All three are non-additive: blood that adds + * light is neon, and the school has exactly one bright thing in it, which is + * the fresh end of the mist. + */ + dropletBurst: 14, // fat droplets thrown by one impact + dropletSize: 0.06, + dropletSpeed: 4.6, // metres/second + dropletLifetime: 0.9, // seconds + dropletGravity: -16.0, // metres/second² + colorDropletA: '#e0424f', + colorDropletB: '#a8121f', + colorDropletC: '#6a0a12', + colorDropletD: '#2a0408', + sprayBurst: 22, // fine spray off the same impact + spraySize: 0.05, + spraySpeed: 9.5, // metres/second + sprayLifetime: 0.4, // seconds + sprayGravity: -13.0, // metres/second² + sprayStretch: 0.3, // how far a spray streak smears along its velocity + colorSprayA: '#ff7a84', + colorSprayB: '#c8202e', + colorSprayC: '#7a0e18', + colorSprayD: '#33060b', + hazeBurst: 5, // slow red haze hanging where the volley struck + hazeSize: 0.75, + hazeSpeed: 0.9, // metres/second + hazeLifetime: 1.9, // seconds + hazeRise: 0.42, // metres/second + hazeOpacity: 0.16, + colorHazeA: '#7a1620', + colorHazeB: '#5a0f18', + colorHazeC: '#3a0810', + colorHazeD: '#180308', + + /* --- the muzzle and the impacts --- */ + muzzleSize: 0.45, // the burst at the hand as the volley leaves, metres + muzzleIntensity: 1.3, + castFlash: 0.06, // screen flash on release + colorMuzzleA: '#5a0a10', + colorMuzzleB: '#c01a28', + colorMuzzleC: '#ff8a92', + colorCastFlash: '#c01a28', + shellSize: 0.9, // the splash one needle opens where it lands, metres + shellIntensity: 1.2, + shellLife: 0.4, // seconds + colorShellA: '#5a0a10', + colorShellB: '#c01a28', + colorShellC: '#ff8a92', + impactFlash: 0.1, // screen flash, fired by the *last* needle only + colorFlash: '#c01a28', + impactShake: 0.11, // per-needle kick + shakeDuration: 0.22, // seconds it decays over + rumble: 0.015, // continuous shake while the volley is in flight + + /* --- dynamic light --- */ + lightIntensity: 9, // the standing glow at the point of impact + lightRadius: 11, // metres + lightColor: '#c01a28', + lightPulse: 0.3, // depth of the arterial pulse, 0 = steady + lightPulseSpeed: 3.4, // pulses/second + lightPunch: 6.0 // added to the light by each needle that lands +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Hemorrhage. + * + * Start in **The volley** with `ripplePhase` and take it from 0 to 0.15 slowly + * — that single control is the difference between a shotgun and a volley, and + * nothing else in the folder matters until it is set. Then **The mist**: + * `mistLife` decides whether the ability leaves evidence or a flicker, and + * `mistSpread` decides whether the evidence is a wire or a smear. + */ +export const hemolanceSchema = { + 'The cast': [ + ['range', 3, 50, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 400, 1, 'front speed'], + ['lifetime', 0.1, 5, 0.05, 'hold time'], + ['fadeTime', 0.1, 5, 0.05, 'fade time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The volley': [ + ['needles', 1, 16, 1, 'needles'], + ['ripplePhase', 0, 0.3, 0.005, 'ripple phase (s/needle)'], + ['volleyLead', 0, 1, 0.005, 'lead-in'], + ['needleTime', 0.02, 1, 0.005, 'time in the air'], + ['fanWidth', 0, 2, 0.01, 'muzzle fan'], + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['spreadSide', 0, 4, 0.01, 'spread across'], + ['spreadForward', 0, 4, 0.01, 'spread along'], + ['landHeight', 0, 3, 0.01, 'convergence height'], + ['pathCurve', 0.4, 3, 0.01, 'flight acceleration'] + ], + 'The needle': [ + ['needleRadius', 0.005, 0.2, 0.001, 'half-width'], + ['needleLength', 2, 60, 0.5, 'length (× radius)'], + ['needleAlign', 0, 1, 0.01, 'align to flight'], + ['needleSpin', 0, 30, 0.1, 'roll rate'], + ['needleFlash', 0.01, 0.5, 0.005, 'birth flash'], + ['needleGlow', 0, 6, 0.01, 'glow'], + ['needleOpacity', 0, 2, 0.01, 'opacity'], + ['needleRim', 0.2, 8, 0.05, 'rim sharpness'], + ['needleTip', 0, 6, 0.05, 'tip heat'], + ['colorNeedleCore', 'needle highlight'], + ['colorNeedleEdge', 'needle body'], + ['colorNeedleDeep', 'needle shadow'] + ], + 'The mist': [ + ['mistLife', 0.05, 4, 0.01, 'mist lifetime'], + ['mistWidth', 0.002, 0.3, 0.002, 'hairline width'], + ['mistSpread', 0, 1.5, 0.005, 'swell at death'], + ['mistDrift', -1, 3, 0.01, 'rise'], + ['mistWander', 0, 1.5, 0.005, 'lateral wander'], + ['mistWanderScale', 0.05, 6, 0.05, 'wander scale'], + ['mistWanderSpeed', 0, 4, 0.01, 'wander speed'], + ['mistCore', 0.3, 10, 0.05, 'core tightness'], + ['mistFalloff', 0.2, 6, 0.05, 'death curve'], + ['mistOpacity', 0, 2, 0.01, 'opacity'], + ['mistSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorMist*', 'Mist colour'] + ], + 'The pools': [ + ['fieldRadius', 0.3, 14, 0.1, 'footprint radius'], + ['poolRadius', 0.05, 3, 0.01, 'pool radius'], + ['poolSpread', 0.2, 25, 0.1, 'spread rate'], + ['poolLife', 0.5, 30, 0.5, 'pool lifetime'], + ['poolRim', 0, 0.4, 0.002, 'lip height'], + ['poolDepth', 0, 0.8, 0.005, 'pool depth'], + ['poolThickness', 0.005, 0.4, 0.005, 'lip width'], + ['poolDetail', 0, 1, 0.01, 'wet grain'], + ['fieldEdge', 0.02, 3, 0.01, 'boundary feather'], + ['fieldRagged', 0, 1, 0.01, 'boundary wander'], + ['fieldRaggedScale', 0.1, 4, 0.05, 'wander scale'], + ['fieldWarp', 0, 3, 0.01, 'domain warp'], + ['fieldRelief', 0, 2, 0.01, 'relief'], + ['fieldSpecular', 0, 4, 0.01, 'wet specular'], + ['fieldGloss', 1, 150, 1, 'gloss'], + ['fieldEmissive', 0, 4, 0.01, 'emissive'], + ['fieldOpacity', 0, 2, 0.01, 'opacity'], + ['fieldHeight', 0.001, 0.2, 0.001, 'height above floor'], + ['colorPoolBase', 'pool body'], + ['colorPoolEdge', 'wet lip'], + ['colorPoolGlow', 'fresh heat'], + ['colorPoolDeep', 'pool depths'] + ], + 'Droplets & spray': [ + ['dropletBurst', 0, 100, 1, 'droplets / impact'], + ['dropletSize', 0.005, 0.4, 0.005, 'droplet size'], + ['dropletSpeed', 0, 20, 0.1, 'droplet speed'], + ['dropletLifetime', 0.1, 4, 0.01, 'droplet lifetime'], + ['dropletGravity', -50, 0, 0.1, 'droplet gravity'], + ['sprayBurst', 0, 150, 1, 'spray / impact'], + ['spraySize', 0.005, 0.3, 0.005, 'spray size'], + ['spraySpeed', 0, 30, 0.1, 'spray speed'], + ['sprayLifetime', 0.05, 3, 0.01, 'spray lifetime'], + ['sprayGravity', -50, 0, 0.1, 'spray gravity'], + ['sprayStretch', 0, 3, 0.01, 'spray stretch'], + ['colorDroplet*', 'Droplet colour'], + ['colorSpray*', 'Spray colour'] + ], + 'Haze': [ + ['hazeBurst', 0, 60, 1, 'haze / impact'], + ['hazeSize', 0.05, 3, 0.01, 'haze size'], + ['hazeSpeed', 0, 6, 0.05, 'haze speed'], + ['hazeLifetime', 0.2, 6, 0.05, 'haze lifetime'], + ['hazeRise', -2, 4, 0.01, 'haze rise'], + ['hazeOpacity', 0, 1, 0.005, 'haze opacity'], + ['colorHaze*', 'Haze colour'] + ], + 'Muzzle & impact': [ + ['muzzleSize', 0.05, 4, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorMuzzleA', 'muzzle shell'], + ['colorMuzzleB', 'muzzle body'], + ['colorMuzzleC', 'muzzle crest'], + ['colorCastFlash', 'release flash colour'], + ['shellSize', 0.05, 5, 0.05, 'splash size'], + ['shellIntensity', 0, 5, 0.01, 'splash intensity'], + ['shellLife', 0.05, 2, 0.01, 'splash life'], + ['colorShellA', 'splash shell'], + ['colorShellB', 'splash body'], + ['colorShellC', 'splash crest'], + ['impactFlash', 0, 2, 0.01, 'last-needle screen flash'], + ['colorFlash', 'screen flash colour'], + ['impactShake', 0, 2, 0.01, 'per-needle shake'], + ['shakeDuration', 0.05, 2, 0.01, 'shake decay'], + ['rumble', 0, 0.3, 0.005, 'flight rumble'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'pulse depth'], + ['lightPulseSpeed', 0.2, 20, 0.1, 'pulse rate'], + ['lightPunch', 0, 40, 0.5, 'punch per needle'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/hivecolumn.js b/src/config/abilities/hivecolumn.js new file mode 100644 index 0000000..d4ed3ab --- /dev/null +++ b/src/config/abilities/hivecolumn.js @@ -0,0 +1,340 @@ +/* ================================================================== */ +/* HIVE COLUMN — hive, far cast */ +/* ================================================================== */ +/** + * A comb tower that is **built**, not grown. + * + * Everything else in the sandbox that comes out of the floor is placed by a + * function of position: a radius, a band, a noise field. This one is placed by + * its own history. One seed cell is laid at the middle of the circle, and every + * cell after it buds one lattice unit off a cell already standing, in *that + * cell's* frame — which has been turned by up to `drift` of a turn from its own + * parent's. Locally the packing is perfect hexagon-on-hexagon and three cells in + * a row read as masonry; globally two fronts that left the seed around opposite + * sides of a void come back at each other out of register, the candidate lands + * within `refuse` of something already placed, and it is **thrown away**. + * + * The refusal is the ability. It is what puts a seam where two fronts met, a + * hole where three did, and a perimeter that is jagged because of what happened + * rather than because a noise function said so. `drift` and `refuse` are one + * control and they are the two sliders to reach for: at `drift = 0` the refusal + * never fires, the whole thing collapses into a hex grid filled by radius, and + * the slot stops being this slot. + * + * Two draw calls for the structure: `LatticeGrowth` for the comb and + * `GroundField(LATTICE)` for the wax the comb is seated on. See + * `src/abilities/hive/HivecolumnAbility.js`. + */ +export const hivecolumn = { + /* --- the cast --- */ + range: 19.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 24.0, // how fast the scent runner reaches the circle, metres/second + cooldown: 2.0, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 4.4, // the footprint the indicator draws, metres + + /* --- the beats, all seconds --- */ + buildPad: 0.4, // slack after the last cell lands before the hold starts + holdTime: 3.4, // the finished tower standing + collapseTime: 1.5, // it withdrawing back into the floor + + /* --- the colony: change any of these and the structure regrows --- */ + cells: 156, // cells the growth attempts, whole number (capped at 240) + drift: 0.19, // turns of lattice drift a child may take from its parent + refuse: 0.93, // LATTICE UNITS — a candidate closer than this is refused + climb: 0.34, // 0..1 tendency to start a new layer instead of spreading out + outward: 0.42, // 0..1 preference for candidates further from the seed + layers: 8, // ceiling on stacked layers, whole number — this is the column + + /* --- the comb, in metres --- */ + pitch: 0.36, // between adjacent cell centres + cellRadius: 0.205, // circumradius of one cell + sizeJitter: 0.16, // ± fraction on a cell's height and radius + heightBase: 0.2, // a cell far out on the skirt + heightPeak: 0.95, // a cell at the seed + heightFalloff: 6.5, // LATTICE UNITS over which peak decays to base + layerHeight: 0.3, // between stacked layers + baseY: 0.0, // the floor the comb is seated on + rise: 0.42, // how far a cell climbs out of the floor as it lands + + /* --- the build clock --- */ + stagger: 0.014, // seconds between one cell landing and the next + growTime: 0.24, // seconds one cell takes to land + overshoot: 0.3, // fraction it overshoots by on the way + + /* --- the comb's surface --- */ + tintRadius: 7.5, // LATTICE UNITS over which the four-stop gradient is walked + tintJitter: 0.16, // ± per-cell walk along it + combWrap: 0.45, // 0 hard terminator, 1 fully wrapped + rimPow: 3.4, // how narrow the bright rim on a cell wall is + rimGain: 0.55, + sheenPow: 40, // waxy specular exponent + sheenGain: 0.62, + coreGlow: 0.9, // how hot the recess at the bottom of a cell is + flashGain: 1.6, // the flash as a cell lands + combGlow: 1.0, // master multiplier on every emissive term + colorCombA: '#e8d179', // birth — fresh wax at the growing edge + colorCombB: '#c39a33', // early + colorCombC: '#7f5a16', // late + colorCombD: '#33240d', // death — old comb deep in the tower + colorCombCore: '#c8f04a', // the sick green down in the recess + colorCombRim: '#fff0b4', // the lit lip of a cell wall + colorCombSheen: '#fffbe6', // the waxy specular + colorCombFlash: '#eaff9a', // a cell the instant it snaps into place + + /* --- the wax mat on the floor, a GroundField(LATTICE) --- */ + floorRadius: 1.18, // × zoneRadius + floorHeight: 0.018, // metres above the floor the quad sits at + floorEdge: 0.5, // metres of feather on the propagating front + floorRagged: 0.36, // how far that front wanders, as a fraction of the radius + floorRaggedScale: 0.8, // lobes per metre + floorWarp: 0.62, // metres of domain warp on those lobes + floorRelief: 0.72, // how hard the height field tilts the fake normal + floorCell: 0.34, // metres — the hex pitch, deliberately near `pitch` + floorSeam: 0.05, // metres — the node at each corner + floorThickness: 0.045, // metres — trace half-width + floorLift: 0.05, // metres — how far a trace stands proud + floorDepth: 0.16, // metres — how far the etch between traces sinks + floorSpeed: 1.1, // charge events per second + floorAmbient: 0.3, // floor on the diffuse term + floorWrap: 0.45, // 0..1 wraps the terminator round the back + floorSpecular: 0.42, + floorGloss: 26, // Blinn exponent + floorParallax: 0.25, // metres of view-driven offset on interior detail + floorDetail: 0.6, + floorSharp: 0.5, + floorEmissive: 1.0, // multiplier on the glowing terms + floorOpacity: 0.92, + floorDepthFade: 0.5, // metres of soft fade against standing geometry + colorFloorBase: '#8a6a26', // the wax itself + colorFloorEdge: '#e8d179', // trace lips and highlights + colorFloorGlow: '#c8f04a', // whatever is charged + colorFloorDeep: '#160f04', // the etch between the traces + + /* --- wax motes coming off the build --- */ + moteRate: 46, // per second while the tower is building + moteSize: 0.075, // metres + moteSpeed: 0.85, // metres/second on release + moteLifetime: 2.4, // seconds + moteRise: 0.55, // metres/second² of buoyancy + moteTurbulence: 0.55, + moteGlow: 1.4, + cellMotes: 3, // extra motes thrown by each cell as it snaps into place + colorMoteA: '#f6ffc0', // birth + colorMoteB: '#d8e878', // early + colorMoteC: '#a8b038', // late + colorMoteD: '#3a3c10', // death + + /* --- the dry dust the tower pushes out of the floor --- */ + dustRate: 22, // per second while building + dustSize: 0.7, // metres + dustSpeed: 0.85, // metres/second + dustLifetime: 2.2, // seconds + dustRise: 0.5, // metres/second² of buoyancy + dustOpacity: 0.42, + colorDustA: '#c8b076', // birth + colorDustB: '#9a8450', // early + colorDustC: '#5e4f2c', // late + colorDustD: '#1c1710', // death + + /* --- chips of old comb knocked loose --- */ + chipSize: 0.1, // metres + chipSpeed: 2.2, // metres/second + chipLifetime: 1.5, // seconds + chipGravity: -13.0, // metres/second² + chipSpin: 9.0, // radians/second + cellChipChance: 0.22, // 0..1 — how often a landing cell throws one + collapseChips: 90, // thrown all at once when the tower lets go + colorChipA: '#e8d179', // birth + colorChipB: '#b48c30', // early + colorChipC: '#6b4c14', // late + colorChipD: '#241a08', // death + + /* --- the muzzle, the seating and the collapse --- */ + handForward: 0.7, // metres downrange of the caster + handSide: 0.34, // metres to the caster's side + handHeight: 1.35, // metres off the floor + muzzleSize: 1.0, // metres, the shell at the hand + muzzleIntensity: 1.1, + castFlash: 0.05, // screen flash on release + colorCastFlash: '#e8d179', + burstSize: 2.6, // metres, the shell as the seed cell is laid + burstIntensity: 1.2, + burstMotes: 90, // motes thrown at that moment + shockRadius: 3.2, // metres, the dust ring pushed out under it + impactShake: 0.4, + shakeDuration: 1.0, // seconds + impactFlash: 0.07, + rumble: 0.014, // continuous shake while the runner travels + colorBurstA: '#f2e29a', + colorBurstB: '#c8a03c', + colorBurstC: '#c8f04a', + colorFlash: '#eaff9a', + colorSeat: '#8a6a26', // the wax mat decal under the tower + colorSeatEdge: '#e8d179', + seatRadius: 1.05, // × zoneRadius + seatLife: 7.0, // seconds the mat weathers away over + seatIntensity: 0.85, + + /* --- the dynamic light --- */ + lightIntensity: 9.0, + lightRadius: 11.0, // metres + lightHeight: 0.75, // × the tower's own height + lightPulse: 0.22, // 0..1 depth of the slow breathing + lightPulseRate: 1.7, // radians/second + lightColor: '#d8c04a' +}; + +/** Editor layout: which folders exist and what goes in them. */ +export const hivecolumnSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 150, 1, 'runner speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['zoneRadius', 1, 16, 0.05, 'footprint radius'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['buildPad', 0, 3, 0.05, 'slack after the last cell'], + ['holdTime', 0.2, 14, 0.1, 'standing'], + ['collapseTime', 0.2, 8, 0.05, 'withdrawing'] + ], + 'The colony': [ + ['cells', 4, 240, 1, 'cells attempted'], + ['drift', 0, 0.5, 0.005, 'lattice drift (turns)'], + ['refuse', 0.3, 1.4, 0.005, 'refusal distance'], + ['climb', 0, 1, 0.01, 'tendency to climb'], + ['outward', -1, 2, 0.01, 'outward bias'], + ['layers', 1, 14, 1, 'layer ceiling'] + ], + 'The comb': [ + ['pitch', 0.08, 1.2, 0.005, 'cell pitch (m)'], + ['cellRadius', 0.03, 0.8, 0.005, 'cell radius (m)'], + ['sizeJitter', 0, 1, 0.01, 'size jitter'], + ['heightBase', 0.02, 2, 0.01, 'height at the skirt (m)'], + ['heightPeak', 0.05, 4, 0.01, 'height at the seed (m)'], + ['heightFalloff', 0.5, 24, 0.1, 'falloff (lattice units)'], + ['layerHeight', 0.05, 1.2, 0.005, 'layer pitch (m)'], + ['baseY', -1, 2, 0.01, 'seat height (m)'], + ['rise', 0, 2, 0.01, 'climb out of the floor (m)'] + ], + 'The build clock': [ + ['stagger', 0.001, 0.12, 0.001, 'seconds per cell'], + ['growTime', 0.03, 1.5, 0.01, 'one cell landing'], + ['overshoot', 0, 1.5, 0.01, 'overshoot'] + ], + 'The comb/Surface': [ + ['tintRadius', 0.5, 24, 0.1, 'gradient reach (lattice units)'], + ['tintJitter', 0, 1, 0.01, 'gradient jitter'], + ['combWrap', 0, 1, 0.01, 'terminator wrap'], + ['rimPow', 0.5, 12, 0.1, 'rim tightness'], + ['rimGain', 0, 3, 0.01, 'rim gain'], + ['sheenPow', 2, 120, 1, 'sheen tightness'], + ['sheenGain', 0, 3, 0.01, 'sheen gain'], + ['coreGlow', 0, 4, 0.01, 'recess glow'], + ['flashGain', 0, 6, 0.01, 'landing flash'], + ['combGlow', 0, 4, 0.01, 'master glow'] + ], + 'The comb/Colour': [ + ['colorComb*', 'Comb gradient'], + ['colorCombCore', 'recess'], + ['colorCombRim', 'cell rim'], + ['colorCombSheen', 'wax sheen'], + ['colorCombFlash', 'landing flash'] + ], + 'The wax mat': [ + ['floorRadius', 0.2, 3, 0.01, 'radius × zone'], + ['floorHeight', 0, 0.3, 0.002, 'hover height'], + ['floorEdge', 0.02, 3, 0.01, 'front feather'], + ['floorRagged', 0, 1, 0.01, 'front raggedness'], + ['floorRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['floorWarp', 0, 3, 0.01, 'domain warp'], + ['floorRelief', 0, 2, 0.01, 'relief'], + ['floorCell', 0.05, 2, 0.01, 'hex pitch (m)'], + ['floorSeam', 0.002, 0.4, 0.002, 'node size (m)'], + ['floorThickness', 0.002, 0.4, 0.002, 'trace width (m)'], + ['floorLift', 0, 0.6, 0.005, 'trace height (m)'], + ['floorDepth', 0, 1, 0.005, 'etch depth (m)'], + ['floorSpeed', 0, 5, 0.01, 'charge rate'], + ['floorAmbient', 0, 1, 0.01, 'ambient'], + ['floorWrap', 0, 1, 0.01, 'terminator wrap'], + ['floorSpecular', 0, 2, 0.01, 'specular'], + ['floorGloss', 2, 128, 1, 'gloss'], + ['floorParallax', 0, 1.5, 0.01, 'parallax'], + ['floorDetail', 0, 1, 0.01, 'detail'], + ['floorSharp', 0, 1, 0.01, 'sharpness'], + ['floorEmissive', 0, 3, 0.01, 'emissive'], + ['floorOpacity', 0, 1, 0.01, 'opacity'], + ['floorDepthFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorFloorBase', 'wax'], + ['colorFloorEdge', 'trace lip'], + ['colorFloorGlow', 'charge'], + ['colorFloorDeep', 'etch'] + ], + 'The motes': [ + ['moteRate', 0, 300, 1, 'rate'], + ['moteSize', 0.005, 0.4, 0.005, 'size'], + ['moteSpeed', 0, 6, 0.05, 'speed'], + ['moteLifetime', 0.2, 8, 0.05, 'lifetime'], + ['moteRise', -2, 3, 0.01, 'buoyancy'], + ['moteTurbulence', 0, 3, 0.01, 'turbulence'], + ['moteGlow', 0, 6, 0.01, 'glow'], + ['cellMotes', 0, 16, 1, 'motes per cell'], + ['colorMote*', 'Mote colour'] + ], + 'The dust': [ + ['dustRate', 0, 200, 1, 'rate'], + ['dustSize', 0.05, 4, 0.01, 'size'], + ['dustSpeed', 0, 5, 0.05, 'speed'], + ['dustLifetime', 0.2, 8, 0.05, 'lifetime'], + ['dustRise', -2, 3, 0.01, 'buoyancy'], + ['dustOpacity', 0, 1, 0.01, 'opacity'], + ['colorDust*', 'Dust colour'] + ], + 'The chips': [ + ['chipSize', 0.01, 0.6, 0.005, 'size'], + ['chipSpeed', 0, 12, 0.05, 'speed'], + ['chipLifetime', 0.2, 6, 0.05, 'lifetime'], + ['chipGravity', -40, 0, 0.5, 'gravity'], + ['chipSpin', 0, 30, 0.5, 'spin'], + ['cellChipChance', 0, 1, 0.01, 'chance per cell'], + ['collapseChips', 0, 400, 1, 'chips on collapse'], + ['colorChip*', 'Chip colour'] + ], + 'Muzzle & seating': [ + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['handHeight', 0, 3, 0.01, 'hand height'], + ['muzzleSize', 0.05, 4, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 4, 0.01, 'muzzle intensity'], + ['castFlash', 0, 1, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 10, 0.05, 'seating shell'], + ['burstIntensity', 0, 4, 0.01, 'shell intensity'], + ['burstMotes', 0, 500, 1, 'motes on seating'], + ['shockRadius', 0.2, 14, 0.05, 'dust ring'], + ['impactShake', 0, 2, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 1, 0.01, 'screen flash'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['seatRadius', 0.2, 3, 0.01, 'mat radius × zone'], + ['seatLife', 0.5, 20, 0.1, 'mat life'], + ['seatIntensity', 0, 3, 0.01, 'mat intensity'], + ['colorBurstA', 'shell'], + ['colorBurstB', 'shell body'], + ['colorBurstC', 'shell motes'], + ['colorFlash', 'seating flash colour'], + ['colorSeat', 'mat'], + ['colorSeatEdge', 'mat rim'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 2, 0.01, 'height × tower'], + ['lightPulse', 0, 1, 0.01, 'pulse depth'], + ['lightPulseRate', 0.05, 6, 0.01, 'pulse rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/hourglass.js b/src/config/abilities/hourglass.js new file mode 100644 index 0000000..24aeb05 --- /dev/null +++ b/src/config/abilities/hourglass.js @@ -0,0 +1,474 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* HOURGLASS — the inversion */ +/* ================================================================== */ +/** + * A far cast. Two raymarched cones of sand stand apex to apex over the circle: + * the bulb above the neck drains, the heap below it grows, the whole zone goes + * weightless for a beat — and then the sand falls **up**. + * + * ### One number is the ability + * + * `flow` is a signed, unitless rate the ability resolves from the durations + * below on every frame. It is +1 while the glass runs down, it eases through + * zero across `stallTime`, and it is −1 while it runs back up. That single + * number is written into `Hook.GRAVITY` as the multiplier inside the well, and + * every consumer — the grains, the two volumes' advection, the dust, the motes + * — reads it **back off the published field** rather than off the local + * variable. There is therefore exactly one sign in this ability, and flipping + * it flips the zone. + * + * ### Why the stall is not only drama + * + * A particle's position in this engine is `start + v·travel(age) + ½·g·age²`, + * closed form in its own age. That is what lets a grain already in the air + * reverse when the field does — nothing is integrated, so nothing has spent the + * old gravity. But it also means position is a *function of g*, so a + * discontinuous g moves every live grain the instant it changes. Easing `flow` + * through zero over `stallTime` is what keeps a thousand grains continuous + * across the turn: at the crossing the quadratic term is exactly zero, every + * grain sits on its own ballistic path, hanging, and then starts to climb. + * + * The beat you can see coming and the mechanism that makes the beat safe are + * the same curve. Wind `stallTime` down toward zero and you can watch it break. + * + * ### The two cones are one shape + * + * `waistHeight` is the neck, and both hulls are measured from it: the upper + * bulb's apex sits there and shrinks toward it as it empties; the heap's apex + * climbs toward it as it fills. At full drain the heap touches the neck and the + * bulb has gone. One number owns the silhouette — invariant I5's stated + * exception, where the sharing *is* the design — so dragging `zoneRadius` + * scales the whole glass and nothing has to be kept in agreement with anything. + * + * There is no glass. A vessel would want a refractive hard surface this school + * does not have, and the shape does not need one: two cones apex to apex say + * hourglass on their own, and leaving the vessel out is what lets the sand be + * the only object in the frame. + */ +export const hourglass = { + /* --- the cast --- */ + range: 18.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 34.0, // how fast the glass runs out to the circle, metres/second + zoneRadius: 3.6, // the footprint the circle indicator draws, metres + cooldown: 1.6, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the cast leaves the caster --- */ + handForward: 0.55, // metres in front of the caster the seed leaves + handHeight: 1.24, // metres above the floor + + /* --- the five beats --- */ + // impactDuration = drainTime + stallTime + riseTime (the seat rides inside + // the drain); fadeDuration = settleTime. All of them are live, so re-timing + // the turn re-times a glass that is already standing. + seatTime: 0.34, // seconds the glass takes to stand up once it lands + drainTime: 1.55, // seconds the upper bulb takes to empty + stallTime: 0.62, // seconds the zone spends going weightless and coming back + riseTime: 1.05, // seconds the sand takes to climb back — faster than the fall + settleTime: 1.2, // seconds the standing glass takes to let go + + /* --- the silhouette, all × zoneRadius except where stated --- */ + waistHeight: 0.62, // the neck — both cones are measured from it + bulbRadius: 0.92, // mouth radius of the upper bulb when it is full + bulbHeight: 1.05, // apex-to-mouth length of the upper bulb when it is full + bulbEmpty: 0.07, // what is left of the bulb at full drain — a sliver at the neck + pileFill: 0.98, // how close the heap's apex gets to the neck at full drain + pileMax: 1.0, // hard cap on the heap's base radius, × zoneRadius + reposeAngle: 34.0, // degrees — the heap's angle of repose; radius = height / tan + neckRadius: 0.11, // metres — the throat the grains are born in + seedFill: 0.16, // how much of the glass exists while the cast is still travelling + hullSlack: 1.06, // extra proxy-hull headroom on top of the margin compensation + + /* --- the gravity well (Hook.GRAVITY) --- */ + // A sphere centred on the neck, so it covers both cones and the air between + // them. `gravityInside` is a *magnitude*: the ability multiplies it by `flow` + // and hands the product to the hook, and that product is the one sign in the + // file. `gravityOutside` stays 1 — the rest of the world is not in the glass. + gravityRadius: 2.4, // × zoneRadius — the well, measured from the neck + gravityEdge: 0.3, // softness of the well's wall, as a fraction of its radius + gravityInside: 1.0, // magnitude of the multiplier inside; 1 = a clean inversion + gravityOutside: 1.0, // ... and outside, where the world must stay normal + + /* --- the advection inside the two volumes --- */ + // Metres per second along each hull's own axis and along world up. These are + // *not* handed to the hull as rates — see `_domain()` in the ability: the + // medium offsets its noise domain by rate × the shared app clock, so a rate + // that changes mid-cast teleports the field by t·Δv. They are integrated to a + // signed displacement first, and it is the displacement that flips. + streamJet: 1.5, // sand running down the upper cone toward the neck + streamFall: 0.85, // ... and the world-Y component of the same stream + settleJet: 0.7, // sand settling into the heap + settleFall: 0.3, // ... and its world-Y component + scourTurn: 0.22, // radians/second the floor's grooves are cut round + + /* --- the falling column (Medium.SAND in a CONE, prefix `sand`) --- */ + // Cost: steps × (1 + taps) × covered pixels. The column is the larger of the + // two on screen; at 26 steps, one tap and a tenth of 1080p that is about 11 M + // samples against the library's 20 M budget, and the heap below is + // deliberately cheaper because it is squat and mostly occludes itself. + ...volumeHullDefaults('sand', Medium.SAND, { + sandSteps: 26, // march steps — the cost knob + sandJitter: 1.0, // step dither; 0 only ever shows you the banding + sandContact: 0.55, // metres of fade where the sand meets standing geometry + sandMargin: 0.15, // headroom inside the proxy cone for the erosion + sandRound: 0.55, // footprint rounding + sandHeightBias: 0.12, // density falls only slightly toward the mouth + sandFeather: 0.22, // far-edge feather + sandThroat: 0.09, // cone: how long the opening at the apex is + sandHollow: 0.0, // cone: peak off-axis — a column, not a ring + sandDensity: 2.5, // density + sandDensityCurve: 1.0, // density curve + sandSoftness: 0.34, // edge softness — sand is a threshold, not a fog edge + sandNoiseFrequency: 2.9, // features per metre — this is the grain size + sandNoiseStrength: 0.72, // erosion + sandNoiseWarp: 0.22, // domain warp + sandOctaves: 4, // octaves + sandDetail: 1.0, // fine-octave gain — sand is all fine octave + sandRise: 0.0, // driven from the integrated displacement; see above + sandJet: 0.0, // ... and so is this + sandSwirl: 0.28, // swirl about the cone's axis, radians/second + sandAbsorption: 3.4, // absorption, 1/metre + sandScatter: 2.6, // scattering — kept near absorption or the sand goes black + sandAmbient: 0.44, // multi-scatter floor + sandAnisotropy: 0.06, // sand scatters almost isotropically + sandShadowTaps: 1, // self-shadow taps — an absorber needs a lit side + sandShadowStrength: 1.05, + sandShadowLength: 0.5, // metres + sandOpacity: 1.0, + sandColorCore: '#e6d0a2', // sunlit grains + sandColorMid: '#bda172', + sandColorEdge: '#6d6252', + sandColorDeep: '#241f19', + sandColorLight: '#ffeccb' // the key light through the column + }), + + /* --- the heap (Medium.SAND in a CONE, prefix `dune`) --- */ + ...volumeHullDefaults('dune', Medium.SAND, { + duneSteps: 20, // squat and self-occluding; it does not need the column's march + duneJitter: 1.0, + duneContact: 0.7, // metres — it sits on the floor, so this one matters + duneMargin: 0.13, + duneRound: 0.7, // a heap is round + duneHeightBias: 0.08, + duneFeather: 0.18, + duneThroat: 0.16, // cone: the apex is blunt — a heap has no needle point + duneHollow: 0.0, + duneDensity: 3.2, // packed, not airborne + duneDensityCurve: 0.9, + duneSoftness: 0.28, + duneNoiseFrequency: 3.4, // a heap reads grainier than the falling column + duneNoiseStrength: 0.55, // ... but erodes less; it is not in the air + duneNoiseWarp: 0.18, + duneOctaves: 4, + duneDetail: 1.0, + duneRise: 0.0, // integrated displacement, as above + duneJet: 0.0, + duneSwirl: 0.0, // a heap does not turn + duneAbsorption: 4.2, + duneScatter: 3.0, + duneAmbient: 0.36, + duneAnisotropy: 0.04, + duneShadowTaps: 1, + duneShadowStrength: 1.15, + duneShadowLength: 0.42, + duneOpacity: 1.0, + duneColorCore: '#d8c297', + duneColorMid: '#a88d63', + duneColorEdge: '#5c5245', + duneColorDeep: '#1c1814', + duneColorLight: '#ffe6bd' + }), + + /* --- the floor under it --- */ + // GroundMode.SCOUR, shaded rather than additive: sand drifting on stone is + // darker than the stone. The grooves keep **one** rotation rate all cast — + // see the ability for why the sign flip deliberately stops at the floor. + scourSpread: 1.35, // radius of the quad, × zoneRadius + scourEdge: 0.45, // metres of feather on the growth front + scourRagged: 0.32, // how far that front wanders, as a fraction of the radius + scourRaggedScale: 0.6, // lobes per metre + scourWarp: 0.55, // metres of domain warp on those lobes + scourDepth: 0.075, // metres — how deep a groove is scoured + scourLift: 0.05, // metres — how high the drift piles between grooves + scourArms: 11, // grooves; a whole number or the spiral tears at ±π + scourSwirl: 0.42, // spiral pitch; 0 gives dead-straight spokes + scourSharp: 0.48, // 0 soft grooves, 1 knife-edged + scourDetail: 0.85, // grain over the drift + scourRelief: 0.9, // how hard the height field tilts the fake normal + scourNormalStep: 0.05, // metres between the height taps + scourAmbient: 0.34, // floor on the diffuse term + scourWrap: 0.5, // wraps the terminator round the back + scourSpecular: 0.16, // dry sand has almost no sheen + scourGloss: 14.0, // Blinn exponent + scourParallax: 0.22, // metres of view-driven offset on the groove detail + scourEmissive: 0.85, // multiplier on every glowing term + scourTurnGlow: 1.6, // × emissive at the top of the stall — the floor lights up + scourOpacity: 0.9, + scourHeight: 0.018, // metres the quad floats above the floor + scourDepthFade: 0.4, // metres of soft fade against anything standing in it + colorScourBase: '#8a7c63', // the drift itself + colorScourEdge: '#cdb98e', // the ridges between the grooves + colorScourGlow: '#e8c27a', // the front, and the light left in the grooves + colorScourDeep: '#241f19', // the bottom of a groove + + /* --- the grains: the particles that actually reverse --- */ + // These are what the trick is *about*. Their gravity is the published field, + // so a grain half way down the neck when the zone turns climbs back out along + // the arc it fell in. + grainRate: 320, // particles/second at full flow + grainSize: 0.05, + grainSpeed: 0.55, // metres/second of initial scatter out of the throat + grainLifetime: 1.15, // seconds + grainGravity: -13.5, // metres/second², × the published multiplier + grainSpin: 6.0, // radians/second of tumble + grainSpread: 0.55, // how wide the throat sprays, 0..1 + grainBurst: 190, // extra grains thrown off the heap on the turn + colorGrainA: '#f2e0b8', + colorGrainB: '#c8ab7c', + colorGrainC: '#8a7758', + colorGrainD: '#4a4134', + + /* --- the dust that puffs where the stream lands --- */ + dustRate: 46, // particles/second + dustSize: 0.55, + dustSpeed: 0.9, // metres/second + dustLifetime: 2.1, // seconds + dustRise: 0.5, // metres/second², × the published multiplier — it sinks when flipped + dustOpacity: 0.2, + dustTurbulence: 0.7, + dustSpread: 0.42, // metres of scatter around the landing point + colorDustA: '#d8c8a8', + colorDustB: '#a8967a', + colorDustC: '#5c5245', + colorDustD: '#2a2620', + + /* --- the motes: bone-amber time dust hanging round the glass --- */ + moteRate: 70, // particles/second + moteSize: 0.055, + moteSpeed: 0.35, // metres/second + moteLifetime: 2.6, // seconds + moteRise: 0.7, // metres/second², × the published multiplier + moteTurbulence: 0.55, + moteGlow: 1.5, + moteShell: 1.35, // × the bulb radius — how far out they hang + colorMoteA: '#ffeccb', + colorMoteB: '#e8c27a', + colorMoteC: '#a98a52', + colorMoteD: '#3a2f1e', + + /* --- the punctuation --- */ + seatBurstSize: 2.1, // the shell of dust as the glass seats, metres + seatBurstIntensity: 1.1, + warnAt: 0.42, // 0..1 of the stall envelope the telegraph fires at + warnBurstSize: 3.4, // the telegraph shell, metres + warnBurstIntensity: 1.35, + warnRingRadius: 5.2, // metres — the ring that says the turn is coming + warnRingIntensity: 0.85, + warnFlash: 0.06, // screen flash on the telegraph + turnBurstSize: 4.6, // the shell at the instant the sand reverses, metres + turnBurstIntensity: 1.7, + turnRingRadius: 8.0, // metres + turnRingIntensity: 1.15, + turnFlash: 0.2, // screen flash on the turn + seatShake: 0.4, + turnShake: 0.72, + shakeDuration: 0.65, // seconds + rumble: 0.028, // continuous shake while the glass is running + colorSeatA: '#5c5245', + colorSeatB: '#bda172', + colorSeatC: '#e6d0a2', + colorWarnA: '#6b5a34', + colorWarnB: '#d5be8c', + colorWarnC: '#ffeccb', + colorTurnA: '#8a7758', + colorTurnB: '#e8c27a', + colorTurnC: '#fff4dc', + colorWarnFlash: '#d5be8c', + colorTurnFlash: '#ffeccb', + colorRingA: '#8a7758', // body of a floor ring + colorRingB: '#ffeccb', // its crest + + /* --- dynamic light --- */ + lightIntensity: 15, // the glow through the falling column + lightRadius: 14, // metres + lightSwell: 0.55, // × intensity at the top of the stall — the weightless beat + lightFlicker: 0.12, // depth of the steady breathing under it + lightTickRate: 5.4, // radians/second of that breathing — constant, see the ability + lightColor: '#e8c27a' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Hourglass. + * + * Reach for `stallTime` first — it is the whole ability. Short, and the turn is + * a jump cut and the grains snap onto a new arc; long, and the zone hangs + * weightless until the beat stops being a beat. Then `drainTime` against + * `riseTime`: the fall wants to be slow enough to be boring and the climb fast + * enough to be wrong, and the gap between those two numbers is the joke. + * + * After that, `reposeAngle` — a steep heap reads as a spike and a shallow one + * as a puddle — and `sandNoiseFrequency`, which is the size of the grain and + * therefore whether the column reads as sand at all. + * + * The `sandBoil*` / `sandVoid*` / `sandSpeck*` keys and their `dune` twins are + * not filed here on purpose. `volumeHullDefaults` emits the whole vocabulary so + * the hull's own audit stays quiet, but bubbles, stars and embers do nothing to + * a SAND medium and twelve inert rows per folder is worse than a "More" folder + * nobody opens. + */ +export const hourglassSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'travel speed'], + ['zoneRadius', 1, 12, 0.1, 'footprint radius'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handHeight', 0, 3, 0.01, 'hand height'] + ], + 'The beats': [ + ['seatTime', 0.05, 2, 0.01, 'seat time'], + ['drainTime', 0.1, 6, 0.01, 'drain time'], + ['stallTime', 0.02, 4, 0.01, 'stall time'], + ['riseTime', 0.1, 6, 0.01, 'rise time'], + ['settleTime', 0.1, 6, 0.01, 'settle time'] + ], + 'The silhouette': [ + ['waistHeight', 0.1, 2.5, 0.01, 'neck height ×R'], + ['bulbRadius', 0.1, 2.5, 0.01, 'bulb radius ×R'], + ['bulbHeight', 0.1, 3, 0.01, 'bulb length ×R'], + ['bulbEmpty', 0, 0.5, 0.005, 'bulb left at full drain'], + ['pileFill', 0.1, 1.2, 0.01, 'heap reach toward the neck'], + ['pileMax', 0.1, 2.5, 0.01, 'heap radius cap ×R'], + ['reposeAngle', 12, 65, 0.5, 'angle of repose (deg)'], + ['neckRadius', 0.01, 1, 0.005, 'throat radius (m)'], + ['seedFill', 0, 1, 0.01, 'glass while travelling'], + ['hullSlack', 1, 2, 0.01, 'proxy hull slack'] + ], + 'The inversion': [ + ['gravityRadius', 0.2, 6, 0.01, 'well radius ×R'], + ['gravityEdge', 0.01, 1, 0.01, 'well edge'], + ['gravityInside', -2, 2, 0.01, 'multiplier inside'], + ['gravityOutside', -2, 2, 0.01, 'multiplier outside'], + ['streamJet', -8, 8, 0.01, 'column axial flow (m/s)'], + ['streamFall', -8, 8, 0.01, 'column vertical flow (m/s)'], + ['settleJet', -8, 8, 0.01, 'heap axial flow (m/s)'], + ['settleFall', -8, 8, 0.01, 'heap vertical flow (m/s)'], + ['scourTurn', -3, 3, 0.01, 'groove rotation (rad/s)'] + ], + ...volumeHullSchema('sand', { + label: 'Falling sand', + only: ['march', 'shape', 'field', 'flow', 'optics', 'colour'] + }), + ...volumeHullSchema('dune', { + label: 'The heap', + only: ['march', 'shape', 'field', 'flow', 'optics', 'colour'] + }), + 'The floor': [ + ['scourSpread', 0.2, 3, 0.01, 'radius ×R'], + ['scourEdge', 0.05, 3, 0.01, 'front feather (m)'], + ['scourRagged', 0, 1, 0.01, 'front wander'], + ['scourRaggedScale', 0.05, 3, 0.01, 'lobes / metre'], + ['scourWarp', 0, 3, 0.01, 'lobe warp (m)'], + ['scourDepth', 0, 0.6, 0.005, 'groove depth (m)'], + ['scourLift', 0, 0.4, 0.005, 'drift height (m)'], + ['scourArms', 1, 32, 1, 'grooves'], + ['scourSwirl', -2, 2, 0.01, 'spiral pitch'], + ['scourSharp', 0, 1, 0.01, 'groove sharpness'], + ['scourDetail', 0, 1, 0.01, 'grain'], + ['scourRelief', 0, 3, 0.01, 'relief'], + ['scourNormalStep', 0.005, 0.4, 0.005, 'normal step (m)'], + ['scourAmbient', 0, 1, 0.01, 'ambient'], + ['scourWrap', 0, 1, 0.01, 'terminator wrap'], + ['scourSpecular', 0, 2, 0.01, 'specular'], + ['scourGloss', 1, 90, 1, 'gloss'], + ['scourParallax', 0, 2, 0.01, 'parallax (m)'], + ['scourEmissive', 0, 4, 0.01, 'emissive'], + ['scourTurnGlow', 1, 6, 0.01, 'emissive × at the stall'], + ['scourOpacity', 0, 1, 0.01, 'opacity'], + ['scourHeight', 0, 0.3, 0.002, 'float above floor (m)'], + ['scourDepthFade', 0.05, 2, 0.01, 'soft fade (m)'], + ['colorScourBase', 'drift'], + ['colorScourEdge', 'ridges'], + ['colorScourGlow', 'front'], + ['colorScourDeep', 'groove floor'] + ], + 'The grains': [ + ['grainRate', 0, 1200, 1, 'grain rate'], + ['grainSize', 0.005, 0.4, 0.005, 'grain size'], + ['grainSpeed', 0, 8, 0.05, 'throat speed (m/s)'], + ['grainLifetime', 0.1, 5, 0.01, 'grain lifetime'], + ['grainGravity', -40, 0, 0.1, 'gravity (m/s²)'], + ['grainSpin', 0, 20, 0.1, 'tumble (rad/s)'], + ['grainSpread', 0, 1, 0.01, 'throat spread'], + ['grainBurst', 0, 800, 1, 'grains on the turn'], + ['colorGrain*', 'Grain colour'] + ], + 'Dust & motes': [ + ['dustRate', 0, 400, 1, 'dust rate'], + ['dustSize', 0.05, 3, 0.01, 'dust size'], + ['dustSpeed', 0, 6, 0.05, 'dust speed'], + ['dustLifetime', 0.1, 8, 0.05, 'dust lifetime'], + ['dustRise', -4, 6, 0.01, 'dust buoyancy'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['dustSpread', 0, 3, 0.01, 'dust scatter (m)'], + ['moteRate', 0, 500, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 6, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -4, 6, 0.01, 'mote buoyancy'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['moteGlow', 0, 6, 0.01, 'mote glow'], + ['moteShell', 0.2, 4, 0.01, 'mote shell ×bulb radius'], + ['colorDust*', 'Dust colour'], + ['colorMote*', 'Mote colour'] + ], + 'Seat, telegraph & turn': [ + ['seatBurstSize', 0.1, 8, 0.05, 'seat shell (m)'], + ['seatBurstIntensity', 0, 4, 0.01, 'seat intensity'], + ['warnAt', 0.05, 0.95, 0.01, 'telegraph trigger'], + ['warnBurstSize', 0.1, 10, 0.05, 'telegraph shell (m)'], + ['warnBurstIntensity', 0, 4, 0.01, 'telegraph intensity'], + ['warnRingRadius', 0.5, 20, 0.1, 'telegraph ring (m)'], + ['warnRingIntensity', 0, 3, 0.01, 'telegraph ring intensity'], + ['warnFlash', 0, 1, 0.01, 'telegraph flash'], + ['turnBurstSize', 0.1, 14, 0.05, 'turn shell (m)'], + ['turnBurstIntensity', 0, 4, 0.01, 'turn intensity'], + ['turnRingRadius', 0.5, 26, 0.1, 'turn ring (m)'], + ['turnRingIntensity', 0, 3, 0.01, 'turn ring intensity'], + ['turnFlash', 0, 2, 0.01, 'turn flash'], + ['seatShake', 0, 3, 0.01, 'seat shake'], + ['turnShake', 0, 3, 0.01, 'turn shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.5, 0.005, 'running rumble'], + ['colorSeatA', 'seat shell'], + ['colorSeatB', 'seat body'], + ['colorSeatC', 'seat crest'], + ['colorWarnA', 'telegraph shell'], + ['colorWarnB', 'telegraph body'], + ['colorWarnC', 'telegraph crest'], + ['colorTurnA', 'turn shell'], + ['colorTurnB', 'turn body'], + ['colorTurnC', 'turn crest'], + ['colorWarnFlash', 'telegraph flash colour'], + ['colorTurnFlash', 'turn flash colour'], + ['colorRingA', 'floor ring'], + ['colorRingB', 'floor ring crest'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightSwell', 0, 3, 0.01, 'swell at the stall'], + ['lightFlicker', 0, 1, 0.01, 'breathing depth'], + ['lightTickRate', 0.5, 30, 0.1, 'breathing rate (rad/s)'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/ice.js b/src/config/abilities/ice.js new file mode 100644 index 0000000..96e94af --- /dev/null +++ b/src/config/abilities/ice.js @@ -0,0 +1,308 @@ +/* ================================================================== */ +/* ICE — ability one */ +/* ================================================================== */ +/** + * A glacial eruption: a fracture front races out along the aimed line and a + * field of crystal spikes tears up out of the floor behind it, small and dense + * at the caster, tall and violent at the far end. + * + * Everything is generated — the crystals are procedural geometry + * (`assets/ProceduralGeometry.js`), their shading is a patched standard + * material (`materials/IceMaterial.js`), the frost is a shader on a quad and + * the mist, shards and glitter are GPU particles. There are no textures and no + * meshes on disk. + */ +export const ice = { + /* --- the cast itself --- */ + range: 15.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused + speed: 26.0, // how fast the fracture front travels, metres/second + lifetime: 3.6, // seconds the field stands before it withdraws + cooldown: 0.4, // seconds before the ability can be armed again + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the footprint the spikes fill --- */ + widthNear: 0.55, // half-width of the band at the caster, metres + width: 2.5, // half-width at the far end, metres + widthCurve: 0.75, // <1 flares early, >1 stays narrow then opens out + spikeCount: 190, // instances spent on one cast (capped at 288) + density: 1.0, // multiplier on that count + clumping: 1.35, // >1 pulls spikes toward the centre line + scatter: 0.55, // extra lateral jitter, fraction of the local half-width + frontBias: 0.85, // <1 crowds spikes toward the impact point + + /* --- silhouette of the field --- */ + heightNear: 0.5, // spike height at the caster, metres + height: 3.1, // spike height at the far end, metres + heightCurve: 1.7, // how late the ramp climbs + heightJitter: 0.55, + crown: 0.55, // how much shorter the flank blades are than the spine, 0..1 + peak: 1.45, // extra height multiplier at the impact point + peakWidth: 0.28, // how much of the line that swell covers, 0..1 + rubble: 0.42, // fraction of the spikes demoted to ankle-height shards + rubbleScale: 0.3, + + /* --- an individual crystal --- */ + radius: 0.41, // base radius, metres + radiusJitter: 0.93, + taper: 0.69, // tip radius as a fraction of the base + facets: 7, // sides of the prism (5–8 read best) + roughness: 0.09, // how far the facets are pushed off a clean prism + bend: 0.66, // sideways curve from base to tip + lean: 0.42, // radians the spikes lean away from the caster + leanJitter: 1.5, + twist: 1.0, // random yaw, 0..1 of a full turn + + /* --- the eruption --- */ + riseTime: 0.17, // seconds from buried to full height + riseOvershoot: 0.26, // how far past full height the punch carries + riseStagger: 0.09, // seconds of random delay between neighbours + settle: 0.55, // seconds the overshoot takes to damp out + shatterDelay: 0.6, // seconds after `lifetime` before they start to go + sinkTime: 1.0, // seconds to withdraw into the floor + + /* --- the ice material --- */ + colorDeep: '#3e737a', // the colour thick ice accumulates toward + colorIce: '#8adaff', // body + colorRim: '#f2feff', // fresnel edge + colorCore: '#638797', // the light trapped inside a fresh crystal + opacity: 0.92, + depthTint: 1.15, // how fast the deep tint builds with thickness + fresnel: 2.3, + fresnelPower: 2.4, + translucency: 1.5, // light bleeding through from behind + envIntensity: 0.9, // how much of the HDR probe the facets catch + facetSharp: 0.68, // crispness of the internal facet shading + fracture: 0.62, // internal crack planes + fractureScale: 6.5, // cracks per metre + veins: 0.45, // milky feather-frost inside the crystal + veinScale: 3.2, + // Named `glint*` rather than `sparkle*` on purpose: these are the pinpoint + // highlights on the crystal *surface*, and the `sparkle*` family further + // down drives the glitter *particles*. Two different effects. + glint: 1.1, + glintScale: 34.0, + glintSpeed: 0.7, + frostLine: 0.5, // rime banding climbing the crystal + glow: 0.85, // overall emissive gain + edgeGlow: 1.1, // brightness of the silhouette rim + birthGlow: 1.6, // extra glow on a crystal that has just erupted + birthFade: 0.45, // seconds that birth flash lasts + + /* --- what the ground does --- */ + frostSpread: 1.35, // frost patch radius, × the local half-width + frostRate: 3.6, // patches laid per metre of front travel + frostLife: 7.0, // seconds a patch lingers + frostIntensity: 0.85, + frostCrystals: 1.5, // grain of the packed snow + colorFrost: '#f0f9ff', // the lit face of the snow + colorFrostEdge: '#79b6dd', // what it goes in its own shadow + shockRadius: 5.5, // impact shockwave ring, metres + colorShockA: '#5fd0ff', // body of the shockwave ring + colorShockB: '#f2feff', // its crest + + /* --- mist, shards and glitter --- */ + /** + * Every particle system is coloured by a four-stop gradient sampled over the + * particle's own lifetime: `A` the instant it is born, `D` as it dies. They + * are spelled out rather than derived from the crystal palette so the fog can + * be warmed, or the glitter recoloured, without touching the ice itself. + */ + mistRate: 260, // rolling ground fog, particles/second + mistSize: 1.15, + mistSpeed: 1.3, + mistLifetime: 2.8, + mistOpacity: 0.05, + mistRise: 0.35, // how fast the fog lifts, metres/second + colorMistA: '#f2feff', + colorMistB: '#cdefff', + colorMistC: '#a9e4ff', + colorMistD: '#09304c', + // Chips are thrown per *breach*, not per second: a crystal cracks the floor + // once and spits once. The old key here was `shardRate`, a per-second figure + // left over from a draft where the shards streamed the whole cast — nothing + // read it, and the count it should have been driving was hard-coded to 3. + // Renamed rather than repurposed so an old preset carrying `shardRate: 150` + // is dropped by `applySettings` instead of quietly emitting fifty times the + // chips it used to. + breachShards: 3, // ice chips spat where one crystal breaks the surface + shardSize: 0.075, + shardSpeed: 7.0, + shardLifetime: 1.7, + shardGravity: -14.0, + colorShardA: '#f2feff', + colorShardB: '#a9e4ff', + colorShardC: '#a9e4ff', + colorShardD: '#12496f', + sparkleRate: 130, // the rising glitter plume + sparkleSize: 0.055, + sparkleSpeed: 3.4, + sparkleLifetime: 2.6, + sparkleRise: 1.6, // upward drift, metres/second + sparkleTurbulence: 0.55, + colorSparkleA: '#f2feff', + colorSparkleB: '#57c9ff', + colorSparkleC: '#a9e4ff', + colorSparkleD: '#041e32', + + /* --- dynamic light --- */ + lightIntensity: 9, + lightRadius: 13, + lightColor: '#7fd4ff', + + /* --- the impact at the far end --- */ + burstSize: 3.6, + burstIntensity: 0.75, + burstShards: 90, // extra chips thrown at the impact + impactShake: 0.7, + impactFlash: 0.12, + shakeDuration: 0.9, + rumble: 0.06, // continuous shake while the front travels + // The frost shell mixes A→B across its billowing noise and lays C over the + // crystallised plates and the fresnel rim, so C is the one that reads hot. + colorBurstA: '#a9e4ff', + colorBurstB: '#cdefff', + colorBurstC: '#f2feff', + colorFlash: '#f2feff' // the full-screen flash on impact +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * The folders the Frost Lance's controls are filed under, and the range each + * one is tuned to. Lifted from the hand-written `Editor#_buildIce` it replaced, + * ranges and labels intact — they were arrived at by dragging, not by rounding + * the default, and a slider whose maximum sits on the shipped value can only + * ever come down. + * + * Format is documented in `config/abilities/index.js`. + */ +export const iceSchema = { + 'The cast': [ + ['range', 2, 40, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 80, 0.5, 'front speed'], + ['lifetime', 0.2, 12, 0.1, 'field lifetime'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + Footprint: [ + ['widthNear', 0.05, 6, 0.01, 'width at caster'], + ['width', 0.1, 10, 0.05, 'width at target'], + ['widthCurve', 0.2, 4, 0.01, 'width curve'], + ['spikeCount', 4, 288, 1, 'crystal count'], + ['density', 0.05, 1, 0.01, 'density'], + ['clumping', 0.3, 4, 0.01, 'pull to centre'], + ['scatter', 0, 2, 0.01, 'lateral scatter'], + ['frontBias', 0.3, 3, 0.01, 'crowd toward target'] + ], + Silhouette: [ + ['heightNear', 0.05, 6, 0.01, 'height at caster'], + ['height', 0.1, 12, 0.05, 'height at target'], + ['heightCurve', 0.2, 5, 0.01, 'height curve'], + ['heightJitter', 0, 1.5, 0.01, 'height jitter'], + ['crown', 0, 0.95, 0.01, 'flank falloff'], + ['peak', 1, 4, 0.01, 'swell at target'], + ['peakWidth', 0.02, 1, 0.01, 'swell width'], + ['rubble', 0, 1, 0.01, 'rubble fraction'], + ['rubbleScale', 0.05, 1, 0.01, 'rubble height'] + ], + // These four regenerate the crystal geometry — see IceAbility#_syncGeometry. + 'The crystal': [ + ['radius', 0.02, 1.5, 0.01, 'base radius'], + ['radiusJitter', 0, 1.5, 0.01, 'radius jitter'], + ['taper', 0.01, 0.8, 0.01, 'tip taper'], + ['facets', 3, 10, 1, 'facets'], + ['roughness', 0, 1, 0.01, 'surface roughness'], + ['bend', 0, 1.5, 0.01, 'bend'], + ['lean', 0, 1.4, 0.01, 'lean from caster'], + ['leanJitter', 0, 1.5, 0.01, 'lean jitter'], + ['twist', 0, 1, 0.01, 'random yaw'] + ], + 'The eruption': [ + ['riseTime', 0.02, 1.5, 0.01, 'rise time'], + ['riseOvershoot', 0, 1, 0.01, 'punch overshoot'], + ['riseStagger', 0, 1, 0.005, 'stagger'], + ['settle', 0.05, 2, 0.01, 'settle time'], + ['shatterDelay', 0, 4, 0.05, 'hold before sinking'], + ['sinkTime', 0.1, 4, 0.05, 'sink time'] + ], + 'Ice material': [ + ['colorDeep', 'deep'], + ['colorIce', 'body'], + ['colorRim', 'rim'], + ['colorCore', 'inner light'], + ['opacity', 0, 1, 0.01, 'opacity'], + ['depthTint', 0, 3, 0.01, 'thickness tint'], + ['fresnel', 0, 6, 0.01, 'fresnel'], + ['fresnelPower', 0.5, 6, 0.05, 'fresnel power'], + ['translucency', 0, 4, 0.01, 'translucency'], + ['envIntensity', 0, 3, 0.01, 'reflection'], + ['facetSharp', 0, 1.5, 0.01, 'facet contrast'], + ['fracture', 0, 2, 0.01, 'internal cracks'], + ['fractureScale', 0.5, 20, 0.1, 'crack scale'], + ['veins', 0, 2, 0.01, 'feather frost'], + ['veinScale', 0.2, 10, 0.05, 'frost scale'], + ['glint', 0, 5, 0.01, 'surface glint'], + ['glintScale', 4, 90, 0.5, 'glint scale'], + ['glintSpeed', 0, 4, 0.01, 'glint speed'], + ['frostLine', 0, 1.5, 0.01, 'rime at the base'], + ['glow', 0, 5, 0.01, 'glow'], + ['edgeGlow', 0, 6, 0.01, 'edge glow'], + ['birthGlow', 0, 10, 0.05, 'birth flash'], + ['birthFade', 0.02, 2, 0.01, 'birth flash time'] + ], + 'Frost on the ground': [ + ['frostSpread', 0.1, 5, 0.01, 'patch radius'], + ['frostRate', 0.2, 12, 0.1, 'patches / metre'], + ['frostLife', 0.5, 20, 0.1, 'patch lifetime'], + ['frostIntensity', 0, 2, 0.01, 'intensity'], + ['frostCrystals', 0, 4, 0.01, 'snow grain'], + ['shockRadius', 0.5, 20, 0.1, 'shockwave radius'], + ['colorFrost', 'snow'], + ['colorFrostEdge', 'snow shadow'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'Mist, chips & glitter': [ + ['mistRate', 0, 900, 1, 'mist rate'], + ['mistSize', 0.05, 4, 0.01, 'mist size'], + ['mistSpeed', 0, 8, 0.05, 'mist speed'], + ['mistLifetime', 0.2, 8, 0.05, 'mist lifetime'], + ['mistOpacity', 0, 2, 0.01, 'mist opacity'], + ['mistRise', -2, 4, 0.01, 'mist rise'], + ['breachShards', 0, 40, 1, 'chips per breach'], + ['shardSize', 0.005, 0.5, 0.005, 'chip size'], + ['shardSpeed', 0, 25, 0.1, 'chip speed'], + ['shardLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['shardGravity', -40, 0, 0.1, 'chip gravity'], + ['sparkleRate', 0, 600, 1, 'glitter rate'], + ['sparkleSize', 0.005, 0.4, 0.005, 'glitter size'], + ['sparkleSpeed', 0, 12, 0.05, 'glitter speed'], + ['sparkleLifetime', 0.2, 8, 0.05, 'glitter lifetime'], + ['sparkleRise', -2, 8, 0.05, 'glitter rise'], + ['sparkleTurbulence', 0, 3, 0.01, 'glitter turbulence'], + ['colorMist*', 'Mist colour'], + ['colorShard*', 'Chip colour'], + ['colorSparkle*', 'Glitter colour'] + ], + Impact: [ + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 4, 0.01, 'burst intensity'], + ['burstShards', 0, 400, 1, 'burst chips'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'vapour shell'], + ['colorBurstB', 'shell body'], + ['colorBurstC', 'plates & rim'], + ['colorFlash', 'screen flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.1, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/index.js b/src/config/abilities/index.js new file mode 100644 index 0000000..6ecbc66 --- /dev/null +++ b/src/config/abilities/index.js @@ -0,0 +1,382 @@ +/** + * config/abilities/index.js — the two maps that stop anything enumerating + * fifty imports twice. + * + * Every ability owns one module in this directory holding exactly two exports: + * its settings block (``) and its editor layout (`Schema`). This file + * imports each module once and re-publishes them as `ABILITY_SETTINGS` (id → + * block) and `ABILITY_SCHEMAS` (id → schema). `config/settings.js` spreads the + * first into the live tree; `abilities/registry.js` hangs the block off each + * descriptor; the editor walks the second. + * + * These imports are **eager and must stay eager**. Fifty ability *classes* are + * lazy — nothing constructs a mesh until a slot is selected — but the settings + * tree has to be complete at module load, because the editor, the preset + * system and the aim controller all need to see every block up front, and + * `structuredClone(settings)` for `DEFAULT_SETTINGS` runs once at boot. Fifty + * modules of plain numbers and `#rrggbb` strings cost nothing; a settings + * module that imports anything beyond a constant is the mistake to avoid. + * + * Adding an ability: one import line, one entry in each map. Keep both maps in + * the same order as `ABILITIES` in the registry, so a diff reads straight. + */ + +import { ice, iceSchema } from './ice.js'; +import { thunder, thunderSchema } from './thunder.js'; +import { meteor, meteorSchema } from './meteor.js'; +import { beam, beamSchema } from './beam.js'; +import { snare, snareSchema } from './snare.js'; +import { glacier, glacierSchema } from './glacier.js'; + +/* ------------------------------------------------------------------ */ +/* APPEND NEW IMPORTS BELOW THIS LINE */ +import { geyser, geyserSchema } from './geyser.js'; +import { pistondrive, pistondriveSchema } from './pistondrive.js'; +import { astralgate, astralgateSchema } from './astralgate.js'; +import { entropy, entropySchema } from './entropy.js'; +import { undertow, undertowSchema } from './undertow.js'; +import { bonecage, bonecageSchema } from './bonecage.js'; +import { rewind, rewindSchema } from './rewind.js'; +import { torrent, torrentSchema } from './torrent.js'; +import { broodburst, broodburstSchema } from './broodburst.js'; +import { obsidian, obsidianSchema } from './obsidian.js'; +import { waspfunnel, waspfunnelSchema } from './waspfunnel.js'; +import { silence, silenceSchema } from './silence.js'; +import { shrapnel, shrapnelSchema } from './shrapnel.js'; +import { afterimage, afterimageSchema } from './afterimage.js'; +import { refractcascade, refractcascadeSchema } from './refractcascade.js'; +import { photonlattice, photonlatticeSchema } from './photonlattice.js'; +import { featherfall, featherfallSchema } from './featherfall.js'; +import { splatterbrand, splatterbrandSchema } from './splatterbrand.js'; +import { sealscript, sealscriptSchema } from './sealscript.js'; +import { hourglass, hourglassSchema } from './hourglass.js'; +import { brinelock, brinelockSchema } from './brinelock.js'; +import { blackice, blackiceSchema } from './blackice.js'; +import { sawline, sawlineSchema } from './sawline.js'; +import { spellbreak, spellbreakSchema } from './spellbreak.js'; +import { firewalk, firewalkSchema } from './firewalk.js'; +import { wildfire, wildfireSchema } from './wildfire.js'; +import { scrollward, scrollwardSchema } from './scrollward.js'; +import { bubblecage, bubblecageSchema } from './bubblecage.js'; +import { carapace, carapaceSchema } from './carapace.js'; +import { stasisfield, stasisfieldSchema } from './stasisfield.js'; +import { unmake, unmakeSchema } from './unmake.js'; +import { gearlock, gearlockSchema } from './gearlock.js'; +import { quench, quenchSchema } from './quench.js'; +import { anvilfall, anvilfallSchema } from './anvilfall.js'; +import { solarlens, solarlensSchema } from './solarlens.js'; +import { tiderush, tiderushSchema } from './tiderush.js'; +import { locusttide, locusttideSchema } from './locusttide.js'; +import { mycelium, myceliumSchema } from './mycelium.js'; +import { godspear, godspearSchema } from './godspear.js'; +import { mirage, mirageSchema } from './mirage.js'; +import { origami, origamiSchema } from './origami.js'; +import { sheetlightning, sheetlightningSchema } from './sheetlightning.js'; +import { echostep, echostepSchema } from './echostep.js'; +import { hivecolumn, hivecolumnSchema } from './hivecolumn.js'; +import { dawnbreak, dawnbreakSchema } from './dawnbreak.js'; +import { eclipse, eclipseSchema } from './eclipse.js'; +import { inkbloom, inkbloomSchema } from './inkbloom.js'; +import { avalanche, avalancheSchema } from './avalanche.js'; +import { sumistroke, sumistrokeSchema } from './sumistroke.js'; +import { webline, weblineSchema } from './webline.js'; +import { shatterlance, shatterlanceSchema } from './shatterlance.js'; +import { soulchain, soulchainSchema } from './soulchain.js'; +import { skyfracture, skyfractureSchema } from './skyfracture.js'; +import { sanguinepact, sanguinepactSchema } from './sanguinepact.js'; +import { magma, magmaSchema } from './magma.js'; +import { hemolance, hemolanceSchema } from './hemolance.js'; +import { umbralspears, umbralspearsSchema } from './umbralspears.js'; +import { nightfall, nightfallSchema } from './nightfall.js'; +import { dragonbreath, dragonbreathSchema } from './dragonbreath.js'; +import { grovecall, grovecallSchema } from './grovecall.js'; +import { runeseal, runesealSchema } from './runeseal.js'; +import { glyphstorm, glyphstormSchema } from './glyphstorm.js'; +import { vinelash, vinelashSchema } from './vinelash.js'; +import { bloomburst, bloomburstSchema } from './bloomburst.js'; +import { stonespine, stonespineSchema } from './stonespine.js'; +import { sinkhole, sinkholeSchema } from './sinkhole.js'; +import { sporefall, sporefallSchema } from './sporefall.js'; +/* ------------------------------------------------------------------ */ +import { tectonic, tectonicSchema } from './tectonic.js'; +import { boulder, boulderSchema } from './boulder.js'; +import { rime, rimeSchema } from './rime.js'; +import { singularity, singularitySchema } from './singularity.js'; +import { cyclone, cycloneSchema } from './cyclone.js'; +import { resonance, resonanceSchema } from './resonance.js'; +import { petrify, petrifySchema } from './petrify.js'; + +import { prismlance, prismlanceSchema } from './prismlance.js'; +import { arcanevolley, arcanevolleySchema } from './arcanevolley.js'; +import { plaguebloom, plaguebloomSchema } from './plaguebloom.js'; +import { aurora, auroraSchema } from './aurora.js'; + +import { starfall, starfallSchema } from './starfall.js'; + +import { chronofracture, chronofractureSchema } from './chronofracture.js'; +import { voidrift, voidriftSchema } from './voidrift.js'; +import { hail, hailSchema } from './hail.js'; +import { sunspear, sunspearSchema } from './sunspear.js'; +import { crimsontide, crimsontideSchema } from './crimsontide.js'; +import { thornwake, thornwakeSchema } from './thornwake.js'; +import { firewhip, firewhipSchema } from './firewhip.js'; +import { railcoil, railcoilSchema } from './railcoil.js'; +import { thunderclap, thunderclapSchema } from './thunderclap.js'; +import { stormwall, stormwallSchema } from './stormwall.js'; +import { pyroclasm, pyroclasmSchema } from './pyroclasm.js'; +import { slipstream, slipstreamSchema } from './slipstream.js'; +import { emberflock, emberflockSchema } from './emberflock.js'; +import { chainarc, chainarcSchema } from './chainarc.js'; +import { balllightning, balllightningSchema } from './balllightning.js'; + +/** + * id → the live settings block. + * + * These are the *same objects* `settings.` exposes — `settings.js` spreads + * this map rather than copying it, so a controller bound through the registry + * and a controller bound through `settings` mutate one object. That identity is + * the whole reason the editor works on a standing effect. + */ +export const ABILITY_SETTINGS = { + /* --- APPEND NEW BLOCKS BELOW THIS LINE --- */ + geyser, + pistondrive, + astralgate, + entropy, + undertow, + bonecage, + torrent, + rewind, + broodburst, + obsidian, + waspfunnel, + silence, + shrapnel, + afterimage, + refractcascade, + photonlattice, + featherfall, + splatterbrand, + sealscript, + hourglass, + brinelock, + blackice, + sawline, + spellbreak, + firewalk, + wildfire, + scrollward, + bubblecage, + carapace, + stasisfield, + unmake, + gearlock, + quench, + anvilfall, + solarlens, + tiderush, + mycelium, + locusttide, + mirage, + godspear, + origami, + sheetlightning, + echostep, + hivecolumn, + avalanche, + dawnbreak, + eclipse, + inkbloom, + sumistroke, + webline, + shatterlance, + soulchain, + skyfracture, + sanguinepact, + magma, + hemolance, + umbralspears, + nightfall, + dragonbreath, + grovecall, + runeseal, + glyphstorm, + vinelash, + bloomburst, + sporefall, + tectonic, + boulder, + stonespine, + sinkhole, + rime, + singularity, + petrify, + cyclone, + resonance, + prismlance, + arcanevolley, + plaguebloom, + aurora, + starfall, + chronofracture, + voidrift, + hail, + sunspear, + crimsontide, + thornwake, + firewhip, + railcoil, + thunderclap, + stormwall, + pyroclasm, + slipstream, + emberflock, + chainarc, + balllightning, + ice, + thunder, + meteor, + beam, + snare, + glacier +}; + +/** + * id → the editor layout. + * + * ### Schema format + * + * A schema is a plain object of `'Folder name': [entries]`. Key order is the + * order the folders are built in; JS preserves insertion order for string keys, + * so the layout is the source order and needs no `order:` field. + * + * A **nested folder** is declared with a slash in the name — `'The fire + * trail/Silhouette'` becomes a sub-folder of `'The fire trail'`. The schema + * stays a flat map, which keeps it greppable. + * + * An **entry** is one of: + * + * | form | means | + * | --- | --- | + * | `'key'` | bare key; the builder infers a range from the default and the key name | + * | `['key', 'label']` | a control whose *type* comes from its value — a `#rrggbb` string gets a colour picker, a boolean a checkbox, `castAnim` the clip dropdown — with an authored label | + * | `['key', min, max, step]` | a slider labelled with its own key | + * | `['key', min, max, step, 'label']` | a slider, fully authored | + * | `['prefix*', 'Title']` | a **gradient group**: the trailing `*` expands to `prefixA/B/C/D` in a sub-folder called `Title`, labelled birth / early / late / death | + * + * The two-element form is distinguished from the four/five-element one by + * length, and never by guessing at the value. + * + * A schema is never *wrong*, only incomplete: any key in the block that no + * folder mentions still gets a control, in a trailing "More" folder. That is + * deliberate — a new slider added to a block during a tuning session appears in + * the panel immediately, and gets filed properly later. + * + * The ranges in these schemas were tuned by dragging, not by rounding the + * default. Several of them sit deliberately well above the shipped value + * (`beam.coilGlow` ships at 8 with a maximum of 14) because a control pinned to + * its own maximum can only ever come down. + */ +export const ABILITY_SCHEMAS = { + /* --- APPEND NEW SCHEMAS BELOW THIS LINE --- */ + geyser: geyserSchema, + pistondrive: pistondriveSchema, + astralgate: astralgateSchema, + entropy: entropySchema, + undertow: undertowSchema, + bonecage: bonecageSchema, + rewind: rewindSchema, + torrent: torrentSchema, + broodburst: broodburstSchema, + obsidian: obsidianSchema, + waspfunnel: waspfunnelSchema, + silence: silenceSchema, + shrapnel: shrapnelSchema, + afterimage: afterimageSchema, + refractcascade: refractcascadeSchema, + featherfall: featherfallSchema, + photonlattice: photonlatticeSchema, + splatterbrand: splatterbrandSchema, + sealscript: sealscriptSchema, + hourglass: hourglassSchema, + brinelock: brinelockSchema, + blackice: blackiceSchema, + spellbreak: spellbreakSchema, + sawline: sawlineSchema, + firewalk: firewalkSchema, + wildfire: wildfireSchema, + scrollward: scrollwardSchema, + bubblecage: bubblecageSchema, + carapace: carapaceSchema, + stasisfield: stasisfieldSchema, + gearlock: gearlockSchema, + quench: quenchSchema, + unmake: unmakeSchema, + anvilfall: anvilfallSchema, + solarlens: solarlensSchema, + tiderush: tiderushSchema, + locusttide: locusttideSchema, + mycelium: myceliumSchema, + godspear: godspearSchema, + mirage: mirageSchema, + origami: origamiSchema, + sheetlightning: sheetlightningSchema, + echostep: echostepSchema, + avalanche: avalancheSchema, + hivecolumn: hivecolumnSchema, + inkbloom: inkbloomSchema, + dawnbreak: dawnbreakSchema, + eclipse: eclipseSchema, + sumistroke: sumistrokeSchema, + webline: weblineSchema, + shatterlance: shatterlanceSchema, + soulchain: soulchainSchema, + skyfracture: skyfractureSchema, + sanguinepact: sanguinepactSchema, + magma: magmaSchema, + hemolance: hemolanceSchema, + umbralspears: umbralspearsSchema, + nightfall: nightfallSchema, + dragonbreath: dragonbreathSchema, + grovecall: grovecallSchema, + runeseal: runesealSchema, + glyphstorm: glyphstormSchema, + vinelash: vinelashSchema, + bloomburst: bloomburstSchema, + sporefall: sporefallSchema, + tectonic: tectonicSchema, + boulder: boulderSchema, + stonespine: stonespineSchema, + sinkhole: sinkholeSchema, + rime: rimeSchema, + singularity: singularitySchema, + petrify: petrifySchema, + cyclone: cycloneSchema, + resonance: resonanceSchema, + prismlance: prismlanceSchema, + arcanevolley: arcanevolleySchema, + plaguebloom: plaguebloomSchema, + aurora: auroraSchema, + chronofracture: chronofractureSchema, + starfall: starfallSchema, + voidrift: voidriftSchema, + hail: hailSchema, + sunspear: sunspearSchema, + crimsontide: crimsontideSchema, + thornwake: thornwakeSchema, + firewhip: firewhipSchema, + railcoil: railcoilSchema, + thunderclap: thunderclapSchema, + stormwall: stormwallSchema, + pyroclasm: pyroclasmSchema, + slipstream: slipstreamSchema, + emberflock: emberflockSchema, + chainarc: chainarcSchema, + balllightning: balllightningSchema, + ice: iceSchema, + thunder: thunderSchema, + meteor: meteorSchema, + beam: beamSchema, + snare: snareSchema, + glacier: glacierSchema +}; diff --git a/src/config/abilities/inkbloom.js b/src/config/abilities/inkbloom.js new file mode 100644 index 0000000..6f524ca --- /dev/null +++ b/src/config/abilities/inkbloom.js @@ -0,0 +1,366 @@ +/* ================================================================== */ +/* INKBLOOM — Ink Bloom */ +/* ================================================================== */ +/** + * A bead of ink dropped into standing water, opening on the floor. + * + * The trick is a **fingering instability**, and it lives in + * `vfx/InkDiffusion.js` rather than here: a front whose bulges see a steeper + * gradient, move faster and become fingers, with the coarse modes inadmissible + * until the blob has grown into them. This block's job is to aim that + * mechanism, and there is exactly one number that decides what the pattern *is* + * — `bloomCoarse`, the wavelength of the largest lobe the bloom can ever grow. + * Read it against `zoneRadius` before touching anything else. At 2.6 m on a + * 5.2 m circle you get five or six fat branching lobes; drop it to 0.9 m and + * the same cast comes out as lace. + * + * ### Two fronts, not one + * + * The ability draws **two** `InkDiffusion` fields, because a drop of ink in + * water is two substances arriving at different speeds and that separation is + * the thing your eye actually uses to date the mark: + * + * - the **wash** (`wash*`, `InkMode.WASH`) is the solvent. It is stable — the + * `WASH` mode has no instability term compiled into it at all — spreads + * faster (`washSpread` well above `bloomSpread`), and carries a strong + * deposition ring at its edge. That ring is the cauliflower line every + * watercolour has, and it is the *water's* boundary, always outside the + * pigment's. + * - the **bloom** (`bloom*`, `InkMode.BLOOM`) is the pigment, and it lags by + * `pigmentDelay`, spreads more slowly, and fingers. + * + * Set `washSpread` below `bloomSpread` and the illusion inverts instantly: the + * pigment overtakes its own solvent and the mark reads as a decal with a halo + * painted round it. The ordering is the design. + * + * ### Why so many of these are metres + * + * Everything the two fields read is resolved per frame, so `bloomCoarse`, + * `bloomSpread` and `washRingWidth` are all live on a standing bloom. Pause + * with **P** halfway through and drag `bloomOnset`: the octaves are re-admitted + * against the front radius they have *already* reached, so the pattern coarsens + * or crinkles in place rather than restarting. + * + * ### The school has no bloom in it + * + * There is no screen flash, no burst shell, no additive particle and no decal + * in this ability. `bloomCeiling` and `washCeiling` hard-clamp each field's + * output luminance below `post.bloomThreshold`, so no combination of the + * fourteen pickers below can feed the bloom pass. The single specular term is + * the wet gloss on the leading edge, and it is inside that clamp — a reflection + * that cannot exceed the bloom threshold is an observation about a wet surface, + * not an emission. + */ + +export const inkbloom = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 130.0, // how fast the bead reaches the water, metres/second + zoneRadius: 5.2, // the pool the ink is allowed to fill, metres + cooldown: 1.6, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats, in seconds --- + * All three are scaled by `global.lifetime`, including `pigmentDelay`: a + * slow-motion bloom in which the pigment still catches the water up in the + * same tenth of a second is a bloom whose two fronts have stopped being two. + */ + pigmentDelay: 0.16, // seconds the pigment lags the solvent + holdTime: 4.2, // seconds the bloom is allowed to keep opening + fadeTime: 2.4, // seconds it soaks away over + + /* --- where the bead comes from --- */ + handHeight: 1.35, // metres above the floor the bead leaves the caster at + handForward: 0.5, // metres in front of the caster + handSide: 0.22, // metres to the side (+ follows `Ability#side`) + dropLift: 0.06, // metres the bead rides above the floor as it arrives + dropRate: 1.3, // pigment grains shed per metre of travel + + /* ================================================================ */ + /* The wash — InkDiffusion(WASH), the solvent */ + /* ================================================================ */ + /** + * The water. One nucleus, no instability, a soft interface and a hard ring. + * + * `washSpread` is the number that keeps the halo outside the pigment. It is + * the front radius at one second, and against `bloomSpread` at 2.15 it buys + * roughly a metre of clear wet paper ahead of the ink for the whole cast. + */ + washHeight: 0.012, // metres above the floor — under the pigment + washSpread: 3.35, // metres of front radius at t = 1 s + washSpreadPower: 0.5, // r ~ t^power; 0.5 is Fickian + washEdge: 0.26, // metres of interface — wide, because water has no edge + washClipSoft: 1.3, // metres the wash fades out over as it meets `zoneRadius` + washCore: 0.82, // density floor inside it — nearly flat, it is not pigment + washFalloff: 7.5, // metres of e-folding from the nucleus + washFilm: 0.35, // alpha floor inside the coverage + washGranulation: 0.1, // the paper's tooth showing through wet + washGranScale: 1.15, // features per metre + washRing: 0.9, // the cauliflower line at the water's edge — the point of the field + washRingWidth: 0.19, // metres + washDryTime: 3.4, // seconds for the sheen to fall to 1/e + washWetDarken: 0.55, // how far wet paper pulls toward `washColorWet` + washGloss: 0.3, // specular on the wet film — the strongest one in the school + washGlossPower: 30, // its tightness — broad, because water beads shallow + washMeniscus: 0.7, // how far the film's normal tips at the interface + washOpacity: 0.62, + washCeiling: 0.6, // max linear luminance; `post.bloomThreshold` is 0.88 + washSoftFade: 0.3, // metres of depth feather against standing geometry + washTint: 0.12, // where in the gradient a zero-density film sits + washTintDensity: 0.7, // how far density walks it + washColorThin: '#b3a894', // the thinnest wet paper + washColorBody: '#8c7f6c', + washColorDeep: '#6b6052', + washColorPool: '#4e463c', // standing water over the drop + washColorRing: '#3a3126', // the deposition line at the water's edge + washColorWet: '#2f2a22', // what the still-wet film pulls toward + washColorGloss: '#cdd6da', // the sheen on it + + /* ================================================================ */ + /* The bloom — InkDiffusion(BLOOM), the pigment */ + /* ================================================================ */ + /** + * The ink. Four nuclei arriving in sequence, because a bead that hits water + * does not stay one bead — it breaks, and each fragment opens on its own + * clock. One nucleus gives a single symmetric flower and reads as a stamp. + */ + bloomHeight: 0.023, // metres above the floor — over the wash + bloomSpread: 2.15, // metres of front radius at t = 1 s + bloomSpreadPower: 0.44, // slower than Fickian: pigment drags on the fibre + bloomEdge: 0.07, // metres of interface — tight, because pigment has an edge + bloomClipSoft: 0.95, // metres it fades out over as it meets `zoneRadius` + bloomSources: 4, // live nuclei — the bead breaking up + bloomSourceScatter: 0.28, // fraction of the radius the later nuclei scatter over + bloomSourceDelay: 0.24, // seconds each later nucleus starts behind the first + + /* --- the instability. `bloomCoarse` first, always --- */ + bloomFinger: 0.62, // overall amplitude; 0 gives a disc + bloomFingerMax: 0.82, // cap, as a fraction of the front radius + bloomCoarse: 2.6, // metres — the coarsest lobe. The one knob that sets the morphology + bloomOnset: 0.52, // front radii per wavelength before a mode is admitted + bloomGrowth: 1.05, // e-folds per wavelength of front travel + bloomGrowthMax: 1.65, // saturation — a finger stops growing at its own width + + /* --- the film --- */ + bloomCore: 0.5, // density floor inside the blob + bloomFalloff: 3.6, // metres of e-folding out from a nucleus + bloomFilm: 0.58, // alpha floor inside the coverage + bloomGranulation: 0.3, // pigment settling into the paper's tooth as it dries + bloomGranScale: 1.9, // features per metre + bloomRing: 0.5, // the deposition line at the pigment's own interface + bloomRingWidth: 0.1, // metres + bloomDryTime: 2.1, // seconds for the gloss to fall to 1/e + bloomWetDarken: 0.42, // how far the wet film pulls toward `bloomColorWet` + bloomGloss: 0.24, // specular on the wet leading edge — under the ceiling + bloomGlossPower: 46, // its tightness + bloomMeniscus: 0.6, // how far the film's normal tips at the interface + bloomOpacity: 1.0, + bloomCeiling: 0.58, // max linear luminance; below the wash's, ink is the darker thing + bloomSoftFade: 0.25, // metres of depth feather + bloomTint: 0.04, // where in the gradient a zero-density film sits + bloomTintDensity: 1.1, // over 1, so a thick film reaches `bloomColorPool` + bloomColorThin: '#8d7f9e', // the thinnest pigment — indigo runs violet when dilute + bloomColorBody: '#42406a', + bloomColorDeep: '#1d1e33', + bloomColorPool: '#07070d', // where the drop went in + bloomColorRing: '#2a2440', // the deposition line at the pigment front + bloomColorWet: '#0b0b16', // what the still-wet ink pulls toward + bloomColorGloss: '#c6ccd6', // the sheen on the leading edge + + /* ================================================================ */ + /* Particles */ + /* ================================================================ */ + /** + * Two systems, both **non-additive**. Every other school in the sandbox + * lights its particles; ink is pigment suspended in water and pigment does + * not emit. Each carries its own four-stop lifetime gradient rather than a + * tint borrowed off the field palette, so the grains can be made to settle + * toward the paper colour while the mark itself stays indigo. + */ + + /* --- pigment grains riding the fingering front --- */ + grainRate: 105, // particles/second while the front is opening + grainSize: 0.055, + grainSpeed: 0.55, // metres/second outward off the front + grainLifetime: 1.9, + grainSink: -0.16, // metres/second — grains settle, they do not rise + grainTurbulence: 0.85, // the curl field is what makes them wander like sediment + grainBand: 0.22, // fraction of the front radius the emission annulus is thick + colorGrainA: '#6f6486', + colorGrainB: '#3b3960', + colorGrainC: '#1b1c2f', + colorGrainD: '#0a0a10', + + /* --- the slow tendril of colour lifting off the drop --- */ + veilRate: 26, + veilSize: 0.75, + veilSpeed: 0.35, // metres/second + veilLifetime: 3.1, + veilOpacity: 0.09, // it occludes; more than a tenth and it is fog, not ink + veilRise: 0.28, // metres/second + colorVeilA: '#4a4763', + colorVeilB: '#33324a', + colorVeilC: '#232232', + colorVeilD: '#15151f', + + /* ================================================================ */ + /* Feedback */ + /* ================================================================ */ + // No flash and no burst: both are emissive and this school does not emit. + // What is left is the weight of the bead going in. + dropShake: 0.16, // camera kick as the bead breaks the surface + shakeDuration: 0.45, // seconds it decays over + rumble: 0.006, // continuous shake while the bloom opens — nearly nothing + + /* --- dynamic light --- + * Deliberately dim. The pool is wet and a wet floor picks up a little light; + * that is the whole brief. Above about 12 the bloom starts to look lit from + * within, which is the failure mode of the school. + */ + lightIntensity: 4.5, + lightRadius: 9.0, + lightColor: '#8d9aa6', // cold, like light off water + lightPulse: 0.2, // depth of its slow swell, 0 = steady + lightPulseSpeed: 1.6 // swells/second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Ink Bloom. + * + * **The instability** is the folder to open first, and `bloomCoarse` against + * `zoneRadius` is the single most instructive pair in the block — it is the + * only control that changes what the pattern *is* rather than how much of it + * you get. Everything in it redraws a bloom that is already lying on the floor. + * + * The second thing worth doing is dragging `washSpread` down through + * `bloomSpread` with the clock stopped, and watching the mark stop being ink in + * water the moment the solvent stops leading. + */ +export const inkbloomSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 10, 400, 1, 'bead speed'], + ['zoneRadius', 1, 14, 0.05, 'pool radius'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['pigmentDelay', 0, 2, 0.005, 'pigment lag (s)'], + ['holdTime', 0.3, 12, 0.05, 'open time (s)'], + ['fadeTime', 0.1, 8, 0.05, 'soak-away time (s)'] + ], + 'The bead': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['dropLift', 0, 1.5, 0.01, 'height at the pool'], + ['dropRate', 0.05, 8, 0.05, 'grains / metre'] + ], + 'The instability': [ + ['bloomCoarse', 0.2, 8, 0.02, 'coarsest lobe (m)'], + ['bloomFinger', 0, 2, 0.01, 'finger amplitude'], + ['bloomFingerMax', 0.05, 2, 0.01, 'finger cap (× front)'], + ['bloomOnset', 0.05, 2, 0.01, 'admission (radii / λ)'], + ['bloomGrowth', 0, 4, 0.01, 'growth (e-folds / λ)'], + ['bloomGrowthMax', 0.1, 6, 0.01, 'growth saturation'] + ], + 'The bloom': [ + ['bloomHeight', 0.002, 0.2, 0.001, 'quad height (m)'], + ['bloomSpread', 0.1, 12, 0.01, 'front at 1 s (m)'], + ['bloomSpreadPower', 0.1, 1.5, 0.005, 'front exponent'], + ['bloomEdge', 0.005, 1, 0.005, 'interface width (m)'], + ['bloomClipSoft', 0.05, 4, 0.01, 'zone feather (m)'], + ['bloomSources', 1, 4, 1, 'nuclei'], + ['bloomSourceScatter', 0, 1, 0.005, 'nucleus scatter'], + ['bloomSourceDelay', 0, 1.5, 0.005, 'nucleus stagger (s)'], + ['bloomCore', 0, 1, 0.005, 'density floor'], + ['bloomFalloff', 0.2, 20, 0.05, 'density e-fold (m)'], + ['bloomFilm', 0, 1, 0.005, 'alpha floor'], + ['bloomGranulation', 0, 1, 0.005, 'granulation'], + ['bloomGranScale', 0.1, 8, 0.02, 'grain / metre'], + ['bloomRing', 0, 2, 0.01, 'deposition ring'], + ['bloomRingWidth', 0.01, 1, 0.005, 'ring width (m)'], + ['bloomOpacity', 0, 2, 0.01, 'ink opacity'], + ['bloomCeiling', 0.05, 1, 0.005, 'luminance ceiling'], + ['bloomSoftFade', 0.01, 3, 0.01, 'soft intersection (m)'], + ['bloomTint', 0, 1, 0.005, 'gradient floor'], + ['bloomTintDensity', 0, 2, 0.01, 'gradient / density'], + ['bloomColorThin', 'thinnest pigment'], + ['bloomColorBody', 'body'], + ['bloomColorDeep', 'deep'], + ['bloomColorPool', 'the drop'], + ['bloomColorRing', 'pigment ring'], + ['bloomColorWet', 'wet ink'], + ['bloomColorGloss', 'wet sheen'] + ], + 'Wet and dry': [ + ['bloomDryTime', 0.05, 12, 0.05, 'ink dry time (s)'], + ['bloomWetDarken', 0, 1, 0.005, 'wet darkening'], + ['bloomGloss', 0, 1.5, 0.005, 'ink gloss'], + ['bloomGlossPower', 2, 160, 1, 'ink gloss tightness'], + ['bloomMeniscus', 0, 2, 0.01, 'ink meniscus'], + ['washDryTime', 0.05, 12, 0.05, 'water dry time (s)'], + ['washWetDarken', 0, 1, 0.005, 'paper darkening'], + ['washGloss', 0, 1.5, 0.005, 'water gloss'], + ['washGlossPower', 2, 160, 1, 'water gloss tightness'], + ['washMeniscus', 0, 2, 0.01, 'water meniscus'] + ], + 'The wash': [ + ['washHeight', 0.002, 0.2, 0.001, 'quad height (m)'], + ['washSpread', 0.1, 12, 0.01, 'front at 1 s (m)'], + ['washSpreadPower', 0.1, 1.5, 0.005, 'front exponent'], + ['washEdge', 0.005, 1.5, 0.005, 'interface width (m)'], + ['washClipSoft', 0.05, 4, 0.01, 'zone feather (m)'], + ['washCore', 0, 1, 0.005, 'density floor'], + ['washFalloff', 0.2, 20, 0.05, 'density e-fold (m)'], + ['washFilm', 0, 1, 0.005, 'alpha floor'], + ['washGranulation', 0, 1, 0.005, 'granulation'], + ['washGranScale', 0.1, 8, 0.02, 'grain / metre'], + ['washRing', 0, 2, 0.01, 'cauliflower ring'], + ['washRingWidth', 0.01, 1, 0.005, 'ring width (m)'], + ['washOpacity', 0, 2, 0.01, 'wash opacity'], + ['washCeiling', 0.05, 1, 0.005, 'luminance ceiling'], + ['washSoftFade', 0.01, 3, 0.01, 'soft intersection (m)'], + ['washTint', 0, 1, 0.005, 'gradient floor'], + ['washTintDensity', 0, 2, 0.01, 'gradient / density'], + ['washColorThin', 'thinnest wet paper'], + ['washColorBody', 'body'], + ['washColorDeep', 'deep'], + ['washColorPool', 'standing water'], + ['washColorRing', 'cauliflower line'], + ['washColorWet', 'wet paper'], + ['washColorGloss', 'water sheen'] + ], + 'Grains & veil': [ + ['grainRate', 0, 600, 1, 'grain rate'], + ['grainSize', 0.005, 0.4, 0.005, 'grain size'], + ['grainSpeed', 0, 6, 0.05, 'grain speed'], + ['grainLifetime', 0.1, 8, 0.05, 'grain lifetime'], + ['grainSink', -3, 2, 0.01, 'grain sink'], + ['grainTurbulence', 0, 3, 0.01, 'grain turbulence'], + ['grainBand', 0.02, 1, 0.005, 'emission band'], + ['veilRate', 0, 300, 1, 'veil rate'], + ['veilSize', 0.05, 4, 0.01, 'veil size'], + ['veilSpeed', 0, 6, 0.05, 'veil speed'], + ['veilLifetime', 0.2, 10, 0.05, 'veil lifetime'], + ['veilOpacity', 0, 0.6, 0.002, 'veil opacity'], + ['veilRise', -2, 4, 0.01, 'veil rise'], + ['colorGrain*', 'Grain colour'], + ['colorVeil*', 'Veil colour'] + ], + 'Feedback & light': [ + ['dropShake', 0, 2, 0.005, 'drop shake'], + ['shakeDuration', 0.05, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.2, 0.001, 'bloom rumble'], + ['lightIntensity', 0, 40, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'light swell'], + ['lightPulseSpeed', 0.1, 12, 0.1, 'swell rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/locusttide.js b/src/config/abilities/locusttide.js new file mode 100644 index 0000000..cd931c3 --- /dev/null +++ b/src/config/abilities/locusttide.js @@ -0,0 +1,516 @@ +/* ================================================================== */ +/* LOCUSTTIDE — Locust Tide (hive, line cast) */ +/* ================================================================== */ +/** + * A colony thrown down the aimed line that **forms shapes on its way**: a + * loose boil out of the hand, closing into a fist, opening into a wall, drawing + * itself into a spear at the target, and coming apart again. + * + * The silhouette is emergent. Nothing in this ability draws a fist — the agents + * target positions sampled from a signed distance field, and the shape is + * whatever four hundred insects standing in it add up to. + * + * **The three schedule keys are the ability.** `fistAt`, `wallAt` and `spearAt` + * are fractions of the *cast line*, not seconds, which is deliberate: a + * fifteen-metre cast and a thirty-metre cast both show all three shapes, and + * both show them in the same places relative to the caster. `morphCurve` + * decides whether the swarm holds a shape and then snaps to the next (> 1) or + * flows continuously between them (≈ 1). Drag any of the three with **P** held + * mid-cast and the standing cloud re-forms: the shapes are a pure function of + * where the lead is, and where the lead is is a pure function of live settings. + * + * **The size triple morphs with the field.** A fist is compact, a wall is broad + * and thin, a spear is long and narrow, so each stage carries its own + * `Width/Height/Depth/Up` and the four are interpolated on *the same* blend + * fraction the field is. Getting that wrong is very visible: blend the fields + * but hold the size and the wall arrives as a fist-sized slab, then inflates. + * + * **The strip in the floor is derived, not authored.** `_biteAt()` asks how far + * the bottom of the current shape is off the floor and turns the answer into a + * contact strength for `vfx/GroundField.js` in `RUT` mode, so the wall — which + * stands low and broad — scours the ground and the spear, which flies, barely + * marks it. Every contact sample already lying in the floor is re-derived every + * frame from its own stored *fraction of the track*, which is why dragging + * `wallUp` on a paused frame re-carves a gouge that was cut two seconds ago. + * + * A cast captures three kinds of thing and no others: one seed, the fraction of + * the line each contact sample was taken at, and one unitless lateral dice roll + * per sample. Not a metre among them. + */ +export const locusttide = { + /* --- the cast --- */ + range: 21.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 14.0, // how fast the colony runs the line, metres/second + lifetime: 1.0, // seconds the spear holds at the target after it lands + fadeTime: 1.2, // seconds the last of the cloud takes to go + cooldown: 1.2, // seconds before the slot re-arms + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the colony leaves the caster --- */ + // The base class puts the cast on the floor because that is what the aim + // arrow targets; a colony boils out of a hand, so the offsets live here. + handForward: 0.85, // metres in front of the caster + handSide: 0.32, // metres to the side (+ follows `Ability#side`) + handHeight: 1.42, // metres above the floor + endHeight: 1.05, // metres above the floor where the line ends + leadRise: 0.9, // metres the lead lofts at mid-span + + /* ------------------------------------------------------------------ */ + /* The colony — vfx/Colony.js `ColonySwarm`, which is a `Swarm` */ + /* ------------------------------------------------------------------ */ + /** + * `latticeX × latticeY × latticeZ` is the number of *distinct* cells an agent + * can claim, and it must stay above `locusts` or two of them share one and + * the separation guarantee — the only reason this is not a cloud of smoke + * with wings — quietly stops holding. 12 × 7 × 6 is 504 for 420 agents. + * + * Everything here still runs while the colony is condensed: the lattice is + * what the agents fall back into as the shape lets go of them, and the churn + * and the wander are what stop a held shape reading as a mesh. + */ + locusts: 420, // live agents (capped at 640) + latticeX: 12, // cells across + latticeY: 7, // cells up + latticeZ: 6, // ranks strung out behind the lead + spacingSide: 0.44, // metres between lateral cells + spacingUp: 0.36, // metres between vertical cells + lag: 0.3, // seconds the back rank trails the lead by + jitter: 0.15, // metres of per-agent slop off its own cell + churn: 0.5, // radians/second the whole formation rolls + breathe: 0.2, // fraction the formation swells by + breatheRate: 2.3, // radians/second of that swell + wander: 0.2, // metres of curl-noise drift — keep under half the spacing + wanderScale: 0.85, // features per metre of that drift + wanderSpeed: 0.9, // how fast the drift field moves + gather: 1.0, // 0 collapses every agent onto the lead's own path + + /* --- one locust --- */ + size: 0.2, // metres, nose to tail + aspect: 1.3, // wingspan / length — an insect is not a gull + sizeJitter: 0.38, // ±fraction of size + sweep: 1.05, // how far the wings rake back + dihedral: 0.55, // wing fold out of the card's plane, fraction of size + wingCurl: 0.06, // static camber across the wing, fraction of size + flapRate: 14.0, // wing-beats/second — each agent runs on its own phase + bank: 0.045, // radians of roll per m/s² of lateral acceleration + bankMax: 0.85, // radians — the hard ceiling on that roll + billboard: 0.1, // 0 the agent's own frame, 1 camera-facing + edgeStretch: 1.9, // how much an edge-on card grows so it stays a line + edgeGain: 2.4, // how much brighter it gets while it is edge-on + lit: 0.55, // 0 pure emissive, 1 wrapped diffuse — chitin is lit, not lit up + revealTime: 0.18, // seconds the colony takes to appear at the hand + revealSpread: 0.38, // 0..1 width of that appearance wave + + /* --- the colony's colour --- */ + colorLocustA: '#efe4a6', // birth end: sunlit wing membrane + colorLocustB: '#b8a44a', // chitin olive + colorLocustC: '#6b7524', // the sick green of a stripped field + colorLocustD: '#20200c', // death end: a husk in shadow + tint: 0.26, // where in that gradient the colony sits + tintJitter: 0.36, // ±per-agent walk along it + tintAlong: 0.3, // extra walk from head to tail + opacity: 1.0, + glow: 0.85, // emissive gain — deliberately below 1; insects do not glow + softFade: 0.3, // metres of soft fade where an agent meets geometry + + /* ------------------------------------------------------------------ */ + /* THE TRICK — the shapes, and the schedule that morphs between them */ + /* ------------------------------------------------------------------ */ + /** + * Four beats along the line, as fractions of it. They must stay in order — + * the ability nudges them apart if they are not, rather than dividing by zero + * — and the interesting drags are the two gaps: a short `fistAt → wallAt` is + * a swarm snapping open, a long one is a wall being drawn out of a fist. + */ + closeAt: 0.1, // where the boil begins to close into a shape at all + fistAt: 0.33, // where it is fully a fist + wallAt: 0.62, // where it is fully a wall + spearAt: 0.95, // where it is fully a spear + morphCurve: 1.4, // >1 holds each shape and then crosses quickly + condenseMax: 0.94, // how completely the shape wins over the flock, 0..1 + + shapeForward: 0.0, // metres the shape sits ahead of the lead + shapeSide: 0.0, // metres it sits to the side of it + shapeSpin: 0.35, // radians/second the whole shape turns about world up + shapeFill: 0.7, // 0 a shell of insects, 1 a packed body + shapeSteps: 3, // gradient-descent steps onto the field, 1..4 + shapeSlack: 0.85, // fraction of each step taken; < 1 stops concavities ringing + shapeRough: 0.08, // unitless slop off the isosurface — a crowd, not a membrane + + /** + * The three silhouettes, each with its own metres. + * + * These scale a field that is **not** a unit cube — the fist is a rounded box + * whose knuckles reach 0.64, the wall is 0.96 tall and 0.16 thick, and the + * spear's widest point is its 0.21-radius head — so the numbers below are + * scale factors on those extents rather than half-extents in metres. + * `LocusttideAbility` carries the six unit constants that convert, which is + * why `spearWidth: 2.2` is a 46 cm spindle and not a 2.2 m one. Multiply if + * you want to know how big something really is; drag if you want to find out. + * + * The `Up` values are the only ones here measured straight off the floor, + * because the ground bite reads them and a bite measured from a lofting lead + * would move every time `leadRise` did. + */ + fistWidth: 1.55, // scale across (× 0.72 unit → ~1.1 m half-width) + fistHeight: 1.5, // scale up (× 0.64 unit → ~0.96 m half-height) + fistDepth: 1.45, // scale downrange + fistUp: 1.7, // metres the fist's centre rides above the floor + wallWidth: 3.7, // a wall is broad... (× 0.98 → 3.6 m half-width) + wallHeight: 2.3, // ...tall... (× 0.96 → 2.2 m half-height) + wallDepth: 1.8, // ...and thin (× 0.16 → 0.29 m half-depth) + wallUp: 2.15, // metres its centre rides above the floor — its foot is in the dirt + spearWidth: 2.2, // a spear is narrow... (× 0.21 → 0.46 m half-width) + spearHeight: 2.2, + spearDepth: 3.0, // ...and long, downrange (× ~0.95 → 2.9 m half-length) + spearUp: 1.95, // metres its centre rides above the floor — it flies, so it barely bites + + /* --- and how it comes apart --- */ + holdTime: 0.35, // seconds the spear holds at the target before it lets go + disperseTime: 0.7, // seconds the shape takes to release the colony + disperseSpacing: 1.7, // × the formation opens out to as it scatters + disperseWander: 1.9, // × the curl drift grows to + disperseChurn: 1.8, // × the formation roll grows to + disperseLag: 1.5, // × the rank lag grows to — the tail strings out + vanishDelay: 0.5, // seconds after the hold before agents start winking out + vanishTime: 0.9, // seconds the whole colony takes to wink out + + /* ------------------------------------------------------------------ */ + /* The surge — a density wave running down the cast */ + /* ------------------------------------------------------------------ */ + // Longitudinal, not a brightness ramp: agents are displaced along the wave + // axis by a sine of their own position on it, which crowds them at the zero + // crossings. Small here; it is the whole read of `waspfunnel` and only a + // texture on the mass in this one. + waveAmp: 0.22, // metres of longitudinal bunching + waveLength: 3.2, // metres between crests + waveSpeed: -7.0, // metres/second the crests travel (negative = back down the line) + waveAlong: 1.0, // 0 the wave climbs, 1 it runs along the cast + + /* ------------------------------------------------------------------ */ + /* The bite — how the shape's own height decides what the floor takes */ + /* ------------------------------------------------------------------ */ + biteReach: 2.5, // metres of clearance over which the strip weakens to nothing + biteGain: 1.2, // multiplier on the resulting contact strength + + /* ------------------------------------------------------------------ */ + /* The stripped track — vfx/GroundField.js, RUT mode */ + /* ------------------------------------------------------------------ */ + rutSamples: 14, // contact samples posted along the track (capped at 16) + rutWidth: 1.6, // half-width of the stripped swathe, metres + rutDepth: 0.14, // metres the floor is taken down at full contact + rutSharp: 0.35, // 0..1 how squarely the edge of the swathe falls away + rutCell: 0.34, // metres between bite marks along the track + rutBiteDepth: 0.7, // 0..1 how deeply those bite marks show + rutSpoil: 0.05, // metres of chaff heaped either side + rutSpoilWidth: 0.22, // metres wide that heap is + rutSampleBlend: 1.1, // metres over which two contact samples blend together + rutEdge: 0.3, // metres of feather at the head of the swathe + rutRagged: 0.34, // how far the edge wanders, as a fraction of the radius + rutRaggedScale: 0.9, // lobes per metre + rutWarp: 0.45, // metres of domain warp on those lobes + rutRelief: 0.7, // how hard the height field tilts the fake normal + rutNormalStep: 0.05, // metres between the height taps + rutAmbient: 0.3, // floor on the diffuse term + rutWrap: 0.45, // 0..1 wraps the terminator round the back + rutSpecular: 0.22, // stripped ground is matte + rutGloss: 16, // Blinn exponent + rutParallax: 0.22, // metres of view-driven offset on the interior detail + rutEmissive: 0.7, // multiplier on anything glowing in the swathe + rutOpacity: 0.95, + rutDepthFade: 0.5, // metres of soft fade against standing geometry + rutHeight: 0.013, // metres above the floor the quad sits at + rutHold: 0.6, // seconds the swathe stays at full strength after arrival + rutFadeTime: 1.7, // seconds it then takes to fade out of the floor + colorRutBase: '#6c6244', // the stripped ground itself + colorRutEdge: '#a89b6a', // the torn lip either side + colorRutGlow: '#c8c05a', // the faint chitin dust left in it + colorRutDeep: '#221e12', // the shadow in the gouge + + /* ------------------------------------------------------------------ */ + /* Chaff — shredded stubble the tide kicks up */ + /* ------------------------------------------------------------------ */ + chaffRate: 190, // particles/second while the colony is travelling + chaffSize: 0.075, // metres + chaffSpeed: 3.4, // metres/second off the floor + chaffSpread: 0.85, // 0..1 cone width + chaffLifetime: 1.5, // seconds + chaffGravity: -7.5, // metres/second² + chaffSpin: 9.0, // radians/second of tumble + colorChaffA: '#d8cf9e', + colorChaffB: '#a89a55', + colorChaffC: '#6b6030', + colorChaffD: '#2a2612', + + /* --- dust the mass drags off the floor --- */ + dustRate: 46, // particles/second + dustSize: 0.85, // metres + dustSpeed: 1.5, // metres/second + dustLifetime: 2.4, // seconds + dustRise: 0.5, // metres/second² of buoyancy + dustOpacity: 0.5, + dustTurbulence: 0.55, + colorDustA: '#b6ad8c', + colorDustB: '#8d8567', + colorDustC: '#5c5742', + colorDustD: '#2a2820', + + /* --- glints: sunlight caught on a wing, one agent at a time --- */ + glintRate: 130, // particles/second + glintSize: 0.055, // metres + glintSpeed: 1.1, // metres/second + glintLifetime: 0.5, // seconds — a flash, not a mote + glintRise: 0.3, // metres/second² of buoyancy + glintTurbulence: 0.7, + glintGlow: 1.6, // emissive gain on the flash + colorGlintA: '#fff6d2', + colorGlintB: '#e6d484', + colorGlintC: '#9aa03c', + colorGlintD: '#33380f', + + /* ------------------------------------------------------------------ */ + /* The cast and the landing */ + /* ------------------------------------------------------------------ */ + muzzleSize: 1.0, // metres the boil at the hand reaches + muzzleIntensity: 0.8, + castFlash: 0.16, // screen flash as the colony leaves the hand + castGlints: 40, // glints thrown at the hand + colorMuzzleA: '#d9cf8e', + colorMuzzleB: '#8f8c3a', + colorMuzzleC: '#4a5417', + colorCastFlash: '#c8c67a', + + burstSize: 2.6, // metres the landing dust ball reaches + burstIntensity: 1.0, + burstChaff: 150, // chaff thrown out by the landing + burstGlints: 120, // glints thrown out by the landing + shockRadius: 3.4, // metres the ground ring reaches + impactShake: 0.42, + shakeDuration: 0.5, // seconds the landing shake decays over + impactFlash: 0.2, + rumble: 0.03, // continuous shake while the tide is travelling + colorBurstA: '#cbc48c', // dust-ball shell + colorBurstB: '#8d8b3e', // its body + colorBurstC: '#4b5418', // its filaments + colorShockA: '#ded39a', // the ground ring's leading edge + colorShockB: '#6a6a28', // and its trail + colorFlash: '#cdc684', // the landing's screen flash + + /* --- the dynamic light --- */ + lightColor: '#c9c46a', // a sickly sunlit green-gold + lightIntensity: 1.5, + lightRadius: 11.0, // metres + lightFlicker: 0.16, // 0..1 depth of the wingbeat shimmer on the light + lightFlickerSpeed: 21.0 // shimmer steps/second +}; + +/** Editor layout. The three schedule folders come first because they are the ability. */ +export const locusttideSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 60, 0.5, 'colony speed'], + ['lifetime', 0.1, 4, 0.01, 'hold at target'], + ['fadeTime', 0.05, 4, 0.01, 'fade time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where it leaves the hand': [ + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['handHeight', 0, 3, 0.01, 'hand height'], + ['endHeight', 0, 5, 0.01, 'height at target'], + ['leadRise', -2, 8, 0.05, 'mid-span loft'] + ], + 'The shapes/Schedule': [ + ['closeAt', 0, 1, 0.01, 'starts closing at'], + ['fistAt', 0, 1, 0.01, 'fist at'], + ['wallAt', 0, 1, 0.01, 'wall at'], + ['spearAt', 0, 1, 0.01, 'spear at'], + ['morphCurve', 0.2, 4, 0.01, 'morph curve'], + ['condenseMax', 0, 1, 0.01, 'shape vs flock'] + ], + 'The shapes/Fist': [ + ['fistWidth', 0.1, 6, 0.01, 'half-width (m)'], + ['fistHeight', 0.1, 6, 0.01, 'half-height (m)'], + ['fistDepth', 0.1, 6, 0.01, 'half-depth (m)'], + ['fistUp', 0, 8, 0.01, 'centre height (m)'] + ], + 'The shapes/Wall': [ + ['wallWidth', 0.1, 10, 0.01, 'half-width (m)'], + ['wallHeight', 0.1, 8, 0.01, 'half-height (m)'], + ['wallDepth', 0.05, 6, 0.01, 'half-depth (m)'], + ['wallUp', 0, 8, 0.01, 'centre height (m)'] + ], + 'The shapes/Spear': [ + ['spearWidth', 0.05, 4, 0.01, 'half-width (m)'], + ['spearHeight', 0.05, 4, 0.01, 'half-height (m)'], + ['spearDepth', 0.2, 10, 0.01, 'half-depth (m)'], + ['spearUp', 0, 8, 0.01, 'centre height (m)'] + ], + 'The shapes/How they are drawn': [ + ['shapeForward', -4, 6, 0.01, 'ahead of the lead (m)'], + ['shapeSide', -4, 4, 0.01, 'off the lead (m)'], + ['shapeSpin', -4, 4, 0.01, 'shape spin (rad/s)'], + ['shapeFill', 0, 1, 0.01, 'shell → solid'], + ['shapeSteps', 1, 4, 1, 'descent steps'], + ['shapeSlack', 0.05, 1, 0.01, 'step relaxation'], + ['shapeRough', 0, 0.5, 0.005, 'crowd slop'] + ], + 'The shapes/Coming apart': [ + ['holdTime', 0, 3, 0.01, 'hold at target (s)'], + ['disperseTime', 0.05, 4, 0.01, 'disperse time (s)'], + ['disperseSpacing', 1, 5, 0.01, 'spacing ×'], + ['disperseWander', 1, 6, 0.01, 'drift ×'], + ['disperseChurn', 1, 6, 0.01, 'roll ×'], + ['disperseLag', 1, 5, 0.01, 'rank lag ×'], + ['vanishDelay', 0, 3, 0.01, 'vanish delay (s)'], + ['vanishTime', 0.05, 4, 0.01, 'vanish time (s)'] + ], + 'The formation': [ + ['locusts', 1, 640, 1, 'locusts'], + ['latticeX', 1, 20, 1, 'cells across'], + ['latticeY', 1, 14, 1, 'cells up'], + ['latticeZ', 1, 24, 1, 'ranks back'], + ['spacingSide', 0.02, 3, 0.01, 'lateral spacing'], + ['spacingUp', 0.02, 3, 0.01, 'vertical spacing'], + ['lag', 0, 2.5, 0.01, 'rank lag'], + ['jitter', 0, 1.5, 0.01, 'cell slop'], + ['churn', -6, 6, 0.01, 'formation roll'], + ['breathe', 0, 1.5, 0.01, 'swell'], + ['breatheRate', 0, 8, 0.05, 'swell rate'], + ['wander', 0, 1.5, 0.01, 'curl drift'], + ['wanderScale', 0.05, 3, 0.01, 'drift features / m'], + ['wanderSpeed', 0, 4, 0.01, 'drift speed'], + ['gather', 0, 1, 0.01, 'collapse onto lead'] + ], + 'The surge': [ + ['waveAmp', 0, 2, 0.01, 'bunching (m)'], + ['waveLength', 0.2, 12, 0.05, 'crest spacing (m)'], + ['waveSpeed', -20, 20, 0.1, 'crest speed (m/s)'], + ['waveAlong', 0, 1, 0.01, 'climbs → runs down'] + ], + 'One locust': [ + ['size', 0.02, 1, 0.005, 'size'], + ['aspect', 0.3, 4, 0.01, 'span / length'], + ['sizeJitter', 0, 1, 0.01, 'size jitter'], + ['sweep', 0, 2, 0.01, 'wing rake'], + ['dihedral', 0, 1.5, 0.01, 'wing fold'], + ['wingCurl', -1, 1, 0.01, 'wing camber'], + ['flapRate', 0, 40, 0.5, 'wing-beats / sec'], + ['bank', 0, 0.5, 0.001, 'bank per m/s²'], + ['bankMax', 0, 2, 0.01, 'max bank'], + ['billboard', 0, 1, 0.01, 'camera facing'], + ['edgeStretch', 1, 5, 0.01, 'edge-on stretch'], + ['edgeGain', 0, 6, 0.01, 'edge-on gain'], + ['lit', 0, 1, 0.01, 'diffuse mix'], + ['revealTime', 0.01, 2, 0.01, 'gather time'], + ['revealSpread', 0.01, 1, 0.01, 'gather spread'] + ], + 'Colony colour': [ + ['colorLocust*', 'Locust gradient'], + ['tint', 0, 1, 0.01, 'gradient position'], + ['tintJitter', 0, 1, 0.01, 'per-agent walk'], + ['tintAlong', 0, 1, 0.01, 'head-to-tail walk'], + ['opacity', 0, 2, 0.01, 'opacity'], + ['glow', 0, 4, 0.01, 'glow'], + ['softFade', 0.02, 2, 0.01, 'soft intersection'] + ], + 'The stripped track': [ + ['biteReach', 0.1, 8, 0.01, 'bite reach (m)'], + ['biteGain', 0, 3, 0.01, 'bite gain'], + ['rutSamples', 1, 16, 1, 'contact samples'], + ['rutWidth', 0.05, 5, 0.01, 'half-width (m)'], + ['rutDepth', 0, 1.5, 0.01, 'depth (m)'], + ['rutSharp', 0, 1, 0.01, 'edge sharpness'], + ['rutCell', 0.05, 2, 0.01, 'bite pitch (m)'], + ['rutBiteDepth', 0, 1, 0.01, 'bite depth'], + ['rutSpoil', 0, 0.6, 0.005, 'chaff heap (m)'], + ['rutSpoilWidth', 0, 1, 0.005, 'heap width (m)'], + ['rutSampleBlend', 0.05, 4, 0.01, 'sample blend (m)'], + ['rutEdge', 0.02, 2, 0.01, 'head feather (m)'], + ['rutRagged', 0, 1, 0.01, 'edge wander'], + ['rutRaggedScale', 0.05, 4, 0.01, 'lobes / m'], + ['rutWarp', 0, 2, 0.01, 'domain warp (m)'], + ['rutRelief', 0, 2, 0.01, 'relief'], + ['rutNormalStep', 0.01, 0.4, 0.005, 'normal step (m)'], + ['rutAmbient', 0, 1, 0.01, 'ambient'], + ['rutWrap', 0, 1, 0.01, 'terminator wrap'], + ['rutSpecular', 0, 2, 0.01, 'specular'], + ['rutGloss', 1, 80, 1, 'gloss'], + ['rutParallax', 0, 1, 0.01, 'parallax (m)'], + ['rutEmissive', 0, 3, 0.01, 'emissive'], + ['rutOpacity', 0, 1, 0.01, 'opacity'], + ['rutDepthFade', 0.02, 2, 0.01, 'soft intersection'], + ['rutHeight', 0.002, 0.1, 0.001, 'height off floor (m)'], + ['rutHold', 0, 4, 0.01, 'hold (s)'], + ['rutFadeTime', 0.1, 6, 0.01, 'fade (s)'], + ['colorRutBase', 'stripped ground'], + ['colorRutEdge', 'torn lip'], + ['colorRutGlow', 'chitin dust'], + ['colorRutDeep', 'gouge shadow'] + ], + 'Chaff': [ + ['chaffRate', 0, 800, 1, 'chaff rate'], + ['chaffSize', 0.005, 0.5, 0.005, 'chaff size'], + ['chaffSpeed', 0, 20, 0.05, 'chaff speed'], + ['chaffSpread', 0, 1, 0.01, 'chaff spread'], + ['chaffLifetime', 0.1, 6, 0.05, 'chaff lifetime'], + ['chaffGravity', -30, 5, 0.1, 'chaff gravity'], + ['chaffSpin', 0, 30, 0.1, 'chaff tumble'], + ['colorChaff*', 'Chaff colour'] + ], + 'Dust': [ + ['dustRate', 0, 400, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 10, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustTurbulence', 0, 4, 0.01, 'dust turbulence'], + ['colorDust*', 'Dust colour'] + ], + 'Wing glints': [ + ['glintRate', 0, 800, 1, 'glint rate'], + ['glintSize', 0.005, 0.4, 0.005, 'glint size'], + ['glintSpeed', 0, 10, 0.05, 'glint speed'], + ['glintLifetime', 0.05, 3, 0.01, 'glint lifetime'], + ['glintRise', -2, 4, 0.01, 'glint rise'], + ['glintTurbulence', 0, 4, 0.01, 'glint turbulence'], + ['glintGlow', 0, 6, 0.01, 'glint glow'], + ['colorGlint*', 'Glint colour'] + ], + 'Release & landing': [ + ['muzzleSize', 0.05, 4, 0.05, 'hand boil size'], + ['muzzleIntensity', 0, 5, 0.01, 'hand boil intensity'], + ['castFlash', 0, 2, 0.01, 'release flash'], + ['castGlints', 0, 400, 1, 'release glints'], + ['colorMuzzleA', 'hand boil shell'], + ['colorMuzzleB', 'hand boil body'], + ['colorMuzzleC', 'hand boil filaments'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.05, 8, 0.05, 'landing dust size'], + ['burstIntensity', 0, 5, 0.01, 'landing dust intensity'], + ['burstChaff', 0, 600, 1, 'landing chaff'], + ['burstGlints', 0, 600, 1, 'landing glints'], + ['shockRadius', 0.2, 12, 0.05, 'ground ring (m)'], + ['impactShake', 0, 3, 0.01, 'landing shake'], + ['shakeDuration', 0.05, 2, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'landing flash'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['colorBurstA', 'dust-ball shell'], + ['colorBurstB', 'dust-ball body'], + ['colorBurstC', 'dust-ball filaments'], + ['colorShockA', 'ring edge'], + ['colorShockB', 'ring trail'], + ['colorFlash', 'landing flash colour'] + ], + 'The light': [ + ['lightColor', 'light colour'], + ['lightIntensity', 0, 8, 0.01, 'intensity'], + ['lightRadius', 1, 40, 0.5, 'radius (m)'], + ['lightFlicker', 0, 1, 0.01, 'wingbeat shimmer'], + ['lightFlickerSpeed', 1, 60, 0.5, 'shimmer speed'] + ] +}; diff --git a/src/config/abilities/magma.js b/src/config/abilities/magma.js new file mode 100644 index 0000000..4e8a74d --- /dev/null +++ b/src/config/abilities/magma.js @@ -0,0 +1,591 @@ +/* ================================================================== */ +/* MAGMA — Magma Fount */ +/* ================================================================== */ +/** + * A molten pool torn open in the floor, with a fount playing out of the middle + * of it. + * + * **The pool is the ability.** It is a live heightfield — a real subdivided + * plane with a displaced vertex shader — and everything interesting about it + * falls out of one quantity the fragment shader computes honestly: the + * **surface speed** in metres per second. Bulk drift, radial outflow from the + * feed, eddies taken as the curl of a scalar field, and downhill gravity read + * straight off the shading normal all add into it, and the black skin's + * coverage is `1 − smoothstep(crustForm, crustBreak, speed)`. + * + * Nothing in the code says "crack when hit". A blob lands, its ripple steepens + * the local slope, the slope feeds `flowGravity`, the flow carries the surface + * past `crustBreak`, and the crust **tears open along the ripple front and + * glows** — then heals behind it as the ripple decays. That coupling is the + * whole reason the pool reads as molten rather than as an orange decal, and it + * is why `flowGravity` is the single most important slider in this block. + * + * The cooling beat works the same way and for the same reason: the fade does + * not paint the crust on, it takes the *flow* away (`flowRadial`, `flowEddy` + * and `flowSpeed` all ramp to zero), and coverage goes to one on its own. + * + * The honest limitation, stated once so nobody looks for the bug: a fragment + * cannot remember when it was last moving, so "the crust re-forms where the + * surface is slow" is instantaneous in space and lagged in time only by + * `crustFormTime`. Real skin has hysteresis. Buying it needs a ping-pong + * buffer, which is a texture, which is **I2**. + */ +export const magma = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + speed: 46.0, // how fast the cast reaches the circle, metres/second + zoneRadius: 4.6, // the footprint — drives the pool, the fount and the haze + lifetime: 3.1, // seconds the fount runs at full tilt + fadeTime: 2.8, // seconds it takes to cool + cooldown: 2.4, // seconds before the slot re-arms + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats --- */ + preFill: 0.28, // 0..1 of the footprint the swelling reaches before it splits + swellTime: 0.45, // seconds the pool takes to open out to full + coolTime: 2.2, // seconds the flow takes to die once the fade starts + fountRamp: 0.35, // seconds the fount takes to reach its full rate + + /* --- the sheet --- */ + round: 1.0, // 0 rectangular footprint, 1 elliptical + edgeSoft: 0.11, // 0..1 of the field over which the waterline fades + edgeNoise: 0.52, // 0..1 how ragged that line is + edgeScale: 1.15, // cycles per metre of the raggedness + poolOpacity: 1.0, + contactFade: 0.3, // metres of soft fade against opaque geometry + // Enough to clear the floor's own polygon without the waterline reading as a + // lip standing off the ground. + poolHeight: 0.06, // metres the mean plane sits above the floor + + /* --- the swell: four directional waves --- */ + // Long and slow, unlike water: lava is heavy and its swell is a heave rather + // than a chop. Dropping waveLengthA below about two metres is the fastest way + // to make the pool read as orange water. + waveAmpA: 0.14, // metres + waveAmpB: 0.08, + waveAmpC: 0.045, + waveAmpD: 0.022, + waveLengthA: 6.4, // metres, crest to crest + waveLengthB: 3.6, + waveLengthC: 2.1, + waveLengthD: 1.15, + waveSpeedA: 0.6, // metres/second + waveSpeedB: 0.45, + waveSpeedC: 0.32, + waveSpeedD: 0.24, + waveAngleA: 0.35, // radians + waveAngleB: 1.4, + waveAngleC: 2.7, + waveAngleD: 4.2, + steepness: 0.5, // 0 sine, 1 Gerstner cusps; above ~1 the mesh self-folds + + /* --- chop --- */ + chop: 0.05, // metres + chopScale: 1.3, // cycles per metre + chopSpeed: 0.28, // metres/second the field drifts + detail: 0.02, // metres — fragment-only, lives entirely in the normal + detailScale: 6.2, // cycles per metre + detailSpeed: 0.7, // metres/second + + /* --- ripples (what a landing blob punches in) --- */ + rippleAmp: 0.26, // metres at strength 1 + rippleSpeed: 3.6, // metres/second the front travels + rippleLength: 1.15, // metres, crest to crest inside the packet + rippleWidth: 0.85, // metres of the gaussian envelope + rippleDecay: 1.5, // seconds to 1/e + rippleSpread: 3.0, // metres over which it also thins with radius + rippleStrength: 1.35, // dimensionless multiplier a landing blob arrives with + + /* --- the flow field (everything the crust does is a function of this) --- */ + // These five numbers decide how much of the pool is black at any moment, and + // they were balanced by arithmetic before they were balanced by eye. At the + // feed the radial term alone is nearly 2 m/s, which is over `crustBreak`, so + // the middle stays bare melt; one 1/e length out it has fallen to a third of + // that and the skin closes; at the rim the total sits at about 0.9 m/s, which + // is coverage of roughly 0.7 — crusted, but with seams still open in it. Take + // `flowRadialFall` up past about three metres and the whole pool goes bare, + // which is the mistake the first tuning pass made and it looks like paint. + flowAngle: 0.6, // radians, the bulk drift's bearing + flowSpeed: 0.18, // metres/second of that drift + flowRadial: 1.9, // metres/second outward at the feed + flowRadialFall: 1.5, // metres to 1/e of that outflow + flowEddy: 0.55, // metres/second of curl swirl + flowEddyScale: 0.3, // cycles per metre of the eddies + flowEddySpeed: 0.14, // Hz they churn at + flowGravity: 2.6, // metres/second per unit of surface slope + + /* --- the crust --- */ + crust: 1.0, // 0..1 master; 0 skips the whole block + crustForm: 0.55, // m/s below which the skin is unbroken + crustBreak: 1.6, // m/s above which there is none + crustFormTime: 0.85, // seconds for the first skin to chill + crackScale: 1.05, // cycles per metre across the flow + crackStretch: 5.2, // how many times longer features are along it + crackWidth: 0.2, // 0..1 of the field — the seam's width + crustAdvect: 1.0, // 0..1 how strongly the pattern is carried by the flow + crustPeriod: 2.6, // seconds before the flow map resets + crustBump: 0.5, // 0..1 how much the skin roughens the normal + seamGlow: 3.2, // how hot the seams between plates run + meltGlow: 1.5, // glow of the bare melt between the plates + + /* --- how the pool is shaded --- */ + poolDepth: 0.55, // metres of melt under the mean plane + depthTint: 1.8, // Beer-Lambert density, per metre + translucency: 0.9, + ambient: 0.3, + specular: 0.85, + shininess: 42, // Blinn-Phong exponent + fresnel: 1.0, + envIntensity: 0.4, + skyIntensity: 0.25, + emissive: 1.15, // self-lit body — this is the one water must not have + poolGlow: 1.4, + normalEps: 0.05, // metres — the finite-difference step of the normal + + colorDeep: '#120806', // the melt at depth + colorShallow: '#7a1a04', // the melt at the waterline + colorCrust: '#0d0806', // the chilled skin + colorSeam: '#ff5a12', // the crack between two plates of it + colorHot: '#ffe08a', // the hottest the seams get + colorSpec: '#ffb14a', // the specular highlight + colorSky: '#2a1410', // what the surface reflects where it sees the sky + + /* --- the fount --- */ + blobs: 26, // blobs thrown over the whole hold (capped at 48) + blobRadius: 0.28, // metres + blobSizeJitter: 0.4, // ±fraction of that + blobStretch: 1.25, // scale along the aligned axis + blobAlign: 0.55, // 0 tumbles freely, 1 lays +Y along the heading + blobSpin: 5.0, // tumble rate, radians/second + blobFlash: 0.16, // seconds the birth flash takes to decay + fountForward: 0.0, // metres downrange of the pool's centre + fountSide: 0.0, // metres to the side of it + fountHeight: 0.35, // metres above the floor the fount's mouth sits + blobReach: 0.86, // fraction of `zoneRadius` a blob may land within + blobBias: 0.62, // 0.5 uniform, <0.5 crowds the rim, >0.5 crowds the middle + blobApex: 3.6, // ballistic loft, metres + blobApexCurve: 0.9, // >1 flattens the top of the lob + blobPathCurve: 1.0, // easing exponent on launch → land + blobWeaveSide: 0.22, // lateral weave at launch, metres + blobWeaveUp: 0.14, // vertical weave at launch, metres + blobWeaveTurns: 1.1, // lateral cycles over the flight + blobWeaveDecay: 1.5, // >0 pulls the weave to zero exactly at the landing + blobFlight: 0.95, // seconds one blob is in the air + blobSpeedJitter: 0.28, // ±fraction of flight time + blobLead: 0.05, // seconds before the first blob leaves + blobWindow: 3.0, // seconds the staggered launches are spread over + blobFillBias: 0.3, // +1 fills outward from the centre, -1 inward from the rim + blobFillScatter: 0.75, // 0 pure radial order, 1 pure spatial hash + blobHashCell: 1.3, // hash lattice size, metres + blobLinger: 0.5, // seconds a landed blob stays on the surface, sinking + blobSink: 1.8, // body radii it sinks over that linger + + /* --- the blob's shape and skin --- */ + blobDetail: 1, // icosahedron subdivisions, 0..3 + blobLumpiness: 0.42, // how far the surface wanders off the sphere + blobNoiseScale: 1.8, // features per unit radius + blobRoughness: 0.14, // high-frequency grain on top + blobCrust: 0.9, // 0..1 skin coverage by the time it lands + blobCrustGrow: 1.5, // exponent on the flight — >1 chills late + blobCrackScale: 2.8, // cycles per unit radius of the seams + blobCrackWidth: 0.24, // 0..1 of the field — the seam's width + blobSeamGlow: 1.4, + blobRim: 0.9, // how hot the silhouette's edge runs + blobRimPower: 2.1, + blobShade: 0.6, // key-light term on the blob's crust + blobAmbient: 0.24, + blobFlashGain: 2.6, // extra heat on a blob that has just left the fount + blobGlow: 1.7, + blobOpacity: 1.0, + blobSoftFade: 0.18, // metres of soft fade against solid geometry + colorBlobHot: '#ffe08a', + colorBlobMelt: '#ff5a12', + colorBlobCrust: '#100807', + colorBlobSeam: '#ff8a2a', + + /* --- the blob's trail --- */ + blobTrailSpan: 0.26, // seconds of flight the tail reaches back over + blobTrailBurn: 0.2, // seconds the tail takes to catch up after landing + blobTrailWidth: 0.19, // metres at the head + blobTrailTaper: 1.5, // >1 sharpens the tail to a point + blobTrailLift: 0.06, // metres the tail floats above the flown path + blobTrailOpacity: 0.9, + blobTrailGlow: 1.5, + blobTrailCore: 2.0, // how tightly light crowds the centre line + blobTrailHeadBias: 0.5, // >0 keeps the brightness near the blob + blobTrailNoise: 0.55, + blobTrailNoiseScale: 1.5, // features per metre + blobTrailNoiseSpeed: 0.55, + blobTrailSoftFade: 0.35, // metres of depth feather + colorBlobTrailA: '#ffe6b0', + colorBlobTrailB: '#ff9a3c', + colorBlobTrailC: '#a82606', + colorBlobTrailD: '#200603', + + /* --- the heat shimmer, which is the last thing left --- */ + /** + * A `HEAT` emitter over the footprint. Its magnitudes are **screen + * fractions**, not metres, and neither `post.distortion` nor + * `global.distortion` belongs in `hazeStrength` — the pass applies both, + * once, and multiplying them in here would square them. + */ + hazeWidth: 1.9, // × zoneRadius — the column's width + hazeHeight: 3.4, // metres the column stands + hazeStrength: 0.42, // screen fractions at post.distortion = 1 + hazeOpacity: 1.0, + hazeFrequency: 1.05, // cycles per metre + hazeSpeed: 1.5, // metres/second the pattern rises + hazeSourceBias: 1.6, // exponent pushing the wobble toward the floor + hazeSpread: 0.85, // how much the column widens with height + hazeVertical: 0.3, // how much of the offset is vertical + hazeFlicker: 0.3, + hazeDepthReject: 1.0, // 0..1 how hard it refuses to bend things in front + hazeDepthFade: 0.4, // metres of depth feather on that + hazePerspective: 0.35, // 0..1 how much the wobble shrinks with distance + hazePerspectiveRef: 14.0, // metres at which it is drawn at full strength + hazeHold: 1.4, // seconds into the cool before the haze starts to go + hazeFade: 1.1, // seconds it then takes to go + + /* --- spatter, cinders and smoke --- */ + /** + * Four-stop lifetime gradients as everywhere else, `A` at birth through `D` + * as it dies. Spelled out rather than derived from the pool palette, so the + * smoke can be made cold and grey while the melt stays gold. + */ + spatterRate: 130, // droplets thrown off the fount, particles/second + spatterSize: 0.12, + spatterSpeed: 5.2, + spatterLifetime: 1.1, + spatterGravity: -14.0, // metres/second² + spatterTurbulence: 0.5, + spatterOnLanding: 16, // extra droplets thrown where a blob comes down + colorSpatterA: '#fff0c8', + colorSpatterB: '#ffb14a', + colorSpatterC: '#ff5a12', + colorSpatterD: '#5a1204', + cinderRate: 34, // cooled chips flung out onto the floor, particles/second + cinderSize: 0.07, + cinderSpeed: 6.5, + cinderLifetime: 1.6, + cinderGravity: -17.0, + colorCinderA: '#ff7a1f', + colorCinderB: '#5a2410', + colorCinderC: '#2a1a14', + colorCinderD: '#1a120e', + smokeRate: 55, // sulphurous haze off the surface, particles/second + smokeSize: 1.3, + smokeSpeed: 1.0, + smokeLifetime: 3.2, + smokeRise: 0.85, + smokeOpacity: 0.1, + colorSmokeA: '#4a3a30', + colorSmokeB: '#3a2e26', + colorSmokeC: '#2a221c', + colorSmokeD: '#181310', + + /* --- what the floor around the pool gets --- */ + scorchRadius: 1.5, // char marks laid outside the waterline, metres + scorchLife: 9.0, // seconds one lingers + scorchIntensity: 0.6, + scorchMarks: 9, // how many are laid when the floor splits + colorScorch: '#100906', // the burn itself + colorScorchEmber: '#ff6a1f', // the embers still cooling in it + + /* --- the split --- */ + burstSize: 3.4, // the shell thrown when the floor opens, metres + burstIntensity: 1.6, + burstSpatter: 190, // droplets thrown with it + burstCinders: 70, + shockRadius: 7.5, // ring snapped across the floor, metres + colorShockA: '#ff8a2a', // body of that ring + colorShockB: '#ffe08a', // its crest + colorBurstA: '#7a1a04', // burst shell + colorBurstB: '#ff5a12', // burst body + colorBurstC: '#ffe08a', // the filaments racing over it + colorFlash: '#ff8a2a', // the full-screen flash as it opens + impactFlash: 0.2, + impactShake: 0.95, + shakeDuration: 0.8, // seconds that shake decays over + rumble: 0.035, // continuous shake while the fount runs + + /* --- dynamic light --- */ + lightIntensity: 24, // the pool lights the stage from below + lightRadius: 16, + lightHeight: 0.6, // metres above the surface the light sits + lightColor: '#ff6a1f', + lightFlicker: 0.2, // depth of the gutter, 0 = steady + lightFlickerSpeed: 8 // gutters/second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Magma Fount. + * + * Reach for **The flow field** first. Every interesting thing the crust does is + * a function of surface speed, and a pool with `flowSpeed`, `flowRadial` and + * `flowEddy` all near zero has a speed of zero everywhere: uniform unbroken + * skin, no seams, no heat, nothing to look at. `flowGravity` is the one that + * couples a landing blob's ripple into the crust — take it to zero and the + * blobs stop breaking the skin, which is the fastest way to see what that + * coupling was buying. + * + * Then **The crust**: `crustForm` and `crustBreak` are the two speeds coverage + * is interpolated between, so bringing them together hardens the boundary into + * a shoreline and pushing them apart turns the whole pool into a gradient. + * + * `zoneRadius` deliberately drives four consumers at once — the sheet, the + * blobs' landing disc, the heat column and the scorched ring — because a pool + * whose fount lands outside it is not one thing. + */ +export const magmaSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'cast speed'], + ['zoneRadius', 1, 14, 0.1, 'footprint radius'], + ['lifetime', 0.2, 8, 0.05, 'fount duration'], + ['fadeTime', 0.2, 8, 0.05, 'cool duration'], + ['cooldown', 0, 10, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['preFill', 0, 1, 0.01, 'swell before the split'], + ['swellTime', 0.05, 3, 0.01, 'time to open out'], + ['coolTime', 0.1, 8, 0.05, 'time the flow takes to die'], + ['fountRamp', 0.02, 2, 0.01, 'fount ramp-up'] + ], + 'The sheet': [ + ['round', 0, 1, 0.01, 'elliptical footprint'], + ['edgeSoft', 0.01, 0.6, 0.005, 'waterline softness'], + ['edgeNoise', 0, 1, 0.01, 'waterline raggedness'], + ['edgeScale', 0.1, 4, 0.01, 'raggedness / metre'], + ['poolOpacity', 0, 1, 0.01, 'pool opacity'], + ['contactFade', 0.02, 2, 0.01, 'soft intersection'], + ['poolHeight', 0, 1, 0.005, 'surface height'] + ], + 'The swell': [ + ['waveAmpA', 0, 1, 0.005, 'amp A'], + ['waveAmpB', 0, 1, 0.005, 'amp B'], + ['waveAmpC', 0, 1, 0.005, 'amp C'], + ['waveAmpD', 0, 1, 0.005, 'amp D'], + ['waveLengthA', 0.3, 20, 0.05, 'length A'], + ['waveLengthB', 0.3, 20, 0.05, 'length B'], + ['waveLengthC', 0.3, 20, 0.05, 'length C'], + ['waveLengthD', 0.3, 20, 0.05, 'length D'], + ['waveSpeedA', -4, 4, 0.01, 'speed A'], + ['waveSpeedB', -4, 4, 0.01, 'speed B'], + ['waveSpeedC', -4, 4, 0.01, 'speed C'], + ['waveSpeedD', -4, 4, 0.01, 'speed D'], + ['waveAngleA', 0, 6.29, 0.01, 'bearing A'], + ['waveAngleB', 0, 6.29, 0.01, 'bearing B'], + ['waveAngleC', 0, 6.29, 0.01, 'bearing C'], + ['waveAngleD', 0, 6.29, 0.01, 'bearing D'], + ['steepness', 0, 1.2, 0.01, 'gerstner steepness'], + ['chop', 0, 0.5, 0.005, 'chop'], + ['chopScale', 0.1, 6, 0.01, 'chop / metre'], + ['chopSpeed', 0, 3, 0.01, 'chop drift'], + ['detail', 0, 0.2, 0.001, 'normal detail'], + ['detailScale', 0.5, 20, 0.1, 'detail / metre'], + ['detailSpeed', 0, 4, 0.01, 'detail drift'] + ], + 'Ripples': [ + ['rippleAmp', 0, 1.5, 0.01, 'ripple height'], + ['rippleSpeed', 0.2, 12, 0.05, 'front speed'], + ['rippleLength', 0.1, 5, 0.01, 'wavelength'], + ['rippleWidth', 0.05, 4, 0.01, 'packet width'], + ['rippleDecay', 0.1, 6, 0.05, 'decay to 1/e'], + ['rippleSpread', 0.2, 12, 0.05, 'radial thinning'], + ['rippleStrength', 0, 4, 0.01, 'strength per blob'] + ], + 'The flow field': [ + ['flowAngle', 0, 6.29, 0.01, 'drift bearing'], + ['flowSpeed', 0, 4, 0.01, 'drift speed'], + ['flowRadial', 0, 6, 0.01, 'outflow at the feed'], + ['flowRadialFall', 0.2, 12, 0.05, 'outflow falloff'], + ['flowEddy', 0, 4, 0.01, 'eddy speed'], + ['flowEddyScale', 0.02, 2, 0.01, 'eddies / metre'], + ['flowEddySpeed', 0, 2, 0.01, 'eddy churn'], + ['flowGravity', 0, 12, 0.05, 'downhill gain'] + ], + 'The crust': [ + ['crust', 0, 1, 0.01, 'crust master'], + ['crustForm', 0, 4, 0.01, 'forms below (m/s)'], + ['crustBreak', 0.05, 6, 0.01, 'breaks above (m/s)'], + ['crustFormTime', 0.05, 5, 0.05, 'first skin time'], + ['crackScale', 0.1, 5, 0.01, 'cracks / metre'], + ['crackStretch', 1, 16, 0.1, 'stretch along the flow'], + ['crackWidth', 0.01, 0.8, 0.005, 'seam width'], + ['crustAdvect', 0, 1, 0.01, 'carried by the flow'], + ['crustPeriod', 0.3, 8, 0.05, 'flow-map period'], + ['crustBump', 0, 1, 0.01, 'skin roughness'], + ['seamGlow', 0, 8, 0.05, 'seam glow'], + ['meltGlow', 0, 6, 0.05, 'melt glow'] + ], + 'Pool shading': [ + ['poolDepth', 0.02, 3, 0.01, 'depth under the plane'], + ['depthTint', 0, 6, 0.01, 'depth density'], + ['translucency', 0, 3, 0.01, 'translucency'], + ['ambient', 0, 2, 0.01, 'ambient'], + ['specular', 0, 4, 0.01, 'specular'], + ['shininess', 2, 200, 1, 'shininess'], + ['fresnel', 0, 4, 0.01, 'fresnel'], + ['envIntensity', 0, 3, 0.01, 'environment'], + ['skyIntensity', 0, 3, 0.01, 'sky'], + ['emissive', 0, 4, 0.01, 'self-lit'], + ['poolGlow', 0, 6, 0.01, 'glow'], + ['normalEps', 0.005, 0.3, 0.001, 'normal step'], + ['colorDeep', 'melt at depth'], + ['colorShallow', 'melt at the edge'], + ['colorCrust', 'crust'], + ['colorSeam', 'seam'], + ['colorHot', 'hottest seam'], + ['colorSpec', 'specular'], + ['colorSky', 'sky reflection'] + ], + 'The fount': [ + ['blobs', 0, 48, 1, 'blobs'], + ['blobRadius', 0.02, 1.5, 0.01, 'blob radius'], + ['blobSizeJitter', 0, 1, 0.01, 'size jitter'], + ['blobStretch', 0.2, 4, 0.01, 'stretch'], + ['blobAlign', 0, 1, 0.01, 'align to heading'], + ['blobSpin', 0, 20, 0.1, 'tumble rate'], + ['blobFlash', 0.01, 1, 0.01, 'birth flash'], + ['fountForward', -6, 6, 0.05, 'fount forward'], + ['fountSide', -6, 6, 0.05, 'fount lateral'], + ['fountHeight', 0, 4, 0.01, 'fount height'], + ['blobReach', 0.05, 1.5, 0.01, 'landing reach'], + ['blobBias', 0.05, 3, 0.01, 'landing bias'], + ['blobApex', 0, 14, 0.05, 'loft'], + ['blobApexCurve', 0.1, 4, 0.01, 'loft curve'], + ['blobPathCurve', 0.2, 4, 0.01, 'path easing'], + ['blobWeaveSide', 0, 3, 0.01, 'lateral weave'], + ['blobWeaveUp', 0, 3, 0.01, 'vertical weave'], + ['blobWeaveTurns', 0, 6, 0.05, 'weave turns'], + ['blobWeaveDecay', 0, 5, 0.01, 'weave decay'], + ['blobFlight', 0.1, 4, 0.01, 'flight time'], + ['blobSpeedJitter', 0, 1, 0.01, 'flight jitter'], + ['blobLead', 0, 2, 0.01, 'first blob delay'], + ['blobWindow', 0.1, 8, 0.05, 'launch window'], + ['blobFillBias', -1, 1, 0.01, 'fill order'], + ['blobFillScatter', 0, 1, 0.01, 'fill scatter'], + ['blobHashCell', 0.1, 6, 0.05, 'hash cell'], + ['blobLinger', 0, 2, 0.01, 'linger on landing'], + ['blobSink', 0, 6, 0.05, 'sink depth (radii)'] + ], + 'The blob': [ + ['blobDetail', 0, 3, 1, 'subdivisions'], + ['blobLumpiness', 0, 1, 0.01, 'lumpiness'], + ['blobNoiseScale', 0.2, 6, 0.01, 'lump scale'], + ['blobRoughness', 0, 0.6, 0.005, 'grain'], + ['blobCrust', 0, 1, 0.01, 'crust coverage'], + ['blobCrustGrow', 0.1, 5, 0.01, 'chill curve'], + ['blobCrackScale', 0.2, 10, 0.05, 'cracks / radius'], + ['blobCrackWidth', 0.01, 0.8, 0.005, 'seam width'], + ['blobSeamGlow', 0, 5, 0.01, 'seam glow'], + ['blobRim', 0, 4, 0.01, 'rim heat'], + ['blobRimPower', 0.2, 6, 0.01, 'rim falloff'], + ['blobShade', 0, 2, 0.01, 'key light'], + ['blobAmbient', 0, 2, 0.01, 'ambient'], + ['blobFlashGain', 0, 8, 0.05, 'birth heat'], + ['blobGlow', 0, 6, 0.01, 'glow'], + ['blobOpacity', 0, 1, 0.01, 'opacity'], + ['blobSoftFade', 0.02, 2, 0.01, 'soft intersection'], + ['colorBlobHot', 'blob melt'], + ['colorBlobMelt', 'blob rim'], + ['colorBlobCrust', 'blob crust'], + ['colorBlobSeam', 'blob seam'] + ], + 'The blob trail': [ + ['blobTrailSpan', 0.02, 2, 0.01, 'tail length (seconds)'], + ['blobTrailBurn', 0.02, 2, 0.01, 'tail burn-back'], + ['blobTrailWidth', 0.01, 1, 0.005, 'tail width'], + ['blobTrailTaper', 0.1, 5, 0.01, 'tail taper'], + ['blobTrailLift', -1, 2, 0.01, 'tail lift'], + ['blobTrailOpacity', 0, 2, 0.01, 'opacity'], + ['blobTrailGlow', 0, 6, 0.01, 'glow'], + ['blobTrailCore', 0.1, 6, 0.01, 'core tightness'], + ['blobTrailHeadBias', 0, 1, 0.01, 'head bias'], + ['blobTrailNoise', 0, 3, 0.01, 'tail noise'], + ['blobTrailNoiseScale', 0.1, 6, 0.01, 'noise / metre'], + ['blobTrailNoiseSpeed', 0, 4, 0.01, 'noise speed'], + ['blobTrailSoftFade', 0.02, 2, 0.01, 'soft intersection'], + ['colorBlobTrail*', 'Trail gradient'] + ], + 'Heat shimmer': [ + ['hazeWidth', 0.2, 5, 0.01, 'width (× radius)'], + ['hazeHeight', 0.2, 14, 0.05, 'column height'], + ['hazeStrength', 0, 2, 0.01, 'strength'], + ['hazeOpacity', 0, 2, 0.01, 'opacity'], + ['hazeFrequency', 0.05, 5, 0.01, 'cycles / metre'], + ['hazeSpeed', 0, 8, 0.05, 'rise speed'], + ['hazeSourceBias', 0.1, 6, 0.05, 'base bias'], + ['hazeSpread', 0, 3, 0.01, 'widening'], + ['hazeVertical', 0, 1, 0.01, 'vertical share'], + ['hazeFlicker', 0, 2, 0.01, 'flicker'], + ['hazeDepthReject', 0, 1, 0.01, 'depth reject'], + ['hazeDepthFade', 0.02, 3, 0.01, 'depth feather'], + ['hazePerspective', 0, 1, 0.01, 'distance falloff'], + ['hazePerspectiveRef', 1, 40, 0.5, 'reference distance'], + ['hazeHold', 0, 6, 0.05, 'holds for'], + ['hazeFade', 0.05, 5, 0.05, 'then fades over'] + ], + 'Spatter & cinders': [ + ['spatterRate', 0, 600, 1, 'spatter rate'], + ['spatterSize', 0.005, 0.6, 0.005, 'spatter size'], + ['spatterSpeed', 0, 20, 0.1, 'spatter speed'], + ['spatterLifetime', 0.05, 4, 0.01, 'spatter lifetime'], + ['spatterGravity', -50, 5, 0.1, 'spatter gravity'], + ['spatterTurbulence', 0, 3, 0.01, 'spatter turbulence'], + ['spatterOnLanding', 0, 120, 1, 'spatter per landing'], + ['cinderRate', 0, 300, 1, 'cinder rate'], + ['cinderSize', 0.005, 0.4, 0.005, 'cinder size'], + ['cinderSpeed', 0, 25, 0.1, 'cinder speed'], + ['cinderLifetime', 0.1, 5, 0.05, 'cinder lifetime'], + ['cinderGravity', -50, 0, 0.1, 'cinder gravity'], + ['colorSpatter*', 'Spatter colour'], + ['colorCinder*', 'Cinder colour'] + ], + 'Smoke & scorch': [ + ['smokeRate', 0, 400, 1, 'smoke rate'], + ['smokeSize', 0.05, 5, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 10, 0.05, 'smoke lifetime'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['colorSmoke*', 'Smoke colour'], + ['scorchRadius', 0.1, 8, 0.05, 'scorch radius'], + ['scorchLife', 0.5, 30, 0.1, 'scorch lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['scorchMarks', 0, 40, 1, 'scorch marks'], + ['colorScorch', 'scorch'], + ['colorScorchEmber', 'scorch embers'] + ], + 'The split': [ + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstSpatter', 0, 600, 1, 'burst spatter'], + ['burstCinders', 0, 400, 1, 'burst cinders'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.5, 0.005, 'sustain rumble'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst filaments'], + ['colorFlash', 'split flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightHeight', 0, 4, 0.01, 'light height'], + ['lightFlicker', 0, 1, 0.01, 'light gutter'], + ['lightFlickerSpeed', 1, 60, 1, 'gutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/meteor.js b/src/config/abilities/meteor.js new file mode 100644 index 0000000..6b22eec --- /dev/null +++ b/src/config/abilities/meteor.js @@ -0,0 +1,508 @@ +/* ================================================================== */ +/* METEOR — ability three */ +/* ================================================================== */ +/** + * A burning rock lobbed along the aimed line, which detonates on arrival. + * + * The rock is real geometry — a cratered, faceted asteroid generated by + * `assets/ProceduralGeometry.js` — shaded by a patched standard material so it + * casts and receives the stage's shadows. Its signature is the **lava seams**: + * the zero crossing of an fbm field sampled in the rock's own local space, so + * the cracks are welded to it and tumble with it. `chargeCurve` decides how + * fast they prise open on the way in. + * + * Behind it hangs the **fire trail**: a black-body volume raymarched inside a + * camera-facing proxy hull laid along the arc. See the `trail*` block. + * + * As in `ice` and `thunder`, a cast captures nothing but dice and timestamps: + * one seed, one tumble axis and a few unitless rolls per debris chunk. The + * trajectory, the size of the rock, the width of its seams and the whole + * ballistic flight of every chunk are resolved against this block each frame — + * which is why dragging `arc` re-lofts a meteor already in the air, and + * dragging `chunkSpeed` re-throws debris that has already landed. + */ +export const meteor = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 21.0, // how fast the rock travels downrange, metres/second + lifetime: 2.2, // seconds the crater burns after the impact + fadeTime: 1.6, // seconds everything takes to clear + cooldown: 0.9, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the flight path --- */ + // The rock is thrown from a hand, so these are measured from the caster's + // origin in the cast's own frame. + handHeight: 1.35, // metres above the floor + handForward: 0.6, // metres in front of the caster + handSide: 0.2, // metres to the side (+ follows `Ability#side`) + endHeight: 0.75, // height of the rock where it lands, metres + arc: 2.6, // metres the mid-span lobs upward + arcCurve: 0.85, // <1 flattens the top of the arc, >1 peaks it + + /* --- the rock --- */ + radius: 0.8, // metres + facets: 3, // icosphere subdivisions, 0–3 (3 = 1280 triangles) + lumpiness: 0.26, // low-frequency deformation, × the radius + lumpScale: 1.5, // lumps per unit radius + surfaceRoughness: 0.16, // high-frequency chipping + cuts: 9, // planar fracture faces sliced off it + cutDepth: 0.28, // how far in those planes bite, × the radius + craters: 5, // impact bowls punched into it + craterDepth: 0.18, // how deep those bowls go, × the radius + craterSize: 0.5, // their angular radius, radians + spin: 3.4, // tumble rate, radians/second + + /* --- the lava seams --- */ + chargeCurve: 1.6, // how late the rock heats up on its way in + crackScale: 0.95, // seams per unit radius + crackWidth: 0.045, // how wide a seam opens (doubled at full charge) + crackBranches: 0.5, // strength of the finer seams splitting off + crackGlow: 2.2, + crackFlow: 0.7, // how much the magma brightness crawls + crackFlowSpeed: 0.9, + rockScale: 3.4, // mottling of the rock between the seams + facetTint: 0.5, // per-facet value break-up — what makes it read as stone + cavity: 0.25, // darkening down in the craters and the cut faces + soot: 0.6, // charring either side of a seam + rimHeat: 0.7, // heat sheath around the silhouette + leadGlow: 0.9, // compression heat on the leading facets + leadSharp: 2.6, // how tightly that hugs the nose + glow: 0.75, // overall emissive gain + envIntensity: 1.25, // how much of the HDR probe the rock catches + colorRock: '#6e675f', + colorChar: '#17130f', + colorCrack: '#ff6a12', + colorHot: '#fff3d0', + + /* --- the fire trail --- */ + /** + * The burning wake, **raymarched as a black-body volume** — the firebending + * stream from the freehand sandbox, re-aimed at the meteor's arc. The mesh + * drawn is only a camera-facing proxy hull; the flame itself is integrated + * inside it by `materials/VolumetricFireMaterial.js`, which is where the four + * layers these controls drive (silhouette → vortex roll-up → turbulence → + * shred) are explained. + * + * As with everything else here it is not a recorded history: the hull's + * centre line is sampled straight off the trajectory, so these reshape fire + * that is already in the air. + * + * The volume borrows the rock's palette — `colorHot`, `colorFlameMid`, + * `colorFlameEdge`, `colorFlameSmoke` — but only reaches for it in + * proportion to `trailPalette`; at 0 it is a pure Planckian radiator and the + * colour comes out of `trailTempCore` / `trailTempEdge` instead. + */ + trailSpan: 7.0, // metres of arc the fire covers behind the rock + trailWidth: 0.66, // tube radius, metres + trailHeadSize: 1.8, // fireball radius at the rock, × trailWidth + trailPlume: 1.1, // upward stretch of the volume (buoyant elongation) + trailWakeSpread: 0.22, // how far the spent gas behind the head has ballooned + trailRise: 0.35, // how far the far end of the wake has floated upward, metres + // Metre-scale lobes in the silhouette. Without these the outline stays a + // capsule no matter how much fine turbulence is piled on top of it, and the + // trail reads as a shaded tube. + trailBulge: 0.18, // how far those lobes swell and pinch the local radius + trailBulgeScale: 0.34, // lobes per metre — lower = bigger, slower shapes + // Ring vortices shed off the head and travelling back down the wake. This is + // what folds the field into curling, mushrooming billows; fbm alone can only + // make clouds. + trailVortex: 0.0, // roll-up strength + trailRingFrequency: 0.0, // vortices per metre of stream + trailRingSpeed: 4.0, // how fast they travel backwards + // Kept low on purpose: rolling the noise frame hard around the axis wraps + // the filaments circumferentially and the flame reads as concentric contour + // lines rather than as tongues running along the flow. + trailCurl: 0.0, // swirl of the density field around the axis + trailTurbulence: 2.94, // noise amplitude eating into the volume + trailWarp: 0.45, // domain warp — folds the noise into curling sheets + trailTongue: 0.94, // < 1 stretches structures upward into licking tongues + trailStreamStretch: 1.13, // < 1 draws them out along the flow + // Radial shear: how far the fringe is dragged up and back relative to the + // axis. This is what makes the edge structures read as licking tongues + // rather than as blobs of the same shape at every radius. + trailLick: 3.1, + trailWisps: 0.81, // ridged filaments shredding the fringe into strands + trailShred: 1.57, // how violently the fringe tears compared to the core + trailOctaves: 5, // turbulence octaves (quality ↔ cost) + trailSpeed: 4.62, // how fast the field streams backwards along the path + trailBuoyancy: 3.5, // how fast it climbs inside the volume + trailDetachment: 0.9, // how hard the tail tears into separate puffs + trailNoiseStrength: 0.78, + trailNoiseFrequency: 3.23, + trailSoftness: 0.42, // 0 = hard tongues, 1 = a soft glow + trailFlicker: 0.74, + trailDensity: 2.09, + trailSoot: 1.42, // absorption — how much the cool gas occludes + trailCoreClarity: 0.54, // extinction left in the hottest gas (low = white blob) + trailSteps: 35, // raymarch samples per pixel (quality ↔ cost) + trailGlow: 3.06, + trailOpacity: 0.96, + trailTailFade: 0.71, // fraction of the trail that has already burnt out + trailBurnout: 1.2, // seconds the trail takes to die after the impact + // Temperature & radiance. The flame is shaded as a Planckian radiator: these + // are the two ends of its temperature range in kelvin, and the exponent the + // emitted power follows. 4 would be Stefan-Boltzmann; a little gentler keeps + // the mid-tones off the floor at this exposure. + trailTempCore: 1920, + trailTempEdge: 1590, + trailEmissionCurve: 4.79, + trailHeatFocus: 1.54, // how fast the gas reaches full heat inside the surface + trailHeatFalloff: 2.46, // how sharply it cools toward that surface + // How far the turbulence is allowed to drag the temperature profile around. + // Radiated power goes as a high power of T, so this number is amplified + // several-fold on screen — past ~0.5 the noise's own contour lines start + // showing through as agate banding. + trailHeatFollow: 0.26, + trailTailHeat: 0.36, // temperature of the spent gas at the far end of the wake + trailPalette: 0.62, // 0 = pure black-body physics, 1 = the colour stops below + trailScatter: 1.99, // firelight bouncing inside the sooty fringe + trailScatterFalloff: 4.4, // how fast that bath dies away from the core + colorFlameMid: '#ffb02e', + colorFlameEdge: '#ff3d10', + colorFlameSmoke: '#181616', + + /* --- the debris the rock breaks into --- */ + chunkCount: 18, // chunks thrown at the impact (capped at 28) + chunkScale: 0.28, // their radius, × the meteor's + chunkSpeed: 7.5, // metres/second they leave the crater at + chunkForward: 0.55, // how far the spray is biased downrange + chunkLoft: 1.0, // how steeply they are thrown + chunkGravity: -17.0, + chunkSpin: 6.0, // tumble rate, radians/second + chunkCool: 2.6, // seconds a chunk's seams take to go out + chunkLinger: 0.5, // seconds they lie there before sinking + chunkSink: 1.0, // seconds to withdraw into the floor + + /* --- embers, sparks, smoke and grit --- */ + /** + * As in `ice` and `thunder`: each system is coloured by a four-stop gradient + * sampled over the particle's own lifetime, `A` at birth through `D` as it + * dies. Spelled out rather than derived from the flame palette, so the + * trail can be cooled to red while the rock itself stays white-hot. + */ + emberRate: 180, // embers streaming off the rock, particles/second + emberSize: 0.1, + emberSpeed: 2.4, + emberLifetime: 1.5, + emberRise: 1.5, // buoyancy, metres/second + emberGlow: 1.2, + emberTurbulence: 0.5, + colorEmberA: '#fff3d0', + colorEmberB: '#ff9a2e', + colorEmberC: '#ff3b0d', + colorEmberD: '#2b0d05', + sparkRate: 110, // sparks flung off it + sparkSize: 0.14, + sparkSpeed: 6.5, + sparkLifetime: 0.8, + sparkGravity: -11.0, + sparkStretch: 0.16, // how far a spark smears along its velocity + colorSparkA: '#fffdf2', + colorSparkB: '#ffd27a', + colorSparkC: '#ff6a12', + colorSparkD: '#3d1103', + smokeRate: 70, // the trail and the column off the crater + smokeSize: 1.1, + smokeSpeed: 1.2, + smokeLifetime: 3.0, + smokeOpacity: 0.12, + smokeRise: 0.9, + colorSmokeA: '#6b503f', + colorSmokeB: '#3b2c25', + colorSmokeC: '#241b17', + colorSmokeD: '#141010', + debrisSize: 0.06, // grit kicked off the floor + debrisSpeed: 6.0, + debrisLifetime: 1.5, + debrisGravity: -18.0, + colorDebrisA: '#3a322c', + colorDebrisB: '#2a231e', + colorDebrisC: '#1c1714', + colorDebrisD: '#151110', + + /* --- the molten cracks torn through the floor --- */ + /** + * Real geometry, not a decal: arms of crack that meander outward from the + * impact, shed branches, glow from a white-hot core through a wide orange + * underglow, and heave basalt up along their lips. See + * `effects/GroundFissures.js` — the network is baked in a unit disc, so + * `fissureRadius` re-scales cracks that are already on the ground. + */ + fissureRadius: 5.2, // how far the cracks reach, metres + fissureLife: 6.5, // seconds before they close up + fissureArms: 6, // main cracks radiating from the impact + fissureWander: 1.6, // how hard an arm veers, radians per unit walked + fissureBranches: 0.75, // fraction of the generated branches kept, 0..1 + fissureBranchLength: 0.85, // how far along a branch runs before its point, 0..1 + fissureWidth: 0.14, // width of the open seam, metres + fissureHeat: 1.5, // core temperature + fissurePulse: 1.0, // speed of the heat waves travelling along them + fissureGrowth: 9.0, // how fast the cracks race outward, metres/second + fissureRockSize: 0.3, // basalt heaved up along the lips, metres + + /* --- what else the ground does --- */ + scorchRadius: 2.8, // burnt patch under it, metres + scorchLife: 8.0, + scorchIntensity: 0.95, + shockRadius: 6.0, // impact shockwave ring, metres + colorScorch: '#0d0907', + colorShockA: '#ff9a2e', // body of the shockwave ring + colorShockB: '#fff3d0', // its crest + + /* --- dynamic light --- */ + lightIntensity: 16, + lightRadius: 14, + lightColor: '#ff8a3c', + lightFlicker: 0.25, // depth of the light's gutter, 0 = steady + lightFlickerSpeed: 13, + + /* --- the launch and the detonation --- */ + muzzleSize: 0.0, // the flare at the hand as the rock leaves it — 0 = none + muzzleIntensity: 1.6, + castFlash: 0.08, // screen flash on release + colorCastFlash: '#ff9a2e', + burstSize: 3.6, // the fireball at the impact point, metres + burstIntensity: 1.0, + burstTurbulence: 2.0, // how hard the noise eats into the fireball's shell + burstEmbers: 260, // extra embers thrown at the impact + burstSparks: 180, + burstDebris: 90, + burstSmoke: 70, + impactShake: 1.0, + shakeDuration: 1.1, + impactFlash: 0.3, + rumble: 0.04, // continuous shake while the rock is in the air + colorFlash: '#ff9a2e' // the full-screen flash on impact +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Cinder Fall. + * + * The seven controls under "The rock" regenerate real geometry — see + * `MeteorAbility#_syncGeometry` — and everything else is read by a shader or + * resolved from scratch on the frame it changes, so the whole folder reshapes + * a meteor that is already in the air. The ones worth reaching for first are + * `arc` (how hard it is lobbed), `crackWidth` and `chargeCurve` (how the lava + * seams open on the way in), `trailSpan` and `trailWidth` (how much fire + * streams off it) and `chunkSpeed` (how far the wreckage is thrown). + * + * The trail's four sub-folders are spelled with `/` in the folder name: a + * schema is a flat map, and the slash is how it declares nesting. + */ +export const meteorSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 3, 90, 0.5, 'travel speed'], + ['lifetime', 0.2, 10, 0.1, 'crater lifetime'], + ['fadeTime', 0.1, 6, 0.05, 'clear-out time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The flight path': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['endHeight', 0, 4, 0.01, 'height at target'], + ['arc', -4, 12, 0.05, 'lob height'], + ['arcCurve', 0.1, 4, 0.01, 'lob curve'] + ], + // Everything down to `craterSize` rebuilds the asteroid geometry. `cuts` is + // the one that decides whether it reads as stone: it slices flat fracture + // faces off the ball, which no amount of noise can fake. + 'The rock': [ + ['radius', 0.05, 3, 0.01, 'radius'], + ['facets', 0, 3, 1, 'subdivisions'], + ['lumpiness', 0, 0.8, 0.01, 'lumpiness'], + ['lumpScale', 0.2, 6, 0.05, 'lumps / radius'], + ['surfaceRoughness', 0, 1, 0.01, 'surface roughness'], + ['cuts', 0, 16, 1, 'fracture faces'], + ['cutDepth', 0, 0.5, 0.01, 'fracture depth'], + ['craters', 0, 14, 1, 'craters'], + ['craterDepth', 0, 0.6, 0.01, 'crater depth'], + ['craterSize', 0.05, 1.4, 0.01, 'crater size'], + ['spin', -20, 20, 0.1, 'tumble rate'] + ], + 'Lava seams': [ + ['chargeCurve', 0.1, 5, 0.01, 'heat-up curve'], + ['crackScale', 0.3, 10, 0.05, 'seams / radius'], + ['crackWidth', 0.005, 0.5, 0.005, 'seam width'], + ['crackBranches', 0, 1.5, 0.01, 'branch seams'], + ['crackGlow', 0, 10, 0.05, 'seam glow'], + ['crackFlow', 0, 1, 0.01, 'magma crawl'], + ['crackFlowSpeed', 0, 5, 0.01, 'crawl speed'], + ['rockScale', 0.2, 10, 0.05, 'rock mottling'], + ['facetTint', 0, 1.2, 0.01, 'per-facet tint'], + ['cavity', 0, 1, 0.01, 'cavity shading'], + ['soot', 0, 1.5, 0.01, 'soot around seams'], + ['rimHeat', 0, 4, 0.01, 'heat sheath'], + ['leadGlow', 0, 6, 0.01, 'leading-face heat'], + ['leadSharp', 0.5, 8, 0.05, 'leading-face falloff'], + ['glow', 0, 4, 0.01, 'glow'], + ['envIntensity', 0, 3, 0.01, 'reflection'], + ['colorRock', 'rock'], + ['colorChar', 'char'], + ['colorCrack', 'seam'], + ['colorHot', 'white hot'] + ], + // The trail is a raymarched volume, so these are volume parameters, not + // surface ones — see `materials/VolumetricFireMaterial.js`. `trailWidth`, + // `trailPlume` and `trailSpan` set its shape; `trailSteps` is the cost dial. + 'The fire trail': [ + ['trailSpan', 0.5, 30, 0.1, 'trail length'], + ['trailWidth', 0.02, 2, 0.01, 'tube radius'], + ['trailHeadSize', 0.5, 5, 0.01, 'head size'], + ['trailPlume', 0.3, 4, 0.01, 'upward stretch'], + ['trailWakeSpread', 0, 3, 0.01, 'wake spread'], + ['trailRise', 0, 3, 0.01, 'wake rise'], + ['trailDetachment', 0, 1.5, 0.01, 'tail break-up'], + ['trailSoftness', 0.05, 1, 0.01, 'surface softness'], + ['trailBurnout', 0.05, 4, 0.05, 'burn-out time'], + ['trailTailFade', 0.01, 0.8, 0.01, 'tail burn-out'] + ], + // Metre-scale lobes. Without these the outline stays a capsule no matter how + // much fine turbulence is piled on top of it. + 'The fire trail/Silhouette': [ + ['trailBulge', 0, 1, 0.01, 'lobe depth'], + ['trailBulgeScale', 0.05, 2, 0.01, 'lobes / metre'], + ['trailShred', 0, 4, 0.01, 'fringe shred'], + ['trailWisps', 0, 2, 0.01, 'wisps'], + ['trailLick', 0, 8, 0.05, 'radial shear'] + ], + 'The fire trail/Motion & turbulence': [ + ['trailSpeed', 0, 12, 0.01, 'flow speed'], + ['trailBuoyancy', 0, 10, 0.01, 'buoyancy'], + ['trailTurbulence', 0, 8, 0.01, 'turbulence'], + ['trailNoiseStrength', 0, 4, 0.01, 'noise strength'], + ['trailNoiseFrequency', 0.1, 10, 0.01, 'noise frequency'], + ['trailWarp', 0, 1.5, 0.01, 'domain warp'], + ['trailCurl', 0, 3, 0.01, 'axial swirl'], + ['trailVortex', 0, 2, 0.01, 'vortex roll-up'], + ['trailRingFrequency', 0, 3, 0.01, 'rings / metre'], + ['trailRingSpeed', 0, 10, 0.05, 'ring speed'], + ['trailTongue', 0.2, 3, 0.01, 'tongue stretch'], + ['trailStreamStretch', 0.2, 3, 0.01, 'streamwise stretch'], + ['trailFlicker', 0, 2, 0.01, 'flicker'], + ['trailOctaves', 1, 5, 1, 'detail octaves'] + ], + // The flame is shaded as a Planckian radiator: colour comes out of the + // temperature. `trailPalette` blends toward the hand-authored stops instead. + 'The fire trail/Temperature & radiance': [ + ['trailTempCore', 1000, 5000, 10, 'core temperature (K)'], + ['trailTempEdge', 1000, 4000, 10, 'edge temperature (K)'], + ['trailEmissionCurve', 1, 6, 0.01, 'radiance exponent'], + ['trailHeatFocus', 0.05, 3, 0.01, 'heat focus'], + ['trailHeatFalloff', 0.05, 4, 0.01, 'heat falloff'], + ['trailHeatFollow', 0, 1, 0.01, 'heat follows noise'], + ['trailTailHeat', 0, 1, 0.01, 'spent-gas heat'], + ['trailScatter', 0, 4, 0.01, 'scatter'], + ['trailScatterFalloff', 0.2, 8, 0.05, 'scatter falloff'], + ['trailPalette', 0, 1, 0.01, 'palette vs physics'], + ['colorFlameMid', 'flame mid'], + ['colorFlameEdge', 'flame edge'], + ['colorFlameSmoke', 'flame smoke'] + ], + 'The fire trail/Volume rendering': [ + ['trailDensity', 0, 6, 0.01, 'density'], + ['trailSoot', 0, 5, 0.01, 'soot absorption'], + ['trailCoreClarity', 0, 1, 0.01, 'core clarity'], + ['trailGlow', 0, 8, 0.01, 'glow'], + ['trailOpacity', 0, 2, 0.01, 'opacity'], + ['trailSteps', 6, 72, 1, 'raymarch steps'] + ], + 'The wreckage': [ + ['chunkCount', 0, 28, 1, 'chunks'], + ['chunkScale', 0.05, 0.8, 0.01, 'chunk size'], + ['chunkSpeed', 0, 30, 0.1, 'throw speed'], + ['chunkForward', 0, 2, 0.01, 'downrange bias'], + ['chunkLoft', 0, 1.5, 0.01, 'loft'], + ['chunkGravity', -50, -1, 0.1, 'gravity'], + ['chunkSpin', 0, 20, 0.1, 'tumble rate'], + ['chunkCool', 0.1, 8, 0.05, 'cool-down time'], + ['chunkLinger', 0, 4, 0.05, 'hold before sinking'], + ['chunkSink', 0.1, 4, 0.05, 'sink time'] + ], + 'Embers & sparks': [ + ['emberRate', 0, 900, 1, 'ember rate'], + ['emberSize', 0.005, 0.5, 0.005, 'ember size'], + ['emberSpeed', 0, 15, 0.05, 'ember speed'], + ['emberLifetime', 0.1, 8, 0.05, 'ember lifetime'], + ['emberRise', -3, 8, 0.05, 'ember rise'], + ['emberGlow', 0, 4, 0.01, 'ember glow'], + ['emberTurbulence', 0, 3, 0.01, 'ember turbulence'], + ['sparkRate', 0, 900, 1, 'spark rate'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['colorEmber*', 'Ember colour'], + ['colorSpark*', 'Spark colour'] + ], + 'Smoke & grit': [ + ['smokeRate', 0, 500, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 10, 0.05, 'smoke lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeRise', -2, 5, 0.01, 'smoke rise'], + ['debrisSize', 0.005, 0.4, 0.005, 'grit size'], + ['debrisSpeed', 0, 25, 0.1, 'grit speed'], + ['debrisLifetime', 0.1, 5, 0.05, 'grit lifetime'], + ['debrisGravity', -50, 0, 0.1, 'grit gravity'], + ['colorSmoke*', 'Smoke colour'], + ['colorDebris*', 'Grit colour'] + ], + 'Molten cracks': [ + ['fissureRadius', 0.5, 16, 0.05, 'reach'], + ['fissureLife', 0.5, 25, 0.1, 'lifetime'], + ['fissureArms', 2, 12, 1, 'main cracks'], + ['fissureWander', 0, 6, 0.05, 'meander'], + ['fissureBranches', 0, 1, 0.01, 'branch density'], + ['fissureBranchLength', 0, 1, 0.01, 'branch length'], + ['fissureWidth', 0.01, 1, 0.005, 'seam width'], + ['fissureHeat', 0, 4, 0.01, 'core heat'], + ['fissurePulse', 0, 5, 0.01, 'heat-wave speed'], + ['fissureGrowth', 0.5, 40, 0.1, 'spread speed'], + ['fissureRockSize', 0, 1.2, 0.01, 'lip rubble size'] + ], + 'The crater': [ + ['scorchRadius', 0.2, 12, 0.05, 'scorch radius'], + ['scorchLife', 0.5, 20, 0.1, 'scorch lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['colorScorch', 'scorch'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'Launch & detonation': [ + ['muzzleSize', 0, 6, 0.05, 'launch flare'], // 0 = no flare + ['muzzleIntensity', 0, 5, 0.01, 'launch intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 18, 0.05, 'fireball size'], + ['burstIntensity', 0, 5, 0.01, 'fireball intensity'], + ['burstTurbulence', 0, 4, 0.01, 'fireball turbulence'], + ['burstEmbers', 0, 800, 1, 'burst embers'], + ['burstSparks', 0, 600, 1, 'burst sparks'], + ['burstDebris', 0, 400, 1, 'burst grit'], + ['burstSmoke', 0, 300, 1, 'burst smoke'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorFlash', 'impact flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightFlicker', 0, 1, 0.01, 'light gutter'], + ['lightFlickerSpeed', 1, 60, 0.5, 'gutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/mirage.js b/src/config/abilities/mirage.js new file mode 100644 index 0000000..ddc966f --- /dev/null +++ b/src/config/abilities/mirage.js @@ -0,0 +1,190 @@ +/* ================================================================== */ +/* MIRAGE — the double made of refraction */ +/* ================================================================== */ +/** + * A duplicate of the caster runs the aimed line, drawn with **nothing but the + * refraction buffer**. + * + * There is no mesh on `LAYER.VFX` in this ability. No emissive, no particles, + * no decal, no shell. Ten `DistortionField(REFRACT)` hulls — a head, a torso, + * two upper arms, two forearms, two thighs, two shins — write a screen-space + * offset into the half-resolution distortion target, and the only reason you + * see anything at all is that the floor grid, the character and every particle + * behind those hulls bends around them. Take the distortion pass out and this + * cast is an empty frame. + * + * ### The two numbers that decide everything + * + * `bodyStrength` and `limbStrength` are in **screen widths at + * `post.distortion = 1`** — never metres. A fragment writing 1 shifts the frame + * behind it by a whole `post.distortion` of screen width whatever its distance, + * which is what makes an authored strength mean the same thing on a cast that + * lands three metres away and one that lands twenty-five. Neither master gain + * (`post.distortion`, `global.distortion`) is folded in here; the pass applies + * both, once. At the shipped 0.62 the torso displaces what is behind it by + * 0.62 × 0.045 ≈ **2.8% of screen width** — fifty pixels at 1080p, a bend you + * cannot miss and cannot mistake for a bug. The first pass shipped 1.6 and the + * figure tore the frame into two halves that did not meet at its silhouette. + * + * The limbs run *lower* than the body on purpose. A refracting solid bends + * hardest at its silhouette (the `refractPower` rim term), and a forearm is + * nearly all silhouette — at equal strength the arms read as the brightest + * thing in the figure and it comes apart into four sausages and a blob. + * + * ### Proportions are fractions of a height, and the height is measured + * + * Every dimension of the figure below is a **fraction of the caster's own + * height** rather than a metre, and the height itself is read off the rigged + * character in the scene every frame (`MirageAbility#_figureHeight`). + * `figureHeight` is only the fallback for a scene with no character in it — + * the headless harness, mostly. That is why the double is the caster's size + * without anybody typing 1.78 twice, and why swapping the FBX for a taller rig + * moves the mirage with it. + * + * The canon here is the rig's, not Vitruvius's: shoulders at 0.815 of height + * and hips at 0.53 are what the Mixamo skeleton measures, and a figure built + * on the textbook 0.5 hip line reads as a short-legged child at twenty metres. + * + * ### Why the gait is driven by distance and not by a clock + * + * `stride` is metres of ground per full gait cycle, and the phase is + * `2π × travelled / stride`. Drive the legs off a frequency instead and the + * feet skate the moment you drag `speed`, which is the single most obvious + * tell in any running animation. Drag `stride` while paused and the double + * re-poses on the spot. + */ +export const mirage = { + /* --- the cast --- */ + range: 26.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 7.4, // how fast the double runs, metres/second — a hard run + lifetime: 1.05, // seconds the halt beat lasts (the double stopping) + fadeTime: 0.55, // seconds the last of the disturbance takes to go + cooldown: 1.1, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the double --- */ + // No proportions here, and that is the point: the figure IS the caster's rig, + // cloned bone for bone, so its height, its build and the cut of its coat come + // from the FBX and can never drift out of step with the man casting it. + figureScale: 0.98, // × the caster. Deliberately not 1 — see `poseDelay`. + figureLead: 0.55, // metres in front of the caster the double peels off + figureSide: 0.0, // metres to the side (+ follows `Ability#side`) + + /* --- the pose track --- */ + // The double replays the caster's own animation from `poseDelay` seconds ago. + // This is what stops it reading as double-vision: at 0 the two figures move + // as one and the eye pairs them, and by about 0.2 s it is plainly a second + // person doing the same thing a moment later. + poseDelay: 0.28, // seconds behind the caster — HEADLINE + rate: 45, // pose samples per second written to the track + window: 2.5, // seconds of history kept; older samples fall off + + /* --- the halt: the beat where you lose it --- */ + haltTime: 0.72, // seconds the double takes to come to a standstill + haltCoast: 1.7, // metres it carries past the target while stopping + stopPower: 2.1, // exponent on speed → visibility; >1 loses it early + stillness: 0.0, // refraction that survives a dead stop — 0 is the ability + + /* --- the refraction --- */ + bodyStrength: 0.9, // the whole body, screen widths at post.distortion = 1 — HEADLINE + refractPower: 1.35, // rim exponent; 0 flattens every hull to a plain pane + refractRipple: 0.16, // break-up of the surface normal — heat off a body + refractRippleScale: 2.4, // ripple cycles per metre + refractRippleSpeed: 1.7, // metres/second the ripple crawls + refractOpacity: 0.95, // coverage — who wins where it overlaps another emitter + // 0.3, not 1. The point of this term is to stop the double warping something + // standing *in front of* it, and at 1 it does far more than that: the double + // stands on a floor that recedes immediately behind its own silhouette, so + // the depth test rejects most of the body against the ground it is walking + // on. Measured, that cost about five-sixths of the effect. A partial reject + // keeps the occlusion that matters and gives the figure back. + refractDepthReject: 0.3, // 0..1 how hard the floor and the real body occlude it + refractDepthFade: 0.7, // metres that occlusion feathers over + refractPerspective: 0.35, // 0 = a flat screen fraction, 1 = shrinks with range + refractPerspectiveRef: 10.0, // metres at which perspective = 1 + + /* --- feedback --- */ + // A mirage weighs nothing and lands on nothing, so there is no impact shake + // and no screen flash anywhere in this ability. The rumble is the only thing + // the camera does and it is set where you feel it and do not see it. + rumble: 0.006, // continuous shake while the double is running + + /* --- dynamic light --- */ + // Ships at zero, and that is the ability: a refracting solid has no radiance + // of its own. The three keys are here because `Ability#_updateLight` indexes + // them blind on every block. Dragging the intensity up is the one way to + // cheat this slot, and it is worth doing once to see what it costs. + lightIntensity: 0.0, + lightRadius: 6.0, + lightColor: '#cfe6ff' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Mirage. + * + * Reach for `bodyStrength` first, then `refractPower`. The second one is the + * unintuitive one: a solid of any real refractive index bends what is behind + * it hardest at its silhouette, and the exponent on that rim term is the + * difference between a person-shaped hole and a person-shaped pane of glass. + * Take it to 0 and the body becomes a uniform offset — the whole figure + * slides the scene sideways as one block and stops reading as a body. + * + * `stopPower` is the ability. Take it to 0.2 and the double is still plainly + * there when it has stopped, which turns the slot into "a ghost that walks"; + * at the shipped 2.1 you lose it about two-thirds of the way through the halt, + * while it is still moving, and the last thing you saw was already gone. + * + * There is no proportions folder any more and there never will be again: the + * figure is the caster's rig, so the only shape control it needs is + * `figureScale`. What replaced it is `poseDelay`, which is the difference + * between a second person and a rendering fault. + */ +export const mirageSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 1, 30, 0.1, 'run speed (m/s)'], + ['lifetime', 0.1, 4, 0.01, 'halt beat'], + ['fadeTime', 0.05, 3, 0.01, 'fade time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The double': [ + ['figureScale', 0.3, 2.5, 0.01, 'size × caster'], + ['figureLead', -1, 4, 0.01, 'start offset (m)'], + ['figureSide', -3, 3, 0.01, 'lateral offset (m)'] + ], + 'The double/Pose track': [ + ['poseDelay', 0, 2, 0.01, 'pose delay (s)'], + ['rate', 5, 120, 1, 'samples / second'], + ['window', 0.5, 6, 0.1, 'history kept (s)'] + ], + 'The halt': [ + ['haltTime', 0.05, 3, 0.01, 'halt time (s)'], + ['haltCoast', 0, 8, 0.05, 'coast past target (m)'], + ['stopPower', 0.1, 8, 0.05, 'loss-of-motion power'], + ['stillness', 0, 1, 0.01, 'refraction at a standstill'] + ], + Refraction: [ + ['bodyStrength', 0, 3, 0.01, 'body strength'], + ['refractPower', 0, 6, 0.01, 'rim exponent'], + ['refractRipple', 0, 1.5, 0.01, 'surface ripple'], + ['refractRippleScale', 0.05, 10, 0.01, 'ripple cycles/m'], + ['refractRippleSpeed', -8, 8, 0.01, 'ripple speed (m/s)'], + ['refractOpacity', 0, 1, 0.01, 'coverage'], + ['refractDepthReject', 0, 1, 0.01, 'occlusion'], + ['refractDepthFade', 0.01, 3, 0.01, 'occlusion feather (m)'], + ['refractPerspective', 0, 1, 0.01, 'shrink with distance'], + ['refractPerspectiveRef', 1, 40, 0.1, 'perspective ref (m)'] + ], + Feedback: [['rumble', 0, 0.2, 0.001, 'run rumble']], + 'Dynamic light': [ + ['lightIntensity', 0, 40, 0.5, 'light intensity'], + ['lightRadius', 0.5, 30, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/mycelium.js b/src/config/abilities/mycelium.js new file mode 100644 index 0000000..003ec7c --- /dev/null +++ b/src/config/abilities/mycelium.js @@ -0,0 +1,335 @@ +/* ================================================================== */ +/* MYCELIUM — verdant, line cast */ +/* ================================================================== */ +/** + * Mycelial Web — the one ability in the sandbox that is **under the floor**. + * + * A fungal mat runs out beneath the flagstones along the aimed line. Nothing + * draws the mat. What is on screen is light *escaping* — through the mortar + * courses, through the open pores of the stone, and nowhere else — so the + * pattern you actually see belongs to the floor, and the mycelium only decides + * how brightly each part of the floor is lit from below. + * + * The escape mask is built out of `world/Ground.js`'s **own** procedural + * fields, at its frequencies and phase offsets: the level set of its + * `fbm3(wp * 0.018)` macro variation is the mortar course, its + * `fbm3(wp * 0.06 + 3.0)` sheen patches seal the light off (polished stone is + * sound stone), and its `snoise01(wp * 0.7)` grain biases where the open pores + * sit. The contour distance is divided by that field's own gradient so + * `seamWidth` is a measurement in metres of a real mortar course rather than a + * dimensionless threshold whose width on the floor depends on where you stand. + * + * Three sliders carry the illusion and each of them is worth taking to zero + * once to see what it was doing: + * + * - **`webParallax`** — the buried web is sampled offset by + * `depth * viewXZ / viewY`, so it slides against the cracks it is seen + * through as the camera orbits. At zero the cast is a sticker. + * - **`webSpread`** — metres of extra blur per metre of burial. At zero every + * strand is equally sharp and the mat reads as painted on the underside of + * a pane of glass rather than as something inside stone. + * - **`polishSeal`** — how completely the floor's own polished patches close. + * At zero the glow ignores the stone entirely and you are looking at the + * network, which is the failure the whole file exists to avoid. + * + * The pieces are one bespoke seep quad (`materials/MyceliumSeepMaterial.js`) + * and one `GroundField(WET)` for the damp patch at the far end — two draw + * calls, plus two shared particle systems. + */ +export const mycelium = { + /* --- the cast --- */ + range: 17.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 13.0, // how fast the growth front creeps, metres/second + cooldown: 1.5, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats, all seconds --- */ + creepTime: 2.2, // the mat creeping on past the target after the front lands + holdTime: 5.0, // it lying there, pulsing + rotTime: 3.2, // it dying back and going out + + /* --- the lane the mat spreads in --- */ + laneHalfWidth: 2.1, // metres — half-width of the band at the caster + laneTaper: 0.22, // 0..1 how much narrower the far end is + laneBack: 0.9, // metres the mat reaches back behind the caster + laneEdge: 0.55, // metres of feather on every boundary + laneRagged: 0.85, // metres the boundaries wander by + laneRaggedScale: 0.42, // lobes per metre + laneWarp: 0.9, // metres of domain warp on those lobes + creepReach: 0.22, // extra length the mat takes after landing, × the cast + dieBack: 1.0, // 0..1 how much of the mat the rot reclaims + seepHeight: 0.012, // metres above the floor the seep quad sits at + + /* --- the buried web --- */ + webCell: 0.44, // metres between hyphae + webStretch: 2.3, // >1 draws the strands out along the lane + webJitter: 0.92, // 0..1 lattice disorder — 0 is a grid, and looks like one + webCore: 0.014, // metres — half-width of a hypha lying on the surface + webSpread: 0.6, // extra metres of half-width per metre of burial depth + webDepthMin: 0.02, // metres — the shallowest a strand runs + webDepthMax: 0.24, // metres — the deepest + webDepthPitch: 0.32, // cycles per metre in the burial-depth field + webAbsorb: 4.2, // 1/metre — Beer-Lambert extinction through the flags + webParallax: 1.0, // 0..1 of the true depth offset. Zero flattens the cast + webBranch: 0.55, // 0..1 weight of the finer secondary web + webBranchScale: 2.8, // >1 how much finer that web is + + /* --- the nutrient pulses running the web --- */ + pulseSpacing: 3.4, // metres between crests + pulseSpeed: 2.2, // metres/second they travel downrange at + pulseSharp: 7.0, // crest exponent — low is a swell, high is a bead + pulseGain: 0.85, // how much brighter a crest is + + /* --- the escape mask: the floor's own detail --- */ + stoneStep: 0.35, // metres between the macro-gradient taps + seamLevel: 0.05, // which contour of the floor's macro field is a mortar course + seamWidth: 0.075, // metres — the course itself, a real measurement + seamWeight: 1.0, // 0..1 how much light a seam passes + seamRelief: 0.55, // 0..1 how much a seam behaves like a slot you look into + seamGlow: 0.4, // extra light on the lip of a lit seam + porePitch: 9.0, // cycles per metre — re-synthesised; see the note below + poreCut: 0.68, // 0..1 how sparse the open pores are + poreWeight: 0.45, // 0..1 how much light a pore passes + polishSeal: 0.85, // 0..1 how completely the floor's sheen patches close + stoneBleed: 0.06, // 0..1 floor on the mask — thin flags are never opaque + + /* --- the seep quad's output --- */ + seepEmissive: 1.5, // multiplier on every glowing term + seepOpacity: 1.0, + seepDepthFade: 0.55, // metres of soft fade against standing geometry + colorSeepDeep: '#14403c', // a strand buried at webDepthMax + colorSeepShallow: '#6cf0b0', // one just under the surface + colorSeepPulse: '#c8ffb0', // a nutrient surge + colorSeepSeam: '#2a6b60', // the lip of a crack catching the light + + /* --- the damp patch at the far end --- */ + dampRadius: 3.1, // metres + dampHeight: 0.016, // metres above the floor the quad sits at + dampEdge: 0.6, // metres of feather on the spreading front + dampRagged: 0.34, // how far that front wanders, as a fraction of the radius + dampRaggedScale: 0.5, // lobes per metre + dampWarp: 0.7, // metres of domain warp on those lobes + dampRelief: 0.3, // how hard the height field tilts the fake normal + dampAmbient: 0.36, + dampSpecular: 0.7, // wet stone is shiny stone + dampGloss: 40, + dampCell: 0.85, // metres — the puddle scale + dampLift: 0.03, // metres — ripple height + dampDepth: 0.1, // metres — how deep the soak reads + dampFlow: 0.1, // metres/second the wet drifts at + dampDetail: 0.6, + dampSpeed: 0.35, // ripple events per second + dampWindAngle: 0.4, // radians, in the quad's frame + dampEmissive: 0.5, // multiplier on the glowing terms — kept low; this is water + dampOpacity: 0.8, + dampDepthFade: 0.6, // metres of soft fade against standing geometry + colorDampBase: '#2c3630', // the soaked stone + colorDampEdge: '#8fb0a4', // the sheen on it + colorDampGlow: '#5fd0a0', // the tide mark left as it dries + colorDampDeep: '#0a120e', // the darkest of the soak + + /* --- the spores leaking out of the seams --- */ + sporeRate: 26.0, // per second along the lane + sporeBirthHeight: 0.03, // metres — they come out of the floor, not above it + sporeRise: 0.55, // metres/second + sporeDrag: 1.4, // 1/second + sporeSag: -0.05, // metres/second² — they barely fall + sporeSize: 0.045, + sporeLifetime: 3.4, // seconds + sporeSpread: 0.45, // launch cone + sporeSpeedVariance: 0.6, + sporeTurbulence: 0.5, + sporeGlow: 2.2, + puffRate: 1.4, // puffs per metre of front travel + puffSpores: 7, // spores in one puff + colorSporeA: '#d8ffd0', // birth + colorSporeB: '#8fe8b0', // early + colorSporeC: '#3f8f78', // late + colorSporeD: '#12241f', // death + + /* --- the vapour off the damp stone --- */ + vapourRate: 12.0, // per second + vapourBirthHeight: 0.05, // metres + vapourSize: 0.7, + vapourSpeed: 0.5, // metres/second + vapourLifetime: 3.6, // seconds + vapourRise: 0.22, // metres/second² + vapourOpacity: 0.2, + colorVapourA: '#8fa8a0', // birth + colorVapourB: '#5f7a72', // early + colorVapourC: '#37453f', // late + colorVapourD: '#121815', // death + + /* --- the hand, and the two one-shots --- */ + handHeight: 1.15, // metres + handForward: 0.55, // metres down the heading + handSide: 0.3, // metres lateral + muzzleSize: 0.65, // the shell at the hand + muzzleIntensity: 0.9, + castFlash: 0.05, // screen flash on release + colorCastFlash: '#4f9e86', + burstSize: 2.4, // the shell as the mat knots up + burstIntensity: 0.8, + burstSpores: 90, // spores thrown at the knot + impactShake: 0.14, + shakeDuration: 0.9, // seconds + impactFlash: 0.05, + rumble: 0.02, // travel rumble + colorBurstA: '#7fd8a8', + colorBurstB: '#2f6b58', + colorBurstC: '#c8ffb0', + colorFlash: '#5fb894', + + /* --- the dynamic light --- */ + lightIntensity: 9.0, + lightRadius: 8.0, // metres + lightHeight: 0.14, // metres — low, because the light is coming out of the floor + lightBreathe: 0.45, // 0..1 depth of the breathe + lightBreatheRate: 0.4, // hertz + lightColor: '#4f9e86' +}; + +/** Editor layout. */ +export const myceliumSchema = { + 'The cast': ['range', 'minRange', 'speed', 'cooldown', 'castAnim'], + 'The beats': [ + ['creepTime', 0.1, 8, 0.05, 'creep (s)'], + ['holdTime', 0.2, 16, 0.1, 'hold (s)'], + ['rotTime', 0.2, 12, 0.1, 'rot (s)'] + ], + 'The lane': [ + ['laneHalfWidth', 0.2, 8, 0.05, 'half-width (m)'], + ['laneTaper', 0, 1, 0.01, 'far-end taper'], + ['laneBack', 0, 4, 0.05, 'reach behind caster (m)'], + ['laneEdge', 0.02, 3, 0.01, 'front feather (m)'], + ['laneRagged', 0, 4, 0.01, 'boundary wander (m)'], + ['laneRaggedScale', 0.05, 3, 0.01, 'lobes / metre'], + ['laneWarp', 0, 4, 0.01, 'domain warp (m)'], + ['creepReach', 0, 1, 0.01, 'creep past target (× cast)'], + ['dieBack', 0, 1, 0.01, 'die-back'], + ['seepHeight', 0, 0.2, 0.002, 'hover height (m)'] + ], + 'The buried web': [ + ['webCell', 0.05, 3, 0.01, 'hypha spacing (m)'], + ['webStretch', 0.2, 8, 0.05, 'stretch along lane'], + ['webJitter', 0, 1, 0.01, 'lattice disorder'], + ['webCore', 0.002, 0.2, 0.001, 'strand half-width (m)'], + ['webSpread', 0, 3, 0.01, 'blur per metre of depth'], + ['webDepthMin', 0, 1, 0.005, 'shallowest (m)'], + ['webDepthMax', 0.01, 2, 0.005, 'deepest (m)'], + ['webDepthPitch', 0.02, 2, 0.01, 'depth variation (cycles/m)'], + ['webAbsorb', 0, 15, 0.05, 'stone absorption (1/m)'], + ['webParallax', 0, 2, 0.01, 'depth parallax'], + ['webBranch', 0, 1, 0.01, 'secondary web'], + ['webBranchScale', 1, 8, 0.05, 'secondary fineness'] + ], + 'The pulses': [ + ['pulseSpacing', 0.2, 12, 0.05, 'crest spacing (m)'], + ['pulseSpeed', -8, 8, 0.05, 'crest speed (m/s)'], + ['pulseSharp', 1, 24, 0.5, 'crest sharpness'], + ['pulseGain', 0, 3, 0.01, 'crest gain'] + ], + 'The stone (the escape mask)': [ + ['stoneStep', 0.05, 2, 0.01, 'gradient tap (m)'], + ['seamLevel', -0.6, 0.6, 0.005, 'mortar contour'], + ['seamWidth', 0.005, 0.5, 0.005, 'mortar width (m)'], + ['seamWeight', 0, 1, 0.01, 'seam transmission'], + ['seamRelief', 0, 1, 0.01, 'slot occlusion'], + ['seamGlow', 0, 2, 0.01, 'seam lip glow'], + ['porePitch', 0.5, 30, 0.1, 'pore pitch (cycles/m)'], + ['poreCut', 0, 1, 0.01, 'pore sparsity'], + ['poreWeight', 0, 1, 0.01, 'pore transmission'], + ['polishSeal', 0, 1, 0.01, 'polish seals'], + ['stoneBleed', 0, 0.5, 0.005, 'bleed through sound stone'] + ], + 'The seep': [ + ['seepEmissive', 0, 5, 0.01, 'emissive'], + ['seepOpacity', 0, 1, 0.01, 'opacity'], + ['seepDepthFade', 0.02, 3, 0.01, 'soft intersection (m)'], + ['colorSeepDeep', 'deep strand'], + ['colorSeepShallow', 'shallow strand'], + ['colorSeepPulse', 'nutrient pulse'], + ['colorSeepSeam', 'seam lip'] + ], + 'The damp': [ + ['dampRadius', 0.2, 10, 0.05, 'radius (m)'], + ['dampHeight', 0, 0.2, 0.002, 'hover height (m)'], + ['dampEdge', 0.02, 3, 0.01, 'front feather (m)'], + ['dampRagged', 0, 1, 0.01, 'front raggedness'], + ['dampRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['dampWarp', 0, 3, 0.01, 'domain warp (m)'], + ['dampRelief', 0, 2, 0.01, 'relief'], + ['dampAmbient', 0, 1, 0.01, 'ambient'], + ['dampSpecular', 0, 2, 0.01, 'specular'], + ['dampGloss', 2, 128, 1, 'gloss'], + ['dampCell', 0.05, 4, 0.01, 'puddle scale (m)'], + ['dampLift', 0, 0.5, 0.005, 'ripple height (m)'], + ['dampDepth', 0, 1, 0.005, 'soak depth (m)'], + ['dampFlow', 0, 2, 0.01, 'drift (m/s)'], + ['dampDetail', 0, 1, 0.01, 'detail'], + ['dampSpeed', 0, 4, 0.01, 'ripple rate'], + ['dampWindAngle', -3.15, 3.15, 0.01, 'drift bearing'], + ['dampEmissive', 0, 3, 0.01, 'emissive'], + ['dampOpacity', 0, 1, 0.01, 'opacity'], + ['dampDepthFade', 0.02, 3, 0.01, 'soft intersection (m)'], + ['colorDampBase', 'soaked stone'], + ['colorDampEdge', 'sheen'], + ['colorDampGlow', 'tide mark'], + ['colorDampDeep', 'deepest soak'] + ], + 'The spores': [ + ['sporeRate', 0, 200, 1, 'rate'], + ['sporeBirthHeight', 0, 1, 0.005, 'birth height (m)'], + ['sporeRise', 0.02, 4, 0.01, 'rise (m/s)'], + ['sporeDrag', 0.05, 4, 0.01, 'drag (1/s)'], + ['sporeSag', -2, 1, 0.01, 'sag (m/s2)'], + ['sporeSize', 0.005, 0.3, 0.005, 'size'], + ['sporeLifetime', 0.2, 10, 0.05, 'lifetime (s)'], + ['sporeSpread', 0, 1, 0.01, 'launch cone'], + ['sporeSpeedVariance', 0, 1, 0.01, 'speed variance'], + ['sporeTurbulence', 0, 3, 0.01, 'turbulence'], + ['sporeGlow', 0, 6, 0.01, 'glow'], + ['puffRate', 0.05, 6, 0.05, 'puffs / metre'], + ['puffSpores', 0, 60, 1, 'spores / puff'], + ['colorSpore*', 'Spore colour'] + ], + 'The vapour': [ + ['vapourRate', 0, 150, 1, 'rate'], + ['vapourBirthHeight', 0, 1, 0.005, 'birth height (m)'], + ['vapourSize', 0.05, 4, 0.01, 'size'], + ['vapourSpeed', 0, 4, 0.01, 'speed (m/s)'], + ['vapourLifetime', 0.2, 12, 0.05, 'lifetime (s)'], + ['vapourRise', -1, 2, 0.01, 'rise (m/s2)'], + ['vapourOpacity', 0, 0.6, 0.005, 'opacity'], + ['colorVapour*', 'Vapour colour'] + ], + 'Hand & knot': [ + ['handHeight', 0, 3, 0.01, 'hand height (m)'], + ['handForward', -1, 3, 0.01, 'hand forward (m)'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral (m)'], + ['muzzleSize', 0.05, 4, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 4, 0.01, 'muzzle intensity'], + ['castFlash', 0, 1, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 10, 0.05, 'knot shell'], + ['burstIntensity', 0, 4, 0.01, 'shell intensity'], + ['burstSpores', 0, 400, 1, 'spores at the knot'], + ['impactShake', 0, 2, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration (s)'], + ['impactFlash', 0, 1, 0.01, 'screen flash'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['colorBurstA', 'shell'], + ['colorBurstB', 'shell body'], + ['colorBurstC', 'shell motes'], + ['colorFlash', 'knot flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius (m)'], + ['lightHeight', 0, 3, 0.01, 'light height (m)'], + ['lightBreathe', 0, 1, 0.01, 'breathe depth'], + ['lightBreatheRate', 0.05, 4, 0.01, 'breathe rate (Hz)'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/nightfall.js b/src/config/abilities/nightfall.js new file mode 100644 index 0000000..9fbab6f --- /dev/null +++ b/src/config/abilities/nightfall.js @@ -0,0 +1,346 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* NIGHTFALL — the dome that takes light away */ +/* ================================================================== */ +/** + * A far cast whose entire job is **subtraction**. A dome of `Medium.VOID` + * closes over the footprint, the floor under it goes to near-black, and the + * only things left inside are a slow starfield and the caster's own dynamic + * light losing an argument with it. + * + * ### Why it genuinely darkens, and what had to be true for that + * + * `VolumeHull` draws non-additive media with premultiplied "over" + * (`ONE, ONE_MINUS_SRC_ALPHA`), and premultiplied over with a near-black + * premultiplied colour **is** subtraction: the destination is multiplied by + * `1 - alpha` and almost nothing is added back. That is why the palette below + * runs `#0a0a18 → #000000` and why `nightScatter` is kept far under + * `nightAbsorption` — a medium whose scatter approaches its absorption gives + * light *back*, and the one thing this ability may not do is give light back. + * + * Three separate things had to be checked before it survived the frame: + * + * 1. **Tone mapping.** The dome draws into the linear HDR buffer, and ACES + * runs later, in `OutputPass`. ACES is monotonic, so multiplying the input + * down always maps down — but it is *compressive at the top*, so taking a + * bloomed highlight to a fifth of itself is worth far less than a fifth on + * screen. The answer is not more opacity, it is `nightVoidBite`, which + * lifts the final alpha independently of the density so the dome can reach + * genuine black without being made physically thick (thick kills the + * transmittance in the first half-metre and the starfield behind it never + * integrates). + * 2. **Bloom.** `UnrealBloomPass` runs *before* tone mapping and *after* the + * dome, so it re-adds energy over the darkened region from anything bright + * just outside it. Nothing in this block can stop that; what it can do is + * not make the problem — hence no burst shell, no additive decal and no + * screen flash that adds. `castDim` is the opposite: `GradeShader` mixes + * the frame *toward* the flash colour, so a black flash colour darkens. + * 3. **Draw order.** Additive VFX drawn after the dome are not darkened by it. + * The `night.veil` particles are therefore non-additive on purpose; they + * are the only particle system in the project whose job is to occlude. + * + * ### The lid, and the uniform that is really an aperture + * + * The beat is "closes over the zone from the rim inward", and `HullShape.DOME` + * has no aperture parameter — `Hollow` and `Throat` are cone-only. Rather than + * pay a second raymarch for a second hull, the lid is `heightBias` driven past + * its nominal 0..1: the silhouette term is `(1 - r) · (1 - bias·yn)`, which + * goes **negative** above `yn = 1/bias`, and negative silhouette is zero + * density. So the medium fills only the part of the dome below that latitude, + * and the circle where it meets the dome's surface has radius + * `R·sqrt(1 - (1/bias)²)`. Walking that circle from the boundary to the apex + * closes the hole from the rim inward, in one uniform, for nothing. The beat + * authors the *circle* (`lidOpenRim`) and the ability inverts for the uniform, + * because the relation is violently non-linear near `bias = 1` — see + * `NightfallAbility#_lidBias`. + * + * It is soft, too, and that is free: erosion scales with `clamp(1 - shape, 0, 2)` + * so it is at its most violent exactly where the silhouette crosses zero, which + * is the lip of the aperture. The lid closes with a ragged edge without a line + * of code asking for one. + */ +export const nightfall = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + speed: 38.0, // how fast the front runs out to the footprint, metres/second + zoneRadius: 6.5, // the footprint the circle indicator draws, metres + cooldown: 2.2, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the four beats --- */ + // impactDuration = (closeTime + holdTime) × global.lifetime. + // fadeDuration = openTime + settleTime. + formTime: 0.16, // seconds the medium takes to reach full density at all + closeTime: 0.9, // seconds the lid takes to seal, rim → apex + holdTime: 2.4, // seconds it stands sealed + openTime: 1.1, // seconds the lid takes to reopen, apex → rim + settleTime: 0.8, // seconds the last of the medium takes to thin out + + /* --- the dome --- */ + domeSpread: 1.04, // hull radius, × zoneRadius + domeHeight: 0.62, // hull height, × zoneRadius + // The lid is authored as the **rim of the hole**, not as the uniform that + // makes it. `heightBias` and the aperture radius are related by + // `rho = sqrt(1 - 1/bias²)`, which is violently non-linear near bias = 1: + // driving the uniform from a linear beat leaves the rim loitering at 0.9 R + // for two thirds of the close and then snapping shut. Authoring `rho` and + // inverting for the uniform makes the closing circle travel evenly, which is + // the only version that reads as a lid rather than as a glitch. + lidOpenRim: 0.985, // aperture rim at full open, × the dome radius + lidRimShare: 0.78, // share of the close spent walking that rim to the apex + lidCurve: 1.15, // exponent on the walk; > 1 starts slow and finishes fast + lidSeal: 0.05, // heightBias once sealed — below ~0.1 the apex is solid + + /* --- the void medium (Medium.VOID, prefix `night`) --- */ + // Steps × covered pixels is the cost, and a dome this wide covers a lot of + // frame: 30 steps with zero shadow taps is the budget. VOID needs no taps — + // there is nothing in it to light. + ...volumeHullDefaults('night', Medium.VOID, { + nightSteps: 30, // march steps — the cost knob + nightJitter: 1.0, // step dither; 0 only ever shows you the banding + nightContact: 1.4, // metres of fade where the dark meets the floor + nightMargin: 0.14, // headroom inside the hull before erosion escapes it + nightRound: 1.0, // a dome is round; Chebyshev would square its footprint + nightHeightBias: 0.05, // the *base*; the lid is added on top (see the note) + nightFeather: 0.25, // unused by DOME, kept so the audit stays quiet + nightDensity: 2.1, // density + nightDensityCurve: 0.7, // < 1 lifts the thin fringe — the dark has no edge + nightSoftness: 0.6, // edge softness + nightNoiseFrequency: 0.5, // features per metre — very large, very slow + nightNoiseStrength: 0.55, // erosion, and the raggedness of the closing lip + nightNoiseWarp: 0.3, // domain warp + nightOctaves: 3, // octaves + nightDetail: 0.4, // fine-octave gain + nightFlowY: 0.05, // world flow Y, metres/second + nightRise: 0.08, // buoyant rise, metres/second — night does not billow + nightSwirl: 0.045, // swirl about the vertical, radians/second + nightAbsorption: 1.9, // absorption, 1/metre. See VoidBite for the blacking. + nightScatter: 0.18, // scattering — deliberately tiny: it must not give back + nightAmbient: 0.04, // multi-scatter floor + nightAnisotropy: 0.0, // no forward lobe; there is nothing to forward-scatter + nightEmission: 1.0, // emission (the stars are carried on this) + nightEmissionCurve: 0.35, // emission by density + nightShadowTaps: 0, // nothing in here needs a lit side + nightOpacity: 1.0, // opacity + nightVoidBite: 1.35, // extra occlusion on the final alpha — the blacking-out + nightSpeckDensity: 0.075, // fraction of lattice cells holding a star + nightSpeckScale: 1.6, // star cells per metre + nightSpeckSize: 0.12, // star size within its cell + nightSpeckGlow: 16.0, // star brightness + nightColorCore: '#0a0a18', // the interior + nightColorMid: '#05050e', + nightColorEdge: '#020205', + nightColorDeep: '#000000', // the deepest part of it + nightColorLight: '#1a1a30', // what little key light it admits + nightColorSpeck: '#c0d0ff' // the stars + }), + + /* --- the floor under it --- */ + // `GroundMode.WET` alpha-blended, which is the one mode in the library built + // to come out *darker* than the stone it lies on. Its beats read backwards + // from the usual: `grow` snaps the footprint out with the cast, the darkness + // then **deepens in place** rather than spreading (the first build grew it + // outward from the centre while the lid closed inward, and two fronts running + // opposite ways read as two effects), and `recede` dries it back from the + // edges as the lid reopens. + floorSpread: 1.02, // mark radius, × zoneRadius + floorGrow: 0.35, // seconds the footprint takes to snap out + floorHeight: 0.018, // metres above the floor the quad sits at + floorShallow: 0.25, // how dark the floor is the instant it lands, 0..1 + floorOpacity: 0.96, // ... and at full seal + floorEmissive: 0.5, // multiplier on the sheen + floorEdge: 0.55, // metres of feather on the front + floorRagged: 0.22, // how far the front wanders, × radius + floorRaggedScale: 0.45, // lobes per metre + floorWarp: 0.7, // metres of domain warp on those lobes + floorRelief: 0.35, // how hard the height field tilts the fake normal + floorNormalStep: 0.07, // metres between the height taps + floorAmbient: 0.06, // floor on the diffuse term — almost none, by design + floorWrap: 0.3, // wraps the terminator round the back, 0..1 + floorSpecular: 0.42, // the only thing that says "stone", not "hole" + floorGloss: 40, // Blinn exponent + floorParallax: 0.2, // metres of view-driven offset on interior detail + floorCell: 0.5, // puddle scale, metres + floorDepth: 0.2, // puddle depth, metres + floorLift: 0.06, // ripple height, metres + floorDetail: 0.5, // ripple gain, 0..1 + floorSpeed: 0.35, // ripple rate + floorFlow: 0.1, // drift, metres/second + floorWind: 1.1, // drift bearing, radians in the quad's frame + floorDepthFade: 0.6, // metres of soft fade against standing geometry + colorFloor: '#05050b', // the soaked stone itself + colorFloorEdge: '#3a4a86', // its sheen — the only light it returns + colorFloorGlow: '#8fa0e0', // the tide mark left as it dries back + colorFloorDeep: '#000000', // the wettest part of it + + /* --- the veil that eats the silhouette --- */ + // Non-additive: this is the only particle system in the project whose job is + // to *occlude*. Additive smoke around a subtractive dome would light its own + // outline, which is the exact opposite of the read. + veilRate: 55, // particles/second + veilSize: 1.6, + veilSpeed: 0.5, // metres/second + veilLifetime: 3.2, // seconds + veilRise: 0.25, // upward drift, metres/second + veilOpacity: 0.5, + veilTurbulence: 0.35, + veilSeat: 0.9, // where it boils off the dome, × zoneRadius + colorVeilA: '#0b0b16', // birth + colorVeilB: '#07070f', // early + colorVeilC: '#040409', // late + colorVeilD: '#000000', // death + + /* --- the stars that lift inside it --- */ + starRate: 26, // particles/second + starSize: 0.05, + starSpeed: 0.35, // metres/second + starLifetime: 3.6, // seconds + starRise: 0.32, // upward drift, metres/second + starTurbulence: 0.18, + starSeat: 0.72, // how far out they are born, × zoneRadius + colorStarA: '#ffffff', // birth + colorStarB: '#c0d0ff', // early + colorStarC: '#5a6bb0', // late + colorStarD: '#080a18', // death + + /* --- the moment it seals --- */ + // `castDim` runs through `ScreenFlash`, and `GradeShader` *mixes* the frame + // toward the flash colour rather than adding it — so a black colour is a + // negative flash. Nothing else in the project uses it that way. + castDim: 0.34, // strength of the negative flash, 0..1 + colorFall: '#02020a', // what the frame is mixed toward + sealShake: 0.35, // camera shake as the lid meets at the apex + shakeDuration: 1.1, // seconds + rumble: 0.02, // continuous shake while the lid is closing + holdRumble: 0.008, // ... and while it stands + + /* --- the caster's light, losing --- */ + // The one thing still lit inside. It is deliberately weak and it is + // deliberately *smothered*: `lightSmother` scales the base intensity down as + // the seal completes, so the light visibly gives ground rather than shining + // through a dome that is supposed to be swallowing it. + lightIntensity: 15, + lightRadius: 7.5, + lightColor: '#9fb4ff', + lightHeight: 1.1, // metres above the floor the light sits at + lightSmother: 0.68, // 0..1 how much of it the sealed dome takes + lightStruggle: 5.4 // radians/second of the flutter it fights with +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Nightfall. + * + * Reach for `lidOpenRim` / `lidRimShare` first: they are the aperture, and + * dragging them with the clock paused walks the closing circle up and down the + * dome by hand. After that, `nightVoidBite` (how black it is allowed to get, + * which is *not* the same control as `nightDensity`) and `lightSmother` (how + * badly the caster's own light loses). + * + * The hull's `boil` folder is omitted — those seven keys belong to `GAS_BOIL` + * and do nothing to a VOID medium. `volumeHullDefaults` still emits them so the + * hull's own audit stays quiet, so they land in "More", where they belong. + */ +export const nightfallSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'front speed'], + ['zoneRadius', 1, 16, 0.1, 'footprint radius'], + ['cooldown', 0, 10, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['formTime', 0.02, 2, 0.01, 'form time'], + ['closeTime', 0.05, 4, 0.01, 'lid closes'], + ['holdTime', 0.1, 10, 0.05, 'sealed hold'], + ['openTime', 0.05, 4, 0.01, 'lid opens'], + ['settleTime', 0.05, 4, 0.01, 'settle out'] + ], + 'The dome': [ + ['domeSpread', 0.3, 2, 0.01, 'radius × zoneRadius'], + ['domeHeight', 0.1, 2, 0.01, 'height × zoneRadius'], + ['lidOpenRim', 0.2, 0.999, 0.001, 'aperture rim, open'], + ['lidRimShare', 0.05, 0.99, 0.01, 'share spent walking it in'], + ['lidCurve', 0.3, 4, 0.01, 'walk curve'], + ['lidSeal', 0, 1.5, 0.01, 'bias once sealed'] + ], + ...volumeHullSchema('night', { + label: 'Night', + only: ['march', 'shape', 'field', 'flow', 'optics', 'speck', 'void', 'colour'] + }), + 'The floor': [ + ['floorSpread', 0.2, 2, 0.01, 'radius × zoneRadius'], + ['floorGrow', 0.02, 3, 0.01, 'footprint snap (s)'], + ['floorHeight', 0, 0.3, 0.002, 'height above floor (m)'], + ['floorShallow', 0, 1, 0.01, 'darkness on arrival'], + ['floorOpacity', 0, 1, 0.01, 'darkness at full seal'], + ['floorEmissive', 0, 3, 0.01, 'sheen'], + ['floorEdge', 0.02, 3, 0.01, 'front feather (m)'], + ['floorRagged', 0, 1, 0.01, 'front wander'], + ['floorRaggedScale', 0.05, 3, 0.01, 'lobes / metre'], + ['floorWarp', 0, 3, 0.01, 'domain warp (m)'], + ['floorDepthFade', 0, 3, 0.01, 'depth feather (m)'], + ['colorFloor', 'soaked stone'], + ['colorFloorEdge', 'sheen'], + ['colorFloorGlow', 'tide mark'], + ['colorFloorDeep', 'the deepest of it'] + ], + 'The floor/Relief': [ + ['floorRelief', 0, 3, 0.01, 'relief'], + ['floorNormalStep', 0.01, 0.4, 0.005, 'normal step (m)'], + ['floorAmbient', 0, 1, 0.01, 'ambient floor'], + ['floorWrap', 0, 1, 0.01, 'terminator wrap'], + ['floorSpecular', 0, 3, 0.01, 'specular'], + ['floorGloss', 1, 128, 1, 'gloss'], + ['floorParallax', 0, 2, 0.01, 'parallax (m)'], + ['floorCell', 0.05, 4, 0.01, 'puddle scale (m)'], + ['floorDepth', 0, 1.5, 0.01, 'puddle depth (m)'], + ['floorLift', 0, 0.6, 0.005, 'ripple height (m)'], + ['floorDetail', 0, 1, 0.01, 'ripple gain'], + ['floorSpeed', 0, 4, 0.01, 'ripple rate'], + ['floorFlow', 0, 2, 0.01, 'drift (m/s)'], + ['floorWind', -3.15, 3.15, 0.01, 'drift bearing (rad)'] + ], + 'The veil': [ + ['veilRate', 0, 300, 1, 'veil rate'], + ['veilSize', 0.1, 6, 0.05, 'veil size'], + ['veilSpeed', 0, 6, 0.05, 'veil speed'], + ['veilLifetime', 0.2, 10, 0.05, 'veil lifetime'], + ['veilRise', -2, 4, 0.01, 'veil rise'], + ['veilOpacity', 0, 1, 0.01, 'veil opacity'], + ['veilTurbulence', 0, 3, 0.01, 'veil turbulence'], + ['veilSeat', 0.1, 2, 0.01, 'seat × zoneRadius'], + ['colorVeil*', 'Veil colour'] + ], + 'The stars inside': [ + ['starRate', 0, 200, 1, 'star rate'], + ['starSize', 0.005, 0.4, 0.005, 'star size'], + ['starSpeed', 0, 4, 0.01, 'star speed'], + ['starLifetime', 0.2, 10, 0.05, 'star lifetime'], + ['starRise', -2, 4, 0.01, 'star rise'], + ['starTurbulence', 0, 3, 0.01, 'star turbulence'], + ['starSeat', 0.05, 1.5, 0.01, 'seat × zoneRadius'], + ['colorStar*', 'Star colour'] + ], + 'The moment it seals': [ + ['castDim', 0, 1, 0.01, 'negative flash'], + ['colorFall', 'what the frame dims toward'], + ['sealShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.3, 0.002, 'closing rumble'], + ['holdRumble', 0, 0.3, 0.002, 'held rumble'] + ], + 'The caster’s light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 4, 0.01, 'light height (m)'], + ['lightSmother', 0, 1, 0.01, 'how much the dome takes'], + ['lightStruggle', 0, 20, 0.1, 'flutter (rad/s)'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/obsidian.js b/src/config/abilities/obsidian.js new file mode 100644 index 0000000..76a0624 --- /dev/null +++ b/src/config/abilities/obsidian.js @@ -0,0 +1,447 @@ +/* ================================================================== */ +/* OBSIDIAN BLOOM — stone, far cast */ +/* ================================================================== */ +/** + * Volcanic glass opening out of the floor in a ring of blades. + * + * Everything in the `The conchoidal fracture` folder below is **geometry**, not + * shading, and that is the whole slot. Obsidian does not break along planes the + * way quartz or feldspar do — it has no crystal structure to break along, so a + * fracture in it is a smooth curved shell with concentric rib marks radiating + * from wherever the blow landed. Every other rock in this sandbox is faceted; + * this one has to be *curved*, and it has to be curved in the mesh, because the + * read is the way a highlight slides across a face rather than jumping from one + * facet to the next. + * + * So the sliders that matter here are not the colour pickers. They are `dish` + * (how deeply a fracture scar bows in between its two arrises), `ripple` and + * `ripplePitch` (the rib marks), and `dishBias` (where on the face the blow + * landed, which is where the ribs are centred). Drag `dish` to zero with the + * bloom standing and paused and you can watch it turn back into an ordinary + * faceted crystal — that is the A/B for whether the trick is doing anything. + * + * The glass sliders are second-order by design: `glassRough` at 0.07 and + * `envIntensity` above 1 do the rest, and `glint`/`glintSharp` add one tight + * analytic lobe on top so the specular is a *point* rather than a smear even on + * a stage with a soft probe. + * + * `zoneRadius` drives the ring, the glaze on the floor and where the flakes are + * thrown from. That sharing is the design — the circle the indicator drew is the + * circle the bloom happens in. + */ +export const obsidian = { + /* --- the cast --- */ + range: 26.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + zoneRadius: 6.5, // the footprint the indicator draws, metres + speed: 44.0, // how fast the heat front runs out to the zone, metres/second + cooldown: 1.5, // seconds + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + holdTime: 2.2, // seconds the bloom stands once it is fully out + fadeTime: 1.1, // seconds from the break to gone + + /* --- the bloom --- */ + shards: 72, // blades planted per cast (capped at 120) + clusterShare: 0.16, // 0..1 of them held back for the knot at the centre + clusterRadius: -1, // metres; < 0 derives it from the inner radius + bloomTime: 0.55, // seconds the ring takes to open from the centre out + riseStagger: 0.3, // seconds of scatter on when a blade is released + ringInner: 0.12, // inner edge of the band, × zoneRadius + ringOuter: 1.0, // outer edge, × zoneRadius + radialCurve: 0.9, // <1 crowds the blades toward the rim + radialJitter: 0.35, // metres of radial wander + angleJitter: 0.22, // radians of bearing wander + + /* --- the silhouette of one blade --- */ + heightNear: 0.55, // metres tall at the centre + bladeHeight: 2.5, // ... and at the rim + heightCurve: 0.85, // how late the ramp climbs + heightJitter: 0.45, // ± fraction + crown: 0.25, // 0..1 how much shorter the flank blades are + crownPower: 1.4, // how sharply that dome falls off + peak: 1.0, // extra height multiplier at the rim + rubble: 0.16, // 0..1 chance a blade is demoted to a broken stub + rubbleScale: 0.32, // height multiplier for those + rubbleSpread: 1.3, // radius multiplier for those + bladeRadius: 0.3, // metres, half-width at the centre + bladeRadius2: 0.42, // ... and at the rim + radiusCurve: 0.7, // how that ramps + radiusJitter: 0.4, // ± fraction + lean: 0.42, // radians the blades tip outward from the centre + leanJitter: 0.5, // ± fraction + leanRamp: 1.0, // 0 leans everything equally, 1 only the rim + leanOutward: 1.0, // weight of "out from the centre" in the lean + leanForward: 0.0, // weight of "away from the caster" — 0: a bloom has no front + twist: 1.0, // 0..1 of a full turn of random yaw + tilt: 0.16, // radians of extra random tip, any bearing + + /* --- how a blade arrives --- */ + riseTime: 0.2, // seconds from buried to full height + riseOvershoot: 0.3, // how far past full height the punch carries + settle: 0.42, // seconds the overshoot damps out over + springRate: 17, // radians/second of that ring + emergeSink: 0.9, // fraction of its height a blade is buried at emerge = 0 + birthScale: 0.82, // footprint scale the moment it breaks the surface + birthFade: 0.45, // seconds the heat flash on a new blade decays over + breachAt: 0.22, // emergence fraction that throws the chips + sinkDepth: 0.5, // extra metres a withdrawing blade drops + + /* --- THE CONCHOIDAL FRACTURE --- */ + /** + * These twelve rebuild the mesh when they move (`syncGeometry` hashes them), + * so they are the expensive folder and the only one worth the cost. See the + * class comment on `ObsidianAbility` for what each of them is doing to the + * surface. + */ + faceSides: 6, // fracture faces around the blade, 4–9 + faceRings: 5, // divisions up one face — the curve's vertical resolution + faceArc: 3, // divisions across one face — the curve's lateral resolution + tipTaper: 0.14, // blade half-width at the shoulder, fraction of the base + tipCurve: 1.7, // >1 keeps the blade wide then narrows it late + tipRise: 0.15, // fraction of the height given to the terminal point + bulge: 0.22, // how much the blade swells at mid-height + dish: 0.3, // how deeply a fracture scar bows in, fraction of the radius + dishBias: 0.18, // where up the face the blow landed, 0..1 + dishStretch: 1.35, // >1 makes the scar an ellipse taller than it is wide + ripple: 0.045, // rib-mark amplitude, fraction of the radius + ripplePitch: 2.6, // ribs per unit distance from the initiation point + shear: 0.32, // radians the blade twists over its own height + chip: 0.3, // ± fraction of per-bearing radius jitter + + /* --- the glass --- */ + colorGlass: '#14121c', // the body — near black, faintly violet + colorDeep: '#050408', // the interior, where the banding is densest + colorSheen: '#b9c4ff', // every reflection: fresnel rim and specular lobe + colorBleed: '#7a2410', // light that gets through a thin edge — obsidian is red + colorHeat: '#ff7326', // molten, on a blade that has just arrived + glassRough: 0.07, // low: this is the number that makes it glass + glassMetal: 0.0, // a dielectric. Metalness here reads as hematite, not glass + envIntensity: 1.7, // how hard the probe shows in the surface + glassOpacity: 1.0, + fresnel: 0.85, // strength of the grazing-angle sheen + fresnelPower: 4.5, // how tightly it hugs the silhouette + glint: 2.4, // the sharp specular lobe + glintSharp: 220, // its exponent — high is a point, low is a smear + bleed: 0.6, // how much light comes through the thin end + bleedPower: 2.2, // how quickly "thin" runs out toward the base + heat: 3.2, // the arrival glow + heatBand: 2.4, // how tightly that glow hugs the base + banding: 0.45, // 0..1 flow banding, in world space + bandScale: 2.2, // bands per metre + emission: 0.04, // the standing glow. Deliberately almost nothing + + /* --- the break --- */ + breakTime: 0.16, // seconds the blades take to go, once they go + flakes: 90, // fragments thrown at the break + flakeSize: 0.19, // metres + flakeJitter: 0.6, // ± fraction + flakeSpawnRadius: 0.5, // metres of scatter about a flake's anchor + flakeSpawnHeight: 1.2, // metres the anchor sits above the floor + flakeSpeed: 5.6, // metres/second + flakeSpeedJitter: 0.65, // ± fraction + flakeSpread: 0.42, // 0 throws them all one way, 1 is fully random + flakeUp: 0.55, // how much +Y is folded into the throw + flakeGravity: -20.0, // metres/second² + flakeDrag: 0.75, // 1/second + flakeSpin: 11.0, // radians/second of tumble + flakeSpinJitter: 0.8, // ± fraction + flakeLife: 1.5, // seconds a flake lives + flakeShrink: 0.5, // 0..1 of its size lost by the end + flakeShrinkPower: 1.7, // how late that bites + flakeFloor: 0.02, // metres, the floor a flake settles on + flakeFloorSpin: 0.2, // fraction of the tumble kept once grounded + colorFlakeA: '#1a1724', // the lit face of a flake + colorFlakeB: '#07060c', // its shaded face + colorFlakeEdge: '#c7d0ff', // its rim — the razor edge catching the key + colorFlakeScene: '#20202c', // what shows through, when a scene copy exists + flakeOpacity: 1.0, + flakeGlow: 0.5, + flakeRim: 1.5, + flakeRimPower: 3.0, + flakeShade: 0.75, + flakeAmbient: 0.2, + flakeFadeStart: 0.62, // 0..1 of life before a flake starts to go + flakeSoft: 0.3, // metres of soft fade against geometry + flakeSceneMix: 0.0, // refraction, when `frame.uSceneColor` ever exists + flakeRefract: 0.4, + flakeSaturation: 0.35, + + /* --- the vitrified floor --- */ + glazeSpan: 1.15, // the glaze's radius, × zoneRadius + glazeHeight: 0.012, // metres above the floor + glazeEdge: 0.5, // metres of feather on the spreading front + glazeRagged: 0.34, // how far that front wanders + glazeRaggedScale: 0.6, // lobes per metre + glazeWarp: 0.55, // metres of domain warp on those lobes + glazeRelief: 0.55, // how hard the height field tilts the fake normal + glazeNormalStep: 0.06, // metres between the height taps + glazeAmbient: 0.18, // floor on the diffuse term + glazeWrap: 0.4, // wraps the terminator round the back + glazeSpecular: 1.3, // high: this is glass on the floor + glazeGloss: 90, // Blinn exponent — a hard, small highlight + glazeParallax: 0.3, // metres of view-driven offset + glazeDetail: 0.7, // 0..1 interior detail + glazeFlow: 0.12, // metres/second the sheen crawls + glazeSharp: 0.7, // 0..1 how hard-edged the glaze is + glazeEmissive: 1.0, // multiplier on the glowing terms + glazeOpacity: 0.9, + colorGlazeBase: '#0d0c12', // the vitrified stone itself + colorGlazeEdge: '#8f9ccb', // its sheen + colorGlazeGlow: '#ff6a24', // the heat still in it + colorGlazeDeep: '#030306', // the deepest part of the pour + + /* --- chips, ash and glints --- */ + /** + * Each system is coloured by a four-stop gradient over the particle's own + * lifetime, `A` at birth through `D` as it dies. Spelled out rather than + * derived from the glass palette, so the chips can be made to cool through + * orange while the blades stay black. + */ + breachChips: 9, // chips thrown as one blade breaks the surface + breakChips: 120, // ... and at the break + chipSize: 0.07, + chipSpeed: 6.5, + chipLifetime: 1.4, + chipGravity: -19.0, + colorChipA: '#ffb060', + colorChipB: '#4a3a44', + colorChipC: '#1a1720', + colorChipD: '#0b0a10', + breachAsh: 5, // puffs of ash as one blade breaks the surface + breakAsh: 60, // ... and at the break + ashSize: 0.9, + ashSpeed: 1.5, + ashLifetime: 2.6, + ashRise: 0.7, // upward drift, metres/second + ashOpacity: 0.2, + colorAshA: '#4c4650', + colorAshB: '#3a3540', + colorAshC: '#26232c', + colorAshD: '#14131a', + glintRate: 26, // sparkles/second off the standing glass + glintSize: 0.06, + glintSpeed: 0.5, + glintLifetime: 0.55, + glintRise: 0.35, + colorGlintA: '#ffffff', + colorGlintB: '#c7d0ff', + colorGlintC: '#6a72b8', + colorGlintD: '#14162e', + + /* --- shake --- */ + bloomShake: 0.3, // the jolt as the ring opens + bloomShakeTime: 0.5, // seconds it decays over + breakShake: 0.42, // the jolt at the break + breakShakeTime: 0.35, + rumble: 0.05, // continuous shake while the ring is still opening + + /* --- dynamic light --- */ + // Orange, not violet: the light in this ability is the magma the glass came + // out of, and it is gone within a second of the bloom finishing. + lightIntensity: 22, + lightRadius: 14, + lightColor: '#ff7a30' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Obsidian Bloom. + * + * `The conchoidal fracture` is the folder that makes this slot what it is and + * it is the one that costs something to drag — every control in it rebuilds + * three meshes. That is deliberate: a curve you can only get by rebuilding is + * still worth having, and the alternative (baking the curvature into a normal + * map) is a texture, which invariant I2 does not allow and which would fall + * apart at the silhouette anyway. + */ +export const obsidianSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 15, 0.1, 'min range'], + ['zoneRadius', 1.5, 20, 0.1, 'zone radius'], + ['speed', 5, 200, 1, 'front speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['holdTime', 0.1, 10, 0.05, 'hold time'], + ['fadeTime', 0.1, 6, 0.01, 'fade time'], + ['castAnim', 'cast animation'] + ], + 'The conchoidal fracture': [ + ['dish', 0, 0.9, 0.005, 'scar depth'], + ['dishBias', 0, 1, 0.01, 'where the blow landed'], + ['dishStretch', 0.3, 4, 0.01, 'scar stretch'], + ['ripple', 0, 0.2, 0.002, 'rib amplitude'], + ['ripplePitch', 0.2, 10, 0.05, 'ribs / unit'], + ['faceSides', 4, 9, 1, 'fracture faces'], + ['faceRings', 3, 8, 1, 'rings per face'], + ['faceArc', 2, 6, 1, 'arc per face'], + ['tipTaper', 0.02, 0.6, 0.005, 'shoulder width'], + ['tipCurve', 0.4, 4, 0.01, 'taper curve'], + ['tipRise', 0.02, 0.5, 0.005, 'terminal point'], + ['bulge', 0, 0.8, 0.005, 'mid-height swell'], + ['shear', -1.5, 1.5, 0.01, 'twist over height'], + ['chip', 0, 1, 0.01, 'bearing jitter'] + ], + 'The bloom': [ + ['shards', 1, 120, 1, 'blades'], + ['clusterShare', 0, 1, 0.01, 'centre knot share'], + ['clusterRadius', -1, 8, 0.05, 'centre knot radius'], + ['bloomTime', 0.05, 4, 0.01, 'open time'], + ['riseStagger', 0, 2, 0.01, 'release scatter'], + ['ringInner', 0, 1, 0.01, 'inner edge × radius'], + ['ringOuter', 0.05, 1.5, 0.01, 'outer edge × radius'], + ['radialCurve', 0.2, 3, 0.01, 'radial crowding'], + ['radialJitter', 0, 3, 0.01, 'radial wander'], + ['angleJitter', 0, 1.5, 0.01, 'bearing wander'], + ['rumble', 0, 0.5, 0.005, 'opening rumble'] + ], + 'One blade': [ + ['heightNear', 0.05, 6, 0.01, 'height at centre'], + ['bladeHeight', 0.1, 10, 0.05, 'height at rim'], + ['heightCurve', 0.1, 4, 0.01, 'height curve'], + ['heightJitter', 0, 1, 0.01, 'height jitter'], + ['crown', 0, 1, 0.01, 'crown'], + ['crownPower', 0.2, 4, 0.01, 'crown falloff'], + ['peak', 0.2, 3, 0.01, 'rim swell'], + ['rubble', 0, 1, 0.01, 'broken stubs'], + ['rubbleScale', 0.05, 1, 0.01, 'stub height'], + ['rubbleSpread', 0.5, 3, 0.01, 'stub spread'], + ['bladeRadius', 0.02, 2, 0.01, 'width at centre'], + ['bladeRadius2', 0.02, 2, 0.01, 'width at rim'], + ['radiusCurve', 0.1, 3, 0.01, 'width curve'], + ['radiusJitter', 0, 1, 0.01, 'width jitter'], + ['lean', -1.4, 1.4, 0.01, 'outward lean'], + ['leanJitter', 0, 1, 0.01, 'lean jitter'], + ['leanRamp', 0, 1, 0.01, 'lean ramp'], + ['leanOutward', 0, 2, 0.01, 'lean outward'], + ['leanForward', -2, 2, 0.01, 'lean downrange'], + ['twist', 0, 1, 0.01, 'random yaw'], + ['tilt', 0, 1, 0.01, 'random tip'] + ], + 'The eruption': [ + ['riseTime', 0.02, 2, 0.01, 'rise time'], + ['riseOvershoot', 0, 1.5, 0.01, 'overshoot'], + ['settle', 0.05, 3, 0.01, 'settle'], + ['springRate', 1, 40, 0.5, 'spring rate'], + ['emergeSink', 0, 1.5, 0.01, 'buried depth'], + ['birthScale', 0.1, 1.5, 0.01, 'birth scale'], + ['birthFade', 0.02, 3, 0.01, 'heat flash decay'], + ['breachAt', 0.02, 1, 0.01, 'breach point'], + ['sinkDepth', 0, 4, 0.05, 'withdraw depth'] + ], + 'The glass': [ + ['glassRough', 0.01, 1, 0.005, 'roughness'], + ['glassMetal', 0, 1, 0.01, 'metalness'], + ['envIntensity', 0, 5, 0.01, 'probe strength'], + ['glassOpacity', 0, 1, 0.01, 'opacity'], + ['fresnel', 0, 3, 0.01, 'grazing sheen'], + ['fresnelPower', 0.5, 12, 0.1, 'sheen tightness'], + ['glint', 0, 8, 0.01, 'specular lobe'], + ['glintSharp', 8, 600, 1, 'specular sharpness'], + ['bleed', 0, 3, 0.01, 'edge transmission'], + ['bleedPower', 0.2, 8, 0.05, 'transmission falloff'], + ['heat', 0, 10, 0.01, 'arrival heat'], + ['heatBand', 0.2, 8, 0.05, 'heat band'], + ['banding', 0, 1, 0.01, 'flow banding'], + ['bandScale', 0.1, 10, 0.05, 'bands / metre'], + ['emission', 0, 1, 0.005, 'standing glow'], + ['colorGlass', 'glass body'], + ['colorDeep', 'interior'], + ['colorSheen', 'reflection'], + ['colorBleed', 'transmission'], + ['colorHeat', 'molten'] + ], + 'The break': [ + ['breakTime', 0.02, 1.5, 0.01, 'break time'], + ['flakes', 0, 192, 1, 'flakes'], + ['flakeSize', 0.02, 1, 0.005, 'flake size'], + ['flakeJitter', 0, 1, 0.01, 'size jitter'], + ['flakeSpawnRadius', 0, 4, 0.05, 'spawn scatter'], + ['flakeSpawnHeight', 0, 6, 0.05, 'spawn height'], + ['flakeSpeed', 0, 25, 0.1, 'throw speed'], + ['flakeSpeedJitter', 0, 1, 0.01, 'speed jitter'], + ['flakeSpread', 0, 1, 0.01, 'throw spread'], + ['flakeUp', 0, 1, 0.01, 'upward bias'], + ['flakeGravity', -60, 0, 0.5, 'gravity'], + ['flakeDrag', 0, 4, 0.01, 'drag'], + ['flakeSpin', 0, 30, 0.1, 'tumble'], + ['flakeSpinJitter', 0, 1, 0.01, 'tumble jitter'], + ['flakeLife', 0.1, 6, 0.05, 'lifetime'], + ['flakeShrink', 0, 1, 0.01, 'shrink'], + ['flakeShrinkPower', 0.2, 5, 0.05, 'shrink curve'], + ['flakeFloor', 0, 2, 0.01, 'floor'], + ['flakeFloorSpin', 0, 1, 0.01, 'grounded tumble'], + ['flakeOpacity', 0, 1, 0.01, 'opacity'], + ['flakeGlow', 0, 4, 0.01, 'glow'], + ['flakeRim', 0, 5, 0.01, 'rim'], + ['flakeRimPower', 0.2, 8, 0.05, 'rim tightness'], + ['flakeShade', 0, 2, 0.01, 'shading depth'], + ['flakeAmbient', 0, 1, 0.01, 'ambient'], + ['flakeFadeStart', 0, 1, 0.01, 'fade start'], + ['flakeSoft', 0.02, 2, 0.01, 'soft intersection'], + ['flakeSceneMix', 0, 1, 0.01, 'scene mix'], + ['flakeRefract', 0, 2, 0.01, 'refraction'], + ['flakeSaturation', 0, 2, 0.01, 'scene saturation'], + ['colorFlakeA', 'lit face'], + ['colorFlakeB', 'shaded face'], + ['colorFlakeEdge', 'razor edge'], + ['colorFlakeScene', 'scene tint'] + ], + 'The vitrified floor': [ + ['glazeSpan', 0.2, 3, 0.01, 'span × radius'], + ['glazeHeight', 0.002, 0.2, 0.002, 'height'], + ['glazeEdge', 0.02, 3, 0.01, 'front feather'], + ['glazeRagged', 0, 1.5, 0.01, 'front wander'], + ['glazeRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['glazeWarp', 0, 3, 0.01, 'domain warp'], + ['glazeRelief', 0, 3, 0.01, 'relief'], + ['glazeNormalStep', 0.01, 0.4, 0.005, 'normal step'], + ['glazeAmbient', 0, 1, 0.01, 'ambient'], + ['glazeWrap', 0, 1, 0.01, 'terminator wrap'], + ['glazeSpecular', 0, 4, 0.01, 'specular'], + ['glazeGloss', 2, 200, 1, 'gloss'], + ['glazeParallax', 0, 2, 0.01, 'parallax'], + ['glazeDetail', 0, 1, 0.01, 'detail'], + ['glazeFlow', -2, 2, 0.01, 'sheen flow'], + ['glazeSharp', 0, 1, 0.01, 'edge hardness'], + ['glazeEmissive', 0, 4, 0.01, 'emissive'], + ['glazeOpacity', 0, 1, 0.01, 'opacity'], + ['colorGlazeBase', 'glaze'], + ['colorGlazeEdge', 'sheen'], + ['colorGlazeGlow', 'heat'], + ['colorGlazeDeep', 'deep'] + ], + 'Chips, ash & glints': [ + ['breachChips', 0, 60, 1, 'chips per blade'], + ['breakChips', 0, 400, 1, 'chips at the break'], + ['chipSize', 0.005, 0.4, 0.005, 'chip size'], + ['chipSpeed', 0, 25, 0.1, 'chip speed'], + ['chipLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['chipGravity', -50, 0, 0.5, 'chip gravity'], + ['breachAsh', 0, 40, 1, 'ash per blade'], + ['breakAsh', 0, 300, 1, 'ash at the break'], + ['ashSize', 0.05, 4, 0.05, 'ash size'], + ['ashSpeed', 0, 8, 0.05, 'ash speed'], + ['ashLifetime', 0.2, 8, 0.05, 'ash lifetime'], + ['ashRise', -2, 4, 0.01, 'ash rise'], + ['ashOpacity', 0, 1, 0.005, 'ash opacity'], + ['glintRate', 0, 200, 1, 'glint rate'], + ['glintSize', 0.005, 0.3, 0.005, 'glint size'], + ['glintSpeed', 0, 5, 0.05, 'glint speed'], + ['glintLifetime', 0.05, 3, 0.01, 'glint lifetime'], + ['glintRise', -2, 4, 0.01, 'glint rise'], + ['colorChip*', 'Chip colour'], + ['colorAsh*', 'Ash colour'], + ['colorGlint*', 'Glint colour'] + ], + 'Shake & dynamic light': [ + ['bloomShake', 0, 2, 0.01, 'opening jolt'], + ['bloomShakeTime', 0.05, 3, 0.01, 'opening decay'], + ['breakShake', 0, 2, 0.01, 'break jolt'], + ['breakShakeTime', 0.05, 3, 0.01, 'break decay'], + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/origami.js b/src/config/abilities/origami.js new file mode 100644 index 0000000..9bc5f4c --- /dev/null +++ b/src/config/abilities/origami.js @@ -0,0 +1,402 @@ +/* ================================================================== */ +/* ORIGAMI — Paper Storm. A flock of cranes that unfolds in flight. */ +/* ================================================================== */ +/** + * The line cast built entirely out of **`vfx/FoldMesh.js`**. + * + * A flight of folded paper cranes is thrown down the aimed line. When they + * arrive they **come apart** — every crease runs backwards through the sequence + * it was folded in, the birds flatten into sheets of written paper, and the + * sheets sink and go. The whole flock opens on **one number**, `progress`, + * staggered per bird by `foldStagger`, and the ability's job is to drive that + * number and nothing else. + * + * The single most important consequence of the module doing the folding is that + * the paper **cannot stretch**: each crease is a rigid motion per material + * point, so a half-open crane has exactly the surface area of a flat sheet at + * every intermediate value. That is the trick, and it is the reason this block + * has no "crane size while folded" key — there is no such quantity. + * + * Three beats, mapped onto the phase machine: + * + * | phase | what it is | how long | + * | --- | --- | --- | + * | travel | the flock flies, folded, appearing as the front passes their dice | `range / speed` | + * | impact | **the unfold**, then a hold with the sheets flat in the air | `openTime + holdTime` | + * | fade | the sheets sink, turn over and go | `sinkTime` | + * + * The four controls worth reaching for first are `foldStagger` (how much of a + * ripple the opening is, versus the whole flock snapping open together), + * `openTime`, `sheets`, and `ink` — a crane folded from a blank sheet is a + * paper aeroplane, and a crane folded from a written one is the ability. + * + * **No bloom, anywhere.** Ink is the matte school. Nothing here is additive, + * nothing goes through `ctx.flash`, `scrapGlow` ships well under 1, and the + * paper material is the one tone-mapped material in `src/vfx/`. If any of this + * starts to glow, that is a bug and not a look. + * + * Nothing below is captured by a cast. A cast rolls one seed; every metre, + * radian and second is re-read on every frame, zero-length ones included. + */ +import { foldMeshSchema } from '../../vfx/FoldMesh.js'; + +/** The sheet, surface and writing folders come straight off the module. */ +const PAPER = foldMeshSchema('Paper'); + +export const origami = { + /* --- the cast --- */ + range: 21.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 15.0, // how fast the flock flies downrange, metres/second + cooldown: 5.2, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats --- */ + openTime: 1.15, // seconds the flock spends coming apart + holdTime: 0.55, // seconds the flat sheets hang in the air afterwards + sinkTime: 1.35, // seconds of falling and fading — the fade phase + + /* ------------------------------------------------------------------ */ + /* The flight */ + /* ------------------------------------------------------------------ */ + /** + * Where the birds are. All of it is `FoldMesh`'s `LINE` layout: the flock is + * strung down the lane, scattered on unitless dice, and slid along it by + * `travel` — so the *shape* of the flock is fixed per cast and its position + * is a live function of the front. + */ + sheets: 18, // live cranes, capped at 28 by the class + spread: 1.9, // metres of lateral scatter across the lane + stretch: 3.4, // metres of scatter along the lane — the flock's depth + lift: 1.55, // metres off the floor the flock flies at + liftJitter: 0.62, // ±metres per bird + bob: 0.12, // metres of vertical breathing + bobRate: 2.3, // radians/second of that breathing + overrun: 2.8, // metres the flock coasts past the end of the line as it opens + sink: 1.2, // metres/second the flat sheets fall through the fade + + /* --- attitude --- */ + flyPitch: -0.12, // radians of nose-up on the folded flock + openPitch: 0.55, // extra radians of pitch by the time a sheet is flat + yawJitter: 0.45, // ±radians per bird + rollJitter: 0.5, // ±radians of bank per bird + openRoll: 1.15, // radians a sheet turns over by as it opens + /** + * Radians/second of free rotation about the sheet's own normal. + * + * **Constant for the whole cast, deliberately.** The shader applies it as + * `tumble × uTime`, and `uTime` is seconds since the app booted — so ramping + * this from 0 as the birds open, which is what the first version did, slews + * every sheet by `t · Δω` on the frame you change it: hundreds of radians, in + * one frame, as an instant snap. The "it starts spinning as it opens" read is + * bought instead by `openRoll`, which is a closed-form *angle* and can be + * re-evaluated at will — including while paused. + */ + tumble: 0.3, + revealSpread: 0.42, // width of the appearance wave, as a fraction of the flock + + /* ------------------------------------------------------------------ */ + /* The unfold */ + /* ------------------------------------------------------------------ */ + /** + * `progress` itself is not here: it is the beat, driven from 1 down to 0 (and + * a stagger's worth above 1, so the last bird still starts closed) across the + * impact phase. What is here is the *shape* of that run. + */ + foldStagger: 0.55, // fraction of the unfold spread across the flock + foldGain: 1.0, // multiplier on every crease angle; >1 overfolds + hinge: 0.016, // metres — the crease radius. Under one grid cell it kinks. + stageEase: 1.0, // 0 linear crease windows, 1 smoothstepped + openLift: 0.42, // metres a sheet rises by as it opens — air under the paper + + /* ------------------------------------------------------------------ */ + /* The sheet */ + /* ------------------------------------------------------------------ */ + /** `CRANE` wants a square: `aspect` away from 1 opens the tips. */ + sheetWidth: 0.36, // metres across (the u axis) + sheetLength: 0.36, // metres along (the v axis) — the head end is +v + aspect: 1.0, // multiplier on the width. Leave at 1 for a crane. + sizeJitter: 0.24, // ±fraction per bird + thickness: 0.0009, // metres of paper per stacked flap — kills the z-fight + + /* --- the paper as an object --- */ + ambient: 0.5, // floor under the diffuse term + tintSpread: 0.08, // ±per-sheet brightness walk + paperOpacity: 1.0, // master coverage of the paper before the fade + + /* ------------------------------------------------------------------ */ + /* The surface — every key here is read by `FoldMesh` verbatim */ + /* ------------------------------------------------------------------ */ + grain: 0.55, // how much fibre shows in the albedo + grainScale: 62, // fibres per metre + grainAngle: 0.25, // radians — the machine direction + grainAniso: 8.5, // how far the fibres are stretched along it + fleck: 0.3, // sparse darker specks in the pulp + laid: 0.15, // the regular ribbing of a laid sheet + laidPitch: 48, // lines per metre, the fine ones + chainPitch: 1.9, // lines per metre, the coarse chain lines + creaseGlow: 0.55, // how much a mountain crease catches the light + creaseDark: 0.42, // how much a valley crease holds shadow + creaseSharp: 1.7, // exponent on the band profile — apparent crease width + transmit: 0.72, // how much light comes through the sheet from behind + transmitPower: 5.0, // tightness of that lobe + wrap: 0.42, // how far light bends around the sheet, 0..1 + sheen: 0.22, // grazing specular along the fibre + gloss: 26, // specular exponent + edge: 0.022, // deckle — the fraction of the sheet its edge thins over + + colorPaper: '#efe6d2', // the sheet, lit + colorShade: '#b0a288', // the sheet, unlit + colorTransmit: '#ffe4bb', // light coming *through* the paper + colorCrease: '#fff8ea', // the highlight along a mountain fold + + /* ------------------------------------------------------------------ */ + /* The writing */ + /* ------------------------------------------------------------------ */ + /** + * Hashed strokes in a grid, in **sheet space** — so the text folds with the + * paper for free and comes out the right way round on a flap that has been + * turned over twice. A crane folded from a blank sheet reads as plastic. + */ + ink: 0.42, // 0 blank paper, 1 fully written + inkRows: 13, // characters down the sheet + inkCols: 5, // columns across + inkFill: 0.72, // fraction of cells that carry a mark + inkWeight: 0.075, // stroke half-width, cell units + inkMargin: 0.1, // fraction of the sheet left blank at the edges + inkSeed: 3.7, // shifts the whole text + inkGhost: 0.34, // how much the writing shows through from the back + colorInk: '#1d1613', // the writing + + /* ------------------------------------------------------------------ */ + /* The scraps — `vfx/Swarm.js`, one draw call */ + /* ------------------------------------------------------------------ */ + /** + * Torn corners and offcuts flying with the flock, on `LEAF` silhouettes. + * + * They are not decoration: they are what makes the unfold read as *violent* + * rather than as an animation, and they are driven by the same beat — their + * `reveal` is `1 − progress`, so a scrap exists exactly to the extent that a + * crane has come apart. + */ + scraps: 96, // live scraps, capped at 192 + scrapSize: 0.115, // metres, nose to tail + scrapAspect: 1.3, // span / length + scrapSizeJitter: 0.45, // ±fraction + scrapCurl: 0.38, // leaf curl across the chord, fraction of size + scrapLatticeX: 5, // cells across the formation + scrapLatticeY: 3, // cells up + scrapLatticeZ: 7, // ranks strung out behind the lead + scrapSpacing: 0.55, // metres between lateral cells + scrapSpacingUp: 0.44, // metres between vertical cells + scrapJitter: 0.34, // metres of slop off the cell + scrapLag: 0.55, // seconds the back rank trails the lead by + scrapChurn: 0.5, // radians/second the formation rolls — see `tumble`, same clock + scrapBreathe: 0.22, // fraction the formation swells by + scrapBreatheRate: 1.4, // radians/second + scrapWander: 0.24, // metres of curl drift — keep under half the spacing + scrapWanderScale: 0.6, // features per metre + scrapWanderSpeed: 0.55, // drift rate + scrapGather: 0.85, // 0 collapses every scrap onto the lead's own path + scrapRise: 1.1, // metres the scrap lead lofts at mid-span + scrapEnd: 1.2, // metres off the floor the scrap lead ends at + scrapBank: 0.05, // radians of roll per m/s² of lateral acceleration + scrapBankMax: 1.1, // radians + scrapEdgeStretch: 1.5, // how much an edge-on scrap grows, ≥1 + scrapRevealSpread: 0.45, // width of the appearance wave + scrapTint: 0.28, // where in the gradient the cloud sits + scrapTintJitter: 0.35, // ±per-scrap walk along it + scrapTintAlong: 0.4, // extra walk from the front of the cloud to the back + scrapOpacity: 0.95, // coverage + scrapGlow: 0.3, // emission. Ink does not bloom — keep this well under 1. + scrapSoftFade: 0.3, // metres of depth feather against solid geometry + colorScrapA: '#e8dcc2', // freshly torn + colorScrapB: '#cbbb99', + colorScrapC: '#a29070', // shaded — a scrap seen edge-on + colorScrapD: '#7d6f58', // going + + /* ------------------------------------------------------------------ */ + /* Paper dust — `origami.dust`, SMOKE, non-additive */ + /* ------------------------------------------------------------------ */ + dustRate: 26, // particles/second while the flock is coming apart + dustBurst: 40, // particles thrown on the frame the unfold starts + dustSize: 0.5, // metres + dustLifetime: 1.5, // seconds + dustSpeed: 0.9, // metres/second + dustRise: 0.35, // metres/second² of buoyancy + dustSpread: 0.85, // radians of the emission cone + dustOpacity: 0.4, // it is dust, not smoke + dustTurbulence: 0.4, // curl noise strength + colorDustA: '#e6dcc6', + colorDustB: '#c9bda3', + colorDustC: '#a1927a', + colorDustD: '#6f6555', + + /* ------------------------------------------------------------------ */ + /* Chaff — `origami.chaff`, LEAF, lit and non-additive */ + /* ------------------------------------------------------------------ */ + chaffRate: 34, // particles/second during the unfold + chaffBurst: 46, // particles per bird on the frame it opens + chaffSize: 0.16, // metres + chaffLifetime: 2.1, // seconds + chaffSpeed: 2.4, // metres/second + chaffGravity: -2.6, // metres/second² — paper falls slowly + chaffSpin: 7.0, // radians/second of tumble + chaffSpread: 0.95, // radians of the emission cone + colorChaffA: '#f2e9d6', + colorChaffB: '#d8c9a8', + colorChaffC: '#b0a082', + colorChaffD: '#6d6250', + + /* ------------------------------------------------------------------ */ + /* The floor and the camera */ + /* ------------------------------------------------------------------ */ + ringRadius: 2.1, // metres — the dust ring dropped under the unfold + ringLife: 1.5, // seconds it lasts + ringIntensity: 0.55, // how strongly it reads against the floor + colorRingA: '#ded2b8', // the ring, near + colorRingB: '#8d8069', // the ring, far + rumble: 0.04, // continuous camera shake while the flock is in the air + openShake: 0.12, // one-shot shake on the frame the flock opens + shakeDuration: 0.5, // seconds that shake takes to die + + /* --- the light --- */ + /** + * One warm lamp riding the flock. Low, because a matte school should be lit + * by the room rather than by itself — this exists so the birds catch an edge + * against the floor, not so they glow. + */ + lightColor: '#ffdca8', // the colour of that lamp + lightIntensity: 2.6, // its intensity + lightRadius: 7.5 // metres it reaches +}; + +/** Editor layout. The three paper folders come straight off `FoldMesh`. */ +export const origamiSchema = { + 'The cast': [ + ['range', 4, 34, 0.5], + ['minRange', 0, 10, 0.5, 'min range'], + ['speed', 4, 40, 0.5], + ['cooldown', 0, 14, 0.1], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['openTime', 0.15, 4, 0.05, 'unfold (s)'], + ['holdTime', 0, 3, 0.05, 'hold (s)'], + ['sinkTime', 0.2, 4, 0.05, 'sink (s)'] + ], + 'The flight': [ + ['sheets', 1, 28, 1, 'cranes'], + ['spread', 0, 6, 0.05, 'lateral scatter (m)'], + ['stretch', 0, 10, 0.05, 'depth (m)'], + ['lift', 0, 5, 0.05, 'altitude (m)'], + ['liftJitter', 0, 2.5, 0.05, 'altitude jitter (m)'], + ['bob', 0, 0.6, 0.01, 'bob (m)'], + ['bobRate', 0, 8, 0.05, 'bob rate'], + ['overrun', 0, 10, 0.1, 'coast past end (m)'], + ['sink', 0, 6, 0.05, 'fall (m/s)'], + ['flyPitch', -1, 1, 0.01, 'pitch'], + ['openPitch', -2, 2, 0.01, 'pitch when open'], + ['yawJitter', 0, 3.2, 0.01, 'yaw jitter'], + ['rollJitter', 0, 3.2, 0.01, 'roll jitter'], + ['openRoll', -3.2, 3.2, 0.01, 'roll when open'], + ['tumble', 0, 3, 0.01, 'tumble (rad/s)'], + ['revealSpread', 0.01, 1, 0.01, 'appear wave'] + ], + 'The unfold': [ + ['foldStagger', 0, 1, 0.01, 'flock stagger'], + ['foldGain', 0, 1.6, 0.01, 'fold gain'], + ['hinge', 0.002, 0.2, 0.001, 'crease radius (m)'], + ['stageEase', 0, 1, 0.01, 'stage ease'], + ['openLift', -1, 2, 0.01, 'pop (m)'] + ], + 'Paper · sheet': PAPER['Paper · sheet'], + 'Paper · body': [ + ['ambient', 0, 1, 0.01], + ['tintSpread', 0, 0.4, 0.01, 'tint spread'], + ['paperOpacity', 0, 1, 0.01, 'opacity'] + ], + 'Paper · surface': PAPER['Paper · surface'], + 'Paper · writing': PAPER['Paper · writing'], + 'The scraps': [ + ['scraps', 0, 192, 1, 'count'], + ['scrapSize', 0.01, 0.6, 0.005, 'size (m)'], + ['scrapAspect', 0.3, 3, 0.01, 'aspect'], + ['scrapSizeJitter', 0, 1, 0.01, 'size jitter'], + ['scrapCurl', 0, 1, 0.01, 'curl'], + ['scrapLatticeX', 1, 12, 1, 'lattice across'], + ['scrapLatticeY', 1, 8, 1, 'lattice up'], + ['scrapLatticeZ', 1, 16, 1, 'ranks'], + ['scrapSpacing', 0.05, 2, 0.01, 'spacing (m)'], + ['scrapSpacingUp', 0.05, 2, 0.01, 'spacing up (m)'], + ['scrapJitter', 0, 1.5, 0.01, 'jitter (m)'], + ['scrapLag', 0, 2, 0.01, 'lag (s)'], + ['scrapChurn', 0, 3, 0.01, 'churn'], + ['scrapBreathe', 0, 1, 0.01, 'breathe'], + ['scrapBreatheRate', 0, 6, 0.05, 'breathe rate'], + ['scrapWander', 0, 1, 0.01, 'wander (m)'], + ['scrapWanderScale', 0.05, 3, 0.05, 'wander scale'], + ['scrapWanderSpeed', 0, 3, 0.05, 'wander speed'], + ['scrapGather', 0, 1, 0.01, 'gather'], + ['scrapRise', -2, 5, 0.05, 'lead rise (m)'], + ['scrapEnd', 0, 5, 0.05, 'lead end (m)'], + ['scrapBank', 0, 0.3, 0.005, 'bank'], + ['scrapBankMax', 0, 3, 0.01, 'bank max'], + ['scrapEdgeStretch', 1, 4, 0.05, 'edge stretch'], + ['scrapRevealSpread', 0.01, 1, 0.01, 'appear wave'], + ['scrapTint', 0, 1, 0.01, 'tint'], + ['scrapTintJitter', 0, 1, 0.01, 'tint jitter'], + ['scrapTintAlong', 0, 1, 0.01, 'tint along'], + ['scrapOpacity', 0, 1, 0.01, 'opacity'], + ['scrapGlow', 0, 1.5, 0.01, 'glow'], + ['scrapSoftFade', 0, 2, 0.01, 'soft fade'], + 'colorScrapA', + 'colorScrapB', + 'colorScrapC', + 'colorScrapD' + ], + 'Paper dust': [ + ['dustRate', 0, 160, 1, 'rate (/s)'], + ['dustBurst', 0, 200, 1, 'burst'], + ['dustSize', 0.05, 3, 0.01, 'size (m)'], + ['dustLifetime', 0.1, 6, 0.05, 'lifetime (s)'], + ['dustSpeed', 0, 6, 0.05, 'speed (m/s)'], + ['dustRise', -2, 4, 0.05, 'rise (m/s²)'], + ['dustSpread', 0, 1.6, 0.01, 'cone'], + ['dustOpacity', 0, 1, 0.01, 'opacity'], + ['dustTurbulence', 0, 2, 0.01, 'turbulence'], + 'colorDustA', + 'colorDustB', + 'colorDustC', + 'colorDustD' + ], + 'Chaff': [ + ['chaffRate', 0, 200, 1, 'rate (/s)'], + ['chaffBurst', 0, 200, 1, 'burst'], + ['chaffSize', 0.02, 1, 0.005, 'size (m)'], + ['chaffLifetime', 0.1, 6, 0.05, 'lifetime (s)'], + ['chaffSpeed', 0, 12, 0.05, 'speed (m/s)'], + ['chaffGravity', -12, 2, 0.05, 'gravity (m/s²)'], + ['chaffSpin', 0, 20, 0.1, 'spin (rad/s)'], + ['chaffSpread', 0, 1.6, 0.01, 'cone'], + 'colorChaffA', + 'colorChaffB', + 'colorChaffC', + 'colorChaffD' + ], + 'Floor and camera': [ + ['ringRadius', 0.2, 8, 0.05, 'dust ring (m)'], + ['ringLife', 0.1, 5, 0.05, 'ring life (s)'], + ['ringIntensity', 0, 2, 0.01, 'ring strength'], + 'colorRingA', + 'colorRingB', + ['rumble', 0, 0.6, 0.005], + ['openShake', 0, 1.2, 0.01, 'unfold shake'], + ['shakeDuration', 0.05, 2, 0.05, 'shake decay (s)'] + ], + 'The light': [ + 'lightColor', + ['lightIntensity', 0, 20, 0.1, 'intensity'], + ['lightRadius', 1, 30, 0.5, 'radius (m)'] + ] +}; diff --git a/src/config/abilities/petrify.js b/src/config/abilities/petrify.js new file mode 100644 index 0000000..de43e3d --- /dev/null +++ b/src/config/abilities/petrify.js @@ -0,0 +1,339 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* PETRIFY — Petrifying Gaze */ +/* ================================================================== */ +/** + * A grey stone column that **accretes out of the air** along the aimed line, + * stands, and then crumbles into a raymarched fall of sand. + * + * The two halves of the trick each own a block below. + * + * **Accretion.** Every facet is a plate that appears out on a cylindrical + * *shell* around the cast axis at `shellRadius` and converges inward onto the + * column's surface as it grows. It is the convergence that reads, not the + * growth: a field that simply scales up in place is a field of props fading + * in, and the first version of this looked exactly like that. `swirl` and + * `tumble` are what stop the approach from being a straight radial slide — + * with both at zero the facets come in like lift doors and the eye reads a + * mechanism rather than stone gathering. + * + * **Collapse.** `crumble*` shrinks the facets to nothing on a front that runs + * the line, and `sand*` (a `VolumeHull` in the SAND medium) fades in *behind* + * them. `sandLead` is the number that matters and it is measured in seconds + * **before** the first facet lets go: the two halves must overlap or there is + * one frame where the column is simply absent, which is the single most + * obvious way this ability can fail. + * + * The palette is deliberately the quietest in the sandbox. `emission` ships at + * 0.06 and the highest value in the four stone pickers is `#9a948a`; there is + * no ember, no rim glow and no birth flash beyond a pale dust bloom. Everything + * else in this project fights the bloom pass. This one hides from it. + */ +export const petrify = { + /* --- the cast --- */ + range: 17.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 21.0, // how fast the gaze runs down the line, metres/second + holdTime: 1.6, // seconds the finished column stands before it lets go + crumbleTime: 0.95, // seconds the facets take to shrink to nothing + sandTail: 0.8, // seconds the sand keeps falling after the last facet has gone + cooldown: 1.4, // seconds before it can be cast again + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the axis the column is built on --- */ + axisHeight: 1.32, // metres above the floor the axis runs at, at the caster + endHeight: 1.05, // metres, the same axis where it lands + axisSag: 0.14, // metres the axis bows *downward* at mid-span + axisForward: 0.6, // metres in front of the caster the column starts + columnRadiusNear: 0.32, // metres, the column's surface radius at the caster + columnRadius: 0.66, // metres, at the far end + columnCurve: 1.3, // >1 keeps the column thin, then swells it late (exponent) + + /* --- the shell the facets arrive from --- */ + facetCount: 190, // plates in the column (hard ceiling 264) + shellRadius: 2.3, // metres from the axis a facet first appears at + shellJitter: 0.4, // ± fraction of that radius, per facet + shellDrift: 0.55, // metres of along-axis wander while it is still out there + swirl: 1.05, // radians a facet sweeps around the axis on its way in + tumble: 2.2, // radians it rolls about its own normal on the way in + accreteTime: 0.44, // seconds one facet takes to arrive and lock + accreteStagger: 0.34, // seconds of random delay between neighbours + overshoot: 0.16, // fraction of the trip it carries past the surface + springRate: 19.0, // radians/second of that overshoot ring + settle: 0.3, // seconds the overshoot damps out over + birthFade: 0.22, // seconds the pale arrival bloom decays over + + /* --- one facet --- */ + facetSize: 0.6, // metres, plate radius at the far end + facetSizeNear: 0.42, // metres, at the caster + facetSizeJitter: 0.44, // ± fraction, per facet + facetThickness: 0.34, // plate thickness, as a fraction of its own radius + facetSides: 6, // vertices around one plate (4–9) + facetTaper: 0.66, // outer face radius as a fraction of the inner face + facetRough: 0.44, // how far the vertices are pushed off a clean polygon + facetShoulder: 0.62, // where up the plate the widest ring sits, 0..1 + + /* --- the stone --- */ + // Four pickers, none derived from another (I5). `colorPale` is the film of + // rock dust on a face that has just landed; it is *not* an emissive colour, + // it is mixed into the albedo, which is why this slot never blooms. + colorStone: '#9a948a', // a face turned toward the light + colorShade: '#565049', // a face turned away from it + colorSeam: '#221f1c', // the crack where two plates meet + colorPale: '#c4bcb0', // the dust bloom on a facet the instant it lands + grain: 0.75, // strength of the world-space stone grain, unitless + grainScale: 5.5, // grain features per metre + seamDepth: 0.6, // how dark a plate goes toward its own rim, unitless + facetSharp: 0.6, // how much a face pointing at the camera is lifted + birthPale: 0.55, // how far a landing facet mixes toward `colorPale` + emission: 0.06, // the whole slot's emissive gain — deliberately near zero + stoneRoughness: 0.88, // MeshStandardMaterial roughness + envIntensity: 0.3, // how much of the HDR probe the stone picks up + opacity: 1.0, + + /* --- the crumble --- */ + crumbleStagger: 0.75, // 0..1 of the crumble spent travelling the line + crumbleSpan: 0.4, // 0..1 of the crumble one facet's own collapse takes + crumbleDrop: 0.55, // metres a facet falls while it collapses + crumbleSpin: 2.6, // radians it tumbles through as it goes + + /* --- the fall of sand --- */ + // A BOX hull from the floor to the top of the column, spanning the cast. + // BOX rather than CYLINDER because the column is horizontal and the sand is + // not: the volume the grains occupy is the column's own footprint extruded + // *down* to the floor, and that is a box, tightly. + sandLead: 0.4, // seconds the sand starts BEFORE the first facet lets go + sandOnset: 0.45, // seconds it takes to reach full density + sandGirth: 1.35, // × the column radius — half-width of the sand box + sandHead: 0.5, // metres of headroom above the column inside the box + sandDust: 1.0, // master multiplier on the volume's own opacity, unitless + ...volumeHullDefaults('sand', Medium.SAND, { + // Fewer steps than the SAND default: this box is long and the camera sees + // a lot of it, and coverage is the expensive axis, not resolution. + sandSteps: 26, + sandMargin: 0.22, + sandHeightBias: 0.75, // the grains pile toward the floor + sandFeather: 0.4, + sandDensity: 1.9, + sandNoiseFrequency: 2.6, + sandNoiseStrength: 1.05, + sandRise: -2.4, // metres/second — it falls + sandFlatten: 0.25, // eddies stretched wide, so it pours rather than billows + sandShadowTaps: 1, + sandAmbient: 0.46, + sandOpacity: 0.9 + }), + + /* --- dust, grit and grains --- */ + /** + * Three systems, each with its own four-stop lifetime gradient (I5). The + * grains are the one that carries the crumble, so it is the one with the + * rate worth reaching for first. + */ + dustRate: 34, // grey haze off the column, particles/second + dustSize: 0.85, + dustSpeed: 0.7, + dustLifetime: 2.4, + dustOpacity: 0.16, + dustRise: 0.32, // upward drift, metres/second + colorDustA: '#8f8880', + colorDustB: '#767068', + colorDustC: '#5c5750', + colorDustD: '#33302c', + gritRate: 26, // chips flicked off as a facet locks, particles/second + gritSize: 0.05, + gritSpeed: 2.2, + gritLifetime: 1.5, + gritGravity: -14.0, + colorGritA: '#a49c90', + colorGritB: '#7d766c', + colorGritC: '#4c4740', + colorGritD: '#2a2724', + grainRate: 320, // the fall of sand itself, particles/second + grainSize: 0.055, + grainSpeed: 1.4, + grainLifetime: 1.1, + grainGravity: -11.0, + grainStretch: 0.22, // how far a grain smears along its velocity + grainOpacity: 0.7, + colorGrainA: '#c0b5a4', + colorGrainB: '#a2988a', + colorGrainC: '#797066', + colorGrainD: '#4a453f', + + /* --- what the ground does --- */ + crackRadius: 1.5, // stone cracking under the column at impact, metres + crackLife: 5.0, // seconds it lingers + crackIntensity: 0.32, + duneRadius: 1.1, // the dust ring where the sand lands, metres + duneLife: 4.0, + duneIntensity: 0.4, + duneRate: 1.1, // dust rings laid per metre of crumble front travel + colorCrack: '#3a352f', + colorCrackEdge: '#7d766c', + colorDune: '#8a8278', + colorDuneEdge: '#c0b6a8', + + /* --- dynamic light --- */ + // The quietest light in the sandbox, and it is not there to light the stone + // — the stage does that. It is there so the *dust* has something to catch. + lightIntensity: 5.0, + lightRadius: 9.0, + lightColor: '#6d665c', + + /* --- the impact and the shake --- */ + lockShake: 0.22, // camera shake as the column locks + shakeDuration: 0.7, // seconds that shake damps out over + crumbleShake: 0.3, // shake as it goes + rumble: 0.014, // continuous shake while the gaze travels + burstSize: 1.6, // the dust shell where the column finishes, metres + burstIntensity: 0.55, + burstGrit: 60, // chips thrown at the lock + colorBurstA: '#8f8880', + colorBurstB: '#6a645c', + colorBurstC: '#b6ada0' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Petrifying Gaze. + * + * The four controls that carry the ability, in the order worth dragging them: + * `shellRadius` (how far out of the air the stone comes from), `swirl` (whether + * the approach reads as gathering or as machinery), `sandLead` (whether the + * handoff has a hole in it) and `emission` (which should never leave the + * bottom of its range — the restraint *is* the slot). + */ +export const petrifySchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 4, 120, 0.5, 'gaze speed'], + ['holdTime', 0.1, 8, 0.05, 'column hold'], + ['crumbleTime', 0.1, 4, 0.01, 'crumble time'], + ['sandTail', 0.05, 4, 0.05, 'sand tail'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The column': [ + ['axisHeight', 0, 4, 0.01, 'axis height at caster'], + ['endHeight', 0, 4, 0.01, 'axis height at target'], + ['axisSag', -1.5, 1.5, 0.01, 'mid-span droop'], + ['axisForward', -1, 4, 0.01, 'start distance'], + ['columnRadiusNear', 0.05, 3, 0.01, 'radius at caster'], + ['columnRadius', 0.05, 3, 0.01, 'radius at target'], + ['columnCurve', 0.2, 4, 0.01, 'radius curve'] + ], + 'Accretion': [ + ['facetCount', 8, 264, 1, 'facets'], + ['shellRadius', 0.2, 8, 0.01, 'shell radius'], + ['shellJitter', 0, 1, 0.01, 'shell jitter'], + ['shellDrift', 0, 3, 0.01, 'along-axis wander'], + ['swirl', -6, 6, 0.01, 'swirl in (rad)'], + ['tumble', 0, 8, 0.01, 'roll in (rad)'], + ['accreteTime', 0.05, 2, 0.01, 'arrival time'], + ['accreteStagger', 0, 2, 0.01, 'neighbour stagger'], + ['overshoot', 0, 1, 0.01, 'lock overshoot'], + ['springRate', 1, 50, 0.5, 'lock ring rate'], + ['settle', 0.05, 2, 0.01, 'lock settle'], + ['birthFade', 0.02, 2, 0.01, 'arrival bloom decay'] + ], + 'One facet': [ + ['facetSize', 0.05, 2, 0.01, 'plate radius at target'], + ['facetSizeNear', 0.05, 2, 0.01, 'plate radius at caster'], + ['facetSizeJitter', 0, 1, 0.01, 'plate size jitter'], + ['facetThickness', 0.02, 1.5, 0.01, 'plate thickness'], + ['facetSides', 4, 9, 1, 'plate sides'], + ['facetTaper', 0.1, 1.6, 0.01, 'outer face taper'], + ['facetRough', 0, 1, 0.01, 'edge roughness'], + ['facetShoulder', 0.05, 0.95, 0.01, 'shoulder height'] + ], + 'The stone': [ + ['grain', 0, 3, 0.01, 'grain'], + ['grainScale', 0.2, 20, 0.05, 'grain features / m'], + ['seamDepth', 0, 2, 0.01, 'plate rim darkening'], + ['facetSharp', 0, 2, 0.01, 'facet lift'], + ['birthPale', 0, 2, 0.01, 'arrival dust'], + ['emission', 0, 1, 0.005, 'emission'], + ['stoneRoughness', 0.05, 1, 0.01, 'roughness'], + ['envIntensity', 0, 2, 0.01, 'probe intensity'], + ['opacity', 0, 1, 0.01, 'opacity'], + ['colorStone', 'lit stone'], + ['colorShade', 'shaded stone'], + ['colorSeam', 'plate seam'], + ['colorPale', 'arrival dust'] + ], + 'The crumble': [ + ['crumbleStagger', 0, 1, 0.01, 'front travel'], + ['crumbleSpan', 0.05, 1, 0.01, 'one facet collapse'], + ['crumbleDrop', 0, 3, 0.01, 'facet drop'], + ['crumbleSpin', 0, 12, 0.05, 'facet tumble'] + ], + 'The fall of sand': [ + ['sandLead', 0, 2, 0.01, 'sand lead (s)'], + ['sandOnset', 0.05, 2, 0.01, 'sand onset (s)'], + ['sandGirth', 0.5, 4, 0.01, 'box half-width ×'], + ['sandHead', 0, 3, 0.01, 'box headroom'], + ['sandDust', 0, 2, 0.01, 'sand opacity ×'] + ], + ...volumeHullSchema('sand', { + label: 'Sand volume', + only: ['march', 'shape', 'field', 'flow', 'optics', 'colour'] + }), + 'Dust, grit & grains': [ + ['dustRate', 0, 300, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 6, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['gritRate', 0, 300, 1, 'grit rate'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 20, 0.1, 'grit speed'], + ['gritLifetime', 0.1, 5, 0.05, 'grit lifetime'], + ['gritGravity', -50, 0, 0.1, 'grit gravity'], + ['grainRate', 0, 1200, 1, 'grain rate'], + ['grainSize', 0.005, 0.4, 0.005, 'grain size'], + ['grainSpeed', 0, 12, 0.05, 'grain speed'], + ['grainLifetime', 0.1, 5, 0.05, 'grain lifetime'], + ['grainGravity', -50, 0, 0.1, 'grain gravity'], + ['grainStretch', 0, 3, 0.01, 'grain stretch'], + ['grainOpacity', 0, 1, 0.01, 'grain opacity'], + ['colorDust*', 'Dust colour'], + ['colorGrit*', 'Grit colour'], + ['colorGrain*', 'Grain colour'] + ], + 'Marks on the ground': [ + ['crackRadius', 0.1, 8, 0.05, 'crack radius'], + ['crackLife', 0.2, 20, 0.1, 'crack lifetime'], + ['crackIntensity', 0, 2, 0.01, 'crack intensity'], + ['duneRadius', 0.1, 6, 0.05, 'dust ring radius'], + ['duneLife', 0.2, 20, 0.1, 'dust ring lifetime'], + ['duneIntensity', 0, 2, 0.01, 'dust ring intensity'], + ['duneRate', 0.05, 6, 0.05, 'rings / metre'], + ['colorCrack', 'crack'], + ['colorCrackEdge', 'crack edge'], + ['colorDune', 'dust ring'], + ['colorDuneEdge', 'dust ring edge'] + ], + 'The lock & the shake': [ + ['lockShake', 0, 2, 0.01, 'lock shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['crumbleShake', 0, 2, 0.01, 'crumble shake'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['burstSize', 0.1, 8, 0.05, 'dust shell size'], + ['burstIntensity', 0, 3, 0.01, 'dust shell intensity'], + ['burstGrit', 0, 400, 1, 'lock grit'], + ['colorBurstA', 'shell'], + ['colorBurstB', 'shell body'], + ['colorBurstC', 'shell rim'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/photonlattice.js b/src/config/abilities/photonlattice.js new file mode 100644 index 0000000..adab44d --- /dev/null +++ b/src/config/abilities/photonlattice.js @@ -0,0 +1,222 @@ +/* ================================================================== */ +/* PHOTONLATTICE — Photon Lattice, the lumen school's standing grid */ +/* ================================================================== */ +/** + * A three-dimensional grid of very thin beams hanging over the footprint: + * one family running across the cast, one running up, one running downrange. + * They cross at sixty-four points and every crossing is a bright node. + * + * **The trick is that nothing is drawn at the nodes.** There is no sprite, no + * flare, no billboard, no second pass and no `uNodeGlow` slider anywhere in this + * block. A node is bright because two beams are being *added* there, and the + * only reason that works is that the beams are drawn additively and each one's + * brightness is a real line integral through a gaussian tube rather than a + * surface with a gradient painted on it. Two integrals sum to exactly twice + * one; three, at a corner, to exactly three. + * + * The consequence is the one rule for tuning this block, and it is worth + * knowing before touching anything: **if the nodes are not bright enough, the + * beams are too dim.** `density`, `coreGain` and `intensity` are the only + * honest answers. Reaching for something that lights the crossings on its own + * would be building the fake this ability exists as the counter-example to. + * + * The second thing that makes it read is that `beamRadius` is *small* — four or + * five centimetres of gaussian sigma. A fat beam has a broad, soft maximum, two + * fat beams crossing sum to a slightly brighter blob, and the lattice reads as + * a fog cube with lumps in it. A thin beam has a sharp maximum, and where two + * sharp maxima coincide the eye reads a point of light. Drag `beamRadius` up + * with the clock stopped and watch the nodes dissolve; that is the whole + * argument in one slider. + * + * The rest is timing. Beams grow **from their own midpoints outward**, which is + * why the nodes near the centre of the volume appear before the ones at the + * corners, and why the fade — which retracts them the same way — puts the + * corner nodes out first. Nothing was told to do that. It falls out of beams + * that are actually the length they say they are. + * + * Four beats: **assemble** (travel — the beams draw in, staggered by + * `stagger`), **lock** (impact — the grid stands, a `pulse` band running along + * every beam at `pulseSpeed`), **retract** (fade — they draw back into their + * midpoints) and out. + * + * A cast captures one seed. Every metre is re-resolved in the vertex shader + * every frame, including a zero-length one: pause with **P** and drag `gridX` + * and beams change *family* under you while the lattice re-lays itself. + */ +export const photonlattice = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.5, // closer than this and the cast is refused + zoneRadius: 5.0, // the footprint — what the circle indicator measures out + speed: 24.0, // metres/second the assemble front crosses to the point + lifetime: 3.4, // seconds the locked grid stands + fadeTime: 1.7, // seconds the beams take to retract + cooldown: 1.9, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the grid --- */ + // Node counts, not beam counts. A family of beams runs along each axis and + // is laid out on the plane of the other two, so these three numbers give + // gy·gz + gx·gz + gx·gy beams and gx·gy·gz crossings. Five is the ceiling: + // 3 × 25 = 75 beams, against a buffer allocated for 96. + gridX: 4, // nodes across the cast + gridY: 4, // nodes up + gridZ: 4, // nodes downrange + latticeSpread: 0.92, // half-extent across and downrange, × `zoneRadius` + latticeHeight: 2.5, // half-extent up, metres + latticeLift: 3.1, // metres the grid's centre floats above the floor + latticeOverhang: 0.6, // metres a beam runs past the outermost node it serves + latticeSpin: 0.0, // radians of standing yaw about the grid's own up + latticeSpinSpeed: 0.09, // radians/second it turns while it stands + + /* --- one beam. Read the header before widening it. --- */ + beamRadius: 0.045, // metres — the CORE gaussian's sigma. This is the ability + haloScale: 5.0, // the halo's sigma as a multiple of the core's + hullPad: 3.2, // sigmas of gaussian tail the bounding hull has to cover + density: 3.2, // per metre — turns the line integral into brightness + coreGain: 1.0, + haloGain: 0.3, // low: the halo is there to seat the core, not to be seen + endTaper: 0.15, // 0..1 of the beam over which each end fades in + endTint: 0.55, // 0..1 how far the ends take `colorEnd` + intensity: 1.05, // master gain + + colorCore: '#fffaf0', // the beam itself, near-white so two of them clip warm + colorHalo: '#8fd0ff', // the wider, cooler lobe that seats it in the air + colorEnd: '#ffb85c', // what the free ends run out to + + /* --- the beats --- */ + stagger: 0.62, // 0..1 how far apart the beams switch on + flicker: 0.14, // 0..1 depth of the per-beam breath + flickerSpeed: 1.25, // breaths per second + pulse: 2.6, // extra brightness in the band that runs along each beam + pulseWidth: 0.12, // 0..1 of a beam + pulseSpeed: 0.85, // traversals per second + retract: 1.0, // 0..1 how far the beams draw back in over the fade + + /* --- motes hanging inside the volume --- */ + // Not node markers. They are seeded through the whole box and drift; the ones + // that happen to be near a beam are lit by the beam, in the frame buffer, + // because both are additive. Nothing samples the lattice to place them. + moteRate: 40, // particles/second + moteSize: 0.035, + moteSpeed: 0.4, // metres/second + moteLifetime: 3.2, // seconds + moteRise: 0.12, // metres/second of buoyancy + moteTurbulence: 0.35, + moteSpread: 1.05, // 0..1 of the box's half-extent they are born across + colorMoteA: '#ffffff', + colorMoteB: '#bfe6ff', + colorMoteC: '#4a7fb0', + colorMoteD: '#0a1420', + + /* --- the lock --- */ + shockRadius: 5.5, // metres — the thin ring that snaps out when the grid locks + shockLife: 0.55, // seconds + colorShockA: '#ffffff', + colorShockB: '#8fd0ff', + castFlash: 0.1, // screen flash as the assemble begins + colorCastFlash: '#e8f4ff', + lockFlash: 0.26, // screen flash on the lock + colorFlash: '#ffffff', + lockShake: 0.14, // camera shake on the lock. A grid of light has no mass + shakeDuration: 0.35, // seconds + + /* --- dynamic light --- */ + lightIntensity: 13.0, + lightRadius: 14.0, + lightColor: '#bfe0ff', + lightSway: 0.12, // depth of the slow swell + lightSwaySpeed: 0.7 // swells per second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Photon Lattice. + * + * Two sliders and nothing else, to begin with. `beamRadius` in **The beam** is + * the ability: at 0.045 m the crossings are points of light, at 0.2 m they are + * lumps in fog, and the transition is continuous and instructive. `density` is + * the honest brightness control, and it is the only one — there is deliberately + * no node term to reach for. + * + * After that, **The grid** is where the shape lives. `gridX/Y/Z` change how many + * beams there are *and which family each one belongs to*, live, with the clock + * stopped. + */ +export const photonlatticeSchema = { + 'The cast': [ + ['range', 5, 45, 0.1, 'max range'], + ['minRange', 0, 14, 0.1, 'min range'], + ['zoneRadius', 1, 14, 0.05, 'footprint radius'], + ['speed', 4, 90, 0.5, 'assemble speed'], + ['lifetime', 0.2, 14, 0.05, 'locked hold'], + ['fadeTime', 0.2, 8, 0.05, 'retract time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The grid': [ + ['gridX', 1, 5, 1, 'nodes across'], + ['gridY', 1, 5, 1, 'nodes up'], + ['gridZ', 1, 5, 1, 'nodes downrange'], + ['latticeSpread', 0.1, 2, 0.01, 'half-extent × zone'], + ['latticeHeight', 0.2, 10, 0.05, 'half-extent up (m)'], + ['latticeLift', 0, 12, 0.05, 'centre height (m)'], + ['latticeOverhang', 0, 4, 0.01, 'overhang past nodes (m)'], + ['latticeSpin', -3.2, 3.2, 0.01, 'standing yaw (rad)'], + ['latticeSpinSpeed', -2, 2, 0.005, 'yaw rate (rad/s)'] + ], + 'The beam': [ + ['beamRadius', 0.005, 0.5, 0.001, 'core sigma (m)'], + ['haloScale', 1, 16, 0.05, 'halo sigma ×'], + ['hullPad', 1.5, 6, 0.05, 'hull padding (sigmas)'], + ['density', 0, 12, 0.01, 'brightness / m'], + ['coreGain', 0, 4, 0.01, 'core gain'], + ['haloGain', 0, 3, 0.01, 'halo gain'], + ['endTaper', 0.005, 0.5, 0.005, 'end taper'], + ['endTint', 0, 1, 0.01, 'end colour pull'], + ['intensity', 0, 5, 0.01, 'intensity'], + ['colorCore', 'beam core'], + ['colorHalo', 'beam halo'], + ['colorEnd', 'free ends'] + ], + 'The beats': [ + ['stagger', 0, 0.95, 0.01, 'switch-on stagger'], + ['flicker', 0, 1, 0.01, 'breath depth'], + ['flickerSpeed', 0, 8, 0.01, 'breaths / second'], + ['pulse', 0, 8, 0.01, 'pulse brightness'], + ['pulseWidth', 0.01, 0.6, 0.005, 'pulse width'], + ['pulseSpeed', -4, 4, 0.01, 'pulse traversals / s'], + ['retract', 0, 1, 0.01, 'retract depth'] + ], + Motes: [ + ['moteRate', 0, 300, 1, 'motes / second'], + ['moteSize', 0.005, 0.3, 0.005, 'mote size'], + ['moteSpeed', 0, 4, 0.01, 'mote speed (m/s)'], + ['moteLifetime', 0.2, 10, 0.05, 'mote lifetime (s)'], + ['moteRise', -2, 2, 0.01, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['moteSpread', 0, 2, 0.01, 'born across × extent'], + ['colorMote*', 'Mote colour'] + ], + 'The lock': [ + ['shockRadius', 0.2, 16, 0.05, 'ring radius (m)'], + ['shockLife', 0.05, 3, 0.01, 'ring life (s)'], + ['castFlash', 0, 1.5, 0.01, 'cast flash'], + ['lockFlash', 0, 1.5, 0.01, 'lock flash'], + ['lockShake', 0, 2, 0.01, 'lock shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration (s)'], + ['colorShockA', 'ring core'], + ['colorShockB', 'ring rim'], + ['colorCastFlash', 'cast flash'], + ['colorFlash', 'lock flash'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightSway', 0, 1, 0.01, 'sway depth'], + ['lightSwaySpeed', 0, 4, 0.01, 'sways / second'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/pistondrive.js b/src/config/abilities/pistondrive.js new file mode 100644 index 0000000..f116494 --- /dev/null +++ b/src/config/abilities/pistondrive.js @@ -0,0 +1,478 @@ +/* ================================================================== */ +/* PISTON DRIVE — forge */ +/* ================================================================== */ +/** + * A battery of machined hydraulic pistons bolted into the floor along the + * aimed line. They dwell. They slam. They dwell at the top, ring once, and + * drop. The whole row runs off one shaft, and each station is keyed a little + * further round it, so the strike travels down the lane as a wave. + * + * ## The cam curve is the ability, and it lives in this block + * + * "The cam" here is not a metaphor for an easing function. It is a real + * dwell–rise–dwell–return cam specified the way a cam is actually specified: + * as **four angular shares of one shaft turn**, plus a motion law for each of + * the two moving segments. + * + * ``` + * lift + * 1 | ╭────────────╮ + * | ╱ ╲ + * | ╱ ╲ + * 0 |─────────╯ ╰────── + * └──────────┬──┬──────────────┬──┬───→ shaft angle, one full turn + * camLow camRise camHigh camFall + * ``` + * + * The four shares are **normalised at use**, not here, so each one is an + * independent slider: pushing `camRise` up shortens everything else + * proportionally rather than forcing you to rebalance the other three by hand. + * Ship values give a 6% rise — about 22° of shaft — which is what makes it a + * *snap* rather than a lift, and that ratio is the single number to reach for + * if the row starts reading as rising rock. + * + * `camSnap` and `camDrop` choose the motion law inside the rise and the return. + * Both blend between two textbook cam laws, and both satisfy the boundary + * conditions a real follower needs (zero velocity at each end, so the follower + * never leaves the cam): + * + * - **cycloidal**, `s = τ − sin(2πτ)/2π` — the jerk-free one. At 0 you get a + * high-speed cam that would not shake a machine apart, and a piston that + * looks like it is being politely raised. + * - **constant acceleration**, the parabolic law — hard, with an acceleration + * step at the midpoint you can genuinely see. At 1 the row hits. + * + * `camRing` is what happens *after* the rise: a real follower on a return + * spring overshoots and rings, and it rings in **seconds** rather than in + * shaft angle, which is why it has its own rate and decay rather than being + * folded into `camHigh`. + * + * ## What is a metre and what is not + * + * `pistonHeight`, `stroke`, `railOffset` and everything in "The ports" are + * metres and are resolved every frame. Everything in "The piston's shape" is a + * **proportion of the piston's own height** — `HardSurface` shapes carry no + * metres at all — and moves real geometry through a `ShapeCache`. And + * everything in "The cam curve" is either a fraction of a turn or a + * dimensionless law parameter, which is why dragging any of it re-poses a row + * that is already standing, with the clock stopped. + */ +export const pistondrive = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 26.0, // how fast the arming front runs down the line, metres/second + lifetime: 2.8, // seconds the battery keeps cycling once the front has arrived + fadeTime: 0.9, // seconds the row takes to withdraw + cooldown: 1.3, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the battery --- */ + pistonCount: 12, // stations along the line (capped at 16) + pistonHeight: 1.55, // metres, base to crown, of one piston + stroke: 1.15, // metres the crown stands proud of the floor at full lift + railOffset: 0.62, // metres either side of the cast line — the two rails + pistonSplay: 0.1, // radians each rail leans outward + pistonYaw: 0.0, // radians of fixed yaw on every station + sizeJitter: 0.06, // ±fraction of the height; a machine is nearly, not quite, uniform + armFeather: 0.05, // fraction of the line a station takes to come live + pistonShadow: true, // do the pistons cast real shadows + + /* --- the piston's shape (proportions of its own height) --- */ + pistonLength: 2.45, // × head diameter, base to crown — the dominant dimension + pistonSegments: 22, // facets round the axis + baseRadius: 0.47, // fractions of the head diameter throughout + baseHeight: 0.17, + baseChamfer: 0.05, + rodRadius: 0.18, + collarAt: 0.44, // 0..1 up the length + collarRadius: 0.31, + collarHeight: 0.14, + collarChamfer: 0.045, + headAt: 0.68, // 0..1 up the length, where the head begins + headRadius: 0.5, + headChamfer: 0.07, + rings: 3, // machined grooves round the head — the detail that says "engine" + ringDepth: 0.035, + ringHeight: 0.05, + faceRecess: 0.06, // dished crown; 0 for a flat punch + pistonCrease: 26.0, // degrees; above this a joint shades hard and counts as an edge + + /* --- the cam curve: four control points and two motion laws --- */ + camRate: 1.15, // shaft turns per second — one full cycle every 0.87 s + camPhase: 0.0, // turns of offset on the whole shaft + camStagger: 0.085, // turns each station is keyed behind the one before it + camLow: 0.4, // share of one turn spent dwelling down + /** + * ... rising, and THIS is the snap. + * + * At the ship values it is 47° of shaft and 0.113 s, which puts a 1.15 m + * stroke through about seven frames at 60 fps. That number was arrived at + * from the other end: 0.06 (22°, 44 ms, under three frames) is more + * faithful to a real pneumatic ram and on screen the piston simply *is* + * somewhere else — there is no motion blur here, so a genuine snap with + * nothing between the endpoints reads as a pop-in rather than as force. + * Seven frames is the fewest that still reads as travel. + */ + camRise: 0.13, + camHigh: 0.29, // ... dwelling at the top + camFall: 0.18, // ... returning + camSnap: 0.88, // 0 cycloidal (jerk-free), 1 constant acceleration (hard) + camDrop: 0.6, // the same blend for the return + camRing: 0.11, // fraction of the stroke the follower overshoots by + camRingRate: 7.5, // ring frequency, hertz + camRingDecay: 9.0, // ring decay, 1/seconds + strikeAt: 0.55, // lift above which a station counts as having struck + + /* --- the steel --- */ + colorMetal: '#98a0a8', // clean steel + colorDeep: '#363b41', // the bottom of a pit + colorScale: '#282420', // mill scale, the blue-black oxide off the forge + colorPolish: '#e9f0f8', // a worn edge, where the guide has been + colorSpec: '#fff0da', // the anisotropic highlight's own colour + steelRough: 0.31, + steelMetalness: 0.94, + steelEnv: 1.0, + brushAniso: 0.84, // 0 round highlight, 1 fully smeared along the grain + brushSpecular: 1.7, // gain on the anisotropic lobe + brushGrain: 0.58, // how hard the brushing cuts into roughness + brushGrainScale: 110.0, // grain frequency, cycles per unit of local space + brushGrainStretch: 30.0, // how far a streak runs along the brush direction + millScale: 0.24, + millScalePatch: 6.5, + millScaleSharp: 0.58, + steelPit: 0.3, + steelPitScale: 58.0, + steelWear: 0.78, + steelWearGrain: 0.36, + + /* --- how hot a station runs --- */ + /** + * The row's heat is **per station**, carried on the `aHeat` instanced + * attribute the `HardSurface` material reads as an *offset* on `uHeat`. It + * is derived from the cam rather than from a clock: a station is coldest + * during its low dwell, comes up across the rise, and bleeds off through the + * top dwell on a real exponential in seconds. Nothing is remembered between + * frames, which is why dragging `camRate` while paused re-heats the whole + * battery in the right pattern rather than the last one. + */ + pistonHeatIdle: 0.1, // 0..1 heat a station carries at rest + pistonHeatDrive: 0.4, // 0..1 extra heat at the moment of the strike + heatBleed: 2.6, // 1/seconds the strike heat decays at + heatCold: 300.0, // kelvin at heat = 0 — a cold workshop + heatHot: 2000.0, // kelvin at heat = 1 + heatRef: 1250.0, // kelvin at which the emission term reaches 1 + heatExponent: 4.0, // Stefan-Boltzmann; 4 is the physical value + heatGlow: 2.3, // gain on the emission + heatTint: 0.78, // how far the albedo washes toward the hot colour + heatEdge: 0.24, // how much cooler an edge reads + + /* --- the deck plate each station comes up through --- */ + deckSize: 1.05, // metres across + deckLift: 0.04, // metres the seated plate sits below the floor line — a recess + deckWidth: 1.0, // the two in-plane extents, relative to each other + deckDepth: 0.86, + deckThickness: 0.1, // unit lengths + deckCorner: 0.13, // fraction of the short side, corner radius + deckBevel: 0.03, // unit lengths, 45° break round the whole outline + deckBolts: 4, // 0, 2, 4 or 6 — laid out on the corners + deckBoltRadius: 0.06, // fraction of the short side + deckBoltInset: 0.17, // fraction of the short side, in from each corner + deckSink: 0.032, // fraction of the short side, countersink flare + colorDeckMetal: '#7d848c', + colorDeckDeep: '#2c3035', + colorDeckScale: '#221f1c', + colorDeckPolish: '#cfd8e2', + colorDeckSpec: '#ffeed6', + deckRough: 0.44, + deckMetalness: 0.88, + deckScale: 0.44, // mill scale coverage on the deck, 0..1 + deckPit: 0.46, + deckHeat: 0.04, // 0..1 — a deck plate does not glow, and this says so + + /* --- the ports in the floor (GroundField, POCK) --- */ + portHeight: 0.015, // metres the quad floats above the floor + portDepth: 0.2, // metres of bowl round a port + portLift: 0.09, // metres the lip stands proud + portRim: 0.16, // metres the lip spreads over + portGrain: 0.5, // 0..1 fine grain in the bowl + portDig: 3.5, // how fast a port opens once it is posted, 1/seconds + portLife: 9.0, // seconds a port weathers away over + portRadius: 0.72, // metres, radius of a full-strength port + portEdge: 0.6, // metres of feather on the field's own front + portRelief: 0.85, // how hard the height field tilts the fake normal + portNormalStep: 0.05, // metres between the height taps + portAmbient: 0.3, // floor on the diffuse term + portWrap: 0.45, // 0..1 wraps the terminator round the back + portSpecular: 0.5, + portGloss: 24.0, // Blinn exponent + portParallax: 0.2, // metres of view-driven offset on the interior + portEmissive: 1.4, // multiplier on the glowing term + portOpacity: 1.0, + portDepthFade: 0.5, // metres of soft fade against standing geometry + colorPortBase: '#6e675e', // the stone round the port + colorPortEdge: '#b4a998', // the lip + colorPortGlow: '#ff9a3c', // heat still in a fresh port + colorPortDeep: '#141210', // the bowl + + /* --- what a strike throws --- */ + strikeDust: 9, // smoke puffs per strike + strikeChips: 7, // chips per strike + strikeSparks: 12, // sparks sheared off the port lip per strike + ventRate: 26.0, // continuous dust off the whole battery, particles/second + dustSize: 0.7, + dustSpeed: 2.4, + dustLifetime: 1.8, + dustOpacity: 0.13, + dustRise: 0.85, + colorDustA: '#7a7168', + colorDustB: '#5e564e', + colorDustC: '#413b36', + colorDustD: '#2a2724', + chipSize: 0.08, + chipSpeed: 6.5, + chipLifetime: 1.2, + chipGravity: -22.0, + chipSpin: 14.0, // radians/second of tumble + colorChipA: '#8b8175', + colorChipB: '#5c554c', + colorChipC: '#3e3833', + colorChipD: '#292522', + sparkSize: 0.11, + sparkSpeed: 8.5, + sparkLifetime: 0.42, + sparkGravity: -19.0, + sparkStretch: 0.26, + sparkGlow: 1.4, + sparkHeat: 0.88, // 0..1 on the same ramp as the pistons — the tint's temperature + sparkTemper: 0.8, // 0..1 how far that tint overrules the gradient + colorSparkA: '#fffaf0', + colorSparkB: '#ffce8a', + colorSparkC: '#ff7a24', + colorSparkD: '#5c1704', + + /* --- the drive, the shake and the light --- */ + strikeShake: 0.16, // per-strike camera kick + shakeDuration: 0.4, + strikeFlash: 0.0, // screen flash per strike; 0 by default, twelve of these is a strobe + colorFlash: '#ffc788', + castShake: 0.5, // the kick as the battery seats + castFlash: 0.1, + colorCastFlash: '#ffd8a8', + seatSize: 1.6, // the shell as the battery seats, metres + seatIntensity: 1.3, + colorSeatA: '#3d4148', + colorSeatB: '#ffa447', + colorSeatC: '#fff0d4', + rumble: 0.045, // continuous shake while the battery is cycling + lightIntensity: 15.0, + lightRadius: 12.0, + lightColor: '#ff9d4c', + lightPulse: 0.35, // how far the light drops between strikes + lightPulseRate: 2.0 // pulses/second, nominally the shaft rate +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Piston Drive. + * + * Open **The cam curve** first and nothing else. Those seven controls are the + * ability: `camRise` against `camLow`/`camHigh` decides whether this is a + * machine or a hillside, `camSnap` decides how hard the hit lands inside that, + * `camStagger` turns the row from a chorus into a wave, and `camRing` is the + * difference between a piston that stops and a piston that *arrives*. + * + * Two combinations worth trying before touching anything else. Set `camRise` + * to 0.4 and `camSnap` to 0 — that is the smooth ease the roster warns about, + * and it is instructive how completely the row stops being machinery. Then set + * `camStagger` to 0 and watch twelve stations fire as one, which is a + * different and much less interesting effect. + */ +export const pistondriveSchema = { + 'The cast': [ + ['range', 3, 50, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 2, 120, 0.5, 'arming speed'], + ['lifetime', 0.1, 8, 0.05, 'cycling time'], + ['fadeTime', 0.05, 4, 0.01, 'withdraw time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The cam curve': [ + ['camRate', 0.05, 8, 0.01, 'shaft turns / sec'], + ['camPhase', 0, 1, 0.005, 'shaft phase'], + ['camStagger', -0.5, 0.5, 0.005, 'keying per station'], + ['camLow', 0, 2, 0.005, 'dwell down'], + ['camRise', 0.005, 1, 0.005, 'rise (the snap)'], + ['camHigh', 0, 2, 0.005, 'dwell up'], + ['camFall', 0.005, 1, 0.005, 'return'], + ['camSnap', 0, 1, 0.01, 'rise law: soft → hard'], + ['camDrop', 0, 1, 0.01, 'return law: soft → hard'], + ['camRing', 0, 0.6, 0.005, 'overshoot'], + ['camRingRate', 0.5, 30, 0.1, 'ring frequency'], + ['camRingDecay', 0.5, 40, 0.1, 'ring decay'], + ['strikeAt', 0.05, 0.95, 0.01, 'strike threshold'] + ], + 'The battery': [ + ['pistonCount', 1, 16, 1, 'stations'], + ['pistonHeight', 0.3, 5, 0.01, 'piston height'], + ['stroke', 0.05, 4, 0.01, 'stroke'], + ['railOffset', 0, 4, 0.01, 'rail offset'], + ['pistonSplay', -0.8, 0.8, 0.005, 'rail splay'], + ['pistonYaw', -3.15, 3.15, 0.01, 'yaw'], + ['sizeJitter', 0, 0.6, 0.005, 'size jitter'], + ['armFeather', 0.005, 0.4, 0.005, 'arming feather'], + ['pistonShadow', 'casts shadows'] + ], + "The piston's shape": [ + ['pistonLength', 1, 5, 0.01, 'length / head Ø'], + ['pistonSegments', 6, 48, 1, 'facets'], + ['baseRadius', 0.1, 1, 0.005, 'base radius'], + ['baseHeight', 0.02, 0.6, 0.005, 'base height'], + ['baseChamfer', 0.001, 0.2, 0.001, 'base chamfer'], + ['rodRadius', 0.03, 0.5, 0.005, 'rod radius'], + ['collarAt', 0.05, 0.95, 0.005, 'collar position'], + ['collarRadius', 0.05, 0.8, 0.005, 'collar radius'], + ['collarHeight', 0.01, 0.5, 0.005, 'collar height'], + ['collarChamfer', 0.001, 0.2, 0.001, 'collar chamfer'], + ['headAt', 0.1, 0.98, 0.005, 'head start'], + ['headRadius', 0.1, 0.7, 0.005, 'head radius'], + ['headChamfer', 0.001, 0.25, 0.001, 'head chamfer'], + ['rings', 0, 8, 1, 'head grooves'], + ['ringDepth', 0.002, 0.15, 0.001, 'groove depth'], + ['ringHeight', 0.005, 0.2, 0.001, 'groove height'], + ['faceRecess', 0, 0.3, 0.005, 'crown dish'], + ['pistonCrease', 5, 70, 1, 'crease angle (deg)'] + ], + 'The steel': [ + ['colorMetal', 'clean steel'], + ['colorDeep', 'pit bottom'], + ['colorScale', 'mill scale'], + ['colorPolish', 'worn edge'], + ['colorSpec', 'highlight'], + ['steelRough', 0.02, 1, 0.01, 'roughness'], + ['steelMetalness', 0, 1, 0.01, 'metalness'], + ['steelEnv', 0, 3, 0.01, 'probe gain'], + ['brushAniso', 0, 1, 0.01, 'anisotropy'], + ['brushSpecular', 0, 6, 0.01, 'lobe gain'], + ['brushGrain', 0, 2, 0.01, 'grain depth'], + ['brushGrainScale', 5, 400, 1, 'grain frequency'], + ['brushGrainStretch', 1, 120, 0.5, 'streak length'], + ['millScale', 0, 1, 0.01, 'scale coverage'], + ['millScalePatch', 0.5, 30, 0.1, 'scale patch size'], + ['millScaleSharp', 0, 1, 0.01, 'flake edge'], + ['steelPit', 0, 1, 0.01, 'pitting'], + ['steelPitScale', 5, 200, 1, 'pit frequency'], + ['steelWear', 0, 1, 0.01, 'edge wear'], + ['steelWearGrain', 0, 1, 0.01, 'wear break-up'] + ], + Heat: [ + ['pistonHeatIdle', 0, 1, 0.005, 'idle heat'], + ['pistonHeatDrive', 0, 1, 0.005, 'strike heat'], + ['heatBleed', 0.1, 20, 0.05, 'heat bleed-off'], + ['heatCold', 200, 1200, 5, 'cold end (K)'], + ['heatHot', 800, 3000, 10, 'hot end (K)'], + ['heatRef', 400, 2500, 10, 'emission reference (K)'], + ['heatExponent', 1, 6, 0.05, 'emission exponent'], + ['heatGlow', 0, 8, 0.01, 'emission gain'], + ['heatTint', 0, 1, 0.01, 'albedo wash'], + ['heatEdge', 0, 1, 0.01, 'edge cooling'] + ], + 'The deck plates': [ + ['deckSize', 0.1, 4, 0.01, 'plate size'], + ['deckLift', 0, 0.5, 0.005, 'plate height'], + ['deckWidth', 0.2, 2, 0.01, 'width'], + ['deckDepth', 0.1, 2, 0.01, 'depth'], + ['deckThickness', 0.01, 0.6, 0.005, 'thickness'], + ['deckCorner', 0.01, 0.5, 0.005, 'corner radius'], + ['deckBevel', 0.002, 0.2, 0.002, 'bevel'], + ['deckBolts', 0, 6, 2, 'bolts'], + ['deckBoltRadius', 0.01, 0.2, 0.002, 'bolt radius'], + ['deckBoltInset', 0.05, 0.45, 0.005, 'bolt inset'], + ['deckSink', 0, 0.2, 0.002, 'countersink'], + ['colorDeckMetal', 'deck metal'], + ['colorDeckDeep', 'deck pit'], + ['colorDeckScale', 'deck scale'], + ['colorDeckPolish', 'deck polish'], + ['colorDeckSpec', 'deck highlight'], + ['deckRough', 0.02, 1, 0.01, 'roughness'], + ['deckMetalness', 0, 1, 0.01, 'metalness'], + ['deckScale', 0, 1, 0.01, 'scale coverage'], + ['deckPit', 0, 1, 0.01, 'pitting'], + ['deckHeat', 0, 1, 0.005, 'heat'] + ], + 'The ports': [ + ['portHeight', 0, 0.3, 0.005, 'quad height'], + ['portDepth', 0, 1.5, 0.005, 'bowl depth'], + ['portLift', 0, 0.6, 0.005, 'lip height'], + ['portRim', 0.01, 0.8, 0.005, 'lip spread'], + ['portGrain', 0, 1, 0.01, 'grain'], + ['portDig', 0.2, 20, 0.1, 'open rate'], + ['portLife', 0.5, 30, 0.1, 'port lifetime'], + ['portRadius', 0.05, 3, 0.01, 'port radius'], + ['portEdge', 0.02, 3, 0.01, 'field feather'], + ['portRelief', 0, 3, 0.01, 'relief'], + ['portNormalStep', 0.005, 0.4, 0.005, 'normal step'], + ['portAmbient', 0, 1, 0.01, 'ambient floor'], + ['portWrap', 0, 1, 0.01, 'terminator wrap'], + ['portSpecular', 0, 3, 0.01, 'specular'], + ['portGloss', 1, 120, 1, 'gloss'], + ['portParallax', 0, 1.5, 0.01, 'parallax'], + ['portEmissive', 0, 6, 0.01, 'emissive'], + ['portOpacity', 0, 2, 0.01, 'opacity'], + ['portDepthFade', 0, 3, 0.01, 'soft intersection'], + ['colorPortBase', 'stone'], + ['colorPortEdge', 'lip'], + ['colorPortGlow', 'heat'], + ['colorPortDeep', 'bowl'] + ], + 'Dust, chips & sparks': [ + ['strikeDust', 0, 60, 1, 'dust per strike'], + ['strikeChips', 0, 60, 1, 'chips per strike'], + ['strikeSparks', 0, 120, 1, 'sparks per strike'], + ['ventRate', 0, 400, 1, 'continuous dust'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 12, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['chipSize', 0.005, 0.4, 0.005, 'chip size'], + ['chipSpeed', 0, 25, 0.1, 'chip speed'], + ['chipLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['chipGravity', -60, 0, 0.1, 'chip gravity'], + ['chipSpin', 0, 40, 0.5, 'chip tumble'], + ['sparkSize', 0.005, 0.6, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 3, 0.01, 'spark lifetime'], + ['sparkGravity', -60, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['sparkGlow', 0, 6, 0.01, 'spark glow'], + ['sparkHeat', 0, 1, 0.005, 'spark temperature'], + ['sparkTemper', 0, 1, 0.01, 'blackbody override'], + ['colorDust*', 'Dust colour'], + ['colorChip*', 'Chip colour'], + ['colorSpark*', 'Spark colour'] + ], + 'Drive, shake & light': [ + ['strikeShake', 0, 2, 0.005, 'shake per strike'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration'], + ['strikeFlash', 0, 1, 0.005, 'flash per strike'], + ['colorFlash', 'strike flash colour'], + ['castShake', 0, 3, 0.01, 'seating shake'], + ['castFlash', 0, 2, 0.01, 'seating flash'], + ['colorCastFlash', 'seating flash colour'], + ['seatSize', 0.1, 10, 0.05, 'seating shell'], + ['seatIntensity', 0, 5, 0.01, 'seating intensity'], + ['colorSeatA', 'seat shell'], + ['colorSeatB', 'seat body'], + ['colorSeatC', 'seat core'], + ['rumble', 0, 0.5, 0.005, 'drive rumble'], + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'light pulse depth'], + ['lightPulseRate', 0.1, 20, 0.1, 'light pulse rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/plaguebloom.js b/src/config/abilities/plaguebloom.js new file mode 100644 index 0000000..aaf99b8 --- /dev/null +++ b/src/config/abilities/plaguebloom.js @@ -0,0 +1,357 @@ +/* ================================================================== */ +/* PLAGUEBLOOM — Plague Bloom, the blood school's far cast */ +/* ================================================================== */ +/** + * A boiling cloud of gas standing over a circle of blistered ground. + * + * **The trick is the sync.** The cloud is a `Medium.GAS_BOIL` volume, whose + * density is driven by a cellular field of bubbles that inflate and pop on + * *individual* timers, so the cloud has visible internal events rather than + * drifting noise. The floor underneath is a `GroundMode.PUSTULE` field, whose + * blisters inflate and burst on individual timers too — and the two lattices + * are deliberately **the same lattice**: + * + * - `gasBoilScale` is *cells per metre*. It sets the pitch of the bubbles in + * the cloud, and the ability hands `1 / gasBoilScale` to the floor as its + * cell size in metres. One slider, two fields, one pitch. + * - `gasBoilRate` is *pops per second*. It is the base rate of the cloud's + * cell clocks and it is handed to the floor as its `speed`. Both modules + * multiply it by `global.noiseSpeed`, so the two stay locked even when the + * whole sandbox is slowed down. + * - both fields take the cast's one dice roll as their `seed`, and both are + * sampled in **world space** off `frame.uTime` — which is why the floor + * quad is never yawed with the cast. Yawing it would slide the floor + * lattice out of register with the cloud lattice above it, and the whole + * read of "that blister burst and *that* bubble popped" would go with it. + * + * This is the one place in the sandbox where two values are shared rather than + * authored twice, and it is the `snare.zoneRadius` exemption in I5: the + * sharing *is* the design. Give the floor its own pitch slider and within + * about ten seconds of dragging it the two fields are strangers. + * + * The beats are `seep → boil → burst`: + * + * 1. **seep** (travel) — a wet stain opens on the floor at the target ahead + * of the front, and the first breath of gas creeps up out of it. + * 2. **boil** (impact, long) — the dome inflates, the blisters come up, and + * the cloud sits there having events in it. + * 3. **burst** (fade, slow) — the footprint is eaten back from its edge, the + * dome climbs and spreads as it thins, and the last blisters vent. + * + * Nothing here is captured at cast time. The cast rolls one seed and notes one + * timestamp per frame; every metre, second and colour below is re-read inside + * the update loop, on a zero-length frame included. + */ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +export const plaguebloom = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + zoneRadius: 5.0, // the footprint — what the circle indicator measures out + speed: 24.0, // how fast the seep front crosses to the point, metres/second + lifetime: 6.0, // seconds the bloom stands and boils + fadeTime: 3.6, // seconds it takes to disperse + cooldown: 1.8, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the cloud, as a body in the world --- */ + // Placement only. Everything about what the gas *is* lives in the `gas*` + // block below, which `VolumeHull` owns. + cloudSpread: 1.08, // dome radius as a multiple of `zoneRadius` + cloudHeight: 3.1, // metres from the floor to the crown of the dome + cloudLift: 0.0, // metres the dome's floor sits above the ground + seepDensity: 0.26, // 0..1 how much cloud there is during the travel beat + riseTime: 1.15, // seconds the dome takes to inflate once the cast lands + disperseSpread: 0.6, // extra dome radius over the disperse, × `zoneRadius` + disperseLift: 1.6, // metres the dome climbs as it lets go + disperseCurve: 1.7, // >1 holds the cloud, then drops it late + + ...volumeHullDefaults('gas', Medium.GAS_BOIL, { + // 32 steps against a dome that fills perhaps a quarter of a 1080p frame is + // 520k × 32 × (1 + 1 tap) = 33M samples — over the 20M budget on paper and + // fine in practice because this cast draws three meshes in total and there + // is nothing else on screen competing for fill. Drop `global.volumeQuality` + // if it ever shares a frame with Pyroclasm. + gasSteps: 32, + gasMargin: 0.2, // the boil eats into the silhouette; it needs the headroom + gasHeightBias: 0.55, // heavier at the floor: this is gas pooling, not a mushroom + gasRound: 0.55, + gasRise: 0.35, // barely buoyant — the point is that it hangs + gasFlatten: 0.35, // pancake the eddies so it spreads rather than billows + gasBoilRate: 0.5, // pops per second — SHARED with the floor's blisters + gasBoilScale: 1.7, // cells per metre — SHARED with the floor's blisters + gasBoilSize: 0.76, + gasBoilDepth: 1.05, // let the bubbles genuinely eat the cloud + gasBoilFlash: 2.6 + }), + + /* --- the floor: the blisters --- */ + /** + * `GroundMode.PUSTULE`, world-aligned, sharing the cloud's pitch and clock. + * There is deliberately no `pustuleCell` and no `pustuleSpeed` here — see the + * header. Everything else about how a blister is *shaped* is its own slider. + */ + pustuleHeight: 0.024, // metres the quad floats above the floor + pustuleJitter: 0.9, // 0..1 lattice disorder + pustuleSeam: 0.06, // metres of skin between neighbouring blisters + pustuleLift: 0.16, // metres a taut blister stands proud + pustuleDepth: 0.13, // metres of crater it leaves when it goes + pustuleSharp: 0.45, // 0..1 profile hardness + pustuleDetail: 0.75, // 0..1 grain on the skin + pustuleEdge: 0.5, // metres of feather on the growth front + pustuleRagged: 0.26, // how far the front wanders, as a fraction of the radius + pustuleRaggedScale: 0.6, // lobes per metre + pustuleWarp: 0.55, // metres of domain warp on those lobes + pustuleRelief: 0.85, // how hard the height field tilts the fake normal + pustuleNormalStep: 0.05, // metres between the height taps + pustuleAmbient: 0.3, // floor on the diffuse term + pustuleWrap: 0.5, // 0..1 wraps the terminator round the back + pustuleSpecular: 0.75, // wet skin is shiny; this is most of why it reads as flesh + pustuleGloss: 42, // Blinn exponent + pustuleParallax: 0.2, // metres of view-driven offset on the interior detail + pustuleEmissive: 1.1, // multiplier on the glowing terms + pustuleOpacity: 1.0, + pustuleDepthFade: 0.4, // metres of soft fade where the character stands in it + pustuleColorBase: '#7a8a2a', // the skin + pustuleColorEdge: '#c8d86a', // the taut highlight on a full blister + pustuleColorGlow: '#e8ff9a', // what a burst throws + pustuleColorDeep: '#1c2408', // inside the crater + + /* --- the floor: the stain that seeps out first --- */ + // A `GroundMode.POOL` quad under the blisters, alpha-blended so it genuinely + // darkens the flagstones instead of lighting them. It is the only thing on + // screen during the travel beat. + stainSpread: 1.15, // stain radius as a multiple of `zoneRadius` + stainHeight: 0.012, // metres above the floor — under the blisters + stainCell: 1.3, // ripple frequency, cycles per metre + stainThickness: 0.09, // metres of meniscus at the rim + stainLift: 0.02, // metres of wave height + stainDepth: 0.07, // metres the middle dishes down + stainSharp: 0.35, + stainDetail: 0.5, + stainSpeed: 0.35, // ripples per second + stainFlow: 0.12, // metres/second the surface drifts + stainWindAngle: 0.9, // radians, bearing of that drift + stainEdge: 0.55, // metres of feather on the front + stainRagged: 0.34, // fraction of the radius the rim wanders by + stainRaggedScale: 0.45, // lobes per metre + stainWarp: 0.7, // metres of domain warp + stainRelief: 0.35, + stainNormalStep: 0.07, // metres between the height taps + stainAmbient: 0.42, + stainWrap: 0.55, // 0..1 wraps the terminator round the back + stainSpecular: 0.9, + stainGloss: 60, + stainParallax: 0.12, // metres of view-driven offset + stainDepthFade: 0.45, // metres of soft fade against standing geometry + stainOpacity: 0.85, + stainEmissive: 0.5, + stainRecede: 0.55, // 0..1 how much of the stain the disperse eats back + stainColorBase: '#4a5a18', // the liquid + stainColorEdge: '#a8c04a', // the meniscus and the sheen + stainColorGlow: '#9aa83a', // the faint luminescence off the deep + stainColorDeep: '#141a06', // the middle, where it has pooled + + /* --- the vents: what a bursting blister throws --- */ + /** + * The CPU walks the *same* lattice the floor shader draws, with the same two + * hashes, and fires these where a blister crosses its burst phase. That is + * the sync made visible: the puff leaves the ground on the frame the blister + * on the ground opens, and the bubble directly above it is popping on the + * same clock. + */ + ventShare: 0.3, // 0..1 fraction of blisters big enough to vent at all + ventPuffs: 3, // gas particles a vent releases + ventSpatter: 4, // droplets a vent throws + ventLight: 0.9, // how hard a vent punches the dynamic light + ventRadius: 0.22, // metres the vent's emission is spread over + + /* --- vent gas --- */ + puffSize: 0.85, + puffSpeed: 1.05, // metres/second it leaves the blister at + puffLifetime: 2.4, // seconds + puffRise: 0.7, // buoyancy, metres/second² + puffOpacity: 0.16, + puffTurbulence: 0.55, + colorPuffA: '#c8d86a', + colorPuffB: '#9aa83a', + colorPuffC: '#5a6a20', + colorPuffD: '#232a0c', + + /* --- spatter --- */ + spatterSize: 0.05, + spatterSpeed: 3.2, // metres/second + spatterLifetime: 1.2, // seconds + spatterGravity: -12.0, // metres/second² + colorSpatterA: '#d8e87a', + colorSpatterB: '#8a9a2a', + colorSpatterC: '#4a5a18', + colorSpatterD: '#2a3210', + + /* --- spores: the slow motes drifting up out of the cloud --- */ + sporeRate: 55, // particles/second over the whole footprint + sporeSize: 0.055, + sporeSpeed: 0.45, // metres/second + sporeLifetime: 3.2, // seconds + sporeRise: 0.55, // metres/second of drift + sporeTurbulence: 0.85, + sporeSpread: 0.9, // 0..1 of the footprint radius they are born across + sporeCeiling: 1.25, // × `cloudHeight`, how high they are seeded + colorSporeA: '#e8ff9a', + colorSporeB: '#c8d86a', + colorSporeC: '#7a8a2a', + colorSporeD: '#2a3a0c', + + /* --- dynamic light --- */ + lightIntensity: 9.5, + lightRadius: 12.0, + lightColor: '#a8c04a', + lightHeight: 1.4, // metres above the footprint the light hangs at + lightBreath: 0.22, // depth of the slow swell under the cloud + lightBreathSpeed: 0.7 // swells per second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Plague Bloom. + * + * The two controls that carry the ability are in **The cloud · boil**: + * `gasBoilRate` and `gasBoilScale`. They are the only sliders in the sandbox + * that drive two separate shaders on purpose — drag either with the clock + * stopped and the blisters on the floor re-pitch and re-time along with the + * bubbles in the cloud. After that, `pustuleLift` and `pustuleSpecular` decide + * whether the floor reads as flesh or as gravel, and `gasBoilDepth` decides how + * much of the cloud the bubbles are allowed to eat. + */ +export const plaguebloomSchema = { + 'The cast': [ + ['range', 4, 40, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['zoneRadius', 1, 12, 0.05, 'footprint radius'], + ['speed', 4, 90, 0.5, 'seep speed'], + ['lifetime', 0.5, 16, 0.05, 'hold'], + ['fadeTime', 0.2, 10, 0.05, 'disperse time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The cloud · placement': [ + ['cloudSpread', 0.3, 2.5, 0.01, 'dome radius × zone'], + ['cloudHeight', 0.4, 10, 0.05, 'dome height (m)'], + ['cloudLift', -1, 3, 0.01, 'dome floor (m)'], + ['seepDensity', 0, 1, 0.01, 'gas during the seep'], + ['riseTime', 0.05, 5, 0.01, 'inflate time (s)'], + ['disperseSpread', 0, 3, 0.01, 'spread on disperse × zone'], + ['disperseLift', 0, 8, 0.05, 'climb on disperse (m)'], + ['disperseCurve', 0.2, 5, 0.01, 'disperse curve'] + ], + ...volumeHullSchema('gas', { + label: 'The cloud', + only: ['march', 'shape', 'field', 'flow', 'optics', 'boil', 'colour'] + }), + 'The blisters': [ + ['pustuleHeight', 0, 0.2, 0.002, 'quad height (m)'], + ['pustuleJitter', 0, 1, 0.01, 'lattice disorder'], + ['pustuleSeam', 0.001, 0.4, 0.002, 'skin between (m)'], + ['pustuleLift', 0, 0.8, 0.005, 'blister height (m)'], + ['pustuleDepth', 0, 0.8, 0.005, 'crater depth (m)'], + ['pustuleSharp', 0, 1, 0.01, 'profile hardness'], + ['pustuleDetail', 0, 1, 0.01, 'skin grain'] + ], + 'The blisters/Front & light': [ + ['pustuleEdge', 0.01, 3, 0.01, 'front feather (m)'], + ['pustuleRagged', 0, 1, 0.01, 'front wander'], + ['pustuleRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['pustuleWarp', 0, 3, 0.01, 'domain warp (m)'], + ['pustuleRelief', 0, 3, 0.01, 'relief'], + ['pustuleNormalStep', 0.005, 0.3, 0.005, 'normal step (m)'], + ['pustuleAmbient', 0, 1, 0.01, 'ambient'], + ['pustuleWrap', 0, 1, 0.01, 'terminator wrap'], + ['pustuleSpecular', 0, 3, 0.01, 'wet sheen'], + ['pustuleGloss', 1, 120, 1, 'gloss'], + ['pustuleParallax', 0, 1.5, 0.01, 'parallax (m)'], + ['pustuleEmissive', 0, 4, 0.01, 'emissive'], + ['pustuleOpacity', 0, 1, 0.01, 'opacity'], + ['pustuleDepthFade', 0.01, 2, 0.01, 'soft intersection (m)'], + ['pustuleColorBase', 'skin'], + ['pustuleColorEdge', 'taut highlight'], + ['pustuleColorGlow', 'burst glow'], + ['pustuleColorDeep', 'crater interior'] + ], + 'The stain': [ + ['stainSpread', 0.2, 2.5, 0.01, 'stain radius × zone'], + ['stainHeight', 0, 0.2, 0.002, 'quad height (m)'], + ['stainCell', 0.05, 6, 0.01, 'ripple cycles / m'], + ['stainThickness', 0.001, 0.6, 0.002, 'meniscus (m)'], + ['stainLift', 0, 0.4, 0.002, 'wave height (m)'], + ['stainDepth', 0, 0.8, 0.005, 'body depth (m)'], + ['stainSharp', 0, 1, 0.01, 'profile hardness'], + ['stainDetail', 0, 1, 0.01, 'grain'], + ['stainSpeed', 0, 4, 0.01, 'ripples / second'], + ['stainFlow', 0, 2, 0.01, 'drift (m/s)'], + ['stainWindAngle', -3.15, 3.15, 0.01, 'drift bearing (rad)'], + ['stainEdge', 0.01, 3, 0.01, 'front feather (m)'], + ['stainRagged', 0, 1, 0.01, 'rim wander'], + ['stainRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['stainWarp', 0, 3, 0.01, 'domain warp (m)'], + ['stainRelief', 0, 3, 0.01, 'relief'], + ['stainNormalStep', 0.005, 0.3, 0.005, 'normal step (m)'], + ['stainAmbient', 0, 1, 0.01, 'ambient'], + ['stainWrap', 0, 1, 0.01, 'terminator wrap'], + ['stainSpecular', 0, 3, 0.01, 'sheen'], + ['stainGloss', 1, 160, 1, 'gloss'], + ['stainParallax', 0, 1.5, 0.01, 'parallax (m)'], + ['stainDepthFade', 0.01, 2, 0.01, 'soft intersection (m)'], + ['stainOpacity', 0, 1, 0.01, 'opacity'], + ['stainEmissive', 0, 3, 0.01, 'emissive'], + ['stainRecede', 0, 1, 0.01, 'eaten back on disperse'], + ['stainColorBase', 'liquid'], + ['stainColorEdge', 'meniscus & sheen'], + ['stainColorGlow', 'luminescence'], + ['stainColorDeep', 'the deep'] + ], + 'The vents': [ + ['ventShare', 0, 1, 0.01, 'blisters that vent'], + ['ventPuffs', 0, 12, 1, 'gas per vent'], + ['ventSpatter', 0, 20, 1, 'droplets per vent'], + ['ventLight', 0, 6, 0.01, 'light punch'], + ['ventRadius', 0.01, 1.5, 0.01, 'vent radius (m)'] + ], + 'Vent gas': [ + ['puffSize', 0.05, 4, 0.01, 'puff size'], + ['puffSpeed', 0, 8, 0.01, 'puff speed (m/s)'], + ['puffLifetime', 0.1, 8, 0.05, 'puff lifetime (s)'], + ['puffRise', -2, 4, 0.01, 'puff rise'], + ['puffOpacity', 0, 1, 0.005, 'puff opacity'], + ['puffTurbulence', 0, 3, 0.01, 'puff turbulence'], + ['colorPuff*', 'Puff colour'] + ], + Spatter: [ + ['spatterSize', 0.005, 0.4, 0.005, 'droplet size'], + ['spatterSpeed', 0, 20, 0.1, 'droplet speed (m/s)'], + ['spatterLifetime', 0.1, 5, 0.05, 'droplet lifetime (s)'], + ['spatterGravity', -40, 0, 0.1, 'gravity'], + ['colorSpatter*', 'Spatter colour'] + ], + Spores: [ + ['sporeRate', 0, 400, 1, 'spores / second'], + ['sporeSize', 0.005, 0.4, 0.005, 'spore size'], + ['sporeSpeed', 0, 6, 0.01, 'spore speed (m/s)'], + ['sporeLifetime', 0.2, 10, 0.05, 'spore lifetime (s)'], + ['sporeRise', -2, 4, 0.01, 'spore rise'], + ['sporeTurbulence', 0, 3, 0.01, 'spore turbulence'], + ['sporeSpread', 0, 1.5, 0.01, 'seeded across × radius'], + ['sporeCeiling', 0, 3, 0.01, 'seeded up to × height'], + ['colorSpore*', 'Spore colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 6, 0.05, 'light height (m)'], + ['lightBreath', 0, 1, 0.01, 'breath depth'], + ['lightBreathSpeed', 0, 6, 0.01, 'breaths / second'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/prismlance.js b/src/config/abilities/prismlance.js new file mode 100644 index 0000000..03ef92d --- /dev/null +++ b/src/config/abilities/prismlance.js @@ -0,0 +1,671 @@ +/* ================================================================== */ +/* PRISM LANCE — arcane, line */ +/* ================================================================== */ +/** + * White light in, spectrum out. + * + * A lance of white light is wound up in the hand and let out along the aimed + * line — but it does not reach the target. Part-way down the span a refracting + * solid is hanging in the air, and the lance ends *there*. What continues is a + * fan of coloured child beams that leave the prism at slightly different + * angles, bow apart, and converge again exactly where the white lance was + * pointed. Then the solid cracks, and the fan whitens as it dies. + * + * Three of the numbers below carry the whole read and are the ones to reach + * for first: + * + * - `prismAt` — where along the span the solid floats. Drag it while the cast + * is standing and the split point slides; the lance and the fan re-cut + * against each other because neither of them owns a length. + * - `fanSpread` — how far the children bow off the axis at mid-span. + * - `fanDispersion` — the *ordering*. It ramps the throw from the first child + * to the last, so the fan opens like a spectrum rather than like six beams + * pointing six ways. At 1 every child bends the same amount and the fan is a + * tube; at 0 the whole fan collapses onto the axis, which is what a prism + * with no dispersion in it should do. + * + * The white lance is a `vfx/Tube` under the `lance` prefix, so all seventy-nine + * of its keys are sliders here for free. The prism and the fan are bespoke — + * `materials/PrismMaterial.js` — because neither a refracting solid nor a + * per-instance-hue beam fan exists in the library, and both are the point. + */ +export const prismlance = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + // Slower than the Storm Lance on purpose. At 105 m/s the split happens 40 ms + // before the impact and the fan never exists as a separate beat; at 46 the + // white half and the coloured half each get about a fifth of a second, which + // is the least you can show someone and have them see two things. + speed: 46.0, // how fast the front travels once it is released, metres/second + charge: 0.42, // seconds the lance winds up in the hand before it goes + lifetime: 0.7, // seconds the fan holds after it lands + fadeTime: 0.85, // seconds the prism cracks and the fan whitens out over + cooldown: 0.8, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the lance leaves the caster --- */ + handHeight: 1.3, // metres above the floor + handForward: 0.6, // metres in front of the caster + handSide: 0.18, // metres to the side (+ follows `Ability#side`) + endHeight: 0.5, // metres above the floor where the fan converges + + /* --- the charge --- */ + chargeStub: 0.22, // fraction of the way to the prism the wind-up glow creeps + chargeSwell: 0.55, // how much narrower the lance is at the start of the charge, × + chargeShake: 0.02, // continuous camera shake while it winds up + + /* --- the solid --- */ + // A triangular bipyramid: three faces up, three down, no smooth normals + // anywhere on it. `prismRadius` is the equator, `prismLength` the half-height, + // so a tall thin prism is `length` up and `radius` down. + prismAt: 0.46, // where the solid floats, fraction of the span + prismHeight: 1.45, // metres above the floor + prismRadius: 0.38, // equator radius, metres + prismLength: 0.62, // apex half-height, metres + prismSpin: 0.35, // revolutions/second about its own axis + prismTilt: 0.34, // radians it leans out of vertical + prismBob: 0.09, // metres it rises and falls by + prismBobSpeed: 0.7, // Hz of that bob + prismSwell: 1.18, // how much bigger it gets while the lance is in it, × + prismIor: 1.62, // index of refraction of the body + prismDispersion: 0.055, // how far the red and blue indices sit either side of it + prismFresnelPower: 2.6, // how tight the grazing-angle rim is + prismFresnelScale: 1.15, // how strong it is + prismEnvIntensity: 1.0, // gain on the environment probe + prismEnvMix: 0.62, // 0 = the two sky pickers only, 1 = the equirect only + prismFacet: 0.55, // strength of the interior veining + prismFacetScale: 5.5, // veining features per metre of the solid's own space + prismGlow: 1.5, // emissive gain into bloom + prismOpacity: 0.34, // how solid it reads away from the rim + prismLoadGlow: 1.9, // how hard it blazes while the lance is striking it + prismCrackTime: 0.55, // seconds the fracture takes to eat the whole solid + prismCrackScale: 5.0, // fracture cells per metre + prismCrackEdge: 0.14, // width of the glowing lip of a fracture + prismCrackGlow: 2.6, // brightness of that lip + colorPrismBody: '#d7e4ff', // what the refracted room is tinted with + colorPrismRim: '#ffffff', // the grazing-angle rim + colorPrismFire: '#ffd6ff', // the interior veins, the load glow and the cracks + colorPrismSkyUp: '#5a6b96', // fallback room, upward — used when no equirect is bound + colorPrismSkyDown: '#0a0a14', // fallback room, downward + + /* --- the fan of children --- */ + children: 6, // child beams (capped at 8; six is the spectrum) + fanSpread: 0.95, // metres the fan bows off the axis at mid-span + fanDispersion: 2.4, // throw of the last child ÷ the first — the spectral order + fanBow: 1.0, // where the bulge sits; >1 pushes it toward the target + fanBowCurve: 1.0, // >1 tightens the bulge into a shorter arc + fanSpin: 0.1, // revolutions/second the whole fan rolls about the axis + fanWave: 0.1, // travelling ripple on the throw, × the throw + fanWaveScale: 2.2, // ripples along the span + fanWaveSpeed: 1.0, // Hz they travel at + fanWidth: 0.16, // half-width of one child's ribbon at the prism, metres + fanWidthTip: 0.62, // that width at the target, as a fraction + fanCoreSharp: 2.8, // how hard the hot core falls off across the ribbon + fanCoreWidth: 0.32, // fraction of the ribbon the core occupies + fanHaloFalloff: 2.6, // how fast the halo fades across the rest of it + fanHaloOpacity: 0.55, + fanTipGlow: 1.5, // extra heat on the leading edge while it extends + fanTipLength: 0.07, // length of that leading edge, fraction of the span + fanFlicker: 0.1, // depth of the per-child brightness blink + fanFlickerSpeed: 22, // blinks/second + fanGlow: 2.2, // emissive gain + fanOpacity: 1.0, + fanSoftFade: 0.5, // metres of soft fade where a child meets geometry + fanCollapse: 1.6, // >1 holds the colour late and whitens all at once + colorChild1: '#ff4d5e', // the spectrum, spelled out. Eight pickers for eight + colorChild2: '#ff9a3c', // possible children; `children` decides how many draw + colorChild3: '#ffe066', + colorChild4: '#69e07a', + colorChild5: '#4fd6ff', + colorChild6: '#7a7bff', + colorChild7: '#c46bff', + colorChild8: '#ff6be0', + colorFanCore: '#ffffff', // the hot centre line every child shares + colorCollapse: '#ffffff', // what the fan whitens back to as it dies + + /* --- the prism chips (vfx/ShatterField) --- */ + chipCount: 96, // fragments thrown when the solid lets go + chipScatter: 0.24, // metres of spawn scatter about the solid + chipSpeed: 4.6, // metres/second + chipSpeedJitter: 0.7, // ± fraction of that + chipSpread: 0.7, // 0 throws every chip downrange, 1 is fully random + chipUpBias: 0.35, // how much +Y is folded into the throw + chipGravity: -14.0, // metres/second² + chipDrag: 1.1, // 1/second + chipSize: 0.17, // metres + chipSizeJitter: 0.65, // ± fraction + chipShrink: 0.75, // fraction of its size a chip loses by the end of life + chipShrinkPower: 1.7, // how late that shrink bites + chipSpin: 12.0, // radians/second of tumble + chipSpinJitter: 0.8, // ± fraction + chipLifetime: 1.5, // seconds a chip lives + chipFloorSpin: 0.2, // fraction of the tumble kept once it is on the floor + chipGlow: 1.5, // emissive gain on a chip + chipRim: 0.9, // strength of its rim term + chipRimPower: 2.2, // how tight that rim is + chipOpacity: 0.9, + chipFadeStart: 0.55, // fraction of its life before it starts fading + chipShade: 0.9, // how much the fake lighting darkens a facing-away face + chipAmbient: 0.4, // floor under that shading + colorChipA: '#ffffff', // a chip's lit face + colorChipB: '#8fa8ff', // its shaded face + colorChipEdge: '#ffd6ff', // its rim + colorChipScene: '#c0d0ff', // what it tints the scene sample with, when there is one + + /* --- spectral motes, sparks and dust --- */ + /** + * Each system is coloured by a four-stop gradient over the particle's own + * lifetime, `A` at birth through `D` as it dies. Spelled out per system + * rather than derived from the beam palette, so the motes can be made to cool + * to violet while the sparks stay white. + */ + moteRate: 110, // motes shed by the solid, particles/second + moteSize: 0.06, + moteSpeed: 1.3, + moteLifetime: 1.5, + moteRise: 0.5, // upward drift, metres/second + moteTurbulence: 0.6, + colorMoteA: '#ffffff', + colorMoteB: '#d9c0ff', + colorMoteC: '#7a7bff', + colorMoteD: '#1a1240', + sparkSize: 0.14, + sparkSpeed: 8.0, + sparkLifetime: 0.55, + sparkGravity: -11.0, + sparkStretch: 0.2, // how far a spark smears along its velocity + splitSparks: 70, // sparks thrown at the moment the lance enters the prism + burstSparks: 150, // ... and at the impact + crackSparks: 90, // ... and when the solid cracks + colorSparkA: '#ffffff', + colorSparkB: '#ffe9ff', + colorSparkC: '#9f8bff', + colorSparkD: '#241a52', + dustSize: 1.0, + dustSpeed: 1.2, + dustLifetime: 2.0, + dustRise: 0.5, + dustOpacity: 0.07, + burstDust: 40, // puffs kicked up where the fan converges + colorDustA: '#4a4a68', + colorDustB: '#3a3a52', + colorDustC: '#2e2e42', + colorDustD: '#1a1a26', + + /* --- what the ground does --- */ + scorchRadius: 0.7, // pale burn under the convergence point, metres + scorchLife: 5.5, // seconds it lingers + scorchIntensity: 0.4, + shockRadius: 6.0, // impact shockwave ring, metres + colorScorch: '#141020', + colorEmber: '#b08aff', + colorShockA: '#e3d0ff', // body of the shockwave ring + colorShockB: '#ffffff', // its crest + + /* --- the muzzle, the split and the impact --- */ + muzzleSize: 0.5, // the flash at the hand as the lance leaves it, metres + muzzleIntensity: 1.7, + castFlash: 0.09, // screen flash on release + colorMuzzleA: '#b9c8ff', + colorMuzzleB: '#e8f0ff', + colorMuzzleC: '#ffffff', + colorCastFlash: '#e8f0ff', + splitSize: 1.5, // the shell that pops off the solid as the lance enters, metres + splitIntensity: 1.8, + splitFlash: 0.14, // screen flash at the split + colorSplitA: '#ff9ae0', + colorSplitB: '#9fd0ff', + colorSplitC: '#ffffff', + burstSize: 2.6, // the shell where the fan converges, metres + burstIntensity: 1.5, + impactShake: 0.6, + shakeDuration: 0.5, + impactFlash: 0.22, + rumble: 0.02, // continuous shake while the front travels + colorBurstA: '#c58aff', + colorBurstB: '#9fe0ff', + colorBurstC: '#ffffff', + colorFlash: '#efe0ff', // the full-screen flash on impact + + /* --- dynamic light --- */ + lightIntensity: 22, + lightRadius: 15, + lightColor: '#cbb4ff', + lightPulse: 0.18, // depth of the light's breathing, 0 = steady + lightPulseSpeed: 2.4, // Hz + + /* --- the white lance (vfx/Tube, prefix `lance`) --- */ + /** + * `Tube` reads its numbers by prefixed key off this very block, so these + * seventy-nine are the tube's whole contract and every one of them is a + * slider for free. + * + * They are spelled out rather than spread from `tubeDefaults('lance')` on + * purpose, and the reason is in the header of `config/abilities/index.js`: a + * settings module must not import anything beyond a constant. `vfx/Tube.js` + * imports `config/settings.js` for its `settings.global` default argument, + * and importing it from here closes a cycle that leaves `settings.js` + * spreading an `ABILITY_SETTINGS` that has not been initialised yet — which + * fails at load with a `ReferenceError`, not at review. (`vfx/VolumeHull.js` + * imports no such thing, which is why Pyroclasm's block may spread its + * defaults and this one may not.) + * + * The list mirrors `TUBE_FIELDS` in `vfx/Tube.js` and must stay in step with + * it; `Tube` names any key it cannot find, once, on the console. The + * WHIP/FUNNEL/VINE/ARC groups are here because `sync()` resolves every field + * whatever the path is — this lance is STRAIGHT and reads none of them. + * + * Overridden away from the tube's defaults on three counts: it is thin, it is + * white, and it does not flare. A lance that opens out at the far end reads + * as arriving somewhere, and this one is supposed to be interrupted. + */ + + /* --- the radius profile (STRAIGHT / WHIP / ARC) --- */ + lanceRadius: 0.3, // half-width at the far end, metres + lanceRadiusNear: 0.14, // half-width at the muzzle, metres + lanceRadiusCurve: 0.85, // <1 opens early, >1 stays thin then opens late + lanceFlare: 0, // extra half-width where it lands, × radius + lanceFlareWidth: 0.12, // how much of the far end flares, fraction of length + lanceThrob: 0.04, // breathing amplitude, × radius + lanceThrobScale: 2.4, // pressure waves along the column, cycles per length + lanceThrobSpeed: 1.6, // Hz they travel at + + /* --- the axis --- */ + lanceWander: 0.03, // smooth low-frequency drift of the axis, metres + lanceWanderScale: 0.9, // drift features per length + lanceWanderSpeed: 0.7, // Hz the drift crawls at + + /* --- the surface --- */ + lanceRipple: 0.07, // radial break-up of the barrel, × radius + lanceRippleBands: 1.6, // break-up features around the barrel + lanceRippleScale: 3.2, // break-up features along it + lanceRippleSpeed: 2.4, // Hz it crawls downrange at + lanceStreak: 0.7, // filaments streaming down the surface + lanceStreakSharp: 0.45, // 0 = soft wash, 1 = hard threads + lanceStreakScale: 7, // filament features per length + lanceStreakBands: 2.6, // filament features around the barrel + lanceStreakGlow: 1.1, // how hard the sheath's filaments burn to core colour + lanceFlowSpeed: 9, // metres-of-parameter per second the filaments run + lanceBands: 0.0, // rings along the length, cycles per length (0 = off) + lanceBandSharp: 2.0, // how tight each ring is + lanceBandDepth: 0.5, // how much they modulate alpha, 0..1 + lanceBandSpeed: 0.6, // Hz they travel at + + /* --- the three layers --- */ + lanceCoreWidth: 0.42, // core radius, × the profile + lanceCoreFill: 0.95, // how solid the core reads + lanceCoreSharp: 1.4, // axis-weighting exponent — the inversion + lanceEdgePower: 2.2, // rim-weighting exponent for the sheath + lanceSheathWidth: 1.0, // sheath radius, × the profile + lanceSheathRim: 0.9, // strength of the sheath's silhouette + lanceSheathFill: 0.18, // how much body the sheath keeps + lanceSheathOpacity: 0.8, + lanceHaloWidth: 2.1, // halo radius, × the profile + lanceHaloRim: 3.4, // rim exponent — high, so it is only a silhouette + lanceHaloOpacity: 0.42, + + /* --- the ends --- */ + lanceMuzzleGlow: 2, // brightness where the column leaves the caster + lanceMuzzleLength: 0.1, // how far that glow reaches, fraction of length + lanceTipGlow: 2.2, // brightness of the leading edge + lanceTipLength: 0.05, // how soft that edge is, fraction of length + + /* --- WHIP --- */ + lanceWaveRate: 1.35, // loops per second travelling handle → tip + lanceWaveWidth: 0.16, // how tight the loop is, fraction of length + lanceWaveAmp: 0.3, // lateral throw of the loop, fraction of length + lanceWaveGain: 2.2, // how much the loop grows on its way to the tip, × + lanceWaveCurve: 1.6, // when that growth happens, >1 = late + lanceWaveRoll: 0.0, // plane the loop cracks in, radians (0 = vertical) + lanceSag: 0.12, // how far the whip hangs under its own weight, metres + lanceCrackRatio: 1.0, // tip speed ÷ wave speed at which the crack fires + + /* --- FUNNEL --- */ + lanceThroat: 0.55, // the vortex waist, metres + lanceSkirtFlare: 1.6, // extra radius at the floor, metres + lanceSkirtHeight: 0.24, // how far up the skirt reaches, fraction of height + lanceSkirtCurve: 1.7, // how abruptly it flares, >1 = tighter to the floor + lanceMouthFlare: 2.4, // extra radius at the top, metres + lanceMouthStart: 0.55, // where the mouth begins to open, fraction of height + lanceMouthCurve: 1.4, // how abruptly it opens + lanceSpin: 0.9, // revolutions per second the surface rotates + lanceSpinTwist: 1.6, // extra revolutions from floor to mouth + lanceSway: 0.35, // how far the axis precesses, metres + lanceSwayScale: 0.5, // twist of the precession along the height + lanceSwaySpeed: 0.25, // revolutions per second it precesses + lanceSwayCurve: 1.8, // how much of the sway is at the top, >1 = only the top + + /* --- VINE --- */ + lanceTipTaper: 1.3, // how fast the radius falls to zero at the front + lanceMeander: 0.18, // helical wander of the stem, metres + lanceMeanderTurns: 1.4, // turns of that helix over the length + lanceRecoilAmp: 0.35, // how far the spring pulls the tip back, fraction + lanceRecoilFreq: 2.6, // Hz the spring rings at + lanceRecoilDamp: 3.4, // s⁻¹ it dies at + lanceRecoilSway: 0.6, // lateral bow while it is recoiling, metres + + /* --- ARC --- */ + lanceArcHeight: 2.6, // apex height above the chord, metres + lanceArcLateral: 0.0, // apex offset across the chord, metres + lanceArcBias: 0.5, // where the apex sits, 0..1 along the chord + lanceArcCurve: 1.0, // >1 pinches the apex, <1 flattens the top + + /* --- rendering --- */ + lanceOpacity: 1.0, + lanceGlow: 2.4, // emissive gain into bloom + lanceSoftFade: 0.6, // metres of depth fade against the opaque scene + + /* --- colour (I5: four pickers, none derived from another) --- */ + lanceColorCore: '#ffffff', // the axis-weighted middle + lanceColorInner: '#f2f4ff', // just off the middle + lanceColorOuter: '#c8d4ff', // the sheath body + lanceColorHalo: '#6070c8' // the outer bloom +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ + +/** + * Prism Lance. + * + * The folders are ordered the way the cast happens: the hand, then the solid, + * then what leaves it, then what it breaks into. Everything in `The solid` and + * `The fan` is read by a shader on the frame it changes, so both folders + * reshape a cast that is already standing — pause with **P** part-way down the + * line and drag `prismAt` to watch the split slide. + */ +export const prismlanceSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 300, 1, 'lance speed'], + ['charge', 0, 3, 0.01, 'wind-up'], + ['lifetime', 0.05, 6, 0.01, 'hold after landing'], + ['fadeTime', 0.05, 4, 0.01, 'collapse time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where it leaves the hand': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['endHeight', 0, 4, 0.01, 'height at target'], + ['chargeStub', 0, 1, 0.01, 'wind-up creep'], + ['chargeSwell', 0.05, 1, 0.01, 'wind-up width'], + ['chargeShake', 0, 0.3, 0.005, 'wind-up shake'] + ], + 'The solid': [ + ['prismAt', 0.05, 0.95, 0.01, 'position along span'], + ['prismHeight', 0.2, 5, 0.01, 'height (m)'], + ['prismRadius', 0.05, 2, 0.01, 'equator radius (m)'], + ['prismLength', 0.05, 3, 0.01, 'apex half-height (m)'], + ['prismSpin', -3, 3, 0.01, 'spin (rev/s)'], + ['prismTilt', -1.6, 1.6, 0.01, 'lean (rad)'], + ['prismBob', 0, 1, 0.005, 'bob (m)'], + ['prismBobSpeed', 0, 4, 0.01, 'bob Hz'], + ['prismSwell', 0.5, 3, 0.01, 'swell under load'] + ], + 'The solid/Refraction': [ + ['prismIor', 1.01, 3, 0.005, 'index of refraction'], + ['prismDispersion', 0, 0.4, 0.001, 'dispersion'], + ['prismFresnelPower', 0.2, 8, 0.01, 'rim power'], + ['prismFresnelScale', 0, 4, 0.01, 'rim strength'], + ['prismEnvIntensity', 0, 4, 0.01, 'probe gain'], + ['prismEnvMix', 0, 1, 0.01, 'probe vs sky'], + ['prismFacet', 0, 3, 0.01, 'interior veins'], + ['prismFacetScale', 0.5, 24, 0.1, 'vein scale'], + ['prismGlow', 0, 6, 0.01, 'glow'], + ['prismOpacity', 0, 1, 0.01, 'body opacity'], + ['prismLoadGlow', 0, 6, 0.01, 'load glow'] + ], + 'The solid/The crack': [ + ['prismCrackTime', 0.05, 3, 0.01, 'crack duration'], + ['prismCrackScale', 0.5, 24, 0.1, 'crack cells / m'], + ['prismCrackEdge', 0.01, 0.6, 0.005, 'crack lip width'], + ['prismCrackGlow', 0, 8, 0.01, 'crack lip glow'] + ], + 'The solid/Colour': [ + 'colorPrismBody', + 'colorPrismRim', + 'colorPrismFire', + 'colorPrismSkyUp', + 'colorPrismSkyDown' + ], + 'The fan': [ + ['children', 1, 8, 1, 'child beams'], + ['fanSpread', 0, 6, 0.01, 'bow off axis (m)'], + ['fanDispersion', 0, 6, 0.01, 'dispersion order'], + ['fanBow', 0.2, 3, 0.01, 'bulge position'], + ['fanBowCurve', 0.2, 4, 0.01, 'bulge tightness'], + ['fanSpin', -3, 3, 0.01, 'fan roll (rev/s)'], + ['fanWave', 0, 1, 0.01, 'ripple'], + ['fanWaveScale', 0, 10, 0.05, 'ripples / span'], + ['fanWaveSpeed', -6, 6, 0.01, 'ripple Hz'] + ], + 'The fan/The ribbon': [ + ['fanWidth', 0.005, 1, 0.005, 'width at prism'], + ['fanWidthTip', 0.02, 3, 0.01, 'width at target'], + ['fanCoreSharp', 0.2, 10, 0.05, 'core sharpness'], + ['fanCoreWidth', 0.02, 1, 0.01, 'core width'], + ['fanHaloFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['fanHaloOpacity', 0, 2, 0.01, 'halo opacity'], + ['fanTipGlow', 0, 6, 0.05, 'leading-edge glow'], + ['fanTipLength', 0.005, 0.5, 0.005, 'leading-edge length'], + ['fanFlicker', 0, 1, 0.01, 'child blink'], + ['fanFlickerSpeed', 1, 90, 1, 'blink rate'], + ['fanGlow', 0, 8, 0.01, 'glow'], + ['fanOpacity', 0, 2, 0.01, 'opacity'], + ['fanSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['fanCollapse', 0.2, 5, 0.01, 'whitening curve'] + ], + 'The fan/Spectrum': [ + 'colorChild1', + 'colorChild2', + 'colorChild3', + 'colorChild4', + 'colorChild5', + 'colorChild6', + 'colorChild7', + 'colorChild8', + 'colorFanCore', + 'colorCollapse' + ], + // The tube's own controls, filed under the lance rather than at the top + // level, and authored here rather than spread from `tubeSchema('lance')` for + // the same import reason the block gives above. + 'The white lance/The column': [ + ['lanceRadius', 0.01, 4, 0.01, 'far radius (m)'], + ['lanceRadiusNear', 0.01, 4, 0.01, 'near radius (m)'], + ['lanceRadiusCurve', 0.05, 4, 0.01, 'radius curve'], + ['lanceFlare', 0, 4, 0.01, 'flare'], + ['lanceFlareWidth', 0.01, 1, 0.01, 'flare width'], + ['lanceThrob', 0, 0.5, 0.001, 'throb'], + ['lanceThrobScale', 0, 12, 0.1, 'throb bands'], + ['lanceThrobSpeed', 0, 8, 0.01, 'throb Hz'], + ['lanceWander', 0, 1, 0.001, 'axis drift (m)'], + ['lanceWanderScale', 0, 6, 0.01, 'drift scale'], + ['lanceWanderSpeed', 0, 4, 0.01, 'drift Hz'] + ], + 'The white lance/Core, sheath & halo': [ + ['lanceCoreWidth', 0.02, 2, 0.01, 'core width'], + ['lanceCoreFill', 0, 2, 0.01, 'core fill'], + ['lanceCoreSharp', 0.05, 8, 0.01, 'core axis power'], + ['lanceEdgePower', 0.05, 8, 0.01, 'sheath rim power'], + ['lanceSheathWidth', 0.05, 3, 0.01, 'sheath width'], + ['lanceSheathRim', 0, 2, 0.01, 'sheath rim'], + ['lanceSheathFill', 0, 1, 0.01, 'sheath fill'], + ['lanceSheathOpacity', 0, 1, 0.01, 'sheath opacity'], + ['lanceHaloWidth', 0.05, 6, 0.01, 'halo width'], + ['lanceHaloRim', 0.05, 10, 0.01, 'halo rim power'], + ['lanceHaloOpacity', 0, 1, 0.01, 'halo opacity'] + ], + 'The white lance/The surface': [ + ['lanceRipple', 0, 1, 0.01, 'ripple'], + ['lanceRippleBands', 0, 8, 0.01, 'ripple bands'], + ['lanceRippleScale', 0, 12, 0.01, 'ripple scale'], + ['lanceRippleSpeed', 0, 10, 0.01, 'ripple Hz'], + ['lanceStreak', 0, 2, 0.01, 'streaks'], + ['lanceStreakSharp', 0, 1, 0.01, 'streak sharpness'], + ['lanceStreakScale', 0, 20, 0.1, 'streak scale'], + ['lanceStreakBands', 0, 8, 0.01, 'streak bands'], + ['lanceStreakGlow', 0, 3, 0.01, 'streak glow'], + ['lanceFlowSpeed', 0, 24, 0.1, 'flow speed'], + ['lanceBands', 0, 24, 0.1, 'rings/length'], + ['lanceBandSharp', 0.05, 8, 0.01, 'ring sharpness'], + ['lanceBandDepth', 0, 1, 0.01, 'ring depth'], + ['lanceBandSpeed', -6, 6, 0.01, 'ring Hz'] + ], + 'The white lance/The ends': [ + ['lanceMuzzleGlow', 0, 5, 0.01, 'muzzle glow'], + ['lanceMuzzleLength', 0, 0.6, 0.001, 'muzzle length'], + ['lanceTipGlow', 0, 5, 0.01, 'tip glow'], + ['lanceTipLength', 0.001, 0.4, 0.001, 'tip length'] + ], + 'The white lance/Colour & render': [ + 'lanceColorCore', + 'lanceColorInner', + 'lanceColorOuter', + 'lanceColorHalo', + ['lanceOpacity', 0, 1, 0.01, 'opacity'], + ['lanceGlow', 0, 8, 0.01, 'glow'], + ['lanceSoftFade', 0, 3, 0.01, 'soft fade (m)'] + ], + // `Tube.sync()` resolves every field it owns whatever path it was built for, + // so these exist on the block and drive uniforms a STRAIGHT column never + // reads. Filed, rather than left to fall into "More", so it is obvious they + // are inert here and not merely undocumented. + 'The white lance/Inert on a straight column': [ + ['lanceWaveRate', 0, 6, 0.01, 'whip: loops/second'], + ['lanceWaveWidth', 0.02, 0.6, 0.001, 'whip: loop width'], + ['lanceWaveAmp', 0, 1, 0.001, 'whip: loop throw'], + ['lanceWaveGain', 0.2, 6, 0.01, 'whip: loop gain'], + ['lanceWaveCurve', 0.1, 6, 0.01, 'whip: gain curve'], + ['lanceWaveRoll', 0, 6.29, 0.01, 'whip: crack plane (rad)'], + ['lanceSag', 0, 2, 0.01, 'whip: sag (m)'], + ['lanceCrackRatio', 0.2, 4, 0.01, 'whip: crack ratio'], + ['lanceThroat', 0.02, 4, 0.01, 'funnel: throat (m)'], + ['lanceSkirtFlare', 0, 8, 0.01, 'funnel: skirt flare (m)'], + ['lanceSkirtHeight', 0.01, 1, 0.01, 'funnel: skirt height'], + ['lanceSkirtCurve', 0.1, 6, 0.01, 'funnel: skirt curve'], + ['lanceMouthFlare', 0, 12, 0.01, 'funnel: mouth flare (m)'], + ['lanceMouthStart', 0, 0.99, 0.01, 'funnel: mouth start'], + ['lanceMouthCurve', 0.1, 6, 0.01, 'funnel: mouth curve'], + ['lanceSpin', -6, 6, 0.01, 'funnel: spin (rev/s)'], + ['lanceSpinTwist', -8, 8, 0.01, 'funnel: twist'], + ['lanceSway', 0, 4, 0.01, 'funnel: precession (m)'], + ['lanceSwayScale', 0, 3, 0.01, 'funnel: precession twist'], + ['lanceSwaySpeed', -3, 3, 0.01, 'funnel: precession (rev/s)'], + ['lanceSwayCurve', 0.1, 6, 0.01, 'funnel: precession curve'], + ['lanceTipTaper', 0.05, 6, 0.01, 'vine: tip taper'], + ['lanceMeander', 0, 2, 0.01, 'vine: meander (m)'], + ['lanceMeanderTurns', 0, 8, 0.01, 'vine: meander turns'], + ['lanceRecoilAmp', 0, 1, 0.01, 'vine: recoil'], + ['lanceRecoilFreq', 0, 10, 0.01, 'vine: recoil Hz'], + ['lanceRecoilDamp', 0.1, 16, 0.01, 'vine: recoil damping'], + ['lanceRecoilSway', 0, 4, 0.01, 'vine: recoil bow (m)'], + ['lanceArcHeight', -12, 12, 0.01, 'arc: apex height (m)'], + ['lanceArcLateral', -12, 12, 0.01, 'arc: apex offset (m)'], + ['lanceArcBias', 0.05, 0.95, 0.01, 'arc: apex position'], + ['lanceArcCurve', 0.1, 4, 0.01, 'arc: apex curve'] + ], + 'Prism chips': [ + ['chipCount', 0, 190, 1, 'fragments'], + ['chipScatter', 0, 2, 0.01, 'spawn scatter (m)'], + ['chipSpeed', 0, 30, 0.1, 'throw speed'], + ['chipSpeedJitter', 0, 2, 0.01, 'speed jitter'], + ['chipSpread', 0, 1, 0.01, 'throw spread'], + ['chipUpBias', 0, 2, 0.01, 'upward bias'], + ['chipGravity', -50, 5, 0.1, 'gravity'], + ['chipDrag', 0, 6, 0.01, 'drag'], + ['chipSize', 0.01, 1, 0.005, 'size (m)'], + ['chipSizeJitter', 0, 2, 0.01, 'size jitter'], + ['chipShrink', 0, 1, 0.01, 'shrink'], + ['chipShrinkPower', 0.1, 6, 0.01, 'shrink curve'], + ['chipSpin', 0, 40, 0.1, 'tumble (rad/s)'], + ['chipSpinJitter', 0, 2, 0.01, 'tumble jitter'], + ['chipLifetime', 0.1, 6, 0.05, 'lifetime'], + ['chipFloorSpin', 0, 1, 0.01, 'grounded tumble'] + ], + 'Prism chips/Look': [ + ['chipGlow', 0, 5, 0.01, 'glow'], + ['chipRim', 0, 3, 0.01, 'rim'], + ['chipRimPower', 0.2, 8, 0.05, 'rim power'], + ['chipOpacity', 0, 1, 0.01, 'opacity'], + ['chipFadeStart', 0, 1, 0.01, 'fade start'], + ['chipShade', 0, 2, 0.01, 'shading'], + ['chipAmbient', 0, 2, 0.01, 'ambient'], + 'colorChipA', + 'colorChipB', + 'colorChipEdge', + 'colorChipScene' + ], + 'Motes, sparks & dust': [ + ['moteRate', 0, 600, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 12, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 8, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['splitSparks', 0, 400, 1, 'sparks at the split'], + ['crackSparks', 0, 400, 1, 'sparks at the crack'], + ['burstSparks', 0, 600, 1, 'sparks at the impact'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 8, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['burstDust', 0, 300, 1, 'dust at the impact'], + ['colorMote*', 'Mote colour'], + ['colorSpark*', 'Spark colour'], + ['colorDust*', 'Dust colour'] + ], + 'The ground': [ + ['scorchRadius', 0.05, 4, 0.05, 'scorch radius'], + ['scorchLife', 0.5, 20, 0.1, 'scorch lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + 'colorScorch', + 'colorEmber', + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'Muzzle, split & impact': [ + ['muzzleSize', 0.05, 6, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorMuzzleA', 'muzzle shell'], + ['colorMuzzleB', 'muzzle body'], + ['colorMuzzleC', 'muzzle arcs'], + ['colorCastFlash', 'release flash colour'], + ['splitSize', 0.1, 8, 0.05, 'split shell size'], + ['splitIntensity', 0, 5, 0.01, 'split intensity'], + ['splitFlash', 0, 2, 0.01, 'split screen flash'], + ['colorSplitA', 'split shell'], + ['colorSplitB', 'split body'], + ['colorSplitC', 'split arcs'], + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst arcs'], + ['colorFlash', 'impact flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'light breathing'], + ['lightPulseSpeed', 0, 12, 0.01, 'breathing Hz'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/pyroclasm.js b/src/config/abilities/pyroclasm.js new file mode 100644 index 0000000..e82e012 --- /dev/null +++ b/src/config/abilities/pyroclasm.js @@ -0,0 +1,406 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* PYROCLASM — the ash dome that collapses before it blows */ +/* ================================================================== */ +/** + * A far cast. A raymarched dome of ash forms at the full footprint, **contracts** + * onto its own centre, stands compressed for a beat, and then inverts its + * density and blasts out past the boundary it started from. + * + * The three beats are three radii — `formSpread`, `collapseRadius`, + * `blastSpread` — all expressed as multiples of `zoneRadius`, which is the one + * value the indicator, the hull, the heat column and the floor scour all read. + * That sharing *is* the design (invariant I5's stated exception): dragging + * `zoneRadius` mid-cast moves every one of them together, which is what you + * want, and giving the dome its own metre would give you two numbers that have + * to agree. + * + * ### Why the medium is sampled in world space + * + * `Medium.ASH` evaluates its fbm in `worldDomain()` — world metres, not hull + * space. That is the whole implosion. A hull-local field (which is what + * `Medium.FLAME` uses, deliberately, so a jet carries its own turbulence) + * shrinks its grain along with `uSize`, so a contracting dome renders as the + * *same* cloud getting smaller: a zoom, not a collapse. Sampled in world space + * the grain is nailed to the room, so the shrinking silhouette eats inward + * through a stationary field and you can watch individual clots of ash pass out + * of the volume. Nothing else in the beat sells it. + * + * ### The inversion + * + * `blast*` below are the far ends of five lerps the ability drives with one + * unitless beat. They are authored as endpoints rather than deltas because the + * pair that actually needs watching — `ashNoiseStrength` against `ashMargin` — + * is only legible when both ends are written down. Erosion is quadratic in the + * distance past the medium's nominal surface, so raising it without also giving + * the hull more headroom slices the ash off along a dead straight line at the + * proxy wall. `blastMargin` is above `ashMargin` for exactly that reason; if you + * raise `blastErosion`, you owe `blastMargin`. + */ +export const pyroclasm = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + speed: 44.0, // how fast the ignition front runs out to the footprint, metres/second + zoneRadius: 6.0, // the footprint the circle indicator draws, metres + cooldown: 1.5, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the cast leaves the caster --- */ + handForward: 0.6, // metres in front of the caster the ash coughs out + handHeight: 1.3, // metres above the floor + + /* --- the four beats --- */ + // impactDuration = collapseTime + holdTime; fadeDuration = blastTime + settleTime. + // All four are live, so re-timing the collapse re-times a dome already standing. + collapseTime: 0.62, // seconds the dome takes to contract from full radius + holdTime: 0.34, // seconds it stands compressed before it goes + blastTime: 0.46, // seconds the blast front takes to reach full spread + settleTime: 1.35, // seconds the surge takes to thin out and die + + /* --- the three radii, × zoneRadius --- */ + formSpread: 1.06, // radius the dome forms at + collapseRadius: 0.33, // radius it contracts to + blastSpread: 1.62, // radius it blasts out to, past the boundary + hullSlack: 1.05, // extra proxy-hull headroom on top of the margin compensation + charSeat: 0.42, // how far the floor is charred before the blast front moves + + /* --- the three heights, × zoneRadius --- */ + // The dome stands *up* as it narrows — the same ash in a smaller footprint — + // and runs low and wide once it lets go. That vertical swing is most of what + // reads as pressure. + formHeight: 0.5, // dome height when it forms + compressHeight: 0.98, // ... at full compression + blastHeight: 0.33, // ... at full spread + + /* --- the density inversion (the far end of five lerps) --- */ + blastHeightBias: 1.55, // > 1 drives the field negative near the top: the dome hollows out + blastDensityCurve: 0.45, // < 1 lifts the thin fringe to nearly the weight of the old core + blastErosion: 1.75, // × ashNoiseStrength — throws the field past its own silhouette + blastMargin: 0.46, // headroom inside the hull at full erosion (see the note above) + blastDensity: 0.62, // × ashDensity — the surge is thinner than the compressed dome + blastRise: 2.6, // metres/second the noise domain climbs once it is loose + + /* --- the ash volume (Medium.ASH, prefix `ash`) --- */ + // Steps × (1 + shadow taps) × covered pixels is the cost; a dome filling a + // quarter of 1080p at 34 steps and one tap is about 35 M samples, which is the + // working limit for a hero volume and the reason the step count is not 44. + // The eight `ashBoil*` / `ashVoid*` keys below belong to other media and are + // inert here — see the note above `pyroclasmSchema`. + ...volumeHullDefaults('ash', Medium.ASH, { + ashSteps: 34, // march steps — the cost knob, see above + ashJitter: 1.0, // step dither; 0 only ever shows you the banding + ashContact: 1.1, // metres of fade where the ash meets the floor + ashMargin: 0.24, // headroom inside the hull before the blast + ashHeightBias: 0.3, // density falls this much toward the crown + ashFeather: 0.28, // far-edge feather + ashDensity: 2.3, // density + ashDensityCurve: 1.05, // density curve before the blast + ashSoftness: 0.4, // edge softness + ashNoiseFrequency: 1.5, // features per metre — the grain the collapse reveals + ashNoiseStrength: 0.9, // erosion + ashNoiseWarp: 0.32, // domain warp + ashOctaves: 4, // octaves + ashDetail: 0.75, // fine-octave gain + ashFlowY: -0.45, // world flow Y, metres/second — ash falls + ashRise: 0.15, // buoyant rise, metres/second — nearly nothing while it collapses + ashSwirl: 0.12, // swirl about the vertical, radians/second + ashAbsorption: 3.0, // absorption, 1/metre + ashScatter: 2.0, // scattering + ashAmbient: 0.32, // multi-scatter floor + ashEmission: 1.9, // emission (the embers inside the medium) + ashEmissionCurve: 0.7, // emission by density + ashShadowTaps: 1, // self-shadow taps — ash is absorbing and needs a lit side + ashShadowLength: 0.8, // self-shadow reach, metres + ashSpeckDensity: 0.09, // fraction of lattice cells holding an ember + ashSpeckScale: 3.0, // ember cells per metre + ashSpeckSize: 0.14, // ember size within its cell + ashSpeckGlow: 11.0, // ember brightness + // The neutral default for this one is a cold blue-white, which is right for + // VOID's stars and wrong for embers in a soot cloud. + ashColorSpeck: '#ff8a3a' // the embers inside the medium + }), + + /* --- the heat column standing over it --- */ + // Magnitudes are SCREEN FRACTIONS, not metres, and the post pass applies + // `post.distortion × global.distortion` on top — never multiply those in here. + hazeStrength: 0.42, // screen widths of offset at post.distortion = 1 + hazeWidth: 2.35, // × the ash radius — how wide the column is + hazeHeight: 2.0, // × the ash radius — how tall + hazeLift: 0.05, // metres above the floor the column starts + hazeTravel: 1.6, // × hazeWidth while the front is still running out + hazeFrequency: 0.85, // shimmer cycles per metre + hazeSpeed: 2.4, // metres/second the shimmer climbs + hazeSourceBias: 1.15, // exponent — how hard it favours the base + hazeSpread: 0.95, // how far the column opens out over its height + hazeVertical: 0.42, // how much of the wobble is up/down rather than sideways + hazeFlicker: 0.35, // depth of the second, slower clock + hazePerspective: 0.5, // 0 = a fixed screen fraction, 1 = shrinks with distance + hazePerspectiveRef: 14.0, // metres at which perspective = 1 + hazeDepthReject: 1.0, // how hard opaque geometry in front of the column cuts it, 0..1 + hazeDepthFade: 0.45, // metres over which standing geometry cuts the shimmer + + /* --- the floor, scoured by the front as it passes --- */ + // GroundMode.SCOUR, shaded rather than additive: a burn is darker than the + // stone it is on. `grow` is driven by the blast front's own radius, so the + // mark is *drawn by* the surge rather than spawned whole at impact. + scorchSpread: 1.72, // radius of the scour quad, × zoneRadius + scorchEdge: 0.62, // metres of feather on the growth front + scorchRagged: 0.3, // how far that front wanders, as a fraction of the radius + scorchRaggedScale: 0.55, // lobes per metre + scorchWarp: 0.75, // metres of domain warp on those lobes + scorchDepth: 0.15, // metres — how deep a groove is cut + scorchLift: 0.055, // metres — how high the spoil piles between grooves + scorchArms: 13, // radial grooves; a whole number or the spiral tears at ±π + scorchSwirl: 0.34, // spiral pitch; 0 gives dead-straight spokes + scorchSharp: 0.62, // 0 soft grooves, 1 knife-edged + scorchDetail: 0.7, // fine grain over the scour + scorchTurn: 0.04, // radians/second the whole pattern creeps + scorchRelief: 0.85, // how hard the height field tilts the fake normal + scorchNormalStep: 0.07, // metres between the height taps + scorchAmbient: 0.26, // floor on the diffuse term + scorchWrap: 0.5, // wraps the terminator round the back + scorchSpecular: 0.22, // vitrified ground has a little sheen + scorchGloss: 18.0, // Blinn exponent + scorchParallax: 0.2, // metres of view-driven offset on the groove detail + scorchEmissive: 1.1, // multiplier on every glowing term + scorchOpacity: 0.95, + scorchHeight: 0.02, // metres the quad floats above the floor + scorchDepthFade: 0.42, // metres of soft fade against anything standing in it + colorScorchBase: '#2a211c', // the burnt ground itself + colorScorchEdge: '#6b5344', // the ridges of spoil between the grooves + colorScorchGlow: '#ff7a2a', // the front, and the heat still in the grooves + colorScorchDeep: '#0d0908', // the bottom of a groove + + /* --- embers raining inside the dome --- */ + emberRate: 170, // particles/second + emberSize: 0.075, + emberSpeed: 1.1, // metres/second of initial scatter + emberLifetime: 1.9, // seconds + emberFall: -2.4, // gravity, metres/second² — negative, they rain + emberTurbulence: 0.85, + emberGlow: 2.4, + emberInset: 0.82, // where inside the dome they are born, × the ash radius + emberCeiling: 0.9, // ... and how far up it, × the dome height + emberBurst: 220, // extra embers thrown out on the blast + colorEmberA: '#ffd9a0', + colorEmberB: '#ff7a2a', + colorEmberC: '#a33a10', + colorEmberD: '#3a1408', + + /* --- the ash that gets out of the dome --- */ + pallRate: 66, // particles/second + pallSize: 1.15, + pallSpeed: 1.4, // metres/second + pallLifetime: 2.8, // seconds + pallRise: 0.5, // gravity, metres/second² — a slow lift + pallOpacity: 0.28, + pallBurst: 140, // extra puffs thrown out on the blast + colorPallA: '#5c4a3e', + colorPallB: '#3a2c24', + colorPallC: '#251c17', + colorPallD: '#1a1210', + + /* --- grit kicked off the floor --- */ + gritRate: 26, // particles/second while the dome is compressed + gritSize: 0.06, + gritSpeed: 6.5, // metres/second + gritLifetime: 1.4, // seconds + gritGravity: -19.0, // metres/second² + gritBurst: 110, // extra chips thrown out on the blast + colorGritA: '#6b5344', + colorGritB: '#2a211c', + colorGritC: '#1a1210', + colorGritD: '#1a1210', + + /* --- the punctuation --- */ + burstSize: 3.6, // the shell of hot air at the moment of collapse, metres + burstIntensity: 1.5, + blastBurstSize: 7.5, // ... and at the moment it lets go, metres + blastBurstIntensity: 2.0, + shockRadius: 11.0, // the ring that snaps out across the floor, metres + castFlash: 0.09, // screen flash as the front leaves the caster + collapseFlash: 0.1, // ... as the dome finishes contracting + blastFlash: 0.34, // ... as it goes + impactShake: 0.55, // shake when the dome forms + blastShake: 1.05, // shake when it blows + shakeDuration: 0.7, // seconds + rumble: 0.05, // continuous shake while the dome is compressed + colorBurstA: '#5c2a10', + colorBurstB: '#ff7a2a', + colorBurstC: '#ffd9a0', + colorCastFlash: '#ff7a2a', + colorFlash: '#ffd9a0', // the full-screen flash on the blast + colorShockA: '#5c2a10', // body of the shockwave ring + colorShockB: '#ffd9a0', // its crest + + /* --- dynamic light --- */ + lightIntensity: 24, // the ember glow inside the dome + lightRadius: 18, // metres + lightHeight: 0.45, // where the light sits in the dome, × the dome height + lightColor: '#ff7a2a' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Pyroclasm. + * + * Reach for `collapseRadius` and `blastSpread` first — those two carry the beat + * — and then `ashNoiseFrequency`, which is the size of the grain the collapse + * reveals and therefore the single control that decides whether the implosion + * reads at all. If you see the ash sliced off along a straight line, that is + * `ashNoiseStrength` (or `blastErosion`) against `ashMargin` (or `blastMargin`), + * every time. + * + * The eight `ashBoil*` and one `ashVoid*` keys are not filed here on purpose. + * `volumeHullDefaults` emits the whole vocabulary so the hull's own audit stays + * quiet, but GAS_BOIL's bubbles and VOID's extra occlusion do nothing to an ASH + * medium, and nine inert rows in a folder is worse than a "More" folder nobody + * opens. + */ +export const pyroclasmSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'front speed'], + ['zoneRadius', 1, 16, 0.1, 'footprint radius'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handHeight', 0, 3, 0.01, 'hand height'] + ], + 'The beats': [ + ['collapseTime', 0.05, 3, 0.01, 'contract time'], + ['holdTime', 0, 3, 0.01, 'compressed hold'], + ['blastTime', 0.05, 3, 0.01, 'blast time'], + ['settleTime', 0.1, 6, 0.01, 'settle time'] + ], + 'The dome': [ + ['formSpread', 0.2, 2.5, 0.01, 'form radius ×R'], + ['collapseRadius', 0.05, 1.2, 0.01, 'collapsed radius ×R'], + ['blastSpread', 0.3, 3, 0.01, 'blast radius ×R'], + ['hullSlack', 1, 2, 0.01, 'proxy hull slack'], + ['charSeat', 0.05, 1.5, 0.01, 'char before the blast ×R'], + ['formHeight', 0.05, 2, 0.01, 'form height ×R'], + ['compressHeight', 0.05, 3, 0.01, 'compressed height ×R'], + ['blastHeight', 0.05, 2, 0.01, 'blast height ×R'] + ], + 'The inversion': [ + ['blastHeightBias', 0, 3, 0.01, 'hollow the crown'], + ['blastDensityCurve', 0.1, 3, 0.01, 'fringe lift'], + ['blastErosion', 0.2, 4, 0.01, 'erosion ×'], + ['blastMargin', 0.02, 0.7, 0.01, 'headroom at full erosion'], + ['blastDensity', 0.05, 2, 0.01, 'density ×'], + ['blastRise', -4, 8, 0.01, 'domain rise (m/s)'] + ], + ...volumeHullSchema('ash', { + label: 'Ash', + only: ['march', 'shape', 'field', 'flow', 'optics', 'speck', 'colour'] + }), + 'Heat column': [ + ['hazeStrength', 0, 2, 0.01, 'strength (screen widths)'], + ['hazeWidth', 0.2, 5, 0.01, 'width ×ash radius'], + ['hazeHeight', 0.2, 6, 0.01, 'height ×ash radius'], + ['hazeLift', -1, 3, 0.01, 'base height (m)'], + ['hazeTravel', 0.05, 4, 0.01, 'width while travelling ×'], + ['hazeFrequency', 0.05, 5, 0.01, 'cycles / metre'], + ['hazeSpeed', 0, 10, 0.01, 'climb speed (m/s)'], + ['hazeSourceBias', 0, 5, 0.01, 'base bias'], + ['hazeSpread', 0, 3, 0.01, 'column spread'], + ['hazeVertical', 0, 1, 0.01, 'vertical share'], + ['hazeFlicker', 0, 2, 0.01, 'flicker'], + ['hazePerspective', 0, 1, 0.01, 'perspective'], + ['hazePerspectiveRef', 1, 40, 0.5, 'perspective ref (m)'], + ['hazeDepthReject', 0, 1, 0.01, 'occlusion strength'], + ['hazeDepthFade', 0.05, 3, 0.01, 'occlusion fade (m)'] + ], + 'Floor scour': [ + ['scorchSpread', 0.3, 3, 0.01, 'radius ×R'], + ['scorchEdge', 0.05, 3, 0.01, 'front feather (m)'], + ['scorchRagged', 0, 1, 0.01, 'front wander'], + ['scorchRaggedScale', 0.05, 3, 0.01, 'lobes / metre'], + ['scorchWarp', 0, 3, 0.01, 'lobe warp (m)'], + ['scorchDepth', 0, 1, 0.005, 'groove depth (m)'], + ['scorchLift', 0, 0.5, 0.005, 'spoil height (m)'], + ['scorchArms', 1, 40, 1, 'grooves'], + ['scorchSwirl', -2, 2, 0.01, 'spiral pitch'], + ['scorchSharp', 0, 1, 0.01, 'groove sharpness'], + ['scorchDetail', 0, 1, 0.01, 'grain'], + ['scorchTurn', -2, 2, 0.01, 'rotation (rad/s)'], + ['scorchRelief', 0, 3, 0.01, 'relief'], + ['scorchNormalStep', 0.005, 0.4, 0.005, 'normal step (m)'], + ['scorchAmbient', 0, 1, 0.01, 'ambient'], + ['scorchWrap', 0, 1, 0.01, 'terminator wrap'], + ['scorchSpecular', 0, 2, 0.01, 'specular'], + ['scorchGloss', 1, 90, 1, 'gloss'], + ['scorchParallax', 0, 2, 0.01, 'parallax (m)'], + ['scorchEmissive', 0, 4, 0.01, 'emissive'], + ['scorchOpacity', 0, 1, 0.01, 'opacity'], + ['scorchHeight', 0, 0.3, 0.005, 'float above floor (m)'], + ['scorchDepthFade', 0.05, 3, 0.01, 'soft intersection (m)'], + ['colorScorchBase', 'burnt ground'], + ['colorScorchEdge', 'spoil ridges'], + ['colorScorchGlow', 'heat in the grooves'], + ['colorScorchDeep', 'groove floor'] + ], + 'Embers': [ + ['emberRate', 0, 800, 1, 'ember rate'], + ['emberSize', 0.005, 0.4, 0.005, 'ember size'], + ['emberSpeed', 0, 12, 0.05, 'ember speed'], + ['emberLifetime', 0.1, 8, 0.05, 'ember lifetime'], + ['emberFall', -20, 4, 0.05, 'ember gravity'], + ['emberTurbulence', 0, 3, 0.01, 'ember turbulence'], + ['emberGlow', 0, 8, 0.01, 'ember glow'], + ['emberInset', 0, 1.5, 0.01, 'birth radius ×ash radius'], + ['emberCeiling', 0, 2, 0.01, 'birth height ×dome height'], + ['emberBurst', 0, 800, 1, 'embers on the blast'], + ['colorEmber*', 'Ember colour'] + ], + 'Ash & grit': [ + ['pallRate', 0, 400, 1, 'ash rate'], + ['pallSize', 0.05, 4, 0.01, 'ash size'], + ['pallSpeed', 0, 10, 0.05, 'ash speed'], + ['pallLifetime', 0.2, 10, 0.05, 'ash lifetime'], + ['pallRise', -3, 5, 0.01, 'ash rise'], + ['pallOpacity', 0, 1, 0.005, 'ash opacity'], + ['pallBurst', 0, 600, 1, 'ash on the blast'], + ['colorPall*', 'Ash colour'], + ['gritRate', 0, 300, 1, 'grit rate'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 30, 0.1, 'grit speed'], + ['gritLifetime', 0.1, 5, 0.05, 'grit lifetime'], + ['gritGravity', -50, 0, 0.1, 'grit gravity'], + ['gritBurst', 0, 500, 1, 'grit on the blast'], + ['colorGrit*', 'Grit colour'] + ], + 'Impact & blast': [ + ['burstSize', 0.2, 16, 0.05, 'collapse shell size'], + ['burstIntensity', 0, 5, 0.01, 'collapse shell intensity'], + ['blastBurstSize', 0.2, 24, 0.05, 'blast shell size'], + ['blastBurstIntensity', 0, 6, 0.01, 'blast shell intensity'], + ['shockRadius', 0.5, 40, 0.1, 'shockwave radius'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['collapseFlash', 0, 2, 0.01, 'flash on collapse'], + ['blastFlash', 0, 2, 0.01, 'flash on the blast'], + ['impactShake', 0, 3, 0.01, 'shake on collapse'], + ['blastShake', 0, 4, 0.01, 'shake on the blast'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.5, 0.005, 'compressed rumble'], + ['colorBurstA', 'shell body'], + ['colorBurstB', 'shell mid'], + ['colorBurstC', 'shell arcs'], + ['colorCastFlash', 'release flash colour'], + ['colorFlash', 'blast flash colour'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 60, 0.1, 'light radius'], + ['lightHeight', 0, 1.5, 0.01, 'light height ×dome height'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/quench.js b/src/config/abilities/quench.js new file mode 100644 index 0000000..18c927b --- /dev/null +++ b/src/config/abilities/quench.js @@ -0,0 +1,497 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* QUENCH — forge · far cast */ +/* ================================================================== */ +/** + * White-hot stock is plunged into the aimed circle. It sits there for a moment, + * barely steaming, and then the vapour blanket collapses and the bath *erupts* + * while the metal falls through yellow, orange, cherry and out the bottom into + * black. + * + * ## THE TRICK — the cooling curve is physical, twice over + * + * **The colour** is not authored. `vfx/HardSurface.js` puts the Planckian locus + * in the shader — Kim's cubic fit above 1667 K, a locus-fitted quadratic in + * `1000/T` below it — and the emission is `(T/T_ref)ⁿ` with `n = 4` off + * Stefan-Boltzmann. That is why there is no `colorHot` picker in this block and + * why there must not be one. An authored white→orange gradient passes through a + * yellow that is too saturated and slightly green, because the straight line in + * RGB between white and orange is not the line the locus takes; steel at 1400 K + * is pale straw, not lemon. And because the brightness is `T⁴` rather than a + * fade curve, the metal **stops glowing on its own** — nothing tells it to, the + * exponent does it, and at 620 K it is 0.06 of the reference and effectively + * out. + * + * **The rate** is not authored either, or at least not as one curve. Real + * quenching has three stages and they are wildly different speeds: + * + * | stage | what is happening | speed | + * | --- | --- | --- | + * | vapour blanket | the surface is above the Leidenfrost point and boils a film of steam that *insulates* it | slow (`filmTau`) | + * | nucleate boiling | the film collapses; water touches metal and flashes | very fast (`boilTau`) | + * | convection | below boiling, ordinary heat transfer into the water | slow (`convectTau`) | + * + * That is the whole shape of the ability. The steam is driven by the + * **heat flow** `Σ mass · dT/dt`, not by an envelope, so it is quiet during the + * blanket, enormous the moment the blanket goes, and gone before the metal is. + * The loud part and the colour ramp are the same number seen from two sides, + * which is why it convinces. At the shipped defaults the bath sits at about 0.3 + * of full steam for three-quarters of a second, pins at 0.9 at t ≈ 0.8 s when + * the billets' film collapses, and is back under 0.1 by t ≈ 2.4 s — while the + * metal goes 1750 → 1550 → 1200 → 930 K over the same window. Nobody authored + * that timing; it is `filmTime`, `filmTau` and `boilTau` doing arithmetic. + * + * Each stage is a Newton exponential and the boundaries are solved in closed + * form, so `T(t)` is a pure function of the elapsed time and these sliders. + * Pause mid-quench and drag `boilTau`: the standing billet re-cools, the steam + * re-thickens and the light re-tints, on a zero-length frame. An integrator + * would have made every one of these dead. + * + * ## Mass matters, and that is the read + * + * A part's lumped time constant scales with its volume-to-area ratio, i.e. with + * its characteristic length. `massRef` is the size at which the sliders above + * mean what they say, and every part's τ is scaled by `(size/massRef)^massExponent` + * — so the small off-cuts go black while the big billet is still cherry, off one + * shared material and one shared curve. It is the single detail that stops the + * scene reading as "some objects with an animated emissive". + * + * It has a second consequence nobody designed and everybody likes: the off-cuts + * have short τs, so **their** vapour blankets collapse first, and the bath gets + * a small early flurry of steam a third of a second before the main event. That + * is the maths, not a beat — set `massExponent` to 0 and it goes away along with + * everything else worth looking at. + * + * ## On the word "white" + * + * The top of this ramp is 1750 K, and a 1750 K blackbody is **orange** — its + * blue channel is a thousandth of its red. The white in a foundry photograph is + * the sensor clipping, and that is exactly how it is reproduced here: the + * emission at entry is 3.8× the reference, which after `heatGlow` and the + * material's Reinhard ceiling lands at roughly `(3.6, 1.6, 0)` and tone-maps to + * a saturated yellow-white core with an orange skirt. Pushing `tempStart` up to + * fake a bluer hue is the wrong lever: it makes the metal *hotter*, the T⁴ term + * blows out, and it still is not white. Turn `heatGlow` instead. + */ +export const quench = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 30.0, // how fast the stock falls downrange to the bath, metres/second + zoneRadius: 4.2, // the aimed circle — the bath, metres + lifetime: 3.0, // seconds of quench you get to watch + fadeTime: 2.2, // seconds the last steam takes to go + cooldown: 1.4, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the work --- */ + plateCount: 3, // billets, 1..8 + boltCount: 9, // off-cuts, 0..16 + plateSize: 1.5, // the hero billet's largest extent, metres + plateSizeJitter: 0.35, // 0..1 spread of that size across the billets + boltSize: 0.42, // an off-cut's largest extent, metres + boltSizeJitter: 0.45, // 0..1 spread + scatter: 0.72, // how much of the circle the work is thrown across, 0..1 + entryHeight: 5.5, // metres above the bath the stock starts + entryTilt: 0.5, // radians of random tumble on the way in + restDepth: -0.06, // metres the work settles to; negative is under the surface + plateLean: 0.22, // radians of random lie, so nothing is axis-aligned + + /* --- the billet's profile (unitless proportions; see `plateShape()`) --- */ + plateWidth: 1.0, // the two in-plane extents, relative to each other + plateDepth: 0.42, + plateThickness: 0.16, // unit lengths + plateCorner: 0.14, // fraction of the short side, corner radius + plateBevel: 0.035, // unit lengths, 45° break round the whole outline + plateBolts: 4, // countersunk holes; 0, 2, 4 or 6 + plateBoltRadius: 0.06, // fraction of the short side + plateBoltInset: 0.18, // fraction of the short side, in from each corner + plateCounterSink: 0.032, // radial flare at the face + plateCounterDepth: 0.04, // unit lengths, how deep the flare cuts + plateCrease: 34.0, // degrees above which a joint shades hard + + /* --- the off-cut's profile (see `boltShape()`) --- */ + boltLength: 2.4, // × head width across flats — the dominant dimension + boltHeadHeight: 0.6, // fractions of the head width throughout + boltHeadChamfer: 0.1, + boltWasher: 0.06, + boltWasherRadius: 0.62, + boltShankRadius: 0.3, + boltThreadTurns: 8, // full turns over the threaded length + boltThreadDepth: 0.04, // radial, fraction of the head width + boltThreadFrom: 0.3, // 0..1 up the shank where the thread starts + boltTipTaper: 0.12, // 0..1 of the shank spent tapering to the point + boltCrease: 28.0, // degrees + + /* --- the cooling curve --- */ + // Kelvin, and they are real: 1650 K is white-yellow welding heat, 1450 K is + // forging heat, 1150 K is cherry, 900 K is the first visible red in a dark + // room, and below 750 K there is nothing to see at all. + tempStart: 1750.0, // kelvin the stock enters at — bright orange-white + tempBath: 335.0, // kelvin of the water; everything decays toward it + filmTime: 0.8, // seconds the insulating vapour blanket holds + filmTau: 7.0, // seconds — the slow time constant under the blanket + boilTau: 1.2, // seconds — nucleate boiling, the violent stage + boilEnd: 640.0, // kelvin at which boiling gives out and convection takes over + convectTau: 5.2, // seconds — the slow tail + massRef: 1.5, // metres of part size at which the four times above hold + massExponent: 1.0, // τ ∝ (size/massRef)^this; 1 is the lumped-capacity value + steamRef: 2200.0, // mass-weighted kelvin/second of heat flow at which the steam pins + + /* --- the steel --- */ + // `hardSurfaceParams()` by name. There is deliberately no hot-colour picker + // — see the header. + colorMetal: '#8f959c', // clean steel + colorDeep: '#33383e', // the bottom of a pit + colorScale: '#241f1b', // mill scale, thick on stock that has been in a fire + colorPolish: '#e4ebf3', // a worn edge + colorSpec: '#fff2e2', // the anisotropic highlight's own colour + roughness: 0.42, // forged stock is not a mirror + metalness: 0.92, + envIntensity: 0.85, + brush: 0, // BrushMode: 0 LINEAR, 1 CIRCUMFERENTIAL, 2 RADIAL — rolled stock + anisotropy: 0.6, + specular: 1.3, + grain: 0.45, + grainScale: 72.0, + grainStretch: 18.0, + scale: 0.46, // mill scale coverage — high; this came out of the fire + scaleScale: 5.5, + scaleSharp: 0.62, + pit: 0.3, + pitScale: 48.0, + wear: 0.5, + wearGrain: 0.45, + heatCold: 300.0, // kelvin at heat = 0 — must bracket the curve below + heatHot: 2000.0, // kelvin at heat = 1 — ditto, or the ramp clips + heatRef: 1250.0, // kelvin at which the emission term reaches 1 + heatExponent: 4.0, // Stefan-Boltzmann; 4 is the physical value + heatGlow: 3.2, // gain on the emission + heatTint: 0.85, // how far the albedo washes toward the hot colour + heatEdge: 0.26, // edges run cooler — thin sections radiate faster + + /* --- the steam volume (Medium.SMOKE, prefix `steam`) --- */ + // A dome is the wrong silhouette for a quench and a cylinder is the right + // one: steam off a bath goes *up* in a column with a flat-ish top, it does + // not arch over. The march cost is steps × (1 + shadow taps) × covered + // pixels; two taps is worth it here because a steam column with no lit side + // reads as fog. + ...volumeHullDefaults('steam', Medium.SMOKE, { + steamSteps: 30, // march steps — the cost knob + steamJitter: 1.0, // step dither; 0 only ever shows you the banding + steamContact: 0.5, // metres of fade where the steam meets the water + steamMargin: 0.2, // headroom inside the hull for the erosion + steamHeightBias: -0.55, // NEGATIVE: steam thickens as it climbs, unlike smoke + steamFeather: 0.42, // far-edge feather + steamDensity: 1.5, + steamDensityCurve: 0.85, + steamSoftness: 0.6, // steam has no hard edge anywhere + steamNoiseFrequency: 0.85, // features per metre + steamNoiseStrength: 0.8, // erosion + steamNoiseWarp: 0.4, // domain warp — the roll of a boiling column + steamOctaves: 4, + steamDetail: 0.6, + steamFlowY: 0.0, + steamRise: 2.4, // buoyant rise, metres/second — steam is fast + steamSwirl: 0.35, // swirl about the vertical, radians/second + steamAbsorption: 1.5, // 1/metre — steam is thin, it is not soot + steamScatter: 3.4, // scattering — and very bright + steamAmbient: 0.55, // multi-scatter floor, high: water vapour is white + steamEmission: 0.0, // steam does not emit; the metal below lights it + steamShadowTaps: 2, // self-shadow taps + steamShadowLength: 1.1, // self-shadow reach, metres + steamColorCore: '#ffffff', + steamColorMid: '#e8eef3', + steamColorEdge: '#c6d2dc', + steamColorDeep: '#8e9aa4', + steamColorLight: '#ffd8a8' // the firelight the hot stock throws up into it + }), + steamWidth: 1.15, // the column's half-width, × zoneRadius + steamHeight: 2.6, // its height, metres per unit of zoneRadius + steamLift: 0.02, // metres above the floor the column starts + steamFade: 1.0, // master gain on the volume's opacity + steamCurve: 0.65, // exponent on the boil rate before it drives the volume + + /* --- the bath (GroundField, POOL) --- */ + fieldEdge: 0.55, // metres of feather on the rim + fieldRagged: 0.14, // how far the rim wanders, fraction of the radius + fieldRaggedScale: 0.6, // lobes per metre + fieldWarp: 0.35, // metres of domain warp on those lobes + fieldRelief: 0.55, // how hard the height field tilts the fake normal + fieldCell: 0.9, // metres — the surface's feature size + fieldThickness: 0.06, // metres — the meniscus rim + fieldDepth: 0.4, // metres of apparent depth + fieldSharp: 0.3, // 0..1 — water is soft + fieldDetail: 0.7, // fine chop + fieldFlow: 0.28, // metres/second the surface drifts + fieldSpeed: 0.9, // surface events per second + fieldWindAngle: 0.4, // radians, in the quad's frame + fieldParallax: 0.4, // metres of view-driven offset into the water + fieldSpecular: 0.9, + fieldGloss: 60.0, // Blinn exponent — water is glossy + fieldOpacity: 0.9, + fieldEmissive: 1.0, + colorFieldBase: '#2c3a42', // the water + colorFieldEdge: '#9fc0cc', // the meniscus and the chop highlights + colorFieldGlow: '#ff7a28', // what the hot stock throws back up through it + colorFieldDeep: '#0c1216', // the bottom of the tank + + /* --- steam, sparks and spatter --- */ + /** + * Four-stop lifetime gradients, `A` at birth through `D` as the particle + * dies. The sparks are the exception that proves I5's rule: they are tinted + * *per emission* with `blackbodyColor(T)` of the metal that threw them, on + * top of this gradient, because a flake of scale is the same temperature as + * the billet and hard-coding an orange is how you end up with cherry-red + * steel throwing lemon sparks. + */ + steamRate: 190, // wisps/second at full boil + steamSize: 1.15, + steamSpeed: 2.3, + steamLifetime: 2.4, + steamOpacity: 0.3, + steamRise: 1.9, // upward drift, metres/second + steamTurbulence: 0.8, + colorSteamA: '#ffffff', + colorSteamB: '#eef3f7', + colorSteamC: '#c4d0da', + colorSteamD: '#8c98a2', + sparkRate: 90, // scale flakes popping off, particles/second at full boil + sparkSize: 0.1, + sparkSpeed: 3.6, + sparkLifetime: 0.65, + sparkGravity: -11.0, + sparkStretch: 0.16, + colorSparkA: '#ffffff', + colorSparkB: '#ffd07a', + colorSparkC: '#ff7420', + colorSparkD: '#4a1204', + dropRate: 120, // water thrown off the boil, particles/second + dropSize: 0.06, + dropSpeed: 4.2, + dropLifetime: 0.9, + dropGravity: -16.0, + colorDropA: '#dff0f7', + colorDropB: '#a9c8d6', + colorDropC: '#6f8f9e', + colorDropD: '#3a4c56', + + /* --- the plunge --- */ + splashDrops: 260, // one-shot water thrown at the moment of entry + splashSteam: 70, // ... and the first flash of steam + plungeShake: 0.6, + shakeDuration: 0.7, + plungeFlash: 0.1, // full-screen flash on entry + colorFlash: '#ffd9b0', + burstSize: 2.4, // the splash dome, metres + burstIntensity: 1.0, + colorBurstA: '#20323c', + colorBurstB: '#9fc0cc', + colorBurstC: '#ffffff', + + /* --- dynamic light --- */ + // The intensity follows the same `T⁴` the metal's emission does, so the light + // goes out with the steel rather than on a fade curve of its own. + lightIntensity: 18.0, + lightRadius: 15.0, + lightColor: '#ff8c3a', // the authored tint... + lightBlackbody: 0.85, // ...and how far it is dragged onto the real locus + lightCeiling: 2.4 // clamp on the T⁴ term, or entry heat blows the exposure +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Quench. + * + * Open **The cooling curve** first and drag `filmTime` with the clock stopped. + * Everything the ability is doing is downstream of that one number: push it to + * two seconds and the billet hangs there glowing while nothing happens, then + * detonates; take it to zero and the whole quench is one violent second. The + * steam, the light, the albedo and the emission all answer, because they are + * all the same `T(t)` read at different points. + * + * `massExponent` is the second thing to reach for. At 0 every part cools + * together and the scene flattens into one animated colour; at 1 the bolts are + * black before the plate is orange and you can read the sizes off the heat. + * + * The nine `steamBoil*` and one `steamVoid*` keys are not filed below on + * purpose: `volumeHullDefaults` emits the whole vocabulary so the hull's own + * audit stays quiet, but they belong to other media and do nothing to SMOKE. + * They are reachable in the trailing "More" folder if anybody needs them. + */ +export const quenchSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 160, 1, 'entry speed'], + ['zoneRadius', 1.5, 12, 0.1, 'bath radius'], + ['lifetime', 0.5, 12, 0.05, 'quench time'], + ['fadeTime', 0.2, 8, 0.05, 'steam clear'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The cooling curve': [ + ['tempStart', 700, 2400, 5, 'entry temp (K)'], + ['tempBath', 280, 373, 1, 'bath temp (K)'], + ['filmTime', 0, 3, 0.01, 'vapour blanket (s)'], + ['filmTau', 0.2, 20, 0.05, 'blanket τ (s)'], + ['boilTau', 0.05, 6, 0.01, 'nucleate boil τ (s)'], + ['boilEnd', 380, 1200, 5, 'boiling gives out (K)'], + ['convectTau', 0.2, 20, 0.05, 'convection τ (s)'], + ['massRef', 0.1, 5, 0.01, 'reference size (m)'], + ['massExponent', 0, 2, 0.01, 'τ ∝ size^n'], + ['steamRef', 100, 12000, 10, 'heat flow at full steam'] + ], + 'The work': [ + ['plateCount', 0, 8, 1, 'billets'], + ['boltCount', 0, 16, 1, 'off-cuts'], + ['plateSize', 0.2, 4, 0.01, 'billet size (m)'], + ['plateSizeJitter', 0, 1, 0.01, 'billet size spread'], + ['boltSize', 0.05, 1.5, 0.01, 'off-cut size (m)'], + ['boltSizeJitter', 0, 1, 0.01, 'off-cut size spread'], + ['scatter', 0, 1, 0.01, 'scatter ÷radius'], + ['entryHeight', 0, 16, 0.1, 'entry height (m)'], + ['entryTilt', 0, 3, 0.01, 'entry tumble (rad)'], + ['restDepth', -1, 1, 0.005, 'resting depth (m)'], + ['plateLean', 0, 1.5, 0.01, 'resting lean (rad)'] + ], + 'The billet profile': [ + ['plateWidth', 0.2, 2, 0.01, 'width'], + ['plateDepth', 0.1, 2, 0.01, 'depth'], + ['plateThickness', 0.02, 0.8, 0.005, 'thickness'], + ['plateCorner', 0, 0.5, 0.005, 'corner radius'], + ['plateBevel', 0, 0.15, 0.001, 'bevel'], + ['plateBolts', 0, 6, 1, 'holes'], + ['plateBoltRadius', 0.01, 0.2, 0.002, 'hole radius'], + ['plateBoltInset', 0.05, 0.45, 0.005, 'hole inset'], + ['plateCounterSink', 0, 0.12, 0.002, 'countersink flare'], + ['plateCounterDepth', 0, 0.12, 0.002, 'countersink depth'], + ['plateCrease', 5, 80, 1, 'crease angle (°)'] + ], + 'The off-cut profile': [ + ['boltLength', 0.6, 5, 0.01, 'length ×head'], + ['boltHeadHeight', 0.2, 1.4, 0.01, 'head height'], + ['boltHeadChamfer', 0, 0.3, 0.005, 'head chamfer'], + ['boltWasher', 0, 0.3, 0.005, 'flange'], + ['boltWasherRadius', 0.3, 1, 0.005, 'flange radius'], + ['boltShankRadius', 0.08, 0.5, 0.005, 'shank radius'], + ['boltThreadTurns', 0, 24, 1, 'thread turns'], + ['boltThreadDepth', 0, 0.12, 0.002, 'thread depth'], + ['boltThreadFrom', 0, 0.9, 0.01, 'thread start'], + ['boltTipTaper', 0, 0.5, 0.005, 'tip taper'], + ['boltCrease', 5, 80, 1, 'crease angle (°)'] + ], + 'The steel': [ + ['colorMetal', 'steel'], + ['colorDeep', 'pit bottom'], + ['colorScale', 'mill scale'], + ['colorPolish', 'worn edge'], + ['colorSpec', 'highlight'], + ['roughness', 0.02, 1, 0.005, 'roughness'], + ['metalness', 0, 1, 0.005, 'metalness'], + ['envIntensity', 0, 3, 0.01, 'probe gain'], + ['brush', 0, 2, 1, 'brush mode'], + ['anisotropy', 0, 1, 0.01, 'anisotropy'], + ['specular', 0, 6, 0.01, 'specular gain'], + ['grain', 0, 2, 0.01, 'grain depth'], + ['grainScale', 4, 300, 1, 'grain frequency'], + ['grainStretch', 1, 120, 1, 'grain stretch'], + ['scale', 0, 1, 0.01, 'mill scale'], + ['scaleScale', 0.5, 30, 0.1, 'scale patch size'], + ['scaleSharp', 0, 1, 0.01, 'scale edge'], + ['pit', 0, 1, 0.01, 'pitting'], + ['pitScale', 4, 200, 1, 'pit frequency'], + ['wear', 0, 1, 0.01, 'edge wear'], + ['wearGrain', 0, 1, 0.01, 'wear breakup'] + ], + 'The blackbody ramp': [ + ['heatCold', 200, 900, 5, 'cold (K)'], + ['heatHot', 900, 2400, 5, 'hot (K)'], + ['heatRef', 400, 2400, 5, 'emission ref (K)'], + ['heatExponent', 1, 8, 0.1, 'emission exponent'], + ['heatGlow', 0, 8, 0.01, 'emission gain'], + ['heatTint', 0, 1, 0.01, 'albedo wash'], + ['heatEdge', 0, 1, 0.01, 'edge cooling'] + ], + 'The steam column': [ + ['steamWidth', 0.2, 3, 0.01, 'half-width ×radius'], + ['steamHeight', 0.2, 8, 0.01, 'height ×radius'], + ['steamLift', -0.5, 2, 0.01, 'base height (m)'], + ['steamFade', 0, 2, 0.01, 'volume gain'], + ['steamCurve', 0.1, 3, 0.01, 'boil → volume curve'] + ], + ...volumeHullSchema('steam', { + label: 'Steam', + only: ['march', 'shape', 'field', 'flow', 'optics', 'colour'] + }), + 'The bath': [ + ['fieldEdge', 0.02, 3, 0.01, 'rim feather (m)'], + ['fieldRagged', 0, 1, 0.01, 'rim wander'], + ['fieldRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['fieldWarp', 0, 3, 0.01, 'domain warp (m)'], + ['fieldRelief', 0, 2, 0.01, 'relief'], + ['fieldCell', 0.1, 4, 0.01, 'feature size (m)'], + ['fieldThickness', 0.005, 0.4, 0.005, 'meniscus (m)'], + ['fieldDepth', 0, 2, 0.01, 'apparent depth (m)'], + ['fieldSharp', 0, 1, 0.01, 'edge hardness'], + ['fieldDetail', 0, 1, 0.01, 'chop'], + ['fieldFlow', 0, 3, 0.01, 'drift (m/s)'], + ['fieldSpeed', 0, 6, 0.01, 'events / second'], + ['fieldWindAngle', -3.2, 3.2, 0.01, 'drift angle (rad)'], + ['fieldParallax', 0, 1.5, 0.01, 'parallax (m)'], + ['fieldSpecular', 0, 3, 0.01, 'specular'], + ['fieldGloss', 1, 200, 1, 'gloss'], + ['fieldOpacity', 0, 1.5, 0.01, 'opacity'], + ['fieldEmissive', 0, 4, 0.01, 'emissive'], + ['colorFieldBase', 'water'], + ['colorFieldEdge', 'meniscus'], + ['colorFieldGlow', 'firelight in the water'], + ['colorFieldDeep', 'tank bottom'] + ], + 'Steam, sparks & spatter': [ + ['steamRate', 0, 800, 1, 'steam rate'], + ['steamSize', 0.05, 5, 0.01, 'steam size'], + ['steamSpeed', 0, 10, 0.05, 'steam speed'], + ['steamLifetime', 0.2, 8, 0.05, 'steam lifetime'], + ['steamOpacity', 0, 1, 0.005, 'steam opacity'], + ['steamRise', -2, 6, 0.01, 'steam rise'], + ['steamTurbulence', 0, 3, 0.01, 'steam turbulence'], + ['sparkRate', 0, 600, 1, 'spark rate'], + ['sparkSize', 0.005, 0.6, 0.005, 'spark size'], + ['sparkSpeed', 0, 20, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 3, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['dropRate', 0, 600, 1, 'spatter rate'], + ['dropSize', 0.005, 0.4, 0.005, 'spatter size'], + ['dropSpeed', 0, 20, 0.1, 'spatter speed'], + ['dropLifetime', 0.1, 4, 0.05, 'spatter lifetime'], + ['dropGravity', -50, 0, 0.1, 'spatter gravity'], + ['colorSteam*', 'Steam colour'], + ['colorSpark*', 'Spark colour'], + ['colorDrop*', 'Spatter colour'] + ], + 'The plunge': [ + ['splashDrops', 0, 900, 1, 'splash droplets'], + ['splashSteam', 0, 400, 1, 'splash steam'], + ['plungeShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['plungeFlash', 0, 2, 0.01, 'screen flash'], + ['burstSize', 0.1, 10, 0.05, 'splash dome (m)'], + ['burstIntensity', 0, 5, 0.01, 'dome intensity'], + ['colorFlash', 'flash colour'], + ['colorBurstA', 'dome shell'], + ['colorBurstB', 'dome body'], + ['colorBurstC', 'dome crest'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightBlackbody', 0, 1, 0.01, 'onto the locus'], + ['lightCeiling', 0.1, 8, 0.01, 'T⁴ ceiling'], + ['lightColor', 'light tint'] + ] +}; diff --git a/src/config/abilities/railcoil.js b/src/config/abilities/railcoil.js new file mode 100644 index 0000000..5051cd8 --- /dev/null +++ b/src/config/abilities/railcoil.js @@ -0,0 +1,398 @@ +/* ================================================================== */ +/* RAILCOIL — the shot that is already there */ +/* ================================================================== */ +/** + * A rail shot with **zero travel time**, and every number below exists to + * serve that one idea. + * + * The beat structure is inverted against every other line cast in the sandbox. + * There is no front racing down the line: `windUp` seconds are spent with the + * shot at the caster while four helical coils collapse inward along the + * barrel, and on the frame they meet the front goes from 0 to 1 in one step. + * The full-length channel is simply *already there*, and everything after it is + * decay — `flashHold` seconds at full white, then `lifetime + fadeTime` seconds + * of an ionisation channel cooling to blue, sagging under its own weight and + * breaking into disconnected segments. + * + * **The two colour sets are shared on purpose**, which is the one place this + * block bends I5. `channelColor*` is the hot palette and `colorCool*` is the + * cold one; the tube and the filaments both read the *same* blend of the two, + * because they are not two effects that happen to match — they are one channel + * drawn as a core and its threads, and a channel whose core and threads cool at + * different rates is a bug rather than a look. The blend runs on `coolCurve`. + * + * **The decay clock is derived, never stored.** It is + * `(age − windUp − flashHold) / (lifetime + fadeTime)`, so dragging `windUp` + * with the clock stopped does not merely move the wind-up: it slides the whole + * decay backwards and forwards through itself, which is the most direct proof + * available that nothing here was captured at spawn. + */ + +import { TubePath, tubeDefaults, tubeSchema } from '../../vfx/Tube.js'; + +export const railcoil = { + /* --- the cast --- */ + range: 26.0, // maximum cast distance, metres — the longest line cast in the set + minRange: 3.0, // closer than this and the cast is refused + /** + * Metres/second the front travels at — and the one dead number in the block, + * because `advance()` is overridden and never reads it. Every settings block + * carries `speed`, the HUD prints it and the harness checks it is positive, + * so it stays; 900 is roughly what the barrel would imply if anything + * integrated it, and nothing does. That is the whole ability. + */ + speed: 900.0, + windUp: 0.55, // seconds the coils take to collapse to the muzzle + flashHold: 0.09, // seconds the shot stands at full white before it starts to die + lifetime: 0.65, // seconds of the hot half of the decay + fadeTime: 0.9, // seconds of the cold half — 1.55 s of decay in total + cooldown: 1.5, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the barrel --- */ + // The shot leaves a rail held at the hip, not a hand held out in front, so + // the muzzle sits further forward and lower than the bolt's does. + handHeight: 1.24, // metres above the floor + handForward: 0.45, // metres in front of the caster + handSide: 0.2, // metres to the side (+ follows `Ability#side`) + endHeight: 1.12, // height of the far end of the shot, metres + barrelLength: 2.8, // metres of barrel the coils collapse along + + /* --- the coils, during the wind-up --- */ + coilCount: 4, // coils along the barrel, 1..4 (one filament role each) + coilFilaments: 3, // filaments in one coil + coilSpan: 0.3, // how much of the barrel one coil covers at the start, 0..1 + coilRadius: 0.6, // metres a coil starts at + coilRadiusEnd: 0.07, // ...and where it has closed to when they meet + coilTurns: 2.6, // turns of the helix over its own span + coilSpin: 3.4, // revolutions/second it rolls at + coilSpread: 0.55, // phase offset between the filaments of one coil, turns + coilTaper: 1.0, // how the radius runs from one end of a coil to the other + coilGather: 2.2, // >1 holds the coils apart and then rushes them together + coilKink: 0.5, // × the shared kink amplitude + coilWidth: 0.85, // × the shared ribbon width + coilDim: 0.95, // how bright a coil is against the channel + coilLift: 0.0, // metres the coils bow upward over their own span + + /* --- the residual coil, after the shot --- */ + // Current does not stop the instant the shot leaves: one coil survives, wound + // around the whole channel, unwinding as it dies. It is also what keeps the + // coil sliders live during the decay, which is the beat you can actually + // pause and look at. + ringdownFilaments: 4, + ringdownRadius: 0.4, // metres it stands off the channel at the start + ringdownTurns: 7.0, // turns over the length of the shot + ringdownSpin: -2.6, // revolutions/second, against the coils' direction + ringdownLife: 0.6, // fraction of the decay it survives + ringdownDim: 0.7, + + /* --- the ionisation channel: Tube(STRAIGHT), prefix `channel` --- */ + ...tubeDefaults('channel', TubePath.STRAIGHT, { + channelRadius: 0.135, // half-width at the far end, metres + channelRadiusNear: 0.165, // ...and at the muzzle: a rail shot is widest where it left + channelRadiusCurve: 0.85, + channelFlare: 0.0, + channelFlareWidth: 0.1, + channelThrob: 0.09, // the channel pulses as it dumps its charge + channelThrobScale: 6.5, + channelThrobSpeed: 5.5, + channelWander: 0.045, + channelWanderScale: 1.4, + channelRipple: 0.22, + channelRippleScale: 6.0, + channelStreak: 1.35, // current running down the outside of the channel + channelStreakScale: 15.0, + channelStreakSharp: 0.62, + channelFlowSpeed: 15.0, + channelCoreWidth: 0.34, + channelCoreFill: 1.0, + channelCoreSharp: 1.6, + channelSheathWidth: 1.0, + channelSheathRim: 1.0, + channelHaloWidth: 2.4, + channelHaloRim: 3.6, + channelHaloOpacity: 0.55, + channelMuzzleGlow: 2.6, + channelMuzzleLength: 0.07, + channelTipGlow: 1.5, + channelTipLength: 0.05, + channelGlow: 2.8, + channelSoftFade: 0.5, + channelColorCore: '#ffffff', // the hot palette — see the header on sharing + channelColorInner: '#dff2ff', + channelColorOuter: '#a8e0ff', + channelColorHalo: '#1a4fd0' + }), + + /* --- how the channel dies --- */ + channelCollapse: 0.42, // fraction of the decay the tube's width collapses over + coolCurve: 1.35, // >1 holds the white and then dumps it — the cooling ramp + colorCoolCore: '#7fc4ff', // the cold palette the hot one is blended toward + colorCoolInner: '#3f8ce8', + colorCoolOuter: '#1a4fd0', + colorCoolHalo: '#050e33', + + /* --- the break-up: the channel snapping into disconnected segments --- */ + segments: 3, // segments the channel breaks into, 1..3 (one filament role each) + segmentFilaments: 4, // filaments in one segment + segmentGap: 0.05, // fraction of the span left clear between segments + segmentSpread: 0.09, // metres the filaments of one segment fan out by + segmentKink: 1.0, // × the shared kink amplitude + segmentWidth: 1.0, // × the shared ribbon width + segmentFray: 1.6, // how much the kink grows as a segment dies + dissolveNoise: 7.3, // spatial frequency of the dissolve noise along the span + dissolveStagger: 0.6, // 0 = every segment dies together, 1 = strictly in turn + dissolveTip: 0.16, // how soft the eaten end of a dying segment is, 0..1 + channelSag: 1.05, // metres the dead channel droops by the end + channelSagCurve: 2.3, // >1 holds it straight and then lets it go + + /* --- the shared filament look (canonical keys, read by FilamentPaths) --- */ + width: 0.026, // half-width of one filament, metres + glowWidth: 6.4, // the halo, × that width + glowOpacity: 0.42, + jitter: 0.14, // metres of kink at the coarsest octave + jitterScale: 1.9, // kinks per metre + octaves: 4, // 1–5; each halves the amplitude and doubles the rate + jitterFalloff: 0.55, + crawl: 3.6, // how fast the kinks slide along + pinch: 0.13, // fraction of a filament its ends are pulled straight over + restrike: 26, // whole re-shapes per second + flicker: 0.28, // depth of the whole-bundle brightness stutter + flickerSpeed: 36, + strandFlash: 0.42, // how much individual filaments blink out + coreSharp: 4.6, // exponent on a filament's edge falloff + glowFalloff: 2.4, + softFade: 0.7, // metres of soft fade where a filament meets geometry + opacity: 1.0, + glow: 2.4, // emissive gain into bloom + + /* --- the recoil --- */ + recoilShake: 2.4, // the hardest kick in the set, and the point of the slot + recoilTime: 0.55, // seconds it decays over + recoilFreq: 34, // Hz the shake rings at + chargeShake: 0.05, // continuous rumble while the coils close + fireFlash: 0.42, // screen flash on firing + colorFireFlash: '#dff2ff', + muzzleSize: 1.5, // the flare at the muzzle, metres + muzzleIntensity: 2.2, + colorMuzzleA: '#1a4fd0', // muzzle shell + colorMuzzleB: '#a8e0ff', // muzzle body + colorMuzzleC: '#ffffff', // muzzle filaments — the one carrying the read + fireLight: 2.2, // light punch on firing, × lightIntensity + + /* --- the scorch line --- */ + // Thin and long: the floor under an ionisation channel does not get a crater, + // it gets a hairline burn that lasts. + scorchMarks: 14, // marks laid along the line at the instant of the shot + scorchRadius: 0.3, // radius of one mark, metres + scorchJitter: 0.35, // metres the marks wander off the axis + scorchLife: 7.5, // seconds a mark lingers + scorchIntensity: 0.55, + colorScorch: '#06080f', // the burnt floor + colorScorchEmber: '#3f8ce8', // the ionisation still glowing in it + + /* --- sparks, motes and haze --- */ + /** + * Four-stop lifetime gradients, `A` at birth through `D` as it dies, spelled + * out per system rather than derived from the channel palette — the sparks + * are metal off the rail and are allowed to be warm while the channel is not. + */ + fireSparks: 260, // sparks thrown out of the muzzle when it goes + sparkRate: 130, // sparks shed off the channel while it is hot, particles/second + sparkSize: 0.13, + sparkSpeed: 11.0, + sparkLifetime: 0.55, + sparkGravity: -14.0, + sparkStretch: 0.24, // how far a spark smears along its velocity + colorSparkA: '#ffffff', + colorSparkB: '#dff2ff', + colorSparkC: '#5f9fe8', + colorSparkD: '#12306b', + moteRate: 150, // ionised motes drifting off the dying channel + moteSize: 0.055, + moteSpeed: 1.2, + moteLifetime: 1.9, + moteRise: 0.9, // upward drift, metres/second + moteTurbulence: 0.9, + colorMoteA: '#ffffff', + colorMoteB: '#a8e0ff', + colorMoteC: '#2f6fd0', + colorMoteD: '#04143c', + smokeRate: 34, // haze off the burnt air + smokeSize: 0.85, + smokeSpeed: 0.8, + smokeLifetime: 2.6, + smokeOpacity: 0.06, + smokeRise: 0.6, + colorSmokeA: '#4a5566', + colorSmokeB: '#39424f', + colorSmokeC: '#2a313a', + colorSmokeD: '#181c22', + + /* --- dynamic light --- */ + lightIntensity: 30, + lightRadius: 20, + lightColor: '#8fc8ff', + lightFlicker: 0.35, // depth of the gutter as the channel dies + lightFlickerSpeed: 30 +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Railcoil. + * + * `wind-up` and `flash hold` are the two that change what the ability *is*: + * the first is how long you wait, the second is how long the shot reads as a + * solid white bar before it starts to come apart. After that go to **The + * break-up** — `dissolve stagger` at 0 kills the whole channel in one piece and + * at 1 walks it out segment by segment, and everything about how the decay + * reads sits between those two. + * + * The channel folders below are generated by the tech library from the + * `channel` prefix, so they carry the same labels here as on any other tube. + */ +export const railcoilSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 2000, 5, 'front speed (unused)'], + ['windUp', 0.05, 3, 0.01, 'wind-up (s)'], + ['flashHold', 0, 1, 0.005, 'flash hold (s)'], + ['lifetime', 0.05, 4, 0.01, 'hot decay (s)'], + ['fadeTime', 0.05, 4, 0.01, 'cold decay (s)'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The barrel': [ + ['handHeight', 0, 3, 0.01, 'muzzle height'], + ['handForward', -1, 3, 0.01, 'muzzle forward'], + ['handSide', -1.5, 1.5, 0.01, 'muzzle lateral'], + ['endHeight', 0, 4, 0.01, 'height at target'], + ['barrelLength', 0.3, 8, 0.05, 'barrel length (m)'] + ], + 'The coils': [ + ['coilCount', 1, 4, 1, 'coils'], + ['coilFilaments', 1, 8, 1, 'filaments / coil'], + ['coilSpan', 0.02, 1, 0.01, 'coil span'], + ['coilRadius', 0.02, 3, 0.01, 'start radius (m)'], + ['coilRadiusEnd', 0.01, 2, 0.01, 'closed radius (m)'], + ['coilTurns', 0.2, 10, 0.05, 'turns'], + ['coilSpin', -12, 12, 0.05, 'spin (rev/s)'], + ['coilSpread', 0, 2, 0.01, 'phase spread'], + ['coilTaper', 0.05, 4, 0.01, 'radius curve'], + ['coilGather', 0.2, 6, 0.01, 'collapse curve'], + ['coilKink', 0, 3, 0.01, 'coil kink'], + ['coilWidth', 0.05, 3, 0.01, 'coil width'], + ['coilDim', 0, 2, 0.01, 'coil brightness'], + ['coilLift', -1, 1, 0.01, 'coil bow (m)'] + ], + 'The ring-down': [ + ['ringdownFilaments', 0, 8, 1, 'filaments'], + ['ringdownRadius', 0.01, 3, 0.01, 'stand-off (m)'], + ['ringdownTurns', 0.2, 24, 0.1, 'turns'], + ['ringdownSpin', -12, 12, 0.05, 'spin (rev/s)'], + ['ringdownLife', 0.02, 1, 0.01, 'survives (× decay)'], + ['ringdownDim', 0, 2, 0.01, 'brightness'] + ], + ...tubeSchema('channel', TubePath.STRAIGHT), + 'The cooling': [ + ['channelCollapse', 0.02, 1, 0.01, 'width collapse'], + ['coolCurve', 0.1, 6, 0.01, 'cooling curve'], + ['colorCoolCore', 'cold core'], + ['colorCoolInner', 'cold inner'], + ['colorCoolOuter', 'cold outer'], + ['colorCoolHalo', 'cold halo'] + ], + 'The break-up': [ + ['segments', 1, 3, 1, 'segments'], + ['segmentFilaments', 1, 8, 1, 'filaments / segment'], + ['segmentGap', 0, 0.3, 0.005, 'gap between segments'], + ['segmentSpread', 0, 1, 0.005, 'fan (m)'], + ['segmentKink', 0, 3, 0.01, 'segment kink'], + ['segmentWidth', 0.05, 3, 0.01, 'segment width'], + ['segmentFray', 0, 6, 0.01, 'fray as it dies'], + ['dissolveNoise', 0.5, 40, 0.1, 'dissolve noise'], + ['dissolveStagger', 0, 0.95, 0.01, 'dissolve stagger'], + ['dissolveTip', 0.01, 0.6, 0.005, 'dissolve softness'], + ['channelSag', 0, 4, 0.01, 'sag (m)'], + ['channelSagCurve', 0.2, 6, 0.01, 'sag curve'] + ], + 'The filaments': [ + ['width', 0.002, 0.3, 0.001, 'width (m)'], + ['glowWidth', 1, 20, 0.1, 'halo width'], + ['glowOpacity', 0, 2, 0.01, 'halo opacity'], + ['jitter', 0, 2, 0.005, 'kink amplitude'], + ['jitterScale', 0.05, 8, 0.01, 'kinks / metre'], + ['octaves', 1, 5, 1, 'octaves'], + ['jitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['crawl', -20, 20, 0.1, 'kink crawl'], + ['pinch', 0.01, 0.5, 0.005, 'end pinch'], + ['restrike', 0.5, 90, 0.5, 'restrikes / sec'], + ['flicker', 0, 1, 0.01, 'brightness stutter'], + ['flickerSpeed', 1, 120, 1, 'stutter rate'], + ['strandFlash', 0, 1, 0.01, 'filament blink'], + ['coreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['glowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['softFade', 0.02, 3, 0.01, 'soft intersection'], + ['opacity', 0, 2, 0.01, 'opacity'], + ['glow', 0, 8, 0.01, 'glow'] + ], + 'The recoil': [ + ['recoilShake', 0, 6, 0.01, 'recoil'], + ['recoilTime', 0.05, 3, 0.01, 'recoil duration'], + ['recoilFreq', 4, 90, 1, 'recoil frequency'], + ['chargeShake', 0, 0.5, 0.005, 'wind-up rumble'], + ['fireFlash', 0, 2, 0.01, 'screen flash'], + ['fireLight', 0, 6, 0.01, 'light punch'], + ['muzzleSize', 0.05, 8, 0.05, 'muzzle flare size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle flare intensity'], + ['colorFireFlash', 'flash colour'], + ['colorMuzzleA', 'muzzle shell'], + ['colorMuzzleB', 'muzzle body'], + ['colorMuzzleC', 'muzzle arcs'] + ], + 'The scorch line': [ + ['scorchMarks', 0, 40, 1, 'marks'], + ['scorchRadius', 0.05, 3, 0.01, 'mark radius'], + ['scorchJitter', 0, 2, 0.01, 'mark wander'], + ['scorchLife', 0.5, 20, 0.1, 'mark lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'mark intensity'], + ['colorScorch', 'scorch'], + ['colorScorchEmber', 'scorch glow'] + ], + 'Sparks & motes': [ + ['fireSparks', 0, 900, 1, 'muzzle sparks'], + ['sparkRate', 0, 900, 1, 'spark rate'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['moteRate', 0, 600, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 12, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 8, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorSpark*', 'Spark colour'], + ['colorMote*', 'Mote colour'] + ], + 'Haze': [ + ['smokeRate', 0, 300, 1, 'haze rate'], + ['smokeSize', 0.05, 4, 0.01, 'haze size'], + ['smokeSpeed', 0, 8, 0.05, 'haze speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'haze lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'haze opacity'], + ['smokeRise', -2, 4, 0.01, 'haze rise'], + ['colorSmoke*', 'Haze colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightFlicker', 0, 1, 0.01, 'light gutter'], + ['lightFlickerSpeed', 1, 90, 1, 'gutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/refractcascade.js b/src/config/abilities/refractcascade.js new file mode 100644 index 0000000..a254ed0 --- /dev/null +++ b/src/config/abilities/refractcascade.js @@ -0,0 +1,383 @@ +/* ================================================================== */ +/* REFRACTION CASCADE — lumen, line */ +/* ================================================================== */ +/** + * A beam that gets to the far end by bouncing. + * + * Three or four glass panes hang in a zig-zag down the aimed line. A shot + * leaves the hand, strikes the first pane, comes off it at the reflected angle, + * crosses to the second, and so on to the floor at the end of the cast — losing + * a slice of its width and its brightness at every bounce. + * + * **The panes reflect the actual scene.** Each one renders the `WORLD` layer + * from a camera mirrored about its own plane and samples that target + * projectively, so what you see in a pane is the room *behind the camera*, and + * it slides across the glass as you orbit. An environment map would have been a + * tenth of the cost and reads as chrome within about half a second of movement, + * because a reflection that does not parallax is filed by the brain as painted + * shine. That parallax is the whole ability. + * + * The numbers to reach for first: + * + * - `paneOffset` — how far the zig-zag throws each pane off the cast line. It + * is the ability's whole silhouette, and because each pane's *normal is + * derived from the two legs meeting on it* rather than authored, dragging it + * re-aims every pane at once. Pause and drag it: the beam stays a legal + * reflection at every value. + * - `paneCount` — one to four. Four is a wall of glass and two nested scene + * renders a frame; the module's budget picks which two of the four actually + * re-render, by apparent size and by how long each has been waiting. + * - `paneRoughness` — 0 is a black mirror, 0.4 is scuffed glass. This is the + * single slider that decides whether the cascade reads as jewellery or as + * industrial. + * - `bounceLoss` — width and brightness kept per bounce. At 1 the fourth leg + * is as hot as the first, which says the mirrors are amplifiers. + * + * ### On the cost that is not a draw call + * + * Five draw calls: four panes and one beam. But every pane that renders is an + * extra full traversal of the `WORLD` layer — a scene graph walk, a render + * list, a sort and every opaque draw again — so `vfx/Mirror.js` caps the number + * that may re-render in one frame at two and hands the slots out on + * `priority × apparent size × (1 + frames since it last rendered)`. At 60 Hz + * with four panes that is a 30–50 ms old reflection on the ones that miss, + * which is invisible on a slow orbit. `paneResolution` is the other half of the + * bill: at 320² a reflection is a tenth of a 1080p frame and about 800 kB of + * half-float colour, per pane. + * + * ### Why the beam's twenty-six keys are not a `Tube` prefix + * + * `vfx/Tube.js` is three draw calls for one tube and does not instance, so four + * legs would be twelve. `materials/CascadeBeamMaterial.js` is the same + * parameter-space grid instanced by leg — one draw call for the whole cascade, + * with each instance looking its own endpoints up out of a uniform array. The + * keys below are that material's contract. + */ +export const refractcascade = { + /* --- the cast --- */ + range: 26.0, // maximum cast distance, metres + minRange: 6.0, // closer than this and the zig-zag has no room + speed: 42.0, // how fast the front runs down the *line*, metres/second + lifetime: 1.6, // seconds the cascade holds once it has landed + fadeTime: 0.8, // seconds it lets go over + cooldown: 1.1, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the shot leaves the caster --- */ + handHeight: 1.3, // metres above the floor + handForward: 0.6, // metres in front of the caster + handSide: 0.18, // metres to the side (+ follows `Ability#side`) + endHeight: 0.3, // metres above the floor where the last leg lands + + /* --- the panes --- */ + // `paneFirst`/`paneLast` are fractions of the cast's own length, so the whole + // zig-zag stretches with the aim instead of bunching at the caster. + paneCount: 3, // 1..4 — see the budget note in the header + paneFirst: 0.24, // fraction of the span the first pane sits at + paneLast: 0.84, // ... and the last + paneSide: 1, // which side the first pane is on; the sign is all that is read + paneOffset: 2.5, // metres the zig-zag throws a pane off the line + paneOffsetTaper: 0.86, // multiplier per pane — the zig-zag narrows downrange + paneAltitude: 1.95, // metres above the floor, first pane + paneRise: 0.22, // metres added per pane + paneBob: 0.1, // metres of vertical drift + paneBobSpeed: 0.38, // Hz of that drift + paneRoll: 0.22, // radians each pane is rolled about its own normal + paneRollStep: 0.4, // radians added per pane + paneStagger: 0.16, // fraction of the span a pane fades in over, before the beam + paneWidth: 2.3, // metres across + paneTall: 2.9, // metres up + paneGrowScale: 0.55, // how small a pane starts, × its final size + + /* --- the glass (vfx/Mirror) --- */ + paneResolution: 320, // square reflection target, pixels — the real cost + paneReflectivity: 0.92, // how much of the reflection survives + paneFresnel: 0.4, // extra reflectivity at a grazing angle + paneFresnelPower: 2.6, + paneRoughness: 0.14, // 0 is a black mirror + paneBlurRadius: 0.03, // the blur kernel at roughness 1, in target UV + paneBlurTaps: 8, // 1..12 + paneRoughStretch: 1.3, // how far the lobe smears along the view-vertical + paneRipple: 0.1, // world-space disturbance on the lookup + paneRippleScale: 1.6, // features per metre + paneRippleSpeed: 0.4, + paneOpacity: 0.96, + paneEdgeFade: 0.14, // fraction of the surface the edge feathers over + paneCorner: 0.15, // 0 rectangle, 1 ellipse + panePriority: 1.0, // weight against the two-reflections-a-frame budget + colorPaneTint: '#e8f4ff', // multiplies the reflection — glass is not neutral + colorPaneBase: '#0b1220', // what shows where it does not reflect + + /* --- the beam (materials/CascadeBeamMaterial.js) --- */ + beamRadiusNear: 0.1, // half-width at the muzzle, metres + beamRadiusFar: 0.065, // ... at the far end + beamRadiusCurve: 1.1, // >1 stays fat and thins late + beamThrob: 0.07, // fraction of the radius that pulses + beamThrobBands: 6, // pulses along the whole cascade + beamThrobSpeed: 5.0, // radians/second they slide at + beamTipSoft: 0.018, // fraction of the path the drawn tip tapers over + bounceLoss: 0.86, // width and brightness kept per bounce + beamCoreSharp: 2.1, // how fast the body falls off toward the silhouette + beamCoreTight: 3.6, // how small the white centre is + beamRimPower: 2.6, // how tight the glass edge is + beamRim: 0.95, // how strong it is + beamBands: 8, // travelling energy bands along the path + beamBandDepth: 0.42, + beamBandSpeed: 6.5, // radians/second + beamBandStagger: 1.2, // radians of phase added per leg + beamGrain: 0.32, // longitudinal noise on the body + beamGrainScale: 13, + beamGrainSpeed: 1.5, + beamTipGlow: 2.4, // extra heat right behind the travelling front + beamTipLength: 0.05, // how far back that reaches, fraction of the path + beamGlow: 3.0, // overall emissive gain + beamOpacity: 1.0, + beamSoftFade: 0.35, // metres of soft fade where a leg meets geometry + colorBeamCore: '#ffffff', // the centre of a leg + colorBeamInner: '#fff0cc', + colorBeamOuter: '#ffb43c', // the outside of it + colorBeamHalo: '#4fd0ff', // the rim — the cold side of the split + + /* --- sparks, motes and dust --- */ + /** + * Each system is coloured by a four-stop gradient sampled over the particle's + * own lifetime, `A` at birth through `D` as it dies. Spelled out rather than + * derived from the beam palette, so the bounce sparks can be made to cool to + * blue while the beam stays gold. + */ + sparkRate: 90, // sparks shed along the beam, particles/second + sparkSize: 0.11, + sparkSpeed: 5.5, + sparkLifetime: 0.55, + sparkGravity: -6.0, + sparkStretch: 0.2, // how far a spark smears along its velocity + colorSparkA: '#ffffff', + colorSparkB: '#fff0cc', + colorSparkC: '#ffb43c', + colorSparkD: '#5a2a05', + moteRate: 70, // glass dust hanging around the panes, particles/second + moteSize: 0.045, + moteSpeed: 0.7, + moteLifetime: 2.0, + moteRise: 0.25, // upward drift, metres/second + moteTurbulence: 0.5, + moteScatter: 1.4, // metres around a pane the dust is seeded in + colorMoteA: '#ffffff', + colorMoteB: '#cfe8ff', + colorMoteC: '#6fb0e0', + colorMoteD: '#10243a', + dustRate: 26, // dust off the floor where the last leg lands + dustSize: 0.7, + dustSpeed: 1.1, + dustLifetime: 1.8, + dustOpacity: 0.08, + dustRise: 0.5, + colorDustA: '#4d4a44', + colorDustB: '#3d3a35', + colorDustC: '#2e2c28', + colorDustD: '#191817', + + /* --- dynamic light --- */ + lightIntensity: 24, + lightRadius: 14, + lightColor: '#ffd8a0', + lightPulse: 0.14, // depth of the light's breathing, 0 = steady + lightPulseSpeed: 3.2, // Hz + + /* --- the beats --- */ + muzzleSize: 0.5, // the flash at the hand, metres + muzzleIntensity: 1.7, + castFlash: 0.09, // screen flash on release + colorMuzzleA: '#ffb43c', + colorMuzzleB: '#fff0cc', + colorMuzzleC: '#ffffff', + colorCastFlash: '#ffe9c4', + bounceSize: 0.9, // the shell thrown at each pane, metres + bounceIntensity: 1.5, + bounceSparks: 55, // sparks thrown at each pane + bounceFlash: 0.05, // screen flash per bounce + colorBounceA: '#4fd0ff', + colorBounceB: '#fff0cc', + colorBounceC: '#ffffff', + burstSize: 2.4, // the shell where the last leg lands, metres + burstIntensity: 1.6, + burstSparks: 140, + burstDust: 40, + shockRadius: 5.0, // impact shockwave ring, metres + colorShockA: '#ffe4a8', + colorShockB: '#ffffff', + scorchRadius: 0.7, // burn mark where it lands, metres + scorchLife: 5.5, + scorchIntensity: 0.5, + colorScorch: '#120d08', + colorEmber: '#ff9a2a', + impactShake: 0.6, + shakeDuration: 0.5, + impactFlash: 0.22, + colorFlash: '#fff2d8', // the full-screen flash on impact + rumble: 0.018 // continuous shake while the cascade stands +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Refraction Cascade. + * + * **The zig-zag** is the folder that changes what this looks like; **The + * glass** is the folder that changes what it costs. Everything in both + * re-resolves on the frame it moves, which matters more here than usual: each + * pane's normal is the bisector of the two legs meeting on it, so a slider that + * moves a pane also re-aims it, and the beam is still a legal reflection at + * every value on the way. + */ +export const refractcascadeSchema = { + 'The cast': [ + ['range', 6, 60, 0.1, 'max range'], + ['minRange', 0, 16, 0.1, 'min range'], + ['speed', 4, 200, 0.5, 'front speed'], + ['lifetime', 0.1, 8, 0.05, 'hold time'], + ['fadeTime', 0.05, 4, 0.01, 'fade time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where it leaves the hand': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['endHeight', 0, 4, 0.01, 'height at target'] + ], + 'The zig-zag': [ + ['paneCount', 1, 4, 1, 'panes'], + ['paneFirst', 0.02, 0.95, 0.01, 'first pane (× span)'], + ['paneLast', 0.05, 0.98, 0.01, 'last pane (× span)'], + ['paneSide', -1, 1, 1, 'first pane side'], + ['paneOffset', 0, 8, 0.05, 'lateral throw (m)'], + ['paneOffsetTaper', 0.3, 1.4, 0.01, 'throw taper / pane'], + ['paneAltitude', 0.2, 8, 0.05, 'altitude (m)'], + ['paneRise', -1, 2, 0.01, 'rise / pane (m)'], + ['paneBob', 0, 1, 0.005, 'bob (m)'], + ['paneBobSpeed', 0, 3, 0.01, 'bob Hz'], + ['paneRoll', -3.1416, 3.1416, 0.01, 'roll (rad)'], + ['paneRollStep', -1.6, 1.6, 0.01, 'roll / pane (rad)'], + ['paneStagger', 0.01, 0.6, 0.005, 'fade-in lead (× span)'], + ['paneWidth', 0.2, 8, 0.05, 'pane width (m)'], + ['paneTall', 0.2, 8, 0.05, 'pane height (m)'], + ['paneGrowScale', 0.05, 1, 0.01, 'start size ×'] + ], + 'The glass': [ + ['paneResolution', 64, 1024, 32, 'reflection target (px)'], + ['paneReflectivity', 0, 1.5, 0.01, 'reflectivity'], + ['paneFresnel', 0, 1.5, 0.01, 'fresnel'], + ['paneFresnelPower', 0.2, 8, 0.05, 'fresnel power'], + ['paneRoughness', 0, 1, 0.005, 'roughness'], + ['paneBlurRadius', 0, 0.2, 0.001, 'blur radius'], + ['paneBlurTaps', 1, 12, 1, 'blur taps'], + ['paneRoughStretch', 0, 4, 0.01, 'lobe stretch'], + ['paneRipple', 0, 1, 0.005, 'ripple'], + ['paneRippleScale', 0.05, 6, 0.01, 'ripple scale'], + ['paneRippleSpeed', 0, 4, 0.01, 'ripple speed'], + ['paneOpacity', 0, 1, 0.01, 'opacity'], + ['paneEdgeFade', 0, 0.6, 0.005, 'edge fade'], + ['paneCorner', 0, 1, 0.01, 'corner (0 rect, 1 ellipse)'], + ['panePriority', 0, 4, 0.05, 'budget priority'], + ['colorPaneTint', 'reflection tint'], + ['colorPaneBase', 'unreflected base'] + ], + 'The beam/Profile': [ + ['beamRadiusNear', 0.005, 1, 0.005, 'radius at hand (m)'], + ['beamRadiusFar', 0.005, 1, 0.005, 'radius at target (m)'], + ['beamRadiusCurve', 0.05, 5, 0.01, 'radius curve'], + ['beamThrob', 0, 0.6, 0.005, 'throb'], + ['beamThrobBands', 0, 24, 0.1, 'throb bands'], + ['beamThrobSpeed', 0, 20, 0.05, 'throb speed'], + ['beamTipSoft', 0.002, 0.2, 0.001, 'tip taper'], + ['bounceLoss', 0.2, 1, 0.005, 'kept per bounce'] + ], + 'The beam/Shading': [ + ['beamCoreSharp', 0.05, 8, 0.01, 'body falloff'], + ['beamCoreTight', 0.05, 12, 0.01, 'white centre'], + ['beamRimPower', 0.05, 8, 0.01, 'rim power'], + ['beamRim', 0, 3, 0.01, 'rim strength'], + ['beamBands', 0, 40, 0.1, 'energy bands'], + ['beamBandDepth', 0, 2, 0.01, 'band depth'], + ['beamBandSpeed', -30, 30, 0.1, 'band speed'], + ['beamBandStagger', -3.1416, 3.1416, 0.01, 'band phase / leg'], + ['beamGrain', 0, 2, 0.01, 'grain'], + ['beamGrainScale', 0.5, 40, 0.5, 'grain scale'], + ['beamGrainSpeed', 0, 8, 0.05, 'grain speed'], + ['beamTipGlow', 0, 8, 0.05, 'front glow'], + ['beamTipLength', 0.002, 0.4, 0.002, 'front length'], + ['beamGlow', 0, 8, 0.01, 'glow'], + ['beamOpacity', 0, 2, 0.01, 'opacity'], + ['beamSoftFade', 0.02, 3, 0.01, 'soft intersection (m)'], + ['colorBeamCore', 'core'], + ['colorBeamInner', 'inner'], + ['colorBeamOuter', 'outer'], + ['colorBeamHalo', 'rim halo'] + ], + 'Sparks, motes & dust': [ + ['sparkRate', 0, 600, 1, 'spark rate'], + ['sparkSize', 0.005, 0.6, 0.005, 'spark size'], + ['sparkSpeed', 0, 30, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -40, 10, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['moteRate', 0, 400, 1, 'mote rate'], + ['moteSize', 0.005, 0.3, 0.005, 'mote size'], + ['moteSpeed', 0, 6, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -2, 4, 0.01, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['moteScatter', 0.05, 5, 0.05, 'mote scatter (m)'], + ['dustRate', 0, 300, 1, 'dust rate'], + ['dustSize', 0.05, 3, 0.01, 'dust size'], + ['dustSpeed', 0, 6, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['colorSpark*', 'Spark colour'], + ['colorMote*', 'Mote colour'], + ['colorDust*', 'Dust colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'breathing depth'], + ['lightPulseSpeed', 0, 20, 0.05, 'breathing Hz'], + ['lightColor', 'light colour'] + ], + 'The beats': [ + ['muzzleSize', 0.05, 6, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorMuzzleA', 'muzzle shell'], + ['colorMuzzleB', 'muzzle body'], + ['colorMuzzleC', 'muzzle core'], + ['colorCastFlash', 'release flash'], + ['bounceSize', 0.05, 5, 0.05, 'bounce shell (m)'], + ['bounceIntensity', 0, 5, 0.01, 'bounce intensity'], + ['bounceSparks', 0, 400, 1, 'bounce sparks'], + ['bounceFlash', 0, 1, 0.005, 'bounce screen flash'], + ['colorBounceA', 'bounce shell'], + ['colorBounceB', 'bounce body'], + ['colorBounceC', 'bounce core'], + ['burstSize', 0.2, 12, 0.05, 'impact shell (m)'], + ['burstIntensity', 0, 5, 0.01, 'impact intensity'], + ['burstSparks', 0, 600, 1, 'impact sparks'], + ['burstDust', 0, 300, 1, 'impact dust'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['scorchRadius', 0.05, 4, 0.05, 'scorch radius'], + ['scorchLife', 0.5, 20, 0.1, 'scorch lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['colorScorch', 'scorch'], + ['colorEmber', 'ember'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['colorFlash', 'impact flash colour'], + ['rumble', 0, 0.3, 0.001, 'hold rumble'] + ] +}; diff --git a/src/config/abilities/resonance.js b/src/config/abilities/resonance.js new file mode 100644 index 0000000..f569bb5 --- /dev/null +++ b/src/config/abilities/resonance.js @@ -0,0 +1,355 @@ +/* ================================================================== */ +/* RESONANCE — aether, line cast */ +/* ================================================================== */ +/** + * Resonant Chord. A note struck down the aimed line: rings of compression run + * out from the caster, **reflect off the far end**, and the outbound and + * returning trains interfere. Where they cancel the air is dead still; where + * they add it is violently compressed. + * + * **The pattern is not painted on.** `vfx/Shell.js` in `RING_TRAIN` mode folds + * every ring off the far end (`s = span − |d − span|` on a `2·span` cycle) and + * evaluates the superposition + * + * ``` + * sin(ks − ωt) − sin(k(2L − s) − ωt) = 2·cos(kL − ωt)·sin(k(s − L)) + * ``` + * + * so a ring standing on a node genuinely pinches to the axis and goes dark. The + * ability asks the module where those nodes are (`nodePosition`), how strong + * the standing wave is at a given metre (`standingAt`) and what wavelength fits + * a whole number of half-waves on this particular cast (`resonantSpacing`), and + * then puts the distortion pockets, the dust lift and the light on the + * **antinodes** — and nothing whatever on the nodes. Move `halfWaves` from 8 to + * 9 and the pockets, the dust and the dark bands all move together, because + * they are all reading one function. + * + * `spacing` and `rings` are therefore *derived* while `lockSpacing` is up: a + * wavelength that does not fit the line an exact number of times gives a + * pattern that slides along it, which reads as "some rings are dimmer" rather + * than as "this line is resonating". Open the lock and the two sliders take + * over and the nodes drift, which is worth seeing once. + * + * Keys prefixed `chord*` are the `Shell` contract, spread verbatim from + * `shellDefaults('chord', ShellMode.RING_TRAIN)` so the module's audit stays + * quiet; the ones belonging to the dome, the cone and the sun disc are inert in + * this mode and are filed together rather than hidden. + */ +export const resonance = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 64.0, // how fast the strike front runs down the line, metres/second + lifetime: 3.2, // seconds the chord rings for + fadeTime: 1.7, // seconds the pattern takes to damp out + cooldown: 1.4, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the chord is strung --- */ + originForward: 0.7, // metres in front of the caster the run starts + originSide: 0.18, // metres to the side (+ follows `Ability#side`) + lineHeight: 1.2, // metres above the floor the whole run sits at + establishTrips: 1.0, // crossings of the line the pattern takes to establish + + /* ------------------------------------------------------------------ */ + /* The standing wave */ + /* ------------------------------------------------------------------ */ + halfWaves: 8, // half-wavelengths that fit the line — the mode number + lockSpacing: 1.0, // 0 uses `chordSpacing` raw, 1 locks it to the resonance + pockets: 4, // antinodes that get a distortion pocket (capped at 4) + + /* ------------------------------------------------------------------ */ + /* The ring train — vfx/Shell.js, prefix `chord`, mode RING_TRAIN */ + /* ------------------------------------------------------------------ */ + chordRadius: 0.3, // ring radius as the chord is struck, metres + chordRadiusEnd: 1.35, // ... once the pattern has established, metres + chordExpand: 2.0, // easing exponent on that growth + chordSpan: 6.0, // run length, metres — the cast overrides this every frame + chordLift: 0.0, // offset along the axis from the strike point, metres + chordRings: 8, // live rings (derived from `halfWaves` while locked) + chordSpacing: 4.4, // wavelength, metres (derived while locked) + chordRingSpeed: 11.0, // metres/second a ring travels at + chordRingThickness: 0.15, // radial thickness of one ring, metres + chordRingSharp: 1.8, // how hard its profile falls off + chordReflect: 1.0, // 0 = rings die at the far end, 1 = perfect reflection + chordStanding: 1.0, // how much of the standing envelope modulates them + chordSwell: 0.55, // extra radius at an antinode, × radius + chordOpacity: 1.0, + chordGlow: 2.4, // emissive gain into bloom + chordSoftFade: 0.6, // metres of depth fade against the opaque scene + chordColorBody: '#5fa0c0', // the body of a ring + chordColorRim: '#d0f0ff', // its edge + chordColorEdge: '#ffffff', // the hottest mark it has — antinodes only + + /* --- inert on RING_TRAIN: the dome, cone and sun-disc groups --- */ + // `Shell` reads its whole contract whatever the mode, so these keep the + // module's audit quiet and stay reachable in the panel. Dragging one of them + // does nothing in this mode, and that is the honest answer. + chordHeight: 1.0, // axial extent, × radius (surface modes only) + chordDisplace: 0.0, // billow along the normal, × radius + chordNoiseScale: 1.8, // billow features per unit radius + chordNoiseSpeed: 0.6, // Hz the billow crawls at + chordTurbulence: 1.0, // master on the billow + chordFill: 0.0, // body fill (surface modes only) + chordRim: 1.0, // fresnel rim strength + chordRimPower: 2.2, // how tight that rim is + chordSeal: 1.4, // DOME: brightness where it meets the floor + chordSealWidth: 0.12, // DOME: width of that band + chordEdge: 1.2, // CONE: brightness of the leading lip + chordEdgeWidth: 0.16, // CONE: width of that lip + chordConeCurve: 1.0, // CONE: flare curve + chordDissolve: 0.9, // age dissolve on the surface modes + chordCoronaReach: 1.8, // SUNDISC: drawn reach past the rim, × radius + chordCorona: 1.3, // SUNDISC: corona brightness + chordCoronaLength: 0.55, // SUNDISC: how far the filaments reach, × radius + chordCoronaScale: 5.0, // SUNDISC: filament features per radius + chordCoronaWarp: 0.45, // SUNDISC: domain warp + chordCoronaSpeed: 0.7, // SUNDISC: Hz they crawl at + chordCoronaSharp: 0.72, // SUNDISC: threshold + chordGranule: 0.45, // SUNDISC: convection cells + chordGranuleScale: 6.0, // SUNDISC: cells per radius + chordRimWidth: 0.18, // SUNDISC: hot band inside the rim, × radius + chordColorCorona: '#ffb44a', // SUNDISC filaments + + /* ------------------------------------------------------------------ */ + /* The pockets — vfx/Distortion.js, SHOCK, one per antinode */ + /* ------------------------------------------------------------------ */ + /** + * A pocket is a shell of compressed air standing on an antinode. Its + * amplitude is `standingAt()` at that metre, so the four of them pulse + * together on the temporal term of the superposition — which is exactly what + * a standing wave does, and it is why the nodes stay empty. + * + * `pocketStrength` is a **screen fraction**, not metres, and the post pass + * applies `post.distortion × global.distortion` on top of it once. Never + * multiply either of those in here. + */ + pocketWidth: 4.4, // metres, the quad the pocket is drawn on + pocketHeight: 4.4, // metres + pocketRadius: 1.9, // metres — where the falloff reaches zero + pocketWindow: 0.55, // 0..1 of the radius the falloff starts at + pocketWave: 0.6, // metres — the radius of the compressed shell + pocketThickness: 0.34, // metres — how thick that shell is + pocketCompression: 1.15, // strength just inside the front + pocketRarefaction: 0.75, // ... and just outside it + pocketRings: 2, // 1..4 concentric fronts per pocket + pocketRingGap: 0.42, // metres between them + pocketRingDecay: 0.7, // how much dimmer each front out is + pocketStrength: 0.055, // screen widths at post.distortion = 1 + pocketMaxOffset: 0.16, // hard ceiling on that offset + pocketOpacity: 1.0, + pocketDepthReject: 0.55, // 0..1 how hard geometry in front rejects it + pocketDepthFade: 1.1, // metres of soft fade against that geometry + pocketPerspective: 0.65, // 0..1 how much distance shrinks it + pocketPerspectiveRef: 10, // metres at which it is drawn unscaled + + /* ------------------------------------------------------------------ */ + /* Dust, grit and sparks */ + /* ------------------------------------------------------------------ */ + /** + * Each system is coloured by a four-stop gradient sampled over the + * particle's own lifetime, `A` at birth through `D` as it dies. The dust and + * the grit are emitted **only at the antinodes**, with a count proportional + * to the standing amplitude there, which is what makes the pattern legible + * without drawing a single marker. + */ + dustRate: 200, // motes lifted at the antinodes, particles/second (all of them) + dustSize: 0.07, + dustSpeed: 2.4, // metres/second off the line + dustLifetime: 1.5, + dustRise: 1.6, // upward drift, metres/second + dustTurbulence: 0.5, + colorDustA: '#ffffff', + colorDustB: '#d0f0ff', + colorDustC: '#5fa0c0', + colorDustD: '#102030', + gritRate: 60, // grit hopping off the floor under an antinode, /second + gritSize: 0.05, + gritSpeed: 3.6, + gritLifetime: 1.1, + gritGravity: -13.0, // metres/second² + colorGritA: '#9fb8c8', + colorGritB: '#6a8496', + colorGritC: '#33454f', + colorGritD: '#102030', + sparkRate: 0, // continuous sparks — off by default; the chord is not a bolt + sparkSize: 0.12, + sparkSpeed: 7.5, + sparkLifetime: 0.5, + sparkGravity: -9.0, + sparkStretch: 0.2, // how far a spark smears along its velocity + colorSparkA: '#ffffff', + colorSparkB: '#d0f0ff', + colorSparkC: '#7fc0dc', + colorSparkD: '#204058', + + /* --- dynamic light --- */ + lightIntensity: 12.0, + lightRadius: 15.0, + lightColor: '#8fd4ee', + lightPulse: 0.55, // how much of the light rides the standing breath, 0..1 + + /* --- the strike and the reflection --- */ + strikeSize: 1.6, // the shell at the caster's hand, metres + strikeIntensity: 1.5, + strikeSparks: 90, // sparks thrown as the chord is struck + strikeFlash: 0.1, // screen flash on the strike + strikeShake: 0.5, + shakeDuration: 0.4, + reflectSize: 2.4, // the shell at the far end when the wave turns round, metres + reflectIntensity: 1.3, + reflectSparks: 120, // sparks thrown by the reflection + reflectShake: 0.7, + rumble: 0.035, // continuous shake while the chord rings + colorStrikeA: '#5fa0c0', // strike/reflection shell body + colorStrikeB: '#d0f0ff', // its billow + colorStrikeC: '#ffffff', // the filaments racing across it + colorFlash: '#d0f0ff' // the full-screen flash +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Resonant Chord. + * + * **The standing wave** is the folder that matters. `halfWaves` is an integer + * mode number: at 8 there are nine nodes and eight antinodes on the line, and + * every consumer — the rings, the pockets, the dust, the light — re-places + * itself the instant it changes. `chordReflect` is the other one to reach for: + * take it to zero and the whole pattern collapses into a plain outbound train, + * which is the clearest way to see that the nodes were real. + */ +export const resonanceSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 4, 200, 0.5, 'strike speed'], + ['lifetime', 0.2, 12, 0.05, 'ring time'], + ['fadeTime', 0.1, 8, 0.05, 'decay time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'], + ['originForward', -1, 4, 0.01, 'run start (m)'], + ['originSide', -2, 2, 0.01, 'run lateral (m)'], + ['lineHeight', 0, 4, 0.01, 'run height (m)'] + ], + 'The standing wave': [ + ['halfWaves', 1, 24, 1, 'half-waves on the line'], + ['lockSpacing', 0, 1, 0.01, 'lock to resonance'], + ['establishTrips', 0.1, 4, 0.05, 'crossings to establish'], + ['chordReflect', 0, 1, 0.01, 'reflection'], + ['chordStanding', 0, 1, 0.01, 'standing envelope'], + ['chordSwell', 0, 2, 0.01, 'antinode swell'], + ['chordSpacing', 0.1, 12, 0.01, 'wavelength (m, unlocked)'], + ['chordRings', 1, 24, 1, 'rings (unlocked)'], + ['chordRingSpeed', 0, 40, 0.05, 'ring speed (m/s)'] + ], + 'The rings': [ + ['chordRadius', 0.01, 12, 0.01, 'start radius (m)'], + ['chordRadiusEnd', 0.01, 20, 0.01, 'end radius (m)'], + ['chordExpand', 0.2, 12, 0.01, 'expansion curve'], + ['chordLift', -2, 2, 0.01, 'lift along the run (m)'], + ['chordRingThickness', 0.01, 2, 0.01, 'thickness (m)'], + ['chordRingSharp', 0.05, 8, 0.01, 'profile'], + ['chordOpacity', 0, 1, 0.01, 'opacity'], + ['chordGlow', 0, 8, 0.01, 'glow'], + ['chordSoftFade', 0, 3, 0.01, 'soft fade (m)'], + 'chordColorBody', + 'chordColorRim', + 'chordColorEdge' + ], + 'The rings/Inert on this mode': [ + ['chordHeight', 0.02, 4, 0.01, 'height × radius'], + ['chordDisplace', 0, 1.5, 0.01, 'billow'], + ['chordNoiseScale', 0.1, 10, 0.01, 'billow scale'], + ['chordNoiseSpeed', 0, 4, 0.01, 'billow Hz'], + ['chordTurbulence', 0, 3, 0.01, 'turbulence'], + ['chordFill', 0, 1, 0.01, 'body fill'], + ['chordRim', 0, 3, 0.01, 'rim'], + ['chordRimPower', 0.1, 8, 0.01, 'rim power'], + ['chordDissolve', 0, 2, 0.01, 'dissolve'], + ['chordSeal', 0, 4, 0.01, 'floor seal'], + ['chordSealWidth', 0.01, 0.6, 0.01, 'seal width'], + ['chordEdge', 0, 4, 0.01, 'leading lip'], + ['chordEdgeWidth', 0.01, 0.8, 0.01, 'lip width'], + ['chordConeCurve', 0.1, 4, 0.01, 'flare curve'], + ['chordSpan', 0.1, 40, 0.05, 'run length (m)'], + ['chordCoronaReach', 1, 4, 0.01, 'drawn reach'], + ['chordCorona', 0, 4, 0.01, 'corona'], + ['chordCoronaLength', 0, 3, 0.01, 'corona length'], + ['chordCoronaScale', 0.5, 20, 0.1, 'corona scale'], + ['chordCoronaWarp', 0, 2, 0.01, 'corona warp'], + ['chordCoronaSpeed', 0, 4, 0.01, 'corona Hz'], + ['chordCoronaSharp', 0, 0.98, 0.01, 'corona threshold'], + ['chordGranule', 0, 2, 0.01, 'granulation'], + ['chordGranuleScale', 0.5, 24, 0.1, 'granule scale'], + ['chordRimWidth', 0.01, 0.6, 0.01, 'rim band'], + 'chordColorCorona' + ], + 'The pockets': [ + ['pockets', 0, 4, 1, 'antinodes with a pocket'], + ['pocketStrength', 0, 0.4, 0.001, 'strength (screen widths)'], + ['pocketRadius', 0.1, 8, 0.05, 'falloff radius (m)'], + ['pocketWindow', 0, 0.99, 0.01, 'falloff start'], + ['pocketWave', 0.05, 4, 0.01, 'shell radius (m)'], + ['pocketThickness', 0.02, 2, 0.01, 'shell thickness (m)'], + ['pocketCompression', 0, 3, 0.01, 'compression'], + ['pocketRarefaction', 0, 3, 0.01, 'rarefaction'], + ['pocketRings', 1, 4, 1, 'fronts'], + ['pocketRingGap', 0.05, 3, 0.01, 'front gap (m)'], + ['pocketRingDecay', 0, 3, 0.01, 'front decay'], + ['pocketMaxOffset', 0.01, 1, 0.005, 'offset ceiling'], + ['pocketOpacity', 0, 1, 0.01, 'opacity'], + ['pocketWidth', 0.5, 16, 0.1, 'quad width (m)'], + ['pocketHeight', 0.5, 16, 0.1, 'quad height (m)'], + ['pocketDepthReject', 0, 1, 0.01, 'depth reject'], + ['pocketDepthFade', 0.01, 4, 0.01, 'depth fade (m)'], + ['pocketPerspective', 0, 1, 0.01, 'perspective'], + ['pocketPerspectiveRef', 1, 40, 0.5, 'perspective ref (m)'] + ], + 'Dust & grit': [ + ['dustRate', 0, 1200, 1, 'dust rate'], + ['dustSize', 0.005, 0.6, 0.005, 'dust size'], + ['dustSpeed', 0, 20, 0.1, 'dust speed'], + ['dustLifetime', 0.05, 6, 0.05, 'dust lifetime'], + ['dustRise', -3, 8, 0.05, 'dust rise'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['gritRate', 0, 400, 1, 'grit rate'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 20, 0.1, 'grit speed'], + ['gritLifetime', 0.05, 4, 0.05, 'grit lifetime'], + ['gritGravity', -40, 0, 0.1, 'grit gravity'], + ['colorDust*', 'Dust colour'], + ['colorGrit*', 'Grit colour'] + ], + 'Sparks': [ + ['sparkRate', 0, 600, 1, 'spark rate'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['colorSpark*', 'Spark colour'] + ], + 'Strike, reflection & light': [ + ['strikeSize', 0.1, 10, 0.05, 'strike shell (m)'], + ['strikeIntensity', 0, 5, 0.01, 'strike intensity'], + ['strikeSparks', 0, 600, 1, 'strike sparks'], + ['strikeFlash', 0, 2, 0.01, 'screen flash'], + ['strikeShake', 0, 3, 0.01, 'strike shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['reflectSize', 0.1, 12, 0.05, 'reflection shell (m)'], + ['reflectIntensity', 0, 5, 0.01, 'reflection intensity'], + ['reflectSparks', 0, 600, 1, 'reflection sparks'], + ['reflectShake', 0, 3, 0.01, 'reflection shake'], + ['rumble', 0, 0.5, 0.005, 'ringing rumble'], + 'colorStrikeA', + 'colorStrikeB', + 'colorStrikeC', + 'colorFlash', + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'light on the breath'], + 'lightColor' + ] +}; diff --git a/src/config/abilities/rewind.js b/src/config/abilities/rewind.js new file mode 100644 index 0000000..03beb6b --- /dev/null +++ b/src/config/abilities/rewind.js @@ -0,0 +1,360 @@ +/* ================================================================== */ +/* REWIND — chrono */ +/* ================================================================== */ +/** + * The floor is torn open along the line, and then it is not. + * + * There is nothing in this block that describes a *reversed* effect, because + * there is no such thing here. Everything under "The wake" is an ordinary + * gouged track and an ordinary shower of debris, authored exactly as Rolling + * Ruin's would be; everything under "The beat" describes what the **clock** they + * are handed does. Point a decreasing number at a closed form and it runs + * backwards for free — that is the whole ability, and it is why this block is + * two thirds the length of the one next to it. + * + * ## The five numbers that are the ability + * + * `forwardRate`, `turnAt`, `holdTime`, `backRate` and `reachBack` are + * `reverseParams()` from `vfx/TimeControl.js`, one for one. Time runs forward at + * `forwardRate` until `turnAt` seconds of cast age, stops dead for `holdTime`, + * and then runs backwards at `backRate` until it reaches `reachBack`. + * + * **`holdTime` is not decoration and it is the first thing to try setting to + * zero.** A reversal with no pause at the top reads as a glitch: the eye needs a + * moment in which nothing at all moves in order to understand that what follows + * is the same motion inverted rather than a different motion. At 0 the slot + * looks broken. At 0.16 it looks deliberate. Nothing else about the ability + * changes between those two values. + * + * **`reachBack` is negative on purpose.** It is the earliest instant the clock + * may reach, measured in cast age, so a negative value takes the wake back past + * the moment the cast started — the dust that was already hanging in the air + * when you pressed the button gathers up too. Set it to 0 and the reversal stops + * politely at the cast; set it to -1.2 and it reaches into whatever was standing + * there before. + * + * ## Why there is not a single decal in here + * + * `GroundDecals` is a pooled one-shot system on the app's forward clock, and + * **nothing can un-spawn a decal**. A scorch or a crack left standing on the + * floor while the debris that made it flies back up and the gouge closes under + * it is the one detail that gives the whole thing away, and it gives it away + * instantly. So the floor here is a `GroundField(RUT)` — a live mesh whose track + * is re-resolved from `progress` every frame — and the ability is entirely + * decal-free. The same argument retired a `DecalType.CRACK` at the break and a + * `DUSTRING` at each station; both looked better on the forward leg and both + * ruined the return. + * + * The one exception, and it is deliberate, is the pressure shell at the turn. + * See `turnBurstSize`. + * + * ## Two lifetimes that are load-bearing + * + * `shardLife` **must outlast the whole beat**. `ShatterField` retires a fragment + * for good the moment its own age passes its lifetime, and no clock brings a + * retired fragment back — so a shard that expires on the forward leg simply is + * not there to reassemble. Keep it above `turnAt - breakAt`. + * + * `dustLife` and `gritLife` want the **opposite**: keep them *under* the length + * of the beat. When the time region lets go, everything inside jumps from its + * rewound age back to its true one in a single frame, and anything still alive + * at that instant pops out of existence. Let them expire on their own and + * nothing does. + */ +export const rewind = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 2.0, // closer than this and the cast is refused + speed: 52.0, // how fast the tearing front travels, metres/second + cooldown: 1.8, // seconds + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beat (vfx/TimeControl.js reverseParams) --- */ + forwardRate: 1.0, // × real time before the turn + turnAt: 1.05, // seconds of cast age at which time stops going forward + holdTime: 0.16, // seconds held at the top — the beat you can see coming + backRate: 2.2, // × the speed it then runs backwards at + reachBack: -0.3, // earliest instant the clock may reach, seconds of cast age + settleTime: 0.4, // seconds after the clock lands on that floor before DONE + + /* --- the sphere the reversal actually reaches into --- */ + // Everything that injects `shaders/lib/timewarp.glsl.js` and stands inside + // this sphere runs the bent clock, including other casts' particles. Outside + // it, the world carries on. + fieldRadius: 12.0, // metres — outer edge + fieldAlong: 0.5, // 0..1 where along the cast line the sphere is centred + fieldHeight: 2.0, // metres above the floor its centre sits at + fieldCore: 0.72, // 0..1 of the radius reversed at full strength + fieldStrength: 1.0, // 0..1 master weight; 0 reverses nothing but this ability + + /* --- the wake: GroundField(RUT) --- */ + rutTime: 0.55, // seconds the gouge takes to reach the end of the line + rutWidth: 0.72, // metres — half-width of the track + rutDepth: 0.3, // metres it is sunk into the floor + rutHeight: 0.02, // metres above the floor the quad sits at + rutEdge: 0.22, // metres of feather on the head of the track + rutRagged: 0.2, // how far the head wanders, fraction of the radius + rutRaggedScale: 0.75, // lobes per metre on that wander + rutWarp: 0.4, // metres of domain warp on those lobes + rutRelief: 0.85, // how hard the height field tilts the fake normal + rutNormalStep: 0.05, // metres between the height taps + rutAmbient: 0.26, // floor on the diffuse term + rutWrap: 0.4, // 0..1 wraps the terminator round the back + rutSpecular: 0.22, + rutGloss: 18.0, // Blinn exponent + rutParallax: 0.3, // metres of view-driven offset on the interior detail + rutSharp: 0.55, // 0..1 how hard the trough profile falls off + rutChatter: 0.42, // metres between the transverse chatter marks + rutChatterDepth: 0.5, // 0..1 how deep they cut + rutSpoil: 0.11, // metres the spoil heaped along the lip stands proud + rutSpoilWidth: 0.16, // metres wide that lip is + rutDrift: 0.16, // how far the track wanders off the cast line + rutEmissive: 0.35, // multiplier on every glowing term (there is almost none) + rutOpacity: 0.95, + rutDepthFade: 0.4, // metres of soft fade against standing geometry + colorRutBase: '#4c4741', // the broken stone + colorRutEdge: '#8f877a', // lips and highlights + colorRutChurn: '#c8a878', // the freshly torn face + colorRutDeep: '#14120f', // the bottom of the trough + + /* --- the debris: ShatterField --- */ + breakAt: 0.3, // seconds of the bent clock the first station gives way at + breakGap: 0.16, // seconds between stations (there are six) + breakShards: 13, // fragments thrown at one station + shardSize: 0.2, // metres — the unit geometry's scale + shardSizeJitter: 0.6, // ± fraction + shardScatter: 0.28, // metres of scatter about the station + shardHeight: 0.18, // metres the station sits above the floor + shardSpeed: 5.4, // metres/second + shardSpeedJitter: 0.55, // ± fraction + shardSpread: 0.4, // 0 throws every fragment downrange, 1 is fully random + shardUp: 0.72, // how much +Y is folded into the throw + shardGravity: -19.0, // metres/second² + shardDrag: 0.7, // 1/second + shardSpin: 8.0, // radians/second of tumble + shardSpinJitter: 0.75, // ± fraction + shardShrink: 0.25, // 0..1 of its size lost by the end of life + shardShrinkPower: 2.2, // how late that shrink bites + shardFloor: 0.0, // metres — fragments rest on the floor + shardFloorSpin: 0.18, // fraction of the tumble kept once grounded + shardLife: 1.5, // seconds — MUST outlast turnAt − breakAt. See the header + shardOpacity: 1.0, + shardGlow: 0.12, + shardRim: 0.5, + shardRimPower: 2.6, + shardShade: 0.75, + shardAmbient: 0.3, + shardFadeStart: 0.8, // 0..1 of life before it starts fading + shardSoft: 0.3, // metres of soft intersection + shardSceneMix: 0.0, // no scene sample: these are rock, not glass + shardRefract: 0.0, + shardSaturation: 0.9, + colorShardA: '#6b6259', // lit face + colorShardB: '#3a352f', // shaded face + colorShardEdge: '#c9b492', // the broken edge + colorShardScene: '#8f877a', // what a translucent one would show (unused at 0 mix) + + /* --- dust and grit --- */ + /** + * Two systems, both on four-stop lifetime gradients (`A` at birth through `D` + * as it dies), and both emitted **only while the clock is going forward** — + * `reverseRate()` is the switch. A spawn is a log entry rather than a state + * and a `RateEmitter` fed a negative step banks credit it dumps later, so the + * reverse leg emits nothing at all and the region un-spawns what is already + * there by driving each particle's own age back to zero at its emitter. + */ + dustRate: 210, // dust drawn up along the head of the gouge, particles/second + dustSize: 0.85, + dustSpeed: 2.1, // metres/second + dustLife: 1.6, // seconds — keep under the beat. See the header + dustRise: 0.6, // metres/second + dustSpread: 0.85, // cone + dustOpacity: 0.1, + dustTurbulence: 0.5, + colorDustA: '#8a7f6e', + colorDustB: '#6f6558', + colorDustC: '#514a41', + colorDustD: '#2b2723', + + gritRate: 90, // chips flicked out of the trough, particles/second + gritSize: 0.05, + gritSpeed: 4.6, // metres/second + gritLife: 1.4, // seconds — keep under the beat + gritGravity: -21.0, // metres/second² + gritSpread: 0.7, // cone + colorGritA: '#7a6f60', + colorGritB: '#5c534a', + colorGritC: '#3d3833', + colorGritD: '#26221f', + + /* --- the turn: the one thing that does not reverse --- */ + turnBurstSize: 3.6, // the pressure shell that announces the turn, metres + turnBurstIntensity: 1.0, + turnFlash: 0.11, // screen flash + turnShake: 0.3, // camera shake + shakeDuration: 0.5, // seconds it decays over + colorTurnA: '#8a7f6e', // burst shell + colorTurnB: '#d9c49a', // burst body + colorTurnC: '#ffeecb', // burst filaments + colorTurnFlash: '#e8d8b4', + + /* --- casting --- */ + castFlash: 0.07, // screen flash as the front leaves the caster + colorCastFlash: '#d9c49a', + rumble: 0.03, // continuous shake while the gouge is opening + + /* --- dynamic light --- */ + lightIntensity: 15.0, + lightRadius: 15.0, + lightColor: '#d9a95e', // warm, and deliberately of a piece with `colorTurnB` + lightSag: 0.35 // how far the key dips once the clock has turned over +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Rewind. + * + * **The beat** is the whole folder. Cast it, pause with **P** halfway through + * the return, and drag `backRate`: the debris in the air re-flies from scratch + * and the gouge re-closes to a different length, because `reverseTime()` is a + * closed form over these five numbers and nothing was ever accumulated. That is + * the difference between the closed form and `TimeWarpClock`, and it is the + * reason the module's own doc says to prefer it. + * + * `fieldRadius` is the second thing to touch, and it is the one to touch with + * *another* ability standing on the line: shrink it to four metres and only the + * middle of the wake runs backwards while both ends carry on. + */ +export const rewindSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 300, 1, 'tear speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The beat': [ + ['forwardRate', 0, 3, 0.01, 'forward rate ×'], + ['turnAt', 0.1, 5, 0.01, 'turn at (s)'], + ['holdTime', 0, 1.5, 0.01, 'hold at the top (s)'], + ['backRate', 0.1, 8, 0.05, 'reverse rate ×'], + ['reachBack', -4, 0, 0.01, 'reach back to (s)'], + ['settleTime', 0.05, 3, 0.05, 'settle (s)'] + ], + 'The sphere it reaches into': [ + ['fieldRadius', 0.5, 40, 0.1, 'radius (m)'], + ['fieldAlong', 0, 1, 0.01, 'centre along the line'], + ['fieldHeight', 0, 10, 0.05, 'centre height (m)'], + ['fieldCore', 0, 0.98, 0.01, 'full-strength core'], + ['fieldStrength', 0, 1, 0.01, 'grip'] + ], + 'The wake': [ + ['rutTime', 0.05, 4, 0.01, 'gouge time (s)'], + ['rutWidth', 0.05, 4, 0.01, 'track half-width (m)'], + ['rutDepth', 0, 1.5, 0.01, 'depth (m)'], + ['rutHeight', 0.005, 0.2, 0.005, 'height above floor (m)'], + ['rutEdge', 0.02, 2, 0.01, 'head feather (m)'], + ['rutRagged', 0, 1, 0.01, 'head wander'], + ['rutRaggedScale', 0.05, 3, 0.01, 'wander lobes / m'], + ['rutWarp', 0, 3, 0.01, 'domain warp (m)'], + ['rutRelief', 0, 2, 0.01, 'relief'], + ['rutNormalStep', 0.005, 0.4, 0.005, 'normal step (m)'], + ['rutAmbient', 0, 1, 0.01, 'ambient'], + ['rutWrap', 0, 1, 0.01, 'terminator wrap'], + ['rutSpecular', 0, 2, 0.01, 'specular'], + ['rutGloss', 1, 120, 1, 'gloss'], + ['rutParallax', 0, 1.5, 0.01, 'parallax (m)'], + ['rutSharp', 0, 1, 0.01, 'trough sharpness'], + ['rutChatter', 0.05, 2, 0.01, 'chatter pitch (m)'], + ['rutChatterDepth', 0, 1, 0.01, 'chatter depth'], + ['rutSpoil', 0, 0.6, 0.005, 'spoil lip (m)'], + ['rutSpoilWidth', 0, 0.6, 0.005, 'spoil width (m)'], + ['rutDrift', 0, 1.5, 0.01, 'track drift'], + ['rutEmissive', 0, 3, 0.01, 'emissive'], + ['rutOpacity', 0, 1, 0.01, 'opacity'], + ['rutDepthFade', 0.02, 3, 0.01, 'soft intersection (m)'], + ['colorRutBase', 'broken stone'], + ['colorRutEdge', 'lip'], + ['colorRutChurn', 'torn face'], + ['colorRutDeep', 'trough'] + ], + 'The debris': [ + ['breakAt', 0, 3, 0.01, 'first station at (s)'], + ['breakGap', 0.01, 1, 0.005, 'between stations (s)'], + ['breakShards', 0, 40, 1, 'fragments / station'], + ['shardSize', 0.02, 1, 0.005, 'size (m)'], + ['shardSizeJitter', 0, 1, 0.01, 'size jitter'], + ['shardScatter', 0, 2, 0.01, 'scatter (m)'], + ['shardHeight', 0, 2, 0.01, 'station height (m)'], + ['shardSpeed', 0, 25, 0.1, 'throw speed'], + ['shardSpeedJitter', 0, 1, 0.01, 'speed jitter'], + ['shardSpread', 0, 1, 0.01, 'throw spread'], + ['shardUp', 0, 1, 0.01, 'up bias'], + ['shardGravity', -60, 0, 0.1, 'gravity'], + ['shardDrag', 0, 4, 0.01, 'drag'], + ['shardSpin', 0, 30, 0.1, 'tumble'], + ['shardSpinJitter', 0, 1, 0.01, 'tumble jitter'], + ['shardShrink', 0, 1, 0.01, 'shrink'], + ['shardShrinkPower', 0.2, 6, 0.05, 'shrink curve'], + ['shardFloor', -1, 2, 0.01, 'rest height (m)'], + ['shardFloorSpin', 0, 1, 0.01, 'grounded tumble'], + ['shardLife', 0.2, 6, 0.05, 'lifetime (s) — must outlast the beat'], + ['shardOpacity', 0, 1, 0.01, 'opacity'], + ['shardGlow', 0, 2, 0.01, 'glow'], + ['shardRim', 0, 3, 0.01, 'rim'], + ['shardRimPower', 0.2, 8, 0.05, 'rim tightness'], + ['shardShade', 0, 2, 0.01, 'shading'], + ['shardAmbient', 0, 1, 0.01, 'ambient'], + ['shardFadeStart', 0, 1, 0.01, 'fade starts at'], + ['shardSoft', 0.02, 2, 0.01, 'soft intersection (m)'], + ['shardSceneMix', 0, 1, 0.01, 'scene sample'], + ['shardRefract', 0, 1, 0.01, 'refraction'], + ['shardSaturation', 0, 2, 0.01, 'saturation'], + ['colorShardA', 'lit face'], + ['colorShardB', 'shaded face'], + ['colorShardEdge', 'broken edge'], + ['colorShardScene', 'scene tint'] + ], + 'Dust & grit': [ + ['dustRate', 0, 900, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 20, 0.1, 'dust speed'], + ['dustLife', 0.1, 6, 0.05, 'dust lifetime (s)'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustSpread', 0, 1, 0.01, 'dust cone'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['gritRate', 0, 600, 1, 'grit rate'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 25, 0.1, 'grit speed'], + ['gritLife', 0.1, 5, 0.05, 'grit lifetime (s)'], + ['gritGravity', -60, 0, 0.1, 'grit gravity'], + ['gritSpread', 0, 1, 0.01, 'grit cone'], + ['colorDust*', 'Dust colour'], + ['colorGrit*', 'Grit colour'] + ], + 'The turn': [ + ['turnBurstSize', 0.2, 14, 0.05, 'burst size (m)'], + ['turnBurstIntensity', 0, 4, 0.01, 'burst intensity'], + ['turnFlash', 0, 1, 0.005, 'screen flash'], + ['turnShake', 0, 2, 0.01, 'shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration (s)'], + ['colorTurnA', 'burst shell'], + ['colorTurnB', 'burst body'], + ['colorTurnC', 'burst filaments'], + ['colorTurnFlash', 'flash colour'] + ], + 'Casting': [ + ['castFlash', 0, 1, 0.005, 'release flash'], + ['colorCastFlash', 'release flash colour'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightSag', 0, 1, 0.01, 'dip once time turns'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/rime.js b/src/config/abilities/rime.js new file mode 100644 index 0000000..0ce6eb7 --- /dev/null +++ b/src/config/abilities/rime.js @@ -0,0 +1,455 @@ +/* ================================================================== */ +/* RIME — Rimewalker */ +/* ================================================================== */ +/** + * Sheet ice. Not spikes: plates a few centimetres thick that glaze the floor + * behind a freezing front, lock, and then peel their downwind edge up off the + * stone like paper curling off a hot pan. + * + * Three groups of numbers matter more than the rest: + * + * - **`curl`, `curlTime`, `curlDelay`** — the roll. `curl` is the total angle + * the rim turns through, in radians: at 0 the field is a frozen pavement, at + * π every plate is standing on its hinge with its rim pointing back at the + * floor. It is a live uniform, so a standing field peels and unpeels under + * the slider with the clock stopped, which is the control worth reaching for + * first. + * - **`thickness` against `plateHeight`** — how thin the ice reads. The plate + * is generated in unit space and scaled to `plateHeight` metres tall, so the + * sheet's own thickness is authored in metres here and divided by the height + * inside the geometry factory. That is also why there is no height jitter in + * this block: jittering the height would jitter the thickness with it, and a + * field of sheets that are not all the same thickness does not read as one + * event. Size variety comes from `radiusJitter` instead. + * - **`sublimeTime`, `sublimeEdge`, `sublimeGlow`** — the directional fade. + * The corridor does not dim; a front runs it from the caster's end forward + * and the ice in front of that line is untouched while the ice behind it has + * gone. Widen `sublimeEdge` and you can watch the band eat the plates. + * + * Everything with a unit below is resolved against this block inside the update + * loop, on every frame including a zero-length one. The cast captures a seed + * and a handful of timestamps and nothing else. + */ +export const rime = { + /* --- the cast --- */ + range: 16.0, // maximum cast distance, metres + minRange: 2.0, // closer than this and the cast is refused + speed: 12.0, // how fast the freezing front runs the line, metres/second + cooldown: 1.0, // seconds + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + lifetime: 1.7, // seconds the corridor stands once the front has landed + snapDelay: 0.3, // seconds after the hold before the curls snap off + sublimeTime: 1.6, // seconds the sublimation front takes to run the corridor + + /* --- where the plates are laid --- */ + plateCount: 168, // plates in the corridor, capped at the field's capacity + clusterShare: 0.16, // 0..1 of them held back for the ring at the impact point + clusterRadius: 1.6, // metres, radius of that ring + widthNear: 0.5, // half-width of the glazed band at the caster, metres + width: 1.7, // ... and at the far end, metres + widthCurve: 0.85, // <1 flares the band early, >1 keeps it narrow then opens + frontBias: 1.0, // <1 crowds the plates toward the far end, unitless + clumping: 0.8, // >1 pulls them onto the centre line, unitless + scatter: 0.85, // extra lateral jitter, fraction of the local half-width + lockStagger: 0.26, // seconds of random delay between neighbouring plates + tilt: 0.1, // radians of random tip, so the pavement is not perfectly flat + twist: 1.0, // 0..1 of a full turn of random yaw per plate + + /* --- the eruption of one plate --- */ + // These are SCALE emergence, not PUSH: a plate accretes on the floor rather + // than punching up out of it, because ice on stone freezes, it does not erupt. + riseTime: 0.14, // seconds from nothing to full size + riseOvershoot: 0.1, // how far past full size the lock carries + settle: 0.3, // seconds that overshoot damps out over + springRate: 20.0, // radians/second of the overshoot ring + birthScale: 0.35, // footprint scale at the instant it locks + + /* --- the size of one plate --- */ + plateRadius: 0.95, // plan diameter at the caster, metres + plateRadiusFar: 1.3, // ... and at the far end, metres + radiusCurve: 0.7, // how the plan size ramps along the cast + radiusJitter: 0.34, // ± fraction, the only size variety in the field + plateHeight: 0.85, // vertical scale of a plate at the caster, metres + plateHeightFar: 1.05, // ... and at the far end, metres + heightCurve: 0.9, // how late that ramp climbs + crown: 0.25, // 0..1 — how much smaller the plates on the flanks are + crownPower: 1.4, // how sharply that dome falls off + + /* --- the sheet itself (these rebuild the geometry when they move) --- */ + facets: 11, // sides of the plan polygon, 7–14 read best + rings: 8, // radial subdivisions; the roll needs these to bend smoothly + rimJitter: 0.34, // 0..1 — how irregular the plan outline is + buckle: 0.055, // unit-space non-planarity, so a flat plate still catches light + thickness: 0.032, // metres — the actual thickness of the ice + + /* --- the curl --- */ + curl: 2.5, // radians the rim rolls through when fully peeled + curlTime: 0.95, // seconds one plate takes to go from flat to fully peeled + curlDelay: 0.34, // 0..1 of that clock spent lying flat first — the "lock" + curlHinge: -0.06, // unit space; where across the plate the roll starts + curlSpread: 0.55, // radians of per-plate jitter on the peel bearing + + /* --- the sheet material --- */ + depthTint: 1.15, // how hard the body tint comes in at a grazing angle + depthPower: 1.5, // how fast it comes in + translucency: 0.9, // interior glow through the rime grain + fresnel: 1.6, // rim brightness + fresnelPower: 2.2, // how tight that rim is + lipGlow: 1.8, // the highlight on the rolled edge — the signature of the slot + lipWidth: 0.45, // 0..1 of the roll that counts as lip + lipPower: 1.6, // how sharply the lip term falls off inward + grain: 0.4, // rime grain over the sheet + grainScale: 4.5, // grain features per metre + glint: 1.3, // pinpoint sparkle + glintScale: 26.0, // glints per metre + glintSpeed: 0.5, // how fast they scroll + glow: 1.25, // overall emissive gain + birthGlow: 2.6, // flash as a plate locks + birthPower: 6.0, // how fast that flash dies inside the curl clock + opacity: 0.86, + roughness: 0.14, // MeshStandardMaterial roughness — drives the env reflection + envIntensity: 1.1, // how much of the HDR probe the sheet reflects + colorSheet: '#dff6ff', // the ice face-on, where it is thinnest + colorDeep: '#123b4a', // the body tint at a grazing angle + colorLip: '#ffffff', // the rolled edge and the frost grain + colorGlow: '#7ecbe0', // everything emissive on the plate + + /* --- the sublimation front --- */ + sublimeEdge: 1.1, // metres of the dissolve band + sublimeGlow: 2.2, // how bright the band burns as the ice turns to vapour + colorSublime: '#bfeaff', // that band + + /* --- the glaze pool where the front grounds out --- */ + // One `GroundField` in PLATE mode: interlocking sheet ice on the floor, with + // its own plates lifting downwind. It is a disc because that is what the + // module is shaped like — see the class comment for why the *corridor* is not + // one of these. + poolRadius: 2.6, // metres + poolGrow: 0.5, // seconds the pool takes to freeze out to full radius + poolHold: 0.55, // 0..1 of the sublimation the pool survives before it recedes + poolHeight: 0.02, // metres above the floor the quad sits at + poolCell: 0.42, // metres — the pitch of one plate in the pool + poolCellJitter: 0.85, // 0..1 — how irregular that packing is + poolSeam: 0.035, // metres of gap between plates + poolThickness: 0.03, // metres of body + poolLift: 0.09, // metres the downwind edge of a pool plate rises + poolEdge: 0.4, // metres of feather on the freezing front + poolRagged: 0.32, // how far that front wanders, fraction of the radius + poolRaggedScale: 0.8, // lobes per metre + poolWarp: 0.5, // metres of domain warp on those lobes + poolRelief: 0.7, // how hard the height field tilts the fake normal + poolNormalStep: 0.05, // metres between the height taps + poolAmbient: 0.34, // floor on the diffuse term + poolWrap: 0.45, // 0..1 wraps the terminator round the back + poolSpecular: 0.65, + poolGloss: 30.0, // Blinn exponent + poolParallax: 0.3, // metres of view-driven offset on the interior + poolDetail: 0.55, // 0..1 frozen spray in the seams + poolEmissive: 1.0, // multiplier on the pool's glowing terms + poolOpacity: 0.95, + poolDepthFade: 0.4, // metres of soft fade against standing geometry + colorPoolBase: '#9fd3e4', // the pool's ice + colorPoolEdge: '#eaf9ff', // its lips and highlights + colorPoolGlow: '#7ecbe0', // its emissive + colorPoolDeep: '#123b4a', // the thick middle of a pool plate + + /* --- the curls that snap off --- */ + shardCount: 90, // fragments thrown when the hold ends + shardSpawnRadius: 0.35, // metres of scatter about the corridor + shardSpawnHeight: 0.3, // metres above the floor they come off at + shardSize: 0.3, // metres + shardSizeJitter: 0.5, // ± fraction + shardSpeed: 2.4, // metres/second + shardSpeedJitter: 0.7, // ± fraction + shardSpread: 0.4, // 0 throws them all downrange, 1 is fully random + shardUpBias: 0.55, // how much +Y is folded into the throw + shardGravity: -11.0, // metres/second² + shardDrag: 1.1, // 1/second + shardSpin: 7.0, // radians/second of tumble + shardSpinJitter: 0.8, // ± fraction + shardLifetime: 1.5, // seconds + shardShrink: 0.75, // 0..1 of its size a fragment loses by the end + shardShrinkPower: 1.7, // how late that shrink bites + shardFloor: 0.02, // metres; a fragment never sinks below this + shardFloorSpin: 0.2, // fraction of the tumble kept once grounded + shardOpacity: 0.95, + shardGlow: 1.2, + shardRim: 1.1, // rim light on a fragment + shardRimPower: 2.4, + shardShade: 1.0, // how much the key direction shades a fragment + shardAmbient: 0.4, + shardFadeStart: 0.55, // 0..1 of its life before a fragment starts fading + shardSoft: 0.3, // metres of soft fade where a fragment meets geometry + colorShardA: '#ffffff', // a fragment lit + colorShardB: '#7ecbe0', // a fragment in shade + colorShardEdge: '#dff6ff', // its rim + + /* --- mist, glints and chips --- */ + /** + * As everywhere in this project, each system is coloured by a four-stop + * gradient sampled over the particle's own lifetime, `A` at birth through `D` + * as it dies — spelled out rather than derived from the plate palette, so the + * vapour can be made to warm off while the ice stays blue. + */ + mistRate: 55, // vapour off the freezing front, particles/second + mistSize: 0.85, + mistSpeed: 0.8, + mistLifetime: 2.4, + mistOpacity: 0.08, + mistRise: 0.35, // upward drift, metres/second + colorMistA: '#dff6ff', + colorMistB: '#a8ccd8', + colorMistC: '#5f7f8e', + colorMistD: '#22333c', + glintRate: 130, // ice crystals hanging over the corridor, particles/second + glintSize: 0.055, + glintSpeed: 0.9, + glintLifetime: 1.5, + glintRise: 0.5, + glintTurbulence: 0.6, + colorGlintA: '#ffffff', + colorGlintB: '#dff6ff', + colorGlintC: '#7ecbe0', + colorGlintD: '#123b4a', + breachChips: 2, // chips thrown as one plate locks + chipSize: 0.05, + chipSpeed: 2.2, + chipLifetime: 1.1, + chipGravity: -14.0, + colorChipA: '#eaf9ff', + colorChipB: '#9fd3e4', + colorChipC: '#4d7f92', + colorChipD: '#1b3440', + + /* --- the impact and the snap --- */ + burstSize: 2.2, // the shell of freezing vapour at the far end, metres + burstIntensity: 1.1, + colorBurstA: '#7ecbe0', + colorBurstB: '#dff6ff', + colorBurstC: '#ffffff', + shockRadius: 4.5, // the ring that snaps out across the floor, metres + colorShockA: '#dff6ff', + colorShockB: '#ffffff', + impactShake: 0.5, + shakeDuration: 0.5, + impactFlash: 0.16, + colorFlash: '#dff6ff', + snapShake: 0.3, // the jolt as the curls break off + rumble: 0.02, // continuous shake while the front runs + + /* --- dynamic light --- */ + lightIntensity: 14.0, + lightRadius: 12.0, + lightColor: '#9fd8ee', + lightPulse: 0.25, // depth of the light's slow breath, 0 = steady + lightPulseSpeed: 1.4 // breaths per second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Rimewalker. + * + * `The curl` is the folder this ability exists for — drag `curl` on a standing + * corridor and every plate in it rolls up or lies back down. After that, + * `thickness` in `The sheet` is the one that decides whether you are looking at + * ice or at glass, and `sublimeTime` in `The cast` decides whether the corridor + * evaporates or is wiped away. + */ +export const rimeSchema = { + 'The cast': [ + ['range', 2, 48, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 80, 0.5, 'freezing front speed'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['lifetime', 0.1, 8, 0.05, 'corridor lifetime'], + ['snapDelay', 0, 3, 0.01, 'delay before the curls snap'], + ['sublimeTime', 0.1, 6, 0.05, 'sublimation time'], + ['castAnim', 'cast animation'] + ], + 'The corridor': [ + ['plateCount', 8, 216, 1, 'plates'], + ['clusterShare', 0, 0.6, 0.01, 'held for the impact ring'], + ['clusterRadius', 0.2, 6, 0.05, 'impact ring radius'], + ['widthNear', 0.05, 4, 0.01, 'band half-width at the caster'], + ['width', 0.05, 6, 0.01, 'band half-width at the target'], + ['widthCurve', 0.2, 4, 0.01, 'band curve'], + ['frontBias', 0.3, 3, 0.01, 'crowd toward the target'], + ['clumping', 0.2, 4, 0.01, 'pull onto the centre line'], + ['scatter', 0, 2, 0.01, 'lateral scatter'], + ['lockStagger', 0, 1.5, 0.01, 'stagger between plates'], + ['tilt', 0, 0.6, 0.005, 'random tip'], + ['twist', 0, 1, 0.01, 'random yaw'] + ], + 'The corridor/Locking': [ + ['riseTime', 0.02, 1, 0.01, 'lock time'], + ['riseOvershoot', 0, 1, 0.01, 'overshoot'], + ['settle', 0.05, 2, 0.01, 'settle'], + ['springRate', 2, 60, 0.5, 'overshoot ring'], + ['birthScale', 0.05, 1, 0.01, 'size at the lock'] + ], + 'The plate': [ + ['plateRadius', 0.1, 4, 0.01, 'plan size at the caster'], + ['plateRadiusFar', 0.1, 5, 0.01, 'plan size at the target'], + ['radiusCurve', 0.1, 4, 0.01, 'plan curve'], + ['radiusJitter', 0, 1, 0.01, 'plan jitter'], + ['plateHeight', 0.1, 4, 0.01, 'vertical scale at the caster'], + ['plateHeightFar', 0.1, 5, 0.01, 'vertical scale at the target'], + ['heightCurve', 0.1, 4, 0.01, 'vertical curve'], + ['crown', 0, 1, 0.01, 'flank falloff'], + ['crownPower', 0.2, 5, 0.01, 'falloff sharpness'] + ], + 'The sheet': [ + ['facets', 5, 18, 1, 'plan sides'], + ['rings', 3, 14, 1, 'radial rings'], + ['rimJitter', 0, 0.9, 0.01, 'outline irregularity'], + ['buckle', 0, 0.3, 0.005, 'non-planarity'], + ['thickness', 0.002, 0.2, 0.001, 'sheet thickness (m)'] + ], + 'The curl': [ + ['curl', 0, 6.2, 0.01, 'roll at the rim (rad)'], + ['curlTime', 0.05, 4, 0.01, 'time to peel'], + ['curlDelay', 0, 0.95, 0.01, 'lie flat first'], + ['curlHinge', -0.45, 0.45, 0.005, 'hinge across the plate'], + ['curlSpread', 0, 3, 0.01, 'peel bearing jitter'] + ], + 'The ice': [ + ['depthTint', 0, 3, 0.01, 'grazing tint'], + ['depthPower', 0.2, 6, 0.01, 'tint curve'], + ['translucency', 0, 3, 0.01, 'interior glow'], + ['fresnel', 0, 5, 0.01, 'rim'], + ['fresnelPower', 0.2, 8, 0.01, 'rim tightness'], + ['lipGlow', 0, 6, 0.01, 'lip highlight'], + ['lipWidth', 0.02, 0.99, 0.01, 'lip width'], + ['lipPower', 0.2, 6, 0.01, 'lip falloff'], + ['grain', 0, 2, 0.01, 'rime grain'], + ['grainScale', 0.2, 20, 0.1, 'grain / metre'], + ['glint', 0, 4, 0.01, 'sparkle'], + ['glintScale', 2, 90, 0.5, 'glints / metre'], + ['glintSpeed', 0, 4, 0.01, 'glint scroll'], + ['glow', 0, 5, 0.01, 'glow'], + ['birthGlow', 0, 8, 0.01, 'lock flash'], + ['birthPower', 0.5, 20, 0.1, 'lock flash decay'], + ['opacity', 0, 1, 0.01, 'opacity'], + ['roughness', 0.01, 1, 0.01, 'roughness'], + ['envIntensity', 0, 4, 0.01, 'env reflection'], + ['colorSheet', 'sheet, face-on'], + ['colorDeep', 'body tint'], + ['colorLip', 'lip & grain'], + ['colorGlow', 'emissive'] + ], + 'The sublimation': [ + ['sublimeEdge', 0.05, 6, 0.01, 'band width (m)'], + ['sublimeGlow', 0, 8, 0.01, 'band glow'], + ['colorSublime', 'the band'] + ], + 'The glaze pool': [ + ['poolRadius', 0.2, 12, 0.05, 'radius'], + ['poolGrow', 0.05, 3, 0.01, 'freeze-out time'], + ['poolHold', 0, 1, 0.01, 'survives this much of the fade'], + ['poolHeight', 0, 0.4, 0.005, 'height above floor'], + ['poolCell', 0.05, 2, 0.01, 'plate pitch'], + ['poolCellJitter', 0, 1, 0.01, 'packing irregularity'], + ['poolSeam', 0.002, 0.4, 0.002, 'seam width'], + ['poolThickness', 0.002, 0.3, 0.002, 'plate body'], + ['poolLift', 0, 0.8, 0.005, 'downwind lift'], + ['poolEdge', 0.02, 3, 0.01, 'front feather'], + ['poolRagged', 0, 1, 0.01, 'front wander'], + ['poolRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['poolWarp', 0, 3, 0.01, 'domain warp'], + ['poolRelief', 0, 3, 0.01, 'relief'], + ['poolNormalStep', 0.005, 0.4, 0.005, 'normal step'], + ['poolAmbient', 0, 1, 0.01, 'ambient'], + ['poolWrap', 0, 1, 0.01, 'terminator wrap'], + ['poolSpecular', 0, 3, 0.01, 'specular'], + ['poolGloss', 1, 120, 1, 'gloss'], + ['poolParallax', 0, 2, 0.01, 'parallax'], + ['poolDetail', 0, 1, 0.01, 'frozen spray'], + ['poolEmissive', 0, 4, 0.01, 'emissive'], + ['poolOpacity', 0, 1, 0.01, 'opacity'], + ['poolDepthFade', 0.02, 2, 0.01, 'soft intersection'], + ['colorPoolBase', 'pool ice'], + ['colorPoolEdge', 'pool lips'], + ['colorPoolGlow', 'pool emissive'], + ['colorPoolDeep', 'pool interior'] + ], + 'The curls that snap off': [ + ['shardCount', 0, 180, 1, 'fragments'], + ['shardSpawnRadius', 0, 2, 0.01, 'scatter'], + ['shardSpawnHeight', 0, 2, 0.01, 'spawn height'], + ['shardSize', 0.02, 1.5, 0.01, 'size'], + ['shardSizeJitter', 0, 1, 0.01, 'size jitter'], + ['shardSpeed', 0, 20, 0.05, 'speed'], + ['shardSpeedJitter', 0, 1, 0.01, 'speed jitter'], + ['shardSpread', 0, 1, 0.01, 'spread'], + ['shardUpBias', 0, 1, 0.01, 'up bias'], + ['shardGravity', -40, 5, 0.1, 'gravity'], + ['shardDrag', 0, 6, 0.01, 'drag'], + ['shardSpin', 0, 30, 0.1, 'tumble'], + ['shardSpinJitter', 0, 1, 0.01, 'tumble jitter'], + ['shardLifetime', 0.1, 6, 0.05, 'lifetime'], + ['shardShrink', 0, 1, 0.01, 'shrink'], + ['shardShrinkPower', 0.2, 6, 0.01, 'shrink curve'], + ['shardFloor', 0, 1, 0.005, 'floor'], + ['shardFloorSpin', 0, 1, 0.01, 'floor tumble'], + ['shardOpacity', 0, 1, 0.01, 'opacity'], + ['shardGlow', 0, 4, 0.01, 'glow'], + ['shardRim', 0, 4, 0.01, 'rim'], + ['shardRimPower', 0.2, 8, 0.01, 'rim tightness'], + ['shardShade', 0, 2, 0.01, 'shading'], + ['shardAmbient', 0, 1, 0.01, 'ambient'], + ['shardFadeStart', 0, 1, 0.01, 'fade start'], + ['shardSoft', 0, 2, 0.01, 'soft intersection'], + ['colorShardA', 'fragment lit'], + ['colorShardB', 'fragment shaded'], + ['colorShardEdge', 'fragment rim'] + ], + 'Mist & glints': [ + ['mistRate', 0, 400, 1, 'mist rate'], + ['mistSize', 0.05, 4, 0.01, 'mist size'], + ['mistSpeed', 0, 8, 0.05, 'mist speed'], + ['mistLifetime', 0.2, 8, 0.05, 'mist lifetime'], + ['mistOpacity', 0, 1, 0.005, 'mist opacity'], + ['mistRise', -2, 4, 0.01, 'mist rise'], + ['glintRate', 0, 600, 1, 'glint rate'], + ['glintSize', 0.005, 0.4, 0.005, 'glint size'], + ['glintSpeed', 0, 10, 0.05, 'glint speed'], + ['glintLifetime', 0.1, 6, 0.05, 'glint lifetime'], + ['glintRise', -2, 6, 0.05, 'glint rise'], + ['glintTurbulence', 0, 3, 0.01, 'glint turbulence'], + ['colorMist*', 'Mist colour'], + ['colorGlint*', 'Glint colour'] + ], + 'Chips': [ + ['breachChips', 0, 20, 1, 'chips per plate'], + ['chipSize', 0.005, 0.4, 0.005, 'chip size'], + ['chipSpeed', 0, 15, 0.05, 'chip speed'], + ['chipLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['chipGravity', -40, 0, 0.1, 'chip gravity'], + ['colorChip*', 'Chip colour'] + ], + 'Impact & snap': [ + ['burstSize', 0.1, 10, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['shockRadius', 0.2, 20, 0.1, 'shockwave radius'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['snapShake', 0, 3, 0.01, 'snap shake'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst plates'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorFlash', 'impact flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'light breath'], + ['lightPulseSpeed', 0, 8, 0.05, 'breath rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/runeseal.js b/src/config/abilities/runeseal.js new file mode 100644 index 0000000..83c5db8 --- /dev/null +++ b/src/config/abilities/runeseal.js @@ -0,0 +1,490 @@ +/* ================================================================== */ +/* RUNESEAL — Runic Seal */ +/* ================================================================== */ +/** + * A seal inscribed on the floor, which then goes off. + * + * The trick is **procedural letterforms**. Everything you can read on the floor + * is a signed-distance field evaluated in metres by one fragment shader: + * `vfx/GroundField.js`'s `RUNE` mode, which draws nested counter-rotating rings + * of real glyphs — strokes with terminals, bowls with counters — struck through + * by a chord armature, collared in tick marks and closed around a central + * rosette. Nothing is a texture, nothing is a decal, and nothing was drawn by a + * human: the alphabet is twenty-four stroke skeletons in JavaScript, unrolled + * into straight-line GLSL at module load. + * + * Which is why the numbers below are **measurements and not proportions**. + * `glyphSize` is the em box in metres, so widening `zoneRadius` gives you *more + * runes* rather than bigger ones — the ring recomputes its slot count from its + * own circumference every frame. `glyphStroke` is the half-width of a nib in + * metres; `rule`, `armStroke` and `tickStroke` are the same measurement for the + * three other kinds of line. Drag any of them with the clock stopped and the + * standing seal re-inks itself. + * + * ### The one clock + * + * `inscribe` runs 0 → 1 once, and the seal spends it in three movements: + * `armStart` is where the rings hand over to the armature, `sigilStart` is + * where the armature hands over to the sigil. That is why the biggest mark on + * the floor is always the last thing you watch finish. `ignite` then runs + * outward from the middle, and `scorch` takes the fire back out of the ink and + * leaves the writing as a scar. + * + * ### Where the keys come from + * + * Three sources, and the prefixes say which: bare keys are this ability's own, + * `column*` is a `vfx/Tube.js` straight tube spread in by `tubeDefaults`, and + * `wave*` is a `vfx/Shell.js` pressure shell spread in by `shellDefaults`. Both + * of those are sliders the moment they land here, which is the point of the + * prefixed-block convention. + */ + +import { TubePath, tubeDefaults, tubeSchema } from '../../vfx/Tube.js'; +import { ShellMode, shellDefaults, shellSchema } from '../../vfx/Shell.js'; + +export const runeseal = { + /* --- the cast --- */ + range: 21.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 150.0, // how fast the scribe line reaches the point, metres/second + zoneRadius: 4.6, // the seal's own radius, metres — the circle the aim drew + cooldown: 1.7, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the four beats, in seconds --- */ + // These run off the cast's own age, not off the phase machine, so the seal + // does not care how far downrange it was planted: a seal at 4 m and a seal at + // 20 m inscribe at the same rate. `impactDuration` is their sum. + inscribeTime: 1.25, // seconds the seal takes to draw itself + igniteTime: 0.45, // seconds the ignition front takes to cross it + dischargeTime: 0.9, // seconds the column and the ring are up for + holdTime: 1.3, // seconds the scar smoulders before the quad starts to go + fadeTime: 1.5, // seconds it takes to burn down to nothing but the decal + + /* --- where the scribe line runs --- */ + handHeight: 1.3, // metres above the floor the line leaves the caster at + handForward: 0.5, // metres in front of the caster + handSide: 0.2, // metres to the side (+ follows `Ability#side`) + scribeLift: 0.05, // metres the line rides above the floor at the far end + scribeRate: 1.1, // ink motes shed per metre of scribe travel + + /* ================================================================ */ + /* The seal — GroundField(RUNE) */ + /* ================================================================ */ + + /* --- the rings of glyphs --- */ + sealHeight: 0.02, // metres the quad floats above the floor + rings: 3, // nested rings of runes, 1..4 + ringInner: 0.34, // innermost ring, as a fraction of zoneRadius + glyphSize: 0.5, // metres — the em box. A measurement, not a proportion + glyphStroke: 0.036, // metres — half-width of a nib stroke + glyphGap: 1.3, // slot pitch, in glyph widths + spin: 0.14, // radians/second, innermost ring + spinFalloff: 0.55, // how much slower each ring out turns + rule: 0.011, // metres — half-width of the compass circles + + /* --- the armature struck between the rings --- */ + armStart: 0.5, // 0..1 through the inscription the chords start being struck + armRadius: 0.68, // the armature circle, as a fraction of zoneRadius + armSides: 7, // chords struck around it — 7 is prime, so the star never closes early + armTangent: 0.36, // apothem as a fraction of armRadius; 1 = a ring, 0 = through the middle + armStroke: 0.019, // metres — half-width of a chord + armPhase: 0.35, // radians — where the first chord starts + armSpin: -0.05, // radians/second; negative so it turns against the inner ring + + /* --- the tick collar --- */ + tickCount: 64, // ticks around the outside + tickRadius: 0.955, // collar radius, as a fraction of zoneRadius + tickLength: 0.13, // metres — a minor tick + tickStroke: 0.011, // metres — half-width of a tick + tickMajor: 8, // every Nth tick is a long one + tickMajorLen: 0.16, // metres of extra length on a major tick + + /* --- the central sigil, drawn last --- */ + sigilStart: 0.72, // 0..1 through the inscription the sigil starts + sigilRadius: 0.38, // metres — how far each arm of the rosette sits out + sigilSize: 0.78, // metres — its em box. Larger than glyphSize on purpose + sigilArms: 5, // rotational symmetry of the rosette + sigilStroke: 0.034, // metres — half-width of a sigil stroke + sigilRing: 0.98, // metres — the ruled circle enclosing it + sigilSpin: 0.07, // radians/second + + /* --- the incision and how it is lit --- */ + incision: 0.05, // metres the strokes are cut into the floor + incisionWidth: 0.03, // metres of bevel on the walls of that cut + relief: 0.85, // how hard the cut tilts the fake normal + ambient: 0.26, // floor on the diffuse term + wrap: 0.4, // 0..1 wraps the terminator round the back of the bevel + specular: 0.55, + gloss: 30, // Blinn exponent on the cut walls + sealEdge: 0.5, // metres of feather on the seal's outer boundary + sealRagged: 0.05, // how far that boundary wanders, as a fraction of the radius + sealRaggedScale: 0.55, // lobes per metre + sealWarp: 0.35, // metres of domain warp on those lobes + sealEmissive: 1.25, // multiplier on every glowing term in the seal + sealOpacity: 1.0, + sealDepthFade: 0.45, // metres of soft fade where the seal meets standing geometry + colorInk: '#ffd27a', // the substance of the writing + colorRule: '#fff3d0', // the compass circles, the tick collar, the specular + colorFire: '#ff8a2a', // everything that burns + colorChar: '#2a1a0a', // the inside of the cut, and the scar it leaves + + /* ================================================================ */ + /* The discharge — Tube(STRAIGHT), prefix `column` */ + /* ================================================================ */ + + columnHeight: 9.5, // metres the column stands, from the seal upward + columnRise: 0.16, // seconds the column takes to reach full height + columnCollapse: 2.4, // >1 holds the column and then drops it + + ...tubeDefaults('column', TubePath.STRAIGHT, { + columnRadius: 0.36, // half-width at the top, metres + columnRadiusNear: 0.92, // ...and where it stands on the seal + columnRadiusCurve: 0.72, // <1 narrows fast off the floor, then holds + columnFlare: 0.55, // the foot spreads where it meets the writing + columnFlareWidth: 0.16, + columnThrob: 0.06, // the column breathes + columnThrobScale: 3.1, + columnThrobSpeed: 2.4, + columnWander: 0.05, // metres the axis drifts — a column of fire is not a pipe + columnWanderScale: 1.6, + columnWanderSpeed: 0.7, + columnCoreWidth: 0.3, + columnCoreFill: 0.85, + columnCoreSharp: 1.5, + columnEdgePower: 2.4, + columnSheathWidth: 0.72, + columnSheathRim: 1.1, + columnSheathFill: 0.35, + columnSheathOpacity: 0.6, + columnHaloWidth: 2.3, + columnHaloRim: 2.6, + columnHaloOpacity: 0.34, + columnRipple: 0.22, + columnRippleBands: 2.6, + columnRippleScale: 3.4, + columnRippleSpeed: 2.2, + columnStreak: 0.85, // vertical filaments running up the shaft + columnStreakSharp: 0.62, + columnStreakScale: 7.5, + columnStreakBands: 1.4, + columnStreakGlow: 1.5, + columnFlowSpeed: 6.5, // metres/second the detail runs *upward* + columnBands: 3.2, // rings of light climbing the column + columnBandSharp: 2.6, + columnBandDepth: 0.4, + columnBandSpeed: -1.6, // negative: the rings climb rather than fall + columnMuzzleGlow: 2.4, // the hot foot standing on the seal + columnMuzzleLength: 0.13, + columnTipGlow: 0.6, // the top is where it lets go, so it is cooler + columnTipLength: 0.2, + columnColorCore: '#fffaf0', + columnColorInner: '#ffd27a', + columnColorOuter: '#ff8a2a', + columnColorHalo: '#8a3a06', + columnOpacity: 1.0, + columnGlow: 2.6, + columnSoftFade: 0.7 + }), + + /* ================================================================ */ + /* The ring — Shell(PRESSURE), prefix `wave` */ + /* ================================================================ */ + + ...shellDefaults('wave', ShellMode.PRESSURE, { + waveRadius: 0.5, // metres it starts at + waveRadiusEnd: 11.0, // ...and ends at. Deliberately past the seal + waveExpand: 4.5, // fast out, easing hard — a front, not a balloon + waveHeight: 0.22, // × radius: squashed flat, so it reads as a ring on the floor + waveLift: 0.06, // metres above the seal so it does not z-fight the writing + waveDisplace: 0.05, // barely billowed; a pressure front is smooth + waveNoiseScale: 2.4, + waveNoiseSpeed: 0.9, + waveTurbulence: 1.0, + waveFill: 0.03, // almost nothing but rim + waveRim: 1.7, + waveRimPower: 3.2, + waveDissolve: 1.3, + waveOpacity: 0.85, + waveGlow: 1.6, + waveSoftFade: 0.6, + waveColorBody: '#8a4a10', + waveColorRim: '#ffb44a', + waveColorEdge: '#fff3d0' + }), + + /* ================================================================ */ + /* Particles */ + /* ================================================================ */ + /** + * Three systems, each with its own four-stop lifetime gradient (`A` at birth + * through `D` as it dies) rather than a tint taken off the seal palette — so + * the embers can be made to cool to red while the writing stays gold. + */ + + /* --- embers thrown off the strokes as the ignition front crosses them --- */ + emberRate: 220, // particles/second while the seal burns + emberDischargeRate: 520, // ...and while the column is up + emberSize: 0.11, + emberSpeed: 3.4, + emberLifetime: 1.1, + emberRise: 2.6, // upward drift, metres/second (positive: they lift) + emberStretch: 0.16, // how far an ember smears along its velocity + emberInset: 0.12, // fraction of the radius the ember band is held inside the rim + colorEmberA: '#fff6df', + colorEmberB: '#ffd27a', + colorEmberC: '#ff8a2a', + colorEmberD: '#5c1c02', + + /* --- the slow gold motes hanging over the writing --- */ + moteRate: 95, + moteSize: 0.06, + moteSpeed: 1.0, + moteLifetime: 2.1, + moteRise: 0.85, // metres/second + moteTurbulence: 0.65, + colorMoteA: '#ffffff', + colorMoteB: '#ffd27a', + colorMoteC: '#ff8a2a', + colorMoteD: '#2a1a0a', + + /* --- char smoke, once the fire has gone through the ink --- */ + smokeRate: 46, + smokeSize: 1.1, + smokeSpeed: 1.0, + smokeLifetime: 2.6, + smokeOpacity: 0.075, + smokeRise: 0.5, // metres/second + colorSmokeA: '#4a3a2c', + colorSmokeB: '#3a2d22', + colorSmokeC: '#2c2219', + colorSmokeD: '#1a140f', + + /* ================================================================ */ + /* What is left on the floor */ + /* ================================================================ */ + + scorchRadius: 4.2, // metres — the burn the seal leaves behind + scorchLife: 9.0, // seconds it lingers + scorchIntensity: 0.6, + colorScorch: '#140c05', + colorSoot: '#7a4416', // the ember glow still in the scorch + + /* ================================================================ */ + /* Feedback */ + /* ================================================================ */ + + igniteFlash: 0.12, // screen flash when the seal catches + colorIgniteFlash: '#ffd27a', + dischargeFlash: 0.34, // ...and when it goes off + colorFlash: '#fff3d0', + burstSize: 3.4, // metres — the shell thrown off at the discharge + burstIntensity: 1.5, + burstEmbers: 260, // extra embers at the discharge + burstSmoke: 40, + dischargeShake: 0.95, + shakeDuration: 0.6, + rumble: 0.022, // continuous shake while the seal inscribes + colorBurstA: '#ff8a2a', + colorBurstB: '#ffd27a', + colorBurstC: '#fffaf0', + + /* --- dynamic light --- */ + lightIntensity: 22, + lightRadius: 15, + lightColor: '#ffb44a', + lightHeight: 0.28, // 0..1 up the column the light sits once it is standing + lightPulse: 0.16, // depth of the light's slow swell, 0 = steady + lightPulseSpeed: 3.4 // swells/second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Runic Seal. + * + * The four folders worth reaching for first are **The rings**, **The armature**, + * **The tick collar** and **The sigil** — between them they are the whole + * drawing, and every one of them redraws a seal that is already on the floor. + * `glyphSize` against `zoneRadius` is the single most instructive pair: shrink + * the em box and the rings fill with smaller, denser writing rather than + * scaling what is already there. + */ + +/** + * The `column` and `wave` prefixes come from `vfx/Tube.js` and `vfx/Shell.js`, + * and each module publishes the folders for the keys its *own mode* uses. The + * rest of the block — a whip's loop parameters on a straight tube, a ring + * train's spacing on a pressure shell — is still there, still a slider, and + * would otherwise land in the editor's catch-all "More" folder next to this + * ability's real controls. + * + * So the leftovers are collected here, by difference, into a folder that says + * what they are. Computing the set rather than listing it is deliberate: a + * hand-written list goes stale the first time either module gains a field, and + * a stale list is worse than none because it looks maintained. + */ +const covered = (schema) => + new Set(Object.values(schema).flat().map((entry) => (Array.isArray(entry) ? entry[0] : entry))); + +const columnSchema = tubeSchema('column', TubePath.STRAIGHT); +const waveSchema = shellSchema('wave', ShellMode.PRESSURE); +const columnCovered = covered(columnSchema); +const waveCovered = covered(waveSchema); + +const inactive = Object.keys(runeseal).filter( + (key) => + (key.startsWith('column') && key in tubeDefaults('column') && !columnCovered.has(key)) || + (key.startsWith('wave') && key in shellDefaults('wave') && !waveCovered.has(key)) +); + +export const runesealSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 10, 400, 1, 'scribe speed'], + ['zoneRadius', 1, 14, 0.05, 'seal radius'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['inscribeTime', 0.15, 6, 0.01, 'inscribe time'], + ['igniteTime', 0.05, 4, 0.01, 'ignite time'], + ['dischargeTime', 0.05, 4, 0.01, 'discharge time'], + ['holdTime', 0, 4, 0.01, 'hold time'], + ['fadeTime', 0.1, 6, 0.01, 'burn-down time'] + ], + 'The scribe line': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['scribeLift', 0, 1.5, 0.01, 'line height at the seal'], + ['scribeRate', 0.05, 8, 0.05, 'ink motes / metre'] + ], + 'The rings': [ + ['sealHeight', 0.002, 0.2, 0.001, 'quad height (m)'], + ['rings', 1, 4, 1, 'rings'], + ['ringInner', 0.05, 0.9, 0.01, 'innermost ring'], + ['glyphSize', 0.1, 2, 0.005, 'em box (m)'], + ['glyphStroke', 0.004, 0.2, 0.001, 'nib half-width (m)'], + ['glyphGap', 0.6, 3, 0.01, 'slot pitch'], + ['spin', -2, 2, 0.005, 'inner ring spin (rad/s)'], + ['spinFalloff', 0, 3, 0.01, 'spin falloff'], + ['rule', 0.002, 0.08, 0.001, 'compass circle (m)'] + ], + 'The armature': [ + ['armStart', 0.05, 0.9, 0.01, 'starts at'], + ['armRadius', 0.1, 1, 0.005, 'chord circle'], + ['armSides', 3, 16, 1, 'chords'], + ['armTangent', 0, 0.98, 0.005, 'tangent radius'], + ['armStroke', 0.002, 0.12, 0.001, 'chord half-width (m)'], + ['armPhase', 0, 6.2832, 0.01, 'first chord (rad)'], + ['armSpin', -2, 2, 0.005, 'armature spin (rad/s)'] + ], + 'The tick collar': [ + ['tickCount', 6, 180, 1, 'ticks'], + ['tickRadius', 0.2, 1.15, 0.005, 'collar radius'], + ['tickLength', 0.01, 0.8, 0.005, 'minor tick (m)'], + ['tickStroke', 0.002, 0.06, 0.0005, 'tick half-width (m)'], + ['tickMajor', 1, 24, 1, 'every Nth is major'], + ['tickMajorLen', 0, 0.8, 0.005, 'major extra (m)'] + ], + 'The sigil': [ + ['sigilStart', 0.1, 0.98, 0.01, 'starts at'], + ['sigilRadius', 0, 3, 0.01, 'arm radius (m)'], + ['sigilSize', 0.1, 3, 0.01, 'em box (m)'], + ['sigilArms', 1, 12, 1, 'rosette arms'], + ['sigilStroke', 0.004, 0.2, 0.001, 'stroke half-width (m)'], + ['sigilRing', 0.05, 4, 0.01, 'enclosing circle (m)'], + ['sigilSpin', -2, 2, 0.005, 'sigil spin (rad/s)'] + ], + 'The incision': [ + ['incision', 0, 0.5, 0.002, 'cut depth (m)'], + ['incisionWidth', 0.002, 0.2, 0.001, 'bevel (m)'], + ['relief', 0, 3, 0.01, 'relief'], + ['ambient', 0, 1, 0.01, 'ambient'], + ['wrap', 0, 1, 0.01, 'terminator wrap'], + ['specular', 0, 3, 0.01, 'specular'], + ['gloss', 1, 120, 1, 'gloss'], + ['sealEdge', 0.01, 3, 0.01, 'boundary feather (m)'], + ['sealRagged', 0, 1, 0.005, 'boundary wander'], + ['sealRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['sealWarp', 0, 3, 0.01, 'lobe warp (m)'], + ['sealEmissive', 0, 5, 0.01, 'seal emissive'], + ['sealOpacity', 0, 2, 0.01, 'seal opacity'], + ['sealDepthFade', 0.01, 3, 0.01, 'soft intersection (m)'], + ['colorInk', 'the writing'], + ['colorRule', 'circles & ticks'], + ['colorFire', 'the burning'], + ['colorChar', 'the cut & the scar'] + ], + // Not 'The column': `tubeSchema` already publishes a folder by that name, and + // two identical keys in one object literal is the later one silently winning. + // The three keys below went missing into the editor's "More" folder for + // exactly that reason. + 'The discharge': [ + ['columnHeight', 0.5, 40, 0.1, 'column height (m)'], + ['columnRise', 0.01, 2, 0.005, 'rise time (s)'], + ['columnCollapse', 0.2, 8, 0.01, 'collapse curve'] + ], + ...columnSchema, + ...waveSchema, + 'Prefixed keys this mode does not read': inactive, + 'Embers & motes': [ + ['emberRate', 0, 1200, 1, 'ember rate'], + ['emberDischargeRate', 0, 2000, 1, 'ember rate (discharge)'], + ['emberSize', 0.005, 0.6, 0.005, 'ember size'], + ['emberSpeed', 0, 20, 0.05, 'ember speed'], + ['emberLifetime', 0.05, 5, 0.01, 'ember lifetime'], + ['emberRise', -8, 12, 0.05, 'ember rise'], + ['emberStretch', 0, 2, 0.01, 'ember stretch'], + ['emberInset', 0, 0.9, 0.01, 'ember band inset'], + ['moteRate', 0, 600, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 10, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 6, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorEmber*', 'Ember colour'], + ['colorMote*', 'Mote colour'] + ], + 'Smoke & the scar': [ + ['smokeRate', 0, 400, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['scorchRadius', 0.2, 14, 0.05, 'scorch radius'], + ['scorchLife', 0.5, 30, 0.1, 'scorch lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['colorScorch', 'scorch'], + ['colorSoot', 'scorch embers'], + ['colorSmoke*', 'Smoke colour'] + ], + 'Ignition & discharge': [ + ['igniteFlash', 0, 2, 0.01, 'flash on ignition'], + ['colorIgniteFlash', 'ignition flash colour'], + ['dischargeFlash', 0, 2, 0.01, 'flash on discharge'], + ['colorFlash', 'discharge flash colour'], + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstEmbers', 0, 900, 1, 'burst embers'], + ['burstSmoke', 0, 300, 1, 'burst smoke'], + ['dischargeShake', 0, 3, 0.01, 'discharge shake'], + ['shakeDuration', 0.05, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.3, 0.002, 'inscribe rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst filaments'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightHeight', 0, 1, 0.01, 'light height up the column'], + ['lightPulse', 0, 1, 0.01, 'light swell'], + ['lightPulseSpeed', 0.1, 20, 0.1, 'swell rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/sanguinepact.js b/src/config/abilities/sanguinepact.js new file mode 100644 index 0000000..9f95b34 --- /dev/null +++ b/src/config/abilities/sanguinepact.js @@ -0,0 +1,502 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* SANGUINE PACT — blood · far cast */ +/* ================================================================== */ +/** + * A pact sealed on a circle: a pool of blood draws itself on the floor, sends + * up a slow mist column, and forty beads of blood climb out of it on real + * inclined orbits. When the pact seals, the orbits flatten into the ring plane + * and the beads merge into a rim. + * + * **`zoneRadius` is the promise, and five things keep it.** The aim indicator + * measures out a circle before the click; the pool's footprint, the mist + * column's radius, the beads' mean orbit, the sealed rim and the ring of + * travelling arcs are all that radius times their own *fraction*. Drag it on a + * standing pact and every one of them re-scales together, which is the one kind + * of shared value I5 allows — the sharing is the design, not a shortcut. + * + * The three groups worth reaching for first: + * + * - **The orbits** (`orbitTilt`, `orbitTiltSpread`, `orbitEccentric`) — the + * trick. Take `orbitTilt` to zero and the beads collapse into a flat ring + * and the whole cast turns into a decal; that is the failure this ability + * exists to avoid, and it is one slider away at all times. + * - **The seal** (`sealSnap`, `rimMerge`, `rimMergeStretch`) — how hard the + * punctuation lands. + * - **The mist** (`mistDensity`, `mistAbsorption`, `mistAnisotropy`) — the only + * soft thing in the cast. Everything else here has an edge. + * + * Deep red, almost black in shadow. `beadAmbient` is deliberately low and + * `colorBeadDeep` is nearly black: a bead lit from one side and *black* on the + * other is what makes forty of them read as wet spheres instead of dots. + * + * ### Two things the harness will mention, both of them expected + * + * The `mist*` keys are spread in by `volumeHullDefaults` and read back by + * `VolumeHull.sync()` through a **key prefix** (`c[keys.Density]`), never by + * bare name — so the static cross-check cannot see the reads and lists them as + * "no visible read". They are all live; drag any of them on a standing column. + * + * `volumeHullDefaults` also emits the `GAS_BOIL`, sparse-point and `VOID` + * fields, which `MIST` does not use. They are deliberately left out of the + * schema's `only` list rather than filed into a folder of their own: filing + * them would put twelve controls that do nothing next to eleven that do, and + * the trailing "More" folder is exactly where a control nobody should reach for + * belongs. + */ +export const sanguinepact = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + speed: 22.0, // how fast the pact runs out to the circle, metres/second + zoneRadius: 4.6, // the footprint the indicator promises, metres + holdTime: 2.7, // seconds the column stands and the beads climb + sealTime: 2.2, // seconds the seal takes, including going out + cooldown: 1.6, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats, all unitless fractions of their own phase --- */ + climbTime: 0.55, // 0..1 of the hold the beads take to reach full height + columnTime: 0.42, // 0..1 of the hold the mist takes to reach full height + sealSnap: 0.2, // 0..1 of the seal phase the flattening takes + sealHold: 0.46, // 0..1 of it the sealed rim stands before it goes out + + /* --- the pool ------------------------------------------------------- */ + // A GroundField(POOL): standing liquid with a meniscus rim, alpha blended so + // it is genuinely darker than the floor. `poolThickness` is the meniscus and + // it is the read — surface tension pulls blood *up* the last few centimetres + // before the edge, and without that lip a pool is a coloured disc. + poolScale: 1.0, // × zoneRadius + poolHeight: 0.014, // metres above the floor the quad sits at + poolEdge: 0.34, // metres of feather on the growth front + poolRagged: 0.3, // fraction of the radius the edge wanders by + poolRaggedScale: 0.6, // lobes per metre + poolWarp: 0.7, // metres of domain warp on those lobes + poolRelief: 0.7, // how hard the surface tilts the fake normal + poolNormalStep: 0.05, // metres between the height taps + poolAmbient: 0.14, + poolWrap: 0.45, // 0..1 wraps the terminator round the back + poolSpecular: 1.9, + poolGloss: 72, // Blinn exponent + poolParallax: 0.22, // metres of view-driven offset on the interior + poolCell: 0.4, // cycles per metre of the surface warp + poolLift: 0.035, // metres the surface ripples by + poolDepth: 0.16, // metres the middle sits below the rim + poolThickness: 0.09, // metres — the meniscus, and the whole read + poolDetail: 0.75, // 0..1 fine grain + poolSpeed: 0.35, // radians/second the surface churns + poolFlow: 0.1, // metres/second it drifts + poolWindAngle: 0.4, // radians, in the quad's frame + poolEmissive: 0.6, // multiplier on the sheen and the rim + poolOpacity: 1.0, + poolDepthFade: 0.35, // metres of soft fade against standing geometry + colorPoolBase: '#7a0a14', // the liquid at the rim + colorPoolEdge: '#c81a28', // the meniscus and the specular + colorPoolGlow: '#3a0208', // the faint bloom out of the middle + colorPoolDeep: '#120103', // looking straight down into it + + /* --- the mist column ------------------------------------------------ */ + // A CYLINDER of MIST: barely absorbing, strongly forward-scattering. It is + // the anisotropy that makes it mist rather than fog, and the only soft thing + // in the whole cast. + mistScale: 0.72, // × zoneRadius — the column's radius + mistHeight: 5.2, // metres the column reaches at full rise + ...volumeHullDefaults('mist', Medium.MIST, { + mistSteps: 30, // the column is tall and thin; it does not need 40 + mistDensity: 0.85, + mistAbsorption: 0.55, + mistScatter: 0.9, + mistAmbient: 0.3, + mistAnisotropy: 0.62, // forward scatter — this is what says "mist" + mistNoiseFrequency: 0.85, + mistNoiseStrength: 0.7, + mistRise: 0.5, // metres/second of buoyancy + mistSwirl: 0.22, // radians/second about the column's own axis + mistHeightBias: 0.5, // it thins out toward the top + mistMargin: 0.22, + mistFeather: 0.42, + mistOpacity: 0.8, + mistColorCore: '#7a2028', + mistColorMid: '#4a1018', + mistColorEdge: '#2a0a10', + mistColorDeep: '#120508', + mistColorLight: '#ffd0c8' + }), + + /* --- the beads ------------------------------------------------------- */ + beadCount: 54, // live beads (capped at 96) + beadSize: 0.115, // metres, radius of one bead + beadSizeJitter: 0.4, // ± fraction of it + beadClimbBase: 0.12, // metres above the pool the lowest orbit sits + beadClimbTop: 3.6, // metres the highest orbit reaches + beadWobble: 0.11, // metres of vertical breathing on every orbit + beadWobbleRate: 1.5, // radians/second of it + beadRevealSpread: 0.4, // 0..1 width of the appear wave + beadAmbient: 0.13, // floor on the wrapped diffuse — keep it low + beadWrap: 0.55, // 0..1 how far the terminator wraps round the back + beadSpecular: 2.0, + beadGloss: 54, // Blinn exponent + beadFresnel: 1.2, + beadGlow: 1.0, + beadOpacity: 1.0, + beadTintAlong: 0.55, // how far up the column the gradient walks + beadTintJitter: 0.28, // ± per-bead walk on top of that + colorBeadBody: '#c81a28', // the lit side + colorBeadDeep: '#1e0206', // the shadow side — almost black, on purpose + colorBeadRim: '#7a0a14', // the fresnel edge + colorBeadSheen: '#ff9aa0', // the highlight + + /* --- the orbits ------------------------------------------------------ */ + orbitScale: 0.72, // × zoneRadius — the mean semi-major axis + orbitRadiusJitter: 0.22, // ± fraction of it, per bead + orbitEccentric: 0.42, // 0 circle .. 0.95 a long ellipse + orbitTilt: 0.95, // radians the mean orbital plane is tipped off horizontal + orbitTiltSpread: 0.7, // radians of per-bead variation on that + orbitNodeJitter: 0.16, // 0..1 slop on the golden-angle node spacing + orbitSpin: 0.22, // turns per second + orbitSpinJitter: 0.35, // ± fraction of it, per bead + + /* --- the sealed rim --------------------------------------------------- */ + rimScale: 0.86, // × zoneRadius — where the flattened ring stands + rimHeight: 0.42, // metres above the floor + rimMerge: 1.0, // 0..1 how far the beads run together + rimMergeStretch: 2.6, // how many times longer a fully merged bead is + + /* --- the filaments ---------------------------------------------------- */ + // One instanced strip, three roles, two draw calls: the threads climbing with + // the beads, the arcs travelling round the sealed rim, and the leash that + // runs out from the hand while the pact is being offered. + threadCount: 7, // ORBIT filaments climbing with the beads + threadRadiusScale: 0.66, // × zoneRadius + threadPoleHeight: 3.0, // metres above the pool the mean orbital axis points to + threadArc: 0.55, // turns one filament covers + threadSpin: 0.14, // turns per second the loops travel + threadWobble: 0.4, // 0..1 how far from a clean circle + threadTilt: 1.0, // radians the plane is tipped + threadTiltSpread: 0.8, // radians of per-filament variation + threadRadiusJitter: 0.25, // ± fraction of the radius, per filament + threadKink: 0.5, // × the shared jitter + threadWidthScale: 0.9, // × the shared width + threadDim: 0.85, // 0..1 how secondary this role is + + rimArcs: 9, // RIM filaments travelling round the sealed ring + rimSpan: 0.16, // turns one arc covers + rimSpeed: 0.4, // turns per second they travel + rimArcLift: 0.16, // metres they hop at mid-span + rimArcJitter: 0.1, // metres of per-arc radial slop + rimArcHug: 0.06, // metres they float above the ring plane + rimKink: 0.35, // × the shared jitter + rimWidthScale: 1.15, // × the shared width + rimDim: 1.0, // 0..1 how secondary this role is + + leashCount: 3, // LINE filaments running out from the hand + handHeight: 1.24, // metres above the floor the leash leaves at + handForward: 0.5, // metres in front of the caster + handSide: 0.18, // metres to the side (+ follows `Ability#side`) + leashSag: -0.35, // metres of bow at mid-span; negative droops, and blood droops + leashSpread: 0.5, // metres the bundle is fanned at the circle + leashSpreadNear: 0.05, // ... and at the hand + leashSpreadCurve: 1.5, // >1 keeps it tight then opens it late + leashTwist: 0.3, // turns of roll from end to end + leashTwistSpeed: 0.4, // turns per second the fan rolls + leashConverge: 0.9, // 0..1 how hard the far end is pinned to the circle + leashKink: 0.7, // × the shared jitter + leashWidthScale: 1.0, // × the shared width + leashDim: 0.8, // 0..1 how secondary this role is + + threadWidth: 0.022, // metres — half-width of a core filament + threadGlowWidth: 5.2, // halo half-width, × the core + threadGlowOpacity: 0.3, // halo alpha relative to the core + threadJitter: 0.16, // metres of lateral kink + threadJitterScale: 1.1, // kinks per metre of path + threadOctaves: 3, // 1..5 + threadJitterFalloff: 0.55, // amplitude kept per octave + threadCrawl: 0.9, // how fast the kinks slide along, per second + threadPinch: 0.2, // 0..1 of the path the kink eases in over at each end + threadRestrike: 4.0, // whole re-shapes per second — slow; blood is not lightning + threadFlicker: 0.1, // 0..1 depth of the whole-bundle stutter + threadFlickerSpeed: 9, // steps per second that stutter is quantised to + threadStrandFlash: 0.2, // 0..1 depth of the per-filament blink + threadCoreSharp: 3.4, // exponent on the core's edge falloff + threadGlowFalloff: 2.6, // the same for the halo + threadSoftFade: 0.5, // metres of depth fade against the opaque scene + threadOpacity: 0.95, + threadGlow: 0.7, // sub-unity: these are wet threads, not filaments of light + colorThreadCore: '#e0505c', // the centre line + colorThreadInner: '#c81a28', + colorThreadOuter: '#7a0a14', + colorThreadHalo: '#2a040a', // the wide, dim atmosphere + + /* --- motes and drips -------------------------------------------------- */ + /** + * Two systems only. The roster line says the mist is the only soft thing in + * this cast, so there is no smoke and no haze here — the motes are hard flecks + * carried up by the column and the drips are what falls back out of it. + * Four-stop lifetime gradients, `A` at birth through `D` as it dies. + */ + moteRate: 70, // flecks lifted by the column, particles/second + moteSize: 0.045, + moteSpeed: 0.7, + moteLifetime: 2.6, + moteRise: 1.5, // upward drift, metres/second + moteTurbulence: 0.55, + colorMoteA: '#c81a28', + colorMoteB: '#7a0a14', + colorMoteC: '#3a0208', + colorMoteD: '#160103', + dripRate: 34, // droplets shed by the beads, particles/second + dripSize: 0.055, + dripSpeed: 1.1, + dripLifetime: 1.3, + dripGravity: -13.0, // metres/second² + colorDripA: '#e0505c', + colorDripB: '#c81a28', + colorDripC: '#7a0a14', + colorDripD: '#2a040a', + + /* --- the seal --------------------------------------------------------- */ + sealBurstSize: 3.2, // the shell that snaps out of the rim, metres + sealBurstIntensity: 1.1, + sealDrips: 160, // droplets flung as the rim closes + sealMotes: 110, + sealShockRadius: 6.0, // the ring that runs out across the floor, metres + sealShake: 0.7, + sealShakeDuration: 0.5, + sealFlash: 0.12, // screen flash on the seal + rumble: 0.03, // continuous shake while the pact stands + colorSealA: '#3a0208', // burst shell + colorSealB: '#7a0a14', // burst body + colorSealC: '#e0505c', // burst filaments + colorSealShockA: '#7a0a14', + colorSealShockB: '#c81a28', + colorFlash: '#5a0810', + + /* --- what the pact leaves on the floor -------------------------------- */ + stainRadius: 3.0, // the residue where the pool stood, metres + stainLife: 7.0, // seconds it lingers + stainIntensity: 0.7, + colorStainA: '#1e0509', + colorStainB: '#6a1018', + + /* --- dynamic light ------------------------------------------------------ */ + // Low. Blood is not a light source; this is here so the beads have a second + // highlight and the mist has something to scatter. + lightIntensity: 9.0, + lightRadius: 11.0, + lightColor: '#c8323c' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Sanguine Pact. + * + * `The orbits` is the folder that owns the ability. `orbitTilt` at zero is a + * flat ring and a flat ring is a sticker; anywhere between 0.6 and 1.2 radians + * the beads pass in front of and behind the column and the cast has depth. + * `The seal` is the punctuation and `Beat timing` is where it lands. + */ +export const sanguinepactSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 16, 0.1, 'min range'], + ['speed', 4, 80, 0.5, 'run-out speed'], + ['zoneRadius', 1, 14, 0.05, 'footprint radius'], + ['holdTime', 0.2, 8, 0.05, 'hold duration'], + ['sealTime', 0.2, 8, 0.05, 'seal duration'], + ['cooldown', 0, 10, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Beat timing': [ + ['climbTime', 0.05, 1, 0.01, 'beads reach full height at'], + ['columnTime', 0.05, 1, 0.01, 'column reaches full height at'], + ['sealSnap', 0.02, 1, 0.01, 'flatten takes'], + ['sealHold', 0.02, 1, 0.01, 'rim stands until'] + ], + 'The pool': [ + ['poolScale', 0.1, 2, 0.01, '× footprint'], + ['poolHeight', 0, 0.2, 0.001, 'height off the floor'], + ['poolEdge', 0.02, 3, 0.01, 'edge feather'], + ['poolRagged', 0, 1, 0.01, 'edge raggedness'], + ['poolRaggedScale', 0.05, 4, 0.01, 'raggedness scale'], + ['poolWarp', 0, 4, 0.01, 'domain warp'], + ['poolRelief', 0, 3, 0.01, 'relief'], + ['poolNormalStep', 0.005, 0.4, 0.005, 'normal step'], + ['poolAmbient', 0, 1, 0.01, 'ambient floor'], + ['poolWrap', 0, 1, 0.01, 'terminator wrap'], + ['poolSpecular', 0, 5, 0.01, 'specular'], + ['poolGloss', 1, 200, 1, 'gloss'], + ['poolParallax', 0, 2, 0.01, 'parallax'], + ['poolCell', 0.05, 4, 0.01, 'surface scale'], + ['poolLift', 0, 0.4, 0.001, 'surface ripple'], + ['poolDepth', 0, 1, 0.005, 'bowl depth'], + ['poolThickness', 0.005, 0.5, 0.005, 'meniscus'], + ['poolDetail', 0, 1, 0.01, 'grain'], + ['poolSpeed', 0, 4, 0.01, 'churn rate'], + ['poolFlow', 0, 2, 0.01, 'drift'], + ['poolWindAngle', -3.2, 3.2, 0.01, 'drift bearing'], + ['poolEmissive', 0, 3, 0.01, 'sheen gain'], + ['poolOpacity', 0, 1, 0.01, 'opacity'], + ['poolDepthFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorPoolBase', 'liquid'], + ['colorPoolEdge', 'meniscus'], + ['colorPoolGlow', 'inner bloom'], + ['colorPoolDeep', 'depth'] + ], + 'The column': [ + ['mistScale', 0.1, 2, 0.01, '× footprint'], + ['mistHeight', 0.5, 16, 0.05, 'height (m)'] + ], + ...volumeHullSchema('mist', { + label: 'The column', + only: ['march', 'shape', 'field', 'flow', 'optics', 'colour'] + }), + 'The beads': [ + ['beadCount', 0, 96, 1, 'beads'], + ['beadSize', 0.01, 0.5, 0.005, 'bead radius'], + ['beadSizeJitter', 0, 1, 0.01, 'size jitter'], + ['beadClimbBase', -0.5, 3, 0.01, 'lowest orbit'], + ['beadClimbTop', 0.1, 12, 0.05, 'highest orbit'], + ['beadWobble', 0, 1, 0.005, 'vertical breathing'], + ['beadWobbleRate', 0, 8, 0.01, 'breathing rate'], + ['beadRevealSpread', 0.02, 1, 0.01, 'appear width'], + ['beadAmbient', 0, 1, 0.01, 'ambient floor'], + ['beadWrap', 0, 1.5, 0.01, 'terminator wrap'], + ['beadSpecular', 0, 6, 0.01, 'specular'], + ['beadGloss', 2, 200, 1, 'gloss'], + ['beadFresnel', 0, 4, 0.01, 'fresnel'], + ['beadGlow', 0, 4, 0.01, 'glow'], + ['beadOpacity', 0, 1, 0.01, 'opacity'], + ['beadTintAlong', 0, 1, 0.01, 'gradient by height'], + ['beadTintJitter', 0, 1, 0.01, 'gradient jitter'], + ['colorBeadBody', 'lit side'], + ['colorBeadDeep', 'shadow side'], + ['colorBeadRim', 'fresnel edge'], + ['colorBeadSheen', 'highlight'] + ], + 'The orbits': [ + ['orbitScale', 0.05, 2, 0.01, '× footprint'], + ['orbitRadiusJitter', 0, 1, 0.01, 'radius jitter'], + ['orbitEccentric', 0, 0.95, 0.01, 'eccentricity'], + ['orbitTilt', 0, 1.6, 0.01, 'inclination'], + ['orbitTiltSpread', 0, 1.6, 0.01, 'inclination spread'], + ['orbitNodeJitter', 0, 1, 0.01, 'node jitter'], + ['orbitSpin', -2, 2, 0.005, 'turns / second'], + ['orbitSpinJitter', 0, 1, 0.01, 'rate jitter'] + ], + 'The seal': [ + ['rimScale', 0.05, 2, 0.01, '× footprint'], + ['rimHeight', 0, 4, 0.01, 'rim height'], + ['rimMerge', 0, 1, 0.01, 'beads merge'], + ['rimMergeStretch', 1, 8, 0.01, 'merged length'], + ['sealBurstSize', 0.2, 14, 0.05, 'burst size'], + ['sealBurstIntensity', 0, 5, 0.01, 'burst intensity'], + ['sealDrips', 0, 600, 1, 'burst drips'], + ['sealMotes', 0, 600, 1, 'burst motes'], + ['sealShockRadius', 0.5, 24, 0.1, 'shockwave radius'], + ['sealShake', 0, 3, 0.01, 'shake'], + ['sealShakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['sealFlash', 0, 1, 0.005, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'standing rumble'], + ['colorSealA', 'burst shell'], + ['colorSealB', 'burst body'], + ['colorSealC', 'burst filaments'], + ['colorSealShockA', 'shockwave ring'], + ['colorSealShockB', 'shockwave crest'], + ['colorFlash', 'screen flash colour'] + ], + 'Climbing threads': [ + ['threadCount', 0, 20, 1, 'filaments'], + ['threadRadiusScale', 0.05, 2, 0.01, '× footprint'], + ['threadPoleHeight', 0.1, 12, 0.05, 'orbital axis height'], + ['threadArc', 0.02, 2, 0.01, 'turns covered'], + ['threadSpin', -2, 2, 0.005, 'turns / second'], + ['threadWobble', 0, 1, 0.01, 'off-circle'], + ['threadTilt', 0, 1.6, 0.01, 'inclination'], + ['threadTiltSpread', 0, 1.6, 0.01, 'inclination spread'], + ['threadRadiusJitter', 0, 1, 0.01, 'radius jitter'], + ['threadKink', 0, 3, 0.01, '× kink'], + ['threadWidthScale', 0, 4, 0.01, '× width'], + ['threadDim', 0, 1, 0.01, 'dim'] + ], + 'Rim arcs': [ + ['rimArcs', 0, 20, 1, 'arcs'], + ['rimSpan', 0.01, 1, 0.005, 'turns covered'], + ['rimSpeed', -3, 3, 0.01, 'turns / second'], + ['rimArcLift', 0, 2, 0.01, 'hop at mid-span'], + ['rimArcJitter', 0, 2, 0.01, 'radial slop'], + ['rimArcHug', 0, 1, 0.005, 'float above the plane'], + ['rimKink', 0, 3, 0.01, '× kink'], + ['rimWidthScale', 0, 4, 0.01, '× width'], + ['rimDim', 0, 1, 0.01, 'dim'] + ], + 'The leash': [ + ['leashCount', 0, 12, 1, 'filaments'], + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['leashSag', -3, 3, 0.01, 'mid-span bow'], + ['leashSpread', 0, 4, 0.01, 'fan at the circle'], + ['leashSpreadNear', 0, 2, 0.01, 'fan at the hand'], + ['leashSpreadCurve', 0.2, 5, 0.01, 'fan curve'], + ['leashTwist', -4, 4, 0.01, 'twist over length'], + ['leashTwistSpeed', -6, 6, 0.01, 'twist speed'], + ['leashConverge', 0, 1, 0.01, 'lock onto the circle'], + ['leashKink', 0, 3, 0.01, '× kink'], + ['leashWidthScale', 0, 4, 0.01, '× width'], + ['leashDim', 0, 1, 0.01, 'dim'] + ], + 'Filament look': [ + ['threadWidth', 0.002, 0.3, 0.001, 'core half-width'], + ['threadGlowWidth', 1, 24, 0.1, 'halo width'], + ['threadGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['threadJitter', 0, 2, 0.005, 'kink amplitude'], + ['threadJitterScale', 0.05, 6, 0.01, 'kinks / metre'], + ['threadOctaves', 1, 5, 1, 'octaves'], + ['threadJitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['threadCrawl', -10, 10, 0.05, 'kink crawl'], + ['threadPinch', 0.01, 0.5, 0.005, 'end pinch'], + ['threadRestrike', 0.5, 60, 0.5, 'restrikes / sec'], + ['threadFlicker', 0, 1, 0.01, 'brightness stutter'], + ['threadFlickerSpeed', 1, 90, 1, 'stutter rate'], + ['threadStrandFlash', 0, 1, 0.01, 'filament blink'], + ['threadCoreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['threadGlowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['threadSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['threadOpacity', 0, 2, 0.01, 'opacity'], + ['threadGlow', 0, 4, 0.01, 'glow'], + ['colorThreadCore', 'core'], + ['colorThreadInner', 'inner'], + ['colorThreadOuter', 'outer'], + ['colorThreadHalo', 'halo'] + ], + 'Motes & drips': [ + ['moteRate', 0, 500, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 8, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -2, 8, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['dripRate', 0, 300, 1, 'drip rate'], + ['dripSize', 0.005, 0.4, 0.005, 'drip size'], + ['dripSpeed', 0, 12, 0.05, 'drip speed'], + ['dripLifetime', 0.1, 5, 0.05, 'drip lifetime'], + ['dripGravity', -40, 0, 0.1, 'drip gravity'], + ['colorMote*', 'Mote colour'], + ['colorDrip*', 'Drip colour'] + ], + 'The stain': [ + ['stainRadius', 0.2, 12, 0.05, 'radius'], + ['stainLife', 0.5, 24, 0.1, 'lifetime'], + ['stainIntensity', 0, 3, 0.01, 'intensity'], + ['colorStainA', 'stain'], + ['colorStainB', 'rim'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/sawline.js b/src/config/abilities/sawline.js new file mode 100644 index 0000000..9ceca19 --- /dev/null +++ b/src/config/abilities/sawline.js @@ -0,0 +1,592 @@ +/* ================================================================== */ +/* SAWLINE — forge */ +/* ================================================================== */ +/** + * A machined circular saw blade drops out of the caster's hand, buries its + * rim in the floor and runs the length of the aimed line, cutting a kerf and + * throwing a sheaf of grinding sparks off the contact. At the far end it + * grinds in place for a moment, hurls a handful of glowing offcuts downrange, + * and dives out of sight into its own cut. + * + * ## What the numbers here are + * + * Three different kinds of number live in this block and they behave very + * differently, so it is worth naming them. + * + * 1. **Metres, radians and seconds** — `bladeDiameter`, `bladeBite`, + * `bladeSpin`. These are resolved inside the update loop every frame, so + * dragging them re-places a blade that is already halfway down the lane. + * 2. **Shape proportions** — `teeth`, `toothRake`, `bladeArbor` and the rest + * of "The blade's teeth". `HardSurface` shapes carry **no metres**: every + * field is a fraction of the blade's own diameter, and the geometry is + * regenerated (through a `ShapeCache`, only when a number actually moves) + * rather than transformed. Drag `teeth` with the clock stopped and the + * blade re-teeths in the frame you let go of the slider. + * 3. **The grind** — the `grind*` group is handed straight to + * `GrindContact`, the solver that turns a contact point, a surface normal + * and a rim velocity into spark jets. `grindGain`, `grindFloor` and + * `grindCeiling` are the only place the sparks' *speed* is authored, and + * they are authored as a fraction of the rim speed rather than as an + * absolute, because that is the whole point of the slot — see the class + * doc in `abilities/forge/SawlineAbility.js`. + * + * ## The one sign that matters + * + * `bladeSpin` is **signed**, and the sign is not cosmetic. The blade's spin + * axis is the cast's `side` vector, so a positive rate puts the teeth at the + * bottom of the blade travelling *downrange* and the spray goes forward with + * the cut (a climb cut); a negative rate throws the whole sheaf back over the + * blade, which is the angle-grinder rooster tail everybody recognises. It + * ships negative. + */ +export const sawline = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 11.0, // how fast the blade tracks down the line, metres/second + lifetime: 0.7, // seconds it grinds in place at the far end + fadeTime: 0.75, // seconds it takes to dive out of the cut + cooldown: 1.1, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the blade, in metres --- */ + bladeDiameter: 1.45, // rim to rim, metres — the only size the blade has + bladeBite: 0.26, // metres the rim runs below the floor at full depth + bladeBiteIn: 0.12, // fraction of the line spent sinking to that depth + /** + * Radians/second about the cast's side vector. **Signed** — see the header. + * + * The magnitude is a compromise with the frame rate rather than with + * realism. There is no motion blur here, so any rate above about 9 rad/s + * aliases against a twenty-tooth rim at 60 fps and the teeth strobe, which + * is exactly what a saw does on video and is not worth fighting. The four + * *expansion slots* are the feature that has to read, and their pitch is + * 90°: at 48 rad/s a slot moves 46° a frame, just inside the half-pitch + * where the eye still resolves which way it is turning. Push this past about + * 60 and the blade visibly runs backwards. + */ + bladeSpin: -48.0, + bladeLean: 0.07, // radians the blade tips forward out of vertical + bladeWander: 0.11, // metres of lateral drift off the cast line + bladeWanderScale: 0.34, // drifts per metre of travel + bladeExit: 2.6, // metres the hub sinks over the fade, taking the blade under the floor + bladeShadow: true, // does the blade cast a real shadow + + /* --- the blade's teeth (proportions, not metres — see the header) --- */ + teeth: 20, // tooth count + toothRake: 15.0, // degrees of hook on the cutting face; negative scrapes + toothClearance: 25.0, // degrees of relief behind the tip + toothGullet: 0.17, // fraction of the tip radius down to the gullet floor + toothLand: 0.34, // 0..1 of the tooth pitch spent flat on the tip + bladeThickness: 0.055, // unit lengths, face to face — a blade is a plate + bladeChamfer: 0.013, // unit lengths, the bevel that catches the light on the rim + bladeArbor: 0.15, // bore, as a fraction of the tip radius + bladeSlots: 4, // expansion slots; 0 for a plain plate + slotDepth: 0.29, // fraction of the tip radius, inward from the gullet floor + slotWidth: 0.036, // fraction of the tip radius + bladeCrease: 30.0, // degrees; above this a joint shades hard and counts as an edge + + /* --- the steel --- */ + /** + * `HardSurface`'s brushed anisotropic material. The brushing is + * **circumferential about the blade's own axis**, which for a `SAWBLADE` + * seated on `HardAxis.X` is local +X — the grain runs round the plate the + * way a surface grinder leaves it, and the highlight smears across the + * grain into an arc rather than sitting on the rim as a dot. + */ + colorMetal: '#9ba3ac', // clean steel + colorDeep: '#383d43', // the bottom of a pit + colorScale: '#2a2622', // mill scale, the blue-black oxide off the forge + colorPolish: '#eef4fb', // a worn edge, where the workpiece has been + colorSpec: '#fff1dd', // the anisotropic highlight's own colour + steelRough: 0.29, // base roughness, before grain / pitting / wear + steelMetalness: 0.95, + steelEnv: 1.05, // HDR probe gain + brushAniso: 0.88, // 0 round highlight, 1 fully smeared along the grain + brushSpecular: 1.9, // gain on the anisotropic lobe + brushGrain: 0.62, // how hard the brushing cuts into roughness + brushGrainScale: 130.0, // grain frequency, cycles per unit of local space + brushGrainStretch: 34.0, // how far a streak runs along the brush direction + millScale: 0.18, // mill scale coverage, 0..1 + millScalePatch: 7.5, // its patch size + millScaleSharp: 0.62, // 0 a smear, 1 a hard flake edge + steelPit: 0.24, // casting pits and corrosion + steelPitScale: 62.0, + steelWear: 0.9, // how bright the machined edges come up + steelWearGrain: 0.34, // how much the grain breaks that wear up + + /* --- how hot the blade gets --- */ + /** + * One 0..1 heat drives a real Planckian ramp — there is deliberately no + * `colorHot` picker, because the value of the term is that nobody gets to + * place the yellow. `bladeHeatIdle` is what the blade carries in the air; + * `bladeHeat` is what it reaches with the rim buried and cutting. + */ + bladeHeatIdle: 0.06, // 0..1 heat with the rim clear of the floor + bladeHeat: 0.44, // 0..1 heat at full engagement + heatCold: 300.0, // kelvin at heat = 0 — a cold workshop + heatHot: 2100.0, // kelvin at heat = 1 — past the point steel burns + heatRef: 1250.0, // kelvin at which the emission term reaches 1 + heatExponent: 4.0, // Stefan-Boltzmann; 4 is the physical value + heatGlow: 2.6, // gain on the emission + heatTint: 0.82, // how far the albedo washes toward the hot colour + heatEdge: 0.26, // how much cooler an edge reads — thin sections radiate faster + + /* --- the grind: where the sparks come from --- */ + /** + * `contactPhase` walks the touch point along the **engagement arc** — the + * chord where the blade's circle crosses the floor. −1 is the entry edge, + * 0 is bottom dead centre, +1 is the exit edge. It matters because the rim + * velocity is `ω × r` evaluated *there*: at bottom dead centre the tangent + * is horizontal, and near either edge it has a real vertical component that + * throws the sheaf up out of the kerf. + */ + contactPhase: 0.62, // −1 entry edge … 0 bottom … +1 exit edge + grindLift: 0.05, // metres the jets start off the surface, along the normal + grindBounce: 0.6, // 0..1 of the into-surface velocity that comes back out + grindRise: 0.34, // 0..1 extra tilt away from the floor — the rooster tail + grindGain: 0.5, // spark speed as a fraction of the rim speed + grindFloor: 2.2, // metres/second, so a stalled blade still throws something + grindCeiling: 30.0, // metres/second + grindFan: 0.82, // radians the jets fan through, in the tangent/normal plane + grindSwing: 0.3, // radians of fan across it + grindGraze: 0.05, // sine of the shallowest angle a jet may leave at + grindJets: 5, // sub-directions per emission; each is one emit() call + grindSpread: 0.09, // cone half-angle handed to the particle system, per jet + grindVariance: 0.55, // ±fraction on each spark's speed + grindDrift: 0.14, // fraction of the rim velocity added as inherited motion + + /* --- the sparks --- */ + /** + * The four stops are the *cooling* of one spark over its own life. The + * blackbody tint on top of them is the *temperature it left at*, taken off + * the same Planckian locus the blade uses, and `sparkTemper` decides how + * much of the authored gradient that tint is allowed to overrule. At 0 the + * gradient rules outright; at 1 the sparks are exactly the colour steel of + * that temperature is. + */ + sparkRate: 1150.0, // sparks thrown off the contact, particles/second + sparkSize: 0.13, + sparkLifetime: 0.52, + sparkGravity: -17.0, // metres/second², so the sheaf droops into an arc + sparkStretch: 0.32, // how far a spark smears along its velocity + sparkGlow: 1.6, + sparkHeat: 0.93, // 0..1 on the same ramp as the blade — the tint's temperature + sparkTemper: 0.85, // 0..1 how far that tint overrules the gradient + colorSparkA: '#fffdf4', + colorSparkB: '#ffd99a', + colorSparkC: '#ff8a2c', + colorSparkD: '#6d1c05', + + /* --- grit off the kerf --- */ + gritRate: 130.0, // cold chips of floor, particles/second + gritSize: 0.075, + gritSpeed: 6.0, + gritLifetime: 1.1, + gritGravity: -21.0, + gritSpin: 13.0, // radians/second of tumble + colorGritA: '#7d7468', + colorGritB: '#5d564d', + colorGritC: '#3f3a34', + colorGritD: '#2a2723', + + /* --- smoke off the cut --- */ + smokeRate: 38.0, + smokeSize: 0.85, + smokeSpeed: 1.35, + smokeLifetime: 2.3, + smokeOpacity: 0.1, + smokeRise: 0.95, + colorSmokeA: '#6a6259', + colorSmokeB: '#544d46', + colorSmokeC: '#3c3733', + colorSmokeD: '#262321', + + /* --- the kerf on the floor (GroundField, RUT) --- */ + kerfHeight: 0.02, // metres the quad floats above the floor + kerfWidth: 0.16, // metres — half-width of the gouge floor + kerfDepth: 0.28, // metres of gouge + kerfLift: 0.1, // metres the spoil ridges stand proud + kerfSharp: 0.74, // 0 a dish, 1 a square-shouldered slot + kerfEdge: 0.55, // metres of feather at the front of the cut + kerfThickness: 0.1, // metres the spoil spreads either side + kerfWander: 0.4, // metres the track drifts — matched to `bladeWander` + kerfWanderScale: 0.9, // drift lobes per metre + kerfSeam: 0.6, // metres one contact sample smears along the track + kerfRelief: 0.95, // how hard the height field tilts the fake normal + kerfNormalStep: 0.05, // metres between the height taps + kerfAmbient: 0.3, // floor on the diffuse term + kerfWrap: 0.45, // 0..1 wraps the terminator round the back + kerfSpecular: 0.55, + kerfGloss: 26.0, // Blinn exponent + kerfParallax: 0.22, // metres of view-driven offset on the interior + kerfDetail: 0.55, // 0..1 grain in the gouge + kerfEmissive: 1.7, // multiplier on the glowing lip right under the blade + kerfOpacity: 1.0, + kerfDepthFade: 0.5, // metres of soft fade against standing geometry + biteRate: 2.4, // contact samples posted per metre of travel + kerfMarkLife: 7.0, // seconds one sample weathers away over + kerfMarkRadius: 0.7, // metres, a full-strength sample + colorKerfBase: '#6b6259', // cut stone + colorKerfEdge: '#b3a794', // the spoil ridges + colorKerfGlow: '#ff8f38', // the hot lip under the blade + colorKerfDeep: '#15120f', // the bottom of the slot + + /* --- the offcuts (Projectile) --- */ + /** + * The last bite throws a handful of slugs downrange. They are the one place + * `Projectile` earns its keep here: staggered launches, a parametric arc + * that re-flies under the slider, a trail drawn entirely in the vertex + * shader, and an `arrivals` event per landing that fires its own little + * spark burst. + */ + offcutCount: 8, // slugs thrown at the last bite + offcutRadius: 0.17, // metres + offcutJitter: 0.42, // ±fraction of that radius + offcutStretch: 0.55, // scale along the aligned axis + offcutAlign: 0.25, // 0 tumble freely, 1 lay the long axis along the heading + offcutSpin: 11.0, // tumble rate, radians/second + offcutFlash: 0.16, // birth flash decay, seconds + offcutThrow: 6.0, // metres past the blade the middle of the fan lands + offcutSpreadSide: 2.4, // ±metres across the cast line + offcutSpreadForward: 2.2, // ±metres along it + offcutApex: 2.5, // ballistic loft, metres + offcutApexCurve: 1.15, // >1 flattens the top of the lob + offcutCurve: 1.0, // easing exponent on launch → land + offcutFlight: 0.66, // seconds one slug is in the air + offcutFlightJitter: 0.28, // ±fraction of that + offcutWindow: 0.42, // seconds the staggered launches spread over + offcutLead: 0.02, // seconds before the first slug leaves + offcutLinger: 0.7, // seconds a landed slug stays on the floor + offcutSink: 1.1, // body radii it sinks over that linger + offcutLandSparks: 24, // sparks kicked up where a slug hits + /* the slug's own shape — proportions again, never metres */ + slugWidth: 1.0, // the two in-plane extents, relative to each other + slugDepth: 0.62, + slugThickness: 0.26, // unit lengths + slugCorner: 0.28, // fraction of the short side, corner radius + slugBevel: 0.055, // unit lengths, 45° break round the whole outline + /* the slug's steel — its own pickers, because it is cut floor, not blade */ + colorSlagMetal: '#8a7f72', + colorSlagDeep: '#2f2a25', + colorSlagScale: '#241f1b', + colorSlagPolish: '#d9cfbe', + colorSlagSpec: '#ffe6c4', + slagRough: 0.46, + slagMetalness: 0.72, + slagScale: 0.42, // mill scale coverage on a slug, 0..1 + slagPit: 0.5, + slagHeat: 0.82, // 0..1 on the shared ramp — slugs come out of the cut hot + slagHeatGlow: 3.0, + slagHeatTint: 0.9, + /* the trail behind a slug */ + trailSpan: 0.26, // seconds of flight the tail reaches back over + trailBurn: 0.24, // seconds the tail takes to catch up after landing + trailWidth: 0.08, // metres at the head + trailTaper: 1.7, // >1 sharpens the tail to a point + trailLift: 0.02, // metres the tail floats above the flown path + trailOpacity: 0.95, + trailGlow: 1.7, + trailCore: 2.3, // how tightly light crowds the centre line + trailHeadBias: 0.5, // >0 keeps the brightness near the body + trailNoise: 0.5, + trailNoiseScale: 1.9, // features per metre + trailNoiseSpeed: 0.7, + trailSoftFade: 0.35, // metres of depth feather against solid geometry + colorTrailA: '#fff4dc', + colorTrailB: '#ffb453', + colorTrailC: '#d9481a', + colorTrailD: '#3a0d04', + + /* --- the drop, the last bite and the shake --- */ + dropSize: 0.9, // the shell where the blade first bites, metres + dropIntensity: 1.5, + dropSparks: 90, // sparks thrown on first contact + castFlash: 0.06, // screen flash as the blade lands + colorDropA: '#3a3f45', + colorDropB: '#ffb453', + colorDropC: '#fff2d8', + colorCastFlash: '#ffcf94', + biteSize: 2.2, // the shell at the far end, metres + biteIntensity: 1.6, + biteSparks: 260, // sparks thrown at the last bite + biteShake: 0.55, + shakeDuration: 0.5, + biteFlash: 0.16, + rumble: 0.05, // continuous shake while the blade is cutting + colorBiteA: '#4a3a2c', + colorBiteB: '#ff9a37', + colorBiteC: '#fff6e4', + colorFlash: '#ffd8a4', // the full-screen flash at the last bite + scorchRadius: 0.55, // dark burn left where a slug lands, metres + scorchLife: 6.0, + scorchIntensity: 0.5, + colorScorch: '#100d0a', + colorEmber: '#ff8f38', + + /* --- dynamic light --- */ + lightIntensity: 17.0, + lightRadius: 11.0, + lightColor: '#ffa04a', + lightFlicker: 0.22, // depth of the light's stutter, 0 = steady + lightFlickerSpeed: 31.0 // stutters/second — roughly the expansion slots going past +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Sawline. + * + * The four controls that carry the slot, in order: **`bladeSpin`** (how fast + * the rim is going, and therefore how hard the sparks leave — the sign flips + * the spray front to back), **`contactPhase`** (where on the engagement arc + * the sparks are struck from, which is what turns a flat spray into a rooster + * tail), **`grindGain`** (spark speed as a fraction of the rim speed) and + * **`bladeBite`** (how deep the cut is, which is what decides how long the + * engagement arc is in the first place). + * + * Everything in "The blade's teeth" rebuilds real geometry. It is cheap + * enough to drag — a blade is about four milliseconds — but it is not free, + * and it is the only folder here that is not just a uniform write. + */ +export const sawlineSchema = { + 'The cast': [ + ['range', 3, 50, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 1, 60, 0.1, 'blade speed'], + ['lifetime', 0.05, 5, 0.01, 'grind at the end'], + ['fadeTime', 0.05, 4, 0.01, 'dive-out time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The blade': [ + ['bladeDiameter', 0.3, 4, 0.01, 'diameter'], + ['bladeBite', 0.01, 1.2, 0.005, 'cut depth'], + ['bladeBiteIn', 0.01, 0.6, 0.005, 'run-in fraction'], + ['bladeSpin', -200, 200, 0.5, 'spin rate (signed)'], + ['bladeLean', -0.6, 0.6, 0.005, 'forward lean'], + ['bladeWander', 0, 1.2, 0.005, 'lateral wander'], + ['bladeWanderScale', 0.02, 3, 0.01, 'wanders / metre'], + ['bladeExit', 0, 8, 0.05, 'dive depth'], + ['bladeShadow', 'casts a shadow'] + ], + "The blade's teeth": [ + ['teeth', 6, 48, 1, 'tooth count'], + ['toothRake', -30, 40, 0.5, 'rake (deg)'], + ['toothClearance', 2, 55, 0.5, 'clearance (deg)'], + ['toothGullet', 0.03, 0.4, 0.005, 'gullet depth'], + ['toothLand', 0.05, 0.8, 0.01, 'tip land'], + ['bladeThickness', 0.01, 0.3, 0.002, 'plate thickness'], + ['bladeChamfer', 0.001, 0.08, 0.001, 'rim chamfer'], + ['bladeArbor', 0.03, 0.5, 0.005, 'arbor bore'], + ['bladeSlots', 0, 10, 1, 'expansion slots'], + ['slotDepth', 0.02, 0.6, 0.005, 'slot depth'], + ['slotWidth', 0.005, 0.12, 0.001, 'slot width'], + ['bladeCrease', 5, 70, 1, 'crease angle (deg)'] + ], + 'The steel': [ + ['colorMetal', 'clean steel'], + ['colorDeep', 'pit bottom'], + ['colorScale', 'mill scale'], + ['colorPolish', 'worn edge'], + ['colorSpec', 'highlight'], + ['steelRough', 0.02, 1, 0.01, 'roughness'], + ['steelMetalness', 0, 1, 0.01, 'metalness'], + ['steelEnv', 0, 3, 0.01, 'probe gain'] + ], + 'The steel/Brushing': [ + ['brushAniso', 0, 1, 0.01, 'anisotropy'], + ['brushSpecular', 0, 6, 0.01, 'lobe gain'], + ['brushGrain', 0, 2, 0.01, 'grain depth'], + ['brushGrainScale', 5, 400, 1, 'grain frequency'], + ['brushGrainStretch', 1, 120, 0.5, 'streak length'] + ], + 'The steel/Surface history': [ + ['millScale', 0, 1, 0.01, 'scale coverage'], + ['millScalePatch', 0.5, 30, 0.1, 'scale patch size'], + ['millScaleSharp', 0, 1, 0.01, 'flake edge'], + ['steelPit', 0, 1, 0.01, 'pitting'], + ['steelPitScale', 5, 200, 1, 'pit frequency'], + ['steelWear', 0, 1, 0.01, 'edge wear'], + ['steelWearGrain', 0, 1, 0.01, 'wear break-up'] + ], + Heat: [ + ['bladeHeatIdle', 0, 1, 0.005, 'heat in the air'], + ['bladeHeat', 0, 1, 0.005, 'heat while cutting'], + ['heatCold', 200, 1200, 5, 'cold end (K)'], + ['heatHot', 800, 3000, 10, 'hot end (K)'], + ['heatRef', 400, 2500, 10, 'emission reference (K)'], + ['heatExponent', 1, 6, 0.05, 'emission exponent'], + ['heatGlow', 0, 8, 0.01, 'emission gain'], + ['heatTint', 0, 1, 0.01, 'albedo wash'], + ['heatEdge', 0, 1, 0.01, 'edge cooling'] + ], + 'The grind': [ + ['contactPhase', -1, 1, 0.01, 'contact along the arc'], + ['grindLift', 0, 0.4, 0.005, 'jet lift'], + ['grindBounce', 0, 1, 0.01, 'restitution'], + ['grindRise', 0, 1.5, 0.01, 'rooster tail'], + ['grindGain', 0, 1.5, 0.01, 'speed / rim speed'], + ['grindFloor', 0, 15, 0.1, 'speed floor'], + ['grindCeiling', 2, 80, 0.5, 'speed ceiling'], + ['grindFan', 0, 2.4, 0.01, 'fan angle'], + ['grindSwing', 0, 1.2, 0.01, 'fan swing'], + ['grindGraze', 0, 0.6, 0.005, 'grazing floor'], + ['grindJets', 1, 12, 1, 'jets'], + ['grindSpread', 0, 0.8, 0.005, 'per-jet spread'], + ['grindVariance', 0, 1.5, 0.01, 'speed variance'], + ['grindDrift', 0, 1, 0.01, 'inherited rim motion'] + ], + Sparks: [ + ['sparkRate', 0, 3500, 5, 'spark rate'], + ['sparkSize', 0.005, 0.6, 0.005, 'spark size'], + ['sparkLifetime', 0.05, 3, 0.01, 'spark lifetime'], + ['sparkGravity', -60, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['sparkGlow', 0, 6, 0.01, 'spark glow'], + ['sparkHeat', 0, 1, 0.005, 'spark temperature'], + ['sparkTemper', 0, 1, 0.01, 'blackbody override'], + ['colorSpark*', 'Spark colour'] + ], + 'Grit & smoke': [ + ['gritRate', 0, 600, 1, 'grit rate'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 25, 0.1, 'grit speed'], + ['gritLifetime', 0.1, 5, 0.05, 'grit lifetime'], + ['gritGravity', -60, 0, 0.1, 'grit gravity'], + ['gritSpin', 0, 40, 0.5, 'grit tumble'], + ['smokeRate', 0, 400, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['colorGrit*', 'Grit colour'], + ['colorSmoke*', 'Smoke colour'] + ], + 'The kerf': [ + ['kerfHeight', 0, 0.3, 0.005, 'quad height'], + ['kerfWidth', 0.02, 1.2, 0.005, 'gouge half-width'], + ['kerfDepth', 0, 1.5, 0.005, 'gouge depth'], + ['kerfLift', 0, 0.6, 0.005, 'spoil height'], + ['kerfSharp', 0, 1, 0.01, 'shoulder sharpness'], + ['kerfEdge', 0.02, 2, 0.01, 'front feather'], + ['kerfThickness', 0, 0.6, 0.005, 'spoil spread'], + ['kerfWander', 0, 2, 0.01, 'track drift'], + ['kerfWanderScale', 0.05, 4, 0.01, 'drift lobes / m'], + ['kerfSeam', 0.05, 3, 0.01, 'sample smear'], + ['biteRate', 0.1, 12, 0.1, 'samples / metre'], + ['kerfMarkLife', 0.5, 20, 0.1, 'sample lifetime'], + ['kerfMarkRadius', 0.05, 3, 0.01, 'sample radius'] + ], + 'The kerf/Shading': [ + ['kerfRelief', 0, 3, 0.01, 'relief'], + ['kerfNormalStep', 0.005, 0.4, 0.005, 'normal step'], + ['kerfAmbient', 0, 1, 0.01, 'ambient floor'], + ['kerfWrap', 0, 1, 0.01, 'terminator wrap'], + ['kerfSpecular', 0, 3, 0.01, 'specular'], + ['kerfGloss', 1, 120, 1, 'gloss'], + ['kerfParallax', 0, 1.5, 0.01, 'parallax'], + ['kerfDetail', 0, 1, 0.01, 'grain'], + ['kerfEmissive', 0, 6, 0.01, 'hot lip'], + ['kerfOpacity', 0, 2, 0.01, 'opacity'], + ['kerfDepthFade', 0, 3, 0.01, 'soft intersection'], + ['colorKerfBase', 'cut stone'], + ['colorKerfEdge', 'spoil'], + ['colorKerfGlow', 'hot lip'], + ['colorKerfDeep', 'slot bottom'] + ], + 'The offcuts': [ + ['offcutCount', 0, 24, 1, 'slugs'], + ['offcutRadius', 0.02, 0.8, 0.005, 'slug radius'], + ['offcutJitter', 0, 1, 0.01, 'size jitter'], + ['offcutStretch', 0.05, 3, 0.01, 'stretch'], + ['offcutAlign', 0, 1, 0.01, 'align to heading'], + ['offcutSpin', 0, 40, 0.5, 'tumble'], + ['offcutFlash', 0.01, 1, 0.01, 'birth flash'], + ['offcutThrow', 0.5, 25, 0.1, 'throw distance'], + ['offcutSpreadSide', 0, 10, 0.05, 'lateral spread'], + ['offcutSpreadForward', 0, 10, 0.05, 'depth spread'], + ['offcutApex', 0, 10, 0.05, 'loft'], + ['offcutApexCurve', 0.2, 4, 0.01, 'loft curve'], + ['offcutCurve', 0.2, 4, 0.01, 'path curve'], + ['offcutFlight', 0.05, 3, 0.01, 'flight time'], + ['offcutFlightJitter', 0, 1, 0.01, 'flight jitter'], + ['offcutWindow', 0, 2, 0.01, 'launch window'], + ['offcutLead', 0, 1.5, 0.01, 'launch lead'], + ['offcutLinger', 0, 4, 0.05, 'linger'], + ['offcutSink', 0, 4, 0.05, 'sink'], + ['offcutLandSparks', 0, 200, 1, 'sparks per landing'] + ], + 'The offcuts/Slug shape': [ + ['slugWidth', 0.2, 2, 0.01, 'width'], + ['slugDepth', 0.1, 2, 0.01, 'depth'], + ['slugThickness', 0.02, 1, 0.005, 'thickness'], + ['slugCorner', 0.01, 0.5, 0.005, 'corner radius'], + ['slugBevel', 0.002, 0.2, 0.002, 'bevel'] + ], + 'The offcuts/Slug steel': [ + ['colorSlagMetal', 'slug metal'], + ['colorSlagDeep', 'slug pit'], + ['colorSlagScale', 'slug scale'], + ['colorSlagPolish', 'slug polish'], + ['colorSlagSpec', 'slug highlight'], + ['slagRough', 0.02, 1, 0.01, 'roughness'], + ['slagMetalness', 0, 1, 0.01, 'metalness'], + ['slagScale', 0, 1, 0.01, 'scale coverage'], + ['slagPit', 0, 1, 0.01, 'pitting'], + ['slagHeat', 0, 1, 0.005, 'heat'], + ['slagHeatGlow', 0, 8, 0.01, 'emission gain'], + ['slagHeatTint', 0, 1, 0.01, 'albedo wash'] + ], + 'The offcuts/Trail': [ + ['trailSpan', 0.01, 2, 0.01, 'tail span'], + ['trailBurn', 0.01, 2, 0.01, 'tail burn'], + ['trailWidth', 0.005, 0.6, 0.005, 'tail width'], + ['trailTaper', 0.2, 5, 0.01, 'tail taper'], + ['trailLift', -0.5, 0.5, 0.005, 'tail lift'], + ['trailOpacity', 0, 2, 0.01, 'tail opacity'], + ['trailGlow', 0, 6, 0.01, 'tail glow'], + ['trailCore', 0.2, 8, 0.01, 'tail core'], + ['trailHeadBias', 0, 2, 0.01, 'head bias'], + ['trailNoise', 0, 2, 0.01, 'tail noise'], + ['trailNoiseScale', 0.1, 8, 0.05, 'noise scale'], + ['trailNoiseSpeed', 0, 4, 0.01, 'noise speed'], + ['trailSoftFade', 0.02, 2, 0.01, 'soft intersection'], + ['colorTrail*', 'Trail colour'] + ], + 'Drop, bite & shake': [ + ['dropSize', 0.05, 6, 0.05, 'drop shell'], + ['dropIntensity', 0, 5, 0.01, 'drop intensity'], + ['dropSparks', 0, 500, 1, 'drop sparks'], + ['castFlash', 0, 2, 0.01, 'flash on landing'], + ['colorDropA', 'drop shell'], + ['colorDropB', 'drop body'], + ['colorDropC', 'drop core'], + ['colorCastFlash', 'landing flash'], + ['biteSize', 0.2, 12, 0.05, 'bite shell'], + ['biteIntensity', 0, 5, 0.01, 'bite intensity'], + ['biteSparks', 0, 900, 1, 'bite sparks'], + ['biteShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['biteFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'cutting rumble'], + ['colorBiteA', 'bite shell'], + ['colorBiteB', 'bite body'], + ['colorBiteC', 'bite core'], + ['colorFlash', 'bite flash colour'], + ['scorchRadius', 0.05, 4, 0.05, 'scorch radius'], + ['scorchLife', 0.5, 20, 0.1, 'scorch lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['colorScorch', 'scorch'], + ['colorEmber', 'ember'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightFlicker', 0, 1, 0.01, 'light stutter'], + ['lightFlickerSpeed', 1, 120, 1, 'stutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/scrollward.js b/src/config/abilities/scrollward.js new file mode 100644 index 0000000..469de73 --- /dev/null +++ b/src/config/abilities/scrollward.js @@ -0,0 +1,348 @@ +/* ================================================================== */ +/* SCROLLWARD — a ring of scrolls that unrolls into a wall of text */ +/* ================================================================== */ +/** + * The far cast built on **`FoldMesh`'s `UNROLL` mode**. + * + * A ring of scrolls stands up around the aimed circle and pays itself out: the + * spools climb, the paper hangs behind them, and what was a row of rolls on the + * floor becomes a wall of writing you can walk round. + * + * ## What the module is doing, and why the numbers below are what they are + * + * The sheet is placed **by arc length from the free end**, which is the entire + * reason this reads as paper rather than as a scrolling texture: + * + * - the paid-out run is an arc of constant curvature `curl`, in **1/metres**. + * Paper keeps a set from having been rolled and loses it as it hangs, so + * this block carries *two* of them — `curlRoll` while there is still a fat + * roll at the top, `curlFlat` once the sheet is fully out — and the ability + * walks between them on the payout. The lean of the wall is that number; + * - the wound part is a real **Archimedean spiral**, `r = √(r₀² + wt/π)`, + * where `core` is `r₀` and `paper` is the sheet thickness `t`. It tightens + * toward the spool because that is what a square root does. Make `paper` + * thicker and the roll is fatter and shorter for the same length of sheet, + * exactly as it would be on a table; + * - because every mark on the paper — grain, laid lines, the writing — is a + * function of the **sheet coordinate** and nothing else, the foreshortening + * of the text as it comes off the roll is free and it is *correct*. The + * shader never learns it is on a curve. Place by fraction of the sheet + * instead of by arc length and the writing bunches at the spool and stretches + * on the run, which is the single tell that separates this from a decal. + * + * Three beats, mapped onto the phase machine: + * + * | phase | what it is | how long | + * | --- | --- | --- | + * | travel | the seed crosses to the circle; the rolls are already lying there | `range / speed` | + * | impact | **the payout**, then a hold with the wall standing | `unrollTime + holdTime` | + * | fade | the scrolls **wind back up** and go | `windTime` | + * + * The four controls worth reaching for first are `sheetLength` (how tall the + * wall is — the roll and the run share it, because they are the same paper), + * `curlRoll`, `ink`, and `scrolls`. + * + * **No bloom.** Ink is the matte school: nothing here is additive, there is no + * screen flash, and the one light is a low warm lamp so the wall casts an edge. + * + * Nothing below is captured by a cast. A cast rolls one seed; every metre, + * radian and second is re-read on every frame, zero-length ones included. + */ +import { foldMeshSchema } from '../../vfx/FoldMesh.js'; + +/** The sheet, surface and writing folders come straight off the module. */ +const PAPER = foldMeshSchema('Paper'); + +export const scrollward = { + /* --- the cast --- */ + range: 19.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + speed: 42.0, // how fast the seed crosses to the circle, metres/second + cooldown: 6.4, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 4.6, // the footprint — what the circle indicator measures out + + /* --- the beats --- */ + unrollTime: 1.5, // seconds the wall takes to pay out + holdTime: 1.7, // seconds it stands there afterwards + windTime: 1.25, // seconds it takes to wind back up and go — the fade phase + payoutSeed: 0.05, // how much paper is off the roll before the cast lands, 0..1 + payoutEnd: 1.0, // how much is off it at full extension, 0..1 + rewind: 0.12, // how far back onto the roll the fade takes it, 0..1 + windHold: 0.42, // fraction of the fade the wall stays opaque for while it winds + + /* ------------------------------------------------------------------ */ + /* The wall */ + /* ------------------------------------------------------------------ */ + /** + * `FoldMesh`'s `ZONE` layout: every scroll stands upright on a circle with + * its face turned outward, so the ring reads as a wall from outside and as a + * room from inside. `arc` under a full turn makes it a screen rather than an + * enclosure, which is the other half of what this layout is for. + */ + scrolls: 13, // live scrolls, capped at 20 by the class + ringRadius: 0.94, // the ring, × zoneRadius + radiusJitter: 0.09, // ±fraction per scroll — a hand-placed row, not a fence + arc: 6.283185, // radians the ring covers; a full turn is a closed room + arcPhase: 0.22, // radians — where the first scroll stands + lift: 0.03, // metres the free end sits off the floor + liftJitter: 0.025, // ±metres + bob: 0.018, // metres of breathing — paper in a room with air in it + bobRate: 0.9, // radians/second + yawJitter: 0.14, // ±radians each scroll is turned off true by + rollJitter: 0.05, // ±radians of lean about its own face + pitch: 0.0, // radians the whole ring tips + sizeJitter: 0.14, // ±fraction per scroll + revealSpread: 0.55, // width of the wave that brings the scrolls into being + /** + * Radians/second of rotation about the sheet's normal. Ships at 0 and should + * stay there for a standing scroll: the shader applies it as `tumble × uTime` + * with `uTime` counting from app start, so any non-zero value has the ring + * already spun through hundreds of radians by the time you cast. It is + * exposed because a slow drift on a *short* scroll is a look, not because a + * scroll should turn. + */ + tumble: 0.0, + + /* ------------------------------------------------------------------ */ + /* The roll */ + /* ------------------------------------------------------------------ */ + /** + * The spool's core radius, `r₀` in the spiral, in metres. + * + * 60 mm rather than the 30 mm a real scroll rod would be, and the reason is + * tessellation rather than taste: the mesh has a fixed number of segments + * along the sheet, so *segments per turn* is `segmentsV × 2πr / sheetLength`. + * Halving the core doubles the number of turns 3.2 m of paper has to make and + * halves the segments each one gets, and the roll goes from a circle to a + * nut. If you want a thinner rod, shorten the sheet with it. + */ + core: 0.06, + paper: 0.0012, // metres — sheet thickness, t. Sets how fast the roll grows. + curlRoll: 0.6, // 1/metres of set in the run while most of it is still wound + curlFlat: 0.13, // 1/metres once it is fully paid out — the paper relaxes + spoolClimb: 1.0, // 0 pins the spool and the sheet falls; 1 the wall rises + spin: 0.0, // radians of phase on the roll + + /* ------------------------------------------------------------------ */ + /* The sheet */ + /* ------------------------------------------------------------------ */ + sheetWidth: 0.64, // metres across + sheetLength: 3.2, // metres along — the roll and the run share this + aspect: 1.0, // multiplier on the width + thickness: 0.0006, // metres per stacked flap. `UNROLL` has no flaps; harmless. + + /* --- the paper as an object --- */ + ambient: 0.48, // floor under the diffuse term + tintSpread: 0.07, // ±per-scroll brightness walk — no two sheets the same age + paperOpacity: 1.0, // master coverage before the fade + woundShade: 0.42, // how much darker the paper is where it is still on the roll + + /* ------------------------------------------------------------------ */ + /* The surface — every key here is read by `FoldMesh` verbatim */ + /* ------------------------------------------------------------------ */ + grain: 0.6, // how much fibre shows in the albedo + grainScale: 48, // fibres per metre + grainAngle: 1.4, // radians — the machine direction runs across a scroll + grainAniso: 12, // how far the fibres are stretched along it + fleck: 0.34, // sparse darker specks in the pulp + laid: 0.2, // the regular ribbing of a laid sheet + laidPitch: 34, // lines per metre, the fine ones + chainPitch: 1.4, // lines per metre, the coarse chain lines + creaseGlow: 0.25, // a scroll has no creases; kept low for the deckle + creaseDark: 0.2, // ... and the same for the shadow side + creaseSharp: 1.6, // exponent on the band profile + transmit: 0.85, // how much light comes through the sheet from behind + transmitPower: 4.0, // tightness of that lobe — a wide one, it is thin paper + wrap: 0.5, // how far light bends around the sheet, 0..1 + sheen: 0.18, // grazing specular along the fibre + gloss: 22, // specular exponent + edge: 0.014, // deckle — the fraction of the sheet its edge thins over + + colorPaper: '#e8dcc0', // the sheet, lit + colorShade: '#a89880', // the sheet, unlit + colorTransmit: '#ffdfae', // light coming *through* the paper + colorCrease: '#fff4de', // the highlight along the deckle and the tangent line + + /* ------------------------------------------------------------------ */ + /* The writing */ + /* ------------------------------------------------------------------ */ + /** + * Columns of hashed strokes in **sheet space**, which is what makes the + * foreshortening real: the text is printed on the material, so it compresses + * exactly as much as the paper it is on does where it turns onto the roll. + * Nothing in the fragment shader knows about the curve. + */ + ink: 0.86, // 0 blank paper, 1 fully written + inkRows: 26, // characters down the scroll + inkCols: 4, // columns across it + inkFill: 0.86, // fraction of cells that carry a mark + inkWeight: 0.06, // stroke half-width, cell units + inkMargin: 0.07, // fraction of the sheet left blank at the edges + inkSeed: 11.3, // shifts the whole text + inkGhost: 0.32, // how much the writing shows through from the back + colorInk: '#201914', // the writing + + /* ------------------------------------------------------------------ */ + /* Dust off the spools — `scrollward.dust`, SMOKE, non-additive */ + /* ------------------------------------------------------------------ */ + dustRate: 34, // particles/second while the wall is paying out + dustBurst: 40, // particles thrown on the frame the scrolls stand up + dustSize: 0.42, // metres + dustLifetime: 1.7, // seconds + dustSpeed: 0.7, // metres/second + dustRise: 0.22, // metres/second² of buoyancy + dustSpread: 0.9, // radians of the emission cone + dustOpacity: 0.36, // it is dust, not smoke + dustTurbulence: 0.35, // curl noise strength + colorDustA: '#e2d7bd', + colorDustB: '#c4b79a', + colorDustC: '#9c8f76', + colorDustD: '#6b6153', + + /* ------------------------------------------------------------------ */ + /* Flakes — `scrollward.flakes`, LEAF, lit and non-additive */ + /* ------------------------------------------------------------------ */ + flakeRate: 22, // particles/second, shed from the spool as it climbs + flakeBurst: 34, // particles on the frame the wall stands up + flakeSize: 0.1, // metres + flakeLifetime: 2.4, // seconds + flakeSpeed: 1.1, // metres/second + flakeGravity: -2.2, // metres/second² — a flake of paper falls slowly + flakeSpin: 5.5, // radians/second of tumble + flakeSpread: 0.85, // radians of the emission cone + colorFlakeA: '#efe4cc', + colorFlakeB: '#d3c4a4', + colorFlakeC: '#a8977b', + colorFlakeD: '#6a6050', + + /* ------------------------------------------------------------------ */ + /* The floor and the camera */ + /* ------------------------------------------------------------------ */ + markCount: 6, // how many dust marks are stamped round the ring at impact + markRadius: 0.62, // metres — the radius of one of them + markLife: 1.9, // seconds it lasts + markIntensity: 0.5, // how strongly it reads against the floor + colorMarkA: '#dccfb4', // the mark, near + colorMarkB: '#8b7f68', // the mark, far + rumble: 0.03, // continuous camera shake while the wall is paying out + standShake: 0.14, // one-shot shake on the frame the scrolls stand up + shakeDuration: 0.6, // seconds that shake takes to die + + /* --- the light --- */ + /** + * One warm lamp inside the ring, climbing with the wall so the inside faces + * stay lit as they rise. Low: the wall should be lit by the room, and this + * exists so the paper's translucency has something to be translucent *to*. + */ + lightColor: '#ffdaa4', // the colour of that lamp + lightIntensity: 3.2, // its intensity + lightRadius: 9.0, // metres it reaches + lightHeight: 0.55 // its height as a fraction of the paid-out wall +}; + +/** Editor layout. The three paper folders come straight off `FoldMesh`. */ +export const scrollwardSchema = { + 'The cast': [ + ['range', 4, 34, 0.5], + ['minRange', 0, 10, 0.5, 'min range'], + ['speed', 6, 90, 0.5], + ['cooldown', 0, 14, 0.1], + ['castAnim', 'cast animation'], + ['zoneRadius', 1, 12, 0.1, 'footprint (m)'] + ], + 'The beats': [ + ['unrollTime', 0.2, 5, 0.05, 'payout (s)'], + ['holdTime', 0, 6, 0.05, 'hold (s)'], + ['windTime', 0.2, 4, 0.05, 'wind back (s)'], + ['payoutSeed', 0, 0.5, 0.005, 'payout on arrival'], + ['payoutEnd', 0.05, 1, 0.005, 'payout, full'], + ['rewind', 0, 1, 0.005, 'payout, wound back'], + ['windHold', 0, 0.95, 0.01, 'opaque through the wind'] + ], + 'The wall': [ + ['scrolls', 1, 20, 1, 'count'], + ['ringRadius', 0.1, 2, 0.01, 'ring (× footprint)'], + ['radiusJitter', 0, 0.5, 0.005, 'ring jitter'], + ['arc', 0.2, 6.284, 0.01, 'arc (rad)'], + ['arcPhase', 0, 6.284, 0.01, 'first scroll'], + ['lift', -0.5, 2, 0.01, 'free end (m)'], + ['liftJitter', 0, 0.5, 0.005, 'free end jitter (m)'], + ['bob', 0, 0.3, 0.005, 'bob (m)'], + ['bobRate', 0, 6, 0.05, 'bob rate'], + ['yawJitter', 0, 1.6, 0.01, 'yaw jitter'], + ['rollJitter', 0, 1, 0.01, 'lean jitter'], + ['pitch', -1, 1, 0.01, 'ring tip'], + ['sizeJitter', 0, 1, 0.01, 'size jitter'], + ['revealSpread', 0.01, 1, 0.01, 'appear wave'], + ['tumble', 0, 2, 0.01, 'tumble (rad/s)'] + ], + 'The roll': [ + ['core', 0.005, 0.3, 0.001, 'core (m)'], + ['paper', 0.0001, 0.004, 0.00005, 'thickness (m)'], + ['curlRoll', -1.5, 1.5, 0.01, 'set, wound (1/m)'], + ['curlFlat', -1.5, 1.5, 0.01, 'set, paid out (1/m)'], + ['spoolClimb', 0, 1, 0.01, 'spool climbs'], + ['spin', 0, 6.283, 0.01, 'roll phase'] + ], + 'Paper · sheet': [ + ['sheetWidth', 0.05, 4, 0.01, 'width (m)'], + ['sheetLength', 0.2, 8, 0.01, 'length (m)'], + ['aspect', 0.25, 4, 0.01], + ['thickness', 0, 0.006, 0.0001, 'flap offset (m)'] + ], + 'Paper · body': [ + ['ambient', 0, 1, 0.01], + ['tintSpread', 0, 0.4, 0.01, 'tint spread'], + ['paperOpacity', 0, 1, 0.01, 'opacity'], + ['woundShade', 0, 1, 0.01, 'roll shading'] + ], + 'Paper · surface': PAPER['Paper · surface'], + 'Paper · writing': PAPER['Paper · writing'], + 'Spool dust': [ + ['dustRate', 0, 160, 1, 'rate (/s)'], + ['dustBurst', 0, 200, 1, 'burst'], + ['dustSize', 0.05, 3, 0.01, 'size (m)'], + ['dustLifetime', 0.1, 6, 0.05, 'lifetime (s)'], + ['dustSpeed', 0, 6, 0.05, 'speed (m/s)'], + ['dustRise', -2, 4, 0.05, 'rise (m/s²)'], + ['dustSpread', 0, 1.6, 0.01, 'cone'], + ['dustOpacity', 0, 1, 0.01, 'opacity'], + ['dustTurbulence', 0, 2, 0.01, 'turbulence'], + 'colorDustA', + 'colorDustB', + 'colorDustC', + 'colorDustD' + ], + 'Flakes': [ + ['flakeRate', 0, 160, 1, 'rate (/s)'], + ['flakeBurst', 0, 200, 1, 'burst'], + ['flakeSize', 0.02, 1, 0.005, 'size (m)'], + ['flakeLifetime', 0.1, 6, 0.05, 'lifetime (s)'], + ['flakeSpeed', 0, 12, 0.05, 'speed (m/s)'], + ['flakeGravity', -12, 2, 0.05, 'gravity (m/s²)'], + ['flakeSpin', 0, 20, 0.1, 'spin (rad/s)'], + ['flakeSpread', 0, 1.6, 0.01, 'cone'], + 'colorFlakeA', + 'colorFlakeB', + 'colorFlakeC', + 'colorFlakeD' + ], + 'Floor and camera': [ + ['markCount', 0, 12, 1, 'floor marks'], + ['markRadius', 0.1, 4, 0.05, 'mark size (m)'], + ['markLife', 0.1, 6, 0.05, 'mark life (s)'], + ['markIntensity', 0, 2, 0.01, 'mark strength'], + 'colorMarkA', + 'colorMarkB', + ['rumble', 0, 0.6, 0.005], + ['standShake', 0, 1.2, 0.01, 'stand-up shake'], + ['shakeDuration', 0.05, 2, 0.05, 'shake decay (s)'] + ], + 'The light': [ + 'lightColor', + ['lightIntensity', 0, 20, 0.1, 'intensity'], + ['lightRadius', 1, 30, 0.5, 'radius (m)'], + ['lightHeight', 0, 1.5, 0.01, 'height (× wall)'] + ] +}; diff --git a/src/config/abilities/sealscript.js b/src/config/abilities/sealscript.js new file mode 100644 index 0000000..9648926 --- /dev/null +++ b/src/config/abilities/sealscript.js @@ -0,0 +1,370 @@ +/* ================================================================== */ +/* SEALSCRIPT — Seal Script */ +/* ================================================================== */ +/** + * A column of characters written top-to-bottom in the air over a wash of ink. + * + * The trick is **legible brush weight in three dimensions**. Ten seal-script + * skeletons are authored in `abilities/ink/SealscriptAbility.js` as unitless + * control points in an em box; every one of those points is turned into metres + * here, every frame. Each stroke is drawn in sequence with a real entry, body + * and exit, and — this is the part that matters — each one is a **swept + * ellipse**, not a billboard. `depth` is a genuine half-thickness through the + * paper normal, so orbiting to ninety degrees off the writing gives you a + * column of ink *bars* rather than a column that has vanished. + * + * ### The three keys the trick lives in + * + * - `depth` — metres of half-thickness through the paper normal. At the + * default 0.024 m against a `strokeWidth` of 0.030 m the cross-section is + * very nearly round, which is why the writing survives being looked at + * edge-on. Drop it to 0.002 and the column disappears when you orbit; that + * is the failure this ability exists to not have. + * - `columnBow` — metres the middle of each character bulges toward the + * reader. A perfectly flat plane of characters reads as a decal the moment + * the camera moves. A barrel gives the column volume and the strokes at the + * edges of the em box genuinely recede. + * - `charTwist` — radians of yaw between one character and the next, so the + * column fans instead of lying in one plane. Small. At 0.2 it stops being + * writing and starts being a mobile. + * + * ### The brush is round + * + * `BrushTip.ROUND` rather than the sumi hake, because seal script is written + * with a round brush and because a round ferrule spreads its bristles on a disc + * — which is the only tip layout where `ferruleDepth` does anything. That + * matters here and nowhere else: it is what puts grain *through* the stroke as + * well as across it, so the depth axis has structure in it rather than being an + * extruded silhouette. + * + * ### Even weight is the legibility + * + * Seal script has almost constant stroke weight, and that evenness is exactly + * what makes it read as *writing* rather than as painting. So `pressEntry` and + * `pressExit` are high (0.74 / 0.66) where sumi's are near zero: the brush is + * tucked in at both ends and never lifts to a point. `inkFalloff` is the one + * concession — the brush genuinely does get drier down a column — and at 0.22 + * the last character has a little fray in it and the first does not. + * + * ### No bloom + * + * `ceiling` clamps the material's output linear luminance against + * `post.bloomThreshold` (0.88), so 0.62 makes it arithmetically impossible for + * this slot to feed the bloom pass. `washCeiling` does the same for the ground + * wash. Both are the anti-glow contract, not a taste setting. + */ + +export const sealscript = { + /* --- the cast --- */ + range: 17.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 140.0, // how fast the brush reaches the point, metres/second + zoneRadius: 3.4, // the wash's footprint, metres — the circle the aim drew + cooldown: 1.6, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats, in seconds --- */ + leadTime: 0.28, // seconds before the first mark; the brush is on its way + writeTime: 2.1, // seconds the whole column takes to write itself + holdTime: 1.9, // seconds it hangs, finished + fadeTime: 1.7, // seconds it takes to go + + /* ================================================================ */ + /* The column */ + /* ================================================================ */ + chars: 5, // characters in the column, 1..6 + emSize: 0.62, // metres — the em box. A measurement, not a proportion + charPitch: 1.34, // slot pitch, in em boxes + baseHeight: 0.95, // metres above the floor the LOWEST character's centre sits + columnDrift: 0.07, // metres of per-character lateral wander; a hand is not a printer + columnBow: 0.09, // metres the middle of a character bulges toward the reader + charTwist: 0.055, // radians of yaw between one character and the next + charPause: 0.55, // stroke-widths of pause between characters, in the drawing clock + strokeOverlap: 0.35, // how far a stroke's window overruns the next one's start + + /* ================================================================ */ + /* The brush — BrushStroke, ROUND tip */ + /* ================================================================ */ + strokeWidth: 0.03, // metres, ferrule half-width at pressure 1 + depth: 0.024, // metres, half-thickness through the paper normal. THE TRICK + ferruleDepth: 0.008, // metres the bristles spread through it — ROUND tips only + bristleWidth: 2.4, // >1 overlaps into a solid stroke; high, because seal script is solid + splay: 0.1, // fraction the bristles fan out at full pressure + wobble: 0.0035, // metres of per-bristle lateral wander + fibreScale: 9.0, // features per metre of that wander — small strokes, fine grain + + /* --- pressure: even, tucked at both ends --- */ + pressEntry: 0.74, // pressure control points, scaled by each stroke's own weight + pressSwell: 0.98, + pressHold: 0.94, + pressExit: 0.66, + + /* ================================================================ */ + /* The ink */ + /* ================================================================ */ + inkLoad: 1.0, // multiplier on every stroke's own metre-pigment load + strokeInk: 2.6, // metre-pigment per stroke; a stroke is ~0.3 m, so it stays wet + inkFalloff: 0.22, // fraction of that lost by the last character in the column + flowLength: 0.7, // pigment laid per metre of travel, at pressure 1 + flowDwell: 0.3, // extra pigment laid per metre when the brush dwells + speedRef: 1.6, // metres per unit t at which the brush counts as "at speed" + edgeStarve: 0.6, // load multiplier at the edge of the ferrule + loadJitter: 0.3, // +/- fraction of load, per bristle + dryBand: 0.7, // metre-pigment over which a bristle goes wet -> dry + dryThin: 0.6, // width multiplier when a bristle is fully dry + skipScale: 11.0, // features per metre of the contact noise + skipSoft: 0.14, // softness of the contact threshold + skipContrast: 1.7, // pushes the value noise off its central pile + poolSwell: 0.4, // fraction the stroke widens per unit of extra pigment + poolCurve: 0.05, // extra pigment per 1/metre of curvature — LOW, see the class doc + pigment: 1.0, // overall density multiplier + + /* ================================================================ */ + /* The mark */ + /* ================================================================ */ + headTaper: 0.16, // fraction of the drawn length the moving head tapers over + wetLength: 0.16, // metres behind the head that still read wet + bleed: 0.3, // fraction of the bristle half-width the edge wicks over + fibreEdge: 0.1, // capillary roughness of that edge + dryPigment: 0.72, // density multiplier where a bristle is dry + wetGain: 0.4, // extra density in the wet band behind the head + opacity: 1.0, + lit: 0.5, // 0 flat pigment, 1 wrapped diffuse. Higher than sumi: this has volume + backLit: 0.6, // the floor of that wrap + ceiling: 0.62, // max linear luminance. post.bloomThreshold is 0.88. DO NOT RAISE + softFade: 0.1, // metres of depth feather — it hangs in air, so this can be generous + tint: 0.28, // where in the gradient a zero-density mark sits + tintDensity: 0.7, // how far density walks it + tintJitter: 0.06, // +/- per bristle + colorWash: '#a89b86', // the palest wicked edge + colorBody: '#4e453c', + colorInk: '#171412', + colorPool: '#050404', + + /* ================================================================ */ + /* The ground wash — InkDiffusion(WASH) */ + /* ================================================================ */ + washHeight: 0.02, // metres above the floor + washSpread: 1.9, // metres of front radius at t = 1 s + washPower: 0.45, // r ~ t^power; 0.5 is Fickian diffusion + washEdge: 0.14, // metres, the width of the interface + washClip: 0.9, // metres the mass fades out over as it reaches `zoneRadius` + washSources: 3, // live nuclei, so the pool is not a perfect disc + washScatter: 0.3, // fraction of the radius the extra nuclei scatter over + washDelay: 0.22, // seconds each later nucleus starts behind the first + washCore: 0.55, // density floor inside the blob + washFalloff: 3.4, // metres of e-folding out from a nucleus + washFilm: 0.5, // alpha floor inside the coverage + washGranulation: 0.3, // pigment settling into the floor's tooth + washGranScale: 1.4, // features per metre + washRing: 0.6, // strength of the deposition line at the interface + washRingWidth: 0.15, // metres + washDryTime: 2.4, // seconds for the gloss to fall to 1/e + washWetDarken: 0.32, // how far the wet film pulls toward `colorWashWet` + washGloss: 0.2, // specular strength on the wet film — under the ceiling + washGlossPower: 40, // its tightness + washMeniscus: 0.5, // how far the film's normal tips at the interface + washOpacity: 0.9, + washCeiling: 0.62, // the same anti-bloom clamp, for the same reason + washSoftFade: 0.22, // metres of depth feather + washTint: 0.08, // where in the gradient a zero-density film sits + washTintDensity: 1.0, // how far density walks it + /* Named `film`, not `wash`, on purpose: the brush already owns `colorWash` + * for its palest wicked edge, and two colour keys one character apart is how + * a picker ends up bound to the wrong thing. */ + colorFilmA: '#a29480', // thinnest film + colorFilmB: '#4a4139', + colorFilmC: '#1b1714', + colorFilmD: '#080706', + colorFilmRing: '#2b1f18', // the deposition line at the interface + colorFilmWet: '#0f0c09', // what the still-wet film pulls toward + colorFilmGloss: '#c4cad0', // the specular on it + + /* ================================================================ */ + /* Ink motes — pigment lifting off the wet strokes */ + /* ================================================================ */ + moteRate: 30.0, // particles/second while the brush is writing + moteSize: 0.05, + moteSpeed: 0.5, // metres/second + moteLifetime: 1.7, // seconds + moteRise: 0.25, // metres/second² + moteTurbulence: 0.55, + moteOpacity: 0.45, + moteGlow: 0.3, // held low: a matte school does not get bright particles + colorMoteA: '#8b8071', + colorMoteB: '#494037', + colorMoteC: '#221d19', + colorMoteD: '#0c0a09', + + /* ================================================================ */ + /* Drips — ink that ran off a stroke and fell into the wash */ + /* ================================================================ */ + dripRate: 5.0, // droplets/second, from the strokes already written + dripBurst: 12, // droplets thrown when the last stroke lands + dripSize: 0.045, + dripSpeed: 0.5, + dripLifetime: 1.6, + dripGravity: -8.5, // metres/second² + dripOpacity: 0.9, + colorDripA: '#5d5348', + colorDripB: '#2d2722', + colorDripC: '#141110', + colorDripD: '#060505', + + /* ================================================================ */ + /* Feel */ + /* ================================================================ */ + rumble: 0.008, // camera rumble while the column is being written. Tiny + finishShake: 0.05, // one-shot nudge as the last stroke is completed + shakeDuration: 0.45, // seconds + + /* ================================================================ */ + /* Dynamic light */ + /* ================================================================ */ + /* A small warm lamp riding the brush, so the wash below the column reads + * damp. Ink emits nothing; this lights the paper. Turn it to 0 and the slot + * still works. */ + lightIntensity: 3.6, + lightRadius: 5.5, // metres + lightColor: '#c6b69c' +}; + +/** Editor layout: which folders exist and what goes in them. */ +export const sealscriptSchema = { + 'The cast': [ + ['range', 4, 40, 0.5, 'range (m)'], + ['minRange', 0.5, 12, 0.1, 'minimum range (m)'], + ['speed', 10, 300, 1, 'travel speed (m/s)'], + ['zoneRadius', 0.5, 14, 0.1, 'wash radius (m)'], + ['cooldown', 0.1, 6, 0.05, 'cooldown (s)'], + 'castAnim', + ['leadTime', 0, 3, 0.01, 'lead-in (s)'], + ['writeTime', 0.2, 12, 0.05, 'write (s)'], + ['holdTime', 0.1, 10, 0.05, 'hold (s)'], + ['fadeTime', 0.2, 8, 0.05, 'fade (s)'] + ], + 'The column': [ + ['chars', 1, 6, 1, 'characters'], + ['emSize', 0.1, 2.5, 0.01, 'em box (m)'], + ['charPitch', 0.6, 3, 0.01, 'slot pitch (em)'], + ['baseHeight', 0, 6, 0.02, 'lowest character (m)'], + ['columnDrift', 0, 0.6, 0.005, 'lateral wander (m)'], + ['columnBow', -0.6, 0.6, 0.005, 'barrel toward the reader (m)'], + ['charTwist', -0.4, 0.4, 0.005, 'twist per character (rad)'], + ['charPause', 0, 3, 0.01, 'pause between characters'], + ['strokeOverlap', 0, 2, 0.01, 'stroke overlap'] + ], + 'The brush': [ + ['strokeWidth', 0.002, 0.2, 0.001, 'ferrule half-width (m)'], + ['depth', 0.001, 0.15, 0.001, 'half-depth through the paper (m)'], + ['ferruleDepth', 0, 0.08, 0.0005, 'ferrule depth (m)'], + ['bristleWidth', 0.2, 5, 0.01, 'bristle overlap'], + ['splay', 0, 1.5, 0.01, 'splay at full pressure'], + ['wobble', 0, 0.05, 0.0005, 'bristle wander (m)'], + ['fibreScale', 0.5, 40, 0.1, 'wander frequency (1/m)'], + ['pressEntry', 0, 2, 0.01, 'pressure: entry'], + ['pressSwell', 0, 2.5, 0.01, 'pressure: swell'], + ['pressHold', 0, 2.5, 0.01, 'pressure: hold'], + ['pressExit', 0, 2, 0.01, 'pressure: exit'] + ], + 'The ink': [ + ['inkLoad', 0.05, 4, 0.01, 'ink load multiplier'], + ['strokeInk', 0.05, 12, 0.05, 'load per stroke (metre-pigment)'], + ['inkFalloff', 0, 1, 0.01, 'load lost down the column'], + ['flowLength', 0.02, 3, 0.01, 'flow per metre'], + ['flowDwell', 0, 3, 0.01, 'flow while dwelling'], + ['speedRef', 0.05, 20, 0.05, 'reference speed'], + ['edgeStarve', 0, 1.5, 0.01, 'edge starvation'], + ['loadJitter', 0, 1, 0.01, 'per-bristle load jitter'], + ['dryBand', 0.02, 6, 0.01, 'dry band (metre-pigment)'], + ['dryThin', 0, 1.5, 0.01, 'dry thinning'], + ['skipScale', 0.5, 40, 0.1, 'skip frequency (1/m)'], + ['skipSoft', 0.005, 0.6, 0.005, 'skip softness'], + ['skipContrast', 0.2, 5, 0.01, 'skip contrast'], + ['poolSwell', 0, 2, 0.01, 'pool swell'], + ['poolCurve', 0, 1, 0.005, 'pool on curvature'], + ['pigment', 0.05, 4, 0.01, 'pigment density'] + ], + 'The mark': [ + ['headTaper', 0.002, 0.6, 0.002, 'head taper'], + ['wetLength', 0, 2, 0.005, 'wet band (m)'], + ['bleed', 0.01, 1, 0.005, 'capillary bleed'], + ['fibreEdge', 0, 1, 0.005, 'edge roughness'], + ['dryPigment', 0, 1.5, 0.01, 'density when dry'], + ['wetGain', 0, 1.5, 0.01, 'density when wet'], + ['opacity', 0, 1, 0.005, 'opacity'], + ['lit', 0, 1, 0.01, 'wrapped diffuse'], + ['backLit', 0, 1, 0.01, 'wrap floor'], + ['ceiling', 0.05, 0.88, 0.005, 'luminance ceiling'], + ['softFade', 0.002, 0.6, 0.002, 'depth feather (m)'], + ['tint', 0, 1, 0.005, 'gradient at zero density'], + ['tintDensity', 0, 2, 0.01, 'gradient walk'], + ['tintJitter', 0, 0.5, 0.005, 'per-bristle tint jitter'], + ['colorWash', 'wicked edge'], + ['colorBody', 'body'], + ['colorInk', 'full ink'], + ['colorPool', 'pooled'] + ], + 'The ground wash': [ + ['washHeight', 0, 0.3, 0.002, 'height (m)'], + ['washSpread', 0.1, 10, 0.05, 'spread at 1 s (m)'], + ['washPower', 0.1, 1.5, 0.01, 'spread exponent'], + ['washEdge', 0.01, 1, 0.005, 'interface width (m)'], + ['washClip', 0.05, 4, 0.05, 'clip softness (m)'], + ['washSources', 1, 4, 1, 'nuclei'], + ['washScatter', 0, 1, 0.01, 'nucleus scatter'], + ['washDelay', 0, 2, 0.01, 'nucleus delay (s)'], + ['washCore', 0, 1, 0.01, 'core density'], + ['washFalloff', 0.2, 20, 0.05, 'falloff (m)'], + ['washFilm', 0, 1, 0.01, 'film floor'], + ['washGranulation', 0, 1, 0.005, 'granulation'], + ['washGranScale', 0.1, 8, 0.05, 'granulation scale (1/m)'], + ['washRing', 0, 2, 0.01, 'deposition ring'], + ['washRingWidth', 0.01, 1, 0.005, 'ring width (m)'], + ['washDryTime', 0.1, 12, 0.05, 'dry time (s)'], + ['washWetDarken', 0, 1, 0.01, 'wet darkening'], + ['washGloss', 0, 1, 0.005, 'wet gloss'], + ['washGlossPower', 2, 200, 1, 'gloss tightness'], + ['washMeniscus', 0, 2, 0.01, 'meniscus'], + ['washOpacity', 0, 1, 0.005, 'opacity'], + ['washCeiling', 0.05, 0.88, 0.005, 'luminance ceiling'], + ['washSoftFade', 0.005, 1, 0.005, 'depth feather (m)'], + ['washTint', 0, 1, 0.005, 'gradient at zero density'], + ['washTintDensity', 0, 2, 0.01, 'gradient walk'], + ['colorFilm*', 'Film colour'], + ['colorFilmRing', 'deposition line'], + ['colorFilmWet', 'wet film'], + ['colorFilmGloss', 'specular on the wet film'] + ], + 'Ink motes': [ + ['moteRate', 0, 400, 1, 'rate'], + ['moteSize', 0.005, 0.4, 0.002, 'size'], + ['moteSpeed', 0, 6, 0.05, 'speed'], + ['moteLifetime', 0.1, 6, 0.05, 'lifetime (s)'], + ['moteRise', -3, 3, 0.01, 'rise (m/s²)'], + ['moteTurbulence', 0, 3, 0.01, 'turbulence'], + ['moteOpacity', 0, 1, 0.005, 'opacity'], + ['moteGlow', 0, 1, 0.005, 'glow — keep it low'], + ['colorMote*', 'Mote colour'] + ], + Drips: [ + ['dripRate', 0, 80, 0.5, 'rate'], + ['dripBurst', 0, 120, 1, 'droplets on the last stroke'], + ['dripSize', 0.005, 0.3, 0.002, 'size'], + ['dripSpeed', 0, 6, 0.05, 'speed'], + ['dripLifetime', 0.1, 5, 0.05, 'lifetime (s)'], + ['dripGravity', -30, 0, 0.1, 'gravity (m/s²)'], + ['dripOpacity', 0, 1, 0.005, 'opacity'], + ['colorDrip*', 'Droplet colour'] + ], + Feel: [ + ['rumble', 0, 0.2, 0.001, 'writing rumble'], + ['finishShake', 0, 1, 0.005, 'shake on the last stroke'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration (s)'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 40, 0.1, 'light intensity'], + ['lightRadius', 0.5, 30, 0.1, 'light radius (m)'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/shatterlance.js b/src/config/abilities/shatterlance.js new file mode 100644 index 0000000..2ddf0bf --- /dev/null +++ b/src/config/abilities/shatterlance.js @@ -0,0 +1,433 @@ +/* ================================================================== */ +/* SHATTERLANCE — the wind-up is the ability */ +/* ================================================================== */ +/** + * One enormous ice lance, assembled in the air out of converging shards, held + * long enough for you to read it as a single object, thrown, and broken on + * arrival into two hundred instanced fragments that inherit its flight. + * + * The numbers that decide whether it works: + * + * - **`assembly`** — how long the wind-up lasts. Everything before the throw + * is bought by `advance()` refusing to let the front leave the caster for + * this many seconds, and the whole ability lives or dies on it being long + * enough to look at. Under about six tenths of a second the shards and the + * lance are one event and the contrast is gone. + * - **`intakeStagger`** against **`intakeEase`** — how the shards arrive. + * Stagger spreads them over the clock so they land in a stream; ease decides + * whether they drift in or snap in at the last moment. At `stagger 0`, + * `ease 1` every shard flies a straight line at the same speed and the + * intake reads as a collapsing balloon. + * - **`fragmentCount` and `fragInherit`** — the break. `fragInherit` is the + * fraction of the lance's own flight velocity every fragment leaves with; at + * 0 the impact is a firework, at 1 the shrapnel carries on downrange, which + * is what makes it read as something that *hit* rather than as something + * that popped. + * + * Everything with a unit is resolved against this block inside the update loop, + * on every frame including a zero-length one — including the whole intake, + * which is a closed-form flight in a vertex shader with no CPU state at all. + */ +export const shatterlance = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 62.0, // how fast the lance flies once released, metres/second + cooldown: 1.4, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + assembly: 1.15, // seconds of wind-up before the front is allowed to leave + lifetime: 0.5, // seconds the impact holds before the fade takes over + fadeTime: 1.6, // seconds the shrapnel and the frost sheet take to go + + /* --- the lance --- */ + lanceLength: 4.2, // metres, nose to tail + lanceRadius: 0.42, // metres, at its widest + lanceHeight: 1.35, // metres above the floor it hangs and flies at + lanceLean: 1.5708, // radians the body is tipped from vertical; π/2 is level + lanceRoll: 0.35, // 0..1 of a turn about its own long axis + hoverForward: 3.4, // metres in front of the caster the lance assembles + hoverBob: 0.06, // metres it rises and falls by while it hangs + hoverBobSpeed: 1.6, // bobs per second + formLengthCurve: 0.6, // <1 extends the lance early in the wind-up + formRadiusCurve: 1.5, // >1 keeps it thin then thickens it late + formSeed: 0.12, // 0..1 of full size the lance starts the wind-up at + + /* --- the body (these rebuild the geometry when they move) --- */ + facets: 7, // sides of the prism + swell: 0.26, // 0..1 up the lance where it is widest + tailWidth: 0.22, // 0..1 of the full radius at the tail + tipCurve: 1.35, // how fast it tapers to the point + flute: 0.28, // 0..1 depth of the grooves between facets + fluteTwist: 0.5, // turns the grooves make over the length + bodyJitter: 0.12, // 0..1 irregularity, so it is ice and not a machined cone + + /* --- the lance material --- */ + depthTint: 1.3, // how dark a facet gets seen head-on + fresnel: 2.0, // rim brightness + fresnelPower: 2.3, // how tight that rim is + facetSharp: 0.62, // how hard the facets separate + fracture: 0.3, // internal crack planes + fractureScale: 5.0, // crack features per metre + veins: 0.28, // milky veining + veinScale: 2.6, // vein features per unit + seamGlow: 2.4, // the light in the flute valleys — the lance's signature + seamCount: 3.5, // seams around the circumference (facets / 2 reads best) + seamWidth: 0.35, // 0..1 how wide one seam is + seamTwist: 0.5, // turns the seams make over the length + glint: 1.4, // pinpoint sparkle + glintScale: 22.0, // glints per metre + glintSpeed: 0.6, // how fast they scroll + glow: 1.4, // overall emissive gain + chargeGlow: 3.0, // the weld line running the lance as it fills + opacity: 0.94, + roughness: 0.1, // drives the env reflection + envIntensity: 1.2, // how much of the HDR probe the ice reflects + colorIce: '#ffffff', // the body at a grazing angle + colorDeep: '#0d2f52', // the body seen head-on + colorSeam: '#8fe3ff', // the flute valleys and the weld line + colorRim: '#d8f4ff', // the silhouette, the veins and the glints + + /* --- the shards that fly in --- */ + intakeCount: 120, // shards in the intake, one draw call for the lot + intakeSphere: 4.5, // metres, the shell they start on + intakeSpread: 3.2, // metres, that shell stretched along the lance + intakeStagger: 0.55, // 0..1 of the wind-up spread across the shards + intakeSize: 0.22, // metres, one shard + intakeSpin: 6.0, // radians/second of tumble on the way in + intakeEase: 3.0, // >1 makes them accelerate in at the last moment + intakeOpacity: 0.95, + intakeGlow: 1.6, + intakeRim: 1.2, // rim light on a shard + intakeRimPower: 2.2, + intakeShade: 1.0, // how much the key direction shades a shard + intakeAmbient: 0.35, + colorIntakeA: '#8fe3ff', // a shard out on the shell + colorIntakeB: '#ffffff', // ...and the moment before it lands + colorIntakeEdge: '#d8f4ff', // its rim + + /* --- the break --- */ + fragmentCount: 200, // fragments the lance breaks into + fragSpawnRadius: 0.5, // metres of scatter about the lance's own body + fragSpawnHeight: 1.35, // metres above the floor the break happens at + fragInherit: 0.45, // 0..1 of the flight velocity every fragment leaves with + fragSize: 0.34, // metres + fragSizeJitter: 0.6, // ± fraction + fragSpeed: 7.5, // metres/second of its own throw, on top of the inheritance + fragSpeedJitter: 0.7, // ± fraction + fragSpread: 0.38, // 0 throws them all downrange, 1 is fully random + fragUpBias: 0.4, // how much +Y is folded into the throw + fragGravity: -19.0, // metres/second² + fragDrag: 0.7, // 1/second + fragSpin: 11.0, // radians/second of tumble + fragSpinJitter: 0.85, // ± fraction + fragLifetime: 1.8, // seconds + fragShrink: 0.7, // 0..1 of its size a fragment loses by the end + fragShrinkPower: 1.5, // how late that shrink bites + fragFloor: 0.03, // metres; a fragment never sinks below this + fragFloorSpin: 0.18, // fraction of the tumble kept once grounded + fragOpacity: 0.96, + fragGlow: 1.3, + fragRim: 1.2, + fragRimPower: 2.3, + fragShade: 1.0, + fragAmbient: 0.38, + fragFadeStart: 0.6, // 0..1 of its life before a fragment starts fading + fragSoft: 0.3, // metres of soft fade where a fragment meets geometry + colorFragA: '#ffffff', // a fragment lit + colorFragB: '#8fe3ff', // a fragment in shade + colorFragEdge: '#d8f4ff', // its rim + + /* --- the frost sheet under the break --- */ + // A `GroundField` in PLATE mode with almost no lift: this is a sheet of frost + // thrown flat across the stone by the impact, not a raft of curling plates. + sheetRadius: 5.5, // metres + sheetGrow: 0.35, // seconds it takes to reach that radius + sheetHold: 0.5, // 0..1 of the fade it survives before it goes + sheetHeight: 0.02, // metres above the floor the quad sits at + sheetCell: 0.3, // metres — the pitch of one frost plate + sheetCellJitter: 0.95, // 0..1 — how irregular that packing is + sheetSeam: 0.02, // metres of gap between plates + sheetThickness: 0.014, // metres of body + sheetLift: 0.012, // metres the downwind edge rises — deliberately almost none + sheetEdge: 0.55, // metres of feather on the front + sheetRagged: 0.42, // how far that front wanders, fraction of the radius + sheetRaggedScale: 1.1, // lobes per metre + sheetWarp: 0.7, // metres of domain warp on those lobes + sheetRelief: 0.55, // how hard the height field tilts the fake normal + sheetNormalStep: 0.04, // metres between the height taps + sheetAmbient: 0.36, // floor on the diffuse term + sheetWrap: 0.5, // 0..1 wraps the terminator round the back + sheetSpecular: 0.7, + sheetGloss: 34.0, // Blinn exponent + sheetParallax: 0.2, // metres of view-driven offset on the interior + sheetDetail: 0.7, // 0..1 frozen spray in the seams + sheetEmissive: 1.0, + sheetOpacity: 0.9, + sheetDepthFade: 0.4, // metres of soft fade against standing geometry + colorSheetBase: '#a9d6ea', // the frost itself + colorSheetEdge: '#ffffff', // its lips and highlights + colorSheetGlow: '#8fe3ff', // its emissive + colorSheetDeep: '#0d2f52', // the thick middle of a plate + + /* --- motes, chips and mist --- */ + /** + * The motes are the only place in the sandbox using the particle engine's + * `swirl` mode with a *negative* expansion: the offset shrinks over the + * particle's life, so they spiral inward onto the assembly point instead of + * away from it. Everything else about them is the usual four-stop gradient. + */ + moteRate: 150, // frost drawn in during the wind-up, particles/second + moteSize: 0.06, + moteLifetime: 1.1, + moteShell: 4.0, // metres, the radius they are drawn in from + moteSwirl: 3.4, // radians/second they orbit the assembly at + moteConverge: 0.92, // 0..1 of their radius they give up over their life + moteRise: 0.15, // upward drift, metres/second + moteTurbulence: 0.35, + colorMoteA: '#8fe3ff', + colorMoteB: '#d8f4ff', + colorMoteC: '#ffffff', + colorMoteD: '#0d2f52', + chipCount: 70, // chips thrown at the break + chipSize: 0.06, + chipSpeed: 6.5, + chipLifetime: 1.3, + chipGravity: -18.0, + colorChipA: '#ffffff', + colorChipB: '#a9d6ea', + colorChipC: '#3f6f92', + colorChipD: '#0d2f52', + mistRate: 40, // vapour off the frost sheet, particles/second + mistSize: 1.1, + mistSpeed: 1.2, + mistLifetime: 2.2, + mistOpacity: 0.1, + mistRise: 0.5, + colorMistA: '#d8f4ff', + colorMistB: '#9db8c8', + colorMistC: '#5a7686', + colorMistD: '#1d2c36', + + /* --- the shock and the shake --- */ + burstSize: 3.4, // the shell of pulverised ice at the break, metres + burstIntensity: 1.5, + colorBurstA: '#8fe3ff', + colorBurstB: '#d8f4ff', + colorBurstC: '#ffffff', + shockRadius: 8.0, // the cold ring that snaps out across the floor, metres + colorShockA: '#8fe3ff', + colorShockB: '#ffffff', + breakFlash: 0.3, // screen flash at the break + colorFlash: '#d8f4ff', + breakShake: 1.1, + shakeDuration: 0.6, + chargeShake: 0.05, // continuous shake while the lance assembles + rumble: 0.02, // continuous shake while it flies + + /* --- dynamic light --- */ + lightIntensity: 20.0, + lightRadius: 15.0, + lightColor: '#8fe3ff', + lightPulse: 0.3, // depth of the light's breath while it charges + lightPulseSpeed: 2.2 // breaths per second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Shatterlance. + * + * Start in `The wind-up`. `assembly` is the ability: shorten it and the slot + * becomes an ordinary fast projectile, lengthen it and it becomes a siege + * weapon. `The shards` decides how the lance is fed, and `The break` decides + * whether the impact reads as shrapnel or as confetti — `fragInherit` is the + * control that separates the two. + */ +export const shatterlanceSchema = { + 'The wind-up': [ + ['assembly', 0.05, 5, 0.01, 'wind-up (s)'], + ['formLengthCurve', 0.1, 4, 0.01, 'length curve'], + ['formRadiusCurve', 0.1, 4, 0.01, 'girth curve'], + ['formSeed', 0.01, 1, 0.01, 'size at t=0'], + ['hoverForward', 0.5, 10, 0.05, 'assembles this far out (m)'], + ['hoverBob', 0, 1, 0.005, 'hover bob (m)'], + ['hoverBobSpeed', 0, 8, 0.05, 'bobs / second'], + ['chargeShake', 0, 0.5, 0.005, 'charge shake'] + ], + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'flight speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['lifetime', 0.05, 4, 0.01, 'impact hold'], + ['fadeTime', 0.1, 6, 0.05, 'fade'], + ['rumble', 0, 0.5, 0.005, 'flight rumble'], + ['castAnim', 'cast animation'] + ], + 'The lance': [ + ['lanceLength', 0.5, 12, 0.05, 'length (m)'], + ['lanceRadius', 0.05, 2, 0.01, 'radius (m)'], + ['lanceHeight', 0.2, 5, 0.01, 'flight height (m)'], + ['lanceLean', 0.6, 2.6, 0.01, 'pitch (rad)'], + ['lanceRoll', 0, 1, 0.01, 'roll'], + ['facets', 4, 16, 1, 'facets'], + ['swell', 0.05, 0.9, 0.01, 'widest point'], + ['tailWidth', 0.02, 1, 0.01, 'tail width'], + ['tipCurve', 0.3, 4, 0.01, 'tip taper'], + ['flute', 0, 0.7, 0.01, 'groove depth'], + ['fluteTwist', -3, 3, 0.01, 'groove twist'], + ['bodyJitter', 0, 0.6, 0.01, 'irregularity'] + ], + 'The ice': [ + ['depthTint', 0, 3, 0.01, 'head-on tint'], + ['fresnel', 0, 6, 0.01, 'rim'], + ['fresnelPower', 0.2, 8, 0.01, 'rim tightness'], + ['facetSharp', 0, 1, 0.01, 'facet separation'], + ['fracture', 0, 2, 0.01, 'internal cracks'], + ['fractureScale', 0.5, 20, 0.1, 'cracks / metre'], + ['veins', 0, 2, 0.01, 'veining'], + ['veinScale', 0.2, 10, 0.05, 'vein scale'], + ['seamGlow', 0, 8, 0.01, 'seam glow'], + ['seamCount', 0.5, 12, 0.5, 'seams around'], + ['seamWidth', 0.02, 1, 0.01, 'seam width'], + ['seamTwist', -3, 3, 0.01, 'seam twist'], + ['chargeGlow', 0, 8, 0.01, 'weld line'], + ['glint', 0, 4, 0.01, 'sparkle'], + ['glintScale', 2, 80, 0.5, 'glints / metre'], + ['glintSpeed', 0, 4, 0.01, 'glint scroll'], + ['glow', 0, 5, 0.01, 'glow'], + ['opacity', 0, 1, 0.01, 'opacity'], + ['roughness', 0.01, 1, 0.01, 'roughness'], + ['envIntensity', 0, 4, 0.01, 'env reflection'], + ['colorIce', 'body, grazing'], + ['colorDeep', 'body, head-on'], + ['colorSeam', 'seams & weld'], + ['colorRim', 'rim, veins, glints'] + ], + 'The shards': [ + ['intakeCount', 0, 256, 1, 'shards'], + ['intakeSphere', 0.2, 14, 0.05, 'shell radius (m)'], + ['intakeSpread', 0.2, 16, 0.05, 'shell along the lance (m)'], + ['intakeStagger', 0, 0.95, 0.01, 'arrival stagger'], + ['intakeSize', 0.02, 1.2, 0.01, 'shard size (m)'], + ['intakeSpin', 0, 30, 0.1, 'tumble'], + ['intakeEase', 1, 8, 0.05, 'convergence curve'], + ['intakeOpacity', 0, 1, 0.01, 'opacity'], + ['intakeGlow', 0, 5, 0.01, 'glow'], + ['intakeRim', 0, 4, 0.01, 'rim'], + ['intakeRimPower', 0.2, 8, 0.01, 'rim tightness'], + ['intakeShade', 0, 2, 0.01, 'shading'], + ['intakeAmbient', 0, 1, 0.01, 'ambient'], + ['colorIntakeA', 'shard on the shell'], + ['colorIntakeB', 'shard arriving'], + ['colorIntakeEdge', 'shard rim'] + ], + 'The break': [ + ['fragmentCount', 0, 256, 1, 'fragments'], + ['fragSpawnRadius', 0, 3, 0.01, 'scatter (m)'], + ['fragSpawnHeight', 0, 5, 0.01, 'break height (m)'], + ['fragInherit', 0, 1.5, 0.01, 'inherited velocity'], + ['fragSize', 0.02, 1.5, 0.01, 'size (m)'], + ['fragSizeJitter', 0, 1, 0.01, 'size jitter'], + ['fragSpeed', 0, 30, 0.1, 'throw speed'], + ['fragSpeedJitter', 0, 1, 0.01, 'speed jitter'], + ['fragSpread', 0, 1, 0.01, 'spread'], + ['fragUpBias', 0, 1, 0.01, 'up bias'], + ['fragGravity', -60, 5, 0.1, 'gravity'], + ['fragDrag', 0, 6, 0.01, 'drag'], + ['fragSpin', 0, 40, 0.1, 'tumble'], + ['fragSpinJitter', 0, 1, 0.01, 'tumble jitter'], + ['fragLifetime', 0.1, 6, 0.05, 'lifetime'], + ['fragShrink', 0, 1, 0.01, 'shrink'], + ['fragShrinkPower', 0.2, 6, 0.01, 'shrink curve'], + ['fragFloor', 0, 1, 0.005, 'floor'], + ['fragFloorSpin', 0, 1, 0.01, 'floor tumble'], + ['fragOpacity', 0, 1, 0.01, 'opacity'], + ['fragGlow', 0, 4, 0.01, 'glow'], + ['fragRim', 0, 4, 0.01, 'rim'], + ['fragRimPower', 0.2, 8, 0.01, 'rim tightness'], + ['fragShade', 0, 2, 0.01, 'shading'], + ['fragAmbient', 0, 1, 0.01, 'ambient'], + ['fragFadeStart', 0, 1, 0.01, 'fade start'], + ['fragSoft', 0, 2, 0.01, 'soft intersection'], + ['colorFragA', 'fragment lit'], + ['colorFragB', 'fragment shaded'], + ['colorFragEdge', 'fragment rim'] + ], + 'The frost sheet': [ + ['sheetRadius', 0.2, 20, 0.05, 'radius'], + ['sheetGrow', 0.02, 3, 0.01, 'spread time'], + ['sheetHold', 0, 1, 0.01, 'survives this much of the fade'], + ['sheetHeight', 0, 0.4, 0.005, 'height above floor'], + ['sheetCell', 0.05, 2, 0.01, 'plate pitch'], + ['sheetCellJitter', 0, 1, 0.01, 'packing irregularity'], + ['sheetSeam', 0.002, 0.4, 0.002, 'seam width'], + ['sheetThickness', 0.002, 0.3, 0.002, 'plate body'], + ['sheetLift', 0, 0.5, 0.002, 'downwind lift'], + ['sheetEdge', 0.02, 3, 0.01, 'front feather'], + ['sheetRagged', 0, 1, 0.01, 'front wander'], + ['sheetRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['sheetWarp', 0, 3, 0.01, 'domain warp'], + ['sheetRelief', 0, 3, 0.01, 'relief'], + ['sheetNormalStep', 0.005, 0.4, 0.005, 'normal step'], + ['sheetAmbient', 0, 1, 0.01, 'ambient'], + ['sheetWrap', 0, 1, 0.01, 'terminator wrap'], + ['sheetSpecular', 0, 3, 0.01, 'specular'], + ['sheetGloss', 1, 120, 1, 'gloss'], + ['sheetParallax', 0, 2, 0.01, 'parallax'], + ['sheetDetail', 0, 1, 0.01, 'frozen spray'], + ['sheetEmissive', 0, 4, 0.01, 'emissive'], + ['sheetOpacity', 0, 1, 0.01, 'opacity'], + ['sheetDepthFade', 0.02, 2, 0.01, 'soft intersection'], + ['colorSheetBase', 'frost'], + ['colorSheetEdge', 'frost lips'], + ['colorSheetGlow', 'frost emissive'], + ['colorSheetDeep', 'plate interior'] + ], + 'Motes, chips & mist': [ + ['moteRate', 0, 600, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteLifetime', 0.1, 5, 0.05, 'mote lifetime'], + ['moteShell', 0.2, 14, 0.05, 'mote shell (m)'], + ['moteSwirl', -12, 12, 0.05, 'mote orbit'], + ['moteConverge', 0, 1, 0.01, 'mote convergence'], + ['moteRise', -2, 4, 0.01, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['chipCount', 0, 300, 1, 'chips at the break'], + ['chipSize', 0.005, 0.4, 0.005, 'chip size'], + ['chipSpeed', 0, 30, 0.1, 'chip speed'], + ['chipLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['chipGravity', -60, 0, 0.1, 'chip gravity'], + ['mistRate', 0, 400, 1, 'mist rate'], + ['mistSize', 0.05, 4, 0.01, 'mist size'], + ['mistSpeed', 0, 8, 0.05, 'mist speed'], + ['mistLifetime', 0.2, 8, 0.05, 'mist lifetime'], + ['mistOpacity', 0, 1, 0.005, 'mist opacity'], + ['mistRise', -2, 4, 0.01, 'mist rise'], + ['colorMote*', 'Mote colour'], + ['colorChip*', 'Chip colour'], + ['colorMist*', 'Mist colour'] + ], + 'The shock': [ + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['shockRadius', 0.5, 30, 0.1, 'shockwave radius'], + ['breakFlash', 0, 2, 0.01, 'screen flash'], + ['breakShake', 0, 4, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst core'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'light breath'], + ['lightPulseSpeed', 0, 10, 0.05, 'breath rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/sheetlightning.js b/src/config/abilities/sheetlightning.js new file mode 100644 index 0000000..22ccf0e --- /dev/null +++ b/src/config/abilities/sheetlightning.js @@ -0,0 +1,315 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* SHEET LIGHTNING — storm, far cast */ +/* ================================================================== */ +/** + * Intracloud lightning, seen from underneath. + * + * Almost nothing in this block describes something you look *at*. The + * discharge itself is a fracture buried in a slab of mist fifteen metres up and + * you never see it cleanly — what you see is the **rest of the stage** strobing, + * because the pulse train is written to the scene's key light and every real + * shadow in the world snaps with it. + * + * That is why the folder order below is what it is. `The strobe` and + * `The world's key light` come first and everything else is scenery. If you are + * tuning this slot, tune those two folders with the camera pointed at the + * *character*, not at the cloud. + * + * ## The two numbers that carry it + * + * `flashWidth` and `strobeRate`. Sheet lightning is short pulses with real gaps + * between them; widen the pulse past about 0.12 s and the whole thing turns into + * a lamp being switched on, which is what a naive version of this looks like and + * why the first draft was thrown away. The gap is doing as much work as the + * flash — the same lesson Thunderclap's `gapTime` teaches, applied to light + * rather than to sound. + * + * ## The exception to I5's "nothing is derived" + * + * `zoneRadius` drives the sheet's span, the cloud's footprint and where the + * motes are seeded. That sharing *is* the design: the circle the aim indicator + * drew before the click is the circle the storm happens over, and three numbers + * that could disagree about it would be three ways to break the promise. + */ +export const sheetlightning = { + /* --- the cast --- */ + range: 30.0, // maximum cast distance, metres + minRange: 5.0, // closer than this and the cast is refused + zoneRadius: 9.0, // the footprint the indicator draws, metres + speed: 95.0, // how fast the charge front runs out to the zone, metres/second + cooldown: 1.2, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + fadeTime: 0.75, // seconds the cloud and the hook blend back out over + + /* --- the strobe: THE TRICK --- */ + /** + * A pulse train evaluated from the ability's own clock every frame. Nothing + * here is latched: `strobeRate` moves the pulse boundaries under a standing + * cast, so pausing mid-train and dragging it walks the world's shadows + * forward and backward through the strobe. + */ + flashes: 5, // pulses in one strike + strobeRate: 6.5, // pulses per second + flashWidth: 0.06, // seconds one pulse stays lit — see the header + flashDecay: 2.4, // exponent on a pulse's fall; >1 snaps off, <1 lingers + flashVary: 0.5, // 0..1 how unequal the pulses in a train are + doubleChance: 0.5, // 0..1 chance a pulse fires twice, as real strokes do + doubleGap: 1.8, // where the second stroke lands, in flash widths + doubleLevel: 0.62, // how bright that second stroke is, 0..1 of the first + afterglow: 0.6, // seconds of dying glow after the last pulse + afterglowLevel: 0.2, // how bright that tail starts, 0..1 + + /* --- the world's key light --- */ + /** + * Absolute intensities in the same units as `environment.sunIntensity`, and + * deliberately absolute rather than multipliers on it: an ability that scaled + * the environment's own number would read completely differently on a stage + * that had been lit down, and the whole point of this slot is that the strobe + * level is *authored*. + * + * `keyWeight` is the blend against `settings.environment`. At 0 the hook is + * transparent and the editor's own sun sliders read straight through, which is + * also exactly what the ability restores to when it ends. + */ + keyFlash: 7.5, // sun intensity at the top of a pulse + keyDark: 0.42, // ... in the gaps between pulses + keyCharge: 0.6, // ... while the charge is still running out to the zone + keyWeight: 1.0, // 0..1 blend against settings.environment + armTime: 0.4, // seconds the charge dim takes to reach full weight + releaseTime: 0.5, // seconds the hook blends back to the environment over + colorKeyFlash: '#dfe6ff', // the sun's colour at the top of a pulse + colorKeyDark: '#3a4a7a', // ... and in the gaps + + /* --- the discharge inside the cloud --- */ + sheetHeight: 15.0, // metres above the floor the main fracture runs at + sheetSpan: 1.35, // its reach across the zone, × zoneRadius + sheetSag: -1.4, // metres the trunk bows; negative droops out of the cloud + sheetDrop: 2.6, // metres the cross-fracture sits below the main one + strands: 8, // filaments in the main fracture + crossStrands: 6, // ... in the cross-fracture + feelerCount: 4, // short cracks hanging out of the cloud's belly + feelerDrop: 7.0, // metres they reach down from the cross-fracture + feelerFloor: 5.5, // metres below which a feeler is clamped — it never lands + feelerSag: 0.5, // metres of bow on a feeler + sheetIdle: 0.07, // 0..1 residual glow between pulses (ionised air) + + /* --- the shape of one fracture --- */ + crackAngle: 0.62, // radians a fork turns off its parent + crackLength: 0.58, // fork length, fraction of its parent + crackFalloff: 0.6, // how much shorter each generation is again + crackSpread: 0.8, // 0..1+ variation in the fork angle + crackStart: 0.16, // earliest point along a parent a fork may leave + crackForkBias: 0.5, // 0..1 slides the branch/twig split + + /* --- the ribbon --- */ + sheetWidth: 0.05, // half-width of a filament, metres + sheetGlowWidth: 7.5, // the halo, × the core width + sheetGlowOpacity: 0.5, + sheetJitter: 0.9, // metres of kink at the coarsest octave + sheetJitterScale: 0.5, // kinks per metre + sheetOctaves: 4, // 1–5 + sheetJitterFalloff: 0.55, // amplitude kept per octave + sheetCrawl: 1.4, // how fast the kinks slide along + sheetPinch: 0.18, // fraction of the span the ends are pulled straight over + sheetRestrike: 30, // times/second the filaments re-roll their shape + sheetFlicker: 0.35, // depth of the whole-sheet brightness stutter + sheetFlickerSpeed: 40, // stutters/second + sheetStrandFlash: 0.5, // how much individual filaments blink out + sheetCoreSharp: 4.0, // how hard the hot core falls off across the ribbon + sheetGlowFalloff: 2.2, // the same for the halo + sheetSoftFade: 0.9, // metres of soft fade where a filament meets geometry + sheetOpacity: 1.0, + sheetGlow: 2.6, // emissive gain at the top of a pulse + colorSheetCore: '#ffffff', // the centre of a filament + colorSheetInner: '#dfe6ff', + colorSheetOuter: '#9db4ff', + colorSheetHalo: '#2a3a9c', // the wide glow the mist scatters + + /* --- the slab of cloud the fracture is buried in --- */ + cloudLift: 15.5, // metres to the centre of the slab + cloudThickness: 5.2, // metres, top to bottom + cloudSpan: 1.5, // its footprint, × zoneRadius + cloudIdle: 0.16, // 0..1 emission floor between pulses + ...volumeHullDefaults('cloud', Medium.MIST, { + // MIST ships with no emission at all, because a mist is normally lit from + // outside. This one is not: the emission *is* the sheet, so it is the key + // the strobe drives and it needs a real value to be driven from. + cloudEmission: 2.6, + cloudEmissionCurve: 1.3, + cloudDensity: 0.7, + cloudNoiseFrequency: 0.34, + cloudNoiseStrength: 0.7, + cloudFlatten: 0.72, // a cloud base is pancaked; a spherical one reads as steam + cloudRise: 0.1, + cloudSwirl: 0.08, + cloudSteps: 22, + cloudFeather: 0.35, + cloudColorCore: '#cfe0ff', + cloudColorMid: '#7e93c4', + cloudColorEdge: '#3d4a72', + cloudColorDeep: '#151b30' + }), + + /* --- what the light finds in the air --- */ + /** + * Two systems, and both of them exist to be *revealed* rather than to be + * looked at. The motes are the aerosol the flash catches and their rate is + * multiplied by the pulse, so between pulses the air is empty; the haze is the + * cloud's own ragged underside and it drifts continuously. + */ + moteRate: 240, // motes/second at the top of a pulse + moteSize: 0.05, + moteSpeed: 0.8, + moteLifetime: 1.3, + moteRise: 0.3, // upward drift, metres/second + moteTurbulence: 0.8, + colorMoteA: '#ffffff', + colorMoteB: '#dfe6ff', + colorMoteC: '#7d9bff', + colorMoteD: '#101a44', + hazeRate: 26, // wisps/second under the cloud + hazeSize: 2.2, + hazeSpeed: 0.5, + hazeLifetime: 3.4, + hazeRise: 0.25, + hazeOpacity: 0.13, + colorHazeA: '#5a6a8c', + colorHazeB: '#48566f', + colorHazeC: '#333e52', + colorHazeD: '#1b2230', + + /* --- the sound of it, such as we have --- */ + openShake: 0.22, // one small jolt when the sheet first fires + openShakeTime: 0.7, // seconds it decays over + rollRumble: 0.05, // continuous rumble weighted by the pulse + + /* --- dynamic light --- */ + // The pooled point light rides the same envelope as the sun, seated at the + // sheet's own height so it rakes downward rather than lighting the floor flat. + lightIntensity: 30, + lightRadius: 28, + lightColor: '#cdd8ff' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Sheet Lightning. + * + * Point the camera at the character, not at the cloud, and start with + * `flashWidth`, `strobeRate` and `keyFlash`. Those three are the ability. The + * cloud folders below them are forty sliders of scenery and none of them will + * make the effect land if the strobe is wrong. + */ +export const sheetlightningSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 15, 0.1, 'min range'], + ['zoneRadius', 2, 24, 0.1, 'zone radius'], + ['speed', 5, 400, 1, 'charge speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['fadeTime', 0.1, 4, 0.01, 'blow-out time'], + ['castAnim', 'cast animation'] + ], + 'The strobe': [ + ['flashes', 1, 16, 1, 'pulses'], + ['strobeRate', 0.5, 24, 0.1, 'pulses / sec'], + ['flashWidth', 0.005, 0.4, 0.005, 'pulse width (s)'], + ['flashDecay', 0.2, 8, 0.05, 'pulse decay'], + ['flashVary', 0, 1, 0.01, 'pulse variation'], + ['doubleChance', 0, 1, 0.01, 'double-stroke chance'], + ['doubleGap', 0.2, 5, 0.05, 'double-stroke gap'], + ['doubleLevel', 0, 1.5, 0.01, 'double-stroke level'], + ['afterglow', 0.05, 3, 0.01, 'afterglow (s)'], + ['afterglowLevel', 0, 1, 0.01, 'afterglow level'] + ], + "The world's key light": [ + ['keyFlash', 0, 40, 0.1, 'sun at the flash'], + ['keyDark', 0, 8, 0.01, 'sun between pulses'], + ['keyCharge', 0, 8, 0.01, 'sun while charging'], + ['keyWeight', 0, 1, 0.01, 'blend vs environment'], + ['armTime', 0.02, 3, 0.01, 'arm time (s)'], + ['releaseTime', 0.05, 3, 0.01, 'release time (s)'], + ['colorKeyFlash', 'sun at the flash'], + ['colorKeyDark', 'sun between pulses'] + ], + 'The discharge': [ + ['sheetHeight', 3, 40, 0.1, 'fracture height'], + ['sheetSpan', 0.2, 3, 0.01, 'span × radius'], + ['sheetSag', -8, 8, 0.05, 'trunk bow'], + ['sheetDrop', 0, 10, 0.05, 'cross-fracture drop'], + ['strands', 1, 20, 1, 'main filaments'], + ['crossStrands', 0, 20, 1, 'cross filaments'], + ['feelerCount', 0, 12, 1, 'feelers'], + ['feelerDrop', 0, 20, 0.1, 'feeler reach'], + ['feelerFloor', 0, 20, 0.1, 'feeler floor'], + ['feelerSag', -4, 4, 0.05, 'feeler bow'], + ['sheetIdle', 0, 1, 0.005, 'glow between pulses'] + ], + 'The fracture shape': [ + ['crackAngle', 0, 1.6, 0.01, 'fork angle'], + ['crackLength', 0.05, 1.5, 0.01, 'fork length'], + ['crackFalloff', 0.05, 1.5, 0.01, 'generation falloff'], + ['crackSpread', 0, 2, 0.01, 'angle variation'], + ['crackStart', 0, 1, 0.01, 'earliest fork'], + ['crackForkBias', 0, 1, 0.01, 'branch / twig split'] + ], + 'The ribbon': [ + ['sheetWidth', 0.005, 0.6, 0.005, 'filament width'], + ['sheetGlowWidth', 1, 30, 0.1, 'halo width'], + ['sheetGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['sheetJitter', 0, 4, 0.01, 'kink amplitude'], + ['sheetJitterScale', 0.05, 6, 0.01, 'kinks / metre'], + ['sheetOctaves', 1, 5, 1, 'octaves'], + ['sheetJitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['sheetCrawl', -20, 20, 0.1, 'kink crawl'], + ['sheetPinch', 0.01, 0.5, 0.005, 'end pinch'], + ['sheetRestrike', 0.5, 90, 0.5, 'restrikes / sec'], + ['sheetFlicker', 0, 1, 0.01, 'brightness stutter'], + ['sheetFlickerSpeed', 1, 120, 1, 'stutter rate'], + ['sheetStrandFlash', 0, 1, 0.01, 'filament blink'], + ['sheetCoreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['sheetGlowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['sheetSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['sheetOpacity', 0, 2, 0.01, 'opacity'], + ['sheetGlow', 0, 10, 0.01, 'glow at the flash'], + ['colorSheetCore', 'core'], + ['colorSheetInner', 'inner'], + ['colorSheetOuter', 'outer'], + ['colorSheetHalo', 'halo'] + ], + 'The cloud': [ + ['cloudLift', 2, 40, 0.1, 'slab centre height'], + ['cloudThickness', 0.5, 20, 0.1, 'slab thickness'], + ['cloudSpan', 0.2, 4, 0.01, 'span × radius'], + ['cloudIdle', 0, 1, 0.005, 'emission between pulses'] + ], + ...volumeHullSchema('cloud', { label: 'The cloud' }), + 'Air & haze': [ + ['moteRate', 0, 1200, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 8, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 6, 0.05, 'mote lifetime'], + ['moteRise', -3, 6, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['hazeRate', 0, 200, 1, 'haze rate'], + ['hazeSize', 0.1, 8, 0.05, 'haze size'], + ['hazeSpeed', 0, 6, 0.05, 'haze speed'], + ['hazeLifetime', 0.2, 10, 0.05, 'haze lifetime'], + ['hazeRise', -2, 4, 0.01, 'haze rise'], + ['hazeOpacity', 0, 1, 0.005, 'haze opacity'], + ['colorMote*', 'Mote colour'], + ['colorHaze*', 'Haze colour'] + ], + 'Shake & dynamic light': [ + ['openShake', 0, 2, 0.01, 'opening jolt'], + ['openShakeTime', 0.05, 3, 0.01, 'jolt decay (s)'], + ['rollRumble', 0, 0.5, 0.005, 'rumble'], + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 60, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/shrapnel.js b/src/config/abilities/shrapnel.js new file mode 100644 index 0000000..3326eb5 --- /dev/null +++ b/src/config/abilities/shrapnel.js @@ -0,0 +1,413 @@ +/* ================================================================== */ +/* SHRAPNEL BLOOM — forge, zone cast */ +/* ================================================================== */ +/** + * A machined canister is thrown at the circle and comes apart into plates and + * bolts, and the plates and bolts **bounce**. + * + * **The tell this block exists to defeat.** Every shatter in the sandbox up to + * now has ended the same way: a fragment reaches the floor and stops dead, + * because `vfx/ShatterField.js` clamps `y` and keeps a fraction of the tumble. + * That is right for ice and for stone, which shatter and stay shattered. It is + * wrong for steel, and it is the single thing that separates a burst from a + * shatter — real fragments hit hard ground and *skitter*. So this slot solves + * the flight as a piecewise ballistic with a real restitution at every + * contact, keeps the tumble running through the bounce, and only lets the + * piece slide to a stop when its rebound falls below `stopSpeed`. + * + * `restitution` is therefore the headline slider, and it is worth dragging it + * from 0 to 0.7 with the clock stopped: at 0 you get every other shatter in + * the project, and every step above it moves the whole scatter outward, + * because a fragment that bounces travels further. + * + * **The first bounce lands on the circle.** The throw speed is not typed in — + * it is fitted to the aim indicator. For a ballistic launch at `elevation`, + * `R = v²·sin(2θ)/g`, so `v = √(R·g / sin 2θ)` with `R = zoneRadius`, and the + * fragments touch down on the ring the player was shown. The craters they + * leave (`vfx/GroundField.js` in `POCK`) are posted at those touchdowns and + * their positions are re-derived every frame, so the pockmarks re-place + * themselves when `zoneRadius`, `elevation`, `gravity` or `speedScale` move. + * A ring of dents at the edge of the circle and a scatter of steel *past* it + * is the whole picture, and both halves come from the same three numbers. + * + * The steel is `vfx/HardSurface.js` — real plate geometry with counterbored + * bolt holes, real hex bolts with a helical thread, brushed anisotropic + * specular, and a blackbody ramp on `heat` that runs a genuine cooling curve + * (`heat = heatStart · e^(−coolRate·t)`) from white-hot at the burst to cold + * grey by the time the pieces have stopped moving. Nothing fades: they are + * lumps of metal, and at the very end of the cast the floor they are lying on + * simply drops out from under them (`exitSink`). + */ +export const shrapnel = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.5, // closer than this and the cast is refused + speed: 34.0, // how fast the canister flies to the circle, metres/second + zoneRadius: 5.0, // the ring the first bounces land on, metres. Drives the throw speed + cooldown: 2.0, // seconds before the slot re-arms + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + holdTime: 2.4, // seconds the field is left alone after everything has stopped + fadeTime: 2.4, // seconds the dust and the light take to go + + /* --- the canister on its way there --- */ + canisterSize: 0.55, // metres — its longest dimension + canisterApex: 2.4, // metres the throw arcs above the straight line + canisterHeight: 1.15, // metres above the floor it leaves the caster at + canisterSpin: 5.5, // radians/second it tumbles end over end + canisterLength: 2.0, // × head width — the bolt proportions, live + canisterHead: 0.68, // head height, fraction of the head width + canisterShank: 0.34, // shank radius, fraction of the head width + canisterThread: 8.0, // full turns of thread over the threaded length + canisterWasher: 0.09, // flange under the head; 0 for none + + /* --- the throw --- */ + fragCount: 56, // fragments thrown (capped at 96) + elevation: 0.62, // radians above the horizontal the sheaf leaves at + speedScale: 1.0, // multiplier on the range-fitted speed. 1 lands them on the ring + speedJitter: 0.32, // ± fraction + spread: 0.26, // 0 every fragment on its ideal arc, 1 fully random + gravity: -20.0, // metres/second² + chargeRadius: 0.3, // metres of scatter on where a fragment starts + chargeHeight: 0.55, // metres above the floor the canister opens at + fragLifetime: 6.5, // seconds a fragment lives. Longer than the cast, on purpose + + /* --- the ricochet: the whole point --- */ + restitution: 0.44, // 0..1 of the into-floor speed that comes back out + friction: 0.28, // 0..1 of the along-floor speed lost at each contact + tumbleKeep: 0.82, // 0..1 of the tumble rate that survives a contact + bounces: 4, // contacts before the next one is treated as dead + stopSpeed: 0.65, // metres/second — a rebound slower than this settles instead + slide: 3.2, // 1/second the along-floor speed decays at once settled + groundSpin: 0.24, // 0..1 of the tumble rate kept while sliding + seat: 0.34, // × the fragment's size — how far its centre rests above the floor + exitSink: 0.7, // metres the floor drops by at the end of the cast + + /* --- the fragments --- */ + fragSize: 0.19, // metres, the unit geometry's scale + fragSizeJitter: 0.5, // ± fraction + fragShrink: 0.0, // 0..1 of its size lost by the end of life. Steel does not shrink + fragShrinkPower: 1.6, // how late that shrink bites + fragSpin: 13.0, // radians/second of tumble at birth + fragSpinJitter: 0.75, // ± fraction + plateWidth: 1.0, // the torn plate: in-plane extents, relative to each other + plateDepth: 0.62, + plateThickness: 0.15, // unit lengths + plateBevel: 0.04, // unit lengths, 45° break round the whole outline + plateCorner: 0.13, // fraction of the short side, corner radius + plateBolts: 2, // 0, 2, 4 or 6 — holes left in the fragment + plateBoltRadius: 0.08, // fraction of the short side + boltLength: 1.7, // the bolt fragment: × head width across flats + boltHead: 0.6, // head height, fraction of the head width + boltShank: 0.3, // shank radius, fraction of the head width + boltThread: 6.0, // full turns over the threaded length + boltThreadDepth: 0.04, // radial, fraction of the head width + + /* --- the steel (vfx/HardSurface.js, canonical names) --- */ + brushMode: 1, // 0 LINEAR, 1 CIRCUMFERENTIAL, 2 RADIAL — turned parts, so round the axis + brushAxisX: 0.0, // the grain direction in the part's own space + brushAxisY: 1.0, + brushAxisZ: 0.0, + anisotropy: 0.74, // 0 round highlight, 1 fully smeared along the grain + specular: 1.6, // gain on the anisotropic lobe + grain: 0.6, // how hard the brushing cuts into roughness + grainScale: 110.0, // grain cycles per unit of local space + grainStretch: 22.0, // how far a streak runs along the brush direction + roughness: 0.34, // base, before grain / pitting / wear + metalness: 0.95, + envIntensity: 1.0, // HDR probe gain + millScale: 0.24, // the blue-black oxide, 0..1 coverage — fresh steel, so not much + millScaleSize: 8.0, // its patch size + millScaleSharp: 0.62, // 0 a smear, 1 a hard flake edge + pit: 0.28, // casting pits and corrosion + pitScale: 90.0, + wear: 0.72, // how bright the machined edges come up + wearGrain: 0.36, // how much the grain breaks that wear up + heatStart: 0.72, // 0..1 heat the fragments leave the burst at + coolRate: 0.55, // 1/second — Newton's law, and the whole cooling curve + heatCold: 300.0, // kelvin at heat = 0 + heatHot: 2100.0, // kelvin at heat = 1 + heatRef: 1250.0, // kelvin at which the emission term reaches 1 + heatExponent: 4.0, // Stefan-Boltzmann; 4 is the physical value + heatGlow: 2.6, // gain on the emission + heatTint: 0.8, // how far the albedo washes toward the hot colour + heatEdge: 0.26, // how much cooler an edge reads — thin sections radiate faster + colorMetal: '#949aa1', // clean steel + colorDeep: '#383d43', // the bottom of a pit + colorScale: '#2c2724', // mill scale + colorPolish: '#e8eef6', // a torn edge, bright where the metal let go + colorSpec: '#fff2e0', // the anisotropic highlight's own colour + + /* --- the craters the first bounces leave (GroundField, POCK) --- */ + pockRadius: 8.0, // metres — the quad, and the frame the marks are fractions of + pockDepth: 0.11, // metres a crater goes down by + pockLift: 0.03, // metres of rim heaved up around it + pockRimWidth: 0.09, // metres — how wide that rim band is + pockMarkRadius: 0.34, // metres — radius of one full-strength crater + pockDig: 22.0, // craters/second — how fast one digs itself in. Fast: this is a strike + pockLife: 24.0, // seconds a crater weathers away over + pockLoad: 0.8, // 0..1 strength of one crater + pockGrain: 0.42, // 0..1 grit across the field + pockHeight: 0.014, // metres the quad floats above the floor + pockEdge: 0.6, // metres of feather on the growth front + pockRagged: 0.18, // how far that front wanders, as a fraction of the radius + pockRaggedScale: 0.5, // lobes per metre + pockWarp: 0.6, // metres of domain warp on those lobes + pockRelief: 1.1, // how hard the height field tilts the fake normal + pockNormalStep: 0.035, // metres between the height taps + pockAmbient: 0.3, // floor on the diffuse term + pockWrap: 0.4, // 0..1 wraps the terminator round the back + pockSpecular: 0.3, // struck stone has a little sheen where it is fresh + pockGloss: 22.0, // Blinn exponent + pockParallax: 0.18, // metres of view-driven offset on interior detail + pockEmissive: 0.9, // multiplier on the glowing term — the scorch a hot fragment leaves + pockOpacity: 0.92, + pockDepthFade: 0.4, // metres of soft fade against standing geometry + colorPockBase: '#6e655a', // the struck floor + colorPockEdge: '#9d9184', // the chipped rim + colorPockGlow: '#d9702c', // where a hot fragment touched + colorPockDeep: '#231f1b', // the bottom of the nick + + /* --- sparks and smoke --- */ + /** + * Two systems, each with its own four-stop lifetime gradient (I5). The + * sparks are additive and are tinted by the *blackbody colour of the steel* + * rather than by a hard-coded orange — see `_sparkTint` in the ability, and + * `HardSurface`'s own note on why a cherry-red fragment must not throw + * lemon-yellow sparks. + */ + burstSparks: 260, // sparks thrown by the detonation itself + bounceSparks: 9, // ... and by one fragment striking the floor + sparkSize: 0.12, + sparkSpeed: 8.0, + sparkLifetime: 0.5, + sparkGravity: -19.0, + sparkStretch: 0.22, // how far a spark smears along its velocity + sparkHeatTint: 0.9, // 0..1 how far the steel's blackbody colour tints them + colorSparkA: '#fff0d2', + colorSparkB: '#ffb257', + colorSparkC: '#d05a1c', + colorSparkD: '#41190a', + smokeCount: 70, // puffed out by the detonation + smokeSize: 0.9, + smokeSpeed: 4.0, + smokeLifetime: 2.4, + smokeRise: 0.7, // upward drift, metres/second + smokeOpacity: 0.16, + smokeTurbulence: 0.7, + colorSmokeA: '#8a8176', + colorSmokeB: '#645c53', + colorSmokeC: '#433e39', + colorSmokeD: '#26221f', + + /* --- feedback --- */ + burstSize: 1.9, // the detonation shell, metres + burstIntensity: 1.5, + colorBurstA: '#c9531d', // shell + colorBurstB: '#ffb257', // body + colorBurstC: '#fff0d2', // arcs + impactShake: 0.85, + shakeDuration: 0.5, + shakeFrequency: 22.0, // Hz. Sharp and bright — the opposite of the anvil + impactFlash: 0.16, + colorFlash: '#ffd9a8', + rumble: 0.015, // continuous shake while the canister is in the air + + /* --- the dynamic light --- */ + lightIntensity: 20.0, + lightRadius: 14.0, + lightHeight: 0.7, // metres above the floor the light sits after the burst + lightColor: '#ff9440' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Shrapnel Bloom. + * + * **The ricochet** is the folder to open first, and `restitution` is the + * slider — at 0 this ability is every other shatter in the project, and it is + * worth seeing that once. `bounces`, `friction` and `stopSpeed` between them + * decide whether the pieces skitter to the far wall or rattle once and lie + * down. After that, **The throw**: `zoneRadius` and `elevation` are what the + * speed is fitted to, so they move the whole bloom and the ring of craters + * with it, and `spread` is how tidy that ring is allowed to be. + */ +export const shrapnelSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 4, 140, 0.5, 'throw speed'], + ['zoneRadius', 1, 16, 0.05, 'zone radius'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['holdTime', 0.2, 10, 0.05, 'hold after burst'], + ['fadeTime', 0.2, 8, 0.05, 'fade time'], + ['castAnim', 'cast animation'] + ], + 'The ricochet': [ + ['restitution', 0, 0.95, 0.005, 'restitution'], + ['friction', 0, 1, 0.005, 'tangential loss'], + ['tumbleKeep', 0, 1, 0.005, 'tumble kept / bounce'], + ['bounces', 0, 8, 1, 'bounces'], + ['stopSpeed', 0.05, 4, 0.01, 'settle speed (m/s)'], + ['slide', 0, 20, 0.05, 'slide decay (1/s)'], + ['groundSpin', 0, 1, 0.01, 'tumble while sliding'], + ['seat', 0, 1, 0.01, 'rest height × size'], + ['exitSink', 0, 4, 0.01, 'floor drop at the end (m)'] + ], + 'The throw': [ + ['fragCount', 1, 96, 1, 'fragments'], + ['elevation', 0.08, 1.45, 0.005, 'launch angle (rad)'], + ['speedScale', 0.2, 2.5, 0.01, 'speed × fitted'], + ['speedJitter', 0, 1.5, 0.01, 'speed jitter'], + ['spread', 0, 1, 0.01, 'direction scatter'], + ['gravity', -60, -1, 0.5, 'gravity'], + ['chargeRadius', 0, 2, 0.01, 'origin scatter (m)'], + ['chargeHeight', 0, 3, 0.01, 'burst height (m)'], + ['fragLifetime', 0.5, 14, 0.1, 'fragment lifetime'] + ], + 'The canister': [ + ['canisterSize', 0.05, 2, 0.01, 'size (m)'], + ['canisterApex', 0, 10, 0.05, 'throw arc (m)'], + ['canisterHeight', 0, 3, 0.01, 'release height (m)'], + ['canisterSpin', -20, 20, 0.1, 'tumble (rad/s)'], + ['canisterLength', 0.6, 5, 0.01, 'length'], + ['canisterHead', 0.2, 1.4, 0.01, 'head height'], + ['canisterShank', 0.1, 0.5, 0.005, 'shank radius'], + ['canisterThread', 0, 24, 0.5, 'thread turns'], + ['canisterWasher', 0, 0.3, 0.005, 'flange'] + ], + 'The fragments': [ + ['fragSize', 0.02, 1, 0.005, 'size (m)'], + ['fragSizeJitter', 0, 1.5, 0.01, 'size jitter'], + ['fragShrink', 0, 1, 0.01, 'shrink'], + ['fragShrinkPower', 0.2, 5, 0.01, 'shrink curve'], + ['fragSpin', 0, 40, 0.1, 'tumble (rad/s)'], + ['fragSpinJitter', 0, 1.5, 0.01, 'tumble jitter'] + ], + 'The fragments/Plate': [ + ['plateWidth', 0.2, 2, 0.01, 'width'], + ['plateDepth', 0.2, 2, 0.01, 'depth'], + ['plateThickness', 0.02, 0.6, 0.005, 'thickness'], + ['plateBevel', 0, 0.2, 0.002, 'bevel'], + ['plateCorner', 0, 0.5, 0.005, 'corner radius'], + ['plateBolts', 0, 6, 2, 'holes'], + ['plateBoltRadius', 0.01, 0.2, 0.002, 'hole radius'] + ], + 'The fragments/Bolt': [ + ['boltLength', 0.6, 5, 0.01, 'length'], + ['boltHead', 0.2, 1.4, 0.01, 'head height'], + ['boltShank', 0.1, 0.5, 0.005, 'shank radius'], + ['boltThread', 0, 24, 0.5, 'thread turns'], + ['boltThreadDepth', 0, 0.12, 0.002, 'thread depth'] + ], + 'The steel/Brushing': [ + ['brushMode', 0, 2, 1, 'grain mode'], + ['brushAxisX', -1, 1, 0.01, 'grain axis X'], + ['brushAxisY', -1, 1, 0.01, 'grain axis Y'], + ['brushAxisZ', -1, 1, 0.01, 'grain axis Z'], + ['anisotropy', 0, 1, 0.01, 'anisotropy'], + ['specular', 0, 5, 0.01, 'specular gain'], + ['grain', 0, 2, 0.01, 'grain depth'], + ['grainScale', 4, 300, 1, 'grain scale'], + ['grainStretch', 1, 120, 0.5, 'grain stretch'] + ], + 'The steel/Surface': [ + ['roughness', 0.02, 1, 0.01, 'roughness'], + ['metalness', 0, 1, 0.01, 'metalness'], + ['envIntensity', 0, 3, 0.01, 'probe gain'], + ['millScale', 0, 1, 0.01, 'mill scale'], + ['millScaleSize', 0.5, 30, 0.1, 'scale patch size'], + ['millScaleSharp', 0, 1, 0.01, 'scale flake edge'], + ['pit', 0, 1, 0.01, 'pitting'], + ['pitScale', 4, 240, 1, 'pit scale'], + ['wear', 0, 1, 0.01, 'edge wear'], + ['wearGrain', 0, 1, 0.01, 'wear break-up'], + ['colorMetal', 'steel'], + ['colorDeep', 'pit floor'], + ['colorScale', 'mill scale'], + ['colorPolish', 'torn edge'], + ['colorSpec', 'highlight'] + ], + 'The steel/Heat': [ + ['heatStart', 0, 1, 0.005, 'heat at the burst'], + ['coolRate', 0, 6, 0.01, 'cooling (1/s)'], + ['heatCold', 100, 900, 5, 'cold (K)'], + ['heatHot', 900, 2600, 5, 'hot (K)'], + ['heatRef', 400, 2400, 5, 'emission ref (K)'], + ['heatExponent', 0.5, 8, 0.05, 'emission exponent'], + ['heatGlow', 0, 8, 0.01, 'emission gain'], + ['heatTint', 0, 1, 0.01, 'albedo wash'], + ['heatEdge', 0, 1, 0.01, 'edge cooling'] + ], + 'The craters': [ + ['pockRadius', 1, 24, 0.1, 'field radius (m)'], + ['pockDepth', 0, 1, 0.002, 'depth (m)'], + ['pockLift', 0, 0.3, 0.002, 'rim height (m)'], + ['pockRimWidth', 0.01, 0.6, 0.002, 'rim width (m)'], + ['pockMarkRadius', 0.02, 2, 0.005, 'crater radius (m)'], + ['pockDig', 0.5, 60, 0.5, 'dig rate'], + ['pockLife', 1, 60, 0.5, 'weathering (s)'], + ['pockLoad', 0, 1, 0.01, 'crater strength'], + ['pockGrain', 0, 1, 0.01, 'grit'] + ], + 'The craters/Shading': [ + ['pockHeight', 0, 0.2, 0.001, 'float above floor (m)'], + ['pockEdge', 0.02, 3, 0.01, 'front feather (m)'], + ['pockRagged', 0, 1, 0.01, 'front wander'], + ['pockRaggedScale', 0.05, 4, 0.01, 'lobes / m'], + ['pockWarp', 0, 3, 0.01, 'domain warp (m)'], + ['pockRelief', 0, 3, 0.01, 'relief'], + ['pockNormalStep', 0.005, 0.4, 0.005, 'normal step (m)'], + ['pockAmbient', 0, 1, 0.01, 'ambient'], + ['pockWrap', 0, 1, 0.01, 'terminator wrap'], + ['pockSpecular', 0, 2, 0.01, 'specular'], + ['pockGloss', 1, 96, 1, 'gloss'], + ['pockParallax', 0, 1.5, 0.01, 'parallax (m)'], + ['pockEmissive', 0, 3, 0.01, 'emissive'], + ['pockOpacity', 0, 1, 0.01, 'opacity'], + ['pockDepthFade', 0, 3, 0.01, 'depth fade (m)'], + ['colorPockBase', 'struck floor'], + ['colorPockEdge', 'chipped rim'], + ['colorPockGlow', 'scorch'], + ['colorPockDeep', 'the nick'] + ], + 'Sparks & smoke': [ + ['burstSparks', 0, 900, 1, 'burst sparks'], + ['bounceSparks', 0, 60, 1, 'sparks / bounce'], + ['sparkSize', 0.005, 0.6, 0.005, 'spark size'], + ['sparkSpeed', 0, 30, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 3, 0.01, 'spark lifetime'], + ['sparkGravity', -60, 5, 0.5, 'spark gravity'], + ['sparkStretch', 0, 2, 0.01, 'spark stretch'], + ['sparkHeatTint', 0, 1, 0.01, 'blackbody tint'], + ['smokeCount', 0, 400, 1, 'smoke'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 20, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeRise', -2, 5, 0.01, 'smoke rise'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeTurbulence', 0, 3, 0.01, 'smoke turbulence'], + ['colorSpark*', 'Spark colour'], + ['colorSmoke*', 'Smoke colour'] + ], + 'Feedback': [ + ['burstSize', 0.1, 12, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['impactShake', 0, 4, 0.01, 'shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration'], + ['shakeFrequency', 2, 60, 0.5, 'shake Hz'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.4, 0.002, 'throw rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst arcs'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 100, 0.5, 'light intensity'], + ['lightRadius', 0.5, 45, 0.1, 'light radius'], + ['lightHeight', 0, 5, 0.01, 'light height (m)'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/silence.js b/src/config/abilities/silence.js new file mode 100644 index 0000000..58bdd4d --- /dev/null +++ b/src/config/abilities/silence.js @@ -0,0 +1,177 @@ +/* ================================================================== */ +/* SILENCE — Silence */ +/* ================================================================== */ +/** + * A sphere of the world stops being rendered. + * + * This is the shortest block in the project on purpose, and it is short for the + * same reason the ability is the most aggressive thing in the sandbox: almost + * nothing here describes something being *drawn*. There is no medium, no + * gradient, no shell and no volume. `Hook.HOLE` from `vfx/SceneHooks.js` parks + * an invisible depth-writing sphere at the anchor before any opaque in the + * frame; everything further away than its front surface fails the depth test + * and is never shaded. What survives in those pixels is the clear colour. Not + * black in front of the world — a pixel the world never reached. + * + * So the numbers divide into three groups and only the first one does anything + * an artist would call rendering: + * + * - **the hole** — `zoneRadius`, `holeLift`, `holeSquash`, `holeWeight` and the + * four times. Four numbers and a schedule, and they are the ability. + * - **the rim** — one billboarded annulus, one draw call, deliberately *dark*. + * `colorRim` is nearly black and the only lit term is `lipGain`, rolled off + * at `lipCeiling`. A bright rim was the first version and it is the one thing + * that genuinely breaks this ability: the hole is punched in the scene pass, + * so `UnrealBloomPass` runs *after* it and a hot edge bleeds straight across + * the void it is supposed to bound. `SceneHooks` documents that caveat; this + * block's answer to it is to not be bright. + * - **the dust** — one sparse system falling inward. It exists to prove the + * trick rather than to decorate it: particles depth-test, the hole is in the + * depth prepass, and so every mote simply *stops* at the boundary with no + * code anywhere telling it to. + * + * **`holeLift` against `zoneRadius`.** The centre sits `holeLift` metres above + * the floor and the sphere has radius `zoneRadius`, so at the shipped 3.0 + * against 4.2 the void bites 1.2 m into the ground and stands seven metres + * high. Lift it past the radius and the hole leaves the floor alone entirely, + * which is a very different and much quieter effect. + * + * **`rimWidth` and `rimWaver` are metres, not fractions.** The rim is 9 cm wide + * whether the hole is one metre across or eight — a rim that scaled with the + * radius read as a ring painted on a balloon. + */ +export const silence = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.5, // closer than this and the cast is refused + speed: 44.0, // how fast the front reaches the circle, metres/second + zoneRadius: 4.2, // metres — the footprint, and the sphere's radius + openTime: 0.34, // seconds the hole takes to open + holdTime: 1.5, // seconds it stands open + closeTime: 0.55, // seconds it takes to close + settleTime: 0.35, // seconds the rim takes to let go after that + cooldown: 1.5, // seconds before it can be cast again + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the hole --- */ + holeLift: 3.0, // metres the sphere's centre sits above the floor + holeSquash: 0.92, // <1 flattens the void vertically; 1 is a true sphere + holeWeight: 1.0, // the hook's blend. 0 is transparent, not "off but held". + openBounce: 0.07, // fraction of overshoot as the hole snaps open + gatherRadius: 1.55, // rim radius during travel, as a multiple of zoneRadius + + /* --- the rim: one annulus, and it does not glow --- */ + rimSwell: 1.03, // ring radius as a multiple of the hole radius + rimPad: 1.3, // billboard half-extent as a multiple of the ring radius + rimWidth: 0.09, // metres — half-width of the band, constant at any radius + rimWaver: 0.06, // metres the ring wanders off a perfect circle + rimWaverSpeed: 0.5, // radians/second of that wander + rimOpacity: 0.9, + rimInnerBias: 0.6, // how much more opaque the inner half of the band is + rimTravel: 0.3, // 0..1 rim opacity while the front is still travelling + lipGain: 0.5, // the one lit term, on the outer half of the band + lipCeiling: 0.6, // hard asymptote, linear HDR — keep well below post.bloomThreshold + colorRim: '#0a0710', // the band. Nearly black, and that is the design. + colorLip: '#9f86d8', // the outer lip, the only colour in the ability + + /* --- the dust drawn in, and erased --- */ + dustRate: 55, // particles/second + dustSize: 0.055, + dustSpeed: 2.7, // metres/second inward + dustLifetime: 1.6, + dustRise: -0.4, // metres/second — it sinks as it goes + dustSpawn: 1.9, // where it starts, as a multiple of the hole radius + dustTurbulence: 0.45, + dustOpacity: 0.55, + colorDustA: '#c9b8ee', + colorDustB: '#7f6bb0', + colorDustC: '#3a2f56', + colorDustD: '#0c0916', + + /* --- dynamic light --- */ + // Turned almost all the way down, on purpose, and it is the only light in the + // project that is. A hole does not emit; this exists to put the faintest cold + // wash on the flagstones at the rim so the boundary has somewhere to sit. + lightIntensity: 1.3, + lightRadius: 7.0, + lightColor: '#2c1a48', + + /* --- the beats you feel --- */ + castDim: 0.16, // the screen darkens slightly as it opens (a negative flash) + colorDim: '#050308', // what it darkens toward + openShake: 0.3, // knock as the hole opens + shakeDuration: 0.45, + rumble: 0.012 // continuous shake while the front travels +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Silence. + * + * **The hole** is the whole panel. Drag `zoneRadius` with the clock stopped and + * the world comes back or goes away around a standing void; drag `holeLift` and + * the bite it takes out of the floor opens and closes. Everything under **The + * rim** is a boundary treatment on something that is not there, and the two + * numbers worth leaving alone are `lipGain` and `lipCeiling` — see the block + * header for what a bright rim does to a hole punched before the bloom pass. + */ +export const silenceSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 4, 200, 0.5, 'front speed'], + ['zoneRadius', 0.5, 14, 0.1, 'hole radius (m)'], + ['openTime', 0.02, 3, 0.01, 'open time'], + ['holdTime', 0.05, 8, 0.05, 'hold time'], + ['closeTime', 0.02, 4, 0.01, 'close time'], + ['settleTime', 0.02, 3, 0.01, 'rim settle'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The hole': [ + ['holeLift', 0, 12, 0.05, 'centre above floor (m)'], + ['holeSquash', 0.2, 2, 0.01, 'vertical squash'], + ['holeWeight', 0, 1, 0.01, 'hook blend'], + ['openBounce', 0, 0.5, 0.01, 'open overshoot'], + ['gatherRadius', 0.5, 3, 0.01, 'gather radius (× hole)'] + ], + 'The rim': [ + ['rimSwell', 0.9, 1.3, 0.005, 'ring radius (× hole)'], + ['rimPad', 1.05, 2.5, 0.01, 'billboard padding'], + ['rimWidth', 0.01, 0.6, 0.005, 'band half-width (m)'], + ['rimWaver', 0, 0.5, 0.005, 'wander (m)'], + ['rimWaverSpeed', 0, 4, 0.01, 'wander rate'], + ['rimOpacity', 0, 1, 0.01, 'opacity'], + ['rimInnerBias', 0, 2, 0.01, 'inner weighting'], + ['rimTravel', 0, 1, 0.01, 'opacity while travelling'], + ['lipGain', 0, 3, 0.01, 'lip gain'], + ['lipCeiling', 0.05, 1.2, 0.01, 'lip ceiling (bloom guard)'], + ['colorRim', 'band'], + ['colorLip', 'lip'] + ], + 'The dust': [ + ['dustRate', 0, 400, 1, 'rate'], + ['dustSize', 0.005, 0.4, 0.005, 'size'], + ['dustSpeed', 0, 12, 0.05, 'inward speed'], + ['dustLifetime', 0.1, 6, 0.05, 'lifetime'], + ['dustRise', -4, 2, 0.01, 'rise'], + ['dustSpawn', 1.05, 4, 0.01, 'spawn radius (× hole)'], + ['dustTurbulence', 0, 3, 0.01, 'turbulence'], + ['dustOpacity', 0, 1, 0.01, 'opacity'], + ['colorDust*', 'Dust colour'] + ], + 'The beats you feel': [ + ['castDim', 0, 1, 0.01, 'screen dim'], + ['colorDim', 'dim toward'], + ['openShake', 0, 3, 0.01, 'opening knock'], + ['shakeDuration', 0.1, 4, 0.01, 'knock duration'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 40, 0.5, 'light intensity'], + ['lightRadius', 0.5, 30, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/singularity.js b/src/config/abilities/singularity.js new file mode 100644 index 0000000..be9e443 --- /dev/null +++ b/src/config/abilities/singularity.js @@ -0,0 +1,587 @@ +/* ================================================================== */ +/* SINGULARITY — a gravity well over the aimed circle */ +/* ================================================================== */ +/** + * The far cast that is mostly made of the **distortion pass**. + * + * `LAYER.DISTORTION` and its offset buffer shipped in the first build with + * nothing writing to them. This block is largely the settings for the thing + * that finally does: a `LENS` emitter whose magnitude goes as 1/r² inside a + * falloff, so the floor, the character and every particle behind the well bend + * around it. Everything else — the accretion streams, the horizon, the dust and + * the hole in the floor — is dressing on that one idea. + * + * Three beats, and they map onto the phase machine like this: + * + * | phase | what it is | how long | + * | --- | --- | --- | + * | travel | a seed thrown downrange, the well already faintly bending light | `range / speed` | + * | impact | **form** then the long **pull** | `formTime + pullTime` | + * | fade | **INVERT** — the lens flips sign — then the throw-out | `invertTime + throwTime` | + * + * The four controls worth reaching for first are `lensStrength` and `lensCore` + * (how hard and how tight the warp is), `orbitRate` (how fast the disc turns, + * and therefore how hard the inside laps the outside) and `zoneRadius`, which + * drives the lens falloff, the disc, the funnel and the dust ring at once — + * one of the few places the sharing *is* the design, exactly as it is on the + * snare. + * + * Nothing here is captured by a cast. A cast rolls one seed and remembers + * whether the collapse has fired; every metre, radian and second below is + * re-read on every frame, zero-length ones included. + */ +export const singularity = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 58.0, // how fast the seed travels to the circle, metres/second + cooldown: 6.0, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 5.2, // the footprint — what the circle indicator measures out + + /* --- the beats --- */ + formTime: 0.5, // seconds the well takes to open once the seed lands + pullTime: 2.4, // seconds it hauls everything inward + invertTime: 0.18, // seconds the lens spends inverted — the flip + throwTime: 1.25, // seconds the wreckage takes to disperse + wellHeight: 1.85, // metres above the floor the well hangs + launchHeight: 1.2, // metres — where the seed leaves the caster + seedOpen: 0.26, // how open the well is while the seed is still travelling, 0..1 + + /* ------------------------------------------------------------------ */ + /* The lens — the ability */ + /* ------------------------------------------------------------------ */ + /** + * Magnitudes are **screen fractions**, not metres: a fragment at strength 1 + * displaces the frame under it by a whole `post.distortion` of screen width + * however far away the well is. The pass multiplies by `post.distortion × + * global.distortion` exactly once, so nothing here may include either. + */ + lensRadius: 1.45, // the falloff edge, × zoneRadius + lensSeed: 0.45, // that radius while the well is still forming, × itself + lensStrength: 0.5, // screen widths at the falloff edge, at post.distortion = 1 + lensPull: 1.8, // extra strength by the end of the pull, × lensStrength + lensBurst: 2.4, // extra strength during the flip, × lensStrength + lensCurve: 1.7, // exponent on the opening ramp — >1 deepens late + lensWindow: 0.3, // 0..1 of the radius at which the falloff starts + lensCore: 0.15, // 0..1 of the radius — the 1/r² clamp at the centre + lensMax: 3.0, // hard ceiling on the displacement + lensSwirl: 0.45, // tangential fraction — frame dragging + lensSwirlPull: 1.2, // extra swirl by the end of the pull, × lensSwirl + lensFold: 0.0, // 0 never lets the sample cross the centre and flip the image + lensInverted: false, // sample inward the whole time — the well magnifies + lensFlips: true, // does the collapse flip the lens? this is the third beat + /** + * How hard opaque geometry *in front of* the emitter cuts the warp. + * + * Ships at 0 and that is deliberate: the lens is a billboard standing at the + * well's own height, so the floor across its lower half is nearer the camera + * than the emitter plane. At 1 the depth term throws all of it away and the + * well bends the sky while the ground stays flat — which is the exact + * symptom that reads as "the distortion pass is not reaching me". Raise it + * only if a well behind a pillar is warping the pillar. + */ + lensDepthReject: 0.0, + lensDepthFade: 0.6, // metres over which that rejection feathers + lensPerspective: 0.0, // 0 keeps the warp a screen fraction, 1 shrinks it with distance + lensPerspectiveRef: 14.0, // metres at which perspective = 1 + lensOpacity: 1.0, // coverage of the emitter against anything overlapping it + + /* ------------------------------------------------------------------ */ + /* The infall — instanced ribbons on a conserved-momentum orbit */ + /* ------------------------------------------------------------------ */ + /** + * `r² · dθ/dt` is held constant, `r` closes linearly on the horizon over + * `fallTime`, and θ comes out in closed form — so the inside of the disc + * genuinely laps the outside and the tail, which is a fixed window of *time*, + * stretches all by itself as a stream speeds up. + */ + streams: 96, // live filaments (capped at 160) + fallTime: 2.6, // seconds one stream takes to reach the horizon + fallSpeedUp: 0.45, // how much shorter that fall gets by the end of the pull, 0..1 + discOuter: 0.92, // the widest orbit, × zoneRadius + discInner: 0.3, // the tightest starting orbit, × zoneRadius + discSeed: 0.5, // the disc's radius while the well is forming, × itself + discDraw: 0.35, // how far the pull hauls the whole disc in, 0..1 + discSwallow: 1.0, // where a stream is swallowed, × horizonRadius + orbitRate: 1.0, // radians/second at the widest orbit + orbitPull: 1.4, // extra rate by the end of the pull, × orbitRate + inclination: 0.42, // radians the orbits tilt out of the disc plane + flatten: 0.5, // 0 a flat disc, 1 a sphere of orbits + trailTime: 0.55, // seconds of history in a stream's tail + ejectDistance: 11.0, // metres the disc is thrown outward by on the collapse + streamWobble: 0.09, // metres of lateral slop on a stream + streamWobbleTurns: 3.0, // wobbles per turn of the orbit + streamWidth: 0.075, // half-width of a stream at its head, metres + streamWidthTail: 0.16, // that width at the tail, as a fraction + streamWidthCurve: 1.5, // how early the taper happens + streamWidthNear: 1.9, // extra width at the horizon, × streamWidth + streamEnter: 0.12, // fraction of the fall spent fading in + streamExit: 0.07, // ... and fading out + streamCoreSharp: 2.6, // how hard the hot core falls off across the ribbon + streamHeat: 0.7, // 0 colours by tail-to-head, 1 by how far down the well it is + streamGlow: 2.2, // emissive gain + streamOpacity: 1.0, + streamSoftFade: 0.4, // metres of soft fade where a stream meets geometry + colorStreamTail: '#3a1f7a', // the cold outer end of a stream + colorStreamHead: '#b58cff', // its hot nose + colorStreamCore: '#ffffff', // the centre of the ribbon + colorStreamHalo: '#160a2e', // the wide soft edge + + /* ------------------------------------------------------------------ */ + /* The event horizon */ + /* ------------------------------------------------------------------ */ + /** + * The only object in the project that is *darker* than what it covers. It + * alpha-blends rather than adding, because additive blending cannot make + * anything darker and an additive hole is a gap you can see straight through. + */ + horizonRadius: 0.62, // metres + horizonSeed: 0.2, // that radius while the well is forming, × itself + horizonBurst: 1.6, // how far it is torn open by the collapse, × itself + horizonEdge: 0.05, // metres of feather on the silhouette + horizonRing: 1.18, // the photon ring, × horizonRadius + horizonRingWidth: 0.05, // metres + horizonRingGlow: 3.6, + horizonDoppler: 0.55, // brightness asymmetry across the turning disc, 0..1 + horizonHalo: 0.45, // metres the outer bloom reaches past the ring + horizonHaloGlow: 1.5, + horizonRim: 0.45, // sheen on the inside of the ring + horizonShimmer: 0.12, // 0..1 wobble on the ring's radius + horizonShimmerRate: 0.7, // cycles/second + horizonOpacity: 1.0, + horizonSoftFade: 0.3, // metres of soft fade against standing geometry + colorVoid: '#0a0612', // the interior + colorRim: '#6a3fd0', // the sheen just inside the ring + colorRing: '#ffffff', // the photon ring + colorHorizonHalo: '#8a5cf0', // the bloom around it + + /* ------------------------------------------------------------------ */ + /* The floor being drawn in — GroundField(FUNNEL) */ + /* ------------------------------------------------------------------ */ + funnelRadius: 1.05, // the pit, × zoneRadius + funnelSeed: 0.3, // that radius while the well is forming, × itself + funnelDepth: 1.3, // metres the cone reads as deep + funnelDeepen: 1.6, // how much deeper it reads by the end of the pull, × itself + funnelLift: 0.1, // metres of spoil slumped over the rim + funnelThickness: 0.09, // metres the lip blocks tilt in by + funnelCell: 0.7, // metres — pitch of those blocks + funnelCellJitter: 0.85, // 0..1 + funnelSeam: 0.05, // metres of gap between them + funnelSharp: 0.62, // 0 a bowl, 1 a shaft + funnelDetail: 0.7, // scree grain on the walls, 0..1 + funnelEdge: 0.4, // metres of feather on the growth front + funnelRagged: 0.22, // how far that front wanders, × the radius + funnelRaggedScale: 0.6, // lobes per metre + funnelWarp: 0.5, // metres of domain warp on them + funnelRelief: 0.85, // how hard the height field tilts the fake normal + funnelParallax: 0.5, // metres of view-driven offset on the wall detail + funnelEmissive: 1.0, + funnelOpacity: 0.95, + funnelDepthFade: 0.5, // metres of soft fade against standing geometry + funnelHeight: 0.02, // metres the quad hovers above the floor + colorFunnelBase: '#241a33', // the ground around the pit + colorFunnelEdge: '#7a5ec0', // the calved lip + colorFunnelGlow: '#b07aff', // the light coming up out of the throat + colorFunnelDeep: '#05030a', // the interior + + /* ------------------------------------------------------------------ */ + /* The dust — Swarm(MOTE) */ + /* ------------------------------------------------------------------ */ + /** + * `churn` is the rate the formation rolls at and is deliberately *not* ramped + * by the pull: the swarm shader multiplies it by the shared clock, so raising + * it mid-cast slews the whole cloud by seconds × Δω. The angular acceleration + * in this ability lives in the infall streams, where θ is closed form. + */ + moteCount: 216, // live agents (capped at 288) + moteLatticeX: 12, // cells across + moteLatticeY: 6, // cells up + moteLatticeZ: 4, // ranks — with a POINT lead these separate by jitter alone + moteSpacing: 0.62, // metres between lateral cells + moteSpacingUp: 0.46, // metres between vertical cells + moteCollapse: 0.16, // that spacing at the end of the pull, × itself + moteEject: 4.5, // how far the collapse throws the cloud, × the spacing + moteLag: 0.35, // seconds the back rank trails by + moteJitter: 0.24, // metres of slop off a cell + moteChurn: 0.8, // radians/second the formation rolls + moteBreathe: 0.14, // fraction it swells by + moteBreatheRate: 1.3, // radians/second + moteWander: 0.16, // metres of curl drift — keep under half the spacing + moteWanderScale: 0.55, // features per metre + moteWanderSpeed: 0.6, + moteGather: 1.0, // 0 collapses every mote onto the well exactly + moteSize: 0.12, // metres + moteAspect: 1.0, // span / length — 1 keeps a mote round + moteSizeJitter: 0.55, // ±fraction + moteEdgeStretch: 1.2, // how much an edge-on mote grows, ≥1 + moteRevealSpread: 0.5, // width of the appearance wave, 0..1 + moteTint: 0.35, // where in the gradient the cloud sits + moteTintJitter: 0.4, // ±per-agent walk along it + moteTintAlong: 0.25, // extra walk across a mote + moteGlow: 1.6, + moteOpacity: 0.85, + moteSoftFade: 0.3, // metres of depth feather + colorMoteA: '#e8dcff', + colorMoteB: '#b58cff', + colorMoteC: '#6a3fd0', + colorMoteD: '#160a2e', + + /* ------------------------------------------------------------------ */ + /* Dust, chips and what comes back out */ + /* ------------------------------------------------------------------ */ + /** + * As on `ice` and `thunder`, each system is coloured by a four-stop gradient + * sampled over the particle's own lifetime, `A` at birth through `D` as it + * dies, spelled out rather than derived from the well's palette. + * + * The dust is the one system in the project that uses the particle shader's + * SWIRL path, and `dustContract` is why: it is `uSwirlExpand`, and a + * *negative* value closes the particle's offset onto its anchor over its + * life. That is an orbit decaying, with no simulation anywhere. + */ + dustRate: 170, // motes drawn off the floor, particles/second + dustRing: 0.55, // inner edge of the ring they seed on, × the disc radius + dustSpread: 0.55, // metres of vertical scatter at birth + dustSize: 0.07, + dustLifetime: 1.9, // seconds + dustRise: 0.35, // vertical drift, metres/second + dustSwirl: 2.2, // radians/second about the well + dustSwirlPull: 1.5, // extra rate by the end of the pull, × dustSwirl + dustContract: -0.82, // fraction the orbit closes by over a life; negative = inward + dustTurbulence: 0.5, + dustOpacity: 0.9, + dustGlow: 1.4, + colorDustA: '#ffffff', + colorDustB: '#c9a8ff', + colorDustC: '#6a3fd0', + colorDustD: '#0a0612', + shardRate: 16, // chips torn off the floor, particles/second + shardSpread: 0.35, // metres of scatter at birth + shardSize: 0.07, + shardSpeed: 4.2, // metres/second, thrown at the well + shardLifetime: 1.5, + shardGravity: -5.5, // metres/second² — light, because the well is holding them up + colorShardA: '#2a2233', + colorShardB: '#1c1626', + colorShardC: '#120e1a', + colorShardD: '#0a0810', + sparkSize: 0.15, + sparkSpeed: 15.0, // metres/second the collapse spits them out at + sparkLifetime: 0.8, + sparkGravity: -9.0, + sparkStretch: 0.22, // how far a spark smears along its velocity + sparkGlow: 2.4, + colorSparkA: '#ffffff', + colorSparkB: '#e0ccff', + colorSparkC: '#8a5cf0', + colorSparkD: '#2a1060', + + /* ------------------------------------------------------------------ */ + /* Emission balance and the shake */ + /* ------------------------------------------------------------------ */ + travelEmission: 0.35, // × the rates while the seed is still in the air + holdEmission: 1.6, // × the rates by the end of the pull + rumble: 0.035, // continuous shake while the well is standing + rumblePull: 1.8, // extra rumble by the end of the pull, × rumble + shakeDuration: 0.55, // seconds a one-shot shake decays over + + /* ------------------------------------------------------------------ */ + /* The three one-shots */ + /* ------------------------------------------------------------------ */ + castBurstSize: 0.7, // the shell at the caster's hand, metres + castBurstIntensity: 1.4, + castFlash: 0.06, // screen flash on release + colorCastA: '#6a3fd0', + colorCastB: '#b58cff', + colorCastC: '#ffffff', + colorCastFlash: '#3a1f7a', + formBurstSize: 3.4, // the shell that collapses inward as the well seats, metres + formBurstIntensity: 1.5, + formRingRadius: 6.5, // the ring that snaps across the floor, metres + formRingIntensity: 1.1, + formFlash: 0.14, + formShake: 0.4, + colorFormA: '#0a0612', + colorFormB: '#6a3fd0', + colorFormC: '#c9a8ff', + colorFormFlash: '#6a3fd0', + collapseSize: 6.5, // the shell thrown out by the inversion, metres + collapseIntensity: 2.3, + collapseSparks: 260, // sparks spat back out + collapseShards: 130, // chips spat back out + collapseThrow: 3.2, // × shardSpeed on the way out + collapseRingRadius: 12.0, // metres + collapseRingIntensity: 1.4, + collapseFlash: 0.42, + collapseShake: 1.4, + colorCollapseA: '#3a1f7a', + colorCollapseB: '#b58cff', + colorCollapseC: '#ffffff', + colorCollapseFlash: '#c9a8ff', + colorRingA: '#b58cff', // body of both shockwave rings + colorRingB: '#ffffff', // their crest + crackRadius: 5.0, // the fractures left in the floor, metres + crackLife: 5.5, // seconds + crackBranches: 0.7, // how finely they split + crackIntensity: 0.8, + colorCrackA: '#1a1026', + colorCrackB: '#8a5cf0', + + /* --- dynamic light --- */ + lightIntensity: 16, + lightRadius: 15, + lightColor: '#7a45e0', + lightBreathe: 0.32, // depth of the well's slow pulse, 0 = steady + lightBreatheRate: 3.4 // radians/second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Singularity. + * + * The first folder is the one that matters. Everything in **The lens** is a + * screen-space measurement rather than a metre, and the three that carry the + * whole effect are `lensStrength`, `lensCore` and `lensRadius` — drag the + * middle one down toward 0.05 with the clock paused and watch the centre of the + * frame turn into a mirror. + */ +export const singularitySchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'seed speed'], + ['cooldown', 0, 20, 0.05, 'cooldown'], + ['zoneRadius', 0.5, 14, 0.05, 'footprint radius'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['formTime', 0.05, 3, 0.01, 'form time'], + ['pullTime', 0.2, 10, 0.05, 'pull time'], + ['invertTime', 0.02, 1.5, 0.01, 'inversion time'], + ['throwTime', 0.1, 5, 0.05, 'throw-out time'], + ['wellHeight', 0, 8, 0.05, 'well height'], + ['launchHeight', 0, 3, 0.01, 'seed height'], + ['seedOpen', 0, 1, 0.01, 'seed openness'] + ], + 'The lens': [ + ['lensRadius', 0.2, 4, 0.01, 'falloff radius'], + ['lensSeed', 0, 1, 0.01, 'radius while forming'], + ['lensStrength', 0, 3, 0.005, 'strength'], + ['lensPull', 0, 6, 0.01, 'strength on the pull'], + ['lensBurst', 0, 8, 0.01, 'strength on the flip'], + ['lensCurve', 0.1, 5, 0.01, 'opening curve'], + ['lensWindow', 0, 1, 0.01, 'falloff start'], + ['lensCore', 0.02, 1, 0.005, 'centre clamp'], + ['lensMax', 0.1, 8, 0.05, 'displacement ceiling'], + ['lensSwirl', -2, 2, 0.01, 'frame dragging'], + ['lensSwirlPull', 0, 6, 0.01, 'dragging on the pull'], + ['lensFold', 0, 1, 0.01, 'allow image flip'], + ['lensInverted', 'sample inward'], + ['lensFlips', 'flip on collapse'], + ['lensDepthReject', 0, 1, 0.01, 'depth rejection'], + ['lensDepthFade', 0.02, 3, 0.01, 'depth feather'], + ['lensPerspective', 0, 1, 0.01, 'shrink with distance'], + ['lensPerspectiveRef', 1, 40, 0.1, 'perspective reference'], + ['lensOpacity', 0, 1, 0.01, 'coverage'] + ], + 'The infall': [ + ['streams', 1, 160, 1, 'streams'], + ['fallTime', 0.2, 10, 0.05, 'fall time'], + ['fallSpeedUp', 0, 0.95, 0.01, 'fall speed-up'], + ['discOuter', 0.05, 2, 0.01, 'widest orbit'], + ['discInner', 0.01, 2, 0.01, 'tightest orbit'], + ['discSeed', 0, 1, 0.01, 'disc while forming'], + ['discDraw', 0, 0.9, 0.01, 'haul-in on the pull'], + ['discSwallow', 0.1, 4, 0.01, 'swallow radius'], + ['orbitRate', 0, 8, 0.01, 'orbit rate'], + ['orbitPull', 0, 8, 0.01, 'rate on the pull'], + ['inclination', 0, 1.6, 0.01, 'orbit tilt'], + ['flatten', 0, 2, 0.01, 'disc / sphere'], + ['trailTime', 0.02, 3, 0.01, 'tail length, seconds'], + ['ejectDistance', 0, 40, 0.1, 'throw-out distance'] + ], + 'The infall/Ribbon': [ + ['streamWidth', 0.005, 0.6, 0.005, 'width at the head'], + ['streamWidthTail', 0.01, 2, 0.01, 'width at the tail'], + ['streamWidthCurve', 0.1, 5, 0.01, 'taper curve'], + ['streamWidthNear', 0.1, 6, 0.01, 'width at the horizon'], + ['streamWobble', 0, 1, 0.005, 'wobble'], + ['streamWobbleTurns', 0, 12, 0.1, 'wobbles / turn'], + ['streamEnter', 0.01, 0.5, 0.005, 'fade in'], + ['streamExit', 0.01, 0.5, 0.005, 'fade out'], + ['streamCoreSharp', 0.5, 10, 0.05, 'core sharpness'], + ['streamHeat', 0, 1, 0.01, 'colour by depth'], + ['streamGlow', 0, 8, 0.01, 'glow'], + ['streamOpacity', 0, 2, 0.01, 'opacity'], + ['streamSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorStreamTail', 'stream tail'], + ['colorStreamHead', 'stream head'], + ['colorStreamCore', 'ribbon core'], + ['colorStreamHalo', 'ribbon halo'] + ], + 'The event horizon': [ + ['horizonRadius', 0.05, 4, 0.01, 'horizon radius'], + ['horizonSeed', 0, 1, 0.01, 'radius while forming'], + ['horizonBurst', 0, 6, 0.01, 'tear on collapse'], + ['horizonEdge', 0.005, 0.5, 0.005, 'silhouette feather'], + ['horizonRing', 1, 2.5, 0.01, 'photon ring radius'], + ['horizonRingWidth', 0.005, 0.5, 0.005, 'ring width'], + ['horizonRingGlow', 0, 12, 0.05, 'ring glow'], + ['horizonDoppler', 0, 1, 0.01, 'beaming asymmetry'], + ['horizonHalo', 0.02, 3, 0.01, 'halo reach'], + ['horizonHaloGlow', 0, 6, 0.05, 'halo glow'], + ['horizonRim', 0, 3, 0.01, 'inner sheen'], + ['horizonShimmer', 0, 1, 0.01, 'ring shimmer'], + ['horizonShimmerRate', 0, 6, 0.05, 'shimmer rate'], + ['horizonOpacity', 0, 1, 0.01, 'opacity'], + ['horizonSoftFade', 0.02, 2, 0.01, 'soft intersection'], + ['colorVoid', 'interior'], + ['colorRim', 'inner sheen'], + ['colorRing', 'photon ring'], + ['colorHorizonHalo', 'halo'] + ], + 'The hole in the floor': [ + ['funnelRadius', 0.1, 3, 0.01, 'pit radius'], + ['funnelSeed', 0, 1, 0.01, 'radius while forming'], + ['funnelDepth', 0, 5, 0.01, 'depth'], + ['funnelDeepen', 0.5, 4, 0.01, 'deepening on the pull'], + ['funnelLift', 0, 1, 0.005, 'spoil over the rim'], + ['funnelThickness', 0, 0.5, 0.005, 'lip tilt'], + ['funnelCell', 0.05, 3, 0.01, 'lip block pitch'], + ['funnelCellJitter', 0, 1, 0.01, 'block jitter'], + ['funnelSeam', 0, 0.3, 0.005, 'block gap'], + ['funnelSharp', 0, 1, 0.01, 'bowl / shaft'], + ['funnelDetail', 0, 1, 0.01, 'wall grain'], + ['funnelEdge', 0.02, 2, 0.01, 'front feather'], + ['funnelRagged', 0, 1, 0.01, 'front raggedness'], + ['funnelRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['funnelWarp', 0, 3, 0.01, 'domain warp'], + ['funnelRelief', 0, 3, 0.01, 'relief'], + ['funnelParallax', 0, 2, 0.01, 'parallax'], + ['funnelEmissive', 0, 4, 0.01, 'emissive'], + ['funnelOpacity', 0, 1, 0.01, 'opacity'], + ['funnelDepthFade', 0.02, 3, 0.01, 'soft intersection'], + ['funnelHeight', 0, 0.2, 0.001, 'hover height'], + ['colorFunnelBase', 'ground'], + ['colorFunnelEdge', 'calved lip'], + ['colorFunnelGlow', 'throat glow'], + ['colorFunnelDeep', 'interior'] + ], + 'The dust cloud': [ + ['moteCount', 0, 288, 1, 'motes'], + ['moteLatticeX', 1, 24, 1, 'cells across'], + ['moteLatticeY', 1, 16, 1, 'cells up'], + ['moteLatticeZ', 1, 16, 1, 'ranks'], + ['moteSpacing', 0.02, 3, 0.01, 'lateral spacing'], + ['moteSpacingUp', 0.02, 3, 0.01, 'vertical spacing'], + ['moteCollapse', 0.01, 1, 0.01, 'spacing at full pull'], + ['moteEject', 0, 12, 0.05, 'throw-out'], + ['moteLag', 0, 2, 0.01, 'rank lag'], + ['moteJitter', 0, 1.5, 0.01, 'cell jitter'], + ['moteChurn', -4, 4, 0.01, 'formation roll'], + ['moteBreathe', 0, 1, 0.01, 'breathe'], + ['moteBreatheRate', 0, 6, 0.05, 'breathe rate'], + ['moteWander', 0, 1, 0.01, 'wander'], + ['moteWanderScale', 0.05, 3, 0.01, 'wander scale'], + ['moteWanderSpeed', 0, 4, 0.05, 'wander speed'], + ['moteGather', 0, 1, 0.01, 'gather onto the well'], + ['moteSize', 0.01, 1, 0.005, 'mote size'], + ['moteAspect', 0.2, 4, 0.01, 'mote aspect'], + ['moteSizeJitter', 0, 1, 0.01, 'size jitter'], + ['moteEdgeStretch', 1, 4, 0.01, 'edge-on stretch'], + ['moteRevealSpread', 0.01, 1, 0.01, 'appearance wave'], + ['moteTint', 0, 1, 0.01, 'tint'], + ['moteTintJitter', 0, 1, 0.01, 'tint jitter'], + ['moteTintAlong', 0, 1, 0.01, 'tint across'], + ['moteGlow', 0, 6, 0.01, 'glow'], + ['moteOpacity', 0, 2, 0.01, 'opacity'], + ['moteSoftFade', 0.02, 2, 0.01, 'soft intersection'], + ['colorMote*', 'Mote colour'] + ], + 'Dust & debris': [ + ['dustRate', 0, 900, 1, 'dust rate'], + ['dustRing', 0, 1.5, 0.01, 'ring inner edge'], + ['dustSpread', 0, 3, 0.01, 'birth scatter'], + ['dustSize', 0.005, 0.5, 0.005, 'dust size'], + ['dustLifetime', 0.1, 8, 0.05, 'dust lifetime'], + ['dustRise', -3, 4, 0.05, 'dust rise'], + ['dustSwirl', -12, 12, 0.05, 'orbit rate'], + ['dustSwirlPull', 0, 6, 0.01, 'orbit rate on the pull'], + ['dustContract', -1, 1, 0.01, 'orbit contraction'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['dustOpacity', 0, 2, 0.01, 'dust opacity'], + ['dustGlow', 0, 6, 0.01, 'dust glow'], + ['shardRate', 0, 200, 1, 'chip rate'], + ['shardSpread', 0, 2, 0.01, 'chip scatter'], + ['shardSize', 0.005, 0.4, 0.005, 'chip size'], + ['shardSpeed', 0, 30, 0.1, 'chip speed'], + ['shardLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['shardGravity', -40, 10, 0.1, 'chip gravity'], + ['colorDust*', 'Dust colour'], + ['colorShard*', 'Chip colour'] + ], + 'The collapse': [ + ['collapseSize', 0.2, 20, 0.05, 'burst size'], + ['collapseIntensity', 0, 6, 0.01, 'burst intensity'], + ['collapseSparks', 0, 800, 1, 'sparks'], + ['collapseShards', 0, 500, 1, 'chips'], + ['collapseThrow', 0.5, 10, 0.05, 'chip speed × '], + ['collapseRingRadius', 0.5, 30, 0.1, 'ring radius'], + ['collapseRingIntensity', 0, 4, 0.01, 'ring intensity'], + ['collapseFlash', 0, 2, 0.01, 'screen flash'], + ['collapseShake', 0, 4, 0.01, 'shake'], + ['crackRadius', 0.2, 16, 0.05, 'fracture radius'], + ['crackLife', 0.2, 20, 0.1, 'fracture lifetime'], + ['crackBranches', 0, 3, 0.01, 'fracture detail'], + ['crackIntensity', 0, 3, 0.01, 'fracture intensity'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 60, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['sparkGlow', 0, 8, 0.01, 'spark glow'], + ['colorCollapseA', 'collapse shell'], + ['colorCollapseB', 'collapse body'], + ['colorCollapseC', 'collapse arcs'], + ['colorCollapseFlash', 'collapse flash'], + ['colorRingA', 'shockwave ring'], + ['colorRingB', 'shockwave crest'], + ['colorCrackA', 'fracture'], + ['colorCrackB', 'fracture glow'], + ['colorSpark*', 'Spark colour'] + ], + 'Cast & form': [ + ['castBurstSize', 0.05, 5, 0.05, 'cast shell size'], + ['castBurstIntensity', 0, 5, 0.01, 'cast shell intensity'], + ['castFlash', 0, 2, 0.01, 'cast flash'], + ['colorCastA', 'cast shell'], + ['colorCastB', 'cast body'], + ['colorCastC', 'cast arcs'], + ['colorCastFlash', 'cast flash colour'], + ['formBurstSize', 0.2, 14, 0.05, 'form shell size'], + ['formBurstIntensity', 0, 5, 0.01, 'form shell intensity'], + ['formRingRadius', 0.5, 25, 0.1, 'form ring radius'], + ['formRingIntensity', 0, 4, 0.01, 'form ring intensity'], + ['formFlash', 0, 2, 0.01, 'form flash'], + ['formShake', 0, 3, 0.01, 'form shake'], + ['colorFormA', 'form shell'], + ['colorFormB', 'form body'], + ['colorFormC', 'form arcs'], + ['colorFormFlash', 'form flash colour'], + ['travelEmission', 0, 2, 0.01, 'emission while travelling'], + ['holdEmission', 0, 4, 0.01, 'emission at full pull'], + ['rumble', 0, 0.5, 0.005, 'rumble'], + ['rumblePull', 0, 6, 0.01, 'rumble on the pull'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightBreathe', 0, 1, 0.01, 'light pulse'], + ['lightBreatheRate', 0, 20, 0.05, 'pulse rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/sinkhole.js b/src/config/abilities/sinkhole.js new file mode 100644 index 0000000..d12d87b --- /dev/null +++ b/src/config/abilities/sinkhole.js @@ -0,0 +1,311 @@ +/* ================================================================== */ +/* SINKHOLE — the ground goes down */ +/* ================================================================== */ +/** + * Everything else in the sandbox comes *up* out of the floor or sits *on* it. + * This one takes the floor away. + * + * The hole is a real displaced surface — a radial mesh whose vertex shader + * places every vertex in metres off a funnel profile and hands the fragment + * stage a normal computed from that same profile by finite difference. That is + * not a detail: a flat quad with a painted gradient survives exactly one camera + * orbit before it reads as a sticker, and this ability has nothing else to sell. + * `depth` and `wallCurve` between them are the whole silhouette — `wallCurve` + * near 1 gives a shallow crater, near 3 a bell-mouthed shaft — and both of them + * reshape a hole that has already finished opening. + * + * The floor of the hole is **never drawn**. The mesh converges on a single + * vertex at the throat and the last half-metre of it is `colorVoid`, so there + * is no surface down there to read a distance off and the eye gives up. The + * calving chunks are never seen to land for the same reason: they fall to a + * point well below the throat and the near wall eats them on the way. + * + * Four beats: the fractures spread over `crackTime`, the floor lets go over + * `dropTime`, the dust blows out over the rim, and then `lifetime` seconds of + * settle in which pebbles are still trickling in. + */ +export const sinkhole = { + /* --- the cast --- */ + range: 24.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 95.0, // how fast the cast reaches the circle, metres/second + zoneRadius: 5.0, // the rim of the hole, metres — drives everything below + cooldown: 1.8, // seconds before the slot re-arms + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + crackTime: 0.4, // seconds the fractures spread before the floor lets go + dropTime: 0.55, // seconds the floor takes to fall + lifetime: 3.4, // seconds of settle after it has fallen + sinkTime: 1.8, // seconds the hole takes to give the floor back + + /* --- the funnel (metres unless noted) --- */ + depth: 6.5, // floor to throat + wallCurve: 2.4, // >1 steepens the throat and flares the mouth + apron: 1.45, // how far past the rim the mesh reaches, × zoneRadius + apronFade: 1.08, // where its alpha starts feathering out, × zoneRadius + lip: 0.3, // spoil heaped just outside the rim + rough: 0.75, // 0..1 grain on the walls, × depth + roughScale: 1.1, // grain features per metre + strata: 0.5, // 0..1 horizontal bedding banding down the walls + strataScale: 1.4, // bands per metre + throat: 0.16, // 0..1 of the radius the blackout at the bottom covers + calve: 1.0, // 0..1 how far the lip wedges break inward + calveCells: 14, // wedges the rim breaks into + calveDrop: 0.95, // metres a calved wedge drops + spokes: 56, // mesh resolution around (rebuilds nothing; fixed at boot) + rings: 36, // mesh resolution outward + + /* --- how the hole is shaded --- */ + colorGround: '#4a4239', // the intact floor colour at the very rim + colorWall: '#332c25', // the exposed wall + colorDeep: '#221e19', // where the walls run out of light + colorVoid: '#0a0908', // the throat, which is nothing at all + colorLip: '#8a7d6a', // the broken lip and the specular catch + ambient: 0.28, // floor on the diffuse term + wrap: 0.42, // 0..1 wraps the terminator round the back of a wall + specular: 0.25, + gloss: 18.0, // Blinn exponent + depthTint: 1.4, // how fast the walls run to `colorDeep` with depth + grit: 0.5, // scree speckle on the walls + gritScale: 9.0, // speckle features per metre + rimLight: 0.6, // how hard the broken lip catches the key light + opacity: 1.0, + + /* --- the cracked apron beyond the rim (GroundField, FUNNEL) --- */ + // The library's FUNNEL mode is a *flat* mark and stays outside the hole, + // where flat is the truth: fracture lines and spoil on ground that has not + // fallen yet. See the class comment for why it is not used for the pit. + fieldEdge: 0.5, // metres of feather on the growth front + fieldRagged: 0.34, // how far that front wanders, fraction of the radius + fieldRaggedScale: 0.65, // lobes per metre + fieldWarp: 0.6, // metres of domain warp on those lobes + fieldRelief: 0.7, // how hard its height field tilts the fake normal + fieldCell: 0.85, // metres — the size of a calved block + fieldCellJitter: 0.9, // 0..1 + fieldSeam: 0.06, // metres — the fracture line between blocks + fieldThickness: 0.12, // metres a block tips by + fieldLift: 0.16, // metres of spoil slumped over the rim + fieldDepth: 0.5, // metres of apparent dip on the flat mark + fieldSharp: 0.6, // 0..1 profile sharpness + fieldDetail: 0.7, // 0..1 grain + fieldParallax: 0.3, // metres of view-driven offset on interior detail + fieldOpacity: 0.95, + colorFieldBase: '#3f382f', + colorFieldEdge: '#8a7d6a', + colorFieldGlow: '#6b5f4c', + colorFieldDeep: '#12100d', + + /* --- the chunks that calve in and are never seen to land --- */ + debrisCount: 26, // live chunks, capped at 48 + debrisRadius: 0.32, // metres + debrisSizeJitter: 0.55, // ± fraction + debrisSpin: 5.0, // tumble, radians/second + debrisFlash: 0.1, // seconds the birth flash decays over + debrisBack: 0.0, // metres the launch point sits behind the hole centre + debrisSpread: 1.05, // launch scatter, × zoneRadius + debrisLaunchHeight: 0.3, // metres above the floor a chunk lets go at + debrisSinkSpread: 0.42, // where they converge to, × zoneRadius + debrisSinkDepth: 7.5, // metres below the floor they fall toward + debrisPathCurve: 1.85, // >1 accelerates — this is the gravity + debrisFlightTime: 1.1, // seconds one chunk is falling + debrisSpeedJitter: 0.35, // ± fraction + debrisLead: 0.05, // seconds before the first chunk goes + debrisWindow: 1.9, // seconds the calving is spread over + debrisFillBias: -1.0, // -1 calves inward from the rim, +1 outward from the middle + debrisFillScatter: 0.55, // 0 pure radial order, 1 pure spatial hash + debrisHashCell: 1.2, // metres — the lattice the calving order is hashed on + colorDebris: '#3a332b', // one picker; the chunks are lit by the scene + debrisRoughness: 0.95, + + /* --- the dust that blows out over the rim --- */ + dustRate: 90, // the slow updraft during the settle, particles/second + dustSize: 1.2, + dustSpeed: 1.2, + dustLifetime: 2.8, + dustOpacity: 0.36, + dustRise: 0.5, // upward drift, metres/second + dustTurbulence: 0.6, + blastDust: 150, // the ring thrown outward the moment the floor lets go + blastSpeed: 7.5, // metres/second that ring travels at + blastLift: 0.35, // 0..1 of that speed aimed upward rather than outward + colorDustA: '#9b9184', + colorDustB: '#736a5e', + colorDustC: '#453f37', + colorDustD: '#1e1b17', + + /* --- pebbles still trickling in --- */ + pebbleRate: 55, // particles/second during the settle + pebbleSize: 0.05, + pebbleSpeed: 1.4, + pebbleLifetime: 0.9, // short: they must die before they reach a bottom + pebbleGravity: -14.0, + pebbleBand: 0.22, // 0..1 of the radius inside the rim they let go from + colorPebbleA: '#6b6357', + colorPebbleB: '#453f37', + colorPebbleC: '#282420', + colorPebbleD: '#141210', + + /* --- the moment it lets go --- */ + burstSize: 3.4, // dust ball over the rim, metres + burstIntensity: 1.0, + shockRadius: 7.0, // the ring that snaps out across the floor, metres + impactShake: 1.15, + shakeDuration: 0.9, + settleShake: 0.05, // continuous rumble through the settle + rumble: 0.04, // ... and while the cast is still reaching the circle + impactFlash: 0.05, // barely anything; a hole does not flash + colorBurstA: '#8a7d6a', + colorBurstB: '#544c40', + colorBurstC: '#221e19', + colorShockA: '#8a7d6a', + colorShockB: '#c8bca6', + colorFlash: '#6b5f4c', + + /* --- dynamic light --- */ + lightIntensity: 5.5, + lightRadius: 11.0, + lightColor: '#a08a68' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Sinkhole. + * + * "The funnel" is the folder that matters. `depth` and `wallCurve` are the + * silhouette; `calve`, `calveCells` and `calveDrop` are what stops the rim + * being a circle; `throat` is how much of the bottom is simply not there. All + * of them re-shape a hole that is already standing, paused included, because + * every one of them is a uniform the vertex shader reads on the frame it draws. + */ +export const sinkholeSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 10, 400, 1, 'reach speed'], + ['zoneRadius', 1, 16, 0.05, 'hole radius'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'], + ['crackTime', 0.02, 3, 0.01, 'cracking time'], + ['dropTime', 0.05, 3, 0.01, 'drop time'], + ['lifetime', 0.2, 12, 0.05, 'settle time'], + ['sinkTime', 0.1, 6, 0.05, 'close-over time'] + ], + 'The funnel': [ + ['depth', 0.5, 20, 0.05, 'depth'], + ['wallCurve', 0.4, 5, 0.01, 'wall profile'], + ['apron', 1.0, 2.5, 0.01, 'mesh reach × radius'], + ['apronFade', 0.9, 2.4, 0.01, 'alpha fade × radius'], + ['lip', 0, 1.5, 0.01, 'spoil at the rim'], + ['rough', 0, 3, 0.01, 'wall grain'], + ['roughScale', 0.1, 6, 0.01, 'grain / metre'], + ['strata', 0, 1, 0.01, 'bedding bands'], + ['strataScale', 0.1, 6, 0.01, 'bands / metre'], + ['throat', 0.01, 0.6, 0.005, 'blackout radius'], + ['calve', 0, 1, 0.01, 'lip calving'], + ['calveCells', 3, 40, 1, 'lip wedges'], + ['calveDrop', 0, 4, 0.01, 'wedge drop'], + ['spokes', 12, 96, 1, 'mesh spokes'], + ['rings', 8, 64, 1, 'mesh rings'] + ], + 'How the hole is shaded': [ + ['colorGround', 'floor at the rim'], + ['colorWall', 'exposed wall'], + ['colorDeep', 'deep wall'], + ['colorVoid', 'the throat'], + ['colorLip', 'broken lip'], + ['ambient', 0, 1, 0.01, 'ambient floor'], + ['wrap', 0, 1, 0.01, 'terminator wrap'], + ['specular', 0, 2, 0.01, 'specular'], + ['gloss', 1, 80, 0.5, 'gloss'], + ['depthTint', 0.2, 4, 0.01, 'depth tint'], + ['grit', 0, 2, 0.01, 'scree speckle'], + ['gritScale', 0.5, 40, 0.1, 'speckle / metre'], + ['rimLight', 0, 3, 0.01, 'lip catch'], + ['opacity', 0, 1, 0.01, 'opacity'] + ], + 'The cracked apron': [ + ['fieldEdge', 0.02, 3, 0.01, 'front feather'], + ['fieldRagged', 0, 1, 0.01, 'front raggedness'], + ['fieldRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['fieldWarp', 0, 3, 0.01, 'domain warp'], + ['fieldRelief', 0, 3, 0.01, 'relief'], + ['fieldCell', 0.05, 4, 0.01, 'block size'], + ['fieldCellJitter', 0, 1, 0.01, 'block jitter'], + ['fieldSeam', 0.005, 0.5, 0.005, 'fracture width'], + ['fieldThickness', 0, 1, 0.01, 'block tip'], + ['fieldLift', 0, 1, 0.01, 'spoil lift'], + ['fieldDepth', 0, 3, 0.01, 'apparent dip'], + ['fieldSharp', 0, 1, 0.01, 'profile sharpness'], + ['fieldDetail', 0, 2, 0.01, 'grain'], + ['fieldParallax', 0, 2, 0.01, 'parallax'], + ['fieldOpacity', 0, 1, 0.01, 'opacity'], + ['colorFieldBase', 'apron ground'], + ['colorFieldEdge', 'apron edges'], + ['colorFieldGlow', 'apron highlight'], + ['colorFieldDeep', 'apron fissures'] + ], + 'The calving chunks': [ + ['debrisCount', 0, 48, 1, 'chunks'], + ['debrisRadius', 0.02, 1.5, 0.01, 'chunk radius'], + ['debrisSizeJitter', 0, 1.5, 0.01, 'size jitter'], + ['debrisSpin', 0, 20, 0.1, 'tumble'], + ['debrisFlash', 0.01, 1, 0.01, 'birth flash'], + ['debrisBack', -6, 6, 0.05, 'launch offset'], + ['debrisSpread', 0, 2, 0.01, 'launch spread × radius'], + ['debrisLaunchHeight', -1, 3, 0.01, 'launch height'], + ['debrisSinkSpread', 0, 1.5, 0.01, 'converge × radius'], + ['debrisSinkDepth', 0.5, 25, 0.1, 'fall to depth'], + ['debrisPathCurve', 0.5, 4, 0.01, 'fall acceleration'], + ['debrisFlightTime', 0.1, 5, 0.01, 'fall time'], + ['debrisSpeedJitter', 0, 1, 0.01, 'fall-time jitter'], + ['debrisLead', 0, 3, 0.01, 'first chunk delay'], + ['debrisWindow', 0, 6, 0.01, 'calving window'], + ['debrisFillBias', -1, 1, 0.01, 'rim-in / middle-out'], + ['debrisFillScatter', 0, 1, 0.01, 'order scatter'], + ['debrisHashCell', 0.1, 6, 0.05, 'hash lattice'], + ['debrisRoughness', 0.05, 1, 0.01, 'chunk roughness'], + ['colorDebris', 'chunk colour'] + ], + 'Dust & pebbles': [ + ['dustRate', 0, 500, 1, 'dust rate'], + ['dustSize', 0.05, 5, 0.01, 'dust size'], + ['dustSpeed', 0, 10, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 10, 0.05, 'dust lifetime'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustRise', -2, 5, 0.01, 'dust rise'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['blastDust', 0, 600, 1, 'blast dust'], + ['blastSpeed', 0, 30, 0.1, 'blast speed'], + ['blastLift', 0, 1, 0.01, 'blast lift'], + ['pebbleRate', 0, 400, 1, 'pebble rate'], + ['pebbleSize', 0.005, 0.4, 0.005, 'pebble size'], + ['pebbleSpeed', 0, 10, 0.05, 'pebble speed'], + ['pebbleLifetime', 0.1, 4, 0.05, 'pebble lifetime'], + ['pebbleGravity', -40, 0, 0.1, 'pebble gravity'], + ['pebbleBand', 0.01, 1, 0.01, 'pebble band'], + ['colorDust*', 'Dust colour'], + ['colorPebble*', 'Pebble colour'] + ], + 'The moment it lets go': [ + ['burstSize', 0.2, 14, 0.05, 'dust ball size'], + ['burstIntensity', 0, 4, 0.01, 'dust ball intensity'], + ['shockRadius', 0.5, 30, 0.1, 'shockwave radius'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['settleShake', 0, 0.5, 0.005, 'settle rumble'], + ['rumble', 0, 0.5, 0.005, 'reach rumble'], + ['impactFlash', 0, 1, 0.005, 'screen flash'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst core'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/skyfracture.js b/src/config/abilities/skyfracture.js new file mode 100644 index 0000000..b3166b8 --- /dev/null +++ b/src/config/abilities/skyfracture.js @@ -0,0 +1,501 @@ +/* ================================================================== */ +/* SKYFRACTURE — reverse-ordered cause and effect */ +/* ================================================================== */ +/** + * The sky splits along the aimed line — and the floor knows about it first. + * + * ### The two gaps are the ability + * + * `skyGap` and `pressureGap` are the only two numbers in this block that + * matter more than the colours, and they are at the top for that reason. + * + * ``` + * the floor ─────█████████████████████████████████████████████ + * the sky ──────────────── skyGap ────█████████████████████ + * the pressure ───────────────────────────── pressureGap ───████ + * ``` + * + * Beat one: a white fracture pattern inks itself across the floor with + * *nothing above it* and no sound, no shake, no dust. Beat two, `skyGap` + * seconds later: the sky opens along the same shape. Beat three, + * `pressureGap` after that: the front arrives and the floor finally reacts. + * Set both gaps to zero and this is a competent lightning ability. Set them to + * 0.4 and 0.3 and it is the only slot in the sandbox where the effect precedes + * its cause, which is worth more than any amount of shader. + * + * ### The same pattern, and how it stays the same + * + * The two fractures are *literally the same fracture*, one translated + * downward and flattened. Both are `FilamentPaths` in `CRACK` mode, both are + * given the identical seed, the identical trunk direction and the identical + * eight branch parameters, and the branch geometry in that mode is a pure + * function of those. Everything under **The fracture** below is therefore + * shared by both copies deliberately — that sharing *is* the design, in the + * sense `docs/EXPANSION.md` I5 allows. Two `crackAngle` sliders would let the + * floor show a shape the sky never makes, which is the one failure this + * ability cannot survive. + * + * What the copies are allowed to disagree about is everything that is not + * shape: width, glow, flicker and all four colours. A reflection is dimmer and + * colder than the thing it reflects, and it should be a separate set of + * pickers. + * + * ### Why the fracture is horizontal, and has one height slider + * + * There is a single `skyHeight` and no far-end height. Tilting the sky crack + * would tilt its trunk direction, `CRACK` builds each branch's frame off that + * direction, and the floor copy — which is horizontal by construction — + * would immediately start drawing a *different* fracture. One slider is not a + * simplification; it is the constraint that keeps the trick true. + * + * `skyHeight` also wants to be lower than instinct says. The sandbox's camera + * sits about seven metres up and eleven back with a 46° field of view, so a + * fracture at twenty metres is a fracture nobody sees. 7.5 m puts it across + * the top of the frame. + * + * ### No `filamentLook()` / `shellDefaults()` import + * + * Both would be the tidy way to write the bottom two-thirds of this file, and + * both are unusable here: `Shell.js` imports `config/settings.js`, + * `settings.js` spreads `ABILITY_SETTINGS` out of `config/abilities/index.js`, + * and `index.js` imports this module. `scripts/check.mjs` loads + * `abilities/registry.js` first, which walks that cycle and evaluates + * `settings.js` while `ABILITY_SETTINGS` is still in its temporal dead zone — + * it throws before an ability is constructed. So the `wave*` block is spelled + * out, and this module keeps the promise the header of `index.js` makes: **a + * settings module imports nothing.** + */ +export const skyfracture = { + /* --- the cast --- */ + range: 26.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 46.0, // how fast the floor pattern inks itself, metres/second + lifetime: 0.5, // seconds everything holds after the pressure front finishes + fadeTime: 1.5, // seconds it all heals over + cooldown: 1.5, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* ================================================================ */ + /* THE TWO GAPS — the whole ability. Read the block comment. */ + /* ================================================================ */ + skyGap: 0.42, // seconds the SKY waits after the floor pattern has landed + skyDraw: 0.14, // seconds the sky fracture takes to tear itself open + pressureGap: 0.28, // seconds the PRESSURE waits after the sky has opened + pressureRise: 0.6, // seconds the pressure front takes to reach full radius + + /* ================================================================ */ + /* The fracture — SHARED by the floor copy and the sky copy */ + /* ================================================================ */ + // Every key in this group feeds both `FilamentPaths` instances with the same + // number. Change one and both patterns change together, which is the point. + crackCount: 14, // filaments; filament 0 is always the trunk + crackAngle: 0.62, // radians a branch leaves its parent by + crackLengthFrac: 0.5, // branch length as a fraction of its parent's + crackDepthFalloff: 0.62, // extra shortening per generation + crackSpread: 0.85, // ± fraction of variation on the branch angle + crackStart: 0.18, // 0..1 earliest point on a parent a fork may happen + crackForkBias: 0.5, // 0..1 slides the branch/twig split + crackLead: 1.2, // metres in front of the caster the trunk starts + crackRestrike: 0.02, // whole re-shapes per second. Near zero: a crack is not + // lightning, it does not re-roll — and both copies must re-roll on the same + // clock or they stop being the same crack for a frame at a time. + crackJitter: 0.34, // metres of kink on a branch + crackJitterScale: 0.55, // kinks per metre + crackOctaves: 4, // 1..5 octaves of value noise + crackJitterFalloff: 0.52, // amplitude kept per octave + crackCrawl: 0.35, // how fast the kinks slide along the path + crackPinch: 0.12, // 0..1 of the path the kink eases in over at each end + groundHeight: 0.03, // metres above the floor the flattened copy is clamped to + groundClearance: 0.6, // metres of safety margin on the projection sink — see + // `SkyfractureAbility#_sync`. The floor copy is placed far *below* the floor + // and clamped up onto it, which is how it becomes an exact plan projection of + // the sky copy rather than an approximation of one. + + /* --- the sky copy --- */ + skyHeight: 7.5, // metres above the floor the fracture opens at + skySag: 1.1, // metres each segment bows; y only, so it cannot alter the plan + skyTipGlow: 3.2, // heat on the tearing edge while it opens + skyTipLength: 0.05, // length of that edge, fraction of the path + skyWidth: 0.05, // half-width of one filament, metres + skyGlowWidth: 7.0, // halo half-width, × the core width + skyGlowOpacity: 0.5, // halo alpha relative to the core + skyFlicker: 0.22, // 0..1 depth of the whole-fracture brightness stutter + skyFlickerSpeed: 26, // steps per second that stutter is quantised to + skyStrandFlash: 0.35, // 0..1 depth of the per-filament blink + skyCoreSharp: 4.6, // exponent on the core's edge falloff + skyGlowFalloff: 2.2, // the same for the halo + skySoftFade: 0.9, // metres of depth fade against the opaque scene + skyOpacity: 1.0, + skyGlow: 3.0, // emissive gain into bloom + colorSkyCore: '#ffffff', // the white-hot centre line + colorSkyInner: '#e6ecff', + colorSkyOuter: '#c0d0ff', + colorSkyHalo: '#1a2a4a', // the wide, dim atmosphere + + /* --- the floor copy: the shadow and the reflection --- */ + floorTipGlow: 1.6, // heat on the inking front + floorTipLength: 0.07, // length of that front, fraction of the path + floorWidth: 0.035, // half-width of one filament, metres + floorGlowWidth: 5.0, // halo half-width, × the core width + floorGlowOpacity: 0.32, + floorFlicker: 0.1, // a reflection is steadier than the thing it reflects + floorFlickerSpeed: 18, + floorStrandFlash: 0.18, + floorCoreSharp: 5.2, // thinner than the sky's: it is lying on stone + floorGlowFalloff: 2.6, + floorSoftFade: 0.35, // metres of depth fade — small, it is on the floor + floorOpacity: 0.9, + floorGlow: 1.5, // emissive gain before the sky opens + floorSkyBoost: 0.9, // extra glow once there is genuinely something to reflect + colorFloorCore: '#ffffff', + colorFloorInner: '#d8e4ff', + colorFloorOuter: '#8fa8e0', + colorFloorHalo: '#101c33', + + /* --- the wet stone under it (GroundField, WET) --- */ + // Not the pattern — the *pool of light* the pattern is lying in. WET is + // alpha-blended rather than additive, so the floor genuinely darkens and goes + // glossy under the fracture instead of brightening, which is what makes the + // white filaments read as a reflection in something rather than as paint on + // top of something. + wetSpan: 1.15, // patch radius as a fraction of half the cast length + wetMargin: 1.6, // metres added to that radius + wetEdge: 0.55, // metres of feather on the growth front + wetRagged: 0.3, // how far that front wanders, fraction of the radius + wetRaggedScale: 0.55, // lobes per metre + wetWarp: 0.7, // metres of domain warp on those lobes + wetRelief: 0.5, // how hard the height field tilts the fake normal + wetNormalStep: 0.06, // metres between the height taps + wetAmbient: 0.22, // floor on the diffuse term + wetWrap: 0.4, // 0..1 wraps the terminator round the back + wetSpecular: 0.9, // high: this is standing water + wetGloss: 46, // Blinn exponent + wetParallax: 0.25, // metres of view-driven offset on interior detail + wetCell: 0.5, // metres — puddle scale + wetLift: 0.006, // metres — surface ripple height + wetDepth: 0.03, // metres — how deep the puddles read + wetDetail: 0.55, // 0..1 ripple strength + wetSpeed: 0.6, // ripple rate + wetFlow: 0.12, // metres/second the surface drifts + wetWind: 0.6, // radians — drift bearing in the quad's frame + wetEmissive: 0.7, // multiplier on every glowing term + wetOpacity: 0.7, + wetDepthFade: 0.5, // metres of soft fade against standing geometry + colorWetBase: '#141b26', // the soaked stone + colorWetEdge: '#9fb4d8', // the sheen + colorWetGlow: '#c0d0ff', // anything emissive in it + colorWetDeep: '#070a10', // the deepest part of a puddle + + /* ================================================================ */ + /* The pressure front — one `Shell`, prefix `wave`, mode PRESSURE */ + /* ================================================================ */ + + /* --- the expansion --- */ + waveRadius: 1.2, // radius at t = 0, metres + waveRadiusEnd: 16, // radius at t = 1, metres + waveExpand: 4.2, // easing exponent: 1 − (1−t)^expand, >1 = fast then easing out + waveHeight: 0.55, // axial extent, × radius (squashed, so it washes outward) + waveSpan: 6, // CONE length / RING_TRAIN run, metres — inert in PRESSURE + waveLift: 3.2, // metres up the axis the front is centred — it comes from above + + /* --- the surface --- */ + waveDisplace: 0.06, // billow along the normal, × radius + waveNoiseScale: 1.2, // billow features per unit radius + waveNoiseSpeed: 0.5, // Hz the billow crawls at + waveTurbulence: 0.8, // master on the billow + + /* --- shading --- */ + waveFill: 0.02, // how much body the shell keeps, 0 = rim only + waveRim: 1.6, // strength of the fresnel rim + waveRimPower: 3.4, // how tight that rim is + waveSeal: 1.4, // DOME only — inert here + waveSealWidth: 0.12, // DOME only — inert here + waveEdge: 1.2, // CONE only — inert here + waveEdgeWidth: 0.16, // CONE only — inert here + waveConeCurve: 1.0, // CONE only — inert here + waveDissolve: 1.2, // how hard the age dissolve bites, 0 = fade evenly + + /* --- RING_TRAIN: inert in PRESSURE, but `Shell#sync` reads them all --- */ + waveRings: 10, // live instance count + waveSpacing: 1.6, // metres between launched rings + waveRingSpeed: 7.0, // metres/second they travel at + waveRingThickness: 0.16, // radial thickness of one ring, metres + waveRingSharp: 1.6, // how hard its profile falls off + waveReflect: 1.0, // 0 = rings die at the far end, 1 = perfect reflection + waveStanding: 1.0, // how much of the standing envelope modulates them + waveSwell: 0.45, // extra radius at an antinode, × radius + + /* --- SUNDISC: also inert here --- */ + waveCoronaReach: 1.8, // how far past the rim the disc is drawn, × radius + waveCorona: 1.3, // brightness of the filaments licking off the rim + waveCoronaLength: 0.55, // how far they reach, × radius + waveCoronaScale: 5.0, // filament features per radius + waveCoronaWarp: 0.45, // domain warp — the thing that stops them being spokes + waveCoronaSpeed: 0.7, // Hz they crawl at + waveCoronaSharp: 0.72, // threshold: low fills the corona in and it reads as fog + waveGranule: 0.45, // convection cells across the disc face + waveGranuleScale: 6.0, // cells per radius + waveRimWidth: 0.18, // hot band inside the rim, × radius + + /* --- rendering --- */ + waveOpacity: 0.85, + waveGlow: 1.2, // emissive gain into bloom + waveSoftFade: 0.8, // metres of depth fade against the opaque scene + + /* --- colour (I5: four pickers, none derived from another) --- */ + waveColorBody: '#1a2a4a', // the body of the shell + waveColorRim: '#c0d0ff', // the fresnel rim + waveColorEdge: '#ffffff', // the hottest mark it has + waveColorCorona: '#8fa8e0', // SUNDISC only — inert here + + /* --- dust, kicked off the floor by beat three and by nothing else --- */ + dustRate: 220, // particles/second while the front is crossing + dustSize: 0.9, // metres + dustSpeed: 3.2, // metres/second + dustLifetime: 2.0, // seconds + dustRise: 0.6, // upward drift, metres/second + dustOpacity: 0.16, + colorDustA: '#6b7688', // birth + colorDustB: '#4a5567', + colorDustC: '#333d4c', + colorDustD: '#161c25', // death + + /* --- glints falling out of the sky crack --- */ + glintRate: 90, // particles/second once the fracture is open + glintSize: 0.07, // metres + glintSpeed: 2.2, // metres/second + glintLifetime: 1.6, // seconds + glintFall: -2.2, // gravity, metres/second² (negative: they come down) + glintTurbulence: 0.6, + glintBurst: 120, // one-shot on the frame the sky opens + colorGlintA: '#ffffff', // birth + colorGlintB: '#e6ecff', + colorGlintC: '#8fa8e0', + colorGlintD: '#1a2a4a', // death + + /* --- feedback --- */ + skyFlash: 0.34, // full-screen flash on the frame the sky opens + colorSkyFlash: '#dfe8ff', + pressureShake: 0.9, // camera kick when the front arrives + shakeDuration: 0.7, // seconds it rings for + rumble: 0.0, // continuous shake while beat one is inking. DELIBERATELY ZERO: + // beat one has to be silent or the reversal does not land. It is a slider so + // that you can hear for yourself how much worse a rumbling beat one is. + + /* --- dynamic light --- */ + lightIntensity: 14, + lightRadius: 16, + lightColor: '#c0d0ff' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Sky Fracture. + * + * 'The order of events' is first because it is the ability. Drag `skyGap` to + * zero and watch a good effect become an ordinary one; drag it to 1.2 and + * watch it become a joke. Somewhere around 0.35–0.5 the floor has had just + * long enough to be unsettling. + * + * 'The fracture' holds every number that both copies share. If the floor and + * the sky ever stop matching, the cause is in that folder or in `crackRestrike` + * specifically — both copies re-roll their branch hashes on the same clock, and + * they have to. + */ +export const skyfractureSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'floor ink speed'], + ['lifetime', 0.05, 4, 0.01, 'hold after the front'], + ['fadeTime', 0.1, 6, 0.01, 'heal time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The order of events': [ + ['skyGap', 0, 2, 0.005, 'floor → sky gap (s)'], + ['skyDraw', 0.01, 1.5, 0.005, 'sky tear time (s)'], + ['pressureGap', 0, 2, 0.005, 'sky → pressure gap (s)'], + ['pressureRise', 0.05, 3, 0.01, 'pressure rise (s)'] + ], + 'The fracture (shared by both copies)': [ + ['crackCount', 1, 24, 1, 'filaments'], + ['crackAngle', 0, 1.6, 0.005, 'branch angle (rad)'], + ['crackLengthFrac', 0.05, 1, 0.005, 'branch length'], + ['crackDepthFalloff', 0.05, 1.5, 0.005, 'per-generation falloff'], + ['crackSpread', 0, 2, 0.01, 'angle variation'], + ['crackStart', 0, 0.95, 0.005, 'earliest fork'], + ['crackForkBias', 0, 1, 0.01, 'branch / twig split'], + ['crackLead', -2, 6, 0.01, 'trunk start (m)'], + ['crackRestrike', 0.001, 12, 0.001, 're-shapes / sec'], + ['crackJitter', 0, 2, 0.005, 'kink amplitude (m)'], + ['crackJitterScale', 0.02, 4, 0.01, 'kinks / metre'], + ['crackOctaves', 1, 5, 1, 'octaves'], + ['crackJitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['crackCrawl', -8, 8, 0.01, 'kink crawl'], + ['crackPinch', 0.01, 0.5, 0.005, 'end pinch'], + ['groundHeight', 0.001, 0.4, 0.001, 'floor copy height (m)'], + ['groundClearance', 0, 8, 0.05, 'projection margin (m)'] + ], + 'The sky copy': [ + ['skyHeight', 1, 30, 0.05, 'fracture height (m)'], + ['skySag', -6, 6, 0.01, 'segment bow (m)'], + ['skyTipGlow', 0, 8, 0.05, 'tearing-edge glow'], + ['skyTipLength', 0.005, 0.4, 0.005, 'tearing-edge length'], + ['skyWidth', 0.002, 0.4, 0.001, 'filament width (m)'], + ['skyGlowWidth', 1, 20, 0.1, 'halo width'], + ['skyGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['skyFlicker', 0, 1, 0.01, 'brightness stutter'], + ['skyFlickerSpeed', 1, 90, 1, 'stutter rate'], + ['skyStrandFlash', 0, 1, 0.01, 'filament blink'], + ['skyCoreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['skyGlowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['skySoftFade', 0.02, 4, 0.01, 'soft fade (m)'], + ['skyOpacity', 0, 2, 0.01, 'opacity'], + ['skyGlow', 0, 8, 0.01, 'glow'], + ['colorSkyCore', 'sky core'], + ['colorSkyInner', 'sky inner'], + ['colorSkyOuter', 'sky outer'], + ['colorSkyHalo', 'sky halo'] + ], + 'The floor copy': [ + ['floorTipGlow', 0, 8, 0.05, 'inking-front glow'], + ['floorTipLength', 0.005, 0.4, 0.005, 'inking-front length'], + ['floorWidth', 0.002, 0.4, 0.001, 'filament width (m)'], + ['floorGlowWidth', 1, 20, 0.1, 'halo width'], + ['floorGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['floorFlicker', 0, 1, 0.01, 'brightness stutter'], + ['floorFlickerSpeed', 1, 90, 1, 'stutter rate'], + ['floorStrandFlash', 0, 1, 0.01, 'filament blink'], + ['floorCoreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['floorGlowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['floorSoftFade', 0.02, 4, 0.01, 'soft fade (m)'], + ['floorOpacity', 0, 2, 0.01, 'opacity'], + ['floorGlow', 0, 8, 0.01, 'glow before the sky opens'], + ['floorSkyBoost', 0, 6, 0.01, 'extra glow once it opens'], + ['colorFloorCore', 'floor core'], + ['colorFloorInner', 'floor inner'], + ['colorFloorOuter', 'floor outer'], + ['colorFloorHalo', 'floor halo'] + ], + 'Wet stone': [ + ['wetSpan', 0, 3, 0.01, 'radius × half-length'], + ['wetMargin', 0, 12, 0.05, 'radius margin (m)'], + ['wetEdge', 0.01, 4, 0.01, 'front feather (m)'], + ['wetRagged', 0, 1.5, 0.01, 'front wander'], + ['wetRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['wetWarp', 0, 4, 0.01, 'domain warp (m)'], + ['wetRelief', 0, 3, 0.01, 'relief'], + ['wetNormalStep', 0.005, 0.5, 0.005, 'normal step (m)'], + ['wetAmbient', 0, 1, 0.01, 'ambient'], + ['wetWrap', 0, 1, 0.01, 'terminator wrap'], + ['wetSpecular', 0, 3, 0.01, 'specular'], + ['wetGloss', 1, 128, 1, 'gloss'], + ['wetParallax', 0, 2, 0.01, 'parallax (m)'], + ['wetCell', 0.05, 4, 0.01, 'puddle scale (m)'], + ['wetLift', 0, 0.1, 0.0005, 'ripple height (m)'], + ['wetDepth', 0, 0.5, 0.001, 'puddle depth (m)'], + ['wetDetail', 0, 1, 0.01, 'ripple strength'], + ['wetSpeed', 0, 6, 0.01, 'ripple rate'], + ['wetFlow', 0, 3, 0.01, 'drift (m/s)'], + ['wetWind', 0, 6.2832, 0.01, 'drift bearing (rad)'], + ['wetEmissive', 0, 4, 0.01, 'emissive'], + ['wetOpacity', 0, 1, 0.01, 'opacity'], + ['wetDepthFade', 0, 3, 0.01, 'soft fade (m)'], + ['colorWetBase', 'soaked stone'], + ['colorWetEdge', 'sheen'], + ['colorWetGlow', 'glow'], + ['colorWetDeep', 'puddle interior'] + ], + 'The pressure front/The shell': [ + ['waveRadius', 0.01, 20, 0.01, 'start radius (m)'], + ['waveRadiusEnd', 0.01, 60, 0.01, 'end radius (m)'], + ['waveExpand', 0.2, 12, 0.01, 'expansion curve'], + ['waveHeight', 0.02, 4, 0.01, 'height × radius'], + // Deliberately wider than `shellSchema`'s −2..2: this front is centred + // several metres up, because it is arriving from a fracture overhead. + ['waveLift', -2, 16, 0.01, 'centre height (m)'], + ['waveDisplace', 0, 1.5, 0.01, 'billow'], + ['waveNoiseScale', 0.1, 10, 0.01, 'billow scale'], + ['waveNoiseSpeed', 0, 4, 0.01, 'billow Hz'], + ['waveTurbulence', 0, 3, 0.01, 'turbulence'] + ], + 'The pressure front/Shading': [ + ['waveFill', 0, 1, 0.01, 'body fill'], + ['waveRim', 0, 3, 0.01, 'rim'], + ['waveRimPower', 0.1, 8, 0.01, 'rim power'], + ['waveDissolve', 0, 2, 0.01, 'dissolve'], + ['waveOpacity', 0, 1, 0.01, 'opacity'], + ['waveGlow', 0, 8, 0.01, 'glow'], + ['waveSoftFade', 0, 3, 0.01, 'soft fade (m)'], + ['waveColorBody', 'shell body'], + ['waveColorRim', 'shell rim'], + ['waveColorEdge', 'shell edge'] + ], + /** + * `Shell#sync` writes every field it knows about regardless of the mode the + * material was compiled for, so leaving these out would mean twenty-three + * silent fallbacks to module defaults — values the editor cannot reach, which + * is the one way `Shell.js` can be used to break I1. Present and labelled + * beats absent and defaulted. The pause test will call them dead; it is + * right, and they wake up if the shell is ever recompiled to another mode. + */ + 'The pressure front/Inert in PRESSURE mode': [ + ['waveSpan', 0.1, 40, 0.1, 'CONE length (m)'], + ['waveSeal', 0, 4, 0.01, 'DOME floor seal'], + ['waveSealWidth', 0.01, 1, 0.01, 'DOME seal width'], + ['waveEdge', 0, 4, 0.01, 'CONE lip'], + ['waveEdgeWidth', 0.01, 1, 0.01, 'CONE lip width'], + ['waveConeCurve', 0.1, 4, 0.01, 'CONE curve'], + ['waveRings', 1, 24, 1, 'TRAIN rings'], + ['waveSpacing', 0.1, 8, 0.01, 'TRAIN spacing (m)'], + ['waveRingSpeed', 0, 40, 0.1, 'TRAIN speed (m/s)'], + ['waveRingThickness', 0.01, 2, 0.01, 'TRAIN thickness (m)'], + ['waveRingSharp', 0.05, 8, 0.01, 'TRAIN sharpness'], + ['waveReflect', 0, 1, 0.01, 'TRAIN reflection'], + ['waveStanding', 0, 1, 0.01, 'TRAIN standing'], + ['waveSwell', 0, 2, 0.01, 'TRAIN swell'], + ['waveCoronaReach', 1, 4, 0.01, 'DISC corona reach'], + ['waveCorona', 0, 4, 0.01, 'DISC corona'], + ['waveCoronaLength', 0, 2, 0.01, 'DISC corona length'], + ['waveCoronaScale', 0.1, 20, 0.1, 'DISC corona scale'], + ['waveCoronaWarp', 0, 2, 0.01, 'DISC corona warp'], + ['waveCoronaSpeed', 0, 4, 0.01, 'DISC corona Hz'], + ['waveCoronaSharp', 0, 1, 0.01, 'DISC corona threshold'], + ['waveGranule', 0, 2, 0.01, 'DISC granulation'], + ['waveGranuleScale', 0.1, 20, 0.1, 'DISC granule scale'], + ['waveRimWidth', 0, 1, 0.01, 'DISC rim width'], + ['waveColorCorona', 'DISC corona colour'] + ], + 'Dust & glints': [ + ['dustRate', 0, 900, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 14, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['colorDust*', 'Dust colour'], + ['glintRate', 0, 600, 1, 'glint rate'], + ['glintSize', 0.005, 0.5, 0.005, 'glint size'], + ['glintSpeed', 0, 12, 0.05, 'glint speed'], + ['glintLifetime', 0.1, 6, 0.05, 'glint lifetime'], + ['glintFall', -30, 5, 0.1, 'glint gravity'], + ['glintTurbulence', 0, 3, 0.01, 'glint turbulence'], + ['glintBurst', 0, 600, 1, 'glints on the tear'], + ['colorGlint*', 'Glint colour'] + ], + Feedback: [ + ['skyFlash', 0, 2, 0.01, 'flash on the tear'], + ['colorSkyFlash', 'flash colour'], + ['pressureShake', 0, 3, 0.01, 'pressure shake'], + ['shakeDuration', 0.05, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.3, 0.001, 'beat-one rumble (keep at 0)'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/slipstream.js b/src/config/abilities/slipstream.js new file mode 100644 index 0000000..078d244 --- /dev/null +++ b/src/config/abilities/slipstream.js @@ -0,0 +1,436 @@ +/* ================================================================== */ +/* SLIPSTREAM — the vacuum blade */ +/* ================================================================== */ +/** + * A blade of vacuum drawn along the aimed line. + * + * This is the most restrained block in the sandbox and it is meant to be. The + * ability draws a hairline thread and *nothing else*: everything you actually + * read is the scene behind the blade sliding sideways, written into the + * refraction buffer by one `DistortionField(BLADE)`. There is no shell, no + * spark system, no ground burn. If you find yourself reaching for one, the two + * sliders you actually wanted are `bladeHeight` and `refractStrength`. + * + * ### The two numbers that decide everything + * + * `bladeHeight` is how deep the sheet of disturbed air is, in metres, and + * `refractStrength` is how hard it bends what is behind it, in **screen widths + * at `post.distortion = 1`**. Not metres — a fragment writing `1` shifts the + * frame under it by a whole `post.distortion` of screen width at any distance, + * which is what makes an authored strength mean the same thing on a cast that + * lands two metres away and one that lands twenty. Neither of the two master + * gains (`post.distortion`, `global.distortion`) is folded in here; the pass + * applies both, once. + * + * ### Metres, not fractions + * + * `DistortionField` expresses the hairline and the wake as fractions of the + * blade's height, which is the right contract for a general emitter and the + * wrong one for an ability: dragging `bladeHeight` would then scale the + * hairline with it and the blade would read as the *same* blade, bigger. + * `refractEdgeWidth` and `refractWakeDepth` below are therefore in metres and + * `SlipstreamAbility#_sync` divides. Drag the height and the hairline stays + * put; that is the difference between a blade getting deeper and a blade being + * zoomed. + * + * ### Why the eighty `thread*` keys are spelled out and not spread + * + * `Tube.js` ships `tubeDefaults('thread', TubePath.STRAIGHT, …)` precisely so + * a block like this one can be four lines, and `src/vfx/README.md` recommends + * it. **It cannot be used here.** `Tube.js` imports `config/settings.js`, + * `settings.js` spreads `ABILITY_SETTINGS` out of `config/abilities/index.js`, + * and `index.js` imports this module — so importing the tube closes an ESM + * evaluation cycle. Node walks it depth-first from whichever root loads first; + * `scripts/check.mjs` imports `abilities/registry.js` on its first line, which + * reaches `index.js` → this module → `Tube.js` → `settings.js`, and + * `settings.js` then evaluates its body while `ABILITY_SETTINGS` is still in + * its temporal dead zone. It throws before a single ability is constructed. So + * the keys are written out, which also restores the rule the header of + * `index.js` states plainly: **a settings module imports nothing.** + * + * The four path-specific groups at the end (WHIP / FUNNEL / VINE / ARC) are + * present but inert. `Tube#sync` reads every field it knows about regardless of + * the path the material was compiled for, so leaving them out means thirty + * silent fallbacks to module defaults — values the editor cannot reach, which + * is the one way `Tube.js` can be used to break I1. Present-and-dead is + * honest; absent-and-defaulted is not. The pause test will list them as sliders + * that do nothing, and it will be right. + * + * ### Why the thread is a whole `Tube` + * + * Three draw calls for a 1.6 cm filament looks extravagant until you look at + * one without its halo. The first pass drew the cutting edge as a single + * additive quad strip and at ten metres it simply stopped existing — a + * one-pixel line has no bloom footprint and the tone mapper eats it. The halo + * layer is what keeps a hairline legible at range, and it is the same argument + * the bolt's glow pass makes. + */ +export const slipstream = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 2.0, // closer than this and the cast is refused + speed: 88.0, // how fast the cutting front travels, metres/second + lifetime: 0.18, // seconds the cut holds wide open before it starts to close + fadeTime: 0.85, // seconds the disturbance takes to heal over + cooldown: 0.7, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the plane of vacuum --- */ + // The blade is a rectangle standing in the air with its cutting edge along + // the top. `bladeHeight` is measured *down* from that edge, because the edge + // is the thing the player is aiming and the wake is what hangs off it. + bladeHeight: 2.1, // metres of sheet hanging below the cutting edge — HEADLINE + cutHeight: 1.34, // metres above the floor the edge leaves the hand + endHeight: 1.05, // metres above the floor the edge reaches at the target + bladeLead: 0.6, // metres in front of the caster the edge starts + bladeSide: 0.14, // metres to the side (+ follows `Ability#side`) + + /* --- the refraction --- */ + // Screen fractions, never metres. See the block comment. + // Arithmetic worth writing down, because it is the difference between a + // vacuum blade and a broken renderer. The pass multiplies by + // `post.distortion` (0.045) × `global.distortion` (1), so the body at 0.5 + // shifts the frame behind it by 0.5 × 0.045 ≈ **2.3% of screen width** — + // about forty pixels at 1080p, which is a shear you cannot miss and cannot + // mistake for a bug. The hairline runs at (1 + edgeGain) = 2.6× that inside a + // 9 cm band, and the cut beat adds `refractCut` on top, peaking near 13% of + // screen width for about a tenth of a second. The first pass shipped 0.95 / + // 1.5 / 3.4 and the cut peaked past a third of the screen — at which point + // the frame tears rather than bends and the whole thing reads as a + // post-processing failure. + refractStrength: 0.5, // how hard the body of the blade bends the scene — HEADLINE + refractCut: 0.7, // extra strength during the cut beat, added on top + refractCutSharp: 2.6, // how fast that spike decays over the hold, exponent + refractGrazing: 1.25, // exponent on the edge-on path length + refractEdgeWidth: 0.09, // metres of hairline concentration at the cutting edge + refractEdgeGain: 1.6, // how much harder that hairline warps than the body + refractWakeDepth: 1.8, // metres of the sheet the wake survives to + refractRipple: 0.22, // break-up of the surface normal, 0 = a perfect mirror + refractRippleScale: 1.15, // ripple cycles per metre + refractRippleSpeed: 2.1, // metres/second the ripple crawls + refractOpacity: 1.0, // coverage — who wins where two emitters overlap + refractDepthReject: 1.0, // 0..1 how hard the floor and the body occlude it + refractDepthFade: 0.4, // metres that occlusion feathers over + refractPerspective: 0.0, // 0 = a screen fraction, 1 = shrinks with distance + refractPerspectiveRef: 12.0, // metres at which perspective = 1 + + /* ================================================================ */ + /* The hairline thread — one `Tube`, prefix `thread`, path STRAIGHT */ + /* ================================================================ */ + + /* --- the radius profile --- */ + threadRadius: 0.016, // half-width at the far end, metres + threadRadiusNear: 0.007, // half-width at the muzzle, metres + threadRadiusCurve: 0.55, // <1 opens early, >1 stays thin then opens late + threadFlare: 0, // extra half-width where it lands, × radius (a cut does not bell) + threadFlareWidth: 0.12, // how much of the far end flares, fraction of length + threadThrob: 0.02, // breathing amplitude, × radius + threadThrobScale: 3.4, // pressure waves along the column, cycles per length + threadThrobSpeed: 2.2, // Hz they travel at + + /* --- the axis --- */ + threadWander: 0.015, // smooth low-frequency drift of the axis, metres + threadWanderScale: 1.4, // drift features per length + threadWanderSpeed: 0.9, // Hz the drift crawls at + + /* --- the surface --- */ + threadRipple: 0.05, // radial break-up of the barrel, × radius + threadRippleBands: 1.0, // break-up features around the barrel + threadRippleScale: 6.0, // break-up features along it + threadRippleSpeed: 3.2, // Hz it crawls downrange at + threadStreak: 0.35, // filaments streaming down the surface + threadStreakSharp: 0.6, // 0 = soft wash, 1 = hard threads + threadStreakScale: 14.0, // filament features per length + threadStreakBands: 1.4, // filament features around the barrel + threadStreakGlow: 1.4, // how hard the sheath's filaments burn to core colour + threadFlowSpeed: 18.0, // metres-of-parameter per second the filaments run + threadBands: 0.0, // rings along the length, cycles per length (0 = off) + threadBandSharp: 2.0, // how tight each ring is + threadBandDepth: 0.5, // how much they modulate alpha, 0..1 + threadBandSpeed: 0.6, // Hz they travel at + + /* --- the three layers --- */ + threadCoreWidth: 0.5, // core radius, × the profile + threadCoreFill: 1.0, // how solid the core reads + threadCoreSharp: 1.1, // axis-weighting exponent — the inversion + threadEdgePower: 2.6, // rim-weighting exponent for the sheath + threadSheathWidth: 1.15, // sheath radius, × the profile + threadSheathRim: 0.55, // strength of the sheath's silhouette + threadSheathFill: 0.1, // how much body the sheath keeps + threadSheathOpacity: 0.55, + threadHaloWidth: 6.5, // halo radius, × the profile — this is what carries it at range + threadHaloRim: 3.2, // rim exponent — high, so it is only a silhouette + threadHaloOpacity: 0.22, + + /* --- the ends --- */ + threadMuzzleGlow: 0.8, // brightness where the thread leaves the caster + threadMuzzleLength: 0.06, // how far that glow reaches, fraction of length + threadTipGlow: 3.2, // brightness of the leading edge — the brightest thing on screen + threadTipLength: 0.03, // how soft that edge is, fraction of length + + /* --- rendering --- */ + threadOpacity: 1.0, + threadGlow: 3.4, // emissive gain into bloom + threadSoftFade: 0.25, // metres of depth fade against the opaque scene + + /* --- colour (I5: four pickers, none derived from another) --- */ + threadColorCore: '#ffffff', // the axis-weighted middle + threadColorInner: '#eaf6ff', // just off the middle + threadColorOuter: '#9fd8ff', // the sheath body + threadColorHalo: '#123048', // the outer bloom + + /* --- inert: WHIP. Read by `Tube#sync`, unused by a STRAIGHT program. --- */ + threadWaveRate: 1.35, // loops per second travelling handle → tip + threadWaveWidth: 0.16, // how tight the loop is, fraction of length + threadWaveAmp: 0.3, // lateral throw of the loop, fraction of length + threadWaveGain: 2.2, // how much the loop grows on its way to the tip, × + threadWaveCurve: 1.6, // when that growth happens, >1 = late + threadWaveRoll: 0.0, // plane the loop cracks in, radians (0 = vertical) + threadSag: 0.12, // how far the whip hangs under its own weight, metres + threadCrackRatio: 1.0, // tip speed ÷ wave speed at which the crack fires + + /* --- inert: FUNNEL --- */ + threadThroat: 0.55, // the vortex waist, metres + threadSkirtFlare: 1.6, // extra radius at the floor, metres + threadSkirtHeight: 0.24, // how far up the skirt reaches, fraction of height + threadSkirtCurve: 1.7, // how abruptly it flares, >1 = tighter to the floor + threadMouthFlare: 2.4, // extra radius at the top, metres + threadMouthStart: 0.55, // where the mouth begins to open, fraction of height + threadMouthCurve: 1.4, // how abruptly it opens + threadSpin: 0.9, // revolutions per second the surface rotates + threadSpinTwist: 1.6, // extra revolutions from floor to mouth + threadSway: 0.35, // how far the axis precesses, metres + threadSwayScale: 0.5, // twist of the precession along the height + threadSwaySpeed: 0.25, // revolutions per second it precesses + threadSwayCurve: 1.8, // how much of the sway is at the top, >1 = only the top + + /* --- inert: VINE --- */ + threadTipTaper: 1.3, // how fast the radius falls to zero at the front + threadMeander: 0.18, // helical wander of the stem, metres + threadMeanderTurns: 1.4, // turns of that helix over the length + threadRecoilAmp: 0.35, // how far the spring pulls the tip back, fraction + threadRecoilFreq: 2.6, // Hz the spring rings at + threadRecoilDamp: 3.4, // s⁻¹ it dies at + threadRecoilSway: 0.6, // lateral bow while it is recoiling, metres + + /* --- inert: ARC --- */ + threadArcHeight: 2.6, // apex height above the chord, metres + threadArcLateral: 0.0, // apex offset across the chord, metres + threadArcBias: 0.5, // where the apex sits, 0..1 along the chord + threadArcCurve: 1.0, // >1 pinches the apex, <1 flattens the top + + /* --- the line of disturbed dust --- */ + /** + * The one particle system, and it is deliberately anaemic. Air that has been + * cut open pulls dust *in* toward the seam and then lets it drift, so these + * are emitted along the part of the line already cut, in a hairline column, + * with almost no speed. At the shipped rate a whole cast is under a hundred + * live particles. Turning `dustRate` up is the fastest way to make this + * ability look like every other one. + */ + dustRate: 55, // motes drawn into the seam, particles/second + dustSize: 0.055, // metres + dustSpeed: 0.5, // metres/second they drift at + dustLifetime: 1.5, // seconds + dustRise: 0.35, // upward drift, metres/second + dustSpread: 0.1, // metres of scatter around the cut line + dustTurbulence: 0.55, // curl-noise strength + dustCut: 60, // extra motes released on the cut beat, one-shot + colorDustA: '#e8f4ff', // birth + colorDustB: '#a8bccc', + colorDustC: '#6c7d8c', + colorDustD: '#232b33', // death + + /* --- feedback --- */ + cutFlash: 0.07, // full-screen flash on the cut — barely there, on purpose + colorCutFlash: '#dff0ff', + cutShake: 0.22, // camera kick on the cut + shakeDuration: 0.35, // seconds it rings for + rumble: 0.012, // continuous shake while the blade is travelling + + /* --- dynamic light --- */ + // A vacuum does not glow. This exists only so the thread has something to + // sit against on a dark floor; at 4.5 it is a suggestion, not a lamp. + lightIntensity: 4.5, + lightRadius: 7.0, + lightColor: '#bfe4ff' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Slipstream. + * + * Reach for `bladeHeight` and `refractStrength` first — between them they are + * the whole ability. `refractGrazing` is the one that is not obvious: a razor- + * thin slab shifts what is behind it along its own surface normal, and the + * shift grows as your view runs *along* the slab, because that is where the + * path through it is longest. The exponent on that term is why you can see a + * vacuum blade at all, and pushing it up makes the blade vanish when you orbit + * to look squarely at its face — which is correct, and worth doing once. + * + * The tube's folders are nested under 'The hairline' rather than sitting at + * the top level. `tubeSchema()` names them 'The column', 'The surface' and so + * on; dropped straight in they sit next to 'The blade' and read as a second, + * larger thing the ability draws, which is precisely the misreading this slot + * cannot afford. + */ +export const slipstreamSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 300, 1, 'cut speed'], + ['lifetime', 0.02, 3, 0.01, 'cut hold'], + ['fadeTime', 0.05, 4, 0.01, 'heal time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The blade': [ + ['bladeHeight', 0.1, 8, 0.01, 'blade depth (m)'], + ['cutHeight', 0, 4, 0.01, 'edge height at hand (m)'], + ['endHeight', 0, 4, 0.01, 'edge height at target (m)'], + ['bladeLead', -1, 4, 0.01, 'start offset (m)'], + ['bladeSide', -2, 2, 0.01, 'lateral offset (m)'] + ], + Refraction: [ + ['refractStrength', 0, 4, 0.01, 'refract strength'], + ['refractCut', 0, 6, 0.01, 'cut spike'], + ['refractCutSharp', 0.2, 8, 0.05, 'cut spike decay'], + ['refractGrazing', 0.1, 6, 0.01, 'grazing exponent'], + ['refractEdgeWidth', 0.005, 1, 0.005, 'hairline width (m)'], + ['refractEdgeGain', 0, 12, 0.05, 'hairline gain'], + ['refractWakeDepth', 0.02, 8, 0.01, 'wake depth (m)'], + ['refractRipple', 0, 1.5, 0.01, 'surface ripple'], + ['refractRippleScale', 0.05, 8, 0.01, 'ripple cycles/m'], + ['refractRippleSpeed', -8, 8, 0.01, 'ripple speed (m/s)'], + ['refractOpacity', 0, 1, 0.01, 'coverage'], + ['refractDepthReject', 0, 1, 0.01, 'occlusion'], + ['refractDepthFade', 0.01, 3, 0.01, 'occlusion feather (m)'], + ['refractPerspective', 0, 1, 0.01, 'shrink with distance'], + ['refractPerspectiveRef', 1, 40, 0.1, 'perspective ref (m)'] + ], + + /* --- the tube, folder for folder as `tubeSchema('thread', STRAIGHT)` --- */ + 'The hairline/The column': [ + ['threadRadius', 0.002, 1, 0.001, 'far radius (m)'], + ['threadRadiusNear', 0.002, 1, 0.001, 'near radius (m)'], + ['threadRadiusCurve', 0.05, 4, 0.01, 'radius curve'], + ['threadFlare', 0, 4, 0.01, 'flare'], + ['threadFlareWidth', 0.01, 1, 0.01, 'flare width'], + ['threadThrob', 0, 0.5, 0.001, 'throb'], + ['threadThrobScale', 0, 12, 0.1, 'throb bands'], + ['threadThrobSpeed', 0, 8, 0.01, 'throb Hz'], + ['threadWander', 0, 1, 0.001, 'axis drift (m)'], + ['threadWanderScale', 0, 6, 0.01, 'drift scale'], + ['threadWanderSpeed', 0, 4, 0.01, 'drift Hz'] + ], + 'The hairline/Core / sheath / halo': [ + ['threadCoreWidth', 0.02, 2, 0.01, 'core width'], + ['threadCoreFill', 0, 2, 0.01, 'core fill'], + ['threadCoreSharp', 0.05, 8, 0.01, 'core axis power'], + ['threadEdgePower', 0.05, 8, 0.01, 'sheath rim power'], + ['threadSheathWidth', 0.05, 3, 0.01, 'sheath width'], + ['threadSheathRim', 0, 2, 0.01, 'sheath rim'], + ['threadSheathFill', 0, 1, 0.01, 'sheath fill'], + ['threadSheathOpacity', 0, 1, 0.01, 'sheath opacity'], + ['threadHaloWidth', 0.05, 12, 0.01, 'halo width'], + ['threadHaloRim', 0.05, 10, 0.01, 'halo rim power'], + ['threadHaloOpacity', 0, 1, 0.01, 'halo opacity'] + ], + 'The hairline/The surface': [ + ['threadRipple', 0, 1, 0.01, 'ripple'], + ['threadRippleBands', 0, 8, 0.01, 'ripple bands'], + ['threadRippleScale', 0, 12, 0.01, 'ripple scale'], + ['threadRippleSpeed', 0, 10, 0.01, 'ripple Hz'], + ['threadStreak', 0, 2, 0.01, 'streaks'], + ['threadStreakSharp', 0, 1, 0.01, 'streak sharpness'], + ['threadStreakScale', 0, 30, 0.1, 'streak scale'], + ['threadStreakBands', 0, 8, 0.01, 'streak bands'], + ['threadStreakGlow', 0, 3, 0.01, 'streak glow'], + ['threadFlowSpeed', 0, 40, 0.1, 'flow speed'], + ['threadBands', 0, 24, 0.1, 'rings/length'], + ['threadBandSharp', 0.05, 8, 0.01, 'ring sharpness'], + ['threadBandDepth', 0, 1, 0.01, 'ring depth'], + ['threadBandSpeed', -6, 6, 0.01, 'ring Hz'] + ], + 'The hairline/The ends': [ + ['threadMuzzleGlow', 0, 5, 0.01, 'muzzle glow'], + ['threadMuzzleLength', 0, 0.6, 0.001, 'muzzle length'], + ['threadTipGlow', 0, 8, 0.01, 'tip glow'], + ['threadTipLength', 0.001, 0.4, 0.001, 'tip length'] + ], + 'The hairline/Colour & render': [ + 'threadColorCore', + 'threadColorInner', + 'threadColorOuter', + 'threadColorHalo', + ['threadOpacity', 0, 1, 0.01, 'opacity'], + ['threadGlow', 0, 8, 0.01, 'glow'], + ['threadSoftFade', 0, 3, 0.01, 'soft fade (m)'] + ], + /** + * The four groups `Tube#sync` writes and a STRAIGHT program never samples. + * They are here so nothing lands in the trailing "More" folder, and they are + * labelled so nobody spends five minutes wondering why 'loop throw' does + * nothing. Compile the tube for another path and they wake up. + */ + 'The hairline/Inert on a straight tube': [ + ['threadWaveRate', 0, 6, 0.01, 'WHIP loops/second'], + ['threadWaveWidth', 0.02, 0.6, 0.001, 'WHIP loop width'], + ['threadWaveAmp', 0, 1, 0.001, 'WHIP loop throw'], + ['threadWaveGain', 0.2, 6, 0.01, 'WHIP loop gain'], + ['threadWaveCurve', 0.1, 6, 0.01, 'WHIP gain curve'], + ['threadWaveRoll', 0, 6.2832, 0.01, 'WHIP crack plane (rad)'], + ['threadSag', 0, 2, 0.01, 'WHIP sag (m)'], + ['threadCrackRatio', 0.2, 4, 0.01, 'WHIP crack ratio'], + ['threadThroat', 0.02, 4, 0.01, 'FUNNEL throat (m)'], + ['threadSkirtFlare', 0, 8, 0.01, 'FUNNEL skirt flare (m)'], + ['threadSkirtHeight', 0.01, 1, 0.01, 'FUNNEL skirt height'], + ['threadSkirtCurve', 0.1, 6, 0.01, 'FUNNEL skirt curve'], + ['threadMouthFlare', 0, 12, 0.01, 'FUNNEL mouth flare (m)'], + ['threadMouthStart', 0, 0.99, 0.01, 'FUNNEL mouth start'], + ['threadMouthCurve', 0.1, 6, 0.01, 'FUNNEL mouth curve'], + ['threadSpin', -6, 6, 0.01, 'FUNNEL spin (rev/s)'], + ['threadSpinTwist', -8, 8, 0.01, 'FUNNEL twist'], + ['threadSway', 0, 4, 0.01, 'FUNNEL precession (m)'], + ['threadSwayScale', 0, 3, 0.01, 'FUNNEL precession twist'], + ['threadSwaySpeed', -3, 3, 0.01, 'FUNNEL precession (rev/s)'], + ['threadSwayCurve', 0.1, 6, 0.01, 'FUNNEL precession curve'], + ['threadTipTaper', 0.05, 6, 0.01, 'VINE tip taper'], + ['threadMeander', 0, 2, 0.01, 'VINE meander (m)'], + ['threadMeanderTurns', 0, 8, 0.01, 'VINE meander turns'], + ['threadRecoilAmp', 0, 1, 0.01, 'VINE recoil'], + ['threadRecoilFreq', 0, 10, 0.01, 'VINE recoil Hz'], + ['threadRecoilDamp', 0.1, 16, 0.01, 'VINE recoil damping'], + ['threadRecoilSway', 0, 4, 0.01, 'VINE recoil bow (m)'], + ['threadArcHeight', -12, 12, 0.01, 'ARC apex height (m)'], + ['threadArcLateral', -12, 12, 0.01, 'ARC apex offset (m)'], + ['threadArcBias', 0.05, 0.95, 0.01, 'ARC apex position'], + ['threadArcCurve', 0.1, 4, 0.01, 'ARC apex curve'] + ], + + 'Disturbed dust': [ + ['dustRate', 0, 400, 1, 'dust rate'], + ['dustSize', 0.005, 0.4, 0.005, 'dust size'], + ['dustSpeed', 0, 6, 0.01, 'dust speed'], + ['dustLifetime', 0.1, 6, 0.05, 'dust lifetime'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustSpread', 0.005, 1.5, 0.005, 'scatter (m)'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['dustCut', 0, 400, 1, 'dust on the cut'], + ['colorDust*', 'Dust colour'] + ], + Feedback: [ + ['cutFlash', 0, 1, 0.005, 'screen flash'], + ['colorCutFlash', 'flash colour'], + ['cutShake', 0, 2, 0.01, 'cut shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration'], + ['rumble', 0, 0.3, 0.001, 'travel rumble'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/snare.js b/src/config/abilities/snare.js new file mode 100644 index 0000000..9ca64c4 --- /dev/null +++ b/src/config/abilities/snare.js @@ -0,0 +1,460 @@ +/* ================================================================== */ +/* SNARE — ability five, and the first **far cast** */ +/* ================================================================== */ +/** + * A trap planted at a point rather than a shot fired along a line: the caster + * whips a leash of current out across the floor, and where it lands the ring + * snaps open — a column of lightning tears up out of the middle, tendrils + * crawl outward to the boundary and arcs run around the rim, all of it + * holding, re-striking and dragging the air upward for `lifetime` before it + * collapses. Reference for the look: `electricalboost.jpg`. + * + * This is the block that defines what a far cast *is* in this project. The + * targeting is a circle (see the `zone` block) and `zoneRadius` is the promise + * that circle makes: the boundary the indicator draws is the boundary the + * field burns, the tendrils reach and the rim arcs run along, so dragging that + * one number re-scales the indicator and a snare that is already standing + * together. + * + * The whole cage is **one instanced strip** — see `materials/SnareMaterial.js`. + * Every filament is the same ribbon, and a *role* decided from its instance + * index (leash → column → tendril → rim) picks which parametric path the + * vertex shader threads it along. Two draw calls for all four, however many + * filaments are in the air. + * + * As in every other block, a cast captures nothing but a seed and a few + * timestamps. Every metre, radian and second is resolved against these numbers + * each frame — including a zero-length one, which is why the trap reshapes + * under the sliders with the clock stopped. + */ +export const snare = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 0.0, // a trap can legitimately be dropped on your own feet + zoneRadius: 4.4, // the footprint — what the circle indicator measures out + speed: 62.0, // how fast the leash races to the point, metres/second + snapTime: 0.16, // seconds the ring takes to slam open once it lands + lifetime: 2.6, // seconds the snare stands + fadeTime: 0.75, // seconds it takes to collapse + cooldown: 1.4, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the leash that plants it --- */ + // Thrown from a hand, so these are measured from the caster's origin in the + // cast's own frame, exactly as the bolt and the rock are. + handHeight: 1.24, // metres above the floor + handForward: 0.58, // metres in front of the caster + handSide: 0.18, // metres to the side (+ follows `Ability#side`) + leashStrands: 3, // filaments in the whip + leashSag: -0.35, // metres the mid-span bows (negative drops it to the floor) + leashSpread: 0.22, // how far the filaments separate, metres + leashKink: 0.3, // kink amplitude on the whip, metres + leashWidth: 1.0, // × the shared filament width + leashCling: 0.12, // how far above the floor the tip runs, metres + + /* --- the column --- */ + strands: 15, // filaments in the pillar + height: 9.2, // how high it reaches, metres + heightCurve: 1.45, // <1 gets it up fast, >1 makes it climb late + throat: 0.16, // radius where it leaves the floor, × zoneRadius + columnSpread: 0.25, // radius at the top, × zoneRadius + columnCurve: 2.88, // >1 keeps the throat tight then opens it late + columnFlare: 0.585, // extra opening over the last quarter, × zoneRadius + columnTwist: 0.22, // turns a filament makes over the climb + columnSpin: 1.26, // turns/second the whole pillar rolls + columnKink: 0.27, // kink amplitude, metres + columnWidth: 1.86, // × the shared filament width + columnTaper: 1.09, // how much thinner the top is than the base + + /* --- the tendrils crawling out to the boundary --- */ + tendrils: 20, // separate ground filaments (capped with the rest at 56) + tendrilInner: 0.0, // where they leave the column, × zoneRadius + tendrilReach: 1.07, // where they end, × zoneRadius (1 = exactly on the band) + tendrilCurve: 1.18, // <1 throws them outward early + tendrilWander: 1.41, // radians a tendril veers over its run + tendrilArch: 1.16, // metres it hops off the floor mid-span + tendrilHug: 0.005, // how far above the floor it runs, metres + tendrilSpin: -0.225, // turns/second the whole fan rotates + tendrilKink: 0.72, // kink amplitude, metres + tendrilWidth: 0.75, // × the shared filament width + tendrilDim: 0.8, // how much dimmer than the column + + /* --- the arcs running around the rim --- */ + rimArcs: 14, // arcs on the boundary at once + rimSpan: 0.335, // fraction of the circle one arc covers + rimSpeed: -1.84, // revolutions/second they travel + // High enough to clear the burnt band underneath them: an arc that hops + // 0.3 m over a band this bright is simply invisible. + rimHeight: 0.98, // metres they hop at mid-span + rimJitter: 0.23, // radial wobble, × zoneRadius + rimKink: 0.15, // kink amplitude, metres + rimWidth: 0.85, // × the shared filament width + rimDim: 1.0, + + /* --- the shape every filament shares --- */ + // The same piecewise-linear value noise the bolt uses — linear on purpose, + // because smoothstep rounds the corners off and the corners are the entire + // reason it reads as lightning. + jitter: 1.0, // master multiplier on the four per-role kink amplitudes + jitterScale: 1.4, // kinks per metre + octaves: 4, // 1–5; each halves the amplitude and doubles the rate + jitterFalloff: 0.55, // amplitude kept per octave + crawl: 2.4, // how fast the kinks slide along a filament + pinch: 0.16, // fraction of the span the ends are pulled straight over + restrike: 21, // times/second every filament re-rolls its shape + flicker: 0.26, // depth of the whole-cage brightness stutter + flickerSpeed: 30, + strandFlash: 0.45, // how much individual filaments blink out + + /* --- the ribbon --- */ + width: 0.032, // half-width of a filament, metres + coreSharp: 4.4, // how hard the hot core falls off across the ribbon + glowWidth: 6.2, // the halo, × the core width + glowFalloff: 2.3, // how fast the halo fades across its ribbon + glowOpacity: 0.44, + softFade: 0.7, // metres of soft fade where a filament meets geometry + + /* --- colour --- */ + // Violet rather than the Storm Lance's blue: two electric abilities on the + // bar need to be told apart at a glance, and the hue split does it before + // the silhouette does. + colorCore: '#ffffff', // the centre of a filament + colorInner: '#dcd0ff', + colorOuter: '#8f6bff', // the outside of a filament + colorHalo: '#2a0e8c', // the wide glow around the cage + glow: 2.2, // overall emissive gain + opacity: 1.0, + + /* --- the field burnt into the floor --- */ + /** + * The indicator's promise, made real: the same circle, the same thick + * boundary, now a live shader instead of a targeting aid. It is an + * ability-owned mesh rather than a decal precisely because a decal captures + * its radius when it spawns — this one has to re-scale under `zoneRadius` + * while it is standing. + */ + fieldBoundary: 0.02, // thickness of the burnt band, metres + fieldBoundaryGlow: 2.9, + fieldFill: 0.65, // the wash inside it + fieldFalloff: 3.6, // how hard that wash crowds to the rim + fieldVeins: 2.98, // filaments burnt across the disc + fieldVeinScale: 2.0, // veins per metre + fieldVeinSharp: 0.72, // 0 = a wash, 1 = hard threads + fieldWarp: 0.55, // domain warp — what stops the veins reading as spokes + fieldCrawl: 0.5, // how fast they writhe + fieldRings: 2.4, // pressure rings travelling out from the middle + fieldRingSpeed: 0.8, // rings/second + fieldSpokes: 20, // ticks stepping around the boundary + fieldSpokeLength: 0.5, // how far they reach in, metres + fieldSpin: 0.05, // revolutions/second the ticks step around + fieldCore: 1.3, // brightness of the pool the column stands in + fieldCoreSize: 0.22, // its radius, × zoneRadius + fieldPulse: 0.0, // brightness breathing + fieldPulseSpeed: 3.95, + fieldOpacity: 1.0, + fieldHeight: 0.03, // hover distance above the floor, metres + colorField: '#8f6bff', // the wash and the veins + colorFieldEdge: '#ffffff', // the boundary band and the core pool + + /* --- what else the ground does --- */ + arcRate: 5.0, // branching burns laid around the rim, per second + arcRadius: 1.2, // radius of one, metres + arcLife: 0.75, + arcIntensity: 0.9, + arcBranches: 0.7, // how finely a burn splits into filaments + trailRate: 1.1, // burns laid per metre while the leash races out + scorchRadius: 1.6, // dark burn under the column, metres + scorchLife: 7.5, + scorchIntensity: 0.5, + colorArc: '#c3b0ff', + colorEmber: '#8f6bff', + colorScorch: '#0b0813', + shockRadius: 7.0, // the ring that snaps out when the trap opens, metres + colorShockA: '#8f6bff', // body of the shockwave ring + colorShockB: '#ffffff', // its crest + + /* --- sparks, updraft, smoke and debris --- */ + /** + * As in every other block: a four-stop gradient sampled over the particle's + * own lifetime, `A` at birth through `D` as it dies. The **updraft** is this + * ability's signature system — motes drawn off the whole disc and hauled + * inward and up into the column, which is the read that says the trap is + * pulling on the air rather than just sitting in it. + */ + sparkRate: 320, // sparks thrown off the cage, particles/second + sparkSize: 0.15, + sparkSpeed: 8.5, + sparkLifetime: 0.55, + sparkGravity: -13.0, + sparkStretch: 0.2, // how far a spark smears along its velocity + colorSparkA: '#ffffff', + colorSparkB: '#dcd0ff', + colorSparkC: '#8f6bff', + colorSparkD: '#2a0e8c', + updraftRate: 210, // motes hauled up the column, particles/second + updraftSize: 0.07, + updraftSpeed: 6.0, // how fast they are pulled in + updraftLifetime: 1.4, + updraftRise: 5.5, // upward acceleration once they are inside, m/s² + updraftInset: 0.15, // how far inside the boundary they are picked up + updraftTurbulence: 0.9, + colorUpdraftA: '#8f6bff', + colorUpdraftB: '#dcd0ff', + colorUpdraftC: '#ffffff', + colorUpdraftD: '#1b0a5e', + smokeRate: 70, // haze scoured off the burnt floor + smokeSize: 1.05, + smokeSpeed: 1.2, + smokeLifetime: 2.4, + smokeOpacity: 0.06, + smokeRise: 0.6, + colorSmokeA: '#4a4368', + colorSmokeB: '#3a3554', + colorSmokeC: '#2b2740', + colorSmokeD: '#191728', + debrisRate: 30, // chips torn off the floor inside the ring + debrisSize: 0.055, + debrisSpeed: 5.5, + debrisLifetime: 1.3, + debrisGravity: -17.0, + colorDebrisA: '#2a2733', + colorDebrisB: '#201e28', + colorDebrisC: '#1a1822', + colorDebrisD: '#1a1822', + + /* --- dynamic light --- */ + lightIntensity: 24, + lightRadius: 18, + lightHeight: 0.38, // how far up the column the light sits, 0..1 + lightColor: '#a98bff', + lightFlicker: 0.38, // depth of the light's gutter, 0 = steady + lightFlickerSpeed: 24, + + /* --- the throw, the snap and the hold --- */ + muzzleSize: 0.5, // the flash at the hand as the leash leaves it + muzzleIntensity: 1.7, + castFlash: 0.09, // screen flash on release + colorCastFlash: '#c3b0ff', + burstSize: 2.8, // the shell thrown off when the ring opens, metres + burstIntensity: 1.5, + burstSparks: 200, // extra sparks at the snap + burstDebris: 60, + pulseRate: 1.5, // pressure shells shed off the column while it holds, /s + pulseSize: 1.2, // radius of one, metres + pulseIntensity: 0.5, + ringRate: 1.4, // dust rings pushed across the floor while it holds, /s + impactShake: 0.85, + shakeDuration: 0.6, + holdShake: 0.07, // continuous rumble while the snare stands + impactFlash: 0.26, + rumble: 0.025, // rumble while the leash races out + colorBurstA: '#8f6bff', + colorBurstB: '#dcd0ff', + colorBurstC: '#ffffff', + colorFlash: '#c3b0ff' // the full-screen flash when it snaps open +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Voltaic Snare — the first far cast. + * + * `zoneRadius` is the control that matters most here and the only one that + * reaches outside the ability: it is read by the circle indicator *and* by + * the tendrils, the rim arcs and the burnt field, so dragging it re-scales + * what you aim with and what you get at the same time. After that, + * `snapTime` and `height` carry the moment the trap opens, and `tendrils` / + * `rimArcs` / `strands` decide how much of the footprint is actually lit. + */ +export const snareSchema = { + 'The cast': [ + ['zoneRadius', 0.5, 14, 0.05, 'footprint radius'], + ['range', 2, 50, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 300, 1, 'leash speed'], + ['snapTime', 0.02, 1.5, 0.01, 'snap-open time'], + ['lifetime', 0.1, 12, 0.05, 'hold time'], + ['fadeTime', 0.05, 4, 0.01, 'collapse time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The leash': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['leashStrands', 0, 6, 1, 'filaments'], + ['leashSag', -3, 3, 0.01, 'mid-span bow'], + ['leashSpread', 0, 2, 0.01, 'fan'], + ['leashCling', 0, 1.5, 0.01, 'height at the tip'], + ['leashKink', 0, 2, 0.01, 'kink amplitude'], + ['leashWidth', 0.1, 4, 0.01, 'ribbon width'] + ], + 'The column': [ + ['strands', 0, 16, 1, 'filaments'], + ['height', 0.5, 24, 0.1, 'height'], + ['heightCurve', 0.1, 4, 0.01, 'climb curve'], + ['throat', 0.005, 1, 0.005, 'throat, × footprint'], + ['columnSpread', 0.01, 1, 0.005, 'top, × footprint'], + ['columnCurve', 0.1, 5, 0.01, 'opening curve'], + ['columnFlare', 0, 1, 0.005, 'top flare'], + ['columnTwist', -4, 4, 0.01, 'twist over height'], + ['columnSpin', -4, 4, 0.01, 'spin'], + ['columnKink', 0, 2, 0.01, 'kink amplitude'], + ['columnWidth', 0.1, 6, 0.01, 'ribbon width'], + ['columnTaper', 0.05, 2, 0.01, 'taper to the top'] + ], + 'The tendrils': [ + ['tendrils', 0, 20, 1, 'tendrils'], + ['tendrilInner', 0, 1, 0.005, 'start, × footprint'], + ['tendrilReach', 0.05, 1.6, 0.01, 'end, × footprint'], + ['tendrilCurve', 0.1, 4, 0.01, 'reach curve'], + ['tendrilWander', 0, 4, 0.01, 'veer'], + ['tendrilArch', 0, 3, 0.01, 'hop off the floor'], + ['tendrilHug', 0.005, 1, 0.005, 'floor clearance'], + ['tendrilSpin', -2, 2, 0.005, 'fan rotation'], + ['tendrilKink', 0, 2, 0.01, 'kink amplitude'], + ['tendrilWidth', 0.05, 4, 0.01, 'ribbon width'], + ['tendrilDim', 0, 1, 0.01, 'dim vs the column'] + ], + 'The rim arcs': [ + ['rimArcs', 0, 14, 1, 'arcs'], + ['rimSpan', 0.01, 1, 0.005, 'arc span, × the circle'], + ['rimSpeed', -3, 3, 0.01, 'travel speed'], + ['rimHeight', 0, 3, 0.01, 'hop height'], + ['rimJitter', 0, 1, 0.01, 'radial wobble'], + ['rimKink', 0, 2, 0.01, 'kink amplitude'], + ['rimWidth', 0.05, 4, 0.01, 'ribbon width'], + ['rimDim', 0, 1, 0.01, 'dim vs the column'] + ], + 'Filaments & flicker': [ + ['jitter', 0, 4, 0.01, 'kink master'], + ['jitterScale', 0.05, 8, 0.01, 'kinks / metre'], + ['octaves', 1, 5, 1, 'octaves'], + ['jitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['crawl', -20, 20, 0.1, 'kink crawl'], + ['pinch', 0.01, 0.5, 0.005, 'end pinch'], + ['restrike', 0.5, 90, 0.5, 'restrikes / sec'], + ['flicker', 0, 1, 0.01, 'brightness stutter'], + ['flickerSpeed', 1, 120, 1, 'stutter rate'], + ['strandFlash', 0, 1, 0.01, 'filament blink'] + ], + 'The ribbon & colour': [ + ['width', 0.005, 0.4, 0.001, 'filament width'], + ['coreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['glowWidth', 1, 30, 0.1, 'halo width'], + ['glowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['glowOpacity', 0, 2, 0.01, 'halo opacity'], + ['softFade', 0.02, 3, 0.01, 'soft intersection'], + ['glow', 0, 8, 0.01, 'glow'], + ['opacity', 0, 2, 0.01, 'opacity'], + ['colorCore', 'core'], + ['colorInner', 'inner'], + ['colorOuter', 'outer'], + ['colorHalo', 'halo'] + ], + 'The field on the floor': [ + ['fieldBoundary', 0.02, 2, 0.01, 'band thickness'], + ['fieldBoundaryGlow', 0, 8, 0.05, 'band glow'], + ['fieldFill', 0, 2, 0.01, 'interior fill'], + ['fieldFalloff', 0.1, 5, 0.05, 'fill falloff'], + ['fieldVeins', 0, 3, 0.01, 'burnt veins'], + ['fieldVeinScale', 0.1, 8, 0.05, 'veins / metre'], + ['fieldVeinSharp', 0, 1, 0.01, 'vein sharpness'], + ['fieldWarp', 0, 2, 0.01, 'domain warp'], + ['fieldCrawl', -4, 4, 0.01, 'vein crawl'], + ['fieldRings', 0, 12, 0.1, 'pressure rings'], + ['fieldRingSpeed', -6, 6, 0.01, 'ring speed'], + ['fieldSpokes', 0, 96, 1, 'boundary ticks'], + ['fieldSpokeLength', 0.05, 3, 0.01, 'tick length'], + ['fieldSpin', -2, 2, 0.005, 'tick spin'], + ['fieldCore', 0, 4, 0.01, 'centre pool'], + ['fieldCoreSize', 0.02, 1, 0.005, 'pool size, × footprint'], + ['fieldPulse', 0, 1, 0.01, 'pulse'], + ['fieldPulseSpeed', 0, 10, 0.05, 'pulse speed'], + ['fieldOpacity', 0, 2, 0.01, 'opacity'], + ['fieldHeight', 0.005, 0.4, 0.005, 'hover height'], + ['colorField', 'field'], + ['colorFieldEdge', 'band & pool'] + ], + 'Burns on the ground': [ + ['arcRate', 0, 30, 0.1, 'rim burns / sec'], + ['arcRadius', 0.1, 8, 0.05, 'burn radius'], + ['arcLife', 0.05, 5, 0.05, 'burn lifetime'], + ['arcIntensity', 0, 3, 0.01, 'burn intensity'], + ['arcBranches', 0, 3, 0.01, 'branch detail'], + ['trailRate', 0.05, 8, 0.05, 'leash burns / metre'], + ['scorchRadius', 0.05, 8, 0.05, 'scorch radius'], + ['scorchLife', 0.5, 20, 0.1, 'scorch lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['ringRate', 0, 12, 0.1, 'dust rings / sec'], + ['colorArc', 'burn'], + ['colorEmber', 'ember'], + ['colorScorch', 'scorch'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'Sparks & updraft': [ + ['sparkRate', 0, 1200, 1, 'spark rate'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['updraftRate', 0, 900, 1, 'updraft rate'], + ['updraftSize', 0.005, 0.4, 0.005, 'updraft size'], + ['updraftSpeed', 0, 25, 0.1, 'pull-in speed'], + ['updraftLifetime', 0.1, 8, 0.05, 'updraft lifetime'], + ['updraftRise', -5, 25, 0.1, 'lift'], + ['updraftInset', 0, 0.95, 0.01, 'pick-up inset'], + ['updraftTurbulence', 0, 3, 0.01, 'updraft swirl'], + ['colorSpark*', 'Spark colour'], + ['colorUpdraft*', 'Updraft colour'] + ], + 'Smoke & debris': [ + ['smokeRate', 0, 500, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['debrisRate', 0, 300, 1, 'debris rate'], + ['debrisSize', 0.005, 0.4, 0.005, 'debris size'], + ['debrisSpeed', 0, 25, 0.1, 'debris speed'], + ['debrisLifetime', 0.1, 5, 0.05, 'debris lifetime'], + ['debrisGravity', -50, 0, 0.1, 'debris gravity'], + ['colorSmoke*', 'Smoke colour'], + ['colorDebris*', 'Debris colour'] + ], + 'Throw, snap & hold': [ + ['muzzleSize', 0.05, 6, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['burstSize', 0.2, 14, 0.05, 'snap shell size'], + ['burstIntensity', 0, 5, 0.01, 'snap shell intensity'], + ['burstSparks', 0, 600, 1, 'snap sparks'], + ['burstDebris', 0, 300, 1, 'snap debris'], + ['pulseRate', 0, 12, 0.1, 'hold shells / sec'], + ['pulseSize', 0.1, 10, 0.05, 'hold shell size'], + ['pulseIntensity', 0, 5, 0.01, 'hold shell intensity'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['holdShake', 0, 0.5, 0.005, 'hold rumble'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorCastFlash', 'release flash colour'], + ['colorBurstA', 'shell'], + ['colorBurstB', 'shell body'], + ['colorBurstC', 'shell arcs'], + ['colorFlash', 'snap flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightHeight', 0, 1, 0.01, 'height up the column'], + ['lightFlicker', 0, 1, 0.01, 'light gutter'], + ['lightFlickerSpeed', 1, 90, 1, 'gutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/solarlens.js b/src/config/abilities/solarlens.js new file mode 100644 index 0000000..32cf3a8 --- /dev/null +++ b/src/config/abilities/solarlens.js @@ -0,0 +1,610 @@ +/* ================================================================== */ +/* SOLAR LENS — lumen, zone */ +/* ================================================================== */ +/** + * A burning glass, hung in the air over the aimed circle. + * + * An element climbs out to the zone, tips over, and pulls focus: the cone of + * light under it narrows from a soft wash to a point, and that point then + * *walks* a rosette across the floor, charring what it crosses. The anamorphic + * flare hanging off the burning point is depth-tested against the scene, so the + * character walking between the camera and the burn dims it. + * + * Four numbers carry the whole read and are the ones to reach for first: + * + * - `focusTight` — the half-angle of the cone once focus is pulled, radians. + * At 0.055 the spot is a coin and the floor smokes; at 0.3 it is a lamp and + * nothing burns. This is the difference between a magnifying glass and a + * torch, and it is one slider. + * - `walkPetals` — how many lobes the focus's rosette has. Whole numbers close + * the figure; 2.5 leaves it open and the burn never repeats itself. + * - `walkSpeed` — turns per second of the carrier angle. Slow is a brand, fast + * is a spirograph. + * - `flareOccRadius` — the screen-space disc the occlusion kernel averages + * over, as a fraction of frame height. Too small and the flare *switches* + * rather than dims as a silhouette crosses it; that is the failure the + * kernel exists to prevent, and this is its size. + * + * ### The four consumers of `zoneRadius` + * + * The walk's rosette, the char field the pits are recorded in, the caustic + * net's own extent and the ember scatter all measure themselves against + * `zoneRadius`, because they are four views of one footprint. Drag it while the + * lens is standing and the whole burn — including pits already on the floor, + * which are stored as *fractions* of it — rescales together. That sharing is + * the design (I5); nothing else here is derived from anything else. + * + * ### Why the element's forty-four keys are spelled out + * + * The element is `vfx/Shell.js` in `SUNDISC` mode under the `disc` prefix, and + * `Shell` reads its numbers by prefixed key straight off this block. The + * fragment is available in one line as `shellDefaults('disc', ShellMode.SUNDISC)` + * and is written out anyway, for the reason `vinelash` gives: a settings module + * must import nothing, because `vfx/Shell.js` imports `config/settings.js` and + * importing it back from here closes a cycle whose failure depends on which + * module a consumer happens to load first. (One is already latent in the tree — + * importing `vfx/GroundField.js` first in a bare Node process throws inside + * `vfx/Tube.js`. Do not add another.) It also means every key gets the trailing + * comment the house style asks for. + */ +export const solarlens = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 30.0, // how fast the element travels out to the zone, metres/second + zoneRadius: 5.2, // the footprint — what the circle indicator measures out + lifetime: 2.8, // seconds the lens burns for once it has arrived + fadeTime: 1.0, // seconds the element and the light die over + cooldown: 1.5, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the element hangs --- */ + // The element is not a projectile: it climbs as the front runs out, so by the + // time the circle is reached it is already overhead. `lensClimb` is the + // exponent on that rise — above 1 it stays low and snaps up at the end. + lensAltitude: 4.4, // metres above the floor the element settles at + lensLead: -0.9, // metres from the zone centre toward the caster + lensClimb: 1.7, // curve of the rise during travel; >1 climbs late + lensBob: 0.16, // metres of vertical drift once it is standing + lensBobSpeed: 0.32, // Hz of that drift + lensSway: 0.22, // metres of lateral drift + lensSwaySpeed: 0.21, // Hz of that + + /* --- pulling focus --- */ + focusTime: 0.6, // seconds the cone takes to narrow after arrival + focusCurve: 2.0, // exponent on that ramp; >1 dwells wide then snaps in + focusWide: 0.58, // cone half-angle before focus, radians + focusTight: 0.055, // ... and after. This is the burn. + focusLift: 0.07, // metres the flare's anchor sits above the floor + + /* --- the walk --- */ + // Everything here is unitless. The focus's position is a pure function of + // `age`, the seed and these six numbers, evaluated fresh every frame, so + // dragging any of them with the clock stopped *moves the burning point* + // rather than changing where it will go next. + walkSpeed: 0.21, // turns per second of the carrier angle + walkPetals: 2.5, // lobes in the rosette; whole numbers close the figure + walkInner: 0.14, // tightest radius, as a fraction of `zoneRadius` + walkOuter: 0.9, // widest radius, ditto + walkPhase: 0.0, // radians the rosette is rotated by + walkWobble: 0.045, // fraction of `zoneRadius` of secondary jitter + walkWobbleRate: 3.1, // Hz of that jitter + + /* --- the char it leaves (vfx/GroundField, POCK) --- */ + charMarksPerTurn: 34, // pits laid per turn of the walk — a rate, not a spacing + charMarkRadius: 0.34, // metres, one pit at full strength + charMarkLife: 8.0, // seconds a pit weathers away over + charDepth: 0.1, // metres the bowl sinks + charLift: 0.035, // metres the rim stands proud + charSharp: 0.62, // 0..1 how crisp the rim is + charDetail: 0.7, // 0..1 grain inside the bowl + charEdge: 0.3, // metres of feather on the field's own front + charRagged: 0.22, // how far that front wanders, as a fraction of the radius + charRaggedScale: 0.75, // lobes per metre + charWarp: 0.4, // metres of domain warp on those lobes + charRelief: 0.75, // how hard the height field tilts the fake normal + charNormalStep: 0.05, // metres between the height taps + charAmbient: 0.28, // floor on the diffuse term + charWrap: 0.45, // 0..1 wraps the terminator round the back + charSpecular: 0.22, // charcoal is not glossy + charGloss: 18, // Blinn exponent + charParallax: 0.28, // metres of view-driven offset on interior detail + charEmissive: 1.0, // multiplier on the ember term + charOpacity: 1.0, + charDepthFade: 0.4, // metres of soft fade against standing geometry + colorCharBase: '#2b231b', // the burnt floor itself + colorCharEdge: '#7a6448', // the raised lip of a pit + colorCharGlow: '#ff7b26', // the ember still alive in a fresh pit + colorCharDeep: '#0a0705', // the bottom of the bowl + + /* --- the focused light (vfx/Caustics, CONE) --- */ + // The net is a *fold* of a height field, not a pattern: `netDepth` times + // (`netIor` − 1) is the whole term, so at `netDepth: 0` there is nothing to + // fold and you are looking at `netRidgeMix` alone. Glass rather than water + // here — 1.62 and a fat dispersion, because a lens is what this is. + netRadius: 2.8, // metres — how far the fold pattern is drawn + netDepth: 0.85, // metres of "glass" the light came through + netIor: 1.62, // crown glass, not water + netDispersion: 0.22, // how far apart the three channels' folds sit + netSampleStep: 0.07, // metres between the Hessian taps + netAbsorb: 0.08, // attenuation through the medium + netFoldFloor: 0.18, // clamp on |det J| — how bright a fold may get + netThreshold: 1.05, // where the filament starts + netGain: 0.85, + netSharpness: 1.7, // how hairline the filament is + netRolloff: 0.18, + netAmp: 0.13, // height of the lattice the fold is taken of + netCellScale: 0.95, // cells per metre + netCellRatio: 1.63, // the second lattice against the first + netCellJitter: 0.85, // 0..1 how irregular the cells are + netDriftAngle: 0.7, // radians the lattices drift along + netDriftSpeed: 0.09, // metres/second + netBoil: 1.15, // how fast the lattice churns in place + netRidgeMix: 0.1, // the received worley-difference recipe, kept low + netRidgeScale: 2.4, + netRidgePower: 6, + netPenumbra: 0.3, // softness of the cone's own edge, fraction of its radius + netEmissive: 1.7, // multiplier on every glowing term + netOpacity: 1.0, + netWash: 0.24, // the general light in the pool, under the filaments + netFringeAt: 1.4, // where a fold is hot enough to go white + netDepthFade: 0.4, // metres of soft fade where the net meets a standing foot + colorNet: '#fff0c6', // the filaments + colorFringe: '#ffffff', // the very top of a fold + colorWash: '#c8791f', // the general light in the burn + + /* --- the flare (vfx/LensFlare) --- */ + // Anchored to the burning point, not to the element — the burn is the bright + // thing, and it is the thing the character can stand in front of. Occlusion + // is the whole reason this module is here: `flareOcclusion` at 0 is a sticker + // on the monitor, and worth setting once to see what the trick is worth. + flareIntensity: 1.6, + flareOpacity: 1.0, + flareHeadroom: 6.0, // linear value the hue-preserving shoulder asymptotes to + flareOcclusion: 1.0, // 0..1 master on the depth test + flareOccRadius: 0.032, // the source's apparent half-size, fraction of frame height + flareOccTaps: 7, // 1..9 taps in the disc kernel + flareOccFade: 0.7, // metres over which the depth compare feathers + flareOccSpin: 0.0, // radians the kernel is rotated off the pixel grid + flareEdgeStart: 0.85, // |ndc| where the flare starts fading out + flareEdgeEnd: 1.4, // ... and where it is gone + flareCoreSize: 0.022, // fraction of frame height + flareCoreGlow: 3.0, + flareBurstBlades: 6, // iris blades; an odd count throws twice as many spikes + flareBurstLength: 0.17, // spike reach, fraction of frame height + flareBurstSharp: 24, + flareBurstJitter: 0.35, // how uneven the spikes are + flareBurstSpin: 0.3, // radians + flareHaloSize: 0.055, + flareHaloWidth: 0.35, + flareHaloGlow: 0.85, + flareStreakLength: 0.36, // per side, as a fraction of frame WIDTH + flareStreakThickness: 0.013, // fraction of frame height + flareStreakFalloff: 2.2, + flareStreakTight: 9, + flareStreakGlow: 1.5, + flareStreakTilt: 0.0, // radians off horizontal + flareStreakGrain: 0.35, + flareStreakChroma: 0.6, + flareGhosts: 5, // how many of the eight built instances draw + flareGhostSpacing: 0.34, // first ghost, as a fraction of source→centre + flareGhostStride: 0.3, // added per ghost; negative walks back out + flareGhostScatter: 0.1, + flareGhostSize: 0.045, // fraction of frame height + flareGhostSizeStep: 0.88, // multiplied per ghost + flareGhostSizeScatter: 0.25, + flareGhostBlades: 6, // one iris, so one blade count for the whole train + flareGhostRound: 0.1, // 0 polygon, 1 disc + flareGhostRoundStep: 0.14, // the far ones defocus + flareGhostSpin: 0.5, // radians added per ghost + flareGhostFill: 0.3, + flareGhostRim: 0.9, + flareGhostRimWidth: 0.14, + flareGhostSoft: 0.22, + flareGhostChroma: 0.5, + flareGhostGlow: 1.0, + flareRing: 1.0, // 0 hides the wide iris ring + flareRingSpacing: 1.15, // fraction of the source→centre vector + flareRingSize: 0.26, + flareRingWidth: 0.03, + flareRingBlades: 6, + flareRingChroma: 1.0, + flareRingGlow: 0.5, + colorFlareCore: '#fff6e2', + colorFlareHalo: '#ffd9a0', + colorFlareStreak: '#ffe6b0', + colorFlareStreakEdge: '#ff7a1f', + colorFlareGhostA: '#ffd9a0', + colorFlareGhostB: '#ff9f6a', + colorFlareGhostC: '#8fe0c8', + colorFlareGhostD: '#9fb4ff', + colorFlareRing: '#ffd08a', + + /* --- the element (vfx/Shell, SUNDISC, prefix `disc`) --- */ + discRadius: 0.55, // half-width on arrival, metres + discRadiusEnd: 1.05, // ... once it is burning. The bloom as focus is pulled. + discExpand: 2.2, // easing exponent between those two + discHeight: 0.02, // metres the disc is lifted off its own plane + discSpan: 6, // metres — CONE/RING_TRAIN only; unread here + discLift: 0.03, // metres of extra lift on the rim + discDisplace: 0.04, // metres the surface is pushed about by noise + discNoiseScale: 2.2, // noise features per metre + discNoiseSpeed: 0.5, // how fast that noise churns + discTurbulence: 1.0, // multiplier on `discDisplace` + discFill: 0.85, // how solid the face reads + discRim: 0.5, // strength of the fresnel rim + discRimPower: 2.2, // how tight that rim is + discSeal: 1.4, // DOME only; unread here + discSealWidth: 0.12, // ditto + discEdge: 1.3, // brightness of the disc's own outline + discEdgeWidth: 0.13, // metres-ish width of it + discConeCurve: 1.0, // CONE only; unread here + discDissolve: 0.25, // how much of the face is eaten away by noise + discRings: 1, // RING_TRAIN only; one ring is the disc + discSpacing: 1.6, // ditto + discRingSpeed: 7, // ditto + discRingThickness: 0.16, // ditto + discRingSharp: 1.6, // ditto + discReflect: 1, // ditto + discStanding: 1, // ditto + discSwell: 0.45, // ditto + discCoronaReach: 1.7, // how far past the rim the corona geometry is drawn + discCorona: 1.5, // its brightness + discCoronaLength: 0.6, // how far the filaments actually reach + discCoronaScale: 5.5, // ridged-noise features across the plane + discCoronaWarp: 0.5, // domain warp on them — this is what stops it being spokes + discCoronaSpeed: 0.65, // how fast the corona licks + discCoronaSharp: 0.7, + discGranule: 0.4, // the mottle on the face + discGranuleScale: 6, + discRimWidth: 0.16, // width of the bright annulus at the edge + discOpacity: 1.0, + discGlow: 3.0, + discSoftFade: 0.6, // metres of soft fade where the element meets geometry + discColorBody: '#ffbe52', // the face + discColorRim: '#fff0c0', // the annulus at the edge + discColorEdge: '#ffffff', // the outline + discColorCorona: '#ffa63a', // the filaments licking off it + + /* --- embers, smoke and motes --- */ + /** + * Each system is coloured by a four-stop gradient sampled over the particle's + * own lifetime, `A` at birth through `D` as it dies. Spelled out rather than + * derived from the light, so the embers can be made to cool to red while the + * beam stays white. + */ + emberRate: 130, // embers thrown off the burning point, particles/second + emberSize: 0.1, + emberSpeed: 2.6, + emberLifetime: 1.1, + emberGravity: 2.4, // positive: they rise on the thermal + emberStretch: 0.14, // how far an ember smears along its velocity + emberScatter: 0.07, // fraction of `zoneRadius` they are thrown from + colorEmberA: '#fff6dc', + colorEmberB: '#ffbe52', + colorEmberC: '#ff5d18', + colorEmberD: '#4a1103', + smokeRate: 46, // the thread of smoke off the char, particles/second + smokeSize: 0.7, + smokeSpeed: 0.9, + smokeLifetime: 2.6, + smokeOpacity: 0.09, + smokeRise: 0.75, // upward drift, metres/second + colorSmokeA: '#4a4038', + colorSmokeB: '#3b332c', + colorSmokeC: '#2e2823', + colorSmokeD: '#1a1613', + moteRate: 55, // dust turning over inside the cone, particles/second + moteSize: 0.05, + moteSpeed: 0.6, + moteLifetime: 2.4, + moteRise: 0.35, // metres/second + moteTurbulence: 0.55, + moteSpread: 0.55, // fraction of the cone's floor radius they fill + colorMoteA: '#fff4d8', + colorMoteB: '#ffd28a', + colorMoteC: '#c98a3a', + colorMoteD: '#3a2411', + + /* --- dynamic light --- */ + lightIntensity: 22, + lightRadius: 12, + lightColor: '#ffd08a', + lightPulse: 0.12, // depth of the light's breathing, 0 = steady + lightPulseSpeed: 1.6, // Hz + + /* --- the beats --- */ + castFlash: 0.06, // screen flash as the element leaves the hand + colorCastFlash: '#ffe7b8', + igniteSize: 1.6, // the shell at the moment focus is pulled, metres + igniteIntensity: 1.6, + igniteFlash: 0.16, // screen flash on ignition + igniteEmbers: 90, // extra embers thrown then + colorIgniteA: '#ff8a1f', + colorIgniteB: '#ffcf6a', + colorIgniteC: '#fffbf0', + colorFlash: '#fff0cc', // the full-screen flash on ignition + impactShake: 0.22, // the knock as the element tips over + shakeDuration: 0.5, + rumble: 0.012 // continuous shake while the floor is burning +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Solar Lens. + * + * Two folders do most of the work. **Pulling focus** decides whether this is a + * burning glass or a lamp, and **The walk** decides what it writes on the + * floor. Everything in both re-resolves on the frame it changes, so the right + * way to tune them is to cast, press **P** at the moment the spot lands, and + * drag — the focus point moves under the cursor because its position is a + * function of the sliders and not of anything that was integrated. + */ +export const solarlensSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 4, 120, 0.5, 'element speed'], + ['zoneRadius', 1, 14, 0.05, 'footprint radius'], + ['lifetime', 0.2, 10, 0.05, 'burn duration'], + ['fadeTime', 0.05, 4, 0.01, 'fade time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where the element hangs': [ + ['lensAltitude', 0.5, 14, 0.05, 'altitude (m)'], + ['lensLead', -6, 6, 0.05, 'lead toward caster (m)'], + ['lensClimb', 0.2, 5, 0.01, 'climb curve'], + ['lensBob', 0, 1.5, 0.01, 'bob (m)'], + ['lensBobSpeed', 0, 3, 0.01, 'bob Hz'], + ['lensSway', 0, 2, 0.01, 'sway (m)'], + ['lensSwaySpeed', 0, 3, 0.01, 'sway Hz'] + ], + 'Pulling focus': [ + ['focusTime', 0.05, 4, 0.01, 'pull time (s)'], + ['focusCurve', 0.2, 6, 0.01, 'pull curve'], + ['focusWide', 0.02, 1.4, 0.005, 'unfocused half-angle (rad)'], + ['focusTight', 0.01, 0.6, 0.001, 'focused half-angle (rad)'], + ['focusLift', 0, 1, 0.005, 'flare anchor lift (m)'] + ], + 'The walk': [ + ['walkSpeed', -2, 2, 0.005, 'turns / second'], + ['walkPetals', 0, 9, 0.05, 'rosette lobes'], + ['walkInner', 0, 1, 0.005, 'tightest radius (× zone)'], + ['walkOuter', 0, 1.4, 0.005, 'widest radius (× zone)'], + ['walkPhase', 0, 6.2832, 0.01, 'rosette phase (rad)'], + ['walkWobble', 0, 0.3, 0.001, 'jitter (× zone)'], + ['walkWobbleRate', 0, 12, 0.05, 'jitter Hz'] + ], + 'The char': [ + ['charMarksPerTurn', 1, 120, 1, 'pits / turn'], + ['charMarkRadius', 0.02, 2, 0.01, 'pit radius (m)'], + ['charMarkLife', 0.5, 30, 0.1, 'pit lifetime (s)'], + ['charDepth', 0, 0.6, 0.005, 'bowl depth (m)'], + ['charLift', 0, 0.3, 0.001, 'rim lift (m)'], + ['charSharp', 0, 1, 0.01, 'rim sharpness'], + ['charDetail', 0, 1, 0.01, 'interior grain'], + ['charEdge', 0.02, 2, 0.01, 'front feather (m)'], + ['charRagged', 0, 1, 0.01, 'front wander'], + ['charRaggedScale', 0.05, 3, 0.01, 'lobes / metre'], + ['charWarp', 0, 3, 0.01, 'domain warp (m)'], + ['charRelief', 0, 2, 0.01, 'relief'], + ['charNormalStep', 0.01, 0.3, 0.005, 'normal tap (m)'], + ['charAmbient', 0, 1, 0.01, 'ambient floor'], + ['charWrap', 0, 1, 0.01, 'terminator wrap'], + ['charSpecular', 0, 2, 0.01, 'specular'], + ['charGloss', 1, 90, 1, 'gloss'], + ['charParallax', 0, 1.5, 0.01, 'parallax (m)'], + ['charEmissive', 0, 4, 0.01, 'ember gain'], + ['charOpacity', 0, 1.5, 0.01, 'opacity'], + ['charDepthFade', 0.02, 2, 0.01, 'soft fade (m)'], + ['colorCharBase', 'burnt floor'], + ['colorCharEdge', 'pit lip'], + ['colorCharGlow', 'live ember'], + ['colorCharDeep', 'bowl bottom'] + ], + 'The focused light': [ + ['netRadius', 0.2, 14, 0.05, 'net radius (m)'], + ['netDepth', 0, 4, 0.01, 'glass depth (m)'], + ['netIor', 1.001, 2.6, 0.001, 'index of refraction'], + ['netDispersion', 0, 1, 0.005, 'dispersion'], + ['netSampleStep', 0.01, 0.4, 0.005, 'Hessian tap (m)'], + ['netAbsorb', 0, 2, 0.005, 'absorption'], + ['netFoldFloor', 0.005, 1, 0.005, 'fold clamp'], + ['netThreshold', 0, 4, 0.01, 'filament threshold'], + ['netGain', 0, 3, 0.01, 'gain'], + ['netSharpness', 0.05, 5, 0.01, 'sharpness'], + ['netRolloff', 0, 2, 0.01, 'rolloff'], + ['netAmp', 0, 1, 0.005, 'lattice height (m)'], + ['netCellScale', 0.05, 4, 0.01, 'cells / metre'], + ['netCellRatio', 0.1, 4, 0.01, 'lattice ratio'], + ['netCellJitter', 0, 1, 0.01, 'cell jitter'], + ['netDriftAngle', 0, 6.2832, 0.01, 'drift angle (rad)'], + ['netDriftSpeed', 0, 1.5, 0.005, 'drift speed (m/s)'], + ['netBoil', 0, 4, 0.01, 'boil'], + ['netRidgeMix', 0, 1, 0.01, 'ridge mix'], + ['netRidgeScale', 0.05, 8, 0.01, 'ridge scale'], + ['netRidgePower', 0.5, 16, 0.1, 'ridge power'], + ['netPenumbra', 0.02, 1, 0.01, 'penumbra'], + ['netEmissive', 0, 6, 0.01, 'emissive'], + ['netOpacity', 0, 2, 0.01, 'opacity'], + ['netWash', 0, 1.5, 0.01, 'pool wash'], + ['netFringeAt', 0, 5, 0.01, 'fringe threshold'], + ['netDepthFade', 0.02, 2, 0.01, 'soft fade (m)'], + ['colorNet', 'filaments'], + ['colorFringe', 'fold crest'], + ['colorWash', 'pool wash'] + ], + 'The flare/Occlusion': [ + ['flareOcclusion', 0, 1, 0.01, 'occlusion'], + ['flareOccRadius', 0.002, 0.2, 0.001, 'source half-size'], + ['flareOccTaps', 1, 9, 1, 'kernel taps'], + ['flareOccFade', 0.02, 4, 0.01, 'depth feather (m)'], + ['flareOccSpin', 0, 6.2832, 0.01, 'kernel spin (rad)'] + ], + 'The flare/Core & streak': [ + ['flareIntensity', 0, 6, 0.01, 'intensity'], + ['flareOpacity', 0, 2, 0.01, 'opacity'], + ['flareHeadroom', 0.5, 24, 0.1, 'shoulder headroom'], + ['flareEdgeStart', 0.1, 2, 0.01, 'edge fade start'], + ['flareEdgeEnd', 0.2, 3, 0.01, 'edge fade end'], + ['flareCoreSize', 0.002, 0.2, 0.001, 'core size'], + ['flareCoreGlow', 0, 8, 0.01, 'core glow'], + ['flareBurstBlades', 3, 12, 1, 'iris blades'], + ['flareBurstLength', 0, 0.6, 0.005, 'spike reach'], + ['flareBurstSharp', 1, 80, 0.5, 'spike sharpness'], + ['flareBurstJitter', 0, 1, 0.01, 'spike jitter'], + ['flareBurstSpin', 0, 6.2832, 0.01, 'spike spin (rad)'], + ['flareHaloSize', 0, 0.4, 0.001, 'halo size'], + ['flareHaloWidth', 0.01, 1, 0.01, 'halo width'], + ['flareHaloGlow', 0, 4, 0.01, 'halo glow'], + ['flareStreakLength', 0, 1, 0.005, 'streak length'], + ['flareStreakThickness', 0.001, 0.1, 0.001, 'streak thickness'], + ['flareStreakFalloff', 0.2, 8, 0.05, 'streak falloff'], + ['flareStreakTight', 1, 30, 0.5, 'streak tightness'], + ['flareStreakGlow', 0, 5, 0.01, 'streak glow'], + ['flareStreakTilt', -0.6, 0.6, 0.005, 'streak tilt (rad)'], + ['flareStreakGrain', 0, 1, 0.01, 'streak grain'], + ['flareStreakChroma', 0, 2, 0.01, 'streak chroma'] + ], + 'The flare/Ghosts & ring': [ + ['flareGhosts', 0, 8, 1, 'ghosts drawn'], + ['flareGhostSpacing', -2, 2, 0.01, 'first ghost'], + ['flareGhostStride', -1, 1, 0.01, 'stride / ghost'], + ['flareGhostScatter', 0, 1, 0.01, 'spacing scatter'], + ['flareGhostSize', 0.002, 0.3, 0.001, 'ghost size'], + ['flareGhostSizeStep', 0.4, 1.6, 0.01, 'size step'], + ['flareGhostSizeScatter', 0, 1, 0.01, 'size scatter'], + ['flareGhostBlades', 3, 12, 1, 'ghost blades'], + ['flareGhostRound', 0, 1, 0.01, 'roundness'], + ['flareGhostRoundStep', 0, 0.5, 0.005, 'roundness step'], + ['flareGhostSpin', -2, 2, 0.01, 'spin / ghost (rad)'], + ['flareGhostFill', 0, 2, 0.01, 'fill'], + ['flareGhostRim', 0, 3, 0.01, 'rim'], + ['flareGhostRimWidth', 0.01, 0.6, 0.005, 'rim width'], + ['flareGhostSoft', 0.01, 1, 0.005, 'edge softness'], + ['flareGhostChroma', 0, 2, 0.01, 'chroma'], + ['flareGhostGlow', 0, 4, 0.01, 'glow'], + ['flareRing', 0, 2, 0.01, 'iris ring'], + ['flareRingSpacing', -2, 2, 0.01, 'ring position'], + ['flareRingSize', 0.01, 1, 0.005, 'ring size'], + ['flareRingWidth', 0.002, 0.2, 0.001, 'ring width'], + ['flareRingBlades', 3, 12, 1, 'ring blades'], + ['flareRingChroma', 0, 3, 0.01, 'ring chroma'], + ['flareRingGlow', 0, 3, 0.01, 'ring glow'] + ], + 'The flare/Colour': [ + ['colorFlareCore', 'core'], + ['colorFlareHalo', 'halo'], + ['colorFlareStreak', 'streak'], + ['colorFlareStreakEdge', 'streak edge'], + ['colorFlareGhostA', 'ghost A'], + ['colorFlareGhostB', 'ghost B'], + ['colorFlareGhostC', 'ghost C'], + ['colorFlareGhostD', 'ghost D'], + ['colorFlareRing', 'iris ring'] + ], + 'The element/The disc': [ + ['discRadius', 0.05, 6, 0.01, 'radius on arrival (m)'], + ['discRadiusEnd', 0.05, 8, 0.01, 'radius burning (m)'], + ['discExpand', 0.05, 8, 0.01, 'bloom curve'], + ['discHeight', 0, 1, 0.005, 'plane lift (m)'], + ['discLift', 0, 1, 0.005, 'rim lift (m)'], + ['discDisplace', 0, 0.6, 0.005, 'surface noise (m)'], + ['discNoiseScale', 0.1, 10, 0.05, 'noise scale'], + ['discNoiseSpeed', 0, 4, 0.01, 'noise speed'], + ['discTurbulence', 0, 3, 0.01, 'turbulence'], + ['discDissolve', 0, 1, 0.01, 'face dissolve'], + ['discGranule', 0, 2, 0.01, 'granulation'], + ['discGranuleScale', 0.2, 20, 0.1, 'granule scale'] + ], + 'The element/Rim & corona': [ + ['discFill', 0, 2, 0.01, 'face fill'], + ['discRim', 0, 3, 0.01, 'fresnel rim'], + ['discRimPower', 0.1, 8, 0.01, 'rim power'], + ['discRimWidth', 0.01, 1, 0.005, 'rim width'], + ['discEdge', 0, 4, 0.01, 'outline'], + ['discEdgeWidth', 0.01, 1, 0.005, 'outline width'], + ['discCoronaReach', 1, 4, 0.01, 'corona reach'], + ['discCorona', 0, 5, 0.01, 'corona brightness'], + ['discCoronaLength', 0, 2, 0.01, 'filament length'], + ['discCoronaScale', 0.5, 20, 0.1, 'corona scale'], + ['discCoronaWarp', 0, 2, 0.01, 'corona warp'], + ['discCoronaSpeed', 0, 4, 0.01, 'corona speed'], + ['discCoronaSharp', 0.05, 3, 0.01, 'corona sharpness'], + ['discOpacity', 0, 2, 0.01, 'opacity'], + ['discGlow', 0, 8, 0.01, 'glow'], + ['discSoftFade', 0.02, 3, 0.01, 'soft fade (m)'], + ['discColorBody', 'face'], + ['discColorRim', 'rim annulus'], + ['discColorEdge', 'outline'], + ['discColorCorona', 'corona'] + ], + // `Shell.sync()` resolves every field whatever the mode is, so the twelve + // keys the other four modes own are still read off this block and still have + // to exist. Filed together and out of the way rather than left to fall into + // "More", where they read as controls that ought to do something. + 'The element/Unread by SUNDISC': [ + ['discSpan', 0.05, 20, 0.05, 'span (CONE/train)'], + ['discSeal', 0, 4, 0.01, 'seal (DOME)'], + ['discSealWidth', 0.01, 1, 0.01, 'seal width (DOME)'], + ['discConeCurve', 0.05, 4, 0.01, 'cone curve (CONE)'], + ['discRings', 1, 24, 1, 'rings (train)'], + ['discSpacing', 0.05, 6, 0.01, 'ring spacing (train)'], + ['discRingSpeed', 0, 30, 0.1, 'ring speed (train)'], + ['discRingThickness', 0.01, 1, 0.01, 'ring thickness (train)'], + ['discRingSharp', 0.05, 6, 0.01, 'ring sharpness (train)'], + ['discReflect', 0, 1, 0.01, 'end reflection (train)'], + ['discStanding', 0, 1, 0.01, 'standing wave (train)'], + ['discSwell', 0, 2, 0.01, 'antinode swell (train)'] + ], + 'Embers, smoke & motes': [ + ['emberRate', 0, 800, 1, 'ember rate'], + ['emberSize', 0.005, 0.5, 0.005, 'ember size'], + ['emberSpeed', 0, 14, 0.05, 'ember speed'], + ['emberLifetime', 0.1, 5, 0.05, 'ember lifetime'], + ['emberGravity', -20, 12, 0.1, 'ember gravity'], + ['emberStretch', 0, 2, 0.01, 'ember stretch'], + ['emberScatter', 0, 0.6, 0.005, 'ember scatter (× zone)'], + ['smokeRate', 0, 400, 1, 'smoke rate'], + ['smokeSize', 0.05, 3, 0.01, 'smoke size'], + ['smokeSpeed', 0, 6, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['moteRate', 0, 400, 1, 'mote rate'], + ['moteSize', 0.005, 0.3, 0.005, 'mote size'], + ['moteSpeed', 0, 6, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -2, 4, 0.01, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['moteSpread', 0, 2, 0.01, 'mote spread (× spot)'], + ['colorEmber*', 'Ember colour'], + ['colorSmoke*', 'Smoke colour'], + ['colorMote*', 'Mote colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'breathing depth'], + ['lightPulseSpeed', 0, 12, 0.05, 'breathing Hz'], + ['lightColor', 'light colour'] + ], + 'The beats': [ + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash'], + ['igniteSize', 0.1, 8, 0.05, 'ignition shell (m)'], + ['igniteIntensity', 0, 5, 0.01, 'ignition intensity'], + ['igniteFlash', 0, 2, 0.01, 'ignition screen flash'], + ['igniteEmbers', 0, 400, 1, 'ignition embers'], + ['colorIgniteA', 'ignition shell'], + ['colorIgniteB', 'ignition body'], + ['colorIgniteC', 'ignition core'], + ['colorFlash', 'ignition flash colour'], + ['impactShake', 0, 2, 0.01, 'arrival shake'], + ['shakeDuration', 0.1, 3, 0.01, 'shake duration'], + ['rumble', 0, 0.3, 0.001, 'burn rumble'] + ] +}; diff --git a/src/config/abilities/soulchain.js b/src/config/abilities/soulchain.js new file mode 100644 index 0000000..dd0f602 --- /dev/null +++ b/src/config/abilities/soulchain.js @@ -0,0 +1,375 @@ +/* ================================================================== */ +/* SOULCHAIN — Soul Tether */ +/* ================================================================== */ +/** + * A chain of real, discrete links thrown down the aimed line: it pays out + * sagging, goes taut with a snap, hangs there rattling, and then comes apart + * one link at a time from the far end back. + * + * The block is organised around the three things that are genuinely separate: + * + * 1. **the curve** (`chain*`) — a catenary, not a parabola. `chainCurve` is + * the shape parameter: 1 is rope, 3 is heavy chain, and the difference is + * all at the anchors, where a hanging chain leaves much steeper than a + * parabola does. `chainSag` → `chainSagHeld` over `tautTime` is the snap, + * and the ease that carries it overshoots, so the sag briefly goes past + * `chainSagHeld` and bows the chain *upward* before it settles. + * 2. **the links** (`link*`, `rattle*`, `iron*`, `soul*`, `ghost*`) — an + * instanced field of swept stadium rings threaded along that curve. Their + * spacing is derived from the **live arc length** of the curve, so dragging + * `chainSag` genuinely re-spaces them and changes how many there are. + * 3. **the tether** (`tether*`, `fray*`) — two `FilamentPaths` roles sharing + * one two-draw-call strip: the spirit thread the links are strung on, and + * the frayed ends that whip loose as they break. + * + * `linkLength`, `linkWidth` and `linkThickness` are metres of *geometry* rather + * than uniforms; the ability rehashes them each frame and rebuilds the link + * mesh when one of them moves, exactly as `GrowthField#syncGeometry` does. They + * are still live under a paused drag — the rebuild is what makes them live. + */ +export const soulchain = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 32.0, // how fast the far end of the chain travels, metres/second + lifetime: 1.15, // seconds the chain hangs taut and rattling + fadeTime: 1.7, // seconds the links take to break, far end back to the hand + cooldown: 1.0, // seconds before it can be cast again + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the two anchors --- */ + handHeight: 1.32, // metres above the floor the chain leaves the caster at + handForward: 0.45, // metres in front of the caster + handSide: 0.3, // metres to the side (+ follows `Ability#side`) + endHeight: 1.05, // height of the far anchor, metres + + /* --- the catenary --- */ + chainSag: 2.6, // metres of droop at mid-span while the chain is slack + chainSagHeld: 0.28, // metres of droop once it has gone taut + chainCurve: 2.9, // 1 is rope, 3 is heavy chain, 0.01 is a parabola + chainSwing: 0.22, // metres of slow lateral sway + chainSwingSpeed: 1.7, // radians/second of that sway + tautTime: 0.34, // seconds the sag takes to collapse + + /* --- the links --- */ + linkLength: 0.42, // metres, end to end along the chain + linkWidth: 0.2, // metres across the ring + linkThickness: 0.05, // metres — the diameter of the bar it is bent from + linkOverlap: 0.44, // fraction of a link its neighbour covers, 0..0.9 + linkTwist: 1.5708, // radians each link is rotated from its neighbour (π/2) + linkOpacity: 1.0, // master alpha of the whole chain + + /* --- the rattle --- */ + rattleAmp: 0.22, // radians of per-link shiver about the chain axis + rattleSway: 0.035, // metres of per-link lateral shiver + rattleRate: 13.0, // shivers per second + rattleDecay: 1.1, // s⁻¹ the jangle dies at once the chain is taut + + /* --- the iron --- */ + ironRough: 0.44, // roughness of an unpitted face + ironMetal: 0.85, // metalness, 0..1 + ironPit: 0.6, // depth of the casting pits, 0..1 + ironPitScale: 44.0, // pit features per metre + ironEnv: 0.8, // environment-map intensity on the iron + + /* --- the soul in the iron --- */ + soulGlow: 2.3, // emissive of the glow inside each ring + soulInner: 2.6, // how tightly that glow hugs the inner wall; higher = tighter + soulPulse: 0.7, // depth of the pulse running along the chain, 0..1 + soulPulseScale: 1.6, // pulses along the whole chain + soulPulseSpeed: 1.1, // pulses per second travelling hand → anchor + ghostRim: 1.35, // strength of the cold fresnel rim + ghostRimPower: 3.2, // how thin that rim is + breakGlow: 3.8, // how much hotter a link burns the instant it lets go + colorIron: '#7a8a9a', // ghost iron + colorIronDeep: '#2a3038', // the bottom of a casting pit + colorSoul: '#9affe0', // the glow inside the ring + colorGhost: '#b8d8e0', // the cold rim on the silhouette + colorBreak: '#e8fff4', // the flash as a link parts + + /* --- coming apart --- */ + breakRate: 15.0, // links parting per second, far end back toward the hand + breakSpeed: 3.4, // metres/second a freed link is thrown at + breakSpread: 0.85, // 0..1 — how wide the cone it is thrown into is + breakGravity: -11.0, // metres/second² a freed link falls at + breakSpin: 7.5, // radians/second it tumbles at + breakLife: 1.15, // seconds a freed link lasts before it is gone + + /* ------------------------------------------------------------------ */ + /* The tether — `FilamentPaths` role 0, path LINK */ + /* ------------------------------------------------------------------ */ + tetherCount: 3, // filaments threaded through the links + tetherSpread: 0.05, // metres between those filaments + tetherWidth: 0.024, // half-width of the core ribbon, metres + tetherGlowWidth: 6.0, // halo half-width, × the core width + tetherGlowOpacity: 0.42, // halo alpha relative to the core + tetherKink: 0.35, // per-role multiplier on the kink amplitude + tetherJitter: 0.11, // metres of lateral kink at the coarsest octave + tetherJitterScale: 1.1, // kinks per metre of path + tetherOctaves: 3, // 1–5; each halves the amplitude and doubles the rate + tetherJitterFalloff: 0.5, // amplitude kept per octave + tetherCrawl: 1.1, // how fast the kinks slide along, per second + tetherPinch: 0.12, // fraction of the path the kink eases in over at each end + tetherRestrike: 13.0, // whole re-shapes per second + tetherFlicker: 0.18, // depth of the whole-bundle brightness stutter + tetherFlickerSpeed: 22.0, // stutters per second + tetherStrandFlash: 0.4, // depth of the per-filament blink + tetherCoreSharp: 4.2, // exponent on the core's edge falloff + tetherGlowFalloff: 2.2, // the same for the halo + tetherSoftFade: 0.6, // metres of depth fade against the opaque scene + tetherTipLength: 0.05, // length of the drawn front, fraction of the path + tetherTipGlow: 1.6, // extra heat on that front + tetherOpacity: 0.9, // master alpha of the strip + tetherGlow: 2.1, // emissive multiplier fed into bloom + colorTetherCore: '#ffffff', // the centre line of a filament + colorTetherInner: '#c8fff0', + colorTetherOuter: '#4ec8a8', + colorTetherHalo: '#0d3a48', // the wide, dim atmosphere + + /* --- the fray — role 1, path CRACK, at the breaking end --- */ + frayCount: 6, // loose spirit ends whipping off the break + frayAngle: 0.75, // radians a branch leaves its parent by + frayLength: 0.55, // branch length as a fraction of its parent's + frayFalloff: 0.6, // extra shortening per generation + fraySpread: 0.6, // ± fraction of variation on the angle + frayStart: 0.15, // earliest point on a parent a fork may happen, 0..1 + fraySag: 0.18, // metres of bow on each fray segment + frayForkBias: 0.5, // 0..1 — slides the branch/twig split + frayReach: 1.4, // metres a fray reaches back down the chain + frayKink: 1.6, // per-role multiplier on the kink amplitude + frayWidth: 0.6, // per-role multiplier on the ribbon width + frayDim: 0.85, // per-role brightness multiplier + + /* ------------------------------------------------------------------ */ + /* The snap */ + /* ------------------------------------------------------------------ */ + snapShake: 0.85, // camera shake when the chain goes taut + shakeDuration: 0.42, // seconds that shake takes to die + snapFlash: 0.2, // full-screen flash on the snap + rumble: 0.018, // continuous shake while the chain is being thrown + burstSize: 2.2, // the pressure shell at the far anchor, metres + burstIntensity: 1.3, // brightness of that shell + shockRadius: 4.5, // the ring that snaps across the floor, metres + colorBurstA: '#2a4a58', // burst shell + colorBurstB: '#4ec8a8', // burst body + colorBurstC: '#c8fff0', // burst filaments + colorShockA: '#4ec8a8', // body of the shockwave ring + colorShockB: '#e8fff4', // its crest + colorSnapFlash: '#9affe0', // the screen flash on the snap + + /* ------------------------------------------------------------------ */ + /* Shards, motes and haze */ + /* ------------------------------------------------------------------ */ + /** + * Four-stop lifetime gradients as everywhere else, `A` at birth through `D` + * as it dies. Written out rather than derived from the iron palette so the + * shards can cool to blue while the links stay grey. + */ + shardPerBreak: 7, // iron fragments thrown when one link parts + shardSize: 0.05, // fragment size + shardSpeed: 4.2, // metres/second they are thrown at + shardLifetime: 1.1, // seconds one lives + shardGravity: -15.0, // metres/second² + colorShardA: '#e8fff4', + colorShardB: '#9affe0', + colorShardC: '#4a5a66', + colorShardD: '#242a30', + moteRate: 55, // soul embers drifting off the chain, particles/second + moteSize: 0.05, // mote size + moteSpeed: 0.8, // metres/second they leave the chain at + moteLifetime: 2.0, // seconds one lives + moteRise: 0.85, // upward drift, metres/second + moteTurbulence: 0.7, // how hard the curl field pushes them around + colorMoteA: '#ffffff', + colorMoteB: '#9affe0', + colorMoteC: '#2f7f78', + colorMoteD: '#08181f', + hazeRate: 26, // cold breath hanging under the chain, particles/second + hazeSize: 0.9, // haze puff size + hazeSpeed: 0.55, // metres/second it drifts at + hazeLifetime: 2.6, // seconds one lives + hazeOpacity: 0.07, // how thick the haze reads + hazeRise: -0.35, // downward drift, metres/second (it is cold, it sinks) + colorHazeA: '#4a5f68', + colorHazeB: '#3a4a55', + colorHazeC: '#2a3640', + colorHazeD: '#161d24', + + /* --- dynamic light --- */ + lightIntensity: 16, // the soul light riding the travelling end + lightRadius: 13, // metres it reaches + lightColor: '#7fe8d0' // its colour +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Soul Tether. + * + * Four controls carry it: `chainSag` (how heavily it hangs, and therefore how + * many links there are), `linkLength` with `linkOverlap` (how coarse the chain + * reads), `tautTime` (how violent the snap is) and `breakRate` (how fast it + * unzips). Drag `chainSag` with the clock stopped and watch the link *count* + * change — that is the ability's whole trick, and it is one slider. + */ +export const soulchainSchema = { + 'The cast': [ + ['range', 2, 45, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 4, 120, 0.5, 'throw speed'], + ['lifetime', 0.1, 6, 0.01, 'hold taut'], + ['fadeTime', 0.1, 6, 0.01, 'break-up time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The anchors': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['endHeight', 0, 5, 0.01, 'far anchor height'] + ], + 'The catenary': [ + ['chainSag', 0, 10, 0.01, 'slack sag (m)'], + ['chainSagHeld', 0, 3, 0.01, 'taut sag (m)'], + ['chainCurve', 0.05, 6, 0.01, 'catenary shape'], + ['chainSwing', 0, 2, 0.01, 'sway (m)'], + ['chainSwingSpeed', 0, 8, 0.01, 'sway (rad/s)'], + ['tautTime', 0.02, 2, 0.01, 'snap time (s)'] + ], + 'The links/Geometry': [ + ['linkLength', 0.05, 1.5, 0.005, 'link length (m)'], + ['linkWidth', 0.02, 1, 0.005, 'link width (m)'], + ['linkThickness', 0.005, 0.3, 0.001, 'bar diameter (m)'], + ['linkOverlap', 0, 0.9, 0.01, 'overlap'], + ['linkTwist', 0, 3.1416, 0.01, 'twist per link (rad)'], + ['linkOpacity', 0, 1, 0.01, 'opacity'] + ], + 'The links/Rattle': [ + ['rattleAmp', 0, 1.2, 0.01, 'shiver (rad)'], + ['rattleSway', 0, 0.3, 0.001, 'shiver (m)'], + ['rattleRate', 0, 40, 0.1, 'shivers / second'], + ['rattleDecay', 0, 8, 0.01, 'jangle decay (s⁻¹)'] + ], + 'The links/Iron': [ + ['ironRough', 0.02, 1, 0.01, 'roughness'], + ['ironMetal', 0, 1, 0.01, 'metalness'], + ['ironPit', 0, 1, 0.01, 'pitting'], + ['ironPitScale', 2, 120, 0.5, 'pits / metre'], + ['ironEnv', 0, 3, 0.01, 'env intensity'] + ], + 'The links/The soul inside': [ + ['soulGlow', 0, 8, 0.01, 'inner glow'], + ['soulInner', 0.2, 10, 0.05, 'glow tightness'], + ['soulPulse', 0, 1, 0.01, 'pulse depth'], + ['soulPulseScale', 0, 8, 0.01, 'pulses along the chain'], + ['soulPulseSpeed', -6, 6, 0.01, 'pulse speed (Hz)'], + ['ghostRim', 0, 5, 0.01, 'ghost rim'], + ['ghostRimPower', 0.2, 10, 0.05, 'rim tightness'], + ['breakGlow', 0, 10, 0.01, 'break flash'], + ['colorIron', 'iron'], + ['colorIronDeep', 'pit'], + ['colorSoul', 'soul'], + ['colorGhost', 'ghost rim'], + ['colorBreak', 'breaking link'] + ], + 'Coming apart': [ + ['breakRate', 0.5, 60, 0.5, 'links / second'], + ['breakSpeed', 0, 20, 0.1, 'throw speed (m/s)'], + ['breakSpread', 0, 1, 0.01, 'throw cone'], + ['breakGravity', -40, 0, 0.1, 'gravity'], + ['breakSpin', 0, 30, 0.1, 'tumble (rad/s)'], + ['breakLife', 0.1, 5, 0.05, 'link lifetime (s)'] + ], + 'The tether/Filaments': [ + ['tetherCount', 0, 12, 1, 'filaments'], + ['tetherSpread', 0, 1, 0.005, 'spread (m)'], + ['tetherWidth', 0.002, 0.3, 0.001, 'ribbon width (m)'], + ['tetherGlowWidth', 1, 20, 0.1, 'halo width'], + ['tetherGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['tetherCoreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['tetherGlowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['tetherTipLength', 0.005, 0.5, 0.005, 'front length'], + ['tetherTipGlow', 0, 8, 0.05, 'front glow'], + ['tetherSoftFade', 0.02, 3, 0.01, 'soft fade (m)'], + ['tetherOpacity', 0, 2, 0.01, 'opacity'], + ['tetherGlow', 0, 8, 0.01, 'glow'] + ], + 'The tether/Kink & flicker': [ + ['tetherKink', 0, 3, 0.01, 'kink multiplier'], + ['tetherJitter', 0, 2, 0.005, 'kink amplitude (m)'], + ['tetherJitterScale', 0.05, 6, 0.01, 'kinks / metre'], + ['tetherOctaves', 1, 5, 1, 'octaves'], + ['tetherJitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['tetherCrawl', -20, 20, 0.1, 'kink crawl'], + ['tetherPinch', 0.01, 0.5, 0.005, 'end pinch'], + ['tetherRestrike', 0.5, 90, 0.5, 'restrikes / sec'], + ['tetherFlicker', 0, 1, 0.01, 'brightness stutter'], + ['tetherFlickerSpeed', 1, 120, 1, 'stutter rate'], + ['tetherStrandFlash', 0, 1, 0.01, 'filament blink'] + ], + 'The tether/Colour': [ + ['colorTetherCore', 'core'], + ['colorTetherInner', 'inner'], + ['colorTetherOuter', 'outer'], + ['colorTetherHalo', 'halo'] + ], + 'The tether/The fray': [ + ['frayCount', 0, 12, 1, 'loose ends'], + ['frayReach', 0, 6, 0.05, 'reach (m)'], + ['frayAngle', 0, 2, 0.01, 'branch angle (rad)'], + ['frayLength', 0.05, 1, 0.01, 'branch length'], + ['frayFalloff', 0.05, 1, 0.01, 'generation falloff'], + ['fraySpread', 0, 2, 0.01, 'angle spread'], + ['frayStart', 0, 1, 0.01, 'earliest fork'], + ['fraySag', 0, 1, 0.01, 'segment bow (m)'], + ['frayForkBias', 0, 1, 0.01, 'fork bias'], + ['frayKink', 0, 4, 0.01, 'kink multiplier'], + ['frayWidth', 0, 3, 0.01, 'width multiplier'], + ['frayDim', 0, 2, 0.01, 'brightness'] + ], + 'The snap': [ + ['snapShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['snapFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'throw rumble'], + ['burstSize', 0.2, 10, 0.05, 'burst size (m)'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['shockRadius', 0.5, 20, 0.1, 'shockwave radius (m)'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst filaments'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorSnapFlash', 'snap flash colour'] + ], + 'Shards & motes': [ + ['shardPerBreak', 0, 40, 1, 'shards / break'], + ['shardSize', 0.005, 0.5, 0.005, 'shard size'], + ['shardSpeed', 0, 25, 0.1, 'shard speed'], + ['shardLifetime', 0.1, 5, 0.05, 'shard lifetime'], + ['shardGravity', -50, 0, 0.1, 'shard gravity'], + ['moteRate', 0, 500, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 12, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 8, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorShard*', 'Shard colour'], + ['colorMote*', 'Mote colour'] + ], + 'Cold haze': [ + ['hazeRate', 0, 300, 1, 'haze rate'], + ['hazeSize', 0.05, 4, 0.01, 'haze size'], + ['hazeSpeed', 0, 8, 0.05, 'haze speed'], + ['hazeLifetime', 0.2, 8, 0.05, 'haze lifetime'], + ['hazeOpacity', 0, 1, 0.005, 'haze opacity'], + ['hazeRise', -3, 3, 0.01, 'haze rise'], + ['colorHaze*', 'Haze colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/spellbreak.js b/src/config/abilities/spellbreak.js new file mode 100644 index 0000000..2ad04f6 --- /dev/null +++ b/src/config/abilities/spellbreak.js @@ -0,0 +1,427 @@ +/* ================================================================== */ +/* SPELLBREAK — arcane, zone cast */ +/* ================================================================== */ +/** + * A bell of arcane glass closes over the zone, holds, and is broken. + * + * Every other block in this directory describes something its ability + * *draws*. A third of this one describes something it **publishes**: the + * `Hook.DISRUPT` region in `vfx/SceneHooks.js`, which is a sphere in world + * metres plus three 0..1 powers, read by every material that has opted in. + * Cast into an empty room and those sliders do nothing you can see; cast into + * a standing Nova Beam and they are the whole ability. + * + * The field folder is therefore first, and it is worth dragging **with another + * cast standing in the zone and the clock paused** — which is the only way to + * see what any of it does. + * + * Two numbers are deliberately independent and deliberately related. + * `shardPixels` is the size of a fracture cell in *device pixels*, and + * `paneSize` is the size of one of this ability's own glass fragments in + * *metres*. They are separate controls because one is measured on the screen + * and the other in the world and no honest formula relates them; they are + * meant to be tuned until they read as the same substance breaking. Push them + * far apart and the cast stops being one event. + * + * Keys prefixed `glass*` are the `vfx/Shell.js` contract, written out longhand + * from `shellDefaults('glass', ShellMode.DOME)` so the module's audit stays + * quiet. The ones belonging to `CONE`, `RING_TRAIN` and `SUNDISC` are inert in + * this mode and are filed together at the end rather than hidden. **There is + * no `glassRadius` or `glassRadiusEnd`** — those two are the one thing the + * ability computes rather than authors, from `zoneRadius` and the two + * fractions below, so the glass and the aim circle can never come unstuck. See + * the overlay note in `SpellbreakAbility`. + */ +export const spellbreak = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused + speed: 58.0, // how fast the null front travels, metres/second + cooldown: 1.4, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 5.0, // the footprint the aim circle measures out, metres + + sealTime: 0.24, // seconds the bell takes to close over the zone + holdTime: 0.42, // seconds it stands intact before it is broken + ringTime: 0.85, // seconds the pressure ring takes to cross the zone + fadeTime: 1.2, // seconds the panes have to fall and the field to let go + + /* ------------------------------------------------------------------ */ + /* The disruption field — what this cast does to the other casts */ + /* ------------------------------------------------------------------ */ + fieldSpan: 1.12, // region radius, × zoneRadius (a little past the glass) + fieldLift: 1.15, // metres above the floor the sphere is centred on + fieldEdge: 0.34, // 0..1 of the radius the field softens over + warnSpan: 0.5, // region radius while the front is travelling, × fieldSpan + warnDrain: 0.5, // 0..1 desaturation during that warning + warnDim: 0.14, // 0..1 dimming during that warning + drain: 1.0, // 0..1 desaturation once the bell has closed + dim: 0.44, // 0..1 dimming once the bell has closed + fracture: 0.16, // 0..1 cell erosion while the bell merely stands + fractureBite: 0.88, // 0..1 peak cell erosion on the frame the bell breaks + fractureFall: 0.62, // seconds that peak decays back to `fracture` over + shardPixels: 9.0, // device pixels per fracture cell — bigger cells, bigger shards + releaseCurve: 2.1, // >1 holds the field then drops it; 1 lets go evenly + + /* ------------------------------------------------------------------ */ + /* The bell of arcane glass — vfx/Shell.js, prefix `glass`, mode DOME */ + /* ------------------------------------------------------------------ */ + domeStart: 0.18, // dome radius as the bell begins to close, × zoneRadius + domeSpan: 1.0, // dome radius once it has closed, × zoneRadius + glassExpand: 3.4, // easing exponent on that closure + glassHeight: 0.72, // axial extent, × radius — a bell, not a hemisphere + glassLift: 0.02, // metres it hovers above the floor + glassDisplace: 0.05, // billow along the normal, × radius. Glass barely billows. + glassNoiseScale: 3.2, // billow features per unit radius + glassNoiseSpeed: 0.22, // Hz the billow crawls at + glassTurbulence: 1.0, // master on the billow + glassFill: 0.1, // body fill — glass is nearly all rim + glassRim: 1.9, // strength of the fresnel rim + glassRimPower: 2.9, // how tight that rim is + glassSeal: 1.6, // brightness where the bell meets the floor + glassSealWidth: 0.09, // how wide that seal band is, fraction of the sweep + glassDissolve: 1.35, // how hard the age dissolve bites as it goes + glassOpacity: 0.86, + glassGlow: 2.2, // emissive gain into bloom + glassSoftFade: 0.5, // metres of depth fade against the opaque scene + glassColorBody: '#2a3f6e', // the body of the glass + glassColorRim: '#9fd8ff', // its fresnel rim and its floor seal + glassColorEdge: '#ffffff', // the hottest mark it has + + /* --- Shell keys the DOME mode never reads. Present for the audit. --- */ + glassSpan: 6.0, // CONE / RING_TRAIN run length, metres + glassEdge: 1.2, // CONE: brightness of the leading lip + glassEdgeWidth: 0.16, // CONE: width of that lip + glassConeCurve: 1.0, // CONE: flare curve + glassRings: 10, // RING_TRAIN: live instance count + glassSpacing: 1.6, // RING_TRAIN: wavelength, metres + glassRingSpeed: 7.0, + glassRingThickness: 0.16, + glassRingSharp: 1.6, + glassReflect: 1.0, + glassStanding: 1.0, + glassSwell: 0.45, + glassCoronaReach: 1.8, // SUNDISC: drawn reach past the rim, × radius + glassCorona: 1.3, + glassCoronaLength: 0.55, + glassCoronaScale: 5.0, + glassCoronaWarp: 0.45, + glassCoronaSpeed: 0.7, + glassCoronaSharp: 0.72, + glassGranule: 0.45, + glassGranuleScale: 6.0, + glassRimWidth: 0.18, + glassColorCorona: '#ffd27a', // SUNDISC filaments + + /* ------------------------------------------------------------------ */ + /* The panes it breaks into — vfx/ShatterField.js */ + /* ------------------------------------------------------------------ */ + paneCount: 96, // fragments thrown when the bell goes + paneSides: 6, // corners on one pane, 3–8 (geometry; rebuilt on change) + paneThickness: 0.1, // pane thickness as a fraction of its width + paneRagged: 0.45, // 0..1 how far the corners wander off a regular polygon + paneSpawnSpan: 0.9, // the annulus the panes come off, × zoneRadius + paneSpawnHeight: 0.85, // metres above the floor the anchor sits + paneSpawnRadius: 0.35, // metres of scatter about that anchor + paneSize: 0.42, // metres, the unit geometry's scale + paneSizeJitter: 0.6, // ± fraction + paneSpeed: 5.4, // metres/second a pane leaves at + paneSpeedJitter: 0.65, // ± fraction + paneSpread: 0.72, // 0 throws every pane downrange, 1 is fully random + paneUp: 0.55, // how much +Y is folded into the throw, 0..1 + paneGravity: -16.0, // metres/second², signed + paneDrag: 0.9, // 1/second; 0 is pure ballistics + paneSpin: 7.5, // radians/second of tumble + paneSpinJitter: 0.85, // ± fraction + paneLifetime: 1.5, // seconds a pane lives + paneShrink: 0.55, // 0..1 of its size lost by the end of life + paneShrinkPower: 2.0, // how late that shrink bites + paneFloorSpin: 0.2, // fraction of the tumble kept once it is grounded + paneOpacity: 0.92, + paneGlow: 1.5, // gain on the edge term only; the body never glows + paneRim: 1.1, + paneRimPower: 2.2, + paneShade: 1.0, // how much the key light models the pane + paneAmbient: 0.34, + paneFadeStart: 0.55, // 0..1 of life before it starts fading + paneSoft: 0.3, // metres of soft fade where a pane meets the floor + paneSceneMix: 0.7, // how much of the scene behind shows through + paneRefract: 0.045, // screen-space offset of that lookup + paneSaturation: 0.22, // how much colour survives in what shows through + colorPaneA: '#cfe8ff', // a pane at birth + colorPaneB: '#3c5f96', // ... and as it dies + colorPaneEdge: '#ffffff', // its fresnel edge + colorPaneScene: '#9fb6d8', // tint on whatever shows through it + + /* ------------------------------------------------------------------ */ + /* The push — vfx/Distortion.js, SHOCK */ + /* ------------------------------------------------------------------ */ + pushSpan: 1.6, // the emitter quad's half-extent, × zoneRadius + pushLift: 0.9, // metres above the floor the emitter is anchored + pushStrength: 0.05, // screen widths at post.distortion = 1. Not metres. + pushWindow: 0.9, // 0..1 of the radius the effect is windowed into + pushMaxOffset: 0.1, // hard clamp on the offset, screen widths + pushThickness: 0.55, // metres of the wavefront + pushCompression: 1.0, // the leading half of the wave + pushRarefaction: 0.7, // the trailing half + pushRings: 2, // 1..4 + pushRingGap: 1.3, // metres between them + pushRingDecay: 0.55, // how much weaker each following ring is + pushDepthFade: 0.4, // metres of depth fade at the silhouette + pushOpacity: 1.0, + + /* ------------------------------------------------------------------ */ + /* Particles */ + /* ------------------------------------------------------------------ */ + glintRate: 130, // glints off the standing bell, particles/second + glintBreak: 260, // extra glints thrown on the break + glintSize: 0.12, + glintSpeed: 6.5, + glintLifetime: 0.62, + glintGravity: -9.0, + glintStretch: 0.22, // how far a glint smears along its velocity + colorGlintA: '#ffffff', + colorGlintB: '#cfe8ff', + colorGlintC: '#6fa8e8', + colorGlintD: '#16294a', + + // The drained colour, leaving. Slow and wide, and the one system meant to be + // read as coming off *other people's spells* rather than off the glass. + dustRate: 110, // particles/second + dustBreak: 180, // extra thrown on the break + dustSize: 0.09, + dustSpeed: 1.3, + dustLifetime: 2.1, + dustRise: 0.75, // upward drift, metres/second + dustTurbulence: 0.85, + colorDustA: '#b9c6d8', + colorDustB: '#8f9db2', + colorDustC: '#5c6a7e', + colorDustD: '#20262f', + + gritRate: 34, // glass powder off the floor, particles/second + gritBreak: 90, + gritSize: 0.05, + gritSpeed: 4.2, + gritLifetime: 1.3, + gritGravity: -19.0, + colorGritA: '#8fa6c4', + colorGritB: '#5d6f8c', + colorGritC: '#39465a', + colorGritD: '#232a35', + + /* --- dynamic light --- */ + lightIntensity: 15.0, + lightRadius: 14.0, + lightColor: '#9fd8ff', + lightPulse: 0.35, // depth of the light's swell, 0 = steady + lightPulseSpeed: 7.0, + + /* --- the break --- */ + burstSize: 3.2, // the shell of dead air at the break, metres + burstIntensity: 1.2, + colorBurstA: '#2a3f6e', + colorBurstB: '#9fd8ff', + colorBurstC: '#ffffff', + shockSpan: 1.9, // the floor ring at the break, × zoneRadius + colorShockA: '#9fd8ff', + colorShockB: '#ffffff', + breakFlash: 0.2, // screen flash on the break + colorFlash: '#cfe8ff', + breakShake: 0.7, + shakeDuration: 0.5, + rumble: 0.022 // continuous shake while the front travels +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Spellbreak. + * + * "The field" is first because it *is* the ability. Everything below it is the + * cast telling you the field is there. + */ +export const spellbreakSchema = { + 'The field (what it does to other casts)': [ + ['fieldSpan', 0.2, 3, 0.01, 'radius × zone'], + ['fieldLift', 0, 5, 0.01, 'centre height (m)'], + ['fieldEdge', 0.02, 1, 0.01, 'edge softness'], + ['warnSpan', 0, 1.5, 0.01, 'warning radius ×'], + ['warnDrain', 0, 1, 0.01, 'warning desaturation'], + ['warnDim', 0, 1, 0.01, 'warning dimming'], + ['drain', 0, 1, 0.01, 'desaturation'], + ['dim', 0, 1, 0.01, 'dimming'], + ['fracture', 0, 1, 0.01, 'standing erosion'], + ['fractureBite', 0, 1, 0.01, 'erosion at the break'], + ['fractureFall', 0.05, 3, 0.01, 'erosion decay (s)'], + ['shardPixels', 1, 40, 0.5, 'shard cell (px)'], + ['releaseCurve', 0.5, 6, 0.05, 'let-go curve'] + ], + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 200, 1, 'null-front speed'], + ['zoneRadius', 1, 14, 0.1, 'zone radius'], + ['sealTime', 0.02, 2, 0.01, 'bell closes over'], + ['holdTime', 0.02, 4, 0.01, 'bell holds for'], + ['ringTime', 0.05, 4, 0.01, 'push ring crossing'], + ['fadeTime', 0.05, 5, 0.01, 'let-go time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The bell': [ + ['domeStart', 0.01, 1.5, 0.01, 'start radius × zone'], + ['domeSpan', 0.1, 2, 0.01, 'closed radius × zone'], + ['glassExpand', 0.2, 12, 0.01, 'closure curve'], + ['glassHeight', 0.02, 3, 0.01, 'height × radius'], + ['glassLift', -1, 1, 0.001, 'lift (m)'], + ['glassDisplace', 0, 1.5, 0.01, 'billow'], + ['glassNoiseScale', 0.1, 10, 0.01, 'billow scale'], + ['glassNoiseSpeed', 0, 4, 0.01, 'billow Hz'], + ['glassTurbulence', 0, 3, 0.01, 'turbulence'] + ], + 'The bell/Shading': [ + ['glassFill', 0, 1, 0.01, 'body fill'], + ['glassRim', 0, 4, 0.01, 'rim'], + ['glassRimPower', 0.1, 8, 0.01, 'rim power'], + ['glassSeal', 0, 4, 0.01, 'floor seal'], + ['glassSealWidth', 0.01, 0.6, 0.005, 'seal width'], + ['glassDissolve', 0, 3, 0.01, 'dissolve'], + ['glassOpacity', 0, 1, 0.01, 'opacity'], + ['glassGlow', 0, 8, 0.01, 'glow'], + ['glassSoftFade', 0, 3, 0.01, 'soft fade (m)'], + ['glassColorBody', 'glass body'], + ['glassColorRim', 'glass rim'], + ['glassColorEdge', 'glass edge'] + ], + 'The panes': [ + ['paneCount', 0, 180, 1, 'panes thrown'], + ['paneSides', 3, 8, 1, 'corners per pane'], + ['paneThickness', 0.01, 0.5, 0.005, 'thickness × width'], + ['paneRagged', 0, 1, 0.01, 'corner wander'], + ['paneSpawnSpan', 0, 2, 0.01, 'break annulus × zone'], + ['paneSpawnHeight', 0, 4, 0.01, 'break height (m)'], + ['paneSpawnRadius', 0, 2, 0.01, 'break scatter (m)'], + ['paneSize', 0.02, 2, 0.01, 'pane size (m)'], + ['paneSizeJitter', 0, 1, 0.01, 'size jitter'], + ['paneSpeed', 0, 25, 0.1, 'throw speed'], + ['paneSpeedJitter', 0, 1, 0.01, 'speed jitter'], + ['paneSpread', 0, 1, 0.01, 'throw spread'], + ['paneUp', 0, 1, 0.01, 'upward bias'], + ['paneGravity', -50, 5, 0.1, 'gravity'], + ['paneDrag', 0, 5, 0.01, 'drag'], + ['paneSpin', 0, 30, 0.1, 'tumble'], + ['paneSpinJitter', 0, 1, 0.01, 'tumble jitter'], + ['paneLifetime', 0.1, 6, 0.05, 'lifetime'], + ['paneShrink', 0, 1, 0.01, 'shrink'], + ['paneShrinkPower', 0.2, 6, 0.05, 'shrink curve'], + ['paneFloorSpin', 0, 1, 0.01, 'grounded tumble'] + ], + 'The panes/Shading': [ + ['paneOpacity', 0, 1, 0.01, 'opacity'], + ['paneGlow', 0, 4, 0.01, 'edge glow'], + ['paneRim', 0, 3, 0.01, 'rim'], + ['paneRimPower', 0.2, 8, 0.05, 'rim power'], + ['paneShade', 0, 1, 0.01, 'key light'], + ['paneAmbient', 0, 1, 0.01, 'ambient'], + ['paneFadeStart', 0, 1, 0.01, 'fade start'], + ['paneSoft', 0, 2, 0.01, 'soft fade (m)'], + ['paneSceneMix', 0, 1, 0.01, 'scene through'], + ['paneRefract', 0, 0.3, 0.001, 'refraction'], + ['paneSaturation', 0, 1, 0.01, 'scene saturation'], + ['colorPaneA', 'pane at birth'], + ['colorPaneB', 'pane at death'], + ['colorPaneEdge', 'pane edge'], + ['colorPaneScene', 'scene tint'] + ], + 'The push': [ + ['pushSpan', 0.2, 4, 0.01, 'radius × zone'], + ['pushLift', 0, 5, 0.01, 'anchor height (m)'], + ['pushStrength', 0, 0.3, 0.001, 'strength (screen)'], + ['pushWindow', 0.1, 1, 0.01, 'window'], + ['pushMaxOffset', 0.01, 0.4, 0.005, 'max offset'], + ['pushThickness', 0.05, 4, 0.01, 'wavefront (m)'], + ['pushCompression', 0, 3, 0.01, 'compression'], + ['pushRarefaction', 0, 3, 0.01, 'rarefaction'], + ['pushRings', 1, 4, 1, 'rings'], + ['pushRingGap', 0.1, 6, 0.05, 'ring gap (m)'], + ['pushRingDecay', 0, 1, 0.01, 'ring decay'], + ['pushDepthFade', 0, 3, 0.01, 'depth fade (m)'], + ['pushOpacity', 0, 1, 0.01, 'opacity'] + ], + 'Glints & drained colour': [ + ['glintRate', 0, 800, 1, 'glint rate'], + ['glintBreak', 0, 900, 1, 'glints on break'], + ['glintSize', 0.005, 0.6, 0.005, 'glint size'], + ['glintSpeed', 0, 30, 0.1, 'glint speed'], + ['glintLifetime', 0.05, 4, 0.01, 'glint lifetime'], + ['glintGravity', -50, 5, 0.1, 'glint gravity'], + ['glintStretch', 0, 3, 0.01, 'glint stretch'], + ['dustRate', 0, 600, 1, 'dust rate'], + ['dustBreak', 0, 700, 1, 'dust on break'], + ['dustSize', 0.005, 0.5, 0.005, 'dust size'], + ['dustSpeed', 0, 12, 0.05, 'dust speed'], + ['dustLifetime', 0.1, 8, 0.05, 'dust lifetime'], + ['dustRise', -3, 6, 0.05, 'dust rise'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['colorGlint*', 'Glint colour'], + ['colorDust*', 'Drained colour'] + ], + Grit: [ + ['gritRate', 0, 300, 1, 'grit rate'], + ['gritBreak', 0, 400, 1, 'grit on break'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 25, 0.1, 'grit speed'], + ['gritLifetime', 0.1, 5, 0.05, 'grit lifetime'], + ['gritGravity', -50, 0, 0.1, 'grit gravity'], + ['colorGrit*', 'Grit colour'] + ], + 'The break': [ + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['shockSpan', 0.2, 5, 0.01, 'floor ring × zone'], + ['breakFlash', 0, 2, 0.01, 'screen flash'], + ['breakShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst core'], + ['colorShockA', 'floor ring'], + ['colorShockB', 'floor ring crest'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'light swell'], + ['lightPulseSpeed', 0.5, 40, 0.1, 'swell rate'], + ['lightColor', 'light colour'] + ], + 'Inert Shell keys (CONE / RING_TRAIN / SUNDISC)': [ + ['glassSpan', 0.1, 30, 0.1, 'run length (m)'], + ['glassEdge', 0, 4, 0.01, 'cone lip'], + ['glassEdgeWidth', 0.01, 0.6, 0.005, 'cone lip width'], + ['glassConeCurve', 0.1, 4, 0.01, 'cone curve'], + ['glassRings', 1, 24, 1, 'rings'], + ['glassSpacing', 0.1, 8, 0.05, 'ring wavelength (m)'], + ['glassRingSpeed', 0, 40, 0.1, 'ring speed'], + ['glassRingThickness', 0.01, 1, 0.005, 'ring thickness'], + ['glassRingSharp', 0.1, 6, 0.05, 'ring profile'], + ['glassReflect', 0, 1, 0.01, 'reflection'], + ['glassStanding', 0, 1, 0.01, 'standing envelope'], + ['glassSwell', 0, 2, 0.01, 'antinode swell'], + ['glassCoronaReach', 0.5, 4, 0.01, 'corona reach'], + ['glassCorona', 0, 4, 0.01, 'corona'], + ['glassCoronaLength', 0, 2, 0.01, 'corona length'], + ['glassCoronaScale', 0.5, 16, 0.1, 'corona scale'], + ['glassCoronaWarp', 0, 2, 0.01, 'corona warp'], + ['glassCoronaSpeed', 0, 4, 0.01, 'corona Hz'], + ['glassCoronaSharp', 0, 1, 0.01, 'corona threshold'], + ['glassGranule', 0, 2, 0.01, 'granulation'], + ['glassGranuleScale', 1, 20, 0.1, 'granule scale'], + ['glassRimWidth', 0.01, 1, 0.01, 'disc rim width'], + ['glassColorCorona', 'corona colour'] + ] +}; diff --git a/src/config/abilities/splatterbrand.js b/src/config/abilities/splatterbrand.js new file mode 100644 index 0000000..9b93b34 --- /dev/null +++ b/src/config/abilities/splatterbrand.js @@ -0,0 +1,427 @@ +/* ================================================================== */ +/* SPLATTERBRAND — Splatterbrand */ +/* ================================================================== */ +/** + * A loaded brush flung down the line. + * + * The trick is **splatter morphology**, and the whole of it is that a thrown + * blob does not make a circle. It makes three things, and the third one is the + * one your eye actually recognises: + * + * 1. a **directional main mass**, stretched down the travel vector, blunter at + * the trailing edge than the leading one because the trailing edge is where + * the sheet tore away rather than where it spread to (`massAlong`, + * `massAcross`, `massLead`, `massRear`); + * 2. a **crown of teeth** on the leading arc, which is a Rayleigh–Plateau + * breakup of an expanding rim and therefore genuinely periodic in bearing + * with a tooth count that rises with the rim's own radius (`crown`, + * `crownSpacing`, `crownSharp`, `crownGate`); + * 3. **satellite droplets** thrown further along the travel vector, sized by a + * bounded Pareto law, with the small ones flying furthest and arriving last. + * + * Number three is where this block earns its keep. `satAlpha` is the exponent + * of the power law and 2–3 is what fragmenting sheets actually do; set it to 8 + * and the draw collapses onto `satMin`, which is a dozen identical dots and + * reads as a stencil. `throwCurve` couples distance to that same draw, so the + * far field is fine and the near field coarse. Those two numbers are the + * ability. + * + * ### Every droplet is flown, not just marked + * + * `vfx/InkDiffusion.js` uploads its satellite dice as uniform arrays rather + * than hashing them in the shader, precisely so the CPU can ask exactly where + * droplet *i* is going to land. This block therefore drives a `vfx/Projectile` + * of `satellites + 1` bodies whose landing points and arrival times are + * *derived from the mark* — the flying drop lands in its own splash on the + * frame the splash pops in. Which is why the numbers below have no separate + * scatter controls for the bodies in the air: there is one distribution and the + * shader owns it. + * + * ### The school has no bloom in it + * + * No flash, no burst shell, no additive particle, no decal. `markCeiling` hard + * clamps the field's luminance below `post.bloomThreshold`, and the trail is + * `NormalBlending` with pigment-dark stops — even at `global.glow` pinned to + * its maximum its luminance stays an order of magnitude under the threshold. + * The only specular term in the ability is the wet gloss on the leading edge, + * inside the clamp. + */ + +export const splatterbrand = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 27.0, // how fast the blob crosses the line, metres/second + cooldown: 1.2, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats, in seconds --- + * `impactDuration` is not `holdTime` on its own: the last satellite lands + * `satDelay + satJitter` after the mass does, and a phase that ended before + * then would tear the projectile out of the air mid-throw. + */ + holdTime: 3.0, // seconds the mark stands once the last droplet is down + fadeTime: 2.4, // seconds it soaks away over + + /* ================================================================ */ + /* The throw — Projectile(ARC) */ + /* ================================================================ */ + /** + * One launch point for all seventeen bodies, because it is one handful. + * + * There is no `spread` here and no `count`: how far each droplet is thrown + * and how many there are is decided by the mark, under `The satellites` + * below. What lives here is only what the *flight* needs. + */ + handHeight: 1.42, // metres above the floor the handful leaves the caster at + handForward: 0.62, // metres in front of the caster + handSide: 0.3, // metres to the side (+ follows `Ability#side`) + apex: 2.1, // metres of ballistic loft + apexCurve: 1.05, // >1 flattens the top of the lob + pathCurve: 1.0, // easing exponent on launch → land; >1 accelerates + + massRadius: 0.27, // metres — the blob in the air. Not the mark it makes + dropScale: 0.72, // a droplet's body radius as a fraction of its own mark + dropStretch: 1.85, // × along the heading — a drop in flight is not a sphere + dropSpin: 2.4, // radians/second of tumble on the unaligned axis + dropFlash: 0.05, // seconds of birth pop + dropLinger: 0.09, // seconds a landed body stays before it has fully sunk + dropSink: 2.6, // body radii it sinks over that linger — into its own splash + + /* --- the blob's silhouette. A change rebuilds the geometry --- */ + blobFacets: 1, // icosphere subdivisions, 0..2 + blobLumps: 0.34, // how far the surface wanders + blobLumpScale: 1.7, // features per unit radius + blobRough: 0.1, // fine break-up on top of the lumps + blobRoughness: 0.36, // MeshStandardMaterial roughness — wet, not glossy + blobMetalness: 0.0, // ink is not a metal and this is here to stay zero + colorBlob: '#0e0d13', // the body in the air + + /* ================================================================ */ + /* The mark — InkDiffusion(SPLATTER) */ + /* ================================================================ */ + + /* --- the spread --- */ + markRadius: 4.6, // metres the *mass* is allowed to reach. Satellites are exempt + markHeight: 0.02, // metres above the floor + spread: 2.35, // metres of front radius at t = 1 s + spreadPower: 0.36, // well under Fickian: a splat arrives, it does not diffuse + edge: 0.075, // metres, the width of the interface + clipSoft: 0.85, // metres the mass fades out over as it reaches `markRadius` + sources: 3, // live nuclei — a flung blob lands in pieces + sourceScatter: 0.22, // fraction of the radius the later nuclei scatter over + sourceDelay: 0.05, // seconds each later nucleus starts behind the first + + /* --- the instability, riding on top of the directional mass --- */ + finger: 0.42, // overall amplitude; 0 gives a smooth lozenge + fingerMax: 0.6, // cap, as a fraction of the front radius + coarse: 1.5, // metres — the coarsest lobe. Tighter than a bloom's, on purpose + onset: 0.6, // front radii per wavelength before a mode is admitted + growth: 1.2, // e-folds per wavelength of front travel + growthMax: 1.3, // saturation — a finger stops growing at its own width + + /* --- the film --- */ + core: 0.62, // density floor inside the mass + falloff: 2.8, // metres of e-folding out from a nucleus + film: 0.66, // alpha floor inside the coverage + granulation: 0.24, // pigment settling into the paper's tooth as it dries + granScale: 2.2, // features per metre + ring: 0.62, // the deposition line at the interface + ringWidth: 0.085, // metres + + /* --- wet and dry --- */ + dryTime: 1.5, // seconds for the gloss to fall to 1/e + wetDarken: 0.45, // how far the wet film pulls toward `colorMarkWet` + gloss: 0.26, // specular strength on the wet film — under the ceiling + glossPower: 42, // its tightness + meniscus: 0.62, // how far the film's normal tips at the interface + + /* --- the mass: the anisotropy is in the metric, never in a bearing --- */ + massAlong: 1.5, // metric stretch down the travel vector + massAcross: 0.74, // and across it + massLead: 0.24, // fraction of the front the mass sits forward by + massRear: 1.55, // >1 blunts the trailing edge, where the sheet tore + + /* --- the crown on the leading arc --- */ + crown: 0.38, // fraction of the front the teeth add + crownSpacing: 0.5, // metres of rim per tooth — the tooth count follows the radius + crownSharp: 8, // tooth narrowness + crownGate: 2.4, // how tightly the crown is held to the leading arc + + /* --- the satellites. `satAlpha` and `throwCurve` are the ability --- */ + satellites: 13, // live droplets, and the number of bodies flown after the mass + satMin: 0.05, // metres, smallest droplet radius + satMax: 0.4, // metres, largest + satAlpha: 2.35, // exponent of the size power law — 2..3 is what splashes do + throwNear: 1.7, // metres past the mass, nearest droplet + throwFar: 9.5, // metres, furthest — also sizes the quad + throwCurve: 1.8, // bias of the distance draw; small drops fly furthest + throwSpread: 0.2, // lateral cone, as a fraction of the distance + satTail: 2.5, // teardrop tail length, in droplet radii + satDelay: 0.5, // seconds the furthest droplet lands behind the mass + satJitter: 0.14, // seconds of per-droplet slop on that + satPop: 0.055, // seconds a droplet's mark takes to appear + + /* --- how the mark is inked --- */ + markOpacity: 1.0, + markCeiling: 0.6, // max linear luminance; `post.bloomThreshold` is 0.88 + markSoftFade: 0.25, // metres of depth feather against standing geometry + markTint: 0.04, // where in the gradient a zero-density film sits + markTintDensity: 1.12, // over 1, so a thick film reaches `colorMarkPool` + colorMarkThin: '#a37f6e', // the thinnest film — vermilion runs warm when dilute + colorMarkBody: '#8c2f1e', + colorMarkDeep: '#42120c', + colorMarkPool: '#160604', // where the mass went down + colorMarkRing: '#5e1c0e', // the deposition line at the interface + colorMarkWet: '#1e0805', // what the still-wet film pulls toward + colorMarkGloss: '#d2c8bc', // the sheen on the wet leading edge + + /* ================================================================ */ + /* The ligaments — Projectile's instanced trail */ + /* ================================================================ */ + /** + * One ribbon width serves every body, which is a real limitation of the + * shared trail and one this ability happens to be able to live with: what + * follows a detaching drop is a **ligament**, the thread that connects it to + * the sheet, and a ligament's thickness is set by surface tension rather than + * by the drop on the end of it. Turn `trailWidth` up past about 0.15 m and + * the excuse stops working. + */ + trailSpan: 0.24, // seconds of flight the tail reaches back over + trailBurn: 0.14, // seconds the tail takes to catch the head up after landing + trailWidth: 0.075, // metres at the head + trailTaper: 1.6, // >1 sharpens the tail to a point + trailLift: 0.0, // metres the tail floats above the flown path + trailOpacity: 0.85, + trailGlow: 0.55, // NOT a glow — the trail is NormalBlending and this only dims it + trailCore: 2.6, // how tightly ink crowds the centre line + trailHeadBias: 0.55, // >0 keeps the density near the body + trailNoise: 0.45, + trailNoiseScale: 2.4, // features per metre + trailNoiseSpeed: 0.5, + trailSoftFade: 0.35, // metres of depth feather + colorTrailA: '#7a3020', // at the head, where the ligament is thickest + colorTrailB: '#4a170e', + colorTrailC: '#240a06', + colorTrailD: '#0e0405', + + /* ================================================================ */ + /* Particles */ + /* ================================================================ */ + /** + * Two systems, both **non-additive**, both burst-driven off the projectile's + * own arrival list rather than emitted at a rate. A droplet throws its spatter + * when it lands and never at any other time, which is what makes the far field + * keep arriving after the mass has gone quiet. + */ + + /* --- the fine spatter thrown off every landing --- */ + spatterCount: 14, // particles per satellite arrival + spatterMassCount: 90, // ...and at the mass + spatterSize: 0.045, + spatterSpeed: 3.2, // metres/second + spatterLifetime: 0.85, + spatterGravity: -9.5, // metres/second² — these are droplets, they fall + colorSpatterA: '#8c2f1e', + colorSpatterB: '#5a1a10', + colorSpatterC: '#2c0b07', + colorSpatterD: '#120504', + + /* --- atomised ink hanging over the mark --- */ + mistCount: 26, // puffs at the mass impact + mistRate: 12, // puffs/second while the mark is still wet + mistSize: 0.65, + mistSpeed: 1.1, // metres/second + mistLifetime: 2.2, + mistOpacity: 0.07, // it occludes; more than a tenth and it is fog, not ink + mistRise: 0.42, // metres/second + colorMistA: '#5c3226', + colorMistB: '#3e2019', + colorMistC: '#281410', + colorMistD: '#160b09', + + /* ================================================================ */ + /* Feedback */ + /* ================================================================ */ + // No flash and no burst: both are emissive and this school does not emit. + impactShake: 0.42, // camera kick as the mass lands + shakeDuration: 0.4, // seconds it decays over + rumble: 0.008, // continuous shake while the handful is in the air + + /* --- dynamic light --- + * Dim, and warm because the mark is vermilion and a wet vermilion floor + * bounces warm. Above about 12 the splatter starts to look lit from within, + * which is the failure mode of the school. + */ + lightIntensity: 5.0, + lightRadius: 8.5, + lightColor: '#a8705c', + lightPulse: 0.14, // depth of its slow swell, 0 = steady + lightPulseSpeed: 2.2 // swells/second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Splatterbrand. + * + * **The satellites** is the folder that owns the ability. Drag `satAlpha` from + * 2.35 up to 6 with the clock stopped and watch the far field collapse into a + * row of identical dots; drag `throwCurve` to 0.2 and watch the biggest drops + * fly furthest, which is the one thing a splatter never does. Both re-place the + * flying bodies as well as the marks, because the bodies are derived from the + * marks and there is only one distribution in the ability. + * + * **The mass** is the second stop. `massAcross` at 1.0 with `massAlong` at 1.0 + * is a circle, and a circle is the failure this whole slot exists to avoid. + */ +export const splatterbrandSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 4, 120, 0.5, 'throw speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['holdTime', 0.2, 12, 0.05, 'hold time (s)'], + ['fadeTime', 0.1, 8, 0.05, 'soak-away time (s)'] + ], + 'The satellites': [ + ['satellites', 0, 16, 1, 'droplets'], + ['satAlpha', 1.1, 6, 0.01, 'size power law'], + ['satMin', 0.005, 1, 0.005, 'smallest (m)'], + ['satMax', 0.02, 2, 0.005, 'largest (m)'], + ['throwNear', 0, 20, 0.05, 'nearest (m)'], + ['throwFar', 0.5, 30, 0.05, 'furthest (m)'], + ['throwCurve', 0.05, 6, 0.01, 'distance bias'], + ['throwSpread', 0, 1, 0.005, 'lateral cone'], + ['satTail', 0, 8, 0.05, 'teardrop tail (radii)'], + ['satDelay', 0, 3, 0.01, 'furthest lands after (s)'], + ['satJitter', 0, 1, 0.005, 'arrival slop (s)'], + ['satPop', 0.005, 0.6, 0.005, 'mark pop-in (s)'] + ], + 'The mass': [ + ['massAlong', 0.1, 4, 0.01, 'stretch along'], + ['massAcross', 0.1, 4, 0.01, 'stretch across'], + ['massLead', -1, 1, 0.005, 'forward offset'], + ['massRear', 0.2, 4, 0.01, 'trailing bluntness'], + ['markRadius', 0.5, 14, 0.05, 'mass clip radius (m)'], + ['spread', 0.1, 12, 0.01, 'front at 1 s (m)'], + ['spreadPower', 0.1, 1.5, 0.005, 'front exponent'], + ['sources', 1, 3, 1, 'nuclei'], + ['sourceScatter', 0, 1, 0.005, 'nucleus scatter'], + ['sourceDelay', 0, 1, 0.005, 'nucleus stagger (s)'], + ['clipSoft', 0.05, 4, 0.01, 'clip feather (m)'] + ], + 'The crown': [ + ['crown', 0, 1.5, 0.005, 'tooth reach'], + ['crownSpacing', 0.05, 3, 0.01, 'metres of rim / tooth'], + ['crownSharp', 1, 30, 0.5, 'tooth narrowness'], + ['crownGate', 0.05, 8, 0.05, 'held to the leading arc'] + ], + 'The instability': [ + ['coarse', 0.2, 8, 0.02, 'coarsest lobe (m)'], + ['finger', 0, 2, 0.01, 'finger amplitude'], + ['fingerMax', 0.05, 2, 0.01, 'finger cap (× front)'], + ['onset', 0.05, 2, 0.01, 'admission (radii / λ)'], + ['growth', 0, 4, 0.01, 'growth (e-folds / λ)'], + ['growthMax', 0.1, 6, 0.01, 'growth saturation'] + ], + 'The film': [ + ['markHeight', 0.002, 0.2, 0.001, 'quad height (m)'], + ['edge', 0.005, 1, 0.005, 'interface width (m)'], + ['core', 0, 1, 0.005, 'density floor'], + ['falloff', 0.2, 20, 0.05, 'density e-fold (m)'], + ['film', 0, 1, 0.005, 'alpha floor'], + ['granulation', 0, 1, 0.005, 'granulation'], + ['granScale', 0.1, 8, 0.02, 'grain / metre'], + ['ring', 0, 2, 0.01, 'deposition ring'], + ['ringWidth', 0.01, 1, 0.005, 'ring width (m)'], + ['dryTime', 0.05, 12, 0.05, 'dry time (s)'], + ['wetDarken', 0, 1, 0.005, 'wet darkening'], + ['gloss', 0, 1.5, 0.005, 'wet gloss'], + ['glossPower', 2, 160, 1, 'gloss tightness'], + ['meniscus', 0, 2, 0.01, 'meniscus'], + ['markOpacity', 0, 2, 0.01, 'mark opacity'], + ['markCeiling', 0.05, 1, 0.005, 'luminance ceiling'], + ['markSoftFade', 0.01, 3, 0.01, 'soft intersection (m)'], + ['markTint', 0, 1, 0.005, 'gradient floor'], + ['markTintDensity', 0, 2, 0.01, 'gradient / density'], + ['colorMarkThin', 'thinnest film'], + ['colorMarkBody', 'body'], + ['colorMarkDeep', 'deep'], + ['colorMarkPool', 'the mass'], + ['colorMarkRing', 'deposition line'], + ['colorMarkWet', 'wet ink'], + ['colorMarkGloss', 'wet sheen'] + ], + 'The throw': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['apex', 0, 12, 0.05, 'loft (m)'], + ['apexCurve', 0.1, 4, 0.01, 'loft flatness'], + ['pathCurve', 0.2, 4, 0.01, 'path easing'], + ['massRadius', 0.02, 1.5, 0.005, 'blob radius (m)'], + ['dropScale', 0.05, 2, 0.01, 'droplet body / mark'], + ['dropStretch', 0.2, 5, 0.01, 'stretch along heading'], + ['dropSpin', 0, 20, 0.1, 'tumble (rad/s)'], + ['dropFlash', 0, 1, 0.005, 'birth pop (s)'], + ['dropLinger', 0, 1, 0.005, 'linger on landing (s)'], + ['dropSink', 0, 8, 0.05, 'sink (radii)'] + ], + 'The blob': [ + ['blobFacets', 0, 2, 1, 'subdivisions'], + ['blobLumps', 0, 1.5, 0.01, 'lumpiness'], + ['blobLumpScale', 0.2, 6, 0.05, 'lumps / radius'], + ['blobRough', 0, 1, 0.005, 'surface break-up'], + ['blobRoughness', 0.02, 1, 0.005, 'material roughness'], + ['blobMetalness', 0, 1, 0.01, 'material metalness'], + ['colorBlob', 'blob colour'] + ], + 'The ligaments': [ + ['trailSpan', 0.01, 2, 0.005, 'tail reach (s)'], + ['trailBurn', 0.01, 2, 0.005, 'tail catch-up (s)'], + ['trailWidth', 0.005, 0.6, 0.005, 'ribbon width (m)'], + ['trailTaper', 0.1, 5, 0.01, 'tail taper'], + ['trailLift', 0, 1, 0.005, 'tail lift (m)'], + ['trailOpacity', 0, 2, 0.01, 'ligament opacity'], + ['trailGlow', 0, 2, 0.01, 'ligament level'], + ['trailCore', 0.2, 8, 0.05, 'centre crowding'], + ['trailHeadBias', -1, 2, 0.01, 'head bias'], + ['trailNoise', 0, 2, 0.01, 'break-up'], + ['trailNoiseScale', 0.1, 8, 0.05, 'break-up / metre'], + ['trailNoiseSpeed', 0, 4, 0.01, 'break-up speed'], + ['trailSoftFade', 0.01, 3, 0.01, 'soft intersection (m)'], + ['colorTrail*', 'Ligament colour'] + ], + 'Spatter & mist': [ + ['spatterCount', 0, 120, 1, 'per droplet'], + ['spatterMassCount', 0, 600, 1, 'at the mass'], + ['spatterSize', 0.005, 0.4, 0.005, 'spatter size'], + ['spatterSpeed', 0, 20, 0.05, 'spatter speed'], + ['spatterLifetime', 0.05, 5, 0.01, 'spatter lifetime'], + ['spatterGravity', -30, 5, 0.1, 'spatter gravity'], + ['mistCount', 0, 200, 1, 'mist at the mass'], + ['mistRate', 0, 200, 1, 'mist rate'], + ['mistSize', 0.05, 4, 0.01, 'mist size'], + ['mistSpeed', 0, 8, 0.05, 'mist speed'], + ['mistLifetime', 0.2, 10, 0.05, 'mist lifetime'], + ['mistOpacity', 0, 0.6, 0.002, 'mist opacity'], + ['mistRise', -2, 4, 0.01, 'mist rise'], + ['colorSpatter*', 'Spatter colour'], + ['colorMist*', 'Mist colour'] + ], + 'Feedback & light': [ + ['impactShake', 0, 3, 0.01, 'impact shake'], + ['shakeDuration', 0.05, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.2, 0.001, 'throw rumble'], + ['lightIntensity', 0, 40, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightPulse', 0, 1, 0.01, 'light swell'], + ['lightPulseSpeed', 0.1, 12, 0.1, 'swell rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/sporefall.js b/src/config/abilities/sporefall.js new file mode 100644 index 0000000..37210c4 --- /dev/null +++ b/src/config/abilities/sporefall.js @@ -0,0 +1,310 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* SPOREFALL — verdant, far cast */ +/* ================================================================== */ +/** + * The quietest cast in the sandbox, and the only volume in it that goes + * **sideways**. + * + * Every other raymarched thing in the expansion billows: a dome, a cone, a + * column. This one pours. A slab of spore-laden air opens on the circle, its + * density field stretched flat in Y so every eddy in it is a pancake rather + * than a ball, and it spreads outward across the floor and pools in the middle + * instead of climbing. Out of it, bioluminescent motes drift up — and **die at + * head height**. + * + * That death height is the ability. A mote that keeps rising turns a zone cast + * into fog: the scene fills, the character is lost in it, and the read stops + * being "there is something on the ground there" and becomes "the fog machine + * is on". So `moteDeathHeight` is a real metre, and the motes genuinely stop + * there: their lifetime is *solved* from it against the rise speed and the drag + * every frame, written to the system's `uLifeScale`, which means dragging the + * slider with the clock stopped re-times every mote already in the air and + * moves the ceiling they vanish at. See `SporefallAbility#_moteLifetime`. + * + * The two pieces are a `VolumeHull(BOX, SPORE)` and a `GroundField(POOL)` — + * two draw calls for the whole cast, which is the other half of "quiet". + */ +export const sporefall = { + /* --- the cast --- */ + range: 19.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 22.0, // how fast the seep runs to the circle, metres/second + cooldown: 1.6, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + zoneRadius: 5.4, // the footprint the indicator draws, metres + + /* --- the beats, all seconds --- */ + seepTime: 1.6, // the slab spreading from the impact point to the boundary + holdTime: 4.5, // it lying there + disperseTime: 2.6, // it thinning out and sinking + + /* --- the slab --- */ + // Half-extents handed to `setSize()` every frame. The hull is a BOX rather + // than a CYLINDER because a box with `sporeRound` near 1 gives the same + // rounded footprint for the same money *and* keeps its corners available: + // turn the rounding down and the slab reads as a spill with a straight + // leading edge, which is a different and useful look. + sporeRadius: 1.0, // slab half-width, × zoneRadius + sporeThickness: 0.62, // slab height, metres — this is the whole trick + sporeBase: 0.0, // metres above the floor the slab starts + sporeGather: 0.3, // footprint at the moment it lands, × the full one + sporeHeap: 1.9, // thickness at that moment, × the full one + sporeSink: 0.45, // 0..1 of the thickness it loses as it disperses + sporeCreep: 0.18, // extra footprint it keeps taking while it disperses, × R + ...volumeHullDefaults('spore', Medium.SPORE, { + sporeSteps: 26, + sporeDensity: 1.25, + sporeDensityCurve: 1.3, + sporeMargin: 0.2, + sporeRound: 0.92, + // Low, on purpose. A slab this thin has almost no Y to fall off across, and + // biasing density downward inside it just makes the top half empty and the + // silhouette a hard lid. + sporeHeightBias: 0.12, + sporeFeather: 0.55, + sporeSoftness: 0.6, + sporeNoiseFrequency: 0.95, + sporeNoiseStrength: 0.7, + // The pancake. `Flatten` stretches the *noise domain's* Y, so every eddy is + // short and wide; squashing the hull alone gives a low cloud made of round + // blobs, which reads as a cloud someone sat on. + sporeFlatten: 0.88, + sporeRise: 0.08, + sporeFlowY: -0.06, + sporeOctaves: 3, + sporeAbsorption: 0.55, + sporeScatter: 1.5, + sporeAmbient: 0.5, + sporeAnisotropy: 0.45, + sporeEmission: 1.5, + sporeEmissionCurve: 0.7, + sporeSpeckDensity: 0.14, + sporeSpeckScale: 3.0, + sporeSpeckSize: 0.18, + sporeSpeckGlow: 5.0, + sporeColorCore: '#a8e8b8', + sporeColorMid: '#7ad0a0', + sporeColorEdge: '#2a6b4a', + sporeColorDeep: '#0c1c14', + sporeColorLight: '#d8ffe8', + sporeColorSpeck: '#c8ff9a' + }), + + /* --- the pool on the floor --- */ + poolRadius: 1.02, // × zoneRadius + poolHeight: 0.02, // metres above the floor the quad sits at + poolEdge: 0.55, // metres of feather on the spreading front + poolRagged: 0.3, // how far that front wanders, as a fraction of the radius + poolRaggedScale: 0.55, // lobes per metre + poolWarp: 0.7, // metres of domain warp on those lobes + poolRelief: 0.35, // how hard the height field tilts the fake normal + poolAmbient: 0.4, + poolSpecular: 0.55, // wet-looking, because a spore pool is damp + poolGloss: 30, + poolCell: 0.9, // metres — the meniscus cell pitch + poolDepth: 0.14, // metres of apparent depth + poolFlow: 0.12, // metres/second the surface drifts at + poolSwirl: 0.22, + poolDetail: 0.55, + poolSharp: 0.35, + poolSpeed: 0.4, // surface events per second + poolEmissive: 0.85, // multiplier on the glowing terms + poolOpacity: 0.9, + poolDepthFade: 0.6, // metres of soft fade against standing geometry + colorPoolBase: '#2a6b4a', // the liquid itself + colorPoolEdge: '#7ad0a0', // the meniscus rim + colorPoolGlow: '#c8ff9a', // whatever is glowing in it + colorPoolDeep: '#08140e', // the bottom + + /* --- the motes: the thing you actually watch --- */ + /** + * Each system is coloured by a four-stop gradient sampled over the + * particle's own lifetime, `A` at birth through `D` as it dies. `D` matters + * more here than anywhere else in the project: it is the colour a mote is + * wearing at exactly the moment it reaches `moteDeathHeight`, so it is what + * draws the ceiling. + */ + moteRate: 34, // motes leaving the slab, particles/second + moteSize: 0.05, + moteRise: 1.15, // metres/second straight up, before drag + moteDrag: 0.62, // 1/second — with the rise, this sets the terminal height + moteSpread: 0.22, // 0..1 cone on the launch direction + moteSpeedVariance: 0.3, // 0..1 — softens the ceiling into a layer, not a plane + moteBirthHeight: 0.16, // metres above the floor they leave from + moteDeathHeight: 1.85, // metres — THE slider. Head height, and it matters. + moteSag: -0.05, // metres/second² — the whisper of gravity at the top + moteTurbulence: 0.55, + moteGlow: 2.2, + moteInset: 0.92, // 0..1 of the footprint they rise from + colorMoteA: '#e8ffd0', + colorMoteB: '#c8ff9a', + colorMoteC: '#7ad0a0', + colorMoteD: '#1c4030', + + /* --- the drift: heavy air rolling off the slab's edge --- */ + driftRate: 20, // particles/second + driftSize: 0.9, + driftSpeed: 0.55, // metres/second, outward + driftLifetime: 3.2, + driftOpacity: 0.07, + driftRise: 0.06, // metres/second² — nearly nothing, it is heavy + driftInset: 0.85, // 0..1 of the footprint it leaves from + colorDriftA: '#4a8a68', + colorDriftB: '#376a50', + colorDriftC: '#264a38', + colorDriftD: '#12241a', + + /* --- the muzzle and the landing --- */ + handHeight: 1.18, // metres above the floor the cast leaves from + handForward: 0.48, // metres in front of the caster + handSide: 0.16, // metres to the side + muzzleSize: 0.4, // the puff at the hand, metres + muzzleIntensity: 0.9, + castFlash: 0.04, // screen flash on release — barely anything, on purpose + colorCastFlash: '#9fe8b0', + burstSize: 2.2, // the low shell as the slab lands, metres + burstIntensity: 0.7, + burstMotes: 90, // motes released at that moment + impactShake: 0.16, + shakeDuration: 0.9, + impactFlash: 0.05, + rumble: 0.008, // continuous shake while the seep travels + colorBurstA: '#2a6b4a', + colorBurstB: '#7ad0a0', + colorBurstC: '#c8ff9a', + colorFlash: '#9fe8b0', + + /* --- dynamic light --- */ + // Deliberately weak and deliberately low. The slab is emissive on its own and + // a strong light in the middle of it flattens the whole thing out. + lightIntensity: 7, + lightRadius: 9, + lightHeight: 0.5, // metres above the floor + lightColor: '#7ad0a0', + lightBreathe: 0.18, // depth of the slow swell on the light, 0 = steady + lightBreatheRate: 0.55 // swells per second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Sporefall. + * + * Two sliders carry the cast and they are worth finding first. + * `moteDeathHeight`, in **The motes**, is the ceiling the glow stops at — + * take it to four metres and watch the zone cast turn into weather, which is + * the failure this ability is built to avoid. `sporeFlatten`, in **spore · + * flow**, is the pancake: take it to zero and the slab immediately reads as a + * low cloud rather than as something pouring, with every other number + * unchanged. + */ +export const sporefallSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 150, 1, 'seep speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['zoneRadius', 1, 16, 0.05, 'footprint radius'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['seepTime', 0.1, 6, 0.05, 'spreading out'], + ['holdTime', 0.2, 14, 0.1, 'lying there'], + ['disperseTime', 0.2, 8, 0.05, 'thinning out'] + ], + 'The slab': [ + ['sporeRadius', 0.2, 3, 0.01, 'half-width'], + ['sporeThickness', 0.05, 4, 0.01, 'thickness'], + ['sporeBase', -0.5, 3, 0.01, 'base height'], + ['sporeGather', 0.05, 1, 0.01, 'footprint on landing'], + ['sporeHeap', 0.5, 5, 0.01, 'thickness on landing'], + ['sporeSink', 0, 1, 0.01, 'sink as it disperses'], + ['sporeCreep', 0, 1, 0.01, 'creep as it disperses'] + ], + ...volumeHullSchema('spore', { + label: 'The slab', + only: ['march', 'shape', 'field', 'flow', 'optics', 'speck', 'colour'] + }), + 'The pool': [ + ['poolRadius', 0.2, 3, 0.01, 'radius'], + ['poolHeight', 0, 0.3, 0.005, 'hover height'], + ['poolEdge', 0.02, 3, 0.01, 'front feather'], + ['poolRagged', 0, 1, 0.01, 'front raggedness'], + ['poolRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['poolWarp', 0, 3, 0.01, 'domain warp'], + ['poolRelief', 0, 2, 0.01, 'relief'], + ['poolAmbient', 0, 1, 0.01, 'ambient'], + ['poolSpecular', 0, 2, 0.01, 'specular'], + ['poolGloss', 2, 128, 1, 'gloss'], + ['poolCell', 0.05, 4, 0.01, 'cell pitch'], + ['poolDepth', 0, 1, 0.005, 'apparent depth'], + ['poolFlow', 0, 2, 0.01, 'surface flow'], + ['poolSwirl', 0, 2, 0.01, 'swirl'], + ['poolDetail', 0, 1, 0.01, 'detail'], + ['poolSharp', 0, 1, 0.01, 'sharpness'], + ['poolSpeed', 0, 4, 0.01, 'surface speed'], + ['poolEmissive', 0, 3, 0.01, 'emissive'], + ['poolOpacity', 0, 1, 0.01, 'opacity'], + ['poolDepthFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorPoolBase', 'liquid'], + ['colorPoolEdge', 'meniscus'], + ['colorPoolGlow', 'glow'], + ['colorPoolDeep', 'bottom'] + ], + 'The motes': [ + ['moteDeathHeight', 0.2, 8, 0.01, 'death height (m)'], + ['moteBirthHeight', 0, 2, 0.01, 'birth height (m)'], + ['moteRise', 0.05, 6, 0.01, 'rise speed (m/s)'], + ['moteDrag', 0.05, 4, 0.01, 'drag (1/s)'], + ['moteSag', -3, 1, 0.01, 'sag (m/s2)'], + ['moteRate', 0, 300, 1, 'rate'], + ['moteSize', 0.005, 0.4, 0.005, 'size'], + ['moteSpread', 0, 1, 0.01, 'launch cone'], + ['moteSpeedVariance', 0, 1, 0.01, 'speed variance'], + ['moteTurbulence', 0, 3, 0.01, 'turbulence'], + ['moteGlow', 0, 6, 0.01, 'glow'], + ['moteInset', 0, 1.5, 0.01, 'inset'], + ['colorMote*', 'Mote colour'] + ], + 'The drift': [ + ['driftRate', 0, 200, 1, 'rate'], + ['driftSize', 0.05, 4, 0.01, 'size'], + ['driftSpeed', 0, 5, 0.05, 'speed'], + ['driftLifetime', 0.2, 10, 0.05, 'lifetime'], + ['driftOpacity', 0, 0.6, 0.005, 'opacity'], + ['driftRise', -2, 2, 0.01, 'rise'], + ['driftInset', 0, 1.5, 0.01, 'inset'], + ['colorDrift*', 'Drift colour'] + ], + 'Muzzle & landing': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['muzzleSize', 0.05, 4, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 4, 0.01, 'muzzle intensity'], + ['castFlash', 0, 1, 0.01, 'flash on release'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 10, 0.05, 'landing shell'], + ['burstIntensity', 0, 4, 0.01, 'shell intensity'], + ['burstMotes', 0, 500, 1, 'motes on landing'], + ['impactShake', 0, 2, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 1, 0.01, 'screen flash'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['colorBurstA', 'shell'], + ['colorBurstB', 'shell body'], + ['colorBurstC', 'shell motes'], + ['colorFlash', 'landing flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', 0, 4, 0.01, 'light height'], + ['lightBreathe', 0, 1, 0.01, 'breathe depth'], + ['lightBreatheRate', 0.05, 4, 0.01, 'breathe rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/starfall.js b/src/config/abilities/starfall.js new file mode 100644 index 0000000..0d1daad --- /dev/null +++ b/src/config/abilities/starfall.js @@ -0,0 +1,381 @@ +/* ================================================================== */ +/* STARFALL — arcane, far cast */ +/* ================================================================== */ +/** + * A rain of cold light called down onto a circle. + * + * The whole ability turns on one number that is *not* a dimension of anything + * you can see on the floor: the **vanishing point**. Every star in the call + * leaves the same place — `skyBack` metres behind the caster and `skyHeight` + * metres up — and lands somewhere else on the disc, so the trails all point + * back at one spot in the sky and fan apart as they come down. That divergence + * is the entire read. Drag `skyHeight` up and the fall steepens and the trails + * crowd together; drag `skyBack` out and the whole rain leans over the caster's + * shoulder. Two sliders and the sky moves. + * + * The first version gave every star its own launch point on a ring overhead, + * on the theory that a shared origin would look like a fountain run backwards. + * It looked like *rain*: twenty parallel streaks with no perspective in them at + * all, because parallel lines with no common point have nothing for the eye to + * converge on. `skyScatter` is what is left of that experiment — a few tens of + * centimetres of slop around the vanishing point so the sky end is a small + * bright knot rather than a mathematical singularity. Past about a metre the + * read starts to go, which is why the slider tops out where it does. + * + * ### The arrival envelope + * + * The stars are not spread evenly through the window. `Projectile` turns a + * body's landing point into a launch delay two ways at once — a radial ordering + * of the disc, and a spatial hash of the floor — and `fillScatter` mixes them. + * At 0 the circle fills in clean rings; at 1 it is confetti. In between, the + * *sum of two differently shaped random variables* is a hump: few stars early, + * a crowd through the middle of the window, a thinning tail. That is the ramp + * and the tail, and it costs nothing because it falls out of the mixture rather + * than being scheduled. `fillScatter` is therefore the envelope control as much + * as it is the fill-order control, and it is worth dragging slowly. + * + * ### Cold, not warm + * + * Cinder Fall already owns "things come out of the sky and hit the floor". The + * separation is entirely in the palette and the impact: no ember gradient + * anywhere, no smoke that lingers, a *ring* on the floor rather than a crater, + * and a thin white shell instead of a fireball. Every colour below is a picker + * and none of them is derived from another, so it can be taken somewhere warm + * on purpose — but the shipped defaults are white through pale blue into a deep + * cobalt, and the dust is pale grey rather than soot. + */ +export const starfall = { + /* --- the cast --- */ + range: 22.0, // maximum distance to the circle's centre, metres + minRange: 3.0, // closer than this and the cast is refused, metres + zoneRadius: 5.0, // radius of the circle the stars fall into, metres + // Slow for a far cast, deliberately. The call has to still be running when + // the first stars appear overhead or the sky opens onto an empty circle — + // at 100 m/s the sweep was over before there was anything to look at. + speed: 34.0, // how fast the call sweeps out to the circle, metres/second + lifetime: 1.8, // seconds the rain holds after the call lands + fadeTime: 1.2, // seconds the last light bleeds off + cooldown: 1.4, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the vanishing point --- */ + // The three numbers that place the point every star falls from. Measured in + // the cast's own frame, so the rain leans with the aim. + skyHeight: 34.0, // metres above the floor + skyBack: 13.0, // metres behind the caster + skyScatter: 0.35, // metres of slop around it; 0 is a perfect convergence + + /* --- the fall: how many, when, and where they land --- */ + stars: 22, // stars in one call (capped at 40) + starLead: 0.02, // seconds before the first star leaves the point + starWindow: 1.4, // seconds the launches are spread over + fallTime: 0.75, // seconds one star is in the air + fallJitter: 0.14, // ± fraction of that flight time, per star + fallCurve: 1.45, // >1 accelerates the star downward over its flight + fillBias: -1.0, // +1 fills outward from the centre, -1 inward from the rim + fillScatter: 0.65, // 0 pure radial order, 1 pure spatial hash — see the header + hashCell: 1.4, // metres — the lattice the fill order clumps on + zoneBias: 0.5, // 0.5 spreads evenly by area, <0.5 crowds the rim + spreadHeight: 0.0, // metres above the floor a star is considered landed + + /* --- the star itself --- */ + starSize: 0.13, // body radius, metres + starSizeJitter: 0.3, // ± fraction of that radius + starStretch: 3.4, // how far the body is drawn out along its own heading + starSpin: 2.0, // tumble, radians/second (mostly hidden once aligned) + starAlign: 0.92, // 0 tumbles freely, 1 lays the long axis along the fall + starFlash: 0.1, // seconds of birth flash as a star appears + starGlow: 2.6, // emissive gain on the body + starOpacity: 1.0, + starRim: 2.2, // fresnel exponent on the body's edge + starHeat: 1.6, // extra brightness as the star nears the floor + starFacet: 0.55, // how hard the facets shimmer + starFacets: 9.0, // facet features across the body (unitless — see the class) + colorStarCore: '#ffffff', // the hot centre of the body + colorStarEdge: '#c0d8ff', // its lit facets + colorStarDeep: '#3a5fd0', // the side turned away from you + + /* --- the trail --- */ + /** + * One instanced strip draws every trail, and it is not a recording: the + * vertex shader re-flies the same parametric path backwards in each star's + * own clock. That is why `trailSpan` re-lengthens a trail that is already in + * the sky, with the clock stopped. + */ + trailSpan: 0.34, // seconds of flight the tail reaches back over + trailBurn: 0.3, // seconds the tail takes to catch the head after landing + trailWidth: 0.11, // metres at the head + trailTaper: 1.9, // >1 sharpens the tail to a point + trailLift: 0.0, // metres the tail floats above the flown path + trailOpacity: 0.95, + trailGlow: 1.9, + trailCore: 2.6, // how tightly light crowds the centre line + trailHeadBias: 0.6, // >0 keeps the brightness near the body + trailNoise: 0.35, // break-up along the ribbon + trailNoiseScale: 1.1, // features per metre + trailNoiseSpeed: 0.5, + trailSoftFade: 0.45, // metres of depth feather against solid geometry + colorTrailA: '#ffffff', // at the head + colorTrailB: '#dbe9ff', + colorTrailC: '#7aa0f0', + colorTrailD: '#12224a', // at the tail + + /* --- the floor --- */ + /** + * One `GroundField(POCK)` carries every impact, so the whole floor is a + * single draw call however many stars land. A mark is a *unitless* hit — + * fraction of the radius across, fraction downrange, and the timestamp it + * fired at — which is why dragging `ringRadius` re-scales rings that are + * already lying on the stone. + * + * POCK digs its crater in over `ringDig`, and the rim is a ridge at the + * crater's edge. Run the dig slow and the rim *is* an expanding ring: it + * travels outward as the mark grows and dies as it weathers. That is the + * whole ring effect, and it is one uniform. + */ + ringRadius: 0.85, // metres — the radius one impact ring reaches + ringDig: 3.4, // how fast it expands to that radius, 1/second + ringLife: 2.6, // seconds a ring takes to weather away + ringRim: 0.12, // metres the rim stands proud — the ring's brightness + ringDepth: 0.05, // metres the bowl sinks; small, these are not craters + ringThickness: 0.07, // metres — how wide the rim ridge is + ringDetail: 0.35, // grain over the floor mark + fieldEdge: 0.45, // metres of feather on the circle's boundary + fieldRagged: 0.16, // how far that boundary wanders, fraction of the radius + fieldRaggedScale: 0.8, // lobes per metre + fieldWarp: 0.4, // metres of domain warp on those lobes + fieldRelief: 0.55, // how hard the height field tilts the fake normal + fieldSpecular: 0.7, + fieldGloss: 40, // Blinn exponent — tight and cold + fieldEmissive: 1.5, // multiplier on the glowing terms + fieldOpacity: 0.9, + fieldHeight: 0.022, // metres the quad floats above the floor + colorFieldBase: '#6f86b4', // the frosted stone inside the circle + colorFieldEdge: '#eaf2ff', // rims and lips + colorFieldGlow: '#9fc4ff', // the heat left in a fresh ring + colorFieldDeep: '#101a34', // the inside of a bowl + + /* --- sparks, motes and dust --- */ + /** + * Three shared systems, each with its own four-stop lifetime gradient (A at + * birth through D as it dies) so the sparks can be made to cool while the + * dust stays neutral. Nothing here is derived from the star palette. + */ + sparkBurst: 16, // sparks thrown by one impact + sparkSize: 0.13, + sparkSpeed: 7.0, // metres/second + sparkLifetime: 0.42, // seconds + sparkGravity: -14.0, // metres/second² + sparkStretch: 0.22, // how far a spark smears along its velocity + colorSparkA: '#ffffff', + colorSparkB: '#e8f2ff', + colorSparkC: '#8fb8ff', + colorSparkD: '#1b3470', + moteBurst: 9, // slow motes released by one impact + moteSize: 0.07, + moteSpeed: 1.3, // metres/second + moteLifetime: 1.5, // seconds + moteRise: 1.1, // upward drift, metres/second + moteTurbulence: 0.65, + sparkleRate: 5.0, // motes shed per second *per star still in the air* + colorMoteA: '#ffffff', + colorMoteB: '#cfe2ff', + colorMoteC: '#6f9aef', + colorMoteD: '#0b1738', + dustBurst: 5, // pale floor dust kicked up by one impact + dustSize: 0.55, + dustSpeed: 1.4, // metres/second + dustLifetime: 1.6, // seconds + dustRise: 0.5, // metres/second + dustOpacity: 0.11, + colorDustA: '#9fa8b8', + colorDustB: '#8a94a6', + colorDustC: '#6e7789', + colorDustD: '#3b414c', + + /* --- the call, and what an impact does --- */ + callSize: 0.6, // the shell at the caster's hand on release, metres + callIntensity: 1.6, + callHeight: 1.32, // metres above the floor the call leaves from + callForward: 0.5, // metres in front of the caster + castFlash: 0.1, // screen flash on release + colorCallA: '#3a5fd0', + colorCallB: '#c0d8ff', + colorCallC: '#ffffff', + colorCastFlash: '#c0d8ff', + shellSize: 1.15, // the shell one star opens where it lands, metres + shellIntensity: 1.5, + shellLife: 0.34, // seconds — short, or twenty of them stack into a fog + colorShellA: '#3a5fd0', + colorShellB: '#c0d8ff', + colorShellC: '#ffffff', + impactFlash: 0.16, // screen flash, fired by the *first* star only + colorFlash: '#dfeaff', + impactShake: 0.16, // per-star kick + shakeDuration: 0.3, // seconds it decays over + rumble: 0.02, // continuous shake while the rain is falling + + /* --- dynamic light --- */ + lightIntensity: 12, // the standing glow over the circle + lightRadius: 16, // metres + lightColor: '#9fc4ff', + lightTwinkle: 0.22, // depth of the cold twinkle, 0 = steady + lightTwinkleSpeed: 7.0, // twinkles/second + lightPunch: 9.0 // added to the light by each star that lands +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Starfall. + * + * Reach for **The vanishing point** first — it is the ability. `skyHeight` and + * `skyBack` between them decide whether this reads as a meteor shower seen from + * underneath or as light dropped straight down a lift shaft, and both extremes + * are worth seeing once. After that, `fillScatter` (which is the arrival + * envelope), `stars` and `starWindow` set the weather, and `ringDig` decides + * whether the floor marks snap or spread. + */ +export const starfallSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['zoneRadius', 1, 16, 0.1, 'circle radius'], + ['speed', 5, 300, 1, 'call speed'], + ['lifetime', 0.2, 8, 0.05, 'rain duration'], + ['fadeTime', 0.1, 5, 0.05, 'fade time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The vanishing point': [ + ['skyHeight', 6, 90, 0.5, 'height above floor'], + ['skyBack', -20, 50, 0.5, 'distance behind caster'], + ['skyScatter', 0, 4, 0.01, 'convergence slop'] + ], + 'The fall': [ + ['stars', 1, 40, 1, 'stars'], + ['starLead', 0, 2, 0.01, 'lead-in'], + ['starWindow', 0.05, 6, 0.01, 'arrival window'], + ['fallTime', 0.1, 3, 0.01, 'time in the air'], + ['fallJitter', 0, 1, 0.01, 'flight-time jitter'], + ['fallCurve', 0.4, 3.5, 0.01, 'fall acceleration'], + ['fillBias', -1, 1, 0.01, 'fill order (rim ↔ centre)'], + ['fillScatter', 0, 1, 0.01, 'arrival envelope'], + ['hashCell', 0.1, 8, 0.05, 'clump size'], + ['zoneBias', 0.1, 2, 0.01, 'rim ↔ middle crowding'], + ['spreadHeight', 0, 2, 0.01, 'landing height'] + ], + 'The star': [ + ['starSize', 0.02, 0.8, 0.005, 'body radius'], + ['starSizeJitter', 0, 1, 0.01, 'size jitter'], + ['starStretch', 0.5, 12, 0.05, 'stretch along heading'], + ['starSpin', 0, 20, 0.1, 'tumble rate'], + ['starAlign', 0, 1, 0.01, 'align to heading'], + ['starFlash', 0.01, 1, 0.01, 'birth flash'], + ['starGlow', 0, 8, 0.01, 'glow'], + ['starOpacity', 0, 2, 0.01, 'opacity'], + ['starRim', 0.2, 8, 0.05, 'rim sharpness'], + ['starHeat', 0, 5, 0.01, 'heat near the floor'], + ['starFacet', 0, 2, 0.01, 'facet depth'], + ['starFacets', 1, 30, 0.5, 'facets across body'], + ['colorStarCore', 'star core'], + ['colorStarEdge', 'star facets'], + ['colorStarDeep', 'star shadow'] + ], + 'The trail': [ + ['trailSpan', 0.02, 2, 0.01, 'trail length (seconds)'], + ['trailBurn', 0.02, 2, 0.01, 'tail catch-up'], + ['trailWidth', 0.005, 1, 0.005, 'width at head'], + ['trailTaper', 0.2, 6, 0.05, 'taper'], + ['trailLift', -1, 1, 0.01, 'lift off the path'], + ['trailOpacity', 0, 2, 0.01, 'opacity'], + ['trailGlow', 0, 6, 0.01, 'glow'], + ['trailCore', 0.2, 8, 0.05, 'core tightness'], + ['trailHeadBias', -1, 2, 0.01, 'head bias'], + ['trailNoise', 0, 2, 0.01, 'break-up'], + ['trailNoiseScale', 0.1, 8, 0.05, 'break-up scale'], + ['trailNoiseSpeed', 0, 4, 0.01, 'break-up speed'], + ['trailSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorTrail*', 'Trail colour'] + ], + 'The floor': [ + ['ringRadius', 0.05, 4, 0.01, 'ring radius'], + ['ringDig', 0.2, 20, 0.1, 'ring expansion rate'], + ['ringLife', 0.2, 14, 0.1, 'ring lifetime'], + ['ringRim', 0, 0.6, 0.005, 'rim height'], + ['ringDepth', 0, 1, 0.005, 'bowl depth'], + ['ringThickness', 0.005, 0.5, 0.005, 'rim width'], + ['ringDetail', 0, 1, 0.01, 'floor grain'], + ['fieldEdge', 0.02, 3, 0.01, 'boundary feather'], + ['fieldRagged', 0, 1, 0.01, 'boundary wander'], + ['fieldRaggedScale', 0.1, 4, 0.05, 'wander scale'], + ['fieldWarp', 0, 3, 0.01, 'domain warp'], + ['fieldRelief', 0, 2, 0.01, 'relief'], + ['fieldSpecular', 0, 3, 0.01, 'specular'], + ['fieldGloss', 1, 120, 1, 'gloss'], + ['fieldEmissive', 0, 5, 0.01, 'emissive'], + ['fieldOpacity', 0, 2, 0.01, 'opacity'], + ['fieldHeight', 0.001, 0.2, 0.001, 'height above floor'], + ['colorFieldBase', 'floor body'], + ['colorFieldEdge', 'floor rims'], + ['colorFieldGlow', 'ring heat'], + ['colorFieldDeep', 'bowl interior'] + ], + 'Sparks & motes': [ + ['sparkBurst', 0, 120, 1, 'sparks / impact'], + ['sparkSize', 0.005, 0.6, 0.005, 'spark size'], + ['sparkSpeed', 0, 30, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 3, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['moteBurst', 0, 80, 1, 'motes / impact'], + ['moteSize', 0.005, 0.5, 0.005, 'mote size'], + ['moteSpeed', 0, 10, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 6, 0.05, 'mote lifetime'], + ['moteRise', -2, 6, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['sparkleRate', 0, 60, 0.5, 'sparkle / star / sec'], + ['colorSpark*', 'Spark colour'], + ['colorMote*', 'Mote colour'] + ], + 'Floor dust': [ + ['dustBurst', 0, 60, 1, 'dust / impact'], + ['dustSize', 0.05, 3, 0.01, 'dust size'], + ['dustSpeed', 0, 8, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 6, 0.05, 'dust lifetime'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['colorDust*', 'Dust colour'] + ], + 'The call & the impacts': [ + ['callSize', 0.05, 6, 0.05, 'call shell size'], + ['callIntensity', 0, 5, 0.01, 'call intensity'], + ['callHeight', 0, 3, 0.01, 'hand height'], + ['callForward', -1, 3, 0.01, 'hand forward'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorCallA', 'call shell'], + ['colorCallB', 'call body'], + ['colorCallC', 'call arcs'], + ['colorCastFlash', 'release flash colour'], + ['shellSize', 0.05, 6, 0.05, 'impact shell size'], + ['shellIntensity', 0, 5, 0.01, 'impact shell intensity'], + ['shellLife', 0.05, 2, 0.01, 'impact shell life'], + ['colorShellA', 'impact shell'], + ['colorShellB', 'impact body'], + ['colorShellC', 'impact rim'], + ['impactFlash', 0, 2, 0.01, 'first-star screen flash'], + ['colorFlash', 'screen flash colour'], + ['impactShake', 0, 2, 0.01, 'per-star shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake decay'], + ['rumble', 0, 0.4, 0.005, 'rain rumble'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightTwinkle', 0, 1, 0.01, 'twinkle depth'], + ['lightTwinkleSpeed', 0.5, 40, 0.5, 'twinkle rate'], + ['lightPunch', 0, 60, 0.5, 'punch per star'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/stasisfield.js b/src/config/abilities/stasisfield.js new file mode 100644 index 0000000..65a2a7e --- /dev/null +++ b/src/config/abilities/stasisfield.js @@ -0,0 +1,336 @@ +/* ================================================================== */ +/* STASIS FIELD — chrono */ +/* ================================================================== */ +/** + * A sphere of stopped time dropped over the aimed circle. + * + * This block is short by the standards of this directory, and that is the + * ability rather than an omission. Stasis Field draws **two** things — a + * refractive hull standing in the air and a hex lattice on the floor — because + * everything else it does happens inside *other* people's shaders. The numbers + * that matter most here are the four in "The held sphere": they are the entire + * ability, and none of them tints a pixel. + * + * ## What each of the four does + * + * `fieldScale` is the outer edge, as a multiple of the aim circle, so the + * reticle you targeted with is the boundary you get. `fieldCore` is how much of + * that radius is held *flat* — the rest is a shell in which the clock runs + * slower the further in you look, and that gradient is the reason the field + * reads as a field rather than as a hard sphere of frozen sprites. `holdRate` + * is the clock rate inside: **0 is a stasis field, 0.2 is slow motion and 1 is + * an ability that does nothing**, and it is a slider because the other two are + * genuinely worth having. `armLag` is how far behind the world the interior + * falls while the field is still closing — see the class doc for why a lag and + * a rate are the same mechanism seen from two ends. + * + * ## The two things that are drawn + * + * `glass*` is a `DistortionField` in `REFRACT` mode on a sphere hull. It emits + * no light at all: it writes screen-space offsets, so what you see is the floor + * grid, the character and every particle *behind* the sphere bending as they + * cross its skin. That is deliberate. The first version put a `Shell(DOME)` + * over it with a bright fresnel rim and the slot immediately read as a shield — + * a thing that stops damage, not a thing that stops time — and no amount of + * recolouring fixed it, because a glowing dome is a *barrier* and the eye knows + * it before it knows anything else. + * + * `lattice*` is a `GroundField` in `LATTICE` mode: a hex mesh that propagates + * cell by cell along its own edges. It is on the floor for one reason, which is + * that it is driven by the field's **own interior clock** — so it starts + * spreading, and then stops dead part way out and holds there for two seconds. + * A growth front arrested mid-growth is the cheapest legible picture of "time + * stopped here" this project can draw, and `latticeTime` against `holdTime` is + * the pair to drag: set `latticeTime` above `holdTime` and the lattice never + * finishes until the field lets go. + */ +export const stasisfield = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 2.0, // closer than this and the cast is refused + zoneRadius: 5.0, // the aimed circle, metres — the field's own radius keys off this + speed: 34.0, // how fast the seed travels out to the circle, metres/second + cooldown: 2.2, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + holdTime: 2.4, // seconds the field stays shut once it has snapped + releaseTime: 0.45, // seconds the interior takes to catch back up when it lets go + settleTime: 0.55, // seconds after the catch-up before the cast is finished + + /* --- the held sphere (vfx/TimeControl.js) — the ability --- */ + fieldScale: 1.0, // outer edge of the held sphere, × zoneRadius + fieldHeight: 2.1, // metres above the floor the sphere is centred at + fieldCore: 0.6, // 0..1 of the radius held flat; past it the clock ramps back up + fieldStrength: 1.0, // 0..1 master weight; 0 is a field that holds nothing + holdRate: 0.0, // clock rate inside: 0 stasis, 0.2 slow motion, 1 identity + armLag: 0.22, // seconds the interior falls behind while the field is closing + armCurve: 1.7, // >1 keeps the grip weak and then takes hold late + + /* --- the glass: DistortionField(REFRACT) on a sphere hull --- */ + // Magnitudes are SCREEN FRACTIONS, not metres. `post.distortion × + // global.distortion` is applied by the pass exactly once and must not be + // multiplied in here. + glassScale: 1.0, // hull radius, × the field radius + glassSquash: 0.94, // vertical scale on the hull, so it is not a perfect ball + glassLift: 0.15, // metres the hull sits above the field centre + glassStrength: 0.42, // screen widths of refraction at the rim + glassPower: 2.6, // rim exponent; 0 flattens the hull into a uniform pane + glassRipple: 0.22, // how much the skin crawls + glassRippleScale: 1.7, // cycles per metre of that crawl + glassRippleSpeed: 0.3, // metres/second it travels at + glassOpacity: 1.0, // master on the emitter's contribution + glassDepthReject: 0.7, // 0..1 how hard opaque geometry in front of it cuts it + glassDepthFade: 0.45, // metres of feather on that rejection + glassPerspective: 0.35, // 0..1 how much the effect falls off with distance + glassPerspectiveRef: 12.0, // metres — the distance that falloff is referenced to + + /* --- the floor: GroundField(LATTICE), on the field's own clock --- */ + latticeScale: 1.02, // radius, × the field radius — a hair proud of the skin + latticeTime: 1.15, // seconds the lattice would take to reach the rim, unheld + latticeHeight: 0.02, // metres above the floor the quad sits at + latticeCell: 0.44, // metres across one hex + latticeSeam: 0.032, // metres — the gap between neighbouring cells + latticeThickness: 0.055, // metres — the wall of a cell + latticeLift: 0.06, // metres a cell stands proud of the floor + latticeDepth: 0.05, // metres of recess inside a cell + latticeSharp: 0.55, // 0..1 how hard the cell profile falls off + latticeDetail: 0.5, // 0..1 grain inside a cell + latticeEdge: 0.28, // metres of feather on the propagation front + latticeRagged: 0.1, // how far that front wanders, fraction of the radius + latticeRaggedScale: 0.6, // lobes per metre on the wander + latticeWarp: 0.3, // metres of domain warp on those lobes + latticeRelief: 0.55, // how hard the height field tilts the fake normal + latticeNormalStep: 0.05, // metres between the height taps + latticeAmbient: 0.3, // floor on the diffuse term + latticeWrap: 0.42, // 0..1 wraps the terminator round the back + latticeSpecular: 0.35, + latticeGloss: 26.0, // Blinn exponent + latticeParallax: 0.2, // metres of view-driven offset on the interior detail + latticeSpeed: 0.3, // events per second in the cell shader's own animation + latticeEmissive: 1.15, // multiplier on every glowing term + latticeOpacity: 0.82, + latticeDepthFade: 0.45, // metres of soft fade against standing geometry + colorLatticeBase: '#5c6b66', // the substance of a cell wall + colorLatticeEdge: '#cfe0d8', // rims and highlights + colorLatticeGlow: '#a8d4c4', // the propagating front + colorLatticeDeep: '#141a19', // the recess inside a cell + + /* --- the dust the field catches --- */ + /** + * Two systems, both coloured by a four-stop lifetime gradient (`A` at birth + * through `D` as it dies) as in `ice` and `thunder`. + * + * `moteRate` is emitted **in a thin shell at the rim**, not through the + * volume, because the rim is the only place in a stasis field where anything + * is still moving. `rimBand` is how thick that shell is as a fraction of the + * radius; take it to 1 and the whole interior is seeded, which fills the + * sphere with motionless specks and looks like a rendering fault. + */ + moteRate: 130, // motes emitted per second into the rim shell + moteSize: 0.055, + moteSpeed: 0.65, // metres/second — barely anything; the field takes it from there + moteLifetime: 3.4, // seconds. Long, because a held mote never spends its life + moteRise: 0.35, // upward drift, metres/second + moteTurbulence: 0.45, + moteGlow: 0.85, + moteOpacity: 0.9, + rimBand: 0.3, // 0..1 of the radius the rim shell occupies + snapMotes: 90, // extra motes thrown inward on the frame the field shuts + colorMoteA: '#ffffff', + colorMoteB: '#cfe6dc', + colorMoteC: '#8fb6a8', + colorMoteD: '#2c3a36', + + dustRate: 34, // the low haze drawn up off the floor as the field closes + dustSize: 0.9, + dustSpeed: 0.9, // metres/second + dustLifetime: 3.0, // seconds + dustRise: 0.25, // metres/second + dustOpacity: 0.07, + snapDust: 40, // extra puffs on the snap + colorDustA: '#5c6b66', + colorDustB: '#4a5754', + colorDustC: '#3a4442', + colorDustD: '#202625', + + /* --- the snap: the one frame that punches --- */ + snapBurstSize: 3.4, // the shell of arrested air, metres + snapBurstIntensity: 1.1, + snapFlash: 0.14, // screen flash + snapShake: 0.24, // camera shake + shakeDuration: 0.4, // seconds it decays over + snapRingRadius: 5.6, // the ring left on the floor at the boundary, metres + snapRingIntensity: 0.9, + colorSnapA: '#8fb6a8', // burst shell + colorSnapB: '#cfe6dc', // burst body + colorSnapC: '#ffffff', // burst filaments + colorSnapFlash: '#dff0e8', + colorRingA: '#a8d4c4', // ring body + colorRingB: '#ffffff', // ring crest + + /* --- the release: the interior catching up --- */ + releaseBurstSize: 4.2, // metres + releaseBurstIntensity: 0.8, + releaseFlash: 0.09, + releaseShake: 0.12, + colorReleaseA: '#6f8f84', + colorReleaseB: '#a8d4c4', + colorReleaseC: '#e8f4ee', + colorReleaseFlash: '#cfe6dc', + + /* --- casting --- */ + castFlash: 0.06, // screen flash as the seed leaves the hand + colorCastFlash: '#cfe6dc', + handHeight: 1.25, // metres above the floor the seed leaves at + handForward: 0.5, // metres in front of the caster + rumble: 0.012, // continuous shake while the seed travels + + /* --- dynamic light --- */ + // Deliberately steady: see `lightShimmer()` in the class. + lightIntensity: 13.0, + lightRadius: 13.0, + lightColor: '#9fc6b8' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Stasis Field. + * + * Reach for **The held sphere** and nothing else first. Cast the field over a + * standing Storm Lance or a Cinder Fall, pause with **P**, and drag + * `fieldScale` — the boundary between the particles that are moving and the + * particles that are not moves with it, on a frame of zero length. That is the + * ability, and it is also invariant I1 demonstrated more loudly than anywhere + * else in the project. + * + * `holdRate` is the second thing to touch. At 0.15 the slot stops being a + * stasis field and becomes a bullet-time bubble, which is a completely + * different ability for the cost of one number. + */ +export const stasisfieldSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['zoneRadius', 1, 14, 0.05, 'aim circle (m)'], + ['speed', 5, 200, 1, 'seed speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'], + ['holdTime', 0.2, 8, 0.05, 'time held (s)'], + ['releaseTime', 0.05, 3, 0.01, 'catch-up (s)'], + ['settleTime', 0.05, 3, 0.05, 'settle (s)'] + ], + 'The held sphere': [ + ['fieldScale', 0.2, 2.5, 0.01, 'radius × aim circle'], + ['fieldHeight', 0, 8, 0.05, 'centre height (m)'], + ['fieldCore', 0, 0.98, 0.01, 'flat core (0..1)'], + ['fieldStrength', 0, 1, 0.01, 'grip'], + ['holdRate', 0, 1, 0.01, 'clock rate inside'], + ['armLag', 0, 1.5, 0.01, 'lag while closing (s)'], + ['armCurve', 0.3, 5, 0.01, 'grip curve'] + ], + 'The glass': [ + ['glassScale', 0.2, 2, 0.01, 'hull × field radius'], + ['glassSquash', 0.3, 1.6, 0.01, 'hull squash'], + ['glassLift', -2, 3, 0.01, 'hull lift (m)'], + ['glassStrength', 0, 2, 0.01, 'refraction (screen widths)'], + ['glassPower', 0, 8, 0.05, 'rim exponent'], + ['glassRipple', 0, 2, 0.01, 'skin crawl'], + ['glassRippleScale', 0.1, 8, 0.05, 'crawl cycles / m'], + ['glassRippleSpeed', -3, 3, 0.01, 'crawl speed (m/s)'], + ['glassOpacity', 0, 2, 0.01, 'opacity'], + ['glassDepthReject', 0, 1, 0.01, 'occlusion'], + ['glassDepthFade', 0.02, 3, 0.01, 'occlusion feather (m)'], + ['glassPerspective', 0, 1, 0.01, 'distance falloff'], + ['glassPerspectiveRef', 1, 40, 0.5, 'reference distance (m)'] + ], + 'The lattice on the floor': [ + ['latticeScale', 0.2, 2, 0.01, 'radius × field radius'], + ['latticeTime', 0.1, 6, 0.05, 'unheld spread time (s)'], + ['latticeHeight', 0.005, 0.2, 0.005, 'height above floor (m)'], + ['latticeCell', 0.08, 2, 0.01, 'cell width (m)'], + ['latticeSeam', 0, 0.3, 0.002, 'seam (m)'], + ['latticeThickness', 0.005, 0.4, 0.005, 'wall (m)'], + ['latticeLift', 0, 0.5, 0.005, 'cell lift (m)'], + ['latticeDepth', 0, 0.6, 0.005, 'cell recess (m)'], + ['latticeSharp', 0, 1, 0.01, 'profile sharpness'], + ['latticeDetail', 0, 1, 0.01, 'interior grain'], + ['latticeEdge', 0.02, 2, 0.01, 'front feather (m)'], + ['latticeRagged', 0, 1, 0.01, 'front wander'], + ['latticeRaggedScale', 0.05, 3, 0.01, 'wander lobes / m'], + ['latticeWarp', 0, 3, 0.01, 'domain warp (m)'], + ['latticeRelief', 0, 2, 0.01, 'relief'], + ['latticeNormalStep', 0.005, 0.4, 0.005, 'normal step (m)'], + ['latticeAmbient', 0, 1, 0.01, 'ambient'], + ['latticeWrap', 0, 1, 0.01, 'terminator wrap'], + ['latticeSpecular', 0, 2, 0.01, 'specular'], + ['latticeGloss', 1, 120, 1, 'gloss'], + ['latticeParallax', 0, 1.5, 0.01, 'parallax (m)'], + ['latticeSpeed', 0, 3, 0.01, 'cell animation rate'], + ['latticeEmissive', 0, 4, 0.01, 'emissive'], + ['latticeOpacity', 0, 1, 0.01, 'opacity'], + ['latticeDepthFade', 0.02, 3, 0.01, 'soft intersection (m)'], + ['colorLatticeBase', 'cell wall'], + ['colorLatticeEdge', 'rim'], + ['colorLatticeGlow', 'front'], + ['colorLatticeDeep', 'recess'] + ], + 'Motes & haze': [ + ['moteRate', 0, 600, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 8, 0.05, 'mote speed'], + ['moteLifetime', 0.2, 10, 0.05, 'mote lifetime'], + ['moteRise', -2, 4, 0.01, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['moteGlow', 0, 3, 0.01, 'mote glow'], + ['moteOpacity', 0, 1, 0.01, 'mote opacity'], + ['rimBand', 0.02, 1, 0.01, 'rim shell thickness'], + ['snapMotes', 0, 400, 1, 'motes on the snap'], + ['dustRate', 0, 300, 1, 'haze rate'], + ['dustSize', 0.05, 4, 0.01, 'haze size'], + ['dustSpeed', 0, 8, 0.05, 'haze speed'], + ['dustLifetime', 0.2, 10, 0.05, 'haze lifetime'], + ['dustRise', -2, 4, 0.01, 'haze rise'], + ['dustOpacity', 0, 1, 0.005, 'haze opacity'], + ['snapDust', 0, 300, 1, 'haze on the snap'], + ['colorMote*', 'Mote colour'], + ['colorDust*', 'Haze colour'] + ], + 'The snap': [ + ['snapBurstSize', 0.2, 14, 0.05, 'burst size (m)'], + ['snapBurstIntensity', 0, 4, 0.01, 'burst intensity'], + ['snapFlash', 0, 1, 0.005, 'screen flash'], + ['snapShake', 0, 2, 0.01, 'shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration (s)'], + ['snapRingRadius', 0.5, 20, 0.1, 'boundary ring (m)'], + ['snapRingIntensity', 0, 3, 0.01, 'ring intensity'], + ['colorSnapA', 'burst shell'], + ['colorSnapB', 'burst body'], + ['colorSnapC', 'burst filaments'], + ['colorSnapFlash', 'flash colour'], + ['colorRingA', 'ring body'], + ['colorRingB', 'ring crest'] + ], + 'The release': [ + ['releaseBurstSize', 0.2, 16, 0.05, 'burst size (m)'], + ['releaseBurstIntensity', 0, 4, 0.01, 'burst intensity'], + ['releaseFlash', 0, 1, 0.005, 'screen flash'], + ['releaseShake', 0, 2, 0.01, 'shake'], + ['colorReleaseA', 'burst shell'], + ['colorReleaseB', 'burst body'], + ['colorReleaseC', 'burst filaments'], + ['colorReleaseFlash', 'flash colour'] + ], + 'Casting': [ + ['handHeight', 0, 3, 0.01, 'hand height (m)'], + ['handForward', -1, 3, 0.01, 'hand forward (m)'], + ['castFlash', 0, 1, 0.005, 'release flash'], + ['colorCastFlash', 'release flash colour'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/stonespine.js b/src/config/abilities/stonespine.js new file mode 100644 index 0000000..962e33e --- /dev/null +++ b/src/config/abilities/stonespine.js @@ -0,0 +1,367 @@ +/* ================================================================== */ +/* STONESPINE — Stone Spine */ +/* ================================================================== */ +/** + * Plates, not spikes. + * + * Every other "something comes out of the ground" slot in the sandbox punches a + * body straight up out of the floor. This one does not: a slab of floor is + * **hinged along one edge** and swings up about that edge like a paving stone + * being levered with a crowbar. The consequence is the whole ability. Because + * the rise is a *rotation about a world-space line* and not a translation, the + * lifted face shows the dirt underside that was buried a moment ago, the far + * edge travels through an arc, and anything sitting on the top face slides off + * it. None of that falls out of a translation, at any amount of tuning. + * + * Two numbers do the heavy lifting and they are the first two to reach for: + * `hingeAngle` (how far over the plates go — past about 1.4 rad they start + * standing on end and the field turns into a wall) and `hingeAlign` (0 gives + * every plate its own bearing and the field reads as broken river ice, 1 combs + * them all downrange and it reads as a staircase). Everything else is a shade + * of those two. + * + * **What a cast captures.** A count, a per-plate bearing roll, a handful of + * unitless jitters and one timestamp per plate. Not a metre, not a radian, not + * a second — the hinge angle, the reach, the thickness and the heave are all + * resolved against this block inside the update loop, on a zero-length frame + * included. Dragging `hingeAngle` on a field that has already finished rising + * swings every plate, with the clock stopped. That is the test. + */ +export const stonespine = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 2.0, // closer than this and the cast is refused + speed: 22.0, // how fast the heave front travels, metres/second + lifetime: 2.6, // seconds the field stands after the front lands + cooldown: 1.1, // seconds before the slot re-arms + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- how many plates and where they sit --- */ + slabCount: 44, // plates per cast, capped at 96 + clusterShare: 0.2, // 0..1 of them held back for the ring at the impact point + riseStagger: 0.16, // seconds of random delay between neighbouring plates + frontBias: 1.0, // <1 crowds the field toward the far end, unitless + widthNear: 0.8, // half-width of the band at the caster, metres + width: 2.6, // ... and at the far end, metres + widthCurve: 1.15, // >1 keeps the band narrow then opens it out late + clumping: 1.0, // >1 pulls plates toward the centre line, unitless + scatter: 0.55, // extra lateral jitter, fraction of the local half-width + clusterRadius: 2.8, // radius of the terminal ring of plates, metres + + /* --- the plate itself (metres unless noted) --- */ + // `reach` is the distance from the hinge to the far edge — the dimension that + // swings up, so it is also how tall a plate stands once it is over. + reachNear: 0.85, // reach at the caster + reach: 2.3, // reach at the far end + reachCurve: 1.1, // how late the ramp climbs + reachJitter: 0.35, // ± fraction + minReach: 0.14, // floor on the above + plateWidth: 0.6, // half-width across the hinge at the caster + plateWidthFar: 1.1, // ... and at the far end + plateWidthCurve: 0.7, // how the width ramps along the cast + plateWidthJitter: 0.35, // ± fraction + minWidth: 0.08, // floor on the above + thickness: 0.24, // slab thickness — the depth of the torn-out floor + thicknessJitter: 0.4, // ± fraction + crown: 0.32, // 0..1 how much shorter the flank plates are than the spine + crownPower: 1.5, // how sharply that dome falls off + peak: 1.25, // extra reach multiplier at the far end + peakWidth: 0.22, // 0..1 of the cast that swell covers + rubble: 0.18, // 0..1 chance a plate is demoted to a broken chip + rubbleScale: 0.34, // reach multiplier for those + rubbleSpread: 1.3, // width multiplier for those + + /* --- THE HINGE --- */ + hingeAngle: 0.95, // radians the plate swings through at full rise + hingeJitter: 0.4, // ± fraction, per plate + hingeAlign: 0.55, // 0 every plate picks its own bearing, 1 all comb downrange + hingeShift: 0.35, // fraction of the reach the hinge sits behind the plate's site + heave: 0.2, // metres the hinge line itself is shoved up out of the floor + burial: 0.4, // extra metres a plate starts below the floor + + /* --- the eruption --- */ + riseTime: 0.28, // seconds from buried to fully swung + riseOvershoot: 0.18, // how far past the final angle the shove carries + settle: 0.42, // seconds that overshoot rocks itself out over + springRate: 15.0, // radians/second of that rocking + birthScale: 0.9, // footprint scale at the moment it breaks the surface + birthFade: 0.32, // seconds the dust flash on a new plate decays over + breachAt: 0.18, // emergence fraction that fires the hinge puff + sinkDelay: 0.3, // seconds into the fade before the plates start dropping back + sinkTime: 0.9, // seconds they take to fall flat and sink + sinkDepth: 0.5, // extra metres they drop past their own thickness + + /* --- the silhouette of one slab (rebuilds the geometry when moved) --- */ + edgeSamples: 12, // perimeter samples round the ragged three edges + ragged: 0.3, // how far the outline wanders off a clean quad, 0..1 + chamfer: 0.18, // 0..1 of the thickness taken off as a bevelled top rim + topInset: 0.16, // how far that bevel pulls the top face in, 0..1 + tear: 0.36, // 0..1 of the thickness the underside is torn away by + + /* --- shading --- */ + // The top face is the old floor: dressed, pale, dusty. Everything below the + // soil line was in the ground five frames ago and is filthy. `soilLine` is + // where the two meet and it is the single term that says "this used to be + // level with your feet". + colorFace: '#6b6357', // the top face — the floor that got lifted + colorFlank: '#4a443b', // the sawn sides + colorSoil: '#3a352e', // earth clinging under the soil line + colorSoilDeep: '#141210', // the deepest crevices of the underside + colorDust: '#8d8375', // the pale film and the birth flash + soilLine: 0.74, // 0..1 up the thickness where the earth stops + soilBlur: 0.2, // 0..1 how soft that line is + soilSmear: 0.3, // 0..1 of the reach that soil creeps over onto the top face + grain: 0.55, // strength of the quarry mottling (world space) + grainScale: 3.2, // mottle features per metre + speckle: 0.5, // bright mineral flecks + speckleScale: 24.0, // flecks per metre + damp: 0.45, // 0..1 how much darker and rougher the underside is + dustFilm: 0.4, // 0..1 pale wash over the upward-facing surfaces + rim: 0.5, // grazing-angle lift on the edges + rimPower: 2.6, // how tightly that hugs the silhouette + roughFace: 0.82, // PBR roughness of the dressed top + roughSoil: 0.98, // ... of the earthy underside + envIntensity: 0.45, // how much of the HDR probe the stone takes + birthDust: 0.9, // brightness of the dust flash as a plate breaks through + glow: 1.0, // master emissive gain (stone barely glows; this is the ceiling) + opacity: 1.0, + + /* --- dust, grit and trickling sand --- */ + /** + * Three systems, each with its own four-stop lifetime gradient (`A` at birth + * through `D` as it dies), spelled out rather than derived from the slab + * palette so the dust can be made to hang blue-grey while the stone stays warm. + */ + dustRate: 60, // the rolling ground dust, particles/second + dustSize: 1.0, + dustSpeed: 1.2, // metres/second off the hinge + dustLifetime: 2.4, + dustOpacity: 0.32, + dustRise: 0.55, // upward drift, metres/second + dustTurbulence: 0.5, + breachDust: 5, // puff thrown at each hinge as it breaks the surface + colorDustA: '#9b9184', + colorDustB: '#7a7165', + colorDustC: '#4e483f', + colorDustD: '#26231e', + gritRate: 26, // chips kicked out along the front, particles/second + gritSize: 0.07, + gritSpeed: 4.2, + gritLifetime: 1.4, + gritGravity: -18.0, + breachGrit: 9, // chips thrown at each hinge as it breaks the surface + colorGritA: '#6b6357', + colorGritB: '#4a443b', + colorGritC: '#2a251f', + colorGritD: '#191714', + sandRate: 80, // the fine stuff that slides off the tilted top faces + sandSize: 0.06, + sandSpeed: 1.1, + sandLifetime: 1.5, + sandFall: -3.2, // gravity on it, metres/second² + sandTurbulence: 0.7, + colorSandA: '#a2988a', + colorSandB: '#877d70', + colorSandC: '#585045', + colorSandD: '#2c2823', + + /* --- what the ground keeps --- */ + crackRadius: 1.2, // fracture mark under each hinge, metres + crackLife: 6.0, // seconds it weathers away over + crackIntensity: 0.5, + crackWidth: 0.45, // how finely the fracture splits + colorCrack: '#2a251f', + colorCrackEdge: '#7a6f5e', + ringRadius: 1.5, // ground-hugging dust ring at each hinge, metres + ringLife: 1.3, + colorRingA: '#8d8375', + colorRingB: '#4e483f', + + /* --- the arrival --- */ + burstSize: 2.6, // dust ball at the impact point, metres + burstIntensity: 1.1, + burstDust: 70, // extra dust thrown there + burstGrit: 80, // extra chips thrown there + shockRadius: 5.5, // the ring that snaps out across the floor, metres + impactShake: 0.95, + shakeDuration: 0.65, + impactFlash: 0.06, // stone does not flash much; this is nearly off + rumble: 0.055, // continuous shake while the front travels + colorBurstA: '#8d8375', + colorBurstB: '#5e564b', + colorBurstC: '#2a251f', + colorShockA: '#a2988a', + colorShockB: '#d8cfc0', + colorFlash: '#8d8375', + + /* --- dynamic light --- */ + lightIntensity: 7.0, + lightRadius: 9.0, + lightColor: '#c8a878' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Stone Spine. + * + * "The hinge" is the folder this ability exists for; open it first. `hingeAngle` + * and `hingeAlign` between them cover everything from a cracked pavement to a + * flight of stairs, and both of them reshape a field that has already finished + * standing. "One slab" rebuilds the geometry when you move it — that is why + * those five controls are slower than the rest and why they are worth it. + */ +export const stonespineSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 3, 120, 0.5, 'heave speed'], + ['lifetime', 0.2, 12, 0.05, 'field lifetime'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The hinge': [ + ['hingeAngle', 0, 1.9, 0.01, 'swing (radians)'], + ['hingeJitter', 0, 1, 0.01, 'swing jitter'], + ['hingeAlign', 0, 1, 0.01, 'comb downrange'], + ['hingeShift', -0.5, 1, 0.01, 'hinge behind site'], + ['heave', -0.5, 1.5, 0.01, 'hinge lift'], + ['burial', 0, 2, 0.01, 'start depth'] + ], + 'The field': [ + ['slabCount', 1, 96, 1, 'plates'], + ['clusterShare', 0, 0.7, 0.01, 'held for the ring'], + ['riseStagger', 0, 1.5, 0.01, 'rise stagger'], + ['frontBias', 0.3, 3, 0.01, 'front bias'], + ['widthNear', 0.05, 6, 0.05, 'band at caster'], + ['width', 0.1, 10, 0.05, 'band at target'], + ['widthCurve', 0.2, 4, 0.01, 'band curve'], + ['clumping', 0.3, 4, 0.01, 'clumping'], + ['scatter', 0, 2, 0.01, 'lateral scatter'], + ['clusterRadius', 0.2, 10, 0.05, 'terminal ring radius'] + ], + 'The plate': [ + ['reachNear', 0.05, 6, 0.05, 'reach at caster'], + ['reach', 0.1, 8, 0.05, 'reach at target'], + ['reachCurve', 0.2, 4, 0.01, 'reach curve'], + ['reachJitter', 0, 1.2, 0.01, 'reach jitter'], + ['minReach', 0.02, 1, 0.01, 'minimum reach'], + ['plateWidth', 0.05, 4, 0.01, 'half-width at caster'], + ['plateWidthFar', 0.05, 6, 0.01, 'half-width at target'], + ['plateWidthCurve', 0.1, 3, 0.01, 'width curve'], + ['plateWidthJitter', 0, 1.2, 0.01, 'width jitter'], + ['minWidth', 0.02, 1, 0.01, 'minimum half-width'], + ['thickness', 0.02, 1.2, 0.01, 'slab thickness'], + ['thicknessJitter', 0, 1.2, 0.01, 'thickness jitter'], + ['crown', 0, 1, 0.01, 'flank crown'], + ['crownPower', 0.3, 4, 0.01, 'crown falloff'], + ['peak', 0.5, 3, 0.01, 'swell at target'], + ['peakWidth', 0.02, 1, 0.01, 'swell width'], + ['rubble', 0, 1, 0.01, 'broken chips'], + ['rubbleScale', 0.05, 1, 0.01, 'chip reach'], + ['rubbleSpread', 0.5, 3, 0.01, 'chip width'] + ], + 'The eruption': [ + ['riseTime', 0.02, 2, 0.01, 'rise time'], + ['riseOvershoot', 0, 1, 0.01, 'overshoot'], + ['settle', 0.05, 2, 0.01, 'settle'], + ['springRate', 1, 40, 0.5, 'rock rate'], + ['birthScale', 0.2, 1, 0.01, 'birth scale'], + ['birthFade', 0.02, 2, 0.01, 'birth flash decay'], + ['breachAt', 0.02, 0.9, 0.01, 'breach at'], + ['sinkDelay', 0, 3, 0.01, 'fall-back delay'], + ['sinkTime', 0.05, 4, 0.01, 'fall-back time'], + ['sinkDepth', 0, 3, 0.01, 'sink depth'] + ], + 'One slab': [ + ['edgeSamples', 6, 22, 1, 'perimeter samples'], + ['ragged', 0, 1, 0.01, 'outline raggedness'], + ['chamfer', 0, 0.6, 0.01, 'top bevel'], + ['topInset', 0, 0.5, 0.01, 'bevel inset'], + ['tear', 0, 0.9, 0.01, 'torn underside'] + ], + 'Stone & soil': [ + ['colorFace', 'top face'], + ['colorFlank', 'sides'], + ['colorSoil', 'earth'], + ['colorSoilDeep', 'deep crevice'], + ['colorDust', 'dust film'], + ['soilLine', 0, 1, 0.01, 'soil line'], + ['soilBlur', 0.01, 0.8, 0.01, 'soil line blur'], + ['soilSmear', 0, 1, 0.01, 'soil smear onto face'], + ['grain', 0, 2, 0.01, 'quarry mottling'], + ['grainScale', 0.2, 12, 0.05, 'mottle / metre'], + ['speckle', 0, 3, 0.01, 'mineral flecks'], + ['speckleScale', 2, 80, 0.5, 'flecks / metre'], + ['damp', 0, 1, 0.01, 'underside darkening'], + ['dustFilm', 0, 1, 0.01, 'dust film'], + ['rim', 0, 3, 0.01, 'edge lift'], + ['rimPower', 0.5, 8, 0.05, 'edge tightness'], + ['roughFace', 0.05, 1, 0.01, 'face roughness'], + ['roughSoil', 0.05, 1, 0.01, 'soil roughness'], + ['envIntensity', 0, 2, 0.01, 'probe intensity'], + ['birthDust', 0, 4, 0.01, 'breach flash'], + ['glow', 0, 4, 0.01, 'emissive gain'], + ['opacity', 0, 1, 0.01, 'opacity'] + ], + 'Dust & grit': [ + ['dustRate', 0, 400, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 8, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['breachDust', 0, 40, 1, 'puff per hinge'], + ['gritRate', 0, 300, 1, 'grit rate'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 20, 0.1, 'grit speed'], + ['gritLifetime', 0.1, 5, 0.05, 'grit lifetime'], + ['gritGravity', -50, 0, 0.1, 'grit gravity'], + ['breachGrit', 0, 60, 1, 'chips per hinge'], + ['sandRate', 0, 500, 1, 'sand rate'], + ['sandSize', 0.005, 0.4, 0.005, 'sand size'], + ['sandSpeed', 0, 8, 0.05, 'sand speed'], + ['sandLifetime', 0.1, 6, 0.05, 'sand lifetime'], + ['sandFall', -30, 2, 0.1, 'sand gravity'], + ['sandTurbulence', 0, 3, 0.01, 'sand turbulence'], + ['colorDust*', 'Dust colour'], + ['colorGrit*', 'Grit colour'], + ['colorSand*', 'Sand colour'] + ], + 'Marks on the ground': [ + ['crackRadius', 0.1, 6, 0.05, 'fracture radius'], + ['crackLife', 0.5, 20, 0.1, 'fracture lifetime'], + ['crackIntensity', 0, 2, 0.01, 'fracture intensity'], + ['crackWidth', 0, 2, 0.01, 'fracture detail'], + ['ringRadius', 0.1, 6, 0.05, 'dust ring radius'], + ['ringLife', 0.1, 6, 0.05, 'dust ring lifetime'], + ['colorCrack', 'fracture'], + ['colorCrackEdge', 'fracture edge'], + ['colorRingA', 'dust ring'], + ['colorRingB', 'dust ring edge'] + ], + 'The arrival': [ + ['burstSize', 0.2, 12, 0.05, 'dust ball size'], + ['burstIntensity', 0, 4, 0.01, 'dust ball intensity'], + ['burstDust', 0, 400, 1, 'burst dust'], + ['burstGrit', 0, 400, 1, 'burst grit'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 1, 0.005, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst core'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorFlash', 'impact flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/stormwall.js b/src/config/abilities/stormwall.js new file mode 100644 index 0000000..a4172b8 --- /dev/null +++ b/src/config/abilities/stormwall.js @@ -0,0 +1,473 @@ +/* ================================================================== */ +/* STORMWALL — the cast you aim is the wall's NORMAL */ +/* ================================================================== */ +/** + * Every other line cast in the project builds *along* the line you aimed: the + * bolt runs down it, the beam lies on it, the fire trail burns it. This one + * turns ninety degrees. The aimed line is the **normal** of a rain curtain that + * stands **across** your heading at the target point — so what the arrow + * indicator measures out is where the wall goes, and how far away it is, not + * how long it is. Its length is `wallWidth`, and it is measured sideways. + * + * Mechanically that is one line of the ability: `Curtain#setPlacement` is + * handed the cast's **side** vector as its `along`, not its direction. The + * module's own doc calls that out because this slot is the reason the argument + * exists. + * + * Three beats: + * + * 1. **`riseTime`** — the wall comes up out of the floor, staggered across the + * rank by `riseSpread` so it is a thing arriving rather than a decal + * appearing. + * 2. **`holdTime`** — it stands. Rain sheets down the face, lightning restrikes + * *inside the wall's own plane* at `strikeRate`, and the floor under and in + * front of it is wet and reflective. + * 3. **`fallTime`** — it **drains**. `rise` runs back to zero, which collapses + * each sheet down onto its own foot, and `fallSink` takes the foot below + * the floor. It does not fade uniformly; a uniform fade is a decal being + * switched off and it throws away the one thing the beat had to say. + * + * Three modules, four draw calls: `Curtain(RAIN)` for the water, + * `FilamentPaths` for the lightning (two passes, halo and core, over one strip) + * and `GroundField(WET)` for the floor. + * + * **What a cast captures.** A seed, three unitless dice per lightning strike + * (where its two ends sit across the wall, and a decorrelation seed) and the + * timestamps of those strikes. Not a metre: `wallWidth`, `wallHeight`, the rank + * pitch, the strike's endpoints and the wet patch are all resolved from these + * numbers inside the update loop, on zero-length frames included. Pause a + * standing wall, drag `wallWidth`, and the rank re-lays itself, the lightning + * re-spans it and the puddle re-scales under it together. + */ + +export const stormwall = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and you are standing in your own wall + speed: 34.0, // how fast the squall runs out to the point, metres/second + cooldown: 2.2, // seconds + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the three beats (seconds) --- */ + riseTime: 0.75, // it comes up out of the floor + holdTime: 4.4, // ... and stands. Scaled by global.lifetime with riseTime + fallTime: 1.6, // ... and drains downward + + /* --- the wall itself ---------------------------------------------- */ + // `wallWidth` is the one number that means anything here: it is the rank's + // total span across your heading, and the sheet pitch and each sheet's own + // width are read off it, the way `snare.zoneRadius` drives five consumers. + // Sizing the sheets independently and hoping they add up to a wall is how you + // get a wall with a hole in it. + wallWidth: 9.5, // metres, ACROSS the heading — the wall's length + wallHeight: 5.4, // metres + sheets: 8, // sheets in the rank (hard ceiling 16) + sheetOverlap: 1.6, // each sheet's width, × the rank pitch. >1 overlaps. + sheetScatter: 0.32, // metres of hashed slop off the nominal rank + sheetLean: 0.5, // metres the head is pushed along the wall's normal + sheetLeanJitter: 0.55, // ± fraction of that + sheetTaper: 1.08, // width multiplier at the head; >1 splays + sheetWidthJitter: 0.22, // ± fraction + sheetHeightJitter: 0.26, // ± fraction + riseSpread: 0.5, // 0..1 stagger of the rise across the rank + fallSink: 0.55, // metres the foot drops below the floor as it drains + phaseSpread: 1.0, // turns of per-sheet ripple phase offset + + /* --- the fold in the cloth --- */ + rippleAmp: 0.42, // metres + rippleLength: 2.9, // metres along the sheet, crest to crest + rippleSpeed: 1.4, // metres/second + rippleCurve: 1.3, // exponent on height: 0 rigid, >0 pins the foot + foldAmp: 0.85, // metres — the second, longer fold + foldLength: 8.0, // metres + foldSpeed: 0.4, // metres/second + rippleNoise: 0.2, // metres of fbm slop + rippleNoiseScale: 0.4, // cycles per metre + rippleNoiseSpeed: 0.18, // Hz + + /* --- the two curves. They MUST disagree ---------------------------- */ + // Coverage and radiance falling off together is a hanging ribbon. Rain is the + // opposite of an aurora: it *covers* most at the foot where the drops pile up + // and it *shines* fairly evenly, so alphaCurve sits well above emissionCurve + // and the head of the wall is thin without going dark. + alphaBase: 1.0, // coverage at the foot + alphaTop: 0.18, // coverage at the head + alphaCurve: 2.2, // exponent between them + emissionBase: 0.95, // radiance at the foot + emissionTop: 0.5, // radiance at the head + emissionCurve: 0.7, // ... deliberately not `alphaCurve` + + /* --- the envelope --- */ + body: 0.44, // 0..1 how much α a sheet is allowed at all + footFade: 0.03, // 0..1 of the height + headFade: 0.24, // 0..1 of the height + edgeFade: 0.28, // 0..1 across the sheet + graze: 1.0, // 0..1 of the 1/|N·V| path term — the volume cue + grazeFloor: 0.13, // clamp on |N·V| + curtainSoftFade: 0.5, // metres of depth fade against opaque geometry + curtainOpacity: 1.0, + curtainGlow: 1.05, // emissive gain into bloom + tintSpread: 0.35, // 0..1 how far apart neighbouring sheets are tinted + + /* --- the rain running down the face --- */ + streakDensity: 12.0, // lanes per metre across the face + streakRepeat: 3.2, // streaks per sheet height + streakSpeed: 2.6, // sheet heights per second + streakSpeedJitter: 0.4, // ± fraction, per lane + streakWidth: 0.13, // 0..1 of a lane + streakTail: 0.1, // 0..1 of a repeat — the trailing smear + streakDuty: 0.6, // 0..1 of lanes carrying a streak + haze: 0.34, // the veil between the streaks + colorRainTail: '#5f7f9a', // the smear behind a drop + colorRainHead: '#c8dcea', // the drop itself + colorRainBody: '#101c2a', // what the sheet's coverage is made of + + /* --- the lightning inside the wall's plane ------------------------- */ + // `CRACK`: filament 0 is the trunk and everything else forks off it, so the + // count is "how much fracture", not "how many lines". Both ends of the trunk + // are placed in the plane spanned by the wall's own width and height, which + // is why the discharge reads as being *inside* the water rather than in front + // of it. + boltFilaments: 6, // filaments in one strike + strikeRate: 2.4, // strikes per second + strikeHold: 0.26, // seconds one strike stays lit + boltTop: 0.92, // where a strike starts, × wallHeight + boltBottom: 0.04, // ... and where it ends + boltDrift: 0.5, // ± fraction of the half-width the two ends differ by + crackAngle: 0.6, // radians a fork leaves its parent by + crackLength: 0.6, // fork length, × its parent's + crackFalloff: 0.55, // extra shortening per generation + crackSpread: 0.5, // ± fraction of variation on `crackAngle` + crackStart: 0.2, // 0..1 earliest point on a parent a fork may happen + crackSag: 0.22, // metres of bow on a segment + crackFork: 0.5, // 0..1 slides the branch/twig split + boltTaper: 0.85, // 0..1 how tapered the trunk's ends are + + /* --- and the current that runs along its foot --- */ + footFilaments: 4, // filaments crawling the wall's base + footSag: -0.12, // metres; negative droops it onto the floor + footSpread: 0.45, // metres the crawl fans out over + footKink: 0.55, // × the shared kink amplitude + footDim: 0.6, // 0..1 how secondary the crawl reads + footDamp: 0.28, // 0..1 on the kink's world y — below 0.4 or it buries itself + footFloor: 0.03, // metres the crawl is clamped above + + /* --- the filament look (both roles share it) --- */ + filWidth: 0.03, // half-width of a core ribbon, metres + filGlowWidth: 6.4, // halo half-width, × the core + filGlowOpacity: 0.42, + filJitter: 0.5, // metres of kink at the coarsest octave + filJitterScale: 1.5, // kinks per metre + filOctaves: 4, // 1..5 + filJitterFalloff: 0.55, // amplitude kept per octave + filCrawl: 3.0, // how fast the kinks slide along + filPinch: 0.14, // 0..1 of the path the kink eases in over at each end + filRestrike: 26, // whole re-shapes per second + filFlicker: 0.3, // depth of the whole-bundle stutter + filFlickerSpeed: 34, // steps/second + filStrandFlash: 0.5, // depth of the per-filament blink + filCoreSharp: 4.6, // exponent on the core's edge falloff + filGlowFalloff: 2.4, + filSoftFade: 0.6, // metres of depth fade + filOpacity: 1.0, + filGlow: 2.6, + colorBoltCore: '#ffffff', + colorBoltInner: '#dcecff', + colorBoltOuter: '#7fb4ff', + colorBoltHalo: '#12325a', + + /* --- the wet floor (vfx/GroundField.js, WET) ----------------------- */ + wetRadius: 7.8, // metres + wetForward: 1.7, // metres downrange of the wall the patch is centred — + // "under and in front of it", so the reflection reads from + // the caster's side as well as through the curtain + wetHeight: 0.015, // metres above the floor the quad sits at + wetDry: 0.8, // 0..1 how far the patch has dried back from its own edge by + // the end of the drain. WET's `recede` eats the *outside* + // first, which is how a puddle actually goes. + wetEdge: 0.5, // metres of feather on the growth front + wetRagged: 0.3, // how far the front wanders, fraction of the radius + wetRaggedScale: 0.55, // lobes per metre + wetWarp: 0.6, // metres of domain warp on those lobes + wetCell: 0.5, // puddle scale, metres + wetCellJitter: 0.8, + wetLift: 0.014, // metres of surface ripple + wetDepth: 0.05, // metres the puddles sit down in + wetSharp: 0.45, + wetDetail: 0.75, + wetSpeed: 1.4, // ripple rate + wetFlow: 0.4, // drift, metres/second + wetWind: 0.5, // drift bearing, radians in the quad's frame + wetRelief: 0.5, // how hard the height field tilts the fake normal + wetNormalStep: 0.05, // metres between the height taps + wetAmbient: 0.22, // floor on the diffuse term + wetWrap: 0.4, + wetSpecular: 1.15, // this is the whole point of a wet floor + wetGloss: 46, // Blinn exponent + wetParallax: 0.25, // metres of view-driven offset + wetEmissive: 0.7, + wetOpacity: 0.9, + wetDepthFade: 0.5, // metres of soft fade against standing geometry + colorWetBase: '#243646', // the soaked stone + colorWetEdge: '#c8dcea', // sheen and highlights + colorWetGlow: '#5f7f9a', // anything emissive in it + colorWetDeep: '#101c2a', // the bottom of a puddle + + /* --- spray, mist and sparks ---------------------------------------- */ + /** + * Four-stop lifetime gradients, `A` at birth through `D` as it dies, spelled + * out per system rather than derived from the rain palette — the spray should + * be able to stay white while the curtain goes slate. + */ + sprayRate: 150, // droplets/second bouncing off the wall's foot + spraySize: 0.05, + spraySpeed: 2.6, + sprayLifetime: 0.65, + sprayGravity: -11.0, + spraySpread: 0.75, + colorSprayA: '#eaf4ff', + colorSprayB: '#c8dcea', + colorSprayC: '#7f9fb8', + colorSprayD: '#3d5266', + + mistRate: 34, // haze rolling off the face + mistSize: 1.5, + mistSpeed: 0.9, + mistLifetime: 3.0, + mistRise: 0.32, // metres/second + mistOpacity: 0.07, + colorMistA: '#7f97ab', + colorMistB: '#5f7f9a', + colorMistC: '#3b4f61', + colorMistD: '#1d2833', + + strikeSparks: 34, // sparks thrown by one lightning strike + sparkSize: 0.14, + sparkSpeed: 7.0, + sparkLifetime: 0.45, + sparkGravity: -14.0, + sparkStretch: 0.2, + colorSparkA: '#ffffff', + colorSparkB: '#dcecff', + colorSparkC: '#7fb4ff', + colorSparkD: '#12325a', + + /* --- what the beats do to the camera and the screen --- */ + riseShake: 0.32, // shake as the wall comes up + riseShakeTime: 0.6, // seconds it decays over + strikeFlash: 0.06, // screen flash on a lightning strike. Small — it fires + // twice a second and a bright one is a strobe. + colorStrikeFlash: '#c8dcea', + strikeLight: 22, // additive punch on the dynamic light per strike + holdRumble: 0.03, // sustained shake while the wall stands + + /* --- dynamic light --- */ + lightIntensity: 8, + lightRadius: 15, + lightColor: '#7f9fbf' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Tempest Wall. + * + * `wallWidth` and `wallHeight` are the two to reach for: they re-lay the rank, + * re-span the lightning and re-scale the puddle at once, on a wall that is + * already standing. After those, `sheetOverlap` decides whether it reads as a + * wall or as a fence (below about 1.2 you can see between the sheets), and the + * pair `alphaCurve` / `emissionCurve` is the one thing in the Curtain module + * that must never be set equal — do it once and you will see the hanging + * ribbon the module exists to avoid. + */ +export const stormwallSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 0.5, 'squall speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The three beats': [ + ['riseTime', 0.05, 3, 0.01, '1 · rise (s)'], + ['holdTime', 0.2, 12, 0.05, '2 · hold (s)'], + ['fallTime', 0.1, 5, 0.05, '3 · drain (s)'] + ], + 'The wall': [ + ['wallWidth', 1, 24, 0.1, 'width ACROSS the aim (m)'], + ['wallHeight', 0.5, 14, 0.05, 'height (m)'], + ['sheets', 1, 16, 1, 'sheets in the rank'], + ['sheetOverlap', 0.5, 3, 0.01, 'sheet width × pitch'], + ['sheetScatter', 0, 2, 0.01, 'rank slop (m)'], + ['sheetLean', -3, 3, 0.01, 'head lean (m)'], + ['sheetLeanJitter', 0, 2, 0.01, 'lean jitter'], + ['sheetTaper', 0.2, 2.5, 0.01, 'head taper'], + ['sheetWidthJitter', 0, 1, 0.01, 'width jitter'], + ['sheetHeightJitter', 0, 1, 0.01, 'height jitter'], + ['riseSpread', 0, 0.95, 0.01, 'rise stagger'], + ['fallSink', 0, 3, 0.01, 'drain sink (m)'], + ['phaseSpread', 0, 4, 0.01, 'phase spread'] + ], + 'The wall/The fold': [ + ['rippleAmp', 0, 3, 0.01, 'ripple (m)'], + ['rippleLength', 0.2, 12, 0.05, 'ripple length (m)'], + ['rippleSpeed', -6, 6, 0.01, 'ripple speed (m/s)'], + ['rippleCurve', 0, 4, 0.01, 'ripple curve'], + ['foldAmp', 0, 4, 0.01, 'fold (m)'], + ['foldLength', 1, 24, 0.1, 'fold length (m)'], + ['foldSpeed', -4, 4, 0.01, 'fold speed (m/s)'], + ['rippleNoise', 0, 2, 0.01, 'slop (m)'], + ['rippleNoiseScale', 0.02, 3, 0.01, 'slop scale'], + ['rippleNoiseSpeed', 0, 2, 0.01, 'slop Hz'] + ], + 'The wall/The two curves': [ + ['alphaBase', 0, 2, 0.01, 'coverage at foot'], + ['alphaTop', 0, 2, 0.01, 'coverage at head'], + ['alphaCurve', 0.05, 6, 0.01, 'coverage curve'], + ['emissionBase', 0, 3, 0.01, 'radiance at foot'], + ['emissionTop', 0, 3, 0.01, 'radiance at head'], + ['emissionCurve', 0.05, 6, 0.01, 'radiance curve'] + ], + 'The wall/The envelope': [ + ['body', 0, 1, 0.01, 'sheet body'], + ['footFade', 0, 0.5, 0.005, 'foot fade'], + ['headFade', 0, 1, 0.01, 'head fade'], + ['edgeFade', 0, 1, 0.01, 'edge fade'], + ['graze', 0, 2, 0.01, 'grazing path term'], + ['grazeFloor', 0.02, 1, 0.01, 'grazing clamp'], + ['curtainSoftFade', 0.02, 3, 0.01, 'soft fade (m)'], + ['curtainOpacity', 0, 2, 0.01, 'opacity'], + ['curtainGlow', 0, 4, 0.01, 'glow'], + ['tintSpread', 0, 1, 0.01, 'tint spread'] + ], + 'The rain': [ + ['streakDensity', 1, 40, 0.5, 'lanes / metre'], + ['streakRepeat', 0.2, 12, 0.05, 'streaks / height'], + ['streakSpeed', 0, 12, 0.05, 'fall speed'], + ['streakSpeedJitter', 0, 1, 0.01, 'speed jitter'], + ['streakWidth', 0.02, 1, 0.005, 'streak width'], + ['streakTail', 0.005, 0.6, 0.005, 'streak tail'], + ['streakDuty', 0, 1, 0.01, 'lanes carrying'], + ['haze', 0, 1, 0.01, 'veil'], + ['colorRainTail', 'streak tail'], + ['colorRainHead', 'drop head'], + ['colorRainBody', 'sheet body'] + ], + 'The lightning': [ + ['boltFilaments', 0, 20, 1, 'filaments / strike'], + ['strikeRate', 0, 12, 0.05, 'strikes / second'], + ['strikeHold', 0.02, 1.5, 0.01, 'strike lifetime (s)'], + ['boltTop', 0, 1.2, 0.01, 'start height × wall'], + ['boltBottom', 0, 1, 0.01, 'end height × wall'], + ['boltDrift', 0, 1.5, 0.01, 'end-to-end drift'], + ['crackAngle', 0, 1.6, 0.01, 'fork angle (rad)'], + ['crackLength', 0.05, 1, 0.01, 'fork length'], + ['crackFalloff', 0, 1, 0.01, 'generation falloff'], + ['crackSpread', 0, 1.5, 0.01, 'fork variation'], + ['crackStart', 0, 0.9, 0.01, 'earliest fork'], + ['crackSag', -2, 2, 0.01, 'segment bow (m)'], + ['crackFork', 0, 1, 0.01, 'branch / twig split'], + ['boltTaper', 0, 1, 0.01, 'trunk taper'] + ], + 'The lightning/Along the foot': [ + ['footFilaments', 0, 12, 1, 'filaments'], + ['footSag', -2, 2, 0.01, 'sag (m)'], + ['footSpread', 0, 3, 0.01, 'fan (m)'], + ['footKink', 0, 3, 0.01, 'kink × shared'], + ['footDim', 0, 1, 0.01, 'dim'], + ['footDamp', 0, 1, 0.01, 'ground damp'], + ['footFloor', 0, 0.5, 0.005, 'floor clamp (m)'] + ], + 'The lightning/The ribbon': [ + ['filWidth', 0.004, 0.4, 0.002, 'core width (m)'], + ['filGlowWidth', 1, 20, 0.1, 'halo width'], + ['filGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['filJitter', 0, 3, 0.01, 'kink (m)'], + ['filJitterScale', 0.05, 6, 0.01, 'kinks / metre'], + ['filOctaves', 1, 5, 1, 'octaves'], + ['filJitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['filCrawl', -20, 20, 0.1, 'kink crawl'], + ['filPinch', 0.01, 0.5, 0.005, 'end pinch'], + ['filRestrike', 0.5, 90, 0.5, 'restrikes / sec'], + ['filFlicker', 0, 1, 0.01, 'brightness stutter'], + ['filFlickerSpeed', 1, 120, 1, 'stutter rate'], + ['filStrandFlash', 0, 1, 0.01, 'filament blink'], + ['filCoreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['filGlowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['filSoftFade', 0.02, 3, 0.01, 'soft fade (m)'], + ['filOpacity', 0, 2, 0.01, 'opacity'], + ['filGlow', 0, 8, 0.01, 'glow'], + ['colorBoltCore', 'core'], + ['colorBoltInner', 'inner'], + ['colorBoltOuter', 'outer'], + ['colorBoltHalo', 'halo'] + ], + 'The wet floor': [ + ['wetRadius', 0.5, 20, 0.1, 'radius (m)'], + ['wetForward', -6, 8, 0.05, 'centred downrange (m)'], + ['wetHeight', 0.002, 0.2, 0.001, 'height above floor (m)'], + ['wetDry', 0, 1, 0.01, 'dries back by (end of drain)'], + ['wetEdge', 0.02, 3, 0.01, 'front feather (m)'], + ['wetRagged', 0, 1, 0.01, 'front wander'], + ['wetRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['wetWarp', 0, 3, 0.01, 'domain warp (m)'], + ['wetCell', 0.05, 4, 0.01, 'puddle scale (m)'], + ['wetCellJitter', 0, 1, 0.01, 'puddle disorder'], + ['wetLift', 0, 0.2, 0.001, 'ripple height (m)'], + ['wetDepth', 0, 0.6, 0.005, 'puddle depth (m)'], + ['wetSharp', 0, 1, 0.01, 'profile hardness'], + ['wetDetail', 0, 1, 0.01, 'detail'], + ['wetSpeed', 0, 6, 0.01, 'ripple rate'], + ['wetFlow', 0, 3, 0.01, 'drift (m/s)'], + ['wetWind', -3.2, 3.2, 0.01, 'drift bearing (rad)'], + ['wetRelief', 0, 2, 0.01, 'relief'], + ['wetNormalStep', 0.005, 0.3, 0.005, 'normal step (m)'], + ['wetAmbient', 0, 1, 0.01, 'ambient'], + ['wetWrap', 0, 1, 0.01, 'terminator wrap'], + ['wetSpecular', 0, 3, 0.01, 'specular'], + ['wetGloss', 1, 120, 1, 'gloss'], + ['wetParallax', 0, 2, 0.01, 'parallax (m)'], + ['wetEmissive', 0, 3, 0.01, 'emissive'], + ['wetOpacity', 0, 1, 0.01, 'opacity'], + ['wetDepthFade', 0.02, 3, 0.01, 'soft fade (m)'], + ['colorWetBase', 'soaked stone'], + ['colorWetEdge', 'sheen'], + ['colorWetGlow', 'emissive'], + ['colorWetDeep', 'puddle floor'] + ], + 'Spray, mist & sparks': [ + ['sprayRate', 0, 600, 1, 'spray rate'], + ['spraySize', 0.005, 0.4, 0.005, 'spray size'], + ['spraySpeed', 0, 12, 0.05, 'spray speed'], + ['sprayLifetime', 0.05, 3, 0.01, 'spray lifetime'], + ['sprayGravity', -40, 0, 0.1, 'spray gravity'], + ['spraySpread', 0, 1, 0.01, 'spray cone'], + ['mistRate', 0, 300, 1, 'mist rate'], + ['mistSize', 0.05, 5, 0.01, 'mist size'], + ['mistSpeed', 0, 6, 0.05, 'mist speed'], + ['mistLifetime', 0.2, 10, 0.05, 'mist lifetime'], + ['mistRise', -2, 4, 0.01, 'mist rise'], + ['mistOpacity', 0, 1, 0.005, 'mist opacity'], + ['strikeSparks', 0, 300, 1, 'sparks / strike'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 30, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 3, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['colorSpray*', 'Spray colour'], + ['colorMist*', 'Mist colour'], + ['colorSpark*', 'Spark colour'] + ], + 'Camera & light': [ + ['riseShake', 0, 2, 0.01, 'shake on the rise'], + ['riseShakeTime', 0.05, 3, 0.01, 'shake duration (s)'], + ['strikeFlash', 0, 1, 0.005, 'flash per strike'], + ['strikeLight', 0, 90, 0.5, 'light punch per strike'], + ['holdRumble', 0, 0.4, 0.005, 'rumble while standing'], + ['colorStrikeFlash', 'strike flash colour'], + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/sumistroke.js b/src/config/abilities/sumistroke.js new file mode 100644 index 0000000..9eeaba4 --- /dev/null +++ b/src/config/abilities/sumistroke.js @@ -0,0 +1,387 @@ +/* ================================================================== */ +/* SUMISTROKE — Sumi Stroke */ +/* ================================================================== */ +/** + * One enormous brushstroke laid down the aimed line, on the floor, which runs + * out of ink before it gets to the end. + * + * The trick is **dry brush**, and dry brush is a *supply* problem rather than a + * texture problem. `vfx/BrushStroke.js` models the ferrule as a rank of + * individual bristles, each carrying its own load of pigment in + * *metre-pigment* — the length of nominal full-pressure stroke it can lay + * before it is empty. Every bristle spends its own load against the pressure + * curve and the brush's own speed, and past the point where a bristle's load + * runs low it starts making intermittent contact with the paper. So the tail + * comes apart into separated fibre streaks that each stop somewhere different, + * with clean floor between them, because they genuinely ran out at different + * places. Nothing here is a mask over an even stroke, and the difference is + * visible from across the room: a mask makes holes in a mark, and dry brush is + * several marks that used to be one. + * + * ### Why the numbers below are shaped the way they are + * + * The rule this block is tuned under is `BrushStroke`'s own: **let the pressure + * curve spend the ink**. Three keys do almost all the work, and none of them is + * called `dryness`: + * + * - `inkLoad` × `mainLoad` — metre-pigment in the ferrule. At 1.0 × 11 the + * brush carries eleven metres of full-pressure stroke, and a `range` of 22 m + * therefore *cannot* be reached wet. That is the whole design: the runout + * point is `load / spend-rate`, and it moves when you drag either. + * - `entryDwell` — where the spine's second control point sits, as a fraction + * of the span. Small means the brush *dwells* at the entry, `|B'(t)|` + * collapses there, `BrushStroke`'s dwell term blows up, the ink pools into a + * heavy head — and that pool is paid for out of the same ferrule, so the + * tail frays sooner. Cause and effect, one slider. + * - `mainSwell` / `mainHold` — how hard the body of the stroke presses. Press + * harder and it dries earlier. That is the correct direction, and it is why + * `dryBand` is a shape control here rather than a placement control. + * + * ### Metres, not fractions + * + * `width`, `depth`, `bow`, `pressLength`, `shoulderOffset` and `wetLength` are + * all **metres**, so a stroke cast at four metres and a stroke cast at twenty + * are drawn with the *same brush* — the short one is a fat comma and the long + * one is a hairline that dies half way. Scaling the brush with the cast length + * was the first thing tried and it looks like a zoom, not like calligraphy. + * + * ### No bloom + * + * Ink is the anti-glow school. `ceiling` is a hard clamp on the material's + * output *linear luminance* against `post.bloomThreshold` (0.88 as shipped), so + * 0.62 makes it arithmetically impossible for this mark to feed the bloom pass + * whatever the four pickers are set to. The particle systems are all + * non-additive with their glow held down for the same reason. If anything in + * this slot ever haloes, the value that is wrong is one of these. + */ + +export const sumistroke = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres — deliberately longer than the ferrule holds + minRange: 4.0, // closer than this and the cast is refused + speed: 17.0, // metres/second the brush is dragged; the mark is drawn at this rate + cooldown: 1.1, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats, in seconds --- */ + liftTime: 0.26, // seconds the brush is dragged past the target before it comes off + holdTime: 2.6, // seconds the finished mark stands while the ink soaks in + soakTime: 1.8, // seconds the capillary bleed takes to reach `bleedSoak` + fadeTime: 1.9, // seconds it takes to go + + /* ================================================================ */ + /* Where the stroke is drawn */ + /* ================================================================ */ + paperLift: 0.03, // metres the mark floats above the floor — see `softFade` + startInset: 0.9, // metres in front of the caster the brush is set down + endOvershoot: 1.4, // metres past the aim point the brush is dragged before lifting + bow: 1.1, // metres of lateral arc; a calligraphic stroke is never straight + bowSkew: 0.42, // 0..1 — where the belly of that arc sits along the span + entryDwell: 0.11, // fraction of the span p1 sits from p0. SMALL = the brush dwells + exitRush: 0.46, // fraction of the span p2 sits back from p3. LARGE = it accelerates away + + /* ================================================================ */ + /* The brush — BrushStroke, FLAT tip */ + /* ================================================================ */ + width: 0.42, // metres, ferrule half-width at pressure 1 + depth: 0.028, // metres, half-thickness through the paper normal + ferruleDepth: 0.0, // metres the bristles spread through it — 0 for a flat hake + bristleWidth: 1.55, // >1 overlaps into a solid stroke, <1 separates it from the start + splay: 0.22, // fraction the bristles fan out at full pressure + wobble: 0.016, // metres of per-bristle lateral wander + fibreScale: 2.1, // features per metre of that wander + + /* ================================================================ */ + /* The ink — the whole trick */ + /* ================================================================ */ + inkLoad: 1.0, // multiplier on every stroke's own metre-pigment load + mainLoad: 11.0, // metre-pigment in the ferrule for the main stroke + pressLoad: 4.5, // and for the entry press — a short mark, so it never runs out + shoulderLoad: 4.0, // and for the shoulder streak, which is meant to die early + flowLength: 0.62, // pigment laid per metre of travel, at pressure 1 + flowDwell: 0.44, // extra pigment laid per metre when the brush dwells + speedRef: 16.0, // metres per unit t at which the brush counts as "at speed" + edgeStarve: 0.48, // load multiplier at the edge of the ferrule — dries outside-in + loadJitter: 0.42, // +/- fraction of load, per bristle: no two let go together + dryBand: 2.9, // metre-pigment over which a bristle goes wet -> dry + dryThin: 0.5, // width multiplier when a bristle is fully dry + skipScale: 3.1, // features per metre of the contact noise + skipSoft: 0.11, // softness of the contact threshold + skipContrast: 1.9, // pushes the value noise off its central pile (README trap 4) + + /* --- pooling --- */ + poolSwell: 0.52, // fraction the stroke widens per unit of extra pigment + poolCurve: 0.4, // extra pigment per 1/metre of spine curvature + pigment: 1.0, // overall density multiplier + + /* ================================================================ */ + /* The three marks the brush makes */ + /* ================================================================ */ + /* Windows are unitless fractions of `progress`, which runs 0..1 as the front + * travels. They overlap on purpose: a brush that finishes one mark before it + * starts the next has been lifted, and lifting is a thing you can see. */ + + /* --- the entry press (起筆): the brush is set down and tucked back --- */ + pressLength: 0.75, // metres — how far back the tuck travels before reversing + pressAngle: 2.15, // radians off the cast heading the tuck runs at + pressStart: 0.0, // where in `progress` it is written + pressSpan: 0.13, + pressEntry: 0.55, // pressure control points: entry, swell, hold, exit + pressSwell: 1.25, + pressHold: 1.15, + pressExit: 0.35, + + /* --- the stroke itself --- */ + mainStart: 0.05, + mainSpan: 0.95, + mainEntry: 0.34, // set down light... + mainSwell: 1.15, // ...press through the body... + mainHold: 0.78, // ...ease off... + mainExit: 0.05, // ...and lift to nothing + + /* --- the shoulder streak: what the top edge of the ferrule left behind --- */ + shoulderOffset: 0.34, // metres to the side of the main spine + shoulderScale: 0.72, // fraction of the main span it covers + shoulderStart: 0.11, + shoulderSpan: 0.7, + shoulderEntry: 0.22, + shoulderSwell: 0.52, + shoulderHold: 0.36, + shoulderExit: 0.03, + + /* ================================================================ */ + /* The mark on the paper */ + /* ================================================================ */ + headTaper: 0.07, // fraction of the drawn length the moving head tapers over + wetLength: 1.3, // metres behind the head that still read wet + bleed: 0.2, // fraction of the bristle half-width the edge wicks over + bleedSoak: 0.16, // extra bleed once the mark has soaked for `soakTime` + fibreEdge: 0.2, // capillary roughness of that edge + dryPigment: 0.66, // density multiplier where a bristle is dry + wetGain: 0.34, // extra density in the wet band behind the head + opacity: 1.0, + lit: 0.3, // 0 flat pigment, 1 wrapped diffuse + backLit: 0.75, // the floor of that wrap — high, because ink is matte + ceiling: 0.62, // max linear luminance. post.bloomThreshold is 0.88. DO NOT RAISE + softFade: 0.035, // metres of depth feather. See the note in the class doc + tint: 0.05, // where in the gradient a zero-density mark sits + tintDensity: 0.86, // how far density walks it + tintJitter: 0.09, // +/- per bristle + + /* --- four pickers, none derived from another --- */ + colorWash: '#b9ac96', // the palest wicked edge, where the vehicle outran the pigment + colorBody: '#574c42', + colorInk: '#1a1714', + colorPool: '#060505', // where it pooled at the tuck + + /* ================================================================ */ + /* Pigment mist — thrown off the wet part of the stroke */ + /* ================================================================ */ + mistRate: 46.0, // particles/second while the brush is down + mistSize: 0.1, + mistSpeed: 0.9, // metres/second + mistLifetime: 1.5, // seconds + mistRise: 0.35, // metres/second² — barely; this is heavy pigment, not smoke + mistTurbulence: 0.5, + mistOpacity: 0.5, + mistGlow: 0.32, // held low: a matte school does not get bright particles + colorMistA: '#8d8375', + colorMistB: '#4a4139', + colorMistC: '#221d1a', + colorMistD: '#0d0b0a', + + /* ================================================================ */ + /* Flying white — paper fibre torn up where the brush has gone dry */ + /* ================================================================ */ + /* Emitted only downstream of the runout point, which the ability computes on + * the CPU with the same integral the vertex shader uses. That is what makes + * this system read as a *consequence* of the dry brush rather than as an + * effect that happens to be near the end. */ + flakeRate: 34.0, // particles/second, over the dry length only + flakeSize: 0.055, + flakeSpeed: 1.5, + flakeLifetime: 1.15, + flakeGravity: -3.4, // metres/second² + flakeSpin: 7.0, // radians/second + flakeOpacity: 0.85, + colorFlakeA: '#d3c8b1', // bare paper, which is the point of flying white + colorFlakeB: '#a2937c', + colorFlakeC: '#5d5348', + colorFlakeD: '#241f1b', + + /* ================================================================ */ + /* Spatter — droplets flung at the tuck and at the lift */ + /* ================================================================ */ + spatterTuck: 26, // droplets thrown when the brush is set down + spatterLift: 18, // and when it is picked up + spatterSize: 0.075, + spatterSpeed: 3.6, + spatterLifetime: 0.95, + spatterGravity: -9.5, + spatterOpacity: 0.9, + colorSpatterA: '#6b5f52', + colorSpatterB: '#312a25', + colorSpatterC: '#171412', + colorSpatterD: '#070606', + + /* ================================================================ */ + /* Feel */ + /* ================================================================ */ + rumble: 0.012, // camera rumble while the brush is dragging. Tiny — this is paper + liftShake: 0.06, // one-shot nudge as the brush is lifted + shakeDuration: 0.4, // seconds + + /* ================================================================ */ + /* Dynamic light */ + /* ================================================================ */ + /* Ink emits nothing. This is a small warm lamp riding the wet head so the + * floor around the brush reads slightly damp — it lights the paper, it does + * not light the ink. Turn it to 0 and the slot still works. */ + lightIntensity: 3.2, + lightRadius: 4.5, // metres + lightHeight: 0.55, // metres above the floor the lamp sits + lightColor: '#c9b79a' +}; + +/** Editor layout: which folders exist and what goes in them. */ +export const sumistrokeSchema = { + 'The cast': [ + ['range', 4, 40, 0.5, 'range (m)'], + ['minRange', 0.5, 12, 0.1, 'minimum range (m)'], + ['speed', 3, 60, 0.5, 'brush speed (m/s)'], + ['cooldown', 0.1, 6, 0.05, 'cooldown (s)'], + 'castAnim', + ['liftTime', 0.02, 3, 0.01, 'lift (s)'], + ['holdTime', 0.2, 10, 0.05, 'hold (s)'], + ['soakTime', 0.1, 8, 0.05, 'soak time (s)'], + ['fadeTime', 0.2, 8, 0.05, 'fade (s)'] + ], + 'The gesture': [ + ['paperLift', 0, 0.4, 0.002, 'paper lift (m)'], + ['startInset', 0, 6, 0.05, 'set-down inset (m)'], + ['endOvershoot', 0, 8, 0.05, 'lift overshoot (m)'], + ['bow', -6, 6, 0.02, 'lateral bow (m)'], + ['bowSkew', 0, 1, 0.01, 'bow skew'], + ['entryDwell', 0.01, 0.6, 0.005, 'entry dwell'], + ['exitRush', 0.01, 0.9, 0.005, 'exit rush'] + ], + 'The brush': [ + ['width', 0.02, 1.6, 0.005, 'ferrule half-width (m)'], + ['depth', 0.001, 0.3, 0.001, 'mark half-depth (m)'], + ['ferruleDepth', 0, 0.2, 0.001, 'ferrule depth (m)'], + ['bristleWidth', 0.2, 4, 0.01, 'bristle overlap'], + ['splay', 0, 1.5, 0.01, 'splay at full pressure'], + ['wobble', 0, 0.12, 0.001, 'bristle wander (m)'], + ['fibreScale', 0.2, 12, 0.05, 'wander frequency (1/m)'] + ], + 'The ink': [ + ['inkLoad', 0.05, 4, 0.01, 'ink load multiplier'], + ['mainLoad', 0.5, 40, 0.1, 'main load (metre-pigment)'], + ['pressLoad', 0.2, 20, 0.1, 'tuck load (metre-pigment)'], + ['shoulderLoad', 0.1, 20, 0.1, 'shoulder load (metre-pigment)'], + ['flowLength', 0.02, 3, 0.01, 'flow per metre'], + ['flowDwell', 0, 3, 0.01, 'flow while dwelling'], + ['speedRef', 1, 60, 0.5, 'reference speed'], + ['edgeStarve', 0, 1.5, 0.01, 'edge starvation'], + ['loadJitter', 0, 1, 0.01, 'per-bristle load jitter'], + ['dryBand', 0.05, 12, 0.05, 'dry band (metre-pigment)'], + ['dryThin', 0, 1.5, 0.01, 'dry thinning'], + ['skipScale', 0.2, 16, 0.05, 'skip frequency (1/m)'], + ['skipSoft', 0.005, 0.6, 0.005, 'skip softness'], + ['skipContrast', 0.2, 5, 0.01, 'skip contrast'], + ['poolSwell', 0, 2, 0.01, 'pool swell'], + ['poolCurve', 0, 3, 0.01, 'pool on curvature'], + ['pigment', 0.05, 4, 0.01, 'pigment density'] + ], + 'The tuck (起筆)': [ + ['pressLength', 0, 4, 0.02, 'tuck length (m)'], + ['pressAngle', -3.2, 3.2, 0.01, 'tuck angle (rad)'], + ['pressStart', 0, 1, 0.005, 'window start'], + ['pressSpan', 0.01, 1, 0.005, 'window span'], + ['pressEntry', 0, 2, 0.01, 'pressure: entry'], + ['pressSwell', 0, 2.5, 0.01, 'pressure: swell'], + ['pressHold', 0, 2.5, 0.01, 'pressure: hold'], + ['pressExit', 0, 2, 0.01, 'pressure: exit'] + ], + 'The stroke': [ + ['mainStart', 0, 1, 0.005, 'window start'], + ['mainSpan', 0.01, 1, 0.005, 'window span'], + ['mainEntry', 0, 2, 0.01, 'pressure: entry'], + ['mainSwell', 0, 2.5, 0.01, 'pressure: swell'], + ['mainHold', 0, 2.5, 0.01, 'pressure: hold'], + ['mainExit', 0, 2, 0.01, 'pressure: exit'] + ], + 'The shoulder streak': [ + ['shoulderOffset', -2, 2, 0.01, 'lateral offset (m)'], + ['shoulderScale', 0.05, 1.4, 0.01, 'span fraction'], + ['shoulderStart', 0, 1, 0.005, 'window start'], + ['shoulderSpan', 0.01, 1, 0.005, 'window span'], + ['shoulderEntry', 0, 2, 0.01, 'pressure: entry'], + ['shoulderSwell', 0, 2.5, 0.01, 'pressure: swell'], + ['shoulderHold', 0, 2.5, 0.01, 'pressure: hold'], + ['shoulderExit', 0, 2, 0.01, 'pressure: exit'] + ], + 'The mark': [ + ['headTaper', 0.002, 0.5, 0.002, 'head taper'], + ['wetLength', 0, 8, 0.02, 'wet band (m)'], + ['bleed', 0.01, 1, 0.005, 'capillary bleed'], + ['bleedSoak', 0, 1, 0.005, 'extra bleed when soaked'], + ['fibreEdge', 0, 1, 0.005, 'edge roughness'], + ['dryPigment', 0, 1.5, 0.01, 'density when dry'], + ['wetGain', 0, 1.5, 0.01, 'density when wet'], + ['opacity', 0, 1, 0.005, 'opacity'], + ['lit', 0, 1, 0.01, 'wrapped diffuse'], + ['backLit', 0, 1, 0.01, 'wrap floor'], + ['ceiling', 0.05, 0.88, 0.005, 'luminance ceiling'], + ['softFade', 0.002, 0.5, 0.002, 'depth feather (m)'], + ['tint', 0, 1, 0.005, 'gradient at zero density'], + ['tintDensity', 0, 2, 0.01, 'gradient walk'], + ['tintJitter', 0, 0.5, 0.005, 'per-bristle tint jitter'], + ['colorWash', 'wicked edge'], + ['colorBody', 'body'], + ['colorInk', 'full ink'], + ['colorPool', 'pooled'] + ], + 'Pigment mist': [ + ['mistRate', 0, 400, 1, 'rate'], + ['mistSize', 0.005, 0.6, 0.005, 'size'], + ['mistSpeed', 0, 8, 0.05, 'speed'], + ['mistLifetime', 0.1, 6, 0.05, 'lifetime (s)'], + ['mistRise', -4, 4, 0.02, 'rise (m/s²)'], + ['mistTurbulence', 0, 3, 0.01, 'turbulence'], + ['mistOpacity', 0, 1, 0.005, 'opacity'], + ['mistGlow', 0, 1, 0.005, 'glow — keep it low'], + ['colorMist*', 'Mist colour'] + ], + 'Flying white (飛白)': [ + ['flakeRate', 0, 400, 1, 'rate'], + ['flakeSize', 0.005, 0.4, 0.002, 'size'], + ['flakeSpeed', 0, 10, 0.05, 'speed'], + ['flakeLifetime', 0.1, 6, 0.05, 'lifetime (s)'], + ['flakeGravity', -20, 4, 0.05, 'gravity (m/s²)'], + ['flakeSpin', 0, 24, 0.1, 'spin (rad/s)'], + ['flakeOpacity', 0, 1, 0.005, 'opacity'], + ['colorFlake*', 'Fibre colour'] + ], + Spatter: [ + ['spatterTuck', 0, 200, 1, 'droplets at the tuck'], + ['spatterLift', 0, 200, 1, 'droplets at the lift'], + ['spatterSize', 0.005, 0.4, 0.002, 'size'], + ['spatterSpeed', 0, 16, 0.05, 'speed'], + ['spatterLifetime', 0.1, 4, 0.05, 'lifetime (s)'], + ['spatterGravity', -30, 0, 0.1, 'gravity (m/s²)'], + ['spatterOpacity', 0, 1, 0.005, 'opacity'], + ['colorSpatter*', 'Droplet colour'] + ], + Feel: [ + ['rumble', 0, 0.2, 0.001, 'drag rumble'], + ['liftShake', 0, 1, 0.005, 'shake on lift'], + ['shakeDuration', 0.05, 3, 0.01, 'shake duration (s)'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 40, 0.1, 'light intensity'], + ['lightRadius', 0.5, 30, 0.1, 'light radius (m)'], + ['lightHeight', 0, 4, 0.02, 'light height (m)'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/sunspear.js b/src/config/abilities/sunspear.js new file mode 100644 index 0000000..f34a599 --- /dev/null +++ b/src/config/abilities/sunspear.js @@ -0,0 +1,469 @@ +import { ShellMode, shellDefaults, shellSchema } from '../../vfx/Shell.js'; + +/* ================================================================== */ +/* SUNSPEAR — flame, line cast */ +/* ================================================================== */ +/** + * A white-fire javelin on a ballistic arc, and the hole it leaves in the air. + * + * This is one of the two slots that exist to prove the distortion pass works, + * and it is the loud one: the spear's wake is written straight into the + * refraction buffer, so the floor grid, the character and every particle behind + * it genuinely bend as it goes past. Nothing about that is a post effect bolted + * on afterwards — it is one quad on `LAYER.DISTORTION` riding the spear, and + * `wakeStrength` is a slider you can push until the room folds. + * + * On landing the same emitter stops travelling and settles over a **sun disc** + * lying flat on the floor, with corona filaments licking off its rim. The spear + * itself is deliberately simple geometry: a faceted spindle, extremely bright. + * All the drama is in the wake and the disc, and a spear with more detail on it + * than that just competes with them. + * + * ## Two things about the numbers below + * + * **`spearFlight` is 1, and that is not a second.** The javelin's τ is driven + * off the ability's own front, so it lands on the exact frame the impact beat + * fires however the ease-in curve behaves. That makes `wakeSpan` a *fraction of + * the flight* rather than a duration — which is the more useful slider anyway — + * and `wakeBurn`, in real seconds, is the one that says how long the wake takes + * to catch its own head up after the spear is gone. + * + * **The disc's radius is not in this block twice.** `discRadius` → `discRadiusEnd` + * is interpolated by `Shell` on its own easing exponent from the ability's + * normalised life, and the corona filaments are placed against whatever radius + * that resolves to *this frame* (`Shell#radius`). Drag `discRadiusEnd` with the + * game paused and the corona moves out with the rim, because it never had its + * own copy of the number. + */ +export const sunspear = { + /* --- the cast --- */ + range: 30.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + speed: 34.0, // how fast the javelin covers the line, metres/second + cooldown: 1.1, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + discHold: 0.9, // seconds the disc holds at full before the bloom-out + discFade: 2.6, // seconds of slow bloom-out — the long tail this slot is for + + /* --- where the javelin leaves the caster --- */ + handForward: 0.7, // metres in front of the caster + handSide: 0.3, // metres to the side (+ follows `Ability#side`) + handHeight: 1.55, // metres above the floor — thrown from over the shoulder + landHeight: 0.34, // metres the spear's centre lands at, so its point meets the floor + + /* --- the arc --- */ + spearFlight: 1.0, // nominal flight, in units of the ability's own front (see above) + arcApex: 3.4, // metres the throw lofts at mid-span + arcApexCurve: 1.1, // >1 flattens the top of the lob + arcCurve: 1.0, // easing on launch → land; >1 accelerates into the ground + + /* --- the javelin --- */ + spearRadius: 0.17, // half-width at the butt, metres + spearStretch: 5.2, // length along the heading, × the radius (half-length) + // Not 1. At exactly 1 the alignment slerp overwrites the tumble outright and + // `spearSpin` becomes a control that does nothing; a few per cent short of it + // leaves a slow roll and a couple of degrees of lean, which is also what + // stops the javelin reading as a prop on a rail. + spearAlign: 0.96, // 1 lays the long axis exactly on the heading + spearSpin: 2.4, // residual roll about that axis, radians/second + spearFlash: 0.14, // seconds the birth pop decays over + spearSides: 5, // facets around the spindle + spearTaper: 0.06, // point radius as a fraction of the butt + spearRough: 0.16, // how far the facets are pushed off a clean prism + spearBend: 0.05, // sideways curve from butt to point + + /* --- the javelin's shading --- */ + spearCore: 0.34, // 0..1 of the half-width that runs pure white + spearFalloff: 2.2, // how hard the fire falls off across the shaft + spearHead: 0.62, // 0..1 along the shaft where the head heat starts + spearHeadGain: 2.4, // how much hotter the point is than the butt + spearFresnel: 1.5, // edge light on the silhouette + spearFresnelPower: 2.0, + spearNoise: 0.55, // how much the fire crawls over the shaft + spearNoiseScale: 4.5, // features per unit length + spearNoiseSpeed: 2.6, // Hz that crawl runs at + spearGlow: 3.2, // emissive gain into bloom + spearOpacity: 1.0, + spearSoftFade: 0.4, // metres of depth feather where it meets geometry + colorSpearCore: '#ffffff', + colorSpearFire: '#ffe07a', + colorSpearEdge: '#ff9a1f', + + /* --- the wake, in the refraction buffer --- */ + // Magnitudes are SCREEN FRACTIONS, not metres, and `post.distortion` × + // `global.distortion` is applied by the pass — never here, or it lands twice. + wakeWidth: 3.6, // metres across + wakeHeight: 3.0, // metres tall + wakeStrength: 0.62, // screen widths at post.distortion = 1. Push it. + wakeOpacity: 1.0, // coverage against other emitters + wakeFrequency: 0.95, // shimmer cycles per metre + wakeSpeed: 2.4, // metres/second the shimmer advects upward, in WORLD space + wakeSourceBias: 1.5, // exponent pushing the amplitude toward the base + wakeSpread: 0.85, // how much the column widens with height + wakeVertical: 0.4, // how much of the offset runs vertically + wakeFlicker: 0.35, + wakeDepthReject: 1.0, // 0..1 — how hard fragments behind the floor are dropped + wakeDepthFade: 0.45, // metres of feather on that rejection + wakePerspective: 0.0, // 0 keeps the warp the same size at any distance + wakePerspectiveRef: 14.0, // metres, the reference distance when it is not + wakeFall: 0.55, // 0..1 the wake decays over the flight — it cools behind the spear + + /* --- the wake once the spear has landed --- */ + // The same single emitter, parked over the disc. A second one is a second + // draw call, a second coverage term to argue with the first, and one more + // thing to keep in step. + discWakeWidth: 8.0, // metres across + discWakeHeight: 2.4, // metres tall + discWakeStrength: 0.45, // screen widths + discWakeSettle: 0.22, // seconds the emitter takes to swap jobs + + /* --- the fire trail --- */ + wakeSpan: 0.34, // FRACTION of the flight the trail reaches back over + wakeBurn: 0.55, // seconds the tail takes to catch the head after landing + wakeTrailWidth: 0.4, // metres at the head + wakeTrailTaper: 1.5, // >1 sharpens the tail to a point + wakeTrailLift: 0.05, // metres the tail floats above the flown path + wakeTrailOpacity: 0.95, + wakeTrailGlow: 2.2, + wakeTrailCore: 2.4, // how tightly light crowds the centre line + wakeTrailHeadBias: 0.55, // >0 keeps the brightness near the spear + wakeTrailNoise: 0.6, + wakeTrailNoiseScale: 1.4, // features per metre + wakeTrailNoiseSpeed: 1.6, + wakeTrailSoftFade: 0.5, // metres of depth feather + colorWakeA: '#ffffff', + colorWakeB: '#ffe07a', + colorWakeC: '#ff9a1f', + colorWakeD: '#4a1602', + + /* --- the sun disc --- */ + // 44 keys, prefixed `disc`, spread out of `Shell`'s own contract. Every one + // of them is a slider in "The sun disc" below. + ...shellDefaults('disc', ShellMode.SUNDISC, { + discRadius: 0.5, + discRadiusEnd: 4.6, + discExpand: 4.0, + discHeight: 0.02, + discLift: 0.035, + discFill: 0.78, + discRim: 0.5, + discRimPower: 2.0, + discDissolve: 0.45, + discGlow: 3.0, + discCoronaReach: 2.1, + discCorona: 1.6, + discCoronaLength: 0.62, + discCoronaScale: 5.5, + discCoronaWarp: 0.5, + discCoronaSpeed: 0.85, + discCoronaSharp: 0.7, + discGranule: 0.55, + discGranuleScale: 6.5, + discRimWidth: 0.2, + discSoftFade: 0.55, + discColorBody: '#ff9a1f', + discColorRim: '#ffe07a', + discColorEdge: '#ffffff', + discColorCorona: '#ffd27a' + }), + + /* --- the corona filaments licking off the rim --- */ + // Two roles on one instanced strip: a crowd of short arcs travelling round + // the boundary, and a handful of long slow licks going the other way. + flareCount: 14, // short arcs + flareRadius: 1.02, // × the disc's live radius + flareSpan: 0.13, // turns one arc covers + flareSpeed: 0.22, // turns per second they travel + flareLift: 0.55, // metres one hops over the rim at mid-span + flareJitter: 0.35, // 0..1 radial wobble + flareHug: 0.08, // metres above the floor + lickCount: 5, // long licks + lickRadius: 1.04, // × the disc's live radius + lickSpan: 0.34, // turns one covers + lickSpeed: -0.09, // turns/second — negative, so the two sets counter-rotate + lickLift: 1.5, // metres it reaches at mid-span + lickJitter: 0.6, + lickHug: 0.1, // metres above the floor + lickPhase: 0.37, // turns of constant offset, so the sets do not stack + + /* --- how the filaments are drawn --- */ + flareWidth: 0.055, // half-width of a filament, metres + flareGlowWidth: 6.5, // the halo, × that width + flareGlowOpacity: 0.42, + flareKink: 0.22, // metres of lateral kink at the coarsest octave + flareKinkScale: 1.6, // kinks per metre + flareOctaves: 3, // 1–5 + flareKinkFalloff: 0.55, // amplitude kept per octave + flareCrawl: 1.8, // how fast the kinks slide along + flarePinch: 0.2, // fraction of the path the ends are eased over + flareRestrike: 9, // whole re-shapes per second + flareFlicker: 0.18, // depth of the whole-corona stutter + flareFlickerSpeed: 22, + flareStrandFlash: 0.35, // depth of the per-filament blink + flareCoreSharp: 3.4, // how hard the core falls off across the ribbon + flareFalloff: 2.2, // the same for the halo + flareSoftFade: 0.5, // metres of depth fade + flareOpacity: 1.0, + flareGlow: 2.6, + colorFlareCore: '#ffffff', + colorFlareInner: '#ffe07a', + colorFlareOuter: '#ff9a1f', + colorFlareHalo: '#6a2202', + + /* --- embers, smoke and motes --- */ + emberRate: 300, // shed off the shaft in flight, particles/second + emberSize: 0.14, + emberSpeed: 5.0, + emberLifetime: 0.55, + emberGravity: 3.0, // white fire rises + emberStretch: 0.3, + emberBurst: 220, // extra thrown at the landing + colorEmberA: '#ffffff', + colorEmberB: '#ffffff', + colorEmberC: '#ffe07a', + colorEmberD: '#ff6a1f', + smokeRate: 40, // haze off the scorched disc + smokeSize: 1.4, + smokeSpeed: 1.3, + smokeLifetime: 2.6, + smokeOpacity: 0.09, + smokeRise: 1.0, + colorSmokeA: '#6b5238', + colorSmokeB: '#54402c', + colorSmokeC: '#3c2e20', + colorSmokeD: '#221a12', + moteRate: 70, // what drifts up out of the disc while it blooms out + moteSize: 0.07, + moteSpeed: 1.1, + moteLifetime: 2.2, + moteRise: 1.4, + moteTurbulence: 0.55, + colorMoteA: '#ffffff', + colorMoteB: '#ffe07a', + colorMoteC: '#ff9a1f', + colorMoteD: '#3a1204', + + /* --- the landing --- */ + burstSize: 3.4, // the shell of white fire at the point of impact, metres + burstIntensity: 1.7, + impactFlash: 0.62, // the hard screen flash. This one is meant to hurt + castFlash: 0.12, // a much smaller one as the javelin leaves the hand + impactShake: 1.0, + shakeDuration: 0.7, + rumble: 0.02, // continuous shake while it flies + colorBurstA: '#ff9a1f', + colorBurstB: '#ffe07a', + colorBurstC: '#ffffff', + colorFlash: '#ffe07a', + colorCastFlash: '#ffffff', + + /* --- dynamic light --- */ + lightIntensity: 34.0, + lightRadius: 22.0, + lightColor: '#ffc46a' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Sunspear. + * + * Start in **The wake**. `wakeStrength` is the whole reason this slot exists, + * and it is worth dragging it to its maximum once with the game paused and the + * spear halfway down the line, just to see what the distortion pass can + * actually do — then bring it back to somewhere you can still read the floor + * through. After that, **The sun disc** and its corona are where the time goes. + */ +export const sunspearSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 0.5, 'throw speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['discHold', 0.05, 6, 0.01, 'disc hold (s)'], + ['discFade', 0.1, 8, 0.05, 'bloom-out (s)'], + ['castAnim', 'cast animation'] + ], + 'The throw': [ + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['handHeight', 0, 3, 0.01, 'hand height'], + ['landHeight', 0, 2, 0.01, 'landing height'], + ['spearFlight', 0.1, 4, 0.05, 'nominal flight'], + ['arcApex', 0, 16, 0.05, 'loft (m)'], + ['arcApexCurve', 0.2, 4, 0.01, 'loft curve'], + ['arcCurve', 0.3, 4, 0.01, 'path easing'] + ], + 'The javelin': [ + ['spearRadius', 0.02, 0.8, 0.005, 'half-width (m)'], + ['spearStretch', 0.5, 16, 0.05, 'half-length × width'], + ['spearAlign', 0, 1, 0.01, 'align to heading'], + ['spearSpin', 0, 20, 0.05, 'roll (rad/s)'], + ['spearFlash', 0.01, 1, 0.01, 'birth pop (s)'], + ['spearSides', 3, 9, 1, 'facets'], + ['spearTaper', 0.01, 0.6, 0.005, 'point radius'], + ['spearRough', 0, 0.8, 0.005, 'facet roughness'], + ['spearBend', 0, 0.6, 0.005, 'bend'] + ], + 'The javelin/Shading': [ + ['spearCore', 0, 1, 0.01, 'white core'], + ['spearFalloff', 0.2, 8, 0.05, 'fire falloff'], + ['spearHead', 0, 1, 0.01, 'head heat starts'], + ['spearHeadGain', 0, 6, 0.01, 'head heat'], + ['spearFresnel', 0, 4, 0.01, 'edge light'], + ['spearFresnelPower', 0.2, 8, 0.05, 'edge tightness'], + ['spearNoise', 0, 2, 0.01, 'fire crawl'], + ['spearNoiseScale', 0.2, 16, 0.1, 'crawl scale'], + ['spearNoiseSpeed', 0, 10, 0.05, 'crawl Hz'], + ['spearGlow', 0, 8, 0.01, 'glow'], + ['spearOpacity', 0, 2, 0.01, 'opacity'], + ['spearSoftFade', 0.02, 2, 0.01, 'soft fade (m)'], + ['colorSpearCore', 'core'], + ['colorSpearFire', 'fire'], + ['colorSpearEdge', 'edge'] + ], + 'The wake': [ + ['wakeStrength', 0, 2, 0.005, 'warp strength'], + ['wakeWidth', 0.2, 16, 0.05, 'width (m)'], + ['wakeHeight', 0.2, 12, 0.05, 'height (m)'], + ['wakeOpacity', 0, 1, 0.01, 'coverage'], + ['wakeFrequency', 0.05, 6, 0.01, 'shimmer / metre'], + ['wakeSpeed', 0, 10, 0.05, 'advection (m/s)'], + ['wakeSourceBias', 0.1, 6, 0.05, 'base bias'], + ['wakeSpread', 0, 3, 0.01, 'column spread'], + ['wakeVertical', 0, 1, 0.01, 'vertical share'], + ['wakeFlicker', 0, 1, 0.01, 'flicker'], + ['wakeDepthReject', 0, 1, 0.01, 'depth reject'], + ['wakeDepthFade', 0.01, 2, 0.01, 'depth feather (m)'], + ['wakePerspective', 0, 1, 0.01, 'perspective'], + ['wakePerspectiveRef', 1, 40, 0.5, 'perspective ref (m)'], + ['wakeFall', 0, 1, 0.01, 'decay over flight'], + ['discWakeWidth', 0.2, 24, 0.1, 'disc width (m)'], + ['discWakeHeight', 0.2, 12, 0.05, 'disc height (m)'], + ['discWakeStrength', 0, 2, 0.005, 'disc strength'], + ['discWakeSettle', 0.02, 2, 0.01, 'settle (s)'] + ], + 'The fire trail': [ + ['wakeSpan', 0.01, 1, 0.005, 'trail span (flight)'], + ['wakeBurn', 0.05, 3, 0.01, 'burn-off (s)'], + ['wakeTrailWidth', 0.01, 2, 0.005, 'width (m)'], + ['wakeTrailTaper', 0.2, 5, 0.01, 'taper'], + ['wakeTrailLift', -1, 1, 0.005, 'lift (m)'], + ['wakeTrailOpacity', 0, 2, 0.01, 'opacity'], + ['wakeTrailGlow', 0, 6, 0.01, 'glow'], + ['wakeTrailCore', 0.2, 8, 0.01, 'core tightness'], + ['wakeTrailHeadBias', -1, 2, 0.01, 'head bias'], + ['wakeTrailNoise', 0, 2, 0.01, 'noise'], + ['wakeTrailNoiseScale', 0.1, 8, 0.05, 'noise scale'], + ['wakeTrailNoiseSpeed', 0, 6, 0.05, 'noise speed'], + ['wakeTrailSoftFade', 0.02, 2, 0.01, 'soft fade (m)'], + ['colorWake*', 'Trail colour'] + ], + ...shellSchema('disc', ShellMode.SUNDISC), + // `Shell`'s settings contract is one block across all five of its modes, so + // spreading it gives a SUNDISC fourteen keys the sun disc's program does not + // compile. Filed here rather than left to fall into the editor's "More" + // folder: they are not unfiled, they are *inert*, and a panel that says so is + // worth fourteen lines. Nothing reads them and moving them does nothing. + 'The sun disc/Inert in this mode': [ + ['discSpan', 0.1, 40, 0.05, 'span (CONE/RING_TRAIN)'], + ['discSeal', 0, 4, 0.01, 'floor seal (DOME)'], + ['discSealWidth', 0.01, 0.6, 0.01, 'seal width (DOME)'], + ['discEdge', 0, 4, 0.01, 'leading lip (CONE)'], + ['discEdgeWidth', 0.01, 0.8, 0.01, 'lip width (CONE)'], + ['discConeCurve', 0.1, 4, 0.01, 'flare curve (CONE)'], + ['discRings', 1, 48, 1, 'rings (RING_TRAIN)'], + ['discSpacing', 0.1, 12, 0.01, 'wavelength (RING_TRAIN)'], + ['discRingSpeed', 0, 40, 0.05, 'ring speed (RING_TRAIN)'], + ['discRingThickness', 0.01, 2, 0.01, 'ring thickness (RING_TRAIN)'], + ['discRingSharp', 0.05, 8, 0.01, 'ring profile (RING_TRAIN)'], + ['discReflect', 0, 1, 0.01, 'reflection (RING_TRAIN)'], + ['discStanding', 0, 1, 0.01, 'standing wave (RING_TRAIN)'], + ['discSwell', 0, 2, 0.01, 'antinode swell (RING_TRAIN)'] + ], + 'The corona': [ + ['flareCount', 0, 32, 1, 'short arcs'], + ['flareRadius', 0.5, 2, 0.01, 'radius × disc'], + ['flareSpan', 0.01, 1, 0.005, 'arc span (turns)'], + ['flareSpeed', -2, 2, 0.01, 'travel (turns/s)'], + ['flareLift', 0, 4, 0.01, 'hop height (m)'], + ['flareJitter', 0, 1, 0.01, 'radial wobble'], + ['flareHug', 0, 1, 0.005, 'floor clearance (m)'], + ['lickCount', 0, 16, 1, 'long licks'], + ['lickRadius', 0.5, 2, 0.01, 'radius × disc'], + ['lickSpan', 0.01, 1, 0.005, 'lick span (turns)'], + ['lickSpeed', -2, 2, 0.01, 'travel (turns/s)'], + ['lickLift', 0, 6, 0.01, 'reach (m)'], + ['lickJitter', 0, 1, 0.01, 'radial wobble'], + ['lickHug', 0, 1, 0.005, 'floor clearance (m)'], + ['lickPhase', 0, 1, 0.01, 'phase offset (turns)'] + ], + 'The corona/Filaments': [ + ['flareWidth', 0.005, 0.4, 0.005, 'width (m)'], + ['flareGlowWidth', 1, 20, 0.1, 'halo width'], + ['flareGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['flareKink', 0, 2, 0.01, 'kink (m)'], + ['flareKinkScale', 0.05, 6, 0.01, 'kinks / metre'], + ['flareOctaves', 1, 5, 1, 'octaves'], + ['flareKinkFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['flareCrawl', -10, 10, 0.05, 'kink crawl'], + ['flarePinch', 0.01, 0.5, 0.005, 'end pinch'], + ['flareRestrike', 0.5, 60, 0.5, 'restrikes / sec'], + ['flareFlicker', 0, 1, 0.01, 'flicker'], + ['flareFlickerSpeed', 1, 90, 1, 'flicker rate'], + ['flareStrandFlash', 0, 1, 0.01, 'filament blink'], + ['flareCoreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['flareFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['flareSoftFade', 0.02, 3, 0.01, 'soft fade (m)'], + ['flareOpacity', 0, 2, 0.01, 'opacity'], + ['flareGlow', 0, 8, 0.01, 'glow'], + ['colorFlareCore', 'core'], + ['colorFlareInner', 'inner'], + ['colorFlareOuter', 'outer'], + ['colorFlareHalo', 'halo'] + ], + 'Embers, smoke & motes': [ + ['emberRate', 0, 1200, 1, 'ember rate'], + ['emberSize', 0.005, 0.8, 0.005, 'ember size'], + ['emberSpeed', 0, 30, 0.1, 'ember speed'], + ['emberLifetime', 0.05, 4, 0.01, 'ember lifetime'], + ['emberGravity', -20, 20, 0.1, 'ember gravity'], + ['emberStretch', 0, 3, 0.01, 'ember stretch'], + ['emberBurst', 0, 800, 1, 'embers at impact'], + ['colorEmber*', 'Ember colour'], + ['smokeRate', 0, 400, 1, 'smoke rate'], + ['smokeSize', 0.05, 5, 0.05, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeRise', -2, 5, 0.01, 'smoke rise'], + ['colorSmoke*', 'Smoke colour'], + ['moteRate', 0, 500, 1, 'mote rate'], + ['moteSize', 0.005, 0.5, 0.005, 'mote size'], + ['moteSpeed', 0, 10, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 8, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorMote*', 'Mote colour'] + ], + 'The landing': [ + ['burstSize', 0.2, 14, 0.05, 'burst size (m)'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.5, 0.005, 'flight rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst core'], + ['colorFlash', 'impact flash'], + ['colorCastFlash', 'release flash'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 60, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/tectonic.js b/src/config/abilities/tectonic.js new file mode 100644 index 0000000..c8b1603 --- /dev/null +++ b/src/config/abilities/tectonic.js @@ -0,0 +1,334 @@ +/* ================================================================== */ +/* TECTONIC SLAM — stone, far cast */ +/* ================================================================== */ +/** + * The ground is hit once and then it tears: five fissures whip out of the + * point of impact to the boundary of the circle **at different speeds**, each + * with a wave of dust riding just behind its own tip, and a pressure ring goes + * with the fastest of them. + * + * Two derivations live in this block and are worth knowing before you drag + * anything: + * + * - **`speedSpread` is the whole ability.** The fastest arm runs at + * `fissureSpeed × (1 + speedSpread)` and the slowest at exactly + * `fissureSpeed`. Take the spread to zero and all five arms land together, + * which is a star, and a star is *revealed* rather than drawn. The default + * is deliberately large. + * - **there is no `ringRadiusEnd`.** The ring's end radius is `zoneRadius × + * ringReach` and its clock is the fastest arm's arrival time, so the two + * events coincide by construction rather than by tuning. It is the one + * number here that is a consequence, and the ability assigns it onto a + * proxy that inherits from this block rather than writing into it. + * + * The rest of the `ring*` family is `vfx/Shell.js` in `DOME` mode, read through + * its `ring` key prefix; the RING_TRAIN and SUNDISC halves of that vocabulary + * are not authored here because a flattened dome has no use for them, and the + * ability fills them in from `shellDefaults()` so the module's audit stays + * quiet. + */ +export const tectonic = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 46.0, // how fast the shock front runs out to the circle, metres/second + zoneRadius: 6.4, // the footprint — what the circle indicator measures out, metres + holdTime: 0.85, // seconds the open ground holds *after* the slowest arm lands + settleTime: 1.7, // seconds the cracks take to close and the rubble to sink + cooldown: 1.6, + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + handForward: 0.6, // metres in front of the caster the front leaves from + + /* --- the fissures --- */ + arms: 5, // main cracks racing outward (capped at 8) — live, re-fans the network + armJitter: 0.24, // radians of scatter on each arm's launch bearing + wander: 1.15, // how hard an arm veers, in units of its baked angular walk + fissureSpeed: 7.2, // the *slowest* arm's propagation, metres/second + speedSpread: 0.9, // the fastest arm runs this much faster again, as a fraction + fissureWidth: 0.46, // full width of an open crack, metres + openLag: 0.85, // metres behind the tip the crack reaches full width — the unzip + tipFeather: 0.26, // metres of feather on the tearing edge + fissureHeight: 0.018, // metres the ribbon sits above the floor + fissureGrain: 0.42, // 0..1 how much noise breaks up the seam's alpha + gashOpacity: 1.0, // the alpha-blended crack itself + forks: 0.72, // 0..1 of the forks kept — rank-culled, live + forkLength: 0.8, // 0..1 of a fork's baked length that is drawn + + /* --- the ember in the floor of the crack --- */ + emberHeat: 1.7, // brightness of a freshly torn crack floor + emberCool: 1.15, // seconds a point of crack takes to cool to dead basalt + emberPulse: 3.0, // radians/second the heat breathes along the crack + emberFlicker: 0.32, // 0..1 depth of the high-frequency guttering + tipFlash: 1.5, // extra white at the tearing tip itself + glowWidth: 3.6, // the underglow ribbon, × the crack's own width + glowOpacity: 0.5, // and its opacity + + /* --- colour of the ground and the fire in it --- */ + colorSeam: '#463f34', // a cold, closed crack — the floor of the gash + colorMagma: '#8a3410', // the first heat + colorEmber: '#ff7a2a', // the ember the underglow is painted in + colorHot: '#ffd9a0', // the white-hot tearing edge + colorRubble: '#8a7f6b', // the blocks of crust levered up along the lips + + /* --- the crust shouldered up along the lips --- */ + rubbleSize: 0.42, // metres, a full-size block + rubbleSpread: 0.55, // metres outside the crack edge the blocks are pushed + rubbleEmerge: 0.55, // metres of crack the tip must pass before a block is fully up + + /* --- the pressure ring (vfx/Shell.js, DOME, `ring` prefix) --- */ + ringReach: 1.0, // where the ring lands, × zoneRadius. 1 = exactly the boundary + ringFade: 0.34, // seconds it takes to die once it has landed + ringRadius: 0.7, // radius at the slam, metres + ringExpand: 1.0, // 1 is a linear front; >1 snaps out and eases in + ringHeight: 0.16, // axial extent, × radius — flat, because it is a ground front + ringLift: 0.05, // metres it hovers above the floor + ringDisplace: 0.14, // billow along the surface normal, × radius + ringNoiseScale: 2.2, // billow features per unit radius + ringNoiseSpeed: 0.5, // Hz the billow crawls at + ringTurbulence: 1.0, // master on the billow + ringFill: 0.12, // how much body the shell keeps, 0 = rim only + ringRim: 1.15, // strength of the fresnel rim + ringRimPower: 2.6, // how tight that rim is + ringSeal: 1.6, // brightness of the band where it meets the floor + ringSealWidth: 0.16, // how wide that band is, fraction of the sweep + ringDissolve: 0.7, // how hard the age dissolve bites + ringOpacity: 0.9, + ringGlow: 1.5, // emissive gain into bloom + ringSoftFade: 0.5, // metres of depth fade against the opaque scene + ringColorBody: '#6b5f4c', // the body of the front — dust, not light + ringColorRim: '#d8c49a', // its fresnel rim + ringColorEdge: '#ffe8c0', // the seal where it scrapes the floor + + /* --- the dust wave riding behind each tip --- */ + /** + * As everywhere else in the project, each system is coloured by a four-stop + * gradient sampled over the particle's own lifetime, `A` at birth through `D` + * as it dies. Spelled out rather than derived from the stone palette, so the + * dust can be made to settle browner than the rock it came out of. + */ + dustRate: 190, // particles/second across every running arm + dustSize: 1.1, + dustSpeed: 3.4, // metres/second off the lip + dustLifetime: 2.4, + dustOpacity: 0.3, + dustRise: 0.5, // upward drift, metres/second + dustTurbulence: 0.75, + dustLag: 1.1, // metres behind the tip the wave sits + dustLift: 0.42, // how much +Y is folded into the sideways throw + dustHeight: 0.16, // metres above the floor it is emitted at + colorDustA: '#a89880', + colorDustB: '#8a7f6b', + colorDustC: '#5d554a', + colorDustD: '#3a352e', + + /* --- chips off the tearing edge --- */ + gritRate: 90, // particles/second across every running arm + gritSize: 0.06, + gritSpeed: 6.5, + gritLifetime: 1.3, + gritGravity: -19.0, + colorGritA: '#6e6455', + colorGritB: '#4a4239', + colorGritC: '#39332b', + colorGritD: '#241f1a', + + /* --- embers lifting out of the open crack --- */ + emberRate: 110, // particles/second across every arm + emberSize: 0.055, + emberSpeed: 1.9, + emberLifetime: 1.5, + emberRise: 1.4, // upward drift, metres/second + emberTurbulence: 0.85, + emberGlow: 2.1, + colorSparkA: '#ffd9a0', + colorSparkB: '#ff7a2a', + colorSparkC: '#b03a08', + colorSparkD: '#2a1008', + + /* --- what the ground keeps --- */ + markRate: 7, // dust rings dropped behind the tips, per second + markRadius: 1.1, // radius of one, metres + markLife: 2.6, // seconds it lingers + markIntensity: 0.5, + starRadius: 0.34, // the crack star under the impact, × zoneRadius + starWidth: 0.5, // how finely it splits into filaments + starLife: 5.0, // seconds + starIntensity: 0.9, + slamDust: 0.8, // the dust ring the slam sits in, × zoneRadius + + /* --- the slam --- */ + muzzleSize: 0.7, // the stamp at the caster's feet, metres + muzzleIntensity: 1.2, + slamSize: 3.4, // the ball of dust punched out of the floor, metres + slamIntensity: 1.3, + slamGrit: 110, // chips thrown straight up at the slam + slamDustCount: 60, // and dust + slamShake: 0.85, // the punch — deliberately modest; the rumble does the work + slamShakeTime: 0.45, // seconds that punch decays over + slamFlash: 0.12, // screen flash on the slam + colorFlash: '#c8a878', // the colour of it + slamRumble: 0.16, // peak continuous shake as the ground comes apart + shakeRamp: 0.75, // <1 climbs early, >1 holds off until the tips are nearly out + shakeDecay: 0.7, // seconds the rumble decays over once every arm has landed + travelRumble: 0.02, // continuous shake while the front runs to the circle + + /* --- dynamic light --- */ + lightIntensity: 16, + lightRadius: 14, + lightColor: '#ff8a3c', + lightHeight: 0.35, // metres above the floor the light sits at + lightBreath: 3.4, // radians/second of the slow breath in it + lightFloor: 0.12 // how much of it survives once the ember is cold, 0..1 +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Tectonic Slam. + * + * The four controls that carry the character: `speedSpread` (whether this is + * five racing cracks or one star), `wander` (whether they are cracks or + * spokes), `emberCool` (how long the ground stays lit behind them) and + * `dustLag` (how far behind the tearing edge the dust wave sits — take it to + * zero and the tip disappears inside its own dust). + * + * Everything below re-draws a network that is already open, on a paused clock. + * Dragging `arms` mid-slam re-fans it; dragging `zoneRadius` re-scales it and + * walks the rubble out along the cracks with it. + */ +export const tectonicSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'front speed'], + ['zoneRadius', 1, 16, 0.05, 'footprint radius'], + ['holdTime', 0.05, 6, 0.01, 'hold after landing'], + ['settleTime', 0.1, 6, 0.01, 'settle time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['handForward', -1, 3, 0.01, 'front offset'], + ['castAnim', 'cast animation'] + ], + 'The fissures': [ + ['arms', 1, 8, 1, 'arms'], + ['armJitter', 0, 1.5, 0.01, 'bearing scatter'], + ['wander', 0, 4, 0.01, 'veer'], + ['fissureSpeed', 1, 40, 0.1, 'slowest arm (m/s)'], + ['speedSpread', 0, 3, 0.01, 'fastest arm, × faster'], + ['fissureWidth', 0.05, 2, 0.01, 'crack width (m)'], + ['openLag', 0.05, 5, 0.01, 'unzip length (m)'], + ['tipFeather', 0.02, 2, 0.01, 'tip feather (m)'], + ['fissureHeight', 0, 0.2, 0.001, 'height above floor (m)'], + ['fissureGrain', 0, 1, 0.01, 'seam grain'], + ['gashOpacity', 0, 1, 0.01, 'crack opacity'], + ['forks', 0, 1, 0.01, 'forks kept'], + ['forkLength', 0.05, 1, 0.01, 'fork length'] + ], + 'The ember': [ + ['emberHeat', 0, 5, 0.01, 'heat'], + ['emberCool', 0.05, 6, 0.01, 'cooling time (s)'], + ['emberPulse', 0, 12, 0.05, 'heat pulse'], + ['emberFlicker', 0, 1, 0.01, 'flicker'], + ['tipFlash', 0, 6, 0.05, 'tip flash'], + ['glowWidth', 1, 12, 0.05, 'underglow width'], + ['glowOpacity', 0, 2, 0.01, 'underglow opacity'], + ['colorSeam', 'cold seam'], + ['colorMagma', 'first heat'], + ['colorEmber', 'ember'], + ['colorHot', 'tearing edge'] + ], + 'The crust': [ + ['rubbleSize', 0.05, 2, 0.01, 'block size (m)'], + ['rubbleSpread', 0, 3, 0.01, 'push off the lip (m)'], + ['rubbleEmerge', 0.05, 3, 0.01, 'emerge over (m)'], + ['colorRubble', 'rubble'] + ], + 'The pressure ring': [ + ['ringReach', 0.2, 2, 0.01, 'lands at × footprint'], + ['ringFade', 0.02, 2, 0.01, 'death after landing (s)'], + ['ringRadius', 0.05, 8, 0.01, 'radius at the slam (m)'], + ['ringExpand', 0.2, 8, 0.01, 'expansion curve'], + ['ringHeight', 0.02, 2, 0.01, 'height × radius'], + ['ringLift', -1, 2, 0.005, 'lift (m)'], + ['ringDisplace', 0, 1.5, 0.01, 'billow'], + ['ringNoiseScale', 0.1, 10, 0.01, 'billow scale'], + ['ringNoiseSpeed', 0, 4, 0.01, 'billow Hz'], + ['ringTurbulence', 0, 3, 0.01, 'turbulence'], + ['ringFill', 0, 1, 0.01, 'body fill'], + ['ringRim', 0, 3, 0.01, 'rim'], + ['ringRimPower', 0.1, 8, 0.01, 'rim power'], + ['ringSeal', 0, 4, 0.01, 'floor seal'], + ['ringSealWidth', 0.01, 0.6, 0.01, 'seal width'], + ['ringDissolve', 0, 2, 0.01, 'dissolve'], + ['ringOpacity', 0, 1, 0.01, 'opacity'], + ['ringGlow', 0, 8, 0.01, 'glow'], + ['ringSoftFade', 0, 3, 0.01, 'soft fade (m)'], + ['ringColorBody', 'front body'], + ['ringColorRim', 'front rim'], + ['ringColorEdge', 'floor seal'] + ], + 'The dust wave': [ + ['dustRate', 0, 900, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 14, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustTurbulence', 0, 3, 0.01, 'dust turbulence'], + ['dustLag', 0, 6, 0.01, 'wave lag behind tip (m)'], + ['dustLift', 0, 3, 0.01, 'wave lift'], + ['dustHeight', 0, 2, 0.01, 'emit height (m)'], + ['colorDust*', 'Dust colour'] + ], + 'Chips & embers': [ + ['gritRate', 0, 500, 1, 'chip rate'], + ['gritSize', 0.005, 0.4, 0.005, 'chip size'], + ['gritSpeed', 0, 25, 0.1, 'chip speed'], + ['gritLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['gritGravity', -50, 0, 0.1, 'chip gravity'], + ['emberRate', 0, 600, 1, 'ember rate'], + ['emberSize', 0.005, 0.4, 0.005, 'ember size'], + ['emberSpeed', 0, 12, 0.05, 'ember speed'], + ['emberLifetime', 0.1, 8, 0.05, 'ember lifetime'], + ['emberRise', -3, 8, 0.05, 'ember rise'], + ['emberTurbulence', 0, 3, 0.01, 'ember turbulence'], + ['emberGlow', 0, 8, 0.01, 'ember glow'], + ['colorGrit*', 'Chip colour'], + ['colorSpark*', 'Ember colour'] + ], + 'Marks on the ground': [ + ['markRate', 0, 40, 0.5, 'dust rings / sec'], + ['markRadius', 0.1, 6, 0.05, 'ring radius'], + ['markLife', 0.2, 12, 0.1, 'ring lifetime'], + ['markIntensity', 0, 3, 0.01, 'ring intensity'], + ['starRadius', 0.05, 1.5, 0.01, 'impact star × footprint'], + ['starWidth', 0, 3, 0.01, 'star detail'], + ['starLife', 0.5, 20, 0.1, 'star lifetime'], + ['starIntensity', 0, 3, 0.01, 'star intensity'], + ['slamDust', 0.05, 2, 0.01, 'slam dust ring × footprint'] + ], + 'The slam': [ + ['muzzleSize', 0.05, 6, 0.05, 'foot stamp size'], + ['muzzleIntensity', 0, 5, 0.01, 'foot stamp intensity'], + ['slamSize', 0.2, 14, 0.05, 'dust ball size'], + ['slamIntensity', 0, 5, 0.01, 'dust ball intensity'], + ['slamGrit', 0, 400, 1, 'slam chips'], + ['slamDustCount', 0, 400, 1, 'slam dust'], + ['slamShake', 0, 3, 0.01, 'slam punch'], + ['slamShakeTime', 0.1, 4, 0.01, 'punch decay'], + ['slamFlash', 0, 2, 0.01, 'screen flash'], + ['colorFlash', 'flash colour'], + ['slamRumble', 0, 0.6, 0.005, 'propagation rumble'], + ['shakeRamp', 0.05, 4, 0.01, 'rumble ramp curve'], + ['shakeDecay', 0.05, 4, 0.01, 'rumble decay (s)'], + ['travelRumble', 0, 0.5, 0.005, 'travel rumble'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightHeight', 0, 4, 0.01, 'light height'], + ['lightBreath', 0, 12, 0.05, 'breath rate'], + ['lightFloor', 0, 1, 0.01, 'floor once cold'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/thornwake.js b/src/config/abilities/thornwake.js new file mode 100644 index 0000000..8fc245a --- /dev/null +++ b/src/config/abilities/thornwake.js @@ -0,0 +1,469 @@ +/* ================================================================== */ +/* THORNWAKE — verdant, line cast */ +/* ================================================================== */ +/** + * A bramble erupts along the aimed line: curved, barbed thorns punching up out + * of the floor behind a travelling front, and **vines threaded between them**. + * + * The vines are the whole ability. A field of forty separate spikes reads as + * forty props no matter how good the spike is; the moment a sagging strand runs + * from the shoulder of one thorn to the flank of its neighbour, and another + * crosses it going the other way, the same forty props read as *one thing that + * grew*. So a vine is not decoration laid over the field — it is a live query + * against it. Every frame each vine asks the `GrowthField` where two of its + * instances currently are, at the current sliders, and re-threads a catenary + * between those two points. Drag `clumping` and the thorns crowd together and + * the vines slacken with them; drag `vineReach` and the whole weave re-routes + * onto different neighbours while the clock is stopped. + * + * Nothing here is captured in metres. A cast rolls two unitless dice per vine + * (which instance to start from, how far along the field to look for a partner) + * and one timestamp per vine (the moment both its ends had broken the surface). + * Every span, sag, grip height and swing is resolved against this block inside + * the update loop. + * + * The `thorn*` group is different in kind from the rest: those eight numbers are + * baked into the *geometry*, because a barb cannot be expressed as a per-instance + * transform. `GrowthField#syncGeometry` compares them each frame and rebuilds + * the three meshes only when one of them actually moved, so they stay live + * sliders — a thorn is about 300 triangles and rebuilding three of them costs + * less than the branch that would avoid it. + */ +export const thornwake = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused + speed: 26.0, // how fast the growth front runs down the line, metres/second + lifetime: 1.5, // seconds the bramble stands after the front lands + fadeTime: 1.4, // seconds it takes to wither back into the floor + cooldown: 1.0, // seconds before it can be cast again + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- how the field is laid out --- */ + thorns: 46, // instances planted per cast (hard ceiling 96) + clusterShare: 0.2, // 0..1 of them held back for the knot at the impact point + clusterRadius: 1.6, // metres — radius of that knot + riseStagger: 0.18, // seconds of random delay between neighbours erupting + frontBias: 0.85, // <1 crowds instances toward the far end, unitless + widthNear: 0.55, // half-width of the bramble band at the caster, metres + width: 2.5, // ... and at the far end, metres + widthCurve: 1.15, // >1 stays narrow, then opens out late + clumping: 0.8, // >1 pulls instances onto the centre line, unitless + scatter: 0.6, // extra lateral jitter, fraction of the local half-width + + /* --- the silhouette of the field --- */ + heightNear: 0.55, // metres tall at the caster + height: 1.9, // metres tall at the far end + heightCurve: 1.05, // how late the height ramp climbs + heightJitter: 0.45, // ± fraction per instance + crown: 0.34, // 0..1 how much shorter the flank thorns are than the spine + crownPower: 1.5, // how sharply that dome falls off + peak: 1.3, // extra height multiplier at the far end + peakWidth: 0.3, // 0..1 of the cast that swell covers + rubble: 0.2, // 0..1 chance an instance is demoted to a low sprawling shoot + rubbleScale: 0.42, // height multiplier for those + rubbleSpread: 1.45, // radius multiplier for those + radiusNear: 0.16, // metres — base radius of a thorn at the caster + radius2: 0.24, // ... and at the far end, metres + radiusCurve: 0.7, // how the radius ramps along the cast + radiusJitter: 0.45, // ± fraction per instance + + /* --- how a thorn is oriented --- */ + lean: 0.55, // radians away from the caster / out across the band + leanJitter: 0.6, // ± fraction per instance + leanRamp: 0.55, // 0 leans everything equally, 1 only leans the far end + leanForward: 0.45, // weight of "away from the caster" in the lean direction + leanOutward: 0.95, // weight of "out across the band" + twist: 1.0, // 0..1 of a full turn of random yaw + tilt: 0.24, // radians of extra random tip, any bearing + + /* --- the eruption --- */ + riseTime: 0.24, // seconds from buried to full height + riseOvershoot: 0.32, // how far past full height the punch carries + settle: 0.45, // seconds that overshoot damps out over + springRate: 15.0, // radians/second of the overshoot ring + emergeSink: 0.9, // fraction of its height a thorn is buried at emergence 0 + birthScale: 0.68, // footprint scale at the moment it breaks through + birthFade: 0.28, // seconds the sap flash decays over + breachAt: 0.22, // emergence fraction that fires the chips and the crack + sinkDepth: 0.6, // extra metres a withering thorn drops beyond its own height + + /* --- the shape of one thorn (rebuilds the geometry when it moves) --- */ + /** + * A tapered curved spike with barbs. `thornTaper` is the exponent on + * `(1 - t)` down the shaft: 1 is a cone, 2.3 is a needle with a thick heel, + * which is what a bramble actually looks like. The barbs point *back down the + * stem* at `thornBarbTilt`, because a thorn that points forward reads as a + * fir tree. + */ + thornSides: 5, // faces around the shaft, 3..8 + thornTaper: 2.3, // exponent on the shaft's radius profile + thornCurve: 0.48, // 0..1 of a unit-height sweep the tip drifts sideways + thornBarbs: 3, // barbs per thorn, 0..5 + thornBarbLength: 0.19, // barb length, fraction of the unit height + thornBarbTilt: -0.5, // -1 points the barbs at the floor, +1 at the sky + thornBarbSpread: 0.62, // 0..1 how far apart the barbs' bearings are scattered + thornRough: 0.34, // 0..1 irregularity of the facets and ring heights + + /* --- bark --- */ + colorBark: '#4a6b2a', // the lit face of the wood + colorHeart: '#243d14', // the deep shadow inside a facet + colorMoss: '#6f8a3a', // the mottle that breaks the bark up + colorTip: '#c8b06a', // the warm bone highlight on a point + colorSap: '#9ade5a', // the flash as a thorn breaks the surface + barkGrain: 0.6, // 0..1 depth of the fibre running up the shaft + barkGrainScale: 5.5, // grain cycles per metre, world space + mossAmount: 0.42, // 0..1 how much of the bark the mottle takes + mossScale: 2.4, // mottle cycles per unit height, local space + barkRough: 0.74, // surface roughness of the standard material + tipStart: 0.6, // 0..1 up the thorn where the warm highlight begins + tipSharp: 1.8, // exponent on that ramp — higher keeps it to the very point + tipGlow: 1.5, // emissive gain on the tips + barbEdge: 0.055, // unit-space margin past the shaft that counts as a barb + barbSpan: 0.075, // unit-space feather on that test + sapGlow: 2.4, // emissive gain on the birth flash + barkGlow: 1.0, // master emissive gain on everything above + barkOpacity: 1.0, + + /* --- the vines that thread the field together --- */ + /** + * Twelve slots, four per `FilamentPaths` strip: the module holds four role + * slots and one anchor pair per role, so twelve vines is three strips and six + * draw calls. `vineReach` is the number of instance indices ahead a vine will + * look for a partner — the field plants stratified along the line, so index + * distance *is* distance down the cast, and a small reach gives you local + * tangles while a large one throws long strands across the corridor. + */ + vines: 10, // vine slots in use, 0..12 — under the ceiling on purpose, so the + // slider has room to go up as well as down + vineStrands: 2, // parallel filaments per vine, 1..4 + vineReach: 5, // how many instances ahead a vine looks for its partner + vineMaxSpan: 4.2, // metres — a pair further apart than this is rejected + vineGripLow: 0.34, // 0..1 up the first thorn the vine is tied off at + vineGripHigh: 0.8, // 0..1 up the second thorn + vineBirth: 0.35, // emergence both ends must reach before a vine threads + vineGrow: 0.3, // seconds a vine takes to draw itself in + vineSlack: 0.6, // metres of droop at mid-span when fully slack + vineCurve: 1.7, // 1 is rope, 3 is heavy chain, 0.01 is a parabola + vineSwing: 0.09, // metres of lateral sway + vineSwingSpeed: 1.5, // radians/second of that sway + vineTaut: 0.12, // 0..1 baseline tension while the field is still growing + vineCinch: 0.72, // 0..1 tension the weave pulls to once the front lands + cinchTime: 0.4, // seconds that cinch takes + vineSpread: 0.07, // metres between the parallel filaments of one vine + vineFloor: 0.04, // metres — vines are clamped above this + vineGroundDamp: 0.4, // 0..1 on the vertical part of a vine's kink + + /* --- how a vine is drawn --- */ + vineWidth: 0.022, // half-width of a vine's core ribbon, metres + vineGlowWidth: 4.4, // halo half-width, × the core + vineGlowOpacity: 0.38, // halo alpha relative to the core + vineKink: 0.3, // metres of lateral kink along a vine + vineKinkScale: 1.05, // kinks per metre + vineOctaves: 3, // 1..5 octaves of that kink + vineKinkFalloff: 0.52, // amplitude kept per octave + vineCrawl: 0.45, // how fast the kinks slide along, per second + vinePinch: 0.22, // 0..1 of the span the kink is eased out over at each end + vineRestrike: 2.0, // whole re-shapes per second — low; a vine is not lightning + vineFlicker: 0.06, // 0..1 depth of the whole-weave brightness stutter + vineFlickerSpeed: 7.0, // steps/second that stutter is quantised to + vineStrandFlash: 0.14, // 0..1 depth of the per-filament blink + vineCoreSharp: 2.4, // exponent on the core's edge falloff + vineGlowFalloff: 2.6, // the same for the halo + vineSoftFade: 0.5, // metres of depth fade where a vine meets geometry + vineOpacity: 1.0, + vineGlow: 0.85, // emissive gain fed into bloom + vineEndFade: 0.85, // 0..1 how much a vine's ends fade out + vineEndTaper: 1.0, // 0..1 how much they narrow + vineTipLength: 0.14, // 0..1 of the span the growing front is smeared over + vineTipGlow: 0.6, // extra core colour at that front + colorVineCore: '#c8e07a', // the lit centre line of a vine + colorVineInner: '#7fa83a', + colorVineOuter: '#3f6b1e', // its outside + colorVineHalo: '#14300a', // the wide dim atmosphere around the weave + + /* --- chaff: leaf and husk flicked off the growth --- */ + /** + * As in `ice` and `thunder`: each system is coloured by a four-stop gradient + * sampled over the particle's own lifetime, `A` at birth through `D` as it + * dies. Spelled out rather than derived from the bark palette, so the litter + * can be made to brown off while the thorns stay green. + */ + chaffRate: 60, // continuous shed off the standing field, particles/second + chaffBreach: 9, // extra flicked off each thorn as it breaks the surface + chaffSize: 0.13, + chaffSpeed: 3.2, // metres/second + chaffLifetime: 1.7, // seconds + chaffGravity: -3.4, // metres/second² + chaffSpin: 5.0, // radians/second of tumble + colorChaffA: '#9ade5a', + colorChaffB: '#6f9a32', + colorChaffC: '#4a6b2a', + colorChaffD: '#2a3a16', + + /* --- spore motes drifting up out of the bramble --- */ + moteRate: 45, // particles/second + moteSize: 0.05, + moteSpeed: 0.8, // metres/second + moteLifetime: 2.6, // seconds + moteRise: 0.5, // upward drift, metres/second + moteTurbulence: 0.8, + colorMoteA: '#e8ffb0', + colorMoteB: '#c8e07a', + colorMoteC: '#6f9a32', + colorMoteD: '#1e3010', + + /* --- soil thrown up where a thorn breaches --- */ + soilBreach: 7, // chips per breach + soilSize: 0.07, + soilSpeed: 3.6, // metres/second + soilLifetime: 1.1, // seconds + soilGravity: -14.0, // metres/second² + colorSoilA: '#4a3a26', + colorSoilB: '#33281a', + colorSoilC: '#241c12', + colorSoilD: '#1a140d', + + /* --- what the floor does --- */ + crackChance: 0.45, // 0..1 chance a breach also splits the floor + crackRadius: 0.7, // metres + crackLife: 5.0, // seconds it lingers + crackWidth: 0.1, // thickness of the split + crackIntensity: 0.7, + colorCrackA: '#1c2411', // the dark of the split + colorCrackB: '#5f8a2a', // the growth glowing out of it + dustRadius: 0.85, // metres — the puff of soil around a breach + dustLife: 1.6, // seconds + dustIntensity: 0.55, + colorDustA: '#3a3020', + colorDustB: '#6f7a4a', + + /* --- the impact --- */ + burstSize: 2.6, // the shell of thrown growth at the impact point, metres + burstIntensity: 1.1, + burstChaff: 90, // extra chaff thrown at the impact + burstSoil: 40, // extra soil thrown at the impact + impactShake: 0.55, + shakeDuration: 0.5, // seconds + impactFlash: 0.1, // screen flash on landing + rumble: 0.035, // continuous shake while the front travels + colorBurstA: '#4a6b2a', // shell body + colorBurstB: '#7fa83a', + colorBurstC: '#c8e07a', // the filaments and the fresnel rim — this carries it + colorFlash: '#9ade5a', // the full-screen flash on impact + + /* --- dynamic light --- */ + lightIntensity: 11.0, + lightRadius: 9.5, // metres + lightColor: '#8ac04a' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Thornwake. + * + * The four controls that carry the character, in the order worth reaching for: + * `vineReach` (how local the weave is — the difference between a tangle and a + * cat's cradle), `clumping` (how tightly the thorns crowd, which the vines then + * follow), `vineSlack` against `vineCinch` (whether the weave hangs or snaps + * taut), and `thornCurve` (whether the field is a bed of nails or a bramble). + * + * Everything in *The thorn's shape* rebuilds three instanced meshes when it + * moves. That is deliberate and it is cheap; see the block's header. + */ +export const thornwakeSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 120, 0.5, 'growth speed'], + ['lifetime', 0.1, 8, 0.05, 'standing time'], + ['fadeTime', 0.1, 6, 0.05, 'wither time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The bramble bed': [ + ['thorns', 1, 96, 1, 'thorns'], + ['clusterShare', 0, 0.6, 0.01, 'knot share'], + ['clusterRadius', 0.1, 6, 0.05, 'knot radius'], + ['riseStagger', 0, 1.5, 0.01, 'eruption stagger'], + ['frontBias', 0.2, 3, 0.01, 'front crowding'], + ['widthNear', 0.05, 4, 0.05, 'width at caster'], + ['width', 0.1, 8, 0.05, 'width at target'], + ['widthCurve', 0.2, 4, 0.01, 'width curve'], + ['clumping', 0.2, 4, 0.01, 'clumping'], + ['scatter', 0, 2, 0.01, 'lateral scatter'] + ], + 'The silhouette': [ + ['heightNear', 0.05, 4, 0.05, 'height at caster'], + ['height', 0.1, 6, 0.05, 'height at target'], + ['heightCurve', 0.2, 4, 0.01, 'height curve'], + ['heightJitter', 0, 1.5, 0.01, 'height jitter'], + ['crown', 0, 1, 0.01, 'flank shortening'], + ['crownPower', 0.2, 4, 0.01, 'dome falloff'], + ['peak', 0.5, 3, 0.01, 'swell at target'], + ['peakWidth', 0.02, 1, 0.01, 'swell width'], + ['rubble', 0, 1, 0.01, 'sprawling shoots'], + ['rubbleScale', 0.05, 1, 0.01, 'shoot height'], + ['rubbleSpread', 0.5, 3, 0.01, 'shoot spread'], + ['radiusNear', 0.02, 0.8, 0.005, 'thorn radius, near'], + ['radius2', 0.02, 0.8, 0.005, 'thorn radius, far'], + ['radiusCurve', 0.1, 3, 0.01, 'radius curve'], + ['radiusJitter', 0, 1.5, 0.01, 'radius jitter'] + ], + 'How they lean': [ + ['lean', 0, 1.5, 0.01, 'lean'], + ['leanJitter', 0, 1.5, 0.01, 'lean jitter'], + ['leanRamp', 0, 1, 0.01, 'lean ramp'], + ['leanForward', -1, 2, 0.01, 'lean downrange'], + ['leanOutward', -1, 2, 0.01, 'lean outward'], + ['twist', 0, 1, 0.01, 'random yaw'], + ['tilt', 0, 1, 0.01, 'random tip'] + ], + 'The eruption': [ + ['riseTime', 0.02, 1.5, 0.01, 'rise time'], + ['riseOvershoot', 0, 1.5, 0.01, 'overshoot'], + ['settle', 0.05, 2, 0.01, 'settle time'], + ['springRate', 2, 40, 0.5, 'spring rate'], + ['emergeSink', 0, 1.5, 0.01, 'buried depth'], + ['birthScale', 0.1, 1, 0.01, 'birth scale'], + ['birthFade', 0.02, 1.5, 0.01, 'sap flash time'], + ['breachAt', 0.02, 1, 0.01, 'breach point'], + ['sinkDepth', 0, 3, 0.05, 'wither sink'] + ], + "The thorn's shape": [ + ['thornSides', 3, 8, 1, 'faces'], + ['thornTaper', 0.6, 5, 0.05, 'taper'], + ['thornCurve', 0, 1.2, 0.01, 'sweep'], + ['thornBarbs', 0, 5, 1, 'barbs'], + ['thornBarbLength', 0.02, 0.5, 0.005, 'barb length'], + ['thornBarbTilt', -1, 1, 0.01, 'barb tilt'], + ['thornBarbSpread', 0, 1, 0.01, 'barb scatter'], + ['thornRough', 0, 1, 0.01, 'roughness'] + ], + Bark: [ + ['colorBark', 'bark'], + ['colorHeart', 'heartwood'], + ['colorMoss', 'mottle'], + ['colorTip', 'thorn tip'], + ['colorSap', 'sap flash'], + ['barkGrain', 0, 1.5, 0.01, 'grain depth'], + ['barkGrainScale', 0.5, 20, 0.1, 'grain / metre'], + ['mossAmount', 0, 1, 0.01, 'mottle amount'], + ['mossScale', 0.2, 10, 0.05, 'mottle scale'], + ['barkRough', 0.05, 1, 0.01, 'surface roughness'], + ['tipStart', 0.1, 1, 0.01, 'highlight start'], + ['tipSharp', 0.2, 6, 0.05, 'highlight sharpness'], + ['tipGlow', 0, 6, 0.05, 'tip glow'], + ['barbEdge', 0, 0.3, 0.005, 'barb edge margin'], + ['barbSpan', 0.005, 0.3, 0.005, 'barb edge feather'], + ['sapGlow', 0, 8, 0.05, 'sap glow'], + ['barkGlow', 0, 4, 0.01, 'bark glow'], + ['barkOpacity', 0.1, 1, 0.01, 'bark opacity'] + ], + 'The weave': [ + ['vines', 0, 12, 1, 'vines'], + ['vineStrands', 1, 4, 1, 'strands / vine'], + ['vineReach', 1, 12, 1, 'neighbour reach'], + ['vineMaxSpan', 0.5, 14, 0.1, 'max span'], + ['vineGripLow', 0, 1.2, 0.01, 'grip, low end'], + ['vineGripHigh', 0, 1.2, 0.01, 'grip, high end'], + ['vineBirth', 0, 1, 0.01, 'thread threshold'], + ['vineGrow', 0.02, 2, 0.01, 'draw-in time'], + ['vineSlack', 0, 4, 0.01, 'slack droop'], + ['vineCurve', 0.01, 5, 0.01, 'catenary curve'], + ['vineSwing', 0, 1, 0.005, 'sway'], + ['vineSwingSpeed', 0, 8, 0.05, 'sway speed'], + ['vineTaut', 0, 1, 0.01, 'tension, growing'], + ['vineCinch', 0, 1, 0.01, 'tension, cinched'], + ['cinchTime', 0.02, 3, 0.01, 'cinch time'], + ['vineSpread', 0, 0.6, 0.005, 'strand spacing'], + ['vineFloor', -1, 1, 0.01, 'floor clamp'], + ['vineGroundDamp', 0, 1, 0.01, 'kink ground damp'] + ], + 'The weave/Drawing': [ + ['vineWidth', 0.002, 0.2, 0.001, 'width'], + ['vineGlowWidth', 1, 20, 0.1, 'halo width'], + ['vineGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['vineKink', 0, 2, 0.01, 'kink amplitude'], + ['vineKinkScale', 0.05, 6, 0.01, 'kinks / metre'], + ['vineOctaves', 1, 5, 1, 'octaves'], + ['vineKinkFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['vineCrawl', -8, 8, 0.05, 'kink crawl'], + ['vinePinch', 0.01, 0.5, 0.005, 'end pinch'], + ['vineRestrike', 0.2, 30, 0.1, 'reshapes / sec'], + ['vineFlicker', 0, 1, 0.01, 'brightness stutter'], + ['vineFlickerSpeed', 1, 60, 0.5, 'stutter rate'], + ['vineStrandFlash', 0, 1, 0.01, 'strand blink'], + ['vineCoreSharp', 0.5, 10, 0.05, 'core sharpness'], + ['vineGlowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['vineSoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['vineOpacity', 0, 2, 0.01, 'opacity'], + ['vineGlow', 0, 5, 0.01, 'glow'], + ['vineEndFade', 0, 1, 0.01, 'end fade'], + ['vineEndTaper', 0, 1, 0.01, 'end taper'], + ['vineTipLength', 0.005, 0.5, 0.005, 'growing front length'], + ['vineTipGlow', 0, 4, 0.05, 'growing front glow'], + ['colorVineCore', 'vine core'], + ['colorVineInner', 'vine inner'], + ['colorVineOuter', 'vine outer'], + ['colorVineHalo', 'vine halo'] + ], + 'Chaff & spores': [ + ['chaffRate', 0, 400, 1, 'chaff rate'], + ['chaffBreach', 0, 60, 1, 'chaff / breach'], + ['chaffSize', 0.005, 0.6, 0.005, 'chaff size'], + ['chaffSpeed', 0, 15, 0.05, 'chaff speed'], + ['chaffLifetime', 0.1, 6, 0.05, 'chaff lifetime'], + ['chaffGravity', -20, 2, 0.1, 'chaff gravity'], + ['chaffSpin', 0, 20, 0.1, 'chaff tumble'], + ['moteRate', 0, 300, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 8, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -2, 5, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorChaff*', 'Chaff colour'], + ['colorMote*', 'Mote colour'] + ], + 'Soil & floor': [ + ['soilBreach', 0, 40, 1, 'soil / breach'], + ['soilSize', 0.005, 0.4, 0.005, 'soil size'], + ['soilSpeed', 0, 15, 0.05, 'soil speed'], + ['soilLifetime', 0.1, 4, 0.05, 'soil lifetime'], + ['soilGravity', -40, 0, 0.1, 'soil gravity'], + ['crackChance', 0, 1, 0.01, 'crack chance'], + ['crackRadius', 0.05, 4, 0.05, 'crack radius'], + ['crackLife', 0.2, 15, 0.1, 'crack lifetime'], + ['crackWidth', 0.01, 0.6, 0.005, 'crack width'], + ['crackIntensity', 0, 3, 0.01, 'crack intensity'], + ['dustRadius', 0.05, 4, 0.05, 'dust radius'], + ['dustLife', 0.1, 6, 0.05, 'dust lifetime'], + ['dustIntensity', 0, 3, 0.01, 'dust intensity'], + ['colorCrackA', 'crack dark'], + ['colorCrackB', 'crack growth'], + ['colorDustA', 'dust body'], + ['colorDustB', 'dust rim'], + ['colorSoil*', 'Soil colour'] + ], + Impact: [ + ['burstSize', 0.2, 12, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstChaff', 0, 400, 1, 'burst chaff'], + ['burstSoil', 0, 300, 1, 'burst soil'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst filaments'], + ['colorFlash', 'impact flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/thunder.js b/src/config/abilities/thunder.js new file mode 100644 index 0000000..c5ee2ff --- /dev/null +++ b/src/config/abilities/thunder.js @@ -0,0 +1,328 @@ +/* ================================================================== */ +/* THUNDER — ability two */ +/* ================================================================== */ +/** + * A bolt thrown from the caster's hand along the aimed line: a bundle of + * lightning filaments that snap into existence, hold while they gutter, and + * blow out. Reference for the look: `thundercast.jpg`. + * + * The bolt is **one mesh**. Every filament is an instance of the same ribbon + * strip, and its entire shape — the sag of the axis, the fan of the bundle, + * the kinks in an individual strand, the camera-facing width — is evaluated in + * the vertex shader from the numbers below. Nothing about the path exists on + * the CPU, which is why `strands`, `jitter` and `spread` reshape a bolt that + * is already in the air, and do it with the clock paused. + * + * The one thing a cast *does* capture is `uSeed`, a single random number + * rolled at spawn so two casts do not draw the identical bolt. That is an + * event, not a dimension — the same rule `IceAbility` follows. + */ +export const thunder = { + /* --- the cast --- */ + range: 24.0, // maximum cast distance, metres + minRange: 2.0, // closer than this and the cast is refused + speed: 105.0, // how fast the strike front travels, metres/second + lifetime: 0.45, // seconds the bolt holds after it lands + fadeTime: 0.5, // seconds it takes to blow out + cooldown: 0.5, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the bolt leaves the caster --- */ + // The beam starts at the hand, not at the feet, so these are measured from + // the caster's origin in the cast's own frame. + handHeight: 1.28, // metres above the floor + handForward: 0.55, // metres in front of the caster + handSide: 0.16, // metres to the side (+ follows `Ability#side`) + endHeight: 0.35, // height of the bolt where it lands, metres + sag: 0.22, // metres the mid-span bows upward (negative droops) + + /* --- the bundle of filaments --- */ + strands: 9, // separate filaments (capped at 24) + spread: 0.75, // metres the bundle fans out at the far end + spreadNear: 0.05, // ... and at the hand + spreadCurve: 1.6, // >1 keeps the bundle tight then opens it late + twist: 0.45, // turns the bundle makes around the axis over its length + twistSpeed: 0.8, // turns/second it rolls on top of that + branchDim: 0.72, // how much dimmer an outer filament is than the spine + + /* --- the shape of one filament --- */ + jitter: 0.34, // metres of kink at the coarsest octave + jitterScale: 0.85, // kinks per metre + octaves: 4, // 1–5; each one halves the amplitude and doubles the rate + jitterFalloff: 0.55, // amplitude kept per octave + crawl: 3.2, // how fast the kinks slide along the bolt + pinch: 0.14, // fraction of the span the ends are pulled straight over + converge: 0.8, // how hard the far end is pulled onto the target, 0..1 + + /* --- the ribbon --- */ + width: 0.025, // half-width of a filament at the hand, metres + widthTip: 0.43, // that width at the impact point, as a fraction + widthCurve: 1.09, // how early the taper happens + coreWidth: 1.31, // multiplier on the central spine + coreSharp: 4.95, // how hard the hot core falls off across the ribbon + glowWidth: 5.7, // the halo, × the core width + glowFalloff: 2.4, // how fast the halo fades across its ribbon + glowOpacity: 0.49, + softFade: 0.78, // metres of soft fade where the bolt meets geometry + + /* --- flicker & restrike --- */ + restrike: 24, // times/second the filaments re-roll their shape + flicker: 0.3, // depth of the whole-bolt brightness stutter + flickerSpeed: 34, // stutters/second + strandFlash: 0.5, // how much individual filaments blink out + tipGlow: 2.0, // extra heat on the leading edge while it travels + tipLength: 0.08, // length of that leading edge, fraction of the span + + /* --- colour --- */ + colorCore: '#ffffff', // the centre of a filament + colorInner: '#c9ecff', + colorOuter: '#3aa0ff', // the outside of a filament + colorHalo: '#0b3fc8', // the wide glow around the bundle + glow: 2.3, // overall emissive gain + opacity: 1.0, + + /* --- what the ground does --- */ + arcRate: 0.9, // electric burns laid per metre of front travel + arcRadius: 1.5, // radius of one burn, metres + arcLife: 0.6, // seconds a burn lingers + arcIntensity: 1.0, + arcBranches: 0.6, // how finely the burn splits into filaments + scorchRadius: 0.5, // dark burn mark under the bolt, metres + scorchLife: 6.5, + scorchIntensity: 0.45, + colorArc: '#9fdcff', + colorScorch: '#080b11', + colorEmber: '#4aa8ff', + shockRadius: 6.5, // impact shockwave ring, metres + colorShockA: '#c9ecff', // body of the shockwave ring + colorShockB: '#ffffff', // its crest + + /* --- sparks, motes, smoke and debris --- */ + /** + * As in `ice`: each system is coloured by a four-stop gradient sampled over + * the particle's own lifetime, `A` at birth through `D` as it dies. Spelled + * out rather than derived from the bolt palette, so the sparks can be made + * to cool to orange while the filaments stay blue. + */ + sparkRate: 240, // sparks thrown off the bolt, particles/second + sparkSize: 0.16, + sparkSpeed: 9.0, + sparkLifetime: 0.5, + sparkGravity: -12.0, + sparkStretch: 0.18, // how far a spark smears along its velocity + colorSparkA: '#ffffff', + colorSparkB: '#ffffff', + colorSparkC: '#c9ecff', + colorSparkD: '#1e5b95', + moteRate: 90, // the slow ionised motes drifting off the bolt + moteSize: 0.05, + moteSpeed: 1.5, + moteLifetime: 1.6, + moteRise: 1.0, // upward drift, metres/second + moteTurbulence: 0.7, + colorMoteA: '#ffffff', + colorMoteB: '#c9ecff', + colorMoteC: '#3aa0ff', + colorMoteD: '#02195f', + smokeRate: 50, // thin haze off the scorched floor + smokeSize: 1.0, + smokeSpeed: 1.1, + smokeLifetime: 2.2, + smokeOpacity: 0.06, + smokeRise: 0.55, + colorSmokeA: '#3d546e', + colorSmokeB: '#33475e', + colorSmokeC: '#33475e', + colorSmokeD: '#1c2938', + debrisRate: 24, // chips kicked off the floor under the bolt + debrisSize: 0.055, + debrisSpeed: 5.0, + debrisLifetime: 1.3, + debrisGravity: -17.0, + colorDebrisA: '#252c36', + colorDebrisB: '#1c222a', + colorDebrisC: '#1c222a', + colorDebrisD: '#1c222a', + + /* --- dynamic light --- */ + lightIntensity: 26, + lightRadius: 17, + lightColor: '#63b8ff', + lightFlicker: 0.4, // depth of the light's gutter, 0 = steady + lightFlickerSpeed: 26, + + /* --- the muzzle and the impact --- */ + // Both shells are the same shader: A→B is mixed across the billowing noise + // and stays nearly empty, and C is what the racing filaments and the fresnel + // rim are drawn in — so C is the one carrying the read. + muzzleSize: 0.55, // the flash at the hand, metres + muzzleIntensity: 1.9, + castFlash: 0.1, // screen flash on release + colorMuzzleA: '#3aa0ff', + colorMuzzleB: '#c9ecff', + colorMuzzleC: '#ffffff', + colorCastFlash: '#c9ecff', + burstSize: 3.0, // the shell at the impact point, metres + burstIntensity: 1.4, + burstSparks: 170, // extra sparks thrown at the impact + burstDebris: 45, + impactShake: 0.8, + shakeDuration: 0.55, + impactFlash: 0.28, + rumble: 0.03, // continuous shake while the front travels + colorBurstA: '#3aa0ff', + colorBurstB: '#c9ecff', + colorBurstC: '#ffffff', + colorFlash: '#c9ecff' // the full-screen flash on impact +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Storm Lance. + * + * Every control here is read by the vertex shader on the frame it changes, so + * the whole folder reshapes a bolt that is already in the air. The ones worth + * reaching for first are `jitter` and `jitterScale` (how violently it kinks), + * `strands` and `spread` (how wide the bundle reads) and `restrike` (how hard + * it strobes) — those four carry the character of the effect. + */ +export const thunderSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 400, 1, 'strike speed'], + ['lifetime', 0.05, 6, 0.01, 'bolt lifetime'], + ['fadeTime', 0.05, 4, 0.01, 'blow-out time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where it leaves the hand': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['endHeight', 0, 4, 0.01, 'height at target'], + ['sag', -3, 3, 0.01, 'mid-span bow'] + ], + 'The bundle': [ + ['strands', 1, 24, 1, 'filaments'], + ['spread', 0, 5, 0.01, 'fan at target'], + ['spreadNear', 0, 2, 0.01, 'fan at hand'], + ['spreadCurve', 0.2, 5, 0.01, 'fan curve'], + ['twist', -4, 4, 0.01, 'twist over length'], + ['twistSpeed', -6, 6, 0.01, 'twist speed'], + ['branchDim', 0, 1, 0.01, 'outer filament dim'] + ], + 'The filament': [ + ['jitter', 0, 3, 0.01, 'kink amplitude'], + ['jitterScale', 0.05, 6, 0.01, 'kinks / metre'], + ['octaves', 1, 5, 1, 'octaves'], + ['jitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['crawl', -20, 20, 0.1, 'kink crawl'], + ['pinch', 0.01, 0.5, 0.005, 'end pinch'], + ['converge', 0, 1, 0.01, 'lock onto target'] + ], + 'The ribbon': [ + ['width', 0.005, 0.6, 0.005, 'width at hand'], + ['widthTip', 0.02, 3, 0.01, 'width at target'], + ['widthCurve', 0.1, 4, 0.01, 'taper curve'], + ['coreWidth', 1, 6, 0.01, 'spine thickness'], + ['coreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['glowWidth', 1, 30, 0.1, 'halo width'], + ['glowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['glowOpacity', 0, 2, 0.01, 'halo opacity'], + ['softFade', 0.02, 3, 0.01, 'soft intersection'] + ], + 'Flicker & restrike': [ + ['restrike', 0.5, 90, 0.5, 'restrikes / sec'], + ['flicker', 0, 1, 0.01, 'brightness stutter'], + ['flickerSpeed', 1, 120, 1, 'stutter rate'], + ['strandFlash', 0, 1, 0.01, 'filament blink'], + ['tipGlow', 0, 8, 0.05, 'leading-edge glow'], + ['tipLength', 0.005, 0.5, 0.005, 'leading-edge length'] + ], + 'Bolt colour': [ + ['colorCore', 'core'], + ['colorInner', 'inner'], + ['colorOuter', 'outer'], + ['colorHalo', 'halo'], + ['glow', 0, 8, 0.01, 'glow'], + ['opacity', 0, 2, 0.01, 'opacity'] + ], + 'Burns on the ground': [ + ['arcRate', 0.05, 8, 0.05, 'burns / metre'], + ['arcRadius', 0.1, 8, 0.05, 'burn radius'], + ['arcLife', 0.05, 5, 0.05, 'burn lifetime'], + ['arcIntensity', 0, 3, 0.01, 'burn intensity'], + ['arcBranches', 0, 3, 0.01, 'branch detail'], + ['scorchRadius', 0.05, 4, 0.05, 'scorch radius'], + ['scorchLife', 0.5, 20, 0.1, 'scorch lifetime'], + ['scorchIntensity', 0, 2, 0.01, 'scorch intensity'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['colorArc', 'burn'], + ['colorEmber', 'ember'], + ['colorScorch', 'scorch'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'Sparks & motes': [ + ['sparkRate', 0, 1200, 1, 'spark rate'], + ['sparkSize', 0.005, 0.8, 0.005, 'spark size'], + ['sparkSpeed', 0, 40, 0.1, 'spark speed'], + ['sparkLifetime', 0.05, 4, 0.01, 'spark lifetime'], + ['sparkGravity', -50, 5, 0.1, 'spark gravity'], + ['sparkStretch', 0, 3, 0.01, 'spark stretch'], + ['moteRate', 0, 600, 1, 'mote rate'], + ['moteSize', 0.005, 0.4, 0.005, 'mote size'], + ['moteSpeed', 0, 12, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 8, 0.05, 'mote lifetime'], + ['moteRise', -3, 8, 0.05, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorSpark*', 'Spark colour'], + ['colorMote*', 'Mote colour'] + ], + 'Smoke & debris': [ + ['smokeRate', 0, 500, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 8, 0.05, 'smoke lifetime'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['debrisRate', 0, 300, 1, 'debris rate'], + ['debrisSize', 0.005, 0.4, 0.005, 'debris size'], + ['debrisSpeed', 0, 25, 0.1, 'debris speed'], + ['debrisLifetime', 0.1, 5, 0.05, 'debris lifetime'], + ['debrisGravity', -50, 0, 0.1, 'debris gravity'], + ['colorSmoke*', 'Smoke colour'], + ['colorDebris*', 'Debris colour'] + ], + 'Muzzle & impact': [ + ['muzzleSize', 0.05, 6, 0.05, 'muzzle size'], + ['muzzleIntensity', 0, 5, 0.01, 'muzzle intensity'], + ['castFlash', 0, 2, 0.01, 'flash on release'], + ['colorMuzzleA', 'muzzle shell'], + ['colorMuzzleB', 'muzzle body'], + ['colorMuzzleC', 'muzzle arcs'], + ['colorCastFlash', 'release flash colour'], + ['burstSize', 0.2, 14, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstSparks', 0, 600, 1, 'burst sparks'], + ['burstDebris', 0, 300, 1, 'burst debris'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst arcs'], + ['colorFlash', 'impact flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightFlicker', 0, 1, 0.01, 'light gutter'], + ['lightFlickerSpeed', 1, 90, 1, 'gutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/thunderclap.js b/src/config/abilities/thunderclap.js new file mode 100644 index 0000000..8ee00a8 --- /dev/null +++ b/src/config/abilities/thunderclap.js @@ -0,0 +1,317 @@ +/* ================================================================== */ +/* THUNDERCLAP — the ability that is mostly silence */ +/* ================================================================== */ +/** + * A far cast whose entire read is a **gap**. + * + * Three beats, and the middle one is empty on purpose: + * + * 1. `clapTime` — a hard white flash and a dome that is fully out before you + * have finished blinking. No particles, no debris, no shake. + * 2. `gapTime` — **nothing**. The dome is gone, the light is off, no emitter + * is running. This is the most interesting number in the block and the one + * the whole slot is built to show off. + * 3. `frontTime` — the pressure front arrives: `frontRings` concentric + * refraction rings crossing to `zoneRadius`, and *this* is where the camera + * shake, the dust and the grit live. + * + * The physics is the joke and the joke is the design: light is fast and sound + * is not, so what you saw and what hit you are separated by a quarter of a + * second. Put the shake on beat 1 and this becomes another shockwave slot. + * + * **Where the metres come from.** `zoneRadius` is the circle the aim indicator + * draws before the click, and it is exactly where the pressure front stops — + * one number driving the promise and the payoff, the way `snare.zoneRadius` + * does. The dome does *not* read it: a clap's dome is a metre and a half and + * its front is six, and deriving one from the other would make the indicator + * lie about the first beat. So the dome carries its own `domeRadius` / + * `domeRadiusEnd` sliders, which is `Shell`'s own contract. + * + * The rings themselves are `vfx/Distortion.js` in `SHOCK` mode, twice: one + * emitter billboarded at the camera for the air, one lying flat on the floor + * for the stone. Their magnitudes are **screen fractions**, never metres, and + * neither of them multiplies `global.distortion` into itself — the post pass + * applies that once, for everybody. + * + * A cast captures one seed and a handful of timestamps. Every metre, radian + * and second below is resolved inside the update loop, including on a + * zero-length frame: pause mid-gap, drag `gapTime` down, and the front you + * were waiting for arrives with the clock stopped. + */ + +import { ShellMode, shellDefaults, shellSchema } from '../../vfx/Shell.js'; + +export const thunderclap = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 2.0, // closer than this and the cast is refused + zoneRadius: 6.0, // the circle — and exactly where the pressure front stops, metres + speed: 150.0, // how fast the knot of compressed air reaches the point, metres/second + cooldown: 1.2, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the three beats (seconds; all scaled by global.lifetime) --- */ + clapTime: 0.15, // BEAT 1 — the flash and the dome, start to finish + gapTime: 0.26, // BEAT 2 — THE GAP. Nothing is drawn, nothing is emitted, the + // light is off. This is the ability. 0 collapses it into an + // ordinary shockwave; 0.5 is a clap a long way off. + frontTime: 0.62, // BEAT 3 — seconds the front takes to cross `zoneRadius` + fadeTime: 1.1, // seconds of settling dust after the front has passed + + /* --- beat 1: the knot and the dome ------------------------------- */ + // The clap travels to the point as a small knot of compressed air — the same + // Shell held at its start radius. It is on screen for about a tenth of a + // second and mostly reads as a smear, which is the intent: the clap arrives + // from somewhere rather than being switched on. + knotFade: 0.32, // 0..1 how visible the travelling knot is + clapFlash: 0.8, // full-screen flash on beat 1, 0..2 + colorClapFlash: '#ffffff', // ... and its colour + clapLight: 44, // additive punch on the dynamic light at beat 1 + clapShake: 0.06, // camera shake on beat 1. Deliberately almost nothing — + // the punch belongs to beat 3 and putting it here undoes + // the whole effect. + + /* --- beat 2: the gap --------------------------------------------- */ + gapGlow: 0.03, // 0..1 of the light that survives the gap. Authored at + // nearly zero on purpose. Push it to 0.4 and you get a + // rumble of heat lightning through the pause, which is a + // different effect and a worse one. + + /* --- beat 3: the pressure front ---------------------------------- */ + frontExpand: 2.4, // easing exponent on the wavefront: 1 − (1−t)^n. >1 = it + // arrives fast and then eases out to `zoneRadius` + frontDecay: 1.5, // how fast the front loses amplitude as it spreads + frontRings: 3, // concentric fronts, 1..4 (the shader's hard ceiling) + frontRingGap: 1.15, // metres between one front and the next + frontRingDecay: 0.7, // how much weaker each trailing front is + frontRumble: 0.09, // sustained camera shake while the front is crossing + frontDepthReject: 1.0, // 0..1 how hard the emitters refuse fragments behind geometry + frontDepthFade: 0.5, // metres of feather on that rejection + + /* --- the front in the air (billboard emitter) --- */ + // Screen fractions, not metres. Never multiply global.distortion in here. + airStrength: 0.55, // peak offset, in screen widths at post.distortion = 1 + airThickness: 0.6, // metres — how thick one wavefront is + airWindow: 0.72, // 0..1 of the falloff radius where the mask starts dying + airCompression: 1.2, // gain on the inside of the front + airRarefaction: 0.85, // ... and on the outside + airSpan: 2.3, // the quad's width, × zoneRadius (it must cover the ring) + airHeight: 1.5, // the quad's height, × zoneRadius + airMaxOffset: 1.25, // hard ceiling on the offset + airPerspective: 0.4, // 0..1 how much the warp shrinks with distance + airPerspectiveRef: 15.0, // metres at which `airPerspective` is 1:1 + + /* --- the same front in the floor (ground emitter) --- */ + floorStrength: 0.4, + floorThickness: 0.8, // metres + floorWindow: 0.8, // 0..1 of the falloff radius + floorCompression: 1.0, + floorRarefaction: 0.7, + floorSpan: 2.2, // the quad's extent, × zoneRadius + floorHeight: 0.05, // metres above the floor the emitter lies at + floorMaxOffset: 1.0, + + /* --- what beat 3 does to the world --- */ + boomShake: 1.35, // the punch, on THIS beat and not the first one + boomShakeTime: 0.75, // seconds it decays over + boomFlash: 0.1, // a small secondary flash. Keep it well under `clapFlash` + colorBoomFlash: '#cfe4ff', + boomLight: 12, // additive punch on the dynamic light at beat 3 + + /* --- the ring the front leaves on the floor --- */ + shockLife: 0.7, // seconds + shockWidth: 0.06, // thickness of the ring, fraction of its radius + shockIntensity: 1.2, + colorShockA: '#3f6fd0', // body of the ring + colorShockB: '#ffffff', // its crest + + /* --- dust rings laid along the front's own travel --- */ + ringRate: 0.7, // puffs per metre of wavefront travel + ringRadius: 1.4, // radius of one puff, metres + ringLife: 1.4, // seconds it lingers + ringIntensity: 0.5, + colorRingA: '#5b6b7e', // the puff + colorRingB: '#cfe4ff', // its lit edge + + /* --- the dust the front shoves ----------------------------------- */ + /** + * As everywhere else, each system is coloured by a four-stop gradient sampled + * over the particle's own lifetime, `A` at birth through `D` as it dies. + * Spelled out rather than derived from the ring palette, so the dust can be + * made to go warm while the refraction stays cold. + */ + dustRate: 300, // particles/second while the front is crossing + dustSize: 1.1, + dustSpeed: 5.5, // metres/second, thrown outward on the wavefront + dustLifetime: 2.1, + dustRise: 0.5, // upward drift, metres/second + dustSpread: 0.55, // 0..1 cone width off the outward normal + dustOpacity: 0.09, + boomDust: 70, // one-shot puff on the frame the front launches + colorDustA: '#7e8896', + colorDustB: '#5b6470', + colorDustC: '#3d4652', + colorDustD: '#232a33', + + /* --- and the grit it kicks up --- */ + gritRate: 110, // chips/second while the front is crossing + gritSize: 0.06, + gritSpeed: 7.5, + gritLifetime: 1.2, + gritGravity: -19.0, // metres/second² + boomGrit: 80, // one-shot on the frame the front launches + colorGritA: '#2b323c', + colorGritB: '#232932', + colorGritC: '#1b2028', + colorGritD: '#151a21', + + /* --- dynamic light --- */ + lightIntensity: 5, // the standing level; the beats punch it with `lightBoost` + lightRadius: 20, + lightColor: '#cfe4ff', + + /* --- the dome (vfx/Shell.js, DOME mode, prefix `dome`) ------------ */ + // 44 keys. The RING_TRAIN and SUNDISC members of the block are inert in DOME + // mode and are left out of the schema below on purpose — they land in the + // editor's trailing "More" folder, which is exactly what it is for. + ...shellDefaults('dome', ShellMode.DOME, { + domeRadius: 0.38, // the travelling knot, metres + domeRadiusEnd: 4.4, // the dome at the end of beat 1, metres + domeExpand: 7.0, // very fast, then easing — "already out" by frame three + domeHeight: 0.82, // squashed a little; a clap is wider than it is tall + domeLift: 0.02, + domeDisplace: 0.13, + domeNoiseScale: 2.6, + domeNoiseSpeed: 1.6, + domeFill: 0.05, // nearly empty: this is a pressure shell, not a fireball + domeRim: 1.9, + domeRimPower: 2.6, + domeSeal: 2.3, // bright where it meets the floor + domeSealWidth: 0.1, + domeDissolve: 1.15, + domeOpacity: 0.85, + domeGlow: 2.4, + domeSoftFade: 0.45, + domeColorBody: '#3f6fd0', + domeColorRim: '#cfe4ff', + domeColorEdge: '#ffffff', + domeColorCorona: '#9fc4ff' + }) +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Thunderclap. + * + * Reach for **The three beats** first and nothing else. `gapTime` is the + * ability: drag it from 0 to 0.6 with a clap standing and you can watch the + * slot turn from "a shockwave" into "a thunderclap" and back. `frontTime` + * against `frontExpand` decides whether the front snaps out or rolls out, and + * `airStrength` is how much of the frame it drags with it. + * + * The `dome*` folders come from `shellSchema('dome', ShellMode.DOME)`; the + * ring-train and sun-disc keys `shellDefaults` also brings in do nothing to a + * dome and are deliberately unfiled. + */ +export const thunderclapSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['zoneRadius', 1, 16, 0.05, 'front reach (m)'], + ['speed', 20, 400, 1, 'arrival speed'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The three beats': [ + ['clapTime', 0.02, 1, 0.005, '1 · flash + dome (s)'], + ['gapTime', 0, 1.2, 0.005, '2 · THE GAP (s)'], + ['frontTime', 0.05, 2.5, 0.01, '3 · front crossing (s)'], + ['fadeTime', 0.1, 4, 0.05, 'settle (s)'] + ], + 'Beat 1 · the clap': [ + ['knotFade', 0, 1, 0.01, 'travelling knot'], + ['clapFlash', 0, 2, 0.01, 'screen flash'], + ['clapLight', 0, 120, 0.5, 'light punch'], + ['clapShake', 0, 1, 0.005, 'shake (keep it small)'], + ['colorClapFlash', 'flash colour'] + ], + 'Beat 2 · the gap': [['gapGlow', 0, 1, 0.005, 'light through the gap']], + 'Beat 3 · the front': [ + ['frontExpand', 0.5, 8, 0.05, 'arrival curve'], + ['frontDecay', 0.2, 5, 0.05, 'amplitude decay'], + ['frontRings', 1, 4, 1, 'concentric fronts'], + ['frontRingGap', 0.1, 5, 0.05, 'gap between fronts (m)'], + ['frontRingDecay', 0, 2, 0.01, 'trailing front decay'], + ['frontRumble', 0, 0.5, 0.005, 'rumble while crossing'], + ['frontDepthReject', 0, 1, 0.01, 'occlusion'], + ['frontDepthFade', 0.02, 3, 0.01, 'occlusion feather (m)'] + ], + 'Beat 3 · the front/In the air': [ + ['airStrength', 0, 2, 0.01, 'strength (screen widths)'], + ['airThickness', 0.05, 3, 0.01, 'wavefront thickness (m)'], + ['airWindow', 0.05, 1, 0.01, 'falloff window'], + ['airCompression', 0, 3, 0.01, 'compression'], + ['airRarefaction', 0, 3, 0.01, 'rarefaction'], + ['airSpan', 1, 4, 0.05, 'quad width × radius'], + ['airHeight', 0.2, 4, 0.05, 'quad height × radius'], + ['airMaxOffset', 0.05, 4, 0.05, 'offset ceiling'], + ['airPerspective', 0, 1, 0.01, 'distance falloff'], + ['airPerspectiveRef', 1, 40, 0.5, 'reference distance (m)'] + ], + 'Beat 3 · the front/In the floor': [ + ['floorStrength', 0, 2, 0.01, 'strength (screen widths)'], + ['floorThickness', 0.05, 3, 0.01, 'wavefront thickness (m)'], + ['floorWindow', 0.05, 1, 0.01, 'falloff window'], + ['floorCompression', 0, 3, 0.01, 'compression'], + ['floorRarefaction', 0, 3, 0.01, 'rarefaction'], + ['floorSpan', 1, 4, 0.05, 'quad extent × radius'], + ['floorHeight', 0.005, 0.5, 0.005, 'height above floor (m)'], + ['floorMaxOffset', 0.05, 4, 0.05, 'offset ceiling'] + ], + 'Beat 3 · the impact': [ + ['boomShake', 0, 3, 0.01, 'shake'], + ['boomShakeTime', 0.05, 3, 0.01, 'shake duration (s)'], + ['boomFlash', 0, 1, 0.005, 'screen flash'], + ['boomLight', 0, 80, 0.5, 'light punch'], + ['colorBoomFlash', 'flash colour'] + ], + 'Marks on the floor': [ + ['shockLife', 0.1, 4, 0.05, 'ring lifetime (s)'], + ['shockWidth', 0.01, 0.5, 0.005, 'ring thickness'], + ['shockIntensity', 0, 3, 0.01, 'ring intensity'], + ['colorShockA', 'ring body'], + ['colorShockB', 'ring crest'], + ['ringRate', 0.05, 4, 0.05, 'dust puffs / metre'], + ['ringRadius', 0.1, 6, 0.05, 'puff radius (m)'], + ['ringLife', 0.1, 6, 0.05, 'puff lifetime (s)'], + ['ringIntensity', 0, 2, 0.01, 'puff intensity'], + ['colorRingA', 'puff'], + ['colorRingB', 'puff edge'] + ], + 'Dust & grit': [ + ['dustRate', 0, 900, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 20, 0.1, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustRise', -2, 4, 0.01, 'dust rise'], + ['dustSpread', 0, 1, 0.01, 'dust cone'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['boomDust', 0, 300, 1, 'dust on the boom'], + ['gritRate', 0, 500, 1, 'grit rate'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 25, 0.1, 'grit speed'], + ['gritLifetime', 0.1, 5, 0.05, 'grit lifetime'], + ['gritGravity', -50, 0, 0.1, 'grit gravity'], + ['boomGrit', 0, 400, 1, 'grit on the boom'], + ['colorDust*', 'Dust colour'], + ['colorGrit*', 'Grit colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'standing intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ], + ...shellSchema('dome', ShellMode.DOME) +}; diff --git a/src/config/abilities/tiderush.js b/src/config/abilities/tiderush.js new file mode 100644 index 0000000..1275de0 --- /dev/null +++ b/src/config/abilities/tiderush.js @@ -0,0 +1,452 @@ +/* ================================================================== */ +/* TIDERUSH — tide · line */ +/* ================================================================== */ +/** + * A breaking wave runs down the aimed line and lights the floor through itself. + * + * The block is long for the same reason `crimsontide`'s is — a heightfield has + * genuinely more dimensions than a bolt — but the group that matters here is + * **the net**, and it is the only group in the file that is not about water at + * all. It is about *light that has been through* water: + * + * - `netDepth` / `netDepthCrest` — how many metres of water the light crosses. + * This is the single strongest control in the file. The refraction map is + * `A(xz) = xz − (1 − 1/ior)·D·∇h`, so `D` is the lever arm on the fold: at + * `netDepth` 0 there is no fold anywhere and the floor is empty, and past + * about a metre and a half the folds overlap into a wash. `netDepthCrest` + * adds metres in proportion to the crest's *current* height, so the net + * sharpens as the wave rises and slackens as it dumps — which is the whole + * reason you can read the wave's body off the ground. + * - `netAbsorb` — Beer extinction down the water column, per metre. This is + * the term that draws the wave's *thickness*: the column under the crest is + * a metre deeper than the column on its face, so the net goes dark under the + * body and flares in the thin water ahead of the lip. Set it to 0 and the + * cast still renders, and the trick is gone. + * - `netStep` — metres between the six Hessian taps. It is the net's finest + * detail and its aliasing limit in one number: below about 0.04 m the + * filaments are thinner than a floor pixel at a normal camera height and + * they crawl. + * + * **The swell is authored in mirror pairs, and that is not decoration.** A/B + * and C/D carry equal amplitudes, lengths and speeds at opposite bearings + * (`+0.36` / `−0.36`). The reason is in `TiderushAbility`'s header: the ground + * quad the caustics are drawn on has a right-handed frame and `LiquidSurface`'s + * does not, so the net is the water's mirror image across the lane. A wave set + * that is symmetric about the lane is mapped onto *itself* by that mirror, so + * the net and the water agree exactly. Break the pairing — give B a different + * amplitude from A — and the grain of the light on the floor starts to lean the + * other way from the grain on the water. It is a small effect and it is the + * kind of small effect this whole ability is a demonstration of, so the pairs + * are the default and the sliders are there to be broken deliberately. + * + * **No crust, no melt glow.** Water does not skin over, so `crust` is never + * written and `LiquidSurface`'s crust block is skipped entirely. Its *melt* + * glow is not gated by `crust`, though, and it defaults to 1.2 — the ability + * pins it to zero by hand, because a warm emissive haze over cold water was the + * first thing this cast did wrong. + */ +export const tiderush = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 12.0, // how fast the surge runs down the line, metres/second + breakTime: 0.8, // seconds the crest spends pitching over and collapsing + drainTime: 2.2, // seconds the sheet takes to run off the floor + cooldown: 1.05, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the travelling window ----------------------------------------- */ + // The sheet is a fixed-length window of water that travels with the crest, + // not a plane the length of the cast. Everything here is re-read every frame. + sheetSpan: 13.0, // metres of water the window holds, tail to leading edge + crestSeat: 0.62, // 0..1 where in that window the crest stands + sheetTail: 1.8, // metres behind the caster the tail is pinned until it unpins + sheetWidth: 7.4, // metres across the line the water spans + sheetHeight: 0.05, // metres the mean plane floats above the floor + sheetFill: 1.0, // 0..1 of the half-extent the water reaches; the drain rides this + sheetRound: 0.28, // 0 rectangular footprint, 1 elliptical + sheetEdge: 0.17, // 0..1 of the field over which the waterline fades out + sheetRagged: 0.4, // 0..1 how ragged that waterline is + sheetRaggedScale: 1.05, // cycles per metre of the raggedness + sheetOpacity: 1.0, + contactFade: 0.22, // metres of soft fade where the sheet meets standing geometry + + /* --- the swell: four directional waves, in mirror pairs -------------- */ + swellAmpA: 0.12, // metres + swellAmpB: 0.12, // pair of A + swellAmpC: 0.05, + swellAmpD: 0.05, // pair of C + swellLengthA: 5.6, // metres, crest to crest + swellLengthB: 5.6, + swellLengthC: 2.3, + swellLengthD: 2.3, + swellSpeedA: 2.3, // metres/second + swellSpeedB: 2.3, + swellSpeedC: 1.4, + swellSpeedD: 1.4, + swellAngleA: 0.36, // radians in the sheet's frame; 0 is downrange + swellAngleB: -0.36, // the mirror of A — see the header + swellAngleC: 1.22, + swellAngleD: -1.22, // the mirror of C + steepness: 0.52, // 0 sine, 1 Gerstner cusps + + /* --- chop and grain -------------------------------------------------- */ + chop: 0.045, // metres of high-frequency displacement + chopScale: 1.75, // cycles per metre + chopSpeed: 0.55, // metres/second the chop field drifts + detail: 0.016, // metres — fragment-only; lives entirely in the normal + detailScale: 8.2, // cycles per metre + detailSpeed: 1.15, + + /* --- the crest ------------------------------------------------------- */ + // `crestBack / crestFace` is the silhouette: a symmetric bump is a swell, a + // bump with a face a ninth as long as its back is a wave about to break. + crestHeight: 1.15, // metres at full surge + crestBack: 2.7, // metres — the long back slope's 1/e length + crestFace: 0.3, // metres — the short front face's 1/e length + crestCurl: 0.62, // metres of forward throw per metre of height + crestWidth: 0.82, // 0..1 of the half-extent across the wave + crestFeather: 0.26, // 0..1 of that, over which the ends die + crestBreak: 0.3, // 0..1 how ragged the lip is + crestBreakScale: 1.2, // cycles per metre along the lip + crestRise: 0.3, // 0..1 of the run the crest takes to reach full height + crestPeak: 1.45, // multiplier on the height at the moment it pitches + crestCurlPeak: 2.1, // multiplier on the curl at the break + crestBreakPeak: 2.3, // multiplier on the raggedness at the break + crestOvershoot: 2.4, // metres the lip is thrown past the end of the line + + /* --- ripples (the lip smacking down rings the sheet) ----------------- */ + rippleAmp: 0.16, // metres at strength 1 + rippleSpeed: 3.4, // metres/second the ring travels + rippleLength: 1.05, // metres, crest to crest inside the packet + rippleWidth: 0.8, // metres of the gaussian envelope + rippleDecay: 1.15, // seconds to 1/e + rippleSpread: 3.2, // metres over which it also thins with radius + rippleRate: 4.5, // packets per second posted under the lip + rippleSpan: 0.25, // 0..1 lateral scatter of those packets — kept small on purpose + + /* --- the flow field (water has no crust; this only seeds foam) -------- */ + flowAngle: 0.0, // radians, the bulk drift's bearing in the sheet's frame + flowSpeed: 1.5, // metres/second of bulk drift + flowRadial: 0.45, // metres/second outward at the sheet's centre + flowRadialFall: 4.5, // metres to 1/e + flowEddy: 0.55, // metres/second of curl swirl + flowEddyScale: 0.3, // cycles per metre + flowEddySpeed: 0.18, // Hz the eddies churn + flowGravity: 3.2, // metres/second per unit of surface slope + + /* --- foam ------------------------------------------------------------ */ + foam: 0.85, // 0..1 master + foamScale: 5.2, // cycles per metre of the speckle + foamSharp: 1.25, + foamCrest: 1.25, // how much the breaking lip seeds it + foamSpeed: 0.6, // how much surface speed seeds it + // The two surface speeds the froth is gated between. They are `LiquidSurface`'s + // `crustForm` / `crustBreak` — the same pair the skin would die between. Water + // has no skin, so here they are only the foam's gate, and they are named for + // what they do in this cast rather than for the box they land in. + foamGateLow: 0.5, // m/s below which the surface is calm and bare + foamGateHigh: 2.4, // m/s above which it is fully frothed + + /* --- shading --------------------------------------------------------- */ + poolDepth: 0.35, // metres of water under the mean plane + depthTint: 1.35, // Beer-Lambert density in the body, per metre + translucency: 1.7, // backlight through the folded lip + ambient: 0.3, + specular: 1.7, + shininess: 90, // Blinn-Phong exponent + fresnel: 1.2, + envIntensity: 0.85, + skyIntensity: 0.55, + glow: 1.0, + normalEps: 0.04, // metres — the finite-difference step of the shading normal + colorDeep: '#06222e', // the body + colorShallow: '#2ea3b4', // thin water and the backlit lip + colorFoam: '#e8fbff', // froth + colorSpec: '#ffffff', // highlight + colorSky: '#39627a', // the sky fallback under the reflection + + /* --- the caustic net ------------------------------------------------- */ + // THE TRICK. Bound to the sheet's own uniform boxes, so every number below is + // about the *light*, and every number about the *water* is upstairs. + // The net's reach is a multiple of the sheet's own HALF-WIDTH, not a metre of + // its own. The projector is a disc measured in the sheet's frame, the sheet + // is a long rectangle, and a disc big enough to reach the far end of the lane + // is a disc that throws filaments three metres out into dry floor either side + // of it. Tying it to the water is the fix, and it is also correct: light on + // the ground past the waterline is light that came through nothing. + netReach: 1.05, // × the sheet's half-width + + netHeight: 0.014, // metres the quad floats above the floor + netDepth: 0.5, // metres of water the light crosses at rest + netDepthCrest: 0.55, // extra metres per metre of the crest's current height + netIor: 1.335, // water. The shader is handed 1 − 1/ior + netDispersion: 0.07, // 0..1 how far the red and blue folds sit either side of green + netStep: 0.075, // metres between the six Hessian taps + netAbsorb: 0.32, // 1/metres down the water column — the thickness read + netFoldFloor: 0.2, // keeps 1/|det| finite; also the widest a filament gets + netThreshold: 1.05, // compression below this is flat water, and black + netGain: 0.62, + netSharp: 1.4, // exponent on the surviving compression + netRolloff: 0.2, // soft clip, so a fold does not detonate the bloom + netPenumbra: 0.42, // 0..1 of the reach over which the pool of light dies + netWash: 0.14, // the general lit-pool light between the filaments + netFringeAt: 1.5, // where on the fold the colour hands over to the fringe + netEmissive: 1.5, + netOpacity: 1.0, + netDepthFade: 0.4, // metres of soft fade against anything standing on the floor + netBreakGain: 2.0, // multiplier on the gain through the break + colorNet: '#a8f0ff', // the filaments + colorFringe: '#ffffff', // the very top of a fold + colorWash: '#1d5f78', // the general light in the pool + + /* --- droplets off the lip -------------------------------------------- */ + sprayRate: 210, // droplets/second + spraySize: 0.07, // metres + spraySpeed: 5.5, // metres/second + sprayLifetime: 1.1, // seconds + sprayGravity: -14.0, // metres/second² + colorSprayA: '#dff8ff', + colorSprayB: '#8fdcee', + colorSprayC: '#3f93aa', + colorSprayD: '#123444', + + /* --- the haze standing over the break -------------------------------- */ + mistRate: 46, // puffs/second + mistSize: 0.85, // metres + mistSpeed: 1.5, // metres/second + mistLifetime: 2.2, // seconds + mistRise: 0.5, // metres/second² + mistOpacity: 0.5, + mistTurbulence: 0.7, + colorMistA: '#cfeef6', + colorMistB: '#9cc6d4', + colorMistC: '#5c8494', + colorMistD: '#22343c', + + /* --- glints: airborne droplets catching the same refracted light ----- */ + glintRate: 70, // glints/second + glintSize: 0.05, // metres + glintSpeed: 3.2, // metres/second + glintLifetime: 0.9, // seconds + glintGravity: -9.0, // metres/second² + glintGlow: 2.2, + colorGlintA: '#ffffff', + colorGlintB: '#b8f4ff', + colorGlintC: '#4fc0dc', + colorGlintD: '#0d3b4c', + + /* --- what the wave leaves on the floor -------------------------------- */ + wetRate: 1.2, // marks per METRE of front travel, not per second + wetRadius: 2.4, // metres + wetLife: 4.5, // seconds + wetIntensity: 1.0, + colorWetA: '#0d2a34', // soaked stone + colorWetB: '#bfe8f2', // the drying tide line + + /* --- the break --------------------------------------------------------- */ + burstSize: 3.2, // metres + burstIntensity: 1.5, + burstSpray: 220, // droplets thrown out of the collapse + burstGlints: 90, + burstRipples: 4, // ripple packets posted into the sheet + shockRadius: 5.5, // metres + impactShake: 0.5, + shakeDuration: 0.6, // seconds + impactFlash: 0.1, // 0..1 screen flash + rumble: 0.06, // per-second shake while the surge runs + colorBurstA: '#eafcff', + colorBurstB: '#63c8e0', + colorBurstC: '#0f4256', + colorShockA: '#d9f7ff', + colorShockB: '#3aa8c4', + colorFlash: '#9fe4f4', + + /* --- dynamic light ------------------------------------------------------ */ + lightIntensity: 14.0, + lightRadius: 12.0, + lightColor: '#7fe4ff' +}; + +/** Editor layout. Folder order is the order they are built in. */ +export const tiderushSchema = { + 'The cast': ['range', 'minRange', 'speed', ['breakTime', 0.1, 3, 0.01, 'break time'], ['drainTime', 0.2, 6, 0.05, 'drain time'], 'cooldown', 'castAnim'], + 'The travelling window': [ + ['sheetSpan', 3, 30, 0.1, 'window length'], + ['crestSeat', 0.05, 0.95, 0.01, 'crest seat in it'], + ['sheetTail', 0, 8, 0.05, 'tail pinned behind'], + ['sheetWidth', 1, 20, 0.1, 'width'], + ['sheetHeight', 0, 0.5, 0.005, 'height off the floor'], + ['sheetFill', 0, 1, 0.01, 'fill'], + ['sheetRound', 0, 1, 0.01, 'rectangular → elliptical'], + ['sheetEdge', 0.01, 1, 0.01, 'waterline feather'], + ['sheetRagged', 0, 1, 0.01, 'waterline raggedness'], + ['sheetRaggedScale', 0.05, 4, 0.01, 'raggedness scale'], + ['sheetOpacity', 0, 1, 0.01, 'opacity'], + ['contactFade', 0.02, 2, 0.01, 'soft intersection'] + ], + 'The swell (mirror pairs)': [ + ['swellAmpA', 0, 1, 0.005, 'amp A'], + ['swellAmpB', 0, 1, 0.005, 'amp B — pairs A'], + ['swellAmpC', 0, 1, 0.005, 'amp C'], + ['swellAmpD', 0, 1, 0.005, 'amp D — pairs C'], + ['swellLengthA', 0.2, 20, 0.05, 'length A'], + ['swellLengthB', 0.2, 20, 0.05, 'length B'], + ['swellLengthC', 0.2, 20, 0.05, 'length C'], + ['swellLengthD', 0.2, 20, 0.05, 'length D'], + ['swellSpeedA', 0, 8, 0.05, 'speed A'], + ['swellSpeedB', 0, 8, 0.05, 'speed B'], + ['swellSpeedC', 0, 8, 0.05, 'speed C'], + ['swellSpeedD', 0, 8, 0.05, 'speed D'], + ['swellAngleA', -3.2, 3.2, 0.01, 'bearing A'], + ['swellAngleB', -3.2, 3.2, 0.01, 'bearing B'], + ['swellAngleC', -3.2, 3.2, 0.01, 'bearing C'], + ['swellAngleD', -3.2, 3.2, 0.01, 'bearing D'], + ['steepness', 0, 1.2, 0.01, 'Gerstner cusps'], + ['chop', 0, 0.4, 0.001, 'chop'], + ['chopScale', 0.05, 6, 0.01, 'chop scale'], + ['chopSpeed', 0, 4, 0.01, 'chop drift'], + ['detail', 0, 0.1, 0.001, 'normal grain'], + ['detailScale', 0.5, 20, 0.1, 'grain scale'], + ['detailSpeed', 0, 4, 0.01, 'grain drift'] + ], + 'The crest': [ + ['crestHeight', 0, 4, 0.01, 'height'], + ['crestBack', 0.1, 12, 0.05, 'back slope'], + ['crestFace', 0.02, 3, 0.01, 'front face'], + ['crestCurl', 0, 3, 0.01, 'forward throw'], + ['crestWidth', 0.05, 1, 0.01, 'width across'], + ['crestFeather', 0.01, 1, 0.01, 'end feather'], + ['crestBreak', 0, 1, 0.01, 'lip raggedness'], + ['crestBreakScale', 0.1, 6, 0.01, 'raggedness scale'], + ['crestRise', 0.02, 1, 0.01, 'run to full height'], + ['crestPeak', 0.2, 4, 0.01, 'height at the break'], + ['crestCurlPeak', 0.2, 6, 0.01, 'curl at the break'], + ['crestBreakPeak', 0.2, 6, 0.01, 'raggedness at the break'], + ['crestOvershoot', 0, 8, 0.05, 'throw past the end'] + ], + 'Ripples': [ + ['rippleAmp', 0, 1, 0.005, 'amplitude'], + ['rippleSpeed', 0.1, 12, 0.05, 'front speed'], + ['rippleLength', 0.1, 4, 0.01, 'wavelength'], + ['rippleWidth', 0.05, 3, 0.01, 'packet width'], + ['rippleDecay', 0.05, 5, 0.01, 'decay (s)'], + ['rippleSpread', 0.1, 10, 0.05, 'radial thinning'], + ['rippleRate', 0, 20, 0.1, 'packets / second'], + ['rippleSpan', 0, 1, 0.01, 'lateral scatter'] + ], + 'The flow (foam only)': [ + ['flowAngle', -3.2, 3.2, 0.01, 'drift bearing'], + ['flowSpeed', 0, 6, 0.01, 'drift speed'], + ['flowRadial', -4, 4, 0.01, 'radial outflow'], + ['flowRadialFall', 0.1, 12, 0.05, 'outflow falloff'], + ['flowEddy', 0, 4, 0.01, 'curl swirl'], + ['flowEddyScale', 0.02, 2, 0.01, 'eddy scale'], + ['flowEddySpeed', 0, 2, 0.01, 'eddy churn'], + ['flowGravity', 0, 10, 0.05, 'downhill gain'], + ['foam', 0, 1, 0.01, 'foam'], + ['foamScale', 0.5, 16, 0.05, 'foam speckle'], + ['foamSharp', 0.2, 4, 0.01, 'foam sharpness'], + ['foamCrest', 0, 3, 0.01, 'seeded by the lip'], + ['foamSpeed', 0, 3, 0.01, 'seeded by speed'], + ['foamGateLow', 0, 6, 0.01, 'calm below (m/s)'], + ['foamGateHigh', 0.1, 10, 0.05, 'frothed above (m/s)'] + ], + 'Shading': [ + ['poolDepth', 0, 3, 0.01, 'depth under the plane'], + ['depthTint', 0, 10, 0.01, 'absorption / metre'], + ['translucency', 0, 4, 0.01, 'backlit front face'], + ['ambient', 0, 1, 0.01, 'ambient floor'], + ['specular', 0, 6, 0.01, 'specular'], + ['shininess', 4, 256, 1, 'gloss'], + ['fresnel', 0, 3, 0.01, 'fresnel'], + ['envIntensity', 0, 3, 0.01, 'reflection'], + ['skyIntensity', 0, 3, 0.01, 'sky fallback'], + ['glow', 0, 4, 0.01, 'glow'], + ['normalEps', 0.005, 0.3, 0.001, 'normal step'], + ['colorDeep', 'body'], + ['colorShallow', 'thin water'], + ['colorFoam', 'froth'], + ['colorSpec', 'highlight'], + ['colorSky', 'sky fallback'] + ], + 'The caustic net': [ + ['netReach', 0.2, 3, 0.01, 'reach (× half-width)'], + ['netHeight', 0.002, 0.2, 0.001, 'height off the floor'], + ['netDepth', 0, 4, 0.01, 'water crossed (m)'], + ['netDepthCrest', 0, 3, 0.01, 'extra per m of crest'], + ['netIor', 1.01, 2, 0.001, 'index of refraction'], + ['netDispersion', 0, 0.4, 0.005, 'chromatic spread'], + ['netStep', 0.02, 0.4, 0.005, 'Hessian tap step'], + ['netAbsorb', 0, 2, 0.01, 'absorption / metre'], + ['netFoldFloor', 0.01, 1, 0.005, 'fold floor'], + ['netThreshold', 0, 4, 0.01, 'black-water threshold'], + ['netGain', 0, 3, 0.01, 'gain'], + ['netSharp', 0.1, 4, 0.01, 'sharpness'], + ['netRolloff', 0, 2, 0.01, 'soft clip'], + ['netPenumbra', 0.02, 1, 0.01, 'edge penumbra'], + ['netWash', 0, 1, 0.005, 'lit pool between filaments'], + ['netFringeAt', 0, 6, 0.01, 'fringe hand-over'], + ['netEmissive', 0, 5, 0.01, 'emissive'], + ['netOpacity', 0, 1, 0.01, 'opacity'], + ['netDepthFade', 0.02, 3, 0.01, 'soft intersection'], + ['netBreakGain', 0.2, 6, 0.01, 'gain at the break'], + ['colorNet', 'filaments'], + ['colorFringe', 'fold crest'], + ['colorWash', 'pool light'] + ], + 'Droplets, mist & glints': [ + ['sprayRate', 0, 900, 1, 'droplet rate'], + ['spraySize', 0.005, 0.5, 0.005, 'droplet size'], + ['spraySpeed', 0, 20, 0.1, 'droplet speed'], + ['sprayLifetime', 0.05, 4, 0.01, 'droplet lifetime'], + ['sprayGravity', -50, 0, 0.1, 'droplet gravity'], + ['mistRate', 0, 400, 1, 'mist rate'], + ['mistSize', 0.05, 4, 0.01, 'mist size'], + ['mistSpeed', 0, 8, 0.05, 'mist speed'], + ['mistLifetime', 0.2, 8, 0.05, 'mist lifetime'], + ['mistRise', -2, 4, 0.01, 'mist rise'], + ['mistOpacity', 0, 1, 0.005, 'mist opacity'], + ['mistTurbulence', 0, 3, 0.01, 'mist turbulence'], + ['glintRate', 0, 400, 1, 'glint rate'], + ['glintSize', 0.005, 0.4, 0.005, 'glint size'], + ['glintSpeed', 0, 20, 0.1, 'glint speed'], + ['glintLifetime', 0.05, 4, 0.01, 'glint lifetime'], + ['glintGravity', -50, 0, 0.1, 'glint gravity'], + ['glintGlow', 0, 6, 0.01, 'glint glow'], + ['colorSpray*', 'Droplet colour'], + ['colorMist*', 'Mist colour'], + ['colorGlint*', 'Glint colour'] + ], + 'Marks on the floor': [ + ['wetRate', 0.05, 4, 0.05, 'marks / metre'], + ['wetRadius', 0.2, 8, 0.05, 'mark radius'], + ['wetLife', 0.5, 20, 0.1, 'mark lifetime'], + ['wetIntensity', 0, 3, 0.01, 'mark intensity'], + ['colorWetA', 'soaked stone'], + ['colorWetB', 'drying tide line'] + ], + 'The break': [ + ['burstSize', 0.2, 12, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstSpray', 0, 800, 1, 'burst droplets'], + ['burstGlints', 0, 400, 1, 'burst glints'], + ['burstRipples', 0, 8, 1, 'ripple packets'], + ['shockRadius', 0.5, 20, 0.1, 'shockwave radius'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 1, 0.005, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'travel rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst crest'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorFlash', 'screen flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/torrent.js b/src/config/abilities/torrent.js new file mode 100644 index 0000000..b142db5 --- /dev/null +++ b/src/config/abilities/torrent.js @@ -0,0 +1,407 @@ +/* ================================================================== */ +/* TORRENT — a cutting jet, and the sheet it throws */ +/* ================================================================== */ +/** + * A line cast. A thin, high-pressure column of water comes off the caster's + * hand and walks its contact point down the line, and where it lands it throws + * a **deflection sheet**. + * + * **The trick is that the spray knows which way the surface is facing.** The + * jet's direction is reflected about the impact surface's normal; the component + * of that reflection *in* the surface is the axis the sheet runs along, and the + * component along the normal — exactly reversed by the reflection — is what + * lifts the crown. The flux is then distributed around the azimuth by the + * Poisson kernel, which is the only distribution on a circle that conserves + * both mass and in-plane momentum and has nothing else in it. + * `materials/DeflectionSheetMaterial.js` has the derivation. + * + * The consequences you can see, none of which a radial puff has: + * + * - the fan is **narrow and forward** at a grazing angle and opens toward a + * full ring as the jet steepens, so it visibly widens as the contact point + * walks in toward the caster; + * - the crown only rises when there is normal momentum to reverse, so a + * grazing jet lies flat on the deck and a steep one throws a bell; + * - `surfaceTilt` and `surfaceRoll` swing the whole thing, sheet and droplets + * together, because they are the same three lines of algebra read from two + * ends — the sheet draws the density, the droplets sample its inverse CDF. + * + * **On `fanConcentration`.** The inviscid in-plane fraction is `sin θ`, and at + * this cast's own geometry that is about 0.99, which collapses the fan to a + * seven-degree line. Real jets lose most of that to the splash and the + * roughness, so `k = sinθ · fanConcentration`. It is a fudge; it is labelled as + * one; and it is a single scalar on a term that still answers the geometry. + * + * A cast captures one seed and a handful of timestamps. Everything with a unit + * below is resolved inside the update loop, zero-length frames included: pause + * with the jet standing and drag `surfaceTilt`, and the fan swings. + */ + +import { TubePath, tubeDefaults, tubeSchema } from '../../vfx/Tube.js'; + +export const torrent = { + /* --- the cast --- */ + range: 15.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused + speed: 17.0, // how fast the contact point walks down the line, metres/second + cooldown: 1.1, // seconds + castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the beats (seconds; scaled by global.lifetime where noted) ---- */ + holdTime: 1.15, // the jet held at full reach after the walk-out + fadeTime: 0.85, // pressure dropping — the column collapses to a thread + openTime: 0.12, // seconds the fan takes to reach full extent on contact + + /* --- where the jet leaves the caster ------------------------------- */ + handForward: 0.55, // metres ahead of the origin + handSide: 0.3, // metres to the side + handHeight: 1.55, // metres above the floor. THIS is what sets the incidence + // angle, and therefore how wide the fan is + + /* --- the impact surface -------------------------------------------- */ + // Default: the floor, dead flat. These two exist because the whole ability is + // "the spray answers the normal", and an ability that claims that had better + // let you move the normal and watch. + surfaceTilt: 0.0, // radians the surface normal leans off vertical + surfaceRoll: 0.0, // radians — which way it leans, in the cast's flat frame + surfaceHeight: 0.03, // metres the sheet sits above the stone (z-fight relief) + + /* --- the fan (the trick) -------------------------------------------- */ + fanConcentration: 0.58, // the loss term on the in-plane momentum. 0 makes the + // spray a perfect ring whatever the angle, 1 is the + // inviscid answer and is far too tight to read + fanReach: 3.6, // metres the mean bearing carries + fanPower: 0.85, // how hard reach follows the flux density. 0 = a circle at + // fanReach with the brightness varying; 1 = the outline + // itself is the distribution + fanCrown: 0.16, // lift off the plate, × reach, × the reversed normal momentum + fanCrownFall: 1.7, // how fast the sheet comes back down to the surface + fanFingers: 13.0, // ligaments the unstable rim breaks into + fanFingerDepth: 0.22, // 0..1 how deep they cut into the outline + fanFingerScale: 3.2, // hashed features around the rim + fanFingerSpeed: 4.5, // Hz they crawl + fanRipple: 0.06, // metres of chop on the sheet's surface + fanRippleScale: 2.4, // chop features per metre + fanRippleSpeed: 3.2, // Hz it runs outward + fanThin: 1.6, // metres over which the sheet halves in thickness + fanBody: 0.7, // how much body it keeps at all + fanRimWidth: 0.16, // 0..1 of the reach the collected rim occupies + fanRimGain: 0.55, // how bright that rim is + fanFresnel: 2.6, // grazing whitening exponent + fanSpecular: 0.95, + fanGloss: 40.0, // Blinn exponent + fanOpacity: 1.0, + fanGlow: 1.35, // emissive gain into bloom + fanSoftFade: 0.3, // metres of depth fade against the opaque scene + colorSheet: '#1f7f9a', // the water where the sheet is thick + colorSheetThin: '#0b3a4c', // ... and where it has stretched out + colorSheetRim: '#d6f6ff', // the collected edge + colorSheetSpray: '#ffffff', // its specular and crest + + /* --- spray: the streaks that leave along the fan --------------------- */ + sprayRate: 320, // streaks/second while the jet is on + spraySize: 0.05, + spraySpeed: 9.5, // metres/second — the jet's own speed, near enough + spraySpeedVariance: 0.55, + sprayLifetime: 0.75, + sprayGravity: -17.0, // metres/second² + sprayStretch: 2.4, // velocity stretch on a streak + sprayLift: 0.35, // 0..1 how much of the reversed normal momentum a droplet + // takes with it. 0 keeps the whole spray on the deck + sprayJitter: 0.18, // radians of scatter on top of the sampled bearing + colorSprayA: '#ffffff', + colorSprayB: '#bfeaf6', + colorSprayC: '#5aa8c4', + colorSprayD: '#1a4c62', + + /* --- the big drops thrown off the rim -------------------------------- */ + dropRate: 55, // drops/second + dropSize: 0.1, + dropSpeed: 5.5, + dropLifetime: 1.4, + dropGravity: -16.0, + colorDropA: '#eafbff', + colorDropB: '#a4dcec', + colorDropC: '#4f96b4', + colorDropD: '#173f52', + + /* --- the atomised haze ------------------------------------------------ */ + mistRate: 90, // puffs/second + mistSize: 0.55, + mistSpeed: 1.6, + mistLifetime: 1.5, + mistRise: 0.9, // metres/second² + mistOpacity: 0.35, + colorMistA: '#d4eef4', + colorMistB: '#96c2cf', + colorMistC: '#4f7784', + colorMistD: '#1e343c', + + /* --- the wet stone (vfx/GroundField.js, WET mode) --------------------- */ + wetRadius: 1.5, // × fanReach + wetBias: 0.35, // × fanReach the patch is pushed downstream along the fan + // axis. Water soaks into stone isotropically, so the *shape* + // stays a disc; where it is centred is not arbitrary + wetHeight: 0.014, // metres above the floor the quad lies at + wetEdge: 0.35, // metres of feather on the front + wetRagged: 0.3, // how far the rim wanders, fraction of the radius + wetRaggedScale: 0.9, // lobes per metre + wetWarp: 0.5, // metres of domain warp on those lobes + wetDepth: 0.08, // metres — how dark the soak reads + wetLift: 0.02, // metres of standing ripple + wetCell: 0.7, // puddle features per metre + wetFlow: 0.4, // metres/second the ripple drifts + wetSpeed: 1.4, // Hz the ripple crawls + wetDetail: 0.7, // 0..1 + wetRelief: 0.45, + wetNormalStep: 0.05, // metres between the height taps + wetAmbient: 0.3, + wetWrap: 0.4, + wetSpecular: 1.1, // wet stone is shinier than dry stone; this is the read + wetGloss: 60.0, + wetOpacity: 0.9, + wetEmissive: 0.5, + wetGrow: 0.2, // seconds the patch takes to reach full radius + wetDry: 1.0, // 0..1 how far the fade dries it back from the rim + colorWetBase: '#4a5158', // damp stone + colorWetEdge: '#cfeaf2', // the sheen + colorWetGlow: '#8fb8c0', // the pale tide mark left by drying + colorWetDeep: '#171d22', // the soak + + /* --- foam left on the stone ------------------------------------------- */ + foamRate: 3.0, // marks per metre of contact-point travel + foamRadius: 0.45, // metres + foamLife: 2.2, // seconds + foamIntensity: 0.7, + colorFoamA: '#e2f6fa', + colorFoamB: '#79b8c8', + + /* --- the impact -------------------------------------------------------- */ + burstSize: 1.5, // the water burst on the frame the jet lands, metres + burstIntensity: 1.0, + colorBurstA: '#1c5c74', + colorBurstB: '#9fdfef', + colorBurstC: '#ffffff', + impactShake: 0.35, + shakeDuration: 0.45, // seconds it decays over + rumble: 0.11, // sustained shake while the jet is running + impactFlash: 0.06, // full-screen flash, 0..2 + colorFlash: '#bfeaf6', + + /* --- dynamic light ------------------------------------------------------ */ + lightIntensity: 5.0, + lightRadius: 12.0, + lightColor: '#79cfe4', + + /* --- the jet (vfx/Tube.js, STRAIGHT path, prefix `jet`) ----------------- */ + // 79 keys. Thin, hard and barely flared: a cutting jet, not a beam. The + // FUNNEL/WHIP/VINE/ARC members of the block are inert on a STRAIGHT tube and + // are left out of the schema on purpose. + ...tubeDefaults('jet', TubePath.STRAIGHT, { + jetRadius: 0.11, // half-width where it lands, metres + jetRadiusNear: 0.16, // ... and at the nozzle. Wider at the nozzle: a free + // jet necks down as it accelerates away + jetRadiusCurve: 0.55, + jetFlare: 0.35, // a little belling where it hits + jetFlareWidth: 0.1, + jetThrob: 0.09, // pump pulsation + jetThrobScale: 5.5, + jetThrobSpeed: 6.0, + jetWander: 0.04, + jetWanderScale: 1.4, + jetWanderSpeed: 1.6, + jetRipple: 0.22, // the barrel breaking up as it travels + jetRippleBands: 2.4, + jetRippleScale: 6.5, + jetRippleSpeed: 5.5, + jetStreak: 1.2, // filaments running down the column — this is water + jetStreakSharp: 0.62, + jetStreakScale: 9.0, + jetStreakBands: 3.4, + jetStreakGlow: 0.8, + jetFlowSpeed: 16.0, // fast: the surface has to look like it is moving + jetBands: 0.0, + jetCoreWidth: 0.42, + jetCoreFill: 0.6, + jetCoreSharp: 1.2, + jetEdgePower: 2.4, + jetSheathWidth: 1.0, + jetSheathRim: 1.1, + jetSheathFill: 0.22, + jetSheathOpacity: 0.92, + jetHaloWidth: 1.7, + jetHaloRim: 3.2, + jetHaloOpacity: 0.35, + jetMuzzleGlow: 1.2, + jetMuzzleLength: 0.07, + jetTipGlow: 1.1, + jetTipLength: 0.05, + jetOpacity: 1.0, + jetGlow: 1.4, + jetSoftFade: 0.4, + jetColorCore: '#e8fbff', + jetColorInner: '#a9e6f6', + jetColorOuter: '#2f88a8', + jetColorHalo: '#0a3244' + }) +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Torrent. + * + * Open **The impact surface** and drag `surfaceTilt` first, with the jet + * standing and the sim paused. The sheet, the crown and every droplet swing + * together, because they all come off one reflection. `surfaceRoll` then + * decides which way it swings. + * + * `fanConcentration` is the second one to reach for — from 0 (a perfect ring, + * which is what everybody else's splash is) to 1 (the inviscid answer, a knife + * of spray). Somewhere near 0.6 is where it reads as pressure. + * + * `handHeight` is the sleeper: it is the only thing that sets the incidence + * angle, so raising it steepens the jet, narrows nothing and *widens* the fan, + * which is the opposite of what most people guess. + * + * The `jet*` folders come from `tubeSchema('jet', TubePath.STRAIGHT)`; the + * whip, funnel, vine and arc keys `tubeDefaults` also brings in do nothing to a + * straight tube and are deliberately unfiled. + */ +export const torrentSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 80, 0.5, 'contact walk (m/s)'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The beats': [ + ['holdTime', 0.05, 6, 0.05, 'jet held (s)'], + ['fadeTime', 0.05, 4, 0.05, 'pressure drop (s)'], + ['openTime', 0.01, 1, 0.005, 'fan opens over (s)'] + ], + 'The nozzle': [ + ['handForward', -1, 3, 0.01, 'forward (m)'], + ['handSide', -2, 2, 0.01, 'side (m)'], + ['handHeight', 0.2, 6, 0.01, 'height (m) — sets the angle'] + ], + 'The impact surface': [ + ['surfaceTilt', 0, 1.2, 0.005, 'normal tilt (rad)'], + ['surfaceRoll', 0, 6.28, 0.01, 'tilt bearing (rad)'], + ['surfaceHeight', 0.002, 0.2, 0.002, 'sheet clearance (m)'] + ], + 'The fan': [ + ['fanConcentration', 0, 1, 0.005, 'in-plane momentum kept'], + ['fanReach', 0.2, 12, 0.05, 'mean reach (m)'], + ['fanPower', 0, 2, 0.01, 'outline follows density'], + ['fanCrown', 0, 1, 0.005, 'crown lift × reach'], + ['fanCrownFall', 0.2, 6, 0.05, 'crown fall-off'], + ['fanThin', 0.05, 8, 0.05, 'thinning distance (m)'], + ['fanBody', 0, 2, 0.01, 'body'], + ['fanRimWidth', 0.01, 1, 0.005, 'rim width'], + ['fanRimGain', 0, 2, 0.01, 'rim brightness'], + ['fanOpacity', 0, 1, 0.005, 'opacity'], + ['fanGlow', 0, 5, 0.01, 'glow'], + ['fanSoftFade', 0.02, 2, 0.01, 'depth fade (m)'] + ], + 'The fan/Break-up': [ + ['fanFingers', 1, 48, 1, 'ligaments'], + ['fanFingerDepth', 0, 1, 0.005, 'ligament depth'], + ['fanFingerScale', 0.2, 10, 0.05, 'ligament scale'], + ['fanFingerSpeed', 0, 20, 0.05, 'ligament crawl (Hz)'], + ['fanRipple', 0, 0.6, 0.005, 'surface chop (m)'], + ['fanRippleScale', 0.1, 10, 0.05, 'chop scale'], + ['fanRippleSpeed', 0, 12, 0.05, 'chop speed (Hz)'] + ], + 'The fan/Shading': [ + ['fanFresnel', 0.5, 8, 0.05, 'grazing whitening'], + ['fanSpecular', 0, 4, 0.01, 'specular'], + ['fanGloss', 2, 160, 1, 'gloss'], + ['colorSheet', 'sheet, thick'], + ['colorSheetThin', 'sheet, stretched'], + ['colorSheetRim', 'collected rim'], + ['colorSheetSpray', 'crest / specular'] + ], + 'Spray, drops & mist': [ + ['sprayRate', 0, 1200, 5, 'spray rate'], + ['spraySize', 0.005, 0.4, 0.005, 'spray size'], + ['spraySpeed', 0, 40, 0.1, 'spray speed'], + ['spraySpeedVariance', 0, 1, 0.01, 'spray speed scatter'], + ['sprayLifetime', 0.1, 4, 0.05, 'spray lifetime'], + ['sprayGravity', -50, 0, 0.1, 'spray gravity'], + ['sprayStretch', 0, 8, 0.05, 'spray stretch'], + ['sprayLift', 0, 1, 0.005, 'normal momentum kept'], + ['sprayJitter', 0, 1.5, 0.005, 'bearing scatter (rad)'], + ['dropRate', 0, 400, 1, 'drop rate'], + ['dropSize', 0.005, 0.5, 0.005, 'drop size'], + ['dropSpeed', 0, 25, 0.1, 'drop speed'], + ['dropLifetime', 0.1, 5, 0.05, 'drop lifetime'], + ['dropGravity', -50, 0, 0.1, 'drop gravity'], + ['mistRate', 0, 600, 1, 'mist rate'], + ['mistSize', 0.05, 4, 0.01, 'mist size'], + ['mistSpeed', 0, 12, 0.05, 'mist speed'], + ['mistLifetime', 0.1, 6, 0.05, 'mist lifetime'], + ['mistRise', -2, 6, 0.01, 'mist rise'], + ['mistOpacity', 0, 1, 0.005, 'mist opacity'], + ['colorSpray*', 'Spray colour'], + ['colorDrop*', 'Drop colour'], + ['colorMist*', 'Mist colour'] + ], + 'The wet stone': [ + ['wetRadius', 0.2, 4, 0.01, 'patch × reach'], + ['wetBias', 0, 2, 0.01, 'pushed downstream × reach'], + ['wetGrow', 0.02, 2, 0.01, 'spread (s)'], + ['wetDry', 0, 1, 0.005, 'drying on the fade'], + ['wetHeight', 0.002, 0.2, 0.002, 'height above floor (m)'], + ['wetEdge', 0.02, 2, 0.01, 'rim feather (m)'], + ['wetRagged', 0, 1, 0.005, 'rim wander'], + ['wetRaggedScale', 0.1, 4, 0.05, 'rim lobes / m'], + ['wetWarp', 0, 3, 0.01, 'rim warp (m)'], + ['wetDepth', 0.005, 0.6, 0.005, 'soak depth (m)'], + ['wetLift', 0, 0.3, 0.002, 'ripple (m)'], + ['wetCell', 0.05, 3, 0.01, 'puddles / m'], + ['wetFlow', 0, 3, 0.01, 'ripple drift (m/s)'], + ['wetSpeed', 0, 4, 0.01, 'ripple crawl (Hz)'], + ['wetDetail', 0, 1, 0.005, 'detail'], + ['wetRelief', 0, 2, 0.01, 'relief'], + ['wetNormalStep', 0.01, 0.4, 0.005, 'normal tap (m)'], + ['wetAmbient', 0, 1, 0.005, 'ambient'], + ['wetWrap', 0, 1, 0.005, 'terminator wrap'], + ['wetSpecular', 0, 3, 0.01, 'specular'], + ['wetGloss', 2, 160, 1, 'gloss'], + ['wetOpacity', 0, 1, 0.005, 'opacity'], + ['wetEmissive', 0, 3, 0.01, 'emissive'], + ['colorWetBase', 'damp stone'], + ['colorWetEdge', 'sheen'], + ['colorWetGlow', 'tide mark'], + ['colorWetDeep', 'soak'] + ], + 'Marks on the floor': [ + ['foamRate', 0.05, 12, 0.05, 'foam marks / metre'], + ['foamRadius', 0.05, 3, 0.01, 'foam radius (m)'], + ['foamLife', 0.1, 8, 0.05, 'foam lifetime (s)'], + ['foamIntensity', 0, 2, 0.01, 'foam intensity'], + ['colorFoamA', 'foam body'], + ['colorFoamB', 'foam edge'] + ], + 'The impact': [ + ['burstSize', 0.1, 8, 0.05, 'water burst (m)'], + ['burstIntensity', 0, 3, 0.01, 'burst intensity'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake decay (s)'], + ['rumble', 0, 0.6, 0.005, 'rumble while running'], + ['impactFlash', 0, 2, 0.005, 'screen flash'], + ['colorBurstA', 'burst body'], + ['colorBurstB', 'burst rim'], + ['colorBurstC', 'burst core'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'standing intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ], + ...tubeSchema('jet', TubePath.STRAIGHT) +}; diff --git a/src/config/abilities/umbralspears.js b/src/config/abilities/umbralspears.js new file mode 100644 index 0000000..c19da3b --- /dev/null +++ b/src/config/abilities/umbralspears.js @@ -0,0 +1,333 @@ +/* ================================================================== */ +/* UMBRALSPEARS — Umbral Spears */ +/* ================================================================== */ +/** + * A line of near-black spears that comes up out of the caster's own shadow. + * + * This block is the **anti-glow** slot, and almost every number in it is + * pointed the wrong way round compared with the other forty-nine. There is no + * body colour brighter than `#0a0710`, no emission anywhere except the rim, no + * additive particle system and no screen flash. The scene is tuned for bloom; + * these are the only objects in it that are darker than the floor, and every + * time a value in here has been raised "just a little" the effect has stopped + * working, because a dark silhouette only reads while everything about it stays + * dark. + * + * **`rimCeiling` is the one number not to touch.** `UnrealBloomPass` runs on + * the linear HDR buffer *before* `OutputPass` tone maps, with + * `settings.post.bloomThreshold` at 0.88. The rim is rolled off so it + * asymptotes at `rimCeiling` and therefore can never cross that threshold — see + * the guard in `UmbralSpearsAbility`. Push this above 0.88 and the bloom pass + * smears the rim across the silhouette the whole ability exists to protect. + * + * **`shadow*` is a real shadow, not a decal.** The band on the floor is a + * `GroundField` in RUT mode, non-additive, so it *shades* the flagstones rather + * than adding light to them, and it is offset along the horizontal projection + * of `frame.uLightDir` — the same key direction the lit meshes use. Each spear + * posts a contact mark as it breaks the surface, which is what pools the + * darkness at the bases instead of laying an even stripe. + */ +export const umbralspears = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused + speed: 33.0, // how fast the front runs the line, metres/second + lifetime: 1.25, // seconds the spears stand after the front lands + sinkDelay: 0.25, // seconds into the fade before they start going back down + sinkTime: 0.7, // seconds they take to withdraw + cooldown: 0.9, // seconds before it can be cast again + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the field on the floor --- */ + spearCount: 104, // spears this cast (hard ceiling 240) + clusterShare: 0.2, // 0..1 of them held back for the ring at the impact point + clusterRadius: 1.9, // metres that ring reaches, < 0 derives from the band + widthNear: 0.45, // metres, half-width of the band at the caster + width: 2.4, // metres, at the far end + widthCurve: 1.35, // >1 keeps the band narrow, then opens it late + frontBias: 0.85, // <1 crowds the spears toward the far end + clumping: 1.15, // >1 pulls them toward the centre line + scatter: 0.45, // extra lateral jitter, fraction of the local half-width + + /* --- the silhouette --- */ + heightNear: 0.7, // metres at the caster + height: 2.6, // metres at the far end + heightCurve: 1.15, // how late the ramp climbs + heightJitter: 0.42, // ± fraction + crown: 0.4, // 0..1 how much shorter a flank spear is than the spine + crownPower: 1.5, // how sharply that dome falls off + peak: 1.2, // extra height multiplier at the far end + peakWidth: 0.22, // 0..1 of the cast that swell covers + rubble: 0.16, // 0..1 chance a spear is demoted to a stub + rubbleScale: 0.32, // height multiplier for those + rubbleSpread: 1.3, // radius multiplier for those + minHeight: 0.05, // metres, floor + radiusNear: 0.13, // metres, base radius at the caster + radius2: 0.19, // metres, at the far end + radiusCurve: 0.7, // how the radius ramps along the cast + radiusJitter: 0.35, // ± fraction + minRadius: 0.02, // metres, floor + + /* --- how they are aimed --- */ + // `leanForward` is the authored part and `shadowLean` is the part the sun + // decides: the spears tip away from the light, so they lie *along* their own + // shadows. With `shadowLean` at zero they stand square to the cast and the + // field loses the one thing that ties it to the room. + lean: 0.3, // radians away from vertical + leanJitter: 0.5, // ± fraction + leanRamp: 0.6, // 0 leans everything equally, 1 only the far end + leanForward: 0.35, // weight of "away from the caster" in the lean direction + leanOutward: 0.9, // weight of "out across the band" + shadowLean: 0.85, // how hard the key light steers that lean, unitless + twist: 1.0, // 0..1 of a full turn of random yaw + tilt: 0.12, // radians of extra random tip, any bearing + + /* --- the eruption --- */ + riseTime: 0.16, // seconds from buried to full height + riseStagger: 0.14, // seconds of random delay between neighbours + riseOvershoot: 0.22, // how far past full height the punch carries + settle: 0.42, // seconds that overshoot damps out over + springRate: 16.0, // radians/second of the overshoot ring + emergeSink: 0.9, // fraction of its height a spear is buried at emerge = 0 + birthScale: 0.8, // footprint scale at the moment it breaks through + birthFade: 0.24, // seconds the birth value decays over + breachAt: 0.22, // emergence fraction that fires the breach event + sinkDepth: 0.5, // extra metres a withdrawing spear drops + + /* --- one spear --- */ + spearSides: 5, // facets around the blade (4–8) + spearTaper: 0.16, // radius at the shoulder, as a fraction of the base + spearBarb: 1.25, // >1 flares a barb out just above the floor + spearBarbAt: 0.3, // 0..1 up the spear that barb sits + spearRough: 0.34, // how far the facets are pushed off a clean blade + spearTwist: 0.35, // turns of flute from base to tip + + /* --- the rim, which is the entire shading model --- */ + colorBody: '#050308', // the spear. Not a tint on a lit surface — this IS it + colorRim: '#8a5fd0', // the only bright colour anywhere in the ability + colorBirth: '#c9a8ff', // the rim, for the instant a spear breaks the surface + rimPower: 4.2, // how tight the fresnel is + rimInner: 0.55, // how much of a wider, softer copy is subtracted back off + rimGain: 1.35, // brightness of the rim before the ceiling + rimCeiling: 0.82, // hard asymptote, linear HDR — keep below post.bloomThreshold + rimShadowBias: 0.6, // 0..1 how much the rim prefers the unlit side + rimTip: 0.45, // 0..1 how much more rim the tip gets than the base + rimGrain: 0.5, // 0..1 how far the rim is eaten into by world-space grain + rimGrainScale: 7.5, // grain features per metre + birthRim: 0.9, // extra rim on a spear that has just arrived + impactRim: 1.1, // extra rim on the whole field when the front lands + impactRimTime: 0.45, // seconds that extra decays over + + /* --- the shadow pooled at the bases --- */ + shadowWidth: 1.9, // metres, half-width of the band + shadowOffset: 0.85, // metres the band is pushed along the shadow direction + shadowDepth: 0.5, // how dark the middle of the band goes, unitless + shadowPool: 1.4, // metres one spear's pooling spreads along the band + shadowEdge: 0.7, // metres of feather on the band's edges + shadowRagged: 0.3, // how far the front wanders, fraction of the radius + shadowRaggedScale: 0.55, // lobes per metre + shadowWarp: 0.6, // metres of domain warp on those lobes + shadowWander: 0.35, // how far the band drifts off the cast line, metres + shadowSharp: 0.35, // 0..1 how hard the band's own edge is + shadowRelief: 0.12, // how much the fake normal sculpts it — a shadow is flat + shadowAmbient: 0.72, // floor on its diffuse term, so it does not read as a pit + shadowOpacity: 0.85, + shadowEmissive: 0.35, // multiplier on the one glowing term, the leading edge + shadowMarkLife: 5.0, // seconds a pooled deepening lasts + shadowHeight: 0.014, // metres above the floor the quad sits at + colorShadow: '#0d0a14', // the band + colorShadowDeep: '#020104', // under a spear + colorShadowEdge: '#3a2a55', // its lip + colorShadowFront: '#6a4aa8', // the line the front draws as it travels + + /* --- gloom and grit --- */ + /** + * Two systems, both **non-additive** — deliberately. An additive haze around + * a black object is a grey object, and every version of this that had one + * looked like smoke with sticks in it. + */ + gloomRate: 46, // dark haze off the bases, particles/second + gloomSize: 1.15, + gloomSpeed: 0.6, + gloomLifetime: 2.0, + gloomOpacity: 0.3, + gloomRise: 0.24, // upward drift, metres/second + colorGloomA: '#1a1424', + colorGloomB: '#120d1a', + colorGloomC: '#0a0710', + colorGloomD: '#050308', + gritRate: 30, // chips kicked up as a spear breaks through, particles/second + gritSize: 0.05, + gritSpeed: 3.4, + gritLifetime: 1.1, + gritGravity: -17.0, + breachGrit: 5, // chips one breaching spear throws + colorGritA: '#3a3140', + colorGritB: '#2a2430', + colorGritC: '#191420', + colorGritD: '#0d0a12', + + /* --- dynamic light --- */ + // It is here to *tint* the floor under the field, not to light it. At the + // shipped intensity it barely reaches the flagstones, which is the point: + // a bright violet key would undo the whole slot in one slider. + lightIntensity: 3.4, + lightRadius: 7.5, + lightColor: '#5a3aa0', + + /* --- the impact --- */ + impactShake: 0.45, + shakeDuration: 0.5, + rumble: 0.02, // continuous shake while the front travels + impactGrit: 70 // chips thrown where the front lands +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Umbral Spears. + * + * The folder to open first is **The rim**, because it is the only lit thing in + * the ability and every read the slot has comes out of five of its controls: + * `rimPower` and `rimInner` (line or wash), `rimShadowBias` (which side of the + * spear it appears on), `rimGain` (how loud) and `rimCeiling` (how loud it is + * allowed to be before the bloom pass takes over). + */ +export const umbralspearsSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 4, 200, 0.5, 'front speed'], + ['lifetime', 0.1, 8, 0.05, 'stand time'], + ['sinkDelay', 0, 3, 0.01, 'sink delay'], + ['sinkTime', 0.05, 4, 0.01, 'sink time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The field': [ + ['spearCount', 4, 240, 1, 'spears'], + ['clusterShare', 0, 0.6, 0.01, 'impact ring share'], + ['clusterRadius', -1, 6, 0.05, 'impact ring radius'], + ['widthNear', 0, 4, 0.01, 'band half-width, near'], + ['width', 0.05, 8, 0.01, 'band half-width, far'], + ['widthCurve', 0.2, 4, 0.01, 'band curve'], + ['frontBias', 0.2, 3, 0.01, 'crowd toward target'], + ['clumping', 0.2, 4, 0.01, 'clumping'], + ['scatter', 0, 2, 0.01, 'lateral scatter'] + ], + 'The silhouette': [ + ['heightNear', 0.05, 4, 0.01, 'height at caster'], + ['height', 0.1, 8, 0.01, 'height at target'], + ['heightCurve', 0.2, 4, 0.01, 'height curve'], + ['heightJitter', 0, 1, 0.01, 'height jitter'], + ['crown', 0, 1, 0.01, 'flank shortening'], + ['crownPower', 0.2, 4, 0.01, 'crown falloff'], + ['peak', 0.2, 3, 0.01, 'swell at target'], + ['peakWidth', 0.02, 1, 0.01, 'swell width'], + ['rubble', 0, 1, 0.01, 'stub chance'], + ['rubbleScale', 0.05, 1, 0.01, 'stub height'], + ['rubbleSpread', 0.5, 3, 0.01, 'stub girth'], + ['minHeight', 0.01, 1, 0.01, 'minimum height'], + ['radiusNear', 0.01, 1, 0.005, 'girth at caster'], + ['radius2', 0.01, 1, 0.005, 'girth at target'], + ['radiusCurve', 0.1, 3, 0.01, 'girth curve'], + ['radiusJitter', 0, 1, 0.01, 'girth jitter'], + ['minRadius', 0.005, 0.5, 0.005, 'minimum girth'] + ], + 'How they are aimed': [ + ['lean', -1.5, 1.5, 0.01, 'lean (rad)'], + ['leanJitter', 0, 1, 0.01, 'lean jitter'], + ['leanRamp', 0, 1, 0.01, 'lean ramp'], + ['leanForward', -2, 2, 0.01, 'lean downrange'], + ['leanOutward', -2, 2, 0.01, 'lean outward'], + ['shadowLean', 0, 3, 0.01, 'lean with the light'], + ['twist', 0, 1, 0.01, 'random yaw'], + ['tilt', 0, 1.5, 0.01, 'random tip (rad)'] + ], + 'The eruption': [ + ['riseTime', 0.02, 1.5, 0.01, 'rise time'], + ['riseStagger', 0, 1.5, 0.01, 'neighbour stagger'], + ['riseOvershoot', 0, 1, 0.01, 'overshoot'], + ['settle', 0.05, 2, 0.01, 'settle'], + ['springRate', 1, 50, 0.5, 'spring rate'], + ['emergeSink', 0, 1.5, 0.01, 'buried depth'], + ['birthScale', 0.1, 1, 0.01, 'breakthrough scale'], + ['birthFade', 0.02, 2, 0.01, 'birth decay'], + ['breachAt', 0.02, 1, 0.01, 'breach point'], + ['sinkDepth', 0, 3, 0.01, 'sink depth'] + ], + 'One spear': [ + ['spearSides', 4, 8, 1, 'facets'], + ['spearTaper', 0.02, 0.8, 0.01, 'shoulder taper'], + ['spearBarb', 0.3, 2, 0.01, 'barb flare'], + ['spearBarbAt', 0.05, 0.8, 0.01, 'barb height'], + ['spearRough', 0, 1, 0.01, 'facet roughness'], + ['spearTwist', -1.5, 1.5, 0.01, 'flute twist'] + ], + 'The rim': [ + ['rimPower', 0.5, 12, 0.05, 'rim tightness'], + ['rimInner', 0, 1, 0.01, 'rim thinning'], + ['rimGain', 0, 4, 0.01, 'rim gain'], + ['rimCeiling', 0.05, 1.2, 0.01, 'rim ceiling (bloom guard)'], + ['rimShadowBias', 0, 1, 0.01, 'prefer the unlit side'], + ['rimTip', 0, 1, 0.01, 'tip weighting'], + ['rimGrain', 0, 1, 0.01, 'rim erosion'], + ['rimGrainScale', 0.5, 30, 0.1, 'erosion / metre'], + ['birthRim', 0, 4, 0.01, 'birth rim'], + ['impactRim', 0, 4, 0.01, 'impact rim'], + ['impactRimTime', 0.05, 2, 0.01, 'impact rim decay'], + ['colorBody', 'body'], + ['colorRim', 'rim'], + ['colorBirth', 'birth rim'] + ], + 'The shadow': [ + ['shadowWidth', 0.1, 8, 0.05, 'band half-width'], + ['shadowOffset', -4, 4, 0.05, 'offset along the shadow'], + ['shadowDepth', 0, 2, 0.01, 'band darkness'], + ['shadowPool', 0.1, 6, 0.05, 'pooling reach'], + ['shadowEdge', 0.05, 3, 0.01, 'band feather'], + ['shadowRagged', 0, 1.5, 0.01, 'front wander'], + ['shadowRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['shadowWarp', 0, 3, 0.01, 'domain warp'], + ['shadowWander', 0, 2, 0.01, 'band drift'], + ['shadowSharp', 0, 1, 0.01, 'edge hardness'], + ['shadowRelief', 0, 2, 0.01, 'relief'], + ['shadowAmbient', 0, 1, 0.01, 'ambient floor'], + ['shadowOpacity', 0, 1, 0.01, 'opacity'], + ['shadowEmissive', 0, 2, 0.01, 'leading-edge glow'], + ['shadowMarkLife', 0.2, 20, 0.1, 'pooling lifetime'], + ['shadowHeight', 0.002, 0.2, 0.002, 'quad height'], + ['colorShadow', 'band'], + ['colorShadowDeep', 'under a spear'], + ['colorShadowEdge', 'lip'], + ['colorShadowFront', 'leading edge'] + ], + 'Gloom & grit': [ + ['gloomRate', 0, 400, 1, 'gloom rate'], + ['gloomSize', 0.05, 4, 0.01, 'gloom size'], + ['gloomSpeed', 0, 6, 0.05, 'gloom speed'], + ['gloomLifetime', 0.2, 8, 0.05, 'gloom lifetime'], + ['gloomOpacity', 0, 1, 0.005, 'gloom opacity'], + ['gloomRise', -2, 4, 0.01, 'gloom rise'], + ['gritRate', 0, 300, 1, 'grit rate'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 20, 0.1, 'grit speed'], + ['gritLifetime', 0.1, 5, 0.05, 'grit lifetime'], + ['gritGravity', -50, 0, 0.1, 'grit gravity'], + ['breachGrit', 0, 40, 1, 'chips per breach'], + ['colorGloom*', 'Gloom colour'], + ['colorGrit*', 'Grit colour'] + ], + 'The impact': [ + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['impactGrit', 0, 400, 1, 'impact grit'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 40, 0.5, 'light intensity'], + ['lightRadius', 0.5, 30, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/undertow.js b/src/config/abilities/undertow.js new file mode 100644 index 0000000..5b12d2a --- /dev/null +++ b/src/config/abilities/undertow.js @@ -0,0 +1,496 @@ +/* ================================================================== */ +/* UNDERTOW — tide · zone */ +/* ================================================================== */ +/** + * A whirlpool opens on the circle, and everything loose goes down it. + * + * **Two numbers are the ability.** `swirl` and `drain` are the circulation and + * the sink strength of an ideal vortex-sink, in metres² per second: + * + * ``` + * v_θ(r) = swirl / r the free vortex — how hard it turns + * v_r(r) = −drain / r the sink — how hard it pulls in + * ``` + * + * Their *ratio* is the whole shape of the cast. A streamline of that field + * satisfies `dr/dθ = −(drain/swirl)·r`, whose solution is `r = r₀·e^{−bθ}` with + * `b = drain/swirl` — a **logarithmic spiral**, the one spiral with a constant + * angle between the streamline and the radius. Turn `swirl` up and the arms + * wind tighter and the fall takes longer; turn `drain` up and they straighten + * out into a sink. There is no separate "spiral tightness" slider because there + * cannot be one: the tightness is `drain/swirl` and inventing a third control + * for it would let the picture and the physics disagree. + * + * `TiderushAbility`'s sibling trick is that the light on the floor is generated + * by the water. This one's is that **the water, the light and the debris are + * generated by the same flow field** — `UndertowAbility#flowAt()` is the only + * place either number is read, and the surface's drift, the caustic net's + * ripples and the debris' orbit are all it, evaluated somewhere different. + * + * **`coreRadius` matters more than it looks.** Both terms go as `1/r`, so + * without a floor the eye of the vortex is a singularity: infinite speed, + * infinite spin, and a debris ring that reaches the centre in one frame. It is + * the radius at which the vortex stops being ideal and starts being a hole, and + * it is also the radius the debris fades out at. + * + * **`poolHeight` is not decoration either.** The brief for this ability is that + * the debris goes *under* the surface and is seen through it, and a sheet of + * water sitting five centimetres off a stone floor has five centimetres of + * under. So the pool stands `poolHeight` metres proud, with its waterline + * feathered off, and there is genuinely a body of water for things to sink + * into. It is also exactly the depth the caustics are told the light crosses — + * one number, both consumers, which is the case where sharing *is* the design. + * + * The swell is authored in **mirror pairs** for the reason `tiderush`'s block + * gives at length: the caustic quad's frame is right-handed and + * `LiquidSurface`'s is not, so the net is the water's mirror image and only a + * wave set symmetric about the pool's own +X axis survives that mirror + * unchanged. + */ +export const undertow = { + /* --- the cast --- */ + range: 19.0, // how far out the circle can be placed, metres + minRange: 3.0, // closer than this and the cast is refused + zoneRadius: 6.5, // radius of the aimed circle, metres — the vortex's rim + speed: 26.0, // how fast the cast travels out to the circle, metres/second + pullTime: 2.8, // seconds the vortex holds open and pulls + closeTime: 1.5, // seconds it takes to shut + cooldown: 1.3, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- THE FLOW FIELD -------------------------------------------------- */ + // Read in exactly one place: `UndertowAbility#flowAt(radius, out)`. + swirl: 9.0, // m²/s of circulation. v_θ = swirl / r + drain: 2.2, // m²/s of sink. v_r = −drain / r + coreRadius: 0.55, // metres — where both are clamped and the eye begins + spinUp: 0.3, // 0..1 of the pull the field takes to reach full strength + flowSampleAt: 0.45, // 0..1 of the rim — the radius the surface's one drift vector is taken at + eddyShare: 0.45, // how much of the tangential flow is handed to the surface's curl term + radialFall: 6.0, // metres to 1/e of the surface's own radial term + + /* --- the pool -------------------------------------------------------- */ + poolHeight: 0.7, // metres the mean plane stands above the floor + poolFill: 1.0, // 0..1 of the half-extent the water reaches + poolRound: 1.0, // 0 rectangular footprint, 1 elliptical — a pool is round + poolEdge: 0.2, // 0..1 of the field over which the waterline fades out + poolRagged: 0.35, // 0..1 how ragged that line is + poolRaggedScale: 0.9, // cycles per metre of the raggedness + poolOpacity: 1.0, + contactFade: 0.25, // metres of soft fade where the water meets standing geometry + openCurve: 1.6, // exponent on how the pool grows out of the impact point + + /* --- the swell: four directional waves, in mirror pairs -------------- */ + swellAmpA: 0.085, // metres + swellAmpB: 0.085, // pair of A + swellAmpC: 0.045, + swellAmpD: 0.045, // pair of C + swellLengthA: 4.4, // metres, crest to crest + swellLengthB: 4.4, + swellLengthC: 2.0, + swellLengthD: 2.0, + swellSpeedA: 1.5, // metres/second + swellSpeedB: 1.5, + swellSpeedC: 1.0, + swellSpeedD: 1.0, + swellAngleA: 0.55, // radians in the pool's frame + swellAngleB: -0.55, // the mirror of A + swellAngleC: 1.42, + swellAngleD: -1.42, // the mirror of C + steepness: 0.4, // 0 sine, 1 Gerstner cusps + + /* --- chop and grain -------------------------------------------------- */ + chop: 0.05, // metres of high-frequency displacement + chopScale: 1.6, // cycles per metre + chopSpeed: 0.6, // metres/second the chop field drifts + detail: 0.014, // metres — fragment-only; lives entirely in the normal + detailScale: 7.6, // cycles per metre + detailSpeed: 1.05, + + /* --- ripples (something going under dimples the surface) -------------- */ + // `rippleAmp` is NEGATIVE on purpose: a packet posted where the vortex takes + // something down is a dimple that rings outward, not a splash. + rippleAmp: -0.13, // metres at strength 1 + rippleSpeed: 2.6, // metres/second the ring travels out + rippleLength: 0.95, // metres, crest to crest inside the packet + rippleWidth: 0.7, // metres of the gaussian envelope + rippleDecay: 1.0, // seconds to 1/e + rippleSpread: 2.8, // metres over which it also thins with radius + rippleRate: 3.2, // packets per second posted under the debris + + /* --- foam ------------------------------------------------------------ */ + // Seeded by surface speed, which the flow field supplies — so the froth ends + // up where the vortex is fastest, which is a ring around the eye. Nothing + // draws that ring; it is where `1/r` gets large. + foam: 0.9, // 0..1 master + foamScale: 6.0, // cycles per metre of the speckle + foamSharp: 1.2, + foamCrest: 0.4, // a pool has no lip, so this is nearly off + foamSpeed: 1.1, // how much surface speed seeds it — the important one here + foamGateLow: 0.6, // m/s below which the surface is calm and bare + foamGateHigh: 3.2, // m/s above which it is fully frothed + + /* --- shading --------------------------------------------------------- */ + poolDepth: 0.7, // metres of water under the mean plane, for the depth tint + depthTint: 1.15, // Beer-Lambert density in the body, per metre + translucency: 0.5, // a pool has no folded lip; kept low deliberately + ambient: 0.32, + specular: 1.5, + shininess: 78, // Blinn-Phong exponent + fresnel: 1.35, // high: a pool is read almost entirely by what it reflects + envIntensity: 0.9, + skyIntensity: 0.6, + glow: 1.0, + normalEps: 0.04, // metres — the finite-difference step of the shading normal + colorDeep: '#04202c', // the body + colorShallow: '#2b93a8', // thin water at the rim + colorFoam: '#e6faff', // froth + colorSpec: '#ffffff', // highlight + colorSky: '#33596f', // the sky fallback under the reflection + + /* --- the caustic net -------------------------------------------------- */ + // Bound to the pool's own uniform boxes. `depth` is `poolHeight` plus the + // bias below — the light crosses exactly the water that is standing there. + netHeight: 0.014, // metres the quad floats above the floor + netDepthBias: 0.25, // metres added to `poolHeight` for the fold's lever arm + netIor: 1.335, // water. The shader is handed 1 − 1/ior + netDispersion: 0.08, // 0..1 how far the red and blue folds sit either side of green + netStep: 0.07, // metres between the six Hessian taps + netAbsorb: 0.28, // 1/metres down the water column + netFoldFloor: 0.2, // keeps 1/|det| finite; also the widest a filament gets + netThreshold: 1.05, // compression below this is flat water, and black + netGain: 0.6, + netSharp: 1.35, // exponent on the surviving compression + netRolloff: 0.2, // soft clip, so a fold does not detonate the bloom + netPenumbra: 0.36, // 0..1 of the reach over which the pool of light dies + netReach: 1.05, // × the rim radius. Past ~1.1 the net throws light onto dry floor + netWash: 0.16, // the general lit-pool light between the filaments + netFringeAt: 1.5, // where on the fold the colour hands over to the fringe + netEmissive: 1.45, + netOpacity: 1.0, + netDepthFade: 0.4, // metres of soft fade against anything standing on the floor + colorNet: '#9fe8ff', // the filaments + colorFringe: '#ffffff', // the very top of a fold + colorWash: '#175a72', // the general light in the pool + + /* --- the debris ------------------------------------------------------- */ + // A flock on the vortex's own streamline: the lead's angle and radius are + // `flowAt` integrated, so dragging `swirl` re-winds the whole raft. + debrisCount: 176, // agents + debrisSize: 0.24, // metres, nose to tail + debrisAspect: 1.35, // span / length + debrisSizeJitter: 0.5, // ±fraction + debrisLatticeX: 5, // cells across + debrisLatticeY: 3, // cells up — this is the depth spread inside the water + debrisLatticeZ: 12, // ranks strung out behind the lead, in TIME + debrisSpacingSide: 0.62, // metres between lateral cells + debrisSpacingUp: 0.24, // metres between vertical cells + debrisLag: 0.85, // seconds the back rank trails the lead by — the arm's length + debrisJitter: 0.2, // metres of slop off the cell + debrisChurn: 0.5, // radians/second the formation rolls + debrisBreathe: 0.12, // fraction it swells by + debrisBreatheRate: 1.1, // radians/second + debrisWander: 0.12, // metres of curl drift + debrisWanderScale: 0.55, // features per metre + debrisWanderSpeed: 0.5, + debrisGather: 1.0, // 0 collapses every agent onto the streamline itself + debrisSinkRim: 0.22, // metres below the mean plane at the rim + debrisSinkCore: 0.62, // metres below it at the eye — this is the "pulled under" + debrisBank: 0.05, // radians of roll per m/s² of lateral acceleration + debrisBankMax: 1.0, // radians + debrisCurl: 0.3, // curl across the chord, fraction of size + debrisBillboard: 0.2, // 0 agent frame, 1 camera facing + debrisEdgeStretch: 1.7, // how much an edge-on card grows + debrisEdgeGain: 1.8, // emission multiplier when edge-on + debrisFadeIn: 0.14, // 0..1 of the fall over which new debris appears at the rim + debrisFadeOut: 0.26, // 0..1 of it over which it is swallowed at the eye + debrisRevealSpread: 0.5, // width of that wave — how staggered the vanishing is + debrisLit: 0.85, // 0 emissive, 1 wrapped diffuse + debrisTint: 0.3, // where in the gradient the raft sits + debrisTintJitter: 0.3, // ±per-agent walk along it + debrisTintAlong: 0.35, // extra walk from head to tail + debrisOpacity: 1.0, + debrisGlow: 0.5, + debrisSoftFade: 0.3, // metres of depth feather against solid geometry + colorDebrisA: '#8fb6bd', + colorDebrisB: '#4d7c88', + colorDebrisC: '#27505c', + colorDebrisD: '#0c2028', + + /* --- spume: the froth thrown off the surface, carried by the flow ----- */ + spumeRate: 130, // flecks/second + spumeSize: 0.06, // metres + spumeSpeed: 0.8, // metres/second ON TOP of the flow velocity it inherits + spumeLifetime: 1.3, // seconds + spumeGravity: -3.5, // metres/second² + spumeAt: 0.7, // 0..1 of the rim the flecks are born at + colorSpumeA: '#f0ffff', + colorSpumeB: '#a9e2ee', + colorSpumeC: '#4e8fa2', + colorSpumeD: '#12333f', + + /* --- the haze standing over the eye ----------------------------------- */ + mistRate: 40, // puffs/second + mistSize: 0.9, // metres + mistSpeed: 1.2, // metres/second + mistLifetime: 2.4, // seconds + mistRise: 0.6, // metres/second² + mistOpacity: 0.45, + mistTurbulence: 0.9, + colorMistA: '#cfeaf2', + colorMistB: '#93bcc8', + colorMistC: '#547c8a', + colorMistD: '#1e3038', + + /* --- grit: the fine stuff too small to be a debris agent -------------- */ + gritRate: 90, // chips/second + gritSize: 0.05, // metres + gritSpeed: 0.6, // metres/second on top of the flow it inherits + gritLifetime: 1.6, // seconds + gritGravity: -2.0, // metres/second² + colorGritA: '#a9c4c9', + colorGritB: '#63838c', + colorGritC: '#2f4c56', + colorGritD: '#0a181e', + + /* --- what it leaves on the floor --------------------------------------- */ + rimRate: 7.0, // marks/second laid around the waterline + rimRadius: 1.6, // metres + rimLife: 5.0, // seconds + rimIntensity: 0.9, + colorRimA: '#0c2731', // soaked stone + colorRimB: '#b7e4ef', // the drying line + + /* --- opening and closing ------------------------------------------------ */ + burstSize: 3.0, // metres — the gulp as it shuts + burstIntensity: 1.4, + burstSpume: 200, // flecks thrown out of the close + burstGrit: 140, + burstRipples: 3, // ripple packets posted at the close + shockRadius: 5.0, // metres + openShake: 0.35, + shakeDuration: 0.7, // seconds + openFlash: 0.07, // 0..1 screen flash + rumble: 0.075, // per-second shake while the vortex holds + colorBurstA: '#e6fbff', + colorBurstB: '#4fb6d0', + colorBurstC: '#0c3a4c', + colorShockA: '#d2f4ff', + colorShockB: '#31a0bd', + colorFlash: '#8fdcf0', + + /* --- dynamic light ------------------------------------------------------- */ + lightIntensity: 12.0, + lightRadius: 11.0, + lightHeight: 0.6, // metres above the pool the light sits + lightColor: '#6fdcf2' +}; + +/** Editor layout. Folder order is the order they are built in. */ +export const undertowSchema = { + 'The cast': [ + 'range', + 'minRange', + ['zoneRadius', 1, 16, 0.1, 'rim radius'], + 'speed', + ['pullTime', 0.2, 8, 0.05, 'pull time'], + ['closeTime', 0.2, 6, 0.05, 'close time'], + 'cooldown', + 'castAnim' + ], + 'The flow field': [ + ['swirl', 0, 40, 0.1, 'swirl (m²/s)'], + ['drain', 0.05, 20, 0.05, 'drain (m²/s)'], + ['coreRadius', 0.05, 4, 0.01, 'core radius'], + ['spinUp', 0.02, 1, 0.01, 'spin-up'], + ['flowSampleAt', 0.05, 1, 0.01, 'drift sampled at'], + ['eddyShare', 0, 2, 0.01, 'curl share'], + ['radialFall', 0.5, 20, 0.1, 'radial falloff'] + ], + 'The pool': [ + ['poolHeight', 0, 3, 0.01, 'height off the floor'], + ['poolFill', 0, 1, 0.01, 'fill'], + ['poolRound', 0, 1, 0.01, 'rectangular → elliptical'], + ['poolEdge', 0.01, 1, 0.01, 'waterline feather'], + ['poolRagged', 0, 1, 0.01, 'waterline raggedness'], + ['poolRaggedScale', 0.05, 4, 0.01, 'raggedness scale'], + ['poolOpacity', 0, 1, 0.01, 'opacity'], + ['contactFade', 0.02, 2, 0.01, 'soft intersection'], + ['openCurve', 0.2, 4, 0.01, 'opening curve'] + ], + 'The swell (mirror pairs)': [ + ['swellAmpA', 0, 1, 0.005, 'amp A'], + ['swellAmpB', 0, 1, 0.005, 'amp B — pairs A'], + ['swellAmpC', 0, 1, 0.005, 'amp C'], + ['swellAmpD', 0, 1, 0.005, 'amp D — pairs C'], + ['swellLengthA', 0.2, 20, 0.05, 'length A'], + ['swellLengthB', 0.2, 20, 0.05, 'length B'], + ['swellLengthC', 0.2, 20, 0.05, 'length C'], + ['swellLengthD', 0.2, 20, 0.05, 'length D'], + ['swellSpeedA', 0, 8, 0.05, 'speed A'], + ['swellSpeedB', 0, 8, 0.05, 'speed B'], + ['swellSpeedC', 0, 8, 0.05, 'speed C'], + ['swellSpeedD', 0, 8, 0.05, 'speed D'], + ['swellAngleA', -3.2, 3.2, 0.01, 'bearing A'], + ['swellAngleB', -3.2, 3.2, 0.01, 'bearing B'], + ['swellAngleC', -3.2, 3.2, 0.01, 'bearing C'], + ['swellAngleD', -3.2, 3.2, 0.01, 'bearing D'], + ['steepness', 0, 1.2, 0.01, 'Gerstner cusps'], + ['chop', 0, 0.4, 0.001, 'chop'], + ['chopScale', 0.05, 6, 0.01, 'chop scale'], + ['chopSpeed', 0, 4, 0.01, 'chop drift'], + ['detail', 0, 0.1, 0.001, 'normal grain'], + ['detailScale', 0.5, 20, 0.1, 'grain scale'], + ['detailSpeed', 0, 4, 0.01, 'grain drift'] + ], + 'Ripples & foam': [ + ['rippleAmp', -1, 1, 0.005, 'amplitude (negative = dimple)'], + ['rippleSpeed', 0.1, 12, 0.05, 'front speed'], + ['rippleLength', 0.1, 4, 0.01, 'wavelength'], + ['rippleWidth', 0.05, 3, 0.01, 'packet width'], + ['rippleDecay', 0.05, 5, 0.01, 'decay (s)'], + ['rippleSpread', 0.1, 10, 0.05, 'radial thinning'], + ['rippleRate', 0, 20, 0.1, 'packets / second'], + ['foam', 0, 1, 0.01, 'foam'], + ['foamScale', 0.5, 16, 0.05, 'foam speckle'], + ['foamSharp', 0.2, 4, 0.01, 'foam sharpness'], + ['foamCrest', 0, 3, 0.01, 'seeded by height'], + ['foamSpeed', 0, 3, 0.01, 'seeded by speed'], + ['foamGateLow', 0, 6, 0.01, 'calm below (m/s)'], + ['foamGateHigh', 0.1, 10, 0.05, 'frothed above (m/s)'] + ], + 'Shading': [ + ['poolDepth', 0, 3, 0.01, 'depth under the plane'], + ['depthTint', 0, 10, 0.01, 'absorption / metre'], + ['translucency', 0, 4, 0.01, 'backlight'], + ['ambient', 0, 1, 0.01, 'ambient floor'], + ['specular', 0, 6, 0.01, 'specular'], + ['shininess', 4, 256, 1, 'gloss'], + ['fresnel', 0, 3, 0.01, 'fresnel'], + ['envIntensity', 0, 3, 0.01, 'reflection'], + ['skyIntensity', 0, 3, 0.01, 'sky fallback'], + ['glow', 0, 4, 0.01, 'glow'], + ['normalEps', 0.005, 0.3, 0.001, 'normal step'], + ['colorDeep', 'body'], + ['colorShallow', 'thin water'], + ['colorFoam', 'froth'], + ['colorSpec', 'highlight'], + ['colorSky', 'sky fallback'] + ], + 'The caustic net': [ + ['netHeight', 0.002, 0.2, 0.001, 'height off the floor'], + ['netDepthBias', -1, 3, 0.01, 'depth over poolHeight'], + ['netIor', 1.01, 2, 0.001, 'index of refraction'], + ['netDispersion', 0, 0.4, 0.005, 'chromatic spread'], + ['netStep', 0.02, 0.4, 0.005, 'Hessian tap step'], + ['netAbsorb', 0, 2, 0.01, 'absorption / metre'], + ['netFoldFloor', 0.01, 1, 0.005, 'fold floor'], + ['netThreshold', 0, 4, 0.01, 'black-water threshold'], + ['netGain', 0, 3, 0.01, 'gain'], + ['netSharp', 0.1, 4, 0.01, 'sharpness'], + ['netRolloff', 0, 2, 0.01, 'soft clip'], + ['netPenumbra', 0.02, 1, 0.01, 'edge penumbra'], + ['netReach', 0.2, 3, 0.01, 'reach past the rim'], + ['netWash', 0, 1, 0.005, 'lit pool between filaments'], + ['netFringeAt', 0, 6, 0.01, 'fringe hand-over'], + ['netEmissive', 0, 5, 0.01, 'emissive'], + ['netOpacity', 0, 1, 0.01, 'opacity'], + ['netDepthFade', 0.02, 3, 0.01, 'soft intersection'], + ['colorNet', 'filaments'], + ['colorFringe', 'fold crest'], + ['colorWash', 'pool light'] + ], + 'The debris': [ + ['debrisCount', 0, 320, 1, 'agents'], + ['debrisSize', 0.02, 1.2, 0.005, 'size'], + ['debrisAspect', 0.2, 4, 0.01, 'aspect'], + ['debrisSizeJitter', 0, 1, 0.01, 'size jitter'], + ['debrisSinkRim', -1, 3, 0.01, 'depth at the rim'], + ['debrisSinkCore', -1, 4, 0.01, 'depth at the eye'], + ['debrisLatticeX', 1, 16, 1, 'cells across'], + ['debrisLatticeY', 1, 12, 1, 'cells deep'], + ['debrisLatticeZ', 1, 24, 1, 'ranks behind'], + ['debrisSpacingSide', 0.02, 2, 0.01, 'lateral spacing'], + ['debrisSpacingUp', 0.02, 2, 0.01, 'vertical spacing'], + ['debrisLag', 0.02, 3, 0.01, 'arm length (s)'], + ['debrisJitter', 0, 1, 0.01, 'cell slop'], + ['debrisChurn', -4, 4, 0.01, 'formation roll'], + ['debrisBreathe', 0, 1, 0.01, 'swell'], + ['debrisBreatheRate', 0, 8, 0.01, 'swell rate'], + ['debrisWander', 0, 1, 0.01, 'curl drift'], + ['debrisWanderScale', 0.05, 4, 0.01, 'drift scale'], + ['debrisWanderSpeed', 0, 4, 0.01, 'drift speed'], + ['debrisGather', 0, 1, 0.01, 'gather onto the streamline'], + ['debrisBank', 0, 0.5, 0.005, 'bank'], + ['debrisBankMax', 0, 3, 0.01, 'bank limit'], + ['debrisCurl', 0, 1, 0.01, 'chip curl'], + ['debrisBillboard', 0, 1, 0.01, 'camera facing'], + ['debrisEdgeStretch', 1, 4, 0.01, 'edge-on stretch'], + ['debrisEdgeGain', 0, 6, 0.01, 'edge-on gain'], + ['debrisFadeIn', 0.01, 1, 0.01, 'appears over'], + ['debrisFadeOut', 0.01, 1, 0.01, 'swallowed over'], + ['debrisRevealSpread', 0.02, 1, 0.01, 'reveal width'], + ['debrisLit', 0, 1, 0.01, 'lit vs emissive'], + ['debrisTint', 0, 1, 0.01, 'gradient position'], + ['debrisTintJitter', 0, 1, 0.01, 'gradient jitter'], + ['debrisTintAlong', 0, 1, 0.01, 'gradient along'], + ['debrisOpacity', 0, 1, 0.01, 'opacity'], + ['debrisGlow', 0, 4, 0.01, 'glow'], + ['debrisSoftFade', 0.02, 2, 0.01, 'soft intersection'], + ['colorDebris*', 'Debris colour'] + ], + 'Spume, mist & grit': [ + ['spumeRate', 0, 600, 1, 'spume rate'], + ['spumeSize', 0.005, 0.5, 0.005, 'spume size'], + ['spumeSpeed', 0, 12, 0.05, 'spume speed'], + ['spumeLifetime', 0.05, 4, 0.01, 'spume lifetime'], + ['spumeGravity', -30, 5, 0.1, 'spume gravity'], + ['spumeAt', 0.05, 1.4, 0.01, 'born at (× rim)'], + ['mistRate', 0, 400, 1, 'mist rate'], + ['mistSize', 0.05, 4, 0.01, 'mist size'], + ['mistSpeed', 0, 8, 0.05, 'mist speed'], + ['mistLifetime', 0.2, 8, 0.05, 'mist lifetime'], + ['mistRise', -2, 4, 0.01, 'mist rise'], + ['mistOpacity', 0, 1, 0.005, 'mist opacity'], + ['mistTurbulence', 0, 3, 0.01, 'mist turbulence'], + ['gritRate', 0, 400, 1, 'grit rate'], + ['gritSize', 0.005, 0.4, 0.005, 'grit size'], + ['gritSpeed', 0, 12, 0.05, 'grit speed'], + ['gritLifetime', 0.05, 4, 0.01, 'grit lifetime'], + ['gritGravity', -30, 5, 0.1, 'grit gravity'], + ['colorSpume*', 'Spume colour'], + ['colorMist*', 'Mist colour'], + ['colorGrit*', 'Grit colour'] + ], + 'The waterline': [ + ['rimRate', 0, 30, 0.1, 'marks / second'], + ['rimRadius', 0.2, 8, 0.05, 'mark radius'], + ['rimLife', 0.5, 20, 0.1, 'mark lifetime'], + ['rimIntensity', 0, 3, 0.01, 'mark intensity'], + ['colorRimA', 'soaked stone'], + ['colorRimB', 'drying line'] + ], + 'Opening & closing': [ + ['burstSize', 0.2, 12, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstSpume', 0, 800, 1, 'burst spume'], + ['burstGrit', 0, 600, 1, 'burst grit'], + ['burstRipples', 0, 8, 1, 'ripple packets'], + ['shockRadius', 0.5, 20, 0.1, 'shockwave radius'], + ['openShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['openFlash', 0, 1, 0.005, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'hold rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst crest'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'], + ['colorFlash', 'screen flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 60, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightHeight', -1, 4, 0.01, 'light height'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/unmake.js b/src/config/abilities/unmake.js new file mode 100644 index 0000000..45e7f9a --- /dev/null +++ b/src/config/abilities/unmake.js @@ -0,0 +1,399 @@ +/* ================================================================== */ +/* UNMAKE — Unmake */ +/* ================================================================== */ +/** + * A lane of the room's own substance is heaved out of the floor and then + * **stops existing**, cube by cube, in pieces that get bigger as it goes. + * + * The block divides in two and the seam between them is the whole ability. + * Everything under *the lane* builds matter — a run of near-cubic ashlar blocks + * on a `GrowthField`, a trench on a `GroundField`, a stone material with a + * lattice printed on it. Everything under *the unmaking* takes it away, through + * the `VOXEL` half of `vfx/Dissolve.js`, and the numbers that matter there are + * only three: + * + * - **`cellSize`** — the *finest* cube, measured in a block's own units, where + * a block is exactly 1 unit on a side. 0.2 means five cubes across a block + * on the first rung. + * - **`rungs`** — how many doublings the ladder walks. Five means the last + * cubes are **sixteen times** the first, and that ratio is the acceleration + * the roster line asks for. Turn it down to 1 and the ability becomes a + * uniform crumble, which is a perfectly good effect and a completely + * different sentence. + * - **`take`** — how much of what is left each rung claims before handing on. + * Low values leave more material for the coarse rungs, so the loss is + * back-loaded and the acceleration is steeper. + * + * **Why the blocks are cubes and not slabs.** The voxel lattice is *object + * space* — it has to be, because the displacement it produces is added to + * `transformed` — so an instance scaled 1 : 3 is partitioned into cuboids of + * the same 1 : 3, and you get dominoes. `GrowthField` scales an instance + * `(radius, height, radius)`, so `blockNear` / `blockFar` feed *both* axes from + * one number and `blockJitter` is deliberately small. This ability is called + * Unmake, not Unbrick. + * + * **Why `blockSegments` is a real control and not a quality setting.** A cell + * with no vertex in it cannot become a cube. At `cellSize` 0.2 a block is five + * cells across, so twelve segments puts roughly two and a half vertices along + * each cell edge — enough for a chunk to hold a face. Drop the segments and the + * lattice starts claiming whole blocks at a time; raise `cellSize` past + * `1 / blockSegments × 3` and you will see the same thing. + * + * **The erosion half of the patch ships switched off.** `erode` is 0 on + * purpose: this is the cube slot, and a burning threshold edge crossing the + * blocks at the same time reads as two abilities in a coat. The controls are + * here because turning `erode` up is the fastest way to see what the other half + * of `Dissolve.js` does. + */ +export const unmake = { + /* --- the cast --- */ + range: 21.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 34.0, // how fast the heave front runs the line, metres/second + standTime: 0.5, // seconds the lane stands whole before it starts to go + unmakeTime: 1.15, // seconds the dissolve takes to run 0 → 1 + settleTime: 0.55, // seconds after that for the last motes to clear + cooldown: 1.1, // seconds before it can be cast again + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the lane on the floor --- */ + blockCount: 68, // blocks this cast (hard ceiling 160) + clusterShare: 0.18, // 0..1 of them held back for the pile at the far end + clusterRadius: 1.7, // metres that pile reaches, < 0 derives from the band + widthNear: 0.5, // metres, half-width of the lane at the caster + width: 1.8, // metres, at the far end + widthCurve: 1.15, // >1 keeps the lane narrow, then opens it late + frontBias: 0.95, // <1 crowds the blocks toward the far end + clumping: 1.15, // >1 pulls them onto the centre line + scatter: 0.45, // extra lateral jitter, fraction of the local half-width + + /* --- one block --- */ + // `blockNear`/`blockFar` drive footprint *and* height from one number. See + // the header: the lattice is object space and a non-cubic instance gives + // non-cubic chunks. + blockNear: 0.62, // metres — cube side at the caster + blockFar: 1.02, // metres — cube side at the far end + blockCurve: 1.05, // how late the size ramp climbs + blockJitter: 0.18, // ± fraction. Kept small on purpose — see the header. + crown: 0.35, // 0..1 how much smaller a flank block is than the spine + crownPower: 1.5, // how sharply that dome falls off + peak: 1.15, // extra size multiplier at the far end + peakWidth: 0.22, // 0..1 of the cast that swell covers + rubble: 0.24, // 0..1 chance a block is demoted to broken masonry + rubbleScale: 0.42, // size multiplier for those + rubbleSpread: 1.15, // footprint multiplier for those + minBlock: 0.06, // metres, floor + + /* --- the block's silhouette (rebuilds the geometry when they move) --- */ + blockSegments: 12, // subdivisions per cube edge — read the header before moving + blockRound: 0.22, // 0 a hard cube, 1 a sphere. The chamfer on a quarried stone. + blockChip: 0.3, // 0..1 how far the corners are knocked off + blockChipScale: 3.2, // chip features per unit — how coarse the damage is + + /* --- how they sit --- */ + lean: 0.12, // radians off vertical + leanJitter: 0.6, // ± fraction + leanRamp: 0.5, // 0 leans everything equally, 1 only the far end + leanForward: 0.5, // weight of "away from the caster" in the lean direction + leanOutward: 0.7, // weight of "out across the lane" + twist: 1.0, // 0..1 of a full turn of random yaw + tilt: 0.1, // radians of extra random tip, any bearing + + /* --- the heave --- */ + riseTime: 0.14, // seconds from buried to standing + riseStagger: 0.1, // seconds of random delay between neighbours + riseOvershoot: 0.3, // how far past full height the punch carries + settle: 0.4, // seconds that overshoot damps out over + springRate: 18.0, // radians/second of the overshoot ring + emergeSink: 0.95, // fraction of its size a block is buried at emerge = 0 + birthScale: 0.78, // footprint scale at the moment it breaks through + birthFade: 0.22, // seconds the birth flash decays over + breachAt: 0.2, // emergence fraction that fires the breach event + + /* --- the unmaking: the VOXEL ladder --- */ + cellSize: 0.2, // the FINEST cube, in block-local units (a block is 1 unit) + rungs: 5, // doublings; 5 means the last cubes are 16× the first + take: 0.5, // fraction of cells a rung claims before handing on + span: 0.26, // progress units one cell takes to let go + blockiness: 0.85, // 0..1 how hard a chunk snaps to the lattice. 0 is a shatter. + facet: 0.5, // sub-lattice size, as a fraction of the cell + hold: 0.4, // 0..1 of a chunk's life before it starts shrinking away + drift: 1.05, // metres a chunk travels + driftUp: 0.42, // unitless upward preference in the block's own frame + lift: 0.38, // metres up, linear in the chunk's life + gravity: 0.5, // metres of fall, on life squared + tumble: 2.1, // radians a chunk turns before it goes + wobble: 0.07, // metres of hashed wander + wobbleRate: 5.0, // radians/second of that wander + colorEmber: '#b98cff', // a chunk lighting up as it lets go + unmakeGlow: 1.35, // multiplier on the ember and the erosion edge + + /* --- the erosion half, shipped switched off. See the header. --- */ + erode: 0, // 0..1 — how much of the threshold behaviour is on + noiseScale: 3.2, // features per block unit + warp: 0.4, // domain warp — the ragged re-entrant edge + edgeWidth: 0.1, // width of the burning band, noise units + colorEdge: '#cfa8ff', // that band + + /* --- the matter --- */ + colorStone: '#191521', // the block itself + colorSeam: '#7d4fd6', // the lattice printed along the cell boundaries + colorBirth: '#c7a4ff', // the flash as a block breaks the surface + stoneRoughness: 0.85, + stoneMetalness: 0.06, // just enough for the key light to find an edge + seamWidth: 0.06, // fraction of a cell — half-width of a printed seam + seamGlow: 0.85, // brightness of the seams before the ceiling + seamCeiling: 0.8, // hard asymptote, linear HDR — keep below post.bloomThreshold + seamFade: 0.7, // 0..1 how far the seams dim once their cube is moving + grain: 0.35, // 0..1 how far world-space grain eats the seams + grainScale: 5.5, // grain features per metre + birthFlash: 1.1, // extra seam brightness on a block that has just arrived + + /* --- the trench the matter came out of --- */ + trenchWidth: 1.7, // metres, half-width of the track + trenchDepth: 0.55, // how deep the gouge reads, unitless + trenchLift: 0.06, // metres of spoil thrown up along its lips + trenchThickness: 0.05, // metres of lip + trenchEdge: 0.5, // metres of feather on the track's edges + trenchRagged: 0.34, // how far the edge wanders, fraction of the radius + trenchRaggedScale: 0.6, // lobes per metre + trenchWarp: 0.5, // metres of domain warp on those lobes + trenchSharp: 0.45, // 0..1 how hard the gouge's own edge is + trenchDetail: 0.5, // 0..1 interior detail + trenchRelief: 0.75, // how hard the fake normal sculpts it + trenchAmbient: 0.3, // floor on its diffuse term + trenchOpacity: 0.9, + trenchEmissive: 0.5, // multiplier on the one glowing term + trenchMarkLife: 6.0, // seconds a breach deepening lasts + trenchHeight: 0.016, // metres above the floor the quad sits at + colorTrench: '#221d2c', // the gouged stone + colorTrenchDeep: '#08060c', // the bottom of it + colorTrenchEdge: '#5a4a72', // the lip + colorTrenchGlow: '#8a5fe0', // the line the heave front draws as it passes + + /* --- chips: the cubes too small to be geometry --- */ + // `chipSize` is a *base*: the emitter scales it by the ladder's current rung, + // so the chips coming off get bigger at exactly the same moments the + // geometry's cubes do. That agreement is most of why the acceleration reads. + chipRate: 150, // chips per second at full dissolve rate + chipSize: 0.045, // metres at rung 0 + chipRungGain: 0.55, // 0..1 how much of the ladder's growth the chips inherit + chipSpeed: 2.4, + chipLifetime: 1.15, + chipGravity: -9.0, + breachChips: 6, // chips one block throws as it breaks the surface + colorChipA: '#8d6fd0', + colorChipB: '#4b3a72', + colorChipC: '#241d33', + colorChipD: '#0e0b16', + + /* --- gloom: what is left where the matter was --- */ + gloomRate: 34, // particles/second + gloomSize: 1.05, + gloomSpeed: 0.55, + gloomLifetime: 2.1, + gloomOpacity: 0.28, + gloomRise: 0.3, // upward drift, metres/second + colorGloomA: '#231b31', + colorGloomB: '#171126', + colorGloomC: '#0d0917', + colorGloomD: '#05040a', + + /* --- motes: the flare of a cube that stops existing --- */ + moteRate: 90, // particles/second at full dissolve rate + moteSize: 0.075, + moteSpeed: 1.5, + moteLifetime: 0.85, + moteRise: 0.9, // upward drift, metres/second + moteTurbulence: 0.7, + colorMoteA: '#e2ccff', + colorMoteB: '#a778f0', + colorMoteC: '#5c3aa8', + colorMoteD: '#1a1030', + + /* --- dynamic light --- */ + lightIntensity: 5.5, + lightRadius: 9.0, + lightColor: '#6a3fc0', + + /* --- the beats you feel --- */ + heaveShake: 0.4, // knock as the front lands + shakeDuration: 0.5, + rumble: 0.025, // continuous shake while the front travels + unmakeRumble: 0.045, // continuous shake while the lane is coming apart + impactChips: 60 // chips thrown where the front lands +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Unmake. + * + * Open **The unmaking** first and drag `rungs` from 1 to 6 with the clock + * stopped mid-dissolve. That is the ability: at 1 every chunk is `cellSize` + * across and the lane crumbles evenly; at 6 the last of it leaves in pieces + * sixty-four times the size of the first, and the eye reads the difference as + * speed rather than as scale. + * + * `cellSize` and `blockSegments` are a pair — see the block header before you + * move either on its own. + */ +export const unmakeSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 4, 200, 0.5, 'front speed'], + ['standTime', 0.05, 4, 0.05, 'stand time'], + ['unmakeTime', 0.1, 6, 0.05, 'unmake time'], + ['settleTime', 0.05, 4, 0.05, 'settle time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The unmaking': [ + ['cellSize', 0.04, 0.6, 0.005, 'finest cube (block units)'], + ['rungs', 1, 6, 1, 'doublings'], + ['take', 0.05, 0.95, 0.01, 'claimed per rung'], + ['span', 0.02, 1, 0.01, 'time to let go'], + ['blockiness', 0, 1, 0.01, 'snap to the lattice'], + ['facet', 0.1, 1, 0.05, 'sub-lattice'], + ['hold', 0, 0.98, 0.01, 'hold before shrink'], + ['drift', 0, 6, 0.01, 'drift (m)'], + ['driftUp', -1, 2, 0.01, 'upward bias'], + ['lift', -2, 4, 0.01, 'lift (m)'], + ['gravity', 0, 8, 0.01, 'fall (m)'], + ['tumble', 0, 9, 0.01, 'tumble (rad)'], + ['wobble', 0, 0.5, 0.005, 'wander (m)'], + ['wobbleRate', 0, 20, 0.1, 'wander rate'], + ['unmakeGlow', 0, 4, 0.01, 'ember glow'], + ['colorEmber', 'ember'] + ], + 'The unmaking/Erosion (off by default)': [ + ['erode', 0, 1, 0.01, 'erosion on'], + ['noiseScale', 0.2, 12, 0.05, 'features / unit'], + ['warp', 0, 2, 0.01, 'domain warp'], + ['edgeWidth', 0.01, 0.5, 0.005, 'burn width'], + ['colorEdge', 'burning edge'] + ], + 'The lane': [ + ['blockCount', 4, 160, 1, 'blocks'], + ['clusterShare', 0, 0.6, 0.01, 'far pile share'], + ['clusterRadius', -1, 6, 0.05, 'far pile radius'], + ['widthNear', 0, 4, 0.01, 'lane half-width, near'], + ['width', 0.05, 8, 0.01, 'lane half-width, far'], + ['widthCurve', 0.2, 4, 0.01, 'lane curve'], + ['frontBias', 0.2, 3, 0.01, 'crowd toward target'], + ['clumping', 0.2, 4, 0.01, 'clumping'], + ['scatter', 0, 2, 0.01, 'lateral scatter'] + ], + 'One block': [ + ['blockNear', 0.05, 3, 0.01, 'cube side, near (m)'], + ['blockFar', 0.05, 4, 0.01, 'cube side, far (m)'], + ['blockCurve', 0.2, 4, 0.01, 'size curve'], + ['blockJitter', 0, 0.6, 0.01, 'size jitter'], + ['crown', 0, 1, 0.01, 'flank shrinking'], + ['crownPower', 0.2, 4, 0.01, 'crown falloff'], + ['peak', 0.2, 3, 0.01, 'swell at target'], + ['peakWidth', 0.02, 1, 0.01, 'swell width'], + ['rubble', 0, 1, 0.01, 'broken chance'], + ['rubbleScale', 0.05, 1, 0.01, 'broken size'], + ['rubbleSpread', 0.5, 3, 0.01, 'broken spread'], + ['minBlock', 0.01, 1, 0.01, 'minimum side'], + ['blockSegments', 4, 18, 1, 'segments / edge'], + ['blockRound', 0, 1, 0.01, 'corner rounding'], + ['blockChip', 0, 1, 0.01, 'corner damage'], + ['blockChipScale', 0.5, 10, 0.1, 'damage / unit'] + ], + 'How they sit': [ + ['lean', -1.5, 1.5, 0.01, 'lean (rad)'], + ['leanJitter', 0, 1, 0.01, 'lean jitter'], + ['leanRamp', 0, 1, 0.01, 'lean ramp'], + ['leanForward', -2, 2, 0.01, 'lean downrange'], + ['leanOutward', -2, 2, 0.01, 'lean outward'], + ['twist', 0, 1, 0.01, 'random yaw'], + ['tilt', 0, 1.5, 0.01, 'random tip (rad)'] + ], + 'The heave': [ + ['riseTime', 0.02, 1.5, 0.01, 'rise time'], + ['riseStagger', 0, 1.5, 0.01, 'neighbour stagger'], + ['riseOvershoot', 0, 1, 0.01, 'overshoot'], + ['settle', 0.05, 2, 0.01, 'settle'], + ['springRate', 1, 50, 0.5, 'spring rate'], + ['emergeSink', 0, 1.5, 0.01, 'buried depth'], + ['birthScale', 0.1, 1, 0.01, 'breakthrough scale'], + ['birthFade', 0.02, 2, 0.01, 'birth decay'], + ['breachAt', 0.02, 1, 0.01, 'breach point'] + ], + 'The matter': [ + ['stoneRoughness', 0.05, 1, 0.01, 'roughness'], + ['stoneMetalness', 0, 1, 0.01, 'metalness'], + ['seamWidth', 0.005, 0.3, 0.005, 'seam half-width'], + ['seamGlow', 0, 4, 0.01, 'seam glow'], + ['seamCeiling', 0.05, 1.2, 0.01, 'seam ceiling (bloom guard)'], + ['seamFade', 0, 1, 0.01, 'seam dimming'], + ['grain', 0, 1, 0.01, 'seam erosion'], + ['grainScale', 0.5, 30, 0.1, 'grain / metre'], + ['birthFlash', 0, 4, 0.01, 'birth flash'], + ['colorStone', 'stone'], + ['colorSeam', 'seam'], + ['colorBirth', 'birth flash'] + ], + 'The trench': [ + ['trenchWidth', 0.1, 8, 0.05, 'half-width'], + ['trenchDepth', 0, 2, 0.01, 'depth'], + ['trenchLift', 0, 1, 0.01, 'spoil lip (m)'], + ['trenchThickness', 0, 0.5, 0.005, 'lip thickness (m)'], + ['trenchEdge', 0.05, 3, 0.01, 'feather'], + ['trenchRagged', 0, 1.5, 0.01, 'edge wander'], + ['trenchRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['trenchWarp', 0, 3, 0.01, 'domain warp'], + ['trenchSharp', 0, 1, 0.01, 'edge hardness'], + ['trenchDetail', 0, 1, 0.01, 'interior detail'], + ['trenchRelief', 0, 2, 0.01, 'relief'], + ['trenchAmbient', 0, 1, 0.01, 'ambient floor'], + ['trenchOpacity', 0, 1, 0.01, 'opacity'], + ['trenchEmissive', 0, 2, 0.01, 'front glow'], + ['trenchMarkLife', 0.2, 20, 0.1, 'breach mark life'], + ['trenchHeight', 0.002, 0.2, 0.002, 'quad height'], + ['colorTrench', 'gouged stone'], + ['colorTrenchDeep', 'the bottom'], + ['colorTrenchEdge', 'lip'], + ['colorTrenchGlow', 'heave front'] + ], + 'Chips, gloom & motes': [ + ['chipRate', 0, 600, 1, 'chip rate'], + ['chipSize', 0.005, 0.3, 0.005, 'chip size at rung 0'], + ['chipRungGain', 0, 1, 0.01, 'chips follow the ladder'], + ['chipSpeed', 0, 20, 0.1, 'chip speed'], + ['chipLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['chipGravity', -50, 0, 0.1, 'chip gravity'], + ['breachChips', 0, 40, 1, 'chips per breach'], + ['gloomRate', 0, 400, 1, 'gloom rate'], + ['gloomSize', 0.05, 4, 0.01, 'gloom size'], + ['gloomSpeed', 0, 6, 0.05, 'gloom speed'], + ['gloomLifetime', 0.2, 8, 0.05, 'gloom lifetime'], + ['gloomOpacity', 0, 1, 0.005, 'gloom opacity'], + ['gloomRise', -2, 4, 0.01, 'gloom rise'], + ['moteRate', 0, 400, 1, 'mote rate'], + ['moteSize', 0.005, 0.5, 0.005, 'mote size'], + ['moteSpeed', 0, 10, 0.05, 'mote speed'], + ['moteLifetime', 0.1, 5, 0.05, 'mote lifetime'], + ['moteRise', -2, 6, 0.01, 'mote rise'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorChip*', 'Chip colour'], + ['colorGloom*', 'Gloom colour'], + ['colorMote*', 'Mote colour'] + ], + 'The beats you feel': [ + ['heaveShake', 0, 3, 0.01, 'landing knock'], + ['shakeDuration', 0.1, 4, 0.01, 'knock duration'], + ['rumble', 0, 0.3, 0.002, 'travel rumble'], + ['unmakeRumble', 0, 0.3, 0.002, 'unmaking rumble'], + ['impactChips', 0, 400, 1, 'impact chips'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 40, 0.5, 'light intensity'], + ['lightRadius', 0.5, 30, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/vinelash.js b/src/config/abilities/vinelash.js new file mode 100644 index 0000000..60dae7f --- /dev/null +++ b/src/config/abilities/vinelash.js @@ -0,0 +1,572 @@ +/* ================================================================== */ +/* VINELASH — Verdant Lash */ +/* ================================================================== */ +/** + * A vine that **grows** down the aimed line rather than flying along it, and + * then snaps back like a released branch. + * + * Three things carry the read, and each one is a separate group below: + * + * 1. **the stem** — a `Tube` on the `VINE` path. The front *is* the length + * (`vineTipTaper` runs the radius to nothing at the growing tip), and + * `vineRecoilAmp/Freq/Damp` are a damped cosine that hauls the whole curve + * back at full extension and lets it overshoot. Every one of those is a + * live slider, so a paused vine re-springs under the mouse. + * 2. **the bark** — a swept mesh whose vertices are re-read off the tube's own + * `pointAt()` / `radiusAt()` every frame, shaded with a *lengthwise* ridged + * field and a wet highlight on whichever facets happen to point up. This is + * the solid, lit, shadow-casting body; the tube behind it is only the sap + * light bleeding out around its silhouette and blazing at the tip. + * 3. **the leaves** — a `Swarm` of `LEAF` agents strung out behind a lead that + * rides the growing front, so they unfurl along the stem as it passes; and + * a torn-leaf particle system that the *snap-back speed* feeds, so the + * stripping is a consequence of the geometry rather than a scheduled event. + * + * ### Why the seventy-nine `vine*` keys are spelled out here + * + * `Tube` reads a **prefixed** block (`c[keys.radius]` → `c.vineRadius`), and + * `vfx/Tube.js` exports `tubeDefaults('vine', TubePath.VINE)` which returns + * exactly this fragment in one line. It is written out instead, for two + * reasons. The first is the house rule that every settings field carries a + * trailing comment naming what it is and in what unit — a spread fragment + * carries none, and eighty undocumented sliders is eighty sliders nobody will + * touch. The second is an import cycle: `vfx/Tube.js` imports + * `config/settings.js`, which imports `config/abilities/index.js`, which would + * import this module — and whichever of the three a consumer happens to load + * first decides whether `tubeDefaults` is initialised by the time this file + * evaluates. Settings modules stay import-free, as `config/abilities/index.js` + * says they must. + * + * The keys the other four tube paths use (`vineWave*`, the `FUNNEL` group, the + * `ARC` group) are present and inert. They are here because `Tube#_audit` + * warns once naming every key a block is missing, and because re-pathing this + * ability is then a one-word change to the constructor rather than a settings + * migration. + */ +export const vinelash = { + /* --- the cast --- */ + range: 17.0, // maximum cast distance, metres + minRange: 2.5, // closer than this and the cast is refused + speed: 26.0, // how fast the growing tip travels, metres/second + lifetime: 0.9, // seconds the vine holds and rings after the snap + fadeTime: 1.1, // seconds it takes to wither + cooldown: 0.9, // seconds before it can be cast again + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- where the stem leaves the caster --- */ + handHeight: 1.24, // metres above the floor the vine sprouts at + handForward: 0.5, // metres in front of the caster + handSide: 0.22, // metres to the side (+ follows `Ability#side`) + endHeight: 0.55, // height of the stem where it reaches the target, metres + + /* ------------------------------------------------------------------ */ + /* The stem — `Tube`, path VINE, prefix `vine` */ + /* ------------------------------------------------------------------ */ + + /* --- the radius profile (VINE reads `vineRadius` and `vineTipTaper`) --- */ + vineRadius: 0.19, // half-width of the stem at the base, metres + vineRadiusNear: 0.22, // (inert on VINE) half-width at the muzzle, metres + vineRadiusCurve: 0.7, // (inert on VINE) <1 opens early, >1 opens late + vineFlare: 0.0, // (inert on VINE) extra half-width where it lands, × radius + vineFlareWidth: 0.22, // (inert on VINE) fraction of the length that flares + vineThrob: 0.035, // breathing amplitude of the stem, × radius + vineThrobScale: 3.4, // pressure waves along the stem, cycles per length + vineThrobSpeed: 1.1, // Hz they travel at + + /* --- the axis --- */ + vineWander: 0.035, // smooth low-frequency drift of the axis, metres + vineWanderScale: 1.4, // drift features per length + vineWanderSpeed: 0.55, // Hz the drift crawls at + + /* --- the surface of the sap column --- */ + vineRipple: 0.22, // radial break-up of the barrel, × radius + vineRippleBands: 2.4, // break-up features around the barrel + vineRippleScale: 4.2, // break-up features along it + vineRippleSpeed: 1.2, // Hz it crawls downrange at + vineStreak: 0.55, // sap filaments streaming down the surface + vineStreakSharp: 0.62, // 0 = soft wash, 1 = hard threads + vineStreakScale: 7.5, // filament features per length + vineStreakBands: 3.2, // filament features around the barrel + vineStreakGlow: 0.9, // how hard the sheath's filaments burn to core colour + vineFlowSpeed: 1.6, // parameter-per-second the sap filaments run + vineBands: 9.0, // leaf-node rings along the stem, cycles per length + vineBandSharp: 2.6, // how tight each node ring is + vineBandDepth: 0.5, // how much the rings modulate alpha, 0..1 + vineBandSpeed: 0.0, // Hz the rings travel at (0 = nodes stay put) + + /* --- the three layers --- */ + vineCoreWidth: 0.5, // core radius, × the profile + vineCoreFill: 0.55, // how solid the sap core reads + vineCoreSharp: 1.5, // axis-weighting exponent — the inversion + vineEdgePower: 2.4, // rim-weighting exponent for the sheath + vineSheathWidth: 1.12, // sheath radius, × the profile + vineSheathRim: 1.0, // strength of the sheath's silhouette + vineSheathFill: 0.14, // how much body the sheath keeps + vineSheathOpacity: 0.85, // opacity of the sheath pass + vineHaloWidth: 1.75, // halo radius, × the profile + vineHaloRim: 2.8, // rim exponent — high, so it is only a silhouette + vineHaloOpacity: 0.42, // opacity of the halo pass + + /* --- the ends --- */ + vineMuzzleGlow: 1.2, // brightness where the stem leaves the caster + vineMuzzleLength: 0.08, // how far that glow reaches, fraction of length + vineTipGlow: 2.4, // brightness of the growing tip + vineTipLength: 0.05, // how soft that tip is, fraction of length + + /* --- WHIP group: inert on the VINE path --- */ + vineWaveRate: 1.35, // (inert) loops per second travelling handle → tip + vineWaveWidth: 0.16, // (inert) how tight the loop is, fraction of length + vineWaveAmp: 0.3, // (inert) lateral throw of the loop, fraction of length + vineWaveGain: 2.2, // (inert) how much the loop grows toward the tip, × + vineWaveCurve: 1.6, // (inert) when that growth happens, >1 = late + vineWaveRoll: 0.0, // (inert) plane the loop cracks in, radians + vineSag: 0.12, // (inert) how far a whip hangs under its weight, metres + vineCrackRatio: 1.0, // (inert) tip ÷ wave speed at which a whip cracks + + /* --- FUNNEL group: inert on the VINE path --- */ + vineThroat: 0.55, // (inert) the vortex waist, metres + vineSkirtFlare: 1.6, // (inert) extra radius at the floor, metres + vineSkirtHeight: 0.24, // (inert) how far up the skirt reaches, fraction + vineSkirtCurve: 1.7, // (inert) how abruptly it flares + vineMouthFlare: 2.4, // (inert) extra radius at the top, metres + vineMouthStart: 0.55, // (inert) where the mouth opens, fraction of height + vineMouthCurve: 1.4, // (inert) how abruptly it opens + vineSpin: 0.9, // (inert) revolutions per second the surface rotates + vineSpinTwist: 1.6, // (inert) extra revolutions floor → mouth + vineSway: 0.35, // (inert) how far the axis precesses, metres + vineSwayScale: 0.5, // (inert) twist of the precession along the height + vineSwaySpeed: 0.25, // (inert) revolutions per second it precesses + vineSwayCurve: 1.8, // (inert) how much of the sway is at the top + + /* --- VINE group: the ones that matter --- */ + vineTipTaper: 1.45, // how fast the radius falls to zero at the front + vineMeander: 0.26, // helical wander of the stem, metres + vineMeanderTurns: 1.15, // turns of that helix over the length + vineRecoilAmp: 0.42, // how far the spring hauls the tip back, fraction + vineRecoilFreq: 2.35, // Hz the recoil spring rings at + vineRecoilDamp: 3.1, // s⁻¹ the ringing dies at + vineRecoilSway: 0.85, // lateral bow while it is recoiling, metres + + /* --- ARC group: inert on the VINE path --- */ + vineArcHeight: 2.6, // (inert) apex height above the chord, metres + vineArcLateral: 0.0, // (inert) apex offset across the chord, metres + vineArcBias: 0.5, // (inert) where the apex sits, 0..1 along the chord + vineArcCurve: 1.0, // (inert) >1 pinches the apex, <1 flattens the top + + /* --- rendering --- */ + vineOpacity: 0.95, // master alpha of the three sap layers + vineGlow: 1.35, // emissive gain into bloom + vineSoftFade: 0.5, // metres of depth fade against the opaque scene + + /* --- the sap palette (I5: four pickers, none derived from another) --- */ + vineColorCore: '#eaffc8', // the axis-weighted middle of the sap column + vineColorInner: '#b6f07a', // just off the middle + vineColorOuter: '#4e9a2c', // the sheath body + vineColorHalo: '#123d12', // the outer bloom + + /* ------------------------------------------------------------------ */ + /* The bark — the swept, lit body */ + /* ------------------------------------------------------------------ */ + barkScale: 0.86, // bark radius, × the tube's own profile + barkSwell: 0.14, // extra radius on the node rings, × the profile + barkNodes: 9.0, // node swellings along the stem, cycles per metre-parameter + barkRidge: 0.75, // depth of the lengthwise grooves, 0..1 + barkRidgeScale: 0.55, // groove features per metre *along* the stem + barkRidgeBands: 3.6, // groove features *around* the stem + barkGrain: 0.32, // fine fibrous grain over the grooves, 0..1 + barkGrainScale: 9.0, // grain features per metre along the stem + barkGrainBands: 7.0, // grain features around the stem + barkDepth: 0.85, // how far a groove darkens toward `colorBarkDeep` + barkRoughness: 0.72, // roughness on a ridge + barkRoughnessWet: 0.24, // roughness in a groove, where the sap sits + barkEnv: 0.45, // environment-map intensity on the bark + sapGlow: 1.5, // emissive of the sap showing through the grooves + sapPulse: 0.55, // depth of the pulse of sap running up the stem, 0..1 + sapPulseScale: 1.4, // pulses per metre along the stem + sapPulseSpeed: 2.4, // metres/second the pulse travels + sheen: 1.6, // strength of the wet highlight on upward-facing bark + sheenPower: 5.5, // how tight that highlight band is; higher = narrower + glisten: 0.9, // moving specks inside the highlight, 0..1 + glistenScale: 26.0, // specks per metre + glistenSpeed: 0.7, // metres/second they crawl at + witherDarken: 0.9, // how far the bark goes to `colorWither` as it dies + colorBark: '#6ba83a', // the lit face of the bark + colorBarkDeep: '#2a4a18', // the bottom of a groove + colorSap: '#c8ff8a', // the sap glowing out of the grooves + colorSheen: '#d8ffb0', // the wet highlight along the top of the curve + colorWither: '#4a3a20', // what the bark goes to as the vine dies + + /* ------------------------------------------------------------------ */ + /* The leaves — `Swarm`, silhouette LEAF */ + /* ------------------------------------------------------------------ */ + leafCount: 84, // live leaves on the stem at full extension + leafSize: 0.29, // metres, stalk to tip + leafAspect: 0.78, // width ÷ length of one leaf + leafSizeJitter: 0.45, // ± fraction of per-leaf size variation + leafCurl: 0.42, // how far a leaf cups across its chord, fraction of size + leafBank: 0.05, // radians of roll per m/s² of lateral acceleration + leafBankMax: 1.4, // radians the bank is clamped to + leafFold: 0.22, // out-of-plane fold of the two halves, fraction of size + leafFlapRate: 2.6, // flutter cycles per second + leafLatticeX: 5, // formation cells across the stem + leafLatticeY: 3, // formation cells up + leafLatticeZ: 12, // ranks strung out behind the growing tip + leafSpacingSide: 0.28, // metres between lateral cells + leafSpacingUp: 0.24, // metres between vertical cells + leafSpan: 0.94, // fraction of the *grown* stem the leaves cover, 0..1 + leafJitter: 0.11, // metres of slop off the cell + leafChurn: 0.5, // radians/second the formation rolls + leafBreathe: 0.16, // fraction the formation swells by + leafBreatheRate: 1.9, // radians/second of that swell + leafWander: 0.09, // metres of curl drift — keep under half the spacing + leafWanderScale: 0.8, // drift features per metre + leafWanderSpeed: 0.6, // Hz the drift crawls at + leafGather: 0.92, // 0 collapses every leaf onto the stem's own line + leafLeadRise: 0.0, // metres the lead lofts at mid-span + leafRevealSpread: 0.3, // width of the unfurl wave, fraction of the stem + leafBillboard: 0.25, // 0 = a plate in the world, 1 = always camera-facing + leafEdgeStretch: 1.5, // how much an edge-on leaf grows so it stays visible + leafEdgeGain: 1.8, // emission multiplier when a leaf goes edge-on + leafLit: 0.72, // 0 emissive, 1 wrapped diffuse + leafTint: 0.35, // where in the gradient the flock sits + leafTintJitter: 0.32, // ± per-leaf walk along the gradient + leafTintAlong: 0.4, // extra walk from the tip back to the base + leafOpacity: 1.0, // master alpha of the leaves + leafGlow: 0.85, // emissive gain into bloom + leafSoftFade: 0.3, // metres of depth feather against solid geometry + colorLeafA: '#c8d86a', // new growth at the unfurling tip + colorLeafB: '#7fc84a', + colorLeafC: '#4b8a28', + colorLeafD: '#22400f', // the oldest leaves, back at the caster's hand + + /* ------------------------------------------------------------------ */ + /* The snap */ + /* ------------------------------------------------------------------ */ + /** + * The strip is driven by `Tube#tipSpeed`, which is the vine's own curve + * differentiated with respect to the spring's clock — so it is correct on a + * zero-length frame and a paused drag on `vineRecoilFreq` genuinely changes + * how hard the leaves come off. + */ + stripThreshold: 6.0, // metres/second of tip speed below which nothing strips + stripRate: 22.0, // torn leaves per second per (m/s) over the threshold + stripBurst: 90, // torn leaves thrown on the frame the snap fires + stripFade: 0.55, // seconds the standing leaves take to wink out after the snap + snapShake: 0.7, // camera shake on the snap + shakeDuration: 0.5, // seconds that shake takes to die + snapFlash: 0.16, // full-screen flash on the snap + rumble: 0.02, // continuous shake while the vine is growing + burstSize: 2.4, // the shell of leaf-litter at the tip, metres + burstIntensity: 1.15, // brightness of that shell + dustRadius: 1.5, // ground dust ring under the tip, metres + dustLife: 1.6, // seconds it lingers + dustIntensity: 0.55, // brightness of the dust ring + colorBurstA: '#4b8a28', // burst shell + colorBurstB: '#c8d86a', // burst body + colorBurstC: '#eaffc8', // burst filaments + colorSnapFlash: '#c8ff8a', // the screen flash on the snap + colorDust: '#6d6042', // the ground dust ring + + /* ------------------------------------------------------------------ */ + /* Torn leaves, pollen and bark chips */ + /* ------------------------------------------------------------------ */ + /** + * As everywhere else in the sandbox each system is coloured by a four-stop + * gradient sampled over the particle's own lifetime, `A` at birth through `D` + * as it dies. Spelled out rather than derived from the leaf palette, so the + * torn leaves can brown off while the ones still on the stem stay green. + */ + tornSize: 0.16, // torn-leaf size + tornSpeed: 4.5, // metres/second they are flung at + tornLifetime: 2.4, // seconds one lives + tornGravity: -4.2, // metres/second² — leaves fall slowly + tornSpin: 5.5, // radians/second they tumble at + colorTornA: '#d8f09a', + colorTornB: '#8fc84a', + colorTornC: '#5a8a2a', + colorTornD: '#3a4a1a', + pollenRate: 70, // motes drifting off the stem, particles/second + pollenSize: 0.045, // mote size + pollenSpeed: 0.9, // metres/second they leave the stem at + pollenLifetime: 2.1, // seconds one lives + pollenRise: 0.75, // upward drift, metres/second + pollenTurbulence: 0.85, // how hard the curl field pushes them around + colorPollenA: '#f2ffd0', + colorPollenB: '#c8ff8a', + colorPollenC: '#7fc84a', + colorPollenD: '#20400f', + chipRate: 26, // bark chips shed while the stem grows, particles/second + chipSize: 0.05, // chip size + chipSpeed: 2.6, // metres/second they are shed at + chipLifetime: 1.5, // seconds one lives + chipGravity: -13.0, // metres/second² + colorChipA: '#5c4a2a', + colorChipB: '#4a3a20', + colorChipC: '#332616', + colorChipD: '#241b10', + + /* --- dynamic light --- */ + lightIntensity: 14, // the sap light riding the growing tip + lightRadius: 11, // metres it reaches + lightColor: '#a8f060' // its colour +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Verdant Lash. + * + * The four controls that carry the character, reach for these first: + * `vineTipTaper` (how needle-like the growing front is), `vineMeander` (how + * much the stem writhes), `vineRecoilAmp` (how violently it snaps back) and + * `barkRidge` (whether it reads as bark or as a glowing hose). Everything in + * "The stem" is a `Tube` uniform read on the frame it changes, so the whole + * folder reshapes a vine that is already standing with the clock stopped. + */ +export const vinelashSchema = { + 'The cast': [ + ['range', 2, 40, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 3, 90, 0.5, 'growth speed'], + ['lifetime', 0.1, 5, 0.01, 'hold after the snap'], + ['fadeTime', 0.1, 5, 0.01, 'wither time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where it sprouts': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['endHeight', 0, 4, 0.01, 'height at target'] + ], + + 'The stem/The vine': [ + ['vineTipTaper', 0.05, 6, 0.01, 'tip taper'], + ['vineMeander', 0, 2, 0.01, 'meander (m)'], + ['vineMeanderTurns', 0, 8, 0.01, 'meander turns'], + ['vineRecoilAmp', 0, 1, 0.01, 'recoil'], + ['vineRecoilFreq', 0, 10, 0.01, 'recoil Hz'], + ['vineRecoilDamp', 0.1, 16, 0.01, 'recoil damping'], + ['vineRecoilSway', 0, 4, 0.01, 'recoil bow (m)'] + ], + 'The stem/Profile': [ + ['vineRadius', 0.01, 1.5, 0.005, 'stem radius (m)'], + ['vineThrob', 0, 0.5, 0.001, 'throb'], + ['vineThrobScale', 0, 12, 0.1, 'throb bands'], + ['vineThrobSpeed', 0, 8, 0.01, 'throb Hz'], + ['vineWander', 0, 1, 0.001, 'axis drift (m)'], + ['vineWanderScale', 0, 6, 0.01, 'drift scale'], + ['vineWanderSpeed', 0, 4, 0.01, 'drift Hz'] + ], + 'The stem/Core / sheath / halo': [ + ['vineCoreWidth', 0.02, 2, 0.01, 'core width'], + ['vineCoreFill', 0, 2, 0.01, 'core fill'], + ['vineCoreSharp', 0.05, 8, 0.01, 'core axis power'], + ['vineEdgePower', 0.05, 8, 0.01, 'sheath rim power'], + ['vineSheathWidth', 0.05, 3, 0.01, 'sheath width'], + ['vineSheathRim', 0, 2, 0.01, 'sheath rim'], + ['vineSheathFill', 0, 1, 0.01, 'sheath fill'], + ['vineSheathOpacity', 0, 1, 0.01, 'sheath opacity'], + ['vineHaloWidth', 0.05, 6, 0.01, 'halo width'], + ['vineHaloRim', 0.05, 10, 0.01, 'halo rim power'], + ['vineHaloOpacity', 0, 1, 0.01, 'halo opacity'] + ], + 'The stem/Sap surface': [ + ['vineRipple', 0, 1, 0.01, 'ripple'], + ['vineRippleBands', 0, 8, 0.01, 'ripple bands'], + ['vineRippleScale', 0, 12, 0.01, 'ripple scale'], + ['vineRippleSpeed', 0, 10, 0.01, 'ripple Hz'], + ['vineStreak', 0, 2, 0.01, 'sap filaments'], + ['vineStreakSharp', 0, 1, 0.01, 'filament sharpness'], + ['vineStreakScale', 0, 20, 0.1, 'filament scale'], + ['vineStreakBands', 0, 8, 0.01, 'filament bands'], + ['vineStreakGlow', 0, 3, 0.01, 'filament glow'], + ['vineFlowSpeed', -12, 24, 0.1, 'flow speed'], + ['vineBands', 0, 24, 0.1, 'node rings/length'], + ['vineBandSharp', 0.05, 8, 0.01, 'ring sharpness'], + ['vineBandDepth', 0, 1, 0.01, 'ring depth'], + ['vineBandSpeed', -6, 6, 0.01, 'ring Hz'] + ], + 'The stem/The ends': [ + ['vineMuzzleGlow', 0, 5, 0.01, 'root glow'], + ['vineMuzzleLength', 0, 0.6, 0.001, 'root glow length'], + ['vineTipGlow', 0, 5, 0.01, 'tip glow'], + ['vineTipLength', 0.001, 0.4, 0.001, 'tip length'] + ], + 'The stem/Sap colour & render': [ + ['vineColorCore', 'sap core'], + ['vineColorInner', 'sap inner'], + ['vineColorOuter', 'sap outer'], + ['vineColorHalo', 'sap halo'], + ['vineOpacity', 0, 1, 0.01, 'opacity'], + ['vineGlow', 0, 8, 0.01, 'glow'], + ['vineSoftFade', 0, 3, 0.01, 'soft fade (m)'] + ], + /** + * The keys the other four `TubePath`s use. Present so `Tube#_audit` stays + * quiet and so the stem can be re-pathed without a settings migration; + * dragging any of them does nothing on `VINE`, which is why they are filed + * together and last. + */ + 'The stem/Inert here (WHIP · FUNNEL · ARC)': [ + ['vineRadiusNear', 0.01, 4, 0.01, 'near radius (m)'], + ['vineRadiusCurve', 0.05, 4, 0.01, 'radius curve'], + ['vineFlare', 0, 4, 0.01, 'flare'], + ['vineFlareWidth', 0.01, 1, 0.01, 'flare width'], + ['vineWaveRate', 0, 6, 0.01, 'loops/second'], + ['vineWaveWidth', 0.02, 0.6, 0.001, 'loop width'], + ['vineWaveAmp', 0, 1, 0.001, 'loop throw'], + ['vineWaveGain', 0.2, 6, 0.01, 'loop gain'], + ['vineWaveCurve', 0.1, 6, 0.01, 'gain curve'], + ['vineWaveRoll', 0, 6.28318, 0.01, 'crack plane (rad)'], + ['vineSag', 0, 2, 0.01, 'sag (m)'], + ['vineCrackRatio', 0.2, 4, 0.01, 'crack ratio'], + ['vineThroat', 0.02, 4, 0.01, 'throat (m)'], + ['vineSkirtFlare', 0, 8, 0.01, 'skirt flare (m)'], + ['vineSkirtHeight', 0.01, 1, 0.01, 'skirt height'], + ['vineSkirtCurve', 0.1, 6, 0.01, 'skirt curve'], + ['vineMouthFlare', 0, 12, 0.01, 'mouth flare (m)'], + ['vineMouthStart', 0, 0.99, 0.01, 'mouth start'], + ['vineMouthCurve', 0.1, 6, 0.01, 'mouth curve'], + ['vineSpin', -6, 6, 0.01, 'spin (rev/s)'], + ['vineSpinTwist', -8, 8, 0.01, 'twist'], + ['vineSway', 0, 4, 0.01, 'precession (m)'], + ['vineSwayScale', 0, 3, 0.01, 'precession twist'], + ['vineSwaySpeed', -3, 3, 0.01, 'precession (rev/s)'], + ['vineSwayCurve', 0.1, 6, 0.01, 'precession curve'], + ['vineArcHeight', -12, 12, 0.01, 'apex height (m)'], + ['vineArcLateral', -12, 12, 0.01, 'apex offset (m)'], + ['vineArcBias', 0.05, 0.95, 0.01, 'apex position'], + ['vineArcCurve', 0.1, 4, 0.01, 'apex curve'] + ], + + 'The bark/Silhouette': [ + ['barkScale', 0.2, 1.6, 0.01, 'bark radius ×'], + ['barkSwell', 0, 0.8, 0.01, 'node swell ×'], + ['barkNodes', 0, 24, 0.1, 'nodes / metre'] + ], + 'The bark/Grooves & grain': [ + ['barkRidge', 0, 1, 0.01, 'groove depth'], + ['barkRidgeScale', 0.02, 4, 0.01, 'grooves / m along'], + ['barkRidgeBands', 0.5, 12, 0.05, 'grooves around'], + ['barkGrain', 0, 1, 0.01, 'grain'], + ['barkGrainScale', 0.5, 30, 0.1, 'grain / m along'], + ['barkGrainBands', 0.5, 24, 0.1, 'grain around'], + ['barkDepth', 0, 1, 0.01, 'groove darkening'], + ['barkRoughness', 0.02, 1, 0.01, 'ridge roughness'], + ['barkRoughnessWet', 0.02, 1, 0.01, 'groove roughness'], + ['barkEnv', 0, 2, 0.01, 'env intensity'] + ], + 'The bark/Sap & wet highlight': [ + ['sapGlow', 0, 6, 0.01, 'sap glow'], + ['sapPulse', 0, 1, 0.01, 'sap pulse depth'], + ['sapPulseScale', 0, 6, 0.01, 'pulses / metre'], + ['sapPulseSpeed', -8, 8, 0.01, 'pulse speed (m/s)'], + ['sheen', 0, 5, 0.01, 'wet highlight'], + ['sheenPower', 0.5, 20, 0.1, 'highlight tightness'], + ['glisten', 0, 2, 0.01, 'glisten'], + ['glistenScale', 1, 80, 0.5, 'glisten / metre'], + ['glistenSpeed', -4, 4, 0.01, 'glisten crawl (m/s)'], + ['witherDarken', 0, 1, 0.01, 'wither'] + ], + 'The bark/Colour': [ + ['colorBark', 'bark'], + ['colorBarkDeep', 'groove'], + ['colorSap', 'sap'], + ['colorSheen', 'wet highlight'], + ['colorWither', 'withered'] + ], + + 'The leaves/Formation': [ + ['leafCount', 0, 220, 1, 'leaves'], + ['leafLatticeX', 1, 16, 1, 'cells across'], + ['leafLatticeY', 1, 10, 1, 'cells up'], + ['leafLatticeZ', 1, 24, 1, 'ranks back'], + ['leafSpacingSide', 0.02, 2, 0.01, 'lateral spacing (m)'], + ['leafSpacingUp', 0.02, 2, 0.01, 'vertical spacing (m)'], + ['leafSpan', 0, 1.4, 0.01, 'stem coverage'], + ['leafJitter', 0, 1, 0.01, 'cell slop (m)'], + ['leafChurn', -4, 4, 0.01, 'roll (rad/s)'], + ['leafBreathe', 0, 1, 0.01, 'breathe'], + ['leafBreatheRate', 0, 8, 0.01, 'breathe (rad/s)'], + ['leafWander', 0, 1, 0.005, 'drift (m)'], + ['leafWanderScale', 0, 4, 0.01, 'drift / metre'], + ['leafWanderSpeed', 0, 4, 0.01, 'drift Hz'], + ['leafGather', 0, 1, 0.01, 'hug the stem'], + ['leafLeadRise', -2, 4, 0.01, 'lead loft (m)'], + ['leafRevealSpread', 0.01, 1, 0.01, 'unfurl wave width'] + ], + 'The leaves/The leaf': [ + ['leafSize', 0.02, 1.5, 0.005, 'size (m)'], + ['leafAspect', 0.1, 3, 0.01, 'aspect'], + ['leafSizeJitter', 0, 1, 0.01, 'size jitter'], + ['leafCurl', 0, 1.5, 0.01, 'cup'], + ['leafFold', 0, 1.5, 0.01, 'fold'], + ['leafFlapRate', 0, 14, 0.05, 'flutter Hz'], + ['leafBank', 0, 0.4, 0.005, 'bank / (m/s²)'], + ['leafBankMax', 0, 3, 0.01, 'bank limit (rad)'], + ['leafBillboard', 0, 1, 0.01, 'camera facing'], + ['leafEdgeStretch', 1, 4, 0.01, 'edge-on stretch'], + ['leafEdgeGain', 0, 6, 0.01, 'edge-on gain'] + ], + 'The leaves/Colour': [ + ['leafLit', 0, 1, 0.01, 'lit ↔ emissive'], + ['leafTint', 0, 1, 0.01, 'gradient position'], + ['leafTintJitter', 0, 1, 0.01, 'gradient jitter'], + ['leafTintAlong', 0, 1, 0.01, 'gradient along'], + ['leafOpacity', 0, 1, 0.01, 'opacity'], + ['leafGlow', 0, 4, 0.01, 'glow'], + ['leafSoftFade', 0.02, 2, 0.01, 'soft fade (m)'], + ['colorLeaf*', 'Leaf colour'] + ], + + 'The snap': [ + ['stripThreshold', 0, 40, 0.1, 'strip threshold (m/s)'], + ['stripRate', 0, 120, 0.5, 'strip rate / (m/s)'], + ['stripBurst', 0, 400, 1, 'leaves torn on the snap'], + ['stripFade', 0.05, 3, 0.01, 'standing leaves fade (s)'], + ['snapShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['snapFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'growth rumble'], + ['burstSize', 0.2, 10, 0.05, 'burst size (m)'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['dustRadius', 0.1, 8, 0.05, 'dust radius (m)'], + ['dustLife', 0.1, 8, 0.05, 'dust lifetime (s)'], + ['dustIntensity', 0, 3, 0.01, 'dust intensity'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst filaments'], + ['colorSnapFlash', 'snap flash colour'], + ['colorDust', 'dust colour'] + ], + 'Torn leaves & pollen': [ + ['tornSize', 0.005, 0.8, 0.005, 'torn leaf size'], + ['tornSpeed', 0, 25, 0.1, 'torn leaf speed'], + ['tornLifetime', 0.1, 8, 0.05, 'torn leaf lifetime'], + ['tornGravity', -30, 2, 0.1, 'torn leaf gravity'], + ['tornSpin', 0, 20, 0.1, 'torn leaf tumble'], + ['pollenRate', 0, 600, 1, 'pollen rate'], + ['pollenSize', 0.005, 0.4, 0.005, 'pollen size'], + ['pollenSpeed', 0, 12, 0.05, 'pollen speed'], + ['pollenLifetime', 0.1, 8, 0.05, 'pollen lifetime'], + ['pollenRise', -3, 8, 0.05, 'pollen rise'], + ['pollenTurbulence', 0, 3, 0.01, 'pollen turbulence'], + ['colorTorn*', 'Torn leaf colour'], + ['colorPollen*', 'Pollen colour'] + ], + 'Bark chips': [ + ['chipRate', 0, 300, 1, 'chip rate'], + ['chipSize', 0.005, 0.4, 0.005, 'chip size'], + ['chipSpeed', 0, 20, 0.1, 'chip speed'], + ['chipLifetime', 0.1, 5, 0.05, 'chip lifetime'], + ['chipGravity', -40, 0, 0.1, 'chip gravity'], + ['colorChip*', 'Chip colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 90, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/voidrift.js b/src/config/abilities/voidrift.js new file mode 100644 index 0000000..bfcefaa --- /dev/null +++ b/src/config/abilities/voidrift.js @@ -0,0 +1,364 @@ +/* ================================================================== */ +/* VOIDRIFT — the hole in the floor */ +/* ================================================================== */ +/** + * A line cast that does not put anything *on* the stage. It takes a piece of + * the stage away: a slit torn open along the aimed line, lying in the floor + * plane, with a different sky behind it. + * + * ### Why the numbers are shaped the way they are + * + * **The tear is two independent fronts, and that is the whole beat.** `open` + * sweeps a threshold on a ragged field, which unzips the wound *across* its + * width from the long centreline — that is `openTime`. `tearSpeed` marches the + * two ends *along* the line at a real metres-per-second, from a `tearSeed` nick + * to `riftSpan × half the cast length`. The second front is the one that + * matters: a slit whose `radiusX` grows while `riftWidth` stands still has ends + * that propagate, and a slit whose *both* radii grow is a sprite scaling up. + * The first build did the second thing and it read as a decal fading in from + * nothing, which is precisely the failure `Portal`'s own doc comment warns + * about — it just warns about it for `open`, and `open` was already right. + * + * **`parallax` must not be 1.** One is geometrically honest and geometric + * honesty reads as a hole in a *wall*. 1.75 is the shipped value: the interior + * slides against the camera about three quarters faster than the aperture says + * it should, and that mismatch is the entire illusion of depth. Drag it to 1 + * with the clock paused and watch the rift turn into a sticker. + * + * **The lens is authored separately from the hole.** `Portal` deliberately + * writes no screen-space offsets; the ring of bent floor comes from a + * `DistortionField` in `LENS` mode at the same anchor, with `lensRadius` a + * little past the rift's own half-length so the bend starts outside the + * fracture rather than inside it. Its `lensStrength` is a screen fraction, not + * a metre, and neither `post.distortion` nor `global.distortion` is folded into + * it — the pass applies both, once. + * + * **Closing runs the tear backwards.** The same ragged field, the same grain, + * the same shards, `open` descending instead of ascending — so it un-tears in + * the places it tore. What is left over `afterTime` is a hairline: `afterOpen` + * is small enough that only the band nearest the centreline is still open, the + * rim exponential covers all of it, and `afterGlow` lifts it to white. A + * separate afterimage quad was tried first and cost a draw call to say + * something the pinch already says. + */ +export const voidrift = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 62.0, // how fast the cast front runs down the line, metres/second + cooldown: 1.4, // seconds + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + holdTime: 1.5, // seconds the rift stands open once the tear finishes + closeTime: 0.42, // seconds the tear takes to run backwards + afterTime: 0.5, // seconds the hairline afterimage lingers for + + /* --- where the rift stands --- */ + // The aperture lies in the floor plane by default (`tilt` = 0 puts its normal + // straight up), so the camera looks *down into* space rather than at a pane + // hung in the air. `riftHeight` is the clearance that keeps a tilted rift + // from sinking its near edge through the floor and being depth-rejected. + centreBias: 0.52, // where along the cast line the rift is centred, 0..1 + riftHeight: 0.24, // metres the rift plane floats above the floor + riftSpan: 0.86, // full half-length as a fraction of half the cast length + riftWidth: 0.95, // metres, half-width of the slit across the line + tilt: 0.16, // radians the plane rolls about the cast line + margin: 0.55, // extra quad around the aperture so the cracks have room, 0..1 + + /* --- the tear --- */ + tearSeed: 0.35, // metres, half-length of the first nick + tearSpeed: 30.0, // metres/second the two ends race outward + openTime: 0.22, // seconds the seam takes to unzip to full width + closeDraw: 0.62, // 0..1 how far the ends pull back in as it shuts + afterOpen: 0.085, // aperture left as the hairline afterimage, 0..1 of the field + afterGlow: 3.4, // × the fracture glow while the hairline burns off + seam: 0.92, // 0 unzips from the centre, 1 from the long centreline + tearJag: 0.72, // 0..1 how uneven the tear front is + tearScale: 2.4, // cycles per metre of crack grain — a physical size + tearCrawl: 0.18, // Hz the grain creeps while the rift is held + edgeSoft: 0.035, // 0..1 of the field — how hard the aperture cuts + + /* --- the fracture rim --- */ + rim: 0.042, // 0..1 of the field — width of the fracture band + rimGlow: 2.9, + core: 0.009, // 0..1 — the white-hot line inside the band + coreGlow: 7.0, + throat: 0.2, // 0..1 — the soft inner glow past the rim + throatGlow: 0.55, + crackCount: 17, // radial fractures around the rim + crackWidth: 0.075, // 0..1 of one angular cell + crackLength: 0.26, // 0..1 of the field they reach out to + crackGlow: 2.1, + + /* --- the interior --- */ + parallax: 1.75, // 1 is honest and reads as a window. See the note above. + swirl: 0.06, // radians/second the whole interior turns + interiorFade: 0.4, // 0..1 how much the void darkens toward the rim + opacity: 1.0, + starSize: 0.04, // star core size, in lattice cells + starTwinkle: 0.42, + starGain: 1.15, + starScaleA: 2.4, // stars per metre, near shell + starScaleB: 5.0, // ... middle shell + starScaleC: 9.8, // ... far shell + starDepthA: 3.0, // metres behind the aperture, near shell + starDepthB: 8.5, // ... middle shell + starDepthC: 22.0, // ... far shell + starDriftA: 0.04, // radians/second, near shell + starDriftB: 0.021, // ... middle shell + starDriftC: 0.009, // ... far shell + nebulaScale: 0.19, // cycles per metre + nebulaSpeed: 0.045, + nebulaGain: 0.6, + nebulaDepth: 14.0, // metres behind the aperture + + /* --- the interior's colours --- */ + // Ten pickers, none derived from another (I5). The interior is genuinely + // `#000000`: `Portal` writes premultiplied, so a black interior at alpha 1 + // *removes* the floor rather than tinting it. + colorVoid: '#000000', // the nothing behind the aperture + colorRim: '#b07aff', // the fracture band + colorCore: '#ffffff', // the white-hot line inside it + colorCrack: '#8a5fd0', // the crown of radial fractures + colorThroat: '#2c1256', // the soft glow just inside the lip + colorStarA: '#ffffff', // near shell + colorStarB: '#c8d4ff', // middle shell + colorStarC: '#8f7dff', // far shell + colorNebulaA: '#0d0418', // the nebula's floor + colorNebulaB: '#5b2ea6', // ... and its crest + + /* --- the lens that bends the floor into it --- */ + // Screen fractions, never metres, and never pre-multiplied by + // `post.distortion` / `global.distortion` — the pass applies both, once. + lensRadius: 1.55, // falloff edge, × the rift's live half-length + lensQuad: 2.3, // the emitter quad, × lensRadius + lensStrength: 0.24, // screen widths at post.distortion = 1 + lensWindow: 0.62, // 0..1 of the radius where the falloff starts + lensCore: 0.2, // 0..1 of the radius — the 1/r² clamp + lensSwirl: 0.35, // tangential component, so the floor drags round the tear + lensMax: 0.9, // hard ceiling on the offset + lensLift: 0.5, // metres the billboard is anchored above the floor + lensDepthReject: 1.0, // 0..1 how hard geometry in front of it kills the warp + lensDepthFade: 0.45, // metres of feather on that rejection + lensPerspective: 0.35, // 0..1 how much the warp shrinks with distance + lensPerspectiveRef: 14.0, // metres at which perspective is neutral + + /* --- motes pulled in and eaten --- */ + // These are the only particles that go *inward*. `motePull` is the swirl + // system's radial expansion, driven negative: at -1 the offset reaches zero + // exactly at the end of a mote's life, so it converges on the rift and is + // gone. Nothing about that is faked with a fade. + moteRate: 130, // motes born per second, particles/second + moteSize: 0.075, + moteSpread: 2.1, // metres — radius of the ball a mote is born in + moteDrift: 0.3, // metres/second the ball sinks toward the plane + moteSink: -0.55, // gravity on a mote, metres/second² + moteSpin: 1.9, // radians/second it orbits the rift while it falls in + motePull: -0.94, // radial expansion; negative eats them at the rim + moteLifetime: 1.5, // seconds + moteTurbulence: 0.35, + colorMoteA: '#e8dcff', // birth + colorMoteB: '#b07aff', // early + colorMoteC: '#4a2a8c', // late + colorMoteD: '#07030f', // death — it goes out as it is swallowed + + /* --- embers shed by the fracture rim --- */ + emberRate: 90, // particles/second, shed along the live tear + emberSize: 0.11, + emberSpeed: 3.6, // metres/second + emberLifetime: 0.55, // seconds + emberGravity: -4.5, // metres/second² + emberStretch: 0.22, // how far a streak smears along its velocity + colorEmberA: '#ffffff', + colorEmberB: '#dcc8ff', + colorEmberC: '#8a5fd0', + colorEmberD: '#1b0a35', + + /* --- what the floor keeps --- */ + scorchRate: 1.1, // stains laid per metre of new tear + scorchRadius: 0.55, // metres + scorchLife: 5.5, // seconds + scorchIntensity: 0.7, + colorScorch: '#04030a', // the stain where the floor is missing + colorScorchEdge: '#6a4aa8', // its lit lip + shockRadius: 6.0, // the ring thrown out when the tear completes, metres + colorShockA: '#7a4fd0', // body of the ring + colorShockB: '#ffffff', // its crest + + /* --- the moment it finishes tearing --- */ + burstSize: 2.4, // shell of displaced air at the far end, metres + burstIntensity: 1.3, + burstEmbers: 140, // extra embers thrown at that moment + colorBurstA: '#3a1a6a', + colorBurstB: '#8a5fd0', + colorBurstC: '#ffffff', + impactShake: 0.55, + shakeDuration: 0.6, // seconds + impactFlash: 0.16, // screen flash on completion + colorFlash: '#c9a2ff', + rumble: 0.035, // continuous shake while the ends are still racing + + /* --- dynamic light --- */ + // Violet, weak and short: the rift is a hole, and a hole that floodlights the + // room is a lamp. What the light is for is picking the floor's own relief out + // of the darkness immediately around the lip. + lightIntensity: 11, + lightRadius: 9, + lightColor: '#a86bff' +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Void Rift. + * + * The four controls that carry the whole effect, in order: `parallax` (1 is a + * window, 1.75 is a hole in space), `tearSpeed` (how fast the ends race, and + * therefore whether it reads as torn or as drawn), `riftWidth` against + * `riftSpan` (a wound versus a manhole) and `lensStrength` (how hard the floor + * bends into it). Everything below is live on a rift that is already standing — + * pause with **P** halfway through the tear and drag any of them. + */ +export const voidriftSchema = { + 'The cast': [ + ['range', 3, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'front speed'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'], + ['holdTime', 0.1, 8, 0.01, 'hold open'], + ['closeTime', 0.05, 3, 0.01, 'close time'], + ['afterTime', 0.05, 3, 0.01, 'afterimage time'] + ], + 'Where it stands': [ + ['centreBias', 0, 1, 0.01, 'centre along the line'], + ['riftHeight', 0, 3, 0.01, 'height above floor (m)'], + ['riftSpan', 0.05, 1.4, 0.01, 'half-length × half the cast'], + ['riftWidth', 0.05, 4, 0.01, 'half-width (m)'], + ['tilt', -1.5, 1.5, 0.01, 'roll about the line (rad)'], + ['margin', 0, 1.5, 0.01, 'crack margin'] + ], + 'The tear': [ + ['tearSeed', 0.02, 3, 0.01, 'first nick (m)'], + ['tearSpeed', 0.5, 80, 0.5, 'ends race (m/s)'], + ['openTime', 0.02, 2, 0.01, 'unzip time'], + ['closeDraw', 0, 1, 0.01, 'ends pull back'], + ['afterOpen', 0.01, 0.4, 0.005, 'hairline aperture'], + ['afterGlow', 0, 10, 0.05, 'hairline glow ×'], + ['seam', 0, 1, 0.01, 'seam (centre → centreline)'], + ['tearJag', 0, 1, 0.01, 'front raggedness'], + ['tearScale', 0.1, 8, 0.05, 'grain (cycles/m)'], + ['tearCrawl', -2, 2, 0.01, 'grain crawl (Hz)'], + ['edgeSoft', 0.002, 0.3, 0.002, 'edge softness'] + ], + 'The fracture': [ + ['rim', 0.002, 0.3, 0.002, 'rim band'], + ['rimGlow', 0, 10, 0.05, 'rim glow'], + ['core', 0.001, 0.1, 0.001, 'core line'], + ['coreGlow', 0, 20, 0.05, 'core glow'], + ['throat', 0.01, 1, 0.005, 'throat depth'], + ['throatGlow', 0, 5, 0.01, 'throat glow'], + ['crackCount', 1, 40, 1, 'radial cracks'], + ['crackWidth', 0.005, 0.4, 0.005, 'crack width'], + ['crackLength', 0.01, 1.2, 0.01, 'crack reach'], + ['crackGlow', 0, 8, 0.05, 'crack glow'], + ['colorRim', 'fracture band'], + ['colorCore', 'hot line'], + ['colorCrack', 'radial cracks'], + ['colorThroat', 'throat glow'] + ], + 'What is behind it': [ + ['parallax', 0.2, 3, 0.01, 'parallax (1 = a window)'], + ['swirl', -1, 1, 0.005, 'interior spin (rad/s)'], + ['interiorFade', 0, 1, 0.01, 'darken toward rim'], + ['opacity', 0, 1.5, 0.01, 'aperture opacity'], + ['starSize', 0.005, 0.2, 0.001, 'star size'], + ['starTwinkle', 0, 1.5, 0.01, 'twinkle'], + ['starGain', 0, 4, 0.01, 'star gain'], + ['nebulaScale', 0.02, 1.5, 0.01, 'nebula scale (cyc/m)'], + ['nebulaSpeed', 0, 1, 0.005, 'nebula drift'], + ['nebulaGain', 0, 3, 0.01, 'nebula gain'], + ['nebulaDepth', 1, 60, 0.5, 'nebula depth (m)'], + ['colorVoid', 'the void itself'], + ['colorNebulaA', 'nebula floor'], + ['colorNebulaB', 'nebula crest'] + ], + 'What is behind it/Star shells': [ + ['starScaleA', 0.2, 30, 0.1, 'near: stars / m'], + ['starDepthA', 0.2, 60, 0.1, 'near: depth (m)'], + ['starDriftA', -0.5, 0.5, 0.001, 'near: drift (rad/s)'], + ['colorStarA', 'near shell'], + ['starScaleB', 0.2, 30, 0.1, 'mid: stars / m'], + ['starDepthB', 0.2, 60, 0.1, 'mid: depth (m)'], + ['starDriftB', -0.5, 0.5, 0.001, 'mid: drift (rad/s)'], + ['colorStarB', 'middle shell'], + ['starScaleC', 0.2, 30, 0.1, 'far: stars / m'], + ['starDepthC', 0.2, 90, 0.1, 'far: depth (m)'], + ['starDriftC', -0.5, 0.5, 0.001, 'far: drift (rad/s)'], + ['colorStarC', 'far shell'] + ], + 'The lens': [ + ['lensRadius', 0.2, 4, 0.01, 'falloff × half-length'], + ['lensQuad', 1, 5, 0.05, 'emitter quad × radius'], + ['lensStrength', 0, 1.5, 0.005, 'strength (screen widths)'], + ['lensWindow', 0, 1, 0.01, 'falloff start'], + ['lensCore', 0.02, 1, 0.01, '1/r² clamp'], + ['lensSwirl', -2, 2, 0.01, 'tangential drag'], + ['lensMax', 0, 3, 0.01, 'offset ceiling'], + ['lensLift', 0, 4, 0.01, 'anchor height (m)'], + ['lensDepthReject', 0, 1, 0.01, 'depth reject'], + ['lensDepthFade', 0, 3, 0.01, 'depth feather (m)'], + ['lensPerspective', 0, 1, 0.01, 'perspective'], + ['lensPerspectiveRef', 1, 40, 0.5, 'perspective ref (m)'] + ], + 'Motes it eats': [ + ['moteRate', 0, 600, 1, 'mote rate'], + ['moteSize', 0.005, 0.5, 0.005, 'mote size'], + ['moteSpread', 0.1, 8, 0.05, 'birth radius (m)'], + ['moteDrift', -3, 3, 0.01, 'sink toward plane (m/s)'], + ['moteSink', -12, 4, 0.05, 'gravity (m/s²)'], + ['moteSpin', -8, 8, 0.05, 'orbit (rad/s)'], + ['motePull', -1, 1, 0.01, 'radial pull (−1 eats them)'], + ['moteLifetime', 0.1, 6, 0.05, 'mote lifetime'], + ['moteTurbulence', 0, 3, 0.01, 'mote turbulence'], + ['colorMote*', 'Mote colour'] + ], + 'Embers off the rim': [ + ['emberRate', 0, 600, 1, 'ember rate'], + ['emberSize', 0.005, 0.6, 0.005, 'ember size'], + ['emberSpeed', 0, 25, 0.1, 'ember speed'], + ['emberLifetime', 0.05, 4, 0.01, 'ember lifetime'], + ['emberGravity', -40, 10, 0.1, 'ember gravity'], + ['emberStretch', 0, 3, 0.01, 'ember stretch'], + ['colorEmber*', 'Ember colour'] + ], + 'What the floor keeps': [ + ['scorchRate', 0.05, 6, 0.05, 'stains / metre'], + ['scorchRadius', 0.05, 4, 0.05, 'stain radius'], + ['scorchLife', 0.5, 20, 0.1, 'stain lifetime'], + ['scorchIntensity', 0, 3, 0.01, 'stain intensity'], + ['shockRadius', 0.5, 25, 0.1, 'shockwave radius'], + ['colorScorch', 'stain'], + ['colorScorchEdge', 'stain lip'], + ['colorShockA', 'shockwave ring'], + ['colorShockB', 'shockwave crest'] + ], + 'The moment it opens': [ + ['burstSize', 0.2, 12, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['burstEmbers', 0, 500, 1, 'burst embers'], + ['impactShake', 0, 3, 0.01, 'shake'], + ['shakeDuration', 0.1, 4, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'screen flash'], + ['rumble', 0, 0.5, 0.005, 'tearing rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst arcs'], + ['colorFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 80, 0.5, 'light intensity'], + ['lightRadius', 0.5, 40, 0.1, 'light radius'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/abilities/waspfunnel.js b/src/config/abilities/waspfunnel.js new file mode 100644 index 0000000..1ea82fe --- /dev/null +++ b/src/config/abilities/waspfunnel.js @@ -0,0 +1,566 @@ +/* ================================================================== */ +/* WASPFUNNEL — Wasp Funnel (hive, far cast) */ +/* ================================================================== */ +/** + * A nest opens on the aimed circle and a funnel of wasps stands up out of it. + * + * **The surge is the ability.** Four keys — `waveAmp`, `waveLength`, + * `waveSpeed` and `waveAlong` — put a *longitudinal* density wave through the + * colony: every agent is displaced along the wave axis by a sine of its own + * position on that axis, which crowds them at the zero crossings and thins them + * at the extremes. Bands of higher density therefore travel up the funnel, the + * way a real swarm surges, and they do it without anything being animated + * globally and without a single agent being told where a band is. + * + * The first version of this modulated per-agent *opacity* on the same sine. It + * looked like a strobing gradient painted over a static swarm, for the obvious + * reason: density you can see is agents *arriving*, not agents brightening. + * That mistake is now written into `vfx/Colony.js` so nobody repeats it. + * + * **Nothing here writes down a radius.** `vfx/Tube.js` in `FUNNEL` mode + * publishes `radiusAt(tau)` — `throat + skirt(tau) + mouth(tau)` — and every + * measurement in the ability that could have been its own slider is read off + * that curve instead: the barrel of wasps is `radiusAt(barrel centre) × + * columnHug`, the crawlers cover `radiusAt(0) × crawlReach`, the pit in the + * floor is `radiusAt(0) × pitReach`, the pollen leaves the mouth on a ring of + * `radiusAt(1)`, and the grit is picked up at the skirt. Drag + * `funnelSkirtFlare` with the clock stopped and all of them move together, + * because there is only one of them. + * + * **The wasps sit in the barrel on purpose.** A funnel is a cone at both ends + * and a cylinder in the middle, and `ColonySwarm`'s shape library has a column + * but no cone. Rather than hide that, the colony is placed over exactly the + * cylindrical section — `barrelFrom` to `barrelTo`, which want to sit inside + * `funnelSkirtHeight`…`funnelMouthStart` — and the two flares are drawn by the + * tube's own dust, the pit and the ejected pollen. A column of agents smeared + * across a flaring profile is the version that looks wrong, and it looks wrong + * everywhere at once. + * + * `zoneRadius` is the one measurement that is deliberately *not* the profile. + * It is the aim circle, and what it promises is the ground the nest disturbs: + * it drives where the grit is picked up from and how far the haze spreads. A + * nest seen from above is a small dark mouth inside a much larger scuffed + * patch, and those are the two numbers. + */ + +import { TubePath, tubeDefaults, tubeSchema } from '../../vfx/Tube.js'; + +export const waspfunnel = { + /* --- the cast --- */ + range: 19.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 26.0, // how fast the nest reaches the target circle, metres/second + zoneRadius: 4.2, // the aim circle: the ground the nest disturbs, metres + lifetime: 3.2, // seconds the funnel stands after it opens + fadeTime: 1.6, // seconds it takes to sink back into the nest + cooldown: 1.8, + castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws + + /* --- the column --- */ + height: 6.4, // metres from the floor to the mouth + baseHeight: 0.03, // metres the foot floats above the floor + standTime: 0.7, // seconds the funnel takes to stand up out of the nest + standTaper: 0.12, // width multiplier at the instant it opens, 0..1 + sinkTaper: 0.18, // width multiplier at the end of the collapse, 0..1 + sinkDrop: 0.55, // fraction of the height the mouth falls to as it dies + lightRide: 0.4, // where on the column the dynamic light sits, 0..1 + + /* ------------------------------------------------------------------ */ + /* THE SURGE — the density wave, and its three other consumers */ + /* ------------------------------------------------------------------ */ + /** + * `waveAlong` at 0 means the wave climbs, which is what a funnel does. The + * crests travel at `waveSpeed` metres/second and sit `waveLength` metres + * apart, so the beat you see arriving at the mouth is `waveSpeed / + * waveLength` per second — worth knowing, because `surgeGain` fires a puff of + * pollen on exactly that beat and `lightSurge` brightens the light on it. One + * wave, three consumers, no second copy of the phase anywhere. + */ + waveAmp: 0.5, // metres of longitudinal bunching — the whole read + waveLength: 2.3, // metres between crests + waveSpeed: 4.2, // metres/second the crests climb + waveAlong: 0.0, // 0 the wave climbs, 1 it runs along the cast + surgeGain: 0.85, // 0..1 how hard the mouth's pollen follows the crest + lightSurge: 0.3, // 0..1 how hard the light follows it + + /* ------------------------------------------------------------------ */ + /* The funnel — vfx/Tube.js, prefix `funnel`, path FUNNEL */ + /* ------------------------------------------------------------------ */ + /** + * Spread verbatim from `tubeDefaults('funnel', TubePath.FUNNEL)` so the + * module's own audit stays quiet; the keys belonging to `WHIP`, `VINE` and + * `ARC` are inert on this path and are filed at the bottom of the editor + * folder rather than hidden, because a key the panel cannot reach is a key + * nobody can rule out. + * + * This tube is **not** the wasps. It is the pheromone haze and the dust the + * colony holds in the air, so it is dim, wide and slow — `funnelOpacity` well + * under half, a long `funnelFlowSpeed`, and a throat wide enough that the + * barrel of agents sits comfortably inside it rather than poking through. + */ + ...tubeDefaults('funnel', TubePath.FUNNEL, { + funnelThroat: 0.85, // the waist of the nest column, metres + funnelSkirtFlare: 1.9, // extra radius at the floor, metres + funnelSkirtHeight: 0.2, // how far up the skirt reaches, fraction of height + funnelSkirtCurve: 1.9, + funnelMouthFlare: 2.6, // extra radius at the top, metres + funnelMouthStart: 0.62, // where the mouth begins to open, fraction of height + funnelMouthCurve: 1.5, + funnelSpin: 0.7, // revolutions/second the haze turns + funnelSpinTwist: 1.4, // extra revolutions from floor to mouth + funnelSway: 0.3, // metres the axis precesses + funnelSwayScale: 0.45, + funnelSwaySpeed: 0.2, + funnelSwayCurve: 2.0, + funnelThrob: 0.05, // the haze breathes; the agents do the surging + funnelThrobScale: 1.8, + funnelThrobSpeed: 0.9, + funnelWander: 0.06, // metres of low-frequency drift on the axis + funnelWanderScale: 0.8, + funnelWanderSpeed: 0.45, + funnelRipple: 0.14, // radial break-up of the barrel + funnelRippleBands: 2.2, + funnelRippleScale: 2.6, + funnelRippleSpeed: 1.4, + funnelStreak: 0.8, // dust filaments climbing the surface + funnelStreakSharp: 0.4, + funnelStreakScale: 3.4, + funnelStreakBands: 3.0, + funnelStreakGlow: 0.5, + funnelFlowSpeed: -2.4, // metres-of-parameter/second (negative = up) + funnelBands: 0.0, // no rings: the density bands are the agents' job + funnelCoreWidth: 0.3, + funnelCoreFill: 0.35, // hollow — you have to be able to see through it + funnelCoreSharp: 1.6, + funnelEdgePower: 2.4, + funnelSheathWidth: 1.0, + funnelSheathRim: 1.0, + funnelSheathFill: 0.12, + funnelSheathOpacity: 0.55, + funnelHaloWidth: 1.7, + funnelHaloRim: 3.6, + funnelHaloOpacity: 0.3, + funnelMuzzleGlow: 0.7, + funnelMuzzleLength: 0.16, + funnelTipGlow: 0.9, + funnelTipLength: 0.1, + funnelOpacity: 0.42, // haze, not a beam + funnelGlow: 0.55, // barely emissive — the nest is lit, not lit up + funnelSoftFade: 0.7, + funnelColorCore: '#e2d8a4', // dust in the throat + funnelColorInner: '#b3a24e', + funnelColorOuter: '#6d6a2a', + funnelColorHalo: '#2a2a12' // the outer haze against the sky + }), + + /* ------------------------------------------------------------------ */ + /* The colony in the barrel — vfx/Colony.js `ColonySwarm` */ + /* ------------------------------------------------------------------ */ + wasps: 520, // live agents in the column (capped at 760) + barrelFrom: 0.16, // bottom of the cylindrical section, fraction of height + barrelTo: 0.72, // top of it — keep both inside skirtHeight…mouthStart + columnHug: 1.0, // × the profile radius at the barrel's mid height + condense: 0.9, // 0 pure flock, 1 pinned to the column + shapeFill: 0.55, // 0 a sleeve of wasps, 1 a solid plug + shapeSteps: 3, // gradient-descent steps onto the field, 1..4 + shapeSlack: 0.85, // fraction of each step taken + shapeRough: 0.11, // unitless slop off the isosurface — a crowd, not a wall + shapeSpin: 0.9, // radians/second the whole column turns + latticeX: 11, // cells across + latticeY: 8, // cells up + latticeZ: 7, // ranks strung out behind the lead + spacingSide: 0.4, // metres between lateral cells + spacingUp: 0.4, // metres between vertical cells + lag: 0.3, // seconds the back rank trails the lead by + jitter: 0.16, // metres of per-agent slop off its own cell + churn: 0.9, // radians/second the formation rolls + breathe: 0.16, // fraction the formation swells by + breatheRate: 2.1, // radians/second of that swell + wander: 0.24, // metres of curl-noise drift + wanderScale: 1.1, // features per metre of that drift + wanderSpeed: 1.3, // how fast the drift field moves + gather: 1.0, // 0 collapses every agent onto the lead's own path + revealTime: 0.3, // seconds the colony takes to appear as the nest opens + revealSpread: 0.45, // 0..1 width of that appearance wave + + /* --- one wasp --- */ + /** + * `flapRate` is the second half of the trick and it is nearly free: the wing + * fold runs on `sin(TAU · (flapRate · uTime + dice.y))`, and `dice.y` is per + * agent, so five hundred wasps beat out of phase with no per-agent state and + * nothing animated globally. `edgeGain` is what turns that into a shimmer — + * a card going edge-on collapses to a line, and the line has to be *brighter* + * than the plate was or the beat reads as a hole rather than as a flash. + */ + size: 0.17, // metres, nose to tail + aspect: 1.15, // wingspan / length + sizeJitter: 0.4, // ±fraction of size + sweep: 0.95, // how far the wings rake back + dihedral: 0.62, // wing fold out of the card's plane, fraction of size + wingCurl: 0.05, // static camber across the wing, fraction of size + flapRate: 22.0, // wing-beats/second, per agent, out of phase + bank: 0.05, // radians of roll per m/s² of lateral acceleration + bankMax: 0.9, // radians + billboard: 0.08, // 0 the agent's own frame, 1 camera-facing + edgeStretch: 2.0, // how much an edge-on card grows so it stays a line + edgeGain: 3.0, // how much brighter it gets while it is edge-on — the shimmer + lit: 0.5, // 0 pure emissive, 1 wrapped diffuse + + /* --- the colony's colour --- */ + colorWaspA: '#ffe9a8', // birth end: a sunlit wing + colorWaspB: '#e0a020', // wasp gold + colorWaspC: '#6a4a12', // the dark of a banded abdomen + colorWaspD: '#1a1408', // death end + tint: 0.3, // where in that gradient the colony sits + tintJitter: 0.42, // ±per-agent walk along it — the banding + tintAlong: 0.35, // extra walk from head to tail + opacity: 1.0, + glow: 0.95, // emissive gain + softFade: 0.3, // metres of soft fade where an agent meets geometry + + /* ------------------------------------------------------------------ */ + /* The crawlers on the nest mouth */ + /* ------------------------------------------------------------------ */ + /** + * A second `ColonySwarm`, condensed onto a *squashed ball* — which is a disc + * — and pinned to the floor by `crawlCling`. They share the wasp's body and + * gradient, because they are the same insect; the only thing they do not + * share is where they are. Their disc is `radiusAt(0) × crawlReach`, so the + * carpet on the ground grows and shrinks with the skirt above it. + */ + crawlers: 180, // live agents on the ground (capped at 260) + crawlReach: 1.25, // × the skirt radius the carpet covers + crawlHeight: 0.09, // metres above the floor a crawler rides + crawlCling: 0.95, // 0 flying, 1 pinned to the floor + crawlCondense: 0.92, // how hard they are held to the disc + crawlThickness: 0.16, // metres — half-height of the disc before the cling + crawlLatticeX: 14, // cells across, for their own separation + crawlLatticeZ: 12, // ranks + crawlSpacing: 0.34, // metres between their cells + crawlChurn: 0.35, // radians/second the carpet turns + crawlJitter: 0.22, // metres of slop off a cell + crawlSize: 0.85, // × the wasp size — the ones on the ground read smaller + crawlOpacity: 1.0, + crawlSwell: 1.7, // × their number as the column comes back down + + /* ------------------------------------------------------------------ */ + /* The nest mouth — vfx/GroundField.js, FUNNEL mode */ + /* ------------------------------------------------------------------ */ + pitReach: 1.15, // × the skirt radius the pit's rim sits at + pitDepth: 0.85, // metres the throat reads as being below the floor + pitSharp: 0.62, // 0..1 how steeply the wall falls away + pitCell: 0.42, // metres — the size of a calved lip block + pitCellJitter: 0.8, // 0..1 how irregular those blocks are + pitSeam: 0.06, // metres of gap between them + pitLipDrop: 0.1, // metres a lip block tilts in by + pitSpoil: 0.07, // metres of chewed spoil heaped over the rim + pitDetail: 0.7, // 0..1 grain on the walls + pitGrowTime: 0.45, // seconds the mouth takes to open + pitEdge: 0.3, // metres of feather on the growth front + pitRagged: 0.3, // how far that front wanders, fraction of the radius + pitRaggedScale: 0.9, // lobes per metre + pitWarp: 0.4, // metres of domain warp on those lobes + pitRelief: 0.85, // how hard the height field tilts the fake normal + pitNormalStep: 0.05, // metres between the height taps + pitAmbient: 0.26, // floor on the diffuse term + pitWrap: 0.4, // 0..1 wraps the terminator round the back + pitSpecular: 0.3, + pitGloss: 22, // Blinn exponent + pitParallax: 0.4, // metres of view-driven offset — the depth cue + pitEmissive: 0.8, // multiplier on the glow down the throat + pitOpacity: 1.0, + pitDepthFade: 0.5, // metres of soft fade against standing geometry + pitHeight: 0.014, // metres above the floor the quad sits at + colorPitBase: '#6a6244', // the chewed ground + colorPitEdge: '#b8ac72', // the calved lip + colorPitGlow: '#c8a028', // what little light comes back up the throat + colorPitDeep: '#141208', // the dark of it + + /* ------------------------------------------------------------------ */ + /* Pollen — thrown out of the mouth on the crest of each surge */ + /* ------------------------------------------------------------------ */ + moteRate: 120, // particles/second at the crest + moteSize: 0.05, // metres + moteSpeed: 2.6, // metres/second + moteLifetime: 1.7, // seconds + moteRise: 0.35, // metres/second² of buoyancy + moteTurbulence: 0.8, + moteGlow: 1.5, // emissive gain + colorMoteA: '#fff3c4', + colorMoteB: '#e8c45c', + colorMoteC: '#9a7c20', + colorMoteD: '#2e2408', + + /* --- grit whipped up off the disturbed ground --- */ + gritRate: 90, // particles/second + gritSize: 0.06, // metres + gritSpeed: 3.0, // metres/second + gritLifetime: 1.3, // seconds + gritGravity: -8.0, // metres/second² + gritSpin: 8.0, // radians/second of tumble + colorGritA: '#cfc59a', + colorGritB: '#9b8f5e', + colorGritC: '#5f5836', + colorGritD: '#241f12', + + /* --- haze at the foot --- */ + hazeRate: 40, // particles/second + hazeSize: 0.9, // metres + hazeSpeed: 1.2, // metres/second + hazeLifetime: 2.6, // seconds + hazeRise: 0.45, // metres/second² of buoyancy + hazeOpacity: 0.45, + hazeTurbulence: 0.6, + colorHazeA: '#b8b092', + colorHazeB: '#8d876c', + colorHazeC: '#5c5844', + colorHazeD: '#282620', + + /* ------------------------------------------------------------------ */ + /* Opening and collapse */ + /* ------------------------------------------------------------------ */ + openSize: 2.2, // metres the burst of the nest opening reaches + openIntensity: 0.9, + openGrit: 90, // grit thrown by the opening + openMotes: 70, // pollen thrown by the opening + shockRadius: 3.8, // metres the ground ring reaches + castFlash: 0.14, // screen flash as the nest opens + impactShake: 0.4, + shakeDuration: 0.55, // seconds the opening shake decays over + impactFlash: 0.18, + rumble: 0.05, // continuous shake while the funnel stands + collapseGrit: 120, // grit thrown as the column sinks + colorOpenA: '#e6d9a2', // the opening burst's shell + colorOpenB: '#c09a2c', // its body + colorOpenC: '#5f4a12', // its filaments + colorShockA: '#e2d49a', // the ground ring's leading edge + colorShockB: '#6d6428', // and its trail + colorCastFlash: '#d8c66e', + colorFlash: '#e0cf84', + + /* --- the dynamic light --- */ + lightColor: '#e5bf58', // nest gold + lightIntensity: 1.7, + lightRadius: 12.0, // metres + lightFlicker: 0.12, // 0..1 depth of the wingbeat shimmer + lightFlickerSpeed: 26.0 // shimmer steps/second +}; + +/** Editor layout. The surge comes first, because the surge is the ability. */ +export const waspfunnelSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 2, 80, 0.5, 'travel speed'], + ['zoneRadius', 0.5, 14, 0.1, 'aim circle (m)'], + ['lifetime', 0.1, 8, 0.01, 'stand time'], + ['fadeTime', 0.05, 6, 0.01, 'collapse time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'The surge': [ + ['waveAmp', 0, 2, 0.01, 'bunching (m)'], + ['waveLength', 0.2, 12, 0.05, 'crest spacing (m)'], + ['waveSpeed', -20, 20, 0.1, 'crest speed (m/s)'], + ['waveAlong', 0, 1, 0.01, 'climbs → runs down'], + ['surgeGain', 0, 1, 0.01, 'pollen follows crest'], + ['lightSurge', 0, 1, 0.01, 'light follows crest'] + ], + // NOT 'The column': `tubeSchema` owns a folder of that name and it is spread + // in below, so an identically named folder here would be silently replaced — + // twenty-six controls vanished into "More" exactly once before this comment + // was written. + 'The nest column': [ + ['height', 1, 20, 0.05, 'height (m)'], + ['baseHeight', 0, 1, 0.005, 'foot float (m)'], + ['standTime', 0.05, 4, 0.01, 'stand-up (s)'], + ['standTaper', 0.01, 1, 0.01, 'width at open'], + ['sinkTaper', 0.01, 1, 0.01, 'width at close'], + ['sinkDrop', 0, 1, 0.01, 'mouth drop'], + ['lightRide', 0, 1, 0.01, 'light height'] + ], + ...tubeSchema('funnel', TubePath.FUNNEL), + /** + * The nineteen `Tube` keys that belong to WHIP, VINE and ARC. They are inert + * on a FUNNEL and `tubeSchema` rightly does not file them — but a key the + * panel cannot reach is a key nobody can rule out when something looks wrong, + * so they are collected here at the bottom instead of being left to the + * "More" folder's alphabetical soup. + */ + 'The funnel/Inert on this path': [ + ['funnelWaveRate', 0, 6, 0.01, 'whip: loops/second'], + ['funnelWaveWidth', 0.02, 0.6, 0.001, 'whip: loop width'], + ['funnelWaveAmp', 0, 1, 0.001, 'whip: loop throw'], + ['funnelWaveGain', 0.2, 6, 0.01, 'whip: loop gain'], + ['funnelWaveCurve', 0.1, 6, 0.01, 'whip: gain curve'], + ['funnelWaveRoll', 0, 6.29, 0.01, 'whip: crack plane'], + ['funnelSag', 0, 2, 0.01, 'whip: sag (m)'], + ['funnelCrackRatio', 0.2, 4, 0.01, 'whip: crack ratio'], + ['funnelTipTaper', 0.05, 6, 0.01, 'vine: tip taper'], + ['funnelMeander', 0, 2, 0.01, 'vine: meander (m)'], + ['funnelMeanderTurns', 0, 8, 0.01, 'vine: meander turns'], + ['funnelRecoilAmp', 0, 1, 0.01, 'vine: recoil'], + ['funnelRecoilFreq', 0, 10, 0.01, 'vine: recoil Hz'], + ['funnelRecoilDamp', 0.1, 16, 0.01, 'vine: recoil damping'], + ['funnelRecoilSway', 0, 4, 0.01, 'vine: recoil bow (m)'], + ['funnelArcHeight', -12, 12, 0.01, 'arc: apex height (m)'], + ['funnelArcLateral', -12, 12, 0.01, 'arc: apex offset (m)'], + ['funnelArcBias', 0.05, 0.95, 0.01, 'arc: apex position'], + ['funnelArcCurve', 0.1, 4, 0.01, 'arc: apex curve'] + ], + 'The colony': [ + ['wasps', 1, 760, 1, 'wasps'], + ['barrelFrom', 0, 1, 0.01, 'barrel bottom'], + ['barrelTo', 0, 1, 0.01, 'barrel top'], + ['columnHug', 0.1, 3, 0.01, '× profile radius'], + ['condense', 0, 1, 0.01, 'shape vs flock'], + ['shapeFill', 0, 1, 0.01, 'sleeve → plug'], + ['shapeSteps', 1, 4, 1, 'descent steps'], + ['shapeSlack', 0.05, 1, 0.01, 'step relaxation'], + ['shapeRough', 0, 0.5, 0.005, 'crowd slop'], + ['shapeSpin', -6, 6, 0.01, 'column spin (rad/s)'], + ['latticeX', 1, 20, 1, 'cells across'], + ['latticeY', 1, 16, 1, 'cells up'], + ['latticeZ', 1, 24, 1, 'ranks back'], + ['spacingSide', 0.02, 3, 0.01, 'lateral spacing'], + ['spacingUp', 0.02, 3, 0.01, 'vertical spacing'], + ['lag', 0, 2.5, 0.01, 'rank lag'], + ['jitter', 0, 1.5, 0.01, 'cell slop'], + ['churn', -6, 6, 0.01, 'formation roll'], + ['breathe', 0, 1.5, 0.01, 'swell'], + ['breatheRate', 0, 8, 0.05, 'swell rate'], + ['wander', 0, 1.5, 0.01, 'curl drift'], + ['wanderScale', 0.05, 3, 0.01, 'drift features / m'], + ['wanderSpeed', 0, 4, 0.01, 'drift speed'], + ['gather', 0, 1, 0.01, 'collapse onto lead'], + ['revealTime', 0.01, 3, 0.01, 'gather time'], + ['revealSpread', 0.01, 1, 0.01, 'gather spread'] + ], + 'One wasp': [ + ['size', 0.02, 1, 0.005, 'size'], + ['aspect', 0.3, 4, 0.01, 'span / length'], + ['sizeJitter', 0, 1, 0.01, 'size jitter'], + ['sweep', 0, 2, 0.01, 'wing rake'], + ['dihedral', 0, 1.5, 0.01, 'wing fold'], + ['wingCurl', -1, 1, 0.01, 'wing camber'], + ['flapRate', 0, 60, 0.5, 'wing-beats / sec'], + ['bank', 0, 0.5, 0.001, 'bank per m/s²'], + ['bankMax', 0, 2, 0.01, 'max bank'], + ['billboard', 0, 1, 0.01, 'camera facing'], + ['edgeStretch', 1, 5, 0.01, 'edge-on stretch'], + ['edgeGain', 0, 8, 0.01, 'edge-on gain'], + ['lit', 0, 1, 0.01, 'diffuse mix'] + ], + 'Colony colour': [ + ['colorWasp*', 'Wasp gradient'], + ['tint', 0, 1, 0.01, 'gradient position'], + ['tintJitter', 0, 1, 0.01, 'per-agent walk'], + ['tintAlong', 0, 1, 0.01, 'head-to-tail walk'], + ['opacity', 0, 2, 0.01, 'opacity'], + ['glow', 0, 4, 0.01, 'glow'], + ['softFade', 0.02, 2, 0.01, 'soft intersection'] + ], + 'The crawlers': [ + ['crawlers', 0, 260, 1, 'crawlers'], + ['crawlReach', 0.2, 4, 0.01, '× skirt radius'], + ['crawlHeight', 0, 1, 0.005, 'ride height (m)'], + ['crawlCling', 0, 1, 0.01, 'pinned to floor'], + ['crawlCondense', 0, 1, 0.01, 'held to the disc'], + ['crawlThickness', 0.02, 1, 0.01, 'disc half-height (m)'], + ['crawlLatticeX', 1, 24, 1, 'cells across'], + ['crawlLatticeZ', 1, 24, 1, 'ranks'], + ['crawlSpacing', 0.02, 2, 0.01, 'cell spacing (m)'], + ['crawlChurn', -4, 4, 0.01, 'carpet turn (rad/s)'], + ['crawlJitter', 0, 1.5, 0.01, 'cell slop'], + ['crawlSize', 0.2, 2, 0.01, '× wasp size'], + ['crawlOpacity', 0, 2, 0.01, 'opacity'], + ['crawlSwell', 1, 4, 0.01, 'number × on collapse'] + ], + 'The nest mouth': [ + ['pitReach', 0.2, 3, 0.01, '× skirt radius'], + ['pitDepth', 0, 4, 0.01, 'depth (m)'], + ['pitSharp', 0, 1, 0.01, 'wall steepness'], + ['pitCell', 0.05, 2, 0.01, 'lip block (m)'], + ['pitCellJitter', 0, 1, 0.01, 'block irregularity'], + ['pitSeam', 0, 0.4, 0.005, 'block gap (m)'], + ['pitLipDrop', 0, 0.6, 0.005, 'lip drop (m)'], + ['pitSpoil', 0, 0.6, 0.005, 'spoil (m)'], + ['pitDetail', 0, 1, 0.01, 'wall grain'], + ['pitGrowTime', 0.05, 3, 0.01, 'opening (s)'], + ['pitEdge', 0.02, 2, 0.01, 'front feather (m)'], + ['pitRagged', 0, 1, 0.01, 'front wander'], + ['pitRaggedScale', 0.05, 4, 0.01, 'lobes / m'], + ['pitWarp', 0, 2, 0.01, 'domain warp (m)'], + ['pitRelief', 0, 2, 0.01, 'relief'], + ['pitNormalStep', 0.01, 0.4, 0.005, 'normal step (m)'], + ['pitAmbient', 0, 1, 0.01, 'ambient'], + ['pitWrap', 0, 1, 0.01, 'terminator wrap'], + ['pitSpecular', 0, 2, 0.01, 'specular'], + ['pitGloss', 1, 80, 1, 'gloss'], + ['pitParallax', 0, 1, 0.01, 'parallax (m)'], + ['pitEmissive', 0, 3, 0.01, 'emissive'], + ['pitOpacity', 0, 1, 0.01, 'opacity'], + ['pitDepthFade', 0.02, 2, 0.01, 'soft intersection'], + ['pitHeight', 0.002, 0.1, 0.001, 'height off floor (m)'], + ['colorPitBase', 'chewed ground'], + ['colorPitEdge', 'calved lip'], + ['colorPitGlow', 'light up the throat'], + ['colorPitDeep', 'the dark of it'] + ], + 'Pollen': [ + ['moteRate', 0, 600, 1, 'pollen rate'], + ['moteSize', 0.005, 0.4, 0.005, 'pollen size'], + ['moteSpeed', 0, 12, 0.05, 'pollen speed'], + ['moteLifetime', 0.1, 6, 0.05, 'pollen lifetime'], + ['moteRise', -2, 4, 0.01, 'pollen rise'], + ['moteTurbulence', 0, 4, 0.01, 'pollen turbulence'], + ['moteGlow', 0, 6, 0.01, 'pollen glow'], + ['colorMote*', 'Pollen colour'] + ], + 'Grit': [ + ['gritRate', 0, 600, 1, 'grit rate'], + ['gritSize', 0.005, 0.5, 0.005, 'grit size'], + ['gritSpeed', 0, 20, 0.05, 'grit speed'], + ['gritLifetime', 0.1, 6, 0.05, 'grit lifetime'], + ['gritGravity', -30, 5, 0.1, 'grit gravity'], + ['gritSpin', 0, 30, 0.1, 'grit tumble'], + ['colorGrit*', 'Grit colour'] + ], + 'Haze': [ + ['hazeRate', 0, 400, 1, 'haze rate'], + ['hazeSize', 0.05, 4, 0.01, 'haze size'], + ['hazeSpeed', 0, 10, 0.05, 'haze speed'], + ['hazeLifetime', 0.2, 8, 0.05, 'haze lifetime'], + ['hazeRise', -2, 4, 0.01, 'haze rise'], + ['hazeOpacity', 0, 1, 0.005, 'haze opacity'], + ['hazeTurbulence', 0, 4, 0.01, 'haze turbulence'], + ['colorHaze*', 'Haze colour'] + ], + 'Opening & collapse': [ + ['openSize', 0.05, 8, 0.05, 'opening burst size'], + ['openIntensity', 0, 5, 0.01, 'opening burst intensity'], + ['openGrit', 0, 600, 1, 'opening grit'], + ['openMotes', 0, 600, 1, 'opening pollen'], + ['shockRadius', 0.2, 14, 0.05, 'ground ring (m)'], + ['castFlash', 0, 2, 0.01, 'opening flash'], + ['impactShake', 0, 3, 0.01, 'opening shake'], + ['shakeDuration', 0.05, 2, 0.01, 'shake duration'], + ['impactFlash', 0, 2, 0.01, 'landing flash'], + ['rumble', 0, 0.3, 0.002, 'standing rumble'], + ['collapseGrit', 0, 600, 1, 'collapse grit'], + ['colorOpenA', 'burst shell'], + ['colorOpenB', 'burst body'], + ['colorOpenC', 'burst filaments'], + ['colorShockA', 'ring edge'], + ['colorShockB', 'ring trail'], + ['colorCastFlash', 'opening flash colour'], + ['colorFlash', 'landing flash colour'] + ], + 'The light': [ + ['lightColor', 'light colour'], + ['lightIntensity', 0, 8, 0.01, 'intensity'], + ['lightRadius', 1, 40, 0.5, 'radius (m)'], + ['lightFlicker', 0, 1, 0.01, 'wingbeat shimmer'], + ['lightFlickerSpeed', 1, 60, 0.5, 'shimmer speed'] + ] +}; diff --git a/src/config/abilities/webline.js b/src/config/abilities/webline.js new file mode 100644 index 0000000..4a625bc --- /dev/null +++ b/src/config/abilities/webline.js @@ -0,0 +1,377 @@ +/* ================================================================== */ +/* WEBLINE — Web Line */ +/* ================================================================== */ +/** + * A drag-line is thrown down the aimed lane, bites, and an orb web spins + * itself out across the lane on four sagging guy-lines. + * + * **What every number in here is for.** The web is a *graph* — a node is the + * pair of integers `(ring, spoke)` and nothing else, ring −1 being the hub — + * and every metre it has is a uniform read by the vertex shader on the frame it + * changes. `radius`, `squash`, `droop`, `depth` and `slack` re-span a web that + * is already hanging, film included, because the film is a Coons patch over the + * same four strand curves the strand mesh draws. + * + * The membrane is the half of the block worth reaching for first. `filmFill` + * decides how many faces carry film at all — **never take it to 1**, because + * the film is a grazing-angle effect and panels that flare need dark panels to + * flare against. `grazePower` is how narrow the rim of visibility is, and + * `filmBands` is how many times round the four-stop gradient the interference + * walks between head-on and edge-on. + * + * A cast captures one number, a seed, and some timestamps. Nothing else. + */ +export const webline = { + /* --- the cast --- */ + range: 22.0, // maximum cast distance, metres + minRange: 3.0, // closer than this and the cast is refused + speed: 42.0, // how fast the drag-line pays out, metres/second + cooldown: 1.1, + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + spinTime: 0.9, // seconds the web takes to spin itself out + holdTime: 1.8, // seconds it hangs finished before it starts to go + fadeTime: 1.2, // seconds the tear takes + + /* --- where the drag-line leaves the caster --- */ + handHeight: 1.26, // metres above the floor + handForward: 0.5, // metres in front of the caster + handSide: 0.18, // metres to the side (+ follows `Ability#side`) + + /* --- where the web hangs --- */ + hubHeight: 2.15, // metres above the floor the hub sits + hubShort: 0.6, // metres short of the aim point the web stands + lean: 0.22, // radians the disc tips back off vertical, so it catches light + + /* --- the graph. Two integers per node; everything else is a uniform --- */ + rings: 5, // rings of chords outside the hub, whole number + spokes: 13, // radials, whole number + radius: 3.4, // metres to the outer ring + squash: 0.86, // 1 circular, < 1 an ellipse lying down + ringCurve: 1.42, // > 1 crowds the rings toward the rim, as a real web does + ringJitter: 0.14, // unitless, fraction of a ring's radius + spokeJitter: 0.24, // unitless, fraction of a spoke's spacing + twist: 0.3, // radians of shear per unit radius fraction + droop: 0.42, // metres the whole disc sags at the rim + depth: 0.34, // metres of out-of-plane node scatter + + /* --- the strands --- */ + slack: 0.1, // sag as a FRACTION of a strand's own span — not a fixed drop + sway: 0.045, // metres of breeze, out of plane + swayRate: 1.6, // radians/second + strandWidth: 0.022, // metres + widthJitter: 0.36, // +/- fraction + chordWidth: 0.7, // multiplier on chords, so the radials read as the frame + strandOpacity: 1.0, + strandGlow: 1.15, + silkPower: 22.0, // Kajiya-Kay exponent — the band of light across the threads + silkGain: 1.7, + coreBias: 0.55, // how much brighter a thread's spine is than its edge + colorStrand: '#e9e2cd', // the thread itself + colorSilk: '#ffffff', // its fibre highlight + + /* --- the membrane: the part everybody forgets --- */ + dish: 0.085, // metres each panel bellies between its four threads + grazePower: 2.8, // higher is a thinner rim of visibility + filmOpacity: 0.6, + filmBands: 2.4, // times round the gradient, head-on to edge-on + filmShift: 0.72, // per-face offset along it, so neighbours never band in step + filmSheen: 0.85, + filmSheenPower: 34.0, + filmFill: 0.76, // 0..1 of the faces that carry film. Never 1 — see the header + tearBias: 0.7, // 1 tears the rim first, 0 tears evenly + filmGlow: 1.0, + colorFilmA: '#a8ead9', // head-on, where the film barely exists + colorFilmB: '#cdd4ff', + colorFilmC: '#ffd9ae', + colorFilmD: '#8ba6c6', // edge-on, where the interference is deepest + + /* --- the spinning --- */ + growFeather: 0.2, // 0..1 width of the spinning front + orderScatter: 0.28, // 0 the film fills ring by ring, 1 at random + + /* --- the snap: what the web does the moment it takes the hit --- */ + snapSway: 0.42, // metres of extra shiver at the catch, out of plane + snapRate: 11.0, // radians/second that shiver runs at + snapDecay: 3.4, // per second the shiver dies away + snapFlash: 1.1, // extra film glow while it is shivering + + /* --- the tear --- */ + tearSlack: 0.22, // extra sag as the threads let go, fraction of a span + tearDroop: 1.4, // extra metres the whole disc sinks as it comes down + tearFilm: 0.55, // fraction of the fade by which the film has gone entirely + + /* --- the guy-lines: real catenaries onto floor anchors --- */ + guys: 4, // anchor lines, whole number, 0..4 (one role each) + guyThreads: 2, // parallel filaments per guy — a real frame thread is doubled + guySpread: 0.05, // metres between those parallel filaments + guyReach: 0.62, // fraction beyond the rim node the floor anchor sits + guySplay: 1.7, // metres fore/aft the anchors alternate + guyFloor: 0.02, // metres above the floor the anchors are pinned + guySlack: 0.55, // metres of droop at mid-span before it goes taut + guyCurve: 1.7, // 1 is rope, 3 is heavy chain, 0.01 is a parabola + guySwing: 0.05, // metres of lateral sway + guySwingSpeed: 1.2, // radians/second of that sway + guyTaut: 0.88, // 0..1 the sag is pulled out of once the web is spun + guyLead: 0.32, // fraction of the spin the guys are laid over, before the disc + guyDim: 0.85, // how much dimmer a guy is than the web it holds up + + /* --- the guy ribbon (a `filamentLook()`, spelled out as sliders) --- */ + guyWidth: 0.028, // metres, half-width of the core ribbon + guyGlowWidth: 4.6, // halo half-width, × the core + guyGlowOpacity: 0.3, + guyJitter: 0.05, // metres of lateral kink — silk is smooth, so nearly none + guyJitterScale: 0.9, // kinks per metre + guyOctaves: 2, // 1..5 + guyJitterFalloff: 0.5, + guyCrawl: 0.5, // how fast the kinks slide along, per second + guyPinch: 0.22, // 0..1 of the path the kink eases in over at each end + guyRestrike: 2.5, // whole re-shapes per second — slow: this is not lightning + guyFlicker: 0.04, // depth of the whole-bundle stutter + guyFlickerSpeed: 8.0, + guyStrandFlash: 0.12, // per-filament blink + guyCoreSharp: 3.4, // exponent on the core's edge falloff + guyGlowFalloff: 2.4, + guySoftFade: 0.5, // metres of depth fade against the opaque scene + guyOpacity: 0.95, + guyGlow: 1.15, + colorGuyCore: '#fffaf0', // the spine of a guy + colorGuyInner: '#e6dcbe', + colorGuyOuter: '#b9ad86', + colorGuyHalo: '#2c2a1c', // the wide, dim atmosphere round it + + /* --- silk motes: the drag-line on the way out, then dust in the air --- */ + dragRate: 3.2, // motes paid out per metre of drag-line travel + silkRate: 34.0, // motes shed by the standing web, particles/second + silkSize: 0.05, + silkSpeed: 0.7, + silkLifetime: 1.8, + silkRise: 0.25, // upward drift, metres/second + silkTurbulence: 0.55, + colorSilkA: '#fffdf4', + colorSilkB: '#eee6cd', + colorSilkC: '#c6bb98', + colorSilkD: '#5c563f', + + /* --- dust: what the web has already caught, shaken loose --- */ + dustRate: 22.0, // particles/second + dustSize: 0.55, + dustSpeed: 0.5, + dustLifetime: 2.6, + dustRise: 0.18, + dustOpacity: 0.09, + colorDustA: '#6e6a58', + colorDustB: '#5d5a4b', + colorDustC: '#4a483d', + colorDustD: '#2b2a24', + + /* --- chaff: torn fibre, thrown only when the web lets go --- */ + chaffTear: 90, // fibres thrown at the moment of the tear + chaffRate: 18.0, // and shed continuously while it comes down + chaffSize: 0.07, + chaffSpeed: 2.4, + chaffLifetime: 1.5, + chaffGravity: -4.5, // metres/second² — silk falls slowly + colorChaffA: '#fffaf0', + colorChaffB: '#ded4b6', + colorChaffC: '#a99f80', + colorChaffD: '#4c4837', + + /* --- the catch --- */ + burstSize: 1.5, // the puff of air where the drag-line bites, metres + burstIntensity: 0.9, + dustRingRadius: 2.2, // dust knocked off the floor under the anchor, metres + dustRingLife: 1.4, + dustRingIntensity: 0.5, + colorBurstA: '#cfc7a8', + colorBurstB: '#efe7cd', + colorBurstC: '#fffdf4', + colorDustRingA: '#6d6754', + colorDustRingB: '#a89f80', + catchFlash: 0.05, // screen flash as it bites — small, this is not an explosion + colorCatchFlash: '#efe7cd', + impactShake: 0.22, + shakeDuration: 0.5, + rumble: 0.012, // continuous shake while the drag-line pays out + + /* --- dynamic light --- */ + lightIntensity: 9.0, + lightRadius: 12.0, + lightColor: '#d8e0c8', + lightPulse: 0.25, // depth of the light's breathing, 0 = steady + lightPulseSpeed: 2.1 +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Web Line. + * + * Start in **The membrane**. Drag `filmFill` from 0 to 1 while orbiting and + * watch the whole idea appear and then destroy itself: at 0 the web is thread + * and nothing else, at 0.76 the panels flare and vanish as the camera swings, + * and at 1 it is a frosted disc. `grazePower` and `filmBands` are the other two + * that carry the look. + * + * After that, **The graph** — `radius`, `rings` and `spokes` re-span a web that + * is already hanging, and `slack` is the one that decides whether it reads as a + * web or as knitting. + */ +export const weblineSchema = { + 'The cast': [ + ['range', 2, 60, 0.1, 'max range'], + ['minRange', 0, 10, 0.1, 'min range'], + ['speed', 5, 200, 1, 'drag-line speed'], + ['spinTime', 0.05, 5, 0.01, 'spin time'], + ['holdTime', 0.05, 8, 0.01, 'hold time'], + ['fadeTime', 0.05, 5, 0.01, 'tear time'], + ['cooldown', 0, 6, 0.05, 'cooldown'], + ['castAnim', 'cast animation'] + ], + 'Where it hangs': [ + ['handHeight', 0, 3, 0.01, 'hand height'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handSide', -1.5, 1.5, 0.01, 'hand lateral'], + ['hubHeight', 0.2, 6, 0.01, 'hub height'], + ['hubShort', -4, 6, 0.05, 'stand short by'], + ['lean', -1.2, 1.2, 0.01, 'lean off vertical'] + ], + 'The graph': [ + ['rings', 1, 8, 1, 'rings'], + ['spokes', 3, 16, 1, 'spokes'], + ['radius', 0.4, 9, 0.05, 'rim radius'], + ['squash', 0.2, 1.6, 0.01, 'ellipse squash'], + ['ringCurve', 0.3, 3, 0.01, 'ring crowding'], + ['ringJitter', 0, 0.6, 0.01, 'ring jitter'], + ['spokeJitter', 0, 1, 0.01, 'spoke jitter'], + ['twist', -2, 2, 0.01, 'shear'], + ['droop', -2, 4, 0.01, 'disc sag'], + ['depth', 0, 2, 0.01, 'out-of-plane scatter'] + ], + 'The strands': [ + ['slack', 0, 0.5, 0.005, 'sag / span'], + ['sway', 0, 0.6, 0.005, 'breeze'], + ['swayRate', 0, 8, 0.05, 'breeze rate'], + ['strandWidth', 0.002, 0.15, 0.001, 'thread width'], + ['widthJitter', 0, 1, 0.01, 'width jitter'], + ['chordWidth', 0.1, 3, 0.01, 'chord width ×'], + ['strandOpacity', 0, 2, 0.01, 'thread opacity'], + ['strandGlow', 0, 4, 0.01, 'thread glow'], + ['silkPower', 2, 90, 0.5, 'fibre highlight'], + ['silkGain', 0, 6, 0.01, 'fibre gain'], + ['coreBias', 0, 3, 0.01, 'spine bias'], + ['colorStrand', 'thread'], + ['colorSilk', 'fibre highlight'] + ], + 'The membrane': [ + ['filmFill', 0, 1, 0.01, 'faces with film'], + ['filmOpacity', 0, 2, 0.01, 'film opacity'], + ['grazePower', 0.2, 10, 0.05, 'grazing sharpness'], + ['filmBands', 0.2, 8, 0.05, 'interference bands'], + ['filmShift', 0, 3, 0.01, 'per-panel offset'], + ['dish', -0.5, 0.5, 0.005, 'panel belly'], + ['filmSheen', 0, 4, 0.01, 'sheen'], + ['filmSheenPower', 2, 120, 1, 'sheen tightness'], + ['tearBias', 0, 1, 0.01, 'tear the rim first'], + ['filmGlow', 0, 4, 0.01, 'film glow'], + ['colorFilm*', 'Film interference'] + ], + 'Spinning, snap & tear': [ + ['growFeather', 0.01, 1, 0.01, 'spinning front'], + ['orderScatter', 0, 1, 0.01, 'fill order scatter'], + ['snapSway', 0, 2, 0.01, 'catch shiver'], + ['snapRate', 0, 40, 0.1, 'shiver rate'], + ['snapDecay', 0.1, 20, 0.05, 'shiver decay'], + ['snapFlash', 0, 5, 0.01, 'shiver flash'], + ['tearSlack', 0, 1, 0.005, 'let-go sag'], + ['tearDroop', 0, 6, 0.01, 'let-go drop'], + ['tearFilm', 0.05, 1, 0.01, 'film gone by'] + ], + 'The guy-lines': [ + ['guys', 0, 4, 1, 'anchor lines'], + ['guyThreads', 1, 4, 1, 'threads per guy'], + ['guySpread', 0, 0.4, 0.005, 'thread spacing'], + ['guyReach', 0, 3, 0.01, 'anchor reach'], + ['guySplay', -6, 6, 0.05, 'fore/aft splay'], + ['guyFloor', 0, 1, 0.005, 'anchor height'], + ['guySlack', 0, 4, 0.01, 'catenary droop'], + ['guyCurve', 0.01, 4, 0.01, 'catenary curve'], + ['guySwing', 0, 1, 0.005, 'guy sway'], + ['guySwingSpeed', 0, 8, 0.05, 'guy sway rate'], + ['guyTaut', 0, 1, 0.01, 'tautness'], + ['guyLead', 0.05, 1, 0.01, 'laid over'], + ['guyDim', 0, 2, 0.01, 'guy dim'] + ], + 'The guy ribbon': [ + ['guyWidth', 0.002, 0.2, 0.001, 'width'], + ['guyGlowWidth', 1, 20, 0.1, 'halo width'], + ['guyGlowOpacity', 0, 2, 0.01, 'halo opacity'], + ['guyJitter', 0, 1, 0.005, 'kink'], + ['guyJitterScale', 0.05, 6, 0.01, 'kinks / metre'], + ['guyOctaves', 1, 5, 1, 'octaves'], + ['guyJitterFalloff', 0.1, 0.95, 0.01, 'octave falloff'], + ['guyCrawl', -10, 10, 0.05, 'kink crawl'], + ['guyPinch', 0.01, 0.5, 0.005, 'end pinch'], + ['guyRestrike', 0.2, 40, 0.1, 'restrikes / sec'], + ['guyFlicker', 0, 1, 0.01, 'stutter'], + ['guyFlickerSpeed', 1, 60, 1, 'stutter rate'], + ['guyStrandFlash', 0, 1, 0.01, 'per-thread blink'], + ['guyCoreSharp', 0.5, 12, 0.05, 'core sharpness'], + ['guyGlowFalloff', 0.2, 8, 0.05, 'halo falloff'], + ['guySoftFade', 0.02, 3, 0.01, 'soft intersection'], + ['guyOpacity', 0, 2, 0.01, 'opacity'], + ['guyGlow', 0, 6, 0.01, 'glow'], + ['colorGuyCore', 'guy core'], + ['colorGuyInner', 'guy inner'], + ['colorGuyOuter', 'guy outer'], + ['colorGuyHalo', 'guy halo'] + ], + 'Silk, dust & chaff': [ + ['dragRate', 0.1, 20, 0.1, 'drag motes / metre'], + ['silkRate', 0, 400, 1, 'silk rate'], + ['silkSize', 0.005, 0.4, 0.005, 'silk size'], + ['silkSpeed', 0, 8, 0.05, 'silk speed'], + ['silkLifetime', 0.1, 8, 0.05, 'silk lifetime'], + ['silkRise', -2, 4, 0.01, 'silk rise'], + ['silkTurbulence', 0, 3, 0.01, 'silk turbulence'], + ['dustRate', 0, 300, 1, 'dust rate'], + ['dustSize', 0.05, 4, 0.01, 'dust size'], + ['dustSpeed', 0, 6, 0.05, 'dust speed'], + ['dustLifetime', 0.2, 8, 0.05, 'dust lifetime'], + ['dustRise', -2, 3, 0.01, 'dust rise'], + ['dustOpacity', 0, 1, 0.005, 'dust opacity'], + ['chaffTear', 0, 400, 1, 'fibres at the tear'], + ['chaffRate', 0, 200, 1, 'fibre rate'], + ['chaffSize', 0.005, 0.4, 0.005, 'fibre size'], + ['chaffSpeed', 0, 20, 0.1, 'fibre speed'], + ['chaffLifetime', 0.1, 5, 0.05, 'fibre lifetime'], + ['chaffGravity', -30, 2, 0.1, 'fibre gravity'], + ['colorSilk*', 'Silk colour'], + ['colorDust*', 'Dust colour'], + ['colorChaff*', 'Fibre colour'] + ], + 'The catch': [ + ['burstSize', 0.1, 8, 0.05, 'air puff'], + ['burstIntensity', 0, 3, 0.01, 'puff intensity'], + ['dustRingRadius', 0.1, 8, 0.05, 'dust ring radius'], + ['dustRingLife', 0.1, 6, 0.05, 'dust ring life'], + ['dustRingIntensity', 0, 2, 0.01, 'dust ring intensity'], + ['catchFlash', 0, 1, 0.005, 'screen flash'], + ['impactShake', 0, 3, 0.01, 'catch shake'], + ['shakeDuration', 0.05, 2, 0.01, 'shake decay'], + ['rumble', 0, 0.3, 0.001, 'travel rumble'], + ['colorBurstA', 'puff inner'], + ['colorBurstB', 'puff mid'], + ['colorBurstC', 'puff rim'], + ['colorDustRingA', 'dust ring'], + ['colorDustRingB', 'dust ring edge'], + ['colorCatchFlash', 'screen flash'] + ], + 'Light': [ + ['lightIntensity', 0, 60, 0.5, 'intensity'], + ['lightRadius', 1, 40, 0.5, 'radius'], + ['lightPulse', 0, 1, 0.01, 'breathing depth'], + ['lightPulseSpeed', 0, 20, 0.1, 'breathing rate'], + ['lightColor', 'colour'] + ] +}; diff --git a/src/config/abilities/wildfire.js b/src/config/abilities/wildfire.js new file mode 100644 index 0000000..db4ee06 --- /dev/null +++ b/src/config/abilities/wildfire.js @@ -0,0 +1,379 @@ +import { Medium, volumeHullDefaults, volumeHullSchema } from '../../vfx/VolumeHull.js'; + +/* ================================================================== */ +/* WILDFIRE — spread as a cellular automaton */ +/* ================================================================== */ +/** + * A far cast. A seed of fire lands in the circle and then **spreads**: cell to + * cell across a grid, biased by a wind, jumping gaps as spot fires, refusing to + * cross ground with no fuel in it, and curling back over ground it has already + * burnt. Not a growing circle — a front. + * + * ### The block below is the rule table, and it is worth reading as one + * + * Everything from `grid` to `seedSpread` is the automaton. None of it is a + * dimension: they are counts, probabilities per tick, and fractions. The + * simulation therefore has no idea how big it is, which is exactly why + * `zoneRadius` stays live over a running fire — the pitch of the lattice is + * `2 · zoneRadius / grid`, re-resolved every frame, and the *pattern* the + * automaton produced is untouched by the rescale. + * + * Three of the rules are the ones that make it read as wildfire rather than as + * a flood fill, and all three are worth dragging: + * + * - **`fuelFloor`** is where the islands come from. Fuel is a two-octave + * value-noise field rolled once per cast, and a cell below the floor never + * catches at all. At 0 the fire fills the circle like paint; by about 0.35 + * there are unburnt patches the front has to go *around*, and going around + * something is what makes a boundary look alive. Turn it up past 0.55 and + * the fire cannot get out of its own seed. + * - **`windBias`** skews the per-neighbour probability by the dot product of + * the step direction with the wind, so the burn goes oval and the upwind + * edge crawls. This is also the direction the flames lean. + * - **`spotChance`** is the ember jump: a burning cell occasionally lights one + * two to `spotRange` cells downwind, over whatever is in between. Spot fires + * are what make a real fire front unpredictable, and each one drops a scorch + * on the floor where it landed. + * + * **`reburnChance`** is the fourth, quieter one. A burnt cell keeps whatever + * fuel `consume` left it, and if that is still above `reburnFloor` a neighbour + * can set it going again — which is how the front ends up burning back over + * itself instead of only ever advancing. + * + * ### What a cast captures + * + * The fuel map (unitless, rolled once — it is the terrain), each cell's state, + * and the timestamp it caught. Every metre, second of duration and colour is + * resolved from this block on the frame it is drawn. + */ +export const wildfire = { + /* --- the cast --- */ + range: 20.0, // maximum cast distance, metres + minRange: 4.0, // closer than this and the cast is refused + speed: 40.0, // how fast the ignition dart flies to the circle, metres/second + zoneRadius: 6.5, // the footprint the circle indicator draws, metres + holdTime: 3.4, // seconds the fire spreads freely + smoulderTime: 2.6, // seconds it takes to run out and go dark + cooldown: 1.8, // seconds + castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws + handForward: 0.6, // metres in front of the caster the dart leaves + handHeight: 1.3, // metres above the floor + + /* --- the automaton (all unitless: counts, chances, fractions) --- */ + grid: 26, // cells across the zone. A topology, captured at spawn + tickRate: 12, // automaton generations per second + spread: 0.36, // chance a burning cell lights an orthogonal neighbour, per tick + diagonalBias: 0.62, // that chance, × for a diagonal neighbour + windBias: 0.85, // how hard the wind skews the chance, ± along the wind + windAngle: 0.25, // radians, measured off the cast heading + fuelFloor: 0.36, // fuel a cell needs before it can catch — this makes the islands + fuelScale: 3.2, // fuel patches across the zone; low is big country, high is speckle + fuelBias: 0.06, // pushes the whole fuel field up or down + spotChance: 0.045, // chance per burning cell per tick of throwing a spot fire + spotRange: 4, // cells a spot fire can jump over + consume: 0.55, // fraction of a cell's fuel one burn eats + reburnFloor: 0.42, // fuel a spent cell needs before it can catch again + reburnChance: 0.12, // × the normal chance, for a spent cell catching again + seeds: 3, // ignition points dropped when the dart lands + seedSpread: 0.16, // how far those scatter, as a fraction of the radius + + /* --- how one cell burns --- */ + catchTime: 0.22, // seconds a cell takes to come up to full heat + burnTime: 2.3, // seconds a *full-fuel* cell burns for; a poor cell burns briefly + charTime: 0.6, // seconds the char takes to blacken + ashTime: 2.2, // seconds after burnout the ash takes to grey over + cellFill: 1.12, // drawn cell width, × the lattice pitch — over 1 so cells touch + cellJitter: 0.22, // per-cell placement slop, × the pitch + blockiness: 0.55, // 0 a disc, 1 a square — how visible the lattice is + cellRough: 0.5, // 0..1 noise chewed out of a cell's edge + cellRoughScale: 2.6, // features per metre on that noise + cellVein: 0.55, // 0..1 how much of the ember is vein rather than wash + cellLift: 0.014, // metres above the floor the scar sits + cellOpacity: 1.0, + cellGlow: 2.0, + cellSoftFade: 0.3, // metres of depth feather + colorFlash: '#fff0cc', // the frame a cell catches + colorEmber: '#ff6a16', // what stays hot afterwards + colorChar: '#120c0a', // burnt ground + colorAsh: '#57504a', // and what it greys over to + + /* --- the flames standing on the burning cells --- */ + flameHeight: 0.9, // metres at full heat on a full-fuel cell + flameWidth: 0.17, // metres, half-width at the base + flameLean: 0.32, // metres the crown leans downwind + flameWaver: 0.05, // metres of sway at the crown + flameWaverRate: 3.6, // sways per second + flameTaper: 1.25, // >1 pulls a tongue to a point + flameBulge: 0.5, // how much it swells above the base + flameNoiseScale: 2.1, // features per metre + flameNoiseSpeed: 2.6, // metres/second the field climbs + flameErosion: 0.95, // how hard the noise eats the silhouette + flameOpacity: 1.0, + flameGlow: 1.6, + flameSoftFade: 0.4, // metres of depth feather + colorFlameA: '#fff2c4', // at the foot + colorFlameB: '#ff9424', + colorFlameC: '#c72e08', + colorFlameD: '#1c1414', // the smoke at the tip + + /* --- the pall over the front (Medium.FLAME, prefix `pall`) --- */ + // It follows the *burning cells*, not the zone: the hull is placed on their + // centroid and sized to their extent, both re-derived every frame. A hull over + // the whole circle would spend most of its step budget in vacuum, which is the + // one failure `VolumeHull` warns about twice. + pallSpread: 1.15, // × the front's own extent + pallHeight: 1.1, // metres, half-height of the hull + pallSlack: 1.14, // proxy hull slack, × + pallCells: 42, // burning cells for a fully opaque pall + ...volumeHullDefaults('pall', Medium.FLAME, { + pallSteps: 24, // march steps — the cost knob + pallJitter: 1.0, // step dither; 0 only ever shows you the banding + pallContact: 0.5, // metres of fade where it meets the floor + pallMargin: 0.26, // headroom inside the hull for the erosion + pallDensity: 1.5, // thinner than a jet: this is the haze over a front + pallNoiseFrequency: 1.4, // features per metre + pallNoiseStrength: 0.95, + pallRise: 1.9, // buoyant rise, metres/second + pallEmission: 2.2, + pallShadowTaps: 0, // an emissive medium does not need a lit side + pallSpeckDensity: 0.05, + pallSpeckGlow: 7.0 + }), + + /* --- the scorch a spot fire leaves where it lands (GroundMode.POCK) --- */ + spotEdge: 0.4, // metres of feather on the field's own front + spotRagged: 0.3, // how far that front wanders + spotRaggedScale: 0.8, // lobes per metre + spotWarp: 0.5, // metres of domain warp + spotDepth: 0.06, // metres of bowl under a spot fire + spotLift: 0.02, // metres of lip around it + spotRelief: 0.5, // how hard the height field tilts the fake normal + spotNormalStep: 0.06, // metres between the height taps + spotAmbient: 0.3, + spotWrap: 0.45, + spotSpecular: 0.12, // burnt ground is not shiny + spotGloss: 16, + spotParallax: 0.2, + spotMarkLife: 8.0, // seconds a spot scorch weathers away over + spotMarkRadius: 0.55, // metres, a full-strength spot scorch + spotHeight: 0.01, // metres above the floor the quad sits + spotEmissive: 1.0, + spotOpacity: 0.9, + spotDepthFade: 0.4, + colorSpotBase: '#2a1f19', + colorSpotEdge: '#6a5a4c', + colorSpotGlow: '#ff5a12', + colorSpotDeep: '#0a0706', + + /* --- embers and smoke --- */ + /** + * Two systems, each with its own four-stop lifetime gradient sampled `A` at + * birth through `D` as it dies. Both are emitted from cells the automaton says + * are burning, so the particles show the same front the geometry does. + */ + moteRate: 170, // embers lifting off the front, particles/second + moteSize: 0.065, + moteSpeed: 1.6, + moteLifetime: 1.7, + moteRise: 2.0, // upward drift, metres/second + moteTurbulence: 0.9, + moteGlow: 2.0, + colorMoteA: '#fff0c8', + colorMoteB: '#ff8e22', + colorMoteC: '#c02c08', + colorMoteD: '#241210', + smokeRate: 55, // pall off the burnt ground, particles/second + smokeSize: 1.1, + smokeSpeed: 1.2, + smokeLifetime: 3.0, + smokeRise: 0.75, + smokeOpacity: 0.085, + colorSmokeA: '#514640', + colorSmokeB: '#3e3630', + colorSmokeC: '#2e2825', + colorSmokeD: '#191514', + + /* --- feedback --- */ + landShake: 0.4, // the kick as the dart lands + shakeDuration: 0.5, // seconds that kick takes to die + landFlash: 0.14, // screen flash on landing + castFlash: 0.05, // screen flash on release + colorCastFlash: '#ffab5e', + burstSize: 2.2, // the shell where the dart lands, metres + burstIntensity: 1.5, + rumble: 0.02, // continuous shake while the fire burns + colorBurstA: '#c72e08', + colorBurstB: '#ff9424', + colorBurstC: '#fff2c4', + + /* --- dynamic light --- */ + lightIntensity: 20, // it rides the centroid of the burning cells + lightRadius: 14, + lightHeight: 0.9, // metres above the floor the light sits + lightColor: '#ff7a2e', + lightFlicker: 0.3, // depth of the gutter, 0 = steady + lightFlickerSpeed: 11 // gutters per second +}; + +/* ------------------------------------------------------------------ */ +/* Editor layout */ +/* ------------------------------------------------------------------ */ +/** + * Wildfire. + * + * **The automaton** is the folder that matters, and it behaves like a rule + * table rather than like a look: change one number and the *shape of the burn* + * changes, not its colour. `fuelFloor`, `windBias` and `spotChance` are the + * three that carry the character. Everything in it takes effect on the next + * generation, so a paused fire keeps whatever pattern it had and resumes under + * the new rules — which is the honest behaviour for a simulation and the one + * place in this block where a slider is *not* retroactive. + * + * Everything else — the pitch of the lattice, how long a cell burns, how tall + * its flame is, where the pall sits — re-resolves every frame, paused included. + * + * The `pallBoil*` and `pallVoid*` keys `volumeHullDefaults` emits belong to + * other media and do nothing to a FLAME hull, so they are left to the trailing + * "More" folder. + */ +export const wildfireSchema = { + 'The cast': [ + ['range', 4, 60, 0.1, 'max range'], + ['minRange', 0, 12, 0.1, 'min range'], + ['speed', 5, 200, 1, 'dart speed'], + ['zoneRadius', 1, 16, 0.1, 'footprint radius'], + ['holdTime', 0.2, 12, 0.05, 'spread time'], + ['smoulderTime', 0.2, 10, 0.05, 'smoulder time'], + ['cooldown', 0, 8, 0.05, 'cooldown'], + ['castAnim', 'cast animation'], + ['handForward', -1, 3, 0.01, 'hand forward'], + ['handHeight', 0, 3, 0.01, 'hand height'] + ], + 'The automaton': [ + ['grid', 8, 30, 1, 'cells across'], + ['tickRate', 1, 40, 0.5, 'generations / sec'], + ['spread', 0, 1, 0.005, 'catch chance'], + ['diagonalBias', 0, 1.5, 0.01, 'diagonal ×'], + ['windBias', 0, 2, 0.01, 'wind skew'], + ['windAngle', -3.15, 3.15, 0.01, 'wind angle (rad)'], + ['fuelFloor', 0, 0.9, 0.01, 'fuel floor'], + ['fuelScale', 0.5, 10, 0.1, 'fuel patches'], + ['fuelBias', -0.5, 0.5, 0.01, 'fuel bias'], + ['spotChance', 0, 0.4, 0.002, 'spot-fire chance'], + ['spotRange', 2, 10, 1, 'spot range (cells)'], + ['consume', 0, 1, 0.01, 'fuel consumed'], + ['reburnFloor', 0, 1, 0.01, 'reburn floor'], + ['reburnChance', 0, 1, 0.01, 'reburn ×'], + ['seeds', 1, 12, 1, 'ignition points'], + ['seedSpread', 0, 1, 0.01, 'seed scatter'] + ], + 'How a cell burns': [ + ['catchTime', 0.01, 2, 0.01, 'catch (s)'], + ['burnTime', 0.1, 10, 0.05, 'burn (s)'], + ['charTime', 0.05, 5, 0.05, 'char (s)'], + ['ashTime', 0.1, 10, 0.05, 'ash (s)'], + ['cellFill', 0.2, 2, 0.01, 'cell width × pitch'], + ['cellJitter', 0, 1, 0.01, 'placement slop × pitch'], + ['blockiness', 0, 1, 0.01, 'lattice visibility'], + ['cellRough', 0, 1.5, 0.01, 'edge chew'], + ['cellRoughScale', 0.1, 8, 0.05, 'chew / metre'], + ['cellVein', 0, 1, 0.01, 'ember vein'], + ['cellLift', 0, 0.2, 0.001, 'scar height (m)'], + ['cellOpacity', 0, 2, 0.01, 'opacity'], + ['cellGlow', 0, 8, 0.05, 'ember glow'], + ['cellSoftFade', 0.02, 2, 0.01, 'soft intersection (m)'], + ['colorFlash', 'catch'], + ['colorEmber', 'ember'], + ['colorChar', 'char'], + ['colorAsh', 'ash'] + ], + 'The flames': [ + ['flameHeight', 0, 5, 0.01, 'height (m)'], + ['flameWidth', 0.02, 1, 0.01, 'half-width (m)'], + ['flameLean', -1, 1.5, 0.01, 'downwind lean (m)'], + ['flameWaver', 0, 0.5, 0.005, 'crown sway (m)'], + ['flameWaverRate', 0, 12, 0.05, 'sways / sec'], + ['flameTaper', 0.1, 4, 0.01, 'taper'], + ['flameBulge', 0, 2, 0.01, 'bulge'], + ['flameNoiseScale', 0.1, 8, 0.05, 'features / metre'], + ['flameNoiseSpeed', 0, 12, 0.05, 'climb (m/s)'], + ['flameErosion', 0, 3, 0.01, 'erosion'], + ['flameOpacity', 0, 2, 0.01, 'opacity'], + ['flameGlow', 0, 6, 0.05, 'glow'], + ['flameSoftFade', 0.02, 2, 0.01, 'soft intersection (m)'], + ['colorFlame*', 'Flame colour'] + ], + 'The pall': [ + ['pallSpread', 0.2, 3, 0.01, 'hull radius × front'], + ['pallHeight', 0.1, 5, 0.01, 'hull half-height (m)'], + ['pallSlack', 1, 2, 0.01, 'proxy hull slack'], + ['pallCells', 4, 200, 1, 'cells for a full pall'] + ], + ...volumeHullSchema('pall', { + label: 'Pall', + only: ['march', 'shape', 'field', 'flow', 'optics', 'speck', 'colour'] + }), + 'Spot-fire scorches': [ + ['spotEdge', 0.02, 2, 0.01, 'front feather (m)'], + ['spotRagged', 0, 1, 0.01, 'front wander'], + ['spotRaggedScale', 0.05, 4, 0.01, 'lobes / metre'], + ['spotWarp', 0, 3, 0.01, 'lobe warp (m)'], + ['spotDepth', 0, 0.5, 0.005, 'bowl depth (m)'], + ['spotLift', 0, 0.3, 0.002, 'rim height (m)'], + ['spotRelief', 0, 2, 0.01, 'relief'], + ['spotNormalStep', 0.01, 0.3, 0.005, 'normal step (m)'], + ['spotAmbient', 0, 1, 0.01, 'ambient'], + ['spotWrap', 0, 1, 0.01, 'terminator wrap'], + ['spotSpecular', 0, 2, 0.01, 'specular'], + ['spotGloss', 1, 80, 1, 'gloss'], + ['spotParallax', 0, 1, 0.01, 'parallax (m)'], + ['spotMarkLife', 0.5, 20, 0.1, 'scorch life (s)'], + ['spotMarkRadius', 0.05, 3, 0.01, 'scorch radius (m)'], + ['spotHeight', 0, 0.2, 0.001, 'quad height (m)'], + ['spotEmissive', 0, 4, 0.01, 'emissive'], + ['spotOpacity', 0, 2, 0.01, 'opacity'], + ['spotDepthFade', 0.05, 3, 0.01, 'soft fade (m)'], + ['colorSpotBase', 'scorched ground'], + ['colorSpotEdge', 'rim'], + ['colorSpotGlow', 'hot centre'], + ['colorSpotDeep', 'deep burn'] + ], + 'Embers & smoke': [ + ['moteRate', 0, 900, 1, 'ember rate'], + ['moteSize', 0.005, 0.5, 0.005, 'ember size'], + ['moteSpeed', 0, 12, 0.05, 'ember speed'], + ['moteLifetime', 0.1, 8, 0.05, 'ember lifetime'], + ['moteRise', -2, 8, 0.05, 'ember rise'], + ['moteTurbulence', 0, 3, 0.01, 'ember turbulence'], + ['moteGlow', 0, 6, 0.05, 'ember glow'], + ['smokeRate', 0, 500, 1, 'smoke rate'], + ['smokeSize', 0.05, 4, 0.01, 'smoke size'], + ['smokeSpeed', 0, 8, 0.05, 'smoke speed'], + ['smokeLifetime', 0.2, 10, 0.05, 'smoke lifetime'], + ['smokeRise', -2, 4, 0.01, 'smoke rise'], + ['smokeOpacity', 0, 1, 0.005, 'smoke opacity'], + ['colorMote*', 'Ember colour'], + ['colorSmoke*', 'Smoke colour'] + ], + 'Landing & feedback': [ + ['burstSize', 0.2, 10, 0.05, 'burst size'], + ['burstIntensity', 0, 5, 0.01, 'burst intensity'], + ['landShake', 0, 3, 0.01, 'landing shake'], + ['shakeDuration', 0.05, 3, 0.01, 'shake decay (s)'], + ['landFlash', 0, 2, 0.01, 'landing flash'], + ['castFlash', 0, 2, 0.01, 'release flash'], + ['rumble', 0, 0.3, 0.002, 'burn rumble'], + ['colorBurstA', 'burst shell'], + ['colorBurstB', 'burst body'], + ['colorBurstC', 'burst core'], + ['colorCastFlash', 'flash colour'] + ], + 'Dynamic light': [ + ['lightIntensity', 0, 120, 0.5, 'light intensity'], + ['lightRadius', 0.5, 50, 0.1, 'light radius'], + ['lightHeight', 0, 4, 0.01, 'light height (m)'], + ['lightFlicker', 0, 1, 0.01, 'light gutter'], + ['lightFlickerSpeed', 1, 60, 1, 'gutter rate'], + ['lightColor', 'light colour'] + ] +}; diff --git a/src/config/castShape.js b/src/config/castShape.js new file mode 100644 index 0000000..3152cf2 --- /dev/null +++ b/src/config/castShape.js @@ -0,0 +1,26 @@ +/** + * castShape.js — how an ability is aimed. + * + * `LINE` is the skillshot the sandbox started with: an arrow swung about the + * caster, cast along its length. `ZONE` is the **far cast** — a circle with a + * thick boundary dropped at the cursor, which answers the only question a + * ground-targeted AoE has to answer before you commit: how much space is this + * going to take. Both resolve to the same `cast(origin, direction, distance)` + * event, so an ability never has to care which one aimed it; a zone ability + * simply reads its target as `pointAt(1)` and works outward from there. + * + * This lives in its own two-line module for one reason: the **registry** needs + * it to declare a descriptor, and `settings.js` needs the registry to derive + * `ELEMENTS` / `ELEMENT_META`. Leaving the enum in `settings.js` would close + * that loop into an import cycle, and an ES-module cycle resolves to + * `undefined` at exactly the moment the registry is being evaluated — the + * failure looks like every ability silently becoming a line cast. Splitting the + * constant out is cheaper than debugging that twice. + * + * `settings.js` re-exports it, so `import { CastShape } from '../config/settings.js'` + * keeps working everywhere it is already written. + */ +export const CastShape = Object.freeze({ + LINE: 'line', + ZONE: 'zone' +}); diff --git a/src/config/globals.js b/src/config/globals.js new file mode 100644 index 0000000..45e082b --- /dev/null +++ b/src/config/globals.js @@ -0,0 +1,69 @@ +/** + * globals.js — the master multipliers, extracted so the load order works. + * + * This is `settings.global`, and it is *the same object*: `config/settings.js` + * writes `global: globals` rather than re-declaring the block, so the editor, + * the presets and every consumer below still mutate one identity. Nothing about + * the shape changed when it moved. + * + * ## Why it is its own file + * + * The same reason `config/castShape.js` is: an import cycle that has to be + * broken *outside* `settings.js` rather than inside it. + * + * `vfx/Shell.js` and `vfx/Tube.js` want `settings.global` as the default third + * argument to `sync()`. Meanwhile an ability's settings module is entitled to + * spread `shellDefaults(…)` / `tubeDefaults(…)` into its own block — the tech + * library documents that as the intended usage, and eight of the fifty do it. + * Put those two facts together and the module graph closes a loop: + * + * ``` + * config/abilities/index.js → abilities/thunderclap.js → vfx/Shell.js + * → config/settings.js → config/abilities/index.js + * ``` + * + * That loop is fatal, not merely ugly. Whichever of `registry.js` / + * `settings.js` / `abilities/index.js` is imported first, one of them ends up + * running its body while another is still resolving dependencies, and the + * symptom is a bare `ReferenceError: Cannot access 'ABILITY_SETTINGS' before + * initialization` thrown from a line that has been correct for months. It was + * diagnosed the hard way; the fix is that the two modules a *settings* module + * may transitively pull in must not reach back to `settings.js`. + * + * So this file imports **nothing**. It is a leaf, and it can be depended on + * from anywhere without closing anything. + * + * `vfx/Distortion.js` still imports `config/settings.js` and is fine, because + * it also needs `settings.post` and no settings module imports it. If one ever + * does, the same treatment applies to `post`. + */ + +/** + * Multipliers that scale everything at once. `1` is neutral for every one of + * them, which is what makes a preset diff readable. + */ +export const globals = { + timeScale: 1.0, // slow-mo / fast forward for the whole simulation + speed: 1.0, // eruption travel speed multiplier + lifetime: 1.0, // ability lifetime multiplier + glow: 1.0, // emissive multiplier fed into bloom + shaderIntensity: 1.0, // master strength of every procedural shader effect + noiseStrength: 1.0, + noiseFrequency: 1.0, + noiseSpeed: 1.0, + turbulence: 1.0, + randomness: 1.0, // per-instance / per-particle jitter multiplier + particleCount: 1.0, + particleLifetime: 1.0, + particleSpeed: 1.0, + particleSize: 1.0, + emissionRate: 1.0, + lightIntensity: 1.0, + lightRadius: 1.0, + distortion: 1.0, + fresnel: 1.0, + opacity: 1.0, + animationSpeed: 1.0, // character animation playback rate + cameraShake: 1.0, + explosionIntensity: 1.0 +}; diff --git a/src/config/settings.js b/src/config/settings.js index 2993154..cee63e3 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -13,21 +13,53 @@ * later — see `IceAbility`, whose spike records hold nothing but unitless dice * rolls, and resolve every metre, radian and second against this file each frame. * + * ## What lives here, and what does not + * + * This file holds the values that belong to the *stage* rather than to any one + * spell: `global`, `aim`, `zone`, `character`, `camera`, `environment`, `post`. + * The per-ability blocks moved out to `config/abilities/.js`, one module + * each, and are spread back in below. At six abilities that was a nicety; at + * fifty it is the difference between a file you can open and a file you cannot, + * and it means two agents adding two abilities never touch the same line. + * + * The spread is a *reference* copy — `settings.ice` and + * `ABILITY_SETTINGS.ice` and `getAbility('ice').settings` are all the same + * object. Nothing here clones a block, because a clone would break the live + * binding the editor depends on. + * + * `ELEMENTS` and `ELEMENT_META` are likewise no longer authored: they are + * **derived views** over `abilities/registry.js`, kept exported with exactly + * the shape they always had so `AimController`, the HUD and `App` never learnt + * that anything moved. + * * Conventions * - Colours are stored as `#rrggbb` strings so lil-gui can bind them directly. * Use `utils/color.js#getColor()` to read them as a cached THREE.Color. * - `global` holds multipliers that scale everything at once (1 = neutral). - * - The per-ability blocks (`ice`, `thunder`, `meteor`, `beam`) hold absolute values. + * - The per-ability blocks (`ice`, `thunder`, `meteor`, …) hold absolute values. * * Every ability block is keyed by its id in `ELEMENTS`, and the shared systems * that need to know about "the ability the player is currently holding" — the * aim controller, the cooldown, the HUD — look it up as `settings[element]`. * The four fields they rely on being present are `range`, `minRange`, `speed` * and `cooldown`; everything else in a block is that ability's own business. - * A **far cast** (`CastShape.ZONE`, declared in `ELEMENT_META`) adds a fifth: + * A **far cast** (`CastShape.ZONE`, declared in the registry) adds a fifth: * `zoneRadius`, the footprint the circle indicator measures out. */ +import { globals } from './globals.js'; +import { ABILITY_SETTINGS } from './abilities/index.js'; +import { ABILITIES, ABILITY_IDS } from '../abilities/registry.js'; +import { CastShape } from './castShape.js'; + +/** + * Re-exported so `import { CastShape } from '../config/settings.js'` keeps + * working. The enum itself lives in `config/castShape.js`, because the registry + * needs it and this file needs the registry — see that module's header for why + * the cycle has to be broken there rather than here. + */ +export { CastShape }; + /** * The cast animations shipped alongside the rig, in `public/models/.fbx`. * @@ -42,31 +74,14 @@ export const settings = { /* ------------------------------------------------------------------ */ /* Global multipliers */ /* ------------------------------------------------------------------ */ - global: { - timeScale: 1.0, // slow-mo / fast forward for the whole simulation - speed: 1.0, // eruption travel speed multiplier - lifetime: 1.0, // ability lifetime multiplier - glow: 1.0, // emissive multiplier fed into bloom - shaderIntensity: 1.0, // master strength of every procedural shader effect - noiseStrength: 1.0, - noiseFrequency: 1.0, - noiseSpeed: 1.0, - turbulence: 1.0, - randomness: 1.0, // per-instance / per-particle jitter multiplier - particleCount: 1.0, - particleLifetime: 1.0, - particleSpeed: 1.0, - particleSize: 1.0, - emissionRate: 1.0, - lightIntensity: 1.0, - lightRadius: 1.0, - distortion: 1.0, - fresnel: 1.0, - opacity: 1.0, - animationSpeed: 1.0, // character animation playback rate - cameraShake: 1.0, - explosionIntensity: 1.0 - }, + /** + * Authored in `config/globals.js` and referenced, not copied, so + * `settings.global === globals`. It lives in its own leaf module because + * `vfx/Shell.js` and `vfx/Tube.js` need it and an ability's settings module + * is allowed to import *them* — see that file's header for the cycle it + * breaks, which is the same argument `config/castShape.js` makes. + */ + global: globals, /* ------------------------------------------------------------------ */ /* The aim indicator — the ground arrow drawn while the cast is armed */ @@ -223,1492 +238,22 @@ export const settings = { }, /* ================================================================== */ - /* ICE — ability one */ - /* ================================================================== */ - /** - * A glacial eruption: a fracture front races out along the aimed line and a - * field of crystal spikes tears up out of the floor behind it, small and dense - * at the caster, tall and violent at the far end. - * - * Everything is generated — the crystals are procedural geometry - * (`assets/ProceduralGeometry.js`), their shading is a patched standard - * material (`materials/IceMaterial.js`), the frost is a shader on a quad and - * the mist, shards and glitter are GPU particles. There are no textures and no - * meshes on disk. - */ - ice: { - /* --- the cast itself --- */ - range: 15.0, // maximum cast distance, metres - minRange: 2.5, // closer than this and the cast is refused - speed: 26.0, // how fast the fracture front travels, metres/second - lifetime: 3.6, // seconds the field stands before it withdraws - cooldown: 0.4, // seconds before the ability can be armed again - castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws - - /* --- the footprint the spikes fill --- */ - widthNear: 0.55, // half-width of the band at the caster, metres - width: 2.5, // half-width at the far end, metres - widthCurve: 0.75, // <1 flares early, >1 stays narrow then opens out - spikeCount: 190, // instances spent on one cast (capped at 288) - density: 1.0, // multiplier on that count - clumping: 1.35, // >1 pulls spikes toward the centre line - scatter: 0.55, // extra lateral jitter, fraction of the local half-width - frontBias: 0.85, // <1 crowds spikes toward the impact point - - /* --- silhouette of the field --- */ - heightNear: 0.5, // spike height at the caster, metres - height: 3.1, // spike height at the far end, metres - heightCurve: 1.7, // how late the ramp climbs - heightJitter: 0.55, - crown: 0.55, // how much shorter the flank blades are than the spine, 0..1 - peak: 1.45, // extra height multiplier at the impact point - peakWidth: 0.28, // how much of the line that swell covers, 0..1 - rubble: 0.42, // fraction of the spikes demoted to ankle-height shards - rubbleScale: 0.3, - - /* --- an individual crystal --- */ - radius: 0.41, // base radius, metres - radiusJitter: 0.93, - taper: 0.69, // tip radius as a fraction of the base - facets: 7, // sides of the prism (5–8 read best) - roughness: 0.09, // how far the facets are pushed off a clean prism - bend: 0.66, // sideways curve from base to tip - lean: 0.42, // radians the spikes lean away from the caster - leanJitter: 1.5, - twist: 1.0, // random yaw, 0..1 of a full turn - - /* --- the eruption --- */ - riseTime: 0.17, // seconds from buried to full height - riseOvershoot: 0.26, // how far past full height the punch carries - riseStagger: 0.09, // seconds of random delay between neighbours - settle: 0.55, // seconds the overshoot takes to damp out - shatterDelay: 0.6, // seconds after `lifetime` before they start to go - sinkTime: 1.0, // seconds to withdraw into the floor - - /* --- the ice material --- */ - colorDeep: '#3e737a', // the colour thick ice accumulates toward - colorIce: '#8adaff', // body - colorRim: '#f2feff', // fresnel edge - colorCore: '#638797', // the light trapped inside a fresh crystal - opacity: 0.92, - depthTint: 1.15, // how fast the deep tint builds with thickness - fresnel: 2.3, - fresnelPower: 2.4, - translucency: 1.5, // light bleeding through from behind - envIntensity: 0.9, // how much of the HDR probe the facets catch - facetSharp: 0.68, // crispness of the internal facet shading - fracture: 0.62, // internal crack planes - fractureScale: 6.5, // cracks per metre - veins: 0.45, // milky feather-frost inside the crystal - veinScale: 3.2, - // Named `glint*` rather than `sparkle*` on purpose: these are the pinpoint - // highlights on the crystal *surface*, and the `sparkle*` family further - // down drives the glitter *particles*. Two different effects. - glint: 1.1, - glintScale: 34.0, - glintSpeed: 0.7, - frostLine: 0.5, // rime banding climbing the crystal - glow: 0.85, // overall emissive gain - edgeGlow: 1.1, // brightness of the silhouette rim - birthGlow: 1.6, // extra glow on a crystal that has just erupted - birthFade: 0.45, // seconds that birth flash lasts - - /* --- what the ground does --- */ - frostSpread: 1.35, // frost patch radius, × the local half-width - frostRate: 3.6, // patches laid per metre of front travel - frostLife: 7.0, // seconds a patch lingers - frostIntensity: 0.85, - frostCrystals: 1.5, // grain of the packed snow - colorFrost: '#f0f9ff', // the lit face of the snow - colorFrostEdge: '#79b6dd', // what it goes in its own shadow - shockRadius: 5.5, // impact shockwave ring, metres - colorShockA: '#5fd0ff', // body of the shockwave ring - colorShockB: '#f2feff', // its crest - - /* --- mist, shards and glitter --- */ - /** - * Every particle system is coloured by a four-stop gradient sampled over the - * particle's own lifetime: `A` the instant it is born, `D` as it dies. They - * are spelled out rather than derived from the crystal palette so the fog can - * be warmed, or the glitter recoloured, without touching the ice itself. - */ - mistRate: 260, // rolling ground fog, particles/second - mistSize: 1.15, - mistSpeed: 1.3, - mistLifetime: 2.8, - mistOpacity: 0.05, - mistRise: 0.35, // how fast the fog lifts, metres/second - colorMistA: '#f2feff', - colorMistB: '#cdefff', - colorMistC: '#a9e4ff', - colorMistD: '#09304c', - shardRate: 150, // ice chips thrown off the eruption - shardSize: 0.075, - shardSpeed: 7.0, - shardLifetime: 1.7, - shardGravity: -14.0, - colorShardA: '#f2feff', - colorShardB: '#a9e4ff', - colorShardC: '#a9e4ff', - colorShardD: '#12496f', - sparkleRate: 130, // the rising glitter plume - sparkleSize: 0.055, - sparkleSpeed: 3.4, - sparkleLifetime: 2.6, - sparkleRise: 1.6, // upward drift, metres/second - sparkleTurbulence: 0.55, - colorSparkleA: '#f2feff', - colorSparkleB: '#57c9ff', - colorSparkleC: '#a9e4ff', - colorSparkleD: '#041e32', - - /* --- dynamic light --- */ - lightIntensity: 9, - lightRadius: 13, - lightColor: '#7fd4ff', - - /* --- the impact at the far end --- */ - burstSize: 3.6, - burstIntensity: 0.75, - burstShards: 90, // extra chips thrown at the impact - impactShake: 0.7, - impactFlash: 0.12, - shakeDuration: 0.9, - rumble: 0.06, // continuous shake while the front travels - // The frost shell mixes A→B across its billowing noise and lays C over the - // crystallised plates and the fresnel rim, so C is the one that reads hot. - colorBurstA: '#a9e4ff', - colorBurstB: '#cdefff', - colorBurstC: '#f2feff', - colorFlash: '#f2feff' // the full-screen flash on impact - }, - - /* ================================================================== */ - /* THUNDER — ability two */ + /* The abilities */ /* ================================================================== */ /** - * A bolt thrown from the caster's hand along the aimed line: a bundle of - * lightning filaments that snap into existence, hold while they gutter, and - * blow out. Reference for the look: `thundercast.jpg`. + * One key per registered id, spread in from `config/abilities/`. * - * The bolt is **one mesh**. Every filament is an instance of the same ribbon - * strip, and its entire shape — the sag of the axis, the fan of the bundle, - * the kinks in an individual strand, the camera-facing width — is evaluated in - * the vertex shader from the numbers below. Nothing about the path exists on - * the CPU, which is why `strands`, `jitter` and `spread` reshape a bolt that - * is already in the air, and do it with the clock paused. + * Spread rather than assigned, so `settings.ice` *is* the object exported by + * `config/abilities/ice.js` — one identity, shared with the registry + * descriptor and with every controller bound to it. Cloning here would give + * the editor a private copy and the abilities would go on reading the + * original, which is a bug that looks like the sliders having no effect. * - * The one thing a cast *does* capture is `uSeed`, a single random number - * rolled at spawn so two casts do not draw the identical bolt. That is an - * event, not a dimension — the same rule `IceAbility` follows. + * The position matters: presets and `snapshotSettings` serialise in key + * order, so the ability blocks sit between `character` and `camera` exactly + * where they were hand-written before they moved out. */ - thunder: { - /* --- the cast --- */ - range: 24.0, // maximum cast distance, metres - minRange: 2.0, // closer than this and the cast is refused - speed: 105.0, // how fast the strike front travels, metres/second - lifetime: 0.45, // seconds the bolt holds after it lands - fadeTime: 0.5, // seconds it takes to blow out - cooldown: 0.5, - castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws - - /* --- where the bolt leaves the caster --- */ - // The beam starts at the hand, not at the feet, so these are measured from - // the caster's origin in the cast's own frame. - handHeight: 1.28, // metres above the floor - handForward: 0.55, // metres in front of the caster - handSide: 0.16, // metres to the side (+ follows `Ability#side`) - endHeight: 0.35, // height of the bolt where it lands, metres - sag: 0.22, // metres the mid-span bows upward (negative droops) - - /* --- the bundle of filaments --- */ - strands: 9, // separate filaments (capped at 24) - spread: 0.75, // metres the bundle fans out at the far end - spreadNear: 0.05, // ... and at the hand - spreadCurve: 1.6, // >1 keeps the bundle tight then opens it late - twist: 0.45, // turns the bundle makes around the axis over its length - twistSpeed: 0.8, // turns/second it rolls on top of that - branchDim: 0.72, // how much dimmer an outer filament is than the spine - - /* --- the shape of one filament --- */ - jitter: 0.34, // metres of kink at the coarsest octave - jitterScale: 0.85, // kinks per metre - octaves: 4, // 1–5; each one halves the amplitude and doubles the rate - jitterFalloff: 0.55, // amplitude kept per octave - crawl: 3.2, // how fast the kinks slide along the bolt - pinch: 0.14, // fraction of the span the ends are pulled straight over - converge: 0.8, // how hard the far end is pulled onto the target, 0..1 - - /* --- the ribbon --- */ - width: 0.025, // half-width of a filament at the hand, metres - widthTip: 0.43, // that width at the impact point, as a fraction - widthCurve: 1.09, // how early the taper happens - coreWidth: 1.31, // multiplier on the central spine - coreSharp: 4.95, // how hard the hot core falls off across the ribbon - glowWidth: 5.7, // the halo, × the core width - glowFalloff: 2.4, // how fast the halo fades across its ribbon - glowOpacity: 0.49, - softFade: 0.78, // metres of soft fade where the bolt meets geometry - - /* --- flicker & restrike --- */ - restrike: 24, // times/second the filaments re-roll their shape - flicker: 0.3, // depth of the whole-bolt brightness stutter - flickerSpeed: 34, // stutters/second - strandFlash: 0.5, // how much individual filaments blink out - tipGlow: 2.0, // extra heat on the leading edge while it travels - tipLength: 0.08, // length of that leading edge, fraction of the span - - /* --- colour --- */ - colorCore: '#ffffff', // the centre of a filament - colorInner: '#c9ecff', - colorOuter: '#3aa0ff', // the outside of a filament - colorHalo: '#0b3fc8', // the wide glow around the bundle - glow: 2.3, // overall emissive gain - opacity: 1.0, - - /* --- what the ground does --- */ - arcRate: 0.9, // electric burns laid per metre of front travel - arcRadius: 1.5, // radius of one burn, metres - arcLife: 0.6, // seconds a burn lingers - arcIntensity: 1.0, - arcBranches: 0.6, // how finely the burn splits into filaments - scorchRadius: 0.5, // dark burn mark under the bolt, metres - scorchLife: 6.5, - scorchIntensity: 0.45, - colorArc: '#9fdcff', - colorScorch: '#080b11', - colorEmber: '#4aa8ff', - shockRadius: 6.5, // impact shockwave ring, metres - colorShockA: '#c9ecff', // body of the shockwave ring - colorShockB: '#ffffff', // its crest - - /* --- sparks, motes, smoke and debris --- */ - /** - * As in `ice`: each system is coloured by a four-stop gradient sampled over - * the particle's own lifetime, `A` at birth through `D` as it dies. Spelled - * out rather than derived from the bolt palette, so the sparks can be made - * to cool to orange while the filaments stay blue. - */ - sparkRate: 240, // sparks thrown off the bolt, particles/second - sparkSize: 0.16, - sparkSpeed: 9.0, - sparkLifetime: 0.5, - sparkGravity: -12.0, - sparkStretch: 0.18, // how far a spark smears along its velocity - colorSparkA: '#ffffff', - colorSparkB: '#ffffff', - colorSparkC: '#c9ecff', - colorSparkD: '#1e5b95', - moteRate: 90, // the slow ionised motes drifting off the bolt - moteSize: 0.05, - moteSpeed: 1.5, - moteLifetime: 1.6, - moteRise: 1.0, // upward drift, metres/second - moteTurbulence: 0.7, - colorMoteA: '#ffffff', - colorMoteB: '#c9ecff', - colorMoteC: '#3aa0ff', - colorMoteD: '#02195f', - smokeRate: 50, // thin haze off the scorched floor - smokeSize: 1.0, - smokeSpeed: 1.1, - smokeLifetime: 2.2, - smokeOpacity: 0.06, - smokeRise: 0.55, - colorSmokeA: '#3d546e', - colorSmokeB: '#33475e', - colorSmokeC: '#33475e', - colorSmokeD: '#1c2938', - debrisRate: 24, // chips kicked off the floor under the bolt - debrisSize: 0.055, - debrisSpeed: 5.0, - debrisLifetime: 1.3, - debrisGravity: -17.0, - colorDebrisA: '#252c36', - colorDebrisB: '#1c222a', - colorDebrisC: '#1c222a', - colorDebrisD: '#1c222a', - - /* --- dynamic light --- */ - lightIntensity: 26, - lightRadius: 17, - lightColor: '#63b8ff', - lightFlicker: 0.4, // depth of the light's gutter, 0 = steady - lightFlickerSpeed: 26, - - /* --- the muzzle and the impact --- */ - // Both shells are the same shader: A→B is mixed across the billowing noise - // and stays nearly empty, and C is what the racing filaments and the fresnel - // rim are drawn in — so C is the one carrying the read. - muzzleSize: 0.55, // the flash at the hand, metres - muzzleIntensity: 1.9, - castFlash: 0.1, // screen flash on release - colorMuzzleA: '#3aa0ff', - colorMuzzleB: '#c9ecff', - colorMuzzleC: '#ffffff', - colorCastFlash: '#c9ecff', - burstSize: 3.0, // the shell at the impact point, metres - burstIntensity: 1.4, - burstSparks: 170, // extra sparks thrown at the impact - burstDebris: 45, - impactShake: 0.8, - shakeDuration: 0.55, - impactFlash: 0.28, - rumble: 0.03, // continuous shake while the front travels - colorBurstA: '#3aa0ff', - colorBurstB: '#c9ecff', - colorBurstC: '#ffffff', - colorFlash: '#c9ecff' // the full-screen flash on impact - }, - - /* ================================================================== */ - /* METEOR — ability three */ - /* ================================================================== */ - /** - * A burning rock lobbed along the aimed line, which detonates on arrival. - * - * The rock is real geometry — a cratered, faceted asteroid generated by - * `assets/ProceduralGeometry.js` — shaded by a patched standard material so it - * casts and receives the stage's shadows. Its signature is the **lava seams**: - * the zero crossing of an fbm field sampled in the rock's own local space, so - * the cracks are welded to it and tumble with it. `chargeCurve` decides how - * fast they prise open on the way in. - * - * Behind it hangs the **fire trail**: a black-body volume raymarched inside a - * camera-facing proxy hull laid along the arc. See the `trail*` block. - * - * As in `ice` and `thunder`, a cast captures nothing but dice and timestamps: - * one seed, one tumble axis and a few unitless rolls per debris chunk. The - * trajectory, the size of the rock, the width of its seams and the whole - * ballistic flight of every chunk are resolved against this block each frame — - * which is why dragging `arc` re-lofts a meteor already in the air, and - * dragging `chunkSpeed` re-throws debris that has already landed. - */ - meteor: { - /* --- the cast --- */ - range: 20.0, // maximum cast distance, metres - minRange: 3.0, // closer than this and the cast is refused - speed: 21.0, // how fast the rock travels downrange, metres/second - lifetime: 2.2, // seconds the crater burns after the impact - fadeTime: 1.6, // seconds everything takes to clear - cooldown: 0.9, - castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws - - /* --- the flight path --- */ - // The rock is thrown from a hand, so these are measured from the caster's - // origin in the cast's own frame. - handHeight: 1.35, // metres above the floor - handForward: 0.6, // metres in front of the caster - handSide: 0.2, // metres to the side (+ follows `Ability#side`) - endHeight: 0.75, // height of the rock where it lands, metres - arc: 2.6, // metres the mid-span lobs upward - arcCurve: 0.85, // <1 flattens the top of the arc, >1 peaks it - - /* --- the rock --- */ - radius: 0.8, // metres - facets: 3, // icosphere subdivisions, 0–3 (3 = 1280 triangles) - lumpiness: 0.26, // low-frequency deformation, × the radius - lumpScale: 1.5, // lumps per unit radius - surfaceRoughness: 0.16, // high-frequency chipping - cuts: 9, // planar fracture faces sliced off it - cutDepth: 0.28, // how far in those planes bite, × the radius - craters: 5, // impact bowls punched into it - craterDepth: 0.18, // how deep those bowls go, × the radius - craterSize: 0.5, // their angular radius, radians - spin: 3.4, // tumble rate, radians/second - - /* --- the lava seams --- */ - chargeCurve: 1.6, // how late the rock heats up on its way in - crackScale: 0.95, // seams per unit radius - crackWidth: 0.045, // how wide a seam opens (doubled at full charge) - crackBranches: 0.5, // strength of the finer seams splitting off - crackGlow: 2.2, - crackFlow: 0.7, // how much the magma brightness crawls - crackFlowSpeed: 0.9, - rockScale: 3.4, // mottling of the rock between the seams - facetTint: 0.5, // per-facet value break-up — what makes it read as stone - cavity: 0.25, // darkening down in the craters and the cut faces - soot: 0.6, // charring either side of a seam - rimHeat: 0.7, // heat sheath around the silhouette - leadGlow: 0.9, // compression heat on the leading facets - leadSharp: 2.6, // how tightly that hugs the nose - glow: 0.75, // overall emissive gain - envIntensity: 1.25, // how much of the HDR probe the rock catches - colorRock: '#6e675f', - colorChar: '#17130f', - colorCrack: '#ff6a12', - colorHot: '#fff3d0', - - /* --- the fire trail --- */ - /** - * The burning wake, **raymarched as a black-body volume** — the firebending - * stream from the freehand sandbox, re-aimed at the meteor's arc. The mesh - * drawn is only a camera-facing proxy hull; the flame itself is integrated - * inside it by `materials/VolumetricFireMaterial.js`, which is where the four - * layers these controls drive (silhouette → vortex roll-up → turbulence → - * shred) are explained. - * - * As with everything else here it is not a recorded history: the hull's - * centre line is sampled straight off the trajectory, so these reshape fire - * that is already in the air. - * - * The volume borrows the rock's palette — `colorHot`, `colorFlameMid`, - * `colorFlameEdge`, `colorFlameSmoke` — but only reaches for it in - * proportion to `trailPalette`; at 0 it is a pure Planckian radiator and the - * colour comes out of `trailTempCore` / `trailTempEdge` instead. - */ - trailSpan: 7.0, // metres of arc the fire covers behind the rock - trailWidth: 0.66, // tube radius, metres - trailHeadSize: 1.8, // fireball radius at the rock, × trailWidth - trailPlume: 1.1, // upward stretch of the volume (buoyant elongation) - trailWakeSpread: 0.22, // how far the spent gas behind the head has ballooned - trailRise: 0.35, // how far the far end of the wake has floated upward, metres - // Metre-scale lobes in the silhouette. Without these the outline stays a - // capsule no matter how much fine turbulence is piled on top of it, and the - // trail reads as a shaded tube. - trailBulge: 0.18, // how far those lobes swell and pinch the local radius - trailBulgeScale: 0.34, // lobes per metre — lower = bigger, slower shapes - // Ring vortices shed off the head and travelling back down the wake. This is - // what folds the field into curling, mushrooming billows; fbm alone can only - // make clouds. - trailVortex: 0.0, // roll-up strength - trailRingFrequency: 0.0, // vortices per metre of stream - trailRingSpeed: 4.0, // how fast they travel backwards - // Kept low on purpose: rolling the noise frame hard around the axis wraps - // the filaments circumferentially and the flame reads as concentric contour - // lines rather than as tongues running along the flow. - trailCurl: 0.0, // swirl of the density field around the axis - trailTurbulence: 2.94, // noise amplitude eating into the volume - trailWarp: 0.45, // domain warp — folds the noise into curling sheets - trailTongue: 0.94, // < 1 stretches structures upward into licking tongues - trailStreamStretch: 1.13, // < 1 draws them out along the flow - // Radial shear: how far the fringe is dragged up and back relative to the - // axis. This is what makes the edge structures read as licking tongues - // rather than as blobs of the same shape at every radius. - trailLick: 3.1, - trailWisps: 0.81, // ridged filaments shredding the fringe into strands - trailShred: 1.57, // how violently the fringe tears compared to the core - trailOctaves: 5, // turbulence octaves (quality ↔ cost) - trailSpeed: 4.62, // how fast the field streams backwards along the path - trailBuoyancy: 3.5, // how fast it climbs inside the volume - trailDetachment: 0.9, // how hard the tail tears into separate puffs - trailNoiseStrength: 0.78, - trailNoiseFrequency: 3.23, - trailSoftness: 0.42, // 0 = hard tongues, 1 = a soft glow - trailFlicker: 0.74, - trailDensity: 2.09, - trailSoot: 1.42, // absorption — how much the cool gas occludes - trailCoreClarity: 0.54, // extinction left in the hottest gas (low = white blob) - trailSteps: 35, // raymarch samples per pixel (quality ↔ cost) - trailGlow: 3.06, - trailOpacity: 0.96, - trailTailFade: 0.71, // fraction of the trail that has already burnt out - trailBurnout: 1.2, // seconds the trail takes to die after the impact - // Temperature & radiance. The flame is shaded as a Planckian radiator: these - // are the two ends of its temperature range in kelvin, and the exponent the - // emitted power follows. 4 would be Stefan-Boltzmann; a little gentler keeps - // the mid-tones off the floor at this exposure. - trailTempCore: 1920, - trailTempEdge: 1590, - trailEmissionCurve: 4.79, - trailHeatFocus: 1.54, // how fast the gas reaches full heat inside the surface - trailHeatFalloff: 2.46, // how sharply it cools toward that surface - // How far the turbulence is allowed to drag the temperature profile around. - // Radiated power goes as a high power of T, so this number is amplified - // several-fold on screen — past ~0.5 the noise's own contour lines start - // showing through as agate banding. - trailHeatFollow: 0.26, - trailTailHeat: 0.36, // temperature of the spent gas at the far end of the wake - trailPalette: 0.62, // 0 = pure black-body physics, 1 = the colour stops below - trailScatter: 1.99, // firelight bouncing inside the sooty fringe - trailScatterFalloff: 4.4, // how fast that bath dies away from the core - colorFlameMid: '#ffb02e', - colorFlameEdge: '#ff3d10', - colorFlameSmoke: '#181616', - - /* --- the debris the rock breaks into --- */ - chunkCount: 18, // chunks thrown at the impact (capped at 28) - chunkScale: 0.28, // their radius, × the meteor's - chunkSpeed: 7.5, // metres/second they leave the crater at - chunkForward: 0.55, // how far the spray is biased downrange - chunkLoft: 1.0, // how steeply they are thrown - chunkGravity: -17.0, - chunkSpin: 6.0, // tumble rate, radians/second - chunkCool: 2.6, // seconds a chunk's seams take to go out - chunkLinger: 0.5, // seconds they lie there before sinking - chunkSink: 1.0, // seconds to withdraw into the floor - - /* --- embers, sparks, smoke and grit --- */ - /** - * As in `ice` and `thunder`: each system is coloured by a four-stop gradient - * sampled over the particle's own lifetime, `A` at birth through `D` as it - * dies. Spelled out rather than derived from the flame palette, so the - * trail can be cooled to red while the rock itself stays white-hot. - */ - emberRate: 180, // embers streaming off the rock, particles/second - emberSize: 0.1, - emberSpeed: 2.4, - emberLifetime: 1.5, - emberRise: 1.5, // buoyancy, metres/second - emberGlow: 1.2, - emberTurbulence: 0.5, - colorEmberA: '#fff3d0', - colorEmberB: '#ff9a2e', - colorEmberC: '#ff3b0d', - colorEmberD: '#2b0d05', - sparkRate: 110, // sparks flung off it - sparkSize: 0.14, - sparkSpeed: 6.5, - sparkLifetime: 0.8, - sparkGravity: -11.0, - sparkStretch: 0.16, // how far a spark smears along its velocity - colorSparkA: '#fffdf2', - colorSparkB: '#ffd27a', - colorSparkC: '#ff6a12', - colorSparkD: '#3d1103', - smokeRate: 70, // the trail and the column off the crater - smokeSize: 1.1, - smokeSpeed: 1.2, - smokeLifetime: 3.0, - smokeOpacity: 0.12, - smokeRise: 0.9, - colorSmokeA: '#6b503f', - colorSmokeB: '#3b2c25', - colorSmokeC: '#241b17', - colorSmokeD: '#141010', - debrisSize: 0.06, // grit kicked off the floor - debrisSpeed: 6.0, - debrisLifetime: 1.5, - debrisGravity: -18.0, - colorDebrisA: '#3a322c', - colorDebrisB: '#2a231e', - colorDebrisC: '#1c1714', - colorDebrisD: '#151110', - - /* --- the molten cracks torn through the floor --- */ - /** - * Real geometry, not a decal: arms of crack that meander outward from the - * impact, shed branches, glow from a white-hot core through a wide orange - * underglow, and heave basalt up along their lips. See - * `effects/GroundFissures.js` — the network is baked in a unit disc, so - * `fissureRadius` re-scales cracks that are already on the ground. - */ - fissureRadius: 5.2, // how far the cracks reach, metres - fissureLife: 6.5, // seconds before they close up - fissureArms: 6, // main cracks radiating from the impact - fissureWander: 1.6, // how hard an arm veers, radians per unit walked - fissureBranches: 0.75, // fraction of the generated branches kept, 0..1 - fissureBranchLength: 0.85, // how far along a branch runs before its point, 0..1 - fissureWidth: 0.14, // width of the open seam, metres - fissureHeat: 1.5, // core temperature - fissurePulse: 1.0, // speed of the heat waves travelling along them - fissureGrowth: 9.0, // how fast the cracks race outward, metres/second - fissureRockSize: 0.3, // basalt heaved up along the lips, metres - - /* --- what else the ground does --- */ - scorchRadius: 2.8, // burnt patch under it, metres - scorchLife: 8.0, - scorchIntensity: 0.95, - shockRadius: 6.0, // impact shockwave ring, metres - colorScorch: '#0d0907', - colorShockA: '#ff9a2e', // body of the shockwave ring - colorShockB: '#fff3d0', // its crest - - /* --- dynamic light --- */ - lightIntensity: 16, - lightRadius: 14, - lightColor: '#ff8a3c', - lightFlicker: 0.25, // depth of the light's gutter, 0 = steady - lightFlickerSpeed: 13, - - /* --- the launch and the detonation --- */ - muzzleSize: 0.0, // the flare at the hand as the rock leaves it — 0 = none - muzzleIntensity: 1.6, - castFlash: 0.08, // screen flash on release - colorCastFlash: '#ff9a2e', - burstSize: 3.6, // the fireball at the impact point, metres - burstIntensity: 1.0, - burstTurbulence: 2.0, // how hard the noise eats into the fireball's shell - burstEmbers: 260, // extra embers thrown at the impact - burstSparks: 180, - burstDebris: 90, - burstSmoke: 70, - impactShake: 1.0, - shakeDuration: 1.1, - impactFlash: 0.3, - rumble: 0.04, // continuous shake while the rock is in the air - colorFlash: '#ff9a2e' // the full-screen flash on impact - }, - - /* ================================================================== */ - /* BEAM — ability four */ - /* ================================================================== */ - /** - * A sustained super beam: the caster winds up a ball of light in both hands, - * then lets a column of it out along the aimed line, where it *stays* — - * burning into the floor for `lifetime` before it collapses back to a thread - * and blinks out. Reference for the look: `superbeam.jpg`. - * - * This is the ability with a **fourth beat**. Ice, thunder and meteor all run - * travel → impact → fade; the beam puts a `charge` in front of that, so the - * shot is something you watch arrive *and* something that lands and holds. - * Nothing in the base class needed changing for it — `BeamAbility` simply - * refuses to let the front leave the hand until the orb is up to power. - * - * The column is **one tube** — see `assets/ProceduralGeometry.js` — drawn - * three times at three radii by `materials/BeamMaterial.js`: a wide halo, a - * hollow rim-weighted sheath and, inside it, a core weighted the *opposite* - * way, brightest where the view ray runs down the barrel. That inversion is - * what makes the middle read as a solid rod of light instead of as a lit - * pipe. The coils spiralling around it and the shock discs racing down it are - * two more instanced passes placed against the same radius profile, so all - * five stay welded together when the shape is dragged. - * - * Deliberately *not* electric: no kinks anywhere. The bolt's noise is - * piecewise-linear so it keeps its corners; every noise term here is smooth - * and stretched hard along the flow, because a beam that kinks is a bolt. - * - * As in every other block, a cast captures nothing but one seed and a few - * timestamps. The barrel, the flare, the coil pitch and the disc train are all - * resolved against these numbers each frame — which is why dragging `radius` - * re-bores a beam that is already burning, with the clock stopped. - */ - beam: { - /* --- the cast --- */ - range: 26.0, // maximum cast distance, metres - minRange: 3.0, // closer than this and the cast is refused - charge: 0.42, // seconds the orb winds up before the beam is let out - speed: 150.0, // how fast the leading edge races downrange, metres/second - lifetime: 1.15, // seconds it burns once it lands - fadeTime: 0.4, // seconds it takes to collapse - cooldown: 1.6, - castAnim: 'cast1', // which clip in `CAST_ANIMATIONS` the body throws - - /* --- where it leaves the caster --- */ - // Both hands, so this one sits on the centre line rather than off a - // shoulder like the bolt and the rock. - handHeight: 1.3, // metres above the floor - handForward: 0.72, // metres in front of the caster - handSide: 0.0, // metres to the side (+ follows `Ability#side`) - endHeight: 1.0, // height of the beam where it lands, metres - - /* --- the column --- */ - // A narrow throat that stays tight (`radiusCurve` above 1) and then opens - // hard over the last tenth of the span: the beam reads as a jet with a bell - // on the end rather than as a cone, which is what puts the weight at the - // impact instead of spreading it down the whole line. - radiusNear: 0.16, // half-width at the muzzle, metres - radius: 0.77, // half-width at the target - radiusCurve: 1.27, // <1 opens out early, >1 stays tight then flares late - flare: 1.74, // extra swell where it lands - flareWidth: 0.09, // how much of the span that swell covers, 0..1 - // Both wobbles ship at zero. The column reads cleaner with a hard, still - // silhouette — the coils already give the eye something moving to follow — - // but the rates below are tuned, so raising either one is a single drag. - throb: 0.0, // pressure waves travelling out along it - throbScale: 4.8, // waves over the length - throbSpeed: 2.6, // waves/second - wander: 0.0, // metres the axis drifts, pinned at both ends - wanderScale: 0.9, // drift features per unit length - wanderSpeed: 0.7, - - /* --- the three tube passes --- */ - // The core is deliberately narrow and not fully opaque. Widen it or push - // `coreFill` up and the three layers stack into one white rod: the cyan - // sheath and the gold coils are only readable because the middle leaves - // them room. - coreWidth: 0.2, // the hot rod, × the column radius - coreSharp: 1.55, // how tightly the core hugs the axis - coreFill: 0.6, // how solid it reads - shellWidth: 1.0, // the sheath - shellRim: 1.15, // brightness of its silhouette edges - shellFill: 0.18, // how much body it has between them - shellOpacity: 0.95, - // Wide and faint: the halo is atmosphere, not a second beam. Pushing its - // opacity up fogs the sheath's silhouette edges, which are the read. - haloWidth: 2.75, // the outer bloom - haloRim: 4.3, // how tightly that hugs the silhouette - haloOpacity: 0.14, - edgePower: 2.2, // rim exponent shared by the sheath - - /* --- the surface --- */ - ripple: 0.2, // how far the noise pushes the barrel off round - rippleBands: 2.2, // ripple features around the barrel - rippleScale: 4.25, // ... and along it - rippleSpeed: 2.0, // how fast they crawl downrange - streak: 1.1, // filaments streaming along the flow - streakSharp: 0.45, // 0 = a wash, 1 = hard threads - streakScale: 4.2, // threads per unit length - streakBands: 1.8, // ... and around the barrel - // Kept low: the threads carry heat into the *sheath*, and pushing this up - // whitens it out until the beam is one colour from axis to rim. - streakGlow: 0.55, // how hot a thread burns in the sheath - flowSpeed: 7.0, // how fast the whole field streams downrange - mouthGlow: 1.6, // heat where the column leaves the orb - mouthLength: 0.1, // how far that reaches, fraction of the span - // Kept below the muzzle's: the flare and the impact shell already carry the - // far end, and stacking a hot cap on top of them blows it out to a disc. - tipGlow: 0.6, // heat on the leading edge / the burning end - tipLength: 0.09, // length of that edge, fraction of the span - softFade: 0.62, // metres of soft fade where it meets geometry - - /* --- colour --- */ - colorCore: '#ffffff', // the axis - colorInner: '#d3f4ff', - colorOuter: '#3ec6ff', // the outside of the sheath - colorHalo: '#0d3ce0', // the wide bloom around it - // The column is deliberately held *back*. Three additive tube passes at full - // strength clip to white and the beam becomes a flat plank; dropping the - // gain and the opacity keeps it glassy and hands the read to the coils. - glow: 0.74, // overall emissive gain - opacity: 0.29, - - /* --- the coils --- */ - /** - * Ribbons spiralling around the column, on the same strip the bolt is drawn - * on. Warm on purpose: the reference frames a white-hot beam with gold - * coils, and the colour split is what stops them dissolving into the sheath. - */ - coils: 4, // ribbons (capped at 8) - coilTurns: 1.45, // turns each one makes over the length - // Negative, so the ribbons roll *against* the direction the charge pulse - // runs. The two motions reading differently is what keeps a held beam from - // looking like a single rotating screw. - coilSpeed: -0.69, // turns/second they roll on top of that - coilRadius: 1.88, // how far out they ride, × the column radius - coilFlare: 0.57, // extra opening at the far end - coilWidth: 0.1, // half-width at the muzzle, metres - coilWidthTip: 1.9, // that width at the target, as a multiple - coilSharp: 2.2, // how hard the ribbon falls off across its width - coilPulse: 0.65, // depth of the charge running along it - coilPulseFreq: 3.0, // pulses over the length - coilPulseSpeed: 1.6, // pulses/second - // Driven hard on purpose. With the column dialled back above, the ribbons - // are what the eye actually follows down the beam. - coilGlow: 8.0, - coilOpacity: 2.0, - colorCoil: '#ffdc8c', - colorCoilEdge: '#ff6a12', - - /* --- the shock discs --- */ - rings: 10, // discs in flight (capped at 12) - ringSpeed: 1.31, // trips down the beam per second - // Both lips well clear of the sheath, and close together: the discs read as - // thin hoops orbiting the column rather than as plates growing out of it. - ringInner: 2.42, // inner lip, × the local column radius - ringOuter: 2.73, // outer lip - ringSwell: 0.55, // how much they open out as they travel - ringFade: 0.18, // how much is left of one by the time it lands - ringSharp: 1.6, // how thin the band reads - ringGlow: 2.4, - ringOpacity: 0.7, - colorRing: '#9ceeff', - - /* --- the charge orb --- */ - orbSize: 0.39, // radius once it is up to power, metres - orbThrob: 0.11, // how hard it pulses - orbThrobSpeed: 6.9, - orbTurbulence: 0.24, // how far the noise eats into its surface - orbScale: 2.2, // features over the surface - orbFlow: 0.9, // how fast they crawl - orbBands: 5.0, // filament frequency - orbRim: 1.8, // rim exponent - orbGlow: 2.8, - orbOpacity: 1.0, - - /* --- what the ground does --- */ - scorchRate: 1.1, // burns laid per metre of front travel - scorchRadius: 0.7, // radius of one, metres - scorchLife: 7.0, // seconds it lingers - scorchIntensity: 0.55, - colorScorch: '#0a0d14', - colorEmber: '#4ad6ff', - dustRate: 7.0, // dust rings thrown off the burning end, per second - dustRadius: 2.4, // radius of one, metres - dustLife: 0.9, - colorDustA: '#3d5c74', - colorDustB: '#9ceeff', - shockRate: 3.5, // pressure rings snapped across the floor, per second - shockRadius: 7.0, // radius of the one at the impact, metres - colorShockA: '#3ec6ff', // body of the shockwave ring - colorShockB: '#ffffff', // its crest - - /* --- sparks, motes, smoke and debris --- */ - /** - * As in `ice`, `thunder` and `meteor`: each system is coloured by a four-stop - * gradient sampled over the particle's own lifetime, `A` at birth through - * `D` as it dies. The motes do double duty — they are the intake spiralling - * *into* the orb while it charges, and the drift shed off the column once it - * is firing. - */ - sparkRate: 300, // sparks shed off the column, particles/second - sparkSize: 0.15, - sparkSpeed: 8.0, - sparkLifetime: 0.55, - sparkGravity: -9.0, - sparkStretch: 0.22, // how far a spark smears along its velocity - sparkForward: 0.9, // how hard the spray is dragged downrange - colorSparkA: '#ffffff', - colorSparkB: '#d3f4ff', - colorSparkC: '#3ec6ff', - colorSparkD: '#0b2f7a', - moteRate: 120, // the drift hanging around the column - moteSize: 0.06, - moteSpeed: 1.6, - moteLifetime: 1.5, - moteRise: 0.9, // upward drift, metres/second - moteTurbulence: 0.8, - colorMoteA: '#ffffff', - colorMoteB: '#9ceeff', - colorMoteC: '#3ec6ff', - colorMoteD: '#06205e', - intakeRate: 260, // motes pulled into the orb while it charges - intakeRadius: 2.6, // how far out they are drawn from, metres - intakeSpeed: 7.5, // how fast they fall in - smokeRate: 90, // steam scoured off the floor under the beam - smokeSize: 1.1, - smokeSpeed: 1.4, - smokeLifetime: 2.4, - smokeOpacity: 0.07, - smokeRise: 0.7, - colorSmokeA: '#41566d', - colorSmokeB: '#35485e', - colorSmokeC: '#2a3949', - colorSmokeD: '#1a2430', - debrisRate: 34, // chips torn off the floor along the burn line - debrisSize: 0.06, - debrisSpeed: 6.0, - debrisLifetime: 1.4, - debrisGravity: -18.0, - colorDebrisA: '#2b323c', - colorDebrisB: '#1f252d', - colorDebrisC: '#1a1f26', - colorDebrisD: '#1a1f26', - - /* --- dynamic light --- */ - // Two lights: one rides the beam, one sits in the caster's hands so the - // charge actually lights the body that is holding it. - lightIntensity: 30, - lightRadius: 20, - lightColor: '#7fdcff', - lightPulse: 0.18, // depth of the hum, 0 = steady - lightPulseSpeed: 5.0, // pulses/second - muzzleLightIntensity: 16, - muzzleLightRadius: 9, - - /* --- the wind-up, the release and the burn --- */ - chargeShake: 0.045, // rumble while the orb spools up - castFlash: 0.22, // screen flash as it is released - muzzleSize: 1.1, // the pressure shell thrown off the hands, metres - muzzleIntensity: 2.0, - colorCastFlash: '#d3f4ff', - burstSize: 4.2, // the shell at the impact point, metres - burstIntensity: 1.6, - burstSparks: 220, // extra sparks thrown when it lands - burstDebris: 70, - pulseRate: 2.6, // pressure shells off the burning end, per second - pulseSize: 2.2, // radius of one, metres - pulseIntensity: 1.1, - splashRate: 260, // sparks kicked back up the beam while it burns - impactShake: 0.9, - shakeDuration: 0.7, - burnShake: 0.09, // continuous rumble while the beam is standing - impactFlash: 0.3, - rumble: 0.05, // rumble while the leading edge travels - colorBurstA: '#3ec6ff', - colorBurstB: '#d3f4ff', - colorBurstC: '#ffffff', - colorFlash: '#d3f4ff' // the full-screen flash on impact - }, - - /* ================================================================== */ - /* SNARE — ability five, and the first **far cast** */ - /* ================================================================== */ - /** - * A trap planted at a point rather than a shot fired along a line: the caster - * whips a leash of current out across the floor, and where it lands the ring - * snaps open — a column of lightning tears up out of the middle, tendrils - * crawl outward to the boundary and arcs run around the rim, all of it - * holding, re-striking and dragging the air upward for `lifetime` before it - * collapses. Reference for the look: `electricalboost.jpg`. - * - * This is the block that defines what a far cast *is* in this project. The - * targeting is a circle (see the `zone` block) and `zoneRadius` is the promise - * that circle makes: the boundary the indicator draws is the boundary the - * field burns, the tendrils reach and the rim arcs run along, so dragging that - * one number re-scales the indicator and a snare that is already standing - * together. - * - * The whole cage is **one instanced strip** — see `materials/SnareMaterial.js`. - * Every filament is the same ribbon, and a *role* decided from its instance - * index (leash → column → tendril → rim) picks which parametric path the - * vertex shader threads it along. Two draw calls for all four, however many - * filaments are in the air. - * - * As in every other block, a cast captures nothing but a seed and a few - * timestamps. Every metre, radian and second is resolved against these numbers - * each frame — including a zero-length one, which is why the trap reshapes - * under the sliders with the clock stopped. - */ - snare: { - /* --- the cast --- */ - range: 20.0, // maximum cast distance, metres - minRange: 0.0, // a trap can legitimately be dropped on your own feet - zoneRadius: 4.4, // the footprint — what the circle indicator measures out - speed: 62.0, // how fast the leash races to the point, metres/second - snapTime: 0.16, // seconds the ring takes to slam open once it lands - lifetime: 2.6, // seconds the snare stands - fadeTime: 0.75, // seconds it takes to collapse - cooldown: 1.4, - castAnim: 'cast2', // which clip in `CAST_ANIMATIONS` the body throws - - /* --- the leash that plants it --- */ - // Thrown from a hand, so these are measured from the caster's origin in the - // cast's own frame, exactly as the bolt and the rock are. - handHeight: 1.24, // metres above the floor - handForward: 0.58, // metres in front of the caster - handSide: 0.18, // metres to the side (+ follows `Ability#side`) - leashStrands: 3, // filaments in the whip - leashSag: -0.35, // metres the mid-span bows (negative drops it to the floor) - leashSpread: 0.22, // how far the filaments separate, metres - leashKink: 0.3, // kink amplitude on the whip, metres - leashWidth: 1.0, // × the shared filament width - leashCling: 0.12, // how far above the floor the tip runs, metres - - /* --- the column --- */ - strands: 15, // filaments in the pillar - height: 9.2, // how high it reaches, metres - heightCurve: 1.45, // <1 gets it up fast, >1 makes it climb late - throat: 0.16, // radius where it leaves the floor, × zoneRadius - columnSpread: 0.25, // radius at the top, × zoneRadius - columnCurve: 2.88, // >1 keeps the throat tight then opens it late - columnFlare: 0.585, // extra opening over the last quarter, × zoneRadius - columnTwist: 0.22, // turns a filament makes over the climb - columnSpin: 1.26, // turns/second the whole pillar rolls - columnKink: 0.27, // kink amplitude, metres - columnWidth: 1.86, // × the shared filament width - columnTaper: 1.09, // how much thinner the top is than the base - - /* --- the tendrils crawling out to the boundary --- */ - tendrils: 20, // separate ground filaments (capped with the rest at 56) - tendrilInner: 0.0, // where they leave the column, × zoneRadius - tendrilReach: 1.07, // where they end, × zoneRadius (1 = exactly on the band) - tendrilCurve: 1.18, // <1 throws them outward early - tendrilWander: 1.41, // radians a tendril veers over its run - tendrilArch: 1.16, // metres it hops off the floor mid-span - tendrilHug: 0.005, // how far above the floor it runs, metres - tendrilSpin: -0.225, // turns/second the whole fan rotates - tendrilKink: 0.72, // kink amplitude, metres - tendrilWidth: 0.75, // × the shared filament width - tendrilDim: 0.8, // how much dimmer than the column - - /* --- the arcs running around the rim --- */ - rimArcs: 14, // arcs on the boundary at once - rimSpan: 0.335, // fraction of the circle one arc covers - rimSpeed: -1.84, // revolutions/second they travel - // High enough to clear the burnt band underneath them: an arc that hops - // 0.3 m over a band this bright is simply invisible. - rimHeight: 0.98, // metres they hop at mid-span - rimJitter: 0.23, // radial wobble, × zoneRadius - rimKink: 0.15, // kink amplitude, metres - rimWidth: 0.85, // × the shared filament width - rimDim: 1.0, - - /* --- the shape every filament shares --- */ - // The same piecewise-linear value noise the bolt uses — linear on purpose, - // because smoothstep rounds the corners off and the corners are the entire - // reason it reads as lightning. - jitter: 1.0, // master multiplier on the four per-role kink amplitudes - jitterScale: 1.4, // kinks per metre - octaves: 4, // 1–5; each halves the amplitude and doubles the rate - jitterFalloff: 0.55, // amplitude kept per octave - crawl: 2.4, // how fast the kinks slide along a filament - pinch: 0.16, // fraction of the span the ends are pulled straight over - restrike: 21, // times/second every filament re-rolls its shape - flicker: 0.26, // depth of the whole-cage brightness stutter - flickerSpeed: 30, - strandFlash: 0.45, // how much individual filaments blink out - - /* --- the ribbon --- */ - width: 0.032, // half-width of a filament, metres - coreSharp: 4.4, // how hard the hot core falls off across the ribbon - glowWidth: 6.2, // the halo, × the core width - glowFalloff: 2.3, // how fast the halo fades across its ribbon - glowOpacity: 0.44, - softFade: 0.7, // metres of soft fade where a filament meets geometry - - /* --- colour --- */ - // Violet rather than the Storm Lance's blue: two electric abilities on the - // bar need to be told apart at a glance, and the hue split does it before - // the silhouette does. - colorCore: '#ffffff', // the centre of a filament - colorInner: '#dcd0ff', - colorOuter: '#8f6bff', // the outside of a filament - colorHalo: '#2a0e8c', // the wide glow around the cage - glow: 2.2, // overall emissive gain - opacity: 1.0, - - /* --- the field burnt into the floor --- */ - /** - * The indicator's promise, made real: the same circle, the same thick - * boundary, now a live shader instead of a targeting aid. It is an - * ability-owned mesh rather than a decal precisely because a decal captures - * its radius when it spawns — this one has to re-scale under `zoneRadius` - * while it is standing. - */ - fieldBoundary: 0.02, // thickness of the burnt band, metres - fieldBoundaryGlow: 2.9, - fieldFill: 0.65, // the wash inside it - fieldFalloff: 3.6, // how hard that wash crowds to the rim - fieldVeins: 2.98, // filaments burnt across the disc - fieldVeinScale: 2.0, // veins per metre - fieldVeinSharp: 0.72, // 0 = a wash, 1 = hard threads - fieldWarp: 0.55, // domain warp — what stops the veins reading as spokes - fieldCrawl: 0.5, // how fast they writhe - fieldRings: 2.4, // pressure rings travelling out from the middle - fieldRingSpeed: 0.8, // rings/second - fieldSpokes: 20, // ticks stepping around the boundary - fieldSpokeLength: 0.5, // how far they reach in, metres - fieldSpin: 0.05, // revolutions/second the ticks step around - fieldCore: 1.3, // brightness of the pool the column stands in - fieldCoreSize: 0.22, // its radius, × zoneRadius - fieldPulse: 0.0, // brightness breathing - fieldPulseSpeed: 3.95, - fieldOpacity: 1.0, - fieldHeight: 0.03, // hover distance above the floor, metres - colorField: '#8f6bff', // the wash and the veins - colorFieldEdge: '#ffffff', // the boundary band and the core pool - - /* --- what else the ground does --- */ - arcRate: 5.0, // branching burns laid around the rim, per second - arcRadius: 1.2, // radius of one, metres - arcLife: 0.75, - arcIntensity: 0.9, - arcBranches: 0.7, // how finely a burn splits into filaments - trailRate: 1.1, // burns laid per metre while the leash races out - scorchRadius: 1.6, // dark burn under the column, metres - scorchLife: 7.5, - scorchIntensity: 0.5, - colorArc: '#c3b0ff', - colorEmber: '#8f6bff', - colorScorch: '#0b0813', - shockRadius: 7.0, // the ring that snaps out when the trap opens, metres - colorShockA: '#8f6bff', // body of the shockwave ring - colorShockB: '#ffffff', // its crest - - /* --- sparks, updraft, smoke and debris --- */ - /** - * As in every other block: a four-stop gradient sampled over the particle's - * own lifetime, `A` at birth through `D` as it dies. The **updraft** is this - * ability's signature system — motes drawn off the whole disc and hauled - * inward and up into the column, which is the read that says the trap is - * pulling on the air rather than just sitting in it. - */ - sparkRate: 320, // sparks thrown off the cage, particles/second - sparkSize: 0.15, - sparkSpeed: 8.5, - sparkLifetime: 0.55, - sparkGravity: -13.0, - sparkStretch: 0.2, // how far a spark smears along its velocity - colorSparkA: '#ffffff', - colorSparkB: '#dcd0ff', - colorSparkC: '#8f6bff', - colorSparkD: '#2a0e8c', - updraftRate: 210, // motes hauled up the column, particles/second - updraftSize: 0.07, - updraftSpeed: 6.0, // how fast they are pulled in - updraftLifetime: 1.4, - updraftRise: 5.5, // upward acceleration once they are inside, m/s² - updraftInset: 0.15, // how far inside the boundary they are picked up - updraftTurbulence: 0.9, - colorUpdraftA: '#8f6bff', - colorUpdraftB: '#dcd0ff', - colorUpdraftC: '#ffffff', - colorUpdraftD: '#1b0a5e', - smokeRate: 70, // haze scoured off the burnt floor - smokeSize: 1.05, - smokeSpeed: 1.2, - smokeLifetime: 2.4, - smokeOpacity: 0.06, - smokeRise: 0.6, - colorSmokeA: '#4a4368', - colorSmokeB: '#3a3554', - colorSmokeC: '#2b2740', - colorSmokeD: '#191728', - debrisRate: 30, // chips torn off the floor inside the ring - debrisSize: 0.055, - debrisSpeed: 5.5, - debrisLifetime: 1.3, - debrisGravity: -17.0, - colorDebrisA: '#2a2733', - colorDebrisB: '#201e28', - colorDebrisC: '#1a1822', - colorDebrisD: '#1a1822', - - /* --- dynamic light --- */ - lightIntensity: 24, - lightRadius: 18, - lightHeight: 0.38, // how far up the column the light sits, 0..1 - lightColor: '#a98bff', - lightFlicker: 0.38, // depth of the light's gutter, 0 = steady - lightFlickerSpeed: 24, - - /* --- the throw, the snap and the hold --- */ - muzzleSize: 0.5, // the flash at the hand as the leash leaves it - muzzleIntensity: 1.7, - castFlash: 0.09, // screen flash on release - colorCastFlash: '#c3b0ff', - burstSize: 2.8, // the shell thrown off when the ring opens, metres - burstIntensity: 1.5, - burstSparks: 200, // extra sparks at the snap - burstDebris: 60, - pulseRate: 1.5, // pressure shells shed off the column while it holds, /s - pulseSize: 1.2, // radius of one, metres - pulseIntensity: 0.5, - ringRate: 1.4, // dust rings pushed across the floor while it holds, /s - impactShake: 0.85, - shakeDuration: 0.6, - holdShake: 0.07, // continuous rumble while the snare stands - impactFlash: 0.26, - rumble: 0.025, // rumble while the leash races out - colorBurstA: '#8f6bff', - colorBurstB: '#dcd0ff', - colorBurstC: '#ffffff', - colorFlash: '#c3b0ff' // the full-screen flash when it snaps open - }, - - /* ================================================================== */ - /* GLACIER — ability six, and the far cast that comes out of the floor */ - /* ================================================================== */ - /** - * A cold front races along the floor to the aimed point, the disc freezes out - * to the boundary the circle drew, and a wall of crystal tears up out of the - * ground around it: a ring of blades leaning outward with a skirt of wreckage - * banked against their feet. It stands, glints, breathes cold off its rim — - * and then breaks into plates and sinks back into the floor. Reference for the - * look: `Hud7Xfg3LH.jpg`. - * - * The **middle stays open**: every shard is seated in a band about - * `zoneRadius` and nothing is planted in the centre, because the read is a - * wall you are looking into and filling the disc stops it being a ring. What - * lives inside it is air and frozen ground. - * - * The second **far cast**, and the counterpart to the Voltaic Snare: same - * circle, same promise, opposite answer. The snare fills the footprint with - * current standing in the air; this one fills it with geometry standing on the - * ground, so `zoneRadius` is again the one number that matters — it is where - * the ring of blades is seated, where the sheet's boundary band burns, where - * the curtain of cold air stands and where the rime creeps. - * - * Three things carry it, and each has its own group below: - * - * - **the sweep.** The ring does not appear; it *closes*. The blade nearest - * the caster goes up first and the wave runs around both sides to meet - * behind the crown (`sweepTime`), with the skirt banking up behind the wave - * (`skirtDelay`, `skirtWave`). - * - **the freeze front.** Every shard crystallises upward along its own axis - * while it rises (`frontRough`, `frontWidth`, `frontGlow` — see - * `materials/GlacierMaterial.js`), so the ice *forms* rather than sliding - * out of a hole. - * - **the shatter.** It leaves the same way it arrived, in pieces: a - * per-shard ramp against a chunk id made of voronoi cells and flat facets, - * so plates and wedges come away one at a time (`shatterScale`, - * `shatterEdge`, `shatterGlow`). - * - * As in every other block, a cast captures nothing but a seed and a handful of - * timestamps. Every metre, radian and second is resolved against these numbers - * each frame — including a zero-length one, which is why the crown reshapes - * under the sliders with the clock stopped. - */ - glacier: { - /* --- the cast --- */ - range: 18.0, // maximum cast distance, metres - minRange: 0.0, // a wall of ice around your own feet is a legitimate play - zoneRadius: 4.6, // the footprint — what the circle indicator measures out - speed: 44.0, // how fast the front races to the point, metres/second - snapTime: 0.22, // seconds the sheet takes to freeze out to the boundary - lifetime: 4.2, // seconds the crown stands - shatterDelay: 0.5, // seconds after `lifetime` before the ice starts to break - shatterStagger: 0.45, // seconds of random delay between neighbours - sinkTime: 1.15, // seconds one shard takes to crumble and withdraw - cooldown: 1.6, - castAnim: 'cast3', // which clip in `CAST_ANIMATIONS` the body throws - - /* --- where the front leaves the caster --- */ - // Thrown from a hand, so these are measured from the caster's origin in the - // cast's own frame, exactly as the bolt, the rock and the leash are. - handHeight: 1.22, // metres above the floor - handForward: 0.6, // metres in front of the caster - handSide: 0.18, // metres to the side (+ follows `Ability#side`) - - /* --- how the footprint is filled --- */ - /** - * Everything is seated in a band about `zoneRadius`; the middle of the - * circle is left empty on purpose, because the read of the ability is a wall - * you are looking *into* and filling the disc stops it being a ring. The - * spire in the middle is kept as a control and ships at zero. - */ - spikeCount: 220, // instances spent on one cast (capped at 320) - density: 1.0, // multiplier on that count - ringShare: 0.6, // fraction of them spent on the wall at the boundary - coreShare: 0.0, // ... on the spire in the middle (0 = the middle stays open) - lateShare: 0.12, // ... held back to push up during the hold - ringSeat: 0.94, // where the wall stands, × zoneRadius - ringScatter: 0.16, // radial jitter of the wall, × zoneRadius - skirtSeat: 0.74, // inner lip of the wreckage banked against it, × zoneRadius - skirtBand: 0.42, // how wide that band is, × zoneRadius - skirtBias: 0.9, // <1 pushes the skirt outward, >1 crowds it inward - coreSpread: 0.16, // radius of the cluster in the middle, × zoneRadius - - /* --- the silhouette --- */ - /** - * The reference is a *starburst*, not a fence: long needles thrown outward - * from the rim at a steep angle, fanned off the radius so they cross, with - * wildly uneven lengths. `ringLean` is the single control that decides - * whether this reads as a crown or a picket line — at 0 it is a fence, and - * the higher it goes the further the blades are thrown out over the floor. - */ - ringHeight: 1.4, // length of a blade on the wall, metres - ringWave: 0.61, // how uneven the crest of that wall is, 0..1 - skirtHeight: 1.7, // length of a shard in the skirt, metres - coreHeight: 5.2, // length of the spire, metres - heightJitter: 0.65, - ringLean: 0.33, // radians the wall is thrown outward (≈19°) - skirtLean: 0.3, // ... and the skirt - coreLean: 0.2, // the spire stands nearly upright - leanJitter: 1.3, - fan: 1.16, // radians a blade is splayed off its own radius, ± — the crossing - twist: 1.0, // random yaw, 0..1 of a full turn - rubble: 0.53, // fraction of the skirt demoted to ankle-height wreckage - rubbleScale: 0.34, - - /* --- an individual crystal --- */ - // Blunt wedges rather than needles: a thick base that only narrows to about - // a third at the tip, so each facet stays wide enough to catch a flash. - radius: 0.375, // base radius, metres - radiusJitter: 0.94, - taper: 0.36, // tip radius as a fraction of the base - facets: 7, // sides of the prism — fewer, so each facet is a broad flash - roughness: 0.0, // how far the facets are pushed off a clean prism - bend: 0.0, // sideways curve from base to tip — nearly straight - - /* --- the bloom: when each shard goes up --- */ - riseTime: 0.2, // seconds from buried to full height - riseOvershoot: 0.3, // how far past full height the punch carries - settle: 0.5, // seconds the overshoot takes to damp out - sweepTime: 0.42, // seconds the wave takes to run around the ring - skirtDelay: 0.1, // seconds before the skirt starts - skirtWave: 0.26, // ... and how long it takes to cross the band - coreDelay: 0.2, // seconds before the spire comes up - stagger: 0.07, // seconds of random delay on top of all of it - bloomSpread: 0.7, // fraction of the hold the late shards are scattered over - - /* --- the ice: prismatic glass, not the Lance's quarried crystal --- */ - /** - * Deliberately the *opposite* treatment to `ice`. Two frost abilities on one - * bar have to be told apart before the silhouette does it, and a recolour is - * not enough — so where the Frost Lance is milky, diffuse and tinted deeper - * the thicker it gets, these blades are near-empty glass carried entirely by - * their edges: a chromatically split fresnel (`dispersion`), light piped up - * the body to an incandescent point (`pipe`, `tipBias`, `tipGlow`), flow - * lines instead of feather frost (`stria`) and one real reflection of the - * stage off every facet (`envIntensity`, `specular`). - * See `materials/GlacierMaterial.js`. - */ - colorGlass: '#0e4a66', // the little body it has - colorEdge: '#ffffff', // the silhouette, the flow lines and the glint - colorPrismA: '#57f0ff', // one end of the dispersion split - colorPrismB: '#8f9bff', // ... and the other - colorCore: '#a8f4ff', // the light piped up the blade - colorTip: '#ffffff', // the incandescent point - body: 1.37, // how much of a body it has at all, 0 = pure edges - edgePower: 1.14, // how tightly the silhouette hugs the rim - edgeGain: 0.81, // how hard it burns - dispersion: 0.73, // how far the red, green and blue fresnels come apart - pipe: 1.09, // light piped along the blade - tipBias: 1.6, // how hard that light crowds toward the point - bands: 1.4, // slow waves travelling up it - pulseSpeed: 0.6, - tipStart: 0.6, // where the incandescent tip begins, 0..1 up the blade - tipGlow: 1.5, - stria: 0.75, // flow lines running the blade's length - striaScale: 6.0, - envIntensity: 0.6, // how much of the HDR probe the facets catch - specular: 2.0, // the tight sun lobe off them - glow: 1.0, // overall emissive gain - opacity: 1.0, - birthGlow: 2.2, // extra glow on a shard that has just erupted - birthFade: 0.5, // seconds that birth flash lasts - - /* --- the freeze front and the shatter --- */ - /** - * The two things that make this ability's ice *arrive* and *leave* rather - * than fade in and out. Both are per-instance ramps the ability drives; what - * lives here is only their look. - */ - frontRough: 0.35, // how ragged the crystallising edge is - frontWidth: 0.12, // how much of the shard is lit behind that edge - frontGlow: 2.4, // how hard it burns - shatterScale: 7.0, // break-up cells per unit of the crystal - shatterEdge: 0.08, // width of the lit rim on a fresh break - shatterGlow: 3.0, - - /* --- the sheet of ice on the floor --- */ - /** - * The indicator's promise, made real: the same circle and the same thick - * boundary, now a frozen sheet instead of a targeting aid. An ability-owned - * mesh rather than a decal precisely because a decal captures its radius - * when it spawns — this one has to re-scale under `zoneRadius` while the - * crown is standing, and to run its own front outward and back. - */ - fieldBoundary: 0.4, // thickness of the band at the edge, metres - fieldBoundaryGlow: 2.4, - fieldFill: 0.26, // the wash inside it - fieldFalloff: 1.4, // how hard that wash crowds to the rim - fieldPlates: 1.0, // tonal break-up between plates - fieldPlateScale: 2.2, // plates per metre - fieldSeam: 0.8, // rime piled in the seams between them - fieldFingers: 0.9, // frost fingers crawling over the sheet - fieldFingerScale: 1.6, // fingers per metre - fieldWarp: 0.5, // domain warp — what stops them reading as spokes - fieldCrawl: 0.12, // how fast they writhe - fieldRings: 2.6, // pressure rings travelling in toward the spire - fieldRingSpeed: -0.5, // rings/second (negative travels inward) - fieldSweep: 0.4, // slow cold sweep around the disc - fieldSweepSpeed: 0.12, // revolutions/second - fieldCore: 1.0, // brightness of the pool the spire stands in - fieldCoreSize: 0.2, // its radius, × zoneRadius - fieldPulse: 0.18, // brightness breathing - fieldPulseSpeed: 1.6, - fieldOpacity: 1.0, - fieldHeight: 0.03, // hover distance above the floor, metres - colorField: '#a7e6ff', // the wash, the plates and the fingers - colorFieldEdge: '#ffffff', // the boundary band, the seams and the pool - - /* --- the curtain of cold air standing on the ring --- */ - /** - * An open cylinder seated on the boundary, eroded by ridged noise stretched - * hard vertically and scrolled downward. This is the piece that frames the - * crown from the outside: without it the wall of blades ends at its own - * silhouette, and a wall of ice that is not shedding cold reads as glass. - * Set `veil` to 0 to take it off. - */ - veil: 0.5, // master opacity of the curtain, 0 hides it - veilHeight: 1.9, // how high it stands, metres - veilRadius: 1.02, // where it stands, × zoneRadius - veilFlare: 0.32, // how far it leans outward at the top - veilBillow: 0.22, // metre-scale lobes pushing its silhouette off round - veilScale: 1.4, // noise features per metre - veilStretch: 0.5, // <1 draws the structures out into vertical falls - veilFlow: 0.4, // how fast they pour downward - veilErode: 0.55, // how much harder the top is eaten away than the base - veilFalloff: 1.8, // how fast it thins with height - veilSpin: 0.02, // revolutions/second the whole curtain turns - veilSoftFade: 0.8, // metres of soft fade where it meets geometry - colorVeil: '#8cd2ff', - colorVeilCrest: '#ffffff', - - /* --- what the ground does --- */ - trailFrostRate: 2.2, // rime patches laid per metre of front travel - trailFrostRadius: 1.0, // radius of one, metres - frostSpread: 1.5, // the rime sheet under the crown, × zoneRadius - frostLife: 7.5, // seconds a rime patch lingers - frostIntensity: 0.85, - frostCrystals: 1.5, // grain of the packed snow - frostCollar: 2.6, // rime around the foot of a blade, × its own radius - rimeRate: 3.0, // rime patches creeping around the boundary, per second - rimeRadius: 1.0, // radius of one, metres - colorFrost: '#f0f9ff', // the lit face of the snow - colorFrostEdge: '#79b6dd', // what it goes in its own shadow - shockRadius: 7.5, // the ring that snaps out when the crown blooms, metres - ringRate: 0.9, // pressure rings pushed out while it stands, per second - colorShockA: '#8ee8ff', // body of the shockwave ring - colorShockB: '#ffffff', // its crest - - /* --- mist, chips, glitter and snow --- */ - /** - * As in every other block: a four-stop gradient sampled over the particle's - * own lifetime, `A` at birth through `D` as it dies. The **snow** is this - * ability's signature system — ice dust spawned *above* the crown and left - * to fall back down through it. Everything else in the project is thrown - * upward, and a slow fall inside the ring is what says the air over it is - * freezing rather than burning. - */ - mistRate: 240, // cold air pouring off the rim, particles/second - mistSize: 1.1, - mistSpeed: 1.6, - mistLifetime: 3.0, - mistOpacity: 0.055, - mistRise: -0.12, // negative: cold air is heavy, it falls and spreads - mistTurbulence: 0.4, - colorMistA: '#f2feff', - colorMistB: '#cdefff', - colorMistC: '#8ec9e8', - colorMistD: '#0a2c42', - shardSize: 0.07, // ice chips - shardSpeed: 6.5, - shardLifetime: 1.6, - shardGravity: -15.0, - breachShards: 3, // chips thrown as one shard breaks the surface - shatterShards: 5, // ... and as it comes apart - colorShardA: '#ffffff', - colorShardB: '#cdefff', - colorShardC: '#8ee8ff', - colorShardD: '#0a3c55', - glitterRate: 150, // the sparkle lifting off the sheet - glitterSize: 0.05, - glitterSpeed: 2.6, - glitterLifetime: 2.4, - glitterRise: 1.3, // upward drift, metres/second - glitterTurbulence: 0.6, - glitterGlow: 1.0, - colorGlitterA: '#ffffff', - colorGlitterB: '#6fe0ff', - colorGlitterC: '#bdeeff', - colorGlitterD: '#062434', - snowRate: 110, // ice dust falling back through the crown - snowSize: 0.045, - snowSpeed: 0.9, // how hard it is pushed downward to start with - snowLifetime: 3.2, - snowFall: -1.1, // gravity on it, metres/second² - snowTurbulence: 0.85, // what turns the fall into a drift - snowGlow: 0.9, - snowInset: 0.85, // how far inside the boundary it falls, × zoneRadius - snowHeight: 1.35, // where it starts, × the height of the wall - colorSnowA: '#ffffff', - colorSnowB: '#e4f9ff', - colorSnowC: '#a7e6ff', - colorSnowD: '#0c3348', - - /* --- dynamic light --- */ - lightIntensity: 14, - lightRadius: 16, - lightHeight: 0.45, // how far up the crown the light sits, 0..1 - lightColor: '#8ee8ff', - - /* --- the throw, the bloom and the hold --- */ - muzzleSize: 0.55, // the puff at the hand as the front leaves it - muzzleIntensity: 1.5, - castFlash: 0.08, // screen flash on release - colorCastFlash: '#cdefff', - burstSize: 4.0, // the vapour shell thrown off at the bloom, metres - burstIntensity: 1.1, - burstShards: 120, // extra chips at the bloom - burstMist: 70, - burstGlitter: 140, - vapourRate: 1.6, // vapour shells shed off the wall while it stands, /s - vapourSize: 1.4, // radius of one, metres - vapourIntensity: 0.7, - impactShake: 0.85, - shakeDuration: 0.85, - holdShake: 0.05, // continuous rumble while the crown stands - impactFlash: 0.2, - rumble: 0.045, // rumble while the front races out - colorBurstA: '#a7e6ff', - colorBurstB: '#cdefff', - colorBurstC: '#ffffff', - colorFlash: '#cdefff' // the full-screen flash when it blooms - }, + ...ABILITY_SETTINGS, /* ------------------------------------------------------------------ */ /* Camera rig */ @@ -1798,63 +343,77 @@ export const settings = { grain: 0.045, // Master gain on the screen-space warp written by LAYER.DISTORTION — the // last link in the heat-haze chain. Screen widths, so it stays put when the - // window resizes. - distortion: 0.045, + // window resizes. Multiplied by `global.distortion` inside the pass, and + // nowhere else: an emitter writes a bare direction and magnitude. + // + // **1.0, and it must stay 1.0.** This shipped at 0.045, which was correct + // for exactly as long as nothing wrote to the buffer: back then the pass was + // dead weight and this was a trim on an idle shimmer. Every emitter written + // since — the lens, the blade, the wake, the pressure front, the rift, the + // panes, the mirage — documents its own strength "in screen widths at + // post.distortion = 1", because that is the only contract under which an + // authored number means anything. Leaving the master at 0.045 quietly ran + // all of them at four and a half percent, and `mirage`, which is *nothing + // but* refraction, was invisible as a result. If the whole school needs + // toning down, this is still the knob — but it is a deliberate art + // decision, not a leftover. + distortion: 1.0, + // The refraction pass alone. Off is the first thing to try on weak + // hardware — it removes a render target's bandwidth and a dependent + // texture fetch per pixel. The pass also self-skips on frames where nothing + // is visible on the distortion layer, so this is for machines, not frames. + distortionEnabled: true, + // Offset-buffer resolution as a fraction of the frame. 0.5 is what the + // buffer always was; 0.25 is the potato setting and still smooth, because + // nothing written into it has an edge sharper than a metre. Clamped to + // [0.25, 1] by PostProcessing. + distortionScale: 0.5, flashStrength: 1.0 } }; -/** - * How an ability is aimed. - * - * `LINE` is the skillshot the sandbox started with: an arrow swung about the - * caster, cast along its length. `ZONE` is the **far cast** — a circle with a - * thick boundary dropped at the cursor, which answers the only question a - * ground-targeted AoE has to answer before you commit: how much space is this - * going to take. Both resolve to the same `cast(origin, direction, distance)` - * event, so an ability never has to care which one aimed it; a zone ability - * simply reads its target as `pointAt(1)` and works outward from there. - */ -export const CastShape = Object.freeze({ - LINE: 'line', - ZONE: 'zone' -}); +/* ------------------------------------------------------------------ */ +/* Derived views over the registry */ +/* ------------------------------------------------------------------ */ /** * Ability ids, in slot order. * - * `AbilityManager`, the HUD, the aim controller and the editor all key off this - * array, and the index is the slot the keyboard binds to — adding a third - * ability is a new file, an entry here and a settings block above. + * Derived from `abilities/registry.js` — this used to be a hand-written array + * and adding an ability meant editing it. It is still the array `App`, the HUD + * and the aim controller iterate, and the index is still the slot the keyboard + * binds to for the first handful of entries. */ -export const ELEMENTS = ['ice', 'thunder', 'meteor', 'beam', 'snare', 'glacier']; +export const ELEMENTS = ABILITY_IDS; /** * Registry metadata: how an ability is presented, and how it is aimed. * - * `key` must match `InputManager`. `cast` is read by `AimController` to pick - * between the arrow and the circle; omit it and the ability is a line cast. + * A derived view, projected out of the descriptors so nothing has to be + * declared twice. Shape is unchanged from when it was authored by hand — + * `{ label, accent, key, hint, cast }` — which is why the HUD and + * `AimController` needed no edit when the registry landed. `hint` mirrors + * `label`: it was always the same string, and a second field nobody diverges + * is a field that will eventually diverge by accident. + * + * `key` is `undefined` for anything that does not hold a default loadout slot, + * which is all forty-four of the expansion — the spellbook binds those at + * runtime. */ -export const ELEMENT_META = { - ice: { label: 'Frost Lance', accent: '#5fd0ff', key: 'Q', hint: 'Frost Lance' }, - thunder: { label: 'Storm Lance', accent: '#7fb4ff', key: 'E', hint: 'Storm Lance' }, - meteor: { label: 'Cinder Fall', accent: '#ff8a3c', key: 'R', hint: 'Cinder Fall' }, - beam: { label: 'Nova Beam', accent: '#7ff0ff', key: 'F', hint: 'Nova Beam' }, - snare: { - label: 'Voltaic Snare', - accent: '#a98bff', - key: 'V', - hint: 'Voltaic Snare', - cast: CastShape.ZONE - }, - glacier: { - label: 'Glacial Crown', - accent: '#8ee8ff', - key: 'X', - hint: 'Glacial Crown', - cast: CastShape.ZONE - } -}; +export const ELEMENT_META = Object.freeze( + Object.fromEntries( + ABILITIES.map((ability) => [ + ability.id, + { + label: ability.label, + accent: ability.accent, + key: ability.key, + hint: ability.label, + cast: ability.cast + } + ]) + ) +); /** How the given ability is aimed. Line unless its metadata says otherwise. */ export function castShapeOf(element) { @@ -1866,6 +425,10 @@ export function zoneRadiusOf(element) { return castShapeOf(element) === CastShape.ZONE ? (settings[element]?.zoneRadius ?? 0) : 0; } +/* ------------------------------------------------------------------ */ +/* Presets */ +/* ------------------------------------------------------------------ */ + /** Immutable snapshot used by "Reset to defaults" and the preset system. */ export const DEFAULT_SETTINGS = structuredClone(settings); diff --git a/src/core/App.js b/src/core/App.js index 94abdda..9389dd2 100644 --- a/src/core/App.js +++ b/src/core/App.js @@ -26,11 +26,15 @@ import { ScreenFlash } from '../effects/ScreenFlash.js'; import { AbilityManager } from '../abilities/AbilityManager.js'; import { PostProcessing } from '../postprocessing/PostProcessing.js'; +import { sceneHooks } from '../vfx/SceneHooks.js'; import { HUD, LoadingScreen } from '../ui/HUD.js'; import { Editor } from '../ui/Editor.js'; +import { Loadout } from '../ui/Loadout.js'; +import { Spellbook } from '../ui/Spellbook.js'; import { settings, ELEMENTS } from '../config/settings.js'; +import { getAbility } from '../abilities/registry.js'; const HDR_URL = './hdri/spruit_sunrise.hdr'; @@ -102,24 +106,51 @@ export class App { this.character = new CharacterController(this.environment); this.scene.add(this.character.root); + /* ---- the loadout: eight slots over fifty abilities ---- */ + // Built before the input manager and the HUD because both are views of it. + this.loadout = new Loadout(); + /* ---- input & targeting ---- */ - this.input = new InputManager(canvas); + this.input = new InputManager(canvas, { slotKeys: this.loadout.keys }); this.aim = new AimController(this.camera); this.scene.add(this.aim.object3D); /* ---- post ---- */ this.post = new PostProcessing(this.renderer, this.scene, this.camera); + /* ---- the world an ability is allowed to borrow ---- */ + // `vfx/SceneHooks.js` is the only module that reaches out of an ability's + // group and edits the scene itself. It is handed the four things it may + // touch and nothing else; leaving any of them out disables exactly one + // hook. Done here, before the first frame, so the floor's ageing patch is + // composed into the material before `compileAsync()` sees it. + sceneHooks.install({ + scene: this.scene, + environment: this.environment, + ground: this.ground, + grade: this.post.gradePass.uniforms, + renderer: this.renderer + }); + /* ---- UI ---- */ this.loading = new LoadingScreen(); - this.hud = new HUD(document.getElementById('hud')); + this.hud = new HUD(document.getElementById('hud'), this.loadout); this.editor = new Editor({ onClear: () => this.clearEffects(), onToast: (message) => this.hud.showToast(message) }); + this.spellbook = new Spellbook(document.getElementById('spellbook'), { + loadout: this.loadout, + onSelect: (id) => this.armAbility(id), + onBind: (slot, id) => this.bindSlot(slot, id), + onToast: (message) => this.hud.showToast(message), + onToggle: (open) => this.hud.setBookOpen(open) + }); this._bindEvents(); - this.selectAbility(ELEMENTS[0], { silent: true }); + // Whatever is in slot one, or — on a loadout somebody has emptied — the + // first thing in the registry, so the app never boots with nothing armed. + this.selectAbility(this.loadout.idAt(0) ?? ELEMENTS[0], { silent: true }); this._focusPoint = new Vector3(); } @@ -149,18 +180,36 @@ export class App { this.aim.on('reject', () => this.hud.showToast('Too close — aim further out')); this.hud.onAbility = (element) => this.armAbility(element); + this.hud.onInspect = (element) => this.inspectAbility(element); + this.hud.onEmptySlot = (slot) => this.spellbook.open({ slot }); + this.hud.onBind = (slot, element) => this.bindSlot(slot, element); + + // The key table is a view of the loadout, so it is rebuilt with it. Today + // the letters are fixed and this is a no-op; the day a slot's letter is + // editable it is the only wiring that needs to already exist. + this.loadout.on('change', () => this.input.setSlotKeys(this.loadout.keys)); } _handleAction(action, slot) { switch (action) { case 'ability': { - const element = ELEMENTS[slot] ?? this.element; + const element = this.loadout.idAt(slot); + if (!element) { + // An empty slot is an invitation, not an error: open the book with + // that slot as the target so the key the player just pressed is the + // key the next click binds. + this.spellbook.open({ slot }); + break; + } // Pressing the *same* key again puts an armed cast away, as it does in a // MOBA; pressing a different one swaps the slot without disarming. if (this.aim.isArmed && element === this.element) this.aim.cancel(); else this.armAbility(element); break; } + case 'toggleSpellbook': + this.spellbook.toggle(); + break; case 'cancel': this.aim.cancel(); break; @@ -187,12 +236,71 @@ export class App { /** * Put an ability in the slot. The aim indicator and the HUD both follow, * because `range` and `minRange` are the ability's, not the app's. + * + * Selecting is also where the ability's class is **warmed**: its module is + * imported and one pooled instance is built, off the frame loop, behind the + * arrow sweeping out. Ability classes are lazy so that fifty of them are not + * constructed at boot, and this is the moment that buys back — selection + * always precedes the click by at least a frame, usually by seconds. The + * promise is deliberately dropped; a cast that somehow beats the import is + * handled by `AbilityManager#cast` returning null. + * + * The editor follows the slot too. With fifty ability folders in the panel + * the one worth looking at is almost always the one about to be cast, so + * selecting opens that folder and scrolls it up — except on the silent + * boot-time selection, where the panel must still come up fully collapsed. */ selectAbility(element, options = {}) { - if (!ELEMENTS.includes(element)) return; + if (!getAbility(element)) return; this.abilities.select(element); + this.abilities.warm(element); this.aim.setElement(element); this.hud.setElement(element, options); + this.spellbook.setSelected(element); + this.editor.focusAbility(element, { open: !options.silent }); + } + + /** + * Bind an ability to a loadout slot, from a drag or from the spellbook. + * + * Binding does not select: dragging a spell onto slot 7 while holding a + * charged beam should not throw the beam away. + */ + bindSlot(slot, element) { + const ability = getAbility(element); + if (!ability) return; + if (!this.loadout.bind(slot, element)) { + // The commonest refusal by far is "it is already in that slot" — a + // shift-click on a spell that is already on the bar. Say so, because a + // click that does nothing at all reads as a broken target. + if (this.loadout.idAt(slot) === element) { + this.hud.showToast(`${ability.label} is already on ${this.loadout.keyAt(slot)}`); + } + return; + } + this.abilities.warm(element); + this.hud.showToast(`${ability.label} bound to ${this.loadout.keyAt(slot)}`); + } + + /** + * Open this ability's folder in the editor. + * + * Wired to the slot's *name*, not to selection. Jumping a 1900-line settings + * tree to a new folder every time the player taps a different key is the kind + * of helpfulness that makes a UI unusable, so it takes a deliberate click. + * `focusAbility` is the editor's, and is feature-detected rather than + * assumed: the schema-driven editor lands separately, and until it does the + * click says where to look instead of throwing. + */ + inspectAbility(element) { + const ability = getAbility(element); + if (!ability) return; + if (typeof this.editor.focusAbility === 'function') { + this.editor.focusAbility(element); + this.hud.showToast(`${ability.label} — editor`); + } else { + this.hud.showToast(`${ability.label} — press G for the editor`); + } } /** Select an ability and arm it, unless it is still cooling down. */ @@ -334,6 +442,12 @@ export class App { // Exactly one cascade shadow update per frame (see Renderer). gl.shadowMap.needsUpdate = true; this.post.sync(this.elapsed, this.flash); + // The one place a borrowed hook lands on the world, and the only position + // that works for all six: the environment has re-authored the key light + // from settings, `post.sync()` has re-authored the grade, and nothing has + // rendered yet — including the shadow map, which three refreshes inside the + // first `gl.render()` below. Costs one integer compare when nothing is held. + sceneHooks.apply(); this.post.render(); /* ---- readouts ---- */ @@ -365,8 +479,11 @@ export class App { this.ground.dispose(); this.dust.dispose(); this.contactShadows.dispose(); + sceneHooks.uninstall(); this.post.dispose(); this.environment.dispose(); + this.spellbook.dispose(); + this.hud.dispose(); this.editor.dispose(); this.rig.dispose(); this.renderer.dispose(); diff --git a/src/core/FrameUniforms.js b/src/core/FrameUniforms.js index a7deddc..d2b773b 100644 --- a/src/core/FrameUniforms.js +++ b/src/core/FrameUniforms.js @@ -1,4 +1,41 @@ -import { Vector2, Vector3 } from 'three'; +import { Vector2, Vector3, Vector4 } from 'three'; + +/* ---------------------------------------------------------------- */ +/* Time regions — the shared clock-bending field */ +/* ---------------------------------------------------------------- */ + +/** + * How many regions may bend the world's clock at once. + * + * Four, and the number is a compromise rather than a limit anybody hit. Every + * shader that opts into the field pays `2 × MAX_TIME_REGIONS` vec4s of its + * uniform budget whether or not a region is live — GLSL ES 1.00 guarantees only + * 128 vec4 vertex uniform vectors, and the particle shader is not the only + * thing that wants a share of them. Eight regions would be sixteen vec4s, an + * eighth of the guaranteed budget, to serve a case that does not exist: the + * ability manager caps at four concurrent casts and no single cast has ever + * wanted two. + * + * The pool lives in `vfx/TimeControl.js`. The boxes live here, holding nothing + * but zeroes, so that `core/` and `particles/` can read the field without + * importing a VFX module and dragging its shader source into the boot bundle — + * the same argument that put `distortionWriters` in `core/Layers.js`. + * + * **With no chrono ability standing, `uTimeRegionCount` is 0 and every consumer + * costs one float compare.** Nothing else in this file has to know they exist. + */ +export const MAX_TIME_REGIONS = 4; + +/** xyz = centre in world metres, w = outer radius in metres. */ +const timeRegions = []; +/** x = strength 0..1, y = inner radius as a fraction of w, z = hold (s), w = rate. */ +const timeRegionWarps = []; +for (let i = 0; i < MAX_TIME_REGIONS; i++) { + timeRegions.push(new Vector4(0, 0, 0, 0)); + // rate 1 and strength 0 are both identity; a slot that was never claimed + // must not bend anything even if a consumer reads past `uTimeRegionCount`. + timeRegionWarps.push(new Vector4(0, 0, 0, 1)); +} /** * Uniform objects shared by *every* custom material, by identity. @@ -26,10 +63,33 @@ export const frame = { uLightDir: { value: new Vector3(0.45, 0.78, 0.44).normalize() }, /** Global multipliers mirrored from settings so shaders can read them. */ uShaderIntensity: { value: 1 }, - uGlobalGlow: { value: 1 } + uGlobalGlow: { value: 1 }, + + /** + * How many slots of the two arrays below are live. **Zero unless a chrono + * ability is standing**, which is the whole cost model: every consumer of the + * field opens with `if (uTimeRegionCount < 0.5) return clock;`. + * + * A float rather than an int because the loops that read the arrays compare + * `float(i)` against it, and mixing an int uniform into a float compare has + * cost people a driver-specific compile before. + */ + uTimeRegionCount: { value: 0 }, + /** xyz = centre (world metres), w = outer radius (metres). */ + uTimeRegion: { value: timeRegions }, + /** x = strength 0..1, y = inner radius / outer, z = hold (s), w = rate. */ + uTimeRegionWarp: { value: timeRegionWarps } }; -/** Convenience: the uniform block every VFX material wants. */ +/** + * Convenience: the uniform block every VFX material wants. + * + * The three `uTimeRegion*` boxes are in here rather than in a separate helper + * so that a material opts into the time field by **declaring the uniforms in + * its shader**, and by nothing else. three.js only uploads uniforms the + * compiled program actually declares, so a material that never injects + * `timeWarpGLSL` carries three unread references and uploads nothing. + */ export function sharedUniforms(extra = {}) { return { uTime: frame.uTime, @@ -40,6 +100,9 @@ export function sharedUniforms(extra = {}) { uLightDir: frame.uLightDir, uShaderIntensity: frame.uShaderIntensity, uGlobalGlow: frame.uGlobalGlow, + uTimeRegionCount: frame.uTimeRegionCount, + uTimeRegion: frame.uTimeRegion, + uTimeRegionWarp: frame.uTimeRegionWarp, ...extra }; } diff --git a/src/core/Layers.js b/src/core/Layers.js index b40b89a..ae62481 100644 --- a/src/core/Layers.js +++ b/src/core/Layers.js @@ -21,3 +21,37 @@ export function setLayerRecursive(object, layer) { object.traverse((node) => node.layers.set(layer)); return object; } + +/* ---------------------------------------------------------------- */ +/* Who is writing to the distortion buffer */ +/* ---------------------------------------------------------------- */ + +/** + * How many meshes are currently *visible* on `LAYER.DISTORTION`. + * + * The refraction pass is a half-res clear plus a half-res draw plus a full-res + * resample, and for most of any given second nothing in the scene is refracting + * anything. `PostProcessing` consults this counter and skips all three when it + * is zero — which is the other half of closing the "the distortion pass runs + * with nothing writing to it" rough edge. Turning the pass *on* would be a poor + * trade if it then ran unconditionally. + * + * It lives here rather than in `vfx/Distortion.js` for one reason: the + * postprocessing stack must not have to import a VFX module (and drag its + * shader source into the boot bundle) merely to ask whether it has work. This + * file is already imported by both sides and is the natural home for anything + * that is *about* the layer rather than about what draws into it. + * + * Retain when a writer becomes visible, release when it hides or is disposed. + * The counter tracks visibility, not construction: abilities are pooled, so a + * writer exists for the lifetime of the app and is visible for a second of it. + */ +export const distortionWriters = { count: 0 }; + +export function retainDistortion() { + distortionWriters.count++; +} + +export function releaseDistortion() { + distortionWriters.count = Math.max(0, distortionWriters.count - 1); +} diff --git a/src/input/InputManager.js b/src/input/InputManager.js index 3f09335..a2db66f 100644 --- a/src/input/InputManager.js +++ b/src/input/InputManager.js @@ -9,22 +9,99 @@ import { EventEmitter } from '../utils/EventEmitter.js'; * `pointer:confirm` (ndc) — left click on the viewport * `action` (name, slot) — everything else, already named by intent. * `ability` carries the 0-based slot index, - * which App maps through `ELEMENTS`. + * which App maps through the loadout. * * Pointer events that begin on top of DOM UI (the editor, the HUD) are ignored * so dragging a slider never fires the ability. + * + * ## Why the switch went away + * + * The keyboard used to be a `switch` with one hard-coded `case` per ability + * letter. That is fine while the letters *are* the roster; it stops being fine + * the moment eight slots are rebindable over fifty abilities, because the truth + * about which key means which slot then lives in `Loadout` and a second copy in + * a switch statement is a second copy that will be wrong. + * + * So the letters arrive as data — `setSlotKeys(['Q','E',...])` — and are folded + * into one lookup table alongside the fixed actions. Rebinding is a call, not + * an edit. Two rules make the table safe to build from user data: + * + * - **Reserved keys win.** `C` clears the scene and has since the first build; + * a loadout that tries to claim it loses, loudly, in the console. Muscle + * memory that suddenly casts a spell is worse than a slot that will not bind. + * - **Digits mirror letters.** Slot `i` also answers to digit `i + 1`, which is + * generated here rather than being eight more rows to keep in step. + */ + +/** + * Keys that are not slots and never will be. + * + * `Tab` is the spellbook, which costs the page its focus-traversal key; that is + * a fair trade in a full-screen canvas demo with one text field in it, and `B` + * is kept as the alternative for anyone who disagrees. */ +const RESERVED_KEYS = Object.freeze({ + Escape: 'cancel', + KeyH: 'toggleHelp', + KeyG: 'toggleEditor', + KeyC: 'clear', + KeyP: 'togglePause', + Tab: 'toggleSpellbook', + KeyB: 'toggleSpellbook' +}); + +/** Keys whose browser default would fight the app. */ +const SWALLOW_DEFAULT = new Set(['Tab']); + export class InputManager extends EventEmitter { - constructor(domElement) { + /** + * @param {HTMLElement} domElement + * @param {object} [options] + * @param {string[]} [options.slotKeys] one letter per loadout slot, in order + */ + constructor(domElement, options = {}) { super(); this.dom = domElement; this.pointer = new Vector2(); // NDC this.keys = new Set(); this.enabled = true; + /** `KeyboardEvent.code` → slot index. Rebuilt by `setSlotKeys`. */ + this._slotByCode = new Map(); + this.setSlotKeys(options.slotKeys ?? []); + this._bind(); } + /** + * Point the slot keys at a new letter list. + * + * Cheap and idempotent: call it every time the loadout changes and forget + * about it. A letter that collides with a reserved key is dropped and its + * slot becomes digit-only. + * + * @param {string[]} keys one letter per slot, in slot order + */ + setSlotKeys(keys) { + this._slotByCode.clear(); + for (let slot = 0; slot < keys.length; slot++) { + const letter = String(keys[slot] ?? '').toUpperCase(); + if (letter.length === 1) { + const code = `Key${letter}`; + if (code in RESERVED_KEYS) { + console.warn( + `[InputManager] slot ${slot + 1} wants ${letter}, which is reserved for ` + + `"${RESERVED_KEYS[code]}" — the slot keeps its digit only.` + ); + } else { + this._slotByCode.set(code, slot); + } + } + // Digits 1..9 mirror the letters. Ten slots would need `Digit0`; eight do not. + if (slot < 9) this._slotByCode.set(`Digit${slot + 1}`, slot); + } + } + _bind() { this.dom.addEventListener('pointerdown', this._onPointerDown); window.addEventListener('pointermove', this._onPointerMove); @@ -69,50 +146,16 @@ export class InputManager extends EventEmitter { this.keys.add(event.code); - switch (event.code) { - // Ability slots. Keep these in step with `ELEMENT_META[...].key`. - case 'KeyQ': - case 'Digit1': - this.emit('action', 'ability', 0); - break; - case 'KeyE': - case 'Digit2': - this.emit('action', 'ability', 1); - break; - case 'KeyR': - case 'Digit3': - this.emit('action', 'ability', 2); - break; - case 'KeyF': - case 'Digit4': - this.emit('action', 'ability', 3); - break; - case 'KeyV': - case 'Digit5': - this.emit('action', 'ability', 4); - break; - case 'KeyX': - case 'Digit6': - this.emit('action', 'ability', 5); - break; - case 'Escape': - this.emit('action', 'cancel'); - break; - case 'KeyH': - this.emit('action', 'toggleHelp'); - break; - case 'KeyG': - this.emit('action', 'toggleEditor'); - break; - case 'KeyC': - this.emit('action', 'clear'); - break; - case 'KeyP': - this.emit('action', 'togglePause'); - break; - default: - break; + // Reserved first, so a bad rebind can never shadow "clear" or "pause". + const action = RESERVED_KEYS[event.code]; + if (action) { + if (SWALLOW_DEFAULT.has(event.code)) event.preventDefault(); + this.emit('action', action); + return; } + + const slot = this._slotByCode.get(event.code); + if (slot !== undefined) this.emit('action', 'ability', slot); }; _onKeyUp = (event) => { diff --git a/src/materials/AfterimageMaterial.js b/src/materials/AfterimageMaterial.js new file mode 100644 index 0000000..a510bee --- /dev/null +++ b/src/materials/AfterimageMaterial.js @@ -0,0 +1,548 @@ +import { + AdditiveBlending, + BufferGeometry, + Color, + DoubleSide, + Float32BufferAttribute, + InstancedBufferAttribute, + InstancedBufferGeometry, + ShaderMaterial, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL, commonVertexGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * The one bespoke material Afterimage needs — and the reason it is bespoke. + * + * The ability's trick is **N frozen copies of the cast, all still live**: six + * moments of one flight standing in a row down the line, each held at the age + * it was taken and each still re-resolving every metre from the sliders. There + * is no module in `src/vfx/` that expresses that, because every one of them + * takes a single `now` and draws the world at that instant. What this needs is + * a *single* draw in which every instance evaluates the same closed form at a + * **different** instant. + * + * So the whole ability is one `InstancedBufferGeometry` and this shader. Per + * instance there are exactly three constant attributes, baked once at + * construction and never touched again: + * + * | attribute | what it is | + * | --- | --- | + * | `aCopy` | `0` for the live body, `k ≥ 1` for the k-th snapshot | + * | `aFin` | which blade of that copy's fan this is | + * | `aSeed` | unitless dice, so two copies do not erode identically | + * + * and the instant a copy is showing is **derived, not stored**: + * + * ```glsl + * float target = aCopy < 0.5 ? uAge : aCopy * uSnapGap; // uSnapGap is a slider + * float shown = min(uAge, target); // grows with the cast, then holds forever + * float held = max(uAge - target, 0.0); // how long it has been standing + * ``` + * + * ### Why that is the whole design + * + * The obvious implementation stamps `frame.uTime.value` into an instance + * attribute the moment a snapshot is taken. It works, it is what the first + * version did, and it quietly throws the ability away: a stamped snapshot is a + * **captured second**, so dragging `snapGap` on a paused frame moves nothing, + * and the roster entry for this slot is precisely "pausing and dragging + * reshapes all six frozen moments at once". Deriving the target from the + * slider instead means every frozen copy re-ages under the cursor, slides to + * the place on the line it would have been at that new age, and re-opens to + * the shape it had there — while the clock is stopped. It is invariant I1 + * turned into an effect, which is the brief. + * + * It is also strictly cheaper: nothing is written to a buffer during a cast, so + * there is no attribute upload, no ring bookkeeping and no I3 exposure at all. + * + * ### The body + * + * A fan of `fins` tapered slivers on a common axis, which open outward as the + * flight progresses. The form has to be strongly age-dependent or six copies of + * it read as six of the same object: a bud at the muzzle, a blade at mid-range + * and a splayed fan at the far end is three legibly different silhouettes off + * one parameter, and that is what makes the row read as *one thing photographed + * six times* rather than as six things. + * + * The first version varied only the length. Six copies of a stick at six + * lengths reads as a ruler. + */ + +/** Rings along the sliver. Enough that the taper is a curve and not a cone. */ +const RINGS = 16; +/** Sides around it. Ten is where the silhouette stops being a polygon at 3 m. */ +const SIDES = 10; + +/** + * Hard ceiling on blades per copy. The `fins` slider clamps here, and the + * instance layout is `copy × MAX_FINS + fin` so that truncating `instanceCount` + * drops whole copies rather than half of one. + */ +export const MAX_FINS = 4; + +/** + * Hard ceiling on frozen copies. Six is the roster's number; eight leaves the + * slider somewhere to go. Note the real cost of raising it is not the draw — + * there is only ever one — but the fill, since every copy is a translucent + * additive body the camera can be inside of. + */ +export const MAX_SNAPS = 8; + +/** + * A unit sliver: a lathe about +Y, `y` running 0 → 1, radius 1 at its widest. + * + * Authored in unit space for the same reason `GrowthField`'s geometry factory + * is — the instance scales length and radius independently, and `position.y` + * then reads straight off in the fragment shader as "how far along this blade + * am I", which is what every gradient on it keys off. + * + * The profile is `sin(πy)^k` rather than a cone: a cone has a flat base that + * catches the rim light as a disc and makes the fan look like a bundle of + * pencils. Both ends have to come to a point. + */ +function createSliverGeometry(profile = 0.62) { + const position = []; + const normal = []; + const index = []; + + for (let r = 0; r <= RINGS; r++) { + const y = r / RINGS; + const radius = Math.pow(Math.sin(Math.PI * y), profile); + // Analytic derivative of the profile — a forward difference here puts a + // visible crease in the normal at the two tips, where dr/dy is unbounded. + const dr = + profile * + Math.pow(Math.max(Math.sin(Math.PI * y), 1e-4), profile - 1) * + Math.cos(Math.PI * y) * + Math.PI; + + for (let s = 0; s <= SIDES; s++) { + const theta = (s / SIDES) * Math.PI * 2; + const cos = Math.cos(theta); + const sin = Math.sin(theta); + position.push(radius * cos, y, radius * sin); + // Surface of revolution: n ∝ (cosθ, −r′, sinθ). + const length = Math.sqrt(1 + dr * dr); + normal.push(cos / length, -dr / length, sin / length); + } + } + + for (let r = 0; r < RINGS; r++) { + for (let s = 0; s < SIDES; s++) { + const a = r * (SIDES + 1) + s; + const b = a + SIDES + 1; + index.push(a, b, a + 1, b, b + 1, a + 1); + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(position, 3)); + geometry.setAttribute('normal', new Float32BufferAttribute(normal, 3)); + geometry.setIndex(index); + return geometry; +} + +/** + * The instanced geometry: `(MAX_SNAPS + 1) × MAX_FINS` copies of one sliver. + * + * Built once per ability instance and never rewritten. `instanceCount` is the + * only thing that moves, and because the layout is copy-major, lowering it + * removes the *oldest* snapshots — which is what a `snaps` slider should do. + */ +export function createAfterimageGeometry() { + const source = createSliverGeometry(); + const geometry = new InstancedBufferGeometry(); + geometry.index = source.index; + geometry.setAttribute('position', source.getAttribute('position')); + geometry.setAttribute('normal', source.getAttribute('normal')); + + const total = (MAX_SNAPS + 1) * MAX_FINS; + const copy = new Float32Array(total); + const fin = new Float32Array(total); + const seed = new Float32Array(total); + + for (let k = 0; k <= MAX_SNAPS; k++) { + for (let f = 0; f < MAX_FINS; f++) { + const i = k * MAX_FINS + f; + copy[i] = k; + fin[i] = f; + // A fixed irrational-ish walk rather than Math.random(): the dice are + // baked into a buffer that outlives every cast, so they must not make + // one pooled instance look different from another. + seed[i] = (i * 0.6180339887) % 1; + } + } + + geometry.setAttribute('aCopy', new InstancedBufferAttribute(copy, 1)); + geometry.setAttribute('aFin', new InstancedBufferAttribute(fin, 1)); + geometry.setAttribute('aSeed', new InstancedBufferAttribute(seed, 1)); + geometry.instanceCount = MAX_FINS; + return geometry; +} + +/* ------------------------------------------------------------------ */ +/* The shader */ +/* ------------------------------------------------------------------ */ + +const AFTERIMAGE_VERTEX = /* glsl */ ` + #define AI_PI 3.141592653589793 + #define AI_TAU 6.283185307185 + + attribute float aCopy; + attribute float aFin; + attribute float aSeed; + + uniform float uAge; // the ability's own clock, seconds + uniform float uSnapGap; // seconds of cast age between one copy and the next + uniform float uSpeed; // metres/second the body flies at + uniform float uLength; // metres to the far end + uniform float uFlightCurve; // >1 starts slow and arrives fast + + uniform vec3 uOrigin; + uniform vec3 uDir; + uniform vec3 uSide; + + uniform float uLiftNear; // metres above the floor at the muzzle + uniform float uLift; // ... and at the far end + uniform float uArc; // metres the flight bows upward at mid-span + uniform float uSway; // metres of lateral wander + uniform float uSwayWaves; // wavelengths of it over the whole line + uniform float uSeed; + + uniform float uFins; + uniform float uSplay; // radians a blade tilts off the axis, fully open + uniform float uFinGap; // metres a blade steps off the axis, fully open + uniform float uRoll; // radians the fan is rolled at birth + uniform float uRollSpeed; // radians/second it keeps rolling as it flies + uniform float uHoldSpin; // radians/second a *frozen* copy keeps turning + + uniform float uLenNear; // metres — the bud at the muzzle + uniform float uLen; // metres — the blade, fully open + uniform float uRadNear; // metres + uniform float uRad; // metres + uniform float uFlatten; // 0..1 squashes the sliver into a blade + uniform float uOpenCurve; // >1 keeps it shut and then opens it late + + uniform float uHoldLife; // seconds a frozen copy takes to give up + uniform float uHoldShrink; // × per second a frozen copy contracts + uniform float uHoldSink; // metres/second it settles + + varying vec3 vWorld; + varying vec3 vNormalW; + varying float vAlong; // 0..1 base → tip of this blade + varying float vHeld; // 0..1 how far through its hold this copy is + varying float vOpen; // 0..1 how far the fan had opened + varying float vSeed; + varying float vViewZ; + + ${commonVertexGLSL} + + void main() { + /* --- which instant is this copy showing --- */ + // 'target' is derived from a live slider, never from a stored timestamp: + // that is the difference between six frozen moments and six dead ones. + float target = aCopy < 0.5 ? uAge : aCopy * uSnapGap; + float shown = min(uAge, target); + float held = max(uAge - target, 0.0); + vHeld = clamp(held / max(uHoldLife, 1e-3), 0.0, 1.0); + vSeed = aSeed; + + /* --- where the body was at that instant --- */ + float travel = uLength / max(uSpeed, 0.01); + float s = pow(clamp(shown / max(travel, 1e-4), 0.0, 1.0), max(uFlightCurve, 0.05)); + float open = pow(s, max(uOpenCurve, 0.05)); + vOpen = open; + + vec3 centre = uOrigin + uDir * (s * uLength); + centre += uSide * (sin((s * uSwayWaves + uSeed) * AI_TAU) * uSway); + centre.y += mix(uLiftNear, uLift, s) + uArc * sin(s * AI_PI) - held * uHoldSink; + + /* --- the blade itself --- */ + // A fin past the live count collapses to a point. Branch-free, because the + // alternative is a per-instance early-out and the degenerate triangles it + // produces never reach a fragment anyway. + float finOn = step(aFin + 0.5, uFins); + float shrink = max(1.0 - held * uHoldShrink, 0.0) * finOn; + float len = mix(uLenNear, uLen, open) * shrink; + float rad = mix(uRadNear, uRad, open) * shrink; + float radZ = rad * max(uFlatten, 0.02); + + vAlong = position.y; + + vec3 q = vec3(position.x * rad, (position.y - 0.5) * len, position.z * radZ); + vec3 n = normal * vec3(1.0 / max(rad, 1e-4), 1.0 / max(len, 1e-4), 1.0 / max(radZ, 1e-4)); + + // Splay: tilt the blade off the travel axis, then step it outward, then + // roll the whole fan about that axis. Doing the tilt first is what makes + // the fan a *cone* — tilting after the roll gives every blade the same + // lean in world space and the thing reads as a bent bundle. + float sp = uSplay * open; + float cs = cos(sp); + float sn = sin(sp); + q = vec3(q.x * cs - q.y * sn, q.x * sn + q.y * cs, q.z); + n = vec3(n.x * cs - n.y * sn, n.x * sn + n.y * cs, n.z); + q.x += uFinGap * open; + + float phi = (aFin + 0.5) / max(uFins, 1.0) * AI_TAU + + uRoll + uRollSpeed * shown + uHoldSpin * held; + float cp = cos(phi); + float sq = sin(phi); + q = vec3(q.x * cp + q.z * sq, q.y, -q.x * sq + q.z * cp); + n = vec3(n.x * cp + n.z * sq, n.y, -n.x * sq + n.z * cp); + + // The cast's own frame: local x is lateral, local y is downrange, local z + // is up. uSide and uDir arrive orthonormal from Ability. + vec3 up = cross(uSide, uDir); + mat3 basis = mat3(uSide, uDir, up); + + vec3 world = centre + basis * q; + vWorld = world; + vNormalW = normalize(basis * n); + + vec4 view = viewMatrix * vec4(world, 1.0); + vViewZ = -view.z; + gl_Position = projectionMatrix * view; + } +`; + +const AFTERIMAGE_FRAGMENT = /* glsl */ ` + #define AI_TAU 6.283185307185 + + uniform vec3 uColorLive; + uniform vec3 uColorHeld; + uniform vec3 uColorRim; + uniform vec3 uColorCore; + uniform float uRim; + uniform float uRimPower; + uniform float uCore; + uniform float uBandScale; + uniform float uBandSpeed; + uniform float uBandGlow; + uniform float uErode; + uniform float uErodeScale; + uniform float uEdge; + uniform float uEdgeGlow; + uniform float uHoldDim; + uniform float uGlow; + uniform float uOpacity; + uniform float uFade; + uniform float uSoftFade; + + uniform float uTime; + uniform float uGlobalGlow; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + varying vec3 vWorld; + varying vec3 vNormalW; + varying float vAlong; + varying float vHeld; + varying float vOpen; + varying float vSeed; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + vec3 view = normalize(cameraPosition - vWorld); + float ndv = clamp(dot(normalize(vNormalW), view), 0.0, 1.0); + float rim = pow(1.0 - ndv, max(uRimPower, 0.05)); + + // Both tips are points, so anything drawn evenly along the blade piles up + // there and the sliver reads as two bright dots on a wire. The core is + // weighted to the middle instead. + float spine = pow(max(sin(vAlong * 3.14159265), 0.0), 1.4); + + // Bands measured in **world metres**, not along the blade: they stay put in + // the room, so a copy that froze between two of them sits between them + // forever. Keyed to the blade's own length instead, every copy would carry + // an identical stripe and the row would look printed. + float band = 0.5 + 0.5 * sin((vWorld.y * uBandScale - uTime * uBandSpeed) * AI_TAU); + + /* --- ageing --- */ + float aged = clamp(vHeld, 0.0, 1.0); + vec3 body = mix(uColorLive, uColorHeld, aged); + + // A held copy comes apart from the inside. dissolveMask returns the keep + // mask and the burning edge in one call — the same pair GhostRig uses, + // so the two chrono slots dissolve with the same vocabulary. + float grain = clamp(fbm3(vWorld * max(uErodeScale, 1e-3) + vSeed * 37.0) * 0.5 + 0.5, 0.0, 1.0); + vec2 cut = dissolveMask(grain, aged * uErode * 1.2 - 0.1, max(uEdge, 1e-3)); + + float shade = uCore * spine * (0.35 + 0.65 * vOpen) + uRim * rim + uBandGlow * band * spine; + vec3 color = body * shade + uColorRim * rim * uRim * 0.6 + uColorCore * uCore * spine * 0.5; + color += uColorRim * cut.y * uEdgeGlow; + // Reinhard ceiling: rim, core and burn all peak on the silhouette of a + // dissolving copy at once, and bloom turns the sum into a white blob. + color /= 1.0 + color * 0.22; + color *= uGlow * uGlobalGlow * mix(1.0, 1.0 - uHoldDim, aged); + + float alpha = (rim * 0.75 + spine * 0.55 + band * 0.15) + * uOpacity * uFade * cut.x * (1.0 - aged * uHoldDim); + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.003) discard; + + gl_FragColor = vec4(color, alpha); + } +`; + +/** + * The material. Additive, depth-tested, no depth write. + * + * Additive because six translucent bodies seen through each other have to + * *add*: with a normal blend the nearest copy over-writes the ones behind it + * and, with no depth write to sort them, the row develops a hard edge wherever + * two of them overlap and it flickers as the camera turns. Light does not do + * that, and a frozen moment is made of light here. + */ +export function createAfterimageMaterial() { + const material = new ShaderMaterial({ + name: 'Afterimage', + vertexShader: AFTERIMAGE_VERTEX, + fragmentShader: AFTERIMAGE_FRAGMENT, + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uAge: { value: 0 }, + uSnapGap: { value: 0.12 }, + uSpeed: { value: 18 }, + uLength: { value: 18 }, + uFlightCurve: { value: 1 }, + + uOrigin: { value: new Vector3() }, + uDir: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + + uLiftNear: { value: 1.2 }, + uLift: { value: 1.0 }, + uArc: { value: 0.35 }, + uSway: { value: 0.2 }, + uSwayWaves: { value: 1.2 }, + uSeed: { value: 0 }, + + uFins: { value: 3 }, + uSplay: { value: 0.4 }, + uFinGap: { value: 0.08 }, + uRoll: { value: 0 }, + uRollSpeed: { value: 1.4 }, + uHoldSpin: { value: 0.15 }, + + uLenNear: { value: 0.5 }, + uLen: { value: 1.9 }, + uRadNear: { value: 0.1 }, + uRad: { value: 0.16 }, + uFlatten: { value: 0.45 }, + uOpenCurve: { value: 0.8 }, + + uHoldLife: { value: 2.4 }, + uHoldShrink: { value: 0.05 }, + uHoldSink: { value: 0.05 }, + + uColorLive: { value: new Color('#f6e3b4') }, + uColorHeld: { value: new Color('#8d8570') }, + uColorRim: { value: new Color('#ffeec8') }, + uColorCore: { value: new Color('#ffffff') }, + uRim: { value: 1.1 }, + uRimPower: { value: 2.4 }, + uCore: { value: 0.9 }, + uBandScale: { value: 3.0 }, + uBandSpeed: { value: 0.6 }, + uBandGlow: { value: 0.35 }, + uErode: { value: 0.85 }, + uErodeScale: { value: 2.6 }, + uEdge: { value: 0.16 }, + uEdgeGlow: { value: 1.6 }, + uHoldDim: { value: 0.55 }, + uGlow: { value: 1.5 }, + uOpacity: { value: 1 }, + uFade: { value: 1 }, + uSoftFade: { value: 0.5 } + }) + }); + + /** + * Push the live settings and this frame's cast state into the shader. + * + * Everything with a unit is read here, every frame, from `settings.afterimage` + * — the state object carries only the cast's frame, its dice and its clock. + * + * @param {object} state `{ origin, dir, side, length, age, seed, fade }` — + * the cast's frame, how far it reaches, its clock and its one dice roll + */ + material.userData.sync = (state) => { + const c = settings.afterimage; + const g = settings.global; + const u = material.uniforms; + + u.uAge.value = state.age; + u.uSeed.value = state.seed; + u.uFade.value = state.fade; + u.uOrigin.value.copy(state.origin); + u.uDir.value.copy(state.dir); + u.uSide.value.copy(state.side); + u.uLength.value = state.length; + + u.uSnapGap.value = c.snapGap; + u.uSpeed.value = c.speed * g.speed; + u.uFlightCurve.value = c.flightCurve; + + u.uLiftNear.value = c.liftNear; + u.uLift.value = c.lift; + u.uArc.value = c.arc; + u.uSway.value = c.sway; + u.uSwayWaves.value = c.swayWaves; + + u.uFins.value = c.fins; + u.uSplay.value = c.splay; + u.uFinGap.value = c.finGap; + u.uRoll.value = c.roll; + u.uRollSpeed.value = c.rollSpeed; + u.uHoldSpin.value = c.holdSpin; + + u.uLenNear.value = c.lengthNear; + u.uLen.value = c.bladeLength; + u.uRadNear.value = c.radiusNear; + u.uRad.value = c.bladeRadius; + u.uFlatten.value = c.flatten; + u.uOpenCurve.value = c.openCurve; + + u.uHoldLife.value = c.holdLife; + u.uHoldShrink.value = c.holdShrink; + u.uHoldSink.value = c.holdSink; + + u.uColorLive.value.copy(getColor(c.colorLive)); + u.uColorHeld.value.copy(getColor(c.colorHeld)); + u.uColorRim.value.copy(getColor(c.colorRim)); + u.uColorCore.value.copy(getColor(c.colorCore)); + u.uRim.value = c.rim; + u.uRimPower.value = c.rimPower; + u.uCore.value = c.core; + u.uBandScale.value = c.bandScale; + u.uBandSpeed.value = c.bandSpeed; + u.uBandGlow.value = c.bandGlow; + u.uErode.value = c.erode; + u.uErodeScale.value = c.erodeScale; + u.uEdge.value = c.erodeEdge; + u.uEdgeGlow.value = c.edgeGlow; + u.uHoldDim.value = c.holdDim; + u.uGlow.value = c.glow * g.glow; + u.uOpacity.value = c.opacity * g.opacity; + u.uSoftFade.value = c.softFade; + }; + + return material; +} diff --git a/src/materials/AstralStoneMaterial.js b/src/materials/AstralStoneMaterial.js new file mode 100644 index 0000000..204a6b3 --- /dev/null +++ b/src/materials/AstralStoneMaterial.js @@ -0,0 +1,354 @@ +import { Color, MeshDepthMaterial, MeshStandardMaterial, RGBADepthPacking, Vector3 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { patchGrowthMaterial } from '../vfx/GrowthField.js'; +import { patchOnBeforeCompile, replaceChunk } from '../utils/shaderPatch.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/* ------------------------------------------------------------------------ */ +/* AstralStoneMaterial — the one material in the project that knows about a */ +/* plane it is not allowed to be seen behind */ +/* ------------------------------------------------------------------------ */ +/** + * Obelisks of dark stone with a starfield inside them, and — the whole reason + * this file exists — a **half-space clip against the gate plane**. + * + * ## The clip + * + * Astral Gate's bodies are `GrowthField` instances whose bases sit on a portal + * aperture floating in the air. `GrowthField` emerges an instance by burying it + * `emergeSink` of its own height below its base and sliding it up, which is + * exactly right when the base is the floor and exactly wrong when the base is a + * hole in space: the buried part hangs *under the gate*, in mid-air, in plain + * view. Two uniforms and one dot product fix it: + * + * ```glsl + * float side = dot(vGrowWorld - uGatePoint, uGateNormal); + * if (side < 0.0) discard; + * ``` + * + * It is a general half-space and not `worldY > k` on purpose. The extra cost is + * a dot product over a subtraction, and what it buys is a gate that can be + * tilted or stood on edge later without this file changing at all — the plane + * is defined by the same point and normal the `Portal` is placed with, so the + * two cannot drift. + * + * ## Why the *depth* material has to be patched too + * + * The first version stopped at the fragment discard above and looked correct + * from every angle except one: the floor. `Environment#registerShadowCasterWithPatch` + * patches the material you hand it, and three renders the shadow map with its + * own auto-generated `MeshDepthMaterial`, which has never heard of the gate. So + * every body that had not come through yet was invisible **and casting a full + * shadow on the stone below** — a ring of shadows with nothing above them, + * which is a far worse artefact than the one the clip was written to remove. + * + * So the ability also gets a `customDepthMaterial` carrying the identical test, + * sharing the **same two uniform boxes by identity**, so one write per frame + * moves the clip in the colour pass and in the shadow pass together. Cloning + * them would give a shadow that lags the geometry by however wrong the second + * copy was, and nobody would ever find it. + * + * ## The emergence lip + * + * A clip on its own is an absence, and an absence is not an effect. The same + * `side` value drives a hot band — `exp(-side / lipWidth)` — so every body is + * ringed in light exactly where it crosses, and the ring travels down the shaft + * as the body climbs. That band is the only thing in the ability that says + * *through* rather than *in front of*, and it exists because the shader knows + * where the plane is. It is the positive half of the trick and the reason the + * clip is worth having rather than merely correct. + * + * ## The stone + * + * Built on `MeshStandardMaterial`, like `IceMaterial` and for the same reason: + * the obelisks take the stage's real shadows, real lights and HDR probe, and + * the stylisation is injected after `` where the face + * normal exists (`flatShading` is on, so there is no `vNormal` varying). + * + * Three marks on top of the PBR: a base-to-tip darkening in **local** space so + * it follows each body's own axis however it is scaled and leaned; ridged + * starlight veins in **world** space so two neighbouring obelisks look quarried + * out of the same block of night; and a hashed lattice of points on the faces, + * which is the same construction the `Portal`'s interior uses for its stars. + * That last one is deliberate: the stone is supposed to look like a piece of + * what is behind the gate, and the cheapest way to say so is to make its specks + * and the portal's stars come out of the same maths. + * + * The first attempt shaded the veins in local space so they ran base-to-tip + * with the gradient. Every obelisk got an identical set of stripes and the + * field read as forty copies of one prop with a decal on it — which it was. + * World space costs nothing and fixes it completely. + * + * Uniform boxes are parked on `material.userData.uniforms` (I8) so the + * harness's pause probe can see them, and `material.userData.sync()` re-reads + * `settings.astralgate` every frame, zero-length frames included. + */ + +/** Declarations shared by the colour pass and the depth pass. */ +const GATE_UNIFORMS_GLSL = /* glsl */ ` + uniform vec3 uGatePoint; // a point on the aperture plane, world metres + uniform vec3 uGateNormal; // its unit normal; +side is "has come through" +`; + +const STONE_FRAGMENT = /* glsl */ ` + // Everything here is in the body's own unit space (vGrowLocal, y = 0 at the + // base and 1 at the tip) or in world metres (vGrowWorld). Never both. + float up = clamp(vGrowLocal.y, 0.0, 1.0); + vec3 N = normalize(normal); + vec3 V = normalize(vViewPosition); + + /* --- the body: dark at the base, opening out toward the tip --- */ + vec3 body = mix(uColorDeep, uColorFace, pow(up, max(uFaceCurve, 0.05))); + + /* --- starlight veins, in WORLD space (see the header) --- */ + float vein = ridged(vGrowWorld * uVeinScale + uSeedOffset * vGrowSeed, 4); + vein = smoothstep(uVeinSharp, 1.0, vein) * uVeins; + + /* --- and the specks, on the same lattice the portal draws stars on --- */ + vec3 cell = floor(vGrowWorld * uSpeckScale); + float pick = hash13(cell + vGrowSeed); + float speck = smoothstep(0.86, 0.995, pick) * uSpecks; + + /* --- grain, so the faces are not flat washes --- */ + float grain = fbm3(vGrowWorld * uGrainScale) * 0.5 + 0.5; + body *= 1.0 - uGrain * (0.5 - grain); + + float fres = pow(1.0 - clamp(abs(dot(N, V)), 0.0, 1.0), max(uFresnelPower, 0.05)); + + diffuseColor.rgb *= body; + totalEmissiveRadiance += + uColorVein * (vein + speck) * uGlow + + uColorFace * fres * uFresnel + // The birth flash GrowthField already writes, spent here rather than on a + // second attribute: 1 on the frame a body is triggered, 0 uGrowBirth + // seconds later. + + uColorBirth * vGrowBirth * uBirthGlow; + + /* --- the emergence lip: the positive half of the clip --- */ + float side = dot(vGrowWorld - uGatePoint, uGateNormal); + float lip = exp(-max(side, 0.0) / max(uLipWidth, 1e-3)); + totalEmissiveRadiance += uColorLip * lip * uLipGlow; +`; + +/** + * The two boxes the colour pass and the depth pass share by identity. + * + * Built per material rather than at module scope: two Astral Gates can be live + * at once under the concurrency cap, standing at two different points, and a + * module-level plane would put both fields' clips at whichever gate synced + * last. Every other uniform in this file could be shared safely; these two + * cannot, and that is worth a sentence because the bug it prevents looks like + * "sometimes half the obelisks are missing". + */ +function gatePlaneUniforms() { + return { + uGatePoint: { value: new Vector3() }, + uGateNormal: { value: new Vector3(0, 1, 0) } + }; +} + +/** + * The depth pass, carrying the identical half-space test. + * + * Written as a patch on a real `MeshDepthMaterial` rather than as a bespoke + * shader because the shadow map's packing, its `logdepthbuf` handling and its + * instancing all come free that way, and every one of them is a thing that + * silently produces a black screen when it is written by hand. + * + * `replaceChunk` rather than `String#replace`: a chunk name that vanishes in a + * three upgrade warns, once, naming the token — the alternative is a shadow + * pass that quietly stops clipping and nobody notices for a month. + */ +function createGateDepthMaterial(plane) { + const material = new MeshDepthMaterial({ depthPacking: RGBADepthPacking }); + + patchOnBeforeCompile(material, (shader) => { + Object.assign(shader.uniforms, plane); + + shader.vertexShader = replaceChunk( + shader.vertexShader, + '#include ', + `#include + varying vec3 vGateWorld;` + ); + shader.vertexShader = replaceChunk( + shader.vertexShader, + '#include ', + `#include + #ifdef USE_INSTANCING + vGateWorld = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz; + #else + vGateWorld = (modelMatrix * vec4(transformed, 1.0)).xyz; + #endif` + ); + + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + ${GATE_UNIFORMS_GLSL} + varying vec3 vGateWorld;` + ); + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + if (dot(vGateWorld - uGatePoint, uGateNormal) < 0.0) discard;` + ); + }); + + material.userData = material.userData ?? {}; + material.userData.uniforms = plane; + return material; +} + +/** + * @param {object} environment `world/Environment.js` — routes the patch through + * `registerShadowCasterWithPatch` so nothing else's patch is clobbered + * @returns {THREE.MeshStandardMaterial} with `userData.sync(gatePoint, gateNormal)` + * and `userData.depthMaterial`, which the ability must hang on every + * one of the field's meshes as `customDepthMaterial` + */ +export function createAstralStoneMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.72, + metalness: 0.08, + // Crisp facets on a quarried body, and the reason the shading above reads + // `normal` rather than a varying. + flatShading: true + }); + + const plane = gatePlaneUniforms(); + + // No `uLightDir`. Every other patched standard material in this project + // carries one because it fakes its own lambert; this one does not fake + // anything — the obelisks are lit by the stage's real key light through the + // physical shader, and the only view-dependent term here is a fresnel off the + // face normal. An unused uniform is not free: it is a line the next person + // has to read and decide is not the reason their light is wrong. + const uniforms = { + ...plane, + + /* --- the body --- */ + uColorDeep: { value: new Color('#0d1024') }, // the base, in shadow + uColorFace: { value: new Color('#3a3f66') }, // the lit faces near the tip + uColorVein: { value: new Color('#b9a6ff') }, // the starlight in the cracks + uColorLip: { value: new Color('#e6dcff') }, // the band at the plane + uColorBirth: { value: new Color('#ffffff') }, // the flash as it breaks through + uFaceCurve: { value: 1.4 }, // >1 keeps the base dark and opens late + + /* --- the marks --- */ + uVeins: { value: 0.85 }, + uVeinScale: { value: 1.6 }, // cycles per metre + uVeinSharp: { value: 0.62 }, + uSpecks: { value: 0.7 }, + uSpeckScale: { value: 11.0 }, // cells per metre + uGrain: { value: 0.35 }, + uGrainScale: { value: 5.5 }, // cycles per metre + uSeedOffset: { value: 3.1 }, // how far a per-instance seed shifts the field + + /* --- optics --- */ + uFresnel: { value: 0.9 }, + uFresnelPower: { value: 3.0 }, + uGlow: { value: 1.4 }, + uBirthGlow: { value: 2.2 }, + + /* --- the clip's visible half --- */ + uLipWidth: { value: 0.22 }, // metres the band falls off over + uLipGlow: { value: 2.6 } + }; + + patchGrowthMaterial(material, { + environment, + uniforms, + common: /* glsl */ ` + ${GATE_UNIFORMS_GLSL} + uniform vec3 uColorDeep; + uniform vec3 uColorFace; + uniform vec3 uColorVein; + uniform vec3 uColorLip; + uniform vec3 uColorBirth; + uniform float uFaceCurve; + uniform float uVeins; + uniform float uVeinScale; + uniform float uVeinSharp; + uniform float uSpecks; + uniform float uSpeckScale; + uniform float uGrain; + uniform float uGrainScale; + uniform float uSeedOffset; + uniform float uFresnel; + uniform float uFresnelPower; + uniform float uGlow; + uniform float uBirthGlow; + uniform float uLipWidth; + uniform float uLipGlow; + ${noiseGLSL} + `, + fragment: STONE_FRAGMENT + }); + + // The clip itself, in the colour pass. Injected separately from the shading + // above and *before* it in the shader, because a discarded fragment must not + // pay for four octaves of ridged noise first. + patchOnBeforeCompile(material, (shader) => { + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + if (dot(vGrowWorld - uGatePoint, uGateNormal) < 0.0) discard;` + ); + }); + + const depthMaterial = createGateDepthMaterial(plane); + + /** + * Re-resolve everything from `settings.astralgate`. + * + * @param {THREE.Vector3} point a point on the aperture plane, world metres + * @param {THREE.Vector3} normal its unit normal + */ + material.userData.sync = (point, normal) => { + const c = settings.astralgate; + const g = settings.global; + + uniforms.uGatePoint.value.copy(point); + uniforms.uGateNormal.value.copy(normal); + + uniforms.uColorDeep.value.copy(getColor(c.colorStoneDeep)); + uniforms.uColorFace.value.copy(getColor(c.colorStoneFace)); + uniforms.uColorVein.value.copy(getColor(c.colorStoneVein)); + uniforms.uColorLip.value.copy(getColor(c.colorLip)); + uniforms.uColorBirth.value.copy(getColor(c.colorBirth)); + + uniforms.uFaceCurve.value = c.stoneFaceCurve; + uniforms.uVeins.value = c.stoneVeins * g.shaderIntensity; + uniforms.uVeinScale.value = c.stoneVeinScale * g.noiseFrequency; + uniforms.uVeinSharp.value = c.stoneVeinSharp; + uniforms.uSpecks.value = c.stoneSpecks * g.shaderIntensity; + uniforms.uSpeckScale.value = c.stoneSpeckScale * g.noiseFrequency; + uniforms.uGrain.value = c.stoneGrain * g.shaderIntensity; + uniforms.uGrainScale.value = c.stoneGrainScale * g.noiseFrequency; + uniforms.uSeedOffset.value = c.stoneSeedOffset; + + uniforms.uFresnel.value = c.stoneFresnel * g.fresnel; + uniforms.uFresnelPower.value = c.stoneFresnelPower; + uniforms.uGlow.value = c.stoneGlow * g.glow; + uniforms.uBirthGlow.value = c.birthGlow * g.glow; + + uniforms.uLipWidth.value = c.lipWidth; + uniforms.uLipGlow.value = c.lipGlow * g.glow; + + material.roughness = c.stoneRoughness; + material.metalness = c.stoneMetalness; + material.envMapIntensity = c.stoneEnv; + }; + + // I8 — the pause probe reads uniforms off here, and so does anyone wondering + // why the lip will not move. + material.userData.uniforms = uniforms; + material.userData.depthMaterial = depthMaterial; + return material; +} diff --git a/src/materials/BeadOrbitMaterial.js b/src/materials/BeadOrbitMaterial.js new file mode 100644 index 0000000..31fd8f4 --- /dev/null +++ b/src/materials/BeadOrbitMaterial.js @@ -0,0 +1,424 @@ +import { + BufferAttribute, + Color, + IcosahedronGeometry, + InstancedBufferAttribute, + InstancedBufferGeometry, + NormalBlending, + ShaderMaterial, + Sphere, + Vector3 +} from 'three'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * BeadOrbitMaterial — beads of blood on real inclined orbits. + * + * ## Why this is not a ring of sprites + * + * The first version of Sanguine Pact drew its beads as billboards on a circle + * in the ground plane, which is what almost every "orbiting motes" effect + * actually is. It reads as a decal. There is no perspective in it: nothing ever + * passes *in front of* the mist column and nothing ever passes *behind* it, so + * the ring sits on the image rather than in the room, and the moment the camera + * drops toward the floor the whole thing collapses to a line. + * + * So these are solid spheres on **inclined ellipses**, and the inclination is + * the entire point. Every bead owns its own orbital plane — its own ascending + * node round the vertical, its own tilt off horizontal — so at any instant a + * third of them are between the camera and the column and the rest are behind + * it, correctly occluded by the depth buffer they write into themselves. Tip + * the camera and the ring becomes a sphere of paths. That is what "real orbits" + * buys and it cannot be faked in two dimensions. + * + * ## Everything is in the vertex shader + * + * A vertex arrives as a point on a unit icosphere plus two per-instance + * numbers — a dice roll and an index — and leaves as a world position. No orbit + * exists on the CPU, so there is nothing to go stale: dragging `orbitTilt` on a + * paused frame re-inclines every plane, and dragging `beadRim` slides the whole + * flock onto a different circle. That is invariant I1 kept by construction + * rather than by discipline. + * + * The orbit is built in three steps: + * + * 1. **the plane** — a line of nodes `n` in the ground plane at longitude + * `node`, and a second axis `t` tipped out of horizontal by `tilt`. Spread + * the nodes on the golden angle rather than uniformly: `count` beads evenly + * spaced round the vertical produces visible symmetry the moment two orbits + * line up, and the golden angle is the standard cure. + * 2. **the ellipse** — semi-major `a`, semi-minor `a·sqrt(1 - e²)`. A real + * ellipse rather than a circle, because a bead that speeds up as it passes + * the near focus is most of what says *orbit* rather than *turntable*. + * 3. **the climb** — each bead's plane sits at its own height up the column, + * and `uClimb` lifts the whole stack out of the pool over the beat. + * + * ## The seal + * + * `uSeal` runs 0 → 1 and does four things at once, which is why it reads as one + * event: it takes every `tilt` to zero, pulls every `a` onto `uRimRadius`, takes + * the eccentricity out of the ellipse, and slides each bead's phase onto an + * evenly spaced slot. The flock flattens into the ring plane and arrives as a + * rim. `uMerge` then stretches each bead along its own orbital tangent so the + * beads touch and the rim closes — a scale along a single axis, so the shading + * normal is the analytic ellipsoid normal rather than the sphere's, which is the + * difference between a chain of beads and a chain of beads with a lighting bug. + * + * The phase mix is deliberately taken against the **cast's own age** and not the + * global clock. Mixing an angular *rate* against `uTime` would jump the whole + * flock by tens of turns the instant the seal began; against an age that starts + * at zero the slew is bounded, and the bounded slew is exactly the "beads racing + * into their slots" the seal wants. + */ + +/** Hard ceiling on beads. The `beadCount` slider clamps here. */ +export const MAX_BEADS = 96; + +const BEAD_VERTEX = /* glsl */ ` + #define TAU 6.283185307179586 + /** The golden angle in radians — 2*PI*(1 - 1/phi). */ + #define GOLDEN 2.399963229728653 + + uniform float uAge; // seconds since the cast began — the orbital clock + uniform vec3 uCentre; // the pool's centre, world metres + uniform float uSeed; // per-cast dice roll + + /* ---- the orbits ---- */ + uniform float uCount; // live beads; the slot spacing is derived from it + uniform float uRadius; // mean semi-major axis, metres + uniform float uRadiusJitter; // +/- fraction of it, per bead + uniform float uEccentric; // 0 circle .. 0.9 a long ellipse + uniform float uTilt; // radians the mean plane is tipped off horizontal + uniform float uTiltSpread; // radians of per-bead variation on that + uniform float uNodeJitter; // 0..1 slop on the golden-angle node spacing + uniform float uSpin; // turns per second + uniform float uSpinJitter; // +/- fraction of it, per bead + + /* ---- the climb ---- */ + uniform float uClimbBase; // metres above the pool the lowest orbit sits + uniform float uClimbTop; // metres the highest orbit reaches + uniform float uClimb; // 0..1 the beat + uniform float uWobble; // metres of vertical breathing + uniform float uWobbleRate; // radians/second of it + + /* ---- the seal ---- */ + uniform float uSeal; // 0..1 flattens the orbits into the ring plane + uniform float uRimRadius; // metres the sealed ring stands at + uniform float uRimHeight; // metres above the floor it stands at + uniform float uMerge; // 0..1 how far the beads run together + uniform float uMergeStretch; // how many times longer a fully merged bead is + + /* ---- the body ---- */ + uniform float uBeadSize; // metres, radius of one bead + uniform float uSizeJitter; // +/- fraction of it, per bead + uniform float uReveal; // 0..1 the beads appear as this passes their dice + uniform float uRevealSpread; // width of that wave + + attribute float aSeed; + attribute float aIndex; + + varying vec3 vNormalW; + varying vec3 vViewW; + varying float vClimb; // 0..1 how far up the column this bead is + varying float vAlive; // 0..1 revealed + varying float vDice; // the bead's own dice roll, for the tint walk + + /** One cheap hash. Three decorrelated draws per bead is all this needs. */ + float beadHash(float n) { + return fract(sin(n * 127.1) * 43758.5453123); + } + + void main() { + float s0 = aSeed; + float s1 = beadHash(aIndex * 3.17 + uSeed); + float s2 = beadHash(aIndex * 7.31 + uSeed + 11.0); + float s3 = beadHash(aIndex * 11.71 + uSeed + 23.0); + + /* ---- has this bead climbed out of the pool yet ---- */ + float spread = max(uRevealSpread, 1e-3); + float appear = clamp((uReveal - s0 * (1.0 - spread)) / spread, 0.0, 1.0); + + /* ---- the plane ---- */ + // Golden angle, jittered. Evenly spaced nodes put two orbital planes on top + // of one another every few beads and the eye finds the pattern instantly. + float node = aIndex * GOLDEN + s1 * TAU * uNodeJitter + uSeed; + float tilt = (uTilt + (s2 - 0.5) * 2.0 * uTiltSpread) * (1.0 - uSeal); + + vec3 nAxis = vec3(cos(node), 0.0, sin(node)); + vec3 mAxis = vec3(sin(node), 0.0, -cos(node)); // cross(up, nAxis) + vec3 tAxis = mAxis * cos(tilt) + vec3(0.0, 1.0, 0.0) * sin(tilt); + + /* ---- the ellipse ---- */ + float a = mix(uRadius * (1.0 + (s3 - 0.5) * 2.0 * uRadiusJitter), uRimRadius, uSeal); + float ecc = clamp(uEccentric, 0.0, 0.95) * (1.0 - uSeal); + float b = a * sqrt(max(1.0 - ecc * ecc, 0.02)); + + /* ---- where on it ---- */ + // The differential rate is faded out by the seal, and because the clock is + // the cast's own age the resulting slew is bounded — see the header. + float rate = uSpin + uSpin * (s2 - 0.5) * 2.0 * uSpinJitter * (1.0 - uSeal); + float slot = aIndex / max(uCount, 1.0) * TAU; + float theta = uAge * rate * TAU + mix(s0 * TAU, slot, uSeal); + + /* ---- how high ---- */ + float lane = s1; + float y = mix(uClimbBase, uClimbBase + uClimbTop * lane, clamp(uClimb, 0.0, 1.0)); + y += uWobble * sin(uAge * uWobbleRate + s2 * TAU); + y = mix(y, uRimHeight, uSeal); + + vec3 centre = uCentre + vec3(0.0, y, 0.0); + float ct = cos(theta); + float st = sin(theta); + vec3 orbit = centre + nAxis * (a * ct) + tAxis * (b * st); + + /* ---- the body ---- */ + // Stretched along the orbital tangent as the rim closes. A single-axis + // scale, so the correct normal is the analytic ellipsoid one: divide the + // tangential component of the unit normal by the square of the scale. + vec3 tangent = normalize(nAxis * (-a * st) + tAxis * (b * ct) + vec3(1e-5)); + float stretch = 1.0 + uMerge * uSeal * uMergeStretch; + float size = uBeadSize * (1.0 + (s3 - 0.5) * 2.0 * uSizeJitter) * appear; + + float along = dot(position, tangent); + vec3 local = position + tangent * (along * (stretch - 1.0)); + vec3 world = orbit + local * size; + + vec3 nrm = normalize(normal - tangent * (dot(normal, tangent) * (1.0 - 1.0 / (stretch * stretch)))); + + vNormalW = nrm; + vViewW = cameraPosition - world; + vClimb = clamp((y - uClimbBase) / max(uClimbTop, 0.01), 0.0, 1.0); + vAlive = appear; + vDice = s0; + + gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0); + } +`; + +const BEAD_FRAGMENT = /* glsl */ ` + uniform vec3 uLightDir; + uniform float uAmbient; // 0..1 floor on the wrapped diffuse + uniform float uWrap; // 0..1 how far the terminator wraps round the back + uniform float uSpecular; + uniform float uGloss; // Blinn exponent + uniform float uFresnel; + uniform float uGlow; + uniform float uOpacity; + uniform float uFade; + uniform float uTintAlong; // how far up the column the gradient walks + uniform float uTintJitter; // per-bead walk on top of that + uniform vec3 uColorBody; + uniform vec3 uColorDeep; // the shadow side — almost black + uniform vec3 uColorRim; // the fresnel edge + uniform vec3 uColorSheen; // the highlight + uniform float uGlobalGlow; + + varying vec3 vNormalW; + varying vec3 vViewW; + varying float vClimb; + varying float vAlive; + varying float vDice; + + void main() { + vec3 N = normalize(vNormalW); + vec3 V = normalize(vViewW); + vec3 L = normalize(uLightDir); + + // Wrapped diffuse. A hard lambert on a 4 mm sphere gives a hemisphere of + // pure black, and forty of those read as holes punched in the mist. + float ndl = dot(N, L); + float lam = clamp((ndl + uWrap) / (1.0 + uWrap), 0.0, 1.0); + float lit = uAmbient + (1.0 - uAmbient) * lam; + + // The tint walk: higher beads are thinner, and every bead is a little + // different from its neighbour. Both are walks toward the deep colour, so + // no colour here is derived from another — I5. + float walk = clamp(vClimb * uTintAlong + (vDice - 0.5) * 2.0 * uTintJitter, 0.0, 1.0); + vec3 body = mix(uColorBody, uColorDeep, walk) * lit; + + vec3 H = normalize(L + V); + float spec = pow(max(dot(N, H), 0.0), max(uGloss, 1.0)) * uSpecular; + float fres = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 3.0) * uFresnel; + + vec3 color = body + uColorSheen * spec + uColorRim * fres; + color *= uGlow * uGlobalGlow; + + float alpha = uOpacity * uFade * vAlive; + if (alpha < 0.004) discard; + gl_FragColor = vec4(color, alpha); + } +`; + +/** + * One unit icosphere, instanced `capacity` times. + * + * The source geometry's arrays are **copied** rather than shared. Handing the + * icosahedron's own `BufferAttribute`s to the instanced geometry and then + * disposing the source frees the GPU buffers out from under the copy that is + * still being drawn, and the symptom is an ability that renders correctly until + * something else in the scene triggers a resource sweep. + * + * @param {number} capacity how many beads the buffer can ever hold + * @param {number} detail icosahedron subdivision; 1 is 80 triangles and is + * plenty for a bead a few centimetres across + */ +export function createBeadGeometry(capacity, detail = 1) { + const source = new IcosahedronGeometry(1, detail); + const geometry = new InstancedBufferGeometry(); + + geometry.setAttribute('position', new BufferAttribute(source.attributes.position.array.slice(), 3)); + geometry.setAttribute('normal', new BufferAttribute(source.attributes.normal.array.slice(), 3)); + if (source.index) geometry.setIndex(new BufferAttribute(source.index.array.slice(), 1)); + source.dispose(); + + const seeds = new Float32Array(capacity); + const indices = new Float32Array(capacity); + for (let i = 0; i < capacity; i++) { + seeds[i] = Math.random(); + indices[i] = i; + } + geometry.setAttribute('aSeed', new InstancedBufferAttribute(seeds, 1)); + geometry.setAttribute('aIndex', new InstancedBufferAttribute(indices, 1)); + geometry.instanceCount = capacity; + + // Placed entirely by the vertex shader; three's idea of its bounds is a + // one-metre ball at the origin and has nothing to do with where it draws. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + return geometry; +} + +/** + * The material. Reads `settings.sanguinepact` in `userData.sync()`, which is + * the same contract `LightningMaterial` uses: the ability hands over the state + * that only it knows (where the pool is, how far through the beat it is) and + * every dimension is re-read here, every frame. + */ +export function createBeadOrbitMaterial() { + const material = new ShaderMaterial({ + name: 'BeadOrbit', + // Solid bodies: they write depth so they occlude each other and the mist + // column correctly, which is the whole reason they are spheres. + transparent: true, + depthWrite: true, + depthTest: true, + blending: NormalBlending, + toneMapped: false, + uniforms: sharedUniforms({ + uAge: { value: 0 }, + uCentre: { value: new Vector3() }, + uSeed: { value: 0 }, + + uCount: { value: 48 }, + uRadius: { value: 3 }, + uRadiusJitter: { value: 0.2 }, + uEccentric: { value: 0.45 }, + uTilt: { value: 0.9 }, + uTiltSpread: { value: 0.6 }, + uNodeJitter: { value: 0.15 }, + uSpin: { value: 0.24 }, + uSpinJitter: { value: 0.3 }, + + uClimbBase: { value: 0.1 }, + uClimbTop: { value: 3.4 }, + uClimb: { value: 0 }, + uWobble: { value: 0.1 }, + uWobbleRate: { value: 1.6 }, + + uSeal: { value: 0 }, + uRimRadius: { value: 3.6 }, + uRimHeight: { value: 0.5 }, + uMerge: { value: 1 }, + uMergeStretch: { value: 2.4 }, + + uBeadSize: { value: 0.12 }, + uSizeJitter: { value: 0.35 }, + uReveal: { value: 1 }, + uRevealSpread: { value: 0.35 }, + + uAmbient: { value: 0.14 }, + uWrap: { value: 0.6 }, + uSpecular: { value: 1.6 }, + uGloss: { value: 42 }, + uFresnel: { value: 1.1 }, + uGlow: { value: 1 }, + uOpacity: { value: 1 }, + uFade: { value: 1 }, + uTintAlong: { value: 0.5 }, + uTintJitter: { value: 0.25 }, + uColorBody: { value: new Color('#c81a28') }, + uColorDeep: { value: new Color('#2a0207') }, + uColorRim: { value: new Color('#7a0a14') }, + uColorSheen: { value: new Color('#ff9aa0') } + }), + vertexShader: BEAD_VERTEX, + fragmentShader: BEAD_FRAGMENT + }); + + /** + * Push the live block and the cast's own state into the uniforms. + * + * @param {object} state `{ centre, age, seed, count, climb, seal, reveal, fade }` + * — the four numbers in the middle are unitless beats and the rest is a + * world point and a dice roll. Not one metre comes in through here. + */ + material.userData.sync = (state) => { + const c = settings.sanguinepact; + const g = settings.global; + const u = material.uniforms; + + u.uAge.value = state.age; + u.uCentre.value.copy(state.centre); + u.uSeed.value = state.seed; + u.uCount.value = state.count; + u.uClimb.value = state.climb; + u.uSeal.value = state.seal; + u.uReveal.value = state.reveal; + u.uFade.value = state.fade; + + // The footprint the aim indicator promised drives the orbit, the rim and + // nothing else scales itself independently — that is the sanctioned kind of + // sharing in I5, because the sharing *is* the design. + const zone = Math.max(0.05, c.zoneRadius); + u.uRadius.value = zone * c.orbitScale; + u.uRadiusJitter.value = c.orbitRadiusJitter * g.randomness; + u.uEccentric.value = c.orbitEccentric; + u.uTilt.value = c.orbitTilt; + u.uTiltSpread.value = c.orbitTiltSpread * g.randomness; + u.uNodeJitter.value = c.orbitNodeJitter * g.randomness; + u.uSpin.value = c.orbitSpin; + u.uSpinJitter.value = c.orbitSpinJitter * g.randomness; + + u.uClimbBase.value = c.beadClimbBase; + u.uClimbTop.value = c.beadClimbTop; + u.uWobble.value = c.beadWobble; + u.uWobbleRate.value = c.beadWobbleRate; + + u.uRimRadius.value = zone * c.rimScale; + u.uRimHeight.value = c.rimHeight; + u.uMerge.value = c.rimMerge; + u.uMergeStretch.value = c.rimMergeStretch; + + u.uBeadSize.value = c.beadSize; + u.uSizeJitter.value = c.beadSizeJitter * g.randomness; + u.uRevealSpread.value = c.beadRevealSpread; + + u.uAmbient.value = c.beadAmbient; + u.uWrap.value = c.beadWrap; + u.uSpecular.value = c.beadSpecular; + u.uGloss.value = c.beadGloss; + u.uFresnel.value = c.beadFresnel * g.fresnel; + u.uGlow.value = c.beadGlow * g.glow; + u.uOpacity.value = c.beadOpacity * g.opacity; + u.uTintAlong.value = c.beadTintAlong; + u.uTintJitter.value = c.beadTintJitter; + + u.uColorBody.value.copy(getColor(c.colorBeadBody)); + u.uColorDeep.value.copy(getColor(c.colorBeadDeep)); + u.uColorRim.value.copy(getColor(c.colorBeadRim)); + u.uColorSheen.value.copy(getColor(c.colorBeadSheen)); + }; + + return material; +} diff --git a/src/materials/BoneRibMaterial.js b/src/materials/BoneRibMaterial.js new file mode 100644 index 0000000..c9a3328 --- /dev/null +++ b/src/materials/BoneRibMaterial.js @@ -0,0 +1,447 @@ +import { BufferGeometry, Float32BufferAttribute, MeshStandardMaterial, Color, FrontSide } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { patchGrowthMaterial } from '../vfx/GrowthField.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; +import { hash11, saturate, lerp, smoothstep } from '../utils/math.js'; + +const TAU = Math.PI * 2; + +/* ---------------------------------------------------------------------- */ +/* The rib */ +/* ---------------------------------------------------------------------- */ + +/** + * Radius profile of one rib at `t` along it, as a fraction of the unit + * footprint. Everything here is a proportion, never a metre — the instance + * matrix supplies the scale, and it does so from live settings every frame. + * + * A bone is not a taper. It is a **bulb, a neck, a long blade and a knuckle**, + * and leaving any of the four out gives a horn. The neck is the one nobody + * expects and the one that does most of the work: the pinch just above the + * articular head is what says "this thing was jointed to something". + * + * @param {number} t 0 at the buried head, 1 at the tip + * @param {object} s the shape block + */ +function ribRadius(t, s) { + // The articular head: a Gaussian bulb that has died away by a tenth of the + // way up, so it reads as a knob on the end rather than as a fat base. + const bulb = Math.exp(-Math.pow(t / 0.1, 2)) * Math.max(0, s.headSwell - s.neck); + const body = s.neck + (s.shaft - s.neck) * smoothstep(0.06, 0.34, t); + // Beyond the shoulder the blade thins out. The 0.75 exponent keeps it a blade + // for most of its length and then loses it quickly, which is the difference + // between a rib and a spike. + const fall = Math.pow(1 - saturate((t - 0.34) / 0.66), 0.75); + const bladed = s.taper + (body - s.taper) * (t <= 0.34 ? 1 : fall); + // The sternal knuckle: the small swell before the end. + const knuckle = s.knuckle * Math.exp(-Math.pow((t - 0.8) / 0.07, 2)); + return Math.max(0.02, bladed + bulb + knuckle) * 0.5; +} + +/** + * One rib, in `GrowthField`'s unit space: footprint inside a circle of radius + * 0.5 on `y = 0`, tip at `y = 1`. + * + * ### Why the rib is straight + * + * A rib should curve, and this one does not, and that is a decision rather than + * an oversight. `GrowthField` orients an instance by tipping its local **+Y** + * toward a lean vector and then rolling it about its own axis by a random yaw. + * There is no local axis that maps to a consistent world direction under that: + * work the Rodrigues rotation through for a ZONE field and the radial component + * of the instance's local +X comes out as `cos θ · cos φ`, so a bend baked into + * the geometry points inward at one bearing on the ring and sideways ninety + * degrees round from it. The first version had a proper sickle in it and the + * cage curved inward on two sides and slewed off tangentially on the other two, + * which looks like a bug and is one. + * + * The curvature therefore lives entirely in the **lean**, which `GrowthField` + * scales by each record's radial fraction — so the ribs at the rim lay in hard + * and the ones near the middle stand nearly upright, and the field is a dome + * rather than a fence. What the geometry keeps is everything that survives an + * arbitrary roll: the profile, the flattened blade section, the twist along its + * own axis and the groove down one face. + * + * @param {number} variant 0..n — decorrelates the three silhouettes + * @param {object} shape the live shape block, hashed by `GrowthField` + */ +export function createBoneRibGeometry(variant = 0, shape = {}) { + const s = { + sides: Math.max(4, Math.round(shape.ribSides ?? 7)), + rings: Math.max(5, Math.round(shape.ribRings ?? 12)), + flatten: saturate(shape.ribFlatten ?? 0.44), + twist: shape.ribTwist ?? 0.18, + groove: saturate(shape.ribGroove ?? 0.3), + headSwell: shape.ribHead ?? 1.05, + neck: shape.ribNeck ?? 0.52, + shaft: shape.ribShaft ?? 0.7, + taper: shape.ribTaper ?? 0.1, + knuckle: shape.ribKnuckle ?? 0.2, + warp: shape.ribWarp ?? 0.28 + }; + + // One deterministic seed per variant: the same variant always builds the same + // rib, so a live rebuild reshapes the cage without reshuffling it. + const seed = 3.17 + variant * 11.9; + const phase = hash11(seed * 1.7) * TAU; + // Each variant is a slightly different bone. Real ribs are not three of one. + const lengthBias = 1 + (hash11(seed * 2.3) - 0.5) * 0.22; + const flatten = saturate(s.flatten * (1 + (hash11(seed * 3.9) - 0.5) * 0.4)); + + const positions = []; + const indices = []; + + for (let ring = 0; ring < s.rings; ring++) { + const u = ring / (s.rings - 1); + // Rings crowd toward both ends, because that is where the shape is: the + // head and the knuckle need resolution and the middle of the blade does + // not. Evenly spaced rings spend half the mesh on a straight bit. + const t = Math.min(0.985, u - Math.sin(u * TAU) * 0.09); + const roll = phase + s.twist * t * TAU; + const radius = ribRadius(t, s) * lerp(1, lengthBias, t); + + for (let i = 0; i < s.sides; i++) { + const a = (i / s.sides) * TAU + roll; + const ca = Math.cos(a); + const sa = Math.sin(a); + + // The costal groove: a scoop down one face, present only along the blade. + // Cubed so it is a channel with a lip rather than a dent across the whole + // half-section. + const cut = + s.groove * + Math.pow(Math.max(0, -sa), 3) * + smoothstep(0.22, 0.4, t) * + smoothstep(0.95, 0.78, t); + + // Deterministic surface irregularity. Bone that is perfectly smooth reads + // as ceramic; this is the only reason the silhouette is not a lathe. + const wobble = + 1 + (hash11(seed * 7.3 + ring * 13.1 + i * 3.7) - 0.5) * s.warp * (0.3 + 0.7 * t); + + const rx = radius * wobble * (1 - cut); + const rz = radius * wobble * (1 - flatten) * (1 - cut); + positions.push(ca * rx, t, sa * rz); + } + } + + for (let ring = 0; ring < s.rings - 1; ring++) { + for (let i = 0; i < s.sides; i++) { + const j = (i + 1) % s.sides; + // Named `v0..v3` and not `a..d`: the harness's static settings pass drops + // any alias that is ever bound to a non-settings right-hand side anywhere + // in the file, and a bare `const c = …` here quietly blinded it to the + // twenty-three keys `sync()` reads through `const c = settings.bonecage`. + const v0 = ring * s.sides + i; + const v1 = ring * s.sides + j; + const v2 = (ring + 1) * s.sides + i; + const v3 = (ring + 1) * s.sides + j; + indices.push(v0, v1, v2, v1, v3, v2); + } + } + + /* --- the tip, and the underside --- */ + const apex = positions.length / 3; + positions.push(0, 1, 0); + const lastRing = (s.rings - 1) * s.sides; + for (let i = 0; i < s.sides; i++) { + const j = (i + 1) % s.sides; + indices.push(lastRing + i, lastRing + j, apex); + } + + const floorCentre = positions.length / 3; + positions.push(0, 0, 0); + for (let i = 0; i < s.sides; i++) { + const j = (i + 1) % s.sides; + indices.push(floorCentre, j, i); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.setIndex(indices); + // Indexed, so this averages across the seams and the blade shades smooth. + // Bone is not faceted, and a faceted rib reads as carved wood. + geometry.computeVertexNormals(); + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* The material */ +/* ---------------------------------------------------------------------- */ + +/** + * Dry bone. **The single most different material in the blood school.** + * + * Crimson Tide is standing two metres away being viscous, wet, red and + * self-lit. This is the argument that the school is not a palette: same school, + * and every one of those four properties is inverted on purpose. + * + * | Crimson Tide | this | + * | --- | --- | + * | specular sheen keyed off the surface normal | no sheen term at all | + * | roughness pulled *down* in the wet grooves | roughness pinned near 0.95 everywhere | + * | emissive sap glowing in the dark | emissive that is **capped below the bloom threshold** | + * | a fresnel rim that brightens the silhouette | a fresnel-shaped term on the *albedo* | + * + * ### The three things that make it chalk rather than plastic + * + * **1 · The anti-fresnel.** A dielectric gets shinier at grazing incidence. + * Chalk gets *lighter*: the surface is porous, and light that would have + * refracted into a smooth dielectric is instead scattered back out of the pores + * a fraction of a millimetre away. So `uChalk` applies a fresnel-shaped curve + * to `diffuseColor` and nothing at all to the specular lobe. It is the opposite + * of what a fresnel term normally does and it is why this reads as matte + * mineral instead of matte paint. Take it to zero and the ribs turn into grey + * rubber under the same lights. + * + * **2 · Warm subsurface scatter.** The cheap back-scatter transmission term — + * `pow(dot(V, -normalize(L + N · distort)), power)` — modulated by a thickness + * field that is genuinely thin where the bone is thin: near the tip, near the + * silhouette, and wherever the trabecular noise says the interior is open. The + * colour is warm (`colorMarrow`, a red-orange) because that is what survives a + * few millimetres of calcium — and it is the one warm thing in an otherwise + * bleached object, which is what stops the cage reading as plaster. + * + * **3 · It is capped.** `uSssCeiling` clamps the scatter under the bloom + * threshold before it reaches `totalEmissiveRadiance`. Bone is lit; bone does + * not glow. The brief for this slot is a material that has no glow in it, and + * that is enforced here rather than trusted to the tuning staying sensible. + * + * ### The dirty birth + * + * Every other growth material in the project flashes **bright** on breach — + * `vGrowBirth` drives a hot rim on ice, on thorns, on stone. Bone cannot: a + * flash is a glow. So the flash is inverted and a rib breaks the surface + * *filthy*, caked in the floor it just came through, and cleans off over + * `birthFade`. Same attribute, same clock, opposite sign, and it is a better + * read anyway — something that has come up through a flagstone should have the + * flagstone on it. + * + * Uniform boxes are parked on `material.userData.uniforms` — I8, and the thing + * the harness's pause probe looks for. + */ +export function createBoneMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.94, + metalness: 0.0, + // Smooth. See the note on `computeVertexNormals` above: a faceted rib is + // carved wood, and the whole slot is about the material being right. + flatShading: false, + side: FrontSide, + transparent: true, + depthWrite: true + }); + + const uniforms = { + uLightDir: frame.uLightDir, + uColorBone: { value: new Color() }, + uColorShade: { value: new Color() }, + uColorMarrow: { value: new Color() }, + uColorStain: { value: new Color() }, + uColorGrime: { value: new Color() }, + + uGrain: { value: 0.35 }, + uGrainScale: { value: 6.5 }, + uGrainBands: { value: 2.2 }, + uPit: { value: 0.45 }, + uPitScale: { value: 24.0 }, + uPitCut: { value: 0.55 }, + + uChalk: { value: 0.55 }, + uChalkPower: { value: 2.6 }, + uRough: { value: 0.94 }, + uRoughPit: { value: 0.4 }, + + uSss: { value: 0.9 }, + uSssPower: { value: 3.2 }, + uSssDistort: { value: 0.35 }, + uSssAmbient: { value: 0.18 }, + uSssCeiling: { value: 0.5 }, + uThinBase: { value: 0.15 }, + uThinEdge: { value: 0.65 }, + + uStain: { value: 0.7 }, + uStainHeight: { value: 0.26 }, + uGrime: { value: 0.85 }, + uWither: { value: 0 } + }; + + /** + * The growth plumbing comes from `patchGrowthMaterial`, which owns the two + * per-instance attributes and the four varyings, routes through + * `registerShadowCasterWithPatch` so CSM's own patch survives, and injects + * the disruption block. Everything below lands in its one fragment slot. + * + * `roughnessFactor` is written from that slot rather than from + * ``, which is where it would normally go: the growth + * patch only offers the one injection point, and it does not matter, because + * `` — the line that actually consumes + * `roughnessFactor` — comes *after* `` in the standard + * fragment shader. Writing it here is one statement out of order and zero + * statements wrong. + */ + patchGrowthMaterial(material, { + environment, + uniforms, + common: /* glsl */ ` + uniform vec3 uLightDir; + uniform vec3 uColorBone; + uniform vec3 uColorShade; + uniform vec3 uColorMarrow; + uniform vec3 uColorStain; + uniform vec3 uColorGrime; + uniform float uGrain; + uniform float uGrainScale; + uniform float uGrainBands; + uniform float uPit; + uniform float uPitScale; + uniform float uPitCut; + uniform float uChalk; + uniform float uChalkPower; + uniform float uRough; + uniform float uRoughPit; + uniform float uSss; + uniform float uSssPower; + uniform float uSssDistort; + uniform float uSssAmbient; + uniform float uSssCeiling; + uniform float uThinBase; + uniform float uThinEdge; + uniform float uStain; + uniform float uStainHeight; + uniform float uGrime; + uniform float uWither; + ${noiseGLSL} + + /** + * Trabecular porosity: the open cells inside the bone, read at the + * surface as pitting. Sampled in WORLD space on purpose, so two ribs + * standing next to each other look like they came out of one animal + * rather than out of one lathe. 0 on solid cortex, 1 in a pit. + */ + float bonePit(vec3 worldPos) { + float cells = snoise01(worldPos * uPitScale); + return smoothstep(uPitCut, 1.0, cells); + }`, + fragment: /* glsl */ ` + float up = clamp(vGrowLocal.y, 0.0, 1.0); + float pit = bonePit(vGrowWorld); + + // Haversian grain: runs UP the bone, so it is sampled in local space + // with the along-frequency well above the around-frequency. Sampling + // it in world space gives a rock, which is the same mistake + // VineBarkMaterial documents and the same fix. + float grain = fbm3(vec3(vGrowLocal.xz * uGrainBands, up * uGrainScale)) * 0.5 + 0.5; + + vec3 body = uColorBone; + body = mix(body, uColorShade, grain * uGrain); + body = mix(body, uColorShade, pit * uPit); + // Earth staining where it came out of the floor, and never above + // uStainHeight — a bone stained end to end reads as painted. + body = mix(body, uColorStain, smoothstep(uStainHeight, 0.0, up) * uStain); + + /* --- the anti-fresnel: chalk gets LIGHTER at grazing incidence --- */ + vec3 V = normalize(vViewPosition); + float facing = abs(dot(normalize(normal), V)); + float grazing = pow(1.0 - facing, max(uChalkPower, 0.1)); + body *= 1.0 + uChalk * grazing; + + /* --- the dirty birth: bright is a glow, so this one goes dark --- */ + float fresh = clamp(vGrowBirth, 0.0, 1.0); + body = mix(body, uColorGrime, fresh * clamp(uGrime, 0.0, 1.0)); + + diffuseColor.rgb *= body; + + /* --- warm subsurface scatter, and its hard ceiling --- */ + // The key direction has to come into view space to sit alongside + // normal and vViewPosition; doing it here rather than carrying a + // varying costs one mat3 multiply on a material that is already + // paying for a standard BRDF. + vec3 Lv = normalize((viewMatrix * vec4(uLightDir, 0.0)).xyz); + vec3 leak = normalize(Lv + normalize(normal) * uSssDistort); + float back = pow(clamp(dot(V, -leak), 0.0, 1.0), max(uSssPower, 0.1)); + + // Thickness: thin at the tip, thin on the silhouette, thin wherever + // the trabecular field says the interior is open. + float thin = mix(uThinBase, 1.0, up * up); + thin = mix(thin, 1.0, (1.0 - facing) * uThinEdge); + thin = clamp(thin * (0.75 + 0.5 * pit), 0.0, 1.0); + + vec3 scatter = uColorMarrow * ((back + uSssAmbient) * thin * uSss); + // Bone is lit; bone does not glow. This clamp is the anti-glow + // contract for the slot and it is deliberately below the bloom + // threshold rather than near it. + scatter = min(scatter, vec3(max(uSssCeiling, 0.0))); + scatter *= 1.0 - clamp(uWither, 0.0, 1.0); + + totalEmissiveRadiance += scatter; + + /* --- chalky micro-roughness, written one statement early --- */ + // See the note above patchGrowthMaterial: + // reads roughnessFactor and it comes after this injection point. + // The pits have to be rougher than the cortex around them or the + // whole surface reads as one uniform matte, which is plaster. + roughnessFactor = clamp(uRough + pit * uRoughPit, 0.04, 1.0); + ` + }); + + material.userData.uniforms = Object.assign(material.userData.uniforms ?? {}, uniforms); + + /** Pull the palette and every shading control off the live settings. */ + material.userData.sync = () => { + const c = settings.bonecage; + const g = settings.global; + + uniforms.uColorBone.value.copy(getColor(c.colorBone)); + uniforms.uColorShade.value.copy(getColor(c.colorBoneShade)); + uniforms.uColorMarrow.value.copy(getColor(c.colorMarrow)); + uniforms.uColorStain.value.copy(getColor(c.colorStain)); + uniforms.uColorGrime.value.copy(getColor(c.colorGrime)); + + uniforms.uGrain.value = c.boneGrain * g.shaderIntensity; + uniforms.uGrainScale.value = c.boneGrainScale * g.noiseFrequency; + uniforms.uGrainBands.value = c.boneGrainBands * g.noiseFrequency; + uniforms.uPit.value = c.bonePit * g.shaderIntensity; + uniforms.uPitScale.value = c.bonePitScale * g.noiseFrequency; + uniforms.uPitCut.value = c.bonePitCut; + + uniforms.uChalk.value = c.boneChalk * g.shaderIntensity; + uniforms.uChalkPower.value = c.boneChalkPower; + uniforms.uRough.value = c.boneRoughness; + uniforms.uRoughPit.value = c.boneRoughnessPit; + + // `g.glow` is allowed to scale the scatter, and the ceiling is applied on + // top of it in the shader — turning the global glow up cannot make bone + // bloom, which is the point of having the ceiling be a uniform. + uniforms.uSss.value = c.sssStrength * g.glow; + uniforms.uSssPower.value = c.sssPower; + uniforms.uSssDistort.value = c.sssDistort; + uniforms.uSssAmbient.value = c.sssAmbient; + uniforms.uSssCeiling.value = c.sssCeiling; + uniforms.uThinBase.value = c.sssThinBase; + uniforms.uThinEdge.value = c.sssThinEdge; + + uniforms.uStain.value = c.stainAmount; + uniforms.uStainHeight.value = c.stainHeight; + uniforms.uGrime.value = c.grimeAmount; + + material.roughness = c.boneRoughness; + material.envMapIntensity = c.boneEnv; + }; + + /** + * How far through the cast's death the bone is, 0..1. + * + * Separate from `sync()` because it is a beat rather than a setting: the + * ability owns the clock, the block owns the numbers. + */ + material.userData.setWither = (wither) => { + uniforms.uWither.value = saturate(wither); + }; + + material.userData.sync(); + return material; +} diff --git a/src/materials/BrineIceMaterial.js b/src/materials/BrineIceMaterial.js new file mode 100644 index 0000000..3002194 --- /dev/null +++ b/src/materials/BrineIceMaterial.js @@ -0,0 +1,234 @@ +import { MeshStandardMaterial, Color, DoubleSide } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { patchGrowthMaterial } from '../vfx/GrowthField.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * Brine ice — the frozen splash of `BrinelockAbility`. + * + * This is the *third* ice material in the project and it exists because the + * other two are both wrong for a splash that has stopped. + * `materials/IceMaterial.js` shades a crystal: a grown solid, opaque toward its + * axis, with facets. `materials/SheetIceMaterial.js` shades a plate: thin, + * clearest face-on, deepest at a grazing angle. A frozen splash finger is + * neither. It is a **thin column of water caught mid-flight**, which means: + * + * - **it is clearest at the tip and cloudy at the foot.** Water that is still + * attached to the pool when it freezes is full of entrained air and traps a + * skin of foam; the bead that had already torn free is glass. That gradient + * runs base-to-tip, so it is keyed off `vGrowLocal.y` — the one thing that + * genuinely belongs in local space, because it must follow each finger's own + * axis however the instance is scaled and leaned. + * - **the light goes *down* it.** A narrow clear column piped end-on is bright + * along its own axis and dull across it, which is the opposite of the plate + * and the same sign as the crystal — but the falloff is much sharper, + * because the column is two centimetres wide and half a metre long. + * - **it fractured while it froze.** Water expands about nine per cent when it + * goes to ice, and a finger frozen from the outside in splits along its + * length. The seams are the zero crossing of a signed fbm evaluated in + * **world** space and squashed along +Y, so a whole crown of fingers looks + * cracked out of one body of brine rather than each carrying its own + * private pattern. A first version sampled the seam field in local space and + * every finger in the lane wore the identical stripe down its front — the + * unmistakable tell of an instanced field, and visible from across the + * stage. + * + * **The freeze flash is `aBirth` read forwards, not backwards.** `GrowthField` + * publishes one per-instance stopwatch, a birth term running 1 → 0 over + * `birthFade` seconds from the moment that instance was triggered. + * `SheetIceMaterial` reads it backwards, as "how long since this plate locked", + * because a plate spends a second curling. This ability wants the opposite: the + * glaze front sweeps the lane in a couple of tenths and each finger needs a + * hard white crack of light **as it locks**, then nothing. So it is used + * directly, raised to `uFlashPower`, and the sweeping front is drawn by the + * stagger rather than by anything in here. + * + * ## The one number that is not resolved per frame + * + * None. Every metre, radian and second is a uniform rewritten from + * `settings.brinelock` by `userData.sync()` on every frame, zero-length ones + * included — and the uniforms are parked on `material.userData.uniforms`, + * because that is the second place the harness's pause probe looks and the only + * place a patched `MeshStandardMaterial` has to put them (**I8**). + */ +export function createBrineIceMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.08, + metalness: 0.0, + // Flat shading, as the crystals use: the finger is a low-facet lathe and + // the facets are the read. A smoothed normal turns it into a wax candle. + flatShading: true, + transparent: true, + // A frozen splash is hollow-looking from every angle and you see the far + // wall of every finger through the near one. + side: DoubleSide, + depthWrite: true + }); + + const uniforms = { + uTime: frame.uTime, + + /* --- the body --- */ + uColorIce: { value: new Color() }, // the clear glass + uColorAerated: { value: new Color() }, //the milky foot + uColorSeam: { value: new Color() }, // the expansion cracks + uColorFlash: { value: new Color() }, // the crack of light as it locks + uAerate: { value: 0.75 }, // 0..1 how milky the foot gets + uAeratePower: { value: 2.1 }, // how fast that clears going up + uDepthTint: { value: 0.9 }, // how much the body deepens across the axis + uPipe: { value: 1.6 }, // brightness looking down the finger's axis + uPipePower: { value: 3.4 }, // how sharply that falls off + uTranslucency: { value: 0.85 }, // how much light comes through it + + /* --- the fracture --- */ + uSeamScale: { value: 3.2 }, // cycles per metre across the crack field + uSeamStretch: { value: 3.6 }, // how many times longer a seam is vertically + uSeamWidth: { value: 0.16 }, // 0..1 of the field — the seam's width + uSeamGlow: { value: 1.5 }, + + /* --- the surface --- */ + uFresnel: { value: 1.5 }, + uFresnelPower: { value: 2.6 }, + uGlint: { value: 1.1 }, + uGlintScale: { value: 22 }, // cycles per metre of the pinpoints + uGlintSpeed: { value: 0.35 }, // metres/second they crawl at + uGlow: { value: 1.2 }, + uFlashGlow: { value: 3.4 }, + uFlashPower: { value: 2.2 } + }; + + patchGrowthMaterial(material, { + environment, + uniforms, + common: /* glsl */ ` + uniform float uTime; + uniform vec3 uColorIce; + uniform vec3 uColorAerated; + uniform vec3 uColorSeam; + uniform vec3 uColorFlash; + uniform float uAerate; + uniform float uAeratePower; + uniform float uDepthTint; + uniform float uPipe; + uniform float uPipePower; + uniform float uTranslucency; + uniform float uSeamScale; + uniform float uSeamStretch; + uniform float uSeamWidth; + uniform float uSeamGlow; + uniform float uFresnel; + uniform float uFresnelPower; + uniform float uGlint; + uniform float uGlintScale; + uniform float uGlintSpeed; + uniform float uGlow; + uniform float uFlashGlow; + uniform float uFlashPower; + ${noiseGLSL} + `, + fragment: /* glsl */ ` + vec3 N = normalize(normal); + vec3 V = normalize(vViewPosition); + float ndv = clamp(dot(N, V), 0.0, 1.0); + + // Down the barrel. A clear column two centimetres across is bright where + // the view ray runs along it and almost invisible across it, and the + // exponent is high because the column is thin: at uPipePower near 1 the + // whole finger lights up and it reads as a plastic tube. + float pipe = pow(ndv, uPipePower) * uPipe; + float fres = pow(1.0 - ndv, uFresnelPower) * uFresnel; + + // Base to tip. Entrained air at the foot, glass at the bead. + float clear = pow(clamp(vGrowLocal.y, 0.0, 1.0), uAeratePower); + float milk = (1.0 - clear) * uAerate; + + // The expansion seams, in WORLD space and squashed vertically, so the + // whole lane looks cracked out of one body of brine. Local space put the + // same stripe down the front of every finger — see the module header. + vec3 q = vec3(vGrowWorld.x * uSeamScale, + vGrowWorld.y * uSeamScale / max(uSeamStretch, 0.05), + vGrowWorld.z * uSeamScale); + float field = fbm3(q + vGrowSeed * 0.31); + float seam = 1.0 - smoothstep(0.0, max(uSeamWidth, 1e-3), abs(field)); + // Seams that reach the milky foot are already opaque there, so they only + // read in the clear part of the body. + seam *= clear; + + // Pinpoints, crawling slowly in world space so a standing crown twinkles + // without anything about it actually moving. + float glint = snoise(vGrowWorld * uGlintScale + + vec3(0.0, uTime * uGlintSpeed, 0.0) + vGrowSeed * 13.0); + glint = pow(clamp(glint, 0.0, 1.0), 12.0); + + vec3 body = mix(uColorIce, uColorAerated, clamp(milk, 0.0, 1.0)); + body = mix(body, uColorSeam, clamp(seam * 0.55, 0.0, 1.0)); + + vec3 glow = uColorIce * pipe * uTranslucency; + glow += uColorSeam * seam * uSeamGlow; + glow += uColorIce * fres * 0.4; + glow += uColorSeam * glint * uGlint; + // The lock. aBirth is 1 on the frame this finger was triggered and gone a + // fraction of a second later, so this is the crack of light running down + // the lane with the glaze front. + glow += uColorFlash * pow(clamp(vGrowBirth, 0.0, 1.0), uFlashPower) * uFlashGlow; + glow *= uGlow; + + // The same Reinhard ceiling the other two ice materials carry. Every term + // above peaks somewhere on the silhouette and without it a crown of forty + // fingers sums past white and the bloom pass smears the whole lane. + glow /= 1.0 + glow * 0.22; + + diffuseColor.rgb *= body; + totalEmissiveRadiance += glow; + + // Clear glass hides nothing face-on; the milky foot and the seams are the + // only parts with any opacity of their own. + float solid = clamp(milk + seam * 0.5 + pow(1.0 - ndv, 1.6) * uDepthTint, 0.0, 1.0); + diffuseColor.a = clamp(diffuseColor.a * (0.22 + 0.78 * solid), 0.0, 1.0); + ` + }); + + material.userData.uniforms = uniforms; + + /** Pull the palette and every shading control from the live settings. */ + material.userData.sync = () => { + const c = settings.brinelock; + const g = settings.global; + + uniforms.uColorIce.value.copy(getColor(c.colorIce)); + uniforms.uColorAerated.value.copy(getColor(c.colorAerated)); + uniforms.uColorSeam.value.copy(getColor(c.colorSeam)); + uniforms.uColorFlash.value.copy(getColor(c.colorIceFlash)); + + uniforms.uAerate.value = c.aerate; + uniforms.uAeratePower.value = Math.max(0.05, c.aeratePower); + uniforms.uDepthTint.value = c.iceDepthTint; + uniforms.uPipe.value = c.icePipe; + uniforms.uPipePower.value = Math.max(0.2, c.icePipePower); + uniforms.uTranslucency.value = c.iceTranslucency; + + uniforms.uSeamScale.value = c.seamScale * g.noiseFrequency; + uniforms.uSeamStretch.value = c.seamStretch; + uniforms.uSeamWidth.value = c.seamWidth; + uniforms.uSeamGlow.value = c.seamGlow * g.shaderIntensity; + + uniforms.uFresnel.value = c.iceFresnel * g.fresnel; + uniforms.uFresnelPower.value = c.iceFresnelPower; + uniforms.uGlint.value = c.iceGlint * g.shaderIntensity; + uniforms.uGlintScale.value = c.iceGlintScale; + uniforms.uGlintSpeed.value = c.iceGlintSpeed * g.noiseSpeed; + uniforms.uGlow.value = c.iceGlow * g.glow; + uniforms.uFlashGlow.value = c.lockGlow; + uniforms.uFlashPower.value = Math.max(0.5, c.lockGlowPower); + + material.opacity = c.iceOpacity * g.opacity; + material.roughness = c.iceRoughness; + material.envMapIntensity = c.iceEnvIntensity; + }; + + material.userData.sync(); + return material; +} diff --git a/src/materials/BroodEggMaterial.js b/src/materials/BroodEggMaterial.js new file mode 100644 index 0000000..c03e4ad --- /dev/null +++ b/src/materials/BroodEggMaterial.js @@ -0,0 +1,377 @@ +import { MeshStandardMaterial, Color, DoubleSide } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * A brood egg: a two-piece chitin shell that inflates, thins, and splits. + * + * This runs on a `GrowthField` whose geometry factory hands it an ovoid built + * as **two separate half-shells** that share a torn seam line — see + * `abilities/hive/BroodburstAbility.js#createEggGeometry`. Everything below is + * one pose function over that buffer, evaluated per vertex, driven entirely by + * per-instance values. + * + * ### The clock is `aBirth`, and there is no other one + * + * `GrowthField` writes `aBirth` per instance, counting 1 → 0 over its live + * `birthFade` seconds. Broodburst hands `birthFade` the sum of its rise, its + * swell and its split, so `1 - aBirth` is a **per-egg ramp across the whole + * hatch**, re-resolved by the field on every `update()` including a zero-length + * one. Every beat here is a threshold on that one number: `uSwellAt` is where + * inflation begins, `uSplitAt` where the seam gives, `uHuskAt` where what is + * left of the shell stops being there. + * + * There is deliberately **no `uTime`**. A clutch of forty eggs must not throb in + * unison — that is the single thing that would give away that they are + * instances of one mesh — so the throb's phase comes from `aSeed` and its clock + * from the egg's own ramp. The first version drove the throb off `frame.uTime` + * and the whole clutch pulsed like one lung. + * + * ### The split is a rigid rotation of each half, not a displacement + * + * Each vertex carries `aShell` = ±1 saying which half-shell it belongs to. The + * split rotates that half about the **z axis at the base** — the line where the + * seam plane meets the floor — so the two halves swing apart like doors and + * nothing about the shell stretches. Pushing vertices apart along the seam + * normal instead was the first attempt, and it thins the shell into a pair of + * flat flaps: the rotation keeps the curvature, which is what makes the empty + * husk still read as having held something. + * + * Because the halves are separate blocks in the buffer that only *coincide* on + * the seam, the closed egg has no visible join at all and the open one has a + * real torn edge with a lip on it. `aShell.w` — the rim weight — is 1 exactly on + * that edge and 0 a couple of rings in, which is what the fragment stage + * thickens and darkens. + * + * ### `flatShading`, on purpose + * + * The pose is written in the vertex shader, so a normal transformed from the + * undeformed attribute would be wrong everywhere the shell has swollen or + * hinged. `flatShading` derives the normal from screen-space derivatives of the + * *final* position, which is correct by construction — the same argument + * `PetalMaterial` makes. The faceting it costs is a gain here rather than a + * loss: a smooth egg reads as a snooker ball, and chitin is plated. + * + * ### The surface is authored in shell coordinates + * + * `aShell.yz` is `(cos θ, sin θ)` of the vertex's bearing round the egg, not θ + * itself, for the reason `VineBarkMaterial` gives at length: a raw angle wraps + * from 1 back to 0 across one quad and every egg gets a bright seam down a side + * that has nothing to do with the real seam. The pair interpolates without a + * discontinuity and the veins hold still while the shell inflates, because they + * are indexed by a material coordinate rather than by a position. + * + * Uniform boxes are parked on `material.userData.uniforms` — the convention the + * harness's pause test looks for. Without it forty working sliders report dead. + */ +export function createBroodEggMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.48, + metalness: 0.0, + flatShading: true, + transparent: true, + // Both sides. Once the seam gives you are looking straight into the shell, + // and the inside of the far half is most of what the gape is *for*. + side: DoubleSide, + depthWrite: true + }); + + const uniforms = { + uLightDir: frame.uLightDir, + + /* --- the swell --- */ + uSwellAt: { value: 0.08 }, // 0..1 of the ramp where inflation starts + uSplitAt: { value: 0.62 }, // 0..1 of the ramp where the seam gives + uSplitSpan: { value: 0.22 }, // 0..1 of the ramp the seam takes to open + uHuskAt: { value: 0.9 }, // 0..1 of the ramp where the husk stops being there + uSwell: { value: 0.42 }, // fraction the shell inflates by + uSwellCurve: { value: 1.8 }, // >1 holds it small then goes late + uBulge: { value: 0.3 }, // extra width at the egg's waist + uStretch: { value: 0.24 }, // extra height, as a fraction of the swell + uThrob: { value: 0.055 }, // fraction of a per-egg pulse on top + uThrobRate: { value: 7.0 }, // pulses across the whole ramp + + /* --- the split --- */ + uGape: { value: 1.25 }, // radians each half swings through + uGapeSlide: { value: 0.12 }, // unit-space metres the halves also slide apart + uGapeLift: { value: 0.06 }, // unit-space metres the husk lifts as it opens + uGapeCurve: { value: 0.6 }, // <1 snaps open then eases + + /* --- the shell --- */ + uVeins: { value: 0.85 }, // strength of the vein network + uVeinScale: { value: 5.5 }, // features around the shell + uVeinBands: { value: 2.6 }, // features up the shell + uVeinSharp: { value: 0.62 }, // 0 soft mottle, 1 hard filaments + uMottle: { value: 0.35 }, // broad blotching under the veins + uMottleScale: { value: 2.2 }, + uRimWidth: { value: 0.55 }, // how far in from the torn edge the lip reaches + uRimDark: { value: 0.7 }, // how much darker that lip is + uRimGlow: { value: 1.6 }, // and how hot it is once the seam has given + uFresnel: { value: 1.2 }, + uFresnelPower: { value: 2.4 }, + uThin: { value: 2.2 }, // how much light the stretched shell passes + uThinPower: { value: 3.0 }, + uBroodGlow: { value: 2.6 }, // the thing inside, showing through + uBroodRise: { value: 1.7 }, // >1 keeps it hidden until the shell is tight + uInteriorGlow: { value: 0.9 }, // the wet inside of an opened husk + uGlow: { value: 1.0 }, // master multiplier + uHuskFade: { value: 1.0 }, // 0..1 master alpha, driven by the fade phase + + /* --- colour, none derived from another --- */ + uColorShell: { value: new Color() }, + uColorBelly: { value: new Color() }, + uColorVein: { value: new Color() }, + uColorBrood: { value: new Color() }, + uColorRim: { value: new Color() }, + uColorInterior: { value: new Color() } + }; + + environment.registerShadowCasterWithPatch(material, (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + `#include + attribute vec4 aShell; // (v 0..1 up the egg, cos bearing, sin bearing, rim weight) + attribute float aSide; // +1 / -1 — which half-shell this vertex belongs to + attribute float aSeed; // per instance, 0..10 + attribute float aBirth; // per instance, 1 -> 0 across the whole hatch + uniform float uSwellAt; + uniform float uSplitAt; + uniform float uSplitSpan; + uniform float uHuskAt; + uniform float uSwell; + uniform float uSwellCurve; + uniform float uBulge; + uniform float uStretch; + uniform float uThrob; + uniform float uThrobRate; + uniform float uGape; + uniform float uGapeSlide; + uniform float uGapeLift; + uniform float uGapeCurve; + varying vec4 vShell; + varying float vHatch; // 0..1 across the whole ramp + varying float vSwellT; // 0..1 how far the inflation has got + varying float vSplit; // 0..1 how far the seam has opened + varying float vEggSeed;` + ) + .replace( + '#include ', + `#include + { + float hatch = clamp(1.0 - aBirth, 0.0, 1.0); + float dice = fract(aSeed * 0.6180339887 + 0.137); + + float swellT = clamp((hatch - uSwellAt) / max(uSplitAt - uSwellAt, 0.001), 0.0, 1.0); + float split = pow(clamp((hatch - uSplitAt) / max(uSplitSpan, 0.001), 0.0, 1.0), + max(uGapeCurve, 0.05)); + + // The inflation. Width and height are separate multipliers because an + // egg under pressure goes fat before it goes long — driving both off + // one number gave a balloon rather than a sac. + float grow = pow(swellT, max(uSwellCurve, 0.05)); + float pulse = uThrob * sin((grow * uThrobRate + dice) * PI2) * swellT; + float waist = 1.0 - abs(aShell.x * 2.0 - 1.0); + float wide = 1.0 + (uSwell + pulse) * grow * (1.0 + uBulge * waist); + float tall = 1.0 + (uSwell * uStretch + pulse * 0.4) * grow; + + transformed.xz *= wide; + transformed.y *= tall; + + // The seam gives. A rigid rotation of this half about the z axis at + // the base — the line where the seam plane meets the floor — so the + // shell keeps every bit of its curvature on the way open. + float a = -aSide * uGape * split; + float ca = cos(a); + float sa = sin(a); + transformed.xy = vec2(transformed.x * ca - transformed.y * sa, + transformed.x * sa + transformed.y * ca); + transformed.x += aSide * uGapeSlide * split; + transformed.y += uGapeLift * split; + + vShell = aShell; + vHatch = hatch; + vSwellT = swellT; + vSplit = split; + vEggSeed = aSeed; + }` + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform vec3 uLightDir; + uniform float uHuskAt; + uniform float uVeins; + uniform float uVeinScale; + uniform float uVeinBands; + uniform float uVeinSharp; + uniform float uMottle; + uniform float uMottleScale; + uniform float uRimWidth; + uniform float uRimDark; + uniform float uRimGlow; + uniform float uFresnel; + uniform float uFresnelPower; + uniform float uThin; + uniform float uThinPower; + uniform float uBroodGlow; + uniform float uBroodRise; + uniform float uInteriorGlow; + uniform float uGlow; + uniform float uHuskFade; + uniform vec3 uColorShell; + uniform vec3 uColorBelly; + uniform vec3 uColorVein; + uniform vec3 uColorBrood; + uniform vec3 uColorRim; + uniform vec3 uColorInterior; + varying vec4 vShell; + varying float vHatch; + varying float vSwellT; + varying float vSplit; + varying float vEggSeed; + ${noiseGLSL}` + ) + // After , where `normal` has been resolved. With + // flatShading there is no vNormal varying at all, so every view-dependent + // term below *has* to live here and not one include earlier. + .replace( + '#include ', + `#include + { + // Shell coordinates: up the egg, and (cos, sin) round it. Not the + // world position — the shell inflates, and a world-space field would + // slide across it while it did. + vec3 shellCoord = vec3(vShell.yz * uVeinScale, vShell.x * uVeinBands + vEggSeed); + + float vein = ridged(shellCoord, 4); + vein = smoothstep(0.42 + 0.3 * (1.0 - uVeinSharp), 0.98, vein); + float mottle = fbm3(vec3(vShell.yz * uMottleScale, vShell.x * uMottleScale + vEggSeed)); + + // Belly to crown. A real egg is paler where it sits. + vec3 body = mix(uColorBelly, uColorShell, clamp(vShell.x, 0.0, 1.0)); + body = mix(body, uColorShell * 0.72, mottle * uMottle); + + // The torn lip. 1 exactly on the seam edge, 0 a couple of rings in. + float rim = smoothstep(1.0 - clamp(uRimWidth, 0.01, 1.0), 1.0, vShell.w); + body = mix(body, body * (1.0 - clamp(uRimDark, 0.0, 1.0)), rim); + + float facing = clamp(dot(normal, normalize(vViewPosition)), 0.0, 1.0); + float edge = pow(1.0 - facing, max(uFresnelPower, 0.05)); + + // The translucency, and it is the beat that matters. A shell under + // pressure thins, so light from behind starts coming through it — + // driven by swellT rather than by a constant, which is what turns + // "an egg" into "an egg about to go". Written as a wrapped + // back-light rather than a fresnel rim: a fresnel puts the glow on + // the silhouette, and a thin shell glows across its whole belly. + float back = pow(clamp(dot(-normal, uLightDir) * 0.5 + 0.5, 0.0, 1.0), + max(uThinPower, 0.05)); + float thin = back * uThin * vSwellT; + + // The brood, showing through. Late, and through the veins, so what + // you read is a shape moving under a membrane rather than a lamp. + float showing = pow(vSwellT, max(uBroodRise, 0.05)); + float lumen = showing * (0.35 + 0.65 * vein); + + vec3 glow = uColorVein * vein * uVeins * (0.4 + 0.6 * vSwellT); + glow += uColorBrood * lumen * uBroodGlow; + glow += uColorShell * thin; + glow += uColorShell * edge * uFresnel; + glow += uColorRim * rim * uRimGlow * vSplit; + + // The inside of an opened husk: wet, dark, and not lit by anything + // the outside is lit by. gl_FrontFacing is the only handle on it and + // it is exactly the right one. + if (!gl_FrontFacing) { + body = uColorInterior; + glow = uColorBrood * uInteriorGlow * (0.25 + 0.75 * vSplit); + } + + diffuseColor.rgb *= body; + + // Soft ceiling, as IceMaterial does: the veins, the brood and the + // back-light all peak in different places but a swollen egg seen + // edge-on has all three at once, and without this the sum runs past + // 4 and bloom turns the clutch into a single white blob. + glow *= uGlow; + glow /= 1.0 + glow * 0.28; + totalEmissiveRadiance += glow; + + // The husk goes last, not first: it has to still be catching light + // while it dissolves, or the shell reads as fading out rather than + // as being left behind. + float husk = 1.0 - smoothstep(clamp(uHuskAt, 0.0, 0.999), 1.0, vHatch); + diffuseColor.a *= husk * clamp(uHuskFade, 0.0, 1.0); + }` + ); + }); + + material.userData.uniforms = uniforms; + + /** + * Pull the palette and every shading control from the live settings. + * + * @param {number} fade 1 while the clutch stands, ramping to 0 as it dies + */ + material.userData.sync = (fade) => { + const c = settings.broodburst; + const g = settings.global; + + uniforms.uSwellAt.value = c.swellAt; + uniforms.uSplitAt.value = c.splitAt; + uniforms.uSplitSpan.value = c.splitSpan; + uniforms.uHuskAt.value = c.huskAt; + uniforms.uSwell.value = c.swell; + uniforms.uSwellCurve.value = c.swellCurve; + uniforms.uBulge.value = c.bulge; + uniforms.uStretch.value = c.stretch; + uniforms.uThrob.value = c.throb; + uniforms.uThrobRate.value = c.throbRate; + + uniforms.uGape.value = c.gape; + uniforms.uGapeSlide.value = c.gapeSlide; + uniforms.uGapeLift.value = c.gapeLift; + uniforms.uGapeCurve.value = c.gapeCurve; + + uniforms.uVeins.value = c.veins * g.shaderIntensity; + uniforms.uVeinScale.value = c.veinScale * g.noiseFrequency; + uniforms.uVeinBands.value = c.veinBands * g.noiseFrequency; + uniforms.uVeinSharp.value = c.veinSharp; + uniforms.uMottle.value = c.mottle * g.noiseStrength; + uniforms.uMottleScale.value = c.mottleScale * g.noiseFrequency; + uniforms.uRimWidth.value = c.rimWidth; + uniforms.uRimDark.value = c.rimDark; + uniforms.uRimGlow.value = c.rimGlow; + uniforms.uFresnel.value = c.shellFresnel * g.fresnel; + uniforms.uFresnelPower.value = c.shellFresnelPower; + uniforms.uThin.value = c.thin * g.shaderIntensity; + uniforms.uThinPower.value = c.thinPower; + uniforms.uBroodGlow.value = c.broodGlow; + uniforms.uBroodRise.value = c.broodRise; + uniforms.uInteriorGlow.value = c.interiorGlow; + uniforms.uGlow.value = c.shellGlow * g.glow; + uniforms.uHuskFade.value = fade; + + uniforms.uColorShell.value.copy(getColor(c.colorShell)); + uniforms.uColorBelly.value.copy(getColor(c.colorBelly)); + uniforms.uColorVein.value.copy(getColor(c.colorVein)); + uniforms.uColorBrood.value.copy(getColor(c.colorBrood)); + uniforms.uColorRim.value.copy(getColor(c.colorRim)); + uniforms.uColorInterior.value.copy(getColor(c.colorInterior)); + + material.roughness = c.shellRoughness; + material.envMapIntensity = c.shellEnv; + material.opacity = 1; + }; + + material.userData.sync(1); + return material; +} diff --git a/src/materials/CascadeBeamMaterial.js b/src/materials/CascadeBeamMaterial.js new file mode 100644 index 0000000..8a187c8 --- /dev/null +++ b/src/materials/CascadeBeamMaterial.js @@ -0,0 +1,488 @@ +import { + BufferAttribute, + InstancedBufferAttribute, + InstancedBufferGeometry, + Sphere, + ShaderMaterial, + AdditiveBlending, + DoubleSide, + Color, + Vector2, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * Legs of the cascade the shader can carry — four mirrors plus the run to the + * floor. A ceiling rather than a count: `refractcascade`'s `mirrors` slider + * clamps into it, and the geometry's `instanceCount` is set to the live number + * every frame so the dead instances are never submitted. + */ +export const MAX_BEAM_SEGMENTS = 5; + +const TAU = Math.PI * 2; + +/* ---------------------------------------------------------------- */ +/* Geometry */ +/* ---------------------------------------------------------------- */ + +/** + * The column every leg is drawn on — a tube in **parameter** space, instanced. + * + * `createBeamTubeGeometry` in `assets/ProceduralGeometry.js` is exactly this + * grid and is what Nova Beam draws on, but it is a plain `BufferGeometry`: one + * beam, one draw call. A cascade is four or five beams that are all the same + * shape function evaluated between different pairs of points, and submitting + * that as five meshes is five draw calls for one idea. + * + * So the grid is instanced and the instance attribute is nothing but the leg's + * ordinal. Every vertex carries `position = (t, a, 0)` — `t` runs 0 → 1 from the + * leg's start to its end, `a` once around the barrel — and the vertex shader + * turns that pair plus `aSegment` into a world position. There are no metres in + * the buffer, so one upload serves a cascade of any length with the mirrors + * anywhere. + * + * A camera-facing ribbon (`createBoltRibbonGeometry`) would have been cheaper + * and is wrong here for the reason the beam's own doc comment gives: this thing + * crosses the frame at four different angles at once and you orbit around it, + * so the silhouette has to actually bow, and the far wall has to add through + * the near one where a leg is seen end-on. A ribbon fakes neither. + * + * The seam column is duplicated so `a` reaches a full 1.0 rather than wrapping + * to 0 — the longitudinal grain in the shader would otherwise show a hard join + * line down every leg. + * + * @param {number} nodes samples along one leg; the ceiling on profile detail + * @param {number} sides facets around the barrel (16–24 reads clean) + * @param {number} segments instance capacity + */ +export function createCascadeBeamGeometry(nodes = 24, sides = 18, segments = MAX_BEAM_SEGMENTS) { + const steps = Math.max(2, Math.round(nodes)); + const facets = Math.max(3, Math.round(sides)); + const columns = facets + 1; + const count = Math.max(1, Math.round(segments)); + + const positions = new Float32Array(steps * columns * 3); + let v = 0; + for (let i = 0; i < steps; i++) { + const t = i / (steps - 1); + for (let j = 0; j < columns; j++) { + positions[v++] = t; + positions[v++] = j / facets; + positions[v++] = 0; + } + } + + const indices = new Uint16Array((steps - 1) * facets * 6); + let k = 0; + for (let i = 0; i < steps - 1; i++) { + for (let j = 0; j < facets; j++) { + const a = i * columns + j; + const b = a + columns; + indices[k++] = a; + indices[k++] = b; + indices[k++] = a + 1; + indices[k++] = b; + indices[k++] = b + 1; + indices[k++] = a + 1; + } + } + + const segmentIndex = new Float32Array(count); + for (let i = 0; i < count; i++) segmentIndex[i] = i; + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setAttribute('aSegment', new InstancedBufferAttribute(segmentIndex, 1)); + geometry.setIndex(new BufferAttribute(indices, 1)); + geometry.instanceCount = count; + // Placed in world space by the vertex shader — its own bounds mean nothing + // and the ability sets `frustumCulled = false`. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + return geometry; +} + +/* ---------------------------------------------------------------- */ +/* Shaders */ +/* ---------------------------------------------------------------- */ + +/** + * The vertex shader owns the whole path. + * + * Two things about it are not obvious. + * + * **The endpoint lookup is an unrolled loop with an `if` in it**, not + * `uFrom[int(aSegment)]`. GLSL ES 1.00 guarantees uniform-array indexing only + * by a constant-index-expression, a loop counter qualifies and a value derived + * from an attribute does not, and the direct version fails on ANGLE. This is + * the same shape `FilamentPaths` uses for its role lookup and for the same + * reason; do not "simplify" it. + * + * **The tube's frame is derived from the leg, not carried on it.** Each leg + * picks a reference axis by looking at how vertical it is and builds two + * normals off the heading, so the ability never has to compute or store a side + * vector per mirror. That matters because the mirrors move every frame: a + * stored frame would be a captured dimension and the tube would twist a frame + * behind the path it is drawn on. + */ +const BEAM_VERTEX = /* glsl */ ` + attribute float aSegment; // which leg of the cascade this instance is + + uniform vec3 uFrom[${MAX_BEAM_SEGMENTS}]; // world start of each leg + uniform vec3 uTo[${MAX_BEAM_SEGMENTS}]; // world end + uniform vec2 uSpan[${MAX_BEAM_SEGMENTS}]; // its start/end as fractions of the whole path + + uniform float uCount; // legs live this frame + uniform float uProgress; // 0..1 how far down the whole path the light has got + uniform float uTipSoft; // fraction of the path the drawn tip tapers over + + uniform float uRadiusNear; // half-width at the muzzle, metres + uniform float uRadiusFar; // ... at the far end + uniform float uRadiusCurve; // >1 stays fat and thins late + uniform float uThrob; // fraction of the radius that pulses + uniform float uThrobBands; // pulses along the whole path + uniform float uThrobSpeed; // radians/second they slide at + uniform float uLoss; // width and brightness kept per bounce + uniform float uSeed; + uniform float uTime; + + varying float vGT; // position along the WHOLE path, 0..1 + varying float vSeg; // leg ordinal, as a float + varying float vGrow; // 0..1 has the light reached here yet + varying float vEnergy; // what is left after the bounces so far + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + + void main() { + float seg = aSegment; + + /* ---- this instance's leg. Loop counter, never the attribute. ---- */ + vec3 head = uFrom[0]; + vec3 tail = uTo[0]; + vec2 reach = uSpan[0]; + for (int i = 0; i < ${MAX_BEAM_SEGMENTS}; i++) { + if (float(i) == seg) { + head = uFrom[i]; + tail = uTo[i]; + reach = uSpan[i]; + } + } + + float t = position.x; + float ang = position.y * ${TAU.toFixed(7)}; + + /* ---- where this ring sits along the cascade as a whole ---- */ + vGT = mix(reach.x, reach.y, t); + vSeg = seg; + // The drawn tip: 1 well behind the front, 0 ahead of it. Written as + // 1 - smoothstep rather than smoothstep with the edges swapped, because + // smoothstep with edge0 > edge1 is undefined and one driver in three + // returns something plausible enough to ship by accident. + vGrow = 1.0 - smoothstep(max(uProgress - uTipSoft, 0.0), uProgress, vGT); + vEnergy = pow(max(uLoss, 0.001), seg); + + /* ---- the tube's own frame ---- */ + vec3 axis = tail - head; + float span = max(length(axis), 1e-4); + axis /= span; + vec3 ref = abs(axis.y) < 0.9 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0); + vec3 n1 = normalize(cross(axis, ref)); + vec3 n2 = cross(axis, n1); + vec3 outward = cos(ang) * n1 + sin(ang) * n2; + + float radius = mix(uRadiusNear, uRadiusFar, pow(clamp(vGT, 0.0, 1.0), max(uRadiusCurve, 0.05))); + radius *= vEnergy; + radius *= 1.0 + uThrob * sin(vGT * uThrobBands * ${TAU.toFixed(7)} - uTime * uThrobSpeed + uSeed); + // Collapse to the axis where the light has not arrived, and for any + // instance past the live count — a degenerate tube costs nothing and is + // simpler than a second branch in the fragment stage. + radius *= vGrow * step(seg, uCount - 0.5); + + vec3 world = mix(head, tail, t) + outward * max(radius, 0.0); + + vNormalW = outward; + vViewDir = cameraPosition - world; + vec4 mv = viewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/** + * The fragment shader shades a cylinder wall, and the two terms it is made of + * are opposites on purpose. + * + * `pow(|N·V|, coreSharp)` peaks where the wall faces the camera — the *middle* + * of the silhouette — and is what makes a beam of light read as a beam rather + * than as a pipe. `pow(1 - |N·V|, rimPower)` peaks at the grazing edge and is + * the glass: without it a bright additive tube has no boundary and dissolves + * into its own bloom. + * + * The first version had only the rim term, which is the reflex from writing + * fresnel on everything, and it produced a hollow drinking straw — bright edges + * with nothing between them. The one after that had only the core term and the + * beam had no silhouette at all against a bright floor. Both are cheap; the + * point is that they answer different questions and an emissive tube needs + * both. + * + * `vEnergy` is the physical bit: each bounce keeps `uLoss` of the width and the + * brightness, so the cascade visibly runs down. A cascade whose fourth leg is + * as hot as its first says the mirrors are amplifiers. + */ +const BEAM_FRAGMENT = /* glsl */ ` + uniform vec3 uColorCore; + uniform vec3 uColorInner; + uniform vec3 uColorOuter; + uniform vec3 uColorHalo; + + uniform float uCoreSharp; // how fast the body falls off toward the silhouette + uniform float uCoreTight; // how small the white centre is + uniform float uRimPower; // how tight the glass edge is + uniform float uRim; // how strong it is + uniform float uBands; // travelling energy bands along the path + uniform float uBandDepth; + uniform float uBandSpeed; // radians/second + uniform float uBandStagger; // radians of phase added per leg + uniform float uGrain; // longitudinal noise on the body + uniform float uGrainScale; + uniform float uGrainSpeed; + uniform float uTipGlow; // extra heat right behind the drawn front + uniform float uTipLength; // how far back that reaches, fraction of the path + uniform float uGlow; + uniform float uOpacity; + uniform float uSoft; // metres of soft fade where a leg meets geometry + uniform float uFade; + uniform float uProgress; + uniform float uSeed; + + uniform float uTime; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + + varying float vGT; + varying float vSeg; + varying float vGrow; + varying float vEnergy; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + vec3 N = normalize(vNormalW); + vec3 V = normalize(vViewDir); + float ndv = clamp(abs(dot(N, V)), 0.0, 1.0); + + float core = pow(ndv, max(uCoreSharp, 0.05)); + float rim = pow(1.0 - ndv, max(uRimPower, 0.05)); + + float bands = 0.5 + 0.5 * sin(vGT * uBands * 6.2831853 - uTime * uBandSpeed + vSeg * uBandStagger); + float grain = snoise(vec3(vGT * uGrainScale, vSeg * 7.3 + uSeed, uTime * uGrainSpeed)); + float body = core * (1.0 + uBandDepth * (bands - 0.5)) * (1.0 + uGrain * grain * 0.5); + + // The head of the light, while it is still travelling. Zero once the + // cascade is complete, because there is no front any more. + float behind = max(uProgress - vGT, 0.0); + float tip = (1.0 - smoothstep(0.0, max(uTipLength, 1e-4), behind)) * uTipGlow * step(uProgress, 0.999); + + vec3 color = mix(uColorOuter, uColorInner, clamp(core, 0.0, 1.0)); + color = mix(color, uColorCore, pow(clamp(core, 0.0, 1.0), max(uCoreTight, 0.05))); + color *= max(body, 0.0); + color += uColorHalo * rim * uRim; + color += uColorCore * tip * core; + color *= vEnergy; + + // The soft ceiling every emissive surface in this project carries: the + // terms above are independent and stack, and a rim crossing the white + // centre sums past 10 where the bloom pass eats the silhouette whole. + color *= uGlow * uGlobalGlow; + color /= 1.0 + color * 0.14; + + float alpha = clamp(body + rim * uRim * 0.5 + tip * 0.4, 0.0, 1.0) * uOpacity * uFade * vGrow; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoft); + if (alpha < 0.004) discard; + + gl_FragColor = vec4(color, alpha); + } +`; + +/* ---------------------------------------------------------------- */ +/* The material */ +/* ---------------------------------------------------------------- */ + +const _head = new Vector3(); +const _tail = new Vector3(); + +/** + * The cascade's beam: every leg between the mirrors, in one draw call. + * + * ```js + * this.beamGeometry = createCascadeBeamGeometry(24, 18); + * this.beamMaterial = createCascadeBeamMaterial(); + * // per frame, after the nodes have been resolved from settings: + * this.beamGeometry.instanceCount = legs; + * this.beamMaterial.userData.sync(this._nodes, legs, state); + * ``` + * + * `sync()` is handed the ability's own array of node positions — muzzle, + * every mirror in order, then the floor — and works out each leg's share of the + * total path length itself, every frame, from wherever the nodes are *now*. + * That is the piece that has to live here rather than in the ability: the legs + * are different lengths and the front has to cross them at one speed, so the + * mapping from "fraction of the whole cascade" to "fraction of this leg" is a + * function of the live geometry and cannot be cached across a frame in which + * anybody dragged a mirror. + */ +export function createCascadeBeamMaterial() { + const from = []; + const to = []; + const span = []; + for (let i = 0; i < MAX_BEAM_SEGMENTS; i++) { + from.push(new Vector3()); + to.push(new Vector3()); + span.push(new Vector2(i / MAX_BEAM_SEGMENTS, (i + 1) / MAX_BEAM_SEGMENTS)); + } + + const material = new ShaderMaterial({ + name: 'CascadeBeam', + transparent: true, + depthWrite: false, + depthTest: true, + // A leg is thin enough that its far wall is always in shot, and the two + // walls adding is most of why it reads as light rather than as plastic. + side: DoubleSide, + blending: AdditiveBlending, + toneMapped: false, + uniforms: sharedUniforms({ + uFrom: { value: from }, + uTo: { value: to }, + uSpan: { value: span }, + + uCount: { value: 1 }, + uProgress: { value: 1 }, + uTipSoft: { value: 0.02 }, + + uRadiusNear: { value: 0.09 }, + uRadiusFar: { value: 0.06 }, + uRadiusCurve: { value: 1 }, + uThrob: { value: 0.06 }, + uThrobBands: { value: 7 }, + uThrobSpeed: { value: 5 }, + uLoss: { value: 0.86 }, + uSeed: { value: 0 }, + + uColorCore: { value: new Color('#ffffff') }, + uColorInner: { value: new Color('#ffeec2') }, + uColorOuter: { value: new Color('#ffb43c') }, + uColorHalo: { value: new Color('#5fd8ff') }, + + uCoreSharp: { value: 2.2 }, + uCoreTight: { value: 3.4 }, + uRimPower: { value: 2.6 }, + uRim: { value: 0.9 }, + uBands: { value: 9 }, + uBandDepth: { value: 0.4 }, + uBandSpeed: { value: 6 }, + uBandStagger: { value: 1.1 }, + uGrain: { value: 0.35 }, + uGrainScale: { value: 14 }, + uGrainSpeed: { value: 1.4 }, + uTipGlow: { value: 2.2 }, + uTipLength: { value: 0.05 }, + uGlow: { value: 2.8 }, + uOpacity: { value: 1 }, + uSoft: { value: 0.35 }, + uFade: { value: 1 } + }), + vertexShader: BEAM_VERTEX, + fragmentShader: BEAM_FRAGMENT + }); + + /** + * Push the live settings and this frame's polyline into the beam. + * + * @param {THREE.Vector3[]} nodes `legs + 1` world points, muzzle first + * @param {number} legs how many of them are live + * @param {object} state `{ progress, fade, seed }` + */ + material.userData.sync = (nodes, legs, state) => { + const c = settings.refractcascade; + const g = settings.global; + const u = material.uniforms; + const count = Math.max(1, Math.min(MAX_BEAM_SEGMENTS, Math.round(legs))); + + /* --- the polyline, and each leg's share of it --- */ + // Total length first, then a running cursor: the front has to cross a long + // leg in proportionally longer, or it appears to accelerate every time the + // mirrors are moved closer together. + let total = 0; + for (let i = 0; i < count; i++) { + _head.copy(nodes[i]); + _tail.copy(nodes[i + 1]); + total += _head.distanceTo(_tail); + } + total = Math.max(total, 1e-4); + + let cursor = 0; + for (let i = 0; i < MAX_BEAM_SEGMENTS; i++) { + const live = i < count; + const a = nodes[live ? i : count]; + const b = nodes[live ? i + 1 : count]; + u.uFrom.value[i].copy(a); + u.uTo.value[i].copy(b); + const length = live ? a.distanceTo(b) : 0; + u.uSpan.value[i].set(cursor / total, (cursor + length) / total); + cursor += length; + } + + u.uCount.value = count; + u.uProgress.value = state.progress; + u.uTipSoft.value = Math.max(1e-4, c.beamTipSoft); + u.uSeed.value = state.seed; + + u.uRadiusNear.value = c.beamRadiusNear; + u.uRadiusFar.value = c.beamRadiusFar; + u.uRadiusCurve.value = c.beamRadiusCurve; + u.uThrob.value = c.beamThrob; + u.uThrobBands.value = c.beamThrobBands; + u.uThrobSpeed.value = c.beamThrobSpeed * g.animationSpeed; + u.uLoss.value = c.bounceLoss; + + u.uColorCore.value.copy(getColor(c.colorBeamCore)); + u.uColorInner.value.copy(getColor(c.colorBeamInner)); + u.uColorOuter.value.copy(getColor(c.colorBeamOuter)); + u.uColorHalo.value.copy(getColor(c.colorBeamHalo)); + + u.uCoreSharp.value = c.beamCoreSharp; + u.uCoreTight.value = c.beamCoreTight; + u.uRimPower.value = c.beamRimPower; + u.uRim.value = c.beamRim * g.fresnel; + u.uBands.value = c.beamBands; + u.uBandDepth.value = c.beamBandDepth; + u.uBandSpeed.value = c.beamBandSpeed * g.animationSpeed; + u.uBandStagger.value = c.beamBandStagger; + u.uGrain.value = c.beamGrain * g.noiseStrength; + u.uGrainScale.value = c.beamGrainScale * g.noiseFrequency; + u.uGrainSpeed.value = c.beamGrainSpeed * g.noiseSpeed; + u.uTipGlow.value = c.beamTipGlow; + u.uTipLength.value = c.beamTipLength; + u.uGlow.value = c.beamGlow * g.glow; + u.uOpacity.value = c.beamOpacity * g.opacity; + u.uSoft.value = c.beamSoftFade; + u.uFade.value = state.fade; + }; + + return material; +} diff --git a/src/materials/DaystarMaterial.js b/src/materials/DaystarMaterial.js new file mode 100644 index 0000000..8975026 --- /dev/null +++ b/src/materials/DaystarMaterial.js @@ -0,0 +1,198 @@ +import { AdditiveBlending, Color, ShaderMaterial, Vector3 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; + +/** + * DAYSTAR — the one thing Dawnbreak actually draws. + * + * Dawnbreak's trick is that it borrows the scene's key light and swings it, so + * that every object on the stage throws a *real* shadow that sweeps. That means + * the ability's own geometry budget is almost entirely spare, and the one thing + * worth spending it on is the **source**: a disc up-sun from the cast, so the + * player can see where the light is coming from and watch it climb. Without it + * the sweep still happens and still reads as "something is wrong with the + * lighting" rather than as "the sun is moving overhead", which is a different + * sentence and the wrong one. + * + * ## Why it is a billboard built in view space + * + * The first version was a `Mesh` with a live `position` and `matrixAutoUpdate` + * left on, oriented at the camera with `lookAt`. Two things went wrong. The + * ability's group has `matrixAutoUpdate = false` — every effect in this project + * passes its geometry in as uniforms rather than riding a model matrix — so the + * mesh's own matrix was never recomposed from the position that was being + * written, and the sun sat at the world origin, inside the floor, for a very + * confusing ten minutes. And `lookAt` needs the camera, which an `Ability` does + * not have and should not acquire. + * + * So the centre arrives as `uCentre` in **world metres** and the quad is built + * around it in view space: + * + * ``` + * vec4 centre = viewMatrix * vec4(uCentre, 1.0); + * centre.xy += position.xy * span; // always camera-facing + * ``` + * + * — no model matrix, no camera reference, exact billboarding for free, and the + * pause probe reads the sun's live world position straight off the uniform. + * + * ## What it draws + * + * One additive quad, three terms, all of them procedural: + * + * - **the body**, limb-darkened by `pow(1 - r², limb)` so the edge of the disc + * is cooler than its middle. A flat disc reads as a hole punched in the + * backdrop; the limb is what makes it a sphere; + * - **the aureole**, a wide power falloff that is the atmosphere the light is + * coming through, not a bloom — bloom is applied to it afterwards and would + * have nothing to work with if the disc had a hard edge and nothing else; + * - **the streak**, a horizontal anamorphic flare on a pair of exponentials. + * It is the one deliberately artificial term and it is what makes a small + * bright shape read as *the sun* rather than as a lamp. + * + * The face carries a slow granulation — one `fbm4` sampled in the disc's own + * plane. It is nearly invisible at the size the disc is usually drawn, and that + * is fine: it is there for the frames where the sun is close to the camera and + * a perfectly smooth disc would look like a decal. + * + * ## Colour, and the horizon + * + * Three pickers, none derived from another (I5): `uColorCore` is the disc at + * its height, `uColorLow` is the disc on the horizon, and `uColorHalo` is the + * aureole and the streak. `uWarm` — 0 on the horizon, 1 overhead — mixes the + * first two, and it is the **same** number Dawnbreak hands the key light, so + * the disc you are looking at and the light falling on the floor redden + * together. Driving those two from separate curves was the second bug: the sun + * went orange while the stage stayed noon-white, and every shadow on the floor + * looked pasted on. + * + * Every uniform below is re-resolved from `settings.dawnbreak` by the ability + * on every frame including a zero-length one (I1). Nothing here is captured. + */ + +const DAYSTAR_VERTEX = /* glsl */ ` + uniform vec3 uCentre; + uniform float uSize; + uniform float uSpan; + + varying vec2 vPlane; + + void main() { + // The quad is built around the centre in view space, so it faces the camera + // whatever the camera does and needs no model matrix of its own. + vec4 centre = viewMatrix * vec4(uCentre, 1.0); + + // position.xy is the unit quad's -0.5..0.5. vPlane is measured in DISC + // RADII, so r = 1.0 is exactly the limb wherever the size slider puts it. + vPlane = position.xy * 2.0 * uSpan; + centre.xy += position.xy * (2.0 * uSpan * uSize); + + gl_Position = projectionMatrix * centre; + } +`; + +const DAYSTAR_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + + uniform float uSeed; + uniform float uFade; + uniform float uWarm; + + uniform float uSoft; + uniform float uLimb; + uniform float uHalo; + uniform float uHaloFalloff; + uniform float uFlare; + uniform float uFlareLength; + uniform float uFlareWidth; + uniform float uGranule; + uniform float uGranuleScale; + uniform float uGranuleSpeed; + + uniform float uGlow; + uniform float uOpacity; + uniform vec3 uColorCore; + uniform vec3 uColorLow; + uniform vec3 uColorHalo; + + varying vec2 vPlane; + + ${noiseGLSL} + + void main() { + float r = length(vPlane); + + /* --- the body, limb-darkened --- */ + float body = 1.0 - smoothstep(1.0 - clamp(uSoft, 0.001, 0.999), 1.0, r); + float limb = pow(clamp(1.0 - r * r, 0.0, 1.0), max(uLimb, 0.01)); + float cells = fbm4(vec3(vPlane * uGranuleScale, uTime * uGranuleSpeed + uSeed)); + body *= mix(1.0, 0.55 + 0.45 * (cells * 0.5 + 0.5), clamp(uGranule, 0.0, 1.0)); + + /* --- the aureole --- */ + float reach = max(uHalo, 1.001); + float halo = pow(clamp(1.0 - r / reach, 0.0, 1.0), max(uHaloFalloff, 0.05)); + + /* --- the streak. Two exponentials, wide in x and tight in y. --- */ + float streak = exp(-abs(vPlane.x) / max(uFlareLength * reach, 1e-3)) * + exp(-abs(vPlane.y) / max(uFlareWidth, 1e-3)); + + vec3 face = mix(uColorLow, uColorCore, clamp(uWarm, 0.0, 1.0)); + + vec3 color = face * (body * (0.35 + 0.65 * limb)); + color += uColorHalo * halo * halo; + color += uColorHalo * streak * uFlare; + + float alpha = clamp(body + halo * 0.55 + streak * uFlare * 0.5, 0.0, 1.0); + alpha *= uFade * uOpacity; + if (alpha < 0.003) discard; + + color *= uGlow * uGlobalGlow * mix(0.7, 1.0, uShaderIntensity); + gl_FragColor = vec4(clamp(color, 0.0, 64.0), alpha); + } +`; + +/** + * One daystar. Uniforms are pushed by the ability every frame. + * + * Additive and depth-tested: the disc is a light source hanging in the void + * beyond the stage, so anything genuinely in front of it should hide it, and + * nothing it lands on should be darkened by it. + */ +export function createDaystarMaterial() { + return new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + toneMapped: false, + uniforms: sharedUniforms({ + uCentre: { value: new Vector3() }, + uSize: { value: 3 }, + uSpan: { value: 4 }, + uSeed: { value: 0 }, + uFade: { value: 0 }, + uWarm: { value: 0 }, + + uSoft: { value: 0.12 }, + uLimb: { value: 0.35 }, + uHalo: { value: 3.4 }, + uHaloFalloff: { value: 2.6 }, + uFlare: { value: 0.55 }, + uFlareLength: { value: 0.85 }, + uFlareWidth: { value: 0.18 }, + uGranule: { value: 0.35 }, + uGranuleScale: { value: 1.6 }, + uGranuleSpeed: { value: 0.15 }, + + uGlow: { value: 2.4 }, + uOpacity: { value: 1 }, + uColorCore: { value: new Color(1, 0.98, 0.9) }, + uColorLow: { value: new Color(1, 0.55, 0.22) }, + uColorHalo: { value: new Color(1, 0.78, 0.45) } + }), + vertexShader: DAYSTAR_VERTEX, + fragmentShader: DAYSTAR_FRAGMENT + }); +} diff --git a/src/materials/DeflectionSheetMaterial.js b/src/materials/DeflectionSheetMaterial.js new file mode 100644 index 0000000..d12eb35 --- /dev/null +++ b/src/materials/DeflectionSheetMaterial.js @@ -0,0 +1,377 @@ +import { ShaderMaterial, AdditiveBlending, DoubleSide, Color, Vector3 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/* ====================================================================== */ +/* DEFLECTION SHEET — where a jet goes after it stops */ +/* ====================================================================== */ +/** + * The liquid sheet a pressurised jet throws when it hits a surface, and the + * distribution that decides which way it goes. + * + * ## The problem this file exists to solve + * + * Every impact effect in this project sprays *radially*, because a radial puff + * is what you get for free and it is right often enough. It is emphatically not + * right for a jet. A jet arriving at forty-five degrees does not splash + * evenly — almost all of it carries on downstream, a thin skirt wraps round the + * sides, and essentially nothing comes back up the way it came. If the spray + * does not know which way the surface is facing, the jet reads as a light + * source touching the floor rather than as pressure hitting it. + * + * ## The distribution, from two conservation laws and nothing else + * + * Let **n** be the surface normal and **d** the jet's unit direction. The + * reflection is + * + * ``` + * r = d − 2(d·n)n + * ``` + * + * Split it: the component **in** the surface is untouched by the reflection, + * and the component **along the normal** is exactly reversed. Those two facts + * are the whole ability. The in-plane part is the direction the sheet runs + * (`uAxis`); the reversed normal part is why a steep jet throws a crown and a + * grazing one lies flat (`uCrown`). + * + * Now, how is the outgoing sheet distributed around the azimuth `φ` measured + * from that axis? Write the flux as a density `q(φ)` on the circle and ask for + * exactly two things: + * + * - **mass is conserved** — `∫q dφ / 2π = 1`; + * - **in-plane momentum is conserved** — the sheet leaves at the speed it + * arrived (Bernoulli, inviscid), so the first Fourier cosine coefficient of + * `q` must equal the fraction of the jet's momentum that was already in the + * plane, which is `sin θ` for an incidence angle `θ` off the normal. + * + * The distribution on a circle whose *only* free parameter is its first Fourier + * coefficient is the Poisson kernel — equivalently the wrapped Cauchy density: + * + * ``` + * q(φ) = (1 − k²) / (1 + k² − 2k·cos φ) k = the in-plane fraction + * ``` + * + * At `k = 0` — a jet straight down onto a flat floor — it is uniform, and the + * spray genuinely is a radial ring, which is correct and is the one case where + * everybody's default happens to be right. As `k → 1` it collapses onto a + * narrow forward sheet. Nothing was chosen by eye. + * + * **And the particles use the same distribution from the other end.** The + * wrapped Cauchy has an exact inverse CDF — + * `φ = 2·atan( ((1−k)/(1+k))·tan(π(u−½)) )` — so `TorrentAbility` turns one + * uniform random number into one exactly-distributed bearing, with no rejection + * loop and no table. The sheet draws the density; the droplets sample it. They + * cannot drift apart, because they are the same three lines of algebra. + * + * ## What the shipped `fanConcentration` is for + * + * The inviscid `k` is `sin θ`, and at the cast geometry this ability actually + * ships — a mouth at chest height and a contact point twelve metres away — that + * is 0.99, which collapses the fan to a line about seven degrees wide. Real + * jets on real stone lose a great deal of their in-plane momentum to the + * splash, the roughness and the boundary layer, so `k = sinθ · fanConcentration` + * with the slider well below 1. That is a fudge and it is labelled as one; what + * is not a fudge is that it is a single scalar on a term with a physical + * meaning, so the *shape* of the fan still answers the geometry. + */ + +/* ---------------------------------------------------------------------- */ +/* The vertex stage */ +/* ---------------------------------------------------------------------- */ +/** + * The sheet is drawn on the beam tube's `(t, a)` grid, reinterpreted: `t` is + * distance out from the contact point as a fraction of that bearing's reach, + * and `a` is the bearing itself. The seam the grid duplicates is put at `φ = π` + * — directly upstream, where the sheet is thinnest and nobody is looking — + * rather than at `φ = 0`, where it would run straight down the brightest part. + * + * Reusing that grid rather than building a fan geometry is not only tidiness: + * the sheet's outline moves *every frame* as the fan swings, and a CPU-built + * fan would be a buffer upload per frame and a captured metre besides. + */ +const SHEET_VERTEX = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform vec3 uContact; // where the jet lands, world metres + uniform vec3 uNormal; // the impact surface's unit normal + uniform vec3 uAxis; // in-plane fan axis (the reflected heading) + uniform vec3 uBinormal; // in-plane, orthogonal to uAxis + + uniform float uK; // 0..0.985 — the in-plane momentum fraction + uniform float uReach; // metres the mean bearing carries + uniform float uFanPower; // how hard reach follows the density + uniform float uOpen; // 0..1 of the fan that exists yet + uniform float uCrown; // lift off the plate, x reach + uniform float uCrownFall; // how fast the sheet comes back down + uniform float uFingers; // ligaments the rim breaks into + uniform float uFingerDepth; // 0..1 how deep they cut + uniform float uFingerScale; // noise features around the rim + uniform float uFingerSpeed; // Hz they crawl + uniform float uRipple; // metres of surface chop on the sheet + uniform float uRippleScale; // chop features per metre + uniform float uRippleSpeed; // Hz it runs outward + uniform float uAge; // seconds since the jet made contact + uniform float uSeed; + + varying float vS; // 0 at the contact point, 1 at the rim + varying float vQ; // the density at this bearing, mean 1 + varying float vR; // metres from the contact point + varying vec3 vNormalW; + varying vec3 vViewW; + varying float vViewZ; + + ${noiseGLSL} + + void main() { + float s = position.x; + float phi = (position.y - 0.5) * TAU; + + /* The Poisson kernel. Mean 1 by construction, so uReach means "how far the + average bearing carries" whatever the concentration is — which is what + lets fanConcentration be dragged without the fan changing size. */ + float k = clamp(uK, 0.0, 0.985); + float q = (1.0 - k * k) / max(1.0 + k * k - 2.0 * k * cos(phi), 1e-4); + vQ = q; + + /* Ligaments. A liquid rim is Plateau-Rayleigh unstable: surface tension + collects the sheet's edge into a torus and the torus beads up. Two terms, + one periodic and one hashed, because a purely periodic rim reads as a + gear and a purely noisy one reads as a stain. */ + float fingers = sin(phi * max(uFingers, 1.0) + uAge * uFingerSpeed + uSeed * 7.0) * 0.5 + 0.5; + fingers = mix(fingers, snoise(vec3(cos(phi), sin(phi), uAge * 0.35) * uFingerScale + uSeed), 0.55); + float reach = uReach * pow(max(q, 1e-4), uFanPower) * (1.0 - uFingerDepth * fingers) * uOpen; + + float r = reach * s; + vec3 inPlane = uAxis * cos(phi) + uBinormal * sin(phi); + + /* The crown. Zero at the contact point, zero again where the rim lands, and + scaled by the normal momentum the reflection handed back — a jet coming + straight down throws a tall bell, a grazing one stays on the deck. */ + float fall = max(uCrownFall, 0.05); + float shoulder = pow(max(1.0 - s, 0.0), fall); + float lift = 4.0 * uCrown * reach * s * shoulder; + lift += uRipple * snoise(vec3(r * uRippleScale, phi * 2.0, uAge * uRippleSpeed)) * s; + + vec3 world = uContact + inPlane * r + uNormal * lift; + + /* Analytic slope of the crown, so the sheet is lit as a surface rather than + as a flat decal. Differentiating 4*A*s*(1-s)^f and dividing by dr/ds. */ + float slope = 4.0 * uCrown * (shoulder - fall * s * pow(max(1.0 - s, 1e-3), fall - 1.0)); + vNormalW = normalize(uNormal - inPlane * slope); + + vS = s; + vR = r; + vViewW = cameraPosition - world; + + vec4 mv = viewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/* ---------------------------------------------------------------------- */ +/* The fragment stage */ +/* ---------------------------------------------------------------------- */ +/** + * The sheet thins as `q/r`, and that is the only reason it looks like water. + * + * A radially spreading sheet of fixed flux gets thinner in exact proportion to + * how far out it has gone, because the same water is covering a circumference + * that grows with `r`. So the alpha is `q / (1 + r/…)` rather than a linear + * fade, and the consequence you can see is that the *bright* part of the fan is + * a narrow crescent near the contact point that gets wider downstream — which + * is what a jet on wet stone actually looks like and what a linear fade never + * produces. + * + * The rim gets its own band because surface tension really does collect the + * edge into a thick torus: the outline of a liquid sheet is its brightest part, + * and losing that was the tell in the first version, which read as a decal. + */ +const SHEET_FRAGMENT = /* glsl */ ` + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform vec3 uLightDir; + uniform float uGlobalGlow; + + uniform float uThin; // metres over which the sheet halves in thickness + uniform float uBody; // how much body the sheet keeps at all + uniform float uRimWidth; // 0..1 of the reach the rim occupies + uniform float uRimGain; // how bright that rim is + uniform float uFresnel; // grazing whitening exponent + uniform float uSpecular; + uniform float uGloss; // Blinn exponent + uniform float uOpacity; + uniform float uGlow; + uniform float uSoftFade; + uniform float uFade; + + uniform vec3 uColorSheet; // the water where it is thick + uniform vec3 uColorThin; // ... and where it has stretched out + uniform vec3 uColorRim; // the collected edge + uniform vec3 uColorSpray; // the specular and the crest + + varying float vS; + varying float vQ; + varying float vR; + varying vec3 vNormalW; + varying vec3 vViewW; + varying float vViewZ; + + ${commonGLSL} + + void main() { + vec3 N = normalize(vNormalW); + vec3 V = normalize(vViewW); + float ndv = clamp(abs(dot(N, V)), 0.0, 1.0); + + /* Mass conservation: the same flux over a circumference that grows with r. */ + float thickness = vQ / (1.0 + vR / max(uThin, 0.02)); + + float rim = smoothstep(1.0 - clamp(uRimWidth, 0.01, 1.0), 1.0, vS); + float fres = pow(1.0 - ndv, max(uFresnel, 0.1)); + + vec3 colour = mix(uColorThin, uColorSheet, clamp(thickness, 0.0, 1.0)); + colour = mix(colour, uColorRim, rim); + colour += uColorSpray * fres * 0.35; + + vec3 H = normalize(normalize(uLightDir) + V); + colour += uColorSpray * pow(max(dot(N, H), 0.0), max(uGloss, 1.0)) * uSpecular; + + float alpha = clamp(thickness * uBody + rim * uRimGain + fres * 0.2, 0.0, 1.0); + /* The sheet is born at the contact point and has to not be a hard disc + there — the jet's own barrel is standing in that spot. */ + alpha *= smoothstep(0.0, 0.08, vS); + alpha *= uOpacity * uFade; + if (alpha < 0.004) discard; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + colour *= uGlow * uGlobalGlow; + gl_FragColor = vec4(clamp(colour, 0.0, 32.0), alpha); + } +`; + +/** + * One draw call. Additive and double sided, like every other water surface in + * the project: a sheet this thin transmits nearly everything and the far side + * of the bell adding through the near side is most of what makes it read as a + * volume of water rather than as a painted shape. + */ +export function createDeflectionSheetMaterial() { + const material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uContact: { value: new Vector3() }, + uNormal: { value: new Vector3(0, 1, 0) }, + uAxis: { value: new Vector3(0, 0, 1) }, + uBinormal: { value: new Vector3(1, 0, 0) }, + + uK: { value: 0.6 }, + uReach: { value: 3.4 }, + uFanPower: { value: 0.8 }, + uOpen: { value: 0 }, + uCrown: { value: 0.12 }, + uCrownFall: { value: 1.6 }, + uFingers: { value: 13 }, + uFingerDepth: { value: 0.22 }, + uFingerScale: { value: 3.2 }, + uFingerSpeed: { value: 4.5 }, + uRipple: { value: 0.06 }, + uRippleScale: { value: 2.4 }, + uRippleSpeed: { value: 3.0 }, + uAge: { value: 0 }, + uSeed: { value: 0 }, + + uThin: { value: 1.6 }, + uBody: { value: 0.65 }, + uRimWidth: { value: 0.16 }, + uRimGain: { value: 0.5 }, + uFresnel: { value: 2.6 }, + uSpecular: { value: 0.9 }, + uGloss: { value: 40 }, + uOpacity: { value: 1 }, + uGlow: { value: 1.3 }, + uSoftFade: { value: 0.3 }, + uFade: { value: 1 }, + + uColorSheet: { value: new Color('#1f7f9a') }, + uColorThin: { value: new Color('#0b3a4c') }, + uColorRim: { value: new Color('#d6f6ff') }, + uColorSpray: { value: new Color('#ffffff') } + }), + vertexShader: SHEET_VERTEX, + fragmentShader: SHEET_FRAGMENT + }); + + /** + * Push the live settings and the cast's own frame into the uniforms. + * + * `state` carries the surface frame the ability computed this frame — the + * contact point, the normal, and the two in-plane axes — plus two unitless + * beats and the concentration. Every metre, radian and second below comes out + * of `settings.torrent` right here, so the fan re-derives on a zero-length + * frame and dragging `surfaceTilt` while paused swings it. + * + * @param {object} state { contact, normal, axis, binormal, k, crown, open, age, seed, fade } + */ + material.userData.sync = (state) => { + const c = settings.torrent; + const g = settings.global; + const u = material.uniforms; + + u.uContact.value.copy(state.contact); + u.uNormal.value.copy(state.normal); + u.uAxis.value.copy(state.axis); + u.uBinormal.value.copy(state.binormal); + + u.uK.value = state.k; + u.uOpen.value = state.open; + u.uAge.value = state.age; + u.uSeed.value = state.seed; + u.uFade.value = state.fade; + + u.uReach.value = c.fanReach; + u.uFanPower.value = c.fanPower; + // Scaled by the reflection's own normal component — the physics, not a + // second slider pretending to be it. + u.uCrown.value = c.fanCrown * state.crown; + u.uCrownFall.value = c.fanCrownFall; + u.uFingers.value = c.fanFingers; + u.uFingerDepth.value = c.fanFingerDepth * g.randomness; + u.uFingerScale.value = c.fanFingerScale * g.noiseFrequency; + u.uFingerSpeed.value = c.fanFingerSpeed * g.noiseSpeed; + u.uRipple.value = c.fanRipple * g.noiseStrength; + u.uRippleScale.value = c.fanRippleScale * g.noiseFrequency; + u.uRippleSpeed.value = c.fanRippleSpeed * g.noiseSpeed; + + u.uThin.value = c.fanThin; + u.uBody.value = c.fanBody; + u.uRimWidth.value = c.fanRimWidth; + u.uRimGain.value = c.fanRimGain; + u.uFresnel.value = c.fanFresnel * g.fresnel; + u.uSpecular.value = c.fanSpecular; + u.uGloss.value = c.fanGloss; + u.uOpacity.value = c.fanOpacity * g.opacity; + u.uGlow.value = c.fanGlow * g.glow; + u.uSoftFade.value = c.fanSoftFade; + + u.uColorSheet.value.copy(getColor(c.colorSheet)); + u.uColorThin.value.copy(getColor(c.colorSheetThin)); + u.uColorRim.value.copy(getColor(c.colorSheetRim)); + u.uColorSpray.value.copy(getColor(c.colorSheetSpray)); + }; + + return material; +} diff --git a/src/materials/EmberTrailMaterial.js b/src/materials/EmberTrailMaterial.js new file mode 100644 index 0000000..828f448 --- /dev/null +++ b/src/materials/EmberTrailMaterial.js @@ -0,0 +1,362 @@ +import { AdditiveBlending, Color, ShaderMaterial } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { getColor } from '../utils/color.js'; + +/* ====================================================================== */ +/* The flock path, mirrored */ +/* ====================================================================== */ +/** + * `agentAt()` and everything it stands on, copied out of `vfx/Swarm.js`. + * + * This is a **mirror**, and it is the one place in the ability where editing + * half the code silently breaks the other half — the same warning + * `vfx/Projectile.js` puts on `pathAt()` in its trail shader, for the same + * reason and with the same remedy: if the flock's path changes, this changes + * with it. + * + * The alternative was a ring buffer of past positions per bird. That is what + * the first attempt did, and it fails on two counts at once. It is a *record of + * metres*, so dragging `spacingSide` on a paused frame moves twenty-four birds + * and leaves twenty-four trails hanging in the air behind where they used to be + * — a direct **I1** violation you can see. And it needs a write per bird per + * frame on the CPU, which is exactly the per-frame work `Swarm` exists to avoid. + * + * A trail here is therefore not a recording. It is the *same closed-form flock* + * evaluated backwards in the bird's own clock, so it is guaranteed to pass + * through the bird, and every slider that reshapes the flock reshapes its + * trails on the same frame, paused or not. + * + * The uniform names are Swarm's own, and the ability hands this material the + * **same uniform objects** the flock's material holds — one write in + * `Swarm.update()` drives both, so the two can never drift out of step by a + * frame. + */ +export const FLOCK_PATH_GLSL = /* glsl */ ` + uniform float uSeed; + + uniform int uLeadMode; + uniform vec3 uLeadA; + uniform vec3 uLeadB; + uniform vec3 uForward; + uniform vec3 uSideAxis; + uniform float uLeadRise; + uniform float uLeadS; + uniform float uLeadRate; + uniform float uOrbitRadius; + uniform float uOrbitHeight; + uniform float uOrbitTurns; + + uniform vec3 uLattice; + uniform float uSpacingSide; + uniform float uSpacingUp; + uniform float uLag; + uniform float uJitter; + uniform float uChurn; + uniform float uBreathe; + uniform float uBreatheRate; + uniform float uWander; + uniform float uWanderScale; + uniform float uWanderSpeed; + uniform float uGather; + + /** Normalise, or fall back — a zero-length basis vector is a NaN ribbon. */ + vec3 safeNormalize(vec3 v, vec3 fallback) { + float l2 = dot(v, v); + return l2 > 1e-8 ? v * inversesqrt(l2) : fallback; + } + + vec3 leadAt(float s) { + if (uLeadMode == 2) { + float a = s * TAU * uOrbitTurns; + return uLeadB + vec3(cos(a), 0.0, sin(a)) * uOrbitRadius + vec3(0.0, uOrbitHeight, 0.0); + } + if (uLeadMode == 1) { + float t = clamp(s, -0.5, 1.0); + vec3 p = mix(uLeadA, uLeadB, t); + p.y += uLeadRise * sin(PI * clamp(t, 0.0, 1.0)); + return p; + } + return uLeadB; + } + + vec3 cellOf(float idx, vec3 L) { + vec3 cell = vec3( + mod(idx, L.x), + mod(floor(idx / L.x), L.y), + mod(floor(idx / (L.x * L.y)), L.z) + ); + vec3 shift = floor(hash31(uSeed * 13.0 + 0.7) * L); + return mod(cell + shift, L); + } + + vec3 agentAt(vec3 dice, float idx, float dt) { + vec3 L = max(vec3(1.0), floor(uLattice)); + vec3 cell = cellOf(idx, L); + vec3 centred = cell - (L - 1.0) * 0.5; + + float lagN = L.z > 1.0 ? cell.z / (L.z - 1.0) : 0.0; + float lag = uLag * lagN; + + float t = uTime + dt; + vec3 home = leadAt(uLeadS + (dt - lag) * uLeadRate); + + vec2 plane = vec2(centred.x * uSpacingSide, centred.y * uSpacingUp); + plane += (dice.xy - 0.5) * (2.0 * uJitter); + plane = rot2(t * uChurn + dice.z * TAU * 0.15 + uSeed) * plane; + plane *= 1.0 + uBreathe * sin(t * uBreatheRate + dice.y * TAU); + + vec3 pos = home + uSideAxis * plane.x + vec3(0.0, plane.y, 0.0); + pos += curlNoise(pos * uWanderScale + vec3(0.0, t * uWanderSpeed, 0.0) + dice.z * 17.0) * uWander; + + return mix(home, pos, clamp(uGather, 0.0, 1.0)); + } +`; + +/* ====================================================================== */ +/* The ribbon */ +/* ====================================================================== */ +/** + * One instanced camera-facing strip per bird. + * + * The strip's `position` is `(v, side, 0)` — `v` runs 0 at the tail to 1 at the + * head, `side` is -1 or +1 — and every metre of it is placed here. The vertex + * at `v` is the bird as it was `(1 - v) · uSpan` seconds ago, so the whole + * ribbon slides forward with the bird for free and there is nothing to + * integrate. + * + * The tail thins rather than dimming. A trail that fades uniformly reads as a + * light going out; one that *shortens and sharpens* reads as something that + * stopped being made — the same observation `vfx/Projectile.js` makes about its + * own trail burn, and it is worth more here than any amount of alpha ramp. + */ +const TRAIL_VERTEX = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + uniform float uTime; + + ${noiseGLSL} + ${FLOCK_PATH_GLSL} + + uniform float uSpan; // seconds of flight the tail reaches back over + uniform float uWidth; // metres, half-width at the head + uniform float uWidthTaper; // >1 sharpens the tail to a point + uniform float uLift; // metres the tail floats above the flown path + uniform float uSag; // metres the tail drops per second of age + uniform float uWobble; // metres of curl drift on the tail + uniform float uWobbleScale; // features per metre + uniform float uWobbleSpeed; + uniform float uReveal; // 0..1 — a bird's trail appears with the bird + uniform float uRevealSpread; // 0..1 width of that wave + uniform float uFade; // the whole ribbon's master fade + + attribute float aStrand; // which bird this instance belongs to + attribute float aSeed; // that bird's seed — mirrors the flock's + + varying float vAlong; // 0 tail, 1 head + varying float vAcross; // -1..1 across the ribbon + varying float vSeed; + varying float vViewZ; + varying float vAlpha; + + void main() { + float v = clamp(position.x, 0.0, 1.0); + float side = position.y; + + // Same hash the flock's vertex shader takes, off the same seed, so this + // ribbon is threaded through that bird and not a bird beside it. + vec3 dice = hash31(aSeed * 91.7 + 3.1); + + float back = -(1.0 - v) * max(uSpan, 0.0); + vec3 p = agentAt(dice, aStrand, back); + + // The tangent comes from a second evaluation a fixed step earlier rather + // than from the neighbouring strip node: the node spacing changes with + // uSpan, and a tangent that changes width when you drag the span is the + // sort of coupling that takes an afternoon to find. + const float H = 0.03; + vec3 behind = agentAt(dice, aStrand, back - H); + vec3 tangent = safeNormalize(p - behind, uForward); + + vec3 viewDir = safeNormalize(cameraPosition - p, vec3(0.0, 0.0, 1.0)); + vec3 across = safeNormalize(cross(tangent, viewDir), uSideAxis); + + float age = (1.0 - v) * max(uSpan, 0.0); + // Embers slump and drift apart behind the bird. Both terms are keyed off + // the age of the sample rather than off v, so they do not change shape when + // the span is dragged. + p.y += uLift * age - uSag * age * age; + p += curlNoise(p * uWobbleScale + vec3(0.0, uTime * uWobbleSpeed, 0.0) + dice.z * 29.0) * (uWobble * age); + + float w = uWidth * pow(v, max(uWidthTaper, 0.01)); + vec3 world = p + across * (side * w); + + float threshold = dice.z * (1.0 - clamp(uRevealSpread, 0.0, 1.0)); + vAlpha = smoothstep(threshold, threshold + max(uRevealSpread, 1e-3), clamp(uReveal, 0.0, 1.0)) * uFade; + + vAlong = v; + vAcross = side; + vSeed = aSeed; + + vec4 mv = modelViewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const TRAIL_FRAGMENT = /* glsl */ ` + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + + uniform vec3 uColorA; + uniform vec3 uColorB; + uniform vec3 uColorC; + uniform vec3 uColorD; + uniform float uTint; // where in the gradient the head sits + uniform float uTintAlong; // how far down the gradient the tail walks + uniform float uTintJitter; // ±per-bird walk along it + uniform float uCore; // how tightly light crowds the centre line + uniform float uHeadBias; // >0 keeps the brightness near the bird + uniform float uOpacity; + uniform float uGlow; + uniform float uSoftFade; // metres of depth feather against solid geometry + + varying float vAlong; + varying float vAcross; + varying float vSeed; + varying float vViewZ; + varying float vAlpha; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + if (vAlpha < 0.004) discard; + + // Triangular across the ribbon, raised to a power: the strip has two + // vertices per node, so anything smoother than this has to come out of the + // exponent rather than out of more geometry. + float body = pow(max(0.0, 1.0 - abs(vAcross)), max(uCore, 0.05)); + float along = mix(1.0, vAlong, clamp(uHeadBias, 0.0, 1.0)); + + float alpha = body * along * vAlpha * uOpacity; + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + float t = clamp( + uTint + (1.0 - vAlong) * uTintAlong + uTintJitter * (hash11(vSeed * 51.3) - 0.5) * 2.0, + 0.0, + 1.0 + ); + vec3 colour = gradient4(uColorA, uColorB, uColorC, uColorD, t); + colour *= uGlow * uGlobalGlow; + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/** + * The path uniforms this material borrows from the flock. + * + * Listed rather than derived by prefix, because "everything the swarm happens + * to own" would also drag in the flock's own colour pickers and silently make + * the trail gradient, in **I5**'s terms, a derived colour rather than an + * authored one. + */ +const FLOCK_UNIFORMS = [ + 'uSeed', + 'uLeadMode', + 'uLeadA', + 'uLeadB', + 'uForward', + 'uSideAxis', + 'uLeadRise', + 'uLeadS', + 'uLeadRate', + 'uOrbitRadius', + 'uOrbitHeight', + 'uOrbitTurns', + 'uLattice', + 'uSpacingSide', + 'uSpacingUp', + 'uLag', + 'uJitter', + 'uChurn', + 'uBreathe', + 'uBreatheRate', + 'uWander', + 'uWanderScale', + 'uWanderSpeed', + 'uGather' +]; + +/** + * Build the trail material for a flock. + * + * @param {object} flockUniforms the `Swarm`'s own `material.uniforms`. The + * twenty-five entries that describe the flock's path are **shared by + * reference**, not copied: `Swarm.update()` writes them once and both + * materials see the write, which is the only way to guarantee a ribbon and + * its bird cannot part company on a frame where one was updated and the + * other was not. + * @returns {ShaderMaterial} + */ +export function createEmberTrailMaterial(flockUniforms) { + const shared = {}; + for (const name of FLOCK_UNIFORMS) shared[name] = flockUniforms[name]; + + return new ShaderMaterial({ + name: 'EmberTrail', + vertexShader: TRAIL_VERTEX, + fragmentShader: TRAIL_FRAGMENT, + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + toneMapped: false, + uniforms: sharedUniforms({ + ...shared, + + uSpan: { value: 0.34 }, + uWidth: { value: 0.09 }, + uWidthTaper: { value: 1.5 }, + uLift: { value: 0.35 }, + uSag: { value: 0.4 }, + uWobble: { value: 0.5 }, + uWobbleScale: { value: 0.7 }, + uWobbleSpeed: { value: 0.6 }, + uReveal: { value: 1 }, + uRevealSpread: { value: 0.35 }, + uFade: { value: 1 }, + + uColorA: { value: new Color(1, 1, 1) }, + uColorB: { value: new Color(1, 1, 1) }, + uColorC: { value: new Color(1, 1, 1) }, + uColorD: { value: new Color(1, 1, 1) }, + uTint: { value: 0.1 }, + uTintAlong: { value: 0.7 }, + uTintJitter: { value: 0.15 }, + uCore: { value: 1.6 }, + uHeadBias: { value: 0.45 }, + uOpacity: { value: 1 }, + uGlow: { value: 1.6 }, + uSoftFade: { value: 0.4 } + }) + }); +} + +/** Four pickers into the trail gradient. Memoised through `getColor`. */ +export function setTrailGradient(material, a, b, c, d) { + const u = material.uniforms; + u.uColorA.value.copy(getColor(a)); + u.uColorB.value.copy(getColor(b)); + u.uColorC.value.copy(getColor(c)); + u.uColorD.value.copy(getColor(d)); + return material; +} diff --git a/src/materials/FeatherMaterial.js b/src/materials/FeatherMaterial.js new file mode 100644 index 0000000..375a59e --- /dev/null +++ b/src/materials/FeatherMaterial.js @@ -0,0 +1,603 @@ +import { + Color, + DoubleSide, + InstancedBufferAttribute, + InstancedBufferGeometry, + MeshStandardMaterial, + PlaneGeometry, + Sphere, + Vector2, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * Feathers that actually know how to fall — the material behind Featherfall. + * + * ## The trick lives here, not in the ability + * + * A feather does not sink. It **stalls, slips sideways, catches and glides**, + * and it does all of that because a thin plate falling through air is + * aerodynamically unstable in two distinct ways. Real flat-plate descent has + * two regimes — *fluttering*, a side-to-side oscillation with a stall at each + * extreme, and *tumbling*, a continuous end-over-end rotation that drifts + * steadily to one side — and which one a given plate falls into depends on its + * aspect ratio and its Reynolds number. Both are implemented, both are in the + * air at once, and which one a feather picks is a per-instance dice roll + * against `tumbleShare`. That is what "no two descend the same way" means: not + * jitter on one motion, two different motions. + * + * The flutter is written as one closed form and it is worth stating, because + * everything else follows from it: + * + * ``` + * lateral(τ) = (swing / ω) · (sin φ − sin φ₀) φ = ωτ + φ₀ + * v_y(τ) = sink · (1 − lift · cos²φ) + * drop(τ) = sink·(1 − lift/2)·τ − sink·lift·(sin 2φ − sin 2φ₀) / 4ω + * ``` + * + * `cos φ` is the horizontal velocity, so `cos²φ` is largest exactly where the + * feather is moving fastest sideways — which is where a plate generates lift + * and where it therefore falls *slowest*. At `cos φ = 0` the horizontal motion + * reverses, the lift vanishes and the feather drops at its full sink rate. + * That is the stall, twice a cycle, and it is the single detail that separates + * this from a sine wave applied to a falling sprite. The vertical drop is the + * analytic integral of the vertical velocity, so the two are exactly + * consistent and the descent cannot drift out of step with the swing. + * + * ## Nothing integrates, so everything is a live slider + * + * Position is a **closed-form function of `(age − release, seed)`** evaluated + * in the vertex shader against the live uniforms. There is no CPU state, no + * Euler step and no per-feather record: an integrator has already spent the + * old `lift` and physically cannot re-fly a feather that is halfway down when + * you drag the slider with the clock stopped. This can, and does — pause with + * **P** and take `lift` to zero and two hundred feathers stop gliding and drop + * where they hang. + * + * It also means the *shape* of the feather is built in the vertex shader from + * `uv` alone, so vane width, taper, cup, arch and the barb ripple are uniforms + * rather than baked geometry. The first version baked the outline and had to + * rebuild it whenever a proportion moved, which is a hash, a comparison and an + * allocation on a frame where a slider changed — all avoidable, since a + * parametric outline costs the vertex shader about eight instructions. + * + * `flatShading` is on, which is what makes that legal: with no `vNormal` + * varying, every view-dependent term reads the face normal that + * `` derives from screen derivatives, so a mesh + * deformed entirely in the vertex stage gets correct normals for free. The + * same argument `PetalMaterial` makes, for the same reason. + * + * ## The landing + * + * A closed-form fall has no collision, so the landing is solved instead. The + * *mean* sink rate is known in advance — `sink·(1 − lift/2)` for a flutterer, + * `tumbleSink` for a tumbler — so the time at which a feather has used up its + * release height is one divide, and the whole model is evaluated at + * `min(τ, τ_land)`. Everything freezes together: swing, drop, pitch and spin, + * all on the same frame, which is what makes it read as coming to rest rather + * than as being switched off. Over the last `settleTime` seconds the attitude + * blends to the nearest flat orientation, so the feather lies down instead of + * standing on its edge. The residual few centimetres of oscillation left over + * by the mean-rate approximation are absorbed by a clamp against the floor. + */ + +/** + * The feather's geometry: a parametric sheet, and nothing else. + * + * `position` is thrown away by the vertex shader — the only attribute that + * matters is `uv`, which carries `(across 0..1, along 0..1)`. That is the + * whole point: the outline is a function of two parameters and a dozen live + * uniforms, so there is no geometry to rebuild when a proportion changes. + * + * The plane's own attributes are handed to the instanced geometry by + * reference and the plane is deliberately **not** disposed: `dispose()` would + * fire a teardown for the very buffers being kept. It has never been uploaded, + * so letting it fall out of scope costs nothing. + * + * @param {number} capacity hard ceiling on feathers in one cast + * @param {number} across quads across the vane; 5 is enough for a cupped + * cross-section and the sixth is invisible + * @param {number} along quads up the rachis; the barb ripple is the + * highest-frequency thing on the outline and 14 + * samples it about twice per wave + */ +export function createFeatherGeometry(capacity, across = 5, along = 14) { + const sheet = new PlaneGeometry(1, 1, across, along); + + const geometry = new InstancedBufferGeometry(); + geometry.index = sheet.index; + geometry.setAttribute('position', sheet.getAttribute('position')); + geometry.setAttribute('normal', sheet.getAttribute('normal')); + geometry.setAttribute('uv', sheet.getAttribute('uv')); + + // Four unitless dice per feather. Everything else the shader needs is + // hashed out of these, so a cast writes 4N floats once and never again. + const seeds = new Float32Array(capacity * 4); + geometry.setAttribute('aSeed', new InstancedBufferAttribute(seeds, 4)); + + geometry.instanceCount = 0; + // Every vertex is placed by the shader, so the real bounds are unknowable + // here. The mesh is `frustumCulled = false`; this only stops three from + // computing a bounding sphere of NaN off the unit sheet. + geometry.boundingSphere = new Sphere(new Vector3(), 1); + return geometry; +} + +/** + * The material. + * + * @param {object} environment the app's `Environment`, for the shadow/CSM patch + */ +export function createFeatherMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.74, + metalness: 0.0, + flatShading: true, + transparent: true, + // A feather has two faces and you see both of them in one descent, twice a + // second. Culling either one makes the tumble blink. + side: DoubleSide, + depthWrite: true + }); + + const uniforms = { + /* --- the clock. The ability's own age, not the global one: the descent + * has to freeze when the sandbox is paused and resume where it was. --- */ + uAge: { value: 0 }, + uFade: { value: 1 }, + uLightDir: frame.uLightDir, + + /* --- where the flock is released, metres --- */ + uOrigin: { value: new Vector3() }, + uRadius: { value: 5 }, + uCeiling: { value: 4.6 }, + uCeilingVary: { value: 0.4 }, + uStagger: { value: 1.9 }, + uFloor: { value: 0.012 }, + uWind: { value: new Vector2() }, + + /* --- the feather itself, in units of its own length --- */ + uSize: { value: 0.26 }, + uSizeVary: { value: 0.45 }, + uVaneWidth: { value: 0.19 }, + uWidthBias: { value: 0.85 }, + uTaper: { value: 0.65 }, + uLeadFrac: { value: 0.62 }, + uQuill: { value: 0.16 }, + uCup: { value: 0.55 }, + uArch: { value: 0.1 }, + uBarb: { value: 0.06 }, + uBarbFreq: { value: 22 }, + uPivot: { value: 0.42 }, + + /* --- the flutter regime --- */ + uSink: { value: 1.05 }, + uLift: { value: 0.6 }, + uSwing: { value: 0.95 }, + uFlutterRate: { value: 4.2 }, + uFlutterVary: { value: 0.55 }, + uPitch: { value: 0.95 }, + uSpin: { value: 0.35 }, + + /* --- the tumbling regime --- */ + uTumbleShare: { value: 0.28 }, + uTumbleRate: { value: 7.5 }, + uTumbleDrift: { value: 0.55 }, + uTumbleSink: { value: 1.15 }, + uTumbleBob: { value: 0.06 }, + + /* --- the catch --- */ + uCatchGain: { value: 0.55 }, + uCatchWidth: { value: 0.55 }, + uCatchWindow: { value: 3.2 }, + + /* --- the ends of the descent --- */ + uBirthFade: { value: 0.35 }, + uSettleTime: { value: 0.9 }, + + /* --- shading --- */ + uColorQuill: { value: new Color() }, + uColorVane: { value: new Color() }, + uColorTip: { value: new Color() }, + uColorGlow: { value: new Color() }, + uRachis: { value: 0.55 }, + uBarbLines: { value: 0.35 }, + uBarbCount: { value: 26 }, + uGrain: { value: 0.18 }, + uGrainScale: { value: 6 }, + uFresnel: { value: 0.7 }, + uFresnelPower: { value: 2.4 }, + uTranslucency: { value: 1.6 }, + uTranslucencyPower: { value: 3.2 }, + uGlow: { value: 0.8 }, + uOpacity: { value: 1 } + }; + + environment.registerShadowCasterWithPatch(material, (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + `#include + attribute vec4 aSeed; // four unitless dice, per feather + uniform float uAge; + uniform float uFade; + uniform vec3 uOrigin; + uniform float uRadius; + uniform float uCeiling; + uniform float uCeilingVary; + uniform float uStagger; + uniform float uFloor; + uniform vec2 uWind; + uniform float uSize; + uniform float uSizeVary; + uniform float uVaneWidth; + uniform float uWidthBias; + uniform float uTaper; + uniform float uLeadFrac; + uniform float uQuill; + uniform float uCup; + uniform float uArch; + uniform float uBarb; + uniform float uBarbFreq; + uniform float uPivot; + uniform float uSink; + uniform float uLift; + uniform float uSwing; + uniform float uFlutterRate; + uniform float uFlutterVary; + uniform float uPitch; + uniform float uSpin; + uniform float uTumbleShare; + uniform float uTumbleRate; + uniform float uTumbleDrift; + uniform float uTumbleSink; + uniform float uTumbleBob; + uniform float uCatchGain; + uniform float uCatchWidth; + uniform float uCatchWindow; + uniform float uBirthFade; + uniform float uSettleTime; + varying float vAlong; + varying float vAcross; + varying float vDice; + varying float vRest; + varying float vAlpha; + varying vec3 vFeatherWorld; + + /* Decorrelated randoms out of the four dice. Deliberately not a noise + * function: this is called six times per vertex and every one of them + * wants a different, stable, unitless number rather than a field. */ + float featherRand(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); + }` + ) + .replace( + '#include ', + `#include + { + /* No turn constant is declared here, and that is not an oversight. + * + * This is a patched MeshStandardMaterial, so three's + * chunk is already above us and it does + * + * #define PI2 6.283185307179586 + * + * A local "const float PI2 = ..." is therefore not a shadowed + * declaration, it is a macro expansion: the preprocessor rewrites it + * to "const float 6.283185307179586 = 6.28318530718;" and the whole + * vertex shader fails to compile with a syntax error pointing at a + * number that appears nowhere in this file. The feathers then do not + * draw at all — and nothing catches it, because npm run check + * compiles no GLSL and the reserved-word sweep only looks for GLSL ES + * 3.00 keywords, not for three's own macros. It took a live GL + * context to find. + * + * PI2 below is three's, same quantity, more digits. The names to + * avoid in any patched standard material are PI, PI2, PI_HALF, + * RECIPROCAL_PI, RECIPROCAL_PI2, EPSILON and LOG2 — see + * three/src/renderers/shaders/ShaderChunk/common.glsl.js. */ + + float r1 = aSeed.x; + float r2 = aSeed.y; + float r3 = aSeed.z; + float r4 = aSeed.w; + float r5 = featherRand(aSeed.xy); + float r6 = featherRand(aSeed.yz + 3.71); + float r7 = featherRand(aSeed.zw + 11.37); + float r8 = featherRand(aSeed.xw + 5.13); + + /* ---- where and when this one was let go ---- */ + // sqrt on the radial roll, or the flock crowds the middle of the + // circle: uniform in r gives uniform in *radius*, not in area. + float bearing = PI2 * r1; + float radius = uRadius * sqrt(r2); + vec3 base = uOrigin + vec3(cos(bearing) * radius, 0.0, sin(bearing) * radius); + + float y0 = uCeiling * (1.0 - uCeilingVary * r3); + float release = uStagger * r4; + float tau = max(0.0, uAge - release); + + /* ---- which regime ---- */ + // Not a blend of two motions: a per-feather choice between them. + float mode = step(r5, uTumbleShare); // 1 = tumbling, 0 = fluttering + + /* ---- the landing time, from the mean sink rate of that regime ---- */ + float meanFlutter = max(0.02, uSink * (1.0 - 0.5 * uLift)); + float meanSink = mix(meanFlutter, max(0.02, uTumbleSink), mode); + float land = max(0.05, (y0 - uFloor) / meanSink); + float te = min(tau, land); + + /* ---- FLUTTER: stall, sideslip, glide ---- */ + float omega = max(0.15, uFlutterRate * (1.0 - 0.5 * uFlutterVary + uFlutterVary * r6)); + float phi0 = PI2 * r7; + float phi = omega * te + phi0; + float slipF = (uSwing / omega) * (sin(phi) - sin(phi0)); + float dropF = uSink * (1.0 - 0.5 * uLift) * te + - uSink * uLift * (sin(2.0 * phi) - sin(2.0 * phi0)) / (4.0 * omega); + // The plate's attitude tracks its horizontal velocity, so it flips + // sign at every stall. That flip is the whole silhouette of a + // falling leaf and it costs one cosine. + float angleF = uPitch * cos(phi); + + /* ---- TUMBLE: end over end, and it drifts ---- */ + float spinDir = r6 < 0.5 ? -1.0 : 1.0; + float rate = uTumbleRate * (0.7 + 0.6 * r7) * spinDir; + float slipT = uTumbleDrift * spinDir * te; + float dropT = uTumbleSink * te + uTumbleBob * (sin(rate * te + phi0) - sin(phi0)); + float angleT = rate * te; + + float slip = mix(slipF, slipT, mode); + float drop = mix(dropF, dropT, mode); + float angle = mix(angleF, angleT, mode); + + /* ---- the catch: one gust, at its own moment, for each feather ---- */ + // A Gaussian bump subtracted from the drop, so the feather rises, + // hangs, and resumes. Doing it as a bump on *position* rather than + // on velocity is what keeps the landing time honest — the integral + // of the bump over the whole descent is finite and small. + float when = uCatchWindow * r8; + float gust = exp(-pow((te - when) / max(0.05, uCatchWidth), 2.0)); + drop -= uCatchGain * gust * (0.35 + 0.65 * r5); + + /* ---- settle: the last second before it is down ---- */ + float rest = smoothstep(land - max(0.02, uSettleTime), land, tau); + // Snap toward the nearest face-up-or-face-down attitude rather than + // toward zero: a tumbler that unwinds three revolutions on the way + // to the floor reads as rewinding, which is the one thing a feather + // never does. + float lying = floor(angle / PI + 0.5) * PI; + angle = mix(angle, lying, rest); + + /* ---- assemble ---- */ + vec3 heading = vec3(cos(bearing), 0.0, sin(bearing)); + vec3 pos = base + + heading * slip + + vec3(uWind.x, 0.0, uWind.y) * te + + vec3(0.0, max(uFloor, y0 - drop), 0.0); + + // The feather's own frame: long axis, face normal, and the vane + // across. Rotating the pair about the horizontal perpendicular by + // angle is exactly "pitch"; at angle 0 the feather lies flat. + float ca = cos(angle); + float sa = sin(angle); + vec3 axis = heading * ca + vec3(0.0, sa, 0.0); + vec3 face = heading * -sa + vec3(0.0, ca, 0.0); + vec3 vane = normalize(cross(face, axis)); + + // A slow roll about the rachis, frozen with everything else once it + // is down. Small: a feather that spins like a propeller is a seed. + float roll = uSpin * te * (r8 * 2.0 - 1.0) * (1.0 - rest); + float cr = cos(roll); + float sr = sin(roll); + vec3 faceR = face * cr + vane * sr; + vec3 vaneR = vane * cr - face * sr; + + /* ---- the outline, from uv and nothing else ---- */ + float along = uv.y; + float across = uv.x * 2.0 - 1.0; + + float bare = smoothstep(uQuill, uQuill + 0.09, along); + float prof = pow(max(sin(PI * pow(clamp(along, 0.0, 1.0), uWidthBias)), 0.0), uTaper); + // The barb ripple runs *diagonally*: the phase is swept by across + // as well as along, because barbs leave the rachis swept back. A + // ripple in along alone gives a scalloped edge on both vanes at + // the same height, which reads as a serrated leaf. + float ripple = 1.0 + uBarb * sin(along * uBarbFreq * PI2 + across * 2.7); + float halfW = uVaneWidth * prof * bare * ripple; + float wide = across < 0.0 ? uLeadFrac : 1.0; + + float lx = across * halfW * wide; + float ly = along - uPivot; + // The cup curls the outer edge of each vane toward the underside; + // the arch bows the whole feather along its length. Two marks, and + // the cup is the one that makes it catch air. + float lz = -uCup * halfW * across * across + uArch * (along - uPivot) * (along - uPivot); + + float span = uSize * (1.0 + uSizeVary * (r3 - 0.5)); + + // Authored in world space. The mesh and its group are both held at + // identity by the ability, so object space *is* world space here — + // the same contract the bolt ribbon works under, and the reason + // uOrigin can be a world point rather than a local one. + transformed = pos + (vaneR * lx + axis * ly + faceR * lz) * span; + + /* ---- what the fragment stage needs ---- */ + vAlong = along; + vAcross = across; + vDice = r5; + vRest = rest; + vAlpha = uFade * smoothstep(0.0, max(0.02, uBirthFade), tau); + vFeatherWorld = (modelMatrix * vec4(transformed, 1.0)).xyz; + }` + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform vec3 uLightDir; + uniform vec3 uColorQuill; + uniform vec3 uColorVane; + uniform vec3 uColorTip; + uniform vec3 uColorGlow; + uniform float uRachis; + uniform float uBarbLines; + uniform float uBarbCount; + uniform float uGrain; + uniform float uGrainScale; + uniform float uFresnel; + uniform float uFresnelPower; + uniform float uTranslucency; + uniform float uTranslucencyPower; + uniform float uGlow; + uniform float uOpacity; + varying float vAlong; + varying float vAcross; + varying float vDice; + varying float vRest; + varying float vAlpha; + varying vec3 vFeatherWorld; + ${noiseGLSL}` + ) + // Injected once the normal is resolved: with flatShading there is no + // vNormal varying, so every view-dependent term below has to read the + // face normal derives from derivatives. + .replace( + '#include ', + `#include + { + vec3 N = normalize(normal); + vec3 V = normalize(vViewPosition); + float rim = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), uFresnelPower); + float side = abs(vAcross); + + vec3 body = mix(uColorQuill, uColorVane, smoothstep(0.05, 0.45, vAlong)); + body = mix(body, uColorTip, smoothstep(0.5, 1.0, vAlong)); + + // The rachis, and the barbs leaving it. The barb phase is swept by + // vAlong for the same reason the outline ripple is: barbs are not + // perpendicular to the shaft, they rake back toward the tip, and a + // comb of parallel lines is the tell that it was drawn rather than + // grown. + float shaft = pow(1.0 - side, 9.0); + float comb = cos(side * uBarbCount * PI - vAlong * 5.5); + float barbs = smoothstep(0.55, 1.0, abs(comb)) * (1.0 - side * 0.6); + body *= 1.0 - uBarbLines * barbs * 0.5; + body = mix(body, uColorQuill, clamp(shaft * uRachis, 0.0, 1.0)); + + // Grain in world space, so a flock does not read as one feather + // stamped two hundred times. + body *= 1.0 + uGrain * (fbm3(vFeatherWorld * uGrainScale + vDice * 17.0) - 0.5); + + diffuseColor.rgb *= body; + + // A feather is one cell thick at the edge and the read of a flock + // against a low sun is almost entirely the light coming through it. + float through = pow(clamp(dot(V, -uLightDir), 0.0, 1.0), uTranslucencyPower); + vec3 glow = uColorGlow * through * uTranslucency * (0.3 + 0.7 * vAlong); + glow += uColorTip * rim * uFresnel; + glow *= uGlow; + // Soft ceiling: both terms above peak at a grazing angle and they + // stack, and a flock on the silhouette otherwise sums past white and + // smears the bloom pass across the whole zone. + glow /= 1.0 + glow * 0.35; + totalEmissiveRadiance += glow; + + // The outer barbs separate rather than ending in a clean curve, and + // the ones that have landed press flat and lose a little of it. + float split = smoothstep(0.72, 1.0, side) * (0.35 + 0.65 * abs(comb)); + float alpha = vAlpha * uOpacity * (1.0 - split * 0.55 * (1.0 - vRest * 0.5)); + diffuseColor.a = clamp(diffuseColor.a * alpha, 0.0, 1.0); + }` + ); + }); + + material.userData.uniforms = uniforms; + + /** + * Pull the whole flutter model, the outline and the palette from live + * settings. Called every frame, zero-length frames included — see **I1**. + * + * @param {THREE.Vector3} origin the zone centre, world space + * @param {number} age seconds since the flock was released + * @param {number} fade 1 while the feathers are lit, ramping to 0 at the end + */ + material.userData.sync = (origin, age, fade) => { + const c = settings.featherfall; + const g = settings.global; + + uniforms.uAge.value = age; + uniforms.uFade.value = fade; + uniforms.uOrigin.value.copy(origin); + + uniforms.uRadius.value = c.zoneRadius; + uniforms.uCeiling.value = c.ceiling; + uniforms.uCeilingVary.value = c.ceilingVary; + uniforms.uStagger.value = c.stagger; + uniforms.uFloor.value = c.floorHeight; + uniforms.uWind.value.set(c.windX, c.windZ); + + uniforms.uSize.value = c.featherSize; + uniforms.uSizeVary.value = c.featherSizeVary; + uniforms.uVaneWidth.value = c.vaneWidth; + uniforms.uWidthBias.value = c.widthBias; + uniforms.uTaper.value = c.taper; + uniforms.uLeadFrac.value = c.leadFrac; + uniforms.uQuill.value = c.quill; + uniforms.uCup.value = c.cup; + uniforms.uArch.value = c.arch; + uniforms.uBarb.value = c.barb; + uniforms.uBarbFreq.value = c.barbFreq; + uniforms.uPivot.value = c.pivot; + + // `global.speed` is the sandbox's master rate control and the descent is + // the only thing in this ability that has a rate, so it lands here rather + // than on the phase clock: scaling the clock would also scale the stagger + // and the release window, which are event times, not speeds. + uniforms.uSink.value = c.sink * g.speed; + uniforms.uLift.value = c.lift; + uniforms.uSwing.value = c.swing * g.speed; + uniforms.uFlutterRate.value = c.flutterRate; + uniforms.uFlutterVary.value = c.flutterVary; + uniforms.uPitch.value = c.pitch; + uniforms.uSpin.value = c.spin; + + uniforms.uTumbleShare.value = c.tumbleShare; + uniforms.uTumbleRate.value = c.tumbleRate; + uniforms.uTumbleDrift.value = c.tumbleDrift * g.speed; + uniforms.uTumbleSink.value = c.tumbleSink * g.speed; + uniforms.uTumbleBob.value = c.tumbleBob; + + uniforms.uCatchGain.value = c.catchGain; + uniforms.uCatchWidth.value = c.catchWidth; + uniforms.uCatchWindow.value = c.catchWindow; + + uniforms.uBirthFade.value = c.birthFade; + uniforms.uSettleTime.value = c.settleTime; + + uniforms.uColorQuill.value.copy(getColor(c.colorQuill)); + uniforms.uColorVane.value.copy(getColor(c.colorVane)); + uniforms.uColorTip.value.copy(getColor(c.colorTip)); + uniforms.uColorGlow.value.copy(getColor(c.colorGlow)); + uniforms.uRachis.value = c.rachis; + uniforms.uBarbLines.value = c.barbLines; + uniforms.uBarbCount.value = c.barbCount; + uniforms.uGrain.value = c.grain; + uniforms.uGrainScale.value = c.grainScale * g.noiseFrequency; + uniforms.uFresnel.value = c.fresnel; + uniforms.uFresnelPower.value = c.fresnelPower; + uniforms.uTranslucency.value = c.translucency; + uniforms.uTranslucencyPower.value = c.translucencyPower; + uniforms.uGlow.value = c.glow * g.glow; + uniforms.uOpacity.value = c.opacity * g.opacity; + }; + + return material; +} diff --git a/src/materials/FirewalkMaterial.js b/src/materials/FirewalkMaterial.js new file mode 100644 index 0000000..13c8247 --- /dev/null +++ b/src/materials/FirewalkMaterial.js @@ -0,0 +1,658 @@ +import { + AdditiveBlending, + BufferAttribute, + Color, + InstancedBufferAttribute, + InstancedBufferGeometry, + NormalBlending, + ShaderMaterial, + Sphere, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { getColor } from '../utils/color.js'; + +/** + * FirewalkMaterial — the two shaders behind Firewalk, and the gait they share. + * + * ## Why there is a shared placement chunk + * + * Firewalk draws two instanced meshes: a flat sole on the floor and a short + * pillar of flame standing on it. They are two programs because one is a ground + * quad and the other is a billboard, but they must agree on **where the print + * is** to within nothing at all — a pillar standing a centimetre off its own + * footprint is immediately, obviously wrong, and it is wrong on every frame the + * two are updated a step apart. + * + * So the gait lives in `PRINT_PLACEMENT_GLSL`, both vertex stages inject it, + * and `createFirewalkPlacement()` hands both materials the **same uniform + * objects** — not copies. One write per frame drives both, exactly the trick + * `EmberTrailMaterial` uses to nail a ribbon to its bird. + * + * The alternative — resolving the print positions on the CPU and pushing them + * into an instanced attribute — was written first and thrown away twice over. + * It is a per-frame write of *metres* into a buffer, so `stride` and `stepLength` + * stop being live the moment you stop resolving them (invariant I1 by + * discipline rather than by construction), and it needs the two meshes updated + * in lockstep for exactly the reason above. Here a print's position is + * `origin + forward·(start + i·step) + side·(±stride)` evaluated in the vertex + * shader, so dragging `stride` on a paused frame walks the whole trail sideways + * and takes the pillars with it. + * + * ## What a print carries + * + * Four instanced floats, and not one of them is a dimension: + * + * - `aIndex` — which footfall this is. Its **parity is the gait**: even prints + * go left, odd prints right. Nothing else in the file knows about feet. + * - `aSeed` — a dice roll, so the grain and the sway differ print to print. + * - `aWander` — a second dice roll, −1..1, multiplied by a live `uWander` in + * metres. It is an attribute rather than a hash of the seed so that the CPU + * knows the *exact* offset a print was given: the light, the sparks and the + * volume hull all have to stand on the print the shader drew, and a + * JavaScript re-implementation of a GLSL hash agrees to about five decimal + * places on a good day and to none at all on a driver that runs `mediump`. + * - `aBirth` — the timestamp the front crossed it, or a negative number if it + * has not been stepped on yet. An unlit print is culled in the vertex shader + * rather than scaled to zero, because a degenerate quad still rasterises a + * seam of fragments on some drivers and the whole trail was faintly visible + * before the cast reached it. + * + * The three geometry helpers share **one set of attribute objects** between the + * two meshes, for the same reason the uniforms are shared: three keys its GPU + * buffers by attribute identity, so this is also one upload rather than two. + */ + +/* ====================================================================== */ +/* The gait */ +/* ====================================================================== */ +/** + * Where footfall `i` is, in world metres, resolved from live uniforms. + * + * `printSide()` is the whole silhouette trick reduced to one line: a walk is + * not a line of marks down the middle, it is two lines of marks either side of + * the middle, taken in turn. The first version put every print on the centre + * line with a lateral jitter and it read as scorch blotches — the eye needs the + * *regular alternation* to reconstruct a body. Stride is what makes it a walk, + * so it is a slider, and at zero the trail collapses back into blotches, which + * is a useful thing to be able to see. + * + * Self-contained: no noise library, no shared helpers. Both vertex stages inject + * it and nothing else. + */ +export const PRINT_PLACEMENT_GLSL = /* glsl */ ` + uniform vec3 uOrigin; // world, on the floor, where the walk starts + uniform vec3 uForward; // unit, downrange + uniform vec3 uSide; // unit, lateral + uniform float uStart; // metres before the first print + uniform float uStep; // metres between successive footfalls + uniform float uStride; // metres from the centre line to a print + uniform float uWander; // metres of per-print lateral slop + uniform float uNow; // the ability's age, seconds + + attribute float aIndex; // footfall number; its parity is the foot + attribute float aSeed; // dice roll + attribute float aWander; // dice roll, -1..1, in units of uWander + attribute float aBirth; // seconds; negative until the front crosses it + + /** -1 for a left print, +1 for a right one. */ + float printSide(float index) { + return mod(index, 2.0) < 0.5 ? -1.0 : 1.0; + } + + vec3 printCentre(float index, float wander) { + float lateral = printSide(index) * uStride + wander * uWander; + return uOrigin + uForward * (uStart + index * uStep) + uSide * lateral; + } +`; + +/* ====================================================================== */ +/* The sole */ +/* ====================================================================== */ +/** + * The footprint itself: one instanced ground quad per print, carrying a signed + * distance field of a sole. + * + * The field is authored **right-footed** and a left print is drawn by mirroring + * its own local x on the way into the fragment stage. That is one multiply, and + * it is the only reason two feet are not two sets of constants that drift apart + * the first time somebody tunes the arch. + * + * Four pieces, unioned: a heel, a waist pushed to the outer edge (the arch is + * the *absence* on the medial side, which is what makes the shape read as a + * foot rather than as a bean), a ball, and five separate toes. The toes are + * unioned with a hard `min` and everything else with a `smoothUnion`, because + * toes that merge into the ball are a mitten — and at the sizes this draws at, + * that reads as a paw print, which is a different ability. + */ +const SOLE_VERTEX = /* glsl */ ` + ${PRINT_PLACEMENT_GLSL} + + uniform float uFootLength; // metres, heel to the end of the toes + uniform float uFootWidth; // metres, across the ball + uniform float uPad; // metres of quad outside the sole, for the scorch halo + uniform float uToeOut; // radians each print splays away from the line of travel + uniform float uLift; // metres above the floor + + varying vec2 vSole; // metres in the print's own frame, right-footed + varying vec3 vWorld; + varying float vAge; // seconds since this print was stepped on + varying float vSeed; + varying float vViewZ; + + void main() { + // Not stepped on yet. Push the whole quad outside the frustum rather than + // collapsing it: a zero-area quad still lights a seam of fragments. + if (aBirth < 0.0) { + vSole = vec2(0.0); + vWorld = vec3(0.0); + vAge = -1.0; + vSeed = 0.0; + vViewZ = -1.0; + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + float side = printSide(aIndex); + vec2 local = vec2(position.x * (uFootWidth + 2.0 * uPad), position.z * (uFootLength + 2.0 * uPad)); + + // Toe-out. Negated against the side so both feet splay *away* from the line + // of travel; without the sign the left foot toes in and the walker limps. + float a = -uToeOut * side; + float ca = cos(a); + float sa = sin(a); + vec2 turned = vec2(local.x * ca - local.y * sa, local.x * sa + local.y * ca); + + vec3 world = printCentre(aIndex, aWander) + uSide * turned.x + uForward * turned.y; + world.y = uLift; + + // Mirror into the right-footed frame the SDF is authored in. + vSole = vec2(local.x * side, local.y); + vWorld = world; + vAge = uNow - aBirth; + vSeed = aSeed; + + vec4 mv = modelViewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const SOLE_FRAGMENT = /* glsl */ ` + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + + uniform float uFootLength; + uniform float uFootWidth; + uniform float uHeelWidth; // heel as a fraction of the ball + uniform float uBallWidth; // ball as a fraction of the nominal width + uniform float uArchCut; // 0..1 how far the medial arch is eaten in + uniform float uSoleRound; // metres of smooth union between heel, waist and ball + uniform float uToeSize; // metres, the big toe + uniform float uToeSpread; // how far the toe row fans across the width + uniform float uToeGap; // metres the toe row sits ahead of the ball + + uniform float uRim; // metres of ember rim inside the outline + uniform float uHalo; // metres of scorch outside it + uniform float uEdge; // metres of feather on the outline + uniform float uRollTime; // seconds the ignition takes to run heel to toe + uniform float uFlashTime; // seconds a freshly lit point stays white + uniform float uCoolTime; // seconds it takes that point to go dark + uniform float uCharTime; // seconds the char takes to blacken + uniform float uGrain; // ember-vein features per metre + uniform float uVein; // 0..1 how much of the sole is vein rather than char + uniform float uRagged; // metres the halo wanders + uniform float uOpacity; + uniform float uGlow; + uniform float uFade; // master fade + uniform float uSoftFade; // metres of depth feather + + uniform vec3 uColorFlash; + uniform vec3 uColorEmber; + uniform vec3 uColorChar; + uniform vec3 uColorAsh; + uniform vec3 uColorHalo; + + varying vec2 vSole; + varying vec3 vWorld; + varying float vAge; + varying float vSeed; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + float sdEllipse(vec2 p, vec2 r) { + vec2 rr = max(r, vec2(1e-4)); + return (length(p / rr) - 1.0) * min(rr.x, rr.y); + } + + float smoothUnion(float a, float b, float k) { + float kk = max(k, 1e-4); + float h = clamp(0.5 + 0.5 * (b - a) / kk, 0.0, 1.0); + return mix(b, a, h) - kk * h * (1.0 - h); + } + + /** Heel, waist and ball. Metres in, metres out. +x is lateral, +y is the toe. */ + float soleSD(vec2 p) { + float L = max(uFootLength, 0.02); + float W = max(uFootWidth, 0.01); + float heel = sdEllipse(p - vec2(0.0, -0.30 * L), vec2(0.5 * W * uHeelWidth, 0.21 * L)); + float ball = sdEllipse(p - vec2(0.0, 0.17 * L), vec2(0.5 * W * uBallWidth, 0.23 * L)); + // The waist is narrowed *and* shifted outward, so what is removed is the + // medial arch and not a symmetric pinch. A symmetric pinch reads as a + // dumbbell; this reads as a foot. + float waist = sdEllipse( + p - vec2(0.25 * W * uArchCut, -0.05 * L), + vec2(0.5 * W * max(1.0 - uArchCut, 0.12), 0.22 * L) + ); + return smoothUnion(smoothUnion(heel, waist, uSoleRound), ball, uSoleRound); + } + + /** Five toes, biggest on the medial side, the row raked back to the outside. */ + float toesSD(vec2 p) { + float L = max(uFootLength, 0.02); + float W = max(uFootWidth, 0.01); + float d = 1e3; + for (int i = 0; i < 5; i++) { + float t = float(i) * 0.25; + float x = mix(-0.34, 0.34, t) * W * uToeSpread; + float y = L * (0.41 - 0.10 * t * t) + uToeGap; + float r = uToeSize * (1.0 - 0.38 * t); + d = min(d, sdEllipse(p - vec2(x, y), vec2(r, r * 1.15))); + } + return d; + } + + void main() { + if (vAge < 0.0 || uFade < 0.004) discard; + + float L = max(uFootLength, 0.02); + float sd = min(soleSD(vSole), toesSD(vSole)); + + // Taken here, before the ignition discard below: a derivative evaluated in + // divergent control flow is undefined, and the 2x2 quad along the ignition + // line is exactly where half the fragments have gone. GroundField learnt the + // same lesson on its stroke antialiasing. + float px = fwidth(sd) + 1e-5; + + // The ignition runs heel to toe on the print's own clock — a footfall is a + // roll, not a stamp, and this is the one detail that makes a still frame of + // the trail look like somebody walked it rather than like somebody stencilled + // it. The parameter below is 0 at the heel and 1 at the toe. + float u = clamp(vSole.y / L + 0.5, 0.0, 1.0); + float local = vAge - u * uRollTime; + if (local < 0.0) discard; + + float flash = smoothstep(0.0, max(uFlashTime, 1e-3), local); + float heat = flash * exp(-local / max(uCoolTime, 0.05)); + float soot = 1.0 - exp(-local / max(uCharTime, 0.05)); + + // Ember veins: the char cracks, and the cracks are what stay hot. Sampled in + // world metres so the grain belongs to the floor and two prints that overlap + // share it, instead of each carrying its own copy of the same pattern. + float grain = snoise(vec3(vWorld.xz * max(uGrain, 0.01), vSeed * 3.7)) * 0.5 + 0.5; + float vein = smoothstep(0.52 - uVein * 0.4, 0.78, grain); + + float inside = 1.0 - smoothstep(-uEdge, max(uEdge, px), sd); + float rimBand = exp(-pow(max(sd + uRim * 0.5, -uHalo) / max(uRim, 1e-3), 2.0)); + + // The halo is the scorch that gets out past the sole. Ragged, warped in the + // plane — never sampled on a bearing, which draws spokes. + float wobble = snoise(vec3(vWorld.xz * 2.6, vSeed * 11.0)) * uRagged; + float halo = exp(-max(sd + wobble, 0.0) / max(uHalo, 1e-3)); + + float alpha = max(inside, halo * 0.55) * uOpacity * uFade; + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + // Cold first, then everything hot added on top: an ember has to be able to + // sit on a black scar, and mixing toward the ember colour instead washes the + // scar out to grey the moment the glow is turned up. + vec3 colour = mix(uColorAsh, uColorChar, soot); + colour = mix(colour, uColorHalo, clamp(halo * (1.0 - inside), 0.0, 1.0)); + + float ember = heat * mix(0.25, 1.0, vein) * inside; + float front = exp(-pow(local / max(uFlashTime * 1.6, 1e-3), 2.0)) * inside; + colour += uColorEmber * ember * uGlow * uGlobalGlow; + colour += uColorFlash * (front * 0.9 + rimBand * heat * 0.7) * uGlow * uGlobalGlow; + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/* ====================================================================== */ +/* The pillar */ +/* ====================================================================== */ +/** + * The short column of flame each print throws. + * + * A cylindrical billboard — world up is up, the quad only yaws to face the + * camera. A full billboard was tried and it leans the whole trail over as you + * orbit, which reads as wind blowing from wherever the camera happens to be. + * + * The pillar's *width at height h* is the sole's own footprint chord at that + * height, tapered: the base of the column is as wide as the foot that made it, + * which is the only thing tying the two meshes together visually once the + * flames are tall enough to hide the print underneath them. + */ +const PILLAR_VERTEX = /* glsl */ ` + ${PRINT_PLACEMENT_GLSL} + + uniform float uHeight; // metres, a pillar at full stretch + uniform float uWidth; // metres, its half-width at the base + uniform float uRiseTime; // seconds to full height + uniform float uFallTime; // seconds to gutter out + uniform float uLean; // metres the crown leans downrange + uniform float uWaver; // metres of sideways sway at the crown + uniform float uWaverRate; // sways per second + uniform float uLift; // metres above the floor the base sits + + varying vec2 vQuad; // x: -1..1 across, y: 0..1 up + varying vec3 vWorld; + varying float vHeat; // 0..1 how hard this pillar is burning + varying float vSeed; + varying float vViewZ; + + void main() { + if (aBirth < 0.0) { + vQuad = vec2(0.0); + vWorld = vec3(0.0); + vHeat = 0.0; + vSeed = 0.0; + vViewZ = -1.0; + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + float age = uNow - aBirth; + // Rise then gutter. Both times are live, so a paused trail re-times when + // either slider moves. + float rise = smoothstep(0.0, max(uRiseTime, 1e-3), age); + float fall = exp(-max(age - uRiseTime, 0.0) / max(uFallTime, 0.05)); + float heat = rise * fall; + + vec3 base = printCentre(aIndex, aWander); + base.y = uLift; + + // Cylindrical billboard: the camera's right, flattened into the floor plane. + vec3 camRight = normalize(vec3(viewMatrix[0][0], 0.0, viewMatrix[2][0]) + vec3(1e-5, 0.0, 0.0)); + + float h = position.y + 0.5; // 0 at the foot, 1 at the crown + float across = position.x * 2.0; // -1..1 + + float wave = sin(uNow * uWaverRate + aSeed * 6.2831) * uWaver * h * h; + vec3 world = base + + camRight * (across * uWidth * heat) + + vec3(0.0, h * uHeight * heat, 0.0) + + uForward * (uLean * h * h * heat) + + uSide * wave; + + vQuad = vec2(across, h); + vWorld = world; + vHeat = heat; + vSeed = aSeed; + + vec4 mv = modelViewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const PILLAR_FRAGMENT = /* glsl */ ` + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + uniform float uTime; + + uniform float uTaper; // >1 pulls the flame to a point + uniform float uBulge; // 0..1 how much it swells above the base + uniform float uNoiseScale; // features per metre + uniform float uNoiseSpeed; // metres/second the field climbs + uniform float uErosion; // how hard the noise eats the silhouette + uniform float uOpacity; + uniform float uGlow; + uniform float uFade; + uniform float uSoftFade; + + uniform vec3 uColorA; // the base + uniform vec3 uColorB; + uniform vec3 uColorC; + uniform vec3 uColorD; // the smoke at the crown + + varying vec2 vQuad; + varying vec3 vWorld; + varying float vHeat; + varying float vSeed; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + if (vHeat < 0.01 || uFade < 0.004) discard; + + float h = clamp(vQuad.y, 0.0, 1.0); + // Width profile: swells just off the floor, then tapers to nothing. A flame + // that is widest at the ground looks like a puddle of light. + float profile = pow(1.0 - h, max(uTaper, 0.05)) * (1.0 + uBulge * sin(h * 3.14159265)); + float body = 1.0 - abs(vQuad.x) / max(profile, 1e-3); + if (body <= 0.0) discard; + + // The field climbs in world metres, so two pillars side by side do not + // flicker in step — the thing that gives away a billboard fire instantly. + float n = fbm3(vec3(vWorld.xz * uNoiseScale, vWorld.y * uNoiseScale - uTime * uNoiseSpeed) + vSeed * 13.0); + float shape = body - uErosion * (0.5 + 0.5 * n) * h; + + float alpha = smoothstep(0.0, 0.35, shape) * vHeat * uOpacity * uFade; + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + float t = clamp(h * 0.85 + (1.0 - shape) * 0.3, 0.0, 1.0); + vec3 colour = gradient4(uColorA, uColorB, uColorC, uColorD, t); + colour *= uGlow * uGlobalGlow * mix(0.6, 1.0, vHeat); + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/* ====================================================================== */ +/* Construction */ +/* ====================================================================== */ + +/** + * The uniform boxes the two materials **share by identity**. + * + * Cloning this object gives you a sole and a pillar that agree until the first + * frame one of them is written and the other is not. There is exactly one call + * site per cast, in `FirewalkAbility#_syncPlacement()`. + */ +export function createFirewalkPlacement() { + return { + uOrigin: { value: new Vector3() }, + uForward: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + uStart: { value: 0.9 }, + uStep: { value: 1.15 }, + uStride: { value: 0.32 }, + uWander: { value: 0.05 }, + uNow: { value: 0 }, + uLift: { value: 0.015 } + }; +} + +/** + * The four per-print attributes, built once and handed to **both** geometries. + * + * `aIndex` never changes. `aSeed` and `aWander` are re-rolled per cast by the + * ability (dice rolls, which a cast is allowed to capture); `aBirth` is written + * as the front crosses each print. All three are `Float32Array`s the ability + * writes into directly — there is no allocation anywhere in a cast. + */ +export function createPrintAttributes(capacity) { + const index = new Float32Array(capacity); + const seed = new Float32Array(capacity); + const wander = new Float32Array(capacity); + const birth = new Float32Array(capacity); + for (let i = 0; i < capacity; i++) { + index[i] = i; + seed[i] = Math.random() * 100; + wander[i] = Math.random() * 2 - 1; + birth[i] = -1; + } + return { + aIndex: new InstancedBufferAttribute(index, 1), + aSeed: new InstancedBufferAttribute(seed, 1), + aWander: new InstancedBufferAttribute(wander, 1), + aBirth: new InstancedBufferAttribute(birth, 1) + }; +} + +/** + * One quad per print, in the print's own frame. + * + * `ground` lays it in XZ (the sole); otherwise it stands in XY (the pillar). + * Both are unit quads centred on the origin — not one metre of either is in the + * buffer, which is what lets `footLength` and `pillarHeight` stay live. + */ +function printQuad(attributes, ground) { + const geometry = new InstancedBufferGeometry(); + + const position = ground + ? new Float32Array([-0.5, 0, -0.5, 0.5, 0, -0.5, 0.5, 0, 0.5, -0.5, 0, 0.5]) + : new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]); + geometry.setAttribute('position', new BufferAttribute(position, 3)); + geometry.setIndex(new BufferAttribute(new Uint16Array([0, 1, 2, 0, 2, 3]), 1)); + + for (const [name, attribute] of Object.entries(attributes)) geometry.setAttribute(name, attribute); + geometry.instanceCount = 0; + + // Every vertex is placed by the shader, so three's idea of the bounds is a + // half-metre box at the world origin and has nothing to do with where this + // draws. Frustum culling is off on the mesh for the same reason. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + return geometry; +} + +/** The instanced ground quad the soles are drawn on. */ +export function createSoleGeometry(attributes) { + return printQuad(attributes, true); +} + +/** The instanced upright quad the pillars are drawn on. */ +export function createPillarGeometry(attributes) { + return printQuad(attributes, false); +} + +/** + * The sole. Alpha-blended, not additive: a burnt footprint is *darker* than the + * flagstone it is burnt into, and the embers are added on top of that. + */ +export function createSoleMaterial(placement) { + return new ShaderMaterial({ + name: 'Firewalk:sole', + vertexShader: SOLE_VERTEX, + fragmentShader: SOLE_FRAGMENT, + transparent: true, + depthWrite: false, + depthTest: true, + blending: NormalBlending, + toneMapped: false, + uniforms: sharedUniforms({ + ...placement, + + uFootLength: { value: 0.3 }, + uFootWidth: { value: 0.115 }, + uPad: { value: 0.12 }, + uToeOut: { value: 0.13 }, + + uHeelWidth: { value: 0.78 }, + uBallWidth: { value: 1.0 }, + uArchCut: { value: 0.42 }, + uSoleRound: { value: 0.02 }, + uToeSize: { value: 0.03 }, + uToeSpread: { value: 0.92 }, + uToeGap: { value: 0.016 }, + + uRim: { value: 0.016 }, + uHalo: { value: 0.1 }, + uEdge: { value: 0.006 }, + uRollTime: { value: 0.16 }, + uFlashTime: { value: 0.09 }, + uCoolTime: { value: 1.6 }, + uCharTime: { value: 0.5 }, + uGrain: { value: 5.5 }, + uVein: { value: 0.6 }, + uRagged: { value: 0.03 }, + uOpacity: { value: 1 }, + uGlow: { value: 2.2 }, + uFade: { value: 1 }, + uSoftFade: { value: 0.3 }, + + uColorFlash: { value: new Color(1, 1, 1) }, + uColorEmber: { value: new Color(1, 0.45, 0.12) }, + uColorChar: { value: new Color(0.06, 0.05, 0.05) }, + uColorAsh: { value: new Color(0.3, 0.27, 0.26) }, + uColorHalo: { value: new Color(0.12, 0.09, 0.08) } + }) + }); +} + +/** The pillar. Additive, because it is fire. */ +export function createPillarMaterial(placement) { + return new ShaderMaterial({ + name: 'Firewalk:pillar', + vertexShader: PILLAR_VERTEX, + fragmentShader: PILLAR_FRAGMENT, + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + toneMapped: false, + uniforms: sharedUniforms({ + ...placement, + + uHeight: { value: 1.3 }, + uWidth: { value: 0.22 }, + uRiseTime: { value: 0.2 }, + uFallTime: { value: 0.9 }, + uLean: { value: 0.18 }, + uWaver: { value: 0.06 }, + uWaverRate: { value: 3.1 }, + + uTaper: { value: 1.35 }, + uBulge: { value: 0.55 }, + uNoiseScale: { value: 1.6 }, + uNoiseSpeed: { value: 2.4 }, + uErosion: { value: 0.9 }, + uOpacity: { value: 1 }, + uGlow: { value: 1.7 }, + uFade: { value: 1 }, + uSoftFade: { value: 0.4 }, + + uColorA: { value: new Color(1, 1, 1) }, + uColorB: { value: new Color(1, 0.6, 0.15) }, + uColorC: { value: new Color(0.8, 0.2, 0.05) }, + uColorD: { value: new Color(0.1, 0.08, 0.09) } + }) + }); +} + +/** Four pickers into the pillar's height gradient. Memoised through `getColor`. */ +export function setPillarGradient(material, a, b, c, d) { + const u = material.uniforms; + u.uColorA.value.copy(getColor(a)); + u.uColorB.value.copy(getColor(b)); + u.uColorC.value.copy(getColor(c)); + u.uColorD.value.copy(getColor(d)); + return material; +} diff --git a/src/materials/GeyserDropMaterial.js b/src/materials/GeyserDropMaterial.js new file mode 100644 index 0000000..e0a7a4a --- /dev/null +++ b/src/materials/GeyserDropMaterial.js @@ -0,0 +1,205 @@ +import { Color, DoubleSide, ShaderMaterial } from 'three'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { getColor } from '../utils/color.js'; + +/* ====================================================================== */ +/* One drop of the column, on its way back down */ +/* ====================================================================== */ +/** + * A blob of the geyser's own water, in the air. + * + * `vfx/Projectile.js` places the body and hands the material three per-instance + * attributes: `aSeed` (a unitless dice roll), `aFlight` (τ, 0 where the drop + * tore off the column and 1 where it lands) and `aFlash` (the birth pop, + * decaying in seconds). Everything here is a function of those three and live + * uniforms — no per-drop state on the CPU, and nothing is a texture. + * + * **The read is a real refraction, not a fresnel ramp.** A drop of water is a + * lens. Light entering the front is bent toward the axis and concentrates into + * a bright point on the *far* side — which is why a dewdrop on a leaf has a hot + * spot that stays put when you move and slides when the sun does. So the + * highlight here is `refract(-V, N, 1/ior)` dotted against the key light, + * raised to `uSpotPower`. It costs one builtin, it is the only term in this + * material that could not be faked with a power of `1 − N·V`, and it is the + * whole reason a hundred and twenty of these read as *water* rather than as a + * hundred and twenty pale spheres. + * + * A first version used a Blinn-Phong lobe for the same job. It put the + * highlight on the near side, every drop lit identically, and the field looked + * like polystyrene beads. Moving the highlight to the far side fixed it, and it + * is one line. + * + * **The second read is τ.** Water that has just torn off a plume losing + * pressure is white with entrained air; by the time it has fallen five metres + * it has shed most of that and is clear. So `uFroth` fades over + * `pow(tau, uFrothFade)` and the drop genuinely *clarifies* as it falls. That + * continuity is the point of the ability — this is the water that went up, so + * it must not look like a different substance coming down. + */ +const DROP_VERTEX = /* glsl */ ` + attribute float aSeed; + attribute float aFlight; + attribute float aFlash; + + varying float vSeed; + varying float vFlight; + varying float vFlash; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + + void main() { + vSeed = aSeed; + vFlight = aFlight; + vFlash = aFlash; + + mat4 im = mat4(1.0); + #ifdef USE_INSTANCING + im = instanceMatrix; + #endif + + vec4 world = modelMatrix * im * vec4(position, 1.0); + vNormalW = normalize(mat3(modelMatrix) * mat3(im) * normal); + vViewDir = cameraPosition - world.xyz; + + vec4 mv = viewMatrix * world; + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const DROP_FRAGMENT = /* glsl */ ` + uniform vec3 uColorClear; // the body once the air has come out of it + uniform vec3 uColorFroth; // the same water, white with entrained air + uniform vec3 uColorSpot; // the refracted hot spot + uniform vec3 uColorRim; // the silhouette + + uniform float uIor; // refractive index. Water is 1.333. + uniform float uSpot; // how bright the refracted point is + uniform float uSpotPower; // how tight it is + uniform float uRim; + uniform float uRimPower; + uniform float uFroth; // 0..1 aeration at tau = 0 + uniform float uFrothFade; // exponent on tau — >1 clears late + uniform float uAmbient; + uniform float uShade; // key-light term on the body + uniform float uFlashGain; // extra light on a drop that has just torn off + uniform float uGlow; + uniform float uOpacity; + uniform float uSoftFade; // metres of depth feather against geometry + + uniform vec3 uLightDir; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + + varying float vSeed; + varying float vFlight; + varying float vFlash; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + + ${commonGLSL} + + void main() { + vec3 N = normalize(vNormalW); + vec3 V = normalize(vViewDir); + vec3 L = normalize(uLightDir); + float facing = clamp(dot(N, V), 0.0, 1.0); + + /* --- the lens ----------------------------------------------------- */ + // The view ray, bent on the way in. Where it comes out pointing at the key + // light is where the drop concentrates it: the far side, not the near one. + vec3 bent = refract(-V, N, 1.0 / max(uIor, 1.001)); + // Total internal reflection returns a zero vector; guard rather than + // normalising a zero and handing NaN to the pow below. + float spot = 0.0; + if (dot(bent, bent) > 1e-6) { + spot = pow(clamp(dot(normalize(bent), L), 0.0, 1.0), max(uSpotPower, 1.0)) * uSpot; + } + + float rim = pow(1.0 - facing, max(uRimPower, 0.1)); + + /* --- the body ------------------------------------------------------ */ + // Air comes out of it as it falls. See the module header. + float froth = uFroth * (1.0 - pow(clamp(vFlight, 0.0, 1.0), max(uFrothFade, 0.05))); + float ndl = clamp(dot(N, L) * 0.5 + 0.5, 0.0, 1.0); + + vec3 body = mix(uColorClear, uColorFroth, clamp(froth, 0.0, 1.0)); + body *= uAmbient + uShade * ndl; + + vec3 colour = body; + colour += uColorSpot * spot; + colour += uColorRim * rim * uRim; + colour += uColorFroth * (vFlash * uFlashGain); + colour *= uGlow * uGlobalGlow; + + // Clear water hides almost nothing face-on; the froth and the silhouette + // are what you actually see, which is why a drop reads as a drop and a + // sphere of flat alpha reads as a bead. + float alpha = uOpacity * clamp(0.2 + 0.55 * froth + 0.8 * rim + spot * 0.4, 0.0, 1.0); + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, max(uSoftFade, 1e-3)); + if (alpha < 0.004) discard; + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/** + * Build the droplet material. + * + * Two-sided: the drop is transparent, so the far wall is part of the read and + * culling it takes the volume out of the silhouette. + * + * @returns {ShaderMaterial} + */ +export function createGeyserDropMaterial() { + return new ShaderMaterial({ + name: 'GeyserDrop', + vertexShader: DROP_VERTEX, + fragmentShader: DROP_FRAGMENT, + transparent: true, + // Off: a hundred transparent drops sorting against one another by depth + // punches holes in the ones behind. They are small and additive-ish enough + // that letting them all composite is the better trade. + depthWrite: false, + depthTest: true, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uColorClear: { value: new Color('#bfeef6') }, + uColorFroth: { value: new Color('#ffffff') }, + uColorSpot: { value: new Color('#eafcff') }, + uColorRim: { value: new Color('#67d4e6') }, + + uIor: { value: 1.333 }, + uSpot: { value: 2.2 }, + uSpotPower: { value: 14 }, + uRim: { value: 0.9 }, + uRimPower: { value: 2.2 }, + uFroth: { value: 0.85 }, + uFrothFade: { value: 1.3 }, + uAmbient: { value: 0.35 }, + uShade: { value: 0.7 }, + uFlashGain: { value: 1.8 }, + uGlow: { value: 1.2 }, + uOpacity: { value: 1 }, + uSoftFade: { value: 0.18 } + }) + }); +} + +/** The four droplet pickers, none derived from another. */ +export function setDropColors(material, clear, froth, spot, rim) { + const u = material.uniforms; + u.uColorClear.value.copy(getColor(clear)); + u.uColorFroth.value.copy(getColor(froth)); + u.uColorSpot.value.copy(getColor(spot)); + u.uColorRim.value.copy(getColor(rim)); + return material; +} diff --git a/src/materials/GhostIronMaterial.js b/src/materials/GhostIronMaterial.js new file mode 100644 index 0000000..643ab4a --- /dev/null +++ b/src/materials/GhostIronMaterial.js @@ -0,0 +1,222 @@ +import { MeshStandardMaterial, Color, DoubleSide } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * Ghost iron — the metal a soul chain is forged out of. + * + * A `MeshStandardMaterial` because a chain has to be *metal*: the read depends + * entirely on a hard specular running along the top of each link as the chain + * swings, and no emissive-only shader in this project can produce one. Cast + * iron with a pitted surface, a cold fresnel rim, and a glow that lives on the + * **inside** of each ring. + * + * ### The inner glow, and why it needs an attribute + * + * "Ghost-iron with an inner glow" is not a rim light. A rim light is on the + * silhouette, which is the outside; what makes a chain look haunted is light + * coming out of the *holes* — the concave inner wall of every link, which is + * the one part of the surface that faces its neighbours rather than the world. + * + * There is no way to ask a general mesh which of its faces those are, so the + * link builder records it. Every vertex carries `aLinkSection`, the cosine and + * sine of its angle around the swept tube, with the cosine measured against the + * ring's **outward** in-plane direction. `-cos α` is therefore exactly "how far + * inside the hole am I", and `uSoulInner` is the exponent that decides how + * tightly the glow hugs it. The first version used `1 − N·V` and the chain lit + * up like chrome; the glow was everywhere the metal was, which is the one place + * it should not be. + * + * ### Per-instance state, and the negative break flag + * + * Three instanced attributes: + * + * | attribute | meaning | + * | --- | --- | + * | `aLinkStation` | 0..1 along the chain, hand → far anchor | + * | `aLinkSeed` | unitless dice, 0..1 | + * | `aLinkBreak` | **−1 while the link is threaded**; 0..1 once it has broken free, as a fraction of `breakLife` | + * + * The negative sentinel is doing real work. A separate boolean attribute would + * be a second upload per frame for one bit, and a `0` sentinel collides with + * "broke this exact frame" — which is the frame the link is supposed to be at + * its hottest. `step(0.0, aLinkBreak)` reads the flag and the same number + * carries the fall. + * + * Uniform boxes are parked on `material.userData.uniforms`, the + * `IceMaterial` / `MeteorMaterial` convention the harness's pause test looks + * for. + */ +export function createGhostIronMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.44, + metalness: 0.85, + flatShading: false, + // A link is a thin swept tube seen from every side at once as the chain + // twists; back faces are a real part of the silhouette through the hole. + side: DoubleSide, + transparent: true, + depthWrite: true + }); + + const uniforms = { + uTime: frame.uTime, + uColorIron: { value: new Color() }, + uColorIronDeep: { value: new Color() }, + uColorSoul: { value: new Color() }, + uColorGhost: { value: new Color() }, + uColorBreak: { value: new Color() }, + uPit: { value: 0.55 }, + uPitScale: { value: 42 }, + uSoulGlow: { value: 2.2 }, + uSoulInner: { value: 2.6 }, + uSoulPulse: { value: 0.7 }, + uPulseScale: { value: 1.6 }, + uPulseSpeed: { value: 1.1 }, + uRim: { value: 1.4 }, + uRimPower: { value: 3.2 }, + uBreakGlow: { value: 3.5 } + }; + + environment.registerShadowCasterWithPatch(material, (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + `#include + attribute vec2 aLinkSection; + attribute float aLinkStation; + attribute float aLinkSeed; + attribute float aLinkBreak; + varying vec2 vLinkSection; + varying float vLinkStation; + varying float vLinkSeed; + varying float vLinkBreak; + varying vec3 vLinkLocal;` + ) + .replace( + '#include ', + `#include + vLinkSection = aLinkSection; + vLinkStation = aLinkStation; + vLinkSeed = aLinkSeed; + vLinkBreak = aLinkBreak; + vLinkLocal = transformed;` + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform float uTime; + uniform vec3 uColorIron; + uniform vec3 uColorIronDeep; + uniform vec3 uColorSoul; + uniform vec3 uColorGhost; + uniform vec3 uColorBreak; + uniform float uPit; + uniform float uPitScale; + uniform float uSoulGlow; + uniform float uSoulInner; + uniform float uSoulPulse; + uniform float uPulseScale; + uniform float uPulseSpeed; + uniform float uRim; + uniform float uRimPower; + uniform float uBreakGlow; + varying vec2 vLinkSection; + varying float vLinkStation; + varying float vLinkSeed; + varying float vLinkBreak; + varying vec3 vLinkLocal; + ${noiseGLSL} + + /* Casting pits. Local space, offset by the per-link dice, so two links + side by side are pitted differently and neither of them swims when + the chain moves. */ + float ironPit() { + return clamp(smoothstep(0.4, 0.95, + ridged(vLinkLocal * uPitScale + vLinkSeed * 31.0, 4)), 0.0, 1.0); + }` + ) + .replace( + '#include ', + `#include + roughnessFactor = clamp(roughnessFactor + ironPit() * uPit * 0.45, 0.03, 1.0);` + ) + .replace( + '#include ', + `#include + { + float pit = ironPit(); + diffuseColor.rgb *= mix(uColorIron, uColorIronDeep, pit * uPit); + + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + + // Inside the hole, not on the silhouette. See the header. + float inner = pow(clamp(-vLinkSection.x, 0.0, 1.0), max(uSoulInner, 0.05)); + float pulse = 0.5 + 0.5 * sin((vLinkStation * uPulseScale - uTime * uPulseSpeed) * PI2); + float soul = inner * mix(1.0, pulse, clamp(uSoulPulse, 0.0, 1.0)); + + float rim = pow(1.0 - ndv, max(uRimPower, 0.05)) * uRim; + + // −1 means "still threaded". A link is at its hottest on the frame it + // lets go and cools on its way down, which is what makes twenty of + // them breaking in sequence read as twenty events. + float broken = step(0.0, vLinkBreak); + float hot = broken * exp(-5.0 * max(vLinkBreak, 0.0)); + + vec3 glow = uColorSoul * soul * uSoulGlow; + glow += uColorGhost * rim; + glow += uColorBreak * hot * uBreakGlow; + // Reinhard ceiling, as in IceMaterial: the three terms peak in + // different places but a freshly broken link on the silhouette hits + // all three at once and bloom turns it into a white disc. + glow /= 1.0 + glow * 0.26; + + totalEmissiveRadiance += glow; + + // A freed link fades out over the back half of its fall rather than + // popping. Threaded links are untouched. + diffuseColor.a *= 1.0 - broken * smoothstep(0.55, 1.0, max(vLinkBreak, 0.0)); + }` + ); + }); + + material.userData.uniforms = uniforms; + + /** Pull the palette and every shading control from the live settings. */ + material.userData.sync = () => { + const c = settings.soulchain; + const g = settings.global; + + uniforms.uColorIron.value.copy(getColor(c.colorIron)); + uniforms.uColorIronDeep.value.copy(getColor(c.colorIronDeep)); + uniforms.uColorSoul.value.copy(getColor(c.colorSoul)); + uniforms.uColorGhost.value.copy(getColor(c.colorGhost)); + uniforms.uColorBreak.value.copy(getColor(c.colorBreak)); + + uniforms.uPit.value = c.ironPit * g.shaderIntensity; + uniforms.uPitScale.value = c.ironPitScale * g.noiseFrequency; + uniforms.uSoulGlow.value = c.soulGlow * g.glow; + uniforms.uSoulInner.value = c.soulInner; + uniforms.uSoulPulse.value = c.soulPulse; + uniforms.uPulseScale.value = c.soulPulseScale; + uniforms.uPulseSpeed.value = c.soulPulseSpeed * g.noiseSpeed; + uniforms.uRim.value = c.ghostRim * g.fresnel; + uniforms.uRimPower.value = c.ghostRimPower; + uniforms.uBreakGlow.value = c.breakGlow * g.glow; + + material.roughness = Math.min(1, c.ironRough); + material.metalness = Math.min(1, c.ironMetal); + material.envMapIntensity = c.ironEnv; + }; + + material.userData.sync(); + return material; +} diff --git a/src/materials/GlacialLanceMaterial.js b/src/materials/GlacialLanceMaterial.js new file mode 100644 index 0000000..058ae46 --- /dev/null +++ b/src/materials/GlacialLanceMaterial.js @@ -0,0 +1,450 @@ +import { + MeshStandardMaterial, + ShaderMaterial, + Color, + Vector3, + DoubleSide, + NormalBlending +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { frame, sharedUniforms } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * The two materials Shatterlance is made of, in one file because they share a + * job: making a swarm of shards and the lance they merge into look like the + * same piece of ice. + * + * - `createLanceMaterial()` — the lance. A patched `MeshStandardMaterial`, so + * it takes the stage's shadows and the HDR probe, with faceted crystal + * shading and a **longitudinal seam** that runs its whole length. + * - `createIntakeMaterial()` — the converging shards. A raw `ShaderMaterial` + * that places every instance *parametrically in its vertex shader*, from a + * point on a sphere to a point on the lance's surface. + * + * ## The seam, and the one place an angular sample is not a mistake + * + * Everything else in this project that samples `atan(y, x)` is a bug — an + * angular lookup hands every radius along a bearing the same value and draws + * dead-straight spokes, which is why `GroundField` warps its front in the plane + * instead. The lance is the exception, and it is worth saying why: a fluted + * prism's grooves genuinely *are* angular features of its cross-section, cut at + * a fixed count around the axis and running the full length. Sampling the seam + * on the bearing is not an approximation of the geometry, it is a description + * of it, and the twist term follows the same helix the geometry factory cuts. + * + * ## The convergence is closed form + * + * A shard's position is `mix(sphere point, lance point, ease(t))` where `t` is + * the assembly clock minus that shard's own stagger. Nothing integrates and + * nothing is stored between frames, so dragging `intakeSphere` on a paused + * wind-up re-flies every shard from a bigger shell — which an Euler integrator + * physically could not do, because it has already spent the old radius. + * + * Both materials park their uniforms on `userData.uniforms`, because the + * harness's pause test reads a patched standard material's uniforms from + * exactly there and will otherwise report thirty working sliders as dead. + */ + +/* ---------------------------------------------------------------------- */ +/* The lance */ +/* ---------------------------------------------------------------------- */ + +export function createLanceMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.1, + metalness: 0.0, + flatShading: true, + transparent: true, + // A lance is a solid you can see into: the far wall is part of the read. + side: DoubleSide, + depthWrite: true + }); + + const uniforms = { + uTime: frame.uTime, + uColorIce: { value: new Color() }, + uColorDeep: { value: new Color() }, + uColorSeam: { value: new Color() }, + uColorRim: { value: new Color() }, + uDepthTint: { value: 1.3 }, + uFresnel: { value: 2.0 }, + uFresnelPower: { value: 2.3 }, + uFacetSharp: { value: 0.62 }, + uFracture: { value: 0.3 }, + uFractureScale: { value: 5.0 }, + uVeins: { value: 0.28 }, + uVeinScale: { value: 2.6 }, + uSeamGlow: { value: 2.4 }, + uSeamCount: { value: 3.5 }, + uSeamWidth: { value: 0.35 }, + uSeamTwist: { value: 0.5 }, + uGlint: { value: 1.4 }, + uGlintScale: { value: 22 }, + uGlintSpeed: { value: 0.6 }, + uGlow: { value: 1.4 }, + /** 0..1 while the lance is assembling; 1 once it is whole. */ + uCharge: { value: 0 }, + uChargeGlow: { value: 3.0 } + }; + + environment.registerShadowCasterWithPatch(material, (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + `#include + attribute float aSeed; + attribute float aBirth; + varying vec3 vLanceLocal; + varying vec3 vLanceWorld; + varying float vLanceSeed; + varying float vLanceBirth;` + ) + .replace( + '#include ', + `#include + vLanceLocal = transformed; + vLanceSeed = aSeed; + vLanceBirth = aBirth; + #ifdef USE_INSTANCING + vLanceWorld = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz; + #else + vLanceWorld = (modelMatrix * vec4(transformed, 1.0)).xyz; + #endif` + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform float uTime; + uniform vec3 uColorIce; + uniform vec3 uColorDeep; + uniform vec3 uColorSeam; + uniform vec3 uColorRim; + uniform float uDepthTint; + uniform float uFresnel; + uniform float uFresnelPower; + uniform float uFacetSharp; + uniform float uFracture; + uniform float uFractureScale; + uniform float uVeins; + uniform float uVeinScale; + uniform float uSeamGlow; + uniform float uSeamCount; + uniform float uSeamWidth; + uniform float uSeamTwist; + uniform float uGlint; + uniform float uGlintScale; + uniform float uGlintSpeed; + uniform float uGlow; + uniform float uCharge; + uniform float uChargeGlow; + varying vec3 vLanceLocal; + varying vec3 vLanceWorld; + varying float vLanceSeed; + varying float vLanceBirth; + ${noiseGLSL}` + ) + .replace( + '#include ', + `#include + { + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + + // A solid, unlike the rime plates: face a facet and you are looking + // down the long axis of the body, so it deepens. + float thick = clamp(ndv * uDepthTint, 0.0, 1.0); + float fres = pow(1.0 - ndv, uFresnelPower) * uFresnel; + + // Fracture in world space so the lance and the fragments it breaks + // into look quarried from one block. + float cracks = smoothstep(0.58, 0.98, ridged(vLanceWorld * uFractureScale + vLanceSeed * 31.0, 4)); + float veins = smoothstep(0.45, 0.92, + fbm3(vLanceLocal * uVeinScale * 4.0 + vLanceSeed * 9.0) * 0.5 + 0.5); + + vec3 body = mix(uColorIce, uColorDeep, thick); + body = mix(body, uColorRim, veins * uVeins * 0.5); + body = mix(body, uColorRim, cracks * uFracture * 0.35); + body *= mix(1.0, 0.55 + 0.9 * ndv, uFacetSharp); + + // The seam: the flute valleys, sampled on the bearing because that + // is genuinely what they are — see the module header. + float bearing = atan(vLanceLocal.z, vLanceLocal.x); + float flute = abs(sin(bearing * uSeamCount + vLanceLocal.y * uSeamTwist * 6.2831853)); + float seam = pow(1.0 - clamp(flute, 0.0, 1.0), max(1.0, 1.0 / max(uSeamWidth, 0.02))); + + // While it assembles, a band of light runs from the tail to the tip + // on the charge clock: the lance filling up, one shard at a time. + float fill = smoothstep(uCharge + 0.18, uCharge - 0.05, vLanceLocal.y); + float weld = smoothstep(0.16, 0.0, abs(vLanceLocal.y - uCharge)) * (1.0 - step(0.999, uCharge)); + + float glint = snoise(vLanceWorld * uGlintScale + + vec3(0.0, uTime * uGlintSpeed, 0.0) + vLanceSeed * 13.0); + glint = pow(clamp(glint, 0.0, 1.0), 13.0); + + vec3 glow = uColorSeam * seam * uSeamGlow * mix(0.35, 1.0, fill); + glow += uColorRim * pow(1.0 - ndv, uFresnelPower) * uFresnel * 0.5; + glow += uColorSeam * (weld + vLanceBirth) * uChargeGlow; + glow += uColorRim * glint * uGlint; + glow += uColorSeam * cracks * uFracture * 0.4; + glow *= uGlow; + + // The same Reinhard ceiling the crystals use — every term here peaks + // at a grazing angle and they stack. + glow /= 1.0 + glow * 0.22; + + diffuseColor.rgb *= body; + totalEmissiveRadiance += glow; + diffuseColor.a = clamp(diffuseColor.a * (0.66 + 0.5 * fres) + seam * 0.1, 0.0, 1.0); + }` + ); + }); + + material.userData.uniforms = uniforms; + + material.userData.sync = () => { + const c = settings.shatterlance; + const g = settings.global; + + uniforms.uColorIce.value.copy(getColor(c.colorIce)); + uniforms.uColorDeep.value.copy(getColor(c.colorDeep)); + uniforms.uColorSeam.value.copy(getColor(c.colorSeam)); + uniforms.uColorRim.value.copy(getColor(c.colorRim)); + + uniforms.uDepthTint.value = c.depthTint; + uniforms.uFresnel.value = c.fresnel * g.fresnel; + uniforms.uFresnelPower.value = c.fresnelPower; + uniforms.uFacetSharp.value = c.facetSharp; + uniforms.uFracture.value = c.fracture * g.shaderIntensity; + uniforms.uFractureScale.value = c.fractureScale * g.noiseFrequency; + uniforms.uVeins.value = c.veins * g.shaderIntensity; + uniforms.uVeinScale.value = c.veinScale * g.noiseFrequency; + uniforms.uSeamGlow.value = c.seamGlow; + uniforms.uSeamCount.value = Math.max(0.5, c.seamCount); + uniforms.uSeamWidth.value = c.seamWidth; + uniforms.uSeamTwist.value = c.seamTwist; + uniforms.uGlint.value = c.glint * g.shaderIntensity; + uniforms.uGlintScale.value = c.glintScale; + uniforms.uGlintSpeed.value = c.glintSpeed * g.noiseSpeed; + uniforms.uGlow.value = c.glow * g.glow; + uniforms.uChargeGlow.value = c.chargeGlow; + + material.opacity = c.opacity * g.opacity; + material.roughness = c.roughness; + material.envMapIntensity = c.envIntensity; + }; + + material.userData.sync(); + return material; +} + +/* ---------------------------------------------------------------------- */ +/* The converging shards */ +/* ---------------------------------------------------------------------- */ + +const INTAKE_VERTEX = /* glsl */ ` + attribute float aSeed; + + uniform vec3 uHub; // world centre of the lance + uniform vec3 uAxis; // unit, down the lance + uniform vec3 uSide; // unit, across it + uniform vec3 uUp; // unit, uAxis x uSide + uniform float uAssembly; // 0..1 the wind-up clock + uniform float uStagger; // 0..1 of that clock spread over the shards + uniform float uSphere; // metres, the shell they start on + uniform float uSpread; // metres, that shell stretched along the axis + uniform float uLanceHalf; // metres, half the lance + uniform float uLanceRadius; // metres + uniform float uSize; // metres, one shard + uniform float uSpin; // radians/second of tumble + uniform float uEase; // >1 makes them accelerate in late + uniform float uSeed; // per-cast, so two casts do not draw the same intake + uniform float uTime; + + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vShardT; + + float ihash(float p) { + return fract(sin(p * 127.1) * 43758.5453); + } + + void main() { + float s0 = aSeed + uSeed; + float h1 = ihash(s0 * 1.7); + float h2 = ihash(s0 * 3.3 + 4.1); + float h3 = ihash(s0 * 5.9 + 8.7); + float h4 = ihash(s0 * 7.1 + 12.3); + float h5 = ihash(s0 * 11.3 + 19.9); + + // Where it starts: a point on a shell about the hub, stretched along the + // axis so the intake is a capsule rather than a ball — a ball puts as many + // shards behind the caster as in front of him. + float cosT = h1 * 2.0 - 1.0; + float sinT = sqrt(max(0.0, 1.0 - cosT * cosT)); + float phi = h2 * 6.2831853; + vec3 start = uHub + + uSide * (sinT * cos(phi) * uSphere) + + uUp * (cosT * uSphere) + + uAxis * (sinT * sin(phi) * uSpread); + + // Where it lands: a point on the lance's own surface. + float along = (h3 * 2.0 - 1.0) * uLanceHalf; + float bearing = h4 * 6.2831853; + vec3 target = uHub + + uAxis * along + + (uSide * cos(bearing) + uUp * sin(bearing)) * (uLanceRadius * (0.45 + 0.55 * h5)); + + // Closed form, so a paused wind-up re-flies under the slider. + float t = clamp((uAssembly - h5 * uStagger) / max(1e-3, 1.0 - uStagger), 0.0, 1.0); + float e = 1.0 - pow(1.0 - t, max(1.0, uEase)); + vec3 hub = mix(start, target, e); + + // It shrinks into the last few centimetres of its flight, so the merge + // reads as absorption rather than as shards parked on the surface. + float scale = uSize * (1.0 - smoothstep(0.62, 1.0, t)); + + // Tumble about its own axis. + vec3 spinAxis = normalize(vec3(h4 - 0.5, h1 - 0.5, h2 - 0.5) + vec3(1e-4, 1e-4, 1e-4)); + float ang = uTime * uSpin * (0.6 + h3) + h1 * 6.2831853; + float ca = cos(ang); + float sa = sin(ang); + + vec3 p = position * scale; + vec3 rotated = p * ca + cross(spinAxis, p) * sa + spinAxis * dot(spinAxis, p) * (1.0 - ca); + vec3 n = normal * ca + cross(spinAxis, normal) * sa + spinAxis * dot(spinAxis, normal) * (1.0 - ca); + + vec3 world = hub + rotated; + + vShardT = t; + vNormalW = normalize(mat3(modelMatrix) * n); + vViewDir = cameraPosition - world; + + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(world, 1.0); + } +`; + +const INTAKE_FRAGMENT = /* glsl */ ` + uniform vec3 uColorA; + uniform vec3 uColorB; + uniform vec3 uColorEdge; + uniform float uOpacity; + uniform float uGlow; + uniform float uRim; + uniform float uRimPower; + uniform float uShade; + uniform float uAmbient; + uniform float uFade; + + uniform vec3 uLightDir; + uniform float uGlobalGlow; + + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vShardT; + + void main() { + vec3 N = normalize(vNormalW); + vec3 V = normalize(vViewDir); + + // Two-sided: a shard is thin enough that its back face is on screen half + // the time, and lighting it from behind makes the swarm flicker black. + float lambert = max(dot(N, uLightDir), 0.0); + if (dot(N, V) < 0.0) lambert = max(dot(-N, uLightDir), 0.0); + float shade = mix(1.0, uAmbient + (1.0 - uAmbient) * lambert, uShade); + + float fres = pow(1.0 - clamp(abs(dot(N, V)), 0.0, 1.0), uRimPower); + + // A shard cools from the shell colour to the lance colour as it arrives, so + // the swarm reads as feeding the thing it is turning into. + vec3 color = mix(uColorA, uColorB, clamp(vShardT, 0.0, 1.0)) * shade; + color += uColorEdge * fres * uRim * uGlow * uGlobalGlow; + + // Gone by the time it lands; the lance is what is left. + float alpha = uOpacity * uFade * (1.0 - smoothstep(0.62, 1.0, vShardT)); + if (alpha < 0.004) discard; + gl_FragColor = vec4(color, alpha); + } +`; + +/** + * The shards that fly in and become the lance. + * + * One draw call for the whole intake: an `InstancedBufferGeometry` whose only + * per-instance attribute is a seed, with every metre of the flight resolved + * from uniforms in the vertex shader. There is no per-shard state on the CPU at + * all, which is the strongest form of invariant I1 available. + */ +export function createIntakeMaterial() { + const uniforms = sharedUniforms({ + uHub: { value: new Vector3() }, + uAxis: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + uUp: { value: new Vector3(0, 1, 0) }, + uAssembly: { value: 0 }, + uStagger: { value: 0.55 }, + uSphere: { value: 4.5 }, + uSpread: { value: 3.2 }, + uLanceHalf: { value: 2.1 }, + uLanceRadius: { value: 0.42 }, + uSize: { value: 0.22 }, + uSpin: { value: 6 }, + uEase: { value: 3 }, + uSeed: { value: 0 }, + uFade: { value: 1 }, + uColorA: { value: new Color() }, + uColorB: { value: new Color() }, + uColorEdge: { value: new Color() }, + uOpacity: { value: 0.95 }, + uGlow: { value: 1.6 }, + uRim: { value: 1.2 }, + uRimPower: { value: 2.2 }, + uShade: { value: 1 }, + uAmbient: { value: 0.35 } + }); + + const material = new ShaderMaterial({ + transparent: true, + depthWrite: true, + depthTest: true, + side: DoubleSide, + blending: NormalBlending, + toneMapped: false, + uniforms, + vertexShader: INTAKE_VERTEX, + fragmentShader: INTAKE_FRAGMENT + }); + + material.userData.uniforms = uniforms; + + material.userData.sync = () => { + const c = settings.shatterlance; + const g = settings.global; + + uniforms.uColorA.value.copy(getColor(c.colorIntakeA)); + uniforms.uColorB.value.copy(getColor(c.colorIntakeB)); + uniforms.uColorEdge.value.copy(getColor(c.colorIntakeEdge)); + + uniforms.uStagger.value = Math.min(0.95, c.intakeStagger); + uniforms.uSphere.value = c.intakeSphere; + uniforms.uSpread.value = c.intakeSpread; + uniforms.uSize.value = c.intakeSize; + uniforms.uSpin.value = c.intakeSpin; + uniforms.uEase.value = Math.max(1, c.intakeEase); + uniforms.uOpacity.value = c.intakeOpacity * g.opacity; + uniforms.uGlow.value = c.intakeGlow * g.glow; + uniforms.uRim.value = c.intakeRim; + uniforms.uRimPower.value = c.intakeRimPower; + uniforms.uShade.value = c.intakeShade; + uniforms.uAmbient.value = c.intakeAmbient; + }; + + material.userData.sync(); + return material; +} diff --git a/src/materials/HailMaterial.js b/src/materials/HailMaterial.js new file mode 100644 index 0000000..14c0679 --- /dev/null +++ b/src/materials/HailMaterial.js @@ -0,0 +1,217 @@ +import { MeshStandardMaterial, Color, Vector3 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * A hailstone — clear ice with a cloudy core, lit by the stage. + * + * Built on `MeshStandardMaterial` for the same reason Frost Lance's crystal and + * Cinder Fall's rock are: a hailstone is a *solid*. It has to take the key + * light, cast into the shadow map and pick up the HDR probe, or thirty of them + * falling read as thirty additive smudges — which is exactly what the first + * version, a raw additive `ShaderMaterial`, looked like. Nothing about hail is + * emissive except its edges. + * + * Four ideas on top of the standard shading, in the order they matter: + * + * - **the cloudy core.** Real hail is layered: clear rime over milky, trapped + * air. An fbm field sampled in **local space** picks out the milk, so the + * cloud is welded into the stone and tumbles with it. Sampling in world + * space — which the ice deliberately does, so a whole field looks quarried + * out of one block — makes the marbling swim across a spinning stone and + * gives the trick away instantly. + * - **the fresnel edge.** A transparent solid is mostly read from its rim, + * and this is the only term that survives being 17 cm across at twenty + * metres. It is the reason `iceRim` ships high. + * - **glints.** Hashed specks rather than a smooth specular: ice catches the + * sun on a handful of facets at a time, and a broad highlight reads as wet + * plastic. `speck`-style hashing, not a ridged noise — value noise piles up + * at its midpoint and a ridged octave of it would frost the whole stone. + * - **the leading face.** The stones are stretched along their velocity, so + * the facets pointing down the heading catch the light first. One dot + * product against a heading the ability resolves each frame, and it is what + * stops a stretched stone reading as a static blob with motion blur. + * + * Per-instance inputs arrive as the attributes `Projectile` dresses its + * geometry with (`aSeed`, `aFlight`, `aFlash`), so this material is only ever + * used on an `InstancedMesh`. + */ +export function createHailMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.42, + metalness: 0.0, + // Faceted, like the crystals. A smooth-shaded hailstone is a pebble. + flatShading: true + }); + + const uniforms = { + uTime: frame.uTime, + uColorIce: { value: new Color() }, + uColorMilk: { value: new Color() }, + uColorRim: { value: new Color() }, + uColorGlint: { value: new Color() }, + uMilk: { value: 0.72 }, + uMilkScale: { value: 3.1 }, + uFacetTint: { value: 0.3 }, + uRim: { value: 1.3 }, + uRimPower: { value: 2.4 }, + uGlint: { value: 1.0 }, + uGlintScale: { value: 7.5 }, + uLead: { value: 0.75 }, + uLeadSharp: { value: 2.6 }, + /** Unit heading of the fall, world space — drives the leading-face light. */ + uHeading: { value: new Vector3(0, -1, 0) }, + uGlow: { value: 1.15 } + }; + + environment.registerShadowCasterWithPatch(material, (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + `#include + attribute float aSeed; + attribute float aFlash; + varying vec3 vHailLocal; + varying vec3 vHailNormalW; + varying float vHailSeed; + varying float vHailFlash;` + ) + // `objectNormal` is declared by , which runs before + // this chunk; three only takes it into *view* space and the leading-face + // term needs it in world space. + .replace( + '#include ', + `#include + vHailLocal = transformed; + vHailSeed = aSeed; + vHailFlash = aFlash; + #ifdef USE_INSTANCING + vHailNormalW = normalize(mat3(modelMatrix) * (instanceMatrix * vec4(objectNormal, 0.0)).xyz); + #else + vHailNormalW = normalize(mat3(modelMatrix) * objectNormal); + #endif` + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform float uTime; + uniform vec3 uColorIce; + uniform vec3 uColorMilk; + uniform vec3 uColorRim; + uniform vec3 uColorGlint; + uniform float uMilk; + uniform float uMilkScale; + uniform float uFacetTint; + uniform float uRim; + uniform float uRimPower; + uniform float uGlint; + uniform float uGlintScale; + uniform float uLead; + uniform float uLeadSharp; + uniform vec3 uHeading; + uniform float uGlow; + varying vec3 vHailLocal; + varying vec3 vHailNormalW; + varying float vHailSeed; + varying float vHailFlash; + ${noiseGLSL}` + ) + // Injected once the normal is resolved: with `flatShading` there is no + // `vNormal` varying, so the view-dependent terms have to read the face + // normal that derives from screen-space + // derivatives. + .replace( + '#include ', + `#include + { + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + float rim = pow(1.0 - ndv, max(uRimPower, 0.05)); + + /* --- the cloudy core, welded into the stone --- */ + float cloud = fbm3(vHailLocal * uMilkScale + vHailSeed * 23.0) * 0.5 + 0.5; + vec3 body = mix(uColorIce, uColorMilk, smoothstep(0.35, 0.85, cloud) * uMilk); + + // Per-facet value break-up. The geometric normal in object space is + // constant across a triangle, so hashing it gives every flat face + // its own shade — two derivatives, and it is the difference between + // cut ice and a noise-painted ball. + vec3 faceN = normalize(cross(dFdx(vHailLocal), dFdy(vHailLocal))); + body *= 1.0 + (hash13(faceN * 41.0 + vHailSeed) - 0.5) * uFacetTint; + + // Cheap curvature occlusion: cut faces sit closer to the centre than + // the lumps do, so radius doubles as a cavity term. + body *= mix(0.82, 1.06, smoothstep(0.55, 1.0, length(vHailLocal))); + diffuseColor.rgb *= body; + + /* --- what glows: edges and specks, nothing else --- */ + vec3 glow = uColorRim * rim * uRim; + + // Specks, hashed on the local position rather than smoothed, so the + // stone catches the sun on a few facets at a time. + float speck = hash13(floor(vHailLocal * uGlintScale) + vHailSeed * 7.0); + speck = pow(clamp(speck, 0.0, 1.0), 9.0) * (0.6 + 0.4 * sin(uTime * 11.0 + vHailSeed * 30.0)); + glow += uColorGlint * speck * uGlint * ndv; + + float lead = pow(clamp(dot(normalize(vHailNormalW), -uHeading), 0.0, 1.0), max(uLeadSharp, 0.05)); + glow += uColorGlint * lead * uLead; + + // The birth pop, so a stone entering the frame is not a hard cut. + glow += uColorGlint * vHailFlash * 1.5; + + glow *= uGlow; + // The same soft ceiling the rock uses: these terms are independent + // and stack, and a glint crossing the rim sums past 10 and smears + // the whole stone into a white blob under bloom. + glow /= 1.0 + glow * 0.25; + + totalEmissiveRadiance += glow; + }` + ); + }); + + /** + * Parked here rather than on `material.uniforms`, which a patched + * `MeshStandardMaterial` does not have — the harness's pause test reads this + * exact property and would otherwise report every slider below as dead. + */ + material.userData.uniforms = uniforms; + + /** + * Pull the palette and every shading control from the live settings. + * @param {THREE.Vector3} heading unit direction of the fall, world space + */ + material.userData.sync = (heading = null) => { + const c = settings.hail; + const g = settings.global; + + material.roughness = c.iceRoughness; + + uniforms.uColorIce.value.copy(getColor(c.colorIce)); + uniforms.uColorMilk.value.copy(getColor(c.colorMilk)); + uniforms.uColorRim.value.copy(getColor(c.colorRim)); + uniforms.uColorGlint.value.copy(getColor(c.colorGlint)); + + uniforms.uMilk.value = c.iceMilk; + uniforms.uMilkScale.value = c.iceMilkScale * g.noiseFrequency; + uniforms.uFacetTint.value = c.iceFacetTint * g.randomness; + uniforms.uRim.value = c.iceRim * g.fresnel; + uniforms.uRimPower.value = c.iceRimPower; + uniforms.uGlint.value = c.iceGlint * g.shaderIntensity; + uniforms.uGlintScale.value = c.iceGlintScale * g.noiseFrequency; + uniforms.uLead.value = c.iceLead; + uniforms.uLeadSharp.value = c.iceLeadSharp; + uniforms.uGlow.value = c.iceGlow * g.glow; + + if (heading) uniforms.uHeading.value.copy(heading); + }; + + return material; +} diff --git a/src/materials/IceMaterial.js b/src/materials/IceMaterial.js index 86b6d81..6bcb098 100644 --- a/src/materials/IceMaterial.js +++ b/src/materials/IceMaterial.js @@ -1,5 +1,6 @@ import { MeshStandardMaterial, Color, DoubleSide } from 'three'; import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { disruptGLSL, disruptUniforms } from '../vfx/SceneHooks.js'; import { frame } from '../core/FrameUniforms.js'; import { settings } from '../config/settings.js'; import { getColor } from '../utils/color.js'; @@ -46,6 +47,13 @@ export function createIceMaterial(environment) { }); const uniforms = { + // Opt-in to `vfx/SceneHooks.js`'s published disruption region — the shared + // boxes, by identity, never a clone. Frost Lance is the standing effect + // Spellbreak is most often cast into, and before this the field simply + // ignored it: three of the library's modules had opted in and none of them + // is a crystal. Nothing is read here unless a `Hook.DISRUPT` is held, at + // which point the cost is one distance in the fragment stage. + ...disruptUniforms(), uTime: frame.uTime, uColorDeep: { value: new Color() }, uColorIce: { value: new Color() }, @@ -125,7 +133,8 @@ export function createIceMaterial(environment) { varying vec3 vIceWorld; varying float vIceSeed; varying float vIceBirth; - ${noiseGLSL}` + ${noiseGLSL} + ${disruptGLSL}` ) // Injected once the normal is resolved: with `flatShading` there is no // `vNormal` varying, so every view-dependent term here has to read the @@ -195,6 +204,28 @@ export function createIceMaterial(environment) { // Thin at the edges, denser through the body and along the cracks. diffuseColor.a = clamp(diffuseColor.a * (0.62 + 0.5 * fres) + cracks * 0.12, 0.0, 1.0); + + // Spellbreak's field (vfx/SceneHooks.js). Last, on the finished + // fragment: draining the body before the emissive above is summed + // would leave a grey crystal with a full-strength glow still coming + // out of its cracks, which reads as a lighting bug rather than as + // the spell being pulled apart. Ice is already a transparent, so the + // cell erosion lands on the alpha and no discard is needed — the + // opaque growth materials in vfx/GrowthField.js are not so lucky. + // Whole locals for the inout arguments, not swizzles. + float iceDisrupt = disruptAt(vIceWorld); + if (iceDisrupt > 0.0) { + vec3 iceAlbedo = diffuseColor.rgb; + float iceAlpha = diffuseColor.a; + disruptShade(iceAlbedo, iceAlpha, iceDisrupt, gl_FragCoord.xy); + diffuseColor.rgb = iceAlbedo; + diffuseColor.a = iceAlpha; + + vec3 iceGlow = totalEmissiveRadiance; + float iceGlowKeep = 1.0; + disruptShade(iceGlow, iceGlowKeep, iceDisrupt, gl_FragCoord.xy); + totalEmissiveRadiance = iceGlow * iceGlowKeep; + } }` ); }); diff --git a/src/materials/MagmaBlobMaterial.js b/src/materials/MagmaBlobMaterial.js new file mode 100644 index 0000000..5e0c71e --- /dev/null +++ b/src/materials/MagmaBlobMaterial.js @@ -0,0 +1,196 @@ +import { Color, DoubleSide, ShaderMaterial } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { getColor } from '../utils/color.js'; + +/* ====================================================================== */ +/* The blob */ +/* ====================================================================== */ +/** + * A lump of the pool, in the air. + * + * `vfx/Projectile.js` places the body and hands the material three per-instance + * attributes: `aSeed` (a unitless dice roll), `aFlight` (τ, 0 at the muzzle and + * 1 at the landing point) and `aFlash` (the birth pop, decaying in seconds). + * Everything this shader does is a function of those three plus live uniforms — + * there is no per-blob state on the CPU and nothing here is a texture. + * + * The read it has to deliver is **the same material as the pool**, so that a + * blob leaving the fount and a blob landing back in it are obviously the same + * stuff. The pool's skin is the zero crossing of a signed fbm evaluated in the + * flow frame; a blob has no flow, so its skin is the zero crossing of the same + * kind of field evaluated in the blob's own local space and *chilled with τ*: + * coverage starts near zero at the muzzle and closes over the flight, so a blob + * genuinely darkens on the way up and cracks open again where it stretches. + * + * The first version faded a hot colour to a cold one over τ. It reads as a + * light being turned down, not as rock forming — the giveaway is that the + * silhouette never changes. Cracking a *coverage* term instead gives the blob + * moving black patches with glowing seams between them, which is the whole + * difference. + */ +const BLOB_VERTEX = /* glsl */ ` + attribute float aSeed; + attribute float aFlight; + attribute float aFlash; + + varying float vSeed; + varying float vFlight; + varying float vFlash; + varying vec3 vLocal; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + + void main() { + vSeed = aSeed; + vFlight = aFlight; + vFlash = aFlash; + vLocal = position; + + mat4 im = mat4(1.0); + #ifdef USE_INSTANCING + im = instanceMatrix; + #endif + + vec4 world = modelMatrix * im * vec4(position, 1.0); + vNormalW = normalize(mat3(modelMatrix) * mat3(im) * normal); + vViewDir = cameraPosition - world.xyz; + + vec4 mv = viewMatrix * world; + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const BLOB_FRAGMENT = /* glsl */ ` + uniform vec3 uColorHot; // the melt underneath the skin + uniform vec3 uColorMelt; // the melt at glancing angles + uniform vec3 uColorCrust; // the chilled skin + uniform vec3 uColorSeam; // the crack between two plates of it + + uniform float uCrust; // 0..1 skin coverage once fully chilled + uniform float uCrustGrow; // exponent on tau — >1 chills late + uniform float uCrackScale; // cycles per unit of blob radius + uniform float uCrackWidth; // 0..1 of the field — the seam's width + uniform float uSeamGlow; + uniform float uRim; // how hot the silhouette's edge runs + uniform float uRimPower; + uniform float uShade; // key-light term on the crust, 0..1 + uniform float uAmbient; + uniform float uFlashGain; // extra heat on a blob that has just left + uniform float uGlow; + uniform float uOpacity; + uniform float uSoftFade; // metres of depth feather against geometry + + uniform vec3 uLightDir; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + + varying float vSeed; + varying float vFlight; + varying float vFlash; + varying vec3 vLocal; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + vec3 N = normalize(vNormalW); + vec3 V = normalize(vViewDir); + float facing = clamp(dot(N, V), 0.0, 1.0); + + /* --- the skin ---------------------------------------------------- */ + // Signed field, so the seam is its zero crossing rather than a threshold on + // an absolute value: continuous, branching, and one number wide anywhere on + // the blob. A threshold on abs(noise) gives crazing, which is pottery. + float field = snoise(vLocal * max(uCrackScale, 0.01) + vSeed * 37.0); + float seam = 1.0 - smoothstep(0.0, max(uCrackWidth, 1e-3), abs(field)); + + // Coverage closes over the flight. The blob leaves the fount as raw melt + // and is a cracked black lump by the time it comes down. + float chill = clamp(uCrust * pow(clamp(vFlight, 0.0, 1.0), max(uCrustGrow, 0.01)), 0.0, 1.0); + float plate = chill * (1.0 - seam); + + /* --- the melt ----------------------------------------------------- */ + float rim = pow(1.0 - facing, max(uRimPower, 0.1)); + vec3 melt = mix(uColorHot, uColorMelt, rim); + melt = mix(melt, uColorSeam, seam * uSeamGlow * chill); + + // The crust is the only part that takes the key light. Lighting the melt + // as well flattens the blob into a lit sphere and the heat stops reading. + float ndl = clamp(dot(N, normalize(uLightDir)), 0.0, 1.0); + vec3 crust = uColorCrust * (uAmbient + uShade * ndl); + + vec3 colour = mix(melt, crust, plate); + colour += uColorMelt * (rim * uRim * (1.0 - plate)); + colour += uColorHot * (vFlash * uFlashGain); + colour *= uGlow * uGlobalGlow; + + float alpha = uOpacity; + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/** + * Build the blob material. + * + * Two-sided, because the asteroid factory cuts facets off the rock and a + * grazing view down one of those cuts otherwise shows a hole straight through + * a lump of lava. + * + * @returns {ShaderMaterial} + */ +export function createMagmaBlobMaterial() { + return new ShaderMaterial({ + name: 'MagmaBlob', + vertexShader: BLOB_VERTEX, + fragmentShader: BLOB_FRAGMENT, + transparent: true, + depthWrite: true, + depthTest: true, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uColorHot: { value: new Color('#ffe08a') }, + uColorMelt: { value: new Color('#ff5a12') }, + uColorCrust: { value: new Color('#120806') }, + uColorSeam: { value: new Color('#ff8a2a') }, + + uCrust: { value: 0.85 }, + uCrustGrow: { value: 1.4 }, + uCrackScale: { value: 2.6 }, + uCrackWidth: { value: 0.22 }, + uSeamGlow: { value: 1.2 }, + uRim: { value: 0.8 }, + uRimPower: { value: 2.0 }, + uShade: { value: 0.55 }, + uAmbient: { value: 0.22 }, + uFlashGain: { value: 2.4 }, + uGlow: { value: 1.6 }, + uOpacity: { value: 1 }, + uSoftFade: { value: 0.2 } + }) + }); +} + +/** The four blob pickers, none derived from another. Memoised. */ +export function setBlobColors(material, hot, melt, crust, seam) { + const u = material.uniforms; + u.uColorHot.value.copy(getColor(hot)); + u.uColorMelt.value.copy(getColor(melt)); + u.uColorCrust.value.copy(getColor(crust)); + u.uColorSeam.value.copy(getColor(seam)); + return material; +} diff --git a/src/materials/MirageSkinMaterial.js b/src/materials/MirageSkinMaterial.js new file mode 100644 index 0000000..a2986fd --- /dev/null +++ b/src/materials/MirageSkinMaterial.js @@ -0,0 +1,212 @@ +import { ShaderMaterial, DoubleSide, NormalBlending } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; + +/** + * The mirage's skin: a **skinned** writer for the refraction buffer. + * + * `vfx/Distortion.js` covers every emitter whose shape is a quad or a static + * hull, and it covered the mirage too — as ten capsules and a sphere posed by + * a hand-written IK solver. That was the wrong shape of solution and it looked + * like it: a capsule figure is a *mannequin*, its silhouette is smooth + * everywhere a person's is not, and the swing of an arm authored as a sine has + * none of the weight-shift a real walk cycle carries. The give-away was the + * shoulders — two spheres cannot make a deltoid, and the eye reads the miss + * long before it can name it. + * + * So the double is now the **caster's own skinned mesh**, cloned by + * `vfx/TimeControl.js#GhostRig` and posed from a `TimeRecorder` track, which + * is the machinery Echo Step already uses. This material is the one piece that + * was missing: `GhostRig` will take any material, but the clone has to write + * the offset encoding `DistortionShader` decodes rather than a colour, and it + * has to do it *through the skeleton*. + * + * ## Skinning a raw ShaderMaterial + * + * three supplies `USE_SKINNING` and the bone-texture uniforms to any material + * drawn on a `SkinnedMesh`, `ShaderMaterial` included — the define is added by + * the program's vertex prefix, not by the built-in shaders. So the only thing + * needed is to include the four skinning chunks in the right order and let the + * renderer bind `boneTexture`, `bindMatrix` and `bindMatrixInverse` itself. + * The normal has to go through `` for the same reason the + * position does: an unskinned normal on a skinned mesh leaves the rim term + * lit from wherever the bind pose happened to be facing, and the figure looks + * like it is made of glass panes rather than of one body. + * + * ## What it writes + * + * The same four channels every other emitter writes, documented in + * `postprocessing/DistortionShader.js`: + * + * ``` + * R,G unit screen-space direction, encoded as d * 0.5 + 0.5 + * B magnitude, in screen widths at post.distortion = 1 + * A coverage — the blend weight where two emitters overlap + * ``` + * + * The direction is the **view-space normal's xy**, which is the direction a ray + * refracting through the surface leaves in, weighted by a rim term so the + * silhouette bends hardest. That is what a solid of any real index of + * refraction does, and it is why the double reads as a body-shaped *absence* + * rather than as a body-shaped smear. + * + * Nothing here emits light. The material is on `LAYER.DISTORTION` and never + * reaches the beauty pass at all, so there is no colour to tone down and no + * bloom to guard against — which is the whole reason this ability can be + * invisible-until-it-moves without fighting the post stack. + */ +export function createMirageSkinMaterial() { + const uniforms = sharedUniforms({ + uStrength: { value: 0.62 }, // screen widths at post.distortion = 1 + uPower: { value: 1.35 }, // rim exponent; 0 flattens to a plain pane + uRipple: { value: 0.16 }, // break-up of the surface normal + uRippleScale: { value: 2.4 }, // ripple cycles per metre + uRippleSpeed: { value: 1.7 }, // metres/second the ripple crawls + uOpacity: { value: 0.95 }, // coverage + uDepthReject: { value: 1.0 }, // how hard the real world occludes it + uDepthFade: { value: 0.3 }, // metres that occlusion feathers over + uPerspective: { value: 0.35 }, // 0 = flat screen fraction, 1 = shrinks with range + uPerspectiveRef: { value: 10.0 }, // metres at which perspective = 1 + uFade: { value: 1 } // the ability's own envelope + }); + + const material = new ShaderMaterial({ + uniforms, + transparent: true, + depthTest: true, + depthWrite: false, + // Both faces. A limb is a closed hull and the far wall's normal points the + // other way, so drawing only the front face throws away half the bend and + // the double reads as a shell instead of as a solid. + side: DoubleSide, + blending: NormalBlending, + toneMapped: false, + vertexShader: /* glsl */ ` + #include + #include + + varying vec3 vNormalV; + varying vec3 vWorld; + varying float vViewZ; + varying float vOnScreen; + + void main() { + vec3 objectNormal = normal; + vec3 transformed = position; + + #include + #include + #include + + vec4 world = modelMatrix * vec4(transformed, 1.0); + vWorld = world.xyz; + + vNormalV = normalize(normalMatrix * objectNormal); + + vec4 mv = viewMatrix * world; + vViewZ = mv.z; + + vec4 clip = projectionMatrix * mv; + // Behind the camera the projected xy is meaningless and the offset it + // would encode is a mirror image of the right one. Gate it here rather + // than clipping, so a double walking past the camera fades instead of + // flipping inside out. + vOnScreen = step(0.0, -mv.z); + gl_Position = clip; + } + `, + fragmentShader: /* glsl */ ` + uniform float uStrength; + uniform float uPower; + uniform float uRipple; + uniform float uRippleScale; + uniform float uRippleSpeed; + uniform float uOpacity; + uniform float uDepthReject; + uniform float uDepthFade; + uniform float uPerspective; + uniform float uPerspectiveRef; + uniform float uFade; + + // From sharedUniforms(). That helper hands over the uniform *boxes*, by + // identity, so one write per frame updates every material at once — but + // it cannot declare them for you, and a missed declaration is a link + // error that only ever shows up in a browser. The check harness never + // compiles GLSL, so this is one of the few classes of fault it is + // structurally unable to catch: put a new material on screen once. + uniform float uTime; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uShaderIntensity; + + varying vec3 vNormalV; + varying vec3 vWorld; + varying float vViewZ; + varying float vOnScreen; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + // Two-sided, so flip the normal on the far wall or the back of the + // figure bends the scene the opposite way to the front and the two + // cancel almost exactly. + vec3 hn = gl_FrontFacing ? vNormalV : -vNormalV; + + // Heat coming off a body: the ripple is sampled in WORLD space and + // crawls upward, so it stays put on the world as the double moves + // through it rather than swimming with the skin. + vec3 wp = vec3(vWorld.xz * uRippleScale, vWorld.y * uRippleScale - uTime * uRippleSpeed); + vec2 raw = hn.xy + vec2(fbm3(wp), fbm3(wp + vec3(23.1, 4.7, 17.5))) * uRipple; + + float rl = length(raw); + vec2 dir = rl > 1e-6 ? raw / rl : vec2(0.0); + + // The silhouette bends hardest. max() on the base because pow() of a + // negative is NaN at every exponent, and one NaN here is smeared over + // the whole frame by the bloom blur. + float rim = pow(max(1.0 - abs(hn.z), 0.0), uPower); + float mag = uStrength * rim * min(rl, 2.0) * uFade; + + // The offset buffer has no depth of its own, so an emitter behind the + // real character would happily warp him. The opaque prepass is already + // in uSceneDepth for the soft particles and costs one sample here. + vec2 screenUV = gl_FragCoord.xy / uResolution; + float occluded = softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uDepthFade); + float mask = mix(1.0, occluded, clamp(uDepthReject, 0.0, 1.0)); + + mag *= mix(1.0, clamp(uPerspectiveRef / max(-vViewZ, 0.05), 0.0, 4.0), uPerspective); + mag = clamp(mag * uShaderIntensity, 0.0, 8.0); + + float alpha = clamp(mask * uOpacity * uFade, 0.0, 1.0) * vOnScreen; + if (alpha * mag < 0.0004) discard; + + gl_FragColor = vec4(clamp(dir, -1.0, 1.0) * 0.5 + 0.5, mag, alpha); + } + ` + }); + + material.userData.uniforms = uniforms; + + /** Pull every refraction control from the live settings. */ + material.userData.sync = (fade) => { + const c = settings.mirage; + uniforms.uStrength.value = c.bodyStrength; + uniforms.uPower.value = c.refractPower; + uniforms.uRipple.value = c.refractRipple; + uniforms.uRippleScale.value = c.refractRippleScale; + uniforms.uRippleSpeed.value = c.refractRippleSpeed; + uniforms.uOpacity.value = c.refractOpacity; + uniforms.uDepthReject.value = c.refractDepthReject; + uniforms.uDepthFade.value = c.refractDepthFade; + uniforms.uPerspective.value = c.refractPerspective; + uniforms.uPerspectiveRef.value = c.refractPerspectiveRef; + uniforms.uFade.value = fade; + }; + + return material; +} diff --git a/src/materials/MyceliumSeepMaterial.js b/src/materials/MyceliumSeepMaterial.js new file mode 100644 index 0000000..8c23e45 --- /dev/null +++ b/src/materials/MyceliumSeepMaterial.js @@ -0,0 +1,559 @@ +import { ShaderMaterial, AdditiveBlending, DoubleSide, Color, Vector2 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { disruptGLSL, disruptUniforms } from '../vfx/SceneHooks.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/* ---------------------------------------------------------------------- */ +/* MyceliumSeepMaterial — light arriving from underneath the floor */ +/* ---------------------------------------------------------------------- */ + +/** + * The seep quad: **one draw call of light that is not allowed to draw itself.** + * + * Every other ground effect in this project is a mark *on* the stone — a + * scorch, a plate of ice, a seal, a puddle. This one is a mark *under* it, and + * the whole difference lives in one multiply near the bottom of the fragment + * shader: the hyphal network computes a radiance and then that radiance is + * multiplied by an **escape mask** derived from the floor's own detail. Where + * the stone is sound, the light does not come out. The pattern you see is + * therefore the flagstone's, not the fungus's; the fungus only decides how + * brightly each part of the flagstone is lit from below. + * + * ## Why the mask is the floor's own numbers and not a new noise field + * + * The first version drew a perfectly good crack field of its own — a ridged + * fbm at about two cycles a metre — and it looked completely wrong the instant + * the camera moved, in a way that took an embarrassing while to name. The + * cracks did not line up with anything. The floor has visible structure (broad + * tonal patches from `fbm3(wp * 0.018)`, a warm wash at `0.09`, polished + * regions at `0.06`), the glow had *different* structure, and the eye reads two + * uncorrelated patterns on one surface as two surfaces — a decal on a floor, + * which is exactly the thing the brief forbids. + * + * So the mask is built out of `world/Ground.js`'s **actual expressions**, at + * the same frequencies and the same phase offsets: + * + * | the floor writes | this shader reads it as | + * | --- | --- | + * | `fbm3(wp * 0.018)` — broad tonal variation | its **level set** is the mortar course: the boundary between two flags | + * | `fbm3(wp * 0.06 + 3.0)` — the sheen patches | polished stone is *closed*; it seals the light off | + * | `snoise01(wp * 0.7)` — the grain | biases where the open pores sit | + * + * Those three lines are copied verbatim, deliberately, and this comment is the + * dependency: if the floor's macro frequency ever changes, the seams here stop + * following the flags and someone has to come back. That is a real coupling and + * it is worth having, because the alternative is the decal. + * + * The one thing that could **not** be shared is the fine pore detail, because + * the floor gets that from its normal map — one of the project's two texture + * exceptions, and something an ability may not sample under I2. `porePitch` is + * therefore a re-synthesised field with its own slider, matched to the map's + * grain by eye and adjustable when the map changes. + * + * ## The seam is measured in metres, not in field units + * + * `abs(macro - seamLevel) < seamWidth` is the obvious way to draw a contour and + * it is wrong: `macro` is dimensionless, so the band's width on the floor is + * whatever the field's local slope happens to make it — hairline across a steep + * patch, half a metre wide across a flat one, and the transition between the + * two reads as a smear. Two extra taps of the same fbm give the field's + * gradient in units-per-metre, and dividing by it turns the contour distance + * into **metres**. `seamWidth` is then a real measurement of a real mortar + * course, and the same gradient, normalised, is the direction across the crack + * — which the crevice term below needs anyway, so it is two taps for two jobs. + * + * ## Depth is the reason it does not look like a painted network + * + * A hypha is buried somewhere between `webDepthMin` and `webDepthMax` metres + * down, varying over the field. Depth does three things, all of them physical + * and all of them sliders: + * + * 1. **It blurs.** The Gaussian half-width the strand is smeared over is + * `webCore + depth * webSpread`, so a shallow run is a tight bright thread + * and a deep one is a broad dim smudge. This is the single term that sells + * "under". + * 2. **It absorbs.** `exp(-depth * webAbsorb)`, Beer–Lambert through the flags. + * 3. **It parallaxes.** The buried point is sampled at + * `lane - viewXZ * depth / viewY`, so the network *slides against the + * cracks it is seen through* as the camera orbits. Turn `webParallax` to + * zero and the whole thing immediately flattens into a sticker; it is the + * cheapest convincing term in the file and the one worth protecting. + * + * ## Cost + * + * One draw call, no textures. Five `fbm3` (three octaves of simplex each), two + * `snoise01` and two 3×3 cell walks per fragment — comparable to + * `GroundField`'s PLATE branch, and it only ever covers the cast lane. + * + * @example + * this.seepMaterial = createMyceliumSeepMaterial(); + * // …every frame, travel and fade alike: + * this.seepMaterial.userData.sync(this._state); + */ + +const SEEP_VERTEX = /* glsl */ ` + uniform vec3 uLightDir; // world space, toward the key + + ${disruptGLSL} + + varying float vDisrupt; + varying vec2 vUv2; + varying vec2 vWorldXZ; // world metres — the stone fields are sampled here + varying vec3 vLight; // key direction, in the quad's frame + varying vec3 vView; // fragment -> camera, in the quad's frame + varying vec3 vViewWorld; // the same thing in world space, for the crevice term + varying float vViewZ; + + void main() { + vUv2 = uv; + + vec4 world = modelMatrix * vec4(position, 1.0); + vWorldXZ = world.xz; + + vec3 ax = normalize(modelMatrix[0].xyz); + vec3 ay = normalize(modelMatrix[1].xyz); + vec3 az = normalize(modelMatrix[2].xyz); + + vLight = normalize(vec3(dot(uLightDir, ax), dot(uLightDir, ay), dot(uLightDir, az))); + + vec3 toCamera = cameraPosition - world.xyz; + vViewWorld = normalize(toCamera); + vView = normalize(vec3(dot(toCamera, ax), dot(toCamera, ay), dot(toCamera, az))); + + vDisrupt = disruptAt(world.xyz); + + vec4 mv = viewMatrix * world; + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const SEEP_FRAGMENT = /* glsl */ ` + ${disruptGLSL} + varying float vDisrupt; + + uniform float uTime; + + /* ---- the lane, all metres ---- */ + uniform vec2 uQuadSize; // metres the quad covers: x across, y downrange + uniform float uLength; // the cast length; dz = 0 is the caster + uniform float uHalfWidth; // half-width of the band the network spreads in + uniform float uTaper; // 0..1 how much narrower the far end is + uniform float uBack; // metres the network reaches back behind the caster + uniform float uEdge; // metres of feather on every boundary + uniform float uRagged; // metres the boundaries wander by + uniform float uRaggedScale; // lobes per metre + uniform float uWarp; // metres of domain warp on those lobes + uniform float uGrow; // 0..1 the advancing front + uniform float uRetreat; // 0..1 the network dying back from the caster + uniform float uSeed; + + /* ---- the buried web ---- */ + uniform float uCell; // metres between hyphae + uniform float uStretch; // >1 draws the strands out along the lane + uniform float uJitter; // 0..1 lattice disorder + uniform float uCore; // metres — half-width of a hypha at zero depth + uniform float uSpread; // extra metres of half-width per metre of depth + uniform float uDepthMin; // metres + uniform float uDepthMax; // metres + uniform float uDepthPitch; // cycles per metre in the burial-depth field + uniform float uAbsorb; // 1/metre — Beer-Lambert through the flags + uniform float uParallax; // 0..1 of the true depth offset + uniform float uBranch; // 0..1 weight of the finer secondary web + uniform float uBranchScale; // >1 how much finer that web is + + /* ---- the nutrient pulses ---- */ + uniform float uPulseSpacing; // metres between pulse crests + uniform float uPulseSpeed; // metres per second they run downrange at + uniform float uPulseSharp; // crest exponent + uniform float uPulseGain; + + /* ---- the escape mask: the floor's own detail ---- */ + uniform float uStoneStep; // metres between the macro-gradient taps + uniform float uSeamLevel; // which contour of the floor's macro field is mortar + uniform float uSeamWidth; // metres — the mortar course, a real measurement + uniform float uSeamWeight; // 0..1 how much light the seams pass + uniform float uSeamRelief; // 0..1 how much a seam behaves like a slot + uniform float uSeamGlow; // extra light on the lip of a lit seam + uniform float uPorePitch; // cycles per metre in the re-synthesised pore field + uniform float uPoreCut; // 0..1 how sparse the open pores are + uniform float uPoreWeight; // 0..1 how much light the pores pass + uniform float uPolishSeal; // 0..1 how completely the floor's sheen patches close + uniform float uBleed; // 0..1 floor on the mask — thin flags are never opaque + + /* ---- output ---- */ + uniform float uFade; + uniform float uOpacity; + uniform float uEmissive; + uniform float uDepthFade; // metres + uniform vec3 uColorDeep; // the far-buried glow + uniform vec3 uColorShallow; // a hypha just under the surface + uniform vec3 uColorPulse; // a nutrient surge running the web + uniform vec3 uColorSeam; // the lip of a crack catching the light + + uniform float uGlobalGlow; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + varying vec2 vUv2; + varying vec2 vWorldXZ; + varying vec3 vLight; + varying vec3 vView; + varying vec3 vViewWorld; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + /** + * Voronoi that reports the distance to the cell **wall** rather than to the + * site, because a mycelial network is a partition of its substrate and the + * hyphae are the walls of that partition, not its centres. + * + * noise.glsl.js#voronoi2 hands back F1, which draws blobs. Half the F1/F2 gap + * is the standard cheap estimate of the distance to the equidistant set — not + * exact near a triple point, and nobody has ever seen the error on something + * this diffuse. + */ + float mycWall(vec2 pt, float jitter, out float id) { + vec2 n = floor(pt); + vec2 f = fract(pt); + float d1 = 8.0; + float d2 = 8.0; + id = 0.0; + + for (int j = -1; j <= 1; j++) { + for (int i = -1; i <= 1; i++) { + vec2 g = vec2(float(i), float(j)); + vec2 o = mix(vec2(0.5), hash21(dot(n + g, vec2(7.13, 113.17))), jitter); + vec2 r = g + o - f; + float d = dot(r, r); + if (d < d1) { + d2 = d1; + d1 = d; + id = hash11(dot(n + g, vec2(31.7, 57.1))); + } else if (d < d2) { + d2 = d; + } + } + } + return 0.5 * (sqrt(d2) - sqrt(d1)); + } + + void main() { + /* ---- uv -> metres in the lane's frame; +y runs downrange ---- */ + vec2 p = vec2(vUv2.x - 0.5, 0.5 - vUv2.y) * uQuadSize; + + // Metres per pixel, taken before the first discard: a derivative evaluated + // in divergent control flow is undefined, and the symptom is a sparkling + // fringe on exactly one vendor's driver. + vec2 pd = fwidth(p); + float px = max(max(pd.x, pd.y), 1e-5); + + // The quad is centred on the middle of the cast, so this is metres + // downrange of the caster and metres sideways off the spine. + float dz = p.y + uLength * 0.5; + float dx = p.x; + + /* ---- the footprint, warped in the plane and never on atan(y, x) ---- */ + // An angular sample hands every radius along a bearing the same value and + // draws dead-straight spokes; a fungus does not grow in spokes. + vec2 warped = p + vec2( + fbm3(vec3(p * uRaggedScale, uSeed)), + fbm3(vec3(p.yx * uRaggedScale, uSeed + 5.0)) + ) * uWarp; + float lobe = fbm3(vec3(warped * uRaggedScale, uSeed + 11.0)); + + float along = clamp(dz / max(uLength, 0.01), 0.0, 1.0); + float halfW = max(uHalfWidth * (1.0 - clamp(uTaper, 0.0, 1.0) * along), 0.02); + float rim = halfW + lobe * uRagged; + float band = smoothstep(rim, rim - max(uEdge, 1e-3), abs(dx)); + + float front = uGrow * (uLength + uEdge) + lobe * uRagged; + float head = smoothstep(front, front - max(uEdge, 1e-3), dz); + // The die-back runs the other way: the network is abandoned from the + // caster outward, so the near end goes first and the far end lingers. + float heel = -uBack + uRetreat * (uLength + uBack) + lobe * uRagged; + float tail = smoothstep(heel - max(uEdge, 1e-3), heel, dz); + + float cover = band * head * tail; + if (cover < 0.002) discard; + + /* ------------------------------------------------------------------ */ + /* The floor's own detail — see the doc comment. These four lines are */ + /* world/Ground.js's, at its frequencies and its phase offsets. */ + /* ------------------------------------------------------------------ */ + vec3 wp = vec3(vWorldXZ.x, 0.0, vWorldXZ.y); + + float macro = fbm3(wp * 0.018); + float step2 = max(uStoneStep, 0.01); + float macroX = fbm3((wp + vec3(step2, 0.0, 0.0)) * 0.018); + float macroZ = fbm3((wp + vec3(0.0, 0.0, step2)) * 0.018); + + // Field units per metre. Dividing the contour distance by it is what makes + // seamWidth a measurement rather than a number. + vec2 grad = vec2(macroX - macro, macroZ - macro) / step2; + float slope = max(length(grad), 1e-4); + float seamMetres = abs(macro - uSeamLevel) / slope; + float seam = 1.0 - smoothstep(uSeamWidth * 0.5, uSeamWidth * 0.5 + px * 1.5, seamMetres); + + // The floor's sheen patches. Polished stone is closed stone. + float polish = smoothstep(0.3, 0.85, fbm3(wp * 0.06 + 3.0) * 0.5 + 0.5); + + // The pores. Re-synthesised rather than shared, because the floor's own + // pore detail is in its normal map and I2 does not let an ability read it. + // The grain the floor *does* compute procedurally biases where they sit. + float grain = snoise01(wp * 0.7); + float pore = smoothstep(uPoreCut, 1.0, snoise01(wp * uPorePitch) * (0.65 + 0.35 * grain)); + + float openness = seam * uSeamWeight + pore * uPoreWeight; + openness *= 1.0 - clamp(uPolishSeal, 0.0, 1.0) * polish; + openness = clamp(max(openness, uBleed), 0.0, 1.0); + + /* ------------------------------------------------------------------ */ + /* The buried network */ + /* ------------------------------------------------------------------ */ + float burial = mix( + uDepthMin, + uDepthMax, + snoise01(vec3(vWorldXZ.x, uSeed, vWorldXZ.y) * uDepthPitch) + ); + + vec3 V = normalize(vView); + // The thing you are looking at is not under the pixel it appears in: it is + // burial metres down, so it sits burial * tan(theta) away from the crack it + // is seen through. Clamping the elevation stops the offset exploding at the + // horizon, where the quad is a pixel tall anyway. + vec2 lane = vec2(dx, dz) - vec2(V.x, V.z) * (burial * uParallax / max(V.y, 0.2)); + + float cellSize = max(uCell, 0.03); + vec2 web = vec2(lane.x / max(uStretch, 0.05), lane.y) / cellSize + uSeed; + + float id = 0.0; + float wall = mycWall(web, clamp(uJitter, 0.0, 1.0), id); + // Approximate: the x axis was divided by uStretch, so a metre across the + // lane is not a metre along it. The error is a widening of the strands seen + // end-on, which is what a stretched network does anyway. + float strand = wall * cellSize; + + float id2 = 0.0; + float fine = max(uBranchScale, 1.0); + float wall2 = mycWall(web * fine + 7.31, clamp(uJitter, 0.0, 1.0), id2); + float strand2 = wall2 * cellSize / fine; + + // Depth blurs. A shallow run is a tight bright thread; a deep one is a + // broad dim smudge, because the stone above it scatters. One Gaussian, + // whose width is a linear function of the burial depth. + float sigma = max(uCore + burial * uSpread, 0.002); + float inv2 = 1.0 / (2.0 * sigma * sigma); + float lit = exp(-strand * strand * inv2); + lit = max(lit, exp(-strand2 * strand2 * inv2) * clamp(uBranch, 0.0, 1.0)); + lit *= exp(-burial * max(uAbsorb, 0.0)); + + /* ---- nutrient pulses, running outward from the caster ---- */ + float phase = fract((dz - uTime * uPulseSpeed) / max(uPulseSpacing, 0.05) + id); + float pulse = pow(1.0 - phase, max(uPulseSharp, 1.0)); + + /* ---- a crack is a slot, and you cannot see all the way into it ---- */ + // Two terms, both real. Looking down a crack you see its glowing floor; + // looking along it you see the near wall instead — that is the elevation + // term. And the two walls are not symmetric about the viewer, so the crack + // brightens on one side as the camera orbits — that is the facing term, + // taken in world space because the stone gradient is a world quantity. + vec2 seamAcross = grad / slope; + vec2 viewFlat = normalize(vec2(vViewWorld.x, vViewWorld.z) + 1e-5); + float facing = dot(seamAcross, viewFlat); + float elevation = clamp(vViewWorld.y, 0.0, 1.0); + float slotted = mix(1.0, elevation * (0.55 + 0.45 * facing), clamp(uSeamRelief, 0.0, 1.0)); + + /* ---- assemble ---- */ + float deepness = clamp( + (burial - uDepthMin) / max(uDepthMax - uDepthMin, 1e-3), + 0.0, + 1.0 + ); + vec3 color = mix(uColorShallow, uColorDeep, deepness); + color += uColorPulse * (pulse * uPulseGain); + color += uColorSeam * (seam * uSeamGlow); + color *= uEmissive; + + // The key light does nothing to this — it is light *leaving* the floor — + // but a fragment facing away from the key sits in the floor's own shadow + // and reads brighter, which is the one place the sun is allowed in. + color *= 1.0 + 0.12 * (1.0 - max(vLight.y, 0.0)); + + float alpha = cover * openness * lit * slotted * uFade * uOpacity; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + float depthBits = unpackRGBAToDepth(texture2D(uSceneDepth, screenUV)); + float sceneViewZ = perspectiveDepthToViewZ(depthBits, uCameraNear, uCameraFar); + alpha *= smoothstep(-max(uDepthFade, 1e-3), 0.0, vViewZ - sceneViewZ); + + if (alpha < 0.004) discard; + color *= uGlobalGlow; + disruptShade(color, alpha, vDisrupt, gl_FragCoord.xy); + gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0)); + } +`; + +/** + * Build the seep quad's material. + * + * Additive, because this is genuinely light being added to a floor that is + * already there. The damp the network leaves behind is a separate, + * alpha-blended `GroundField(WET)` — one mesh cannot both add and subtract, and + * splitting them is what lets the stain be darker than the stone while the + * glow is brighter. + * + * @returns {THREE.ShaderMaterial} with `userData.sync(state)` on it + */ +export function createMyceliumSeepMaterial() { + const material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uQuadSize: { value: new Vector2(12, 20) }, + uLength: { value: 16 }, + uHalfWidth: { value: 2.4 }, + uTaper: { value: 0.25 }, + uBack: { value: 0.8 }, + uEdge: { value: 0.5 }, + uRagged: { value: 0.7 }, + uRaggedScale: { value: 0.5 }, + uWarp: { value: 0.8 }, + uGrow: { value: 0 }, + uRetreat: { value: 0 }, + uSeed: { value: 0 }, + + uCell: { value: 0.42 }, + uStretch: { value: 2.2 }, + uJitter: { value: 0.9 }, + uCore: { value: 0.012 }, + uSpread: { value: 0.55 }, + uDepthMin: { value: 0.02 }, + uDepthMax: { value: 0.22 }, + uDepthPitch: { value: 0.35 }, + uAbsorb: { value: 4.5 }, + uParallax: { value: 1 }, + uBranch: { value: 0.5 }, + uBranchScale: { value: 2.7 }, + + uPulseSpacing: { value: 3.2 }, + uPulseSpeed: { value: 2.4 }, + uPulseSharp: { value: 7 }, + uPulseGain: { value: 0.8 }, + + uStoneStep: { value: 0.35 }, + uSeamLevel: { value: 0.05 }, + uSeamWidth: { value: 0.07 }, + uSeamWeight: { value: 1 }, + uSeamRelief: { value: 0.55 }, + uSeamGlow: { value: 0.35 }, + uPorePitch: { value: 9 }, + uPoreCut: { value: 0.68 }, + uPoreWeight: { value: 0.45 }, + uPolishSeal: { value: 0.85 }, + uBleed: { value: 0.06 }, + + uFade: { value: 1 }, + uOpacity: { value: 1 }, + uEmissive: { value: 1 }, + uDepthFade: { value: 0.5 }, + uColorDeep: { value: new Color(0.09, 0.28, 0.3) }, + uColorShallow: { value: new Color(0.45, 0.95, 0.72) }, + uColorPulse: { value: new Color(0.75, 1, 0.68) }, + uColorSeam: { value: new Color(0.16, 0.42, 0.38) }, + + // Opt in to vfx/SceneHooks.js's disruption field by identity, so a + // Spellbreak standing in the lane reaches this quad too. + ...disruptUniforms() + }), + vertexShader: SEEP_VERTEX, + fragmentShader: SEEP_FRAGMENT + }); + + /** + * Re-resolve every dimension from `settings.mycelium`. Called every frame, + * including a zero-length one — nothing here is ever cached. + * + * @param {object} state `{ length, grow, retreat, fade, seed }` — the beats + * the ability owns. Everything with a unit comes off the settings block. + */ + material.userData.sync = (state) => { + const c = settings.mycelium; + const g = settings.global; + const u = material.uniforms; + + const length = Math.max(0.4, state.length); + + u.uLength.value = length; + u.uGrow.value = state.grow; + u.uRetreat.value = state.retreat; + u.uFade.value = state.fade; + u.uSeed.value = state.seed; + + u.uHalfWidth.value = Math.max(0.05, c.laneHalfWidth); + u.uTaper.value = c.laneTaper; + u.uBack.value = Math.max(0, c.laneBack); + u.uEdge.value = Math.max(0.01, c.laneEdge); + u.uRagged.value = c.laneRagged * g.noiseStrength; + u.uRaggedScale.value = Math.max(0.02, c.laneRaggedScale * g.noiseFrequency); + u.uWarp.value = c.laneWarp * g.noiseStrength; + + u.uCell.value = Math.max(0.03, c.webCell); + u.uStretch.value = Math.max(0.05, c.webStretch); + u.uJitter.value = c.webJitter; + u.uCore.value = Math.max(0.001, c.webCore); + u.uSpread.value = Math.max(0, c.webSpread); + u.uDepthMin.value = Math.max(0, c.webDepthMin); + u.uDepthMax.value = Math.max(c.webDepthMin + 0.005, c.webDepthMax); + u.uDepthPitch.value = Math.max(0.01, c.webDepthPitch * g.noiseFrequency); + u.uAbsorb.value = Math.max(0, c.webAbsorb); + u.uParallax.value = c.webParallax; + u.uBranch.value = c.webBranch; + u.uBranchScale.value = Math.max(1, c.webBranchScale); + + u.uPulseSpacing.value = Math.max(0.05, c.pulseSpacing); + u.uPulseSpeed.value = c.pulseSpeed * g.noiseSpeed; + u.uPulseSharp.value = Math.max(1, c.pulseSharp); + u.uPulseGain.value = c.pulseGain * g.shaderIntensity; + + u.uStoneStep.value = Math.max(0.02, c.stoneStep); + u.uSeamLevel.value = c.seamLevel; + u.uSeamWidth.value = Math.max(0.002, c.seamWidth); + u.uSeamWeight.value = c.seamWeight; + u.uSeamRelief.value = c.seamRelief; + u.uSeamGlow.value = c.seamGlow * g.glow; + u.uPorePitch.value = Math.max(0.1, c.porePitch * g.noiseFrequency); + u.uPoreCut.value = c.poreCut; + u.uPoreWeight.value = c.poreWeight; + u.uPolishSeal.value = c.polishSeal; + u.uBleed.value = c.stoneBleed; + + u.uEmissive.value = c.seepEmissive * g.glow; + u.uOpacity.value = c.seepOpacity * g.opacity; + u.uDepthFade.value = Math.max(0.01, c.seepDepthFade); + u.uColorDeep.value.copy(getColor(c.colorSeepDeep)); + u.uColorShallow.value.copy(getColor(c.colorSeepShallow)); + u.uColorPulse.value.copy(getColor(c.colorSeepPulse)); + u.uColorSeam.value.copy(getColor(c.colorSeepSeam)); + + /* ---- the quad, re-derived every frame so a drag re-scales the canvas ---- */ + const pad = Math.max(0.01, c.laneEdge) + Math.abs(c.laneRagged) + 0.4; + const across = (Math.max(0.05, c.laneHalfWidth) + pad) * 2; + const down = length + Math.max(0, c.laneBack) * 2 + pad * 2; + u.uQuadSize.value.set(across, down); + return u.uQuadSize.value; + }; + + return material; +} diff --git a/src/materials/PetalMaterial.js b/src/materials/PetalMaterial.js new file mode 100644 index 0000000..b9cab38 --- /dev/null +++ b/src/materials/PetalMaterial.js @@ -0,0 +1,459 @@ +import { MeshStandardMaterial, Color, Vector4, DoubleSide } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * Procedurally shaded, procedurally **posed** flowers — the material behind + * Bloomburst. + * + * This is the one place in the project where the vertex shader does more than + * place a ribbon: it *builds the pose*. Every petal on every flower is stored + * once, unbent, as four numbers per vertex — how far along the blade it is, + * how far across, which bearing its blade leaves the heart on, and a per-blade + * dice roll — and the shader bends it from a closed bud to a fully open flower + * on a per-instance clock. Nothing about the pose is on the CPU, which is why + * dragging `unfurlCurve` re-opens a field of flowers that is already standing, + * with the clock stopped. + * + * ## Why the pose has to live here + * + * The obvious first attempt was to bake the bend into the geometry and let + * `GrowthField` scale it. That cannot work: the bend is a *function of time, + * per instance*, and `GrowthField` owns one geometry per variant shared by + * every instance of it. Baking the bend would give the whole field one pose. + * + * The second attempt bent the blade as a circular arc of total turn `theta`, + * which is the textbook answer and looks wrong: constant curvature gives every + * petal the same shape at every stage of opening, so the flower reads as a + * paper fan being spread rather than as a bud splitting. A real petal is nearly + * straight at the base and does almost all of its turning in the outer third. + * So the centreline is a **quadratic Bezier in the (radial, up) plane** whose + * two free control points are simply lerped from the bud pose to the open + * pose. Four sliders per pose, they are readable on a panel, and the shape of + * the interpolation — which is the whole character of the unfurl — is one more + * slider on top, `unfurlCurve`. + * + * ## Where the clock comes from + * + * `aBirth`, the per-instance value `GrowthField` already writes, counts 1 → 0 + * over its `birthFade` seconds from the moment that instance was triggered. + * Bloomburst hands `birthFade` its `unfurlTime`, so `1 - aBirth` is exactly + * "how far open is this flower", staggered per instance by the field's own + * eruption wave and re-derived every frame from a live slider. No second + * attribute, no second clock, and it is live on a paused frame because + * `GrowthField` rewrites `aBirth` from `birthFade` on every call to `update()`. + * + * ## Shading + * + * Built on `MeshStandardMaterial` for the same reason `IceMaterial` is: the + * flowers take the stage's real shadows and its HDR probe, and the stylisation + * is injected on top — a base-to-tip gradient, a radiating vein fan off the + * midrib, a fibrous world-space grain so two petals are not the same flat + * wash, a backlit translucency term (a petal is thin, and the read of a flower + * in a low sun is the light coming *through* it), and a heart glow that the + * pollen volume sits on. + * + * `flatShading` is on, so there is no `vNormal` varying and every + * view-dependent term reads the face normal that `` + * derives from screen derivatives. That is also what makes the vertex-shader + * pose work at all: a deformed mesh gets correct normals for free, which it + * would not if the normals had been transformed from the unbent attribute. + * + * Per-instance inputs arrive as instanced attributes (`aSeed`, `aBirth`), so + * this material is only ever used on an `InstancedMesh`. + */ +export function createPetalMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.62, + metalness: 0.0, + flatShading: true, + transparent: true, + // A petal has no back: you see the underside of the far ones through the + // gap between the near ones, and culling them leaves a flower that is + // hollow from every angle except dead on. + side: DoubleSide, + depthWrite: true + }); + + const uniforms = { + // No clock. Every other patched material in the project carries `uTime`, + // and this one deliberately does not: a flower's whole animation is its + // pose, which is driven per instance off `aBirth`, and a global time-based + // shimmer on top of that turns a still field into a shivering one. If you + // add one, add it as a slider first. + uLightDir: frame.uLightDir, + + /* --- the stalk and the heart, in the flower's own unit space --- */ + uStemTop: { value: 0.42 }, // fraction of the flower's height the stalk occupies + uStemRadius: { value: 0.045 }, // stalk radius at the floor + uHeartRadius: { value: 0.085 }, // radius the petals are hinged on + + /* --- the blade --- */ + uWidth: { value: 0.3 }, // half-width at the widest point + uWidthBias: { value: 0.78 }, // <1 pushes the widest point toward the base + uTaper: { value: 0.72 }, // >1 sharpens the tip, <1 rounds it + uCrease: { value: 0.42 }, // the lengthwise fold, as a fraction of the half-width + uCup: { value: 0.34 }, // cross-sectional curl, same units + + /* --- the two poses the unfurl runs between --- */ + // (mid.radial, mid.up, tip.radial, tip.up) — Bezier control points in the + // blade's own (radial, up) plane, in units of the flower's height. + uBud: { value: new Vector4(0.02, 0.34, 0.06, 0.68) }, + uOpen: { value: new Vector4(0.3, 0.42, 0.62, 0.16) }, + uUnfurlCurve: { value: 1.5 }, + uPetalStagger: { value: 0.28 }, + // 0..1 of the birth ramp spent still climbing out of the floor, before the + // bud is allowed to start opening. See the note in the vertex stage. + uOpenDelay: { value: 0.35 }, + + /* --- the release --- */ + uRelease: { value: 0 }, // 0..1, driven by the phase clock, not a slider + uThrow: { value: 0.9 }, // radial metres-of-unit-height a freed petal travels + uLift: { value: 0.55 }, + uSpin: { value: 1.35 }, // radians it tumbles about its own hinge + uShrink: { value: 0.85 }, // how far it closes to nothing on the way out + uWilt: { value: 0.65 }, // how far the stalk folds once the petals have gone + + /* --- colour --- */ + uColorStem: { value: new Color() }, + uColorBase: { value: new Color() }, + uColorMid: { value: new Color() }, + uColorTip: { value: new Color() }, + uColorVein: { value: new Color() }, + uColorHeart: { value: new Color() }, + + uVeins: { value: 0.55 }, + uVeinCount: { value: 5 }, + uGrain: { value: 0.22 }, + uGrainScale: { value: 7.5 }, + uFresnel: { value: 1.1 }, + uFresnelPower: { value: 2.6 }, + uTranslucency: { value: 1.8 }, + uTranslucencyPower: { value: 4.0 }, + uGlow: { value: 1.0 }, + uEdgeGlow: { value: 0.7 }, + uHeartGlow: { value: 2.4 }, + uBirthGlow: { value: 1.6 } + }; + + environment.registerShadowCasterWithPatch(material, (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + `#include + attribute vec4 aPetal; // (along 0..1, across -1..1, blade bearing, blade dice) + attribute float aPart; // 0 = stalk, 1 = petal + attribute float aSeed; // per instance + attribute float aBirth; // per instance, 1 -> 0 over the unfurl + uniform float uStemTop; + uniform float uStemRadius; + uniform float uHeartRadius; + uniform float uWidth; + uniform float uWidthBias; + uniform float uTaper; + uniform float uCrease; + uniform float uCup; + uniform vec4 uBud; + uniform vec4 uOpen; + uniform float uUnfurlCurve; + uniform float uPetalStagger; + uniform float uOpenDelay; + uniform float uRelease; + uniform float uThrow; + uniform float uLift; + uniform float uSpin; + uniform float uShrink; + uniform float uWilt; + varying float vPart; + varying float vAlong; + varying float vAcross; + varying float vBloom; + varying float vBirth; + varying float vFlowerSeed; + varying vec3 vPetalWorld;` + ) + .replace( + '#include ', + `#include + { + float along = aPetal.x; + float across = aPetal.y; + float bearing = aPetal.z; + float dice = aPetal.w; + + // The unfurl clock. GrowthField counts aBirth from 1 down to 0 over + // its birthFade seconds, and this ability hands birthFade the sum of + // its rise and its unfurl, so 1 - aBirth is a per-instance ramp + // across both — re-resolved on every call to the field's update(), + // a zero-length one included. + // + // uOpenDelay is the share of that ramp the stalk is still climbing + // for. Without it a bud starts splitting while it is half buried, + // which loses the whole first beat: the read of this ability is + // stalks, THEN flowers, and if they overlap you only ever see + // flowers. + float bloom = clamp(1.0 - aBirth, 0.0, 1.0); + float delay = clamp(uOpenDelay, 0.0, 0.95); + bloom = clamp((bloom - delay) / max(1e-3, 1.0 - delay), 0.0, 1.0); + // ...and a small ripple inside each flower, so the petals do not all + // let go on the identical frame. Without it a six-petal flower opens + // like an umbrella; with it, it opens like a flower. + float stagger = clamp(uPetalStagger, 0.0, 0.9); + bloom = clamp((bloom - dice * stagger) / max(1e-3, 1.0 - stagger), 0.0, 1.0); + bloom = pow(bloom, max(0.05, uUnfurlCurve)); + + vPart = aPart; + vAlong = along; + vAcross = across; + vBloom = bloom; + vBirth = aBirth; + vFlowerSeed = aSeed; + + vec3 outward = vec3(cos(bearing), 0.0, sin(bearing)); + vec3 lateral = vec3(-sin(bearing), 0.0, cos(bearing)); + + if (aPart < 0.5) { + // The stalk. Stored as a unit prism and given its real proportions + // here, so stem height and thickness stay live sliders that need no + // geometry rebuild. aPetal.y carries a 0..1 radial scale, which is + // how the cap fan gets its centre vertex. + float grip = mix(uStemRadius, uHeartRadius, along) * across; + float wilt = 1.0 - uWilt * uRelease; + transformed = outward * grip * wilt + vec3(0.0, uStemTop * along * wilt, 0.0); + } else { + // The centreline, as a quadratic Bezier from the hinge on the rim + // of the heart out to the tip. Both free control points lerp from + // the bud pose to the open pose; that lerp is the whole unfurl. + vec2 hinge = vec2(uHeartRadius, 0.0); + vec2 mid = mix(uBud.xy, uOpen.xy, bloom); + vec2 tip = mix(uBud.zw, uOpen.zw, bloom); + + float s = along; + float ms = 1.0 - s; + vec2 centre = ms * ms * hinge + 2.0 * ms * s * mid + s * s * tip; + vec2 slope = 2.0 * ms * (mid - hinge) + 2.0 * s * (tip - mid); + + vec2 tangentPlane = normalize(slope + vec2(1e-5, 1e-5)); + // In-plane normal. For a blade standing up this comes out pointing + // radially outward, which is the face of the petal. + vec2 facePlane = vec2(tangentPlane.y, -tangentPlane.x); + + // Half-width along the blade. A sine raised to a power: zero at the + // hinge, widest a little past the middle, and rounded or pointed at + // the tip depending on uTaper. Evaluated here rather than baked so + // both controls stay live. + float halfWidth = uWidth * + pow(max(sin(3.14159265 * pow(clamp(along, 0.0, 1.0), uWidthBias)), 0.0), uTaper); + halfWidth *= 1.0 - uShrink * uRelease; + + // The lengthwise crease is a tent fold about the midrib; the cup is + // the cross-section curling toward the axis. Two separate marks: + // the crease is what catches the key light down the middle of the + // blade, the cup is what makes the flower hold water. + float fold = uCrease * halfWidth * (1.0 - abs(across)); + float curl = -uCup * halfWidth * (1.0 - across * across); + vec2 planar = centre + facePlane * (fold + curl); + + // The release: every petal in the field lets go on the same frame, + // tumbling about its own hinge and thrown outward and up. + if (uRelease > 0.0) { + float turn = uSpin * uRelease * (0.35 + dice); + vec2 arm = planar - hinge; + float cs = cos(turn); + float sn = sin(turn); + planar = hinge + vec2(arm.x * cs - arm.y * sn, arm.x * sn + arm.y * cs); + planar += vec2(uThrow, uLift) * uRelease * (0.55 + 0.9 * dice); + } + + transformed = outward * planar.x + + lateral * (across * halfWidth) + + vec3(0.0, uStemTop + planar.y, 0.0); + } + + #ifdef USE_INSTANCING + vPetalWorld = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz; + #else + vPetalWorld = (modelMatrix * vec4(transformed, 1.0)).xyz; + #endif + }` + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform vec3 uLightDir; + uniform vec3 uColorStem; + uniform vec3 uColorBase; + uniform vec3 uColorMid; + uniform vec3 uColorTip; + uniform vec3 uColorVein; + uniform vec3 uColorHeart; + uniform float uVeins; + uniform float uVeinCount; + uniform float uGrain; + uniform float uGrainScale; + uniform float uFresnel; + uniform float uFresnelPower; + uniform float uTranslucency; + uniform float uTranslucencyPower; + uniform float uGlow; + uniform float uEdgeGlow; + uniform float uHeartGlow; + uniform float uBirthGlow; + uniform float uRelease; + varying float vPart; + varying float vAlong; + varying float vAcross; + varying float vBloom; + varying float vBirth; + varying float vFlowerSeed; + varying vec3 vPetalWorld; + ${noiseGLSL}` + ) + // Injected once the normal is resolved: with flatShading there is no + // vNormal varying, so every view-dependent term below has to read the + // face normal that derives from derivatives. + .replace( + '#include ', + `#include + { + vec3 N = normalize(normal); + vec3 V = normalize(vViewPosition); + float ndv = clamp(dot(N, V), 0.0, 1.0); + float rim = pow(1.0 - ndv, uFresnelPower); + + vec3 body; + float emissiveMask = 0.0; + + if (vPart < 0.5) { + body = uColorStem; + // A little sap-green variation up the stalk so a field of forty + // does not read as forty copies of one extruded tube. + body *= 0.86 + 0.28 * fbm3(vPetalWorld * 3.1 + vFlowerSeed * 9.0); + } else { + body = mix(uColorBase, uColorMid, smoothstep(0.0, 0.55, vAlong)); + body = mix(body, uColorTip, smoothstep(0.42, 1.0, vAlong)); + + // Veins. The midrib is a hard ridge at across = 0; the fan is a + // set of ribs running out from it, swept back along the blade. + // The first version drew the fan as cos(across * n) alone, which + // gives ribs parallel to the midrib all the way to the tip — a + // corduroy petal. Sweeping the phase with vAlong is what makes + // them converge on the hinge the way a real vein pattern does. + float across = abs(vAcross); + float midrib = pow(1.0 - across, 7.0); + float fan = cos(vAcross * uVeinCount * 3.14159265 - vAlong * 2.6); + float vein = midrib + 0.4 * smoothstep(0.72, 1.0, abs(fan)) * (1.0 - across); + body = mix(body, uColorVein, clamp(vein * uVeins, 0.0, 1.0)); + + // A fibrous grain in world space, so neighbouring petals look cut + // from the same tissue rather than each carrying its own pattern. + body *= 1.0 + uGrain * (fbm3(vPetalWorld * uGrainScale + vFlowerSeed * 13.0) - 0.5); + + // The heart. Bright, tight to the hinge, and the thing the pollen + // volume is anchored on visually. + emissiveMask = smoothstep(0.24, 0.0, vAlong) * vBloom; + } + + diffuseColor.rgb *= body; + + // Backlit translucency. A petal is two cells thick and the read of a + // flower in a low sun is entirely the light coming through it, not + // the light bouncing off it — so this term is deliberately stronger + // than the diffuse response it sits on top of. + float through = pow(clamp(dot(V, -uLightDir), 0.0, 1.0), uTranslucencyPower); + float thin = step(0.5, vPart) * (0.35 + 0.65 * smoothstep(0.1, 0.9, vAlong)); + + vec3 glow = uColorTip * through * thin * uTranslucency; + glow += uColorVein * rim * uFresnel * uEdgeGlow; + glow += uColorHeart * emissiveMask * uHeartGlow; + glow += uColorHeart * vBirth * uBirthGlow * step(0.5, vPart); + glow *= uGlow; + + // Soft ceiling, as on the ice: every term above peaks at a grazing + // angle and they stack, and without this a petal on the silhouette + // sums past 4x white and the bloom pass smears the whole field. + glow /= 1.0 + glow * 0.3; + + totalEmissiveRadiance += glow; + + // Petals thin out as they are thrown; the stalk does not. + float shed = step(0.5, vPart) * uRelease; + diffuseColor.a = clamp(diffuseColor.a * (1.0 - shed * shed), 0.0, 1.0); + }` + ); + }); + + material.userData.uniforms = uniforms; + + /** + * Pull the pose, the palette and every shading control from live settings. + * + * @param {number} release 0..1 phase value — how far the petals have let go. + * Not a slider: it is the fade clock, and the only argument here that + * does not come out of `settings.bloomburst`. + */ + material.userData.sync = (release = 0) => { + const c = settings.bloomburst; + const g = settings.global; + + uniforms.uStemTop.value = c.stemFrac; + uniforms.uStemRadius.value = c.stemRadius; + uniforms.uHeartRadius.value = c.heartRadius; + + uniforms.uWidth.value = c.petalWidth; + uniforms.uWidthBias.value = c.petalWidthBias; + uniforms.uTaper.value = c.petalTaper; + uniforms.uCrease.value = c.petalCrease; + uniforms.uCup.value = c.petalCup; + + uniforms.uBud.value.set(c.budMidOut, c.budMidUp, c.budTipOut, c.budTipUp); + uniforms.uOpen.value.set(c.openMidOut, c.openMidUp, c.openTipOut, c.openTipUp); + uniforms.uUnfurlCurve.value = c.unfurlCurve; + uniforms.uPetalStagger.value = c.petalStagger; + // The split of the field's birth ramp between climbing and opening. It has + // to be derived rather than authored, because the ramp `GrowthField` writes + // is exactly `riseTime + unfurlTime` long — see `_fillGrowth`. + uniforms.uOpenDelay.value = c.riseTime / Math.max(1e-3, c.riseTime + c.unfurlTime); + + uniforms.uRelease.value = release; + uniforms.uThrow.value = c.releaseThrow; + uniforms.uLift.value = c.releaseLift; + uniforms.uSpin.value = c.releaseSpin; + uniforms.uShrink.value = c.releaseShrink; + uniforms.uWilt.value = c.stemWilt; + + uniforms.uColorStem.value.copy(getColor(c.colorStem)); + uniforms.uColorBase.value.copy(getColor(c.colorPetalBase)); + uniforms.uColorMid.value.copy(getColor(c.colorPetalMid)); + uniforms.uColorTip.value.copy(getColor(c.colorPetalTip)); + uniforms.uColorVein.value.copy(getColor(c.colorVein)); + uniforms.uColorHeart.value.copy(getColor(c.colorHeart)); + + uniforms.uVeins.value = c.veins * g.shaderIntensity; + uniforms.uVeinCount.value = c.veinCount; + uniforms.uGrain.value = c.grain * g.shaderIntensity; + uniforms.uGrainScale.value = c.grainScale * g.noiseFrequency; + uniforms.uFresnel.value = c.fresnel * g.fresnel; + uniforms.uFresnelPower.value = c.fresnelPower; + uniforms.uTranslucency.value = c.translucency; + uniforms.uTranslucencyPower.value = c.translucencyPower; + uniforms.uGlow.value = c.petalGlow * g.glow; + uniforms.uEdgeGlow.value = c.edgeGlow; + uniforms.uHeartGlow.value = c.heartGlow; + uniforms.uBirthGlow.value = c.birthGlow; + + material.opacity = c.petalOpacity * g.opacity; + material.roughness = c.petalRoughness; + material.envMapIntensity = c.envIntensity; + }; + + material.userData.sync(0); + return material; +} diff --git a/src/materials/PhotonBeamMaterial.js b/src/materials/PhotonBeamMaterial.js new file mode 100644 index 0000000..2f1102e --- /dev/null +++ b/src/materials/PhotonBeamMaterial.js @@ -0,0 +1,509 @@ +import { + AdditiveBlending, + BackSide, + Color, + CylinderGeometry, + InstancedBufferAttribute, + InstancedBufferGeometry, + ShaderMaterial, + Sphere, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; + +/* ---------------------------------------------------------------------- */ +/* PhotonBeamMaterial — a three-dimensional grid of beams, in one draw call */ +/* ---------------------------------------------------------------------- */ + +/** + * An instanced lattice of thin gaussian beams whose brightness at any pixel is + * the **closed-form line integral** of a gaussian tube along the view ray. + * + * ## Why this is not `vfx/Tube.js` + * + * `Tube` is the right module for one beam and it draws a very good one — three + * passes, core, sheath and halo, and the reason the Nova Beam reads as solid. + * It is also **one tube per instance of the class**: `t.group`, `t.materials`, + * `t.geometry`, one path, three meshes. A 4×4×4 lattice is forty-eight beams, + * which through `Tube` is forty-eight groups and **one hundred and forty-four + * draw calls** against a budget of twelve. There is no instanced mode to reach + * for; the module's whole interface is a single `state = {origin, target, …}`. + * So this file exists, and it borrows `Tube`'s three-layer idea rather than its + * geometry: the core and the halo are two gaussians of different width summed + * in one fragment, which is cheaper than two passes and — because they are + * summed before the blend rather than after it — behaves correctly where four + * of them overlap, which is the entire point of the ability that uses it. + * + * ## The integral, and why it is closed form + * + * A beam here is not a surface. It is a line segment with a gaussian density + * around it, and what you see at a pixel is + * + * ``` + * I = ∫ exp( -d(s)² / σ² ) ds over the part of the view ray inside the beam + * ``` + * + * where `d(s)` is the distance from the ray at parameter `s` to the beam's axis. + * Because both the ray and the axis are straight, `d(s)²` is exactly a quadratic + * `A·s² + 2B·s + C`, and the integral of a gaussian in a quadratic is an error + * function. So it is evaluated in closed form, with the Winitzki approximation + * to `erf`, in about a dozen instructions. + * + * The first version marched it, the way `LightShaft` marches its cone, and that + * is the version that taught me why this one is worth the algebra. A marched + * thin beam is *catastrophically* worse than a marched fat cone: the beam is a + * centimetre or two across, so at sixteen steps down a twelve-metre ray a + * typical sample count **inside** the beam is one, sometimes zero, and the beam + * flickers along its length as the dither moves. Pushing to sixty-four steps + * fixed the flicker and cost forty-eight beams' worth of fill. Worse, the noise + * lands squarely on the thing the ability is about: a node is bright because two + * integrals *add*, and two integrals that are each ±40 % noise do not make a + * node, they make a bright speckle in roughly the right place. Closed form is + * exact, it is cheaper than eight steps, and the doubling at a crossing is + * exactly 2.00. + * + * ## What it draws + * + * One `InstancedBufferGeometry` — a capped unit cylinder per beam — and **one + * draw call** for the whole lattice, no textures. The hull is only a bound; the + * beam is solved analytically inside it, so `sides` can be six. + * + * Back faces with the depth test off, for `LightShaft`'s reasons and not for a + * different set that happens to agree: every view ray entering the hull gets + * exactly one fragment to integrate in whether the camera is inside the lattice + * or outside it, the caps are on because a ray straight down a beam would + * otherwise find no back face and punch a hole along its own axis, and occlusion + * is part of the integral rather than a test — the far limit is the depth + * buffer, so a beam passing behind the character genuinely stops contributing at + * the shoulder instead of being thrown away whole. + * + * ## Invariants + * + * - **I1** — per-beam state is *an index*. Nothing else. Every metre — the + * grid's extent, the spacing between nodes, the overhang past the outer nodes, + * the beam radius — is resolved from a uniform in the vertex shader each + * frame, so a paused lattice re-lays itself under a slider drag and beams + * move between families if the node counts change. + * - **I3** — `sync()` writes into existing uniform boxes; the geometry is + * allocated once at `capacity`. + * - **I5** — four pickers, none derived from another. + * - **I7/I8** — one mesh, and the uniforms live on `material.uniforms` because + * this is a `ShaderMaterial` and not a patched standard one. + */ + +/** Beams the geometry is allocated for. 3 × 5 × 5 = 75 is a 5-node lattice. */ +export const MAX_PHOTON_BEAMS = 96; + +/* ---------------------------------------------------------------------- */ +/* Vertex — where a beam is, and how much of it is drawn */ +/* ---------------------------------------------------------------------- */ + +/** + * The whole lattice is derived from one instance index against three live node + * counts, which is what makes the grid a *slider* rather than a build step. + * + * Beams come in three families — those running across (`+side`), those running + * up (`+up`) and those running downrange (`+along`) — and the index space is + * laid out as the three families' plane-grids back to back. Family 0 has + * `gy × gz` members, family 1 has `gx × gz`, family 2 has `gx × gy`. Change + * `uGrid` and every beam moves, several of them into a different family, with + * no buffer touched. + */ +const BEAM_VERTEX = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform float uTime; // seconds — the only clock this stage reads + + uniform vec3 uAnchor; // world, on the floor, centre of the footprint + uniform vec3 uAlong; // unit, the cast's heading + uniform vec3 uSide; // unit, across it + uniform vec3 uUp; // unit, normally +Y + + uniform vec3 uGrid; // node counts per axis (x across, y up, z downrange) + uniform vec3 uSpan; // HALF-extents of the lattice, metres + uniform float uLift; // metres the lattice centre floats above the floor + uniform float uOverhang; // metres a beam runs past the outermost node + uniform float uSpin; // radians the lattice is yawed about its own up + uniform float uSeed; + + uniform float uRadius; // metres — the core gaussian's sigma + uniform float uHaloScale; // halo sigma as a multiple of the core's + uniform float uHullPad; // sigmas of tail the bounding hull has to cover + + uniform float uAssemble; // 0..1 — how much of the lattice has been drawn in + uniform float uStagger; // 0..1 — how far apart the beams switch on + uniform float uFlicker; // 0..1 depth of the per-beam breath + uniform float uFlickerSpeed; // breaths per second + uniform float uFade; // 0..1 master + + attribute float aIndex; + + varying vec3 vWorld; + varying vec3 vOrigin; // the beam's start, world metres + varying vec3 vDir; // unit, along the beam + varying vec2 vSpan; // metres along the beam that are drawn: (from, to) + varying vec2 vBeam; // x = amplitude 0..1, y = the beam's full length + + ${noiseGLSL} + + /** + * Node i of n, as -1..1 across the lattice. + * + * A single node on an axis sits in the middle rather than at -1, because a + * 1 x 4 x 4 lattice should be one plane of crossings and not one face of a + * box that is not there. + */ + float latCoord(float i, float n) { + return n > 1.5 ? (i / (n - 1.0)) * 2.0 - 1.0 : 0.0; + } + + void main() { + float gx = max(floor(uGrid.x + 0.5), 1.0); + float gy = max(floor(uGrid.y + 0.5), 1.0); + float gz = max(floor(uGrid.z + 0.5), 1.0); + float nAcross = gy * gz; + float nUp = gx * gz; + float nDown = gx * gy; + + if (aIndex > nAcross + nUp + nDown - 0.5) { + // Collapsed off-screen rather than scaled to zero: a degenerate triangle + // still rasterises a sliver on some drivers and this shader's fragment is + // the expensive one. + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + /* ---- the lattice's own frame ---- */ + float cs = cos(uSpin); + float sn = sin(uSpin); + vec3 ex = normalize(uSide * cs + uAlong * sn); + vec3 ez = normalize(uAlong * cs - uSide * sn); + vec3 ey = uUp; + vec3 mid = uAnchor + ey * uLift; + + /* ---- which beam is this ---- */ + vec3 dirV; + vec3 off; + float reach; + if (aIndex < nAcross - 0.5) { + float j = floor(aIndex / gz); + float k = aIndex - j * gz; + dirV = ex; + reach = uSpan.x + uOverhang; + off = ey * (latCoord(j, gy) * uSpan.y) + ez * (latCoord(k, gz) * uSpan.z); + } else if (aIndex < nAcross + nUp - 0.5) { + float slot = aIndex - nAcross; + float i = floor(slot / gz); + float k = slot - i * gz; + dirV = ey; + reach = uSpan.y + uOverhang; + off = ex * (latCoord(i, gx) * uSpan.x) + ez * (latCoord(k, gz) * uSpan.z); + } else { + float slot = aIndex - nAcross - nUp; + float i = floor(slot / gy); + float j = slot - i * gy; + dirV = ez; + reach = uSpan.z + uOverhang; + off = ex * (latCoord(i, gx) * uSpan.x) + ey * (latCoord(j, gy) * uSpan.y); + } + + float len = max(2.0 * reach, 0.02); + vec3 origin = mid + off - dirV * reach; + + /* ---- how much of it is drawn, and how bright ---- */ + float dice = hash11(aIndex * 1.7 + uSeed); + float threshold = dice * uStagger; + float grow = clamp((uAssemble - threshold) / max(1.0 - uStagger, 1e-3), 0.0, 1.0); + + // Drawn from the middle out. A beam that grows from one end has a *tip*, + // and a tip travelling along a lattice edge reads as a projectile being + // fired down a wire; growing symmetrically reads as the beam coming into + // existence, which is what light does. + float from = (0.5 - 0.5 * grow) * len; + float to = (0.5 + 0.5 * grow) * len; + + float breath = 1.0 - uFlicker * 0.5 * (1.0 - cos(uTime * uFlickerSpeed * TAU + dice * TAU)); + float amp = uFade * breath * grow; + + if (amp <= 0.002 || to - from < 1e-4) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + vOrigin = origin; + vDir = dirV; + vSpan = vec2(from, to); + vBeam = vec2(amp, len); + + /* ---- the bound ---- + * A gaussian is never zero, so the hull has to cover enough of the tail + * that clipping it is invisible: uHullPad is in sigmas, and three is about + * exp(-9) = 1e-4 of the peak. */ + float rad = uRadius * max(uHaloScale, 1.0) * uHullPad; + float k = position.y + 0.5; + float a = mix(from, to, k); + vec3 ref = abs(dot(dirV, vec3(0.0, 1.0, 0.0))) > 0.95 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0); + vec3 perpA = normalize(cross(ref, dirV)); + vec3 perpB = cross(dirV, perpA); + vec3 world = origin + dirV * a + (perpA * position.x + perpB * position.z) * rad; + + vWorld = world; + gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0); + } +`; + +/* ---------------------------------------------------------------------- */ +/* Fragment — the closed-form integral */ +/* ---------------------------------------------------------------------- */ + +const BEAM_FRAGMENT = /* glsl */ ` + uniform float uRadius; + uniform float uHaloScale; + uniform float uDensity; // per metre — turns the integral into brightness + uniform float uCoreGain; + uniform float uHaloGain; + uniform float uEndTaper; // 0..1 of the beam over which the ends fade in + uniform float uEndTint; // 0..1 how far the ends take uColorEnd + uniform float uPulse; // extra brightness in the travelling band + uniform float uPulseAt; // 0..1 along the beam + uniform float uPulseWidth; // 0..1 of the beam + uniform float uIntensity; + + uniform vec3 uColorCore; + uniform vec3 uColorHalo; + uniform vec3 uColorEnd; + + uniform sampler2D uSceneDepth; + uniform vec2 uResolution; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + + varying vec3 vWorld; + varying vec3 vOrigin; + varying vec3 vDir; + varying vec2 vSpan; + varying vec2 vBeam; + + ${commonGLSL} + + /** + * Winitzki's approximation to erf. Maximum absolute error about 2e-4, which + * is four orders of magnitude better than the marched version this replaced + * and costs one exp and one sqrt. + */ + float erfApprox(float x) { + float ax = abs(x); + float x2 = ax * ax; + float t = (1.2732395447 + 0.147 * x2) / (1.0 + 0.147 * x2); + return sign(x) * sqrt(1.0 - exp(-x2 * t)); + } + + /** + * The whole trick, in eight lines. + * + * The squared distance from the view ray to the beam's axis is exactly + * A s^2 + 2B s + C, so completing the square gives a gaussian in (s - sm) + * whose integral between two limits is a difference of error functions. + * 0.8862269255 is sqrt(pi)/2. + * + * The A -> 0 branch is not a numerical guard bolted on afterwards: it is the + * case where the eye is looking straight down the beam, the distance to the + * axis is constant along the whole ray, and the answer is genuinely the + * segment length times a constant. It is also the case that produces the + * brightest pixel in the ability, so getting it wrong is not subtle. + */ + float gaussLine(float qa, float qb, float qc, float s0, float s1, float sigma) { + float inv = 1.0 / max(sigma, 1e-4); + if (qa < 1e-8) return (s1 - s0) * exp(-qc * inv * inv); + float rootA = sqrt(qa); + float sm = -qb / qa; + float floorSq = max(qc - qb * qb / qa, 0.0); + float scale = sigma * 0.8862269255 / rootA; + float hi = erfApprox(rootA * (s1 - sm) * inv); + float lo = erfApprox(rootA * (s0 - sm) * inv); + return exp(-floorSq * inv * inv) * scale * (hi - lo); + } + + void main() { + float amp = vBeam.x; + float len = vBeam.y; + if (amp <= 0.002) discard; + + vec3 ro = cameraPosition; + vec3 rd = normalize(vWorld - ro); + + vec3 axis = vDir; + vec3 rel = ro - vOrigin; + float a0 = dot(rel, axis); + float ad = dot(rd, axis); + + /* ---- the drawn part of the beam, as a range of s ---- */ + float sNear = 0.0; + float sFar = 1e6; + if (abs(ad) > 1e-5) { + float s1 = (vSpan.x - a0) / ad; + float s2 = (vSpan.y - a0) / ad; + sNear = min(s1, s2); + sFar = max(s1, s2); + } else if (a0 < vSpan.x || a0 > vSpan.y) { + discard; + } + + /* ---- and how far the ray may go before something opaque stops it ---- */ + vec2 screenUV = gl_FragCoord.xy / uResolution; + float depthBits = unpackRGBAToDepth(texture2D(uSceneDepth, screenUV)); + float sceneViewZ = perspectiveDepthToViewZ(depthBits, uCameraNear, uCameraFar); + // The third row of the view matrix is the camera's own -Z in world space, + // which converts a view-space depth into a distance along OUR ray with no + // inverse projection. + vec3 row2 = vec3(viewMatrix[0][2], viewMatrix[1][2], viewMatrix[2][2]); + float axialCos = dot(row2, rd); + float sScene = abs(axialCos) > 1e-4 ? sceneViewZ / axialCos : 1e6; + if (sScene < 0.0) sScene = 1e6; + + sNear = max(sNear, 0.0); + sFar = min(sFar, sScene); + if (sFar <= sNear) discard; + + /* ---- the quadratic ---- */ + vec3 perpRel = rel - axis * a0; + vec3 perpRay = rd - axis * ad; + float qa = dot(perpRay, perpRay); + float qb = dot(perpRay, perpRel); + float qc = dot(perpRel, perpRel); + + float sigmaCore = max(uRadius, 1e-3); + float sigmaHalo = sigmaCore * max(uHaloScale, 1.0); + float core = gaussLine(qa, qb, qc, sNear, sFar, sigmaCore); + float halo = gaussLine(qa, qb, qc, sNear, sFar, sigmaHalo); + if (core + halo < 1e-5) discard; + + /* ---- where along the beam this pixel is looking ---- + * The closest approach, clamped into the drawn range. The axial profile is + * evaluated once there rather than under the integral, which is the one + * approximation in this file: it is exact for a ray crossing the beam and + * wrong by half a taper width for a ray running along it, where the taper + * is the least visible thing on screen anyway. */ + float sMid = qa > 1e-8 ? clamp(-qb / qa, sNear, sFar) : (sNear + sFar) * 0.5; + float an = clamp((a0 + sMid * ad) / max(len, 1e-4), 0.0, 1.0); + + float taper = smoothstep(0.0, max(uEndTaper, 1e-3), an) * + smoothstep(0.0, max(uEndTaper, 1e-3), 1.0 - an); + float dp = (an - uPulseAt) / max(uPulseWidth, 1e-3); + float pulse = 1.0 + uPulse * exp(-dp * dp); + + float endMix = clamp(abs(an - 0.5) * 2.0, 0.0, 1.0) * clamp(uEndTint, 0.0, 1.0); + vec3 tint = mix(uColorCore, uColorEnd, endMix); + + vec3 rgb = tint * (core * uCoreGain) + uColorHalo * (halo * uHaloGain); + rgb *= uDensity * taper * pulse * amp * uIntensity * uShaderIntensity; + + if (max(max(rgb.r, rgb.g), rgb.b) < 0.003) discard; + + // Alpha 1 with AdditiveBlending (SrcAlpha, One): the destination gets rgb, + // once, and two beams crossing get exactly the sum of two beams. Writing a + // luminance into alpha instead — the obvious thing — makes the blend square + // it, and a node would then be four times a beam rather than twice, which + // sounds better and is a lie. + gl_FragColor = vec4(rgb * uGlobalGlow, 1.0); + } +`; + +/* ---------------------------------------------------------------------- */ +/* Construction */ +/* ---------------------------------------------------------------------- */ + +/** + * The instanced hull. Capped, six-sided, and only ever a bound. + * + * @param {number} [capacity=MAX_PHOTON_BEAMS] beams the buffer is sized for + * @param {number} [sides=6] hull tessellation + */ +export function createPhotonBeamGeometry(capacity = MAX_PHOTON_BEAMS, sides = 6) { + const count = Math.max(1, Math.round(capacity)); + const slots = new Float32Array(count); + for (let i = 0; i < count; i++) slots[i] = i; + + const hull = new CylinderGeometry(1, 1, 1, Math.max(3, Math.round(sides)), 1, false); + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', hull.getAttribute('position')); + geometry.setIndex(hull.getIndex()); + geometry.setAttribute('aIndex', new InstancedBufferAttribute(slots, 1)); + geometry.instanceCount = 0; + // Placed in world space by the vertex shader; its own bounds mean nothing. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + // `hull` is deliberately not disposed: the instanced geometry holds its + // attribute objects by reference and disposing it would tell the renderer to + // delete GL buffers the mesh is still drawing from. It has never been + // uploaded on its own, so nothing leaks. + return geometry; +} + +/** Every uniform, with its unit. The ability writes these every frame. */ +export function createPhotonBeamMaterial() { + return new ShaderMaterial({ + transparent: true, + depthWrite: false, + // Occlusion is part of the integral. See the class comment. + depthTest: false, + blending: AdditiveBlending, + side: BackSide, + toneMapped: false, + uniforms: sharedUniforms({ + uAnchor: { value: new Vector3() }, + uAlong: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + uUp: { value: new Vector3(0, 1, 0) }, + + uGrid: { value: new Vector3(4, 4, 4) }, + uSpan: { value: new Vector3(3, 2, 3) }, + uLift: { value: 2.4 }, + uOverhang: { value: 0.5 }, + uSpin: { value: 0 }, + uSeed: { value: 0 }, + + uRadius: { value: 0.05 }, + uHaloScale: { value: 4 }, + uHullPad: { value: 3.2 }, + + uAssemble: { value: 0 }, + uStagger: { value: 0.55 }, + uFlicker: { value: 0.16 }, + uFlickerSpeed: { value: 1.4 }, + uFade: { value: 1 }, + + uDensity: { value: 2.4 }, + uCoreGain: { value: 1 }, + uHaloGain: { value: 0.35 }, + uEndTaper: { value: 0.14 }, + uEndTint: { value: 0.6 }, + uPulse: { value: 0 }, + uPulseAt: { value: 0.5 }, + uPulseWidth: { value: 0.16 }, + uIntensity: { value: 1 }, + + uColorCore: { value: new Color(1, 0.98, 0.9) }, + uColorHalo: { value: new Color(0.62, 0.85, 1) }, + uColorEnd: { value: new Color(1, 0.78, 0.45) } + }), + vertexShader: BEAM_VERTEX, + fragmentShader: BEAM_FRAGMENT + }); +} + +/** + * How many beams a node count implies — three families of plane-grids. + * + * The ability needs this on the CPU to set `geometry.instanceCount`, and it is + * the one number the shader and the CPU both have to agree on. + */ +export function photonBeamCount(gx, gy, gz) { + const x = Math.max(1, Math.round(gx)); + const y = Math.max(1, Math.round(gy)); + const z = Math.max(1, Math.round(gz)); + return Math.min(MAX_PHOTON_BEAMS, y * z + x * z + x * y); +} diff --git a/src/materials/PrismMaterial.js b/src/materials/PrismMaterial.js new file mode 100644 index 0000000..acf4421 --- /dev/null +++ b/src/materials/PrismMaterial.js @@ -0,0 +1,613 @@ +import { + AdditiveBlending, + Color, + DoubleSide, + NormalBlending, + ShaderMaterial, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms, frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * The two bespoke materials Prism Lance needs, and nothing else. + * + * Everything else in that ability is library work — the white lance is a + * `vfx/Tube`, the chips are a `vfx/ShatterField` — but the two things that make + * the slot *the prism slot* are not in the library and should not be: + * + * 1. **the solid** — a refracting triangular bipyramid with a real dispersion + * term, three refracted probes at three indices, one per channel; + * 2. **the fan** — six (up to eight) coloured child beams in **one instanced + * draw**, each with its own hue and its own exit angle out of the prism. + * + * Both read `settings.prismlance` directly, the way `materials/LightningMaterial` + * reads `settings.thunder`: the ability hands `userData.sync(state)` the dice + * rolls and the timestamps, and the material resolves every metre, radian and + * second itself, on every frame including a zero-length one. + */ + +/** Hard ceiling on child beams. `children` clamps here, and so does the fan geometry. */ +export const MAX_CHILDREN = 8; + +/* ------------------------------------------------------------------ */ +/* 1 · The solid */ +/* ------------------------------------------------------------------ */ + +/** + * The prism's vertex stage, which is unusually boring on purpose. + * + * This is the only thing in the ability that rides a model matrix: the solid is + * a *body*, it has to tumble and it has to be scaled independently in radius + * and in length, so placing it on the CPU each frame is both correct and + * cheaper than re-deriving a basis per vertex. The one wrinkle is that a + * non-uniform scale skews normals — `mat3(modelMatrix)` is `R·S`, and the + * normal wants `R·S⁻¹` — so the ability hands over `uNormalFix = 1/s²` and the + * shader multiplies twice. The first version skipped that and the bipyramid's + * six faces caught the light as though it were a sphere, which is exactly the + * one thing a faceted solid must not do. + */ +const PRISM_VERTEX = /* glsl */ ` + uniform vec3 uNormalFix; + + varying vec3 vWorld; + varying vec3 vNormalW; + varying vec3 vLocal; + + void main() { + vLocal = position; + vec4 world = modelMatrix * vec4(position, 1.0); + vWorld = world.xyz; + vNormalW = normalize(mat3(modelMatrix) * (normal * uNormalFix)); + gl_Position = projectionMatrix * viewMatrix * world; + } +`; + +/** + * The refraction. + * + * A prism disperses because the index of refraction is a function of + * wavelength: blue bends harder than red. There is no spectral renderer here + * and there is not going to be one, so the cheap-and-correct-looking version is + * three `refract()` calls at three indices and one channel taken from each — + * `r` from the red ray, `g` from the green, `b` from the blue. Where the three + * rays land on the same thing you get the room; where they diverge — grazing + * angles, the edges of the faces — they fringe, and the fringe is the effect. + * + * The first version sampled a single refracted ray and multiplied the result by + * a hue ramp taken from the surface UV. It looked like a sticker of a rainbow + * on a lump of glass, because the colour was not moving with the *ray*: orbit + * the prism and the fringes have to swing round with the geometry behind it, + * and only three real probes do that. + * + * Total internal reflection makes `refract()` return the zero vector, which + * would sample the probe at an undefined direction and read as a hard black + * facet. Those rays fall back to the reflected direction, which is what + * physically happens anyway. + * + * The environment probe degrades: `frame.uEnvMap` is null until `App` sets the + * equirect, and a null sampler reads black. So `uEnvMix` blends the probe + * against a two-colour procedural sky — the prism is never a black hole, and + * the sky is two pickers rather than a value derived from the body tint. + */ +const PRISM_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform sampler2D uEnvMap; + uniform float uEnvIntensity; + uniform float uEnvMix; + + uniform float uIor; + uniform float uDispersion; + uniform float uFresnelPower; + uniform float uFresnelScale; + + uniform float uFacet; + uniform float uFacetScale; + uniform float uGlow; + uniform float uOpacity; + uniform float uFade; + uniform float uLoad; + uniform float uLoadGlow; + + uniform float uCrack; + uniform float uCrackScale; + uniform float uCrackEdge; + uniform float uCrackGlow; + uniform float uSeed; + + uniform vec3 uColorBody; + uniform vec3 uColorRim; + uniform vec3 uColorFire; + uniform vec3 uColorSkyUp; + uniform vec3 uColorSkyDown; + + uniform float uGlobalGlow; + + varying vec3 vWorld; + varying vec3 vNormalW; + varying vec3 vLocal; + + ${noiseGLSL} + ${commonGLSL} + + /** The two-picker fallback room, for when there is no equirect bound. */ + vec3 sky(vec3 d) { + return mix(uColorSkyDown, uColorSkyUp, clamp(d.y * 0.5 + 0.5, 0.0, 1.0)); + } + + /** What the prism sees down a ray. */ + vec3 probe(vec3 d) { + vec3 lit = texture2D(uEnvMap, equirectUv(d)).rgb * uEnvIntensity; + return mix(sky(d), lit, clamp(uEnvMix, 0.0, 1.0)); + } + + void main() { + vec3 N = normalize(vNormalW); + if (!gl_FrontFacing) N = -N; + vec3 V = normalize(vWorld - cameraPosition); + + /* ---- the crack, which happens before anything is shaded ---- */ + // A fracture is a *hole*, so it is a discard and not a darkening. The field + // is world-space fbm at a metre scale, so the cracks keep their size when + // the solid is scaled and do not swim when it spins. + float grain = fbm3(vWorld * max(uCrackScale, 0.01) + uSeed); + vec2 broken = dissolveMask(grain, uCrack, max(uCrackEdge, 1e-3)); + if (uCrack > 0.001 && broken.x < 0.5) discard; + + /* ---- three rays, three indices ---- */ + float eta = 1.0 / max(uIor, 1.0001); + vec3 mirror = reflect(V, N); + vec3 rr = refract(V, N, eta * (1.0 + uDispersion)); + vec3 rg = refract(V, N, eta); + vec3 rb = refract(V, N, eta * (1.0 - uDispersion)); + // Total internal reflection: refract() hands back the zero vector. + if (dot(rr, rr) < 0.25) rr = mirror; + if (dot(rg, rg) < 0.25) rg = mirror; + if (dot(rb, rb) < 0.25) rb = mirror; + + vec3 through = vec3(probe(rr).r, probe(rg).g, probe(rb).b); + vec3 reflected = probe(mirror); + + /* ---- the interior ---- */ + // Facets inside the body, in the solid's *own* space so they tumble with + // it. Without this the prism is an empty shell: the refraction alone has no + // internal structure and the thing reads as a soap bubble. + float veins = fbm3(vLocal * max(uFacetScale, 0.01) + vec3(0.0, uTime * 0.15, uSeed)); + float fres = fresnelTerm(V, N, uFresnelPower, uFresnelScale); + + vec3 color = through * uColorBody; + color += reflected * fres; + color += uColorRim * fres * fres; + color += uColorFire * veins * uFacet; + // While the lance is actually in it, the solid blazes from the inside. + color += uColorFire * uLoad * uLoadGlow; + color += uColorFire * broken.y * uCrackGlow; + color *= uGlow * uGlobalGlow; + + // Same Reinhard ceiling GlacierMaterial uses: every term above peaks at a + // grazing angle and they stack, and a slider pinned against a white cutout + // is a slider nobody can hear. + color /= 1.0 + color * 0.16; + + float alpha = mix(uOpacity, 1.0, clamp(fres, 0.0, 1.0)) * uFade; + alpha = max(alpha, broken.y * uFade); + if (alpha < 0.004) discard; + gl_FragColor = vec4(color, alpha); + } +`; + +/** + * The refracting solid. + * + * Normal blending, not additive: this is glass in a room, and glass that adds + * has no body. `depthWrite` stays off so the back faces of the bipyramid show + * through the front ones, which is what makes the six facets legible as a + * volume rather than as a silhouette. + */ +export function createPrismSolidMaterial() { + const material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: NormalBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uEnvMap: frame.uEnvMap, + uNormalFix: { value: new Vector3(1, 1, 1) }, + uEnvIntensity: { value: 1 }, + uEnvMix: { value: 0.6 }, + uIor: { value: 1.62 }, + uDispersion: { value: 0.06 }, + uFresnelPower: { value: 2.6 }, + uFresnelScale: { value: 1.1 }, + uFacet: { value: 0.6 }, + uFacetScale: { value: 5.5 }, + uGlow: { value: 1.4 }, + uOpacity: { value: 0.35 }, + uFade: { value: 0 }, + uLoad: { value: 0 }, + uLoadGlow: { value: 1.6 }, + uCrack: { value: 0 }, + uCrackScale: { value: 6.0 }, + uCrackEdge: { value: 0.12 }, + uCrackGlow: { value: 2.4 }, + uSeed: { value: 0 }, + uColorBody: { value: new Color(0.85, 0.9, 1) }, + uColorRim: { value: new Color(1, 1, 1) }, + uColorFire: { value: new Color(1, 0.85, 1) }, + uColorSkyUp: { value: new Color(0.35, 0.42, 0.6) }, + uColorSkyDown: { value: new Color(0.05, 0.05, 0.08) } + }), + vertexShader: PRISM_VERTEX, + fragmentShader: PRISM_FRAGMENT + }); + + /** + * @param {object} state { fade, load, crack, seed, normalFix } + */ + material.userData.sync = (state) => { + const c = settings.prismlance; + const g = settings.global; + const u = material.uniforms; + + u.uNormalFix.value.copy(state.normalFix); + u.uFade.value = state.fade; + u.uLoad.value = state.load; + u.uCrack.value = state.crack; + u.uSeed.value = state.seed; + + u.uEnvIntensity.value = c.prismEnvIntensity; + u.uEnvMix.value = c.prismEnvMix; + u.uIor.value = c.prismIor; + u.uDispersion.value = c.prismDispersion; + u.uFresnelPower.value = c.prismFresnelPower; + u.uFresnelScale.value = c.prismFresnelScale * g.fresnel; + u.uFacet.value = c.prismFacet * g.noiseStrength; + u.uFacetScale.value = c.prismFacetScale * g.noiseFrequency; + u.uGlow.value = c.prismGlow * g.glow; + u.uOpacity.value = c.prismOpacity * g.opacity; + u.uLoadGlow.value = c.prismLoadGlow; + u.uCrackScale.value = c.prismCrackScale * g.noiseFrequency; + u.uCrackEdge.value = c.prismCrackEdge; + u.uCrackGlow.value = c.prismCrackGlow; + + u.uColorBody.value.copy(getColor(c.colorPrismBody)); + u.uColorRim.value.copy(getColor(c.colorPrismRim)); + u.uColorFire.value.copy(getColor(c.colorPrismFire)); + u.uColorSkyUp.value.copy(getColor(c.colorPrismSkyUp)); + u.uColorSkyDown.value.copy(getColor(c.colorPrismSkyDown)); + }; + + return material; +} + +/* ------------------------------------------------------------------ */ +/* 2 · The fan */ +/* ------------------------------------------------------------------ */ + +/** + * Six child beams, one draw call. + * + * The geometry is `createBoltRibbonGeometry` — the same parameter-space ladder + * the Storm Lance's filaments are drawn on — instanced once per child. Nothing + * about a child's path exists on the CPU: the vertex shader is handed the prism + * point, the impact point and eight colours, and derives the rest from the + * instance index. + * + * **The exit angle and the hue are the same number.** A child's index picks its + * bearing around the axis, *and* its colour, *and* how hard it bends — + * `uDispersion` ramps the lateral throw from the first child to the last, so + * the fan is ordered the way a real spectrum is ordered rather than being six + * beams in six arbitrary directions. Drag `fanDispersion` to nothing and the + * six collapse into one white line, which is the correct behaviour for a prism + * with no dispersion in it and is a good way to check the rest of the shader. + * + * **They converge because the bow is zero at both ends.** The lateral offset is + * `sin(π·tᵇ)` — zero at the prism, zero at the target, and with `b = 1` its + * derivative at the prism is `π`, so the beams genuinely *leave at an angle* + * rather than easing away from the axis. That one function is the whole trick: + * there is no convergence logic, no correction term, and no way for a child to + * miss the point the parent beam was aimed at. + * + * One pass, not two. A halo pass and a core pass over the same ribbon are both + * additive, and additive blending is a sum — so summing the two profiles inside + * one fragment is the identical image for half the draw calls. The ribbon is + * therefore as wide as the *halo* and the core is a tight power of the + * cross-ribbon coordinate inside it. + */ +const FAN_VERTEX = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + #define MAX_CHILDREN ${MAX_CHILDREN} + + uniform float uTime; + uniform vec3 uPrism; + uniform vec3 uTarget; + uniform vec3 uSide; + uniform float uSeed; + uniform float uCount; + uniform float uFade; + + uniform float uSpread; + uniform float uDispersion; + uniform float uBowBias; + uniform float uBowCurve; + uniform float uSpin; + uniform float uWave; + uniform float uWaveScale; + uniform float uWaveSpeed; + + uniform float uWidth; + uniform float uWidthTip; + uniform float uFlicker; + uniform float uFlickerSpeed; + uniform float uWhite; + + uniform vec3 uChildColor[MAX_CHILDREN]; + uniform vec3 uColorCollapse; + + attribute float aStrand; + + varying float vT; + varying float vSide; + varying float vFlash; + varying float vViewZ; + varying vec3 vTint; + + ${noiseGLSL} + + /** + * One child's axis point at t, 0 at the prism and 1 at the target. + * + * The 'order' argument is the child's place in the spectrum, 0..1, and it is the only + * thing that separates one child from another: it picks the bearing, it + * scales the throw, and up in main() it picks the colour. + */ + vec3 childPoint(float t, float order, float bearing, vec3 n1, vec3 n2) { + vec3 axis = mix(uPrism, uTarget, t); + + float bow = pow(max(sin(PI * pow(clamp(t, 0.0, 1.0), max(uBowBias, 0.05))), 0.0), max(uBowCurve, 0.05)); + float amp = uSpread * mix(1.0, uDispersion, order); + // A slow travelling ripple down the beam. Small: a child beam is coherent + // light, and anything that kinks it reads as the Storm Lance by mistake. + amp *= 1.0 + uWave * sin(TAU * (t * uWaveScale - uTime * uWaveSpeed) + order * 4.0); + + float angle = bearing + uTime * uSpin * TAU; + vec2 offset = vec2(cos(angle), sin(angle)) * amp * bow; + return axis + n1 * offset.x + n2 * offset.y; + } + + void main() { + float t = position.x; + vT = t; + vSide = position.y; + + /* ---- the frame the fan opens in ---- */ + vec3 delta = uTarget - uPrism; + float span = max(length(delta), 0.01); + vec3 dir = delta / span; + vec3 n1 = uSide - dir * dot(uSide, dir); + n1 = length(n1) > 1e-4 ? normalize(n1) : normalize(cross(dir, vec3(0.0, 1.0, 0.0))); + vec3 n2 = normalize(cross(dir, n1)); + + /* ---- which child is this ---- */ + float count = max(uCount, 1.0); + float order = count > 1.0 ? aStrand / (count - 1.0) : 0.0; + float bearing = (aStrand / count) * TAU + uSeed; + + vec3 tint = uChildColor[0]; + // Indexed by the loop counter and compared against the instance id: a + // uniform array indexed by a varying does not compile on ANGLE, and this is + // the same dance FilamentPaths does for its roles. + for (int i = 0; i < MAX_CHILDREN; i++) { + if (float(i) == aStrand) tint = uChildColor[i]; + } + vTint = mix(tint, uColorCollapse, clamp(uWhite, 0.0, 1.0)); + + vec3 here = childPoint(t, order, bearing, n1, n2); + + /* ---- camera-facing ribbon ---- */ + float step_ = 0.02; + float ahead = t + step_; + float flip = 1.0; + if (ahead > 1.0) { ahead = t - step_; flip = -1.0; } + vec3 next = childPoint(ahead, order, bearing, n1, n2); + vec3 tangent = (next - here) * flip; + tangent = length(tangent) > 1e-5 ? normalize(tangent) : dir; + + vec3 toCamera = normalize(cameraPosition - here); + vec3 binormal = cross(tangent, toCamera); + float bl = length(binormal); + binormal = bl > 1e-4 ? binormal / bl : n1; + + /* ---- width ---- */ + float flash = mix(1.0, hash11(floor(uTime * uFlickerSpeed) + aStrand * 3.7 + uSeed), uFlicker); + vFlash = flash; + + float halfWidth = uWidth * mix(1.0, uWidthTip, t) * flash * uFade; + + vec4 mv = viewMatrix * vec4(here + binormal * vSide * halfWidth, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const FAN_FRAGMENT = /* glsl */ ` + uniform float uProgress; + uniform float uTipGlow; + uniform float uTipLength; + uniform float uCoreSharp; + uniform float uCoreWidth; + uniform float uHaloFalloff; + uniform float uHaloOpacity; + uniform float uOpacity; + uniform float uGlow; + uniform float uFade; + uniform float uSoftFade; + uniform vec3 uColorCore; + + uniform float uGlobalGlow; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + varying float vT; + varying float vSide; + varying float vFlash; + varying float vViewZ; + varying vec3 vTint; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + // Ahead of the split front there is no child beam yet. Clipped, never + // scaled: the *shape* must not change as the fan reaches the target. + float tip = max(uTipLength, 1e-3); + float drawn = smoothstep(uProgress, uProgress - tip, vT); + if (drawn <= 0.002) discard; + + float v = clamp(abs(vSide), 0.0, 1.0); + + // Two profiles summed in one fragment — see the header. The core lives + // inside the first uCoreWidth of the ribbon; the halo spans all of it. + float core = pow(clamp(1.0 - v / max(uCoreWidth, 1e-3), 0.0, 1.0), max(uCoreSharp, 0.05)); + float halo = pow(1.0 - v, max(uHaloFalloff, 0.05)) * uHaloOpacity; + + vec3 color = vTint * (core * 0.35 + halo); + color += uColorCore * core * core; + color += uColorCore * smoothstep(uProgress - tip * 2.0, uProgress, vT) * uTipGlow; + + float alpha = (core + halo) * drawn * vFlash * uFade * uOpacity; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.003) discard; + + color *= uGlow * uGlobalGlow; + gl_FragColor = vec4(color, alpha); + } +`; + +/** + * The fan. One instanced draw, `children` instances. + * + * @returns {THREE.ShaderMaterial} with `userData.sync(state)` on it + */ +export function createPrismFanMaterial() { + const childColors = []; + for (let i = 0; i < MAX_CHILDREN; i++) childColors.push(new Color(1, 1, 1)); + + const material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uPrism: { value: new Vector3() }, + uTarget: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + uSeed: { value: 0 }, + uCount: { value: 6 }, + uFade: { value: 1 }, + uProgress: { value: 0 }, + + uSpread: { value: 0.9 }, + uDispersion: { value: 2.2 }, + uBowBias: { value: 1 }, + uBowCurve: { value: 1 }, + uSpin: { value: 0.12 }, + uWave: { value: 0.12 }, + uWaveScale: { value: 2.4 }, + uWaveSpeed: { value: 1.1 }, + + uWidth: { value: 0.16 }, + uWidthTip: { value: 0.6 }, + uFlicker: { value: 0.12 }, + uFlickerSpeed: { value: 24 }, + uWhite: { value: 0 }, + + uCoreSharp: { value: 2.6 }, + uCoreWidth: { value: 0.34 }, + uHaloFalloff: { value: 2.6 }, + uHaloOpacity: { value: 0.5 }, + uTipGlow: { value: 1.4 }, + uTipLength: { value: 0.07 }, + uOpacity: { value: 1 }, + uGlow: { value: 2.1 }, + uSoftFade: { value: 0.5 }, + + uChildColor: { value: childColors }, + uColorCore: { value: new Color(1, 1, 1) }, + uColorCollapse: { value: new Color(1, 1, 1) } + }), + vertexShader: FAN_VERTEX, + fragmentShader: FAN_FRAGMENT + }); + + /** + * @param {object} state { prism, target, side, progress, fade, seed, count, white } + */ + material.userData.sync = (state) => { + const c = settings.prismlance; + const g = settings.global; + const u = material.uniforms; + + u.uPrism.value.copy(state.prism); + u.uTarget.value.copy(state.target); + u.uSide.value.copy(state.side); + u.uSeed.value = state.seed; + u.uCount.value = state.count; + u.uFade.value = state.fade; + u.uProgress.value = state.progress; + u.uWhite.value = state.white; + + u.uSpread.value = c.fanSpread; + u.uDispersion.value = c.fanDispersion; + u.uBowBias.value = c.fanBow; + u.uBowCurve.value = c.fanBowCurve; + u.uSpin.value = c.fanSpin * g.animationSpeed; + u.uWave.value = c.fanWave * g.noiseStrength; + u.uWaveScale.value = c.fanWaveScale * g.noiseFrequency; + u.uWaveSpeed.value = c.fanWaveSpeed * g.noiseSpeed; + + u.uWidth.value = c.fanWidth; + u.uWidthTip.value = c.fanWidthTip; + u.uFlicker.value = c.fanFlicker; + u.uFlickerSpeed.value = c.fanFlickerSpeed; + + u.uCoreSharp.value = c.fanCoreSharp; + u.uCoreWidth.value = c.fanCoreWidth; + u.uHaloFalloff.value = c.fanHaloFalloff; + u.uHaloOpacity.value = c.fanHaloOpacity; + u.uTipGlow.value = c.fanTipGlow; + u.uTipLength.value = c.fanTipLength; + u.uOpacity.value = c.fanOpacity * g.opacity; + u.uGlow.value = c.fanGlow * g.glow; + u.uSoftFade.value = c.fanSoftFade; + + // Eight pickers, none derived from another (I5). The block spells out the + // whole spectrum rather than generating one from a hue sweep, because a + // generated spectrum is one control and this is meant to be eight. + const boxes = u.uChildColor.value; + boxes[0].copy(getColor(c.colorChild1)); + boxes[1].copy(getColor(c.colorChild2)); + boxes[2].copy(getColor(c.colorChild3)); + boxes[3].copy(getColor(c.colorChild4)); + boxes[4].copy(getColor(c.colorChild5)); + boxes[5].copy(getColor(c.colorChild6)); + boxes[6].copy(getColor(c.colorChild7)); + boxes[7].copy(getColor(c.colorChild8)); + u.uColorCore.value.copy(getColor(c.colorFanCore)); + u.uColorCollapse.value.copy(getColor(c.colorCollapse)); + }; + + return material; +} diff --git a/src/materials/SheetIceMaterial.js b/src/materials/SheetIceMaterial.js new file mode 100644 index 0000000..e9de8d6 --- /dev/null +++ b/src/materials/SheetIceMaterial.js @@ -0,0 +1,364 @@ +import { MeshStandardMaterial, Color, DoubleSide, Vector2, Vector3 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * Sheet ice — the thin, curling plates of `RimeAbility`. + * + * **This is not `IceMaterial` in another colour, and the difference is the one + * thing the ability is for.** A crystal is a *solid*: face one of its facets + * head-on and you are looking down the long axis of the body, so it darkens; + * catch it at a grazing angle and you are only clipping an edge, so it stays + * pale. `IceMaterial` writes exactly that — `thickness = ndv * density` — and + * it is right for a spike. A sheet three centimetres thick is the *other way + * round*: face-on you are looking through three centimetres of ice and it is + * nearly clear; at a grazing angle you are looking *along* the sheet and the + * path is half a metre, so that is where it deepens toward the body tint. The + * first version of Rimewalker reused `createIceMaterial` verbatim and the + * plates read as blue slabs of glass lying on the floor — solid, heavy, and the + * exact opposite of the brief. Inverting one term fixed it. + * + * Three things carry the read, in this order: + * + * - **the lip.** A curled plate catches the key light on the outer band of its + * roll and nowhere else, so `uLipGlow` is keyed off `vSheetRoll`, which is 0 + * at the hinge and 1 at the rolled rim, multiplied by how far this + * particular plate has actually peeled. A flat plate has no lip and gets no + * highlight, which is what makes the field *turn on* as it curls. + * - **translucency.** The plate behind shows through the plate in front. Alpha + * is low face-on and climbs with the path length, so a stack of three plates + * reads as three plates rather than as one wall. + * - **the environment.** These are polished sheets on a lit stage. Roughness + * and `envMapIntensity` are sliders because that reflection is doing more + * work here than any procedural term in the file. + * + * ## The curl is a vertex-shader roll, not geometry + * + * The plate is generated flat. Everything downwind of a hinge line is rolled + * about that line at constant curvature, which is what paper does coming off a + * hot pan, and the total angle at the rim is `uCurl` radians — a live slider + * that runs a plate from dead flat to fully peeled while the clock is stopped. + * + * The per-plate clock is `aBirth`. `GrowthField` publishes exactly two + * per-instance attributes, `aSeed` and `aBirth`, and `aBirth` is a birth flash + * that runs 1 → 0 over `birthFade` seconds from the moment that instance was + * triggered. Read backwards it is the only per-instance stopwatch the field + * has, so the ability sets `birthFade` to the *curl* time and this shader reads + * `1 - aBirth` as "how long since this plate locked". A staggered field + * therefore peels in a wave without one line of per-instance bookkeeping on the + * CPU. The cost is that the birth flash and the curl share a clock, which is + * why the flash is raised to `uBirthPower` — at 6 it is gone in the first tenth + * of a second of a one-second roll. + * + * The alternative, baking the curl into the geometry as a shape parameter, was + * written first and thrown away: `syncGeometry` rebuilds *all* the variants at + * once, so every plate in the field peeled in lockstep and the whole corridor + * hinged like one sheet of paper. The stagger is the effect. + * + * ## The one number that is not resolved per frame + * + * None. Every metre, radian and second below is a uniform written from + * `settings.rime` on every call to `userData.sync()`, paused frames included — + * and `userData.uniforms` is where they are parked, because the harness's pause + * test reads a patched `MeshStandardMaterial`'s uniforms from exactly there. + */ +export function createSheetIceMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.14, + metalness: 0.0, + // Flat shading is doing real work here: the roll is applied in the vertex + // shader, and a face normal derived from screen-space derivatives follows a + // displaced vertex for free. A smooth normal would have to be rotated by + // the same roll, and `` has already run and written + // `vNormal` by the time an injection after `` gets a look in. + flatShading: true, + transparent: true, + // A sheet is seen from underneath the moment it peels. + side: DoubleSide, + // Kept on, as the crystals do: the plates are opaque enough that sorting + // them against each other by depth reads better than letting the mist and + // the glints composite straight through them. + depthWrite: true + }); + + const uniforms = { + uTime: frame.uTime, + + /* --- the roll --- */ + uCurl: { value: 2.5 }, // radians at the rim when fully peeled + uCurlDelay: { value: 0.34 }, // fraction of the curl clock spent lying flat + uCurlSpread: { value: 0.55 }, // radians of per-plate bearing jitter + uCurlHinge: { value: -0.06 }, // unit-space position of the hinge line + uCurlDir: { value: new Vector2(0, 1) }, // world XZ, the direction they peel toward + + /* --- the sublimation front --- */ + uSublimeOrigin: { value: new Vector3() }, + uSublimeDir: { value: new Vector3(0, 0, 1) }, + uSublimeFront: { value: -1e4 }, // metres downrange; below the field means "intact" + uSublimeEdge: { value: 1.1 }, // metres of the dissolve band + uSublimeGlow: { value: 2.2 }, + uColorSublime: { value: new Color() }, + + /* --- the sheet --- */ + uColorSheet: { value: new Color() }, + uColorDeep: { value: new Color() }, + uColorLip: { value: new Color() }, + uColorGlow: { value: new Color() }, + uDepthTint: { value: 1.15 }, + uDepthPower: { value: 1.5 }, + uTranslucency: { value: 0.9 }, + uFresnel: { value: 1.6 }, + uFresnelPower: { value: 2.2 }, + uLipGlow: { value: 1.8 }, + uLipWidth: { value: 0.45 }, + uLipPower: { value: 1.6 }, + uGrain: { value: 0.4 }, + uGrainScale: { value: 4.5 }, + uGlint: { value: 1.3 }, + uGlintScale: { value: 26 }, + uGlintSpeed: { value: 0.5 }, + uGlow: { value: 1.25 }, + uBirthGlow: { value: 2.6 }, + uBirthPower: { value: 6 } + }; + + environment.registerShadowCasterWithPatch(material, (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + `#include + attribute float aSeed; + attribute float aBirth; + uniform float uCurl; + uniform float uCurlDelay; + uniform float uCurlSpread; + uniform float uCurlHinge; + uniform vec2 uCurlDir; + varying vec3 vSheetLocal; + varying vec3 vSheetWorld; + varying float vSheetSeed; + varying float vSheetBirth; + varying float vSheetRoll; + varying float vSheetPeel;` + ) + .replace( + '#include ', + `#include + vSheetSeed = aSeed; + vSheetBirth = aBirth; + + // GrowthField's birth flash, read backwards, is the only per-instance + // stopwatch the field publishes: 0 the instant this plate locked, + // 1 once its curl clock has run out. See the module header. + float locked = 1.0 - aBirth; + float peel = smoothstep(uCurlDelay, 1.0, locked); + vSheetPeel = peel; + + // The plan of the plate is authored around its own axes and the wind + // is a world direction, so the wind has to come back into object + // space or a yawed plate peels sideways. Columns 0 and 2 of the + // instance matrix are the instance's own X and Z in world; flattened + // and normalised they are its yaw, scale removed. + vec2 wind = uCurlDir; + #ifdef USE_INSTANCING + vec2 ex = vec2(instanceMatrix[0].x, instanceMatrix[0].z); + vec2 ez = vec2(instanceMatrix[2].x, instanceMatrix[2].z); + ex = length(ex) > 1e-5 ? normalize(ex) : vec2(1.0, 0.0); + ez = length(ez) > 1e-5 ? normalize(ez) : vec2(0.0, 1.0); + wind = vec2(dot(uCurlDir, ex), dot(uCurlDir, ez)); + #endif + float windLen = length(wind); + wind = windLen > 1e-5 ? wind / windLen : vec2(0.0, 1.0); + + // ...plus a small per-plate bearing jitter, because a field that peels + // on exactly one bearing reads as combed rather than as frozen. + float bearing = (aSeed - 5.0) * uCurlSpread * 0.2; + float cb = cos(bearing); + float sb = sin(bearing); + wind = vec2(wind.x * cb - wind.y * sb, wind.x * sb + wind.y * cb); + + // Constant-curvature roll about the hinge line. Everything upwind of + // the hinge is left alone; downwind of it the sheet wraps onto a + // cylinder whose radius shrinks as the plate peels. + float along = dot(transformed.xz, wind); + float reach = max(0.5 - uCurlHinge, 0.05); + float rollT = clamp(max(0.0, along - uCurlHinge) / reach, 0.0, 1.0); + vSheetRoll = rollT * peel; + + float bend = uCurl * peel; + if (bend > 1.0e-3) { + float radius = reach / bend; + // The sheet has thickness, and a roll tighter than the sheet is + // thick turns it inside out. + float body = min(transformed.y, radius * 0.85); + float theta = bend * rollT; + vec2 perp = vec2(-wind.y, wind.x); + float across = dot(transformed.xz, perp); + float lifted = radius - (radius - body) * cos(theta); + float slid = uCurlHinge + (radius - body) * sin(theta); + transformed.xz = wind * slid + perp * across; + transformed.y = lifted; + } + + vSheetLocal = transformed; + #ifdef USE_INSTANCING + vSheetWorld = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz; + #else + vSheetWorld = (modelMatrix * vec4(transformed, 1.0)).xyz; + #endif` + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform float uTime; + uniform vec3 uColorSheet; + uniform vec3 uColorDeep; + uniform vec3 uColorLip; + uniform vec3 uColorGlow; + uniform vec3 uColorSublime; + uniform float uDepthTint; + uniform float uDepthPower; + uniform float uTranslucency; + uniform float uFresnel; + uniform float uFresnelPower; + uniform float uLipGlow; + uniform float uLipWidth; + uniform float uLipPower; + uniform float uGrain; + uniform float uGrainScale; + uniform float uGlint; + uniform float uGlintScale; + uniform float uGlintSpeed; + uniform float uGlow; + uniform float uBirthGlow; + uniform float uBirthPower; + uniform vec3 uSublimeOrigin; + uniform vec3 uSublimeDir; + uniform float uSublimeFront; + uniform float uSublimeEdge; + uniform float uSublimeGlow; + varying vec3 vSheetLocal; + varying vec3 vSheetWorld; + varying float vSheetSeed; + varying float vSheetBirth; + varying float vSheetRoll; + varying float vSheetPeel; + ${noiseGLSL}` + ) + .replace( + '#include ', + `#include + { + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + + // A sheet, not a solid: face-on the ray crosses the thickness and + // almost nothing absorbs; edge-on it runs the length of the plate. + // This is the inverted term the module header is about. + float depth = clamp(pow(1.0 - ndv, uDepthPower) * uDepthTint, 0.0, 1.0); + float fres = pow(1.0 - ndv, uFresnelPower) * uFresnel; + + vec3 body = mix(uColorSheet, uColorDeep, depth); + + // The lip: the outer band of the roll, and only once the plate has + // actually peeled. A flat plate has no lip and must not glow. + float lip = pow(smoothstep(1.0 - uLipWidth, 1.0, vSheetRoll), uLipPower) * vSheetPeel; + body = mix(body, uColorLip, clamp(lip * 0.8, 0.0, 1.0)); + + // Rime grain in *world* space, so two plates that interlock look + // frozen out of the same film rather than each carrying its own + // private pattern. Local space was tried and every plate wore an + // identical bullseye, which is the giveaway of an instanced field. + float grain = fbm3(vSheetWorld * uGrainScale + vSheetSeed * 3.7) * 0.5 + 0.5; + float frost = smoothstep(0.52, 1.0, grain); + body = mix(body, uColorLip, frost * uGrain); + + // Pinpoint glints, scrolling slowly in world space. + float glint = snoise(vSheetWorld * uGlintScale + + vec3(0.0, uTime * uGlintSpeed, 0.0) + vSheetSeed * 17.0); + glint = pow(clamp(glint, 0.0, 1.0), 12.0); + + // The sublimation front, in metres downrange of the caster. Below + // the front the plate has gone; the transition band is where the ice + // is actually turning to vapour, so that is where it is brightest. + float reach = dot(vSheetWorld - uSublimeOrigin, uSublimeDir); + float gone = 1.0 - smoothstep(uSublimeFront - uSublimeEdge, + uSublimeFront + uSublimeEdge, reach); + float band = clamp(gone * (1.0 - gone) * 4.0, 0.0, 1.0); + + vec3 glow = uColorGlow * lip * uLipGlow; + glow += uColorLip * fres * 0.45; + glow += uColorGlow * frost * uTranslucency * 0.5; + glow += uColorLip * glint * uGlint; + glow += uColorGlow * pow(clamp(vSheetBirth, 0.0, 1.0), uBirthPower) * uBirthGlow; + glow += uColorSublime * band * uSublimeGlow; + glow *= uGlow; + + // The same Reinhard ceiling the crystals use: every term above peaks + // at a grazing angle, so without it a plate on the silhouette sums + // past 5x white and the bloom pass smears the whole corridor. + glow /= 1.0 + glow * 0.22; + + diffuseColor.rgb *= body; + totalEmissiveRadiance += glow; + + // Thin face-on so the plate behind shows through, denser along the + // roll and at the lip where there is genuinely more ice in the way. + diffuseColor.a = clamp(diffuseColor.a * (0.4 + 0.6 * clamp(depth + lip, 0.0, 1.0)), 0.0, 1.0); + diffuseColor.a *= 1.0 - gone; + }` + ); + }); + + material.userData.uniforms = uniforms; + + /** Pull the palette and every shading control from the live settings. */ + material.userData.sync = () => { + const c = settings.rime; + const g = settings.global; + + uniforms.uColorSheet.value.copy(getColor(c.colorSheet)); + uniforms.uColorDeep.value.copy(getColor(c.colorDeep)); + uniforms.uColorLip.value.copy(getColor(c.colorLip)); + uniforms.uColorGlow.value.copy(getColor(c.colorGlow)); + uniforms.uColorSublime.value.copy(getColor(c.colorSublime)); + + uniforms.uCurl.value = c.curl; + uniforms.uCurlDelay.value = Math.min(0.95, c.curlDelay); + uniforms.uCurlSpread.value = c.curlSpread * g.randomness; + uniforms.uCurlHinge.value = c.curlHinge; + + uniforms.uDepthTint.value = c.depthTint; + uniforms.uDepthPower.value = c.depthPower; + uniforms.uTranslucency.value = c.translucency; + uniforms.uFresnel.value = c.fresnel * g.fresnel; + uniforms.uFresnelPower.value = c.fresnelPower; + uniforms.uLipGlow.value = c.lipGlow; + uniforms.uLipWidth.value = Math.min(0.99, c.lipWidth); + uniforms.uLipPower.value = c.lipPower; + uniforms.uGrain.value = c.grain * g.shaderIntensity; + uniforms.uGrainScale.value = c.grainScale * g.noiseFrequency; + uniforms.uGlint.value = c.glint * g.shaderIntensity; + uniforms.uGlintScale.value = c.glintScale; + uniforms.uGlintSpeed.value = c.glintSpeed * g.noiseSpeed; + uniforms.uGlow.value = c.glow * g.glow; + uniforms.uBirthGlow.value = c.birthGlow; + uniforms.uBirthPower.value = Math.max(0.5, c.birthPower); + uniforms.uSublimeEdge.value = Math.max(0.02, c.sublimeEdge); + uniforms.uSublimeGlow.value = c.sublimeGlow; + + material.opacity = c.opacity * g.opacity; + material.roughness = c.roughness; + material.envMapIntensity = c.envIntensity; + }; + + material.userData.sync(); + return material; +} diff --git a/src/materials/SingularityMaterial.js b/src/materials/SingularityMaterial.js new file mode 100644 index 0000000..f3d97ff --- /dev/null +++ b/src/materials/SingularityMaterial.js @@ -0,0 +1,564 @@ +import { ShaderMaterial, AdditiveBlending, NormalBlending, Color, DoubleSide, Vector3 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/* ====================================================================== */ +/* SINGULARITY — the two bespoke shaders the well needs */ +/* ====================================================================== */ +/** + * Everything else about this ability is library work: the lens is + * `vfx/Distortion.js` in `LENS` mode, the dust is `vfx/Swarm.js`, the hole in + * the floor is `vfx/GroundField.js` in `FUNNEL` mode. Two things are not, and + * they are both here: + * + * 1. **the infall** — instanced ribbons on a genuine angular-momentum orbit, + * and + * 2. **the event horizon** — a camera-facing disc that is the only object in + * the project which is *darker* than what it covers. + * + * Neither is a recolour of anything in `src/materials`, and the first one is + * the reason this file exists rather than a `FilamentPaths` role: `SPIRAL_IN` + * draws a spiral whose whole pattern rotates rigidly, which means every point + * on it turns at the same rate. A body falling into a well does not do that. + * The whole read of an accretion disc is that the inside laps the outside, and + * you only get that from the conservation law. + */ + +/* ---------------------------------------------------------------------- */ +/* The infall */ +/* ---------------------------------------------------------------------- */ +/** + * One vertex arrives as `(t, side)` — how far along a stream's tail it is and + * which edge of the ribbon it sits on — and leaves as a world position on a + * Keplerian in-spiral. There is no path on the CPU, no per-stream record, and + * no integrated angle anywhere; the shape is a closed-form function of the + * cast's age and the numbers in `settings.singularity`. + * + * ## The orbit, and why it is closed form + * + * Specific angular momentum is conserved: `r² · dθ/dt = L`. Let a stream's + * radius fall linearly from its own starting radius `r0` to the horizon `rh` + * over `fallTime` seconds, so with `τ` running 0..1 over that fall, + * + * ``` + * r(τ) = r0 + (rh − r0) · τ + * θ(τ) = θ0 + L · T · ∫ dτ / r² = θ0 + ω0 · T · τ · r0 / r(τ) + * ``` + * + * — because the integral of `1/(r0 + cτ)²` is `τ / (r0 · r(τ))`, which is one + * multiply and one divide, and `L = ω0 · r0²` names the constant as *the + * angular rate at the widest point*, which is a slider a person can reason + * about. At the horizon the sweep rate is `r0/rh` times what it was on the + * outside: with a 5 m disc closing onto a 0.6 m horizon, the last turn is taken + * eight times faster than the first. That ratio is the entire effect. + * + * The first version integrated `theta += omega * dt` on the CPU and kept it in + * a per-stream record. It looked identical for about a second and then drifted + * apart from the settings entirely: pausing and dragging `orbitRate` did + * nothing, because the angle already banked was not a function of the slider + * any more. Closed form is not an optimisation here, it is invariant **I1**. + * + * ## The tail, which stretches for free + * + * A stream's ribbon samples the same orbit at `τ − (1 − t) · trailTime / T`, so + * the tail is a fixed window of *time* behind the head, not a fixed length. The + * head near the horizon is moving many times faster than one out on the rim, so + * the same half-second of history covers many times more arc — the streams + * visibly stretch as they fall in, and nothing in the code says so. Tapering + * the trail by length instead was the first attempt and it reads as a comet + * pinned to a wire: the tail has to be a memory, not a shape. + * + * Sampling with `τ` allowed to go negative matters too. Clamping it at zero + * bunched every tail up at the birth radius into a bright bead on the rim; a + * negative τ simply puts the tail further out than the stream has yet been, + * which is where it was. + */ +const INFALL_VERTEX = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform float uAge; // seconds since the cast began + uniform vec3 uCentre; // the well, world metres + uniform float uSeed; // per-cast dice roll, unitless + + /* --- the orbit --- */ + uniform float uFallTime; // seconds one stream takes to reach the horizon + uniform float uRadiusInner; // metres — the tightest starting orbit + uniform float uRadiusOuter; // metres — the widest + uniform float uHorizon; // metres — where a stream is swallowed + uniform float uOrbitRate; // radians/second at the widest orbit + uniform float uInclination; // radians the orbits tilt out of the disc plane + uniform float uFlatten; // 0 a flat disc, 1 a sphere of orbits + uniform float uEject; // metres of outward blow-out (the collapse) + uniform float uTrailTime; // seconds of history in a stream's tail + uniform float uWobble; // metres of lateral slop on a stream + uniform float uWobbleTurns; // wobbles per turn of the orbit + + /* --- the ribbon --- */ + uniform float uWidth; // half-width at the head, metres + uniform float uWidthTail; // that width at the tail, as a fraction + uniform float uWidthCurve; // how early the taper happens + uniform float uWidthNear; // extra width at the horizon, × uWidth + uniform float uEnter; // fraction of the fall spent fading in + uniform float uExit; // ... and fading out + uniform float uOpen; // 0..1 how much of the disc exists yet + uniform float uFade; // master fade + + attribute float aStrand; + + varying float vT; + varying float vSide; + varying float vTau; + varying float vAlpha; + varying float vViewZ; + + ${noiseGLSL} + + /** + * Where one stream is at fall parameter tau. Tau may run negative — that is + * the tail, still out where the stream has not been yet. + */ + vec3 orbitAt(float tau, float bearing, float rDice, float tilt) { + float r0 = mix(uRadiusInner, uRadiusOuter, rDice); + float rh = max(min(uHorizon, r0), 0.02); + float r = mix(r0, rh, tau); + // r is linear in tau and never reaches zero, so the closed form below has + // no singularity in it — the *hole* has one, the maths does not. + float theta = bearing + uOrbitRate * uFallTime * tau * (r0 / max(r, 1e-3)); + + float span = max(r + uEject, 0.0); + float c = cos(tilt); + float s = sin(tilt); + + vec3 p = uCentre; + p.x += cos(theta) * span * c; + p.z += sin(theta) * span * c; + p.y += s * span * uFlatten; + + // Phase-locked to the orbit rather than to the clock: a wobble keyed off + // time slides along a stream and reads as a wet noodle, while one keyed off + // theta is a fixed corrugation the stream travels through. + float w = theta * uWobbleTurns; + p += vec3(sin(w + uSeed), cos(w * 1.31 + bearing * 7.0), sin(w * 0.87 + rDice * 13.0)) * uWobble; + return p; + } + + void main() { + float t = position.x; // 0 at the tail, 1 at the head + float side = position.y; + + vec3 dice = hash31(aStrand * 3.77 + uSeed * 1.31 + 0.5); + float bearing = dice.x * TAU; + float rDice = dice.y; + float tilt = (dice.z - 0.5) * 2.0 * uInclination; + float phase = hash11(aStrand * 9.13 + uSeed + 4.7); + + float fall = max(uFallTime, 0.05); + // Each stream runs its own loop of the fall, offset by its dice. The cast's + // own age drives it, not the shared clock, so a fresh cast starts with the + // disc already populated instead of with everything on the rim at once. + float head = fract(uAge / fall + phase); + float tau = head - (1.0 - t) * (uTrailTime / fall); + + vec3 here = orbitAt(tau, bearing, rDice, tilt); + vec3 back = orbitAt(tau - 0.015, bearing, rDice, tilt); + vec3 tangent = here - back; + tangent = length(tangent) > 1e-6 ? normalize(tangent) : vec3(0.0, 1.0, 0.0); + + /* Turn the ribbon edge-on to the eye, the way the bolt does. */ + vec3 toCamera = normalize(cameraPosition - here); + vec3 binormal = cross(tangent, toCamera); + float bl = length(binormal); + binormal = bl > 1e-4 ? binormal / bl : vec3(1.0, 0.0, 0.0); + + float taper = pow(clamp(t, 0.0, 1.0), max(uWidthCurve, 0.01)); + float halfWidth = uWidth * mix(uWidthTail, 1.0, taper); + halfWidth *= mix(1.0, uWidthNear, clamp(head, 0.0, 1.0)); + + float appear = smoothstep(0.0, max(uEnter, 1e-3), head); + float vanish = 1.0 - smoothstep(1.0 - max(uExit, 1e-3), 1.0, head); + vAlpha = appear * vanish * uFade * clamp(uOpen, 0.0, 1.0); + + vT = t; + vSide = side; + vTau = head; + + // World space throughout: the ability's group is an identity transform. + vec4 mv = viewMatrix * vec4(here + binormal * side * halfWidth, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const INFALL_FRAGMENT = /* glsl */ ` + uniform float uCoreSharp; // how hard the hot core falls off across the ribbon + uniform float uHeat; // how much of the colour walk the fall owns + uniform float uGlow; + uniform float uOpacity; + uniform float uSoftFade; + uniform vec3 uColorTail; + uniform vec3 uColorHead; + uniform vec3 uColorCore; + uniform vec3 uColorHalo; + + uniform float uGlobalGlow; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + varying float vT; + varying float vSide; + varying float vTau; + varying float vAlpha; + varying float vViewZ; + + ${commonGLSL} + + void main() { + if (vAlpha < 0.003) discard; + + float v = clamp(abs(vSide), 0.0, 1.0); + float profile = pow(1.0 - v, max(uCoreSharp, 0.05)); + + // Two walks along one gradient, weighted by uHeat: how far down the well + // the stream is, and how near the head of its own tail this fragment is. + // At uHeat = 1 the whole disc reddens together as it collapses; at 0 each + // stream is cold at the tail and hot at the nose whatever its radius. + float heat = clamp(mix(vT, vTau, clamp(uHeat, 0.0, 1.0)), 0.0, 1.0); + vec3 body = mix(uColorTail, uColorHead, heat); + + vec3 colour = mix(uColorHalo, body, profile); + colour = mix(colour, uColorCore, smoothstep(0.55, 1.0, profile) * heat); + + float alpha = profile * vAlpha * uOpacity; + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.003) discard; + + colour *= uGlow * uGlobalGlow; + gl_FragColor = vec4(colour, alpha); + } +`; + +/** + * The instanced in-spiral. One draw call, however many streams. + * + * Pairs with `createBoltRibbonGeometry(nodes, streams)` — the same ribbon strip + * the bolt uses, because the vertex contract (`position.x` along, `position.y` + * across, `aStrand` per instance) is exactly the same and a second copy of that + * buffer builder would be a second thing to keep in step. + */ +export function createInfallMaterial() { + const material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uAge: { value: 0 }, + uCentre: { value: new Vector3() }, + uSeed: { value: 0 }, + + uFallTime: { value: 2.4 }, + uRadiusInner: { value: 1.8 }, + uRadiusOuter: { value: 4.8 }, + uHorizon: { value: 0.6 }, + uOrbitRate: { value: 1.05 }, + uInclination: { value: 0.42 }, + uFlatten: { value: 0.55 }, + uEject: { value: 0 }, + uTrailTime: { value: 0.55 }, + uWobble: { value: 0.1 }, + uWobbleTurns: { value: 3 }, + + uWidth: { value: 0.07 }, + uWidthTail: { value: 0.15 }, + uWidthCurve: { value: 1.5 }, + uWidthNear: { value: 1.8 }, + uEnter: { value: 0.12 }, + uExit: { value: 0.06 }, + uOpen: { value: 0 }, + uFade: { value: 1 }, + + uCoreSharp: { value: 2.6 }, + uHeat: { value: 0.72 }, + uGlow: { value: 2.2 }, + uOpacity: { value: 1 }, + uSoftFade: { value: 0.4 }, + uColorTail: { value: new Color('#3a1f7a') }, + uColorHead: { value: new Color('#b58cff') }, + uColorCore: { value: new Color('#ffffff') }, + uColorHalo: { value: new Color('#160a2e') } + }), + vertexShader: INFALL_VERTEX, + fragmentShader: INFALL_FRAGMENT + }); + + /** + * Push the live settings and the cast's own beats into the uniforms. + * + * Called every frame, zero-length frames included. `state` carries only + * unitless beats, a seed and a world point; every metre, radian and second + * below is read out of `settings.singularity` right here. + * + * @param {object} state { centre, age, seed, open, pull, blow, fade } + */ + material.userData.sync = (state) => { + const c = settings.singularity; + const g = settings.global; + const u = material.uniforms; + + u.uCentre.value.copy(state.centre); + u.uAge.value = state.age; + u.uSeed.value = state.seed; + u.uOpen.value = state.open; + u.uFade.value = state.fade; + + // The disc is narrower while the well is still forming and while the pull + // is hauling it in — both of those are a *fraction* on the beat side and a + // metre on the settings side, which is the split invariant I1 asks for. + const wide = c.discOuter * c.zoneRadius; + const tight = c.discInner * c.zoneRadius; + const draw = 1 - c.discDraw * state.pull; + u.uRadiusOuter.value = wide * (c.discSeed + (1 - c.discSeed) * state.open) * draw; + u.uRadiusInner.value = tight * (c.discSeed + (1 - c.discSeed) * state.open) * draw; + u.uHorizon.value = c.horizonRadius * c.discSwallow; + + u.uFallTime.value = Math.max(0.05, c.fallTime * (1 - c.fallSpeedUp * state.pull)); + u.uOrbitRate.value = c.orbitRate * (1 + c.orbitPull * state.pull); + u.uInclination.value = c.inclination; + u.uFlatten.value = c.flatten; + u.uEject.value = c.ejectDistance * state.blow * g.explosionIntensity; + u.uTrailTime.value = c.trailTime; + u.uWobble.value = c.streamWobble * g.randomness * g.noiseStrength; + u.uWobbleTurns.value = c.streamWobbleTurns * g.noiseFrequency; + + u.uWidth.value = c.streamWidth; + u.uWidthTail.value = c.streamWidthTail; + u.uWidthCurve.value = c.streamWidthCurve; + u.uWidthNear.value = c.streamWidthNear; + u.uEnter.value = c.streamEnter; + u.uExit.value = c.streamExit; + + u.uCoreSharp.value = c.streamCoreSharp; + u.uHeat.value = c.streamHeat; + u.uGlow.value = c.streamGlow * g.glow; + u.uOpacity.value = c.streamOpacity * g.opacity; + u.uSoftFade.value = c.streamSoftFade; + u.uColorTail.value.copy(getColor(c.colorStreamTail)); + u.uColorHead.value.copy(getColor(c.colorStreamHead)); + u.uColorCore.value.copy(getColor(c.colorStreamCore)); + u.uColorHalo.value.copy(getColor(c.colorStreamHalo)); + }; + + return material; +} + +/* ---------------------------------------------------------------------- */ +/* The event horizon */ +/* ---------------------------------------------------------------------- */ +/** + * The quad is built in the vertex shader from an anchor and a half-extent in + * metres, the way `vfx/Distortion.js` builds its emitters, so the mesh's matrix + * stays identity and moving or resizing the horizon is a uniform write. Placing + * it with `mesh.position` and `mesh.scale` instead works right up until you + * pause and drag `horizonRadius`, at which point the *shader* is still thinking + * in the old metres while the matrix has moved on. + */ +const HORIZON_VERTEX = /* glsl */ ` + uniform vec3 uCentre; + uniform float uSize; // half-extent of the quad, metres + + varying vec2 vLocal; // metres from the centre, in the billboard plane + varying float vViewZ; + + void main() { + // viewMatrix's rows are the camera's basis in world space. + vec3 ax = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]); + vec3 ay = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]); + + vec2 ext = (uv - 0.5) * 2.0 * uSize; + vec3 world = uCentre + ax * ext.x + ay * ext.y; + vLocal = ext; + + vec4 mv = viewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/** + * Three concentric statements, and the first one is the unusual one. + * + * **The interior is drawn, not left out.** Every other emissive surface in this + * project adds light; this one alpha-blends a colour darker than the floor over + * the top of whatever is behind it, which is the only way a hole reads on a + * renderer with bloom turned up. Additive blending cannot make anything darker, + * so an "invisible" hole with additive blending is not a hole — it is a gap you + * can see the accretion streams through, and the illusion dies instantly. + * + * **The photon ring is offset outward.** A real one sits at 1.5 Schwarzschild + * radii and it is worth honouring, because a bright ring drawn exactly on the + * silhouette edge reads as an outline on a sticker. The gap between the black + * and the light is what makes it look like something in front of the light. + * + * **One side is brighter.** Relativistic beaming: the half of the disc rotating + * toward the eye is the bright one. Faked as a lateral gradient on the ring, + * signed by the disc's spin, which costs one dot product and is the single + * cheapest cue in the file that the thing is *turning*. + */ +const HORIZON_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform float uRadius; // the horizon, metres + uniform float uEdge; // metres of feather on it + uniform float uRing; // photon-ring radius, × uRadius + uniform float uRingWidth; // metres + uniform float uRingGlow; + uniform float uDoppler; // 0..1 brightness asymmetry across the disc + uniform float uHalo; // metres the outer bloom reaches + uniform float uHaloGlow; + uniform float uRim; // brightness of the sheen inside the ring + uniform float uShimmer; // 0..1 wobble on the ring's radius + uniform float uShimmerRate; // cycles/second + uniform float uOpacity; + uniform float uFade; + uniform float uSoftFade; + uniform vec3 uColorVoid; + uniform vec3 uColorRim; + uniform vec3 uColorRing; + uniform vec3 uColorHalo; + + uniform float uGlobalGlow; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + varying vec2 vLocal; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + float d = length(vLocal); + float rr = max(uRadius, 1e-3); + + // Sampled in the plane, never on atan(y, x): an angular lookup gives every + // radius along a bearing the same value and the ring breaks into spokes. + float wobble = snoise(vec3(vLocal * 2.7, uTime * uShimmerRate)); + float ringR = rr * uRing * (1.0 + uShimmer * wobble); + + float inside = 1.0 - smoothstep(rr - uEdge, rr + uEdge, d); + float ring = exp(-abs(d - ringR) / max(uRingWidth, 1e-3)); + float halo = exp(-max(d - ringR, 0.0) / max(uHalo, 1e-3)) * (1.0 - inside); + + float side = d > 1e-4 ? vLocal.x / d : 0.0; + ring *= clamp(1.0 + uDoppler * side, 0.0, 2.0); + + vec3 colour = uColorVoid * inside; + colour += uColorRim * inside * smoothstep(rr * 0.3, rr, d) * uRim; + colour += uColorRing * ring * uRingGlow; + colour += uColorHalo * halo * uHaloGlow; + + float alpha = clamp(max(inside, max(ring, halo * 0.8)), 0.0, 1.0) * uOpacity * uFade; + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.003) discard; + + // The interior is deliberately left out of the glow multiplier: multiplying + // a near-black by the bloom gain is how a black hole ends up grey. + colour = uColorVoid * inside + (colour - uColorVoid * inside) * uGlobalGlow; + gl_FragColor = vec4(colour, alpha); + } +`; + +/** + * The horizon disc. One draw call, alpha-blended, no depth write. + * + * Give it a render order *above* the infall so it covers the streams that have + * reached it — matter arriving at the horizon should stop existing, and letting + * the ribbons draw over the black is what makes the well look like a decal. + */ +export function createHorizonMaterial() { + const material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: NormalBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uCentre: { value: new Vector3() }, + uSize: { value: 1 }, + + uRadius: { value: 0.62 }, + uEdge: { value: 0.05 }, + uRing: { value: 1.16 }, + uRingWidth: { value: 0.05 }, + uRingGlow: { value: 3.4 }, + uDoppler: { value: 0.55 }, + uHalo: { value: 0.42 }, + uHaloGlow: { value: 1.4 }, + uRim: { value: 0.5 }, + uShimmer: { value: 0.12 }, + uShimmerRate: { value: 0.7 }, + uOpacity: { value: 1 }, + uFade: { value: 1 }, + uSoftFade: { value: 0.3 }, + uColorVoid: { value: new Color('#0a0612') }, + uColorRim: { value: new Color('#6a3fd0') }, + uColorRing: { value: new Color('#ffffff') }, + uColorHalo: { value: new Color('#8a5cf0') } + }), + vertexShader: HORIZON_VERTEX, + fragmentShader: HORIZON_FRAGMENT + }); + + /** + * @param {object} state { centre, open, blow, fade } + */ + material.userData.sync = (state) => { + const c = settings.singularity; + const g = settings.global; + const u = material.uniforms; + + u.uCentre.value.copy(state.centre); + + // The horizon opens out of nothing and is *torn* open by the collapse + // before it goes — the last frame of a singularity should be big, not dim. + const radius = c.horizonRadius * (c.horizonSeed + (1 - c.horizonSeed) * state.open) + * (1 + c.horizonBurst * state.blow); + u.uRadius.value = radius; + u.uEdge.value = c.horizonEdge; + u.uRing.value = c.horizonRing; + u.uRingWidth.value = c.horizonRingWidth; + u.uRingGlow.value = c.horizonRingGlow * g.glow; + u.uDoppler.value = c.horizonDoppler; + u.uHalo.value = c.horizonHalo; + u.uHaloGlow.value = c.horizonHaloGlow * g.glow; + u.uRim.value = c.horizonRim; + u.uShimmer.value = c.horizonShimmer * g.noiseStrength; + u.uShimmerRate.value = c.horizonShimmerRate * g.noiseSpeed; + u.uOpacity.value = c.horizonOpacity * g.opacity; + u.uFade.value = state.fade; + u.uSoftFade.value = c.horizonSoftFade; + + // The quad has to cover the ring and the whole exponential tail of the + // halo, or the bloom is cut off in a square — resolved from the same live + // metres, never guessed at. + u.uSize.value = radius * c.horizonRing + c.horizonHalo * 4 + c.horizonRingWidth * 3; + + u.uColorVoid.value.copy(getColor(c.colorVoid)); + u.uColorRim.value.copy(getColor(c.colorRim)); + u.uColorRing.value.copy(getColor(c.colorRing)); + u.uColorHalo.value.copy(getColor(c.colorHorizonHalo)); + }; + + return material; +} diff --git a/src/materials/SnareMaterial.js b/src/materials/SnareMaterial.js index b799401..7d31f46 100644 --- a/src/materials/SnareMaterial.js +++ b/src/materials/SnareMaterial.js @@ -310,7 +310,7 @@ const CAGE_VERTEX = /* glsl */ ` float halfWidth = uWidth * uWidthScale * widthMul; // Every filament is an arc with two loose ends, so it tapers at both. - halfWidth *= mix(1.0, pow(sin(clamp(t, 0.0, 1.0) * PI), 0.35), 0.85); + halfWidth *= mix(1.0, pow(max(sin(clamp(t, 0.0, 1.0) * PI), 0.0), 0.35), 0.85); if (role > 0.5 && role < 1.5) halfWidth *= mix(1.0, uColumnTaper, t); halfWidth *= flash * uFade; @@ -376,7 +376,7 @@ const CAGE_FRAGMENT = /* glsl */ ` // The loose ends fade as well as thin. A ribbon that only narrows leaves a // hard dot at each tip, and every filament here has two of them. - alpha *= pow(sin(clamp(vT, 0.0, 1.0) * PI), 0.35); + alpha *= pow(max(sin(clamp(vT, 0.0, 1.0) * PI), 0.0), 0.35); alpha *= flicker * vFlash * vDim * uFade * uPassOpacity * uOpacity; vec2 screenUV = gl_FragCoord.xy / uResolution; diff --git a/src/materials/SnowSlabMaterial.js b/src/materials/SnowSlabMaterial.js new file mode 100644 index 0000000..a0dedda --- /dev/null +++ b/src/materials/SnowSlabMaterial.js @@ -0,0 +1,222 @@ +import { MeshStandardMaterial, Color, Vector3 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * A block of wind-packed snow, broken out of a slab and shouldered up by the + * flow behind it. + * + * Built on `MeshStandardMaterial`, like the Lance's crystal and the hailstone, + * for the same reason: this is a **solid** sitting in a heap of translucent + * grains, and if it does not take the key light and cast into the shadow map it + * disappears into the heap it is supposed to be standing out of. The first + * version was an additive `ShaderMaterial` and the slabs read as bright smudges + * hovering over the snow — the exact failure the hail material's header warns + * about, arrived at independently. + * + * Three ideas, in the order they matter: + * + * - **the fracture face.** A snow slab does not erode, it *breaks*, and it + * breaks on a plane. So one face of every block — the one pointing back up + * the slope, against the flow — is raw, bright, untouched snow, and every + * other face is the wind crust that was on the surface of the snowpack an + * hour ago. It is one dot product against a direction the ability resolves + * each frame, and it is the entire reason the blocks read as *broken* rather + * than as rocks that happen to be white. Widen `fractureSharp` down toward + * 1 and the whole block goes raw; push it up and only the true crown face + * catches it. + * - **strata.** Wind-packed snow is layered, and the layers are horizontal + * *in the world*, not in the block: the block has been tipped over. So the + * banding is sampled on **world y** and not on local y. Sampling it locally + * was the first attempt and every slab came out banded along its own axis, + * which reads as a stack of coins and tells you instantly that the block + * was extruded rather than broken off something bigger. + * - **grain, hashed rather than smoothed.** A speckle on a hashed lattice, + * because value noise piles up at its midpoint and thresholding it gives + * either a clean block or a uniform rash — the trap `VolumeHull` documents. + * + * Per-instance inputs are the two attributes `GrowthField` dresses its geometry + * with (`aSeed`, `aBirth`), so this is only ever used on an `InstancedMesh`. + */ +export function createSnowSlabMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.86, + metalness: 0.0, + // Faceted. A smooth-shaded snow block is a bread roll. + flatShading: true + }); + + const uniforms = { + uTime: frame.uTime, + uColorCrust: { value: new Color() }, + uColorFracture: { value: new Color() }, + uColorShade: { value: new Color() }, + uColorGlow: { value: new Color() }, + uStrata: { value: 0.4 }, + uStrataScale: { value: 9.0 }, + uGrain: { value: 0.22 }, + uGrainScale: { value: 34.0 }, + uFracture: { value: 1.1 }, + uFractureSharp: { value: 1.9 }, + uRim: { value: 0.7 }, + uRimPower: { value: 2.6 }, + uGlow: { value: 0.85 }, + uBirthGlow: { value: 1.6 }, + /** Unit heading of the flow, world space. The crown face points at -this. */ + uFlow: { value: new Vector3(0, 0, 1) } + }; + + environment.registerShadowCasterWithPatch(material, (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + `#include + attribute float aSeed; + attribute float aBirth; + varying vec3 vSlabLocal; + varying vec3 vSlabWorld; + varying vec3 vSlabNormalW; + varying float vSlabSeed; + varying float vSlabBirth;` + ) + // `objectNormal` is declared by , which runs before + // this chunk. three only ever takes it into *view* space, and the crown + // face test needs it in world space. + .replace( + '#include ', + `#include + vSlabLocal = transformed; + vSlabSeed = aSeed; + vSlabBirth = aBirth; + #ifdef USE_INSTANCING + vSlabWorld = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz; + vSlabNormalW = normalize(mat3(modelMatrix) * (instanceMatrix * vec4(objectNormal, 0.0)).xyz); + #else + vSlabWorld = (modelMatrix * vec4(transformed, 1.0)).xyz; + vSlabNormalW = normalize(mat3(modelMatrix) * objectNormal); + #endif` + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform float uTime; + uniform vec3 uColorCrust; + uniform vec3 uColorFracture; + uniform vec3 uColorShade; + uniform vec3 uColorGlow; + uniform float uStrata; + uniform float uStrataScale; + uniform float uGrain; + uniform float uGrainScale; + uniform float uFracture; + uniform float uFractureSharp; + uniform float uRim; + uniform float uRimPower; + uniform float uGlow; + uniform float uBirthGlow; + uniform vec3 uFlow; + varying vec3 vSlabLocal; + varying vec3 vSlabWorld; + varying vec3 vSlabNormalW; + varying float vSlabSeed; + varying float vSlabBirth; + ${noiseGLSL}` + ) + // Injected once the normal is resolved: with flatShading there is no + // vNormal varying, so every view-dependent term has to read the face + // normal that derives from the derivatives. + .replace( + '#include ', + `#include + { + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + + /* --- the crown face: raw snow where the slab broke --- */ + // Measured on the WORLD normal, against the flow the ability hands + // in. A block that has tumbled shows its broken face wherever that + // face has ended up, which is the whole point of resolving it here + // rather than baking it into the geometry. + float crown = clamp(dot(normalize(vSlabNormalW), -uFlow), 0.0, 1.0); + crown = pow(crown, max(uFractureSharp, 0.05)); + + /* --- strata: horizontal in the ROOM, not in the block --- */ + float bands = sin(vSlabWorld.y * uStrataScale * 6.2831853 + + vSlabSeed * 5.1 + + fbm3(vSlabWorld * 0.9 + vSlabSeed) * 2.2); + float strata = 1.0 + bands * uStrata * 0.5 * (1.0 - crown); + + /* --- grain, on a hashed lattice so it stays a speckle --- */ + float speck = hash13(floor(vSlabWorld * max(uGrainScale, 1.0)) + vSlabSeed * 13.0); + float grain = 1.0 + (speck - 0.5) * 2.0 * uGrain; + + vec3 body = mix(uColorCrust, uColorFracture, crown * clamp(uFracture, 0.0, 1.5)); + body *= strata * grain; + + // Cheap cavity term: unit-space radius says how far out of the block + // this fragment is, and the recesses of a fracture are dark. + body *= mix(0.74, 1.05, smoothstep(0.15, 0.75, length(vSlabLocal.xz) + vSlabLocal.y * 0.3)); + // The shade colour is a picker, not a multiply on the body, so a + // slab in shadow is blue-grey and not merely dimmer (I5). + body = mix(uColorShade, body, clamp(0.35 + 0.65 * ndv, 0.0, 1.0)); + + diffuseColor.rgb *= max(body, vec3(0.0)); + + /* --- what actually emits: a rim, and the birth flash --- */ + vec3 glow = uColorGlow * pow(1.0 - ndv, max(uRimPower, 0.05)) * uRim; + glow += uColorGlow * vSlabBirth * uBirthGlow; + glow *= uGlow; + // The soft ceiling the rock and the hailstone both use: these terms + // are independent and stack, and a rim crossing a birth flash sums + // past 8 and blows the whole block out under bloom. + glow /= 1.0 + glow * 0.3; + totalEmissiveRadiance += glow; + }` + ); + }); + + /** + * Parked here rather than on `material.uniforms`, which a patched + * `MeshStandardMaterial` does not have. The harness's pause test reads this + * exact property, and without it every slider below is reported dead (I8). + */ + material.userData.uniforms = uniforms; + + /** + * Pull the palette and every shading control from the live settings. + * @param {THREE.Vector3} [flow] unit heading of the avalanche, world space + */ + material.userData.sync = (flow = null) => { + const c = settings.avalanche; + const g = settings.global; + + material.roughness = c.slabRoughness; + + uniforms.uColorCrust.value.copy(getColor(c.colorCrust)); + uniforms.uColorFracture.value.copy(getColor(c.colorFracture)); + uniforms.uColorShade.value.copy(getColor(c.colorShade)); + uniforms.uColorGlow.value.copy(getColor(c.colorSlabGlow)); + + uniforms.uStrata.value = c.strata; + uniforms.uStrataScale.value = c.strataScale * g.noiseFrequency; + uniforms.uGrain.value = c.grain * g.randomness; + uniforms.uGrainScale.value = c.grainScale * g.noiseFrequency; + uniforms.uFracture.value = c.fracture; + uniforms.uFractureSharp.value = c.fractureSharp; + uniforms.uRim.value = c.slabRim * g.fresnel; + uniforms.uRimPower.value = c.slabRimPower; + uniforms.uGlow.value = c.slabGlow * g.glow; + uniforms.uBirthGlow.value = c.slabBirthGlow * g.shaderIntensity; + + if (flow) uniforms.uFlow.value.copy(flow); + }; + + return material; +} diff --git a/src/materials/SunspearMaterial.js b/src/materials/SunspearMaterial.js new file mode 100644 index 0000000..f48e00e --- /dev/null +++ b/src/materials/SunspearMaterial.js @@ -0,0 +1,214 @@ +import { ShaderMaterial, Color, AdditiveBlending, DoubleSide } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * The white-fire javelin. + * + * Deliberately the least interesting material in the ability. Sunspear's read + * is the *wake* — a hole punched in the refraction buffer — and the sun disc it + * opens; a spear with its own crust, seams and embers would compete with both + * and win, because it is the thing in the middle of the screen. So this is one + * unlit additive pass over a faceted spindle, and everything it does is in + * service of "very bright, and pointed that way". + * + * Three terms, all of them in the geometry's **own** frame so they hold still + * while the spear rolls: + * + * - **the core.** Local radius against `uCore`: the middle of the shaft runs + * pure white and the fire falls off across it on `uFalloff`. Doing this in + * screen space instead — the obvious "bright in the middle of the + * silhouette" — makes the hot line slide about as the camera orbits, and + * the spear stops looking solid. + * - **the head.** Everything past `uHead` along the shaft gets hotter, so the + * point is the brightest thing on it. A javelin with an even temperature is + * a fluorescent tube. + * - **the crawl.** One noise octave advected along the shaft, which is all a + * 1.8 m object needs; anything finer disappears at the distance you + * actually watch this from. + * + * The fresnel is added rather than mixed. On an additive pass a mixed rim + * darkens the middle of the silhouette to pay for the edge, which on a white + * object reads as a hole. + */ +const SPEAR_VERTEX = /* glsl */ ` + attribute float aSeed; + attribute float aFlight; + attribute float aFlash; + + varying vec3 vLocal; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + varying float vSeed; + varying float vFlight; + varying float vFlash; + + void main() { + vLocal = position; + vSeed = aSeed; + vFlight = aFlight; + vFlash = aFlash; + + mat4 im = mat4(1.0); + #ifdef USE_INSTANCING + im = instanceMatrix; + #endif + + vec4 world = modelMatrix * im * vec4(position, 1.0); + vNormalW = normalize(mat3(modelMatrix) * mat3(im) * normal); + vViewDir = cameraPosition - world.xyz; + + vec4 mv = viewMatrix * world; + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const SPEAR_FRAGMENT = /* glsl */ ` + uniform vec3 uColorCore; + uniform vec3 uColorFire; + uniform vec3 uColorEdge; + uniform float uCore; + uniform float uFalloff; + uniform float uHead; + uniform float uHeadGain; + uniform float uFresnel; + uniform float uFresnelPower; + uniform float uNoise; + uniform float uNoiseScale; + uniform float uNoiseSpeed; + uniform float uGlow; + uniform float uOpacity; + uniform float uSoft; + uniform float uFade; + + uniform float uTime; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + + varying vec3 vLocal; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + varying float vSeed; + varying float vFlight; + varying float vFlash; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + vec3 N = normalize(vNormalW); + vec3 V = normalize(vViewDir); + + // Local frame. The spindle spans y in -1..1 with the point at +1 and its + // half-width at 0.5, so "across" is 0 on the axis and 1 at the surface. + float along = clamp(vLocal.y * 0.5 + 0.5, 0.0, 1.0); + float across = clamp(length(vLocal.xz) * 2.0, 0.0, 1.0); + + // The shaft, hottest on the axis. + float body = pow(1.0 - across, max(uFalloff, 0.05)); + float core = smoothstep(uCore, uCore * 0.35, across); + + // The head. Everything past uHead runs hotter, on a curve rather than a + // step, or the join reads as a collar. + float head = smoothstep(uHead, 1.0, along); + + // One octave, advected down the shaft. The seed decorrelates two casts. + float crawl = snoise(vec3(vLocal.xz * uNoiseScale, along * uNoiseScale - uTime * uNoiseSpeed) + vSeed * 13.0); + float fire = body * (1.0 + uNoise * crawl * 0.5); + + float fres = pow(1.0 - clamp(abs(dot(N, V)), 0.0, 1.0), max(uFresnelPower, 0.05)); + + vec3 color = uColorFire * fire; + color += uColorCore * (core + head * uHeadGain * 0.35 + vFlash); + color += uColorEdge * fres * uFresnel; + + // The soft ceiling every emissive surface in this project carries: these + // terms are independent and stack, and a fresnel edge crossing the white + // core sums past 10 and the bloom pass eats the silhouette. + color *= uGlow * uGlobalGlow; + color /= 1.0 + color * 0.14; + + float alpha = clamp(fire + core * 0.8 + fres * 0.6, 0.0, 1.0) * uOpacity * uFade; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoft); + if (alpha < 0.004) discard; + + gl_FragColor = vec4(color, alpha); + } +`; + +/** + * Build the javelin's material. One per ability instance; the ability owns it + * and disposes it. + */ +export function createSunspearMaterial() { + const material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + // The spindle is thin enough that you see its far side constantly. + side: DoubleSide, + blending: AdditiveBlending, + toneMapped: false, + uniforms: sharedUniforms({ + uColorCore: { value: new Color('#ffffff') }, + uColorFire: { value: new Color('#ffe07a') }, + uColorEdge: { value: new Color('#ff9a1f') }, + uCore: { value: 0.34 }, + uFalloff: { value: 2.2 }, + uHead: { value: 0.62 }, + uHeadGain: { value: 2.4 }, + uFresnel: { value: 1.5 }, + uFresnelPower: { value: 2.0 }, + uNoise: { value: 0.55 }, + uNoiseScale: { value: 4.5 }, + uNoiseSpeed: { value: 2.6 }, + uGlow: { value: 3.2 }, + uOpacity: { value: 1 }, + uSoft: { value: 0.4 }, + uFade: { value: 1 } + }), + vertexShader: SPEAR_VERTEX, + fragmentShader: SPEAR_FRAGMENT + }); + + /** + * Pull every shading control from the live settings. + * @param {number} fade 1 while the spear is in the air, 0 once it has landed + */ + material.userData.sync = (fade = 1) => { + const c = settings.sunspear; + const g = settings.global; + const u = material.uniforms; + + u.uColorCore.value.copy(getColor(c.colorSpearCore)); + u.uColorFire.value.copy(getColor(c.colorSpearFire)); + u.uColorEdge.value.copy(getColor(c.colorSpearEdge)); + + u.uCore.value = Math.max(0.01, c.spearCore); + u.uFalloff.value = c.spearFalloff; + u.uHead.value = c.spearHead; + u.uHeadGain.value = c.spearHeadGain; + u.uFresnel.value = c.spearFresnel * g.fresnel; + u.uFresnelPower.value = c.spearFresnelPower; + u.uNoise.value = c.spearNoise * g.noiseStrength; + u.uNoiseScale.value = c.spearNoiseScale * g.noiseFrequency; + u.uNoiseSpeed.value = c.spearNoiseSpeed * g.noiseSpeed; + u.uGlow.value = c.spearGlow * g.glow; + u.uOpacity.value = c.spearOpacity * g.opacity; + u.uSoft.value = c.spearSoftFade; + u.uFade.value = fade; + }; + + return material; +} diff --git a/src/materials/TectonicMaterial.js b/src/materials/TectonicMaterial.js new file mode 100644 index 0000000..e7f6dd4 --- /dev/null +++ b/src/materials/TectonicMaterial.js @@ -0,0 +1,364 @@ +import { ShaderMaterial, AdditiveBlending, NormalBlending, Color, DoubleSide, Vector3 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; +import { clamp } from '../utils/math.js'; + +/** + * Hard ceiling on main arms in one slam. The editor's `arms` slider clamps + * here, and the geometry is baked for this many whatever the slider says — + * which is the whole reason `arms` can be dragged on a network that is already + * lying on the floor. + */ +export const MAX_FISSURE_ARMS = 8; + +/** + * Which of the two passes over the same ribbon a material draws. + * + * They are *not* the same blend. The gash is alpha-blended, because a crack in + * a stone floor is a hole and a hole is darker than the stone around it; the + * underglow is additive, because the ember light in the bottom of it is not. + * Every other two-pass effect in this project is additive twice over (the bolt, + * the meteor's fissures) and that is exactly why those read as light and this + * one reads as ground. + */ +export const FissurePass = Object.freeze({ + GASH: 0, // the open crack itself: dark seam, hot floor, white tip + UNDERGLOW: 1 // the ember light it throws onto the stone either side +}); + +const TAU = Math.PI * 2; + +/** + * The whole fissure network lives in this vertex shader. + * + * A vertex arrives as a **unitless walk** — how far out along its arm it is, + * how far that arm has veered by then, and which edge of the ribbon it is on — + * and leaves as a world position. Nothing about the network's shape exists on + * the CPU as a metre or a radian, which is the difference between this and + * `effects/GroundFissures.js`: that one bakes `x, z` into a unit disc at spawn, + * so its `wander` is frozen the instant a crater lands. Here the *angular + * series* is baked and the radians are applied here, so `wander`, `armJitter` + * and `arms` all re-draw a network that is already open, with the clock stopped. + * + * Three numbers stack into the shape: + * + * 1. **the bearing** — `spin + (arm + ½)/arms · 2π + jitter`. Rebuilt every + * frame from the live arm count, which is what lets five cracks become + * eight while you watch and stay evenly fanned. + * 2. **the veer** — a baked unit-amplitude random walk in θ, multiplied by + * `uWander`. Zero wander gives you dead-straight spokes, which is a + * starburst and looks wrong; that is what the slider is for. + * 3. **the offset** — forks only. A fork is stored in its *parent's* frame as + * (along, lateral) at the anchor's reach, so it swings with the parent + * instead of drifting off it when the wander is dragged. + * + * The tangent is analytic. For r ↦ r·(cos θ(r), sin θ(r)) the derivative is + * `(cos θ − r·θ′·sin θ, sin θ + r·θ′·cos θ)`, and since θ′ is just the baked + * rate times `uWander` we get the exact perpendicular for free. The first + * version used a baked `aSide` copied off the generator, and the moment + * `wander` moved the ribbon started leaning: the centreline had turned and the + * width had not. + * + * **The propagation.** Each arm carries its own unitless speed dice and the + * front is `age · mix(uSpeedMin, uSpeedMax, dice)` **metres of reach** — radial + * distance from the centre, not arc length walked. That is deliberate. The + * contract this ability is built on is that the fastest crack and the shockwave + * ring touch the boundary on the same frame; parameterise the front on arc + * length and a crack that wandered further arrives late, and the two events + * come apart by a slider nobody thinks to look at. + */ +const FISSURE_VERTEX = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + uniform vec3 uCentre; + uniform float uRadius; + uniform float uAge; + uniform float uSpin; + uniform float uArms; + uniform float uArmJitter; + uniform float uWander; + uniform float uSpeedMin; + uniform float uSpeedMax; + uniform float uWidth; + uniform float uWidthScale; + uniform float uOpenLag; + uniform float uForkFrac; + uniform float uForkLength; + + /* position = (baseReach, along, lateral) — unit fractions of the footprint, + never metres. See the class comment on TectonicAbility. */ + attribute vec4 aShape; // (wander, wanderRate, propagation, widthJitter) + attribute vec4 aRole; // (arm index, bearing jitter, fork progress, speed dice) + attribute vec2 aEdge; // (across the ribbon, fork rank) + + varying float vBehind; + varying float vSince; + varying float vAcross; + varying float vTaper; + varying float vProp; + varying float vLive; + + void main() { + float baseReach = position.x; + float along = position.y; + float lateral = position.z; + + float wander = aShape.x; + float rate = aShape.y; + float prop = aShape.z; + float widthJit = aShape.w; + + float arm = aRole.x; + float bearJit = aRole.y; + float fork = aRole.z; + float speed01 = aRole.w; + + float across = aEdge.x; + float rank = aEdge.y; + + // An arm past the live count is culled, and so is a fork whose rank sits + // above the density fraction. Main arms are rank 0 and survive everything. + float live = step(arm, uArms - 0.5) * step(rank, uForkFrac); + + float bearing = uSpin + (arm + 0.5) / max(uArms, 1.0) * TAU + bearJit * uArmJitter; + float theta = bearing + wander * uWander; + float dTheta = rate * uWander; + + vec2 dir = vec2(cos(theta), sin(theta)); + vec2 tangent = normalize(vec2(dir.x - baseReach * dTheta * dir.y, + dir.y + baseReach * dTheta * dir.x)); + vec2 sideDir = vec2(-tangent.y, tangent.x); + + vec2 unitPoint = dir * baseReach + tangent * along + sideDir * lateral; + + // A fork is pinched to a point wherever the length slider currently ends. + float forkTaper = pow(clamp(1.0 - fork / max(uForkLength, 1e-3), 0.0, 1.0), 0.7); + float taper = mix(1.0, forkTaper, step(1e-4, fork)); + + float speed = max(mix(uSpeedMin, uSpeedMax, speed01), 0.01); + float behind = uAge * speed - prop * uRadius; // metres of reach behind the tip + + // The crack is a hairline at the tip and opens out behind it: it unzips + // rather than appearing at full width, which is most of what sells the + // motion. The first cut opened instantly and read as a decal being wiped on. + float openWidth = smoothstep(0.0, max(uOpenLag, 0.01), behind); + float halfWidth = uWidth * 0.5 * uWidthScale * widthJit * taper * openWidth * live; + + vec3 world = uCentre + vec3(unitPoint.x * uRadius + sideDir.x * across * halfWidth, + 0.0, + unitPoint.y * uRadius + sideDir.y * across * halfWidth); + + vBehind = behind; + vSince = behind / speed; // seconds this point has been open — the ember clock + vAcross = across; + vTaper = taper; + vProp = prop; + vLive = live; + + gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0); + } +`; + +const FISSURE_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform float uRadius; + uniform float uFade; + uniform float uSeed; + uniform float uTipFeather; + uniform float uHeat; + uniform float uCool; + uniform float uPulse; + uniform float uFlicker; + uniform float uTipFlash; + uniform float uGrain; + uniform float uPassOpacity; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + uniform vec3 uColorSeam; + uniform vec3 uColorRed; + uniform vec3 uColorEmber; + uniform vec3 uColorHot; + + varying float vBehind; + varying float vSince; + varying float vAcross; + varying float vTaper; + varying float vProp; + varying float vLive; + + ${noiseGLSL} + + void main() { + if (vLive < 0.5) discard; + + // Nothing exists in front of the tip. + float open = smoothstep(0.0, max(uTipFeather, 0.005), vBehind); + if (open < 0.002) discard; + + /* + * The cooling is the point of the ability, so it is a real exponential on + * the *local* clock rather than one fade over the whole network: a point + * one metre out has been open for far longer than a point on the rim, so + * by the time the boundary is still tearing white the middle is already + * dull basalt. Fading the lot together looked like a light being switched + * off, which is the one thing cooling stone does not do. + */ + float cool = exp(-max(vSince, 0.0) / max(uCool, 0.02)); + + float grain = snoise01(vec3(vProp * uRadius * 2.7, vAcross * 1.6, uSeed)); + float pulse = sin(vProp * uRadius * 3.1 - uTime * uPulse) * 0.22 + 0.78; + float flick = mix(1.0, snoise01(vec3(vProp * 7.3, uTime * 2.6, uSeed + 4.1)), uFlicker); + + float across = abs(vAcross); + float centreness = 1.0 - smoothstep(0.1, 1.0, across); + float lip = 1.0 - smoothstep(0.78, 1.0, across); + // A white flash rides the tearing tip itself. + float flash = (1.0 - smoothstep(0.0, max(uTipFeather * 2.5, 0.01), abs(vBehind))) * uTipFlash; + + float heat = uHeat * cool * centreness * pulse * flick * (vTaper * 0.4 + 0.6) + flash; + + #if TECTONIC_PASS == 0 /* THE GASH */ + vec3 color = mix(uColorSeam, uColorRed, smoothstep(0.0, 0.5, heat)); + color = mix(color, uColorEmber, smoothstep(0.5, 1.1, heat)); + color = mix(color, uColorHot, smoothstep(1.1, 2.0, heat)); + // Only the hot part of the gash answers to the global glow. Multiplying + // the cold seam by it as well made a dead crack brighten when the bloom + // was turned up, which is backwards. + color *= mix(1.0, uGlobalGlow, smoothstep(0.2, 1.2, heat)); + float alpha = open * lip * uFade * uPassOpacity * mix(1.0, grain, uGrain); + + #else /* THE UNDERGLOW */ + float falloff = pow(max(1.0 - across, 0.0), 1.7); + float strength = falloff * heat * 0.4; + vec3 color = (uColorEmber * strength + uColorHot * flash * 0.5) * uGlobalGlow; + float alpha = open * uFade * uPassOpacity * clamp(strength * 2.0, 0.0, 1.0); + #endif + + if (alpha < 0.004) discard; + gl_FragColor = vec4(clamp(color * mix(0.7, 1.0, uShaderIntensity), 0.0, 64.0), + clamp(alpha, 0.0, 1.0)); + } +`; + +/** + * One pass of the fissure network. + * + * Both passes share the geometry and every uniform except `uWidthScale` and + * `uPassOpacity`, so `userData.sync()` can be handed the same state object for + * each and the editor drives them together — the convention + * `materials/LightningMaterial.js` established. + * + * @param {number} pass FissurePass.* + */ +export function createFissureMaterial(pass = FissurePass.GASH) { + const glow = pass === FissurePass.UNDERGLOW; + + const material = new ShaderMaterial({ + defines: { TECTONIC_PASS: pass }, + transparent: true, + depthWrite: false, + depthTest: true, + blending: glow ? AdditiveBlending : NormalBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uCentre: { value: new Vector3() }, + uRadius: { value: 7 }, + uAge: { value: 0 }, + uFade: { value: 1 }, + uSeed: { value: 0 }, + uSpin: { value: 0 }, + + uArms: { value: 5 }, + uArmJitter: { value: 0.22 }, + uWander: { value: 1.1 }, + uSpeedMin: { value: 9 }, + uSpeedMax: { value: 17 }, + + uWidth: { value: 0.5 }, + uWidthScale: { value: glow ? 3.4 : 1 }, + uOpenLag: { value: 0.9 }, + uTipFeather: { value: 0.3 }, + uForkFrac: { value: 0.7 }, + uForkLength: { value: 0.8 }, + + uHeat: { value: 1.6 }, + uCool: { value: 1.1 }, + uPulse: { value: 3.2 }, + uFlicker: { value: 0.35 }, + uTipFlash: { value: 1.6 }, + uGrain: { value: 0.45 }, + uPassOpacity: { value: glow ? 0.5 : 1 }, + + uColorSeam: { value: new Color(0.05, 0.04, 0.03) }, + uColorRed: { value: new Color(0.5, 0.13, 0.03) }, + uColorEmber: { value: new Color(1, 0.48, 0.16) }, + uColorHot: { value: new Color(1, 0.85, 0.63) } + }), + vertexShader: FISSURE_VERTEX, + fragmentShader: FISSURE_FRAGMENT + }); + + material.name = glow ? 'TectonicUnderglow' : 'TectonicGash'; + + /** + * Push the live settings and the cast's dice into this pass. + * + * `state` carries **only** dice rolls and clocks: where the slam landed, how + * long ago it landed, the cast's seed and its spin fraction. Every metre, + * radian and second below is re-read from `settings.tectonic` on the frame it + * is used, including a zero-length one. + * + * @param {object} state { centre, age, fade, seed, spin } + */ + material.userData.sync = (state) => { + const c = settings.tectonic; + const g = settings.global; + const u = material.uniforms; + + u.uCentre.value.copy(state.centre); + u.uCentre.value.y = c.fissureHeight; + u.uAge.value = state.age; + u.uFade.value = state.fade; + u.uSeed.value = state.seed; + // A unitless dice roll on the CPU; the turn is applied here, so the cast + // never wrote down a radian (I1). + u.uSpin.value = state.spin * TAU; + + u.uRadius.value = Math.max(0.2, c.zoneRadius); + u.uArms.value = clamp(Math.round(c.arms), 1, MAX_FISSURE_ARMS); + u.uArmJitter.value = c.armJitter; + u.uWander.value = c.wander * g.randomness; + u.uSpeedMin.value = Math.max(0.2, c.fissureSpeed) * g.speed; + u.uSpeedMax.value = Math.max(0.2, c.fissureSpeed * (1 + Math.max(0, c.speedSpread))) * g.speed; + + u.uWidth.value = c.fissureWidth; + u.uWidthScale.value = glow ? c.glowWidth : 1; + u.uOpenLag.value = c.openLag; + u.uTipFeather.value = c.tipFeather; + u.uForkFrac.value = c.forks; + u.uForkLength.value = c.forkLength; + + u.uHeat.value = c.emberHeat * g.shaderIntensity; + u.uCool.value = c.emberCool; + u.uPulse.value = c.emberPulse; + u.uFlicker.value = c.emberFlicker; + u.uTipFlash.value = c.tipFlash; + u.uGrain.value = c.fissureGrain * g.noiseStrength; + u.uPassOpacity.value = (glow ? c.glowOpacity : c.gashOpacity) * g.opacity; + + u.uColorSeam.value.copy(getColor(c.colorSeam)); + u.uColorRed.value.copy(getColor(c.colorMagma)); + u.uColorEmber.value.copy(getColor(c.colorEmber)); + u.uColorHot.value.copy(getColor(c.colorHot)); + }; + + return material; +} + +/** The layer both passes are drawn on — VFX, like every other ribbon here. */ +export const FISSURE_LAYER = LAYER.VFX; diff --git a/src/materials/ThinFilmMaterial.js b/src/materials/ThinFilmMaterial.js new file mode 100644 index 0000000..48feff6 --- /dev/null +++ b/src/materials/ThinFilmMaterial.js @@ -0,0 +1,642 @@ +import { + ShaderMaterial, + InstancedBufferGeometry, + InstancedBufferAttribute, + BufferAttribute, + SphereGeometry, + AdditiveBlending, + DoubleSide, + Color, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/* ====================================================================== */ +/* THIN FILM — soap-bubble colour, computed rather than authored */ +/* ====================================================================== */ +/** + * The one shader in this project whose **hue is not a picker**. + * + * Everything else in `src/materials` gets its colour from a gradient, a lerp + * between two pickers, or a fresnel ramp between a body colour and a rim + * colour. That is the right answer almost every time, and it is the wrong + * answer for a soap film, because a soap film's colour is a *measurement*: it + * is what is left of white light after the wave reflected off the front face of + * the film and the wave reflected off the back face have been added together. + * Change the film's thickness by eighty nanometres and the colour moves from + * gold to magenta whether the artist wanted it to or not. + * + * So this file implements the interference and lets the colour fall out. + * + * ## The term + * + * For a film of index `n` and thickness `d`, a ray arriving at `θᵢ` refracts to + * `θₜ` inside it (`sin θᵢ = n sin θₜ`) and the two reflected waves differ in + * optical path by + * + * ``` + * Δ = 2 · n · d · cos θₜ + * ``` + * + * — and then there is the half-wave. The reflection off the **front** face is + * air → water, going *into* the denser medium, so it flips sign; the reflection + * off the back face is water → air and does not. That is an extra `π` of phase + * that no amount of geometry produces, and it is the whole reason a soap film + * goes **black** rather than white as it thins to nothing: + * + * ``` + * δ(λ) = 2π·Δ/λ + π I(λ) = ½(1 + cos δ) = ½ − ½·cos(2π·Δ/λ) + * ``` + * + * At `d = 0` that is exactly zero for every wavelength. Drop the `π` — which + * the first version of this shader did — and a vanishing film goes brilliant + * white instead, which is not a subtlety: the top of a bubble about to burst is + * the single most recognisable thing about bubbles, and getting it backwards + * makes the whole effect read as a fresnel ramp with rainbow noise on it. + * + * ## Spectrum → RGB + * + * `I(λ)` is a spectrum, and a spectrum is not a colour until it has been + * integrated against an observer. Sampling three wavelengths and calling them + * R, G and B was the first attempt and it is visibly wrong — the three-tap + * version beats against its own sampling and produces cyan/orange banding that + * no real film has. Instead this integrates `FILM_SAMPLES` wavelengths across + * 400–700 nm against a piecewise-Gaussian fit of the CIE 1931 colour matching + * functions and converts the resulting XYZ to linear sRGB. It costs + * `FILM_SAMPLES × 7` exponentials a fragment, and it is the reason the bands + * come out in the right order — gold, magenta, blue, silver, black — going up + * a draining bubble. + * + * `FILM_SAMPLES` is a compile-time constant rather than a slider because it is + * a *sampling rate*, not a look. Above roughly 900 nm of film the integrand + * oscillates faster than 14 taps across the visible band can follow and the + * colours alias into false pastels, which is why `filmThickness` stops there. + * + * ## What is still a picker + * + * Four things, because they are genuinely authored and not derived from the + * interference: the water's own body tint, the specular glint, the wash on the + * silhouette, and the colour the film decays to as it goes black (which is + * *not* forced to `#000000` — a real black film still picks up a little of the + * room). The film hue itself is deliberately absent from the settings block and + * `bubblecage.js` says so at the point where a reader will look for it. + */ + +/** Wavelength taps across 400–700 nm. See the note on aliasing above. */ +const FILM_SAMPLES = 14; + +/** + * The CIE fit and the interference, as one chunk. + * + * Kept out of `shaders/lib/` on purpose: it is one ability's physics, not a + * shared helper, and putting it in the library would invite the next author to + * reach for it as a "rainbow" function — which is exactly the fake-iridescence + * habit this material exists to avoid. + */ +const FILM_GLSL = /* glsl */ ` + #define FILM_SAMPLES ${FILM_SAMPLES} + #define FILM_LAMBDA_MIN 400.0 + #define FILM_LAMBDA_SPAN 300.0 + + /* A Gaussian with a different width either side of its peak. */ + float lobe(float w, float mu, float sLow, float sHigh) { + float s = w < mu ? sLow : sHigh; + float t = (w - mu) / max(s, 1e-3); + return exp(-0.5 * t * t); + } + + /* + * Multi-lobe piecewise-Gaussian fit of the CIE 1931 2-degree observer. + * Three lobes for x-bar (one of them negative, which is real — x-bar dips + * below zero around 500 nm and dropping that lobe warms every cyan band by a + * visible amount), two each for y-bar and z-bar. + */ + vec3 cieFit(float w) { + float cx = 1.056 * lobe(w, 599.8, 37.9, 31.0) + + 0.362 * lobe(w, 442.0, 16.0, 26.7) + - 0.065 * lobe(w, 501.1, 20.4, 26.2); + float cy = 0.821 * lobe(w, 568.8, 46.9, 40.5) + + 0.286 * lobe(w, 530.9, 16.3, 31.1); + float cz = 1.217 * lobe(w, 437.0, 11.8, 36.0) + + 0.681 * lobe(w, 459.0, 26.0, 13.8); + return vec3(cx, cy, cz); + } + + /* CIE XYZ (D65) to linear sRGB. Clamped by the caller, not here. */ + vec3 xyzToLinear(vec3 c) { + return vec3( + 3.2406 * c.x - 1.5372 * c.y - 0.4986 * c.z, + -0.9689 * c.x + 1.8758 * c.y + 0.0415 * c.z, + 0.0557 * c.x - 0.2040 * c.y + 1.0570 * c.z + ); + } + + /* + * The interference colour of a film d nanometres thick, seen at cosI against + * its normal, with refractive index ior. + * + * Returns linear sRGB normalised so a perfectly reflecting film integrates to + * roughly unit luminance — i.e. the *hue and saturation* of the reflection. + * How much light there is at all is the caller's fresnel term, deliberately + * kept separate so nobody can quietly turn this back into a fresnel ramp. + */ + vec3 filmColour(float d, float cosI, float ior) { + float n = max(ior, 1.001); + float sin2 = max(0.0, 1.0 - cosI * cosI); + /* Snell, inside the film. Grazing rays travel further through it, which is + why the bands crowd toward the silhouette of a sphere. */ + float cosT = sqrt(max(0.0, 1.0 - sin2 / (n * n))); + float opd = 2.0 * n * d * cosT; // nanometres + + vec3 xyz = vec3(0.0); + float weight = 0.0; + for (int i = 0; i < FILM_SAMPLES; i++) { + float lambda = FILM_LAMBDA_MIN + FILM_LAMBDA_SPAN * (float(i) + 0.5) / float(FILM_SAMPLES); + /* The half-wave from the hard front reflection is folded into the sign: + I = 0.5*(1 + cos(2*pi*opd/lambda + pi)). */ + float intensity = 0.5 - 0.5 * cos(6.283185307179586 * opd / lambda); + vec3 bar = cieFit(lambda); + xyz += bar * intensity; + weight += bar.y; + } + xyz /= max(weight, 1e-4); + return max(xyzToLinear(xyz), vec3(0.0)); + } +`; + +/* ---------------------------------------------------------------------- */ +/* The vertex stage — every bubble placed from uniforms */ +/* ---------------------------------------------------------------------- */ +/** + * No `instanceMatrix`, and that is the invariant. + * + * The obvious build is an `InstancedMesh` whose matrices the ability writes + * each frame; it works, and it fails the moment somebody pauses and drags + * `cageRadius`, because the matrix was composed from the old metres one frame + * ago and nothing recomputes it on a zero-length frame unless the ability + * remembers to. Here the *only* per-instance data is four unitless dice rolls — + * a direction on the cage, a size fraction, a decorrelation seed and a pop roll + * — and every metre and second comes out of a uniform. A paused cage genuinely + * re-inflates under `bubbleRadius`. + * + * `aDir` is a unit vector rather than an angle pair because the Fibonacci + * lattice the ability plants on is naturally Cartesian, and because a pair of + * angles has a pole where the bubbles pile up. + */ +const FILM_VERTEX = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + uniform float uAge; // seconds since the cage was raised + uniform vec3 uCentre; // cage centre, world metres + uniform float uSeed; // per-cast dice roll, unitless + + /* --- the cage --- */ + uniform float uCageRadius; // metres from the centre to a bubble's centre + uniform float uCageSquash; // vertical scale on the cage sphere, unitless + uniform float uBreathe; // radial pulse, fraction of uCageRadius + uniform float uBreatheSpeed; // Hz + uniform float uJostle; // tangential slop, metres + uniform float uJostleSpeed; // Hz + + /* --- one bubble --- */ + uniform float uBubbleRadius; // metres, before aScale + uniform float uStagger; // seconds between the first bubble and the last + uniform float uInflate; // seconds one bubble takes to reach full size + uniform float uOvershoot; // how far past full size it goes on the way + uniform float uSwell; // extra radius as the film gives out, fraction + + /* --- the rupture --- */ + uniform float uPopAt; // seconds after birth the film gives way + uniform float uPopSpread; // seconds of scatter on that, × aPop + uniform float uBurstTime; // seconds the hole takes to eat the bubble + uniform float uRuptureJitter; // radians the rupture wanders off the crown + + attribute vec3 aDir; // unit direction on the cage — a dice roll + attribute float aSeed; // 0..1 decorrelation + attribute float aScale; // 0..2 size fraction + attribute float aPop; // 0..1 where in uPopSpread this one sits + + varying vec3 vNormalW; + varying vec3 vViewW; + varying vec3 vLocalN; // the point on the film, in the bubble's frame + varying vec3 vRupture; // unit direction of the rupture, same frame + varying float vSeed; + varying float vLife; // 0 at birth, 1 at the pop + varying float vBurst; // 0 before the pop, then 0..1 through it + varying float vBorn; // 0..1 inflation + varying float vViewZ; + + ${noiseGLSL} + + void main() { + vec3 dir = normalize(aDir); + + /* --- this bubble's own clock --- */ + float local = uAge - aSeed * max(uStagger, 0.0); + float born = clamp(local / max(uInflate, 0.02), 0.0, 1.0); + float popAt = max(uPopAt + aPop * uPopSpread, 0.05); + vLife = clamp(local / popAt, 0.0, 1.0); + vBurst = clamp((local - popAt) / max(uBurstTime, 0.01), 0.0, 1.0); + vBorn = born; + + /* Inflation with a real overshoot: a bubble blown off a wand snaps past its + resting size and settles back. Easing straight in reads as a balloon on a + pump, which is the wrong kind of slow. */ + float grow = smoothstep(0.0, 1.0, born); + grow *= 1.0 + uOvershoot * sin(born * PI) * (1.0 - born); + grow *= 1.0 + uSwell * smoothstep(0.62, 1.0, vLife); + + /* --- where the bubble sits on the cage --- */ + float breathe = 1.0 + uBreathe * sin(uAge * TAU * uBreatheSpeed + aSeed * TAU + uSeed); + vec3 wob = vec3( + snoise(dir * 1.7 + vec3(uAge * uJostleSpeed, aSeed * 9.1, uSeed)), + snoise(dir * 1.7 + vec3(aSeed * 9.1, uAge * uJostleSpeed + 4.0, uSeed)), + snoise(dir * 1.7 + vec3(uSeed, aSeed * 9.1, uAge * uJostleSpeed + 8.0)) + ); + /* Tangential only. A radial component would make the cage boil in and out + and fight the breathe term, which is the one that is supposed to do that. */ + wob -= dir * dot(wob, dir); + + /* The squash is applied to the *offset*, not to the bubble, so the cage + flattens into a dome while every bubble on it stays round. Scaling the + geometry instead gives you a cage of eggs. */ + vec3 offset = vec3(dir.x, dir.y * uCageSquash, dir.z) * (uCageRadius * breathe); + vec3 centre = uCentre + offset + wob * uJostle; + float radius = max(uBubbleRadius, 0.0) * aScale * grow; + + vec3 nLocal = normalize(normal); + vec3 world = centre + nLocal * radius; + + /* --- where the film will give way --- */ + /* The crown, jittered. Drainage makes the top the thinnest part of any + bubble, so that is where it goes; the jitter is what stops thirty bubbles + all bursting from the same pixel of their own north pole. */ + float ja = hash11(aSeed * 31.7 + uSeed) * TAU; + float jb = hash11(aSeed * 13.1 + 5.0) * uRuptureJitter; + vRupture = normalize(vec3(sin(jb) * cos(ja), cos(jb), sin(jb) * sin(ja))); + + vLocalN = nLocal; + vNormalW = nLocal; + vViewW = cameraPosition - world; + vSeed = aSeed; + + vec4 mv = viewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/* ---------------------------------------------------------------------- */ +/* The fragment stage */ +/* ---------------------------------------------------------------------- */ +/** + * Three things happen here and the order matters. + * + * **The hole is cut first.** A ruptured film does not fade; a hole opens at the + * thinnest point and the rim retracts across the bubble in a few milliseconds, + * dragging the film into it. Fading the alpha instead was the first version and + * it reads as thirty bubbles being switched off, which is somehow less + * convincing than one bubble popping. The hole is an angular cap around + * `vRupture` whose radius runs to `π`, so it genuinely eats the whole sphere, + * and the band just outside it carries the collected rim. + * + * **Then the thickness, then the colour.** Thickness is a field over the film — + * a drainage gradient plus marbling — and the interference reads it. Not the + * other way round: an early attempt drove the *hue* from a noise field and + * scaled the thickness to match, which is a rainbow texture wearing a physics + * costume, and it does not do the one thing a real film does, which is march + * its bands in order as the film thins. + * + * **The fresnel is amplitude only.** `filmColour()` is normalised, so it + * carries the hue; Schlick against `uReflect` carries how much of it there is. + * Keeping those two apart in the code is the only reason the trick survives + * being tuned by somebody in a hurry. + */ +const FILM_FRAGMENT = /* glsl */ ` + #define PI 3.141592653589793 + + uniform float uAge; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform vec3 uLightDir; + uniform float uGlobalGlow; + + /* --- the film --- */ + uniform float uThickness; // nanometres at the foot of the bubble + uniform float uIor; // refractive index of the film + uniform float uDrain; // 0..1 how much thinner the crown is + uniform float uDrainSeed; // 0..1 of that drainage present at birth + uniform float uMarble; // 0..1 thickness variation from convection + uniform float uMarbleScale; // marbling features per bubble + uniform float uFlow; // Hz the marbling creeps + uniform float uThinRate; // fraction of thickness lost by the pop + + /* --- how much light comes back --- */ + uniform float uReflect; // normal-incidence reflectance + uniform float uFresnel; // Schlick exponent + uniform float uFilmGain; // master on the interference term + uniform float uFilmSat; // 0 = the film in grey, 1 = as computed + uniform float uRim; // wash on the silhouette + uniform float uRimPower; + uniform float uSheen; // the specular glint + uniform float uSheenSharp; + uniform float uBurstFlash; // brightness of the retracting rim + uniform float uBurstWidth; // radians of that rim + + uniform float uOpacity; + uniform float uGlow; + uniform float uSoftFade; + uniform float uFade; + + uniform vec3 uColorBody; // the water's own tint, under the interference + uniform vec3 uColorBlack; // what a film thinner than a quarter-wave is + uniform vec3 uColorSheen; // the glint + uniform vec3 uColorRim; // the silhouette wash + + varying vec3 vNormalW; + varying vec3 vViewW; + varying vec3 vLocalN; + varying vec3 vRupture; + varying float vSeed; + varying float vLife; + varying float vBurst; + varying float vBorn; + varying float vViewZ; + + ${commonGLSL} + ${noiseGLSL} + ${FILM_GLSL} + + /* + * Film thickness at this point, nanometres. + * + * Gravity drains a real film downward, so the crown is thin and the foot is + * thick, and the gradient steepens as the bubble ages. uThinRate is the whole + * film losing water at once, which is what actually kills it. + */ + float thicknessAt(vec3 nl, float seed, float life) { + float crown = 0.5 + 0.5 * nl.y; + float drain = clamp(uDrain, 0.0, 1.0) * (uDrainSeed + (1.0 - uDrainSeed) * life); + float d = uThickness * (1.0 - drain * crown); + d *= 1.0 - clamp(uThinRate, 0.0, 1.0) * life; + /* Marbling. Sampled on the surface direction, not on world space: the + swirls belong to this bubble and have to ride it, and a world-space field + makes every bubble look cut out of one block of marble. */ + float swirl = fbm3(nl * uMarbleScale + vec3(0.0, -uFlow * uAge, seed * 27.0)); + d *= 1.0 + uMarble * swirl; + return max(d, 0.0); + } + + void main() { + /* --- the hole, cut before anything is shaded --- */ + float away = acos(clamp(dot(vLocalN, vRupture), -1.0, 1.0)); + float hole = PI * pow(clamp(vBurst, 0.0, 1.0), 0.65); + if (away < hole) discard; + float retract = smoothstep(hole + max(uBurstWidth, 1e-3), hole, away) * step(0.001, vBurst); + + vec3 N = normalize(vNormalW); + vec3 V = normalize(vViewW); + /* abs(), because the material is double sided and the far wall of the + bubble is seen from behind. That second wall is not a cheat — it is why a + bubble reads as hollow, and its interference is genuinely a different + colour because the view angle across it is different. */ + float cosI = abs(dot(N, V)); + + float d = thicknessAt(vLocalN, vSeed, vLife); + vec3 hue = filmColour(d, cosI, uIor); + hue = mix(vec3(dot(hue, vec3(0.2126, 0.7152, 0.0722))), hue, clamp(uFilmSat, 0.0, 2.0)); + + /* As the film goes below a quarter-wave the interference term goes to zero + on its own — the black film is not painted on. uColorBlack only decides + what little is left, so the crown can be given a hint of the room instead + of a hard hole. */ + float lum = dot(hue, vec3(0.2126, 0.7152, 0.0722)); + vec3 colour = mix(uColorBlack, hue * uColorBody, clamp(lum * 3.0, 0.0, 1.0)); + + /* Amplitude, and only amplitude. */ + float schlick = uReflect + (1.0 - uReflect) * pow(1.0 - cosI, max(uFresnel, 0.1)); + colour *= schlick * uFilmGain; + + /* The wash that keeps the silhouette legible at twenty metres. */ + float rim = pow(1.0 - cosI, max(uRimPower, 0.1)); + colour += uColorRim * rim * uRim; + + /* One glint. A soap bubble has exactly one bright highlight and a broad + specular lobe turns it into a plastic marble. */ + vec3 H = normalize(normalize(uLightDir) + V); + colour += uColorSheen * pow(max(dot(N, H), 0.0), max(uSheenSharp, 1.0)) * uSheen * schlick; + + /* The retracting rim carries the film it has swallowed. */ + colour += uColorSheen * retract * uBurstFlash; + + float alpha = clamp(schlick * (0.22 + 0.78 * lum) + rim * uRim * 0.4 + retract, 0.0, 1.0); + alpha *= uOpacity * uFade * smoothstep(0.0, 0.35, vBorn); + if (alpha < 0.004) discard; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + colour *= uGlow * uGlobalGlow; + gl_FragColor = vec4(clamp(colour, 0.0, 48.0), alpha); + } +`; + +/* ---------------------------------------------------------------------- */ +/* Geometry */ +/* ---------------------------------------------------------------------- */ +/** + * One sphere, instanced, with four unitless per-instance attributes. + * + * The arrays are allocated once at construction at `capacity`; `plant()` fills + * as many as the cast wants and `geometry.instanceCount` decides how many are + * drawn. Nothing here is touched again per frame — I3. + * + * @param {number} capacity hard ceiling on bubbles + * @param {number} segments longitudinal facets on one bubble + * @param {number} rings latitudinal facets + */ +export function createBubbleGeometry(capacity = 64, segments = 26, rings = 18) { + const source = new SphereGeometry(1, segments, rings); + const geometry = new InstancedBufferGeometry(); + + // Copied rather than shared, so disposing the source here cannot pull the + // buffers out from under the instanced geometry later. + geometry.setIndex(new BufferAttribute(Uint16Array.from(source.getIndex().array), 1)); + for (const name of ['position', 'normal', 'uv']) { + const attribute = source.getAttribute(name); + geometry.setAttribute( + name, + new BufferAttribute(Float32Array.from(attribute.array), attribute.itemSize) + ); + } + source.dispose(); + + geometry.setAttribute('aDir', new InstancedBufferAttribute(new Float32Array(capacity * 3), 3)); + geometry.setAttribute('aSeed', new InstancedBufferAttribute(new Float32Array(capacity), 1)); + geometry.setAttribute('aScale', new InstancedBufferAttribute(new Float32Array(capacity), 1)); + geometry.setAttribute('aPop', new InstancedBufferAttribute(new Float32Array(capacity), 1)); + + geometry.instanceCount = 0; + // The bubbles are placed in the vertex shader, so the mesh's own bounds are + // meaningless and the frustum test would cull the cage the moment the camera + // looked away from the origin. + geometry.boundingSphere = null; + return geometry; +} + +/* ---------------------------------------------------------------------- */ +/* The material */ +/* ---------------------------------------------------------------------- */ +/** + * Additive and double sided, which together are the bubble. + * + * Additive because a film *adds* reflected light to whatever is behind it and + * transmits the rest — a soap bubble never occludes anything. Double sided + * because both walls reflect, and the pair of them adding is what makes a + * sphere of film read as a sphere rather than as a disc. + */ +export function createThinFilmMaterial() { + const material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uAge: { value: 0 }, + uCentre: { value: new Vector3() }, + uSeed: { value: 0 }, + uFade: { value: 1 }, + + uCageRadius: { value: 3.2 }, + uCageSquash: { value: 0.72 }, + uBreathe: { value: 0.05 }, + uBreatheSpeed: { value: 0.35 }, + uJostle: { value: 0.16 }, + uJostleSpeed: { value: 0.4 }, + + uBubbleRadius: { value: 0.9 }, + uStagger: { value: 0.5 }, + uInflate: { value: 0.35 }, + uOvershoot: { value: 0.22 }, + uSwell: { value: 0.1 }, + + uPopAt: { value: 2.1 }, + uPopSpread: { value: 1.7 }, + uBurstTime: { value: 0.16 }, + uRuptureJitter: { value: 0.7 }, + + uThickness: { value: 420 }, + uIor: { value: 1.34 }, + uDrain: { value: 0.55 }, + uDrainSeed: { value: 0.3 }, + uMarble: { value: 0.3 }, + uMarbleScale: { value: 2.1 }, + uFlow: { value: 0.25 }, + uThinRate: { value: 0.72 }, + + uReflect: { value: 0.06 }, + uFresnel: { value: 3.4 }, + uFilmGain: { value: 9 }, + uFilmSat: { value: 1 }, + uRim: { value: 0.35 }, + uRimPower: { value: 3 }, + uSheen: { value: 1.4 }, + uSheenSharp: { value: 90 }, + uBurstFlash: { value: 1.6 }, + uBurstWidth: { value: 0.12 }, + + uOpacity: { value: 1 }, + uGlow: { value: 1.5 }, + uSoftFade: { value: 0.35 }, + + uColorBody: { value: new Color('#bfe8ff') }, + uColorBlack: { value: new Color('#0a1420') }, + uColorSheen: { value: new Color('#ffffff') }, + uColorRim: { value: new Color('#4fd8d0') } + }), + vertexShader: FILM_VERTEX, + fragmentShader: FILM_FRAGMENT + }); + + /** + * Push the live settings and the cast's unitless beats into the uniforms. + * + * Called every frame, zero-length frames included. `state` carries a world + * point, an age in seconds and two 0..1 beats; every nanometre, metre and + * second below is read out of `settings.bubblecage` right here, which is what + * makes a paused cage answer `filmThickness`. + * + * @param {object} state { centre, age, seed, fade, collapse } + */ + material.userData.sync = (state) => { + const c = settings.bubblecage; + const g = settings.global; + const u = material.uniforms; + + u.uCentre.value.copy(state.centre); + u.uAge.value = state.age; + u.uSeed.value = state.seed; + u.uFade.value = state.fade; + + u.uCageRadius.value = c.zoneRadius * c.cageRadius; + u.uCageSquash.value = c.cageSquash; + u.uBreathe.value = c.cageBreathe; + u.uBreatheSpeed.value = c.cageBreatheSpeed * g.noiseSpeed; + u.uJostle.value = c.cageJostle * g.randomness; + u.uJostleSpeed.value = c.cageJostleSpeed * g.noiseSpeed; + + u.uBubbleRadius.value = c.zoneRadius * c.bubbleRadius; + u.uStagger.value = c.bubbleStagger; + u.uInflate.value = c.bubbleInflate; + u.uOvershoot.value = c.bubbleOvershoot; + u.uSwell.value = c.bubbleSwell; + + // The collapse beat pulls every remaining bubble's deadline toward now, so + // the fade phase *is* the cage giving out rather than a dissolve over it. + u.uPopAt.value = Math.max(0.05, c.popTime * (1 - c.popCollapse * state.collapse)); + u.uPopSpread.value = c.popSpread * (1 - state.collapse); + u.uBurstTime.value = c.popBurstTime; + u.uRuptureJitter.value = c.popJitter; + + u.uThickness.value = c.filmThickness; + u.uIor.value = c.filmIor; + u.uDrain.value = c.filmDrain; + u.uDrainSeed.value = c.filmDrainSeed; + u.uMarble.value = c.filmMarble * g.noiseStrength; + u.uMarbleScale.value = c.filmMarbleScale * g.noiseFrequency; + u.uFlow.value = c.filmFlow * g.noiseSpeed; + u.uThinRate.value = c.filmThinRate; + + u.uReflect.value = c.filmReflect; + u.uFresnel.value = c.filmFresnel * g.fresnel; + u.uFilmGain.value = c.filmGain * g.shaderIntensity; + u.uFilmSat.value = c.filmSaturation; + u.uRim.value = c.filmRim; + u.uRimPower.value = c.filmRimPower; + u.uSheen.value = c.filmSheen; + u.uSheenSharp.value = c.filmSheenSharp; + u.uBurstFlash.value = c.popFlash; + u.uBurstWidth.value = c.popRimWidth; + + u.uOpacity.value = c.filmOpacity * g.opacity; + u.uGlow.value = c.filmGlow * g.glow; + u.uSoftFade.value = c.filmSoftFade; + + u.uColorBody.value.copy(getColor(c.colorFilmBody)); + u.uColorBlack.value.copy(getColor(c.colorFilmBlack)); + u.uColorSheen.value.copy(getColor(c.colorFilmSheen)); + u.uColorRim.value.copy(getColor(c.colorFilmRim)); + }; + + return material; +} diff --git a/src/materials/UmbraMaterial.js b/src/materials/UmbraMaterial.js new file mode 100644 index 0000000..f57b152 --- /dev/null +++ b/src/materials/UmbraMaterial.js @@ -0,0 +1,184 @@ +import { Color, NormalBlending, ShaderMaterial, Vector3 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; + +/** + * UMBRA — the black disc, and the one thing in Eclipse that is not additive. + * + * ## Why this is not a `Shell` + * + * Eclipse's corona *is* a `Shell` in `SUNDISC` mode, and that mode already + * draws a disc with filaments licking off its rim. What it cannot draw is the + * middle, because a `Shell` is additive and **the darkest mark an additive + * material can make is nothing at all**. An eclipse is not a bright thing that + * has been dimmed; it is a hole with a ring of fire round it, and the hole has + * to be able to take light *away* from the floor underneath it. + * + * So the shell paints the corona with `fill`, `granule` and `rim` at zero, and + * this material — `NormalBlending`, black, nearly opaque — paints the umbra + * inside it. Two draw calls, each doing the one thing its blend mode can. + * + * ## Why it is a ground disc rather than a billboard in the sky + * + * The first version hung the disc in the air, camera-facing, at the far end of + * the cast. It reads as a sticker: the stage is a lit floor with a flat dark + * backdrop, so a black circle in front of that backdrop is invisible, and a + * black circle in front of the *floor* is a shape with no relationship to + * anything the player can walk on. On the floor it is unambiguous — the umbra + * has *landed* somewhere, the aim circle measured out where, and everything + * inside it is gone. It is also the only reading under which the corona ring + * and the black middle are coplanar and therefore obviously one object. + * + * ## What it draws + * + * No model matrix: the quad is placed around `uCentre` in the vertex shader + * from `vfx/quads.js`'s unit ground quad, and `vPlane` comes out in **disc + * radii** rather than metres, so every feature below is scale-invariant and a + * dragged `discRadiusEnd` never changes the look of the limb, only its size. + * + * - **the disc** — `uColorUmbra` at `uShade`, cut off with a soft limb; + * - **the limb ring** — a thin bright band exactly at `r = 1`, which is what + * stops the black disc reading as a hole in the render rather than as an + * object; + * - **Baily's beads** — hashed bright spots pinned to the limb. + * + * ### The beads are allowed to be sampled on the angle + * + * `vfx/Shell.js` warns at length that sampling a corona on `atan(y, x)` gives + * every radius along a bearing the same value and draws dead-straight spokes. + * That is exactly right for a corona, and exactly wrong to apply here: a bead + * genuinely *is* an angular feature pinned to one radius — it is a piece of + * sunlight coming through a valley on the limb — so the angle is the correct + * domain and the radial Gaussian is what keeps it from becoming a spoke. Beads + * hashed in the plane instead were tried and drift off the limb the moment the + * disc grows, which is the wrong physics and looks like dirt on the lens. + */ + +const UMBRA_VERTEX = /* glsl */ ` + uniform vec3 uCentre; + uniform float uRadius; + uniform float uReach; + + varying vec2 vPlane; + + void main() { + // The shared ground quad is 1 x 1 in XZ. Measured in DISC RADII, so r = 1.0 + // is the limb wherever the radius slider puts it. + vec2 q = vec2(position.x, position.z) * 2.0 * uReach; + vPlane = q; + + vec3 here = uCentre + vec3(q.x, 0.0, q.y) * uRadius; + gl_Position = projectionMatrix * viewMatrix * vec4(here, 1.0); + } +`; + +const UMBRA_FRAGMENT = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform float uTime; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + + uniform float uSeed; + uniform float uFade; + uniform float uEdge; + uniform float uShade; + uniform float uOpacity; + + uniform float uRim; + uniform float uRimWidth; + + uniform float uBead; + uniform float uBeadCount; + uniform float uBeadSize; + uniform float uBeadWidth; + uniform float uBeadSpin; + + uniform vec3 uColorUmbra; + uniform vec3 uColorLimb; + uniform vec3 uColorBead; + + varying vec2 vPlane; + + ${noiseGLSL} + + void main() { + float r = length(vPlane); + + /* --- the disc --- */ + float soft = clamp(uEdge, 0.002, 0.9); + float body = 1.0 - smoothstep(1.0 - soft, 1.0, r); + + /* --- the limb ring, a band centred exactly on r = 1 --- */ + float band = max(uRimWidth, 1e-3); + float ring = exp(-pow((r - 1.0) / band, 2.0)); + + /* --- Baily's beads --- */ + float turn = atan(vPlane.y, vPlane.x) / TAU + 0.5 + uTime * uBeadSpin; + float count = max(floor(uBeadCount + 0.5), 1.0); + float cell = turn * count; + float index = floor(cell); + float within = cell - index; + // Two hashes off the cell index: where in its cell the bead sits, and how + // bright it is. Evenly spaced beads of equal brightness read as a dial. + float place = 0.5 + (hash11(index + uSeed) - 0.5) * 0.6; + float bright = mix(0.3, 1.0, hash11(index + 31.7 + uSeed)); + float da = abs(within - place); + float bead = exp(-pow(da / max(uBeadSize, 1e-3), 2.0)) * bright; + bead *= exp(-pow((r - 1.0) / max(uBeadWidth, 1e-3), 2.0)); + + float lit = ring * uRim + bead * uBead; + + // uShade is how much of the picker survives: 1 keeps colorUmbra's faint + // violet, 0 crushes the disc to absolute black. The bright marks are gained + // by the global glow; the body deliberately is not, because a black that + // brightens with the bloom slider is not a black. + vec3 color = uColorUmbra * clamp(uShade, 0.0, 1.0) * body; + float gain = uGlobalGlow * mix(0.7, 1.0, uShaderIntensity); + color += (uColorLimb * ring * uRim + uColorBead * bead * uBead) * gain; + + // Normal blending, so alpha is coverage: the middle is opaque black and + // genuinely removes the floor, and the bright marks sit on top of it. + float alpha = clamp(body * uOpacity + lit * 0.85, 0.0, 1.0) * uFade; + if (alpha < 0.003) discard; + + gl_FragColor = vec4(clamp(color, 0.0, 64.0), alpha); + } +`; + +/** One umbra. Uniforms are pushed by the ability every frame. */ +export function createUmbraMaterial() { + return new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: NormalBlending, + toneMapped: false, + uniforms: sharedUniforms({ + uCentre: { value: new Vector3() }, + uRadius: { value: 1 }, + uReach: { value: 1.4 }, + uSeed: { value: 0 }, + uFade: { value: 0 }, + + uEdge: { value: 0.05 }, + uShade: { value: 1 }, + uOpacity: { value: 0.96 }, + + uRim: { value: 1.5 }, + uRimWidth: { value: 0.09 }, + + uBead: { value: 0 }, + uBeadCount: { value: 9 }, + uBeadSize: { value: 0.13 }, + uBeadWidth: { value: 0.07 }, + uBeadSpin: { value: 0.1 }, + + uColorUmbra: { value: new Color(0.02, 0.015, 0.04) }, + uColorLimb: { value: new Color(0.85, 0.78, 1) }, + uColorBead: { value: new Color(1, 0.96, 0.88) } + }), + vertexShader: UMBRA_VERTEX, + fragmentShader: UMBRA_FRAGMENT + }); +} diff --git a/src/materials/VineBarkMaterial.js b/src/materials/VineBarkMaterial.js new file mode 100644 index 0000000..d16ec1c --- /dev/null +++ b/src/materials/VineBarkMaterial.js @@ -0,0 +1,260 @@ +import { MeshStandardMaterial, Color, FrontSide } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * Bark. + * + * A `MeshStandardMaterial` rather than a raw `ShaderMaterial`, for the same + * reason `IceMaterial` is one: the vine is a *solid* in a scene with a sun, a + * rim light and an HDR probe, and the single biggest thing separating it from + * the sap column glowing behind it is that the sun actually lands on it and it + * casts a shadow. Everything below is injected on top of that. + * + * ### The surface is authored in stem coordinates, not world space + * + * The mesh this runs on is swept along `Tube#pointAt()` fresh every frame, so + * its vertices are somewhere different on every one of them. Two custom + * attributes carry a coordinate system that is *not*: + * + * | attribute | meaning | + * | --- | --- | + * | `aBarkAlong` | metres from the root, measured along the stem | + * | `aBarkRing` | `(cos θ, sin θ)` of the position around the section | + * + * Both are material coordinates. Bark does not slide when the vine recoils and + * grain does not swim when the axis drifts, and neither does anything here. + * + * The first version sampled `ridged(worldPosition * scale)` — the obvious + * thing, and it gives a rock. Three-dimensional noise has no preferred + * direction and bark is nothing *but* direction: what turns the mottle into + * bark is sampling with the around-frequency an order above the along-frequency + * (`uRidgeBands` ≈ 3.6 against `uRidgeScale` ≈ 0.55/m), so the field is nearly + * constant along the stem and the ridges run its length. + * + * Passing `(cos θ, sin θ)` rather than θ itself is the other half of that. A + * raw angle wraps from 1 back to 0 across the last quad of the ring, the noise + * lookup sweeps backwards over that quad, and every vine had one bright seam + * down its side. Interpolating the cosine and sine instead shortens the chord + * by a fraction of a percent and has no seam at all. + * + * ### The wet highlight + * + * `uSheen` is keyed off the **world** normal's `y`, not off a fresnel term: the + * brief is a wet highlight along *the top of the curve*, and a fresnel rim puts + * it on the silhouette instead, which reads as a glass tube. `vBarkNormalW` is + * carried from the vertex stage for exactly that, and `uSheenPower` decides how + * narrow the band on top is. A crawling speck field (`uGlisten`) breaks it up, + * because an unbroken specular stripe reads as plastic. + * + * Uniform boxes are parked on `material.userData.uniforms` — the + * `IceMaterial` / `MeteorMaterial` convention, and the thing the harness's + * pause test looks for. Without it thirty working sliders report as dead. + */ +export function createVineBarkMaterial(environment) { + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.72, + metalness: 0.0, + // Smooth shading: a vine is a swept curve, and `flatShading` would facet + // the ten-sided section into something that reads as a pencil. It also + // means `vNormal` exists, which is what the roughness injection needs. + flatShading: false, + // Single-sided and depth-writing. The bark is opaque even while its master + // alpha is winding down, and it has to occlude the additive sap tube drawn + // in the same place — that occlusion is the entire reason the sap reads as + // *inside* the stem rather than painted over it. + side: FrontSide, + transparent: true, + depthWrite: true + }); + + const uniforms = { + uTime: frame.uTime, + uColorBark: { value: new Color() }, + uColorDeep: { value: new Color() }, + uColorSap: { value: new Color() }, + uColorSheen: { value: new Color() }, + uColorWither: { value: new Color() }, + uRidge: { value: 0.75 }, + uRidgeScale: { value: 0.55 }, + uRidgeBands: { value: 3.6 }, + uGrain: { value: 0.32 }, + uGrainScale: { value: 9.0 }, + uGrainBands: { value: 7.0 }, + uDepth: { value: 0.85 }, + uRough: { value: 0.72 }, + uRoughWet: { value: 0.24 }, + uSapGlow: { value: 1.5 }, + uSapPulse: { value: 0.55 }, + uSapPulseScale: { value: 1.4 }, + uSapPulseSpeed: { value: 2.4 }, + uSheen: { value: 1.6 }, + uSheenPower: { value: 5.5 }, + uGlisten: { value: 0.9 }, + uGlistenScale: { value: 26.0 }, + uGlistenSpeed: { value: 0.7 }, + uWither: { value: 0 } + }; + + environment.registerShadowCasterWithPatch(material, (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + `#include + attribute float aBarkAlong; + attribute vec2 aBarkRing; + varying float vBarkAlong; + varying vec2 vBarkRing; + varying vec3 vBarkNormalW;` + ) + // After so `objectNormal` exists; the vine's group + // is parked at the origin with matrixAutoUpdate off, so modelMatrix is + // the identity — this is written the long way anyway, because a mesh that + // silently depends on its parent being untransformed is a landmine. + .replace( + '#include ', + `#include + vBarkAlong = aBarkAlong; + vBarkRing = aBarkRing; + vBarkNormalW = normalize(mat3(modelMatrix) * objectNormal);` + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform float uTime; + uniform vec3 uColorBark; + uniform vec3 uColorDeep; + uniform vec3 uColorSap; + uniform vec3 uColorSheen; + uniform vec3 uColorWither; + uniform float uRidge; + uniform float uRidgeScale; + uniform float uRidgeBands; + uniform float uGrain; + uniform float uGrainScale; + uniform float uGrainBands; + uniform float uDepth; + uniform float uRough; + uniform float uRoughWet; + uniform float uSapGlow; + uniform float uSapPulse; + uniform float uSapPulseScale; + uniform float uSapPulseSpeed; + uniform float uSheen; + uniform float uSheenPower; + uniform float uGlisten; + uniform float uGlistenScale; + uniform float uGlistenSpeed; + uniform float uWither; + varying float vBarkAlong; + varying vec2 vBarkRing; + varying vec3 vBarkNormalW; + ${noiseGLSL} + + /* 0 on a ridge, 1 at the bottom of a groove. The one field the whole + material is built out of, so it is computed once and shared. */ + float barkGroove() { + float r = ridged(vec3(vBarkRing * uRidgeBands, vBarkAlong * uRidgeScale), 4); + return clamp(smoothstep(0.42, 0.92, r), 0.0, 1.0); + }` + ) + // Grooves hold water and shed light differently from the ridges between + // them, so roughness varies with the same field the colour does. Injected + // here because `roughnessFactor` does not exist before this line. + .replace( + '#include ', + `#include + roughnessFactor = mix(uRough, uRoughWet, barkGroove());` + ) + .replace( + '#include ', + `#include + { + float groove = barkGroove(); + + // Fibrous grain on top of the grooves: the same trick at a higher + // frequency, and the reason a close-up does not read as corduroy. + float grain = fbm3(vec3(vBarkRing * uGrainBands, vBarkAlong * uGrainScale)) * 0.5 + 0.5; + + vec3 body = mix(uColorBark, uColorDeep, groove * uDepth); + body = mix(body, uColorDeep, grain * uGrain * 0.5); + body = mix(body, uColorWither, clamp(uWither, 0.0, 1.0)); + diffuseColor.rgb *= body; + + // Sap in the grooves, pulsing up the stem. A travelling wave rather + // than a global throb: the first version pulsed the whole stem at + // once and it read as a heartbeat in a rubber hose, where what a + // growing thing does is push a bolus of sap toward the tip. + float wave = 0.5 + 0.5 * sin((vBarkAlong * uSapPulseScale - + uTime * uSapPulseSpeed * uSapPulseScale) * PI2); + float sap = groove * mix(1.0, wave, clamp(uSapPulse, 0.0, 1.0)); + + // The wet highlight: the top of the curve, not the silhouette. + float up = clamp(vBarkNormalW.y, 0.0, 1.0); + float wet = pow(up, max(uSheenPower, 0.05)); + float speck = snoise(vec3(vBarkRing * uGlistenScale, + vBarkAlong * uGlistenScale - uTime * uGlistenSpeed * uGlistenScale)); + wet *= mix(1.0, smoothstep(0.15, 0.85, speck * 0.5 + 0.5), clamp(uGlisten, 0.0, 1.0)); + + vec3 glow = uColorSap * sap * uSapGlow; + glow += uColorSheen * wet * uSheen; + // Withered bark is dead wood: it stops glowing before it stops being + // there, which is what makes the fade read as dying rather than as + // an opacity ramp. + glow *= 1.0 - clamp(uWither, 0.0, 1.0); + // Soft ceiling, as in IceMaterial: the sap and the sheen peak in + // different places but they do overlap on a wet ridge, and without + // this the overlap sums past 4 and bloom smears the stem into a bar. + glow /= 1.0 + glow * 0.3; + + totalEmissiveRadiance += glow; + }` + ); + }); + + material.userData.uniforms = uniforms; + + /** Pull the palette and every shading control from the live settings. */ + material.userData.sync = () => { + const c = settings.vinelash; + const g = settings.global; + + uniforms.uColorBark.value.copy(getColor(c.colorBark)); + uniforms.uColorDeep.value.copy(getColor(c.colorBarkDeep)); + uniforms.uColorSap.value.copy(getColor(c.colorSap)); + uniforms.uColorSheen.value.copy(getColor(c.colorSheen)); + uniforms.uColorWither.value.copy(getColor(c.colorWither)); + + uniforms.uRidge.value = c.barkRidge * g.shaderIntensity; + uniforms.uRidgeScale.value = c.barkRidgeScale * g.noiseFrequency; + uniforms.uRidgeBands.value = c.barkRidgeBands * g.noiseFrequency; + uniforms.uGrain.value = c.barkGrain * g.shaderIntensity; + uniforms.uGrainScale.value = c.barkGrainScale * g.noiseFrequency; + uniforms.uGrainBands.value = c.barkGrainBands * g.noiseFrequency; + uniforms.uDepth.value = c.barkDepth * c.barkRidge; + uniforms.uRough.value = c.barkRoughness; + uniforms.uRoughWet.value = c.barkRoughnessWet; + uniforms.uSapGlow.value = c.sapGlow * g.glow; + uniforms.uSapPulse.value = c.sapPulse; + uniforms.uSapPulseScale.value = c.sapPulseScale; + uniforms.uSapPulseSpeed.value = c.sapPulseSpeed * g.noiseSpeed; + uniforms.uSheen.value = c.sheen * g.shaderIntensity; + uniforms.uSheenPower.value = c.sheenPower; + uniforms.uGlisten.value = c.glisten; + uniforms.uGlistenScale.value = c.glistenScale * g.noiseFrequency; + uniforms.uGlistenSpeed.value = c.glistenSpeed * g.noiseSpeed; + + material.roughness = c.barkRoughness; + material.envMapIntensity = c.barkEnv; + }; + + material.userData.sync(); + return material; +} diff --git a/src/materials/VolleyMaterial.js b/src/materials/VolleyMaterial.js new file mode 100644 index 0000000..518b971 --- /dev/null +++ b/src/materials/VolleyMaterial.js @@ -0,0 +1,583 @@ +import { AdditiveBlending, Color, DoubleSide, ShaderMaterial, Vector2, Vector3 } from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; + +/** + * Arcane Volley's two draw calls: the trails, and the heads that draw them. + * + * Both are instanced once per bolt and both evaluate **the same parametric + * flight**, which is the only reason a trail is possible at all. A recorded + * trail is a ring buffer of metres, and a ring buffer of metres cannot be + * re-shaped by a slider — drag `weaveSide` on a recorded trail and the history + * behind the bolt stays where it was flown, which looks exactly like a bug. So + * nothing is recorded: the trail's vertex shader samples the flight *backwards + * in the bolt's own clock* and gets a ribbon that re-flies itself, live, on a + * zero-length frame. + * + * `vfx/Projectile` does this too, and does it well — but its Lissajous mode + * carries **one** pair of weave frequencies for the whole volley, with the + * bolts separated only by phase and sign. Seven congruent curves at seven + * phases is a braid, and a braid is uniform. The table below is the difference: + * a per-bolt frequency ratio, so bolt 3 traces a 3:2 figure while bolt 5 traces + * a 1:3, and the crossings stop being periodic. + */ + +/** Hard ceiling on bolts. `bolts` clamps here and the geometry is built for it. */ +export const MAX_BOLTS = 8; + +/** + * The small table. + * + * Lateral : vertical cycles over the flight, per bolt. Unitless — a live + * `weaveTurns` slider multiplies the pair, so the whole volley speeds up or + * slows down together while the *ratios* stay what makes each bolt distinct. + * + * They are near-coprime on purpose. A 2:4 bolt is a 1:2 bolt drawn twice as + * fast and reads as the same figure; 1:2, 2:3, 3:2, 2:1, 3:4, 1:3, 4:3, 5:3 all + * close at different points in the flight, so the seven cross each other at + * irregular intervals instead of pulsing in unison. The first version used + * `i + 1 : i + 2` and the volley breathed — every bolt hit its widest point at + * the same instant, which is the one thing a weave must not do. + */ +const RATIOS = [ + [1, 2], + [2, 3], + [3, 2], + [2, 1], + [3, 4], + [1, 3], + [4, 3], + [5, 3] +]; + +/** + * The flight, shared verbatim by both shaders. + * + * `q` is the bolt's whole life in one number: + * + * | q | where the bolt is | + * | --- | --- | + * | `-1` | the instant the charge began, circling the hand at `uOrbitStart` | + * | `0` | release — still at the hand, at full weave amplitude | + * | `0..1` | in flight | + * | `1` | at the target, **exactly**, whatever its ratio or phase | + * + * The wind-up and the flight are not two behaviours. During the charge the + * bolt's weave phase is *rotated* and its τ is pinned at zero, so it traces the + * τ = 0 slice of its own Lissajous figure — a small loop around the hand — and + * at q = 0 the rotation stops and the same offsets simply start being carried + * downrange. The two beats are continuous by construction, which is what lets + * one trail run through the release without a seam. + * + * The convergence is the decay: the lateral and vertical offsets are multiplied + * by `(1 − τ)^weaveDecay`, which is *identically zero* at τ = 1. Nothing steers + * and nothing is corrected. Seven bolts on seven different figures arrive at one + * point on one frame because the algebra leaves them nowhere else to be. + */ +const VOLLEY_PATH = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + #define MAX_BOLTS ${MAX_BOLTS} + + uniform vec3 uHand; + uniform vec3 uTarget; + uniform vec3 uSide; + uniform float uSeed; + uniform float uCount; + + uniform float uWeaveSide; + uniform float uWeaveUp; + uniform float uWeaveTurns; + uniform float uWeavePhase; + uniform float uWeaveDecay; + uniform float uPathCurve; + uniform float uOrbitTurns; + uniform float uOrbitStart; + uniform float uChargeCurve; + uniform vec2 uRatio[MAX_BOLTS]; + + /** The perpendicular frame the weave lives in. Gram-Schmidt, as the bolt does. */ + void volleyFrame(out vec3 dir, out vec3 n1, out vec3 n2) { + vec3 delta = uTarget - uHand; + float span = max(length(delta), 0.01); + dir = delta / span; + n1 = uSide - dir * dot(uSide, dir); + n1 = length(n1) > 1e-4 ? normalize(n1) : normalize(cross(dir, vec3(0.0, 1.0, 0.0))); + n2 = normalize(cross(dir, n1)); + } + + /** This bolt's frequency pair, fetched the only way ANGLE allows. */ + vec2 boltRatio(float bolt) { + vec2 r = uRatio[0]; + for (int i = 0; i < MAX_BOLTS; i++) { + if (float(i) == bolt) r = uRatio[i]; + } + return r; + } + + /** Where bolt number 'bolt' is at 'q'. See the header for what q means. */ + vec3 boltPoint(float bolt, float q, vec3 n1, vec3 n2) { + float tau = clamp(q, 0.0, 1.0); + float wind = clamp(q, -1.0, 0.0); + + vec2 ratio = boltRatio(bolt); + float phase = (bolt / max(uCount, 1.0)) * TAU + uSeed; + // Rotating while it is held, frozen the moment it leaves. + phase += wind * uOrbitTurns * TAU; + + // The ring opens from uOrbitStart to full amplitude over the wind-up, and + // is exactly 1 at release, so the flight inherits the offset it had. + float grow = pow(clamp(1.0 + wind, 0.0, 1.0), max(uChargeCurve, 0.05)); + float gate = mix(uOrbitStart, 1.0, grow); + float decay = pow(1.0 - tau, max(uWeaveDecay, 0.01)) * gate; + + vec3 base = mix(uHand, uTarget, pow(tau, max(uPathCurve, 0.05))); + base += n1 * (uWeaveSide * decay * sin(TAU * ratio.x * uWeaveTurns * tau + phase)); + base += n2 * (uWeaveUp * decay * sin(TAU * ratio.y * uWeaveTurns * tau + phase + uWeavePhase)); + return base; + } +`; + +/* ------------------------------------------------------------------ */ +/* 1 · The trails — the main read */ +/* ------------------------------------------------------------------ */ + +/** + * One instanced ribbon per bolt, sampled backwards along its own flight. + * + * The head's `q` and the tail's `q` both arrive as uniforms — the ability turns + * `trailSpan` seconds into a Δq using the live flight time, and turns the + * post-arrival catch-up into a tail that walks *forward* to meet a head that has + * stopped. A trail that dims reads as a light going out; one that shortens + * reads as something that has stopped being made, which is what a spent bolt is. + */ +const TRAIL_VERTEX = /* glsl */ ` + ${VOLLEY_PATH} + + uniform float uTime; + uniform float uQHead; + uniform float uQTail; + uniform float uWidth; + uniform float uTaper; + uniform float uFade; + uniform float uFlicker; + uniform float uFlickerSpeed; + + attribute float aStrand; + + varying float vV; + varying float vSide; + varying float vFlash; + varying float vViewZ; + + ${noiseGLSL} + + void main() { + float v = position.x; + vV = v; + vSide = position.y; + + vec3 dir, n1, n2; + volleyFrame(dir, n1, n2); + + float q = mix(uQTail, uQHead, v); + vec3 here = boltPoint(aStrand, q, n1, n2); + + // The tangent is taken along the *flight*, not along the ribbon's own + // parameter, so a ribbon whose ends coincide (a bolt that has stopped) + // still has a direction to face the camera with. + float dq = max(abs(uQHead - uQTail), 1e-3) * 0.05; + float flip = 1.0; + float other = q + dq; + if (other > 1.0) { other = q - dq; flip = -1.0; } + vec3 next = boltPoint(aStrand, other, n1, n2); + vec3 tangent = (next - here) * flip; + tangent = length(tangent) > 1e-5 ? normalize(tangent) : dir; + + vec3 toCamera = normalize(cameraPosition - here); + vec3 binormal = cross(tangent, toCamera); + float bl = length(binormal); + binormal = bl > 1e-4 ? binormal / bl : n1; + + float flash = mix(1.0, hash11(floor(uTime * uFlickerSpeed) + aStrand * 5.1 + uSeed), uFlicker); + vFlash = flash; + + // Tapered to a point at the tail. The head keeps its width so the ribbon + // reads as *emitted* rather than as a spindle. + float halfWidth = uWidth * pow(clamp(v, 0.0, 1.0), max(uTaper, 0.05)) * flash * uFade; + + vec4 mv = viewMatrix * vec4(here + binormal * vSide * halfWidth, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const TRAIL_FRAGMENT = /* glsl */ ` + uniform float uCoreSharp; + uniform float uCoreWidth; + uniform float uHaloFalloff; + uniform float uHaloOpacity; + uniform float uOpacity; + uniform float uGlow; + uniform float uFade; + uniform float uSoftFade; + uniform vec3 uColorA; + uniform vec3 uColorB; + uniform vec3 uColorC; + uniform vec3 uColorD; + + uniform float uGlobalGlow; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + varying float vV; + varying float vSide; + varying float vFlash; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + float v = clamp(abs(vSide), 0.0, 1.0); + + // One pass carrying both profiles: additive blending is a sum, so a halo + // pass and a core pass over the same ribbon are the same image as adding + // them here — for one draw call instead of two. + float core = pow(clamp(1.0 - v / max(uCoreWidth, 1e-3), 0.0, 1.0), max(uCoreSharp, 0.05)); + float halo = pow(1.0 - v, max(uHaloFalloff, 0.05)) * uHaloOpacity; + + // Four stops from the tail to the head. Sampled along the ribbon rather + // than over a particle's life, because the ribbon *is* the history. + vec3 tint = gradient4(uColorA, uColorB, uColorC, uColorD, clamp(vV, 0.0, 1.0)); + + float ends = smoothstep(0.0, 0.12, vV); + float alpha = (core + halo) * ends * vFlash * uFade * uOpacity; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.003) discard; + + vec3 color = tint * (core + halo) * uGlow * uGlobalGlow; + gl_FragColor = vec4(color, alpha); + } +`; + +/** The trail pass. One draw call, `bolts` instances. */ +export function createVolleyTrailMaterial() { + const ratios = []; + for (let i = 0; i < MAX_BOLTS; i++) ratios.push(new Vector2(RATIOS[i][0], RATIOS[i][1])); + + const material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uHand: { value: new Vector3() }, + uTarget: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + uSeed: { value: 0 }, + uCount: { value: 7 }, + uRatio: { value: ratios }, + + uWeaveSide: { value: 1.9 }, + uWeaveUp: { value: 1.2 }, + uWeaveTurns: { value: 1.0 }, + uWeavePhase: { value: Math.PI * 0.5 }, + uWeaveDecay: { value: 1.5 }, + uPathCurve: { value: 1.0 }, + uOrbitTurns: { value: 1.2 }, + uOrbitStart: { value: 0.25 }, + uChargeCurve: { value: 0.8 }, + + uQHead: { value: 0 }, + uQTail: { value: 0 }, + uWidth: { value: 0.13 }, + uTaper: { value: 1.5 }, + uFade: { value: 1 }, + uFlicker: { value: 0.1 }, + uFlickerSpeed: { value: 20 }, + + uCoreSharp: { value: 2.4 }, + uCoreWidth: { value: 0.36 }, + uHaloFalloff: { value: 2.4 }, + uHaloOpacity: { value: 0.6 }, + uOpacity: { value: 1 }, + uGlow: { value: 2.0 }, + uSoftFade: { value: 0.5 }, + + uColorA: { value: new Color(0.16, 0.1, 0.29) }, + uColorB: { value: new Color(0.54, 0.37, 0.82) }, + uColorC: { value: new Color(0.88, 0.75, 1) }, + uColorD: { value: new Color(1, 1, 1) } + }), + vertexShader: TRAIL_VERTEX, + fragmentShader: TRAIL_FRAGMENT + }); + + /** + * @param {object} state { hand, target, side, seed, count, qHead, qTail, fade } + */ + material.userData.sync = (state) => { + const c = settings.arcanevolley; + const g = settings.global; + const u = material.uniforms; + + u.uHand.value.copy(state.hand); + u.uTarget.value.copy(state.target); + u.uSide.value.copy(state.side); + u.uSeed.value = state.seed; + u.uCount.value = state.count; + u.uQHead.value = state.qHead; + u.uQTail.value = state.qTail; + u.uFade.value = state.fade; + + u.uWeaveSide.value = c.weaveSide; + u.uWeaveUp.value = c.weaveUp; + u.uWeaveTurns.value = c.weaveTurns; + u.uWeavePhase.value = c.weavePhase; + u.uWeaveDecay.value = c.weaveDecay; + u.uPathCurve.value = c.pathCurve; + u.uOrbitTurns.value = c.orbitTurns; + u.uOrbitStart.value = c.orbitStart; + u.uChargeCurve.value = c.chargeCurve; + + u.uWidth.value = c.trailWidth; + u.uTaper.value = c.trailTaper; + u.uFlicker.value = c.trailFlicker; + u.uFlickerSpeed.value = c.trailFlickerSpeed; + + u.uCoreSharp.value = c.trailCoreSharp; + u.uCoreWidth.value = c.trailCoreWidth; + u.uHaloFalloff.value = c.trailHaloFalloff; + u.uHaloOpacity.value = c.trailHaloOpacity; + u.uOpacity.value = c.trailOpacity * g.opacity; + u.uGlow.value = c.trailGlow * g.glow; + u.uSoftFade.value = c.trailSoftFade; + + u.uColorA.value.copy(getColor(c.colorTrailA)); + u.uColorB.value.copy(getColor(c.colorTrailB)); + u.uColorC.value.copy(getColor(c.colorTrailC)); + u.uColorD.value.copy(getColor(c.colorTrailD)); + }; + + return material; +} + +/* ------------------------------------------------------------------ */ +/* 2 · The heads — small on purpose */ +/* ------------------------------------------------------------------ */ + +/** + * A camera-facing quad per bolt, placed by the same `boltPoint()`. + * + * Small, and it must stay small: the trails are the read, and a big bright head + * turns seven weaving ribbons into seven dots with smears behind them. The quad + * is expanded along the view basis pulled out of `viewMatrix` rather than out of + * a billboard matrix, because there is no model matrix on this mesh at all. + */ +const HEAD_VERTEX = /* glsl */ ` + ${VOLLEY_PATH} + + uniform float uTime; + uniform float uQHead; + uniform float uSize; + uniform float uFade; + + attribute float aStrand; + + varying vec2 vQuad; + varying float vViewZ; + + ${noiseGLSL} + + void main() { + vQuad = position.xy; + + vec3 dir, n1, n2; + volleyFrame(dir, n1, n2); + vec3 here = boltPoint(aStrand, uQHead, n1, n2); + + vec3 right = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]); + vec3 up = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]); + float size = uSize * uFade; + + vec4 mv = viewMatrix * vec4(here + (right * position.x + up * position.y) * size, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const HEAD_FRAGMENT = /* glsl */ ` + uniform float uCoreSharp; + uniform float uSoft; + uniform float uGlow; + uniform float uOpacity; + uniform float uFade; + uniform float uSoftFade; + uniform vec3 uColorCore; + uniform vec3 uColorRim; + + uniform float uGlobalGlow; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + varying vec2 vQuad; + varying float vViewZ; + + ${commonGLSL} + + void main() { + float r = length(vQuad); + if (r > 1.0) discard; + + float core = pow(clamp(1.0 - r, 0.0, 1.0), max(uCoreSharp, 0.05)); + float bloom = pow(clamp(1.0 - r, 0.0, 1.0), max(uSoft, 0.05)); + + vec3 color = (uColorCore * core + uColorRim * bloom) * uGlow * uGlobalGlow; + float alpha = clamp(core + bloom * 0.6, 0.0, 1.0) * uOpacity * uFade; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + gl_FragColor = vec4(color, alpha); + } +`; + +/** The head pass. One draw call, `bolts` instances. */ +export function createVolleyHeadMaterial() { + const ratios = []; + for (let i = 0; i < MAX_BOLTS; i++) ratios.push(new Vector2(RATIOS[i][0], RATIOS[i][1])); + + const material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uHand: { value: new Vector3() }, + uTarget: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + uSeed: { value: 0 }, + uCount: { value: 7 }, + uRatio: { value: ratios }, + + uWeaveSide: { value: 1.9 }, + uWeaveUp: { value: 1.2 }, + uWeaveTurns: { value: 1.0 }, + uWeavePhase: { value: Math.PI * 0.5 }, + uWeaveDecay: { value: 1.5 }, + uPathCurve: { value: 1.0 }, + uOrbitTurns: { value: 1.2 }, + uOrbitStart: { value: 0.25 }, + uChargeCurve: { value: 0.8 }, + + uQHead: { value: 0 }, + uSize: { value: 0.11 }, + uFade: { value: 1 }, + uCoreSharp: { value: 3.4 }, + uSoft: { value: 1.1 }, + uGlow: { value: 2.4 }, + uOpacity: { value: 1 }, + uSoftFade: { value: 0.35 }, + uColorCore: { value: new Color(1, 1, 1) }, + uColorRim: { value: new Color(0.6, 0.4, 0.9) } + }), + vertexShader: HEAD_VERTEX, + fragmentShader: HEAD_FRAGMENT + }); + + /** + * The path half of this is the same state the trail is handed, so the two + * cannot drift apart: they are literally the same numbers into the same + * chunk. + * + * @param {object} state { hand, target, side, seed, count, qHead, fade } + */ + material.userData.sync = (state) => { + const c = settings.arcanevolley; + const g = settings.global; + const u = material.uniforms; + + u.uHand.value.copy(state.hand); + u.uTarget.value.copy(state.target); + u.uSide.value.copy(state.side); + u.uSeed.value = state.seed; + u.uCount.value = state.count; + u.uQHead.value = state.qHead; + u.uFade.value = state.fade; + + u.uWeaveSide.value = c.weaveSide; + u.uWeaveUp.value = c.weaveUp; + u.uWeaveTurns.value = c.weaveTurns; + u.uWeavePhase.value = c.weavePhase; + u.uWeaveDecay.value = c.weaveDecay; + u.uPathCurve.value = c.pathCurve; + u.uOrbitTurns.value = c.orbitTurns; + u.uOrbitStart.value = c.orbitStart; + u.uChargeCurve.value = c.chargeCurve; + + u.uSize.value = c.headSize; + u.uCoreSharp.value = c.headCoreSharp; + u.uSoft.value = c.headSoft; + u.uGlow.value = c.headGlow * g.glow; + u.uOpacity.value = c.headOpacity * g.opacity; + u.uSoftFade.value = c.headSoftFade; + u.uColorCore.value.copy(getColor(c.colorHeadCore)); + u.uColorRim.value.copy(getColor(c.colorHeadRim)); + }; + + return material; +} + +/** + * The JS mirror of `boltPoint()`, for the handful of things the CPU has to + * place against the flight: the dynamic light, the motes that come off a bolt, + * and the point the volley converges on. + * + * **This function and the GLSL above are one thing in two languages.** Editing + * either without the other is the single way to break this ability silently — + * the bolts would fly one path and everything hung off them would sit on + * another, and nothing would error. + * + * @param {object} c the live settings block + * @param {number} seed the cast's unitless dice roll + * @param {number} bolt instance index + * @param {number} count live bolt count + * @param {number} q the flight parameter; see the header + * @param {THREE.Vector3} hand + * @param {THREE.Vector3} target + * @param {THREE.Vector3} n1 lateral basis + * @param {THREE.Vector3} n2 vertical basis + * @param {THREE.Vector3} out + */ +export function boltPointJS(c, seed, bolt, count, q, hand, target, n1, n2, out) { + const TAU2 = Math.PI * 2; + const tau = q < 0 ? 0 : q > 1 ? 1 : q; + const wind = q < -1 ? -1 : q > 0 ? 0 : q; + const ratio = RATIOS[Math.max(0, Math.min(MAX_BOLTS - 1, bolt))]; + + let phase = (bolt / Math.max(1, count)) * TAU2 + seed; + phase += wind * c.orbitTurns * TAU2; + + const grow = Math.pow(Math.max(0, 1 + wind), Math.max(0.05, c.chargeCurve)); + const gate = c.orbitStart + (1 - c.orbitStart) * grow; + const decay = Math.pow(1 - tau, Math.max(0.01, c.weaveDecay)) * gate; + + out.copy(hand).lerp(target, Math.pow(tau, Math.max(0.05, c.pathCurve))); + out.addScaledVector(n1, c.weaveSide * decay * Math.sin(TAU2 * ratio[0] * c.weaveTurns * tau + phase)); + out.addScaledVector(n2, c.weaveUp * decay * Math.sin(TAU2 * ratio[1] * c.weaveTurns * tau + phase + c.weavePhase)); + return out; +} diff --git a/src/materials/WildfireMaterial.js b/src/materials/WildfireMaterial.js new file mode 100644 index 0000000..4732ad3 --- /dev/null +++ b/src/materials/WildfireMaterial.js @@ -0,0 +1,539 @@ +import { + AdditiveBlending, + BufferAttribute, + Color, + InstancedBufferAttribute, + InstancedBufferGeometry, + NormalBlending, + ShaderMaterial, + Sphere, + Vector2, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { getColor } from '../utils/color.js'; + +/** + * WildfireMaterial — how a cellular automaton is drawn without a texture. + * + * ## The problem this file exists to solve + * + * Wildfire's trick is that the spread is a real automaton on a grid: cell to + * cell, biased by wind, jumping gaps, leaving islands, burning back over its + * own ground. The state of that automaton is a few hundred numbers that change + * every tick, and the obvious way to get a few hundred numbers onto the GPU is + * a `DataTexture` — which invariant **I2** forbids, and rightly: a texture is a + * resource with a size, a filter and a format, and this sandbox has spent fifty + * abilities proving it does not need one. + * + * So the grid is **instanced geometry**: one quad per cell, with the cell's + * state carried in instanced attributes the ability writes into directly. Two + * meshes — the burnt floor and the flames standing on it — share one set of + * attribute objects, so the state is one array, one upload, and the flame on a + * cell can never disagree with the scar under it. Both are one draw call each, + * whatever the grid resolution. + * + * ## What is a dimension and what is not + * + * Per cell the attributes are: its **lattice coordinate** (a pair of integers), + * two **dice rolls** (a placement jitter and a seed), its **fuel** (0..1) and + * the **timestamp** it caught fire, or a negative number if it has not. Not one + * of those is a metre or a second of duration. The pitch of the lattice is + * `2·zoneRadius / grid`, resolved in `update()` every frame, so dragging the + * zone radius on a paused fire re-scales the whole burn — cells, flames, + * jitter and all — and the *pattern* stays exactly the pattern the automaton + * produced. That separation is the entire reason the CA state is unitless. + * + * ## Why the flames are not Firewalk's pillars + * + * They look related and they are deliberately not shared. A Firewalk pillar is + * one of a dozen, tall, individually authored and individually watched; a + * Wildfire flame is one of several hundred, short, and only ever read as part + * of a *front*. Folding them together gives one material with a mode switch and + * two sets of uniforms that are dead in the other mode, which is exactly the + * shape of thing this project keeps refusing to write. + */ + +/* ====================================================================== */ +/* The lattice */ +/* ====================================================================== */ +/** + * Where cell `(col, row)` is, in world metres. + * + * `uGrid` is a **count** — the automaton's own topology, fixed for the life of + * a cast because changing it mid-burn would renumber every cell — and `uPitch` + * is a metre, re-resolved every frame. Keeping those two apart is what lets the + * zone radius stay a live slider over a running simulation. + */ +export const CELL_LATTICE_GLSL = /* glsl */ ` + uniform vec3 uCentre; // world, on the floor, the middle of the zone + uniform vec3 uForward; // unit, downrange (lattice +y) + uniform vec3 uSideAxis; // unit, lateral (lattice +x) + uniform float uGrid; // cells across — a count, not a measurement + uniform float uPitch; // metres between cell centres + uniform float uJitter; // metres of per-cell placement slop + uniform float uLift; // metres above the floor + uniform float uNow; // the ability's age, seconds + + attribute vec2 aCell; // lattice coordinate + attribute vec2 aJitter; // two dice rolls, -1..1 + attribute float aSeed; // dice roll + attribute float aFuel; // 0..1 fuel this cell had when it caught + attribute float aIgnite; // timestamp it caught, or negative + + /** Lattice coordinate to metres in the zone's own frame. */ + vec2 cellOffset(vec2 cell) { + return (cell - (uGrid - 1.0) * 0.5) * uPitch + aJitter * uJitter; + } + + vec3 cellCentre(vec2 cell) { + vec2 o = cellOffset(cell); + return uCentre + uSideAxis * o.x + uForward * o.y; + } +`; + +/* ====================================================================== */ +/* The burnt floor */ +/* ====================================================================== */ +/** + * One ground quad per cell that has caught. + * + * A cell that has never been lit is culled in the vertex shader, so the scar on + * the floor **is** the automaton's state — you are looking straight at the + * array. That is the point: the unburnt islands are holes in this mesh, not + * darker patches in a texture, and the front is a jagged boundary between + * instances rather than a smoothstep on a radius. + * + * `uBlock` is the honesty slider. At 1 a cell is a square and the lattice is + * plainly visible; at 0 it is a disc and the burn reads as organic. It ships + * near the middle, where the grid is *felt* rather than counted — which is + * where an automaton should sit, because hiding the lattice completely throws + * away the only visual evidence that the spread is simulated at all. + */ +const CELL_VERTEX = /* glsl */ ` + ${CELL_LATTICE_GLSL} + + uniform float uCellSize; // metres, the drawn width of a cell + + varying vec2 vLocal; // -1..1 within the cell + varying vec3 vWorld; + varying float vAge; // seconds since this cell caught + varying float vFuel; + varying float vSeed; + varying float vViewZ; + + void main() { + if (aIgnite < 0.0) { + vLocal = vec2(0.0); + vWorld = vec3(0.0); + vAge = -1.0; + vFuel = 0.0; + vSeed = 0.0; + vViewZ = -1.0; + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + vec2 local = position.xz * uCellSize; + vec3 world = cellCentre(aCell) + uSideAxis * local.x + uForward * local.y; + world.y = uLift; + + vLocal = position.xz * 2.0; + vWorld = world; + vAge = uNow - aIgnite; + vFuel = aFuel; + vSeed = aSeed; + + vec4 mv = modelViewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const CELL_FRAGMENT = /* glsl */ ` + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + + uniform float uBlock; // 0 a disc, 1 a square — how visible the lattice is + uniform float uRough; // 0..1 noise chewed out of the cell's edge + uniform float uRoughScale; // features per metre on that noise + uniform float uCatchTime; // seconds a cell takes to come up to full heat + uniform float uBurnTime; // seconds a cell with full fuel burns for + uniform float uCharTime; // seconds the char takes to blacken + uniform float uAshTime; // seconds after burnout the ash takes to grey over + uniform float uVein; // 0..1 how much of the ember is vein rather than wash + uniform float uOpacity; + uniform float uGlow; + uniform float uFade; + uniform float uSoftFade; // metres of depth feather + + uniform vec3 uColorFlash; // the moment it catches + uniform vec3 uColorEmber; + uniform vec3 uColorChar; + uniform vec3 uColorAsh; + + varying vec2 vLocal; + varying vec3 vWorld; + varying float vAge; + varying float vFuel; + varying float vSeed; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + /** Rounded box, in the cell's own -1..1 space. */ + float sdCell(vec2 p, float corner) { + vec2 q = abs(p) - vec2(1.0) + corner; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - corner; + } + + void main() { + if (vAge < 0.0 || uFade < 0.004) discard; + + float corner = clamp(1.0 - uBlock, 0.0, 1.0); + float sd = sdCell(vLocal, corner); + // Chewed in world metres, so neighbouring cells erode into each other and + // the burn stops being a mosaic of identical tiles. + sd += (snoise(vec3(vWorld.xz * max(uRoughScale, 0.01), vSeed * 5.3)) * 0.5) * uRough; + + float cover = 1.0 - smoothstep(-0.12, 0.06, sd); + if (cover < 0.004) discard; + + // How long this cell burns is its own fuel times a live setting: a rich + // cell is a long, hot burn, a starved one flares and dies. Drag the burn + // time on a paused fire and every cell re-times at once. + float burn = max(vFuel, 0.05) * max(uBurnTime, 0.05); + float rise = smoothstep(0.0, max(uCatchTime, 1e-3), vAge); + float heat = rise * (1.0 - smoothstep(burn * 0.55, burn, vAge)); + float soot = 1.0 - exp(-vAge / max(uCharTime, 0.05)); + float ash = smoothstep(burn, burn + max(uAshTime, 0.05), vAge); + + float grain = snoise(vec3(vWorld.xz * max(uRoughScale, 0.01) * 2.7, vSeed * 17.0)) * 0.5 + 0.5; + float vein = mix(1.0, smoothstep(0.45 - uVein * 0.35, 0.8, grain), clamp(uVein, 0.0, 1.0)); + + vec3 colour = mix(uColorChar, uColorAsh, ash); + colour = mix(uColorChar, colour, soot); + colour += uColorEmber * heat * vein * uGlow * uGlobalGlow; + // The catch itself: a hard white flick on the frame a cell takes, which is + // what makes the front read as a chain of ignitions rather than a wipe. + colour += uColorFlash * exp(-pow(vAge / max(uCatchTime * 1.4, 1e-3), 2.0)) * uGlow * uGlobalGlow; + + float alpha = cover * uOpacity * uFade; + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/* ====================================================================== */ +/* The flames */ +/* ====================================================================== */ +/** + * One upright billboard per burning cell, leaning downwind. + * + * The lean is the same wind vector the automaton spreads along, handed in as + * two components in the lattice's frame. Getting that agreement right is worth + * more than any amount of flame detail: a front that spreads north-east while + * its flames lean south-west is instantly, unaccountably wrong, and it took a + * while to work out that *that* was what looked wrong rather than the noise. + * + * Cells that are not burning collapse to nothing through `heat`, so the mesh + * carries every cell and draws only the front. No compaction, no sorting, no + * per-frame rebuild: the flames are wherever the state array says the fire is. + */ +const FLAME_VERTEX = /* glsl */ ` + ${CELL_LATTICE_GLSL} + + uniform float uHeight; // metres at full heat on a full-fuel cell + uniform float uWidth; // metres, half-width at the base + uniform float uCatchTime; // seconds a cell takes to come up to full heat + uniform float uBurnTime; // seconds a cell with full fuel burns for + uniform float uLean; // metres the crown leans downwind + uniform vec2 uWind; // unit-ish, in the lattice frame + uniform float uWaver; // metres of sway at the crown + uniform float uWaverRate; // sways per second + + varying vec2 vQuad; // x: -1..1 across, y: 0..1 up + varying vec3 vWorld; + varying float vHeat; + varying float vSeed; + varying float vViewZ; + + void main() { + float age = uNow - aIgnite; + float burn = max(aFuel, 0.05) * max(uBurnTime, 0.05); + float heat = aIgnite < 0.0 + ? 0.0 + : smoothstep(0.0, max(uCatchTime, 1e-3), age) * (1.0 - smoothstep(burn * 0.5, burn, age)); + + if (heat < 0.01) { + vQuad = vec2(0.0); + vWorld = vec3(0.0); + vHeat = 0.0; + vSeed = 0.0; + vViewZ = -1.0; + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + vec3 base = cellCentre(aCell); + base.y = uLift; + + // Cylindrical billboard: the camera's right, flattened into the floor plane. + // A full billboard tips the whole front over as the camera orbits, which + // reads as the wind following you around the room. + vec3 camRight = normalize(vec3(viewMatrix[0][0], 0.0, viewMatrix[2][0]) + vec3(1e-5, 0.0, 0.0)); + vec3 windDir = uSideAxis * uWind.x + uForward * uWind.y; + + float h = position.y + 0.5; + float across = position.x * 2.0; + float grow = heat * (0.45 + 0.55 * aFuel); + float sway = sin(uNow * uWaverRate + aSeed * 6.2831) * uWaver * h * h; + + vec3 world = base + + camRight * (across * uWidth * grow + sway) + + vec3(0.0, h * uHeight * grow, 0.0) + + windDir * (uLean * h * h * grow); + + vQuad = vec2(across, h); + vWorld = world; + vHeat = heat; + vSeed = aSeed; + + vec4 mv = modelViewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const FLAME_FRAGMENT = /* glsl */ ` + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + uniform float uTime; + + uniform float uTaper; + uniform float uBulge; + uniform float uNoiseScale; + uniform float uNoiseSpeed; + uniform float uErosion; + uniform float uOpacity; + uniform float uGlow; + uniform float uFade; + uniform float uSoftFade; + + uniform vec3 uColorA; + uniform vec3 uColorB; + uniform vec3 uColorC; + uniform vec3 uColorD; + + varying vec2 vQuad; + varying vec3 vWorld; + varying float vHeat; + varying float vSeed; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + if (vHeat < 0.01 || uFade < 0.004) discard; + + float h = clamp(vQuad.y, 0.0, 1.0); + float profile = pow(1.0 - h, max(uTaper, 0.05)) * (1.0 + uBulge * sin(h * 3.14159265)); + float body = 1.0 - abs(vQuad.x) / max(profile, 1e-3); + if (body <= 0.0) discard; + + // World-space field: two cells side by side flicker independently, which is + // the difference between a fire front and a row of identical sprites. + float n = fbm3(vec3(vWorld.xz * uNoiseScale, vWorld.y * uNoiseScale - uTime * uNoiseSpeed) + vSeed * 7.0); + float shape = body - uErosion * (0.5 + 0.5 * n) * h; + + float alpha = smoothstep(0.0, 0.3, shape) * vHeat * uOpacity * uFade; + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + vec3 colour = gradient4(uColorA, uColorB, uColorC, uColorD, clamp(h * 0.9 + (1.0 - shape) * 0.25, 0.0, 1.0)); + colour *= uGlow * uGlobalGlow * mix(0.55, 1.0, vHeat); + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/* ====================================================================== */ +/* Construction */ +/* ====================================================================== */ + +/** + * The uniform boxes the two materials **share by identity** — the lattice, the + * clock and where the zone is. One write per frame drives the scar and the + * flame standing on it, so they cannot part company on a frame where one was + * updated and the other was not. + */ +export function createWildfirePlacement() { + return { + uCentre: { value: new Vector3() }, + uForward: { value: new Vector3(0, 0, 1) }, + uSideAxis: { value: new Vector3(1, 0, 0) }, + uGrid: { value: 24 }, + uPitch: { value: 0.4 }, + uJitter: { value: 0.06 }, + uLift: { value: 0.014 }, + uNow: { value: 0 } + }; +} + +/** + * The five per-cell attributes, built once and handed to both geometries. + * + * `aCell` and `aJitter` are set here and never touched again; `aFuel` and + * `aIgnite` are the automaton's state, written straight into these arrays by + * the ability every frame. Nothing is allocated during a cast. + */ +export function createCellAttributes(capacity, grid) { + const cell = new Float32Array(capacity * 2); + const jitter = new Float32Array(capacity * 2); + const seed = new Float32Array(capacity); + const fuel = new Float32Array(capacity); + const ignite = new Float32Array(capacity); + for (let i = 0; i < capacity; i++) { + cell[i * 2] = i % grid; + cell[i * 2 + 1] = Math.floor(i / grid); + jitter[i * 2] = Math.random() * 2 - 1; + jitter[i * 2 + 1] = Math.random() * 2 - 1; + seed[i] = Math.random() * 100; + fuel[i] = 0; + ignite[i] = -1; + } + return { + aCell: new InstancedBufferAttribute(cell, 2), + aJitter: new InstancedBufferAttribute(jitter, 2), + aSeed: new InstancedBufferAttribute(seed, 1), + aFuel: new InstancedBufferAttribute(fuel, 1), + aIgnite: new InstancedBufferAttribute(ignite, 1) + }; +} + +/** A unit quad, flat in XZ for the scar or upright in XY for the flame. */ +function cellQuad(attributes, ground) { + const geometry = new InstancedBufferGeometry(); + + const position = ground + ? new Float32Array([-0.5, 0, -0.5, 0.5, 0, -0.5, 0.5, 0, 0.5, -0.5, 0, 0.5]) + : new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]); + geometry.setAttribute('position', new BufferAttribute(position, 3)); + geometry.setIndex(new BufferAttribute(new Uint16Array([0, 1, 2, 0, 2, 3]), 1)); + + for (const [name, attribute] of Object.entries(attributes)) geometry.setAttribute(name, attribute); + geometry.instanceCount = 0; + + // Placed entirely in the vertex shader; three's bounds are meaningless here. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + return geometry; +} + +export function createCellGeometry(attributes) { + return cellQuad(attributes, true); +} + +export function createFlameGeometry(attributes) { + return cellQuad(attributes, false); +} + +/** The burnt floor. Alpha-blended: a scar is darker than the stone it is on. */ +export function createCellMaterial(placement) { + return new ShaderMaterial({ + name: 'Wildfire:cells', + vertexShader: CELL_VERTEX, + fragmentShader: CELL_FRAGMENT, + transparent: true, + depthWrite: false, + depthTest: true, + blending: NormalBlending, + toneMapped: false, + uniforms: sharedUniforms({ + ...placement, + + uCellSize: { value: 0.42 }, + uBlock: { value: 0.55 }, + uRough: { value: 0.45 }, + uRoughScale: { value: 2.6 }, + uCatchTime: { value: 0.22 }, + uBurnTime: { value: 2.2 }, + uCharTime: { value: 0.6 }, + uAshTime: { value: 2.0 }, + uVein: { value: 0.55 }, + uOpacity: { value: 1 }, + uGlow: { value: 2.0 }, + uFade: { value: 1 }, + uSoftFade: { value: 0.3 }, + + uColorFlash: { value: new Color(1, 0.95, 0.8) }, + uColorEmber: { value: new Color(1, 0.42, 0.1) }, + uColorChar: { value: new Color(0.07, 0.05, 0.05) }, + uColorAsh: { value: new Color(0.34, 0.31, 0.3) } + }) + }); +} + +/** The flames. Additive, because they are fire. */ +export function createFlameMaterial(placement) { + return new ShaderMaterial({ + name: 'Wildfire:flames', + vertexShader: FLAME_VERTEX, + fragmentShader: FLAME_FRAGMENT, + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + toneMapped: false, + uniforms: sharedUniforms({ + ...placement, + + uHeight: { value: 0.85 }, + uWidth: { value: 0.16 }, + uCatchTime: { value: 0.22 }, + uBurnTime: { value: 2.2 }, + uLean: { value: 0.3 }, + uWind: { value: new Vector2(0, 1) }, + uWaver: { value: 0.05 }, + uWaverRate: { value: 3.6 }, + + uTaper: { value: 1.25 }, + uBulge: { value: 0.5 }, + uNoiseScale: { value: 2.1 }, + uNoiseSpeed: { value: 2.6 }, + uErosion: { value: 0.95 }, + uOpacity: { value: 1 }, + uGlow: { value: 1.6 }, + uFade: { value: 1 }, + uSoftFade: { value: 0.4 }, + + uColorA: { value: new Color(1, 0.94, 0.72) }, + uColorB: { value: new Color(1, 0.58, 0.14) }, + uColorC: { value: new Color(0.78, 0.18, 0.04) }, + uColorD: { value: new Color(0.1, 0.08, 0.08) } + }) + }); +} + +/** Four pickers into the flame's height gradient. Memoised through `getColor`. */ +export function setFlameGradient(material, a, b, c, d) { + const u = material.uniforms; + u.uColorA.value.copy(getColor(a)); + u.uColorB.value.copy(getColor(b)); + u.uColorC.value.copy(getColor(c)); + u.uColorD.value.copy(getColor(d)); + return material; +} diff --git a/src/particles/ParticleSystem.js b/src/particles/ParticleSystem.js index e47797c..bfd2ad6 100644 --- a/src/particles/ParticleSystem.js +++ b/src/particles/ParticleSystem.js @@ -14,6 +14,7 @@ import { } from 'three'; import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { timeWarpGLSL } from '../shaders/lib/timewarp.glsl.js'; import { sharedUniforms } from '../core/FrameUniforms.js'; import { LAYER } from '../core/Layers.js'; @@ -396,26 +397,33 @@ const PARTICLE_VERTEX = /* glsl */ ` varying vec3 vTint; varying float vViewZ; varying vec3 vNormalish; + /** + * The clock this particle is actually living on — uTime for all but the + * handful held inside a time region. The fragment stage needs it because the + * SMOKE silhouette erodes on a clock of its own, and a puff frozen in mid-air + * that is still churning internally is very obviously not frozen. + */ + varying float vClock; ${noiseGLSL} + ${timeWarpGLSL} - void main() { - vUv = uv; - vSeed = aSeed; - vTint = aColor; - - float life = aLife * uLifeScale; - float age = uTime - aSpawn; - float t = age / max(life, 1e-4); - vT = t; - - // Dead particles are pushed outside the clip volume; the GPU discards the - // whole triangle before rasterisation. - if (age < 0.0 || t > 1.0) { - gl_Position = vec4(2.0, 2.0, 2.0, 1.0); - return; - } - + /** + * The whole trajectory, as a closed-form function of age. + * + * This used to be inline in main. It is a function now for one reason: a + * particle sitting inside a stasis region has to be asked *where it was when + * the region locked*, which means evaluating the same path at a second age in + * the same invocation. Nothing about the arithmetic changed when it moved — + * with no region live the function is called exactly once and the compiler + * inlines it, so the shader that ships is the shader that shipped before. + * + * clock is passed rather than read from uTime so the curl advection also + * freezes: the noise field this samples drifts on the clock, and a frozen + * ember riding a field that is still flowing crawls sideways for as long as + * you hold it. + */ + vec3 particlePath(float age, float t, float clock) { vec3 vel = aVelocity * uSpeedScale; // Analytic exponential drag — exact, and independent of frame rate. @@ -438,7 +446,7 @@ const PARTICLE_VERTEX = /* glsl */ ` // Turbulence: a cheap deterministic wobble, optionally upgraded to real // curl noise for the heavier smoke/flame systems. #ifdef USE_CURL - pos += curlNoise(aStart * uTurbFrequency + vec3(0.0, uTime * uTurbSpeed, 0.0) + aSeed * 4.0) + pos += curlNoise(aStart * uTurbFrequency + vec3(0.0, clock * uTurbSpeed, 0.0) + aSeed * 4.0) * uTurbulence * age; #else vec3 wobble = vec3( @@ -449,6 +457,69 @@ const PARTICLE_VERTEX = /* glsl */ ` pos += wobble * uTurbulence * age * 0.55; #endif + return pos; + } + + void main() { + vUv = uv; + vSeed = aSeed; + vTint = aColor; + + float life = aLife * uLifeScale; + float rawAge = uTime - aSpawn; + + /* ---- which clock is this particle on? ------------------------------ + * + * Nothing below this comment costs anything while uTimeRegionCount is 0, + * which is every frame in which no chrono ability is standing: one uniform + * compare, taken the same way by every vertex in the draw. + * + * When a region *is* live, each slot is probed at the position this + * particle had **on the frame that slot locked** — not at its current + * position. The obvious version probes the current position and it thaws + * its own freeze one frame later: the particle stops, the world clock runs + * on, and the probe is now asking about a place the particle only occupies + * because it is held. Probing the lock instant is a fixed point instead, + * and it is also the right fiction — a bubble of stopped time holds + * whatever was inside it when it snapped shut. + * + * The probe age is clamped into [0, min(rawAge, life)], which handles + * three cases without a branch: a particle born after the lock probes its + * own spawn point (so an emitter firing into a standing field has its + * output frozen on arrival), a particle that died before the lock probes + * its last position and is then killed by the t > 1.0 test below, and a + * dead slot in the ring buffer — spawn time -1e4, life 0 — probes age 0 + * rather than evaluating curl noise ten thousand seconds out. + */ + float clock = uTime; + if (uTimeRegionCount > 0.5 && rawAge > 0.0) { + for (int i = 0; i < MAX_TIME_REGIONS; i++) { + if (float(i) >= uTimeRegionCount) break; + vec4 region = uTimeRegion[i]; + vec4 warp = uTimeRegionWarp[i]; + float probeAge = clamp(warp.z - aSpawn, 0.0, min(rawAge, life)); + vec3 probe = particlePath(probeAge, probeAge / max(life, 1e-4), warp.z); + clock = timeRegionClock(clock, warp, timeRegionFalloff(probe, region, warp)); + } + } + vClock = clock; + + float age = clock - aSpawn; + float t = age / max(life, 1e-4); + vT = t; + + // Dead particles are pushed outside the clip volume; the GPU discards the + // whole triangle before rasterisation. This runs on the *bent* age, which + // is what makes a region with a negative rate un-spawn its particles: they + // fly backwards into the emitter, reach age 0, and stop existing there. + if (age < 0.0 || t > 1.0) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + vec3 vel = aVelocity * uSpeedScale; + vec3 pos = particlePath(age, t, clock); + vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); vViewZ = mvPosition.z; @@ -501,6 +572,7 @@ const PARTICLE_FRAGMENT = /* glsl */ ` varying vec3 vTint; varying float vViewZ; varying vec3 vNormalish; + varying float vClock; // the vertex stage's bent clock — see the vertex shader ${noiseGLSL} ${commonGLSL} @@ -513,7 +585,9 @@ const PARTICLE_FRAGMENT = /* glsl */ ` return smoothstep(1.0, 0.0, d); #elif SHAPE == 1 // SMOKE - float n = fbm3(vec3(c * 1.6, vSeed * 21.0 + uTime * 0.25)); + // vClock, not uTime: the erosion is the one part of a puff that is + // animated rather than parametric, so a held puff has to hold this too. + float n = fbm3(vec3(c * 1.6, vSeed * 21.0 + vClock * 0.25)); return smoothstep(1.0, 0.05, d + n * 0.42) * 0.9; #elif SHAPE == 2 // STREAK diff --git a/src/postprocessing/DistortionShader.js b/src/postprocessing/DistortionShader.js index df33619..40a7310 100644 --- a/src/postprocessing/DistortionShader.js +++ b/src/postprocessing/DistortionShader.js @@ -1,9 +1,34 @@ /** - * Applies the screen-space refraction buffer written by LAYER.DISTORTION. + * Applies the screen-space refraction buffer written by `LAYER.DISTORTION`. * - * The buffer holds an offset encoded around 0.5 in RG, a strength in B and a - * coverage mask in A, so heat haze, water refraction and airbending pressure - * waves all warp the frame through this single pass. + * The buffer is a half-resolution HalfFloat target cleared to `(0.5, 0.5, 0, 0)` + * — "no offset, no coverage" — and every visible mesh on the distortion layer is + * drawn into it with normal blending before the composer runs. See + * `vfx/Distortion.js` for the emitters and `core/Layers.js` for the counter that + * lets this whole pass be skipped when nothing is writing. + * + * ``` + * R,G unit screen-space direction, encoded as d * 0.5 + 0.5 + * B magnitude, in screen widths at uScale = 1 + * A coverage — the blend weight between overlapping emitters + * ``` + * + * ## Why the decode does not multiply by alpha + * + * It used to. The buffer is normal-blended, which means an emitter covering a + * fragment at coverage `a` writes `rg = 0.5 + dir·a·0.5` and `b = mag·a`. Both + * channels therefore already carry the coverage, and multiplying by `a` a third + * time made every emitter's soft edge fall off as `a³` — a heat plume that was + * authored to feather over 20% of its width feathered over most of it, and the + * effect read as weaker than its slider said it was. Dropping the term leaves + * `a²`, which is still one more than is strictly correct and is exactly the + * price of expressing "who wins where two distorters overlap" in a single blend + * mode. Alpha is kept as the early-out. + * + * `uScale` is `settings.post.distortion × settings.global.distortion`, applied + * here and only here. Emitters never fold the global gains into their own + * strength — one place to apply them means a writer that forgets still obeys the + * master sliders, and no writer can apply them twice. */ export const DistortionShader = { name: 'DistortionShader', @@ -11,7 +36,7 @@ export const DistortionShader = { uniforms: { tDiffuse: { value: null }, tDistortion: { value: null }, - uScale: { value: 0.03 } + uScale: { value: 0.045 } }, vertexShader: /* glsl */ ` @@ -30,8 +55,26 @@ export const DistortionShader = { void main() { vec4 d = texture2D(tDistortion, vUv); - vec2 offset = (d.rg - 0.5) * 2.0 * d.b * d.a * uScale; - // Clamp so a hot spot can never sample outside the frame. + + // The overwhelming majority of the frame is untouched buffer. Bailing out + // here costs one compare and saves the second texture fetch's dependent + // address computation on the tiles that need nothing. + if (d.a < 0.002 || d.b < 0.0005) { + gl_FragColor = texture2D(tDiffuse, vUv); + return; + } + + vec2 offset = (d.rg - 0.5) * 2.0 * d.b * uScale; + + // Fade the offset out against the frame border rather than clamping into + // it. A clamp smears the edge row of pixels across whatever asked for a + // sample from outside, which reads as a streak pinned to the screen edge — + // and a screen-pinned artefact is the one thing that gives a + // world-anchored effect away. + vec2 border = min(vUv, 1.0 - vUv); + float inset = smoothstep(0.0, 0.03, min(border.x, border.y)); + offset *= inset; + vec2 uv = clamp(vUv + offset, vec2(0.0), vec2(1.0)); gl_FragColor = texture2D(tDiffuse, uv); } diff --git a/src/postprocessing/PostProcessing.js b/src/postprocessing/PostProcessing.js index 2d00c01..08cf2ac 100644 --- a/src/postprocessing/PostProcessing.js +++ b/src/postprocessing/PostProcessing.js @@ -13,23 +13,49 @@ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js' import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; import { GradeShader } from './GradeShader.js'; import { DistortionShader } from './DistortionShader.js'; -import { LAYER } from '../core/Layers.js'; +import { LAYER, distortionWriters } from '../core/Layers.js'; import { frame } from '../core/FrameUniforms.js'; import { settings } from '../config/settings.js'; const DISTORTION_CLEAR = new Color(0.5, 0.5, 0.0); +/** `post.distortionScale` is clamped here: below this the warp visibly stairsteps. */ +const MIN_DISTORTION_SCALE = 0.25; + /** * The full render pipeline. * * Per frame: * 1. depth prepass — opaque WORLD layer into a packed-depth buffer, which * every VFX shader samples for soft intersections - * 2. distortion — DISTORTION layer into an offset buffer + * 2. distortion — DISTORTION layer into an offset buffer, *if anything is + * writing to it* * 3. composer — scene → refraction → bloom → tone map → grade * - * Passes 1 and 2 run at half resolution: both are only ever read as smooth, - * low-frequency data, so full resolution would be wasted fill rate. + * Pass 1 runs at half resolution and pass 2 at `post.distortionScale` of it: + * both are only ever read as smooth, low-frequency data, so full resolution + * would be wasted fill rate. + * + * ## The distortion pass + * + * This used to be dead weight — the README carried "the distortion pass runs + * with nothing writing to it, it costs a half-res clear per frame" as a known + * rough edge. `vfx/Distortion.js` now writes to it, and the accounting works + * both ways: `core/Layers.js#distortionWriters` counts the meshes currently + * *visible* on the layer, and when that count is zero the clear, the draw and + * the full-res resample are all skipped. An idle frame is therefore cheaper than + * it was before the pass did anything. + * + * Three switches gate it, in order of how blunt they are: + * + * - `post.enabled` — the whole stack, as before. + * - `post.distortionEnabled` — the refraction pass alone. This is the one to + * turn off on weak hardware; it removes a render target's worth of bandwidth + * and a dependent texture fetch per pixel. + * - `post.distortionScale` — the offset buffer's resolution as a fraction of + * the frame. 0.5 is the shipped value and what the buffer always was; 0.25 is + * the potato setting and is still perfectly smooth for heat and lensing, + * because nothing that writes here has an edge sharper than a metre. */ export class PostProcessing { constructor(renderer, scene, camera) { @@ -43,13 +69,24 @@ export class PostProcessing { const width = Math.floor(size.x * pixelRatio); const height = Math.floor(size.y * pixelRatio); + /** Device pixels of the frame, kept so the offset buffer can be re-scaled live. */ + this._pixelWidth = width; + this._pixelHeight = height; + this._distortionScale = 0.5; + /** Set every `sync()`: whether pass 2 has anything to do this frame. */ + this._distortionActive = false; + /* ---- auxiliary buffers ---- */ this.depthTarget = new WebGLRenderTarget(Math.floor(width / 2), Math.floor(height / 2)); this.depthTarget.texture.generateMipmaps = false; this.depthMaterial = new MeshDepthMaterial({ depthPacking: RGBADepthPacking }); + // No depth attachment: the emitters are depth-test-off by construction and + // do their own occlusion against the prepass, so a depth buffer here would + // be a per-frame clear of memory nothing ever reads. this.distortionTarget = new WebGLRenderTarget(Math.floor(width / 2), Math.floor(height / 2), { - type: HalfFloatType + type: HalfFloatType, + depthBuffer: false }); this.distortionTarget.texture.generateMipmaps = false; @@ -117,6 +154,24 @@ export class PostProcessing { gl.setClearColor(this._clearColor, previousAlpha); } + /** + * Re-size the offset buffer when `post.distortionScale` moves. + * + * A live slider that reallocates a render target is normally a bad idea, but + * this one is dragged once on a machine that is struggling and then never + * again, and gating it behind a dimension compare means the reallocation only + * happens on the frame the value actually changes. + */ + _syncDistortionScale(scale) { + const clamped = Math.min(1, Math.max(MIN_DISTORTION_SCALE, scale || 0.5)); + if (clamped === this._distortionScale) return; + this._distortionScale = clamped; + this.distortionTarget.setSize( + Math.max(2, Math.floor(this._pixelWidth * clamped)), + Math.max(2, Math.floor(this._pixelHeight * clamped)) + ); + } + /** Screen-space refraction offsets. */ _renderDistortion() { const gl = this.gl; @@ -164,13 +219,27 @@ export class PostProcessing { u.uFlashStrength.value = flash.strength; u.uFlashColor.value.copy(flash.color); - this.distortionPass.uniforms.uScale.value = post.enabled ? post.distortion : 0; - this.distortionPass.enabled = post.enabled; + /* ---- the refraction pass ---- */ + // `global.distortion` is applied here and only here. Emitters write a bare + // direction and magnitude; folding the two master gains in at the writing + // end would let one ability apply them twice and another not at all. + const warp = + post.enabled && post.distortionEnabled !== false + ? post.distortion * settings.global.distortion + : 0; + + this._syncDistortionScale(post.distortionScale); + this.distortionPass.uniforms.uScale.value = warp; + + // Nothing visible on the layer means the clear, the draw and the resample + // are all skipped — see `core/Layers.js#distortionWriters`. + this._distortionActive = warp > 0.00005 && distortionWriters.count > 0; + this.distortionPass.enabled = this._distortionActive; } render() { this._renderDepth(); - this._renderDistortion(); + if (this._distortionActive) this._renderDistortion(); // Tone mapping is applied by OutputPass: three automatically disables the // in-material tone mapping while rendering into the composer's targets. this.composer.render(); @@ -184,8 +253,13 @@ export class PostProcessing { const w = Math.floor(width * pixelRatio); const h = Math.floor(height * pixelRatio); + this._pixelWidth = w; + this._pixelHeight = h; this.depthTarget.setSize(Math.max(2, Math.floor(w / 2)), Math.max(2, Math.floor(h / 2))); - this.distortionTarget.setSize(Math.max(2, Math.floor(w / 2)), Math.max(2, Math.floor(h / 2))); + this.distortionTarget.setSize( + Math.max(2, Math.floor(w * this._distortionScale)), + Math.max(2, Math.floor(h * this._distortionScale)) + ); frame.uResolution.value.set(w, h); } diff --git a/src/shaders/lib/common.glsl.js b/src/shaders/lib/common.glsl.js index bb69009..d9ede54 100644 --- a/src/shaders/lib/common.glsl.js +++ b/src/shaders/lib/common.glsl.js @@ -1,27 +1,28 @@ /** - * Shared shading helpers: soft particles, fresnel, dissolve, gradients. + * The subset of the shared helpers that is legal in a **vertex** shader. * - * Pulls in three's `` chunk itself (needed by `softFade`), so this - * chunk must only be injected into raw ShaderMaterials — never into a built-in - * material that already includes ``, or the depth helpers would be - * defined twice. - */ -export const commonGLSL = /* glsl */ ` -#ifndef COMMON_LIB_INCLUDED -#define COMMON_LIB_INCLUDED - -#include - -/** - * Depth-based soft particle fade. - * sceneDepth is a packed-RGBA depth prepass of the opaque scene. - * Returns 0 where the fragment intersects geometry, 1 when well in front of it. + * This split exists because injecting `commonGLSL` into a vertex stage does not + * compile, and the error points at a function you are not using. `aastep` calls + * `fwidth`, and derivative functions do not exist in a vertex shader; three + * reports it as a failure on that line and the natural reading is that the + * chunk is broken rather than that it is in the wrong stage. It has cost two + * people a round-trip here, and the first workaround both times was to + * hand-copy the four lines they actually wanted into the shader — which is how + * a gradient ends up with three subtly different definitions and a colour ramp + * that shifts depending on which mesh you are looking at. + * + * So: everything derivative-free and sampler-free lives here, `commonGLSL` + * includes it, and a vertex stage injects this instead. Both carry include + * guards and the guards are different, so injecting **both** into the same + * stage — which a material sharing one string across two stages will do — is + * safe in either order. + * + * Nothing in here reads a texture either, so it is also the chunk to reach for + * in a stage with no sampler bound. */ -float softFade(sampler2D sceneDepth, vec2 screenUV, float fragViewZ, float near, float far, float fadeDist) { - float packed = unpackRGBAToDepth(texture2D(sceneDepth, screenUV)); - float sceneViewZ = perspectiveDepthToViewZ(packed, near, far); - return clamp((fragViewZ - sceneViewZ) / max(fadeDist, 1e-4), 0.0, 1.0); -} +export const commonVertexGLSL = /* glsl */ ` +#ifndef COMMON_VERTEX_LIB_INCLUDED +#define COMMON_VERTEX_LIB_INCLUDED /** Standard Schlick-ish rim term. */ float fresnelTerm(vec3 viewDir, vec3 normal, float power, float scale) { @@ -48,6 +49,62 @@ vec2 screenUVFromClip(vec4 clipPos) { return (clipPos.xy / clipPos.w) * 0.5 + 0.5; } +/* + * Equirectangular lookup for a world-space direction — the environment probe's + * UV for a reflected ray. + * + * The two magic numbers are 1/(2*PI) and 1/PI. asin rather than acos because + * three's equirect convention puts v = 0 at the south pole, and the clamp is + * not paranoia: a normal perturbed by a heightfield routinely comes back a few + * ulps outside the unit sphere, asin(1.0000001) is NaN, and that reads on + * screen as one black pixel crawling across the water. + * + * NOTE for anyone editing this chunk: no backticks in these comments. The file + * is one big template literal and a stray backtick ends it, reporting as a + * JS syntax error pointing into the middle of the shader. + * + * This lived in three copies — GlacierMaterial and both surface modules in + * vfx/ — before it moved here. GlacierMaterial keeps its own: it injects + * noiseGLSL but not this chunk, and folding it in would mean editing a shipped + * material for tidiness alone. + */ +vec2 equirectUv(vec3 dir) { + return vec2(atan(dir.z, dir.x) * 0.15915494 + 0.5, + asin(clamp(dir.y, -1.0, 1.0)) * 0.31830989 + 0.5); +} + +#endif +`; + +/** + * Shared shading helpers: soft particles, fresnel, dissolve, gradients. + * + * **Fragment stages only.** Pulls in three's `` chunk itself (needed + * by `softFade`), so this chunk must only be injected into raw + * ShaderMaterials — never into a built-in material that already includes + * ``, or the depth helpers would be defined twice. For a vertex stage + * inject `commonVertexGLSL` above, which is the same chunk minus the two + * functions that cannot exist there. + */ +export const commonGLSL = /* glsl */ ` +#ifndef COMMON_LIB_INCLUDED +#define COMMON_LIB_INCLUDED + +#include + +${commonVertexGLSL} + +/** + * Depth-based soft particle fade. + * sceneDepth is a packed-RGBA depth prepass of the opaque scene. + * Returns 0 where the fragment intersects geometry, 1 when well in front of it. + */ +float softFade(sampler2D sceneDepth, vec2 screenUV, float fragViewZ, float near, float far, float fadeDist) { + float depthBits = unpackRGBAToDepth(texture2D(sceneDepth, screenUV)); + float sceneViewZ = perspectiveDepthToViewZ(depthBits, near, far); + return clamp((fragViewZ - sceneViewZ) / max(fadeDist, 1e-4), 0.0, 1.0); +} + /** Anti-aliased step using screen-space derivatives where available. */ float aastep(float threshold, float value) { float afwidth = fwidth(value) * 0.7; diff --git a/src/shaders/lib/timewarp.glsl.js b/src/shaders/lib/timewarp.glsl.js new file mode 100644 index 0000000..5dd49ea --- /dev/null +++ b/src/shaders/lib/timewarp.glsl.js @@ -0,0 +1,167 @@ +import { MAX_TIME_REGIONS } from '../../core/FrameUniforms.js'; + +/** + * The time field — a handful of spheres that bend any shader's clock. + * + * ## What the field is + * + * Up to `MAX_TIME_REGIONS` spheres, published as shared uniform boxes in + * `core/FrameUniforms.js` and filled by the pool in `vfx/TimeControl.js`. Each + * one carries a centre, an outer radius, a soft inner radius, a strength, a + * **hold timestamp** and a **rate**. A fragment or vertex inside a region does + * not see the world clock; it sees + * + * bent = mix(clock, hold + (clock - hold) * rate, weight) + * + * and that one line is all three chrono capabilities: + * + * | rate | what the region does | + * | --- | --- | + * | `0` | **stasis** — the clock stops dead at `hold`. Nothing inside advances. | + * | `-1` | **rewind** — the clock is mirrored about `hold` and runs backwards. | + * | `0.2`| slow motion at a fifth speed, still anchored at `hold`. | + * | `1` | identity, whatever the weight. | + * + * `weight` is `strength × (1 - smoothstep(inner, outer, distance))`, so the + * shell of a region is a **time-dilation gradient**: the derivative of `bent` + * with respect to `clock` is `mix(1, rate, weight)`, which at rate 0 means + * things at the rim crawl and things at the centre are stopped. That gradient + * is the reason a stasis field reads as a *field* rather than as a hard sphere + * of frozen sprites, and it comes free with the `mix`. + * + * ## Why a hold timestamp rather than a time scale + * + * The first version published a per-region `timeScale` and expected each + * consumer to integrate it — `myClock += dt * scaleAtMyPosition`. That works + * exactly once and then falls apart, because every consumer in this project is + * a **closed-form function of time** with no per-object state to integrate + * into: a particle is `f(uTime - aSpawn)` evaluated fresh in the vertex shader + * every frame with nowhere to keep an accumulator, and a crystal's emergence is + * `g(now - birth)`. There is no `myClock` to advance. Publishing the instant + * the region locked instead turns the whole thing into a closed form too, which + * means it also survives the one test that matters here: pause with **P**, drag + * the radius, and particles that were outside the sphere snap to their held + * pose while the rest keep theirs. A frame of zero length changes the picture. + * + * ## Composition + * + * Regions compose in slot order — each is applied to the clock the previous one + * left behind. Every region is exactly identity at `weight = 0`, so the fold + * over four slots with none live is four `mix`es by zero, and with one live it + * is the single bend you asked for. Overlapping two regions is well defined but + * is not a designed case; a stasis inside a rewind wins, because a rate of zero + * discards whatever it is handed. + * + * ## Cost + * + * `uTimeRegionCount` is `0` unless a chrono ability is standing. Both entry + * points open on it, so the cost of the entire mechanism in the fifty abilities + * that never heard of it is **one uniform float compare and a branch that every + * fragment in the draw takes the same way**. The uniform boxes themselves are + * in `sharedUniforms()`, but three.js only uploads uniforms the compiled + * program declares, so a shader that does not inject this chunk uploads none of + * them. + * + * ## Using it + * + * Inject `timeWarpGLSL` (it carries its own uniform declarations behind an + * include guard) and replace your clock: + * + * ```glsl + * float t = warpedTime(uTime, vWorldPos) - uBirth; + * ``` + * + * That is the whole integration for any shader whose position does not depend + * on its own clock — ground fields, growth fields, tubes, shells, rim rings. + * + * A shader whose position *is* a function of the clock (a particle) cannot use + * `warpedTime`, because probing the field at the bent position is a feedback + * loop that thaws its own freeze: the frozen particle sits still, the world + * clock moves on, and one frame later the probe is being asked about a position + * that the particle only has *because* it is frozen. Such a shader must write + * its own loop over the slots and probe each region at the position the body + * had **when that region locked** — see `particles/ParticleSystem.js`, which + * does exactly that with `timeRegionFalloff` and `timeRegionClock`. + * + * ## Editing this file + * + * No backticks in the GLSL comments below: the source is one template literal + * and a stray backtick ends it, reporting as a JS syntax error pointing into + * the middle of a shader. Reserved words to stay away from while you are in + * here: `packed`, `flat`, `sample`, `filter`, `input`, `output`. Uniform arrays + * may be indexed **only by a loop counter**, which is why both loops below run + * to the compile-time constant and break on the live count rather than looping + * to the live count directly. + */ +export const timeWarpGLSL = /* glsl */ ` +#ifndef TIMEWARP_LIB_INCLUDED +#define TIMEWARP_LIB_INCLUDED + +#define MAX_TIME_REGIONS ${MAX_TIME_REGIONS} + +uniform float uTimeRegionCount; +uniform vec4 uTimeRegion[MAX_TIME_REGIONS]; // xyz centre (m), w outer radius (m) +uniform vec4 uTimeRegionWarp[MAX_TIME_REGIONS]; // x strength, y inner/outer, z hold (s), w rate + +/* + * How strongly one region holds a point. 0 outside, strength at the core. + * + * Both vec4s arrive as parameters rather than being fetched in here, because a + * function cannot index a uniform array by its own argument in ESSL 1.00 — the + * caller has to be a loop, and the loop has to do the fetch. + * + * The inner radius is pushed a millimetre below the outer one before the + * smoothstep sees it. smoothstep(a, a, x) divides by zero, and a feather of + * exactly 1 with a small radius lands there. + */ +float timeRegionFalloff(vec3 worldPos, vec4 region, vec4 warp) { + float outer = max(region.w, 1e-4); + float inner = min(clamp(warp.y, 0.0, 1.0) * outer, outer - 1e-3); + float d = distance(worldPos, region.xyz); + return clamp(warp.x, 0.0, 1.0) * (1.0 - smoothstep(inner, outer, d)); +} + +/* + * Bend one clock by one region. Identity at weight 0 and at rate 1. + * warp.z is the hold instant, warp.w the rate. + */ +float timeRegionClock(float clock, vec4 warp, float weight) { + return mix(clock, warp.z + (clock - warp.z) * warp.w, weight); +} + +/* + * The clock a point at worldPos lives on. Only valid where the position does + * not itself depend on the answer — see the header. + */ +float warpedTime(float clock, vec3 worldPos) { + if (uTimeRegionCount < 0.5) return clock; + float bent = clock; + for (int i = 0; i < MAX_TIME_REGIONS; i++) { + if (float(i) >= uTimeRegionCount) break; + vec4 region = uTimeRegion[i]; + vec4 warp = uTimeRegionWarp[i]; + bent = timeRegionClock(bent, warp, timeRegionFalloff(worldPos, region, warp)); + } + return bent; +} + +/* + * How held a point is, 0..1, strongest region wins. + * + * This is what lets a stasis field be seen at all. The ability draws almost + * nothing; every other shader in the frame can desaturate, add a pale rim, or + * kill its own flicker by this number, and the bubble appears as an absence of + * motion with an edge you can find. + */ +float timeRegionWeight(vec3 worldPos) { + if (uTimeRegionCount < 0.5) return 0.0; + float held = 0.0; + for (int i = 0; i < MAX_TIME_REGIONS; i++) { + if (float(i) >= uTimeRegionCount) break; + held = max(held, timeRegionFalloff(worldPos, uTimeRegion[i], uTimeRegionWarp[i])); + } + return held; +} + +#endif +`; diff --git a/src/ui/Editor.js b/src/ui/Editor.js index d5e5f97..51b7da6 100644 --- a/src/ui/Editor.js +++ b/src/ui/Editor.js @@ -1,5 +1,7 @@ import GUI from 'lil-gui'; import { settings, CAST_ANIMATIONS } from '../config/settings.js'; +import { ABILITY_SCHEMAS } from '../config/abilities/index.js'; +import { abilitiesBySchool } from '../abilities/registry.js'; import { PresetManager } from './PresetManager.js'; /** @@ -16,7 +18,205 @@ import { PresetManager } from './PresetManager.js'; * silhouette of a frozen eruption and the shape of a frozen bolt are the things * worth tuning, and both abilities re-resolve themselves from these values on a * zero-length frame. + * + * ## A hundred abilities + * + * The stage furniture — presets, globals, the aim arrow, the far-cast circle, + * the environment, post, camera and character — is still hand-written here, + * because there is one of each and each is its own argument about what matters. + * + * The abilities are not. Six hand-written `_buildX()` methods were fine at six + * and would have been sixteen thousand lines at a hundred, every one of them a + * second place for a settings key to be forgotten. They are gone; + * `_buildAbility()` walks the schema that ships beside each settings block + * (`config/abilities/.js`) and builds the same folders out of it. The six + * shipped abilities produce a control set identical to the one the methods + * produced — same folders, same order, same labels, same ranges — because the + * schemas were lifted from those methods rather than re-derived. + * + * Four things make the panel survive a hundred entries: + * + * - **Schools nest.** One top-level folder per school, ability folders inside + * it. Collapsed, the panel is fifteen rows plus the eight fixed sections. + * - **Ability folders are built on first open.** See below — this is the one + * change the roster's second half forced. + * - **Search.** Type in the box under the title and the whole tree filters to + * controllers whose label matches, opening the folders that hold them. + * Clearing it puts every folder back the way you left it. + * - **Jump.** `focusAbility(id)` opens the selected ability's folder and + * scrolls it into view; `App` calls it when the player changes slot and the + * ⤓ button in the header strip calls it again on demand. Scrolling past a + * hundred folders to find the one you are casting is not a UI. + * + * ## Why the ability folders are lazy + * + * They were not, and at fifty abilities that was defensible. Measured in + * Chrome on an M-series laptop, against the real schemas: + * + * | | 50 abilities | 100 abilities | + * | --- | --- | --- | + * | `new Editor()` | 152 ms | **597 ms** | + * | …including first layout | 386 ms | **683 ms** | + * | controllers | 8,555 | 16,937 | + * | DOM nodes | 54,292 | **107,472** | + * | one keystroke in the search box | 11 ms | **48 ms** | + * + * Six hundred milliseconds is not a slow panel, it is a *stalled boot* — it + * happens on the main thread while the loading bar is up and nothing else can + * run — and a hundred thousand permanently-resident DOM nodes tax every style + * recalculation the page ever does afterwards, including the HUD's. All of it + * to build controls for ninety-nine abilities nobody has opened. + * + * So `_buildSchools()` now creates the *folders* — which is what you have to + * see to navigate — and parks the descriptor in `_pending`. The controls are + * built the first time the folder is opened, in the `onOpenClose` handler, + * synchronously inside the click so lil-gui's open animation still measures + * the right height. One ability costs about six milliseconds. + * + * The thing that nearly sank the idea is that **search has to see controls + * that do not exist**. Typing `mist` and getting only the two abilities you + * happen to have opened is worse than a slow panel; it is a lying one. The fix + * is that the searchable text does not come from the controllers at all — it + * comes from `Editor.plan()`, the same DOM-free function that decides what the + * controllers *will be*, run once over every schema on the first keystroke + * (18 ms for a hundred abilities) and cached. A query is matched against that + * index, the abilities it names are built, and the ordinary DOM filter then + * runs over a tree that now contains them. + * + * With a budget and a delay, because a one-character query names every ability + * in the game and building all hundred to answer it would put back the exact + * stall this removed — and because a 50 ms hitch between two characters of a + * word is what "the panel is slow" actually means to the person typing. The + * keystroke filters and lists; the building happens 140 ms after the typing + * stops. See `SEARCH_BUILD_LIMIT`. */ + +/* ------------------------------------------------------------------ */ +/* School marks */ +/* ------------------------------------------------------------------ */ +/** + * One leading character per school. + * + * Not the sigils. `ui/glyphs/` holds 100×100 stroke SVGs and lil-gui writes its + * folder titles with `textContent`, so markup is not on offer — the editor gets + * a single glyph from the font instead, chosen to read at 11px on a dark + * background and to be told apart from its neighbours at a glance. + * + * Frost and storm keep the marks their hand-written folders had (`❄`, `⚡`), and + * arcane keeps Nova Beam's `✦`. Flame does not: `☄` is drawn as a hairline at + * this size and all but disappears against the panel, so the school takes the + * solid `✷` instead. The two zone abilities give up their own marks (`◈`, `❆`) + * and inherit their school's, which is the point of grouping by school — + * fifteen marks the eye can learn, not a hundred it cannot. + * + * The six later schools are all drawn from the BMP's geometric-shape and + * mathematical blocks on purpose. The obvious candidates were the pictographs + * — `⚒` for forge, `☀` for lumen, `⚙` for gearlock — and every one of them is + * an emoji-presentation codepoint on macOS and Windows both: the browser + * substitutes a colour emoji font, the glyph arrives at twice the line height + * in full colour, and the folder title jumps a pixel. Monochrome outline + * characters do not have that problem. Hive would obviously like `⬡`, which is + * stone's `⬢` with the fill taken out and indistinguishable from it at 11px, + * so it takes `※` — many marks read as one, which is the school. + */ +const SCHOOL_MARK = Object.freeze({ + frost: '❄', + flame: '✷', + storm: '⚡', + stone: '⬢', + verdant: '✿', + void: '◍', + arcane: '✦', + blood: '✜', + aether: '❂', + tide: '≋', // three waves + forge: '▤', // a brushed plate + lumen: '☼', // a sun that is not an emoji + ink: '▮', // the slab, and the stroke it makes + chrono: '◷', // a quarter gone + hive: '※' // many marks, one shape +}); + +/** A school with no mark still gets a bullet, so titles stay aligned. */ +const DEFAULT_MARK = '◇'; + +/* ------------------------------------------------------------------ */ +/* Inference tables */ +/* ------------------------------------------------------------------ */ + +/** `#rrggbb`, the only colour spelling the settings tree uses. */ +const HEX = /^#[0-9a-f]{6}$/i; + +/** Key names that name a *quantity of things*, and therefore step by one. */ +const COUNTED = + /(count|facets|octaves|steps|strands|arms|spokes|ticks|dashes|shards|sparks|debris|embers|motes|rings|coils|tendrils|arcs|branches|cuts|craters|plates|segments|points|chunks)$/i; + +/** + * Key names that are a *measurement*, whatever their value happens to be. + * + * These veto the "a whole number of eight or more is a count" rule below. A + * range of 18 metres and a lifetime of 12 seconds are both integers today and + * neither wants a step of one — `range` in particular is the dial most likely + * to be dragged, and a whole-metre track makes it useless. + */ +const CONTINUOUS = + /(range|radius|height|width|length|size|scale|time|life|lifetime|delay|duration|speed|distance|intensity|opacity|gravity|angle|depth|curve|falloff|power)$/i; + +/** Key names somebody will want to run backwards, so their track is symmetric. */ +const SIGNED = /(speed|spin|crawl|twist|sag|lean|drift|wander|scroll|bias|flow|tilt|shear|sweep)$/i; + +/** + * How many unbuilt abilities one search is allowed to build, and how much of a + * query has to be typed before it is allowed to build any. + * + * A search that matches four abilities should open into their controls, the + * way it always has — that is the whole value of the box. A search that + * matches ninety is a different question: three thousand visible controllers + * is not a result, it is the panel again, and building them costs the + * half-second the laziness was introduced to remove. + * + * Past the budget the matching abilities are still *listed* — their folders + * show, closed, in the filtered tree — and clicking one builds it and drops + * you into its matching controls. So a broad query degrades into "here are the + * eleven abilities with a `mist` control", which is a reasonable answer to a + * broad query, rather than into a stall. + * + * The minimum length exists because a two-character query is a prefix on the + * way somewhere rather than a question, and the eight abilities `mi` names are + * not the eight `mist` names — building for both is building sixteen to answer + * one. + * + * The delay is why typing does not stutter at all. Building costs about six + * milliseconds an ability, so a budget of eight is a ~50 ms hitch, and a hitch + * in the middle of a word is exactly the thing people describe as "the panel + * is slow". So the keystroke does the cheap half — filter what exists, list + * what does not — and the expensive half runs once the typing stops. 140 ms is + * comfortably longer than a fast typist's inter-key gap and short enough that + * it reads as the panel settling rather than as a wait. + */ +const SEARCH_BUILD_LIMIT = 8; +const SEARCH_BUILD_MIN = 3; +const SEARCH_BUILD_DELAY = 140; // ms of quiet before a search builds anything + +/** ms after a folder is clicked before its animated height is released. */ +const UNCLAMP_DELAY = 340; // lil-gui's transition is 300 ms + +/** Readable ceilings: one of these × a power of ten. */ +const NICE = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]; + +/** Readable steps. Coarser than the ceilings on purpose — 2.5 is not a step. */ +const NICE_STEP = [1, 2, 5, 10]; + +/** Smallest of `list` × a power of ten that is ≥ `x`. `x` > 0. */ +function niceUp(x, list) { + const decade = 10 ** Math.floor(Math.log10(x)); + for (const n of list) { + const candidate = n * decade; + if (candidate >= x - 1e-12) return Number(candidate.toPrecision(6)); + } + return Number((10 * decade).toPrecision(6)); +} + export class Editor { /** * @param {object} hooks { onClear, onToast } @@ -30,20 +230,37 @@ export class Editor { this._presetState = { name: 'My preset', selected: this.presets.names[0] ?? '' }; + /** school id → its top-level folder. */ + this.schoolFolders = new Map(); + /** ability id → { school, folder, ability }, the pair `focusAbility` opens. */ + this.abilityFolders = new Map(); + /** folder → the descriptor whose controls it has not built yet. */ + this._pending = new Map(); + /** the `.lil-title` element of each ability folder → that folder. */ + this._byTitle = new Map(); + /** ability id → every label under it, lower case. Built on first search. */ + this._searchIndex = null; + /** Unbuilt ability folders the running query names but could not afford. */ + this._hits = new Set(); + /** Pending "the typing stopped, finish the search" timer. */ + this._settleTimer = 0; + + this._activeAbility = null; + /** The `{ school, folder }` pair `focusAbility` last opened. */ + this._focused = null; + /** Folder collapse state as it was before the current search. */ + this._preSearch = null; + this._buildPresets(); this._buildGlobal(); this._buildAim(); this._buildZone(); - this._buildIce(); - this._buildThunder(); - this._buildMeteor(); - this._buildBeam(); - this._buildSnare(); - this._buildGlacier(); + this._buildSchools(); this._buildEnvironment(); this._buildPost(); this._buildCamera(); this._buildCharacter(); + this._buildTools(); // Everything starts collapsed, top-level folders included. There are enough // controls here that any folder left open pushes the rest off the screen, @@ -66,8 +283,8 @@ export class Editor { * beam and the snare should not be cast the same way. `App` reads the value * at the moment of the cast, so switching it applies to the very next click. */ - static castAnimation(folder, object) { - return folder.add(object, 'castAnim', CAST_ANIMATIONS).name('cast animation'); + static castAnimation(folder, object, label = 'cast animation') { + return folder.add(object, 'castAnim', CAST_ANIMATIONS).name(label); } /** @@ -97,6 +314,633 @@ export class Editor { this.gui.show(!this._hidden); } + /* ------------------------------------------------------------------ */ + /* the generic ability builder */ + /* ------------------------------------------------------------------ */ + + /** + * A schema and a settings block, resolved into a flat build list. + * + * Pure, static and DOM-free on purpose: this is the one place that decides + * *what controls an ability has*, so the headless harness (`npm run check`) + * can ask the same question the panel asks without a WebGL context or a + * `document`. `_buildAbility()` is then a switch over the answer. + * + * Each step is `{ folder, kind, key, label }` plus what its kind needs: + * + * | kind | extra | drawn as | + * | --- | --- | --- | + * | `range` | `min`, `max`, `step` | a slider | + * | `color` | — | a picker | + * | `gradient` | `prefix` | a sub-folder of four pickers | + * | `castAnim` | — | the clip dropdown | + * | `boolean` | — | a checkbox | + * | `string` | — | a text field | + * | `missing` | — | nothing; the schema names a key the block does not have | + * + * `folder` is the path as an array, so `'The fire trail/Silhouette'` arrives + * as `['The fire trail', 'Silhouette']` and the builder does not parse + * strings twice. + * + * `missing` steps are emitted rather than swallowed. A schema that has drifted + * off its block is a real defect — it is how a control silently disappears — + * and this is the seam a checker can grep for. + * + * @param {object} schema `config/abilities/.js`'s `Schema`, or undefined + * @param {object} block the live settings block + * @returns {Array} build steps, in build order + */ + static plan(schema, settingsBlock) { + // Both halves are allowed to be absent: a registry entry whose settings + // module has not landed yet should give an empty folder, not take the whole + // panel — and with it the boot — down with a TypeError. + const block = settingsBlock ?? {}; + const steps = []; + const seen = new Set(); + + for (const [path, entries] of Object.entries(schema ?? {})) { + const folder = path + .split('/') + .map((part) => part.trim()) + .filter(Boolean); + for (const entry of entries) steps.push(Editor._step(folder, entry, block, seen)); + } + + // Anything the schema forgot. A block key that no folder mentions still gets + // a control, filed at the bottom under "More", so the panel is never a *lie* + // about what is in `settings[id]` — only untidy about it. An ability whose + // schema has not been written yet renders entirely out of this branch, which + // is what lets a registry entry land before its layout does. + const more = []; + for (const key of Object.keys(block)) { + if (!seen.has(key)) more.push(Editor._step(['More'], key, block, seen)); + } + + return steps.concat(more); + } + + /** + * One schema entry → one build step. See `config/abilities/index.js` for the + * entry forms; the shape is read by `length`, never by sniffing the value. + */ + static _step(folder, entry, block, seen) { + if (typeof entry === 'string') return Editor._infer(folder, entry, entry, block, seen); + + const [key] = entry; + + // `['colorMist*', 'Mist colour']` — the four lifetime stops as one entry. + if (typeof key === 'string' && key.endsWith('*')) { + const prefix = key.slice(0, -1); + const stops = ['A', 'B', 'C', 'D'].map((suffix) => `${prefix}${suffix}`); + // A quartet with a stop missing is not a gradient. The remaining stops are + // deliberately *not* marked seen, so they fall through to "More" as plain + // pickers rather than disappearing along with the group. + if (stops.some((stop) => !(stop in block))) { + return { folder, kind: 'missing', key, label: entry[1] ?? prefix }; + } + for (const stop of stops) seen.add(stop); + return { folder, kind: 'gradient', key, prefix, label: entry[1] ?? prefix }; + } + + // `['key', min, max, step]` / `['key', min, max, step, 'label']`, and the + // three-element form for the rare dial that wants its own ends but not its + // own precision. + if (entry.length >= 3) { + const [, min, max, step, label] = entry; + seen.add(key); + if (!(key in block)) return { folder, kind: 'missing', key, label: label ?? key }; + return { + folder, + kind: 'range', + key, + label: label ?? key, + min, + max, + step: step ?? niceUp((max - min) / 400, NICE_STEP) + }; + } + + // `['key', 'label']` — the type comes from the value. + return Editor._infer(folder, key, entry[1] ?? key, block, seen); + } + + /** A key with no authored range: the type from its value, the range guessed. */ + static _infer(folder, key, label, block, seen) { + seen.add(key); + if (!(key in block)) return { folder, kind: 'missing', key, label }; + + const value = block[key]; + + if (key === 'castAnim') return { folder, kind: 'castAnim', key, label }; + if (typeof value === 'boolean') return { folder, kind: 'boolean', key, label }; + if (typeof value === 'string') { + return { folder, kind: HEX.test(value) ? 'color' : 'string', key, label }; + } + if (typeof value !== 'number' || !Number.isFinite(value)) { + return { folder, kind: 'missing', key, label }; + } + return { folder, kind: 'range', key, label, ...Editor.inferRange(key, value) }; + } + + /** + * A range for a key nobody wrote a tuple for. + * + * This is the fallback that makes a schema *incomplete* rather than *wrong*: + * a slider added to a settings block during a tuning session appears in the + * panel on the next reload without anyone touching the layout. It guesses + * from two things and nothing else — the magnitude of the shipped value and + * the shape of the key's name. + * + * - **The ceiling is three times the shipped value**, rounded up to a + * readable number. Three because a control pinned to its own maximum can + * only ever come down, and ten would leave the interesting quarter of the + * track inside the first three millimetres. + * - **Counts step by one.** A key is a count when its name ends in one of + * the plural nouns this project actually uses (`…Count`, `…Facets`, + * `…Strands`, `…Shards`), or when the shipped value is a whole number of + * eight or more *and* the name is not a measurement — `range: 18` is + * eighteen metres, not eighteen of anything, and a whole-metre track on + * the most-dragged dial in the panel would be a poor joke. + * - **A negative default gets a negative track** — gravity is the whole + * reason — and a name ending in a signed word (`…Speed`, `…Spin`, + * `…Crawl`, `…Lean`) gets a symmetric one. + * - **A zero default carries no magnitude**, so it gets `0..1`. That is the + * case where the guess is worst, and the fix is a tuple in the schema. + * + * A guessed range is never as good as an authored one, and none of the six + * shipped abilities uses it: all ~900 of their controls carry their own + * min/max/step, arrived at by dragging rather than by rounding the default. + * The first thing to do with a control that turns up in "More" is to give it + * a folder and a tuple. + * + * @returns {{min:number, max:number, step:number}} + */ + static inferRange(key, value) { + const magnitude = Math.abs(value); + const counted = + Number.isInteger(value) && (COUNTED.test(key) || (magnitude >= 8 && !CONTINUOUS.test(key))); + const ceiling = magnitude > 0 ? niceUp(magnitude * 3, NICE) : 1; + + const symmetric = SIGNED.test(key); + const min = value < 0 ? -ceiling : symmetric ? -ceiling : 0; + const max = value < 0 && !symmetric ? 0 : ceiling; + + const step = counted ? 1 : niceUp((max - min) / 400, NICE_STEP); + return { min, max, step }; + } + + /** + * Build one ability's folder from its schema. + * + * Folders are created on first mention and memoised by path, so the schema + * can nest with a `/` in the key and the map underneath stays flat. A step + * whose kind is `missing` draws nothing — the schema named a key the block no + * longer has, and a controller bound to `undefined` is worse than a gap. + */ + _buildAbility(root, ability) { + const block = ability.settings ?? {}; + const plan = Editor.plan(ABILITY_SCHEMAS[ability.id], block); + const folders = new Map([['', root]]); + + for (const step of plan) { + if (step.kind === 'missing') continue; + const folder = this._folderAt(root, folders, step.folder); + + switch (step.kind) { + case 'range': + Editor.range(folder, block, step.key, step.min, step.max, step.step, step.label); + break; + case 'color': + folder.addColor(block, step.key).name(step.label); + break; + case 'gradient': + Editor.gradient(folder, block, step.prefix, step.label); + break; + case 'castAnim': + Editor.castAnimation(folder, block, step.label); + break; + default: + // boolean and string: lil-gui picks the widget off the value's type. + folder.add(block, step.key).name(step.label); + break; + } + } + + return root; + } + + /** `['The fire trail', 'Silhouette']` → the folder, creating what is missing. */ + _folderAt(root, folders, path) { + let folder = root; + let key = ''; + for (const name of path) { + key = key ? `${key}/${name}` : name; + let child = folders.get(key); + if (!child) { + child = folder.addFolder(name); + folders.set(key, child); + } + folder = child; + } + return folder; + } + + /** + * One folder per school, in registry order, with its abilities inside it — + * as empty folders, filled in on first open. + * + * The nesting is the whole reason the panel still works at a hundred: a + * school folder collapses everything under it, so the default view is + * fifteen rows and the count in each title says how much is behind it. A + * school with no abilities yet does not appear at all; `abilitiesBySchool()` + * drops it, and an empty folder is a row that promises something. + */ + _buildSchools() { + for (const { school, abilities } of abilitiesBySchool()) { + const mark = SCHOOL_MARK[school.id] ?? DEFAULT_MARK; + const folder = this.gui.addFolder(`${mark} ${school.label} · ${abilities.length}`); + this.schoolFolders.set(school.id, folder); + + for (const ability of abilities) { + const sub = folder.addFolder(`${mark} ${ability.label}`); + this._pending.set(sub, ability); + this._byTitle.set(sub.$title, sub); + this.abilityFolders.set(ability.id, { school: folder, folder: sub, ability }); + } + } + + /* + * Build on the way *into* lil-gui's own click handler, not after it. + * + * The obvious hook is `onOpenClose`, and it is wrong here by one frame. + * lil-gui animates a folder open by scheduling a `requestAnimationFrame` + * that measures `$children.scrollHeight` and transitions to it; children + * that appear after that measurement are inside an element whose height + * has already been decided, and the folder opens to nothing. (Worse: the + * height ends up 0 → 0, no `transitionend` fires, and the inline + * `height: 0px` never gets cleared, so the folder stays empty until it is + * clicked twice.) It is a genuinely confusing failure — the controls exist + * in the DOM, they are simply inside a box of zero height. + * + * A capture-phase listener on the panel root runs before any listener on + * the title itself, so by the time lil-gui starts the animation the + * controls are already there and it measures the real height. A listener + * added to `$title` directly would not do: at the target element, handlers + * run in registration order regardless of the capture flag, and lil-gui + * registered first. + */ + this.gui.domElement.addEventListener( + 'click', + (event) => { + const title = event.target?.closest?.('.lil-title'); + if (!title) return; + const folder = this._byTitle.get(title); + if (folder) this._realise(folder); + this._unclamp(title.parentElement); + }, + true + ); + + // And a fallback for the folders opened from code rather than from a + // click — `open()` has no animation and no measurement, so late children + // are fine there. `focusAbility` realises explicitly anyway; this is here + // so that no future caller can find an empty folder. + this.gui.onOpenClose((changed) => { + if (!changed._closed) this._realise(changed); + }); + } + + /** + * Build an ability folder's controls, if they are not built already. + * + * @param {object} folder a lil-gui folder + * @returns {boolean} whether anything was built + */ + _realise(folder) { + const ability = this._pending.get(folder); + if (!ability) return false; + + // Deleted first: `_buildAbility` creates sub-folders, and a sub-folder is + // created *open*, which re-enters this handler for the child. Without the + // delete the child would look up its parent's descriptor and build the + // whole ability a second time into itself. + this._pending.delete(folder); + this._byTitle.delete(folder.$title); + this._buildAbility(folder, ability); + + // Filling a folder that is *already* open — the programmatic path — leaves + // whatever height a previous animation pinned on it. Clearing the inline + // height lets it size to the content that has just arrived. + if (!folder._closed) { + folder.$children.style.height = ''; + folder.domElement.classList.remove('lil-transition'); + } + + // Everything in this panel starts collapsed — see the constructor. The + // folders that have just appeared were born open, so they are put back to + // the state they would have been in had they existed at boot. + for (const child of folder.foldersRecursive()) { + child.close(); + // A search is running: these folders did not exist when the collapse + // state was snapshotted, so record them or clearing the box leaves them + // expanded behind the restored tree. + if (this._preSearch) this._preSearch.push([child, true]); + } + + return true; + } + + /** + * Undo lil-gui's open animation once it has had its chance. + * + * Not part of the lazy building — this is an older bug that a hundred + * ability folders would have made everyone's problem. lil-gui opens a folder + * by pinning an inline pixel height on its children and clearing that height + * again on `transitionend`. Its measurement is taken one frame after the + * click, and on a folder's *first* open in this panel it frequently comes + * back as zero: the children are still `display: none` under + * `.lil-closed:not(.lil-transition)` at the moment `clientHeight` is read. + * A height of zero animating to zero fires no `transitionend`, so nothing + * ever clears it, and the folder stays open and permanently clamped shut — + * which is why "Presets" (nothing lazy about it) opens empty and needs a + * second click. It is easy to blame that on whatever changed most recently. + * + * Clearing the inline height after the animation window costs nothing when + * the animation worked, and unclamps the folder when it did not. + * + * @param {HTMLElement} element the folder's `.lil-gui` element + */ + _unclamp(element) { + setTimeout(() => { + const children = element?.querySelector(':scope > .lil-children'); + if (!children) return; + element.classList.remove('lil-transition'); + children.style.height = ''; + }, UNCLAMP_DELAY); + } + + /** + * Every control label an ability *will* have, without building any of them. + * + * `Editor.plan()` is the same function `_buildAbility()` walks, so the index + * cannot disagree with the panel about what a control is called — which is + * the failure that makes a lazy tree unsearchable. Built once, on the first + * keystroke rather than at boot, because most sessions never search. + * + * @returns {Map} ability id → its labels, newline-joined + */ + _index() { + if (this._searchIndex) return this._searchIndex; + + this._searchIndex = new Map(); + for (const [id, entry] of this.abilityFolders) { + const words = new Set(); + for (const step of Editor.plan(ABILITY_SCHEMAS[id], entry.ability.settings ?? {})) { + if (step.kind === 'missing') continue; + words.add(String(step.label).toLowerCase()); + for (const part of step.folder) words.add(part.toLowerCase()); + // A gradient is one schema entry and four controllers, and the four are + // named here rather than in the schema. Searching "death" has always + // found every gradient's last stop; it still does. + if (step.kind === 'gradient') { + words.add('birth'); + words.add('early'); + words.add('late'); + words.add('death'); + } + } + this._searchIndex.set(id, [...words].join('\n')); + } + return this._searchIndex; + } + + /* ------------------------------------------------------------------ */ + /* the header strip: search and jump */ + /* ------------------------------------------------------------------ */ + + /** + * The two affordances that make a thousand controllers navigable. + * + * They live in a strip of our own between lil-gui's title bar and its + * scrolling children, which is the only place in the panel that does not + * scroll away. The jump button is *not* inside the title: lil-gui's title is + * itself a ` + `; + } + + _indexCards() { + this.cards.clear(); + for (const card of this.abilityBar.querySelectorAll('.ability-card[data-id]')) { + const list = this.cards.get(card.dataset.id); + if (list) list.push(card); + else this.cards.set(card.dataset.id, [card]); + } + } + + /* ------------------------------------------------------------------ */ + /* Bar interaction */ + /* ------------------------------------------------------------------ */ + + /** + * One delegated listener per event on the bar, not per card: the bar is + * rebuilt on every rebind, and per-card handlers would be eight closures + * dropped on the floor each time. + */ + _bindBar() { + this.abilityBar.addEventListener('pointerdown', (event) => { + event.stopPropagation(); + const card = event.target.closest?.('.ability-card'); + if (!card) return; + + // The label is a separate target: it opens the ability in the editor + // rather than arming it, which is the one bit of the HUD that reaches + // into the tuning UI. Deliberately not on selection — jumping the editor + // every time the player swaps slot is intrusive. + if (event.target.closest('[data-inspect]')) { + if (card.dataset.id) this.onInspect?.(card.dataset.id); + return; + } + + if (card.classList.contains('is-empty')) { + this.onEmptySlot?.(Number(card.dataset.slot)); + return; + } + if (card.dataset.id) this.onAbility?.(card.dataset.id); + }); + + /* ---- drag: from the spellbook, or from another slot ---- */ + + this.abilityBar.addEventListener('dragstart', (event) => { + const card = event.target.closest?.('.ability-card[data-id]'); + if (!card) return; + event.dataTransfer.setData('text/plain', card.dataset.id); + event.dataTransfer.setData('application/x-ability', card.dataset.id); + event.dataTransfer.effectAllowed = 'move'; + card.classList.add('is-dragging'); + }); + + this.abilityBar.addEventListener('dragend', (event) => { + event.target.closest?.('.ability-card')?.classList.remove('is-dragging'); + for (const card of this.abilityBar.children) card.classList.remove('is-drop'); + }); + + this.abilityBar.addEventListener('dragover', (event) => { + const card = event.target.closest?.('.ability-card[data-slot]'); + if (!card) return; + // Without the preventDefault the browser refuses the drop, silently. + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + card.classList.add('is-drop'); + }); + + this.abilityBar.addEventListener('dragleave', (event) => { + event.target.closest?.('.ability-card')?.classList.remove('is-drop'); + }); + + this.abilityBar.addEventListener('drop', (event) => { + const card = event.target.closest?.('.ability-card[data-slot]'); + if (!card) return; + event.preventDefault(); + card.classList.remove('is-drop'); + const id = + event.dataTransfer.getData('application/x-ability') || + event.dataTransfer.getData('text/plain'); + if (id) this.onBind?.(Number(card.dataset.slot), id); + }); + } + + /* ------------------------------------------------------------------ */ + /* Help panel */ + /* ------------------------------------------------------------------ */ + + /** + * The key list, generated. + * + * It was hard-coded, and hard-coded was already wrong the moment the loadout + * became rebindable. Slots print as `letter / digit → name`, two to a row; + * the far casts name themselves in the note underneath rather than the note + * claiming "V and X" forever. + */ + _renderHelp() { + const bound = []; + const zones = []; + for (let slot = 0; slot < SLOT_COUNT; slot++) { + const ability = getAbility(this.loadout.idAt(slot)); + if (!ability) continue; + bound.push( + `${SLOT_KEYS[slot]}${slot + 1} + ${ability.label}` + ); + if (ability.cast === CastShape.ZONE) zones.push(ability.label); + } + + const zoneNote = + zones.length === 0 + ? '' + : `
${this._list(zones)} + ${zones.length > 1 ? 'are far casts' : 'is a far cast'} — aimed with a circle, + not an arrow.
`; + + this.help.innerHTML = ` +
${bound.join('')}
+ ${zoneNote} +
+ Tab or B opens the spellbook — ${ABILITIES.length} spells, + drag one onto a slot to bind it. +
+
Move — aim   Left click — cast
+
Esc / right click — cancel the cast
+
Right drag — orbit   Scroll — zoom
+
+ G editor   P pause   C clear +
+
H hide this   click a slot's name to tune it
+
Paused still applies every editor change.
+ `; } - /** @param {{silent?: boolean}} [options] */ + /** "A", "A and B", "A, B and C". */ + _list(items) { + if (items.length < 2) return items[0] ?? ''; + return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`; + } + + /* ------------------------------------------------------------------ */ + /* State */ + /* ------------------------------------------------------------------ */ + + /** + * Show which ability is in the slot. + * + * An ability with no slot — selected straight out of the spellbook — takes + * over the transient card at the end of the bar, so it still has somewhere to + * pulse and sweep. The moment it is bound to a real slot the transient card + * folds away again. + * + * @param {string} element + * @param {{silent?: boolean}} [options] + */ setElement(element, options = {}) { - for (const [key, card] of this.cards) { - card.classList.toggle('is-active', key === element); + this._selected = element; + const ability = getAbility(element); + + if (ability && !this.loadout.has(element)) { + this.looseCard.hidden = false; + this.looseCard.dataset.id = element; + this.looseCard.style.setProperty('--accent', ability.accent); + this.looseCard.querySelector('[data-loose-glyph]').innerHTML = sigilFor(element); + this.looseCard.querySelector('[data-loose-label]').textContent = ability.label; + this.looseCard.title = `${ability.label} — not bound to a slot`; + this._indexCards(); + } else if (!this.looseCard.hidden) { + this.looseCard.hidden = true; + delete this.looseCard.dataset.id; + this._indexCards(); } - const meta = ELEMENT_META[element]; - if (meta && !options.silent) this.showToast(`${meta.hint} selected`); + + for (const [id, cards] of this.cards) { + for (const card of cards) card.classList.toggle('is-active', id === element); + } + + if (ability && !options.silent) this.showToast(`${ability.label} selected`); } /** Highlight the slot while a cast is armed. */ @@ -105,24 +335,32 @@ export class HUD { this.abilityBar.classList.toggle('is-armed', armed); } + /** The spellbook is up: dim the panels it would otherwise fight with. */ + setBookOpen(open) { + this.root.classList.toggle('is-book-open', open); + } + /** - * Drive one slot's cooldown sweep. Cooldowns are per ability, so this is - * called once per element each frame. + * Drive one ability's cooldown sweep. Cooldowns are per ability, so this is + * called once per registered id each frame — a `Map` miss for the forty-odd + * that are not on the bar, which is the cheapest thing in the loop. * * @param {string} element * @param {number} remaining seconds left * @param {number} total the full cooldown, for the sweep angle */ setCooldown(element, remaining, total) { - const card = this.cards.get(element); - if (!card) return; + const cards = this.cards.get(element); + if (!cards) return; const ratio = Math.max(0, Math.min(1, remaining / Math.max(total, 0.001))); // Only touch the DOM when the sweep visibly moves. if (Math.abs(ratio - (this._cooldownShown.get(element) ?? -1)) < 0.01) return; this._cooldownShown.set(element, ratio); - card.style.setProperty('--cooldown', ratio); - card.classList.toggle('is-cooling', ratio > 0.001); + for (const card of cards) { + card.style.setProperty('--cooldown', ratio); + card.classList.toggle('is-cooling', ratio > 0.001); + } } setPaused(paused) { @@ -161,6 +399,11 @@ export class HUD { this.stats.spikes.textContent = info.spikes; this.stats.calls.textContent = info.calls; } + + dispose() { + clearTimeout(this._toastTimer); + this._unsubscribe?.(); + } } /** Boot screen helper. */ diff --git a/src/ui/Loadout.js b/src/ui/Loadout.js new file mode 100644 index 0000000..6756faa --- /dev/null +++ b/src/ui/Loadout.js @@ -0,0 +1,256 @@ +import { EventEmitter } from '../utils/EventEmitter.js'; +import { ABILITIES, getAbility } from '../abilities/registry.js'; + +/** + * The loadout — which eight abilities are on the bar, and in what order. + * + * Fifty abilities do not fit on a keyboard, so the keyboard stops being the + * roster and becomes a *view* of it. Eight slots, each holding an ability id or + * `null`; the spellbook rebinds them; the binding is persisted. Everything that + * used to index `ELEMENTS` by slot number now goes through here. + * + * ## Why the model is separate from the HUD + * + * Three consumers need the same eight values and none of them owns it: the HUD + * draws them, the input manager binds keys from them, and the spellbook writes + * them. The first cut hung the array off `HUD` and had the spellbook reach in + * to mutate it, which worked right up until the input table needed to rebuild + * on a rebind and there was nothing to listen to. So this is a tiny model with + * one event: `change`, fired after any mutation, with no arguments — the whole + * loadout is eight values and every listener re-reads all of them. + * + * ## What is a slot and what is a key + * + * `SLOT_KEYS[i]` is the letter for slot `i`, and digit `i + 1` mirrors it. The + * letters are the ones the sandbox shipped with — `Q E R F V X` — plus `Z` and + * `T` for the two new slots. `C` is deliberately *not* a slot: it clears the + * scene and has done since the first build, and a demo where the muscle memory + * for "clear" suddenly casts something is a worse demo. + */ + +/** localStorage key. Versioned: a v2 slot count must not read a v1 array. */ +const STORAGE_KEY = 'frost-sandbox.loadout.v1'; + +/** + * Slot letters, in slot order. Digits `1..8` mirror them. + * + * The first six match the `key` field on the six registry descriptors that + * shipped; `defaultLoadout()` asserts that correspondence rather than assuming + * it, so moving a letter in the registry moves the ability with it. + */ +export const SLOT_KEYS = Object.freeze(['Q', 'E', 'R', 'F', 'V', 'X', 'Z', 'T']); + +/** How many slots the bar has. Derived so the two can never disagree. */ +export const SLOT_COUNT = SLOT_KEYS.length; + +/** + * What goes in the slots no registry entry claims with a `key`. + * + * Slots 7 and 8 (`Z` and `T`) are empty in the shipping six-ability build and + * fill themselves in as the roster lands — the ids below are the first two of + * the forty-four in `docs/ROSTER.md`. An id that is not registered yet is + * skipped silently, which is why this list can name abilities that do not + * exist: today it resolves to nothing and the slots read "empty", and the day + * `rime` is registered the slot picks it up with no edit here. + */ +const DEFAULT_FILL = Object.freeze(['rime', 'hail']); + +/** + * The loadout a fresh browser gets: every registry entry that names a `key` + * goes to that key's slot, then `DEFAULT_FILL` mops up what is left. + * + * Derived rather than written down, because a hand-written default is a second + * place the letters live and the two drift the first time somebody swaps `V` + * and `X` in the registry. + * + * @returns {(string|null)[]} exactly `SLOT_COUNT` entries + */ +export function defaultLoadout() { + const slots = new Array(SLOT_COUNT).fill(null); + const placed = new Set(); + + for (const ability of ABILITIES) { + if (!ability.key) continue; + const slot = SLOT_KEYS.indexOf(ability.key.toUpperCase()); + if (slot < 0 || slots[slot]) continue; + slots[slot] = ability.id; + placed.add(ability.id); + } + + let fill = 0; + for (let slot = 0; slot < SLOT_COUNT; slot++) { + if (slots[slot]) continue; + while (fill < DEFAULT_FILL.length) { + const id = DEFAULT_FILL[fill++]; + if (placed.has(id) || !getAbility(id)) continue; + slots[slot] = id; + placed.add(id); + break; + } + } + + return slots; +} + +/* ------------------------------------------------------------------ */ + +export class Loadout extends EventEmitter { + constructor() { + super(); + /** @type {(string|null)[]} slot index → ability id. Read through `idAt`. */ + this._slots = this._read(); + } + + /* ---- reading ---------------------------------------------------- */ + + /** + * The raw array. Treat it as read-only — mutating it skips the persist and + * the `change` event, and the HUD will happily draw a stale bar for the rest + * of the session. + */ + get slots() { + return this._slots; + } + + /** + * The slot letters, in slot order. + * + * Read through the instance rather than importing `SLOT_KEYS` directly, so + * the day the letters themselves become rebindable there is one place that + * has to change and every consumer already asks the right object. + */ + get keys() { + return SLOT_KEYS; + } + + /** How many slots actually hold something. */ + get filled() { + let count = 0; + for (const id of this._slots) if (id) count++; + return count; + } + + /** Ability id in a slot, or `null` — including for an out-of-range index. */ + idAt(slot) { + return this._slots[slot] ?? null; + } + + /** The letter bound to a slot, or `''`. */ + keyAt(slot) { + return SLOT_KEYS[slot] ?? ''; + } + + /** Which slot holds this id, or `-1`. */ + slotOf(id) { + return this._slots.indexOf(id); + } + + /** The letter this ability answers to, or `''` when it is not on the bar. */ + keyFor(id) { + return this.keyAt(this.slotOf(id)); + } + + has(id) { + return this.slotOf(id) >= 0; + } + + /** First slot holding nothing, or `-1` when the bar is full. */ + firstEmpty() { + return this._slots.indexOf(null); + } + + /* ---- writing ---------------------------------------------------- */ + + /** + * Put an ability in a slot. + * + * An ability already on the bar **moves** rather than duplicating: the two + * slots swap contents. Two slots holding the same spell is never what the + * drag meant, and the alternative — silently refusing the drop — reads as a + * broken drag target. + * + * @param {number} slot + * @param {string|null} id a registered ability id, or null to empty the slot + * @returns {boolean} whether anything changed + */ + bind(slot, id) { + if (slot < 0 || slot >= SLOT_COUNT) return false; + if (id !== null && !getAbility(id)) return false; + if (this._slots[slot] === id) return false; + + const from = id === null ? -1 : this._slots.indexOf(id); + if (from >= 0) this._slots[from] = this._slots[slot]; + this._slots[slot] = id; + + this._commit(); + return true; + } + + /** Empty a slot. */ + clearSlot(slot) { + return this.bind(slot, null); + } + + /** Exchange two slots' contents. Used by dragging one bar card onto another. */ + swap(a, b) { + if (a === b) return false; + if (a < 0 || b < 0 || a >= SLOT_COUNT || b >= SLOT_COUNT) return false; + const held = this._slots[a]; + this._slots[a] = this._slots[b]; + this._slots[b] = held; + this._commit(); + return true; + } + + /** Back to the registry-derived default. */ + reset() { + this._slots = defaultLoadout(); + this._commit(); + return true; + } + + /* ---- persistence ------------------------------------------------- */ + + /** + * Read the stored loadout, repairing anything that no longer makes sense. + * + * Stored ids are validated against the registry one by one instead of the + * array being accepted or rejected whole: a saved bar that names one ability + * which has since been renamed should lose that slot, not the other seven. + * Any failure at all — no storage, bad JSON, wrong shape — falls back to the + * default, because a spellbook demo that will not boot in private browsing is + * not a demo. + */ + _read() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return defaultLoadout(); + const stored = JSON.parse(raw); + if (!Array.isArray(stored)) return defaultLoadout(); + + const slots = new Array(SLOT_COUNT).fill(null); + const placed = new Set(); + for (let slot = 0; slot < SLOT_COUNT; slot++) { + const id = stored[slot]; + if (typeof id !== 'string' || placed.has(id) || !getAbility(id)) continue; + slots[slot] = id; + placed.add(id); + } + return slots; + } catch (error) { + console.warn('[Loadout] could not read the stored loadout', error); + return defaultLoadout(); + } + } + + _commit() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this._slots)); + } catch (error) { + // Private browsing, a full quota, a file:// origin. The bar still works + // for this session; it just will not survive a reload. + console.warn('[Loadout] could not persist the loadout', error); + } + this.emit('change'); + } +} diff --git a/src/ui/Spellbook.js b/src/ui/Spellbook.js new file mode 100644 index 0000000..a0c52d9 --- /dev/null +++ b/src/ui/Spellbook.js @@ -0,0 +1,803 @@ +import { abilitiesBySchool, getAbility, getSchool, ABILITIES } from '../abilities/registry.js'; +import { CastShape } from '../config/castShape.js'; +import { sigilFor } from './glyphs/index.js'; +import { SLOT_COUNT } from './Loadout.js'; + +/** + * The spellbook — a full-screen browser for the whole roster. + * + * A hundred abilities do not fit on a bar, so the bar stops being the roster + * and becomes a shortcut into it. `Tab` (or `B`) opens this; a card is one + * ability, grouped under its school. + * + * ## The three ways in, and why there are three + * + * A hundred of anything is only navigable if the user can arrive from whichever + * direction they already know the answer from: + * + * - **Scanning** — one section per school with sticky headers, so the heading + * that tells you where you are never scrolls off. The first cut used nine + * fixed columns side by side, which is a lovely diagram and a terrible + * interface: with an uneven roster eight of the columns are half empty, the + * cards shrink to fit the narrowest one, and the blurb — the only thing that + * distinguishes two frost slots — gets clipped to two words. + * - **Jumping** — the school rail across the top, which scrolls to a section, + * tracks the one you are reading, and doubles as the roster's shape at a + * glance. `PgUp` / `PgDn` walk the same sections from the keyboard. + * - **Typing** — a fuzzy filter over name, school and blurb. No focus ritual: + * the book takes the keyboard while it is open, so you just type. + * + * ## What fifteen schools and a hundred cards changed + * + * Nothing structural, which was the point of the audit — the panel was built + * for fifty and scaled to a hundred with four changes, none of them visual: + * + * - **The rail wraps.** Fifteen chips do not fit across a 1420px header. It + * was already an `overflow-x: auto` strip with the scrollbar hidden, which + * at nine chips never scrolled and at fifteen silently hides Hive off the + * right-hand edge with nothing on screen to say so. It now wraps to two + * rows; the chips are unchanged and the rail is still the roster's shape. + * - **The rail follows the scroll.** With fifteen sections the header you are + * under is no longer obvious from the scrollbar, so an `IntersectionObserver` + * lights the chip for whichever section owns the top of the body. It is the + * same treatment `:hover` already gave a chip, so nothing new was designed. + * - **The filter walks arrays, not the DOM.** Every keystroke used to run + * `querySelectorAll('.spell')` once per section plus once over the body — + * a hundred and twenty element visits and two live NodeLists per character + * typed. The card lists are now sliced once at build time, and a card's + * `hidden` flag is only written when it actually changes, which is what + * keeps a hundred cards off the style-recalculation path. + * - **Nothing is virtualised.** A hundred cards is roughly six hundred + * elements, which every browser lays out in single-digit milliseconds, and + * the book is built once at boot rather than on open. Virtualisation would + * buy nothing and would cost the sticky headers, the arrow-key ring and the + * filter's ability to answer in one pass. Revisit it at a thousand. + * + * ## Keyboard ownership + * + * While the book is open it installs a **capture-phase** `keydown` listener on + * the document and stops every key there. `InputManager` listens on `window` in + * the bubble phase, which is the very last stop in the propagation path, so one + * `stopPropagation()` at the top makes the game deaf for exactly as long as the + * book is up. The alternative — an `enabled` flag on the input manager — needs + * an exception carved out for the keys that close the book, and exceptions to a + * mute switch are how a UI ends up casting a spell into its own search box. + * + * ## Selecting versus binding + * + * A click **casts-selects**: the ability goes in the slot, arms, and the book + * closes — one click from "I wonder what Hailwrath looks like" to aiming it. + * Binding is the deliberate act, so it takes a modifier or a drag: shift-click, + * `Shift`+`Enter`, or dragging the card onto a slot in the bar, which stays + * visible below the book for exactly that reason. Opening the book *from* an + * empty slot puts it in bind mode, where a plain click binds instead — at that + * point the user has already said which slot they mean. + */ + +/** + * How many abilities the finished roster has, for the "N of 100" readout. + * + * A constant rather than a count, because the whole point of the indicator is + * the *gap*: `N` is `ABILITIES.length` and comes from the registry, and this is + * what `docs/ROSTER.md` and `docs/ROSTER-II.md` between them specify. The two + * being equal is the finish line, and a target derived from the registry could + * never say anything but "done". + */ +export const ROSTER_TARGET = 100; + +/** Cast-shape tags, as the cards label them. */ +const SHAPE_TAG = { + [CastShape.LINE]: 'ARROW', + [CastShape.ZONE]: 'CIRCLE' +}; + +/* ------------------------------------------------------------------ */ +/* Fuzzy matching */ +/* ------------------------------------------------------------------ */ + +/** + * Score `needle` as a subsequence of `hay`; `-1` when it is not one. + * + * Plain `includes()` is not enough for a roster where the memorable part of a + * name is rarely its start — "cndr" should find Cinder Fall. The scoring is the + * usual one: every matched character is worth something, consecutive matches + * are worth more (so "cinder" beats "cndr" on the same card), and a gap costs a + * little, capped so a long blurb is not punished into oblivion. + * + * Both arguments must already be lower case; this is called ~150 times per + * keystroke and `toLowerCase()` in the loop was measurable. + */ +function subsequenceScore(needle, hay) { + let score = 0; + let cursor = 0; + let streak = 0; + + for (let i = 0; i < needle.length; i++) { + const at = hay.indexOf(needle[i], cursor); + if (at < 0) return -1; + streak = at === cursor && i > 0 ? streak + 1 : 0; + // Word starts read as intentional: "gc" should find Glacial Crown. + const boundary = at === 0 || hay[at - 1] === ' ' ? 3 : 0; + score += 6 + streak * 4 + boundary - Math.min(at - cursor, 6); + cursor = at + 1; + } + return score; +} + +/* ------------------------------------------------------------------ */ + +export class Spellbook { + /** + * @param {HTMLElement} root the overlay host, `#spellbook` + * @param {object} hooks + * @param {import('./Loadout.js').Loadout} hooks.loadout + * @param {(id: string) => void} hooks.onSelect cast-select and arm + * @param {(slot: number, id: string) => void} [hooks.onBind] bind, warm and toast; + * when absent the book writes the loadout itself + * @param {(id: string) => void} [hooks.onInspect] open this ability in the editor + * @param {(open: boolean) => void} [hooks.onToggle] fired on every open/close + * @param {(message: string) => void} [hooks.onToast] + */ + constructor(root, hooks = {}) { + this.root = root; + this.loadout = hooks.loadout; + this.onSelect = hooks.onSelect ?? null; + this.onBind = hooks.onBind ?? null; + this.onInspect = hooks.onInspect ?? null; + this.onToggle = hooks.onToggle ?? null; + this.onToast = hooks.onToast ?? null; + + this.isOpen = false; + /** Slot a plain click binds to, or `null` for cast-select. */ + this._bindTarget = null; + /** The ability the app currently has in the slot, highlighted here. */ + this._selected = null; + /** Current filter text, lower case. */ + this._query = ''; + /** Cards passing the filter, in DOM order — the arrow-key ring. */ + this._visible = []; + this._cursor = -1; + /** Counts last written to the chips and headers, so the filter can skip. */ + this._shownCounts = new Map(); + /** The school whose section owns the top of the body, for the rail. */ + this._currentSchool = null; + + /** + * Search haystacks, built once at construction. + * Per keystroke this is a flat array walk instead of fifty registry lookups + * and fifty string concatenations. + */ + this._haystacks = ABILITIES.map((ability) => ({ + id: ability.id, + label: ability.label.toLowerCase(), + school: (getSchool(ability.school)?.label ?? ability.school).toLowerCase(), + blurb: ability.blurb.toLowerCase(), + shape: (SHAPE_TAG[ability.cast] ?? '').toLowerCase() + })); + + this._build(); + + this._unsubscribe = this.loadout.on('change', () => this._paintSlotKeys()); + this._onKeyDown = this._onKeyDown.bind(this); + } + + /* ------------------------------------------------------------------ */ + /* Construction */ + /* ------------------------------------------------------------------ */ + + _build() { + const groups = abilitiesBySchool(); + const registered = ABILITIES.length; + + this.root.innerHTML = ` +
+ + `; + + this.panel = this.root.querySelector('.book'); + this.body = this.root.querySelector('[data-body]'); + this.search = this.root.querySelector('[data-search]'); + this.modeBadge = this.root.querySelector('[data-mode]'); + this.emptyNote = this.root.querySelector('[data-empty]'); + + /** id → card element. The only lookup the filter and the cursor need. */ + this.cards = new Map(); + /** Every card in DOM order — the array the filter and the ring walk. */ + this._ordered = []; + /** card → the index of the section holding it, for `PgUp` / `PgDn`. */ + this._cardSection = new Map(); + /** card → its `[data-key]` badge, so a rebind is not a hundred queries. */ + this._keyBadges = new Map(); + /** One entry per section: its element, its cards, and its live count node. */ + this.sections = []; + + this.chips = new Map( + [...this.root.querySelectorAll('[data-jump]')].map((chip) => [chip.dataset.jump, chip]) + ); + + for (const element of this.root.querySelectorAll('.book-school')) { + const cards = [...element.querySelectorAll('.spell')]; + const index = this.sections.length; + for (const card of cards) { + this.cards.set(card.dataset.id, card); + this._cardSection.set(card, index); + this._keyBadges.set(card, card.querySelector('[data-key]')); + this._ordered.push(card); + } + this.sections.push({ + id: element.dataset.school, + element, + cards, + // Two counts that must always agree: the one in the sticky header and + // the one in the chip that jumps to it. Resolved once, written together. + headCount: element.querySelector('[data-school-count]'), + chipCount: this.chips.get(element.dataset.school)?.querySelector('b') ?? null, + chip: this.chips.get(element.dataset.school) ?? null + }); + } + + this._paintSlotKeys(); + this._bindEvents(); + this._watchScroll(); + } + + /** + * Light the chip for the section you are reading. + * + * At nine schools the rail was a menu; at fifteen it is also a position + * indicator, because the sticky header only tells you where you are once you + * have already looked away from the rail. + * + * This wants to be an `IntersectionObserver` and is not, twice over. The + * question is "which heading was the last one to pass the top", and that is + * not an intersection: a section taller than the observed band satisfies it + * while intersecting nothing at all, which is how the first version got + * stuck on Flame for the length of the roster. Answering it needs the + * geometry anyway, and once you are reading rects the observer is only a + * way of being told when to read them — which a scroll event does more + * simply, and without the delivery quirks an observer has in a page that is + * not the front tab. + * + * The read is one rect for the body plus one per section, at most once per + * animation frame, and only while the body is actually being scrolled. + */ + _watchScroll() { + if (!this.sections.length) return; + + let queued = false; + this._onScroll = () => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { + queued = false; + this._markCurrentSchool(); + }); + }; + this.body.addEventListener('scroll', this._onScroll, { passive: true }); + } + + /** Light the chip for the last section whose heading is at or above the top. */ + _markCurrentSchool() { + if (!this.sections.length) return; + + // One read of the body and at most fifteen of the sections, a few times per + // scroll gesture. Sections are in document order, so the first one that has + // not reached the top ends the walk. + const band = this.body.getBoundingClientRect().top + 4; + let current = this.sections[0].id; + for (const section of this.sections) { + if (section.element.hidden) continue; + if (section.element.getBoundingClientRect().top > band) break; + current = section.id; + } + + if (current === this._currentSchool) return; + this.chips.get(this._currentSchool)?.classList.remove('is-current'); + this.chips.get(current)?.classList.add('is-current'); + this._currentSchool = current; + } + + _schoolMarkup({ school, abilities }) { + return ` +
+

+ ${school.label} + ${abilities.length} +

+
+ ${abilities.map((ability) => this._cardMarkup(ability)).join('')} +
+
`; + } + + _cardMarkup(ability) { + return ` +
+ ${sigilFor(ability.id)} + + ${ability.label} + ${ability.blurb} + + ${SHAPE_TAG[ability.cast] ?? ''} + +
`; + } + + /* ------------------------------------------------------------------ */ + /* Events */ + /* ------------------------------------------------------------------ */ + + _bindEvents() { + this.root.querySelector('[data-scrim]').addEventListener('pointerdown', () => this.close()); + + for (const [id, chip] of this.chips) { + chip.addEventListener('click', () => { + this.root + .querySelector(`.book-school[data-school="${id}"]`) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + } + + this.root.querySelector('[data-reset]').addEventListener('click', () => { + this.loadout.reset(); + this.onToast?.('Loadout reset'); + }); + + this.search.addEventListener('input', () => this._applyFilter(this.search.value)); + + // One delegated listener for a hundred cards, on the body rather than each: + // the card markup is generated and re-generated, and per-card listeners are + // fifty closures to leak on every rebuild. + this.body.addEventListener('click', (event) => { + const card = event.target.closest?.('.spell'); + if (!card) return; + this._activate(card.dataset.id, event.shiftKey); + }); + + this.body.addEventListener('pointerover', (event) => { + const card = event.target.closest?.('.spell'); + if (card) this._setCursor(this._visible.indexOf(card), { scroll: false }); + }); + + this.body.addEventListener('dragstart', (event) => { + const card = event.target.closest?.('.spell'); + if (!card) return; + // `text/plain` so the drop target can read it during `dragover` in every + // browser that bothers to expose the types list, and the app-specific + // type so a stray text drag from elsewhere cannot bind a slot. + event.dataTransfer.setData('text/plain', card.dataset.id); + event.dataTransfer.setData('application/x-ability', card.dataset.id); + event.dataTransfer.effectAllowed = 'copy'; + this.root.classList.add('is-dragging'); + }); + + this.body.addEventListener('dragend', () => this.root.classList.remove('is-dragging')); + } + + /* ------------------------------------------------------------------ */ + /* Open / close */ + /* ------------------------------------------------------------------ */ + + /** + * @param {object} [options] + * @param {number} [options.slot] open in bind mode, targeting this slot + */ + open(options = {}) { + const slot = Number.isInteger(options.slot) ? options.slot : null; + this._bindTarget = slot !== null && slot >= 0 && slot < SLOT_COUNT ? slot : null; + + if (this._bindTarget === null) { + this.modeBadge.hidden = true; + } else { + this.modeBadge.hidden = false; + this.modeBadge.textContent = `Binding to ${this.loadout.keyAt(this._bindTarget)}`; + } + + if (!this.isOpen) { + this.isOpen = true; + this.root.classList.add('is-open'); + // The editor is a `lil-gui` root at z 40 and would otherwise sit on the + // book's right-hand column. It is a tool, not part of the reading, so it + // fades for as long as the book is up; `G` brings it back afterwards. + document.body.classList.add('is-book-open'); + document.addEventListener('keydown', this._onKeyDown, true); + this.onToggle?.(true); + } + + this.search.value = ''; + this._applyFilter(''); + // Focus is a convenience, not the input path — the capture handler feeds + // the field either way — but it gives the caret somewhere honest to blink. + this.search.focus({ preventScroll: true }); + + const cursor = this._selected ? this._visible.indexOf(this.cards.get(this._selected)) : -1; + this._setCursor(cursor >= 0 ? cursor : 0, { scroll: true, instant: true }); + // The cursor has just moved the body without a scroll event having been + // dispatched yet, and on the first open nothing has scrolled at all. + this._markCurrentSchool(); + } + + close() { + if (!this.isOpen) return; + this.isOpen = false; + this._bindTarget = null; + this.root.classList.remove('is-open', 'is-dragging'); + document.body.classList.remove('is-book-open'); + document.removeEventListener('keydown', this._onKeyDown, true); + this.search.blur(); + this.onToggle?.(false); + } + + toggle(options) { + if (this.isOpen) this.close(); + else this.open(options); + } + + /* ------------------------------------------------------------------ */ + /* State painting */ + /* ------------------------------------------------------------------ */ + + /** Mirror the app's current slot into the book's highlight. */ + setSelected(id) { + if (id === this._selected) return; + if (this._selected) this.cards.get(this._selected)?.classList.remove('is-selected'); + this._selected = id; + this.cards.get(id)?.classList.add('is-selected'); + } + + /** + * Stamp each card with the key it is bound to, if any. + * + * Runs on every loadout change, so the badge element is resolved once at + * build time rather than by a `querySelector` per card — at a hundred cards + * that was a hundred selector matches to repaint at most eight badges. + */ + _paintSlotKeys() { + for (const [id, card] of this.cards) { + const key = this.loadout.keyFor(id); + const badge = this._keyBadges.get(card); + if (!badge) continue; + badge.hidden = !key; + badge.textContent = key; + card.classList.toggle('is-bound', Boolean(key)); + } + } + + /* ------------------------------------------------------------------ */ + /* Filtering */ + /* ------------------------------------------------------------------ */ + + /** + * Re-run the filter. Called on every keystroke, so every line here is on a + * budget of one hundred cards times however fast the user types. + * + * Three things keep it instant, and all three are about *not* touching the + * DOM rather than about the scoring: + * + * - the haystacks are a flat array built once, so no card is asked what it + * is called; + * - `hidden` is written only where it changes, because assigning the same + * value still invalidates style for that element and a hundred of those + * per keystroke is a visible stutter on a laptop; + * - the visible list is rebuilt from the cached DOM-order array instead of + * a fresh `querySelectorAll`, which is where the old version spent most + * of its time. + */ + _applyFilter(raw) { + const query = raw.trim().toLowerCase(); + this._query = query; + + let best = -Infinity; + let bestCard = null; + + for (const hay of this._haystacks) { + const card = this.cards.get(hay.id); + if (!card) continue; + const score = this._score(query, hay); + const hit = score > -Infinity; + if (card.hidden === hit) card.hidden = !hit; + if (hit && score > best) { + best = score; + bestCard = card; + } + } + + // Fold away a school with nothing left in it, and correct its count so the + // headers read as a live tally of the filter rather than of the roster. + for (const section of this.sections) { + let shown = 0; + for (const card of section.cards) if (!card.hidden) shown++; + + if (this._shownCounts.get(section.id) !== shown) { + this._shownCounts.set(section.id, shown); + section.element.hidden = shown === 0; + if (section.headCount) section.headCount.textContent = shown; + // The chip carries the same number as the header it jumps to; two + // counts side by side that disagree reads as a bug. + if (section.chipCount) section.chipCount.textContent = shown; + section.chip?.classList.toggle('is-dim', shown === 0); + } + } + + this._visible = this._ordered.filter((card) => !card.hidden); + this.emptyNote.hidden = this._visible.length > 0; + + const index = bestCard ? this._visible.indexOf(bestCard) : -1; + this._setCursor(index, { scroll: Boolean(query) }); + } + + /** + * Best of the three haystacks, weighted: the name is what the user is most + * likely typing, the school is a coarse filter ("frost"), and the blurb is + * the long tail ("hailstones") that should match but never outrank a name. + */ + _score(query, hay) { + if (!query) return 0; + + const name = subsequenceScore(query, hay.label); + if (name >= 0) return name * 3 + 40; + + const school = subsequenceScore(query, hay.school); + if (school >= 0) return school * 2 + 20; + + if (hay.shape.startsWith(query)) return 15; + // The blurb is a sentence, so a subsequence over it matches nearly + // everything — substring only, and it scores below every name hit. + if (hay.blurb.includes(query)) return 10; + + return -Infinity; + } + + /* ------------------------------------------------------------------ */ + /* Cursor */ + /* ------------------------------------------------------------------ */ + + _setCursor(index, options = {}) { + const previous = this._visible[this._cursor]; + if (previous) previous.classList.remove('is-cursor'); + + this._cursor = this._visible.length === 0 ? -1 : Math.max(0, Math.min(index, this._visible.length - 1)); + + const card = this._visible[this._cursor]; + if (!card) return; + card.classList.add('is-cursor'); + if (options.scroll !== false) { + card.scrollIntoView({ block: 'nearest', behavior: options.instant ? 'auto' : 'smooth' }); + } + } + + _moveCursor(delta) { + if (this._visible.length === 0) return; + const next = (this._cursor + delta + this._visible.length) % this._visible.length; + this._setCursor(next); + } + + /** + * Vertical movement is a column count, not a fixed stride: the grid is + * `auto-fill`, so how many cards make a row is a function of the window and + * the only honest place to ask is the computed style at the moment of the + * keypress. Once per arrow press, which is not a hot path. + */ + _moveRow(direction) { + const card = this._visible[this._cursor]; + if (!card) return this._moveCursor(direction); + const columns = getComputedStyle(card.parentElement).gridTemplateColumns.split(' ').length; + this._moveCursor(direction * Math.max(1, columns)); + } + + /** + * `PgDn` / `PgUp`: the first card of the next or previous school. + * + * Fifteen sections is the point at which arrowing is no longer a way to get + * anywhere — Hive is thirty row-presses from Frost — and the rail is a mouse + * affordance. This is the rail from the keyboard, and it deliberately walks + * the *visible* list, so under a filter it steps between the schools that + * still have something in them rather than through empty ones. + * + * Backwards behaves the way "previous paragraph" does in a text editor: from + * the middle of a section it goes to the top of that section first, and only + * then to the one above. Jumping straight past the section you are reading + * is the behaviour everyone complains about. + * + * The scroll is instant here where an arrow key's is smooth. A section jump + * crosses most of the panel, and smoothly animating six hundred pixels turns + * a keystroke into a wait — worse, holding the key queues animations that + * each restart from wherever the last one had got to, and the cursor ends up + * somewhere the view never reaches. + */ + _moveSection(direction) { + if (this._visible.length === 0) return; + const jump = { instant: true }; + const from = this._cardSection.get(this._visible[this._cursor]); + + if (direction > 0) { + for (let i = this._cursor + 1; i < this._visible.length; i++) { + if (this._cardSection.get(this._visible[i]) !== from) return this._setCursor(i, jump); + } + return this._setCursor(this._visible.length - 1, jump); + } + + let index = this._cursor - 1; + while (index >= 0 && this._cardSection.get(this._visible[index]) === from) index--; + if (index < 0) return this._setCursor(0, jump); + const target = this._cardSection.get(this._visible[index]); + while (index > 0 && this._cardSection.get(this._visible[index - 1]) === target) index--; + return this._setCursor(index, jump); + } + + /* ------------------------------------------------------------------ */ + /* Activation */ + /* ------------------------------------------------------------------ */ + + /** + * A card has been chosen. `bind` is the shift modifier — or the standing bind + * mode, when the book was opened from an empty slot. + */ + _activate(id, bind) { + if (!getAbility(id)) return; + + if (bind || this._bindTarget !== null) { + const slot = this._bindTarget ?? this._defaultBindSlot(id); + // Routed through the app when it is listening, so a bind warms the class + // and announces itself the same way a drag onto the bar does. + if (this.onBind) this.onBind(slot, id); + else if (this.loadout.bind(slot, id)) { + this.onToast?.(`${getAbility(id).label} bound to ${this.loadout.keyAt(slot)}`); + } + // Bind mode is a one-shot: the slot the user asked for is filled, so the + // book drops back to browsing rather than quietly rebinding it again. + if (this._bindTarget !== null) { + this._bindTarget = null; + this.modeBadge.hidden = true; + this.close(); + } + return; + } + + this.onSelect?.(id); + this.close(); + } + + /** + * Where a shift-click lands when the user has not named a slot: the first + * empty one, and failing that the slot holding whatever is selected — which + * is the slot they are looking at, so the swap is the one they can see. + */ + _defaultBindSlot(id) { + const already = this.loadout.slotOf(id); + if (already >= 0) return already; + const empty = this.loadout.firstEmpty(); + if (empty >= 0) return empty; + const selected = this._selected ? this.loadout.slotOf(this._selected) : -1; + return selected >= 0 ? selected : 0; + } + + /* ------------------------------------------------------------------ */ + /* Keyboard */ + /* ------------------------------------------------------------------ */ + + _onKeyDown(event) { + if (!this.isOpen) return; + + // Everything, unconditionally: while the book is up the game does not get + // the keyboard. See the class header. + event.stopPropagation(); + + switch (event.key) { + case 'Escape': + event.preventDefault(); + this.close(); + return; + case 'Tab': + // Tab opened it, Tab closes it. Preventing the default also keeps the + // browser from walking focus out of the dialog. + event.preventDefault(); + this.close(); + return; + case 'ArrowRight': + event.preventDefault(); + this._moveCursor(1); + return; + case 'ArrowLeft': + event.preventDefault(); + this._moveCursor(-1); + return; + case 'ArrowDown': + event.preventDefault(); + this._moveRow(1); + return; + case 'ArrowUp': + event.preventDefault(); + this._moveRow(-1); + return; + case 'PageDown': + event.preventDefault(); + this._moveSection(1); + return; + case 'PageUp': + event.preventDefault(); + this._moveSection(-1); + return; + case 'Home': + event.preventDefault(); + this._setCursor(0); + return; + case 'End': + event.preventDefault(); + this._setCursor(this._visible.length - 1); + return; + case 'Enter': { + event.preventDefault(); + const card = this._visible[this._cursor]; + if (card) this._activate(card.dataset.id, event.shiftKey); + return; + } + default: + break; + } + + // Anything else is typing. The field owns it, so make sure it has focus — + // a click on a card takes it away and the next keystroke would go nowhere. + if (document.activeElement !== this.search) { + this.search.focus({ preventScroll: true }); + } + } + + /* ------------------------------------------------------------------ */ + + dispose() { + document.removeEventListener('keydown', this._onKeyDown, true); + if (this._onScroll) this.body?.removeEventListener('scroll', this._onScroll); + this._unsubscribe?.(); + this.root.innerHTML = ''; + this.cards.clear(); + this._cardSection.clear(); + this._keyBadges.clear(); + this._ordered.length = 0; + this.sections.length = 0; + } +} diff --git a/src/ui/glyphs.js b/src/ui/glyphs.js deleted file mode 100644 index 8aa52ec..0000000 --- a/src/ui/glyphs.js +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Ability sigils for the HUD — drawn inline so they inherit `currentColor` (the - * slot's `--accent`) and need no image assets. - * - * A 100×100 box, stroke only, so the mark reads the same at 34px in the ability - * slot as it does scaled up. - */ - -const WRAP = (body) => - ``; - -/** - * Ice — a six-fold snowflake over a rising lance. - * - * Three axes at 60°, each with a pair of barbs, and a heavier vertical that runs - * past the star into a point: the star says frost, the point says skillshot. - */ -const ICE = WRAP(` - - - - - - - - - -`); - -/** - * Thunder — a bolt struck through a pair of arcs. - * - * The zigzag is drawn on the same diagonal the cast travels on, and the two - * open arcs behind it read as the discharge spreading off it. Stroke only, like - * the snowflake, so the two slots sit at the same visual weight. - */ -const THUNDER = WRAP(` - - - -`); - -/** - * Meteor — a cracked ball trailing fire. - * - * The circle sits forward and low with three seams splitting it, and three - * tapering streaks run back up the same diagonal the other two sigils are drawn - * on, so the slot reads as "the rock, thrown" at 34px. - */ -const METEOR = WRAP(` - - - -`); - -/** - * Beam — a charge held in a bracket, firing a cone. - * - * The orb sits low-left where the other three sigils start their diagonal, two - * open brackets behind it read as the hands holding it, and three tapering rays - * open out to the upper right with a single wave threaded through them: the - * column, and the coil wrapped around it. - */ -const BEAM = WRAP(` - - - - - -`); - -/** - * Snare — a ring with a bolt standing in it. - * - * The only sigil in the set built around a *circle you look into* rather than a - * diagonal, because that is the one thing this slot has to say before anything - * else: it is not a skillshot, it is a footprint. The ellipse is the boundary - * seen in perspective, four arcs step around it where the rim current runs, and - * the zigzag rises out of the middle. - */ -const SNARE = WRAP(` - - - - -`); - -/** - * Glacier — a crown of blades standing on a ring. - * - * The second sigil built around a *circle you look into*, because it is the - * second far cast and that is the first thing the slot has to say. Where the - * Snare stands one bolt in the middle of its ellipse, this one stands the ring - * itself up: five blades of uneven height rising off the boundary with the - * spire tallest in the middle, which is the silhouette the ability actually - * makes. - */ -const GLACIER = WRAP(` - - - - - - -`); - -/** Keyed by the ids in `ELEMENTS`. */ -export const ELEMENT_SIGILS = { - ice: ICE, - thunder: THUNDER, - meteor: METEOR, - beam: BEAM, - snare: SNARE, - glacier: GLACIER -}; diff --git a/src/ui/glyphs/aether.js b/src/ui/glyphs/aether.js new file mode 100644 index 0000000..aea3ce4 --- /dev/null +++ b/src/ui/glyphs/aether.js @@ -0,0 +1,159 @@ +/** + * Aether sigils. + * + * Nothing shipped in this school — see `docs/ROSTER.md` for what is coming. + * Author each mark with `WRAP` from `./wrap.js`, keyed by ability id, and add + * it to the map below. `index.js` already folds this module into + * `ELEMENT_SIGILS`, so a new sigil needs no edit anywhere else. + */ +import { WRAP } from './wrap.js'; + +/** + * Slipstream — a diagonal, and two rules that do not line up across it. + * + * The diagonal is the house mark for a line cast, and here it is also the + * blade itself. The idea is carried entirely by the other four strokes: two + * horizontal rules that pass behind the blade and come out the far side + * *stepped*, which is the one thing this ability does — it displaces what is + * behind it and emits nothing of its own. + * + * A first version drew three parallel diagonals, meaning "the same edge, + * slid". At 34px that is three lines and no idea. Breaking a rule across the + * blade needs the viewer to do no work at all. + */ +export const SLIPSTREAM = WRAP(` + + + + + +`); + +/** + * Cyclone — the funnel seen from the side, standing in its own footprint. + * + * A far cast, so it is built on the ellipse you look *into*: the wide ellipse + * is the scoured floor and the two curves rising out of it are the profile + * itself, waisted at the throat and flaring again at the mouth. That is the + * ability's one idea drawn in two strokes. A spiral was the obvious mark and it + * was wrong — at 34px a spiral is a smudge, and it says "swirl" rather than + * "something is standing on the ground over there". + */ +export const CYCLONE = WRAP(` + + + + +`); + +/** + * Resonant Chord — the line, and the standing pattern on it. + * + * A line cast, so it is a diagonal. The three rings crossing it are the ring + * train seen edge-on, and they are drawn at deliberately *unequal* sizes — + * wide, pinched, wide — because the whole point of the slot is that the pattern + * has places where the air is still and places where it is violently + * compressed. Evenly sized rings read as a spring, which is the one thing this + * must not look like. + */ +export const RESONANCE = WRAP(` + + + + +`); + +/** + * Aurora Veil — four folded sheets standing in the ellipse you look into. + * + * A far cast, so the ellipse. The four strokes rising out of it are the sheets + * seen edge-on: each is a shallow S, they are drawn to *different* heights, and + * none of them is vertical. That is the whole idea — a rank of straight + * parallel lines is a fence, and it takes exactly one bend per stroke to make + * it cloth. The bends alternate direction across the four so the mark has a + * wave in it at 34px even when the individual curves have stopped resolving. + */ +export const AURORA = WRAP(` + + + + + +`); + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/** + * Sky Fracture — the crack, and the crack's reflection. + * + * The one sigil in the set built around a horizon: the split above the line and + * its answer below it, because the ability's whole trick is that you see the + * mark on the floor *before* the sky does anything. The lower half is drawn + * lighter-handed — fewer branches — so the pair reads as reflection rather than + * as symmetry. + */ +export const SKYFRACTURE = WRAP(` + + + + +`); + +/** + * Mirage — a runner, and the rule behind them stepped out of true. + * + * The figure is four strokes and no more: a head, a leaning trunk, a stride. + * It has to be a *runner* rather than a standing person, because the ability's + * second half is that you lose the double the moment it slows — a figure at + * rest is the one thing this slot never shows you. + * + * The two short rules either side of it are the ability's first half, and they + * are the reason there is no separate diagonal for the line cast: they are one + * line, offset where the body crosses it, so the mark says "the world bends + * around this" without drawing a single thing the figure itself emits. An + * earlier version outlined the runner twice, slightly apart, meaning "a + * duplicate". At 34px two overlapping stick figures are a smudge with a hat on. + */ +export const MIRAGE = WRAP(` + + + + + +`); + +/** + * Featherfall — three descents into the ellipse you look into. + * + * A far cast, so the ellipse. What is standing in it is the entire ability: + * three paths down, and **no two of them the same kind of line**. One weaves — + * that is the flutterer, glide, stall, glide back. One is a hard zigzag of + * straight segments — the tumbler, which has no curve in it anywhere. One is a + * single smooth sweep that barely turns — the one that caught a gust and rode + * it. Three copies of the same wiggle would say "feathers"; three different + * *kinds* of wiggle say the thing this slot is actually about. + * + * A drawn feather was the first version and it is a worse mark: at 34px a + * feather is an almond, and an almond is not a fall. + */ +export const FEATHERFALL = WRAP(` + + + + +`); + +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +export const AETHER_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + featherfall: FEATHERFALL, + mirage: MIRAGE, + skyfracture: SKYFRACTURE, + aurora: AURORA, + cyclone: CYCLONE, + resonance: RESONANCE, + slipstream: SLIPSTREAM +}; diff --git a/src/ui/glyphs/arcane.js b/src/ui/glyphs/arcane.js new file mode 100644 index 0000000..f54a0b8 --- /dev/null +++ b/src/ui/glyphs/arcane.js @@ -0,0 +1,204 @@ +/** + * Arcane sigils. + * + * One shipped: the Nova Beam's charge held in a bracket. Runic Seal, Prism + * Lance, Chronofracture, Starfall, Arcane Volley and Glyphstorm belong here. + */ +import { WRAP } from './wrap.js'; + +/** + * Beam — a charge held in a bracket, firing a cone. + * + * The orb sits low-left where the other three sigils start their diagonal, two + * open brackets behind it read as the hands holding it, and three tapering rays + * open out to the upper right with a single wave threaded through them: the + * column, and the coil wrapped around it. + */ +export const BEAM = WRAP(` + + + + + +`); + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * Astral Gate — the far ellipse with three shafts standing *out of* it. + * + * The house ellipse for a zone cast, read here as the aperture seen from + * above, and the whole point of the mark is the crossing: each of the three + * shafts is drawn as a full stroke that starts *below* the ellipse's near edge + * and continues above it, so the ring passes across them. That overlap is the + * only thing in the sigil doing any work — it is the two-dimensional version + * of the ability's clip, and it survives 34px where a starfield inside the + * ring does not. + * + * A first attempt drew the shafts stopping at the ellipse, which is what a + * decal looks like: three sticks resting on a circle. Letting them cross is + * one changed coordinate and it is the difference between "on" and "through". + */ +export const ASTRALGATE = WRAP(` + + + + +`); + +/** + * Spellbreak — the far ellipse, cracked, with the pieces already leaving. + * + * The house mark for a zone cast is an ellipse you look into, and this is the + * only one in the set that is **broken**: the ring is drawn as two arcs with a + * gap on each side rather than as a closed curve, so at 34px the first thing + * you read is that the circle has failed. A single jagged stroke crosses it — + * one line, not a web, because a spider-crack turns to grey mush at slot size + * — and two short strokes fly off the top left and top right, which are the + * panes. + * + * Four strokes. An early version drew a dome standing on the ellipse and it + * was a good picture of what the ability *draws* and a bad picture of what it + * *does*: what it does is break the thing that was there. The dome went. + */ +export const SPELLBREAK = WRAP(` + + + + +`); + +/** + * Starfall — three falls out of one knot, into the far circle. + * + * The mark *is* the trick. A small ring high and to the left is the vanishing + * point every star leaves from; three strokes come out of it and fan apart on + * their way down to the house ellipse, so the sigil says "one origin, many + * landings" before the blurb does. The rays are drawn from just outside the + * ring rather than from its centre, because three lines meeting inside a circle + * at 34px is a blot. + */ +export const STARFALL = WRAP(` + + + + + +`); + +/** + * Chronofracture — two panes standing on a far circle, one of them failing. + * + * The ellipse is the house mark for a far cast, and it is read as the ground + * the panes are hanging over. Two leaning quads stand on it — deliberately not + * parallel, because the panes never are — and a single zigzag runs down the + * right-hand one. Three strokes plus the circle: at 34px the crack is the only + * thing that survives as detail, which is correct, because it is the tell that + * says this is glass and not a gate. + */ +export const CHRONOFRACTURE = WRAP(` + + + + +`); + +/** + * Prism Lance — one line in, three out, reconverging. + * + * The house diagonal comes up from the lower left and *stops* at a triangle, + * which is the only place in the set a cast line is interrupted by a body. Out + * of the far side, three curves bow apart and meet again at a single point in + * the upper right: the whole ability in five strokes, and the only mark in the + * school whose rays are curved rather than straight — a claw of straight lines + * says "many shots", and these are one shot that was bent. + * + * Six children draw in the world; three read at 34px. Six here is a smudge. + */ +export const PRISMLANCE = WRAP(` + + + + + +`); + +/** + * Glyphstorm — four marks up the diagonal, two of them side-on. + * + * The house diagonal for a line cast, with the ability's one idea drawn on it + * literally: the first and third marks are plates you can read, the second and + * fourth are the same plates caught edge-on and reduced to a bare stroke. Two + * quads and two slashes is the whole sigil, and at 34px the alternation is + * still the thing you see — which is the point, because the alternation is the + * ability. An attempt at putting a glyph *inside* the plates went in the bin: + * at slot size it filled them in and the storm became a row of bricks. + */ +export const GLYPHSTORM = WRAP(` + + + + +`); + +/** + * Runic Seal — two rings, four ticks, one mark in the middle. + * + * The house ellipse for a far cast, doubled: the band between the two rings is + * where the writing goes, and leaving it *empty* is the whole decision. Three + * attempts put actual glyph strokes in that band and every one of them turned + * to a dotted grey smear at 34px — the runes are five centimetres wide in a + * five-metre seal, and there is no honest way to say that in a slot icon. What + * survives shrinking is the *structure*: concentric, divided, centred. So the + * four ticks carry "this thing is measured out" and the diamond carries "and + * there is a bigger mark in the middle", which is the ability in two facts. + * + * It reads apart from its school-mates on count: Starfall's ellipse has rays + * coming into it and Chronofracture's has panes standing on it, and this is the + * only mark in the set with a second ring. + */ +export const RUNESEAL = WRAP(` + + + + +`); + +/** + * Arcane Volley — three weaves out of one knot, into one point. + * + * The house diagonal again, but drawn three times: three sine curves leaving a + * small ring at the hand, crossing each other, and meeting at a single dot in + * the upper right. The ring says the bolts gather before they go, the crossings + * say they weave, and the dot says they land together — which is the ability in + * order of events. + * + * Three curves rather than seven for the same reason Prism Lance draws three + * rays: seven strokes through a 100-unit box is a hatch pattern at slot size. + * The dot is drawn as a tiny filled-looking circle (a stroked r=3) because a + * point where three strokes converge otherwise reads as a fray. + */ +export const ARCANEVOLLEY = WRAP(` + + + + + +`); + +export const ARCANE_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + astralgate: ASTRALGATE, + spellbreak: SPELLBREAK, + arcanevolley: ARCANEVOLLEY, + runeseal: RUNESEAL, + glyphstorm: GLYPHSTORM, + prismlance: PRISMLANCE, + starfall: STARFALL, + chronofracture: CHRONOFRACTURE, + beam: BEAM +}; diff --git a/src/ui/glyphs/blood.js b/src/ui/glyphs/blood.js new file mode 100644 index 0000000..e052316 --- /dev/null +++ b/src/ui/glyphs/blood.js @@ -0,0 +1,126 @@ +/** + * Blood sigils. + * + * Nothing shipped in this school — see `docs/ROSTER.md` for what is coming. + * Author each mark with `WRAP` from `./wrap.js`, keyed by ability id, and add + * it to the map below. `index.js` already folds this module into + * `ELEMENT_SIGILS`, so a new sigil needs no edit anywhere else. + */ +import { WRAP } from './wrap.js'; + +/** + * Crimson Tide — a breaking wave on the cast diagonal. + * + * The house convention wants a diagonal for a line cast, and a wave already is + * one: the long back slope runs up from bottom-left, the lip curls over at the + * top right and throws two droplets clear of it. The short chord under the curl + * is the hollow of the barrel, and it is the one stroke that stops the mark + * reading as a comma. Five strokes, and the two droplets survive 34px because + * they are round caps on zero-length paths rather than circles. + */ +export const CRIMSONTIDE = WRAP(` + + + + + +`); + +/** + * Plague Bloom — a blister letting go on the rim of a far cast. + * + * The ellipse is the house convention for a circle you look into, and the three + * domes sitting on it are the pustules. The middle one has already gone: its + * walls are drawn splayed outward with no cap, and three strokes leave it + * upward. Two intact blisters against one burst one is the smallest way to say + * "these are on individual timers", which is the whole ability. The spray is + * deliberately narrow — widen it and the mark turns into a plant. + */ +export const PLAGUEBLOOM = WRAP(` + + + + + +`); + +/** + * Sanguine Pact — two orbits crossing over a circle you look into. + * + * The flat ellipse is the house mark for a far cast and here it is also the + * pool. The two ellipses standing over it are inclined orbits, crossed rather + * than concentric, because *crossed* is the one arrangement that cannot be read + * as a flat ring seen from an angle — which is precisely the mistake the + * ability is built to avoid. Three beads sit on them, one on each orbit and one + * where they meet. + * + * The first version drew the orbits as concentric ellipses of different widths. + * At 34px that is a target, not a pact. + */ +export const SANGUINEPACT = WRAP(` + + + + + + +`); + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * Bone Cage — the house ellipse with a ribcage standing in it. + * + * Sanguine Pact already owns two *closed* ellipses crossed over a flat one, so + * this cannot be another set of rings: side by side at 34px they would be the + * same mark. These are **open arcs** — they spring off the rim, meet at a point + * overhead and stop there — and the two shallow ties running between them are + * what turn an arch into a cage. The ties are also the only thing in the mark + * that is horizontal, which is what the eye finds first at slot size. + * + * The first version drew the ribs with a knuckle on each, to say bone rather + * than iron. A knuckle is about a pixel and a half at 34px and it read as a + * wobble in the stroke, so the bone is carried by the palette and the mark is + * left to say cage. + */ +export const BONECAGE = WRAP(` + + + + + +`); + +/** + * Hemorrhage — three threads on the cast diagonal, arriving out of step. + * + * The house diagonal, drawn three times: same heading, offset laterally, and + * — the whole point — stopping at three *different* distances, so the far ends + * make a staircase instead of a row. That stagger is the ripple, and it is the + * only thing in the mark that has to survive 34px. + * + * A splash flicks off the two leading ends and a shallow puddle lies under + * them. The first draft had the threads dashed, to say "mist"; at 34px a dash + * is a gap and the mark read as broken rather than as vapour, so the threads + * are solid and the pool carries the aftermath instead. + */ +export const HEMOLANCE = WRAP(` + + + + + +`); + +export const BLOOD_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + bonecage: BONECAGE, + sanguinepact: SANGUINEPACT, + hemolance: HEMOLANCE, + plaguebloom: PLAGUEBLOOM, + crimsontide: CRIMSONTIDE +}; diff --git a/src/ui/glyphs/chrono.js b/src/ui/glyphs/chrono.js new file mode 100644 index 0000000..b383256 --- /dev/null +++ b/src/ui/glyphs/chrono.js @@ -0,0 +1,187 @@ +/** + * Chrono sigils. + * + * Nothing shipped in this school — see `docs/ROSTER-II.md` for what is coming: + * Echo Step, Stasis Field, Rewind, Hourglass, Afterimage and Entropy Wave. + * Author each mark with `WRAP` from `./wrap.js`, keyed by ability id, and add + * it to the map below. `index.js` already folds this module into + * `ELEMENT_SIGILS`, so a new sigil needs no edit anywhere else. + * + * Every one of these six is *about* time, so the cheap mark — a clock face, a + * circling arrow — is available to all of them and therefore useful to none. + * Worse, a ring with a tick in it is Runic Seal's territory already. + * + * The set has one device that no other school can use, and it should carry the + * whole school: **repetition with decay**. The same shape drawn two or three + * times, offset, is a copy of a moment; which end of the run is the present is + * said by which end is denser or larger. Echo Step, Afterimage and Rewind are + * three different answers to that one idea and must not be three drawings of + * it — decide early which is offset in space, which in scale and which in + * direction, and hold the line. + */ +import { WRAP } from './wrap.js'; + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * Entropy Wave — one ring drawn three times, eaten away toward the middle. + * + * A far cast, so the outermost ring is the house rule's ellipse you look into, + * and it is the only one that is whole. The middle ring has lost its two ends + * and the innermost is down to a pair of dashes, so reading inward is reading + * *forward in time*: the centre aged first and has nearly gone, and the + * complete outer ring is the front that has not finished arriving. + * + * That is the school's repetition-with-decay device on the one axis its + * neighbours did not take — Echo Step owns offset in space, Afterimage owns + * scale and Rewind owns direction, and this one is the same shape repeated with + * *material* removed. It is also the only mark in the school that is legible as + * a statement about the ground rather than about a body. + * + * The draft with five evenly spaced rings is a moiré pattern at 34px: the + * vertical gap between two flat concentric ellipses is a fraction of the gap + * between their radii, so the rings have to be spaced by their *minor* axis and + * three is all that fits. + */ +export const ENTROPY = WRAP(` + + + + + +`); + +/** + * Hourglass — the same cone twice, one full and one open. + * + * A far cast, so the house rule gives it the ellipse you look into, and the + * bowtie standing in it is the two bulbs meeting at the neck. Nothing else in + * the set is a bowtie, which is most of why it survives on a bar of a hundred. + * + * The school's device is repetition with decay, and this is the one slot where + * the two copies are **the same shape in opposite states**: the upper cone is + * closed and the lower one is drawn as an open V. Closed reads as full and open + * reads as filling, so the mark says which way the sand is going — and because + * the two are otherwise identical it also says that the ability is about + * swapping them. + * + * The draft that closed both triangles is a solid bowtie at 34px and it stops + * being an hourglass and starts being a stroke-weight test. Leaving the bottom + * edge to the ellipse costs nothing: the eye closes it. + */ +export const HOURGLASS = WRAP(` + + + +`); + +/** + * Echo Step — the same stride drawn three times, walking away down the line. + * + * Chrono's one device is repetition with decay, and this module's header asks + * each slot to commit to *which* repetition it is. **Echo Step is the one + * offset in space**: three identical chevrons marching up the diagonal, each + * further back and each smaller, so the largest is unambiguously the present. + * Afterimage takes offset in scale and Rewind takes offset in direction; if a + * later hand blurs that split the school collapses into three drawings of one + * idea. + * + * The chevrons carry the diagonal on their own, which is why there is no fourth + * stroke ruling it in. The draft that had one crossed all three apexes and at + * 34px the whole mark went to a hatched smudge — the white space between the + * repeats is the only thing saying there are three of them. + */ +export const ECHOSTEP = WRAP(` + + + +`); + +/** + * Stasis Field — a fall that stops at the boundary. + * + * A far cast, so the house rule gives it the ellipse you look *into*, and the + * arch over it is the held sphere. The idea is the other two strokes: they are + * **one** trajectory, coming down from the top right, drawn twice with a gap + * where it crosses the arch and with the second piece far shorter than the + * first. Something arrived, and then it did not carry on. + * + * Deliberately not repetition-with-decay: this is the one chrono slot that is + * not about copies of a moment, and drawing it as one would have taken a device + * the other three need. It is also why the arch is closed rather than broken — + * Nightfall's dome is a lid coming down with a gap in it, and the two marks have + * to survive sitting on the same bar. + * + * The first draft put the stub *outside* the arch, on the near side. That reads + * as a thing bouncing off, which is a shield; the stub has to be inside, past + * the skin, or the mark says the wrong ability. + */ +export const STASISFIELD = WRAP(` + + + + +`); + +/** + * Afterimage — one body drawn at three scales, in one place, on the diagonal. + * + * The school's device is repetition with decay and each slot has to own a + * different axis of it. Echo Step is the repeat offset in **space**; this is + * the repeat offset in **scale** — the same almond nested twice inside itself + * with the spine of it left showing, so what you read is one object at three + * ages rather than three objects. Rewind still has direction, which is the + * only axis left and the reason neither of these two may take it. + * + * The short stroke running out of the bottom-left corner is collinear with the + * almonds' own long axis, so the house rule for a line cast is paid without a + * fifth stroke: the trajectory and the body are one diagonal. The draft that + * made all three repeats the same size needed an offset to be legible at all, + * which is Echo Step's mark with worse spacing. + */ +export const AFTERIMAGE = WRAP(` + + + + +`); + +/** + * Rewind — one diagonal, gone up it and come back down. + * + * The school's device is repetition with decay on three axes, and the other two + * are spoken for: Echo Step took **space**, Afterimage took **scale**, so this + * takes **direction**, exactly as the module header asks. It is the only one of + * the three whose repeat is a single continuous stroke — the outbound leg, a + * hairpin over the top, and the return leg beside it — because direction is the + * one axis you cannot state with two separate copies of a shape. Two strokes + * side by side are Void Rift's tear; a stroke that turns over is a journey. + * + * The line cast's diagonal is therefore paid for twice, which is a bonus rather + * than the idea. The arrowhead has to be on the *near* end: put it at the top + * and the mark reads as a throw with a flourish, and the whole ability is that + * the flourish is the ability. + * + * The draft with a third, straight rule through the middle of the hairpin was + * more obviously a loop and completely closed the counter at 34px. The white + * space between the two legs is the only thing saying there are two of them. + */ +export const REWIND = WRAP(` + + + +`); + +export const CHRONO_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + entropy: ENTROPY, + rewind: REWIND, + afterimage: AFTERIMAGE, + hourglass: HOURGLASS, + echostep: ECHOSTEP, + stasisfield: STASISFIELD, +}; diff --git a/src/ui/glyphs/flame.js b/src/ui/glyphs/flame.js new file mode 100644 index 0000000..40494b4 --- /dev/null +++ b/src/ui/glyphs/flame.js @@ -0,0 +1,200 @@ +/** + * Flame sigils. + * + * One shipped: Cinder Fall's cracked ball trailing fire. Pyroclasm, Wyrm's + * Breath, Ashen Lash, Emberflight, Magma Fount and Sunspear belong here. + */ +import { WRAP } from './wrap.js'; + +/** + * Meteor — a cracked ball trailing fire. + * + * The circle sits forward and low with three seams splitting it, and three + * tapering streaks run back up the same diagonal the other two sigils are drawn + * on, so the slot reads as "the rock, thrown" at 34px. + */ +export const METEOR = WRAP(` + + + +`); + +/** + * Emberflight — three birds climbing the diagonal, and the column at the end. + * + * The chevron is the only bird shape that survives 34px: a wing outline loses + * its rake and turns into a lozenge, while an open V keeps reading as + * "something flying away from you" down to a handful of pixels. Three of them + * on the line cast's diagonal give the skein; the vertical stroke with a fourth + * chevron on top is the collapse going up, which is the beat no other flame + * slot has. + */ +export const EMBERFLOCK = WRAP(` + + + + + +`); + +/** + * Pyroclasm — the far cast's ellipse, with the beat drawn inside it. + * + * A ZONE slot has to say "you look *into* this" before it says anything else, + * so the footprint ellipse carries the mark and everything else sits on it. The + * two short strokes lean inward off the rim: they are the collapse, and pointing + * them in is the only cue that separates this slot from every other flame circle + * at 34px. The column standing in the middle is what the collapse becomes. + * + * An earlier version drew three inward arrowheads instead. Arrowheads are two + * strokes each, so six of them plus the ellipse turned the whole mark into a + * ring of grey fuzz the moment it was scaled down — the chevron rule the + * Emberflight mark learnt, arrived at from the other direction. + */ +export const PYROCLASM = WRAP(` + + + +`); + +/** + * Ashen Lash — the whip, with the loop still in it, cracking off the end. + * + * One continuous stroke does the whole job: it curls into a loop at the handle + * end and then runs away up the line cast's diagonal, which is the ability's + * entire beat in a single line — the loop is at the handle, and it travels. The + * three short ticks off the far end are the crack, deliberately *detached* from + * the lash so they read as a bang in the air rather than as a frayed tip. + * + * The loop was half this size in the first draft and closed into a solid dot at + * 34px; at twenty-odd units across the counter survives, which is the only + * reason you can tell this slot from Emberflight's skein on the bar. + */ +export const FIREWHIP = WRAP(` + + +`); + +/** + * Sunspear — the javelin coming down, and the sun it lands in. + * + * The line cast's diagonal, thrown *downward* — every other flame line in the + * set climbs to the right, so a stroke going the other way separates this slot + * before you have read anything else on it. The two short strokes flaring back + * off the tip are the head; the shallow crown below is the disc's near rim + * lying on the floor, with two corona ticks coming off its ends. + * + * The rim is deliberately an **open arc** and not an ellipse. An ellipse is the + * house mark for a far cast, and this is a line cast that happens to finish in + * a circle; closing that curve made the bar say the wrong thing about how the + * slot is aimed, which is the first thing a sigil owes the player. + */ +export const SUNSPEAR = WRAP(` + + + + +`); + +/** + * Wyrm's Breath — the cone, opening up the line cast's diagonal. + * + * Two rays from a single apex at the bottom left, closed by an arc across the + * mouth: that is the whole ability, and a closed mouth on an *open* apex is what + * separates a cone from Sunspear's javelin-into-a-disc at 34px. The S-curve + * inside it is the hollow — the tongue passing through its own middle, which is + * the trick the slot is selling. + * + * The first draft drew the mouth as a straight bar between the two rays. It read + * as a triangle, i.e. as a flat shape, which is exactly the thing the ability + * spends a raymarcher not being; the arc is the only stroke in the mark that + * says there is a volume in there. + */ +export const DRAGONBREATH = WRAP(` + + + +`); + +/** + * Magma Fount — the pool, its seam, and one blob out and back. + * + * A far cast has to say "you look *into* this" first, so the footprint ellipse + * carries the mark. The zigzag across it is the crust seam, and it is the one + * stroke that separates this slot from every other flame circle: a smooth line + * would read as a waterline, while a broken one reads as something with a skin + * on it. The arch leaving the middle and coming back down inside the rim is the + * fount, drawn as a closed loop on purpose — a blob that lands outside its own + * pool is a different ability. + */ +export const MAGMA = WRAP(` + + + + +`); + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/** + * Firewalk — four prints alternating up the line cast's diagonal, and a flame + * off the last one. + * + * The prints are ovals rather than little feet, and that is not a compromise: + * at 34px five toes are five grey pixels and the mark turns to mush. What has + * to survive the scale is the *alternation* — four marks stepping either side + * of an implied diagonal — because that is what the ability is, and it is a + * shape no other flame slot in the set makes. + * + * The lateral offset was ±9 units in the first draft and the trail read as + * scattered blots; at ±6 the eye still joins the marks into a line and the zig + * survives. The single flame curling off the far print is what says the trail + * is burning rather than merely printed, and it goes on the *last* print + * because that is the one that just lit. + */ +export const FIREWALK = WRAP(` + + + + + +`); + +/** + * Wildfire — the footprint ellipse, with the automaton stepping across it. + * + * A far cast has to say "you look into this" first, so the ellipse carries the + * mark. What sits on it is three **squares** in a staircase and a fourth one + * detached beyond them: the front advancing cell by cell, and the ember that + * jumped the gap. Nothing else in the school — or in the set — uses squares, + * so the slot is separable from Magma Fount's ellipse-and-seam at a glance, + * which the first draft was not: that one drew the front as a jagged polyline + * and read as the same mark as the magma crust at 34px. + * + * Right angles are also the one shape that says *lattice* without a caption, + * which is the whole ability. + */ +export const WILDFIRE = WRAP(` + + + + + +`); + +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +export const FLAME_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + firewalk: FIREWALK, + wildfire: WILDFIRE, + magma: MAGMA, + dragonbreath: DRAGONBREATH, + sunspear: SUNSPEAR, + firewhip: FIREWHIP, + pyroclasm: PYROCLASM, + emberflock: EMBERFLOCK, + meteor: METEOR +}; diff --git a/src/ui/glyphs/forge.js b/src/ui/glyphs/forge.js new file mode 100644 index 0000000..935efac --- /dev/null +++ b/src/ui/glyphs/forge.js @@ -0,0 +1,165 @@ +/** + * Forge sigils. + * + * All six have landed — Anvilfall, Sawline, Piston Drive, Gearlock, Quench and + * Shrapnel Bloom — and the marks below are the shipped set. Author any further + * mark with `WRAP` from `./wrap.js`, keyed by ability id, and add it to the map + * below. `index.js` already folds this module into `ELEMENT_SIGILS`, so a new + * sigil needs no edit anywhere else. + * + * This is the one school whose subjects are *machined*, and that is the gift: + * everywhere else in the set a straight line and a right angle are wrong, and + * here they are the point. Flat faces, bevels, parallel rules and struck arcs + * read as manufactured against forty-odd organic marks without anything else + * being done to them. + * + * The thing to resist is detail. A gear with twenty teeth is a cog at 100px + * and a filled circle at 34px; six teeth say "gear" and survive. The same + * applies to the saw and to the anvil's bevels — one bevel, drawn large. + */ +import { WRAP } from './wrap.js'; + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * Shrapnel Bloom — three fragments, and the skips they took to get there. + * + * The ellipse is the far-cast convention. What the mark has to say beyond that + * is *ricochet*, and a burst of straight rays cannot say it — every other + * explosion sigil in the set is straight rays. So each of the three arms is a + * pair of shallow arcs meeting the ellipse at a point: a bounce, drawn. Three + * arms rather than six, because at 34px six turn the ellipse into a smudge and + * the whole read depends on one arc being separable from the next. + * + * The single tumbling chip at the end of the longest arm is what stopped the + * mark reading as a splash: an arc that ends in nothing is water. + */ +export const SHRAPNEL = WRAP(` + + + + + +`); + +/** + * Anvilfall — the silhouette, dropping, into the dish it is about to make. + * + * The anvil is drawn as one closed outline rather than as a face plus a horn, + * because two shapes touching at a corner fill in and turn to mud at 34px. The + * horn is the single point on the left, which is the only part of the profile + * nobody mistakes for a hammer. + * + * The ellipse is the house mark for a far cast, and here it is doing a second + * job: it is the dent, so the anvil is standing *in* it rather than on the + * line. The two ticks overhead are the fall — the one thing the profile alone + * cannot say — and they are deliberately short, because long ones read as + * speed and this ability is about weight. + */ +export const ANVILFALL = WRAP(` + + + + + +`); + +/** + * Gearlock — two wheels in mesh, seen from above. + * + * Both are ellipses, which is the house mark for a far cast and also, by luck, + * exactly what a gear lying flat on the floor looks like from the camera this + * sandbox uses. They are drawn *touching*, because the whole ability is that + * the teeth interlock and a pair with daylight between them says the opposite + * of the thing the slot is for. + * + * The teeth are three ticks on the big wheel and nothing at all on the small + * one. A first version put six ticks on each and at 34px the result was two + * fuzzy discs — the ticks merged into the outline and took the outline with + * them. Three, on one wheel, on the side away from the mesh, is the fewest that + * still reads as "toothed" and the most that survives. + */ +export const GEARLOCK = WRAP(` + + + + + +`); + +/** + * Quench — the billet going in, and the steam coming off. + * + * The ellipse is the bath (and the far cast); the bar is struck straight + * through its centre so it is unmistakably *entering* rather than lying beside + * it. The two curls are the steam, and they are drawn as opposed hooks rather + * than as a rising plume: a plume at this size is a smudge, whereas two hooks + * facing away from each other read as boiling even when they are four pixels + * tall. + * + * The bar stops short of the bottom of the ellipse rather than crossing it, + * which is the one detail that keeps it from reading as a sword on a stand. + */ +export const QUENCH = WRAP(` + + + + +`); + +/** + * Sawline — the blade, the cut behind it, and the spray off the tangent. + * + * The diagonal is the house mark for a line cast and here it is the kerf, so + * the mark reads bottom-left to top-right the way the cast travels. The blade + * is a plain circle: teeth at this size are the same mistake the gear makes at + * twenty of them, and the tooth count is not what this slot is about. + * + * What it *is* about is the three strokes. They leave from the point where the + * circle touches the kerf, and they leave **along the tangent there** rather + * than out from the centre — which is the whole ability in one decision. The + * first version drew them radially and it was immediately a sun with a stick + * through it; swinging them onto the tangent turned the same five strokes into + * a grinder. They fan slightly and they are unequal, because a sheaf is not a + * comb. + */ +export const SAWLINE = WRAP(` + + + +`); + +/** + * Piston Drive — one ram up, two down, over the rail they are bolted to. + * + * The diagonal is the house mark for a line cast, and here it is the rail: a + * single straight rule with three stations on it, drawn as short verticals + * rather than as pistons, because a piston at 34px is a lozenge and a lozenge + * is not saying anything. + * + * What the mark has to say beyond the aim is the **cam**, and it says it by + * making the three stations unequal: one is fully up, one is halfway, one is + * still down. That is the phase offset — the sequence down the line — read as + * a staircase, and it is the only part of this slot that a still frame can + * carry. The stroke over the tall one is the top of its travel, which is what + * turns a stick into something that has arrived somewhere. + */ +export const PISTONDRIVE = WRAP(` + + + +`); + +export const FORGE_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + pistondrive: PISTONDRIVE, + shrapnel: SHRAPNEL, + sawline: SAWLINE, + anvilfall: ANVILFALL, + gearlock: GEARLOCK, + quench: QUENCH, +}; diff --git a/src/ui/glyphs/frost.js b/src/ui/glyphs/frost.js new file mode 100644 index 0000000..c6d6acc --- /dev/null +++ b/src/ui/glyphs/frost.js @@ -0,0 +1,163 @@ +/** + * Frost sigils. + * + * Two shipped: the Lance's snowflake-over-a-point, and the Crown's ring of + * blades. Rimewalker, Hailwrath and Shatterlance belong here. + */ +import { WRAP } from './wrap.js'; + +/** + * Ice — a six-fold snowflake over a rising lance. + * + * Three axes at 60°, each with a pair of barbs, and a heavier vertical that runs + * past the star into a point: the star says frost, the point says skillshot. + */ +export const ICE = WRAP(` + + + + + + + + + +`); + +/** + * Glacier — a crown of blades standing on a ring. + * + * The second sigil built around a *circle you look into*, because it is the + * second far cast and that is the first thing the slot has to say. Where the + * Snare stands one bolt in the middle of its ellipse, this one stands the ring + * itself up: five blades of uneven height rising off the boundary with the + * spire tallest in the middle, which is the silhouette the ability actually + * makes. + */ +export const GLACIER = WRAP(` + + + + + + +`); + +/** + * Hail — stones falling into the circle. + * + * The far cast's ellipse again, because that is the first thing the slot has to + * say, but this one is read from *outside* it: the Crown stands its blades up + * off the boundary, so Hailwrath puts everything above the rim instead and + * leaves the ring itself unbroken. Three steep strokes of unequal length, + * stopped short of the ellipse so they read as still falling, plus one stub + * that has almost landed — the stagger the whole ability is built on, drawn as + * four different distances to go. + * + * A first version drew the stones as small diamonds. At 34px a diamond is a + * dot, four dots over an ellipse is a domino, and the mark said nothing about + * falling; the strokes say it with the same ink. + */ +export const HAIL = WRAP(` + + + + + +`); + +/** + * Rime — three plates peeling off a frozen line. + * + * The line cast's diagonal, laid low across the box so it reads as *floor* + * rather than as a bolt, with three hooks standing off it. Each hook is one + * stroke that leaves the ground, turns over and comes back down — which is the + * entire silhouette of the ability, and the only mark in the set that curls. + * They grow toward the far end, so the diagonal also says which way the cast + * goes. + * + * A first version drew the plates as flat lozenges lying on the line. At 34px + * flat lozenges on a diagonal are a dashed line, and a dashed line says + * nothing; the hooks say "this stands up" with the same amount of ink. + */ +export const RIME = WRAP(` + + + + +`); + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/** + * Shatterlance — one lance, already coming apart. + * + * The other two frost marks are fields of many small things; this slot is a + * single object, so the mark is a single heavy diagonal. The three chips thrown + * off its trailing edge are what stop it reading as the Lance's point again: + * the whole ability is the contrast between one solid and two hundred pieces, + * and the sigil has to carry that at 34px. + */ +export const SHATTERLANCE = WRAP(` + + + +`); + +/** + * Avalanche — a slope shedding into two heaps at their angle of repose. + * + * The line cast's diagonal is the release plane at the top left, and what it + * has shed is the whole mark: two cones sitting on the floor, the far one + * wider and *shallower* than the near one, because a heap that has had longer + * to slump is flatter. That difference in slope between two otherwise + * identical triangles is the ability, drawn with no extra ink. + * + * A first version drew the heap as a single smooth mound. It was unreadable — + * a mound is a hill, and a hill says nothing about material. Two straight-sided + * cones overlapping say "this is granular and it has stopped where it could + * not get any steeper", which is the only thing the mark has to say. + */ +export const AVALANCHE = WRAP(` + + + + +`); + +/** + * Black Ice — a shape and its reflection, across the far cast's ellipse. + * + * The ellipse is the house convention for a zone, and here it doubles as the + * waterline: one peak stands above it and a shorter, inverted twin hangs + * below. Nothing else in the set is symmetric about a horizontal, so the mark + * says "reflective" before it says anything else — which is right, because the + * reflection is the whole ability. + * + * The twin is deliberately *shorter* than the peak rather than a true mirror + * image. A perfectly symmetrical mark reads as a diamond and loses the idea + * completely; foreshortening the lower half is what makes the eye read the + * ellipse as a ground plane seen at an angle. The short stroke on the left is + * the sheen line that says the plane is glassy rather than open. + */ +export const BLACKICE = WRAP(` + + + + +`); + +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +export const FROST_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + blackice: BLACKICE, + avalanche: AVALANCHE, + shatterlance: SHATTERLANCE, + rime: RIME, + hail: HAIL, + ice: ICE, + glacier: GLACIER +}; diff --git a/src/ui/glyphs/hive.js b/src/ui/glyphs/hive.js new file mode 100644 index 0000000..cca977a --- /dev/null +++ b/src/ui/glyphs/hive.js @@ -0,0 +1,160 @@ +/** + * Hive sigils. + * + * Nothing shipped in this school — see `docs/ROSTER-II.md` for what is coming: + * Locust Tide, Web Line, Hive Column, Wasp Funnel, Carapace and Broodburst. + * Author each mark with `WRAP` from `./wrap.js`, keyed by ability id, and add + * it to the map below. `index.js` already folds this module into + * `ELEMENT_SIGILS`, so a new sigil needs no edit anywhere else. + * + * The school is emergence — many small things behaving as one — and the mark + * cannot be many small things. Twenty dots in a 100-unit box is grey. Draw the + * *one* the many add up to: the silhouette the swarm condenses into, the cell + * the lattice is made of, the plate the tessellation locks. Three or four + * cells say "lattice" far better than thirty do. + * + * Where an agent has to appear at all, one is enough and it should be large — + * a single insect at the head of a mass, drawn big enough to have a body and + * two wings, reads at 34px where a scatter of specks does not. + */ +import { WRAP } from './wrap.js'; + +/** + * Web Line — a corner web hung off the drag-line that threw it. + * + * The house rule for a line cast is a diagonal, and here the diagonal is the + * thing that does the work: the drag-line runs off the bottom-left corner and + * the hub sits on it, so the whole mark leans the way the cast is aimed. Three + * radials and two chords is the fewest that reads as *web* rather than as + * *fan* — the chords are what say the radials are connected to each other. + * + * The first draft drew a full orb, all the way round. At 34px a complete web is + * a disc with a texture on it and the counter fills in; a quarter of one keeps + * its white space and is legible at half the size. + */ +export const WEBLINE = WRAP(` + + + + + + +`); + +/** + * Hive Column — three cells that agree with each other, and one that budded off. + * + * The far-cast ellipse sits under a stack of *flat-top* hexagons, and flat-top + * is the whole reason it works: in that orientation a neighbour is directly + * above, sharing a whole edge, so two cells drawn one above the other read as + * *built* rather than as two hexagons someone lined up. The third is offset + * sixty degrees off the second, sharing its upper-left edge, which is what says + * the tower grew by budding and is why its silhouette is lopsided. + * + * Every shared edge is drawn once, by both cells, at the same coordinates — the + * first draft nudged them apart by a couple of units to "show" the seam and the + * mark immediately read as a pile of loose tiles. The lattice is exact or it is + * not a lattice. + */ +export const HIVECOLUMN = WRAP(` + + + + +`); + +/** + * Locust Tide — the last of the three shapes, on the diagonal of a line cast. + * + * The swarm becomes a fist, then a wall, then a spear, and only one of those + * can be the mark. It is the spear: it is what the cast *arrives* as, and a + * spearhead on a diagonal says "thrown down a line" before you have read + * anything else in the box. The two strokes sweeping back off the shaft are one + * pair of wings, drawn large — the school's rule about never trying to draw the + * many, applied to a mark that still has to say *insects*. + * + * The first version drew all three silhouettes in sequence across the box. At + * 34px it was three grey lumps and the diagonal had gone. + */ +export const LOCUSTTIDE = WRAP(` + + + + +`); + +/** + * Carapace — a dome of plates, and one still on its way in. + * + * The far-cast ellipse is the footprint, because that is the first thing a + * ground-targeted slot has to say. The dome over it is drawn as *three* strokes + * and not as one arc: the two seams running down from the crown are what turn a + * bump into an assembly, and they meet the rim exactly where the arc does, + * because a seam that stops short says the plates do not touch. + * + * The loose quadrilateral up and to the right is the plate that has not landed + * yet, at the same tilt as the seam it is going to fill. One is enough — two + * read as debris, and this is the school where everything ends up in its place. + */ +export const CARAPACE = WRAP(` + + + + + +`); + +/** + * Wasp Funnel — a nest mouth, a funnel, and the bands travelling up it. + * + * The far-cast ellipse is the mouth, because a ground-targeted slot has to say + * where it lands first. The two walls flare out of it rather than up from it, + * which is what separates a funnel from a column at 34px. The two arcs across + * them are the ability: they sag in the middle, so they read as bands *wrapped + * round* the cone rather than as rungs drawn over the top of it — and two of + * them, spaced unevenly, say a wave is travelling where three evenly spaced + * ones would just say "ladder". + */ +export const WASPFUNNEL = WRAP(` + + + + + +`); + +/** + * Broodburst — one egg, already open. + * + * The far-cast ellipse under it, and above it a *single* egg split into two + * half-shells tipped apart. One egg and not a clutch: three or four small ovals + * at 34px are three or four grey pills, and the thing the slot has to say is not + * "several" but "these come apart". + * + * The inner edge of each half is a zig-zag rather than a line, and the two + * zig-zags do not mirror each other — a symmetric split reads as a hinge on a + * manufactured case, and this is supposed to have torn. The gap between them is + * deliberately empty: whatever came out is the next second of the cast, and + * drawing it here would put two ideas in one mark. + */ +export const BROODBURST = WRAP(` + + + +`); + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +export const HIVE_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + broodburst: BROODBURST, + waspfunnel: WASPFUNNEL, + carapace: CARAPACE, + locusttide: LOCUSTTIDE, + hivecolumn: HIVECOLUMN, + webline: WEBLINE, +}; diff --git a/src/ui/glyphs/index.js b/src/ui/glyphs/index.js new file mode 100644 index 0000000..1024d26 --- /dev/null +++ b/src/ui/glyphs/index.js @@ -0,0 +1,78 @@ +/** + * Ability sigils for the HUD and the spellbook. + * + * Drawn inline so they inherit `currentColor` (the slot's `--accent`) and need + * no image assets — the same rule the rest of the project follows: nothing is a + * texture, including the icons. + * + * One module per school, folded together here. Splitting them was not + * housekeeping: a hundred sigils in one file is a hundred merge conflicts when + * seventeen agents author them in parallel, and a sigil belongs next to the + * other marks it has to be *distinguishable from*, which is its school's. + * + * A new school adds two lines here — the import, and the spread in + * `ELEMENT_SIGILS` — and `npm run check` fails the roster if a school in + * `SCHOOLS` has no module folded in, so the pair cannot drift apart. + * + * The envelope lives in `./wrap.js`. See that file for the authoring rules — + * the short version is one mark, three to six strokes, and a diagonal for a + * line cast against an ellipse for a far cast. + */ + +import { FROST_SIGILS } from './frost.js'; +import { FLAME_SIGILS } from './flame.js'; +import { STORM_SIGILS } from './storm.js'; +import { STONE_SIGILS } from './stone.js'; +import { VERDANT_SIGILS } from './verdant.js'; +import { VOID_SIGILS } from './void.js'; +import { ARCANE_SIGILS } from './arcane.js'; +import { BLOOD_SIGILS } from './blood.js'; +import { AETHER_SIGILS } from './aether.js'; +import { TIDE_SIGILS } from './tide.js'; +import { FORGE_SIGILS } from './forge.js'; +import { LUMEN_SIGILS } from './lumen.js'; +import { INK_SIGILS } from './ink.js'; +import { CHRONO_SIGILS } from './chrono.js'; +import { HIVE_SIGILS } from './hive.js'; + +/** + * ability id → inline SVG markup. + * + * Flat rather than nested by school, because every consumer looks a sigil up by + * the id it already has and none of them care which file it came from. School + * ordering matches `SCHOOLS` in the registry so a duplicate id would be caught + * by reading the spread in one direction. + */ +export const ELEMENT_SIGILS = Object.freeze({ + ...FROST_SIGILS, + ...FLAME_SIGILS, + ...STORM_SIGILS, + ...STONE_SIGILS, + ...VERDANT_SIGILS, + ...VOID_SIGILS, + ...ARCANE_SIGILS, + ...BLOOD_SIGILS, + ...AETHER_SIGILS, + ...TIDE_SIGILS, + ...FORGE_SIGILS, + ...LUMEN_SIGILS, + ...INK_SIGILS, + ...CHRONO_SIGILS, + ...HIVE_SIGILS +}); + +/** + * The sigil for an ability, or an empty string. + * + * Always use this rather than indexing the map. A registry entry can land + * before its sigil is drawn — that is the normal order of work on a new + * ability, and a slot with no icon is a fine intermediate state. Indexing + * directly puts `undefined` into an `innerHTML` and prints the word on the bar, + * which looks like a bug and is not one. + * + * @param {string} id + * @returns {string} inline SVG, or '' if this ability has no sigil yet + */ +export function sigilFor(id) { + return ELEMENT_SIGILS[id] ?? ''; +} diff --git a/src/ui/glyphs/ink.js b/src/ui/glyphs/ink.js new file mode 100644 index 0000000..8ca163d --- /dev/null +++ b/src/ui/glyphs/ink.js @@ -0,0 +1,166 @@ +/** + * Ink sigils. + * + * Nothing shipped in this school — see `docs/ROSTER-II.md` for what is coming: + * Sumi Stroke, Ink Bloom, Paper Storm, Seal Script, Splatterbrand and + * Scrollward. Author each mark with `WRAP` from `./wrap.js`, keyed by ability + * id, and add it to the map below. `index.js` already folds this module into + * `ELEMENT_SIGILS`, so a new sigil needs no edit anywhere else. + * + * The envelope is stroke-only at a fixed weight, which everywhere else in the + * set is a constraint and here is a coincidence: these six *are* strokes. The + * temptation is therefore to draw a real brushstroke — a swelling, tapering + * mark. `WRAP` cannot: one weight, round caps, no fill. Do not fight it by + * stacking three paths to fake a taper, which at 34px is a blot. Say "brush" + * with the *gesture* — an entry that starts off-box, a hooked exit, a broken + * tail — and let the ability itself carry the dry-brush dynamics. + * + * This is also the school with no bloom, and the sigils should feel it: fewer + * strokes than anywhere else, more white space, nothing radiating. + */ +import { WRAP } from './wrap.js'; + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * SEALSCRIPT — a character hung over the ellipse it was written above. + * + * The house rule for a far cast is an ellipse you look *into*, and here it is + * doing double duty: it is the aim circle and it is the wash of ink on the + * floor that the column stands over. Above it, one seal-script character — + * a stem through two bars — because a column of three tiny characters at 34px + * is a stack of grey rectangles, and the thing to say is *writing*, not *a lot + * of writing*. + * + * The bars are bowed rather than straight. That is the entire difference + * between this and a plumb-line-and-crossbars glyph, and it is the one place in + * the set where a curve is load-bearing: seal script has no straight lines in + * it, and two flat bars read as a diagram. + */ +export const SEALSCRIPT = WRAP(` + + + + +`); + +/** + * SUMISTROKE — one diagonal gesture that stops being a line. + * + * The diagonal is the house mark for a line cast, so the only thing left to say + * is *dry brush*, and the way to say it inside a single stroke weight is to let + * the stroke break. The tuck is the short tick at the top left, set at an angle + * to the gesture the way a real 起筆 reverses before it travels; the body runs + * down the box; and then the mark comes apart into two shortening dashes that + * do not reach the corner. + * + * Drawing the tail as a dotted line was the first attempt and it reads as a + * measurement, not a fray — evenly spaced anything looks deliberate. Two dashes + * of falling length with a widening gap is the smallest thing that reads as + * running out. + */ +export const SUMISTROKE = WRAP(` + + + + +`); + +/** + * INKBLOOM — the house ellipse for a far cast, with an unstable front inside it. + * + * The ring is the pool; the closed curve inside it is the ink. Its four lobes + * are deliberately *unequal* — that is the whole mark. A rosette of five even + * petals is what the first version drew and it reads as a flower, which is the + * one thing a fingering instability is not: the instability is interesting + * precisely because it is not symmetric, and unequal reach is the cheapest way + * to say so in a still image. + * + * The short stroke overhead is the bead still on its way in, and it is what + * stops the mark being read as a lily pad. It stops well clear of the ring so + * that at 34px it stays a separate object rather than joining the blot. + */ +export const INKBLOOM = WRAP(` + + + +`); + +/** + * ORIGAMI — a crane on the diagonal, and the flat sheet it came from. + * + * The house rule for a line cast is a diagonal, and here the diagonal *is* the + * crane's own centre crease — the one line every folded thing has. The two + * strokes hanging off it are the halves either side of that crease, one seen + * nearly edge-on and one nearly flat: the same two strokes at different + * foreshortenings, which is what says "folded" rather than "pointed". + * + * The lozenge left behind at the tail is the ability. A square of paper, + * unfolded, dropping out of the shot. The first draft drew it as a second, + * smaller crane and the mark turned into a flock — at 34px a flock is a smudge, + * and *before and after* is the idea anyway. + */ +export const ORIGAMI = WRAP(` + + + + +`); + +/** + * SCROLLWARD — two scrolls standing on the far-cast ellipse. + * + * The ellipse is the house mark for a ground-targeted cast and here it is also + * the ring the scrolls stand on, so it is doing two jobs and paying for itself. + * + * The idea is in the two vertical strokes: each one **bows near its roll and + * straightens toward the floor**, because that is the ability — curvature + * tightest where the paper has just come off the spool, relaxing as it pays + * out. Draw them as straight lines and the mark says "two posts". The circles + * are the rolls, set at different heights so the pair reads as a wall caught + * mid-rise rather than as a gate. + */ +export const SCROLLWARD = WRAP(` + + + + + +`); + +/** + * SPLATTERBRAND — the house diagonal for a line cast, read as a *distribution*. + * + * Three things sit on it, in the order the ability puts them there: the mass, + * tilted along the travel vector rather than round; two teeth off its leading + * arc and none off its trailing one, which is the asymmetry the whole slot is + * about; and three satellites getting smaller as they get further, which is the + * power law drawn as literally as six strokes allow. + * + * The satellites are the mark. The first draft scattered five dots of one size + * around the blob and it read as a paw print — even sizes say "stencil" in a + * sigil for exactly the reason they say it in the effect. Shrinking them down + * the diagonal is the only thing here that could not be any other ink slot. + */ +export const SPLATTERBRAND = WRAP(` + + + + + + +`); + +export const INK_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + splatterbrand: SPLATTERBRAND, + sealscript: SEALSCRIPT, + origami: ORIGAMI, + scrollward: SCROLLWARD, + inkbloom: INKBLOOM, + sumistroke: SUMISTROKE, +}; diff --git a/src/ui/glyphs/lumen.js b/src/ui/glyphs/lumen.js new file mode 100644 index 0000000..f9df957 --- /dev/null +++ b/src/ui/glyphs/lumen.js @@ -0,0 +1,170 @@ +/** + * Lumen sigils. + * + * All six have landed — Godspear, Solar Lens, Refraction Cascade, Dawnbreak, + * Eclipse and Photon Lattice — and the marks below are the shipped set. Author + * any further mark with `WRAP` from `./wrap.js`, keyed by ability id, and add it + * to the map below. `index.js` already folds this module into `ELEMENT_SIGILS`, + * so a new sigil needs no edit anywhere else. + * + * A school made of light has one obvious mark — rays coming off a disc — and + * if two of these six use it, both are wasted. Radiating strokes are also the + * single worst pattern at slot size: seven lines through a 100-unit box is a + * hatch, and hatch reads as texture rather than as a symbol. + * + * Draw what the light *does* instead. Lumen's tricks are about occlusion, + * bouncing and direction, so the marks that work are the ones with something + * in the way: a beam broken across a mirror, a disc with a bite taken out of + * it, a shaft that lands somewhere. The one thing the school may share is a + * consistent light direction — pick it once and keep it, and six unrelated + * marks will still look like one family. + */ +import { WRAP } from './wrap.js'; + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * Refraction Cascade — one line, folded twice. + * + * A line cast, so it is built on the house diagonal from the lower left to the + * upper right — but the diagonal is *broken*, and each break has a short bar + * lying across it. Three strokes and the whole ability is there: light that + * gets where it is going by not going straight. + * + * The two bars are drawn at the angle a mirror would actually have to be at to + * turn the beam the way the path turns, tilts computed rather than eyeballed, + * which is the same discipline the ability itself runs on. It costs nothing and + * it is the difference between a zig-zag with two ticks on it and a mark + * somebody can read. + * + * A version with the panes as small parallelograms — glass, seen at an angle — + * fills in at 34px and the mark becomes a dashed line with two blobs. + */ +export const REFRACTCASCADE = WRAP(` + + + +`); + +/** + * Solar Lens — a burning glass, and the point it comes to. + * + * Two arcs make a biconvex element, two straight strokes run from its edges to + * a single point on the far-cast ellipse, and that is the ability: something in + * the air, and everything it collects arriving in one place. The convergence + * lands just *inside* the ellipse rather than at its centre, because the point + * walks and a mark that puts it dead centre says "beam", which is the wrong + * slot. + * + * The first draft drew rays coming *out* of the element as well, which is the + * hatch the school header warns about — seven strokes through a 100-unit box — + * and at 34px the element and its rays merged into one grey lozenge. Taking the + * incoming light out entirely and keeping only the cone that leaves is what + * made it legible; the lens shape carries the rest. + */ +export const SOLARLENS = WRAP(` + + + + +`); + +/** + * Dawnbreak — a sun riding a track over a circle you look into. + * + * The far-cast ellipse is the footprint, and the two strokes above it are the + * whole ability: an arc that is a *path* rather than a dome, with the disc + * sitting on it. Nothing radiates, which is the school's one rule, and the mark + * still says "sun" because the arc tells you it is going somewhere. + * + * The first draft drew the sun low and to the left with a long shadow raking + * across the footprint, which is literally what the ability does to the stage. + * At 34px the shadow stroke and the ellipse's near edge merge into one thick + * line and the mark reads as a struck-through circle. The track survives the + * size; the shadow did not. + */ +export const DAWNBREAK = WRAP(` + + + +`); + +/** + * Eclipse — a disc with a bite taken out of it. + * + * Exactly the mark the school's header asks for, and the reason it is not a + * wasted idea here is that occlusion *is* this slot: the second arc is the + * occluder, drawn as a rim rather than as a filled shape so the two circles + * keep their white space where they cross. A corona of rays round the outside + * was tried and is the hatch the header warns about — at slot size it turns the + * whole mark into a grey smudge with a hole in it. + * + * The footprint ellipse is the far cast, and it is drawn thin and low so the + * eye reads the bite first. + */ +export const ECLIPSE = WRAP(` + + + +`); + +/** + * Godspear — a slanted shaft, capped at the mouth, landing in a bar. + * + * The one mark in this school made entirely of straight lines, which is what + * separates it from Dawnbreak, Eclipse and Solar Lens at slot size before any + * colour arrives. It is a line cast, so it is built on a diagonal, and the + * diagonal leans the way `shaftTilt` leans the real thing — the shaft lies over + * with the stage's key light rather than standing on end. + * + * The two edges *diverge* downward because the shaft does: narrow where it + * enters, wide where it lands. The short cap across the top is what stops the + * wedge reading as an arrowhead, and the bar along the bottom is the band on the + * floor, which is half the ability. + * + * A version with three hatch strokes across the wedge — the dust in the beam — + * is exactly the radiating-hatch failure this file's header warns about: at 34px + * the wedge fills in and the mark becomes a grey triangle. + */ +export const GODSPEAR = WRAP(` + + + + +`); + +/** + * Photon Lattice — four beams crossing, and nothing at the crossings. + * + * The mark *is* the trick. Two families of parallel lines meet at four points + * and there is deliberately no dot, no star and no thickening drawn at any of + * them: at 34px the crossings read as bright because two strokes overlap there, + * which is exactly why the nodes in the ability read as bright. Adding four + * little diamonds was the first draft and it is the same lie the shader refuses + * to tell. + * + * It is a far cast, so it sits over the ellipse the school uses for one. The + * lines are drawn on the two isometric bearings rather than square to the box, + * because a square grid at this size reads as a hash symbol. + */ +export const PHOTONLATTICE = WRAP(` + + + + + +`); + +export const LUMEN_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + refractcascade: REFRACTCASCADE, + photonlattice: PHOTONLATTICE, + godspear: GODSPEAR, + solarlens: SOLARLENS, + dawnbreak: DAWNBREAK, + eclipse: ECLIPSE, +}; diff --git a/src/ui/glyphs/stone.js b/src/ui/glyphs/stone.js new file mode 100644 index 0000000..c56f4d3 --- /dev/null +++ b/src/ui/glyphs/stone.js @@ -0,0 +1,134 @@ +/** + * Stone sigils. + * + * Nothing shipped in this school — see `docs/ROSTER.md` for what is coming. + * Author each mark with `WRAP` from `./wrap.js`, keyed by ability id, and add + * it to the map below. `index.js` already folds this module into + * `ELEMENT_SIGILS`, so a new sigil needs no edit anywhere else. + */ +import { WRAP } from './wrap.js'; + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * Obsidian Bloom — one blade with the fracture ribs drawn across it. + * + * The school already has two marks built on an ellipse you look into — the + * Sinkhole's rim and Tectonic's cracked circle — so the ellipse here is pushed + * to the bottom of the frame and used as a *floor* with something standing out + * of it, which is the only mark in the school shaped that way. + * + * The blade's two flanks bow **inward**. That is the entire ability in two + * curves: obsidian breaks in smooth curved shells, so a mark for it whose sides + * were straight would be describing a quartz crystal. The two arcs struck across + * the low end are the rib marks that radiate from wherever the blow landed, and + * they are the reason this reads as broken glass rather than as a tooth. + * + * A ring of six blades was truer to what the ability actually does and turned + * into a smear of verticals at 34px. One blade, big, with the ribs legible, says + * more. + */ +export const OBSIDIAN = WRAP(` + + + + +`); + +/** + * Stone Spine — three slabs heaving off one floor line, on the diagonal. + * + * The house convention for a line cast is a diagonal, and here the diagonal is + * made of the *progression*: each plate is tipped further over than the one + * before it and stands taller, so the mark reads left to right the way the cast + * travels. Every plate is a closed quad rather than a spike, which is the one + * thing this slot has to say before anything else — plates, not spikes — and + * each of them keeps one edge on the floor line, which is the hinge. + */ +export const STONESPINE = WRAP(` + + + + +`); + +/** + * Sinkhole — a rim you look into, with walls that never meet. + * + * The ellipse is the far-cast convention. The two walls run inward from the rim + * and simply stop: there is no floor stroke, because a drawn floor is exactly + * what stops a hole reading as bottomless, and the ability makes the same + * decision in the shader. The block tumbling between them is falling, and it is + * never shown landing either. + */ +export const SINKHOLE = WRAP(` + + + + +`); + + +/** + * Petrify — a column on the diagonal, closed on from both sides, running out. + * + * The diagonal is the house convention for a line cast. The two short strokes + * bracketing it are the facets coming in off the shell — they point *at* the + * line rather than standing on it, which is the whole difference between this + * slot and every other stone slot. The three ticks falling off the low end are + * the sand, and they are the only part of the mark that is not straight. + */ +export const PETRIFY = WRAP(` + + + +`); + +/** + * Tectonic — a circle you look into, with three cracks of unequal length in it. + * + * The ellipse is the house convention for a far cast. What the mark has to say + * beyond that is the trick, so the three cracks are deliberately *not* the same + * length: one has already reached the rim, one is most of the way, one has + * barely left the middle. Each has a single kink, because a straight line out + * of a centre is a spoke and this ability spends a whole slider avoiding that. + * Five cracks would be truer to the effect and unreadable at 34px; three is the + * fewest that still shows a race. + */ +export const TECTONIC = WRAP(` + + + + +`); + +/** + * Boulder — a circle running down a diagonal, over the track it has cut. + * + * The diagonal is the line-cast convention, and here it is the *rut*: two + * parallel strokes converging into the distance with the rock sitting on the + * near end of them, so the mark reads bottom-left to top-right the way the cast + * travels. The two short ticks are what it has thrown out behind itself. The + * circle is the only closed shape in the school, which is what tells it apart + * from Stone Spine's plates at 34px. + */ +export const BOULDER = WRAP(` + + + + +`); + +export const STONE_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + obsidian: OBSIDIAN, + boulder: BOULDER, + stonespine: STONESPINE, + sinkhole: SINKHOLE, + tectonic: TECTONIC, + petrify: PETRIFY +}; diff --git a/src/ui/glyphs/storm.js b/src/ui/glyphs/storm.js new file mode 100644 index 0000000..c09e078 --- /dev/null +++ b/src/ui/glyphs/storm.js @@ -0,0 +1,170 @@ +/** + * Storm sigils. + * + * Two shipped: the Lance's bolt-through-arcs, and the Snare's ring with a bolt + * standing in it. Chain Arc, Thunderclap, Fulminant Orb, Tempest Wall and + * Railcoil belong here. + */ +import { WRAP } from './wrap.js'; + +/** + * Thunder — a bolt struck through a pair of arcs. + * + * The zigzag is drawn on the same diagonal the cast travels on, and the two + * open arcs behind it read as the discharge spreading off it. Stroke only, like + * the snowflake, so the two slots sit at the same visual weight. + */ +export const THUNDER = WRAP(` + + + +`); + +/** + * Snare — a ring with a bolt standing in it. + * + * The only sigil in the set built around a *circle you look into* rather than a + * diagonal, because that is the one thing this slot has to say before anything + * else: it is not a skillshot, it is a footprint. The ellipse is the boundary + * seen in perspective, four arcs step around it where the rim current runs, and + * the zigzag rises out of the middle. + */ +export const SNARE = WRAP(` + + + + +`); + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * Sheet Lightning — the sheet overhead, and the light coming *down* out of it. + * + * A far cast, so it is built on an ellipse; what makes it findable next to the + * school's three other ellipses is where the ellipse *sits*. Thunderclap, + * Snare and Fulminant Orb all put their circle on the floor or around a body, + * and this one is the only mark in the set whose ellipse is at the top of the + * frame with everything else hanging below it — which is the one thing the slot + * has to say before anything else, that the event is above you. + * + * The flat zigzag threaded through the ellipse is the discharge inside the + * cloud: horizontal, because every other storm mark's zigzag runs on the cast + * diagonal and this one goes nowhere. The three rays falling out of it are the + * trick — the ability's whole output is light arriving on things that are not + * the ability. Drawing a ground line under them was tried and it closed the mark + * into a box; leaving the rays to run out of the frame is what says they land on + * whatever happens to be standing there. + */ +export const SHEETLIGHTNING = WRAP(` + + + +`); + +/** + * Thunderclap — a small dome, a conspicuous empty band, and three rings. + * + * A far cast, so it is built on ellipses you look *into*, like the Snare's. What + * separates the two at 34px is that the Snare has one boundary with something + * standing in it and this has *four* concentric marks with nothing in the + * middle. The band between the dome and the innermost ring is the widest gap in + * the drawing and it is the only part of the sigil that is about anything: it is + * the quarter-second of silence the slot is built around, and the first version + * of this mark — five evenly spaced rings — said nothing at all because there + * was no gap left to notice. + */ +export const THUNDERCLAP = WRAP(` + + + + +`); + +/** + * Tempest Wall — the aimed diagonal stopped by a bar drawn across it. + * + * The house convention gives a line cast a diagonal, and this slot's whole + * point is that the diagonal is the wall's *normal*. So the diagonal runs + * up-right in the usual way and then stops dead against a second stroke at + * right angles to it, with three short verticals falling out of that stroke. + * Nothing else in the school has a right angle in it, which is what makes it + * findable on the bar. + */ +export const STORMWALL = WRAP(` + + + +`); + +/** + * Chain Arc — a zigzag that steps, with two earthing ticks under it. + * + * The Lance's bolt is one continuous stroke on a single diagonal; this one is + * the same diagonal broken into four equal *hops* with hard corners and no + * taper, which is the difference the slot is about. The two short ticks + * dropping from the bends are the earthing spikes, and they double as the thing + * that separates this mark from the Lance's at 34px, where the corner count + * alone stops being legible. + */ +export const CHAINARC = WRAP(` + + + +`); + +/** + * Fulminant Orb — a ball with a loop drawn *around* it, on a diagonal. + * + * The only circle in the school that is not lying on the floor. It sits on the + * cast diagonal with a short trail behind it, so it still reads as a line cast, + * and the tilted ellipse crossing it is the cage: the one thing the slot has to + * say is that the filaments go around rather than out, and an ellipse that + * passes behind the ball and comes out the other side says it in one stroke. + * The tick dropping off the underside is a spike earthing. Drawing the cage as + * two crossing ellipses was truer to the effect and turned into a scribble at + * 34px, so there is one. + */ +export const BALLLIGHTNING = WRAP(` + + + + +`); + +/** + * Railcoil — the shot as one unbroken bar, with the coils stacked on the near + * end of it. + * + * Every other storm mark is made of corners, because every other storm effect + * is: the Lance zigzags, Chain Arc hops, the Orb loops. This one is a single + * dead-straight diagonal, and that is the whole point — a shot with no travel + * time has no shape to it. The three bars crossing the low end are the coils on + * the barrel, and they read as coils rather than as a ladder because they sit + * at the *breech* end and stop, leaving the run to the tip clear. + * + * The tick off the far end is the muzzle mark. Two of them made a chevron, + * which turned the mark into an arrow and put it back in the same family as the + * Lance; one is enough to say which way it went. + */ +export const RAILCOIL = WRAP(` + + + +`); + +export const STORM_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + sheetlightning: SHEETLIGHTNING, + railcoil: RAILCOIL, + thunderclap: THUNDERCLAP, + stormwall: STORMWALL, + chainarc: CHAINARC, + balllightning: BALLLIGHTNING, + thunder: THUNDER, + snare: SNARE +}; diff --git a/src/ui/glyphs/tide.js b/src/ui/glyphs/tide.js new file mode 100644 index 0000000..a84511b --- /dev/null +++ b/src/ui/glyphs/tide.js @@ -0,0 +1,178 @@ +/** + * Tide sigils. + * + * All six have landed — Tiderush, Undertow, Brinelock, Geyser, Abyssal Cage and + * Torrent — and the marks below are the shipped set. Author any further mark + * with `WRAP` from `./wrap.js`, keyed by ability id, and add it to the map + * below. `index.js` already folds this module into `ELEMENT_SIGILS`, so a new + * sigil needs no edit anywhere else. + * + * The trap this school walks into: every one of the six is water, so every one + * of them wants to be drawn as a curve, and six curves at 34px are one smudge. + * Pick the *structure* the ability has that the others do not — a crown that + * has stopped (Brinelock), a column standing in its own pool (Geyser), a + * closed sphere (Abyssal Cage), a spiral that goes under (Undertow) — and draw + * that. The wave itself is the least distinguishing thing in the set. + * + * Watch frost, too, and not only the other tide marks: a breaking wave and a + * sheet of ice are neighbours in colour as well as in form, and Brinelock in + * particular has to be told apart from Rimewalker with the colour taken away. + */ +import { WRAP } from './wrap.js'; + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * Brinelock — a crown that has stopped. + * + * The line cast's diagonal, laid low so it reads as a lane on the floor rather + * than as a bolt, with a splash crown standing on it: four fingers of unequal + * height, each one ending in the bead it was about to shed. The beads are the + * mark. Take them away and this is Rimewalker's row of plates or Glacier's ring + * of blades; leave them on and it is unmistakably *water*, and water drawn as + * something rigid is the whole ability in one shape. + * + * A first version drew the crown as a smooth arc — a wave curling over the + * diagonal. At 34px it was a comma, it said "wave" and nothing else, and it was + * indistinguishable from the other five tide marks before any of them existed. + * The frozen fingers say which wave. + */ +export const BRINELOCK = WRAP(` + + + + + + +`); + +/** + * Tiderush — a breaking diagonal with its own light lying on the floor. + * + * The house rule for a line cast is a diagonal, and the wave is it: one long + * back slope hooking over into a curl at the top right, which is the direction + * the cast travels. The three short strokes underneath are the caustic net, and + * they are the reason this mark is not just "a wave" — they get *longer* toward + * the front, because the whole ability is that the brightest light is thrown on + * the ground ahead of the water rather than under it. + * + * The first draft drew the net as three more curves echoing the crest, which is + * what caustics actually look like and is exactly the smudge this school's + * header warns about: five curves in one 34px box and the mark says nothing. + * Straight dashes under a curve keep their white space, and they read as + * *ground* rather than as more water. + */ +export const TIDERUSH = WRAP(` + + + + + +`); + +/** + * Abyssal Cage — a closed sphere built out of smaller closed spheres. + * + * A far cast, so the footprint ellipse is there to say "over there" — but the + * idea is carried by the four circles above it. One big one is the cage; three + * small ones straddle its outline, which is the only stroke in the set that + * says *made of bubbles* rather than *a bubble*. They deliberately overlap the + * big circle rather than sitting inside it: a cluster tucked in the middle + * reads as a diagram of an atom, and at 34px the crossings are what carry the + * foam. + * + * The obvious mark was a rainbow arc, and it is unusable — the sigil set is + * monochrome by construction, so the one thing this ability is actually about + * is the one thing the mark cannot say. Drawing the *structure* instead is the + * same lesson the school header already gives for Brinelock. + */ +export const BUBBLECAGE = WRAP(` + + + + + +`); + +/** + * Torrent — the diagonal, and where it goes afterwards. + * + * A line cast, so the jet is a diagonal, and it is drawn thin and straight to + * say "pressure" rather than "wave". The idea is the three strokes leaving the + * foot of it: they fan out **forward**, along the floor, in the direction the + * jet was already travelling, and they are deliberately of unequal length with + * nothing at all going back up the slope. That asymmetry is the whole ability. + * + * The first version fanned five strokes symmetrically about the contact point, + * which is a starburst — the exact picture this slot exists to argue against — + * and at 34px it was indistinguishable from every impact mark in the set. A fan + * that only goes one way reads as deflection immediately, even before you can + * resolve the individual strokes. + */ +export const TORRENT = WRAP(` + + + + + +`); + +/** + * Undertow — a far cast you look into, with a way out of the bottom. + * + * The house rule for a ground-targeted slot is an ellipse you look *into*, so + * the pool is the ellipse and everything else happens inside it. The idea is + * the arm: one stroke that comes in from the rim and winds once toward the + * centre, and then the throat dropping straight out of the bottom of the mark + * with a chevron on its end. That downward exit is the whole point — of the six + * tide marks this is the only one whose stroke *leaves the ellipse*, which is + * how it stays apart from Abyssal Cage's circles and Geyser's column at 34px. + * + * A true drawn spiral was the obvious mark and it is unreadable small: three + * turns fill the counter and the whole thing goes to a disc. One winding arm + * plus a hole says the same thing with four strokes. + */ +export const UNDERTOW = WRAP(` + + + + +`); + +/** + * Geyser — a column standing in its own pool. + * + * A far cast, so the pool is the ellipse you look into; the idea is the *only + * vertical stroke in the school*. Two lines rise out of the middle of the + * ellipse and splay apart at the top — the inverted funnel, narrow at the vent + * and open at the head — with two short strokes falling back outside them. + * Those two are the whole ability in two marks: the water that went up is the + * water that comes down, so they are drawn shorter than the column and angled + * *outward*, landing back inside the same ellipse. + * + * The nearest neighbour is Undertow, which is also an ellipse with a vertical + * stroke — so this one deliberately goes *up* out of the ring and splays, where + * that one goes down out of it and closes into a chevron. Up-and-open against + * down-and-shut is legible at 34px; two verticals would not be. + */ +export const GEYSER = WRAP(` + + + + + +`); + +export const TIDE_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + geyser: GEYSER, + torrent: TORRENT, + undertow: UNDERTOW, + brinelock: BRINELOCK, + bubblecage: BUBBLECAGE, + tiderush: TIDERUSH, +}; diff --git a/src/ui/glyphs/verdant.js b/src/ui/glyphs/verdant.js new file mode 100644 index 0000000..9da71c4 --- /dev/null +++ b/src/ui/glyphs/verdant.js @@ -0,0 +1,135 @@ +/** + * Verdant sigils. + * + * Nothing shipped in this school — see `docs/ROSTER.md` for what is coming. + * Author each mark with `WRAP` from `./wrap.js`, keyed by ability id, and add + * it to the map below. `index.js` already folds this module into + * `ELEMENT_SIGILS`, so a new sigil needs no edit anywhere else. + */ +import { WRAP } from './wrap.js'; + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * Mycelial Web — the house diagonal, but **broken**, with the two breaks + * crossed by the mortar courses the light actually comes out of. + * + * Every other line cast in this school draws one continuous stem: Thornwake's + * bramble, Vinelash's whip. This slot is the one whose subject is not on the + * surface, so the stem is the only thing in the set that is interrupted, and + * what interrupts it is a transverse stroke rather than empty space — a seam, + * crossing the lane, with the thread reappearing on the far side of it. Barbs + * sit on one side of a stem and stop; these cross it, which is what keeps the + * mark out of Thornwake's territory at 34px. One short fork says the thing is + * a network rather than a root. + * + * The first version put a round cap in each gap instead of a crossing stroke, + * on the theory that a dot reads as a point of light. At slot size a dot on the + * same bearing as the stem simply rejoins it, and the mark went back to being + * an unbroken diagonal with two lumps in it. + */ +export const MYCELIUM = WRAP(` + + + + + + +`); + +/** + * Thornwake — a line cast, so it is built on the house diagonal: one bramble + * stem running low-left to high-right with three barbs hooking back off it. + * The barbs are the read; a bare diagonal is every line cast in the set, and + * the backward hook is the one thing this school does that no other does. + */ +export const THORNWAKE = WRAP(` + + + + +`); + +/** + * Vinelash — the house diagonal again, but the far end *curls back on itself*. + * + * Thornwake owns the straight bramble with barbs, so this one cannot be a + * stem with leaves on it: side by side at 34px the two would be the same + * mark. The single idea here is the hook — the tip whipping back past where it + * reached — and the two cupped leaves are there only to say the stem is alive. + * The first version put five leaves along the stem and it turned into a smudge + * at slot size; the curl survives being shrunk and the leaves nearly do. + */ +export const VINELASH = WRAP(` + + + + +`); + +/** + * Grovecall — a far cast, so the house ellipse, seen in perspective and looked + * *into*. One trunk and one canopy arch stand inside it, and a single slanted + * stroke falls through the crown to the ring: the ability is the light getting + * through, and a grove drawn without it is just a tree in a circle. + * + * Deliberately the only mark in this school with a closed curve — Thornwake and + * Vinelash are both diagonals, and at 34px the closed ring is what separates + * this slot from them before any of the detail resolves. + */ +export const GROVECALL = WRAP(` + + + + +`); + +/** + * Bloomburst — a far cast, so the house ellipse, and inside it one flower seen + * from slightly above: four petals opening off a centre, plus the hint of a + * fifth as the short stroke that keeps the mark from being a perfect cross. + * + * The petals are drawn as *opening* rather than open — each one is a curve that + * leaves the centre tight and turns out at its end, which is the same shape the + * vertex shader draws and the only way a still image says "unfurling". A + * radially symmetric rosette would read as a snowflake at 34px, and the frost + * school already owns that. + */ +export const BLOOMBURST = WRAP(` + + + + + +`); + +/** + * Sporefall — the house ellipse again, but this one is *flat and low*, drawn + * twice: an outer rim and an inner pool, because the ability is a substance + * lying on the ground with a spreading front. Three short rising strokes with a + * dot over each stop dead at the same height — that stopped line is the whole + * slot, and it is what separates this from Grovecall's ellipse, which is + * something standing up inside a ring rather than something leaving one. + */ +export const SPOREFALL = WRAP(` + + + + + + +`); + +export const VERDANT_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + mycelium: MYCELIUM, + bloomburst: BLOOMBURST, + sporefall: SPOREFALL, + grovecall: GROVECALL, + thornwake: THORNWAKE, + vinelash: VINELASH +}; diff --git a/src/ui/glyphs/void.js b/src/ui/glyphs/void.js new file mode 100644 index 0000000..c2bb012 --- /dev/null +++ b/src/ui/glyphs/void.js @@ -0,0 +1,159 @@ +/** + * Void sigils. + * + * Nothing shipped in this school — see `docs/ROSTER.md` for what is coming. + * Author each mark with `WRAP` from `./wrap.js`, keyed by ability id, and add + * it to the map below. `index.js` already folds this module into + * `ELEMENT_SIGILS`, so a new sigil needs no edit anywhere else. + */ +import { WRAP } from './wrap.js'; + +/** + * Void Rift — a slit prised open on the diagonal, still cracking at both ends. + * + * The house rule for a line cast is a diagonal, and the mark obeys it twice + * over: the diagonal *is* the cast, and the two bowed strokes are the same + * diagonal pulled apart. Everything the ability does is in the gap between + * them. The two short strokes running past the tips are the tear continuing + * into rock it has not opened yet, which is the one thing that stops the lens + * shape reading as an eye — and an eye is what the first draft was. + */ +export const VOIDRIFT = WRAP(` + + + + + +`); + +/** + * Singularity — two straight lines that fail to stay straight. + * + * The far-cast ellipse is there because the house rule says a circle you look + * *into* is the first thing a ground-targeted slot has to say, and the small + * ring standing in the middle of it is the horizon. The idea is the other two + * strokes: they come in level from both edges and bow hard around the ring + * before leaving on the same level they arrived at. That is the ability — light + * that was going somewhere else, going around this instead. + * + * The first draft drew the two strokes as a spiral wound onto the ring, which + * is what the accretion disc actually looks like and is completely illegible at + * 34px: it fills the counter and the whole mark goes to a smudge. Bent lines + * keep their white space, and white space is the only thing a sigil has. + */ +export const SINGULARITY = WRAP(` + + + + +`); + +/** + * Nightfall — a lid coming down on a circle you look into. + * + * The far-cast ellipse is the footprint. The two strokes rising off its rim are + * the dome, and the *gap* between their tips is the whole ability: the lid has + * not met yet, and the small cross standing in that gap is the last of the sky. + * Drawing the dome closed gives you a filled shape at 34px and the mark stops + * saying anything at all; the hole is what carries it. + */ +export const NIGHTFALL = WRAP(` + + + + + +`); + +/** Keyed by ability id. */ +/* ------------------------------------------------------------------ */ +/** + * Soul Tether — three links on a sagging line. + * + * Drawn as a catenary rather than a straight diagonal because the sag *is* the + * ability before it snaps taut, and the middle link is turned 90° to its + * neighbours for the same reason the real chain is: an unturned chain reads as + * a string of beads. Three links is the fewest that shows the alternation. + */ +export const SOULCHAIN = WRAP(` + + + + +`); + +/** + * Unmake — three squares climbing a diagonal, the last of them half gone. + * + * The diagonal is the house convention for a line cast and it is not drawn: the + * three squares stand on it, and they *grow* along it, because the one thing + * this slot has to say before anything else is that the pieces get bigger as it + * goes. The first is whole, the second has lost a corner, and the third is two + * sides and a chip leaving — so the mark reads left to right as the same event + * accelerating, which is the ability. + * + * The first draft drew all three the same size and shaded the last one in. At + * 34px that is a row of dots: identical squares carry no direction, and a + * filled counter is a blob. Growth plus subtraction is what survives the size. + */ +export const UNMAKE = WRAP(` + + + + + +`); + +/** + * Silence — an ellipse with the strokes that cross it missing inside it. + * + * The far-cast ellipse is the house convention for a ground-targeted slot, and + * here it is doing a second job: three lines run straight across the mark and + * every one of them stops at the ellipse and picks up again on the far side. + * Nothing is drawn *in* the counter, which is the ability — the lines are not + * bent round the circle, not shaded out and not interrupted by anything you can + * see. They are simply not there for the width of it. + * + * The first draft filled the ellipse in. That is a black disc in front of the + * world and it is exactly the thing this slot is not; it also went to a blob at + * 34px. An empty counter with three broken lines through it says absence with + * no ink at all, which is the same argument the ability makes. + */ +export const SILENCE = WRAP(` + + + + +`); + +/* APPEND NEW MARKS ABOVE THE MAP — one `export const NAME = WRAP(...)` */ +/* per ability, with the doc comment that says what the mark is saying. */ +/* ------------------------------------------------------------------ */ + +/** + * Umbral Spears — three blades on a diagonal, and the shadow they lie in. + * + * The diagonal is the house convention for a line cast; here it is not drawn as + * a line at all but implied by three barbed blades of rising height standing on + * it. The long stroke beneath them is the shadow, offset to one side rather + * than centred, because the one thing this slot has that no other void slot has + * is a light direction. Each blade carries its barb as a single kink, which is + * the only interior detail that survives 34px. + */ +export const UMBRALSPEARS = WRAP(` + + + + +`); + +export const VOID_SIGILS = { + /* --- APPEND SIGILS BELOW THIS LINE (id: NAME,) --- */ + silence: SILENCE, + unmake: UNMAKE, + soulchain: SOULCHAIN, + umbralspears: UMBRALSPEARS, + nightfall: NIGHTFALL, + singularity: SINGULARITY, + voidrift: VOIDRIFT +}; diff --git a/src/ui/glyphs/wrap.js b/src/ui/glyphs/wrap.js new file mode 100644 index 0000000..5197dd7 --- /dev/null +++ b/src/ui/glyphs/wrap.js @@ -0,0 +1,32 @@ +/** + * WRAP — the one SVG envelope every sigil in the set is drawn inside. + * + * A 100×100 box, stroke only, no fill, inheriting `currentColor` — which in the + * HUD is the slot's `--accent`, so a sigil is recoloured by CSS and never by + * re-authoring the mark. Stroke-only is not a style choice: a filled glyph at + * 34px on a dark bar turns into a blob, and the whole point of the set is that + * a hundred of them stay distinguishable at that size. + * + * It lives in its own module so a hundred sigils can be authored across fifteen + * school files without any of them copying the envelope — and so that changing + * the stroke weight for the whole set stays a one-line edit rather than a + * hundred. + * + * Author a sigil as paths in a 100×100 space and hand the body to `WRAP`: + * + * ```js + * const RIME = WRAP(` + * + * `); + * ``` + * + * The rules the existing six follow, worth following: + * - one mark, not a scene — three to six strokes is the budget; + * - line casts are built on a diagonal, far casts on an ellipse you look + * *into*, because the first thing a slot has to say is how it is aimed; + * - no stroke thinner than the shared weight, because the renderer will not + * hint it at 34px and it will simply disappear. + */ +export const WRAP = (body) => + ``; diff --git a/src/ui/styles.css b/src/ui/styles.css index a0ed576..83725be 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -13,12 +13,24 @@ --ui-radius: 14px; --ui-shadow: 0 10px 40px rgba(0, 0, 0, 0.45); --ui-font: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; + /* Small caps used for every tag, key and section label in the HUD. */ + --ui-caps: 0.16em; } * { box-sizing: border-box; } +/* + * `hidden` is how the HUD and the spellbook fold things away, and a flex or + * grid `display` in a class rule beats the attribute's default `display: none` + * every time. One `!important` here is cheaper than remembering that at each + * of the dozen places something is hidden. + */ +[hidden] { + display: none !important; +} + html, body { margin: 0; @@ -143,25 +155,62 @@ body { * HUD * ------------------------------------------------------------------ */ +/* + * The HUD sits *above* the spellbook (z 25) on purpose: the loadout bar has to + * stay lit and droppable while the book is open, and a child cannot escape its + * parent's stacking context, so the whole HUD goes over the top and the panels + * that would fight the book are faded out instead (`.is-book-open`). + */ .hud { position: fixed; inset: 0; pointer-events: none; - z-index: 20; + z-index: 30; font-size: 13px; } +.hud.is-book-open .hud__title, +.hud.is-book-open .hud__stats, +.hud.is-book-open .hud__help { + opacity: 0; + pointer-events: none; +} + +.hud__title, +.hud__stats { + transition: opacity 0.25s ease; +} + /* ------------------------------------------------------------------ * * Ability bar * ------------------------------------------------------------------ */ +/* + * Full width and centred, rather than `left: 50%` + `translateX(-50%)`. + * + * The old trick shrink-wraps: an absolutely positioned box with `left: 50%` + * gets exactly half the viewport to lay out in, which was invisible while the + * bar could not wrap and became a bar that folded after six cards the moment + * it could. Full-bleed with `justify-content: center` gives the row the whole + * window, so eight slots and the transient card stay on one line. + * + * The container is therefore transparent to the pointer — it spans the screen + * — and the cards opt back in. Events still bubble to the delegated listeners. + */ .hud__abilities { position: absolute; - left: 50%; + left: 0; + right: 0; bottom: 26px; - transform: translateX(-50%); display: flex; + justify-content: center; + flex-wrap: wrap; gap: 10px; + padding: 0 22px; + pointer-events: none; +} + +.hud__abilities > .ability-card { pointer-events: auto; } @@ -278,6 +327,109 @@ body { color: var(--ui-text-dim); } +/* + * The slot's name is a button: clicking it opens that ability in the editor + * rather than arming it. It has to look like the label it replaced, so every + * inherited button style is unwound. + */ +button.ability-card__label { + display: block; + width: 100%; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + cursor: pointer; +} + +button.ability-card__label:hover { + color: var(--accent, var(--ui-accent)); + text-decoration: underline; + text-decoration-color: rgba(255, 255, 255, 0.35); + text-underline-offset: 3px; +} + +/* An empty slot: an invitation, drawn as an outline rather than a panel. */ +.ability-card.is-empty { + border-style: dashed; + border-color: rgba(255, 255, 255, 0.14); + background: rgba(10, 14, 20, 0.32); + box-shadow: none; + color: var(--ui-text-dim); +} + +.ability-card.is-empty:hover { + border-color: rgba(127, 214, 255, 0.5); + background: rgba(16, 24, 34, 0.55); + color: var(--ui-text); +} + +.ability-card.is-empty .ability-card__label { + opacity: 0.55; + font-weight: 500; +} + +.ability-card__plus { + display: block; + width: 34px; + height: 34px; + margin: 1px auto 0; + font-size: 20px; + font-weight: 300; + line-height: 34px; + opacity: 0.5; +} + +/* + * The transient slot. An ability chosen straight from the spellbook holds no + * key, but it still needs somewhere to pulse and sweep, so it borrows a ninth + * card marked out with a dotted key box. + */ +.ability-card--loose { + border-color: rgba(255, 255, 255, 0.2); + border-left-style: dotted; +} + +.ability-card--loose .ability-card__key { + opacity: 0.5; +} + +/* Drag and drop: the source dims, the target lights up. */ +.ability-card.is-dragging { + opacity: 0.4; +} + +.ability-card.is-drop { + border-color: var(--ui-accent); + background: rgba(24, 38, 52, 0.9); + box-shadow: 0 0 0 1px var(--ui-accent) inset, 0 0 26px -6px var(--ui-accent); + transform: translateY(-3px); +} + +/* While the book is open, every slot advertises itself as a drop target. */ +.hud.is-book-open .ability-card { + border-color: rgba(127, 214, 255, 0.28); +} + +.hud.is-book-open .ability-card.is-empty { + animation: slot-invite 2.4s ease-in-out infinite; +} + +@keyframes slot-invite { + 0%, + 100% { + border-color: rgba(127, 214, 255, 0.28); + } + 50% { + border-color: rgba(127, 214, 255, 0.62); + } +} + .hud__panel { position: absolute; padding: 12px 14px; @@ -290,10 +442,22 @@ body { line-height: 1.65; } +/* + * The controls panel moved from bottom-left to under the title. + * + * It sat above the ability bar for six slots and 636 pixels of bar. Eight + * slots plus the transient card is 944, and a centred bar that wide runs + * straight through the bottom-left corner — the panel and the first two slots + * were drawing on top of each other. Stacking it under the title keeps the + * left column as one block, leaves the whole bottom edge to the bar, and costs + * nothing: the corner it vacated is the emptiest part of the stage. + */ .hud__help { left: 22px; - bottom: 26px; - max-width: 260px; + top: 126px; + /* Wider than it was: the key list is generated two-up now and eight ability + * names elided at 260px read as eight identical stubs. */ + width: 296px; color: var(--ui-text-dim); transition: opacity 0.3s ease; } @@ -352,6 +516,51 @@ body { text-transform: none; } +/* "6 of 100 spells · Tab spellbook" — the roster's size, and the way in. */ +.hud__roster { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--ui-border); + color: var(--ui-text-dim); +} + +.hud__roster b { + color: var(--ui-accent); + font-weight: 600; +} + +.hud__roster kbd { + margin-left: 6px; +} + +/* + * The generated key list. Two columns of `letter digit — name`, which is the + * densest shape that still reads: eight rows of prose ran the help panel off + * the bottom of a laptop screen. + */ +.hud__keys { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px 10px; + margin-bottom: 4px; +} + +.hud__key { + display: flex; + align-items: baseline; + gap: 3px; + min-width: 0; + font-size: 11px; +} + +.hud__key i { + overflow: hidden; + color: var(--ui-text); + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + .hud__toast { position: absolute; left: 50%; @@ -406,11 +615,483 @@ body { opacity: 0.72; } +/* ------------------------------------------------------------------ * + * Spellbook + * + * The full-screen roster browser. It sits at z 25, under the HUD (z 30), so + * the loadout bar stays lit above it and a card can be dragged onto a slot — + * which is why the panel stops 148px short of the bottom instead of filling + * the viewport. + * ------------------------------------------------------------------ */ + +.spellbook { + position: fixed; + inset: 0; + z-index: 25; + visibility: hidden; + pointer-events: none; + font-size: 13px; +} + +.spellbook.is-open { + visibility: visible; + pointer-events: auto; +} + +.book__scrim { + position: absolute; + inset: 0; + background: radial-gradient(130% 120% at 50% 18%, rgba(14, 22, 32, 0.68), rgba(3, 5, 8, 0.9)); + backdrop-filter: blur(6px) saturate(0.8); + -webkit-backdrop-filter: blur(6px) saturate(0.8); + opacity: 0; + transition: opacity 0.28s ease; +} + +.spellbook.is-open .book__scrim { + opacity: 1; +} + +/* The book itself: one panel in the HUD's language, at a larger scale. */ +.book { + position: absolute; + top: max(20px, 3vh); + bottom: 148px; + left: 50%; + width: min(1420px, calc(100vw - 44px)); + transform: translate(-50%, 14px) scale(0.99); + display: flex; + flex-direction: column; + border-radius: 18px; + border: 1px solid var(--ui-border); + background: linear-gradient(180deg, rgba(13, 18, 26, 0.94), rgba(8, 11, 16, 0.94)); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.03) inset; + opacity: 0; + overflow: hidden; + transition: opacity 0.24s ease, transform 0.24s cubic-bezier(0.2, 0.9, 0.3, 1); +} + +.spellbook.is-open .book { + opacity: 1; + transform: translate(-50%, 0) scale(1); +} + +/* While a card is being dragged the book gets out of the way of the bar. */ +.spellbook.is-dragging .book { + opacity: 0.55; +} + +/* ---- header ---- */ + +.book__head { + display: flex; + align-items: center; + gap: 16px; + padding: 15px 20px 13px; + border-bottom: 1px solid var(--ui-border); +} + +.book__brand { + flex: 0 0 auto; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.24em; + text-transform: uppercase; +} + +.book__count { + margin-left: 10px; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid var(--ui-border); + background: rgba(255, 255, 255, 0.04); + font-size: 10px; + letter-spacing: 0.14em; + color: var(--ui-text-dim); +} + +.book__count b { + color: var(--ui-accent); +} + +.book__search { + position: relative; + flex: 1 1 auto; + display: flex; + align-items: center; + max-width: 520px; + height: 34px; + padding: 0 12px 0 34px; + border-radius: 10px; + border: 1px solid var(--ui-border); + background: rgba(255, 255, 255, 0.05); + transition: border-color 0.18s ease, background 0.18s ease; +} + +.book__search:focus-within { + border-color: rgba(127, 214, 255, 0.55); + background: rgba(255, 255, 255, 0.09); +} + +.book__search-icon { + position: absolute; + left: 11px; + width: 15px; + height: 15px; + fill: none; + stroke: var(--ui-text-dim); + stroke-width: 1.6; + stroke-linecap: round; +} + +.book__search input { + width: 100%; + border: 0; + background: none; + color: var(--ui-text); + font-family: var(--ui-font); + font-size: 13px; + outline: none; +} + +.book__search input::placeholder { + color: rgba(197, 214, 232, 0.42); +} + +/* Bind mode: the book was opened *from* a slot, so a plain click binds. */ +.book__mode { + flex: 0 0 auto; + padding: 5px 12px; + border-radius: 999px; + border: 1px solid rgba(127, 214, 255, 0.45); + background: rgba(127, 214, 255, 0.12); + color: var(--ui-accent); + font-size: 10px; + font-weight: 600; + letter-spacing: var(--ui-caps); + text-transform: uppercase; +} + +/* ---- school rail ---- */ + +/* + * Fifteen chips do not fit across the header, so the rail wraps to two rows. + * It used to scroll horizontally with the scrollbar hidden, which at nine + * schools never scrolled and at fifteen put the last school off the right-hand + * edge with nothing on screen to say it was there. Wrapping costs ~26px of + * body height and keeps the whole roster's shape visible at once, which is + * half of what the rail is for. + */ +.book__rail { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 20px; + border-bottom: 1px solid var(--ui-border); +} + +.book-chip { + display: flex; + align-items: center; + gap: 7px; + flex: 0 0 auto; + padding: 5px 11px; + border-radius: 999px; + border: 1px solid var(--ui-border); + background: rgba(255, 255, 255, 0.03); + color: var(--ui-text-dim); + font-family: var(--ui-font); + font-size: 10px; + font-weight: 600; + letter-spacing: var(--ui-caps); + text-transform: uppercase; + cursor: pointer; + transition: color 0.16s ease, border-color 0.16s ease, background 0.16s ease, opacity 0.16s ease; +} + +.book-chip i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 8px -1px var(--accent); +} + +.book-chip b { + color: rgba(255, 255, 255, 0.35); + font-weight: 600; +} + +.book-chip:hover { + color: var(--ui-text); + border-color: var(--accent); + background: rgba(255, 255, 255, 0.07); +} + +/* The section currently under the top of the body. Deliberately the hover + * treatment plus a wash of the school's own colour rather than a new idea: + * the rail is a position indicator now as well as a menu, and a second visual + * language in a 22px chip would just be noise. */ +.book-chip.is-current { + color: var(--ui-text); + border-color: color-mix(in srgb, var(--accent) 70%, transparent); + background: color-mix(in srgb, var(--accent) 14%, rgba(255, 255, 255, 0.03)); +} + +.book-chip.is-current b { + color: rgba(255, 255, 255, 0.55); +} + +/* A school the filter has emptied stays in place, greyed — the rail is also + * the roster's shape, and a rail that reflows on every keystroke is a rail + * nobody can aim at. */ +.book-chip.is-dim { + opacity: 0.28; +} + +/* ---- body ---- */ + +.book__body { + flex: 1 1 auto; + padding: 0 20px 22px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.18) transparent; +} + +.book__body::-webkit-scrollbar { + width: 10px; +} + +.book__body::-webkit-scrollbar-thumb { + border: 3px solid transparent; + border-radius: 999px; + background: rgba(255, 255, 255, 0.16) padding-box; +} + +.book-school { + padding-bottom: 6px; +} + +/* + * Pinned school headers. The pad-and-negative-margin dance is what lets the + * sticky bar cover the full gutter width while the section itself keeps the + * body's padding — without it the cards slide out from under the header at + * the edges, which looks like a rendering bug. + */ +.book-school__head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 9px; + margin: 0 -20px; + padding: 14px 20px 9px; + background: linear-gradient(180deg, rgba(10, 14, 20, 0.98) 62%, rgba(10, 14, 20, 0)); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--ui-text); +} + +.book-school__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 10px -1px var(--accent); +} + +.book-school__head b { + color: rgba(255, 255, 255, 0.3); + font-size: 10px; + font-weight: 600; +} + +.book-school__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); + gap: 8px; +} + +/* ---- a card ---- */ + +.spell { + position: relative; + display: grid; + grid-template-columns: 38px 1fr auto; + align-items: start; + gap: 11px; + padding: 11px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.022); + cursor: pointer; + user-select: none; + transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; +} + +.spell:hover, +.spell.is-cursor { + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + background: rgba(255, 255, 255, 0.06); + transform: translateY(-1px); +} + +/* The keyboard cursor has to be legible without a pointer anywhere near it. */ +.spell.is-cursor { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent) inset; +} + +/* The ability the app currently has in the slot. */ +.spell.is-selected { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, rgba(255, 255, 255, 0.03)); + box-shadow: 0 0 22px -10px var(--accent); +} + +.spell__glyph { + display: block; + color: var(--accent); +} + +.spell__glyph .glyph-svg { + display: block; + width: 34px; + height: 34px; + filter: drop-shadow(0 0 7px color-mix(in srgb, var(--accent) 70%, transparent)); +} + +.spell__text { + display: block; + min-width: 0; +} + +.spell__name { + display: block; + font-size: 12.5px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--ui-text); +} + +.spell__blurb { + display: block; + margin-top: 3px; + font-size: 11px; + line-height: 1.45; + color: var(--ui-text-dim); +} + +/* ARROW / CIRCLE — how the thing is aimed, in four characters. */ +.spell__shape { + align-self: start; + padding: 3px 7px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(197, 214, 232, 0.5); + font-size: 8.5px; + font-weight: 600; + letter-spacing: 0.14em; + white-space: nowrap; +} + +/* The key it answers to, if it is on the bar. */ +.spell__key { + position: absolute; + right: 10px; + bottom: 9px; + min-width: 17px; + padding: 1px 4px; + border-radius: 5px; + border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; + text-align: center; +} + +.book__empty { + padding: 40px 0; + text-align: center; + color: var(--ui-text-dim); +} + +/* ---- footer ---- */ + +.book__foot { + display: flex; + align-items: center; + gap: 18px; + padding: 10px 20px; + border-top: 1px solid var(--ui-border); + color: var(--ui-text-dim); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.book__foot kbd { + display: inline-block; + min-width: 17px; + padding: 1px 5px; + margin-right: 3px; + border-radius: 5px; + border: 1px solid var(--ui-border); + background: rgba(255, 255, 255, 0.07); + font: 10px/1.5 var(--ui-font); + color: var(--ui-text); + text-align: center; +} + +.book__reset { + margin-left: auto; + padding: 5px 12px; + border-radius: 8px; + border: 1px solid var(--ui-border); + background: rgba(255, 255, 255, 0.05); + color: var(--ui-text-dim); + font-family: var(--ui-font); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; +} + +.book__reset:hover { + border-color: rgba(255, 122, 106, 0.5); + color: #ff9c8f; +} + /* ------------------------------------------------------------------ * * lil-gui theming * ------------------------------------------------------------------ */ -.lil-gui.root { +/* + * The panel itself. + * + * Selected as `body > .lil-gui`, not `.lil-gui.root`. lil-gui prefixed its + * class names at 0.20 — the root element is `.lil-gui.lil-root` now — so this + * whole block, and the title rule under it, silently stopped matching and had + * been dead ever since. Nobody noticed the theming, because lil-gui's own + * default happens to be a dark panel too. What was noticed, eventually, is + * `max-height`: with the authored one dead the panel fell back to the library's + * `100%`, which against `top: 16px` puts the last sixteen pixels of a + * fifteen-school panel below the fold — and at fifteen schools with a hundred + * abilities the panel is always at its limit. The child combinator picks out + * the one GUI autoplaced on the page and is immune to the next rename; it is + * the same idiom the spellbook-fade rule below already uses, and for the same + * reason. + */ +body > .lil-gui { --background-color: rgba(12, 16, 22, 0.9); --widget-color: rgba(255, 255, 255, 0.1); --focus-color: rgba(127, 214, 255, 0.35); @@ -427,10 +1108,26 @@ body { border-radius: 12px; overflow: hidden; box-shadow: var(--ui-shadow); +} + +/* + * The height cap needs two classes to land. + * + * lil-gui ships `.lil-gui.lil-auto-place { max-height: 100% }`, which is + * specificity (0,2,0) and beats `body > .lil-gui` at (0,1,1). `100%` of the + * *containing block* is the viewport, so with `top: 16px` the panel's last + * sixteen pixels sit below the fold — invisible until the panel is tall enough + * to hit the cap, which at fifteen schools it always is. Both spellings, for + * the same reason as the title rule. + */ +body > .lil-gui.lil-auto-place, +body > .lil-gui.autoPlace { max-height: calc(100vh - 32px); } -.lil-gui.root > .title { +/* Both spellings: 0.21 emits `lil-title`, older builds emitted `title`. */ +body > .lil-gui > .lil-title, +body > .lil-gui > .title { letter-spacing: 0.16em; text-transform: uppercase; font-weight: 600; @@ -441,6 +1138,133 @@ body { right: 16px; } +/* Out of the way while the spellbook is being read. */ +body > .lil-gui { + transition: opacity 0.24s ease; +} + +body.is-book-open > .lil-gui { + opacity: 0; + pointer-events: none; +} + +/* ------------------------------------------------------------------ * + * The editor's header strip — search + jump + * + * Not a lil-gui controller: a strip of our own, wedged between the title bar + * and the scrolling children so it is the one row in a panel of a thousand + * controls that never scrolls away. Styled against the HUD's own variables + * rather than lil-gui's, so it matches the rest of the shell whichever + * version of the library is underneath. + * + * The widget rules are qualified with `.lil-gui` for specificity, not for + * scope: our strip lives inside the panel, and lil-gui ships + * `.lil-gui input, .lil-gui button { width: 100% }` — one class and one type, + * which beats a bare class every time. Unqualified, the search field collapses + * to nothing and the jump button eats the row. + * ------------------------------------------------------------------ */ + +.editor-tools { + display: flex; + gap: 6px; + align-items: center; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.28); + border-bottom: 1px solid var(--ui-border); +} + +/* Collapsing the panel by its title takes the strip with it. */ +.lil-gui.lil-closed > .editor-tools, +.lil-gui.closed > .editor-tools { + display: none; +} + +.lil-gui .editor-tools__search { + flex: 1 1 auto; + /* lil-gui's `input { width: 100% }` would size the field to the whole strip + and push the jump button out of the panel. */ + width: auto; + min-width: 0; + height: 22px; + padding: 0 8px; + border: 1px solid var(--ui-border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + color: var(--ui-text); + font-family: var(--ui-font); + font-size: 11px; + outline: none; + appearance: none; + -webkit-appearance: none; +} + +.lil-gui .editor-tools__search::placeholder { + color: var(--ui-text-dim); +} + +.lil-gui .editor-tools__search:focus { + border-color: rgba(127, 214, 255, 0.55); + background: rgba(255, 255, 255, 0.12); +} + +/* The native macOS clear affordance sits badly on a 22px dark field. */ +.lil-gui .editor-tools__search::-webkit-search-cancel-button { + appearance: none; + -webkit-appearance: none; +} + +.lil-gui .editor-tools__jump { + flex: 0 0 auto; + width: 24px; + height: 22px; + padding: 0; + border: 1px solid var(--ui-border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + color: var(--ui-accent); + font-family: var(--ui-font); + font-size: 13px; + line-height: 1; + cursor: pointer; +} + +.lil-gui .editor-tools__jump:hover { + background: rgba(255, 255, 255, 0.16); +} + +.lil-gui .editor-tools__jump:active { + background: rgba(127, 214, 255, 0.25); +} + +/* ------------------------------------------------------------------ * + * Narrow windows + * + * The bar is nine cards wide now, not six, so it is shrunk in two steps + * before it is allowed to wrap — a wrapped bar is two rows tall and the + * spellbook has to be told to stop above it, which is the `bottom` bump in + * the last block. + * ------------------------------------------------------------------ */ + +@media (max-width: 1180px) { + .ability-card { + width: 84px; + } +} + +@media (max-width: 940px) { + .ability-card { + width: 72px; + padding: 10px 6px 8px; + } + .ability-card__label { + font-size: 10px; + letter-spacing: 0.06em; + } + .book { + bottom: 130px; + } +} + @media (max-width: 760px) { .hud__help, .hud__stats { @@ -449,4 +1273,20 @@ body { .ability-card { width: 78px; } + .book { + top: 12px; + bottom: 250px; + width: calc(100vw - 20px); + } + .book__head { + flex-wrap: wrap; + gap: 10px; + } + .book-school__grid { + grid-template-columns: 1fr; + } + /* Keep only "Esc close" and the reset button; the rest is mouse advice. */ + .book__foot span:not(:last-of-type) { + display: none; + } } diff --git a/src/utils/color.js b/src/utils/color.js index 2875b3a..85295e4 100644 --- a/src/utils/color.js +++ b/src/utils/color.js @@ -31,3 +31,29 @@ export function copyColor(target, hex) { export function makeColor(hex) { return getColor(hex).clone(); } + +/** + * `copyColor` for a params bag: takes a `#rrggbb` string **or** a `THREE.Color` + * **or** nothing, and falls back. + * + * Every VFX module reads its colours off a live params object every frame, and + * every one of them has to answer the same three questions: did the caller + * supply this key at all, did they supply a string from a settings block or a + * Color they are already holding, and what does the module look like if they + * did not. `copyColor` above answers none of them — it is the fast path for a + * key you know is a string. + * + * `Dissolve` and `FoldMesh` each arrived with a private, character-identical + * copy of this function; it is here so the next module does not write a third. + * Note `??`, not `||`: an empty string is a caller mistake worth seeing as + * black rather than silently becoming the fallback. + * + * @param {THREE.Color} target the uniform's Color, mutated in place + * @param {string|THREE.Color|null|undefined} value from the params bag + * @param {string|THREE.Color} fallback the module's default + */ +export function putColor(target, value, fallback) { + const v = value ?? fallback; + target.copy(typeof v === 'string' ? getColor(v) : v); + return target; +} diff --git a/src/utils/shaderPatch.js b/src/utils/shaderPatch.js index e6e52df..ee04aa7 100644 --- a/src/utils/shaderPatch.js +++ b/src/utils/shaderPatch.js @@ -1,3 +1,16 @@ +/** + * FNV-1a over a string. Short, stable, and good enough to tell two shader + * injections apart — which is the only thing it is asked to do. + */ +function hashSource(text) { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(36); +} + /** * Compose `onBeforeCompile` callbacks. * @@ -5,13 +18,54 @@ * cascade lookup, we inject procedural colour). Assigning `onBeforeCompile` * naively would silently clobber whichever ran first, so all patching goes * through here. + * + * ## Why `customProgramCacheKey` is not optional here + * + * This is the subtlest bug the project has had, so it is worth the paragraph. + * + * three caches compiled programs and, for a patched material, keys that cache + * on `material.customProgramCacheKey()` — whose default implementation is + * `this.onBeforeCompile.toString()`. That default is exactly right for a + * hand-written `onBeforeCompile`, because two materials carrying *the same* + * function genuinely want the same program. + * + * It is exactly wrong for this helper. Every material patched here gets the + * same six-line wrapper above, so `toString()` returns **identical text for + * every patched material in the project** — the injected code lives in `fn`, + * which the wrapper only closes over and never spells out. Sixty-odd materials + * therefore collided on one cache key, and every one of them after the first + * silently rendered with *some other material's compiled program*: its own + * uniforms did not exist there, the ones that did held another shader's values, + * and the result was NaN fragments. One NaN reaching the HDR buffer 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. It presented as "some abilities black out the screen, + * but only sometimes", because which material won the cache depended on which + * one was cast first. + * + * So the key is derived from `fn`'s own source instead. Materials sharing an + * injection still share a program, which is the behaviour that makes the cache + * worth having; materials with different injections no longer collide. Keys are + * composed down the chain so a second patch on the same material is also + * distinguished from the first. */ export function patchOnBeforeCompile(material, fn) { const previous = material.onBeforeCompile; + const previousKey = material.customProgramCacheKey; + const key = hashSource(fn.toString()); + material.onBeforeCompile = function (shader, renderer) { if (previous) previous.call(this, shader, renderer); fn.call(this, shader, renderer); }; + + // Composed, not replaced: a material can be patched more than once (the + // shadow-caster path does exactly that) and each layer has to contribute. + material.customProgramCacheKey = function () { + const base = previousKey ? previousKey.call(this) : ''; + return `${base}|${key}`; + }; + return material; } diff --git a/src/vfx/ArcNetwork.js b/src/vfx/ArcNetwork.js new file mode 100644 index 0000000..f75f83f --- /dev/null +++ b/src/vfx/ArcNetwork.js @@ -0,0 +1,313 @@ +import { Vector3 } from 'three'; +import { FilamentPaths, MAX_CHAIN_NODES } from './FilamentPaths.js'; +import { clamp, saturate, hash11 } from '../utils/math.js'; + +/* ---------------------------------------------------------------------- */ +/* ArcNetwork — a discharge that hops instead of travelling */ +/* ---------------------------------------------------------------------- */ + +/** + * Node graph plus a segment-lighting clock, for chained discharges. + * + * Everything else in the project that crosses a distance *travels*: a front + * moves at metres per second and the effect is drawn behind it. A chain + * lightning does not do that. It picks a handful of points, and then it is at + * the first one, and then it is at the second one, and the space between them + * was never crossed so much as *skipped*. That discontinuity is the entire + * read, and it is why this is a module rather than a `progress` uniform. + * + * ## What it owns + * + * Three things, and deliberately nothing else: + * + * 1. **the scatter** — `nodes` points between two anchors, stored as + * *unitless fractions* (`along`, `lateral`, `lift`) rolled once per cast + * from one seed. Not one metre is captured. The metres arrive when the + * shader resolves a node against the role's live `scatter` and `lift`, so + * dragging either re-routes a chain that is already in the air, and + * `reseed()` re-routes it outright. + * 2. **the clock** — a cursor that advances in **hops**, not seconds + * (`dt / hopTime`), so changing the hop time mid-flight changes what + * happens next rather than rewriting what already happened. A hop is dark + * until the cursor reaches it, holds for `hold` hops, then decays over + * `overlap` hops. `hold = 0, overlap = 0.6` is a single spark running the + * chain; `hold = 8` lights the whole thing and leaves it lit. + * 3. **the hooks** — `onNode(index, position, count)` fires exactly once per + * node, on the frame the cursor reaches it, with the node's *resolved* + * world position. That is where the burst, the light punch and the decal + * go. + * + * ## What it does not own + * + * A renderer. It draws through `FilamentPaths` in `CHAIN` mode, which is the + * same instanced ribbon strip the bolt and the snare are drawn on — **two draw + * calls**, and the other three role slots are still free, so an ability can + * hang earthing spikes or a rim ring off `net.paths.role(1)` for nothing. + * + * ## The one rule for using it well + * + * **Write `from` and `to` every frame, then call `update()` with a params + * object filled from `settings[id]` that frame.** The network holds dice rolls + * and a hop counter; it holds no geometry. If you find yourself caching a node + * position between frames, use `nodePoint()` instead — it re-derives it, and it + * is the same arithmetic the vertex shader does, so the burst lands *on* the + * node rather than near it. + * + * @example + * this.net = new ArcNetwork(this.group, { capacity: 24 }); + * this.net.onNode = this._nodeBurst; // bound once, never per frame + * // spawn + * this.net.reset(Math.random() * 100); + * // travel / fade, every frame + * this._fillParams(); // reads settings.chainarc into a scratch + * this.net.from.copy(_hand); + * this.net.to.copy(_target); + * this.net.update(dt, this._params, fade); + */ + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _node = new Vector3(); + +/** + * Canonical parameter names with their defaults. + * + * Read fresh on every `update()`; a missing field falls back to the default + * here. The block also carries the whole of `filamentLook()` — width, colours, + * kinks — because it is handed straight to `FilamentPaths.sync()`, so an + * ability keeps one params object rather than two. + */ +export function arcNetworkParams() { + return { + /* --- the graph --- */ + nodes: 6, // points including both ends, 2..MAX_CHAIN_NODES + filaments: 3, // whole polylines drawn over the same nodes + scatter: 1.6, // metres the lateral fractions are scaled by + lift: 0.5, // metres the lift fractions are scaled by + alongJitter: 0.5, // 0..1 of a hop that a node may slide down the line + + /* --- each hop --- */ + sag: 0.12, // metres a hop bows downward at mid-hop + bow: 0.3, // metres a hop bows sideways, alternating per hop + floorY: 0.06, // metres — a node's filament is clamped above this + + /* --- the clock --- */ + hopTime: 0.045, // seconds per hop + hold: 1.4, // hops a lit segment stays at full + overlap: 2.6, // hops it then decays over + tip: 0.3, // how much of a hop the front is smeared over + + /* --- the role's share of the shared look --- */ + kink: 1, // multiplier on the shared `jitter` + chainWidth: 1, // multiplier on the shared `width` + dim: 1, // 0..1 alpha + groundDamp: 0.55 // 0..1 on the kink's world y — a chain skimming the floor wants < 1 + + /* --- plus every field of filamentLook(), passed through to sync() --- */ + }; +} + +export class ArcNetwork { + /** + * @param {THREE.Object3D} parent the ability's group + * @param {object} [options] + * @param {FilamentPaths} [options.paths] draw through an existing instance instead + * of building one — the network then owns neither the meshes nor their disposal + * @param {number} [options.role=0] which role slot of that instance to use + * @param {number} [options.samples=96] nodes along one filament. Higher than the + * bolt's 72 because a polyline has corners to resolve, and a corner landing + * between two samples rounds itself off — which is the one thing this must not do. + * @param {number} [options.capacity=24] filament ceiling + * @param {number} [options.renderOrder] + * @param {number} [options.layer] + */ + constructor(parent, options = {}) { + this.paths = + options.paths ?? + new FilamentPaths(parent, { + samples: options.samples ?? 96, + capacity: options.capacity ?? 24, + renderOrder: options.renderOrder, + layer: options.layer + }); + /** True when we built the FilamentPaths and are therefore allowed to free it. */ + this._ownsPaths = !options.paths; + this._role = this.paths.role(options.role ?? 0); + + /** Anchors. The caller writes these every frame; the network never keeps them. */ + this.from = new Vector3(); + this.to = new Vector3(0, 0, 1); + + /** + * Fired once per node, on the frame the cursor reaches it. + * `(index, position, count)`. `position` is module scratch — read it, do not + * keep it. Assign this once, in `createShaders()`; a closure built per frame + * is the allocation invariant I3 forbids. + */ + this.onNode = null; + + /* --- the dice, and nothing but --- */ + this._lateral = new Float32Array(MAX_CHAIN_NODES); + this._lift = new Float32Array(MAX_CHAIN_NODES); + this._slide = new Float32Array(MAX_CHAIN_NODES); + + this.seed = 0; + /** The lit front, in hops. Unitless, so `hopTime` can move under it. */ + this.cursor = 0; + this._fired = 0; + this._nodes = 2; + + this.reseed(0); + } + + /** The two meshes, if this network built them. */ + get object3D() { + return this.paths.object3D; + } + + /** Two — the chain shares the strip with whatever else is on it. */ + get drawCalls() { + return this.paths.drawCalls; + } + + /** Live nodes, including both ends. */ + get nodeCount() { + return this._nodes; + } + + /** Hops between them. */ + get segments() { + return Math.max(1, this._nodes - 1); + } + + /** 0..1 of the way down the chain the front has reached. */ + get progress() { + return saturate(this.cursor / this.segments); + } + + /** True once the last node has lit. */ + get arrived() { + return this.cursor >= this.segments; + } + + /** Nodes whose hook has already fired. */ + get firedCount() { + return this._fired; + } + + /** + * Re-roll the scatter. + * + * Deterministic in `seed`, and safe to call at any time — including mid-cast, + * which is what "re-rolling the node scatter live re-routes a chain already in + * the air" means. The clock is untouched, so a re-route does not restart the + * discharge, it just changes where the rest of it goes. + */ + reseed(seed) { + this.seed = seed; + for (let i = 0; i < MAX_CHAIN_NODES; i++) { + // Alternating sides, not free scatter. Two consecutive nodes on the same + // side draw a curve, and a curve is a bolt — the zig is the whole point of + // a chain. The magnitude still varies, so it does not read as a zip. + const side = i % 2 === 0 ? 1 : -1; + this._lateral[i] = side * (0.35 + 0.65 * hash11(seed + i * 3.71)); + this._lift[i] = hash11(seed + i * 7.13 + 11.7); + this._slide[i] = hash11(seed + i * 5.31 + 41.3) - 0.5; + } + return this; + } + + /** Start a cast: re-roll, rewind the clock, un-fire every hook. */ + reset(seed) { + this.reseed(seed); + this.cursor = 0; + this._fired = 0; + return this; + } + + /** + * Where node `i` is, in metres. The shader's arithmetic, mirrored. + * @param {number} i + * @param {THREE.Vector3} out + */ + nodePoint(i, out) { + return this.paths.nodePoint(this._role.index, i, out); + } + + /** + * Advance the clock, re-resolve the graph, fire whatever hooks came due. + * + * @param {number} dt seconds + * @param {object} p live params — see `arcNetworkParams()` + * @param {number} [fade] 1 while lit, ramping to 0 as it blows out + */ + update(dt, p, fade = 1) { + const n = clamp(Math.round(p.nodes ?? 6), 2, MAX_CHAIN_NODES); + this._nodes = n; + this.paths.setNodeCount(n); + + /* ---- the scatter, resolved against the live node count ---- */ + // `along` is derived here rather than stored, because the count is a live + // slider: dropping from seven nodes to four has to re-space the survivors, + // not leave a gap where the last three were. + const segs = n - 1; + const slide = (p.alongJitter ?? 0.5) / segs; + for (let i = 0; i < n; i++) { + const end = i === 0 || i === n - 1; + const along = end ? i / segs : i / segs + this._slide[i] * slide; + // The ends are the anchors themselves — the hand and the thing that was + // aimed at. Scattering those makes the cast look like it missed. + this.paths.setNode(i, along, end ? 0 : this._lateral[i], end ? 0 : this._lift[i]); + } + + /* ---- the clock ---- */ + const hop = Math.max(p.hopTime ?? 0.045, 1e-4); + const hold = Math.max(p.hold ?? 1.4, 0); + const overlap = Math.max(p.overlap ?? 2.6, 1e-3); + this.cursor += dt / hop; + // Park it once the tail has passed the last node. Left to run, the cursor + // grows without bound and the float loses the fractional precision the + // front's smear needs. + this.cursor = Math.min(this.cursor, segs + hold + overlap + 1); + + /* ---- the role ---- */ + this._role.count = Math.max(1, Math.round(p.filaments ?? 3)); + this._role.chain( + this.from, + this.to, + p.scatter ?? 1.6, + p.lift ?? 0.5, + p.sag ?? 0.12, + p.bow ?? 0.3, + this.cursor, + hold, + overlap, + Math.max(p.tip ?? 0.3, 1e-3) + ); + this._role.style(p.kink ?? 1, p.chainWidth ?? 1, p.dim ?? 1, p.groundDamp ?? 0.55); + this._role.ends(1, 1, 1, 1); + // The chain does its own front in `chainLit`, so the ribbon-wide clip stays + // out of its way; the floor clamp is the only part of `draw` it wants. + this._role.draw(2, 0.08, p.floorY ?? 0.06, 0); + + /* ---- the hooks, after the role is configured so a node resolves ---- */ + while (this._fired < n && this.cursor >= this._fired) { + const index = this._fired; + this._fired++; + if (this.onNode) this.onNode(index, this.nodePoint(index, _node), n); + } + + this.paths.sync(p, fade, this.seed); + } + + /** Retire the chain. Leaves the instance reusable. */ + clear() { + this._role.retire(); + this.cursor = 0; + this._fired = 0; + if (this._ownsPaths) this.paths.clear(); + } + + dispose() { + if (this._ownsPaths) this.paths.dispose(); + } +} diff --git a/src/vfx/BrushStroke.js b/src/vfx/BrushStroke.js new file mode 100644 index 0000000..baa5905 --- /dev/null +++ b/src/vfx/BrushStroke.js @@ -0,0 +1,1250 @@ +import { + BufferAttribute, + Color, + DoubleSide, + Group, + InstancedBufferAttribute, + InstancedBufferGeometry, + Mesh, + NormalBlending, + ShaderMaterial, + Sphere, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { getColor } from '../utils/color.js'; + +/* ====================================================================== */ +/* BrushStroke — a loaded brush, not a ribbon with a noise mask */ +/* ====================================================================== */ + +/** + * How the bristles are packed into the ferrule. + * + * This is the *only* thing that decides where the dry-brush streaks appear, + * because a streak is one bristle running out of ink and the bristles are laid + * out once, here. It is a construction option rather than a slider because it + * re-lays the per-instance dice; `retip()` exists for an editor dropdown. + */ +export const BrushTip = Object.freeze({ + /** A hake / flat brush: bristles in one rank across the width. Sumi default. */ + FLAT: 0, + /** A round brush: bristles on a golden-angle disc, so streaks split in depth too. */ + ROUND: 1, + /** A worn brush whose bristles have clumped into three tufts, with gaps between. */ + SPLIT: 2 +}); + +/** Names in enum order, for an editor dropdown. */ +export const BRUSH_TIP_NAMES = ['FLAT', 'ROUND', 'SPLIT']; + +/* ---------------------------------------------------------------------- */ +/* Shared GLSL */ +/* ---------------------------------------------------------------------- */ + +/** + * One-dimensional value noise on a hashed lattice, plus the contrast curve + * that stops it piling up in the middle. + * + * Value noise concentrates hard around 0.5 (README trap 4). That is fatal for + * the dry-brush contact test, which compares this against a dryness in 0..1: if + * almost every sample sits near 0.5 then every bristle on the brush loses + * contact within a few millimetres of the same place, and the stroke ends with + * a clean horizontal cut instead of a fray. `uSkipContrast` pushes the + * distribution back out toward the ends; 1.8 is enough that the bristles let go + * across a hand's width of stroke rather than all at once. + */ +const BRUSH_NOISE = /* glsl */ ` + float vnoise1(float x) { + float i = floor(x); + float f = fract(x); + f = f * f * (3.0 - 2.0 * f); + return mix(hash11(i), hash11(i + 1.0), f); + } +`; + +/** + * The stroke lives entirely in this vertex shader. + * + * An instance is one **bristle of one stroke**. Its spine is a cubic Bézier + * handed over as four world-space control points, its width comes from a + * four-point pressure curve, and how much ink it still holds comes from + * integrating what it has already laid down. Nothing about the stroke exists on + * the CPU between frames except the numbers the ability wrote this frame — so + * every metre in here re-resolves on a zero-length frame (I1). + * + * ## Why the bristles are instances + * + * The first version drew one even ribbon and multiplied its alpha by a noise + * field to fake the dry tail. It is the obvious thing and it is wrong in a way + * you can see from across the room: a mask makes *holes in a stroke*, and dry + * brush is not a stroke with holes in it — it is four or five separate marks + * that used to be one mark, each ending at its own place, each with its own + * width, with clean paper between them. The mask version also breaks up + * uniformly along the width, because the noise does not know where the middle + * of the brush is, whereas a real brush keeps ink in the core long after the + * outside bristles have given up. + * + * Modelling the bristles gets all of that for free and costs one instanced draw: + * + * - each bristle carries its own **ink load**, jittered per bristle and + * starved toward the edge of the ferrule (`uEdgeStarve`), so the stroke goes + * dry from the outside in; + * - ink is **spent** by integrating the deposition along the spine, so a + * stroke that presses hard runs out sooner than one that skims — the tail is + * a consequence of the pressure curve rather than a separate slider; + * - past the point where a bristle's load runs low it makes **intermittent + * contact** with the paper, gated per bristle by `vnoise1` along its own + * arclength. That is the fray. + * + * ## Why it is a prism and not a billboard + * + * The cross-section is an ellipse of half-width `bw` (the bristle's ink width) + * and half-depth `uDepth` (metres, through the paper normal), swept along the + * spine. Sealscript hangs a column of characters in the air and the camera + * orbits it; a billboard turned edge-on is thinner than a pixel and the whole + * column blinks out of existence. A prism seen edge-on is a solid bar of ink + * `2 * uDepth` wide, and the writing stays writing. Face-on the depth is + * invisible and it reads flat, which is what sumi wants — the same geometry + * serves both because the ellipse is degenerate in exactly one axis. + * + * ## The ink integral + * + * `inkSpent(t)` is a 13-point trapezoid over the deposition. The analytic + * alternative — integrating only the pressure cubic, which has a closed form — + * was tried first and is wrong: it ignores the brush's *speed*, so a stroke + * with a hairpin in it (control points bunched, brush dwelling) spends no extra + * ink at the hairpin and never runs dry early. The dwell is the whole reason a + * hook pools and the tail after the hook is bone dry. + */ +const BRUSH_VERTEX = /* glsl */ ` + #define TAU 6.283185307179586 + #define INK_QUAD 12 + + /* --- the paper --- */ + uniform vec3 uPaper; // unit normal of the plane the stroke is written in + + /* --- the brush --- */ + uniform float uWidth; // metres, ferrule half-width at pressure 1 + uniform float uDepth; // metres, half-thickness of one bristle's deposit + uniform float uFerruleDepth; // metres the bristles are spread through the paper normal + uniform float uBristles; // how many, so one bristle's share of the width is known + uniform float uBristleWidth; // >1 overlaps into a solid stroke, <1 separates + uniform float uSplay; // fraction the bristles fan out at full pressure + uniform float uWobble; // metres of per-bristle lateral wander + uniform float uFibreScale; // features per metre of that wander + + /* --- ink --- */ + uniform float uFlowLength; // pigment laid per metre of travel, at pressure 1 + uniform float uFlowDwell; // extra pigment laid per metre when the brush dwells + uniform float uSpeedRef; // metres per unit t at which the brush is "at speed" + uniform float uEdgeStarve; // load multiplier at the edge of the ferrule + uniform float uLoadJitter; // +/- fraction of load, per bristle + uniform float uDryBand; // ink units over which a bristle goes wet -> dry + uniform float uDryThin; // width multiplier when a bristle is fully dry + uniform float uSkipScale; // features per metre of the contact noise + uniform float uSkipSoft; // softness of the contact threshold + uniform float uSkipContrast; // pushes the value noise off its central pile + + /* --- pooling --- */ + uniform float uPoolSwell; // fraction the stroke widens per unit of extra pigment + uniform float uPoolCurve; // extra pigment per 1/metre of spine curvature + uniform float uPigment; // overall density multiplier + + /* --- the beat --- */ + uniform float uProgress; // 0..1 across the whole set of strokes + uniform float uHeadTaper; // fraction of the drawn length the moving head tapers over + uniform float uWetLength; // metres behind the head that still read wet + + attribute vec3 aP0; + attribute vec3 aP1; + attribute vec3 aP2; + attribute vec3 aP3; + attribute vec4 aPress; // pressure control points: entry, swell, hold, exit + attribute vec4 aStroke; // drawStart, drawSpan, strokeSeed, inkLoad + attribute vec4 aBrush; // across -1..1, through -1..1, load dice, bristle seed + + varying float vPigment; + varying float vAcross; + varying float vDry; + varying float vContact; + varying float vWet; + varying float vAlong; + varying float vSeed; + varying vec3 vNormalW; + varying float vViewZ; + + ${noiseGLSL} + ${BRUSH_NOISE} + + vec3 safeNormalize(vec3 v, vec3 fallback) { + float l2 = dot(v, v); + return l2 > 1e-10 ? v * inversesqrt(l2) : fallback; + } + + /* --- the spine ---------------------------------------------------- */ + + vec3 bezier(float t) { + float m = 1.0 - t; + return m * m * m * aP0 + 3.0 * m * m * t * aP1 + 3.0 * m * t * t * aP2 + t * t * t * aP3; + } + + vec3 bezierD(float t) { + float m = 1.0 - t; + return 3.0 * (m * m * (aP1 - aP0) + 2.0 * m * t * (aP2 - aP1) + t * t * (aP3 - aP2)); + } + + vec3 bezierDD(float t) { + float m = 1.0 - t; + return 6.0 * (m * (aP2 - 2.0 * aP1 + aP0) + t * (aP3 - 2.0 * aP2 + aP1)); + } + + /** + * Pressure, on the same cubic Bernstein basis as the spine. + * + * Bernstein rather than an interpolating spline on purpose: the curve passes + * through the first and last control points exactly, which is what "entry" + * and "exit" have to mean, and the two middle points only pull at the body. + * An interpolating curve through four authored values overshoots between them + * and the overshoot is a bulge the author did not draw. + */ + float pressureAt(float t) { + float m = 1.0 - t; + return m * m * m * aPress.x + 3.0 * m * m * t * aPress.y + + 3.0 * m * t * t * aPress.z + t * t * t * aPress.w; + } + + /** Pigment laid per metre of travel. 1.0 is a nominal full-pressure stroke. */ + float pigmentAt(float t) { + float sp = max(length(bezierD(t)), 1e-3); + float pr = max(pressureAt(t), 0.0); + // Two terms because deposition has two causes: area swept (scales with the + // distance covered) and time in contact (does not). Where the spine's + // control points bunch, sp collapses, the dwell term blows up, and the ink + // pools — which is the whole of "pooling where the stroke slows". + float dwell = uFlowDwell * (uSpeedRef / sp); + return pr * (uFlowLength + dwell) * uPigment; + } + + /** Ink drawn out of the ferrule between the start of the stroke and t. */ + float inkSpent(float t) { + float h = t / float(INK_QUAD); + float acc = 0.0; + for (int i = 0; i <= INK_QUAD; i++) { + float s = float(i) * h; + float w = (i == 0 || i == INK_QUAD) ? 0.5 : 1.0; + acc += w * pigmentAt(s) * max(length(bezierD(s)), 1e-3); + } + return acc * h; + } + + void main() { + /* + * The head. Strokes are written in sequence: uProgress is one clock for the + * whole set and each stroke owns a window of it. The along-parameter is + * *remapped* into 0..head rather than clipped at it, so the strip spends + * all its samples on the part that exists and the head itself is a full + * cross-section — the brush is still there, it has not been lifted. + */ + float head = clamp((uProgress - aStroke.x) / max(aStroke.y, 1e-4), 0.0, 1.0); + float t = position.x * head; + float ring = position.y; + + vec3 P = bezier(t); + vec3 T = bezierD(t); + float sp = max(length(T), 1e-4); + vec3 Tn = T / sp; + + vec3 paper = safeNormalize(uPaper, vec3(0.0, 1.0, 0.0)); + vec3 W = safeNormalize(cross(Tn, paper), vec3(1.0, 0.0, 0.0)); // across the stroke + vec3 D = safeNormalize(cross(W, Tn), paper); // through the paper + + float pr = max(pressureAt(t), 0.0); + float halfW = uWidth * pr; + + /* --- pooling ---------------------------------------------------- */ + // Curvature in 1/metres. A hairpin is where the brush pivots on its tip and + // dumps ink, and the speed term alone under-reads it because a tight turn + // can still be quick. + vec3 A = bezierDD(t); + float curv = length(cross(T, A)) / (sp * sp * sp); + float pig = pigmentAt(t) * (1.0 + uPoolCurve * curv); + halfW *= 1.0 + uPoolSwell * max(pig - 1.0, 0.0); + + /* --- the head, while it is still moving -------------------------- */ + // step(head, 0.999) is 1 while the stroke is unfinished. A finished stroke + // must not taper at t = 1: that is the exit, and the exit is the author's + // last pressure control point, not ours. + float drawing = step(head, 0.999); + float toHead = (head - t) / max(head, 1e-4); + float headFade = mix(1.0, smoothstep(0.0, max(uHeadTaper, 1e-4), toHead), drawing); + + /* --- arclength, approximated by the chord ------------------------ */ + // Good enough for the noise phases and the wet band, and it costs nothing. + // The exact arclength would need its own quadrature and would move the + // streaks by less than their own width. + float chord = max(length(aP3 - aP0), 1e-3); + float along = t * chord; + float wet = 1.0 - smoothstep(0.0, max(uWetLength, 1e-3), (head - t) * chord); + + /* --- this bristle's ink ------------------------------------------ */ + float load = aStroke.w + * mix(1.0, max(uEdgeStarve, 0.0), abs(aBrush.x)) + * (1.0 + uLoadJitter * (aBrush.z - 0.5) * 2.0); + float remain = load - inkSpent(t); + float dry = 1.0 - clamp(remain / max(uDryBand, 1e-3), 0.0, 1.0); + + float bseed = aBrush.w * 613.0 + aStroke.z * 37.0; + float n = vnoise1(along * uSkipScale + bseed); + n = clamp(0.5 + (n - 0.5) * max(uSkipContrast, 0.0), 0.0, 1.0); + float soft = max(uSkipSoft, 1e-3); + // Below the threshold the bristle is off the paper. step() on remain is not + // redundant with the smoothstep: dryness saturates at 1 while remain keeps + // going negative, and a lucky noise sample would otherwise let a spent + // bristle print a stray dash a metre past its runout. + float contact = smoothstep(dry - soft, dry + soft, n) * step(0.0, remain); + + /* --- the cross-section ------------------------------------------- */ + float bw = halfW * (max(uBristleWidth, 0.0) / max(uBristles, 1.0)); + bw *= mix(1.0, max(uDryThin, 0.0), dry); + float bd = uDepth * mix(1.0, max(uDryThin, 0.0), dry); + + float across = aBrush.x * (1.0 + uSplay * pr); + float wob = (vnoise1(along * uFibreScale + bseed * 1.7) - 0.5) * 2.0 * uWobble; + vec3 centre = P + W * (across * halfW + wob) + D * (aBrush.y * uFerruleDepth); + + float ang = ring * TAU; + vec2 sect = vec2(cos(ang), sin(ang)); + // Collapsing the section onto the spine where contact is lost is what makes + // the fray read as fibres tapering off rather than as a stroke with bites + // taken out of it. The degenerate triangles cost nothing; the fragment + // discards them on alpha anyway. + float sc = contact * headFade; + vec3 world = centre + W * (sect.x * bw * sc) + D * (sect.y * bd * sc); + + // The ellipse normal, written as the un-normalised gradient so a zero + // half-axis does not divide by zero. + vec3 nrm = safeNormalize(W * (sect.x * bd) + D * (sect.y * bw), D); + + vPigment = pig; + vAcross = sect.x; + vDry = dry; + vContact = sc * step(0.0001, head); + vWet = wet; + vAlong = along; + vSeed = bseed; + vNormalW = nrm; + + vec4 mv = modelViewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/** + * Pigment, and nothing that glows. + * + * Two ideas: the **bleed**, which is the wicking at the edge of the mark where + * the vehicle carries the pigment further than the pigment can stay dense; and + * the **ceiling**, which is how this school stays out of the bloom pass. + * + * The bloom high-pass runs on linear scene colour *before* the tone map + * (`RenderPass -> distortion -> UnrealBloomPass -> OutputPass`), so its + * threshold — `settings.post.bloomThreshold`, 0.88 as shipped — is a linear + * luminance. Clamping this material's output luminance to `uCeiling` (0.62 by + * default) therefore makes it *impossible* for ink to feed bloom, whatever the + * caster is standing next to and whatever the pickers are set to. That is + * stronger than "pick dark colours", which is one careless colour picker away + * from a glowing brushstroke. + * + * Note also what is missing: this material never multiplies by `uGlobalGlow`. + * Every other VFX material in the project does, because every other one is + * emissive. Ink is not emissive; the global glow slider must not touch it, or + * the anti-glow school glows whenever anybody turns the sandbox up. + */ +const BRUSH_FRAGMENT = /* glsl */ ` + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform vec3 uLightDir; + + uniform float uBleed; // fraction of the bristle half-width the edge wicks over + uniform float uFibreEdge; // capillary roughness of that edge + uniform float uFibreScale; // features per metre + uniform float uDryPigment; // density multiplier where a bristle is dry + uniform float uWetGain; // extra density in the wet band behind the head + uniform float uOpacity; + uniform float uFade; // the ability's own fade, 0..1 + uniform float uLit; // 0 flat pigment, 1 wrapped diffuse + uniform float uBackLit; // the floor of that wrap + uniform float uCeiling; // max linear luminance — the anti-bloom clamp + uniform float uSoftFade; // metres of depth feather + + uniform vec3 uColorA; // the palest wicked edge + uniform vec3 uColorB; // body + uniform vec3 uColorC; // full-strength ink + uniform vec3 uColorD; // pooled + uniform float uTint; // where in the gradient a zero-density mark sits + uniform float uTintDensity; // how far density walks it + uniform float uTintJitter; // +/- per bristle + + varying float vPigment; + varying float vAcross; + varying float vDry; + varying float vContact; + varying float vWet; + varying float vAlong; + varying float vSeed; + varying vec3 vNormalW; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + ${BRUSH_NOISE} + + void main() { + if (vContact < 0.004) discard; + + // Distance in from the rim of this bristle, 1 on its centreline. + float edge = 1.0 - abs(vAcross); + float fibre = (vnoise1(vAlong * uFibreScale * 3.1 + vSeed * 0.37) - 0.5) * uFibreEdge; + float ink = smoothstep(0.0, max(uBleed, 1e-3), edge + fibre); + if (ink < 0.004) discard; + + float density = vPigment * ink * vContact; + density *= mix(1.0, max(uDryPigment, 0.0), vDry); + density *= 1.0 + uWetGain * vWet; + + float alpha = clamp(density * uOpacity, 0.0, 1.0) * clamp(uFade, 0.0, 1.0); + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + float jitter = (fract(vSeed * 0.618034) - 0.5) * 2.0 * uTintJitter; + float g = clamp(uTint + density * uTintDensity + jitter, 0.0, 1.0); + vec3 colour = gradient4(uColorA, uColorB, uColorC, uColorD, g); + + // Wrapped diffuse and no specular at all. A specular lobe on ink is the one + // thing that would put this school back among the emissive ones; the only + // gloss in Ink is on wet InkDiffusion, where it is a real observation. + vec3 nrm = normalize(vNormalW); + float ndl = dot(nrm, normalize(uLightDir)) * 0.5 + 0.5; + colour *= mix(1.0, mix(max(uBackLit, 0.0), 1.0, ndl), clamp(uLit, 0.0, 1.0)); + + float lum = dot(colour, vec3(0.2126, 0.7152, 0.0722)); + colour *= lum > uCeiling ? uCeiling / max(lum, 1e-4) : 1.0; + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/* ---------------------------------------------------------------------- */ +/* Scratch — the frame allocates nothing (I3) */ +/* ---------------------------------------------------------------------- */ + +const _dir = new Vector3(); +const _side = new Vector3(); +const _mid = new Vector3(); +const _tmp = new Vector3(); +const _up = new Vector3(0, 1, 0); + +/* ---------------------------------------------------------------------- */ +/* A stroke */ +/* ---------------------------------------------------------------------- */ + +/** + * One mark of the brush. + * + * Handles are built once at construction and re-used, the way `FilamentPaths` + * hands out roles — an ability holds no references it did not get from the + * module and nothing here allocates once the brush exists. + * + * Everything a handle is given is a **live dimension**: the ability calls + * `curve()` / `line()` / `pressure()` every frame with values it has just + * resolved from `settings[id]`. The handle stores them so `update()` can expand + * them across the bristles, not so anything can be captured. + */ +class Stroke { + constructor(brush, index) { + this._brush = brush; + this.index = index; + + /** The spine, as four world-space control points. */ + this.p0 = new Vector3(); + this.p1 = new Vector3(); + this.p2 = new Vector3(); + this.p3 = new Vector3(); + + /** Pressure control points: entry, swell, hold, exit. Unitless, 0..1-ish. */ + this.pressEntry = 0.15; + this.pressSwell = 1; + this.pressHold = 0.85; + this.pressExit = 0.05; + + /** Where in `uProgress` this stroke is written. */ + this.drawStart = 0; + this.drawSpan = 1; + + /** + * Ink in the ferrule, in **metre-pigment**: the length of nominal + * full-pressure stroke it can lay before the brush is empty. 9 means "nine + * metres and then nothing", which is a unit an author can actually aim. + */ + this.load = 9; + + /** Unitless, set by `roll()`. Shifts this stroke's bristle noise. */ + this.seed = 0; + + /** Skipped entirely when false. */ + this.active = true; + } + + /** The spine as an explicit cubic. Everything else is sugar over this. */ + curve(p0, p1, p2, p3) { + this.p0.copy(p0); + this.p1.copy(p1); + this.p2.copy(p2); + this.p3.copy(p3); + return this; + } + + /** + * A stroke from `from` to `to`, bowed `bow` metres sideways in the paper + * plane and lifted `lift` metres along the paper normal at its middle. + * + * The two inner control points sit at 1/3 and 2/3 and share the same + * displacement, which gives an arc rather than an S. Ask for an S by calling + * `curve()` — the point of this helper is that a calligraphic stroke is + * almost always one arc, and four points is three too many to author per + * frame for something that simple. + */ + line(from, to, bow = 0, lift = 0) { + const paper = this._brush._paper; + _dir.copy(to).sub(from); + const span = _dir.length(); + if (span > 1e-5) _dir.multiplyScalar(1 / span); + else _dir.set(0, 0, 1); + _side.copy(_dir).cross(paper); + if (_side.lengthSq() < 1e-8) _side.copy(_dir).cross(_up); + if (_side.lengthSq() < 1e-8) _side.set(1, 0, 0); + _side.normalize(); + + this.p0.copy(from); + this.p3.copy(to); + _mid.copy(_side).multiplyScalar(bow).addScaledVector(paper, lift); + this.p1.copy(from).lerp(to, 1 / 3).add(_mid); + this.p2.copy(from).lerp(to, 2 / 3).add(_mid); + return this; + } + + /** + * The pressure curve, in ferrule half-widths. + * + * `entry` and `exit` are hit exactly; `swell` and `hold` pull at the body. + * A sumi stroke is roughly `(0.12, 1.0, 0.8, 0.02)` — set down light, press + * through the body, lift off to nothing. A seal-script stroke is closer to + * `(0.75, 0.9, 0.9, 0.7)`: seal script has almost constant weight, and that + * evenness is exactly what makes it legible as *writing* rather than as + * painting. + */ + pressure(entry, swell, hold, exit) { + this.pressEntry = entry; + this.pressSwell = swell; + this.pressHold = hold; + this.pressExit = exit; + return this; + } + + /** Metre-pigment in the ferrule. Lower it and the tail frays sooner. */ + ink(load) { + this.load = load; + return this; + } + + /** The window of `uProgress` this stroke occupies. */ + timing(start, span) { + this.drawStart = start; + this.drawSpan = span; + return this; + } + + /** How far this stroke has been written, 0..1 of its own length. */ + get head() { + const u = this._brush.material.uniforms.uProgress.value; + return Math.max(0, Math.min(1, (u - this.drawStart) / Math.max(this.drawSpan, 1e-4))); + } +} + +/* ---------------------------------------------------------------------- */ +/* BrushStroke */ +/* ---------------------------------------------------------------------- */ + +/** + * **What it draws.** Brush marks with brush dynamics: a pressure curve that + * swells and lifts, ink that pools where the brush slows or turns, an edge that + * wicks into the paper, and a dry tail that comes apart into separate bristle + * streaks because the bristles are separately modelled and separately run out. + * The mark is a swept ellipse, not a billboard, so it survives being looked at + * edge-on. + * + * **Draw calls.** One, for every stroke and every bristle. Two brushes that + * must differ in *tip layout* are two `BrushStroke`s and two draw calls; two + * that only differ in width, pressure or colour are one. + * + * **What it reads from settings.** Nothing directly. `update(_now, params)` + * takes a live block and resolves `p.key ?? default` against + * `brushStrokeParams()`, and the per-stroke dimensions arrive on the `Stroke` + * handles every frame. Pause with **P** and drag `width`, `flowDwell` or + * `inkLoad` and the standing mark re-inks itself. + * + * **The one rule for using it well.** *Let the pressure curve spend the ink.* + * The tail frays where `inkLoad` runs out against what `pressure()` and the + * spine's own speed have already spent — so a heavy stroke should fray early + * and a skimming one should not fray at all, and the way to move the fray is to + * change how hard the brush is pressing, not to reach for `dryBand`. If you + * find yourself tuning the dry parameters to put the fray in a particular + * place, the pressure curve is wrong. + * + * --- + * + * ## Clock + * + * `update()` **ignores its first argument**, like `Swarm` and `Curtain`. A + * brush mark is not an animation, it is a standing object whose only temporal + * parameter is `progress` — how much of it has been written — and the ability + * owns that beat. Taking a clock as well would mean two sources of truth for + * where the head is, and they would disagree the first time anything paused. + * + * ## Sizing + * + * `strokes * bristles` instances, each `(samples + 1) * sides` vertices. The + * defaults (6 x 14 x 41 x 6) are about 20k vertices, which is nothing. Seal + * script wants many short strokes: build it `{ strokes: 24, bristles: 10, + * samples: 20 }` — a character stroke is 15 cm long and does not need forty + * samples down it — and it lands in the same place. + */ +export class BrushStroke { + /** + * @param {THREE.Object3D} parent the ability's group + * @param {object} [options] + * @param {number} [options.strokes=6] marks the brush can hold at once + * @param {number} [options.bristles=14] bristles per mark — the fray's resolution + * @param {number} [options.samples=40] segments along a spine + * @param {number} [options.sides=6] vertices around the swept ellipse + * @param {number} [options.tip=BrushTip.FLAT] + * @param {boolean} [options.depthWrite=false] + * @param {number} [options.layer=LAYER.VFX] + * @param {number} [options.renderOrder=7] + * @param {string} [options.name] + */ + constructor( + parent, + { + strokes = 6, + bristles = 14, + samples = 40, + sides = 6, + tip = BrushTip.FLAT, + depthWrite = false, + layer = LAYER.VFX, + renderOrder = 7, + name = null + } = {} + ) { + this.strokes = Math.max(1, Math.round(strokes)); + this.bristles = Math.max(1, Math.round(bristles)); + this.samples = Math.max(2, Math.round(samples)); + this.sides = Math.max(3, Math.round(sides)); + this.capacity = this.strokes * this.bristles; + + this.group = new Group(); + this.group.name = name ?? 'BrushStroke'; + this.group.matrixAutoUpdate = false; + parent?.add(this.group); + + /* --- the swept strip --------------------------------------------- */ + const rings = this.samples + 1; + const vertexCount = rings * this.sides; + const positions = new Float32Array(vertexCount * 3); + for (let i = 0; i < rings; i++) { + const u = i / this.samples; + for (let j = 0; j < this.sides; j++) { + const v = (i * this.sides + j) * 3; + positions[v + 0] = u; // along the spine, 0..1 + positions[v + 1] = j / this.sides; // around the ellipse, 0..1 (wraps in the index) + positions[v + 2] = 0; + } + } + const indices = new Uint16Array(this.samples * this.sides * 6); + let k = 0; + for (let i = 0; i < this.samples; i++) { + for (let j = 0; j < this.sides; j++) { + const j1 = (j + 1) % this.sides; + const a = i * this.sides + j; + const b = i * this.sides + j1; + const c = (i + 1) * this.sides + j; + const d = (i + 1) * this.sides + j1; + indices[k++] = a; + indices[k++] = c; + indices[k++] = b; + indices[k++] = b; + indices[k++] = c; + indices[k++] = d; + } + } + + /* --- per-instance state ------------------------------------------ */ + this._p0 = new InstancedBufferAttribute(new Float32Array(this.capacity * 3), 3); + this._p1 = new InstancedBufferAttribute(new Float32Array(this.capacity * 3), 3); + this._p2 = new InstancedBufferAttribute(new Float32Array(this.capacity * 3), 3); + this._p3 = new InstancedBufferAttribute(new Float32Array(this.capacity * 3), 3); + this._press = new InstancedBufferAttribute(new Float32Array(this.capacity * 4), 4); + this._stroke = new InstancedBufferAttribute(new Float32Array(this.capacity * 4), 4); + this._brushAttr = new InstancedBufferAttribute(new Float32Array(this.capacity * 4), 4); + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setAttribute('aP0', this._p0); + geometry.setAttribute('aP1', this._p1); + geometry.setAttribute('aP2', this._p2); + geometry.setAttribute('aP3', this._p3); + geometry.setAttribute('aPress', this._press); + geometry.setAttribute('aStroke', this._stroke); + geometry.setAttribute('aBrush', this._brushAttr); + geometry.setIndex(new BufferAttribute(indices, 1)); + geometry.instanceCount = 0; + // Placed in world space by the vertex shader; its own bounds mean nothing. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.geometry = geometry; + + this.material = new ShaderMaterial({ + transparent: true, + depthWrite, + depthTest: true, + blending: NormalBlending, + // Double-sided because the ellipse degenerates to a flat ribbon whenever + // depth is authored near zero, and a one-sided flat ribbon is invisible + // from below. The prism is closed when it has depth, so this only ever + // costs the degenerate case. + side: DoubleSide, + /* + * Declared true, unlike every other module in this library. + * + * It is inert as things stand — the composer always renders to a target + * and three switches materials to NoToneMapping off-screen, so the grade + * happens once, in OutputPass, for everything. But the flag is a + * statement about what the material *is*, and ink is pigment: it belongs + * under the same grade as the floor it is lying on, not punched through + * it like an ember. Copying `toneMapped: false` off an emissive module + * into a matte one is exactly the mistake this school exists to avoid. + */ + toneMapped: true, + uniforms: sharedUniforms({ + uPaper: { value: new Vector3(0, 1, 0) }, + + uWidth: { value: 0.34 }, + uDepth: { value: 0.035 }, + uFerruleDepth: { value: 0 }, + uBristles: { value: this.bristles }, + uBristleWidth: { value: 1.7 }, + uSplay: { value: 0.18 }, + uWobble: { value: 0.012 }, + uFibreScale: { value: 2.4 }, + + uFlowLength: { value: 0.65 }, + uFlowDwell: { value: 0.35 }, + uSpeedRef: { value: 12 }, + uEdgeStarve: { value: 0.55 }, + uLoadJitter: { value: 0.35 }, + uDryBand: { value: 2.6 }, + uDryThin: { value: 0.55 }, + uSkipScale: { value: 3.4 }, + uSkipSoft: { value: 0.12 }, + uSkipContrast: { value: 1.8 }, + + uPoolSwell: { value: 0.45 }, + uPoolCurve: { value: 0.35 }, + uPigment: { value: 1 }, + + uProgress: { value: 1 }, + uHeadTaper: { value: 0.05 }, + uWetLength: { value: 0.9 }, + + uBleed: { value: 0.22 }, + uFibreEdge: { value: 0.16 }, + uDryPigment: { value: 0.7 }, + uWetGain: { value: 0.3 }, + uOpacity: { value: 1 }, + uFade: { value: 1 }, + uLit: { value: 0.35 }, + uBackLit: { value: 0.72 }, + uCeiling: { value: 0.62 }, + uSoftFade: { value: 0.12 }, + + uColorA: { value: new Color('#b6a893') }, + uColorB: { value: new Color('#544a41') }, + uColorC: { value: new Color('#1b1815') }, + uColorD: { value: new Color('#070605') }, + uTint: { value: 0.04 }, + uTintDensity: { value: 0.82 }, + uTintJitter: { value: 0.08 } + }), + vertexShader: BRUSH_VERTEX, + fragmentShader: BRUSH_FRAGMENT + }); + + this.mesh = new Mesh(geometry, this.material); + this.mesh.name = `${this.group.name}:mesh`; + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(layer); + this.mesh.renderOrder = renderOrder; + this.mesh.visible = false; + this.group.add(this.mesh); + + /* --- handles, built once ----------------------------------------- */ + this._strokes = []; + for (let i = 0; i < this.strokes; i++) this._strokes.push(new Stroke(this, i)); + this._liveStrokes = this.strokes; + this._liveInstances = 0; + + this._paper = new Vector3(0, 1, 0); + this._tip = tip; + this.seed = 0; + + this._p = brushStrokeParams(); + this.roll(0); + this.retip(tip); + } + + get object3D() { + return this.group; + } + + get uniforms() { + return this.material.uniforms; + } + + /** One. However many strokes, however many bristles. */ + get drawCalls() { + return 1; + } + + /** Instances drawn on the last update. → `Ability#instanceCount`. */ + get count() { + return this._liveInstances; + } + + /** Strokes the ability is currently using, ≤ `strokes`. */ + get strokeCount() { + return this._liveStrokes; + } + + get tip() { + return this._tip; + } + + /** How many of the handles are written this cast. Clamped to capacity. */ + setStrokeCount(n) { + this._liveStrokes = Math.max(0, Math.min(this.strokes, Math.round(n))); + return this; + } + + /** Handle `i`, built at construction. Out of range returns the last one. */ + stroke(i) { + return this._strokes[Math.max(0, Math.min(this.strokes - 1, i | 0))]; + } + + /** + * The plane the writing lies in, as its unit normal. + * + * Sumi lays a stroke on the floor and passes world up. Seal script hangs a + * column in the air facing the caster and passes the horizontal facing + * direction — and it is precisely then that the swept ellipse earns its keep, + * because orbiting to 90° off that normal is what would kill a billboard. + */ + setPaper(normal) { + this._paper.copy(normal); + if (this._paper.lengthSq() < 1e-8) this._paper.set(0, 1, 0); + this._paper.normalize(); + this.material.uniforms.uPaper.value.copy(this._paper); + return this; + } + + /** + * Four pickers, none derived from another: wicked edge, body, full ink, + * pooled. Takes `THREE.Color`s or `#rrggbb` straight out of a settings block. + */ + setColors(a, b, c, d) { + const u = this.material.uniforms; + u.uColorA.value.copy(typeof a === 'string' ? getColor(a) : a); + u.uColorB.value.copy(typeof b === 'string' ? getColor(b) : b); + u.uColorC.value.copy(typeof c === 'string' ? getColor(c) : c); + const pool = d ?? c; + u.uColorD.value.copy(typeof pool === 'string' ? getColor(pool) : pool); + return this; + } + + /** + * Re-roll the unitless dice. Call from `onSpawn` and nowhere else. + * + * The dice are all a bristle carries: how much ink it started with relative + * to its neighbours, and where its own contact noise sits. Every metre those + * turn into is resolved in the vertex shader from live uniforms. + */ + roll(seed = Math.random() * 100) { + this.seed = seed; + const brush = this._brushAttr.array; + for (let i = 0; i < this.capacity; i++) { + brush[i * 4 + 2] = Math.random(); // load dice + brush[i * 4 + 3] = Math.random(); // contact-noise seed + } + this._brushAttr.needsUpdate = true; + for (let s = 0; s < this.strokes; s++) { + // Derived rather than random so two casts with the same seed write the + // same characters — a preset has to be reproducible. + this._strokes[s].seed = ((seed * 0.6180339887 + s * 0.7548776662) % 1) * 100; + } + return this; + } + + /** + * Lay the bristles out in the ferrule. Cheap; safe to drive from a dropdown. + * + * FLAT is one rank across the width — the sumi brush, and the layout where a + * dry tail comes apart into clean parallel streaks. ROUND spreads them on a + * golden-angle disc so the streaks separate through the paper normal as well, + * which is what a round brush's tail actually does and which needs + * `ferruleDepth` to be non-zero to show. SPLIT clumps them into three tufts + * with paper between: a brush somebody has already ruined, and the fastest + * way to a mark that looks two hundred years old. + */ + retip(tip) { + this._tip = tip; + const brush = this._brushAttr.array; + const n = this.bristles; + for (let b = 0; b < n; b++) { + let across; + let through = 0; + if (tip === BrushTip.ROUND) { + // Vogel's spiral: the only cheap fill of a disc with no clumps and no + // preferred axis. A random fill leaves holes and doubles, and holes in + // a ferrule read as a brush that was already dry. + const r = Math.sqrt((b + 0.5) / n); + const a = b * 2.399963229728653; + across = r * Math.cos(a); + through = r * Math.sin(a); + } else if (tip === BrushTip.SPLIT) { + const tufts = 3; + const per = Math.max(1, Math.ceil(n / tufts)); + const tuft = Math.min(tufts - 1, Math.floor(b / per)); + const within = per > 1 ? ((b % per) / (per - 1) - 0.5) * 2 : 0; + // The tuft is deliberately much narrower than its share of the ferrule: + // a split brush is defined by the *paper between the tufts*, and at + // 0.72 the tufts touched and the mark came back out as a solid stroke. + across = ((tuft + 0.5) / tufts - 0.5) * 2 + within * (0.42 / tufts); + } else { + across = n > 1 ? ((b + 0.5) / n - 0.5) * 2 : 0; + } + for (let s = 0; s < this.strokes; s++) { + const i = s * n + b; + brush[i * 4 + 0] = across; + brush[i * 4 + 1] = through; + } + } + this._brushAttr.needsUpdate = true; + return this; + } + + /** Hide the brush. Leaves the instance reusable — the pooling contract. */ + reset() { + this._liveInstances = 0; + this._liveStrokes = this.strokes; + this.geometry.instanceCount = 0; + this.mesh.visible = false; + for (let s = 0; s < this.strokes; s++) { + const stroke = this._strokes[s]; + stroke.active = true; + stroke.p0.set(0, 0, 0); + stroke.p1.set(0, 0, 0); + stroke.p2.set(0, 0, 0); + stroke.p3.set(0, 0, 0); + stroke.timing(0, 1); + } + return this; + } + + /** Fill `_p` from the caller's block, defaults where it is silent. */ + _resolve(params) { + const p = this._p; + for (const key in DEFAULT_PARAMS) { + const value = params[key]; + p[key] = value === undefined ? DEFAULT_PARAMS[key] : value; + } + return p; + } + + /** + * Push the live params into the uniforms and expand the strokes across the + * bristles. + * + * @param {number} _now ignored — see the clock note in the class doc. + * @param {object} params live block; anything absent falls back to + * `brushStrokeParams()`. + */ + update(_now, params) { + const p = this._resolve(params ?? DEFAULT_PARAMS); + const u = this.material.uniforms; + + /* --- the strokes -------------------------------------------------- + * Written every frame, from whatever the ability resolved this frame. The + * arrays are the transport, not the storage: nothing in here survives a + * frame in a form that could go stale under a paused slider. + */ + const n = this.bristles; + let live = 0; + for (let s = 0; s < this._liveStrokes; s++) { + const stroke = this._strokes[s]; + const base = s * n; + /* + * An inactive stroke is *parked*, not skipped. Its slots stay where they + * are and its draw window is pushed past the end of the clock, so its + * head resolves to zero and the vertex shader collapses it. Compacting + * the live strokes down instead would slide every later stroke into a + * different slot, and the slot is where the bristle dice live — the + * remaining characters would silently re-write themselves. + */ + const parked = !stroke.active; + for (let b = 0; b < n; b++) { + const i = base + b; + const v3 = i * 3; + const v4 = i * 4; + this._p0.array[v3 + 0] = stroke.p0.x; + this._p0.array[v3 + 1] = stroke.p0.y; + this._p0.array[v3 + 2] = stroke.p0.z; + this._p1.array[v3 + 0] = stroke.p1.x; + this._p1.array[v3 + 1] = stroke.p1.y; + this._p1.array[v3 + 2] = stroke.p1.z; + this._p2.array[v3 + 0] = stroke.p2.x; + this._p2.array[v3 + 1] = stroke.p2.y; + this._p2.array[v3 + 2] = stroke.p2.z; + this._p3.array[v3 + 0] = stroke.p3.x; + this._p3.array[v3 + 1] = stroke.p3.y; + this._p3.array[v3 + 2] = stroke.p3.z; + this._press.array[v4 + 0] = stroke.pressEntry; + this._press.array[v4 + 1] = stroke.pressSwell; + this._press.array[v4 + 2] = stroke.pressHold; + this._press.array[v4 + 3] = stroke.pressExit; + this._stroke.array[v4 + 0] = parked ? 1e4 : stroke.drawStart; + this._stroke.array[v4 + 1] = Math.max(stroke.drawSpan, 1e-4); + this._stroke.array[v4 + 2] = stroke.seed; + this._stroke.array[v4 + 3] = stroke.load * p.inkLoad; + } + if (!parked) live = base + n; + } + + this._p0.needsUpdate = true; + this._p1.needsUpdate = true; + this._p2.needsUpdate = true; + this._p3.needsUpdate = true; + this._press.needsUpdate = true; + this._stroke.needsUpdate = true; + + this._liveInstances = live; + this.geometry.instanceCount = live; + this.mesh.visible = live > 0 && p.opacity > 0 && p.fade > 0 && p.width > 0; + + /* --- the brush ---------------------------------------------------- */ + u.uWidth.value = p.width; + u.uDepth.value = p.depth; + u.uFerruleDepth.value = p.ferruleDepth; + u.uBristles.value = this.bristles; + u.uBristleWidth.value = p.bristleWidth; + u.uSplay.value = p.splay; + u.uWobble.value = p.wobble; + u.uFibreScale.value = p.fibreScale; + + /* --- ink ----------------------------------------------------------- */ + u.uFlowLength.value = p.flowLength; + u.uFlowDwell.value = p.flowDwell; + u.uSpeedRef.value = p.speedRef; + u.uEdgeStarve.value = p.edgeStarve; + u.uLoadJitter.value = p.loadJitter; + u.uDryBand.value = p.dryBand; + u.uDryThin.value = p.dryThin; + u.uSkipScale.value = p.skipScale; + u.uSkipSoft.value = p.skipSoft; + u.uSkipContrast.value = p.skipContrast; + + /* --- pooling -------------------------------------------------------- */ + u.uPoolSwell.value = p.poolSwell; + u.uPoolCurve.value = p.poolCurve; + u.uPigment.value = p.pigment; + + /* --- the beat -------------------------------------------------------- */ + u.uProgress.value = p.progress; + u.uHeadTaper.value = p.headTaper; + u.uWetLength.value = p.wetLength; + + /* --- the mark --------------------------------------------------------- */ + u.uBleed.value = p.bleed; + u.uFibreEdge.value = p.fibreEdge; + u.uDryPigment.value = p.dryPigment; + u.uWetGain.value = p.wetGain; + u.uOpacity.value = p.opacity; + u.uFade.value = p.fade; + u.uLit.value = p.lit; + u.uBackLit.value = p.backLit; + u.uCeiling.value = p.ceiling; + u.uSoftFade.value = p.softFade; + u.uTint.value = p.tint; + u.uTintDensity.value = p.tintDensity; + u.uTintJitter.value = p.tintJitter; + + this.setColors(p.colorWash, p.colorBody, p.colorInk, p.colorPool); + if (p.paper) this.setPaper(p.paper); + } + + /* --- CPU mirrors -------------------------------------------------- * + * The ability needs the tip of the brush to hang a light, a wisp of smoke or + * a particle emitter on. These evaluate the same cubic the vertex shader + * does: change one, change the other. + */ + + /** The spine of stroke `i` at parameter `t`, in world space. */ + pointAt(i, t, out) { + const s = this.stroke(i); + const m = 1 - t; + out.set(0, 0, 0); + out.addScaledVector(s.p0, m * m * m); + out.addScaledVector(s.p1, 3 * m * m * t); + out.addScaledVector(s.p2, 3 * m * t * t); + out.addScaledVector(s.p3, t * t * t); + return out; + } + + /** The unit tangent of stroke `i` at `t`. Falls back downrange if degenerate. */ + tangentAt(i, t, out) { + const s = this.stroke(i); + const m = 1 - t; + out.set(0, 0, 0); + _tmp.copy(s.p1).sub(s.p0); + out.addScaledVector(_tmp, 3 * m * m); + _tmp.copy(s.p2).sub(s.p1); + out.addScaledVector(_tmp, 6 * m * t); + _tmp.copy(s.p3).sub(s.p2); + out.addScaledVector(_tmp, 3 * t * t); + if (out.lengthSq() < 1e-10) out.set(0, 0, 1); + return out.normalize(); + } + + /** How far stroke `i` has been written, 0..1 of its own length. */ + headOf(i) { + return this.stroke(i).head; + } + + /** Where the brush is right now on stroke `i`. */ + tipPoint(i, out) { + return this.pointAt(i, this.headOf(i), out); + } + + /** The pressure curve of stroke `i` at `t`, unitless. */ + pressureOf(i, t) { + const s = this.stroke(i); + const m = 1 - t; + return ( + m * m * m * s.pressEntry + + 3 * m * m * t * s.pressSwell + + 3 * m * t * t * s.pressHold + + t * t * t * s.pressExit + ); + } + + /** Half-width of the mark of stroke `i` at `t`, in metres. Reads `_p`. */ + widthAt(i, t) { + return Math.max(0, this.pressureOf(i, t)) * this._p.width; + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + this.group.parent?.remove(this.group); + } +} + +/* ---------------------------------------------------------------------- */ +/* Params */ +/* ---------------------------------------------------------------------- */ + +/** + * Every key `update()` understands, with its default and its unit. + * + * `inkLoad` is a *multiplier* on the per-stroke load rather than the load + * itself, because the load is genuinely per stroke — the first character of a + * column is written with a full brush and the last one is not — while the + * slider that says "how wet is this cast" is one number for the whole thing. + */ +export function brushStrokeParams() { + return { + /* --- the brush --- */ + width: 0.34, // metres, ferrule half-width at pressure 1 + depth: 0.035, // metres, half-thickness through the paper normal + ferruleDepth: 0, // metres the bristles spread through that normal (ROUND tips) + bristleWidth: 1.7, // >1 overlaps into a solid stroke, <1 separates + splay: 0.18, // fraction the bristles fan out at full pressure + wobble: 0.012, // metres of per-bristle lateral wander + fibreScale: 2.4, // features per metre of that wander + paper: null, // optional Vector3 normal; setPaper() otherwise + + /* --- ink --- */ + inkLoad: 1, // multiplier on every stroke's own metre-pigment load + flowLength: 0.65, // pigment laid per metre of travel, at pressure 1 + flowDwell: 0.35, // extra pigment laid per metre when the brush dwells + speedRef: 12, // metres per unit t at which the brush is "at speed" + edgeStarve: 0.55, // load multiplier at the edge of the ferrule + loadJitter: 0.35, // +/- fraction of load, per bristle + dryBand: 2.6, // metre-pigment over which a bristle goes wet -> dry + dryThin: 0.55, // width multiplier when a bristle is fully dry + skipScale: 3.4, // features per metre of the contact noise + skipSoft: 0.12, // softness of the contact threshold + skipContrast: 1.8, // pushes the value noise off its central pile + + /* --- pooling --- */ + poolSwell: 0.45, // fraction the stroke widens per unit of extra pigment + poolCurve: 0.35, // extra pigment per 1/metre of spine curvature + pigment: 1, // overall density multiplier + + /* --- the beat --- */ + progress: 1, // 0..1 across the whole set of strokes + headTaper: 0.05, // fraction of the drawn length the moving head tapers over + wetLength: 0.9, // metres behind the head that still read wet + + /* --- the mark --- */ + bleed: 0.22, // fraction of the bristle half-width the edge wicks over + fibreEdge: 0.16, // capillary roughness of that edge + dryPigment: 0.7, // density multiplier where a bristle is dry + wetGain: 0.3, // extra density in the wet band behind the head + opacity: 1, + fade: 1, // the ability's own fade, 0..1 + lit: 0.35, // 0 flat pigment, 1 wrapped diffuse + backLit: 0.72, // the floor of that wrap — high, because ink is matte + ceiling: 0.62, // max linear luminance; post.bloomThreshold is 0.88 + softFade: 0.12, // metres of depth feather against solid geometry + tint: 0.04, // where in the gradient a zero-density mark sits + tintDensity: 0.82, // how far density walks it + tintJitter: 0.08, // +/- per bristle + + /* --- four pickers, none derived from another --- */ + colorWash: '#b6a893', // the palest wicked edge + colorBody: '#544a41', + colorInk: '#1b1815', + colorPool: '#070605' + }; +} + +/** Resolved once at module load; `_resolve` walks its keys every frame. */ +const DEFAULT_PARAMS = brushStrokeParams(); diff --git a/src/vfx/Caustics.js b/src/vfx/Caustics.js new file mode 100644 index 0000000..57bdd71 --- /dev/null +++ b/src/vfx/Caustics.js @@ -0,0 +1,1173 @@ +import { + AddEquation, + Color, + CustomBlending, + DoubleSide, + Mesh, + OneFactor, + OneMinusSrcAlphaFactor, + ShaderMaterial, + Vector2, + Vector3, + Vector4 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { acquireGroundQuad, releaseGroundQuad } from './quads.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { getColor } from '../utils/color.js'; + +/* ---------------------------------------------------------------------- */ +/* Caustics — the net of light a water surface throws on the floor */ +/* ---------------------------------------------------------------------- */ + +/** + * An animated caustic net projected onto the ground. + * + * ## Why this is a module and not a decal + * + * A caustic is not a picture of a pattern. It is the *image of a refracting + * surface*, and the only reason it is worth having in a sandbox with a floor is + * that the pattern on the ground and the water above it are the same object + * seen twice. `tiderush`'s whole trick is that you read the wave's thickness + * off the floor ahead of it; if the floor is running its own unrelated loop of + * squiggles, the two never agree and every viewer works it out in a second + * without being able to say why. + * + * So the net here is computed from a **height field**, and the height field is + * pluggable: + * + * | source | the height field | + * | --- | --- | + * | `SCROLL` | two counter-drifting worley lattices, differenced — the fallback | + * | `WAVE` | the Gerstner swell + breaking crest + ripple packets that `LiquidSurface` draws, with **the same uniform boxes**, shared by identity | + * | `CUSTOM` | a GLSL chunk the caller supplies, defining `float causticHeight(vec2 xz)` | + * + * `bindSource(liquid.uniforms)` is the whole hook: it swaps our uniform boxes + * for the surface's, so there is literally one set of numbers driving the wave + * and the light under it. Drag `crestHeight` with the game paused and the wave + * and its caustics move together, because there is nothing to keep in step. + * + * ## How the pattern is drawn, and the version that failed + * + * The received recipe for caustics is *the difference of two scrolling worley + * fields, raised to a power*. The first version here did exactly that and it is + * wrong in a way that is hard to unsee once noticed: raising a cell distance to + * a power gives you **blobs with soft shoulders**, because a worley field is + * smooth everywhere except at its cell walls. Real caustics are the opposite — + * hairline-thin highlights an order of magnitude brighter than anything near + * them, with genuinely black water between. You cannot get that falloff by + * choosing a better exponent, because the thing being sharpened is the wrong + * quantity. + * + * What is actually happening is a *fold*. Light entering the surface at `xz` + * refracts and lands on the floor at + * + * A(xz) = xz - k · D · ∇h(xz) k = 1 - 1/ior, D = depth in metres + * + * and the brightness at the arrival point is the reciprocal of how much that + * map stretched the area it came from — `1 / |det J|`, `J = ∂A/∂xz`. Where the + * map folds, `det J` passes through zero and the brightness goes to infinity: + * that singular curve *is* the filament, and it is a curve, not a blob, which is + * why it comes out thin without being told to be. Everywhere else `det J ≈ 1` + * and the floor is dark. Both halves of the look fall out of one term. + * + * J = I - k·D·H(h) H = the Hessian of the height field + * det J = (1 - a·hxx)(1 - a·hzz) - (a·hxz)² a = k·D + * + * Six height taps give the Hessian by finite differences. That moves the worley + * difference **one derivative earlier**: it is still what the `SCROLL` source's + * height field is made of, but the folds of a worley-difference surface are the + * sharp veins along its cell walls rather than the walls themselves. The direct + * recipe is still there for `SCROLL` as `ridgeMix`, because at low depth the + * fold term has nothing to fold and a little of the old mottle keeps the floor + * from going empty — but it defaults low and it is not what you are looking at. + * + * ## Chromatic dispersion is free, and it is real + * + * Three channels refract at slightly different `ior`, so `a` differs per + * channel, so the curve `det J = 0` sits in a *different place* for red than for + * blue. The fringes are therefore spatially separated filaments, not a hue + * gradient painted along one filament — which is the difference between water + * and a decal with a rainbow on it. One extra `det` per channel, no extra taps. + * + * ## Depth + * + * The quad lies in the ground plane with the depth test on, so the character + * occludes it. That is not enough on its own: a floor quad and the floor are + * within millimetres of each other and a foot planted on the boundary gets a + * hard bright line up its ankle. The fragment therefore also fades against + * `frame.uSceneDepth` over `depthFade` metres, the same way `GroundField` does, + * so the net dies out as it approaches anything standing on the floor instead + * of climbing it. + * + * ## What it costs + * + * **One draw call**, no textures. It is *fill*-bound, not vertex-bound, and the + * `SCROLL` source is the expensive one: six taps × two worley lattices × nine + * cells is a hundred-odd hashes a pixel. Treat it like `LiquidSurface` — one per + * screen, and keep `radius` honest, because the quad is sized from it. + * + * @example + * // construction — tiderush + * this.water = new LiquidSurface({ mode: LiquidMode.WAVE }); + * this.group.add(this.water.object3D); + * this.net = new Caustics(this.group, { + * source: CausticSource.WAVE, + * shape: CausticShape.LANE + * }); + * this.net.bindSource(this.water.uniforms); // ONE heightfield, two consumers + * + * // module scope + * const _net = causticsParams(); + * + * // every frame + * _net.centre = this.origin; // the same anchor the surface was placed at + * _net.yaw = this.yaw; // ...and the same yaw + * _net.depth = c.causticDepth; // metres of water over the floor + * _net.front = this.travel; // 0..1, the same front the wave is on + * this.net.update(_net); + */ + +/** Where the refracting height field comes from. A `#define`, fixed at build. */ +export const CausticSource = Object.freeze({ + SCROLL: 0, + WAVE: 1, + CUSTOM: 2 +}); + +/** How the projector's own falloff shapes the net. Also a `#define`. */ +export const CausticShape = Object.freeze({ + DISC: 0, + CONE: 1, + LANE: 2 +}); + +/** Human names, for the editor and for `check.mjs` error messages. */ +export const CAUSTIC_SOURCE_NAMES = Object.freeze(['SCROLL', 'WAVE', 'CUSTOM']); +export const CAUSTIC_SHAPE_NAMES = Object.freeze(['DISC', 'CONE', 'LANE']); + +/** + * Ripple packets carried by the `WAVE` source. + * + * **Not configurable.** It is eight because `LiquidSurface.RIPPLE_SLOTS` is + * eight, and a bound `uRipples` array whose length disagrees with the array the + * shader declared uploads garbage into the tail. If that constant ever moves, + * this one moves with it in the same commit. + */ +export const CAUSTIC_RIPPLE_SLOTS = 8; + +/** + * The uniform boxes `bindSource()` is allowed to take over. + * + * Exactly the keys the `WAVE` height field reads, spelled the way + * `LiquidSurface` spells them. Anything not in here stays ours — in particular + * the projector, the fold and the colours, which belong to the light on the + * floor and not to the water. + */ +export const CAUSTIC_BOUND_KEYS = Object.freeze([ + 'uHalf', + 'uSeed', + 'uNow', + 'uWaveAmp', + 'uWaveLength', + 'uWaveSpeed', + 'uWaveAngle', + 'uChop', + 'uChopScale', + 'uChopSpeed', + 'uWaveFront', + 'uCrestHeight', + 'uCrestBack', + 'uCrestFace', + 'uCrestWidth', + 'uCrestFeather', + 'uCrestBreak', + 'uCrestBreakScale', + 'uRipples', + 'uRippleAmp', + 'uRippleSpeed', + 'uRippleLength', + 'uRippleWidth', + 'uRippleDecay', + 'uRippleSpread' +]); + +/* ---------------------------------------------------------------------- */ +/* The height fields */ +/* ---------------------------------------------------------------------- */ + +/** + * `SCROLL` — two counter-drifting worley lattices, differenced. + * + * The feature points *orbit* inside their cells rather than only sliding with + * the domain. A lattice that only slides is a conveyor belt: the eye finds the + * direction of travel in about a second and the whole thing reads as a texture + * being dragged. Orbiting costs one `sin` per cell and the net boils in place, + * which is what water on a floor does when nobody is throwing anything into it. + */ +const SCROLL_SOURCE = /* glsl */ ` + uniform float uSourceAmp; // metres of relief in the fake surface + uniform float uCellScale; // cells per metre, lattice A + uniform float uCellRatio; // lattice B's scale, as a multiple of A + uniform float uCellJitter; // 0..1 how far a feature point wanders in its cell + uniform vec2 uDrift; // cells per second, lattice A (B goes the other way) + uniform float uBoil; // radians per second the feature points orbit + uniform float uRidgeMix; // 0..1 blend of the *direct* worley-difference net + uniform float uRidgeScale; // how wide a direct vein is, in field units + uniform float uRidgePower; // the exponent the received recipe asks for + + /** F1 distance to an animated feature-point lattice. */ + float causticCell(vec2 p, float phase, float jitter) { + vec2 n = floor(p); + vec2 f = fract(p); + float best = 8.0; + for (int j = -1; j <= 1; j++) { + for (int i = -1; i <= 1; i++) { + vec2 g = vec2(float(i), float(j)); + vec2 o = hash21(dot(n + g, vec2(7.13, 113.17))); + o = 0.5 + jitter * 0.5 * sin(phase + 6.283185307 * o); + vec2 r = g + o - f; + best = min(best, dot(r, r)); + } + } + return sqrt(best); + } + + float causticHeight(vec2 xz) { + vec2 p = xz * uCellScale; + vec2 drift = uDrift * uTime; + float a = causticCell(p + drift, uTime * uBoil + uSeed, uCellJitter); + float b = causticCell(p * uCellRatio - drift * 0.6, -uTime * uBoil * 0.83 + uSeed * 1.7, uCellJitter); + return uSourceAmp * (a - b); + } + + /** + * The received recipe, kept so the fold term has something to sit on when + * there is barely any water to fold light through. Zero at defaults + a bit. + */ + float causticRidge(vec2 xz) { + if (uRidgeMix <= 0.0) return 0.0; + vec2 p = xz * uCellScale; + vec2 drift = uDrift * uTime; + float a = causticCell(p + drift, uTime * uBoil + uSeed, uCellJitter); + float b = causticCell(p * uCellRatio - drift * 0.6, -uTime * uBoil * 0.83 + uSeed * 1.7, uCellJitter); + float vein = clamp(1.0 - abs(a - b) * max(uRidgeScale, 0.01), 0.0, 1.0); + return pow(vein, max(uRidgePower, 0.1)) * uRidgeMix; + } +`; + +/** + * `WAVE` — the surface `LiquidSurface` is drawing, re-evaluated as a height + * field. + * + * Every uniform below is spelled exactly as `LiquidSurface` spells it so that + * `bindSource()` can hand this shader the *same boxes*. Two deliberate + * omissions, both because a caustic needs `h(x, z)` and Gerstner does not give + * you one: + * + * - the **horizontal** Gerstner throw (`uSteepness`) is dropped. Including it + * would mean inverting the surface's parametrisation at every one of six taps + * to find which material point is over this floor point. What is lost is a + * slight lateral lag of the net behind the crest at high steepness; what would + * be spent is six Newton iterations a tap. + * - the crest's forward **curl** throw (`uCrestCurl`) is dropped for the same + * reason. The crest's *height* is fully present, and the height is what bends + * the light. + * + * The fragment-only `uDetail` chop is also absent, on purpose: it is a normal-map + * wrinkle worth a few millimetres, and its second derivative is enormous. Feed + * it in and the floor fills with a fizzing static of sub-pixel folds that + * aliases the moment the camera moves. + */ +const WAVE_SOURCE = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform vec2 uHalf; // half-extents of the surface, metres + uniform float uNow; // seconds since the cast began — the ripple clock + + uniform vec4 uWaveAmp; // metres, one component per directional wave + uniform vec4 uWaveLength; // metres, crest to crest + uniform vec4 uWaveSpeed; // metres/second + uniform vec4 uWaveAngle; // radians, bearing in the surface plane + + uniform float uChop; // metres + uniform float uChopScale; // cycles per metre + uniform float uChopSpeed; // metres/second the chop drifts + + uniform float uWaveFront; // 0..1 along +X + uniform float uCrestHeight; // metres + uniform float uCrestBack; // metres — the long back slope's 1/e length + uniform float uCrestFace; // metres — the short front face's 1/e length + uniform float uCrestWidth; // 0..1 of the half-extent across the wave + uniform float uCrestFeather; // 0..1 of that, over which the ends die + uniform float uCrestBreak; // 0..1 how ragged the lip is + uniform float uCrestBreakScale; // cycles per metre along the lip + + uniform vec4 uRipples[${CAUSTIC_RIPPLE_SLOTS}]; // (u, v, born, strength) + uniform float uRippleAmp; + uniform float uRippleSpeed; + uniform float uRippleLength; + uniform float uRippleWidth; + uniform float uRippleDecay; + uniform float uRippleSpread; + + float causticWave(vec2 xz, float amp, float len, float spd, float ang) { + if (amp <= 0.0) return 0.0; + vec2 d = vec2(cos(ang), sin(ang)); + float k = TAU / max(len, 0.05); + return amp * sin(dot(d, xz) * k - uTime * spd * k); + } + + float causticCrest(vec2 xz) { + if (uCrestHeight <= 0.0) return 0.0; + float front = (uWaveFront - 0.5) * uHalf.x * 2.0; + float s = xz.x - front; + float prof = s < 0.0 + ? exp(s / max(uCrestBack, 0.02)) + : exp(-s / max(uCrestFace, 0.01)); + float rag = fbm3(vec3(xz.y * uCrestBreakScale, uSeed, uNow * 0.6)) * 0.5 + 0.5; + prof *= mix(1.0, rag, clamp(uCrestBreak, 0.0, 1.0)); + float a = abs(xz.y) / max(uHalf.y, 1e-3); + float w = clamp(uCrestWidth, 0.0, 1.0); + float lateral = 1.0 - smoothstep(max(w - max(uCrestFeather, 1e-3), 0.0), w, a); + return uCrestHeight * prof * lateral; + } + + float causticRipples(vec2 xz) { + float sum = 0.0; + for (int i = 0; i < ${CAUSTIC_RIPPLE_SLOTS}; i++) { + vec4 r = uRipples[i]; + if (r.w <= 0.0) continue; + float age = uNow - r.z; + if (age < 0.0) continue; + vec2 c = r.xy * uHalf; + float d = length(xz - c); + float x = d - age * uRippleSpeed; + float env = exp(-(x * x) / max(uRippleWidth * uRippleWidth, 1e-4)); + float decay = exp(-age / max(uRippleDecay, 0.02)) / (1.0 + d / max(uRippleSpread, 0.05)); + sum += r.w * uRippleAmp * env * decay * cos(x * TAU / max(uRippleLength, 0.05)); + } + return sum; + } + + float causticHeight(vec2 xz) { + float h = 0.0; + h += causticWave(xz, uWaveAmp.x, uWaveLength.x, uWaveSpeed.x, uWaveAngle.x); + h += causticWave(xz, uWaveAmp.y, uWaveLength.y, uWaveSpeed.y, uWaveAngle.y); + h += causticWave(xz, uWaveAmp.z, uWaveLength.z, uWaveSpeed.z, uWaveAngle.z); + h += causticWave(xz, uWaveAmp.w, uWaveLength.w, uWaveSpeed.w, uWaveAngle.w); + if (uChop > 0.0) h += uChop * fbm3(vec3(xz * uChopScale, uTime * uChopSpeed + uSeed)); + h += causticRipples(xz); + h += causticCrest(xz); + return h; + } + + float causticRidge(vec2 xz) { return 0.0; } +`; + +/* ---------------------------------------------------------------------- */ +/* Shaders */ +/* ---------------------------------------------------------------------- */ + +const CAUSTIC_VERTEX = /* glsl */ ` + varying vec2 vUv; + varying vec3 vWorld; + varying float vViewZ; + + void main() { + vUv = uv; + vec4 world = modelMatrix * vec4(position, 1.0); + vWorld = world.xyz; + vec4 mv = viewMatrix * world; + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/** + * The fragment is assembled per instance because the `CUSTOM` source replaces a + * whole block of it. A single `const` with `#if`s cannot do that: the caller's + * chunk is a string that does not exist until construction. + */ +function causticFragment(source, custom) { + const field = + source === CausticSource.WAVE + ? WAVE_SOURCE + : source === CausticSource.CUSTOM + ? custom + : SCROLL_SOURCE; + + return /* glsl */ ` + #define CS_DISC 0 + #define CS_CONE 1 + #define CS_LANE 2 + + uniform float uTime; + uniform float uSeed; + + /* ---- placement, in metres ---- */ + uniform vec2 uQuadSize; // metres the quad covers: x across, y downrange + uniform vec3 uAnchor; // world position of the quad's centre + uniform vec3 uLightAxis; // unit, the direction the light TRAVELS (downward) + uniform float uRadius; // metres — the projector's own reach + uniform float uLength; // metres — LANE only + + /* ---- refraction ---- */ + uniform float uDepth; // metres of water between the surface and the floor + uniform float uBend; // 1 - 1/ior, resolved on the CPU from the ior slider + uniform float uDispersion; // 0..1 fractional spread of uBend across R and B + uniform float uStep; // metres between the Hessian taps + uniform float uAbsorb; // 1/metres — Beer extinction through the water column + + /* ---- the fold, and how hard it is squeezed ---- */ + uniform float uFoldFloor; // keeps 1/|det| finite; also the widest a filament gets + uniform float uThreshold; // compression below this is flat water, and black + uniform float uGain; + uniform float uSharpness; // exponent on the surviving compression + uniform float uRolloff; // soft clip, so a fold does not detonate the bloom + + /* ---- the projector ---- */ + uniform float uPenumbra; // 0..1 of the reach, over which the edge dies + uniform float uConeAngle; // radians, half-angle (CONE) + uniform float uProjHeight; // metres up the axis to the apex (CONE) + uniform float uLaneWidth; // metres, half-width (LANE) + uniform float uLaneFeather; // metres (LANE) + uniform float uFront; // 0..1 down the lane, the wave's own front + uniform float uSpanBack; // metres behind the front the net survives + uniform float uSpanFront; // metres ahead of it + + /* ---- output ---- */ + uniform float uFade; + uniform float uOpacity; + uniform float uEmissive; + uniform float uWash; // the lit-pool light BETWEEN the filaments + uniform float uFringeAt; // where on the fold the colour hands over + uniform vec3 uColorNet; + uniform vec3 uColorFringe; + uniform vec3 uColorWash; + + /* ---- depth ---- */ + uniform sampler2D uSceneDepth; + uniform vec2 uResolution; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uDepthFade; // metres + uniform float uGlobalGlow; + uniform float uShaderIntensity; + + varying vec2 vUv; + varying vec3 vWorld; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + /* ---- the pluggable height field ---- */ + ${field} + + /** + * How much the refraction map compresses area here, per channel. + * + * 'a' is the only thing that differs between the three: red bends least, blue + * most, so each channel folds along its own curve and the fringes are three + * filaments a few centimetres apart rather than one filament with a gradient + * painted along it. + */ + float causticDet(float a, float hxx, float hzz, float hxz) { + float jxx = 1.0 - a * hxx; + float jzz = 1.0 - a * hzz; + float jxz = a * hxz; + return jxx * jzz - jxz * jxz; + } + + /** Compression to light. Everything under the threshold is water, and black. */ + float causticFold(float det) { + float comp = 1.0 / (abs(det) + max(uFoldFloor, 1e-3)); + float lit = max(comp - uThreshold, 0.0) * uGain; + lit = pow(lit, max(uSharpness, 0.05)); + return lit / (1.0 + lit * max(uRolloff, 0.0)); + } + + /** The projector's own falloff. Never derived from the pattern. */ + float causticEnvelope(vec2 lp) { + float pen = clamp(uPenumbra, 0.02, 1.0); + + #if CS_SHAPE == CS_CONE + // The apex sits up the light's own axis, so a low sun gives an ellipse on + // the floor for free — which is the entire reason the axis is a parameter + // and not hard-wired to +Y. + vec3 apex = uAnchor - uLightAxis * uProjHeight; + vec3 d = vWorld - apex; + float len = max(length(d), 1e-4); + float c = dot(d / len, uLightAxis); + float ci = cos(clamp(uConeAngle, 0.0, 1.5)); + float co = cos(clamp(uConeAngle, 0.0, 1.5) * (1.0 + pen)); + return smoothstep(co, ci, c); + #elif CS_SHAPE == CS_LANE + float across = 1.0 - smoothstep(max(uLaneWidth - uLaneFeather, 0.0), + uLaneWidth + uLaneFeather, abs(lp.x)); + float front = uFront * uLength; + float feather = max(uLaneFeather, 0.02); + float behind = smoothstep(front - uSpanBack - feather, front - uSpanBack, lp.y); + float ahead = 1.0 - smoothstep(front + uSpanFront, front + uSpanFront + feather, lp.y); + return across * behind * ahead; + #else + float r = length(lp) / max(uRadius, 1e-3); + return 1.0 - smoothstep(1.0 - pen, 1.0, r); + #endif + } + + void main() { + vec2 lp = vec2(vUv.x - 0.5, 0.5 - vUv.y) * uQuadSize; + + float env = causticEnvelope(lp); + if (env <= 0.0015) discard; + + /* ---- six taps, one Hessian ---- */ + float s = max(uStep, 0.005); + float inv = 1.0 / (s * s); + float h0 = causticHeight(lp); + float hpx = causticHeight(lp + vec2(s, 0.0)); + float hmx = causticHeight(lp - vec2(s, 0.0)); + float hpz = causticHeight(lp + vec2(0.0, s)); + float hmz = causticHeight(lp - vec2(0.0, s)); + float hd = causticHeight(lp + vec2(s, s)); + + float hxx = (hpx - 2.0 * h0 + hmx) * inv; + float hzz = (hpz - 2.0 * h0 + hmz) * inv; + // The mixed partial needs the diagonal tap. Drop it and every fold that is + // not aligned with the quad's own axes disappears, which shows up as a net + // made of plus signs. + float hxz = (hd - hpx - hpz + h0) * inv; + + float a = uBend * uDepth; + float spread = clamp(uDispersion, 0.0, 1.0); + vec3 lit = vec3( + causticFold(causticDet(a * (1.0 - spread), hxx, hzz, hxz)), + causticFold(causticDet(a, hxx, hzz, hxz)), + causticFold(causticDet(a * (1.0 + spread), hxx, hzz, hxz)) + ); + + lit += vec3(causticRidge(lp)); + + /* ---- the water above is not clear ---- + * Thicker column, dimmer floor. This is the term that lets you read the + * wave's body off the ground: the net dims under the crest and flares in + * the thin water on its face, with no extra input beyond the height field + * that is already here. */ + float column = max(uDepth + h0, 0.0); + lit *= exp(-max(uAbsorb, 0.0) * column); + + float strength = uFade * uOpacity * uEmissive * uShaderIntensity; + float peak = max(max(lit.r, lit.g), lit.b); + vec3 tint = mix(uColorNet, uColorFringe, clamp(peak * uFringeAt, 0.0, 1.0)); + vec3 rgb = tint * lit + uColorWash * (uWash * env); + + rgb *= env * strength; + + /* ---- do not paint the character ---- + * The depth test already loses the argument on the millimetre between this + * quad and the floor it is lying on, so the fade does the last of it: the + * net dies over uDepthFade metres as anything standing on the floor comes + * between it and the camera. */ + vec2 screenUV = gl_FragCoord.xy / uResolution; + float depthBits = unpackRGBAToDepth(texture2D(uSceneDepth, screenUV)); + float sceneViewZ = perspectiveDepthToViewZ(depthBits, uCameraNear, uCameraFar); + rgb *= smoothstep(-max(uDepthFade, 1e-3), 0.0, vViewZ - sceneViewZ); + + float alpha = clamp(max(max(rgb.r, rgb.g), rgb.b), 0.0, 1.0); + if (alpha < 0.003) discard; + + gl_FragColor = vec4(rgb * uGlobalGlow, alpha); + } +`; +} + +/* ---------------------------------------------------------------------- */ +/* Params */ +/* ---------------------------------------------------------------------- */ + +/** + * Every canonical key with its default and its unit. + * + * Hold one of these at module scope, fill it from `settings[id]` every frame, + * and hand it to `update()`. Nothing here is remembered between calls. + */ +export function causticsParams() { + return { + /* --- where it is (the ability's own vectors; never copied out) --- */ + centre: null, // Vector3 on the floor — the quad's centre + lightAxis: null, // Vector3, unit, the direction light TRAVELS. Default straight down + yaw: 0, // radians about +Y; local +Z is downrange + height: 0.015, // metres above the floor the quad sits at + radius: 6, // metres — the projector's reach. DISC/CONE cap, quad sizing + length: 18, // metres — LANE only, how far downrange the lane runs + + /* --- the beats (unitless; the ability's clock resolves them) --- */ + fade: 1, // 0..1 master fade + front: 0.5, // 0..1 LANE: where the wave's front is + now: 0, // seconds since the cast began — a timestamp, not a dimension + seed: 0, // decorrelates two casts. A dice roll, safe to capture + + /* --- refraction --- */ + depth: 1.4, // metres of water between the surface and the floor + ior: 1.333, // water. The shader gets 1 - 1/ior + dispersion: 0.06, // 0..1 how far R and B sit either side of G + sampleStep: 0.09, // metres between the Hessian taps — the net's finest detail + absorb: 0.18, // 1/metres, Beer extinction down the water column + + /* --- the fold --- */ + foldFloor: 0.22, // keeps 1/|det| finite; the widest a filament can get + threshold: 1.15, // compression under this is flat water, and black + gain: 0.55, + sharpness: 1.35, // exponent on the surviving compression + rolloff: 0.22, // soft clip on the peak + + /* --- SCROLL only --- */ + sourceAmp: 0.16, // metres of relief in the fake surface + cellScale: 0.55, // cells per metre + cellRatio: 1.63, // second lattice's scale, as a multiple of the first + cellJitter: 0.85, // 0..1 how far a feature point wanders in its cell + driftAngle: 0.7, // radians, the bearing the lattice drifts on + driftSpeed: 0.12, // cells per second + boil: 0.9, // radians per second the feature points orbit + ridgeMix: 0.12, // 0..1 of the direct worley-difference net + ridgeScale: 2.2, // how tight a direct vein is + ridgePower: 6, // the exponent the received recipe asks for + + /* --- WAVE only, and all of it overridden by bindSource() --- */ + half: 8, // metres, half-extent of the surface (both axes) + waveAmp: 0.16, // metres — the swell, applied to all four components + waveLength: 5.5, // metres + waveSpeed: 1.6, // metres/second + waveAngle: 0.4, // radians + chop: 0.05, // metres + chopScale: 0.55, // cycles per metre + chopSpeed: 0.3, // metres/second + waveFront: 0.5, // 0..1 along the surface's +X + crestHeight: 1.1, // metres + crestBack: 3.2, // metres + crestFace: 0.5, // metres + crestWidth: 0.8, // 0..1 + crestFeather: 0.25, // 0..1 + crestBreak: 0.35, // 0..1 + crestBreakScale: 0.6, // cycles per metre + rippleAmp: 0.12, // metres + rippleSpeed: 3.2, // metres/second + rippleLength: 1.1, // metres + rippleWidth: 0.7, // metres + rippleDecay: 1.4, // seconds + rippleSpread: 3, // metres + + /* --- the projector --- */ + penumbra: 0.35, // 0..1 of the reach, over which the edge dies + coneAngle: 0.45, // radians, half-angle (CONE) + projectorHeight: 5, // metres up the axis to the apex (CONE) + laneWidth: 2.6, // metres, half-width (LANE) + laneFeather: 0.9, // metres (LANE) + spanBack: 2, // metres behind the front the net survives (LANE) + spanFront: 7, // metres ahead of it (LANE) + + /* --- output --- */ + additive: true, // caustics are light; false shades instead, for oil and ink + emissive: 1, + opacity: 1, + wash: 0.18, // the lit pool between the filaments + fringeAt: 1.6, // where on the fold the colour hands over to the fringe + depthFade: 0.45, // metres of soft fade against standing geometry + colorNet: '#9fe6ff', // the filaments + colorFringe: '#ffffff', // the very top of a fold + colorWash: '#2a6d86', // the general light in the pool + + /* --- global multipliers (settings.global.*, 1 = neutral) --- */ + noiseStrength: 1, + noiseFrequency: 1, + noiseSpeed: 1, + opacityScale: 1 + }; +} + +/* ---------------------------------------------------------------------- */ +/* Scratch — module scope, per invariant I3 */ +/* ---------------------------------------------------------------------- */ + +const _axis = new Vector3(0, -1, 0); + +/* ---------------------------------------------------------------------- */ +/* The module */ +/* ---------------------------------------------------------------------- */ + +export class Caustics { + /** + * @param {THREE.Object3D} parent the ability's group + * @param {object} [options] + * @param {number} [options.source=CausticSource.SCROLL] a `#define`, fixed for the lifetime + * @param {number} [options.shape=CausticShape.DISC] ditto + * @param {string} [options.custom] GLSL for `CausticSource.CUSTOM`. Must define + * `float causticHeight(vec2 xz)` and + * `float causticRidge(vec2 xz)`, and may declare + * its own uniforms — pass their boxes in `uniforms` + * @param {object} [options.uniforms] extra uniform boxes merged in, shared by identity + * @param {boolean} [options.additive=true] initial blend; `params.additive` drives it after + * @param {boolean} [options.depthTest=true] + * @param {number} [options.layer=LAYER.VFX] + * @param {number} [options.renderOrder=7] + * @param {string} [options.name] + */ + constructor(parent, options = {}) { + const { + source = CausticSource.SCROLL, + shape = CausticShape.DISC, + custom = '', + uniforms = null, + additive = true, + depthTest = true, + layer = LAYER.VFX, + renderOrder = 7, + name = null + } = options; + + if (source === CausticSource.CUSTOM && !custom) { + throw new Error( + 'Caustics: CausticSource.CUSTOM needs options.custom — a GLSL chunk defining ' + + 'float causticHeight(vec2 xz) and float causticRidge(vec2 xz)' + ); + } + + this.parent = parent; + this.source = source; + this.shape = shape; + this.geometry = acquireGroundQuad(); + + /** + * Which uniform boxes now belong to somebody else. + * + * `update()` consults this before writing: a bound key is the surface's to + * drive, and writing it here would mean two authors for one number and a + * one-frame flicker whichever way you look at it. + */ + this._bound = new Set(); + + /** Ripple packets, for the standalone case. Bound away by `bindSource()`. */ + this._ripples = []; + for (let i = 0; i < CAUSTIC_RIPPLE_SLOTS; i++) this._ripples.push(new Vector4(0, 0, -1e4, 0)); + this._rippleNext = 0; + + this.material = new ShaderMaterial({ + defines: { + CS_SOURCE: source, + CS_SHAPE: shape + }, + transparent: true, + depthWrite: false, + depthTest, + // Premultiplied, the way `Portal` and `Curtain` are, so one pass can add + // light or lay a darkening over the floor with no change but the + // destination factor. `AdditiveBlending` was the first version and it is + // wrong here: it is (SrcAlpha, One), so it multiplies the colour by an + // alpha that is *derived from the colour*, squaring everything dim. A + // caustic is nine-tenths dim. + blending: CustomBlending, + blendEquation: AddEquation, + blendSrc: OneFactor, + blendDst: additive ? OneFactor : OneMinusSrcAlphaFactor, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uQuadSize: { value: new Vector2(12, 12) }, + uAnchor: { value: new Vector3() }, + uLightAxis: { value: new Vector3(0, -1, 0) }, + uRadius: { value: 6 }, + uLength: { value: 18 }, + uSeed: { value: 0 }, + uNow: { value: 0 }, + + uDepth: { value: 1.4 }, + uBend: { value: 1 - 1 / 1.333 }, + uDispersion: { value: 0.06 }, + uStep: { value: 0.09 }, + uAbsorb: { value: 0.18 }, + + uFoldFloor: { value: 0.22 }, + uThreshold: { value: 1.15 }, + uGain: { value: 0.55 }, + uSharpness: { value: 1.35 }, + uRolloff: { value: 0.22 }, + + /* SCROLL */ + uSourceAmp: { value: 0.16 }, + uCellScale: { value: 0.55 }, + uCellRatio: { value: 1.63 }, + uCellJitter: { value: 0.85 }, + uDrift: { value: new Vector2(0.08, 0.09) }, + uBoil: { value: 0.9 }, + uRidgeMix: { value: 0.12 }, + uRidgeScale: { value: 2.2 }, + uRidgePower: { value: 6 }, + + /* WAVE — the bindable set */ + uHalf: { value: new Vector2(8, 8) }, + uWaveAmp: { value: new Vector4(0.16, 0.11, 0.07, 0.04) }, + uWaveLength: { value: new Vector4(5.5, 3.1, 1.9, 1.1) }, + uWaveSpeed: { value: new Vector4(1.6, 1.2, 0.9, 0.7) }, + uWaveAngle: { value: new Vector4(0.4, 1.1, 2.2, 3.4) }, + uChop: { value: 0.05 }, + uChopScale: { value: 0.55 }, + uChopSpeed: { value: 0.3 }, + uWaveFront: { value: 0.5 }, + uCrestHeight: { value: 1.1 }, + uCrestBack: { value: 3.2 }, + uCrestFace: { value: 0.5 }, + uCrestWidth: { value: 0.8 }, + uCrestFeather: { value: 0.25 }, + uCrestBreak: { value: 0.35 }, + uCrestBreakScale: { value: 0.6 }, + uRipples: { value: this._ripples }, + uRippleAmp: { value: 0.12 }, + uRippleSpeed: { value: 3.2 }, + uRippleLength: { value: 1.1 }, + uRippleWidth: { value: 0.7 }, + uRippleDecay: { value: 1.4 }, + uRippleSpread: { value: 3 }, + + /* the projector */ + uPenumbra: { value: 0.35 }, + uConeAngle: { value: 0.45 }, + uProjHeight: { value: 5 }, + uLaneWidth: { value: 2.6 }, + uLaneFeather: { value: 0.9 }, + uFront: { value: 0.5 }, + uSpanBack: { value: 2 }, + uSpanFront: { value: 7 }, + + /* output */ + uFade: { value: 1 }, + uOpacity: { value: 1 }, + uEmissive: { value: 1 }, + uWash: { value: 0.18 }, + uFringeAt: { value: 1.6 }, + uDepthFade: { value: 0.45 }, + uColorNet: { value: new Color(0.62, 0.9, 1) }, + uColorFringe: { value: new Color(1, 1, 1) }, + uColorWash: { value: new Color(0.16, 0.43, 0.53) }, + + ...(uniforms ?? {}) + }), + vertexShader: CAUSTIC_VERTEX, + fragmentShader: causticFragment(source, custom) + }); + + this.mesh = new Mesh(this.geometry, this.material); + this.mesh.name = + name ?? `Caustics:${CAUSTIC_SOURCE_NAMES[source] ?? source}/${CAUSTIC_SHAPE_NAMES[shape] ?? shape}`; + this.mesh.layers.set(layer); + this.mesh.renderOrder = renderOrder; + this.mesh.frustumCulled = false; + this._additive = additive; + + parent?.add(this.mesh); + } + + get object3D() { + return this.mesh; + } + + /** One. Always one. */ + get drawCalls() { + return 1; + } + + /** How many uniform boxes a driver has taken over. 0 means procedural. */ + get boundCount() { + return this._bound.size; + } + + setVisible(visible) { + this.mesh.visible = visible; + } + + /** + * Take the refracting surface's own uniform boxes, by identity. + * + * This is the hook the whole module exists for. `LiquidSurface#uniforms` is + * public and every key in `CAUSTIC_BOUND_KEYS` is spelled the same in both + * files, so after one call the wave and the light under it are reading the + * *same numbers* — not two copies kept in step by an ability remembering to + * copy them, which is the version that drifts the first time somebody adds a + * slider to one block and not the other. + * + * Everything bound is then skipped by `update()`, because a number with two + * authors has none. + * + * @param {object} uniforms the driver's uniform map — `liquid.uniforms` + * @param {string[]} [keys=CAUSTIC_BOUND_KEYS] narrow it if you only want some + * @returns {number} how many boxes were taken + */ + bindSource(uniforms, keys = CAUSTIC_BOUND_KEYS) { + if (!uniforms) return 0; + const mine = this.material.uniforms; + for (const key of keys) { + const box = uniforms[key]; + if (!box || typeof box !== 'object' || !('value' in box)) continue; + if (!(key in mine)) continue; + mine[key] = box; + this._bound.add(key); + } + // three rebuilds its uniform list from `material.uniforms` lazily and reads + // each box by key at upload time, so a swap after the first render is seen. + // Do it at construction anyway: it is one less thing that is true only on + // this version. + this.material.uniformsNeedUpdate = true; + return this._bound.size; + } + + /** Hand the boxes back. Part of the pooling contract if an ability rebinds. */ + unbindSource() { + if (!this._bound.size) return; + const mine = this.material.uniforms; + if (this._bound.has('uRipples')) mine.uRipples = { value: this._ripples }; + for (const key of this._bound) { + if (key === 'uRipples') continue; + const box = mine[key]; + // Re-box, so a later write here cannot reach into the old driver. + mine[key] = { value: box?.value?.clone ? box.value.clone() : box?.value }; + } + this._bound.clear(); + this.material.uniformsNeedUpdate = true; + } + + /** + * Ring the standalone surface — a droplet, a footfall, a focused point. + * + * `u`/`v` are **fractions of `half`**, never metres, for the same reason + * `GroundField#mark()` takes fractions: a packet posted at 0.5 stays halfway + * out when the surface is resized with the clock stopped. + * + * No-op once `uRipples` is bound — the driver owns the packet list then, and + * `LiquidSurface#ripple()` is the call you want. + */ + ripple(u, v, strength = 1, now = 0) { + if (this._bound.has('uRipples')) return null; + const slot = this._ripples[this._rippleNext]; + slot.set(u, v, now, strength); + this._rippleNext = (this._rippleNext + 1) % CAUSTIC_RIPPLE_SLOTS; + return slot; + } + + /** Forget every packet. `onSpawn()` calls this; `onDestroy()` should too. */ + clearRipples() { + if (this._bound.has('uRipples')) return; + for (let i = 0; i < CAUSTIC_RIPPLE_SLOTS; i++) this._ripples[i].set(0, 0, -1e4, 0); + this._rippleNext = 0; + } + + /** Leaves the instance reusable — the other half of the pooling contract. */ + reset() { + this.clearRipples(); + this.mesh.visible = true; + } + + /** + * Re-resolve everything from the live params and place the quad. + * + * There is no clock argument on purpose: `GroundField` has none either, and + * for the same reason — every beat arrives as a unitless `0..1` on `p`, and + * the animation clock is `frame.uTime`, which the shader reads itself. The one + * timestamp the module needs, `p.now`, is the ripple epoch and is a timestamp + * rather than a duration. + * + * Allocation-free, and correct on a zero-length frame. + * + * @param {object} p live params — see `causticsParams()` + */ + update(p) { + const u = this.material.uniforms; + const bound = this._bound; + + const nf = p.noiseFrequency ?? 1; + const ns = p.noiseStrength ?? 1; + const nsp = p.noiseSpeed ?? 1; + + const radius = Math.max(0.05, p.radius ?? 6); + const length = Math.max(0.2, p.length ?? 18); + + u.uRadius.value = radius; + u.uLength.value = length; + if (!bound.has('uSeed')) u.uSeed.value = p.seed ?? 0; + if (!bound.has('uNow')) u.uNow.value = p.now ?? 0; + + /* ---- refraction ---- */ + // 1 - 1/ior is the small-angle bend coefficient. Resolved here rather than + // in the shader because it is one divide a frame instead of one a tap. + const ior = Math.max(1.0001, p.ior ?? 1.333); + u.uDepth.value = Math.max(0, p.depth ?? 1.4); + u.uBend.value = 1 - 1 / ior; + u.uDispersion.value = Math.max(0, Math.min(1, p.dispersion ?? 0.06)); + u.uStep.value = Math.max(0.005, (p.sampleStep ?? 0.09) / Math.max(nf, 0.05)); + u.uAbsorb.value = Math.max(0, p.absorb ?? 0.18); + + /* ---- the fold ---- */ + u.uFoldFloor.value = Math.max(0.001, p.foldFloor ?? 0.22); + u.uThreshold.value = Math.max(0, p.threshold ?? 1.15); + u.uGain.value = Math.max(0, p.gain ?? 0.55); + u.uSharpness.value = Math.max(0.05, p.sharpness ?? 1.35); + u.uRolloff.value = Math.max(0, p.rolloff ?? 0.22); + + /* ---- SCROLL ---- */ + u.uSourceAmp.value = (p.sourceAmp ?? 0.16) * ns; + u.uCellScale.value = Math.max(0.01, (p.cellScale ?? 0.55) * nf); + u.uCellRatio.value = Math.max(0.05, p.cellRatio ?? 1.63); + u.uCellJitter.value = Math.max(0, Math.min(1, p.cellJitter ?? 0.85)); + const driftAngle = p.driftAngle ?? 0.7; + const driftSpeed = (p.driftSpeed ?? 0.12) * nsp; + u.uDrift.value.set(Math.cos(driftAngle) * driftSpeed, Math.sin(driftAngle) * driftSpeed); + u.uBoil.value = (p.boil ?? 0.9) * nsp; + u.uRidgeMix.value = Math.max(0, p.ridgeMix ?? 0.12); + u.uRidgeScale.value = Math.max(0.01, p.ridgeScale ?? 2.2); + u.uRidgePower.value = Math.max(0.1, p.ridgePower ?? 6); + + /* ---- WAVE: only what nobody else is driving ---- */ + if (this.source === CausticSource.WAVE && bound.size < CAUSTIC_BOUND_KEYS.length) { + const half = Math.max(0.2, p.half ?? 8); + if (!bound.has('uHalf')) u.uHalf.value.set(half, half); + if (!bound.has('uWaveAmp')) { + // One slider for the swell, split across four components on a fixed + // decay. An ability that wants four amplitudes binds a surface instead. + const amp = (p.waveAmp ?? 0.16) * ns; + u.uWaveAmp.value.set(amp, amp * 0.68, amp * 0.44, amp * 0.26); + } + if (!bound.has('uWaveLength')) { + const len = Math.max(0.05, p.waveLength ?? 5.5); + u.uWaveLength.value.set(len, len * 0.56, len * 0.35, len * 0.2); + } + if (!bound.has('uWaveSpeed')) { + const spd = (p.waveSpeed ?? 1.6) * nsp; + u.uWaveSpeed.value.set(spd, spd * 0.75, spd * 0.56, spd * 0.44); + } + if (!bound.has('uWaveAngle')) { + const ang = p.waveAngle ?? 0.4; + u.uWaveAngle.value.set(ang, ang + 0.7, ang + 1.8, ang + 3.0); + } + if (!bound.has('uChop')) u.uChop.value = (p.chop ?? 0.05) * ns; + if (!bound.has('uChopScale')) u.uChopScale.value = Math.max(0.01, (p.chopScale ?? 0.55) * nf); + if (!bound.has('uChopSpeed')) u.uChopSpeed.value = (p.chopSpeed ?? 0.3) * nsp; + if (!bound.has('uWaveFront')) u.uWaveFront.value = p.waveFront ?? 0.5; + if (!bound.has('uCrestHeight')) u.uCrestHeight.value = Math.max(0, p.crestHeight ?? 1.1); + if (!bound.has('uCrestBack')) u.uCrestBack.value = Math.max(0.02, p.crestBack ?? 3.2); + if (!bound.has('uCrestFace')) u.uCrestFace.value = Math.max(0.01, p.crestFace ?? 0.5); + if (!bound.has('uCrestWidth')) u.uCrestWidth.value = Math.max(0, Math.min(1, p.crestWidth ?? 0.8)); + if (!bound.has('uCrestFeather')) u.uCrestFeather.value = Math.max(0.001, p.crestFeather ?? 0.25); + if (!bound.has('uCrestBreak')) u.uCrestBreak.value = Math.max(0, Math.min(1, p.crestBreak ?? 0.35)); + if (!bound.has('uCrestBreakScale')) { + u.uCrestBreakScale.value = Math.max(0.01, (p.crestBreakScale ?? 0.6) * nf); + } + if (!bound.has('uRippleAmp')) u.uRippleAmp.value = (p.rippleAmp ?? 0.12) * ns; + if (!bound.has('uRippleSpeed')) u.uRippleSpeed.value = Math.max(0.01, (p.rippleSpeed ?? 3.2) * nsp); + if (!bound.has('uRippleLength')) u.uRippleLength.value = Math.max(0.05, p.rippleLength ?? 1.1); + if (!bound.has('uRippleWidth')) u.uRippleWidth.value = Math.max(0.02, p.rippleWidth ?? 0.7); + if (!bound.has('uRippleDecay')) u.uRippleDecay.value = Math.max(0.02, p.rippleDecay ?? 1.4); + if (!bound.has('uRippleSpread')) u.uRippleSpread.value = Math.max(0.05, p.rippleSpread ?? 3); + } + + /* ---- the projector ---- */ + const penumbra = Math.max(0.02, Math.min(1, p.penumbra ?? 0.35)); + const coneAngle = Math.max(0.01, Math.min(1.5, p.coneAngle ?? 0.45)); + const projHeight = Math.max(0.05, p.projectorHeight ?? 5); + const laneWidth = Math.max(0.05, p.laneWidth ?? 2.6); + const laneFeather = Math.max(0.02, p.laneFeather ?? 0.9); + const spanBack = Math.max(0, p.spanBack ?? 2); + const spanFront = Math.max(0, p.spanFront ?? 7); + + u.uPenumbra.value = penumbra; + u.uConeAngle.value = coneAngle; + u.uProjHeight.value = projHeight; + u.uLaneWidth.value = laneWidth; + u.uLaneFeather.value = laneFeather; + u.uFront.value = Math.max(0, Math.min(1, p.front ?? 0.5)); + u.uSpanBack.value = spanBack; + u.uSpanFront.value = spanFront; + + /* ---- output ---- */ + u.uFade.value = p.fade ?? 1; + u.uOpacity.value = (p.opacity ?? 1) * (p.opacityScale ?? 1); + u.uEmissive.value = p.emissive ?? 1; + u.uWash.value = Math.max(0, p.wash ?? 0.18); + u.uFringeAt.value = Math.max(0, p.fringeAt ?? 1.6); + u.uDepthFade.value = Math.max(0.001, p.depthFade ?? 0.45); + u.uColorNet.value.copy(getColor(p.colorNet ?? '#9fe6ff')); + u.uColorFringe.value.copy(getColor(p.colorFringe ?? '#ffffff')); + u.uColorWash.value.copy(getColor(p.colorWash ?? '#2a6d86')); + + this.setAdditive(p.additive ?? true); + + /* ---- the axis the projector points down ---- */ + if (p.lightAxis) { + _axis.copy(p.lightAxis); + if (_axis.lengthSq() < 1e-8) _axis.set(0, -1, 0); + _axis.normalize(); + } else { + _axis.set(0, -1, 0); + } + u.uLightAxis.value.copy(_axis); + + /* ---- the quad ---- + * Re-derived every frame, so dragging `radius` or `coneAngle` grows the + * canvas along with the drawing on it. A quad sized once at spawn is the + * exact failure this whole library is built to avoid. */ + let across; + let down; + if (this.shape === CausticShape.LANE) { + across = (laneWidth + laneFeather) * 2 + 0.4; + down = length + spanFront + spanBack + laneFeather * 2 + 0.4; + } else if (this.shape === CausticShape.CONE) { + // The apex sits up the axis, so a slanted axis walks the footprint + // sideways as well as widening it. Both are covered, conservatively. + const drop = Math.max(0.2, Math.abs(_axis.y)); + const reach = projHeight * Math.tan(coneAngle) * (1 + penumbra); + const slide = projHeight * Math.hypot(_axis.x, _axis.z) / drop; + across = (Math.max(radius, reach + slide) + 0.4) * 2; + down = across; + } else { + across = (radius + 0.4) * 2; + down = across; + } + + u.uQuadSize.value.set(across, down); + this.mesh.scale.set(across, 1, down); + + const yaw = p.yaw ?? 0; + const centre = p.centre; + const height = p.height ?? 0.015; + if (centre) { + this.mesh.position.set(centre.x, centre.y + height, centre.z); + u.uAnchor.value.set(centre.x, centre.y + height, centre.z); + } else { + this.mesh.position.y = height; + u.uAnchor.value.copy(this.mesh.position); + } + this.mesh.rotation.set(0, yaw, 0); + } + + /** + * Light on the floor, or a shading of it. Free to change per frame. + * + * Only the destination factor moves, so this does **not** set + * `needsUpdate` — blend state is not compiled into the program and marking the + * material dirty every frame would rebuild the shader on any ability that + * drives `additive` from a slider. + */ + setAdditive(additive) { + if (additive === this._additive) return; + this._additive = additive; + this.material.blendDst = additive ? OneFactor : OneMinusSrcAlphaFactor; + } + + dispose() { + this.parent?.remove(this.mesh); + this.material.dispose(); + releaseGroundQuad(); + this.geometry = null; + } +} diff --git a/src/vfx/Colony.js b/src/vfx/Colony.js new file mode 100644 index 0000000..66440a7 --- /dev/null +++ b/src/vfx/Colony.js @@ -0,0 +1,3282 @@ +import { + AdditiveBlending, + BufferAttribute, + BufferGeometry, + Color, + DoubleSide, + FrontSide, + Group, + InstancedBufferAttribute, + InstancedBufferGeometry, + Mesh, + NormalBlending, + ShaderMaterial, + Sphere, + Vector3 +} from 'three'; +import { Swarm, swarmParams } from './Swarm.js'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { getColor } from '../utils/color.js'; +import { clamp, saturate, hash11 } from '../utils/math.js'; + +/* ====================================================================== */ +/* Colony.js — the Hive school's four tricks */ +/* ====================================================================== */ + +/** + * Four renderers for **many small things behaving as one**, in one file + * because they share a vocabulary rather than a shader: a unitless structural + * description rolled once, and every metre in it resolved from a live params + * object each frame. + * + * 1. `ColonySwarm` — a flock that condenses onto a signed distance field, so + * the cloud *becomes* a fist, a wall or a spear and then + * comes apart again. Serves `locusttide`, `waspfunnel` + * and the swarm half of `broodburst`. + * 2. `WebGraph` — sagging strands between anchor points **plus the film + * between them**, built from the graph's own faces. + * Serves `webline`. + * 3. `LatticeGrowth`— hexagonal cells building outward from a seed on a + * lattice their neighbours defined, with overlapping + * candidates refused. Serves `hivecolumn`. + * 4. `PlateShell` — a spherical Voronoi tessellation whose cells are chitin + * plates that fly in and lock. Serves `carapace`. + * + * ## Conventions, which are the same for all four + * + * **Parent-first.** `new X(parent, options)` — every class here builds its own + * meshes and adds them to `parent`. There is no `.object3D` to hang yourself, + * and no class here owns more than two meshes. + * + * **Canonical settings.** Each class has an `xxxParams()` returning every key it + * understands with its default and its unit; `update()` reads `p.key ?? default` + * every frame. Hand it `settings[id]` outright if your block uses these names. + * + * **The clock.** `LatticeGrowth.update(now, p)` and `PlateShell.update(now, p)` + * take **`now`** — the ability's `age` in seconds — because both are staggered + * builds and the stagger is measured against it. `ColonySwarm.update(_now, p)` + * and `WebGraph.update(_now, p)` **ignore** the first argument, as `Swarm` and + * `Curtain` do: their motion is a standing one driven by `frame.uTime` in the + * shader, and a web should not stop swaying because the ability was re-cast. + * + * ## Why one file + * + * The first cut of this was four files. It was worse. Three of these four share + * `COLONY_CHITIN_GLSL` — one wrapped-diffuse-plus-fresnel chunk that is what + * makes chitin read as chitin rather than as plastic — and two of them share the + * same "roll a unitless structure, rebuild only when its *shape* hash changes, + * resolve every metre in the vertex shader" skeleton. Split across files that + * became either a fifth module nobody could name or four divergent copies. It is + * one file, sectioned, and the sections are independent. + */ + +/* ---------------------------------------------------------------------- */ +/* Scratch — the frame allocates nothing (I3) */ +/* ---------------------------------------------------------------------- */ + +const _v0 = new Vector3(); +const _v1 = new Vector3(); +const _v2 = new Vector3(); +const _v3 = new Vector3(); + +const TAU = Math.PI * 2; + +/** Golden angle, in radians. The one irrational that spaces a spiral evenly. */ +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); + +/* ---------------------------------------------------------------------- */ +/* The shared look */ +/* ---------------------------------------------------------------------- */ + +/** + * Chitin, in eighteen lines. + * + * Three terms, and the order matters. A **wrapped** diffuse (`n·l` remapped + * from -1..1 rather than clamped at zero) because a beetle's shell is thin + * enough to bleed light through the far side, and a hard terminator on a curved + * plate reads as painted metal. A **fresnel** rim, because the shine on chitin + * is almost entirely grazing — this is the term that separates it from plastic. + * And a **sheen** lobe on the half vector with a narrow exponent, which is the + * oily highlight that slides across a carapace as the camera moves. + * + * The first version was a plain Lambert plus an additive rim colour. It read as + * a painted sphere: the rim sat at a fixed width all the way round because a + * constant rim does not know where the light is, and the moment two plates met + * at an angle the seam vanished. Weighting the rim by the fresnel *and* the + * sheen by the light direction is what makes an edge-on plate flare and a + * face-on one go matte. + */ +const COLONY_CHITIN_GLSL = /* glsl */ ` + /** + * n surface normal (unit, world) + * v direction to the eye (unit, world) + * l direction to the key light (unit, world) + * wrap 0 hard terminator, 1 fully wrapped + * rimPow fresnel exponent + */ + vec3 chitin(vec3 n, vec3 v, vec3 l, vec3 body, vec3 rimColour, vec3 sheenColour, + float wrap, float rimPow, float rimGain, float sheenPow, float sheenGain) { + float ndl = dot(n, l); + float diffuse = mix(max(ndl, 0.0), ndl * 0.5 + 0.5, clamp(wrap, 0.0, 1.0)); + float ndv = clamp(abs(dot(n, v)), 0.0, 1.0); + float rim = pow(1.0 - ndv, max(rimPow, 0.1)); + vec3 h = normalize(l + v); + float sheen = pow(clamp(dot(n, h), 0.0, 1.0), max(sheenPow, 1.0)); + return body * (0.18 + 0.82 * diffuse) + + rimColour * (rim * rimGain) + + sheenColour * (sheen * sheenGain); + } +`; + +/* ====================================================================== */ +/* §1 · SDF-targeted swarm */ +/* ====================================================================== */ + +/** + * The shapes a colony can condense into. + * + * Six, deliberately: enough that a transition between two of them is legible as + * *this became that*, few enough that every one of them is recognisable from + * across the arena at agent scale. A silhouette that needs a second look is not + * a silhouette. + * + * They are authored in a **unit space** — the field is evaluated in roughly + * `[-1, 1]³` with `x` lateral, `y` up and `z` downrange — and the metres arrive + * afterwards as `shapeWidth/Height/Depth`. That split is what keeps invariant + * I1: no shape in here knows how big it is. + */ +export const ColonyShape = Object.freeze({ + /** A ball. The resting state, and the thing every other shape reads against. */ + BALL: 0, + /** A slab broadside to the caster — the swarm as a wall you cannot walk into. */ + WALL: 1, + /** A shaft with a spindle head, pointing downrange. */ + SPEAR: 2, + /** A closed fist: palm, four knuckles, a thumb folded across. */ + FIST: 3, + /** A torus about the cast axis — a hoop to fly through. */ + RING: 4, + /** A standing pillar. What a funnel condenses into at the top of its surge. */ + COLUMN: 5 +}); + +/** Editor-facing labels, index-aligned with `ColonyShape`. */ +export const COLONY_SHAPE_NAMES = Object.freeze([ + 'ball', + 'wall', + 'spear', + 'fist', + 'ring', + 'column' +]); + +/** + * The distance-field library and the projection that samples it. + * + * ### Why a projection and not rejection sampling + * + * The obvious way to scatter agents through a shape is to draw points in its + * bounding box and throw away the ones outside it. That cannot be done here for + * two reasons and both of them are fatal. A shader has no unbounded loop, so + * "keep drawing until one lands inside" has no expressible form; and even given + * one, the number of draws an agent needs depends on the shape, so an agent's + * final position would *change identity* the moment the shape morphed — the + * whole cloud would boil rather than flow. + * + * So: every agent draws **one** point from its own dice, once, and then walks + * that point onto the isosurface by gradient descent. Rejection-free by + * construction, and stable by construction — the dice never change, so an agent + * keeps its own place in the shape and morphs across with it. That stability is + * the entire difference between "the swarm formed a fist" and "the swarm + * dissolved and a fist appeared". + * + * ### Why the fields are blended and not the points + * + * `mix(pointInA, pointInB, k)` is a crossfade: at `k = 0.5` every agent is + * halfway between two unrelated places and the intermediate state is a smear + * with no shape at all. `mix(fieldA, fieldB, k)` is a **morph**: the blended + * field is a real distance-ish field at every `k`, so the intermediate states + * are themselves shapes — a fist growing a point, a wall drawing itself into a + * spear. Locusttide's trick is the transition, so the transition is what had to + * be a real object. + * + * The price is honest: both shapes are evaluated at every tap even when the + * blend is 0 or 1. Roughly forty field evaluations per vertex at the default + * three steps, which is a rounding error in a vertex shader and buys the only + * feature anybody will notice. + * + * ### Why the interior fill is an isolevel and not a second scatter + * + * Projecting every agent to `d = 0` gives a hollow shell, and a hollow swarm + * looks like a balloon. Each agent instead targets `d = -fill · dice`, so it + * lands somewhere inside on its own private isosurface. Same descent, same cost, + * a solid mass — and `fill` is a slider, so the same cast can go from a skin of + * insects to a packed body. + */ +const COLONY_SDF_GLSL = /* glsl */ ` + /* --- the target shape ------------------------------------------- */ + uniform int uShapeA; // ColonyShape.* + uniform int uShapeB; // the shape being morphed toward + uniform float uShapeBlend; // 0 all A, 1 all B — blends the FIELDS + uniform float uCondense; // 0 pure flock, 1 pure shape + uniform vec3 uShapeSize; // metres: half-width, half-height, half-depth + uniform vec3 uShapeOffset; // metres from the lead: forward, side, up + uniform float uShapeSpin; // radians/second about the world up axis + uniform float uShapeFill; // 0 a shell of agents, 1 a solid body + uniform float uShapeSteps; // descent steps, 1..4 + uniform float uShapeSlack; // fraction of each step taken (under-relaxation) + uniform float uShapeRough; // unitless slop on the landed target + + /* --- the density wave ------------------------------------------- */ + uniform float uWaveAmp; // metres of longitudinal bunching + uniform float uWaveLength; // metres between crests + uniform float uWaveSpeed; // metres/second the crests travel + uniform vec3 uWaveAxis; // unit, world space — normalised on the CPU + + /* --- crawling ----------------------------------------------------- */ + uniform float uCling; // 0 flying, 1 pinned to the floor + uniform float uFloorY; // metres + uniform float uCrawlHeight; // metres above the floor a crawler rides + + /* --- primitives, all in unit space -------------------------------- */ + + /** Polynomial smooth minimum. A hard union creases; a hive does not. */ + float colonySmin(float a, float b, float k) { + float h = clamp(0.5 + 0.5 * (b - a) / max(k, 1e-4), 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); + } + + float colonyBox(vec3 p, vec3 b) { + vec3 q = abs(p) - b; + return min(max(q.x, max(q.y, q.z)), 0.0) + length(max(q, 0.0)); + } + + float colonyCapsule(vec3 p, vec3 a, vec3 b, float r) { + vec3 pa = p - a; + vec3 ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0); + return length(pa - ba * h) - r; + } + + /** Torus about +z, so the hoop faces downrange. */ + float colonyTorus(vec3 p, float bigR, float smallR) { + vec2 q = vec2(length(p.xy) - bigR, p.z); + return length(q) - smallR; + } + + float colonyCylinderY(vec3 p, float r, float h) { + vec2 d = vec2(length(p.xz) - r, abs(p.y) - h); + return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); + } + + /** + * The fist. + * + * Palm, four knuckles and a thumb, smooth-unioned. The knuckles are the whole + * read: a rounded box alone is a brick, and it is the four bumps along its + * top-front edge that the eye resolves as a hand at fifteen metres. They are + * placed on a loop rather than unrolled because four hand-written spheres is + * four places to get the spacing wrong. + */ + float colonyFist(vec3 p) { + float d = colonyBox(p, vec3(0.52, 0.44, 0.34)) - 0.20; + for (int i = 0; i < 4; i++) { + float u = (float(i) - 1.5) / 1.5; // -1..1 across the hand + vec3 knuckle = vec3(u * 0.46, 0.40, 0.34 - abs(u) * 0.08); + d = colonySmin(d, length(p - knuckle) - 0.23, 0.14); + } + // The thumb rides across the front, low and to one side. Without it the + // silhouette is symmetric, and a symmetric hand reads as a rock. + d = colonySmin(d, colonyCapsule(p, vec3(-0.44, -0.22, 0.10), vec3(0.06, -0.08, 0.46), 0.19), 0.12); + return d; + } + + /** + * The spear. + * + * A thin shaft smooth-unioned with a fatter, shorter capsule at the front, + * which gives a spindle head rather than a cone. A true round cone is about + * thirty lines of iq arithmetic and buys a sharper point that no agent is + * small enough to resolve; the spindle costs two capsules and reads the same. + */ + float colonySpear(vec3 p) { + float shaft = colonyCapsule(p, vec3(0.0, 0.0, -0.95), vec3(0.0, 0.0, 0.42), 0.085); + float head = colonyCapsule(p, vec3(0.0, 0.0, 0.40), vec3(0.0, 0.0, 0.86), 0.21); + return colonySmin(shaft, head, 0.16); + } + + float colonyShapeField(int which, vec3 p) { + if (which == 1) return colonyBox(p, vec3(0.92, 0.90, 0.10)) - 0.06; + if (which == 2) return colonySpear(p); + if (which == 3) return colonyFist(p); + if (which == 4) return colonyTorus(p, 0.70, 0.22); + if (which == 5) return colonyCylinderY(p, 0.40, 0.90) - 0.06; + return length(p) - 0.90; + } + + /** The blended field. This, not the projected point, is what morphs. */ + float colonyField(vec3 p) { + float k = clamp(uShapeBlend, 0.0, 1.0); + return mix(colonyShapeField(uShapeA, p), colonyShapeField(uShapeB, p), k); + } + + /** + * Gradient by the tetrahedron trick — four taps rather than the six a + * central difference wants, for the same accuracy at this step size. + */ + vec3 colonyGrad(vec3 p) { + const vec2 e = vec2(1.0, -1.0); + const float h = 0.055; + vec3 g = e.xyy * colonyField(p + e.xyy * h) + + e.yyx * colonyField(p + e.yyx * h) + + e.yxy * colonyField(p + e.yxy * h) + + e.xxx * colonyField(p + e.xxx * h); + return g * inversesqrt(max(dot(g, g), 1e-8)); + } + + /** + * Where this agent belongs in the shape, in world metres. + * + * The agent's dice and its index are its entire identity and neither + * changes, so this is a pure function of the shape uniforms — which is + * exactly why a paused slider re-forms the whole silhouette. + */ + vec3 colonyTarget(vec3 dice, float idx) { + // One draw from the unit cube. Not the surface, not a sphere — the descent + // does the shaping, and starting from a cube is what lets a concave shape + // (a ring, the gap under a thumb) get agents into its hollows at all. + vec3 q = (hash31(dot(dice, vec3(11.3, 27.7, 53.1)) + idx * 0.0137 + 4.19) - 0.5) * 2.0; + + float iso = -max(uShapeFill, 0.0) * hash11(idx * 0.771 + 3.31); + float steps = clamp(uShapeSteps, 1.0, 4.0); + float relax = clamp(uShapeSlack, 0.05, 1.0); + // Masked rather than broken out of: a break on a uniform is the kind of + // thing that compiles on the desktop driver and fails on the one the player + // has, which is the same reason Swarm's glyph walk masks its strokes. + for (int i = 0; i < 4; i++) { + float on = step(float(i), steps - 1.0); + float d = colonyField(q) - iso; + vec3 g = colonyGrad(q); + q -= g * (d * relax * on); + } + + // A perfectly converged surface is a shrink-wrap, and a swarm is not a + // membrane. A little unitless slop off the isosurface is what keeps it a + // crowd of insects that happens to be fist-shaped. + q += (hash31(idx * 1.913 + 17.7) - 0.5) * (2.0 * uShapeRough); + + // Rigid spin about the shape's own up axis. Applied after the descent so + // every agent turns with the shape instead of sliding around inside it. + q.xz = rot2(uShapeSpin * uTime) * q.xz; + + vec3 up = vec3(0.0, 1.0, 0.0); + vec3 centre = leadAt(uLeadS) + + uForward * uShapeOffset.x + + uSideAxis * uShapeOffset.y + + up * uShapeOffset.z; + vec3 local = q * uShapeSize; + return centre + uSideAxis * local.x + up * local.y + uForward * local.z; + } + + /** + * The density wave, and the floor. + * + * A LONGITUDINAL compression along the wave axis: agents are displaced + * along the axis by a sine of their own position on it, which crowds them at + * the zero crossings and thins them at the extremes. That is a real surge — + * a band of high density travelling up a funnel — and it costs one sine. + * + * The first version modulated per-agent *opacity* by the same sine. It looked + * like a strobing gradient painted over a static swarm, because nothing + * moved: density you can see is agents arriving, not agents brightening. + */ + vec3 colonySurge(vec3 p, float t) { + float len = max(uWaveLength, 0.05); + float phase = TAU * (dot(p, uWaveAxis) - uWaveSpeed * t) / len; + p += uWaveAxis * (uWaveAmp * sin(phase)); + p.y = mix(p.y, uFloorY + uCrawlHeight, clamp(uCling, 0.0, 1.0)); + return p; + } +`; + +/* ---------------------------------------------------------------------- */ +/* The splice */ +/* ---------------------------------------------------------------------- */ + +/** + * Two anchors in `Swarm`'s vertex shader, matched literally. + * + * `ColonySwarm` **extends** `Swarm` rather than reimplementing it, and the way + * a subclass extends a shader is by editing the string its superclass built. + * That is a seam, so it is checked: if either anchor stops matching — because + * somebody reformatted `Swarm.js`, which they are entitled to do — construction + * throws here, naming the anchor, rather than silently rendering a flock that + * ignores every shape uniform. + */ +const COLONY_ANCHOR_DECL = 'void main() {'; +const COLONY_ANCHOR_BODY = 'vec3 vel = (p0 - p1) / H;'; + +/** + * What replaces the body anchor. + * + * The target is evaluated **once per vertex** and the same value is mixed into + * all three time samples, which has a consequence worth stating: while the + * swarm is condensed its finite-difference velocity, and therefore its bank, + * falls to zero. That is correct — an agent holding station in a shape is not + * turning — and it is also forty field evaluations saved per vertex against the + * alternative of sampling the field at t, t−h and t−2h. What it costs is that a + * shape being *carried* downrange does not bank its agents; nothing in the + * roster asks for that, and if something does, the fix is to sample + * `colonyTarget` three times and pay for it. + * + * The branch on `uCondense` is uniform flow — every invocation in the draw + * agrees on it — so it is a real saving and not a divergence penalty. With the + * swarm dispersed, a `ColonySwarm` costs what a `Swarm` costs. + */ +const COLONY_SPLICE_BODY = /* glsl */ ` + float cnd = clamp(uCondense, 0.0, 1.0); + if (cnd > 0.002) { + vec3 tgt = colonyTarget(dice, idx); + p0 = mix(p0, tgt, cnd); + p1 = mix(p1, tgt, cnd); + p2 = mix(p2, tgt, cnd); + } + p0 = colonySurge(p0, uTime); + p1 = colonySurge(p1, uTime - H); + p2 = colonySurge(p2, uTime - 2.0 * H); + + vec3 vel = (p0 - p1) / H;`; + +/** + * **What it draws.** A `Swarm` that can condense onto a signed distance field. + * Everything `Swarm` does — the lattice separation, the lag cohesion, the + * banking, the five silhouettes, the four-stop gradient, the edge-on flicker — + * plus a target shape, a morph between two shapes, a travelling density wave + * and a floor-crawling mode. + * + * **Draw calls.** One. It is one `Swarm`. + * + * **What it reads from settings.** Nothing directly; `update(_now, params)` is + * handed a live block and resolves `p.key ?? default` for every key in + * `colonySwarmParams()`, which is `swarmParams()` plus this class's own. + * + * **The one rule for using it well.** *Drive `condense` and `shapeBlend` from + * the ability's beats, and leave everything else alone.* The module has exactly + * two ideas in it and both of them are transitions. A swarm parked at + * `condense = 1` on one shape is a mesh with a bad silhouette; a swarm that + * arrives as a cloud, closes into a fist over three-tenths of a second, holds + * for two frames and opens into a spear is the ability. + * + * --- + * + * ## Extending rather than composing, and why + * + * `Swarm`'s flock is a closed-form function evaluated in its vertex shader — + * there is no agent state on the CPU to intercept, and no uniform that could + * carry a per-agent target without becoming a texture (I2) or a uniform array + * indexed by something other than a loop counter (which does not compile on + * ANGLE). Composition — holding a `Swarm` and driving it from outside — can + * therefore only move the *lead*, which moves all the agents together and + * cannot make a shape. + * + * So this subclasses `Swarm` and splices two blocks into the vertex shader it + * built: the field library before `main`, and a target-blend before the finite + * difference. Both anchors are asserted at construction. The alternative was a + * second flock, which would have meant a second copy of the lattice bijection, + * the lag cohesion, the bank and the five silhouettes — and the moment one of + * them was fixed the two would disagree about what a flock is. + * + * @example + * this.colony = new ColonySwarm(this.group, { capacity: 384 }); + * // onSpawn + * this.colony.roll(); + * // onTravel, every frame + * this._p.condense = this._closeCurve(age); // 0..1, authored beats + * this._p.shapeA = ColonyShape.WALL; + * this._p.shapeB = ColonyShape.SPEAR; + * this._p.shapeBlend = saturate((age - c.morphAt) / c.morphTime); + * this.colony.setBasis(origin, direction, side, length); + * this.colony.update(age, this._p); + */ +export class ColonySwarm extends Swarm { + /** + * @param {THREE.Object3D} parent + * @param {object} [options] every `Swarm` option; see that class. + */ + constructor(parent, options = {}) { + super(parent, options); + + const source = this.material.vertexShader; + if (source.indexOf(COLONY_ANCHOR_DECL) < 0 || source.indexOf(COLONY_ANCHOR_BODY) < 0) { + throw new Error( + 'ColonySwarm: the splice anchors are gone from Swarm.js. Expected to find ' + + `"${COLONY_ANCHOR_DECL}" and "${COLONY_ANCHOR_BODY}" in its vertex shader. ` + + 'Re-point COLONY_ANCHOR_DECL / COLONY_ANCHOR_BODY in vfx/Colony.js at whatever ' + + 'replaced them — do not paper over it by copying the flock.' + ); + } + + this.material.vertexShader = source + .replace(COLONY_ANCHOR_DECL, `${COLONY_SDF_GLSL}\n\n${COLONY_ANCHOR_DECL}`) + .replace(COLONY_ANCHOR_BODY, COLONY_SPLICE_BODY); + this.material.needsUpdate = true; + + const u = this.material.uniforms; + u.uShapeA = { value: ColonyShape.BALL }; + u.uShapeB = { value: ColonyShape.BALL }; + u.uShapeBlend = { value: 0 }; + u.uCondense = { value: 0 }; + u.uShapeSize = { value: new Vector3(1.4, 1.4, 1.4) }; + u.uShapeOffset = { value: new Vector3(0, 0, 0) }; + u.uShapeSpin = { value: 0 }; + u.uShapeFill = { value: 0.6 }; + u.uShapeSteps = { value: 3 }; + u.uShapeSlack = { value: 0.85 }; + u.uShapeRough = { value: 0.06 }; + u.uWaveAmp = { value: 0 }; + u.uWaveLength = { value: 2.5 }; + u.uWaveSpeed = { value: 3 }; + u.uWaveAxis = { value: new Vector3(0, 1, 0) }; + u.uCling = { value: 0 }; + u.uFloorY = { value: 0 }; + u.uCrawlHeight = { value: 0.12 }; + + /** The colony half of the resolved params. Written into, never replaced. */ + this._cp = colonyOwnParams(); + } + + /** + * Push the live params. + * + * @param {number} _now ignored, as `Swarm`'s is — the shape's spin and the + * density wave both run on `frame.uTime`, because a colony that has been in + * the air for four seconds should not restart its surge when it condenses. + * @param {object} params live block; keys from `colonySwarmParams()`. + */ + update(_now, params) { + super.update(_now, params); + + const p = params ?? COLONY_OWN_DEFAULTS; + const c = this._cp; + for (const key in COLONY_OWN_DEFAULTS) { + const value = p[key]; + c[key] = value === undefined ? COLONY_OWN_DEFAULTS[key] : value; + } + + const u = this.material.uniforms; + u.uShapeA.value = clamp(Math.round(c.shapeA), 0, 5); + u.uShapeB.value = clamp(Math.round(c.shapeB), 0, 5); + u.uShapeBlend.value = c.shapeBlend; + u.uCondense.value = c.condense; + u.uShapeSize.value.set(c.shapeWidth, c.shapeHeight, c.shapeDepth); + u.uShapeOffset.value.set(c.shapeForward, c.shapeSide, c.shapeUp); + u.uShapeSpin.value = c.shapeSpin; + u.uShapeFill.value = c.shapeFill; + u.uShapeSteps.value = c.shapeSteps; + u.uShapeSlack.value = c.shapeSlack; + u.uShapeRough.value = c.shapeRough; + + u.uWaveAmp.value = c.waveAmp; + u.uWaveLength.value = c.waveLength; + u.uWaveSpeed.value = c.waveSpeed; + // One slider instead of a vector: 0 is a wave climbing a funnel, 1 is a + // wave running down the cast. Normalised here so the shader never has to. + _v0.set(0, 1, 0).lerp(_v1.copy(this._direction).setY(0.0001), saturate(c.waveAlong)); + if (_v0.lengthSq() < 1e-8) _v0.set(0, 1, 0); + u.uWaveAxis.value.copy(_v0.normalize()); + + u.uCling.value = c.cling; + u.uFloorY.value = c.floorY; + u.uCrawlHeight.value = c.crawlHeight; + } + + /** + * Where the shape's centre is, in world space — for a light, a burst or an + * emitter. The CPU mirror of the `centre` term in `colonyTarget()`; mirror, + * so if you change one change the other. + */ + shapeCentre(out) { + this.leadPoint(out); + const c = this._cp; + out.addScaledVector(this._direction, c.shapeForward).addScaledVector(this._side, c.shapeSide); + out.y += c.shapeUp; + return out; + } +} + +/** The keys `ColonySwarm` adds on top of `swarmParams()`. */ +function colonyOwnParams() { + return { + /* --- the shape --- */ + shapeA: ColonyShape.BALL, // ColonyShape.* + shapeB: ColonyShape.BALL, // the shape being morphed toward + shapeBlend: 0, // 0 all A, 1 all B — blends the fields, so the middle is a shape + condense: 0, // 0 pure flock, 1 pure shape + shapeWidth: 1.4, // metres, half-extent across + shapeHeight: 1.4, // metres, half-extent up + shapeDepth: 1.4, // metres, half-extent downrange + shapeForward: 0, // metres from the lead, downrange + shapeSide: 0, // metres from the lead, lateral + shapeUp: 0, // metres from the lead, vertical + shapeSpin: 0.35, // radians/second about the world up axis + shapeFill: 0.6, // 0 a shell of agents, 1 a solid body + shapeSteps: 3, // descent steps, 1..4 — 2 is soft, 4 is a shrink-wrap + shapeSlack: 0.85, // fraction of each step taken; < 1 stops concavities ringing + shapeRough: 0.06, // unitless slop off the isosurface — a crowd, not a membrane + + /* --- the density wave --- */ + waveAmp: 0, // metres of longitudinal bunching + waveLength: 2.5, // metres between crests + waveSpeed: 3, // metres/second the crests travel + waveAlong: 0, // 0 the wave climbs, 1 it runs down the cast + + /* --- crawling --- */ + cling: 0, // 0 flying, 1 pinned to the floor + floorY: 0, // metres + crawlHeight: 0.12 // metres above the floor a crawler rides + }; +} + +const COLONY_OWN_DEFAULTS = colonyOwnParams(); + +/** + * Every key `ColonySwarm.update()` understands — `swarmParams()` plus the + * shape, the wave and the crawl. Same contract: anything absent falls back. + */ +export function colonySwarmParams() { + return { ...swarmParams(), ...colonyOwnParams() }; +} + +/* ====================================================================== */ +/* §2 · Graph + membrane */ +/* ====================================================================== */ + +/** + * The field both meshes are drawn in. + * + * Injected verbatim into the strand shader *and* the film shader, and every + * uniform it names is a **shared box** — the two materials hold the same + * `{ value }` objects by identity, so one write updates both. That is not a + * micro-optimisation, it is the correctness property: a film whose sag formula + * drifts a millimetre from its threads' does not look like a slightly wrong + * web, it looks like a sheet of cling film hovering near one. Sharing the + * source and sharing the boxes makes the drift unrepresentable. + * + * A node is addressed by two integers — its ring and its spoke — and nothing + * else. Ring -1 is the hub, and it needs no special case: its radius fraction + * is zero, and every per-node jitter in here is multiplied by that fraction, so + * all the spokes of ring -1 land on exactly the same point however the dice + * fell. The first version carried an explicit hub flag and a branch; deleting + * the flag deleted three bugs, all of them "the hub moved". + * + * The dice are hashed from the indices rather than stored in a per-node array, + * for the reason given in the traps file: a uniform array may only be indexed + * by a loop counter, and a texture is I2. The cost is that the CPU cannot + * reproduce them exactly — see `nodePoint()`. + */ +const WEB_FIELD_GLSL = /* glsl */ ` + uniform vec3 uAnchor; // metres, the hub + uniform vec3 uAxisX; // unit, the web's own right + uniform vec3 uAxisY; // unit, the web's own up + uniform vec3 uNormalAxis; // unit, out of the web's plane + + uniform float uRings; // whole number + uniform float uSpokes; // whole number + uniform float uRadius; // metres to the outer ring + uniform float uSquash; // 1 circular, < 1 an ellipse lying down + uniform float uRingCurve; // > 1 crowds the rings toward the rim + uniform float uRingJitter; // unitless, fraction of a ring's radius + uniform float uSpokeJitter; // unitless, fraction of a spoke's spacing + uniform float uTwist; // radians of shear per unit radius fraction + uniform float uDroop; // metres the whole disc sags at the rim + uniform float uDepth; // metres of out-of-plane node scatter + uniform float uSlack; // sag as a FRACTION of a strand's own span + uniform float uSway; // metres of breeze, out of plane + uniform float uSwayRate; // radians/second + uniform float uSeed; + + /** + * The dice for the strand between two nodes. + * + * Argument order matters and must match the CPU's: the film's four boundary + * curves are the same four strands the strand mesh draws, and they only agree + * if they roll the same number. + */ + float webEdgeDice(vec2 a, vec2 b) { + return hash11(dot(a, vec2(17.31, 5.77)) + dot(b, vec2(3.13, 29.71)) + uSeed * 1.7); + } + + /** Node (ring, spoke) in world metres. Ring -1 is the hub. */ + vec3 webNode(vec2 rs) { + float rings = max(uRings, 1.0); + float spokes = max(uSpokes, 1.0); + vec3 dice = hash31(uSeed * 3.11 + rs.x * 37.13 + rs.y * 7.31 + 5.77); + + float rho = clamp((rs.x + 1.0) / rings, 0.0, 1.0); + rho = pow(rho, max(uRingCurve, 0.05)); + rho *= 1.0 + (dice.x - 0.5) * 2.0 * uRingJitter; + + float turn = rs.y / spokes + (dice.y - 0.5) * 2.0 * uSpokeJitter / spokes; + float a = turn * TAU + uTwist * rho; + + vec2 pl = vec2(cos(a), sin(a)) * (rho * uRadius); + pl.y *= uSquash; + pl.y -= uDroop * rho * rho; + float off = (dice.z - 0.5) * 2.0 * uDepth * rho; + + return uAnchor + uAxisX * pl.x + uAxisY * pl.y + uNormalAxis * (off * rho); + } + + /** + * A point at fraction u along the strand between two nodes. + * + * The sag is a fraction of the strand's OWN span, not a fixed drop. That is + * the difference between a web and a net: a short chord near the hub hangs + * almost straight while a two-metre outer chord bellies, which is what the + * eye reads as tension. The first version used an absolute sag in metres and + * every strand hung by the same amount, so the inner rings looked slack and + * the whole thing read as knitting. + */ + vec3 webEdge(vec3 a, vec3 b, float u, float dice) { + vec3 p = mix(a, b, u); + float span = length(b - a); + float bow = 4.0 * u * (1.0 - u); + p.y -= uSlack * span * bow * (0.55 + 0.9 * dice); + p += uNormalAxis * (uSway * bow * sin(uTime * uSwayRate + dice * TAU + span * 1.7)); + return p; + } + + vec3 webSafeNormalize(vec3 v, vec3 fallback) { + float l2 = dot(v, v); + return l2 > 1e-10 ? v * inversesqrt(l2) : fallback; + } +`; + +/* --- strands ---------------------------------------------------------- */ + +const WEB_STRAND_VERTEX = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform float uTime; + uniform float uStrandWidth; // metres + uniform float uWidthJitter; // +/- fraction + uniform float uChordWidth; // multiplier on chords, so radials read heavier + uniform float uGrow; // 0..1 of the web that has been spun + uniform float uGrowFeather; // 0..1 width of the spinning front + + attribute vec2 aFrom; // (ring, spoke) + attribute vec2 aTo; + attribute vec2 aStrand; // x order 0..1, y kind: 0 radial, 1 chord + + varying vec2 vUv; + varying vec3 vTangent; + varying vec3 vWorld; + varying float vAlpha; + varying float vDice; + + ${noiseGLSL} + ${WEB_FIELD_GLSL} + + void main() { + float drawn = smoothstep(aStrand.x, aStrand.x + max(uGrowFeather, 1e-3), clamp(uGrow, 0.0, 1.0)); + float u = min(position.x, drawn); + + vec3 a = webNode(aFrom); + vec3 b = webNode(aTo); + float dice = webEdgeDice(aFrom, aTo); + + vec3 p = webEdge(a, b, u, dice); + // Central difference for the tangent rather than (b - a): the strand sags, + // and a ribbon oriented off the chord twists visibly at the belly. + const float du = 0.03; + vec3 ahead = webEdge(a, b, min(u + du, 1.0), dice); + vec3 behind = webEdge(a, b, max(u - du, 0.0), dice); + vec3 tangent = webSafeNormalize(ahead - behind, vec3(0.0, 1.0, 0.0)); + + vec3 view = webSafeNormalize(cameraPosition - p, vec3(0.0, 0.0, 1.0)); + vec3 across = webSafeNormalize(cross(tangent, view), vec3(1.0, 0.0, 0.0)); + + float w = uStrandWidth + * (1.0 + uWidthJitter * (dice - 0.5) * 2.0) + * mix(1.0, uChordWidth, aStrand.y); + vec3 world = p + across * (position.y * w * 0.5); + + vUv = uv; + vTangent = tangent; + vWorld = world; + vDice = dice; + vAlpha = step(0.002, drawn); + + gl_Position = projectionMatrix * modelViewMatrix * vec4(world, 1.0); + } +`; + +/** + * Silk is a fibre, so it is shaded as one. + * + * A thread has no surface normal — it has a tangent, and every normal + * perpendicular to that tangent is equally the surface. The Kajiya–Kay model is + * the standard answer: integrate the highlight around that ring and you get a + * lobe that depends on the angle between the tangent, the light and the eye, + * and nothing else. What it looks like is the *band* of light that runs across + * a web perpendicular to the threads and slides along them as you move — which + * is the single most recognisable thing about a web and cannot be faked with a + * fresnel. + * + * The first version shaded the ribbon as a billboarded quad with a soft edge. + * It gave grey string. Nothing about it moved when the camera did, because a + * billboard's normal always points at you and the highlight therefore never + * goes anywhere. + */ +const WEB_STRAND_FRAGMENT = /* glsl */ ` + uniform vec3 uLightDir; + uniform float uGlobalGlow; + uniform vec3 uStrandColor; + uniform vec3 uSilkColor; + uniform float uSilkPower; // fibre highlight exponent + uniform float uSilkGain; + uniform float uStrandOpacity; + uniform float uStrandGlow; + uniform float uCoreBias; // how much brighter the thread's spine is + + varying vec2 vUv; + varying vec3 vTangent; + varying vec3 vWorld; + varying float vAlpha; + varying float vDice; + + void main() { + if (vAlpha < 0.004) discard; + + // The ribbon is flat; treating its across-coordinate as a cylinder's + // cross-section is what stops it reading as tape. + float across = vUv.y * 2.0 - 1.0; + float round = sqrt(max(1.0 - across * across, 0.0)); + float alpha = vAlpha * uStrandOpacity * round; + if (alpha < 0.004) discard; + + vec3 v = normalize(cameraPosition - vWorld); + vec3 l = normalize(uLightDir); + vec3 t = normalize(vTangent); + + float tl = dot(t, l); + float tv = dot(t, v); + float sinTL = sqrt(max(1.0 - tl * tl, 0.0)); + float sinTV = sqrt(max(1.0 - tv * tv, 0.0)); + float spec = pow(max(sinTL * sinTV - tl * tv, 0.0), max(uSilkPower, 1.0)); + + vec3 colour = uStrandColor * (0.35 + 0.65 * sinTL); + colour += uSilkColor * (spec * uSilkGain * (0.7 + 0.6 * vDice)); + colour *= mix(1.0, 1.0 + uCoreBias, round); + colour *= uStrandGlow * uGlobalGlow; + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/* --- the membrane ----------------------------------------------------- */ + +const WEB_FILM_VERTEX = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform float uTime; + uniform float uDish; // metres the film bellies between its threads + uniform float uGrow; + uniform float uGrowFeather; + uniform float uOrderScatter; // 0 the film fills ring by ring, 1 at random + + attribute vec2 aC0; // inner ring, spoke j + attribute vec2 aC1; // inner ring, spoke j+1 + attribute vec2 aC2; // outer ring, spoke j+1 + attribute vec2 aC3; // outer ring, spoke j + + varying vec3 vNormal; + varying vec3 vWorld; + varying vec3 vDice; + varying float vRho; + varying float vAlpha; + + ${noiseGLSL} + ${WEB_FIELD_GLSL} + + /** + * A Coons patch over the face's four strands. + * + * Bilinear interpolation of the four *nodes* would give a flat quad whose + * edges cut straight across the sag of the threads that bound it — the film + * would visibly float off its own frame at every belly. A Coons patch is the + * surface that interpolates four given boundary *curves* exactly, so the film + * meets each of its four strands along its whole length, by construction. The + * subtracted bilinear term is the correction that keeps the corners from + * being counted twice; drop it and the patch bulges by exactly one corner. + */ + vec3 filmPoint(vec3 c0, vec3 c1, vec3 c2, vec3 c3, vec4 d, vec2 st, float dish) { + float s = clamp(st.x, 0.0, 1.0); + float t = clamp(st.y, 0.0, 1.0); + vec3 inner = webEdge(c0, c1, s, d.x); + vec3 outer = webEdge(c3, c2, s, d.y); + vec3 left = webEdge(c0, c3, t, d.z); + vec3 right = webEdge(c1, c2, t, d.w); + vec3 corners = mix(mix(c0, c1, s), mix(c3, c2, s), t); + vec3 p = mix(inner, outer, t) + mix(left, right, s) - corners; + // Zero on all four edges, so dishing the film cannot break the seal. + float bump = 16.0 * s * (1.0 - s) * t * (1.0 - t); + return p + uNormalAxis * (dish * bump); + } + + void main() { + vec3 c0 = webNode(aC0); + vec3 c1 = webNode(aC1); + vec3 c2 = webNode(aC2); + vec3 c3 = webNode(aC3); + // The same four rolls the strand mesh makes for these same four edges. + vec4 d = vec4( + webEdgeDice(aC0, aC1), + webEdgeDice(aC3, aC2), + webEdgeDice(aC0, aC3), + webEdgeDice(aC1, aC2) + ); + + vec3 dice = hash31(uSeed * 5.13 + aC0.x * 31.7 + aC0.y * 11.3 + 91.1); + float dish = uDish * ((dice.x - 0.5) * 2.0); + + vec2 st = position.xy; + vec3 world = filmPoint(c0, c1, c2, c3, d, st, dish); + + // Derivatives of the patch itself, so the normal knows about the sag and + // the dish. Four extra evaluations; the alternative was a per-face constant + // normal, and a flat normal on a bellied film kills the grazing term + // exactly where the film is most visible. + const float h = 0.09; + vec3 ds = filmPoint(c0, c1, c2, c3, d, st + vec2(h, 0.0), dish) + - filmPoint(c0, c1, c2, c3, d, st - vec2(h, 0.0), dish); + vec3 dt = filmPoint(c0, c1, c2, c3, d, st + vec2(0.0, h), dish) + - filmPoint(c0, c1, c2, c3, d, st - vec2(0.0, h), dish); + vNormal = webSafeNormalize(cross(ds, dt), uNormalAxis); + + float rho = clamp((aC3.x + 1.0) / max(uRings, 1.0), 0.0, 1.0); + float order = mix(rho, dice.y, clamp(uOrderScatter, 0.0, 1.0)); + vAlpha = smoothstep(order, order + max(uGrowFeather, 1e-3), clamp(uGrow, 0.0, 1.0)); + + vWorld = world; + vDice = dice; + vRho = rho; + + gl_Position = projectionMatrix * modelViewMatrix * vec4(world, 1.0); + } +`; + +/** + * The membrane, which is the part everybody forgets. + * + * Two facts about a real web film and the shader is both of them. It is + * **invisible head-on** — a film a fraction of a micron thick transmits almost + * everything at normal incidence — and it **flares at grazing angles**, where + * the path through it lengthens and the Fresnel reflectance climbs toward one. + * So the alpha here is a pure grazing term and there is no constant part at all. + * Adding even a small ambient floor was the first version's mistake: it turned + * the web into a frosted disc, and the whole trick — panels of the web + * appearing and vanishing as the camera swings — went with it. + * + * The colour comes from the same angle. Thin-film interference shifts hue with + * path length, so walking the four-stop gradient with the grazing term gives + * the oil-slick banding for nothing; `filmBands` is how many times round the + * gradient the film goes between head-on and edge-on, and the per-face dice + * offsets each panel so neighbours never band in step. + */ +const WEB_FILM_FRAGMENT = /* glsl */ ` + uniform vec3 uLightDir; + uniform float uGlobalGlow; + + uniform vec3 uFilmA; + uniform vec3 uFilmB; + uniform vec3 uFilmC; + uniform vec3 uFilmD; + uniform vec3 uFilmSheenColor; + uniform float uGrazePower; // higher is a thinner rim of visibility + uniform float uFilmOpacity; + uniform float uFilmBands; // times round the gradient, head-on to edge-on + uniform float uFilmShift; // per-face offset along it + uniform float uFilmSheen; + uniform float uFilmSheenPower; + uniform float uFilmFill; // 0..1 of the faces that carry any film at all + uniform float uTearBias; // 1 tears the rim first, 0 tears evenly + uniform float uFilmGlow; + + varying vec3 vNormal; + varying vec3 vWorld; + varying vec3 vDice; + varying float vRho; + varying float vAlpha; + + ${commonGLSL} + + void main() { + if (vAlpha < 0.004) discard; + + // Torn panels. A web with every face filled is a lampshade; the holes are + // what say it was built by something and then walked through. + float keep = uFilmFill * mix(1.0, 1.0 - vRho, clamp(uTearBias, 0.0, 1.0)); + if (vDice.z > keep) discard; + + vec3 n = normalize(vNormal); + vec3 v = normalize(cameraPosition - vWorld); + float graze = 1.0 - clamp(abs(dot(n, v)), 0.0, 1.0); + + float alpha = vAlpha * uFilmOpacity * pow(graze, max(uGrazePower, 0.1)); + if (alpha < 0.003) discard; + + float band = fract(graze * uFilmBands + vDice.y * uFilmShift); + vec3 colour = gradient4(uFilmA, uFilmB, uFilmC, uFilmD, band); + + vec3 l = normalize(uLightDir); + float sheen = pow(clamp(dot(reflect(-l, n), v), 0.0, 1.0), max(uFilmSheenPower, 1.0)); + colour += uFilmSheenColor * (sheen * uFilmSheen); + colour *= uFilmGlow * uGlobalGlow; + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/** + * Every key `WebGraph.update()` understands, with its default and its unit. + * + * The **topology** group is structural: changing `rings` or `spokes` rewrites + * the index attributes, which happens inside `update()` on the frame the number + * changes and costs a few hundred integer writes. Everything else is a uniform + * and is live on a paused frame. + */ +export function webGraphParams() { + return { + /* --- the graph --- */ + rings: 5, // rings of chords outside the hub, whole number + spokes: 12, // radials, whole number + radius: 3.2, // metres to the outer ring + squash: 0.82, // 1 circular, < 1 an ellipse lying down + ringCurve: 1.35, // > 1 crowds the rings toward the rim, as a real web does + ringJitter: 0.12, // unitless, fraction of a ring's radius + spokeJitter: 0.22, // unitless, fraction of a spoke's spacing + twist: 0.25, // radians of shear per unit radius fraction + droop: 0.35, // metres the whole disc sags at the rim + depth: 0.28, // metres of out-of-plane node scatter + + /* --- the strands --- */ + slack: 0.09, // sag as a FRACTION of a strand's own span + sway: 0.05, // metres of breeze, out of plane + swayRate: 1.7, // radians/second + strandWidth: 0.02, // metres + widthJitter: 0.35, // +/- fraction + chordWidth: 0.75, // multiplier on chords, so the radials read as the frame + strandOpacity: 1, + strandGlow: 1.1, + silkPower: 22, // fibre highlight exponent — the band across the threads + silkGain: 1.6, + coreBias: 0.5, // how much brighter a thread's spine is than its edge + + /* --- the membrane --- */ + dish: 0.07, // metres each panel bellies between its four threads + grazePower: 2.6, // higher is a thinner rim of visibility + filmOpacity: 0.55, + filmBands: 2.2, // times round the gradient, head-on to edge-on + filmShift: 0.7, // per-face offset along it + filmSheen: 0.8, + filmSheenPower: 34, + filmFill: 0.8, // 0..1 of the faces that carry any film at all + tearBias: 0.65, // 1 tears the rim first, 0 tears evenly + filmGlow: 1, + + /* --- the spinning --- */ + grow: 1, // 0..1 of the web that has been spun + growFeather: 0.18, // 0..1 width of the spinning front + orderScatter: 0.25, // 0 the film fills ring by ring, 1 at random + + /* --- colour: five pickers, none derived from another --- */ + strandColor: '#efe7d2', + silkColor: '#ffffff', + filmA: '#9fe8d8', + filmB: '#cfd2ff', + filmC: '#ffd9b0', + filmD: '#8fa8c8', + filmSheenColor: '#ffffff' + }; +} + +const WEB_DEFAULTS = webGraphParams(); + +/** + * **What it draws.** An orb web: radial strands from a hub out to a rim, chords + * between them, every strand sagging by a fraction of its own span — **and the + * film in the faces those strands bound**. + * + * **Draw calls.** Two. One instanced strip for every strand, one indexed mesh + * for every panel of film. + * + * **What it reads from settings.** Nothing directly. `update(_now, params)` + * resolves `p.key ?? default` for every key in `webGraphParams()`. + * + * **The one rule for using it well.** *Put the caster somewhere they will move, + * and never let `filmFill` reach 1.* The membrane is the whole point of the + * module and it is a grazing-angle effect: it does not exist head-on, and if + * every face carries it the panels that do flare have nothing to flare against. + * A web at `filmFill = 0.8` with the camera orbiting is the ability; the same + * web filled solid and viewed square-on is a disc. + * + * --- + * + * ## Anchors are two integers + * + * A node is `(ring, spoke)` and that is all that is stored — not a position, not + * an angle, not a radius. Ring -1 is the hub. Every metre the web has is a + * uniform: the rim radius, the ellipse's squash, the out-of-plane scatter, the + * sag, the breeze. Drag `radius` with **P** held and the whole web re-spans, + * film included, because the film is built from the same four functions the + * strands are. + * + * ## The two meshes cannot disagree + * + * They are compiled from one source chunk and they hold the same uniform boxes + * by identity. A film panel is a **Coons patch** over its four boundary + * strands, so it meets them along their entire length rather than at their + * corners, and it rolls the same per-strand dice for the sag because + * `webEdgeDice()` takes the endpoints in an order both builders agree on. + * + * @example + * this.web = new WebGraph(this.group, { maxRings: 6, maxSpokes: 14 }); + * // onSpawn + * this.web.roll(); + * // every frame + * this.web.setPlacement(_target, _direction, _up); + * this._p.grow = saturate(age / c.spinTime); + * this.web.update(age, this._p); + */ +export class WebGraph { + /** + * @param {THREE.Object3D} parent + * @param {object} [options] + * @param {number} [options.maxRings=8] ceiling on `rings` + * @param {number} [options.maxSpokes=16] ceiling on `spokes` + * @param {number} [options.samples=10] segments along one strand. Ten is + * enough for a sag; the strand is a smooth curve, not a kinked bolt. + * @param {number} [options.filmSubdiv=2] quads per film panel, per axis. The + * patch is curved, so 1 would draw its two triangles straight across the + * belly and lose exactly the dish the grazing term is looking for. + * @param {boolean} [options.additive=false] + * @param {number} [options.renderOrder=11] + */ + constructor(parent, options = {}) { + const maxRings = Math.max(1, Math.round(options.maxRings ?? 8)); + const maxSpokes = Math.max(3, Math.round(options.maxSpokes ?? 16)); + const samples = Math.max(2, Math.round(options.samples ?? 10)); + const sub = Math.max(1, Math.round(options.filmSubdiv ?? 2)); + + this.maxRings = maxRings; + this.maxSpokes = maxSpokes; + this.subdivision = sub; + + this.group = new Group(); + this.group.name = 'WebGraph'; + this.group.matrixAutoUpdate = false; + parent?.add(this.group); + + /* --- the shared uniform boxes -------------------------------------- */ + // Held by identity by both materials. See WEB_FIELD_GLSL for why. + const shared = { + uAnchor: { value: new Vector3() }, + uAxisX: { value: new Vector3(1, 0, 0) }, + uAxisY: { value: new Vector3(0, 1, 0) }, + uNormalAxis: { value: new Vector3(0, 0, 1) }, + uRings: { value: 5 }, + uSpokes: { value: 12 }, + uRadius: { value: 3.2 }, + uSquash: { value: 0.82 }, + uRingCurve: { value: 1.35 }, + uRingJitter: { value: 0.12 }, + uSpokeJitter: { value: 0.22 }, + uTwist: { value: 0.25 }, + uDroop: { value: 0.35 }, + uDepth: { value: 0.28 }, + uSlack: { value: 0.09 }, + uSway: { value: 0.05 }, + uSwayRate: { value: 1.7 }, + uSeed: { value: 0 }, + uGrow: { value: 1 }, + uGrowFeather: { value: 0.18 } + }; + this._shared = shared; + + /* --- strands -------------------------------------------------------- */ + const maxStrands = maxRings * maxSpokes * 2; + this.maxStrands = maxStrands; + + const columns = samples + 1; + const stripPos = new Float32Array(columns * 2 * 3); + const stripUv = new Float32Array(columns * 2 * 2); + for (let i = 0; i < columns; i++) { + const u = i / samples; + for (let r = 0; r < 2; r++) { + const v = (i * 2 + r) * 3; + const t = (i * 2 + r) * 2; + stripPos[v + 0] = u; + stripPos[v + 1] = r === 0 ? -1 : 1; + stripPos[v + 2] = 0; + stripUv[t + 0] = u; + stripUv[t + 1] = r; + } + } + const stripIndex = new Uint16Array(samples * 6); + for (let i = 0; i < samples; i++) { + const a = i * 2; + const o = i * 6; + stripIndex[o + 0] = a; + stripIndex[o + 1] = a + 2; + stripIndex[o + 2] = a + 1; + stripIndex[o + 3] = a + 1; + stripIndex[o + 4] = a + 2; + stripIndex[o + 5] = a + 3; + } + + this._aFrom = new InstancedBufferAttribute(new Float32Array(maxStrands * 2), 2); + this._aTo = new InstancedBufferAttribute(new Float32Array(maxStrands * 2), 2); + this._aStrand = new InstancedBufferAttribute(new Float32Array(maxStrands * 2), 2); + + const strandGeometry = new InstancedBufferGeometry(); + strandGeometry.setAttribute('position', new BufferAttribute(stripPos, 3)); + strandGeometry.setAttribute('uv', new BufferAttribute(stripUv, 2)); + strandGeometry.setAttribute('aFrom', this._aFrom); + strandGeometry.setAttribute('aTo', this._aTo); + strandGeometry.setAttribute('aStrand', this._aStrand); + strandGeometry.setIndex(new BufferAttribute(stripIndex, 1)); + strandGeometry.instanceCount = 0; + strandGeometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.strandGeometry = strandGeometry; + + this.strandMaterial = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: options.additive ? AdditiveBlending : NormalBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + ...shared, + uStrandWidth: { value: 0.02 }, + uWidthJitter: { value: 0.35 }, + uChordWidth: { value: 0.75 }, + uStrandColor: { value: new Color('#efe7d2') }, + uSilkColor: { value: new Color('#ffffff') }, + uSilkPower: { value: 22 }, + uSilkGain: { value: 1.6 }, + uStrandOpacity: { value: 1 }, + uStrandGlow: { value: 1.1 }, + uCoreBias: { value: 0.5 } + }), + vertexShader: WEB_STRAND_VERTEX, + fragmentShader: WEB_STRAND_FRAGMENT + }); + + this.strandMesh = new Mesh(strandGeometry, this.strandMaterial); + this.strandMesh.frustumCulled = false; + this.strandMesh.matrixAutoUpdate = false; + this.strandMesh.layers.set(LAYER.VFX); + this.strandMesh.renderOrder = (options.renderOrder ?? 11) + 1; + this.group.add(this.strandMesh); + + /* --- the membrane --------------------------------------------------- */ + const maxFaces = maxRings * maxSpokes; + this.maxFaces = maxFaces; + const vpf = (sub + 1) * (sub + 1); + this._vertsPerFace = vpf; + this._indexPerFace = sub * sub * 6; + + const filmPos = new Float32Array(maxFaces * vpf * 3); + const filmC = [0, 1, 2, 3].map(() => new Float32Array(maxFaces * vpf * 2)); + // The index buffer is a pure function of the subdivision, so it is written + // once here and never again — a topology change only rewrites vertices, and + // `setDrawRange` hides the faces that are not in use. + const filmIndex = new Uint16Array(maxFaces * this._indexPerFace); + for (let f = 0; f < maxFaces; f++) { + const base = f * vpf; + let o = f * this._indexPerFace; + for (let a = 0; a < sub; a++) { + for (let b = 0; b < sub; b++) { + const v00 = base + a * (sub + 1) + b; + const v10 = base + (a + 1) * (sub + 1) + b; + const v11 = base + (a + 1) * (sub + 1) + b + 1; + const v01 = base + a * (sub + 1) + b + 1; + filmIndex[o++] = v00; + filmIndex[o++] = v10; + filmIndex[o++] = v11; + filmIndex[o++] = v00; + filmIndex[o++] = v11; + filmIndex[o++] = v01; + } + } + } + + const filmGeometry = new BufferGeometry(); + filmGeometry.setAttribute('position', new BufferAttribute(filmPos, 3)); + filmGeometry.setAttribute('aC0', new BufferAttribute(filmC[0], 2)); + filmGeometry.setAttribute('aC1', new BufferAttribute(filmC[1], 2)); + filmGeometry.setAttribute('aC2', new BufferAttribute(filmC[2], 2)); + filmGeometry.setAttribute('aC3', new BufferAttribute(filmC[3], 2)); + filmGeometry.setIndex(new BufferAttribute(filmIndex, 1)); + filmGeometry.setDrawRange(0, 0); + filmGeometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.filmGeometry = filmGeometry; + this._filmCorners = filmC; + + this.filmMaterial = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: options.additive ? AdditiveBlending : NormalBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + ...shared, + uDish: { value: 0.07 }, + uOrderScatter: { value: 0.25 }, + uFilmA: { value: new Color('#9fe8d8') }, + uFilmB: { value: new Color('#cfd2ff') }, + uFilmC: { value: new Color('#ffd9b0') }, + uFilmD: { value: new Color('#8fa8c8') }, + uFilmSheenColor: { value: new Color('#ffffff') }, + uGrazePower: { value: 2.6 }, + uFilmOpacity: { value: 0.55 }, + uFilmBands: { value: 2.2 }, + uFilmShift: { value: 0.7 }, + uFilmSheen: { value: 0.8 }, + uFilmSheenPower: { value: 34 }, + uFilmFill: { value: 0.8 }, + uTearBias: { value: 0.65 }, + uFilmGlow: { value: 1 } + }), + vertexShader: WEB_FILM_VERTEX, + fragmentShader: WEB_FILM_FRAGMENT + }); + + this.filmMesh = new Mesh(filmGeometry, this.filmMaterial); + this.filmMesh.frustumCulled = false; + this.filmMesh.matrixAutoUpdate = false; + this.filmMesh.layers.set(LAYER.VFX); + this.filmMesh.renderOrder = options.renderOrder ?? 11; + this.group.add(this.filmMesh); + + /** Topology hash: rebuild the index attributes only when this changes. */ + this._topology = -1; + this.strands = 0; + this.faces = 0; + this.seed = 0; + this._p = webGraphParams(); + + this._rebuild(5, 12); + } + + /** Two — the strands and the film. */ + get drawCalls() { + return 2; + } + + /** Live strands. → `Ability#instanceCount`. */ + get count() { + return this.strands; + } + + /** + * Re-roll the web. + * + * The seed only feeds the hashes inside the shader, so this is one uniform + * write and it is safe at any time: re-rolling mid-spin re-scatters a web that + * is already half built without restarting it. + */ + roll(seed = Math.random() * 100) { + this.seed = seed; + this._shared.uSeed.value = seed; + return this; + } + + /** Hide the web. Leaves the instance reusable — the pooling contract. */ + reset() { + this.strandGeometry.instanceCount = 0; + this.filmGeometry.setDrawRange(0, 0); + this.strandMesh.visible = false; + this.filmMesh.visible = false; + } + + /** + * Where the web hangs and which way it faces. + * + * @param {THREE.Vector3} anchor the hub, in metres + * @param {THREE.Vector3} normal out of the web's plane — for a LINE cast this + * is the cast direction, so the web stands across the lane + * @param {THREE.Vector3} up the world up the web's own up is built from + */ + setPlacement(anchor, normal, up) { + const s = this._shared; + s.uAnchor.value.copy(anchor); + _v0.copy(normal); + if (_v0.lengthSq() < 1e-8) _v0.set(0, 0, 1); + _v0.normalize(); + _v1.copy(up ?? _v2.set(0, 1, 0)); + if (_v1.lengthSq() < 1e-8) _v1.set(0, 1, 0); + // Gram-Schmidt against the normal, so a caster aiming steeply downhill does + // not get a web sheared into a sliver. + _v1.addScaledVector(_v0, -_v1.dot(_v0)); + if (_v1.lengthSq() < 1e-8) _v1.set(0, 1, 0).addScaledVector(_v0, -_v0.y); + _v1.normalize(); + _v3.crossVectors(_v1, _v0).normalize(); + s.uNormalAxis.value.copy(_v0); + s.uAxisY.value.copy(_v1); + s.uAxisX.value.copy(_v3); + return this; + } + + /** + * Rewrite the index attributes for a new `rings × spokes`. + * + * Called from `update()` when the topology hash changes and from nowhere + * else. Every number written here is an **integer index or a unitless + * ordering fraction** — not one metre — which is why a topology rebuild is + * legal under I1 and why it never has to happen again once the sliders stop + * moving. + */ + _rebuild(rings, spokes) { + const from = this._aFrom.array; + const to = this._aTo.array; + const strand = this._aStrand.array; + + let n = 0; + // Radials first, hub outward: a real orb weaver lays the frame before the + // spiral, and drawing them in that order is most of why the spin reads. + for (let r = 0; r < rings; r++) { + const order = 0.45 * ((r + 1) / rings); + for (let j = 0; j < spokes; j++) { + from[n * 2] = r - 1; + from[n * 2 + 1] = j; + to[n * 2] = r; + to[n * 2 + 1] = j; + strand[n * 2] = order; + strand[n * 2 + 1] = 0; + n++; + } + } + // Then the spiral, rim inward — which is the direction the animal walks it. + for (let r = 0; r < rings; r++) { + const order = 0.5 + 0.5 * (1 - (r + 1) / rings); + for (let j = 0; j < spokes; j++) { + from[n * 2] = r; + from[n * 2 + 1] = j; + to[n * 2] = r; + to[n * 2 + 1] = (j + 1) % spokes; + strand[n * 2] = order; + strand[n * 2 + 1] = 1; + n++; + } + } + this.strands = n; + this._aFrom.needsUpdate = true; + this._aTo.needsUpdate = true; + this._aStrand.needsUpdate = true; + this.strandGeometry.instanceCount = n; + + /* --- the faces ---------------------------------------------------- */ + const sub = this.subdivision; + const vpf = this._vertsPerFace; + const pos = this.filmGeometry.attributes.position.array; + const [c0, c1, c2, c3] = this._filmCorners; + + let f = 0; + for (let r = 0; r < rings; r++) { + for (let j = 0; j < spokes; j++) { + const jn = (j + 1) % spokes; + const base = f * vpf; + for (let a = 0; a <= sub; a++) { + for (let b = 0; b <= sub; b++) { + const v = base + a * (sub + 1) + b; + pos[v * 3 + 0] = a / sub; + pos[v * 3 + 1] = b / sub; + pos[v * 3 + 2] = 0; + // The four corners, in the order the boundary curves expect: + // c0-c1 is the inner chord, c3-c2 the outer, c0-c3 and c1-c2 the + // two radials. Swap any pair and the film turns inside out. + c0[v * 2] = r - 1; + c0[v * 2 + 1] = j; + c1[v * 2] = r - 1; + c1[v * 2 + 1] = jn; + c2[v * 2] = r; + c2[v * 2 + 1] = jn; + c3[v * 2] = r; + c3[v * 2 + 1] = j; + } + } + f++; + } + } + this.faces = f; + this.filmGeometry.attributes.position.needsUpdate = true; + for (const name of ['aC0', 'aC1', 'aC2', 'aC3']) { + this.filmGeometry.attributes[name].needsUpdate = true; + } + this.filmGeometry.setDrawRange(0, f * this._indexPerFace); + } + + /** + * Push the live params. + * + * @param {number} _now ignored, as `Swarm`'s and `Curtain`'s are — the breeze + * runs on `frame.uTime`, and the spin is driven by `p.grow`, which the + * ability authors from its own beats. + * @param {object} params live block; keys from `webGraphParams()`. + */ + update(_now, params) { + const p = this._p; + const src = params ?? WEB_DEFAULTS; + for (const key in WEB_DEFAULTS) { + const value = src[key]; + p[key] = value === undefined ? WEB_DEFAULTS[key] : value; + } + + const rings = clamp(Math.round(p.rings), 1, this.maxRings); + const spokes = clamp(Math.round(p.spokes), 3, this.maxSpokes); + const hash = rings * 1024 + spokes; + if (hash !== this._topology) { + this._topology = hash; + this._rebuild(rings, spokes); + } + + const s = this._shared; + s.uRings.value = rings; + s.uSpokes.value = spokes; + s.uRadius.value = p.radius; + s.uSquash.value = p.squash; + s.uRingCurve.value = p.ringCurve; + s.uRingJitter.value = p.ringJitter; + s.uSpokeJitter.value = p.spokeJitter; + s.uTwist.value = p.twist; + s.uDroop.value = p.droop; + s.uDepth.value = p.depth; + s.uSlack.value = p.slack; + s.uSway.value = p.sway; + s.uSwayRate.value = p.swayRate; + s.uGrow.value = p.grow; + s.uGrowFeather.value = p.growFeather; + + const su = this.strandMaterial.uniforms; + su.uStrandWidth.value = p.strandWidth; + su.uWidthJitter.value = p.widthJitter; + su.uChordWidth.value = p.chordWidth; + su.uSilkPower.value = p.silkPower; + su.uSilkGain.value = p.silkGain; + su.uStrandOpacity.value = p.strandOpacity; + su.uStrandGlow.value = p.strandGlow; + su.uCoreBias.value = p.coreBias; + copyPicker(su.uStrandColor.value, p.strandColor); + copyPicker(su.uSilkColor.value, p.silkColor); + + const fu = this.filmMaterial.uniforms; + fu.uDish.value = p.dish; + fu.uOrderScatter.value = p.orderScatter; + fu.uGrazePower.value = p.grazePower; + fu.uFilmOpacity.value = p.filmOpacity; + fu.uFilmBands.value = p.filmBands; + fu.uFilmShift.value = p.filmShift; + fu.uFilmSheen.value = p.filmSheen; + fu.uFilmSheenPower.value = p.filmSheenPower; + fu.uFilmFill.value = p.filmFill; + fu.uTearBias.value = p.tearBias; + fu.uFilmGlow.value = p.filmGlow; + copyPicker(fu.uFilmA.value, p.filmA); + copyPicker(fu.uFilmB.value, p.filmB); + copyPicker(fu.uFilmC.value, p.filmC); + copyPicker(fu.uFilmD.value, p.filmD); + copyPicker(fu.uFilmSheenColor.value, p.filmSheenColor); + + const shown = p.grow > 0.001 && p.radius > 0; + this.strandMesh.visible = shown && p.strandOpacity > 0 && p.strandWidth > 0; + this.filmMesh.visible = shown && p.filmOpacity > 0 && p.filmFill > 0; + } + + /** + * Where node `(ring, spoke)` is, in world metres. Ring -1 is the hub. + * + * **Without the per-node jitter.** The jitter is hashed in the shader, and a + * JS mirror of a `fract`-chain hash run at double precision does not agree + * with the same chain run at `highp` float — a hash amplifies the last bit + * into a completely different number, so a "mirror" would be confidently + * wrong rather than approximately right. This returns the node's nominal + * place, which is within `ringJitter` of where the strand actually is. Same + * decision, same reason, as `Curtain#sheetPoint()` leaving out the travelling + * ripple and `LiquidSurface#lipPosition()` leaving out the chop. + * + * @param {number} ring -1 for the hub + * @param {number} spoke + * @param {object} p the same live block handed to `update()` + * @param {THREE.Vector3} out + */ + nodePoint(ring, spoke, p, out) { + const rings = Math.max(1, Math.round(p.rings ?? WEB_DEFAULTS.rings)); + const spokes = Math.max(3, Math.round(p.spokes ?? WEB_DEFAULTS.spokes)); + const radius = p.radius ?? WEB_DEFAULTS.radius; + const curve = Math.max(p.ringCurve ?? WEB_DEFAULTS.ringCurve, 0.05); + const rho = Math.pow(saturate((ring + 1) / rings), curve); + const a = (spoke / spokes) * TAU + (p.twist ?? WEB_DEFAULTS.twist) * rho; + + const s = this._shared; + const x = Math.cos(a) * rho * radius; + const y = + Math.sin(a) * rho * radius * (p.squash ?? WEB_DEFAULTS.squash) - + (p.droop ?? WEB_DEFAULTS.droop) * rho * rho; + return out + .copy(s.uAnchor.value) + .addScaledVector(s.uAxisX.value, x) + .addScaledVector(s.uAxisY.value, y); + } + + dispose() { + this.strandGeometry.dispose(); + this.strandMaterial.dispose(); + this.filmGeometry.dispose(); + this.filmMaterial.dispose(); + this.group.parent?.remove(this.group); + } +} + +/** + * Copy a picker into a `THREE.Color`, accepting either a `#rrggbb` string or a + * `Color`. Strings go through the memoised `getColor`, so calling this every + * frame for a dozen pickers costs a dozen copies and no allocation. + */ +function copyPicker(target, value) { + target.copy(typeof value === 'string' ? getColor(value) : value); +} + +/* ====================================================================== */ +/* §3 · Lattice growth */ +/* ====================================================================== */ + +/** + * One comb cell, in unit space: circumradius 1 across, height 1 up, open at the + * top with a rim and a recess. + * + * It is not a hexagonal prism. A prism gives a tiled floor — the eye reads a + * mosaic and stops. What says *hive* is the **rim and the hole**: a wall of a + * definite thickness, a shadowed recess behind it, and a floor down there + * catching a little light. Those three features cost thirty triangles and they + * are the difference between a honeycomb and a bathroom. + * + * Every face carries a `kind` so the fragment shader can treat them + * differently — the recess is what glows, and it can only glow if the shader + * knows which triangles are inside it. + * + * Normals are flat and explicit, so the geometry is drawn `DoubleSide`. Winding + * on a five-part hand-built solid is a bug farm and the interior of a cell is + * meant to be looked into anyway; the cost is fill on faces the rim already + * hides. + * + * @param {number} sides 6 is a hive; 4 and 8 are also legal and also read + * @param {number} wall wall thickness as a fraction of the circumradius + * @param {number} depth recess depth as a fraction of the height + */ +function buildCombCell(sides, wall, depth) { + const n = Math.max(3, Math.round(sides)); + const inner = clamp(1 - wall, 0.05, 0.98); + const yFloor = clamp(1 - depth, 0.02, 0.98); + + const verts = n * 18; + const position = new Float32Array(verts * 3); + const normal = new Float32Array(verts * 3); + const kind = new Float32Array(verts); + const index = new Uint16Array(n * 24); + + let v = 0; + let i = 0; + const put = (x, y, z, nx, ny, nz, k) => { + position[v * 3] = x; + position[v * 3 + 1] = y; + position[v * 3 + 2] = z; + normal[v * 3] = nx; + normal[v * 3 + 1] = ny; + normal[v * 3 + 2] = nz; + kind[v] = k; + return v++; + }; + const quad = (a, b, c, d) => { + index[i++] = a; + index[i++] = b; + index[i++] = c; + index[i++] = a; + index[i++] = c; + index[i++] = d; + }; + const tri = (a, b, c) => { + index[i++] = a; + index[i++] = b; + index[i++] = c; + }; + + for (let k = 0; k < n; k++) { + const a0 = ((k + 0.5) / n) * TAU; + const a1 = ((k + 1.5) / n) * TAU; + const x0 = Math.cos(a0); + const z0 = Math.sin(a0); + const x1 = Math.cos(a1); + const z1 = Math.sin(a1); + // The face normal of a wall is the bisector of its two corners, which for a + // regular polygon is the outward normal of the edge itself. + const mx = x0 + x1; + const mz = z0 + z1; + const ml = Math.hypot(mx, mz) || 1; + const nx = mx / ml; + const nz = mz / ml; + + /* outer wall */ + quad( + put(x0, 0, z0, nx, 0, nz, 0), + put(x1, 0, z1, nx, 0, nz, 0), + put(x1, 1, z1, nx, 0, nz, 0), + put(x0, 1, z0, nx, 0, nz, 0) + ); + /* the rim, between the outer and inner polygons at full height */ + quad( + put(x0, 1, z0, 0, 1, 0, 1), + put(x1, 1, z1, 0, 1, 0, 1), + put(x1 * inner, 1, z1 * inner, 0, 1, 0, 1), + put(x0 * inner, 1, z0 * inner, 0, 1, 0, 1) + ); + /* inner wall, down into the recess — normal points inward */ + quad( + put(x0 * inner, 1, z0 * inner, -nx, 0, -nz, 2), + put(x1 * inner, 1, z1 * inner, -nx, 0, -nz, 2), + put(x1 * inner, yFloor, z1 * inner, -nx, 0, -nz, 2), + put(x0 * inner, yFloor, z0 * inner, -nx, 0, -nz, 2) + ); + /* the floor of the recess */ + tri( + put(0, yFloor, 0, 0, 1, 0, 3), + put(x0 * inner, yFloor, z0 * inner, 0, 1, 0, 3), + put(x1 * inner, yFloor, z1 * inner, 0, 1, 0, 3) + ); + /* the underside */ + tri( + put(0, 0, 0, 0, -1, 0, 4), + put(x1, 0, z1, 0, -1, 0, 4), + put(x0, 0, z0, 0, -1, 0, 4) + ); + } + + return { position, normal, kind, index, vertexCount: v, indexCount: i }; +} + +const LATTICE_VERTEX = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + uniform vec3 uAnchor; // metres + uniform vec3 uAxisX; // unit + uniform vec3 uAxisZ; // unit + uniform float uNow; // seconds since the cast began + + uniform float uCellPitch; // metres between adjacent cell centres + uniform float uCellRadius; // metres, circumradius of one cell + uniform float uSizeJitter; // +/- fraction + uniform float uHeightBase; // metres, a cell far from the seed + uniform float uHeightPeak; // metres, a cell at the seed + uniform float uHeightFalloff; // LATTICE UNITS over which peak decays to base + uniform float uLayerHeight; // metres between stacked layers + uniform float uBaseY; // metres, the floor the comb sits on + uniform float uRise; // metres a cell climbs out of the floor as it lands + + uniform float uStagger; // seconds between one cell and the next + uniform float uGrowTime; // seconds one cell takes to land + uniform float uOvershoot; // fraction it overshoots by on the way + uniform float uRetract; // 1 fully built, 0 fully withdrawn + + attribute vec3 aCell; // x, y in LATTICE UNITS; z the layer index + attribute vec4 aCellInfo; // turn fraction, order index, dice, lattice radius + + varying vec3 vNormal; + varying vec3 vWorld; + varying float vKind; + varying float vDice; + varying float vRadius; + varying float vFlash; + varying float vEmerge; + + attribute float aKind; + + ${noiseGLSL} + + void main() { + float dice = aCellInfo.z; + float birth = aCellInfo.y * uStagger; + float e = clamp((uNow - birth) / max(uGrowTime, 1e-3), 0.0, 1.0); + // Cubic ease out, then an overshoot that is zero at both ends — so the cell + // punches past its height and settles onto it exactly, rather than settling + // onto whatever the overshoot happened to leave behind. + float ease = 1.0 - pow(1.0 - e, 3.0); + float grow = ease * (1.0 + uOvershoot * sin(PI * e) * (1.0 - e)); + grow *= clamp(uRetract, 0.0, 1.0); + + float far = clamp(aCellInfo.w / max(uHeightFalloff, 0.01), 0.0, 1.0); + float h = mix(uHeightPeak, uHeightBase, far) * (1.0 + uSizeJitter * (dice - 0.5) * 2.0); + float radius = uCellRadius * (1.0 + uSizeJitter * (hash11(dice * 31.7) - 0.5) * 2.0); + + float turn = aCellInfo.x * TAU; + mat2 spin = rot2(turn); + vec2 local = spin * position.xz * radius; + // Swizzling a constructor is legal and compiles here and fails somewhere + // else; two statements cost nothing and cannot be got wrong. + vec2 nrmXZ = spin * normal.xz; + vec3 nrm = vec3(nrmXZ.x, normal.y, nrmXZ.y); + + vec2 centre = aCell.xy * uCellPitch; + float y = uBaseY + aCell.z * uLayerHeight + position.y * h * grow - (1.0 - ease) * uRise; + + vec3 world = uAnchor + + uAxisX * (centre.x + local.x) + + uAxisZ * (centre.y + local.y); + world.y = y; + + vNormal = normalize(uAxisX * nrm.x + vec3(0.0, 1.0, 0.0) * nrm.y + uAxisZ * nrm.z); + vWorld = world; + vKind = aKind; + vDice = dice; + vRadius = aCellInfo.w; + vEmerge = e; + // The flash is the moment of landing, and it is a function of e rather than + // of the clock: a cell that has not been reached yet has never flashed, and + // one whose stagger was dragged forward under a paused frame flashes now. + vFlash = pow(1.0 - e, 3.0) * step(0.001, e); + + gl_Position = projectionMatrix * modelViewMatrix * vec4(world, 1.0); + } +`; + +const LATTICE_FRAGMENT = /* glsl */ ` + uniform vec3 uLightDir; + uniform float uGlobalGlow; + + uniform vec3 uCombA; + uniform vec3 uCombB; + uniform vec3 uCombC; + uniform vec3 uCombD; + uniform vec3 uCoreColor; + uniform vec3 uRimColor; + uniform vec3 uSheenColor; + uniform vec3 uFlashColor; + + uniform float uTintRadius; // LATTICE UNITS over which the gradient is walked + uniform float uTintJitter; + uniform float uWrap; + uniform float uRimPow; + uniform float uRimGain; + uniform float uSheenPow; + uniform float uSheenGain; + uniform float uCoreGlow; // how hot the recess is + uniform float uFlashGain; + uniform float uGlow; + + varying vec3 vNormal; + varying vec3 vWorld; + varying float vKind; + varying float vDice; + varying float vRadius; + varying float vFlash; + varying float vEmerge; + + ${commonGLSL} + ${COLONY_CHITIN_GLSL} + + void main() { + if (vEmerge < 0.001) discard; + + vec3 n = normalize(vNormal); + vec3 v = normalize(cameraPosition - vWorld); + vec3 l = normalize(uLightDir); + + float t = clamp(vRadius / max(uTintRadius, 0.01) + (vDice - 0.5) * 2.0 * uTintJitter, 0.0, 1.0); + vec3 body = gradient4(uCombA, uCombB, uCombC, uCombD, t); + + vec3 colour = chitin(n, v, l, body, uRimColor, uSheenColor, + uWrap, uRimPow, uRimGain, uSheenPow, uSheenGain); + + // Only the recess glows. A cell lit all over is a lantern; a cell with a + // hot hole in it is a hive, and the read comes entirely from the rim being + // dark against it. + float core = step(1.5, vKind); + colour += uCoreColor * (core * uCoreGlow * (0.55 + 0.45 * vDice)); + colour += uFlashColor * (vFlash * uFlashGain); + colour *= uGlow * uGlobalGlow; + + gl_FragColor = vec4(colour, 1.0); + } +`; + +/** Buckets in the refusal hash. A power of two, so the mask is an AND. */ +const LATTICE_BUCKETS = 2048; + +/** + * Every key `LatticeGrowth.update()` understands, with its default and its unit. + * + * The **structure** group is what the growth is run against; changing any of it + * regrows the comb inside `update()` on the frame it changes, which is a few + * hundred microseconds and happens while a slider is moving. Everything else is + * a uniform and is live on a zero-length frame. + */ +export function latticeGrowthParams() { + return { + /* --- the structure: changing any of these regrows --- */ + cells: 120, // cells to attempt, whole number + seed: 0, // the growth's dice + drift: 0.16, // turns of lattice drift a child may take from its parent + refuse: 0.94, // LATTICE UNITS — candidates closer than this to a placed cell + // are refused. Just under 1, so a clean neighbour passes and a + // drifted one does not. This is what makes the edge ragged. + climb: 0.15, // 0..1 tendency to start a new layer instead of spreading + outward: 0.55, // 0..1 preference for candidates further from the seed + layers: 4, // ceiling on stacked layers, whole number + + /* --- the metres --- */ + pitch: 0.34, // metres between adjacent cell centres + cellRadius: 0.2, // metres, circumradius of one cell + sizeJitter: 0.12, // +/- fraction on height and radius + heightBase: 0.22, // metres, a cell far from the seed + heightPeak: 1.1, // metres, a cell at the seed + heightFalloff: 5, // LATTICE UNITS over which peak decays to base + layerHeight: 0.26, // metres between stacked layers + baseY: 0, // metres, the floor the comb sits on + rise: 0.35, // metres a cell climbs out of the floor as it lands + + /* --- the clock --- */ + stagger: 0.012, // seconds between one cell and the next + growTime: 0.22, // seconds one cell takes to land + overshoot: 0.28, // fraction it overshoots by on the way + retract: 1, // 1 fully built, 0 fully withdrawn + + /* --- the look --- */ + tintRadius: 7, // LATTICE UNITS over which the gradient is walked + tintJitter: 0.14, + wrap: 0.45, // 0 hard terminator, 1 fully wrapped + rimPow: 3.2, + rimGain: 0.5, + sheenPow: 42, + sheenGain: 0.6, + coreGlow: 0.7, // how hot the recess is + flashGain: 1.4, + glow: 1, + + /* --- eight pickers, none derived from another --- */ + combA: '#e8d27a', + combB: '#c8a03c', + combC: '#8a6218', + combD: '#3a2a10', + coreColor: '#ffd24a', + rimColor: '#fff0b0', + sheenColor: '#fffbe8', + flashColor: '#fff6c8' + }; +} + +const LATTICE_DEFAULTS = latticeGrowthParams(); + +/** + * **What it draws.** A comb: hexagonal cells building outward from a seed, each + * one snapping to the lattice its own parent defined, each landing on its own + * staggered clock. + * + * **Draw calls.** One, however many cells. + * + * **What it reads from settings.** Nothing directly. `update(now, params)` + * resolves `p.key ?? default` for every key in `latticeGrowthParams()`. + * + * **The one rule for using it well.** *`refuse` and `drift` are one control, and + * they are the ability.* With `drift = 0` this is a hex grid and the refusal + * never fires, which is precisely the thing the roster says not to build. With + * `drift` up and `refuse` just under 1, children inherit a rotated lattice, + * cousins collide, the collisions are refused, and the structure grows an + * irregular perimeter and interior voids that no radius function would have + * produced. Start at `drift 0.16 / refuse 0.94` and pull them apart. + * + * --- + * + * ## Why it is not a hex grid filled by radius + * + * A global hexagonal grid has one lattice, and a cell either exists or it does + * not. Everything you can do with it is a mask, and every mask reads as a + * *shape someone cut out of a honeycomb*, because the honeycomb was there first + * and the shape came after. + * + * Here each cell carries **its own lattice frame**, inherited from its parent + * and turned by up to `drift` of a turn. A child buds one lattice unit away + * along one of its parent's six directions, so locally the packing is perfectly + * hexagonal — three cells in a row look built — but two branches that started at + * the seed and went round opposite sides of a void arrive back at each other + * out of register. When they do, the candidate cell lands within `refuse` of + * something already placed and is **thrown away**. + * + * That refusal is the whole design. It is what puts a seam where two growth + * fronts met, a hole where three did, and a perimeter that is jagged in a way + * that follows the history of the growth rather than a noise function. + * + * ## Cost of a regrow + * + * Refusal is a lookup in a fixed open-addressed spatial hash — a head array and + * a next array, both `Int32Array`s allocated once, so a regrow allocates + * nothing and touches nine buckets per candidate. The first version tested + * every placed cell and was O(n²); at 256 cells that was several milliseconds + * on every frame of a slider drag, which is exactly the interaction this has to + * survive. + * + * @example + * this.comb = new LatticeGrowth(this.group, { capacity: 220 }); + * // every frame + * this.comb.setPlacement(_anchor, _direction, null); + * this.comb.update(age, settings.hivecolumn); + */ +export class LatticeGrowth { + /** + * @param {THREE.Object3D} parent + * @param {object} [options] + * @param {number} [options.capacity=192] hard ceiling on cells + * @param {number} [options.sides=6] 6 is a hive + * @param {number} [options.wall=0.24] wall thickness, fraction of radius + * @param {number} [options.recess=0.62] recess depth, fraction of height + * @param {number} [options.renderOrder=2] + * @param {boolean} [options.castShadow=true] + */ + constructor(parent, options = {}) { + this.capacity = Math.max(1, Math.round(options.capacity ?? 192)); + + this.group = new Group(); + this.group.name = 'LatticeGrowth'; + this.group.matrixAutoUpdate = false; + parent?.add(this.group); + + const cell = buildCombCell(options.sides ?? 6, options.wall ?? 0.24, options.recess ?? 0.62); + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(cell.position, 3)); + geometry.setAttribute('normal', new BufferAttribute(cell.normal, 3)); + geometry.setAttribute('aKind', new BufferAttribute(cell.kind, 1)); + geometry.setIndex(new BufferAttribute(cell.index, 1)); + + this._aCell = new InstancedBufferAttribute(new Float32Array(this.capacity * 3), 3); + this._aInfo = new InstancedBufferAttribute(new Float32Array(this.capacity * 4), 4); + geometry.setAttribute('aCell', this._aCell); + geometry.setAttribute('aCellInfo', this._aInfo); + geometry.instanceCount = 0; + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.geometry = geometry; + + this.material = new ShaderMaterial({ + transparent: false, + depthWrite: true, + depthTest: true, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uAnchor: { value: new Vector3() }, + uAxisX: { value: new Vector3(1, 0, 0) }, + uAxisZ: { value: new Vector3(0, 0, 1) }, + uNow: { value: 0 }, + + uCellPitch: { value: 0.34 }, + uCellRadius: { value: 0.2 }, + uSizeJitter: { value: 0.12 }, + uHeightBase: { value: 0.22 }, + uHeightPeak: { value: 1.1 }, + uHeightFalloff: { value: 5 }, + uLayerHeight: { value: 0.26 }, + uBaseY: { value: 0 }, + uRise: { value: 0.35 }, + + uStagger: { value: 0.012 }, + uGrowTime: { value: 0.22 }, + uOvershoot: { value: 0.28 }, + uRetract: { value: 1 }, + + uCombA: { value: new Color('#e8d27a') }, + uCombB: { value: new Color('#c8a03c') }, + uCombC: { value: new Color('#8a6218') }, + uCombD: { value: new Color('#3a2a10') }, + uCoreColor: { value: new Color('#ffd24a') }, + uRimColor: { value: new Color('#fff0b0') }, + uSheenColor: { value: new Color('#fffbe8') }, + uFlashColor: { value: new Color('#fff6c8') }, + + uTintRadius: { value: 7 }, + uTintJitter: { value: 0.14 }, + uWrap: { value: 0.45 }, + uRimPow: { value: 3.2 }, + uRimGain: { value: 0.5 }, + uSheenPow: { value: 42 }, + uSheenGain: { value: 0.6 }, + uCoreGlow: { value: 0.7 }, + uFlashGain: { value: 1.4 }, + uGlow: { value: 1 } + }), + vertexShader: LATTICE_VERTEX, + fragmentShader: LATTICE_FRAGMENT + }); + + this.mesh = new Mesh(geometry, this.material); + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(LAYER.WORLD); + this.mesh.renderOrder = options.renderOrder ?? 2; + this.mesh.castShadow = options.castShadow ?? true; + this.mesh.receiveShadow = options.receiveShadow ?? true; + this.group.add(this.mesh); + + /* --- the structure, all unitless ---------------------------------- */ + this._x = new Float32Array(this.capacity); + this._z = new Float32Array(this.capacity); + this._layer = new Int32Array(this.capacity); + this._turn = new Float32Array(this.capacity); + this._rad = new Float32Array(this.capacity); + /** Spatial hash for the refusal test: a head list and a next list. */ + this._head = new Int32Array(LATTICE_BUCKETS); + this._next = new Int32Array(this.capacity); + + this.cells = 0; + /** Last structure the growth ran against. Compared field by field. */ + this._structure = { cells: -1, seed: 0, drift: -1, refuse: -1, climb: -1, outward: -1, layers: -1 }; + this._p = latticeGrowthParams(); + } + + /** One, however many cells. */ + get drawCalls() { + return 1; + } + + /** Cells that survived the refusal. → `Ability#instanceCount`. */ + get count() { + return this.cells; + } + + /** Hide the comb. Leaves the instance reusable — the pooling contract. */ + reset() { + this.geometry.instanceCount = 0; + this.mesh.visible = false; + } + + /** + * Where the comb sits and which way its lattice's x axis points. + * + * @param {THREE.Vector3} anchor the seed cell, in metres + * @param {THREE.Vector3} [forward] the lattice's second axis; the first is + * derived from it, so a cast direction is the natural thing to pass + */ + setPlacement(anchor, forward) { + const u = this.material.uniforms; + u.uAnchor.value.copy(anchor); + _v0.copy(forward ?? _v1.set(0, 0, 1)); + _v0.y = 0; + if (_v0.lengthSq() < 1e-8) _v0.set(0, 0, 1); + _v0.normalize(); + u.uAxisZ.value.copy(_v0); + u.uAxisX.value.set(_v0.z, 0, -_v0.x); + return this; + } + + _bucket(ix, iz, layer) { + return ((ix * 73856093) ^ (iz * 19349663) ^ (layer * 83492791)) >>> 0 & (LATTICE_BUCKETS - 1); + } + + /** True when a candidate lands on top of something already placed. */ + _refused(x, z, layer, refuse) { + const r2 = refuse * refuse; + const cx = Math.floor(x); + const cz = Math.floor(z); + for (let dx = -1; dx <= 1; dx++) { + for (let dz = -1; dz <= 1; dz++) { + let i = this._head[this._bucket(cx + dx, cz + dz, layer)]; + while (i >= 0) { + if (this._layer[i] === layer) { + const ex = this._x[i] - x; + const ez = this._z[i] - z; + if (ex * ex + ez * ez < r2) return true; + } + i = this._next[i]; + } + } + } + return false; + } + + /** + * Run the growth. Writes unitless lattice coordinates and nothing else. + * + * Called from `update()` when the structure hash changes. Allocation-free: + * every array it writes was sized at construction. + */ + _grow(count, seed, drift, refuse, climb, outward, layers) { + this._head.fill(-1); + + const put = (x, z, layer, turn, n) => { + this._x[n] = x; + this._z[n] = z; + this._layer[n] = layer; + this._turn[n] = turn; + this._rad[n] = Math.hypot(x, z); + const b = this._bucket(Math.floor(x), Math.floor(z), layer); + this._next[n] = this._head[b]; + this._head[b] = n; + }; + + put(0, 0, 0, hash11(seed * 3.7) * drift, 0); + let n = 1; + + /* + * A breadth-first frontier, and it has to be breadth-first. + * + * The first version took the parent as `cursor % n` with `cursor` advancing + * once per attempt. That reads like a sweep and is not one: `n` grows on + * every success, so `cursor` and `n` advance in lockstep and the parent is + * always the cell placed last. What grows is a single chain — a hundred and + * sixty cells laid end to end over a hundred and twenty lattice units, one + * cell wide. It looked like a worm. + * + * A head pointer that advances only when its parent is exhausted keeps the + * frontier a ring, which is what a colony builds. `BRANCH` children per + * parent before moving on: at 1 the comb is a spiral, at 6 it is a disc with + * no history in it, and 3 leaves the lobes. + */ + const BRANCH = 3; + let head = 0; + let budded = 0; + const budget = count * 16; + for (let attempt = 0; attempt < budget && n < count; attempt++) { + // The frontier can run out before the count is met when refusals are + // dense; sweeping back to the start lets the interior try again rather + // than stopping short of the requested cells. + if (head >= n) { + head = 0; + budded = 0; + } + const parent = head; + + const px = this._x[parent]; + const pz = this._z[parent]; + const pl = this._layer[parent]; + const pt = this._turn[parent]; + const plen = Math.hypot(px, pz); + + let best = -1e9; + let bx = 0; + let bz = 0; + let bl = 0; + let bt = 0; + let found = false; + + for (let k = 0; k < 7; k++) { + let cx; + let cz; + let cl; + let ct; + let score; + if (k === 6) { + if (climb <= 0 || pl + 1 >= layers) continue; + cx = px; + cz = pz; + cl = pl + 1; + ct = pt; + // Climbing competes with spreading rather than pre-empting it, so a + // blocked front goes up on its own instead of needing a rule for it. + score = hash11(seed + parent * 5.13 + 77.1) * 0.5 + climb * 2 - 1; + } else { + // The child's frame is the PARENT's frame, turned. Not the world's — + // there is no world lattice here, and that is the entire trick. + const turn = pt + (hash11(seed + n * 7.13 + k * 2.71) - 0.5) * drift; + const a = (k / 6 + pt) * TAU; + cx = px + Math.cos(a); + cz = pz + Math.sin(a); + cl = pl; + ct = turn; + const radial = Math.hypot(cx, cz) - plen; + score = hash11(seed + parent * 13.77 + k * 4.31) + outward * radial; + } + if (score <= best) continue; + if (this._refused(cx, cz, cl, refuse)) continue; + best = score; + bx = cx; + bz = cz; + bl = cl; + bt = ct; + found = true; + } + + if (!found) { + // Every direction refused: this cell is enclosed and will never bud. + head++; + budded = 0; + continue; + } + put(bx, bz, bl, bt, n); + n++; + budded++; + if (budded >= BRANCH) { + head++; + budded = 0; + } + } + + this.cells = n; + + const cellArr = this._aCell.array; + const infoArr = this._aInfo.array; + for (let i = 0; i < n; i++) { + cellArr[i * 3] = this._x[i]; + cellArr[i * 3 + 1] = this._z[i]; + cellArr[i * 3 + 2] = this._layer[i]; + infoArr[i * 4] = this._turn[i]; + infoArr[i * 4 + 1] = i; // order index — the stagger multiplies it + infoArr[i * 4 + 2] = hash11(seed + i * 1.913 + 0.37); + infoArr[i * 4 + 3] = this._rad[i]; + } + this._aCell.needsUpdate = true; + this._aInfo.needsUpdate = true; + this.geometry.instanceCount = n; + } + + /** + * Push the live params, regrowing first if the structure changed. + * + * @param {number} now the ability's `age` in seconds — the stagger is + * measured against it, so it must be the same clock the ability uses. + * @param {object} params live block; keys from `latticeGrowthParams()`. + */ + update(now, params) { + const p = this._p; + const src = params ?? LATTICE_DEFAULTS; + for (const key in LATTICE_DEFAULTS) { + const value = src[key]; + p[key] = value === undefined ? LATTICE_DEFAULTS[key] : value; + } + + const count = clamp(Math.round(p.cells), 1, this.capacity); + const layers = Math.max(1, Math.round(p.layers)); + // Seven compared scalars rather than a joined string. The string version + // allocated once per frame for the lifetime of every cast, which is the + // allocation invariant I3 forbids and the kind that never shows up in a + // profile because it is only forty bytes — it shows up in the GC sawtooth. + const s = this._structure; + if ( + s.cells !== count || + s.seed !== p.seed || + s.drift !== p.drift || + s.refuse !== p.refuse || + s.climb !== p.climb || + s.outward !== p.outward || + s.layers !== layers + ) { + s.cells = count; + s.seed = p.seed; + s.drift = p.drift; + s.refuse = p.refuse; + s.climb = p.climb; + s.outward = p.outward; + s.layers = layers; + this._grow( + count, + p.seed, + Math.max(p.drift, 0), + Math.max(p.refuse, 0.01), + saturate(p.climb), + p.outward, + layers + ); + } + + const u = this.material.uniforms; + u.uNow.value = now; + u.uCellPitch.value = p.pitch; + u.uCellRadius.value = p.cellRadius; + u.uSizeJitter.value = p.sizeJitter; + u.uHeightBase.value = p.heightBase; + u.uHeightPeak.value = p.heightPeak; + u.uHeightFalloff.value = p.heightFalloff; + u.uLayerHeight.value = p.layerHeight; + u.uBaseY.value = p.baseY; + u.uRise.value = p.rise; + u.uStagger.value = p.stagger; + u.uGrowTime.value = p.growTime; + u.uOvershoot.value = p.overshoot; + u.uRetract.value = p.retract; + u.uTintRadius.value = p.tintRadius; + u.uTintJitter.value = p.tintJitter; + u.uWrap.value = p.wrap; + u.uRimPow.value = p.rimPow; + u.uRimGain.value = p.rimGain; + u.uSheenPow.value = p.sheenPow; + u.uSheenGain.value = p.sheenGain; + u.uCoreGlow.value = p.coreGlow; + u.uFlashGain.value = p.flashGain; + u.uGlow.value = p.glow; + copyPicker(u.uCombA.value, p.combA); + copyPicker(u.uCombB.value, p.combB); + copyPicker(u.uCombC.value, p.combC); + copyPicker(u.uCombD.value, p.combD); + copyPicker(u.uCoreColor.value, p.coreColor); + copyPicker(u.uRimColor.value, p.rimColor); + copyPicker(u.uSheenColor.value, p.sheenColor); + copyPicker(u.uFlashColor.value, p.flashColor); + + this.mesh.visible = this.cells > 0 && p.retract > 0 && p.cellRadius > 0; + } + + /** + * Where cell `index` stands, in world metres — for an emitter, a light or a + * decal. Reads the same floats the vertex shader reads, so it lands *on* the + * cell rather than near it. + * + * @param {number} index + * @param {object} p the same live block handed to `update()` + * @param {THREE.Vector3} out + * @param {number} [height] 0 the cell's base, 1 its rim + */ + cellPoint(index, p, out, height = 1) { + const i = clamp(Math.round(index), 0, Math.max(0, this.cells - 1)); + const u = this.material.uniforms; + const pitch = p.pitch ?? LATTICE_DEFAULTS.pitch; + const far = saturate(this._rad[i] / Math.max(p.heightFalloff ?? LATTICE_DEFAULTS.heightFalloff, 0.01)); + const h = + (p.heightPeak ?? LATTICE_DEFAULTS.heightPeak) * (1 - far) + + (p.heightBase ?? LATTICE_DEFAULTS.heightBase) * far; + out + .copy(u.uAnchor.value) + .addScaledVector(u.uAxisX.value, this._x[i] * pitch) + .addScaledVector(u.uAxisZ.value, this._z[i] * pitch); + out.y = + (p.baseY ?? LATTICE_DEFAULTS.baseY) + + this._layer[i] * (p.layerHeight ?? LATTICE_DEFAULTS.layerHeight) + + h * height; + return out; + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + this.group.parent?.remove(this.group); + } +} + +/* ====================================================================== */ +/* §4 · Shell tessellation */ +/* ====================================================================== */ + +/** + * Vertices a plate's polygon may have. + * + * A Voronoi cell over a Fibonacci hemisphere is almost always a hexagon and + * occasionally a pentagon or a heptagon; twelve is four standard deviations of + * headroom. It matters because the vertex layout is **fixed** — every plate + * occupies the same number of slots whatever its real side count, unused slots + * collapse onto the last real vertex and draw as zero-area triangles, and that + * is what lets the index buffer be written once at construction and never + * touched again. + */ +const MAX_PLATE_SIDES = 12; + +/** Vertices per plate: two fan centres, two rims, four per side wall. */ +const PLATE_VERTS = 2 + 6 * MAX_PLATE_SIDES; + +/** Indices per plate: two fans of M triangles, M quads of two. */ +const PLATE_INDICES = 12 * MAX_PLATE_SIDES; + +/** Tangent radius of the polygon each cell starts as, before any clipping. */ +const PLATE_SEED_RADIUS = 2.2; + +const SHELL_VERTEX = /* glsl */ ` + #define PI 3.141592653589793 + + uniform vec3 uAnchor; // metres + uniform vec3 uAxisX; // unit + uniform vec3 uAxisZ; // unit + uniform float uNow; // seconds since the cast began + + uniform vec3 uRadius; // metres — three, so the dome can be an ellipsoid + uniform float uThickness; // metres a plate is extruded inward + uniform float uSeam; // 0..1 the rim is drawn in toward the plate centre + uniform float uFlyOut; // metres a plate starts out from its locked place + uniform float uTumbleSpin; // radians it starts rotated about its own axis + uniform float uTumbleSwing; // radians it starts tipped off the dome + uniform float uStagger; // seconds between one plate locking and the next + uniform float uLockTime; // seconds one plate takes to arrive + uniform float uOrderScatter; // 0 they lock from the ground up, 1 at random + uniform float uRetract; // 1 the dome is closed, 0 it is gone + + attribute vec3 aDir; // unit: this vertex on the sphere + attribute vec3 aCentre; // unit: the plate's own centre + attribute vec3 aNrm; // unit: the face normal, in the sphere's frame + attribute vec3 aFace; // x side 0 outer / 1 inner, y rim, z side wall + attribute vec3 aPlate; // orderY, orderHash, dice + + varying vec3 vNormal; + varying vec3 vWorld; + varying float vWall; + varying float vDice; + varying float vHeight; + varying float vLock; + + ${noiseGLSL} + + /** Rotate v about a unit axis. Rodrigues, three terms, no matrix. */ + vec3 shellRotate(vec3 v, vec3 axis, float a) { + float c = cos(a); + float s = sin(a); + return v * c + cross(axis, v) * s + axis * (dot(axis, v) * (1.0 - c)); + } + + void main() { + float dice = aPlate.z; + float order = mix(aPlate.x, aPlate.y, clamp(uOrderScatter, 0.0, 1.0)); + float e = clamp((uNow - order * uStagger) / max(uLockTime, 1e-3), 0.0, 1.0); + float ease = 1.0 - pow(1.0 - e, 3.0); + // Everything the fly-in does is multiplied by k, and k is exactly zero when + // the plate has landed. That is the guarantee the whole module exists for: + // no easing curve, no overshoot and no tumble can leave a plate a + // millimetre off the tessellation, because at e = 1 none of them apply. + float k = 1.0 - ease; + + // The seam is drawn on the UNIT sphere before any of the metres arrive, so + // two neighbours inset by the same fraction of their own cell and the gap + // between them stays even all the way round. + vec3 dir = normalize(mix(aDir, aCentre, clamp(uSeam, 0.0, 1.0))); + vec3 nrm = aNrm; + vec3 centre = aCentre; + + vec3 tangent = normalize(cross(aCentre, vec3(0.0, 1.0, 0.0)) + vec3(1e-4, 0.0, 1e-4)); + float spin = uTumbleSpin * k * ((hash11(dice * 17.3) - 0.5) * 2.0); + float swing = uTumbleSwing * k * ((dice - 0.5) * 2.0); + + dir = shellRotate(dir, aCentre, spin); + nrm = shellRotate(nrm, aCentre, spin); + dir = shellRotate(dir, tangent, swing); + nrm = shellRotate(nrm, tangent, swing); + centre = shellRotate(centre, tangent, swing); + + vec3 rad = max(uRadius - aFace.x * uThickness, vec3(0.01)); + vec3 local = dir * rad + centre * (uFlyOut * k); + local *= clamp(uRetract, 0.0, 1.0); + + // An ellipsoid's normal is not its radial direction. One divide fixes it, + // and without it a squashed dome lights as though it were a sphere. + vec3 fixedNrm = normalize(nrm / rad); + + vec3 world = uAnchor + + uAxisX * local.x + + vec3(0.0, 1.0, 0.0) * local.y + + uAxisZ * local.z; + + vNormal = normalize(uAxisX * fixedNrm.x + vec3(0.0, 1.0, 0.0) * fixedNrm.y + uAxisZ * fixedNrm.z); + vWorld = world; + vWall = aFace.z; + vDice = dice; + vHeight = clamp(aCentre.y, 0.0, 1.0); + vLock = ease; + + gl_Position = projectionMatrix * modelViewMatrix * vec4(world, 1.0); + } +`; + +const SHELL_FRAGMENT = /* glsl */ ` + uniform vec3 uLightDir; + uniform float uGlobalGlow; + + uniform vec3 uPlateA; + uniform vec3 uPlateB; + uniform vec3 uPlateC; + uniform vec3 uPlateD; + uniform vec3 uRimColor; + uniform vec3 uSheenColor; + uniform vec3 uSeamColor; + uniform vec3 uArriveColor; + + uniform float uTintHeight; // how much of the gradient the dome's height walks + uniform float uTintJitter; + uniform float uWrap; + uniform float uRimPow; + uniform float uRimGain; + uniform float uSheenPow; + uniform float uSheenGain; + uniform float uSeamGlow; // how hot the side walls are + uniform float uArriveGain; // flash as a plate locks + uniform float uGlow; + + varying vec3 vNormal; + varying vec3 vWorld; + varying float vWall; + varying float vDice; + varying float vHeight; + varying float vLock; + + ${commonGLSL} + ${COLONY_CHITIN_GLSL} + + void main() { + if (vLock < 0.001) discard; + + vec3 n = normalize(vNormal); + vec3 v = normalize(cameraPosition - vWorld); + vec3 l = normalize(uLightDir); + + float t = clamp(vHeight * uTintHeight + (vDice - 0.5) * 2.0 * uTintJitter, 0.0, 1.0); + vec3 body = gradient4(uPlateA, uPlateB, uPlateC, uPlateD, t); + + vec3 colour = chitin(n, v, l, body, uRimColor, uSheenColor, + uWrap, uRimPow, uRimGain, uSheenPow, uSheenGain); + + // The side walls are the seams, and the seams are the point: they are the + // only evidence that the dome is made of separate plates at all once it has + // closed. Lighting them from inside is cheaper and reads better than trying + // to find the seam in screen space. + colour += uSeamColor * (vWall * uSeamGlow); + colour += uArriveColor * (pow(1.0 - vLock, 3.0) * uArriveGain); + colour *= uGlow * uGlobalGlow; + + gl_FragColor = vec4(colour, 1.0); + } +`; + +/** + * Every key `PlateShell.update()` understands, with its default and its unit. + * + * The **tessellation** group is structural: `sites`, `seed` and `jitter` are + * what the Voronoi is computed from, and changing any of them re-tessellates + * inside `update()` on the frame it changes. Everything else is a uniform. + */ +export function plateShellParams() { + return { + /* --- the tessellation: changing any of these re-tessellates --- */ + sites: 46, // plates, whole number, at least 12 + seed: 0, + jitter: 0.35, // unitless wobble on the Fibonacci sites — 0 is too regular + + /* --- the metres --- */ + radiusX: 3.2, // metres + radiusY: 2.4, // metres — a dome is not a hemisphere, it is squatter + radiusZ: 3.2, // metres + thickness: 0.12, // metres a plate is extruded inward + seam: 0.045, // 0..1 the rim is drawn in toward the plate centre + flyOut: 2.6, // metres a plate starts out from its locked place + tumbleSpin: 1.9, // radians it starts rotated about its own axis + tumbleSwing: 1.1, // radians it starts tipped off the dome + + /* --- the clock --- */ + stagger: 0.013, // seconds between one plate locking and the next + lockTime: 0.34, // seconds one plate takes to arrive + orderScatter: 0.3, // 0 they lock from the ground up, 1 at random + retract: 1, // 1 the dome is closed, 0 it is gone + + /* --- the look --- */ + tintHeight: 0.85, // how much of the gradient the dome's height walks + tintJitter: 0.16, + wrap: 0.35, + rimPow: 3, + rimGain: 0.65, + sheenPow: 56, + sheenGain: 0.9, + seamGlow: 0.35, // how hot the side walls are + arriveGain: 1.6, // flash as a plate locks + glow: 1, + + /* --- eight pickers, none derived from another --- */ + plateA: '#5a4a22', + plateB: '#8a7028', + plateC: '#c8a83c', + plateD: '#e8d888', + rimColor: '#fff2c0', + sheenColor: '#ffffff', + seamColor: '#ffb43a', + arriveColor: '#fff0b8' + }; +} + +const SHELL_DEFAULTS = plateShellParams(); + +/** + * **What it draws.** A dome made of interlocking chitin plates: a Voronoi + * tessellation of a hemisphere, each cell a solid plate with a rim and a side + * wall, each flying in from outside and locking against its neighbours. + * + * **Draw calls.** One, however many plates. + * + * **What it reads from settings.** Nothing directly. `update(now, params)` + * resolves `p.key ?? default` for every key in `plateShellParams()`. + * + * **The one rule for using it well.** *Let it finish, and keep `seam` small.* + * Everything this module is worth is in the last tenth of a second of the + * animation, when the last plates drop into holes that are exactly their own + * shape. A dome held at half-built, or opened up with a large `seam`, is a + * scatter of debris — and a scatter of debris is what `ShatterField` is for. + * + * --- + * + * ## Why the seams are exact + * + * Two points on a unit sphere are equidistant from a third exactly when that + * third point lies on the plane through the **origin** whose normal is their + * difference — the `|x|² ` terms cancel because both sites are unit vectors. So + * a spherical Voronoi cell is the intersection of half-spaces through the + * origin, and it can be built by ordinary polygon clipping: start each cell as + * a wide polygon in the tangent plane at its site, clip it by one plane per + * other site, and project what survives back onto the sphere. + * + * The exactness follows for free. The edge that cell `i` gets from the plane + * bisecting `i` and `j` is the *same plane* cell `j` gets from bisecting `j` and + * `i`; the endpoints of that edge are where a third bisector cuts it, and both + * cells cut it with the same third bisector. Adjacent plates therefore share + * their boundary vertices to the last bit of the arithmetic that produced them. + * The dome closes with no gaps and no overlaps because the two plates that meet + * at a seam are solving the same equation. + * + * That is why this is not "scatter some plates and hope". A rejection-sampled + * or noise-perturbed plate layout leaves slivers of daylight, and the eye finds + * every one of them the moment there is anything bright behind the dome. + * + * ## The equator is one more plane + * + * A hemisphere's rim needs no special case: clipping every cell additionally by + * `y ≥ 0` — a plane through the origin like all the others — cuts the dome off + * flat at the ground with the same exactness, so the plates that meet the floor + * meet it in a straight line rather than a fringe. + * + * ## Cost + * + * `tessellate()` is O(n²) half-plane clips — 46 sites is about two thousand, + * around a fifth of a millisecond, and it runs only when `sites`, `seed` or + * `jitter` change. It allocates nothing: the clipper ping-pongs between two + * scratch arrays sized at construction, as does everything it writes. + * + * @example + * this.shell = new PlateShell(this.group, { capacity: 64 }); + * // every frame + * this.shell.setPlacement(_anchor, _direction); + * this.shell.update(age, settings.carapace); + */ +export class PlateShell { + /** + * @param {THREE.Object3D} parent + * @param {object} [options] + * @param {number} [options.capacity=64] hard ceiling on plates + * @param {number} [options.renderOrder=2] + * @param {boolean} [options.castShadow=true] + */ + constructor(parent, options = {}) { + this.capacity = clamp(Math.round(options.capacity ?? 64), 12, 400); + + this.group = new Group(); + this.group.name = 'PlateShell'; + this.group.matrixAutoUpdate = false; + parent?.add(this.group); + + const verts = this.capacity * PLATE_VERTS; + this._aDir = new BufferAttribute(new Float32Array(verts * 3), 3); + this._aCentre = new BufferAttribute(new Float32Array(verts * 3), 3); + this._aNrm = new BufferAttribute(new Float32Array(verts * 3), 3); + this._aFaceAttr = new BufferAttribute(new Float32Array(verts * 3), 3); + this._aPlate = new BufferAttribute(new Float32Array(verts * 3), 3); + + // `position` is never read by the shader — every vertex is placed from + // aDir — but three.js wants the attribute to exist, and a zero-filled one + // costs a buffer nobody samples rather than a special case in the shader. + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(new Float32Array(verts * 3), 3)); + geometry.setAttribute('aDir', this._aDir); + geometry.setAttribute('aCentre', this._aCentre); + geometry.setAttribute('aNrm', this._aNrm); + geometry.setAttribute('aFace', this._aFaceAttr); + geometry.setAttribute('aPlate', this._aPlate); + + /* --- the index buffer, written once and never again --------------- */ + const index = new Uint16Array(this.capacity * PLATE_INDICES); + const M = MAX_PLATE_SIDES; + for (let p = 0; p < this.capacity; p++) { + const base = p * PLATE_VERTS; + const outerCentre = base; + const outerRim = base + 1; + const innerCentre = base + 1 + M; + const innerRim = base + 2 + M; + const wall = base + 2 + 2 * M; + let o = p * PLATE_INDICES; + for (let j = 0; j < M; j++) { + const jn = (j + 1) % M; + index[o++] = outerCentre; + index[o++] = outerRim + j; + index[o++] = outerRim + jn; + index[o++] = innerCentre; + index[o++] = innerRim + jn; + index[o++] = innerRim + j; + const w = wall + j * 4; + index[o++] = w; + index[o++] = w + 1; + index[o++] = w + 2; + index[o++] = w; + index[o++] = w + 2; + index[o++] = w + 3; + } + } + geometry.setIndex(new BufferAttribute(index, 1)); + geometry.setDrawRange(0, 0); + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.geometry = geometry; + + this.material = new ShaderMaterial({ + transparent: false, + depthWrite: true, + depthTest: true, + side: FrontSide, + toneMapped: false, + uniforms: sharedUniforms({ + uAnchor: { value: new Vector3() }, + uAxisX: { value: new Vector3(1, 0, 0) }, + uAxisZ: { value: new Vector3(0, 0, 1) }, + uNow: { value: 0 }, + uRadius: { value: new Vector3(3.2, 2.4, 3.2) }, + uThickness: { value: 0.12 }, + uSeam: { value: 0.045 }, + uFlyOut: { value: 2.6 }, + uTumbleSpin: { value: 1.9 }, + uTumbleSwing: { value: 1.1 }, + uStagger: { value: 0.013 }, + uLockTime: { value: 0.34 }, + uOrderScatter: { value: 0.3 }, + uRetract: { value: 1 }, + uPlateA: { value: new Color('#5a4a22') }, + uPlateB: { value: new Color('#8a7028') }, + uPlateC: { value: new Color('#c8a83c') }, + uPlateD: { value: new Color('#e8d888') }, + uRimColor: { value: new Color('#fff2c0') }, + uSheenColor: { value: new Color('#ffffff') }, + uSeamColor: { value: new Color('#ffb43a') }, + uArriveColor: { value: new Color('#fff0b8') }, + uTintHeight: { value: 0.85 }, + uTintJitter: { value: 0.16 }, + uWrap: { value: 0.35 }, + uRimPow: { value: 3 }, + uRimGain: { value: 0.65 }, + uSheenPow: { value: 56 }, + uSheenGain: { value: 0.9 }, + uSeamGlow: { value: 0.35 }, + uArriveGain: { value: 1.6 }, + uGlow: { value: 1 } + }), + vertexShader: SHELL_VERTEX, + fragmentShader: SHELL_FRAGMENT + }); + + this.mesh = new Mesh(geometry, this.material); + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(LAYER.WORLD); + this.mesh.renderOrder = options.renderOrder ?? 2; + this.mesh.castShadow = options.castShadow ?? true; + this.mesh.receiveShadow = options.receiveShadow ?? false; + this.group.add(this.mesh); + + /* --- tessellation scratch, sized once ----------------------------- */ + const SLOTS = MAX_PLATE_SIDES * 4; + this._sites = new Float32Array(this.capacity * 3); + this._centres = new Float32Array(this.capacity * 3); + this._poly = new Float32Array(this.capacity * MAX_PLATE_SIDES * 3); + this._polyCount = new Int32Array(this.capacity); + this._clipA = new Float32Array(SLOTS * 3); + this._clipB = new Float32Array(SLOTS * 3); + this._slots = SLOTS; + + this.plates = 0; + /** Last tessellation the clipper ran. Compared field by field. */ + this._structure = { sites: -1, seed: 0, jitter: -1 }; + this._p = plateShellParams(); + } + + /** One, however many plates. */ + get drawCalls() { + return 1; + } + + /** Plates in the tessellation. → `Ability#instanceCount`. */ + get count() { + return this.plates; + } + + /** Hide the dome. Leaves the instance reusable — the pooling contract. */ + reset() { + this.geometry.setDrawRange(0, 0); + this.mesh.visible = false; + } + + /** + * Where the dome stands and which way its lattice's z axis points. + * The dome's base sits on `anchor.y`. + */ + setPlacement(anchor, forward) { + const u = this.material.uniforms; + u.uAnchor.value.copy(anchor); + _v0.copy(forward ?? _v1.set(0, 0, 1)); + _v0.y = 0; + if (_v0.lengthSq() < 1e-8) _v0.set(0, 0, 1); + _v0.normalize(); + u.uAxisZ.value.copy(_v0); + u.uAxisX.value.set(_v0.z, 0, -_v0.x); + return this; + } + + /** + * Clip a polygon by the half-space `dot(x, m) <= 0`, where the plane passes + * through the origin. Sutherland–Hodgman, in 3D, allocation-free. + * + * @returns the number of vertices written to `dst` + */ + _clip(src, n, dst, mx, my, mz) { + let out = 0; + for (let a = 0; a < n; a++) { + const b = (a + 1) % n; + const ax = src[a * 3]; + const ay = src[a * 3 + 1]; + const az = src[a * 3 + 2]; + const bx = src[b * 3]; + const by = src[b * 3 + 1]; + const bz = src[b * 3 + 2]; + const da = ax * mx + ay * my + az * mz; + const db = bx * mx + by * my + bz * mz; + const keepA = da <= 0; + if (keepA && out < this._slots) { + dst[out * 3] = ax; + dst[out * 3 + 1] = ay; + dst[out * 3 + 2] = az; + out++; + } + if (keepA !== db <= 0 && out < this._slots) { + // The crossing point. Both cells sharing this plane compute it from the + // same two endpoints, which is the whole basis of the exactness claim. + const t = da / (da - db); + dst[out * 3] = ax + (bx - ax) * t; + dst[out * 3 + 1] = ay + (by - ay) * t; + dst[out * 3 + 2] = az + (bz - az) * t; + out++; + } + } + return out; + } + + /** + * Build the tessellation and write the whole vertex buffer. + * + * Called from `update()` when the structure hash changes. + * + * @param {number} sites plates to place, >= 12 + * @param {number} seed + * @param {number} jitter unitless wobble on the Fibonacci lattice + */ + tessellate(sites, seed, jitter) { + const n = clamp(Math.round(sites), 12, this.capacity); + const S = this._sites; + + /* --- the sites: a Fibonacci hemisphere ---------------------------- */ + // Even by construction and rejection-free: the i-th of n points has + // height (i + 0.5) / n, which distributes area uniformly because a sphere's + // area is uniform in height. A jittered *lattice* rather than a random + // scatter, because random points clump, and a clumped Voronoi gives one + // plate the size of four and reads as damage rather than as armour. + for (let i = 0; i < n; i++) { + const y = (i + 0.5) / n; + const r = Math.sqrt(Math.max(1 - y * y, 0)); + const phi = i * GOLDEN_ANGLE + seed; + let x = Math.cos(phi) * r; + let z = Math.sin(phi) * r; + let yy = y; + if (jitter > 0) { + const j = jitter / Math.sqrt(n); + x += (hash11(seed + i * 3.71) - 0.5) * 2 * j; + yy += (hash11(seed + i * 7.13 + 5.1) - 0.5) * 2 * j; + z += (hash11(seed + i * 11.9 + 9.7) - 0.5) * 2 * j; + // A site that jittered under the equator would own a cell the clip + // throws away entirely, so it is folded back up rather than dropped. + yy = Math.abs(yy); + } + const len = Math.hypot(x, yy, z) || 1; + S[i * 3] = x / len; + S[i * 3 + 1] = yy / len; + S[i * 3 + 2] = z / len; + } + + /* --- one cell at a time ------------------------------------------- */ + let live = 0; + for (let i = 0; i < n; i++) { + const sx = S[i * 3]; + const sy = S[i * 3 + 1]; + const sz = S[i * 3 + 2]; + + // A tangent basis at the site. The seed polygon is an octagon out at + // PLATE_SEED_RADIUS, which is several times any real cell's reach, so it + // never contributes an edge of its own. + _v0.set(sx, sy, sz); + _v1.set(0, 1, 0); + if (Math.abs(sy) > 0.9) _v1.set(1, 0, 0); + _v2.crossVectors(_v1, _v0).normalize(); + _v3.crossVectors(_v0, _v2).normalize(); + + let src = this._clipA; + let dst = this._clipB; + let count = 8; + for (let k = 0; k < 8; k++) { + const a = (k / 8) * TAU; + const cx = sx + (_v2.x * Math.cos(a) + _v3.x * Math.sin(a)) * PLATE_SEED_RADIUS; + const cy = sy + (_v2.y * Math.cos(a) + _v3.y * Math.sin(a)) * PLATE_SEED_RADIUS; + const cz = sz + (_v2.z * Math.cos(a) + _v3.z * Math.sin(a)) * PLATE_SEED_RADIUS; + src[k * 3] = cx; + src[k * 3 + 1] = cy; + src[k * 3 + 2] = cz; + } + + for (let j = 0; j < n && count >= 3; j++) { + if (j === i) continue; + count = this._clip(src, count, dst, S[j * 3] - sx, S[j * 3 + 1] - sy, S[j * 3 + 2] - sz); + const swap = src; + src = dst; + dst = swap; + } + // The equator: exactly one more plane through the origin, so the rim of + // the dome is as exact as every other seam. + if (count >= 3) { + count = this._clip(src, count, dst, 0, -1, 0); + const swap = src; + src = dst; + dst = swap; + } + + if (count < 3) { + this._polyCount[live] = 0; + continue; + } + + const k = Math.min(count, MAX_PLATE_SIDES); + let cx = 0; + let cy = 0; + let cz = 0; + for (let v = 0; v < k; v++) { + const px = src[v * 3]; + const py = src[v * 3 + 1]; + const pz = src[v * 3 + 2]; + const len = Math.hypot(px, py, pz) || 1; + const base = (live * MAX_PLATE_SIDES + v) * 3; + this._poly[base] = px / len; + this._poly[base + 1] = py / len; + this._poly[base + 2] = pz / len; + cx += px / len; + cy += py / len; + cz += pz / len; + } + const clen = Math.hypot(cx, cy, cz) || 1; + this._centres[live * 3] = cx / clen; + this._centres[live * 3 + 1] = cy / clen; + this._centres[live * 3 + 2] = cz / clen; + this._polyCount[live] = k; + live++; + } + + this.plates = live; + this._writeVertices(seed); + this.geometry.setDrawRange(0, live * PLATE_INDICES); + return live; + } + + /** Expand the polygons into the fixed vertex layout. */ + _writeVertices(seed) { + const M = MAX_PLATE_SIDES; + const dir = this._aDir.array; + const cen = this._aCentre.array; + const nrm = this._aNrm.array; + const face = this._aFaceAttr.array; + const plate = this._aPlate.array; + + for (let p = 0; p < this.plates; p++) { + const k = this._polyCount[p]; + const base = p * PLATE_VERTS; + const ccx = this._centres[p * 3]; + const ccy = this._centres[p * 3 + 1]; + const ccz = this._centres[p * 3 + 2]; + const dice = hash11(seed + p * 2.713 + 0.91); + // The base plates lock first: a dome that closes from the ground up looks + // like it is being built, and one that closes from the top down looks + // like it is being dropped on you. + const orderY = 1 - clamp(ccy, 0, 1); + const orderHash = hash11(seed + p * 5.317 + 13.3); + + const put = (slot, dx, dy, dz, nx, ny, nz, side, rim, wall) => { + const v = (base + slot) * 3; + dir[v] = dx; + dir[v + 1] = dy; + dir[v + 2] = dz; + cen[v] = ccx; + cen[v + 1] = ccy; + cen[v + 2] = ccz; + nrm[v] = nx; + nrm[v + 1] = ny; + nrm[v + 2] = nz; + face[v] = side; + face[v + 1] = rim; + face[v + 2] = wall; + plate[v] = orderY; + plate[v + 1] = orderHash; + plate[v + 2] = dice; + }; + + // Fan centres. The outer face's normal is the plate's own centre for + // every vertex on it, not the vertex's radial direction: a flat normal + // over a curved position is what makes the dome read as panels rather + // than as a smooth shell with lines drawn on it. + put(0, ccx, ccy, ccz, ccx, ccy, ccz, 0, 0, 0); + put(1 + M, ccx, ccy, ccz, -ccx, -ccy, -ccz, 1, 0, 0); + + for (let j = 0; j < M; j++) { + // Slots past the real side count collapse onto the last real vertex, + // so their triangles have zero area and cost a vertex shader each. + const a = Math.min(j, k - 1); + const ax = this._poly[(p * M + a) * 3]; + const ay = this._poly[(p * M + a) * 3 + 1]; + const az = this._poly[(p * M + a) * 3 + 2]; + put(1 + j, ax, ay, az, ccx, ccy, ccz, 0, 1, 0); + put(2 + M + j, ax, ay, az, -ccx, -ccy, -ccz, 1, 1, 0); + } + + for (let j = 0; j < M; j++) { + // Real edges are (j, j+1) with the last wrapping to 0; the padding + // slots point both ends at the last real vertex and vanish. + const j0 = Math.min(j, k - 1); + const j1 = j + 1 < k ? j + 1 : j + 1 === k ? 0 : j0; + const ax = this._poly[(p * M + j0) * 3]; + const ay = this._poly[(p * M + j0) * 3 + 1]; + const az = this._poly[(p * M + j0) * 3 + 2]; + const bx = this._poly[(p * M + j1) * 3]; + const by = this._poly[(p * M + j1) * 3 + 1]; + const bz = this._poly[(p * M + j1) * 3 + 2]; + + // Outward normal of the wall: perpendicular to the edge and to the + // plate's own axis, flipped to face away from the plate's centre. + _v0.set(bx - ax, by - ay, bz - az); + _v1.set(ccx, ccy, ccz); + _v2.crossVectors(_v0, _v1); + if (_v2.lengthSq() < 1e-12) _v2.set(ax - ccx, ay - ccy, az - ccz); + _v2.normalize(); + _v3.set((ax + bx) * 0.5 - ccx, (ay + by) * 0.5 - ccy, (az + bz) * 0.5 - ccz); + if (_v2.dot(_v3) < 0) _v2.multiplyScalar(-1); + + const w = 2 + 2 * M + j * 4; + put(w, ax, ay, az, _v2.x, _v2.y, _v2.z, 0, 1, 1); + put(w + 1, bx, by, bz, _v2.x, _v2.y, _v2.z, 0, 1, 1); + put(w + 2, bx, by, bz, _v2.x, _v2.y, _v2.z, 1, 1, 1); + put(w + 3, ax, ay, az, _v2.x, _v2.y, _v2.z, 1, 1, 1); + } + } + + this._aDir.needsUpdate = true; + this._aCentre.needsUpdate = true; + this._aNrm.needsUpdate = true; + this._aFaceAttr.needsUpdate = true; + this._aPlate.needsUpdate = true; + } + + /** + * Push the live params, re-tessellating first if the structure changed. + * + * @param {number} now the ability's `age` in seconds — the lock stagger is + * measured against it. + * @param {object} params live block; keys from `plateShellParams()`. + */ + update(now, params) { + const p = this._p; + const src = params ?? SHELL_DEFAULTS; + for (const key in SHELL_DEFAULTS) { + const value = src[key]; + p[key] = value === undefined ? SHELL_DEFAULTS[key] : value; + } + + const sites = clamp(Math.round(p.sites), 12, this.capacity); + // Three compared scalars, not a joined string — see LatticeGrowth#update(). + const s = this._structure; + if (s.sites !== sites || s.seed !== p.seed || s.jitter !== p.jitter) { + s.sites = sites; + s.seed = p.seed; + s.jitter = p.jitter; + this.tessellate(sites, p.seed, Math.max(p.jitter, 0)); + } + + const u = this.material.uniforms; + u.uNow.value = now; + u.uRadius.value.set(p.radiusX, p.radiusY, p.radiusZ); + u.uThickness.value = p.thickness; + u.uSeam.value = p.seam; + u.uFlyOut.value = p.flyOut; + u.uTumbleSpin.value = p.tumbleSpin; + u.uTumbleSwing.value = p.tumbleSwing; + u.uStagger.value = p.stagger; + u.uLockTime.value = p.lockTime; + u.uOrderScatter.value = p.orderScatter; + u.uRetract.value = p.retract; + u.uTintHeight.value = p.tintHeight; + u.uTintJitter.value = p.tintJitter; + u.uWrap.value = p.wrap; + u.uRimPow.value = p.rimPow; + u.uRimGain.value = p.rimGain; + u.uSheenPow.value = p.sheenPow; + u.uSheenGain.value = p.sheenGain; + u.uSeamGlow.value = p.seamGlow; + u.uArriveGain.value = p.arriveGain; + u.uGlow.value = p.glow; + copyPicker(u.uPlateA.value, p.plateA); + copyPicker(u.uPlateB.value, p.plateB); + copyPicker(u.uPlateC.value, p.plateC); + copyPicker(u.uPlateD.value, p.plateD); + copyPicker(u.uRimColor.value, p.rimColor); + copyPicker(u.uSheenColor.value, p.sheenColor); + copyPicker(u.uSeamColor.value, p.seamColor); + copyPicker(u.uArriveColor.value, p.arriveColor); + + this.mesh.visible = this.plates > 0 && p.retract > 0; + } + + /** + * 0..1 of the dome that has locked, given the live clock. What an ability + * polls to know when to fire the impact. + */ + progress(now, p) { + const stagger = p.stagger ?? SHELL_DEFAULTS.stagger; + const lock = Math.max(p.lockTime ?? SHELL_DEFAULTS.lockTime, 1e-3); + const span = Math.max(this.plates - 1, 1) * stagger + lock; + return saturate(now / span); + } + + /** + * The locked world position of plate `index`'s centre — for a light, an + * emitter or a decal. The fly-in is deliberately left out: this is where the + * plate is *going*, which is what a light wants to be at. + */ + plateCentre(index, p, out) { + const i = clamp(Math.round(index), 0, Math.max(0, this.plates - 1)); + const u = this.material.uniforms; + const dx = this._centres[i * 3]; + const dy = this._centres[i * 3 + 1]; + const dz = this._centres[i * 3 + 2]; + const rx = (p.radiusX ?? SHELL_DEFAULTS.radiusX) * dx; + const ry = (p.radiusY ?? SHELL_DEFAULTS.radiusY) * dy; + const rz = (p.radiusZ ?? SHELL_DEFAULTS.radiusZ) * dz; + out + .copy(u.uAnchor.value) + .addScaledVector(u.uAxisX.value, rx) + .addScaledVector(u.uAxisZ.value, rz); + out.y += ry; + return out; + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + this.group.parent?.remove(this.group); + } +} diff --git a/src/vfx/Curtain.js b/src/vfx/Curtain.js new file mode 100644 index 0000000..1579a89 --- /dev/null +++ b/src/vfx/Curtain.js @@ -0,0 +1,1487 @@ +import { + AddEquation, + Color, + CustomBlending, + DoubleSide, + Group, + InstancedBufferAttribute, + InstancedBufferGeometry, + Mesh, + OneFactor, + OneMinusSrcAlphaFactor, + PlaneGeometry, + ShaderMaterial, + Sphere, + Vector3, + Vector4 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms, frame } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { copyColor } from '../utils/color.js'; + +/* ------------------------------------------------------------------------ */ +/* Curtain — vertical sheets of light in air */ +/* ------------------------------------------------------------------------ */ +/** + * Instanced vertical sheets with a travelling vertex ripple. + * + * ## The mismatch is the effect + * + * A hanging ribbon and a curtain of aurora are the same geometry. The single + * thing that separates them is that on a ribbon, **coverage and brightness fall + * off together** — where the cloth thins out, it both stops hiding what is + * behind it and stops being bright. Light in air does not do that. A column of + * excited gas keeps emitting long after it has stopped occluding anything, so + * the top of a real aurora is *pure radiance over a visible sky*: bright, and + * transparent, at the same time. + * + * So alpha and emission are driven by two independent curves — + * + * ``` + * alpha = mix(alphaTop, alphaBase, pow(1 - h, alphaCurve)) + * emission = mix(emissionTop, emissionBase, pow(1 - h, emissionCurve)) + * ``` + * + * — and **they are meant to disagree**. Set `alphaCurve` well above + * `emissionCurve` and the sheet stops covering things halfway up while still + * throwing light out of its top; that is the aurora. Set them equal and you get + * the hanging ribbon back, which is the useful thing to try once so you can see + * what the mismatch is buying. For a light shaft, put `emissionTop` above + * `emissionBase` and the shaft is brightest where it enters the canopy. + * + * That only works because the output is **premultiplied alpha** + * (`ONE, ONE_MINUS_SRC_ALPHA`), the same trick `Portal` uses. One pass writes + * `rgb = body·α + emissive` and `a = α`, so it can be an occluding sheet + * (rain on glass), pure additive light (aurora), or a *darkening* (the wet + * floor) with no change but the numbers. `body` — the amount of α the sheet is + * allowed to have at all — is the `body` slider, and setting it to 0 turns the + * whole curtain into light with no substance. + * + * ## The other thing that matters: the grazing term + * + * A sheet has no thickness, so a ray crossing it face-on passes through almost + * nothing and a ray crossing it edge-on travels the length of a fold. Both alpha + * and emission are scaled by `1/|N·V|`, clamped. Without it a curtain is a flat + * decal that looks identical from every angle; with it the folds flare as you + * orbit and the sheet reads as a volume. It is two lines and it is most of the + * effect. + * + * ## Modes + * + * - **RAIN** — hashed streak lanes scrolling down the face, with an antialias + * floor so a distant curtain melts into haze rather than into a stipple of + * dots. Pairs with the floor companion, which goes wet and reflective and + * takes impact rings. + * - **AURORA** — vertical rays from an fbm sampled along the sheet's *length* + * only, a slow three-way hue band, and a distinct hem of a fourth colour along + * the bottom edge. Real aurora has that hem (a couple of hundred metres of + * nitrogen under the oxygen green), and leaving it out is the reason most + * attempts read as a green rag. + * - **SHAFT** — a tapered translucent volume falling through a canopy: a + * gaussian core across the sheet, a fixed noise gate along it so each shaft + * has its own silhouette of leaf gaps, and dust motes on a hashed world-space + * lattice. Pass `-frame.uLightDir` as the sheet's up axis and the shafts slant + * with the stage's own sun. + * + * ## Cost + * + * **One draw call** for any number of sheets — one `InstancedBufferGeometry`, + * every sheet placed by the vertex shader. **Two** if the floor companion is on. + * No textures. + * + * ## Invariants + * + * - **I1** — the per-sheet attributes are *dice*: four disc/bearing/jitter rolls + * and a phase. Every metre — spacing, width, height, lean, ripple amplitude — + * is re-resolved from `update()`'s params each frame, so a standing curtain + * re-lays itself under a paused slider. + * - **I3** — `roll()` allocates nothing (it refills existing typed arrays); + * `update()` writes into existing uniform boxes. + * - **I5** — eight pickers on the sheets, three more on the floor, none derived. + */ + +/* ---------------------------------------------------------------- */ +/* Enums */ +/* ---------------------------------------------------------------- */ + +export const CurtainMode = Object.freeze({ + /** Streaks running down the face. Wants the floor companion. */ + RAIN: 0, + /** Banded, multi-hue, slow, with a coloured hem. */ + AURORA: 1, + /** A tapered translucent shaft with a canopy gate and dust motes. */ + SHAFT: 2 +}); + +export const CurtainLayout = Object.freeze({ + /** A rank of sheets side by side along +X — a wall. */ + LINE: 0, + /** Sheets standing tangentially around a circle — a ring of aurora. */ + RING: 1, + /** Hashed placement inside a disc, each at its own bearing — light shafts. */ + SCATTER: 2 +}); + +/** Hard ceiling on the floor companion's per-sheet pool loop. */ +const FLOOR_SHEETS = 32; + +/* ---------------------------------------------------------------- */ +/* Layout — shared by the sheets and by the floor under them */ +/* ---------------------------------------------------------------- */ +/** + * Written once and injected into both shaders. The floor companion has to know + * exactly where the sheets stand in order to put a puddle or a light pool under + * each one, and two copies of a layout drift apart the first time someone adds a + * mode. + */ +const CURTAIN_LAYOUT = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform vec3 uAnchor; // world foot of the curtain + uniform vec3 uAxisX; // the layout's +X, unit + uniform vec3 uAxisY; // the sheets' up, unit. Tilt it for slanted shafts. + uniform vec3 uAxisZ; // the layout's +Z, unit + uniform int uLayout; + uniform float uCount; // sheets drawn this frame + uniform float uSpacing; // metres between sheets (LINE) + uniform float uRadius; // metres (RING, SCATTER) + uniform float uScatter; // metres of hashed slop off the nominal place + uniform float uSeed; + + /** + * Where sheet 'slot' stands and which way it runs. Metres. + * + * @param dice (discRadius, discAngle, bearing, jitterAlong) — unitless rolls + * @param jitterAcross a fifth roll, kept separate so SCATTER can spend four + */ + void sheetFrame(float slot, vec4 dice, float jitterAcross, + out vec3 centre, out vec3 along, out vec3 norm) { + float n = max(uCount, 1.0); + + if (uLayout == 1) { + float a = (slot + 0.5) / n * TAU; + vec3 dir = uAxisX * cos(a) + uAxisZ * sin(a); + centre = uAnchor + dir * uRadius; + along = -uAxisX * sin(a) + uAxisZ * cos(a); + norm = dir; + } else if (uLayout == 2) { + // sqrt on the radial die, or every shaft crowds into the middle: the + // area of an annulus grows with r, so a uniform die must be warped to + // scatter uniformly over the disc. + float rr = uRadius * sqrt(clamp(dice.x, 0.0, 1.0)); + float aa = dice.y * TAU; + centre = uAnchor + (uAxisX * cos(aa) + uAxisZ * sin(aa)) * rr; + float b = dice.z * TAU; + along = uAxisX * cos(b) + uAxisZ * sin(b); + norm = normalize(cross(along, uAxisY) + vec3(1e-5)); + } else { + centre = uAnchor + uAxisX * ((slot - (n - 1.0) * 0.5) * uSpacing); + along = uAxisX; + norm = uAxisZ; + } + + // A rank of identical sheets on an even pitch reads as a fence at any + // distance. Two hashed metres of slop is all it takes to stop that, and it + // is a *die* times a live metre — never a captured offset. + centre += (along * (dice.w - 0.5) + norm * (jitterAcross - 0.5)) * uScatter * 2.0; + } +`; + +/* ---------------------------------------------------------------- */ +/* Sheet vertex */ +/* ---------------------------------------------------------------- */ + +const CURTAIN_VERTEX = /* glsl */ ` + uniform float uTime; + + /* --- the body of a sheet --- */ + uniform float uWidth; // metres + uniform float uWidthJitter; // ±fraction + uniform float uHeight; // metres + uniform float uHeightJitter; // ±fraction + uniform float uBase; // metres the foot sits above the anchor plane + uniform float uTaper; // width multiplier at the top. <1 for a shaft. + uniform float uLean; // metres the top is pushed along the sheet normal + uniform float uLeanJitter; // ±fraction + uniform float uRise; // 0..1 how far the curtain has risen + uniform float uRiseSpread; // 0..1 stagger of that wave across the sheets + + /* --- the travelling ripple --- */ + uniform float uRippleAmp; // metres + uniform float uRippleLength; // metres along the sheet, crest to crest + uniform float uRippleSpeed; // metres/second + uniform float uRippleCurve; // exponent on height: 0 rigid, >0 pinned foot + uniform float uFoldAmp; // metres — the second, longer fold + uniform float uFoldLength; // metres + uniform float uFoldSpeed; // metres/second + uniform float uRippleNoise; // metres of fbm slop on top + uniform float uRippleNoiseScale; // cycles per metre + uniform float uRippleNoiseSpeed; // Hz + uniform float uPhaseSpread; // turns of per-sheet phase offset + + attribute vec4 aDiceA; // (discRadius, discAngle, bearing, jitterAlong) + attribute vec4 aDiceB; // (jitterAcross, width, height, lean) + attribute float aSeed; // phase / hue / reveal + /** The instance's slot. Carried explicitly rather than read from + gl_InstanceID, which does not exist in GLSL ES 1.00 and this project's + raw materials are all compiled as ES 1.00. */ + attribute float aIndex; + + varying float vH; // 0 at the foot, 1 at the head + varying float vS; // metres along the sheet from its centre + varying float vHalf; // metres — this sheet's half-width at this height + varying float vSeed; + varying float vGrow; // 0..1 this sheet's own reveal + varying vec3 vNormalW; + varying vec3 vWorld; + varying float vViewZ; + + ${noiseGLSL} + ${CURTAIN_LAYOUT} + + /** + * Lateral displacement of the sheet at 's' metres along it. + * + * The ripple travels along the sheet's *length*, not up it — that is what + * makes a curtain fold rather than flap. A sine plus a longer, slower fold + * plus a little fbm: the sine alone is a corrugated iron sheet, and the fbm + * alone is a rag. The height curve pins the foot, because a curtain that + * swings from the bottom reads as hanging cloth. + */ + float sheetRipple(float s, float h, float phase) { + float amp = pow(clamp(h, 0.0, 1.0), max(uRippleCurve, 0.0)); + float k1 = TAU / max(uRippleLength, 0.05); + float k2 = TAU / max(uFoldLength, 0.05); + float r = sin(s * k1 - uTime * uRippleSpeed * k1 + phase) * uRippleAmp; + r += sin(s * k2 - uTime * uFoldSpeed * k2 + phase * 1.7) * uFoldAmp; + if (uRippleNoise > 0.0) { + r += fbm3(vec3(s * uRippleNoiseScale, h * 0.6, + uTime * uRippleNoiseSpeed + phase * 3.0)) * uRippleNoise; + } + return r * amp; + } + + void main() { + float slot = aIndex; + vSeed = aSeed; + + vec3 centre; + vec3 along; + vec3 norm; + sheetFrame(slot, aDiceA, aDiceB.x, centre, along, norm); + + // Staggered reveal. A curtain that appears all at once is a decal; one that + // walks up the rank is a thing arriving. + float g = clamp((uRise - aSeed * uRiseSpread) / max(1.0 - uRiseSpread, 1e-3), 0.0, 1.0); + g = g * g * (3.0 - 2.0 * g); + vGrow = g; + + float h = uv.y; + float hh = h * g; + vH = h; + + float w = uWidth * (1.0 + (aDiceB.y - 0.5) * 2.0 * uWidthJitter); + w *= mix(1.0, uTaper, hh); + vHalf = w * 0.5; + + float s = (uv.x - 0.5) * w; + vS = s; + + float phase = aSeed * uPhaseSpread * TAU; + float r = sheetRipple(s, hh, phase); + + vec3 pos = centre + + along * s + + uAxisY * (uBase + hh * uHeight * (1.0 + (aDiceB.z - 0.5) * 2.0 * uHeightJitter)) + + norm * (r + uLean * hh * (1.0 + (aDiceB.w - 0.5) * 2.0 * uLeanJitter)); + + // The mesh normal is meaningless once the sheet has been rebuilt here, and + // the grazing term is the whole reason the curtain reads as a volume — so + // difference the ripple for a real one. dP/ds = along + norm·(dr/ds), and + // the surface normal is that turned a quarter into the sheet. + const float ds = 0.05; + float dr = (sheetRipple(s + ds, hh, phase) - r) / ds; + vNormalW = normalize(norm - along * dr); + + vWorld = pos; + vec4 mv = viewMatrix * vec4(pos, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/* ---------------------------------------------------------------- */ +/* Sheet fragment */ +/* ---------------------------------------------------------------- */ + +const CURTAIN_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform int uMode; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + /* --- the two curves. See the header; they are meant to disagree. --- */ + uniform float uAlphaBase; // coverage at the foot + uniform float uAlphaTop; // coverage at the head + uniform float uAlphaCurve; // exponent between them + uniform float uEmissionBase; // radiance at the foot + uniform float uEmissionTop; // radiance at the head + uniform float uEmissionCurve; // exponent between them + + uniform float uBody; // 0..1 how much α the sheet is allowed at all + uniform float uFootFade; // 0..1 of the height, feathering the bottom edge + uniform float uHeadFade; // 0..1 of the height, feathering the top edge + uniform float uEdgeFade; // 0..1 across the sheet, feathering the sides + uniform float uGraze; // 0..1 how much the 1/|N·V| term is applied + uniform float uGrazeFloor;// clamp on |N·V| — 0.1 gives a 10× ceiling + uniform float uSoftFade; // metres of depth fade against opaque geometry + + /* --- RAIN --- */ + uniform float uStreakDensity; // lanes per metre across the face + uniform float uStreakRepeat; // streaks per sheet height + uniform float uStreakSpeed; // sheet heights per second + uniform float uStreakSpeedJitter;// ±fraction, per lane + uniform float uStreakWidth; // 0..1 of a lane + uniform float uStreakTail; // 0..1 of a repeat — the trailing smear + uniform float uStreakDuty; // 0..1 of lanes that carry a streak + uniform float uHaze; // the veil between the streaks + + /* --- AURORA --- */ + uniform float uRayScale; // cycles per metre of the vertical striations + uniform float uRaySpeed; // Hz + uniform float uRaySharp; + uniform float uBandScale; // cycles per metre of the hue banding + uniform float uBandSpeed; // Hz + uniform float uHem; // 0..1 of the height — the coloured bottom band + + /* --- SHAFT --- */ + uniform float uCoreWidth; // 0..1 of the half-width — the gaussian core + uniform float uCanopy; // 0..1 threshold — how much light gets through + uniform float uCanopySoft; // 0..1 of the field + uniform float uCanopyScale; // cycles per metre along the sheet + uniform float uMote; // dust motes + uniform float uMoteScale; // motes per metre + uniform float uMoteSize; + uniform float uMoteDrift; // metres/second the motes settle + + uniform vec3 uColorA; + uniform vec3 uColorB; + uniform vec3 uColorC; + uniform vec3 uColorHem; + uniform vec3 uColorCore; + uniform vec3 uColorMote; + uniform vec3 uColorBody; // what the sheet OCCLUDES with, independent of its light + uniform float uTintSpread; // 0..1 how far apart neighbouring sheets are tinted + uniform float uGlow; + uniform float uOpacity; + + varying float vH; + varying float vS; + varying float vHalf; + varying float vSeed; + varying float vGrow; + varying vec3 vNormalW; + varying vec3 vWorld; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + float h = clamp(vH, 0.0, 1.0); + float across = clamp(abs(vS) / max(vHalf, 1e-4), 0.0, 1.0); + + /* ---------------- the two curves ---------------- */ + float aFall = mix(uAlphaTop, uAlphaBase, pow(1.0 - h, max(uAlphaCurve, 0.01))); + float eFall = mix(uEmissionTop, uEmissionBase, pow(1.0 - h, max(uEmissionCurve, 0.01))); + + /* ---------------- the envelope ---------------- */ + float env = smoothstep(0.0, max(uFootFade, 1e-3), h) + * (1.0 - smoothstep(1.0 - max(uHeadFade, 1e-3), 1.0, h)) + * (1.0 - smoothstep(1.0 - max(uEdgeFade, 1e-3), 1.0, across)) + * vGrow; + + /* ---------------- the grazing term ---------------- */ + vec3 N = normalize(vNormalW); + vec3 V = normalize(cameraPosition - vWorld); + float path = 1.0 / max(abs(dot(N, V)), max(uGrazeFloor, 0.02)); + path = mix(1.0, path, clamp(uGraze, 0.0, 1.0)); + + /* ---------------- the mode ---------------- */ + float cov = 0.0; // what covers (feeds α) + float emit = 0.0; // what shines (feeds the emissive) + vec3 tint = uColorA; + + if (uMode == 0) { + /* ---- RAIN ---- */ + float lane = vS * max(uStreakDensity, 0.1); + float id = floor(lane); + float r1 = hash11(id * 1.37 + vSeed * 91.0); + float r2 = hash11(id * 4.11 + vSeed * 17.0); + float spd = mix(1.0 - uStreakSpeedJitter, 1.0 + uStreakSpeedJitter, r2); + + // The pattern scrolls toward the foot, so the bright q ≈ 0 end is the + // head of the drop and the exponential trails up behind it. + float q = fract(h * max(uStreakRepeat, 0.1) + uTime * uStreakSpeed * spd + r1); + float head = exp(-q / max(uStreakTail, 1e-3)); + + // Antialias floor, straight out of the archived wind material: widen the + // lane to at least a pixel, then fade what is left into the lane's own + // average once the pitch stops resolving. Without the second half a + // distant curtain is a stipple of beating dots rather than a haze. + float d = abs(fract(lane) - 0.5) * 2.0; + float aa = fwidth(lane); + float w0 = max(uStreakWidth, 0.02); + float w = max(w0, aa * 2.0); + float core = 1.0 - smoothstep(0.0, w, d); + float resolve = 1.0 - smoothstep(w0 * 0.35, w0 * 1.5, aa); + core = mix(clamp(w, 0.0, 1.0) * 0.5, core, resolve); + + float lit = step(1.0 - clamp(uStreakDuty, 0.0, 1.0), hash11(id * 7.71 + vSeed * 5.0)); + lit = mix(clamp(uStreakDuty, 0.0, 1.0), lit, resolve); + + float streak = core * head * lit; + cov = streak + uHaze * 0.35; + emit = streak * 0.6 + uHaze * 0.1; + tint = mix(uColorA, uColorB, clamp(head, 0.0, 1.0)); + } else if (uMode == 1) { + /* ---- AURORA ---- */ + // Rays sampled on the sheet's length ALONE. Feed the height in as well — + // the obvious thing — and the striations break into blotches, because a + // ray is by definition a field that does not vary along a field line. + float ray = fbm3(vec3(vS * uRayScale, uTime * uRaySpeed + vSeed * 13.0, 0.0)); + ray = smoothstep(-0.15, max(uRaySharp, 0.05), ray); + float fine = fbm3(vec3(vS * uRayScale * 3.7, uTime * uRaySpeed * 1.6 + vSeed * 3.0, 9.0)); + ray *= 0.6 + 0.6 * (fine * 0.5 + 0.5); + + float band = fbm3(vec3(vS * uBandScale, uTime * uBandSpeed + vSeed * 7.0, 5.0)) * 0.5 + 0.5; + band = clamp(band + (vSeed - 0.5) * uTintSpread, 0.0, 1.0); + tint = band < 0.5 ? mix(uColorA, uColorB, band * 2.0) + : mix(uColorB, uColorC, (band - 0.5) * 2.0); + + // The hem. Real aurora carries a band of a different gas along its bottom + // edge; without it the curtain is a green rag with a straight cut. + float hem = 1.0 - smoothstep(0.0, max(uHem, 1e-3), h); + tint = mix(tint, uColorHem, hem); + + cov = ray * 0.5; + emit = ray + hem * 0.35; + } else { + /* ---- SHAFT ---- */ + // A gaussian across the sheet, not a smoothstep: a shaft has no edge, it + // has a falloff, and a smoothstep draws the plane it is standing in. + float core = exp(-(across * across) / max(uCoreWidth * uCoreWidth, 1e-4)); + + // The canopy. A fixed field along the sheet, seeded per shaft, so each one + // has its own silhouette of leaf gaps and they do not all flicker together. + float leaf = fbm3(vec3(vS * uCanopyScale, vSeed * 23.0, 0.0)) * 0.5 + 0.5; + float gate = smoothstep(uCanopy, uCanopy + max(uCanopySoft, 1e-3), leaf); + + float motes = 0.0; + if (uMote > 0.0) { + vec3 mp = vWorld * max(uMoteScale, 0.05) + vec3(0.0, uTime * uMoteDrift, 0.0); + vec3 cell = floor(mp); + vec3 fr = fract(mp) - 0.5; + float hh = hash13(cell); + vec3 off = (hash31(hh * 37.1) - 0.5) * 0.6; + float dd = length(fr - off); + motes = smoothstep(0.62, 0.97, hh) * exp(-dd / max(uMoteSize, 1e-3)) * uMote; + } + + tint = mix(uColorCore, uColorA, clamp(across, 0.0, 1.0)); + cov = core * gate * 0.4; + emit = core * gate + motes; + tint = mix(tint, uColorMote, clamp(motes, 0.0, 1.0)); + } + + /* ---------------- composite, premultiplied ---------------- */ + float alpha = clamp(cov * aFall * env * path * uBody * uOpacity, 0.0, 1.0); + vec3 emissive = tint * (emit * eFall * env * path * uGlow * uOpacity) + * uGlobalGlow * mix(0.7, 1.0, uShaderIntensity); + + vec2 screenUV = gl_FragCoord.xy / uResolution; + float soft = softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, max(uSoftFade, 1e-3)); + alpha *= soft; + emissive *= soft; + + if (alpha < 0.002 && max(emissive.r, max(emissive.g, emissive.b)) < 0.002) discard; + + gl_FragColor = vec4(uColorBody * alpha + emissive, alpha); + } +`; + +/* ---------------------------------------------------------------- */ +/* Floor companion */ +/* ---------------------------------------------------------------- */ + +const FLOOR_VERTEX = /* glsl */ ` + uniform float uFloorSize; // metres, the quad's full extent + + varying vec2 vLocal; // metres from the anchor, in the floor plane + varying vec3 vWorld; + varying float vViewZ; + + ${CURTAIN_LAYOUT} + + void main() { + vec2 ext = (uv - 0.5) * uFloorSize; + vLocal = ext; + // Lifted a hair off the ground so it never z-fights the stage floor. + vec3 world = uAnchor + uAxisX * ext.x + uAxisZ * ext.y + uAxisY * 0.012; + vWorld = world; + vec4 mv = viewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/** + * What the ground under a curtain does. + * + * Three terms, all of them premultiplied so one pass can darken *and* add: + * + * - **sheen** — a Schlick fresnel over `frame.uEnvMap` on a normal perturbed by + * a slow fbm. This is what "wet" is. Dry stone is diffuse and returns the + * same colour from every angle; wet stone is a mirror with a very short + * memory, and it is the *grazing* reflection that says so, which is why the + * fresnel matters more here than the reflection does. + * - **pools** — a falloff around each sheet's footprint, evaluated by walking + * the same `sheetFrame()` the sheets themselves use. For RAIN that is the + * wet patch; for SHAFT it is the disc of light the shaft lands in, which is + * the whole reason a shaft reads as reaching the ground. + * - **rings** — expanding impact rings on a hashed lattice, each cell holding + * its own phase, so a downpour is a hundred rings at a hundred ages rather + * than one animation played everywhere. + */ +const FLOOR_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform sampler2D uEnvMap; + uniform vec3 uLightDir; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + uniform float uFloorSize; + uniform float uFloorFade; // 0..1 of the half-extent where the quad dies + uniform float uWet; // 0..1 how wet the stone is + uniform float uWetDark; // 0..1 how much wet stone darkens + uniform float uSheen; // env reflection gain + uniform float uSheenRough; // cycles per metre of the surface ripple + uniform float uSheenSpeed; // Hz + uniform float uFresnel; + + uniform float uPool; // 0..1 master on the per-sheet footprints + uniform float uPoolWidth; // metres either side of a sheet + uniform float uPoolLength; // 0..1 of the sheet's own half-width + uniform float uPoolSoft; // metres of feather + + uniform float uRings; // 0..1 master + uniform float uRingScale; // cells per metre + uniform float uRingPeriod; // seconds per ring + uniform float uRingRadius; // metres a ring reaches + uniform float uRingWidth; // metres + + uniform vec3 uColorWet; + uniform vec3 uColorPool; + uniform vec3 uColorRing; + uniform float uFloorOpacity; + + /* --- the sheets' dice, mirrored so the pools land under them --- */ + uniform vec4 uSheetDice[${FLOOR_SHEETS}]; // (discR, discA, bearing, jitterAlong) + uniform float uSheetJitter[${FLOOR_SHEETS}]; // the fifth roll, jitterAcross + uniform float uSheetHalf; // metres — half of the sheets' nominal width + + varying vec2 vLocal; + varying vec3 vWorld; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + ${CURTAIN_LAYOUT} + + // equirectUv comes from commonGLSL above. (No backticks in here: the file is + // one template literal and a stray one ends it mid-shader.) + + void main() { + /* ---------------- the sheen ---------------- */ + float sc = max(uSheenRough, 0.02); + float e = 0.4 / sc; + float t = uTime * uSheenSpeed; + float n0 = fbm3(vec3(vLocal * sc, t)); + float nx = fbm3(vec3((vLocal + vec2(e, 0.0)) * sc, t)); + float nz = fbm3(vec3((vLocal + vec2(0.0, e)) * sc, t)); + vec3 nLocal = normalize(vec3(-(nx - n0) / e, 1.0, -(nz - n0) / e) * vec3(uWet, 1.0, uWet)); + vec3 N = normalize(uAxisX * nLocal.x + uAxisY * nLocal.y + uAxisZ * nLocal.z); + + vec3 V = normalize(cameraPosition - vWorld); + float ndv = clamp(dot(N, V), 0.0, 1.0); + float fres = clamp((0.02 + 0.98 * pow(1.0 - ndv, 5.0)) * uFresnel, 0.0, 1.0); + + vec3 refl = reflect(-V, N); + vec3 env = texture2D(uEnvMap, equirectUv(refl)).rgb; + float glint = pow(max(dot(refl, normalize(uLightDir)), 0.0), 90.0); + + /* ---------------- the pools ---------------- */ + float pool = 0.0; + if (uPool > 0.001) { + for (int i = 0; i < ${FLOOR_SHEETS}; i++) { + if (float(i) >= uCount) break; + vec3 centre; + vec3 along; + vec3 norm; + sheetFrame(float(i), uSheetDice[i], uSheetJitter[i], centre, along, norm); + + // Distance to the sheet's footprint, treated as a segment: the puddle + // under a two-metre sheet is a two-metre puddle, not a circle. + vec3 rel = vWorld - centre; + float a = dot(rel, along); + float half_ = uSheetHalf * clamp(uPoolLength, 0.0, 2.0); + a -= clamp(a, -half_, half_); + float b = dot(rel, norm); + float d = length(vec2(a, b)); + pool = max(pool, 1.0 - smoothstep(max(uPoolWidth - uPoolSoft, 0.0), uPoolWidth, d)); + } + pool *= clamp(uPool, 0.0, 1.0); + } + + /* ---------------- the rings ---------------- */ + float ring = 0.0; + if (uRings > 0.001) { + vec2 g = vLocal * max(uRingScale, 0.05); + vec2 id = floor(g); + vec2 fr = fract(g) - 0.5; + float cellSize = 1.0 / max(uRingScale, 0.05); + float hcell = hash13(vec3(id, 3.7)); + vec2 off = (hash21(hcell * 71.3) - 0.5) * 0.7; + float d = length(fr - off) * cellSize; + float age = fract(uTime / max(uRingPeriod, 0.05) + hcell); + float r = age * uRingRadius; + ring = exp(-abs(d - r) / max(uRingWidth, 1e-3)) * (1.0 - age) * uRings; + } + + /* ---------------- composite ---------------- */ + float edge = 1.0 - smoothstep(1.0 - max(uFloorFade, 1e-3), 1.0, + length(vLocal) / max(uFloorSize * 0.5, 1e-3)); + + // Wetness *darkens*, which premultiplied alpha can express and additive + // cannot: a low-luminance rgb with a real α subtracts from what is behind. + float wet = clamp(uWet * (0.35 + 0.65 * pool), 0.0, 1.0) * edge; + float alpha = clamp(wet * uWetDark, 0.0, 1.0) * uFloorOpacity; + + vec3 emissive = (env * uSheen + uColorWet * glint * uSheen) * fres * wet; + emissive += uColorPool * pool * edge * uFloorOpacity; + emissive += uColorRing * ring * edge * uFloorOpacity; + emissive *= uGlobalGlow * mix(0.7, 1.0, uShaderIntensity); + + vec2 screenUV = gl_FragCoord.xy / uResolution; + float soft = softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, 0.25); + alpha *= soft; + emissive *= soft; + + if (alpha < 0.002 && max(emissive.r, max(emissive.g, emissive.b)) < 0.002) discard; + + gl_FragColor = vec4(uColorWet * alpha + emissive, alpha); + } +`; + +/* ---------------------------------------------------------------- */ +/* Defaults */ +/* ---------------------------------------------------------------- */ + +/** + * A curtain that is visible on the first frame, not an art direction. Every one + * of these is meant to become a slider in the ability's own settings block. + */ +const DEFAULTS = { + /* --- the layout --- */ + count: 7, // sheets drawn + spacing: 1.4, // metres between sheets (LINE) + radius: 5, // metres (RING, SCATTER) + scatter: 0.35, // metres of hashed slop off the nominal place + seed: 0, + + /* --- the body of a sheet --- */ + width: 4, // metres + widthJitter: 0.25, // ±fraction + height: 5, // metres + heightJitter: 0.2, // ±fraction + base: 0, // metres the foot sits above the anchor plane + taper: 1, // width multiplier at the head. <1 for a shaft. + lean: 0.6, // metres the head is pushed along the sheet normal + leanJitter: 0.5, // ±fraction + rise: 1, // 0..1 how far the curtain has risen + riseSpread: 0.45, // 0..1 stagger of that wave across the sheets + + /* --- the travelling ripple --- */ + rippleAmp: 0.5, // metres + rippleLength: 3.2, // metres along the sheet, crest to crest + rippleSpeed: 1.1, // metres/second + rippleCurve: 1.2, // exponent on height: 0 rigid, >0 pins the foot + foldAmp: 0.9, // metres — the second, longer fold + foldLength: 9, // metres + foldSpeed: 0.45, // metres/second + rippleNoise: 0.18, // metres of fbm slop + rippleNoiseScale: 0.35, //cycles per metre + rippleNoiseSpeed: 0.15, //Hz + phaseSpread: 1, // turns of per-sheet phase offset + + /* --- the two curves --- */ + alphaBase: 1, // coverage at the foot + alphaTop: 0, // coverage at the head + alphaCurve: 2.4, // exponent between them + emissionBase: 1, // radiance at the foot + emissionTop: 0.35, // radiance at the head + emissionCurve: 0.7, //exponent between them — DELIBERATELY not alphaCurve + + body: 0.25, // 0..1 how much α the sheet is allowed at all + footFade: 0.03, // 0..1 of the height + headFade: 0.25, // 0..1 of the height + edgeFade: 0.3, // 0..1 across the sheet + graze: 1, // 0..1 how much the 1/|N·V| path term is applied + grazeFloor: 0.12, // clamp on |N·V| — 0.12 gives a ~8× ceiling + softFade: 0.5, // metres of depth fade against opaque geometry + opacity: 1, + glow: 1.2, + tintSpread: 0.4, // 0..1 how far apart neighbouring sheets are tinted + + /* --- RAIN --- */ + streakDensity: 9, // lanes per metre across the face + streakRepeat: 2.4, // streaks per sheet height + streakSpeed: 1.6, // sheet heights per second + streakSpeedJitter: 0.35, //±fraction, per lane + streakWidth: 0.14, // 0..1 of a lane + streakTail: 0.09, // 0..1 of a repeat — the trailing smear + streakDuty: 0.55, // 0..1 of lanes that carry a streak + haze: 0.35, // the veil between the streaks + + /* --- AURORA --- */ + rayScale: 0.9, // cycles per metre of the vertical striations + raySpeed: 0.07, // Hz + raySharp: 0.45, + bandScale: 0.12, // cycles per metre of the hue banding + bandSpeed: 0.04, // Hz + hem: 0.12, // 0..1 of the height — the coloured bottom band + + /* --- SHAFT --- */ + coreWidth: 0.45, // 0..1 of the half-width + canopy: 0.4, // 0..1 threshold — how much light gets through + canopySoft: 0.25, //0..1 of the field + canopyScale: 0.6, //cycles per metre along the sheet + mote: 0.7, + moteScale: 2.2, // motes per metre + moteSize: 0.06, + moteDrift: 0.06, // metres/second the motes settle + + colorA: '#5fffc0', + colorB: '#3a9aff', + colorC: '#c05fff', + colorHem: '#ff5f9a', + colorCore: '#e8f0c0', + colorMote: '#ffffff', + colorBody: '#101c2a', + + /* --- the floor companion --- */ + floorSize: 18, // metres, the quad's full extent + floorFade: 0.35, // 0..1 of the half-extent where it dies + wet: 0.7, // 0..1 how wet the stone is + wetDark: 0.55, // 0..1 how much wet stone darkens + sheen: 0.8, // env reflection gain + sheenRough: 1.1, // cycles per metre of the surface ripple + sheenSpeed: 0.5, // Hz + floorFresnel: 1.4, + pool: 1, // 0..1 master on the per-sheet footprints + poolWidth: 1.6, // metres either side of a sheet + poolLength: 1, // 0..1 of the sheet's own half-width + poolSoft: 1.1, // metres of feather + rings: 0.8, // 0..1 master + ringScale: 0.55, // cells per metre + ringPeriod: 0.9, // seconds per ring + ringRadius: 0.5, // metres a ring reaches + ringWidth: 0.035, // metres + floorOpacity: 1, + colorWet: '#101c2a', + colorPool: '#2a4258', + colorRing: '#c8dcea' +}; + +/** Every canonical key with its default. Copy it into a settings block. */ +export function curtainParams() { + return { ...DEFAULTS }; +} + +const num = (v, d) => (v === undefined || v === null ? d : v); + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope (I3) */ +/* ---------------------------------------------------------------- */ + +const _ax = new Vector3(); +const _ay = new Vector3(); +const _az = new Vector3(); +const _centre = new Vector3(); +const _along = new Vector3(); +const _norm = new Vector3(); + +/* ---------------------------------------------------------------- */ +/* Curtain */ +/* ---------------------------------------------------------------- */ + +/** + * ```js + * const _o = curtainParams(); // module scope — I3 + * const _p = new Vector3(); + * + * this.veil = new Curtain({ mode: CurtainMode.AURORA, layout: CurtainLayout.RING }); + * this.group.add(this.veil.object3D); + * // … + * onSpawn() { this.veil.roll(); } + * onTravel(dt) { + * const c = settings.aurora; + * this.veil.setPlacement(this.target, this.direction, UP); + * _o.count = c.sheets; + * _o.radius = c.zoneRadius; // metres, resolved THIS frame + * _o.height = c.veilHeight; + * _o.rise = this.u; + * this.veil.update(this.age, _o); + * } + * ``` + */ +export class Curtain { + /** + * @param {object} options + * @param {number} [options.capacity] hard ceiling on sheets + * @param {number} [options.segmentsX] quads across a sheet. The ripple lives + * here, so this is the one that matters. + * @param {number} [options.segmentsY] quads up a sheet + * @param {number} [options.mode] CurtainMode.* + * @param {number} [options.layout] CurtainLayout.* + * @param {boolean} [options.floor] build the wet/lit ground companion + * @param {number} [options.renderOrder] + * @param {string} [options.name] + */ + constructor({ + capacity = 16, + segmentsX = 32, + segmentsY = 16, + mode = CurtainMode.AURORA, + layout = CurtainLayout.LINE, + floor = false, + renderOrder = 8, + name = 'Curtain' + } = {}) { + this.capacity = Math.max(1, Math.round(capacity)); + + this.group = new Group(); + this.group.name = name; + this.group.matrixAutoUpdate = false; + + /* --- the sheet mesh -------------------------------------------- */ + + // A unit plane, borrowed for its uv grid and its index buffer only: every + // metre comes from a uniform in the vertex shader, so resizing a curtain + // never touches a buffer. + const plane = new PlaneGeometry(1, 1, Math.max(1, segmentsX | 0), Math.max(1, segmentsY | 0)); + + this._diceA = new Float32Array(this.capacity * 4); + this._diceB = new Float32Array(this.capacity * 4); + this._seeds = new Float32Array(this.capacity); + const slots = new Float32Array(this.capacity); + for (let i = 0; i < this.capacity; i++) slots[i] = i; + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', plane.getAttribute('position')); + geometry.setAttribute('uv', plane.getAttribute('uv')); + geometry.setIndex(plane.getIndex()); + geometry.setAttribute('aDiceA', new InstancedBufferAttribute(this._diceA, 4)); + geometry.setAttribute('aDiceB', new InstancedBufferAttribute(this._diceB, 4)); + geometry.setAttribute('aSeed', new InstancedBufferAttribute(this._seeds, 1)); + geometry.setAttribute('aIndex', new InstancedBufferAttribute(slots, 1)); + geometry.instanceCount = 0; + // Placed in world space by the vertex shader; its own bounds mean nothing. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.geometry = geometry; + + const layoutUniforms = { + uAnchor: { value: new Vector3() }, + uAxisX: { value: new Vector3(1, 0, 0) }, + uAxisY: { value: new Vector3(0, 1, 0) }, + uAxisZ: { value: new Vector3(0, 0, 1) }, + uLayout: { value: layout }, + uCount: { value: DEFAULTS.count }, + uSpacing: { value: DEFAULTS.spacing }, + uRadius: { value: DEFAULTS.radius }, + uScatter: { value: DEFAULTS.scatter }, + uSeed: { value: DEFAULTS.seed } + }; + // Shared *by identity* with the floor companion, so the pools cannot drift + // out from under the sheets: one write moves both. + this._layoutUniforms = layoutUniforms; + + this.material = new ShaderMaterial({ + name: `${name}:sheets`, + transparent: true, + depthWrite: false, + depthTest: true, + side: DoubleSide, + toneMapped: false, + // Premultiplied alpha — see the class header. One pass, three behaviours. + blending: CustomBlending, + blendEquation: AddEquation, + blendSrc: OneFactor, + blendDst: OneMinusSrcAlphaFactor, + blendEquationAlpha: AddEquation, + blendSrcAlpha: OneFactor, + blendDstAlpha: OneMinusSrcAlphaFactor, + uniforms: sharedUniforms({ + ...layoutUniforms, + uMode: { value: mode }, + + uWidth: { value: DEFAULTS.width }, + uWidthJitter: { value: DEFAULTS.widthJitter }, + uHeight: { value: DEFAULTS.height }, + uHeightJitter: { value: DEFAULTS.heightJitter }, + uBase: { value: DEFAULTS.base }, + uTaper: { value: DEFAULTS.taper }, + uLean: { value: DEFAULTS.lean }, + uLeanJitter: { value: DEFAULTS.leanJitter }, + uRise: { value: DEFAULTS.rise }, + uRiseSpread: { value: DEFAULTS.riseSpread }, + + uRippleAmp: { value: DEFAULTS.rippleAmp }, + uRippleLength: { value: DEFAULTS.rippleLength }, + uRippleSpeed: { value: DEFAULTS.rippleSpeed }, + uRippleCurve: { value: DEFAULTS.rippleCurve }, + uFoldAmp: { value: DEFAULTS.foldAmp }, + uFoldLength: { value: DEFAULTS.foldLength }, + uFoldSpeed: { value: DEFAULTS.foldSpeed }, + uRippleNoise: { value: DEFAULTS.rippleNoise }, + uRippleNoiseScale: { value: DEFAULTS.rippleNoiseScale }, + uRippleNoiseSpeed: { value: DEFAULTS.rippleNoiseSpeed }, + uPhaseSpread: { value: DEFAULTS.phaseSpread }, + + uAlphaBase: { value: DEFAULTS.alphaBase }, + uAlphaTop: { value: DEFAULTS.alphaTop }, + uAlphaCurve: { value: DEFAULTS.alphaCurve }, + uEmissionBase: { value: DEFAULTS.emissionBase }, + uEmissionTop: { value: DEFAULTS.emissionTop }, + uEmissionCurve: { value: DEFAULTS.emissionCurve }, + + uBody: { value: DEFAULTS.body }, + uFootFade: { value: DEFAULTS.footFade }, + uHeadFade: { value: DEFAULTS.headFade }, + uEdgeFade: { value: DEFAULTS.edgeFade }, + uGraze: { value: DEFAULTS.graze }, + uGrazeFloor: { value: DEFAULTS.grazeFloor }, + uSoftFade: { value: DEFAULTS.softFade }, + + uStreakDensity: { value: DEFAULTS.streakDensity }, + uStreakRepeat: { value: DEFAULTS.streakRepeat }, + uStreakSpeed: { value: DEFAULTS.streakSpeed }, + uStreakSpeedJitter: { value: DEFAULTS.streakSpeedJitter }, + uStreakWidth: { value: DEFAULTS.streakWidth }, + uStreakTail: { value: DEFAULTS.streakTail }, + uStreakDuty: { value: DEFAULTS.streakDuty }, + uHaze: { value: DEFAULTS.haze }, + + uRayScale: { value: DEFAULTS.rayScale }, + uRaySpeed: { value: DEFAULTS.raySpeed }, + uRaySharp: { value: DEFAULTS.raySharp }, + uBandScale: { value: DEFAULTS.bandScale }, + uBandSpeed: { value: DEFAULTS.bandSpeed }, + uHem: { value: DEFAULTS.hem }, + + uCoreWidth: { value: DEFAULTS.coreWidth }, + uCanopy: { value: DEFAULTS.canopy }, + uCanopySoft: { value: DEFAULTS.canopySoft }, + uCanopyScale: { value: DEFAULTS.canopyScale }, + uMote: { value: DEFAULTS.mote }, + uMoteScale: { value: DEFAULTS.moteScale }, + uMoteSize: { value: DEFAULTS.moteSize }, + uMoteDrift: { value: DEFAULTS.moteDrift }, + + uColorA: { value: new Color(DEFAULTS.colorA) }, + uColorB: { value: new Color(DEFAULTS.colorB) }, + uColorC: { value: new Color(DEFAULTS.colorC) }, + uColorHem: { value: new Color(DEFAULTS.colorHem) }, + uColorCore: { value: new Color(DEFAULTS.colorCore) }, + uColorMote: { value: new Color(DEFAULTS.colorMote) }, + uColorBody: { value: new Color(DEFAULTS.colorBody) }, + uTintSpread: { value: DEFAULTS.tintSpread }, + uGlow: { value: DEFAULTS.glow }, + uOpacity: { value: DEFAULTS.opacity } + }), + vertexShader: CURTAIN_VERTEX, + fragmentShader: CURTAIN_FRAGMENT + }); + + this.mesh = new Mesh(geometry, this.material); + this.mesh.name = `${name}:sheets`; + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(LAYER.VFX); + this.mesh.renderOrder = renderOrder; + this.group.add(this.mesh); + + /* --- the floor companion --------------------------------------- */ + + this.floorMesh = null; + this.floorMaterial = null; + this._sheetDice = null; + this._sheetJitter = null; + if (floor) this._buildFloor(name, renderOrder, layoutUniforms); + + this.roll(); + } + + _buildFloor(name, renderOrder, layoutUniforms) { + // The dice the sheets carry as attributes, mirrored into uniform arrays so + // the floor can walk the same `sheetFrame()`. Allocated once here, mutated + // in `roll()`, never reallocated. + this._sheetDice = []; + for (let i = 0; i < FLOOR_SHEETS; i++) this._sheetDice.push(new Vector4(0, 0, 0, 0)); + this._sheetJitter = new Float32Array(FLOOR_SHEETS); + + this.floorMaterial = new ShaderMaterial({ + name: `${name}:floor`, + transparent: true, + depthWrite: false, + depthTest: true, + side: DoubleSide, + toneMapped: false, + blending: CustomBlending, + blendEquation: AddEquation, + blendSrc: OneFactor, + blendDst: OneMinusSrcAlphaFactor, + blendEquationAlpha: AddEquation, + blendSrcAlpha: OneFactor, + blendDstAlpha: OneMinusSrcAlphaFactor, + uniforms: sharedUniforms({ + ...layoutUniforms, + uEnvMap: frame.uEnvMap, + uFloorSize: { value: DEFAULTS.floorSize }, + uFloorFade: { value: DEFAULTS.floorFade }, + uWet: { value: DEFAULTS.wet }, + uWetDark: { value: DEFAULTS.wetDark }, + uSheen: { value: DEFAULTS.sheen }, + uSheenRough: { value: DEFAULTS.sheenRough }, + uSheenSpeed: { value: DEFAULTS.sheenSpeed }, + uFresnel: { value: DEFAULTS.floorFresnel }, + uPool: { value: DEFAULTS.pool }, + uPoolWidth: { value: DEFAULTS.poolWidth }, + uPoolLength: { value: DEFAULTS.poolLength }, + uPoolSoft: { value: DEFAULTS.poolSoft }, + uRings: { value: DEFAULTS.rings }, + uRingScale: { value: DEFAULTS.ringScale }, + uRingPeriod: { value: DEFAULTS.ringPeriod }, + uRingRadius: { value: DEFAULTS.ringRadius }, + uRingWidth: { value: DEFAULTS.ringWidth }, + uColorWet: { value: new Color(DEFAULTS.colorWet) }, + uColorPool: { value: new Color(DEFAULTS.colorPool) }, + uColorRing: { value: new Color(DEFAULTS.colorRing) }, + uFloorOpacity: { value: DEFAULTS.floorOpacity }, + uSheetDice: { value: this._sheetDice }, + uSheetJitter: { value: this._sheetJitter }, + uSheetHalf: { value: DEFAULTS.width * 0.5 } + }), + vertexShader: FLOOR_VERTEX, + fragmentShader: FLOOR_FRAGMENT + }); + + this.floorMesh = new Mesh(new PlaneGeometry(1, 1, 1, 1), this.floorMaterial); + this.floorMesh.name = `${name}:floor`; + this.floorMesh.frustumCulled = false; + this.floorMesh.matrixAutoUpdate = false; + this.floorMesh.layers.set(LAYER.VFX); + this.floorMesh.renderOrder = renderOrder - 1; + this.group.add(this.floorMesh); + } + + /* ---------------- handles ---------------- */ + + /** Add this to the ability's group. */ + get object3D() { + return this.group; + } + + get uniforms() { + return this.material.uniforms; + } + + /** One, or two with the floor companion. */ + get drawCalls() { + return this.floorMesh ? 2 : 1; + } + + /** Sheets drawn this frame. HUD readout / `Ability#instanceCount`. */ + get instanceCount() { + return this.geometry.instanceCount; + } + + get visible() { + return this.group.visible; + } + + set visible(v) { + this.group.visible = !!v; + } + + get mode() { + return this.material.uniforms.uMode.value; + } + + /** RAIN ⇄ AURORA ⇄ SHAFT. A uniform branch — no recompile. */ + set mode(m) { + this.material.uniforms.uMode.value = m | 0; + } + + get layout() { + return this._layoutUniforms.uLayout.value; + } + + /** LINE ⇄ RING ⇄ SCATTER. Shared by identity with the floor. */ + set layout(l) { + this._layoutUniforms.uLayout.value = l | 0; + } + + /** + * Place the curtain without touching a matrix. + * + * @param {THREE.Vector3} anchor world foot of the curtain, metres + * @param {THREE.Vector3} along the layout's +X. For a **wall across the cast** + * pass the cast's *side* vector, not its + * direction — the line you aim is the wall's + * normal, which is `stormwall`'s whole trick. + * @param {THREE.Vector3} up the sheets' up axis, re-orthogonalised. Pass + * a negated `frame.uLightDir` for slanted shafts. + */ + setPlacement(anchor, along, up) { + const u = this._layoutUniforms; + u.uAnchor.value.copy(anchor); + _ax.copy(along).normalize(); + _az.crossVectors(up, _ax).normalize(); + _ay.crossVectors(_ax, _az).normalize(); + u.uAxisX.value.copy(_ax); + u.uAxisY.value.copy(_ay); + u.uAxisZ.value.copy(_az); + return this; + } + + /** + * Re-roll the per-sheet dice. Call from `onSpawn` and nowhere else. + * + * The dice are the only thing a sheet carries and every one of them is + * unitless: two for its place on the scatter disc, one for its bearing, four + * for jitter/width/height/lean, one for phase. Where that lands it in metres + * is decided by `update()` every frame, which is why a paused curtain re-lays + * itself under `spacing` and `radius`. + * + * @param {number} [seed] per-cast seed; shifts the whole layout + */ + roll(seed = Math.random() * 100) { + this._layoutUniforms.uSeed.value = seed; + for (let i = 0; i < this.capacity; i++) { + const o = i * 4; + this._diceA[o + 0] = Math.random(); + this._diceA[o + 1] = Math.random(); + this._diceA[o + 2] = Math.random(); + this._diceA[o + 3] = Math.random(); + this._diceB[o + 0] = Math.random(); + this._diceB[o + 1] = Math.random(); + this._diceB[o + 2] = Math.random(); + this._diceB[o + 3] = Math.random(); + this._seeds[i] = Math.random(); + } + this.geometry.getAttribute('aDiceA').needsUpdate = true; + this.geometry.getAttribute('aDiceB').needsUpdate = true; + this.geometry.getAttribute('aSeed').needsUpdate = true; + + if (this._sheetDice) { + for (let i = 0; i < FLOOR_SHEETS; i++) { + const src = (i < this.capacity ? i : 0) * 4; + this._sheetDice[i].set( + this._diceA[src + 0], + this._diceA[src + 1], + this._diceA[src + 2], + this._diceA[src + 3] + ); + this._sheetJitter[i] = this._diceB[src + 0]; + } + } + return this; + } + + /** Hide it. Leaves the instance reusable — the pooling contract. */ + reset() { + this.geometry.instanceCount = 0; + this.group.visible = false; + return this; + } + + /* ---------------- the frame ---------------- */ + + /** + * Re-resolve every dimension. Every frame, zero-length ones included. + * + * *the layout* — `count`, `spacing` (metres, LINE), `radius` (metres, RING and + * SCATTER), `scatter` (metres), `seed`. + * + * *the sheet* — `width` / `widthJitter`, `height` / `heightJitter`, `base` + * (metres above the anchor), `taper` (width multiplier at the head), `lean` / + * `leanJitter` (metres), `rise` (0..1) and `riseSpread`. + * + * *the ripple* — `rippleAmp` (metres), `rippleLength` (metres), `rippleSpeed` + * (m/s), `rippleCurve`, the slower `foldAmp` / `foldLength` / `foldSpeed`, the + * `rippleNoise` / `rippleNoiseScale` / `rippleNoiseSpeed` slop, `phaseSpread`. + * + * *the two curves* — `alphaBase` / `alphaTop` / `alphaCurve` against + * `emissionBase` / `emissionTop` / `emissionCurve`. **Make them disagree.** + * + * *the envelope* — `body` (0..1 how much α the sheet may have at all), + * `footFade`, `headFade`, `edgeFade`, `graze`, `grazeFloor`, `softFade`, + * `opacity`, `glow`, `tintSpread`. + * + * *RAIN* — `streakDensity` (lanes/metre), `streakRepeat`, `streakSpeed`, + * `streakSpeedJitter`, `streakWidth`, `streakTail`, `streakDuty`, `haze`. + * + * *AURORA* — `rayScale`, `raySpeed`, `raySharp`, `bandScale`, `bandSpeed`, + * `hem`. + * + * *SHAFT* — `coreWidth`, `canopy`, `canopySoft`, `canopyScale`, `mote`, + * `moteScale`, `moteSize`, `moteDrift`. + * + * *colour* — `colorA`, `colorB`, `colorC` (the band mix), `colorHem`, + * `colorCore`, `colorMote`, `colorBody` (what it occludes with — independent + * of everything it emits). + * + * *the floor* — `floorSize`, `floorFade`, `wet`, `wetDark`, `sheen`, + * `sheenRough`, `sheenSpeed`, `floorFresnel`, `pool`, `poolWidth`, + * `poolLength`, `poolSoft`, `rings`, `ringScale`, `ringPeriod`, `ringRadius`, + * `ringWidth`, `floorOpacity`, `colorWet`, `colorPool`, `colorRing`. + * + * @param {number} _now seconds since the cast began. Accepted for symmetry + * with the rest of the library; a curtain's motion is a standing one and + * rides the shared `uTime`, so it does not restart on every cast. + * @param {object} p a plain object. Keep it at module scope and refill it. + */ + update(_now, p) { + const u = this.material.uniforms; + const l = this._layoutUniforms; + + const count = Math.max(0, Math.min(this.capacity, Math.round(num(p.count, DEFAULTS.count)))); + this.geometry.instanceCount = count; + l.uCount.value = count; + l.uSpacing.value = num(p.spacing, DEFAULTS.spacing); + l.uRadius.value = num(p.radius, DEFAULTS.radius); + l.uScatter.value = num(p.scatter, DEFAULTS.scatter); + if (p.seed !== undefined && p.seed !== null) l.uSeed.value = p.seed; + + u.uWidth.value = num(p.width, DEFAULTS.width); + u.uWidthJitter.value = num(p.widthJitter, DEFAULTS.widthJitter); + u.uHeight.value = num(p.height, DEFAULTS.height); + u.uHeightJitter.value = num(p.heightJitter, DEFAULTS.heightJitter); + u.uBase.value = num(p.base, DEFAULTS.base); + u.uTaper.value = num(p.taper, DEFAULTS.taper); + u.uLean.value = num(p.lean, DEFAULTS.lean); + u.uLeanJitter.value = num(p.leanJitter, DEFAULTS.leanJitter); + u.uRise.value = num(p.rise, DEFAULTS.rise); + // Hard-capped below 1: at 1 the stagger's denominator is zero and every + // sheet either pops in on frame one or never appears at all. + u.uRiseSpread.value = Math.min(num(p.riseSpread, DEFAULTS.riseSpread), 0.95); + + u.uRippleAmp.value = num(p.rippleAmp, DEFAULTS.rippleAmp); + u.uRippleLength.value = num(p.rippleLength, DEFAULTS.rippleLength); + u.uRippleSpeed.value = num(p.rippleSpeed, DEFAULTS.rippleSpeed); + u.uRippleCurve.value = num(p.rippleCurve, DEFAULTS.rippleCurve); + u.uFoldAmp.value = num(p.foldAmp, DEFAULTS.foldAmp); + u.uFoldLength.value = num(p.foldLength, DEFAULTS.foldLength); + u.uFoldSpeed.value = num(p.foldSpeed, DEFAULTS.foldSpeed); + u.uRippleNoise.value = num(p.rippleNoise, DEFAULTS.rippleNoise); + u.uRippleNoiseScale.value = num(p.rippleNoiseScale, DEFAULTS.rippleNoiseScale); + u.uRippleNoiseSpeed.value = num(p.rippleNoiseSpeed, DEFAULTS.rippleNoiseSpeed); + u.uPhaseSpread.value = num(p.phaseSpread, DEFAULTS.phaseSpread); + + u.uAlphaBase.value = num(p.alphaBase, DEFAULTS.alphaBase); + u.uAlphaTop.value = num(p.alphaTop, DEFAULTS.alphaTop); + u.uAlphaCurve.value = num(p.alphaCurve, DEFAULTS.alphaCurve); + u.uEmissionBase.value = num(p.emissionBase, DEFAULTS.emissionBase); + u.uEmissionTop.value = num(p.emissionTop, DEFAULTS.emissionTop); + u.uEmissionCurve.value = num(p.emissionCurve, DEFAULTS.emissionCurve); + + u.uBody.value = num(p.body, DEFAULTS.body); + u.uFootFade.value = num(p.footFade, DEFAULTS.footFade); + u.uHeadFade.value = num(p.headFade, DEFAULTS.headFade); + u.uEdgeFade.value = num(p.edgeFade, DEFAULTS.edgeFade); + u.uGraze.value = num(p.graze, DEFAULTS.graze); + u.uGrazeFloor.value = num(p.grazeFloor, DEFAULTS.grazeFloor); + u.uSoftFade.value = num(p.softFade, DEFAULTS.softFade); + + u.uStreakDensity.value = num(p.streakDensity, DEFAULTS.streakDensity); + u.uStreakRepeat.value = num(p.streakRepeat, DEFAULTS.streakRepeat); + u.uStreakSpeed.value = num(p.streakSpeed, DEFAULTS.streakSpeed); + u.uStreakSpeedJitter.value = num(p.streakSpeedJitter, DEFAULTS.streakSpeedJitter); + u.uStreakWidth.value = num(p.streakWidth, DEFAULTS.streakWidth); + u.uStreakTail.value = num(p.streakTail, DEFAULTS.streakTail); + u.uStreakDuty.value = num(p.streakDuty, DEFAULTS.streakDuty); + u.uHaze.value = num(p.haze, DEFAULTS.haze); + + u.uRayScale.value = num(p.rayScale, DEFAULTS.rayScale); + u.uRaySpeed.value = num(p.raySpeed, DEFAULTS.raySpeed); + u.uRaySharp.value = num(p.raySharp, DEFAULTS.raySharp); + u.uBandScale.value = num(p.bandScale, DEFAULTS.bandScale); + u.uBandSpeed.value = num(p.bandSpeed, DEFAULTS.bandSpeed); + u.uHem.value = num(p.hem, DEFAULTS.hem); + + u.uCoreWidth.value = num(p.coreWidth, DEFAULTS.coreWidth); + u.uCanopy.value = num(p.canopy, DEFAULTS.canopy); + u.uCanopySoft.value = num(p.canopySoft, DEFAULTS.canopySoft); + u.uCanopyScale.value = num(p.canopyScale, DEFAULTS.canopyScale); + u.uMote.value = num(p.mote, DEFAULTS.mote); + u.uMoteScale.value = num(p.moteScale, DEFAULTS.moteScale); + u.uMoteSize.value = num(p.moteSize, DEFAULTS.moteSize); + u.uMoteDrift.value = num(p.moteDrift, DEFAULTS.moteDrift); + + u.uTintSpread.value = num(p.tintSpread, DEFAULTS.tintSpread); + u.uGlow.value = num(p.glow, DEFAULTS.glow); + u.uOpacity.value = num(p.opacity, DEFAULTS.opacity); + + copyColor(u.uColorA.value, p.colorA || DEFAULTS.colorA); + copyColor(u.uColorB.value, p.colorB || DEFAULTS.colorB); + copyColor(u.uColorC.value, p.colorC || DEFAULTS.colorC); + copyColor(u.uColorHem.value, p.colorHem || DEFAULTS.colorHem); + copyColor(u.uColorCore.value, p.colorCore || DEFAULTS.colorCore); + copyColor(u.uColorMote.value, p.colorMote || DEFAULTS.colorMote); + copyColor(u.uColorBody.value, p.colorBody || DEFAULTS.colorBody); + + if (this.floorMaterial) { + const f = this.floorMaterial.uniforms; + f.uFloorSize.value = num(p.floorSize, DEFAULTS.floorSize); + f.uFloorFade.value = num(p.floorFade, DEFAULTS.floorFade); + f.uWet.value = num(p.wet, DEFAULTS.wet); + f.uWetDark.value = num(p.wetDark, DEFAULTS.wetDark); + f.uSheen.value = num(p.sheen, DEFAULTS.sheen); + f.uSheenRough.value = num(p.sheenRough, DEFAULTS.sheenRough); + f.uSheenSpeed.value = num(p.sheenSpeed, DEFAULTS.sheenSpeed); + f.uFresnel.value = num(p.floorFresnel, DEFAULTS.floorFresnel); + f.uPool.value = num(p.pool, DEFAULTS.pool); + f.uPoolWidth.value = num(p.poolWidth, DEFAULTS.poolWidth); + f.uPoolLength.value = num(p.poolLength, DEFAULTS.poolLength); + f.uPoolSoft.value = num(p.poolSoft, DEFAULTS.poolSoft); + f.uRings.value = num(p.rings, DEFAULTS.rings); + f.uRingScale.value = num(p.ringScale, DEFAULTS.ringScale); + f.uRingPeriod.value = num(p.ringPeriod, DEFAULTS.ringPeriod); + f.uRingRadius.value = num(p.ringRadius, DEFAULTS.ringRadius); + f.uRingWidth.value = num(p.ringWidth, DEFAULTS.ringWidth); + f.uFloorOpacity.value = num(p.floorOpacity, DEFAULTS.floorOpacity); + // The pools have to follow the sheets' live width, not a captured one. + f.uSheetHalf.value = num(p.width, DEFAULTS.width) * 0.5; + copyColor(f.uColorWet.value, p.colorWet || DEFAULTS.colorWet); + copyColor(f.uColorPool.value, p.colorPool || DEFAULTS.colorPool); + copyColor(f.uColorRing.value, p.colorRing || DEFAULTS.colorRing); + } + + return this; + } + + /* ---------------- read-back ---------------- */ + + /** + * A point on sheet `index`, in world metres — where to hang an emitter. + * + * Mirrors `sheetFrame()` exactly, because the dice it reads are the same + * floats the vertex shader reads. It deliberately leaves out the travelling + * ripple: you want droplets leaving a clean line, and sampling the ripple + * would jitter every emitter by the sheet's own fold, which reads as a fault + * in the emitter rather than as motion in the curtain. + * + * @param {number} index 0..count-1 + * @param {object} p the same live params `update()` was given + * @param {THREE.Vector3} out written in place + * @param {number} [across] −1..1 across the sheet's width + * @param {number} [height] 0..1 up the sheet + */ + sheetPoint(index, p, out, across = 0, height = 0) { + const l = this._layoutUniforms; + const n = Math.max(1, Math.min(this.capacity, Math.round(num(p.count, DEFAULTS.count)))); + const slot = Math.max(0, Math.min(n - 1, index | 0)); + const o = slot * 4; + + const dRadius = this._diceA[o + 0]; + const dAngle = this._diceA[o + 1]; + const dBearing = this._diceA[o + 2]; + const dJitterAlong = this._diceA[o + 3]; + const dJitterAcross = this._diceB[o + 0]; + + const ax = l.uAxisX.value; + const ay = l.uAxisY.value; + const az = l.uAxisZ.value; + const radius = num(p.radius, DEFAULTS.radius); + const spacing = num(p.spacing, DEFAULTS.spacing); + + if (l.uLayout.value === CurtainLayout.RING) { + const a = ((slot + 0.5) / n) * Math.PI * 2; + const c = Math.cos(a); + const s = Math.sin(a); + _norm.copy(ax).multiplyScalar(c).addScaledVector(az, s); + _along.copy(ax).multiplyScalar(-s).addScaledVector(az, c); + _centre.copy(l.uAnchor.value).addScaledVector(_norm, radius); + } else if (l.uLayout.value === CurtainLayout.SCATTER) { + const rr = radius * Math.sqrt(Math.min(Math.max(dRadius, 0), 1)); + const aa = dAngle * Math.PI * 2; + _centre + .copy(l.uAnchor.value) + .addScaledVector(ax, Math.cos(aa) * rr) + .addScaledVector(az, Math.sin(aa) * rr); + const b = dBearing * Math.PI * 2; + _along.copy(ax).multiplyScalar(Math.cos(b)).addScaledVector(az, Math.sin(b)); + _norm.crossVectors(_along, ay).normalize(); + } else { + _centre.copy(l.uAnchor.value).addScaledVector(ax, (slot - (n - 1) * 0.5) * spacing); + _along.copy(ax); + _norm.copy(az); + } + + const scatter = num(p.scatter, DEFAULTS.scatter); + _centre + .addScaledVector(_along, (dJitterAlong - 0.5) * scatter * 2) + .addScaledVector(_norm, (dJitterAcross - 0.5) * scatter * 2); + + const hh = Math.min(Math.max(height, 0), 1); + const w = + num(p.width, DEFAULTS.width) * + (1 + (this._diceB[o + 1] - 0.5) * 2 * num(p.widthJitter, DEFAULTS.widthJitter)) * + (1 + (num(p.taper, DEFAULTS.taper) - 1) * hh); + const H = + num(p.height, DEFAULTS.height) * + (1 + (this._diceB[o + 2] - 0.5) * 2 * num(p.heightJitter, DEFAULTS.heightJitter)); + const lean = + num(p.lean, DEFAULTS.lean) * + (1 + (this._diceB[o + 3] - 0.5) * 2 * num(p.leanJitter, DEFAULTS.leanJitter)); + + out + .copy(_centre) + .addScaledVector(_along, across * w * 0.5) + .addScaledVector(ay, num(p.base, DEFAULTS.base) + hh * H) + .addScaledVector(_norm, lean * hh); + return out; + } + + dispose() { + this.group.visible = false; + this.geometry.dispose(); + this.material.dispose(); + if (this.floorMesh) { + this.floorMesh.geometry.dispose(); + this.floorMaterial.dispose(); + } + this.group.parent?.remove(this.group); + } +} diff --git a/src/vfx/Dissolve.js b/src/vfx/Dissolve.js new file mode 100644 index 0000000..dc13f0e --- /dev/null +++ b/src/vfx/Dissolve.js @@ -0,0 +1,1363 @@ +import { + BufferAttribute, + BufferGeometry, + Color, + DoubleSide, + Group, + Mesh, + ShaderMaterial, + Sphere, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { sharedUniforms, frame } from '../core/FrameUniforms.js'; +import { patchOnBeforeCompile, replaceChunk } from '../utils/shaderPatch.js'; +import { LAYER } from '../core/Layers.js'; +import { putColor } from '../utils/color.js'; + +/* ---------------------------------------------------------------------- */ +/* Dissolve — three ways for matter to stop being there */ +/* ---------------------------------------------------------------------- */ + +/** + * `unmake` takes a thing apart into cubes. `avalanche` buries the floor under a + * heap of snow that keeps collapsing over itself. `entropy` ages a surface until + * it is not there any more. Three abilities, one idea — **something is being + * lost, and the loss has a shape** — and three shapes that could not be less + * alike. + * + * ## Why this module has two entry points and not one + * + * `VOXEL` and `EROSION` are ways of *taking away* something that already exists. + * The geometry is not this module's, the material is not this module's, and the + * ability that reaches for them already has both — so they are a **patch**: + * `patchDissolveMaterial(material, …)` injects into whatever the caller is + * already drawing, and the caller keeps its own look. + * + * `GRANULAR` is the opposite. Nothing in the scene is heap-shaped, so the heap + * has to be **drawn**, and that is a class with a mesh in it. One class doing + * both meant either an object that owned a mesh half its callers threw away or a + * patch that had to invent a surface out of nothing. The seam is here because + * the two halves genuinely differ. + * + * ``` + * patchDissolveMaterial(mat, { mode: DissolveMode.VOXEL }) // + EROSION + * new DissolveField(group, { … }) // GRANULAR + * ``` + * + * Both read the same canonical params objects (`dissolveParams()` / + * `heapParams()`) every frame and resolve every metre from them, so a paused + * slider drag re-cuts a standing dissolve and re-slumps a standing heap. + * + * --- + * + * ## VOXEL — the cube size grows, which is why it reads as *accelerating* + * + * Matter comes apart into cubes that drift and wink out, and **the cubes get + * bigger as it goes**. That is the whole ability: losing eight small cubes and + * losing one cube eight times the size are the same volume, but the second one + * looks like the thing is going faster, because the eye counts events and not + * litres. + * + * It is done entirely in the vertex shader against a lattice whose cell size is + * a uniform. **No CPU rebuild, ever** — the geometry is untouched and the mesh + * is the mesh the ability was already drawing. + * + * The first version slid the cell size up continuously. It does not work, and + * the reason is worth writing down: a lattice with a sliding cell size + * re-partitions the mesh *every frame*, so a chunk that was drifting away as + * part of one cell is suddenly half of two others and the whole surface + * visibly reshuffles, twice a second, like a bad mosaic filter. + * + * The fix is a **power-of-two ladder walked once per vertex**: + * + * ``` + * for rung r = 0 … rungs-1: + * s = cell · 2^r // this rung's cell size + * id = floor(p / s) // which cell of it this vertex is in + * if hash(id, r) < take or r is last: + * this vertex belongs to THIS cell, for ever. Stop. + * it lets go at t = (r + hash2(id, r)) / rungs + * ``` + * + * Every vertex is claimed exactly once, by the *finest* rung that wants it, and + * the claim depends only on the vertex's rest position and the hashes — so it + * never changes, never re-partitions, and needs no state. Material that rung 0 + * did not claim is still there when rung 1 comes round and gets taken away in + * pieces twice the size, at twice the size again on rung 2. The acceleration is + * not animated; it falls out of the ladder. + * + * **The voxel lattice is always object space**, and it is not the same choice as + * the erosion field's `space`. It has to be: the displacement it produces is + * added to `transformed`, which is object space, and a lattice in world + * coordinates would hand a rotating mesh a drift direction that swings round + * with it. Two consequences to know about — `cell` is metres *in the mesh's own + * units*, so a mesh with a non-unit scale scales its cubes with it; and every + * instance of an `InstancedMesh` comes apart identically, which is usually what + * you want from a field of one thing and is otherwise a per-instance `seed`. + * + * A claimed cell then: snaps its vertices toward a sub-lattice (`block`, which + * is what actually makes the chunk *cubic* rather than merely detached), + * tumbles about its own centre, drifts, lifts, falls, and finally scales to + * nothing **about the cell centre** — so it disappears cleanly instead of + * collapsing into a spike of stray triangles. + * + * ## EROSION — and the world/local question, which has a real answer + * + * A threshold on a noise field with a bright band above it. `dissolveMask()` in + * `shaders/lib/common.glsl.js` does exactly this and is not used here, because + * that chunk pulls in `` and injecting it into a built-in material + * defines the depth helpers twice; the two lines are re-stated locally. + * + * The field can be sampled in **local** or **world** space and it matters: + * + * - **LOCAL (the default)** — the pattern is glued to the object. A thing being + * consumed keeps its burn on the same part of itself however it moves, which + * is what you want when the *object* is the event. Use it for anything that + * travels, spins or is instanced-and-scattered. + * - **WORLD** — the pattern is glued to the room. Several separate meshes handed + * the same uniform box dissolve as **one event**: the front crosses all of + * them consistently and the seams between them stop existing, which is the + * only way `unmake` can eat a standing ice field that is nine draw calls. + * The cost is that a moving mesh *slides through* the pattern, so the burn + * crawls over its surface. On something slow that reads as the room eating it; + * on something fast it reads as a bad projection. Do not use world space on a + * projectile. + * + * `biasDirection` adds `dot(p, dir) · biasAmount` to the threshold, which turns + * the noise into a **front** sweeping a chosen way rather than a rash breaking + * out everywhere at once. + * + * ## GRANULAR — a heap, not a wave + * + * The roster's line for `avalanche` is "the front is not a wave; it is a heap + * that keeps collapsing forward over itself", and a heightfield with a sine in + * it will never be that. What makes a heap a heap is the **angle of repose**: + * granular material piles until its surface reaches a critical slope and then + * refuses to get any steeper, so it grows sideways instead of upward. + * + * So the surface is the **upper envelope of a train of collapsing cones**: + * + * - a *lobe* is released every `1/rate` seconds at the front, which is at + * `frontSpeed · t`. Its identity is its release **ordinal**, not its slot in + * the loop, so a lobe keeps its own dice as it ages out of the window — the + * version that hashed the slot index gave every lobe a new size and place + * each time the window shifted, and the heap boiled; + * - it is born **over-steep** (`repose + excess`) and relaxes back toward repose + * at `slump` per second, which is genuinely what wet snow and dry sand both + * do. With its volume held, that means it *gets shorter and wider on its own*: + * `H = ∛(3V·tan²φ/π)`, `radius = H/tanφ`. One exponential and a cube root, and + * the lobe collapses; + * - the surface is the **max** of the lobes, not the sum. Heaps merge by taking + * the upper envelope — that is what a repose surface *is* — and summing them + * gives a smooth mound with no ridges, which is a pudding. The creases where + * two lobes meet are the whole texture of the thing; + * - each lobe's centroid also creeps forward at `creep` while it collapses, so + * new lobes land **on top of** older ones and slide over them. That is the + * "collapsing forward over itself" in the roster line, and it is one term. + * + * Nothing is integrated and nothing is stored. Every lobe is a closed-form + * function of `now − birth`, so a paused heap re-slumps under `repose` — pull + * the repose angle down with the clock stopped and the whole avalanche flattens + * and spreads, which is a thing worth doing once just to watch. + * + * ## Cost + * + * The patch is **free** — no extra draw call, it is the caller's mesh. The heap + * is **one**. No textures anywhere. + * + * ## Invariants + * + * - **I1** — nothing is captured. Cell sizes, lobe volumes, repose angles and + * drift distances all re-resolve from the params every frame, including a + * zero-length one. + * - **I3** — the patch allocates its uniform box once; `syncDissolve()` and + * `DissolveField.update()` write into existing boxes. + * - **I5** — every dimension a slider, every colour a picker. + * - **I8** — `patchDissolveMaterial()` parks its box on + * `material.userData.uniforms`, or the harness's pause test reports forty + * working sliders as dead. + * + * @example + * // unmake: dissolve someone else's standing field as one event + * const box = dissolveUniforms({ space: DissolveSpace.WORLD }); + * for (const m of field.materials) patchDissolveMaterial(m, { uniforms: box }); + * // every frame + * syncDissolve(box, this._params); // params.progress drives all of them + */ + +const PI = Math.PI; + +/** Rungs of the voxel ladder. Six doublings is 64× the cell — more than enough. */ +export const MAX_RUNGS = 6; + +/** Lobes alive in the heap at once. The loop bound, and it is a real cost. */ +export const MAX_LOBES = 24; + +/* ---------------------------------------------------------------- */ +/* Enums */ +/* ---------------------------------------------------------------- */ + +export const DissolveMode = Object.freeze({ + /** Cubes, growing as it goes. A patch. */ + VOXEL: 0, + /** A heap at its angle of repose, collapsing forward. A mesh — `DissolveField`. */ + GRANULAR: 1, + /** A noise threshold with a burning edge. A patch. */ + EROSION: 2 +}); + +/** Where the erosion field is nailed down. See the class header — this matters. */ +export const DissolveSpace = Object.freeze({ + /** Glued to the object. The default, and right for anything that moves. */ + LOCAL: 0, + /** Glued to the room. Several meshes dissolve as one event. */ + WORLD: 1 +}); + +/* ---------------------------------------------------------------------- */ +/* The patch: VOXEL + EROSION */ +/* ---------------------------------------------------------------------- */ + +/** + * Declarations, shared by both stages of a patched material. + * + * NOTE for anyone editing this chunk: no backticks in these comments. The file + * is one template literal and a stray backtick ends it, reporting as a + * JavaScript syntax error pointing into the middle of the shader. + */ +const DISSOLVE_UNIFORMS = /* glsl */ ` +#ifndef DISSOLVE_UNIFORMS_INCLUDED +#define DISSOLVE_UNIFORMS_INCLUDED + #define DISSOLVE_RUNGS ${MAX_RUNGS} + + uniform float uDisProgress; // 0 intact, 1 gone. The one slider. + uniform float uDisSeed; + uniform float uDisSpace; // 0 local, 1 world + + uniform float uDisVoxel; // 0..1 — how much of the cube behaviour is on + uniform float uDisCell; // metres — the FINEST rung + uniform float uDisRungs; // doublings the ladder walks, 1..DISSOLVE_RUNGS + uniform float uDisTake; // fraction of cells a rung claims + uniform float uDisSpan; // progress units one cell takes to let go + uniform float uDisBlock; // 0..1 — how far a chunk snaps to the lattice + uniform float uDisFacet; // sub-lattice size, fraction of the cell + uniform float uDisHold; // 0..1 of a cell's life before it starts shrinking + uniform float uDisDrift; // metres a chunk travels + uniform vec3 uDisDriftBias; // unitless direction preference + uniform float uDisLift; // metres up + uniform float uDisGravity; // metres of fall, on k squared + uniform float uDisTumble; // radians a chunk turns + uniform float uDisWobble; // metres of hashed wander + uniform float uDisWobbleRate; // radians/second + + uniform float uDisErode; // 0..1 — how much of the threshold behaviour is on + uniform float uDisNoiseScale; // features per metre + uniform float uDisWarp; // metres the field is domain-warped by + uniform float uDisEdge; // width of the burning band, noise units + uniform vec3 uDisBiasDir; // unit-ish; turns the rash into a front + uniform float uDisBiasAmount; // threshold shift per metre along it + + uniform vec3 uDisColorEdge; // the burning edge + uniform vec3 uDisColorEmber; // a chunk lighting up as it lets go + uniform float uDisGlow; +#endif +`; + +/** + * The vertex half. Two entry points because three computes the normal before it + * computes the position, and the chunk has to be able to turn both. + */ +const DISSOLVE_VERTEX = /* glsl */ ` +#ifndef DISSOLVE_VERTEX_INCLUDED +#define DISSOLVE_VERTEX_INCLUDED + varying float vDisK; // 0..1 — how far through letting go this chunk is + varying float vDisRung; // which rung claimed it + varying float vDisRand; // its own die + varying vec3 vDisLocal; // rest position, object space + varying vec3 vDisWorld; // rest position, world space + + /* Filled by dissolveResolve(), read by the two appliers. Globals rather than + out-params so the two injection sites can share one evaluation. */ + vec3 gDisCentre; + vec3 gDisAxis; + float gDisAngle; + float gDisK; + float gDisCell; + + vec3 disSpin(vec3 v, vec3 axis, float a) { + float c = cos(a); + float s = sin(a); + return v * c + cross(axis, v) * s + axis * dot(axis, v) * (1.0 - c); + } + + /** + * Walk the ladder and find the cell that owns this vertex. + * + * Runs once per vertex. The claim depends on nothing but the rest position and + * the hashes, so it is stable for the whole cast — which is the property the + * sliding-cell-size version did not have and the reason it reshuffled. + */ + void dissolveResolve(vec3 rest) { + gDisK = 0.0; + gDisCell = uDisCell; + gDisCentre = rest; + gDisAxis = vec3(0.0, 1.0, 0.0); + gDisAngle = 0.0; + vDisRung = 0.0; + vDisRand = 0.0; + if (uDisVoxel <= 0.001) return; + + float rungs = max(uDisRungs, 1.0); + float cell = max(uDisCell, 1e-4); + + for (int r = 0; r < DISSOLVE_RUNGS; r++) { + float rf = float(r); + if (rf >= rungs) break; + float s = cell * exp2(rf); + vec3 id = floor(rest / s); + float pick = hash13(id * 0.731 + vec3(rf * 19.7 + uDisSeed)); + // The last rung claims whatever is left, or material with an unlucky hash + // would survive the dissolve entirely and hang in the air for ever. + float last = step(rungs - 1.5, rf); + if (pick < uDisTake || last > 0.5) { + float when = (rf + hash13(id * 1.317 + vec3(rf * 7.3 + uDisSeed + 31.0))) / rungs; + gDisK = clamp((uDisProgress - when) / max(uDisSpan, 1e-3), 0.0, 1.0); + gDisCell = s; + gDisCentre = (id + 0.5) * s; + float a0 = hash13(id + vec3(uDisSeed + 3.1)); + float a1 = hash13(id + vec3(uDisSeed + 8.9)); + gDisAxis = normalize(vec3(a0 - 0.5, a1 - 0.5, hash13(id + vec3(uDisSeed + 15.7)) - 0.5) + + vec3(1e-4, 1.0, 1e-4) * 0.001); + gDisAngle = uDisTumble * gDisK * gDisK; + vDisRung = rf; + vDisRand = a0; + break; + } + } + vDisK = gDisK; + } + + /** The normal turns with the chunk. Called from beginnormal_vertex. */ + vec3 dissolveNormal(vec3 n) { + if (gDisAngle == 0.0) return n; + return disSpin(n, gDisAxis, gDisAngle); + } + + /** The position. Called from begin_vertex, after dissolveResolve(). */ + vec3 dissolveVertex(vec3 p, float time) { + if (gDisK <= 0.0) return p; + float k = gDisK; + + // Cube it. Snapping to a sub-lattice is what turns a detached chunk into a + // *cube*: coplanar faces become axis-aligned steps and the silhouette goes + // blocky. The mix ramps in over the first sliver of the chunk's life so the + // surface does not visibly crystallise before it has started to move. + float grid = max(gDisCell * uDisFacet, 1e-5); + vec3 snapped = floor(p / grid + 0.5) * grid; + p = mix(p, snapped, clamp(uDisBlock, 0.0, 1.0) * smoothstep(0.0, 0.18, k)); + + // Turn about its own centre. + p = gDisCentre + disSpin(p - gDisCentre, gDisAxis, gDisAngle); + + // Then let it go. Drift on k, the fall on k squared — one is a push and the + // other is gravity, and giving them the same curve is why an early version + // looked like the cubes were on strings. + vec3 jitter = vec3(hash13(gDisCentre * 3.7 + uDisSeed) - 0.5, + hash13(gDisCentre * 5.1 + uDisSeed + 4.0) - 0.5, + hash13(gDisCentre * 7.3 + uDisSeed + 9.0) - 0.5); + vec3 dir = normalize(jitter + uDisDriftBias + vec3(1e-5, 1e-5, 1e-5)); + p += dir * (uDisDrift * k); + p += vec3(0.0, uDisLift * k - uDisGravity * k * k, 0.0); + p += jitter * (uDisWobble * k * sin(time * uDisWobbleRate + jitter.x * 20.0)); + + // Wink out: scale to nothing about the cell centre so the chunk vanishes + // whole. Collapsing it toward the object's origin instead leaves a spray of + // stretched triangles crossing the model, which is what the first one did. + float shrink = 1.0 - smoothstep(clamp(uDisHold, 0.0, 0.98), 1.0, k); + p = gDisCentre + (p - gDisCentre) * shrink; + return p; + } +#endif +`; + +/** + * The fragment half: the threshold, the burning edge and the ember on a chunk + * that has just let go. + */ +const DISSOLVE_FRAGMENT = /* glsl */ ` +#ifndef DISSOLVE_FRAGMENT_INCLUDED +#define DISSOLVE_FRAGMENT_INCLUDED + varying float vDisK; + varying float vDisRung; + varying float vDisRand; + varying vec3 vDisLocal; + varying vec3 vDisWorld; + + /** + * The erosion field. + * + * Sampled in local or world space by a mix, not a branch: both are already + * varyings, the compiler folds one away when the uniform is constant, and a + * branch here costs more than the lerp does. + */ + float dissolveField(out float threshold) { + vec3 sp = mix(vDisLocal, vDisWorld, clamp(uDisSpace, 0.0, 1.0)); + vec3 q = sp * max(uDisNoiseScale, 1e-3); + // One warp octave: an unwarped fbm threshold gives round holes, and round + // holes read as a texture. Warped, the holes get the ragged re-entrant edge + // that says something is eating this. + q += uDisWarp * uDisNoiseScale * vec3(snoise(q * 0.7 + 11.0), snoise(q * 0.7 + 27.0), 0.0); + float n = fbm3(q) * 0.5 + 0.5; + // The threshold sweeps past 1 + edge so the last of the surface goes too, + // and the directional bias turns a rash into a front. + float sweep = uDisProgress * (1.0 + uDisEdge * 2.0) - uDisEdge; + threshold = sweep + dot(sp, uDisBiasDir) * uDisBiasAmount; + return n; + } +#endif +`; + +/** + * Exported so a bespoke `ShaderMaterial` can inject the same chunks by hand. + * + * Inject `noiseGLSL` **first** — these chunks call `hash13`, `snoise` and + * `fbm3` and do not carry them, because a material that already has the noise + * library would otherwise get it twice. All three are `#ifndef`-guarded, so + * injecting them into a material that `patchDissolveMaterial()` has also + * touched compiles; it will, however, displace the vertex twice, which is a + * different mistake and one you can see. + */ +export const DISSOLVE_GLSL = Object.freeze({ + uniforms: DISSOLVE_UNIFORMS, + vertex: DISSOLVE_VERTEX, + fragment: DISSOLVE_FRAGMENT +}); + +/** + * Build a uniform box for the patch. + * + * **Share it by identity** across every material that should dissolve as one + * event — that is the whole reason it is a separate function rather than + * something the patch hides. One `syncDissolve()` then drives nine draw calls' + * worth of standing ice, and because three stores uniforms as `{ value }` boxes + * it costs one write, not nine. + * + * @param {object} [overrides] any key of `dissolveParams()` + */ +export function dissolveUniforms(overrides = {}) { + const p = { ...DISSOLVE_DEFAULTS, ...overrides }; + return { + uDisProgress: { value: p.progress }, + uDisSeed: { value: p.seed }, + uDisSpace: { value: p.space }, + + uDisVoxel: { value: p.voxel }, + uDisCell: { value: p.cell }, + uDisRungs: { value: p.rungs }, + uDisTake: { value: p.take }, + uDisSpan: { value: p.span }, + uDisBlock: { value: p.block }, + uDisFacet: { value: p.facet }, + uDisHold: { value: p.hold }, + uDisDrift: { value: p.drift }, + uDisDriftBias: { value: new Vector3(p.driftBiasX, p.driftBiasY, p.driftBiasZ) }, + uDisLift: { value: p.lift }, + uDisGravity: { value: p.gravity }, + uDisTumble: { value: p.tumble }, + uDisWobble: { value: p.wobble }, + uDisWobbleRate: { value: p.wobbleRate }, + + uDisErode: { value: p.erode }, + uDisNoiseScale: { value: p.noiseScale }, + uDisWarp: { value: p.warp }, + uDisEdge: { value: p.edge }, + uDisBiasDir: { value: new Vector3(p.biasX, p.biasY, p.biasZ) }, + uDisBiasAmount: { value: p.biasAmount }, + + uDisColorEdge: { value: new Color(p.colorEdge) }, + uDisColorEmber: { value: new Color(p.colorEmber) }, + uDisGlow: { value: p.glow } + }; +} + +/** + * Push a live params block into a uniform box. + * + * @param {object} target the box from `dissolveUniforms()`, or a material that + * was patched (its box is on `material.userData.uniforms`) + * @param {object} params live block; anything absent falls back to + * `dissolveParams()` + */ +export function syncDissolve(target, params) { + const u = target?.userData?.uniforms ?? target; + if (!u || !u.uDisProgress) return target; + const p = params ?? DISSOLVE_DEFAULTS; + const num = (key) => (p[key] === undefined ? DISSOLVE_DEFAULTS[key] : p[key]); + + u.uDisProgress.value = num('progress'); + u.uDisSeed.value = num('seed'); + u.uDisSpace.value = num('space'); + + u.uDisVoxel.value = num('voxel'); + u.uDisCell.value = num('cell'); + u.uDisRungs.value = Math.max(1, Math.min(MAX_RUNGS, Math.round(num('rungs')))); + u.uDisTake.value = num('take'); + u.uDisSpan.value = num('span'); + u.uDisBlock.value = num('block'); + u.uDisFacet.value = num('facet'); + u.uDisHold.value = num('hold'); + u.uDisDrift.value = num('drift'); + u.uDisDriftBias.value.set(num('driftBiasX'), num('driftBiasY'), num('driftBiasZ')); + u.uDisLift.value = num('lift'); + u.uDisGravity.value = num('gravity'); + u.uDisTumble.value = num('tumble'); + u.uDisWobble.value = num('wobble'); + u.uDisWobbleRate.value = num('wobbleRate'); + + u.uDisErode.value = num('erode'); + u.uDisNoiseScale.value = num('noiseScale'); + u.uDisWarp.value = num('warp'); + u.uDisEdge.value = num('edge'); + u.uDisBiasDir.value.set(num('biasX'), num('biasY'), num('biasZ')); + u.uDisBiasAmount.value = num('biasAmount'); + + putColor(u.uDisColorEdge.value, p.colorEdge, DISSOLVE_DEFAULTS.colorEdge); + putColor(u.uDisColorEmber.value, p.colorEmber, DISSOLVE_DEFAULTS.colorEmber); + u.uDisGlow.value = num('glow'); + return target; +} + +/** + * Inject `VOXEL` and `EROSION` into a built-in material. + * + * Works on anything three compiles from chunks — `MeshStandardMaterial` is the + * one it was written against and the one the abilities use. Chunks that are + * missing from a simpler material (`MeshBasicMaterial` has no + * `` and no ``) are **skipped rather + * than warned about**: the dissolve still cuts and still drifts, it just does + * not turn the normal or add the ember, and that is a reasonable thing for a + * basic material to do. + * + * Patching is composed through `patchOnBeforeCompile`, so this does not clobber + * a shadow-caster patch or CSM. + * + * @param {THREE.Material} material the caller's material. Mutated and returned. + * @param {object} [options] + * @param {number} [options.mode] `DissolveMode.VOXEL` or `.EROSION` — sets which + * half starts switched on. Both can run at once; the mode is a starting point, + * not an exclusion. + * @param {number} [options.space] `DissolveSpace.*` for the erosion field + * @param {object} [options.uniforms] an existing box from `dissolveUniforms()` + * to **share by identity**. Pass one and several materials dissolve as one + * event. Omit it and the material gets its own. + * @param {object} [options.environment] if given, the patch is registered with + * the shadow caster too, so a dissolving mesh's shadow dissolves with it + * @param {string} [options.vertex] extra GLSL after the displacement + * @param {string} [options.fragment] extra GLSL after the edge + */ +export function patchDissolveMaterial(material, options = {}) { + const { + mode = DissolveMode.VOXEL, + space = DissolveSpace.LOCAL, + uniforms = null, + environment = null, + vertex = '', + fragment = '' + } = options; + + const box = + uniforms ?? + dissolveUniforms({ + space, + voxel: mode === DissolveMode.EROSION ? 0 : 1, + erode: mode === DissolveMode.VOXEL ? 0 : 1 + }); + + const patch = (shader) => { + Object.assign(shader.uniforms, box); + + shader.vertexShader = replaceChunk( + shader.vertexShader, + '#include ', + `#include + ${noiseGLSL} + ${DISSOLVE_UNIFORMS} + ${DISSOLVE_VERTEX}` + ); + + // Normals first: three resolves them before it touches the position, so + // where the material has normals at all the ladder is walked there and the + // result kept in the globals. A global with no initialiser is *not* + // guaranteed to be zero in GLSL ES, so which site resolves is decided here, + // on the JavaScript side, rather than guarded by a runtime test. + const hasNormals = shader.vertexShader.includes('#include '); + if (hasNormals) { + shader.vertexShader = replaceChunk( + shader.vertexShader, + '#include ', + `#include + dissolveResolve(position); + objectNormal = dissolveNormal(objectNormal);` + ); + } + + shader.vertexShader = replaceChunk( + shader.vertexShader, + '#include ', + `#include + ${hasNormals ? '' : 'dissolveResolve(position);'} + vDisLocal = position; + #ifdef USE_INSTANCING + vDisWorld = (modelMatrix * instanceMatrix * vec4(position, 1.0)).xyz; + #else + vDisWorld = (modelMatrix * vec4(position, 1.0)).xyz; + #endif + transformed = dissolveVertex(transformed, uTimeDis); + ${vertex}` + ); + + // uTime is not a uniform of a built-in material, and adding one named uTime + // risks colliding with somebody else's patch. The wobble reads this alias. + shader.vertexShader = `uniform float uTimeDis;\n${shader.vertexShader}`; + shader.uniforms.uTimeDis = frame.uTime; + + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + ${noiseGLSL} + ${DISSOLVE_UNIFORMS} + ${DISSOLVE_FRAGMENT}` + ); + + // The cut goes as early as three will let it, before any lighting is paid + // for. clipping_planes_fragment is in every material and is the first thing + // in main(). + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + float disThreshold; + float disNoise = dissolveField(disThreshold); + float disCut = mix(1.0, step(disThreshold, disNoise), clamp(uDisErode, 0.0, 1.0)); + float disEdge = clamp(uDisErode, 0.0, 1.0) * + (1.0 - smoothstep(disThreshold, disThreshold + max(uDisEdge, 1e-4), disNoise)) * + disCut; + if (disCut < 0.5) discard; + if (vDisK >= 0.999) discard;` + ); + + if (shader.fragmentShader.includes('#include ')) { + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + { + // A chunk lights up as it lets go — the ember peaks in the middle of + // its fall and is gone by the time it has shrunk away, so the eye + // gets told which pieces are leaving. + float ember = uDisVoxel * sin(clamp(vDisK, 0.0, 1.0) * ${PI.toFixed(7)}) ; + totalEmissiveRadiance += uDisColorEmber * ember * uDisGlow; + totalEmissiveRadiance += uDisColorEdge * disEdge * uDisGlow; + ${fragment} + }` + ); + } + }; + + if (environment && typeof environment.registerShadowCasterWithPatch === 'function') { + environment.registerShadowCasterWithPatch(material, patch); + } else { + patchOnBeforeCompile(material, patch); + } + + // I8. Without this the harness's pause test cannot see any of these sliders + // and reports every one of them as dead. + material.userData.uniforms = { ...(material.userData.uniforms ?? {}), ...box }; + material.userData.dissolve = box; + return material; +} + +/** + * Canonical names for the patch, with defaults and units. + * + * Hand the whole thing to `syncDissolve()` every frame, or fill a scratch from + * `settings[id]`. Nothing here is captured — that is the point. + */ +export function dissolveParams() { + return { + /* --- the one slider --- */ + progress: 0, // 0 intact, 1 gone + seed: 0, // per-cast; shifts every hash + space: DissolveSpace.LOCAL, // where the erosion field is nailed down + + /* --- VOXEL --- */ + voxel: 1, // 0..1 — how much of the cube behaviour is on + cell: 0.12, // metres — the FINEST cube. Everything else is 2^r of this. + rungs: 4, // doublings; 4 means the last cubes are 8× the first + take: 0.55, // fraction of cells a rung claims before handing on + span: 0.22, // progress units one cell takes to let go + block: 0.7, // 0..1 — how far a chunk snaps to the lattice. 0 is a shatter. + facet: 0.5, // sub-lattice size as a fraction of the cell + hold: 0.45, // 0..1 of a chunk's life before it starts shrinking away + drift: 0.9, // metres a chunk travels + driftBiasX: 0, // unitless direction preference… + driftBiasY: 0.35, // …0.35 up reads as "lifting off" rather than "exploding" + driftBiasZ: 0, + lift: 0.35, // metres up, linear in k + gravity: 0.8, // metres of fall, on k squared + tumble: 1.6, // radians a chunk turns before it goes + wobble: 0.06, // metres of hashed wander + wobbleRate: 5.5, // radians/second + + /* --- EROSION --- */ + erode: 0, // 0..1 — how much of the threshold behaviour is on + noiseScale: 2.4, // features per metre + warp: 0.35, // metres of domain warp — the ragged re-entrant edge + edge: 0.12, // width of the burning band, noise units + biasX: 0, // a front instead of a rash: which way it sweeps + biasY: 0, + biasZ: 0, + biasAmount: 0, // threshold shift per metre along that direction + + /* --- colour: two pickers, neither derived --- */ + colorEdge: '#ff9a3c', // the burning edge + colorEmber: '#a06bff', // a chunk letting go + glow: 1.2 + }; +} + +/** Resolved once at module load. */ +const DISSOLVE_DEFAULTS = dissolveParams(); + +/* ---------------------------------------------------------------------- */ +/* GRANULAR: the heap */ +/* ---------------------------------------------------------------------- */ + +/** + * The heap, shared by the vertex shader and by nothing else — but written as its + * own chunk because it is the part of this file worth reading twice. + */ +const HEAP_GLSL = /* glsl */ ` + #define MAX_LOBES ${MAX_LOBES} + + uniform float uNow; // the ability's age, seconds + uniform float uSeed; + uniform float uLobes; // lobes alive at once, 1..MAX_LOBES + uniform float uRate; // lobes released per second + uniform float uFrontSpeed; // m/s the release point advances + uniform float uVolume; // cubic metres per lobe + uniform float uVolumeJitter; // ±fraction + uniform float uRepose; // radians — the angle it refuses to exceed + uniform float uExcess; // radians above repose at release + uniform float uSlump; // 1/s the excess decays at + uniform float uCreep; // m/s the lobe's centroid crawls forward + uniform float uScatter; // metres of lateral scatter on release + uniform float uWiden; // >1 stretches a lobe across the line + uniform float uBed; // metres — the settled deposit left behind + uniform float uBedRamp; // metres the bed takes to reach full depth + uniform float uBedWidth; // metres, half-width of the bed + uniform float uBedCurve; // how sharply the bed thins toward its edges + + /** + * Height of the heap at q = (metres along the cast, metres across it). + * + * The upper envelope of a train of collapsing cones. See the module header for + * why it is a max and not a sum, and why a lobe is identified by its release + * ordinal rather than by its slot in this loop. + */ + float heapAt(vec2 q, out float age, out float slope, out vec2 towards) { + float best = 0.0; + age = 0.0; + slope = uRepose; + towards = vec2(0.0, 1.0); + + float ordNow = uNow * max(uRate, 1e-3); + for (int k = 0; k < MAX_LOBES; k++) { + float kf = float(k); + if (kf >= uLobes) break; + float m = floor(ordNow) - kf; // release ordinal: this lobe's identity + if (m < 0.0) continue; + + float birth = m / max(uRate, 1e-3); + float a = uNow - birth; + if (a < 0.0) continue; + + float d1 = hash11(m * 1.71 + uSeed); + float d2 = hash11(m * 3.19 + uSeed + 11.0); + + // Over-steep at release, relaxing back toward repose. With the volume + // held, that alone makes the lobe get shorter and wider — the collapse is + // not animated, it is what conservation does to a decaying slope. + float s = clamp(uRepose + uExcess * exp(-uSlump * a), 0.06, 1.45); + float tn = tan(s); + float vol = max(uVolume * (1.0 + (d1 - 0.5) * 2.0 * uVolumeJitter), 1e-5); + float H = pow(3.0 * vol * tn * tn / PI, 1.0 / 3.0); + + vec2 centre = vec2(birth * uFrontSpeed + uCreep * a, + (d2 - 0.5) * 2.0 * uScatter); + vec2 off = (q - centre) * vec2(1.0, 1.0 / max(uWiden, 0.05)); + float dist = length(off); + float h = max(0.0, H - tn * dist); + if (h > best) { + best = h; + age = a; + slope = s; + towards = dist > 1e-4 ? off / dist : vec2(0.0, 1.0); + } + } + + // The settled deposit the train leaves behind it. Without it the tail + // vanishes as lobes age out of the window and the avalanche is a comet. + float front = uNow * uFrontSpeed; + float behind = smoothstep(0.0, max(uBedRamp, 1e-3), front - q.x); + float across = 1.0 - clamp(abs(q.y) / max(uBedWidth, 1e-3), 0.0, 1.0); + float bed = uBed * behind * pow(across, max(uBedCurve, 0.05)); + return max(best, bed); + } +`; + +const HEAP_VERTEX = /* glsl */ ` + #define PI 3.141592653589793 + + uniform vec3 uOrigin; + uniform vec3 uForward; + uniform vec3 uSide; + uniform float uLength; // metres of cast the sheet covers + uniform float uHalfWidth; // metres either side of the line + uniform float uFloor; // metres — the y the heap sits on + uniform float uHeightGain; // multiplier on the whole heap + + ${noiseGLSL} + ${HEAP_GLSL} + + attribute vec2 aGrid; // (along 0..1, across -0.5..0.5) + + varying vec3 vWorld; + varying vec3 vNormal; + varying vec2 vLocal; // metres (along, across) + varying float vHeight; // metres + varying float vAge; // seconds since the winning lobe was released + varying float vSlope; // radians of the winning lobe's face + varying float vFlow; // 0..1 — how far above repose that face is + + void main() { + vec2 q = vec2(aGrid.x * uLength, aGrid.y * 2.0 * uHalfWidth); + + float age; + float slope; + vec2 towards; + float h = heapAt(q, age, slope, towards) * uHeightGain; + + // The envelope is not differentiable at its ridges, and that is not an + // artefact — a heap has ridges. So the normal is a forward difference at the + // grid's own scale, which shades a ridge as the crease it actually is + // instead of rounding it away. + float eps = max(uLength / 96.0, 0.02); + float a2; + float s2; + vec2 t2; + float hx = heapAt(q + vec2(eps, 0.0), a2, s2, t2) * uHeightGain; + float hz = heapAt(q + vec2(0.0, eps), a2, s2, t2) * uHeightGain; + vec3 nLocal = normalize(vec3(-(hx - h) / eps, 1.0, -(hz - h) / eps)); + + vec3 world = uOrigin + uForward * q.x + uSide * q.y; + world.y = uFloor + h; + + vWorld = world; + vNormal = normalize(uForward * nLocal.x + vec3(0.0, nLocal.y, 0.0) + uSide * nLocal.z); + vLocal = q; + vHeight = h; + vAge = age; + vSlope = slope; + vFlow = clamp((slope - uRepose) / max(uExcess, 1e-3), 0.0, 1.0); + + gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0); + } +`; + +const HEAP_FRAGMENT = /* glsl */ ` + #define PI 3.141592653589793 + + uniform vec3 uLightDir; + uniform float uShaderIntensity; + + uniform vec3 uColorFresh; // just-collapsed material at the front + uniform vec3 uColorSettled; // the tail, hours old in avalanche seconds + uniform vec3 uColorFace; // the slip face, actively moving + uniform vec3 uColorDeep; // shadow inside the heap + uniform float uAmbient; + uniform float uWrap; // how far light bends round a translucent grain + uniform float uFresh; // seconds a lobe counts as fresh for + uniform float uGrain; + uniform float uGrainScale; // grains per metre + uniform float uGlint; // pinpoint sparkle — snow, not sand + uniform float uGlintScale; + uniform float uToe; // metres of height the heap fades out over + uniform float uOpacity; + + ${noiseGLSL} + + varying vec3 vWorld; + varying vec3 vNormal; + varying vec2 vLocal; + varying float vHeight; + varying float vAge; + varying float vSlope; + varying float vFlow; + + void main() { + if (vHeight <= 0.0005) discard; + + vec3 N = normalize(vNormal); + vec3 L = normalize(uLightDir); + vec3 V = normalize(cameraPosition - vWorld); + + float ndl = dot(N, L); + float diffuse = clamp((ndl + uWrap) / (1.0 + uWrap), 0.0, 1.0); + + // Grains on a hashed lattice, not a noise threshold: value noise piles up + // at its midpoint, so thresholding it gives either nothing or a rash. + vec3 cell = floor(vWorld * max(uGrainScale, 1.0)); + float grain = hash13(cell); + // The glint sits on its own, finer lattice: tie it to the grain and every + // sparkle lands in the middle of a speckle and the snow reads as glitter + // paper. They are different physical scales and they need different cells. + vec3 glintCell = floor(vWorld * max(uGlintScale, 1.0)); + float glint = step(0.9955, hash13(glintCell * 1.37 + 3.0)) * + step(0.55, pow(clamp(dot(N, normalize(L + V)), 0.0, 1.0), 40.0)) * uGlint; + + float fresh = 1.0 - clamp(vAge / max(uFresh, 1e-3), 0.0, 1.0); + vec3 body = mix(uColorSettled, uColorFresh, fresh); + // The slip face is the part still above repose: it is the moving surface and + // it is the only part of an avalanche that looks like it is going anywhere. + body = mix(body, uColorFace, vFlow); + body = mix(uColorDeep, body, diffuse); + body *= 1.0 + (grain - 0.5) * 2.0 * uGrain; + body += vec3(glint); + body *= uAmbient + (1.0 - uAmbient) * uShaderIntensity; + + // The toe: a heap does not end at a hard line on the floor, it thins out. + float alpha = uOpacity * smoothstep(0.0, max(uToe, 1e-4), vHeight); + + gl_FragColor = vec4(max(body, vec3(0.0)), clamp(alpha, 0.0, 1.0)); + } +`; + +/** + * The granular heap. One mesh, one draw call, no state. + * + * Parent-first, like `GroundField` — it owns its mesh and you never see it. + */ +export class DissolveField { + /** + * @param {THREE.Object3D} parent the ability's group + * @param {object} [options] + * @param {number} [options.along] grid resolution down the cast. The lobes are + * metres across, so this is what decides whether a ridge is a ridge or a + * staircase; 96 is generous and 48 is honest. + * @param {number} [options.across] grid resolution across it + */ + constructor(parent, options = {}) { + const { + along = 72, + across = 40, + renderOrder = 3, + layer = LAYER.WORLD, + name = 'DissolveField' + } = options; + + this.group = new Group(); + this.group.name = name; + this.group.matrixAutoUpdate = false; + parent.add(this.group); + + /* --- the grid ---------------------------------------------------- */ + const nx = Math.max(2, along | 0); + const nz = Math.max(2, across | 0); + const vertices = (nx + 1) * (nz + 1); + const grid = new Float32Array(vertices * 2); + for (let j = 0; j <= nz; j++) { + for (let i = 0; i <= nx; i++) { + const o = (j * (nx + 1) + i) * 2; + grid[o] = i / nx; // along, 0..1 + grid[o + 1] = j / nz - 0.5; // across, -0.5..0.5 + } + } + const indices = new Uint32Array(nx * nz * 6); + let w = 0; + for (let j = 0; j < nz; j++) { + for (let i = 0; i < nx; i++) { + const a = j * (nx + 1) + i; + const b = a + 1; + const c = a + nx + 1; + const d = c + 1; + indices[w++] = a; + indices[w++] = c; + indices[w++] = b; + indices[w++] = b; + indices[w++] = c; + indices[w++] = d; + } + } + + const geometry = new BufferGeometry(); + // The vertex shader needs no `position`, but three wants one to exist and + // every downstream chunk assumes it does. + geometry.setAttribute('position', new BufferAttribute(new Float32Array(vertices * 3), 3)); + geometry.setAttribute('aGrid', new BufferAttribute(grid, 2)); + geometry.setIndex(new BufferAttribute(indices, 1)); + // Placed in world space by the vertex shader; its own bounds mean nothing. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.geometry = geometry; + + this.material = new ShaderMaterial({ + name, + transparent: true, + depthWrite: true, + depthTest: true, + side: DoubleSide, + toneMapped: true, + uniforms: sharedUniforms({ + uNow: { value: 0 }, + uSeed: { value: 0 }, + + uOrigin: { value: new Vector3() }, + uForward: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + uLength: { value: 12 }, + uHalfWidth: { value: HEAP_DEFAULTS.halfWidth }, + uFloor: { value: HEAP_DEFAULTS.floor }, + uHeightGain: { value: HEAP_DEFAULTS.heightGain }, + + uLobes: { value: HEAP_DEFAULTS.lobes }, + uRate: { value: HEAP_DEFAULTS.rate }, + uFrontSpeed: { value: HEAP_DEFAULTS.frontSpeed }, + uVolume: { value: HEAP_DEFAULTS.volume }, + uVolumeJitter: { value: HEAP_DEFAULTS.volumeJitter }, + uRepose: { value: HEAP_DEFAULTS.repose }, + uExcess: { value: HEAP_DEFAULTS.excess }, + uSlump: { value: HEAP_DEFAULTS.slump }, + uCreep: { value: HEAP_DEFAULTS.creep }, + uScatter: { value: HEAP_DEFAULTS.scatter }, + uWiden: { value: HEAP_DEFAULTS.widen }, + uBed: { value: HEAP_DEFAULTS.bed }, + uBedRamp: { value: HEAP_DEFAULTS.bedRamp }, + uBedWidth: { value: HEAP_DEFAULTS.bedWidth }, + uBedCurve: { value: HEAP_DEFAULTS.bedCurve }, + + uColorFresh: { value: new Color(HEAP_DEFAULTS.colorFresh) }, + uColorSettled: { value: new Color(HEAP_DEFAULTS.colorSettled) }, + uColorFace: { value: new Color(HEAP_DEFAULTS.colorFace) }, + uColorDeep: { value: new Color(HEAP_DEFAULTS.colorDeep) }, + uAmbient: { value: HEAP_DEFAULTS.ambient }, + uWrap: { value: HEAP_DEFAULTS.wrap }, + uFresh: { value: HEAP_DEFAULTS.fresh }, + uGrain: { value: HEAP_DEFAULTS.grain }, + uGrainScale: { value: HEAP_DEFAULTS.grainScale }, + uGlint: { value: HEAP_DEFAULTS.glint }, + uGlintScale: { value: HEAP_DEFAULTS.glintScale }, + uToe: { value: HEAP_DEFAULTS.toe }, + uOpacity: { value: HEAP_DEFAULTS.opacity } + }), + vertexShader: HEAP_VERTEX, + fragmentShader: HEAP_FRAGMENT + }); + + this.mesh = new Mesh(geometry, this.material); + this.mesh.name = name; + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(layer); + this.mesh.renderOrder = renderOrder; + this.group.add(this.mesh); + + this._origin = new Vector3(); + this._direction = new Vector3(0, 0, 1); + this._side = new Vector3(1, 0, 0); + this._length = 1; + this._p = heapParams(); + this.seed = 0; + } + + get uniforms() { + return this.material.uniforms; + } + + /** One, however deep the heap. */ + get drawCalls() { + return 1; + } + + get visible() { + return this.group.visible; + } + + set visible(v) { + this.group.visible = !!v; + } + + /** The cast's frame. Called once per frame by the ability. */ + setBasis(origin, direction, side, length) { + this._origin.copy(origin); + this._direction.copy(direction); + this._side.copy(side); + this._length = Math.max(0.01, length); + return this; + } + + /** + * Re-seed the lobe train. Call from `onSpawn` and nowhere else. + * + * The seed is the only thing the heap carries. Everything else — where each + * lobe was released, how big it was, how far it has collapsed — is a function + * of `now` and the live params, which is why dragging `repose` on a paused + * avalanche flattens it. + */ + roll(seed = Math.random() * 100) { + this.seed = seed; + this.material.uniforms.uSeed.value = seed; + return this; + } + + /** Hide the heap. Leaves the instance reusable — the pooling contract. */ + reset() { + this.mesh.visible = false; + this.material.uniforms.uNow.value = 0; + return this; + } + + /** Four pickers, none derived from another. */ + setColors(fresh, settled, face, deep) { + const u = this.material.uniforms; + putColor(u.uColorFresh.value, fresh, HEAP_DEFAULTS.colorFresh); + putColor(u.uColorSettled.value, settled, HEAP_DEFAULTS.colorSettled); + putColor(u.uColorFace.value, face, HEAP_DEFAULTS.colorFace); + putColor(u.uColorDeep.value, deep, HEAP_DEFAULTS.colorDeep); + return this; + } + + /** Fill `_p` from the caller's block, defaults where it is silent. */ + _resolve(params) { + const p = this._p; + for (const key in HEAP_DEFAULTS) { + const value = params[key]; + p[key] = value === undefined ? HEAP_DEFAULTS[key] : value; + } + return p; + } + + /** + * @param {number} now seconds since the cast began — **this one is used**. + * The lobe train is an event with a start, unlike a flock or a curtain, so + * it runs on the ability's own clock and not on the shared `uTime`. + * @param {object} params live block; anything absent falls back to + * `heapParams()` + */ + update(now, params) { + const p = this._resolve(params ?? HEAP_DEFAULTS); + const u = this.material.uniforms; + + u.uNow.value = now; + u.uOrigin.value.copy(this._origin); + u.uForward.value.copy(this._direction); + u.uSide.value.copy(this._side); + u.uLength.value = this._length; + u.uHalfWidth.value = p.halfWidth; + u.uFloor.value = p.floor; + u.uHeightGain.value = p.heightGain; + + u.uLobes.value = Math.max(1, Math.min(MAX_LOBES, Math.round(p.lobes))); + u.uRate.value = p.rate; + u.uFrontSpeed.value = p.frontSpeed; + u.uVolume.value = p.volume; + u.uVolumeJitter.value = p.volumeJitter; + u.uRepose.value = p.repose; + u.uExcess.value = p.excess; + u.uSlump.value = p.slump; + u.uCreep.value = p.creep; + u.uScatter.value = p.scatter; + u.uWiden.value = p.widen; + u.uBed.value = p.bed; + u.uBedRamp.value = p.bedRamp; + u.uBedWidth.value = p.bedWidth; + u.uBedCurve.value = p.bedCurve; + + u.uAmbient.value = p.ambient; + u.uWrap.value = p.wrap; + u.uFresh.value = p.fresh; + u.uGrain.value = p.grain; + u.uGrainScale.value = p.grainScale; + u.uGlint.value = p.glint; + u.uGlintScale.value = p.glintScale; + u.uToe.value = p.toe; + u.uOpacity.value = p.opacity; + + this.mesh.visible = p.opacity > 0 && p.heightGain > 0; + return this; + } + + /** + * Where the release point is right now, on the floor, in world space. + * + * The CPU mirror of the front — the ability hangs the dust plume, the light + * and the shake on it. Mirror, so if you change one change the other. + */ + frontPoint(now, p, out) { + const speed = p?.frontSpeed ?? HEAP_DEFAULTS.frontSpeed; + const along = Math.min(now * speed, this._length); + return out + .copy(this._origin) + .addScaledVector(this._direction, along) + .setY((p?.floor ?? HEAP_DEFAULTS.floor) + (p?.bed ?? HEAP_DEFAULTS.bed)); + } + + /** + * Height of the youngest lobe, in metres — the CPU mirror of the crest. + * + * Only the newest lobe, because that is the one an ability wants (it is where + * the crest is), and mirroring the whole envelope on the CPU would be a second + * implementation of the thing this module exists to keep in one place. + */ + crestHeight(now, p) { + const c = p ?? HEAP_DEFAULTS; + const rate = Math.max(c.rate ?? HEAP_DEFAULTS.rate, 1e-3); + const age = (now * rate - Math.floor(now * rate)) / rate; + const slope = Math.max( + 0.06, + Math.min(1.45, (c.repose ?? 0.6) + (c.excess ?? 0.2) * Math.exp(-(c.slump ?? 2) * age)) + ); + const tn = Math.tan(slope); + const volume = Math.max(c.volume ?? HEAP_DEFAULTS.volume, 1e-5); + return Math.cbrt((3 * volume * tn * tn) / PI) * (c.heightGain ?? 1); + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + this.group.parent?.remove(this.group); + } +} + +/** + * Canonical names for the heap, with defaults and units. + * + * The defaults are a snow avalanche: a 33° repose angle (dry snow sits between + * 30° and 38°), released a few times a second, over-steep by 12° and relaxing + * within half a second. + */ +export function heapParams() { + return { + /* --- the footprint --- */ + halfWidth: 3.2, // metres either side of the cast line the sheet covers + floor: 0.005, // metres — the y the heap sits on. Off zero to beat the floor. + heightGain: 1, // multiplier on the whole heap + + /* --- the lobe train --- */ + lobes: 16, // lobes alive at once, ≤ MAX_LOBES. This is the cost. + rate: 5, // lobes released per second + frontSpeed: 7, // m/s the release point advances down the line + volume: 1.4, // cubic metres per lobe + volumeJitter: 0.45, // ±fraction + repose: 0.58, // radians — 33°, dry snow. The angle it refuses to exceed. + excess: 0.21, // radians above repose at release — 12° of over-steepening + slump: 2.4, // 1/s the excess decays at + creep: 1.6, // m/s the lobe's centroid crawls forward as it collapses + scatter: 0.9, // metres of lateral scatter on release + widen: 1.4, // >1 stretches a lobe across the line + + /* --- the deposit left behind --- */ + bed: 0.22, // metres of settled snow in the tail + bedRamp: 2.5, // metres it takes to reach that depth behind the front + bedWidth: 2.6, // metres, half-width + bedCurve: 0.6, // how sharply it thins toward its edges + + /* --- shading --- */ + ambient: 0.5, // floor under the diffuse term + wrap: 0.5, // how far light bends round a translucent grain + fresh: 0.7, // seconds a lobe counts as fresh for + grain: 0.12, // speckle depth + grainScale: 70, // grains per metre + glint: 0.5, // pinpoint sparkle — snow, not sand + glintScale: 120, + toe: 0.06, // metres of height the heap fades out over + opacity: 1, + + /* --- colour: four pickers, none derived --- */ + colorFresh: '#f2f6fb', + colorSettled: '#cfd8e4', + colorFace: '#ffffff', + colorDeep: '#6d7f96' + }; +} + +/** Resolved once at module load. */ +const HEAP_DEFAULTS = heapParams(); + +/** + * Editor schema fragments. Spread whichever half the ability uses. + */ +export function dissolveSchema(label = 'Dissolve') { + return { + [`${label} · cubes`]: [ + ['progress', 0, 1, 0.001], + ['voxel', 0, 1, 0.01, 'cubes on'], + ['cell', 0.01, 1, 0.005, 'finest cube (m)'], + ['rungs', 1, MAX_RUNGS, 1, 'doublings'], + ['take', 0.05, 0.95, 0.01, 'claimed per rung'], + ['span', 0.02, 1, 0.01, 'time to let go'], + ['block', 0, 1, 0.01, 'blockiness'], + ['facet', 0.1, 1, 0.05, 'sub-lattice'], + ['hold', 0, 0.98, 0.01, 'hold before shrink'], + ['drift', 0, 6, 0.01, 'drift (m)'], + ['lift', -2, 4, 0.01, 'lift (m)'], + ['gravity', 0, 8, 0.01, 'fall (m)'], + ['tumble', 0, 9, 0.01, 'tumble (rad)'], + ['wobble', 0, 0.5, 0.005, 'wander (m)'], + ['wobbleRate', 0, 20, 0.1, 'wander rate'], + 'colorEmber' + ], + [`${label} · erosion`]: [ + ['erode', 0, 1, 0.01, 'erosion on'], + ['noiseScale', 0.2, 12, 0.05, 'features/m'], + ['warp', 0, 2, 0.01, 'domain warp'], + ['edge', 0.01, 0.5, 0.005, 'burn width'], + ['biasAmount', -1, 1, 0.01, 'front bias'], + 'colorEdge', + ['glow', 0, 4, 0.01] + ], + [`${label} · heap`]: [ + ['halfWidth', 0.5, 12, 0.1, 'half-width (m)'], + ['heightGain', 0, 3, 0.01, 'height'], + ['lobes', 1, MAX_LOBES, 1], + ['rate', 0.5, 20, 0.1, 'lobes/s'], + ['frontSpeed', 0, 30, 0.1, 'front (m/s)'], + ['volume', 0.05, 12, 0.05, 'lobe (m³)'], + ['volumeJitter', 0, 1, 0.01, 'lobe jitter'], + ['repose', 0.15, 1.2, 0.005, 'repose (rad)'], + ['excess', 0, 0.8, 0.005, 'over-steep (rad)'], + ['slump', 0.1, 12, 0.05, 'slump (1/s)'], + ['creep', 0, 8, 0.05, 'creep (m/s)'], + ['scatter', 0, 6, 0.05, 'scatter (m)'], + ['widen', 0.2, 4, 0.05, 'lobe widening'], + ['bed', 0, 2, 0.01, 'deposit (m)'], + ['bedRamp', 0.1, 12, 0.1, 'deposit ramp (m)'], + ['bedWidth', 0.2, 12, 0.1, 'deposit width (m)'], + ['bedCurve', 0.05, 4, 0.05, 'deposit edge'] + ], + [`${label} · snow`]: [ + ['ambient', 0, 1, 0.01], + ['wrap', 0, 1, 0.01, 'light wrap'], + ['fresh', 0.05, 4, 0.05, 'fresh (s)'], + ['grain', 0, 0.6, 0.01], + ['grainScale', 5, 300, 1, 'grains/m'], + ['glint', 0, 3, 0.01], + ['toe', 0.005, 0.4, 0.005, 'toe (m)'], + 'colorFresh', + 'colorSettled', + 'colorFace', + 'colorDeep' + ] + }; +} diff --git a/src/vfx/Distortion.js b/src/vfx/Distortion.js new file mode 100644 index 0000000..029900e --- /dev/null +++ b/src/vfx/Distortion.js @@ -0,0 +1,953 @@ +import { + Mesh, + ShaderMaterial, + NormalBlending, + AdditiveBlending, + FrontSide, + DoubleSide, + Color, + Vector2, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { uprightQuad } from './quads.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER, retainDistortion, releaseDistortion } from '../core/Layers.js'; +import { settings } from '../config/settings.js'; +import { copyColor } from '../utils/color.js'; + +/* ------------------------------------------------------------------------ */ +/* Distortion — the pass that used to do nothing */ +/* ------------------------------------------------------------------------ */ +/** + * Screen-space refraction emitters. + * + * `LAYER.DISTORTION` and its half-resolution buffer have existed since the + * first build and the README has carried "the distortion pass runs with nothing + * writing to it" as a known rough edge ever since. This module is the thing that + * writes to it. + * + * ## What is in the buffer + * + * `PostProcessing._renderDistortion()` clears a half-res HalfFloat target to + * `(0.5, 0.5, 0, 0)` — "no offset, no coverage" — and draws every visible mesh + * on `LAYER.DISTORTION` into it. `DistortionShader` then resamples the composed + * frame by whatever it finds: + * + * ``` + * R,G unit screen-space direction, encoded as d * 0.5 + 0.5 + * B magnitude, in screen widths at uScale = 1 + * A coverage — the blend weight between overlapping emitters + * ``` + * + * The offset the pass finally applies is `dir * B * settings.post.distortion * + * settings.global.distortion`. **An emitter never multiplies the two global + * gains in itself** — they are applied once, in the pass, so a writer that + * forgets still obeys the master sliders and nothing double-applies them. + * + * ## The one rule + * + * Magnitudes are **screen fractions, not metres**. A fragment that writes + * `B = 1` displaces the frame under it by a full `post.distortion` of screen + * width no matter how far away the emitter is. That is a deliberate choice: it + * makes an authored strength mean the same thing on every cast, and the thing + * that *should* shrink with distance — the area being warped — already does, + * because the emitter is real geometry. `perspective` (0..1) is there for the + * cases where you disagree; heat shimmer usually does. + * + * ## The five emitters + * + * | mode | what it is | + * | --- | --- | + * | `HEAT` | rising shimmer above a hot region, advected upward in world space, strongest at the source | + * | `LENS` | radial displacement going as 1/r² inside a falloff — a gravity well that bends the floor, the character and every particle behind it | + * | `SHOCK` | a travelling ring of compression then rarefaction, up to four concentric fronts | + * | `BLADE` | a razor-thin plane of pure refraction with a hairline at its cutting edge | + * | `REFRACT` | a generic hull that refracts along its own normal — water, glass, prisms | + * + * ## Cost + * + * One draw call each, at half resolution, into a buffer nothing else reads. + * A `BLADE` with `edge: true` is two, because the bright hairline is real + * emissive geometry on `LAYER.VFX` and cannot live in an offset buffer. + * + * ## Invariants + * + * - **I1** — the field holds no dimensions. Everything in `update()` is + * re-resolved by the caller from `settings` every frame, zero-length frames + * included, and written straight into uniforms. Pause with **P**, drag a + * slider, and the standing warp changes. + * - **I3** — `update()` writes numbers into existing uniform boxes. Nothing + * allocates. The quad geometry is a module-scope singleton shared by every + * emitter in the app. + * - **I5** — every number below is a slider. There is one colour in the whole + * module (the blade's hairline) because an offset buffer has no colour. + */ + +/* ---------------------------------------------------------------- */ +/* Enums */ +/* ---------------------------------------------------------------- */ + +export const DistortionMode = Object.freeze({ + HEAT: 0, + LENS: 1, + SHOCK: 2, + BLADE: 3, + REFRACT: 4 +}); + +/** + * How the emitter's quad is oriented. Everything except `WORLD` is built in the + * vertex shader from `uAnchor` and the view matrix, so the mesh's own transform + * stays identity and moving an emitter is a uniform write rather than a matrix + * update — which is also what keeps it honest about **I1**. + */ +export const DistortionFacing = Object.freeze({ + /** Squarely at the camera. Lenses and shock fronts that read as spheres. */ + BILLBOARD: 'billboard', + /** Local +Y is world up, local +X is camera-right flattened. Heat columns. */ + UPRIGHT: 'upright', + /** Flat on the floor: local +X is world +X, local +Y is world +Z. */ + GROUND: 'ground', + /** Placed by `setBasis()` — an arbitrary plane. Blades and panes. */ + WORLD: 'world' +}); + +const FACING_DEFINE = { + [DistortionFacing.BILLBOARD]: 'DISTORT_FACE_BILLBOARD', + [DistortionFacing.UPRIGHT]: 'DISTORT_FACE_UPRIGHT', + [DistortionFacing.GROUND]: 'DISTORT_FACE_GROUND', + [DistortionFacing.WORLD]: 'DISTORT_FACE_WORLD' +}; + +/* ---------------------------------------------------------------- */ +/* Shaders */ +/* ---------------------------------------------------------------- */ + +/** + * The vertex shader does three jobs, and the third is the interesting one. + * + * 1. It *builds* the quad, rather than transforming one. `uAnchor` plus two + * world axes plus `uSize` in metres — so a heat column that is 0.6 m across + * and 2.4 m tall is two numbers, live, and the mesh matrix never changes. + * 2. It carries `vLocal`, the fragment's offset from the anchor **in metres in + * the emitter's own plane**, which is what every radial mode measures against. + * 3. It projects the anchor and the two unit axes into NDC and hands the results + * down as varyings. + * + * That third one deserves a note, because the obvious alternative is wrong. A + * radial emitter needs, per fragment, the screen-space direction pointing away + * from its centre. Interpolating a per-vertex "direction from centre" across the + * quad gives a field that is only correct at the four corners and visibly wrong + * in the middle. But a varying whose value is *identical at every vertex* + * interpolates to exactly that value, perspective correction or not — so + * projecting the anchor once and passing the constant, then differencing against + * the fragment's own `vClip`, is exact everywhere for the price of one extra + * matrix multiply. `vAxX` / `vAxY` are the same trick applied to the screen image + * of one metre along each axis, which is what lets a plane-space wobble come out + * pointing the right way on screen. + */ +const DISTORTION_VERTEX = /* glsl */ ` + uniform vec3 uAnchor; // world position the emitter is pinned to + uniform vec2 uSize; // quad extent, metres (x across, y along local +Y) + uniform vec2 uPivot; // 0.5,0.5 centres the quad; 0.5,0.0 sits it on the anchor + uniform vec3 uAxisX; // WORLD facing only — unit, in the plane + uniform vec3 uAxisY; // WORLD facing only — unit, in the plane + + varying vec2 vUv; + varying vec2 vLocal; // metres from the anchor, in the emitter plane + varying vec4 vClip; + varying vec2 vCentre; // the anchor in NDC — constant across the quad + varying vec2 vAxX; // screen-UV image of one metre along local +X + varying vec2 vAxY; + varying float vMetreUV; // |vAxX| — screen UV per metre at the anchor's depth + varying float vViewZ; // negative, view space + varying vec3 vWorld; + varying vec3 vNormalV; // view-space surface normal + varying float vOnScreen; // 0 when the anchor is behind the eye + + void main() { + vUv = uv; + + vec3 ax; + vec3 ay; + vec3 world; + + #ifdef DISTORT_HULL + /* A real mesh with real normals: use its matrix as authored. */ + world = (modelMatrix * vec4(position, 1.0)).xyz; + ax = normalize(mat3(modelMatrix) * vec3(1.0, 0.0, 0.0)); + ay = normalize(mat3(modelMatrix) * vec3(0.0, 1.0, 0.0)); + vLocal = position.xy; + vNormalV = normalize(mat3(viewMatrix) * mat3(modelMatrix) * normal); + #else + #if defined(DISTORT_FACE_BILLBOARD) + // viewMatrix's rows are the camera's basis expressed in world space. + ax = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]); + ay = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]); + #elif defined(DISTORT_FACE_UPRIGHT) + // Heat rises along world up whatever the camera is doing; only the + // *roll* of the column follows the eye. A full billboard here makes a + // plume lean over when you orbit, which reads as wind, not heat. + vec3 camRight = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]); + ay = vec3(0.0, 1.0, 0.0); + vec3 planar = camRight - ay * dot(camRight, ay); + ax = length(planar) > 1e-4 ? normalize(planar) : vec3(1.0, 0.0, 0.0); + #elif defined(DISTORT_FACE_GROUND) + ax = vec3(1.0, 0.0, 0.0); + ay = vec3(0.0, 0.0, 1.0); + #else + ax = uAxisX; + ay = uAxisY; + #endif + + vec2 ext = (uv - uPivot) * uSize; + world = uAnchor + ax * ext.x + ay * ext.y; + vLocal = ext; + vNormalV = normalize(mat3(viewMatrix) * normalize(cross(ax, ay))); + #endif + + vWorld = world; + vec4 mv = viewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + vClip = projectionMatrix * mv; + gl_Position = vClip; + + /* ---- the constant varyings: the anchor and its two unit axes, in NDC ---- */ + vec4 c0 = projectionMatrix * viewMatrix * vec4(uAnchor, 1.0); + vec4 cx = projectionMatrix * viewMatrix * vec4(uAnchor + ax, 1.0); + vec4 cy = projectionMatrix * viewMatrix * vec4(uAnchor + ay, 1.0); + vOnScreen = step(0.001, c0.w); + vCentre = c0.xy / max(c0.w, 0.001); + vAxX = (cx.xy / max(cx.w, 0.001) - vCentre) * 0.5; + vAxY = (cy.xy / max(cy.w, 0.001) - vCentre) * 0.5; + vMetreUV = length(vAxX); + } +`; + +/** + * One fragment shader, five emitters, selected by `#if DISTORT_MODE`. + * + * They share the tail — depth rejection, perspective, the encode — because the + * tail is the part that has to agree with `DistortionShader` and there should be + * exactly one copy of it in the project. + */ +const DISTORTION_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform float uShaderIntensity; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + /* --- shared --- */ + uniform float uStrength; // screen widths at post.distortion = 1 + uniform float uOpacity; // master coverage / fade-out, 0..1 + uniform float uSeed; // per-cast dice roll, unitless + uniform float uDepthReject; // 0..1 — how hard opaque geometry in front cuts it + uniform float uDepthFade; // metres over which that rejection feathers + uniform float uPerspective; // 0 = screen fraction, 1 = shrink with distance + uniform float uPerspectiveRef;// metres at which perspective = 1 + uniform float uPostScale; // mirror of post.distortion × global.distortion + + /* --- radial (LENS, SHOCK) --- */ + uniform float uRadius; // metres — the falloff edge + uniform float uWindow; // 0..1 of uRadius where the falloff starts + uniform float uCore; // 0..1 of uRadius — the 1/r² clamp + uniform float uMaxOffset; // hard ceiling on the magnitude + uniform float uInvert; // 0 = sample outward (safe), 1 = sample inward + uniform float uFold; // 0 = never let the image cross the centre + uniform float uSwirl; // tangential fraction — frame dragging + + /* --- SHOCK --- */ + uniform float uWave; // wavefront radius, metres + uniform float uThickness; // metres from the front to the lobe peak + uniform float uCompression; // amplitude of the inner (negative) lobe + uniform float uRarefaction; // amplitude of the outer (positive) lobe + uniform float uRings; // 1..4 concentric fronts + uniform float uRingGap; // metres between them + uniform float uRingDecay; // per-ring amplitude falloff + + /* --- HEAT --- */ + uniform float uFrequency; // cycles per metre + uniform float uSpeed; // metres per second the shimmer climbs + uniform float uSourceBias; // exponent — how hard it favours the base + uniform float uSpread; // how much the column opens out over its height + uniform float uVertical; // 0..1 how much of the wobble is up/down + uniform float uFlicker; // 0..1 amplitude of the second, slower clock + + /* --- BLADE --- */ + uniform float uCut; // 0..1 how far along its length the blade has cut + uniform float uGrazing; // exponent on the edge-on path length + uniform float uEdge; // 0..1 of the height — the hairline's width + uniform float uEdgeGain; // how much harder the hairline warps than the body + uniform float uWake; // 0..1 of the height the wake survives to + + /* --- BLADE + REFRACT --- */ + uniform float uRipple; // amplitude of the normal perturbation + uniform float uRippleScale; // cycles per metre + uniform float uRippleSpeed; // metres per second + + /* --- REFRACT --- */ + uniform float uPower; // rim exponent; 0 = flat across the hull + + varying vec2 vUv; + varying vec2 vLocal; + varying vec4 vClip; + varying vec2 vCentre; + varying vec2 vAxX; + varying vec2 vAxY; + varying float vMetreUV; + varying float vViewZ; + varying vec3 vWorld; + varying vec3 vNormalV; + varying float vOnScreen; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + vec2 ndc = vClip.xy / vClip.w; + vec2 screenUV = ndc * 0.5 + 0.5; + + /* Offset from the anchor in screen UV. Exact, see the vertex shader. */ + vec2 fromCentre = (ndc - vCentre) * 0.5; + float screenR = length(fromCentre); + vec2 radial = screenR > 1e-5 ? fromCentre / screenR : vec2(1.0, 0.0); + + vec2 dir = vec2(0.0); + float mag = 0.0; + float mask = 1.0; + + #if DISTORT_MODE == 0 + /* ---------------------------------------------------------------- */ + /* HEAT — shimmer rising off something hot */ + /* ---------------------------------------------------------------- */ + /* The noise is sampled in **world** space and scrolled downward in it, so + * the shimmer is advected up through a column that stands still: orbiting + * the camera does not drag the pattern along, which is the tell that gives + * a screen-space heat haze away instantly. + * + * The column widens with height rather than being a rectangle of wobble, + * because hot air entrains cold air and the plume opens out. And it is + * biased hard toward the base — uSourceBias — because the whole read is + * "this is coming off *that*". A plume of even strength top to bottom looks + * like fog. */ + float h = vUv.y; + float halfW = 0.5 * (1.0 + uSpread * h); + float lateral = (vUv.x - 0.5) / max(halfW, 1e-3); + mask = (1.0 - smoothstep(0.5, 1.0, abs(lateral))) * (1.0 - smoothstep(0.35, 1.0, h)); + + vec3 np = vec3( + vWorld.xz * uFrequency, + vWorld.y * uFrequency * 0.55 - uTime * uSpeed * uFrequency + uSeed + ); + vec2 wob = vec2(fbm3(np), fbm3(np + vec3(31.7, 11.3, 5.9)) * uVertical); + + /* Orientation from the plane's screen image, magnitude from the slider — + * mixing the two would make the warp weaker as the camera pulls back, which + * is what uPerspective is for and should not be automatic. */ + vec2 screenOff = wob.x * vAxX + wob.y * vAxY; + float sl = length(screenOff); + dir = sl > 1e-7 ? screenOff / sl : vec2(0.0); + mag = uStrength * min(length(wob), 1.5); + mag *= pow(max(1.0 - h, 1e-4), uSourceBias); + mag *= 1.0 + uFlicker * sin(uTime * 7.3 + vWorld.y * 2.1 + uSeed * 6.0); + #endif + + #if DISTORT_MODE == 1 + /* ---------------------------------------------------------------- */ + /* LENS — a gravity well */ + /* ---------------------------------------------------------------- */ + /* Magnitude goes as 1/r² in the emitter's own plane, measured in metres, so + * the well keeps its physical size when the camera moves. Two things stop + * it being a divide-by-zero with a hole in the middle: + * + * - uCore clamps the denominator, so the centre is a finite, very strong + * smear rather than NaN. Below about 0.08 the core samples so far out + * that it reads as a mirror; that is a look, and it is a slider. + * - uFold guards the *inverted* case. Sampling outward (the default) can + * only ever run off the edge of the frame. Sampling inward runs the + * sample point through the centre and out the other side, which flips the + * image — real, and occasionally wanted, but never by accident. With + * uFold at 0 the magnitude is clamped to exactly the screen distance + * back to the centre, using the post pass's own gain, so the core packs + * down to a point and stops. + */ + float rm = length(vLocal); + float rn = rm / max(uRadius, 1e-3); + mask = 1.0 - smoothstep(uWindow, 1.0, rn); + + float denom = max(rn * rn, uCore * uCore); + mag = min(uStrength / denom, uMaxOffset); + + vec2 tangent = vec2(-radial.y, radial.x); + dir = normalize(radial + tangent * uSwirl); + dir *= uInvert > 0.5 ? -1.0 : 1.0; + + float inward = max(-dot(dir, radial), 1e-3); + float foldLimit = screenR * 0.98 / max(uPostScale * inward, 1e-4); + mag = mix(min(mag, foldLimit), mag, clamp(uFold, 0.0, 1.0)); + #endif + + #if DISTORT_MODE == 2 + /* ---------------------------------------------------------------- */ + /* SHOCK — a travelling pressure front */ + /* ---------------------------------------------------------------- */ + /* The profile is the derivative of a Gaussian, d·exp(0.5 − d²), which is + * ±1 at d = ∓1/√2 and crosses zero exactly on the wavefront. That single + * expression gives compression just inside the front and rarefaction just + * outside it with no seam between them — the first version used two + * smoothsteps back to back and the join was visible as a stationary ring + * inside a moving one. + * + * uWave is a metre, so the ability resolves it from settings every frame: + * dragging the ring's speed while paused moves a front that is standing. */ + float rm2 = length(vLocal); + float rn2 = rm2 / max(uRadius, 1e-3); + mask = 1.0 - smoothstep(uWindow, 1.0, rn2); + + float acc = 0.0; + for (int i = 0; i < 4; i++) { + if (float(i) >= uRings) break; + float r0 = uWave - float(i) * uRingGap; + if (r0 <= 0.0) continue; + float d = (rm2 - r0) / max(uThickness, 1e-3); + float g = d * exp(0.5 - d * d); + acc += g * (g < 0.0 ? uCompression : uRarefaction) / (1.0 + float(i) * uRingDecay); + } + + mag = min(abs(uStrength * acc), uMaxOffset); + dir = radial * (acc < 0.0 ? -1.0 : 1.0); + #endif + + #if DISTORT_MODE == 3 + /* ---------------------------------------------------------------- */ + /* BLADE — a plane of vacuum */ + /* ---------------------------------------------------------------- */ + /* A razor-thin slab shifts what is behind it along its own surface normal, + * and the shift goes up as the view runs along the slab because that is + * where the path through it is longest. So the whole effect is + * normal projected to screen × (1 − |N·V|)^grazing, and the reason you see + * a vacuum blade at all is that you are almost never looking squarely at it. + * + * uCut extends it along its own length: the blade is not scaled, it is + * *drawn further*, so the wake it has already opened stays where it was. */ + float along = vUv.x; + float across = 1.0 - vUv.y; // 0 at the cutting edge, 1 at the trailing edge + + mask = smoothstep(0.0, 0.05, along) * (1.0 - smoothstep(uCut - 0.06, uCut + 0.01, along)); + mask *= 1.0 - smoothstep(uWake * 0.55, uWake, across); + + vec3 nv = vNormalV; + vec2 nScreen = nv.xy; + + vec3 rp = vec3(vWorld.xz * uRippleScale, vWorld.y * uRippleScale - uTime * uRippleSpeed); + nScreen += vec2(fbm3(rp), fbm3(rp + vec3(7.3, 19.7, 2.9))) * uRipple; + + float nl = length(nScreen); + dir = nl > 1e-6 ? nScreen / nl : vec2(0.0); + + float graze = pow(clamp(1.0 - abs(nv.z), 0.0, 1.0), uGrazing); + float hairline = exp(-across / max(uEdge, 1e-3)); + mag = uStrength * graze * (1.0 + uEdgeGain * hairline); + #endif + + #if DISTORT_MODE == 4 + /* ---------------------------------------------------------------- */ + /* REFRACT — a hull that bends what is behind it */ + /* ---------------------------------------------------------------- */ + /* The generic one: water, glass, a prism, a pane of frozen time. The offset + * is the surface normal in **view** space, whose xy *is* the screen-space + * direction the refracted ray leaves in, weighted by a rim term so the + * silhouette bends hardest — which is what a solid of any real index of + * refraction actually does. + * + * uPower = 0 flattens the rim term to 1 and the hull becomes a uniform + * offset, which is the pane-of-glass look. */ + vec3 hn = gl_FrontFacing ? vNormalV : -vNormalV; + + vec3 wp = vec3(vWorld.xz * uRippleScale, vWorld.y * uRippleScale - uTime * uRippleSpeed); + vec2 raw = hn.xy + vec2(fbm3(wp), fbm3(wp + vec3(23.1, 4.7, 17.5))) * uRipple; + + float rl = length(raw); + dir = rl > 1e-6 ? raw / rl : vec2(0.0); + + float rim = pow(clamp(1.0 - abs(hn.z), 0.0, 1.0), uPower); + mag = uStrength * rim * min(rl, 2.0); + #endif + + /* ---------------------------------------------------------------- */ + /* Shared tail */ + /* ---------------------------------------------------------------- */ + + /* The distortion buffer has no depth of its own — nothing else renders into + * it — so an emitter behind a pillar would happily warp the pillar. The + * opaque depth prepass is already sitting in uSceneDepth for the soft + * particles, and it costs one sample to use it here too. This is the only + * occlusion the pass gets, and it is why the character stops shimmering the + * moment they walk in front of the flame. */ + float occluded = softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uDepthFade); + mask *= mix(1.0, occluded, clamp(uDepthReject, 0.0, 1.0)); + + mag *= mix(1.0, clamp(uPerspectiveRef / max(-vViewZ, 0.05), 0.0, 4.0), uPerspective); + mag = clamp(mag * uShaderIntensity, 0.0, 8.0); + + float alpha = clamp(mask * uOpacity, 0.0, 1.0) * vOnScreen; + + /* Blending costs more than the fragment does at this point. */ + if (alpha * mag < 0.0004) discard; + + gl_FragColor = vec4(clamp(dir, -1.0, 1.0) * 0.5 + 0.5, mag, alpha); + } +`; + +/** + * The blade's hairline. + * + * An offset buffer cannot carry colour, so the one thing a vacuum blade *is* + * allowed to emit — the white line at its cutting edge — has to be a second + * mesh on `LAYER.VFX`. It shares the anchor, size, pivot, axis and cut uniform + * boxes with the distortion material by identity, so `update()` writes them once + * and both passes see the same plane. Two draw calls, welded together for free. + */ +const BLADE_EDGE_FRAGMENT = /* glsl */ ` + uniform vec3 uColorEdge; + uniform float uEdgeGlow; + uniform float uEdge; + uniform float uCut; + uniform float uOpacity; + uniform float uGlobalGlow; + + varying vec2 vUv; + + void main() { + float along = vUv.x; + float across = 1.0 - vUv.y; + float line = exp(-across / max(uEdge * 0.35, 1e-4)); + float ends = smoothstep(0.0, 0.03, along) * (1.0 - smoothstep(uCut - 0.05, uCut, along)); + float a = line * ends * uOpacity; + if (a < 0.002) discard; + gl_FragColor = vec4(uColorEdge * uEdgeGlow * uGlobalGlow * a, a); + } +`; + +/* ---------------------------------------------------------------- */ +/* Defaults */ +/* ---------------------------------------------------------------- */ + +/** + * Every uniform's neutral value. + * + * These are *not* an art direction — they are the values that make the emitter + * visible and stable so an ability author can see something on the first frame + * and then author every one of them as a slider. Anything an ability leaves out + * of its `update()` bag falls back to here and stays there, which is the one + * way this module can be used to violate **I1**. Don't. + */ +const DEFAULTS = { + strength: 0.35, + opacity: 1, + seed: 0, + depthReject: 1, + depthFade: 0.35, + perspective: 0, + perspectiveRef: 12, + + /* radial */ + radius: 3, + window: 0.55, + core: 0.16, + maxOffset: 1.6, + invert: 0, + fold: 0, + swirl: 0, + + /* shock */ + wave: 1, + thickness: 0.45, + compression: 1, + rarefaction: 0.7, + rings: 1, + ringGap: 0.9, + ringDecay: 0.8, + + /* heat */ + frequency: 1.1, + speed: 1.4, + sourceBias: 1.4, + spread: 0.8, + vertical: 0.35, + flicker: 0.25, + + /* blade */ + cut: 1, + grazing: 1.5, + edge: 0.06, + edgeGain: 2.2, + wake: 1, + edgeGlow: 3, + edgeColor: '#ffffff', + + /* blade + refract */ + ripple: 0, + rippleScale: 1.6, + rippleSpeed: 1.2, + + /* refract */ + power: 1.6 +}; + +/** Pivot per mode: a heat column sits *on* its anchor, everything else centres. */ +const PIVOT = { + [DistortionMode.HEAT]: [0.5, 0], + [DistortionMode.LENS]: [0.5, 0.5], + [DistortionMode.SHOCK]: [0.5, 0.5], + [DistortionMode.BLADE]: [0, 0.5], + [DistortionMode.REFRACT]: [0.5, 0.5] +}; + +const num = (v, d) => (v === undefined || v === null ? d : v); + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope, reused, never allocated in a frame (I3) */ +/* ---------------------------------------------------------------- */ + +const _ax = new Vector3(); +const _ay = new Vector3(); +const _n = new Vector3(); + +/* ---------------------------------------------------------------- */ +/* DistortionField */ +/* ---------------------------------------------------------------- */ + +/** + * One emitter. Add `field.object3D` to your ability's group, call + * `setAnchor()` / `setBasis()` and `update()` every frame, and toggle + * `field.visible`. + * + * ```js + * const _p = new Vector3(); + * const _d = {}; // module scope — I3 + * + * this.haze = new DistortionField({ mode: DistortionMode.HEAT }); + * this.group.add(this.haze.object3D); + * // … + * onTravel(dt) { + * const c = settings.sunspear; + * this.haze.setAnchor(this.pointAt(this.u, _p)); + * _d.width = c.hazeWidth; // metres, resolved this frame + * _d.height = c.hazeHeight; + * _d.strength = c.hazeStrength * (1 - this.u * c.hazeDecay); + * this.haze.update(_d); + * } + * ``` + */ +export class DistortionField { + /** + * @param {object} options + * @param {number} options.mode one of `DistortionMode` + * @param {string} [options.facing] one of `DistortionFacing`; defaults per mode + * @param {THREE.BufferGeometry} [options.geometry] + * a hull for `REFRACT`. Supplying one switches the emitter to using the + * mesh's own matrix and normals, and `object3D` becomes something you + * position and scale in the ordinary way. + * @param {boolean} [options.edge] `BLADE` only — also draw the bright hairline + * @param {number} [options.renderOrder] within the distortion pass, later wins + * @param {string} [options.name] + */ + constructor({ mode = DistortionMode.HEAT, facing, geometry = null, edge = false, renderOrder = 0, name } = {}) { + this.mode = mode; + this.isHull = geometry !== null; + this.facing = + facing || + (this.isHull + ? DistortionFacing.WORLD + : mode === DistortionMode.HEAT + ? DistortionFacing.UPRIGHT + : mode === DistortionMode.BLADE + ? DistortionFacing.WORLD + : DistortionFacing.BILLBOARD); + + const pivot = PIVOT[mode] || [0.5, 0.5]; + + /* Uniform boxes the blade's edge pass shares by identity. */ + this._shared = { + uAnchor: { value: new Vector3() }, + uSize: { value: new Vector2(1, 1) }, + uPivot: { value: new Vector2(pivot[0], pivot[1]) }, + uAxisX: { value: new Vector3(1, 0, 0) }, + uAxisY: { value: new Vector3(0, 1, 0) }, + uCut: { value: DEFAULTS.cut }, + uEdge: { value: DEFAULTS.edge }, + uOpacity: { value: DEFAULTS.opacity } + }; + + this.material = new ShaderMaterial({ + name: name ? `${name}:distortion` : 'DistortionField', + transparent: true, + depthWrite: false, + // Nothing else renders into the offset buffer, so there is no depth to + // test against. Occlusion comes from the prepass in the fragment shader. + depthTest: false, + blending: NormalBlending, + side: this.isHull ? FrontSide : DoubleSide, + toneMapped: false, + defines: { + DISTORT_MODE: String(mode), + [FACING_DEFINE[this.facing]]: '', + ...(this.isHull ? { DISTORT_HULL: '' } : {}) + }, + uniforms: sharedUniforms({ + ...this._shared, + + uStrength: { value: DEFAULTS.strength }, + uSeed: { value: DEFAULTS.seed }, + uDepthReject: { value: DEFAULTS.depthReject }, + uDepthFade: { value: DEFAULTS.depthFade }, + uPerspective: { value: DEFAULTS.perspective }, + uPerspectiveRef: { value: DEFAULTS.perspectiveRef }, + uPostScale: { value: 0.045 }, + + uRadius: { value: DEFAULTS.radius }, + uWindow: { value: DEFAULTS.window }, + uCore: { value: DEFAULTS.core }, + uMaxOffset: { value: DEFAULTS.maxOffset }, + uInvert: { value: DEFAULTS.invert }, + uFold: { value: DEFAULTS.fold }, + uSwirl: { value: DEFAULTS.swirl }, + + uWave: { value: DEFAULTS.wave }, + uThickness: { value: DEFAULTS.thickness }, + uCompression: { value: DEFAULTS.compression }, + uRarefaction: { value: DEFAULTS.rarefaction }, + uRings: { value: DEFAULTS.rings }, + uRingGap: { value: DEFAULTS.ringGap }, + uRingDecay: { value: DEFAULTS.ringDecay }, + + uFrequency: { value: DEFAULTS.frequency }, + uSpeed: { value: DEFAULTS.speed }, + uSourceBias: { value: DEFAULTS.sourceBias }, + uSpread: { value: DEFAULTS.spread }, + uVertical: { value: DEFAULTS.vertical }, + uFlicker: { value: DEFAULTS.flicker }, + + uGrazing: { value: DEFAULTS.grazing }, + uEdgeGain: { value: DEFAULTS.edgeGain }, + uWake: { value: DEFAULTS.wake }, + + uRipple: { value: DEFAULTS.ripple }, + uRippleScale: { value: DEFAULTS.rippleScale }, + uRippleSpeed: { value: DEFAULTS.rippleSpeed }, + + uPower: { value: DEFAULTS.power } + }), + vertexShader: DISTORTION_VERTEX, + fragmentShader: DISTORTION_FRAGMENT + }); + + this.mesh = new Mesh(geometry || uprightQuad(), this.material); + this.mesh.name = name || 'DistortionField'; + this.mesh.layers.set(LAYER.DISTORTION); + this.mesh.renderOrder = renderOrder; + // The quad is built from uniforms, so its bounding sphere is a lie. + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = this.isHull; + this.mesh.visible = false; + + /** The blade's bright hairline, or null. */ + this.edgeMaterial = null; + this.edgeMesh = null; + if (edge && mode === DistortionMode.BLADE) { + this.edgeMaterial = new ShaderMaterial({ + name: name ? `${name}:edge` : 'DistortionBladeEdge', + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + defines: { DISTORT_MODE: String(mode), [FACING_DEFINE[this.facing]]: '' }, + uniforms: sharedUniforms({ + ...this._shared, + uColorEdge: { value: new Color(DEFAULTS.edgeColor) }, + uEdgeGlow: { value: DEFAULTS.edgeGlow } + }), + vertexShader: DISTORTION_VERTEX, + fragmentShader: BLADE_EDGE_FRAGMENT + }); + this.edgeMesh = new Mesh(uprightQuad(), this.edgeMaterial); + this.edgeMesh.name = `${name || 'DistortionField'}:edge`; + this.edgeMesh.layers.set(LAYER.VFX); + this.edgeMesh.renderOrder = 14; + this.edgeMesh.frustumCulled = false; + this.edgeMesh.matrixAutoUpdate = false; + this.edgeMesh.visible = false; + this.mesh.add(this.edgeMesh); + } + + this._visible = false; + } + + /** Add this to the ability's group. */ + get object3D() { + return this.mesh; + } + + /** + * Visible emitters are counted so `PostProcessing` can skip the whole pass — + * clear included — on frames where nothing is warping anything. That was the + * documented cost of the rough edge this module closes; leaving it in place + * while *also* adding writers would be a poor trade. + */ + get visible() { + return this._visible; + } + + set visible(v) { + const on = !!v; + if (on === this._visible) return; + this._visible = on; + this.mesh.visible = on; + if (this.edgeMesh) this.edgeMesh.visible = on; + if (on) retainDistortion(); + else releaseDistortion(); + } + + /** Pin the emitter to a world point. Metres. */ + setAnchor(v) { + this._shared.uAnchor.value.copy(v); + return this; + } + + /** Pin the emitter to a world point, component-wise. */ + setAnchorXYZ(x, y, z) { + this._shared.uAnchor.value.set(x, y, z); + return this; + } + + /** + * Orient a `WORLD`-facing emitter without touching a matrix. + * + * @param {THREE.Vector3} along the plane's local +X, e.g. the cast heading + * @param {THREE.Vector3} up the plane's local +Y, e.g. world up + * + * `up` is re-orthogonalised against `along`, so passing the world up vector + * for a blade that is leaning downrange does the right thing. + */ + setBasis(along, up) { + _ax.copy(along).normalize(); + _n.crossVectors(_ax, up).normalize(); + _ay.crossVectors(_n, _ax).normalize(); + this._shared.uAxisX.value.copy(_ax); + this._shared.uAxisY.value.copy(_ay); + return this; + } + + /** + * Re-resolve every dimension. Call it every frame, including on a zero-length + * one — that is the whole contract (**I1**). + * + * Keys, all optional, all falling back to `DEFAULTS`: + * + * *shared* — `width` `height` (metres, the quad), `strength` (screen widths at + * `post.distortion = 1`), `opacity`, `seed`, `depthReject`, `depthFade` + * (metres), `perspective`, `perspectiveRef` (metres). + * + * *LENS / SHOCK* — `radius` (metres), `window` (0..1 of radius), + * `maxOffset`. + * + * *LENS* — `core` (0..1 of radius, the 1/r² clamp), `invert`, `fold`, `swirl`. + * + * *SHOCK* — `wave` (metres, the wavefront), `thickness` (metres), + * `compression`, `rarefaction`, `rings` (1..4), `ringGap` (metres), + * `ringDecay`. + * + * *HEAT* — `frequency` (cycles/m), `speed` (m/s), `sourceBias`, `spread`, + * `vertical`, `flicker`. + * + * *BLADE* — `cut` (0..1), `grazing`, `edge` (0..1 of height), `edgeGain`, + * `wake` (0..1 of height), plus `edgeColor` and `edgeGlow` when the hairline + * is enabled. + * + * *BLADE / REFRACT* — `ripple`, `rippleScale` (cycles/m), `rippleSpeed` (m/s). + * + * *REFRACT* — `power` (rim exponent; 0 is a flat pane). + * + * @param {object} p a plain object. Keep it at module scope and refill it. + */ + update(p) { + const u = this.material.uniforms; + const s = this._shared; + + if (!this.isHull) { + s.uSize.value.set(num(p.width, 1), num(p.height, 1)); + } + + s.uOpacity.value = num(p.opacity, DEFAULTS.opacity); + s.uCut.value = num(p.cut, DEFAULTS.cut); + s.uEdge.value = num(p.edge, DEFAULTS.edge); + + u.uStrength.value = num(p.strength, DEFAULTS.strength); + u.uSeed.value = num(p.seed, DEFAULTS.seed); + u.uDepthReject.value = num(p.depthReject, DEFAULTS.depthReject); + u.uDepthFade.value = num(p.depthFade, DEFAULTS.depthFade); + u.uPerspective.value = num(p.perspective, DEFAULTS.perspective); + u.uPerspectiveRef.value = num(p.perspectiveRef, DEFAULTS.perspectiveRef); + + // The fold guard needs the exact gain the post pass will apply, and the + // post pass is the only place the two global multipliers live. Reading them + // here — rather than baking them into `strength` — is what keeps the guard + // exact when someone drags `global.distortion`. + u.uPostScale.value = settings.post.distortion * settings.global.distortion; + + u.uRadius.value = num(p.radius, DEFAULTS.radius); + u.uWindow.value = num(p.window, DEFAULTS.window); + u.uCore.value = num(p.core, DEFAULTS.core); + u.uMaxOffset.value = num(p.maxOffset, DEFAULTS.maxOffset); + u.uInvert.value = num(p.invert, DEFAULTS.invert); + u.uFold.value = num(p.fold, DEFAULTS.fold); + u.uSwirl.value = num(p.swirl, DEFAULTS.swirl); + + u.uWave.value = num(p.wave, DEFAULTS.wave); + u.uThickness.value = num(p.thickness, DEFAULTS.thickness); + u.uCompression.value = num(p.compression, DEFAULTS.compression); + u.uRarefaction.value = num(p.rarefaction, DEFAULTS.rarefaction); + u.uRings.value = num(p.rings, DEFAULTS.rings); + u.uRingGap.value = num(p.ringGap, DEFAULTS.ringGap); + u.uRingDecay.value = num(p.ringDecay, DEFAULTS.ringDecay); + + u.uFrequency.value = num(p.frequency, DEFAULTS.frequency); + u.uSpeed.value = num(p.speed, DEFAULTS.speed); + u.uSourceBias.value = num(p.sourceBias, DEFAULTS.sourceBias); + u.uSpread.value = num(p.spread, DEFAULTS.spread); + u.uVertical.value = num(p.vertical, DEFAULTS.vertical); + u.uFlicker.value = num(p.flicker, DEFAULTS.flicker); + + u.uGrazing.value = num(p.grazing, DEFAULTS.grazing); + u.uEdgeGain.value = num(p.edgeGain, DEFAULTS.edgeGain); + u.uWake.value = num(p.wake, DEFAULTS.wake); + + u.uRipple.value = num(p.ripple, DEFAULTS.ripple); + u.uRippleScale.value = num(p.rippleScale, DEFAULTS.rippleScale); + u.uRippleSpeed.value = num(p.rippleSpeed, DEFAULTS.rippleSpeed); + + u.uPower.value = num(p.power, DEFAULTS.power); + + if (this.edgeMaterial) { + const e = this.edgeMaterial.uniforms; + copyColor(e.uColorEdge.value, p.edgeColor || DEFAULTS.edgeColor); + e.uEdgeGlow.value = num(p.edgeGlow, DEFAULTS.edgeGlow); + } + + return this; + } + + dispose() { + this.visible = false; + this.material.dispose(); + this.edgeMaterial?.dispose(); + // The quad is a shared singleton; a supplied hull belongs to its owner. + this.mesh.parent?.remove(this.mesh); + } +} diff --git a/src/vfx/FilamentPaths.js b/src/vfx/FilamentPaths.js new file mode 100644 index 0000000..6c48e5c --- /dev/null +++ b/src/vfx/FilamentPaths.js @@ -0,0 +1,1297 @@ +import { + Group, + Mesh, + ShaderMaterial, + AdditiveBlending, + DoubleSide, + Color, + Vector3, + Vector4 +} from 'three'; +import { createBoltRibbonGeometry } from '../assets/ProceduralGeometry.js'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { disruptGLSL, disruptUniforms } from './SceneHooks.js'; +import { getColor } from '../utils/color.js'; +import { LAYER } from '../core/Layers.js'; + +/* ---------------------------------------------------------------------- */ +/* FilamentPaths — every filament in the project, on two draw calls */ +/* ---------------------------------------------------------------------- */ + +/** + * The instanced ribbon strip with pluggable parametric paths. + * + * `LightningMaterial` proved the shape of the idea: a vertex arrives as + * `(t, side)` — how far along its filament it is and which edge of the ribbon it + * is on — and leaves as a world position, so *no path exists on the CPU to go + * stale*. `SnareMaterial` then proved the generalisation: a filament's **role** + * is decided in the vertex shader by testing its instance index against a set of + * live counts, and the role picks which parametric path it is threaded along, so + * one strip draws a whip, a pillar, a crawl of tendrils and a ring of travelling + * arcs at once, and setting a count to zero retires that role outright. + * + * This module is that second idea with the snare taken out of it. Nine path + * modes, four role slots, one geometry, two draw calls, and every metre re-read + * from the caller's live settings on every frame including a zero-length one. + * + * ## What it draws + * + * One `InstancedBufferGeometry` ribbon strip (`createBoltRibbonGeometry`) drawn + * **twice** — a wide soft halo underneath and the hot core on top. Drawing the + * glow as real ribbon rather than leaving it to bloom is what keeps it + * *attached* to every kink; it is the single biggest reason the original bolt + * reads as lightning at any distance. + * + * **Two draw calls. Always.** Four roles or one, forty filaments or three. + * + * ## The three stages, which never change + * + * 1. **the path** — the role's own centreline, `pathAt(t)`. The only part that + * knows the cast's geometry. + * 2. **the frame** — a tangent by finite difference on that path and two + * normals off it. Every offset lives in this frame, which is what lets one + * kink function serve a vertical pillar and a filament crawling flat across + * the floor. + * 3. **the kinks** — octaves of *linearly* interpolated value noise. Linear on + * purpose: `smoothstep` would round the corners off, and the corners are + * the entire reason it reads as lightning rather than as a wobbly tube. + * Every mode gets them, including the ones that are not lightning; set + * `kink` to 0 on a role that wants a clean curve. + * + * ## Ground-hugging roles + * + * A kink with a free `y` buries half of every filament that runs flat, and the + * effect reads as a broken dotted line. Two knobs, both per role: `groundDamp` + * scales the world-`y` component of the kink (0.3 is the snare's value) and + * `floorY` clamps the result above the floor. Set them on `MEANDER`, `RIM` and + * any `CHAIN` that skims the ground; leave them at 1 and −∞ for anything in + * the air. + * + * ## The one rule for using it well + * + * **Fill the role from `settings[id]` every frame, and never keep a metre + * between frames.** A role's setters take resolved metres and radians, so the + * call belongs in `onTravel`/`onFade` next to the settings read that produced + * it — not in `onSpawn`. The test is the one in `docs/EXPANSION.md §0`: pause + * with **P**, drag a slider, and the standing filaments must move. + * + * ## Cost + * + * 2 draw calls. ~90 of the 128 vertex uniform vectors WebGL guarantees, which + * is why there are four role slots and twelve chain nodes rather than eight and + * thirty-two; if you need a fifth role you want a second `FilamentPaths` (and + * two more draw calls), not a wider array. + * + * @example + * this.paths = new FilamentPaths(this.group, { samples: 72, capacity: 48 }); + * // …every frame: + * const bolt = this.paths.role(0); + * bolt.count = c.strands; + * bolt.line(_hand, _target, c.sag, c.spreadNear, c.spread, c.spreadCurve, + * c.twist, c.twistSpeed, c.converge); + * bolt.ends(0, 1, 0, 1); // pinned at the hand, tapering at the tip + * bolt.draw(this.u, c.tipLength, -1e4, c.tipGlow); + * this.paths.sync(this._look, fade, this._seed); + */ + +/** Which parametric path a role is threaded along. A mode, not a dimension. */ +export const PathMode = Object.freeze({ + /** The bolt: straight axis + a fan opening downrange + linear-value-noise kinks. */ + LINE: 0, + /** A coil around the axis between two points. Railgun barrels, wound ropes. */ + HELIX: 1, + /** Great slow loops around a point, each on its own inclined plane. Caged orbs. */ + ORBIT: 2, + /** A committed veer running outward from a centre. The snare's tendril. */ + MEANDER: 3, + /** An arc travelling around a boundary, lifting over it at mid-span. */ + RIM: 4, + /** A polyline through the scattered nodes, with a per-hop lighting clock. */ + CHAIN: 5, + /** A sagging catenary between two points, with a slack→taut control. */ + LINK: 6, + /** A spiral collapsing from one radius to another as it travels. */ + SPIRAL_IN: 7, + /** A branching fracture: trunk, branches, twigs, three generations deep. */ + CRACK: 8 +}); + +/** The two passes. Same geometry, same paths — only width and profile differ. */ +export const FilamentPass = Object.freeze({ + CORE: 0, // the hot filament itself + GLOW: 1 // the wide halo it sits inside +}); + +/** Role slots per instance. Four, because four is what fits — see the header. */ +export const MAX_FILAMENT_ROLES = 4; + +/** Nodes a `CHAIN` may thread. Twelve is already more hops than reads. */ +export const MAX_CHAIN_NODES = 12; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _dir = new Vector3(); +const _lat = new Vector3(); +const _up = new Vector3(0, 1, 0); +const _flat = new Vector3(); + +/** + * Canonical look parameters with their defaults. + * + * Every one is read fresh on every call to `sync()`; a missing field falls back + * to the default listed here, so a look object only carries what the ability + * actually authors. The names are deliberately the snare's and the bolt's, so + * an ability whose settings block uses them can pass the block itself and make + * breaking invariant **I1** impossible. + * + * The five `global.*` multipliers at the bottom default to 1, which is what you + * get if you do pass the block straight through; fill them from + * `settings.global` when the ability wants the master sliders to bite. + */ +export function filamentLook() { + return { + /* --- the ribbon (metres) --- */ + width: 0.032, // half-width of the core ribbon + glowWidth: 6.2, // halo half-width as a multiple of `width` + glowOpacity: 0.44, // halo alpha relative to the core + + /* --- the kinks --- */ + jitter: 1, // metres of lateral kink, before the per-role multiplier + jitterScale: 1.4, // kinks per metre of path + octaves: 4, // 1..5 octaves of value noise + jitterFalloff: 0.55, // amplitude multiplier per octave + crawl: 2.4, // how fast the kinks slide along, per second + pinch: 0.16, // 0..1 of the path the kink is eased in over at each end + restrike: 21, // whole re-shapes per second + + /* --- the guttering --- */ + flicker: 0.26, // 0..1 depth of the whole-bundle brightness stutter + flickerSpeed: 30, // steps per second that stutter is quantised to + strandFlash: 0.45, // 0..1 depth of the per-filament blink + + /* --- the cross-ribbon profile --- */ + coreSharp: 4.4, // exponent on the core's edge falloff — higher is thinner + glowFalloff: 2.3, // the same for the halo + softFade: 0.7, // metres of depth fade against the opaque scene + + /* --- output --- */ + opacity: 1, + glow: 2.2, // emissive multiplier fed into bloom + colorCore: '#ffffff', // the white-hot centre line + colorInner: '#dcd2ff', + colorOuter: '#8f6bff', + colorHalo: '#290d8c', // the wide, dim atmosphere + + /* --- settings.global, if the ability wants them --- */ + randomness: 1, // multiplies `jitter` + noiseStrength: 1, // multiplies `jitter` + noiseFrequency: 1, // multiplies `jitterScale` + noiseSpeed: 1, // multiplies `crawl` + opacityScale: 1, // multiplies `opacity` + glowScale: 1 // multiplies `glow` + }; +} + +/* ---------------------------------------------------------------------- */ +/* The shader */ +/* ---------------------------------------------------------------------- */ + +/** + * The whole library lives in this vertex shader. + * + * Two things about it are worth knowing before reading it. + * + * **Role parameters are lifted into globals.** `pathAt()` is called three times + * per vertex (here, behind, ahead) and threading eight uniforms through every + * call would drown the maths it exists to show. They are assigned once in + * `main()` and read as `g*` from there down. + * + * **Uniform arrays are only ever indexed by a loop counter.** GLSL ES 1.00 only + * guarantees array indexing by a *constant-index-expression*, and a for-loop + * index counts while a value derived from an attribute does not. So the role + * lookup and the chain-node lookup are both a fixed loop with an `if` inside + * rather than the direct `uFrom[role]` they would obviously like to be. This is + * not a style choice; the direct version fails to compile on ANGLE. + */ +const FILAMENT_VERTEX = /* glsl */ ` + ${disruptGLSL} + varying float vDisrupt; // spellbreak's field, sampled per vertex — see SceneHooks + + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + #define ROLES ${MAX_FILAMENT_ROLES} + #define NODES ${MAX_CHAIN_NODES} + + uniform float uTime; + uniform float uSeed; + uniform float uRestrike; + uniform float uFade; + + /* --- per-role blocks. See the JSDoc on each FilamentRole method. --- */ + uniform float uMode[ROLES]; // PathMode + uniform float uCount[ROLES]; // live filaments in this role + uniform vec3 uFrom[ROLES]; // anchor A — start, or centre, metres + uniform vec3 uTo[ROLES]; // anchor B — end, or axis reference, metres + uniform vec4 uShape[ROLES]; // mode-specific + uniform vec4 uShape2[ROLES]; // mode-specific, continued + uniform vec4 uStyle[ROLES]; // kink amp, width multiplier, dim, ground damp + uniform vec4 uEnds[ROLES]; // fade start, fade end, taper start, taper end + uniform vec4 uDraw[ROLES]; // progress, tip length, floor y (m), tip glow + + /* --- the chain's scatter, unitless fractions only --- */ + uniform vec4 uNodes[NODES]; // along 0..1, lateral -1..1, lift 0..1, spare + uniform float uNodeCount; + + /* --- shared look --- */ + uniform float uJitter; + uniform float uJitterScale; + uniform float uOctaves; + uniform float uJitterFalloff; + uniform float uCrawl; + uniform float uPinch; + uniform float uWidth; + uniform float uWidthScale; + uniform float uStrandFlash; + uniform float uFlickerSpeed; + + attribute float aStrand; + + varying float vSide; + varying float vDim; + varying float vFlash; + varying float vLit; + varying float vDrawn; + varying float vTip; + varying float vEndFade; + varying float vViewZ; + + ${noiseGLSL} + + /* --- the role, hoisted out of main so pathAt can see it --- */ + float gMode; + vec3 gFrom; + vec3 gTo; + vec4 gShape; + vec4 gShape2; + vec4 gStyle; + vec4 gEnds; + vec4 gDraw; + float gF; // this filament's place in its role, 0..1, half-open + float gSeed; // per-filament, re-rolled on every strike + + /** Value noise with a *linear* ramp — piecewise-linear output, sharp corners. */ + float vnoise(float x, float seed) { + float i = floor(x); + float f = x - i; + return mix(hash11(i + seed), hash11(i + 1.0 + seed), f) * 2.0 - 1.0; + } + + /** ESSL 1.00 has no hyperbolics. The catenary needs one. */ + float coshf(float x) { return 0.5 * (exp(x) + exp(-x)); } + + /** + * Offset of one filament from its path, in the perpendicular plane. + * 'span' is the length of that path, so 'uJitterScale' stays kinks per + * *metre* whether the filament is a 20 m whip or a 1 m hop around a rim. + */ + vec2 kink(float t, float seed, float span) { + vec2 o = vec2(0.0); + float amp = 1.0; + float freq = max(uJitterScale, 0.01) * span; + float scroll = uTime * uCrawl; + + // Fixed trip count with a per-octave gate: a dynamic bound is not portable, + // and five multiply-adds are cheaper than the branch would be anyway. + for (int i = 0; i < 5; i++) { + float on = step(float(i), uOctaves - 1.0); + o.x += on * amp * vnoise(t * freq + scroll, seed + 13.0 * float(i)); + o.y += on * amp * vnoise(t * freq + scroll * 1.17, seed + 71.3 + 13.0 * float(i)); + amp *= uJitterFalloff; + freq *= 2.0; + scroll *= 1.63; + } + return o; + } + + /** Unit tangent plus two normals for the segment A→B. */ + void axisFrame(vec3 a, vec3 b, out vec3 dir, out vec3 n1, out vec3 n2) { + vec3 d = b - a; + float len = length(d); + dir = len > 1e-4 ? d / len : vec3(0.0, 1.0, 0.0); + // The usual world-up reference degenerates on exactly the paths that run + // vertically — the column, the spiral — which are the ones that need a + // frame most. + vec3 up = abs(dir.y) > 0.9 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0); + n1 = normalize(cross(dir, up)); + n2 = normalize(cross(dir, n1)); + } + + /** The chain's lateral and vertical, taken flat so 'lift' is genuinely up. */ + void chainBasis(out vec3 lat, out vec3 up) { + vec3 d = gTo - gFrom; + // 'flat' would be the obvious name and is a reserved word in GLSL ES. + vec3 ground = vec3(d.x, 0.0, d.z); + up = vec3(0.0, 1.0, 0.0); + lat = length(ground) > 1e-4 ? normalize(cross(up, normalize(ground))) : vec3(1.0, 0.0, 0.0); + } + + /** One chain node, resolved from its unitless fractions. Mirrored on the CPU. */ + vec3 nodeWorld(vec4 f, vec3 lat, vec3 up) { + return mix(gFrom, gTo, f.x) + lat * (f.y * gShape.x) + up * (f.z * gShape.y); + } + + /** + * The role's centreline at 't', before any kink is added. + * + * Every branch reads only from the 'g*' globals and 'uTime', which is what + * makes the whole thing re-resolve for free on a paused frame. + */ + vec3 pathAt(float t) { + vec3 dir, n1, n2; + + if (gMode < 0.5) { + /* ---- LINE: the bolt ---- */ + // shape = (sag m, spreadNear m, spread m, spreadCurve) + // shape2 = (twist turns, twistSpeed turns/s, converge, —) + axisFrame(gFrom, gTo, dir, n1, n2); + vec3 p = mix(gFrom, gTo, t); + p.y += gShape.x * sin(t * PI); + // A constant per-filament offset in the perpendicular plane, opening from + // spreadNear at the start to spread at the end and rolling with twist. + // This, not the noise, is what separates one filament from the next. + float a = gSeed * TAU + (t * gShape2.x + uTime * gShape2.y) * TAU; + float reach = mix(gShape.y, gShape.z, pow(clamp(t, 0.0, 1.0), max(gShape.w, 0.01))); + return p + (n1 * cos(a) + n2 * sin(a)) * reach * gF; + } + + if (gMode < 1.5) { + /* ---- HELIX: a coil around the axis ---- */ + // shape = (radius m, radiusEnd m, turns, spin turns/s) + // shape2 = (sag m, phaseSpread, taperCurve, —) + axisFrame(gFrom, gTo, dir, n1, n2); + vec3 p = mix(gFrom, gTo, t); + p.y += gShape2.x * sin(t * PI); + float a = (gF * gShape2.y + t * gShape.z + uTime * gShape.w) * TAU; + float r = mix(gShape.x, gShape.y, pow(clamp(t, 0.0, 1.0), max(gShape2.z, 0.01))); + return p + (n1 * cos(a) + n2 * sin(a)) * r; + } + + if (gMode < 2.5) { + /* ---- ORBIT: great slow loops around a point ---- */ + // shape = (radius m, arc turns, spin turns/s, wobble) + // shape2 = (tilt rad, tiltSpread rad, radiusJitter, —) + // A caged orb's filaments do not radiate, they *circle*. The loops need + // to sit on different planes or the cage reads as one flat ring, so each + // filament tips its plane by 'tilt' and rotates its ascending node by a + // per-filament hash. Wobble then stops any of them being a clean circle. + axisFrame(gFrom, gTo, dir, n1, n2); + float tilt = gShape2.x + (hash11(gSeed + 2.7) - 0.5) * gShape2.y; + vec3 u = n1; + vec3 v = n2 * cos(tilt) + dir * sin(tilt); + float node = hash11(gSeed + 8.3) * TAU; + vec3 uu = u * cos(node) + v * sin(node); + vec3 vv = -u * sin(node) + v * cos(node); + + float theta = (gF + t * gShape.y + uTime * gShape.z) * TAU; + float r = gShape.x * (1.0 + (hash11(gSeed + 5.1) - 0.5) * gShape2.z); + r *= 1.0 + gShape.w * sin(theta * 3.0 + gSeed); + return gFrom + (uu * cos(theta) + vv * sin(theta)) * r; + } + + if (gMode < 3.5) { + /* ---- MEANDER: a committed veer running outward ---- */ + // shape = (inner m, reach m, curve, wander rad) + // shape2 = (arch m, hug m, spin turns/s, —) + // The veer is a per-filament constant rather than noise so the tendril + // curves *consistently*, the way a discharge that has committed to a + // direction does; the kinks on top of it supply the rest. + axisFrame(gFrom, gTo, dir, n1, n2); + float veer = (hash11(gSeed + 5.0) - 0.5) * 2.0 * gShape.w; + float a = gF * TAU + uTime * gShape2.z * TAU + hash11(gSeed) * 0.4 + veer * pow(t, 1.4); + float r = mix(gShape.x, gShape.y, pow(clamp(t, 0.0, 1.0), max(gShape.z, 0.01))); + return gFrom + (n1 * cos(a) + n2 * sin(a)) * r + + dir * (gShape2.y + gShape2.x * sin(t * PI)); + } + + if (gMode < 4.5) { + /* ---- RIM: an arc travelling around a boundary ---- */ + // shape = (radius m, span turns, speed turns/s, lift m) + // shape2 = (jitter, hug m, phase turns, —) + axisFrame(gFrom, gTo, dir, n1, n2); + float a = (gF + uTime * gShape.z + gShape2.z) * TAU + hash11(gSeed) * 0.3 + t * gShape.y * TAU; + float r = gShape.x * (1.0 + gShape2.x * 0.25 * sin(t * 6.0 + gSeed)); + return gFrom + (n1 * cos(a) + n2 * sin(a)) * r + + dir * (gShape2.y + gShape.w * sin(t * PI)); + } + + if (gMode < 5.5) { + /* ---- CHAIN: a polyline through the scattered nodes ---- */ + // shape = (scatter m, lift m, sag m, bow m) + // shape2 = (lit cursor in hops, hold hops, overlap hops, tip hops) + vec3 lat, up; + chainBasis(lat, up); + + float segs = max(uNodeCount - 1.0, 1.0); + float x = clamp(t, 0.0, 1.0) * segs; + float s = min(floor(x), segs - 1.0); + float u = x - s; + + // Constant-index-expression lookup: see the header. 'n + 1' is still one. + vec4 fa = uNodes[0]; + vec4 fb = uNodes[1]; + for (int n = 0; n < NODES - 1; n++) { + if (float(n) == s) { fa = uNodes[n]; fb = uNodes[n + 1]; } + } + + // Linear between nodes, deliberately: a spline through the scatter would + // round off the corners, and the corners are what say "this jumped". + vec3 p = mix(nodeWorld(fa, lat, up), nodeWorld(fb, lat, up), u); + float bowSign = mod(s, 2.0) < 0.5 ? 1.0 : -1.0; + p += lat * (gShape.w * bowSign * sin(u * PI)); + p.y += gShape.z * sin(u * PI); + return p; + } + + if (gMode < 6.5) { + /* ---- LINK: a sagging catenary ---- */ + // shape = (slack m, curve, swing m, swingSpeed rad/s) + // shape2 = (taut 0..1, spread m, —, —) + // A real catenary, not a parabola: the difference is all at the ends, + // where a hanging chain leaves its anchor much steeper than a parabola + // does, and that steepness is most of what says "heavy". + axisFrame(gFrom, gTo, dir, n1, n2); + vec3 p = mix(gFrom, gTo, t); + float k = max(gShape.y, 0.01); + float bow = (coshf(k) - coshf(k * (2.0 * t - 1.0))) / max(coshf(k) - 1.0, 1e-4); + p.y -= gShape.x * (1.0 - clamp(gShape2.x, 0.0, 1.0)) * bow; + float swing = sin(uTime * gShape.w + gSeed) * gShape.z * sin(t * PI); + return p + n1 * (swing + (gF - 0.5) * 2.0 * gShape2.y); + } + + if (gMode < 7.5) { + /* ---- SPIRAL_IN: a spiral collapsing as it travels ---- */ + // shape = (radius m, radiusEnd m, turns, spin turns/s) + // shape2 = (curve, phaseSpread, wobble, —) + axisFrame(gFrom, gTo, dir, n1, n2); + float e = pow(clamp(t, 0.0, 1.0), max(gShape2.x, 0.01)); + vec3 p = mix(gFrom, gTo, e); + float theta = (gF * gShape2.y + t * gShape.z + uTime * gShape.w) * TAU; + float r = mix(gShape.x, gShape.y, e) * (1.0 + gShape2.z * sin(theta * 2.0 + gSeed)); + return p + (n1 * cos(theta) + n2 * sin(theta)) * r; + } + + /* ---- CRACK: a branching fracture ---- */ + // shape = (angle rad, lengthFrac, depthFalloff, spread) + // shape2 = (start 0..1, sag m, forkBias 0..1, —) + // Three generations, walked forward rather than recursed: a twig has to + // know where its branch went, and its branch has to know where the trunk + // went, so each pass re-anchors A→B onto the child before the next one + // reads it. Filament 0 is always the trunk, which is why a crack still + // reads with the count wound down to one. + vec3 a = gFrom; + vec3 b = gTo; + float depth = gF > 0.0001 ? 1.0 + step(mix(0.75, 0.35, clamp(gShape2.z, 0.0, 1.0)), gF) : 0.0; + + for (int gen = 0; gen < 2; gen++) { + if (float(gen) < depth) { + vec3 d = b - a; + float len = length(d); + vec3 fd = len > 1e-4 ? d / len : vec3(0.0, -1.0, 0.0); + vec3 up = abs(fd.y) > 0.9 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0); + vec3 m1 = normalize(cross(fd, up)); + vec3 m2 = normalize(cross(fd, m1)); + + float at = mix(clamp(gShape2.x, 0.0, 1.0), 1.0, hash11(gSeed + float(gen) * 17.0)); + float roll = hash11(gSeed + float(gen) * 7.3 + 3.1) * TAU; + float sgn = hash11(gSeed + float(gen) * 5.5) < 0.5 ? -1.0 : 1.0; + float ang = gShape.x * (1.0 + (hash11(gSeed + float(gen) * 2.9) - 0.5) * gShape.w); + + vec3 off = m1 * cos(roll) + m2 * sin(roll); + vec3 nd = normalize(fd * cos(ang) + off * sin(ang) * sgn); + + vec3 root = mix(a, b, at); + a = root; + b = root + nd * len * gShape.y * pow(max(gShape.z, 0.01), float(gen)); + } + } + + vec3 p = mix(a, b, t); + p.y += gShape2.y * sin(t * PI); + return p; + } + + /** Metres of path, so the kink frequency means the same thing in every mode. */ + float pathSpan() { + float len = length(gTo - gFrom); + if (gMode < 1.5) return max(len, 0.01); // LINE, HELIX + if (gMode < 2.5) return max(gShape.x * gShape.y * TAU, 0.01); // ORBIT + if (gMode < 3.5) return max(abs(gShape.y - gShape.x), 0.05); // MEANDER + if (gMode < 4.5) return max(gShape.x * gShape.y * TAU, 0.01); // RIM + return max(len, 0.01); // CHAIN, LINK, SPIRAL, CRACK + } + + /** + * How lit this point of a chain is. + * + * The clock is a cursor in *hops*, not seconds — 'ArcNetwork' integrates + * 'dt / hopTime' so that dragging the hop time never rewrites the past. A hop + * is dark until the cursor reaches it, holds for 'hold' hops, then decays over + * 'overlap' hops, which is the knob that decides whether the discharge is a + * single travelling spark or a whole lit chain. + */ + float chainLit(float t) { + float segs = max(uNodeCount - 1.0, 1.0); + float x = clamp(t, 0.0, 1.0) * segs; + float s = min(floor(x), segs - 1.0); + float u = x - s; + + float d = gShape2.x - s; + float hold = max(gShape2.y, 0.0); + float over = max(gShape2.z, 1e-3); + float tip = max(gShape2.w, 1e-3); + + float lum = step(0.0, d) * (1.0 - smoothstep(hold, hold + over, d)); + // Inside the hop the front is crossing, only the part behind it exists. + float drawn = mix(smoothstep(d, d - tip, u), 1.0, step(1.0, d)); + return lum * drawn; + } + + void main() { + float t = position.x; + float side = position.y; + vSide = side; + + /* ---- which role is this filament wearing ---- */ + // The instance index is tested against the live counts, so a role retires + // the instant its count hits zero and the rest close up behind it. + gMode = uMode[0]; gFrom = uFrom[0]; gTo = uTo[0]; + gShape = uShape[0]; gShape2 = uShape2[0]; gStyle = uStyle[0]; + gEnds = uEnds[0]; gDraw = uDraw[0]; + + float local = aStrand; + float count = 1.0; + float acc = 0.0; + for (int i = 0; i < ROLES; i++) { + float n = max(uCount[i], 0.0); + if (aStrand >= acc && aStrand < acc + n) { + local = aStrand - acc; + count = max(n, 1.0); + gMode = uMode[i]; gFrom = uFrom[i]; gTo = uTo[i]; + gShape = uShape[i]; gShape2 = uShape2[i]; gStyle = uStyle[i]; + gEnds = uEnds[i]; gDraw = uDraw[i]; + } + acc += n; + } + // Half-open on purpose: 0 and 1 would put two filaments on the same bearing + // for every mode that fans around a circle. + gF = local / count; + + // The strike index snaps every filament onto a new shape uRestrike times a + // second; the crawl inside kink() slides it continuously in between. Both + // together are what stops a held filament looking like a static ribbon. + float strike = floor(uTime * max(uRestrike, 0.01)); + gSeed = hash11(aStrand * 7.13 + uSeed + strike * 3.77) * 97.0; + + vDim = gStyle.z; + vLit = gMode > 4.5 && gMode < 5.5 ? chainLit(t) : 1.0; + + /* ---- the frame the offsets live in ---- */ + float step_ = 0.02; + vec3 here = pathAt(t); + vec3 behind = pathAt(max(t - step_, 0.0)); + vec3 ahead = pathAt(min(t + step_, 1.0)); + + vec3 tangent = ahead - behind; + tangent = length(tangent) > 1e-5 ? normalize(tangent) : vec3(0.0, 1.0, 0.0); + vec3 upRef = abs(tangent.y) > 0.9 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0); + vec3 n1 = normalize(cross(tangent, upRef)); + vec3 n2 = normalize(cross(tangent, n1)); + + /* ---- the kinks ---- */ + float pinch = max(uPinch, 1e-3); + // Only LINE has an end it may leave loose: a bolt that lands somewhere other + // than where it was aimed reads as a bug, but converge < 1 lets a fracture + // fray. Every other mode is pinched at both ends. + float converge = gMode < 0.5 ? clamp(gShape2.z, 0.0, 1.0) : 1.0; + float ends = smoothstep(0.0, pinch, t) * mix(1.0, smoothstep(0.0, pinch, 1.0 - t), converge); + + vec2 k = kink(t, gSeed, pathSpan()) * gStyle.x * uJitter * ends; + vec3 offset = n1 * k.x + n2 * k.y; + // Ground roles keep their kinks in the floor plane. A free y here buries + // half of every tendril and the effect reads as a broken dotted line. + offset.y *= gStyle.w; + + vec3 world = here + offset; + vec3 nextWorld = ahead + offset; + world.y = max(world.y, gDraw.z); + nextWorld.y = max(nextWorld.y, gDraw.z); + + /* ---- how much of it exists yet ---- */ + // The ribbon is drawn whole and clipped in the fragment rather than scaled, + // so the *shape* never changes as the front travels — only how much of it + // is there. Roles that never travel sit at progress 2 and skip all of this. + float tipLen = max(gDraw.y, 1e-3); + vDrawn = smoothstep(gDraw.x, gDraw.x - tipLen, t); + vTip = smoothstep(gDraw.x - tipLen * 2.0, gDraw.x, t) * gDraw.w; + + /* ---- turn the ribbon to face the camera ---- */ + vec3 tan2 = nextWorld - world; + tan2 = length(tan2) > 1e-5 ? normalize(tan2) : tangent; + vec3 toCamera = normalize(cameraPosition - world); + vec3 binormal = cross(tan2, toCamera); + float bl = length(binormal); + binormal = bl > 1e-4 ? binormal / bl : n1; + + /* ---- width ---- */ + // A stuttering per-filament blink, quantised to uFlickerSpeed so the whole + // bundle strobes on one clock instead of shimmering independently. + float flash = mix(1.0, hash11(floor(uTime * uFlickerSpeed) + aStrand * 3.7 + uSeed), uStrandFlash); + vFlash = flash; + + // One-sided tapers, so a filament pinned at the hand can keep its full + // width there while the loose end still comes to a point. + float taperS = pow(max(sin(min(t, 0.5) * PI), 0.0), 0.35); + float taperE = pow(max(sin(max(t, 0.5) * PI), 0.0), 0.35); + vEndFade = mix(1.0, taperS, gEnds.x) * mix(1.0, taperE, gEnds.y); + + float halfWidth = uWidth * uWidthScale * gStyle.y; + halfWidth *= mix(1.0, taperS, gEnds.z) * mix(1.0, taperE, gEnds.w); + halfWidth *= flash * uFade; + + // World space throughout: the ability's group is an identity transform, and + // going through modelMatrix would only invite it to drift. + // Opt-in to the disruption field (vfx/SceneHooks.js). One compare against a + // uniform when nothing is disrupting, and the field is smooth over metres, + // so sampling it here and interpolating costs one varying instead of a + // world position and a distance in every fragment of the strip. + vDisrupt = disruptAt(world); + + vec4 mv = viewMatrix * vec4(world + binormal * side * halfWidth, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const FILAMENT_FRAGMENT = /* glsl */ ` + ${disruptGLSL} + varying float vDisrupt; + + uniform float uTime; + uniform float uSeed; + uniform float uCoreSharp; + uniform float uGlowFalloff; + uniform float uFlicker; + uniform float uFlickerSpeed; + uniform float uPassOpacity; + uniform float uOpacity; + uniform float uGlow; + uniform float uFade; + uniform float uSoftFade; + uniform vec3 uColorCore; + uniform vec3 uColorInner; + uniform vec3 uColorOuter; + uniform vec3 uColorHalo; + + uniform float uGlobalGlow; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + varying float vSide; + varying float vDim; + varying float vFlash; + varying float vLit; + varying float vDrawn; + varying float vTip; + varying float vEndFade; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + // Ahead of the front, and behind a hop that has not lit yet, there is no + // filament at all. + float presence = vDrawn * vLit; + if (presence <= 0.002) discard; + + float v = clamp(abs(vSide), 0.0, 1.0); + + #ifdef FILAMENT_GLOW + float profile = pow(1.0 - v, max(uGlowFalloff, 0.05)); + vec3 color = mix(uColorHalo, uColorOuter, profile); + float alpha = profile; + #else + float profile = pow(1.0 - v, max(uCoreSharp, 0.05)); + vec3 color = mix(uColorOuter, uColorInner, smoothstep(0.0, 0.5, profile)); + color = mix(color, uColorCore, smoothstep(0.45, 1.0, profile)); + float alpha = profile; + #endif + + // The leading edge is where the air is actually breaking down. + color += uColorCore * vTip; + + // Quantised, not sinusoidal: real lightning stutters between brightnesses, + // it does not breathe. + float flicker = 1.0 - uFlicker * hash11(floor(uTime * uFlickerSpeed) + uSeed); + + // A loose end has to fade as well as thin. A ribbon that only narrows + // leaves a hard dot at its tip. + alpha *= vEndFade * presence * flicker * vFlash * vDim * uFade * uPassOpacity * uOpacity; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.003) discard; + + color *= uGlow * uGlobalGlow; + disruptShade(color, alpha, vDisrupt, gl_FragCoord.xy); + gl_FragColor = vec4(color, alpha); + } +`; + +/* ---------------------------------------------------------------------- */ +/* One role slot */ +/* ---------------------------------------------------------------------- */ + +/** + * A handle onto one of the four role slots. + * + * Every setter is positional and writes straight into the uniform vectors — + * there is no options object anywhere in here, because these are called every + * frame and an object literal per call per role is exactly the allocation + * invariant **I3** exists to forbid. The argument order of each mode method is + * the order of the two `vec4`s it fills; the comments in the vertex shader are + * the same list. + */ +class FilamentRole { + constructor(uniforms, index) { + this._u = uniforms; + this.index = index; + + this._u.uMode.value[index] = PathMode.LINE; + this._u.uCount.value[index] = 0; + this.style(1, 1, 1, 1); + this.ends(1, 1, 1, 1); + this.draw(2, 0.08, -1e4, 0); + } + + /** Live filaments in this role. 0 retires it outright. */ + get count() { + return this._u.uCount.value[this.index]; + } + + set count(n) { + this._u.uCount.value[this.index] = Math.max(0, Math.round(n)); + } + + /** Shorthand for `count = 0`. */ + retire() { + this._u.uCount.value[this.index] = 0; + } + + /** + * Per-role modifiers on the shared look. + * + * @param {number} kink multiplier on the shared `jitter`, unitless + * @param {number} width multiplier on the shared `width`, unitless + * @param {number} dim 0..1 alpha multiplier — how secondary this role is + * @param {number} groundDamp 0..1 on the kink's world y; 0.3 for anything flat + */ + style(kink, width, dim, groundDamp) { + this._u.uStyle.value[this.index].set(kink, width, dim, groundDamp); + return this; + } + + /** + * How each end of the filament finishes, 0 (square) .. 1 (tapered to nothing). + * + * A bolt leaving a hand wants `ends(0, 1, 0, 1)`; a rim arc with two loose + * ends wants the default `ends(1, 1, 1, 1)`. + */ + ends(fadeStart, fadeEnd, taperStart, taperEnd) { + this._u.uEnds.value[this.index].set(fadeStart, fadeEnd, taperStart, taperEnd); + return this; + } + + /** + * The travelling front and the floor. + * + * @param {number} progress 0..1 of the path that exists; ≥ 1 + tip draws it whole + * @param {number} tipLength 0..1 of the path the front is smeared over + * @param {number} floorY metres — the result is clamped above this; use -1e4 for none + * @param {number} tipGlow additive core colour at the front, unitless + */ + draw(progress, tipLength, floorY, tipGlow) { + this._u.uDraw.value[this.index].set(progress, tipLength, floorY, tipGlow); + return this; + } + + /* ---- the modes. Each one is its own uniform block. ---- */ + + /** + * `LINE` — the bolt. A straight axis bowed by `sag`, a fan opening downrange, + * and the kinks on top. + * + * @param {THREE.Vector3} from where it leaves, metres + * @param {THREE.Vector3} to where it lands, metres + * @param {number} sag metres of bow at mid-span; negative droops + * @param {number} spreadNear metres the bundle is fanned at `from` + * @param {number} spread metres it is fanned at `to` + * @param {number} spreadCurve how late the fan opens; >1 stays tight then flares + * @param {number} twist turns of roll from end to end + * @param {number} twistSpeed turns per second the whole fan rolls + * @param {number} converge 0..1 — how hard the far end is pinned to `to` + */ + line(from, to, sag, spreadNear, spread, spreadCurve, twist, twistSpeed, converge) { + return this._set(PathMode.LINE, from, to, sag, spreadNear, spread, spreadCurve, + twist, twistSpeed, converge, 0); + } + + /** + * `HELIX` — a coil wound around the axis between two points. + * + * @param {number} radius metres at `from` + * @param {number} radiusEnd metres at `to` + * @param {number} turns full turns from end to end + * @param {number} spin turns per second the coil rotates + * @param {number} sag metres of bow at mid-span + * @param {number} phaseSpread how far the filaments are spread around the coil, 1 = evenly + * @param {number} taperCurve how late the radius reaches `radiusEnd` + */ + helix(from, to, radius, radiusEnd, turns, spin, sag, phaseSpread, taperCurve) { + return this._set(PathMode.HELIX, from, to, radius, radiusEnd, turns, spin, + sag, phaseSpread, taperCurve, 0); + } + + /** + * `ORBIT` — great slow loops around a point, each on its own inclined plane. + * + * @param {THREE.Vector3} centre the point being orbited, metres + * @param {THREE.Vector3} pole a point along the mean orbital axis, metres + * @param {number} radius metres + * @param {number} arc turns one filament covers; 0.4 is an open loop + * @param {number} spin turns per second the loops travel + * @param {number} wobble 0..1 — how far from a clean circle + * @param {number} tilt radians the orbital plane is tipped by + * @param {number} tiltSpread radians of per-filament variation on that + * @param {number} radiusJitter ± fraction of `radius` per filament + */ + orbit(centre, pole, radius, arc, spin, wobble, tilt, tiltSpread, radiusJitter) { + return this._set(PathMode.ORBIT, centre, pole, radius, arc, spin, wobble, + tilt, tiltSpread, radiusJitter, 0); + } + + /** + * `MEANDER` — a committed veer running out from a centre. The snare's tendril. + * + * @param {THREE.Vector3} centre where it starts, metres + * @param {THREE.Vector3} up a point along the plane's normal (usually centre + up) + * @param {number} inner metres from the centre at t = 0 + * @param {number} reach metres at t = 1 + * @param {number} curve how late it covers the ground; <1 sprints out + * @param {number} wander radians of per-filament veer + * @param {number} arch metres it lifts at mid-span + * @param {number} hug metres it floats above the plane throughout + * @param {number} spin turns per second the whole crawl rotates + */ + meander(centre, up, inner, reach, curve, wander, arch, hug, spin) { + return this._set(PathMode.MEANDER, centre, up, inner, reach, curve, wander, + arch, hug, spin, 0); + } + + /** + * `RIM` — an arc travelling around a boundary. + * + * @param {THREE.Vector3} centre metres + * @param {THREE.Vector3} up a point along the plane's normal + * @param {number} radius metres + * @param {number} span turns one arc covers; 0.19 is a short hop + * @param {number} speed turns per second the arcs travel + * @param {number} lift metres it hops over the boundary at mid-span + * @param {number} jitter 0..1 radial wobble + * @param {number} hug metres above the plane + * @param {number} phase turns of constant offset, for a second ring + */ + rim(centre, up, radius, span, speed, lift, jitter, hug, phase) { + return this._set(PathMode.RIM, centre, up, radius, span, speed, lift, + jitter, hug, phase, 0); + } + + /** + * `CHAIN` — a polyline through the nodes set with `setNode()`. + * + * The lighting arguments are what `ArcNetwork` drives; a static chain passes + * `lit` far past the node count and gets the whole thing. + * + * @param {THREE.Vector3} from node 0, metres + * @param {THREE.Vector3} to the last node, metres + * @param {number} scatter metres the lateral fractions are scaled by + * @param {number} lift metres the lift fractions are scaled by + * @param {number} sag metres each hop bows downward at mid-hop + * @param {number} bow metres each hop bows sideways, alternating + * @param {number} lit the front, in hops + * @param {number} hold hops a lit segment stays at full + * @param {number} overlap hops it then decays over + * @param {number} tip how much of a hop the front is smeared over + */ + chain(from, to, scatter, lift, sag, bow, lit, hold, overlap, tip) { + return this._set(PathMode.CHAIN, from, to, scatter, lift, sag, bow, + lit, hold, overlap, tip); + } + + /** + * `LINK` — a sagging catenary between two points. + * + * @param {number} slack metres of droop at mid-span when fully slack + * @param {number} curve 1 is rope, 3 is heavy chain, 0.01 is a parabola + * @param {number} swing metres of lateral sway + * @param {number} swingSpeed radians per second of that sway + * @param {number} taut 0..1 — 1 pulls the sag out entirely + * @param {number} spread metres between parallel filaments + */ + link(from, to, slack, curve, swing, swingSpeed, taut, spread) { + return this._set(PathMode.LINK, from, to, slack, curve, swing, swingSpeed, + taut, spread, 0, 0); + } + + /** + * `SPIRAL_IN` — a spiral collapsing from one radius to another as it travels. + * + * @param {THREE.Vector3} from the wide end, metres + * @param {THREE.Vector3} to the point it spirals into, metres + * @param {number} radius metres at `from` + * @param {number} radiusEnd metres at `to` + * @param {number} turns full turns along the way + * @param {number} spin turns per second + * @param {number} curve how late the travel happens; >1 lingers wide + * @param {number} phaseSpread how far the filaments are spread around, 1 = evenly + * @param {number} wobble 0..1 radial wobble + */ + spiralIn(from, to, radius, radiusEnd, turns, spin, curve, phaseSpread, wobble) { + return this._set(PathMode.SPIRAL_IN, from, to, radius, radiusEnd, turns, spin, + curve, phaseSpread, wobble, 0); + } + + /** + * `CRACK` — a branching fracture, three generations deep. + * + * Filament 0 is always the trunk from `from` to `to`; the rest fork off it and + * off each other, so the count is "how much fracture", not "how many lines". + * + * @param {number} angle radians a branch leaves its parent by + * @param {number} lengthFrac branch length as a fraction of its parent's + * @param {number} depthFalloff extra shortening per generation + * @param {number} spread ± fraction of variation on `angle` + * @param {number} start 0..1 — earliest point on a parent a fork may happen + * @param {number} sag metres of bow on each segment + * @param {number} forkBias 0..1 — slides the branch/twig split + */ + crack(from, to, angle, lengthFrac, depthFalloff, spread, start, sag, forkBias) { + return this._set(PathMode.CRACK, from, to, angle, lengthFrac, depthFalloff, spread, + start, sag, forkBias, 0); + } + + /** @private Everything above funnels through here. */ + _set(mode, from, to, a, b, c, d, e, f, g, h) { + const i = this.index; + const u = this._u; + u.uMode.value[i] = mode; + u.uFrom.value[i].copy(from); + u.uTo.value[i].copy(to); + u.uShape.value[i].set(a, b, c, d); + u.uShape2.value[i].set(e, f, g, h); + return this; + } +} + +/* ---------------------------------------------------------------------- */ +/* The system */ +/* ---------------------------------------------------------------------- */ + +function fillVector(count, make) { + const out = new Array(count); + for (let i = 0; i < count; i++) out[i] = make(); + return out; +} + +export class FilamentPaths { + /** + * @param {THREE.Object3D} parent the ability's group + * @param {object} [options] + * @param {number} [options.samples=72] nodes along one filament. The ceiling on + * how fine a kink can be: anything higher-frequency than one kink per two + * samples only aliases, so `jitterScale` above `samples / (2 × span)` stops + * adding detail and starts costing vertices. + * @param {number} [options.capacity=48] hard ceiling on filaments across all roles + * @param {number} [options.renderOrder=11] halo draws here, core at +2 + * @param {number} [options.layer] defaults to LAYER.VFX + */ + constructor(parent, options = {}) { + const samples = Math.max(2, Math.round(options.samples ?? 72)); + this.capacity = Math.max(1, Math.round(options.capacity ?? 48)); + + this.geometry = createBoltRibbonGeometry(samples, this.capacity); + this.geometry.instanceCount = 0; + + /** + * Both passes share every uniform box except the two that define the pass + * itself, by identity — the same trick `core/FrameUniforms.js` plays. One + * write in `sync()` therefore updates the halo and the core together, and + * there is no way for them to disagree about where a filament is. + */ + const shared = { + uSeed: { value: 0 }, + uRestrike: { value: 21 }, + uFade: { value: 1 }, + + uMode: { value: new Float32Array(MAX_FILAMENT_ROLES) }, + uCount: { value: new Float32Array(MAX_FILAMENT_ROLES) }, + uFrom: { value: fillVector(MAX_FILAMENT_ROLES, () => new Vector3()) }, + uTo: { value: fillVector(MAX_FILAMENT_ROLES, () => new Vector3(0, 1, 0)) }, + uShape: { value: fillVector(MAX_FILAMENT_ROLES, () => new Vector4()) }, + uShape2: { value: fillVector(MAX_FILAMENT_ROLES, () => new Vector4()) }, + uStyle: { value: fillVector(MAX_FILAMENT_ROLES, () => new Vector4(1, 1, 1, 1)) }, + uEnds: { value: fillVector(MAX_FILAMENT_ROLES, () => new Vector4(1, 1, 1, 1)) }, + uDraw: { value: fillVector(MAX_FILAMENT_ROLES, () => new Vector4(2, 0.08, -1e4, 0)) }, + + uNodes: { value: fillVector(MAX_CHAIN_NODES, () => new Vector4()) }, + uNodeCount: { value: 2 }, + + uJitter: { value: 1 }, + uJitterScale: { value: 1.4 }, + uOctaves: { value: 4 }, + uJitterFalloff: { value: 0.55 }, + uCrawl: { value: 2.4 }, + uPinch: { value: 0.16 }, + uWidth: { value: 0.032 }, + uStrandFlash: { value: 0.45 }, + uFlickerSpeed: { value: 30 }, + uFlicker: { value: 0.26 }, + uCoreSharp: { value: 4.4 }, + uGlowFalloff: { value: 2.3 }, + uSoftFade: { value: 0.7 }, + uOpacity: { value: 1 }, + uGlow: { value: 2.2 }, + uColorCore: { value: new Color(1, 1, 1) }, + uColorInner: { value: new Color(0.86, 0.82, 1) }, + uColorOuter: { value: new Color(0.56, 0.42, 1) }, + uColorHalo: { value: new Color(0.16, 0.05, 0.55) } + }; + this.uniforms = shared; + + this.group = new Group(); + this.group.name = 'FilamentPaths'; + this.group.matrixAutoUpdate = false; + this.group.layers.set(options.layer ?? LAYER.VFX); + + this.materials = []; + this.meshes = []; + // Halo first so the core adds on top of it. + for (const pass of [FilamentPass.GLOW, FilamentPass.CORE]) { + const glow = pass === FilamentPass.GLOW; + const material = new ShaderMaterial({ + defines: glow ? { FILAMENT_GLOW: '' } : {}, + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + ...shared, + ...disruptUniforms(), + uWidthScale: { value: glow ? 6.2 : 1 }, + uPassOpacity: { value: glow ? 0.44 : 1 } + }), + vertexShader: FILAMENT_VERTEX, + fragmentShader: FILAMENT_FRAGMENT + }); + + const mesh = new Mesh(this.geometry, material); + mesh.frustumCulled = false; + mesh.matrixAutoUpdate = false; + mesh.layers.set(options.layer ?? LAYER.VFX); + mesh.renderOrder = (options.renderOrder ?? 11) + (glow ? 0 : 2); + + this.group.add(mesh); + this.materials.push(material); + this.meshes.push(mesh); + if (glow) this.glowMaterial = material; + else this.coreMaterial = material; + } + + this.roles = fillVector(MAX_FILAMENT_ROLES, () => null); + for (let i = 0; i < MAX_FILAMENT_ROLES; i++) this.roles[i] = new FilamentRole(shared, i); + + // Two nodes is the minimum chain: a straight hop from A to B. + this.setNode(0, 0, 0, 0); + this.setNode(1, 1, 0, 0); + + if (parent) parent.add(this.group); + } + + /** The two meshes. Add this to the ability's group if you passed no parent. */ + get object3D() { + return this.group; + } + + /** Two. Four roles or one, forty filaments or three. */ + get drawCalls() { + return this.meshes.length; + } + + /** Filaments actually being drawn this frame, after the capacity clamp. */ + get liveCount() { + return this.geometry.instanceCount; + } + + set visible(v) { + this.group.visible = v; + } + + get visible() { + return this.group.visible; + } + + /** @param {number} i 0..MAX_FILAMENT_ROLES-1 */ + role(i) { + return this.roles[i]; + } + + /** How many of the chain nodes are live. Clamped to `MAX_CHAIN_NODES`. */ + setNodeCount(n) { + this.uniforms.uNodeCount.value = Math.max(2, Math.min(MAX_CHAIN_NODES, Math.round(n))); + return this.uniforms.uNodeCount.value; + } + + get nodeCount() { + return this.uniforms.uNodeCount.value; + } + + /** + * One chain node, as **unitless fractions only** — the metres come from the + * role's `scatter` and `lift` when the shader resolves it, which is what lets + * a chain re-route under a slider mid-flight. + * + * @param {number} i node index + * @param {number} along 0..1 from the role's `from` to its `to` + * @param {number} lateral −1..1, scaled by the role's `scatter` + * @param {number} lift 0..1, scaled by the role's `lift` + */ + setNode(i, along, lateral, lift) { + if (i < 0 || i >= MAX_CHAIN_NODES) return; + this.uniforms.uNodes.value[i].set(along, lateral, lift, 0); + } + + /** + * Where node `i` of a `CHAIN` role actually is, in metres. + * + * This is the shader's `nodeWorld()` line for line, and it exists so the + * burst an ability fires at a node lands *on* the node rather than near it. + * Anything else — a second scatter formula on the CPU, a captured position — + * drifts the moment a slider moves. + * + * @param {number} roleIndex + * @param {number} i + * @param {THREE.Vector3} out + */ + nodePoint(roleIndex, i, out) { + const u = this.uniforms; + const from = u.uFrom.value[roleIndex]; + const to = u.uTo.value[roleIndex]; + const shape = u.uShape.value[roleIndex]; + const f = u.uNodes.value[Math.max(0, Math.min(MAX_CHAIN_NODES - 1, i))]; + + _flat.set(to.x - from.x, 0, to.z - from.z); + if (_flat.lengthSq() > 1e-8) { + _dir.copy(_flat).normalize(); + _lat.crossVectors(_up, _dir).normalize(); + } else { + _lat.set(1, 0, 0); + } + + out.copy(from).lerp(to, f.x); + out.addScaledVector(_lat, f.y * shape.x); + out.y += f.z * shape.y; + return out; + } + + /** + * Push the live look and the current cast state into both passes. + * + * Called every frame — including on a zero-length frame while the sandbox is + * paused, which is what keeps every control below a live slider. + * + * @param {object} look see `filamentLook()`; a settings block works directly + * @param {number} [fade] 1 while lit, ramping to 0 as it blows out + * @param {number} [seed] the cast's one captured dice roll + */ + sync(look, fade = 1, seed = 0) { + const u = this.uniforms; + + u.uFade.value = fade; + u.uSeed.value = seed; + u.uRestrike.value = look.restrike ?? 21; + + /* --- the counts, and the capacity clamp --- */ + // A role that overruns the capacity is truncated rather than wrapping into + // the next role's index space, which would silently redraw it as the wrong + // path. Roles earlier in the list win, so put the structural ones first. + let total = 0; + for (let i = 0; i < MAX_FILAMENT_ROLES; i++) { + const want = u.uCount.value[i]; + const room = Math.max(0, this.capacity - total); + const got = Math.min(want, room); + u.uCount.value[i] = got; + total += got; + } + this.geometry.instanceCount = total; + + /* --- the kinks. The one place the global noise multipliers bite. --- */ + const randomness = look.randomness ?? 1; + u.uJitter.value = (look.jitter ?? 1) * randomness * (look.noiseStrength ?? 1); + u.uJitterScale.value = (look.jitterScale ?? 1.4) * (look.noiseFrequency ?? 1); + u.uOctaves.value = Math.round(look.octaves ?? 4); + u.uJitterFalloff.value = look.jitterFalloff ?? 0.55; + u.uCrawl.value = (look.crawl ?? 2.4) * (look.noiseSpeed ?? 1); + u.uPinch.value = look.pinch ?? 0.16; + + /* --- the ribbon --- */ + u.uWidth.value = look.width ?? 0.032; + // The only two uniforms the passes do not share. + this.glowMaterial.uniforms.uWidthScale.value = look.glowWidth ?? 6.2; + this.glowMaterial.uniforms.uPassOpacity.value = look.glowOpacity ?? 0.44; + + u.uStrandFlash.value = look.strandFlash ?? 0.45; + u.uFlickerSpeed.value = look.flickerSpeed ?? 30; + u.uFlicker.value = look.flicker ?? 0.26; + u.uCoreSharp.value = look.coreSharp ?? 4.4; + u.uGlowFalloff.value = look.glowFalloff ?? 2.3; + u.uSoftFade.value = look.softFade ?? 0.7; + + u.uOpacity.value = (look.opacity ?? 1) * (look.opacityScale ?? 1); + u.uGlow.value = (look.glow ?? 2.2) * (look.glowScale ?? 1); + u.uColorCore.value.copy(getColor(look.colorCore ?? '#ffffff')); + u.uColorInner.value.copy(getColor(look.colorInner ?? '#dcd2ff')); + u.uColorOuter.value.copy(getColor(look.colorOuter ?? '#8f6bff')); + u.uColorHalo.value.copy(getColor(look.colorHalo ?? '#290d8c')); + } + + /** + * Retire every role and stop drawing. This is what an ability's `onDestroy()` + * calls — it leaves the instance reusable, unlike `dispose()`. + */ + clear() { + for (let i = 0; i < MAX_FILAMENT_ROLES; i++) this.uniforms.uCount.value[i] = 0; + this.geometry.instanceCount = 0; + this.uniforms.uFade.value = 0; + } + + dispose() { + this.geometry.dispose(); + for (const material of this.materials) material.dispose(); + this.group.parent?.remove(this.group); + } +} diff --git a/src/vfx/FoldMesh.js b/src/vfx/FoldMesh.js new file mode 100644 index 0000000..715f8b8 --- /dev/null +++ b/src/vfx/FoldMesh.js @@ -0,0 +1,1675 @@ +import { + BufferAttribute, + Color, + DoubleSide, + Group, + InstancedBufferAttribute, + InstancedBufferGeometry, + Mesh, + ShaderMaterial, + Sphere, + Vector2, + Vector3, + Vector4 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { putColor } from '../utils/color.js'; +import { saturate } from '../utils/math.js'; + +/* ---------------------------------------------------------------------- */ +/* FoldMesh — paper that folds without stretching */ +/* ---------------------------------------------------------------------- */ + +/** + * A flat sheet with a **crease pattern**, folded from one number. + * + * Two abilities want this and they want opposite ends of it. `origami` throws a + * flock of cranes downrange that *unfold* into flat sheets in the air; + * `scrollward` stands a ring of scrolls up and pays them out into a wall of + * text. Both are the same object — a rectangle of paper being told, by a single + * `progress` slider, how far along a sequence of rigid motions it currently is. + * + * ## The one rule: paper does not stretch + * + * This is the whole module. Everything below is in service of it, and if you + * change one line in the vertex shader, change it so that this stays true. + * + * The first version did the obvious thing: author the flat sheet, author the + * folded sheet, and `mix()` between them on `progress`. It is four lines and it + * is wrong in a way you cannot un-see. A vertex travelling in a straight line + * between two positions that are *rotated* apart cuts the chord instead of + * walking the arc, so every span across a fold **shortens** on the way over: a + * 180° fold at the halfway point has lost `1 − cos(θ/2)` — thirty per cent — of + * its width. The crane deflates as it closes and re-inflates as it opens. It + * reads as rubber, or as a balloon, and no amount of shading rescues it. + * + * So nothing here interpolates positions. **Every vertex is moved by a product + * of rigid motions**, one per crease, and a product of rigid motions preserves + * every distance on the sheet by construction — at every intermediate value of + * `progress`, not just at the ends. The isometry is not a quality setting, it + * is a consequence of the representation. + * + * ## How a crease works + * + * A crease is a **line in sheet space** with a signed target angle. Sheet space + * is the unfolded material: `aSheet` runs `-0.5 .. 0.5` on both axes and never + * changes, whatever the paper is doing. For each crease: + * + * - the *moving flap* is the half-plane you reach by turning right from the + * crease's own direction — `n = (dir.y, −dir.x)` in sheet coordinates, which + * is `cross(up, dir)` in three dimensions. Material with `d ≤ 0` is held; + * material with `d > 0` moves; + * - a **positive** angle lifts that flap toward `+y` (a *valley* seen from + * above), a negative one drives it down (a *mountain*). The tables below use + * the `VALLEY` / `MOUNTAIN` constants rather than bare signs, because a + * crease pattern read six months later is a list of signs and nothing else; + * - the crease is not a knife edge. It is a **hinge of finite radius**: over a + * band `hinge` metres wide the flap rolls onto a cylinder tangent to the + * sheet, and only past the band is it rigidly rotated. Paper does exactly + * this — a crease has a radius of a few paper thicknesses — and it costs + * nothing, because a cylindrical roll parameterised **by arc length** is + * itself isometric. The band is also where the crease highlight lives, so + * the fold catches light along its length with no extra geometry. + * + * The per-crease operator is, in the crease's own frame, a rotation by + * `φ = clamp(d/w, 0, 1)·θ` about the hinge axis followed by a slide of + * `−min(d, w)` along the rotated in-plane direction. That is a rigid motion for + * each material point — which is why it composes with the others — and + * differentiating it along `n` gives exactly 1, which is the proof that the + * band does not stretch either. Both halves of that sentence were worth the + * afternoon it took to get them: the naive "rotate the flap about the axis" + * form is rigid but has a knife crease, and the naive "roll it onto a cylinder" + * form is smooth but is not a rigid motion of the *point*, so it silently threw + * away every fold applied before it. + * + * ## The hierarchy + * + * Real folding is a tree. The head is folded, then the neck the head is on is + * folded, then the body the neck is on is folded, and the earlier folds ride + * along. The tables are authored **root first**, the way you would describe the + * model out loud, and the shader walks them **backwards** so the deepest fold is + * applied to the vertex first and every fold above it carries the result. Each + * crease line is expressed in *flat* sheet space and stays there — a parent's + * rotation transports its children automatically, so nothing has to re-derive + * where a crease has got to. + * + * Two consequences worth knowing: + * + * - the side test is done against the vertex's **flat** position, never its + * current one. Material either side of a crease is decided by the paper, not + * by where the paper happens to be pointing; + * - the loop runs `MAX_CREASES` times and skips the empty slots, because + * `uCreaseLine[i]` may only be indexed by something built out of constants + * and the loop counter. `MAX_CREASES - 1 - k` qualifies; `uCreaseCount - 1 - k` + * does not, and does not compile on ANGLE. + * + * ## The patterns + * + * `DART` (six creases), `CRANE` (seven) and `FAN` (an accordion, generated) plus + * `FLAT`, which is a sheet of paper and is not a lesser thing — it is what a + * crane becomes and what `scrollward` starts from. + * + * They are **stylised**, and the honest statement of how is this: a real crease + * pattern limits a crease to a *region*, and a half-plane fold cannot. Where a + * pattern needs one — the crane's neck and tail are strips, not halves — the + * crease carries a **gate**, a slab through the crease's own origin outside + * which the fold does not apply. A gate is a cut, not a crease: the sheet + * separates along it. Use one only where the real pattern already has a crease + * there, keep `hinge` small enough that the seam is a line rather than a gap, + * and do not go looking for a paper aeroplane you can fly. + * + * `CRANE` also wants a **square**. `aspect` away from 1 stretches the crease + * pattern, the fold angles stop meeting, and the tips open. That is not a bug in + * the fold; it is the pattern being asked to do something paper cannot. `DART` + * tolerates a rectangle, `FAN` does not care. + * + * ## `UNROLL` — the other half of the module + * + * A scroll is not a crease pattern; it is one continuous bend, and it is the + * mode `scrollward` is built on. The sheet is placed **by arc length from the + * free end**: + * + * - the paid-out run is an arc of constant curvature `curl` — the paper + * remembers the roll — with `sin(κa)/κ` and `(1−cos(κa))/κ` for the position, + * which is a cylindrical bend and therefore isometric; + * - the wound part is an **Archimedean spiral**, `r(w) = √(r₀² + wt/π)`, which + * is the exact relation between wound length and radius for paper of + * thickness `t` on a core of radius `r₀`. It **tightens toward the spool** + * because that is what the square root does, and the turn angle + * `θ = 2π(r_outer − r)/t` is its integral. Arc length along it comes back as + * `√(1 + (t/2πr)²) ≈ 1 + 10⁻⁵` — the residual stretch is five parts in a + * million at a millimetre of paper on a 30 mm core, which is a thousand times + * under a pixel. + * + * Placing by arc length is the whole trick, and the alternative fails loudly: + * place by *fraction of the sheet* and a tight inner turn and a flat metre of + * paper are declared the same amount of material, so the marks bunch up at the + * spool and stretch on the run. Because every mark this module draws — grain, + * laid lines, ink — is a function of the sheet coordinate and nothing else, the + * **foreshortening is free and it is correct**: the shader never learns it is on + * a curve, and the writing compresses as it comes off the roll because the + * paper it is printed on genuinely is compressed there in screen space. + * + * ## The shading: it has to be paper + * + * Thin, matte, slightly translucent, with a fibre grain and a crease that + * catches light. Five pickers, none derived from another: the sheet, its shaded + * side, the colour of light coming *through* it, the ink and the crease. + * + * - **Translucency is the tell.** A sheet with light behind it glows, and it + * glows *less where the ink is*, because ink is opaque and paper is not. + * That one line — the ink ghosting through from the back — is the difference + * between paper and painted card, and it costs a multiply. + * - **The grain is anisotropic and lives in sheet space.** Paper fibres lie + * along the machine direction; the noise is stretched `grainAniso` times + * along `grainAngle`, and it does not move when the sheet folds, because it + * is printed on the material rather than projected onto it. + * - **Laid lines.** The faint regular ribbing of a laid sheet. Two periods, a + * fine one and the chain lines every few centimetres. Free, and it is the + * thing that stops a big flat scroll reading as a polygon. + * - **The crease highlight** is the hinge band, brightened on the mountain + * side and darkened on the valley side, scaled by how far the fold has + * actually gone. It appears as the paper folds and vanishes when it opens, + * which is what a crease does. + * + * This is the only material in `src/vfx/` with `toneMapped: true`. It is paper, + * not light: left out of the tone map a white sheet is the brightest thing on + * the screen and blooms, and the ink school forbids bloom. + * + * ## Cost + * + * **One draw call** for the whole flock — one `InstancedBufferGeometry`, every + * sheet placed, folded and lit by the vertex shader. No textures. The vertex + * cost is `segments² × MAX_CREASES` rotations per sheet, which is the one number + * to watch: 24 segments and 12 creases is 7k rotations a sheet, fine for a + * flock of twenty, and `segments` is a constructor option because a scroll wants + * resolution along one axis and a crane wants it on both. + * + * ## Invariants + * + * - **I1** — a sheet carries four dice and a phase. Every metre — the sheet's + * own size, the spread of the flock, the hinge radius, the core of the roll — + * is re-resolved from `update()`'s params each frame, so a paused flock + * re-folds under a dragged slider. The crease *tables* carry no metres either: + * they are fractions of the sheet and multiples of π. + * - **I3** — `roll()` refills existing typed arrays; `update()` writes into + * existing uniform boxes; `setPattern()` writes into the twelve `Vector4`s it + * allocated at construction. + * - **I5** — every dimension is a slider and every colour is a picker. + * + * @example + * this.paper = new FoldMesh(this.group, { + * pattern: FoldPattern.CRANE, layout: FoldLayout.LINE, capacity: 24 + * }); + * // spawn + * this.paper.roll(); + * // travel, every frame + * this._fillParams(); // reads settings.origami into a scratch + * this.paper.setBasis(origin, direction, side, length); + * this.paper.update(this.age, this._params); + */ + +/** Hard ceiling on the crease table. Sized by the uniform budget — see the header. */ +export const MAX_CREASES = 12; + +/* ---------------------------------------------------------------- */ +/* Enums */ +/* ---------------------------------------------------------------- */ + +/** Which crease table is loaded. A mode, not a dimension — safe to capture. */ +export const FoldPattern = Object.freeze({ + /** No creases. A sheet, a page, a leaf. What a crane unfolds into. */ + FLAT: 0, + /** A paper dart: keel, two nose folds, two wings. Tolerates a rectangle. */ + DART: 1, + /** A stylised crane: body, swept wings, tail, neck, head. Wants a square. */ + CRANE: 2, + /** An accordion of parallel pleats, generated from `fanCreases(count)`. */ + FAN: 3, + /** + * Not a crease pattern at all — the sheet wraps onto a spool. Ignores the + * crease table entirely; `progress` still drives it, through `payout`. + */ + UNROLL: 4 +}); + +/** Where the sheets stand. */ +export const FoldLayout = Object.freeze({ + /** Strung down the cast line, lying flat, nose downrange. A flock. */ + LINE: 0, + /** Standing upright around a circle, facing out. Scrolls, banners, walls. */ + ZONE: 1, + /** One sheet at the anchor, carried downrange by `travel`. */ + SINGLE: 2 +}); + +/** Crease signs, so a pattern table reads as folding instructions. */ +export const VALLEY = 1; +export const MOUNTAIN = -1; + +/* ---------------------------------------------------------------- */ +/* The crease tables */ +/* ---------------------------------------------------------------- */ + +/** + * One crease. + * + * Everything here is **unitless**: fractions of the unfolded sheet, multiples of + * π, and fractions of `progress`. A table can therefore be captured — it is not + * a dimension, it is a shape — and every metre it turns into is resolved in the + * shader from a live uniform. + * + * @typedef {object} Crease + * @property {number[]} o origin of the crease line, sheet units, −0.5..0.5 + * @property {number[]} dir direction of the line, sheet units, normalised here + * @property {number} turns fold angle in half-turns, signed: + valley, − mountain + * @property {number} [t0] progress at which this crease starts folding + * @property {number} [t1] progress at which it is fully folded + * @property {number} [hinge] multiplier on the global hinge radius + * @property {number[]} [gate] gate line direction through `o`; omit for none + * @property {number[]} [span] gate slab bounds, sheet units, `[min, max]` + */ + +/** + * A paper dart. + * + * Root first: the keel, then the two nose folds inside it, then the wings. The + * nose folds stop at 0.94 rather than 1.0 turns because a fold that lies exactly + * flat on the sheet under it is two coplanar surfaces fighting for the same + * depth; the stack offset handles the rest. + */ +const DART_CREASES = [ + { o: [0, 0], dir: [0, -1], turns: 0.12 * VALLEY, t0: 0, t1: 0.35 }, // keel, left half + { o: [0, 0], dir: [0, 1], turns: 0.12 * VALLEY, t0: 0, t1: 0.35 }, // keel, right half + { o: [0, 0.5], dir: [-0.7071, -0.7071], turns: 0.94 * VALLEY, t0: 0.15, t1: 0.7 }, // nose, left + { o: [0, 0.5], dir: [0.7071, -0.7071], turns: 0.94 * VALLEY, t0: 0.15, t1: 0.7 }, // nose, right + { o: [-0.16, 0], dir: [0, -1], turns: 0.8 * MOUNTAIN, t0: 0.55, t1: 1 }, // wing, left + { o: [0.16, 0], dir: [0, 1], turns: 0.8 * MOUNTAIN, t0: 0.55, t1: 1 } // wing, right +]; + +/** + * A crane, stylised. + * + * The sheet's `+v` is the head end. Root first: the spine lifts both halves into + * the body, the wings break back out of them, and the tail, neck and head are + * gated strips down the middle. The head is authored last because it is the + * deepest fold in the tree — it has to be applied to the vertex before the neck + * that carries it. + * + * The staging is the ability: the windows are laid end to end so the bird + * assembles in the order you would fold it, and `origami` runs `progress` + * *backwards* so it comes apart in the order you would unfold it. + */ +const CRANE_CREASES = [ + { o: [0, 0], dir: [0, -1], turns: 0.55 * VALLEY, t0: 0, t1: 0.45 }, // spine, left half + { o: [0, 0], dir: [0, 1], turns: 0.55 * VALLEY, t0: 0, t1: 0.45 }, // spine, right half + { o: [-0.17, 0], dir: [0, -1], turns: 0.35 * MOUNTAIN, t0: 0.3, t1: 0.7 }, // wing break, left + { o: [0.17, 0], dir: [0, 1], turns: 0.35 * MOUNTAIN, t0: 0.3, t1: 0.7 }, // wing break, right + { + o: [0, -0.22], + dir: [1, 0], + turns: 0.5 * VALLEY, + t0: 0.45, + t1: 0.75, + gate: [0, 1], + span: [-0.13, 0.13] + }, // tail — a strip, not a half + { + o: [0, 0.15], + dir: [-1, 0], + turns: 0.6 * VALLEY, + t0: 0.55, + t1: 0.9, + gate: [0, 1], + span: [-0.11, 0.11] + }, // neck + { + o: [0, 0.35], + dir: [-1, 0], + turns: 0.75 * MOUNTAIN, + t0: 0.8, + t1: 1, + gate: [0, 1], + span: [-0.11, 0.11], + hinge: 0.6 + } // head +]; + +/** + * An accordion of `count` parallel pleats. + * + * Generated rather than authored, and it is the clearest demonstration of the + * hierarchy in the module: the first crease turns the sheet by θ, and every + * crease after it turns by **−2θ, +2θ, −2θ…** because each one has to undo its + * parent and go the same distance again the other way. Author them as ±θ each + * and you get a spiral, not a fan — which is how the sign convention got tested. + * + * @param {number} count pleats, 2..MAX_CREASES + * @param {number} [turns] half-turns of the first fold; 1 lies flat + * @returns {Crease[]} + */ +export function fanCreases(count = 8, turns = 0.5) { + const n = Math.max(2, Math.min(MAX_CREASES, Math.round(count))); + const table = []; + for (let i = 0; i < n; i++) { + const u = -0.5 + ((i + 1) / (n + 1)) * 1; + const magnitude = i === 0 ? turns : turns * 2; + const sign = i % 2 === 0 ? VALLEY : MOUNTAIN; + table.push({ + o: [u, 0], + dir: [0, 1], // moving flap is everything to the right of this pleat + turns: magnitude * sign, + // Pleats close outward from the first one, a beat apart, so the fan opens + // like a fan instead of snapping shut in one frame. + t0: (i / n) * 0.5, + t1: 0.5 + (i / n) * 0.5 + }); + } + return table; +} + +/** Keyed by `FoldPattern`. `UNROLL` and `FLAT` have no creases by definition. */ +export const CREASE_PATTERNS = Object.freeze({ + [FoldPattern.FLAT]: [], + [FoldPattern.DART]: DART_CREASES, + [FoldPattern.CRANE]: CRANE_CREASES, + [FoldPattern.FAN]: fanCreases(8), + [FoldPattern.UNROLL]: [] +}); + +/* ---------------------------------------------------------------- */ +/* The fold, in GLSL */ +/* ---------------------------------------------------------------- */ + +/** + * Injected into the vertex shader. Kept as its own chunk because it is the part + * of this file that has to be read carefully, and burying it three hundred + * lines into a placement shader is how it stops being read at all. + * + * NOTE for anyone editing: no backticks in these comments. The file is one + * template literal and a stray backtick ends it, reporting as a JavaScript + * syntax error pointing into the middle of the shader. + */ +const FOLD_GLSL = /* glsl */ ` + #define MAX_CREASES ${MAX_CREASES} + + uniform vec4 uCreaseLine[MAX_CREASES]; // (ox, oy, dx, dy) sheet units + uniform vec4 uCreaseFold[MAX_CREASES]; // (halfTurns, t0, t1, hingeScale) + uniform vec4 uCreaseGate[MAX_CREASES]; // (gdx, gdy, spanMin, spanMax) + uniform float uCreaseCount; + + uniform vec2 uSheetSize; // metres: (across, along) + uniform float uProgress; // 0 flat, 1 folded — the one slider + uniform float uFoldGain; // multiplier on every angle; > 1 overfolds + uniform float uHinge; // metres — the crease radius band + uniform float uStageEase; // 0 linear stage windows, 1 smoothstepped + uniform float uThickness; // metres of paper, per stacked layer + + /** Rodrigues, with the cosine and sine already taken. */ + vec3 spin(vec3 v, vec3 axis, float c, float s) { + return v * c + cross(axis, v) * s + axis * dot(axis, v) * (1.0 - c); + } + + /** + * Fold the sheet. + * + * @param sheet material coordinate, -0.5..0.5 on both axes. Never changes. + * @param prog this instance's progress. Staggered per sheet by the caller. + * @param pos out: position in the sheet's own local frame, metres + * @param nrm out: unit normal in that frame + * @param crease out: signed hinge term, -1..1, for the crease highlight + * @param layers out: how many flaps are stacked under this vertex + */ + void foldSheet(vec2 sheet, float prog, out vec3 pos, out vec3 nrm, + out float crease, out float layers) { + vec2 scale = uSheetSize; + vec2 rest = sheet * scale; // unfolded position, metres + pos = vec3(rest.x, 0.0, rest.y); + nrm = vec3(0.0, 1.0, 0.0); + crease = 0.0; + layers = 0.0; + + for (int k = 0; k < MAX_CREASES; k++) { + // Backwards: the table is authored root first and the deepest fold has to + // reach the vertex first. MAX_CREASES - 1 - k is a constant-index + // expression; uCreaseCount - 1 - k is not, and will not compile. + int i = MAX_CREASES - 1 - k; + if (float(i) >= uCreaseCount) continue; + + vec4 line = uCreaseLine[i]; + vec4 fold = uCreaseFold[i]; + vec4 gate = uCreaseGate[i]; + + /* --- how far through its own window is this crease? --- */ + float span = max(fold.z - fold.y, 1e-4); + float w01 = clamp((prog - fold.y) / span, 0.0, 1.0); + w01 = mix(w01, w01 * w01 * (3.0 - 2.0 * w01), uStageEase); + float theta = fold.x * PI * uFoldGain * w01; + if (abs(theta) < 1e-5) continue; + + /* --- the crease frame, in metres --- */ + // The direction is scaled with the sheet before it is normalised, so a + // diagonal crease on a rectangle stays where the paper put it. + vec2 t2 = normalize(line.zw * scale + vec2(1e-8, 0.0)); + vec2 n2 = vec2(t2.y, -t2.x); // turn right: the moving flap + vec2 o2 = line.xy * scale; + float d = dot(rest - o2, n2); // UNFOLDED distance. Material, not geometry. + if (d <= 0.0) continue; + + /* --- the gate: a slab through the crease origin, in sheet units --- */ + if (dot(gate.xy, gate.xy) > 1e-6) { + vec2 g2 = normalize(gate.xy); + float gd = dot(sheet - line.xy, vec2(g2.y, -g2.x)); + if (gd < gate.z || gd > gate.w) continue; + } + + /* --- the hinge --- */ + float hw = max(uHinge * max(fold.w, 0.02), 1e-4); + float dc = min(d, hw); + float phi = dc * theta / hw; // partway across the band, part-folded + float radius = hw / theta; // signed: mountains curl the other way + vec3 axis = vec3(t2.x, 0.0, t2.y); + vec3 nrm3 = vec3(n2.x, 0.0, n2.y); + vec3 up3 = vec3(0.0, 1.0, 0.0); + vec3 axisPoint = vec3(o2.x, 0.0, o2.y) + up3 * radius; + + float c = cos(phi); + float s = sin(phi); + + // A rigid motion of THIS material point: rotate about the hinge axis by + // the angle its own distance across the band has earnt, then slide back + // along the rotated in-plane direction by that same distance. On the + // neutral surface this lands exactly on the cylinder of radius hw/theta, + // and d(arc)/d(distance) is 1 — the band does not stretch. Past the band + // it degenerates into a plain rotation-plus-offset, which is what carries + // the flap. + pos = axisPoint + spin(pos - axisPoint, axis, c, s); + pos -= (nrm3 * c + up3 * s) * dc; + nrm = spin(nrm, axis, c, s); + + layers += 1.0; + + /* --- the highlight lives in the band, on both sides of the line --- */ + float band = 1.0 - clamp(abs(d) / hw, 0.0, 1.0); + float lit = band * clamp(abs(theta) / PI, 0.0, 1.0) * sign(theta); + if (abs(lit) > abs(crease)) crease = lit; + } + + // Flaps folded flat onto the sheet under them are not coplanar in real + // paper and must not be here either, or two layers z-fight along their whole + // shared face. One thickness per fold this vertex is downstream of, along + // the final normal: the cheapest possible depth-fight fix, and it is also + // physically what is happening. + pos += nrm * layers * uThickness; + } +`; + +/** + * `UNROLL`. Placement by arc length from the free end — see the class header for + * why that is the only correct choice and what breaks when it is not made. + */ +const UNROLL_GLSL = /* glsl */ ` + uniform float uPayout; // 0..1 of the sheet paid out flat + uniform float uCore; // metres — radius of the spool's core + uniform float uPaper; // metres — paper thickness. Sets how fast r grows. + uniform float uCurl; // 1/metres — residual curvature of the paid-out run + uniform float uSpoolClimb; // 0 spool fixed and the sheet falls, 1 the wall rises + uniform float uSpin; // radians of phase on the roll + + /** + * @param sheet material coordinate, -0.5..0.5 + * @param pos out: local position, metres + * @param nrm out: unit normal + * @param wound out: 0 on the flat run, 1 where the paper is on the roll + */ + void unrollSheet(vec2 sheet, out vec3 pos, out vec3 nrm, out float wound) { + float L = max(uSheetSize.y, 1e-4); + float s = (sheet.y + 0.5) * L; // arc length from the free end + float payout = clamp(uPayout, 0.0, 1.0) * L; + float across = sheet.x * uSheetSize.x; + + /* --- the paid-out run: an arc of constant curvature ---------------- */ + // Series-expanded below a hundredth of a curvature unit, because sin(k a)/k + // is a perfectly good straight line in the limit and a division by nothing + // is not. + float kappa = uCurl; + float aRun = min(s, payout); + float psi = kappa * aRun; + float runZ = abs(kappa) > 1e-3 ? sin(psi) / kappa : aRun; + float runY = abs(kappa) > 1e-3 ? (1.0 - cos(psi)) / kappa : 0.5 * kappa * aRun * aRun; + vec3 runPos = vec3(across, runY, runZ); + vec3 runNrm = vec3(0.0, cos(psi), -sin(psi)); + + // Where the paper leaves the roll, and the frame there. + float psiT = kappa * payout; + float tz = abs(kappa) > 1e-3 ? sin(psiT) / kappa : payout; + float ty = abs(kappa) > 1e-3 ? (1.0 - cos(psiT)) / kappa : 0.5 * kappa * payout * payout; + vec3 tangentPoint = vec3(across, ty, tz); + vec3 e1 = vec3(0.0, sin(psiT), cos(psiT)); // along the paper at the tangent + vec3 e2 = vec3(0.0, cos(psiT), -sin(psiT)); // the paper's own +y there + + /* --- the wound part: an Archimedean spiral ------------------------- */ + float W = max(L - payout, 0.0); // metres still on the roll + float t = max(uPaper, 1e-5); + float r0 = max(uCore, 1e-4); + float w = max(s - payout, 0.0); // metres in from the tangent point + float rOuter = sqrt(r0 * r0 + W * t / PI); + float r = sqrt(r0 * r0 + max(W - w, 0.0) * t / PI); + float turn = (2.0 * PI / t) * (rOuter - r) + uSpin * step(1e-6, w); + + vec3 centre = tangentPoint + e2 * rOuter; + float cs = cos(turn); + float sn = sin(turn); + vec3 spiralPos = centre + (-e2 * cs + e1 * sn) * r; + // The face that pointed at the roll becomes the inside of the roll, so the + // paper's own +y is the inward radial. Continuous with the run at turn = 0. + vec3 spiralNrm = e2 * cs - e1 * sn; + + wound = step(payout, s); + pos = mix(runPos, spiralPos, wound); + nrm = normalize(mix(runNrm, spiralNrm, wound)); + + // spoolClimb 0 pins the tangent point and lets the sheet grow downwards; + // 1 pins the free end on the floor and the spool climbs, which is the wall + // rising out of nothing that scrollward is after. + pos -= tangentPoint * (1.0 - clamp(uSpoolClimb, 0.0, 1.0)); + } +`; + +/* ---------------------------------------------------------------- */ +/* Vertex */ +/* ---------------------------------------------------------------- */ + +const FOLD_VERTEX = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + attribute vec2 aSheet; // material coordinate, -0.5..0.5 + attribute vec4 aDice; // (lateral, along, lift, size) — unitless rolls + attribute vec4 aDiceB; // (bearing, phase, tint, stagger) + attribute float aSlot; + + uniform float uTime; + uniform float uSeed; + uniform int uMode; // 0 crease table, 1 unroll + + /* --- the basis, written by setBasis() every frame --- */ + uniform vec3 uOrigin; + uniform vec3 uForward; + uniform vec3 uSide; + uniform vec3 uUp; + uniform float uLength; // metres of cast + + /* --- the flock --- */ + uniform int uLayout; + uniform float uCount; + uniform float uTravel; // 0..1 of the cast the flock has covered + uniform float uSpread; // metres of lateral scatter + uniform float uStretch; // metres of along-line scatter + uniform float uLift; // metres off the floor + uniform float uLiftJitter; + uniform float uRadius; // metres, ZONE + uniform float uRadiusJitter; + uniform float uArc; // radians of the ZONE fan, TAU for a full ring + uniform float uArcPhase; + + /* --- attitude --- */ + uniform float uPitch; // radians about the sheet's own across axis + uniform float uYaw; + uniform float uYawJitter; + uniform float uRoll; + uniform float uRollJitter; + uniform float uBob; // metres of vertical breathing + uniform float uBobRate; // radians/second + uniform float uTumble; // radians/second of free rotation about the normal + uniform float uSizeJitter; + + /* --- staging --- */ + uniform float uSpreadStagger; // fraction of progress spread across the flock + uniform float uReveal; // 0..1, sheets appear as this passes their dice + uniform float uRevealSpread; + + ${FOLD_GLSL} + ${UNROLL_GLSL} + + varying vec2 vSheet; + varying vec3 vWorld; + varying vec3 vNormal; + varying float vCrease; + varying float vLayers; + varying float vTint; + varying float vFade; + varying float vWound; + + /** Rotate about an arbitrary unit axis. */ + vec3 turnAbout(vec3 v, vec3 axis, float a) { + return spin(v, axis, cos(a), sin(a)); + } + + void main() { + float slot = aSlot; + float phase = aDiceB.y; + + /* --- where this sheet stands, and which way it is lying ---------- */ + vec3 centre; + vec3 ex; // the sheet's +u + vec3 ez; // the sheet's +v + if (uLayout == 1) { + // ZONE — upright, facing out of the circle. Scrolls, banners, walls. + float bearing = uArcPhase + (slot + 0.5) / max(uCount, 1.0) * uArc; + bearing += (aDiceB.x - 0.5) * 0.35 * uArc / max(uCount, 1.0); + vec3 radial = uSide * cos(bearing) + uForward * sin(bearing); + float r = uRadius * (1.0 + (aDice.x - 0.5) * 2.0 * uRadiusJitter); + centre = uOrigin + radial * r; + ex = normalize(cross(uUp, radial)); + ez = uUp; + } else if (uLayout == 2) { + // SINGLE — one sheet, carried downrange by travel. + centre = uOrigin + uForward * (uTravel * uLength); + ex = uSide; + ez = uForward; + } else { + // LINE — a flock strung down the cast, lying flat, nose downrange. + float along = uTravel * uLength + (aDice.y - 0.5) * 2.0 * uStretch; + centre = uOrigin + uForward * along + uSide * ((aDice.x - 0.5) * 2.0 * uSpread); + ex = uSide; + ez = uForward; + } + centre += uUp * (uLift + (aDice.z - 0.5) * 2.0 * uLiftJitter); + centre += uUp * (uBob * sin(uTime * uBobRate + phase * TAU)); + + vec3 ey = normalize(cross(ez, ex)); + + /* --- attitude, all of it live -------------------------------------- */ + float yaw = uYaw + (aDiceB.x - 0.5) * 2.0 * uYawJitter; + float roll = uRoll + (aDiceB.z - 0.5) * 2.0 * uRollJitter + uTumble * uTime; + // Pitch first about the sheet's own across axis, then yaw about the world + // up, then roll about what is left of the normal. Order matters and this one + // is "nose up, then turn, then bank", which is how a thing in the air reads. + ez = turnAbout(ez, ex, uPitch); + ey = normalize(cross(ez, ex)); + ex = turnAbout(ex, uUp, yaw); + ez = turnAbout(ez, uUp, yaw); + ey = normalize(cross(ez, ex)); + ex = turnAbout(ex, ey, roll); + ez = turnAbout(ez, ey, roll); + + /* --- fold it ------------------------------------------------------- */ + // Per-sheet stagger is a fraction of progress, so a flock opens as a + // ripple from one slider rather than in lockstep. + float prog = clamp(uProgress - aDiceB.w * uSpreadStagger, -0.5, 1.5); + + vec3 local; + vec3 lnrm; + float creaseTerm = 0.0; + float layers = 0.0; + float wound = 0.0; + if (uMode == 1) { + unrollSheet(aSheet, local, lnrm, wound); + } else { + foldSheet(aSheet, prog, local, lnrm, creaseTerm, layers); + } + + float size = 1.0 + (aDice.w - 0.5) * 2.0 * uSizeJitter; + local *= size; + + vec3 world = centre + ex * local.x + ey * local.y + ez * local.z; + vec3 normal = normalize(ex * lnrm.x + ey * lnrm.y + ez * lnrm.z); + + /* --- reveal -------------------------------------------------------- */ + // Its own die, mixed out of two of the others rather than borrowing one + // whole: share the size die and the flock appears smallest-first, which + // nobody authored and everybody notices. + float rDie = fract(aDice.w * 7.13 + aDiceB.y * 3.71); + float rs = max(uRevealSpread, 1e-3); // smoothstep(a, a, x) is a coin toss + float rStart = rDie * (1.0 - rs); + float appear = smoothstep(rStart, rStart + rs, uReveal); + + vSheet = aSheet; + vWorld = world; + vNormal = normal; + vCrease = creaseTerm; + vLayers = layers; + vTint = aDiceB.z; + vFade = appear; + vWound = wound; + + // Collapsing a hidden sheet to a point is cheaper than paying for its + // fragments and discarding them one at a time. + world = mix(centre, world, step(0.001, appear)); + + gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0); + } +`; + +/* ---------------------------------------------------------------- */ +/* Fragment */ +/* ---------------------------------------------------------------- */ + +const FOLD_FRAGMENT = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + uniform float uTime; + uniform vec3 uLightDir; + uniform float uShaderIntensity; + + uniform vec3 uColorPaper; + uniform vec3 uColorShade; + uniform vec3 uColorTransmit; + uniform vec3 uColorInk; + uniform vec3 uColorCrease; + + uniform float uAmbient; + uniform float uWrap; // how far light bends around the sheet, 0..1 + uniform float uTransmit; // how much comes through from behind + uniform float uTransmitPower; // tightness of that lobe + uniform float uSheen; // grazing specular + uniform float uGloss; + + uniform vec2 uSheetSize; + uniform float uGrain; + uniform float uGrainScale; // fibres per metre + uniform float uGrainAngle; // radians + uniform float uGrainAniso; // how far the fibres are stretched + uniform float uFleck; + uniform float uLaid; + uniform float uLaidPitch; // lines per metre + uniform float uChainPitch; // the coarse chain lines, lines per metre + + uniform float uCreaseGlow; + uniform float uCreaseDark; + uniform float uCreaseSharp; + + uniform float uInk; + uniform float uInkRows; + uniform float uInkCols; + uniform float uInkFill; + uniform float uInkWeight; + uniform float uInkMargin; + uniform float uInkSeed; + uniform float uInkGhost; // how much the writing shows through the back + + uniform float uEdge; // deckle edge softening + uniform float uTintSpread; + uniform float uOpacity; + uniform float uWoundShade; // how much darker the paper is on the roll + + ${noiseGLSL} + + varying vec2 vSheet; + varying vec3 vWorld; + varying vec3 vNormal; + varying float vCrease; + varying float vLayers; + varying float vTint; + varying float vFade; + varying float vWound; + + /** + * The writing. + * + * Hashed bars inside a grid of cells, in SHEET space — which is what makes it + * fold and wrap correctly with no work at all. Four strokes per cell reads as + * script at any distance a viewer will ever be at; the fifth stroke costs the + * same and reads as noise, which was worth finding out once. + */ + float inkField(vec2 sheet) { + vec2 g = sheet + 0.5; + float margin = uInkMargin; + float inside = step(margin, g.x) * step(g.x, 1.0 - margin) * + step(margin, g.y) * step(g.y, 1.0 - margin); + if (inside < 0.5) return 0.0; + + vec2 cells = vec2(max(uInkCols, 1.0), max(uInkRows, 1.0)); + vec2 uv = (g - margin) / max(1.0 - 2.0 * margin, 1e-3) * cells; + vec2 id = floor(uv); + vec2 f = fract(uv); + + float live = step(hash13(vec3(id, uInkSeed)) , uInkFill); + if (live < 0.5) return 0.0; + + float ink = 0.0; + float weight = max(uInkWeight, 0.002); + for (int b = 0; b < 4; b++) { + float bi = float(b); + vec3 key = vec3(id, uInkSeed + bi * 17.31); + float y = 0.18 + 0.64 * hash13(key); + float x0 = 0.12 + 0.4 * hash13(key + 3.7); + float x1 = x0 + 0.15 + 0.5 * hash13(key + 8.1); + float vertical = step(0.72, hash13(key + 12.9)); + vec2 q = mix(f, f.yx, vertical); + float bar = step(x0, q.x) * step(q.x, x1) * + (1.0 - smoothstep(weight * 0.6, weight, abs(q.y - y))); + ink = max(ink, bar); + } + return clamp(ink, 0.0, 1.0); + } + + void main() { + if (vFade <= 0.001) discard; + + vec3 N = normalize(vNormal); + if (!gl_FrontFacing) N = -N; + vec3 V = normalize(cameraPosition - vWorld); + vec3 L = normalize(uLightDir); + + /* --- the grain, in sheet metres, stretched along the machine direction --- */ + vec2 metres = vSheet * uSheetSize; + float ca = cos(uGrainAngle); + float sa = sin(uGrainAngle); + vec2 grainUV = vec2(metres.x * ca + metres.y * sa, + (-metres.x * sa + metres.y * ca) / max(uGrainAniso, 0.05)); + float fibre = fbm3(vec3(grainUV * uGrainScale, 0.0)) * 0.5 + 0.5; + // Flecks are a hashed lattice, not a noise threshold: value noise piles up + // at its midpoint, so thresholding it gives you either nothing or a rash. + vec3 fleckCell = floor(vec3(grainUV * uGrainScale * 3.0, 0.0)); + float fleck = step(0.972, hash13(fleckCell)) * uFleck; + + float laid = (sin(metres.y * uLaidPitch * TAU) * 0.5 + 0.5) * uLaid; + float chain = smoothstep(0.86, 1.0, sin(metres.x * uChainPitch * TAU) * 0.5 + 0.5) * uLaid; + + /* --- the writing --- */ + float ink = inkField(vSheet) * uInk; + // Seen from the back, the writing is a shadow inside the sheet rather than + // a mark on it. Fainter, and it never gets the ink's own colour. + float inkFace = gl_FrontFacing ? ink : ink * uInkGhost; + + /* --- lighting: thin, matte, and lit from both sides ---------------- */ + float ndl = dot(N, L); + float diffuse = clamp((ndl + uWrap) / (1.0 + uWrap), 0.0, 1.0); + // A sheet is thin enough that light behind it arrives at the eye. The lobe + // is around -L, not around the mirror direction, and the ink blocks it — + // which is the entire difference between paper and painted card. + float through = pow(clamp(dot(V, -L), 0.0, 1.0), max(uTransmitPower, 0.5)); + through *= clamp(-ndl * 0.5 + 0.5, 0.0, 1.0) * uTransmit * (1.0 - ink); + + vec3 H = normalize(L + V); + float spec = pow(clamp(dot(N, H), 0.0, 1.0), max(uGloss, 1.0)); + // Paper's sheen is grazing and anisotropic — it runs along the fibre. + float graze = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 3.0); + spec *= uSheen * (0.35 + 0.65 * graze) * (0.7 + 0.6 * fibre); + + /* --- the crease --- */ + float creaseAmount = pow(clamp(abs(vCrease), 0.0, 1.0), max(uCreaseSharp, 0.2)); + float mountain = clamp(-vCrease, 0.0, 1.0); + float valley = clamp(vCrease, 0.0, 1.0); + + /* --- put it together ----------------------------------------------- */ + vec3 body = mix(uColorShade, uColorPaper, diffuse); + body = mix(body, uColorShade, (laid + chain) * 0.5); + body *= 1.0 - fleck * 0.55; + body = mix(body, uColorPaper * (0.9 + 0.2 * fibre), uGrain * 0.5); + body = mix(body, uColorShade * 0.75, vWound * uWoundShade); + + // Deckle: the sheet thins at its edges, so it passes more light and holds + // less pigment. Two lines, and it is what stops a rectangle reading as a + // rectangle of plastic. + vec2 fromEdge = 0.5 - abs(vSheet); + float deckle = 1.0 - smoothstep(0.0, max(uEdge, 1e-4), min(fromEdge.x, fromEdge.y)); + + body += uColorTransmit * (through + deckle * uTransmit * 0.6); + body += uColorCrease * creaseAmount * uCreaseGlow * pow(mountain, 0.6); + body *= 1.0 - creaseAmount * uCreaseDark * pow(valley, 0.6); + body = mix(body, uColorInk, inkFace); + body += vec3(spec) * (1.0 - ink); + + // Stacked flaps are more opaque and slightly darker: two sheets of paper. + body *= 1.0 - clamp(vLayers, 0.0, 4.0) * 0.045; + body *= 1.0 + (vTint - 0.5) * 2.0 * uTintSpread; + body *= uAmbient + (1.0 - uAmbient) * uShaderIntensity; + + float alpha = uOpacity * vFade; + alpha = mix(alpha, alpha * 0.82, deckle); + + gl_FragColor = vec4(max(body, vec3(0.0)), clamp(alpha, 0.0, 1.0)); + } +`; + +/* ---------------------------------------------------------------- */ +/* The module */ +/* ---------------------------------------------------------------- */ + +export class FoldMesh { + /** + * @param {THREE.Object3D} parent the ability's group. Parent-first, like + * `Swarm` — this module owns its mesh and you never see it. + * @param {object} [options] + * @param {number} [options.pattern] `FoldPattern.*`. Live-swappable afterwards + * with `setPattern()`; no recompile, because the pattern is a uniform table + * and the mode is a uniform branch. + * @param {number} [options.layout] `FoldLayout.*` + * @param {number} [options.capacity] sheets in the pool + * @param {number} [options.segments] grid resolution across the sheet. The + * crease is only as sharp as the grid can resolve: a hinge narrower than one + * cell quantises into a kink. 24 is a crane; a scroll wants 12 × 48. + * @param {number} [options.segmentsV] resolution along the sheet; defaults to + * `segments`. `UNROLL` spends almost everything here. + */ + constructor(parent, options = {}) { + const { + pattern = FoldPattern.CRANE, + layout = FoldLayout.LINE, + capacity = 32, + segments = 20, + segmentsV = segments, + renderOrder = 4, + layer = LAYER.WORLD, + name = 'FoldMesh' + } = options; + + this.capacity = Math.max(1, capacity | 0); + this.group = new Group(); + this.group.name = name; + this.group.matrixAutoUpdate = false; + parent.add(this.group); + + /* --- the sheet's grid ------------------------------------------- */ + const nx = Math.max(1, segments | 0); + const nz = Math.max(1, segmentsV | 0); + const vertices = (nx + 1) * (nz + 1); + const sheet = new Float32Array(vertices * 2); + for (let j = 0; j <= nz; j++) { + for (let i = 0; i <= nx; i++) { + const o = (j * (nx + 1) + i) * 2; + sheet[o] = i / nx - 0.5; + sheet[o + 1] = j / nz - 0.5; + } + } + const indices = new Uint16Array(nx * nz * 6); + let w = 0; + for (let j = 0; j < nz; j++) { + for (let i = 0; i < nx; i++) { + const a = j * (nx + 1) + i; + const b = a + 1; + const c = a + nx + 1; + const d = c + 1; + indices[w++] = a; + indices[w++] = c; + indices[w++] = b; + indices[w++] = b; + indices[w++] = c; + indices[w++] = d; + } + } + + this._dice = new Float32Array(this.capacity * 4); + this._diceB = new Float32Array(this.capacity * 4); + const slots = new Float32Array(this.capacity); + for (let i = 0; i < this.capacity; i++) slots[i] = i; + + const geometry = new InstancedBufferGeometry(); + // The vertex shader needs no `position`, but three wants one to compute a + // bounding sphere from and every downstream chunk assumes it exists. + geometry.setAttribute('position', new BufferAttribute(new Float32Array(vertices * 3), 3)); + geometry.setAttribute('aSheet', new BufferAttribute(sheet, 2)); + geometry.setIndex(new BufferAttribute(indices, 1)); + geometry.setAttribute('aDice', new InstancedBufferAttribute(this._dice, 4)); + geometry.setAttribute('aDiceB', new InstancedBufferAttribute(this._diceB, 4)); + geometry.setAttribute('aSlot', new InstancedBufferAttribute(slots, 1)); + geometry.instanceCount = 0; + // Placed in world space by the vertex shader; its own bounds mean nothing. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.geometry = geometry; + + /* --- the crease table lives in uniforms, allocated once ---------- */ + this._creaseLine = []; + this._creaseFold = []; + this._creaseGate = []; + for (let i = 0; i < MAX_CREASES; i++) { + this._creaseLine.push(new Vector4(0, 0, 0, 1)); + this._creaseFold.push(new Vector4(0, 0, 1, 1)); + this._creaseGate.push(new Vector4(0, 0, -9, 9)); + } + + this.material = new ShaderMaterial({ + name, + transparent: true, + depthWrite: true, + depthTest: true, + side: DoubleSide, + // The one toneMapped material in this library. See the class header: it is + // paper, not light, and an untonemapped white sheet blooms. + toneMapped: true, + uniforms: sharedUniforms({ + uSeed: { value: 0 }, + uMode: { value: pattern === FoldPattern.UNROLL ? 1 : 0 }, + + uOrigin: { value: new Vector3() }, + uForward: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + uUp: { value: new Vector3(0, 1, 0) }, + uLength: { value: 12 }, + + uLayout: { value: layout }, + uCount: { value: 0 }, + uTravel: { value: 0 }, + uSpread: { value: DEFAULTS.spread }, + uStretch: { value: DEFAULTS.stretch }, + uLift: { value: DEFAULTS.lift }, + uLiftJitter: { value: DEFAULTS.liftJitter }, + uRadius: { value: DEFAULTS.radius }, + uRadiusJitter: { value: DEFAULTS.radiusJitter }, + uArc: { value: DEFAULTS.arc }, + uArcPhase: { value: DEFAULTS.arcPhase }, + + uPitch: { value: DEFAULTS.pitch }, + uYaw: { value: DEFAULTS.yaw }, + uYawJitter: { value: DEFAULTS.yawJitter }, + uRoll: { value: DEFAULTS.roll }, + uRollJitter: { value: DEFAULTS.rollJitter }, + uBob: { value: DEFAULTS.bob }, + uBobRate: { value: DEFAULTS.bobRate }, + uTumble: { value: DEFAULTS.tumble }, + uSizeJitter: { value: DEFAULTS.sizeJitter }, + + uSpreadStagger: { value: DEFAULTS.foldStagger }, + uReveal: { value: DEFAULTS.reveal }, + uRevealSpread: { value: DEFAULTS.revealSpread }, + + uSheetSize: { value: new Vector2(DEFAULTS.sheetWidth, DEFAULTS.sheetLength) }, + uProgress: { value: DEFAULTS.progress }, + uFoldGain: { value: DEFAULTS.foldGain }, + uHinge: { value: DEFAULTS.hinge }, + uStageEase: { value: DEFAULTS.stageEase }, + uThickness: { value: DEFAULTS.thickness }, + uCreaseCount: { value: 0 }, + uCreaseLine: { value: this._creaseLine }, + uCreaseFold: { value: this._creaseFold }, + uCreaseGate: { value: this._creaseGate }, + + uPayout: { value: DEFAULTS.payout }, + uCore: { value: DEFAULTS.core }, + uPaper: { value: DEFAULTS.paper }, + uCurl: { value: DEFAULTS.curl }, + uSpoolClimb: { value: DEFAULTS.spoolClimb }, + uSpin: { value: DEFAULTS.spin }, + + uColorPaper: { value: new Color(DEFAULTS.colorPaper) }, + uColorShade: { value: new Color(DEFAULTS.colorShade) }, + uColorTransmit: { value: new Color(DEFAULTS.colorTransmit) }, + uColorInk: { value: new Color(DEFAULTS.colorInk) }, + uColorCrease: { value: new Color(DEFAULTS.colorCrease) }, + + uAmbient: { value: DEFAULTS.ambient }, + uWrap: { value: DEFAULTS.wrap }, + uTransmit: { value: DEFAULTS.transmit }, + uTransmitPower: { value: DEFAULTS.transmitPower }, + uSheen: { value: DEFAULTS.sheen }, + uGloss: { value: DEFAULTS.gloss }, + + uGrain: { value: DEFAULTS.grain }, + uGrainScale: { value: DEFAULTS.grainScale }, + uGrainAngle: { value: DEFAULTS.grainAngle }, + uGrainAniso: { value: DEFAULTS.grainAniso }, + uFleck: { value: DEFAULTS.fleck }, + uLaid: { value: DEFAULTS.laid }, + uLaidPitch: { value: DEFAULTS.laidPitch }, + uChainPitch: { value: DEFAULTS.chainPitch }, + + uCreaseGlow: { value: DEFAULTS.creaseGlow }, + uCreaseDark: { value: DEFAULTS.creaseDark }, + uCreaseSharp: { value: DEFAULTS.creaseSharp }, + + uInk: { value: DEFAULTS.ink }, + uInkRows: { value: DEFAULTS.inkRows }, + uInkCols: { value: DEFAULTS.inkCols }, + uInkFill: { value: DEFAULTS.inkFill }, + uInkWeight: { value: DEFAULTS.inkWeight }, + uInkMargin: { value: DEFAULTS.inkMargin }, + uInkSeed: { value: DEFAULTS.inkSeed }, + uInkGhost: { value: DEFAULTS.inkGhost }, + + uEdge: { value: DEFAULTS.edge }, + uTintSpread: { value: DEFAULTS.tintSpread }, + uOpacity: { value: DEFAULTS.opacity }, + uWoundShade: { value: DEFAULTS.woundShade } + }), + vertexShader: FOLD_VERTEX, + fragmentShader: FOLD_FRAGMENT + }); + + this.mesh = new Mesh(geometry, this.material); + this.mesh.name = name; + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(layer); + this.mesh.renderOrder = renderOrder; + this.mesh.castShadow = false; + this.mesh.receiveShadow = false; + this.group.add(this.mesh); + + /* --- the basis --------------------------------------------------- */ + this._origin = new Vector3(); + this._direction = new Vector3(0, 0, 1); + this._side = new Vector3(1, 0, 0); + this._up = new Vector3(0, 1, 0); + this._length = 1; + + /** The last block `update()` resolved. Owned, written into, never replaced. */ + this._p = foldMeshParams(); + this.pattern = pattern; + this.liveSheets = 0; + this.seed = 0; + + this.setPattern(pattern); + this.roll(0); + } + + /* ---------------- handles ---------------- */ + + get uniforms() { + return this.material.uniforms; + } + + /** Sheets drawn on the last update. → `Ability#instanceCount`. */ + get count() { + return this.liveSheets; + } + + /** One, however many sheets. */ + get drawCalls() { + return 1; + } + + get visible() { + return this.group.visible; + } + + set visible(v) { + this.group.visible = !!v; + } + + /** Where the flock stands. LINE ⇄ ZONE ⇄ SINGLE, a uniform branch. */ + get layout() { + return this.material.uniforms.uLayout.value; + } + + set layout(l) { + this.material.uniforms.uLayout.value = l | 0; + } + + /** + * Load a crease table. + * + * Writes into the twelve `Vector4`s allocated at construction, so this is + * free to call every frame if an ability wants to morph between patterns — + * and it does not recompile, because the pattern is data and the crease/unroll + * split is a uniform branch. + * + * @param {number|Crease[]} pattern a `FoldPattern` or your own table + */ + setPattern(pattern) { + const table = Array.isArray(pattern) ? pattern : (CREASE_PATTERNS[pattern] ?? []); + const n = Math.min(MAX_CREASES, table.length); + for (let i = 0; i < MAX_CREASES; i++) { + const line = this._creaseLine[i]; + const fold = this._creaseFold[i]; + const gate = this._creaseGate[i]; + if (i >= n) { + fold.set(0, 0, 1, 1); + continue; + } + const c = table[i]; + const dx = c.dir?.[0] ?? 0; + const dy = c.dir?.[1] ?? 1; + const len = Math.hypot(dx, dy) || 1; + line.set(c.o?.[0] ?? 0, c.o?.[1] ?? 0, dx / len, dy / len); + fold.set(c.turns ?? 0, c.t0 ?? 0, c.t1 ?? 1, c.hinge ?? 1); + if (c.gate) { + const gl = Math.hypot(c.gate[0], c.gate[1]) || 1; + gate.set(c.gate[0] / gl, c.gate[1] / gl, c.span?.[0] ?? -9, c.span?.[1] ?? 9); + } else { + gate.set(0, 0, -9, 9); + } + } + this.material.uniforms.uCreaseCount.value = n; + if (!Array.isArray(pattern)) { + this.pattern = pattern; + this.material.uniforms.uMode.value = pattern === FoldPattern.UNROLL ? 1 : 0; + } + return this; + } + + /** + * Five pickers, none derived from another. + * + * Takes `THREE.Color`s or `#rrggbb` straight out of a settings block; the + * strings go through the memoised `getColor`, so calling this every frame + * costs five copies and no allocation. + */ + setColors(paper, shade, transmit, ink, crease) { + const u = this.material.uniforms; + // Written out rather than looped through a helper closure: a closure built + // inside a per-frame call allocates, and this is a per-frame call (I3). + putColor(u.uColorPaper.value, paper, DEFAULTS.colorPaper); + putColor(u.uColorShade.value, shade, DEFAULTS.colorShade); + putColor(u.uColorTransmit.value, transmit, DEFAULTS.colorTransmit); + putColor(u.uColorInk.value, ink, DEFAULTS.colorInk); + putColor(u.uColorCrease.value, crease, DEFAULTS.colorCrease); + return this; + } + + /** The cast's frame. Called once per frame by the ability. */ + setBasis(origin, direction, side, length) { + this._origin.copy(origin); + this._direction.copy(direction); + this._side.copy(side); + this._up.crossVectors(this._direction, this._side).normalize(); + if (this._up.lengthSq() < 0.5) this._up.set(0, 1, 0); + this._length = Math.max(0.01, length); + return this; + } + + /** + * Re-roll the per-sheet dice. Call from `onSpawn` and nowhere else. + * + * Four dice and four more: where the sheet sits across and along the lane, how + * high, how big, its bearing, its bob phase, its tint and its place in the + * fold stagger. Every one of them unitless — where they land in metres is + * decided by `update()` every frame, which is why a paused flock re-lays + * itself under `spread` and re-folds under `progress`. + * + * @param {number} [seed] per-cast seed; shifts the whole flock + */ + roll(seed = Math.random() * 100) { + this.seed = seed; + this.material.uniforms.uSeed.value = seed; + for (let i = 0; i < this.capacity; i++) { + const o = i * 4; + this._dice[o] = Math.random(); + this._dice[o + 1] = Math.random(); + this._dice[o + 2] = Math.random(); + this._dice[o + 3] = Math.random(); + this._diceB[o] = Math.random(); + this._diceB[o + 1] = Math.random(); + this._diceB[o + 2] = Math.random(); + this._diceB[o + 3] = Math.random(); + } + this.geometry.getAttribute('aDice').needsUpdate = true; + this.geometry.getAttribute('aDiceB').needsUpdate = true; + return this; + } + + /** Hide the flock. Leaves the instance reusable — the pooling contract. */ + reset() { + this.liveSheets = 0; + this.geometry.instanceCount = 0; + this.mesh.visible = false; + return this; + } + + /** + * Fill `_p` from the caller's block, defaults where it is silent. + * + * A `for...in` over eighty keys per frame is a rounding error next to one + * uniform upload, and it is what lets an ability hand this module its raw + * `settings[id]` — the version of the contract that cannot be got wrong. + */ + _resolve(params) { + const p = this._p; + for (const key in DEFAULTS) { + const value = params[key]; + p[key] = value === undefined ? DEFAULTS[key] : value; + } + return p; + } + + /** + * Push the live params into the uniforms. + * + * @param {number} _now seconds since the cast began. Accepted for symmetry + * with the rest of the library and unused: a sheet's motion is a standing + * one driven by the shared `uTime`, and its fold is driven by `progress`, + * which the ability owns because the *staging* is the ability. + * @param {object} params live block; anything absent falls back to + * `foldMeshParams()`. + */ + update(_now, params) { + const p = this._resolve(params ?? DEFAULTS); + const u = this.material.uniforms; + + const count = Math.max(0, Math.min(this.capacity, Math.round(p.count))); + this.liveSheets = count; + this.geometry.instanceCount = count; + this.mesh.visible = count > 0 && p.opacity > 0 && p.sheetWidth > 0 && p.sheetLength > 0; + + u.uOrigin.value.copy(this._origin); + u.uForward.value.copy(this._direction); + u.uSide.value.copy(this._side); + u.uUp.value.copy(this._up); + u.uLength.value = this._length; + + u.uCount.value = count; + u.uTravel.value = p.travel; + u.uSpread.value = p.spread; + u.uStretch.value = p.stretch; + u.uLift.value = p.lift; + u.uLiftJitter.value = p.liftJitter; + u.uRadius.value = p.radius; + u.uRadiusJitter.value = p.radiusJitter; + u.uArc.value = p.arc; + u.uArcPhase.value = p.arcPhase; + + u.uPitch.value = p.pitch; + u.uYaw.value = p.yaw; + u.uYawJitter.value = p.yawJitter; + u.uRoll.value = p.roll; + u.uRollJitter.value = p.rollJitter; + u.uBob.value = p.bob; + u.uBobRate.value = p.bobRate; + u.uTumble.value = p.tumble; + u.uSizeJitter.value = p.sizeJitter; + + u.uSpreadStagger.value = p.foldStagger; + u.uReveal.value = p.reveal; + u.uRevealSpread.value = p.revealSpread; + + /* --- the sheet and the fold ------------------------------------- */ + // aspect is a multiplier on the across axis. A crane wants 1; see the header. + u.uSheetSize.value.set(p.sheetWidth * p.aspect, p.sheetLength); + u.uProgress.value = p.progress; + u.uFoldGain.value = p.foldGain; + u.uHinge.value = p.hinge; + u.uStageEase.value = p.stageEase; + u.uThickness.value = p.thickness; + + u.uPayout.value = p.payout; + u.uCore.value = p.core; + u.uPaper.value = p.paper; + u.uCurl.value = p.curl; + u.uSpoolClimb.value = p.spoolClimb; + u.uSpin.value = p.spin; + + /* --- shading ------------------------------------------------------ */ + u.uAmbient.value = p.ambient; + u.uWrap.value = p.wrap; + u.uTransmit.value = p.transmit; + u.uTransmitPower.value = p.transmitPower; + u.uSheen.value = p.sheen; + u.uGloss.value = p.gloss; + + u.uGrain.value = p.grain; + u.uGrainScale.value = p.grainScale; + u.uGrainAngle.value = p.grainAngle; + u.uGrainAniso.value = p.grainAniso; + u.uFleck.value = p.fleck; + u.uLaid.value = p.laid; + u.uLaidPitch.value = p.laidPitch; + u.uChainPitch.value = p.chainPitch; + + u.uCreaseGlow.value = p.creaseGlow; + u.uCreaseDark.value = p.creaseDark; + u.uCreaseSharp.value = p.creaseSharp; + + u.uInk.value = p.ink; + u.uInkRows.value = p.inkRows; + u.uInkCols.value = p.inkCols; + u.uInkFill.value = p.inkFill; + u.uInkWeight.value = p.inkWeight; + u.uInkMargin.value = p.inkMargin; + u.uInkSeed.value = p.inkSeed; + u.uInkGhost.value = p.inkGhost; + + u.uEdge.value = p.edge; + u.uTintSpread.value = p.tintSpread; + u.uOpacity.value = p.opacity; + u.uWoundShade.value = p.woundShade; + return this; + } + + /** + * Where sheet `index` is standing, in world space. + * + * The CPU mirror of the placement half of the vertex shader — the ability + * needs it to hang a light, a burst or an emitter on a sheet. It deliberately + * does **not** mirror the fold: nothing on the CPU needs to know where the + * crane's head got to, and a second copy of the fold would be a second thing + * to keep in step. Mirror, so if you change one change the other. + * + * @param {number} index sheet slot + * @param {object} p live params — the same block you hand `update()` + * @param {THREE.Vector3} out written into and returned + */ + sheetPoint(index, p, out) { + const i = Math.max(0, Math.min(this.capacity - 1, index | 0)) * 4; + const d0 = this._dice[i]; + const d1 = this._dice[i + 1]; + const d2 = this._dice[i + 2]; + const b0 = this._diceB[i]; + const count = Math.max(1, Math.min(this.capacity, Math.round(p.count ?? DEFAULTS.count))); + const layout = this.material.uniforms.uLayout.value; + + if (layout === FoldLayout.ZONE) { + const arc = p.arc ?? DEFAULTS.arc; + const bearing = + (p.arcPhase ?? DEFAULTS.arcPhase) + + ((index + 0.5) / count) * arc + + (b0 - 0.5) * 0.35 * (arc / count); + const r = (p.radius ?? DEFAULTS.radius) * (1 + (d0 - 0.5) * 2 * (p.radiusJitter ?? DEFAULTS.radiusJitter)); + out + .copy(this._origin) + .addScaledVector(this._side, Math.cos(bearing) * r) + .addScaledVector(this._direction, Math.sin(bearing) * r); + } else if (layout === FoldLayout.SINGLE) { + out.copy(this._origin).addScaledVector(this._direction, (p.travel ?? 0) * this._length); + } else { + const along = (p.travel ?? 0) * this._length + (d1 - 0.5) * 2 * (p.stretch ?? DEFAULTS.stretch); + out + .copy(this._origin) + .addScaledVector(this._direction, along) + .addScaledVector(this._side, (d0 - 0.5) * 2 * (p.spread ?? DEFAULTS.spread)); + } + out.addScaledVector( + this._up, + (p.lift ?? DEFAULTS.lift) + (d2 - 0.5) * 2 * (p.liftJitter ?? DEFAULTS.liftJitter) + ); + return out; + } + + /** + * Where the spool of sheet `index` is, in world space — `UNROLL` only. + * + * The roll climbs as the paper pays out, so this is where the dust comes off + * and where a light wants to be. Zero payout puts it a whole sheet-length up + * the standing axis; full payout puts it at the free end. + */ + spoolPoint(index, p, out) { + this.sheetPoint(index, p, out); + const length = p.sheetLength ?? DEFAULTS.sheetLength; + const payout = saturate(p.payout ?? DEFAULTS.payout); + const climb = saturate(p.spoolClimb ?? DEFAULTS.spoolClimb); + const kappa = p.curl ?? DEFAULTS.curl; + const a = payout * length; + // Same arc as the shader, same guard. Mirror. + const run = Math.abs(kappa) > 1e-3 ? Math.sin(kappa * a) / kappa : a; + const axis = this.material.uniforms.uLayout.value === FoldLayout.ZONE ? this._up : this._direction; + return out.addScaledVector(axis, run * climb); + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + this.group.parent?.remove(this.group); + } +} + +/* ---------------------------------------------------------------------- */ +/* Params */ +/* ---------------------------------------------------------------------- */ + +/** + * Every key `update()` understands, with its default and its unit. + * + * Call it to seed a scratch block you fill from `settings[id]` each frame, or + * ignore it and hand `update()` your settings block — anything absent falls back + * to the value here. Defaults exist so that a forgotten field gives paper rather + * than a `NaN`, which is the most expensive mistake to debug in this codebase. + */ +export function foldMeshParams() { + return { + /* --- how many --- */ + count: 12, // live sheets, clamped to capacity + + /* --- the sheet --- */ + sheetWidth: 0.42, // metres across (the u axis) + sheetLength: 0.42, // metres along (the v axis) + aspect: 1, // multiplier on the width. CRANE wants 1 — see the header. + sizeJitter: 0.18, // ±fraction per sheet + thickness: 0.0008, // metres of paper, per stacked flap. Kills the z-fight. + + /* --- the fold: the one slider --- */ + progress: 1, // 0 flat, 1 folded. Run it backwards to unfold. + foldGain: 1, // multiplier on every crease angle; >1 overfolds + hinge: 0.02, // metres — the crease radius. Under one grid cell it kinks. + stageEase: 1, // 0 linear stage windows, 1 smoothstepped + foldStagger: 0.35, // fraction of progress spread across the flock + + /* --- UNROLL --- */ + payout: 0.5, // 0..1 of the sheet off the roll + core: 0.03, // metres — the spool's core radius + paper: 0.0006, // metres — paper thickness. Sets how fast the roll grows. + curl: 0.35, // 1/metres of residual curvature in the paid-out run + spoolClimb: 1, // 0 spool fixed and the sheet falls, 1 the wall rises + spin: 0, // radians of phase on the roll + + /* --- the flock --- */ + travel: 0, // 0..1 of the cast the flock has covered + spread: 1.2, // metres of lateral scatter (LINE) + stretch: 1.6, // metres of along-line scatter (LINE) + lift: 1.2, // metres off the floor + liftJitter: 0.5, // ±metres + radius: 3.5, // metres (ZONE) + radiusJitter: 0.12, // ±fraction + arc: 6.283185307179586, // radians the ZONE ring covers + arcPhase: 0, // radians + + /* --- attitude --- */ + pitch: 0, // radians, nose up + yaw: 0, // radians + yawJitter: 0.5, // ±radians + roll: 0, // radians of bank + rollJitter: 0.35, // ±radians + bob: 0.09, // metres of vertical breathing + bobRate: 2.1, // radians/second + tumble: 0, // radians/second about the sheet's normal + + /* --- appearing --- */ + reveal: 1, // 0..1 — sheets appear as this passes their dice + revealSpread: 0.3, // width of that wave + + /* --- shading --- */ + ambient: 0.45, // floor under the diffuse term + wrap: 0.35, // how far light bends around the sheet, 0..1 + transmit: 0.55, // how much light comes through from behind + transmitPower: 6, // tightness of that lobe + sheen: 0.25, // grazing specular + gloss: 24, // specular exponent + + grain: 0.5, // how much fibre shows in the albedo + grainScale: 55, // fibres per metre + grainAngle: 0.2, // radians — the machine direction + grainAniso: 9, // how far the fibres are stretched along it + fleck: 0.3, // sparse darker specks in the pulp + laid: 0.16, // the regular ribbing of a laid sheet + laidPitch: 42, // lines per metre, the fine ones + chainPitch: 1.6, // lines per metre, the coarse chain lines + + creaseGlow: 0.5, // how much a mountain crease catches + creaseDark: 0.35, // how much a valley crease holds shadow + creaseSharp: 1.6, // exponent on the band profile — apparent width + + /* --- the writing --- */ + ink: 0, // 0 blank paper, 1 fully written + inkRows: 14, // characters down the sheet + inkCols: 5, // columns across + inkFill: 0.8, // fraction of cells that carry a mark + inkWeight: 0.07, // stroke half-width, cell units + inkMargin: 0.08, // fraction of the sheet left blank at the edges + inkSeed: 3.7, // shifts the whole text + inkGhost: 0.35, // how much the writing shows through the back + + /* --- the sheet as an object --- */ + edge: 0.02, // deckle: fraction of the sheet the edge thins over + tintSpread: 0.06, // ±per-sheet brightness walk + opacity: 1, + woundShade: 0.35, // how much darker the paper is on the roll + + /* --- colour: five pickers, none derived --- */ + colorPaper: '#efe7d6', + colorShade: '#b6a992', + colorTransmit: '#ffe9bd', + colorInk: '#241d18', + colorCrease: '#fffaf0' + }; +} + +/** Resolved once at module load; `_resolve` walks its keys every frame. */ +const DEFAULTS = foldMeshParams(); + +/** + * Editor schema fragment. Spread into an ability's schema so the sheet, the + * fold and the paper get sensible ranges without every ability re-deriving them. + */ +export function foldMeshSchema(label = 'Paper') { + return { + [`${label} · sheet`]: [ + ['sheetWidth', 0.05, 4, 0.01, 'width (m)'], + ['sheetLength', 0.05, 8, 0.01, 'length (m)'], + ['aspect', 0.25, 4, 0.01], + ['sizeJitter', 0, 1, 0.01, 'size jitter'], + ['thickness', 0, 0.006, 0.0001, 'paper (m)'] + ], + [`${label} · fold`]: [ + ['progress', -0.2, 1.2, 0.001], + ['foldGain', 0, 1.6, 0.01, 'fold gain'], + ['hinge', 0.002, 0.2, 0.001, 'crease radius (m)'], + ['stageEase', 0, 1, 0.01, 'stage ease'], + ['foldStagger', 0, 1, 0.01, 'flock stagger'] + ], + [`${label} · unroll`]: [ + ['payout', 0, 1, 0.001], + ['core', 0.005, 0.3, 0.001, 'core (m)'], + ['paper', 0.0001, 0.004, 0.00005, 'thickness (m)'], + ['curl', -1.5, 1.5, 0.01, 'curl (1/m)'], + ['spoolClimb', 0, 1, 0.01, 'spool climbs'], + ['spin', 0, 6.283, 0.01] + ], + [`${label} · surface`]: [ + ['grain', 0, 1, 0.01], + ['grainScale', 4, 200, 1, 'fibres/m'], + ['grainAngle', 0, 3.142, 0.01, 'grain angle'], + ['grainAniso', 1, 30, 0.5, 'grain stretch'], + ['fleck', 0, 1, 0.01], + ['laid', 0, 0.6, 0.01, 'laid lines'], + ['laidPitch', 4, 120, 1, 'laid/m'], + ['chainPitch', 0.2, 8, 0.05, 'chain/m'], + ['creaseGlow', 0, 2, 0.01, 'crease glow'], + ['creaseDark', 0, 1, 0.01, 'crease shadow'], + ['creaseSharp', 0.2, 5, 0.05, 'crease width'], + ['transmit', 0, 2, 0.01], + ['transmitPower', 0.5, 24, 0.5, 'transmit focus'], + ['wrap', 0, 1, 0.01, 'light wrap'], + ['sheen', 0, 1.5, 0.01], + ['gloss', 2, 128, 1], + ['edge', 0, 0.15, 0.001, 'deckle'], + 'colorPaper', + 'colorShade', + 'colorTransmit', + 'colorCrease' + ], + [`${label} · writing`]: [ + ['ink', 0, 1, 0.01], + ['inkRows', 1, 40, 1, 'rows'], + ['inkCols', 1, 16, 1, 'columns'], + ['inkFill', 0, 1, 0.01, 'fill'], + ['inkWeight', 0.01, 0.3, 0.005, 'stroke'], + ['inkMargin', 0, 0.3, 0.005, 'margin'], + ['inkSeed', 0, 40, 0.1, 'seed'], + ['inkGhost', 0, 1, 0.01, 'shows through'], + 'colorInk' + ] + }; +} diff --git a/src/vfx/GroundField.js b/src/vfx/GroundField.js new file mode 100644 index 0000000..0c1ec3e --- /dev/null +++ b/src/vfx/GroundField.js @@ -0,0 +1,2173 @@ +import { + Mesh, + ShaderMaterial, + AdditiveBlending, + NormalBlending, + DoubleSide, + Color, + Vector2, + Vector4 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { acquireGroundQuad, releaseGroundQuad } from './quads.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { disruptGLSL, disruptUniforms } from './SceneHooks.js'; +import { LAYER } from '../core/Layers.js'; +import { getColor } from '../utils/color.js'; + +/* ---------------------------------------------------------------------- */ +/* GroundField — one quad that thinks in metres */ +/* ---------------------------------------------------------------------- */ + +/** + * A ground quad whose fragment shader works in **metres from an anchor**. + * + * This generalises the snare's burnt field and the two targeting indicators, + * and it exists for the reason the snare's field is a mesh rather than a pooled + * decal: *a decal captures its radius when it spawns*. `DecalSystem.spawn()` + * writes `uRadius` once and scales the mesh once, so a crater that is already + * on the floor cannot hear a slider move. Everything here is re-resolved from + * the live params on every call to `update()` — including a zero-length frame — + * so dragging `radius` on a standing mark re-scales the mark, its grain, its + * relief and its growth front together, with the clock stopped. That is + * invariant **I1** and it is the whole reason this module exists. + * + * ## What it draws + * + * One `Mesh`, **one draw call**, no textures, ten modes selected by a `#define` + * so a program only ever carries the branch it needs. Every mode is a different + * *substance*, not a different palette: + * + * | mode | what it is | + * | --- | --- | + * | `PLATE` | interlocking sheet-ice plates whose downwind edges lift and curl | + * | `RUNE` | a whole inscribed seal — nested counter-rotating rings of signed-distance glyphs, a chord armature, a tick collar and a central sigil, all inking themselves stroke by stroke and then igniting from the inside out | + * | `POCK` | impact craters accumulating from a list of unitless hits | + * | `RUT` | a gouged track behind a rolling body, depth following contact force | + * | `WET` | darkened, reflective stone that dries from the edges in | + * | `PUSTULE` | cellular blisters inflating and bursting on individual timers | + * | `FUNNEL` | an inverted cone read as depth off a faked normal — the sinkhole | + * | `SCOUR` | rotational scour marks under a vortex | + * | `LATTICE` | a hex lattice propagating outward along its own edges | + * | `POOL` | a liquid pool with a meniscus rim and a slow surface flow | + * + * ## The three things every mode shares + * + * **A ragged front.** The growth boundary is warped *in the plane* and never + * sampled on `atan(y, x)`. An angular lookup hands every radius along a bearing + * the same value, which draws dead-straight spokes out of the centre — a + * firework, not a burn. The README documents that mistake twice (the bolt's + * ground burn and the snare's field) because it was made twice. Angular + * sampling is used in exactly two places below, both of them deliberate: the + * scour spiral, whose marks genuinely *are* rotational, and the compass sweep + * that draws the rune circles. + * + * **A fake normal.** Each mode publishes a height field in metres; the shared + * path takes two forward differences off it and lights the result with + * `frame.uLightDir`, the same key direction the lit meshes use. Without it a + * ground mark is a sticker — the FROST decal learnt that and this is the same + * lesson generalised. `RUNE` skips the taps: the glyph SDF hands you its own + * gradient for free, which is the cheapest normal in the file. + * + * **Both blend modes.** The README's rough-edges list ends on "both the + * targeting circle and the snare's field are additive, so the footprint + * brightens the floor rather than shading it". `additive: false` (the default) + * alpha-blends, so a wet flagstone, a hole and a pool of blood are genuinely + * *darker* than the floor they lie on. Set `additive: true` for the marks that + * really are made of light. + * + * ## The rule for using it well + * + * Fill the params object from `settings[id]` **every frame** and hand it to + * `update()`. Hold that object at module scope and mutate it; an object literal + * per frame is the allocation invariant **I3** forbids. Never keep a resolved + * metre between frames — in particular never cache `radius`, because re-scaling + * live is the one thing this module is for. + * + * @example + * // construction + * this.seal = new GroundField(this.group, { mode: GroundMode.RUNE, additive: false }); + * + * // module scope, next to the other scratch + * const _seal = groundFieldParams(); + * + * // every frame, travel and fade alike + * const c = settings.runeseal; + * _seal.centre = this.position; // a Vector3 the ability owns + * _seal.radius = c.zoneRadius; // resolved from settings, never stored + * _seal.inscribe = saturate(this.age / c.inscribeTime); + * _seal.ignite = saturate((this.age - c.inscribeTime) / c.igniteTime); + * _seal.fade = fade; + * _seal.glyphSize = c.glyphSize; // …and the other forty + * this.seal.update(_seal); + */ + +/** Which substance the one shader is drawing. A mode, not a dimension. */ +export const GroundMode = Object.freeze({ + PLATE: 0, + RUNE: 1, + POCK: 2, + RUT: 3, + WET: 4, + PUSTULE: 5, + FUNNEL: 6, + SCOUR: 7, + LATTICE: 8, + POOL: 9 +}); + +/** Human names, for the editor and for `check.mjs` error messages. */ +export const GROUND_MODE_NAMES = Object.freeze([ + 'PLATE', + 'RUNE', + 'POCK', + 'RUT', + 'WET', + 'PUSTULE', + 'FUNNEL', + 'SCOUR', + 'LATTICE', + 'POOL' +]); + +/** Rings the RUNE branch will draw. Fixed so the loop bound is a constant. */ +const RUNE_RINGS = 4; + +/* ---------------------------------------------------------------------- */ +/* The glyph alphabet */ +/* ---------------------------------------------------------------------- */ + +/** + * A rune skeleton: eighteen candidate strokes on a 3 × 3 armature. + * + * Authoring the alphabet in JavaScript and *generating* the GLSL is not a + * shortcut, it is the only portable way to do it. GLSL ES 1.00 forbids indexing + * an array with anything but a constant expression, so a `vec4 STROKES[18]` + * table read at `strokes[glyphIndex * 18 + i]` will not compile on half the + * targets three supports. Unrolling the skeleton into straight-line code at + * module load removes the array, removes the dynamic index, and leaves the + * alphabet readable *here* — where it is data — rather than smeared through a + * shader as magic numbers. + * + * Segments are `['seg', ax, ay, bx, by]`, arcs are `['arc', cx, cy, r, a0, + * span]`, all in glyph-local units where the em box is roughly 0.7 × 1.2 and + * the origin is the waist. Index order is drawing order: uprights, then bars, + * then the stem, then diagonals, then the bowls and the counter — a scribe + * blocking in the frame and closing the curves last. + * + * ### Why there are twenty-four of them and not twenty-five + * + * A stroke set is carried as a **bit field in a float** (see `GLYPH_CODES`), and + * a highp float holds integers exactly only to 2²⁴. Twenty-four strokes gives a + * largest possible code of 16 777 215, which is exactly representable; + * twenty-five gives 33 554 431, which is not, and the failure mode is not a + * compile error but a glyph that silently loses its last stroke on some + * hardware and not others. If the alphabet ever needs a twenty-fifth stroke it + * needs a second code word, not a wider one. + * + * The first eighteen shipped with the module. Indices 18–23 were added for + * `runeseal`, which needed the alphabet to carry a five-metre seal without the + * eye finding the repeat: **asymmetric arms** (20, 21) so a mark can be + * left- or right-handed rather than always balanced, **barbs** (18, 19) so a + * terminal can flick rather than stop, and **two off-centre counters** + * (22, 23) so an enclosed space can sit above or below the waist instead of + * only on it. Those three families are what a script has and a set of runes + * usually does not. + */ +const G_XL = -0.34; +const G_XM = 0.0; +const G_XR = 0.34; +const G_YT = 0.55; +const G_YM = 0.0; +const G_YB = -0.55; +const G_PI = Math.PI; + +const GLYPH_STROKES = [ + ['seg', G_XL, G_YM, G_XL, G_YT], // 0 left upright, upper half + ['seg', G_XL, G_YB, G_XL, G_YM], // 1 left upright, lower half + ['seg', G_XR, G_YM, G_XR, G_YT], // 2 right upright, upper half + ['seg', G_XR, G_YB, G_XR, G_YM], // 3 right upright, lower half + ['seg', G_XL, G_YT, G_XR, G_YT], // 4 head bar + ['seg', G_XL, G_YM, G_XR, G_YM], // 5 waist bar + ['seg', G_XL, G_YB, G_XR, G_YB], // 6 foot bar + ['seg', G_XM, G_YM, G_XM, G_YT], // 7 stem, upper half + ['seg', G_XM, G_YB, G_XM, G_YM], // 8 stem, lower half + ['seg', G_XL, G_YT, G_XM, G_YM], // 9 shoulder, NW → waist + ['seg', G_XR, G_YT, G_XM, G_YM], // 10 shoulder, NE → waist + ['seg', G_XM, G_YM, G_XL, G_YB], // 11 leg, waist → SW + ['seg', G_XM, G_YM, G_XR, G_YB], // 12 leg, waist → SE + ['arc', G_XM, 0.24, 0.3, 0.0, G_PI], // 13 upper bowl — a cap + ['arc', G_XM, -0.24, 0.3, G_PI, G_PI], // 14 lower bowl + ['arc', G_XM, G_YM, 0.36, -G_PI * 0.5, G_PI], // 15 right bowl, the 'D' + ['arc', G_XM, G_YM, 0.36, G_PI * 0.5, G_PI], // 16 left bowl + ['arc', G_XM, G_YM, 0.15, 0.0, G_PI * 2], // 17 the counter — a closed eye + ['seg', G_XL, G_YT, -0.1, 0.28], // 18 NW barb — a flag off the head + ['seg', G_XR, G_YB, 0.1, -0.28], // 19 SE barb — the same, upside down + ['seg', G_XM, G_YM, G_XR, G_YM], // 20 right arm — half a waist bar + ['seg', G_XL, G_YT, G_XM, G_YT], // 21 left ear — half a head bar + ['arc', G_XM, 0.3, 0.12, 0.0, G_PI * 2], // 22 upper counter, off the waist + ['arc', G_XM, -0.3, 0.12, 0.0, G_PI * 2] // 23 lower counter +]; + +/** + * Twenty-four marks, each a set of skeleton strokes. + * + * Sixteen was plenty while the only consumer was a two-ring decoration. It is + * not plenty for a seal that is meant to be **paused and stared at**: three + * rings of a five-metre seal carry roughly sixty slots, and with sixteen marks + * a slow orbit of the camera walks you past the same letterform four or five + * times. At twenty-four the eye reads "a script it does not know" rather than + * "a pattern repeating", which is the whole job. + * + * Every one has at least one terminal and most have a counter, because a + * stroke-only alphabet with no enclosed space reads as scaffolding rather than + * as writing. The rule when adding one: it must differ from every mark below by + * more than a reflection — a mirrored twin is the one kind of near-duplicate the + * eye catches instantly, because the rings counter-rotate and eventually show + * you both at once. + */ +const GLYPH_ALPHABET = [ + [0, 1, 4, 5], // 0 upright with head and waist bars + [0, 1, 15], // 1 thorn — upright with a right bowl + [7, 8, 4, 11, 12], // 2 dagger — stem, head bar, two legs + [5, 17], // 3 a counter struck through the waist + [13, 14, 17], // 4 the eye — two bowls closed round a counter + [5, 9, 12], // 5 the fold + [0, 1, 2, 3, 4], // 6 the gate + [9, 10, 11, 12, 17], // 7 saltire with a counter + [6, 7, 16], // 8 the sickle + [4, 9, 12], // 9 tri-branch + [5, 15, 16], // 10 closed ring, struck + [2, 3, 16], // 11 mirrored thorn + [0, 1, 4, 6], // 12 the bracket + [6, 8, 13], // 13 the anvil + [7, 8, 9, 10], // 14 the crown + [4, 5, 11, 12], // 15 the anchor + [0, 1, 20, 22], // 16 the flag — upright, right arm, counter above the waist + [7, 8, 21, 19], // 17 the pennant — full stem, left ear, barbed foot + [2, 3, 4, 18], // 18 the hook — right upright under a barbed head + [5, 22, 23], // 19 the abacus — a bar strung with two counters + [13, 14, 8], // 20 the spindle — two bowls on a lower stem + [0, 1, 12, 23], // 21 the kick — upright, one leg, counter below + [4, 6, 7, 8], // 22 the pillar — a full stem between head and foot + [16, 20, 6] // 23 the ladle — left bowl, right arm, foot bar +]; + +const GLYPH_COUNT = GLYPH_ALPHABET.length; + +/** A stroke set as a bit field. 18 bits — exact in a highp float, see below. */ +const GLYPH_CODES = GLYPH_ALPHABET.map((strokes) => + strokes.reduce((code, index) => code + Math.pow(2, index), 0) +); + +/** GLSL float literal — `0` must come out as `0.0` or the compiler rejects it. */ +const f = (n) => (Number.isInteger(n) ? `${n}.0` : `${n}`); + +/** `code = CODES[index]`, without an array and without a dynamic index. */ +const GLYPH_CODE_SELECT = GLYPH_CODES.map( + (code, i) => ` code += ${f(code)} * step(abs(index - ${f(i)}), 0.5);` +).join('\n'); + +/** How many strokes this glyph has — the denominator of the inking clock. */ +const GLYPH_COUNT_PASS = GLYPH_STROKES.map( + (_, i) => ` count += gfBit(code, ${f(i)});` +).join('\n'); + +/** + * The alphabet, unrolled. + * + * `order` counts only the strokes this glyph actually has, so the nib spends + * the same time on every stroke of a three-stroke mark as on every stroke of a + * five-stroke one; the inking is even, and the glyphs finish together. + */ +const GLYPH_DRAW_PASS = GLYPH_STROKES.map((s, i) => { + const head = ` on = gfBit(code, ${f(i)}); + if (on > 0.5) { + part = clamp(draw * count - order, 0.0, 1.0); + order += 1.0;`; + const body = + s[0] === 'seg' + ? ` if (part > 0.002) gfSeg(g, vec2(${f(s[1])}, ${f(s[2])}), + mix(vec2(${f(s[1])}, ${f(s[2])}), vec2(${f(s[3])}, ${f(s[4])}), part), w, sd, grad);` + : ` if (part > 0.002) gfArc(g, vec2(${f(s[1])}, ${f(s[2])}), ${f(s[3])}, + ${f(s[4])}, ${f(s[5])} * part, w, sd, grad);`; + return `${head}\n${body}\n }`; +}).join('\n'); + +/* ---------------------------------------------------------------------- */ +/* The shaders */ +/* ---------------------------------------------------------------------- */ + +/** + * Nothing but a frame change. + * + * The key direction and the view vector are rotated into the quad's own frame + * here, once per vertex, because the quad carries the caster's yaw: a world + * direction used straight in the fragment stage would light every mark from a + * different side of the room as the anchor turns. `GroundDecals` negates the Z + * component and this does not — that file's `c.y` runs along local **-Z**, + * while `p.y` below runs along local **+Z** so that "downrange" and "the aim + * heading" are the same axis as in the two indicators. Get that sign wrong and + * the relief lights from behind, which looks almost right and is maddening. + */ +const GROUND_VERTEX = /* glsl */ ` + uniform vec3 uLightDir; // world space, toward the sun + + ${disruptGLSL} + + varying float vDisrupt; // spellbreak's field — see vfx/SceneHooks.js + varying vec2 vUv; + varying vec3 vLight; // key direction, in the quad's frame + varying vec3 vView; // fragment → camera, in the quad's frame + varying float vViewZ; + + void main() { + vUv = uv; + + vec4 world = modelMatrix * vec4(position, 1.0); + + vec3 ax = normalize(modelMatrix[0].xyz); + vec3 ay = normalize(modelMatrix[1].xyz); + vec3 az = normalize(modelMatrix[2].xyz); + + vLight = normalize(vec3(dot(uLightDir, ax), dot(uLightDir, ay), dot(uLightDir, az))); + + vec3 toCamera = cameraPosition - world.xyz; + vView = normalize(vec3(dot(toCamera, ax), dot(toCamera, ay), dot(toCamera, az))); + + // Opt-in to the disruption field. Four vertices per quad, so this is the + // cheapest opt-in in the library: one distance, four times, per mark. + vDisrupt = disruptAt(world.xyz); + + vec4 mv = viewMatrix * world; + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const GROUND_FRAGMENT = /* glsl */ ` + ${disruptGLSL} + varying float vDisrupt; + + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + #define GF_PLATE 0 + #define GF_RUNE 1 + #define GF_POCK 2 + #define GF_RUT 3 + #define GF_WET 4 + #define GF_PUSTULE 5 + #define GF_FUNNEL 6 + #define GF_SCOUR 7 + #define GF_LATTICE 8 + #define GF_POOL 9 + + uniform float uTime; + + /* ---- the anchor and its footprint ---- */ + uniform vec2 uQuadSize; // metres the quad covers: x across, y downrange + uniform float uRadius; // the mark's radius, metres — live, never captured + uniform float uLength; // RUT: length of the track, metres + uniform float uSeed; + + /* ---- the growth front ---- */ + uniform float uGrow; // 0..1 how far the front has reached + uniform float uRecede; // 0..1 how far the *outside* has been eaten back + uniform float uEdge; // metres of feather on that front + uniform float uRagged; // fraction of the radius the front wanders by + uniform float uRaggedScale; // lobes per metre + uniform float uWarp; // metres of domain warp on the lobes + + /* ---- how the fake relief is lit ---- */ + uniform float uRelief; // how hard the height field tilts the normal + uniform float uNormalStep; // metres between the height taps + uniform float uAmbient; // 0..1 floor on the diffuse term + uniform float uWrap; // 0..1 wraps the terminator round the back + uniform float uSpecular; + uniform float uGloss; // Blinn exponent + uniform float uParallax; // metres of view-driven offset on interior detail + + /* ---- the shared shape vocabulary (per-mode meaning in GroundField.js) ---- */ + uniform float uCell; // metres — plate / blister / hex / groove pitch + uniform float uCellJitter; // 0..1 lattice disorder + uniform float uSeam; // metres — gap or blend width between cells + uniform float uThickness; // metres — sheet, trace, rim, meniscus + uniform float uLift; // metres — how far the substance stands proud + uniform float uDepth; // metres — how far it goes down + uniform float uWidth; // metres — track / groove half-width + uniform float uSharp; // 0..1 profile hardness + uniform float uDetail; // 0..1 fine grain + uniform float uSwirl; // rotational shear / lateral wander + uniform float uArms; // count — scour arms + uniform float uSpeed; // events or radians per second + uniform float uFlow; // metres per second of surface drift + uniform vec2 uWind; // unit, in the quad's frame: downwind / flow + + /* ---- output ---- */ + uniform float uFade; + uniform float uOpacity; + uniform float uEmissive; + uniform float uDepthFade; // metres + uniform vec3 uColorBase; + uniform vec3 uColorEdge; + uniform vec3 uColorGlow; + uniform vec3 uColorDeep; + + uniform float uGlobalGlow; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + #if GF_MODE == GF_RUNE + uniform float uRings; // how many nested rings, 1..4 + uniform float uRingInner; // innermost ring, as a fraction of the radius + uniform float uGlyphSize; // metres, em box height — a real measurement + uniform float uGlyphStroke; // metres, half-width of a stroke + uniform float uGlyphGap; // slot pitch, in glyph widths + uniform float uSpin; // radians/second, ring 0 + uniform float uSpinFalloff; // how much slower each ring out turns + uniform float uRule; // metres, half-width of the compass circles + uniform float uInscribe; // 0..1 how much of the seal has been inked + uniform float uIgnite; // 0..1 ignition front, inner ring first + uniform float uScorch; // 0..1 how far the seal has burnt down to a scar + + /* ---- the armature: chords struck across the seal, and a tick collar ---- */ + uniform float uArmStart; // 0..1 point in the inscription the armature begins + uniform float uArmRadius; // armature circle, as a fraction of uRadius + uniform float uArmSides; // how many chords are struck around it + uniform float uArmTangent; // apothem as a fraction of the armature radius + uniform float uArmStroke; // metres, half-width of an armature line + uniform float uArmPhase; // radians, where the first chord starts + uniform float uArmSpin; // radians/second the armature turns + uniform float uTickCount; // ticks in the collar + uniform float uTickRadius; // collar radius, as a fraction of uRadius + uniform float uTickLength; // metres, a minor tick + uniform float uTickStroke; // metres, half-width of a tick + uniform float uTickMajor; // every Nth tick is a long one + uniform float uTickMajorLen; // metres of extra length on a major tick + + /* ---- the central sigil: larger than anything else, and drawn last ---- */ + uniform float uSigilStart; // 0..1 point in the inscription the sigil begins + uniform float uSigilRadius; // metres, how far the rosette glyph sits out + uniform float uSigilSize; // metres, its em box — deliberately not uGlyphSize + uniform float uSigilArms; // rotational symmetry of the rosette + uniform float uSigilStroke; // metres, half-width of a sigil stroke + uniform float uSigilRing; // metres, the circle enclosing the sigil + uniform float uSigilSpin; // radians/second the sigil turns + #endif + + #if GF_MODE == GF_POCK || GF_MODE == GF_RUT + /** + * Up to GF_MARKS events: xy is a **unitless** position (fractions of the + * radius, in the anchor's frame), z is the timestamp it fired at and w + * is its strength. Nothing here carries a metre, so the marks re-place + * themselves when the radius slider moves — which is the point. + */ + uniform vec4 uMarks[GF_MARKS]; + uniform float uMarkCount; + uniform float uMarkLife; // seconds a mark takes to weather away + uniform float uMarkRadius; // metres, radius of a full-strength mark + #endif + + #if GF_MODE == GF_RUT + uniform float uProgress; // 0..1 how far the body has rolled + #endif + + varying vec2 vUv; + varying vec3 vLight; + varying vec3 vView; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + /* ------------------------------------------------------------------ */ + /* Shared field helpers */ + /* ------------------------------------------------------------------ */ + + /** + * Voronoi that also reports how far it is to the *wall* of the cell. + * + * noise.glsl.js#voronoi2 returns F1 and an id, which is enough to stipple + * with and not enough to build plates out of: a plate needs its own outline, + * and the outline is where two sites are equidistant. Half the F1/F2 gap is + * the standard cheap estimate of that — not exact near a corner, and nobody + * has ever noticed on a sheet of ice. + */ + float gfCells(vec2 p, float jitter, out float wall, out vec2 site, out float id) { + vec2 n = floor(p); + vec2 f = fract(p); + float d1 = 8.0; + float d2 = 8.0; + site = n; + id = 0.0; + + for (int j = -1; j <= 1; j++) { + for (int i = -1; i <= 1; i++) { + vec2 g = vec2(float(i), float(j)); + vec2 o = mix(vec2(0.5), hash21(dot(n + g, vec2(7.13, 113.17))), jitter); + vec2 r = g + o - f; + float d = dot(r, r); + if (d < d1) { + d2 = d1; + d1 = d; + site = n + g + o; + id = hash11(dot(n + g, vec2(31.7, 57.1))); + } else if (d < d2) { + d2 = d; + } + } + } + + d1 = sqrt(d1); + wall = (sqrt(d2) - d1) * 0.5; + return d1; + } + + /** + * Hex lattice. xy is the offset from the nearest cell centre, zw is that + * centre, both in cell units (circumradius 1). Two interleaved rectangular + * lattices, nearest wins — the honeycomb falls out of the pair. + */ + vec4 gfHex(vec2 p) { + const vec2 s = vec2(1.7320508, 3.0); + const vec2 h = vec2(0.8660254, 1.5); + vec2 ca = (floor(p / s) + 0.5) * s; + vec2 cb = (floor((p - h) / s) + 0.5) * s + h; + vec2 a = p - ca; + vec2 b = p - cb; + return dot(a, a) < dot(b, b) ? vec4(a, ca) : vec4(b, cb); + } + + /** Hex radial distance: 0 at the centre, 0.8660254 on every one of the six flats. */ + float gfHexRadius(vec2 p) { + p = abs(p); + return max(p.x, dot(p, vec2(0.5, 0.8660254))); + } + + /** + * The growth front, folded into the distance so every mode compares one + * number against one front. + * + * The wander is sampled **in the plane** and domain warped. Sampled on + * atan(y, x) it would hand every radius along a bearing the same value and + * the front would open as a star with dead-straight arms — a firework, not a + * spreading substance. The bolt's ground burn made that mistake, the snare's + * field inherited the fix, and it is written down here so the next forty + * abilities inherit it too. + * + * The wander scales with uRadius, so a 2 m mark and an 8 m mark are ragged + * by the same *proportion* and both stay recognisable when the slider moves. + */ + float gfReach(vec2 q, float d) { + vec2 w = vec2( + fbm3(vec3(q * uRaggedScale * 0.5, uSeed)), + fbm3(vec3(q * uRaggedScale * 0.5, uSeed + 5.7)) + ) * uWarp; + float lobes = fbm3(vec3((q + w) * uRaggedScale, uSeed + 13.0)); + return d - lobes * uRagged * uRadius; + } + + /* ------------------------------------------------------------------ */ + /* PLATE — sheet ice */ + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_PLATE + + /** + * Metres of relief for a raft of interlocking plates. + * + * The plate is a *sheet*: a couple of centimetres of body everywhere, cut + * along the cell walls, plus a curl that rises toward whichever wall is + * downwind. Hinging the lift on the wind rather than on the cell id is what + * makes a field of these read as one event — paper curling off a hot pan, + * all of it peeling the same way — instead of as scattered debris. + */ + float gfHeight(vec2 q) { + float wall; + vec2 site; + float id; + gfCells(q / max(uCell, 0.02), uCellJitter, wall, site, id); + + float wallM = wall * uCell; // metres to the plate edge + float along = dot(q - site * uCell, uWind); // metres downwind of the centre + + float body = uThickness * smoothstep(0.0, max(uSeam, 0.001), wallM); + float downwind = smoothstep(-uCell * 0.15, uCell * 0.75, along); + float lipness = 1.0 - smoothstep(0.0, max(uCell * 0.45, 0.01), wallM); + float curl = uLift * downwind * lipness * lipness * (0.55 + 0.9 * id); + + // Frozen spray settles in the seams and roughens the top face. + float grain = snoise(vec3(q * 6.0, uSeed)) * uDetail * uThickness * 0.6; + return body + curl * smoothstep(0.0, max(uSeam * 0.4, 0.001), wallM) + grain; + } + + #endif + + /* ------------------------------------------------------------------ */ + /* RUNE — the alphabet */ + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_RUNE + + /** + * Bit i of a stroke set. Codes run to 2^18, which a highp float holds + * exactly (integers are exact to 2^24); three compiles fragment shaders at + * highp by default and this is the one thing in the file that depends on it. + */ + float gfBit(float code, float i) { + return mod(floor(code / exp2(i)), 2.0); + } + + /** + * One stroke of the nib, accumulated into sd — and into grad, the unit + * direction *away* from the nearest point on the winning stroke. + * + * That gradient is the trick that makes the seal cheap. Every other mode + * pays three evaluations of its height field for a normal; a signed distance + * field already knows which way is out, so the incision can be bevelled and + * lit off one evaluation. Three taps through eighteen strokes and four rings + * would have been the most expensive fragment in the project. + * + * The width swells toward the middle of the stroke and tapers into its ends, + * which is what gives the letterforms terminals instead of sawn-off tubes. + */ + void gfSeg(vec2 p, vec2 a, vec2 b, float w, inout float sd, inout vec2 grad) { + vec2 pa = p - a; + vec2 ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-7), 0.0, 1.0); + vec2 r = pa - ba * h; + float len = length(r); + float d = len - w * (0.62 + 0.38 * sqrt(max(sin(h * PI), 0.0))); + if (d < sd) { + sd = d; + grad = len > 1e-5 ? r / len : vec2(0.0, 1.0); + } + } + + /** The same, on an arc — bowls and counters. span is already scaled by the ink. */ + void gfArc(vec2 p, vec2 c, float r, float a0, float span, float w, inout float sd, inout vec2 grad) { + vec2 q = p - c; + float a = atan(q.y, q.x); + float rel = mod(a - a0 + TAU * 2.0, TAU); + float len = length(q); + + float d; + vec2 dir; + if (rel <= span) { + dir = len > 1e-5 ? q / len : vec2(1.0, 0.0); + float side = len - r; + d = abs(side) - w; + dir *= sign(side + 1e-6); + } else { + // Outside the wedge the nearest point is a terminal, so the arc gets the + // same round cap a segment does and the two kinds of stroke join cleanly. + vec2 e0 = c + vec2(cos(a0), sin(a0)) * r; + vec2 e1 = c + vec2(cos(a0 + span), sin(a0 + span)) * r; + vec2 r0 = p - e0; + vec2 r1 = p - e1; + vec2 rn = dot(r0, r0) < dot(r1, r1) ? r0 : r1; + float ln = length(rn); + d = ln - w; + dir = ln > 1e-5 ? rn / ln : vec2(0.0, 1.0); + } + + if (d < sd) { + sd = d; + grad = dir; + } + } + + /** + * One glyph, in its own em box, inked to draw. + * + * code selects the stroke set; the strokes are unrolled from + * GLYPH_STROKES at module load — see the note there for why the obvious + * table lookup is not portable. + */ + float gfGlyph(vec2 g, float index, float draw, float w, out vec2 grad) { + float code = 0.0; +${GLYPH_CODE_SELECT} + + float count = 0.0; +${GLYPH_COUNT_PASS} + count = max(count, 1.0); + + float sd = 1e4; + float order = 0.0; + float on = 0.0; + float part = 0.0; + grad = vec2(0.0, 1.0); + +${GLYPH_DRAW_PASS} + + return sd; + } + + #endif + + /* ------------------------------------------------------------------ */ + /* POCK — craters */ + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_POCK + + /** + * Craters, accumulated. + * + * Depth combines with min and rims with +: two hits landing on the same + * spot dig one hole and pile two lips, which is what happens. Summing the + * bowls instead would punch a well twice as deep as the ordnance. + * + * The gate is the snare's octave trick — a step per slot rather than a + * break, because a fixed trip count is what every compiler agrees on and + * an unused slot costs one multiply. + */ + float gfHeight(vec2 q) { + float h = 0.0; + float rims = 0.0; + + for (int i = 0; i < GF_MARKS; i++) { + float on = step(float(i), uMarkCount - 1.0); + vec4 m = uMarks[i]; + + vec2 c = m.xy * uRadius; // unitless → metres, live + float age = max(uTime - m.z, 0.0); + float dig = 1.0 - exp(-age * max(uSpeed, 0.1)); // the crater digs itself in + float weather = 1.0 - smoothstep(uMarkLife * 0.4, uMarkLife, age); + + float rad = max(uMarkRadius * (0.35 + 0.65 * m.w) * dig, 0.02); + float r = length(q - c) / rad; + + float bowl = -uDepth * m.w * max(0.0, 1.0 - r * r) * weather; + float lip = (r - 1.0) / max(uThickness / rad, 1e-3); + float rim = uLift * m.w * exp(-lip * lip) * weather; + + h = mix(h, min(h, bowl), on); + rims += on * rim; + } + + float grain = snoise(vec3(q * (2.0 + uDetail * 8.0), uSeed)) * uDetail * uThickness * 0.5; + return h + rims + grain; + } + + /** Union coverage of every crater, 0..1 — the mask the colour rides on. */ + float gfPockCover(vec2 q) { + float cover = 0.0; + for (int i = 0; i < GF_MARKS; i++) { + float on = step(float(i), uMarkCount - 1.0); + vec4 m = uMarks[i]; + float age = max(uTime - m.z, 0.0); + float dig = 1.0 - exp(-age * max(uSpeed, 0.1)); + float rad = max(uMarkRadius * (0.35 + 0.65 * m.w) * dig, 0.02); + float r = length(q - m.xy * uRadius) / rad; + float weather = 1.0 - smoothstep(uMarkLife * 0.5, uMarkLife, age); + cover = max(cover, on * smoothstep(1.25, 0.85, r) * weather); + } + return cover; + } + + /** How hot the freshest crater under this fragment still is, 0..1. */ + float gfPockHeat(vec2 q) { + float heat = 0.0; + for (int i = 0; i < GF_MARKS; i++) { + float on = step(float(i), uMarkCount - 1.0); + vec4 m = uMarks[i]; + float age = max(uTime - m.z, 0.0); + float rad = max(uMarkRadius * (0.35 + 0.65 * m.w), 0.02); + float r = length(q - m.xy * uRadius) / rad; + heat = max(heat, on * smoothstep(1.0, 0.2, r) * exp(-age * max(uSpeed, 0.1) * 0.6)); + } + return heat; + } + + #endif + + /* ------------------------------------------------------------------ */ + /* RUT — the gouged track */ + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_RUT + + /** + * Where the track is, laterally, s fractions down it. + * + * A sine would be a slalom. This is one axis of a noise field sampled along + * the track in metres, so the body drifts the way a heavy thing rolling over + * uneven ground drifts — and it stays put when the length slider moves, + * because the lookup is in metres rather than in fractions. + */ + float gfTrack(float s) { + return fbm3(vec3(17.0, s * uLength * max(uRaggedScale, 0.01) * 0.35, uSeed)) * uSwirl; + } + + /** + * Contact force at s, interpolated from the samples the ability posted. + * + * With no samples at all it returns 1 — a rut of constant depth — so an + * ability can use this mode without bothering to weigh its own boulder. + */ + float gfForce(float s) { + float acc = 0.0; + float wsum = 0.0; + for (int i = 0; i < GF_MARKS; i++) { + float on = step(float(i), uMarkCount - 1.0); + vec4 m = uMarks[i]; + float dz = (s - m.y) * uLength / max(uSeam, 0.05); + float k = on * exp(-dz * dz); + acc += k * m.w; + wsum += k; + } + return wsum > 1e-4 ? acc / wsum : 1.0; + } + + /** Metres of relief: a gouge, two spoil ridges, and the body's own chatter. */ + float gfHeight(vec2 q) { + float s = clamp((q.y + uLength * 0.5) / max(uLength, 0.01), 0.0, 1.0); + float off = abs(q.x - gfTrack(s)); + float force = gfForce(s); + + float floorProfile = smoothstep(uWidth, uWidth * mix(0.9, 0.1, uSharp), off); + float gouge = -uDepth * force * floorProfile; + + // The body is round, so it prints its own circumference into the soft floor. + float chatter = 0.5 + 0.5 * sin(s * uLength / max(uCell, 0.05) * TAU); + gouge *= 1.0 - uDetail * 0.35 * chatter; + + float sp = (off - uWidth) / max(uThickness, 0.02); + float spoil = uLift * force * exp(-sp * sp) * (0.7 + 0.6 * snoise01(vec3(q * 3.0, uSeed))); + + float behind = smoothstep(uProgress + 0.01, uProgress - 0.01, s); + return (gouge + spoil) * behind; + } + + #endif + + /* ------------------------------------------------------------------ */ + /* WET — soaked stone */ + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_WET + + /** + * Almost flat. What relief there is exists to bend the specular: standing + * water fills the low spots, so the puddles are where the sheen is, and a + * millimetre of height difference is enough to sell it. + */ + float gfHeight(vec2 q) { + float pool = fbm3(vec3(q * max(uCell, 0.05), uSeed)) * 0.5 + 0.5; + float ripple = snoise(vec3((q - uWind * uFlow * uTime) * 9.0, uTime * uSpeed * 0.2)); + return -uDepth * pool + uLift * ripple * uDetail; + } + + #endif + + /* ------------------------------------------------------------------ */ + /* PUSTULE — blisters */ + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_PUSTULE + + /** Where a cell is in its own cycle: 0 flat, ~0.8 taut, then it goes. */ + float gfPhase(float id) { + return fract(uTime * uSpeed * (0.55 + 0.9 * id) + id * 7.13 + uSeed); + } + + /** + * A dome that inflates and a crater that replaces it. + * + * Each cell runs its own clock off its own id hash, which is the entire + * effect: a field on one clock pulses like a heartbeat and reads as a + * material property, a field on N clocks has *events* in it and reads as + * something alive. The collapse is four times faster than the swell, because + * a blister does not deflate, it bursts. + */ + float gfHeight(vec2 q) { + float wall; + vec2 site; + float id; + float f1 = gfCells(q / max(uCell, 0.02), uCellJitter, wall, site, id); + + float phase = gfPhase(id); + float inflate = smoothstep(0.0, 0.72, phase); + float burst = smoothstep(0.76, 0.84, phase); + + float profile = max(0.0, 1.0 - f1 * f1 / 0.36); + float dome = uLift * (0.35 + 0.75 * id) * inflate * (1.0 - burst) * sqrt(profile); + float crater = uDepth * burst * (1.0 - smoothstep(0.0, 0.5, f1)) * (1.0 - smoothstep(0.9, 1.0, phase)); + + float skin = snoise(vec3(q * 5.0, uSeed)) * uDetail * uLift * 0.25; + return dome - crater + skin * smoothstep(0.0, max(uSeam, 0.001), wall * uCell); + } + + #endif + + /* ------------------------------------------------------------------ */ + /* FUNNEL — the sinkhole */ + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_FUNNEL + + /** + * An inverted cone, with the lip calved into blocks. + * + * There is no hole in the floor and there never will be — the floor is one + * opaque plane. What sells the depth is that the wall normal swings a long + * way between the near and far rims, so one side of the pit is lit and the + * other is in shadow, and the eye reads a shaded interior as a volume. The + * parallax offset in main does the rest: the wall detail slides against the + * lip as the camera orbits, which is the only cue a flat quad cannot fake by + * shading alone. + */ + float gfHeight(vec2 q) { + float d = length(q); + float t = clamp(d / max(uRadius, 0.05), 0.0, 1.0); + + float wall = pow(1.0 - t, mix(0.7, 3.5, uSharp)); + float h = -uDepth * wall; + + // The lip does not break on a circle: it calves into blocks that tilt in. + float cellWall; + vec2 site; + float id; + gfCells(q / max(uCell, 0.02), uCellJitter, cellWall, site, id); + float lipBand = smoothstep(uRadius * 1.15, uRadius * 0.82, d) * smoothstep(uRadius * 0.55, uRadius * 0.9, d); + h -= uThickness * lipBand * id * smoothstep(0.0, max(uSeam, 0.001), cellWall * uCell); + + // Spoil slumped over the rim, and a scree grain on the walls. + float outside = smoothstep(uRadius, uRadius * 1.25, d) * (1.0 - smoothstep(uRadius * 1.3, uRadius * 1.7, d)); + h += uLift * outside * (0.5 + 0.9 * id); + h += snoise(vec3(q * 3.5, uSeed)) * uDetail * uDepth * 0.06 * (1.0 - t); + return h; + } + + #endif + + /* ------------------------------------------------------------------ */ + /* SCOUR — what a vortex leaves */ + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_SCOUR + + /** + * Log-spiral grooves. + * + * This is the one place in the file where sampling on atan(y, x) is right: + * the marks *are* rotational, a vortex cuts them by turning, and a spiral is + * an angular function of the radius by definition. It is still domain warped + * in the plane, because a mathematically exact spiral reads as a logo. The + * growth front above stays plane-sampled regardless — the front is not + * rotational and never was. + * + * uArms must be a whole number or the phase tears along the ±π seam; the + * CPU side rounds it. + */ + float gfHeight(vec2 q) { + float d = max(length(q), 1e-3); + vec2 w = vec2(fbm3(vec3(q * 0.6, uSeed)), fbm3(vec3(q * 0.6, uSeed + 3.3))) * uWarp; + vec2 qw = q + w; + + float a = atan(qw.y, qw.x); + // fract() before the cosine, and it is not tidiness — it is the fix for a + // bug that blacked out the entire frame. uTime is the app's elapsed seconds + // and grows without bound, so after a few minutes this phase was in the + // thousands and cos(phase * TAU) was being asked for the cosine of ~10^4 + // radians. A float32 cosine that far out has lost most of its mantissa to + // argument reduction and can return fractionally *outside* [-1, 1] — at + // which point 0.5 + 0.5 * cos() goes microscopically negative, pow() of a + // negative base is NaN by definition, the NaN lands in the HDR buffer, and + // the very next bloom blur smears it across every pixel on screen. One + // fragment, one whole black frame. + // + // cos(phase * TAU) has period 1 in phase, so folding it is *exact* rather + // than an approximation, and it keeps the argument inside a couple of + // radians for as long as the app is open. + float phase = a / TAU * max(uArms, 1.0) + + log(max(length(qw), 1e-3) / max(uRadius, 0.05)) * uSwirl + + uTime * uSpeed; + phase = fract(phase); + + // The max() is the second half of the same fix, and it stays even with the + // fold above: any base reaching pow() here must be provably non-negative, + // because a driver computes pow as exp2(y * log2(x)) and therefore returns + // NaN for a negative base at *every* exponent — including 1.0, which is + // why turning the sharpness slider to zero did not mask the fault. + float groove = 0.5 + 0.5 * cos(phase * TAU); + groove = pow(max(groove, 0.0), mix(1.0, 9.0, uSharp)); + + // Deepest where the throat sat, feathering out to nothing at the skirt. + float bite = smoothstep(uRadius, uRadius * 0.1, d); + float h = -uDepth * groove * bite; + + // Between the grooves the scoured dust piles into low ridges. + h += uLift * (1.0 - groove) * bite * 0.6; + h += snoise(vec3(q * 7.0, uSeed)) * uDetail * uDepth * 0.08; + return h; + } + + #endif + + /* ------------------------------------------------------------------ */ + /* LATTICE — a circuit that grows */ + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_LATTICE + + /** Distance from the anchor to this cell's centre, jittered per cell. */ + float gfCellFront(vec2 centre, float id) { + return length(centre) + (id - 0.5) * uCellJitter * uCell * 2.0; + } + + /** Traces standing proud of the substrate, plus a node at each corner. */ + float gfHeight(vec2 q) { + vec4 hx = gfHex(q / max(uCell, 0.05)); + float edge = 0.8660254 - gfHexRadius(hx.xy); // 0 on the wall + float trace = smoothstep(uThickness / max(uCell, 0.05), 0.0, edge); + float node = smoothstep(uSeam / max(uCell, 0.05), 0.0, abs(length(hx.xy) - 1.0)); + return uLift * (trace * 0.7 + node * 0.9) - uDepth * (1.0 - trace) * 0.15; + } + + #endif + + /* ------------------------------------------------------------------ */ + /* POOL — standing liquid */ + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_POOL + + /** + * A flat surface with a raised rim. + * + * The meniscus is the read. Surface tension pulls the liquid *up* the last + * few centimetres before the edge, and that lip is where the whole rim of + * specular comes from; without it a pool is a coloured disc lying on a floor. + * Everything else is two crossed layers of flow-warped noise, slow enough to + * be a liquid rather than a shader. + */ + float gfHeight(vec2 q) { + float d = length(q); + vec2 drift = uWind * uFlow * uTime; + + float w1 = snoise(vec3((q - drift) * max(uCell, 0.05), uTime * uSpeed * 0.2)); + float w2 = snoise(vec3((q - drift * 0.6) * max(uCell, 0.05) * 2.3 + 11.0, uTime * uSpeed * 0.31)); + float surface = (w1 * 0.65 + w2 * 0.35) * uLift * uDetail; + + float men = uThickness > 1e-4 + ? smoothstep(uRadius - uThickness, uRadius - uThickness * 0.15, d) * (1.0 - smoothstep(uRadius, uRadius + uEdge, d)) + : 0.0; + + return -uDepth * smoothstep(uRadius, 0.0, d) + surface + uLift * men * 2.0; + } + + #endif + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + void main() { + /* ---- uv → metres, measured from the anchor; +y runs downrange ---- */ + vec2 p = vec2(vUv.x - 0.5, 0.5 - vUv.y) * uQuadSize; + float d = length(p); + + // Metres per pixel, near enough. Every antialiasing width below is derived + // from this one number, and it is taken *before* the first discard: a + // derivative in divergent control flow is undefined, and the symptom is a + // sparkling fringe on exactly one GPU vendor. + float px = fwidth(d) + 1e-4; + + // ...and the same thing measured **in the plane** rather than along the + // radius. The two are the same number only where the fragment happens to + // move radially. A glyph stroke runs in any direction it likes, and a + // stroke that happens to point at the anchor sits where d barely changes + // at all: antialiasing it on px gave every such stroke a hard, crawling + // edge while its neighbour two slots round the ring was clean. Taken here, + // with the same discipline — before the first discard, because a + // derivative in divergent control flow is undefined. + vec2 pd = fwidth(p); + float pxp = max(max(pd.x, pd.y), 1e-5); + + /* ---- the growth front ---- */ + float reach = gfReach(p, d); + float front = uGrow * (uRadius + uEdge) * (1.0 - uRecede); + float cover = smoothstep(front, front - max(uEdge, 1e-3), reach); + float lip = smoothstep(max(uEdge, 1e-3), 0.0, abs(reach - front)) * step(0.002, uGrow); + + #if GF_MODE == GF_RUT + // The rut's front runs down the track, not out from a centre. + float sRaw = (p.y + uLength * 0.5) / max(uLength, 0.01); + float s01 = clamp(sRaw, 0.0, 1.0); + float lateral = abs(p.x - gfTrack(s01)); + // The quad is longer than the track — it has to hold the spoil and the + // feather — so the *unclamped* parameter caps both ends. Without this the + // clamp holds the last value all the way to the quad's edge and the rut + // ends on a dead-straight line at right angles to itself. + float cap = uEdge / max(uLength, 0.01); + float ends = smoothstep(-cap, 0.0, sRaw) * smoothstep(1.0 + cap, 1.0, sRaw); + cover = ends + * smoothstep(uProgress + 0.006, uProgress - 0.006, s01) + * smoothstep(uWidth + uThickness * 2.5, uWidth + uThickness * 0.5, lateral); + lip = smoothstep(0.05, 0.0, abs(s01 - uProgress)) * step(0.002, uProgress); + #endif + + if (cover < 0.002 && lip < 0.002) discard; + + /* ---- relief, and the fake normal that makes it a substance ---- */ + float h = 0.0; + vec3 nrm = vec3(0.0, 1.0, 0.0); + + #if GF_MODE == GF_RUNE + /* ---- the seal: rings of glyphs, an armature, a tick collar, a sigil ---- */ + float sd = 1e4; // metres to the nearest stroke + vec2 grad = vec2(0.0, 1.0); // that stroke's own outward gradient + float rule = 0.0; // the compass circles + float ringT = 1.0; // 0 innermost, 1 outermost + float inked = 0.0; // how far through its own inking this stroke is + + float rings = clamp(uRings, 1.0, float(GF_RINGS)); + float band = max(uRadius * (1.0 - uRingInner), 0.001); + + /** + * The inscription has three movements, and uInscribe is the one clock + * they share. The rings of glyphs take the first slice, the armature and + * its collar of ticks the second, the central sigil the last — so the + * thing you are still watching when the seal finishes is the biggest mark + * on it. A single clock rather than three is what keeps a slider drag + * coherent: shortening the inscription shortens all three movements in + * proportion instead of desynchronising them. + * + * A slice of zero length is the off switch, and both starts default to 1. + * A caller that asks for RUNE and says nothing else therefore gets + * exactly what this mode drew before the armature and the sigil existed: + * rings of glyphs, over the whole clock. Growing a shared mode must not + * redecorate somebody else's floor. + */ + float armStart = clamp(uArmStart, 0.02, 1.0); + float sigStart = clamp(uSigilStart, armStart, 1.0); + float armSpan = sigStart - armStart; + float sigSpan = 1.0 - sigStart; + float inkRun = clamp(uInscribe / armStart, 0.0, 1.0); + float armRun = armSpan > 1e-3 ? clamp((uInscribe - armStart) / armSpan, 0.0, 1.0) : 0.0; + float sigRun = sigSpan > 1e-3 ? clamp((uInscribe - sigStart) / sigSpan, 0.0, 1.0) : 0.0; + + for (int r = 0; r < GF_RINGS; r++) { + if (float(r) >= rings) continue; + + float rt = (float(r) + 0.5) / rings; + float rr = uRadius * uRingInner + band * rt; + if (abs(d - rr) > uGlyphSize * 0.9 + uRule * 4.0) continue; + + // Counter-rotation: even rings run with the clock, odd against it, and + // each ring out turns slower. Two rings turning the same way read as + // one wheel; turning against each other they read as machinery. + float dir = mod(float(r), 2.0) < 0.5 ? 1.0 : -1.0; + float spin = uTime * uSpin * dir / (1.0 + float(r) * uSpinFalloff); + float a = atan(p.y, p.x) - spin; + + // The glyph keeps its size in *metres*, so a bigger seal carries more + // runes rather than bigger ones — the single most convincing thing + // about working in metres instead of in UV. + float n = max(floor(TAU * rr / max(uGlyphSize * uGlyphGap, 0.02)), 3.0); + float slot = floor(a / TAU * n + 0.5); + float centre = slot / n * TAU; + + float off = a - centre; + off -= TAU * floor(off / TAU + 0.5); + + // Glyph frame: x is arc length along the ring, y is radially outward. + vec2 g = vec2(off * rr, d - rr) / max(uGlyphSize, 0.02); + + float index = floor(hash11(slot * 3.71 + float(r) * 17.31 + uSeed * 41.0) * float(GF_GLYPHS)); + index = clamp(index, 0.0, float(GF_GLYPHS) - 1.0); + + // Inking order: ring by ring outward, glyph by glyph round each ring. + float k = mod(slot, n); + float start = (float(r) + k / n) / rings; + float width = 1.0 / (rings * n); + float draw = clamp((inkRun - start) / width, 0.0, 1.0); + + vec2 g2; + float dsd = gfGlyph(g, index, draw, uGlyphStroke / max(uGlyphSize, 0.02), g2) * uGlyphSize; + if (dsd < sd) { + sd = dsd; + // The glyph's gradient comes back in its own em box, where x runs + // along the ring and y runs outward. Handing that straight to the + // normal — which the first version did — lights every glyph as if it + // were sitting at bearing zero, so the incisions on the far side of + // the seal are shaded from the wrong edge and the ring looks + // embossed on one side and engraved on the other. + vec2 rdir = d > 1e-4 ? p / d : vec2(1.0, 0.0); + grad = vec2(-rdir.y, rdir.x) * g2.x + rdir * g2.y; + inked = draw; + ringT = rt; + } + + // The compass circles bracketing the band, swept round on the same + // clock. An angular sweep is exactly right here: a compass *is* an + // angular instrument, and this is the only furniture that draws itself + // by turning rather than by spreading. + float progress = clamp((inkRun - float(r) / rings) * rings, 0.0, 1.0); + float bearing = fract(a / TAU + 0.5); + float sweep = smoothstep(progress + 0.015, progress - 0.015, bearing); + float r0 = rr - uGlyphSize * 0.62; + float r1 = rr + uGlyphSize * 0.62; + float circles = (1.0 - smoothstep(uRule, uRule + px, abs(d - r0))) + + (1.0 - smoothstep(uRule, uRule + px, abs(d - r1))); + rule = max(rule, min(circles, 1.0) * sweep); + } + + /* ------------------------------------------------------------------ */ + /* The armature — what a seal has that a wheel of letters does not */ + /* ------------------------------------------------------------------ */ + /** + * Chords struck between the rings, each one a straight line whose ends + * land on the armature circle and which passes at distance apothem from + * the anchor. That single parameterisation covers both things the eye + * wants here: at uArmTangent = cos(PI / sides) the chords close into an + * inscribed polygon, and below that they cross into a star — and at any + * value they are simultaneously *tangent lines* to the inner circle of + * that radius, which is the other half of the figure. + * + * The obvious cheap implementation folds the bearing into one sector and + * measures the distance to a single line. That draws the polygon fine and + * silently truncates the star: a chord that spans three sectors is only + * the nearest line inside one of them, so a pentagram came out as five + * disconnected stubs. Hence the fixed seven-candidate sweep, three + * sectors either side of the nearest. + * + * Walking the centre line of every chord and asking whether the field is + * still negative says where that runs out: at up to twelve chords the + * seven candidates hold the figure whole down to uArmTangent = 0, and at + * sixteen they need about 0.2. Above sixteen chords with a low tangency + * the ends will start to break off — widen the loop, do not widen the + * stroke. + */ + float asd = 1e4; + vec2 agrad = vec2(0.0, 1.0); + float armR = uRadius * clamp(uArmRadius, 0.02, 1.0); + float armPhase = uArmPhase + uTime * uArmSpin; + + if (armRun > 0.001 && d < armR + uArmStroke * 6.0) { + float sides = max(floor(uArmSides + 0.5), 3.0); + float sector = TAU / sides; + float apo = armR * clamp(uArmTangent, 0.0, 0.985); + float halfChord = sqrt(max(armR * armR - apo * apo, 1e-4)); + float aa = atan(p.y, p.x) - armPhase; + float base = floor(aa / sector + 0.5); + + for (int j = -3; j <= 3; j++) { + float idx = base + float(j); + float th = armPhase + idx * sector; + vec2 nl = vec2(cos(th), sin(th)); // the chord's own normal + vec2 tl = vec2(-nl.y, nl.x); // ...and its direction + vec2 ea = nl * apo - tl * halfChord; + vec2 eb = nl * apo + tl * halfChord; + // Struck one at a time, in order round the figure, so the armature + // is drawn rather than switched on. + float slotA = mod(idx, sides); + float partA = clamp(armRun * sides - slotA, 0.0, 1.0); + if (partA > 0.002) gfSeg(p, ea, mix(ea, eb, partA), uArmStroke, asd, agrad); + } + } + + /** + * The tick collar. Sixty short radial marks with every sixth one long is + * the difference between "a magic circle" and "an instrument": it says + * the seal is *divided*, and division is what makes a drawing look like + * it was measured out rather than decorated. + * + * One segment per fragment, because ticks are radial and short enough + * that the nearest bearing is always the nearest tick — the only place in + * this branch where the angular fold is exact rather than a compromise. + */ + float tickR = uRadius * clamp(uTickRadius, 0.02, 1.2); + float tickOut = uTickLength + uTickMajorLen + uTickStroke * 3.0; + if (armRun > 0.001 && d > tickR - uTickStroke * 3.0 && d < tickR + tickOut) { + float ticks = max(floor(uTickCount + 0.5), 4.0); + float ts = TAU / ticks; + float ta = atan(p.y, p.x) - armPhase; + float ti = floor(ta / ts + 0.5); + float th = armPhase + ti * ts; + vec2 rdir = vec2(cos(th), sin(th)); + float every = max(floor(uTickMajor + 0.5), 1.0); + float major = 1.0 - step(0.5, mod(ti, every)); + float len = uTickLength + uTickMajorLen * major; + float partT = clamp(armRun * ticks - mod(ti, ticks), 0.0, 1.0); + if (partT > 0.002) { + gfSeg(p, rdir * tickR, rdir * (tickR + len * partT), uTickStroke, asd, agrad); + } + } + + if (asd < sd) { + sd = asd; + grad = agrad; + inked = armRun; + // The armature is one continuous figure rather than a ring of separate + // marks, so it takes the ignition front where the fragment actually + // stands. A chord that ran from an inner ring to an outer one and lit + // all at once looked like a switch closing, not like fire spreading. + ringT = clamp(d / max(uRadius, 0.05), 0.0, 1.0); + } + + /* ------------------------------------------------------------------ */ + /* The central sigil — larger than anything else, and drawn last */ + /* ------------------------------------------------------------------ */ + /** + * A rosette: one glyph from the same alphabet, at its own em box, copied + * uSigilArms times about the anchor and closed inside a ruled circle. + * + * The copies are free. Folding the bearing into one sector and evaluating + * the alphabet **once** gives exact N-fold rotational symmetry; the first + * version looped the arms and paid for the most expensive function in the + * file five times over for a picture the fold hands you for nothing. + */ + float ssd = 1e4; + vec2 sgrad = vec2(0.0, 1.0); + float sigReach = uSigilRing + uSigilRadius + uSigilSize * 1.4; + + if (sigRun > 0.001 && d < sigReach) { + // The frame before the writing, as a scribe would: the enclosing circle + // is ruled in over the first part of the sigil's slice. + gfArc(p, vec2(0.0, 0.0), uSigilRing, -PI * 0.5, + TAU * clamp(sigRun * 2.2, 0.0, 1.0), uSigilStroke, ssd, sgrad); + + float arms = max(floor(uSigilArms + 0.5), 1.0); + float ss = TAU / arms; + float sa = atan(p.y, p.x) - uTime * uSigilSpin; + float mfold = sa - ss * floor(sa / ss + 0.5); + vec2 q = vec2(cos(mfold), sin(mfold)) * d; + + // Same convention as the rings: x tangential, y radially outward, waist + // on uSigilRadius — so the rosette reads as the alphabet at a larger + // size rather than as a different kind of mark. + vec2 gg = vec2(q.y, q.x - uSigilRadius) / max(uSigilSize, 0.02); + float sIndex = floor(hash11(uSeed * 41.0 + 7.13) * float(GF_GLYPHS)); + sIndex = clamp(sIndex, 0.0, float(GF_GLYPHS) - 1.0); + + vec2 sg; + float sDraw = clamp(sigRun * 1.7 - 0.35, 0.0, 1.0); + float sGlyph = gfGlyph(gg, sIndex, sDraw, uSigilStroke / max(uSigilSize, 0.02), sg) + * uSigilSize; + if (sGlyph < ssd) { + // Out of the em box, out of the sector, into the plane. Two rotations, + // and skipping the second is the bug that lights one arm of the + // rosette correctly and the other four from the wrong side. + float rot = atan(p.y, p.x) - mfold; + vec2 gp = vec2(sg.y, sg.x); + ssd = sGlyph; + sgrad = vec2(gp.x * cos(rot) - gp.y * sin(rot), gp.x * sin(rot) + gp.y * cos(rot)); + } + } + + if (ssd < sd) { + sd = ssd; + grad = sgrad; + inked = sigRun; + // The middle of the seal: whatever else it is, it is the first thing + // the ignition front reaches. + ringT = 0.0; + } + + // The incision, and its normal straight off the SDF's gradient. + float cut = smoothstep(0.0, -max(uThickness, 0.002), sd); + h = -uDepth * cut; + float slope = uDepth / max(uThickness, 0.002) * (1.0 - abs(2.0 * cut - 1.0)); + nrm = normalize(vec3(-grad.x * slope * uRelief, 1.0, -grad.y * slope * uRelief)); + #else + // Two forward differences. Cheap, one-sided and slightly biased, which + // costs a fraction of a degree of tilt and saves two evaluations of a + // field that can be nine voronoi cells deep. + float e = max(uNormalStep, 0.005); + h = gfHeight(p); + float hdx = gfHeight(p + vec2(e, 0.0)); + float hdy = gfHeight(p + vec2(0.0, e)); + nrm = normalize(vec3((h - hdx) / e * uRelief, 1.0, (h - hdy) / e * uRelief)); + #endif + + /* ---- one lighting model for every mode ---- */ + vec3 L = normalize(vLight); + vec3 V = normalize(vView); + float lambert = dot(nrm, L); + // Wrapped, because none of these substances is a Lambertian brick: snow, + // liquid and churned earth all carry light round the terminator, and a hard + // cosine on a fake normal reads as embossed metal. + float diffuse = mix(max(lambert, 0.0), lambert * 0.5 + 0.5, uWrap); + float shade = uAmbient + (1.0 - uAmbient) * diffuse; + float spec = pow(max(dot(nrm, normalize(L + V)), 0.0), max(uGloss, 1.0)) * uSpecular; + float fresnel = pow(1.0 - clamp(dot(nrm, V), 0.0, 1.0), 4.0); + + // View-driven offset for interior detail, in metres. vView is already in + // the quad's frame, so its horizontal part is (x across, y downrange) — + // the same axes p uses. + vec2 parallax = vec2(V.x, V.z) * -h * uParallax; + + vec3 body = uColorBase; + vec3 emit = vec3(0.0); + float alpha = 0.0; + + /* ------------------------------------------------------------------ */ + #if GF_MODE == GF_PLATE + + float wall; + vec2 site; + float id; + gfCells(p / max(uCell, 0.02), uCellJitter, wall, site, id); + float wallM = wall * uCell; + float along = dot(p - site * uCell, uWind); + + float plate = smoothstep(0.0, max(uSeam, 0.001) + px, wallM); + float curled = smoothstep(0.0, max(uCell * 0.45, 0.01), wallM); + float lifted = (1.0 - curled) * smoothstep(-uCell * 0.15, uCell * 0.75, along); + + // Sheet ice is thin enough to see the next plate through, so the body + // colour is graded by how much ice the ray had to cross rather than by a + // flat tint: the middle of a plate is deep, the fractured seams are pale. + float thick = mix(0.25, 1.0, id) * plate; + body = mix(uColorEdge, uColorDeep, thick * 0.85); + body = mix(body, uColorBase, shade * 0.7); + // The lip is the only part that catches the key properly. + body = mix(body, uColorEdge, lifted * 0.65); + emit = uColorGlow * (spec * (0.4 + lifted) + lip * 0.6); + + alpha = cover * clamp(plate * (0.55 + 0.45 * thick) + lifted * 0.5, 0.0, 1.0); + alpha = mix(alpha, alpha * 0.25, smoothstep(0.0, 1.0, uRecede)); + + /* ------------------------------------------------------------------ */ + #elif GF_MODE == GF_RUNE + + /** + * The crisp edge. This is the whole reason the mode exists, so it is + * worth being exact about how it is got. + * + * aastep is the house analytic step: smoothstep across + * fwidth(value), which resolves an edge in about one pixel *at any + * zoom*. Feeding it the distance normalised to pixels rather than the + * distance in metres is what makes that true — a seal that fills the + * screen and a seal seen from the far corner of the stage then get the + * same edge, and a paused camera can be walked right up to it. + * + * The clamp is not cosmetic and it is not paranoia. The ring loop above + * skips bands with continue, so two fragments of one 2×2 quad can leave + * it holding 1e4 and 0.01; fwidth of that is astronomical, aastep + * spreads its transition over the whole range, and the result is a row of + * bright grey pixels tracing every band boundary — which is exactly the + * kind of fault that only shows up when somebody pauses and stares. + * Bounding the normalised distance to six pixels either side kills it and + * cannot touch the edge itself, where the distance is by definition + * within a pixel of zero. + */ + float sdn = clamp(sd / pxp, -6.0, 6.0); + float ink = aastep(0.0, -sdn); + float halo = 1.0 - smoothstep(0.0, max(uThickness, 0.002) * 3.0, sd); + + // Ignition runs outward from the middle: a mark lights when the front + // passes its own normalised radius. The sigil sits at ringT = 0, so the + // seal always catches at the centre and the rings go up in order. + float lit = smoothstep(ringT + 0.18, ringT - 0.18, uIgnite); + float burning = smoothstep(0.14, 0.0, abs(ringT - uIgnite)) * step(0.002, uIgnite); + // The stroke currently under the nib burns brightest — a stroke being cut + // is hot, a stroke already cut is only warm. + float nib = inked * (1.0 - inked) * 4.0; + // ...and what is left when the fire has gone through it. The seal does + // not fade out, it burns down: the ink chars to uColorDeep and stops + // emitting, and what remains on the floor is a scar in the shape of the + // writing rather than a rune that got quieter. + float scar = clamp(uScorch, 0.0, 1.0); + + float wash = cover * smoothstep(uRadius, uRadius * 0.2, d); + body = mix(uColorDeep, uColorBase, ink * (0.45 + 0.55 * shade)); + body = mix(body, uColorDeep, scar * 0.85 * ink); + emit = (uColorGlow * (ink * (0.35 + lit * 1.6 + burning * 2.2 + nib * 1.4) + halo * lit * 0.25) + + uColorEdge * (rule * 1.2 + spec * ink)) * (1.0 - scar); + + alpha = clamp(ink * 0.95 + halo * 0.18 * lit * (1.0 - scar) + rule * 0.9 + + wash * 0.14 * (1.0 - scar), 0.0, 1.0) * cover; + + /* ------------------------------------------------------------------ */ + #elif GF_MODE == GF_POCK + + float pit = gfPockCover(p + parallax); + float heat = gfPockHeat(p); + float rimness = smoothstep(0.0, uLift + 1e-4, h); + float depthness = smoothstep(0.0, -uDepth * 0.6 - 1e-4, h); + + body = mix(uColorBase, uColorDeep, depthness); + body = mix(body, uColorEdge, rimness * 0.8); + body *= shade; + emit = uColorGlow * heat * (0.6 + depthness) + uColorEdge * spec * 0.4; + + alpha = clamp(pit * (0.7 + 0.3 * rimness), 0.0, 1.0) * cover; + + /* ------------------------------------------------------------------ */ + #elif GF_MODE == GF_RUT + + float force = gfForce(s01); + float inRut = smoothstep(uWidth, uWidth * 0.35, lateral); + float ridge = smoothstep(0.0, uLift + 1e-4, h); + + body = mix(uColorBase, uColorDeep, inRut * (0.55 + 0.45 * force)); + body = mix(body, uColorEdge, ridge * 0.7); + body *= shade; + // The churn right under the body is still moving, so it is the only part + // that is lit rather than shaded. + emit = uColorGlow * lip * (0.5 + force) + uColorEdge * spec * 0.3; + + alpha = clamp(cover * (inRut * (0.55 + 0.45 * force) + ridge * 0.6), 0.0, 1.0); + + /* ------------------------------------------------------------------ */ + #elif GF_MODE == GF_WET + + // Soaked stone is *darker* than dry stone and shinier — which is only + // expressible with a non-additive blend. This mode is the argument for + // the option existing. + float damp = cover; + float sheen = spec * (0.5 + 0.5 * fresnel); + + body = mix(uColorBase, uColorDeep, damp * (0.7 + 0.3 * shade)); + emit = uColorEdge * sheen + uColorGlow * lip * 0.4; + + // Drying leaves a pale tide mark where the last of it stood. + float tide = smoothstep(0.0, max(uEdge, 1e-3), abs(reach - front)) ; + body = mix(uColorGlow, body, clamp(tide + 1.0 - step(0.002, uRecede), 0.0, 1.0)); + + alpha = clamp(damp * (0.55 + 0.45 * (1.0 - shade)) + sheen * 0.5, 0.0, 1.0); + + /* ------------------------------------------------------------------ */ + #elif GF_MODE == GF_PUSTULE + + float wall; + vec2 site; + float id; + float f1 = gfCells(p / max(uCell, 0.02), uCellJitter, wall, site, id); + float phase = gfPhase(id); + float burst = smoothstep(0.76, 0.84, phase); + + // The splatter thrown out at the moment it goes, travelling in metres. + float since = max(phase - 0.8, 0.0) / max(uSpeed, 0.01); + float ringR = since * uFlow; + float splat = smoothstep(uSeam, 0.0, abs(f1 * uCell - ringR)) * (1.0 - smoothstep(0.0, uCell * 0.9, ringR)); + + float taut = smoothstep(0.35, 0.95, phase) * (1.0 - burst); + body = mix(uColorBase, uColorEdge, taut * smoothstep(0.5, 0.0, f1)); + body = mix(body, uColorDeep, burst * smoothstep(0.55, 0.0, f1)); + body *= shade; + emit = uColorGlow * (splat * 1.4 + spec * taut) ; + + alpha = clamp(cover * (smoothstep(0.95, 0.25, f1) * (0.6 + 0.4 * taut) + splat), 0.0, 1.0); + + /* ------------------------------------------------------------------ */ + #elif GF_MODE == GF_FUNNEL + + float t = clamp(d / max(uRadius, 0.05), 0.0, 1.0); + // Detail on the walls is offset by the view, which is the only cue a flat + // quad has that says "this is below the floor" while the camera orbits. + float grit = snoise01(vec3((p + parallax) * 4.0, uSeed)) ; + float depthness = 1.0 - t; + + body = mix(uColorBase, uColorDeep, depthness * depthness); + body = mix(body, uColorEdge, smoothstep(0.85, 1.0, t) * 0.6); // the calved lip + body *= shade * (0.65 + 0.5 * grit * uDetail); + // The throat is never drawn. It is drawn as *nothing*, at full alpha. + body = mix(body, uColorDeep * 0.2, smoothstep(0.28, 0.0, t)); + emit = uColorGlow * smoothstep(0.2, 0.0, t) + uColorEdge * spec * 0.5; + + alpha = clamp(cover * smoothstep(1.35, 0.95, t) * (0.75 + 0.25 * depthness), 0.0, 1.0); + + /* ------------------------------------------------------------------ */ + #elif GF_MODE == GF_SCOUR + + float groove = smoothstep(0.0, -uDepth * 0.5 - 1e-4, h); + float ridge = smoothstep(0.0, uLift * 0.6 + 1e-4, h); + float bite = smoothstep(uRadius, uRadius * 0.1, d); + + body = mix(uColorBase, uColorDeep, groove); + body = mix(body, uColorEdge, ridge * 0.7); + body *= shade; + emit = uColorGlow * groove * bite * 0.5 + uColorEdge * spec * 0.4; + + alpha = clamp(cover * bite * (0.45 + 0.55 * max(groove, ridge)), 0.0, 1.0); + + /* ------------------------------------------------------------------ */ + #elif GF_MODE == GF_LATTICE + + vec4 hx = gfHex(p / max(uCell, 0.05)); + vec2 centre = hx.zw * max(uCell, 0.05); + float id = hash11(dot(floor(centre / max(uCell, 0.05) + 0.5), vec2(31.7, 57.1))); + float edge = 0.8660254 - gfHexRadius(hx.xy); + float trace = smoothstep((uThickness + px) / max(uCell, 0.05), uThickness / max(uCell, 0.05) * 0.4, edge); + float node = smoothstep((uSeam + px) / max(uCell, 0.05), 0.0, abs(length(hx.xy) - 1.0)) * trace; + + // The front does not cross the field, it crosses the *lattice*: a cell + // lights when the front reaches that cell's own centre, so the growth + // steps from cell to cell along the edges instead of sliding over them. + float cellFront = gfCellFront(centre, id); + float lit = smoothstep(cellFront + uCell * 0.9, cellFront - uCell * 0.2, front); + // Inside a lit cell the trace still fills from the side the charge came + // in on, which is what stops the whole hex flashing on at once. + float entry = dot(normalize(hx.xy + 1e-5), normalize(centre + 1e-5)); + lit *= smoothstep(-1.0, 0.4, entry + (front - cellFront) / max(uCell, 0.05)); + + // Charge running along the traces, phase keyed to the cell so two + // neighbours are never in step. + float run = fract((edge * max(uCell, 0.05) + length(centre)) * 0.5 - uTime * uSpeed + id); + float pulse = pow(1.0 - run, 6.0); + + body = mix(uColorDeep, uColorBase, trace) * shade; + emit = uColorGlow * (trace * lit * (0.5 + pulse * 1.5)) + uColorEdge * node * lit * 1.6; + + alpha = clamp(cover * (trace * (0.35 + 0.65 * lit) + node * lit), 0.0, 1.0); + + /* ------------------------------------------------------------------ */ + #else /* GF_POOL */ + + float deepness = smoothstep(uRadius, uRadius * 0.15, d); + float men = smoothstep(uRadius - uThickness, uRadius, d) * step(d, uRadius + uEdge); + + // A liquid is mostly what it reflects at grazing angles and mostly what + // it absorbs looking straight down, which is one Fresnel term and two + // colour pickers. + body = mix(uColorBase, uColorDeep, deepness); + body = mix(body, uColorEdge, clamp(fresnel * 0.8 + men * 0.5, 0.0, 1.0)); + body *= 0.4 + 0.6 * shade; + emit = uColorEdge * spec + uColorGlow * deepness * 0.25; + + alpha = clamp(cover * (0.35 + 0.65 * deepness + men * 0.4), 0.0, 1.0); + + #endif + + /* ---- assemble ---- */ + vec3 color = body + emit * uEmissive; + alpha *= uFade * uOpacity; + + // Soft against anything standing in the mark. The depth prepass is half + // resolution, so its silhouettes are a pixel or two soft — which is exactly + // the band worth feathering, and why this is worth doing even with the + // depth test on. Turn the test off and this term does all the work. + vec2 screenUV = gl_FragCoord.xy / uResolution; + // Not 'packed', which is the obvious name and a future-reserved word in + // GLSL ES: ANGLE rejects it outright, so every mode of this shader would + // have failed to compile on macOS Chrome. commonGLSL#softFade made the same + // mistake and renamed to depthBits; VolumeHull.js has the note. This one + // survived because the reserved-word scanner in scripts/ only audits + // Distortion and Portal, and nothing had compiled a GroundField in a + // browser yet. + float depthBits = unpackRGBAToDepth(texture2D(uSceneDepth, screenUV)); + float sceneViewZ = perspectiveDepthToViewZ(depthBits, uCameraNear, uCameraFar); + alpha *= smoothstep(-max(uDepthFade, 1e-3), 0.0, vViewZ - sceneViewZ); + + if (alpha < 0.004) discard; + color *= uGlobalGlow; + disruptShade(color, alpha, vDisrupt, gl_FragCoord.xy); + gl_FragColor = vec4(color, alpha); + } +`; + +/* ---------------------------------------------------------------------- */ +/* Params */ +/* ---------------------------------------------------------------------- */ + +/** + * Canonical parameter names with their defaults, in the units the field wants. + * + * Every one of these is read fresh on every call to `update()`, so a missing + * field falls back to the value listed here and a params object only carries + * what the ability actually authors. + * + * ### The shared shape vocabulary + * + * Ten modes with private names would be four hundred uniforms. Instead there is + * one vocabulary of measurements and each mode says what it does with them: + * + * | param | PLATE | RUNE | POCK | RUT | WET | PUSTULE | FUNNEL | SCOUR | LATTICE | POOL | + * | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + * | `cell` | plate size | — | grain | chatter pitch | puddle scale | blister size | lip block | — | hex size | ripple scale | + * | `seam` | gap between plates | — | — | force blend | — | skin gap | block gap | — | node size | — | + * | `thickness` | sheet | stroke depth | rim width | spoil width | — | — | lip drop | — | trace width | meniscus | + * | `lift` | curl height | — | rim height | spoil height | ripple | dome height | spoil | ridge | trace height | wave height | + * | `depth` | — | incision | crater | gouge | puddle | crater | the pit | groove | etch | body depth | + * | `width` | — | — | — | track half-width | — | — | — | — | — | — | + * | `swirl` | — | — | — | track wander | — | — | — | spiral pitch | — | — | + * | `arms` | — | — | — | — | — | — | — | groove count | — | — | + * | `speed` | — | — | dig rate | — | ripple rate | bursts/s | — | rotation | charge rate | ripple rate | + * | `flow` | — | — | — | — | drift | splatter speed | — | — | — | drift | + * | `windAngle` | downwind | — | — | — | drift bearing | — | — | — | — | flow bearing | + */ +export function groundFieldParams() { + return { + /* --- where it is (the ability's own vectors; never copied) --- */ + centre: null, // Vector3 on the floor. RUT: the *start* of the track + yaw: 0, // radians about +Y; local +Z is downrange + height: 0.02, // metres above the floor the quad sits at + radius: 4, // metres — the mark's own radius. Re-read every frame + length: 10, // metres — RUT only, how long the track is + + /* --- the beats (unitless; the ability's clock resolves them) --- */ + grow: 1, // 0..1 the front spreading outward + recede: 0, // 0..1 the outside being eaten back (drying, sublimating) + progress: 1, // 0..1 RUT: how far the body has rolled + inscribe: 1, // 0..1 RUNE: how much of the seal has been inked + ignite: 0, // 0..1 RUNE: the ignition front, inner ring first + fade: 1, // 0..1 master fade + seed: 0, // decorrelates two casts. A dice roll, safe to capture + + /* --- the front --- */ + edge: 0.35, // metres of feather on the front + ragged: 0.28, // how far it wanders, as a fraction of the radius + raggedScale: 0.7, // lobes per metre + warp: 0.5, // metres of domain warp on those lobes + + /* --- relief and lighting --- */ + relief: 0.6, // how hard the height field tilts the fake normal + normalStep: 0.06, // metres between the height taps + ambient: 0.32, // floor on the diffuse term + wrap: 0.45, // 0..1 wraps the terminator round the back + specular: 0.5, + gloss: 24, // Blinn exponent + parallax: 0.35, // metres of view-driven offset on interior detail + + /* --- the shape vocabulary; see the table above --- */ + cell: 0.55, // metres + cellJitter: 0.85, // 0..1 + seam: 0.05, // metres + thickness: 0.05, // metres + lift: 0.12, // metres + depth: 0.25, // metres + width: 0.5, // metres + sharp: 0.5, // 0..1 + detail: 0.6, // 0..1 + swirl: 0.3, + arms: 5, // whole number; rounded on the way in + speed: 1.2, // events or radians per second + flow: 0.35, // metres per second + windAngle: 0.6, // radians, in the quad's frame + + /* --- RUNE --- */ + rings: 3, // 1..4 nested rings + ringInner: 0.35, // innermost ring as a fraction of the radius + glyphSize: 0.55, // metres — the em box, a real measurement + glyphStroke: 0.045, // metres — half-width of a stroke + glyphGap: 1.35, // slot pitch, in glyph widths + spin: 0.12, // radians/second, ring 0 + spinFalloff: 0.6, // how much slower each ring out turns + rule: 0.012, // metres — half-width of the compass circles + scorch: 0, // 0..1 how far the seal has burnt down to a scar + + /* --- RUNE: the armature and its tick collar --- */ + armStart: 1, // 0..1 where the rings hand over to the armature. 1 = no armature + armRadius: 0.66, // armature circle, as a fraction of the radius + armSides: 7, // chords struck around it + armTangent: 0.4, // apothem, as a fraction of the armature radius (0 = through the anchor) + armStroke: 0.02, // metres — half-width of an armature line + armPhase: 0.35, // radians — where the first chord starts + armSpin: -0.05, // radians/second the armature turns + tickCount: 60, // ticks in the collar + tickRadius: 0.95, // collar radius, as a fraction of the radius + tickLength: 0.13, // metres — a minor tick + tickStroke: 0.011, // metres — half-width of a tick + tickMajor: 6, // every Nth tick is a long one + tickMajorLen: 0.15, // metres of extra length on a major tick + + /* --- RUNE: the central sigil --- */ + sigilStart: 1, // 0..1 where the armature hands over to the sigil. 1 = no sigil + sigilRadius: 0.36, // metres — how far the rosette glyph sits from the anchor + sigilSize: 0.72, // metres — its em box; deliberately larger than glyphSize + sigilArms: 5, // rotational symmetry of the rosette + sigilStroke: 0.032, // metres — half-width of a sigil stroke + sigilRing: 0.92, // metres — the circle enclosing the sigil + sigilSpin: 0.06, // radians/second the sigil turns + + /* --- POCK / RUT marks --- */ + markLife: 6, // seconds a mark weathers away over + markRadius: 0.7, // metres, a full-strength mark + + /* --- output --- */ + additive: false, // true adds light, false shades the floor + emissive: 1, // multiplier on every glowing term + opacity: 1, + depthFade: 0.5, // metres of soft fade against standing geometry + colorBase: '#8a8f96', // the substance itself + colorEdge: '#dfe8f0', // rims, lips, highlights, sheen + colorGlow: '#ffd27a', // anything emissive + colorDeep: '#161a1e', // the interior, the shadow, the hole + + /* --- global multipliers (settings.global.*, 1 = neutral) --- */ + noiseStrength: 1, + noiseFrequency: 1, + noiseSpeed: 1, + opacityScale: 1 + }; +} + +/* ---------------------------------------------------------------------- */ +/* The mesh */ +/* ---------------------------------------------------------------------- */ + +export class GroundField { + /** + * @param {THREE.Object3D} parent the ability's group + * @param {object} options + * @param {number} options.mode GroundMode.* — a `#define`, fixed for the lifetime + * @param {number} [options.marks=12] POCK/RUT: how many events the shader carries + * @param {boolean} [options.additive=false] initial blend; `params.additive` drives it after + * @param {boolean} [options.depthTest=true] false lets the soft fade do all the occlusion + * @param {number} [options.layer=LAYER.VFX] + * @param {number} [options.renderOrder] defaults to 6 shaded / 8 additive, as the decals do + * @param {string} [options.name] + */ + constructor(parent, options = {}) { + const { + mode = GroundMode.PLATE, + marks = 12, + additive = false, + depthTest = true, + layer = LAYER.VFX, + renderOrder = null, + name = null + } = options; + + this.parent = parent; + this.mode = mode; + this.marks = Math.max(1, Math.round(marks)); + this.geometry = acquireGroundQuad(); + + /** + * The event list, as `(x, z, time, strength)`. + * + * `xy` is a fraction of the radius, not a metre — which is the whole reason + * a crater re-places itself when the radius slider moves. Allocated once + * here; `mark()` writes into it and never grows it. + */ + this._marks = []; + for (let i = 0; i < this.marks; i++) this._marks.push(new Vector4(0, 0, -1e4, 1)); + this._markCount = 0; + this._markNext = 0; + + this.material = new ShaderMaterial({ + defines: { + GF_MODE: mode, + GF_MARKS: this.marks, + GF_RINGS: RUNE_RINGS, + GF_GLYPHS: GLYPH_COUNT + }, + transparent: true, + depthWrite: false, + depthTest, + blending: additive ? AdditiveBlending : NormalBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uQuadSize: { value: new Vector2(12, 12) }, + uRadius: { value: 4 }, + uLength: { value: 10 }, + uSeed: { value: 0 }, + + uGrow: { value: 1 }, + uRecede: { value: 0 }, + uEdge: { value: 0.35 }, + uRagged: { value: 0.28 }, + uRaggedScale: { value: 0.7 }, + uWarp: { value: 0.5 }, + + uRelief: { value: 0.6 }, + uNormalStep: { value: 0.06 }, + uAmbient: { value: 0.32 }, + uWrap: { value: 0.45 }, + uSpecular: { value: 0.5 }, + uGloss: { value: 24 }, + uParallax: { value: 0.35 }, + + uCell: { value: 0.55 }, + uCellJitter: { value: 0.85 }, + uSeam: { value: 0.05 }, + uThickness: { value: 0.05 }, + uLift: { value: 0.12 }, + uDepth: { value: 0.25 }, + uWidth: { value: 0.5 }, + uSharp: { value: 0.5 }, + uDetail: { value: 0.6 }, + uSwirl: { value: 0.3 }, + uArms: { value: 5 }, + uSpeed: { value: 1.2 }, + uFlow: { value: 0.35 }, + uWind: { value: new Vector2(0.56, 0.83) }, + + uRings: { value: 3 }, + uRingInner: { value: 0.35 }, + uGlyphSize: { value: 0.55 }, + uGlyphStroke: { value: 0.045 }, + uGlyphGap: { value: 1.35 }, + uSpin: { value: 0.12 }, + uSpinFalloff: { value: 0.6 }, + uRule: { value: 0.012 }, + uInscribe: { value: 1 }, + uIgnite: { value: 0 }, + uScorch: { value: 0 }, + + uArmStart: { value: 1 }, + uArmRadius: { value: 0.66 }, + uArmSides: { value: 7 }, + uArmTangent: { value: 0.4 }, + uArmStroke: { value: 0.02 }, + uArmPhase: { value: 0.35 }, + uArmSpin: { value: -0.05 }, + uTickCount: { value: 60 }, + uTickRadius: { value: 0.95 }, + uTickLength: { value: 0.13 }, + uTickStroke: { value: 0.011 }, + uTickMajor: { value: 6 }, + uTickMajorLen: { value: 0.15 }, + + uSigilStart: { value: 1 }, + uSigilRadius: { value: 0.36 }, + uSigilSize: { value: 0.72 }, + uSigilArms: { value: 5 }, + uSigilStroke: { value: 0.032 }, + uSigilRing: { value: 0.92 }, + uSigilSpin: { value: 0.06 }, + + uMarks: { value: this._marks }, + uMarkCount: { value: 0 }, + uMarkLife: { value: 6 }, + uMarkRadius: { value: 0.7 }, + uProgress: { value: 1 }, + + uFade: { value: 1 }, + uOpacity: { value: 1 }, + uEmissive: { value: 1 }, + uDepthFade: { value: 0.5 }, + uColorBase: { value: new Color(0.54, 0.56, 0.59) }, + uColorEdge: { value: new Color(0.87, 0.91, 0.94) }, + uColorGlow: { value: new Color(1, 0.82, 0.48) }, + uColorDeep: { value: new Color(0.09, 0.1, 0.12) }, + + // Opt-in to vfx/SceneHooks.js's disruption field. Shared boxes, by + // identity: one write in sceneHooks.apply() reaches every mark in the + // app, and cloning them here would read a field that is always off. + ...disruptUniforms() + }), + vertexShader: GROUND_VERTEX, + fragmentShader: GROUND_FRAGMENT + }); + + this.mesh = new Mesh(this.geometry, this.material); + this.mesh.name = name ?? `GroundField:${GROUND_MODE_NAMES[mode] ?? mode}`; + this.mesh.layers.set(layer); + // Under the VFX, over the floor — and additive marks last, so a shaded mark + // never composites on top of the light it is supposed to be sitting under. + this.mesh.renderOrder = renderOrder ?? (additive ? 8 : 6); + this.mesh.frustumCulled = false; + this._additive = additive; + this._renderOrder = renderOrder; + + parent?.add(this.mesh); + } + + get object3D() { + return this.mesh; + } + + /** One. Always one. */ + get drawCalls() { + return 1; + } + + /** Events currently in the list. */ + get markCount() { + return this._markCount; + } + + setVisible(visible) { + this.mesh.visible = visible; + } + + /** + * Post an event: an impact for `POCK`, a contact sample for `RUT`. + * + * @param {number} x unitless, fraction of the radius across (local +X) + * @param {number} z unitless, fraction of the radius downrange (local +Z); + * for `RUT`, 0..1 along the track + * @param {number} time the timestamp it fired at — an event, not a dimension + * @param {number} [strength=1] 0..1, scales the crater or the contact force + * + * Past `marks` events the list recycles oldest-first. That is a ring buffer, + * not a queue: nothing shuffles and nothing allocates. + */ + mark(x, z, time, strength = 1) { + const slot = this._marks[this._markNext]; + slot.set(x, z, time, strength); + this._markNext = (this._markNext + 1) % this.marks; + this._markCount = Math.min(this._markCount + 1, this.marks); + this.material.uniforms.uMarkCount.value = this._markCount; + return slot; + } + + /** Forget every event. Part of the pooling contract — call it from `onSpawn`. */ + clearMarks() { + this._markCount = 0; + this._markNext = 0; + this.material.uniforms.uMarkCount.value = 0; + for (let i = 0; i < this.marks; i++) this._marks[i].set(0, 0, -1e4, 1); + } + + /** + * Re-resolve everything from the live params and place the quad. + * + * Allocation-free, and safe on a zero-length frame — which is the test that + * matters: pause, drag `radius`, and the mark on the floor re-scales, its + * grain re-scales with it and its front stays where the growth clock left it. + * + * @param {object} p live params — see `groundFieldParams()` + */ + update(p) { + const u = this.material.uniforms; + + const nf = p.noiseFrequency ?? 1; + const ns = p.noiseStrength ?? 1; + const nsp = p.noiseSpeed ?? 1; + + const radius = Math.max(0.05, p.radius ?? 4); + const length = Math.max(0.2, p.length ?? 10); + const edge = Math.max(0.001, p.edge ?? 0.35); + + /* ---- the front ---- */ + u.uRadius.value = radius; + u.uLength.value = length; + u.uSeed.value = p.seed ?? 0; + u.uGrow.value = p.grow ?? 1; + u.uRecede.value = p.recede ?? 0; + u.uEdge.value = edge; + u.uRagged.value = (p.ragged ?? 0.28) * ns; + u.uRaggedScale.value = (p.raggedScale ?? 0.7) * nf; + u.uWarp.value = (p.warp ?? 0.5) * ns; + + /* ---- relief ---- */ + u.uRelief.value = p.relief ?? 0.6; + u.uNormalStep.value = Math.max(0.005, p.normalStep ?? 0.06); + u.uAmbient.value = p.ambient ?? 0.32; + u.uWrap.value = p.wrap ?? 0.45; + u.uSpecular.value = p.specular ?? 0.5; + u.uGloss.value = Math.max(1, p.gloss ?? 24); + u.uParallax.value = p.parallax ?? 0.35; + + /* ---- the shape vocabulary ---- */ + u.uCell.value = Math.max(0.02, p.cell ?? 0.55); + u.uCellJitter.value = p.cellJitter ?? 0.85; + u.uSeam.value = Math.max(0.001, p.seam ?? 0.05); + u.uThickness.value = Math.max(0.001, p.thickness ?? 0.05); + u.uLift.value = p.lift ?? 0.12; + u.uDepth.value = p.depth ?? 0.25; + u.uWidth.value = Math.max(0.01, p.width ?? 0.5); + u.uSharp.value = p.sharp ?? 0.5; + u.uDetail.value = p.detail ?? 0.6; + u.uSwirl.value = p.swirl ?? 0.3; + // Whole grooves only: the spiral phase is angular and a fractional arm + // count tears the field open along the ±π seam. + u.uArms.value = Math.max(1, Math.round(p.arms ?? 5)); + u.uSpeed.value = (p.speed ?? 1.2) * nsp; + u.uFlow.value = (p.flow ?? 0.35) * nsp; + const wind = p.windAngle ?? 0.6; + u.uWind.value.set(Math.sin(wind), Math.cos(wind)); + + /* ---- the seal ---- */ + u.uRings.value = Math.min(RUNE_RINGS, Math.max(1, Math.round(p.rings ?? 3))); + u.uRingInner.value = Math.min(0.95, Math.max(0.02, p.ringInner ?? 0.35)); + u.uGlyphSize.value = Math.max(0.02, p.glyphSize ?? 0.55); + u.uGlyphStroke.value = Math.max(0.002, p.glyphStroke ?? 0.045); + u.uGlyphGap.value = Math.max(0.5, p.glyphGap ?? 1.35); + u.uSpin.value = p.spin ?? 0.12; + u.uSpinFalloff.value = p.spinFalloff ?? 0.6; + u.uRule.value = Math.max(0.001, p.rule ?? 0.012); + u.uInscribe.value = p.inscribe ?? 1; + u.uIgnite.value = p.ignite ?? 0; + u.uScorch.value = p.scorch ?? 0; + + /* ---- the armature and the tick collar ---- */ + // The counts are rounded here rather than in the shader for the same reason + // `arms` is: a fractional side count puts the last chord and the first one + // at different bearings and the figure tears open along one seam. + // 1 means "the rings own the whole clock", which is how this mode behaved + // before the armature existed and is therefore what a caller who says + // nothing must keep getting. + u.uArmStart.value = Math.min(1, Math.max(0.02, p.armStart ?? 1)); + u.uArmRadius.value = p.armRadius ?? 0.66; + u.uArmSides.value = Math.max(3, Math.round(p.armSides ?? 7)); + u.uArmTangent.value = p.armTangent ?? 0.4; + u.uArmStroke.value = Math.max(0.001, p.armStroke ?? 0.02); + u.uArmPhase.value = p.armPhase ?? 0.35; + u.uArmSpin.value = p.armSpin ?? -0.05; + u.uTickCount.value = Math.max(4, Math.round(p.tickCount ?? 60)); + u.uTickRadius.value = p.tickRadius ?? 0.95; + u.uTickLength.value = Math.max(0.001, p.tickLength ?? 0.13); + u.uTickStroke.value = Math.max(0.001, p.tickStroke ?? 0.011); + u.uTickMajor.value = Math.max(1, Math.round(p.tickMajor ?? 6)); + u.uTickMajorLen.value = Math.max(0, p.tickMajorLen ?? 0.15); + + /* ---- the central sigil ---- */ + u.uSigilStart.value = Math.min(1, Math.max(0.03, p.sigilStart ?? 1)); + u.uSigilRadius.value = Math.max(0, p.sigilRadius ?? 0.36); + u.uSigilSize.value = Math.max(0.02, p.sigilSize ?? 0.72); + u.uSigilArms.value = Math.max(1, Math.round(p.sigilArms ?? 5)); + u.uSigilStroke.value = Math.max(0.002, p.sigilStroke ?? 0.032); + u.uSigilRing.value = Math.max(0.01, p.sigilRing ?? 0.92); + u.uSigilSpin.value = p.sigilSpin ?? 0.06; + + /* ---- events ---- */ + u.uMarkLife.value = Math.max(0.05, p.markLife ?? 6); + u.uMarkRadius.value = Math.max(0.02, p.markRadius ?? 0.7); + u.uProgress.value = p.progress ?? 1; + + /* ---- output ---- */ + u.uFade.value = p.fade ?? 1; + u.uOpacity.value = (p.opacity ?? 1) * (p.opacityScale ?? 1); + u.uEmissive.value = p.emissive ?? 1; + u.uDepthFade.value = Math.max(0.001, p.depthFade ?? 0.5); + u.uColorBase.value.copy(getColor(p.colorBase ?? '#8a8f96')); + u.uColorEdge.value.copy(getColor(p.colorEdge ?? '#dfe8f0')); + u.uColorGlow.value.copy(getColor(p.colorGlow ?? '#ffd27a')); + u.uColorDeep.value.copy(getColor(p.colorDeep ?? '#161a1e')); + + this.setAdditive(p.additive ?? false); + + /* ---- the quad ---- */ + // Big enough to hold the mark at full raggedness plus whatever stands + // outside it — spoil, splatter, a calved lip. Re-derived every frame, so a + // radius drag grows the canvas along with the drawing on it. + const pad = edge + radius * (p.ragged ?? 0.28) + Math.max(p.lift ?? 0, p.thickness ?? 0) * 4 + 0.6; + const across = (radius + pad) * 2; + const down = this.mode === GroundMode.RUT ? length + pad * 2 : across; + + u.uQuadSize.value.set(across, down); + this.mesh.scale.set(across, 1, down); + + const yaw = p.yaw ?? 0; + const centre = p.centre; + const height = p.height ?? 0.02; + if (centre) { + // RUT anchors at the *start* of the track, so the quad slides downrange + // by half its own length to cover it. + const slide = this.mode === GroundMode.RUT ? length * 0.5 : 0; + this.mesh.position.set( + centre.x + Math.sin(yaw) * slide, + height, + centre.z + Math.cos(yaw) * slide + ); + } else { + this.mesh.position.y = height; + } + this.mesh.rotation.set(0, yaw, 0); + } + + /** + * Switch between adding light to the floor and shading it. + * + * Blend state is not compiled into the program, so this is free to drive from + * a checkbox every frame — which is the point, since choosing wrong is the + * documented rough edge this module exists to close. + */ + setAdditive(additive) { + if (additive === this._additive) return; + this._additive = additive; + this.material.blending = additive ? AdditiveBlending : NormalBlending; + this.mesh.renderOrder = this._renderOrder ?? (additive ? 8 : 6); + } + + dispose() { + this.mesh.parent?.remove(this.mesh); + this.material.dispose(); + releaseGroundQuad(); + this.geometry = null; + } +} diff --git a/src/vfx/GrowthField.js b/src/vfx/GrowthField.js new file mode 100644 index 0000000..7a6d6d8 --- /dev/null +++ b/src/vfx/GrowthField.js @@ -0,0 +1,974 @@ +import { InstancedMesh, InstancedBufferAttribute, Object3D, Vector3, Quaternion } from 'three'; +import { patchOnBeforeCompile, replaceChunk } from '../utils/shaderPatch.js'; +import { LAYER } from '../core/Layers.js'; +import { disruptGLSL, disruptUniforms } from './SceneHooks.js'; +import { saturate, lerp, smoothstep, Easing } from '../utils/math.js'; + +/* ---------------------------------------------------------------------- */ +/* GrowthField — things that come out of the ground */ +/* ---------------------------------------------------------------------- */ + +/** + * Instanced procedural geometry erupting along a line or across a zone. + * + * This is `IceAbility`'s crystal field with the ice taken out of it. That field + * is the best system in the original build for one reason, and it is not the + * shader: a spike record holds **nothing but dice rolls and one timestamp**, and + * every metre, radian and second is resolved against the live settings inside + * the update loop. Drag `height` on a field that is already standing, with the + * clock stopped, and the field re-grows. Twelve abilities in `docs/ROSTER.md` + * want that behaviour with a different silhouette — thorns, sheet-ice plates, + * heaved stone slabs, bone spears, petals, chain links — so the discipline moved + * here and the look stayed with the ability. + * + * **This module does not own a material.** It owns placement, timing and + * matrices; the ability supplies the geometry factory and the material, which is + * why rime plates and umbral spears can share every line of this file and look + * nothing like each other. `patchGrowthMaterial()` at the bottom wires the two + * per-instance attributes into a `MeshStandardMaterial` for you. + * + * ## What it draws + * + * `variants` InstancedMeshes over one shared record pool — three by default, + * because that is what `IceAbility` learnt: per-instance scaling gives you + * proportion variety, but only distinct geometry gives you *facet* variety, and + * a field of one silhouette scaled forty ways reads as a repeated prop the + * moment the camera moves. Three draw calls is the price and it is worth it. Set + * `variants: 1` for anything whose shape genuinely does not repeat (one big + * lance) and the cost drops with it. + * + * ## The contract with the geometry factory + * + * The factory is called as `factory(variant, shape)` and must return geometry in + * **unit space**: footprint inside a circle of radius 0.5 on `y = 0`, tip at + * `y = 1`. An instance then scales footprint and height independently, and + * `local.y` reads straight off in the fragment shader as "how far up this thing + * am I" — which is what every base-to-tip gradient (rime line, bark, wither) + * keys off. `assets/ProceduralGeometry.js#createCrystalGeometry` is the + * reference implementation. + * + * ## The rule for using it well + * + * Fill the params object from `settings[id]` **every frame** and hand it to + * `update()`. Never keep a resolved metre between frames. If your settings block + * happens to use the canonical names below you can pass the block itself, which + * makes breaking invariant I1 impossible. + * + * @example + * this.field = new GrowthField(this.group, { + * geometry: (v, shape) => createCrystalGeometry({ seed: 7.3 + v * 21.7, ...shape }), + * material: this.material, + * capacity: 240 + * }); + * // spawn + * this.field.plant(count, 0.22); + * // travel, every frame + * this._fillParams(); // reads settings.rime into a scratch + * this.field.syncGeometry(this._shape); // live shape sliders + * this.field.triggerUpTo(this.age, this.u, c.riseStagger, c.frontBias, false); + * this.field.update(this.age, this._params, 0); + */ + +const TAU = Math.PI * 2; + +/* Module-scope scratch. Nothing in the frame path allocates — invariant I3. */ +const _dummy = new Object3D(); +const _lean = new Vector3(); +const _axis = new Vector3(); +const _up = new Vector3(0, 1, 0); +const _tilt = new Quaternion(); +const _spin = new Quaternion(); +const _centre = new Vector3(); +const _breachPos = new Vector3(); +const _zero = new Vector3(); +const _forward = new Vector3(0, 0, 1); +const _right = new Vector3(1, 0, 0); + +/** Where the field is laid out. A mode, not a dimension — safe to capture. */ +export const GrowthLayout = Object.freeze({ + LINE: 0, // a band down the cast line, widening with `widthCurve` + ZONE: 1 // an annulus about a centre, `innerRadius`..`radius` +}); + +/** How an instance arrives. */ +export const GrowthEmerge = Object.freeze({ + /** Punches up out of the floor: buried, then slid into place. Ice, stone, bone. */ + PUSH: 0, + /** Accretes in place at full position, scaling from nothing. Petals, facets, air growth. */ + SCALE: 1 +}); + +/** + * Canonical parameter names with their defaults, in the units the field wants. + * + * Every one of these is read fresh on every call to `update()`. A missing field + * falls back to the default listed here, so a params object only carries what + * the ability actually authors. + */ +export function growthParams() { + return { + /* --- mode + basis (the ability's own vectors; never copied) --- */ + layout: GrowthLayout.LINE, + emerge: GrowthEmerge.PUSH, + origin: null, // Vector3, on the floor + direction: null, // Vector3, unit, flat + side: null, // Vector3, unit, lateral + length: 10, // metres down the line + centre: null, // Vector3 for ZONE; defaults to the far end of the line + radius: 4, // metres, ZONE outer + innerRadius: 0, // metres, ZONE inner (0 fills the disc) + + /* --- LINE footprint (metres unless noted) --- */ + widthNear: 0.5, // half-width of the band at the caster + width: 2.5, // half-width at the far end + widthCurve: 1, // <1 flares early, >1 stays narrow then opens out + frontBias: 1, // <1 crowds instances toward the far end, unitless + clumping: 1, // >1 pulls them toward the centre line, unitless + scatter: 0.5, // extra lateral jitter, fraction of the local half-width + + /* --- ZONE footprint --- */ + radialCurve: 1, // <1 pushes the band toward the rim + radialJitter: 0, // metres of radial wander + angleJitter: 0, // radians of bearing wander + + /* --- the terminal cluster (the records held back by `plant`) --- */ + clusterRadius: -1, // metres; < 0 derives 1.25 × the far half-width / inner radius + + /* --- silhouette --- */ + heightNear: 0.5, // metres at the near end / centre + height: 3, // metres at the far end / rim + heightCurve: 1, // how late the ramp climbs + heightJitter: 0.4, // ± fraction + crown: 0, // 0..1 — how much shorter the flank instances are than the spine + crownPower: 1.4, // how sharply that dome falls off + peak: 1, // extra height multiplier at the far end + peakWidth: 0.25, // 0..1 of the cast that swell covers + rubble: 0, // 0..1 chance an instance is demoted to wreckage + rubbleScale: 0.3, // height multiplier for those + rubbleSpread: 1.25, // radius multiplier for those + minHeight: 0.02, // metres, floor + + radiusNear: 0.3, // metres, base radius at the near end / centre + radius2: -1, // metres at the far end / rim; < 0 tracks `radiusNear` + radiusCurve: 0.6, // how the radius ramps along the cast + radiusJitter: 0.4, // ± fraction + minRadius: 0.01, // metres, floor + + /* --- orientation --- */ + lean: 0, // radians away from the caster / outward from the centre + leanJitter: 0, // ± fraction + leanRamp: 0.65, // 0 leans everything equally, 1 only leans the far end + leanForward: 0.75, // weight of "away from the caster" in the lean direction + leanOutward: 0.85, // weight of "out across the band" + twist: 1, // 0..1 of a full turn of random yaw + tilt: 0, // radians of extra random tip, any bearing + + /* --- where the base sits (air growth) --- */ + baseHeight: 0, // metres above the floor + baseJitter: 0, // ± fraction of `baseHeight` + + /* --- the eruption --- */ + riseTime: 0.18, // seconds from buried to full height + riseOvershoot: 0.25, // how far past full height the punch carries + settle: 0.5, // seconds the overshoot damps out over + springRate: 14, // radians/second of that overshoot ring + emergeSink: 0.85, // fraction of its height an instance is buried at emerge = 0 + birthScale: 0.86, // footprint scale at the moment it breaks through + birthFade: 0.18, // seconds the `aBirth` flash decays over + breachAt: 0.25, // emergence fraction that fires `onBreach` + + /* --- withdrawal --- */ + sinkDepth: 0.4, // extra metres a retracting instance drops beyond its own height + + /* --- globals --- */ + randomness: 1 // multiplies every *Jitter above (settings.global.randomness) + }; +} + +export class GrowthField { + /** + * @param {THREE.Object3D} parent the ability's group + * @param {object} options + * @param {(variant: number, shape: object) => THREE.BufferGeometry} options.geometry + * unit-space geometry factory — see the class header for the space + * @param {THREE.Material} options.material supplied by the ability, never owned here + * @param {object} [options.shape] first shape params, handed to the factory and + * used to prime the rebuild hash, so the first `syncGeometry` with the same numbers + * is a no-op. Omit it only if your factory tolerates `null`. + * @param {number} [options.variants=3] distinct silhouettes = distinct draw calls + * @param {number} [options.capacity=288] hard ceiling on instances per cast + * @param {number} [options.layer=LAYER.WORLD] + * @param {number} [options.renderOrder=2] + * @param {boolean} [options.castShadow=true] + * @param {boolean} [options.receiveShadow=true] + */ + constructor(parent, options = {}) { + const { + geometry, + material, + shape = null, + variants = 3, + capacity = 288, + layer = LAYER.WORLD, + renderOrder = 2, + castShadow = true, + receiveShadow = true + } = options; + + if (typeof geometry !== 'function') throw new Error('GrowthField: options.geometry must be a factory'); + if (!material) throw new Error('GrowthField: options.material is required'); + + this.parent = parent; + this.factory = geometry; + this.material = material; + this.variants = Math.max(1, Math.round(variants)); + this.capacity = Math.max(1, Math.round(capacity)); + this.slots = Math.ceil(this.capacity / this.variants); + + /** + * Fired the frame an instance breaks the surface — chips, a puff, a decal. + * Assign **once**, at construction: a closure built inside the update loop + * is an allocation per frame per instance, which is exactly what I3 forbids. + * @type {null | (index: number, position: Vector3, radius: number, height: number) => void} + */ + this.onBreach = null; + + this.meshes = []; + this.seedAttributes = []; + this.birthAttributes = []; + + for (let v = 0; v < this.variants; v++) { + const seeds = new InstancedBufferAttribute(new Float32Array(this.slots), 1); + const births = new InstancedBufferAttribute(new Float32Array(this.slots), 1); + for (let i = 0; i < this.slots; i++) seeds.array[i] = Math.random() * 10; + + const mesh = new InstancedMesh(this._buildGeometry(v, shape, seeds, births), material, this.slots); + mesh.castShadow = castShadow; + mesh.receiveShadow = receiveShadow; + // Placed in world space from the CPU every frame; its own bounds are a lie + // and the cost of computing them per cast is not worth paying. + mesh.frustumCulled = false; + mesh.count = 0; + mesh.layers.set(layer); + mesh.renderOrder = renderOrder; + parent.add(mesh); + + this.meshes.push(mesh); + this.seedAttributes.push(seeds); + this.birthAttributes.push(births); + } + + /** + * Fixed-size record pool — a cast allocates nothing. + * + * Dice and one timestamp. If you are tempted to add a metre to this object, + * add a slider to the params instead; see the class header. + */ + this.records = []; + for (let i = 0; i < this.capacity; i++) { + this.records.push({ + along: 0, // 0..1 uniform down the line, *before* frontBias + lateral: 0, // -1..1 across the band, before clumping + scatter: 0, // -1..1 extra lateral / angular jitter + angle: 0, // 0..1 of a turn — cluster bearing, or ZONE bearing + radial: 0, // 0..1 sqrt-uniform, so a disc fills evenly + cluster: false, // held back for the group at the far end / the centre + rubbleRoll: 0, // 0..1, compared live against `rubble` + heightRoll: 0, // -1..1 + radiusRoll: 0, // -1..1 + leanRoll: 0, // -1..1 + tiltRoll: 0, // -1..1 + tiltBearing: 0, // 0..1 of a turn + baseRoll: 0, // -1..1 + yaw: 0, // 0..1 of a turn + stagger: 0, // 0..1 of the caller's stagger window + eruptTime: -1, // absolute age it was triggered at, or -1 for buried + breached: false + }); + } + + this._count = 0; + this._clusterStart = this.capacity; + this._used = new Int32Array(this.variants); + + /** Numeric signature of the last geometry build. See `syncGeometry`. */ + this._shapeKeys = null; + this._shapeValues = null; + if (shape) { + this._shapeKeys = Object.keys(shape); + this._shapeValues = new Float64Array(this._shapeKeys.length); + for (let k = 0; k < this._shapeKeys.length; k++) { + this._shapeValues[k] = +shape[this._shapeKeys[k]]; + } + } + } + + /** Instances currently planted. Feed this to `Ability#instanceCount`. */ + get count() { + return this._count; + } + + /** Draw calls this field costs while it is standing. */ + get drawCalls() { + return this.variants; + } + + /* ------------------------------------------------------------------ */ + /* Geometry */ + /* ------------------------------------------------------------------ */ + + _buildGeometry(variant, shape, seeds, births) { + const geometry = this.factory(variant, shape); + // The per-instance attributes are state, not shape — they survive a rebuild. + geometry.setAttribute('aSeed', seeds ?? this.seedAttributes[variant]); + geometry.setAttribute('aBirth', births ?? this.birthAttributes[variant]); + return geometry; + } + + /** + * Rebuild the meshes when a *shape* control moves. + * + * Facet counts, taper, curl and bend cannot be expressed as a per-instance + * transform, so they are baked into the geometry — and a six-sided crystal is + * 108 triangles, cheap enough to rebuild outright rather than approximate in a + * vertex shader. That is what keeps them live sliders, paused included. + * + * The change test compares numbers in a Float64Array rather than building a + * key string. `IceAbility#_syncGeometry` composes a template literal every + * frame; at one ability that is invisible, at fifty it is fifty strings a + * frame for the garbage collector to sweep up for nothing. + * + * @param {object|null} shape handed straight to the factory + * @returns {boolean} true if the geometry was rebuilt this call + */ + syncGeometry(shape) { + if (!shape) return false; + + if (!this._shapeKeys) { + // One allocation, on the first call, outside any cast. + this._shapeKeys = Object.keys(shape); + this._shapeValues = new Float64Array(this._shapeKeys.length); + for (let k = 0; k < this._shapeKeys.length; k++) this._shapeValues[k] = NaN; + } + + let changed = false; + for (let k = 0; k < this._shapeKeys.length; k++) { + const value = +shape[this._shapeKeys[k]]; + if (value !== this._shapeValues[k]) { + this._shapeValues[k] = value; + changed = true; + } + } + if (!changed) return false; + + for (let v = 0; v < this.variants; v++) { + const mesh = this.meshes[v]; + const previous = mesh.geometry; + mesh.geometry = this._buildGeometry(v, shape); + previous.dispose(); + } + return true; + } + + /* ------------------------------------------------------------------ */ + /* Planting — the only place the dice are rolled */ + /* ------------------------------------------------------------------ */ + + /** + * Roll a fresh field. Call from `onSpawn()`. + * + * `clusterShare` holds the last slice of the count back from the travelling + * front: in LINE layout they land in a ring about the impact point, in ZONE + * layout they land inside `innerRadius` as the core. Pass 0 for neither. + * + * The count is captured because it is a *count*, not a dimension — changing it + * mid-cast would reshuffle a standing field rather than reshape it, which is + * the one thing the paused-slider test is not asking for. + * + * @param {number} count instances this cast, clamped to `capacity` + * @param {number} [clusterShare] 0..1 of them reserved for the cluster + * @returns {number} the count actually planted + */ + plant(count, clusterShare = 0) { + const wanted = Math.min(this.capacity, Math.max(0, Math.round(count))); + const clustered = Math.round(wanted * saturate(clusterShare)); + + this._count = wanted; + this._clusterStart = wanted - clustered; + + const spine = Math.max(1, this._clusterStart); + + for (let i = 0; i < wanted; i++) { + const record = this.records[i]; + const cluster = i >= this._clusterStart; + + record.cluster = cluster; + record.eruptTime = -1; + record.breached = false; + record.yaw = Math.random(); + record.stagger = Math.random(); + record.heightRoll = Math.random() * 2 - 1; + record.radiusRoll = Math.random() * 2 - 1; + record.leanRoll = Math.random() * 2 - 1; + record.tiltRoll = Math.random() * 2 - 1; + record.tiltBearing = Math.random(); + record.baseRoll = Math.random() * 2 - 1; + record.scatter = Math.random() * 2 - 1; + record.rubbleRoll = Math.random(); + record.lateral = Math.random() * 2 - 1; + record.angle = Math.random(); + // sqrt keeps a disc evenly dense instead of piling everything in the middle. + record.radial = Math.sqrt(Math.random()); + // Stratified rather than uniform: one instance per slot down the line plus + // a jitter inside it, so a sparse field still covers the whole cast. + // `frontBias` is *not* applied here — it is a live exponent in `_alongOf`, + // which is the one place this field improves on the crystal field it came + // from, where dragging the bias could not move a standing spike. + record.along = cluster ? 1 : (i + Math.random()) / spine; + } + + for (let i = wanted; i < this.capacity; i++) this.records[i].eruptTime = -1; + for (let v = 0; v < this.variants; v++) this.meshes[v].count = 0; + return wanted; + } + + /** Un-plant everything. `onDestroy()`. */ + clear() { + this._count = 0; + this._clusterStart = this.capacity; + for (let v = 0; v < this.variants; v++) this.meshes[v].count = 0; + } + + /* ------------------------------------------------------------------ */ + /* Triggering */ + /* ------------------------------------------------------------------ */ + + /** + * Erupt everything the travelling front has now reached (LINE). + * + * @param {number} now the ability's `age`, seconds + * @param {number} limit how far down the line the front has got, 0..1 + * @param {number} stagger seconds of random delay between neighbours + * @param {number} [frontBias] the same live exponent `update` will use + * @param {boolean} [includeCluster] release the terminal group too + */ + triggerUpTo(now, limit, stagger, frontBias = 1, includeCluster = false) { + for (let i = 0; i < this._count; i++) { + const record = this.records[i]; + if (record.eruptTime >= 0) continue; + if (record.cluster) { + if (!includeCluster) continue; + } else if (Math.pow(record.along, frontBias) > limit) { + continue; + } + record.eruptTime = now + record.stagger * stagger; + } + } + + /** + * Erupt everything the front has reached measured *radially* (ZONE). + * `invert` fills from the boundary inward, which is how Hailwrath reads. + */ + triggerRadial(now, limit, stagger, invert = false, includeCluster = true) { + for (let i = 0; i < this._count; i++) { + const record = this.records[i]; + if (record.eruptTime >= 0) continue; + if (record.cluster && !includeCluster) continue; + const r = invert ? 1 - record.radial : record.radial; + if (!record.cluster && r > limit) continue; + record.eruptTime = now + record.stagger * stagger; + } + } + + /** Erupt the lot. */ + triggerAll(now, stagger) { + for (let i = 0; i < this._count; i++) { + const record = this.records[i]; + if (record.eruptTime >= 0) continue; + record.eruptTime = now + record.stagger * stagger; + } + } + + /** Erupt one instance by index, `delay` seconds from now. */ + triggerIndex(now, index, delay = 0) { + if (index < 0 || index >= this._count) return; + const record = this.records[index]; + if (record.eruptTime >= 0) return; + record.eruptTime = now + delay; + } + + /** Whether every planted instance has been released. */ + get isFullyTriggered() { + for (let i = 0; i < this._count; i++) if (this.records[i].eruptTime < 0) return false; + return true; + } + + /* ------------------------------------------------------------------ */ + /* Resolving — every metre, radian and second comes from here */ + /* ------------------------------------------------------------------ */ + + /** The curve parameter an instance is sampled at: 0 at the caster, 1 at the far end. */ + _curveT(record, p) { + if ((p.layout ?? GrowthLayout.LINE) === GrowthLayout.ZONE) return record.radial; + if (record.cluster) return 1; + return Math.pow(saturate(record.along), p.frontBias ?? 1); + } + + /** Half-width of the LINE band at `t`, metres. */ + _halfWidth(t, p) { + return lerp(p.widthNear ?? 0.5, p.width ?? 2.5, Math.pow(saturate(t), p.widthCurve ?? 1)); + } + + /** Signed lateral offset, as a fraction of the local half-width. */ + _lateralNorm(record, p) { + const raw = record.lateral; + const clumping = p.clumping ?? 1; + const clumped = Math.sign(raw) * Math.pow(Math.abs(raw), clumping); + return clumped + record.scatter * (p.scatter ?? 0); + } + + /** How far off the spine an instance sits, 0 on the axis, 1 at the edge. */ + _edgeNorm(record, p) { + if ((p.layout ?? GrowthLayout.LINE) === GrowthLayout.ZONE) return saturate(Math.abs(record.lateral)); + if (record.cluster) return record.radial; + return saturate(Math.abs(this._lateralNorm(record, p))); + } + + /** The centre of a ZONE / of the terminal cluster, live. */ + _anchor(p, out) { + if (p.centre) return out.copy(p.centre); + const origin = p.origin ?? _zero; + const direction = p.direction ?? _forward; + return out.copy(origin).addScaledVector(direction, p.length ?? 0); + } + + /** Where an instance stands right now, at the live footprint. Writes `out`. */ + _position(record, p, out) { + const layout = p.layout ?? GrowthLayout.LINE; + const side = p.side ?? _right; + + if (layout === GrowthLayout.ZONE) { + const outer = p.radius ?? 4; + const inner = Math.min(outer, p.innerRadius ?? 0); + const angle = (record.angle + record.scatter * ((p.angleJitter ?? 0) / TAU)) * TAU; + let r; + if (record.cluster) { + // The core group lives inside the ring, never on it. + r = inner * record.radial; + } else { + r = lerp(inner, outer, Math.pow(saturate(record.radial), p.radialCurve ?? 1)); + r += record.lateral * (p.radialJitter ?? 0); + } + this._anchor(p, out); + out.x += Math.cos(angle) * r; + out.z += Math.sin(angle) * r; + return out; + } + + if (record.cluster) { + const authored = p.clusterRadius ?? -1; + const reach = (authored >= 0 ? authored : this._halfWidth(1, p) * 1.25) * record.radial; + const angle = record.angle * TAU; + this._anchor(p, out); + out.x += Math.cos(angle) * reach; + out.z += Math.sin(angle) * reach; + return out; + } + + const t = this._curveT(record, p); + const origin = p.origin ?? _zero; + const direction = p.direction ?? _forward; + out.copy(origin).addScaledVector(direction, t * (p.length ?? 0)); + return out.addScaledVector(side, this._lateralNorm(record, p) * this._halfWidth(t, p)); + } + + /** Full height of an instance, metres. */ + _height(record, p) { + const t = this._curveT(record, p); + const jitter = p.randomness ?? 1; + + let h = lerp(p.heightNear ?? 0.5, p.height ?? 3, Math.pow(saturate(t), p.heightCurve ?? 1)); + + // The swell at the far end. + const peak = p.peak ?? 1; + if (peak !== 1) h *= 1 + (peak - 1) * smoothstep(1 - (p.peakWidth ?? 0.25), 1, t); + + // Domed silhouette: the instances on the flanks are shorter than the spine. + const crown = saturate(p.crown ?? 0); + if (crown > 0) h *= lerp(1, 1 - crown, Math.pow(this._edgeNorm(record, p), p.crownPower ?? 1.4)); + + h *= 1 + record.heightRoll * (p.heightJitter ?? 0) * jitter; + if (record.rubbleRoll < (p.rubble ?? 0)) h *= p.rubbleScale ?? 0.3; + + return Math.max(p.minHeight ?? 0.02, h); + } + + /** Base radius of an instance, metres. */ + _radius(record, p) { + const t = this._curveT(record, p); + const near = p.radiusNear ?? 0.3; + const far = (p.radius2 ?? -1) >= 0 ? p.radius2 : near; + const jitter = 1 + record.radiusRoll * (p.radiusJitter ?? 0) * (p.randomness ?? 1); + const grow = lerp(near, far, Math.pow(saturate(t), p.radiusCurve ?? 0.6)); + const spread = record.rubbleRoll < (p.rubble ?? 0) ? (p.rubbleSpread ?? 1.25) : 1; + return Math.max(p.minRadius ?? 0.01, grow * jitter * spread); + } + + /** + * How far out of the ground an instance is: 0 → 1 with a springy overshoot + * past 1. Negative while it is still buried and waiting. + */ + _emergence(record, now, p) { + if (record.eruptTime < 0) return -1; + const elapsed = now - record.eruptTime; + if (elapsed < 0) return -1; + + const riseTime = Math.max(0.02, p.riseTime ?? 0.18); + const rise = Easing.outQuint(saturate(elapsed / riseTime)); + if (elapsed <= riseTime) return rise; + + // The punch-through carries past full height and settles back. + const after = elapsed - riseTime; + const spring = + Math.sin(after * (p.springRate ?? 14)) * Math.exp(-after / Math.max(0.05, p.settle ?? 0.5)); + return 1 + (p.riseOvershoot ?? 0.25) * spring; + } + + /* --- public read-back, for abilities that thread things between instances - */ + + /** World position of instance `i` at the live settings. Thornwake links off this. */ + positionOf(index, p, out) { + return this._position(this.records[index], p, out); + } + + /** Metres. */ + heightOf(index, p) { + return this._height(this.records[index], p); + } + + /** Metres. */ + radiusOf(index, p) { + return this._radius(this.records[index], p); + } + + /** 0..1(+) emergence, or negative while buried. */ + emergenceOf(index, now, p) { + return this._emergence(this.records[index], now, p); + } + + /** World position of the tip of instance `i`, ignoring lean. */ + tipOf(index, p, out) { + this._position(this.records[index], p, out); + out.y += this._height(this.records[index], p); + return out; + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Rebuild every instance matrix from the live params. Allocation-free. + * + * @param {number} now the ability's `age`, seconds + * @param {object} p live params — see `growthParams()` + * @param {number} [retract] 0..1, the whole field withdrawing into the floor + */ + update(now, p, retract = 0) { + const variants = this.variants; + const used = this._used; + used.fill(0); + + const emergeMode = p.emerge ?? GrowthEmerge.PUSH; + const birthFade = Math.max(0.02, p.birthFade ?? 0.18); + const jitter = p.randomness ?? 1; + const layout = p.layout ?? GrowthLayout.LINE; + const direction = p.direction ?? _forward; + const side = p.side ?? _right; + const sink = retract > 0 ? Easing.inCubic(saturate(retract)) : 0; + + for (let i = 0; i < this._count; i++) { + const record = this.records[i]; + const variant = i % variants; + const slot = (i / variants) | 0; + const emerge = this._emergence(record, now, p); + + if (emerge < 0) { + // Still buried. Park it out of the view rather than drawing a degenerate + // matrix at the origin, which shows up as a speck on the floor. + _dummy.position.set(0, -999, 0); + _dummy.quaternion.identity(); + _dummy.scale.setScalar(0.0001); + _dummy.updateMatrix(); + this.meshes[variant].setMatrixAt(slot, _dummy.matrix); + this.birthAttributes[variant].array[slot] = 0; + used[variant] = Math.max(used[variant], slot + 1); + continue; + } + + const height = this._height(record, p); + const radius = this._radius(record, p); + + /* --- the moment it breaks the surface --- */ + if (!record.breached && emerge > (p.breachAt ?? 0.25)) { + record.breached = true; + if (this.onBreach) { + this._position(record, p, _breachPos); + this.onBreach(i, _breachPos, radius, height); + } + } + + /* --- lean: away from the caster, and outward across the band --- */ + const outward = + layout === GrowthLayout.ZONE || record.cluster + ? record.radial + : this._lateralNorm(record, p); + + if (layout === GrowthLayout.ZONE) { + // Outward means radially outward from the centre, not sideways. + const angle = record.angle * TAU; + _lean + .set(Math.cos(angle), 0, Math.sin(angle)) + .multiplyScalar((p.leanOutward ?? 0.85) * record.radial) + .addScaledVector(direction, p.leanForward ?? 0); + } else { + _lean + .copy(direction) + .multiplyScalar(p.leanForward ?? 0.75) + .addScaledVector(side, outward * (p.leanOutward ?? 0.85)); + } + if (_lean.lengthSq() < 1e-6) _lean.copy(direction); + _lean.normalize(); + + const t = this._curveT(record, p); + const leanRamp = p.leanRamp ?? 0.65; + const leanAngle = + (p.lean ?? 0) * + lerp(1 - leanRamp, 1, t) * + (1 + record.leanRoll * (p.leanJitter ?? 0) * jitter); + + // Rotating about (up × lean) tips the instance's own +Y toward `lean`. + _axis.crossVectors(_up, _lean); + if (_axis.lengthSq() < 1e-8) _axis.set(1, 0, 0); + _axis.normalize(); + _tilt.setFromAxisAngle(_axis, leanAngle); + + const tilt = p.tilt ?? 0; + if (tilt !== 0) { + // A second, bearing-random tip so a field never looks combed. + const bearing = record.tiltBearing * TAU; + _axis.set(Math.cos(bearing), 0, Math.sin(bearing)); + _spin.setFromAxisAngle(_axis, tilt * record.tiltRoll * jitter); + _tilt.multiply(_spin); + } + + _spin.setFromAxisAngle(_up, record.yaw * TAU * (p.twist ?? 1)); + _tilt.multiply(_spin); + + /* --- slide it up out of the floor (or scale it up in place) --- */ + const settled = Math.min(1, emerge); + this._position(record, p, _dummy.position); + _dummy.position.y += (p.baseHeight ?? 0) * (1 + record.baseRoll * (p.baseJitter ?? 0) * jitter); + + let scale = lerp(p.birthScale ?? 0.86, 1, settled); + if (emergeMode === GrowthEmerge.PUSH) { + _dummy.position.y += (emerge - 1) * height * (p.emergeSink ?? 0.85); + } else { + // Accretion: nothing is buried, the body simply arrives. + scale *= Math.max(0.0001, emerge); + } + + if (sink > 0) _dummy.position.y -= sink * (height + radius + (p.sinkDepth ?? 0.4)); + + _dummy.quaternion.copy(_tilt); + _dummy.scale.set(radius, height, radius).multiplyScalar(scale); + _dummy.updateMatrix(); + + this.meshes[variant].setMatrixAt(slot, _dummy.matrix); + this.birthAttributes[variant].array[slot] = saturate(1 - (now - record.eruptTime) / birthFade); + used[variant] = Math.max(used[variant], slot + 1); + } + + for (let v = 0; v < variants; v++) { + this.meshes[v].count = used[v]; + this.meshes[v].instanceMatrix.needsUpdate = true; + this.birthAttributes[v].needsUpdate = true; + } + } + + /** Geometry and meshes. The material belongs to the ability — not touched. */ + dispose() { + for (const mesh of this.meshes) { + mesh.geometry.dispose(); + mesh.dispose(); + mesh.parent?.remove(mesh); + } + this.meshes.length = 0; + this.onBreach = null; + } +} + +/* ---------------------------------------------------------------------- */ +/* Material helper */ +/* ---------------------------------------------------------------------- */ + +/** + * Spellbreak's disruption, applied to a shaded PBR fragment. + * + * Three shared modules already opted into `vfx/SceneHooks.js`'s published + * region — `FilamentPaths`, `GroundField` and `Swarm` — and between them they + * cover every bolt, every ground mark and every flock. They do not cover a + * single thing that is *standing in the zone as an object*, which is most of + * what a player has on the floor when they reach for a dispel: a lance, a + * thorn wall, a spine of heaved stone. So this is the fourth opt-in, and it is + * the one that made the ability read. + * + * It is not the three-line fragment call the other three use, for one reason: + * they all draw with a blend, so erasing a shard cell is `alpha *= 0` and the + * cell is gone. A growth material is **opaque** — the alpha channel of an + * opaque fragment is written and then ignored — so zeroing it changes nothing + * at all. The first version did exactly that and a crystal field inside a + * Spellbreak went grey and stayed whole, which reads as a lighting change and + * not as an effect being torn up. The `discard` is what actually breaks the + * silhouette, and it is gated on the shade call having zeroed the alpha rather + * than on a threshold, so a genuinely transparent growth material (petals) is + * eroded on the same cells and not simply erased. + * + * The emissive is drained on the same field and with the same cells. Draining + * albedo alone leaves a crystal that is grey and *still glowing through the + * holes it is being erased in*, which looks like a z-fighting bug. + */ +const GROWTH_DISRUPT = /* glsl */ ` + float growDisrupt = disruptAt(vGrowWorld); + if (growDisrupt > 0.0) { + // Whole locals rather than swizzles for the inout arguments. A + // non-repeating swizzle is a legal l-value and three of the drivers this + // has been read on accept it; the fourth is not worth finding out about + // for the sake of two lines. + vec3 growAlbedo = diffuseColor.rgb; + float growKeep = diffuseColor.a; + disruptShade(growAlbedo, growKeep, growDisrupt, gl_FragCoord.xy); + float growEmissiveKeep = 1.0; + disruptShade(totalEmissiveRadiance, growEmissiveKeep, growDisrupt, gl_FragCoord.xy); + if (growKeep < diffuseColor.a * 0.5) discard; + diffuseColor.rgb = growAlbedo; + diffuseColor.a = growKeep; + } +`; + +/** + * Wire a `MeshStandardMaterial` up to the two per-instance attributes a + * GrowthField writes, and inject the ability's own shading on top. + * + * This is `materials/IceMaterial.js`'s patch reduced to its skeleton. Building + * on a standard material rather than a raw `ShaderMaterial` is deliberate and it + * is the reason the crystals sit in the stage at all: they take the real + * shadows, the real lights and the HDR probe, and the stylisation is injected + * after ``, where the normal has been resolved (with + * `flatShading` there is no `vNormal` varying, so any view-dependent term *must* + * go after that include and read `normal`). + * + * Available to the injected fragment code: + * + * | varying | meaning | + * | --- | --- | + * | `vGrowLocal` | unit-space position — `.y` is 0 at the base, 1 at the tip | + * | `vGrowWorld` | world position, for detail that must keep a fixed physical size | + * | `vGrowSeed` | per-instance seed, 0..10 | + * | `vGrowBirth` | per-instance birth flash, 1 → 0 over `birthFade` | + * + * The two spaces are not interchangeable and picking the wrong one is the usual + * bug: cracks and grain belong in **world** space so neighbouring instances look + * quarried from the same block, while anything that runs base-to-tip belongs in + * **local** space so it follows each instance's own axis however it is scaled. + * + * @param {THREE.MeshStandardMaterial} material + * @param {object} [options] + * @param {object} [options.environment] `world/Environment.js` — routes through + * `registerShadowCasterWithPatch` so CSM's patch is not clobbered + * @param {object} [options.uniforms] merged into the compiled shader + * @param {string} [options.common] fragment-stage declarations (noise libs, uniforms) + * @param {string} [options.vertex] GLSL appended after `` + * @param {string} [options.fragment] GLSL appended after `` + * @returns {THREE.MeshStandardMaterial} the same material + */ +export function patchGrowthMaterial(material, options = {}) { + const { environment = null, uniforms = null, common = '', vertex = '', fragment = '' } = options; + + const patch = (shader) => { + if (uniforms) Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = replaceChunk( + shader.vertexShader, + '#include ', + `#include + attribute float aSeed; + attribute float aBirth; + varying vec3 vGrowLocal; + varying vec3 vGrowWorld; + varying float vGrowSeed; + varying float vGrowBirth;` + ); + + shader.vertexShader = replaceChunk( + shader.vertexShader, + '#include ', + `#include + vGrowLocal = transformed; + vGrowSeed = aSeed; + vGrowBirth = aBirth; + #ifdef USE_INSTANCING + vGrowWorld = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz; + #else + vGrowWorld = (modelMatrix * vec4(transformed, 1.0)).xyz; + #endif + ${vertex}` + ); + + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + varying vec3 vGrowLocal; + varying vec3 vGrowWorld; + varying float vGrowSeed; + varying float vGrowBirth; + ${disruptGLSL} + ${common}` + ); + + // Always injected, even with no `fragment` snippet, because the disruption + // block below has to land somewhere. Sampling it per *fragment* rather than + // per vertex — the opposite of what `FilamentPaths` and `Tube` do — is the + // one place the cheap route is wrong: a growth instance is a handful of + // large facets, so a per-vertex sample of a field measured in metres is + // interpolated across half a spike and the erosion front crosses the field + // boundary as a smooth gradient instead of as an edge. `vGrowWorld` is + // already there for the ability's own grain, so this costs one distance. + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + { + ${fragment} + } + { + ${GROWTH_DISRUPT} + }` + ); + }; + + if (environment) environment.registerShadowCasterWithPatch(material, patch); + else patchOnBeforeCompile(material, patch); + + // I8 — where the harness's pause probe and anyone debugging why a standing + // field will not break up look for the live boxes. Merged rather than + // assigned, so a material that parked its own block first keeps it. A + // material that parks *afterwards* with a plain assignment (which is what all + // eleven of them do) drops these again, and that is fine: nothing reads them + // from here, they are here to be seen. + material.userData = material.userData ?? {}; + material.userData.uniforms = Object.assign(material.userData.uniforms ?? {}, disruptUniforms()); + + return material; +} diff --git a/src/vfx/HardSurface.js b/src/vfx/HardSurface.js new file mode 100644 index 0000000..df3d982 --- /dev/null +++ b/src/vfx/HardSurface.js @@ -0,0 +1,2945 @@ +import { + BufferAttribute, + BufferGeometry, + Color, + MeshStandardMaterial, + ShapeUtils, + Vector2, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { patchOnBeforeCompile, replaceChunk } from '../utils/shaderPatch.js'; +import { frame } from '../core/FrameUniforms.js'; +import { getColor } from '../utils/color.js'; +import { clamp, saturate, smoothstep, hash11 } from '../utils/math.js'; + +/* ---------------------------------------------------------------------- */ +/* HardSurface — the machined half of the material vocabulary */ +/* ---------------------------------------------------------------------- */ + +/** + * Gears, pistons, sawblades, plates, bolts and anvils, in brushed steel that + * can be brought up to forging heat and back down again. + * + * Everything in this sandbox before Forge was **grown, quarried or bled**: + * crystal, rock, bone, vine, ash, blood. All of it is soft-edged, all of it is + * noise-driven, and all of it looks *made by nature*. A machined object reads + * differently for three reasons, none of which is the silhouette: + * + * 1. **Its edges are deliberate.** A chamfer is a decision. Nature does not + * put a 0.4 mm bevel round the top of a hole, and a shader cannot fake one + * — the geometry has to have it, which is why this module generates + * geometry at all rather than shipping a material and letting abilities + * bring their own boxes. + * 2. **Its highlight is directional.** Brushed and turned metal has a grain, + * and the specular smears *across* that grain into a line. An isotropic + * GGX lobe on a cylinder gives you a plastic bottle; an anisotropic lobe + * whose tangent runs round the axis gives you a lathe-turned collar, from + * the same silhouette and the same albedo. + * 3. **Its colour is a temperature, not a palette.** Steel above about 900 K + * stops taking its colour from the light in the room and starts making its + * own, along one fixed curve that every foundry photograph in the world + * agrees on. Authoring that curve as a four-stop gradient is the tell; the + * stops always land slightly wrong and the metal looks lit rather than hot. + * + * So this module is three things bolted together, and it is deliberately *not* + * a renderer: it owns no meshes, adds nothing to a parent and has no `update`. + * + * | part | what it gives you | + * | --- | --- | + * | six geometry generators | unit-space `BufferGeometry`, for `GrowthField` / `Projectile` / a plain `Mesh` | + * | `createHardSurfaceMaterial()` | a patched `MeshStandardMaterial`: anisotropic brushing, blackbody heat, scale, pitting, edge wear | + * | `GearTrain` + `GrindContact` | the two solvers whose output the abilities cannot compute for themselves | + * + * ## Attach convention + * + * **Neither.** `HardSurface` is a toolkit, like `prefixedBlock.js`, not a + * system. You get geometry and a material and you put them wherever you like — + * usually into `GrowthField` (which wants a factory and a material and owns + * the instancing) or `Projectile` (same). Its draw-call cost is zero of its + * own; whatever you hang it on pays. + * + * ## Settings convention + * + * **Canonical.** `hardSurfaceParams()` is the key list for the material; + * `gearTrainParams()` and `grindParams()` for the two solvers. Every one of + * them is re-read on every call, so a paused gear train re-meshes under the + * `module` slider and a paused ingot cools under the `heat` slider. The + * *shapes* are the exception and they are the interesting exception — see + * "Shapes are unitless" below. + * + * ## Shapes are unitless, and that is what keeps I1 honest + * + * A shape object (`gearShape()`, `anvilShape()`, …) holds **no metres**. Every + * generator emits geometry normalised into the unit box: the largest extent — + * horizontal diameter or height, whichever binds — is exactly 1, the body sits + * on `y = 0`, and the axis is centred. The footprint therefore always fits + * inside the circle of radius 0.5 that `GrowthField`'s factory contract asks + * for, and the instance transform is the only place a metre ever appears. + * + * The consequence worth internalising: a shape field is a **proportion**, not + * a size. `gearShape().thickness = 0.24` means "0.24 of the gear's own + * diameter", so a gear scaled to 1.4 m across is 0.34 m thick and the slider + * still bites while paused. The one place this bites back is a field that + * controls the *dominant* dimension — push a piston's `length` past its + * diameter and `length` stops changing the silhouette and starts changing what + * the other proportions mean, because the normalisation immediately divides it + * back out. That is stated here rather than defended: the alternative, + * normalising each axis separately, makes every proportion slider inert and + * turns a gear into a drum the moment you touch its thickness. + * + * The other consequence is the good one. A gear's *module* — millimetres of + * pitch diameter per tooth, the number that decides whether two gears mesh — + * is a metre, so it is **not in `gearShape()` at all**. It lives in + * `GearTrain`'s params and is resolved every frame. Drag it and the whole + * train re-spaces and re-phases without a single vertex being rebuilt. + * + * ## What it is for + * + * The Forge school, all six of it: + * + * | ability | what it takes from here | + * | --- | --- | + * | `anvilfall` | `createAnvilGeometry` — the fillets are why it reads as forged rather than as a box | + * | `sawline` | `createSawbladeGeometry` + `GrindContact` — sparks leave at the contact tangent | + * | `pistondrive` | `createPistonGeometry` — the cam curve is the ability's; the collar and head are here | + * | `gearlock` | `createGearGeometry` + `GearTrain` — involute flanks that genuinely interlock | + * | `quench` | the blackbody ramp — white → yellow → orange → cherry → black on the Planckian locus | + * | `shrapnel` | `createPlateGeometry` / `createBoltGeometry` as `ShatterField` fragment stock | + * + * @example + * // gearlock: three tooth counts, three InstancedMeshes, one material. + * this.metal = createHardSurfaceMaterial({ environment: ctx.environment }); + * this.cache = new ShapeCache(); + * this.train = new GearTrain({ capacity: 12 }); + * // spawn + * this.train.plant(c.gearCount, this._seed); + * // every frame + * this._fillTrain(); // metres, from settings.gearlock + * this.train.solve(this._trainParams); + * for (let i = 0; i < this.train.count; i++) { + * const d = this.train.tipRadiusOf(i) * 2; + * this.train.positionOf(i, this._trainParams, _pos); + * _dummy.position.copy(_pos); + * _dummy.rotation.set(0, this.train.yawOf(i), 0); + * _dummy.scale.setScalar(d); + * ... + * } + * syncHardSurfaceMaterial(this.metal, this._look); + */ + +const TAU = Math.PI * 2; +const DEG = Math.PI / 180; + +/* Module-scope scratch — invariant I3. Nothing in a frame path allocates. + The geometry generators are exempt and say so: they run on a slider change, + not on a frame, and building a gear out of pooled Vector2s would be an + unreadable false economy. */ +const _v3a = new Vector3(); +const _v3b = new Vector3(); +const _v3c = new Vector3(); +const _v3d = new Vector3(); +const _colorScratch = new Color(); + +/* ====================================================================== */ +/* §1 · Shapes */ +/* ====================================================================== */ + +/** + * Which way the part's own axis points once it has been seated. + * + * The generators all build in a canonical frame — profile in XY, axis along + * +Z — and this rotates the finished mesh. It is a mode, not a dimension, so + * it is safe to capture and safe to bake into a geometry hash. + */ +export const HardAxis = Object.freeze({ + /** Axis up. A gear lies flat, a piston stands on end, a bolt points at the sky. */ + Y: 0, + /** Axis lateral. A sawblade stands on its rim in the plane of the cast line. */ + X: 1, + /** Axis along the cast. A piston drives forward, a gear faces the caster. */ + Z: 2 +}); + +/** The six parts. Used by `hardSurfaceGeometry()` and `ShapeCache`. */ +export const HardShape = Object.freeze({ + GEAR: 0, + PISTON: 1, + SAWBLADE: 2, + PLATE: 3, + BOLT: 4, + ANVIL: 5 +}); + +export const HARD_SHAPE_NAMES = Object.freeze(['GEAR', 'PISTON', 'SAWBLADE', 'PLATE', 'BOLT', 'ANVIL']); + +/** + * A spur gear with a true involute tooth profile. + * + * Note what is *not* here: `module`. See the file header — the module is + * millimetres of pitch diameter per tooth, which is a metre, which means it + * belongs to `GearTrain` and is resolved every frame. In unit space the only + * things that decide what a gear looks like are the tooth count and the + * coefficients, because normalisation divides the module straight back out. + * + * Radial fields are fractions of the **outer radius** — the radius the + * profile actually reaches, which is 0.5 after seating and which is not the + * nominal `pitch + addendum` once a tooth has gone pointed. Axial fields are + * in unit-space lengths, i.e. fractions of the outer *diameter*. + */ +export function gearShape(overrides) { + return { + teeth: 14, // tooth count — an integer; 4 is the floor, below that the flanks cross + pressureAngle: 20, // degrees; 20 is the modern standard, 14.5 the old one + addendum: 1.0, // × module, tooth height above the pitch circle + dedendum: 1.25, // × module, tooth depth below it — the extra 0.25 is the root clearance + backlash: 0.04, // 0..1 of the tooth thickness, taken off both flanks + rootFillet: 0.45, // 0..1 of the available root gap, blended into the flank + flankSteps: 6, // involute samples per flank; 3 is visibly polygonal, 10 is free + tipSteps: 2, // arc samples across the tooth tip + rootSteps: 2, // arc samples along the root circle between teeth + thickness: 0.22, // unit lengths, face to face + chamfer: 0.018, // unit lengths, 45° break on both faces — this is the highlight catcher + bore: 0.3, // fraction of the outer radius; 0 for a solid blank + boreSegments: 28, // facets round the bore + boreChamfer: 0.014, // unit lengths, countersink on the bore + lightenHoles: 0, // count of lightening holes; 0, or 4..8 for a spoked look + lightenRadius: 0.14, // fraction of the outer radius + lightenRing: 0.58, // fraction of the outer radius, the circle they sit on + lightenSegments: 16, + creaseAngle: 30, // degrees; above this a joint shades hard and counts as an edge + axis: HardAxis.Y, + ...overrides + }; +} + +/** A driven piston: base flange, rod, collar, ringed head. */ +export function pistonShape(overrides) { + return { + length: 2.4, // × head diameter, base to crown — the dominant dimension + segments: 24, // facets round the axis + baseRadius: 0.46, // fractions of the head diameter throughout + baseHeight: 0.16, + baseChamfer: 0.05, + rodRadius: 0.17, + collarAt: 0.46, // 0..1 up the length + collarRadius: 0.3, + collarHeight: 0.13, + collarChamfer: 0.045, + headAt: 0.7, // 0..1 up the length, where the head begins + headRadius: 0.5, + headChamfer: 0.07, + rings: 2, // machined grooves round the head — the detail that says "engine" + ringDepth: 0.035, + ringHeight: 0.05, + faceRecess: 0.05, // dished crown; 0 for a flat punch + creaseAngle: 26, + axis: HardAxis.Y, + ...overrides + }; +} + +/** + * A circular saw blade: raked teeth, gullets, expansion slots, arbor bore. + * + * `rake` and `clearance` are angles off the radial, and the faces they cut are + * logarithmic spirals rather than straight lines, because a constant rake + * angle *is* a logarithmic spiral — see `sawOutline`. + */ +export function sawbladeShape(overrides) { + return { + teeth: 22, // tooth count + rake: 14, // degrees of hook on the cutting face; negative for a scraping tooth + clearance: 26, // degrees of relief behind the tip + gullet: 0.16, // fraction of the tip radius, depth to the gullet floor + gulletSteps: 4, // arc samples round the gullet floor + flankSteps: 3, // samples along each spiral face; 1 makes them straight chords + tipLand: 0.35, // 0..1 of the tooth pitch spent flat on the tip + thickness: 0.05, // unit lengths — a blade is a plate, keep it thin + chamfer: 0.012, // unit lengths, the bevel that catches the light on the rim + arbor: 0.16, // fraction of the tip radius + arborSegments: 20, + slots: 4, // expansion slots; 0 for a plain plate + slotDepth: 0.3, // fraction of the tip radius, inward from the gullet floor + slotWidth: 0.035, // fraction of the tip radius + slotSteps: 3, + creaseAngle: 30, + axis: HardAxis.X, + ...overrides + }; +} + +/** A bevelled plate with countersunk bolt holes. */ +export function plateShape(overrides) { + return { + width: 1.0, // the two in-plane extents, relative to each other + depth: 0.7, + thickness: 0.12, // unit lengths + corner: 0.1, // fraction of the short side, corner radius + cornerSteps: 4, + bevel: 0.03, // unit lengths, 45° break round the whole outline + bolts: 4, // 0, 2, 4 or 6 — laid out on the corners + boltRadius: 0.055, // fraction of the short side + boltInset: 0.16, // fraction of the short side, in from each corner + boltSegments: 14, + counterSink: 0.03, // fraction of the short side, radial flare at the face + counterDepth: 0.035, // unit lengths, how deep the flare cuts + creaseAngle: 34, + axis: HardAxis.Y, + ...overrides + }; +} + +/** A hex-head bolt with a real helical thread. */ +export function boltShape(overrides) { + return { + length: 2.2, // × head width across flats — the dominant dimension + headHeight: 0.62, // fractions of the head width throughout + headChamfer: 0.09, + washer: 0.06, // flange under the head; 0 for none + washerRadius: 0.62, + shankRadius: 0.29, + shankSegments: 18, + threadTurns: 9, // full turns over the threaded length + threadDepth: 0.035, // radial, fraction of the head width + threadFrom: 0.28, // 0..1 up the shank where the thread starts + threadSteps: 6, // z samples per turn; below 4 the helix aliases into rings + tipTaper: 0.1, // 0..1 of the shank spent tapering to the point + creaseAngle: 28, + axis: HardAxis.Y, + ...overrides + }; +} + +/** + * A blacksmith's anvil: horn, face, waist, base. + * + * The fillets are the whole job. An anvil built from boxes reads as a prop + * even with a perfect material on it, because every real one was upset and + * drawn out under a hammer and there is no sharp interior corner anywhere on + * it. See `smoothProfile` for how they are made, and why they are not arcs. + */ +export function anvilShape(overrides) { + return { + height: 0.66, // × overall length — the anvil is longer than it is tall + faceWidth: 0.5, // fractions of the overall length throughout + faceDepth: 0.42, + faceHeight: 0.14, // the working slab, top down to the underside flare + waistWidth: 0.24, + waistDepth: 0.22, + waistHeight: 0.28, + baseWidth: 0.52, + baseDepth: 0.44, + baseHeight: 0.12, + corner: 0.11, // 0..1 of the half-width, the rounding on every cross-section + cornerSteps: 3, + fillet: 0.62, // 0..1 smoothing weight on the silhouette — the forged look + filletPasses: 4, // how many times the smoothing runs + sections: 26, // silhouette samples up the body + horn: 0.46, // × overall length, how far the horn reaches past the face + hornRadius: 0.3, // fraction of the face depth at its root + hornDroop: 0.06, // × overall length, how far the tip falls + hornSections: 10, + hornSteps: 12, // facets round the horn + creaseAngle: 34, + axis: HardAxis.Y, + ...overrides + }; +} + +const SHAPE_FACTORIES = [gearShape, pistonShape, sawbladeShape, plateShape, boltShape, anvilShape]; + +/** `hardShape(HardShape.GEAR, { teeth: 9 })` — the dispatcher form. */ +export function hardShape(kind, overrides) { + const factory = SHAPE_FACTORIES[kind]; + if (!factory) throw new Error(`HardSurface: unknown shape kind ${kind}`); + return factory(overrides); +} + +/* ====================================================================== */ +/* §2 · The surface builder */ +/* ====================================================================== */ + +/** + * A welded triangle soup with no normals. + * + * Every generator emits positions and topology only, sharing a vertex wherever + * two bands meet, and `finishSurface()` decides afterwards which of those + * shared vertices are creases and splits them. That ordering is the reason + * this file is a third of the length it was on the first attempt: the first + * version had every generator emit its own normals and duplicate its own + * hard edges, which meant a chamfer band was fourteen lines of bookkeeping in + * six different places and the sawblade's gullet was smooth in exactly one of + * them. + * + * The split pass also hands back something no generator could easily compute: + * `aEdge`, one per vertex, marking every joint that ended up hard. That + * attribute is what the material's edge-wear term keys off, and it exists only + * because we make the geometry. A material handed somebody else's mesh has no + * way to know where the machinist's file would have been. + */ +class Surface { + constructor() { + this.pos = []; + this.tri = []; + /** Per-vertex authored wear hint, used when a vertex turns out *not* to be a crease. */ + this.hint = []; + } + + vertex(x, y, z, hint = 0) { + this.pos.push(x, y, z); + this.hint.push(hint); + return this.hint.length - 1; + } + + face(a, b, c) { + this.tri.push(a, b, c); + } + + /** Wound so that `a → b → c → d` runs anticlockwise seen from outside. */ + quad(a, b, c, d) { + this.tri.push(a, b, c, a, c, d); + } + + get vertexCount() { + return this.hint.length; + } +} + +/** + * Split creases, compute normals, seat into the unit box, hand back geometry. + * + * The crease pass is the standard smoothing-group algorithm: every triangle + * contributes its area-weighted face normal to one of several *groups* at each + * of its corners, joining an existing group only if the group's running average + * is within `creaseAngle` of the face. A vertex that ends with one group is + * smooth; a vertex that ends with three is the corner of a chamfer where three + * planes meet, and it becomes three vertices. + * + * Two details that are not obvious: + * + * - **Degenerate triangles are dropped, not skipped.** A gear root fillet + * collapses to a sliver when `rootFillet` is wound down, and a zero-area + * triangle contributes a NaN normal that poisons the whole vertex. The + * harness scans for exactly that. + * - **A crease is not the same thing as a machined edge**, and the first + * version conflated them. Every ring of a chamfered extrusion is a crease — + * the cap meets the chamfer, the chamfer meets the wall — so 84% of a + * gear's vertices came back marked, `aEdge` was 1 almost everywhere, and + * the wear term just brightened the whole part. What the material actually + * wants is the *lip*: the outermost ring of a chamfer, falling off to + * nothing across the band. So the generators author that ring's `hint` + * directly and an unhinted crease is only worth `CREASE_WEAR`, which is + * enough to catch a bore rim and not enough to wash out a face. + * - **The crease's contribution is scaled by how outward-facing it is**, + * using the dot of the averaged normal against the direction from the mesh + * centroid. A heuristic, and these parts are all roughly star-shaped, so it + * holds. The honest version — signing the curvature across each crease edge + * — needs an edge-adjacency map for a term that ends up multiplied by a + * slider anyway. + */ + +/** What an unhinted crease is worth in `aEdge`. An authored lip is worth 1. */ +const CREASE_WEAR = 0.22; +function finishSurface(surface, options = {}) { + const { creaseAngle = 30, axis = HardAxis.Y } = options; + const creaseCos = Math.cos(clamp(creaseAngle, 1, 179) * DEG); + const pos = surface.pos; + const src = surface.tri; + const vertexCount = surface.vertexCount; + + /* --- centroid, for the outward-facing test --- */ + let cx = 0; + let cy = 0; + let cz = 0; + for (let i = 0; i < vertexCount; i++) { + cx += pos[i * 3]; + cy += pos[i * 3 + 1]; + cz += pos[i * 3 + 2]; + } + if (vertexCount > 0) { + cx /= vertexCount; + cy /= vertexCount; + cz /= vertexCount; + } + + /* --- accumulate face normals into per-vertex smoothing groups --- */ + const groups = new Array(vertexCount); + for (let i = 0; i < vertexCount; i++) groups[i] = []; + + const kept = []; + const cornerGroup = []; + + for (let t = 0; t < src.length; t += 3) { + const a = src[t]; + const b = src[t + 1]; + const c = src[t + 2]; + const ax = pos[a * 3]; + const ay = pos[a * 3 + 1]; + const az = pos[a * 3 + 2]; + const ux = pos[b * 3] - ax; + const uy = pos[b * 3 + 1] - ay; + const uz = pos[b * 3 + 2] - az; + const vx = pos[c * 3] - ax; + const vy = pos[c * 3 + 1] - ay; + const vz = pos[c * 3 + 2] - az; + let nx = uy * vz - uz * vy; + let ny = uz * vx - ux * vz; + let nz = ux * vy - uy * vx; + const area2 = Math.hypot(nx, ny, nz); + // A sliver contributes nothing but a division by zero. + if (!(area2 > 1e-12)) continue; + const inv = 1 / area2; + const fx = nx * inv; + const fy = ny * inv; + const fz = nz * inv; + + kept.push(a, b, c); + for (const v of [a, b, c]) { + const list = groups[v]; + let picked = -1; + for (let g = 0; g < list.length; g++) { + const group = list[g]; + const len = Math.hypot(group[0], group[1], group[2]); + if (len <= 1e-12) continue; + if ((group[0] * fx + group[1] * fy + group[2] * fz) / len >= creaseCos) { + picked = g; + break; + } + } + if (picked < 0) { + picked = list.length; + list.push([0, 0, 0]); + } + const group = list[picked]; + // Area weighting: a chamfer band is a sliver next to the face it breaks, + // and an unweighted average lets it drag the face's normal off true. + group[0] += nx; + group[1] += ny; + group[2] += nz; + cornerGroup.push(picked); + } + } + + /* --- emit one vertex per (vertex, group) pair --- */ + const outPos = []; + const outNormal = []; + const outEdge = []; + const base = new Array(vertexCount); + for (let v = 0; v < vertexCount; v++) { + const list = groups[v]; + base[v] = outEdge.length; + const px = pos[v * 3]; + const py = pos[v * 3 + 1]; + const pz = pos[v * 3 + 2]; + for (let g = 0; g < list.length; g++) { + const group = list[g]; + const len = Math.hypot(group[0], group[1], group[2]) || 1; + const nx = group[0] / len; + const ny = group[1] / len; + const nz = group[2] / len; + outPos.push(px, py, pz); + outNormal.push(nx, ny, nz); + + let edge = surface.hint[v]; + if (list.length > 1) { + const dx = px - cx; + const dy = py - cy; + const dz = pz - cz; + const dlen = Math.hypot(dx, dy, dz) || 1; + const outward = (nx * dx + ny * dy + nz * dz) / dlen; + edge = Math.max(edge, CREASE_WEAR * smoothstep(-0.25, 0.35, outward)); + } + outEdge.push(saturate(edge)); + } + } + + const index = new Array(kept.length); + for (let i = 0; i < kept.length; i++) index[i] = base[kept[i]] + cornerGroup[i]; + + /* --- axis, then seat into the unit box --- */ + seatUnit(outPos, outNormal, axis); + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(Float32Array.from(outPos), 3)); + geometry.setAttribute('normal', new BufferAttribute(Float32Array.from(outNormal), 3)); + geometry.setAttribute('aEdge', new BufferAttribute(Float32Array.from(outEdge), 1)); + geometry.setIndex( + outEdge.length > 65535 ? new BufferAttribute(Uint32Array.from(index), 1) : new BufferAttribute(Uint16Array.from(index), 1) + ); + geometry.computeBoundingSphere(); + return geometry; +} + +/** + * Rotate the canonical frame onto the requested axis, then fit the unit box. + * + * The fit is one uniform scale against `max(footprint diameter, height)`, + * where the footprint diameter is measured as a real radius about the axis + * rather than as a bounding-box width — `GrowthField`'s contract asks for a + * *circle* of radius 0.5, and a square plate measured by its width would poke + * its corners out of it by 41%. + */ +function seatUnit(pos, normal, axis) { + const count = pos.length / 3; + if (!count) return; + + // Canonical frame is profile-in-XY, axis along +Z. + for (let i = 0; i < count; i++) { + const x = pos[i * 3]; + const y = pos[i * 3 + 1]; + const z = pos[i * 3 + 2]; + const nx = normal[i * 3]; + const ny = normal[i * 3 + 1]; + const nz = normal[i * 3 + 2]; + if (axis === HardAxis.Y) { + // +Z → +Y (rotate −90° about X) + pos[i * 3 + 1] = z; + pos[i * 3 + 2] = -y; + normal[i * 3 + 1] = nz; + normal[i * 3 + 2] = -ny; + } else if (axis === HardAxis.X) { + // +Z → +X (rotate +90° about Y) + pos[i * 3] = z; + pos[i * 3 + 2] = -x; + normal[i * 3] = nz; + normal[i * 3 + 2] = -nx; + } + // HardAxis.Z is the canonical frame already. + } + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; + for (let i = 0; i < count; i++) { + const x = pos[i * 3]; + const y = pos[i * 3 + 1]; + const z = pos[i * 3 + 2]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + } + + const midX = (minX + maxX) * 0.5; + const midZ = (minZ + maxZ) * 0.5; + let radius = 0; + for (let i = 0; i < count; i++) { + const dx = pos[i * 3] - midX; + const dz = pos[i * 3 + 2] - midZ; + const r = Math.hypot(dx, dz); + if (r > radius) radius = r; + } + + const span = Math.max(radius * 2, maxY - minY, 1e-4); + const scale = 1 / span; + for (let i = 0; i < count; i++) { + pos[i * 3] = (pos[i * 3] - midX) * scale; + pos[i * 3 + 1] = (pos[i * 3 + 1] - minY) * scale; + pos[i * 3 + 2] = (pos[i * 3 + 2] - midZ) * scale; + } +} + +/* ---------------------------------------------------------------------- */ +/* 2D contour helpers */ +/* ---------------------------------------------------------------------- */ + +/** + * Miter-offset a closed contour by `inset`, positive meaning **into the + * material** for both windings. + * + * The winding does the work: the inward normal of an edge is `(-dy, dx)`, + * which for an anticlockwise outline points into the part and for a clockwise + * hole points into the wall of the hole — so one positive number chamfers an + * outline and countersinks a bore with no special case. + * + * The miter is clamped at `MITER_MIN`. A gear tooth tip is a 20° corner whose + * true miter is nine times the inset, and an unclamped offset there swings the + * chamfer ring right through the neighbouring tooth; the clamp blunts the very + * sharpest tips by a fraction of a millimetre instead, which nobody has ever + * noticed and which is why the tip does not disappear. + */ +const MITER_MIN = 0.34; + +function insetContour(points, inset) { + const n = points.length; + const out = new Array(n); + for (let i = 0; i < n; i++) { + const p = points[i]; + const prev = points[(i - 1 + n) % n]; + const next = points[(i + 1) % n]; + + let ax = p.x - prev.x; + let ay = p.y - prev.y; + let al = Math.hypot(ax, ay) || 1; + ax /= al; + ay /= al; + let bx = next.x - p.x; + let by = next.y - p.y; + let bl = Math.hypot(bx, by) || 1; + bx /= bl; + by /= bl; + + // Inward normals of the two edges meeting at this corner. + const n1x = -ay; + const n1y = ax; + const n2x = -by; + const n2y = bx; + + let mx = n1x + n2x; + let my = n1y + n2y; + const ml = Math.hypot(mx, my); + if (ml < 1e-6) { + mx = n2x; + my = n2y; + } else { + mx /= ml; + my /= ml; + } + const cos = Math.max(mx * n2x + my * n2y, MITER_MIN); + const scale = inset / cos; + out[i] = new Vector2(p.x + mx * scale, p.y + my * scale); + } + return out; +} + +/** A circle as a contour. `winding` −1 gives the clockwise form a hole wants. */ +function circleContour(cx, cy, radius, segments, winding = 1) { + const out = []; + const n = Math.max(3, Math.round(segments)); + for (let i = 0; i < n; i++) { + const a = (winding * i * TAU) / n; + out.push(new Vector2(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius)); + } + return out; +} + +/** + * Extrude a contour with holes from `z0` to `z1`, with a chamfer at each face + * and an independent countersink on the holes. + * + * Cap triangulation is `ShapeUtils.triangulateShape`, three's own Earcut. It + * is the one part of this file that is not hand-rolled and it stays that way: + * a gear blank with a bore and six lightening holes is a 400-point outline + * with seven holes in it, and writing another ear-clipper to avoid the import + * would be pure vanity. + */ +function extrudeContour(surface, spec) { + const { + outline, + holes = [], + z0 = 0, + z1 = 1, + chamfer = 0, + chamferDepth = null, + holeChamfer = 0, + holeChamferDepth = null, + hint = 0 + } = spec; + + const height = z1 - z0; + const cDepth = Math.min(chamferDepth ?? chamfer, height * 0.45); + const hDepth = Math.min(holeChamferDepth ?? holeChamfer, height * 0.45); + + /* Ring heights, then each contour's inset evaluated *independently* at each + of them. + The first version carried one `(z, outlineInset, holeInset)` triple per + level and set both insets to zero at the foot of the outline's chamfer — + so a countersink deeper than the plate's bevel silently got the bevel's + depth instead, and `counterDepth` was a slider that did nothing beyond a + threshold. Insets are two independent ramps in z and are now written as + two independent ramps in z. */ + const heights = [z0, z0 + cDepth, z0 + hDepth, z1 - hDepth, z1 - cDepth, z1] + .sort((a, b) => a - b) + .filter((z, i, list) => i === 0 || z - list[i - 1] > 1e-7); + + /** A 45°-or-not chamfer ramp: full inset at the face, zero at `depth` in. */ + const rampAt = (z, inset, depth) => { + if (inset <= 1e-7) return 0; + const fromBottom = depth > 1e-7 ? saturate((z - z0) / depth) : 1; + const fromTop = depth > 1e-7 ? saturate((z1 - z) / depth) : 1; + return inset * (1 - Math.min(fromBottom, fromTop)); + }; + const levels = heights.map((z) => ({ + z, + oi: rampAt(z, chamfer, cDepth), + hi: rampAt(z, holeChamfer, hDepth) + })); + + const contours = [outline, ...holes]; + /** rings[level][contour] = array of vertex indices */ + const rings = []; + + for (const level of levels) { + const row = []; + for (let c = 0; c < contours.length; c++) { + const inset = c === 0 ? level.oi : level.hi; + const full = c === 0 ? chamfer : holeChamfer; + const pts = inset > 1e-7 ? insetContour(contours[c], inset) : contours[c]; + const ids = new Array(pts.length); + // `aEdge` rides the chamfer ramp itself: 1 at the lip, 0 at the foot of + // the band. Marking every inset ring as 1 instead — which is what the + // countersink fix accidentally did — puts the whole band at full wear + // and the thin bright line a file leaves becomes a stripe. + const wear = full > 1e-7 ? Math.max(hint, inset / full) : hint; + for (let i = 0; i < pts.length; i++) ids[i] = surface.vertex(pts[i].x, pts[i].y, level.z, wear); + row.push({ ids, pts }); + } + rings.push(row); + } + + /* Walls. */ + for (let l = 0; l < rings.length - 1; l++) { + for (let c = 0; c < contours.length; c++) { + const lower = rings[l][c].ids; + const upper = rings[l + 1][c].ids; + const n = lower.length; + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + surface.quad(lower[i], lower[j], upper[j], upper[i]); + } + } + } + + /* Caps, from the extreme rings so the chamfer is what the face sits on. */ + capContourRing(surface, rings[0], false); + capContourRing(surface, rings[rings.length - 1], true); +} + +/** Triangulate one ring row into a flat cap. `up` picks the winding. */ +function capContourRing(surface, row, up) { + const outline = row[0]; + const holes = row.slice(1); + const faces = ShapeUtils.triangulateShape( + outline.pts, + holes.map((h) => h.pts) + ); + const flat = [...outline.ids]; + for (const hole of holes) flat.push(...hole.ids); + for (const face of faces) { + const a = flat[face[0]]; + const b = flat[face[1]]; + const c = flat[face[2]]; + if (a === undefined || b === undefined || c === undefined) continue; + if (up) surface.face(a, b, c); + else surface.face(a, c, b); + } +} + +/** + * Surface of revolution about +Z. + * + * `stations` are `{ r, z }` in profile order; a station with `r ≈ 0` becomes a + * single pole vertex rather than a ring of coincident ones, which is what + * keeps the bolt's point from being a fan of degenerate slivers. `radial` is + * an optional `(z, theta, r) => r` used by exactly one caller — the bolt's + * thread, which is the only place in this file where the radius depends on the + * angle as well as the height. + */ +function revolveProfile(surface, spec) { + const { stations, segments = 24, radial = null, capBottom = true, capTop = true, hint = 0 } = spec; + const n = Math.max(3, Math.round(segments)); + const rings = []; + + for (const station of stations) { + const r = Math.max(station.r, 0); + const wear = station.hint ?? hint; + if (r < 1e-5) { + rings.push({ pole: true, ids: [surface.vertex(0, 0, station.z, wear)] }); + continue; + } + const ids = new Array(n); + for (let i = 0; i < n; i++) { + const a = (i * TAU) / n; + const rr = radial ? radial(station.z, a, r) : r; + ids[i] = surface.vertex(Math.cos(a) * rr, Math.sin(a) * rr, station.z, wear); + } + rings.push({ pole: false, ids }); + } + + for (let s = 0; s < rings.length - 1; s++) { + const lower = rings[s]; + const upper = rings[s + 1]; + if (lower.pole && upper.pole) continue; + if (lower.pole) { + for (let i = 0; i < n; i++) surface.face(lower.ids[0], upper.ids[i], upper.ids[(i + 1) % n]); + } else if (upper.pole) { + for (let i = 0; i < n; i++) surface.face(upper.ids[0], lower.ids[(i + 1) % n], lower.ids[i]); + } else { + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + surface.quad(lower.ids[i], lower.ids[j], upper.ids[j], upper.ids[i]); + } + } + } + + const first = rings[0]; + const last = rings[rings.length - 1]; + if (capBottom && !first.pole) { + const centre = surface.vertex(0, 0, stations[0].z, hint); + for (let i = 0; i < n; i++) surface.face(centre, first.ids[(i + 1) % n], first.ids[i]); + } + if (capTop && !last.pole) { + const centre = surface.vertex(0, 0, stations[stations.length - 1].z, hint); + for (let i = 0; i < n; i++) surface.face(centre, last.ids[i], last.ids[(i + 1) % n]); + } +} + +/** + * Loft equal-count convex cross-sections along a travel direction. + * + * Sections must be wound right-handed about the direction of travel — for the + * anvil's body that is anticlockwise in XY going up, for its horn it is the + * same relation about +X. Caps are centroid fans, which is legitimate here and + * only here: every section this module lofts is convex by construction. + */ +function loftSections(surface, sections, options = {}) { + const { capFirst = true, capLast = true, hint = 0, hints = null } = options; + const rings = sections.map((section, s) => { + const ids = new Array(section.length); + const wear = hints?.[s] ?? hint; + for (let i = 0; i < section.length; i++) { + const p = section[i]; + ids[i] = surface.vertex(p.x, p.y, p.z, wear); + } + return ids; + }); + + for (let s = 0; s < rings.length - 1; s++) { + const lower = rings[s]; + const upper = rings[s + 1]; + const n = lower.length; + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + surface.quad(lower[i], lower[j], upper[j], upper[i]); + } + } + + const fan = (ring, section, forward) => { + let cx = 0; + let cy = 0; + let cz = 0; + for (const p of section) { + cx += p.x; + cy += p.y; + cz += p.z; + } + const inv = 1 / Math.max(1, section.length); + const centre = surface.vertex(cx * inv, cy * inv, cz * inv, hint); + const n = ring.length; + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + if (forward) surface.face(centre, ring[i], ring[j]); + else surface.face(centre, ring[j], ring[i]); + } + }; + + if (capFirst) fan(rings[0], sections[0], false); + if (capLast) fan(rings[rings.length - 1], sections[sections.length - 1], true); +} + +/** A rounded rectangle, anticlockwise, in the XY plane at height `z`. */ +function roundedRect(halfWidth, halfDepth, corner, steps, z) { + const r = Math.min(corner, halfWidth * 0.99, halfDepth * 0.99); + const x = Math.max(halfWidth - r, 0); + const y = Math.max(halfDepth - r, 0); + const n = Math.max(1, Math.round(steps)); + const out = []; + const arc = (ox, oy, from) => { + for (let i = 0; i <= n; i++) { + const a = from + (i / n) * (Math.PI / 2); + out.push(new Vector3(ox + Math.cos(a) * r, oy + Math.sin(a) * r, z)); + } + }; + arc(x, y, 0); + arc(-x, y, Math.PI / 2); + arc(-x, -y, Math.PI); + arc(x, -y, Math.PI * 1.5); + return out; +} + +/** + * Laplacian smoothing over a resampled silhouette — the fillet machinery. + * + * An anvil has nine slope changes between the base and the face and every one + * of them is filleted on a real one. Authoring nine tangent arcs means solving + * nine tangency conditions and re-solving all of them whenever a proportion + * slider moves, and the first version of this function did exactly that and + * produced a self-intersecting waist the moment `waistWidth` went below a + * third. Resampling the silhouette evenly and then running a handful of + * three-tap averaging passes rounds every corner at once, cannot self- + * intersect, degrades to the sharp original at `fillet = 0`, and is nine + * lines. The endpoints are pinned so the base stays flat on the floor and the + * working face stays flat. + */ +function smoothProfile(keys, samples, weight, passes) { + const count = Math.max(3, Math.round(samples)); + const out = []; + // Resample the piecewise-linear key list on the vertical. + const zFirst = keys[0].z; + const zLast = keys[keys.length - 1].z; + for (let i = 0; i < count; i++) { + const z = zFirst + ((zLast - zFirst) * i) / (count - 1); + let k = 0; + while (k < keys.length - 2 && keys[k + 1].z < z) k++; + const a = keys[k]; + const b = keys[k + 1]; + const t = b.z - a.z > 1e-9 ? clamp((z - a.z) / (b.z - a.z), 0, 1) : 0; + out.push({ z, w: a.w + (b.w - a.w) * t, d: a.d + (b.d - a.d) * t }); + } + + const k = saturate(weight); + for (let pass = 0; pass < Math.max(0, Math.round(passes)); pass++) { + const copy = out.map((s) => ({ ...s })); + for (let i = 1; i < out.length - 1; i++) { + out[i].w = copy[i].w + ((copy[i - 1].w + copy[i + 1].w) * 0.5 - copy[i].w) * k; + out[i].d = copy[i].d + ((copy[i - 1].d + copy[i + 1].d) * 0.5 - copy[i].d) * k; + out[i].z = copy[i].z + ((copy[i - 1].z + copy[i + 1].z) * 0.5 - copy[i].z) * k; + } + } + return out; +} + +/* ====================================================================== */ +/* §3 · The involute gear */ +/* ====================================================================== */ + +/** + * The involute function, `inv(α) = tan α − α`. + * + * This is the whole reason gears mesh. Two involute flanks in contact + * transmit motion at a constant ratio *regardless of how far apart their + * centres are* — the property no other tooth curve has, and the property that + * makes `gearlock`'s trick possible: the ability can drag the module, the + * centres move, and the train stays meshed rather than needing a new profile. + */ +function involute(alpha) { + return Math.tan(alpha) - alpha; +} + +/** + * Half the angular tooth thickness at radius `x`, measured from the tooth's + * own centreline. + * + * `ψ(x) = ψ_p + inv(α) − inv(α_x)` where `cos α_x = r_b / x`. At the pitch + * circle `α_x = α` and this collapses to `ψ_p`, which is the identity worth + * checking when you change anything in here. + */ +function toothHalfAngle(x, baseRadius, psiPitch, invAlpha) { + const cosAx = clamp(baseRadius / Math.max(x, 1e-6), -1, 1); + const alphaX = Math.acos(cosAx); + return psiPitch + invAlpha - involute(alphaX); +} + +/** + * The gear's radii, in *profile* units where the tip radius is exactly 0.5. + * + * Everything about the gear that the rest of the file needs comes out of here, + * including the pitch radius the ability wants for spacing. Fixing the tip at + * 0.5 means the seating pass is a no-op for a flat gear and the numbers you + * read here are the numbers the mesh has. + */ +function gearRadii(shape) { + const teeth = Math.max(4, Math.round(shape.teeth)); + const addendum = Math.max(shape.addendum, 0.05); + const dedendum = Math.max(shape.dedendum, addendum * 0.2); + // Choose the module so that the nominal tip diameter is exactly 1. + const module = 1 / (teeth + 2 * addendum); + const pitch = (module * teeth) / 2; + const alpha = clamp(shape.pressureAngle, 5, 35) * DEG; + const base = pitch * Math.cos(alpha); + const tip = pitch + addendum * module; + const root = Math.max(pitch - dedendum * module, module * 0.15); + + const psiPitch = (Math.PI / (2 * teeth)) * (1 - clamp(shape.backlash, 0, 0.5)); + const invAlpha = involute(alpha); + + /* Where the flank starts. The involute exists only outside the base circle + and below that the fillet takes over — but there is a second bound that + the obvious `max(base, root)` misses. The tooth's half-angle grows as the + radius falls, and on a fine, high-pressure-angle gear it passes `π/z` + before the flank reaches the base circle: the tooth is then wider than + the pitch it has to live in and every tooth overlaps its neighbour. Six + of the editor's slider combinations reach it (31 teeth, dedendum 1.8, + 30°) and the symptom is one crossing per tooth, all the way round. So the + flank starts at whichever radius comes first, and the fillet covers a + little more of the root than it otherwise would. */ + const capAngle = (Math.PI / teeth) * 0.92; + let flankStart = Math.max(base, root) + 1e-5; + if (toothHalfAngle(flankStart, base, psiPitch, invAlpha) > capAngle) { + let lo = flankStart; + let hi = tip; + for (let i = 0; i < 28; i++) { + const mid = (lo + hi) * 0.5; + if (toothHalfAngle(mid, base, psiPitch, invAlpha) > capAngle) lo = mid; + else hi = mid; + } + flankStart = hi; + } + + /* A pointed tooth is a real failure of high addendum on a low tooth count, + and it has a real answer: the tip circle moves down to the radius where + the two flanks meet. Clamping the half-angle at zero instead — the first + version — leaves every sample above that radius sitting on the centreline + and the two flanks walk straight through each other; a sweep of the + editor's whole range found it in 31 of 1080 profiles, all reachable with + two sliders. Bisection because ψ(r) = 0 has no closed form; twenty-eight + steps is exact to the last float and it runs once per rebuild. */ + let outer = tip; + if (toothHalfAngle(tip, base, psiPitch, invAlpha) <= 1e-4) { + let lo = flankStart; + let hi = tip; + for (let i = 0; i < 28; i++) { + const mid = (lo + hi) * 0.5; + if (toothHalfAngle(mid, base, psiPitch, invAlpha) > 1e-4) lo = mid; + else hi = mid; + } + outer = Math.max(lo, flankStart * 1.02); + } + + return { + teeth, + module, + alpha, + pitch, + base, + tip, // the NOMINAL tip: pitch + addendum + outer, // what the profile actually reaches — lower on a pointed tooth + root, + psiPitch, + invAlpha, + capAngle, + flankStart, + halfAtStart: clamp(toothHalfAngle(flankStart, base, psiPitch, invAlpha), 1e-4, capAngle), + halfAtTip: Math.max(toothHalfAngle(outer, base, psiPitch, invAlpha), 0), + pointed: outer < tip - 1e-6 + }; +} + +/** + * The pitch radius as a fraction of the gear's actual outer radius. + * + * After seating, that outer radius **is** half the instance's footprint, so + * `pitchRadiusMetres = 0.5 · instanceScale · gearPitchFraction(shape)` and, + * going the other way, `instanceScale = 2 · pitchRadiusMetres / + * gearPitchFraction(shape)`. + * + * It is measured against the *actual* outer radius rather than the nominal + * one for a reason that only shows up in one place and shows up badly: a + * pointed tooth's profile stops short of `pitch + addendum`, the seating pass + * scales the point out to 0.5 regardless, and a gear placed by the nominal + * radius is then drawn slightly too large — with its pitch circle outside + * where its neighbour thinks it is, which is interpenetration. Passing this + * number to `GearTrain#scaleOf` is what keeps that exact. + */ +export function gearPitchFraction(shape) { + const r = gearRadii(gearShape(shape)); + return r.pitch / r.outer; +} + +/** The root radius as a fraction of the outer radius — where a gullet bottoms out. */ +export function gearRootFraction(shape) { + const r = gearRadii(gearShape(shape)); + return r.root / r.outer; +} + +/** + * The involute tooth outline, anticlockwise, starting in a root gap. + * + * Per tooth, in strictly increasing angle so the polygon cannot fold: + * + * root arc → fillet → trailing flank out → tip arc → leading flank in → fillet + * + * The fillet is a quadratic blend from the root circle up to the base of the + * flank, standing in for the trochoid a hob would actually cut. The trochoid + * depends on the cutter's tip radius, which is a parameter nobody wants and + * nobody can see at this scale; the blend is tangent-ish at both ends and is + * the difference between a tooth that looks machined and a tooth that looks + * stamped out of card. + * + * A tooth whose half-angle reaches zero before the tip radius has *pointed* — + * a real failure mode of high addendum on a low tooth count — and the profile + * clamps to the point rather than crossing itself. + */ +function gearOutline(shape) { + const { + teeth, + base, + root, + psiPitch, + invAlpha, + flankStart, + halfAtStart, + halfAtTip, + outer: tipRadius, + pointed + } = gearRadii(shape); + const flankSteps = Math.max(2, Math.round(shape.flankSteps)); + const tipSteps = Math.max(1, Math.round(shape.tipSteps)); + const rootSteps = Math.max(1, Math.round(shape.rootSteps)); + + // How much of the root gap the fillet may eat, leaving the gap itself open. + const gapHalf = Math.PI / teeth - halfAtStart; + const filletSpan = clamp(shape.rootFillet, 0, 1) * Math.max(gapHalf, 0) * 0.8; + + const points = []; + const at = (radius, angle) => points.push(new Vector2(Math.cos(angle) * radius, Math.sin(angle) * radius)); + + for (let k = 0; k < teeth; k++) { + const centre = (k * TAU) / teeth; + + /* Root arc, from the previous tooth's fillet exit to this one's entry. */ + const gapFrom = centre - Math.PI / teeth + filletSpan; + const gapTo = centre - halfAtStart - filletSpan; + for (let i = 0; i < rootSteps; i++) { + at(root, gapFrom + ((gapTo - gapFrom) * i) / rootSteps); + } + + /* Trailing fillet: quadratic from the root circle to the flank base. */ + const fx0 = Math.cos(gapTo) * root; + const fy0 = Math.sin(gapTo) * root; + const cxT = Math.cos(centre - halfAtStart) * root; + const cyT = Math.sin(centre - halfAtStart) * root; + const fx1 = Math.cos(centre - halfAtStart) * flankStart; + const fy1 = Math.sin(centre - halfAtStart) * flankStart; + for (let i = 0; i < 3; i++) { + const t = i / 3; + const u = 1 - t; + points.push( + new Vector2(u * u * fx0 + 2 * u * t * cxT + t * t * fx1, u * u * fy0 + 2 * u * t * cyT + t * t * fy1) + ); + } + + /* Trailing flank, out to the tip. */ + for (let i = 0; i < flankSteps; i++) { + const x = flankStart + ((tipRadius - flankStart) * i) / flankSteps; + at(x, centre - Math.max(toothHalfAngle(x, base, psiPitch, invAlpha), halfAtTip)); + } + + /* Tip: an arc, or the single point a pointed tooth has instead. */ + if (pointed) { + at(tipRadius, centre); + } else { + for (let i = 0; i <= tipSteps; i++) { + at(tipRadius, centre - halfAtTip + ((2 * halfAtTip) * i) / tipSteps); + } + } + + /* Leading flank, back down. */ + for (let i = flankSteps - 1; i >= 0; i--) { + const x = flankStart + ((tipRadius - flankStart) * i) / flankSteps; + at(x, centre + Math.max(toothHalfAngle(x, base, psiPitch, invAlpha), halfAtTip)); + } + + /* Leading fillet, back to the root circle. */ + const gx0 = Math.cos(centre + halfAtStart) * flankStart; + const gy0 = Math.sin(centre + halfAtStart) * flankStart; + const cxL = Math.cos(centre + halfAtStart) * root; + const cyL = Math.sin(centre + halfAtStart) * root; + const gx1 = Math.cos(centre + halfAtStart + filletSpan) * root; + const gy1 = Math.sin(centre + halfAtStart + filletSpan) * root; + for (let i = 1; i <= 3; i++) { + const t = i / 3; + const u = 1 - t; + points.push( + new Vector2(u * u * gx0 + 2 * u * t * cxL + t * t * gx1, u * u * gy0 + 2 * u * t * cyL + t * t * gy1) + ); + } + } + + // Guard against the degenerate case where every point landed on top of the + // last: Earcut answers a fold with inverted triangles, not an error. + return dedupeContour(points); +} + +/** Drop points a hair apart — Earcut folds on them and the crease pass NaNs. */ +function dedupeContour(points, epsilon = 1e-6) { + const out = []; + for (const p of points) { + const last = out[out.length - 1]; + if (last && Math.abs(last.x - p.x) < epsilon && Math.abs(last.y - p.y) < epsilon) continue; + out.push(p); + } + while (out.length > 3) { + const first = out[0]; + const last = out[out.length - 1]; + if (Math.abs(first.x - last.x) < epsilon && Math.abs(first.y - last.y) < epsilon) out.pop(); + else break; + } + return out; +} + +/** An involute spur gear: bore, optional lightening holes, chamfered faces. */ +export function createGearGeometry(shape = gearShape()) { + const s = gearShape(shape); + const radii = gearRadii(s); + const surface = new Surface(); + + const holes = []; + const bore = clamp(s.bore, 0, 0.85) * radii.outer; + if (bore > 1e-3) holes.push(circleContour(0, 0, bore, s.boreSegments, -1)); + + const lighten = Math.max(0, Math.round(s.lightenHoles)); + if (lighten > 0) { + const ring = clamp(s.lightenRing, 0, 0.92) * radii.outer; + const r = Math.max(clamp(s.lightenRadius, 0, 0.4) * radii.outer, 1e-3); + for (let i = 0; i < lighten; i++) { + const a = (i * TAU) / lighten; + holes.push(circleContour(Math.cos(a) * ring, Math.sin(a) * ring, r, s.lightenSegments, -1)); + } + } + + const thickness = Math.max(s.thickness, 0.01); + extrudeContour(surface, { + outline: gearOutline(s), + holes, + z0: 0, + z1: thickness, + chamfer: Math.min(s.chamfer, thickness * 0.4), + holeChamfer: Math.min(s.boreChamfer, thickness * 0.4) + }); + + return finishSurface(surface, { creaseAngle: s.creaseAngle, axis: s.axis }); +} + +/* ====================================================================== */ +/* §4 · The other five */ +/* ====================================================================== */ + +/** + * The saw tooth outline. + * + * Both working faces are **logarithmic spirals**, and that is not a + * flourish: a face held at a constant angle `β` off the radial satisfies + * `dθ/dr = −tan β / r`, whose integral is `θ(r) = θ_tip ± tan β · ln(r_tip/r)`. + * Straight chords look right at 24 teeth and visibly wrong at 8, where the + * rake angle at the gullet ends up nothing like the rake angle at the tip. + * + * The rake and clearance are scaled back together if the two faces would meet + * before the gullet floor does. A self-intersecting outline does not throw — + * Earcut hands back a fan of inverted triangles and the blade renders inside + * out, which is a twenty-minute bug to find and a one-line clamp to avoid. + */ +function sawOutline(shape) { + const teeth = Math.max(3, Math.round(shape.teeth)); + const tip = 0.5; + const gulletRadius = Math.max(tip * (1 - clamp(shape.gullet, 0.02, 0.6)), tip * 0.2); + const pitchAngle = TAU / teeth; + const land = clamp(shape.tipLand, 0.02, 0.7) * pitchAngle * 0.5; + const logDepth = Math.log(tip / gulletRadius); + + let tanRake = Math.tan(clamp(shape.rake, -35, 45) * DEG); + let tanClear = Math.tan(clamp(shape.clearance, 0, 60) * DEG); + + /* Both faces are clamped together, and the bound is **two-sided**. The + first version only guarded the top of it — the case where a deep hook and + a deep relief eat the whole tooth pitch — and left the bottom open, so a + strongly negative rake on a coarse blade swung the cutting face back past + the clearance face of its own tooth. The condition is one number either + way: at the gullet floor the rake face sits at `+land + tanRake·L` and + the clearance face at `−land − tanClear·L`, so the sum has to stay inside + `(−2·land, pitch − 2·land)` for the outline to be simple. 195 of 2160 + profiles in a sweep of the editor's range failed the lower half of it. */ + const sum = (tanRake + tanClear) * logDepth; + const upper = (pitchAngle - 2 * land) * 0.82; + const lower = -2 * land * 0.82; + let k = 1; + if (sum > upper && sum > 1e-9) k = upper / sum; + else if (sum < lower && sum < -1e-9) k = lower / sum; + tanRake *= k; + tanClear *= k; + + const steps = Math.max(1, Math.round(shape.flankSteps)); + const gulletSteps = Math.max(1, Math.round(shape.gulletSteps)); + const points = []; + const at = (radius, angle) => points.push(new Vector2(Math.cos(angle) * radius, Math.sin(angle) * radius)); + + for (let k = 0; k < teeth; k++) { + const centre = k * pitchAngle; + + /* Clearance face, climbing from the gullet floor to the trailing tip corner. */ + for (let i = 0; i <= steps; i++) { + const r = gulletRadius + ((tip - gulletRadius) * i) / steps; + at(r, centre - land - tanClear * Math.log(tip / r)); + } + /* The land across the tip. */ + at(tip, centre + land); + /* Rake face, dropping forward into the next gullet. */ + for (let i = 1; i <= steps; i++) { + const r = tip - ((tip - gulletRadius) * i) / steps; + at(r, centre + land + tanRake * Math.log(tip / r)); + } + /* Gullet floor: an arc on to the next tooth's clearance face. */ + const from = centre + land + tanRake * logDepth; + const to = centre + pitchAngle - land - tanClear * logDepth; + for (let i = 1; i < gulletSteps; i++) { + at(gulletRadius, from + ((to - from) * i) / gulletSteps); + } + } + + return dedupeContour(points); +} + +/** A radial expansion slot: a rounded slit cut inward from the gullet floor. */ +function slotContour(angle, from, to, halfWidth, steps) { + const points = []; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const perpX = -sin; + const perpY = cos; + const n = Math.max(1, Math.round(steps)); + const cap = (radius, sign) => { + for (let i = 0; i <= n; i++) { + const a = (Math.PI * i) / n; + const w = Math.cos(a) * halfWidth * sign; + const d = Math.sin(a) * halfWidth * sign; + points.push(new Vector2(cos * (radius + d) + perpX * w, sin * (radius + d) + perpY * w)); + } + }; + cap(to, 1); + cap(from, -1); + // Clockwise for a hole. + points.reverse(); + return dedupeContour(points); +} + +/** A circular saw blade. */ +export function createSawbladeGeometry(shape = sawbladeShape()) { + const s = sawbladeShape(shape); + const surface = new Surface(); + const tip = 0.5; + + const holes = []; + const arbor = clamp(s.arbor, 0, 0.6) * tip; + if (arbor > 1e-3) holes.push(circleContour(0, 0, arbor, s.arborSegments, -1)); + + const slots = Math.max(0, Math.round(s.slots)); + if (slots > 0) { + const gulletRadius = tip * (1 - clamp(s.gullet, 0.02, 0.6)); + const to = gulletRadius - tip * 0.03; + const from = Math.max(to - clamp(s.slotDepth, 0.02, 0.7) * tip, arbor + tip * 0.06); + const halfWidth = Math.max(clamp(s.slotWidth, 0.005, 0.2) * tip, 1e-3); + if (to - from > halfWidth * 2.2) { + for (let i = 0; i < slots; i++) { + holes.push(slotContour((i * TAU) / slots + Math.PI / slots, from, to, halfWidth, s.slotSteps)); + } + } + } + + const thickness = Math.max(s.thickness, 0.006); + extrudeContour(surface, { + outline: sawOutline(s), + holes, + z0: 0, + z1: thickness, + chamfer: Math.min(s.chamfer, thickness * 0.4), + holeChamfer: Math.min(s.chamfer * 0.6, thickness * 0.3) + }); + + return finishSurface(surface, { creaseAngle: s.creaseAngle, axis: s.axis }); +} + +/** A bevelled plate with countersunk bolt holes. */ +export function createPlateGeometry(shape = plateShape()) { + const s = plateShape(shape); + const surface = new Surface(); + + const halfW = Math.max(s.width, 0.05) * 0.5; + const halfD = Math.max(s.depth, 0.05) * 0.5; + const short = Math.min(halfW, halfD) * 2; + const corner = clamp(s.corner, 0, 0.5) * short; + + const outline = roundedRect(halfW, halfD, corner, s.cornerSteps, 0).map((p) => new Vector2(p.x, p.y)); + + const bolts = Math.max(0, Math.round(s.bolts)); + const holes = []; + if (bolts > 0) { + const inset = clamp(s.boltInset, 0.05, 0.45) * short; + const r = Math.max(clamp(s.boltRadius, 0.005, 0.25) * short, 1e-3); + const x = Math.max(halfW - inset, 0); + const y = Math.max(halfD - inset, 0); + // Corners first, then the mid-span pair a six-bolt pattern adds. + const spots = [ + [x, y], + [-x, y], + [-x, -y], + [x, -y], + [0, y], + [0, -y], + [x, 0], + [-x, 0] + ]; + for (let i = 0; i < Math.min(bolts, spots.length); i++) { + holes.push(circleContour(spots[i][0], spots[i][1], r, s.boltSegments, -1)); + } + } + + const thickness = Math.max(s.thickness, 0.01); + extrudeContour(surface, { + outline, + holes, + z0: 0, + z1: thickness, + chamfer: Math.min(s.bevel, thickness * 0.4), + // The countersink is the one chamfer here that is not 45°: a flare wider + // than it is deep is what a real countersink bit leaves, and it is what + // makes the hole read as machined rather than as drilled and abandoned. + holeChamfer: clamp(s.counterSink, 0, 0.3) * short, + holeChamferDepth: Math.min(s.counterDepth, thickness * 0.45) + }); + + return finishSurface(surface, { creaseAngle: s.creaseAngle, axis: s.axis }); +} + +/** A driven piston: base flange, rod, collar, ringed head. */ +export function createPistonGeometry(shape = pistonShape()) { + const s = pistonShape(shape); + const surface = new Surface(); + const length = Math.max(s.length, 0.2); + + const stations = []; + const add = (r, z, hint = 0) => stations.push({ r: Math.max(r, 0), z, hint }); + + const baseR = Math.max(s.baseRadius, 0.02); + const rodR = clamp(s.rodRadius, 0.01, baseR); + const baseH = Math.max(s.baseHeight, 0.01) * length * 0.25; + const baseC = Math.min(s.baseChamfer, baseH * 0.45, baseR * 0.45); + + /* Base flange. One hint per chamfer — see `finishSurface`. */ + add(0, 0); + add(baseR - baseC, 0, 1); + add(baseR, baseC); + add(baseR, baseH - baseC); + add(baseR - baseC, baseH, 1); + add(rodR, baseH + baseC * 0.4); + + /* Collar. */ + const collarZ = clamp(s.collarAt, 0.05, 0.95) * length; + const collarR = clamp(s.collarRadius, rodR, baseR * 1.2); + const collarH = Math.max(s.collarHeight, 0.01) * length * 0.5; + const collarC = Math.min(s.collarChamfer, collarH * 0.45, (collarR - rodR) * 0.9 + 1e-4); + add(rodR, Math.max(collarZ - collarH * 0.5 - collarC, baseH + baseC)); + add(collarR - collarC, collarZ - collarH * 0.5, 1); + add(collarR, collarZ - collarH * 0.5 + collarC); + add(collarR, collarZ + collarH * 0.5 - collarC); + add(collarR - collarC, collarZ + collarH * 0.5, 1); + add(rodR, collarZ + collarH * 0.5 + collarC); + + /* Head, with its machined rings. */ + const headZ = clamp(s.headAt, 0.2, 0.98) * length; + const headR = Math.max(s.headRadius, collarR); + const headC = Math.min(s.headChamfer, headR * 0.4, (length - headZ) * 0.4); + add(rodR, Math.max(headZ - headC, collarZ + collarH * 0.5 + collarC * 1.5)); + add(headR - headC, headZ, 1); + add(headR, headZ + headC); + + const rings = Math.max(0, Math.round(s.rings)); + const crown = length; + const ringSpan = crown - headC - (headZ + headC); + if (rings > 0 && ringSpan > 0) { + const depth = Math.min(s.ringDepth, headR * 0.5); + const height = Math.min(s.ringHeight * length * 0.4, ringSpan / (rings * 2.4)); + for (let i = 0; i < rings; i++) { + const z = headZ + headC + (ringSpan * (i + 0.7)) / (rings + 0.4); + add(headR, z - height, 1); + add(headR - depth, z - height * 0.35); + add(headR - depth, z + height * 0.35); + add(headR, z + height, 1); + } + } + + /* Crown: a chamfer and an optional dish. */ + add(headR, crown - headC); + const recess = clamp(s.faceRecess, 0, 0.4); + add(headR - headC, crown, 1); + if (recess > 1e-4) { + add(headR * 0.72, crown, 1); + add(headR * 0.6, crown - recess * headR * 0.5); + add(0, crown - recess * headR * 0.55); + } else { + add(0, crown); + } + + // Monotone in z, or the lathe folds through itself. + for (let i = 1; i < stations.length; i++) { + if (stations[i].z < stations[i - 1].z) stations[i].z = stations[i - 1].z; + } + + revolveProfile(surface, { + stations, + segments: s.segments, + capBottom: stations[0].r > 1e-5, + capTop: stations[stations.length - 1].r > 1e-5 + }); + + return finishSurface(surface, { creaseAngle: s.creaseAngle, axis: s.axis }); +} + +/** + * A hex-head bolt with a real helical thread. + * + * The thread is a sawtooth on the shank radius keyed on `z·turns + θ/2π`, + * which is a genuine helix rather than a stack of rings: drop `threadSteps` + * below about four and you can watch it degenerate into rings, which is a + * useful way to see that it was never rings to begin with. + */ +export function createBoltGeometry(shape = boltShape()) { + const s = boltShape(shape); + const surface = new Surface(); + const length = Math.max(s.length, 0.3); + + /* Head: a hexagonal prism, chamfered on both faces. Across the flats is 1. */ + const headHeight = Math.max(s.headHeight, 0.05); + const acrossFlats = 0.5; + const acrossCorners = acrossFlats / Math.cos(Math.PI / 6); + const hex = []; + for (let i = 0; i < 6; i++) { + const a = (i * TAU) / 6 + Math.PI / 6; + hex.push(new Vector2(Math.cos(a) * acrossCorners, Math.sin(a) * acrossCorners)); + } + extrudeContour(surface, { + outline: hex, + z0: length - headHeight, + z1: length, + chamfer: Math.min(s.headChamfer, headHeight * 0.4, acrossFlats * 0.4) + }); + + /* Shank, washer flange and point. */ + const shankR = clamp(s.shankRadius, 0.02, acrossFlats * 0.95); + const washer = Math.max(s.washer, 0); + const washerR = Math.max(s.washerRadius * 0.5, shankR); + const shankTop = length - headHeight; + const stations = []; + const add = (r, z, hint = 0) => stations.push({ r: Math.max(r, 0), z, hint }); + + const taper = clamp(s.tipTaper, 0, 0.5) * length; + add(0, 0); + add(shankR * 0.45, 0, 1); + if (taper > 1e-3) add(shankR, taper); + else add(shankR, 0); + + const threadFrom = clamp(s.threadFrom, 0, 0.9) * shankTop; + const threadTo = Math.max(threadFrom, shankTop - washer * 1.2); + const turns = Math.max(0, Math.round(s.threadTurns)); + const depth = clamp(s.threadDepth, 0, shankR * 0.6); + const threaded = turns > 0 && depth > 1e-4 && threadTo - threadFrom > 1e-3; + + if (threaded) { + const steps = Math.max(3, Math.round(s.threadSteps)) * turns; + for (let i = 0; i <= steps; i++) { + add(shankR, threadFrom + ((threadTo - threadFrom) * i) / steps); + } + } else { + add(shankR, threadTo); + } + + if (washer > 1e-4) { + add(shankR, shankTop - washer); + add(washerR, shankTop - washer, 1); + add(washerR, shankTop, 1); + } else { + add(shankR, shankTop); + } + add(0, shankTop); + + for (let i = 1; i < stations.length; i++) { + if (stations[i].z < stations[i - 1].z) stations[i].z = stations[i - 1].z; + } + + const pitch = threaded ? (threadTo - threadFrom) / turns : 1; + revolveProfile(surface, { + stations, + segments: s.shankSegments, + capBottom: false, + capTop: false, + radial: threaded + ? (z, theta, r) => { + if (z < threadFrom - 1e-6 || z > threadTo + 1e-6 || r < shankR - 1e-6) return r; + // One sawtooth per turn, advanced by the angle: a helix, not a ring. + const phase = (z - threadFrom) / pitch - theta / TAU; + const f = phase - Math.floor(phase); + return r - depth * (1 - Math.abs(f * 2 - 1)); + } + : null + }); + + return finishSurface(surface, { creaseAngle: s.creaseAngle, axis: s.axis }); +} + +/** + * A blacksmith's anvil — horn, face, waist, base. + * + * Two lofts that interpenetrate: a body lofted up +Z through smoothed + * rounded-rectangle sections, and a horn lofted along +X through circles that + * shrink and droop. They are not booleaned together, because two closed solids + * sharing an interior read exactly like one solid from every angle a camera in + * this project can reach, and a CSG kernel for one prop is not a trade anybody + * should make. + * + * Everything that makes it read as forged is in `smoothProfile`: the fillet + * under the face, the hollow of the waist and the flare into the base are one + * smoothing pass over one silhouette, and turning `fillet` to zero gives you + * back the stack of boxes it would otherwise have been. + */ +export function createAnvilGeometry(shape = anvilShape()) { + const s = anvilShape(shape); + const surface = new Surface(); + + const height = Math.max(s.height, 0.15); + const baseH = Math.max(s.baseHeight, 0.02) * height; + const waistH = Math.max(s.waistHeight, 0.02) * height; + const faceH = Math.max(s.faceHeight, 0.02) * height; + const total = baseH + waistH + faceH; + const k = total > 1e-4 ? height / total : 1; + + const zBase = baseH * k; + const zWaist = zBase + waistH * k; + const zTop = zWaist + faceH * k; + + /* Key silhouette. Half-extents, so the numbers read as radii. */ + const keys = [ + { z: 0, w: s.baseWidth * 0.5, d: s.baseDepth * 0.5 }, + { z: zBase * 0.55, w: s.baseWidth * 0.5, d: s.baseDepth * 0.5 }, + { z: zBase, w: s.waistWidth * 0.62, d: s.waistDepth * 0.62 }, + { z: zBase + (zWaist - zBase) * 0.45, w: s.waistWidth * 0.5, d: s.waistDepth * 0.5 }, + { z: zWaist, w: s.waistWidth * 0.62, d: s.waistDepth * 0.62 }, + { z: zWaist + (zTop - zWaist) * 0.42, w: s.faceWidth * 0.5, d: s.faceDepth * 0.5 }, + { z: zTop, w: s.faceWidth * 0.5, d: s.faceDepth * 0.5 } + ]; + + const profile = smoothProfile(keys, s.sections, s.fillet, s.filletPasses); + const sections = profile.map((station) => { + const half = Math.min(station.w, station.d); + return roundedRect( + Math.max(station.w, 1e-3), + Math.max(station.d, 1e-3), + clamp(s.corner, 0, 0.9) * half, + s.cornerSteps, + station.z + ); + }); + // The face perimeter is the one part of an anvil that is genuinely polished, + // by a century of hot steel sliding over it, and the loft has no chamfer to + // hang that on — so the top two sections are hinted by hand. + const bodyHints = sections.map((_, i) => (i === sections.length - 1 ? 1 : i === sections.length - 2 ? 0.45 : 0)); + loftSections(surface, sections, { capFirst: true, capLast: true, hints: bodyHints }); + + /* The horn. Sections in the YZ plane, marching along +X so the loft's + right-handed winding rule still holds. */ + const reach = Math.max(s.horn, 0) * 1; + if (reach > 1e-3) { + const rootX = Math.max(s.faceWidth, 0.05) * 0.5 - 1e-3; + const rootR = Math.max(clamp(s.hornRadius, 0.05, 1) * s.faceDepth * 0.5, 1e-3); + const centreZ = zTop - Math.min(rootR, faceH * k * 0.9); + const steps = Math.max(3, Math.round(s.hornSteps)); + const count = Math.max(3, Math.round(s.hornSections)); + const hornSections = []; + for (let i = 0; i <= count; i++) { + const t = i / count; + const x = rootX + reach * t; + // Cubic taper: a horn that tapers linearly reads as a traffic cone. + const r = Math.max(rootR * (1 - t * t * (3 - 2 * t)) * (1 - t * 0.15) + rootR * 0.06 * (1 - t), 1e-3); + const droop = s.hornDroop * t * t; + const ring = []; + for (let j = 0; j < steps; j++) { + const a = (j * TAU) / steps; + ring.push(new Vector3(x, Math.cos(a) * r, centreZ - droop + Math.sin(a) * r)); + } + hornSections.push(ring); + } + loftSections(surface, hornSections, { capFirst: false, capLast: true }); + } + + return finishSurface(surface, { creaseAngle: s.creaseAngle, axis: s.axis }); +} + +const GEOMETRY_FACTORIES = [ + createGearGeometry, + createPistonGeometry, + createSawbladeGeometry, + createPlateGeometry, + createBoltGeometry, + createAnvilGeometry +]; + +/** `hardSurfaceGeometry(HardShape.ANVIL, shape)` — the dispatcher form. */ +export function hardSurfaceGeometry(kind, shape) { + const factory = GEOMETRY_FACTORIES[kind]; + if (!factory) throw new Error(`HardSurface: unknown shape kind ${kind}`); + return factory(shape); +} + +/* ====================================================================== */ +/* §5 · The shape cache */ +/* ====================================================================== */ + +/** + * Rebuild geometry only when a number in the shape actually moved. + * + * The same trick `GrowthField#syncGeometry` uses, and for the same reason: an + * anvil is a live slider panel, the panel is polled every frame, and building + * it every frame would be four milliseconds of lathe work per frame for + * nothing. The change test compares a `Float64Array` rather than composing a + * key string, because at fifty abilities a template literal per shape per + * frame is fifty strings a frame for the collector to sweep up. + * + * **This cache is yours, not global.** One `ShapeCache` per ability, disposed + * with it. A shared cache would have to reference-count, and the first thing + * that would happen is `GrowthField` — which disposes the geometry it is + * handed the moment its own hash moves — quietly freeing a gear another + * ability was still drawing. If you are feeding `GrowthField`, do not use this + * at all: hand it `createGearGeometry` directly as its factory and let it own + * the rebuild, because it is already doing exactly this test one level up. + * + * @example + * this.cache = new ShapeCache(); + * // every frame — resolves live, rebuilds on change only + * this.mesh.geometry = this.cache.get(0, HardShape.GEAR, this._gearShape); + */ +export class ShapeCache { + constructor(options = {}) { + const { capacity = 8 } = options; + this.capacity = Math.max(1, Math.round(capacity)); + this._slots = new Map(); + /** True if the last `get()` rebuilt. Poll it to re-upload derived data. */ + this.changed = false; + } + + /** How many distinct geometries this cache is holding. */ + get size() { + return this._slots.size; + } + + /** + * @param {number|string} slot caller's own key — a variant index, usually + * @param {number} kind a `HardShape` + * @param {object} shape a shape object; every value must be a number + * @returns {THREE.BufferGeometry} owned by the cache; never dispose it yourself + */ + get(slot, kind, shape) { + let entry = this._slots.get(slot); + if (!entry) { + if (this._slots.size >= this.capacity) { + throw new Error(`HardSurface: ShapeCache is full at ${this.capacity} slots — raise capacity`); + } + entry = { kind: -1, keys: null, values: null, geometry: null }; + this._slots.set(slot, entry); + } + + const keys = entry.keys ?? Object.keys(shape); + let changed = entry.geometry === null || entry.kind !== kind || entry.keys === null; + if (!changed) { + for (let i = 0; i < keys.length; i++) { + if (+shape[keys[i]] !== entry.values[i]) { + changed = true; + break; + } + } + } + + this.changed = changed; + if (!changed) return entry.geometry; + + if (entry.keys === null) { + entry.keys = keys; + entry.values = new Float64Array(keys.length); + } + for (let i = 0; i < keys.length; i++) entry.values[i] = +shape[keys[i]]; + entry.kind = kind; + entry.geometry?.dispose(); + entry.geometry = hardSurfaceGeometry(kind, shape); + return entry.geometry; + } + + dispose() { + for (const entry of this._slots.values()) entry.geometry?.dispose(); + this._slots.clear(); + } +} + +/* ====================================================================== */ +/* §6 · GearTrain — the solver that makes the teeth mesh */ +/* ====================================================================== */ + +/** + * Canonical parameters for a gear train. Read fresh on every `solve()`. + * + * `teeth` is the interesting one: it is a *list of unitless counts* the nodes + * draw from, and each node stored a 0..1 pick at plant time rather than a + * count. Edit the list — with the clock stopped — and every gear re-teeths, + * re-sizes, re-spaces and re-phases in the same frame, still meshed. That is + * the roster's "stays interlocked when you drag the ratios", and it only works + * because nothing about a node is resolved until this object is read. + */ +export function gearTrainParams() { + return { + origin: null, // Vector3 on the floor — the first gear's centre + direction: null, // Vector3, unit, flat: bearing zero + side: null, // Vector3, unit, lateral + module: 0.16, // METRES of pitch diameter per tooth — the mesh condition + addendum: 1.0, // × module; must match the gear shape's `addendum` + teeth: null, // number[] of unitless tooth counts; defaults to [10, 14, 18] + spin: 1.6, // radians/second of the root gear + time: 0, // seconds — the ability's age + phase: 0, // 0..1 turns of the root gear at time zero + bearingSpread: 0.13, // ± turns each link may wander off the previous bearing + bearingBias: 0, // turns added to every link — curls the train + lift: 0.02, // metres the gears float above the floor + reverse: false // run the whole train backwards; a mode, not a dimension + }; +} + +const DEFAULT_TEETH = [10, 14, 18]; + +/** + * A chain of meshing spur gears. + * + * The two pieces of arithmetic worth stating, because getting either wrong is + * the failure everybody ships — teeth that pass straight through each other: + * + * **Spacing.** Two involute gears of the same module mesh at the standard + * centre distance `d = m(z₁ + z₂)/2`, which is exactly the sum of their pitch + * radii. Not their tip radii, which is the tempting wrong answer and leaves a + * visible gap of one whole tooth height. + * + * **Phase.** Rate alone is not enough. Two gears turning at the perfect + * ratio still interpenetrate unless a tooth of one is aimed at a *gap* of the + * other along the line of centres, and that is a constraint on the absolute + * angles, not on their derivatives. Number the teeth of gear *i* from its body + * angle: a tooth points along bearing `β` when `zᵢ(β − θᵢ)/2π` is an integer. + * Requiring gear 1's tooth index along `β` and gear 2's along `β + π` to sum + * to a half-integer gives + * + * θ₂ = β + π + (z₁/z₂)(β − θ₁) − π/z₂ + * + * and differentiating it recovers `ω₂ = −(z₁/z₂)ω₁` for free — which is the + * check that the constraint is the right one. The first version of this class + * set the rates from the tooth counts and left the phases at zero, and it was + * *nearly* right: the train counter-rotated correctly and the teeth ground + * straight through one another, which is a bug you can watch for a full minute + * before you see it. + * + * Because the phase is solved rather than integrated, a paused train re-phases + * under the sliders and a train started at `time = 0` is identical every cast. + * + * @example + * const train = new GearTrain({ capacity: 10 }); + * train.plant(6, seed); // onSpawn — the only dice roll + * train.solve(params); // every frame, including dt = 0 + */ +export class GearTrain { + constructor(options = {}) { + const { capacity = 12 } = options; + this.capacity = Math.max(1, Math.round(capacity)); + + /** Dice only. A metre in here would be invariant I1 broken in one line. */ + this._nodes = []; + for (let i = 0; i < this.capacity; i++) { + this._nodes.push({ + teethRoll: 0, // 0..1 pick into the live `teeth` list + bearingRoll: 0 // -1..1 of the live `bearingSpread` + }); + } + + this._count = 0; + // Resolved every solve(): parallel arrays, never reallocated. + this._teeth = new Int32Array(this.capacity); + this._pitch = new Float64Array(this.capacity); + this._tip = new Float64Array(this.capacity); + this._angle = new Float64Array(this.capacity); + this._rate = new Float64Array(this.capacity); + this._u = new Float64Array(this.capacity); // 2D position along `direction` + this._v = new Float64Array(this.capacity); // 2D position along `side` + this._bearing = new Float64Array(this.capacity); + this._handed = -1; + this._lift = 0; + } + + /** Gears currently in the train. */ + get count() { + return this._count; + } + + /** + * Roll a fresh train. Call from `onSpawn()`. The only dice in the class. + * + * @param {number} count gears, clamped to capacity + * @param {number} seed any number; the same seed gives the same train + */ + plant(count, seed = Math.random() * 100) { + this._count = clamp(Math.round(count), 0, this.capacity); + for (let i = 0; i < this._count; i++) { + const node = this._nodes[i]; + node.teethRoll = hash11(seed * 7.13 + i * 3.77); + node.bearingRoll = hash11(seed * 2.91 + i * 9.41) * 2 - 1; + } + return this._count; + } + + /** Forget the train; leaves the instance reusable. */ + clear() { + this._count = 0; + } + + /** + * Resolve every metre, radian and second from `p`. Call every frame. + * + * @param {object} p see `gearTrainParams()` + */ + solve(p) { + const teethList = Array.isArray(p.teeth) && p.teeth.length ? p.teeth : DEFAULT_TEETH; + const module = Math.max(p.module ?? 0.16, 1e-4); + const addendum = p.addendum ?? 1; + const spread = (p.bearingSpread ?? 0.13) * TAU; + const bias = (p.bearingBias ?? 0) * TAU; + const spin = (p.spin ?? 1.6) * (p.reverse ? -1 : 1); + const time = p.time ?? 0; + this._lift = p.lift ?? 0; + + const direction = p.direction; + const side = p.side; + // Handedness of the caller's basis decides the sign of the yaw we hand + // back; the 2D solve itself is blind to it. See `yawOf`. + this._handed = + direction && side ? Math.sign(direction.z * side.x - direction.x * side.z) || -1 : -1; + + let bearing = 0; + for (let i = 0; i < this._count; i++) { + const node = this._nodes[i]; + const pick = clamp(Math.floor(node.teethRoll * teethList.length), 0, teethList.length - 1); + const teeth = Math.max(4, Math.round(teethList[pick])); + this._teeth[i] = teeth; + this._pitch[i] = (module * teeth) / 2; + this._tip[i] = this._pitch[i] + addendum * module; + + if (i === 0) { + this._u[0] = 0; + this._v[0] = 0; + this._bearing[0] = 0; + this._angle[0] = (p.phase ?? 0) * TAU + spin * time; + this._rate[0] = spin; + continue; + } + + bearing += bias + node.bearingRoll * spread; + this._bearing[i] = bearing; + + // Standard centre distance: the sum of the PITCH radii. + const distance = this._pitch[i - 1] + this._pitch[i]; + this._u[i] = this._u[i - 1] + Math.cos(bearing) * distance; + this._v[i] = this._v[i - 1] + Math.sin(bearing) * distance; + + const zPrev = this._teeth[i - 1]; + const ratio = zPrev / teeth; + this._angle[i] = bearing + Math.PI + ratio * (bearing - this._angle[i - 1]) - Math.PI / teeth; + this._rate[i] = -ratio * this._rate[i - 1]; + } + } + + /** Tooth count of gear `i`, resolved this frame. */ + teethOf(i) { + return this._teeth[i]; + } + + /** Pitch radius in metres. Two meshing gears sit exactly this far apart, summed. */ + pitchRadiusOf(i) { + return this._pitch[i]; + } + + /** Tip radius in metres — half the footprint the instance scale must produce. */ + tipRadiusOf(i) { + return this._tip[i]; + } + + /** + * Uniform instance scale for a unit-space gear, in metres. + * + * Pass `gearPitchFraction(shape)` and it is exact for any profile, + * including one whose teeth have gone pointed and whose outer radius is + * therefore short of `pitch + addendum`. Pass nothing and you get the + * nominal tip diameter, which is right for every profile that has not. + */ + scaleOf(i, pitchFraction = 0) { + if (pitchFraction > 1e-6) return (this._pitch[i] * 2) / pitchFraction; + return this._tip[i] * 2; + } + + /** Body angle in the solver's own 2D frame, radians. */ + angleOf(i) { + return this._angle[i]; + } + + /** Angular rate in radians/second — signed, and alternating down the train. */ + rateOf(i) { + return this._rate[i]; + } + + /** + * The Y rotation to write into the instance, radians. + * + * The 2D solve measures angles from `direction` toward `side`; whether that + * is a positive rotation about world +Y depends on which way round the + * ability built its basis, so the sign is recovered from the basis itself + * every frame rather than assumed. An involute tooth is symmetric about its + * own centreline, so the mirrored train still meshes — only the sign of the + * spin would have been visible, and it would have been visible as gears + * turning the wrong way against a floor scour that turned the right way. + */ + yawOf(i) { + return this._handed * this._angle[i]; + } + + /** World centre of gear `i`. Writes and returns `out`. */ + positionOf(i, p, out) { + out.set(0, 0, 0); + if (p.origin) out.copy(p.origin); + if (p.direction) out.addScaledVector(p.direction, this._u[i]); + if (p.side) out.addScaledVector(p.side, this._v[i]); + out.y += this._lift; + return out; + } + + /** + * The pitch point between gear `i` and gear `i − 1` — where the teeth + * actually touch, and therefore where sparks, dust and a light belong. + * + * It is *not* the midpoint unless the two gears are the same size: it + * divides the centre distance in the ratio of the pitch radii. + */ + contactOf(i, p, out) { + if (i <= 0) return this.positionOf(0, p, out); + const a = this._pitch[i - 1]; + const b = this._pitch[i]; + const t = a + b > 1e-9 ? a / (a + b) : 0.5; + this.positionOf(i - 1, p, out); + this.positionOf(i, p, _v3a); + return out.lerp(_v3a, t); + } +} + +/* ====================================================================== */ +/* §7 · The material */ +/* ====================================================================== */ + +/** Which way the brushing runs. A mode, safe to capture. */ +export const BrushMode = Object.freeze({ + /** Straight, along `brushAxis`. Plates, blades, anything rolled or ground flat. */ + LINEAR: 0, + /** Round the axis. Gears, collars, anything that came off a lathe. */ + CIRCUMFERENTIAL: 1, + /** Out from the axis. Fly-cut faces, the top of an anvil. */ + RADIAL: 2 +}); + +export const BRUSH_MODE_NAMES = Object.freeze(['LINEAR', 'CIRCUMFERENTIAL', 'RADIAL']); + +/** + * Canonical parameters for the material. Every one is read on every `sync()`. + * + * Temperatures are in **kelvin** and they are real: 300 K is a cold workshop, + * 900 K is the first visible red in a dark room, 1150 K is cherry, 1450 K is + * forging heat, 1700 K is where steel starts to throw sparks and burn. The + * ramp between them is not authored (see `blackbodyGLSL`), which is why there + * is no `colorHot` picker here and why there should not be one — the whole + * value of the term is that nobody gets to place the yellow. + */ +export function hardSurfaceParams() { + return { + /* --- the metal --- */ + colorMetal: '#9aa1a9', // clean steel + colorDeep: '#3d4248', // the bottom of a pit + colorScale: '#2b2723', // mill scale, the blue-black oxide off the forge + colorPolish: '#e6edf5', // a worn edge, where the file has been + colorSpec: '#fff3e2', // the anisotropic highlight's own colour + roughness: 0.36, // base, before grain / pitting / wear + metalness: 0.94, + envIntensity: 1.0, // HDR probe gain + + /* --- brushing --- */ + brush: BrushMode.CIRCUMFERENTIAL, + brushAxisX: 0, // the part's own axis in LOCAL space; unit-space geometry + brushAxisY: 1, // is seated with its axis along +Y, so this is the default + brushAxisZ: 0, + anisotropy: 0.78, // 0 round highlight, 1 fully smeared along the grain + specular: 1.5, // gain on the anisotropic lobe + grain: 0.55, // how hard the brushing cuts into roughness + grainScale: 90, // grain frequency, cycles per unit of local space + grainStretch: 24, // how far a streak runs along the brush direction + + /* --- surface history --- */ + scale: 0.3, // mill scale coverage, 0..1 + scaleScale: 6.5, // its patch size + scaleSharp: 0.55, // 0 a smear, 1 a hard flake edge + pit: 0.35, // casting pits and corrosion + pitScale: 55, + wear: 0.6, // how bright the machined edges come up + wearGrain: 0.4, // how much the grain breaks the wear up + + /* --- heat --- */ + heat: 0, // 0..1 — the ONE control quench drives + heatCold: 300, // kelvin at heat = 0 — a cold workshop + heatHot: 2000, // kelvin at heat = 1 — welding heat, past the point steel burns + heatRef: 1250, // kelvin at which the emission term reaches 1 + heatExponent: 4, // Stefan-Boltzmann; 4 is the physical value + heatGlow: 2.4, // gain on the emission + heatTint: 0.8, // how far the albedo washes toward the hot colour + heatEdge: 0.22, // how much cooler an edge is — thin sections radiate faster + + /* --- globals, folded in by the ability --- */ + glow: 1, // settings.global.glow + shaderIntensity: 1, // settings.global.shaderIntensity + noiseFrequency: 1 // settings.global.noiseFrequency + }; +} + +/** + * The brushing tangent, in local space. Shared by both shader stages. + * + * It has to be shared: the vertex stage needs the tangent to transform into + * view space for the specular lobe, and the fragment stage needs the same + * direction in local space to squash the grain noise along. Computing them + * from two copies of the maths is how you get a highlight that runs at a + * slight angle to the streaks it is supposed to be lying in, which looks like + * a bug in the noise and is not. + */ +const brushGLSL = /* glsl */ ` +uniform float uBrushMode; +uniform vec3 uBrushAxis; + +vec3 hardBrushDir(vec3 localPos) { + vec3 axis = uBrushAxis; + float len = length(axis); + axis = len > 1e-5 ? axis / len : vec3(0.0, 1.0, 0.0); + vec3 t; + if (uBrushMode < 0.5) { + t = axis; + } else if (uBrushMode < 1.5) { + t = cross(axis, localPos); + } else { + t = localPos - axis * dot(localPos, axis); + } + // On the axis itself both of the derived modes collapse. Any consistent + // direction will do there, because there is no surface there either. + float tl = length(t); + return tl > 1e-5 ? t / tl : normalize(cross(axis, vec3(0.371, 0.629, 0.812))); +} +`; + +/** + * Blackbody colour on the Planckian locus, and the emission that goes with it. + * + * Kim et al. (2002)'s cubic fit gives CIE `x` from the temperature and a + * second cubic gives `y` from `x`; `xyY → XYZ → linear sRGB` finishes the job. + * That is a real locus rather than four authored stops, and the difference is + * visible in one specific place: the yellow. An authored white→orange gradient + * passes through a yellow that is too saturated and slightly green, because + * the straight line in RGB between white and orange does not follow the curve + * the locus takes. Steel cooling through 1400 K goes pale straw, not lemon. + * + * **Kim's fit stops at 1667 K and every temperature this material cares about + * is below it**, which is the trap. 1667 K is bright orange; forging heat is + * 1450 K, the first visible red is 900 K, and a quench spends its whole life + * under the fit. Clamping there — the first version — gave one flat orange + * across the entire ramp that simply dimmed, and a quench that dims is not a + * quench. + * + * Extrapolating the cubic instead is worse and is spectacular: below about + * 1200 K `x` runs off past the spectral locus, `y` collapses, and the metal + * cools through cherry into *magenta* and then into a colour that does not + * exist. So the low end gets its own fit — a quadratic in `1000/T` through + * the published locus points at 1667 K, 1000 K and 800 K, which lands within + * 0.005 of the true chromaticity at 1200 K. The `y` cubic needs no such + * treatment: it is a fit in `x` rather than in `T` and it tracks the locus to + * within 0.004 all the way down, which is worth knowing before anybody + * "fixes" it. + * + * The quadratic turns over at its vertex, 693 K, so the whole thing is clamped + * at 700 K. There is nothing down there: the `T⁴` term is 0.001 of the + * reference and the metal is black, which is exactly what the eye and a long + * exposure both report. + * + * The emission is `(T / T_ref)^n` with `n = 4` from Stefan-Boltzmann. It is a + * slider because the *visible* fraction rises faster than the total does — + * Wien's peak is still deep in the infrared at forging heat, so the visible + * tail grows more like `T⁶` over this range — and a director who wants the + * quench to snap out faster should be able to say so. + */ +const blackbodyGLSL = /* glsl */ ` +vec3 hardBlackbody(float kelvin) { + float t = clamp(kelvin, 700.0, 25000.0); + float i1 = 1.0 / t; + float i2 = i1 * i1; + float i3 = i2 * i1; + + float x; + if (t < 1667.0) { + // Below Kim's range: the low-temperature locus, fitted in 1000/T. + float u = 1000.0 * i1; + x = 0.32933 + 0.495206 * u - 0.171736 * u * u; + } else if (t < 4000.0) { + x = -0.2661239e9 * i3 - 0.2343589e6 * i2 + 0.8776956e3 * i1 + 0.179910; + } else { + x = -3.0258469e9 * i3 + 2.1070379e6 * i2 + 0.2226347e3 * i1 + 0.240390; + } + + float x2 = x * x; + float x3 = x2 * x; + float y; + // Kim's y is a fit in x, not in T, and it holds well past the end of the + // x fit it was published with — which is why the low branch above only had + // to replace x. + if (t < 2222.0) { + y = -1.1063814 * x3 - 1.34811020 * x2 + 2.18555832 * x - 0.20219683; + } else if (t < 4000.0) { + y = -0.9549476 * x3 - 1.37418593 * x2 + 2.09137015 * x - 0.16748867; + } else { + y = 3.0817580 * x3 - 5.87338670 * x2 + 3.75112997 * x - 0.37001483; + } + + y = max(y, 1e-3); + float bigY = 1.0; + float bigX = (x / y) * bigY; + float bigZ = ((1.0 - x - y) / y) * bigY; + + vec3 rgb = vec3( + 3.2404542 * bigX - 1.5371385 * bigY - 0.4985314 * bigZ, + -0.9692660 * bigX + 1.8760108 * bigY + 0.0415560 * bigZ, + 0.0556434 * bigX - 0.2040259 * bigY + 1.0572252 * bigZ + ); + rgb = max(rgb, vec3(0.0)); + // Normalise the hue; the brightness is the T^4 term's job, not the locus's. + return rgb / max(max(rgb.r, max(rgb.g, rgb.b)), 1e-4); +} +`; + +/** + * The same locus on the CPU, for a light colour or a particle tint. + * + * Sparks off hot steel are the same temperature as the steel, so the one + * number that drives the metal should drive them too — hard-coding an orange + * for the sparks is how a quench ends up with cherry-red metal throwing + * lemon-yellow sparks. + * + * @param {number} kelvin + * @param {THREE.Color} [out] + */ +export function blackbodyColor(kelvin, out = _colorScratch) { + const t = clamp(kelvin, 700, 25000); + const i1 = 1 / t; + const i2 = i1 * i1; + const i3 = i2 * i1; + let x; + if (t < 1667) { + const u = 1000 * i1; + x = 0.32933 + 0.495206 * u - 0.171736 * u * u; + } else if (t < 4000) { + x = -0.2661239e9 * i3 - 0.2343589e6 * i2 + 0.8776956e3 * i1 + 0.17991; + } else { + x = -3.0258469e9 * i3 + 2.1070379e6 * i2 + 0.2226347e3 * i1 + 0.24039; + } + const x2 = x * x; + const x3 = x2 * x; + let y; + if (t < 2222) y = -1.1063814 * x3 - 1.3481102 * x2 + 2.18555832 * x - 0.20219683; + else if (t < 4000) y = -0.9549476 * x3 - 1.37418593 * x2 + 2.09137015 * x - 0.16748867; + else y = 3.081758 * x3 - 5.8733867 * x2 + 3.75112997 * x - 0.37001483; + y = Math.max(y, 1e-3); + + const bigX = x / y; + const bigZ = (1 - x - y) / y; + let r = 3.2404542 * bigX - 1.5371385 - 0.4985314 * bigZ; + let g = -0.969266 * bigX + 1.8760108 + 0.041556 * bigZ; + let b = 0.0556434 * bigX - 0.2040259 + 1.0572252 * bigZ; + r = Math.max(r, 0); + g = Math.max(g, 0); + b = Math.max(b, 0); + const peak = Math.max(r, g, b, 1e-4); + out.setRGB(r / peak, g / peak, b / peak); + return out; +} + +/** + * Temperature in kelvin for a 0..1 heat, matching the shader exactly. + * + * Use it when you need the *same* number the metal is using — a light's + * colour, a spark's tint, the emissive strength of a decal underneath. + */ +export function heatToKelvin(heat, p) { + const cold = p?.heatCold ?? 300; + const hot = p?.heatHot ?? 2000; + return cold + (hot - cold) * saturate(heat); +} + +/** + * Brushed anisotropic steel with a blackbody ramp, on a real + * `MeshStandardMaterial`. + * + * ### Why the anisotropy is hand-rolled + * + * `MeshPhysicalMaterial` has had native anisotropy since r155 and it is + * better than this one. It is also unreachable here: it reads its direction + * from the tangent frame, which three only switches on (`USE_TANGENT`) + * alongside a **normal map** — and a normal map is a texture, which invariant + * I2 forbids outright. Supplying a `tangent` attribute without the map leaves + * the define off and the anisotropy silently isotropic, which is exactly the + * failure that cost the afternoon: the material compiled, `material.anisotropy + * = 0.8` was set, and nothing whatsoever changed on screen. + * + * So the lobe is a **Ward** anisotropic specular, evaluated against the + * stage's key light (`frame.uLightDir`) with the tangent carried through from + * the vertex stage, and added to `totalEmissiveRadiance`. Two honest + * consequences, neither worth fixing: + * + * - it is not shadowed, because the injection point is before the light loop + * and the shadow factor does not exist yet. On a spinning gear at forge + * temperature nobody has ever noticed; + * - it is one light. The stage has one key light and the rest is probe. + * + * The tangent is transformed by `mat3(modelViewMatrix)`, **not** by + * `normalMatrix`. A tangent is a direction *along* the surface and transforms + * like a position difference; the inverse-transpose is for normals. It matters + * here more than usual because `GrowthField` scales footprint and height + * independently, so a squat gear is genuinely non-uniformly scaled and the two + * matrices disagree by a visible amount. + * + * ### What each surface term is doing + * + * | term | roughness | metalness | albedo | emissive | + * | --- | --- | --- | --- | --- | + * | grain | ± streaks along the brush | — | — | modulates the lobe | + * | pitting | up | — | toward `colorDeep` | — | + * | mill scale | up | **down** — oxide is a dielectric | toward `colorScale` | kills the lobe | + * | edge wear | down | — | toward `colorPolish` | lifts the lobe | + * | heat | — | — | toward the blackbody colour | `(T/T_ref)⁴` | + * + * The metalness drop under mill scale is the one that earns its line: scale is + * an oxide, oxides are not conductors, and a scaled patch that keeps + * `metalness = 0.94` reads as a dirty mirror instead of as a crust. + * + * ### Attributes it reads + * + * | attribute | source | meaning | + * | --- | --- | --- | + * | `aEdge` | this module's generators | 1 on a machined edge, 0 on a face | + * | `aHeat` | yours, instanced, optional | added to `heat` per instance | + * + * Both default to zero when absent, which is why `aHeat` is an **offset** + * rather than an absolute: an unset attribute reads as 0 in WebGL, and an + * absolute would mean every mesh without one was ice cold. + * + * @param {object} [options] + * @param {object} [options.environment] `world/Environment.js`; routes through + * `registerShadowCasterWithPatch` so CSM's own patch is not clobbered + * @param {boolean} [options.flatShading=false] + * @returns {THREE.MeshStandardMaterial} with `userData.uniforms` and `userData.sync` + */ +export function createHardSurfaceMaterial(options = {}) { + const { environment = null, flatShading = false } = options; + + const material = new MeshStandardMaterial({ + color: 0xffffff, + roughness: 0.36, + metalness: 0.94, + flatShading + }); + + const uniforms = { + uTime: frame.uTime, + uLightDir: frame.uLightDir, + uColorMetal: { value: new Color('#9aa1a9') }, + uColorDeep: { value: new Color('#3d4248') }, + uColorScale: { value: new Color('#2b2723') }, + uColorPolish: { value: new Color('#e6edf5') }, + uColorSpec: { value: new Color('#fff3e2') }, + uBrushMode: { value: BrushMode.CIRCUMFERENTIAL }, + uBrushAxis: { value: new Vector3(0, 1, 0) }, + uAniso: { value: 0.78 }, + uSpecular: { value: 1.5 }, + uGrain: { value: 0.55 }, + uGrainScale: { value: 90 }, + uGrainStretch: { value: 24 }, + uScale: { value: 0.3 }, + uScaleScale: { value: 6.5 }, + uScaleSharp: { value: 0.55 }, + uPit: { value: 0.35 }, + uPitScale: { value: 55 }, + uWear: { value: 0.6 }, + uWearGrain: { value: 0.4 }, + uHeat: { value: 0 }, + uHeatCold: { value: 300 }, + uHeatHot: { value: 2000 }, + uHeatRef: { value: 1250 }, + uHeatExponent: { value: 4 }, + uHeatGlow: { value: 2.4 }, + uHeatTint: { value: 0.8 }, + uHeatEdge: { value: 0.22 } + }; + + const patch = (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = replaceChunk( + shader.vertexShader, + '#include ', + `#include + attribute float aEdge; + attribute float aHeat; + varying vec3 vHardLocal; + varying vec3 vHardTangent; + varying float vHardEdge; + varying float vHardHeat; + ${brushGLSL}` + ); + + shader.vertexShader = replaceChunk( + shader.vertexShader, + '#include ', + `#include + vHardLocal = transformed; + vHardEdge = aEdge; + vHardHeat = aHeat; + { + vec3 hardT = hardBrushDir(transformed); + #ifdef USE_INSTANCING + hardT = mat3(instanceMatrix) * hardT; + #endif + // modelViewMatrix, NOT normalMatrix — see the header. + vHardTangent = mat3(modelViewMatrix) * hardT; + }` + ); + + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + uniform vec3 uColorMetal; + uniform vec3 uColorDeep; + uniform vec3 uColorScale; + uniform vec3 uColorPolish; + uniform vec3 uColorSpec; + uniform vec3 uLightDir; + uniform float uAniso; + uniform float uSpecular; + uniform float uGrain; + uniform float uGrainScale; + uniform float uGrainStretch; + uniform float uScale; + uniform float uScaleScale; + uniform float uScaleSharp; + uniform float uPit; + uniform float uPitScale; + uniform float uWear; + uniform float uWearGrain; + uniform float uHeat; + uniform float uHeatCold; + uniform float uHeatHot; + uniform float uHeatRef; + uniform float uHeatExponent; + uniform float uHeatGlow; + uniform float uHeatTint; + uniform float uHeatEdge; + varying vec3 vHardLocal; + varying vec3 vHardTangent; + varying float vHardEdge; + varying float vHardHeat; + ${noiseGLSL} + ${brushGLSL} + ${blackbodyGLSL} + + /* Filled once per fragment by hardResolve(), read by three chunks. + Globals rather than a struct returned three times over: the grain is + four octaves of noise and paying for it in , + again in and again after + was measurably the most expensive line in the + material. */ + float hardGrainV; + float hardPitV; + float hardScaleV; + float hardWearV; + + void hardResolve() { + vec3 t = hardBrushDir(vHardLocal); + vec3 q = vHardLocal * uGrainScale; + // Squash the sample along the brush so the noise draws streaks that + // run WITH the grain. The first version scaled the whole coordinate + // and produced a uniform sandblast: correct roughness statistics, + // no direction, and therefore no read at all. + float stretch = max(uGrainStretch, 1.0); + q += t * dot(q, t) * (1.0 / stretch - 1.0); + hardGrainV = fbm3(q); + + hardPitV = smoothstep(0.42, 0.95, ridged(vHardLocal * uPitScale, 4)); + + float flake = fbm3(vHardLocal * uScaleScale + 17.3) * 0.5 + 0.5; + float edgeW = mix(0.34, 0.03, clamp(uScaleSharp, 0.0, 1.0)); + hardScaleV = smoothstep(0.5 - edgeW, 0.5 + edgeW, flake); + + // The grain breaks the wear up so a chamfer is a broken glint rather + // than a drawn line, which is the tell of a bevel modifier. + hardWearV = clamp(vHardEdge * (1.0 - clamp(uWearGrain, 0.0, 1.0) * (hardGrainV * 0.5 + 0.5)), 0.0, 1.0); + }` + ); + + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + hardResolve(); + roughnessFactor = clamp( + roughnessFactor + + hardGrainV * uGrain * 0.22 + + hardPitV * uPit * 0.4 + + hardScaleV * uScale * 0.4 + - hardWearV * uWear * 0.28, + 0.035, 1.0);` + ); + + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + metalnessFactor = clamp(metalnessFactor - hardScaleV * uScale * 0.6, 0.0, 1.0);` + ); + + shader.fragmentShader = replaceChunk( + shader.fragmentShader, + '#include ', + `#include + { + vec3 body = uColorMetal; + body = mix(body, uColorDeep, hardPitV * clamp(uPit, 0.0, 1.0)); + body = mix(body, uColorScale, hardScaleV * clamp(uScale, 0.0, 1.0)); + body = mix(body, uColorPolish, hardWearV * clamp(uWear, 0.0, 1.0) * 0.7); + + /* --- anisotropic specular, Ward, one key light --- */ + vec3 N = normalize(normal); + vec3 V = normalize(vViewPosition); + vec3 L = normalize((viewMatrix * vec4(uLightDir, 0.0)).xyz); + vec3 X = vHardTangent - N * dot(vHardTangent, N); + float xl = length(X); + X = xl > 1e-4 ? X / xl : normalize(cross(N, vec3(0.0, 0.0, 1.0)) + vec3(1e-4)); + vec3 B = cross(N, X); + + float ndl = dot(N, L); + float ward = 0.0; + if (ndl > 0.0) { + vec3 H = normalize(L + V); + float ndv = max(dot(N, V), 1e-3); + float ndh = max(dot(N, H), 1e-3); + float rough = clamp(roughnessFactor, 0.03, 1.0); + float alpha = rough * rough; + float aspect = sqrt(1.0 - 0.9 * clamp(uAniso, 0.0, 1.0)); + float ax = max(alpha / max(aspect, 1e-3), 2e-3); + float ay = max(alpha * aspect, 2e-3); + float hx = dot(H, X) / ax; + float hy = dot(H, B) / ay; + ward = exp(-(hx * hx + hy * hy) / (ndh * ndh)) / + (4.0 * PI * ax * ay * sqrt(max(ndl * ndv, 1e-4))); + // Ward has no shadowing term and goes to infinity at grazing. + ward = min(ward, 24.0) * ndl; + } + ward *= (1.0 - hardScaleV * clamp(uScale, 0.0, 1.0) * 0.85); + ward *= 1.0 + hardWearV * clamp(uWear, 0.0, 1.0) * 0.6; + + /* --- blackbody --- */ + // Edges run cooler: a chamfer has more surface per unit of steel + // behind it, so it is the first thing to go black in a quench and the + // last thing to come up in a fire. + float heat = clamp(uHeat + vHardHeat - vHardEdge * uHeatEdge, 0.0, 1.0); + float kelvin = mix(uHeatCold, uHeatHot, heat); + vec3 hot = hardBlackbody(kelvin); + float emit = pow(max(kelvin, 1.0) / max(uHeatRef, 1.0), max(uHeatExponent, 0.1)); + + body = mix(body, hot, clamp(emit * uHeatTint, 0.0, 1.0)); + diffuseColor.rgb *= body; + + vec3 glow = hot * emit * uHeatGlow; + glow += uColorSpec * ward * uSpecular; + // Reinhard ceiling, as in IceMaterial: forging heat and a specular + // hit land on the same pixel constantly and the sum runs past 30. + glow /= 1.0 + glow * 0.2; + + totalEmissiveRadiance += glow; + }` + ); + }; + + if (environment) environment.registerShadowCasterWithPatch(material, patch); + else patchOnBeforeCompile(material, patch); + + // The convention the harness's pause test looks for. Without this, thirty + // working sliders are reported as dead. + material.userData.uniforms = uniforms; + material.userData.sync = (p) => syncHardSurfaceMaterial(material, p); + return material; +} + +/** Memoised colour writes — `getColor` caches, this avoids even the map hit. */ +function writeColor(uniform, hex, cacheKey, cache) { + if (cache[cacheKey] === hex) return; + cache[cacheKey] = hex; + uniform.value.copy(getColor(hex)); +} + +/** + * Push a params object into a material built by `createHardSurfaceMaterial`. + * + * Call it **every frame**, including a zero-length one. Nothing here is + * cached against time, so a paused ingot cools under the `heat` slider and a + * paused gear re-brushes under `grainScale`. + */ +export function syncHardSurfaceMaterial(material, p = {}) { + const u = material.userData.uniforms; + if (!u) return material; + const cache = (material.userData.colorCache ??= {}); + + const glow = p.glow ?? 1; + const intensity = p.shaderIntensity ?? 1; + const frequency = p.noiseFrequency ?? 1; + + writeColor(u.uColorMetal, p.colorMetal ?? '#9aa1a9', 'metal', cache); + writeColor(u.uColorDeep, p.colorDeep ?? '#3d4248', 'deep', cache); + writeColor(u.uColorScale, p.colorScale ?? '#2b2723', 'scale', cache); + writeColor(u.uColorPolish, p.colorPolish ?? '#e6edf5', 'polish', cache); + writeColor(u.uColorSpec, p.colorSpec ?? '#fff3e2', 'spec', cache); + + u.uBrushMode.value = p.brush ?? BrushMode.CIRCUMFERENTIAL; + u.uBrushAxis.value.set(p.brushAxisX ?? 0, p.brushAxisY ?? 1, p.brushAxisZ ?? 0); + u.uAniso.value = saturate(p.anisotropy ?? 0.78); + u.uSpecular.value = (p.specular ?? 1.5) * intensity; + u.uGrain.value = (p.grain ?? 0.55) * intensity; + u.uGrainScale.value = (p.grainScale ?? 90) * frequency; + u.uGrainStretch.value = Math.max(p.grainStretch ?? 24, 1); + u.uScale.value = saturate(p.scale ?? 0.3); + u.uScaleScale.value = (p.scaleScale ?? 6.5) * frequency; + u.uScaleSharp.value = saturate(p.scaleSharp ?? 0.55); + u.uPit.value = saturate(p.pit ?? 0.35); + u.uPitScale.value = (p.pitScale ?? 55) * frequency; + u.uWear.value = saturate(p.wear ?? 0.6); + u.uWearGrain.value = saturate(p.wearGrain ?? 0.4); + + u.uHeat.value = saturate(p.heat ?? 0); + u.uHeatCold.value = Math.max(p.heatCold ?? 300, 1); + u.uHeatHot.value = Math.max(p.heatHot ?? 2000, u.uHeatCold.value + 1); + u.uHeatRef.value = Math.max(p.heatRef ?? 1250, 1); + u.uHeatExponent.value = Math.max(p.heatExponent ?? 4, 0.1); + u.uHeatGlow.value = (p.heatGlow ?? 2.4) * glow; + u.uHeatTint.value = saturate(p.heatTint ?? 0.8); + u.uHeatEdge.value = saturate(p.heatEdge ?? 0.22); + + material.roughness = clamp(p.roughness ?? 0.36, 0.02, 1); + material.metalness = saturate(p.metalness ?? 0.94); + material.envMapIntensity = p.envIntensity ?? 1; + return material; +} + +/* ====================================================================== */ +/* §8 · GrindContact — sparks that leave at the tangent */ +/* ====================================================================== */ + +/** + * Canonical parameters for the grinding-spark solver. + * + * The speeds are the only awkward ones: they are metres per second, and they + * are *derived* from the rim velocity the caller hands in, which is itself + * derived from a live radius and a live spin rate. That chain never touches + * the CPU's memory between frames, so it is I1-clean — the moment you cache a + * rim speed at spawn it is not. + */ +export function grindParams() { + return { + lift: 0.05, // metres the jets start off the surface, along the normal + bounce: 0.55, // 0..1 of the into-surface velocity that comes back out + rise: 0.3, // 0..1 extra tilt away from the surface — the rooster tail + speedGain: 0.55, // spark speed as a fraction of the rim speed + speedFloor: 1.2, // metres/second, so a stalled blade still throws something + speedCeiling: 26, // metres/second + fan: 0.6, // radians the jets fan through, in the tangent/normal plane + swing: 0.22, // radians of fan across it + graze: 0.04, // sine of the shallowest angle a jet may leave the surface at + jets: 5, // sub-directions per emission; each is one emit() call + spread: 0.1, // handed straight to the particle system, per jet + speedVariance: 0.5, // ditto + drift: 0.12 // fraction of the rim velocity added as `inherit` + }; +} + +/** + * Turn a contact into spark emission parameters. + * + * ### The whole point + * + * A saw that throws sparks radially is a firework. Real grinding sparks leave + * at the **contact tangent** — they are lumps of the workpiece that were + * travelling with the tooth when it let go of them, so they carry off the + * tooth's velocity, and the tooth's velocity at the rim is tangential by + * definition. Point them along the wheel's radius and you have drawn a + * dandelion; point them along `ω × r` and you have drawn an angle grinder, + * from the same particle system with the same colours. + * + * The one correction on top of that: a tooth at the contact is usually driving + * *into* the workpiece, so part of `ω × r` points below the surface. Sparks do + * not tunnel. The component into the surface is reflected back out with a + * restitution (`bounce`), and a little extra `rise` tilts the whole sheaf away + * from the floor, which is what produces the arc every photograph of a grinder + * shows. Both are sliders because the ratio between them is the difference + * between a cut-off wheel and a bench grinder. + * + * ### Using it + * + * ```js + * // once, at construction + * this.grind = new GrindContact(); + * // every frame, after you know where the blade is touching + * GrindContact.rimVelocity(_vel, _axis, spinRate, _contact, _centre); + * this.grind.solve(_contact, _normal, _vel, this._grindParams); + * for (let j = 0; j < this.grind.jets; j++) { + * this.grind.jet(j, _emit); + * _emit.time = this.age; + * _emit.tint = blackbodyColor(1900); + * this.sparks.emit(count, _emit); + * } + * ``` + * + * `jet()` writes the caller's emit object and points its vectors at the + * solver's own scratch. `ParticleSystem#emit` reads and never retains, which + * is the contract that makes this allocation-free; do not keep the object. + */ +export class GrindContact { + constructor() { + this.origin = new Vector3(); + /** The deflected contact tangent — the sheaf's centreline. */ + this.direction = new Vector3(); + /** Unit surface normal, as handed in. */ + this.normal = new Vector3(0, 1, 0); + /** Perpendicular to both — the axis the fan swings about. */ + this.binormal = new Vector3(1, 0, 0); + /** Metres/second at the rim, before `speedGain`. */ + this.rimSpeed = 0; + /** Metres/second the sparks actually leave at. */ + this.speed = 0; + /** How many `jet()` calls this solve wants. */ + this.jets = 1; + + this._inherit = new Vector3(); + this._jet = new Vector3(); + this._spread = 0.1; + this._variance = 0.5; + this._fan = 0.6; + this._swing = 0.22; + this._graze = 0.04; + } + + /** + * The velocity of a point on a spinning rim: `v = ω × r`. + * + * Static because it is the caller's job to know where the blade is; this is + * only the cross product, kept here so nobody writes it with the operands + * the other way round, which produces sparks leaving backwards and looks + * almost right. + * + * @param {THREE.Vector3} out + * @param {THREE.Vector3} axis spin axis, unit + * @param {number} rate radians/second, signed + * @param {THREE.Vector3} point the contact, world + * @param {THREE.Vector3} centre the hub, world + */ + static rimVelocity(out, axis, rate, point, centre) { + _v3a.copy(axis).normalize().multiplyScalar(rate); + _v3b.copy(point).sub(centre); + return out.copy(_v3a).cross(_v3b); + } + + /** + * Resolve one contact. Every metre and second comes out of `p`, every frame. + * + * @param {THREE.Vector3} contact the touch point, world + * @param {THREE.Vector3} normal the *workpiece's* surface normal, world + * @param {THREE.Vector3} rimVel the tooth's velocity there, world, m/s + * @param {object} p see `grindParams()` + */ + solve(contact, normal, rimVel, p = {}) { + const lift = p.lift ?? 0.05; + const bounce = saturate(p.bounce ?? 0.55); + const rise = saturate(p.rise ?? 0.3); + + this.normal.copy(normal); + if (this.normal.lengthSq() < 1e-8) this.normal.set(0, 1, 0); + this.normal.normalize(); + + this.rimSpeed = rimVel.length(); + this.origin.copy(contact).addScaledVector(this.normal, lift); + + // The tangent, with whatever was heading into the workpiece bounced back + // out of it. `into` is negative when the tooth is driving downward. + _v3c.copy(rimVel); + const into = _v3c.dot(this.normal); + if (into < 0) _v3c.addScaledVector(this.normal, -(1 + bounce) * into); + if (_v3c.lengthSq() < 1e-10) _v3c.copy(this.normal); + _v3c.normalize(); + // The rooster tail: tilt the whole sheaf off the surface. + _v3c.addScaledVector(this.normal, rise).normalize(); + this.direction.copy(_v3c); + + _v3d.copy(this.normal).cross(this.direction); + if (_v3d.lengthSq() < 1e-10) { + // Sparks leaving straight along the normal have no preferred fan plane. + _v3d.set(this.normal.y, this.normal.z, this.normal.x).cross(this.normal); + } + this.binormal.copy(_v3d).normalize(); + + const gain = p.speedGain ?? 0.55; + const floor = p.speedFloor ?? 1.2; + const ceiling = p.speedCeiling ?? 26; + this.speed = clamp(this.rimSpeed * gain, floor, Math.max(ceiling, floor)); + + this._inherit.copy(rimVel).multiplyScalar(p.drift ?? 0.12); + this.jets = Math.max(1, Math.round(p.jets ?? 5)); + this._fan = p.fan ?? 0.6; + this._swing = p.swing ?? 0.22; + this._graze = Math.max(p.graze ?? 0.04, 0); + this._spread = p.spread ?? 0.1; + this._variance = p.speedVariance ?? 0.5; + return this; + } + + /** + * Fill an emit object for jet `index` of `this.jets`. + * + * The jets are spread deterministically across the fan rather than + * randomly: a random fan re-rolls its shape every frame and the stream + * shimmers, where a fixed fan with random *particles* inside it reads as one + * continuous sheaf, which is what a grinder throws. + */ + jet(index, emit) { + const t = this.jets > 1 ? index / (this.jets - 1) - 0.5 : 0; + const angle = t * this._fan; + // Swing is hashed off the index so the sheaf is not a flat fan; it is the + // same number every frame, so the sheaf itself does not flicker. + const swing = (hash11(index * 12.9898 + 4.1) * 2 - 1) * this._swing; + + this._jet.copy(this.direction).applyAxisAngle(this.binormal, angle); + this._jet.applyAxisAngle(this.normal, swing).normalize(); + + // The fan sweeps in the plane containing the normal, so its flattest jet + // can end up pointing *into* the workpiece — and a spark emitted into the + // floor is a spark you never see, which reads as the stream thinning out + // on one side for no reason. Lifting it back to a grazing angle is what + // the surface would have done to it anyway. + const under = this._graze - this._jet.dot(this.normal); + if (under > 0) this._jet.addScaledVector(this.normal, under).normalize(); + + emit.position = this.origin; + emit.direction = this._jet; + emit.speed = this.speed; + emit.spread = this._spread; + emit.speedVariance = this._variance; + emit.inherit = this._inherit; + return emit; + } +} diff --git a/src/vfx/InkDiffusion.js b/src/vfx/InkDiffusion.js new file mode 100644 index 0000000..8fc5b99 --- /dev/null +++ b/src/vfx/InkDiffusion.js @@ -0,0 +1,1062 @@ +import { + Color, + DoubleSide, + Mesh, + NormalBlending, + ShaderMaterial, + Vector2, + Vector3, + Vector4 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { acquireGroundQuad, releaseGroundQuad } from './quads.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { getColor } from '../utils/color.js'; + +/* ====================================================================== */ +/* InkDiffusion — ink spreading on the floor, unstably */ +/* ====================================================================== */ + +/** + * What the front is doing. + * + * A `#define`, like `GroundField`'s mode, so the satellite loop and the crown + * do not exist at all in a bloom and the bloom's octaves do not exist in a + * wash. Fixed for the lifetime of the instance. + */ +export const InkMode = Object.freeze({ + /** Isotropic, unstable, branching. A drop of ink in still water. */ + BLOOM: 0, + /** Directional mass, crown of spikes, satellite droplets. A flung blob. */ + SPLATTER: 1, + /** Stable and soft-edged. A laid wash, for backing paper and underlays. */ + WASH: 2 +}); + +/** Names in enum order, for an editor dropdown. */ +export const INK_MODE_NAMES = ['BLOOM', 'SPLATTER', 'WASH']; + +/* ---------------------------------------------------------------------- */ +/* Shaders */ +/* ---------------------------------------------------------------------- */ + +const INK_VERTEX = /* glsl */ ` + uniform vec2 uQuadSize; // metres, the full width and depth of the quad + + varying vec2 vMetres; // metres from the anchor, in the field's own frame + varying vec3 vWorld; + varying float vViewZ; + + void main() { + // The unit quad is +/-0.5 in x and z after its rotateX, so this is metres + // from the anchor with the field's local +z pointing down the travel + // vector. Everything in the fragment shader is in metres; nothing is in UV. + vMetres = position.xz * uQuadSize; + + vec4 world = modelMatrix * vec4(position, 1.0); + vWorld = world.xyz; + vec4 mv = viewMatrix * world; + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/** + * The whole field. Nothing here is a simulation and nothing here is a texture. + * + * ## The fingering instability + * + * Ink dropped into water does not grow as a disc. The interface between a thin + * fluid pushing into a thick one is **unstable**: any bulge in the front sees a + * steeper local gradient, moves faster, and becomes a finger. Surface tension + * fights back through the curvature term, which is what stops the whole rim + * dissolving into fuzz. + * + * The part that has to be modelled — the part that separates a bloom from a + * wobbly disc — is not that the front is rough. It is the **order the scales + * arrive in**. A lobe two metres across cannot exist on a blob that is not yet + * two metres across; the coarse modes are physically inadmissible until the + * front has grown into them. So the picture goes: a young bloom is a small + * crinkled disc, and a mature one is a handful of enormous branching lobes with + * that same crinkle still riding on their tips. Watch a real one and that is + * exactly what it does. + * + * `fingers()` implements that and nothing else: + * + * - five octaves, wavelengths `uCoarse`, `uCoarse/2`, `uCoarse/4`, …; + * - octave *k* is **admitted** once the front radius passes `uOnset * L_k`, so + * the finest octave is live almost immediately and the coarsest is the last + * to arrive; + * - after admission its amplitude grows like `exp(uGrowth * travel / L_k)`, + * which is the linear instability's exponential; + * - growth **saturates** at `uGrowthMax`, so a mature mode's amplitude settles + * at roughly its own wavelength. Without the saturation the first octave to + * mature eats everything and the blob is two lobes for ever; with it, every + * mature scale contributes in proportion to its size and the result is + * self-similar and branchy. + * + * Two versions of this were wrong before this one, in different ways: + * + * - `r + amplitude * fbm(atan(p.y, p.x))`. Sampling on the **bearing** hands + * every radius along a bearing the same value, so the "fingers" were + * dead-straight radial spokes of constant width from the centre to the rim — + * a firework, not a bloom. No number of octaves fixes it, because the error + * is in the domain and not in the spectrum. The noise here is sampled in + * two-dimensional metres, so a finger can bend and two fingers born at + * different radii do not line up. + * - all five octaves live from t = 0 at fixed amplitudes. That is an fbm ring, + * and the giveaway is that it is *the same shape at every size*: you can see + * that the blob is being scaled rather than grown. Admitting the octaves by + * radius is the whole of the difference, and it costs one `smoothstep`-free + * `max()` per octave. + * + * ## Why the crown *is* allowed to be spokes + * + * In `SPLATTER` the leading edge grows a crown of teeth, and those are + * bearing-indexed on purpose. A crown is a Rayleigh–Plateau breakup of an + * expanding *rim* — it genuinely is periodic in bearing, its teeth genuinely + * are radial, and its tooth count genuinely does rise with the rim's radius + * (`teeth = 2 pi r / uCrownSpacing`). The rule was never "never index on + * bearing", it was "do not index on bearing when the physics is areal". + * + * ## Wet and dry + * + * A fragment's ink arrived when the front radius equalled its distance. The + * front law `r = uSpread * t^uSpreadPower` inverts in closed form, so + * `arrivalOf(d)` gives the arrival time exactly, `uAge - arrival` gives the age + * of the film at every point, and the gloss is `exp(-age / uDryTime)`. No + * history buffer, no per-fragment state, and the whole drying pattern + * re-resolves when a paused author drags `dryTime`. + * + * ## The anti-glow clamp + * + * `UnrealBloomPass` runs on linear scene colour before the tone map, so its + * threshold (`settings.post.bloomThreshold`, 0.88) is a linear luminance. + * `uCeiling` hard-clamps this material's luminance below it. The wet gloss is + * the one specular term in the school and it is inside that clamp — which is + * the point: a reflection that cannot exceed the bloom threshold is an + * observation about a wet surface, not an emission. + */ +function inkFragment(mode, sources, satellites) { + return /* glsl */ ` + #define INK_MODE ${mode} + #define INK_SOURCES ${sources} + #define INK_SATELLITES ${satellites} + #define TAU 6.283185307179586 + + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform vec3 uLightDir; + + uniform float uAge; // seconds since the cast began + uniform float uSeed; + uniform float uRadius; // metres, the authored extent of the field + uniform vec3 uAxisAlong; // world unit vector the field's local +z maps to + uniform vec3 uAxisAcross; // world unit vector its local +x maps to + + /* --- the spread --- */ + uniform float uSpread; // metres of front radius at t = 1 s + uniform float uSpreadPower; // r ~ t^power; 0.5 is Fickian + uniform float uEdge; // metres, the width of the interface + uniform float uClipSoft; // metres the mass fades out over at uRadius + uniform float uSources; // live nuclei + uniform float uSourceScatter; // fraction of the radius the extra nuclei scatter over + uniform float uSourceDelay; // seconds each later nucleus starts behind the first + uniform vec4 uSourceDice[INK_SOURCES]; // x,y scatter in -1..1 · z noise phase · w crown phase + + /* --- the instability --- */ + uniform float uFinger; // overall amplitude, 0 gives a disc + uniform float uFingerMax; // cap, as a fraction of the front radius + uniform float uCoarse; // metres, the coarsest (last-admitted) wavelength + uniform float uOnset; // front radii per wavelength before a mode is admitted + uniform float uGrowth; // e-folds per wavelength of front travel + uniform float uGrowthMax; // saturation of that growth + + /* --- the film --- */ + uniform float uCore; // density floor inside the blob + uniform float uFalloff; // metres of e-folding out from a nucleus + uniform float uFilm; // alpha floor inside the coverage + uniform float uGranulation; // pigment mottle + uniform float uGranScale; // features per metre + uniform float uRing; // strength of the deposition line at the interface + uniform float uRingWidth; // metres + + /* --- wet and dry --- */ + uniform float uDryTime; // seconds for the gloss to fall to 1/e + uniform float uWetDarken; // how far the wet film pulls toward colorWet + uniform float uGloss; // specular strength on the wet film + uniform float uGlossPower; // its tightness + uniform float uMeniscus; // how far the film's normal tips at the interface + + /* --- splatter --- */ + uniform float uMassAlong; // metric stretch down the travel vector + uniform float uMassAcross; // and across it + uniform float uMassLead; // fraction of the front the mass sits forward by + uniform float uMassRear; // >1 blunts the trailing edge + uniform float uCrown; // fraction of the front the teeth add + uniform float uCrownSpacing; // metres of rim per tooth + uniform float uCrownSharp; // tooth narrowness + uniform float uCrownGate; // how tightly the crown is held to the leading arc + uniform float uSatellites; // live droplets + uniform float uSatMin; // metres, smallest droplet radius + uniform float uSatMax; // metres, largest + uniform float uSatAlpha; // exponent of the size power law + uniform float uThrowNear; // metres, nearest droplet + uniform float uThrowFar; // metres, furthest + uniform float uThrowCurve; // bias of the distance draw + uniform float uThrowSpread; // lateral cone, as a fraction of the distance + uniform float uSatTail; // tail length, in droplet radii + uniform float uSatDelay; // seconds the furthest droplet lands behind the mass + uniform float uSatJitter; // seconds of per-droplet slop on that + uniform float uSatPop; // seconds a droplet takes to appear + uniform vec4 uSatDice[INK_SATELLITES]; // x size draw · y lateral draw · z delay dice · w spare + + /* --- the mark --- */ + uniform float uOpacity; + uniform float uFade; + uniform float uCeiling; // max linear luminance — the anti-bloom clamp + uniform float uSoftFade; // metres of depth feather + uniform float uTint; + uniform float uTintDensity; + uniform vec3 uColorWash; + uniform vec3 uColorBody; + uniform vec3 uColorDeep; + uniform vec3 uColorPool; + uniform vec3 uColorRing; + uniform vec3 uColorWet; + uniform vec3 uColorGloss; + + varying vec2 vMetres; + varying vec3 vWorld; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + /** The unperturbed front radius at time t. Fickian by default. */ + float frontAt(float t) { + return uSpread * pow(max(t, 0.0), max(uSpreadPower, 0.05)); + } + + /** Its exact inverse: when the front reached distance d. */ + float arrivalOf(float d) { + return pow(max(d, 0.0) / max(uSpread, 1e-4), 1.0 / max(uSpreadPower, 0.05)); + } + + /** + * The perturbation of the interface, in metres. See the header. + * + * Positive pushes the front outward. Sampled on q in metres, never on the + * bearing. + */ + float fingers(vec2 q, float rf, float phase) { + float sum = 0.0; + float L = max(uCoarse, 0.05); + for (int i = 0; i < 5; i++) { + // A lobe of wavelength L is inadmissible until the front has grown into + // it, so the coarse octaves are the last to arrive, not the first. + float onset = uOnset * L; // metres of front radius + float e = min(uGrowth * max(rf - onset, 0.0) / L, 8.0); + float g = min(exp(e) - 1.0, uGrowthMax); + sum += L * g * snoise(vec3(q / L, phase + float(i) * 37.0)); + L *= 0.5; + } + return sum; + } + + void main() { + vec2 q0 = vMetres; + + float covBest = 0.0; // how much ink is here at all + float densBest = 0.0; // how thick it is + float ringBest = 0.0; // the deposition line + float wetBest = 0.0; // 1 just laid, 0 dry + float dBest = 1e4; // signed distance to the nearest interface + vec2 gradBest = vec2(0.0, 1.0); + + /* --- the nuclei --------------------------------------------------- */ + for (int i = 0; i < INK_SOURCES; i++) { + // Masked, not broken out of: a break on a uniform compiles on the driver + // you have and fails on the one the player has. + float live = step(float(i) + 0.5, uSources); + vec4 dice = uSourceDice[i]; + + // Nucleus 0 is the anchor itself; the rest scatter. A zone cast that + // dropped every nucleus in the same place is one blob, and ink in water + // is never one blob. + float extra = step(0.5, float(i)); + vec2 nuc = dice.xy * uSourceScatter * uRadius * extra; + float age = uAge - uSourceDelay * float(i); + float rf = frontAt(age); + + vec2 q = q0 - nuc; + vec2 core = q; + float d; + + #if INK_MODE == 1 + // Directional. The anisotropy lives in the *metric*, not in a + // bearing-indexed radius: measuring the front as f(bearing) gives every + // point along a bearing the same answer and the mass grows flat facets + // down its flanks. + core = q - vec2(0.0, uMassLead * rf); + vec2 s = vec2(core.x / max(uMassAcross, 0.05), core.y / max(uMassAlong, 0.05)); + // The trailing edge is where the sheet tore away, not where it spread + // to, so it is blunter than the leading edge. + s.y *= mix(1.0, max(uMassRear, 0.05), step(core.y, 0.0)); + d = length(s); + #else + d = length(core); + #endif + + float cl = length(core); + vec2 radial = cl > 1e-4 ? core / cl : vec2(0.0, 1.0); + + #if INK_MODE != 2 + float cap = uFingerMax * rf; + d -= clamp(uFinger * fingers(q, rf, dice.z), -cap, cap); + #endif + + /* + * The crown is allowed to move the *silhouette* and nothing else, which + * is why it gets its own distance rather than being folded into d. + * + * The first version subtracted it from d outright. The teeth came out + * right and the inside of the mass grew a fan of radial spokes reaching + * all the way to the impact point, because the film's thickness term + * reads the same d and the crown is periodic in bearing at every radius. + * A rim instability is a property of the rim. dShade never sees it. + */ + float dShade = d; + #if INK_MODE == 1 + // Bearing-indexed and radial because a rim instability really is both + // — see the header. The tooth count rises with the rim's own radius. + float bearing = atan(core.x, core.y); + float gate = pow(max(radial.y, 0.0), max(uCrownGate, 0.01)); + float teeth = max(3.0, floor(TAU * max(rf, 0.05) / max(uCrownSpacing, 0.05))); + float tooth = pow(max(cos(teeth * bearing + dice.w * TAU), 0.0), max(uCrownSharp, 1.0)); + d -= uCrown * rf * tooth * gate; + #endif + + float w = max(uEdge, 1e-3); + float cov = smoothstep(rf + w, rf - w, d) * live; + + /* + * The zone boundary. Measured from the anchor rather than the nucleus, + * because a zone is the ability's, not the blob's — and soft, over + * uClipSoft metres, because a front that has outgrown its quad is the one + * failure that reads instantly: the ink ends in a straight line with a + * corner on it. Satellites are exempt; they are thrown out of the zone on + * purpose. + */ + cov *= smoothstep(uRadius, uRadius - max(uClipSoft, 1e-3), length(q0)); + + float wetAge = max(age - arrivalOf(d), 0.0); + float wet = exp(-wetAge / max(uDryTime, 1e-3)); + float dens = cov * (uCore + (1.0 - uCore) * exp(-max(dShade, 0.0) / max(uFalloff, 0.05))); + + float rz = (dShade - rf) / max(uRingWidth, 1e-3); + float ring = exp(-rz * rz) * cov; + + float take = step(densBest, dens); + densBest = max(densBest, dens); + covBest = max(covBest, cov); + ringBest = max(ringBest, ring); + wetBest = mix(wetBest, wet * cov, take); + gradBest = mix(gradBest, radial, take); + dBest = mix(dBest, d - rf, take); + } + + /* --- the satellites ------------------------------------------------ */ + #if INK_MODE == 1 + for (int i = 0; i < INK_SATELLITES; i++) { + float live = step(float(i) + 0.5, uSatellites); + vec4 dice = uSatDice[i]; + + // Bounded-Pareto inverse CDF on the droplet radius. A fragmenting sheet + // really does produce a power law, and it is the *only* thing that makes + // a splatter read as a splatter: a uniform draw gives a dozen same-sized + // dots and the eye files it as a stencil. + float a = max(uSatAlpha, 1.05); + float lo = max(uSatMin, 1e-3); + float hi = max(uSatMax, lo * 1.001); + float loA = pow(lo, -a); + float hiA = pow(hi, -a); + float size = pow(dice.x * (hiA - loA) + loA, -1.0 / a); + + // Small droplets fly furthest — they detach last, from the fastest part + // of the sheet, and carry the least drag per unit mass. Driving distance + // off the *same* draw is what makes the far field fine and the near field + // coarse, which is the readable half of the effect. + float reach = mix(uThrowNear, uThrowFar, pow(1.0 - dice.x, max(uThrowCurve, 0.05))); + vec2 bead = vec2((dice.y - 0.5) * 2.0 * uThrowSpread * reach, reach); + + // A teardrop: a disc at the bead tapering to nothing down a tail pointing + // back toward the throw. A droplet that lands moving forward does not + // make a circle, and the tails are the direction cue that turns twelve + // scattered dots into one thrown handful. + vec2 tail = -vec2(0.0, uSatTail * size); + float h = clamp(dot(q0 - bead, tail) / max(dot(tail, tail), 1e-6), 0.0, 1.0); + float sd = length(q0 - (bead + tail * h)) - size * (1.0 - h); + + float w = max(uEdge, 1e-3) * 0.6; + float sAge = uAge - uSatDelay * (reach / max(uThrowFar, 1e-3)) - dice.z * uSatJitter; + float cov = smoothstep(w, -w, sd) * live * smoothstep(0.0, max(uSatPop, 1e-3), sAge); + float wet = exp(-max(sAge, 0.0) / max(uDryTime, 1e-3)); + + vec2 gv = q0 - bead; + float gl2 = dot(gv, gv); + vec2 radial = gl2 > 1e-8 ? gv * inversesqrt(gl2) : vec2(0.0, 1.0); + + float rz = sd / max(uRingWidth, 1e-3); + float take = step(densBest, cov); + densBest = max(densBest, cov); + covBest = max(covBest, cov); + ringBest = max(ringBest, exp(-rz * rz) * cov); + wetBest = mix(wetBest, wet * cov, take); + gradBest = mix(gradBest, radial, take); + dBest = mix(dBest, sd, take); + } + #endif + + /* --- the paper ----------------------------------------------------- */ + // Granulation is pigment settling into the tooth of the paper, so it shows + // where the film has dried and is invisible while it is still wet. + float grain = snoise(vec3(q0 * uGranScale, uSeed * 0.37)) * 0.5 + 0.5; + float granAmt = uGranulation * (1.0 - 0.6 * wetBest); + float mottle = 1.0 - granAmt * grain; + + float density = clamp(densBest * mottle, 0.0, 1.0); + float alpha = clamp(covBest * (uFilm + (1.0 - uFilm) * density) * uOpacity, 0.0, 1.0); + alpha *= clamp(uFade, 0.0, 1.0); + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + float g = clamp(uTint + density * uTintDensity, 0.0, 1.0); + vec3 colour = gradient4(uColorWash, uColorBody, uColorDeep, uColorPool, g); + colour = mix(colour, uColorWet, clamp(wetBest * uWetDarken, 0.0, 1.0)); + colour = mix(colour, uColorRing, clamp(ringBest * uRing, 0.0, 1.0)); + + /* --- the wet gloss -------------------------------------------------- + * The film is a lens: flat in the middle, tipped outward at the meniscus, + * which is exactly where the interface is. dBest is the signed distance to + * that interface, so a Gaussian on it is the lip, and the tilt is the + * radial direction rotated into world space by the field's own axes. + */ + float lz = dBest / max(uRingWidth, 1e-3); + float lip = exp(-lz * lz); + vec3 tilt = uAxisAcross * gradBest.x + uAxisAlong * gradBest.y; + vec3 nrm = normalize(vec3(0.0, 1.0, 0.0) - tilt * (uMeniscus * lip)); + vec3 viewDir = normalize(cameraPosition - vWorld); + vec3 halfway = normalize(viewDir + normalize(uLightDir)); + float spec = pow(max(dot(nrm, halfway), 0.0), max(uGlossPower, 1.0)); + colour += uColorGloss * (spec * uGloss * wetBest); + + // The clamp that keeps this school out of the bloom pass. Never multiplied + // by uGlobalGlow — ink is not emissive and the global glow slider must not + // be able to light it. + float lum = dot(colour, vec3(0.2126, 0.7152, 0.0722)); + colour *= lum > uCeiling ? uCeiling / max(lum, 1e-4) : 1.0; + + gl_FragColor = vec4(colour, alpha); + } +`; +} + +/* ---------------------------------------------------------------------- */ +/* Scratch — the frame allocates nothing (I3) */ +/* ---------------------------------------------------------------------- */ + +const _flat = new Vector3(); + +/** Tolerates a picker string or a THREE.Color, like `Swarm#setColors` does. */ +const _col = (value) => (typeof value === 'string' ? getColor(value) : value); + +/* ---------------------------------------------------------------------- */ +/* InkDiffusion */ +/* ---------------------------------------------------------------------- */ + +/** + * **What it draws.** One ground quad carrying ink that spreads through a real + * fingering instability, glossy at the wet leading edge and matte behind it, + * with a `SPLATTER` mode that adds a directional mass, a crown of rim teeth and + * a power-law scatter of satellite droplets down the travel vector. + * + * **Draw calls.** One. Always one, whatever the mode, however many satellites. + * + * **What it reads from settings.** Nothing directly. `update(now, params)` + * takes a live block and resolves `p.key ?? default` against + * `inkDiffusionParams()`. `now` is the ability's age in seconds and it is the + * *only* thing the front law needs, because the front is closed-form in it — + * pause with **P** and drag `spread`, `coarse` or `dryTime` and the standing + * pattern re-grows. + * + * **The one rule for using it well.** *`coarse` is the only knob that changes + * what the pattern is; everything else changes how much of it you get.* The + * finger wavelength sets the whole morphology — 3 m of `coarse` on a 4 m field + * gives two fat lobes, 0.8 m gives lace. Set it against the field radius first, + * then reach for `finger` and `growth`. Authors who start with `finger` end up + * with a wobbly disc turned up loud. + * + * --- + * + * ## Seeds + * + * `roll()` fills two dice arrays and one noise phase, all unitless. Those dice + * are **uploaded as uniform arrays** rather than hashed in the shader, for one + * specific reason: `satellitePoint()` has to agree with the fragment shader + * exactly, because `splatterbrand` throws a `Projectile` at each satellite and + * it has to land in the droplet rather than near it. A JS mirror of a GLSL hash + * is a mirror of float32 rounding and it drifts. The arrays are indexed only by + * the loop counter, which is the one indexing ESSL 1.00 allows. + * + * ## Placement + * + * `setPlacement(anchor, along)` puts the field at a world point with its local + * `+z` down `along` (projected flat), which is the direction satellites are + * thrown and the mass is stretched. The quad's *size* is not part of placement: + * it is re-derived from `radius` — and, in `SPLATTER`, from `throwFar` — every + * frame, so dragging either grows the canvas along with the drawing on it. + */ +export class InkDiffusion { + /** + * @param {THREE.Object3D} parent the ability's group + * @param {object} [options] + * @param {number} [options.mode=InkMode.BLOOM] a `#define`, fixed for the lifetime + * @param {number} [options.sources=4] nuclei the shader carries; `params.sources` drives how many are live + * @param {number} [options.satellites=16] droplet slots, SPLATTER only + * @param {number} [options.layer=LAYER.VFX] + * @param {number} [options.renderOrder=6] + * @param {string} [options.name] + */ + constructor( + parent, + { + mode = InkMode.BLOOM, + sources = 4, + satellites = 16, + layer = LAYER.VFX, + renderOrder = 6, + name = null + } = {} + ) { + this.mode = mode; + this.sources = Math.max(1, Math.round(sources)); + this.satellites = Math.max(1, Math.round(satellites)); + + // The shared, refcounted ground quad. This module originally built its own, + // on the argument that a private four-vertex plane has no shared-state bug + // surface — true when the alternative was importing GroundField, which is + // two thousand lines. vfx/quads.js is thirty, holds nothing but the buffer + // and a count, and is now what every flat effect in the library draws on. + this.geometry = acquireGroundQuad(); + + const sourceDice = []; + for (let i = 0; i < this.sources; i++) sourceDice.push(new Vector4()); + const satDice = []; + for (let i = 0; i < this.satellites; i++) satDice.push(new Vector4()); + + this.material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: NormalBlending, + side: DoubleSide, + /* + * True, and deliberately unlike the emissive modules in this library. + * Inert as things stand — the composer always renders to a target, where + * three switches materials to NoToneMapping and OutputPass does the grade + * once for everything — but ink is pigment on a floor, and it belongs + * under the same grade as the floor. The flag says which kind of thing + * this is; the ceiling in the fragment is what actually enforces it. + */ + toneMapped: true, + uniforms: sharedUniforms({ + uQuadSize: { value: new Vector2(1, 1) }, + uAge: { value: 0 }, + uSeed: { value: 0 }, + uRadius: { value: 6 }, + uAxisAlong: { value: new Vector3(0, 0, 1) }, + uAxisAcross: { value: new Vector3(1, 0, 0) }, + + uSpread: { value: 2.6 }, + uSpreadPower: { value: 0.5 }, + uEdge: { value: 0.09 }, + uClipSoft: { value: 0.8 }, + uSources: { value: 1 }, + uSourceScatter: { value: 0.35 }, + uSourceDelay: { value: 0.18 }, + uSourceDice: { value: sourceDice }, + + uFinger: { value: 0.5 }, + uFingerMax: { value: 0.75 }, + uCoarse: { value: 2.4 }, + uOnset: { value: 0.55 }, + uGrowth: { value: 0.9 }, + uGrowthMax: { value: 1.5 }, + + uCore: { value: 0.5 }, + uFalloff: { value: 5.5 }, + uFilm: { value: 0.6 }, + uGranulation: { value: 0.22 }, + uGranScale: { value: 1.6 }, + uRing: { value: 0.55 }, + uRingWidth: { value: 0.12 }, + + uDryTime: { value: 1.6 }, + uWetDarken: { value: 0.35 }, + uGloss: { value: 0.22 }, + uGlossPower: { value: 44 }, + uMeniscus: { value: 0.55 }, + + uMassAlong: { value: 1.35 }, + uMassAcross: { value: 0.8 }, + uMassLead: { value: 0.22 }, + uMassRear: { value: 1.45 }, + uCrown: { value: 0.35 }, + uCrownSpacing: { value: 0.55 }, + uCrownSharp: { value: 7 }, + uCrownGate: { value: 2.2 }, + uSatellites: { value: 12 }, + uSatMin: { value: 0.055 }, + uSatMax: { value: 0.42 }, + uSatAlpha: { value: 2.3 }, + uThrowNear: { value: 1.6 }, + uThrowFar: { value: 9 }, + uThrowCurve: { value: 1.7 }, + uThrowSpread: { value: 0.22 }, + uSatTail: { value: 2.4 }, + uSatDelay: { value: 0.42 }, + uSatJitter: { value: 0.12 }, + uSatPop: { value: 0.06 }, + uSatDice: { value: satDice }, + + uOpacity: { value: 1 }, + uFade: { value: 1 }, + uCeiling: { value: 0.62 }, + uSoftFade: { value: 0.25 }, + uTint: { value: 0.05 }, + uTintDensity: { value: 1.05 }, + uColorWash: { value: new Color('#a89a88') }, + uColorBody: { value: new Color('#4c433b') }, + uColorDeep: { value: new Color('#1a1613') }, + uColorPool: { value: new Color('#080706') }, + uColorRing: { value: new Color('#2e2019') }, + uColorWet: { value: new Color('#100c09') }, + uColorGloss: { value: new Color('#c8ced2') } + }), + vertexShader: INK_VERTEX, + fragmentShader: inkFragment(mode, this.sources, this.satellites) + }); + + this.mesh = new Mesh(this.geometry, this.material); + this.mesh.name = name ?? `InkDiffusion:${INK_MODE_NAMES[mode] ?? mode}`; + this.mesh.layers.set(layer); + // Below the emissive decals, above the floor. Ink is shaded, never additive + // — there is no additive path in this module and there is not going to be + // one, because "ink that adds light" is the failure mode of the school. + this.mesh.renderOrder = renderOrder; + this.mesh.frustumCulled = false; + this.mesh.visible = false; + parent?.add(this.mesh); + + this._anchor = new Vector3(); + this._along = new Vector3(0, 0, 1); + this._yaw = 0; + this._age = 0; + this.seed = 0; + this._p = inkDiffusionParams(); + this.roll(0); + } + + get object3D() { + return this.mesh; + } + + get uniforms() { + return this.material.uniforms; + } + + /** One. Always one. */ + get drawCalls() { + return 1; + } + + /** Seconds of spread the last `update()` was given. */ + get age() { + return this._age; + } + + setVisible(visible) { + this.mesh.visible = visible; + return this; + } + + /** + * Where the field is and which way the ink was thrown. + * + * `along` is projected flat and normalised; it becomes the field's local `+z`, + * so it is the axis the mass stretches down and the satellites fly along. For + * a `BLOOM` or a `WASH` it only rotates the noise, which is still worth + * passing the cast direction for — two casts down the same line should not be + * distinguishable by their grain. + */ + setPlacement(anchor, along) { + this._anchor.copy(anchor); + _flat.copy(along); + _flat.y = 0; + if (_flat.lengthSq() < 1e-8) _flat.set(0, 0, 1); + _flat.normalize(); + this._along.copy(_flat); + this._yaw = Math.atan2(_flat.x, _flat.z); + return this; + } + + /** + * Re-roll the unitless dice. Call from `onSpawn` and nowhere else. + * + * Nothing rolled here has a unit: nucleus scatter is a fraction of the + * radius, a droplet's size draw is a probability, the phases are phases. Every + * metre they turn into is resolved in the fragment shader from live uniforms, + * which is why a paused `throwFar` drag moves the whole spatter pattern + * without re-rolling it. + */ + roll(seed = Math.random() * 100) { + this.seed = seed; + this.material.uniforms.uSeed.value = seed; + const src = this.material.uniforms.uSourceDice.value; + for (let i = 0; i < src.length; i++) { + src[i].set( + Math.random() * 2 - 1, // scatter x, -1..1 + Math.random() * 2 - 1, // scatter z, -1..1 + Math.random() * 100, // the octaves' noise phase + Math.random() // the crown's angular phase + ); + } + const sat = this.material.uniforms.uSatDice.value; + for (let i = 0; i < sat.length; i++) { + sat[i].set(Math.random(), Math.random(), Math.random(), Math.random()); + } + return this; + } + + /** Hide the field. Leaves the instance reusable — the pooling contract. */ + reset() { + this._age = 0; + this.material.uniforms.uAge.value = 0; + this.mesh.visible = false; + return this; + } + + /** Fill `_p` from the caller's block, defaults where it is silent. */ + _resolve(params) { + const p = this._p; + for (const key in DEFAULT_PARAMS) { + const value = params[key]; + p[key] = value === undefined ? DEFAULT_PARAMS[key] : value; + } + return p; + } + + /** + * @param {number} now the ability's age in seconds — the front law's only input + * @param {object} params live block; anything absent falls back to `inkDiffusionParams()` + */ + update(now, params) { + const p = this._resolve(params ?? DEFAULT_PARAMS); + const u = this.material.uniforms; + this._age = Math.max(0, now); + + u.uAge.value = this._age; + u.uRadius.value = p.radius; + + u.uSpread.value = p.spread; + u.uSpreadPower.value = p.spreadPower; + u.uEdge.value = p.edge; + u.uClipSoft.value = p.clipSoft; + u.uSources.value = Math.max(0, Math.min(this.sources, Math.round(p.sources))); + u.uSourceScatter.value = p.sourceScatter; + u.uSourceDelay.value = p.sourceDelay; + + u.uFinger.value = p.finger; + u.uFingerMax.value = p.fingerMax; + u.uCoarse.value = p.coarse; + u.uOnset.value = p.onset; + u.uGrowth.value = p.growth; + u.uGrowthMax.value = p.growthMax; + + u.uCore.value = p.core; + u.uFalloff.value = p.falloff; + u.uFilm.value = p.film; + u.uGranulation.value = p.granulation; + u.uGranScale.value = p.granScale; + u.uRing.value = p.ring; + u.uRingWidth.value = p.ringWidth; + + u.uDryTime.value = p.dryTime; + u.uWetDarken.value = p.wetDarken; + u.uGloss.value = p.gloss; + u.uGlossPower.value = p.glossPower; + u.uMeniscus.value = p.meniscus; + + u.uMassAlong.value = p.massAlong; + u.uMassAcross.value = p.massAcross; + u.uMassLead.value = p.massLead; + u.uMassRear.value = p.massRear; + u.uCrown.value = p.crown; + u.uCrownSpacing.value = p.crownSpacing; + u.uCrownSharp.value = p.crownSharp; + u.uCrownGate.value = p.crownGate; + u.uSatellites.value = Math.max(0, Math.min(this.satellites, Math.round(p.satellites))); + u.uSatMin.value = p.satMin; + u.uSatMax.value = p.satMax; + u.uSatAlpha.value = p.satAlpha; + u.uThrowNear.value = p.throwNear; + u.uThrowFar.value = p.throwFar; + u.uThrowCurve.value = p.throwCurve; + u.uThrowSpread.value = p.throwSpread; + u.uSatTail.value = p.satTail; + u.uSatDelay.value = p.satDelay; + u.uSatJitter.value = p.satJitter; + u.uSatPop.value = p.satPop; + + u.uOpacity.value = p.opacity; + u.uFade.value = p.fade; + u.uCeiling.value = p.ceiling; + u.uSoftFade.value = p.softFade; + u.uTint.value = p.tint; + u.uTintDensity.value = p.tintDensity; + u.uColorWash.value.copy(_col(p.colorWash)); + u.uColorBody.value.copy(_col(p.colorBody)); + u.uColorDeep.value.copy(_col(p.colorDeep)); + u.uColorPool.value.copy(_col(p.colorPool)); + u.uColorRing.value.copy(_col(p.colorRing)); + u.uColorWet.value.copy(_col(p.colorWet)); + u.uColorGloss.value.copy(_col(p.colorGloss)); + + /* --- the canvas ---------------------------------------------------- + * Re-derived every frame from the live params, never captured. + * + * `radius` is the whole story for the mass, because the mass is clipped to + * it in the fragment stage — that clip exists precisely so this number is + * bounded and a long-lived bloom cannot grow a quad the size of the level. + * The satellites are not clipped, so a splatter's canvas has to reach past + * its furthest droplet *and that droplet's tail*: getting this wrong cuts + * the far field off with a straight edge, which is worse-looking than + * having no satellites at all. + */ + let extent = Math.max(p.radius, 0.1); + if (this.mode === InkMode.SPLATTER) { + extent = Math.max(extent, p.throwFar + p.satMax * (1 + p.satTail) + p.edge * 2); + } + extent += p.edge * 2 + p.clipSoft; + const size = extent * 2; + this.mesh.scale.set(size, 1, size); + u.uQuadSize.value.set(size, size); + + this.mesh.position.set(this._anchor.x, p.height, this._anchor.z); + this.mesh.rotation.set(0, this._yaw, 0); + + // The field's own axes in world space, so the fragment shader can tip the + // wet meniscus the right way without knowing about the yaw. + u.uAxisAlong.value.set(Math.sin(this._yaw), 0, Math.cos(this._yaw)); + u.uAxisAcross.value.set(Math.cos(this._yaw), 0, -Math.sin(this._yaw)); + + this.mesh.visible = p.opacity > 0 && p.fade > 0; + } + + /* --- CPU mirrors -------------------------------------------------- * + * All of these read the last-synced params and age, so call them after + * update() on the same frame. They mirror the fragment shader exactly, + * including its dice, because the dice are uploaded rather than hashed. + */ + + /** The unperturbed front radius of nucleus `i`, in metres. */ + frontRadius(i = 0) { + const p = this._p; + const age = Math.max(0, this._age - p.sourceDelay * i); + return p.spread * Math.pow(age, Math.max(p.spreadPower, 0.05)); + } + + /** World position of nucleus `i`. Nucleus 0 is the anchor. */ + sourcePoint(i, out) { + const p = this._p; + out.copy(this._anchor); + if (i <= 0) return out; + const dice = this.material.uniforms.uSourceDice.value[Math.min(i, this.sources - 1)]; + const x = dice.x * p.sourceScatter * p.radius; + const z = dice.y * p.sourceScatter * p.radius; + return this._toWorld(x, z, out); + } + + /** Radius of satellite `i`, in metres. The same Pareto draw as the shader. */ + satelliteSize(i) { + const p = this._p; + const dice = this.material.uniforms.uSatDice.value[Math.min(Math.max(i, 0), this.satellites - 1)]; + const a = Math.max(p.satAlpha, 1.05); + const lo = Math.max(p.satMin, 1e-3); + const hi = Math.max(p.satMax, lo * 1.001); + const loA = Math.pow(lo, -a); + const hiA = Math.pow(hi, -a); + return Math.pow(dice.x * (hiA - loA) + loA, -1 / a); + } + + /** How far down the travel vector satellite `i` was thrown, in metres. */ + satelliteReach(i) { + const p = this._p; + const dice = this.material.uniforms.uSatDice.value[Math.min(Math.max(i, 0), this.satellites - 1)]; + const t = Math.pow(1 - dice.x, Math.max(p.throwCurve, 0.05)); + return p.throwNear + (p.throwFar - p.throwNear) * t; + } + + /** + * World position of satellite `i`'s bead. + * + * This is the hook `splatterbrand` hangs its `Projectile`s on: fire one at + * `satellitePoint(i, v)` and it lands in the droplet, not near it. + */ + satellitePoint(i, out) { + const p = this._p; + const dice = this.material.uniforms.uSatDice.value[Math.min(Math.max(i, 0), this.satellites - 1)]; + const reach = this.satelliteReach(i); + const across = (dice.y - 0.5) * 2 * p.throwSpread * reach; + out.copy(this._anchor); + out.y = p.height; + return this._toWorld(across, reach, out); + } + + /** Seconds since satellite `i` landed. Negative means it has not yet. */ + satelliteAge(i) { + const p = this._p; + const dice = this.material.uniforms.uSatDice.value[Math.min(Math.max(i, 0), this.satellites - 1)]; + return ( + this._age - p.satDelay * (this.satelliteReach(i) / Math.max(p.throwFar, 1e-3)) - dice.z * p.satJitter + ); + } + + /** Local (across, along) metres into a world point, at the field's height. */ + _toWorld(across, along, out) { + const s = Math.sin(this._yaw); + const c = Math.cos(this._yaw); + out.x = this._anchor.x + across * c + along * s; + out.z = this._anchor.z - across * s + along * c; + out.y = this._p.height; + return out; + } + + dispose() { + this.mesh.parent?.remove(this.mesh); + this.material.dispose(); + releaseGroundQuad(); + } +} + +/* ---------------------------------------------------------------------- */ +/* Params */ +/* ---------------------------------------------------------------------- */ + +/** + * Every key `update()` understands, with its default and its unit. + * + * The instability block is the one worth reading twice. `coarse` is the + * wavelength of the **largest** lobe the bloom can ever grow; `onset` is how + * many of a mode's own wavelengths of front radius have to pass before that + * mode is admitted, so it is what staggers the scales; `growth` is how fast an + * admitted mode then grows and `growthMax` is where it stops. Those four are + * the dispersion relation, and between them they are the difference between a + * bloom, a puddle and a bath sponge. + * + * Read `onset` with `coarse`: at 0.55 the coarsest lobe needs a front of + * `0.55 * coarse` metres before it appears at all, which on the defaults is + * 1.3 m — so a small cast never grows its biggest feature, and a large one + * grows into it. That is the behaviour that makes two casts of different sizes + * look like different events rather than one event at two zoom levels. + */ +export function inkDiffusionParams() { + return { + /* --- placement --- */ + radius: 6, // metres, the authored extent of the field + height: 0.02, // metres above the floor + + /* --- the spread --- */ + spread: 2.6, // metres of front radius at t = 1 s + spreadPower: 0.5, // r ~ t^power; 0.5 is Fickian diffusion + edge: 0.09, // metres, the width of the interface + clipSoft: 0.8, // metres the mass fades out over as it reaches `radius` + sources: 1, // live nuclei, clamped to the constructed count + sourceScatter: 0.35, // fraction of the radius the extra nuclei scatter over + sourceDelay: 0.18, // seconds each later nucleus starts behind the first + + /* --- the instability --- */ + finger: 0.5, // overall amplitude; 0 gives a disc + fingerMax: 0.75, // cap, as a fraction of the front radius + coarse: 2.4, // metres, the coarsest lobe — the one knob that sets the morphology + onset: 0.55, // front radii per wavelength before that mode is admitted + growth: 0.9, // e-folds per wavelength of front travel + growthMax: 1.5, // saturation — a finger stops growing at its own width + + /* --- the film --- */ + core: 0.5, // density floor inside the blob + falloff: 5.5, // metres of e-folding out from a nucleus + film: 0.6, // alpha floor inside the coverage + granulation: 0.22, // pigment settling into the paper's tooth + granScale: 1.6, // features per metre + ring: 0.55, // strength of the deposition line at the interface + ringWidth: 0.12, // metres + + /* --- wet and dry --- */ + dryTime: 1.6, // seconds for the gloss to fall to 1/e + wetDarken: 0.35, // how far the wet film pulls toward colorWet + gloss: 0.22, // specular strength on the wet film — under the ceiling + glossPower: 44, // its tightness + meniscus: 0.55, // how far the film's normal tips at the interface + + /* --- SPLATTER: the mass --- */ + massAlong: 1.35, // metric stretch down the travel vector + massAcross: 0.8, // and across it + massLead: 0.22, // fraction of the front the mass sits forward by + massRear: 1.45, // >1 blunts the trailing edge + crown: 0.35, // fraction of the front the teeth add + crownSpacing: 0.55, // metres of rim per tooth + crownSharp: 7, // tooth narrowness + crownGate: 2.2, // how tightly the crown is held to the leading arc + + /* --- SPLATTER: the satellites --- */ + satellites: 12, // live droplets, clamped to the constructed count + satMin: 0.055, // metres, smallest droplet radius + satMax: 0.42, // metres, largest + satAlpha: 2.3, // exponent of the size power law — 2..3 is what splashes do + throwNear: 1.6, // metres, nearest droplet + throwFar: 9, // metres, furthest — also sizes the quad + throwCurve: 1.7, // bias of the distance draw + throwSpread: 0.22, // lateral cone, as a fraction of the distance + satTail: 2.4, // tail length, in droplet radii + satDelay: 0.42, // seconds the furthest droplet lands behind the mass + satJitter: 0.12, // seconds of per-droplet slop on that + satPop: 0.06, // seconds a droplet takes to appear + + /* --- the mark --- */ + opacity: 1, + fade: 1, // the ability's own fade, 0..1 + ceiling: 0.62, // max linear luminance; post.bloomThreshold is 0.88 + softFade: 0.25, // metres of depth feather + tint: 0.05, // where in the gradient a zero-density film sits + tintDensity: 1.05, // how far density walks it — over 1 so a thick film reaches colorPool + + /* --- seven pickers, none derived from another --- */ + colorWash: '#a89a88', // the thinnest film + colorBody: '#4c433b', + colorDeep: '#1a1613', + colorPool: '#080706', + colorRing: '#2e2019', // the deposition line at the interface + colorWet: '#100c09', // what the still-wet film pulls toward + colorGloss: '#c8ced2' // the specular on it + }; +} + +/** Resolved once at module load; `_resolve` walks its keys every frame. */ +const DEFAULT_PARAMS = inkDiffusionParams(); diff --git a/src/vfx/LensFlare.js b/src/vfx/LensFlare.js new file mode 100644 index 0000000..7436cdb --- /dev/null +++ b/src/vfx/LensFlare.js @@ -0,0 +1,995 @@ +import { + Mesh, + PlaneGeometry, + InstancedBufferGeometry, + InstancedBufferAttribute, + ShaderMaterial, + AdditiveBlending, + DoubleSide, + Color, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonVertexGLSL } from '../shaders/lib/common.glsl.js'; +import { frame, sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { copyColor } from '../utils/color.js'; + +/* ------------------------------------------------------------------------ */ +/* LensFlare — the artefact that belongs to the camera, not to the world */ +/* ------------------------------------------------------------------------ */ +/** + * A screen-space anamorphic flare anchored to a world point, **occlusion tested + * against the depth prepass**. + * + * Everything else in `src/vfx/` draws something that is *there*: a tube of + * plasma, a sheet of ice, a hole in the air. This module draws something that + * is not there at all. A flare is what a bright point does to the glass in + * front of the sensor — ghosts bouncing between element surfaces, a starburst + * from the iris blades, a horizontal smear from the cylindrical anamorphic + * element — and none of it exists in the scene. That single fact decides every + * design choice below. + * + * ## Why the occlusion test is the whole module + * + * Because a flare lives on the lens it must be drawn **after** the scene, with + * the depth test off: a ghost that vanishes behind a pillar is not a lens + * artefact, it is a decal. But turning the depth test off also throws away the + * only occlusion the renderer gives you for free, and a flare that survives the + * character walking in front of the lamp is the tell that has made cheap flares + * look cheap for twenty years. It is a sticker stuck to the monitor. + * + * So the module buys the occlusion back by hand. `frame.uSceneDepth` is already + * there — a packed-RGBA depth prepass of the opaque `WORLD` layer, rendered + * every frame for the soft particles — and the flare samples it at the source's + * own screen position, in the **vertex** shader, with a small area-uniform disc + * kernel. The average over the kernel is an estimate of what fraction of the + * source's disc is unoccluded, and the whole flare is scaled by it. + * + * The first version took one tap at the source's exact pixel. It is much worse + * than it sounds: the flare does not dim as the character crosses the source, + * it **switches off**, on one frame, at the instant the silhouette edge crosses + * that one pixel — and switches back on the same way. Sixteen elements totalling + * a third of the screen popping in and out is far more distracting than no + * occlusion at all. The kernel is not a quality nicety; it is the difference + * between the feature working and being worse than not having it. + * + * Second thing the kernel buys: the prepass is **half resolution** and is + * sampled with linear filtering, and interpolating *packed* depth between two + * texels produces a number that is not a depth at all. Every `softFade` in the + * project already lives with that. Spreading the taps over the source's + * apparent size means the nonsense values are a minority of the average instead + * of the whole answer. + * + * ## Why it is one draw call + * + * Sixteen billboards is sixteen draw calls if you build it the obvious way. + * Here one instanced quad carries every element: instance 0 is the core and its + * starburst, 1 the anamorphic streak, 2 the iris ring, and 3..10 the ghosts. + * The vertex shader places each instance directly in NDC from the projected + * anchor, so the mesh's own matrix is identity for its whole life and moving + * the flare is a `Vector3` copy into a uniform. + * + * The ghost series is generated from sliders rather than from an array of + * per-ghost records, for two reasons. The first is **I1**: an array of metres + * would be a dimension living somewhere other than a settings block. The second + * is that a uniform array may be indexed only by a loop counter in ESSL 1.00 + * (see the traps in the README), so per-ghost lookups would need the + * `if (i == slot)` unrolled loop `FilamentPaths` uses, for values that a base, + * a stride and a hashed scatter describe better anyway. + * + * ## Why every ghost shares a blade count + * + * `ghostBlades` is one slider for the whole flare, and this is deliberate. The + * ghosts are all images of the *same iris*, so they all have the same number of + * sides; what differs between them is rotation, roundness (the far ones defocus + * into discs) and polarity. Giving each ghost its own blade count — a hexagon + * next to a pentagon next to an octagon — is the single most common way a drawn + * flare announces itself. Each ghost does get its own size, its own tint off a + * four-stop gradient, its own spin and its own roundness; that is "its own + * aperture shape" in the only sense a real lens allows. + * + * ## Tone mapping + * + * The material is `toneMapped: false` and draws at `renderOrder` 3000 with the + * depth test off, so it is the last thing in the scene pass and the renderer + * never applies a curve to it directly. But inside the composer *everything* is + * linear HDR until `OutputPass`, and ACES will happily take an authored deep + * blue streak at 8× and hand back a white bar — hue clipped, which is exactly + * the failure the module has to avoid, because the colour of the streak is the + * whole reason it is there. + * + * So the fragment shader ends with a **hue-preserving shoulder**: the Reinhard + * curve is applied to the largest channel and all three are scaled by the same + * ratio, which compresses the magnitude and leaves chromaticity untouched. + * `headroom` is the value the peak asymptotes to, in linear light, and it is a + * slider. The version before this applied Reinhard per channel, which is the + * textbook thing to do and desaturates towards white precisely as hard as the + * tone curve we were trying to get out from under. + * + * ## Cost + * + * **One draw call.** Up to eleven instanced quads, fill-heavy by nature (the + * streak is a third of the frame wide) but the fragment work per element is a + * handful of `pow`s. The occlusion kernel is evaluated per *vertex*: with the + * default seven taps that is 7 × 4 × 11 = 308 depth fetches for the whole + * flare, against roughly a million if the same test were done per fragment. + * + * ## Invariants + * + * - **I1** — nothing dimensioned is stored. `update()` writes every size, + * spacing and angle into uniforms from the caller's live params object, on + * every frame including a zero-length one. + * - **I2** — no texture. The starburst, the polygons, the dispersion fringes + * and the streak grain are all analytic. + * - **I3** — the mesh, the geometry and the eleven instances are built once. + * `update()` writes numbers into existing boxes and sets `instanceCount`. + * - **I5** — every dimension below is a slider and every colour is a picker; + * seven pickers, none derived from another. + */ + +/* ---------------------------------------------------------------- */ +/* Layout */ +/* ---------------------------------------------------------------- */ + +/** Instance roles. The order is the instance order and must not be reshuffled. */ +export const FlareRole = Object.freeze({ + /** The source itself: hot core, iris starburst, tight halo. */ + CORE: 0, + /** The horizontal anamorphic smear. */ + STREAK: 1, + /** The wide, dim, chromatic iris ring near the opposite side of the frame. */ + RING: 2, + /** Everything from index 3 up: the ghost train along the source→centre line. */ + GHOST: 3 +}); + +/** Fixed instances that exist whatever the ghost count is. */ +const FIXED_INSTANCES = 3; + +/** Hard ceiling on the ghost train. Eight is more than any real lens shows. */ +export const MAX_FLARE_GHOSTS = 8; + +/** Kernel ceiling. The loop is unrolled to this; `occTaps` clamps into it. */ +const MAX_OCC_TAPS = 9; + +/* ---------------------------------------------------------------- */ +/* Shaders */ +/* ---------------------------------------------------------------- */ + +/** + * The vertex shader does all four hard jobs, and the fragment shader only + * shades a unit square. + * + * 1. Project the anchor once and keep it as a constant across the quad. (The + * same trick `Distortion.js` documents: a varying whose value is identical + * at every vertex interpolates to exactly that value, so "where is the + * source on screen" is exact per fragment for the price of one matrix + * multiply, rather than approximately right at the corners.) + * 2. Run the occlusion kernel against the depth prepass. + * 3. Place, size, spin and tint this instance — the ghost train's arithmetic + * lives here, so the fragment shader never learns what a ghost is. + * 4. Emit the quad **directly in NDC**, aspect-corrected, so a "size" slider + * means a fraction of the frame and not a number of metres. + * + * `vLocal` is the un-rotated quad coordinate. Rotating the *vertices* and + * shading in the un-rotated frame is what lets `ghostSpin` and `streakTilt` + * exist without either of them appearing in the fragment shader at all. + */ +const FLARE_VERTEX = /* glsl */ ` + attribute float aRole; // 0 core · 1 streak · 2 ring · 3+ ghost + attribute float aSlot; // ghost ordinal, 0-based; 0 for the fixed three + + uniform vec3 uAnchor; // world point the flare hangs off + uniform vec2 uResolution; // device pixels — the only reason aspect is right + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + /* --- occlusion --- */ + uniform float uOcclusion; // 0..1 master; 0 also means "no depth buffer bound" + uniform float uOccRadius; // screen fraction — the source's apparent half-size + uniform float uOccTaps; // 1..9 + uniform float uOccFade; // metres over which the depth compare feathers + uniform float uOccSpin; // radians — rotates the kernel off the pixel grid + + /* --- framing --- */ + uniform float uEdgeStart; // |ndc| where the flare starts fading out + uniform float uEdgeEnd; // |ndc| where it is gone + uniform float uIntensity; + uniform float uOpacity; + uniform float uSeed; + + /* --- core --- */ + uniform float uCoreSize; // fraction of frame HEIGHT + uniform float uCoreGlow; + uniform float uBurstLength; // fraction of frame height, spike reach + uniform float uBurstSpin; // radians + uniform float uHaloSize; + uniform float uHaloWidth; + + /* --- streak --- */ + uniform float uStreakLength; // fraction of frame WIDTH, per side + uniform float uStreakThickness; // fraction of frame height + uniform float uStreakTilt; // radians off horizontal + uniform float uStreakGlow; + + /* --- ring --- */ + uniform float uRing; // 0 hides it + uniform float uRingSpacing; // fraction of the source→centre vector + uniform float uRingSize; // fraction of frame height + uniform float uRingWidth; + uniform float uRingGlow; + + /* --- ghosts --- */ + uniform float uGhostCount; + uniform float uGhostSpacing; // first ghost, as a fraction of source→centre + uniform float uGhostStride; // added per ghost — negative walks back out + uniform float uGhostScatter; // hashed jitter on the spacing + uniform float uGhostSize; // fraction of frame height + uniform float uGhostSizeStep; // multiplied per ghost + uniform float uGhostSizeScatter; + uniform float uGhostSpin; // radians added per ghost + uniform float uGhostRound; // 0 polygon, 1 disc — the first ghost + uniform float uGhostRoundStep; // added per ghost; the far ones defocus + uniform float uGhostGlow; + uniform float uGhostChroma; + + /* --- colours --- */ + uniform vec3 uColorCore; + uniform vec3 uColorStreak; + uniform vec3 uColorRing; + uniform vec3 uColorGhostA; + uniform vec3 uColorGhostB; + uniform vec3 uColorGhostC; + uniform vec3 uColorGhostD; + + varying float vRole; + varying vec2 vLocal; // un-rotated quad coords, -1..1 + varying vec3 vTint; + varying float vAmp; // intensity x glow x occlusion x edge fade x opacity + varying vec4 vShape; // x extent (height fractions) · y roundness · z chroma · w polarity + + ${noiseGLSL} + + /* commonGLSL is deliberately NOT injected here, and this is a trap worth + * knowing about: it carries aastep, which calls fwidth, and derivative + * functions do not exist in a vertex shader. Injecting the *fragment* chunk + * into a vertex stage fails to compile the whole program with an error that + * points at a helper you are not using. commonVertexGLSL is that chunk minus + * the two functions that cannot live here, and injecting it is the answer — + * this shader used to carry a hand-copied gradient4 instead, which is exactly + * how a four-stop ramp ends up meaning something slightly different on one + * mesh than on the next. */ + ${commonVertexGLSL} + #include + + /** + * softFade of common.glsl.js, restated — and this one genuinely cannot be + * shared. It samples a texture, and a sampler read in a vertex shader is a + * different capability from a sampler read in a fragment shader (vertex + * texture units, no implicit LOD); the shared vertex chunk deliberately + * contains nothing that touches a sampler, so that injecting it can never be + * the reason a program fails to link on a weak driver. + */ + float flareFade(sampler2D sceneDepth, vec2 screenUV, float viewZ, float near, float far, float fade) { + /* screenUV, not uv: three declares the mesh's own uv attribute in the + * prefix, and a parameter that shadows it is legal and unreadable. */ + float bits = unpackRGBAToDepth(texture2D(sceneDepth, screenUV)); + float sceneViewZ = perspectiveDepthToViewZ(bits, near, far); + return clamp((viewZ - sceneViewZ) / max(fade, 1e-4), 0.0, 1.0); + } + + void main() { + float aspect = max(uResolution.x, 1.0) / max(uResolution.y, 1.0); + + /* ---- the source, in NDC ---- */ + vec4 clip = projectionMatrix * viewMatrix * vec4(uAnchor, 1.0); + float behind = step(clip.w, 0.0001); // 1 when the source is behind the eye + vec2 src = clip.xy / max(clip.w, 0.0001); + vec2 srcUV = src * 0.5 + 0.5; + float srcViewZ = (viewMatrix * vec4(uAnchor, 1.0)).z; + + /* ---- occlusion: an area-uniform disc over the prepass ---- */ + /* Area-uniform, not Gaussian. A Gaussian weighting puts most of the answer + * back on the centre tap and brings the popping straight back; the whole + * point is an unweighted estimate of the *fraction* of the source's disc + * that is showing. sqrt() on the radius is what makes the golden-angle + * spiral cover area evenly instead of piling up in the middle. */ + float taps = clamp(uOccTaps, 1.0, ${MAX_OCC_TAPS}.0); + float vis = 0.0; + for (int i = 0; i < ${MAX_OCC_TAPS}; i++) { + float fi = float(i); + if (fi >= taps) break; + float ang = fi * 2.39996323 + uOccSpin; + float rad = sqrt((fi + 0.5) / taps) * uOccRadius; + vec2 off = vec2(cos(ang) / aspect, sin(ang)) * rad; + vis += flareFade(uSceneDepth, srcUV + off, srcViewZ, uCameraNear, uCameraFar, uOccFade); + } + vis /= taps; + float dim = mix(1.0, vis, clamp(uOcclusion, 0.0, 1.0)); + + /* A flare does not stop existing when its source leaves the frame — the + * glass is still lit — but it must not snap off at the border either. */ + float outside = max(abs(src.x), abs(src.y)); + dim *= 1.0 - smoothstep(uEdgeStart, max(uEdgeEnd, uEdgeStart + 0.001), outside); + dim *= 1.0 - behind; + dim *= clamp(uOpacity, 0.0, 1.0) * max(uIntensity, 0.0); + + /* ---- place this instance ---- */ + vec2 centre = src; // NDC position of the element's own centre + vec2 half2 = vec2(0.0); // NDC half-extent (x, y) + float spin = 0.0; + float extent = 0.0; // isotropic half-extent, in height fractions + float glow = 1.0; + vec3 tint = uColorCore; + float roundness = 0.0; + float chroma = 0.0; + float polarity = 0.0; + + if (aRole < 0.5) { + /* CORE — the quad has to cover the longest spike and the halo. */ + extent = max(uCoreSize * 3.0, max(uBurstLength * 1.7, uHaloSize * (1.0 + uHaloWidth * 2.5))); + half2 = vec2(extent * 2.0 / aspect, extent * 2.0); + spin = uBurstSpin; + glow = uCoreGlow; + tint = uColorCore; + } else if (aRole < 1.5) { + /* STREAK — length in frame widths, thickness in frame heights. Those are + * different units on purpose: a streak is authored by how far across the + * frame it reaches, and a thickness of "0.012 of the width" would get + * thicker every time somebody widened the window. */ + half2 = vec2(uStreakLength * 2.0, uStreakThickness * 2.0); + spin = uStreakTilt; + glow = uStreakGlow; + tint = uColorStreak; + extent = 1.0; + } else if (aRole < 2.5) { + /* RING — the wide dim halo the iris throws across the frame. */ + centre = src * (1.0 - uRingSpacing); + extent = uRingSize * (1.0 + uRingWidth * 2.0) + 0.01; + half2 = vec2(extent * 2.0 / aspect, extent * 2.0); + glow = uRingGlow * step(0.5, uRing); + tint = uColorRing; + } else { + /* GHOST — bounce n along the line from the source through the centre. + * A spacing over 1 puts the ghost on the far side of the frame, which is + * where half of a real train ends up; nothing here clamps it. */ + float n = aSlot; + float h = hash11(n * 13.31 + uSeed * 7.77); + float h2 = hash11(n * 5.17 + uSeed * 3.11 + 41.0); + float t = uGhostSpacing + uGhostStride * n + (h - 0.5) * 2.0 * uGhostScatter; + centre = src * (1.0 - t); + + float size = uGhostSize * pow(max(uGhostSizeStep, 0.01), n); + size *= 1.0 + (h2 - 0.5) * 2.0 * uGhostSizeScatter; + extent = max(size, 0.0005); + half2 = vec2(extent * 2.0 / aspect, extent * 2.0); + + spin = uGhostSpin * n; + glow = uGhostGlow; + roundness = clamp(uGhostRound + uGhostRoundStep * n, 0.0, 1.0); + chroma = uGhostChroma; + /* Alternate ghosts come off the far surface of the doublet and arrive + * with the polygon inverted — bright rim, hollow middle, then the other + * way about. Turning that off makes the train read as one sprite scaled. */ + polarity = mod(n, 2.0); + + float span = max(uGhostCount - 1.0, 1.0); + tint = gradient4(uColorGhostA, uColorGhostB, uColorGhostC, uColorGhostD, n / span); + dim *= step(n, uGhostCount - 0.5); + } + + /* ---- the quad, in NDC ---- */ + vec2 local = position.xy; // -1..1 + float cs = cos(spin); + float sn = sin(spin); + /* Rotate in *pixel* space or a spun hexagon comes out sheared on a wide + * window: undo the aspect, rotate, redo it. */ + vec2 pix = vec2(local.x * half2.x * aspect, local.y * half2.y); + vec2 rot = vec2(pix.x * cs - pix.y * sn, pix.x * sn + pix.y * cs); + vec2 ndc = centre + vec2(rot.x / aspect, rot.y); + + vLocal = local; + vRole = aRole; + vTint = tint; + vAmp = dim * glow; + vShape = vec4(extent, roundness, chroma, polarity); + + /* Collapsing to a point is cheaper than a discard: an element that is off + * or behind the camera never reaches the rasteriser at all. */ + gl_Position = vec4(vAmp > 0.0001 ? ndc : centre, 0.0, 1.0); + } +`; + +/** + * One fragment shader, four elements, selected on the constant `vRole`. + * + * Every chromatic effect in here is **the same shape evaluated at three + * radii**, one per channel, rather than a hue rotation of one evaluation. + * Dispersion is geometry: the red image of the iris really is a slightly + * different size from the blue one. A hue rotation gives you a rainbow that + * slides around when the ghost moves, which is the giveaway. + */ +const FLARE_FRAGMENT = /* glsl */ ` + uniform float uGlobalGlow; + uniform float uShaderIntensity; + uniform float uSeed; + uniform float uHeadroom; + + /* --- core --- */ + uniform float uCoreSize; + uniform float uBurstBlades; + uniform float uBurstLength; + uniform float uBurstSharp; + uniform float uBurstJitter; + uniform float uHaloSize; + uniform float uHaloWidth; + uniform float uHaloGlow; + uniform vec3 uColorHalo; + + /* --- streak --- */ + uniform float uStreakFalloff; + uniform float uStreakTight; + uniform float uStreakGrain; + uniform float uStreakChroma; + uniform vec3 uColorStreakEdge; + + /* --- ring --- */ + uniform float uRingSize; + uniform float uRingWidth; + uniform float uRingChroma; + uniform float uRingBlades; + + /* --- ghosts --- */ + uniform float uGhostBlades; + uniform float uGhostFill; + uniform float uGhostRim; + uniform float uGhostRimWidth; + uniform float uGhostSoft; + + varying float vRole; + varying vec2 vLocal; + varying vec3 vTint; + varying float vAmp; + varying vec4 vShape; + + ${noiseGLSL} + + const float TAU = 6.28318530718; + + /** + * Distance to a regular polygon of apothem 1, blended toward a circle. + * The blend is what "the far ghosts defocus into discs" is made of. + */ + float irisDistance(vec2 p, float blades, float roundness) { + float r = length(p); + if (r < 1e-6) return 0.0; + float a = atan(p.y, p.x); + float seg = TAU / max(blades, 3.0); + float poly = cos(floor(0.5 + a / seg) * seg - a) * r; + return mix(poly, r, clamp(roundness, 0.0, 1.0)); + } + + /** Filled iris with a bright rim. scale is the dispersion knob. */ + float iris(vec2 p, float blades, float roundness, float scale, float fill, float rim) { + float d = irisDistance(p * scale, blades, roundness); + float body = 1.0 - smoothstep(1.0 - clamp(uGhostSoft, 0.001, 0.999), 1.0, d); + float edge = exp(-abs(d - 1.0) / max(uGhostRimWidth, 0.001)); + return body * fill + edge * rim; + } + + /** Annulus of unit radius, width w, in the same units as iris. */ + float band(vec2 p, float blades, float scale, float w) { + float d = irisDistance(p * scale, blades, 0.85); + return exp(-abs(d - 1.0) * abs(d - 1.0) / max(w * w, 1e-6)); + } + + /** + * Hue-preserving shoulder — see the module comment. Reinhard on the largest + * channel, all three scaled by the same ratio, so a deep blue streak stays + * deep blue however hard it is driven. + */ + vec3 shoulder(vec3 c, float headroom) { + float m = max(max(c.r, c.g), c.b); + if (m <= 1e-5) return c; + float h = max(headroom, 0.05); + float s = (m / (1.0 + m / h)); + return c * (s / m); + } + + void main() { + vec3 rgb = vec3(0.0); + + if (vRole < 0.5) { + /* ---------------------------------------------------------------- */ + /* CORE — hot point, iris starburst, halo */ + /* ---------------------------------------------------------------- */ + /* rf is in fractions of the frame height, so every size slider in this + * block is in the same unit and comparable by eye in the editor. */ + float rf = length(vLocal) * vShape.x; + + /* An even-bladed iris throws as many spikes as it has blades; an odd one + * throws twice as many, because opposite edges are no longer parallel. + * A five-bladed lens with five spikes is one of those details that is + * wrong in most CG flares and instantly right in the ones that work. */ + float blades = max(uBurstBlades, 3.0); + float spikes = mod(blades, 2.0) < 0.5 ? blades : blades * 2.0; + + float ang = atan(vLocal.y, vLocal.x); + float lobe = abs(cos(ang * spikes * 0.5)); + float index = floor((ang + 3.14159265) / (TAU / spikes)); + float jitter = 1.0 + (hash11(index * 3.77 + uSeed * 9.13) - 0.5) * 2.0 * uBurstJitter; + + float reach = max(uBurstLength * jitter, 1e-4); + float spike = pow(lobe, max(uBurstSharp, 1.0)) * exp(-rf / reach); + + float core = exp(-(rf * rf) / max(uCoreSize * uCoreSize, 1e-8)); + float halo = exp(-pow(abs(rf - uHaloSize) / max(uHaloWidth * uHaloSize, 1e-5), 2.0)); + + rgb = vTint * (core + spike) + uColorHalo * halo * uHaloGlow; + } else if (vRole < 1.5) { + /* ---------------------------------------------------------------- */ + /* STREAK — the cylindrical element's smear */ + /* ---------------------------------------------------------------- */ + float x = vLocal.x; + float y = vLocal.y; + + float body = pow(max(1.0 - abs(x), 0.0), max(uStreakFalloff, 0.01)); + float thin = exp(-y * y * max(uStreakTight, 0.1)); + float hot = exp(-abs(y) * 26.0); + + /* Real anamorphic streaks are not a clean gradient — the element is a + * lens, not a light bar, and the smear is grainy along its length. A + * static hash on x is enough; anything animated boils. */ + float grain = 1.0 + (hash11(floor(x * 220.0) + uSeed * 17.0) - 0.5) * uStreakGrain; + + /* The ends of a streak run blue because the coating's transmission falls + * off first at the short end of the spectrum. Two pickers, mixed on the + * distance out, not a hue shift. */ + vec3 tint = mix(vTint, uColorStreakEdge, pow(abs(x), 0.7) * uStreakChroma); + rgb = tint * body * (thin + hot * 0.6) * grain; + } else if (vRole < 2.5) { + /* ---------------------------------------------------------------- */ + /* RING — the wide chromatic iris halo */ + /* ---------------------------------------------------------------- */ + float ext = max(vShape.x, 1e-5); + vec2 p = vLocal * (ext / max(uRingSize, 1e-5)); + float w = max(uRingWidth, 0.002) / max(uRingSize, 1e-5) * ext; + float c = uRingChroma * 0.06; + rgb = vTint * vec3( + band(p, uRingBlades, 1.0 - c, w), + band(p, uRingBlades, 1.0, w), + band(p, uRingBlades, 1.0 + c, w) + ); + } else { + /* ---------------------------------------------------------------- */ + /* GHOST — an image of the iris */ + /* ---------------------------------------------------------------- */ + float roundness = vShape.y; + float c = vShape.z * 0.06; + float fill = mix(uGhostFill, uGhostFill * 0.25, vShape.w); + float rim = mix(uGhostRim, uGhostRim * 1.6, vShape.w); + rgb = vTint * vec3( + iris(vLocal, uGhostBlades, roundness, 1.0 - c, fill, rim), + iris(vLocal, uGhostBlades, roundness, 1.0, fill, rim), + iris(vLocal, uGhostBlades, roundness, 1.0 + c, fill, rim) + ); + } + + rgb *= vAmp * uGlobalGlow * uShaderIntensity; + rgb = shoulder(rgb, uHeadroom); + + /* Additive with alpha 1: three's AdditiveBlending is (SRC_ALPHA, ONE), so + * writing the coverage into alpha as well would square it and quietly + * darken every soft edge in the flare. */ + if (max(max(rgb.r, rgb.g), rgb.b) < 0.0008) discard; + gl_FragColor = vec4(rgb, 1.0); + } +`; + +/* ---------------------------------------------------------------- */ +/* Defaults */ +/* ---------------------------------------------------------------- */ + +/** + * Every canonical key, its default and its unit. + * + * As everywhere else in this library these are *not* an art direction — they + * are the values that make a recognisable flare appear on the first frame so + * the author has something to drag. Anything an ability leaves out of its + * params bag falls back to here and stays there, which is the one way this + * module can be used to break **I1**. + */ +const DEFAULTS = { + /* --- master --- */ + intensity: 1, // multiplies every element + opacity: 1, // fade the whole flare in and out + seed: 0, // unitless dice roll: spike jitter, ghost scatter, streak grain + headroom: 6, // linear-light ceiling of the hue-preserving shoulder + + /* --- occlusion (the point of the module) --- */ + occlusion: 1, // 0..1 — how much the depth test dims the flare + occRadius: 0.02, // screen fraction — the source's apparent half-size + occTaps: 7, // 1..9 kernel taps + occFade: 0.6, // metres over which the depth compare feathers + occSpin: 0, // radians — rotate the kernel off the pixel grid + + /* --- framing --- */ + edgeStart: 0.8, // |NDC| where the flare begins to fade out of frame + edgeEnd: 1.35, // |NDC| where it is gone + + /* --- core + starburst --- */ + coreSize: 0.02, // fraction of frame height + coreGlow: 2.4, + burstBlades: 6, // iris blades; odd counts throw twice as many spikes + burstLength: 0.12, // fraction of frame height + burstSharp: 26, // spike sharpness exponent + burstJitter: 0.35, // 0..1 per-spike length variation + burstSpin: 0.35, // radians + haloSize: 0.05, // fraction of frame height + haloWidth: 0.35, // fraction of haloSize + haloGlow: 0.7, + + /* --- anamorphic streak --- */ + streakLength: 0.3, // fraction of frame WIDTH, per side + streakThickness: 0.014, // fraction of frame height + streakFalloff: 2.2, // how fast it dies along its length + streakTight: 9, // Gaussian tightness across it + streakGlow: 1.3, + streakTilt: 0, // radians off horizontal + streakGrain: 0.35, // 0..1 along-length grain + streakChroma: 0.7, // 0..1 mix toward colorStreakEdge at the ends + + /* --- ghost train --- */ + ghosts: 5, // 0..8 + ghostSpacing: 0.34, // first ghost, fraction of source→centre + ghostStride: 0.3, // added per ghost; > 1 total crosses the centre + ghostScatter: 0.1, // hashed jitter on the spacing + ghostSize: 0.045, // fraction of frame height + ghostSizeStep: 0.88, // multiplied per ghost + ghostSizeScatter: 0.25, + ghostBlades: 6, // ONE iris for the whole flare — see the module comment + ghostRound: 0.1, // 0 polygon, 1 disc + ghostRoundStep: 0.14, // added per ghost + ghostSpin: 0.5, // radians per ghost + ghostFill: 0.3, // interior brightness + ghostRim: 0.9, // rim brightness + ghostRimWidth: 0.14, // rim falloff, in apothems + ghostSoft: 0.22, // edge softness of the fill + ghostChroma: 0.5, // dispersion between the R and B evaluations + ghostGlow: 1, + + /* --- iris ring --- */ + ring: 1, // 0 hides it + ringSpacing: 1.15, // fraction of source→centre; > 1 is the far side + ringSize: 0.26, // fraction of frame height + ringWidth: 0.03, // fraction of frame height + ringBlades: 6, + ringChroma: 1, // dispersion across the band + ringGlow: 0.45, + + /* --- colours: seven pickers, none derived --- */ + colorCore: '#fff4dc', + colorHalo: '#ffd7a0', + colorStreak: '#bfe4ff', + colorStreakEdge: '#2f5cff', + colorGhostA: '#ffd9a0', + colorGhostB: '#ff7fae', + colorGhostC: '#7fe0d0', + colorGhostD: '#8fa8ff', + colorRing: '#9fd8ff' +}; + +/** Every canonical key with its default. This function is the key list. */ +export function lensFlareParams() { + return { ...DEFAULTS }; +} + +const num = (v, d) => (v === undefined || v === null ? d : v); + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope, reused, never allocated in a frame (I3) */ +/* ---------------------------------------------------------------- */ + +const _anchor = new Vector3(); + +/* ---------------------------------------------------------------- */ +/* LensFlare */ +/* ---------------------------------------------------------------- */ + +/** + * ```js + * const _f = lensFlareParams(); // module scope — I3 + * const _p = new Vector3(); + * + * this.flare = new LensFlare({ ghosts: 6 }); + * this.group.add(this.flare.object3D); + * // … + * onTravel(dt) { + * const c = settings.solarlens; + * this.flare.setAnchor(this.lensPoint(_p)); // world metres, this frame + * _f.intensity = c.flareIntensity * this.charge; + * _f.occRadius = c.flareOccRadius; // screen fraction + * _f.ghosts = c.flareGhosts; + * _f.streakLength = c.flareStreak; + * _f.colorCore = c.colorFlareCore; + * this.flare.update(_f); + * this.flare.visible = this.charge > 0.01; + * } + * ``` + */ +export class LensFlare { + /** + * @param {object} [options] + * @param {number} [options.ghosts] instances built for the ghost train. + * This is the *capacity*; `params.ghosts` is how many draw. Building + * fewer than you will ever ask for silently caps the slider. + * @param {number} [options.renderOrder] default 3000 — after the scene + * @param {number} [options.layer] default `LAYER.VFX` + * @param {string} [options.name] + */ + constructor({ ghosts = MAX_FLARE_GHOSTS, renderOrder = 3000, layer = LAYER.VFX, name } = {}) { + /** How many ghost instances exist. `update()` cannot exceed it. */ + this.capacity = Math.max(0, Math.min(MAX_FLARE_GHOSTS, Math.round(ghosts))); + + const total = FIXED_INSTANCES + this.capacity; + + /* A 2×2 plane so `position.xy` is already the -1..1 quad coordinate the + * shaders want; the base geometry is never rendered or uploaded itself. */ + const base = new PlaneGeometry(2, 2, 1, 1); + const geometry = new InstancedBufferGeometry(); + geometry.index = base.index; + geometry.setAttribute('position', base.attributes.position); + geometry.setAttribute('uv', base.attributes.uv); + + const roles = new Float32Array(total); + const slots = new Float32Array(total); + for (let i = 0; i < total; i++) { + roles[i] = i < FIXED_INSTANCES ? i : FlareRole.GHOST; + slots[i] = i < FIXED_INSTANCES ? 0 : i - FIXED_INSTANCES; + } + geometry.setAttribute('aRole', new InstancedBufferAttribute(roles, 1)); + geometry.setAttribute('aSlot', new InstancedBufferAttribute(slots, 1)); + geometry.instanceCount = total; + this.geometry = geometry; + + this.material = new ShaderMaterial({ + name: name ? `${name}:flare` : 'LensFlare', + transparent: true, + // A lens artefact is in front of everything by definition. This is also + // why the module has to do its own occlusion — see the header. + depthTest: false, + depthWrite: false, + blending: AdditiveBlending, + side: DoubleSide, + // Never let the renderer put a curve on this directly; the shoulder in + // the fragment shader is the only compression the flare wants. + toneMapped: false, + uniforms: sharedUniforms({ + uAnchor: { value: new Vector3() }, + + uOcclusion: { value: DEFAULTS.occlusion }, + uOccRadius: { value: DEFAULTS.occRadius }, + uOccTaps: { value: DEFAULTS.occTaps }, + uOccFade: { value: DEFAULTS.occFade }, + uOccSpin: { value: DEFAULTS.occSpin }, + + uEdgeStart: { value: DEFAULTS.edgeStart }, + uEdgeEnd: { value: DEFAULTS.edgeEnd }, + uIntensity: { value: DEFAULTS.intensity }, + uOpacity: { value: DEFAULTS.opacity }, + uSeed: { value: DEFAULTS.seed }, + uHeadroom: { value: DEFAULTS.headroom }, + + uCoreSize: { value: DEFAULTS.coreSize }, + uCoreGlow: { value: DEFAULTS.coreGlow }, + uBurstBlades: { value: DEFAULTS.burstBlades }, + uBurstLength: { value: DEFAULTS.burstLength }, + uBurstSharp: { value: DEFAULTS.burstSharp }, + uBurstJitter: { value: DEFAULTS.burstJitter }, + uBurstSpin: { value: DEFAULTS.burstSpin }, + uHaloSize: { value: DEFAULTS.haloSize }, + uHaloWidth: { value: DEFAULTS.haloWidth }, + uHaloGlow: { value: DEFAULTS.haloGlow }, + + uStreakLength: { value: DEFAULTS.streakLength }, + uStreakThickness: { value: DEFAULTS.streakThickness }, + uStreakFalloff: { value: DEFAULTS.streakFalloff }, + uStreakTight: { value: DEFAULTS.streakTight }, + uStreakGlow: { value: DEFAULTS.streakGlow }, + uStreakTilt: { value: DEFAULTS.streakTilt }, + uStreakGrain: { value: DEFAULTS.streakGrain }, + uStreakChroma: { value: DEFAULTS.streakChroma }, + + uGhostCount: { value: DEFAULTS.ghosts }, + uGhostSpacing: { value: DEFAULTS.ghostSpacing }, + uGhostStride: { value: DEFAULTS.ghostStride }, + uGhostScatter: { value: DEFAULTS.ghostScatter }, + uGhostSize: { value: DEFAULTS.ghostSize }, + uGhostSizeStep: { value: DEFAULTS.ghostSizeStep }, + uGhostSizeScatter: { value: DEFAULTS.ghostSizeScatter }, + uGhostBlades: { value: DEFAULTS.ghostBlades }, + uGhostRound: { value: DEFAULTS.ghostRound }, + uGhostRoundStep: { value: DEFAULTS.ghostRoundStep }, + uGhostSpin: { value: DEFAULTS.ghostSpin }, + uGhostFill: { value: DEFAULTS.ghostFill }, + uGhostRim: { value: DEFAULTS.ghostRim }, + uGhostRimWidth: { value: DEFAULTS.ghostRimWidth }, + uGhostSoft: { value: DEFAULTS.ghostSoft }, + uGhostChroma: { value: DEFAULTS.ghostChroma }, + uGhostGlow: { value: DEFAULTS.ghostGlow }, + + uRing: { value: DEFAULTS.ring }, + uRingSpacing: { value: DEFAULTS.ringSpacing }, + uRingSize: { value: DEFAULTS.ringSize }, + uRingWidth: { value: DEFAULTS.ringWidth }, + uRingBlades: { value: DEFAULTS.ringBlades }, + uRingChroma: { value: DEFAULTS.ringChroma }, + uRingGlow: { value: DEFAULTS.ringGlow }, + + uColorCore: { value: new Color(DEFAULTS.colorCore) }, + uColorHalo: { value: new Color(DEFAULTS.colorHalo) }, + uColorStreak: { value: new Color(DEFAULTS.colorStreak) }, + uColorStreakEdge: { value: new Color(DEFAULTS.colorStreakEdge) }, + uColorGhostA: { value: new Color(DEFAULTS.colorGhostA) }, + uColorGhostB: { value: new Color(DEFAULTS.colorGhostB) }, + uColorGhostC: { value: new Color(DEFAULTS.colorGhostC) }, + uColorGhostD: { value: new Color(DEFAULTS.colorGhostD) }, + uColorRing: { value: new Color(DEFAULTS.colorRing) } + }), + vertexShader: FLARE_VERTEX, + fragmentShader: FLARE_FRAGMENT + }); + + this.mesh = new Mesh(geometry, this.material); + this.mesh.name = name || 'LensFlare'; + this.mesh.layers.set(layer); + this.mesh.renderOrder = renderOrder; + // Built in NDC: the bounding sphere is meaningless and the matrix is never + // anything but identity. + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.visible = false; + + this._visible = false; + } + + /** Add this to the ability's group. */ + get object3D() { + return this.mesh; + } + + /** One, always — every element is an instance of the same quad. */ + get drawCalls() { + return 1; + } + + get visible() { + return this._visible; + } + + set visible(v) { + this._visible = !!v; + this.mesh.visible = this._visible; + } + + /** Pin the flare to a world point. Metres. */ + setAnchor(v) { + this.material.uniforms.uAnchor.value.copy(v); + return this; + } + + /** Pin the flare to a world point, component-wise. */ + setAnchorXYZ(x, y, z) { + this.material.uniforms.uAnchor.value.set(x, y, z); + return this; + } + + /** Where the flare currently hangs, for an ability that wants to read it back. */ + anchor(out = _anchor) { + return out.copy(this.material.uniforms.uAnchor.value); + } + + /** + * Re-resolve everything. Call it every frame, including a zero-length one. + * + * Keys are exactly those of `lensFlareParams()`; anything omitted falls back + * to the module default and *stays* there, which is the one way to break I1 + * with this module. + * + * @param {object} p a plain object. Keep it at module scope and refill it. + */ + update(p) { + const u = this.material.uniforms; + + u.uIntensity.value = num(p.intensity, DEFAULTS.intensity); + u.uOpacity.value = num(p.opacity, DEFAULTS.opacity); + u.uSeed.value = num(p.seed, DEFAULTS.seed); + u.uHeadroom.value = num(p.headroom, DEFAULTS.headroom); + + /* README trap 7, inverted: with nothing bound to `uSceneDepth` the sampler + * reads zeroes, `perspectiveDepthToViewZ(0)` lands on the near plane and + * the kernel decides the flare is buried in a wall — the flare would simply + * never appear, in the app *and* in any bespoke harness that skips the + * prepass. Degrade by data rather than by branch: no buffer, no test. */ + const hasDepth = frame.uSceneDepth.value !== null && frame.uSceneDepth.value !== undefined; + u.uOcclusion.value = hasDepth ? num(p.occlusion, DEFAULTS.occlusion) : 0; + u.uOccRadius.value = num(p.occRadius, DEFAULTS.occRadius); + u.uOccTaps.value = Math.max(1, Math.min(MAX_OCC_TAPS, num(p.occTaps, DEFAULTS.occTaps))); + u.uOccFade.value = num(p.occFade, DEFAULTS.occFade); + u.uOccSpin.value = num(p.occSpin, DEFAULTS.occSpin); + + u.uEdgeStart.value = num(p.edgeStart, DEFAULTS.edgeStart); + u.uEdgeEnd.value = num(p.edgeEnd, DEFAULTS.edgeEnd); + + u.uCoreSize.value = num(p.coreSize, DEFAULTS.coreSize); + u.uCoreGlow.value = num(p.coreGlow, DEFAULTS.coreGlow); + u.uBurstBlades.value = num(p.burstBlades, DEFAULTS.burstBlades); + u.uBurstLength.value = num(p.burstLength, DEFAULTS.burstLength); + u.uBurstSharp.value = num(p.burstSharp, DEFAULTS.burstSharp); + u.uBurstJitter.value = num(p.burstJitter, DEFAULTS.burstJitter); + u.uBurstSpin.value = num(p.burstSpin, DEFAULTS.burstSpin); + u.uHaloSize.value = num(p.haloSize, DEFAULTS.haloSize); + u.uHaloWidth.value = num(p.haloWidth, DEFAULTS.haloWidth); + u.uHaloGlow.value = num(p.haloGlow, DEFAULTS.haloGlow); + + u.uStreakLength.value = num(p.streakLength, DEFAULTS.streakLength); + u.uStreakThickness.value = num(p.streakThickness, DEFAULTS.streakThickness); + u.uStreakFalloff.value = num(p.streakFalloff, DEFAULTS.streakFalloff); + u.uStreakTight.value = num(p.streakTight, DEFAULTS.streakTight); + u.uStreakGlow.value = num(p.streakGlow, DEFAULTS.streakGlow); + u.uStreakTilt.value = num(p.streakTilt, DEFAULTS.streakTilt); + u.uStreakGrain.value = num(p.streakGrain, DEFAULTS.streakGrain); + u.uStreakChroma.value = num(p.streakChroma, DEFAULTS.streakChroma); + + const ghosts = Math.max(0, Math.min(this.capacity, Math.round(num(p.ghosts, DEFAULTS.ghosts)))); + u.uGhostCount.value = ghosts; + u.uGhostSpacing.value = num(p.ghostSpacing, DEFAULTS.ghostSpacing); + u.uGhostStride.value = num(p.ghostStride, DEFAULTS.ghostStride); + u.uGhostScatter.value = num(p.ghostScatter, DEFAULTS.ghostScatter); + u.uGhostSize.value = num(p.ghostSize, DEFAULTS.ghostSize); + u.uGhostSizeStep.value = num(p.ghostSizeStep, DEFAULTS.ghostSizeStep); + u.uGhostSizeScatter.value = num(p.ghostSizeScatter, DEFAULTS.ghostSizeScatter); + u.uGhostBlades.value = num(p.ghostBlades, DEFAULTS.ghostBlades); + u.uGhostRound.value = num(p.ghostRound, DEFAULTS.ghostRound); + u.uGhostRoundStep.value = num(p.ghostRoundStep, DEFAULTS.ghostRoundStep); + u.uGhostSpin.value = num(p.ghostSpin, DEFAULTS.ghostSpin); + u.uGhostFill.value = num(p.ghostFill, DEFAULTS.ghostFill); + u.uGhostRim.value = num(p.ghostRim, DEFAULTS.ghostRim); + u.uGhostRimWidth.value = num(p.ghostRimWidth, DEFAULTS.ghostRimWidth); + u.uGhostSoft.value = num(p.ghostSoft, DEFAULTS.ghostSoft); + u.uGhostChroma.value = num(p.ghostChroma, DEFAULTS.ghostChroma); + u.uGhostGlow.value = num(p.ghostGlow, DEFAULTS.ghostGlow); + + u.uRing.value = num(p.ring, DEFAULTS.ring); + u.uRingSpacing.value = num(p.ringSpacing, DEFAULTS.ringSpacing); + u.uRingSize.value = num(p.ringSize, DEFAULTS.ringSize); + u.uRingWidth.value = num(p.ringWidth, DEFAULTS.ringWidth); + u.uRingBlades.value = num(p.ringBlades, DEFAULTS.ringBlades); + u.uRingChroma.value = num(p.ringChroma, DEFAULTS.ringChroma); + u.uRingGlow.value = num(p.ringGlow, DEFAULTS.ringGlow); + + copyColor(u.uColorCore.value, p.colorCore || DEFAULTS.colorCore); + copyColor(u.uColorHalo.value, p.colorHalo || DEFAULTS.colorHalo); + copyColor(u.uColorStreak.value, p.colorStreak || DEFAULTS.colorStreak); + copyColor(u.uColorStreakEdge.value, p.colorStreakEdge || DEFAULTS.colorStreakEdge); + copyColor(u.uColorGhostA.value, p.colorGhostA || DEFAULTS.colorGhostA); + copyColor(u.uColorGhostB.value, p.colorGhostB || DEFAULTS.colorGhostB); + copyColor(u.uColorGhostC.value, p.colorGhostC || DEFAULTS.colorGhostC); + copyColor(u.uColorGhostD.value, p.colorGhostD || DEFAULTS.colorGhostD); + copyColor(u.uColorRing.value, p.colorRing || DEFAULTS.colorRing); + + // Instances the ghost count has switched off never reach the rasteriser. + this.geometry.instanceCount = FIXED_INSTANCES + ghosts; + + return this; + } + + dispose() { + this.visible = false; + this.geometry.dispose(); + this.material.dispose(); + this.mesh.parent?.remove(this.mesh); + } +} diff --git a/src/vfx/LightShaft.js b/src/vfx/LightShaft.js new file mode 100644 index 0000000..ea82ac2 --- /dev/null +++ b/src/vfx/LightShaft.js @@ -0,0 +1,1029 @@ +import { + AdditiveBlending, + BackSide, + Color, + CylinderGeometry, + InstancedBufferAttribute, + InstancedBufferGeometry, + Mesh, + ShaderMaterial, + Sphere, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { getColor } from '../utils/color.js'; + +/* ---------------------------------------------------------------------- */ +/* LightShaft — light in air, integrated along the view ray */ +/* ---------------------------------------------------------------------- */ + +/** + * Volumetric shafts: a real in-scattering integral through a cone of lit air, + * clamped by the depth buffer, landing in a bright band on the ground that is + * produced by the same integral rather than decalled on afterwards. + * + * ## Why this is not `Curtain(SHAFT)` + * + * `Curtain` already has a `SHAFT` mode and it is good; it is also a **sheet**, + * and three of the four things a shaft has to do are not expressible on a sheet. + * That was checked before this file was written, not asserted after: + * + * 1. **Anisotropy.** The single thing that makes light in air read as light in + * air is that a shaft seen nearly end-on is several times brighter than the + * same shaft seen across. That is the Henyey–Greenstein phase function of the + * angle between the view ray and the *shaft's axis*. A sheet's only angular + * term is `1/|N·V|` against the *sheet's normal*, which is a proxy for path + * length through a fold and points the wrong way: it peaks when you look + * along the sheet, not when you look along the beam. Curtain's own + * documentation is honest about what that term is for. + * 2. **A path length that is a path length.** A sheet has no thickness, so its + * brightness is a function of where you hit it. Here the ray genuinely enters + * and leaves a cone, and the segment between them is integrated. Move the + * camera towards the axis and the segment lengthens on its own; no term had + * to be written to make that happen. + * 3. **A soft floor intersection.** A sheet meeting the ground gives a straight + * cut, which is why Curtain has `footFade`. Here the far end of the integral + * is the depth buffer, feathered over `contact` metres, so the shaft dies + * into whatever it actually meets — floor, character, a rock — at the right + * distance and with the right shape. + * 4. **The band on the ground.** Curtain's floor companion is a second quad with + * its own pool term, and the README says so. Here the band *is* the shaft: + * the view ray terminates on the floor, we know where, and we evaluate the + * shaft's own radial falloff, its own gobo silhouette and its own axial + * extinction at that point and add the bounce. Drag `gobo` and the leaf-gaps + * on the floor change with the gaps in the air, because they are one field. + * + * Extending `Curtain` would have meant a second geometry, a second placement + * path, a second integral and a mode that shares nothing with the other two but + * the file. `Curtain(SHAFT)` remains the right answer for a rank of cheap + * god-rays behind something; this is the right answer when the shaft is the + * subject. + * + * ## What it draws + * + * One `InstancedBufferGeometry` — a tapered hull per shaft — and **one draw + * call** for all of them, no textures. The hull is only a bound: everything + * visible is solved analytically against the shaft primitive inside it, so the + * hull's tessellation changes nothing but the silhouette of the region that gets + * fragments. + * + * The material renders **back faces with the depth test off**. Back faces + * because every view ray that enters the hull then has exactly one fragment to + * do its integral in, whether the camera is inside the hull or outside it; the + * caps are on for the same reason (open-ended, a ray straight down the axis + * finds no back face and punches a hole through the middle of the shaft). The + * depth test is off because occlusion is *part of the integral* — a shaft behind + * a wall must be dark because its light never reaches you, not because a test + * threw the fragment away, and the difference shows the moment a character's + * shoulder is halfway into it. + * + * ## Invariants + * + * - **I1** — per-shaft state is four dice and an index. Every metre — length, + * the two radii, spacing, ring radius, scatter — is re-resolved from `update()` + * each frame in the vertex shader, so a paused shaft re-lays itself under a + * slider drag. There is nothing dimensional on the CPU but the scratch vectors + * that place the anchor. + * - **I3** — `roll()` refills existing typed arrays; `update()` writes into + * existing uniform boxes; the CPU mirrors write into a caller's `out`. + * - **I5** — four pickers (`colorMouth`, `colorFoot`, `colorMote`, `colorPool`), + * none derived from another. + * - **I6** — no light pool use at all. A shaft is not a `PointLight`. + * + * @example + * // construction — godspear + * this.shafts = new LightShaft(this.group, { layout: ShaftLayout.LINE, capacity: 8 }); + * + * // module scope + * const _shaft = lightShaftParams(); + * + * // onSpawn + * this.shafts.roll(); + * + * // every frame + * this.shafts.setPlacement(this.origin, this.direction, _up); + * _shaft.count = c.shaftCount; + * _shaft.length = c.shaftLength; // metres, re-read every frame + * _shaft.sweep = this.travel; // 0..1 — which shaft is lit now + * this.shafts.update(_shaft); + * + * // and the scene's own dust, brightened as it crosses one: + * const lit = this.shafts.irradianceAt(motePosition, _shaft); // 0..1 + */ + +/** How the shafts are laid out around the anchor. A uniform, not a `#define`. */ +export const ShaftLayout = Object.freeze({ + SINGLE: 0, + LINE: 1, + RING: 2, + SCATTER: 3 +}); + +/** Human names, for the editor and for `check.mjs` error messages. */ +export const SHAFT_LAYOUT_NAMES = Object.freeze(['SINGLE', 'LINE', 'RING', 'SCATTER']); + +/* ---------------------------------------------------------------------- */ +/* Vertex — the hull, and nothing else */ +/* ---------------------------------------------------------------------- */ + +/** + * The hull is a unit capped cylinder that the vertex shader bends into each + * shaft's own truncated cone. + * + * It carries no lighting at all. Everything the fragment needs about the shaft + * arrives as five varyings describing the *primitive* — mouth, axis, length, + * the two radii — and the fragment solves against those, not against the + * triangles. That is why `sides` can be twelve: the hull is a bound, and a + * coarser bound only means slightly more fragments that immediately find no + * intersection and discard. + */ +const SHAFT_VERTEX = /* glsl */ ` + #define TAU 6.283185307179586 + + #define SL_SINGLE 0 + #define SL_LINE 1 + #define SL_RING 2 + #define SL_SCATTER 3 + + uniform vec3 uAnchor; // world, on the floor + uniform vec3 uAlong; // unit, the cast's heading + uniform vec3 uSide; // unit, across it + uniform vec3 uUp; // unit, the shaft's own up. Pass -frame.uLightDir for sun shafts + + uniform float uLayout; + uniform float uCount; + uniform float uSpacing; // metres between shafts (LINE) + uniform float uRing; // metres (RING) + uniform float uScatter; // metres of positional slop + uniform float uSeed; + + uniform float uLength; // metres, mouth to foot + uniform float uRadiusMouth; // metres + uniform float uRadiusFoot; // metres + uniform float uLengthJitter; // 0..1 + uniform float uRadiusJitter; // 0..1 + uniform float uHullPad; // how much wider than the shaft the bound is + + uniform float uFade; + uniform float uSweep; // 0..1 — where the lit window sits along the rank + uniform float uSweepWidth; // 0..1 of the rank + + attribute float aIndex; + attribute vec4 aDice; // four unitless rolls. The only captured state + + varying vec3 vMouth; + varying vec3 vAxis; // unit, pointing DOWN the shaft + varying vec3 vWorld; + varying vec3 vRadii; // x = at the mouth, y = at the foot, z = length + varying vec2 vShaft; // x = per-shaft amplitude, y = seed + + void main() { + if (aIndex > uCount - 0.5) { + // Collapsed off-screen rather than scaled to zero: a degenerate triangle + // still rasterises a sliver on some drivers, and this shader's fragment is + // the expensive one in the file. + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + float slot = uCount > 1.5 ? aIndex / (uCount - 1.0) : 0.5; + + /* ---- where this shaft stands ---- */ + vec3 offset = vec3(0.0); + // NOT called 'layout': that identifier is a keyword in GLSL ES 3.00, and a + // shader that only ever compiles under 1.00 today is one glslVersion away + // from a build error nobody would connect to this line. + // (And no backticks in here. This comment cost a round-trip proving that + // README trap #1 is still live in 2026.) + int rank = int(uLayout + 0.5); + if (rank == SL_LINE) { + offset = uAlong * ((aIndex - (uCount - 1.0) * 0.5) * uSpacing); + } else if (rank == SL_RING) { + float bearing = TAU * (aIndex / max(uCount, 1.0)) + uSeed; + offset = (uSide * cos(bearing) + uAlong * sin(bearing)) * uRing; + } else if (rank == SL_SCATTER) { + float bearing = TAU * aDice.x; + float reach = sqrt(aDice.y) * uRing; + offset = (uSide * cos(bearing) + uAlong * sin(bearing)) * reach; + } + offset += (uSide * (aDice.z - 0.5) + uAlong * (aDice.w - 0.5)) * uScatter; + + /* ---- how big it is, resolved from live metres every frame ---- */ + float len = max(uLength * (1.0 + (aDice.x - 0.5) * uLengthJitter), 0.05); + float jr = 1.0 + (aDice.y - 0.5) * uRadiusJitter; + float rMouth = max(uRadiusMouth * jr, 0.01); + float rFoot = max(uRadiusFoot * jr, 0.01); + + vec3 axis = normalize(-uUp); + vec3 foot = uAnchor + offset; + vec3 mouth = foot - axis * len; + + /* ---- the sweep: a window travelling down the rank ---- */ + float window = 1.0; + if (uSweepWidth < 0.999) { + float d = abs(slot - uSweep) / max(uSweepWidth, 1e-3); + window = 1.0 - smoothstep(0.6, 1.0, d); + } + + vMouth = mouth; + vAxis = axis; + vRadii = vec3(rMouth, rFoot, len); + vShaft = vec2(uFade * window, aDice.z * 37.0 + aIndex * 11.7 + uSeed); + + /* ---- the bound ---- + * position.y is +0.5 at the mouth and -0.5 at the foot; the caps sit at + * those two planes with |position.xz| <= 1, so one expression places wall + * and cap alike. */ + float k = 0.5 - position.y; // 0 at the mouth, 1 at the foot + float rad = mix(rMouth, rFoot, k) * uHullPad; + // The same basis the fragment derives, by the same rule, so the hull cannot + // end up rotated a few degrees off the primitive it is supposed to bound. + // Derived rather than passed: two more varyings to save four instructions. + vec3 ref = abs(axis.y) > 0.95 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0); + vec3 perpA = normalize(cross(ref, axis)); + vec3 perpB = cross(axis, perpA); + vec3 world = mouth + axis * (k * len) + (perpA * position.x + perpB * position.z) * rad; + + vWorld = world; + gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0); + } +`; + +/* ---------------------------------------------------------------------- */ +/* Fragment — the integral */ +/* ---------------------------------------------------------------------- */ + +const SHAFT_FRAGMENT = /* glsl */ ` + uniform float uTime; + + /* ---- the medium ---- */ + uniform float uSteps; // marching samples; capped by MAX_STEPS + uniform float uJitter; // 0..1 dither on the first sample, kills the banding + uniform float uDensity; // per metre — the in-scattering coefficient + uniform float uExtinct; // 1/metres, down the shaft: the beam loses energy + uniform float uSoft; // widens the gaussian core. NOT an edge + uniform float uAxialCurve; // how the medium thins toward the mouth + uniform float uAxialMouth; // 0..1 density AT the mouth — never let this be 0 + uniform float uAnisotropy; // -0.95..0.95, the Henyey-Greenstein g + uniform float uContact; // metres of feather where the shaft meets geometry + + /* ---- the shaft's own silhouette ---- */ + uniform float uGobo; // 0..1 how much of the shaft the gaps eat + uniform float uGoboScale; // cycles per metre, across the shaft + uniform float uGoboBias; // moves the gate: bigger = more open sky + uniform float uGoboDrift; // radians/second the canopy stirs + + /* ---- dust ---- */ + uniform float uMote; // 0 disables the lattice outright + uniform float uMoteScale; // cells per metre + uniform float uMoteSize; // 0..1 of a cell + uniform float uMoteFall; // cells/second the lattice drifts down + + /* ---- the ground ---- */ + uniform float uBounce; // how much of the landed light comes back at you + uniform float uPoolSoft; // widens the band's gaussian + uniform float uLandBand; // metres either side of the foot plane that count + + /* ---- output ---- */ + uniform float uIntensity; + uniform vec3 uColorMouth; + uniform vec3 uColorFoot; + uniform vec3 uColorMote; + uniform vec3 uColorPool; + + uniform sampler2D uSceneDepth; + uniform vec2 uResolution; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + + varying vec3 vMouth; + varying vec3 vAxis; + varying vec3 vWorld; + varying vec3 vRadii; + varying vec2 vShaft; + + ${noiseGLSL} + ${commonGLSL} + + /** + * Henyey-Greenstein, normalised so g = 0 gives exactly 1. + * + * This is the whole reason the module exists. 'c' is the cosine between the + * direction the light is travelling and the direction it would have to go to + * reach the eye; at g = 0.7 a shaft looked at nearly end-on is about eight + * times the brightness of the same shaft looked at across, and that ratio is + * what the eye reads as "there is light in the air" rather than "there is a + * translucent cone in the scene". + * + * The denominator is (1 + g^2 - 2gc)^1.5 and cannot go negative for |g| < 1, + * because its minimum is (1 - |g|)^2. It is still clamped: g arrives from a + * slider and 1.0 is one drag away. + */ + float shaftPhase(float g, float c) { + float gg = clamp(g, -0.95, 0.95); + float d = 1.0 + gg * gg - 2.0 * gg * c; + return (1.0 - gg * gg) / pow(max(d, 1e-4), 1.5); + } + + /** The canopy the light came through. Fixed across the shaft, so gaps are gaps. */ + float shaftGobo(vec2 cross2, float seed) { + if (uGobo <= 0.0) return 1.0; + float n = snoise(vec3(cross2 * uGoboScale, seed + uTime * uGoboDrift)); + return mix(1.0, smoothstep(-uGoboBias, uGoboBias + 0.4, n), uGobo); + } + + void main() { + float amp = vShaft.x; + if (amp <= 0.002) discard; + + vec3 ro = cameraPosition; + vec3 rd = normalize(vWorld - ro); + + vec3 O = vMouth; + vec3 D = vAxis; + float len = vRadii.z; + float rMouth = vRadii.x; + float rFoot = vRadii.y; + float rMax = max(rMouth, rFoot); + + /* ---- how far the ray may go before something opaque stops it ---- */ + vec2 screenUV = gl_FragCoord.xy / uResolution; + float depthBits = unpackRGBAToDepth(texture2D(uSceneDepth, screenUV)); + float sceneViewZ = perspectiveDepthToViewZ(depthBits, uCameraNear, uCameraFar); + // The third row of the view matrix is the camera's own -Z in world space, so + // this converts a view-space depth into a distance along OUR ray without + // needing the inverse projection. + vec3 row2 = vec3(viewMatrix[0][2], viewMatrix[1][2], viewMatrix[2][2]); + float axialCos = dot(row2, rd); + float sScene = abs(axialCos) > 1e-4 ? sceneViewZ / axialCos : 1e6; + if (sScene < 0.0) sScene = 1e6; + + /* ---- the axial slab: 0 <= a <= len ---- */ + vec3 w = ro - O; + float a0 = dot(w, D); + float ad = dot(rd, D); + float sNear = 0.0; + float sFar = 1e6; + if (abs(ad) > 1e-5) { + float s1 = -a0 / ad; + float s2 = (len - a0) / ad; + sNear = min(s1, s2); + sFar = max(s1, s2); + } else if (a0 < 0.0 || a0 > len) { + discard; + } + + /* ---- the enclosing cylinder ---- */ + vec3 wp = w - D * a0; + vec3 rp = rd - D * ad; + float A = dot(rp, rp); + float B = 2.0 * dot(wp, rp); + float C = dot(wp, wp) - rMax * rMax; + if (A > 1e-6) { + float disc = B * B - 4.0 * A * C; + if (disc < 0.0) discard; + float sq = sqrt(disc); + sNear = max(sNear, (-B - sq) / (2.0 * A)); + sFar = min(sFar, (-B + sq) / (2.0 * A)); + } else if (C > 0.0) { + discard; + } + + sNear = max(sNear, 0.0); + // Beyond the scene surface plus the contact feather there is nothing left to + // integrate, so the march stops there rather than spending samples inside a + // wall. The feather itself is applied per sample, below. + sFar = min(sFar, sScene + uContact); + if (sFar <= sNear) discard; + + /* ---- march ---- */ + float steps = clamp(uSteps, 4.0, float(MAX_STEPS)); + float ds = (sFar - sNear) / steps; + float dither = uJitter * (hash13(vec3(gl_FragCoord.xy, uTime * 60.0)) - 0.5); + float s = sNear + ds * (0.5 + dither); + + // A basis across the shaft, derived rather than passed: two more varyings + // would have cost a slot on WebGL1 hardware to save four instructions. + vec3 up = abs(D.y) > 0.95 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0); + vec3 sideA = normalize(cross(up, D)); + vec3 sideB = cross(D, sideA); + + float accMouth = 0.0; + float accFoot = 0.0; + float accMote = 0.0; + + for (int i = 0; i < MAX_STEPS; i++) { + if (float(i) >= steps) break; + + vec3 P = ro + rd * s; + float a = dot(P - O, D); + float k = clamp(a / len, 0.0, 1.0); + + vec3 off = (P - O) - D * a; + float rad = mix(rMouth, rFoot, k); + float r2 = dot(off, off); + // A gaussian, not a smoothstep. A shaft has a falloff, not an edge, and a + // smoothstep draws the surface of the cone you were trying not to have. + float fall = exp(-r2 / max(rad * rad * uSoft, 1e-6)); + + if (fall > 0.0015) { + vec2 cross2 = vec2(dot(off, sideA), dot(off, sideB)); + float gate = shaftGobo(cross2, vShaft.y); + // Thinner near the mouth: dust settles, and a column of even density + // reads as a solid object because nothing about it varies. The mix + // floor is not tidiness — a bare pow(k, c) is exactly zero at the mouth, + // which puts a hard flat disc of nothing where the shaft enters and + // reads as the shaft having been cut off with scissors. + float axial = mix(uAxialMouth, 1.0, pow(k, max(uAxialCurve, 0.001))); + // Feathered against whatever is in front. This is the soft intersection: + // the medium stops existing over uContact metres as it reaches the floor. + float clip = smoothstep(0.0, max(uContact, 1e-3), sScene - s); + float dens = fall * gate * axial * clip; + float trans = exp(-max(uExtinct, 0.0) * a); + float lit = dens * trans; + + accMouth += lit * (1.0 - k); + accFoot += lit * k; + + if (uMote > 0.0) { + vec3 q = P * uMoteScale + vec3(0.0, uTime * uMoteFall, 0.0); + vec3 ci = floor(q); + vec3 cf = fract(q); + vec3 o = hash31(dot(ci, vec3(7.13, 113.17, 31.71))); + float d = length(cf - o); + accMote += smoothstep(max(uMoteSize, 1e-3), 0.0, d) * lit; + } + } + + s += ds; + } + + float scale = ds * uDensity; + accMouth *= scale; + accFoot *= scale; + accMote *= scale; + + /* ---- anisotropy ---- + * cos between the direction the light travels (D) and the direction back to + * the eye (-rd). Looking up the shaft toward its mouth gives c -> 1 and the + * forward lobe. */ + float phase = shaftPhase(uAnisotropy, dot(D, -rd)); + + /* ---- the band on the ground ---- + * Not a decal. The ray stopped somewhere; if that somewhere is inside the + * shaft's footprint and near its foot plane, the light that landed there is + * bouncing back at us, and it carries the same radial falloff, the same + * canopy gaps and the same axial extinction the air above it has. */ + float pool = 0.0; + if (uBounce > 0.0 && sScene < 1e5) { + vec3 hit = ro + rd * sScene; + float ha = dot(hit - O, D); + vec3 hoff = (hit - O) - D * ha; + float hk = clamp(ha / len, 0.0, 1.0); + float hrad = mix(rMouth, rFoot, hk); + float hfall = exp(-dot(hoff, hoff) / max(hrad * hrad * uPoolSoft, 1e-6)); + float band = 1.0 - smoothstep(0.0, max(uLandBand, 1e-3), abs(ha - len)); + vec2 hcross = vec2(dot(hoff, sideA), dot(hoff, sideB)); + // Lambert against a horizontal receiver. The module cannot know the real + // normal without a normal buffer, and the ground is what a shaft lands on. + float lambert = clamp(-D.y, 0.0, 1.0); + pool = hfall * band * shaftGobo(hcross, vShaft.y) * exp(-max(uExtinct, 0.0) * ha) * lambert; + } + + float gain = amp * uIntensity * uShaderIntensity; + vec3 rgb = (uColorMouth * accMouth + uColorFoot * accFoot) * phase; + rgb += uColorMote * (accMote * uMote); + rgb += uColorPool * (pool * uBounce); + rgb *= gain; + + float lum = max(max(rgb.r, rgb.g), rgb.b); + if (lum < 0.003) discard; + + // Alpha 1 with AdditiveBlending (SrcAlpha, One) means the destination gets + // rgb, once. Writing the luminance here instead — the obvious thing — makes + // the blend square it, so every dim part of the shaft goes to nothing and + // only the core survives. Light adds; it does not add proportionally to how + // bright it already is. + gl_FragColor = vec4(rgb * uGlobalGlow, 1.0); + } +`; + +/* ---------------------------------------------------------------------- */ +/* Params */ +/* ---------------------------------------------------------------------- */ + +/** + * Every canonical key with its default and its unit. + * + * Hold one at module scope, fill it from `settings[id]` every frame, hand it to + * `update()`. Nothing here is remembered between calls. + */ +export function lightShaftParams() { + return { + /* --- the rank --- */ + layout: ShaftLayout.SINGLE, + count: 1, // shafts drawn; clamped to the capacity + spacing: 3.2, // metres between shafts (LINE) + ring: 4, // metres (RING / SCATTER reach) + scatter: 0.4, // metres of positional slop + seed: 0, // decorrelates two casts. A dice roll, safe to capture + + /* --- the shaft --- */ + length: 9, // metres, mouth to foot + radiusMouth: 0.5, // metres — narrow at the source + radiusFoot: 1.6, // metres — a cone if these differ, a cylinder if they do not + lengthJitter: 0.15, // 0..1 + radiusJitter: 0.2, // 0..1 + hullPad: 1.35, // how much wider than the shaft the bound is. Cheap insurance + + /* --- the medium --- */ + steps: 28, // marching samples, capped by the compile-time MAX_STEPS + jitter: 0.9, // 0..1 dither on the first sample + density: 0.5, // per metre — the in-scattering coefficient + extinct: 0.06, // 1/metres down the shaft + soft: 0.55, // widens the gaussian core + axialCurve: 0.55, // how the medium thins toward the mouth + axialMouth: 0.4, // 0..1 density at the mouth. Zero cuts the shaft off flat + anisotropy: 0.62, // -0.95..0.95 Henyey-Greenstein g. THE slider + contact: 0.7, // metres of feather where the shaft meets geometry + + /* --- the canopy --- */ + gobo: 0.55, // 0..1 how much of the shaft the gaps eat + goboScale: 0.5, // cycles per metre across the shaft + goboBias: 0.18, // bigger = more open sky + goboDrift: 0.08, // radians/second the canopy stirs + + /* --- dust --- */ + mote: 0.7, // 0 disables the lattice outright + moteScale: 2.6, // cells per metre + moteSize: 0.13, // 0..1 of a cell + moteFall: 0.09, // cells/second the lattice drifts down + + /* --- the ground --- */ + bounce: 0.9, // how much of the landed light comes back at you + poolSoft: 0.75, // widens the band's gaussian + landBand: 0.5, // metres either side of the foot plane that count + + /* --- the beats --- */ + fade: 1, // 0..1 master fade + sweep: 0.5, // 0..1 where the lit window sits along the rank + sweepWidth: 1, // 0..1 of the rank; 1 lights everything + + /* --- output --- */ + intensity: 1, + colorMouth: '#fff4d6', // where it enters + colorFoot: '#ffd79a', // where it lands + colorMote: '#fffbee', // the dust + colorPool: '#ffe6b4', // the band on the ground + + /* --- global multipliers (settings.global.*, 1 = neutral) --- */ + noiseStrength: 1, + noiseFrequency: 1, + noiseSpeed: 1, + opacityScale: 1 + }; +} + +/* ---------------------------------------------------------------------- */ +/* Scratch — module scope, per invariant I3 */ +/* ---------------------------------------------------------------------- */ + +const _along = new Vector3(0, 0, 1); +const _up = new Vector3(0, 1, 0); +const _side = new Vector3(1, 0, 0); +const _axis = new Vector3(0, -1, 0); +const _foot = new Vector3(); +const _rel = new Vector3(); + +/** Mulberry-ish: a deterministic 0..1 stream from one integer seed. */ +function diceStream(seed) { + let t = (seed * 1013904223 + 1664525) >>> 0; + return () => { + t = (t + 0x6d2b79f5) >>> 0; + let x = Math.imul(t ^ (t >>> 15), 1 | t); + x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; + return ((x ^ (x >>> 14)) >>> 0) / 4294967296; + }; +} + +/* ---------------------------------------------------------------------- */ +/* The module */ +/* ---------------------------------------------------------------------- */ + +export class LightShaft { + /** + * @param {THREE.Object3D} parent the ability's group + * @param {object} [options] + * @param {number} [options.capacity=6] hard ceiling on shafts + * @param {number} [options.layout=ShaftLayout.SINGLE] initial layout; live after + * @param {number} [options.sides=14] hull tessellation. It is only a bound + * @param {number} [options.maxSteps=48] compile-time cap on the march + * @param {number} [options.layer=LAYER.VFX] + * @param {number} [options.renderOrder=10] + * @param {string} [options.name] + */ + constructor(parent, options = {}) { + const { + capacity = 6, + layout = ShaftLayout.SINGLE, + sides = 14, + maxSteps = 48, + layer = LAYER.VFX, + renderOrder = 10, + name = null + } = options; + + this.parent = parent; + this.capacity = Math.max(1, Math.round(capacity)); + this.maxSteps = Math.max(4, Math.round(maxSteps)); + this._count = 0; + + /** Four unitless rolls per shaft. The only state a cast captures. */ + this._dice = new Float32Array(this.capacity * 4); + const slots = new Float32Array(this.capacity); + for (let i = 0; i < this.capacity; i++) slots[i] = i; + this.roll(0); + + // Capped, not open-ended: a ray straight down the axis of an open tube finds + // no back face and leaves a hole in the middle of the shaft — which reads as + // a ring of light with a dead centre, exactly wrong. + const hull = new CylinderGeometry(1, 1, 1, Math.max(3, Math.round(sides)), 1, false); + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', hull.getAttribute('position')); + geometry.setIndex(hull.getIndex()); + geometry.setAttribute('aIndex', new InstancedBufferAttribute(slots, 1)); + geometry.setAttribute('aDice', new InstancedBufferAttribute(this._dice, 4)); + geometry.instanceCount = 0; + // Placed in world space by the vertex shader; its own bounds mean nothing. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + // `hull` is deliberately NOT disposed: the instanced geometry above holds + // its attribute objects by reference, and `BufferGeometry#dispose()` tells + // the renderer to delete the GL buffers *for those attributes*. It has never + // been uploaded so nothing leaks, and disposing it would eventually delete + // buffers out from under the mesh that is still drawing them. + this.geometry = geometry; + + this.material = new ShaderMaterial({ + defines: { MAX_STEPS: this.maxSteps }, + transparent: true, + depthWrite: false, + // Occlusion is part of the integral, not a test. See the class comment. + depthTest: false, + blending: AdditiveBlending, + side: BackSide, + toneMapped: false, + uniforms: sharedUniforms({ + uAnchor: { value: new Vector3() }, + uAlong: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + uUp: { value: new Vector3(0, 1, 0) }, + + uLayout: { value: layout }, + uCount: { value: 1 }, + uSpacing: { value: 3.2 }, + uRing: { value: 4 }, + uScatter: { value: 0.4 }, + uSeed: { value: 0 }, + + uLength: { value: 9 }, + uRadiusMouth: { value: 0.5 }, + uRadiusFoot: { value: 1.6 }, + uLengthJitter: { value: 0.15 }, + uRadiusJitter: { value: 0.2 }, + uHullPad: { value: 1.35 }, + + uFade: { value: 1 }, + uSweep: { value: 0.5 }, + uSweepWidth: { value: 1 }, + + uSteps: { value: 28 }, + uJitter: { value: 0.9 }, + uDensity: { value: 0.5 }, + uExtinct: { value: 0.06 }, + uSoft: { value: 0.55 }, + uAxialCurve: { value: 0.55 }, + uAxialMouth: { value: 0.4 }, + uAnisotropy: { value: 0.62 }, + uContact: { value: 0.7 }, + + uGobo: { value: 0.55 }, + uGoboScale: { value: 0.5 }, + uGoboBias: { value: 0.18 }, + uGoboDrift: { value: 0.08 }, + + uMote: { value: 0.7 }, + uMoteScale: { value: 2.6 }, + uMoteSize: { value: 0.13 }, + uMoteFall: { value: 0.09 }, + + uBounce: { value: 0.9 }, + uPoolSoft: { value: 0.75 }, + uLandBand: { value: 0.5 }, + + uIntensity: { value: 1 }, + uColorMouth: { value: new Color(1, 0.96, 0.84) }, + uColorFoot: { value: new Color(1, 0.84, 0.6) }, + uColorMote: { value: new Color(1, 0.98, 0.93) }, + uColorPool: { value: new Color(1, 0.9, 0.7) } + }), + vertexShader: SHAFT_VERTEX, + fragmentShader: SHAFT_FRAGMENT + }); + + this.mesh = new Mesh(this.geometry, this.material); + this.mesh.name = name ?? `LightShaft:${SHAFT_LAYOUT_NAMES[layout] ?? layout}`; + this.mesh.layers.set(layer); + this.mesh.renderOrder = renderOrder; + this.mesh.frustumCulled = false; + this.mesh.visible = false; + + parent?.add(this.mesh); + } + + get object3D() { + return this.mesh; + } + + /** One, for any number of shafts. */ + get drawCalls() { + return 1; + } + + /** Shafts currently drawn → the ability's `instanceCount`. */ + get instanceCount() { + return this._count; + } + + get layout() { + return this.material.uniforms.uLayout.value; + } + + set layout(value) { + this.material.uniforms.uLayout.value = value; + } + + get visible() { + return this.mesh.visible; + } + + set visible(value) { + this.mesh.visible = value; + } + + /** + * The frame the rank is laid out in. + * + * `up` is the shaft's own up, and it is the interesting argument: hand it + * `-frame.uLightDir` and the shafts slant with the stage's own sun, which is + * the difference between "there is a spotlight here" and "the light in this + * room is coming through something". + */ + setPlacement(anchor, along, up) { + const u = this.material.uniforms; + if (anchor) u.uAnchor.value.copy(anchor); + + _along.copy(along ?? _along); + if (_along.lengthSq() < 1e-8) _along.set(0, 0, 1); + _along.normalize(); + + _up.copy(up ?? _up); + if (_up.lengthSq() < 1e-8) _up.set(0, 1, 0); + _up.normalize(); + + _side.crossVectors(_up, _along); + if (_side.lengthSq() < 1e-8) _side.set(1, 0, 0); + _side.normalize(); + + u.uAlong.value.copy(_along); + u.uUp.value.copy(_up); + u.uSide.value.copy(_side); + return this; + } + + /** + * Re-roll the per-shaft dice. The only place random numbers are written. + * + * Every one is unitless: two for the scatter disc, one for the length jitter + * (also reused as the scatter bearing, which decorrelates fine and saves an + * attribute), one for the radius jitter. What they turn into is resolved from + * live metres in the vertex shader, every frame — which is why a paused rank + * re-lays itself when `spacing` moves. + */ + roll(seed = Math.random() * 100) { + const next = diceStream(Math.floor(Math.abs(seed) * 1013) + 7); + for (let i = 0; i < this._dice.length; i++) this._dice[i] = next(); + if (this.geometry) this.geometry.getAttribute('aDice').needsUpdate = true; + this.material && (this.material.uniforms.uSeed.value = seed); + return this; + } + + /** Leaves the instance reusable — the pooling contract. */ + reset() { + this._count = 0; + if (this.geometry) this.geometry.instanceCount = 0; + this.mesh.visible = false; + return this; + } + + /** + * Re-resolve every metre from the live params. + * + * There is no clock argument, for the reason `Curtain` ignores its one: every + * animated term is driven by `frame.uTime` inside the shader and every beat + * arrives as a unitless `0..1` on `p`. Allocation-free, correct on a + * zero-length frame. + * + * @param {object} p live params — see `lightShaftParams()` + */ + update(p) { + const u = this.material.uniforms; + + const nf = p.noiseFrequency ?? 1; + const ns = p.noiseStrength ?? 1; + const nsp = p.noiseSpeed ?? 1; + + const count = Math.max(0, Math.min(this.capacity, Math.round(p.count ?? 1))); + this._count = count; + this.geometry.instanceCount = count; + this.mesh.visible = count > 0 && (p.fade ?? 1) > 0.0005; + + u.uLayout.value = p.layout ?? ShaftLayout.SINGLE; + u.uCount.value = Math.max(1, count); + u.uSpacing.value = p.spacing ?? 3.2; + u.uRing.value = Math.max(0, p.ring ?? 4); + u.uScatter.value = Math.max(0, p.scatter ?? 0.4); + if (p.seed !== undefined && p.seed !== null) u.uSeed.value = p.seed; + + u.uLength.value = Math.max(0.05, p.length ?? 9); + u.uRadiusMouth.value = Math.max(0.01, p.radiusMouth ?? 0.5); + u.uRadiusFoot.value = Math.max(0.01, p.radiusFoot ?? 1.6); + u.uLengthJitter.value = Math.max(0, Math.min(1, p.lengthJitter ?? 0.15)); + u.uRadiusJitter.value = Math.max(0, Math.min(1, p.radiusJitter ?? 0.2)); + u.uHullPad.value = Math.max(1, p.hullPad ?? 1.35); + + u.uFade.value = (p.fade ?? 1) * (p.opacityScale ?? 1); + u.uSweep.value = p.sweep ?? 0.5; + u.uSweepWidth.value = Math.max(0.01, p.sweepWidth ?? 1); + + /* ---- the medium ---- */ + u.uSteps.value = Math.max(4, Math.min(this.maxSteps, Math.round(p.steps ?? 28))); + u.uJitter.value = Math.max(0, p.jitter ?? 0.9); + u.uDensity.value = Math.max(0, p.density ?? 0.5); + u.uExtinct.value = Math.max(0, p.extinct ?? 0.06); + u.uSoft.value = Math.max(0.02, p.soft ?? 0.55); + u.uAxialCurve.value = Math.max(0, p.axialCurve ?? 0.55); + u.uAxialMouth.value = Math.max(0, Math.min(1, p.axialMouth ?? 0.4)); + u.uAnisotropy.value = Math.max(-0.95, Math.min(0.95, p.anisotropy ?? 0.62)); + u.uContact.value = Math.max(0.001, p.contact ?? 0.7); + + /* ---- the canopy ---- */ + u.uGobo.value = Math.max(0, Math.min(1, (p.gobo ?? 0.55) * ns)); + u.uGoboScale.value = Math.max(0.01, (p.goboScale ?? 0.5) * nf); + u.uGoboBias.value = p.goboBias ?? 0.18; + u.uGoboDrift.value = (p.goboDrift ?? 0.08) * nsp; + + /* ---- dust ---- */ + u.uMote.value = Math.max(0, p.mote ?? 0.7); + u.uMoteScale.value = Math.max(0.01, (p.moteScale ?? 2.6) * nf); + u.uMoteSize.value = Math.max(0.001, Math.min(0.9, p.moteSize ?? 0.13)); + u.uMoteFall.value = (p.moteFall ?? 0.09) * nsp; + + /* ---- the ground ---- */ + u.uBounce.value = Math.max(0, p.bounce ?? 0.9); + u.uPoolSoft.value = Math.max(0.02, p.poolSoft ?? 0.75); + u.uLandBand.value = Math.max(0.001, p.landBand ?? 0.5); + + /* ---- output ---- */ + u.uIntensity.value = Math.max(0, p.intensity ?? 1); + u.uColorMouth.value.copy(getColor(p.colorMouth ?? '#fff4d6')); + u.uColorFoot.value.copy(getColor(p.colorFoot ?? '#ffd79a')); + u.uColorMote.value.copy(getColor(p.colorMote ?? '#fffbee')); + u.uColorPool.value.copy(getColor(p.colorPool ?? '#ffe6b4')); + } + + /* ------------------------------------------------------------------ */ + /* CPU mirrors */ + /* ------------------------------------------------------------------ */ + + /** + * Where shaft `index` meets the ground, in world metres. + * + * Reads the same floats the vertex shader reads, so a CPU emitter lands on + * the shaft rather than near it. Like `LiquidSurface#lipPosition()` it leaves + * out nothing — the layout is closed-form. + */ + footPoint(index, p, out) { + const u = this.material.uniforms; + const i = Math.max(0, Math.min(this.capacity - 1, Math.round(index))); + const d0 = this._dice[i * 4]; + const d1 = this._dice[i * 4 + 1]; + const d2 = this._dice[i * 4 + 2]; + const d3 = this._dice[i * 4 + 3]; + + const count = Math.max(1, this._count); + const layout = p?.layout ?? u.uLayout.value; + const along = u.uAlong.value; + const side = u.uSide.value; + const ring = Math.max(0, p?.ring ?? u.uRing.value); + const scatter = Math.max(0, p?.scatter ?? u.uScatter.value); + const spacing = p?.spacing ?? u.uSpacing.value; + const seed = p?.seed ?? u.uSeed.value; + + out.copy(u.uAnchor.value); + if (layout === ShaftLayout.LINE) { + out.addScaledVector(along, (i - (count - 1) * 0.5) * spacing); + } else if (layout === ShaftLayout.RING) { + const bearing = Math.PI * 2 * (i / count) + seed; + out.addScaledVector(side, Math.cos(bearing) * ring); + out.addScaledVector(along, Math.sin(bearing) * ring); + } else if (layout === ShaftLayout.SCATTER) { + const bearing = Math.PI * 2 * d0; + const reach = Math.sqrt(d1) * ring; + out.addScaledVector(side, Math.cos(bearing) * reach); + out.addScaledVector(along, Math.sin(bearing) * reach); + } + out.addScaledVector(side, (d2 - 0.5) * scatter); + out.addScaledVector(along, (d3 - 0.5) * scatter); + return out; + } + + /** Where shaft `index` enters, in world metres. */ + mouthPoint(index, p, out) { + const u = this.material.uniforms; + const i = Math.max(0, Math.min(this.capacity - 1, Math.round(index))); + const jitter = p?.lengthJitter ?? u.uLengthJitter.value; + const len = Math.max(0.05, (p?.length ?? u.uLength.value) * (1 + (this._dice[i * 4] - 0.5) * jitter)); + this.footPoint(i, p, out); + return out.addScaledVector(u.uUp.value, len); + } + + /** + * How brightly the shafts light a world point — `0..1`, the strongest shaft. + * + * This is the answer to "the dust in the shaft must be the scene's own dust + * motes, brightened as they pass through it, not a second system". The module + * cannot reach into `ParticleEngine`, but it can tell an ability exactly how + * lit a particle is, and the ability can put that on the particle's own + * brightness — one shared field, two consumers, which is the same shape as + * `Caustics#bindSource()`. + * + * It mirrors the radial gaussian, the axial curve and the extinction. It + * deliberately leaves out the gobo, for the reason `LiquidSurface#lipPosition` + * leaves out the chop: the caller wants a smooth envelope to multiply into a + * particle, not a field that flickers as a mote crosses a leaf edge at four + * metres a second. + */ + irradianceAt(point, p, out = null) { + void out; + const u = this.material.uniforms; + const count = this._count; + if (count <= 0) return 0; + + _axis.copy(u.uUp.value).negate(); + const soft = Math.max(0.02, p?.soft ?? u.uSoft.value); + const curve = Math.max(0.001, p?.axialCurve ?? u.uAxialCurve.value); + const mouth = Math.max(0, Math.min(1, p?.axialMouth ?? u.uAxialMouth.value)); + const extinct = Math.max(0, p?.extinct ?? u.uExtinct.value); + const rJit = p?.radiusJitter ?? u.uRadiusJitter.value; + const lJit = p?.lengthJitter ?? u.uLengthJitter.value; + const rMouth0 = Math.max(0.01, p?.radiusMouth ?? u.uRadiusMouth.value); + const rFoot0 = Math.max(0.01, p?.radiusFoot ?? u.uRadiusFoot.value); + const len0 = Math.max(0.05, p?.length ?? u.uLength.value); + const fade = (p?.fade ?? u.uFade.value) * (p?.intensity ?? u.uIntensity.value); + + let best = 0; + for (let i = 0; i < count; i++) { + const len = Math.max(0.05, len0 * (1 + (this._dice[i * 4] - 0.5) * lJit)); + const jr = 1 + (this._dice[i * 4 + 1] - 0.5) * rJit; + + this.footPoint(i, p, _foot); + // The mouth is `len` up the axis from the foot; a is measured down from it. + _rel.copy(point).sub(_foot); + const axialUp = -_rel.dot(_axis); // metres above the foot + const a = len - axialUp; + if (a < 0 || a > len) continue; + + const k = a / len; + const rad = Math.max(0.01, (rMouth0 + (rFoot0 - rMouth0) * k) * jr); + // Perpendicular distance: |rel| minus the axial component. + const axialLen = _rel.dot(_axis); + const perp2 = Math.max(0, _rel.lengthSq() - axialLen * axialLen); + const fall = Math.exp(-perp2 / Math.max(rad * rad * soft, 1e-6)); + const axial = mouth + (1 - mouth) * Math.pow(k, curve); + const lit = fall * axial * Math.exp(-extinct * a); + if (lit > best) best = lit; + } + return Math.max(0, Math.min(1, best * fade)); + } + + dispose() { + this.parent?.remove(this.mesh); + this.geometry.dispose(); + this.material.dispose(); + } +} diff --git a/src/vfx/LiquidSurface.js b/src/vfx/LiquidSurface.js new file mode 100644 index 0000000..263f1ec --- /dev/null +++ b/src/vfx/LiquidSurface.js @@ -0,0 +1,1348 @@ +import { + Color, + DoubleSide, + FrontSide, + Mesh, + PlaneGeometry, + ShaderMaterial, + Sphere, + Vector2, + Vector3, + Vector4 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms, frame } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { copyColor } from '../utils/color.js'; + +/* ------------------------------------------------------------------------ */ +/* LiquidSurface — a heightfield that knows how fast it is moving */ +/* ------------------------------------------------------------------------ */ +/** + * A live liquid heightfield: lava, blood, water. + * + * The archived `OceanWaterMaterial` raymarched a *body* of water because a + * thrown stream has no top and bottom — it is a tube in the air. A pool is the + * opposite problem. It has exactly one surface, gravity has already decided + * where that surface is, and everything the eye reads off it is a property of + * that one sheet: its slope, how fast it is sliding, and what it reflects. So + * this is a real subdivided plane with a displaced vertex shader, and it costs + * a fortieth of what the march did. + * + * Four things make it more than a wobbling plane. + * + * **1 — Surface speed is a real quantity.** Everything interesting about molten + * rock is a function of how fast the skin is travelling, so the shader computes + * an honest 2-D flow field in metres per second at every fragment: + * + * - a bulk drift (`flowAngle` / `flowSpeed`) — the pour; + * - a radial outflow that dies off with distance (`flowRadial`) — the fount; + * - eddies taken as the **curl of a scalar noise field**, so they swirl + * without any point of the surface acting as a source or a sink. Sampling + * two independent noises as `(nx, nz)` instead — the obvious thing — gives a + * field with divergence, and a divergent flow map makes the crust pile up in + * some places and tear open in others for no reason the picture can explain; + * - **downhill gravity**, taken straight off the shading normal. Lava runs + * down slopes, so `flowGravity` adds `n.xz / n.y` metres per second of it. + * + * That last term is the one that pays for itself. A ripple arriving from an + * impact steepens the local slope; the slope feeds the flow; the flow speeds + * the surface past `crustBreak`; and the black skin *cracks open along the + * ripple front and glows*, then heals behind it. Nothing in the code says + * "crack when hit" — the coupling is real, and that is why it reads. + * + * **2 — The crust is a flow map, not a texture.** Coverage is + * `1 − smoothstep(crustForm, crustBreak, speed)`: skin where the surface is + * slow, bare melt where it is fast. The crack pattern is the zero-crossing of an + * fbm evaluated in a frame **built from the flow direction** and squashed along + * it by `crackStretch`, so the seams run *with* the flow the way pahoehoe does + * rather than in isotropic blobs. It is advected by the flow using the two-phase + * cross-fade (sample at `fract(t/T)` and `fract(t/T + 0.5)`, weight by + * `|1 − 2·fract(t/T)|`): a single advected layer would smear without bound after + * a few seconds, which is exactly the failure that makes hand-rolled flow maps + * look like melting plastic. + * + * The honest limitation, stated once: a fragment cannot remember when it was + * last moving fast, so "the crust re-forms where it is slow" is instantaneous in + * space and only lagged in time by the global `crustFormTime` ramp. Real skin + * has hysteresis. Fixing it needs a ping-pong buffer, which is a texture, which + * is **I2**. + * + * **3 — Ripples are analytic and unitless.** `ripple(u, v, strength)` writes one + * slot of a small ring buffer holding *a position fraction, a timestamp and a + * strength* — no metres, per **I1**. The shader turns the fraction into metres + * against the live half-extents every frame, so dragging `sizeX` moves standing + * ripples with the pool. Each is a gaussian-enveloped cosine packet riding out at + * `rippleSpeed`, decaying as `e^{−age/rippleDecay}` and spreading as + * `1/(1 + r/rippleSpread)`. No integrator, so a paused pool still re-rings under + * the sliders. + * + * **4 — WAVE mode curls.** The travelling crest is a profile in metres from a + * front at `waveFront` (a 0..1 fraction of the plane, driven by the ability): a + * long exponential back and a short exponential face. The overhang is Gerstner's + * trick pushed hard — the horizontal displacement is proportional to the *height* + * (`crestCurl · h`), so the material at the top of the crest is thrown further + * forward than the material at its foot and the sheet genuinely folds over + * itself. Where it has folded, `vFace` is high: the shader thins the alpha there + * and adds a backlight term, which is what makes the front face of a breaking + * wave read as lit from inside. `lipPosition()` hands the ability the world point + * of that lip so it can emit droplets from it. + * + * ## Cost + * + * **One draw call**, `segments²·2` triangles, no textures. It is *fill*-heavy, + * not vertex-heavy: the shading normal is four evaluations of the whole + * heightfield, and the crust adds a flow field and two advected fbm phases on + * top. Both of the expensive blocks are behind `if (uCrust > 0.001)` style + * gates, so water and blood pay for neither. Do not stack two of these over the + * same pixels, and keep `sizeX`/`sizeZ` honest — a pool the size of the stage is + * a full-screen shader. + * + * ## Invariants + * + * - **I1** — holds no dimension whatsoever. `update(now, p)` re-resolves every + * metre and second from `p`; the ripple records hold fractions and timestamps. + * - **I2** — waves are sines, chop is fbm, the crust is a noise zero-crossing, + * the reflection is `frame.uEnvMap` sampled equirectangularly the way + * `IceMaterial` reflects the probe and `GlacierMaterial` samples it by hand. + * - **I3** — `update()` writes into existing uniform boxes and into `Vector4`s + * that were allocated at construction. Nothing in the frame path allocates. + * - **I5** — eight colour pickers, none derived from another. + */ + +/* ---------------------------------------------------------------- */ +/* Enums */ +/* ---------------------------------------------------------------- */ + +export const LiquidMode = Object.freeze({ + /** A standing pool: swell, chop and ripples only. */ + POOL: 0, + /** POOL plus one travelling crest that curls, thins and breaks. */ + WAVE: 1 +}); + +/** + * Ripple slots. Eight is not arbitrary: the packet is evaluated five times per + * fragment (once for shading, four times for the finite-difference normal), so + * every slot costs five length()s and five exponentials whether or not it is + * live. Eight simultaneous impacts is more than any ability in the roster + * throws, and `ripple()` recycles the oldest slot rather than dropping the + * newest — a dropped *newest* is the one the player was looking at. + */ +const RIPPLE_SLOTS = 8; + +/* ---------------------------------------------------------------- */ +/* The field — injected into both shaders so they agree exactly */ +/* ---------------------------------------------------------------- */ +/** + * The vertex shader and the fragment shader must evaluate *the same* height + * function or the lighting will not sit on the silhouette. So it is written + * once, here, uniform declarations and all, and pasted into both stages. + * + * The one asymmetry is deliberate and is the same split the ocean material + * documents: the `detail` argument scales an octave far finer than the vertex + * grid can resolve. The vertex passes 0 (displacing by something smaller than a + * quad is aliasing, not detail); the fragment passes 1, because in the *normal* + * that same octave is the difference between a wobbling sheet and a surface. + */ +const LIQUID_FIELD = /* glsl */ ` + #define TAU 6.283185307179586 + + uniform float uTime; + /** Seconds since the ability's cast began — the ripple clock. */ + uniform float uNow; + /** Half-extents of the sheet in metres. Ripple fractions resolve against it. */ + uniform vec2 uHalf; + uniform float uSeed; + uniform int uMode; + + /* --- the swell: four independent directional waves --- */ + uniform vec4 uWaveAmp; // metres, one component per wave + uniform vec4 uWaveLength; // metres, crest to crest + uniform vec4 uWaveSpeed; // metres/second + uniform vec4 uWaveAngle; // radians, bearing in the surface plane + uniform float uSteepness; // 0 = sine, 1 = Gerstner cusps + + /* --- chop --- */ + uniform float uChop; // metres + uniform float uChopScale; // cycles per metre + uniform float uChopSpeed; // metres/second the chop drifts + uniform float uDetail; // metres — fragment-only, lives in the normal + uniform float uDetailScale; // cycles per metre + uniform float uDetailSpeed; // metres/second + + /* --- ripples: (u, v, born, strength). Unitless + a timestamp. I1. --- */ + uniform vec4 uRipples[${RIPPLE_SLOTS}]; + uniform float uRippleAmp; // metres at strength 1 + uniform float uRippleSpeed; // metres/second the front travels + uniform float uRippleLength; // metres, crest to crest inside the packet + uniform float uRippleWidth; // metres, the packet's gaussian envelope + uniform float uRippleDecay; // seconds to 1/e + uniform float uRippleSpread; // metres over which it also thins with radius + + /* --- the travelling wave (WAVE mode) --- */ + uniform float uWaveFront; // 0..1 along +X. The ability drives this. + uniform float uCrestHeight; // metres + uniform float uCrestBack; // metres — the long back slope's 1/e length + uniform float uCrestFace; // metres — the short front face's 1/e length + uniform float uCrestCurl; // metres of forward throw per metre of height + uniform float uCrestWidth; // 0..1 of the half-extent across the wave + uniform float uCrestFeather; // 0..1 of that, over which the ends die + uniform float uCrestBreak; // 0..1 how ragged the lip is + uniform float uCrestBreakScale; // cycles per metre along the lip + + /** 0 at the trailing edge of the sheet, 2*uHalf.x at the leading edge. */ + float crestFrontX() { return (uWaveFront - 0.5) * uHalf.x * 2.0; } + + /** How much of the crest survives this far off the wave's centreline. */ + float crestLateral(float z) { + float a = abs(z) / max(uHalf.y, 1e-3); + float w = clamp(uCrestWidth, 0.0, 1.0); + return 1.0 - smoothstep(max(w - max(uCrestFeather, 1e-3), 0.0), w, a); + } + + /** + * The crest's height in metres. + * + * Two exponentials meeting at 1 where they join, so the profile is continuous + * at the front and the *asymmetry* is entirely 'uCrestBack' against + * 'uCrestFace'. A symmetric bump is a swell; a bump with a face an eighth as + * long as its back is a wave about to break, and that ratio does more for the + * silhouette than any amount of noise on top of it. + */ + float crestHeightAt(vec2 xz) { + if (uMode == 0) return 0.0; + float s = xz.x - crestFrontX(); + float prof = s < 0.0 + ? exp(s / max(uCrestBack, 0.02)) + : exp(-s / max(uCrestFace, 0.01)); + // The lip breaks unevenly along its length. Sampled on z alone, so a gap is + // a gap for the whole depth of the wave rather than a dent in its middle. + float rag = fbm3(vec3(xz.y * uCrestBreakScale, uSeed, uNow * 0.6)) * 0.5 + 0.5; + prof *= mix(1.0, rag, clamp(uCrestBreak, 0.0, 1.0)); + return uCrestHeight * prof * crestLateral(xz.y); + } + + /** + * 0..1: how much this point is on the *front face* of the crest. + * + * 'ahead' climbs with distance past the front while the profile falls, so the + * product peaks in the thin overhanging lip and is zero both on the back slope + * and out in the flat water. It is the mask for every translucency term. + */ + float crestFaceAt(vec2 xz) { + if (uMode == 0) return 0.0; + float s = xz.x - crestFrontX(); + float ahead = smoothstep(0.0, max(uCrestFace, 0.01) * 1.5, s); + float h = crestHeightAt(xz) / max(uCrestHeight, 1e-3); + return clamp(ahead * h * 2.2, 0.0, 1.0); + } + + /** + * One directional wave, accumulated in place. + * + * The horizontal term is Gerstner's: pulling material *toward* the crest + * sharpens it and flattens the trough, which is what an ocean does and what a + * plain sine cannot do at any amplitude. It is also why 'uSteepness' above ~1 + * folds the mesh through itself — capped in 'update()', not here, so the + * shader stays a shader. + */ + void addWave(inout vec3 acc, vec2 xz, float amp, float len, float spd, float ang) { + if (amp <= 0.0) return; + vec2 d = vec2(cos(ang), sin(ang)); + float k = TAU / max(len, 0.05); + float phase = dot(d, xz) * k - uTime * spd * k; + acc.y += amp * sin(phase); + acc.xz -= d * (amp * uSteepness * cos(phase)); + } + + /** + * Every live ripple, summed. + * + * Analytic, not integrated: a packet's whole history is a closed-form function + * of 'uNow - born', so the surface can be re-rung from a slider with the clock + * stopped. The '1/(1 + r/spread)' term is the geometric thinning of a circular + * wave (energy over a growing circumference); the exponential is the viscous + * loss. Both are needed — with only the exponential a small pool's ripples die + * in the middle, with only the spread they never die at all. + */ + float ripplesAt(vec2 xz) { + float sum = 0.0; + for (int i = 0; i < ${RIPPLE_SLOTS}; i++) { + vec4 r = uRipples[i]; + if (r.w <= 0.0) continue; + float age = uNow - r.z; + if (age < 0.0) continue; + // The fraction becomes metres HERE, against this frame's half-extents. + vec2 c = r.xy * uHalf; + float d = length(xz - c); + float x = d - age * uRippleSpeed; + float env = exp(-(x * x) / max(uRippleWidth * uRippleWidth, 1e-4)); + float decay = exp(-age / max(uRippleDecay, 0.02)) / (1.0 + d / max(uRippleSpread, 0.05)); + sum += r.w * uRippleAmp * env * decay * cos(x * TAU / max(uRippleLength, 0.05)); + } + return sum; + } + + /** + * The surface's displacement from the flat plane, in surface-local metres. + * + * @param xz parametric position on the sheet, metres from its centre + * @param detail 0 in the vertex stage, 1 in the fragment stage + * @returns (dx, dy, dz) — dy is height, dx/dz are the Gerstner and curl throws + */ + vec3 liquidOffset(vec2 xz, float detail) { + vec3 acc = vec3(0.0); + + addWave(acc, xz, uWaveAmp.x, uWaveLength.x, uWaveSpeed.x, uWaveAngle.x); + addWave(acc, xz, uWaveAmp.y, uWaveLength.y, uWaveSpeed.y, uWaveAngle.y); + addWave(acc, xz, uWaveAmp.z, uWaveLength.z, uWaveSpeed.z, uWaveAngle.z); + addWave(acc, xz, uWaveAmp.w, uWaveLength.w, uWaveSpeed.w, uWaveAngle.w); + + if (uChop > 0.0) { + acc.y += uChop * fbm3(vec3(xz * uChopScale, uTime * uChopSpeed + uSeed)); + } + if (detail > 0.0 && uDetail > 0.0) { + acc.y += uDetail * detail * + snoise(vec3(xz * uDetailScale, uTime * uDetailSpeed + uSeed * 3.1)); + } + + acc.y += ripplesAt(xz); + + // The curl. Horizontal throw proportional to height is what turns a bump + // into an overhang: the crest's own material outruns its foot. + float ch = crestHeightAt(xz); + acc.y += ch; + acc.x += ch * uCrestCurl; + + return acc; + } + + /** The surface point in local space — parametric position plus its offset. */ + vec3 liquidPoint(vec2 xz, float detail) { + vec3 d = liquidOffset(xz, detail); + return vec3(xz.x + d.x, d.y, xz.y + d.z); + } +`; + +/* ---------------------------------------------------------------- */ +/* Vertex */ +/* ---------------------------------------------------------------- */ + +const LIQUID_VERTEX = /* glsl */ ` + uniform vec3 uAnchor; // world centre of the sheet + uniform vec3 uAxisX; // the sheet's local +X, unit — the "along" of a WAVE + uniform vec3 uAxisY; // the sheet's up, unit + uniform vec3 uAxisZ; // the sheet's local +Z, unit + + varying vec2 vParam; // parametric metres from the centre, pre-displacement + varying vec3 vWorld; + varying float vHeight; // metres above the mean plane + varying float vFace; // 0..1 on the crest's front face + varying float vViewZ; + + ${noiseGLSL} + ${LIQUID_FIELD} + + void main() { + // The plane geometry is a unit square; every metre comes from uHalf, which + // is rewritten every frame. Resizing the pool never touches a buffer. + vec2 xz = (uv - 0.5) * (uHalf * 2.0); + vParam = xz; + + vec3 local = liquidPoint(xz, 0.0); + vHeight = local.y; + vFace = crestFaceAt(xz); + + vec3 world = uAnchor + uAxisX * local.x + uAxisY * local.y + uAxisZ * local.z; + vWorld = world; + + vec4 mv = viewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/* ---------------------------------------------------------------- */ +/* Fragment */ +/* ---------------------------------------------------------------- */ + +const LIQUID_FRAGMENT = /* glsl */ ` + uniform vec3 uAxisX; + uniform vec3 uAxisY; + uniform vec3 uAxisZ; + uniform sampler2D uEnvMap; + uniform vec3 uLightDir; // world direction TOWARD the key light + uniform float uGlobalGlow; + uniform float uShaderIntensity; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + /* --- the edge of the pool --- */ + uniform float uFill; // 0..1 of the half-extent the liquid reaches + uniform float uRound; // 0 rectangular footprint, 1 elliptical + uniform float uEdgeSoft; // 0..1 of the field over which it fades out + uniform float uEdgeNoise; // 0..1 how ragged the waterline is + uniform float uEdgeScale; // cycles per metre of that raggedness + + /* --- the flow field, metres/second --- */ + uniform float uFlowAngle; // radians, the bulk drift's bearing + uniform float uFlowSpeed; // m/s + uniform float uFlowRadial; // m/s outward at the centre + uniform float uFlowRadialFall; // metres to 1/e + uniform float uFlowEddy; // m/s of curl-noise swirl + uniform float uFlowEddyScale; // cycles per metre + uniform float uFlowEddySpeed; // Hz the eddies churn + uniform float uFlowGravity; // m/s per unit of surface slope + + /* --- the crust --- */ + uniform float uCrust; // 0..1 master. 0 skips the whole block. + uniform float uCrustForm; // m/s below which skin is unbroken + uniform float uCrustBreak; // m/s above which there is none + uniform float uCrustFormTime;// seconds for the first skin to appear + uniform float uCrackScale; // cycles per metre across the flow + uniform float uCrackStretch; // how many times longer features are along it + uniform float uCrackWidth; // 0..1 of the field — the seam's width + uniform float uCrustAdvect; // 0..1 how strongly the pattern is carried + uniform float uCrustPeriod; // seconds before the flow map resets + uniform float uCrustBump; // 0..1 how much the skin roughens the normal + uniform float uSeamGlow; + uniform float uMeltGlow; // glow of the bare melt between the plates + + /* --- foam --- */ + uniform float uFoam; // 0..1 master + uniform float uFoamScale; // cycles per metre of the speckle + uniform float uFoamSharp; + uniform float uFoamCrest; // how much a rising crest seeds it + uniform float uFoamSpeed; // how much surface speed seeds it + + /* --- shading --- */ + uniform vec3 uColorDeep; + uniform vec3 uColorShallow; + uniform vec3 uColorCrust; + uniform vec3 uColorSeam; + uniform vec3 uColorHot; + uniform vec3 uColorFoam; + uniform vec3 uColorSpec; + uniform vec3 uColorSky; + uniform float uPoolDepth; // metres of liquid under the mean plane + uniform float uDepthTint; // Beer-Lambert density, per metre + uniform float uTranslucency; // backlight through the thin lip + uniform float uAmbient; + uniform float uSpecular; + uniform float uShininess; + uniform float uFresnel; + uniform float uEnvIntensity; + uniform float uSkyIntensity; + uniform float uEmissive; // self-lit body — lava reads at night, water does not + uniform float uGlow; + uniform float uOpacity; + uniform float uNormalEps; // metres — the finite-difference step + uniform float uContactFade; // metres of soft fade against opaque geometry + + varying vec2 vParam; + varying vec3 vWorld; + varying float vHeight; + varying float vFace; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + ${LIQUID_FIELD} + + // equirectUv comes from commonGLSL above. (No backticks in here: the file is + // one template literal and a stray one ends it mid-shader.) + + /** + * Floor under the reflected probe — lifted wholesale from the archived ocean + * material, for the same reason it existed there: on a night stage most of the + * probe is genuinely black, a straight lookup returns nothing, and the surface + * collapses into flat holes of body colour. Something must always be + * reflected, or there is no shape. + */ + vec3 skyFloor(vec3 dir) { + float h = clamp(dir.y * 0.5 + 0.5, 0.0, 1.0); + return mix(uColorSky * 0.25, uColorSky, pow(h, 0.65)) * uSkyIntensity; + } + + /** The 2-D flow of the skin at this point, metres per second. */ + vec2 flowAt(vec2 xz, vec3 nLocal) { + vec2 f = vec2(cos(uFlowAngle), sin(uFlowAngle)) * uFlowSpeed; + + float d = length(xz) + 1e-4; + f += (xz / d) * uFlowRadial * exp(-d / max(uFlowRadialFall, 0.05)); + + if (uFlowEddy > 0.0) { + // Curl of one scalar field: divergence-free by construction. Three taps + // with a forward difference — the eddies are low frequency, so the extra + // accuracy of a central difference buys nothing at twice the cost. + float sc = max(uFlowEddyScale, 0.02); + float e = 0.5 / sc; + float t = uTime * uFlowEddySpeed; + float n0 = snoise(vec3(xz * sc, t)); + float nx = snoise(vec3((xz + vec2(e, 0.0)) * sc, t)); + float nz = snoise(vec3((xz + vec2(0.0, e)) * sc, t)); + vec2 grad = vec2(nx - n0, nz - n0) / e; + f += vec2(grad.y, -grad.x) * uFlowEddy; + } + + // Downhill. For y = h(x,z) the normal is proportional to (-dh/dx, 1, -dh/dz), + // so the downhill direction is n.xz / n.y and needs no extra taps at all. + f += (nLocal.xz / max(nLocal.y, 0.15)) * uFlowGravity; + + return f; + } + + /** + * The crack field: 0 deep inside a plate of skin, 1 on a seam. + * + * Seams are the *zero crossing* of a signed fbm rather than a ridge or a + * voronoi edge. Zero crossings of a smooth field are naturally continuous, + * they branch and rejoin, and their width is set by one number — three + * properties a threshold on |noise| does not have. The domain is squashed + * along the flow by 'uCrackStretch', which is the whole reason the seams run + * *with* the pour instead of looking like crazed pottery. + */ + float crackField(vec2 q) { + float n = fbm3(vec3(q, uSeed * 7.3)); + return 1.0 - smoothstep(0.0, max(uCrackWidth, 1e-3), abs(n)); + } + + void main() { + /* ---------------- normal, by finite difference ---------------- */ + // Four evaluations of the *whole* field, including the fragment-only octave. + // Differencing the point rather than the height is what makes this correct + // in the presence of the Gerstner and curl throws: the tangents pick up the + // horizontal displacement too, so an overhanging crest is lit as an overhang. + float e = max(uNormalEps, 1e-3); + vec3 px = liquidPoint(vParam + vec2(e, 0.0), 1.0) - liquidPoint(vParam - vec2(e, 0.0), 1.0); + vec3 pz = liquidPoint(vParam + vec2(0.0, e), 1.0) - liquidPoint(vParam - vec2(0.0, e), 1.0); + vec3 nLocal = normalize(cross(pz, px) + vec3(0.0, 1e-6, 0.0)); + vec3 N = normalize(uAxisX * nLocal.x + uAxisY * nLocal.y + uAxisZ * nLocal.z); + + vec3 V = normalize(cameraPosition - vWorld); + if (dot(N, V) < 0.0) N = -N; // the curl folds the sheet; light both faces + + /* ---------------- the flow, and therefore the speed ---------------- */ + float speed = 0.0; + vec2 flow = vec2(0.0); + bool needFlow = uCrust > 0.001 || uFoamSpeed > 0.001; + if (needFlow) { + flow = flowAt(vParam, nLocal); + speed = length(flow); + } + + /* ---------------- the crust ---------------- */ + float skin = 0.0; // 0..1 — how much black plate covers this fragment + float seam = 0.0; // 0..1 — how much glowing crack + if (uCrust > 0.001) { + // Coverage: unbroken where the surface is slow, gone where it is fast. + float cover = 1.0 - smoothstep(uCrustForm, max(uCrustBreak, uCrustForm + 1e-3), speed); + // The first skin takes a moment to chill. This is the only memory the + // module has, and it is global rather than per-fragment — see the header. + cover *= smoothstep(0.0, max(uCrustFormTime, 1e-3), uNow); + cover *= clamp(uCrust, 0.0, 1.0); + + // A frame built from the flow. Squashing the along-flow axis stretches the + // features along it; the perpendicular axis keeps its scale, so the seams + // come out long and parallel rather than isotropic. + vec2 fdir = speed > 1e-4 ? flow / speed : vec2(1.0, 0.0); + vec2 fperp = vec2(-fdir.y, fdir.x); + vec2 q = vec2(dot(vParam, fdir) / max(uCrackStretch, 0.05), + dot(vParam, fperp)) * uCrackScale; + + // Two-phase advection. One layer advected forever smears without bound; + // two layers half a period out of step, cross-faded on |1 - 2·frac|, reset + // one at a time and the smear never gets past half a period's worth. + float period = max(uCrustPeriod, 0.05); + float ph = uTime / period; + float p0 = fract(ph); + float p1 = fract(ph + 0.5); + // The flow expressed in the crack frame, so the pattern rides the same + // field the coverage was computed from. + vec2 fq = vec2(speed / max(uCrackStretch, 0.05), 0.0) * uCrackScale * uCrustAdvect; + float w = abs(1.0 - 2.0 * p0); + float ca = crackField(q - fq * (p0 * period)); + float cb = crackField(q - fq * (p1 * period)); + float crack = mix(cb, ca, w); + + seam = crack * cover; + skin = cover * (1.0 - crack); + + if (uCrustBump > 0.001) { + // One extra pair of taps, offset across the flow. The seams are grooves, + // so the skin's normal tilts across them and not along them — a 1-D + // gradient is the whole of the effect and a 2-D one is twice the price + // for nothing. Differenced against the *unweighted* crack field, or the + // coverage term would leak a phantom bump into the melt. + float bump = mix(crackField(q + vec2(0.0, 0.35) - fq * (p1 * period)), + crackField(q + vec2(0.0, 0.35) - fq * (p0 * period)), w); + vec3 perpW = normalize(uAxisX * fperp.x + uAxisZ * fperp.y); + N = normalize(N + perpW * (bump - crack) * cover * uCrustBump * 2.0); + } + } + + /* ---------------- lighting ---------------- */ + vec3 L = normalize(uLightDir); + float ndl = dot(N, L); + float ndv = clamp(dot(N, V), 0.0, 1.0); + + // Wrapped diffuse: a liquid scatters, so the terminator is soft and the + // shaded side never reaches zero. A hard lambert on a heightfield draws the + // wave troughs as black bands. + float lam = clamp(ndl * 0.5 + 0.5, 0.0, 1.0); + float lit = uAmbient + (1.0 - uAmbient) * lam; + + vec3 H = normalize(L + V); + float spec = pow(max(dot(N, H), 0.0), max(uShininess, 1.0)) * uSpecular; + // Skin is matte and melt is glass: the crust has to kill the highlight or + // the black plates read as wet paint. + spec *= mix(1.0, 0.12, skin); + + float fres = clamp((0.02 + 0.98 * pow(1.0 - ndv, 5.0)) * uFresnel, 0.0, 1.0); + fres *= mix(1.0, 0.15, skin); + + vec3 refl = reflect(-V, N); + vec3 env = texture2D(uEnvMap, equirectUv(refl)).rgb * uEnvIntensity; + env = max(env, skyFloor(refl)); + + /* ---------------- the body ---------------- */ + // Thickness is the liquid standing above the floor, so a crest is *deeper* + // than a trough and the depth tint tracks the swell instead of fighting it. + float thick = clamp((vHeight + uPoolDepth) * uDepthTint, 0.0, 1.0); + thick = 1.0 - exp(-thick * 2.0); + vec3 body = mix(uColorShallow, uColorDeep, thick) * lit; + + // The thin front face of a breaking wave is lit from behind. Only ever + // applied where the sheet has actually folded, which is what vFace is for. + float through = pow(clamp(dot(V, -L), 0.0, 1.0), 2.0); + vec3 sss = uColorShallow * (vFace * uTranslucency * (0.35 + 0.65 * through)); + + /* ---------------- foam ---------------- */ + float foam = 0.0; + if (uFoam > 0.001) { + // Density varies smoothly, the speckle itself does not. Shading foam as a + // smooth function of the wave paints that field's iso-contours onto the + // surface and the pool comes out looking like a contour map. + float sp = fbm3(vec3(vParam * uFoamScale, uTime * 0.7 + uSeed)) * 0.5 + 0.5; + float bias = clamp(vFace * uFoamCrest + + smoothstep(uCrustForm, max(uCrustBreak, uCrustForm + 1e-3), speed) * uFoamSpeed + + clamp(vHeight * 2.0, 0.0, 1.0) * 0.25, 0.0, 1.4); + float threshold = mix(0.95, 0.34, clamp(bias, 0.0, 1.0)); + foam = smoothstep(threshold, threshold + 0.14, sp); + foam = pow(foam, max(uFoamSharp, 0.2)) * clamp(bias * 1.4, 0.0, 1.0) * uFoam; + } + + /* ---------------- composite ---------------- */ + vec3 color = mix(body, env + uColorSpec * spec, fres); + color += uColorSpec * spec * (1.0 - fres); + color += sss; + + // The crust goes over the top of everything the melt was doing: it is opaque + // rock, not a tint on the liquid. + color = mix(color, uColorCrust * (uAmbient + (1.0 - uAmbient) * lam), skin); + + vec3 emissive = + uColorHot * (1.0 - skin) * uEmissive + + uColorSeam * seam * uSeamGlow + + uColorHot * clamp(speed / max(uCrustBreak, 0.01), 0.0, 1.0) * (1.0 - skin) * uMeltGlow; + color += emissive; + + color = mix(color, uColorFoam * (0.5 + 0.5 * lit), foam); + color *= uGlow * uGlobalGlow * mix(0.7, 1.0, uShaderIntensity); + + /* ---------------- the waterline ---------------- */ + vec2 nq = vParam / max(uHalf, vec2(1e-3)); + float r = mix(max(abs(nq.x), abs(nq.y)), length(nq), clamp(uRound, 0.0, 1.0)); + // Sampled in metres so the raggedness is a fixed physical size: a small pool + // and a large one break up with the same size of tongue. + float jag = fbm3(vec3(vParam * uEdgeScale, uSeed * 5.7)) * 0.5 + 0.5; + r *= mix(1.0, 0.7 + 0.6 * jag, clamp(uEdgeNoise, 0.0, 1.0)); + float mask = 1.0 - smoothstep(max(uFill - uEdgeSoft, 0.0), uFill, r); + + float alpha = mask * uOpacity; + // The folded lip is a sheet a few millimetres thick — you can see through it. + alpha *= 1.0 - vFace * clamp(uTranslucency * 0.4, 0.0, 0.85); + alpha = clamp(alpha + foam * 0.5, 0.0, 1.0); + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, max(uContactFade, 1e-3)); + if (alpha < 0.004) discard; + + gl_FragColor = vec4(color, alpha); + } +`; + +/* ---------------------------------------------------------------- */ +/* Defaults */ +/* ---------------------------------------------------------------- */ + +/** + * A pool that is visible on the first frame, not an art direction. Every one of + * these is meant to become a slider in the ability's own settings block — + * anything left falling back to here is a value the editor cannot reach, which + * is an **I1** violation waiting to be filed as a bug. + */ +const DEFAULTS = { + /* --- the sheet --- */ + sizeX: 8, // metres, along the surface's +X + sizeZ: 8, // metres, across + fill: 1, // 0..1 of the half-extent the liquid reaches + round: 1, // 0 rectangular footprint, 1 elliptical + edgeSoft: 0.14, // 0..1 of the field over which the waterline fades + edgeNoise: 0.35, // 0..1 how ragged that line is + edgeScale: 0.9, // cycles per metre of the raggedness + seed: 0, // decorrelates two pools on screen at once + opacity: 1, + contactFade: 0.25, // metres of soft fade against opaque geometry + + /* --- the swell: four directional waves --- */ + waveAmpA: 0.09, // metres + waveAmpB: 0.05, + waveAmpC: 0.03, + waveAmpD: 0.02, + waveLengthA: 5.5, // metres, crest to crest + waveLengthB: 3.1, + waveLengthC: 1.7, + waveLengthD: 0.9, + waveSpeedA: 1.1, // metres/second + waveSpeedB: 0.8, + waveSpeedC: 0.55, + waveSpeedD: 0.4, + waveAngleA: 0.0, // radians + waveAngleB: 1.05, + waveAngleC: 2.4, + waveAngleD: 3.9, + steepness: 0.45, // 0 sine, 1 Gerstner cusps. Above ~1 the mesh self-folds. + + /* --- chop --- */ + chop: 0.035, // metres + chopScale: 1.6, // cycles per metre + chopSpeed: 0.35, // metres/second the field drifts + detail: 0.012, // metres — fragment-only; lives entirely in the normal + detailScale: 7.5, // cycles per metre + detailSpeed: 0.9, // metres/second + + /* --- ripples --- */ + rippleAmp: 0.18, // metres at strength 1 + rippleSpeed: 3.2, // metres/second the front travels + rippleLength: 1.0, // metres, crest to crest inside the packet + rippleWidth: 0.75, // metres of the gaussian envelope + rippleDecay: 1.3, // seconds to 1/e + rippleSpread: 2.6, // metres over which it also thins with radius + + /* --- the flow field --- */ + flowAngle: 0, // radians, the bulk drift's bearing + flowSpeed: 0.25, // metres/second + flowRadial: 0.6, // metres/second outward at the centre + flowRadialFall: 3.2, // metres to 1/e + flowEddy: 0.5, // metres/second of curl swirl + flowEddyScale: 0.28, // cycles per metre + flowEddySpeed: 0.12, // Hz the eddies churn + flowGravity: 2.2, // metres/second per unit of surface slope + + /* --- the crust (0 for water and blood) --- */ + crust: 0, // 0..1 master; 0 skips the whole block + crustForm: 0.35, // m/s below which the skin is unbroken + crustBreak: 1.5, // m/s above which there is none + crustFormTime: 0.7, // seconds for the first skin to chill + crackScale: 1.15, // cycles per metre across the flow + crackStretch: 4.5, // how many times longer features are along it + crackWidth: 0.18, // 0..1 of the field — the seam's width + crustAdvect: 1, // 0..1 how strongly the pattern is carried by the flow + crustPeriod: 2.4, // seconds before the flow map resets + crustBump: 0.4, // 0..1 how much the skin roughens the normal + seamGlow: 2.6, + meltGlow: 1.2, // glow of the bare melt between the plates + + /* --- foam --- */ + foam: 0, // 0..1 master + foamScale: 5.5, // cycles per metre of the speckle + foamSharp: 1.4, + foamCrest: 1.1, // how much the breaking lip seeds it + foamSpeed: 0.5, // how much surface speed seeds it + + /* --- the travelling wave (WAVE mode) --- */ + waveFront: 0.5, // 0..1 along +X. The ability drives this. + crestHeight: 1.1, // metres + crestBack: 2.2, // metres — the back slope's 1/e length + crestFace: 0.3, // metres — the front face's 1/e length + crestCurl: 0.55, // metres of forward throw per metre of height + crestWidth: 0.8, // 0..1 of the half-extent across the wave + crestFeather: 0.3, // 0..1 of that, over which the ends die + crestBreak: 0.35, // 0..1 how ragged the lip is + crestBreakScale: 1.3, //cycles per metre along the lip + + /* --- shading --- */ + poolDepth: 0.4, // metres of liquid under the mean plane + depthTint: 1.5, // Beer-Lambert density, per metre + translucency: 1.0, // backlight through the folded lip + ambient: 0.28, + specular: 1.3, + shininess: 64, // Blinn-Phong exponent + fresnel: 1.1, + envIntensity: 0.7, + skyIntensity: 0.45, + emissive: 0, // self-lit body — lava needs it, water must not have it + glow: 1, + normalEps: 0.045, // metres — the finite-difference step + + colorDeep: '#0a2230', + colorShallow: '#2c7f92', + colorCrust: '#120806', + colorSeam: '#ff5a12', + colorHot: '#ffe08a', + colorFoam: '#eef6f8', + colorSpec: '#ffffff', + colorSky: '#3a5570' +}; + +/** Every canonical key with its default. Copy it into a settings block. */ +export function liquidParams() { + return { ...DEFAULTS }; +} + +const num = (v, d) => (v === undefined || v === null ? d : v); + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope (I3) */ +/* ---------------------------------------------------------------- */ + +const _ax = new Vector3(); +const _ay = new Vector3(); +const _az = new Vector3(); +const _rel = new Vector3(); + +/* ---------------------------------------------------------------- */ +/* LiquidSurface */ +/* ---------------------------------------------------------------- */ + +/** + * ```js + * const _o = liquidParams(); // module scope — I3 + * const _hit = new Vector3(); + * + * this.pool = new LiquidSurface({ mode: LiquidMode.POOL, segments: 96 }); + * this.group.add(this.pool.object3D); + * // … + * onTravel(dt) { + * const c = settings.magma; + * this.pool.setPlacement(this.target, this.direction, UP); + * _o.sizeX = _o.sizeZ = c.zoneRadius * 2; // metres, resolved THIS frame + * _o.fill = this.u; + * _o.crust = c.crust; + * this.pool.update(this.age, _o); + * } + * onBlobLanded(worldPos) { + * this.pool.rippleAtWorld(worldPos, 1); // fraction + timestamp, no metres + * } + * ``` + */ +export class LiquidSurface { + /** + * @param {object} options + * @param {number} [options.segments] grid resolution per side. 96 is a good + * default; below ~48 the Gerstner cusps + * facet and above ~160 you are paying + * vertex cost for detail the normal + * already carries for free. + * @param {number} [options.mode] LiquidMode.* + * @param {boolean} [options.depthWrite] a heightfield is a solid: its own + * crests should hide its far side. + * @param {boolean} [options.doubleSide] needed once `crestCurl` folds the sheet + * @param {number} [options.renderOrder] + * @param {string} [options.name] + */ + constructor({ + segments = 96, + mode = LiquidMode.POOL, + depthWrite = true, + doubleSide = true, + renderOrder = 3, + name = 'LiquidSurface' + } = {}) { + const n = Math.max(2, Math.round(segments)); + + /* The geometry is a unit square. Every metre it ever occupies comes out of + `uHalf` in the vertex shader, which is what lets a pool be resized by a + slider on a paused frame without touching a buffer. */ + this.geometry = new PlaneGeometry(1, 1, n, n); + this.geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + + /** Ripple records: (u, v, born, strength). Allocated once, mutated forever. */ + this._ripples = []; + for (let i = 0; i < RIPPLE_SLOTS; i++) this._ripples.push(new Vector4(0, 0, 0, 0)); + this._nextSlot = 0; + + this.material = new ShaderMaterial({ + name: `${name}:surface`, + transparent: true, + depthWrite, + depthTest: true, + side: doubleSide ? DoubleSide : FrontSide, + toneMapped: false, + uniforms: sharedUniforms({ + uEnvMap: frame.uEnvMap, + + uAnchor: { value: new Vector3() }, + uAxisX: { value: new Vector3(1, 0, 0) }, + uAxisY: { value: new Vector3(0, 1, 0) }, + uAxisZ: { value: new Vector3(0, 0, 1) }, + uHalf: { value: new Vector2(DEFAULTS.sizeX * 0.5, DEFAULTS.sizeZ * 0.5) }, + uNow: { value: 0 }, + uSeed: { value: DEFAULTS.seed }, + uMode: { value: mode }, + + uWaveAmp: { value: new Vector4() }, + uWaveLength: { value: new Vector4(1, 1, 1, 1) }, + uWaveSpeed: { value: new Vector4() }, + uWaveAngle: { value: new Vector4() }, + uSteepness: { value: DEFAULTS.steepness }, + + uChop: { value: DEFAULTS.chop }, + uChopScale: { value: DEFAULTS.chopScale }, + uChopSpeed: { value: DEFAULTS.chopSpeed }, + uDetail: { value: DEFAULTS.detail }, + uDetailScale: { value: DEFAULTS.detailScale }, + uDetailSpeed: { value: DEFAULTS.detailSpeed }, + + uRipples: { value: this._ripples }, + uRippleAmp: { value: DEFAULTS.rippleAmp }, + uRippleSpeed: { value: DEFAULTS.rippleSpeed }, + uRippleLength: { value: DEFAULTS.rippleLength }, + uRippleWidth: { value: DEFAULTS.rippleWidth }, + uRippleDecay: { value: DEFAULTS.rippleDecay }, + uRippleSpread: { value: DEFAULTS.rippleSpread }, + + uWaveFront: { value: DEFAULTS.waveFront }, + uCrestHeight: { value: DEFAULTS.crestHeight }, + uCrestBack: { value: DEFAULTS.crestBack }, + uCrestFace: { value: DEFAULTS.crestFace }, + uCrestCurl: { value: DEFAULTS.crestCurl }, + uCrestWidth: { value: DEFAULTS.crestWidth }, + uCrestFeather: { value: DEFAULTS.crestFeather }, + uCrestBreak: { value: DEFAULTS.crestBreak }, + uCrestBreakScale: { value: DEFAULTS.crestBreakScale }, + + uFill: { value: DEFAULTS.fill }, + uRound: { value: DEFAULTS.round }, + uEdgeSoft: { value: DEFAULTS.edgeSoft }, + uEdgeNoise: { value: DEFAULTS.edgeNoise }, + uEdgeScale: { value: DEFAULTS.edgeScale }, + + uFlowAngle: { value: DEFAULTS.flowAngle }, + uFlowSpeed: { value: DEFAULTS.flowSpeed }, + uFlowRadial: { value: DEFAULTS.flowRadial }, + uFlowRadialFall: { value: DEFAULTS.flowRadialFall }, + uFlowEddy: { value: DEFAULTS.flowEddy }, + uFlowEddyScale: { value: DEFAULTS.flowEddyScale }, + uFlowEddySpeed: { value: DEFAULTS.flowEddySpeed }, + uFlowGravity: { value: DEFAULTS.flowGravity }, + + uCrust: { value: DEFAULTS.crust }, + uCrustForm: { value: DEFAULTS.crustForm }, + uCrustBreak: { value: DEFAULTS.crustBreak }, + uCrustFormTime: { value: DEFAULTS.crustFormTime }, + uCrackScale: { value: DEFAULTS.crackScale }, + uCrackStretch: { value: DEFAULTS.crackStretch }, + uCrackWidth: { value: DEFAULTS.crackWidth }, + uCrustAdvect: { value: DEFAULTS.crustAdvect }, + uCrustPeriod: { value: DEFAULTS.crustPeriod }, + uCrustBump: { value: DEFAULTS.crustBump }, + uSeamGlow: { value: DEFAULTS.seamGlow }, + uMeltGlow: { value: DEFAULTS.meltGlow }, + + uFoam: { value: DEFAULTS.foam }, + uFoamScale: { value: DEFAULTS.foamScale }, + uFoamSharp: { value: DEFAULTS.foamSharp }, + uFoamCrest: { value: DEFAULTS.foamCrest }, + uFoamSpeed: { value: DEFAULTS.foamSpeed }, + + uColorDeep: { value: new Color(DEFAULTS.colorDeep) }, + uColorShallow: { value: new Color(DEFAULTS.colorShallow) }, + uColorCrust: { value: new Color(DEFAULTS.colorCrust) }, + uColorSeam: { value: new Color(DEFAULTS.colorSeam) }, + uColorHot: { value: new Color(DEFAULTS.colorHot) }, + uColorFoam: { value: new Color(DEFAULTS.colorFoam) }, + uColorSpec: { value: new Color(DEFAULTS.colorSpec) }, + uColorSky: { value: new Color(DEFAULTS.colorSky) }, + + uPoolDepth: { value: DEFAULTS.poolDepth }, + uDepthTint: { value: DEFAULTS.depthTint }, + uTranslucency: { value: DEFAULTS.translucency }, + uAmbient: { value: DEFAULTS.ambient }, + uSpecular: { value: DEFAULTS.specular }, + uShininess: { value: DEFAULTS.shininess }, + uFresnel: { value: DEFAULTS.fresnel }, + uEnvIntensity: { value: DEFAULTS.envIntensity }, + uSkyIntensity: { value: DEFAULTS.skyIntensity }, + uEmissive: { value: DEFAULTS.emissive }, + uGlow: { value: DEFAULTS.glow }, + uOpacity: { value: DEFAULTS.opacity }, + uNormalEps: { value: DEFAULTS.normalEps }, + uContactFade: { value: DEFAULTS.contactFade } + }), + vertexShader: LIQUID_VERTEX, + fragmentShader: LIQUID_FRAGMENT + }); + + this.mesh = new Mesh(this.geometry, this.material); + this.mesh.name = name; + this.mesh.layers.set(LAYER.VFX); + this.mesh.renderOrder = renderOrder; + // Placed entirely by the vertex shader; its own bounds mean nothing. + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.visible = false; + + this._now = 0; + this._halfX = DEFAULTS.sizeX * 0.5; + this._halfZ = DEFAULTS.sizeZ * 0.5; + } + + /** Add this to the ability's group. */ + get object3D() { + return this.mesh; + } + + get uniforms() { + return this.material.uniforms; + } + + /** Always one. */ + get drawCalls() { + return 1; + } + + get visible() { + return this.mesh.visible; + } + + set visible(v) { + this.mesh.visible = !!v; + } + + get mode() { + return this.material.uniforms.uMode.value; + } + + /** POOL ⇄ WAVE. A uniform branch — no recompile, switch it mid-cast. */ + set mode(m) { + this.material.uniforms.uMode.value = m | 0; + } + + /** + * Place the sheet without touching a matrix. + * + * @param {THREE.Vector3} anchor world centre of the mean plane, metres + * @param {THREE.Vector3} along the sheet's local +X — a WAVE travels this way + * @param {THREE.Vector3} up the sheet's normal at rest, re-orthogonalised + */ + setPlacement(anchor, along, up) { + const u = this.material.uniforms; + u.uAnchor.value.copy(anchor); + _ax.copy(along).normalize(); + _az.crossVectors(up, _ax).normalize(); + _ay.crossVectors(_ax, _az).normalize(); + u.uAxisX.value.copy(_ax); + u.uAxisY.value.copy(_ay); + u.uAxisZ.value.copy(_az); + return this; + } + + /* ---------------- ripples ---------------- */ + + /** + * Inject a circular wave. + * + * @param {number} u −1..1 across the sheet's +X half-extent. **Unitless.** + * @param {number} v −1..1 across the +Z half-extent. **Unitless.** + * @param {number} strength dimensionless multiplier on `rippleAmp` + * @param {number} [now] timestamp; defaults to the clock `update()` last saw + * @returns {number} the slot it took + * + * The record is a fraction, a timestamp and a strength — never a metre, per + * **I1**. Resize the pool afterwards and the standing ripples move with it, + * which is the observable proof the rule is being kept. + */ + ripple(u, v, strength = 1, now = this._now) { + const slot = this._oldestSlot(now); + this._ripples[slot].set(u, v, now, Math.max(0, strength)); + return slot; + } + + /** + * The same, from a world point — converted against the live placement. + * + * Call it *after* `update()` on the frame you want it measured against, since + * the conversion needs this frame's half-extents. What is stored is still only + * the fraction. + */ + rippleAtWorld(position, strength = 1, now = this._now) { + const un = this.material.uniforms; + _rel.copy(position).sub(un.uAnchor.value); + const u = _rel.dot(un.uAxisX.value) / Math.max(this._halfX, 1e-4); + const v = _rel.dot(un.uAxisZ.value) / Math.max(this._halfZ, 1e-4); + return this.ripple(u, v, strength, now); + } + + /** + * Prefer a free slot; otherwise evict the *oldest*, never the newest. The + * newest is the one the player is looking at. + */ + _oldestSlot(now) { + let best = 0; + let bestAge = -1; + for (let i = 0; i < RIPPLE_SLOTS; i++) { + const r = this._ripples[i]; + if (r.w <= 0) return i; + const age = now - r.z; + if (age > bestAge) { + bestAge = age; + best = i; + } + } + // Round-robin as a tie-break so a burst of same-frame impacts spreads out. + const slot = this._nextSlot; + this._nextSlot = (this._nextSlot + 1) % RIPPLE_SLOTS; + return bestAge > 0 ? best : slot; + } + + clearRipples() { + for (let i = 0; i < RIPPLE_SLOTS; i++) this._ripples[i].set(0, 0, 0, 0); + this._nextSlot = 0; + return this; + } + + /** Hide it and blank the ring buffer. Leaves the instance reusable. */ + reset() { + this.clearRipples(); + this._now = 0; + this.material.uniforms.uNow.value = 0; + this.mesh.visible = false; + return this; + } + + /* ---------------- the frame ---------------- */ + + /** + * Re-resolve every dimension. Every frame, zero-length ones included. + * + * *the sheet* — `sizeX` `sizeZ` (metres), `fill` (0..1 of the half-extent), + * `round` (0 rectangular, 1 elliptical), `edgeSoft` / `edgeNoise` / + * `edgeScale`, `seed`, `opacity`, `contactFade` (metres). + * + * *the swell* — `waveAmpA..D` (metres), `waveLengthA..D` (metres), + * `waveSpeedA..D` (m/s), `waveAngleA..D` (radians), `steepness`. + * + * *chop* — `chop` / `chopScale` / `chopSpeed`, and `detail` / `detailScale` / + * `detailSpeed` which the vertex stage deliberately ignores. + * + * *ripples* — `rippleAmp` (metres), `rippleSpeed` (m/s), `rippleLength` + * (metres), `rippleWidth` (metres), `rippleDecay` (seconds), `rippleSpread` + * (metres). + * + * *the flow* — `flowAngle` (rad), `flowSpeed` (m/s), `flowRadial` (m/s), + * `flowRadialFall` (metres), `flowEddy` (m/s), `flowEddyScale`, + * `flowEddySpeed`, `flowGravity`. + * + * *the crust* — `crust` (0..1 master; 0 skips the block entirely), `crustForm` + * and `crustBreak` (m/s — the speeds between which the skin dies), + * `crustFormTime` (seconds), `crackScale`, `crackStretch`, `crackWidth`, + * `crustAdvect`, `crustPeriod` (seconds), `crustBump`, `seamGlow`, `meltGlow`. + * + * *foam* — `foam`, `foamScale`, `foamSharp`, `foamCrest`, `foamSpeed`. + * + * *the wave* — `waveFront` (0..1), `crestHeight` / `crestBack` / `crestFace` / + * `crestCurl` (metres), `crestWidth` / `crestFeather` / `crestBreak` (0..1), + * `crestBreakScale`. + * + * *shading* — `poolDepth`, `depthTint`, `translucency`, `ambient`, `specular`, + * `shininess`, `fresnel`, `envIntensity`, `skyIntensity`, `emissive`, `glow`, + * `normalEps` (metres), and eight pickers `colorDeep`, `colorShallow`, + * `colorCrust`, `colorSeam`, `colorHot`, `colorFoam`, `colorSpec`, `colorSky`. + * + * @param {number} now seconds since the cast began — the ripple clock + * @param {object} p a plain object. Keep it at module scope and refill it. + */ + update(now, p) { + const u = this.material.uniforms; + this._now = now; + u.uNow.value = now; + + this._halfX = Math.max(num(p.sizeX, DEFAULTS.sizeX), 0.01) * 0.5; + this._halfZ = Math.max(num(p.sizeZ, DEFAULTS.sizeZ), 0.01) * 0.5; + u.uHalf.value.set(this._halfX, this._halfZ); + u.uSeed.value = num(p.seed, DEFAULTS.seed); + + u.uWaveAmp.value.set( + num(p.waveAmpA, DEFAULTS.waveAmpA), + num(p.waveAmpB, DEFAULTS.waveAmpB), + num(p.waveAmpC, DEFAULTS.waveAmpC), + num(p.waveAmpD, DEFAULTS.waveAmpD) + ); + u.uWaveLength.value.set( + num(p.waveLengthA, DEFAULTS.waveLengthA), + num(p.waveLengthB, DEFAULTS.waveLengthB), + num(p.waveLengthC, DEFAULTS.waveLengthC), + num(p.waveLengthD, DEFAULTS.waveLengthD) + ); + u.uWaveSpeed.value.set( + num(p.waveSpeedA, DEFAULTS.waveSpeedA), + num(p.waveSpeedB, DEFAULTS.waveSpeedB), + num(p.waveSpeedC, DEFAULTS.waveSpeedC), + num(p.waveSpeedD, DEFAULTS.waveSpeedD) + ); + u.uWaveAngle.value.set( + num(p.waveAngleA, DEFAULTS.waveAngleA), + num(p.waveAngleB, DEFAULTS.waveAngleB), + num(p.waveAngleC, DEFAULTS.waveAngleC), + num(p.waveAngleD, DEFAULTS.waveAngleD) + ); + // Capped here rather than in the shader: past ~1 the Gerstner term pulls + // vertices through one another and the surface knots. Better to clamp the + // slider's effect than to litter the field function with guards. + u.uSteepness.value = Math.min(num(p.steepness, DEFAULTS.steepness), 1.2); + + u.uChop.value = num(p.chop, DEFAULTS.chop); + u.uChopScale.value = num(p.chopScale, DEFAULTS.chopScale); + u.uChopSpeed.value = num(p.chopSpeed, DEFAULTS.chopSpeed); + u.uDetail.value = num(p.detail, DEFAULTS.detail); + u.uDetailScale.value = num(p.detailScale, DEFAULTS.detailScale); + u.uDetailSpeed.value = num(p.detailSpeed, DEFAULTS.detailSpeed); + + u.uRippleAmp.value = num(p.rippleAmp, DEFAULTS.rippleAmp); + u.uRippleSpeed.value = num(p.rippleSpeed, DEFAULTS.rippleSpeed); + u.uRippleLength.value = num(p.rippleLength, DEFAULTS.rippleLength); + u.uRippleWidth.value = num(p.rippleWidth, DEFAULTS.rippleWidth); + u.uRippleDecay.value = num(p.rippleDecay, DEFAULTS.rippleDecay); + u.uRippleSpread.value = num(p.rippleSpread, DEFAULTS.rippleSpread); + + u.uWaveFront.value = num(p.waveFront, DEFAULTS.waveFront); + u.uCrestHeight.value = num(p.crestHeight, DEFAULTS.crestHeight); + u.uCrestBack.value = num(p.crestBack, DEFAULTS.crestBack); + u.uCrestFace.value = num(p.crestFace, DEFAULTS.crestFace); + u.uCrestCurl.value = num(p.crestCurl, DEFAULTS.crestCurl); + u.uCrestWidth.value = num(p.crestWidth, DEFAULTS.crestWidth); + u.uCrestFeather.value = num(p.crestFeather, DEFAULTS.crestFeather); + u.uCrestBreak.value = num(p.crestBreak, DEFAULTS.crestBreak); + u.uCrestBreakScale.value = num(p.crestBreakScale, DEFAULTS.crestBreakScale); + + u.uFill.value = num(p.fill, DEFAULTS.fill); + u.uRound.value = num(p.round, DEFAULTS.round); + u.uEdgeSoft.value = num(p.edgeSoft, DEFAULTS.edgeSoft); + u.uEdgeNoise.value = num(p.edgeNoise, DEFAULTS.edgeNoise); + u.uEdgeScale.value = num(p.edgeScale, DEFAULTS.edgeScale); + + u.uFlowAngle.value = num(p.flowAngle, DEFAULTS.flowAngle); + u.uFlowSpeed.value = num(p.flowSpeed, DEFAULTS.flowSpeed); + u.uFlowRadial.value = num(p.flowRadial, DEFAULTS.flowRadial); + u.uFlowRadialFall.value = num(p.flowRadialFall, DEFAULTS.flowRadialFall); + u.uFlowEddy.value = num(p.flowEddy, DEFAULTS.flowEddy); + u.uFlowEddyScale.value = num(p.flowEddyScale, DEFAULTS.flowEddyScale); + u.uFlowEddySpeed.value = num(p.flowEddySpeed, DEFAULTS.flowEddySpeed); + u.uFlowGravity.value = num(p.flowGravity, DEFAULTS.flowGravity); + + u.uCrust.value = num(p.crust, DEFAULTS.crust); + u.uCrustForm.value = num(p.crustForm, DEFAULTS.crustForm); + u.uCrustBreak.value = num(p.crustBreak, DEFAULTS.crustBreak); + u.uCrustFormTime.value = num(p.crustFormTime, DEFAULTS.crustFormTime); + u.uCrackScale.value = num(p.crackScale, DEFAULTS.crackScale); + u.uCrackStretch.value = num(p.crackStretch, DEFAULTS.crackStretch); + u.uCrackWidth.value = num(p.crackWidth, DEFAULTS.crackWidth); + u.uCrustAdvect.value = num(p.crustAdvect, DEFAULTS.crustAdvect); + u.uCrustPeriod.value = num(p.crustPeriod, DEFAULTS.crustPeriod); + u.uCrustBump.value = num(p.crustBump, DEFAULTS.crustBump); + u.uSeamGlow.value = num(p.seamGlow, DEFAULTS.seamGlow); + u.uMeltGlow.value = num(p.meltGlow, DEFAULTS.meltGlow); + + u.uFoam.value = num(p.foam, DEFAULTS.foam); + u.uFoamScale.value = num(p.foamScale, DEFAULTS.foamScale); + u.uFoamSharp.value = num(p.foamSharp, DEFAULTS.foamSharp); + u.uFoamCrest.value = num(p.foamCrest, DEFAULTS.foamCrest); + u.uFoamSpeed.value = num(p.foamSpeed, DEFAULTS.foamSpeed); + + u.uPoolDepth.value = num(p.poolDepth, DEFAULTS.poolDepth); + u.uDepthTint.value = num(p.depthTint, DEFAULTS.depthTint); + u.uTranslucency.value = num(p.translucency, DEFAULTS.translucency); + u.uAmbient.value = num(p.ambient, DEFAULTS.ambient); + u.uSpecular.value = num(p.specular, DEFAULTS.specular); + u.uShininess.value = num(p.shininess, DEFAULTS.shininess); + u.uFresnel.value = num(p.fresnel, DEFAULTS.fresnel); + u.uEnvIntensity.value = num(p.envIntensity, DEFAULTS.envIntensity); + u.uSkyIntensity.value = num(p.skyIntensity, DEFAULTS.skyIntensity); + u.uEmissive.value = num(p.emissive, DEFAULTS.emissive); + u.uGlow.value = num(p.glow, DEFAULTS.glow); + u.uOpacity.value = num(p.opacity, DEFAULTS.opacity); + u.uNormalEps.value = num(p.normalEps, DEFAULTS.normalEps); + u.uContactFade.value = num(p.contactFade, DEFAULTS.contactFade); + + copyColor(u.uColorDeep.value, p.colorDeep || DEFAULTS.colorDeep); + copyColor(u.uColorShallow.value, p.colorShallow || DEFAULTS.colorShallow); + copyColor(u.uColorCrust.value, p.colorCrust || DEFAULTS.colorCrust); + copyColor(u.uColorSeam.value, p.colorSeam || DEFAULTS.colorSeam); + copyColor(u.uColorHot.value, p.colorHot || DEFAULTS.colorHot); + copyColor(u.uColorFoam.value, p.colorFoam || DEFAULTS.colorFoam); + copyColor(u.uColorSpec.value, p.colorSpec || DEFAULTS.colorSpec); + copyColor(u.uColorSky.value, p.colorSky || DEFAULTS.colorSky); + + return this; + } + + /* ---------------- read-back ---------------- */ + + /** + * Where the breaking lip is, in world metres — the point to emit droplets from. + * + * Deliberately evaluates only the crest profile, not the chop and not the + * ripples. You want droplets leaving a clean moving line; sampling the full + * field would jitter every emitter by the finest octave in it, which reads as + * a fault in the emitter rather than as detail in the wave. + * + * @param {object} p the same live params `update()` was given + * @param {THREE.Vector3} out written in place + * @param {number} [across] −1..1 along the lip. Roll it per droplet. + */ + lipPosition(p, out, across = 0) { + const u = this.material.uniforms; + const halfX = Math.max(num(p.sizeX, DEFAULTS.sizeX), 0.01) * 0.5; + const halfZ = Math.max(num(p.sizeZ, DEFAULTS.sizeZ), 0.01) * 0.5; + + const h = this.lipHeight(p, across); + const x = (num(p.waveFront, DEFAULTS.waveFront) - 0.5) * halfX * 2 + h * num(p.crestCurl, DEFAULTS.crestCurl); + const z = across * num(p.crestWidth, DEFAULTS.crestWidth) * halfZ; + + out.copy(u.uAnchor.value) + .addScaledVector(u.uAxisX.value, x) + .addScaledVector(u.uAxisY.value, h) + .addScaledVector(u.uAxisZ.value, z); + return out; + } + + /** The crest's height in metres at a −1..1 position along the lip. */ + lipHeight(p, across = 0) { + if (this.mode === LiquidMode.POOL) return 0; + const width = Math.min(Math.max(num(p.crestWidth, DEFAULTS.crestWidth), 0), 1); + const feather = Math.max(num(p.crestFeather, DEFAULTS.crestFeather), 1e-3); + // `across` is a fraction of the crest's own width, so it lands at + // |across| * width of the half-extent — the same coordinate `crestLateral` + // thresholds in the shader. + const a = Math.abs(across) * width; + const edge0 = Math.max(width - feather, 0); + const span = Math.max(width - edge0, 1e-4); + // The same smoothstep the shader uses, expanded, so the two never disagree. + const t = Math.min(Math.max((a - edge0) / span, 0), 1); + const lateral = 1 - t * t * (3 - 2 * t); + return num(p.crestHeight, DEFAULTS.crestHeight) * lateral; + } + + dispose() { + this.mesh.visible = false; + this.geometry.dispose(); + this.material.dispose(); + this.mesh.parent?.remove(this.mesh); + } +} diff --git a/src/vfx/Mirror.js b/src/vfx/Mirror.js new file mode 100644 index 0000000..30477c0 --- /dev/null +++ b/src/vfx/Mirror.js @@ -0,0 +1,938 @@ +import { + Mesh, + ShaderMaterial, + NormalBlending, + DoubleSide, + WebGLRenderTarget, + HalfFloatType, + LinearFilter, + PerspectiveCamera, + Matrix4, + Frustum, + Sphere, + Plane, + Color, + Vector2, + Vector3, + Vector4 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { uprightQuad } from './quads.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { copyColor } from '../utils/color.js'; + +/* ------------------------------------------------------------------------ */ +/* Mirror — a planar surface that reflects the real scene */ +/* ------------------------------------------------------------------------ */ +/** + * A flat reflector: a camera mirrored about the surface's plane renders the + * `WORLD` layer into a small target, and the surface samples that target + * projectively. + * + * Two abilities need this and neither of them is served by faking it. + * `blackice` is *the* reflective surface in the sandbox and the entire read is + * "that is the actual room, upside down, sharpening as it freezes". + * `refractcascade` bounces a beam between floating panes and the roster line is + * blunt about it: fake it with an environment map and it reads as chrome; do it + * properly and it reads as glass. The reason is that an env map has no + * *parallax* — the reflection does not slide across the surface as you orbit, + * and the brain reads a static reflection as a painted-on shine within about + * half a second of camera movement. + * + * ## What it costs, honestly + * + * A rendering mirror is **one extra `renderer.render()` of the `WORLD` layer + * per frame**. Not a cheap pass — a whole scene traversal, render list, sort + * and draw. What it is *not* is a whole frame: no post stack, no depth prepass, + * no distortion pass, no VFX layer, no shadow-map update (the nested render + * turns `shadowMap.autoUpdate` off and reuses the maps the main pass already + * built, which is both faster and correct — the shadows in a reflection are the + * same shadows). + * + * The measurable numbers, which the module reports rather than estimates: + * `mirrorBudget.calls` and `mirrorBudget.triangles` are read straight out of + * `renderer.info` immediately after each nested render, so a mirror will tell + * you what it actually cost on this machine in this scene. The arithmetic ones: + * at the default 384² a reflection is 147,456 pixels, which is 16% of a 720p + * frame and 7% of a 1080p one, and its target is 384 × 384 × RGBA16F = 1.13 MB + * of colour plus a depth attachment, so roughly 1.7 MB per live mirror. + * + * Two caveats worth having in writing: + * + * - **`renderer.info` is contaminated on a mirror frame.** `render()` resets + * the counters at the top of every call, so a nested render wipes whatever + * the outer frame had accumulated so far. Anything reading `info.render.calls` + * for a HUD will under-report on frames where a mirror rendered. That is + * also exactly why the module publishes its own numbers. + * - A mirror is a **fill** cost as well as a draw cost, and the fill is the + * part that scales with `resolution`. Halving the resolution slider quarters + * the pixels and changes the draw-call count not at all. + * + * ## The budget, and the discipline it copies + * + * `core/Layers.js#distortionWriters` counts the meshes currently visible on the + * distortion layer so `PostProcessing` can skip that pass — clear included — + * whenever nothing is writing to it. The lesson is that a pass that runs when + * it has nothing to do is worse than no pass at all, and this module inherits + * it in two ways. + * + * The first is structural: a mirror's reflection is rendered from the mesh's + * own `onBeforeRender`, which the renderer calls only for a mesh that is + * visible and in the render list. **No visible mirror, no pass at all** — + * there is nothing to skip, because there is nothing to run. That is also why + * this module needs no wiring into `App` or `PostProcessing`: the renderer + * hands `onBeforeRender` the renderer, the scene and the camera, which is + * everything the reflection needs, and a module in `src/vfx/` never has to be + * given a renderer handle it has no other use for. + * + * The second is the hard cap. `mirrorBudget.max` mirrors may re-render in one + * frame — **two** by default. `refractcascade` puts five panes down a line, and + * five extra scene renders a frame is not a cast, it is a slideshow. At the + * top of each frame the module scores every live mirror by + * + * ``` + * priority × apparent size × (1 + frames since it last rendered) + * ``` + * + * and gives the slots to the best few. The starvation term is what stops the + * nearest mirror hogging the budget forever, and it is unbounded upward on + * purpose so a mirror that has *never* rendered outranks everything. + * + * A mirror that misses its slot keeps last frame's texture. At 60 Hz with five + * panes and two slots each pane updates every 2–3 frames, which is a 30–50 ms + * old reflection — invisible for a slow orbit, and visible as a slight lag if + * you whip the camera. Given the alternative is 5× the cost, that is the right + * trade, and `mirrorBudget.max` is there for whoever disagrees. + * + * ## The oblique near plane + * + * The mirrored camera can see things *behind* the mirror, and they must not + * appear in the reflection: a floating pane would show the wall it is hanging + * in front of, glued to its own surface. The fix is Lengyel's oblique + * projection — the near plane is skewed to lie exactly on the mirror's plane, + * so everything behind it is clipped by hardware for free. The alternative, a + * user clipping plane, costs a `gl_ClipDistance` in every shader in the scene + * (three implements it by patching every material) and is enormously more + * expensive for the same result. The maths here follows `three/addons`' + * `Reflector`, which is the reference implementation of it in this ecosystem. + * + * ## Roughness + * + * The reflection lookup is blurred by a jittered disc whose radius is + * `roughness × blurRadius`, so `blackice` can start as a scuffed frozen puddle + * and sharpen into a black mirror as it freezes by moving one slider from 1 to + * 0. Two things about that kernel are not obvious: + * + * - The jitter hash is on `gl_FragCoord` **and nothing else**. Adding `uTime` + * to it, which is the obvious way to break up the banding, turns a soft + * reflection into boiling static — there is no temporal filter in this + * pipeline to resolve it against. + * - The kernel is *stretched* along the screen-space direction of the surface + * normal (`roughStretch`). A rough planar surface does not blur its + * reflection isotropically: at a grazing angle the reflected lobe is + * smeared along the view-vertical, which is why a wet road smears the + * headlights above them into a streak and not into a disc. + * + * ## Invariants + * + * - **I1** — the plane, the extents and every look parameter are re-resolved + * from the caller's live params object each frame, zero-length frames + * included. The quad is built in the vertex shader from `uAnchor`, two axes + * and `uSize`, so the mesh matrix is identity for its whole life and the + * reflection camera derives its plane from those same uniform boxes — there + * is nowhere for a stale metre to hide. + * - **I3** — the reflection camera, the frustum, the plane and every vector in + * the per-frame path are module-scope scratch. The render target is + * reallocated only on the frame the `resolution` slider actually changes. + * - **I5** — every dimension is a slider, both colours are pickers. + */ + +/* ---------------------------------------------------------------- */ +/* The budget */ +/* ---------------------------------------------------------------- */ + +/** + * How many mirrors may re-render per frame, plus the live tally. + * + * `max` is the only field you should write; use `setMirrorBudget()` so the + * clamp applies. Everything else is published by the module each frame and is + * there for a HUD, a profiling overlay, or a sanity check in the console. + * + * This lives here rather than in `core/Layers.js` — where `distortionWriters` + * lives — for the reason that file states in its own doc comment: the counter + * belongs wherever the *consumer* can reach it without dragging shader source + * into its bundle. `PostProcessing` has to ask about distortion, so that + * counter cannot live in a VFX module. Nothing outside `src/vfx/` has to ask + * about mirrors, because nothing outside runs their pass. + */ +export const mirrorBudget = { + /** Hard cap on reflection renders per frame. Small on purpose. */ + max: 2, + /** Mirrors currently visible. Retain/release, `distortionWriters`-style. */ + live: 0, + /** Reflections rendered so far this frame. */ + rendered: 0, + /** Mirrors that wanted a slot this frame and did not get one. */ + skipped: 0, + /** Draw calls the reflections cost this frame — measured, not estimated. */ + calls: 0, + /** Triangles ditto. */ + triangles: 0 +}; + +/** Set the per-frame cap. 0 freezes every reflection at its last texture. */ +export function setMirrorBudget(n) { + mirrorBudget.max = Math.max(0, Math.min(8, Math.round(n))); + return mirrorBudget.max; +} + +/** + * Live mirrors, in retain order, and the frame bookkeeping. + * + * `own` is the number of nested renders this module has issued during the + * current frame, and it exists because the obvious frame token does not work. + * `renderer.info.render.frame` increments inside *every* `render()` call — + * including the depth prepass, and including our own reflection renders — so + * the second mirror in a frame would see a different value from the first and + * conclude a new frame had started, resetting the budget on every mirror in + * turn and rendering all of them. Subtracting our own renders back out makes + * the token constant across one traversal, which is all it has to be. + */ +const _reg = { + list: [], + token: -1, + own: 0, + rendering: false +}; + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope, reused, never allocated in a frame (I3) */ +/* ---------------------------------------------------------------- */ + +const _refCam = new PerspectiveCamera(); +const _normal = new Vector3(); +const _anchor = new Vector3(); +const _camPos = new Vector3(); +const _eye = new Vector3(); +const _look = new Vector3(); +const _target = new Vector3(); +const _up = new Vector3(); +const _ax = new Vector3(); +const _ay = new Vector3(); +const _tmp = new Vector3(); +const _rot = new Matrix4(); +const _proj = new Matrix4(); +const _frustum = new Frustum(); +const _sphere = new Sphere(); +const _plane = new Plane(); +const _clip = new Vector4(); +const _q = new Vector4(); + +/** Bias matrix: clip space → texture space. Built once, never mutated. */ +const _bias = new Matrix4().set(0.5, 0, 0, 0.5, 0, 0.5, 0, 0.5, 0, 0, 0.5, 0.5, 0, 0, 0, 1); + +/* ---------------------------------------------------------------- */ +/* Frame scheduling */ +/* ---------------------------------------------------------------- */ + +/** + * Start a frame if this is the first mirror to ask, and pick the winners. + * + * Selection is a repeated max-scan rather than a sort: the live list is at most + * a handful of entries, and `Array.prototype.sort` with a comparator would put + * a closure in the per-frame path (**I3**). + */ +function beginFrame(renderer, camera) { + const raw = renderer.info.render.frame - _reg.own; + if (raw === _reg.token) return; + + _reg.token = renderer.info.render.frame; + _reg.own = 0; + mirrorBudget.rendered = 0; + mirrorBudget.skipped = 0; + mirrorBudget.calls = 0; + mirrorBudget.triangles = 0; + + const list = _reg.list; + let wanting = 0; + for (let i = 0; i < list.length; i++) { + const m = list[i]; + m._selected = false; + m._wants = m._wantsRender(camera); + if (m._wants) wanting++; + } + + const slots = Math.min(mirrorBudget.max, wanting); + for (let k = 0; k < slots; k++) { + let best = null; + let bestScore = -1; + for (let i = 0; i < list.length; i++) { + const m = list[i]; + if (!m._wants || m._selected) continue; + const score = m._score(camera); + if (score > bestScore) { + bestScore = score; + best = m; + } + } + if (!best) break; + best._selected = true; + } + + mirrorBudget.skipped = wanting - slots; + for (let i = 0; i < list.length; i++) { + const m = list[i]; + if (m._wants && !m._selected) m._starve++; + } +} + +/* ---------------------------------------------------------------- */ +/* Shaders */ +/* ---------------------------------------------------------------- */ + +/** + * The quad is built here from an anchor and two in-plane axes rather than from + * the mesh's matrix, for the same reason `Distortion.js` does it: a metre that + * lives in `mesh.scale` is a metre the ability captured at spawn, and **I1** + * says there are none of those. Placing a mirror is two uniform writes. + * + * Three varyings are the interesting ones: + * + * - `vReflect` is the fragment's world position pushed through + * `bias × projection × view` of the *reflection* camera, i.e. the projective + * texture coordinate. Doing it here and dividing in the fragment is the + * standard planar-reflection lookup and is exact under perspective. + * - `vStretch` is the screen-space direction of the surface normal, and it is a + * constant-across-the-quad varying (the anchor and the anchor-plus-normal are + * both projected here, once). The roughness kernel elongates along it. + * - `vLocal` is metres from the centre in the plane, which is what the edge + * fade and the ripple measure in. + */ +const MIRROR_VERTEX = /* glsl */ ` + uniform vec3 uAnchor; + uniform vec3 uAxisX; + uniform vec3 uAxisY; + uniform vec2 uSize; // full extents in metres (x along uAxisX) + uniform mat4 uReflectMatrix; // bias * proj * view of the reflection camera + + varying vec2 vLocal; // metres from the centre, in the plane + varying vec2 vNorm; // -1..1 across the quad + varying vec4 vReflect; + varying vec3 vWorld; + varying vec3 vViewDir; // world-space, fragment → eye + varying vec3 vNormalW; + varying vec2 vStretch; // screen direction of the normal, constant + + void main() { + vec2 ext = (uv - 0.5) * uSize; + vec3 world = uAnchor + uAxisX * ext.x + uAxisY * ext.y; + + vLocal = ext; + vNorm = (uv - 0.5) * 2.0; + vWorld = world; + vNormalW = normalize(cross(uAxisX, uAxisY)); + vViewDir = normalize(cameraPosition - world); + + vReflect = uReflectMatrix * vec4(world, 1.0); + + vec4 clip = projectionMatrix * viewMatrix * vec4(world, 1.0); + gl_Position = clip; + + /* The screen image of the anchor and of one metre of surface normal. Both + * are constant over the quad, so the varying is exact everywhere. */ + vec4 c0 = projectionMatrix * viewMatrix * vec4(uAnchor, 1.0); + vec4 c1 = projectionMatrix * viewMatrix * vec4(uAnchor + vNormalW, 1.0); + vec2 s0 = c0.xy / max(c0.w, 0.001); + vec2 s1 = c1.xy / max(c1.w, 0.001); + vec2 d = s1 - s0; + vStretch = length(d) > 1e-5 ? normalize(d) : vec2(0.0, 1.0); + } +`; + +const MIRROR_FRAGMENT = /* glsl */ ` + uniform sampler2D uReflection; + uniform float uHasReflection; // 0 until the first reflection has been rendered + uniform vec2 uResolution; + uniform float uTime; + + uniform float uRoughness; // 0 mirror-smooth, 1 fully scattered + uniform float uBlurRadius; // reflection-UV radius at roughness 1 + uniform float uBlurTaps; // 1..12 + uniform float uRoughStretch; // elongation along the normal's screen direction + + uniform float uReflectivity; // 0..1 base reflection weight + uniform float uFresnel; // 0..1 how much the grazing term shapes it + uniform float uFresnelPower; + + uniform float uRipple; // UV amplitude of the surface disturbance + uniform float uRippleScale; // cycles per metre + uniform float uRippleSpeed; // metres per second + + uniform float uOpacity; + uniform float uEdgeFade; // 0..1 of the half-extent + uniform float uCorner; // 0 rectangle, 1 ellipse + uniform float uSeed; + + uniform vec3 uColorTint; // multiplies the reflection + uniform vec3 uColorBase; // what shows through where it does not reflect + + varying vec2 vLocal; + varying vec2 vNorm; + varying vec4 vReflect; + varying vec3 vWorld; + varying vec3 vViewDir; + varying vec3 vNormalW; + varying vec2 vStretch; + + ${noiseGLSL} + + void main() { + /* --- the reflection lookup ------------------------------------- */ + vec2 base = vReflect.xy / max(vReflect.w, 0.0001); + + /* The disturbance is sampled in world metres and scrolls in world metres, + * so orbiting the camera does not drag the ripple across the surface — + * the same reason Distortion.js's heat haze works in world space. */ + vec3 rp = vec3(vWorld.xz * uRippleScale, vWorld.y * uRippleScale - uTime * uRippleSpeed); + base += vec2(fbm3(rp), fbm3(rp + vec3(19.3, 7.1, 41.7))) * uRipple; + + float aspect = max(uResolution.x, 1.0) / max(uResolution.y, 1.0); + float radius = clamp(uRoughness, 0.0, 1.0) * uBlurRadius; + float taps = clamp(uBlurTaps, 1.0, 12.0); + + /* Static per-pixel jitter. Deliberately not a function of time — see the + * module comment; a temporal hash here boils. */ + float jitter = hash13(vec3(gl_FragCoord.xy, uSeed)) * 6.28318530718; + + vec3 refl = vec3(0.0); + float wsum = 0.0; + for (int i = 0; i < 12; i++) { + float fi = float(i); + if (fi >= taps) break; + float ang = fi * 2.39996323 + jitter; + float rad = sqrt((fi + 0.5) / taps) * radius; + vec2 dir = vec2(cos(ang), sin(ang)); + vec2 off = dir * rad; + // Elongate along the normal's screen direction: a rough plane smears its + // reflection along the view-vertical, it does not fog it evenly. + off += vStretch * dot(dir, vStretch) * rad * uRoughStretch; + off.x /= aspect; // the square target holds a wide frame; unsquash it + refl += texture2D(uReflection, clamp(base + off, 0.0015, 0.9985)).rgb; + wsum += 1.0; + } + refl /= max(wsum, 1.0); + refl *= uColorTint; + + /* --- how much of it survives ------------------------------------ */ + float grazing = 1.0 - abs(dot(normalize(vViewDir), normalize(vNormalW))); + float fres = mix(1.0, pow(clamp(grazing, 0.0, 1.0), max(uFresnelPower, 0.01)), clamp(uFresnel, 0.0, 1.0)); + float k = clamp(uReflectivity * fres, 0.0, 1.0) * uHasReflection; + + vec3 rgb = mix(uColorBase, refl, k); + + /* --- the silhouette ---------------------------------------------- */ + float d = mix(max(abs(vNorm.x), abs(vNorm.y)), length(vNorm), clamp(uCorner, 0.0, 1.0)); + float edge = 1.0 - smoothstep(1.0 - clamp(uEdgeFade, 0.001, 1.0), 1.0, d); + float alpha = clamp(uOpacity, 0.0, 1.0) * edge; + + if (alpha < 0.002) discard; + gl_FragColor = vec4(rgb, alpha); + } +`; + +/* ---------------------------------------------------------------- */ +/* Defaults */ +/* ---------------------------------------------------------------- */ + +/** Every canonical key, its default and its unit. */ +const DEFAULTS = { + /* --- the surface --- */ + width: 4, // metres along the placement's `along` axis + height: 4, // metres along the other in-plane axis + opacity: 1, + edgeFade: 0.12, // 0..1 of the half-extent + corner: 0, // 0 rectangle, 1 ellipse + seed: 0, // unitless dice roll — the blur jitter + + /* --- the reflection --- */ + resolution: 384, // pixels, square. 256 is fine for rough, 512 for a black mirror + reflectivity: 0.85, // 0..1 + fresnel: 0.35, // 0..1 how much grazing angle shapes the reflection + fresnelPower: 2.4, + + /* --- roughness --- */ + roughness: 0.25, // 0 mirror-smooth. blackice drives this to 0 as it freezes + blurRadius: 0.035, // reflection-UV radius at roughness 1 + blurTaps: 6, // 1..12 + roughStretch: 1.2, // elongation along the normal's screen direction + + /* --- disturbance --- */ + ripple: 0, // UV amplitude + rippleScale: 1.4, // cycles per metre + rippleSpeed: 0.5, // metres per second + + /* --- scheduling --- */ + priority: 1, // relative claim on the per-frame budget + + /* --- colour --- */ + colorTint: '#ffffff', // multiplies the reflection + colorBase: '#0a1116' // what shows where the surface does not reflect +}; + +/** Every canonical key with its default. This function is the key list. */ +export function mirrorParams() { + return { ...DEFAULTS }; +} + +const num = (v, d) => (v === undefined || v === null ? d : v); + +/* ---------------------------------------------------------------- */ +/* Mirror */ +/* ---------------------------------------------------------------- */ + +/** + * ```js + * const _m = mirrorParams(); // module scope — I3 + * const _p = new Vector3(); + * + * this.sheet = new Mirror({ resolution: 384 }); + * this.group.add(this.sheet.object3D); + * // … + * onImpact() { this.sheet.visible = true; } + * onFade(dt, t) { + * const c = settings.blackice; + * this.sheet.setPlacement(this.target, UP, this.direction); + * _m.width = c.zoneRadius * 2; // metres, resolved THIS frame + * _m.height = c.zoneRadius * 2; + * _m.roughness = c.roughness * (1 - this.freeze); // sharpens as it freezes + * _m.colorBase = c.colorIce; + * this.sheet.update(_m); + * } + * onDestroy() { this.sheet.visible = false; } + * ``` + */ +export class Mirror { + /** + * @param {object} [options] + * @param {number} [options.resolution] initial square target edge, pixels. + * `params.resolution` re-sizes it live; this is only the first value. + * @param {number} [options.layer] the surface's own layer. Default + * `LAYER.VFX`. Putting a mirror on `LAYER.WORLD` also puts it in the + * depth prepass, which is handled (see `_wantsRender`) but means the + * surface writes depth for soft particles — usually what you want for + * a floor sheet and never what you want for a floating pane. + * @param {number} [options.reflectLayer] which layer the reflection renders. + * Default `LAYER.WORLD`: the real scene, no VFX. Reflecting the VFX + * layer as well means a mirror can see another mirror, which is a + * recursion this module refuses rather than resolves. + * @param {number} [options.renderOrder] + * @param {boolean} [options.doubleSided] default true — a floating pane is + * reflective from both sides and the plane's normal is flipped toward + * the camera each frame. Set false for a floor sheet you never see + * from underneath and get back a free cull. + * @param {boolean} [options.depthWrite] default false + * @param {string} [options.name] + */ + constructor({ + resolution = 384, + layer = LAYER.VFX, + reflectLayer = LAYER.WORLD, + renderOrder = 4, + doubleSided = true, + depthWrite = false, + name + } = {}) { + this.reflectLayer = reflectLayer; + this.doubleSided = doubleSided; + + this._resolution = Math.max(64, Math.min(1024, Math.round(resolution))); + this.target = new WebGLRenderTarget(this._resolution, this._resolution, { + type: HalfFloatType, // the world is linear HDR here; 8 bits would band the sky + depthBuffer: true, + minFilter: LinearFilter, + magFilter: LinearFilter + }); + this.target.texture.generateMipmaps = false; + this.target.texture.name = name ? `${name}:reflection` : 'Mirror:reflection'; + + this.material = new ShaderMaterial({ + name: name ? `${name}:mirror` : 'Mirror', + transparent: true, + depthWrite, + depthTest: true, + blending: NormalBlending, + side: DoubleSide, + uniforms: sharedUniforms({ + uAnchor: { value: new Vector3() }, + uAxisX: { value: new Vector3(1, 0, 0) }, + uAxisY: { value: new Vector3(0, 0, 1) }, + uSize: { value: new Vector2(DEFAULTS.width, DEFAULTS.height) }, + uReflectMatrix: { value: new Matrix4() }, + uReflection: { value: this.target.texture }, + uHasReflection: { value: 0 }, + + uRoughness: { value: DEFAULTS.roughness }, + uBlurRadius: { value: DEFAULTS.blurRadius }, + uBlurTaps: { value: DEFAULTS.blurTaps }, + uRoughStretch: { value: DEFAULTS.roughStretch }, + + uReflectivity: { value: DEFAULTS.reflectivity }, + uFresnel: { value: DEFAULTS.fresnel }, + uFresnelPower: { value: DEFAULTS.fresnelPower }, + + uRipple: { value: DEFAULTS.ripple }, + uRippleScale: { value: DEFAULTS.rippleScale }, + uRippleSpeed: { value: DEFAULTS.rippleSpeed }, + + uOpacity: { value: DEFAULTS.opacity }, + uEdgeFade: { value: DEFAULTS.edgeFade }, + uCorner: { value: DEFAULTS.corner }, + uSeed: { value: DEFAULTS.seed }, + + uColorTint: { value: new Color(DEFAULTS.colorTint) }, + uColorBase: { value: new Color(DEFAULTS.colorBase) } + }), + vertexShader: MIRROR_VERTEX, + fragmentShader: MIRROR_FRAGMENT + }); + + this.mesh = new Mesh(uprightQuad(), this.material); + this.mesh.name = name || 'Mirror'; + this.mesh.layers.set(layer); + this.mesh.renderOrder = renderOrder; + // The quad is built from uniforms: its bounding sphere is a lie and its + // matrix never changes. Culling is done by hand in `_wantsRender()`. + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.visible = false; + this.mesh.onBeforeRender = (renderer, scene, camera) => this._onBeforeRender(renderer, scene, camera); + + /** Priority weight against the per-frame budget. Re-read every update(). */ + this.priority = DEFAULTS.priority; + /** Frames this mirror has wanted a slot and not had one. */ + this._starve = 999; // never rendered: outranks everything on the first frame + this._selected = false; + this._wants = false; + this._visible = false; + this._disposed = false; + + /** Draw calls the last reflection cost. Measured off `renderer.info`. */ + this.lastCalls = 0; + /** Triangles ditto. */ + this.lastTriangles = 0; + } + + /** Add this to the ability's group. */ + get object3D() { + return this.mesh; + } + + /** One — the surface. The reflection pass is reported by `mirrorBudget`. */ + get drawCalls() { + return 1; + } + + /** Current square edge of the reflection target, in pixels. */ + get resolution() { + return this._resolution; + } + + /** + * Visible mirrors are registered, and only registered mirrors are scheduled. + * Retain/release exactly as `Layers.js#distortionWriters` does — the counter + * tracks visibility, not construction, because abilities are pooled and a + * mirror exists for the lifetime of the app and is visible for a second of it. + */ + get visible() { + return this._visible; + } + + set visible(v) { + const on = !!v && !this._disposed; + if (on === this._visible) return; + this._visible = on; + this.mesh.visible = on; + if (on) { + _reg.list.push(this); + mirrorBudget.live++; + // Come back hungry: a mirror that has been hidden for a second is showing + // a reflection of where the camera used to be. + this._starve = 999; + this.material.uniforms.uHasReflection.value = 0; + } else { + const i = _reg.list.indexOf(this); + if (i >= 0) _reg.list.splice(i, 1); + mirrorBudget.live = Math.max(0, mirrorBudget.live - 1); + } + } + + /** + * Put the plane somewhere. Call it every frame — nothing here is captured. + * + * @param {THREE.Vector3} anchor the centre of the surface, world metres + * @param {THREE.Vector3} normal the surface normal; world up for a floor + * @param {THREE.Vector3} along an in-plane direction hint — becomes the + * `width` axis. Re-orthogonalised against `normal`, so passing the + * cast heading for a floor sheet does the obvious thing. + */ + setPlacement(anchor, normal, along) { + const u = this.material.uniforms; + _normal.copy(normal).normalize(); + _ax.copy(along); + _ax.addScaledVector(_normal, -_ax.dot(_normal)); + if (_ax.lengthSq() < 1e-8) { + // `along` was parallel to the normal. Any in-plane axis will do. + _ax.set(1, 0, 0).addScaledVector(_normal, -_normal.x); + if (_ax.lengthSq() < 1e-8) _ax.set(0, 0, 1).addScaledVector(_normal, -_normal.z); + } + _ax.normalize(); + _ay.crossVectors(_normal, _ax).normalize(); + u.uAnchor.value.copy(anchor); + u.uAxisX.value.copy(_ax); + u.uAxisY.value.copy(_ay); + return this; + } + + /** + * Re-resolve everything. Call it every frame, including a zero-length one. + * + * Keys are exactly those of `mirrorParams()`. `resolution` is the only one + * with a side effect beyond a uniform write: changing it reallocates the + * render target, which is gated on the value actually moving, so dragging the + * slider costs one reallocation per distinct value and nothing on the frames + * between. + * + * @param {object} p a plain object. Keep it at module scope and refill it. + */ + update(p) { + const u = this.material.uniforms; + + u.uSize.value.set(num(p.width, DEFAULTS.width), num(p.height, DEFAULTS.height)); + + u.uRoughness.value = num(p.roughness, DEFAULTS.roughness); + u.uBlurRadius.value = num(p.blurRadius, DEFAULTS.blurRadius); + u.uBlurTaps.value = num(p.blurTaps, DEFAULTS.blurTaps); + u.uRoughStretch.value = num(p.roughStretch, DEFAULTS.roughStretch); + + u.uReflectivity.value = num(p.reflectivity, DEFAULTS.reflectivity); + u.uFresnel.value = num(p.fresnel, DEFAULTS.fresnel); + u.uFresnelPower.value = num(p.fresnelPower, DEFAULTS.fresnelPower); + + u.uRipple.value = num(p.ripple, DEFAULTS.ripple); + u.uRippleScale.value = num(p.rippleScale, DEFAULTS.rippleScale); + u.uRippleSpeed.value = num(p.rippleSpeed, DEFAULTS.rippleSpeed); + + u.uOpacity.value = num(p.opacity, DEFAULTS.opacity); + u.uEdgeFade.value = num(p.edgeFade, DEFAULTS.edgeFade); + u.uCorner.value = num(p.corner, DEFAULTS.corner); + u.uSeed.value = num(p.seed, DEFAULTS.seed); + + copyColor(u.uColorTint.value, p.colorTint || DEFAULTS.colorTint); + copyColor(u.uColorBase.value, p.colorBase || DEFAULTS.colorBase); + + this.priority = num(p.priority, DEFAULTS.priority); + this._setResolution(num(p.resolution, DEFAULTS.resolution)); + + return this; + } + + /** Reallocate the target only when the slider actually moves. */ + _setResolution(value) { + const next = Math.max(64, Math.min(1024, Math.round(value))); + if (next === this._resolution) return; + this._resolution = next; + this.target.setSize(next, next); + // The old texture object survives `setSize`, but its contents do not. + this.material.uniforms.uHasReflection.value = 0; + this._starve = 999; + } + + /* ---------------------------------------------------------------- */ + /* Scheduling */ + /* ---------------------------------------------------------------- */ + + /** Half the diagonal of the surface, metres — the culling sphere's radius. */ + _boundRadius() { + const s = this.material.uniforms.uSize.value; + return 0.5 * Math.sqrt(s.x * s.x + s.y * s.y); + } + + /** + * Would this mirror benefit from a reflection this frame? + * + * Three cheap rejections, in increasing cost: hidden, facing away (only when + * single-sided — a double-sided pane flips its plane toward the camera and is + * never facing away), and outside the frustum. The frustum test is done here + * rather than left to the renderer because `frustumCulled` is off on a mesh + * whose geometry is built in the vertex shader, so the free cull is not + * available and this is the honest replacement for it. + */ + _wantsRender(camera) { + if (!this._visible || this._disposed) return false; + + const u = this.material.uniforms; + _anchor.copy(u.uAnchor.value); + _camPos.setFromMatrixPosition(camera.matrixWorld); + + if (!this.doubleSided) { + _normal.crossVectors(u.uAxisX.value, u.uAxisY.value).normalize(); + if (_tmp.subVectors(_camPos, _anchor).dot(_normal) <= 0) return false; + } + + _proj.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); + _frustum.setFromProjectionMatrix(_proj); + _sphere.center.copy(_anchor); + _sphere.radius = this._boundRadius(); + return _frustum.intersectsSphere(_sphere); + } + + /** + * Priority × apparent size × starvation. + * + * Apparent size is the bounding radius over the distance to the eye, which is + * the tangent of the half-angle the mirror subtends — near and large beats + * far and small, which is the order the eye notices a stale reflection in. + */ + _score(camera) { + _camPos.setFromMatrixPosition(camera.matrixWorld); + const distance = Math.max(0.25, _camPos.distanceTo(this.material.uniforms.uAnchor.value)); + const span = this._boundRadius() / distance; + return Math.max(this.priority, 0) * span * (1 + this._starve); + } + + /* ---------------------------------------------------------------- */ + /* The reflection pass */ + /* ---------------------------------------------------------------- */ + + _onBeforeRender(renderer, scene, camera) { + // The depth prepass draws `LAYER.WORLD` with `scene.overrideMaterial` set. + // A mirror on that layer would otherwise render its whole reflection from + // inside the prepass, into a target the prepass then throws away. + if (scene.overrideMaterial) return; + // A mirror inside another mirror's reflection: refused, not resolved. + if (_reg.rendering) return; + if (!camera.isPerspectiveCamera) return; + + beginFrame(renderer, camera); + if (!this._selected) return; + this._selected = false; + + this._renderReflection(renderer, scene, camera); + } + + _renderReflection(renderer, scene, camera) { + const u = this.material.uniforms; + + _anchor.copy(u.uAnchor.value); + _normal.crossVectors(u.uAxisX.value, u.uAxisY.value).normalize(); + _camPos.setFromMatrixPosition(camera.matrixWorld); + + // Face the plane at the eye. `Reflector` skips the mirror when it is facing + // away; a pane hanging in mid-air that you can walk around wants both sides + // to work, and flipping the plane is all that takes. + if (_tmp.subVectors(_camPos, _anchor).dot(_normal) < 0) { + if (!this.doubleSided) return; + _normal.negate(); + } + + /* ---- the mirrored camera ---- */ + // Eye, reflected about the plane. + _eye.subVectors(_anchor, _camPos).reflect(_normal).negate().add(_anchor); + + // The point the real camera is looking at, reflected the same way. + _rot.extractRotation(camera.matrixWorld); + _look.set(0, 0, -1).applyMatrix4(_rot).add(_camPos); + _target.subVectors(_anchor, _look).reflect(_normal).negate().add(_anchor); + + _up.set(0, 1, 0).applyMatrix4(_rot).reflect(_normal); + + _refCam.position.copy(_eye); + _refCam.up.copy(_up); + _refCam.lookAt(_target); + _refCam.near = camera.near; + _refCam.far = camera.far; // WebGLBackground reads it + _refCam.updateMatrixWorld(true); + _refCam.matrixWorldInverse.copy(_refCam.matrixWorld).invert(); + _refCam.projectionMatrix.copy(camera.projectionMatrix); + _refCam.layers.set(this.reflectLayer); + + /* ---- the oblique near plane (Lengyel) ---- */ + // Skew the near plane onto the mirror so everything behind the surface is + // clipped in hardware. Without this a pane shows the wall it hangs in front + // of, welded to its own face. + _plane.setFromNormalAndCoplanarPoint(_normal, _anchor); + _plane.applyMatrix4(_refCam.matrixWorldInverse); + _clip.set(_plane.normal.x, _plane.normal.y, _plane.normal.z, _plane.constant); + + const pm = _refCam.projectionMatrix; + _q.x = (Math.sign(_clip.x) + pm.elements[8]) / pm.elements[0]; + _q.y = (Math.sign(_clip.y) + pm.elements[9]) / pm.elements[5]; + _q.z = -1; + _q.w = (1 + pm.elements[10]) / pm.elements[14]; + _clip.multiplyScalar(2 / _clip.dot(_q)); + pm.elements[2] = _clip.x; + pm.elements[6] = _clip.y; + // CLIP_BIAS pushes the plane a hair behind the surface: exactly on it, the + // mirror's own edge pixels sample a hairline of clipped background. + pm.elements[10] = _clip.z + 1 - 0.004; + pm.elements[14] = _clip.w; + + /* ---- the projective lookup matrix ---- */ + u.uReflectMatrix.value + .copy(_bias) + .multiply(_refCam.projectionMatrix) + .multiply(_refCam.matrixWorldInverse); + + /* ---- render ---- */ + const prevTarget = renderer.getRenderTarget(); + const prevXr = renderer.xr.enabled; + const prevShadowAuto = renderer.shadowMap.autoUpdate; + const wasVisible = this.mesh.visible; + + this.mesh.visible = false; + renderer.xr.enabled = false; // the reflection camera is not the XR camera + renderer.shadowMap.autoUpdate = false; // reuse the maps the main pass built + + _reg.rendering = true; + renderer.setRenderTarget(this.target); + // #18897: the composer leaves the depth mask wherever the last pass put it, + // and a target that cannot clear its depth reflects last frame's geometry. + renderer.state.buffers.depth.setMask(true); + if (renderer.autoClear === false) renderer.clear(); + renderer.render(scene, _refCam); + _reg.rendering = false; + + // `info` was reset at the top of that render call, so these are exactly the + // reflection's own numbers — and the outer frame's are now gone, which the + // module comment warns about. + this.lastCalls = renderer.info.render.calls; + this.lastTriangles = renderer.info.render.triangles; + mirrorBudget.calls += this.lastCalls; + mirrorBudget.triangles += this.lastTriangles; + mirrorBudget.rendered++; + _reg.own++; + + renderer.setRenderTarget(prevTarget); + renderer.xr.enabled = prevXr; + renderer.shadowMap.autoUpdate = prevShadowAuto; + this.mesh.visible = wasVisible; + + // Restoring the target resets the viewport to the target's full size; a + // camera with an explicit viewport has to have it put back. + if (camera.viewport !== undefined) renderer.state.viewport(camera.viewport); + + u.uHasReflection.value = 1; + this._starve = 0; + } + + dispose() { + this.visible = false; + this._disposed = true; + this.mesh.onBeforeRender = () => {}; + this.target.dispose(); + this.material.dispose(); + // The quad is a shared module-scope singleton — four vertices, never freed. + this.mesh.parent?.remove(this.mesh); + } +} diff --git a/src/vfx/Portal.js b/src/vfx/Portal.js new file mode 100644 index 0000000..c15c0b9 --- /dev/null +++ b/src/vfx/Portal.js @@ -0,0 +1,682 @@ +import { + Mesh, + ShaderMaterial, + CustomBlending, + AddEquation, + OneFactor, + OneMinusSrcAlphaFactor, + DoubleSide, + Color, + Vector2, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { uprightQuad } from './quads.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { copyColor } from '../utils/color.js'; + +/* ------------------------------------------------------------------------ */ +/* Portal — a hole, not a decal */ +/* ------------------------------------------------------------------------ */ +/** + * A disc or a slit with something behind it. + * + * Everything in this sandbox is additive light laid over the stage. A portal has + * to do the opposite: it has to *remove* the stage and put a different world + * where it was. Three things make that read, and only one of them is obvious. + * + * **1 — It occludes.** The interior is drawn with premultiplied-alpha custom + * blending (`ONE, ONE_MINUS_SRC_ALPHA`), which is the whole reason this is one + * draw call instead of two. Premultiplied output lets a single fragment be both + * *opaque black* (rgb ≈ 0, a = 1 — the void genuinely covers the floor) and + * *pure additive glow* (rgb = hot, a = 0 — the fracture rim adds over whatever + * is behind it) depending only on what it writes. A normal-blended pass cannot + * express the second and an additive pass cannot express the first, so the + * naive build is two meshes fighting over the same SDF. + * + * **2 — The interior parallaxes, at the wrong rate.** For each of three star + * shells the view ray is continued *through* the portal plane to a depth in + * metres behind it, and the starfield is sampled where it lands. That alone is + * geometrically correct parallax, and geometrically correct parallax looks like + * a window. The `parallax` slider then scales the lateral part of that shift + * away from 1 — the interior slides against the camera *faster than the geometry + * says it should*, and that mismatch is the entire illusion. Set it to 1 and the + * portal collapses back into a hole in a wall; set it to 1.6 and it becomes a + * hole in space. It is the single most important number in this file. + * + * **3 — It tears.** `open` does not scale the aperture. The aperture is always + * full size; what `open` sweeps is a **threshold on a field**, and that field is + * the normalised distance to the seam multiplied by a world-space noise. So + * different bearings open at different rates, the boundary is ragged, and it is + * ragged in the *same places* on the way closed. A portal that scales is a + * sprite growing; a portal that tears is something being forced. + * + * ## Depth at the edge + * + * `depthTest` is on and `depthWrite` is off, which is the correct pair for a + * transparent that must be hidden by nearer opaque geometry — walk the character + * in front of a rift and the rift is behind them. What it will *not* do is hide + * transparents drawn after it, because they are not depth-tested against + * something that never wrote depth. If an ability needs the void to swallow its + * own particles, construct with `writeDepth: true` and accept that the aperture + * then punches a hole in the transparent queue with a hard alpha-tested edge. + * + * ## Cost + * + * One draw call. One material. No textures — the starfield is a hashed lattice + * and the nebula is fbm, both evaluated in the shell's own tangent plane. + * + * ## Invariants + * + * - **I1** — holds no dimensions. `update()` is called every frame with values + * the ability resolved from `settings` that frame, zero-length frames + * included. Pause mid-tear and drag `open`, `radiusX`, `tearJag`. + * - **I3** — `update()` writes into existing uniform boxes and into cached + * `Color`s via `copyColor`. Nothing allocates. The quad is a module-scope + * singleton. + * - **I5** — ten colour pickers, and not one of them is derived from another. + */ + +/* ---------------------------------------------------------------- */ +/* Shaders */ +/* ---------------------------------------------------------------- */ + +const PORTAL_VERTEX = /* glsl */ ` + uniform vec3 uAnchor; // world centre of the aperture + uniform vec2 uSize; // quad extent, metres — aperture plus the crack margin + uniform vec3 uAxisX; // the plane's local +X, unit + uniform vec3 uAxisY; // the plane's local +Y, unit + + varying vec2 vUv; + varying vec2 vLocal; // metres from the centre, in the portal plane + varying vec3 vWorld; + varying vec3 vT; + varying vec3 vB; + varying vec3 vN; + + void main() { + vUv = uv; + + vec3 ax; + vec3 ay; + #ifdef PORTAL_BILLBOARD + ax = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]); + ay = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]); + #else + ax = uAxisX; + ay = uAxisY; + #endif + + vec2 ext = (uv - 0.5) * uSize; + vec3 world = uAnchor + ax * ext.x + ay * ext.y; + + vLocal = ext; + vWorld = world; + vT = ax; + vB = ay; + vN = normalize(cross(ax, ay)); + + gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0); + } +`; + +const PORTAL_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + + uniform vec3 uAnchor; + uniform vec2 uRadii; // aperture half-extents, metres. Equal = a disc. + uniform float uOpen; // 0..1 tear progress + uniform float uSeam; // 0 opens from the centre, 1 from the long centreline + uniform float uTearJag; // 0..1 how uneven the front is + uniform float uTearScale; // cycles per metre of the crack grain + uniform float uTearCrawl; // Hz — the grain creeping while the tear is held + uniform float uEdgeSoft; // 0..1 of the field — how hard the aperture cuts + uniform float uSeed; + + uniform float uRim; // 0..1 of the field — the fracture band's width + uniform float uRimGlow; + uniform float uCore; // 0..1 — the white-hot line inside the band + uniform float uCoreGlow; + uniform float uThroat; // 0..1 — the soft inner glow past the rim + uniform float uThroatGlow; + + uniform float uCrackCount; // radial fractures around the rim + uniform float uCrackWidth; // 0..1 of one angular cell + uniform float uCrackLength; // 0..1 of the field they reach out to + uniform float uCrackGlow; + + uniform float uParallax; // 1 = geometrically honest. Do not ship 1. + uniform float uSwirl; // rad/s — the whole interior turning + uniform float uInteriorFade; // 0..1 — how much the void darkens toward the rim + + uniform float uStarSize; + uniform float uStarTwinkle; + uniform float uStarGain; + uniform vec3 uStarScale; // stars per metre, per shell + uniform vec3 uStarDepth; // metres behind the plane, per shell + uniform vec3 uStarDrift; // rad/s, per shell + + uniform float uNebulaScale; // cycles per metre + uniform float uNebulaSpeed; + uniform float uNebulaGain; + uniform float uNebulaDepth; // metres behind the plane + + uniform vec3 uColorVoid; + uniform vec3 uColorRim; + uniform vec3 uColorCore; + uniform vec3 uColorCrack; + uniform vec3 uColorThroat; + uniform vec3 uColorStarA; + uniform vec3 uColorStarB; + uniform vec3 uColorStarC; + uniform vec3 uColorNebulaA; + uniform vec3 uColorNebulaB; + + uniform float uOpacity; + + varying vec2 vUv; + varying vec2 vLocal; + varying vec3 vWorld; + varying vec3 vT; + varying vec3 vB; + varying vec3 vN; + + ${noiseGLSL} + ${commonGLSL} + + #define TAU 6.28318530718 + + vec2 rot2(vec2 p, float a) { + float c = cos(a); + float s = sin(a); + return vec2(p.x * c - p.y * s, p.x * s + p.y * c); + } + + /** + * Where the view ray lands on a plane depth metres behind the portal. + * + * base is where we already are on the portal plane; the difference between + * the two is the honest parallax shift, and uParallax scales *that* rather + * than the depth — scaling the depth would also change how big the stars are, + * which is a different lie and a worse one. + */ + vec2 shellCoord(vec3 V, vec3 N, vec2 base, float depth) { + float vn = max(abs(dot(V, N)), 0.18); // clamp: a grazing view shoots to infinity + vec3 hit = vWorld + V * (depth / vn); + vec3 rel = hit - uAnchor; + vec2 there = vec2(dot(rel, vT), dot(rel, vB)); + return base + (there - base) * uParallax; + } + + /** + * A hashed lattice of points. Not a texture, not a sprite — one cell per star, + * jittered inside its cell, with exp(-d/size) for the falloff because a + * gaussian core with an exponential skirt is what a small bright thing does to + * a lens and a smoothstep disc is what a UI element does. + */ + float starLayer(vec2 s, float scale, float seed) { + vec2 g = s * scale; + vec2 id = floor(g); + vec2 fr = fract(g) - 0.5; + float h = hash13(vec3(id, seed)); + vec2 off = (hash21(h * 91.7 + seed) - 0.5) * 0.72; + float d = length(fr - off); + float lit = smoothstep(0.52, 0.98, h); + float tw = 1.0 + uStarTwinkle * sin(uTime * (1.7 + h * 6.1) + h * 37.0); + return lit * max(tw, 0.0) * exp(-d / max(uStarSize, 1e-3)); + } + + void main() { + /* ---------------- the aperture ---------------- */ + vec2 p = vLocal; + vec2 q = p / max(uRadii, vec2(1e-3)); + float rn = length(q); + + // Which bearings open first. A disc unzips from the middle; a slit unzips + // from its long centreline, which is what makes it read as a wound rather + // than as an iris. + float seamField = mix(rn, abs(q.y), clamp(uSeam, 0.0, 1.0)); + + // Sampled in **metres**, so the crack grain is a fixed physical size — a + // two-metre rift and a six-metre rift tear with the same size of shard. + float jag = fbm3(vec3(p * uTearScale, uSeed + uTime * uTearCrawl)) * 0.5 + 0.5; + float factor = mix(1.0, 0.72 + 0.56 * jag, clamp(uTearJag, 0.0, 1.0)); + float f = seamField * factor; + + // Signed distance to whichever boundary is nearer: the travelling tear front + // or the aperture's own ellipse. Positive inside the hole. + float edge = min(uOpen - f, 1.0 - rn); + float born = smoothstep(0.0, 0.02, uOpen); + + float aperture = smoothstep(0.0, max(uEdgeSoft, 1e-3), edge) * born; + + /* ---------------- the interior ---------------- */ + vec3 V = normalize(vWorld - cameraPosition); + vec3 N = gl_FrontFacing ? vN : -vN; + vec3 rel0 = vWorld - uAnchor; + vec2 base = vec2(dot(rel0, vT), dot(rel0, vB)); + + float spin = uTime * uSwirl; + + vec2 sA = rot2(shellCoord(V, N, base, uStarDepth.x), spin + uTime * uStarDrift.x); + vec2 sB = rot2(shellCoord(V, N, base, uStarDepth.y), spin + uTime * uStarDrift.y); + vec2 sC = rot2(shellCoord(V, N, base, uStarDepth.z), spin + uTime * uStarDrift.z); + + vec3 stars = + uColorStarA * starLayer(sA, uStarScale.x, uSeed + 1.0) + + uColorStarB * starLayer(sB, uStarScale.y, uSeed + 2.0) + + uColorStarC * starLayer(sC, uStarScale.z, uSeed + 3.0); + stars *= uStarGain; + + vec2 sN = rot2(shellCoord(V, N, base, uNebulaDepth), spin * 0.4); + float neb = fbm4(vec3(sN * uNebulaScale, uTime * uNebulaSpeed + uSeed)) * 0.5 + 0.5; + neb = pow(clamp(neb, 0.0, 1.0), 2.0) * uNebulaGain; + vec3 nebula = mix(uColorNebulaA, uColorNebulaB, clamp(neb, 0.0, 1.0)) * neb; + + vec3 interior = uColorVoid + stars + nebula; + // Fall off toward the rim: the hole is deepest in the middle, and without + // this the starfield runs flat into the fracture and the whole thing reads + // as a painted disc again. + interior *= mix(1.0, smoothstep(0.0, 0.5, edge), clamp(uInteriorFade, 0.0, 1.0)); + + /* ---------------- the fracture ---------------- */ + float rim = exp(-abs(edge) / max(uRim, 1e-4)); + float core = exp(-abs(edge) / max(uCore, 1e-4)); + float throat = exp(-max(edge, 0.0) / max(uThroat, 1e-4)) * aperture; + + // Radial fractures licking out past the boundary. One per angular cell, at a + // hashed bearing inside it, with a hashed reach — so the crown of cracks is + // irregular without a single loop. + // + // Two details that are not optional. The angular distance wraps, because a + // crack whose bearing lands near a cell boundary is otherwise sliced in half + // and the crown develops a seam you cannot unsee. And the whole term is + // gated to *strictly outside* the boundary: the first build multiplied by + // step(0.0, outside), which is 1 at outside == 0 and therefore 1 across the + // entire interior, so every crack ran unbroken to the centre of the hole. + // Dead-straight spokes out of a middle — a firework, not a fracture. + float cells = max(uCrackCount, 1.0); + float ang = atan(q.y, q.x) / TAU + 0.5; + float cell = floor(ang * cells); + float within = fract(ang * cells); + float bearing = hash11(cell + uSeed * 13.0); + float reach = uCrackLength * (0.35 + 0.9 * hash11(cell + uSeed * 13.0 + 7.7)); + float sweep = abs(within - bearing); + sweep = min(sweep, 1.0 - sweep); + float spike = 1.0 - smoothstep(0.0, max(uCrackWidth, 1e-3), sweep); + float outside = max(-edge, 0.0); + float crack = + spike * exp(-outside / max(reach, 1e-3)) * smoothstep(0.0, max(uRim, 1e-4), outside) * born; + + vec3 emissive = + uColorRim * rim * uRimGlow + + uColorCore * core * uCoreGlow + + uColorThroat * throat * uThroatGlow + + uColorCrack * crack * uCrackGlow; + emissive *= born * uGlobalGlow * uShaderIntensity; + + /* ---------------- premultiplied output ---------------- */ + float a = clamp(aperture * uOpacity, 0.0, 1.0); + vec3 rgb = interior * a + emissive; + + if (a < 0.001 && max(rgb.r, max(rgb.g, rgb.b)) < 0.002) discard; + + #ifdef PORTAL_WRITE_DEPTH + // Only the solid part of the hole is allowed to own depth; the additive + // crown must not, or every crack punches a rectangle out of the queue. + if (a < 0.5) discard; + #endif + + gl_FragColor = vec4(rgb, a); + } +`; + +/* ---------------------------------------------------------------- */ +/* Defaults */ +/* ---------------------------------------------------------------- */ + +/** + * A portal that is visible on the first frame, not an art direction. Every one + * of these is meant to become a slider in the ability's own settings block — + * anything left to fall back to here is a value the editor cannot reach, which + * is an **I1** violation waiting to be filed as a bug. + */ +const DEFAULTS = { + radiusX: 1.6, + radiusY: 1.6, + margin: 0.45, + open: 1, + seam: 0, + tearJag: 0.65, + tearScale: 1.8, + tearCrawl: 0.12, + edgeSoft: 0.045, + seed: 0, + opacity: 1, + + rim: 0.035, + rimGlow: 2.4, + core: 0.01, + coreGlow: 5, + throat: 0.16, + throatGlow: 0.6, + + crackCount: 14, + crackWidth: 0.09, + crackLength: 0.22, + crackGlow: 1.8, + + parallax: 1.6, + swirl: 0.05, + interiorFade: 0.35, + + starSize: 0.045, + starTwinkle: 0.35, + starGain: 1, + starScaleA: 2.6, + starScaleB: 5.2, + starScaleC: 9.5, + starDepthA: 2.5, + starDepthB: 7, + starDepthC: 18, + starDriftA: 0.03, + starDriftB: 0.017, + starDriftC: 0.008, + + nebulaScale: 0.22, + nebulaSpeed: 0.05, + nebulaGain: 0.55, + nebulaDepth: 11, + + colorVoid: '#000000', + colorRim: '#b07aff', + colorCore: '#ffffff', + colorCrack: '#8a5fd0', + colorThroat: '#3a1a6a', + colorStarA: '#ffffff', + colorStarB: '#c0d0ff', + colorStarC: '#8a7aff', + colorNebulaA: '#12061f', + colorNebulaB: '#5a2ea0' +}; + +const num = (v, d) => (v === undefined || v === null ? d : v); + +/* ---------------------------------------------------------------- */ +/* Scratch — module scope (I3) */ +/* ---------------------------------------------------------------- */ + +const _ax = new Vector3(); +const _ay = new Vector3(); +const _n = new Vector3(); + +/* ---------------------------------------------------------------- */ +/* Portal */ +/* ---------------------------------------------------------------- */ + +/** + * ```js + * const _p = new Vector3(); + * const _o = {}; // module scope — I3 + * + * this.rift = new Portal(); + * this.group.add(this.rift.object3D); + * // … + * onTravel(dt) { + * const c = settings.voidrift; + * this.rift.setPlacement(this.pointAt(0.5, _p), this.direction, UP); + * _o.radiusX = this.length * 0.5 * c.riftSpan; // metres, resolved this frame + * _o.radiusY = c.riftHeight; + * _o.open = this.u; + * _o.seam = c.riftSeam; + * this.rift.update(_o); + * } + * ``` + * + * Pair it with a `DistortionField` in `LENS` mode anchored at the same point to + * get the ring of bent floor around the edge — the portal itself deliberately + * writes no offsets, because an ability that wants a hole does not always want + * the frame warped around it. + */ +export class Portal { + /** + * @param {object} options + * @param {boolean} [options.billboard] face the camera instead of using `setPlacement` + * @param {boolean} [options.writeDepth] punch the aperture into the depth buffer + * @param {number} [options.renderOrder] + * @param {string} [options.name] + */ + constructor({ billboard = false, writeDepth = false, renderOrder = 6, name = 'Portal' } = {}) { + this.material = new ShaderMaterial({ + name: `${name}:surface`, + transparent: true, + depthWrite: writeDepth, + depthTest: true, + side: DoubleSide, + toneMapped: false, + // Premultiplied alpha. See the class header — this is what lets one pass + // be an occluder and an emitter at the same time. + blending: CustomBlending, + blendEquation: AddEquation, + blendSrc: OneFactor, + blendDst: OneMinusSrcAlphaFactor, + blendEquationAlpha: AddEquation, + blendSrcAlpha: OneFactor, + blendDstAlpha: OneMinusSrcAlphaFactor, + defines: { + ...(billboard ? { PORTAL_BILLBOARD: '' } : {}), + ...(writeDepth ? { PORTAL_WRITE_DEPTH: '' } : {}) + }, + uniforms: sharedUniforms({ + uAnchor: { value: new Vector3() }, + uSize: { value: new Vector2(1, 1) }, + uAxisX: { value: new Vector3(1, 0, 0) }, + uAxisY: { value: new Vector3(0, 1, 0) }, + + uRadii: { value: new Vector2(DEFAULTS.radiusX, DEFAULTS.radiusY) }, + uOpen: { value: DEFAULTS.open }, + uSeam: { value: DEFAULTS.seam }, + uTearJag: { value: DEFAULTS.tearJag }, + uTearScale: { value: DEFAULTS.tearScale }, + uTearCrawl: { value: DEFAULTS.tearCrawl }, + uEdgeSoft: { value: DEFAULTS.edgeSoft }, + uSeed: { value: DEFAULTS.seed }, + uOpacity: { value: DEFAULTS.opacity }, + + uRim: { value: DEFAULTS.rim }, + uRimGlow: { value: DEFAULTS.rimGlow }, + uCore: { value: DEFAULTS.core }, + uCoreGlow: { value: DEFAULTS.coreGlow }, + uThroat: { value: DEFAULTS.throat }, + uThroatGlow: { value: DEFAULTS.throatGlow }, + + uCrackCount: { value: DEFAULTS.crackCount }, + uCrackWidth: { value: DEFAULTS.crackWidth }, + uCrackLength: { value: DEFAULTS.crackLength }, + uCrackGlow: { value: DEFAULTS.crackGlow }, + + uParallax: { value: DEFAULTS.parallax }, + uSwirl: { value: DEFAULTS.swirl }, + uInteriorFade: { value: DEFAULTS.interiorFade }, + + uStarSize: { value: DEFAULTS.starSize }, + uStarTwinkle: { value: DEFAULTS.starTwinkle }, + uStarGain: { value: DEFAULTS.starGain }, + uStarScale: { value: new Vector3(DEFAULTS.starScaleA, DEFAULTS.starScaleB, DEFAULTS.starScaleC) }, + uStarDepth: { value: new Vector3(DEFAULTS.starDepthA, DEFAULTS.starDepthB, DEFAULTS.starDepthC) }, + uStarDrift: { value: new Vector3(DEFAULTS.starDriftA, DEFAULTS.starDriftB, DEFAULTS.starDriftC) }, + + uNebulaScale: { value: DEFAULTS.nebulaScale }, + uNebulaSpeed: { value: DEFAULTS.nebulaSpeed }, + uNebulaGain: { value: DEFAULTS.nebulaGain }, + uNebulaDepth: { value: DEFAULTS.nebulaDepth }, + + uColorVoid: { value: new Color(DEFAULTS.colorVoid) }, + uColorRim: { value: new Color(DEFAULTS.colorRim) }, + uColorCore: { value: new Color(DEFAULTS.colorCore) }, + uColorCrack: { value: new Color(DEFAULTS.colorCrack) }, + uColorThroat: { value: new Color(DEFAULTS.colorThroat) }, + uColorStarA: { value: new Color(DEFAULTS.colorStarA) }, + uColorStarB: { value: new Color(DEFAULTS.colorStarB) }, + uColorStarC: { value: new Color(DEFAULTS.colorStarC) }, + uColorNebulaA: { value: new Color(DEFAULTS.colorNebulaA) }, + uColorNebulaB: { value: new Color(DEFAULTS.colorNebulaB) } + }), + vertexShader: PORTAL_VERTEX, + fragmentShader: PORTAL_FRAGMENT + }); + + this.mesh = new Mesh(uprightQuad(), this.material); + this.mesh.name = name; + this.mesh.layers.set(LAYER.VFX); + this.mesh.renderOrder = renderOrder; + // The quad is built from uniforms; its bounding sphere means nothing. + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.visible = false; + } + + /** Add this to the ability's group. */ + get object3D() { + return this.mesh; + } + + get visible() { + return this.mesh.visible; + } + + set visible(v) { + this.mesh.visible = !!v; + } + + /** + * Place the aperture without touching a matrix. + * + * @param {THREE.Vector3} anchor world centre, metres + * @param {THREE.Vector3} along the aperture's local +X — for a slit, its length + * @param {THREE.Vector3} up the aperture's local +Y, re-orthogonalised + * + * The plane's normal comes out as `along × up`, so a rift opened across the + * cast line faces the caster when you pass the cast direction and world up. + */ + setPlacement(anchor, along, up) { + const u = this.material.uniforms; + u.uAnchor.value.copy(anchor); + _ax.copy(along).normalize(); + _n.crossVectors(_ax, up).normalize(); + _ay.crossVectors(_n, _ax).normalize(); + u.uAxisX.value.copy(_ax); + u.uAxisY.value.copy(_ay); + return this; + } + + /** + * Re-resolve every dimension. Every frame, zero-length ones included. + * + * *shape* — `radiusX` `radiusY` (metres, half-extents; equal makes a disc, + * unequal a slit), `margin` (0..1 extra quad around the aperture so the cracks + * have somewhere to go), `seed`, `opacity`. + * + * *the tear* — `open` (0..1; **not** a scale), `seam` (0 unzips from the + * centre, 1 from the long centreline), `tearJag` (0..1 how uneven the front + * is), `tearScale` (cycles per metre of crack grain), `tearCrawl` (Hz), + * `edgeSoft` (0..1 of the field). + * + * *the fracture* — `rim` / `rimGlow` / `colorRim`, `core` / `coreGlow` / + * `colorCore` (the white-hot line), `throat` / `throatGlow` / `colorThroat` + * (the soft inner glow), `crackCount` / `crackWidth` / `crackLength` / + * `crackGlow` / `colorCrack`. + * + * *the interior* — `parallax` (1 = honest; ship something else), + * `swirl` (rad/s), `interiorFade`, `colorVoid`; three star shells as + * `starScaleA/B/C` (stars per metre), `starDepthA/B/C` (metres behind the + * plane), `starDriftA/B/C` (rad/s), `colorStarA/B/C`, plus the shared + * `starSize`, `starTwinkle`, `starGain`; and the nebula as `nebulaScale`, + * `nebulaSpeed`, `nebulaGain`, `nebulaDepth`, `colorNebulaA`, `colorNebulaB`. + * + * @param {object} p a plain object. Keep it at module scope and refill it. + */ + update(p) { + const u = this.material.uniforms; + + const rx = num(p.radiusX, DEFAULTS.radiusX); + const ry = num(p.radiusY, DEFAULTS.radiusY); + const margin = num(p.margin, DEFAULTS.margin); + u.uRadii.value.set(rx, ry); + // The quad has to hold the aperture *and* the crown of cracks; sizing it off + // the same two numbers is what keeps a resized portal from clipping. + u.uSize.value.set(rx * 2 * (1 + margin), ry * 2 * (1 + margin)); + + u.uOpen.value = num(p.open, DEFAULTS.open); + u.uSeam.value = num(p.seam, DEFAULTS.seam); + u.uTearJag.value = num(p.tearJag, DEFAULTS.tearJag); + u.uTearScale.value = num(p.tearScale, DEFAULTS.tearScale); + u.uTearCrawl.value = num(p.tearCrawl, DEFAULTS.tearCrawl); + u.uEdgeSoft.value = num(p.edgeSoft, DEFAULTS.edgeSoft); + u.uSeed.value = num(p.seed, DEFAULTS.seed); + u.uOpacity.value = num(p.opacity, DEFAULTS.opacity); + + u.uRim.value = num(p.rim, DEFAULTS.rim); + u.uRimGlow.value = num(p.rimGlow, DEFAULTS.rimGlow); + u.uCore.value = num(p.core, DEFAULTS.core); + u.uCoreGlow.value = num(p.coreGlow, DEFAULTS.coreGlow); + u.uThroat.value = num(p.throat, DEFAULTS.throat); + u.uThroatGlow.value = num(p.throatGlow, DEFAULTS.throatGlow); + + u.uCrackCount.value = num(p.crackCount, DEFAULTS.crackCount); + u.uCrackWidth.value = num(p.crackWidth, DEFAULTS.crackWidth); + u.uCrackLength.value = num(p.crackLength, DEFAULTS.crackLength); + u.uCrackGlow.value = num(p.crackGlow, DEFAULTS.crackGlow); + + u.uParallax.value = num(p.parallax, DEFAULTS.parallax); + u.uSwirl.value = num(p.swirl, DEFAULTS.swirl); + u.uInteriorFade.value = num(p.interiorFade, DEFAULTS.interiorFade); + + u.uStarSize.value = num(p.starSize, DEFAULTS.starSize); + u.uStarTwinkle.value = num(p.starTwinkle, DEFAULTS.starTwinkle); + u.uStarGain.value = num(p.starGain, DEFAULTS.starGain); + u.uStarScale.value.set( + num(p.starScaleA, DEFAULTS.starScaleA), + num(p.starScaleB, DEFAULTS.starScaleB), + num(p.starScaleC, DEFAULTS.starScaleC) + ); + u.uStarDepth.value.set( + num(p.starDepthA, DEFAULTS.starDepthA), + num(p.starDepthB, DEFAULTS.starDepthB), + num(p.starDepthC, DEFAULTS.starDepthC) + ); + u.uStarDrift.value.set( + num(p.starDriftA, DEFAULTS.starDriftA), + num(p.starDriftB, DEFAULTS.starDriftB), + num(p.starDriftC, DEFAULTS.starDriftC) + ); + + u.uNebulaScale.value = num(p.nebulaScale, DEFAULTS.nebulaScale); + u.uNebulaSpeed.value = num(p.nebulaSpeed, DEFAULTS.nebulaSpeed); + u.uNebulaGain.value = num(p.nebulaGain, DEFAULTS.nebulaGain); + u.uNebulaDepth.value = num(p.nebulaDepth, DEFAULTS.nebulaDepth); + + copyColor(u.uColorVoid.value, p.colorVoid || DEFAULTS.colorVoid); + copyColor(u.uColorRim.value, p.colorRim || DEFAULTS.colorRim); + copyColor(u.uColorCore.value, p.colorCore || DEFAULTS.colorCore); + copyColor(u.uColorCrack.value, p.colorCrack || DEFAULTS.colorCrack); + copyColor(u.uColorThroat.value, p.colorThroat || DEFAULTS.colorThroat); + copyColor(u.uColorStarA.value, p.colorStarA || DEFAULTS.colorStarA); + copyColor(u.uColorStarB.value, p.colorStarB || DEFAULTS.colorStarB); + copyColor(u.uColorStarC.value, p.colorStarC || DEFAULTS.colorStarC); + copyColor(u.uColorNebulaA.value, p.colorNebulaA || DEFAULTS.colorNebulaA); + copyColor(u.uColorNebulaB.value, p.colorNebulaB || DEFAULTS.colorNebulaB); + + return this; + } + + dispose() { + this.mesh.visible = false; + this.material.dispose(); + this.mesh.parent?.remove(this.mesh); + } +} diff --git a/src/vfx/Projectile.js b/src/vfx/Projectile.js new file mode 100644 index 0000000..bc132b4 --- /dev/null +++ b/src/vfx/Projectile.js @@ -0,0 +1,1187 @@ +import { + AdditiveBlending, + BufferAttribute, + Color, + DoubleSide, + DynamicDrawUsage, + Group, + InstancedBufferAttribute, + InstancedBufferGeometry, + InstancedMesh, + Mesh, + NormalBlending, + Object3D, + Quaternion, + ShaderMaterial, + Sphere, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { saturate, lerp } from '../utils/math.js'; +import { getColor } from '../utils/color.js'; + +const TAU = Math.PI * 2; +const EPSILON = 0.004; // τ step used for the finite-difference heading + +/** + * How a body gets from the hand to the ground. + * + * These are not six different integrators. They are six different ways of + * choosing **two endpoints**; the curve between them is one formula with + * different coefficients — see `_pathPoint`. That collapse is the whole reason + * the trail can be drawn entirely in a vertex shader without the GPU needing a + * copy of the mode switch. + */ +export const FlightMode = Object.freeze({ + /** Hand → target, flat, with an optional lift. Bolts, needles, darts. */ + LINE: 'line', + /** Ballistic lob. Launch, landing and apex are all live metres. */ + ARC: 'arc', + /** On the floor, rotation derived from distance / radius so it never skates. */ + ROLL: 'roll', + /** From one shared vanishing point above and behind the caster. */ + FALL: 'fall', + /** Hand → target with a weave whose amplitude decays to zero on arrival. */ + HOMING: 'homing', + /** As HOMING, but the lateral and vertical weaves run at different rates. */ + LISSAJOUS: 'lissajous', + /** A fanned line cast whose bodies leave in an index-ordered ripple. */ + VOLLEY: 'volley' +}); + +/** + * Where a body's launch delay comes from. + * + * `AUTO` picks the one the flight mode wants: a ripple for `VOLLEY`, the + * spatial hash for `FALL`, nothing for the rest. + */ +export const Stagger = Object.freeze({ + AUTO: 'auto', + NONE: 'none', + /** Index order — body 0 first, body n-1 last. A ripple down the volley. */ + RIPPLE: 'ripple', + /** Hash of the landing point on a lattice, blended with a radial ordering. */ + HASH: 'hash' +}); + +/* ---------------------------------------------------------------------- */ +/* The spatial hash */ +/* ---------------------------------------------------------------------- */ + +/** + * Deterministic 0..1 ordering key for a point on the floor. + * + * The problem this solves: a zone fill wants forty arrivals spread over a + * second, and the obvious `i / count` fills the circle in whatever order the + * dice happened to hand out — which, because the dice are also what places the + * stones, means the fill order and the layout are correlated and you can *see* + * the loop. Hashing the **landing point** instead decorrelates them completely: + * the order is a property of the floor, not of the array. + * + * The coordinates are quantised onto a lattice of `cell` metres first. Hashing + * the raw position would give per-stone white noise, which reads as no order at + * all; hashing the cell means neighbours share a key, so the circle fills in + * *patches* that spread — much closer to how weather actually arrives. Drop + * `cell` toward zero for confetti, push it past the zone radius and the whole + * circle lands as one sheet. + * + * @param {number} x metres + * @param {number} z metres + * @param {number} cell lattice size, metres + * @param {number} seed per-cast seed, so no two casts fill in the same order + * @returns {number} 0..1 + */ +export function spatialStagger(x, z, cell, seed) { + const size = Math.max(0.01, cell); + const cx = Math.floor(x / size); + const cz = Math.floor(z / size); + // Two decorrelated hashes of the same cell, folded together — one sine hash + // on its own bands badly along the diagonal where cx + cz is constant. + const a = Math.sin(cx * 127.1 + cz * 311.7 + seed * 74.7) * 43758.5453123; + const b = Math.sin(cx * 269.5 + cz * 183.3 - seed * 41.3) * 24634.6345345; + const h = (a - Math.floor(a)) * 0.65 + (b - Math.floor(b)) * 0.35; + return h - Math.floor(h); +} + +/* ---------------------------------------------------------------------- */ +/* The trail */ +/* ---------------------------------------------------------------------- */ + +/** + * One instanced strip, N ribbons. + * + * A vertex arrives as `(v, side)` — how far along its own trail it is and which + * edge it sits on — plus the four per-instance values that describe its body's + * flight: two endpoints, two timings, four dice. It leaves as a world position. + * Nothing about any trail exists on the CPU, which is what makes forty trails + * cost one draw call and zero per-frame geometry work. + * + * The first version recorded the body's past positions into a ring buffer and + * rebuilt a `RibbonGeometry` per body. That is forty draw calls, forty + * allocations' worth of history, and — fatally — a trail that *cannot* be + * reshaped by a slider, because the history is a record of metres. Sampling the + * parametric flight backwards in the body's own clock has none of those + * problems: dragging `apex` re-lofts the trail along with the rock making it, + * with the simulation paused. + */ +const TRAIL_VERTEX = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + uniform float uClock; // seconds since the cast began + uniform vec3 uSideAxis; // the cast's lateral unit vector + uniform float uPathCurve; // easing exponent on the launch → land parameter + uniform float uApex; // ballistic loft, metres + uniform float uApexCurve; // >1 flattens the top of the lob + uniform float uWeaveSide; // lateral weave amplitude at launch, metres + uniform float uWeaveUp; // vertical weave amplitude at launch, metres + uniform float uWeaveTurns; // lateral cycles over the whole flight + uniform float uWeaveTurnsUp; // vertical cycles over the whole flight + uniform float uWeavePhase; // radians the vertical weave leads the lateral + uniform float uWeaveDecay; // >0 pulls the weave to zero at the target + + uniform float uTrailSpan; // how far back the tail reaches, seconds + uniform float uTrailBurn; // seconds the trail takes to eat itself after landing + uniform float uTrailWidth; // width at the head, metres + uniform float uTrailTaper; // >1 sharpens the tail to a point + uniform float uTrailLift; // metres the tail floats above the flown path + + attribute vec3 aLaunch; + attribute vec3 aLand; + attribute vec2 aTiming; // x = launch delay (s), y = flight time (s) + attribute vec4 aDice; // seed, phase, weave sign, spin — all unitless + + varying float vAlong; // 0 tail → 1 head + varying float vSide; // -1 .. 1 across the ribbon + varying float vSeed; + varying float vBurn; + varying vec3 vWorld; + varying float vViewZ; + + /** + * The flight. Mirror of _pathPoint() in Projectile.js — if you change one, + * change the other, or the body and its own trail will part company. + */ + vec3 pathAt(float tau) { + float t = clamp(tau, 0.0, 1.0); + float s = pow(t, max(uPathCurve, 0.01)); + vec3 p = mix(aLaunch, aLand, s); + + p.y += uApex * pow(max(sin(PI * t), 0.0), max(uApexCurve, 0.05)); + + float decay = pow(1.0 - t, max(uWeaveDecay, 0.0)); + float phase = aDice.y * TAU; + p += uSideAxis * (uWeaveSide * aDice.z * decay * sin(TAU * uWeaveTurns * t + phase)); + p.y += uWeaveUp * aDice.z * decay * sin(TAU * uWeaveTurnsUp * t + phase + uWeavePhase); + return p; + } + + void main() { + float flight = max(aTiming.y, 1e-3); + float raw = (uClock - aTiming.x) / flight; + + // Before the body leaves the hand there is nothing to trail. Collapse the + // whole instance outside the clip volume rather than drawing a dot at the + // launch point. + if (raw < 0.0) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + float head = min(raw, 1.0); + float over = max(0.0, raw - 1.0) * flight; // seconds since landing + float burn = clamp(over / max(uTrailBurn, 1e-3), 0.0, 1.0); + // The tail catches the head up rather than the whole ribbon fading: a trail + // that dims uniformly reads as a light going out, one that shortens reads as + // something that stopped being made. + float span = (uTrailSpan / flight) * (1.0 - burn); + float tail = max(0.0, head - span); + + float v = position.x; + float tau = mix(tail, head, v); + + vec3 centre = pathAt(tau); + vec3 ahead = pathAt(tau + ${EPSILON.toFixed(4)}); + vec3 tangent = ahead - centre; + if (dot(tangent, tangent) < 1e-10) tangent = vec3(0.0, 0.0, 1.0); + tangent = normalize(tangent); + + vec3 view = cameraPosition - centre; + vec3 side = cross(tangent, view); + if (dot(side, side) < 1e-10) side = vec3(1.0, 0.0, 0.0); + side = normalize(side); + + float profile = pow(clamp(v, 0.0, 1.0), max(uTrailTaper, 0.01)); + vec3 world = centre + + side * (position.y * 0.5 * uTrailWidth * profile) + + vec3(0.0, uTrailLift * (1.0 - v), 0.0); + + vAlong = v; + vSide = position.y; + vSeed = aDice.x; + vBurn = burn; + vWorld = world; + + vec4 mv = modelViewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const TRAIL_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + + uniform vec3 uColorA; // at the head + uniform vec3 uColorB; + uniform vec3 uColorC; + uniform vec3 uColorD; // at the tail + uniform float uOpacity; + uniform float uGlow; + uniform float uCore; // how tightly light crowds the centre line + uniform float uHeadBias; // >0 keeps the brightness near the body + uniform float uNoise; // how hard the grain eats into the ribbon + uniform float uNoiseScale; // features per metre + uniform float uNoiseSpeed; + uniform float uSoftFade; // metres of depth feather against solid geometry + + varying float vAlong; + varying float vSide; + varying float vSeed; + varying float vBurn; + varying vec3 vWorld; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + // Across the ribbon: a hot filament in a soft sheath. The exponent is what + // stops a wide trail reading as a flat strip of colour. + float across = 1.0 - abs(vSide); + float body = pow(clamp(across, 0.0, 1.0), max(uCore, 0.05)); + + // Along it: the grain is sampled in *world* space so it stays pinned to the + // air the body flew through instead of sliding along the ribbon's own uv, + // which is the tell that gives a scrolling texture away. + float grain = fbm3(vWorld * uNoiseScale + vec3(0.0, uTime * uNoiseSpeed, vSeed * 31.0)); + body *= 1.0 - uNoise * (0.5 - 0.5 * grain); + + float head = mix(1.0, pow(clamp(vAlong, 0.0, 1.0), 1.6), clamp(uHeadBias, 0.0, 1.0)); + float alpha = body * head * uOpacity * (1.0 - vBurn); + if (alpha < 0.004) discard; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + vec3 colour = gradient4(uColorA, uColorB, uColorC, uColorD, 1.0 - vAlong); + colour *= uGlow * uGlobalGlow; + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/* ---------------------------------------------------------------------- */ +/* Scratch — the frame allocates nothing */ +/* ---------------------------------------------------------------------- */ + +const _point = new Vector3(); +const _ahead = new Vector3(); +const _behind = new Vector3(); +const _heading = new Vector3(); +const _axis = new Vector3(); +const _dummy = new Object3D(); +const _spin = new Quaternion(); +const _aligned = new Quaternion(); +const _UP = new Vector3(0, 1, 0); + +/* ---------------------------------------------------------------------- */ +/* Projectile */ +/* ---------------------------------------------------------------------- */ + +/** + * **What it draws.** One to a few hundred travelling bodies — rocks, needles, + * stars, hailstones, blobs — each optionally dragging a ribbon trail. + * + * **Draw calls.** Two: one `InstancedMesh` for the bodies, one instanced strip + * for every trail. Both are unconditional; a cast with no live body draws + * nothing because the counts go to zero, not because the meshes are removed. + * + * **What it reads from settings.** Nothing directly. `update(now, params)` is + * handed a live block every frame and resolves every key as `p.key ?? default`, + * so you can either pass `settings[id]` outright (if your block uses the + * canonical names — which makes breaking I1 impossible) or fill a scratch + * object from it when you need to fold in `settings.global` multipliers. + * `projectileParams()` returns the full key list with defaults and units. + * + * **The one rule for using it well.** Never write a metre into a dice roll. + * `roll()` captures unitless fractions and nothing else; `update()` is handed a + * *timestamp* and re-resolves every dimension from the params. If you find + * yourself wanting to remember where a body was, you have already lost — ask + * `pointAt()` again with a smaller τ. + * + * --- + * + * ## The flight + * + * Every mode is the same curve: + * + * ``` + * p(τ) = mix(launch, land, τ^pathCurve) + * + up · apex · sin(πτ)^apexCurve + * + side · weaveSide · (1-τ)^decay · sin(2π·turns·τ + φ) + * + up · weaveUp · (1-τ)^decay · sin(2π·turnsUp·τ + φ + ψ) + * ``` + * + * The modes differ only in where the two endpoints are and which coefficients + * are non-zero. A ballistic lob is `apex > 0`; a homing bolt is `weave > 0` with + * a decay that pulls the weave to *exactly* zero at τ = 1, which is why seven + * bolts on seven different Lissajous paths arrive at one point on one frame with + * nothing being simulated and nothing being corrected. A fall is a launch point + * shared by every body, high and behind the caster, so the trails converge in + * the sky and diverge on the ground — the parallax is the effect. + * + * ## Staggered arrival + * + * For a zone fill the launch delay comes from `spatialStagger()` of the landing + * point, blended with a radial ordering (`fillBias`: +1 fills outward from the + * centre, -1 inward from the boundary). Because the key is a property of the + * *floor* and of the per-cast seed, the circle fills deterministically for a + * given seed and never twice in the same order. + */ +export class Projectile { + /** + * @param {import('three').Object3D} parent the ability's group + * @param {object} options + * @param {number} [options.capacity] hard ceiling on bodies + * @param {import('three').BufferGeometry|Function} options.geometry + * The body. A factory is re-run when `shapeKey` changes; a plain + * geometry is adopted outright (this class adds instanced attributes + * to it and disposes it). + * @param {Function} [options.shapeKey] () => string, hashed for rebuilds + * @param {import('three').Material} options.material the body's material + * @param {boolean} [options.trail] build the instanced multi-trail + * @param {number} [options.trailNodes] samples along one trail + * @param {boolean} [options.trailAdditive] + * @param {number} [options.layer] LAYER.* for the bodies + * @param {number} [options.renderOrder] + * @param {boolean} [options.castShadow] + */ + constructor( + parent, + { + capacity = 48, + geometry, + shapeKey = null, + material, + trail = true, + trailNodes = 28, + trailAdditive = true, + layer = LAYER.WORLD, + renderOrder = 2, + castShadow = false + } + ) { + this.capacity = Math.max(1, Math.round(capacity)); + + this.group = new Group(); + this.group.name = 'Projectile'; + this.group.matrixAutoUpdate = false; + parent?.add(this.group); + + /* --- the bodies ------------------------------------------------- */ + + this._geometryFactory = typeof geometry === 'function' ? geometry : null; + this._shapeKey = shapeKey; + this._shapeHash = ''; + + this.seeds = new InstancedBufferAttribute(new Float32Array(this.capacity), 1); + this.flights = new InstancedBufferAttribute(new Float32Array(this.capacity), 1); + this.flashes = new InstancedBufferAttribute(new Float32Array(this.capacity), 1); + this.seeds.setUsage(DynamicDrawUsage); + this.flights.setUsage(DynamicDrawUsage); + this.flashes.setUsage(DynamicDrawUsage); + + this.geometry = this._geometryFactory ? this._geometryFactory() : geometry; + this._dressGeometry(this.geometry); + + this.material = material; + this.mesh = new InstancedMesh(this.geometry, this.material, this.capacity); + this.mesh.frustumCulled = false; + this.mesh.castShadow = castShadow; + this.mesh.receiveShadow = castShadow; + this.mesh.count = 0; + this.mesh.layers.set(layer); + this.mesh.renderOrder = renderOrder; + this.group.add(this.mesh); + + /* --- the trails ------------------------------------------------- */ + + this.trailMesh = null; + this.trailMaterial = null; + this.trailGeometry = null; + if (trail) this._buildTrail(Math.max(2, Math.round(trailNodes)), trailAdditive); + + /* --- the dice ---------------------------------------------------- */ + + /** + * Fixed-size record pool. A cast allocates nothing, and every field in here + * is a *unitless* roll — see the class comment. + */ + this.dice = []; + for (let i = 0; i < this.capacity; i++) { + this.dice.push({ + seed: 0, // 0..1, general-purpose + angle: 0, // 0..1 of a turn, bearing of the landing point + radial: 0, // 0..1, radius fraction before the bias exponent + lateral: 0, // -1..1 across the cast line + depth: 0, // -1..1 along the cast line + speed: 0, // -1..1 flight-time jitter + size: 0, // -1..1 radius jitter + phase: 0, // 0..1 of a turn, weave phase + weave: 0, // -1..1 weave amplitude and handedness + spin: 0, // -1..1 tumble rate + axis: new Vector3(0, 1, 0) // unit tumble axis + }); + } + + /** Endpoints, re-resolved every frame. Metres — never read across frames. */ + this._launchPoints = []; + this._landPoints = []; + this._slotPoints = []; + for (let i = 0; i < this.capacity; i++) { + this._launchPoints.push(new Vector3()); + this._landPoints.push(new Vector3()); + this._slotPoints.push(new Vector3()); + } + this._landedFlags = new Uint8Array(this.capacity); + + /** Bodies that crossed τ = 1 this frame. Consume it in `onTravel`. */ + this.arrivals = new Int32Array(this.capacity); + this.arrivalCount = 0; + + /** Where the rolling body touches the floor, and how hard. */ + this.contact = new Vector3(); + this.contactLoad = 0; + + this.liveBodies = 0; + this.liveTrails = 0; + this.seed = 0; + + /* --- the basis --------------------------------------------------- */ + + this._origin = new Vector3(); + this._direction = new Vector3(0, 0, 1); + this._side = new Vector3(1, 0, 0); + this._length = 1; + + /** + * The last block `update()` resolved, defaults filled in. + * + * Owned per instance rather than module-scope so the read-back queries + * (`pointAt`, `landPoint`) stay valid for *this* projectile after another + * one has updated. Written into, never replaced — no frame allocates. + */ + this._p = projectileParams(); + } + + /* ------------------------------------------------------------------ */ + /* Construction helpers */ + /* ------------------------------------------------------------------ */ + + _dressGeometry(geometry) { + geometry.setAttribute('aSeed', this.seeds); + geometry.setAttribute('aFlight', this.flights); + geometry.setAttribute('aFlash', this.flashes); + } + + /** + * The instanced ladder every trail is drawn on, in *parameter* space. + * + * `position = (v, side, 0)`: v runs 0 → 1 from the tail to the head, side is + * ±1 across the ribbon. There are no metres in the buffer at all, which is + * what lets one strip serve forty bodies of any speed on any path. + */ + _buildTrail(nodes, additive) { + const positions = new Float32Array(nodes * 2 * 3); + for (let i = 0; i < nodes; i++) { + const v = i / (nodes - 1); + const o = i * 6; + positions[o + 0] = v; + positions[o + 1] = -1; + positions[o + 3] = v; + positions[o + 4] = 1; + } + + const indices = new Uint16Array((nodes - 1) * 6); + for (let i = 0; i < nodes - 1; i++) { + const a = i * 2; + const o = i * 6; + indices[o + 0] = a; + indices[o + 1] = a + 1; + indices[o + 2] = a + 2; + indices[o + 3] = a + 1; + indices[o + 4] = a + 3; + indices[o + 5] = a + 2; + } + + this.trailLaunch = new InstancedBufferAttribute(new Float32Array(this.capacity * 3), 3); + this.trailLand = new InstancedBufferAttribute(new Float32Array(this.capacity * 3), 3); + this.trailTiming = new InstancedBufferAttribute(new Float32Array(this.capacity * 2), 2); + this.trailDice = new InstancedBufferAttribute(new Float32Array(this.capacity * 4), 4); + for (const attribute of [this.trailLaunch, this.trailLand, this.trailTiming, this.trailDice]) { + attribute.setUsage(DynamicDrawUsage); + } + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setAttribute('aLaunch', this.trailLaunch); + geometry.setAttribute('aLand', this.trailLand); + geometry.setAttribute('aTiming', this.trailTiming); + geometry.setAttribute('aDice', this.trailDice); + geometry.setIndex(new BufferAttribute(indices, 1)); + geometry.instanceCount = 0; + // Built in world space by the vertex shader, so its own bounds mean nothing. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.trailGeometry = geometry; + + this.trailMaterial = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: additive ? AdditiveBlending : NormalBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uClock: { value: 0 }, + uSideAxis: { value: new Vector3(1, 0, 0) }, + uPathCurve: { value: 1 }, + uApex: { value: 0 }, + uApexCurve: { value: 1 }, + uWeaveSide: { value: 0 }, + uWeaveUp: { value: 0 }, + uWeaveTurns: { value: 1 }, + uWeaveTurnsUp: { value: 1 }, + uWeavePhase: { value: Math.PI * 0.5 }, + uWeaveDecay: { value: 1.6 }, + uTrailSpan: { value: 0.35 }, + uTrailBurn: { value: 0.25 }, + uTrailWidth: { value: 0.18 }, + uTrailTaper: { value: 1.4 }, + uTrailLift: { value: 0 }, + uColorA: { value: new Color('#ffffff') }, + uColorB: { value: new Color('#ffc27a') }, + uColorC: { value: new Color('#a03a12') }, + uColorD: { value: new Color('#100806') }, + uOpacity: { value: 1 }, + uGlow: { value: 1.4 }, + uCore: { value: 2.2 }, + uHeadBias: { value: 0.45 }, + uNoise: { value: 0.5 }, + uNoiseScale: { value: 1.6 }, + uNoiseSpeed: { value: 0.6 }, + uSoftFade: { value: 0.4 } + }), + vertexShader: TRAIL_VERTEX, + fragmentShader: TRAIL_FRAGMENT + }); + + this.trailMesh = new Mesh(geometry, this.trailMaterial); + this.trailMesh.frustumCulled = false; + this.trailMesh.matrixAutoUpdate = false; + this.trailMesh.layers.set(LAYER.VFX); + // After the non-additive smoke (10) so a plume can occlude it, before the + // additive particles (12) so sparks in front of it are not dimmed. + this.trailMesh.renderOrder = 11; + this.group.add(this.trailMesh); + } + + /** Bodies drawn on the last update. → `Ability#instanceCount`. */ + get count() { + return this.liveBodies; + } + + /** One for the bodies, one more if they carry trails. */ + get drawCalls() { + return this.trailMesh ? 2 : 1; + } + + /** Live uniforms of the trail material, for anything `params` does not cover. */ + get trailUniforms() { + return this.trailMaterial ? this.trailMaterial.uniforms : null; + } + + /** + * Head → tail colours. Four pickers, none derived from another. + * + * Takes `THREE.Color`s or `#rrggbb` strings straight out of a settings block; + * the strings go through the memoised `getColor`, so calling this every frame + * costs four copies and no allocation. + */ + setTrailColors(a, b, c, d) { + if (!this.trailMaterial) return; + const u = this.trailMaterial.uniforms; + u.uColorA.value.copy(typeof a === 'string' ? getColor(a) : a); + u.uColorB.value.copy(typeof b === 'string' ? getColor(b) : b); + u.uColorC.value.copy(typeof c === 'string' ? getColor(c) : c); + const tail = d ?? c; + u.uColorD.value.copy(typeof tail === 'string' ? getColor(tail) : tail); + } + + /** + * Rebuild the body geometry when a *shape* control moves. + * + * Only meaningful with a factory. Lumps, facets and cuts displace real + * vertices — the silhouette and the shadow have to see them — so they cannot + * be faked per instance, and a few hundred triangles is cheap enough to + * simply regenerate. That is what keeps them live sliders. + */ + syncGeometry() { + if (!this._geometryFactory || !this._shapeKey) return; + const key = String(this._shapeKey()); + if (key === this._shapeHash) return; + this._shapeHash = key; + + const previous = this.geometry; + this.geometry = this._geometryFactory(); + this._dressGeometry(this.geometry); + this.mesh.geometry = this.geometry; + previous.dispose(); + } + + /* ------------------------------------------------------------------ */ + /* Casting */ + /* ------------------------------------------------------------------ */ + + /** The cast's frame. Called once per frame by the ability; metres live here. */ + setBasis(origin, direction, side, length) { + this._origin.copy(origin); + this._direction.copy(direction); + this._side.copy(side); + this._length = Math.max(0.01, length); + return this; + } + + /** + * Re-roll every body's dice. Call from `onSpawn` and nowhere else. + * + * @param {number} [seed] per-cast seed; random when omitted + */ + roll(seed = Math.random() * 100) { + this.seed = seed; + for (let i = 0; i < this.capacity; i++) { + const d = this.dice[i]; + d.seed = Math.random(); + d.angle = Math.random(); + d.radial = Math.random(); + d.lateral = Math.random() * 2 - 1; + d.depth = Math.random() * 2 - 1; + d.speed = Math.random() * 2 - 1; + d.size = Math.random() * 2 - 1; + d.phase = Math.random(); + d.weave = Math.random() * 2 - 1; + d.spin = Math.random() * 2 - 1; + + // Uniform point on the sphere — `acos` on a uniform cosine, not a uniform + // angle, or the tumble axes bunch at the poles. + const phi = Math.acos(Math.random() * 2 - 1); + const theta = Math.random() * TAU; + const sinPhi = Math.sin(phi); + d.axis.set(sinPhi * Math.cos(theta), Math.cos(phi), sinPhi * Math.sin(theta)); + } + this._landedFlags.fill(0); + this.arrivalCount = 0; + return this; + } + + /** Hide everything. Leaves the instance reusable — the pooling contract. */ + reset() { + this.liveBodies = 0; + this.liveTrails = 0; + this.arrivalCount = 0; + this.contactLoad = 0; + this.mesh.count = 0; + if (this.trailGeometry) this.trailGeometry.instanceCount = 0; + this._landedFlags.fill(0); + } + + /* ------------------------------------------------------------------ */ + /* The flight — every metre resolved from live params */ + /* ------------------------------------------------------------------ */ + + /** + * Fill `_p` from the caller's block, defaults where it is silent. + * + * A `for...in` over seventy keys per frame is a rounding error next to one + * matrix rebuild, and it is what lets an ability hand this module its raw + * `settings[id]` — the version of the contract that cannot be got wrong. + */ + _resolve(params) { + const p = this._p; + for (const key in DEFAULT_PARAMS) { + const value = params[key]; + p[key] = value === undefined ? DEFAULT_PARAMS[key] : value; + } + return p; + } + + /** How many bodies this cast is flying, from the last resolved params. */ + _bodyCount(p) { + return Math.max(0, Math.min(this.capacity, Math.round(p.count))); + } + + /** Where a body leaves from, metres. Writes into `_launchPoints[i]`. */ + _resolveLaunch(i, p) { + const d = this.dice[i]; + const out = this._launchPoints[i]; + + if (p.mode === FlightMode.FALL) { + // One shared vanishing point, above and behind the caster. The scatter is + // deliberately tiny by default: the trails are supposed to *converge* up + // there, and a metre of spread at fifty metres of height throws the whole + // read away. + out + .copy(this._origin) + .addScaledVector(this._direction, -p.skyBack + d.depth * p.skyScatter) + .addScaledVector(this._side, d.lateral * p.skyScatter); + out.y = p.skyHeight; + return out; + } + + out + .copy(this._origin) + .addScaledVector(this._direction, p.handForward) + .addScaledVector(this._side, p.handSide); + // A volley leaves the hand as a fan rather than from one point, otherwise + // the first metre of seven needles is a single bright line. + if (p.mode === FlightMode.VOLLEY) { + const total = this._bodyCount(p); + const fan = total > 1 ? (i / (total - 1)) * 2 - 1 : 0; + out.addScaledVector(this._side, fan * p.fanWidth); + } + out.y = p.mode === FlightMode.ROLL ? this._radius(i, p) : p.handHeight; + return out; + } + + /** Where a body lands, metres. Writes into `_landPoints[i]`. */ + _resolveLand(i, p) { + const d = this.dice[i]; + const out = this._landPoints[i]; + + if (p.mode === FlightMode.FALL || p.landInZone) { + // Uniform over the disc at bias 0.5; push the exponent down to crowd the + // rim, up to crowd the middle. + const radius = p.zoneRadius * Math.pow(saturate(d.radial), Math.max(0.05, p.zoneBias)); + const angle = d.angle * TAU; + out + .copy(this._origin) + .addScaledVector(this._direction, this._length + radius * Math.sin(angle)) + .addScaledVector(this._side, radius * Math.cos(angle)); + } else { + out + .copy(this._origin) + .addScaledVector(this._direction, this._length + d.depth * p.spreadForward) + .addScaledVector(this._side, d.lateral * p.spreadSide); + } + + out.y = p.mode === FlightMode.ROLL ? this._radius(i, p) : p.landHeight; + return out; + } + + /** Body radius, metres. */ + _radius(i, p) { + return Math.max(0.005, p.radius * (1 + p.sizeJitter * this.dice[i].size)); + } + + /** + * A body's launch delay, seconds. + * + * Needs the landing point, so it is resolved after `_resolveLand`. + */ + _launchDelay(i, p) { + let mode = p.stagger; + if (mode === Stagger.AUTO) { + mode = + p.mode === FlightMode.FALL + ? Stagger.HASH + : p.mode === FlightMode.VOLLEY + ? Stagger.RIPPLE + : Stagger.NONE; + } + + let order = 0; + if (mode === Stagger.RIPPLE) { + const total = this._bodyCount(p); + order = total > 1 ? i / (total - 1) : 0; + } else if (mode === Stagger.HASH) { + const land = this._landPoints[i]; + // Radial ordering first: +1 fills outward from the centre, -1 inward from + // the boundary, 0 leaves the hash to decide on its own. + const radial = saturate(Math.pow(saturate(this.dice[i].radial), Math.max(0.05, p.zoneBias))); + const radialOrder = saturate(0.5 + p.fillBias * (radial - 0.5)); + const hashed = spatialStagger(land.x, land.z, p.hashCell, this.seed); + order = saturate(lerp(radialOrder, hashed, saturate(p.fillScatter))); + } + + return p.lead + p.window * order; + } + + /** A body's flight time, seconds. */ + _flightTime(i, p) { + return Math.max(0.02, p.flightTime * (1 + p.speedJitter * this.dice[i].speed)); + } + + /** + * The flight. + * + * Mirror of `pathAt()` in `TRAIL_VERTEX` — if you change one, change the + * other, or a body and its own trail will part company. The formula is + * documented on the class; everything in it is a live metre except `τ`. + */ + _pathPoint(i, tau, out, p = this._p) { + const d = this.dice[i]; + const t = saturate(tau); + const s = Math.pow(t, Math.max(0.01, p.pathCurve)); + + out.copy(this._launchPoints[i]).lerp(this._landPoints[i], s); + out.y += p.apex * Math.pow(Math.sin(Math.PI * t), Math.max(0.05, p.apexCurve)); + + const decay = Math.pow(1 - t, Math.max(0, p.weaveDecay)); + const phase = d.phase * TAU; + out.addScaledVector( + this._side, + p.weaveSide * d.weave * decay * Math.sin(TAU * p.weaveTurns * t + phase) + ); + out.y += p.weaveUp * d.weave * decay * Math.sin(TAU * p.weaveTurnsUp * t + phase + p.weavePhase); + return out; + } + + /** + * Unit heading at τ. + * + * Differentiated numerically rather than by hand: every coefficient in the + * path is a live slider, and a hand-written derivative is one more thing that + * silently stops matching them. + */ + _headingAt(i, tau, out, p = this._p) { + this._pathPoint(i, tau + EPSILON, _ahead, p); + this._pathPoint(i, tau - EPSILON, _behind, p); + out.subVectors(_ahead, _behind); + if (out.lengthSq() < 1e-10) return out.copy(this._direction); + return out.normalize(); + } + + /* ------------------------------------------------------------------ */ + /* Per-frame */ + /* ------------------------------------------------------------------ */ + + /** + * Rebuild every instance from the live params. + * + * Consume `arrivals` immediately after this returns: an arrival fires on the + * frame a body crosses τ = 1 and is not re-raised on the next call. + * + * @param {number} now seconds since the cast began — a timestamp, and the + * only thing in here that is not re-resolved from the params. + * @param {object} params live block; every key falls back to + * `projectileParams()` where it is absent. + */ + update(now, params) { + const p = this._resolve(params ?? DEFAULT_PARAMS); + this.syncGeometry(); + + const count = this._bodyCount(p); + const linger = Math.max(0, p.linger); + const trailBurn = Math.max(0.001, p.trailBurn); + const spin = p.spin; + const align = saturate(p.align); + + let bodySlot = 0; + let trailSlot = 0; + this.arrivalCount = 0; + this.contactLoad = 0; + + for (let i = 0; i < count; i++) { + const d = this.dice[i]; + this._resolveLaunch(i, p); + this._resolveLand(i, p); + + const delay = this._launchDelay(i, p); + const flight = this._flightTime(i, p); + const since = now - delay; // seconds since this body left + const tau = since / flight; + + /* --- arrival events, re-derived rather than remembered --------- */ + if (tau >= 1) { + if (!this._landedFlags[i]) { + this._landedFlags[i] = 1; + this.arrivals[this.arrivalCount++] = i; + } + } else if (this._landedFlags[i]) { + // Dragging a slider can put a landed body back in the air. Let it. + this._landedFlags[i] = 0; + } + + if (tau < 0) continue; + + const over = (tau - 1) * flight; // seconds since landing, negative in flight + + /* --- the trail -------------------------------------------------- */ + if (this.trailGeometry && (tau <= 1 || over <= trailBurn)) { + const o3 = trailSlot * 3; + const launch = this._launchPoints[i]; + const land = this._landPoints[i]; + this.trailLaunch.array[o3 + 0] = launch.x; + this.trailLaunch.array[o3 + 1] = launch.y; + this.trailLaunch.array[o3 + 2] = launch.z; + this.trailLand.array[o3 + 0] = land.x; + this.trailLand.array[o3 + 1] = land.y; + this.trailLand.array[o3 + 2] = land.z; + this.trailTiming.array[trailSlot * 2 + 0] = delay; + this.trailTiming.array[trailSlot * 2 + 1] = flight; + const o4 = trailSlot * 4; + this.trailDice.array[o4 + 0] = d.seed; + this.trailDice.array[o4 + 1] = d.phase; + this.trailDice.array[o4 + 2] = d.weave; + this.trailDice.array[o4 + 3] = d.spin; + trailSlot++; + } + + /* --- the body --------------------------------------------------- */ + if (tau > 1 && over > linger) continue; + + const held = Math.min(tau, 1); + this._pathPoint(i, held, _point, p); + const radius = this._radius(i, p); + + // A body sinks into the floor as it lingers rather than blinking out. + if (over > 0 && p.sink > 0) { + _point.y -= (over / Math.max(0.001, linger)) * p.sink * radius; + } + + _dummy.position.copy(_point); + this._slotPoints[bodySlot].copy(_point); + + if (p.mode === FlightMode.ROLL) { + /* + * Real rolling. The contact point has to be instantaneously at rest, so + * the angle is *distance over radius* and nothing else — no spin slider + * touches it. Get this wrong by any factor at all and the boulder + * skates, which is the single most obvious failure mode a rolling + * object has. + * + * The sign: for a body travelling along `direction`, the angular + * velocity is about `direction × up` — which is `side` — with a + * negative sense. + */ + const travelled = this._launchPoints[i].distanceTo(_point); + _axis.copy(this._side); + _spin.setFromAxisAngle(_axis, -travelled / Math.max(0.01, radius)); + // What a GroundField(RUT) wants: where the body is pressing, and how + // hard. Flat while it is rolling and zero once it has stopped — shape + // it in the ability if the rut should deepen, because only the ability + // knows whether its boulder is accelerating. + this.contact.copy(_point); + this.contact.y = 0; + this.contactLoad = tau <= 1 ? saturate(p.load) : 0; + } else { + _axis.copy(d.axis); + _spin.setFromAxisAngle(_axis, spin * d.spin * Math.max(0, since)); + } + + if (align > 0) { + this._headingAt(i, held, _heading, p); + _aligned.setFromUnitVectors(_UP, _heading); + _spin.slerp(_aligned, align); + } + + _dummy.quaternion.copy(_spin); + _dummy.scale.set(radius, radius * Math.max(0.01, p.stretch), radius); + _dummy.updateMatrix(); + this.mesh.setMatrixAt(bodySlot, _dummy.matrix); + + this.seeds.array[bodySlot] = d.seed; + this.flights.array[bodySlot] = tau; + this.flashes.array[bodySlot] = saturate(1 - since / Math.max(0.001, p.flash)); + bodySlot++; + } + + this.liveBodies = bodySlot; + this.liveTrails = trailSlot; + + this.mesh.count = bodySlot; + this.mesh.instanceMatrix.needsUpdate = true; + this.seeds.needsUpdate = true; + this.flights.needsUpdate = true; + this.flashes.needsUpdate = true; + + if (this.trailGeometry) { + this.trailGeometry.instanceCount = trailSlot; + this.trailMesh.visible = trailSlot > 0 && p.trailWidth > 0 && p.trailOpacity > 0; + this.trailLaunch.needsUpdate = true; + this.trailLand.needsUpdate = true; + this.trailTiming.needsUpdate = true; + this.trailDice.needsUpdate = true; + this._syncTrailUniforms(now, p); + } + } + + /** Push the live params into the trail's uniforms. */ + _syncTrailUniforms(now, p) { + const u = this.trailMaterial.uniforms; + u.uClock.value = now; + u.uSideAxis.value.copy(this._side); + u.uPathCurve.value = p.pathCurve; + u.uApex.value = p.apex; + u.uApexCurve.value = p.apexCurve; + u.uWeaveSide.value = p.weaveSide; + u.uWeaveUp.value = p.weaveUp; + u.uWeaveTurns.value = p.weaveTurns; + u.uWeaveTurnsUp.value = p.weaveTurnsUp; + u.uWeavePhase.value = p.weavePhase; + u.uWeaveDecay.value = p.weaveDecay; + u.uTrailSpan.value = p.trailSpan; + u.uTrailBurn.value = p.trailBurn; + u.uTrailWidth.value = p.trailWidth; + u.uTrailTaper.value = p.trailTaper; + u.uTrailLift.value = p.trailLift; + u.uOpacity.value = p.trailOpacity; + u.uGlow.value = p.trailGlow; + u.uCore.value = p.trailCore; + u.uHeadBias.value = p.trailHeadBias; + u.uNoise.value = p.trailNoise; + u.uNoiseScale.value = p.trailNoiseScale; + u.uNoiseSpeed.value = p.trailNoiseSpeed; + u.uSoftFade.value = p.trailSoftFade; + } + + /* ------------------------------------------------------------------ */ + /* Queries */ + /* ------------------------------------------------------------------ */ + + /** World position of a drawn body, `slot` in `0 .. liveBodies-1`. */ + slotPosition(slot, out) { + return out.copy(this._slotPoints[Math.max(0, Math.min(this.liveBodies - 1, slot))]); + } + + /** Where body `index` lands, metres. Valid after `update`. */ + landPoint(index, out) { + return out.copy(this._landPoints[Math.max(0, Math.min(this.capacity - 1, index))]); + } + + /** Where body `index` is at τ. Valid after `update` has resolved endpoints. */ + pointAt(index, tau, out) { + return this._pathPoint(Math.max(0, Math.min(this.capacity - 1, index)), tau, out); + } + + /** Unit heading of body `index` at τ. */ + headingAt(index, tau, out) { + return this._headingAt(Math.max(0, Math.min(this.capacity - 1, index)), tau, out); + } + + dispose() { + this.geometry.dispose(); + if (this.trailGeometry) this.trailGeometry.dispose(); + if (this.trailMaterial) this.trailMaterial.dispose(); + this.mesh.dispose(); + this.group.parent?.remove(this.group); + } +} + +/* ---------------------------------------------------------------------- */ +/* Params */ +/* ---------------------------------------------------------------------- */ + +/** + * Every key `update()` understands, with its default and its unit. + * + * Call it to seed a scratch block you fill from `settings[id]` each frame, or + * ignore it entirely and hand `update()` your settings block — anything absent + * falls back to the value here. Defaults exist so that a forgotten field gives + * a shape rather than a `NaN`, which is the single most expensive mistake to + * debug in this codebase. + */ +export function projectileParams() { + return { + /* --- what is flying --- */ + mode: FlightMode.ARC, // FlightMode.* + stagger: Stagger.AUTO, // Stagger.* + count: 1, // live bodies, clamped to capacity + radius: 0.35, // body radius, metres + sizeJitter: 0.25, // ±fraction of radius + stretch: 1, // scale along the aligned axis (needs `align`) + align: 0, // 0 tumble freely, 1 lay +Y along the heading + spin: 4, // tumble rate, radians/second + flash: 0.12, // birth flash decay, seconds + + /* --- the launch --- */ + handForward: 0.55, // metres downrange of the caster + handSide: 0.34, // metres to the caster's side + handHeight: 1.35, // metres off the floor + fanWidth: 0.5, // VOLLEY: half-width of the muzzle fan, metres + + /* --- the landing --- */ + landHeight: 0.0, // metres off the floor at the target + spreadSide: 0, // ±metres across the cast line + spreadForward: 0, // ±metres along it + landInZone: false, // land on the far disc instead of the line's end + zoneRadius: 4, // that disc, metres + zoneBias: 0.5, // 0.5 uniform, <0.5 crowds the rim, >0.5 crowds the middle + + /* --- the curve --- */ + pathCurve: 1, // easing exponent on launch → land (>1 accelerates) + apex: 1.6, // ballistic loft, metres + apexCurve: 1, // >1 flattens the top of the lob + weaveSide: 0, // lateral weave at launch, metres + weaveUp: 0, // vertical weave at launch, metres + weaveTurns: 1.5, // lateral cycles over the flight + weaveTurnsUp: 2.5, // vertical cycles over the flight + weavePhase: Math.PI * 0.5, // radians the vertical weave leads by + weaveDecay: 1.6, // >0 pulls the weave to zero exactly at the target + + /* --- the fall --- */ + skyBack: 9, // metres behind the caster the vanishing point sits + skyHeight: 26, // metres above the floor + skyScatter: 0.4, // ±metres of spread around it (0 = perfect convergence) + + /* --- the clock --- */ + flightTime: 0.7, // seconds one body is in the air + speedJitter: 0.15, // ±fraction of flight time + lead: 0, // seconds before the first body leaves + window: 0.9, // seconds the staggered launches are spread over + fillBias: -1, // +1 fills outward from the centre, -1 inward from the rim + fillScatter: 0.55, // 0 pure radial order, 1 pure spatial hash + hashCell: 1.1, // hash lattice size, metres + linger: 0, // seconds a landed body stays on the floor + sink: 1.2, // body radii it sinks over that linger + + /* --- the rut --- */ + load: 1, // ROLL: peak contact load, 0..1 + + /* --- the trail --- */ + trailSpan: 0.3, // seconds of flight the tail reaches back over + trailBurn: 0.22, // seconds the tail takes to catch up after landing + trailWidth: 0.16, // metres at the head + trailTaper: 1.4, // >1 sharpens the tail to a point + trailLift: 0, // metres the tail floats above the flown path + trailOpacity: 1, + trailGlow: 1.4, + trailCore: 2.2, // how tightly light crowds the centre line + trailHeadBias: 0.45, // >0 keeps the brightness near the body + trailNoise: 0.5, + trailNoiseScale: 1.6, // features per metre + trailNoiseSpeed: 0.6, + trailSoftFade: 0.4 // metres of depth feather against solid geometry + }; +} + +/** Resolved once at module load; `_resolve` walks its keys every frame. */ +const DEFAULT_PARAMS = projectileParams(); diff --git a/src/vfx/README.md b/src/vfx/README.md new file mode 100644 index 0000000..4ec7989 --- /dev/null +++ b/src/vfx/README.md @@ -0,0 +1,5689 @@ +# `src/vfx` — the tech library + +**Twenty-six shared modules** — twenty-four renderers and toolkits plus two files of shared +plumbing — that exist so an ability is *configuration plus beats* rather than a new renderer. They +are a floor, not a ceiling: an ability may still write a bespoke material when its trick genuinely +needs one — that is how the first six were built and it is why they are good. + +This file was written by ten agents at once and then reconciled, twice. Every signature in the +**[API reference](#api-reference)** below has been read back off the source, not copied from a +report; where a section further down disagrees with the reference, the reference is right. + +**Every module in here obeys the invariants in `docs/EXPANSION.md` §0.** In particular: + +- **I1** — a module never snapshots a dimension. It is handed a **live settings or params object** + every frame and re-resolves every metre, radian and second from it, including on a zero-length + frame. The test is always the same: pause with **P**, drag a slider, and the standing effect must + change. +- **I3** — nothing in a frame path allocates. Module-scope scratch at the top of each file. +- **I4** — draw calls are counted, and the index table below states every module's cost. + +--- + +## The three conventions + +Three things vary between modules, and knowing which is which up front saves reading the source. +None of them is an accident, and none of them is going to be unified — the reasons are given. + +### 1 · How you attach it + +**Parent-first** — `new X(parent, options)`. The module builds its meshes and adds them to `parent` +itself, and may own several. You never see the meshes. + +> `GrowthField` · `ShatterField` · `GroundField` · `FilamentPaths` · `ArcNetwork` · `Projectile` · +> `Swarm` · `Caustics` · `LightShaft` · `BrushStroke` · `InkDiffusion` · `FoldMesh` · +> `DissolveField` · `GhostRig` · `ColonySwarm` · `WebGraph` · `LatticeGrowth` · `PlateShell` + +**Options-only** — `new X(options)`, then you add its node to your group yourself. These are the +modules that own exactly one visible thing and hand it to you. + +> `Tube` (`.group`) · `Shell` (`.group`) · `VolumeHull` (`.mesh`) · `DistortionField` (`.object3D`) +> · `Portal` (`.object3D`) · `LiquidSurface` (`.object3D`) · `Curtain` (`.object3D`) · +> `LensFlare` (`.object3D`) · `Mirror` (`.object3D`) + +**Neither** — three modules attach nothing. `HardSurface` is a toolkit of generators and solvers, +`Dissolve`'s two patch modes compose into a material you already own, and `SceneHooks` is a +singleton ledger over the scene itself. `quads.js` and `prefixedBlock.js` are shared plumbing. + +### 2 · How it reads settings + +**Canonical names.** The module is handed a params object each frame and reads `p.someName ?? +default`. Fill a scratch object from `settings[id]` (needed when you fold in `settings.global` +multipliers), or — if your block happens to use the canonical names — pass the settings block +itself, which makes breaking I1 impossible. Each of these modules exports a `xxxParams()` returning +every canonical key with its default and a unit comment; that function is the key list. + +> `GrowthField` · `ShatterField` · `GroundField` · `FilamentPaths` · `ArcNetwork` · `Projectile` · +> `Swarm` · `LiquidSurface` · `Curtain` · `DistortionField` · `Portal` · `Caustics` · +> `LightShaft` · `LensFlare` · `Mirror` · `BrushStroke` · `InkDiffusion` · `FoldMesh` · +> `Dissolve` · `TimeControl` · `HardSurface` · `Colony` + +`SceneHooks` is the one module with neither convention: it is driven by **method calls on a +borrowed token** rather than by a params bag, because a hook is held over several frames by one +owner and a bag has nowhere to put "who is asking". + +**Prefixed names.** The module is handed `settings[id]` and reads `c[keys.radius]` where +`keys.radius` is `'tubeRadius'`. Uglier, and it exists because these three are the modules an +ability plausibly wants **two** of: Pyroclasm carries an ash hull and a flame hull, and bare names +collide silently the moment it does — the second hull just quietly drives the first one's radius. +Each exports `xxxDefaults(prefix, mode, overrides)` to spread into the block and +`xxxSchema(prefix, mode)` to spread into the editor schema, and warns once naming every missing key. + +> `Tube` · `Shell` · `VolumeHull` — shared plumbing in [`prefixedBlock.js`](#prefixedblockjs) + +### 3 · What its clock argument means + +Most `update()` methods take `now` — **the ability's `age` in seconds**, the same clock the +records' timestamps are in. A handful take something else, and the harness will not catch you +getting it wrong: + +| module | first argument | note | +| --- | --- | --- | +| `ArcNetwork.update(dt, …)` | **`dt`**, not `now` | it integrates a cursor along the graph | +| `Swarm.update(_now, …)` | ignored | the flock is driven by `frame.uTime` in the shader | +| `Curtain.update(_now, …)` | ignored | same | +| `ColonySwarm.update(_now, …)` | ignored | inherits `Swarm`'s; calls `super.update()` first | +| `WebGraph.update(_now, …)` | ignored | same reason | +| `BrushStroke.update(_now, p)` | ignored | `p.progress` is the only beat a stroke has | +| `FoldMesh.update(_now, p)` | ignored | `p.fold` is the beat | +| `Caustics.update(p)` | **no clock** | takes params only; reads `frame.uTime` itself | +| `LightShaft.update(p)` | **no clock** | same | +| `LensFlare.update(p)` / `Mirror.update(p)` | **no clock** | same | +| `Tube.sync` / `Shell.sync` | no clock | time arrives on the `state` object as `state.time` | +| `VolumeHull.sync(c, g)` | no clock | reads `frame.uTime` itself | +| `LatticeGrowth` · `PlateShell` · `InkDiffusion` · `DissolveField` | `now` = the ability's age | the ordinary case, listed because their siblings are not | + +--- + +## Index + +| module | what it is | draw calls | attach | settings | +| --- | --- | --- | --- | --- | +| [`GrowthField.js`](#growthfieldjs) | Instanced procedural geometry erupting along a line or across a zone. Generalises `IceAbility`'s crystal field. | `variants` (3) | parent | canonical | +| [`ShatterField.js`](#shatterfieldjs) | Instanced fragments that inherit a velocity and tumble, with an optional screen-space scene sample. | `variants` (2) | parent | canonical | +| [`GroundField.js`](#groundfieldjs) | A ground quad whose fragment shader works in metres from the anchor. Ten modes. | 1 | parent | canonical | +| [`FilamentPaths.js`](#filamentpathsjs) | Instanced ribbon strip with pluggable parametric paths. Generalises `LightningMaterial` + `SnareMaterial`. | 2 | parent | canonical | +| [`ArcNetwork.js`](#arcnetworkjs) | Node graph + segment lighting for chained discharges. | 0–2 (shares a strip) | parent | canonical | +| [`Projectile.js`](#projectilejs) | One or many travelling bodies on a parametric flight, each optionally carrying a trail. | 2 (1 without trail) | parent | canonical | +| [`Swarm.js`](#swarmjs) | Instanced agents on a shader-evaluated flock. | 1 | parent | canonical | +| [`Tube.js`](#tubejs) | The parametric tube. Generalises `BeamMaterial`. | 3 | `.group` | prefixed | +| [`Shell.js`](#shelljs) | Expanding shells, domes, cones and ring trains. Extends `BurstSphere`. | 1 | `.group` | prefixed | +| [`VolumeHull.js`](#volumehulljs) | Raymarched volume inside a proxy hull. Generalises `VolumetricFireMaterial`. | 1 | `.mesh` | prefixed | +| [`Distortion.js`](#distortionjs) | Writes to the distortion buffer. `HEAT`, `LENS`, `SHOCK`, `BLADE`, `REFRACT`. | 1 per emitter | `.object3D` | canonical | +| [`Portal.js`](#portaljs) | A disc/slit with a parallax interior, a fracture rim and a depth-correct edge. | 1 | `.object3D` | canonical | +| [`LiquidSurface.js`](#liquidsurfacejs) | A live heightfield plane with flow-mapped crust. | 1 | `.object3D` | canonical | +| [`Curtain.js`](#curtainjs) | Vertical sheets with a travelling vertex ripple and a height-dependent emission curve. | 1 (2 with `floor`) | `.object3D` | canonical | +| [`Caustics.js`](#causticsjs--the-net-of-light-a-surface-throws-on-the-floor) | A caustic net on the floor, folded out of a pluggable height field. `SCROLL` / `WAVE` / `CUSTOM`. | 1 | parent | canonical | +| [`LightShaft.js`](#lightshaftjs--light-in-air-integrated-along-the-view-ray) | Volumetric shafts with a real phase function, a depth-clamped integral and the ground band it implies. | 1 | parent | canonical | +| [`LensFlare.js`](#lensflarejs--the-artefact-that-belongs-to-the-camera) | A screen-space flare — core, starburst, anamorphic streak, iris ring, eight ghosts — occlusion-tested against the depth prepass in the vertex shader. | 1 | `.object3D` | canonical | +| [`Mirror.js`](#mirrorjs--a-planar-surface-that-reflects-the-real-scene) | A planar reflector: an oblique-frustum render of `LAYER.WORLD` into its own target, with a roughness blur and a per-frame budget. | 1 + one nested `render()` | `.object3D` | canonical | +| [`BrushStroke.js`](#brushstrokejs--a-loaded-brush-not-a-ribbon-with-a-noise-mask) | Swept-ellipse strokes laid by bristles that each carry their own ink load and run dry from the outside in. | 1 | `.object3D` | canonical | +| [`InkDiffusion.js`](#inkdiffusionjs--ink-spreading-on-the-floor-unstably) | Ink blooming on the floor: five noise octaves admitted as the front passes them, plus bounded-Pareto satellites. `BLOOM` / `SPLATTER` / `WASH`. | 1 | `.object3D` | canonical | +| [`FoldMesh.js`](#foldmeshjs--paper-that-folds-without-stretching) | A creased sheet folded from one slider by a product of rigid motions, plus a scroll unrolling off a spool. | 1 | parent | canonical | +| [`Dissolve.js`](#dissolvejs--three-ways-for-matter-to-stop-being-there) | `VOXEL` and `EROSION` as a material patch; `GRANULAR` — a heap at its angle of repose — as a mesh. | 0 (patch) / 1 (heap) | parent | canonical | +| [`TimeControl.js`](#timecontroljs--recording-the-caster-and-bending-everyone-elses-clock) | The shared time-region field (stasis / rewind / slow), a skeletal recorder and ghost rig, and the reversible-clock helpers. | 0 (field) / 1 per ghost | pool + parent | canonical | +| [`SceneHooks.js`](#scenehooksjs--the-module-that-changes-the-world) | The borrow/restore ledger for the six pieces of the **world** an ability may edit: key light, grade, floor ageing, hole, gravity, disrupt. | 0 (2 with `HOLE`) | singleton | tokens | +| [`HardSurface.js`](#hardsurfacejs--the-machined-half-of-the-material-vocabulary) | Six machined geometry generators (involute gear, piston, sawblade, plate, bolt, anvil), brushed anisotropic steel with a blackbody ramp, and the gear-train and grinding-spark solvers. | 0 (a toolkit) | — | canonical | +| [`Colony.js`](#colonyjs--many-small-things-behaving-as-one) | Four Hive renderers: an SDF-targeted swarm (extends `Swarm`), a web graph with its membrane, hex lattice growth, and a Voronoi plate dome. | 1 / 2 / 1 / 1 | parent | canonical | +| [`quads.js`](#quadsjs) | The two unit quads — flat and upright — that every quad-backed module in the library draws on. No renderer. | — | — | — | +| [`prefixedBlock.js`](#prefixedblockjs) | The shared plumbing behind prefixed settings blocks. No renderer. | — | — | — | + +### Contents + +1. [The three conventions](#the-three-conventions) +2. [Index](#index) +3. [API reference](#api-reference) — every signature, verified against source +4. [Budgeting a cast](#budgeting-a-cast) +5. [Verifying your module](#verifying-your-module) +6. [Traps that have already cost someone a day](#traps-that-have-already-cost-someone-a-day) +7. The long-form section for each module, in index order — what it draws, why it is built the way + it is, and **the one rule** for using it well: + [GrowthField](#growthfieldjs--things-that-come-out-of-the-ground) · + [ShatterField](#shatterfieldjs--things-that-come-apart) · + [GroundField](#groundfieldjs--one-quad-that-thinks-in-metres) · + [FilamentPaths](#filamentpathsjs--every-filament-in-the-project) · + [ArcNetwork](#arcnetworkjs--a-discharge-that-hops-instead-of-travelling) · + [Projectile](#projectilejs--things-that-travel) · + [Swarm](#swarmjs--things-that-flock) · + [Tube](#tubejs--the-parametric-tube) · + [Shell](#shelljs--the-standing-half-of-the-burst-vocabulary) · + [the distortion pass](#how-the-distortion-pass-works-now) · + [Distortion](#distortionjs--the-pass-that-used-to-do-nothing) · + [Portal](#portaljs--a-hole-not-a-decal) · + [VolumeHull](#volumehulljs--a-raymarched-volume-inside-a-proxy-hull) · + [LiquidSurface](#liquidsurfacejs--a-heightfield-that-knows-how-fast-it-is-moving) · + [Curtain](#curtainjs--vertical-sheets-of-light-in-air) · + [SceneHooks](#scenehooksjs--the-module-that-changes-the-world) · + [LensFlare](#lensflarejs--the-artefact-that-belongs-to-the-camera) · + [Mirror](#mirrorjs--a-planar-surface-that-reflects-the-real-scene) · + [Caustics](#causticsjs--the-net-of-light-a-surface-throws-on-the-floor) · + [LightShaft](#lightshaftjs--light-in-air-integrated-along-the-view-ray) · + [FoldMesh](#foldmeshjs--paper-that-folds-without-stretching) · + [Dissolve](#dissolvejs--three-ways-for-matter-to-stop-being-there) · + [TimeControl](#timecontroljs--recording-the-caster-and-bending-everyone-elses-clock) · + [Colony](#colonyjs--many-small-things-behaving-as-one) · + [BrushStroke](#brushstrokejs--a-loaded-brush-not-a-ribbon-with-a-noise-mask) · + [InkDiffusion](#inkdiffusionjs--ink-spreading-on-the-floor-unstably) · + [the anti-glow contract](#the-anti-glow-contract) · + [HardSurface](#hardsurfacejs--the-machined-half-of-the-material-vocabulary) + +--- + +## API reference + +Read back off the source. Defaults shown are the real ones. `v3` is a `THREE.Vector3`; `out` is +always a caller-supplied `Vector3` that is written into and returned, so nothing here allocates. + +### `GrowthField.js` + +```js +import { GrowthField, GrowthLayout, GrowthEmerge, growthParams, patchGrowthMaterial } + from '../../vfx/GrowthField.js'; + +GrowthLayout = { LINE: 0, ZONE: 1 } +GrowthEmerge = { PUSH: 0, SCALE: 1 } +growthParams() -> object // every canonical key, default + unit comment + +new GrowthField(parent, { geometry, material, shape = null, variants = 3, capacity = 288, + layer = LAYER.WORLD, renderOrder = 2, + castShadow = true, receiveShadow = true }) + // `geometry` is a FACTORY: (variant, shape) => BufferGeometry, unit-space — + // footprint r <= 0.5 on y = 0, tip at y = 1. Throws if it is not a function. + // `material` is required and is the ABILITY'S; dispose() does not touch it. + +field.count field.drawCalls // === variants +field.meshes field.records field.material field.factory +field.onBreach = (index, position, radius, height) => {} // assign ONCE at construction (I3) +field.isFullyTriggered + +field.plant(count, clusterShare = 0) -> number // the only dice roll +field.clear() +field.syncGeometry(shape) -> boolean // Float64Array hash; rebuilds on change +field.triggerUpTo(now, limit, stagger, frontBias = 1, includeCluster = false) +field.triggerRadial(now, limit, stagger, invert = false, includeCluster = true) +field.triggerAll(now, stagger) +field.triggerIndex(now, index, delay = 0) +field.update(now, p, retract = 0) +field.positionOf(index, p, out) -> v3 field.tipOf(index, p, out) -> v3 +field.heightOf(index, p) -> metres field.radiusOf(index, p) -> metres +field.emergenceOf(index, now, p) -> 0..1 +field.dispose() // geometry only; material is yours + +patchGrowthMaterial(material, { environment, uniforms, common, vertex, fragment }) -> material + // varyings the patch provides: vGrowLocal, vGrowWorld, vGrowSeed, vGrowBirth + // per-instance attributes: aSeed, aBirth +``` + +There is **no `object3D`** — the field owns `variants` meshes and adds them to `parent` itself. + +### `ShatterField.js` + +```js +import { ShatterField, ShatterLayout, shatterParams } from '../../vfx/ShatterField.js'; + +ShatterLayout // re-export of GrowthLayout +shatterParams() -> object + +new ShatterField(parent, { geometry, variants = 2, capacity = 192, material = null, + additive = false, depthWrite = true, layer = LAYER.VFX, + renderOrder = 6, castShadow = false, receiveShadow = false }) + // `geometry` is a FACTORY, as above. Throws if it is not a function. + // Owns a ShaderMaterial unless you pass one; yours must consume aSeed / aLife. + +shatter.count shatter.drawCalls // === variants +shatter.uniforms shatter.material + +shatter.burst(now, count, along = 1, lateral = 0) -> number +shatter.clear() +shatter.sync(look) // look/colour block, separate from update() +shatter.setSceneTexture(texture | null) +shatter.update(now, p) -> live count +shatter.positionOf(index, now, p, out) -> v3 +shatter.dispose() +``` + +### `GroundField.js` + +```js +import { GroundField, GroundMode, GROUND_MODE_NAMES, groundFieldParams } + from '../../vfx/GroundField.js'; + +GroundMode = { PLATE:0, RUNE:1, POCK:2, RUT:3, WET:4, + PUSTULE:5, FUNNEL:6, SCOUR:7, LATTICE:8, POOL:9 } +GROUND_MODE_NAMES : string[10] +groundFieldParams() -> object + +new GroundField(parent, { mode = GroundMode.PLATE, marks = 12, additive = false, + depthTest = true, layer = LAYER.VFX, + renderOrder = null, name = null }) + // `mode` is a #define — it cannot change after construction. `additive` can, every frame. + +field.object3D -> Mesh field.drawCalls // === 1 +field.mode field.marks field.markCount +field.setVisible(visible) +field.setAdditive(additive) // blend state only, no recompile +field.mark(x, z, time, strength = 1) -> Vector4 // x,z are FRACTIONS of the radius, never metres +field.clearMarks() +field.update(p) +field.dispose() +``` + +### `FilamentPaths.js` + +```js +import { FilamentPaths, PathMode, FilamentPass, filamentLook, + MAX_FILAMENT_ROLES, MAX_CHAIN_NODES } from '../../vfx/FilamentPaths.js'; + +PathMode = { LINE:0, HELIX:1, ORBIT:2, MEANDER:3, RIM:4, + CHAIN:5, LINK:6, SPIRAL_IN:7, CRACK:8 } +FilamentPass = { CORE: 0, GLOW: 1 } +MAX_FILAMENT_ROLES = 4 MAX_CHAIN_NODES = 12 +filamentLook() -> object // canonical look keys + defaults + +new FilamentPaths(parent, { samples = 72, capacity = 48, renderOrder = 11, layer = LAYER.VFX }) + +paths.object3D paths.drawCalls // === 2 paths.liveCount paths.visible (get/set) +paths.role(i) -> Role // i in 0..3 +paths.setNodeCount(n) paths.nodeCount +paths.setNode(i, along, lateral, lift) // unitless fractions +paths.nodePoint(roleIndex, i, out) -> v3 +paths.sync(look, fade = 1, seed = 0) // OVERWRITES uCount — set counts every frame +paths.clear() paths.dispose() + +// Role — every setter is positional and returns nothing; all of it is per-frame. +role.count = n role.retire() +role.style(kink, width, dim, groundDamp) +role.ends(fadeStart, fadeEnd, taperStart, taperEnd) +role.draw(progress, tipLength, floorY, tipGlow) // progress defaults to 2 == "drawn whole" +role.line(from, to, sag, spreadNear, spread, spreadCurve, twist, twistSpeed, converge) +role.helix(from, to, radius, radiusEnd, turns, spin, sag, phaseSpread, taperCurve) +role.orbit(centre, pole, radius, arc, spin, wobble, tilt, tiltSpread, radiusJitter) +role.meander(centre, up, inner, reach, curve, wander, arch, hug, spin) +role.rim(centre, up, radius, span, speed, lift, jitter, hug, phase) +role.chain(from, to, scatter, lift, sag, bow, lit, hold, overlap, tip) +role.link(from, to, slack, curve, swing, swingSpeed, taut, spread) +role.spiralIn(from, to, radius, radiusEnd, turns, spin, curve, phaseSpread, wobble) +role.crack(from, to, angle, lengthFrac, depthFalloff, spread, start, sag, forkBias) +``` + +### `ArcNetwork.js` + +```js +import { ArcNetwork, arcNetworkParams } from '../../vfx/ArcNetwork.js'; + +arcNetworkParams() -> object // graph + hops + clock; also carries filamentLook() + +new ArcNetwork(parent, { paths = null, role = 0, samples = 96, capacity = 24, + renderOrder, layer }) + // pass { paths: existing, role: n } to share another FilamentPaths strip — + // chain + spikes + rim then cost two draw calls in total, not six. + +net.from net.to // Vector3; the caller writes these each frame +net.onNode = (index, position, count) => {} +net.reset(seed) net.reseed(seed) +net.update(dt, p, fade = 1) // NOTE: dt, not now +net.nodePoint(i, out) -> v3 +net.clear() net.dispose() +net.paths net.object3D net.drawCalls net.nodeCount net.segments +net.progress net.arrived net.firedCount net.cursor +``` + +### `Projectile.js` + +```js +import { Projectile, FlightMode, Stagger, spatialStagger, projectileParams } + from '../../vfx/Projectile.js'; + +FlightMode = { LINE, ARC, ROLL, FALL, HOMING, LISSAJOUS, VOLLEY } // string enum +Stagger = { AUTO, NONE, RIPPLE, HASH } // string enum +spatialStagger(x, z, cell, seed) -> 0..1 +projectileParams() -> object + +new Projectile(parent, { geometry, material, shapeKey = null, capacity = 48, trail = true, + trailNodes = 28, trailAdditive = true, layer = LAYER.WORLD, + renderOrder = 2, castShadow = false }) + // The options object has NO default — `new Projectile(parent)` throws. + // `geometry` may be a factory (preferred: the module owns and dresses it) or a geometry. + +body.count body.drawCalls // 2, or 1 with trail:false body.trailUniforms +body.arrivals : Int32Array body.arrivalCount // crossed tau = 1 THIS frame +body.contact : Vector3 body.contactLoad // ROLL -> GroundField(RUT) +body.roll(seed = Math.random() * 100) body.reset() +body.setBasis(origin, direction, side, length) +body.setTrailColors(a, b, c, d) body.syncGeometry() +body.update(now, params) +body.landPoint(index, out) body.pointAt(index, tau, out) +body.headingAt(index, tau, out) body.slotPosition(slot, out) +body.dispose() +``` + +Read `arrivals` / `arrivalCount` **immediately** after `update()`: the flag clears again if a +slider puts a body back in the air, which is what makes a paused drag re-fire it correctly. + +### `Swarm.js` + +```js +import { Swarm, Silhouette, LeadPath, swarmParams } from '../../vfx/Swarm.js'; + +Silhouette = { BIRD:0, LEAF:1, CARD:2, DROPLET:3, MOTE:4 } +LeadPath = { POINT:0, LINE:1, ORBIT:2 } +swarmParams() -> object + +new Swarm(parent, { capacity = 256, silhouette = Silhouette.BIRD, + additive = true, renderOrder = 12 }) + +swarm.count swarm.drawCalls // === 1 swarm.uniforms +swarm.roll(seed = Math.random() * 100) swarm.reset() +swarm.setBasis(origin, direction, side, length) +swarm.setColors(a, b, c, d) // '#rrggbb' or THREE.Color; memoised, no per-frame alloc +swarm.update(_now, params) // first argument is IGNORED +swarm.leadPoint(out) -> v3 +swarm.dispose() +``` + +### `Tube.js` + +```js +import { Tube, TubePath, TubeLayer, TUBE_PATH_NAMES, + tubeDefaults, tubeKeys, tubeSchema, createTubeMaterial } from '../../vfx/Tube.js'; + +TubePath = { STRAIGHT:0, WHIP:1, FUNNEL:2, VINE:3, ARC:4 } +TubeLayer = { CORE:0, SHEATH:1, HALO:2 } +TUBE_PATH_NAMES : string[5] +tubeDefaults(prefix = 'tube', path = TubePath.STRAIGHT, overrides = {}) -> 79-key fragment +tubeKeys(prefix = 'tube') tubeSchema(prefix, path) +createTubeMaterial(layer = TubeLayer.CORE, path = TubePath.STRAIGHT) + +new Tube({ path = TubePath.STRAIGHT, prefix = 'tube', nodes = 96, sides = 26, renderOrder = 11 }) + // WHIP and ARC facet visibly below ~80 nodes; a straight column is fine at 48. + +tube.group tube.materials{core,sheath,halo} tube.meshes{...} tube.keys tube.geometry +tube.drawCalls // === 3 tube.visible (get/set) +tube.sync(c, state, g = settings.global) + // state = { origin, target, side, progress, fade, widthFade, seed, time, grow, snapAge } +tube.radiusAt(t) -> metres // THE profile fn — a dust skirt or scour uses this, never its own maths +tube.pointAt(t, out) -> v3 tube.tangentAt(t, out) -> v3 +tube.span tube.skirtRadius tube.mouthRadius +tube.tipPoint : Vector3 tube.tipSpeed : m/s tube.waveSpeed : m/s +tube.crack = { fired, point, speed, at } // recomputed by sync(); poll immediately after +tube.dispose() +``` + +### `Shell.js` + +```js +import { Shell, ShellMode, SHELL_MODE_NAMES, shellDefaults, shellKeys, shellSchema, + createShellMaterial, BurstMode, BurstSystem } from '../../vfx/Shell.js'; + // BurstMode / BurstSystem are re-exports of ../effects/BurstSphere.js + +ShellMode = { DOME:0, CONE:1, RING_TRAIN:2, SUNDISC:3, PRESSURE:4 } +SHELL_MODE_NAMES : string[5] +shellDefaults(prefix = 'shell', mode = ShellMode.DOME, overrides = {}) -> 44-key fragment +shellKeys(prefix = 'shell') shellSchema(prefix, mode) createShellMaterial(mode) + +new Shell({ mode = ShellMode.DOME, prefix = 'shell', nodes = 48, sides = 48, + rings = 24, segments = 96, renderOrder = 14 }) + +shell.group shell.mesh shell.material shell.keys shell.geometry +shell.drawCalls // === 1 shell.visible (get/set) shell.instanceCount +shell.sync(c, state, g = settings.global) + // state = { origin, axis, side, span, t, fade, seed } +shell.radius shell.span shell.nodeSpacing shell.nodeCount +shell.standingAt(s) shell.nodePosition(i, out) shell.resonantSpacing(n) +shell.dispose() +``` + +### `VolumeHull.js` + +```js +import { VolumeHull, HullShape, Medium, HULL_NAMES, MEDIUM_NAMES, VOLUME_HULL_KEYS, + VOLUME_SAMPLE_BUDGET, volumeHullDefaults, volumeHullSchema, + disposeVolumeHullGeometry } from '../../vfx/VolumeHull.js'; + +HullShape = { BOX:0, CYLINDER:1, CONE:2, DOME:3, SPHERE:4 } +Medium = { FLAME:0, SMOKE:1, ASH:2, SPORE:3, SAND:4, MIST:5, GAS_BOIL:6, VOID:7 } +VOLUME_SAMPLE_BUDGET = 20e6 +volumeHullDefaults(prefix, medium = Medium.SMOKE, overrides = {}) -> prefixed sub-block +volumeHullSchema(prefix, { label, only }) disposeVolumeHullGeometry() // app teardown + +new VolumeHull({ hull, medium, prefix = 'volume', maxSteps = 48, shadow, + additive = false, renderOrder = 12, seed }) + +hull.mesh hull.material hull.steps hull.shadowTaps hull.hull hull.medium hull.prefix +hull.place(position, direction = null) -> this // yaw only; hull local +Z is the heading +hull.setSize(x, y = x, z = x) -> this // HALF-EXTENTS in metres, every frame +hull.setFade(k) -> this // 0 hides the mesh +hull.sync(c, g) -> this // c = settings[id], g = settings.global +hull.cost(coveredPixels) -> field samples/frame +hull.dispose() +``` + +Scale with `setSize()` and **never** `mesh.scale`, or the march's `t` stops meaning metres. Reads +`settings.global.volumeQuality` (defaults to 1 if absent) to scale steps and shadow taps. + +### `Distortion.js` + +```js +import { DistortionField, DistortionMode, DistortionFacing } from '../../vfx/Distortion.js'; + +DistortionMode = { HEAT:0, LENS:1, SHOCK:2, BLADE:3, REFRACT:4 } +DistortionFacing = { BILLBOARD, UPRIGHT, GROUND, WORLD } + +new DistortionField({ mode = DistortionMode.HEAT, facing, geometry = null, + edge = false, renderOrder = 0, name }) + +field.object3D -> Mesh field.visible (get/set) // retains/releases the writer counter +field.setAnchor(v3) field.setAnchorXYZ(x, y, z) +field.setBasis(along, up) // WORLD facing; no matrix is touched +field.update(p) // every key falls back, so update({}) is legal +field.dispose() +``` + +Magnitudes are **screen fractions, not metres** (`perspective`/`perspectiveRef` is the opt-out). +Never multiply `global.distortion` or `post.distortion` into `strength` — the pass applies both, +once. Toggle `field.visible` rather than hiding the parent group, or the pass runs all session. + +### `Portal.js` + +```js +import { Portal } from '../../vfx/Portal.js'; + +new Portal({ billboard = false, writeDepth = false, renderOrder = 6, name = 'Portal' }) + +portal.object3D portal.visible (get/set) +portal.setPlacement(anchor, along, up) +portal.update(p) // every key falls back +portal.dispose() +``` + +### `LiquidSurface.js` + +```js +import { LiquidSurface, LiquidMode, liquidParams } from '../../vfx/LiquidSurface.js'; + +LiquidMode = { POOL: 0, WAVE: 1 } +liquidParams() -> object + +new LiquidSurface({ segments = 96, mode = LiquidMode.POOL, depthWrite = true, + doubleSide = true, renderOrder = 3, name = 'LiquidSurface' }) + +s.object3D s.uniforms s.drawCalls // === 1 s.visible (get/set) s.mode (get/set) +s.setPlacement(anchor, along, up) +s.ripple(u, v, strength = 1, now?) -> slot // u,v are FRACTIONS in -1..1 +s.rippleAtWorld(position, strength = 1, now?) -> slot // call AFTER update() this frame +s.clearRipples() s.reset() +s.update(now, p) +s.lipPosition(p, out, across = 0) -> v3 s.lipHeight(p, across = 0) -> metres +s.dispose() +``` + +Eight ripple slots; `ripple()` evicts the oldest. Records are `(u, v, born, strength)` — fractions +and a timestamp, nothing else. Fill-heavy: one per screen. + +### `Curtain.js` + +```js +import { Curtain, CurtainMode, CurtainLayout, curtainParams } from '../../vfx/Curtain.js'; + +CurtainMode = { RAIN: 0, AURORA: 1, SHAFT: 2 } +CurtainLayout = { LINE: 0, RING: 1, SCATTER: 2 } +curtainParams() -> object + +new Curtain({ capacity = 16, segmentsX = 32, segmentsY = 16, mode = CurtainMode.AURORA, + layout = CurtainLayout.LINE, floor = false, renderOrder = 8, name = 'Curtain' }) + +c.object3D -> Group c.uniforms c.drawCalls // 1, or 2 with floor c.instanceCount +c.visible (get/set) c.mode (get/set) c.layout (get/set) +c.setPlacement(anchor, along, up) +c.roll(seed = Math.random() * 100) c.reset() +c.update(_now, p) // first argument is IGNORED +c.sheetPoint(index, p, out, across = 0, height = 0) -> v3 +c.dispose() +``` + +`alphaCurve` must differ from `emissionCurve` (defaults 2.4 vs 0.7) or it is a hanging ribbon. +`stormwall` passes the cast's **side** vector as `along`; shafts pass a negated `frame.uLightDir` +as `up`. + +### `SceneHooks.js` + +```js +/* SceneHooks.js ─ a ledger, 0 draw calls (2 while HOLE is held) ─ singleton ─ tokens */ +import { sceneHooks, Hook, disruptUniforms, disruptGLSL, gravityUniforms, gravityGLSL, + patchAgeMaterial } from '../../vfx/SceneHooks.js'; + +Hook = { KEY_LIGHT:'keyLight', GRADE:'grade', AGE:'age', HOLE:'hole', + GRAVITY:'gravity', DISRUPT:'disrupt' } + +sceneHooks.acquire(hook, owner) -> token // never null for a real hook; owner is `this` +sceneHooks.isHeld(hook) · driver(hook) · heldCount +sceneHooks.reclaim(owner) -> n · releaseAll() +sceneHooks.gravityAt(x,y,z) -> multiplier (1 when free) // CPU mirrors of the GLSL +sceneHooks.disruptAt(x,y,z) -> 0..1 sceneHooks.ageAt(x,z) -> 0..1 +sceneHooks.observe(material) -> material // park the live state where the pause probe looks +sceneHooks.describe() -> string // readout only +// install() / uninstall() / apply() belong to App. Do not call them from an ability. + +/* every token */ token.blend(0..1) · hold() · release() + token.driving · active · owner · hook +KEY_LIGHT t.aim(azimuth, elevation) · tint('#rrggbb'|Color) · brightness(intensity) +GRADE t.saturate(v) · temper(v) · raise(v) · darken(v) +AGE t.at(x,y,z) | atPoint(v3) · field(radius, edge, amount, inner = 0) + t.wear(rust, dust, moss, pit, bleach) · scale(metres) · colours(rust, dust, moss) +HOLE t.at(x,y,z) | atPoint(v3) · size(radius, squash = 1) +GRAVITY t.at(x,y,z) | atPoint(v3) · well(radius, edge = 0.25) · scale(inside, outside = 1) +DISRUPT t.at(x,y,z) | atPoint(v3) · region(radius, edge = 0.35) + t.power(drain, fracture, dim) · shardSize(pixels) + +/* opting a material into the published fields */ +uniforms: sharedUniforms({ ...disruptUniforms(), ...gravityUniforms() }) // shared boxes, never cloned +vertex: ${disruptGLSL} vDisrupt = disruptAt(worldPos); +fragment: ${disruptGLSL} disruptShade(colour, alpha, vDisrupt, gl_FragCoord.xy); + ${gravityGLSL} float g = gravityScaleAt(worldPos); // exactly 1.0 when nothing is held +patchAgeMaterial(material) // any MeshStandardMaterial; App does the floor +``` + +Acquire through **`this.borrow(sceneHooks.acquire(hook, this))`** and `Ability#destroy()` gives it +back however the cast ends. An owner holds **at most one token per hook** — re-acquiring renews. +Two owners on one hook resolve **LIFO**: the last acquirer drives, the earlier one stays live with +`driving === false` and resumes the frame the top one releases. + +### `LensFlare.js` + +```js +/* LensFlare.js ─ 1 draw call ─ .object3D ─ canonical */ +import { LensFlare, FlareRole, MAX_FLARE_GHOSTS, lensFlareParams } from '../../vfx/LensFlare.js'; + +FlareRole = { CORE:0, STREAK:1, RING:2, GHOST:3 } MAX_FLARE_GHOSTS = 8 +lensFlareParams() -> object // 48 sliders, 9 pickers + +new LensFlare({ ghosts = 8, renderOrder = 3000, layer = LAYER.VFX, name }) + // `ghosts` is the CAPACITY; params.ghosts is how many draw this frame. + +f.object3D -> Mesh f.drawCalls // 1 f.capacity f.visible (get/set) +f.setAnchor(v3) f.setAnchorXYZ(x, y, z) f.anchor(out?) -> v3 +f.update(p) f.dispose() +``` + +### `Mirror.js` + +```js +/* Mirror.js ─ 1 draw call + ONE nested renderer.render() per rendering mirror ─ .object3D */ +import { Mirror, mirrorParams, mirrorBudget, setMirrorBudget } from '../../vfx/Mirror.js'; + +mirrorParams() -> object // 20 sliders, 2 pickers +mirrorBudget = { max: 2, live, rendered, skipped, calls, triangles } // read-only readout +setMirrorBudget(n) + +new Mirror({ resolution = 384, layer = LAYER.VFX, reflectLayer = LAYER.WORLD, renderOrder = 4, + doubleSided = true, depthWrite = false, name }) + +m.object3D -> Mesh m.drawCalls // 1 m.resolution m.visible (get/set) +m.priority m.lastCalls m.lastTriangles // measured, not estimated +m.setPlacement(anchor, normal, along) +m.update(p) m.dispose() +``` + +### `Caustics.js` + +```js +/* Caustics.js ─ 1 draw call ─ parent ─ canonical */ +import { Caustics, CausticSource, CausticShape, CAUSTIC_SOURCE_NAMES, CAUSTIC_SHAPE_NAMES, + CAUSTIC_RIPPLE_SLOTS, CAUSTIC_BOUND_KEYS, causticsParams } from '../../vfx/Caustics.js'; + +CausticSource = { SCROLL:0, WAVE:1, CUSTOM:2 } CausticShape = { DISC:0, CONE:1, LANE:2 } +CAUSTIC_RIPPLE_SLOTS = 8 // === LiquidSurface.RIPPLE_SLOTS; they move together or not at all +causticsParams() -> object + +new Caustics(parent, { source = CausticSource.SCROLL, shape = CausticShape.DISC, custom = '', + uniforms = null, additive = true, depthTest = true, + layer = LAYER.VFX, renderOrder = 7, name = null }) + // CUSTOM throws without `custom`: a chunk defining + // float causticHeight(vec2 xz) and float causticRidge(vec2 xz) + +c.object3D -> Mesh c.drawCalls // 1 c.boundCount c.setVisible(v) +c.bindSource(liquid.uniforms, keys = CAUSTIC_BOUND_KEYS) c.unbindSource() +c.ripple(u, v, strength = 1, now = 0) c.clearRipples() // no-ops while uRipples is bound +c.reset() c.update(p) c.setAdditive(bool) c.dispose() +``` + +### `LightShaft.js` + +```js +/* LightShaft.js ─ 1 draw call ─ parent ─ canonical */ +import { LightShaft, ShaftLayout, SHAFT_LAYOUT_NAMES, lightShaftParams } + from '../../vfx/LightShaft.js'; + +ShaftLayout = { SINGLE:0, LINE:1, RING:2, SCATTER:3 } +lightShaftParams() -> object + +new LightShaft(parent, { capacity = 6, layout = ShaftLayout.SINGLE, sides = 14, maxSteps = 48, + layer = LAYER.VFX, renderOrder = 10, name = null }) + // `maxSteps` is the compile-time cap; `p.steps` is the slider inside it. + +s.object3D -> Mesh s.drawCalls // 1 s.instanceCount s.layout (get/set) s.visible (get/set) +s.setPlacement(anchor, along, up) s.roll(seed = Math.random() * 100) s.reset() +s.update(p) +s.footPoint(index, p, out) -> v3 s.mouthPoint(index, p, out) -> v3 +s.irradianceAt(point, p, out = null) -> 0..1 // multiply into your own motes +s.dispose() +``` + +### `BrushStroke.js` + +```js +/* BrushStroke.js ─ 1 draw call ─ .object3D ─ canonical */ +import { BrushStroke, BrushTip, BRUSH_TIP_NAMES, brushStrokeParams } + from '../../vfx/BrushStroke.js'; + +BrushTip = { FLAT:0, ROUND:1, SPLIT:2 } +brushStrokeParams() -> object + +new BrushStroke(parent, { strokes = 6, bristles = 14, samples = 40, sides = 6, + tip = BrushTip.FLAT, depthWrite = false, + layer = LAYER.VFX, renderOrder = 7, name = null }) + +b.object3D · b.uniforms · b.drawCalls // 1 · b.count · b.strokeCount · b.tip +b.setStrokeCount(n) b.stroke(i) -> Stroke b.retip(tip) b.reset() +b.setPaper(normal) b.setColors(a, b, c, d) b.roll(seed = Math.random() * 100) +b.update(_now, p) // FIRST ARGUMENT IGNORED — p.progress is the only beat +b.pointAt(i, t, out) · tangentAt(i, t, out) · headOf(i) · tipPoint(i, out) +b.pressureOf(i, t) · widthAt(i, t) · dispose() + +/* one stroke */ +stroke.curve(p0, p1, p2, p3) · line(from, to, bow = 0, lift = 0) +stroke.pressure(entry, swell, hold, exit) · ink(load) · timing(start, span) +stroke.active · seed · index +``` + +### `InkDiffusion.js` + +```js +/* InkDiffusion.js ─ 1 draw call ─ .object3D ─ canonical */ +import { InkDiffusion, InkMode, INK_MODE_NAMES, inkDiffusionParams } + from '../../vfx/InkDiffusion.js'; + +InkMode = { BLOOM:0, SPLATTER:1, WASH:2 } // a #define, fixed at construction +inkDiffusionParams() -> object + +new InkDiffusion(parent, { mode = InkMode.BLOOM, sources = 4, satellites = 16, + layer = LAYER.VFX, renderOrder = 6, name = null }) + +k.object3D · k.uniforms · k.drawCalls // 1 · k.age · k.setVisible(v) +k.setPlacement(anchor, along) k.roll(seed = Math.random() * 100) k.reset() +k.update(now, p) +k.frontRadius(i = 0) -> metres · sourcePoint(i, out) -> v3 +k.satelliteSize(i) · satelliteReach(i) · satellitePoint(i, out) · satelliteAge(i) +k.dispose() +``` + +### `FoldMesh.js` + +```js +/* FoldMesh.js ─ 1 draw call ─ parent ─ canonical */ +import { FoldMesh, FoldPattern, FoldLayout, CREASE_PATTERNS, MAX_CREASES, VALLEY, MOUNTAIN, + fanCreases, foldMeshParams, foldMeshSchema } from '../../vfx/FoldMesh.js'; + +FoldPattern = { FLAT:0, DART:1, CRANE:2, FAN:3, UNROLL:4 } +FoldLayout = { LINE:0, ZONE:1, SINGLE:2 } +MAX_CREASES = 12 VALLEY = 1 MOUNTAIN = -1 +fanCreases(count = 8, turns = 0.5) -> crease table // the -2θ/+2θ alternation +foldMeshParams() -> object foldMeshSchema(label = 'Paper') -> editor schema + +new FoldMesh(parent, { pattern = FoldPattern.CRANE, layout = FoldLayout.LINE, capacity = 32, + segments = 20, segmentsV = segments, renderOrder = 4, + layer = LAYER.WORLD, name = 'FoldMesh' }) + +m.uniforms · m.count · m.drawCalls // 1 · m.visible (get/set) · m.layout (get/set) +m.setPattern(pattern) m.setColors(paper, shade, transmit, ink, crease) +m.setBasis(origin, direction, side, length) m.reset() +m.update(_now, p) // FIRST ARGUMENT IGNORED — p.fold is the beat +m.sheetPoint(index, p, out) -> v3 m.spoolPoint(index, p, out) -> v3 +m.dispose() +``` + +### `Dissolve.js` + +```js +/* Dissolve.js ─ 0 draw calls (patch) / 1 (heap) ─ parent ─ canonical */ +import { patchDissolveMaterial, dissolveUniforms, syncDissolve, dissolveParams, dissolveSchema, + DissolveMode, DissolveSpace, DISSOLVE_GLSL, MAX_RUNGS, + DissolveField, heapParams, MAX_LOBES } from '../../vfx/Dissolve.js'; + +DissolveMode = { VOXEL:0, GRANULAR:1, EROSION:2 } DissolveSpace = { LOCAL:0, WORLD:1 } +MAX_RUNGS = 6 MAX_LOBES = 24 +dissolveParams() -> object dissolveSchema(label = 'Dissolve') -> editor schema + +/* the patch — free, no draw call of its own */ +patchDissolveMaterial(material, { mode = DissolveMode.VOXEL, space = DissolveSpace.LOCAL, + uniforms = null, environment = null, + vertex = '', fragment = '' }) -> material +dissolveUniforms(overrides = {}) -> uniform block // share by IDENTITY across materials +syncDissolve(target, p) // every frame; target is the block or a material + +/* the heap — GRANULAR */ +new DissolveField(parent, { along = 72, across = 40, renderOrder = 3, + layer = LAYER.WORLD, name = 'DissolveField' }) +d.uniforms · d.drawCalls // 1 · d.visible (get/set) +d.setBasis(origin, direction, side, length) d.setColors(fresh, settled, face, deep) d.reset() +d.update(now, p) +d.frontPoint(now, p, out) -> v3 d.crestHeight(now, p) -> metres +d.dispose() +``` + +### `TimeControl.js` + +```js +/* TimeControl.js ─ the field costs 0 draw calls; a ghost costs 1 ─ pool + parent ─ canonical */ +import { timeField, TimeField, TimeRegion, MAX_TIME_REGIONS, timeRegionParams, + TimeRecorder, MAX_TRACK_SAMPLES, MAX_TRACK_BONES, recorderParams, + GhostRig, createGhostMaterial, ghostLook, applyGhostLook, findCaster, + TimeWarpClock, RewindGate, reverseTime, reverseRate, reverseParams } + from '../../vfx/TimeControl.js'; +import { timeWarpGLSL } from '../../shaders/lib/timewarp.glsl.js'; + +/* 1 · the field — four slots, shared by every shader that injects the chunk */ +MAX_TIME_REGIONS = 4 timeRegionParams() -> { radius, strength, core, rate } +timeField.acquire(now = frame.uTime.value) -> TimeRegion | null // NULL when all four are taken +timeField.release(region) · reset() · liveCount +timeField.clockAt(clock, worldPos) -> seconds timeField.weightAt(worldPos) -> 0..1 +region.lock(now?) · place(v3) · placeXYZ(x,y,z) · sync(p) · weightAt(v3) · release() +region.isLive · region.hold +// in any shader: ${timeWarpGLSL} float t = warpedTime(uTime, vWorldPos) - uBirth; +// float held = timeRegionWeight(vWorldPos); + +/* 2 · the recorder + the ghosts */ +new TimeRecorder({ capacity = 120, bones = MAX_TRACK_BONES }) +rec.attach(source) · detach() · clear() · sample(now, p) · trim(now, p) +rec.transformAt(t, outPosition, outQuaternion) · poseAt(t, ghost) +rec.boneCount · sampleCount · newest · oldest · span +new GhostRig(parent, { layer = LAYER.VFX, renderOrder = 4, material = null }) +g.setSource(source) // ALLOCATES — a documented I3 exception; call it from createShaders() +g.place(position, heading = 0) · setScale(s) · sync(look) · visible (get/set) +g.drawCalls // 1 per ghost · boneCount · hasSource · dispose() +createGhostMaterial(source = null) · ghostLook() · applyGhostLook(uniforms, look) +findCaster(scene) -> Object3D | null // scene.getObjectByName('Character') + +/* 3 · the reversible clock */ +new TimeWarpClock(start = 0) +clk.reset(start = 0) · advance(dt, rate = 1, floor = -Infinity, ceiling = Infinity) +clk.direction · reversing · stalled · emitDt · spanDt +new RewindGate(); gate.reset() · gate.past · gate.poll(time, mark) +reverseTime(age, p) · reverseRate(age, p) · reverseParams() +``` + +### `Colony.js` + +```js +/* Colony.js ─ 4 classes ─ 1/2/1/1 draw calls ─ parent ─ canonical */ +ColonyShape={BALL:0,WALL:1,SPEAR:2,FIST:3,RING:4,COLUMN:5}; COLONY_SHAPE_NAMES:string[6] +colonySwarmParams() · webGraphParams() · latticeGrowthParams() · plateShellParams() + +new ColonySwarm(parent, {…Swarm options}) // EXTENDS Swarm; splices its vertex shader +c.update(_now,params) // FIRST ARGUMENT IGNORED; calls super.update() first +c.shapeCentre(out) // CPU mirror of the shape's centre + // inherited: count · drawCalls(1) · uniforms · roll · reset · setBasis · setColors · leadPoint + // colonySwarmParams() = swarmParams() + shapeA/B · shapeBlend · condense · + // shapeWidth/Height/Depth · shapeForward/Side/Up · shapeSpin · shapeFill · + // shapeSteps · shapeSlack · shapeRough · waveAmp/Length/Speed/Along · + // cling · floorY · crawlHeight + +new WebGraph(parent,{maxRings=8,maxSpokes=16,samples=10,filmSubdiv=2,additive=false, + renderOrder=11}) +w.drawCalls(2) · count · strands · faces · seed +w.roll(seed) · reset() · setPlacement(anchor,normal,up) // normal is OUT of the plane +w.update(_now,params) // FIRST ARGUMENT IGNORED +w.nodePoint(ring,spoke,p,out) // ring -1 is the hub; omits the per-node jitter +w.dispose() + // rings/spokes are LIVE but structural: changing either rewrites both index buffers + +new LatticeGrowth(parent,{capacity=192,sides=6,wall=0.24,recess=0.62,renderOrder=2, + castShadow=true,receiveShadow=true}) +g.drawCalls(1) · count · cells · capacity +g.reset() · setPlacement(anchor,forward) · update(now,params) // now = ability age +g.cellPoint(index,p,out,height=1) · dispose() + // cells/seed/drift/refuse/climb/outward/layers are structural — they regrow in update() + +new PlateShell(parent,{capacity=64,renderOrder=2,castShadow=true}) +s.drawCalls(1) · count · plates · capacity +s.reset() · setPlacement(anchor,forward) · update(now,params) // now = ability age +s.tessellate(sites,seed,jitter)->plates // update() calls it when sites/seed/jitter change +s.progress(now,p)->0..1 · plateCentre(index,p,out) · dispose() + // MAX_PLATE_SIDES=12 internally; sites is clamped to 12..capacity +``` + +### `HardSurface.js` + +```js +/* HardSurface.js ─ a toolkit, 0 draw calls ─ no attach ─ canonical */ +HardShape={GEAR:0,PISTON:1,SAWBLADE:2,PLATE:3,BOLT:4,ANVIL:5}; HARD_SHAPE_NAMES:string[6] +HardAxis={Y:0,X:1,Z:2}; BrushMode={LINEAR:0,CIRCUMFERENTIAL:1,RADIAL:2}; BRUSH_MODE_NAMES:string[3] +gearShape/pistonShape/sawbladeShape/plateShape/boltShape/anvilShape(overrides)->object +hardShape(kind,overrides)->object // every field is a NUMBER — no metres, ever + +createGeometry(shape)->BufferGeometry // position, normal, aEdge; indexed +hardSurfaceGeometry(kind,shape)->BufferGeometry + // unit space: max(footprint diameter, height) == 1, sits on y=0, axis centred. + // 0.8-9 ms to build. NEVER call it speculatively — see ShapeCache. +gearPitchFraction(shape)->0..1 · gearRootFraction(shape)->0..1 // vs the ACTUAL outer radius + +new ShapeCache({capacity=8}) +cache.get(slot,kind,shape)->BufferGeometry // rebuild only when a number moved; cache owns it +cache.changed · size · dispose() + // per ability, never shared. Feeding GrowthField? Hand it the raw generator instead. + +new GearTrain({capacity=12}); gearTrainParams()->object +train.plant(count,seed)->number · clear() · solve(p) // solve() every frame, dt=0 included +train.count · teethOf(i) · pitchRadiusOf(i) · tipRadiusOf(i) · angleOf(i) · rateOf(i) +train.scaleOf(i,pitchFraction=0)->metres · yawOf(i)->radians +train.positionOf(i,p,out) · contactOf(i,p,out) // contact is the PITCH point + // p.teeth is a live list of unitless counts; p.module is METRES of pitch diameter per tooth + +createHardSurfaceMaterial({environment=null,flatShading=false})->MeshStandardMaterial +syncHardSurfaceMaterial(material,p)->material // == material.userData.sync(p); EVERY frame +hardSurfaceParams()->object · heatToKelvin(heat,p)->K · blackbodyColor(K,out?)->Color + // attributes: aEdge (from the generators) · aHeat (yours, instanced, an OFFSET not an absolute) + // uniforms parked on material.userData.uniforms — the pause test looks there + +new GrindContact(); grindParams()->object +GrindContact.rimVelocity(out,axis,rate,point,centre)->out // v = omega x r +grind.solve(contact,normal,rimVelocity,p)->this +grind.jet(index,emit)->emit // position/direction/speed/spread/speedVariance/inherit +grind.jets · speed · rimSpeed · origin · direction · normal · binormal + // emit's vectors are the solver's scratch: ParticleSystem#emit reads and never retains +``` + +### `quads.js` + +Not a renderer — the two unit quads the library draws on, refcounted once instead of five times. + +```js +import { acquireGroundQuad, releaseGroundQuad, groundQuadRefs, uprightQuad, disposeQuads } + from '../../vfx/quads.js'; + +acquireGroundQuad() -> PlaneGeometry // 1x1 in XZ, normal +Y. Refcounted: release in dispose(). +releaseGroundQuad() // the last release disposes it +groundQuadRefs() -> number // readout only +uprightQuad() -> PlaneGeometry // 1x1 in XY, normal +Z. NOT refcounted, never disposed. +disposeQuads() // teardown only; a live mesh is left holding a dead buffer +``` + +Neither quad carries a metre. Scale the mesh; a quad built at the effect's radius is a dimension +captured in a buffer, which is the one thing I1 exists to prevent. + +### `prefixedBlock.js` + +Not a renderer — the shared plumbing behind the prefixed-key convention, extracted after `Tube`, +`Shell` and `VolumeHull` each independently wrote the same eight lines. You only need it if you are +writing a **new** module that wants two instances per ability. + +```js +import { num, str, prefixed, buildKeys, buildDefaults, auditBlock } + from '../../vfx/prefixedBlock.js'; + +num(value, fallback) -> number // finite-or-default; the NaN test is the point +str(value, fallback) -> string +prefixed(prefix, name) -> string // ('tube','radius') -> 'tubeRadius'; '' passes through +buildKeys(fieldNames, prefix) -> { name: prefixedKey } +buildDefaults(fieldNames, fields, tuning, prefix, overrides = {}) -> settings fragment +auditBlock(label, keys, fieldNames, block, remedy) -> string[] // warns once, names every gap +``` + +--- + +## Budgeting a cast + +`docs/EXPANSION.md` §0 I7: **≤ 12 draw calls, ≤ 1500 live particles, ≤ 1 dynamic light** per cast, +and the manager caps at four concurrent casts. Every module's `drawCalls` getter reports its real +cost, and `npm run check` fails any single module that exceeds the whole-ability budget on its own. + +Some combinations that fit, for calibration: + +| cast | modules | draw calls | +| --- | --- | --- | +| a beam | `Tube(STRAIGHT)` | 3 | +| a tornado | `Tube(FUNNEL)` + `GroundField(SCOUR)` + `Swarm(LEAF)` | 5 | +| a shockwave | `Shell(PRESSURE)` + `DistortionField(SHOCK)` + `GroundField(POCK)` | 3 | +| a chained bolt | `FilamentPaths` + `ArcNetwork` sharing its strip | 2 | +| an ice field | `GrowthField` (3 variants) + `ShatterField` (2) + `GroundField(PLATE)` | 6 | +| a portal | `Portal` + `DistortionField(LENS)` + `Curtain(AURORA, floor)` | 4 | +| a flooded lane | `LiquidSurface(WAVE)` + `Caustics(WAVE)` bound to it + `GroundField(WET)` | 3 | +| a shaft of light | `LightShaft(LINE)` + `LensFlare` + one particle system read through `irradianceAt` | 2 | +| a forge strike | `GrowthField` (2 variants, `HardSurface` gears) + `Shell(RING)` + sparks | 4 | +| a stasis bubble | `timeField` (0) + `Shell(DOME)` + `DistortionField(LENS)` | 2 | +| a sealscript | `BrushStroke` + `InkDiffusion(BLOOM)` + `GroundField(RUNE)` | 3 | +| a hive dome | `PlateShell` + `ColonySwarm` + `WebGraph` | 4 | + +Three costs are not draw calls and are easy to miss. A **`Mirror`** adds one nested +`renderer.render()` of `LAYER.WORLD` per frame it is visible — capped at two mirrors a frame by +`mirrorBudget`, and it reports what it actually cost. A **`Caustics(SCROLL)`** is fill-bound at +around a hundred hashes a pixel; treat it like `LiquidSurface`, one per screen (`WAVE` is much +cheaper). A **`GhostRig`** is one draw call but roughly seventy `Object3D`s of skeleton, and N +poses genuinely cannot be one draw call. + +`ArcNetwork` costs **nothing** if you hand it a `FilamentPaths` you already have and a spare role +index — that is the single cheapest saving in the library, and there are only four role slots. + +## Verifying your module + +`npm run check` covers `src/vfx/` in its own stage (§10 of `scripts/check.mjs`). It constructs every +exported class with reasonable defaults, sweeps every mode enum, drives each for six frames plus a +zero-length one, scans every transform, instance attribute and uniform for NaN, checks the draw-call +budget, and calls `dispose()`. + +**This is the only thing that compiles these files at all.** `npm run build` bundles from +`index.html`, and until an ability imports a module nothing in `src/vfx/` is in the graph — a syntax +error in an unimported module builds perfectly and passes every ability test. Two modules were +sitting broken exactly that way when this stage was added. + +The coverage rule at the end of the stage is deliberate: **every exported class in every file under +`src/vfx/` must have a case**. Add a module without a case and the check fails, naming it. + +Two sweeps run alongside it and cover the ground a case cannot: + +- **The GLSL reserved-word sweep** reads every `/* glsl */` block under `src/` as text and fails on + an identifier that is reserved at `#version 300 es`. It is a text sweep rather than a walk over + constructed materials because the two failures it exists to catch both live in source no + constructed material carries — a `patchOnBeforeCompile` body, which needs a renderer before it is + ever assembled, and a shader behind a `#define` the run's options did not select. See trap 2. +- **The borrowed-globals check** fails an ability whose `destroy()` left a scene hook held or a time + region live. See trap 10. + +``` +npm run check # everything, including the vfx stage +npm run check -- --only ice # one ability; skips the vfx stage +npm run check -- --only ice --vfx # ...and puts it back +``` + +## Traps that have already cost someone a day + +Every one of these was a real bug in this library, found by hand or by the harness. + +1. **A backtick inside a GLSL comment ends the template literal.** It reports as + `SyntaxError: Unexpected identifier` pointing into the middle of your shader. Cost two agents a + round-trip each, including the one who had just written the warning about it. +2. **Reserved words — and the list is the GLSL ES 3.00 one, not the 1.00 one.** This is worth + getting right because the advice here used to be wrong in both directions, and the wrong half + cost the whole ground material. + + three's `WebGLProgram` prepends `#version 300 es` to **everything that is not a + `RawShaderMaterial`** — every built-in material and every plain `ShaderMaterial` in this + project — and `#define`s `varying`, `attribute` and `texture2D` so that source written in the + 1.00 dialect still compiles. Two consequences, both counter-intuitive: + + - `packed` is illegal in ESSL 1.00 and **legal** in 3.00. `VolumetricFireMaterial` has had a + `float packed` in it since the beginning and it compiles. + - `patch` is legal in 1.00 and **reserved** in 3.00. A `float patch` in the floor's ageing + patch took the entire ground material out of the frame — no floor, no shadow, one console + error deep in a two-thousand-line generated shader that says `syntax error` and never says + *why* the word is special. + + The list is now **checked in `npm run check`**, over every `/* glsl */` block under `src/`, so + this cannot happen a third time. It was built by compiling every candidate at `#version 300 es` + on a real driver as a local, a parameter and a function name, and keeping only the words that + failed all three. In practice the ones you will reach for are `patch`, `sample`, `filter`, + `input`, `output`, `flat`, `smooth`, `layout`, `common`, `active`, `this`, `interface`. + + Shadowing a built-in *function* with a local is fine — `float round = …` compiles — but + **defining** a function with a built-in's name does not, whatever its signature. +3. **Uniform arrays may be indexed only by a loop counter** — the obvious `uFrom[role]` does not + compile on ANGLE. `FilamentPaths` loops with `if (float(i) == role)` for exactly that reason; do + not "simplify" it. (`cosh` is present in 3.00 and the old warning about it is stale, but the + `coshf` helpers already written against it are harmless and stay.) +4. **Value noise piles up at 0.5**, so a ridged octave `1 - |2n - 1|` piles up at 1 and your embers + cover the whole cloud. `VolumeHull` uses a hashed-lattice `speck()` instead. +5. **A patched `MeshStandardMaterial` must park its uniform boxes on `material.userData.uniforms`** + (the `IceMaterial` / `MeteorMaterial` convention) or the harness's pause test cannot see them and + will report thirty working sliders as dead. +6. **`frame.uSceneColor` does not exist yet.** `ShatterField.sync()` polls for it and degrades by + `#define`, not by branch. Whoever adds a scene-colour copy to `core/FrameUniforms.js` gets + refraction in every `ShatterField` for free — but must not point it at the composer's write + target, which is a feedback loop. +7. **The fragment `softFade`s against `frame.uSceneDepth`.** With nothing bound it discards + everything: fine in the app, a trap in a bespoke test harness that renders one mesh. +8. **`frame.uEnvMap` is `null` until `App` sets `environment.equirect`.** `LiquidSurface` and + `Curtain` sample it unconditionally, as `GlacierMaterial` does. +9. **`commonGLSL` does not compile in a vertex shader.** It carries `aastep`, which calls `fwidth`, + and derivative functions do not exist there; the program fails on a line you are not using. + Inject **`commonVertexGLSL`** instead — the same chunk minus `aastep` and `softFade`, with its + own include guard, so a material that wants both stages can spread both. +10. **A global borrowed in `onSpawn()` must be given back in `onDestroy()`** — a scene hook, a time + region, anything with an `acquire()`. Wrap the acquisition in `this.borrow(...)` and the base + class returns it however the cast ends, including the three ways that are not the ability's + idea (the player pressing **C**, a fifth cast pushing it off the concurrency cap, teardown). + `npm run check` fails an ability that leaves one held. + +--- + + +## `GrowthField.js` — things that come out of the ground + +`IceAbility`'s crystal field with the ice taken out of it. N instanced copies of a caller-supplied +procedural geometry, placed along a **LINE** band or across a **ZONE** annulus, each erupting on its +own staggered clock with an overshoot, a settle and a birth flash. + +**It does not own a material.** The ability supplies the geometry factory *and* the material, which +is why rime plates, thorns, bone spears, heaved slabs and petals can share every line of the file +and look nothing like each other. + +### Draw-call cost + +`variants` — one `InstancedMesh` per silhouette. **3 by default, and 3 is the recommended number**: +per-instance scaling buys proportion variety, but only distinct geometry buys *facet* variety, and a +field of one shape scaled forty ways reads as a repeated prop the moment the camera moves. Drop to +`variants: 1` for anything that is genuinely a single object. + +### Signatures + +```js +import { + GrowthField, GrowthLayout, GrowthEmerge, growthParams, patchGrowthMaterial +} from '../vfx/GrowthField.js'; + +GrowthLayout = { LINE: 0, ZONE: 1 } // where the field is laid out +GrowthEmerge = { PUSH: 0, SCALE: 1 } // punches out of the floor / accretes in place + +new GrowthField(parent, { + geometry, // (variant: number, shape: object) => BufferGeometry REQUIRED + material, // THREE.Material, owned by the ability REQUIRED + shape = null, // first shape params; also primes the rebuild hash + variants = 3, + capacity = 288, // hard ceiling on instances per cast + layer = LAYER.WORLD, + renderOrder = 2, + castShadow = true, + receiveShadow = true +}) + +field.onBreach = (index, position, radius, height) => {} // assign ONCE, never per frame +field.count // instances planted → Ability#instanceCount +field.drawCalls // === variants + +field.plant(count, clusterShare = 0) → number // onSpawn(); the only dice roll +field.clear() // onDestroy() +field.syncGeometry(shape) → boolean // rebuilds only when a number moved + +field.triggerUpTo(now, limit, stagger, frontBias = 1, includeCluster = false) // LINE front +field.triggerRadial(now, limit, stagger, invert = false, includeCluster = true) // ZONE front +field.triggerAll(now, stagger) +field.triggerIndex(now, index, delay = 0) +field.isFullyTriggered // boolean + +field.update(now, params, retract = 0) // rebuild every matrix, allocation-free + +field.positionOf(index, params, out) → Vector3 // live read-back, for threading things between +field.tipOf(index, params, out) → Vector3 // instances (Thornwake's interlacing vines) +field.heightOf(index, params) → metres +field.radiusOf(index, params) → metres +field.emergenceOf(index, now, params) → 0..1+, negative while buried + +field.dispose() // geometry + meshes; the material is yours + +growthParams() → object // every canonical key with its default and a unit comment +patchGrowthMaterial(material, { environment, uniforms, common, vertex, fragment }) → material +``` + +`now` is always the ability's `age` in seconds. `params` is read fresh on every call; see +`growthParams()` in the source for the full key list — footprint (`widthNear`/`width`/`widthCurve`/ +`clumping`/`scatter`/`frontBias`, or `radius`/`innerRadius`/`radialCurve` in ZONE), silhouette +(`heightNear`/`height`/`heightCurve`/`crown`/`peak`/`rubble`), the individual body (`radiusNear`/ +`radius2`/`lean`/`twist`/`tilt`/`baseHeight`) and the eruption (`riseTime`/`riseOvershoot`/`settle`/ +`birthFade`/`emergeSink`). + +### The geometry factory contract + +`factory(variant, shape)` must return geometry in **unit space**: footprint inside a circle of +radius 0.5 on `y = 0`, tip at `y = 1`. An instance then scales footprint and height independently, +and `local.y` reads straight off in the fragment shader as "how far up this thing am I" — which is +what every base-to-tip gradient keys off. `assets/ProceduralGeometry.js#createCrystalGeometry` is +the reference implementation. + +### The material helper + +`patchGrowthMaterial()` is `materials/IceMaterial.js`'s patch reduced to its skeleton. Pass +`environment` and it routes through `registerShadowCasterWithPatch`, so CSM's own patch is not +clobbered. Your fragment snippet is injected after `` — it *must* go there, +because with `flatShading` there is no `vNormal` varying and the face normal only exists after that +include. Available varyings: + +| varying | meaning | +| --- | --- | +| `vGrowLocal` | unit-space position — `.y` is 0 at the base, 1 at the tip | +| `vGrowWorld` | world position, for detail that must keep a fixed physical size | +| `vGrowSeed` | per-instance seed, 0..10 | +| `vGrowBirth` | per-instance birth flash, 1 → 0 over `birthFade` | + +Picking the wrong space is the usual bug: cracks and grain belong in **world** space so neighbouring +instances look quarried from the same block; anything that runs base-to-tip belongs in **local** +space so it follows each instance's own axis however it is scaled and leaned. + +### The one rule + +**Fill the params from `settings[id]` every frame and never keep a resolved metre between frames.** +The records hold dice and one timestamp; that is the whole reason a paused field re-grows under the +slider. `frontBias` is a live exponent here rather than baked into the placement roll the way +`IceAbility` bakes it, so dragging it moves a field that is already standing — the one place this +module improves on the thing it came from. + +--- + +## `ShatterField.js` — things that come apart + +Instanced fragments that inherit a velocity and tumble under gravity, fading and shrinking. Fragment +geometry is caller-supplied (shards, panes, prism chips). Optional **screen-space scene sample**: a +shard that shows a distorted, desaturated copy of what is behind it. + +Nothing here integrates. A fragment's position is a closed-form function of `now − born` evaluated +against the live params — with drag, `p(t) = p₀ + (v₀ − g/k)(1 − e^{−kt})/k + (g/k)·t`, falling back +to the ballistic form below `k ≈ 0`. That is not a micro-optimisation, it is invariant I1: an Euler +integrator has already spent the old gravity and physically cannot re-fly a fragment when you drag +the slider with the clock stopped. + +### Draw-call cost + +`variants` — one `InstancedMesh` per fragment shape. **2 by default**; one is fine, three is +usually indulgent for debris that is on screen for a second. + +### Signatures + +```js +import { ShatterField, ShatterLayout, shatterParams } from '../vfx/ShatterField.js'; + +ShatterLayout = GrowthLayout // { LINE: 0, ZONE: 1 }, re-exported so you need one import + +new ShatterField(parent, { + geometry, // (variant: number) => BufferGeometry REQUIRED + variants = 2, + capacity = 192, // hard ceiling on live fragments (ring-allocated) + material = null, // supply one to bypass the built-in shader entirely; + // it must consume `aSeed` and `aLife` itself + additive = false, + depthWrite = true, + layer = LAYER.VFX, + renderOrder = 6, + castShadow = false, // a shadow per fragment costs more than it reads + receiveShadow = false +}) + +shatter.count // live fragments → Ability#instanceCount +shatter.drawCalls // === variants +shatter.uniforms // the built-in material's uniforms, if you did not supply your own + +shatter.burst(now, count, along = 1, lateral = 0) → number // the only dice roll +shatter.clear() +shatter.sync(look) // colours + shading, from live settings +shatter.setSceneTexture(texture | null) // bind/unbind the screen-space sample +shatter.update(now, params) → live count // re-fly everything, allocation-free +shatter.positionOf(index, now, params, out) → Vector3 +shatter.dispose() + +shatterParams() → object // every canonical key with its default and a unit comment +``` + +`params` covers the basis (`origin`/`direction`/`side`/`length`, or `centre`/`radius` in ZONE), the +throw (`speed`/`speedJitter`/`spread`/`upBias`/`inherit`/`inheritScale`), the flight +(`gravity`/`drag`), the body (`size`/`sizeJitter`/`shrink`/`shrinkPower`/`spin`/`spinJitter`), the +clock (`lifetime`) and the floor (`floor`/`floorSpin`). + +`look` for `sync()` is `colorA`, `colorB`, `colorEdge`, `colorScene` (THREE.Color — four independent +pickers, none derived from another, per I5) plus `opacity`, `glow`, `rim`, `rimPower`, `shade`, +`ambient`, `fadeStart`, `soft`, `sceneMix`, `refract`, `saturation`. + +### The screen-space sample, and how it degrades + +The repo **does not currently expose a read buffer**: `PostProcessing` runs an `EffectComposer` +whose ping-pong targets are internal, and sampling the target you are drawing into is a feedback +loop, not a refraction. So: + +- with no texture bound, `SHATTER_SCENE` is undefined and the sampler is not in the compiled shader + at all — the shard is its solid tint, and nothing renders black; +- `sync()` polls `frame.uSceneColor` (which does not exist today) so the day someone adds a scene + copy to `core/FrameUniforms.js`, every ShatterField starts refracting with no change here; +- `setSceneTexture(tex)` binds one by hand and takes precedence over that poll. + +Either way the define flips **once**, on the transition, and the material recompiles then — never +per frame. + +### The one rule + +**Fragments are debris, so give them somewhere to have come from.** Pass the live velocity of +whatever broke as `inherit` and keep `spread` low. A burst at `spread: 1` is a firework; a burst at +`spread: 0.35` with an inherited velocity is a thing that shattered. + +--- + +## `GroundField.js` — one quad that thinks in metres + +The snare's burnt field and the two targeting indicators, generalised into ten substances. One +ground quad, anchored at a point, whose fragment shader remaps UV into **metres from that anchor**, +so every control below is a real measurement: the meniscus on a pool stays 4 cm wide whether the +pool is 1 m or 6 m across, and a seal three metres wider carries *more* runes rather than bigger +ones. + +It exists because of one sentence in `GroundDecals.js`: `spawn()` writes `uRadius` **once**. A +pooled decal captures its radius the moment it lands, so a crater already on the floor cannot hear a +slider move — which is fine for a scorch mark that lives for two seconds and wrong for anything an +ability is still holding. Everything here is re-resolved from the live params on every `update()`, +including a zero-length frame. Pause, drag `radius`, and the mark, its grain, its relief and its +growth front all re-scale together. That is invariant **I1**, and it is the whole reason the module +is a mesh rather than a decal type. + +### Draw-call cost + +**One.** Always one, in every mode, at any radius, with any number of craters in it. The mode is a +`#define`, so a program only ever carries the branch it needs and the RUNE alphabet is not compiled +into the other nine. + +### The modes + +Ten, and none of them is another one recoloured: + +| mode | the substance | +| --- | --- | +| `PLATE` | interlocking sheet-ice plates, each lifting and curling along whichever edge is downwind | +| `RUNE` | a whole inscribed seal: nested counter-rotating rings of signed-distance **glyphs** — strokes, terminals and counters — plus a chord armature, a tick collar and a central rosette sigil, all inking themselves stroke by stroke and igniting from the inside out | +| `POCK` | impact craters accumulating from a list of unitless hits, bowls unioned and rims summed | +| `RUT` | a gouged track behind a rolling body, depth following the contact force it posted | +| `WET` | darkened, reflective stone that dries from the edges in | +| `PUSTULE` | cellular blisters that inflate and burst on individual timers | +| `FUNNEL` | an inverted cone read as depth off a faked normal and a parallax offset — the sinkhole | +| `SCOUR` | log-spiral scour grooves under a vortex | +| `LATTICE` | a hex lattice that propagates **along its own edges**, cell by cell | +| `POOL` | standing liquid with a meniscus rim and a slow flow-warped surface | + +### Signatures + +```js +import { GroundField, GroundMode, GROUND_MODE_NAMES, groundFieldParams } from '../../vfx/GroundField.js'; + +new GroundField(parent, { + mode: GroundMode.RUNE, // fixed for the lifetime — it is a #define + marks: 12, // POCK / RUT: how many events the shader carries + additive: false, // initial blend; params.additive drives it after + depthTest: true, // false lets the soft fade do all the occlusion + layer, renderOrder, name +}); + +field.object3D // the Mesh +field.drawCalls // 1 +field.markCount +field.setVisible(visible) +field.setAdditive(additive) // free — blend state, not a program +field.mark(x, z, time, strength = 1) → Vector4 // a dice roll and a timestamp, nothing else +field.clearMarks() +field.update(params) // re-resolve everything, allocation-free +field.dispose() + +groundFieldParams() → object // every canonical key with its default and a unit comment +``` + +`mark()` is the only place a cast records anything, and it records **unitless** data: `x`/`z` are +fractions of the radius in the anchor's frame (for `RUT`, `z` is 0..1 along the track), `time` is +the timestamp the event fired at, and `strength` is 0..1. Nothing carries a metre, so the craters +re-place *and* re-scale themselves when the radius slider moves. Past `marks` events the list +recycles oldest-first out of a ring buffer that was allocated in the constructor. + +`params` covers the anchor (`centre`/`yaw`/`height`/`radius`/`length`), the beats +(`grow`/`recede`/`progress`/`inscribe`/`ignite`/`fade`/`seed` — all unitless, all resolved by the +ability's own clock), the front (`edge`/`ragged`/`raggedScale`/`warp`), the lighting +(`relief`/`normalStep`/`ambient`/`wrap`/`specular`/`gloss`/`parallax`), the shape vocabulary below, +the seal (`rings`/`ringInner`/`glyphSize`/`glyphStroke`/`glyphGap`/`spin`/`spinFalloff`/`rule`, plus +the armature, collar and sigil groups described below), the +events (`markLife`/`markRadius`) and the output (`additive`/`emissive`/`opacity`/`depthFade` plus +four colour pickers, none derived from another, per I5). + +### The shared shape vocabulary + +Ten modes with private parameter names would be four hundred uniforms. There is one vocabulary of +measurements instead, and each mode says what it does with each of them — the full table is in the +doc comment on `groundFieldParams()`. The short version: `cell` is a pitch in metres, `seam` a gap, +`thickness`/`lift`/`depth` are how thick, how far proud and how far down, `width` is a track's +half-width, `swirl`/`arms` shape rotational marks, and `speed`/`flow`/`windAngle` are the clocks and +the direction things drift in. + +### The three things every mode shares + +**A ragged front, warped in the plane.** The growth boundary is a noise field sampled in 2D and +domain warped, never on `atan(y, x)`. An angular lookup hands every radius along a bearing the same +value, which draws dead-straight spokes out of the centre — a firework, not a spreading substance. +The main README documents that mistake twice, on the bolt's ground burn and on the snare's field, +because it was made twice. There are exactly two deliberate angular samples in the file: the SCOUR +spiral, whose marks genuinely *are* rotational, and the compass sweep that draws the rune circles — +a compass is an angular instrument. + +**A fake normal off a real height field.** Each mode publishes metres of relief; the shared path +takes two forward differences and lights the result with `frame.uLightDir`, the same key direction +the lit meshes use, rotated into the quad's frame in the vertex shader so a yawed anchor is not lit +from a different side of the room. Without it every one of these is a sticker. `RUNE` skips the +taps entirely: a signed distance field already knows which way is out, so `gfSeg`/`gfArc` hand back +the gradient of the winning stroke and the incision is bevelled and lit off **one** evaluation — +three taps through twenty-four strokes, four rings, an armature and a sigil would have been the most +expensive fragment in the project by a wide margin. + +### The seal, and what `runeseal` added to it + +`RUNE` shipped as rings of glyphs. `runeseal` — the roster's showpiece ground shader, and the one +slot whose brief is "it must survive being paused and stared at" — needed more than rings, so the +mode grew three pieces of furniture, a longer alphabet and a burn-down. All of it is **off by +default**: a caller who asks for `RUNE` and sets nothing gets exactly the rings it always drew. + +- **Twenty-four strokes, twenty-four marks** (was eighteen and sixteen). Three families were + missing, and all three are things a script has that a set of runes usually does not: asymmetric + arms, so a mark can be left- or right-handed rather than always balanced; barbs, so a terminal can + flick rather than stop; and off-centre counters, so an enclosed space can sit above or below the + waist. At sixteen marks a slow orbit of a five-metre seal walks you past the same letterform five + times. **Twenty-four is a ceiling, not a round number**: a stroke set is a bit field in a float, a + highp float holds integers exactly to 2²⁴, and a twenty-fifth stroke does not fail to compile — it + silently drops on some hardware and not others. A twenty-fifth stroke needs a second code word. +- **The armature** (`armStart`/`armRadius`/`armSides`/`armTangent`/`armStroke`/`armPhase`/`armSpin`) + — chords whose ends land on a circle and which pass at a chosen distance from the anchor. One + parameterisation, two figures: at `armTangent = cos(PI / sides)` the chords close into an + inscribed polygon, below that they cross into a star, and at *any* value they are simultaneously + tangents to the inner circle of that radius. The fragment sweeps seven candidate chords rather + than folding to the nearest one, because folding truncates a star to disconnected stubs; measured + by walking each chord's centre line, seven holds the figure whole down to `armTangent = 0` at up + to twelve chords, and needs about 0.2 at sixteen. +- **The tick collar** (`tickCount`/`tickRadius`/`tickLength`/`tickStroke`/`tickMajor`/`tickMajorLen`) + — the difference between a magic circle and an instrument. Division is what makes a drawing look + measured out rather than decorated. One segment per fragment: ticks are radial and short, so the + nearest bearing really is the nearest tick. +- **The central sigil** (`sigilStart`/`sigilRadius`/`sigilSize`/`sigilArms`/`sigilStroke`/ + `sigilRing`/`sigilSpin`) — one glyph at its own em box, folded into N-fold rotational symmetry + about the anchor and ruled inside a circle. The fold is the economy: one evaluation of the + alphabet buys every arm. Its gradient needs rotating twice on the way out — out of the em box and + out of the sector — and skipping the second lights one arm correctly and the rest from the wrong + side. +- **`scorch`** — 0..1, takes the fire back out of the ink and leaves the writing as a scar, so a + seal can burn down rather than fade out. + +`inscribe` is still one clock, and `armStart`/`sigilStart` cut it into three movements: rings, then +armature and collar, then sigil — which is how the biggest mark on the floor ends up being the last +thing you watch finish. **A slice of zero length is the off switch**, which is why both default +to 1. `runeseal` sets them to 0.5 and 0.72. + +Two things worth knowing before editing this branch: + +- **The stroke ink is antialiased with `aastep` fed a distance in *pixels*, not metres**, so the edge + resolves in about one pixel at any zoom — which is the entire requirement. It is handed + `clamp(sd / pxp, -6, 6)`, and the clamp is load-bearing: the ring loop skips bands with + `continue`, two fragments of one 2×2 quad can leave it holding `1e4` and `0.01`, and `fwidth` of + that spreads `aastep`'s transition across the whole range and paints a bright grey pixel along + every band boundary — a fault that only ever shows up when somebody pauses and stares. `pxp` is + `fwidth(p)`, the metric **in the plane**, taken before the first discard. The radial `fwidth(d)` + the other modes use is the wrong measure for a stroke that points at the anchor, where `d` barely + changes: those strokes, and only those, came out with a hard crawling edge. +- **`packed` was a local in the depth fade** and is a future-reserved word in GLSL ES, so every mode + of this shader would have failed to compile on ANGLE. `commonGLSL#softFade` made the same mistake + and renamed to `depthBits`; so has this. It survived because the reserved-word scanner in + `scripts/` only audits `Distortion` and `Portal`, and nothing had put a `GroundField` in front of a + browser yet. + +**Both blend modes.** The main README's rough-edges list ends on "both the targeting circle and the +snare's field are additive, so the footprint brightens the floor rather than shading it". This +closes that: `additive: false` alpha-blends, so wet flagstones, a hole and a pool of blood come out +genuinely *darker* than the floor they lie on. `WET` is the argument for the option existing — a +soaked stone that adds light is a lit stone. Blend state is not compiled into the program, so +`additive` is a live checkbox rather than a construction decision. + +There is a fourth, quieter one: the soft depth fade. The depth prepass is half resolution, so its +silhouettes are a pixel or two soft, and that is exactly the band worth feathering where the +character's legs meet the mark. Turn `depthTest` off at construction and the same term does all the +occlusion instead of a hard z-test. + +### The glyph alphabet is authored in JavaScript + +`GLYPH_STROKES` is an eighteen-stroke skeleton — six half-uprights, three bars, two stem halves, +four diagonals, two bowls, a left and a right bowl and a counter — and `GLYPH_ALPHABET` is sixteen +bit-sets over it. Both are plain data at the top of the file, and the GLSL that walks them is +**generated at module load** as straight-line code. + +That is not a shortcut, it is the only portable way to do it: GLSL ES 1.00 forbids indexing an array +with anything but a constant expression, so the obvious `STROKES[glyph * 18 + i]` table will not +compile on half the targets three supports. Unrolling removes the array and the dynamic index, and +leaves the alphabet readable where it is data instead of smeared through a shader as magic numbers. +Adding a seventeenth rune is one line of JavaScript. + +Sixteen letterforms is enough that three rings of a 5 m seal read as "a script I do not know" +rather than as a pattern repeating. Stroke width swells toward the middle and tapers into the ends, +which is what gives them terminals instead of sawn-off tubes; several have counters, because a +stroke-only alphabet with no enclosed space reads as scaffolding. The inking clock divides evenly +over the strokes a glyph actually has, so a three-stroke mark and a five-stroke mark finish +together, and glyphs are inked ring by ring outward and slot by slot around each ring. + +### The one rule + +**Never cache the radius.** Fill a module-scope params object from `settings[id]` every frame and +hand it to `update()`; an object literal per frame is the allocation I3 forbids, and a metre kept +between frames is the invariant I1 forbids. If a mark on your floor stops responding to its slider, +you have written down a dimension somewhere — and the module you wanted instead was +`DecalSystem.spawn()`, which is allowed to, and does. + +--- + +## `FilamentPaths.js` — every filament in the project + +`LightningMaterial` proved the shape of the idea: a vertex arrives as `(t, side)` — how far along +its filament it is and which edge of the ribbon it is on — and leaves as a world position, so *no +path exists on the CPU to go stale*. `SnareMaterial` proved the generalisation: a filament's +**role** is decided in the vertex shader by testing its instance index against a set of live +counts, and the role picks which parametric path it is threaded along, so one strip draws a whip, a +pillar, a crawl of tendrils and a ring of travelling arcs at once — and setting a count to zero +retires that role outright, which is how the snare's leash vanishes on the frame the ring takes +over. + +This is that second idea with the snare taken out of it. Nine path modes, four role slots, one +`InstancedBufferGeometry` (`createBoltRibbonGeometry`, the same strip the bolt and the beam's coils +are drawn on), and every metre re-read from the caller's params each frame. + +### Draw-call cost + +**2. Always.** Four roles or one, forty filaments or three. The strip is drawn twice — a wide soft +halo underneath and the hot core on top — because drawing the glow as *real ribbon* rather than +leaving it to bloom is what keeps it attached to every kink, and it is most of why the original +bolt reads at any distance. Both passes share every uniform box by identity except `uWidthScale` +and `uPassOpacity`, so they cannot disagree about where a filament is. + +The budget that shaped the module is the uniform one: the per-role blocks and the chain node table +come to roughly 90 of the **128 vertex uniform vectors WebGL guarantees**. That is why there are +four role slots and twelve chain nodes and not eight and thirty-two. If you need a fifth role you +want a second `FilamentPaths` — two more draw calls — not a wider array. + +### Signatures + +```js +import { + FilamentPaths, PathMode, FilamentPass, filamentLook, + MAX_FILAMENT_ROLES, MAX_CHAIN_NODES +} from '../vfx/FilamentPaths.js'; + +PathMode = { LINE: 0, HELIX: 1, ORBIT: 2, MEANDER: 3, RIM: 4, + CHAIN: 5, LINK: 6, SPIRAL_IN: 7, CRACK: 8 } +MAX_FILAMENT_ROLES = 4 +MAX_CHAIN_NODES = 12 + +new FilamentPaths(parent, { + samples = 72, // nodes along one filament — the ceiling on kink detail + capacity = 48, // hard ceiling on filaments across all roles + renderOrder = 11, // halo here, core at +2 + layer = LAYER.VFX +}) + +paths.role(i) → FilamentRole // i in 0..3 +paths.object3D // the Group holding both meshes +paths.drawCalls // === 2 +paths.liveCount // filaments drawn this frame → Ability#instanceCount +paths.visible // get/set + +paths.setNodeCount(n) → n // CHAIN only, clamped 2..MAX_CHAIN_NODES +paths.setNode(i, along, lateral, lift) // UNITLESS fractions only +paths.nodePoint(roleIndex, i, out) → Vector3 // where the shader will actually put node i + +paths.sync(look, fade = 1, seed = 0) // every frame, including a zero-length one +paths.clear() // onDestroy(); leaves it reusable +paths.dispose() + +filamentLook() → object // every canonical key with its default and a unit comment +``` + +A role, whose setters are all positional and all write straight into the uniform vectors — there is +no options object anywhere in the frame path, because an object literal per role per frame is +exactly the allocation **I3** forbids: + +```js +role.count = n // 0 retires the role +role.retire() + +role.style(kink, width, dim, groundDamp) // per-role modifiers on the shared look +role.ends(fadeStart, fadeEnd, taperStart, taperEnd) // 0 square, 1 tapered to nothing +role.draw(progress, tipLength, floorY, tipGlow) // the travelling front and the floor + +role.line(from, to, sag, spreadNear, spread, spreadCurve, twist, twistSpeed, converge) +role.helix(from, to, radius, radiusEnd, turns, spin, sag, phaseSpread, taperCurve) +role.orbit(centre, pole, radius, arc, spin, wobble, tilt, tiltSpread, radiusJitter) +role.meander(centre, up, inner, reach, curve, wander, arch, hug, spin) +role.rim(centre, up, radius, span, speed, lift, jitter, hug, phase) +role.chain(from, to, scatter, lift, sag, bow, lit, hold, overlap, tip) +role.link(from, to, slack, curve, swing, swingSpeed, taut, spread) +role.spiralIn(from, to, radius, radiusEnd, turns, spin, curve, phaseSpread, wobble) +role.crack(from, to, angle, lengthFrac, depthFalloff, spread, start, sag, forkBias) +``` + +Every one of those returns the role, so `role.line(…).ends(0, 1, 0, 1).draw(u, 0.08, -1e4, 2)` +chains. Argument order is the order of the two `vec4`s the mode fills; the vertex shader carries +the same list beside each branch. + +### The modes + +| mode | the path, and what it is for | +| --- | --- | +| `LINE` | the bolt — straight axis bowed by `sag`, a fan opening from `spreadNear` to `spread` and rolling with `twist`. The only mode with a loose far end (`converge < 1`) | +| `HELIX` | a coil wound around the axis between two points. Railcoil's barrel, wound rope, a beam's collar | +| `ORBIT` | great slow loops around a point, each on its own inclined plane with its own ascending node. The caged orb — filaments that *circle* rather than radiate | +| `MEANDER` | the snare's tendril: a committed veer running outward, whose curve is a per-filament constant rather than noise, because a discharge that has chosen a direction keeps going that way | +| `RIM` | an arc travelling around a boundary, hopping over it at mid-span | +| `CHAIN` | a polyline through the nodes, **linear between them** and lit hop by hop. Drive it with `ArcNetwork` | +| `LINK` | a real catenary, not a parabola — the difference is all at the anchors, where a hanging chain leaves much steeper, and that steepness is most of what says "heavy". `taut` pulls the sag out for the snap | +| `SPIRAL_IN` | a spiral collapsing from one radius to another as it travels | +| `CRACK` | a branching fracture, three generations walked forward rather than recursed. Filament 0 is always the trunk, so it still reads with the count wound down to one | + +### The three stages, which never change + +1. **the path** — the role's centreline, `pathAt(t)`. The only part that knows the cast's geometry. +2. **the frame** — a tangent by finite difference on that path and two normals off it. Every offset + lives in this frame, which is what lets one kink function serve a vertical pillar and a filament + crawling flat across the floor. +3. **the kinks** — octaves of *linearly* interpolated value noise. Linear on purpose: `smoothstep` + would round the corners off, and the corners are the entire reason it reads as lightning rather + than as a wobbly tube. Every mode gets them; set `kink` to 0 on a role that wants a clean curve. + +Two clocks run the flicker, both inherited: `restrike` snaps every filament onto a new shape N times +a second, and `crawl` slides the kinks continuously in between. + +### Ground-hugging roles + +A kink with a free `y` buries half of every filament that runs flat, and the effect reads as a +broken dotted line — the snare learnt this the hard way. Two knobs, both per role: `groundDamp` (the +fourth argument to `style()`) scales the world-`y` component of the kink, 0.3 being the snare's +value, and `floorY` (the third to `draw()`) clamps the result above the floor. Set them on +`MEANDER`, `RIM` and any `CHAIN` that skims the ground; leave them at `1` and `-1e4` in the air. + +### Two shader notes worth not re-learning + +Uniform arrays are only ever indexed by a **loop counter**. GLSL ES 1.00 guarantees array indexing +only by a constant-index-expression — a for-loop index qualifies, a value derived from an attribute +does not — so the role lookup and the chain-node lookup are both a fixed loop with an `if` inside +rather than the `uFrom[role]` they would obviously like to be. The direct version fails on ANGLE. + +ESSL 1.00 also has no hyperbolics, which is why `LINK` carries its own two-line `coshf`. + +### The one rule + +**Fill the role from `settings[id]` every frame; never keep a metre between frames.** A role's +setters take resolved metres and radians, so the call belongs in `onTravel`/`onFade` next to the +settings read that produced it — never in `onSpawn`. Pause with **P**, drag `spread`, and the +standing bundle must re-fan. + +--- + +## `ArcNetwork.js` — a discharge that hops instead of travelling + +Everything else in the project that crosses a distance *travels*: a front moves at metres per second +and the effect is drawn behind it. Chain lightning does not do that. It picks a handful of points, +and then it is at the first one, and then it is at the second one, and the space between them was +never crossed so much as **skipped**. That discontinuity is the whole read, and it is why this is a +module and not a `progress` uniform. + +It owns three things and deliberately nothing else — a scatter, a clock and two hooks. It draws +through `FilamentPaths` in `CHAIN` mode; there is no second ribbon renderer here. + +### Draw-call cost + +**2**, and they are shared: the chain takes one role slot, so the other three are still free. +Hand an existing `FilamentPaths` in as `options.paths` and a chained ability's earthing spikes, rim +ring and chain all come out of the same two calls. + +### Signatures + +```js +import { ArcNetwork, arcNetworkParams } from '../vfx/ArcNetwork.js'; + +new ArcNetwork(parent, { + paths = null, // draw through an existing FilamentPaths instead of building one + role = 0, // which of its four role slots to take + samples = 96, // nodes along one filament — higher than the bolt's 72 on purpose + capacity = 24, + renderOrder, layer +}) + +net.from, net.to // Vector3 anchors — the caller WRITES these every frame +net.onNode = (index, position, count) => {} // assign ONCE; `position` is scratch, read it now +net.paths // the FilamentPaths, for the other three roles +net.object3D +net.drawCalls // === 2 + +net.reset(seed) // onSpawn(): re-roll, rewind the clock, un-fire every hook +net.reseed(seed) // re-route mid-flight WITHOUT restarting the discharge +net.update(dt, params, fade = 1) // the whole frame: clock, graph, hooks, sync +net.nodePoint(i, out) → Vector3 // metres — the shader's arithmetic, mirrored +net.clear() // onDestroy() +net.dispose() + +net.nodeCount net.segments net.progress net.arrived net.firedCount net.cursor + +arcNetworkParams() → object // the graph, the hops and the clock; also carries filamentLook() +``` + +The params object doubles as the look — it is handed straight to `FilamentPaths.sync()` — so a +chained ability keeps one scratch, not two. + +### The scatter + +`nodes` points between the two anchors, stored as **unitless fractions only** (`along`, `lateral`, +`lift`) rolled once per cast from one seed. Not one metre is captured. The metres arrive when the +shader resolves a node against the role's live `scatter` and `lift`, so dragging either re-routes a +chain that is already in the air, and `reseed()` re-routes it outright — which is the trick the +roster asks Chain Arc for. + +Two details that are not obvious and are the difference between a chain and a wobbly bolt: + +- **the sides alternate.** Two consecutive nodes scattered onto the same side draw a curve, and a + curve is a bolt. Magnitude still varies, so it does not read as a zip either. +- **the ends are not scattered.** Node 0 is the hand and the last node is the thing that was aimed + at. Scattering those makes the cast look like it missed. + +`along` is derived per frame from the live node count rather than stored, because the count is a +slider: dropping seven nodes to four has to re-space the survivors, not leave a gap where the last +three were. + +### The clock + +The cursor advances in **hops**, not seconds (`dt / hopTime`), so changing the hop time mid-flight +changes what happens next instead of rewriting what already happened. A hop is dark until the cursor +reaches it, holds at full for `hold` hops, then decays over `overlap` hops: + +- `hold 0, overlap 0.6` — a single spark running the chain, one hop lit at a time. +- `hold 8` — the whole chain lights and stays lit; the hops just arrive in order. +- `tip` smears the front *within* the hop it is crossing, so a hop is not a light switch. + +`onNode` fires exactly once per node, on the frame the cursor reaches it, with the node's resolved +world position — that is where the burst, the light punch and the decal go. The last node arrives +with `index === count - 1`, which is the ability's cue to move to `IMPACT`. + +### The one rule + +**Write `from` and `to` every frame, then call `update()` with params filled that frame.** The +network holds dice rolls and a hop counter; it holds no geometry. If you catch yourself caching a +node position between frames, call `nodePoint()` instead — it re-derives it with the same arithmetic +the vertex shader uses, which is the only reason a burst lands *on* a node rather than near it. + +--- + +## `Projectile.js` — things that travel + +One to a few hundred bodies leaving the hand (or the sky) and arriving somewhere else: rocks, +needles, hailstones, stars, blobs of magma. The body is caller-supplied geometry on a caller-supplied +material; the flight, the tumble, the arrival clock and the optional trail belong to the module. + +Nothing here integrates either. A body's position is a closed-form function of its own τ evaluated +against the live params, so dragging `apex` re-lofts a rock that is already in the air, with the +clock stopped — which an Euler step physically cannot do, because it has already spent the old +gravity. + +### Draw-call cost + +**2** — one `InstancedMesh` for every body, one instanced strip for every trail. Both are +unconditional; a cast with nothing in the air draws nothing because the counts fall to zero, not +because the meshes were removed. Pass `trail: false` for **1**. + +### Signatures + +```js +import { + Projectile, FlightMode, Stagger, projectileParams, spatialStagger +} from '../vfx/Projectile.js'; + +FlightMode = { LINE, ARC, ROLL, FALL, HOMING, LISSAJOUS, VOLLEY } // string enum +Stagger = { AUTO, NONE, RIPPLE, HASH } // string enum + +new Projectile(parent, { + geometry, // BufferGeometry | (() => BufferGeometry) REQUIRED + material, // THREE.Material, owned by the ability REQUIRED + shapeKey = null, // () => string; a change rebuilds the geometry + capacity = 48, // hard ceiling on bodies + trail = true, // build the instanced multi-trail + trailNodes = 28, // samples along one trail + trailAdditive = true, + layer = LAYER.WORLD, + renderOrder = 2, + castShadow = false +}) + +field.count // bodies drawn → Ability#instanceCount +field.drawCalls // 2, or 1 without trails +field.trailUniforms // the trail material's uniforms, for anything params does not cover + +field.roll(seed = Math.random() * 100) // onSpawn(); the only dice roll +field.reset() // onDestroy() +field.setBasis(origin, direction, side, length) // the cast's frame, every frame +field.setTrailColors(a, b, c, d) // THREE.Color or '#rrggbb' +field.syncGeometry() // rebuilds only when shapeKey() moves + +field.update(now, params) // re-fly everything, allocation-free + +field.arrivals // Int32Array of body indices that landed on THIS frame +field.arrivalCount // how many of them — consume immediately after update() +field.contact // ROLL: the contact point on the floor +field.contactLoad // ROLL: 0..1, what a GroundField(RUT) wants for its depth + +field.landPoint(index, out) → Vector3 // where body `index` lands, metres +field.pointAt(index, tau, out) → Vector3 // where it is at τ +field.headingAt(index, tau, out) → Vector3 // unit heading there +field.slotPosition(slot, out) → Vector3 // a *drawn* body, 0 .. count-1 +field.dispose() // geometry, trail, mesh; the material is yours + +projectileParams() → object // every canonical key with its default and a unit comment +spatialStagger(x, z, cell, seed) → 0..1 // the fill-order hash, exported on its own +``` + +`now` is the ability's `age` in seconds. Per-instance attributes `aSeed`, `aFlight` (τ) and `aFlash` +(the birth pop) are added to whatever geometry you hand over — the module takes ownership of it and +disposes it, so pass a factory rather than a geometry you are also drawing elsewhere. + +### One curve, seven modes + +The seven flight modes are not seven integrators. They are seven ways of choosing **two endpoints**; +the curve between them is one formula: + +``` +p(τ) = mix(launch, land, τ^pathCurve) + + up · apex · sin(πτ)^apexCurve + + side · weaveSide · (1−τ)^weaveDecay · sin(2π·weaveTurns·τ + φ) + + up · weaveUp · (1−τ)^weaveDecay · sin(2π·weaveTurnsUp·τ + φ + ψ) +``` + +A lob is `apex > 0`. A homing bolt is `weave > 0` with a decay that pulls the weave to *exactly* +zero at τ = 1 — which is why seven Lissajous bolts weave apart, cross, and then arrive at one point +on one frame with nothing simulated and nothing corrected. A fall is a launch point shared by every +body, high and behind the caster, so the trails converge in the sky and diverge on the ground; that +parallax is the entire reason the sky feels like it is above you. A roll is two endpoints at +`y = radius` with the rotation taken from **distance over radius** and nothing else, so the body +cannot skate — get that wrong by any factor at all and it is the first thing anyone notices. + +That collapse is what makes the trail possible. The alternative — a `switch` on the mode in the +vertex shader — means the GPU needs a second copy of every mode and the two drift apart the first +time someone edits one. Here the GPU needs the *coefficients*, which are uniforms, and the two +endpoints, which are per-instance attributes the CPU has already resolved. + +### The trail is one draw call, and it is not a recording + +`RibbonTrail` per body would be forty draw calls, forty ring buffers of history, and — fatally — a +trail that cannot be reshaped by a slider, because a history is a record of metres. Instead one +instanced strip carries `(v, side)` per vertex and `(launch, land, timing, dice)` per instance, and +the vertex shader samples the *same parametric flight backwards in the body's own clock*: + +``` +τ_head = min((now − launchDelay) / flightTime, 1) +τ(v) = mix(τ_head − trailSpan/flightTime, τ_head, v) +``` + +After the body lands the tail catches the head up over `trailBurn` rather than the ribbon fading +uniformly: a trail that dims reads as a light going out, one that shortens reads as something that +stopped being made. `pathAt()` in `TRAIL_VERTEX` is a line-for-line mirror of `_pathPoint()` in the +JavaScript — the pair is called out in both files, and it is the one place in this module where +editing half the code silently breaks the other half. + +### Staggered arrival is a property of the floor + +A zone fill wants forty arrivals spread over a second, and the obvious `i / count` fills the circle +in whatever order the dice handed out — which, because the same dice place the stones, correlates +the fill order with the layout and lets you *see* the loop. `spatialStagger()` hashes the **landing +point** instead, quantised onto a `hashCell` lattice, mixed with a per-cast seed: + +- `fillBias` orders by radius: **+1** fills outward from the centre, **−1** inward from the + boundary, 0 hands the decision to the hash entirely; +- `fillScatter` blends between that clean radial order and the hash; +- `hashCell` decides how the hash clumps. Near zero it is confetti; at about a body's spacing the + circle fills in *patches* that spread, which is much closer to how weather actually arrives; wider + than the zone and the whole circle lands as one sheet. + +Because the key is a property of the floor and of the seed, the circle fills deterministically for a +given seed and never twice in the same order. `Stagger.AUTO` picks the hash for `FALL`, an index +ripple for `VOLLEY`, and nothing for the rest. + +### Arrivals + +`update()` re-derives, rather than remembers, whether each body has landed: it raises `arrivals` on +the frame a body crosses τ = 1, and *clears* the flag again if a slider puts that body back in the +air. Consume the list straight after `update()` — that is where the pock, the chip burst and the +bounce go. + +### The one rule + +**A dice roll is unitless.** `roll()` captures a seed, a bearing fraction, a radius fraction and a +handful of jitters — not one metre, radian or second. Everything with a unit is resolved inside +`update()` from the params, including on a zero-length frame. If you catch yourself wanting to +remember where a body was, ask `pointAt()` again with a smaller τ. + +--- + +## `Swarm.js` — things that flock + +Up to a few hundred instanced agents on a shader-evaluated flock: ember birds carrying trails, +leaves stripped off a vine, a blizzard of glyph cards, blood droplets, drifting motes. Five +silhouettes, all signed-distance fields in the fragment shader — no atlas, no alpha map, nothing +loaded. + +### Draw-call cost + +**1.** However many agents. Two flocks that must differ in *silhouette* are two `Swarm`s and two +draw calls, which is the only reason to build a second one. + +### Signatures + +```js +import { Swarm, Silhouette, LeadPath, swarmParams } from '../vfx/Swarm.js'; + +Silhouette = { BIRD: 0, LEAF: 1, CARD: 2, DROPLET: 3, MOTE: 4 } +LeadPath = { POINT: 0, LINE: 1, ORBIT: 2 } + +new Swarm(parent, { + capacity = 256, // hard ceiling on agents + silhouette = Silhouette.BIRD, // initial shape; live afterwards via params + additive = true, // embers add, leaves do not + renderOrder = 12 +}) + +swarm.count // agents drawn → Ability#instanceCount +swarm.drawCalls // 1 +swarm.uniforms // the material's uniforms + +swarm.roll(seed = Math.random() * 100) // onSpawn(); re-seeds and shifts the lattice +swarm.reset() // onDestroy() +swarm.setBasis(origin, direction, side, length) // the cast's frame, every frame +swarm.setColors(a, b, c, d) // THREE.Color or '#rrggbb' +swarm.update(_now, params) // push the live params, allocation-free + // NB: the clock argument is IGNORED — the flock + // is driven by frame.uTime inside the shader +swarm.leadPoint(out) → Vector3 // CPU mirror of leadAt(), for lights and emitters +swarm.dispose() + +swarmParams() → object // every canonical key with its default and a unit comment +``` + +### Three boid behaviours, none of them simulated + +**Cohesion is a lag.** An agent's home is the lead point as it was `lag` seconds ago. Because the +lead is parametric — a point, the cast line, or an orbit — that is one evaluation rather than a +history buffer, and the flock strings out behind the lead and pours round its corners a beat late, +which is most of what cohesion looks like from outside. + +**Separation is a lattice.** Each agent owns one cell of an `latticeX × latticeY × latticeZ` grid, +decoded from its instance index and shifted per cast by a wrap-around offset — a bijection, so two +agents cannot claim the same cell. Separation is normally the n² term in a boid solver; here it is +free, and the price is that spacing is authored rather than emergent. **The third lattice axis is +not a distance, it is the lag**, so the formation is genuinely three-dimensional and the guarantee +survives: agents that could collide are the ones in the same rank, and they are the ones on distinct +`(x, y)` cells. A long thin skein is `4 × 2 × 16`; a wall of glyphs coming at you is `14 × 10 × 2`. +The product is the number of *distinct* slots, and asking for more agents than that is the one way +the guarantee breaks. + +**Banking is the second derivative.** Position is sampled at t, t−h and t−2h, giving a velocity and +an acceleration; the lateral component of that acceleration rolls the agent's card about its own +heading. This is the one that matters. A bird that turns without banking reads as a leaf, and a leaf +that banks reads as a bird — the roll *is* the species. `h` is a fixed fraction of a second rather +than the frame delta, because the bank must not change when the frame rate does. + +The first version separated agents with per-agent noise instead of the lattice. It gave a cloud, not +a flock: independent offsets let agents drift through one another and the whole thing read as smoke +with wings. + +### The card, the fold and the edge-on flicker + +The base geometry is **three columns by two rows** — six vertices, four triangles. The middle column +is the point: a four-vertex quad can only ever be flat, and a flat bird does not flap, it strobes. +`dihedral` swings the wing columns out of the plane on a per-agent flap phase, so the bird genuinely +goes edge-on at the top of its stroke; `curl` bends the same columns quadratically, which is a leaf. + +`billboard` blends the card's basis between the agent's own frame (wing axis, heading, lift) and the +camera's. At 0 a `CARD` is a plate in the world that vanishes as it turns; at 1 it is a sprite that +never does. The interesting value is in between, and it is what the roster asks Glyphstorm for: a +storm that flickers between a wall of symbols and a scatter of bright lines. Two sliders keep the +line legible — `edgeStretch` grows the card as it turns so it never falls under a pixel, and +`edgeGain` lifts its emission, because a collapsing card that does not get brighter reads as a gap +rather than as an edge. + +`CARD`'s glyph is a walk of up to six strokes between points of a 3 × 5 lattice, plus a terminal +dot, seeded off the agent. The lattice is what makes the marks read as *writing*: an unconstrained +random walk gives scribble, while snapping the endpoints to a coarse grid makes every card in the +storm share terminals and angles, and a few hundred of those look like an alphabet nobody has taught +you. (For a *legible* alphabet — a seal you can pause and stare at — use `GroundField`'s `RUNE`, +which authors sixteen real letterforms. This one is deliberately cheaper: it is weather.) + +### The one rule + +**An agent carries a seed and an index, and nothing else.** Every metre — cell spacing, lag, size, +wander — is a uniform resolved from the params each frame, which is why a paused flock re-forms +under the slider. If you catch yourself wanting to store an agent's velocity, the answer is another +`agentAt()` evaluation at `−h`; the flock is a closed-form function of time and that is the only +reason it is free. + +--- + +## `Tube.js` — the parametric tube + +`BeamMaterial` with the beam taken out of it. One `(t, a)` grid — +`createBeamTubeGeometry`, the same one Nova Beam is drawn on — placed in world space by a vertex +shader and drawn **three times at three radii**, along one of five parametric paths. + +Nothing about the shape touches the CPU, so a tube of any length, any profile and any path costs +the same, and `nodes`/`sides` are the only two numbers that decide how much geometry there is. + +It follows the `VolumeHull` half of the params convention rather than the `growthParams()` half: +keys are **prefixed** (`tubeRadius`, `tubeWaveAmp`…) and read straight off the ability's settings +block, so `sync()` can be handed `settings[id]` directly and breaking I1 is not expressible. Two +tubes on one ability — a parent beam and its children — simply take two prefixes. + +### Draw-call cost + +**3.** Always, whatever the path and however long the column is. Coils, discs and charge orbs are +`FilamentPaths` and `Shell`; this module stops at the column deliberately, so the count stays a +number you can hold in your head. + +### The three layers, and why the middle one is inverted + +Lifted wholesale from `materials/BeamMaterial.js`, because it is the single best idea in that file: + +- **halo** — widest, nothing but a rim term. The atmosphere the tube is shoving out of the way. +- **sheath** — rim-weighted, so it reads as *hollow* and its silhouette edges are its brightest part. +- **core** — narrow, and weighted the **opposite** way: brightest where the view ray runs down the + barrel and its path through the tube is longest. + +Rim-weighted outside, axis-weighted inside, both faces adding: that is a volume integral, cheaply, +and the inversion is the entire reason the middle reads as a solid rod of light rather than as a lit +pipe. Widen `tubeCoreWidth` or push `tubeCoreFill` and the three collapse into one white tube — the +sheath is only legible because the core leaves it room. (`SHELL` became `SHEATH` here only so it +does not collide with the sibling module `vfx/Shell.js`.) + +### Signatures + +```js +import { + Tube, TubePath, TubeLayer, TUBE_PATH_NAMES, + tubeDefaults, tubeKeys, tubeSchema, createTubeMaterial +} from '../vfx/Tube.js'; + +TubePath = { STRAIGHT: 0, WHIP: 1, FUNNEL: 2, VINE: 3, ARC: 4 } +TubeLayer = { CORE: 0, SHEATH: 1, HALO: 2 } + +new Tube({ + path = TubePath.STRAIGHT, // compile-time — one path per tube + prefix = 'tube', // settings-key prefix + nodes = 96, // samples along the column (WHIP/ARC want them) + sides = 26, // facets around the barrel + renderOrder = 11 // halo; sheath +1, core +2 +}) + +tube.group // add to ability.group +tube.materials // { core, sheath, halo } +tube.meshes // { core, sheath, halo } +tube.keys // unprefixed → prefixed key map +tube.visible // get/set +tube.drawCalls // === 3 + +tube.sync(c, state, g = settings.global) // EVERY frame, zero-length included + +tube.radiusAt(t) → metres // THE profile function — place everything against this +tube.pointAt(t, out) → Vector3 // the mean axis +tube.tangentAt(t, out) → Vector3 +tube.span // metres, origin → target +tube.skirtRadius / tube.mouthRadius // sugar for radiusAt(0) / radiusAt(1) + +tube.tipPoint // Vector3, live — do not keep the reference +tube.tipSpeed // m/s, differentiated from the curve, correct at dt = 0 +tube.waveSpeed // m/s the curvature wave itself travels at +tube.crack // { fired, point, speed, at } — poll it right after sync() + +tube.dispose() // geometry + the three materials + +tubeDefaults(prefix = 'tube', path = TubePath.STRAIGHT, overrides = {}) → settings fragment +tubeKeys(prefix) → { radius: 'tubeRadius', … } +tubeSchema(prefix, path) → editor folders, path-specific ones included +createTubeMaterial(layer, path) → ShaderMaterial // if you would rather place the layers yourself +``` + +`state` carries dice rolls and timestamps **only**: + +| field | meaning | +| --- | --- | +| `origin` / `target` | the two ends, world space | +| `side` | lateral reference for the cross-section seam | +| `progress` | 0..1 of the column that exists yet (VINE overrides this with `grow`) | +| `fade` / `widthFade` | master alpha, and the collapse to a thread | +| `seed` | the cast's unitless dice roll | +| `time` | seconds since this path's own beat began — drives WHIP | +| `grow` | VINE: the front, 0..1 | +| `snapAge` | VINE: seconds since the recoil was triggered, `< 0` for not yet | + +79 keys; see `TUBE_FIELDS` in the source for the full list with units — the profile +(`radius`/`radiusNear`/`radiusCurve`/`flare`/`flareWidth`/`throb*`), the axis (`wander*`), the +surface (`ripple*`/`streak*`/`flowSpeed`/`bands*`/`spin*`), the three layers +(`coreWidth`/`coreFill`/`coreSharp`/`edgePower`/`sheath*`/`halo*`), the ends +(`muzzle*`/`tip*`), four colours, and the per-path groups below. + +### WHIP, and why the crack is not on a timer + +A bullwhip cracks because the loop travelling down it carries a fixed energy through a decreasing +mass per length: the loop tightens, and the tip briefly goes supersonic. Two terms reproduce that, +and both of them are **in the geometry**: + +1. **the lobe** — a Gaussian bump of lateral offset centred on the travelling wave phase, whose + amplitude grows as the phase approaches the tip (`tubeWaveGain`); +2. **arc-length conservation** — a whip does not stretch, so the length the lobe eats comes out of + the axial extent. To first order that excess is `½∫(dy/ds)² ds`, which for a Gaussian has the + closed form `½·√(π/2)·A²/w ≈ 0.6267·A²/w`, distributed along the whip by the cumulative of the + same bump. + +So while the loop is mid-whip the tip is pulled **back**, and as the loop runs off the end the +lateral offset collapses and the axial extent returns — both at once. That is a speed spike, and it +is a property of the curve rather than of a clock. + +`tipSpeed` is that curve differentiated with respect to its own driver — a central difference on the +wave phase, times the phase rate — so it is correct on a **zero-length frame**. Pause the sandbox, +drag `tubeWaveWidth`, and the reported speed changes because the shape did. `waveSpeed` is +`span × tubeWaveRate`, the speed the loop itself travels at. When the first crosses the second times +`tubeCrackRatio`, `tube.crack.fired` is true for exactly that frame and `tube.crack.point` is where +the tip was standing: + +```js +this.lash.sync(c, this._state); +if (this.lash.crack.fired) { + this.shock.reset(this.lash.crack.point); // the shock ring, at the frame and the place + this.ctx.shake?.(c.crackShake * this.lash.crack.speed / this.lash.waveSpeed); +} +``` + +At the shipped defaults the tip peaks around **90 m/s** against a **16 m/s** wave on a 12 m cast, and +cracks exactly once per pass. Tighten `tubeWaveWidth` and it cracks harder; drop `tubeWaveAmp` below +about `0.6 × waveWidth` and it stops cracking at all, which is correct — a slack whip does not bang. + +### FUNNEL, and the one function + +`radiusAt(t)` **is** the vortex profile — `throat + skirt(t) + mouth(t)`, a tight waist with a skirt +flaring to the floor and a mouth flaring to the top: + +```js +const skirtR = funnel.skirtRadius; // radiusAt(0) — the dust skirt, the ground scour +const debrisR = funnel.radiusAt(u); // where a debris ribbon rides at height u +const mouthR = funnel.mouthRadius; // radiusAt(1) — the intake at the top +``` + +The debris, the dust skirt and the ground scour all read that one function, so dragging +`tubeSkirtFlare` moves every one of them together. The first version of this had the skirt as its +own slider on the ability and the scour as another; they were never the same number twice. + +The funnel also leans and precesses (`tubeSway*`), weighted to the top so the foot stays planted on +the scour it is standing in, and the surface spins by rotating the *noise lookup* rather than the +mesh — a circular cross-section rotated about its own centre is a no-op. + +### VINE + +The front is the length: `state.grow` renormalises `t`, and the radius is +`radius × (1 − s)^tipTaper`, so it genuinely tapers to nothing at the tip rather than being clipped +there. `state.snapAge` starts a damped-cosine spring (`tubeRecoilAmp`/`Freq`/`Damp`) that hauls the +whole curve back and lets it overshoot — a cosine rather than an exponential, because a vine that +only eases back is a vine on a lift. `tipSpeed` reports the snap-back speed, which is what strips +the leaves off. + +### The JS mirror, and what is *not* in it + +`radiusAt()` / `pointAt()` are a deliberate duplication of `tubeRadius()` / `tubeAxis()` in the +vertex shader. The alternative — reading the shape back off the GPU — is a pipeline stall per query, +and the queries happen several times a frame. + +What is **not** mirrored is the `wander` noise: the JS side returns the *mean* axis. A skirt placed +on the noisy axis jitters against the tube it is supposed to hug, and the wobble is centimetres. + +### The one rule + +**Place everything else against `radiusAt()` and `pointAt()`, never against your own copy of the +numbers.** A funnel whose skirt was sized from `settings.cyclone.tubeThroat * 3` at spawn is a funnel +that comes apart the first time somebody touches a slider. + +--- + +## `Shell.js` — the standing half of the burst vocabulary + +`effects/BurstSphere.js` stays exactly as it is and the six shipped abilities go on using it. It is +a **fire-and-forget pool**: you hand `spawn()` a start radius and an end radius in metres and it runs +its own clock to death. That is a captured dimension — the thing I1 exists to forbid — and it is +completely fine there, because a burst lives 900 ms and nobody can drag a slider inside 900 ms. +Retrofitting live re-resolution onto a pool whose whole contract is "spawn and forget" would have +meant every existing caller passing a settings object it does not have. + +What the roster needs instead is shells that **stand**: a thunderclap dome that holds while three +pressure fronts cross it, a resonant chord's ring train running for two seconds, a sun disc lying on +the floor while you tune the corona licking off its rim. Those are owned by the ability, live in its +group, and re-resolve every metre from settings on every frame. + +So **`BurstSystem` owns the transient vocabulary and `Shell` owns the standing one**, and both are +re-exported from `Shell.js`, because from an ability's point of view they are one vocabulary with two +lifetimes and having to remember which file a hemisphere lives in is exactly the friction the tech +library exists to remove. + +### Draw-call cost + +**1 per shell.** Two geometry kinds cover all five modes, and that economy is the good idea in the +file: + +- **the `(t, a)` surface grid** (`createBeamTubeGeometry`, the same grid the beam and `Tube.js` use) + — map `t` to a quarter polar sweep and it is a dome, a half sweep and it is a sphere, distance + along an axis with a rising radius and it is a cone. One grid, three silhouettes, no new geometry + builders; +- **the instanced annulus** (`createBeamRingGeometry`) — one instance is one ring. That is the ring + train, and with a single instance and an inner radius of zero it is also the sun disc. + +### Signatures + +```js +import { + Shell, ShellMode, SHELL_MODE_NAMES, + shellDefaults, shellKeys, shellSchema, createShellMaterial, + BurstSystem, BurstMode // re-exported: the transient half +} from '../vfx/Shell.js'; + +ShellMode = { DOME: 0, CONE: 1, RING_TRAIN: 2, SUNDISC: 3, PRESSURE: 4 } + +new Shell({ + mode = ShellMode.DOME, // compile-time + prefix = 'shell', + nodes = 48, sides = 48, // surface-grid tessellation + rings = 24, // RING_TRAIN instance capacity + segments = 96, // facets around one ring / the disc + renderOrder = 14 +}) + +shell.group / shell.mesh / shell.material / shell.keys / shell.visible +shell.drawCalls // === 1 +shell.instanceCount // rings drawn → Ability#instanceCount + +shell.sync(c, state, g = settings.global) // EVERY frame + +shell.radius // metres, live — what the rim is standing on +shell.span // metres — the cone's length, the train's line + +// RING_TRAIN only: +shell.standingAt(s) → 0..1 // the standing-wave amplitude s metres along +shell.nodeSpacing // metres between nodes (half a wavelength) +shell.nodeCount +shell.nodePosition(i, out) → Vector3 // node 0 is the reflecting far end +shell.resonantSpacing(n) → metres // the λ that fits exactly n half-waves + +shell.dispose() + +shellDefaults(prefix = 'shell', mode = ShellMode.DOME, overrides = {}) → settings fragment +shellKeys(prefix) / shellSchema(prefix, mode) / createShellMaterial(mode) +``` + +`state` is `{ origin, axis, side, span, t, fade, seed }` — `axis` defaults to world up, `span` +overrides the settings value with the cast's own length, and `t` is the ability's normalised life. +44 keys: the expansion (`radius`/`radiusEnd`/`expand`/`height`/`lift`), the surface +(`displace`/`noise*`/`turbulence`), the shading (`fill`/`rim`/`rimPower`/`dissolve` plus the +mode-specific `seal*`, `edge*`, `coneCurve`), the ring train, the sun disc, and four colours. + +`shellDefaults()` tunes itself per mode, because a pressure front and a sun disc want opposite +numbers out of the same block — one is 95% rim, the other is a solid face with filaments coming off +it. + +### RING_TRAIN, and the standing wave + +Resonant Chord's whole trick, so it is worth being exact. + +Rings launch at the origin `shellSpacing` metres apart and travel out at `shellRingSpeed`. At the +far end they **fold** — `s = span − |d − span|` on a `2·span` cycle — so a ring runs out, turns +round and comes back. Outbound and returning rings cross, and where they cross they add. + +The interference is not faked. Superposing the outbound wave with its reflection off a fixed end, + +``` +sin(ks − ωt) − sin(k(2L − s) − ωt) = 2·cos(kL − ωt)·sin(k(s − L)) +``` + +— a spatial envelope `|sin(k(s − L))|` with a node at the far end and every half-wavelength back +from it, times a temporal term that pulses the whole line together. That identity is three lines of +GLSL. Each ring reads the envelope at its own position: at a node it pinches to the axis and goes +dark, at an antinode it blooms and swells by `shellSwell`. The nodes are visible because they are +actually there. + +`shellReflect` fades between a plain travelling train (rings die at the far end) and full +reflection, which is what a lossy end does. `resonantSpacing(n)` is how you make the nodes stand +still instead of drifting: + +```js +c.chordSpacing = this.chord.resonantSpacing(c.chordHalfWaves); // an integer slider +this.chord.sync(c, this._state); +for (let i = 0; i < this.chord.nodeCount; i++) { + this.chord.nodePosition(i, _pos); // put the dust where the air is still +} +``` + +### SUNDISC, and the spokes it does not draw + +The corona is ridged noise sampled **in the plane** and domain-warped, then masked to the annulus +just outside the rim. The obvious implementation — sampling on `atan(y, x)` — hands every radius +along a given bearing the same value and draws dead-straight spokes out of the centre: a firework, +not a corona. The bolt's ground burns learnt this the hard way, and the lesson applies to anything +radial. + +`shellCoronaReach` is how far past the rim the geometry is drawn and `shellCoronaLength` how far the +filaments actually reach, so the second can be tuned without re-tessellating anything. + +### The one rule + +**Drive it with a normalised life `t`, never with a captured radius.** The shell interpolates +`radius → radiusEnd` itself on a live easing exponent, so an ability hands it `t = age / duration` +and gets an expansion that reshapes under the sliders while it is standing. An ability that computes +its own metres and pokes them in has thrown away the only reason this module exists. + +--- + +## How the distortion pass works now + +Read this before you write an ability that refracts anything. It is the one part of the tech +library that reaches outside `src/vfx/`, and the README's "Known rough edges" used to say the pass +existed but that nothing wrote to it. + +**The chain, end to end.** + +1. A `DistortionField` puts a mesh on `LAYER.DISTORTION`. That layer is *not* enabled on the main + camera — `CameraRig` enables `WORLD` and `VFX` and nothing else — so the mesh is invisible to + the ordinary render. +2. `PostProcessing._renderDistortion()` swaps the camera's layer mask for `DISTORTION`, clears a + HalfFloat target to `(0.5, 0.5, 0, 0)` — "no offset, no coverage" — draws the layer into it, and + puts the mask back. +3. `DistortionShader` runs as a composer pass between the scene and bloom, and resamples the frame + at `vUv + offset`. + +**What a fragment writes.** + +``` +R,G a unit screen-space direction, encoded as d * 0.5 + 0.5 +B the magnitude, in screen widths at uScale = 1 +A coverage — the blend weight between overlapping emitters +``` + +and what the pass applies is + +``` +offset = (rg - 0.5) * 2 * b * settings.post.distortion * settings.global.distortion +``` + +**Six things that will bite you if you do not know them.** + +- **Magnitudes are screen fractions, not metres.** `strength = 1` on a fragment shifts the frame + under it by a full `post.distortion` of screen *width*, at any distance. That is deliberate: an + authored strength then means the same thing on every cast, and the thing that genuinely should + shrink with distance — the *area* being warped — already does, because the emitter is real + geometry. `perspective` (0..1, with `perspectiveRef` in metres) is the opt-out. +- **Never multiply `settings.global.distortion` or `settings.post.distortion` into your own + strength.** The pass applies both, once. Folding them in at the writing end means one ability + applies them twice and the next one not at all. +- **Coverage is `a²`, not `a`.** The buffer is normal-blended, so an emitter at coverage `a` writes + `rg` *and* `b` already scaled by `a`, and the decode multiplies them together. The pass used to + multiply by `a` a third time; that is fixed, but the square remains and it is the honest price of + expressing "who wins where two distorters overlap" in one blend mode. Author your masks knowing + the feather is squared. +- **Later `renderOrder` wins.** Overlapping emitters blend by coverage, so the one drawn last with + near-full coverage takes the direction. A lens over a heat plume needs the higher order. +- **The pass self-skips.** `core/Layers.js#distortionWriters` counts the meshes currently *visible* + on the layer; at zero, `PostProcessing` skips the clear, the draw and the resample. Toggle + `field.visible` — do not just hide the parent group and leave the counter retained, and do not + add a raw mesh to the layer without retaining. An emitter that is never released keeps the pass + running for the rest of the session. +- **Occlusion is the depth prepass, not a depth buffer.** The offset target has no depth + attachment. Emitters reject fragments behind opaque geometry by sampling `frame.uSceneDepth` + themselves — that is `depthReject` / `depthFade`. It only knows about `LAYER.WORLD`, so the floor + and the character occlude the warp and other VFX do not. + +**Turning it off.** `settings.post.distortionEnabled` kills the pass outright, and +`settings.post.distortionScale` (0.25 … 1, default 0.5) sets the offset buffer's resolution as a +fraction of the frame. Both are in the editor under **Post processing**. 0.25 is still perfectly +smooth, because nothing written into this buffer has an edge sharper than a metre. + +--- + +## `Distortion.js` — the pass that used to do nothing + +**What it draws.** Nothing you can see. It writes screen-space UV offsets into the half-resolution +refraction buffer, and what you see is the rest of the frame moving. + +**Cost.** One draw call per emitter, at `post.distortionScale` of the frame, into a buffer nothing +else reads. A `BLADE` with `edge: true` is two, because the bright hairline is emissive geometry on +`LAYER.VFX` and an offset buffer has no colour channel to put it in. + +**Reads from settings.** Nothing directly, with one exception: `update()` mirrors +`post.distortion × global.distortion` into `uPostScale` so the lens's non-inversion guard can be +exact. Everything else arrives in the params object. + +**The one rule.** Author the *mask* and the *strength* separately. Every mode already has a shape +term that dies at its own boundary; `strength` is the amplitude and nothing else. Emitters that +carry their falloff in the strength cannot be faded out without changing their silhouette. + +### The five modes + +| mode | facing default | what it is | +| --- | --- | --- | +| `HEAT` | `UPRIGHT` | rising shimmer above a hot region, advected upward **in world space**, biased hard toward the base | +| `LENS` | `BILLBOARD` | radial displacement going as 1/r² inside a falloff — a gravity well that bends the floor, the character and every particle behind it | +| `SHOCK` | `BILLBOARD` | a travelling ring of compression then rarefaction, up to four concentric fronts | +| `BLADE` | `WORLD` | a razor-thin plane of pure refraction with a hairline at its cutting edge | +| `REFRACT` | `WORLD` (hull) | a generic hull that refracts along its own normal — water, glass, prisms, panes of frozen time | + +`DistortionFacing` decides how the quad is oriented, and everything except `WORLD` is built in the +vertex shader from `uAnchor` and the view matrix — the mesh's own transform stays identity, so +moving an emitter is a uniform write: + +- `BILLBOARD` — squarely at the camera. +- `UPRIGHT` — local +Y is world up, local +X is camera-right flattened against it. Heat rises + vertically whatever the camera is doing; a full billboard makes a plume lean over when you orbit, + which reads as wind. +- `GROUND` — flat on the floor, local +X/+Y along world +X/+Z. Shock rings and ground lensing. +- `WORLD` — placed by `setBasis(along, up)`. Blades and panes. + +### API + +```js +import { DistortionField, DistortionMode, DistortionFacing } from '../../vfx/Distortion.js'; + +new DistortionField({ mode, facing?, geometry?, edge?, renderOrder?, name? }) + .object3D // add to the ability's group + .visible = true|false // retains / releases the pass's writer counter + .setAnchor(v3) / .setAnchorXYZ(x, y, z) + .setBasis(alongV3, upV3) // WORLD facing, re-orthogonalised + .update(params) // every frame, zero-length ones included + .dispose() +``` + +Supplying `geometry` switches the emitter to hull mode: it uses the mesh's own matrix and normals, +`object3D` becomes something you position and scale in the ordinary way, and `REFRACT` is the only +mode that means anything. + +### Params + +Everything is optional and falls back to a visible-but-neutral default. **Anything you leave out is +a value the editor cannot reach**, which is an I1 violation waiting to be filed as a bug. + +*shared* — `width` `height` (metres, the quad), `strength` (screen widths at `post.distortion = 1`), +`opacity`, `seed`, `depthReject` (0..1), `depthFade` (metres), `perspective` (0..1), +`perspectiveRef` (metres). + +*`LENS` + `SHOCK`* — `radius` (metres, the falloff edge), `window` (0..1 of radius where the falloff +starts), `maxOffset` (hard ceiling). + +*`LENS`* — `core` (0..1 of radius, the 1/r² clamp), `invert`, `fold`, `swirl`. + +*`SHOCK`* — `wave` (metres, the wavefront), `thickness` (metres), `compression`, `rarefaction`, +`rings` (1..4), `ringGap` (metres), `ringDecay`. + +*`HEAT`* — `frequency` (cycles/m), `speed` (m/s), `sourceBias` (exponent), `spread`, `vertical`, +`flicker`. + +*`BLADE`* — `cut` (0..1 along its length), `grazing` (exponent), `edge` (0..1 of the height), +`edgeGain`, `wake` (0..1 of the height), plus `edgeColor` and `edgeGlow` with `edge: true`. + +*`BLADE` + `REFRACT`* — `ripple`, `rippleScale` (cycles/m), `rippleSpeed` (m/s). + +*`REFRACT`* — `power` (rim exponent; 0 flattens it to a uniform pane). + +### The tricks worth knowing + +**The constant varying.** A radial emitter needs, per fragment, the screen-space direction pointing +away from its centre. Interpolating a per-vertex "direction from the centre" across the quad is +correct at the four corners and visibly wrong everywhere in between. But a varying whose value is +*identical at every vertex* interpolates to exactly that value, perspective correction or not — so +the vertex shader projects the anchor once, hands the constant down, and the fragment differences it +against its own clip position. Exact everywhere, for one extra matrix multiply. `vAxX` / `vAxY` are +the same trick applied to the screen image of one metre along each axis, which is what lets a +plane-space wobble come out pointing the right way on screen. + +**The lens does not invert unless you ask.** Sampling *outward* from the centre — the default — +pulls distant imagery in toward the well and can only ever run off the edge of the frame. Sampling +inward (`invert: 1`) runs the sample point through the centre and out the other side, which flips +the image. Real, occasionally wanted, never by accident: with `fold: 0` the magnitude is clamped to +exactly the screen distance back to the centre, using the post pass's own gain, so the core packs +down to a point and stops. `core` clamps the 1/r² denominator so the middle is a finite smear rather +than a NaN; below about 0.08 it samples far enough out to read as a mirror, which is also a look. + +**The shock profile is one expression.** `d·exp(0.5 − d²)`, the derivative of a Gaussian: ±1 at +`d = ∓1/√2` and crossing zero exactly on the wavefront. Compression just inside, rarefaction just +outside, no seam between them. The first build used two smoothsteps back to back and the join was +visible as a stationary ring inside a moving one. + +**Heat is sampled in world space.** The noise is advected upward through a column that stands still, +so orbiting the camera does not drag the pattern along — the tell that gives a screen-space heat +haze away instantly. The column widens with height rather than being a rectangle of wobble, because +hot air entrains cold air, and `sourceBias` pushes the amplitude hard toward the base because the +whole read is "this is coming off *that*". A plume of even strength top to bottom looks like fog. + +**A blade is visible because you are never looking squarely at it.** A razor-thin slab shifts what is +behind it along its own surface normal, and the shift goes up as the view runs along the slab +because that is where the path through it is longest — so the whole effect is +`normal projected to screen × (1 − |N·V|)^grazing`. `cut` extends the blade along its own length +rather than scaling it, so the wake it has already opened stays where it was. + +### Using it + +```js +const _p = new Vector3(); +const _d = {}; // module scope — I3 + +createShaders() { + this.haze = new DistortionField({ mode: DistortionMode.HEAT, name: 'sunspear.haze' }); + this.group.add(this.haze.object3D); +} + +onSpawn() { + this._seed = Math.random() * 10; // a unitless dice roll — allowed + this.haze.visible = true; +} + +onTravel(dt) { + const c = settings.sunspear; // every metre, this frame + this.haze.setAnchor(this.pointAt(this.u, _p)); + _d.width = c.hazeWidth; + _d.height = c.hazeHeight; + _d.strength = c.hazeStrength * (1 - this.u * c.hazeDecay); + _d.frequency = c.hazeFrequency * settings.global.noiseFrequency; + _d.speed = c.hazeSpeed * settings.global.noiseSpeed; + _d.seed = this._seed; + this.haze.update(_d); +} + +onDestroy() { this.haze.visible = false; } // releases the writer counter +dispose() { this.haze.dispose(); super.dispose(); } +``` + +--- + +## `Portal.js` — a hole, not a decal + +**What it draws.** A disc or a slit with a different world behind it: a parallax starfield and +nebula, a white-hot fracture rim, a crown of radial cracks, and an opening progress that tears +rather than scales. + +**Cost.** One draw call, one material, no textures — the starfield is a hashed lattice and the +nebula is fbm, both evaluated in the shell's own tangent plane. + +**Reads from settings.** Nothing. Everything arrives in the params object. + +**The one rule.** `parallax` is the single most important number in the module and it must not be 1. +See below. + +### Three things make it read as a hole + +**1 — It occludes.** Everything else in this sandbox is additive light laid over the stage; a portal +has to *remove* the stage. The material uses premultiplied-alpha custom blending +(`ONE, ONE_MINUS_SRC_ALPHA`), which is the whole reason this is one draw call instead of two. +Premultiplied output lets a single fragment be both *opaque black* (`rgb ≈ 0, a = 1` — the void +genuinely covers the floor) and *pure additive glow* (`rgb = hot, a = 0` — the fracture rim adds over +whatever is behind it) depending only on what it writes. A normal-blended pass cannot express the +second and an additive pass cannot express the first, so the naive build is two meshes fighting over +the same SDF. + +**2 — The interior parallaxes, at the wrong rate.** For each of three star shells the view ray is +continued *through* the portal plane to a depth in metres behind it, and the starfield is sampled +where it lands. That alone is geometrically correct parallax — and geometrically correct parallax +looks like a window. `parallax` then scales the lateral part of that shift away from 1, so the +interior slides against the camera faster than the geometry says it should, and *that mismatch is +the illusion*. Set it to 1 and the portal collapses into a hole in a wall; set it to 1.6 and it +becomes a hole in space. The lateral shift is scaled rather than the depth on purpose: scaling the +depth would also change how big the stars are, which is a different lie and a worse one. + +**3 — It tears.** `open` does not scale the aperture. The aperture is always full size; what `open` +sweeps is a **threshold on a field**, and that field is the normalised distance to the seam +multiplied by a noise sampled *in metres* — so the crack grain is a fixed physical size, a two-metre +rift and a six-metre rift tear with the same size of shard. Different bearings therefore open at +different rates, the boundary is ragged, and it is ragged in the *same places* on the way closed. A +portal that scales is a sprite growing; a portal that tears is something being forced. + +`seam` picks which bearings go first: 0 unzips from the centre (a disc), 1 from the long centreline +(a slit, which is what makes it read as a wound rather than as an iris). + +### Depth at the edge + +`depthTest` on, `depthWrite` off — the correct pair for a transparent that must be hidden by nearer +opaque geometry. Walk the character in front of a rift and the rift is behind them. What it will +*not* do is hide transparents drawn after it, because they are not tested against something that +never wrote depth. If an ability needs the void to swallow its own particles, construct with +`writeDepth: true` and accept that the aperture then punches a hole in the transparent queue with a +hard alpha-tested edge — and that the additive crown is dropped, or every crack would punch a +rectangle out of the queue with it. + +### API + +```js +import { Portal } from '../../vfx/Portal.js'; + +new Portal({ billboard?, writeDepth?, renderOrder?, name? }) + .object3D + .visible = true|false + .setPlacement(anchorV3, alongV3, upV3) // normal comes out as along × up + .update(params) // every frame + .dispose() +``` + +### Params + +*shape* — `radiusX` `radiusY` (metres, half-extents; equal makes a disc, unequal a slit), `margin` +(0..1 of extra quad so the cracks have somewhere to go), `seed`, `opacity`. + +*the tear* — `open` (0..1, **not** a scale), `seam` (0 centre, 1 long centreline), `tearJag` (0..1), +`tearScale` (cycles per metre of crack grain), `tearCrawl` (Hz), `edgeSoft` (0..1 of the field). + +*the fracture* — `rim` / `rimGlow` / `colorRim`, `core` / `coreGlow` / `colorCore` (the white-hot +line inside the band), `throat` / `throatGlow` / `colorThroat` (the soft inner glow), `crackCount` / +`crackWidth` / `crackLength` / `crackGlow` / `colorCrack`. + +*the interior* — `parallax`, `swirl` (rad/s), `interiorFade`, `colorVoid`; three star shells as +`starScaleA/B/C` (stars per metre), `starDepthA/B/C` (metres behind the plane), `starDriftA/B/C` +(rad/s) and `colorStarA/B/C`, plus the shared `starSize`, `starTwinkle`, `starGain`; and the nebula +as `nebulaScale`, `nebulaSpeed`, `nebulaGain`, `nebulaDepth`, `colorNebulaA`, `colorNebulaB`. + +Ten colour pickers, and not one is derived from another. + +### A note on the crack crown + +The radial fractures are gated to *strictly outside* the aperture boundary. The first build +multiplied by `step(0.0, outside)`, which is 1 at `outside == 0` and therefore 1 across the entire +interior — every crack ran unbroken to the centre of the hole. Dead-straight spokes out of a middle: +a firework, not a fracture. The same lesson `ThunderAbility`'s ground burns teach, arrived at from +the other direction. The angular distance also wraps, or a crack whose bearing lands near a cell +boundary is sliced in half and the crown grows a seam you cannot unsee. + +### Pairing it + +`Portal` deliberately writes no screen-space offsets, because an ability that wants a hole does not +always want the frame warped around it. For the ring of bent floor at the edge — Void Rift's read — +put a `DistortionField` in `LENS` mode at the same anchor with a radius a little past `radiusX` and +a low `strength`. Two draw calls, and the floor bends into the hole. + +### A trap that cost an hour + +Both of these modules keep their GLSL in tagged template literals, and **a backtick inside a shader +comment terminates the string**. It fails as `SyntaxError: Unexpected identifier` pointing at a line +in the middle of the shader, which is not an obvious read. Do not write `` `uSourceBias` `` inside a +GLSL comment; write `uSourceBias`. Backticks in the *JSDoc* above the template are fine. + +--- + +## `VolumeHull.js` — a raymarched volume inside a proxy hull + +`VolumetricFireMaterial` with the fire taken out of it, and a hull enum bolted on. One mesh — a unit +BOX, CYLINDER, CONE, DOME or SPHERE — whose fragment shader fires a ray from the camera, intersects +the hull **analytically in the hull's own frame**, and integrates a procedural density field front +to back, compositing with premultiplied "over" and clipping every sample against the opaque depth +prepass so the volume fades into the floor and the character instead of cutting a line across them. + +Nothing is a texture. The field is trilinear value noise, a domain-warped fbm, and — for `GAS_BOIL` +— a cellular lattice of bubbles on individual timers. + +This is the most expensive thing in the expansion. The cost section below is not decoration. + +### Draw-call cost + +**One.** Always one, whatever the hull and whatever the medium. The eight media are `#define`d +variants of a single fragment shader rather than eight shaders, because a raymarcher is expensive +enough to *compile* that eight copies of the identical four hundred lines would be a visible hitch +the first time a school is opened. + +### It reads settings through a key prefix, not by bare name + +The convention at the top of this file — a params object read as `p.someName ?? default` — is +deliberately **not** what this module does. It takes a settings block and a **prefix**: + +```js +hull.sync(settings.plaguebloom, settings.global); // reads boilDensity, boilSteps, boilColorCore… +``` + +The reason is that a volume is rarely alone. Sanguine Pact wants a mist column *and* a pool; Plague +Bloom wants a boiling cloud over a floor of pustules; Pyroclasm wants ash and, later, smoke. Bare +names collide the moment an ability carries two hulls, and the fix — two params objects with two +different fill functions — is worse than a prefix. Prefixing also means you hand the module +`settings[id]` directly, which is the same I1 guarantee the params convention is after: there is no +intermediate object to forget to refill. + +The prefixed key strings are built **once**, when the hull is constructed, and cached per prefix +module-wide. Concatenating forty short strings per hull per frame is exactly the kind of thing I3 +exists to stop. + +You do not write the block by hand: + +```js +export const plaguebloom = { + range: 20, minRange: 4, speed: 26, cooldown: 1.4, castAnim: 'cast2', + zoneRadius: 5.0, + ...volumeHullDefaults('boil', Medium.GAS_BOIL, { boilSteps: 42, boilRise: 0.8 }) +}; + +export const plaguebloomSchema = { + 'The cast': ['range', 'minRange', 'speed', 'cooldown', 'castAnim'], + ...volumeHullSchema('boil', { label: 'Gas', only: ['march', 'shape', 'field', 'flow', 'optics', 'boil', 'colour'] }) +}; +``` + +`volumeHullDefaults` starts from the medium's own tuning — including the palette its ROSTER entry +asks for — so an ash dome starts as ash rather than as grey, and you tune from there. + +### Signatures + +```js +import { + VolumeHull, HullShape, Medium, + volumeHullDefaults, volumeHullSchema, + VOLUME_HULL_KEYS, VOLUME_SAMPLE_BUDGET, + HULL_NAMES, MEDIUM_NAMES, disposeVolumeHullGeometry +} from '../../vfx/VolumeHull.js'; + +HullShape = { BOX: 0, CYLINDER: 1, CONE: 2, DOME: 3, SPHERE: 4 } +Medium = { FLAME: 0, SMOKE: 1, ASH: 2, SPORE: 3, SAND: 4, MIST: 5, GAS_BOIL: 6, VOID: 7 } + +new VolumeHull({ + hull = HullShape.SPHERE, // HullShape.* + medium = Medium.SMOKE, // Medium.* + prefix = 'volume', // settings-key prefix + maxSteps = 48, // compile-time loop cap; uSteps clamps to it + shadow = , // compile the self-shadow tap at all + additive = false, // AdditiveBlending instead of premultiplied "over" + renderOrder = 12, + seed = Math.random() * 97 // unitless dice roll +}) + +hull.mesh // THREE.Mesh — add to the ability's group once +hull.material // THREE.ShaderMaterial + +hull.place(position, direction = null) // world anchor; direction is flattened, yaw only → this +hull.setSize(x, y = x, z = x) // half-extents in METRES — see the table → this +hull.setFade(k) // 0..1 from the phase clock; 0 hides the mesh → this +hull.sync(c, g) // c = settings[id], g = settings.global → this + +hull.steps // steps actually marched last frame, after global.volumeQuality +hull.shadowTaps // taps actually taken last frame +hull.cost(coveredPixels) // field samples/frame; compare with VOLUME_SAMPLE_BUDGET +hull.dispose() // material only — the unit hulls are shared + +volumeHullDefaults(prefix, medium, overrides) // → a settings sub-block, prefixed keys +volumeHullSchema(prefix, { label, only }) // → editor folders, ready to spread +VOLUME_HULL_KEYS // bare suffixes, in table order +VOLUME_SAMPLE_BUDGET // 20e6 +disposeVolumeHullGeometry() // app teardown only +``` + +Per frame, from `onTravel` / `onFade`, **including on a zero-length frame**: + +```js +const c = settings.pyroclasm; +const r = c.zoneRadius * c.ashSpread; // metres, re-resolved here, not captured +this.dome.place(this.position) + .setSize(r, r * 0.55, r) + .setFade(1 - t) + .sync(c, settings.global); +``` + +### The hulls, and what `setSize` means for each + +Local axes in metres, after the vertex stage has applied the size uniform: + +| hull | x | y | z | sits on | +| --- | --- | --- | --- | --- | +| `BOX` | ±Sx | 0 → Sy | ±Sz | the floor | +| `CYLINDER` | elliptic radius Sx | 0 → Sy | elliptic radius Sz | the floor | +| `CONE` | mouth radius Sx | mouth radius Sy | apex at 0 → mouth at Sz | nothing — it aims | +| `DOME` | radius Sx | radius Sy (y ≥ 0) | radius Sz | the floor | +| `SPHERE` | radius Sx | radius Sy | radius Sz | nothing | + +`CONE` points down **+Z** because +Z is the cast heading everywhere else in the project +(`Ability.direction`). The others stand up +Y because they sit on the ground and gravity does not +care which way the caster is facing. `place()` therefore only ever applies **yaw**: a dome that +pitched with an aim line would lift off the floor at one edge and bury itself at the other. + +**The hull does not own its own footprint, on purpose.** In every ability that wants a volume the +footprint already belongs to something else — `zoneRadius`, the cast length, the cone's reach — and +giving the hull its own radius slider would produce two numbers that have to agree and one bug +report per ability when they do not. `setSize()` writes a uniform and rebuilds nothing, so calling +it every frame with metres you have just re-read is free. + +That is also why the hull is resized **in the vertex shader** rather than by `mesh.scale` or by +rebuilding geometry: a paused sim (**P**) still has to re-size when a slider moves, and a CPU-side +rebuild cannot happen on a zero-length frame. + +### The media + +| medium | absorbs | emits | the point of it | +| --- | --- | --- | --- | +| `FLAME` | a little | a lot | the cheap cousin of `VolumetricFireMaterial`. No black-body fit, no vortex roll-up. Use the real one when the flame *is* the ability. | +| `SMOKE` | hard | no | lit entirely by the self-shadow tap. Without the tap smoke is a flat grey blob. | +| `ASH` | hard | embers | smoke plus sparse embers on a hashed lattice, and a downward drift because ash falls. | +| `SPORE` | barely | glints | discrete points drifting in a dim cloud, not the cloud lit up. | +| `SAND` | hard | no | the fbm hard-thresholded, so it reads as a curtain of grains rather than a cloud tinted brown. | +| `MIST` | barely | no | near-uniform, strongly forward-scattering. It is the anisotropy that makes it mist rather than fog. | +| `GAS_BOIL` | hard | pop flashes | see below. | +| `VOID` | very hard | stars only | see below. | + +### World space or local space, and which is which + +Both are available at every sample for free, because the ray is parameterised once and transformed +twice — see the note about affine maps in `main()`. + +- **The silhouette is local.** It *is* the hull. Sampled in world space it would slide out of its own + proxy the instant the ability moved the anchor, and get sliced along the hull's wall. +- **`FLAME`'s turbulence is local.** A jet's turbulence belongs to the jet; a cone that yaws with the + caster must take its tongues of flame with it. `Jet` pushes the noise domain along the hull's own + axis (+Z for a cone, +Y for anything standing up) and `Swirl` rotates it about that same axis. +- **Everything else is world.** A medium sampled in world space keeps a fixed physical grain, which + is the whole of Pyroclasm's implosion: a dome that contracts reveals *more* grain rather than + magnifying the same grain, and magnifying the same grain reads as a zoom, not a collapse. It is + also why two casts landing beside each other do not look like the same cloud printed twice. +- **Embers, glints and stars are world.** They belong to the room, not to the hull. + +`Flatten` (0..1) does not squash anything — it **stretches** the noise domain's Y, which makes every +eddy short and wide. Sporefall's cloud pours across the floor because its eddies are pancakes; +squashing the hull alone gives you a low cloud made of round blobs, which reads as a cloud someone +sat on. + +### `GAS_BOIL`, which is the interesting one + +The density is driven by a cellular field whose cells inflate and pop on **individual timers**, so +the cloud has internal events rather than drifting noise. Each cell's clock is +`fract(t · rate · jitter + offset)`: inflate to 0.55, hold to 0.80, then swell and thin — the pop. + +The first version was a 3×3×3 Worley field, which is the textbook answer and is unaffordable here: +twenty-seven hashed cells inside a forty-step march is eleven hundred hashes a pixel before the fbm +has been touched. So each bubble is **confined to its own lattice cell**: jitter the centre by at +most `(1 - size)/2`, cap the radius at `size/2`, and a bubble provably cannot leave its cell — which +makes the containing cell the only cell that can contribute. One hash, no neighbourhood loop, exact. + +The price is that bubbles never overlap and the lattice would be plainly visible. One octave of +value noise bending the domain first (`BoilWarp`) buys that back for a single extra tap. Two taps +total, against twenty-seven. Note the radius is **clamped**, not merely scaled, by `BoilPop`: the +containment argument depends on it and the burst swell would otherwise push straight through. + +### `VOID` needs no special blend mode, and that surprises people + +Premultiplied "over" with a near-black premultiplied colour **is** subtraction. The destination is +multiplied by `(1 - alpha)` and almost nothing is added back, so the dome genuinely darkens what is +behind it. `VoidBite` lets it occlude harder than its own density would, and it works on the *final* +alpha — after the emission has been gathered — which is the only reason the stars survive. + +That last point is worth stating as a rule, because it bit: absorption high enough to black the dome +out on its own kills the transmittance in the first half-metre, so every star behind that is +integrated at `T ≈ 0` and never appears. Nightfall's absorption is therefore modest and `VoidBite` +does the blacking-out. + +### Cost, which is the whole reason to read this section + +``` +samples per frame = coveredPixels × steps × (1 + shadowTaps) +``` + +A "sample" is one field evaluation: an fbm at `Octaves` octaves, each octave eight hashes, so four +octaves is around three hundred ALU ops. The budget for **all** volumes on screen combined: + +**`VOLUME_SAMPLE_BUDGET` = 20 000 000 field samples per frame**, roughly four milliseconds on a +mid-range discrete GPU at 1080p — which is what this sandbox has spare once the bloom chain is paid +for. It is a budget, not a measurement; `hull.cost(pixels)` does the arithmetic so you can check it. + +| coverage @1080p | steps | taps | samples | verdict | +| --- | --- | --- | --- | --- | +| 10 % (210 k px) | 32 | 0 | 6.6 M | comfortable | +| 25 % (520 k px) | 36 | 0 | 18.7 M | the working limit for a hero volume | +| 25 % | 36 | 1 | 37 M | only if it is the only VFX on screen | +| 50 % | 48 | 1 | 100 M | will not hold 60 fps anywhere | + +There is no half-resolution VFX pass in this renderer, so a volume pays full fragment cost. The two +levers, in order of effect, are **coverage** (shrink the hull, or frame it further away) and **taps** +(emissive media need none — `FLAME`, `SPORE` and `VOID` compile the branch out entirely). Dropping +steps is the *last* resort, because it is the one that shows. + +Three cost controls are already wired: + +- `settings.global.volumeQuality` (defaults to 1 when the key is absent) multiplies the step count + and the tap count, so the whole expansion's raymarching moves on one slider. +- `setFade(0)` hides the mesh outright. `frustumCulled` is off — the geometry is unit-sized and + stretched in the vertex shader, so three's bounding sphere describes a one-metre ball that has + nothing to do with where the volume is — and an invisible-but-drawn hull would keep paying full + fill rate for a volume nobody can see. +- The march coasts through empty space at a coarser stride and drops back to the fine one the + instant it hits anything, which buys back most of the cost of the headroom the silhouette needs. + +### The one rule + +**The hull must be the smallest shape that still contains the field.** + +Both failures are ugly and both are common. Too small and the volume is sliced off along a dead +straight line where it meets the proxy — unmistakable, and the single most obvious way this +technique fails. Too big and every ray spends its step budget crossing vacuum, so the volume costs +more *and* resolves less. + +The knob that reconciles them is `Margin`: it holds the medium's nominal surface that +fraction of the hull inside the wall, leaving the erosion somewhere to push into. Erosion is what +escapes, so the pair to watch is `Margin` against `NoiseStrength` — turn one up and you owe the +other. If you see a straight edge, that is the pair, every time. + +### Four traps worth not re-learning + +**A backtick inside a GLSL comment terminates the template literal.** Same trap `Portal.js` +documents; this file hit it twice. Write `uMargin`, not the quoted form. + +**`packed` is a future-reserved word in GLSL ES.** `commonGLSL`'s `softFade` used it as a local, and +ANGLE rejects it outright — every material that injects the chunk failed to compile on macOS Chrome. +Renamed to `depthBits` there and never used here. If you write a new depth-sampling shader, do not +reach for the obvious name. + +**A ridged term built on value noise is bright almost everywhere.** Value noise is a *smoothed* +hash, so its values pile up around 0.5 and `1 - |2n - 1|` therefore piles up around **one**. Embers +taken off a ridged octave came out as red television static covering the whole cloud, and no +threshold fixes a distribution that is wrong at the shape level. Sparse points want a hashed lattice +(`speck()`), which is uniform by construction — `density` then means exactly what it says. + +**A medium is only as bright as its scatter-to-absorption ratio.** The integral gathers +`albedo × scatter` and loses transmittance at `absorption`, so a medium whose scatter is a third of +its absorption renders a third as dark as its own palette. Slate-grey smoke came out as a black +hole. If a medium looks too dark, that ratio is the first place to look, not the colour pickers — +and `Ambient`, which stands in for multiple scattering, is what keeps the unlit side grey rather +than absent. + +--- + +## `LiquidSurface.js` — a heightfield that knows how fast it is moving + +Lava, blood and water on one displaced plane. The archived `OceanWaterMaterial` raymarched a *body* +of water because a thrown stream has no top and bottom; a pool is the opposite problem — gravity has +already decided where the one surface is, and everything you read off it is a property of that sheet. +So this is a real subdivided plane with a displaced vertex shader, and it costs a fortieth of what +the march did. It is still worth reading `src/archive/materials/OceanWaterMaterial.js`: the sky +floor under the env probe, the two-band displacement rule and the "foam must be a speckle whose +*density* varies, never a smooth function of the wave" rule all came from it unchanged. + +### Draw-call cost + +**One.** `segments²·2` triangles, no textures. It is *fill*-heavy, not vertex-heavy: the shading +normal is four evaluations of the entire heightfield, and the crust adds a flow field plus two +advected fbm phases on top. Both expensive blocks sit behind `uCrust`/`uFoamSpeed` gates, so water +and blood pay for neither. **Do not stack two of these over the same pixels**, and keep `sizeX` / +`sizeZ` honest — a pool the size of the stage is a full-screen shader. + +### Signatures + +```js +import { LiquidSurface, LiquidMode, liquidParams } from '../vfx/LiquidSurface.js'; + +LiquidMode = { POOL: 0, WAVE: 1 } // WAVE adds one travelling crest that curls and breaks + +new LiquidSurface({ + segments = 96, // grid per side. <48 facets the Gerstner cusps; >160 is wasted + mode = LiquidMode.POOL, + depthWrite = true, // a heightfield is a solid — its crests must hide its far side + doubleSide = true, // required once `crestCurl` folds the sheet over itself + renderOrder = 3, + name = 'LiquidSurface' +}) + +surface.object3D // add to the ability's group +surface.uniforms +surface.drawCalls // === 1 +surface.visible // get/set +surface.mode // get/set — a uniform branch, no recompile + +surface.setPlacement(anchor, along, up) // along = the direction a WAVE travels + +surface.ripple(u, v, strength = 1, now?) → slot // u,v are −1..1 FRACTIONS of the half-extents +surface.rippleAtWorld(position, strength = 1, now?) → slot +surface.clearRipples() +surface.reset() // onDestroy(); leaves it reusable + +surface.update(now, params) // re-resolves every metre, allocation-free + +surface.lipPosition(params, out, across = 0) → Vector3 // world point of the breaking lip +surface.lipHeight(params, across = 0) → metres + +surface.dispose() + +liquidParams() → object // every canonical key with its default and a unit comment +``` + +`now` is the ability's `age` in seconds — it is the ripple clock, so it must be the same clock the +ripples were stamped with. `params` is read fresh on every call. + +### The four things it does + +**1 — Surface speed is a real quantity.** The fragment shader computes an honest 2-D flow in metres +per second: a bulk drift (`flowAngle`/`flowSpeed`), a radial outflow that dies with distance +(`flowRadial`/`flowRadialFall`), eddies taken as the **curl of a scalar noise field** so they swirl +without any point acting as a source or a sink, and **downhill gravity** read straight off the +shading normal (`n.xz / n.y`, free — the normal is already there). + +That last term is the one that pays for itself. A ripple from an impact steepens the local slope; +the slope feeds the flow; the flow pushes the surface past `crustBreak`; and the black skin **cracks +open along the ripple front and glows**, healing behind it. Nothing in the code says "crack when +hit". The coupling is real, which is why it reads. + +**2 — The crust is a flow map, not a texture.** Coverage is `1 − smoothstep(crustForm, crustBreak, +speed)`. The crack pattern is the **zero crossing of a signed fbm** — continuous, branching, +one-number width, none of which a threshold on `|noise|` gives you — evaluated in a frame built from +the flow direction and squashed along it by `crackStretch`, so the seams run *with* the pour like +pahoehoe rather than crazing like pottery. It is advected by the two-phase cross-fade (sample at +`fract(t/T)` and `fract(t/T + 0.5)`, weight `|1 − 2·fract(t/T)|`), because a single advected layer +smears without bound after a few seconds and that is exactly what makes hand-rolled flow maps look +like melting plastic. + +The honest limitation, stated once: a fragment cannot remember when it was last moving fast, so +"the crust re-forms where it is slow" is instantaneous in space and lagged in time only by the +global `crustFormTime` ramp. Real skin has hysteresis. Fixing it needs a ping-pong buffer, which is +a texture, which is **I2**. + +**3 — Ripples are analytic and unitless.** A slot holds `(u, v, born, strength)`: a position +*fraction*, a timestamp and a dimensionless strength. Never a metre — **I1**. The shader multiplies +the fraction by this frame's half-extents, so dragging `sizeX` moves standing ripples with the pool, +which is the observable proof the rule is being kept. Each packet is a gaussian-enveloped cosine +riding out at `rippleSpeed`, decaying as `e^{−age/rippleDecay}` and thinning as +`1/(1 + r/rippleSpread)` — the exponential alone kills small pools' ripples in the middle, the +spread alone never kills them at all, and you need both. Eight slots; `ripple()` evicts the *oldest*, +because the newest is the one the player is looking at. + +**4 — WAVE mode curls.** The crest is a profile in metres from a front at `waveFront` (a 0..1 +fraction the ability drives): a long exponential back against a short exponential face, and that +ratio does more for the silhouette than any noise on top of it. The overhang is Gerstner pushed hard +— horizontal throw proportional to *height* (`crestCurl · h`), so the top of the crest outruns its +foot and the sheet genuinely folds over itself. Where it has folded, the shader thins alpha and adds +a backlight, which is what makes the front face read as lit from inside. `lipPosition(p, out, +across)` hands you the world point of that lip; roll `across` per droplet and emit there. + +`lipPosition()` deliberately ignores chop and ripples. You want droplets leaving a clean moving +line; sampling the full field jitters every emitter by the finest octave in it, which reads as a +fault in the emitter rather than as detail in the wave. + +### Params + +`liquidParams()` in the source is the full list with units. The groups are: the sheet +(`sizeX`/`sizeZ`/`fill`/`round`/`edgeSoft`/`edgeNoise`/`edgeScale`/`seed`/`opacity`/`contactFade`), +the swell (`waveAmpA..D`, `waveLengthA..D`, `waveSpeedA..D`, `waveAngleA..D`, `steepness`), chop +(`chop`/`chopScale`/`chopSpeed` plus the fragment-only `detail`/`detailScale`/`detailSpeed`), +ripples, the flow field, the crust, foam, the travelling wave, and shading — including eight pickers +`colorDeep`, `colorShallow`, `colorCrust`, `colorSeam`, `colorHot`, `colorFoam`, `colorSpec`, +`colorSky`, none derived from another. + +`detail` is deliberately invisible to the vertex stage. Displacing by something smaller than a quad +is aliasing, not detail; in the *normal* that same octave is the difference between a wobbling sheet +and a surface. Same split the ocean material documents, same reason. + +### The one rule + +**Give the surface somewhere to flow.** Every interesting thing the crust does is a function of +`speed`, and a pool with `flowSpeed`, `flowRadial` and `flowEddy` all near zero has a speed of zero +everywhere: uniform unbroken skin, no seams, no heat. Lava wants a radial outflow from where it is +being fed and enough `flowGravity` that a passing ripple can tear the crust. Water and blood want +`crust: 0` and pay for none of it. + +--- + +## `Curtain.js` — vertical sheets of light in air + +Instanced sheets with a travelling vertex ripple. Rain, aurora, light shafts. + +### The mismatch is the effect + +A hanging ribbon and a curtain of aurora are the same geometry. The one thing that separates them is +that on a ribbon **coverage and brightness fall off together** — where the cloth thins it both stops +hiding things and stops being bright. Light in air does not do that. A column of excited gas keeps +emitting long after it has stopped occluding anything, so the top of a real aurora is pure radiance +over a visible sky: bright and transparent at once. + +So alpha and emission ride two independent curves — + +``` +alpha = mix(alphaTop, alphaBase, pow(1 − h, alphaCurve)) +emission = mix(emissionTop, emissionBase, pow(1 − h, emissionCurve)) +``` + +— and **they are meant to disagree**. `alphaCurve` well above `emissionCurve` and the sheet stops +covering things halfway up while still throwing light out of its head: that is the aurora. Set them +equal once, look at it, and you will see the hanging ribbon you were trying not to make. For a light +shaft, put `emissionTop` above `emissionBase` so the shaft is brightest where it enters the canopy. + +That only works because the output is **premultiplied alpha** (`ONE, ONE_MINUS_SRC_ALPHA`), the same +trick `Portal` uses. One pass writes `rgb = colorBody·α + emissive`, `a = α`, so it can be an +occluding sheet (rain), pure additive light (aurora), or a *darkening* (the wet floor) with no change +but the numbers. `body` is how much α the sheet is allowed at all; set it to 0 for light with no +substance. + +### The other thing that matters + +A sheet has no thickness, so a ray crossing it face-on passes through nothing and a ray crossing it +edge-on travels the length of a fold. Both alpha and emission are scaled by `1/|N·V|`, clamped at +`grazeFloor`. Without it a curtain is a flat decal identical from every angle; with it the folds +flare as you orbit and the sheet reads as a volume. Two lines, most of the effect. + +### Draw-call cost + +**One** for any number of sheets — one `InstancedBufferGeometry`, every sheet placed by the vertex +shader. **Two** with the floor companion. + +### Signatures + +```js +import { Curtain, CurtainMode, CurtainLayout, curtainParams } from '../vfx/Curtain.js'; + +CurtainMode = { RAIN: 0, AURORA: 1, SHAFT: 2 } +CurtainLayout = { LINE: 0, RING: 1, SCATTER: 2 } + +new Curtain({ + capacity = 16, // hard ceiling on sheets + segmentsX = 32, // quads ACROSS a sheet — the ripple lives here, this is the one that matters + segmentsY = 16, + mode = CurtainMode.AURORA, + layout = CurtainLayout.LINE, + floor = false, // build the wet / lit ground companion (+1 draw call) + renderOrder = 8, + name = 'Curtain' +}) + +curtain.object3D // a Group: sheets + optional floor +curtain.uniforms +curtain.drawCalls // 1, or 2 with the floor +curtain.instanceCount // sheets drawn → Ability#instanceCount +curtain.visible // get/set +curtain.mode // get/set — uniform branch, no recompile +curtain.layout // get/set — shared BY IDENTITY with the floor + +curtain.setPlacement(anchor, along, up) // `up` may be tilted: pass a negated frame.uLightDir + // and the shafts slant with the stage's own sun +curtain.roll(seed?) // onSpawn(); the only dice roll +curtain.reset() // onDestroy(); leaves it reusable +curtain.update(_now, params) // re-resolves every metre, allocation-free + // NB: the clock argument is IGNORED — the sheets are + // driven by frame.uTime inside the shader + +curtain.sheetPoint(index, params, out, across = 0, height = 0) → Vector3 +curtain.dispose() + +curtainParams() → object // every canonical key with its default and a unit comment +``` + +For a **wall across the cast** (`stormwall`) pass the cast's *side* vector as `along`, not its +direction — the line you aim is the wall's normal, and that is the whole trick of the slot. + +### The modes + +- **RAIN** — hashed streak lanes scrolling down the face, each lane with its own speed and duty, and + the antialias floor out of the archived `WindMaterial`: widen a lane to at least a pixel, then fade + what is left into the lane's own average once the pitch stops resolving. Without the second half a + distant curtain is a beating stipple of dots instead of haze. +- **AURORA** — vertical rays from an fbm sampled on the sheet's **length alone**. Feed the height in + as well (the obvious thing) and the striations break into blotches, because a ray is by definition + a field that does not vary along a field line. Then a slow three-way hue band, and a distinct + **hem** of a fourth colour along the bottom edge. Real aurora has that hem; leaving it out is why + most attempts read as a green rag with a straight cut. +- **SHAFT** — a gaussian core across the sheet (not a smoothstep: a shaft has a falloff, not an edge, + and a smoothstep draws the plane it is standing in), a fixed per-shaft noise gate along it so each + one has its own silhouette of leaf gaps, dust motes on a hashed world-space lattice, and `taper` + below 1. + +### The floor companion + +One extra quad that walks the **same `sheetFrame()`** the sheets do — the layout uniforms are shared +by identity and the per-sheet dice are mirrored into a uniform array, so a pool cannot drift out from +under its sheet. Three terms, all premultiplied so one pass can darken and add: a Schlick fresnel over +`frame.uEnvMap` on an fbm-perturbed normal (this is what "wet" is — it is the *grazing* reflection +that says so, which is why the fresnel matters more than the reflection does), a falloff around each +sheet's footprint treated as a **segment** so a two-metre sheet gets a two-metre puddle rather than a +circle, and expanding impact rings on a hashed lattice where each cell holds its own phase. + +For `SHAFT` the same "pool" term is the disc of light the shaft lands in, which is the reason a shaft +reads as reaching the ground at all. + +### Params + +`curtainParams()` in the source is the full list with units. Groups: the layout +(`count`/`spacing`/`radius`/`scatter`/`seed`), the sheet +(`width`/`height`/`base`/`taper`/`lean`/`rise`/`riseSpread` and their jitters), the ripple +(`rippleAmp`/`rippleLength`/`rippleSpeed`/`rippleCurve`, the slower `fold*`, the `rippleNoise*` slop, +`phaseSpread`), **the two curves**, the envelope (`body`/`footFade`/`headFade`/`edgeFade`/`graze`/ +`grazeFloor`/`softFade`/`opacity`/`glow`/`tintSpread`), the three mode blocks, seven pickers +(`colorA`, `colorB`, `colorC`, `colorHem`, `colorCore`, `colorMote`, `colorBody`) and the floor's own +block including three more (`colorWet`, `colorPool`, `colorRing`). None derived from another. + +Per-sheet state is nine **dice** — two for the scatter disc, one for bearing, four for +jitter/width/height/lean, one for phase — and `roll()` is the only place they are written. Every +metre they turn into is resolved in `update()` each frame, which is why a paused curtain re-lays +itself under `spacing` and `radius`. `sheetPoint()` reads the same floats the vertex shader reads, so +a CPU emitter lands exactly on its sheet; it leaves the travelling ripple out for the same reason +`LiquidSurface#lipPosition()` leaves out the chop. + +### The one rule + +**Make the two curves disagree, and never let a sheet face the camera square-on for long.** The +module has exactly two ideas in it — the emission/alpha mismatch and the grazing path term — and both +of them only show themselves when the curtain is folded and the camera is moving. A flat rank of +sheets seen head-on will look like wallpaper however good the numbers are: give it `lean`, +`rippleAmp` and a `scatter` of half a metre, and put the caster somewhere they will orbit it. + +--- + + +## `SceneHooks.js` — the module that changes the world + +Every other module in this library draws inside the ability's own group. This one reaches **out** of +it and edits the scene the ability is standing in: it swings the sun, drains the grade, ages the +floor, punches a hole in the frame, inverts gravity, and publishes a region that other abilities' +shaders read and come apart inside. + +Seven abilities in `docs/ROSTER-II.md` are built on it — `dawnbreak` and `sheetlightning` (key +light), `eclipse` (grade), `entropy` (material age), `silence` (hole), `hourglass` (gravity) and +`spellbreak` (disrupt). + +It is also the only module in `src/vfx/` that is dangerous. Four casts can be live at once, an +ability can be destroyed mid-effect by the concurrency cap or by **C**, and if any one of those +paths can leave the sun pointing sideways or the floor rusted then the sandbox is broken for the +rest of the session and the only fix is a reload. So the graphics in here are deliberately thin — a +depth proxy, a uniform blend, a patch into the floor — and the substance is the **borrow/restore +ledger** underneath them. + +### Draw-call cost + +Measured in the running app (`gl.info.render.calls`, averaged over 30 frames, empty stage): + +| held | draw calls | notes | +| --- | --- | --- | +| nothing | 31 | the baseline frame | +| `KEY_LIGHT`, sun swinging every frame | **31** | *identical* — see below | +| `GRADE` / `AGE` / `GRAVITY` / `DISRUPT` | **31** | uniform writes into things that were already being drawn | +| `HOLE` | **33** | +2: the depth proxy in the depth prepass, and again in the main pass | + +The hole's two draws shade nothing — `colorWrite` is off and it is a 32×16 sphere — but they are two +real draws and they are counted here rather than in a footnote. + +On the CPU, `apply()` with nothing held is **~1 ns**; with one hook held and written it is **138 ns**, +and with all six it is **≈400 ns**, measured in Node over two million frames. This is not on the +per-frame budget in any meaningful sense. + +### Signatures + +```js +Hook = { KEY_LIGHT:'keyLight', GRADE:'grade', AGE:'age', + HOLE:'hole', GRAVITY:'gravity', DISRUPT:'disrupt' } + +sceneHooks // the app's singleton — this is what you import +new SceneHooks() // your own, for a test harness + +hooks.install({scene, environment, ground, grade, renderer}) // App does this once, at boot +hooks.uninstall() · hooks.dispose() +hooks.observe(material) // park the live state on userData.uniforms (see below) + +hooks.acquire(hook, owner) -> token // NEVER null +hooks.reclaim(owner) -> number // the line onDestroy() wants +hooks.releaseAll() · hooks.isHeld(hook) · hooks.driver(hook) · hooks.heldCount +hooks.apply() // App calls this once per frame; idempotent +hooks.describe() -> 'keyLight[eclipse < dawnbreak] hole[silence]' + +hooks.gravityAt(x,y,z) -> number // exactly 1 when nothing is held +hooks.disruptAt(x,y,z) -> 0..1 · hooks.ageAt(x,z) -> 0..1 + +token.hold() · token.release() · token.blend(weight) · token.driving · token.active + KEY_LIGHT .aim(azimuth, elevation) · .tint('#rrggbb'|Color) · .brightness(intensity) + GRADE .saturate(v) · .temper(v) · .raise(lift) · .darken(vignette) + AGE .at(x,y,z)|.atPoint(v3) · .field(radius, edge, amount, inner=0) + .wear(rust, dust, moss, pit, bleach) · .scale(metres) · .colours(rust, dust, moss) + HOLE .at(x,y,z)|.atPoint(v3) · .size(radius, squash=1) + GRAVITY .at(x,y,z)|.atPoint(v3) · .well(radius, edge=0.25) · .scale(inside, outside=1) + DISRUPT .at(x,y,z)|.atPoint(v3) · .region(radius, edge=0.35) + .power(drain, fracture, dim) · .shardSize(pixels) + +// for a material that wants to read the published fields +disruptUniforms() · gravityUniforms() // the SHARED boxes — do not clone them +disruptGLSL · gravityGLSL // the chunks +patchAgeMaterial(material) // any MeshStandardMaterial can be aged +``` + +Every setter returns the token, so a frame's worth of writing is one chained expression. Every +setter also **renews the lease** — see below. + +### The discipline + +1. **Borrow.** `sceneHooks.acquire(Hook.KEY_LIGHT, this)` returns a token. Unlike + `ctx.lights.acquire()` it never returns null: there is no pool to run out of, because the world + has exactly one of each of these things and sharing it is a question of *ordering*, not of + availability. +2. **Write, every frame,** from live settings. The setters store on the token; they do not touch the + world. That is invariant I1 with no room to cheat — the module holds no metre, radian, second or + colour across a frame boundary, so a paused slider drag reshapes a standing hook. +3. **Apply, once.** `sceneHooks.apply()` runs from `App.frame`, between `post.sync()` and + `post.render()`, and is the only code in the project that writes to the borrowed world. +4. **Release.** `token.release()`, or `sceneHooks.reclaim(this)` for the lot. Releasing twice is a + no-op; so is releasing a token that has since been recycled into somebody else's hands, because + tokens carry a serial and a release has to present the current one. +5. **Or don't, and the module takes it back.** A hook whose token has not been written for **eight + frames** is reclaimed automatically, with one warning naming the owner. + +Step 5 is the net under the trapeze and it is why this is a discipline rather than a hope. The first +version had no ledger: `takeKeyLight()` / `restoreKeyLight()`, paired by the caller. It survived +exactly as long as it took to cast Dawnbreak and press **C** halfway through — `onDestroy()` ran, +`restoreKeyLight()` was called from a path that had already been torn down, and the stage stayed lit +from the horizon until reload. + +A holder that legitimately has nothing to say on a frame calls `token.hold()`. Eight frames is 133 ms +at 60 Hz: long enough that a stutter or a dropped frame never trips it, short enough that a leak is +gone before the eye finds it. + +### Restore, and why two of the six get it free + +`Environment.update()` re-authors the key light from `settings.environment` every frame *before* the +abilities run. `PostProcessing.sync()` re-authors the grade from `settings.post` every frame, just +before `apply()`. Those two hooks are therefore a per-frame **overwrite** of a value that is itself +rewritten from settings on the next frame — stop applying and the world is already exact, not +approximately but bit-for-bit the value the sliders say. `npm run check` asserts precisely that, to +1e-9, after a `reclaim()`. + +This is also why `apply()` blends **from `settings`**, never from the live light or the live grade +uniform. Blending from the live value compounds the moment anything calls `apply()` twice, and drifts +in a way nobody would ever find. It costs a duplicate of Environment's four lines of +azimuth/elevation trigonometry, called out in `_applyKeyLight`, and it buys an `apply()` that is +idempotent. + +The other four hooks mutate persistent state — a material uniform block, a mesh's visibility, two +published uniform blocks — so each has an explicit neutral (`radius = 0`, `amount = 0`, +`visible = false`, `scale = 1`) and releasing the last holder writes it. + +### Last acquirer wins + +Two abilities may hold the same hook. Acquiring never fails and never evicts: the new token goes on +**top of that hook's stack** and drives the world, the earlier holder keeps a live token and reads +`token.driving === false`, and it resumes driving the instant the top token is released. LIFO, +skipping anything released in the meantime. + +The tie-break is **acquisition order, not write order**, and that is the whole reason everything +lands in one central `apply()` instead of writing through from the setters. With write-through, two +live holders would resolve to whichever ability's `update()` happened to run last in +`AbilityManager`'s iteration — an ordering that is real, invisible, and changes when an unrelated +cast expires. With a stack: cast Eclipse, then Dawnbreak, and Dawnbreak's sun wins; let Dawnbreak +expire and Eclipse's is back on the next frame, mid-cast, with no special case anywhere. + +### 1 · `KEY_LIGHT` — the ability moves the sun + +```js +onSpawn() { this._sun = sceneHooks.acquire(Hook.KEY_LIGHT, this); } +onTravel(dt) { const c = settings.dawnbreak; + this._sun.aim(c.sunAzimuth, c.sunElevation * this.u) + .brightness(c.sunIntensity) + .blend(c.sunWeight * this.fade); } +onDestroy() { sceneHooks.reclaim(this); } +``` + +`weight` is the blend against `settings.environment`, so an ability can take the sun 30% of the way +somewhere and the editor's own sliders still read through. At weight 0 the hook is transparent. + +Three things move together and all three matter: + +- **`sun.position` and `sunTarget.position`** — what three builds the shadow camera from, so the real + shadows swing. This is the whole ability: the character, the crystals of a Frost Lance still + standing, and the floor's own relief all throw a real sweeping shadow. +- **`frame.uLightDir`** — every custom material that fakes its own normal reads this. Miss it and the + lit meshes swing while the effects stay lit from the old sun, which reads as the effects being + stickers. This was the bug that took longest to see, because each half looks correct on its own. +- **`shadowMap.needsUpdate`**, defensively. See below. + +**What the moved shadow costs: nothing, today, and it was measured rather than assumed.** +`App.frame` already sets `gl.shadowMap.needsUpdate = true` unconditionally on every frame +(`shadowMap.autoUpdate` is off, and the frame renders the scene four times, so the flag exists to +stop the map being rebuilt for each of them). Reading the flag back straight after a frame returns +`false` — three consumed it — so the 4096² directional map is re-rendered **every single frame** +whether the sun moves or not. + +Toggling `sun.castShadow` puts a number on what that render is: **1 draw call and 9,578 triangles**, +rasterised into 16.8 M depth texels. Swinging the sun changes what lands in those texels and nothing +else: 31 draw calls idle, 31 with the sun sweeping 90° over thirty frames. **Zero added cost.** + +The hook sets `needsUpdate` again anyway, because making shadow refresh conditional is the obvious +win on a stage this static — one static 4096² map, re-rendered sixty times a second, for a scene +where usually nothing has moved — and the day somebody takes it, a swinging sun must not silently +keep last frame's shadows. + +The cost that *is* real is a look cost: the shadow map's contents now change every frame, so PCF's +temporal stability goes and a shadow edge that was rock-steady crawls slightly while the sun sweeps. +Slow the sweep, or raise `environment.shadowRadius` for the duration, and it reads as softness. + +`sheetlightning` uses the same hook and never calls `aim()` — `brightness()` and `tint()` alone, on a +strobe. One number, and every real shadow in the world snaps at once. + +### 2 · `GRADE` — the light goes wrong before anything appears + +Four parameters, and they are the four that read as *the world going wrong* rather than as a filter: +saturation, temperature, lift, vignette. Contrast and gain are deliberately absent — they read as a +camera setting being changed, which is a different sentence. The hook honours `post.enabled` exactly +the way `PostProcessing.sync()` does, so an ability cannot switch the grade back on for somebody who +turned the post stack off. + +`eclipse` drains colour toward the umbra before the disc ever opens. It is the Thunderclap lesson +applied to light: the anticipation carries it. + +### 3 · `AGE` — one parameter ages a real material + +A 0..1 field (centre, radius, edge, amount, and an optional inner cut) that `world/Ground.js`'s +`MeshStandardMaterial` reads through a patch. `entropy` sweeps it outward and back — set `inner` and +the disc becomes a travelling annulus, which is what a wave of decay actually wants, because it has a +trailing edge. + +Five terms come off that one number, and the reason it does not read as a decal is that each one +moves a **different channel of the material**: + +| term | albedo | roughness | metalness | +| --- | --- | --- | --- | +| rust | toward `rustColor`, patchy | up | **up** — the only metal on this floor | +| dust | toward `dustColor`, even | hard up | down | +| moss | toward `mossColor`, in the low-frequency hollows | up | down | +| pit | darkened specks | up | down | +| bleach | desaturate and lift | — | — | + +Metalness is what sells rust. The first version graded albedo only, all five terms, and the aged ring +read as a coloured decal painted on clean stone — because that is what it was. Rust that goes +metallic catches the key light at a different angle from the stone beside it, and the eye reads it as +a different substance before it reads the colour at all. That one channel is the trick. + +Three noise bands at deliberately incommensurate scales (patch, hollow, speck) keep the five terms +from landing in the same places; sharing one band collapses the whole thing back into a tint. Pitting +is shaded rather than displaced, because the floor is one flat plane with four vertices and stays +that way — every raycast in the project assumes it. + +The patch lands at `#include `, the one point in the physical shader where +`diffuseColor`, `roughnessFactor` and `metalnessFactor` are all in scope and still mutable, and it +composes through `patchOnBeforeCompile` so `Ground`'s own tint and sheen patch runs first and is +untouched. It declares its **own** world-position varying rather than reusing `Ground`'s +`vGroundWorld`: one duplicated matrix multiply per vertex, on a plane with four of them, for a patch +that works on any standard material and does not break the day somebody renames a varying in a file +that has never heard of this one. + +### 4 · `HOLE` — not black, absent + +One invisible sphere, drawn **first** among the opaques, that writes depth and no colour. Everything +drawn afterwards that is further away than its front surface fails the depth test and is never +shaded. What survives in those pixels is the clear colour — `environment.backgroundColor`, the same +flat void the floor already fades into at the edge of the stage. Not a black disc in front of the +world: a pixel the world never reached. + +Occlusion comes out right for free, which is the part that would have been fiddly any other way. The +character standing between the camera and the hole is nearer than the proxy, passes the test, and is +drawn. Walk them behind it and they are gone. A shard flying through the volume disappears while it +is inside and is back the instant it clears the front surface. Nothing in the project had to be told +the hole exists. + +It sits on `LAYER.WORLD`, so it is in the depth prepass too, so every soft particle in the sandbox +*fades* as it crosses into the hole rather than clipping at it. That was luck, but it is good luck — +and it is also why the hole costs **two** draw calls and not one: it is drawn once into the prepass +and once into the main pass, and both were counted. + +**A stencil written by a proxy mesh** was the first design and it does not fit this pipeline without +paying three times. `core/Renderer.js` builds the `WebGLRenderer` with `stencil: false`, so the +drawing buffer has no stencil attachment; `EffectComposer`'s ping-pong targets have none either, and +each would need one added and cleared per pass. That is a stencil buffer on the frame plus two on the +composer plus a clear per pass, for a mask that the depth buffer — which already exists, is already +cleared, and is already being tested against by every draw in the frame — hands over for nothing. +Depth is not a workaround here. A stencil is the tool for a mask that has to *ignore* geometry, and +this mask must not. + +**A mask honoured by the final pass** was the second: an analytic sphere in `GradeShader`, projected +to screen space, occlusion-tested against `frame.uSceneDepth`. It has one real advantage — it runs +after `UnrealBloomPass`, so it punches through the bloom as well. Two things killed it. The prepass is +`LAYER.WORLD` only, so it cannot occlude against anything on `LAYER.VFX`: a bolt drawn in *front* of +the hole gets erased along with it, which is exactly wrong. And it is a permanent edit to a shader +every frame in the app runs, to serve one ability. + +The caveat that remains, stated plainly: because the hole is punched in the scene pass, a bright +effect beside it still bleeds across the rim by the bloom radius. At the shipped +`post.bloomStrength` of 0.03 it is invisible; crank bloom and cast `silence` next to something hot +and there is a faint halo lying over the void. + +### 5 · `GRAVITY` — a signed multiplier, published + +```js +this._g.at(x, y, z).well(c.zoneRadius, c.gravityEdge).scale(c.gravitySign, 1); +... +_emit.gravity = c.emberGravity * sceneHooks.gravityAt(x, y, z); // exactly 1 when unheld +``` + +A multiplier rather than a replacement vector, on purpose. Everything that falls in this project +already owns a gravity in metres/second² that is a slider on its own block — `ShatterField`'s +`gravity`, a particle system's `uGravity` — and I1 says that number stays the ability's. Handing out +a replacement takes the slider away from whoever is falling; handing out a signed scale leaves it +where it was and lets `hourglass` flip the sign of everything in the zone with one number. + +`gravityAt()` on the CPU and `gravityScaleAt()` in `gravityGLSL` are the same falloff, smoothstep for +smoothstep, so an emitter integrated on the CPU and one integrated on the GPU agree at the boundary. + +### 6 · `DISRUPT` — the opt-in other abilities join + +`spellbreak` is the one ability aware of the rest. Cast into an empty room it is arcane glass +shattering; cast into a standing Nova Beam it desaturates and fragments what is already there. + +**Three shared materials already opt in** — `FilamentPaths`, `GroundField` and `Swarm` — and between +them that is most of what is ever left standing in a zone: every bolt, snare, chain and crack; every +ground mark in all ten modes; every flock. No ability file was edited to get there, and none needs to +be. + +Joining is one spread and three lines of GLSL: + +```js +import { disruptGLSL, disruptUniforms } from './SceneHooks.js'; + +uniforms: sharedUniforms({ ...disruptUniforms(), /* yours */ }) // the SHARED boxes, not copies +``` + +```glsl +/* vertex */ /* fragment */ +${disruptGLSL} ${disruptGLSL} +varying float vDisrupt; varying float vDisrupt; + ... ... +vDisrupt = disruptAt(worldPos); disruptShade(colour, alpha, vDisrupt, gl_FragCoord.xy); +``` + +Sample it in the **vertex** stage. The field is smooth over metres, so a varying costs one +interpolant instead of a world position and a distance in every fragment — on a filament strip that +is the difference between free and not. + +**Cost when nothing is disrupting:** `disruptAt()` opens with `if (uDisruptRegion.w <= 0.0) return +0.0;`. That is one compare against a uniform, uniform control flow, the same answer for every vertex +in the draw call, and the fragment stage early-outs again on the interpolated 0. It is as close to +free as a shader gets without a recompile, and a recompile is not on the table because the field +switches on mid-cast. + +The fracture is a screen-space **cell** dither, not a per-pixel one and not a displacement. There is +no shared vertex stage to push geometry apart in — a ribbon strip, a four-vertex quad and an +instanced flock have nothing in common — so the erosion has to happen in the fragment stage. +Quantising `gl_FragCoord` into cells and erasing whole cells reads as the effect *breaking up*; +the first version dithered per pixel and read as dissolve-into-static, indistinguishable from a fade +at any distance. `shardSize()` is the cell size and it is the slider that matters. + +Opting **out** is a design choice, not an oversight: an ability whose trick is that it cannot be +broken simply does not add the lines. + +### The pause test, and hook-only abilities + +The harness's I1 probe (`docs/EXPANSION.md` §7 step 6) snapshots the uniforms an *ability* owns. An +ability whose entire output is a scene hook owns none, so thirty working sliders read as dead. The +remedy is `sceneHooks.observe(material)` — call it once at construction on any material the ability +already has, and the module's live state is parked on that material's `userData.uniforms` where the +probe can see it. Nothing in any shader reads those uniforms; they are there to be seen. `apply()` is +idempotent, so an ability may also call it at the end of its own `update()` without disturbing the +one that lands the grade. + +### The one rule + +**Acquire in `onSpawn`, write every frame, and put `sceneHooks.reclaim(this)` in `onDestroy` — then +forget about it.** Everything else in the module exists to make the frame where you *don't* do that +survivable, and none of it is a substitute for the one line. The world is shared; four casts can be +holding pieces of it; and the only version of this that stays correct is the one where returning it +is a reflex. + +--- + + +## `LensFlare.js` — the artefact that belongs to the camera + +Every other module in here draws something that is *there*. This one draws something that is not: +ghosts bouncing between the elements of a lens, a starburst thrown by the iris blades, and the +horizontal smear a cylindrical anamorphic element puts under a highlight. None of it exists in the +world, all of it exists on the glass, and that one fact decides every line of the file. + +Serves `solarlens` (lumen) — *"an occlusion-tested flare … a flare that ignores occlusion is a +sticker on the lens"* — and anything else that wants a bright point to feel bright. + +### Draw-call cost + +**One.** Up to eleven elements are instances of a single quad: instance 0 is the core and its +starburst, 1 the anamorphic streak, 2 the iris ring, 3..10 the ghost train. The vertex shader places +each one directly in NDC from the projected anchor, so the mesh matrix is identity for its whole life +and moving the flare is a `Vector3` copy into a uniform. + +It is fill-heavy rather than draw-heavy — the streak alone is a third of the frame wide — but the +per-fragment work is a handful of `pow`s and no texture fetch at all. + +### Signatures + +```js +/* LensFlare.js ─ 1 draw call ─ .object3D ─ canonical */ +FlareRole={CORE:0,STREAK:1,RING:2,GHOST:3}; MAX_FLARE_GHOSTS=8; lensFlareParams()->object +new LensFlare({ghosts=MAX_FLARE_GHOSTS, renderOrder=3000, layer=LAYER.VFX, name}) +f.object3D · drawCalls(1) · capacity · visible(get/set) +f.setAnchor(v3) · setAnchorXYZ(x,y,z) · anchor(out?)->Vector3 +f.update(p) · dispose() + // `ghosts` in the constructor is the CAPACITY; params.ghosts is how many draw. + // sizes are fractions of the frame HEIGHT — except streakLength, which is a + // fraction of the frame WIDTH. Occlusion is disabled automatically when + // frame.uSceneDepth is null (see the traps). +``` + +### The occlusion test is the module + +A flare lives on the lens, so it is drawn after the scene with `depthTest: false` — a ghost that +disappears behind a pillar is a decal, not a lens artefact. But switching the depth test off throws +away the only occlusion the renderer gives you free, and a flare that survives the character walking +in front of the lamp is the tell that has made cheap flares look cheap for twenty years. + +So the module buys it back. `frame.uSceneDepth` — the half-res packed-depth prepass of the opaque +`WORLD` layer, already rendered every frame for the soft particles — is sampled at the source's own +screen position **in the vertex shader**, with a small area-uniform disc kernel, and the whole flare +is scaled by the average. + +Two things about that kernel are not decoration: + +- **One tap does not work.** The first version sampled the source's exact pixel and the flare does + not dim as the character crosses it, it *switches off*, on one frame, when the silhouette edge + crosses that pixel — and switches back on the same way. Eleven elements covering a third of the + screen popping in and out is more distracting than no occlusion at all. +- **The prepass is half resolution and linearly filtered**, and interpolating *packed* depth between + two texels yields a number that is not a depth. Every `softFade` in the project already lives with + that; spreading taps over the source's apparent size makes the nonsense a minority of the average + rather than the whole answer. + +The kernel is a golden-angle spiral with `sqrt` on the radius so it covers area evenly. It is +deliberately **unweighted**: a Gaussian puts most of the answer back on the centre tap and brings the +popping straight back. What comes out is an estimate of the *fraction of the source's disc that is +showing*, which is the physically meaningful quantity. + +Cost of doing it per-vertex rather than per-fragment: seven taps × four vertices × eleven instances +is 308 depth fetches for the whole flare. Per-fragment it would be of the order of a million. + +### Why the ghosts share one blade count + +`ghostBlades` is one slider. The ghosts are all images of the same iris, so they all have the same +number of sides; a hexagon next to a pentagon next to an octagon is the most common way a drawn flare +announces itself. What each ghost *does* get is its own size (`ghostSize` × `ghostSizeStep^n`, with a +hashed scatter), its own spin (`ghostSpin` × n), its own roundness (`ghostRound` + `ghostRoundStep` +× n — the far ones defocus into discs) and its own tint off the four-stop `colorGhostA..D` gradient. +Alternate ghosts also invert: bright rim and hollow middle, then the other way about, because they +come off the far surface of the doublet. Turn that off and the train reads as one sprite scaled. + +The series is generated from sliders rather than an array of per-ghost records for two reasons. **I1** +— an array of metres is a dimension living somewhere other than a settings block; and a uniform array +may be indexed only by a loop counter in ESSL 1.00, so per-ghost lookups would need the +`if (i == slot)` unrolled loop `FilamentPaths` uses, for values a base, a stride and a hash describe +better anyway. + +Two more details that are correct rather than convenient. An **even**-bladed iris throws as many +spikes as it has blades; an **odd** one throws twice as many, because opposite edges stop being +parallel — a five-bladed lens has ten spikes. And every chromatic effect in the module is **the same +shape evaluated at three radii**, one per channel, not a hue rotation of one evaluation: dispersion +is geometry, and a hue rotation gives you a rainbow that slides around as the ghost moves. + +### Tone mapping, and the shoulder + +The material is `toneMapped: false` at `renderOrder` 3000 with the depth test off, so it is the last +thing in the scene pass and the renderer never puts a curve on it directly. But inside the composer +everything is linear HDR until `OutputPass`, and ACES will take an authored deep-blue streak at 8× +and hand back a white bar — hue clipped, which is the one failure this module cannot have, because +the colour of the streak is the reason it is there. + +So the fragment ends with a hue-preserving shoulder: Reinhard applied to the **largest channel**, +all three scaled by the same ratio. `headroom` is the linear value the peak asymptotes to and it is a +slider. The version before it applied Reinhard per channel — textbook, and it desaturates toward +white exactly as hard as the tone curve we were trying to get out from under. + +Related: the fragment writes `alpha = 1` and puts everything in `rgb`. three's `AdditiveBlending` is +`(SRC_ALPHA, ONE)`, so writing coverage into alpha as well squares it and quietly darkens every soft +edge in the flare. + +### The trap that cost this module an hour + +**Never inject `commonGLSL` into a vertex stage.** It carries `aastep`, which calls `fwidth`, and +derivative functions do not exist in a vertex shader; the whole program fails to compile with an +error pointing at a helper you are not using. `LensFlare`'s vertex stage needs exactly two things out +of that chunk — the packed-depth fade and the four-stop gradient — so it restates them locally +against the same `#include `, eleven lines, and +`scripts/check-vfx-lensflare-mirror.mjs` asserts nobody tidies that back into an injection. + +### The one rule + +**Give it a source that is actually bright, and let the occlusion do the acting.** The flare is a +consequence, not a light: anchor it to something the scene already lights — a lit projectile, a +`Tube` mouth, a `VolumeHull`'s hot core — and drive `intensity` from that thing's own falloff. A +flare hanging in empty air with nothing under it reads as a UI element, and the moment the character +walks in front of the source and it does not dim, everything else stops working too. + + +## `Mirror.js` — a planar surface that reflects the real scene + +A camera mirrored about the surface's plane renders the `WORLD` layer into a small target, and the +surface samples that target projectively. Serves `blackice` (frost) — *"the only true reflective +surface in the sandbox"* — and `refractcascade` (lumen), whose roster line is blunt about the +alternative: fake it with an environment map and it reads as chrome; do it properly and it reads as +glass. + +The reason is **parallax**. An env-map reflection does not slide across the surface as the camera +orbits, and the brain files a static reflection as painted-on shine within about half a second of +movement. That is the whole feature; everything below is the cost of it. + +### Draw-call cost + +`drawCalls` is **1** — the surface. The reflection is not a draw call, it is a whole extra +`renderer.render()` of the `WORLD` layer: a scene traversal, a render list, a sort and every opaque +draw in the world, again, per rendering mirror per frame. + +What it is *not* is a whole frame. No post stack, no depth prepass, no distortion pass, no VFX layer, +and no shadow-map update — the nested render turns `shadowMap.autoUpdate` off and reuses the maps the +main pass already built, which is both cheaper and correct, since the shadows in a reflection are the +same shadows. + +**Measured, not estimated.** `mirrorBudget.calls` / `.triangles` and each mirror's `lastCalls` / +`lastTriangles` are read straight out of `renderer.info` immediately after the nested render, so a +mirror reports what it actually cost on your machine in this scene. The arithmetic parts: at the +default 384² a reflection is 147,456 pixels — 16% of a 720p frame, 7% of 1080p — and the target is +384 × 384 × RGBA16F ≈ 1.13 MB of colour plus a depth attachment, so about 1.7 MB per live mirror. +Halving `resolution` quarters the fill and changes the draw count not at all. + +Two caveats in writing: + +- **`renderer.info` is contaminated on a mirror frame.** `render()` resets the counters at the top of + every call, so a nested render wipes what the outer frame had accumulated. Anything reading + `info.render.calls` for a HUD under-reports on frames where a mirror rendered — which is precisely + why this module publishes its own numbers. +- A mirror that misses its budget slot shows **last frame's** reflection. + +### Signatures + +```js +/* Mirror.js ─ 1 draw call + one WORLD render per rendering mirror ─ .object3D ─ canonical */ +mirrorParams()->object +mirrorBudget = {max:2, live, rendered, skipped, calls, triangles} // live tally +setMirrorBudget(n)->n // 0..8; 0 freezes every reflection +new Mirror({resolution=384, layer=LAYER.VFX, reflectLayer=LAYER.WORLD, renderOrder=4, + doubleSided=true, depthWrite=false, name}) +m.object3D · drawCalls(1) · resolution · visible(get/set) · priority +m.lastCalls · lastTriangles // measured off renderer.info, last reflection +m.setPlacement(anchor, normal, along) // along is re-orthogonalised; call EVERY frame +m.update(p) · dispose() + // no renderer handle anywhere: the pass runs from the mesh's own onBeforeRender. +``` + +### No wiring, and no pass when nothing is visible + +`core/Layers.js#distortionWriters` counts the meshes visible on the distortion layer so +`PostProcessing` can skip that pass — clear included — when nothing is writing to it. The lesson is +that a pass which runs with nothing to do is worse than no pass, and this module inherits it twice. + +**Structurally**: a reflection is rendered from the mesh's own `onBeforeRender`, which the renderer +calls only for a mesh that is visible and in the render list. No visible mirror, no pass — there is +nothing to skip because there is nothing to run. It is also why this module needs no wiring into +`App` or `PostProcessing`: `onBeforeRender` is handed the renderer, the scene and the camera, which +is everything a reflection needs, and no module under `src/vfx/` has to be given a renderer handle it +has no other use for. + +**By cap**: `mirrorBudget.max` mirrors may re-render in one frame, **two** by default. +`refractcascade` puts a line of panes down the cast, and five extra scene renders a frame is not a +cast, it is a slideshow. At the top of each frame every live mirror is scored + +``` +priority × apparent size × (1 + frames since it last rendered) +``` + +and the best few get the slots. Apparent size is the bounding radius over the distance to the eye — +near and large beats far and small, which is the order the eye notices a stale reflection in. The +starvation term is what stops the nearest pane hogging the budget forever, and it is unbounded upward +so a mirror that has **never** rendered outranks everything; until it has, `uHasReflection` is 0 and +the surface shows `colorBase` alone rather than an uninitialised target. + +At 60 Hz with five panes and two slots each pane updates every 2–3 frames — a 30–50 ms old +reflection, invisible on a slow orbit and a slight lag if you whip the camera. Against 5× the cost +that is the right trade, and `setMirrorBudget()` is there for whoever disagrees. + +Selection is a repeated max-scan rather than a `sort`, because a comparator is a closure in the +per-frame path (**I3**), and the live list is never more than a handful of entries. + +### The frame token, which is not `info.render.frame` + +The scheduler has to know when a new frame has started, and the obvious token is wrong. +`renderer.info.render.frame` increments inside *every* `render()` call — the depth prepass, the +distortion pass, and each of our own nested reflection renders. The second mirror in a frame would +therefore see a different value from the first, conclude a new frame had begun, reset the budget and +render — and so would the third. Every mirror renders every frame, which is exactly the failure the +budget exists to prevent, and it would never show up as anything but "mirrors are expensive". + +The module subtracts the nested renders it has itself issued back out of the counter, which makes the +token constant across one traversal. That is all it has to be. + +### The oblique near plane + +The mirrored camera can see things **behind** the mirror, and they must not appear in it — a floating +pane would show the wall it is hanging in front of, welded to its own face. The near plane is +therefore skewed onto the mirror's plane (Lengyel's oblique projection, following `three/addons`' +`Reflector`, which is the reference implementation in this ecosystem), so everything behind the +surface is clipped in hardware for free. A user clipping plane would do the same thing at the cost of +a `gl_ClipDistance` in every shader in the scene, because three implements those by patching every +material. + +A small difference from `Reflector`: this one **flips the plane's normal toward the eye** each frame +rather than skipping a mirror that faces away, so a pane you can walk around works from both sides. +Set `doubleSided: false` for a floor sheet you never see from underneath and get the cull back. + +### Roughness, and how it degrades + +The lookup is blurred by a jittered golden-angle disc of radius `roughness × blurRadius`, taps +1..12 — so `blackice` starts as a scuffed frozen puddle and sharpens into a black mirror by moving +one slider to 0. Two non-obvious things: + +- The jitter hash is on `gl_FragCoord` **and nothing else**. Adding `uTime`, which is the obvious way + to break up the banding, turns a soft reflection into boiling static: there is no temporal filter + in this pipeline to resolve it against. +- The kernel is **stretched** along the screen-space direction of the surface normal + (`roughStretch`). A rough plane does not blur its reflection isotropically; at a grazing angle the + lobe smears along the view-vertical, which is why a wet road pulls headlights into a vertical + streak rather than a disc. The stretch direction is a constant-across-the-quad varying, projected + once in the vertex shader — the same trick `Distortion.js` documents. + +The blur radius is also divided by the frame aspect on x, because a wide frame is squashed into a +square target and a circular kernel in that UV space is an ellipse on screen. + +`ripple` / `rippleScale` / `rippleSpeed` perturb the lookup with `fbm3` sampled in **world** metres +and scrolled in world metres, so orbiting the camera does not drag the disturbance across the +surface. + +### Two guards worth knowing about + +- **The depth prepass draws `LAYER.WORLD` with `scene.overrideMaterial` set.** A mirror placed on + that layer would otherwise render its entire reflection from inside the prepass, into a target the + prepass then throws away. `scene.overrideMaterial !== null` is the tell and it is the guard. +- **A mirror inside another mirror's reflection is refused, not resolved.** `reflectLayer` defaults + to `LAYER.WORLD` and mirrors default to `LAYER.VFX`, so it cannot normally arise; if you point a + mirror at the VFX layer, the second bounce simply does not render. + +### Params + +`mirrorParams()` in the source is the full list with units. Groups: the surface +(`width`/`height`/`opacity`/`edgeFade`/`corner` — 0 rectangle, 1 ellipse — and `seed`), the reflection +(`resolution` in pixels, `reflectivity`, `fresnel`, `fresnelPower`), roughness +(`roughness`/`blurRadius`/`blurTaps`/`roughStretch`), the disturbance +(`ripple`/`rippleScale`/`rippleSpeed`), `priority`, and two pickers: `colorTint` multiplies the +reflection, `colorBase` is what shows where it does not reflect. Neither is derived from the other. + +`resolution` is the only key with a side effect beyond a uniform write — it reallocates the render +target — and it is gated on the value actually moving, so dragging the slider costs one reallocation +per distinct value and nothing on the frames between. + +### The one rule + +**Put something worth reflecting in front of it, and never let it be the only thing on screen.** +A mirror is only convincing when the thing it shows moves relative to it: the character crossing +behind the camera, a `Tube` beam sweeping past, the floor's own relief sliding as you orbit. And keep +the count honest — five panes at 384² with a budget of two is a cast; five panes with the budget +lifted to five is four extra scene renders a frame, and `npm run check` will not save you from that +one because it costs nothing on the CPU. + + +--- + +## `Caustics.js` — the net of light a surface throws on the floor + +An animated caustic net projected onto the ground plane. It is the effect a scene with a floor gets +for nothing and this one did not have. + +### A caustic is an image of a surface, not a pattern + +The reason to build this as a module rather than an ability's own material is the same reason +`GroundField` exists rather than a decal: the interesting thing is not the drawing, it is that the +drawing and something else in the scene are **the same object seen twice**. `tiderush`'s trick is +that you read the wave's thickness off the floor *ahead of* the wave. If the floor is running its +own unrelated loop of squiggles the two never agree, and every viewer works that out in about a +second without being able to say why. + +So the pattern is computed from a **height field**, and the height field is pluggable: + +| `CausticSource` | the height field | +| --- | --- | +| `SCROLL` | two counter-drifting worley lattices, differenced — the procedural fallback | +| `WAVE` | the Gerstner swell, breaking crest and ripple packets `LiquidSurface` is drawing, **using its uniform boxes** | +| `CUSTOM` | a GLSL chunk you supply, defining `float causticHeight(vec2 xz)` and `float causticRidge(vec2 xz)` | + +`bindSource(liquid.uniforms)` is the whole hook. It swaps our boxes for the surface's, by identity, +for every key in `CAUSTIC_BOUND_KEYS` — and `update()` then **skips writing any key that is bound**, +because a number with two authors has none. After one call there is literally one set of numbers +driving the wave and the light under it. Pause, drag `crestHeight`, and both answer, because there +is nothing to keep in step. + +### The pattern, and the version that failed + +The received recipe is *the difference of two scrolling worley fields, raised to a power*. The first +version here was exactly that, and it is wrong in a way that is hard to unsee once noticed: raising a +cell distance to a power gives you **blobs with soft shoulders**, because a worley field is smooth +everywhere except at its cell walls. Real caustics are the opposite — hairline highlights an order of +magnitude brighter than anything near them, with genuinely black water between. No exponent fixes +that, because the quantity being sharpened is the wrong one. + +What is actually happening is a *fold*. Light entering at `xz` refracts and lands at + +``` +A(xz) = xz − k·D·∇h(xz) k = 1 − 1/ior, D = depth in metres +``` + +and the brightness at the arrival point is the reciprocal of how much that map stretched the patch it +came from: `1/|det J|`, `J = ∂A/∂xz`. Where the map folds, `det J` crosses zero and the brightness +diverges. That singular set is a **curve**, which is why the filament comes out thin without being +told to be; everywhere else `det J ≈ 1` and the floor is black. Both halves of the look come out of +one term. + +``` +J = I − a·H(h) a = k·D, H = the Hessian +det J = (1 − a·hxx)(1 − a·hzz) − (a·hxz)² +``` + +Six height taps give the Hessian by finite differences. That moves the worley difference **one +derivative earlier** — it is still what `SCROLL`'s height field is made of, but the folds of a +worley-difference surface are the sharp veins along its cell walls rather than the walls themselves. + +Two things worth not re-learning: + +- **The diagonal tap is not optional.** Drop `hxz` and every fold that is not aligned with the quad's + own axes disappears, which shows up as a net made of plus signs. +- **The fragment-only detail chop must stay out of the height field.** It is a normal-map wrinkle + worth a few millimetres and its second derivative is enormous; feed it in and the floor fills with + a fizzing static of sub-pixel folds that aliases the moment the camera moves. `WAVE` deliberately + reads `uChop` and not `uDetail`. + +### Chromatic dispersion is free, and it is real + +Three channels refract at slightly different `ior`, so `a` differs per channel, so the curve +`det J = 0` sits in a *different place* for red than for blue. The fringes are therefore three +filaments a few centimetres apart, not one filament with a hue gradient painted along it — which is +the difference between water and a decal with a rainbow on it. One extra `det` per channel, no extra +taps. `dispersion` is the slider; `0.06` is water and `0.25` is a gemstone. + +### Reading the water's thickness off the floor + +`absorb` attenuates the net by `exp(−absorb · (depth + h))`, where `h` is the same height the fold +came from. It is one line and it is the term that makes `tiderush` work: the net dims under the body +of the crest and flares in the thin water on its face, with no input beyond the height field that was +already there. + +### The projector + +Intensity is shaped by the projector's own falloff, never by the pattern: + +- **`DISC`** — radial, `penumbra` as a fraction of `radius`. +- **`CONE`** — an apex `projectorHeight` metres up `lightAxis`, half-angle `coneAngle`. Because the + test is the cosine against the axis, a slanted axis gives a correct **ellipse** on the floor for + free — which is the whole reason the axis is a parameter and not hard-wired to `+Y`. +- **`LANE`** — a band `laneWidth` either side of local `+Z`, windowed to `spanBack` metres behind and + `spanFront` metres ahead of `front`. A `LINE` cast's caustics live in a band ahead of the wave; a + disc there is a spotlight. + +### Depth + +The quad lies in the ground plane with the depth test on, so the character occludes it. That is not +enough on its own — a floor quad and the floor are within millimetres of each other and a planted +foot gets a hard bright line up its ankle — so the fragment also fades against `frame.uSceneDepth` +over `depthFade` metres, exactly as `GroundField` does. The net dies out as it approaches anything +standing on the floor instead of climbing it. + +Output is **premultiplied** (`ONE`, and `ONE`/`ONE_MINUS_SRC_ALPHA` on the destination), the way +`Portal` and `Curtain` are. `AdditiveBlending` was the first version and it is wrong here: it is +`(SrcAlpha, One)`, so it multiplies the colour by an alpha *derived from the colour*, squaring +everything dim. A caustic is nine-tenths dim. `additive: false` then gives a genuine darkening, for +oil and ink. + +### Draw-call cost + +**One.** No textures. + +It is *fill*-bound, and `SCROLL` is the expensive source: six taps × two lattices × nine cells is a +hundred-odd hashes a pixel. `WAVE` is far cheaper (four sines, an fbm and the live ripple packets per +tap). Treat it like `LiquidSurface`: one per screen, and keep `radius` honest, because the quad is +sized from it every frame. + +### Signatures + +```js +import { + Caustics, CausticSource, CausticShape, causticsParams, + CAUSTIC_BOUND_KEYS, CAUSTIC_RIPPLE_SLOTS +} from '../vfx/Caustics.js'; + +CausticSource = { SCROLL: 0, WAVE: 1, CUSTOM: 2 } // a #define, fixed for the lifetime +CausticShape = { DISC: 0, CONE: 1, LANE: 2 } // ditto +CAUSTIC_RIPPLE_SLOTS = 8 // NOT configurable — it must match LiquidSurface's + +new Caustics(parent, { + source = CausticSource.SCROLL, + shape = CausticShape.DISC, + custom = '', // required GLSL for CUSTOM; throws without it + uniforms = null, // extra boxes merged in, shared by identity + additive = true, + depthTest = true, + layer = LAYER.VFX, + renderOrder = 7, + name = null +}) + +net.object3D net.drawCalls // 1 net.boundCount // 0 means procedural +net.setVisible(v) net.setAdditive(v) +net.bindSource(uniforms, keys = CAUSTIC_BOUND_KEYS) -> number // boxes taken +net.unbindSource() +net.ripple(u, v, strength = 1, now = 0) -> Vector4|null // u,v are FRACTIONS of `half` +net.clearRipples() net.reset() +net.update(p) // NO clock argument — see below +net.dispose() + +causticsParams() -> object // every canonical key with its default and its unit +``` + +`update(p)` takes **no clock**, for the reason `GroundField.update(p)` takes none: every beat arrives +as a unitless `0..1` on `p` and the animation clock is `frame.uTime`, read by the shader itself. The +one timestamp the module needs is `p.now`, the ripple epoch — a timestamp, not a duration. + +### Params + +`causticsParams()` in the source is the full list with units. Groups: placement +(`centre`/`lightAxis`/`yaw`/`height`/`radius`/`length`), the beats (`fade`/`front`/`now`/`seed`), +refraction (`depth`/`ior`/`dispersion`/`sampleStep`/`absorb`), the fold +(`foldFloor`/`threshold`/`gain`/`sharpness`/`rolloff`), the `SCROLL` lattice +(`sourceAmp`/`cellScale`/`cellRatio`/`cellJitter`/`driftAngle`/`driftSpeed`/`boil`, and +`ridgeMix`/`ridgeScale`/`ridgePower` — the received recipe, kept low by default so the fold has +something to sit on in shallow water), the `WAVE` block (all of it overridden by `bindSource`), the +projector, and the output including three pickers — `colorNet` for the filaments, `colorFringe` for +the very top of a fold, `colorWash` for the general light in the pool. None derived from another. + +### The one rule + +**Bind it to the surface that is supposed to be making it, or turn `depth` down until you can see +that you have not.** The fold term is driven by `depth × (ior − 1)`; at `depth = 0` there is nothing +to fold and you are looking at `ridgeMix` alone, which is the mottle this module was written to stop +shipping. If there is no surface above the floor to bind, there is probably no reason for caustics +to be on the floor. + + +--- + +## `LightShaft.js` — light in air, integrated along the view ray + +Volumetric shafts: a real in-scattering integral through a cone of lit air, clamped by the depth +buffer, landing in a bright band on the ground that is produced by the same integral rather than +decalled on afterwards. + +### Why this is not `Curtain(SHAFT)` + +`Curtain` already has a `SHAFT` mode. It is good and it stays. It is also a **sheet**, and three of +the four things this module has to do are not expressible on one. That was checked before the file +was written, not asserted after: + +1. **Anisotropy.** The single thing that makes light in air read as light in air is that a shaft seen + nearly end-on is several times brighter than the same shaft seen across. That is the + Henyey–Greenstein phase function of the angle between the view ray and the **shaft's axis**. A + sheet's only angular term is `1/|N·V|` against the **sheet's normal** — a proxy for path length + through a fold, pointing the wrong way: it peaks when you look *along the sheet*, not when you + look *along the beam*. Curtain's own section is honest about what that term is for. +2. **A path length that is a path length.** A sheet has no thickness, so its brightness is a function + of where you hit it. Here the ray genuinely enters and leaves a cone and the segment between them + is integrated. Move the camera toward the axis and the segment lengthens on its own; no term had + to be written to make that happen. +3. **A soft floor intersection.** A sheet meeting the ground gives a straight cut, which is why + Curtain has `footFade`. Here the far end of the integral **is** the depth buffer, feathered over + `contact` metres, so the shaft dies into whatever it actually meets — floor, character, a rock — + at the right distance and with the right shape. +4. **The band on the ground.** Curtain's floor companion is a second quad with its own pool term, and + its section says so. Here the band *is* the shaft: the view ray terminates somewhere, we know + where, and the shaft's own radial falloff, its own canopy gaps and its own axial extinction are + evaluated at that point. Drag `gobo` and the leaf-gaps on the floor change with the gaps in the + air, because there is one field and not two. + +Extending `Curtain` would have meant a second geometry, a second placement path, a second integral +and a mode sharing nothing with the other two but the file. `Curtain(SHAFT)` is still the right +answer for a rank of cheap god-rays behind something; this is the right answer when the shaft is the +subject. + +### How it is rendered, and why every part of that is deliberate + +The hull is an instanced capped cylinder that the vertex shader bends into each shaft's own truncated +cone. It carries **no lighting at all**: the fragment gets five varyings describing the *primitive* — +mouth, axis, length, the two radii — and solves against those, not against the triangles. `sides` can +be twelve; a coarser bound only means a few more fragments that immediately find no intersection and +discard. + +- **Back faces.** Every view ray that enters the hull then has exactly one fragment to do its + integral in, whether the camera is inside the hull or outside it. +- **Caps on.** Open-ended, a ray straight down the axis finds no back face and punches a hole through + the middle of the shaft — which reads as a ring of light with a dead centre, exactly wrong. +- **Depth test off.** Occlusion is *part of the integral*. A shaft behind a wall must be dark because + its light never reached you, not because a test threw the fragment away, and the difference shows + the moment a character's shoulder is halfway into it. The far limit comes from `frame.uSceneDepth`, + converted to a distance along our own ray by the third row of `viewMatrix` — no inverse projection + needed. +- **Alpha 1.** With `AdditiveBlending` (`SrcAlpha`, `One`) the destination gets `rgb` exactly once. + Writing the luminance into alpha instead — the obvious thing — makes the blend square it, so every + dim part of the shaft vanishes and only the core survives. Light adds; it does not add in + proportion to how bright it already is. + +Inside the march: a **gaussian** radial falloff, not a smoothstep — a shaft has a falloff, not an +edge, and a smoothstep draws the surface of the cone you were trying not to have (the same lesson +`Curtain(SHAFT)` learnt). A fixed per-shaft `gobo` sampled on the *cross-section only*, so a gap is a +gap all the way down rather than a dent in the middle. `exp(−extinct · a)` down the axis so the beam +loses energy on the way to the floor. And `axialMouth` is a floor on the axial curve, not tidiness: +a bare `pow(k, curve)` is exactly zero at the mouth, which puts a hard flat disc of nothing where the +shaft enters and reads as the shaft having been cut off with scissors. + +### The scene's own dust + +`irradianceAt(point, params) → 0..1` is the answer to "the dust in the shaft must be the scene's own +dust motes, brightened as they pass through it, not a second system". The module cannot reach into +`ParticleEngine`, but it can tell an ability exactly how lit a world point is, and the ability can +multiply that into its own particles' brightness — one shared field, two consumers, which is the same +shape as `Caustics#bindSource()`. It mirrors the radial gaussian, the axial curve and the extinction, +and deliberately leaves out the gobo, for the reason `LiquidSurface#lipPosition()` leaves out the +chop: a caller wants a smooth envelope to multiply into a particle, not a field that flickers as a +mote crosses a leaf edge at four metres a second. + +The in-shader `mote` lattice is still there for the shafts' own sparkle and is free to switch off +(`mote: 0`) once the ability is feeding real particles through `irradianceAt`. + +### Draw-call cost + +**One**, for any number of shafts — one `InstancedBufferGeometry`, every shaft placed by the vertex +shader. No textures. + +The fragment is the expensive one in the library after `VolumeHull`: `steps` samples, each with a +`snoise` for the canopy and optionally a hashed lattice for the motes. `steps` is a slider and +`maxSteps` is the compile-time cap; 28 of a possible 48 is the default and 16 is fine for a shaft +that is never looked at end-on. + +### Signatures + +```js +import { LightShaft, ShaftLayout, lightShaftParams, SHAFT_LAYOUT_NAMES } from '../vfx/LightShaft.js'; + +ShaftLayout = { SINGLE: 0, LINE: 1, RING: 2, SCATTER: 3 } // a uniform — live, no recompile + +new LightShaft(parent, { + capacity = 6, // hard ceiling on shafts + layout = ShaftLayout.SINGLE, + sides = 14, // hull tessellation. It is only a bound + maxSteps = 48, // compile-time cap on the march + layer = LAYER.VFX, + renderOrder = 10, + name = null +}) + +shafts.object3D shafts.drawCalls // 1 shafts.instanceCount // → Ability#instanceCount +shafts.visible (get/set) shafts.layout (get/set) +shafts.setPlacement(anchor, along, up) // `up` may be tilted: pass -frame.uLightDir +shafts.roll(seed = Math.random() * 100) // onSpawn(); the only dice roll +shafts.reset() // onDestroy(); leaves it reusable +shafts.update(p) // no clock argument +shafts.footPoint(index, p, out) -> v3 +shafts.mouthPoint(index, p, out) -> v3 +shafts.irradianceAt(point, p) -> 0..1 +shafts.dispose() + +lightShaftParams() -> object +``` + +`anchor` is on the **floor**: the foot of every shaft sits there and the mouth is `length` metres up +`up`. Hand it `-frame.uLightDir` and the shafts slant with the stage's own sun, which is the +difference between "there is a spotlight here" and "the light in this room is coming through +something". + +### Params + +`lightShaftParams()` in the source is the full list with units. Groups: the rank +(`layout`/`count`/`spacing`/`ring`/`scatter`/`seed`), the shaft +(`length`/`radiusMouth`/`radiusFoot` — a cone if the radii differ, a cylinder if they do not — plus +their jitters and `hullPad`), the medium +(`steps`/`jitter`/`density`/`extinct`/`soft`/`axialCurve`/`axialMouth`/**`anisotropy`**/`contact`), +the canopy (`gobo`/`goboScale`/`goboBias`/`goboDrift`), dust +(`mote`/`moteScale`/`moteSize`/`moteFall`), the ground (`bounce`/`poolSoft`/`landBand`), the beats +(`fade`/`sweep`/`sweepWidth` — a lit window travelling down the rank, which is how a shaft *sweeps* +along a `LINE` cast), and four pickers: `colorMouth`, `colorFoot`, `colorMote`, `colorPool`. None +derived from another. + +Per-shaft state is four **dice** and an index. Every metre they turn into is resolved in the vertex +shader from live uniforms each frame, which is why a paused rank re-lays itself under `spacing`. + +### The one rule + +**Put the camera somewhere it will look down the axis.** `anisotropy` is the module and it is +invisible from side-on: a shaft seen across is a soft cone and any of five cheaper things would have +drawn it. Aim the cast so the player ends up looking up the beam — or give the rank a `sweep`, so +each shaft passes through end-on as the window crosses it — and the eight-to-one swing in brightness +does the work nothing else in the library can do. + +--- + + +## `FoldMesh.js` — paper that folds without stretching + +A flat sheet with a **crease pattern**, folded from one number, instanced into a flock. + +`origami` throws cranes downrange that *unfold* into flat sheets in the air. `scrollward` stands a +ring of scrolls up and pays them out into a wall of text. Both are the same object: a rectangle of +paper being told, by a single `progress` slider, how far along a sequence of rigid motions it +currently is. + +### The one rule + +**Paper does not stretch.** That is the module. Everything below is in service of it, and if you +change a line in the vertex shader, change it so that this stays true. + +The first version did the obvious thing — author the flat sheet, author the folded sheet, `mix()` on +`progress`. Four lines, and wrong in a way you cannot un-see. A vertex travelling in a straight line +between two positions that are *rotated* apart cuts the chord instead of walking the arc, so every +span across a fold **shortens** on the way over: a 180° fold at halfway has lost `1 − cos(θ/2)`, +thirty per cent, of its width. The crane deflates as it closes and re-inflates as it opens. It reads +as rubber, and no amount of shading rescues it. + +So nothing here interpolates positions. Every vertex is moved by a **product of rigid motions**, one +per crease, and a product of rigid motions preserves every distance on the sheet by construction — +at every intermediate value of `progress`, not just at the ends. The isometry is not a quality +setting; it is a consequence of the representation. + +### A crease + +A line in **sheet space** — the unfolded material, `-0.5 .. 0.5` on both axes, which never changes +whatever the paper is doing — plus a signed angle. + +- The **moving flap** is the half-plane you reach by turning right from the crease's direction: + `n = (dir.y, −dir.x)` in sheet coordinates, which is `cross(up, dir)` in three dimensions. + Material at `d ≤ 0` is held; material at `d > 0` moves. +- A **positive** angle lifts the flap toward `+y` (a *valley* seen from above); negative drives it + down (a *mountain*). The tables use the exported `VALLEY` / `MOUNTAIN` constants, because a crease + pattern read six months later is a list of signs and nothing else. +- A crease is **not a knife edge**. Over a band `hinge` metres wide the flap rolls onto a cylinder + tangent to the sheet, and only past the band is it rigidly rotated. Real paper does exactly this, + and it costs nothing: a cylindrical roll parameterised **by arc length** is itself isometric. The + band is also where the crease highlight lives, so a fold catches light along its length with no + extra geometry. + +The per-crease operator is, in the crease's own frame, a rotation by `φ = clamp(d/w, 0, 1)·θ` about +the hinge axis followed by a slide of `−min(d, w)` along the rotated in-plane direction. That is a +rigid motion **for each material point**, which is why it composes with the others, and +differentiating it along `n` gives exactly 1, which is the proof the band does not stretch either. +Both halves of that sentence were worth the afternoon they took: the naive "rotate the flap about the +axis" form is rigid but has a knife crease, and the naive "roll it onto a cylinder" form is smooth +but is not a rigid motion of the *point*, so it silently threw away every fold applied before it. + +### The hierarchy + +Real folding is a tree: the head is folded, then the neck the head is on, then the body the neck is +on, and the earlier folds ride along. Tables are authored **root first**, the way you would describe +the model out loud, and the shader walks them **backwards** so the deepest fold reaches the vertex +first and everything above it carries the result. Crease lines stay in flat sheet space — a parent's +rotation transports its children automatically. + +Two consequences: + +- the side test is against the vertex's **unfolded** position, never its current one. Which side of + a crease material is on is decided by the paper, not by where the paper is pointing; +- the loop runs `MAX_CREASES` times and skips empty slots, because `uCreaseLine[i]` may only be + indexed by something built out of constants and the loop counter. `MAX_CREASES - 1 - k` qualifies; + `uCreaseCount - 1 - k` does not, and does not compile on ANGLE. + +### The patterns, and how they are stylised + +`FLAT` (a sheet of paper, and not a lesser thing — it is what a crane becomes), `DART` (six creases), +`CRANE` (seven), `FAN` (an accordion, generated by `fanCreases(count, turns)`) and `UNROLL`. + +The honest statement of the stylisation: a real crease pattern limits a crease to a *region*, and a +half-plane fold cannot. Where a pattern needs one — the crane's neck and tail are strips, not halves +— the crease carries a **gate**, a slab through its own origin outside which the fold does not apply. +A gate is a **cut, not a crease**: the sheet separates along it. Use one only where the real pattern +already has a crease there, keep `hinge` small enough that the seam is a line rather than a gap, and +do not go looking for a paper aeroplane you can fly. + +`CRANE` also wants a **square**. `aspect` away from 1 stretches the crease pattern, the fold angles +stop meeting and the tips open. That is not a bug in the fold; it is the pattern being asked to do +something paper cannot. `DART` tolerates a rectangle. `FAN` does not care. + +`fanCreases()` is the clearest demonstration of the hierarchy in the module: the first crease turns +the sheet by θ and every crease after it turns by **−2θ, +2θ, −2θ…**, because each one has to undo +its parent and go the same distance again the other way. Author them as ±θ each and you get a spiral, +not a fan — which is how the sign convention got tested. + +Staging is authored per crease as a `[t0, t1]` window on `progress`, so a bird assembles in the order +you would fold it. Run `progress` backwards and it comes apart in the order you would unfold it, +which is `origami`'s whole beat. + +### `UNROLL`, and why arc length is the only correct parameterisation + +A scroll is not a crease pattern; it is one continuous bend. The sheet is placed **by arc length from +the free end**: + +- the paid-out run is an arc of constant curvature `curl` — the paper remembers the roll — using + `sin(κa)/κ` and `(1−cos(κa))/κ`, which is a cylindrical bend and therefore isometric. Below a + hundredth of a curvature unit it falls back to the straight-line limit rather than dividing by + nothing; +- the wound part is an **Archimedean spiral**, `r(w) = √(r₀² + wt/π)` — the exact relation between + wound length and radius for paper of thickness `t` on a core of radius `r₀`. It **tightens toward + the spool** because that is what the square root does, and the turn `θ = 2π(r_outer − r)/t` is its + integral. Arc length along it comes back as `√(1 + (t/2πr)²)`: the residual stretch is five parts + in a million at a millimetre of paper on a 30 mm core, a thousand times under a pixel. + +Place by *fraction of the sheet* instead and you have declared that a tight inner turn and a flat +metre of paper are the same amount of material — the marks bunch at the spool and stretch on the run. +Because every mark the module draws (grain, laid lines, ink) is a function of the sheet coordinate +and nothing else, the **foreshortening is free and correct**: the shader never learns it is on a +curve, and the writing compresses coming off the roll because the paper it is printed on genuinely is +compressed there in screen space. + +`spoolClimb` picks which end is pinned: 0 holds the tangent point and lets the sheet grow downward, 1 +holds the free end on the floor and the spool climbs — the wall rising out of nothing that +`scrollward` is after. + +### The shading: it has to be paper + +Five pickers, none derived from another: the sheet, its shaded side, the colour of light coming +*through* it, the ink, the crease. + +- **Translucency is the tell.** A sheet with light behind it glows, and it glows *less where the ink + is*, because ink is opaque and paper is not. That one multiply is the difference between paper and + painted card. +- **The grain is anisotropic and lives in sheet space.** Fibres lie along the machine direction; the + noise is stretched `grainAniso` times along `grainAngle` and does not move when the sheet folds, + because it is printed on the material rather than projected onto it. +- **Laid lines** — the faint regular ribbing of a laid sheet, a fine period plus chain lines every + few centimetres. Free, and it is what stops a big flat scroll reading as a polygon. +- **The crease highlight** is the hinge band, brightened on the mountain side and darkened on the + valley side, scaled by how far the fold has gone. It appears as the paper folds and vanishes when + it opens, which is what a crease does. +- **The writing** is hashed bars inside a grid of cells in sheet space — four strokes per cell reads + as script at any distance a viewer will be at; the fifth costs the same and reads as noise. + +This is the only material in `src/vfx/` with `toneMapped: true`. It is paper, not light: left out of +the tone map a white sheet is the brightest thing on screen and blooms, and the ink school forbids +bloom. + +### Draw-call cost + +**One**, for the whole flock — one `InstancedBufferGeometry`, every sheet placed, folded and lit by +the vertex shader. No textures. + +The vertex cost is `segments² × MAX_CREASES` rotations per sheet and it is the number to watch: 24 +segments and 12 creases is 7k rotations a sheet. `segments` and `segmentsV` are separate constructor +options because a crane wants resolution on both axes and a scroll spends almost all of it on one. +The crease is only as sharp as the grid can resolve — a `hinge` narrower than one cell quantises into +a kink. + +### Signatures + +```js +import { FoldMesh, FoldPattern, FoldLayout, foldMeshParams, foldMeshSchema, + fanCreases, CREASE_PATTERNS, MAX_CREASES, VALLEY, MOUNTAIN } from '../../vfx/FoldMesh.js'; + +FoldPattern = { FLAT: 0, DART: 1, CRANE: 2, FAN: 3, UNROLL: 4 } +FoldLayout = { LINE: 0, ZONE: 1, SINGLE: 2 } +MAX_CREASES = 12; VALLEY = 1; MOUNTAIN = -1 +CREASE_PATTERNS // frozen, keyed by FoldPattern +fanCreases(count = 8, turns = 0.5) -> Crease[] + +new FoldMesh(parent, { pattern = FoldPattern.CRANE, layout = FoldLayout.LINE, capacity = 32, + segments = 20, segmentsV = segments, renderOrder = 4, + layer = LAYER.WORLD, name = 'FoldMesh' }) +f.count · drawCalls (1) · uniforms · visible (get/set) · layout (get/set) · pattern · capacity +f.setPattern(FoldPattern.* | Crease[]) // no recompile, no allocation +f.setBasis(origin, direction, side, length) +f.setColors(paper, shade, transmit, ink, crease) +f.roll(seed = Math.random() * 100) · reset() +f.update(_now, params) // FIRST ARGUMENT IGNORED +f.sheetPoint(index, params, out) · spoolPoint(index, params, out) · dispose() + +// a crease, all of it unitless — fractions of the sheet and multiples of PI +{ o: [u, v], dir: [du, dv], turns, t0, t1, hinge, gate: [gu, gv], span: [min, max] } +``` + +`update()`'s clock argument is **ignored**, like `Swarm` and `Curtain`: the bob runs on the shared +`uTime` because a sheet in the air is a standing motion, and the fold runs on `progress` because the +staging is the ability's, not the module's. + +### Params + +`foldMeshParams()` is the key list. The sheet (`sheetWidth`/`sheetLength`/`aspect`/`sizeJitter`/ +`thickness`), the fold (`progress`/`foldGain`/`hinge`/`stageEase`/`foldStagger`), the roll +(`payout`/`core`/`paper`/`curl`/`spoolClimb`/`spin`), the flock (`count`/`travel`/`spread`/`stretch`/ +`lift`/`liftJitter`/`radius`/`radiusJitter`/`arc`/`arcPhase`), attitude (`pitch`/`yaw`/`yawJitter`/ +`roll`/`rollJitter`/`bob`/`bobRate`/`tumble`), appearing (`reveal`/`revealSpread`), the surface +(`grain`/`grainScale`/`grainAngle`/`grainAniso`/`fleck`/`laid`/`laidPitch`/`chainPitch`/ +`creaseGlow`/`creaseDark`/`creaseSharp`/`transmit`/`transmitPower`/`wrap`/`sheen`/`gloss`/`edge`), +the writing (`ink`/`inkRows`/`inkCols`/`inkFill`/`inkWeight`/`inkMargin`/`inkSeed`/`inkGhost`), and +five pickers. `foldMeshSchema(label)` spreads the lot into an ability's editor schema. + +Per-sheet state is **eight dice** and nothing else. Every metre they turn into is resolved in the +vertex shader from live uniforms each frame, which is why a paused flock re-lays itself under +`spread` and re-folds under `progress`. + +### `thickness`, which is not decoration + +`DART`'s nose folds lie flat onto the sheet beneath them, and two coplanar surfaces fight for the +same depth along their whole shared face. Every vertex is pushed out along the final normal by +`thickness` per fold it is downstream of. It is the cheapest possible depth-fight fix and it is also +physically what paper is doing. + +--- + + +## `Dissolve.js` — three ways for matter to stop being there + +`unmake` takes a thing apart into cubes. `avalanche` buries the floor under a heap of snow that keeps +collapsing over itself. `entropy` ages a surface until it is gone. Three abilities, one idea — +**something is being lost and the loss has a shape** — and three shapes that could not be less alike. + +### Why this module has two entry points and not one + +`VOXEL` and `EROSION` are ways of *taking away* something that already exists. The geometry is not +this module's, the material is not this module's, and the ability reaching for them already has both +— so they are a **patch**. `GRANULAR` is the opposite: nothing in the scene is heap-shaped, so the +heap has to be **drawn**, and that is a class with a mesh in it. + +```js +patchDissolveMaterial(material, { mode: DissolveMode.VOXEL }) // and/or EROSION +new DissolveField(parent, { along, across }) // GRANULAR +``` + +One class doing both meant either an object owning a mesh half its callers threw away, or a patch +that had to invent a surface out of nothing. The seam is here because the two halves genuinely +differ. + +### VOXEL — the cube size grows, which is why it reads as *accelerating* + +Matter comes apart into cubes that drift and wink out, and **the cubes get bigger as it goes**. +That is the ability: losing eight small cubes and losing one cube eight times the size are the same +volume, but the second looks faster, because the eye counts events and not litres. + +Entirely in the vertex shader, against a lattice whose cell size is a uniform. **No CPU rebuild** — +the geometry is untouched and the mesh is the one the ability was already drawing. + +The first version slid the cell size up continuously, and it does not work: a lattice with a sliding +cell size re-partitions the mesh *every frame*, so a chunk drifting away as part of one cell is +suddenly half of two others and the whole surface visibly reshuffles, twice a second, like a bad +mosaic filter. + +The fix is a **power-of-two ladder walked once per vertex**: + +``` +for rung r = 0 … rungs-1: + s = cell · 2^r // this rung's cell size + id = floor(p / s) // which of its cells this vertex is in + if hash(id, r) < take or r is last: + this vertex belongs to THIS cell, for ever. Stop. + it lets go at t = (r + hash2(id, r)) / rungs +``` + +Every vertex is claimed exactly once, by the *finest* rung that wants it, and the claim depends only +on the rest position and the hashes — so it never changes, never re-partitions and needs no state. +Material rung 0 did not claim is still there when rung 1 comes round and is taken away in pieces +twice the size, and twice that again on rung 2. **The acceleration is not animated; it falls out of +the ladder.** The last rung claims whatever is left, or unlucky material would survive the dissolve +and hang in the air for ever. + +A claimed cell then snaps its vertices toward a sub-lattice (`block`, which is what makes the chunk +*cubic* rather than merely detached), tumbles about its own centre, drifts on `k`, falls on `k²` — +one is a push and the other is gravity, and giving them the same curve is why an early version looked +like the cubes were on strings — and finally scales to nothing **about the cell centre**, so it +disappears whole instead of collapsing into a spray of stretched triangles crossing the model. + +**The voxel lattice is always object space**, and that is not the same choice as the erosion field's +`space`: the displacement it produces is added to `transformed`, which is object space, and a +world-space lattice would hand a rotating mesh a drift direction that swings round with it. So `cell` +is metres *in the mesh's own units* (a non-unit scale scales the cubes with it), and every instance +of an `InstancedMesh` comes apart identically — usually what you want from a field of one thing, and +otherwise a per-instance `seed`. + +### EROSION, and the world/local question, which has a real answer + +A threshold on a warped fbm with a bright band above it. One warp octave, because an unwarped +threshold gives round holes and round holes read as a texture. + +`dissolveMask()` in `shaders/lib/common.glsl.js` does exactly this and is deliberately **not** used +here: that chunk pulls in ``, and injecting it into a built-in material defines the depth +helpers twice. The two lines are re-stated locally. + +The field is sampled in **local** or **world** space and it matters: + +- **LOCAL (default)** — glued to the object. A thing being consumed keeps its burn on the same part + of itself however it moves, which is what you want when the *object* is the event. Use it for + anything that travels, spins, or is instanced and scattered. +- **WORLD** — glued to the room. Several separate meshes handed the same uniform box dissolve as + **one event**: the front crosses all of them consistently and the seams between them stop existing, + which is the only way `unmake` can eat a standing ice field that is nine draw calls. The cost is + that a moving mesh *slides through* the pattern, so the burn crawls over its surface. On something + slow that reads as the room eating it; on something fast it reads as a bad projection. **Do not use + world space on a projectile.** + +`biasDirection` × `biasAmount` adds `dot(p, dir)·amount` to the threshold, which turns the noise into +a **front** sweeping a chosen way rather than a rash breaking out everywhere at once. + +### GRANULAR — a heap, not a wave + +The roster's line for `avalanche` is "the front is not a wave; it is a heap that keeps collapsing +forward over itself", and a heightfield with a sine in it will never be that. What makes a heap a +heap is the **angle of repose**: granular material piles until its surface reaches a critical slope +and then refuses to get steeper, so it grows sideways instead of upward. + +So the surface is the **upper envelope of a train of collapsing cones**: + +- a *lobe* is released every `1/rate` seconds at the front, which is at `frontSpeed · t`. Its + identity is its release **ordinal**, not its slot in the loop — the version that hashed the slot + index gave every lobe a new size and place each time the window shifted, and the heap boiled; +- it is born **over-steep** (`repose + excess`) and relaxes toward repose at `slump` per second, + which is what wet snow and dry sand both do. With its volume held, that alone makes it get shorter + and wider: `H = ∛(3V·tan²φ/π)`, `radius = H/tanφ`. One exponential and a cube root, and the lobe + collapses on its own; +- the surface is the **max** of the lobes, not the sum. Heaps merge by taking the upper envelope — + that is what a repose surface *is* — and summing them gives a smooth mound with no ridges, which is + a pudding. The creases where two lobes meet are the whole texture of the thing; +- each lobe's centroid creeps forward at `creep` while it collapses, so new lobes land **on top of** + older ones and slide over them. That is "collapsing forward over itself", and it is one term. + +Behind the train, `bed` leaves a settled deposit; without it the tail vanishes as lobes age out of +the window and the avalanche is a comet. + +Nothing is integrated and nothing is stored. Every lobe is a closed-form function of `now − birth`, +so a paused heap re-slumps under a dragged slider — pull `repose` down with the clock stopped and the +whole avalanche flattens and spreads, which is worth doing once just to watch. + +The normal is a **forward difference** at the grid's own scale rather than an analytic gradient, +because the envelope is not differentiable at its ridges — and that is not an artefact. A heap has +ridges, and the difference shades one as the crease it is instead of rounding it away. The price is +three evaluations of the lobe loop per vertex, which is the module's real cost and the reason `lobes` +is a slider. + +### Draw-call cost + +The patch is **free** — it is the caller's mesh. The heap is **one**. No textures anywhere. + +### Signatures + +```js +import { DissolveMode, DissolveSpace, DissolveField, dissolveParams, heapParams, + dissolveUniforms, syncDissolve, patchDissolveMaterial, dissolveSchema, + DISSOLVE_GLSL, MAX_RUNGS, MAX_LOBES } from '../../vfx/Dissolve.js'; + +DissolveMode = { VOXEL: 0, GRANULAR: 1, EROSION: 2 } +DissolveSpace = { LOCAL: 0, WORLD: 1 } +MAX_RUNGS = 6; MAX_LOBES = 24 + +dissolveUniforms(overrides = {}) -> uniform box // SHARE IT BY IDENTITY +syncDissolve(boxOrMaterial, params) -> the target // every frame +patchDissolveMaterial(material, { mode = DissolveMode.VOXEL, space = DissolveSpace.LOCAL, + uniforms = null, environment = null, + vertex = '', fragment = '' }) -> material + // parks its box on material.userData.uniforms (I8) and on .userData.dissolve + // composes through patchOnBeforeCompile — does not clobber a shadow patch or CSM + // and are used when the material has + // them and skipped when it does not: a basic material still cuts and still drifts, + // it just does not turn the normal or add the ember +DISSOLVE_GLSL = { uniforms, vertex, fragment } // inject noiseGLSL FIRST + +new DissolveField(parent, { along = 72, across = 40, renderOrder = 3, + layer = LAYER.WORLD, name = 'DissolveField' }) +h.drawCalls (1) · uniforms · visible (get/set) +h.setBasis(origin, direction, side, length) · setColors(fresh, settled, face, deep) +h.roll(seed = Math.random() * 100) · reset() +h.update(now, params) // now IS used — the train is an event +h.frontPoint(now, params, out) · crestHeight(now, params) -> metres · dispose() +``` + +### Params + +`dissolveParams()` for the patch: `progress`/`seed`/`space`, the cubes (`voxel`/`cell`/`rungs`/ +`take`/`span`/`block`/`facet`/`hold`/`drift`/`driftBias{X,Y,Z}`/`lift`/`gravity`/`tumble`/`wobble`/ +`wobbleRate`), the threshold (`erode`/`noiseScale`/`warp`/`edge`/`bias{X,Y,Z}`/`biasAmount`), and two +pickers plus `glow`. `heapParams()` for the heap: the footprint (`halfWidth`/`floor`/`heightGain`), +the train (`lobes`/`rate`/`frontSpeed`/`volume`/`volumeJitter`/`repose`/`excess`/`slump`/`creep`/ +`scatter`/`widen`), the deposit (`bed`/`bedRamp`/`bedWidth`/`bedCurve`), and the snow +(`ambient`/`wrap`/`fresh`/`grain`/`grainScale`/`glint`/`toe`/`opacity`) with four pickers. +`dissolveSchema(label)` returns all four folders. + +The heap's defaults are a snow avalanche: 33° of repose (dry snow sits between 30° and 38°), +over-steep by 12°, relaxing inside half a second, released five times a second at 7 m/s. + +### The one rule + +**Share the uniform box.** `dissolveUniforms()` is a separate function for exactly one reason: hand +the same box to every material that should come apart as one event and a single `syncDissolve()` +drives all of them, because three stores uniforms as `{ value }` boxes and one write moves every +material holding that box. A dissolve applied per-material with per-material progress does not look +like one thing being unmade; it looks like several things being unmade near each other, which is the +difference between `unmake` and a screen wipe. + +--- + +## `TimeControl.js` — recording the caster, and bending everyone else's clock + +The most architecturally invasive module in the library, and the one that draws the least. Two of +its three capabilities draw **nothing at all**: they are a shared uniform block, a GLSL chunk and +some arithmetic, and the read is entirely in what the rest of the frame does differently. + +It serves the whole **chrono** school — `echostep`, `stasisfield`, `rewind`, `hourglass`, +`afterimage`, `entropy` — and the two extension abilities that want a paused world (`spellbreak` +wants `timeRegionWeight` to disrupt what is standing inside it; `mirage` wants a `GhostRig` with the +bleach turned off and the alpha driven from distortion instead). + +It is the only module in `src/vfx/` that touches shared infrastructure. Three files outside it moved: + +| file | change | why | +| --- | --- | --- | +| `core/FrameUniforms.js` | `MAX_TIME_REGIONS`, three uniform boxes, three lines in `sharedUniforms()` | the field has to be readable by `core/` and `particles/` without either importing a VFX module and dragging its shader source into the boot bundle — the same argument that put `distortionWriters` in `core/Layers.js` | +| `shaders/lib/timewarp.glsl.js` | **new**, no renderer | the chunk any shader injects to read the field | +| `particles/ParticleSystem.js` | the vertex path became a function; a stasis loop and one varying | so particles hold mid-air, which is `stasisfield`'s entire ability | + +### Draw-call cost + +`TimeField` **0** · `TimeRecorder` **0** · `GhostRig` **1 per skinned mesh in the source rig** (one, +for the character in this project). Three ghosts is three draw calls and leaves nine of I7's twelve. + +### Signatures + +```js +/* TimeControl.js ─ 0 draw calls (GhostRig: 1 per source mesh) ─ mixed ─ canonical */ +MAX_TIME_REGIONS = 4 // re-exported from core/FrameUniforms.js +MAX_TRACK_SAMPLES = 240 · MAX_TRACK_BONES = 128 + +/* --- the field: a pool, a singleton, no renderer --- */ +timeRegionParams() -> { radius, core, strength, rate } +export const timeField = new TimeField() +field.acquire(now = frame.uTime.value) -> TimeRegion | null // NULL when all four are gone — I6 +field.release(region) -> null · field.reset() · field.liveCount +field.clockAt(clock, worldPos) -> seconds // CPU mirror of warpedTime() +field.weightAt(worldPos) -> 0..1 // CPU mirror of timeRegionWeight() + +region.centre : Vector3 // the caller writes it every frame +region.index · isLive · hold +region.lock(now = frame.uTime.value) // stamp the instant the region snapped shut +region.place(v3) · placeXYZ(x, y, z) +region.sync(p) // p = timeRegionParams(); EVERY frame, from live settings +region.weightAt(worldPos) -> 0..1 +region.release() -> null + +/* --- the recorder --- */ +recorderParams() -> { rate, window } +new TimeRecorder({ capacity = 120, bones = MAX_TRACK_BONES }) +r.attach(source) · detach() · clear() +r.bones · boneCount · sampleCount · oldest · newest · span +r.sample(now, p) -> boolean // false if not due, or if `now` went backwards +r.trim(now, p) +r.transformAt(t, outPosition, outQuaternion) -> boolean // false = off the end of the track +r.poseAt(t, ghostRig) -> boolean // same + +/* --- the ghosts --- */ +ghostLook() -> 16 keys: tint/deep/rimColor/bleach, fade/facing/rim/rimPower, + glow/bandScale/bandSpeed, erode/erodeScale/edge/edgeGlow/seed +createGhostMaterial(sourceMaterial | null) -> MeshStandardMaterial // userData.uniforms + .sync +applyGhostLook(uniforms, look) +findCaster(scene) -> Object3D | null // scene.getObjectByName('Character'); call it ONCE + +new GhostRig(parent, { source = null, material = null, layer = LAYER.VFX, renderOrder = 4 }) +g.object3D · drawCalls · boneCount · bones · meshes · hasSource · visible (get/set) · material +g.setSource(source) -> boolean // THE ONLY ALLOCATING CALL. Idempotent. createShaders(). +g.place(position, headingRadians | Quaternion) · setScale(k) · sync(look) · dispose() + +/* --- reverse --- */ +reverseParams() -> { rate, turnAt, hold, back, floor } +reverseTime(age, p) -> seconds // the closed form. PREFER THIS. +reverseRate(age, p) -> signed × // +rate, 0 at the top and at the floor, -back +new TimeWarpClock(start = 0) // the integrating alternative +c.now · step · rate · direction · turned · reversing · stalled · emitDt · spanDt +c.reset(start) · advance(dt, rate = 1, floor = -Infinity, ceiling = Infinity) -> signed step +new RewindGate() +gate.poll(time, mark) -> +1 forward | -1 backward | 0 · reset() · past · crossings +``` + +### The field, in one line of GLSL + +Up to four spheres, each carrying a centre, an outer radius, a soft core, a strength, a **hold +timestamp** and a **rate**. A shader that injects `timeWarpGLSL` sees, inside a region: + +```glsl +bent = mix(clock, hold + (clock - hold) * rate, weight); +``` + +and that line is all three chrono capabilities at once: + +| `rate` | what the region does | +| --- | --- | +| `0` | **stasis** — the clock stops dead at `hold`. Nothing inside advances. | +| `-1` | **rewind** — the clock is mirrored about `hold` and runs backwards. | +| `0.25` | slow motion at a quarter speed, still anchored at `hold`. | +| `1` | identity, whatever the weight. | + +`weight` is `strength × (1 - smoothstep(core × radius, radius, distance))`, so the derivative of the +bent clock with respect to the real one is `mix(1, rate, weight)` — the shell of a region is a +**time-dilation gradient**, and things at the rim crawl while things at the centre are stopped. That +gradient is why a stasis field reads as a field instead of as a hard sphere of frozen sprites, and it +falls out of the `mix` for free. + +The chunk exposes four functions. `warpedTime(clock, worldPos)` and `timeRegionWeight(worldPos)` are +the two an ordinary consumer wants; `timeRegionFalloff(worldPos, region, warp)` and +`timeRegionClock(clock, warp, weight)` take the already-fetched `vec4`s and exist because a function +**cannot index a uniform array by its own argument** in ESSL 1.00 — the caller has to be a loop, and +the loop has to do the fetch. That is the same rule that makes `FilamentPaths` compare +`float(i) == role` instead of writing `uFrom[role]`. + +### Why an instant and a rate, and not a time scale + +The first version published a per-region `timeScale` and expected each consumer to integrate it — +`myClock += dt * scaleAtMyPosition`. That works exactly once and then falls apart, because **every +effect in this project is a closed-form function of time with nowhere to keep an accumulator.** A +particle is `f(uTime - aSpawn)`, evaluated fresh in the vertex shader every frame from nine +attributes; there is no `myClock` to advance and no per-particle memory to advance it in. Publishing +the instant the region locked turns the field into a closed form too, which is also what makes it +survive the only test that matters here: pause with **P**, drag the radius, and particles that were +outside the sphere snap to their held pose while the rest keep theirs. A frame of zero length changes +the picture. + +### The probe, and the freeze that thawed itself + +A shader whose position does **not** depend on its own clock — a ground field, a growth field, a tube +— calls `warpedTime(uTime, vWorldPos)` and is finished. A particle cannot, and the reason is worth +writing down because the wrong version looks right for exactly one frame. + +Probe the field at the particle's *current* position and the freeze thaws itself: the particle stops, +the world clock runs on, and the next frame the probe is asking about a place the particle only +occupies **because** it is held; one step later the answer has drifted and it lets go. There is no +fixed point in that formulation. + +So the particle shader writes its own loop and probes each slot at the position the body had **on the +frame that slot locked** — `particlePath(clamp(hold - aSpawn, 0, min(rawAge, life)))`. That *is* a +fixed point, it costs one extra evaluation of the trajectory per live region, and it is also the +right fiction: a bubble of stopped time holds whatever was inside it when it snapped shut. The clamp +handles three cases without a branch — a particle born after the lock probes its own spawn point (an +emitter firing into a standing field has its output frozen on arrival), a particle that died before +the lock probes its last position and is then killed by the ordinary `t > 1.0` test, and a dead ring +slot probes age 0 rather than evaluating curl noise ten thousand seconds out. + +Because the kill test runs on the *bent* age, a region with a negative rate **un-spawns** particles +properly: they fly backwards into their emitter, reach age 0, and stop existing there. That is the +only path in the project by which particles genuinely reverse. + +### What it costs when no chrono ability is standing + +`uTimeRegionCount` is `0` until somebody calls `acquire()`. Every entry point opens on it, so the +whole mechanism costs **one uniform float compare and a branch every fragment in the draw takes the +same way**. The uniform boxes are in `sharedUniforms()`, but three only uploads uniforms the compiled +program declares, so a material that never injects the chunk uploads none of the eight `vec4`s. + +Measured rather than asserted: the shipped particle shader was rendered against the pre-change one +across **eleven `SHAPE` × flag permutations at five timestamps**, with `Math.random` seeded so both +systems got byte-identical spawn attributes, reading back a 128×128 framebuffer each time. Ten of the +eleven are **pixel-for-pixel identical**. The eleventh — `STREAK` with `USE_STRETCH` — differs by +**one pixel at one 8-bit level**, a single-ULP rounding difference at an antialiased silhouette edge +from the driver inlining across the new function boundary. + +### The recorder, and full skeletal ghosting + +`TimeRecorder` is a ring of timestamps, the source's world transform, and every tracked bone's +**local position and rotation**. `GhostRig` is a real clone of the caster's skinned meshes on its own +`Skeleton`, which the recorder drives to any instant on the track. + +**Full skeletal ghosting is feasible here and this module ships it** rather than a silhouette proxy, +because the `echostep` roster entry is explicit that "They are the character, not a proxy; that is +what makes it unsettling". The trade-offs are real and are stated rather than hidden: + +- **N poses cost N skeletons.** Skinning reads a per-skeleton matrix palette, so two ghosts showing + two instants need two palettes, therefore two sets of `Bone` objects for `Skeleton.update()` to + read. There is no arrangement in which N poses of one rig cost one draw call. Each ghost is about + seventy `Object3D`s and one draw call; geometry and the skin map are shared with the character. +- **`setSource()` allocates, and it is the only thing here that does.** That is a documented + exception to I3 with an unavoidable cause: the character is an asynchronous FBX load that finishes + *after* ability pools can be warmed, so there is no earlier moment at which the source rig exists. + Call it from `createShaders()` — by the time a chrono ability is warmed on selection the character + is long loaded — and the cast itself allocates nothing. It is idempotent, so calling it again from + `onSpawn()` as a fallback is free after the first cast. +- **Scale is not recorded.** Mixamo clips carry no scale tracks, so a third of the buffer and a third + of the per-frame write would replay a constant. Bone scale comes from the bind pose at clone time. + A future rig that animates scale gets ghosts that are stiff, not broken. +- **Rotation is slerped, not lerped.** Element-wise lerping the *matrices* was the first version. At + 30 Hz it is almost right, and then a wrist crosses ninety degrees between two samples and the hand + collapses into the forearm for two frames. +- **No new context plumbing.** `findCaster(scene)` is `scene.getObjectByName('Character')`, and + `ctx.scene` is already in the context every ability is constructed against. Nothing in + `AbilityManager` or `App` had to change, and the character's async load stops being an ordering + problem. Call it once and keep the result; it is a full traversal. + +The property that makes this an effect rather than a feature: sampling is driven by timestamps, so a +zero-length frame writes nothing — but **playback is a function of the delay, and the delay is a +slider**. Pause mid-cast and drag `ghostDelay`, and three ghosts walk backwards and forwards through +the recording while the world stands still. + +### The ghost material, and the coloured blob it is not + +`createGhostMaterial()` returns a patched **`MeshStandardMaterial`**, not a `ShaderMaterial`. The +first version was a `ShaderMaterial` — a fresnel ramp over a flat colour on the cloned geometry — and +it looked like a mannequin. What makes a person recognisable at ten metres is not the silhouette, it +is the shading: the normal map across the shoulders, the probe sitting on the top of the head, the +terminator down the side of the ribcage. All of that arrives free from the standard material and +costs a re-implementation otherwise. + +Three things happen on the way out. The skin is **bleached by its own luminance** toward `deep → +tint`, so the map's light and shade survive and only its hue is replaced — multiplying by a tint +instead, which is the obvious version, turns a dark jacket into black and the whole figure into +exactly the coloured blob the roster entry warns about. A fresnel term drives both the rim emission +and the alpha, so the ghost is thinner head-on than edge-on and reads as a volume of air. And the +banding and the dissolve are both measured in **world metres**, not UV, so the bands stay put as the +figure moves through them and two ghosts a metre apart erode differently without either owning a +seed attribute. + +The ghost shares the character's diffuse map. That is not a new **I2** exception; it is the same one, +reused, and the moment the caster's skin changes so does every echo of it. Uniform boxes are parked +on `material.userData.uniforms` — **I8**, or the pause test reports sixteen live sliders as dead. + +### Reverse, and the guard rails + +`reverseTime(age, p)` is three legs: forward at `rate` until `turnAt`, held for `hold`, then backwards +at `back` until it hits `floor`. Hand the result to any module's `now` and that module runs the beat. +The hold is not decoration — a reversal with no pause at the top reads as a glitch, because the eye +needs the moment where nothing moves to understand that what follows is the same motion inverted +rather than a different motion. + +**Prefer the closed form to `TimeWarpClock`.** It is the version that survives the pause test: freeze +the sandbox mid-rewind, drag `back`, and the whole reversal re-times itself retroactively because +nothing was accumulated. The integrator exists for a rate that genuinely is not a slider, and it is +honest about what it gives up — an accumulated clock cannot be re-derived, so a paused drag has no +retroactive effect on it. It earns its place with the two things a closed form cannot own: `turned`, +the frame the direction flipped, and `emitDt`, the guard rail below. + +**Reverses cleanly** — anything whose clock is an argument: `GrowthField.update(now, p)` (crystals +retract into the floor), `ShatterField.update(now, p)` (fragments reassemble), `FilamentPaths` via +`role.draw(progress, …)`, `Projectile.update(now, p)`, `Tube`/`Shell` via `state.time` / `state.t`, +`GroundField` (marks carry their own timestamps), `LiquidSurface` (the surface does; injected ripples +do not). + +**Does not reverse, and what to do instead:** + +| | | +| --- | --- | +| **the particle ring buffer** | A spawn is a log entry, not a state. A negative `dt` into a `RateEmitter` drives its fractional accumulator the wrong way, emits nothing, and banks credit that dumps on the first forward frame. Use `clock.emitDt`. The only genuine particle reversal is a time region with a negative rate. | +| **decals, fissures, bursts** | One-shot systems on the app's forward clock; nothing can un-spawn one. Lay them on the forward leg and let them expire. Do not spawn during a reverse leg. | +| `ArcNetwork.update(dt, …)` | Integrates a hop cursor and fires `onNode` once per node; `_fired` never counts down. Feed it `clock.emitDt` and `reset(seed)` on the turn. | +| `Swarm` · `Curtain` · `VolumeHull` | Their motion lives in `frame.uTime` inside the shader — the first argument to `Swarm.update` and `Curtain.update` is ignored by design. An ability clock cannot reach them. A **time region** can, if they are taught to inject the chunk; none of them has been, and that is the obvious next piece of work in this module. | +| `Ability.advance(dt)` | The base class's front and phase machine are monotone and are not yours to reverse. Let the phases run forward and let the bent clock feed only the modules — `rewind` is a LINE cast whose travel still travels. | +| `ctx.lights` | `LightPool.set(…, dt)` smooths toward a target; a negative `dt` is not meaningful. Pass `clock.spanDt`. | + +`RewindGate` is the guard rail for every beat fired at an instant. On a monotone clock those are a +boolean; on a clock that can reverse they are a **crossing**, and the difference is that the same beat +has to be able to fire again — or be undone — when the clock comes back through it. `mark` is passed +to `poll()` rather than held, so it stays a live slider and drags under your cursor while paused. + +### The one rule + +**`region.sync(p)` every frame from live settings, and `region.release()` in `onDestroy()`.** The +pool is four slots wide and `acquire()` returns `null` when they are gone, on exactly the +`ctx.lights.acquire()` contract — guard it, and an ability that wants two regions must read +acceptably with one. A region that is never released is invisible and freezes a piece of the world +for the rest of the session, which is the most alarming bug this library can produce. + +```js +/* stasisfield, in full */ +createShaders() { this._region = null; this._warp = timeRegionParams(); } +onSpawn() { this._region = timeField.acquire(); this._region?.lock(); } +onTravel(dt) { + const c = settings.stasisfield; + const region = this._region; + if (!region) return; // I6 + region.centre.copy(this.origin).setY(c.domeHeight); + this._warp.radius = c.zoneRadius * settings.global.scale; + this._warp.core = c.holdCore; + this._warp.strength = c.holdStrength; + this._warp.rate = 0; // stasis + region.sync(this._warp); +} +onDestroy() { this._region = this._region?.release() ?? null; } +``` + +--- + +## `Colony.js` — many small things behaving as one + +The Hive school's four tricks, in one file: a flock that condenses into a **silhouette**, a web that +is strands **plus the film between them**, a comb that grows on a lattice **its own neighbours +defined**, and a dome of plates cut from **one tessellation** so it closes exactly. + +Four independent classes. They share a file because they share a chitin lighting chunk and a +skeleton — *roll a unitless structure, rebuild it only when its shape hash changes, resolve every +metre in the vertex shader* — and splitting them produced either a fifth module nobody could name or +four copies of the same drift. + +| class | what it draws | draw calls | serves | +| --- | --- | --- | --- | +| `ColonySwarm` | a `Swarm` that condenses onto a signed distance field | 1 | `locusttide`, `waspfunnel`, `broodburst` | +| `WebGraph` | sagging strands **and** the membrane in their faces | 2 | `webline` | +| `LatticeGrowth` | hexagonal comb cells budding outward, overlaps refused | 1 | `hivecolumn` | +| `PlateShell` | a spherical Voronoi dome of interlocking plates | 1 | `carapace` | + +All four are **parent-first** and **canonical**. `LatticeGrowth.update(now, …)` and +`PlateShell.update(now, …)` take the ability's `age`; `ColonySwarm.update(_now, …)` and +`WebGraph.update(_now, …)` **ignore** it, as `Swarm` and `Curtain` do. + +### Signatures + +```js +import { + ColonySwarm, ColonyShape, COLONY_SHAPE_NAMES, colonySwarmParams, + WebGraph, webGraphParams, + LatticeGrowth, latticeGrowthParams, + PlateShell, plateShellParams +} from '../vfx/Colony.js'; + +ColonyShape = { BALL:0, WALL:1, SPEAR:2, FIST:3, RING:4, COLUMN:5 } +COLONY_SHAPE_NAMES : string[6] + +/* ColonySwarm extends Swarm — every Swarm option, method and param still applies */ +new ColonySwarm(parent, { capacity = 256, silhouette, additive, renderOrder }) +s.update(_now, params) // FIRST ARGUMENT IGNORED; super.update() runs first +s.shapeCentre(out) -> Vector3 // CPU mirror of the shape's centre, for a light +// inherited: count · drawCalls(1) · uniforms · roll() · reset() · setBasis() · +// setColors() · leadPoint() +colonySwarmParams() -> swarmParams() + { shapeA, shapeB, shapeBlend, condense, + shapeWidth/Height/Depth, shapeForward/Side/Up, shapeSpin, shapeFill, shapeSteps, + shapeSlack, shapeRough, waveAmp, waveLength, waveSpeed, waveAlong, + cling, floorY, crawlHeight } + +new WebGraph(parent, { maxRings = 8, maxSpokes = 16, samples = 10, + filmSubdiv = 2, additive = false, renderOrder = 11 }) +w.drawCalls(2) · count · strands · faces · seed +w.roll(seed = Math.random()*100) · reset() +w.setPlacement(anchor, normal, up) // `normal` is OUT of the web's plane +w.update(_now, params) // FIRST ARGUMENT IGNORED +w.nodePoint(ring, spoke, p, out) // ring -1 is the hub; NO per-node jitter +w.dispose() + +new LatticeGrowth(parent, { capacity = 192, sides = 6, wall = 0.24, recess = 0.62, + renderOrder = 2, castShadow = true, receiveShadow = true }) +g.drawCalls(1) · count · cells · capacity +g.reset() · setPlacement(anchor, forward) · update(now, params) +g.cellPoint(index, p, out, height = 1) · dispose() + +new PlateShell(parent, { capacity = 64, renderOrder = 2, castShadow = true }) +s.drawCalls(1) · count · plates · capacity +s.reset() · setPlacement(anchor, forward) · update(now, params) +s.tessellate(sites, seed, jitter) -> plates // update() calls it when needed +s.progress(now, p) -> 0..1 // how much of the dome has locked +s.plateCentre(index, p, out) · dispose() +``` + +### `ColonySwarm` — extending the flock, not rebuilding it + +`Swarm`'s flock is a closed-form function inside its vertex shader. There is no per-agent state on +the CPU to intercept, and no way to hand each agent a different target without a texture (**I2**) or +a uniform array indexed by something other than a loop counter (which does not compile on ANGLE). +Composition can therefore only move the *lead* — which moves every agent together and cannot make a +shape. + +So `ColonySwarm extends Swarm` and **splices two blocks into the vertex shader its superclass +built**: the field library before `main`, and a target blend just before the finite difference. Both +anchors are asserted in the constructor and it throws, naming them, if either stops matching. The +alternative was a second flock, and the moment somebody fixed the bank in one of them the two would +have disagreed about what a flock is. + +**Sampling is rejection-free and stable, and it has to be both.** Rejection sampling has no +expressible form in a shader — there is no unbounded loop — and even given one, the number of draws +an agent needed would depend on the shape, so an agent's place would change identity the instant the +shape morphed and the whole cloud would boil. Instead every agent draws **one** point from its own +dice, once, and walks it onto the isosurface by three steps of gradient descent (tetrahedron taps, +four evaluations a gradient). The dice never change, so an agent keeps its own place in the shape and +*flows* across a morph. + +**The blend is between the fields, not the points.** `mix(pointInA, pointInB, k)` is a crossfade and +its midpoint is a smear with no shape in it. `mix(fieldA, fieldB, k)` is a morph: the blended field +is a real field at every `k`, so a fist grows a point and a wall draws itself into a spear. +Locusttide's trick is the transition, so the transition had to be a real object. The cost is that +both shapes are evaluated at every tap; the branch on `condense` is uniform flow, so a *dispersed* +`ColonySwarm` costs exactly what a `Swarm` costs. + +**Interior fill is an isolevel, not a second scatter.** Every agent targets `d = −fill · dice` +instead of `d = 0`, so it lands on its own private isosurface somewhere inside. Same descent, same +cost, and `fill` slides from a skin of insects to a packed body. + +Two extras earn their place because named abilities need them: a **longitudinal density wave** +(`waveAmp`/`waveLength`/`waveSpeed`/`waveAlong`) that crowds agents at its zero crossings — a real +surge up a funnel, for `waspfunnel` — and a **crawl** (`cling`/`floorY`/`crawlHeight`) that pins +agents to the floor, which is what separates `broodburst`'s hatchlings from every other swarm in the +sandbox. The wave modulates *position*, not opacity; the first version brightened agents on a sine +and looked like a gradient painted over a static swarm, because density you can see is agents +arriving. + +The target is evaluated once per vertex and mixed into all three time samples, so a condensed swarm's +finite-difference velocity — and therefore its bank — falls to zero. That is correct: an agent +holding station in a shape is not turning. + +### `WebGraph` — the membrane is the part everybody forgets + +A node is **two integers**, `(ring, spoke)`, and nothing else. Ring −1 is the hub and needs no +special case: every per-node jitter is multiplied by the radius fraction, which is zero there, so all +the spokes of ring −1 land on the same point however the dice fell. Every metre — rim radius, squash, +out-of-plane scatter, sag, breeze — is a uniform, so a paused `radius` drag re-spans the whole web, +film included. + +**Sag is a fraction of a strand's own span, not a fixed drop.** That is the difference between a web +and a net: a short chord near the hub hangs almost straight while a two-metre outer chord bellies, +and that gradient is what the eye reads as tension. An absolute sag in metres made the inner rings +look slack and the whole thing read as knitting. + +**The film is a Coons patch over its face's four strands.** Bilinear interpolation of the four +*nodes* gives a flat quad whose edges cut straight across the sag of the threads bounding it, and the +film visibly floats off its own frame at every belly. A Coons patch interpolates four boundary +*curves* exactly, so the film meets each strand along its whole length by construction. Its normal +comes from central differences of the patch itself — four extra evaluations — because a flat normal +on a bellied film kills the grazing term precisely where the film is most visible. + +**The two meshes cannot disagree.** They are compiled from one source chunk (`WEB_FIELD_GLSL`) and +hold the same uniform boxes *by identity*, and both roll the per-strand sag dice through +`webEdgeDice()` with the endpoints in an order the CPU builder and both shaders agree on. A film +whose sag drifts a millimetre from its threads' does not look slightly wrong, it looks like cling +film hovering nearby, so the drift was made unrepresentable rather than merely avoided. + +**Alpha is a pure grazing term with no constant part.** A real film is invisible head-on and flares +at glancing angles; adding even a small ambient floor turned the web into a frosted disc and took the +whole trick with it. Colour comes from the same angle — thin-film interference shifts hue with path +length, so walking the four-stop gradient with the grazing term gives oil-slick banding for nothing, +and the per-face dice keeps neighbouring panels out of step. Strands are shaded **Kajiya–Kay**, +because a thread has a tangent rather than a normal and the band of light running across a web +perpendicular to its threads cannot be faked with a fresnel. + +`nodePoint()` deliberately omits the per-node jitter: the jitter is a `fract`-chain hash evaluated at +`highp`, and a JS mirror at double precision produces a *completely different* number rather than an +approximately right one, because that is what hashes do. Same decision, same reason, as +`Curtain#sheetPoint()` leaving out the ripple. + +### `LatticeGrowth` — the refusal is the design + +A global hex grid has one lattice and a cell either exists or it does not, so everything you can do +with it is a mask — and every mask reads as *a shape cut out of a honeycomb*, because the honeycomb +was there first. + +Here each cell carries **its own lattice frame**, inherited from its parent and turned by up to +`drift` of a turn. A child buds one lattice unit along one of its parent's six directions, so locally +the packing is perfectly hexagonal and three cells in a row look *built* — but two branches that left +the seed around opposite sides of a void arrive back at each other out of register, the candidate +lands within `refuse` of something already placed, and it is **thrown away**. That refusal puts a +seam where two fronts met, a hole where three did, and a perimeter whose raggedness follows the +history of the growth rather than a noise function. `drift = 0.16` with `refuse = 0.94` is the +starting point; at `drift = 0` the refusal never fires and you have the hex grid the roster says not +to build. + +Two things that were bugs and are worth not re-learning: + +1. **The frontier must be breadth-first.** The first version took the parent as `cursor % n` with + `cursor` advancing once per attempt. That reads like a sweep and is not one — `n` grows on every + success, so `cursor` and `n` advance in lockstep and the parent is always the cell placed last. + What grows is a single chain: a hundred and sixty cells laid end to end over a hundred and twenty + lattice units, one cell wide. A head pointer that advances only when its parent is exhausted keeps + the frontier a ring, and three children per parent leaves the lobes in. +2. **Refusal is a spatial hash, not a scan.** A fixed open-addressed head/next pair of `Int32Array`s + allocated at construction, nine buckets per candidate. The O(n²) version cost several milliseconds + per regrow at 256 cells — and a regrow happens on every frame of a slider drag, which is exactly + the interaction this has to survive. + +The cell geometry is not a prism. A prism gives a tiled floor and the eye stops at *mosaic*; what +says hive is the **rim and the hole** — a wall with thickness, a shadowed recess, a floor down there +catching light. Only the recess glows (`coreGlow`), and the read comes entirely from the rim being +dark against it. + +### `PlateShell` — why the dome closes exactly + +Two points on a unit sphere are equidistant from a third exactly when that third lies on the plane +through the **origin** whose normal is their difference — the `|x|²` terms cancel because both sites +are unit vectors. So a spherical Voronoi cell is an intersection of half-spaces through the origin +and can be built by ordinary polygon clipping: start each cell as a wide octagon in the tangent plane +at its site, Sutherland–Hodgman it by one plane per other site, project the survivors back onto the +sphere. + +Exactness follows for free. The edge cell *i* gets from the bisector of *i* and *j* lies in the *same +plane* cell *j* gets from bisecting *j* and *i*, and the endpoints of that edge are where a third +bisector cuts it — the same third bisector for both. Adjacent plates share their boundary vertices to +the last bit of the arithmetic that produced them. Measured on a 70-plate dome: **393 polygon +vertices, zero of them unshared, worst coincidence error 1.03 × 10⁻⁷** (float32 storage), and the +summed plate area is within 0.9 % of `2π` — the chord deficit of flat polygons under a sphere, and +nothing else. That is the difference between this and scattering plates and hoping: a +noise-perturbed layout leaves slivers of daylight and the eye finds every one the moment something +bright is behind the dome. + +The equator needs no special case — it is one more plane through the origin (`y ≥ 0`), so the plates +meeting the floor meet it in a straight line rather than a fringe. + +**Nothing the fly-in does can leave a plate off the tessellation.** Every term — the outward offset, +the spin about the plate's own axis, the swing off the dome — is multiplied by `k = 1 − ease`, which +is exactly zero when the plate has landed. No easing curve or overshoot can be tuned into a gap. + +Sites are a **jittered Fibonacci hemisphere**, not a random scatter: the *i*-th of *n* points sits at +height `(i + 0.5) / n`, which distributes area uniformly because a sphere's area is uniform in +height. Random points clump, and a clumped Voronoi gives one plate the size of four, which reads as +damage rather than as armour. + +The vertex layout is fixed at `2 + 6·12` slots per plate whatever its real side count — padding slots +collapse onto the last real vertex and draw as zero-area triangles — which is what lets the index +buffer be written once at construction and never touched again. `tessellate()` is O(n²) clips (46 +sites ≈ 0.2 ms), runs only when `sites`, `seed` or `jitter` change, and allocates nothing: the +clipper ping-pongs between two scratch arrays sized in the constructor. + +### The one rule, per class + +- **`ColonySwarm`** — *drive `condense` and `shapeBlend` from the beats and leave the rest alone.* + The module has two ideas and both are transitions. Parked at `condense = 1` it is a mesh with a bad + silhouette; arriving as a cloud, closing into a fist over three tenths of a second and opening into + a spear is the ability. +- **`WebGraph`** — *put the caster somewhere they will move, and never let `filmFill` reach 1.* The + membrane does not exist head-on, and if every face carries it the panels that flare have nothing to + flare against. +- **`LatticeGrowth`** — *`drift` and `refuse` are one control, and they are the ability.* +- **`PlateShell`** — *let it finish, and keep `seam` small.* Everything the module is worth happens in + the last tenth of a second, when the final plates drop into holes that are exactly their own shape. + A dome held half-built is a scatter of debris, and that is what `ShatterField` is for. + +--- + +## `BrushStroke.js` — a loaded brush, not a ribbon with a noise mask + +The Ink school's mark-maker. A brush stroke with real brush dynamics: width from an authored +pressure curve, ink that pools where the brush slows or turns, an edge that wicks into the paper, +and a dry tail that comes apart into separate bristle streaks because the bristles are separately +modelled and separately run out. + +It serves `sumistroke` (one enormous stroke down the line, running dry) and `sealscript` (a column +of characters written in the air, top to bottom, one stroke at a time), and both of those are the +same file with different numbers. + +**Everything in it is matte.** See [The anti-glow contract](#the-anti-glow-contract) below, which +applies to this module and `InkDiffusion.js` equally and is not optional. + +### Draw-call cost + +**One.** For every stroke and every bristle: `strokes × bristles` instances of one strip. Two +brushes that must differ in *tip layout* are two `BrushStroke`s and two draw calls; two that differ +only in width, pressure, colour or paper plane are one. + +### Signatures + +```js +BrushTip = { FLAT: 0, ROUND: 1, SPLIT: 2 }; BRUSH_TIP_NAMES: string[3] +brushStrokeParams() -> object // canonical key list with units + +new BrushStroke(parent, { strokes = 6, bristles = 14, samples = 40, sides = 6, + tip = BrushTip.FLAT, depthWrite = false, + layer = LAYER.VFX, renderOrder = 7, name = null }) +b.object3D · mesh · material · uniforms · geometry · drawCalls(1) · count · strokeCount · tip +b.capacity · strokes · bristles · samples · sides · seed +b.stroke(i) -> Stroke // built at construction; out of range clamps +b.setStrokeCount(n) · setPaper(normal) · setColors(a, b, c, d) · roll(seed) · retip(tip) · reset() +b.update(_now, params) // FIRST ARGUMENT IGNORED +b.pointAt(i, t, out) · tangentAt(i, t, out) · headOf(i) · tipPoint(i, out) +b.pressureOf(i, t) · widthAt(i, t) -> metres · dispose() + +stroke.curve(p0, p1, p2, p3) // world-space cubic Bezier, the spine +stroke.line(from, to, bow = 0, lift = 0) // sugar: an arc, bowed in the paper plane +stroke.pressure(entry, swell, hold, exit) // cubic Bernstein; entry and exit are hit exactly +stroke.ink(load) · timing(start, span) · active · seed · head +``` + +### The clock + +`update()` **ignores its first argument**, like `Swarm` and `Curtain`. A brush mark is not an +animation; it is a standing object whose only temporal parameter is `progress`, and the ability owns +that beat. Taking a clock as well would mean two sources of truth for where the head is, and they +would disagree the first time anything paused. + +### The bristle model, and the noise mask that came before it + +The first version drew one even ribbon and multiplied its alpha by a noise field to fake the dry +tail. It is the obvious thing and it is wrong in a way you can see from across the room. A mask +makes *holes in a stroke*; dry brush is not a stroke with holes in it, it is four or five separate +marks that used to be one mark, each ending at its own place, each its own width, with clean paper +between them. The mask also breaks up uniformly across the width, because the noise does not know +where the middle of the brush is — and a real brush holds ink in its core long after the outside +bristles have given up. + +Instancing the bristles gets all of that for one draw call: + +- each bristle carries its own **ink load**, jittered per bristle and starved toward the rim of the + ferrule by `edgeStarve`, so the stroke goes dry **from the outside in**; +- ink is **spent** by integrating the deposition along the spine — `inkSpent(t)`, a 13-point + trapezoid — so a stroke that presses hard runs out sooner than one that skims; +- once a bristle's load runs low it makes **intermittent contact**, gated per bristle by a value + noise along its own arclength. That is the fray, and it happens at a different place for every + bristle because every bristle started with a different amount of ink. + +`skipContrast` exists because of README trap 4: value noise piles up at 0.5, and the contact test +compares it against a dryness in 0..1. Without the contrast curve every bristle on the brush lets go +within a few millimetres of the same place and the stroke ends in a clean horizontal cut — the exact +failure the module was written to avoid, reintroduced by the noise's own distribution. + +### Pooling comes out of the deposition, not out of a slider + +Deposition has two causes: **area swept**, which scales with the distance covered, and **time in +contact**, which does not. `pigmentAt()` is `pressure * (flowLength + flowDwell * speedRef / speed)` +where `speed` is `|B'(t)|` in metres per unit of the Bézier parameter. Where the spine's control +points bunch, the brush dwells, the second term blows up, and the ink pools — and the *same* +expression, integrated, is what spends the ferrule. That is the reason a hook pools **and** the +tail after the hook is bone dry: one model, two readings. + +Curvature adds to it (`poolCurve`), because a tight turn is a pivot on the tip and can still be +quick. + +The analytic alternative — integrating only the pressure cubic, which has a closed form — was tried +first. It ignores speed, so a stroke with a hairpin in it never runs dry early, and the hairpin is +where a calligrapher's brush actually empties. + +### Why it is a prism and not a billboard + +The cross-section is an ellipse of half-width `bw` (the bristle's ink width) and half-depth `depth` +in metres through the paper normal, swept along the spine. Seal script hangs a column of characters +in the air and the camera orbits it: a billboard turned edge-on is thinner than a pixel and the +whole column blinks out of existence. A prism seen edge-on is a solid bar of ink `2 * depth` wide +and the writing stays writing. Face-on the depth is invisible and the mark reads flat, which is what +sumi wants — one geometry serves both because the ellipse is degenerate in exactly one axis. + +`setPaper(normal)` is that axis. Sumi lays a stroke on the floor and passes world up; seal script +passes the horizontal direction the column faces. + +### The tips + +| tip | layout | what it is for | +| --- | --- | --- | +| `FLAT` | one rank across the width | the sumi brush; a dry tail of clean parallel streaks | +| `ROUND` | golden-angle disc fill | streaks separate through the paper normal too; needs `ferruleDepth` | +| `SPLIT` | three tufts with paper between | a brush somebody has already ruined | + +`SPLIT`'s tufts are deliberately much narrower than their share of the ferrule. At the first value +they touched, and the mark came back out as a solid stroke — a split brush is defined by the gaps, +not by the clumps. + +### Parking, and why a stroke is never compacted + +`stroke.active = false` **parks** a stroke: its draw window is pushed past the end of the clock, so +its head resolves to zero and the vertex shader collapses it. It is not skipped and the live strokes +are not shuffled down. The bristle dice live in the instance slot, so compacting would slide every +later character into a different slot and silently re-write it. + +### The one rule + +**Let the pressure curve spend the ink.** The tail frays where `inkLoad` runs out against what +`pressure()` and the spine's own speed have already spent, so a heavy stroke should fray early and a +skimming one should not fray at all. The way to move the fray is to change how hard the brush is +pressing — not to reach for `dryBand`. If you are tuning the dry parameters to put the fray in a +particular place, the pressure curve is wrong. + +--- + +## `InkDiffusion.js` — ink spreading on the floor, unstably + +One ground quad working in **metres from the anchor**, like `GroundField`, carrying ink that spreads +through a real fingering instability: glossy at the wet leading edge, matte behind it, and with a +`SPLATTER` mode that adds a directional mass, a crown of rim teeth and a power-law scatter of +satellite droplets down the travel vector. + +It serves `inkbloom` (`BLOOM`) and `splatterbrand` (`SPLATTER` plus `Projectile`), and `WASH` is +there as the cheap flat underlay the other two sit on. + +### Draw-call cost + +**One.** Always one, whatever the mode, however many satellites. + +### Signatures + +```js +InkMode = { BLOOM: 0, SPLATTER: 1, WASH: 2 }; INK_MODE_NAMES: string[3] +inkDiffusionParams() -> object // canonical key list with units + +new InkDiffusion(parent, { mode = InkMode.BLOOM, sources = 4, satellites = 16, + layer = LAYER.VFX, renderOrder = 6, name = null }) + // mode, sources and satellites are #defines / array sizes — fixed for the lifetime +f.object3D · mesh · material · uniforms · geometry · drawCalls(1) · mode · age +f.sources · satellites · seed +f.setPlacement(anchor, along) // `along` is projected flat; it becomes local +z +f.setVisible(v) · roll(seed) · reset() · update(now, params) // `now` = the ability's age +f.frontRadius(i = 0) -> metres +f.sourcePoint(i, out) · satellitePoint(i, out) · satelliteSize(i) · satelliteReach(i) +f.satelliteAge(i) · dispose() + // the mirrors read the last-synced params and age: call them AFTER update() on the same frame +``` + +### The instability, and two versions of it that were wrong + +The interface between a thin fluid pushing into a thick one is unstable — a bulge sees a steeper +gradient, moves faster, and becomes a finger. The part that has to be *modelled*, though, is not +that the front is rough. It is the **order the scales arrive in**. A lobe two metres across cannot +exist on a blob that is not yet two metres across, so the coarse modes are inadmissible until the +front has grown into them. A young bloom is a small crinkled disc; a mature one is a handful of +enormous branching lobes with that same crinkle riding on their tips. + +`fingers()` is five octaves at wavelengths `coarse`, `coarse/2`, … Octave *k* is **admitted** once +the front radius passes `onset * L_k`, then grows like `exp(growth * travel / L_k)` and **saturates** +at `growthMax`, so a mature mode settles at roughly its own wavelength. + +Two earlier versions, both instructive: + +1. `r + amplitude * fbm(atan(p.y, p.x))`. Sampling on the **bearing** hands every radius along a + bearing the same value, so the fingers were dead-straight radial spokes of constant width from + the centre to the rim — a firework, not a bloom. No number of octaves fixes it: the error is in + the domain, not the spectrum. The noise here is sampled in two-dimensional metres, so a finger + can bend and two fingers born at different radii do not line up. +2. All five octaves live from `t = 0` at fixed amplitudes. That is an fbm ring, and the giveaway is + that it is *the same shape at every size* — you can see that the blob is being scaled rather than + grown. Admitting the octaves by radius is the whole of the difference and it costs one `max()` + per octave. + +Saturation matters as much as admission: without `growthMax` the first octave to mature eats +everything and the bloom is two lobes for ever. + +**`coarse` is the only knob that changes what the pattern *is*.** Everything else changes how much of +it you get. Set it against the field radius first — 3 m of `coarse` on a 4 m field gives two fat +lobes, 0.8 m gives lace — and only then reach for `finger` and `growth`. + +### Why the crown *is* allowed to be spokes + +In `SPLATTER` the leading edge grows a crown of teeth, and those **are** bearing-indexed. A crown is +a Rayleigh–Plateau breakup of an expanding rim: it genuinely is periodic in bearing, its teeth +genuinely are radial, and its tooth count genuinely does rise with the rim's radius +(`teeth = 2πr / crownSpacing`). The rule was never "never index on bearing", it was "do not index on +bearing when the physics is areal". + +The crown is allowed to move the **silhouette and nothing else**, which is why it has its own +distance (`dShade` keeps the pre-crown value). Subtracting it from `d` outright gets the teeth right +and grows a fan of radial spokes reaching all the way to the impact point, because the film's +thickness term reads the same `d` and the crown is periodic in bearing at *every* radius. A rim +instability is a property of the rim. + +### The satellites are the readable half + +A flung blob does not make a circle. It makes a directional main mass (anisotropy in the **metric**, +never in a bearing-indexed radius, or the mass grows flat facets down its flanks), a crown, and +satellite droplets thrown further along the travel vector. + +- **Sizes are a bounded Pareto**, inverse-CDF sampled with exponent `satAlpha` (2–3 is what + fragmenting sheets actually do). A uniform draw gives a dozen same-sized dots and the eye files it + as a stencil. +- **Small droplets fly furthest** — they detach last, from the fastest part of the sheet, with the + least drag per unit mass. `reach` is driven off the *same* draw as `size`, so the far field is fine + and the near field is coarse. +- **Each droplet is a teardrop**, a bead tapering down a tail that points back toward the throw. A + droplet that lands moving forward does not make a circle, and the tails are the direction cue that + turns twelve scattered dots into one thrown handful. +- **Each lands on its own clock**, further meaning later, so the far field arrives after the mass. + +The dice are **uploaded as uniform arrays**, not hashed in the shader, so `satellitePoint(i, out)` +agrees with the fragment shader exactly — `splatterbrand` throws a `Projectile` at each satellite +and it has to land *in* the droplet, not near it. A JS mirror of a GLSL hash is a mirror of float32 +rounding and it drifts. The arrays are indexed only by the loop counter, which is the one form of +uniform-array indexing ESSL 1.00 allows. + +### Wet and dry, with no history + +A fragment's ink arrived when the front radius equalled its distance. `r = spread * t^spreadPower` +inverts in closed form, so `arrivalOf(d)` is exact, `age - arrival` is the age of the film at every +point, and the gloss is `exp(-age / dryTime)`. No buffer, no per-fragment state, and the whole +drying pattern re-resolves when a paused author drags `dryTime`. + +The gloss itself tips the film's normal outward at the meniscus — a Gaussian on the signed distance +to the interface — and rotates that tilt into world space with the field's own axes, which is why +`setPlacement` uploads `uAxisAlong` / `uAxisAcross`. + +### `radius` is a boundary, not a hint + +The mass is clipped to `radius` over a soft `clipSoft` band, measured from the **anchor** rather than +from a nucleus, because a zone belongs to the ability and not to the blob. That clip is also what +keeps the quad bounded: a front left running for ten seconds would otherwise grow a canvas the size +of the level. Satellites are exempt — they are thrown out of the zone on purpose — so a `SPLATTER`'s +quad is sized from `throwFar` plus the furthest droplet's tail. Get *that* wrong and the far field is +cut off by a straight edge with a corner on it, which looks worse than having no satellites. + +### The one rule + +**Set `coarse` against the field radius before you touch anything else.** It is the wavelength of +the largest lobe the bloom can ever grow, and it decides whether the cast reads as a bloom, a puddle +or a bath sponge. Authors who start with `finger` end up with a wobbly disc turned up loud. + +--- + +## The anti-glow contract + +Ink is the school with no bloom in it, and that premise is one careless line away from being lost. +Both modules hold to the same four rules, and `scripts/check-vfx-ink.mjs` asserts every one of them +so that losing it fails a check rather than a screenshot. + +1. **A luminance ceiling in the fragment stage.** `UnrealBloomPass` runs on linear scene colour + *before* the tone map — the chain is `RenderPass → distortion → bloom → OutputPass` — so + `settings.post.bloomThreshold` (0.88 as shipped) is a **linear** luminance. Both materials + hard-clamp their output luminance to `ceiling`, 0.62 by default. That makes it *impossible* for + ink to feed bloom, whatever it is standing next to and whatever the pickers are set to, which is + stronger than "pick dark colours" — one careless picker away from a glowing brushstroke. +2. **Never multiply by `uGlobalGlow`.** Every other VFX material in this library does, because every + other one is emissive. Ink is not, and the global glow slider must not be able to light it. +3. **`toneMapped: true`,** unlike every other module here. It is inert as things stand — the composer + always renders to a target, where three switches materials to `NoToneMapping` and `OutputPass` + grades the composite once for everything — but the flag is a statement about what the material + *is*. Ink is pigment and belongs under the same grade as the floor it is lying on. Copying + `toneMapped: false` off an emissive module into a matte one is exactly the mistake this school + exists to avoid. +4. **No additive path, and no specular except one.** Neither module can be switched to + `AdditiveBlending`; "ink that adds light" is the school's failure mode. The single specular term + is the gloss on `InkDiffusion`'s wet leading edge, and it is inside the ceiling — a reflection + that cannot exceed the bloom threshold is an observation about a wet surface, not an emission. + +### Verifying these two + +``` +node scripts/check-vfx-ink.mjs # ~950 assertions, ink-specific +npm run check # the shared vfx stage, plus every ability +``` + +The bench covers what the shared stage cannot: static shader sanity (balance, reserved words, +backticks, undeclared uniforms, varyings, `fwidth` in a vertex stage), the anti-glow contract above, +the bristle layouts and the parking path, the admission order of the instability's octaves, the +shape of the satellites' power law, and the placement mirrors that `splatterbrand` aims with. + +--- + + +## `HardSurface.js` — the machined half of the material vocabulary + +Gears, pistons, sawblades, plates, bolts and anvils, in brushed steel that can be brought up to +forging heat and back down again. + +Everything in this sandbox before Forge was **grown, quarried or bled** — crystal, rock, bone, vine, +ash, blood. All of it soft-edged, all of it noise-driven, all of it made by nature. A machined +object reads differently for three reasons, and none of them is the silhouette: + +1. **Its edges are deliberate.** A chamfer is a decision. Nature does not put a 0.4 mm bevel round + the top of a hole, and no shader can fake one — the geometry has to have it. That is why this + module generates geometry at all rather than shipping a material and letting abilities bring + their own boxes. +2. **Its highlight is directional.** Brushed and turned metal has a grain and the specular smears + *across* it into a line. An isotropic GGX lobe on a cylinder is a plastic bottle; an anisotropic + lobe whose tangent runs round the axis is a lathe-turned collar — same silhouette, same albedo. +3. **Its colour is a temperature, not a palette.** Steel above about 900 K stops taking its colour + from the room and starts making its own, along one curve every foundry photograph agrees on. + Authoring that curve as a four-stop gradient is the tell. + +### Attach convention, and draw calls + +**Neither.** This is a toolkit, like `prefixedBlock.js`, not a system: it owns no meshes, adds +nothing to a parent and has no `update()`. **Zero draw calls of its own** — whatever you hang the +geometry on pays. In practice that is `GrowthField` (which wants a factory and a material and owns +the instancing), `Projectile`, or a plain `Mesh`. + +### Settings convention + +**Canonical.** `hardSurfaceParams()`, `gearTrainParams()` and `grindParams()` are the three key +lists, all re-read on every call. The *shapes* are the interesting exception. + +### Signatures + +```js +import { + HardShape, HARD_SHAPE_NAMES, HardAxis, BrushMode, BRUSH_MODE_NAMES, + gearShape, pistonShape, sawbladeShape, plateShape, boltShape, anvilShape, hardShape, + createGearGeometry, createPistonGeometry, createSawbladeGeometry, + createPlateGeometry, createBoltGeometry, createAnvilGeometry, hardSurfaceGeometry, + gearPitchFraction, gearRootFraction, ShapeCache, + GearTrain, gearTrainParams, + createHardSurfaceMaterial, syncHardSurfaceMaterial, hardSurfaceParams, + blackbodyColor, heatToKelvin, + GrindContact, grindParams +} from '../vfx/HardSurface.js'; + +HardShape = { GEAR: 0, PISTON: 1, SAWBLADE: 2, PLATE: 3, BOLT: 4, ANVIL: 5 } +HardAxis = { Y: 0, X: 1, Z: 2 } // which way the part's own axis points once seated +BrushMode = { LINEAR: 0, CIRCUMFERENTIAL: 1, RADIAL: 2 } + +Shape(overrides) → object // all-numeric; every field is a slider +hardShape(kind, overrides) → object // the dispatcher form +hardSurfaceGeometry(kind, shape) → BufferGeometry // position, normal, aEdge, indexed +createGeometry(shape) → BufferGeometry // same thing, named + +gearPitchFraction(shape) → 0..1 // pitch radius / actual outer radius +gearRootFraction(shape) → 0..1 + +new ShapeCache({ capacity = 8 }) +cache.get(slot, kind, shape) → BufferGeometry // rebuilds only when a number moved +cache.changed · size · dispose() + +new GearTrain({ capacity = 12 }) +train.plant(count, seed) → number // onSpawn; the only dice roll +train.solve(p) // every frame, dt = 0 included +train.count · teethOf(i) · pitchRadiusOf(i) · tipRadiusOf(i) · angleOf(i) · rateOf(i) +train.scaleOf(i, pitchFraction = 0) → metres // uniform instance scale +train.yawOf(i) → radians // write into mesh.rotation.y +train.positionOf(i, p, out) · contactOf(i, p, out) // world; contact is the pitch point +train.clear() + +createHardSurfaceMaterial({ environment = null, flatShading = false }) + → MeshStandardMaterial with .userData.uniforms and .userData.sync(p) +syncHardSurfaceMaterial(material, p) → material // call EVERY frame +blackbodyColor(kelvin, out?) → THREE.Color // the same locus, on the CPU +heatToKelvin(heat, p) → kelvin + +new GrindContact() +GrindContact.rimVelocity(out, axis, rate, point, centre) → out // v = ω × r +grind.solve(contact, normal, rimVelocity, p) → this +grind.jet(index, emit) → emit // fills position/direction/speed/… +grind.jets · speed · rimSpeed · origin · direction · normal · binormal +``` + +### Shapes are unitless, and that is what keeps I1 honest + +A shape object holds **no metres**. Every generator emits geometry normalised into the unit box: +the largest extent — footprint diameter or height, whichever binds — is exactly 1, the body sits on +`y = 0`, and the axis is centred. The footprint therefore always fits the circle of radius 0.5 that +`GrowthField`'s factory contract asks for, and the instance transform is the only place a metre +appears. + +So a shape field is a **proportion**: `gearShape().thickness = 0.22` means "0.22 of the gear's own +diameter", and a gear scaled to 1.4 m across is 0.31 m thick. The one place it bites back is a field +that controls the *dominant* dimension — push a piston's `length` past its diameter and `length` +stops changing the silhouette and starts changing what the other proportions mean, because the +normalisation divides it straight back out. The alternative, normalising each axis separately, makes +every proportion slider inert and turns a gear into a drum the moment you touch its thickness. + +The good consequence: a gear's **module** — metres of pitch diameter per tooth, the number that +decides whether two gears mesh — is a metre, so it is *not in `gearShape()` at all*. It lives in +`GearTrain`'s params. Drag it and the whole train re-spaces and re-phases without one vertex being +rebuilt. + +### Build cost, and where to spend it + +Measured on an M-series, cold: + +| shape | vertices | triangles | build | +| --- | --- | --- | --- | +| `GEAR` (14 teeth, `flankSteps: 6`) | 3,948 | 4,200 | 8.7 ms | +| `SAWBLADE` (22 teeth, 4 slots) | 3,778 | 3,522 | 4.4 ms | +| `BOLT` (9 thread turns) | 1,625 | 2,228 | 1.3 ms | +| `PISTON` | 1,178 | 1,248 | 1.1 ms | +| `ANVIL` | 595 | 1,084 | 1.2 ms | +| `PLATE` | 760 | 924 | 0.8 ms | + +Nine milliseconds is a dropped frame, so **nothing here may be rebuilt speculatively** — that is +what `ShapeCache` is for, and what `GrowthField#syncGeometry` is already for one level up. For a +*field* of gears rather than a hero one, drop `flankSteps` to 3 and `tipSteps`/`rootSteps` to 1: the +tooth count is the silhouette and the flank samples are not, and it costs a third of the vertices. + +### The involute, and why it is not decoration + +`gearlock`'s trick is that the teeth genuinely mesh, and they only mesh if the profile is right. +Two things do that work. + +**The curve.** The flank is a real involute of the base circle: half the angular tooth thickness at +radius *x* is `ψ(x) = ψ_p + inv(α) − inv(α_x)` with `cos α_x = r_b / x` and `inv(α) = tan α − α`. At +the pitch circle `α_x = α` and it collapses to `ψ_p`, which is the identity to check when you change +anything in there. Involute flanks transmit motion at a constant ratio *regardless of centre +distance*, which no other tooth curve does, and which is what lets an ability drag the module and +stay meshed instead of needing a new profile. + +**The phase.** Rate alone is not enough, and this is the failure everybody ships. Two gears turning +at the perfect ratio still interpenetrate unless a tooth of one is aimed at a *gap* of the other +along the line of centres, and that is a constraint on the absolute angles, not their derivatives: + +``` +θ₂ = β + π + (z₁/z₂)(β − θ₁) − π/z₂ +``` + +Differentiate it and `ω₂ = −(z₁/z₂)ω₁` falls out, which is the check that it is the right +constraint. The first version of `GearTrain` set the rates from the tooth counts and left the phases +at zero: the train counter-rotated correctly and the teeth ground straight through one another, +which is a bug you can watch for a full minute before you see it. + +Both are verified numerically rather than by eye. Sweeping a full turn of five tooth pairs with +`backlash: 0`, the flanks stay in contact to within **0.02–0.4 mm on gears two metres across** and +nothing crosses; drop the `−π/z₂` term and 70+ points per frame are inside the other gear. The +default `backlash: 0.04` opens that to a ~9.5 mm clearance that never varies by more than a +millimetre over the sweep, which is conjugate action doing exactly what it promises. + +The other numbers a train needs are equally unforgiving: the centre distance is `m(z₁ + z₂)/2`, +which is the sum of the **pitch** radii — not the tip radii, the tempting wrong answer that leaves a +gap of one whole tooth height. + +**A tooth count is a shape, not a transform.** Gears with different `teeth` cannot share an +`InstancedMesh`. Keep a train to two or three distinct counts and it stays at two or three draw +calls; give every gear its own count and you have bought a draw call each. + +### Profiles are clamped, because Earcut does not throw + +Every field in these shapes is a live slider, so every combination of them is reachable, and a +self-intersecting outline does not raise anything — `ShapeUtils.triangulateShape` answers it with a +fan of inverted triangles and the part renders inside out. A sweep of 1,080 gear profiles and 2,160 +blade profiles across the editor's whole range found three separate ways in, all now closed: + +- **A pointed tooth** (high addendum, low tooth count) has a real answer — the tip circle moves down + to the radius where the flanks meet, found by bisection. Clamping the half-angle at zero instead + leaves every sample above that radius on the centreline and the flanks walk through each other. +- **A tooth wider than its own pitch** happens on a fine, high-pressure-angle gear whose half-angle + passes `π/z` before the flank reaches the base circle. The flank now starts at whichever radius + comes first and the fillet covers a little more of the root. +- **A blade's rake and clearance** are clamped *two-sided*. Only the upper bound was guarded at + first — the case where a deep hook and a deep relief eat the whole tooth pitch — and a strongly + negative rake then swung the cutting face back past the clearance face of its own tooth. The + condition is one number either way: the sum `(tan rake + tan clearance)·ln(r_tip/r_gullet)` has to + stay inside `(−2·land, pitch − 2·land)`. + +Both working faces of a saw tooth are **logarithmic spirals**, incidentally, and that is not a +flourish: a face at a constant angle β off the radial satisfies `dθ/dr = −tan β / r`, whose integral +is `θ(r) = θ_tip ± tan β · ln(r_tip/r)`. Straight chords look right at 24 teeth and visibly wrong at +8, where the rake at the gullet ends up nothing like the rake at the tip. + +### `aEdge`, and why only a generator can produce it + +The generators emit positions and topology only, welded; a single pass afterwards splits creases +(the standard smoothing-group algorithm), computes normals, and hands back `aEdge` — a per-vertex +value marking where the machinist's file would have been. A material handed somebody else's mesh has +no way to know that, which is the whole argument for generating geometry here. + +The first version marked every crease and was useless: **every** ring of a chamfered extrusion is a +crease, so 84% of a gear's vertices came back marked, `aEdge` was 1 almost everywhere, and edge wear +just brightened the part. What the material wants is the *lip* — the outermost ring of a chamfer, +falling to zero across the band, which is a thin bright line rather than a wash. So the generators +author that ring directly and an unhinted crease is worth only `CREASE_WEAR` (0.22), enough to catch +a bore rim and not enough to lose a face. + +Degenerate triangles are dropped rather than skipped: a gear's root fillet collapses to a sliver +when `rootFillet` winds down, and a zero-area triangle contributes a NaN normal that poisons the +whole vertex. + +### The material + +A patched `MeshStandardMaterial`, so it takes CSM's real shadows and the HDR probe, in the +`IceMaterial` tradition. Uniform boxes are parked on `material.userData.uniforms`. + +| term | roughness | metalness | albedo | emissive | +| --- | --- | --- | --- | --- | +| grain | ± streaks along the brush | — | — | modulates the lobe | +| pitting | up | — | toward `colorDeep` | — | +| mill scale | up | **down** | toward `colorScale` | kills the lobe | +| edge wear | down | — | toward `colorPolish` | lifts the lobe | +| heat | — | — | toward the blackbody colour | `(T/T_ref)⁴` | + +The metalness drop under mill scale earns its line: scale is an oxide, oxides are not conductors, +and a scaled patch that keeps `metalness = 0.94` reads as a dirty mirror instead of a crust. + +**The anisotropy is hand-rolled, and it had to be.** `MeshPhysicalMaterial` has had native +anisotropy since r155 and it is better than this. It is also unreachable: it reads its direction +from the tangent frame, which three only switches on (`USE_TANGENT`) alongside a **normal map** — +and a normal map is a texture, which **I2** forbids. Supplying a `tangent` attribute without the map +leaves the define off and the anisotropy silently isotropic, which is the failure that cost the +afternoon — the material compiled, `material.anisotropy = 0.8` was set, and nothing changed. So the +lobe is a **Ward** anisotropic specular against `frame.uLightDir`, added to +`totalEmissiveRadiance`. It is not shadowed (the injection point is before the light loop, where the +shadow factor does not exist yet) and it is one light. Neither has ever been visible on a spinning +gear. + +The tangent is transformed by `mat3(modelViewMatrix)`, **not** `normalMatrix`. A tangent lies along +the surface and transforms like a position difference; the inverse-transpose is for normals. It +matters more than usual here because `GrowthField` scales footprint and height independently, so a +squat gear is genuinely non-uniformly scaled and the two matrices visibly disagree. + +`hardBrushDir()` is shared verbatim between the two stages. It has to be: the vertex stage needs the +tangent in view space for the lobe and the fragment stage needs the same direction in local space to +squash the grain noise along. Two copies of the maths gives you a highlight running at a slight +angle to the streaks it is supposed to be lying in, which looks like a bug in the noise and is not. + +### The blackbody ramp — `quench`'s whole trick + +One `heat` uniform in 0..1 maps to kelvin between two sliders (300 K cold workshop, 2000 K welding +heat by default), and the colour comes off the **Planckian locus**: Kim et al.'s cubic for CIE `x`, +a second cubic for `y`, then `xyY → XYZ → linear sRGB`. Emission is `(T/T_ref)^n` with `n = 4` from +Stefan–Boltzmann, so hot metal genuinely glows and cool metal genuinely does not — 900 K is 0.27 of +the reference and 1800 K is 4.3. + +Two things about it are worth knowing before anybody edits it. + +**Kim's fit stops at 1667 K and every temperature this material cares about is below it.** 1667 K +is bright orange; forging heat is 1450 K, first visible red is 900 K, and a quench spends its whole +life under the fit. Clamping there — the first version — gave one flat orange across the entire ramp +that simply dimmed, and a quench that dims is not a quench. Extrapolating the cubic instead is +worse and is spectacular: below ~1200 K `x` runs off past the spectral locus, `y` collapses, and the +metal cools through cherry into *magenta* and then into a colour that does not exist. So the low end +gets its own fit, a quadratic in `1000/T` through the published locus points at 1667 K, 1000 K and +800 K, landing within 0.005 of the true chromaticity at 1200 K. The `y` cubic needs no such +treatment — it is a fit in `x`, not in `T`, and it tracks the locus to within 0.004 all the way +down. + +What comes out, and it is a cooling curve rather than a gradient: + +| K | 900 | 1000 | 1100 | 1200 | 1450 | 1600 | 1800 | 2000 | 2400 | 3000 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| hue | `#ff0000` | `#ff1200` | `#ff3300` | `#ff4600` | `#ff6400` | `#ff7000` | `#ff7e00` | `#ff8b16` | `#ffa042` | `#ffb86d` | + +**Edges run cooler.** A chamfer has more surface per unit of steel behind it, so `heatEdge` subtracts +`aEdge` from the temperature: the machined edges are the first thing to go black in a quench and the +last thing to come up in a fire. + +`blackbodyColor(kelvin, out)` is the same locus on the CPU, for a light colour or a particle tint. +Use it: sparks off hot steel are the same temperature as the steel, and hard-coding an orange for +them is how a quench ends up with cherry-red metal throwing lemon-yellow sparks. + +### `GrindContact` — `sawline`'s whole trick + +A saw that throws sparks radially is a firework. Real grinding sparks leave at the **contact +tangent** — they are lumps of the workpiece that were travelling with the tooth when it let go of +them, so they carry off the tooth's velocity, and the tooth's velocity at the rim is tangential by +definition. Point them along the radius and you have drawn a dandelion; point them along `ω × r` and +you have drawn an angle grinder, from the same particle system with the same colours. + +Two corrections sit on top. A tooth at the contact is usually driving *into* the workpiece, so part +of `ω × r` points below the surface — sparks do not tunnel, so that component is reflected back out +with a restitution (`bounce`), and `rise` tilts the whole sheaf away from the floor, which is what +makes the arc every photograph of a grinder shows. The ratio between them is the difference between +a cut-off wheel and a bench grinder, so both are sliders. + +The fan sweeps in the plane containing the normal, which means its flattest jet can still end up +pointing into the workpiece — the harness caught exactly that — so each jet is lifted back to a +grazing angle (`graze`) if it goes under. A spark emitted into the floor is a spark you never see, +and it reads as the stream thinning on one side for no reason. + +The jets are spread deterministically across the fan and only the particles inside them are random. +A random fan re-rolls its shape every frame and the stream shimmers; a fixed fan with random +particles reads as one continuous sheaf. + +```js +GrindContact.rimVelocity(_vel, _axis, spinRate, _contact, _centre); +this.grind.solve(_contact, _normal, _vel, this._grindParams); +for (let j = 0; j < this.grind.jets; j++) { + this.grind.jet(j, _emit); + _emit.time = this.age; + _emit.tint = blackbodyColor(1900); + this.sparks.emit(count, _emit); +} +``` + +`jet()` writes the caller's emit object and points its vectors at the solver's own scratch; +`ParticleSystem#emit` reads and never retains, which is the contract that makes it allocation-free. +Do not keep the object. + +### Composing with `GrowthField` + +`createHardSurfaceMaterial()` does not carry `GrowthField`'s per-instance varyings, but the two +patches **compose** — both route through `patchOnBeforeCompile` and both re-emit the +`#include ` they replace — so `patchGrowthMaterial(metal, {})` over the top gives a gear the +birth flash and the stagger as well. Hand `GrowthField` the raw generator as its factory, not a +`ShapeCache`: it already runs the same change test one level up, and it disposes the geometry it is +handed the moment its own hash moves, which would free a gear another ability was still drawing. + +### The one rule, per part + +- **The shapes** — *author proportions, never sizes, and let the instance carry the metre.* The + moment a shape field means millimetres, `syncGeometry` starts firing on a slider that should have + been a transform, and a nine-millisecond rebuild lands in the middle of a cast. +- **`GearTrain`** — *place gears by `positionOf` and `scaleOf`, never by hand.* The centre distance + is the sum of the pitch radii and the phase has a `−π/z₂` in it; both are easy to nearly get + right, and nearly right is teeth passing through teeth. +- **The material** — *drive `heat` and leave the palette alone.* There is no `colorHot` picker and + there should not be one. The whole value of the term is that nobody gets to place the yellow. +- **`GrindContact`** — *hand it `ω × r`, not a direction you liked the look of.* diff --git a/src/vfx/SceneHooks.js b/src/vfx/SceneHooks.js new file mode 100644 index 0000000..3c0b118 --- /dev/null +++ b/src/vfx/SceneHooks.js @@ -0,0 +1,1558 @@ +import { + Color, + Mesh, + MeshBasicMaterial, + SphereGeometry, + Vector3, + Vector4 +} from 'three'; +import { LAYER } from '../core/Layers.js'; +import { frame } from '../core/FrameUniforms.js'; +import { settings } from '../config/settings.js'; +import { getColor } from '../utils/color.js'; +import { clamp, saturate, lerp } from '../utils/math.js'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { patchOnBeforeCompile, replaceChunk } from '../utils/shaderPatch.js'; +import { + disruptBlock as _disrupt, + gravityBlock as _gravity, + neutraliseDisrupt, + neutraliseGravity +} from './hookFields.js'; + +// The published fields are re-exported by identity so the seven modules that +// opt in keep their existing import line. See `./hookFields.js` for why they +// are not defined here any more — the short version is that this file imports +// `config/settings.js` and `vfx/Tube.js` cannot. +export { + disruptGLSL, + disruptUniforms, + gravityGLSL, + gravityUniforms +} from './hookFields.js'; + +/* ------------------------------------------------------------------------ */ +/* SceneHooks — the module that lets an ability change the world */ +/* ------------------------------------------------------------------------ */ +/** + * Every other module in `src/vfx/` draws inside the ability's own group. This + * one does the opposite: it reaches *out* of the group and edits the world the + * ability is standing in — swings the sun, drains the grade, ages the floor, + * punches a hole in the frame, inverts gravity, and publishes a region that + * other abilities' shaders can read and come apart inside. + * + * Seven abilities in `docs/ROSTER-II.md` are built on it: `dawnbreak` and + * `sheetlightning` (key light), `eclipse` (grade), `entropy` (material age), + * `silence` (hole), `hourglass` (gravity) and `spellbreak` (disrupt). + * + * ## Why this file is mostly a ledger and hardly at all a renderer + * + * Four casts can be live at once. An ability can be destroyed mid-effect by + * the concurrency cap, by **C**, or by the editor's clear button. If any one of + * those paths can leave the sun pointing sideways or the floor rusted, the + * sandbox is broken for the rest of the session and the only fix is a reload. + * That risk is the whole design problem, and it is a *bookkeeping* problem, not + * a graphics one. So the graphics here are deliberately thin — a depth proxy, a + * uniform blend, a patch into the floor — and the bookkeeping is the substance. + * + * The first version had no ledger at all: each hook was a pair of free + * functions, `takeKeyLight(...)` and `restoreKeyLight()`, and the ability was + * trusted to pair them. It survived exactly as long as it took to cast Dawnbreak + * and press **C** halfway through: `onDestroy()` ran, `restoreKeyLight()` was + * called from a code path that had already been torn down, and the stage stayed + * lit from the horizon until reload. Trusting the caller to pair calls across a + * lifecycle that can be interrupted is not a discipline, it is a hope. + * + * ## The discipline + * + * 1. **Borrow.** `sceneHooks.acquire(Hook.KEY_LIGHT, this)` returns a *token*. + * It never returns null — unlike `ctx.lights.acquire()`, there is no pool to + * run out of, because the world has exactly one of each of these things and + * sharing it is a question of *ordering*, not of availability. + * 2. **Write, every frame.** The token's setters take live values resolved from + * `settings[id]`. They store; they do not touch the world. Writing also + * *renews the lease* (see 5). + * 3. **Apply, once.** `sceneHooks.apply()` runs once per frame from `App.frame` + * and is the only code in the project that writes to the borrowed world. + * 4. **Release.** `token.release()`, or `sceneHooks.reclaim(owner)` for the lot. + * Releasing twice is a no-op. Releasing a token that has been recycled into + * another ability's hands is a no-op, because tokens carry a serial. + * 5. **Or don't, and the module takes it back.** A held hook whose token has not + * been written for `LEASE_FRAMES` frames is reclaimed automatically, with one + * warning naming the owner. That is the net under the trapeze: an ability + * destroyed mid-effect, an exception thrown between `acquire()` and the + * matching `release()`, a hot reload — none of them can strand the world. + * + * ## Restore is *exactness*, and two of the six get it for free + * + * `Environment.update()` re-authors the key light from `settings.environment` + * every frame, before the abilities run; `PostProcessing.sync()` re-authors the + * grade from `settings.post` every frame, just before `apply()`. Those two hooks + * therefore have nothing to put back: they are a per-frame *overwrite* of a + * value that is itself rewritten from settings on the next frame. Stop applying + * and the world is already exact — not approximately, not within a float, but + * bit-for-bit the value the sliders say. + * + * That is also why `apply()` blends **from `settings`**, never from the live + * light or the live grade uniform. Blending from the live value would compound + * frame over frame the moment anything called `apply()` twice, and would drift + * in a way nobody would ever find. It costs a duplicate of Environment's four + * lines of azimuth/elevation trigonometry; that duplication is called out at + * `_applyKeyLight` and is worth it. + * + * The other four hooks mutate persistent state — a material uniform, a mesh's + * visibility, two published uniform blocks — so each one has an explicit, + * documented neutral, and releasing writes it. + * + * ## Last acquirer wins + * + * Two abilities may hold the same hook. Acquiring never fails and never evicts: + * the new token goes on **top of that hook's stack** and drives the world; the + * earlier holder keeps a live token, reads `token.driving === false`, and + * resumes driving the instant the top token is released. LIFO, skipping any + * token released in the meantime. + * + * The tie-break is *acquisition* order, not write order, and that is the reason + * everything lands in one central `apply()` rather than write-through from the + * setters. With write-through, two live holders would resolve to whoever's + * `update()` happened to run last in `AbilityManager`'s iteration — an ordering + * that is real, invisible, and changes when a cast expires. With a stack, cast + * Eclipse then Dawnbreak and Dawnbreak's sun wins; let Dawnbreak expire and + * Eclipse's is back on the next frame, mid-cast, with no special case. + * + * ## Cost when nothing is held + * + * `apply()` is `if (this._held === 0) return;` — one integer compare per frame, + * measured at 3–5 ns. Nothing is allocated, no uniform is written, no mesh is + * added to the scene, and the hole proxy is `visible = false` so the renderer + * never sees it. The three opted-in shared materials (`FilamentPaths`, + * `GroundField`, `Swarm`) each pay **one compare against a uniform** per vertex + * for the disrupt field — uniform control flow, coherent across the whole draw + * call, and it early-outs to a constant 0.0 that the fragment stage then also + * early-outs on. That is as close to free as a shader gets without a recompile, + * and a recompile is not on the table: the field switches on mid-cast. + * + * ## Invariants + * + * - **I1** — *this module holds no dimensions at all*. Not one metre, radian, + * second or colour lives here across a frame boundary: every value in every + * hook arrives through a token setter that the holding ability calls each + * frame from its own live settings, zero-length frames included. Pause with + * **P**, drag `entropy`'s rust slider, and the floor rusts differently while + * the clock is stopped. The one exception is the *neutral* each hook restores + * to, which is not a dimension but a definition of off. + * - **I3** — tokens come from per-hook free lists and are recycled; `apply()` + * writes numbers into uniform boxes that already exist. A cast that borrows + * four hooks and holds them for ten seconds allocates nothing after the first + * time that ability id is cast. + * - **I5** — there is not a single authored number in this file. Every value is + * the ability's, which is what keeps the sliders honest: `dawnbreak`'s sun + * elevation is a slider in `settings.dawnbreak`, not a constant in here. + * - **I8** — the floor patch parks its uniform boxes on + * `material.userData.uniforms`, and `observe(material)` will park the module's + * whole live state there for the harness's pause probe. + */ + +/* ---------------------------------------------------------------- */ +/* The six hooks */ +/* ---------------------------------------------------------------- */ + +export const Hook = Object.freeze({ + /** The scene's directional light: direction, colour, intensity, weight. */ + KEY_LIGHT: 'keyLight', + /** The post stack's grade: saturation, temperature, lift, vignette, weight. */ + GRADE: 'grade', + /** A 0..1 ageing field the floor material reads. */ + AGE: 'age', + /** A region where nothing renders. */ + HOLE: 'hole', + /** A signed gravity multiplier inside a volume. */ + GRAVITY: 'gravity', + /** A published region other abilities' materials opt into reading. */ + DISRUPT: 'disrupt' +}); + +/** Iteration order for sweeps and for `describe()`. Stable, for the readout. */ +const HOOK_IDS = Object.freeze([ + Hook.KEY_LIGHT, + Hook.GRADE, + Hook.AGE, + Hook.HOLE, + Hook.GRAVITY, + Hook.DISRUPT +]); + +/** + * Frames a token may go unwritten before the module takes the hook back. + * + * Eight, because the holder is expected to write every frame and eight frames + * is 133 ms at 60 Hz — long enough that a stutter, a tab switch or a single + * dropped frame in `AbilityManager` never trips it, short enough that a leak is + * gone before the eye finds it. A holder that legitimately has nothing to say + * this frame calls `token.hold()`, which renews the lease and changes nothing. + */ +const LEASE_FRAMES = 8; + +/** + * Metres the key light is parked up-sun of the focus point. + * + * Mirrors the literal in `world/Environment.js#update`. It is not a look + * parameter — the shadow camera's far plane is 140 m and the light has to sit + * inside it — so it is a constant in both places rather than a slider, and if + * one moves the other must. Named here so the grep finds both. + */ +const SUN_DISTANCE = 70; + +/* ---------------------------------------------------------------- */ +/* Module scratch — I3 */ +/* ---------------------------------------------------------------- */ + +const _dirBase = new Vector3(); +const _dirWant = new Vector3(); +const _dirOut = new Vector3(); +const _colBase = new Color(); +const _colOut = new Color(); + +/** + * Direction a light *travels*, from azimuth and elevation. + * + * Character-for-character `Environment#_computeLightDirection`. See the header: + * the alternative is reading the direction back off `environment.sun.position`, + * which is the live value, which is what makes a blend compound. + */ +function lightDirection(out, azimuth, elevation) { + const cosE = Math.cos(elevation); + out.set(-Math.cos(azimuth) * cosE, -Math.sin(elevation), -Math.sin(azimuth) * cosE); + return out.normalize(); +} + +/* ---------------------------------------------------------------- */ +/* The published uniform blocks */ +/* ---------------------------------------------------------------- */ +/* + * DISRUPT's and GRAVITY's boxes and GLSL live in `./hookFields.js` — a leaf + * that imports one class from three and nothing else. They were moved out of + * this file to break a module ring: this file imports `config/settings.js`, + * settings imports every ability block, six ability blocks import `vfx/Tube.js` + * for `tubeDefaults()`, and `Tube` opts into the disruption field. See that + * file's header for the failure it caused and why MATERIAL AGE stayed here. + * + * They are re-exported below under their original names, by identity, so every + * `import { disruptGLSL, disruptUniforms } from '.../SceneHooks.js'` in the + * project keeps working and keeps sharing the same boxes. + */ + +/** + * MATERIAL AGE's block. Unlike the two above this one is *not* shared widely — + * there is one floor, and it is the only surface in the sandbox with enough + * real material to age. Kept in the same shape so a second consumer (a wall, a + * prop kit) is a spread rather than a rewrite. + */ +const _age = { + /** xyz centre in metres, w radius in metres. w <= 0 means no field. */ + uAgeField: { value: new Vector4(0, 0, 0, 0) }, + /** x edge 0..1 of radius, y amount 0..1, z inner cut 0..1, w unused. */ + uAgeShape: { value: new Vector4(0.35, 0, 0, 0) }, + /** rust, dust, moss, pit — each 0..1, each independent. */ + uAgeMix: { value: new Vector4(0, 0, 0, 0) }, + /** Bleaching: desaturation and lift of whatever survived the other four. */ + uAgeBleach: { value: 0 }, + uAgeRustColor: { value: new Color('#7a3b1c') }, + uAgeDustColor: { value: new Color('#8a8375') }, + uAgeMossColor: { value: new Color('#3d5a20') }, + /** Metres — the feature size of the ageing patches. */ + uAgeGrain: { value: 1.4 } +}; + +/** The neutral for the age block. Written on release; see the header. */ +function neutraliseAge() { + _age.uAgeField.value.set(0, 0, 0, 0); + _age.uAgeShape.value.set(0.35, 0, 0, 0); + _age.uAgeMix.value.set(0, 0, 0, 0); + _age.uAgeBleach.value = 0; +} + +/* ---------------------------------------------------------------- */ +/* MATERIAL AGE — the floor patch */ +/* ---------------------------------------------------------------- */ + +/** Declarations added to both stages of the patched standard material. */ +const AGE_COMMON = /* glsl */ ` +uniform vec4 uAgeField; +uniform vec4 uAgeShape; +uniform vec4 uAgeMix; +uniform float uAgeBleach; +uniform vec3 uAgeRustColor; +uniform vec3 uAgeDustColor; +uniform vec3 uAgeMossColor; +uniform float uAgeGrain; +varying vec3 vSceneAgeWorld; +`; + +/** + * The ageing field and the five things it does to a PBR surface. + * + * One 0..1 drives all five, and the five are *not* five tints. The reason a + * decal reads as a decal and this does not is that each term moves a different + * channel of the material: + * + * | term | albedo | roughness | metalness | + * | --- | --- | --- | --- | + * | rust | toward `uAgeRustColor`, patchy | up | **up** — rust is the only metal on this floor | + * | dust | toward `uAgeDustColor`, even | hard up | down | + * | moss | toward `uAgeMossColor`, in the low-frequency hollows | up | down | + * | pit | darkened specks | up | down | + * | bleach | desaturate and lift | — | — | + * + * Metalness is what sells rust. The first version graded albedo only, all five + * terms, and the aged ring read as a coloured decal painted on clean stone — + * because it *was* one. Rust that goes metallic catches the key light at a + * different angle from the stone beside it and the eye reads it as a different + * substance before it reads the colour at all. That single channel is the trick. + * + * The pitting is shaded, not displaced: the floor is one flat plane with four + * vertices (`world/Ground.js` keeps it flat because every raycast in the + * project assumes it), so there is nothing to displace. Darkened specks plus + * roughness is what a pit looks like from standing height anyway. + */ +const AGE_FIELD = /* glsl */ ` +float sceneAgeField(vec3 wp) { + float r = uAgeField.w; + if (r <= 0.0) return 0.0; + float d = length(wp.xz - uAgeField.xz); + float edge = clamp(uAgeShape.x, 0.001, 1.0); + float inner = clamp(uAgeShape.z, 0.0, 0.999) * r; + float rise = smoothstep(inner * (1.0 - edge), inner + 1e-3, d); + float fall = 1.0 - smoothstep(r * (1.0 - edge), r, d); + return rise * fall * clamp(uAgeShape.y, 0.0, 1.0); +} +`; + +const AGE_APPLY = /* glsl */ ` +{ + float age = sceneAgeField(vSceneAgeWorld); + if (age > 0.0005) { + vec3 wp = vSceneAgeWorld; + float grain = max(uAgeGrain, 0.05); + + // Three bands, deliberately incommensurate scales: patches of rust are + // metres across, moss hollows are broader still, pitting is centimetres. + // Sharing one band makes all five terms land in the same places and the + // whole thing collapses back into a single tint. + // + // NOT named 'patch'. It was, and the floor did not compile: three converts + // this material to GLSL ES 3.00, where 'patch' is the tessellation-shader + // qualifier and therefore reserved. The error is a syntax error on this + // line with no mention of the word being reserved anywhere the eye lands, + // and it takes the whole ground material down with it. The README's + // reserved-word list has it now, and so does the harness. + float mottle = fbm3(wp / grain) * 0.5 + 0.5; + float hollow = fbm3(wp / (grain * 2.7) + 41.0) * 0.5 + 0.5; + float speck = snoise01(wp / (grain * 0.16) + 7.0); + + float rust = clamp(uAgeMix.x, 0.0, 1.0) * age * smoothstep(0.42, 0.78, mottle); + float dust = clamp(uAgeMix.y, 0.0, 1.0) * age * (0.65 + 0.35 * speck); + float moss = clamp(uAgeMix.z, 0.0, 1.0) * age * smoothstep(0.62, 0.30, hollow); + float pit = clamp(uAgeMix.w, 0.0, 1.0) * age * smoothstep(0.74, 0.94, speck); + + // Pitting first: it is a hole in the stone, so everything that settles + // afterwards settles into it. + diffuseColor.rgb *= 1.0 - 0.62 * pit; + roughnessFactor = mix(roughnessFactor, 1.0, pit * 0.8); + + // Rust. The metalness lift is the term that makes this a material change. + diffuseColor.rgb = mix(diffuseColor.rgb, uAgeRustColor, rust * 0.85); + roughnessFactor = mix(roughnessFactor, 0.82, rust); + metalnessFactor = mix(metalnessFactor, 0.55, rust * 0.9); + + // Moss in the hollows: matte, and it kills metalness wherever it lands. + diffuseColor.rgb = mix(diffuseColor.rgb, uAgeMossColor, moss * 0.8); + roughnessFactor = mix(roughnessFactor, 0.97, moss); + metalnessFactor *= 1.0 - moss * 0.9; + + // Dust lies on top of all of it and flattens the lot. + diffuseColor.rgb = mix(diffuseColor.rgb, uAgeDustColor, dust * 0.55); + roughnessFactor = mix(roughnessFactor, 1.0, dust * 0.9); + metalnessFactor *= 1.0 - dust * 0.7; + + // Bleaching last, because sun-bleaching acts on whatever colour is there. + float bleach = clamp(uAgeBleach, 0.0, 1.0) * age; + float grey = dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722)); + diffuseColor.rgb = mix(diffuseColor.rgb, vec3(grey), bleach * 0.75); + diffuseColor.rgb += bleach * 0.10; + } +} +`; + +/** + * Patch a `MeshStandardMaterial` so it reads the ageing field. + * + * Called by `install()` on `ground.material`, and exported so a future prop kit + * can join. Composes through `patchOnBeforeCompile`, so `Ground`'s own tint and + * sheen patch is untouched and runs first — this one lands at + * ``, which is the one injection point in the physical + * shader where `diffuseColor`, `roughnessFactor` and `metalnessFactor` are all + * in scope and all still mutable. + * + * It declares its **own** world-position varying rather than reusing the + * `vGroundWorld` that `Ground` happens to provide. One duplicated matrix + * multiply per vertex, on a plane with four of them, buys a patch that works on + * any standard material and does not break the day somebody renames a varying + * in a file that has no idea this one exists. + */ +export function patchAgeMaterial(material) { + if (!material || material.userData?.sceneHookAged) return material; + + patchOnBeforeCompile(material, (shader) => { + Object.assign(shader.uniforms, _age); + + // `replaceChunk` rather than a bare `.replace`: a token that vanishes in a + // three.js upgrade would otherwise silently produce a floor that cannot be + // aged, with no error anywhere. It warns, once, naming the token. + let vs = replaceChunk( + shader.vertexShader, + '#include ', + `#include \nvarying vec3 vSceneAgeWorld;` + ); + vs = replaceChunk( + vs, + '#include ', + `#include \nvSceneAgeWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;` + ); + shader.vertexShader = vs; + + // `` is the injection point because it is the one + // place in the physical shader where diffuseColor, roughnessFactor and + // metalnessFactor are all in scope and all still mutable — and it sits + // after ``, which is where Ground's own sheen patch + // lands, so ageing composes on top of the floor's look rather than under it. + let fs = replaceChunk( + shader.fragmentShader, + '#include ', + `#include \n${AGE_COMMON}\n${noiseGLSL}\n${AGE_FIELD}` + ); + fs = replaceChunk( + fs, + '#include ', + `#include \n${AGE_APPLY}` + ); + shader.fragmentShader = fs; + }); + + // I8 — the harness's pause probe reads uniforms off here, and so does anyone + // debugging why the floor will not rust. + material.userData = material.userData ?? {}; + material.userData.uniforms = Object.assign(material.userData.uniforms ?? {}, _age); + material.userData.sceneHookAged = true; + material.needsUpdate = true; + return material; +} + +/* ---------------------------------------------------------------- */ +/* The tokens */ +/* ---------------------------------------------------------------- */ + +/** + * The borrow receipt. + * + * One token per (hook, owner), kept in a `WeakMap` on the ledger and reused + * across that owner's casts. The reuse is deliberate — abilities are pooled and + * an ability that takes the grade on every cast must not allocate a receipt on + * every cast (I3) — but the token is **never** handed to a different owner, and + * that is the load-bearing half. + * + * The version this replaced kept a per-hook free list and relied on `serial` to + * catch the recycle: ability A releases, ability B is handed A's token, A's + * late `onDestroy()` calls `release()` and is supposed to be rejected. It never + * was. `release()` reads the serial off the token it is called on, so after the + * recycle the stale reference and the live holder are the same object and the + * comparison is a number against itself — A silently evicted B, which is worse + * than the leak the check was there to prevent, because it is intermittent. + * + * Keyed by owner, the situation cannot arise: A's token is A's, so a late + * release from A can only find A's own borrow, already inert. `active` closes + * the double release; `serial` remains as a cheap second gate for callers that + * hold one, and the harness asserts both. + */ +class HookToken { + constructor(hook) { + /** @type {string} which hook this is a receipt for. */ + this.hook = hook; + /** Bumped on every acquisition. A release must present the current one. */ + this.serial = 0; + /** The ability (or anything) that borrowed it. Used by `reclaim()`. */ + this.owner = null; + /** True between acquire and release. */ + this.active = false; + /** True when this token is the one at the top of its hook's stack. */ + this.driving = false; + /** Frame stamp of the last write. The lease. */ + this.seen = 0; + /** The ledger, so `release()` can be called on the token alone. */ + this.hooks = null; + this.reset(); + } + + /** Every token type overrides this to clear its own stored values. */ + reset() { + this.weight = 1; + } + + /** + * The blend against whatever the world would have been without this hook, + * 0..1. On every hook, weight 0 is *transparent* — not "off but still + * holding": the world takes exactly the value the sliders say. That + * uniformity is what lets an ability fade a hook in and out on its own + * envelope without a special case per hook. + */ + blend(weight) { + this.weight = weight; + return this.hold(); + } + + /** + * Renew the lease without changing anything. + * + * A holder that is mid-beat and has nothing to say this frame still has to + * say *that*, or the sweep in `apply()` takes the hook back. Every setter + * calls this, so a holder writing normally never needs it. + */ + hold() { + if (this.active && this.hooks) this.seen = this.hooks._frame; + return this; + } + + /** Give the hook back. Safe to call twice, and safe on a recycled token. */ + release() { + this.hooks?._release(this, this.serial); + } +} + +/** + * KEY LIGHT — the sun. + * + * `dawnbreak` swings it from the horizon to overhead and back, and every object + * in the world throws a real sweeping shadow because it is the same light that + * was always casting them. `sheetlightning` leaves the direction alone and + * strobes intensity and colour, so all the real shadows in the world snap at + * once — one number, and the whole stage flinches. + * + * `weight` is the blend against `settings.environment`, so an ability can take + * the sun 30% of the way somewhere and the editor's own sliders still read + * through. It is not an opacity: at weight 0 the hook is transparent and the + * environment's value is the exact value on the light. + */ +class KeyLightToken extends HookToken { + reset() { + this.weight = 1; + this.azimuth = 0; // radians — the holder resolves it every frame + this.elevation = 0; // radians + this.aimed = false; // false: leave direction alone, tint only + // Reused, not rebuilt: `reset()` runs on every acquisition, and the other + // four tokens carrying a Color already do it this way. + this.color = this.color ?? new Color(); + this.color.setRGB(1, 1, 1); + this.tinted = false; + this.intensity = 1; + this.lit = false; // false: leave intensity alone + } + + /** Swing the sun. Both angles in radians, same frame as `sunAzimuth`. */ + aim(azimuth, elevation) { + this.azimuth = azimuth; + this.elevation = elevation; + this.aimed = true; + return this.hold(); + } + + /** Recolour it. Accepts a `#rrggbb` string or a `THREE.Color`. */ + tint(color) { + if (typeof color === 'string') this.color.copy(getColor(color)); + else if (color) this.color.copy(color); + this.tinted = true; + return this.hold(); + } + + /** Absolute intensity, in the same units as `environment.sunIntensity`. */ + brightness(intensity) { + this.intensity = intensity; + this.lit = true; + return this.hold(); + } + +} + +/** + * GRADE — the look pass. + * + * `eclipse` drains colour toward the umbra before the disc ever appears, which + * is the Thunderclap lesson applied to light: the anticipation carries it. + * + * Four parameters only, and they are the four that read as *the world going + * wrong* rather than as a filter: saturation, temperature, lift and vignette. + * Contrast and gain are deliberately not here — they read as a camera setting + * being changed, which is a different sentence. + */ +class GradeToken extends HookToken { + reset() { + this.weight = 1; + this.saturation = 1; + this.hasSaturation = false; + this.temperature = 0; + this.hasTemperature = false; + this.lift = 0; + this.hasLift = false; + this.vignette = 0; + this.hasVignette = false; + } + + /** 0 is monochrome, 1 is untouched. */ + saturate(value) { + this.saturation = value; + this.hasSaturation = true; + return this.hold(); + } + + /** + warm, - cool, matching `post.temperature`. */ + temper(value) { + this.temperature = value; + this.hasTemperature = true; + return this.hold(); + } + + /** Black level. Negative crushes toward the umbra. */ + raise(value) { + this.lift = value; + this.hasLift = true; + return this.hold(); + } + + /** How much darker the corners are, 0..1. */ + darken(value) { + this.vignette = value; + this.hasVignette = true; + return this.hold(); + } +} + +/** + * MATERIAL AGE — the floor's own shader. + * + * `entropy` sweeps the field outward and back. The field is a disc with an + * optional inner cut, so the same token draws a filling circle (`ring(0)`) or a + * travelling annulus (`ring(0.8)`) — which is what "sweeps outward and then + * retreats" actually wants, because a wave of decay has a trailing edge. + */ +class AgeToken extends HookToken { + reset() { + this.weight = 1; + this.centre = this.centre ?? new Vector3(); + this.centre.set(0, 0, 0); + this.radius = 0; + this.edge = 0.35; + this.amount = 0; + this.inner = 0; + this.rust = 0; + this.dust = 0; + this.moss = 0; + this.pit = 0; + this.bleach = 0; + this.grain = 1.4; + this.rustColor = this.rustColor ?? new Color(); + this.dustColor = this.dustColor ?? new Color(); + this.mossColor = this.mossColor ?? new Color(); + this.rustColor.set('#7a3b1c'); + this.dustColor.set('#8a8375'); + this.mossColor.set('#3d5a20'); + } + + /** Centre of the field, in metres. */ + at(x, y, z) { + this.centre.set(x, y, z); + return this.hold(); + } + + /** Vector3 form of `at()`. */ + atPoint(point) { + this.centre.copy(point); + return this.hold(); + } + + /** + * The field: outer radius in metres, edge softness as a fraction of it, + * strength 0..1, and the inner cut that turns the disc into an annulus. + */ + field(radius, edge, amount, inner = 0) { + this.radius = radius; + this.edge = edge; + this.amount = amount; + this.inner = inner; + return this.hold(); + } + + /** The five ageing terms, each 0..1 and each independent of the others. */ + wear(rust, dust, moss, pit, bleach) { + this.rust = rust; + this.dust = dust; + this.moss = moss; + this.pit = pit; + this.bleach = bleach; + return this.hold(); + } + + /** Metres — the feature size of the patches. */ + scale(metres) { + this.grain = metres; + return this.hold(); + } + + /** Three pickers, because I5 says so. `#rrggbb` or `THREE.Color`. */ + colours(rust, dust, moss) { + if (rust) this.rustColor.copy(typeof rust === 'string' ? getColor(rust) : rust); + if (dust) this.dustColor.copy(typeof dust === 'string' ? getColor(dust) : dust); + if (moss) this.mossColor.copy(typeof moss === 'string' ? getColor(moss) : moss); + return this.hold(); + } +} + +/** + * HOLE — a region where nothing renders. + * + * `silence`. See `_applyHole` for what it actually is and what was tried first. + */ +class HoleToken extends HookToken { + reset() { + this.weight = 1; + this.centre = this.centre ?? new Vector3(); + this.centre.set(0, 0, 0); + this.radius = 0; + this.squash = 1; + } + + at(x, y, z) { + this.centre.set(x, y, z); + return this.hold(); + } + + atPoint(point) { + this.centre.copy(point); + return this.hold(); + } + + /** Radius in metres. <= 0 hides the hole without releasing it. */ + size(radius, squash = 1) { + this.radius = radius; + this.squash = squash; + return this.hold(); + } +} + +/** + * GRAVITY — a signed multiplier, published, read by whoever wants it. + * + * `hourglass` flips it. The sand falls into the cone, the zone inverts, and the + * same field with the same grains falls up. + */ +class GravityToken extends HookToken { + reset() { + this.weight = 1; + this.centre = this.centre ?? new Vector3(); + this.centre.set(0, 0, 0); + this.radius = 0; + this.edge = 0.25; + this.inside = 1; + this.outside = 1; + } + + at(x, y, z) { + this.centre.set(x, y, z); + return this.hold(); + } + + atPoint(point) { + this.centre.copy(point); + return this.hold(); + } + + /** The volume: radius in metres, edge softness as a fraction of it. */ + well(radius, edge = 0.25) { + this.radius = radius; + this.edge = edge; + return this.hold(); + } + + /** The multipliers. `inside` is signed: -1 is full inversion. */ + scale(inside, outside = 1) { + this.inside = inside; + this.outside = outside; + return this.hold(); + } +} + +/** + * DISRUPT — the published region other abilities read. + * + * `spellbreak` is the one ability aware of the rest. Cast into an empty room it + * is arcane glass shattering; cast into a standing Nova Beam it desaturates and + * fragments what is already there. Three shared materials opt in — + * `FilamentPaths`, `GroundField` and `Swarm` — and between them they are most + * of what is ever left standing in a zone: every bolt, snare, chain and crack; + * every ground mark in all ten modes; every flock. The opt-in is one spread and + * three lines of GLSL, documented in the README, and it is opt-**in** on + * purpose: an ability whose trick is that it cannot be broken simply does not + * add the lines. + */ +class DisruptToken extends HookToken { + reset() { + this.weight = 1; + this.centre = this.centre ?? new Vector3(); + this.centre.set(0, 0, 0); + this.radius = 0; + this.edge = 0.35; + this.drain = 0; + this.fracture = 0; + this.dim = 0; + this.grain = 6; + } + + at(x, y, z) { + this.centre.set(x, y, z); + return this.hold(); + } + + atPoint(point) { + this.centre.copy(point); + return this.hold(); + } + + /** The region: radius in metres, edge softness as a fraction of it. */ + region(radius, edge = 0.35) { + this.radius = radius; + this.edge = edge; + return this.hold(); + } + + /** What it does: desaturation, dither erosion, and dimming — each 0..1. */ + power(drain, fracture, dim) { + this.drain = drain; + this.fracture = fracture; + this.dim = dim; + return this.hold(); + } + + /** Device pixels per fracture cell. Bigger cells, bigger shards. */ + shardSize(pixels) { + this.grain = pixels; + return this.hold(); + } +} + +const TOKEN_TYPES = { + [Hook.KEY_LIGHT]: KeyLightToken, + [Hook.GRADE]: GradeToken, + [Hook.AGE]: AgeToken, + [Hook.HOLE]: HoleToken, + [Hook.GRAVITY]: GravityToken, + [Hook.DISRUPT]: DisruptToken +}; + +/* ---------------------------------------------------------------- */ +/* The ledger */ +/* ---------------------------------------------------------------- */ + +/** + * The borrow/restore ledger, and the one place the world is written. + * + * There is one of these in the app (`sceneHooks`, below). It is a class rather + * than a module of functions for one reason: the harness needs to build its own + * against a mock world and drive it without touching the app's. + */ +export class SceneHooks { + constructor() { + /** Stacks of live tokens, one per hook. The last entry drives. */ + this._stacks = new Map(); + /** + * The token store: one `WeakMap` per hook, from **owner** to that owner's + * one token for that hook. See `acquire()` for why it is keyed by owner + * rather than being a free list. + */ + this._owned = new Map(); + for (const id of HOOK_IDS) { + this._stacks.set(id, []); + this._owned.set(id, new WeakMap()); + } + + /** How many hooks have at least one holder. The whole idle cost is this. */ + this._held = 0; + /** Frames since construction. The lease clock; advances in `apply()`. */ + this._frame = 0; + /** Owners already warned about, so a leak is one line and not a flood. */ + this._warned = new Set(); + + /** The world, filled in by `install()`. Every field is optional. */ + this._scene = null; + this._environment = null; + this._grade = null; + this._renderer = null; + + /** The HOLE's depth proxy. Built on the first acquisition, never disposed. */ + this._holeMesh = null; + /** True while the age patch has been applied to somebody's material. */ + this._aged = false; + + /** Counters for the readout and for the harness. */ + this.stats = { acquired: 0, released: 0, reclaimed: 0, applies: 0 }; + } + + /* ---------------------------------------------------------------- */ + /* Installation */ + /* ---------------------------------------------------------------- */ + + /** + * Point the module at the world. Called once, from `App`, before the first + * frame. Every field is optional and a missing one disables exactly one hook + * — `acquire()` still succeeds and `apply()` still runs, so an ability never + * has to know whether it is running in the app or in the harness. + * + * @param {object} world + * @param {THREE.Scene} [world.scene] for the HOLE proxy + * @param {object} [world.environment] `world/Environment.js` + * @param {object} [world.ground] `world/Ground.js`, or any `{material}` + * @param {object} [world.grade] the `GradeShader` uniform block + * @param {object} [world.renderer] `core/Renderer.js`, for shadow refresh + */ + install(world = {}) { + this._scene = world.scene ?? null; + this._environment = world.environment ?? null; + this._grade = world.grade ?? null; + this._renderer = world.renderer ?? null; + + const material = world.ground?.material ?? world.groundMaterial ?? null; + if (material) { + patchAgeMaterial(material); + this._aged = true; + } + return this; + } + + /** + * Drop every hook and forget the world. `App.dispose()` calls it. + * + * It tears the hole proxy down as well, which matters more than it looks: + * the proxy is parented to the scene it was built in, and a module that kept + * it across an uninstall would hand the next `install()` a mesh living in the + * previous app's scene graph — invisible, undisposed, and impossible to find. + */ + uninstall() { + return this.dispose(); + } + + /** + * Park the module's live state on a material's `userData.uniforms`. + * + * The harness's pause probe (`docs/EXPANSION.md` §7 step 6) snapshots the + * uniforms an *ability* owns. An ability whose entire output is a scene hook + * owns none, so it reads as a dead slider bank even though it is driving the + * sun. Calling this once at construction, on any material the ability already + * has, makes the hook's own numbers visible to the probe — and to anyone + * inspecting the material in a debugger. + * + * Nothing reads these uniforms in the shader. They are there to be seen. + */ + observe(material) { + if (!material) return material; + material.userData = material.userData ?? {}; + material.userData.uniforms = Object.assign( + material.userData.uniforms ?? {}, + _disrupt, + _gravity, + _age + ); + return material; + } + + /* ---------------------------------------------------------------- */ + /* Borrow and return */ + /* ---------------------------------------------------------------- */ + + /** + * Borrow a hook. + * + * Never returns null for a real hook — see the header. The returned token + * drives the world from the next `apply()` until it is released or its lease + * lapses. + * + * **An owner holds at most one token per hook.** Acquiring a hook you are + * already holding renews your lease and hands you back the same token; it + * does not stack a second borrow on top of your own, which would be a + * self-deadlock nobody would diagnose. + * + * ## Why tokens are keyed by owner and not pooled in a free list + * + * The first version kept a per-hook free list and stamped each token with a + * serial, on the theory that a stale reference could be caught by comparing + * serials. It cannot, and the harness now proves it: `release()` reads the + * serial *off the token it is called on*, so once the token has been recycled + * the stale reference and the live holder are byte-identical and the check + * compares a number with itself. A released token handed straight to the next + * acquirer meant that ability A's late `onDestroy()` silently evicted ability + * B — the exact failure the serial was written to prevent. + * + * Keying the store by owner makes the hazard unrepresentable rather than + * detectable: A's token is A's for as long as A exists, so B is never handed + * it and A's late release can only release A's own already-inert borrow. It + * is still allocation-free after the first cast — abilities are pooled, so + * the same handful of owners come back — and a `WeakMap` means a genuinely + * discarded owner takes its tokens with it. + * + * An **anonymous** acquisition (`owner` omitted) has no key, so it gets a + * fresh token. That is one small object for a caller that has also given up + * `reclaim()`; abilities pass `this`. + * + * @param {string} hook a `Hook` value + * @param {object} owner whatever is borrowing; `reclaim(owner)` takes it back + */ + acquire(hook, owner = null) { + const stack = this._stacks.get(hook); + if (!stack) { + console.warn(`[SceneHooks] no such hook: ${String(hook)}`); + return null; + } + + const store = this._owned.get(hook); + const mine = owner ? store.get(owner) : null; + // Already holding it: renew, do not stack on yourself. + if (mine && mine.active) return mine.hold(); + + const token = mine ?? new TOKEN_TYPES[hook](hook); + if (owner && !mine) store.set(owner, token); + token.reset(); + token.hooks = this; + token.owner = owner; + token.active = true; + token.serial++; + token.seen = this._frame; + + if (stack.length === 0) this._held++; + for (const other of stack) other.driving = false; + stack.push(token); + token.driving = true; + + this.stats.acquired++; + return token; + } + + /** Is anybody holding this hook? */ + isHeld(hook) { + return (this._stacks.get(hook)?.length ?? 0) > 0; + } + + /** The token currently driving this hook, or null. */ + driver(hook) { + const stack = this._stacks.get(hook); + return stack && stack.length ? stack[stack.length - 1] : null; + } + + /** How many hooks have at least one holder. Zero means `apply()` is a compare. */ + get heldCount() { + return this._held; + } + + /** + * Give every hook this owner holds back. The line an ability's `onDestroy()` + * wants, because it does not have to remember which of the six it took. + */ + reclaim(owner) { + let n = 0; + for (const id of HOOK_IDS) { + const stack = this._stacks.get(id); + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i].owner === owner) { + this._release(stack[i], stack[i].serial); + n++; + } + } + } + return n; + } + + /** Drop everything and restore the world. For teardown and for the editor. */ + releaseAll() { + for (const id of HOOK_IDS) { + const stack = this._stacks.get(id); + for (let i = stack.length - 1; i >= 0; i--) this._release(stack[i], stack[i].serial); + } + return this; + } + + /** + * The one release path. Everything else funnels through here. + * + * `active` is what makes a double release, a release from a destroyed + * ability, and a release racing the lease sweep all the same harmless no-op. + * The serial rides along as a second gate for a caller that kept one; it can + * no longer be *needed*, because a token is never handed to a second owner — + * see `acquire()` for the version of this that did not work. + */ + _release(token, serial) { + if (!token || !token.active || token.serial !== serial) return false; + + const stack = this._stacks.get(token.hook); + const at = stack.indexOf(token); + if (at >= 0) stack.splice(at, 1); + + token.active = false; + token.driving = false; + token.owner = null; + token.hooks = null; + + if (stack.length === 0) { + this._held = Math.max(0, this._held - 1); + this._restore(token.hook); + } else { + // LIFO: whoever was under the released token takes the world back, now, + // not on some later re-acquisition. + stack[stack.length - 1].driving = true; + } + + this.stats.released++; + return true; + } + + /** + * Put one hook's slice of the world back exactly. + * + * KEY_LIGHT and GRADE are absent from this switch on purpose: their owners + * re-author them from settings every frame before `apply()` runs, so there is + * nothing to restore — see the header. The other four mutate persistent state + * and each has a documented neutral. + */ + _restore(hook) { + switch (hook) { + case Hook.AGE: + neutraliseAge(); + break; + case Hook.HOLE: + if (this._holeMesh) this._holeMesh.visible = false; + break; + case Hook.GRAVITY: + neutraliseGravity(); + break; + case Hook.DISRUPT: + neutraliseDisrupt(); + break; + default: + break; + } + } + + /* ---------------------------------------------------------------- */ + /* The one write */ + /* ---------------------------------------------------------------- */ + + /** + * Write every held hook into the world. Called once per frame by `App`, + * between `post.sync()` and `post.render()`. + * + * That position is not arbitrary and it is the only one that works for all + * six: `Environment.update()` has already re-authored the key light from + * settings, `PostProcessing.sync()` has already re-authored the grade, and + * nothing has rendered yet — including the shadow map, which three refreshes + * inside the first `gl.render()` of `post.render()`. Move this call above + * `post.sync()` and the grade hook silently stops working. + * + * It is **idempotent**. Everything blends from `settings`, never from the + * live value, so calling it twice in a frame is a waste and not a bug — which + * is what lets an ability call it from its own `update()` to make the pause + * probe see a hook-only effect (see `observe()`). + */ + apply() { + if (this._held === 0) return; + + this._frame++; + this.stats.applies++; + this._sweep(); + + if (this._held === 0) return; // the sweep may have taken the last one back + + if (this.isHeld(Hook.KEY_LIGHT)) this._applyKeyLight(this.driver(Hook.KEY_LIGHT)); + if (this.isHeld(Hook.GRADE)) this._applyGrade(this.driver(Hook.GRADE)); + if (this.isHeld(Hook.AGE)) this._applyAge(this.driver(Hook.AGE)); + if (this.isHeld(Hook.HOLE)) this._applyHole(this.driver(Hook.HOLE)); + if (this.isHeld(Hook.GRAVITY)) this._applyGravity(this.driver(Hook.GRAVITY)); + if (this.isHeld(Hook.DISRUPT)) this._applyDisrupt(this.driver(Hook.DISRUPT)); + } + + /** + * Take back anything whose lease has lapsed. + * + * This is the safety net described in the header, and it is the reason a + * mid-effect destroy cannot strand the world. It walks at most six short + * arrays and only when something is held. + */ + _sweep() { + for (const id of HOOK_IDS) { + const stack = this._stacks.get(id); + for (let i = stack.length - 1; i >= 0; i--) { + const token = stack[i]; + if (this._frame - token.seen <= LEASE_FRAMES) continue; + const name = token.owner?.id ?? token.owner?.constructor?.name ?? 'an anonymous owner'; + const key = `${id}:${name}`; + if (!this._warned.has(key)) { + this._warned.add(key); + console.warn( + `[SceneHooks] reclaimed '${id}' from ${name}: no write for ${LEASE_FRAMES} frames. ` + + `Call token.hold() on a frame with nothing to say, or token.release() when done.` + ); + } + this._release(token, token.serial); + this.stats.reclaimed++; + } + } + } + + /** + * KEY LIGHT. + * + * The blend is from `settings.environment`, and the four lines of + * azimuth/elevation trigonometry are a deliberate copy of + * `Environment#_computeLightDirection` — the header explains why reading the + * live direction back off the light instead would compound. + * + * Three things move together and all three matter: + * + * - `sun.position` and `sunTarget.position`, which is what three's shadow + * camera is built from, so the *real* shadows swing; + * - `frame.uLightDir`, which every custom material that fakes its own normal + * reads — ground snow, rubble, the filament strip. Miss this one and the + * lit meshes swing while the effects stay lit from the old sun, which + * reads as the effects being stickers. This was the bug that took longest + * to see, because each half looks correct on its own. + * - `shadowMap.needsUpdate`, defensively. `App.frame` already sets it true + * unconditionally every frame and three consumes it inside the first + * `gl.render()`, so the 4096² map is re-rendered every frame whether the + * sun moves or not: 1 draw call and 9,578 triangles, measured by toggling + * `sun.castShadow`, and the frame is 31 draw calls with the sun still and + * 31 with it sweeping. A swinging sun therefore costs **nothing** today. + * This write is here so that the day somebody makes shadow refresh + * conditional — the obvious win on a stage this static — a swinging sun + * does not silently keep last frame's shadows. + */ + _applyKeyLight(token) { + const env = this._environment; + if (!env || !token) return; + const e = settings.environment; + const w = saturate(token.weight); + + lightDirection(_dirBase, e.sunAzimuth, e.sunElevation); + if (token.aimed) { + lightDirection(_dirWant, token.azimuth, token.elevation); + // Normalised lerp rather than a slerp: over the arc a sun sweep actually + // covers, the two are within a degree of each other, and this one cannot + // produce a NaN when the endpoints are antipodal. + _dirOut.copy(_dirBase).lerp(_dirWant, w); + if (_dirOut.lengthSq() < 1e-8) _dirOut.copy(_dirWant); + _dirOut.normalize(); + } else { + _dirOut.copy(_dirBase); + } + + env.sunTarget.position.copy(env.focus); + env.sun.position.copy(env.focus).addScaledVector(_dirOut, -SUN_DISTANCE); + frame.uLightDir.value.copy(_dirOut).negate(); + + if (token.tinted) { + _colBase.copy(getColor(e.sunColor)); + _colOut.copy(_colBase).lerp(token.color, w); + env.sun.color.copy(_colOut); + } + if (token.lit) env.sun.intensity = lerp(e.sunIntensity, token.intensity, w); + + if (this._renderer?.gl) this._renderer.gl.shadowMap.needsUpdate = true; + } + + /** + * GRADE. Blends from `settings.post`, honouring `post.enabled` exactly the + * way `PostProcessing.sync()` does — an ability must not be able to switch + * the grade back on for someone who turned the post stack off. + */ + _applyGrade(token) { + const u = this._grade; + if (!u || !token) return; + const p = settings.post; + const on = p.enabled !== false; + const w = saturate(token.weight); + + if (token.hasSaturation && u.uSaturation) { + u.uSaturation.value = lerp(on ? p.saturation : 1, token.saturation, w); + } + if (token.hasTemperature && u.uTemperature) { + u.uTemperature.value = lerp(on ? p.temperature : 0, token.temperature, w); + } + if (token.hasLift && u.uLift) { + u.uLift.value = lerp(p.lift, token.lift, w); + } + if (token.hasVignette && u.uVignette) { + u.uVignette.value = lerp(on ? p.vignette : 0, token.vignette, w); + } + } + + /** MATERIAL AGE. Straight into the shared block the floor patch reads. */ + _applyAge(token) { + if (!token) return; + const w = saturate(token.weight); + _age.uAgeField.value.set(token.centre.x, token.centre.y, token.centre.z, Math.max(0, token.radius)); + _age.uAgeShape.value.set( + clamp(token.edge, 0.001, 1), + saturate(token.amount) * w, + clamp(token.inner, 0, 0.999), + 0 + ); + _age.uAgeMix.value.set( + saturate(token.rust), + saturate(token.dust), + saturate(token.moss), + saturate(token.pit) + ); + _age.uAgeBleach.value = saturate(token.bleach); + _age.uAgeGrain.value = Math.max(0.05, token.grain); + _age.uAgeRustColor.value.copy(token.rustColor); + _age.uAgeDustColor.value.copy(token.dustColor); + _age.uAgeMossColor.value.copy(token.mossColor); + } + + /** + * HOLE — a region where nothing renders. + * + * ## What it is + * + * One invisible sphere, drawn **first** among the opaques, that writes depth + * and no colour. Everything the renderer draws afterwards that is further + * away than its front surface fails the depth test and is never shaded. What + * survives in those pixels is the clear colour, which is + * `environment.backgroundColor` — the same flat void the floor already fades + * into at the edges of the stage. Not a black disc *in front of* the world: a + * pixel the world never reached. + * + * Occlusion comes out right for free, which is the part that would have been + * fiddly any other way. The character standing between the camera and the + * hole is nearer than the proxy, passes the test, and is drawn. Walk behind + * the hole and the character is gone. A shard flying *through* the volume is + * gone while it is inside and back the instant it clears the front surface. + * Nothing in the project had to be told the hole exists. + * + * It is on `LAYER.WORLD`, so it is also in the depth prepass, so every soft + * particle in the sandbox fades as it crosses into the hole instead of + * clipping at it. That was luck, but it is good luck and it is worth keeping. + * + * ## What was tried first + * + * **A stencil written by a proxy mesh** — the obvious answer, and the brief's + * first suggestion. It does not fit this pipeline without paying for it three + * times over. `core/Renderer.js` constructs the `WebGLRenderer` with + * `stencil: false`, so the drawing buffer has no stencil attachment; + * `EffectComposer`'s ping-pong targets have none either, and every pass would + * need one added and cleared. That is a stencil buffer on the frame plus two + * on the composer plus a clear per pass, for a mask the depth buffer — which + * already exists, is already cleared, and is already being tested against by + * every draw in the frame — hands over for nothing. Depth is not a + * workaround here; it is the correct tool that the stencil is a fallback for + * when you need a mask that ignores geometry, and this mask does not. + * + * **A mask honoured by the final pass** — analytic sphere in `GradeShader`, + * projected to screen space, occlusion-tested against `frame.uSceneDepth`. + * Real advantage: it runs after `UnrealBloomPass`, so it also punches through + * the bloom, which the depth proxy does not. Two problems killed it. It + * cannot occlude against anything that is not in the depth prepass — the + * prepass is `LAYER.WORLD` only — so a bolt drawn in front of the hole gets + * erased along with it, which is exactly wrong. And it is a permanent edit to + * the grade shader that every frame in the app pays for. + * + * ## What it costs, and the one honest caveat + * + * **Two** draw calls while held, and nothing at all when it is not: a 32×16 + * sphere with colour writes off, drawn once into the depth prepass and once + * into the main pass. Measured, not assumed — the frame goes from 31 draw + * calls to 33. The caveat is bloom: because the hole is + * punched in the scene pass, a bright effect beside it still bleeds across + * the rim by the bloom radius. On this stage `post.bloomStrength` ships at + * 0.03 and it is invisible; cast `silence` next to something with the bloom + * cranked and there is a faint halo lying over the void. Fixing it properly + * means the grade-pass mask above, and it is not worth what it breaks. + */ + _applyHole(token) { + if (!token) return; + const mesh = this._ensureHole(); + if (!mesh) return; + const r = Math.max(0, token.radius) * saturate(token.weight); + mesh.visible = r > 1e-4; + if (!mesh.visible) return; + mesh.position.copy(token.centre); + mesh.scale.set(r, r * Math.max(0.01, token.squash), r); + mesh.updateMatrix(); + } + + /** Built on first use so an app that never casts `silence` never allocates it. */ + _ensureHole() { + if (this._holeMesh) return this._holeMesh; + if (!this._scene) return null; + + const geometry = new SphereGeometry(1, 32, 16); + const material = new MeshBasicMaterial({ + colorWrite: false, // the whole trick: depth only + depthWrite: true, + depthTest: true, + fog: false + }); + const mesh = new Mesh(geometry, material); + mesh.name = 'SceneHooks.Hole'; + mesh.layers.set(LAYER.WORLD); // so it is in the depth prepass too + // Ahead of every other opaque. Three sorts opaques by renderOrder first, + // and a depth-only proxy that draws after the floor has already shaded the + // floor: the whole point is to be in the depth buffer before anyone tests. + mesh.renderOrder = -1000; + mesh.castShadow = false; // a hole does not shadow; it is not there + mesh.receiveShadow = false; + mesh.matrixAutoUpdate = false; + mesh.visible = false; + + this._scene.add(mesh); + this._holeMesh = mesh; + return mesh; + } + + /** GRAVITY. Publishes; reads are `gravityAt()` on the CPU, `gravityScaleAt()` in GLSL. */ + _applyGravity(token) { + if (!token) return; + const w = saturate(token.weight); + _gravity.uGravityWell.value.set( + token.centre.x, + token.centre.y, + token.centre.z, + Math.max(0, token.radius) + ); + _gravity.uGravityMix.value.set( + lerp(token.outside, token.inside, w), + token.outside, + clamp(token.edge, 0.001, 1), + 0 + ); + } + + /** DISRUPT. Publishes into the block the opted-in materials already hold. */ + _applyDisrupt(token) { + if (!token) return; + const w = saturate(token.weight); + _disrupt.uDisruptRegion.value.set( + token.centre.x, + token.centre.y, + token.centre.z, + Math.max(0, token.radius) + ); + _disrupt.uDisruptPower.value.set( + saturate(token.drain) * w, + saturate(token.fracture) * w, + saturate(token.dim) * w, + clamp(token.edge, 0.001, 1) + ); + _disrupt.uDisruptGrain.value = Math.max(1, token.grain); + } + + /* ---------------------------------------------------------------- */ + /* The read side */ + /* ---------------------------------------------------------------- */ + + /** + * The gravity multiplier at a world point, on the CPU. + * + * Mirrors `gravityScaleAt()` in `gravityGLSL` exactly, including the + * smoothstep, so a CPU-integrated emitter and a GPU-integrated one agree at + * the boundary. Returns exactly 1 when nothing is held, so a caller can + * multiply unconditionally. + */ + gravityAt(x, y, z) { + const well = _gravity.uGravityWell.value; + if (well.w <= 0) return 1; + const mix = _gravity.uGravityMix.value; + const edge = clamp(mix.z, 0.001, 1); + const d = Math.hypot(x - well.x, y - well.y, z - well.z); + const k = 1 - smooth01(well.w * (1 - edge), well.w, d); + return mix.y + (mix.x - mix.y) * k; + } + + /** Disruption strength at a world point, 0..1, on the CPU. Mirrors `disruptAt()`. */ + disruptAt(x, y, z) { + const region = _disrupt.uDisruptRegion.value; + if (region.w <= 0) return 0; + const edge = clamp(_disrupt.uDisruptPower.value.w, 0.001, 1); + const d = Math.hypot(x - region.x, y - region.y, z - region.z); + return 1 - smooth01(region.w * (1 - edge), region.w, d); + } + + /** The ageing amount at a world point, 0..1. Mirrors `sceneAgeField()`. */ + ageAt(x, z) { + const f = _age.uAgeField.value; + if (f.w <= 0) return 0; + const shape = _age.uAgeShape.value; + const d = Math.hypot(x - f.x, z - f.z); + const edge = clamp(shape.x, 0.001, 1); + const inner = clamp(shape.z, 0, 0.999) * f.w; + const rise = smooth01(inner * (1 - edge), inner + 1e-3, d); + const fall = 1 - smooth01(f.w * (1 - edge), f.w, d); + return rise * fall * saturate(shape.y); + } + + /** One line per hook, for a debug overlay or a console poke. */ + describe() { + const parts = []; + for (const id of HOOK_IDS) { + const stack = this._stacks.get(id); + if (!stack.length) continue; + const names = stack.map((t) => t.owner?.id ?? t.owner?.constructor?.name ?? '?'); + parts.push(`${id}[${names.join(' < ')}]`); + } + return parts.length ? parts.join(' ') : 'nothing held'; + } + + /** + * Drop the hole proxy. Only for teardown — `dispose()` on a module that the + * whole app shares is a footgun, so this one is explicit and rarely wanted. + */ + dispose() { + this.releaseAll(); + if (this._holeMesh) { + this._holeMesh.parent?.remove(this._holeMesh); + this._holeMesh.geometry.dispose(); + this._holeMesh.material.dispose(); + this._holeMesh = null; + } + this._scene = null; + this._environment = null; + this._grade = null; + this._renderer = null; + return this; + } +} + +/** GLSL smoothstep, so the CPU mirrors agree with the shaders to the bit. */ +function smooth01(a, b, x) { + if (b - a <= 0) return x >= b ? 1 : 0; + const t = saturate((x - a) / (b - a)); + return t * t * (3 - 2 * t); +} + +/** + * The app's ledger. There is one world, so there is one of these. + * + * ```js + * import { sceneHooks, Hook } from '../../vfx/SceneHooks.js'; + * + * onSpawn() { this._sun = sceneHooks.acquire(Hook.KEY_LIGHT, this); } + * onTravel(dt) { const c = settings.dawnbreak; + * this._sun.aim(c.sunAzimuth, c.sunElevation * this.u) + * .brightness(c.sunIntensity) + * .blend(c.sunWeight * this.fade); } + * onDestroy() { sceneHooks.reclaim(this); } + * ``` + */ +export const sceneHooks = new SceneHooks(); diff --git a/src/vfx/ShatterField.js b/src/vfx/ShatterField.js new file mode 100644 index 0000000..62c0a55 --- /dev/null +++ b/src/vfx/ShatterField.js @@ -0,0 +1,766 @@ +import { + InstancedMesh, + InstancedBufferAttribute, + ShaderMaterial, + DoubleSide, + NormalBlending, + AdditiveBlending, + Object3D, + Vector3, + Quaternion, + Color +} from 'three'; +import { frame, sharedUniforms } from '../core/FrameUniforms.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { LAYER } from '../core/Layers.js'; +import { saturate } from '../utils/math.js'; +import { GrowthLayout } from './GrowthField.js'; + +/* ---------------------------------------------------------------------- */ +/* ShatterField — things that come apart */ +/* ---------------------------------------------------------------------- */ + +/** + * Instanced fragments that inherit a velocity and tumble under gravity. + * + * Ice breaking, glass breaking, a prism splitting, a pane of frozen time coming + * down: the same system every time, and the fragment geometry is the only part + * that differs. So the geometry is the caller's (a shard, a pane, a prism chip + * from `assets/ProceduralGeometry.js` or the ability's own), and this module + * owns the flight, the tumble, the fade and the draw. + * + * ## The flight is solved, not stepped + * + * Nothing here integrates. A fragment's position is a closed-form function of + * `now − born` evaluated against the **live** params, so: + * + * - pausing and dragging `gravity` re-flies every fragment in the air — which + * an Euler integrator physically cannot do, because it has already spent the + * old gravity and has nowhere to put the new one (invariant I1); + * - a zero-length frame is not a special case, it is just the same evaluation + * again; + * - a fragment stores nothing but dice and one timestamp. + * + * With drag, `dv/dt = −k·v + g` integrates to + * `p(t) = p₀ + (v₀ − g/k)(1 − e^{−kt})/k + (g/k)·t`, which is what the loop + * evaluates; below `k ≈ 0` it falls back to the ballistic form rather than + * dividing by nothing. + * + * ## The screen-space sample + * + * A shard of glass that is a flat tint is a shard of plastic. Given a texture of + * the scene, the fragment shader samples it at its own screen position, offset + * by the surface normal (cheap refraction), desaturated and tinted — so the + * shard shows a cold, displaced copy of what is behind it. That is the whole + * trick behind Chronofracture's panes. + * + * **The repo does not currently expose a read buffer.** `PostProcessing` runs an + * `EffectComposer` whose ping-pong targets are internal, and sampling the target + * you are drawing into is a feedback loop, not a refraction. So this degrades: + * with no texture bound the shader compiles without the sample at all and the + * shard is its solid tint. When a colour buffer does appear — either + * `frame.uSceneColor`, which is polled here so a later agent can add it with no + * change to this file, or one handed over via `setSceneTexture()` — the define + * flips and the material recompiles once. Never per frame. + * + * ## The rule for using it well + * + * Fragments are *debris*, so give them somewhere to have come from: pass the + * live velocity of whatever broke as `inherit`, and set `spread` low. A burst of + * fragments with `spread: 1` is a firework; a burst with `spread: 0.35` and an + * inherited velocity is a thing that shattered. + * + * @example + * this.shards = new ShatterField(this.group, { + * geometry: (v) => createShardGeometry(3.1 + v * 9.7), + * capacity: 200, + * variants: 2 + * }); + * // impact + * this.shards.burst(this.age, c.fragmentCount, 1); + * // every frame, travel and fade alike + * this.shards.sync(this._look); + * this.shards.update(this.age, this._params); + */ + +/* Module-scope scratch — invariant I3. */ +const _dummy = new Object3D(); +const _v = new Vector3(); +const _rand = new Vector3(); +const _anchor = new Vector3(); +const _axis = new Vector3(); +const _spin = new Quaternion(); +const _up = new Vector3(0, 1, 0); +const _zero = new Vector3(); +const _forward = new Vector3(0, 0, 1); +const _right = new Vector3(1, 0, 0); + +/** Re-exported so an ability can spell the layout without importing GrowthField. */ +export { GrowthLayout as ShatterLayout }; + +const SHATTER_VERTEX = /* glsl */ ` + attribute float aSeed; + attribute float aLife; + + varying float vSeed; + varying float vLife; + varying vec3 vLocal; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + + void main() { + vSeed = aSeed; + vLife = aLife; + vLocal = position; + + mat4 im = mat4(1.0); + #ifdef USE_INSTANCING + im = instanceMatrix; + #endif + + vec4 world = modelMatrix * im * vec4(position, 1.0); + vNormalW = normalize(mat3(modelMatrix) * mat3(im) * normal); + vViewDir = cameraPosition - world.xyz; + + vec4 mv = viewMatrix * world; + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const SHATTER_FRAGMENT = /* glsl */ ` + uniform vec3 uColorA; + uniform vec3 uColorB; + uniform vec3 uColorEdge; + uniform vec3 uColorScene; + uniform float uOpacity; + uniform float uGlow; + uniform float uRim; + uniform float uRimPower; + uniform float uShade; + uniform float uAmbient; + uniform float uFadeStart; + uniform float uSoft; + uniform float uSceneMix; + uniform float uRefract; + uniform float uSaturation; + + uniform vec3 uLightDir; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + uniform float uShaderIntensity; + + #ifdef SHATTER_SCENE + uniform sampler2D uScene; + #endif + + varying float vSeed; + varying float vLife; + varying vec3 vLocal; + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vViewZ; + + ${commonGLSL} + + void main() { + vec3 N = normalize(vNormalW); + vec3 V = normalize(vViewDir); + + // Faceted debris, lit off the same key direction the stage uses so the + // fragments do not read as stickers against the shadowed floor. Two-sided: + // a shard is thin enough that you see its back face constantly. + float lambert = max(dot(N, uLightDir), 0.0); + if (dot(N, V) < 0.0) lambert = max(dot(-N, uLightDir), 0.0); + float shade = mix(1.0, uAmbient + (1.0 - uAmbient) * lambert, uShade); + + float fres = pow(1.0 - clamp(abs(dot(N, V)), 0.0, 1.0), uRimPower); + + vec3 body = mix(uColorA, uColorB, clamp(vLife, 0.0, 1.0)) * shade; + + #ifdef SHATTER_SCENE + // Screen-space refraction: push the lookup along the surface normal, so a + // fragment turned edge-on to the camera displaces what is behind it most. + // The sample is deliberately *not* depth-corrected — it is a shard, not a + // window, and paying for a correct parallax would buy nothing you can see. + vec2 uv = gl_FragCoord.xy / uResolution + N.xy * uRefract; + vec3 behind = texture2D(uScene, clamp(uv, vec2(0.002), vec2(0.998))).rgb; + float grey = dot(behind, vec3(0.2126, 0.7152, 0.0722)); + behind = mix(vec3(grey), behind, uSaturation) * uColorScene; + body = mix(body, behind, clamp(uSceneMix, 0.0, 1.0)); + #endif + + // The rim is the only emissive term. Scaling the *body* by the global glow + // would brighten a lump of debris along with the sparks, and debris that + // glows is the single fastest way to make a shatter read as confetti. + vec3 color = body; + color += uColorEdge * fres * uRim * uGlow * uGlobalGlow * mix(0.7, 1.0, uShaderIntensity); + + float alpha = uOpacity * (1.0 - smoothstep(uFadeStart, 1.0, clamp(vLife, 0.0, 1.0))); + + if (uSoft > 0.0) { + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoft); + } + + if (alpha < 0.004) discard; + gl_FragColor = vec4(color, alpha); + } +`; + +/** + * Canonical parameter names with their defaults, in the units the field wants. + * Read fresh on every `update()`; a missing field falls back to the default. + */ +export function shatterParams() { + return { + /* --- basis (the ability's own vectors; never copied) --- */ + layout: GrowthLayout.LINE, + origin: null, // Vector3, on the floor + direction: null, // Vector3, unit, flat — also the default throw direction + side: null, // Vector3, unit, lateral + length: 10, // metres down the line + centre: null, // Vector3 for ZONE; defaults to the far end of the line + radius: 4, // metres, ZONE footprint the fragments start inside + width: 1, // metres, LINE half-width the `lateral` dice is measured against + + /* --- where a fragment starts --- */ + spawnRadius: 0.5, // metres of scatter about its anchor point + spawnHeight: 0.4, // metres the anchor sits above the floor + + /* --- how it leaves --- */ + speed: 6, // metres/second + speedJitter: 0.6, // ± fraction + spread: 0.45, // 0 throws every fragment along `direction`, 1 is fully random + upBias: 0.5, // how much +Y is folded into the throw direction, 0..1 + inherit: null, // Vector3, metres/second, the velocity of whatever broke + inheritScale: 1, // multiplier on that + + /* --- the flight --- */ + gravity: -18, // metres/second², signed + drag: 0.8, // 1/second; 0 is pure ballistics + + /* --- the body --- */ + size: 0.28, // metres, the unit geometry's scale + sizeJitter: 0.55, // ± fraction + shrink: 0.7, // 0..1 of its size lost by the end of life + shrinkPower: 1.6, // how late that shrink bites + spin: 9, // radians/second of tumble + spinJitter: 0.8, // ± fraction + + /* --- the clock --- */ + lifetime: 1.4, // seconds a fragment lives + + /* --- the floor --- */ + floor: 0.0, // metres; fragments never sink below floor + their own half-size + floorSpin: 0.25, // fraction of the tumble rate kept once grounded + + /* --- globals --- */ + randomness: 1 // multiplies every *Jitter above (settings.global.randomness) + }; +} + +export class ShatterField { + /** + * @param {THREE.Object3D} parent the ability's group + * @param {object} options + * @param {(variant: number) => THREE.BufferGeometry} options.geometry + * unit-space fragment factory — a body inside a unit sphere, centred on + * its own origin, because the tumble rotates about that point + * @param {number} [options.variants=2] distinct fragment shapes = draw calls + * @param {number} [options.capacity=192] hard ceiling on live fragments + * @param {THREE.Material} [options.material] supply one to bypass the built-in + * shader entirely; it must consume `aSeed` and `aLife` itself + * @param {boolean} [options.additive=false] additive blending for glowing debris + * @param {boolean} [options.depthWrite=true] + * @param {number} [options.layer=LAYER.VFX] + * @param {number} [options.renderOrder=6] + * @param {boolean} [options.castShadow=false] fragments are small and numerous; + * a shadow per fragment costs more than it reads + */ + constructor(parent, options = {}) { + const { + geometry, + variants = 2, + capacity = 192, + material = null, + additive = false, + depthWrite = true, + layer = LAYER.VFX, + renderOrder = 6, + castShadow = false, + receiveShadow = false + } = options; + + if (typeof geometry !== 'function') throw new Error('ShatterField: options.geometry must be a factory'); + + this.parent = parent; + this.factory = geometry; + this.variants = Math.max(1, Math.round(variants)); + this.capacity = Math.max(1, Math.round(capacity)); + this.slots = Math.ceil(this.capacity / this.variants); + + /** True when the material is ours to dispose. */ + this._ownsMaterial = !material; + this.material = material ?? this._createMaterial(additive, depthWrite); + this.uniforms = this.material.uniforms ?? null; + + this.meshes = []; + this.seedAttributes = []; + this.lifeAttributes = []; + + for (let v = 0; v < this.variants; v++) { + const seeds = new InstancedBufferAttribute(new Float32Array(this.slots), 1); + const lives = new InstancedBufferAttribute(new Float32Array(this.slots), 1); + + const geo = this.factory(v); + geo.setAttribute('aSeed', seeds); + geo.setAttribute('aLife', lives); + + const mesh = new InstancedMesh(geo, this.material, this.slots); + mesh.frustumCulled = false; + mesh.castShadow = castShadow; + mesh.receiveShadow = receiveShadow; + mesh.count = 0; + mesh.layers.set(layer); + mesh.renderOrder = renderOrder; + parent.add(mesh); + + this.meshes.push(mesh); + this.seedAttributes.push(seeds); + this.lifeAttributes.push(lives); + } + + /** + * Fixed-size record pool. Dice and one timestamp — see the class header. + */ + this.records = []; + for (let i = 0; i < this.capacity; i++) { + this.records.push({ + born: -1, // absolute age it was thrown at, or -1 for a dead slot + along: 1, // 0..1 down the cast line it came off + lateral: 0, // -1..1 across the band + angle: 0, // 0..1 of a turn — ZONE bearing + radial: 0, // 0..1 sqrt-uniform — ZONE distance + offX: 0, // unit-sphere scatter about the anchor + offY: 0, + offZ: 0, + dirX: 0, // unit-sphere throw dice + dirY: 1, + dirZ: 0, + axisX: 0, // unit tumble axis + axisY: 1, + axisZ: 0, + speedRoll: 0, // -1..1 + sizeRoll: 0, // -1..1 + spinRoll: 0, // -1..1 + seed: 0 // 0..10, mirrored into aSeed + }); + } + + this._cursor = 0; + this._live = 0; + this._used = new Int32Array(this.variants); + this._hasScene = false; + /** True once the ability bound a texture by hand; stops `sync` overriding it. */ + this._sceneOverride = false; + } + + /** Live fragments this frame. Feed into `Ability#instanceCount`. */ + get count() { + return this._live; + } + + /** Draw calls while anything is in the air. */ + get drawCalls() { + return this.variants; + } + + /* ------------------------------------------------------------------ */ + /* Material */ + /* ------------------------------------------------------------------ */ + + _createMaterial(additive, depthWrite) { + return new ShaderMaterial({ + transparent: true, + depthWrite, + depthTest: true, + // A fragment is a solid seen from both sides as it tumbles; culling the + // back face makes a thin pane vanish for half of every rotation. + side: DoubleSide, + blending: additive ? AdditiveBlending : NormalBlending, + toneMapped: false, + defines: {}, + uniforms: sharedUniforms({ + uColorA: { value: new Color(1, 1, 1) }, + uColorB: { value: new Color(0.4, 0.6, 0.8) }, + uColorEdge: { value: new Color(1, 1, 1) }, + uColorScene: { value: new Color(1, 1, 1) }, + uOpacity: { value: 1 }, + uGlow: { value: 1 }, + uRim: { value: 0.8 }, + uRimPower: { value: 2.4 }, + uShade: { value: 1 }, + uAmbient: { value: 0.35 }, + uFadeStart: { value: 0.6 }, + uSoft: { value: 0 }, + uSceneMix: { value: 0.75 }, + uRefract: { value: 0.05 }, + uSaturation: { value: 0.3 }, + uScene: { value: null } + }), + vertexShader: SHATTER_VERTEX, + fragmentShader: SHATTER_FRAGMENT + }); + } + + /** + * Bind (or unbind) the colour buffer the fragments sample. + * + * Recompiles the material on the transition only — the define is what removes + * the sampler from the shader entirely when there is nothing to sample, which + * is the difference between "degrades gracefully" and "renders black". + * + * @param {THREE.Texture|null} texture + */ + setSceneTexture(texture) { + this._sceneOverride = !!texture; + this._bindScene(texture ?? null); + } + + /** The binding itself, without claiming ownership of the choice. */ + _bindScene(texture) { + if (!this.uniforms) return; + const has = !!texture; + this.uniforms.uScene.value = texture ?? null; + if (has === this._hasScene) return; + this._hasScene = has; + if (has) this.material.defines.SHATTER_SCENE = ''; + else delete this.material.defines.SHATTER_SCENE; + this.material.needsUpdate = true; + } + + /** + * Push the look from live settings. Every colour is its own picker (I5); the + * three body/edge colours are never derived from one another. + * + * @param {object} look colorA, colorB, colorEdge, colorScene (THREE.Color), + * opacity, glow, rim, rimPower, shade, ambient, fadeStart, soft, + * sceneMix, refract, saturation + */ + sync(look) { + const u = this.uniforms; + if (!u) return; + + if (look.colorA) u.uColorA.value.copy(look.colorA); + if (look.colorB) u.uColorB.value.copy(look.colorB); + if (look.colorEdge) u.uColorEdge.value.copy(look.colorEdge); + if (look.colorScene) u.uColorScene.value.copy(look.colorScene); + + u.uOpacity.value = look.opacity ?? 1; + u.uGlow.value = look.glow ?? 1; + u.uRim.value = look.rim ?? 0.8; + u.uRimPower.value = look.rimPower ?? 2.4; + u.uShade.value = look.shade ?? 1; + u.uAmbient.value = look.ambient ?? 0.35; + u.uFadeStart.value = look.fadeStart ?? 0.6; + u.uSoft.value = look.soft ?? 0; + u.uSceneMix.value = look.sceneMix ?? 0.75; + u.uRefract.value = look.refract ?? 0.05; + u.uSaturation.value = look.saturation ?? 0.3; + + // A colour buffer the app grows *later* costs nothing to pick up here, and + // nothing to not find: `frame.uSceneColor` does not exist in the repo today, + // so this reads as null and the shard stays a solid tint. The day someone + // wires a scene copy into `FrameUniforms`, every ShatterField starts + // refracting without a line changing in this file. + if (!this._sceneOverride) { + const shared = frame.uSceneColor ? frame.uSceneColor.value : null; + if (shared !== u.uScene.value) this._bindScene(shared); + } + } + + /* ------------------------------------------------------------------ */ + /* Bursting — the only place the dice are rolled */ + /* ------------------------------------------------------------------ */ + + /** + * Throw `count` fragments. Call from an impact or a break beat. + * + * Everything captured here is unitless: where along the cast the break + * happened as a fraction, a unit throw direction, a unit tumble axis and a + * handful of ± rolls. The metres arrive in `update()`. + * + * @param {number} now the ability's `age`, seconds + * @param {number} count fragments to throw, clamped to the free capacity + * @param {number} [along] 0..1 down the cast line (LINE), or the radial + * fraction of the zone (ZONE) the break happened at + * @param {number} [lateral] -1..1 across the band, for a break off the axis + * @returns {number} fragments actually thrown + */ + burst(now, count, along = 1, lateral = 0) { + const wanted = Math.max(0, Math.round(count)); + let thrown = 0; + + for (let n = 0; n < wanted; n++) { + const index = this._cursor; + this._cursor = (this._cursor + 1) % this.capacity; + + const record = this.records[index]; + record.born = now; + record.along = saturate(along); + record.lateral = lateral; + record.angle = Math.random(); + record.radial = Math.sqrt(Math.random()); + record.seed = Math.random() * 10; + record.speedRoll = Math.random() * 2 - 1; + record.sizeRoll = Math.random() * 2 - 1; + record.spinRoll = Math.random() * 2 - 1; + + randomUnit(_rand); + record.offX = _rand.x; + record.offY = _rand.y; + record.offZ = _rand.z; + + randomUnit(_rand); + record.dirX = _rand.x; + record.dirY = _rand.y; + record.dirZ = _rand.z; + + randomUnit(_rand); + record.axisX = _rand.x; + record.axisY = _rand.y; + record.axisZ = _rand.z; + + // The seed attribute is per *slot*, and a slot is shared by the record + // that owns it, so it is written here rather than once at construction. + const variant = index % this.variants; + const slot = (index / this.variants) | 0; + this.seedAttributes[variant].array[slot] = record.seed; + this.seedAttributes[variant].needsUpdate = true; + + thrown++; + } + + return thrown; + } + + /** Kill everything in the air. `onDestroy()`. */ + clear() { + for (let i = 0; i < this.capacity; i++) this.records[i].born = -1; + this._live = 0; + this._cursor = 0; + for (let v = 0; v < this.variants; v++) this.meshes[v].count = 0; + } + + /* ------------------------------------------------------------------ */ + /* Resolving */ + /* ------------------------------------------------------------------ */ + + /** Where a fragment was thrown from, at the live footprint. Writes `out`. */ + _anchorOf(record, p, out) { + const layout = p.layout ?? GrowthLayout.LINE; + const origin = p.origin ?? _zero; + const direction = p.direction ?? _forward; + const side = p.side ?? _right; // only used in LINE, where the caller supplies it + + if (layout === GrowthLayout.ZONE) { + const centre = p.centre; + if (centre) out.copy(centre); + else out.copy(origin).addScaledVector(direction, p.length ?? 0); + const angle = record.angle * Math.PI * 2; + const r = (p.radius ?? 0) * record.radial * saturate(record.along); + out.x += Math.cos(angle) * r; + out.z += Math.sin(angle) * r; + } else { + out.copy(origin).addScaledVector(direction, record.along * (p.length ?? 0)); + out.addScaledVector(side, record.lateral * (p.width ?? 0)); + } + + out.y += p.spawnHeight ?? 0; + const scatter = p.spawnRadius ?? 0; + out.x += record.offX * scatter; + out.y += record.offY * scatter; + out.z += record.offZ * scatter; + return out; + } + + /** Initial velocity of a fragment, metres/second. Writes `out`. */ + _velocityOf(record, p, out) { + const jitter = p.randomness ?? 1; + const spread = saturate(p.spread ?? 0.45); + + _rand.set(record.dirX, record.dirY, record.dirZ); + out.copy(p.direction ?? _forward); + out.addScaledVector(_up, p.upBias ?? 0); + if (out.lengthSq() < 1e-8) out.copy(_up); + out.normalize().lerp(_rand, spread); + if (out.lengthSq() < 1e-8) out.copy(_rand); + out.normalize(); + + const speed = (p.speed ?? 0) * (1 + record.speedRoll * (p.speedJitter ?? 0) * jitter); + out.multiplyScalar(speed); + if (p.inherit) out.addScaledVector(p.inherit, p.inheritScale ?? 1); + return out; + } + + /** + * World position of a fragment `t` seconds after it was thrown. + * + * The analytic drag solution; see the class header for why this is not an + * integrator. Returns `out`. + */ + _flight(record, p, t, out) { + this._anchorOf(record, p, out); + this._velocityOf(record, p, _v); + + const g = p.gravity ?? 0; + const k = p.drag ?? 0; + + if (k > 1e-3) { + const e = Math.exp(-k * t); + const decay = (1 - e) / k; + const terminal = g / k; // the fall speed drag settles at, m/s + out.x += _v.x * decay; + out.z += _v.z * decay; + out.y += (_v.y - terminal) * decay + terminal * t; + } else { + out.x += _v.x * t; + out.z += _v.z * t; + out.y += _v.y * t + 0.5 * g * t * t; + } + return out; + } + + /** World position of fragment `i`, live. Trails and lights hang off this. */ + positionOf(index, now, p, out) { + const record = this.records[index]; + if (record.born < 0) return out.set(0, -999, 0); + return this._flight(record, p, Math.max(0, now - record.born), out); + } + + /* ------------------------------------------------------------------ */ + /* The frame */ + /* ------------------------------------------------------------------ */ + + /** + * Re-fly every fragment from the live params. Allocation-free. + * + * @param {number} now the ability's `age`, seconds + * @param {object} p live params — see `shatterParams()` + * @returns {number} live fragments + */ + update(now, p) { + const variants = this.variants; + const used = this._used; + used.fill(0); + + const lifetime = Math.max(0.02, p.lifetime ?? 1.4); + const jitter = p.randomness ?? 1; + const floor = p.floor ?? 0; + const shrink = saturate(p.shrink ?? 0); + const shrinkPower = p.shrinkPower ?? 1.6; + let live = 0; + + for (let i = 0; i < this.capacity; i++) { + const record = this.records[i]; + const variant = i % variants; + const slot = (i / variants) | 0; + const age = record.born < 0 ? -1 : now - record.born; + const life = age / lifetime; + + if (age < 0 || life >= 1) { + // Dead or unborn: park it, keep the slot contiguous. + if (record.born >= 0 && life >= 1) record.born = -1; + _dummy.position.set(0, -999, 0); + _dummy.quaternion.identity(); + _dummy.scale.setScalar(0.0001); + _dummy.updateMatrix(); + this.meshes[variant].setMatrixAt(slot, _dummy.matrix); + this.lifeAttributes[variant].array[slot] = 1; + used[variant] = Math.max(used[variant], slot + 1); + continue; + } + + /* --- size, before the flight: the floor clamp needs the half-height --- */ + const size = + Math.max(0.001, (p.size ?? 0.25) * (1 + record.sizeRoll * (p.sizeJitter ?? 0) * jitter)) * + (1 - shrink * Math.pow(life, shrinkPower)); + + /* --- the flight --- */ + this._flight(record, p, age, _dummy.position); + + const rest = floor + size * 0.35; + let grounded = false; + if (_dummy.position.y < rest) { + _dummy.position.y = rest; + grounded = true; + } + + /* --- the tumble --- */ + // A grounded fragment keeps `floorSpin` of its rate rather than stopping + // dead. Solving for the contact time to stop it properly is a quadratic + // with no closed form once drag is in play, and at the size these read at + // nobody has ever noticed the difference. + const rate = + (p.spin ?? 0) * + (1 + record.spinRoll * (p.spinJitter ?? 0) * jitter) * + (grounded ? saturate(p.floorSpin ?? 0.25) : 1); + _axis.set(record.axisX, record.axisY, record.axisZ); + if (_axis.lengthSq() < 1e-8) _axis.copy(_up); + _axis.normalize(); + _spin.setFromAxisAngle(_axis, rate * age); + + _dummy.quaternion.copy(_spin); + _dummy.scale.setScalar(size); + _dummy.updateMatrix(); + + this.meshes[variant].setMatrixAt(slot, _dummy.matrix); + this.lifeAttributes[variant].array[slot] = life; + used[variant] = Math.max(used[variant], slot + 1); + live++; + } + + for (let v = 0; v < variants; v++) { + this.meshes[v].count = used[v]; + this.meshes[v].instanceMatrix.needsUpdate = true; + this.lifeAttributes[v].needsUpdate = true; + } + + this._live = live; + return live; + } + + dispose() { + for (const mesh of this.meshes) { + mesh.geometry.dispose(); + mesh.dispose(); + mesh.parent?.remove(mesh); + } + this.meshes.length = 0; + if (this._ownsMaterial) this.material.dispose(); + } +} + +/* ---------------------------------------------------------------------- */ +/* Helpers */ +/* ---------------------------------------------------------------------- */ + +/** + * A uniformly distributed point on the unit sphere, written into `out`. + * + * Rejection sampling inside the cube is the textbook version and it is wrong + * here for a mundane reason: it loops an unbounded number of times, and a burst + * rolls three of these per fragment. The z-then-angle construction is exact, + * branch-free and constant time. + */ +function randomUnit(out) { + const z = Math.random() * 2 - 1; + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.max(0, 1 - z * z)); + return out.set(Math.cos(angle) * r, z, Math.sin(angle) * r); +} diff --git a/src/vfx/Shell.js b/src/vfx/Shell.js new file mode 100644 index 0000000..b7d6823 --- /dev/null +++ b/src/vfx/Shell.js @@ -0,0 +1,1061 @@ +import { + AdditiveBlending, + Color, + DoubleSide, + Group, + Mesh, + ShaderMaterial, + Vector3 +} from 'three'; +import { createBeamTubeGeometry, createBeamRingGeometry } from '../assets/ProceduralGeometry.js'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms, frame } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { globals } from '../config/globals.js'; +import { getColor } from '../utils/color.js'; +import { clamp, saturate } from '../utils/math.js'; +import { num, str, prefixed, buildKeys, buildDefaults, auditBlock } from './prefixedBlock.js'; + +/** + * `vfx/Shell.js` — the standing half of the burst vocabulary. + * + * ## Why this is a new module and not four more `BurstMode`s + * + * `effects/BurstSphere.js` stays exactly as it is, and the six shipped abilities + * go on using it. It is a **fire-and-forget pool**: you hand `spawn()` a start + * radius and an end radius in metres, and it runs its own clock to death. That + * is a *captured dimension* — the thing invariant I1 exists to forbid — and it + * is completely fine there, because a burst lives 900 ms and nobody can drag a + * slider inside 900 ms. Trying to retrofit live re-resolution onto a pool whose + * whole contract is "spawn and forget" would have made every existing caller + * pass a settings object it does not have. + * + * What the roster needs instead is shells that **stand**: a thunderclap dome + * that holds while three pressure fronts cross it, a resonant chord's ring train + * running for two seconds, a sun disc lying on the floor while you tune the + * corona licking off its rim. Those are owned by the ability, live in its group, + * and re-resolve every metre from settings on every frame including a + * zero-length one. + * + * So: **`BurstSystem` owns the transient vocabulary, `Shell` owns the standing + * one.** Both are re-exported from here, because from an ability's point of view + * they are one vocabulary with two lifetimes, and having to remember which file + * a hemisphere lives in is exactly the friction the tech library is supposed to + * remove. + * + * ## What it draws + * + * **One draw call per shell.** Two geometry kinds cover all five modes, and that + * economy is the good idea in this file: + * + * - **the `(t, a)` surface grid** — `createBeamTubeGeometry`, the same grid the + * beam and `vfx/Tube.js` are drawn on. Map `t` to a polar angle and it is a + * dome; map it to a full polar sweep and it is a sphere; map it to distance + * along an axis with a rising radius and it is a cone. One grid, three + * silhouettes, no new geometry builders. + * - **the instanced annulus** — `createBeamRingGeometry`. One instance is one + * ring; the shader places it. That is the ring train, and with a single + * instance and an inner radius of zero it is also the sun disc. + * + * ## Modes + * + * | mode | what it is | + * | --- | --- | + * | `DOME` | a hemisphere standing on the floor, with a bright seal where it meets it. | + * | `CONE` | an open blast cone from an apex along an axis, rim-weighted and hollow. | + * | `RING_TRAIN` | rings travelling out along an axis at fixed spacing, reflecting off the far end. | + * | `SUNDISC` | a low disc lying on the ground with corona filaments licking off its rim. | + * | `PRESSURE` | a near-invisible shell that is almost entirely a fresnel rim. | + * + * ### RING_TRAIN, and the standing wave + * + * This is Resonant Chord's whole trick, so it is worth being exact about it. + * + * Rings are launched at the origin `spacing` metres apart and travel out at + * `ringSpeed`. At the far end they **fold**: `s = span − |d − span|` on a + * `2·span` cycle, so a ring runs out, turns round and comes back. Outbound and + * returning rings therefore cross, and where they cross they add. + * + * The interference is not faked. Superposing the outbound wave with its + * reflection off a fixed end, + * + * ``` + * sin(ks − ωt) − sin(k(2L − s) − ωt) = 2·cos(kL − ωt)·sin(k(s − L)) + * ``` + * + * — a spatial envelope `|sin(k(s − L))|` with a node at the far end and every + * half-wavelength back from it, multiplied by a temporal term `cos(kL − ωt)` + * that pulses the *whole line together*. That identity is three lines of GLSL, + * and each ring reads the envelope at its own position: at a node it pinches to + * the axis and goes dark, at an antinode it blooms. The nodes are visible + * because they are actually there. + * + * `resonantSpacing(n)` returns the wavelength that fits exactly `n` half-waves + * on the line, which is how an ability makes the nodes stand still instead of + * drifting. `nodePosition(i)` hands back where they are, so a burst or a dust + * puff can be dropped on one. + * + * ### SUNDISC, and the spokes it does not draw + * + * The corona is ridged noise sampled **in the plane** and domain-warped, then + * masked to the annulus just outside the rim. The obvious implementation — + * sampling on `atan(y, x)` — hands every radius along a given bearing the same + * value and draws dead-straight spokes out of the centre: a firework, not a + * corona. The bolt's ground burns learnt this the hard way; the same lesson + * applies to anything radial. + * + * ## What it reads from settings + * + * Everything in `shellDefaults()`, by prefixed key, off whatever object is + * handed to `sync()` — plus `settings.global`. Spread `shellDefaults()` into the + * ability's block and `shellSchema()` into its editor schema. + * + * ## The one rule for using it well + * + * **Drive it with a normalised life `t`, never with a captured radius.** The + * shell interpolates `radius → radiusEnd` itself, on a live easing exponent, so + * an ability hands it `t = age / duration` and gets an expansion that reshapes + * under the sliders while it is standing. An ability that computes its own + * metres and pokes them in has thrown away the only reason this module exists. + */ + +/* The transient half of the vocabulary. Re-exported deliberately — see above. */ +export { BurstMode, BurstSystem } from '../effects/BurstSphere.js'; + +/* ---------------------------------------------------------------- */ +/* Enums */ +/* ---------------------------------------------------------------- */ + +export const ShellMode = Object.freeze({ + DOME: 0, // hemisphere on the floor + CONE: 1, // open blast cone along an axis + RING_TRAIN: 2, // reflecting train of rings — standing waves + SUNDISC: 3, // ground disc with a corona + PRESSURE: 4 // near-invisible fresnel rim +}); + +/** Enum → name, for the editor dropdown and for readable diagnostics. */ +export const SHELL_MODE_NAMES = Object.freeze(['DOME', 'CONE', 'RING_TRAIN', 'SUNDISC', 'PRESSURE']); + +const TAU = Math.PI * 2; + +/* ---------------------------------------------------------------- */ +/* The settings contract */ +/* ---------------------------------------------------------------- */ + +const SHELL_FIELDS = { + /* --- the expansion --- */ + radius: 0.6, // radius at t = 0, metres + radiusEnd: 4.0, // radius at t = 1, metres + expand: 5.0, // easing exponent: 1 − (1−t)^expand, >1 = fast then easing out + height: 1.0, // axial extent, × radius (squash a dome, stretch a sphere) + span: 6.0, // CONE length / RING_TRAIN run, metres (state.span overrides) + lift: 0.04, // hover above the anchor along the axis, metres + + /* --- the surface --- */ + displace: 0.22, // billow along the normal, × radius + noiseScale: 1.8, // billow features per unit radius + noiseSpeed: 0.6, // Hz the billow crawls at + turbulence: 1.0, // master on the billow + + /* --- shading --- */ + fill: 0.25, // how much body the shell keeps, 0 = rim only + rim: 1.0, // strength of the fresnel rim + rimPower: 2.2, // how tight that rim is + seal: 1.4, // DOME: brightness where the dome meets the floor + sealWidth: 0.12, // how wide that seal band is, fraction of the sweep + edge: 1.2, // CONE: brightness of the leading lip + edgeWidth: 0.16, // how wide that lip is, fraction of the length + coneCurve: 1.0, // CONE: <1 bells out early, >1 stays needle-thin + dissolve: 0.9, // how hard the age dissolve bites, 0 = fade evenly + + /* --- RING_TRAIN --- */ + rings: 10, // live instance count + spacing: 1.6, // metres between launched rings — the wavelength + ringSpeed: 7.0, // metres/second they travel at + ringThickness: 0.16, // radial thickness of one ring, metres + ringSharp: 1.6, // how hard its profile falls off + reflect: 1.0, // 0 = rings die at the far end, 1 = perfect reflection + standing: 1.0, // how much of the standing envelope modulates them + swell: 0.45, // extra radius at an antinode, × radius + + /* --- SUNDISC --- */ + coronaReach: 1.8, // how far past the rim the disc is drawn, × radius + corona: 1.3, // brightness of the filaments licking off the rim + coronaLength: 0.55, // how far they reach, × radius + coronaScale: 5.0, // filament features per radius + coronaWarp: 0.45, // domain warp — the thing that stops them being spokes + coronaSpeed: 0.7, // Hz they crawl at + coronaSharp: 0.72, // threshold: low fills the corona in and it reads as fog + granule: 0.45, // convection cells across the disc face + granuleScale: 6.0, // cells per radius + rimWidth: 0.18, // hot band inside the rim, × radius + + /* --- rendering --- */ + opacity: 1.0, + glow: 1.8, // emissive gain into bloom + softFade: 0.6, // metres of depth fade against the opaque scene + + /* --- colour (I5: four pickers, none derived from another) --- */ + colorBody: '#2f6f9a', // the body of the shell + colorRim: '#d0f0ff', // the fresnel rim / the seal / the leading lip + colorEdge: '#ffffff', // the hottest mark it has + colorCorona: '#ffb44a' // SUNDISC filaments — deliberately its own picker +}; + +const FIELD_NAMES = Object.freeze(Object.keys(SHELL_FIELDS)); + +/** + * Per-mode tuning, applied on top of the field defaults. + * + * A pressure front and a sun disc want opposite numbers from the same block — + * one is 95% rim and 5% body, the other is a solid face with filaments coming + * off it. Starting points only; every one is a slider once the block is spread. + */ +const MODE_DEFAULTS = { + [ShellMode.CONE]: { fill: 0.14, rim: 1.2, rimPower: 1.6, displace: 0.12, radius: 0.05, radiusEnd: 2.4 }, + [ShellMode.RING_TRAIN]: { radius: 0.5, radiusEnd: 1.4, expand: 2, fill: 0, displace: 0, glow: 2.4 }, + [ShellMode.SUNDISC]: { + radius: 0.4, radiusEnd: 3.2, height: 0.02, fill: 0.7, rim: 0.4, + displace: 0, dissolve: 0.4, glow: 2.6, lift: 0.03, + colorBody: '#ff9a1f', colorRim: '#ffe07a', colorEdge: '#ffffff', colorCorona: '#ffd27a' + }, + [ShellMode.PRESSURE]: { + fill: 0.02, rim: 1.6, rimPower: 3.4, displace: 0.06, dissolve: 1.2, glow: 1.2 + } +}; + + +/** + * A fresh settings fragment for one shell. Spread into an ability's block. + * + * ```js + * export const resonance = { + * range: 20, minRange: 2, speed: 40, cooldown: 1.1, castAnim: 'cast1', + * ...shellDefaults('chord', ShellMode.RING_TRAIN, { chordSpacing: 2.2 }) + * }; + * ``` + * + * Overrides are keyed by the **prefixed** name, so they read the same way the + * ability will read them. + * + * @param {string} [prefix] key prefix — take a second one for a second shell + * @param {number} [mode] ShellMode.*, which picks the per-mode tuning above + * @param {object} [overrides] prefixed key → value + */ +export function shellDefaults(prefix = 'shell', mode = ShellMode.DOME, overrides = {}) { + return buildDefaults(FIELD_NAMES, SHELL_FIELDS, MODE_DEFAULTS[mode] ?? {}, prefix, overrides); +} + +/** The unprefixed → prefixed key map, built once. */ +export function shellKeys(prefix = 'shell') { + return buildKeys(FIELD_NAMES, prefix); +} + +/** Editor folders for one shell. Spread into an ability's schema. */ +export function shellSchema(prefix = 'shell', mode = ShellMode.DOME) { + const k = (name) => prefixed(prefix, name); + const schema = { + 'The shell': [ + [k('radius'), 0.01, 20, 0.01, 'start radius (m)'], + [k('radiusEnd'), 0.01, 40, 0.01, 'end radius (m)'], + [k('expand'), 0.2, 12, 0.01, 'expansion curve'], + [k('height'), 0.02, 4, 0.01, 'height × radius'], + [k('lift'), -2, 2, 0.001, 'lift (m)'], + [k('displace'), 0, 1.5, 0.01, 'billow'], + [k('noiseScale'), 0.1, 10, 0.01, 'billow scale'], + [k('noiseSpeed'), 0, 4, 0.01, 'billow Hz'], + [k('turbulence'), 0, 3, 0.01, 'turbulence'] + ], + 'Shell shading': [ + [k('fill'), 0, 1, 0.01, 'body fill'], + [k('rim'), 0, 3, 0.01, 'rim'], + [k('rimPower'), 0.1, 8, 0.01, 'rim power'], + [k('dissolve'), 0, 2, 0.01, 'dissolve'], + k('colorBody'), + k('colorRim'), + k('colorEdge'), + [k('opacity'), 0, 1, 0.01, 'opacity'], + [k('glow'), 0, 8, 0.01, 'glow'], + [k('softFade'), 0, 3, 0.01, 'soft fade (m)'] + ] + }; + + if (mode === ShellMode.DOME) { + schema['The dome'] = [ + [k('seal'), 0, 4, 0.01, 'floor seal'], + [k('sealWidth'), 0.01, 0.6, 0.01, 'seal width'] + ]; + } + if (mode === ShellMode.CONE) { + schema['The cone'] = [ + [k('span'), 0.1, 40, 0.05, 'length (m)'], + [k('coneCurve'), 0.1, 4, 0.01, 'flare curve'], + [k('edge'), 0, 4, 0.01, 'leading lip'], + [k('edgeWidth'), 0.01, 0.8, 0.01, 'lip width'] + ]; + } + if (mode === ShellMode.RING_TRAIN) { + schema['The ring train'] = [ + [k('span'), 0.1, 40, 0.05, 'run length (m)'], + [k('rings'), 1, 48, 1, 'rings'], + [k('spacing'), 0.1, 12, 0.01, 'wavelength (m)'], + [k('ringSpeed'), 0, 40, 0.05, 'speed (m/s)'], + [k('ringThickness'), 0.01, 2, 0.01, 'thickness (m)'], + [k('ringSharp'), 0.05, 8, 0.01, 'profile'], + [k('reflect'), 0, 1, 0.01, 'reflection'], + [k('standing'), 0, 1, 0.01, 'standing wave'], + [k('swell'), 0, 2, 0.01, 'antinode swell'] + ]; + } + if (mode === ShellMode.SUNDISC) { + schema['The sun disc'] = [ + [k('coronaReach'), 1, 4, 0.01, 'drawn reach'], + [k('corona'), 0, 4, 0.01, 'corona'], + [k('coronaLength'), 0, 3, 0.01, 'corona length'], + [k('coronaScale'), 0.5, 20, 0.1, 'corona scale'], + [k('coronaWarp'), 0, 2, 0.01, 'corona warp'], + [k('coronaSpeed'), 0, 4, 0.01, 'corona Hz'], + [k('coronaSharp'), 0, 0.98, 0.01, 'corona threshold'], + [k('granule'), 0, 2, 0.01, 'granulation'], + [k('granuleScale'), 0.5, 24, 0.1, 'granule scale'], + [k('rimWidth'), 0.01, 0.6, 0.01, 'rim band'], + k('colorCorona') + ]; + } + return schema; +} + +/* ---------------------------------------------------------------- */ +/* GLSL */ +/* ---------------------------------------------------------------- */ + +const SHELL_UNIFORMS = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + #define MODE_DOME 0 + #define MODE_CONE 1 + #define MODE_RING_TRAIN 2 + #define MODE_SUNDISC 3 + #define MODE_PRESSURE 4 + + uniform float uTime; + uniform vec3 uOrigin; + uniform vec3 uAxis; + uniform vec3 uSide; + uniform float uSeed; + uniform float uAge; + uniform float uFade; + + uniform float uRadius; + uniform float uHeight; + uniform float uSpan; + uniform float uLift; + + uniform float uDisplace; + uniform float uNoiseScale; + uniform float uNoiseSpeed; + + uniform float uFill; + uniform float uRim; + uniform float uRimPower; + uniform float uSeal; + uniform float uSealWidth; + uniform float uEdge; + uniform float uEdgeWidth; + uniform float uConeCurve; + uniform float uDissolve; + + uniform float uSpacing; + uniform float uRingSpeed; + uniform float uRingThickness; + uniform float uRingSharp; + uniform float uReflect; + uniform float uStanding; + uniform float uSwell; + + uniform float uCoronaReach; + uniform float uCorona; + uniform float uCoronaLength; + uniform float uCoronaScale; + uniform float uCoronaWarp; + uniform float uCoronaSpeed; + uniform float uCoronaSharp; + uniform float uGranule; + uniform float uGranuleScale; + uniform float uRimWidth; + + uniform float uOpacity; + uniform float uGlow; + uniform float uSoftFade; + uniform vec3 uColorBody; + uniform vec3 uColorRim; + uniform vec3 uColorEdge; + uniform vec3 uColorCorona; +`; + +/** + * Five is the ceiling here, guarded per mode — the same discipline + * `BeamMaterial` keeps, and for the same reason: the low end has a varying + * budget and a shell is never the only thing on screen. + */ +const SHELL_VARYINGS = /* glsl */ ` + varying float vT; + varying float vViewZ; + + #if SHELL_MODE == MODE_RING_TRAIN + varying float vAmp; + varying float vS; + #elif SHELL_MODE == MODE_SUNDISC + varying vec2 vPolar; + varying vec2 vPlane; + #else + varying vec3 vNormalW; + varying vec3 vViewDir; + varying float vDisp; + #endif +`; + +const SHELL_SHAPE = /* glsl */ ` + /** The shell's local frame: its axis and two vectors spanning across it. */ + void shellFrame(out vec3 ax, out vec3 e1, out vec3 e2) { + ax = normalize(uAxis); + vec3 lateral = uSide - ax * dot(uSide, ax); + e1 = length(lateral) > 1e-4 ? normalize(lateral) : normalize(cross(ax, vec3(0.0, 0.0, 1.0))); + e2 = normalize(cross(ax, e1)); + } + + /** + * The standing-wave envelope at s metres along the run. + * + * sin(ks − ωt) − sin(k(2L − s) − ωt) = 2·cos(kL − ωt)·sin(k(s − L)) + * + * so the spatial part is a fixed pattern of nodes counted back from the far + * end, and the temporal part pulses the whole line at once. uReflect fades + * between a plain travelling train and the full standing pattern, which is + * exactly what a lossy end reflection does. + */ + float standingAmp(float s) { + float lambda = max(uSpacing, 0.05); + float k = TAU / lambda; + float w = k * uRingSpeed; + float envelope = abs(sin(k * (s - uSpan))); + float breath = abs(cos(w * uTime - k * uSpan)); + return mix(1.0, envelope * breath, clamp(uReflect * uStanding, 0.0, 1.0)); + } +`; + +const SHELL_VERTEX = /* glsl */ ` + ${SHELL_UNIFORMS} + ${SHELL_VARYINGS} + + #if SHELL_MODE == MODE_RING_TRAIN || SHELL_MODE == MODE_SUNDISC + attribute float aRing; + #endif + + ${noiseGLSL} + ${SHELL_SHAPE} + + void main() { + vec3 ax, e1, e2; + shellFrame(ax, e1, e2); + + vec3 here; + + #if SHELL_MODE == MODE_DOME || SHELL_MODE == MODE_PRESSURE + // One (t, a) grid, two silhouettes: sweep the polar angle a quarter turn + // and it is a dome standing on the floor, a half turn and it is a ball. + #if SHELL_MODE == MODE_DOME + float polar = position.x * (PI * 0.5); + #else + float polar = position.x * PI; + #endif + float ang = position.y * TAU; + vec3 rad = e1 * cos(ang) + e2 * sin(ang); + vec3 n = ax * cos(polar) + rad * sin(polar); + + // Billowing surface, scrolling as the shell expands. Sampled on the + // undisplaced direction so the grain stays welded to the shell rather + // than swimming through it. + float d = fbm4(n * uNoiseScale * (1.0 + uAge) + vec3(uSeed * 13.0) + - vec3(0.0, uTime * uNoiseSpeed, 0.0)); + vDisp = d; + + vec3 shape = ax * (cos(polar) * uHeight) + rad * sin(polar); + here = uOrigin + ax * uLift + (shape + n * d * uDisplace) * uRadius; + vNormalW = normalize(n); + vT = position.x; + + #elif SHELL_MODE == MODE_CONE + float t = position.x; + float ang = position.y * TAU; + vec3 rad = e1 * cos(ang) + e2 * sin(ang); + float rr = uRadius * pow(clamp(t, 0.0, 1.0), max(uConeCurve, 0.05)); + + float d = fbm4(rad * uNoiseScale + vec3(t * 2.7 + uSeed * 13.0) + - vec3(0.0, uTime * uNoiseSpeed, 0.0)); + vDisp = d; + + here = uOrigin + ax * (uLift + uSpan * t) + rad * (rr + d * uDisplace * uRadius); + // Analytic cone normal: the surface leans by dr/dz, so the rim term knows + // it is looking at a slope rather than at a cylinder. + float slope = uRadius * max(uConeCurve, 0.05) * + pow(max(t, 1e-3), max(uConeCurve, 0.05) - 1.0) / max(uSpan, 1e-3); + vNormalW = normalize(rad - ax * slope); + vT = t; + + #elif SHELL_MODE == MODE_RING_TRAIN + // Launched spacing metres apart and folded off the far end, so one train is + // both the outbound and the returning wave. + float lambda = max(uSpacing, 0.05); + float cycle = 2.0 * max(uSpan, 0.05); + float d = mod(uRingSpeed * uTime + aRing * lambda, cycle); + float s = uSpan - abs(d - uSpan); + float returning = step(uSpan, d); + + float amp = standingAmp(s); + float ang = position.y * TAU; + vec3 rad = e1 * cos(ang) + e2 * sin(ang); + float rr = uRadius * (1.0 + uSwell * amp) * mix(1.0, amp, clamp(uStanding, 0.0, 1.0)); + rr += (position.x - 0.5) * uRingThickness; + + here = uOrigin + ax * (uLift + s) + rad * max(rr, 0.0); + vT = position.x; + vAmp = amp * mix(1.0, uReflect, returning); + vS = s; + + #else /* SUNDISC */ + float band = position.x; + float ang = position.y * TAU; + float reach = max(uCoronaReach, 1.0); + vec3 rad = e1 * cos(ang) + e2 * sin(ang); + + here = uOrigin + ax * uLift + rad * (uRadius * reach * band); + // Radius in units of the disc, so 1.0 is exactly the rim wherever the + // slider puts it, and the plane coordinates the corona is sampled in. + vPolar = vec2(band * reach, ang); + vPlane = vec2(cos(ang), sin(ang)) * (band * reach); + vT = band; + #endif + + vec4 mv = viewMatrix * vec4(here, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const SHELL_FRAGMENT = /* glsl */ ` + ${SHELL_UNIFORMS} + ${SHELL_VARYINGS} + + uniform float uGlobalGlow; + uniform float uShaderIntensity; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + ${noiseGLSL} + ${commonGLSL} + + void main() { + vec3 color = vec3(0.0); + float alpha = 0.0; + + #if SHELL_MODE == MODE_RING_TRAIN + // A ring is a band across its own thickness. At a node vAmp is zero and + // the ring is simply not there — which is the point of the whole mode. + float band = 1.0 - abs(vT * 2.0 - 1.0); + float profile = pow(clamp(band, 0.0, 1.0), max(uRingSharp, 0.05)); + float amp = clamp(vAmp, 0.0, 1.0); + color = mix(uColorBody, uColorRim, profile); + color += uColorEdge * profile * amp * amp; + alpha = profile * mix(0.15, 1.0, amp); + + #elif SHELL_MODE == MODE_SUNDISC + float rn = vPolar.x; // 1.0 is the rim + + // The face: convection cells, plus a hot band inside the rim. + float cells = 1.0 - voronoi2(vPlane * uGranuleScale + uSeed * 7.0 + + vec2(uTime * uCoronaSpeed * 0.3)).x; + float face = 1.0 - smoothstep(1.0 - uRimWidth, 1.0, rn); + float ring = smoothstep(1.0 - uRimWidth, 1.0, rn) * (1.0 - smoothstep(1.0, 1.06, rn)); + + // The corona. Sampled in the *plane* and domain warped — sampling on + // atan(y, x) gives every radius along a bearing the same value and draws + // dead-straight spokes, which is a firework, not a corona. + vec2 q = vPlane * uCoronaScale; + q += vec2( + snoise(vec3(q * 0.7, uTime * uCoronaSpeed)), + snoise(vec3(q * 0.7 + 11.3, uTime * uCoronaSpeed + 4.1)) + ) * uCoronaWarp * uCoronaScale * 0.25; + float fil = ridged(vec3(q, uTime * uCoronaSpeed * 0.5 + uSeed), 4); + float lick = smoothstep(clamp(uCoronaSharp, 0.0, 0.98), 0.99, fil); + lick *= smoothstep(0.94, 1.02, rn) * + (1.0 - smoothstep(1.0, 1.0 + max(uCoronaLength, 1e-3), rn)); + + color = mix(uColorBody, uColorRim, face * (0.35 + uGranule * cells)); + color += uColorEdge * ring; + color += uColorCorona * lick * uCorona; + alpha = face * (uFill + uGranule * cells * 0.35) + ring + lick * uCorona * 0.6; + + #else + float fres = fresnelTerm(vViewDir, vNormalW, max(uRimPower, 0.1), 1.0) * uRim; + float heat = clamp(vDisp * 0.5 + 0.5, 0.0, 1.0); + // Dissolve the shell away over its life, torn by the same billow the + // vertex stage already computed rather than by a second noise field. + vec2 dis = dissolveMask(heat, uAge * (0.6 + uDissolve * 0.75) - 0.15, 0.3); + + #if SHELL_MODE == MODE_DOME + // The seal: a dome that fades out at the floor looks like it is + // floating. Brightening the last few degrees of the sweep is what + // plants it. + float seal = smoothstep(1.0 - max(uSealWidth, 1e-3), 1.0, vT); + color = mix(uColorBody, uColorRim, clamp(fres, 0.0, 1.0)); + color += uColorEdge * seal * uSeal; + alpha = (uFill * (0.35 + heat * 0.5) + fres + seal * uSeal * 0.35) * dis.x; + + #elif SHELL_MODE == MODE_CONE + // Hollow: the body contributes almost nothing and the silhouette does + // the work, so orbiting the cone shows you the far wall through it. + float lip = smoothstep(1.0 - max(uEdgeWidth, 1e-3), 1.0, vT); + color = mix(uColorBody, uColorRim, clamp(fres, 0.0, 1.0)); + color += uColorEdge * lip * uEdge; + alpha = (uFill * heat * 0.6 + fres + lip * uEdge * 0.4) * dis.x; + + #else /* PRESSURE */ + // Almost nothing is drawn. This is a front you read from the way the + // rim tightens as it passes, and filling it in at all defeats it. + color = mix(uColorBody, uColorRim, fres); + color += uColorEdge * dis.y * 0.6; + alpha = (uFill * 0.15 + fres) * dis.x; + #endif + #endif + + alpha *= uFade * uOpacity; + if (alpha < 0.004) discard; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + color *= uGlow * uGlobalGlow * mix(0.7, 1.0, uShaderIntensity); + gl_FragColor = vec4(clamp(color, 0.0, 64.0), clamp(alpha, 0.0, 1.0)); + } +`; + +/** One shell's material. Uniforms are pushed by `Shell#sync`. */ +export function createShellMaterial(mode = ShellMode.DOME) { + return new ShaderMaterial({ + defines: { SHELL_MODE: mode }, + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + uOrigin: { value: new Vector3() }, + uAxis: { value: new Vector3(0, 1, 0) }, + uSide: { value: new Vector3(1, 0, 0) }, + uSeed: { value: 0 }, + uAge: { value: 0 }, + uFade: { value: 1 }, + + uRadius: { value: SHELL_FIELDS.radius }, + uHeight: { value: SHELL_FIELDS.height }, + uSpan: { value: SHELL_FIELDS.span }, + uLift: { value: SHELL_FIELDS.lift }, + + uDisplace: { value: SHELL_FIELDS.displace }, + uNoiseScale: { value: SHELL_FIELDS.noiseScale }, + uNoiseSpeed: { value: SHELL_FIELDS.noiseSpeed }, + + uFill: { value: SHELL_FIELDS.fill }, + uRim: { value: SHELL_FIELDS.rim }, + uRimPower: { value: SHELL_FIELDS.rimPower }, + uSeal: { value: SHELL_FIELDS.seal }, + uSealWidth: { value: SHELL_FIELDS.sealWidth }, + uEdge: { value: SHELL_FIELDS.edge }, + uEdgeWidth: { value: SHELL_FIELDS.edgeWidth }, + uConeCurve: { value: SHELL_FIELDS.coneCurve }, + uDissolve: { value: SHELL_FIELDS.dissolve }, + + uSpacing: { value: SHELL_FIELDS.spacing }, + uRingSpeed: { value: SHELL_FIELDS.ringSpeed }, + uRingThickness: { value: SHELL_FIELDS.ringThickness }, + uRingSharp: { value: SHELL_FIELDS.ringSharp }, + uReflect: { value: SHELL_FIELDS.reflect }, + uStanding: { value: SHELL_FIELDS.standing }, + uSwell: { value: SHELL_FIELDS.swell }, + + uCoronaReach: { value: SHELL_FIELDS.coronaReach }, + uCorona: { value: SHELL_FIELDS.corona }, + uCoronaLength: { value: SHELL_FIELDS.coronaLength }, + uCoronaScale: { value: SHELL_FIELDS.coronaScale }, + uCoronaWarp: { value: SHELL_FIELDS.coronaWarp }, + uCoronaSpeed: { value: SHELL_FIELDS.coronaSpeed }, + uCoronaSharp: { value: SHELL_FIELDS.coronaSharp }, + uGranule: { value: SHELL_FIELDS.granule }, + uGranuleScale: { value: SHELL_FIELDS.granuleScale }, + uRimWidth: { value: SHELL_FIELDS.rimWidth }, + + uOpacity: { value: 1 }, + uGlow: { value: SHELL_FIELDS.glow }, + uSoftFade: { value: SHELL_FIELDS.softFade }, + uColorBody: { value: new Color(0.18, 0.44, 0.6) }, + uColorRim: { value: new Color(0.82, 0.94, 1) }, + uColorEdge: { value: new Color(1, 1, 1) }, + uColorCorona: { value: new Color(1, 0.7, 0.29) } + }), + vertexShader: SHELL_VERTEX, + fragmentShader: SHELL_FRAGMENT + }); +} + +/* ---------------------------------------------------------------- */ +/* Module scratch — I3 */ +/* ---------------------------------------------------------------- */ + +const WORLD_UP = new Vector3(0, 1, 0); +const _lat = new Vector3(); + +const DEFAULT_STATE = { + origin: null, + axis: null, // unit; defaults to world up + side: null, // unit reference for the angular seam + span: 0, // metres; 0 = fall back to the settings value + t: 0, // 0..1 through the shell's life + fade: 1, + seed: 0 +}; + +/* ---------------------------------------------------------------- */ +/* Shell */ +/* ---------------------------------------------------------------- */ + +export class Shell { + /** + * @param {object} [options] + * @param {number} [options.mode] ShellMode.* — compile-time + * @param {string} [options.prefix] settings key prefix ('shell') + * @param {number} [options.nodes] surface-grid samples along t + * @param {number} [options.sides] surface-grid facets around a + * @param {number} [options.rings] RING_TRAIN instance capacity + * @param {number} [options.segments] facets around one ring / the disc + * @param {number} [options.renderOrder] + */ + constructor({ + mode = ShellMode.DOME, + prefix = 'shell', + nodes = 48, + sides = 48, + rings = 24, + segments = 96, + renderOrder = 14 + } = {}) { + this.mode = mode; + this.prefix = prefix; + this.keys = shellKeys(prefix); + this.capacity = Math.max(1, Math.round(rings)); + + this.group = new Group(); + this.group.name = `Shell:${prefix}`; + this.group.matrixAutoUpdate = false; + + if (mode === ShellMode.RING_TRAIN) { + this.geometry = createBeamRingGeometry(this.capacity, segments); + } else if (mode === ShellMode.SUNDISC) { + // One instance, inner lip at the centre: the annulus is a disc. + this.capacity = 1; + this.geometry = createBeamRingGeometry(1, segments); + } else { + this.geometry = createBeamTubeGeometry(nodes, sides); + } + + this.material = createShellMaterial(mode); + this.mesh = new Mesh(this.geometry, this.material); + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(LAYER.VFX); + this.mesh.renderOrder = renderOrder; + this.group.add(this.mesh); + + /* --- the frame --- */ + this._origin = new Vector3(); + this._axis = new Vector3(0, 1, 0); + this._sideRef = new Vector3(1, 0, 0); + + /** Resolved dimensions, rewritten whole every frame. Never captured. */ + this._r = {}; + for (const name of FIELD_NAMES) { + if (typeof SHELL_FIELDS[name] === 'number') this._r[name] = SHELL_FIELDS[name]; + } + this._r.liveRadius = SHELL_FIELDS.radius; + this._r.liveSpan = SHELL_FIELDS.span; + this._r.count = this.capacity; + + /** Defaults for this mode, so a missing key falls back rather than NaNs. */ + this._defaults = shellDefaults(prefix, mode); + /** The settings contract is audited once, on the first frame. */ + this._checked = false; + } + + /** Draw calls this shell costs. One, always. */ + get drawCalls() { + return 1; + } + + set visible(value) { + this.group.visible = value; + } + + get visible() { + return this.group.visible; + } + + /* ------------------------------------------------------------------ */ + /* The frame update */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve everything from settings and push it into the material. + * + * @param {object} c the ability's live settings block (`settings.`) + * @param {object} state { origin, axis, side, span, t, fade, seed } — dice + * rolls and normalised time only + * @param {object} [g] `settings.global`; defaults to the live block. + * Taken from `config/globals.js` rather than from + * `config/settings.js`, because a settings module is + * allowed to spread this module's defaults and the + * round trip through `settings.js` closes a fatal + * import cycle — see `config/globals.js`. + */ + sync(c, state, g = globals) { + const s = state || DEFAULT_STATE; + const K = this.keys; + const D = this._defaults; + const r = this._r; + const u = this.material.uniforms; + + if (!this._checked) this._audit(c); + + if (s.origin) this._origin.copy(s.origin); + if (s.axis) this._axis.copy(s.axis); + if (s.side) this._sideRef.copy(s.side); + if (this._axis.lengthSq() < 1e-8) this._axis.copy(WORLD_UP); + this._axis.normalize(); + // The seam reference has to be off-axis or the frame degenerates; pick one + // rather than letting a caller who does not care see a NaN. + _lat.copy(this._sideRef).addScaledVector(this._axis, -this._sideRef.dot(this._axis)); + if (_lat.lengthSq() < 1e-8) { + this._sideRef.set(0, 0, 1).cross(this._axis); + if (this._sideRef.lengthSq() < 1e-8) this._sideRef.set(1, 0, 0); + this._sideRef.normalize(); + } + + const t = saturate(s.t ?? 0); + + /* --- the expansion. Both ends are sliders; t is the ability's clock. --- */ + r.radius = num(c[K.radius], D[K.radius]); + r.radiusEnd = num(c[K.radiusEnd], D[K.radiusEnd]); + r.expand = num(c[K.expand], D[K.expand]); + const eased = 1 - Math.pow(1 - t, Math.max(r.expand, 0.05)); + r.liveRadius = Math.max(r.radius + (r.radiusEnd - r.radius) * eased, 1e-4); + + r.height = num(c[K.height], D[K.height]); + r.span = num(c[K.span], D[K.span]); + r.liveSpan = Math.max(s.span || r.span, 0.05); + r.lift = num(c[K.lift], D[K.lift]); + + r.displace = num(c[K.displace], D[K.displace]) * g.turbulence * num(c[K.turbulence], D[K.turbulence]); + r.noiseScale = num(c[K.noiseScale], D[K.noiseScale]) * g.noiseFrequency; + r.noiseSpeed = num(c[K.noiseSpeed], D[K.noiseSpeed]) * g.noiseSpeed; + + r.fill = num(c[K.fill], D[K.fill]); + r.rim = num(c[K.rim], D[K.rim]) * g.fresnel; + r.rimPower = num(c[K.rimPower], D[K.rimPower]); + r.seal = num(c[K.seal], D[K.seal]); + r.sealWidth = num(c[K.sealWidth], D[K.sealWidth]); + r.edge = num(c[K.edge], D[K.edge]); + r.edgeWidth = num(c[K.edgeWidth], D[K.edgeWidth]); + r.coneCurve = num(c[K.coneCurve], D[K.coneCurve]); + r.dissolve = num(c[K.dissolve], D[K.dissolve]); + + r.count = clamp(Math.round(num(c[K.rings], D[K.rings])), 1, this.capacity); + r.spacing = num(c[K.spacing], D[K.spacing]); + r.ringSpeed = num(c[K.ringSpeed], D[K.ringSpeed]) * g.speed; + r.ringThickness = num(c[K.ringThickness], D[K.ringThickness]); + r.ringSharp = num(c[K.ringSharp], D[K.ringSharp]); + r.reflect = num(c[K.reflect], D[K.reflect]); + r.standing = num(c[K.standing], D[K.standing]); + r.swell = num(c[K.swell], D[K.swell]); + + r.coronaReach = num(c[K.coronaReach], D[K.coronaReach]); + r.corona = num(c[K.corona], D[K.corona]); + r.coronaLength = num(c[K.coronaLength], D[K.coronaLength]); + r.coronaScale = num(c[K.coronaScale], D[K.coronaScale]) * g.noiseFrequency; + r.coronaWarp = num(c[K.coronaWarp], D[K.coronaWarp]); + r.coronaSpeed = num(c[K.coronaSpeed], D[K.coronaSpeed]) * g.noiseSpeed; + r.coronaSharp = num(c[K.coronaSharp], D[K.coronaSharp]); + r.granule = num(c[K.granule], D[K.granule]); + r.granuleScale = num(c[K.granuleScale], D[K.granuleScale]) * g.noiseFrequency; + r.rimWidth = num(c[K.rimWidth], D[K.rimWidth]); + + r.opacity = num(c[K.opacity], D[K.opacity]) * g.opacity; + r.glow = num(c[K.glow], D[K.glow]); + r.softFade = num(c[K.softFade], D[K.softFade]); + r.age = t; + r.seed = s.seed ?? 0; + r.fade = s.fade ?? 1; + + /* --- push --- */ + u.uOrigin.value.copy(this._origin); + u.uAxis.value.copy(this._axis); + u.uSide.value.copy(this._sideRef); + u.uSeed.value = r.seed; + u.uAge.value = r.age; + u.uFade.value = r.fade; + + u.uRadius.value = r.liveRadius; + u.uHeight.value = r.height; + u.uSpan.value = r.liveSpan; + u.uLift.value = r.lift; + + u.uDisplace.value = r.displace; + u.uNoiseScale.value = r.noiseScale; + u.uNoiseSpeed.value = r.noiseSpeed; + + u.uFill.value = r.fill; + u.uRim.value = r.rim; + u.uRimPower.value = r.rimPower; + u.uSeal.value = r.seal; + u.uSealWidth.value = r.sealWidth; + u.uEdge.value = r.edge; + u.uEdgeWidth.value = r.edgeWidth; + u.uConeCurve.value = r.coneCurve; + u.uDissolve.value = r.dissolve; + + u.uSpacing.value = r.spacing; + u.uRingSpeed.value = r.ringSpeed; + u.uRingThickness.value = r.ringThickness; + u.uRingSharp.value = r.ringSharp; + u.uReflect.value = r.reflect; + u.uStanding.value = r.standing; + u.uSwell.value = r.swell; + + u.uCoronaReach.value = r.coronaReach; + u.uCorona.value = r.corona; + u.uCoronaLength.value = r.coronaLength; + u.uCoronaScale.value = r.coronaScale; + u.uCoronaWarp.value = r.coronaWarp; + u.uCoronaSpeed.value = r.coronaSpeed; + u.uCoronaSharp.value = r.coronaSharp; + u.uGranule.value = r.granule; + u.uGranuleScale.value = r.granuleScale; + u.uRimWidth.value = r.rimWidth; + + u.uOpacity.value = r.opacity; + u.uGlow.value = r.glow; + u.uSoftFade.value = r.softFade; + u.uColorBody.value.copy(getColor(str(c[K.colorBody], D[K.colorBody]))); + u.uColorRim.value.copy(getColor(str(c[K.colorRim], D[K.colorRim]))); + u.uColorEdge.value.copy(getColor(str(c[K.colorEdge], D[K.colorEdge]))); + u.uColorCorona.value.copy(getColor(str(c[K.colorCorona], D[K.colorCorona]))); + + if (this.mode === ShellMode.RING_TRAIN) this.geometry.instanceCount = r.count; + } + + /* ------------------------------------------------------------------ */ + /* The queries */ + /* ------------------------------------------------------------------ */ + + /** The shell's current radius, metres. Live — what the rim is standing on. */ + get radius() { + return this._r.liveRadius; + } + + /** The run length, metres: the cone's length or the ring train's line. */ + get span() { + return this._r.liveSpan; + } + + /** Instances currently drawn. HUD readout. */ + get instanceCount() { + return this.mode === ShellMode.RING_TRAIN ? this._r.count : 1; + } + + /** + * The standing-wave amplitude at `s` metres along the run, 0..1. + * + * The JS mirror of `standingAmp()` in the shader, for the same reason + * `Tube#radiusAt` exists: an ability that wants to put a dust puff or a burst + * where the air is actually being compressed has to be able to ask. + */ + standingAt(s) { + const r = this._r; + const lambda = Math.max(r.spacing, 0.05); + const k = TAU / lambda; + const w = k * r.ringSpeed; + const envelope = Math.abs(Math.sin(k * (s - r.liveSpan))); + const breath = Math.abs(Math.cos(w * frame.uTime.value - k * r.liveSpan)); + const mixAmount = saturate(r.reflect * r.standing); + return 1 + (envelope * breath - 1) * mixAmount; + } + + /** Metres between adjacent nodes: half a wavelength. */ + get nodeSpacing() { + return Math.max(this._r.spacing, 0.05) * 0.5; + } + + /** How many nodes fit on the run, counting the one at the far end. */ + get nodeCount() { + return Math.max(1, Math.floor(this._r.liveSpan / this.nodeSpacing) + 1); + } + + /** + * World position of node `i`, counted back from the reflecting far end — + * node 0 is the far end itself, which is always a node for a fixed boundary. + */ + nodePosition(i, out) { + const s = Math.max(this._r.liveSpan - i * this.nodeSpacing, 0); + return out + .copy(this._origin) + .addScaledVector(this._axis, this._r.lift + s); + } + + /** + * The wavelength that fits exactly `n` half-waves on the current run, metres. + * + * Feed it back into `shellSpacing` and the nodes stop drifting and stand + * still — which is the difference between "some rings are dimmer" and "this + * line is resonating". + */ + resonantSpacing(n) { + return (2 * this._r.liveSpan) / Math.max(Math.round(n), 1); + } + + /** + * Warn once about a settings block that has not been given the contract — + * see the same audit in `vfx/Tube.js` for why this warns and falls back + * rather than throwing. + */ + _audit(c) { + this._checked = true; + auditBlock( + `Shell:${this.prefix}`, + this.keys, + FIELD_NAMES, + c, + `shellDefaults('${this.prefix}', ShellMode.${SHELL_MODE_NAMES[this.mode]})` + ); + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + this.group.parent?.remove(this.group); + } +} diff --git a/src/vfx/Swarm.js b/src/vfx/Swarm.js new file mode 100644 index 0000000..560a390 --- /dev/null +++ b/src/vfx/Swarm.js @@ -0,0 +1,963 @@ +import { + AdditiveBlending, + BufferAttribute, + Color, + DoubleSide, + Group, + InstancedBufferAttribute, + InstancedBufferGeometry, + Mesh, + NormalBlending, + ShaderMaterial, + Sphere, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { disruptGLSL, disruptUniforms } from './SceneHooks.js'; +import { LAYER } from '../core/Layers.js'; +import { getColor } from '../utils/color.js'; + +/** + * What an agent looks like. Drawn as a signed-distance silhouette in the + * fragment shader — there is no atlas, no alpha map and no sprite sheet. + */ +export const Silhouette = Object.freeze({ + BIRD: 0, // swept wings with a real dihedral fold, body along the heading + LEAF: 1, // tapered lens with a midrib and a curl across the chord + CARD: 2, // rounded plate carrying a procedural glyph + DROPLET: 3, // bulb and tail, tail toward the back + MOTE: 4 // soft round, the cheapest thing here +}); + +/** Where the flock's lead point is, and how it moves. */ +export const LeadPath = Object.freeze({ + /** A fixed point — a flock milling around a target. */ + POINT: 0, + /** Down the cast line, with an optional loft. */ + LINE: 1, + /** A circle about the far point — a cyclone's debris, a summoning ring. */ + ORBIT: 2 +}); + +/* ---------------------------------------------------------------------- */ +/* The flock */ +/* ---------------------------------------------------------------------- */ + +/** + * The whole flock lives in this vertex shader. + * + * A vertex arrives as `(x, y)` on a three-column card plus one per-instance + * seed and one per-instance index, and leaves as a world position with a full + * orientation. Nothing about any agent exists on the CPU — no velocities, no + * neighbour lists, no state that survives a frame — which is what lets four + * hundred agents cost one draw call and nothing at all in the update loop, and + * what lets a slider reshape a flock that is already in the air with the clock + * stopped. + * + * Three boid behaviours, none of them simulated: + * + * 1. **Cohesion** is a *lag*. Every agent's home is the lead point as it was + * `lag` seconds ago, and because the lead is parametric that is one + * evaluation, not a history buffer. Agents therefore string out behind the + * lead and pour round its corners a beat late, which is most of what + * cohesion looks like from outside. + * 2. **Separation** is a *lattice*. Each agent owns one cell of an + * `LX × LY × LZ` grid, decoded from its instance index and rotated per cast + * by a wrap-around shift — a bijection, so two agents cannot claim the same + * cell. Separation is normally the n² term in a boid solver; here it is + * free, and the price is that spacing is authored rather than emergent. + * The lattice's third axis is not a distance, it is the **lag**, so the + * formation is genuinely three-dimensional: wide, thin, and strung out. + * 3. **Banking** is the second derivative. Position is sampled at t, t-h and + * t-2h, which gives a velocity and an acceleration; the component of that + * acceleration along the agent's own wing axis is rolled into the card. + * This is the one that matters. A bird that turns without banking reads as + * a leaf, and a leaf that banks reads as a bird — the roll *is* the + * species. + * + * The first version separated agents with a per-agent noise offset instead of + * the lattice. It gave a cloud, not a flock: the offsets are independent, so + * agents drift through one another and the whole thing reads as smoke with + * wings. Distinct cells are what buys the sense of a formation holding. + */ +const SWARM_VERTEX = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + uniform float uTime; + uniform float uSeed; + + /* --- the lead --- */ + uniform int uLeadMode; + uniform vec3 uLeadA; // where the flock comes from + uniform vec3 uLeadB; // where it is going (or the orbit's centre) + uniform vec3 uForward; // unit heading of the cast + uniform vec3 uSideAxis; // unit lateral + uniform float uLeadRise; // metres the lead lofts at mid-span + uniform float uLeadS; // where the lead is now, 0..1 along its path + uniform float uLeadRate; // d(s)/dt, so the shader can rewind the lead + uniform float uOrbitRadius; // metres + uniform float uOrbitHeight; // metres + uniform float uOrbitTurns; // turns per unit of s + + /* --- the formation --- */ + uniform vec3 uLattice; // cells across, up and back (whole numbers) + uniform float uSpacingSide; // metres between lateral cells + uniform float uSpacingUp; // metres between vertical cells + uniform float uLag; // seconds the back rank trails the lead by + uniform float uJitter; // metres of per-agent slop off its cell + uniform float uChurn; // radians/second the formation rolls + uniform float uBreathe; // fraction the formation swells by + uniform float uBreatheRate; // radians/second + uniform float uWander; // metres of curl-noise drift + uniform float uWanderScale; // features per metre + uniform float uWanderSpeed; + uniform float uGather; // 0 collapses every agent onto the lead's path + + /* --- the body --- */ + uniform float uSize; // metres, nose to tail + uniform float uAspect; // span / length + uniform float uSizeJitter; // ±fraction + uniform float uBillboard; // 0 agent frame, 1 camera facing + uniform float uBank; // radians of roll per m/s² of lateral accel + uniform float uBankMax; // radians + uniform float uDihedral; // wing fold, fraction of size + uniform float uFlapRate; // beats/second + uniform float uCurl; // leaf curl across the chord, fraction of size + uniform float uEdgeStretch; // how much an edge-on card is grown, ≥1 + uniform float uReveal; // 0..1, agents appear as this passes their dice + uniform float uRevealSpread; // 0..1 width of that wave + uniform vec3 uLightDir; // world-space direction toward the key light + + attribute float aSeed; // 0..1, the only thing an agent carries + attribute float aIndex; // its slot, used to claim a lattice cell + + ${disruptGLSL} + + varying float vDisrupt; // spellbreak's field — see vfx/SceneHooks.js + varying vec2 vUv; + varying float vSeed; + varying float vFacing; // |n · v| — 0 when the card is edge-on + varying float vNdl; + varying float vAlpha; + varying float vViewZ; + + ${noiseGLSL} + + /** Normalise, or fall back — a zero-length basis vector is a NaN silhouette. */ + vec3 safeNormalize(vec3 v, vec3 fallback) { + float l2 = dot(v, v); + return l2 > 1e-8 ? v * inversesqrt(l2) : fallback; + } + + /** The lead point at path parameter s. */ + vec3 leadAt(float s) { + if (uLeadMode == 2) { + float a = s * TAU * uOrbitTurns; + return uLeadB + vec3(cos(a), 0.0, sin(a)) * uOrbitRadius + vec3(0.0, uOrbitHeight, 0.0); + } + if (uLeadMode == 1) { + // Allowed to run behind the start so the tail of the flock is still + // leaving the hand while the head is downrange; never past the target, + // because the target is where the ability's impact is. + float t = clamp(s, -0.5, 1.0); + vec3 p = mix(uLeadA, uLeadB, t); + p.y += uLeadRise * sin(PI * clamp(t, 0.0, 1.0)); + return p; + } + return uLeadB; + } + + /** + * Which cell of the lattice this agent owns. + * + * Decoded from the instance index so it is unique by construction, then + * shifted per cast by a wrap-around offset — still a bijection, so two casts + * lay the same formation out differently without ever doubling up. + */ + vec3 cellOf(float idx, vec3 L) { + vec3 cell = vec3( + mod(idx, L.x), + mod(floor(idx / L.x), L.y), + mod(floor(idx / (L.x * L.y)), L.z) + ); + vec3 shift = floor(hash31(uSeed * 13.0 + 0.7) * L); + return mod(cell + shift, L); + } + + /** + * Where an agent is, dt seconds from now (dt ≤ 0 for the finite difference). + */ + vec3 agentAt(vec3 dice, float idx, float dt) { + vec3 L = max(vec3(1.0), floor(uLattice)); + vec3 cell = cellOf(idx, L); + vec3 centred = cell - (L - 1.0) * 0.5; + + // The third lattice axis is time, not distance: rank 0 rides the lead, the + // back rank is uLag seconds behind it. + float lagN = L.z > 1.0 ? cell.z / (L.z - 1.0) : 0.0; + float lag = uLag * lagN; + + float t = uTime + dt; + vec3 home = leadAt(uLeadS + (dt - lag) * uLeadRate); + + vec2 plane = vec2(centred.x * uSpacingSide, centred.y * uSpacingUp); + plane += (dice.xy - 0.5) * (2.0 * uJitter); + // The formation rolls as a whole, so the silhouette of the flock keeps + // changing without any agent leaving its cell. + plane = rot2(t * uChurn + dice.z * TAU * 0.15 + uSeed) * plane; + plane *= 1.0 + uBreathe * sin(t * uBreatheRate + dice.y * TAU); + + vec3 pos = home + uSideAxis * plane.x + vec3(0.0, plane.y, 0.0); + // Keep uWander under half the cell spacing or the lattice's separation + // guarantee stops being a guarantee. + pos += curlNoise(pos * uWanderScale + vec3(0.0, t * uWanderSpeed, 0.0) + dice.z * 17.0) * uWander; + + return mix(home, pos, clamp(uGather, 0.0, 1.0)); + } + + void main() { + vec3 dice = hash31(aSeed * 91.7 + 3.1); + float idx = aIndex; + + // Three samples: position, velocity, acceleration. The step is a fraction + // of a second rather than the frame delta on purpose — the bank must not + // change when the frame rate does. + const float H = 0.06; + vec3 p0 = agentAt(dice, idx, 0.0); + vec3 p1 = agentAt(dice, idx, -H); + vec3 p2 = agentAt(dice, idx, -2.0 * H); + + vec3 vel = (p0 - p1) / H; + vec3 acc = (p0 - 2.0 * p1 + p2) / (H * H); + + vec3 forward = safeNormalize(vel, uForward); + vec3 right = safeNormalize(cross(forward, vec3(0.0, 1.0, 0.0)), uSideAxis); + vec3 lift0 = cross(right, forward); + + // Bank into the turn. Negative because a bird turning to its right drops + // its right wing — get the sign backwards and the whole flock reads as + // sliding rather than turning. + float bank = clamp(-atan(dot(acc, right) * uBank), -uBankMax, uBankMax); + // Only the wing axis is rolled; the normal follows from the cross product + // below, so the card tips as one piece without a second basis to keep in + // step with the first. + vec3 wing = right * cos(bank) + lift0 * sin(bank); + + // Camera basis straight off the view matrix's rows. + vec3 camRight = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]); + vec3 camUp = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]); + + float bb = clamp(uBillboard, 0.0, 1.0); + vec3 axisX = safeNormalize(mix(wing, camRight, bb), camRight); + vec3 axisY = mix(forward, camUp, bb); + axisY = safeNormalize(axisY - axisX * dot(axisX, axisY), camUp); + vec3 normal = safeNormalize(cross(axisX, axisY), vec3(0.0, 1.0, 0.0)); + + vec3 viewDir = safeNormalize(cameraPosition - p0, vec3(0.0, 0.0, 1.0)); + float facing = abs(dot(normal, viewDir)); + + float size = uSize * (1.0 + uSizeJitter * (dice.x - 0.5) * 2.0); + // An edge-on card is thinner than a pixel and simply disappears; growing it + // as it turns is what turns the vanishing act into a bright line. + size *= mix(max(uEdgeStretch, 1.0), 1.0, facing); + + vec2 corner = position.xy; + vec3 world = p0 + axisX * (corner.x * size * uAspect * 0.5) + axisY * (corner.y * size * 0.5); + + // A real fold, not a painted one: the middle column stays on the plane and + // the wing columns swing out of it, so the bird genuinely goes edge-on at + // the top of its stroke. + float flap = sin(TAU * (uFlapRate * uTime + dice.y)); + world += normal * (uDihedral * abs(corner.x) * flap * size); + world += normal * (uCurl * corner.x * corner.x * size); + + float threshold = dice.z * (1.0 - clamp(uRevealSpread, 0.0, 1.0)); + vAlpha = smoothstep(threshold, threshold + max(uRevealSpread, 1e-3), clamp(uReveal, 0.0, 1.0)); + + vUv = uv; + vSeed = aSeed; + vFacing = facing; + vNdl = abs(dot(normal, normalize(uLightDir))); + + // Opt-in to the disruption field. The mesh matrix is identity and the + // vertex shader has already placed the card in world space, so this + // position IS the world point and needs no further transform. + vDisrupt = disruptAt(world); + + vec4 mv = modelViewMatrix * vec4(world, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +/** + * The silhouettes. + * + * All five are distance fields evaluated in the card's own -1..1 space, so they + * stay crisp at any size and cost nothing to author. `uShape` is a uniform + * rather than a `#define` because switching silhouette is a dropdown in the + * editor, and a recompile in the middle of a drag is a stall you can feel. + */ +const SWARM_FRAGMENT = /* glsl */ ` + uniform float uTime; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform float uGlobalGlow; + uniform vec3 uLightDir; + + uniform int uShape; + uniform float uSweep; // BIRD: how far the wings rake back + uniform float uGlyphWeight; // CARD: stroke half-width, card units + uniform float uGlyphStrokes; // CARD: strokes in the walk + uniform float uCardFrame; // CARD: brightness of the plate's border + uniform float uEdgeGain; // emission multiplier when edge-on + uniform float uLit; // 0 emissive, 1 wrapped diffuse + + uniform vec3 uColorA; + uniform vec3 uColorB; + uniform vec3 uColorC; + uniform vec3 uColorD; + uniform float uTint; // where in the gradient the flock sits + uniform float uTintJitter; // ±per-agent walk along it + uniform float uTintAlong; // extra walk from head to tail + uniform float uOpacity; + uniform float uGlow; + uniform float uSoftFade; // metres of depth feather + + ${disruptGLSL} + + varying float vDisrupt; + varying vec2 vUv; + varying float vSeed; + varying float vFacing; + varying float vNdl; + varying float vAlpha; + varying float vViewZ; + + ${noiseGLSL} + ${commonGLSL} + + float segDist(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a; + vec2 ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0); + return length(pa - ba * h); + } + + /* --- BIRD ---------------------------------------------------------- */ + float birdMask(vec2 c) { + float x = abs(c.x); + float y = c.y; + // Leading and trailing edges both rake back with the span, which is what + // makes a two-triangle card read as a wing rather than as a lozenge. + float lead = 0.62 - uSweep * x + 0.22 * x * x; + float trail = -0.10 - (uSweep + 0.35) * x; + float wing = smoothstep(0.0, 0.07, y - trail) * smoothstep(0.0, 0.07, lead - y); + float body = smoothstep(0.34, 0.14, length(vec2(c.x * 3.4, (y - 0.08) * 0.8))); + return clamp(max(wing, body), 0.0, 1.0); + } + + /* --- LEAF ---------------------------------------------------------- */ + float leafMask(vec2 c) { + float w = max(0.0, 1.0 - c.y * c.y); + float body = smoothstep(w * 0.66, w * 0.30, abs(c.x)); + float rib = smoothstep(0.05, 0.0, abs(c.x)) * 0.5; + return clamp(body - rib * 0.35, 0.0, 1.0); + } + + /* --- CARD ---------------------------------------------------------- */ + /** + * One lattice point of a 3 × 5 grid inside the card. + * + * The grid is what makes the marks read as *writing*. An unconstrained random + * walk gives scribble; snapping the endpoints to a coarse lattice gives + * strokes that share terminals and angles across every card in the storm, and + * a few hundred of those look like an alphabet nobody has taught you. + */ + vec2 glyphNode(float h) { + float ix = floor(hash11(h) * 3.0); + float iy = floor(hash11(h + 11.7) * 5.0); + return vec2((ix - 1.0) * 0.40, (iy - 2.0) * 0.27); + } + + float glyphMask(vec2 p, float seed) { + float d = 1e3; + vec2 prev = glyphNode(seed); + float strokes = clamp(uGlyphStrokes, 1.0, 6.0); + // Masked rather than broken out of: GLSL ES 1.00 wants a constant loop + // bound, and a break on a uniform is the kind of thing that compiles on + // the desktop driver and fails on the one the player has. + for (int i = 1; i <= 6; i++) { + vec2 next = glyphNode(seed + float(i) * 3.77); + // A zero-length stroke is a blot; nudge the degenerate case sideways. + if (dot(next - prev, next - prev) < 1e-4) next += vec2(0.40, 0.0); + float on = step(float(i), strokes); + d = min(d, mix(1e3, segDist(p, prev, next), on)); + prev = mix(prev, next, on); + } + d = min(d, length(p - prev) - 0.03); + float weight = max(uGlyphWeight, 1e-3); + return smoothstep(weight, weight * 0.35, d); + } + + float cardMask(vec2 c, float seed) { + vec2 q = abs(c) - vec2(0.74, 0.92); + float plate = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - 0.08; + float frame = smoothstep(0.035, 0.0, abs(plate) - 0.014) * uCardFrame; + float mark = glyphMask(c * vec2(1.0, 0.86), seed * 37.0 + 1.3); + return clamp(max(frame, mark), 0.0, 1.0); + } + + /* --- DROPLET ------------------------------------------------------- */ + float dropletMask(vec2 c) { + float bulb = length(c - vec2(0.0, -0.34)) - 0.54; + float t = clamp((c.y + 0.34) / 1.34, 0.0, 1.0); + float taper = abs(c.x) - 0.54 * pow(1.0 - t, 1.5); + float d = min(bulb, max(taper, -(c.y + 0.34))); + return smoothstep(0.07, -0.02, d); + } + + void main() { + if (vAlpha < 0.004) discard; + + vec2 c = (vUv - 0.5) * 2.0; + + float mask; + if (uShape == 0) mask = birdMask(c); + else if (uShape == 1) mask = leafMask(c); + else if (uShape == 2) mask = cardMask(c, vSeed); + else if (uShape == 3) mask = dropletMask(c); + else mask = smoothstep(1.0, 0.0, length(c)); + + if (mask <= 0.004) discard; + + float alpha = mask * vAlpha * uOpacity; + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.004) discard; + + float t = clamp(uTint + uTintJitter * (vSeed - 0.5) * 2.0 + (1.0 - vUv.y) * uTintAlong, 0.0, 1.0); + vec3 colour = gradient4(uColorA, uColorB, uColorC, uColorD, t); + + // Wrapped, two-sided diffuse: a leaf lit from behind is still a leaf, so the + // term uses |n·l| rather than a clamped dot. + colour *= mix(1.0, mix(0.42, 1.3, vNdl), clamp(uLit, 0.0, 1.0)); + // Edge-on is where the storm flickers: the card collapses to a line and the + // line has to be *brighter* than the plate was, or it just reads as a gap. + colour *= mix(max(uEdgeGain, 0.0), 1.0, vFacing); + colour *= uGlow * uGlobalGlow; + disruptShade(colour, alpha, vDisrupt, gl_FragCoord.xy); + + gl_FragColor = vec4(colour, clamp(alpha, 0.0, 1.0)); + } +`; + +/* ---------------------------------------------------------------------- */ +/* Scratch — the frame allocates nothing */ +/* ---------------------------------------------------------------------- */ + +const _leadA = new Vector3(); +const _leadB = new Vector3(); + +/* ---------------------------------------------------------------------- */ +/* Swarm */ +/* ---------------------------------------------------------------------- */ + +/** + * **What it draws.** Up to a few hundred instanced agents on a + * shader-evaluated flock: ember birds, falling leaves, a blizzard of glyph + * cards, blood droplets, drifting motes. + * + * **Draw calls.** One. However many agents, however many silhouettes — as long + * as they share a silhouette. Two flocks that must differ in *shape* are two + * `Swarm`s and two draw calls. + * + * **What it reads from settings.** Nothing directly. `update(now, params)` is + * handed a live block every frame and resolves every key as `p.key ?? default`, + * so you can pass `settings[id]` outright or fill a scratch object from it. + * `swarmParams()` returns the full key list with defaults and units. Every + * metre in there is live: drag `spacingSide` with **P** held and the formation + * opens out mid-flight. + * + * **The one rule for using it well.** The agents carry a seed and an index and + * nothing else. If you catch yourself wanting to store an agent's velocity, the + * answer is another `agentAt()` evaluation at `-H` — the flock is a closed-form + * function of time and that is the only reason it is free. + * + * --- + * + * ## Sizing the lattice + * + * `latticeX × latticeY × latticeZ` is the number of *distinct* slots. Ask for + * more agents than that and they start doubling up, which is the one way the + * separation guarantee breaks. `latticeZ` is the number of ranks strung out + * behind the lead, and `lag` is how far back the last of them sits in seconds — + * so a long, thin skein is `4 × 2 × 16`, and a wall of glyphs coming at you is + * `14 × 10 × 2`. + */ +export class Swarm { + /** + * @param {object} options + * @param {number} [options.capacity] hard ceiling on agents + * @param {number} [options.silhouette] Silhouette.* — the initial shape + * @param {boolean} [options.additive] additive (embers) vs normal (leaves) + * @param {number} [options.renderOrder] + */ + constructor( + parent, + { capacity = 256, silhouette = Silhouette.BIRD, additive = true, renderOrder = 12 } = {} + ) { + this.capacity = Math.max(1, Math.round(capacity)); + + this.group = new Group(); + this.group.name = 'Swarm'; + this.group.matrixAutoUpdate = false; + parent?.add(this.group); + + /* + * Three columns, two rows. The middle column is what makes the wing fold + * possible: a four-vertex quad can only ever be flat, and a flat bird does + * not flap — it strobes. Four triangles is a rounding error next to the + * fill cost of the silhouette. + */ + const columns = [-1, 0, 1]; + const positions = new Float32Array(6 * 3); + const uvs = new Float32Array(6 * 2); + for (let c = 0; c < 3; c++) { + for (let r = 0; r < 2; r++) { + const v = (c * 2 + r) * 3; + const t = (c * 2 + r) * 2; + positions[v + 0] = columns[c]; + positions[v + 1] = r === 0 ? -1 : 1; + positions[v + 2] = 0; + uvs[t + 0] = (columns[c] + 1) * 0.5; + uvs[t + 1] = r; + } + } + const indices = new Uint16Array(12); + for (let c = 0; c < 2; c++) { + const a = c * 2; + const o = c * 6; + indices[o + 0] = a; + indices[o + 1] = a + 2; + indices[o + 2] = a + 1; + indices[o + 3] = a + 1; + indices[o + 4] = a + 2; + indices[o + 5] = a + 3; + } + + this.seeds = new InstancedBufferAttribute(new Float32Array(this.capacity), 1); + const slots = new Float32Array(this.capacity); + for (let i = 0; i < this.capacity; i++) { + this.seeds.array[i] = Math.random(); + slots[i] = i; + } + + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setAttribute('uv', new BufferAttribute(uvs, 2)); + geometry.setAttribute('aSeed', this.seeds); + geometry.setAttribute('aIndex', new InstancedBufferAttribute(slots, 1)); + geometry.setIndex(new BufferAttribute(indices, 1)); + geometry.instanceCount = 0; + // Placed in world space by the vertex shader; its own bounds mean nothing. + geometry.boundingSphere = new Sphere(new Vector3(), 1e4); + this.geometry = geometry; + + this.material = new ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: additive ? AdditiveBlending : NormalBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + // Opt-in to vfx/SceneHooks.js's disruption field — shared boxes, by + // identity. See the README's SceneHooks section for the three lines of + // GLSL that go with it. + ...disruptUniforms(), + uSeed: { value: 0 }, + + uLeadMode: { value: LeadPath.LINE }, + uLeadA: { value: new Vector3() }, + uLeadB: { value: new Vector3(0, 0, 1) }, + uForward: { value: new Vector3(0, 0, 1) }, + uSideAxis: { value: new Vector3(1, 0, 0) }, + uLeadRise: { value: 0 }, + uLeadS: { value: 0 }, + uLeadRate: { value: 1 }, + uOrbitRadius: { value: 3 }, + uOrbitHeight: { value: 1.5 }, + uOrbitTurns: { value: 1 }, + + uLattice: { value: new Vector3(6, 3, 8) }, + uSpacingSide: { value: 0.5 }, + uSpacingUp: { value: 0.35 }, + uLag: { value: 0.5 }, + uJitter: { value: 0.12 }, + uChurn: { value: 0.7 }, + uBreathe: { value: 0.18 }, + uBreatheRate: { value: 1.6 }, + uWander: { value: 0.14 }, + uWanderScale: { value: 0.5 }, + uWanderSpeed: { value: 0.5 }, + uGather: { value: 1 }, + + uSize: { value: 0.32 }, + uAspect: { value: 1.5 }, + uSizeJitter: { value: 0.3 }, + uBillboard: { value: 0 }, + uBank: { value: 0.06 }, + uBankMax: { value: 1.2 }, + uDihedral: { value: 0.3 }, + uFlapRate: { value: 5 }, + uCurl: { value: 0 }, + uEdgeStretch: { value: 1.6 }, + uReveal: { value: 1 }, + uRevealSpread: { value: 0.35 }, + + uShape: { value: silhouette }, + uSweep: { value: 0.85 }, + uGlyphWeight: { value: 0.07 }, + uGlyphStrokes: { value: 4 }, + uCardFrame: { value: 0.35 }, + uEdgeGain: { value: 2.4 }, + uLit: { value: 0 }, + + uColorA: { value: new Color('#ffe6b0') }, + uColorB: { value: new Color('#ff8a3c') }, + uColorC: { value: new Color('#8a2a0c') }, + uColorD: { value: new Color('#1a0a06') }, + uTint: { value: 0.2 }, + uTintJitter: { value: 0.25 }, + uTintAlong: { value: 0.35 }, + uOpacity: { value: 1 }, + uGlow: { value: 1.3 }, + uSoftFade: { value: 0.35 } + }), + vertexShader: SWARM_VERTEX, + fragmentShader: SWARM_FRAGMENT + }); + + this.mesh = new Mesh(geometry, this.material); + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.layers.set(LAYER.VFX); + this.mesh.renderOrder = renderOrder; + this.group.add(this.mesh); + + /* --- the basis --------------------------------------------------- */ + + this._origin = new Vector3(); + this._direction = new Vector3(0, 0, 1); + this._side = new Vector3(1, 0, 0); + this._length = 1; + + /** + * The last block `update()` resolved, defaults filled in. Owned per + * instance, written into, never replaced — no frame allocates. + */ + this._p = swarmParams(); + this._p.silhouette = silhouette; + this.seed = 0; + this.liveAgents = 0; + } + + get uniforms() { + return this.material.uniforms; + } + + /** Agents drawn on the last update. → `Ability#instanceCount`. */ + get count() { + return this.liveAgents; + } + + /** One, however many agents. */ + get drawCalls() { + return 1; + } + + /** + * Four pickers, none derived from another. + * + * Takes `THREE.Color`s or `#rrggbb` strings straight out of a settings block; + * the strings go through the memoised `getColor`, so calling this every frame + * costs four copies and no allocation. + */ + setColors(a, b, c, d) { + const u = this.material.uniforms; + u.uColorA.value.copy(typeof a === 'string' ? getColor(a) : a); + u.uColorB.value.copy(typeof b === 'string' ? getColor(b) : b); + u.uColorC.value.copy(typeof c === 'string' ? getColor(c) : c); + const tail = d ?? c; + u.uColorD.value.copy(typeof tail === 'string' ? getColor(tail) : tail); + return this; + } + + /** The cast's frame. Called once per frame by the ability. */ + setBasis(origin, direction, side, length) { + this._origin.copy(origin); + this._direction.copy(direction); + this._side.copy(side); + this._length = Math.max(0.01, length); + return this; + } + + /** + * Re-roll the per-agent seeds. Call from `onSpawn` and nowhere else. + * + * The seeds are the *only* thing an agent carries, and they are unitless. + * Everything else — where its cell is, how far it lags, how big it is, how + * hard it banks — is resolved from `params` in the vertex shader every frame. + * + * @param {number} [seed] per-cast seed; shifts the whole lattice + */ + roll(seed = Math.random() * 100) { + this.seed = seed; + this.material.uniforms.uSeed.value = seed; + for (let i = 0; i < this.capacity; i++) this.seeds.array[i] = Math.random(); + this.seeds.needsUpdate = true; + return this; + } + + /** Hide the flock. Leaves the instance reusable — the pooling contract. */ + reset() { + this.liveAgents = 0; + this.geometry.instanceCount = 0; + this.mesh.visible = false; + } + + /** + * Fill `_p` from the caller's block, defaults where it is silent. + * + * A `for...in` over sixty keys per frame is a rounding error next to one + * uniform upload, and it is what lets an ability hand this module its raw + * `settings[id]` — the version of the contract that cannot be got wrong. + */ + _resolve(params) { + const p = this._p; + for (const key in DEFAULT_PARAMS) { + const value = params[key]; + p[key] = value === undefined ? DEFAULT_PARAMS[key] : value; + } + return p; + } + + /** + * Push the live params into the uniforms. + * + * @param {number} _now seconds since the cast began. Accepted for symmetry + * with `Projectile`; the flock's own clock is the shared `uTime`, because a + * flock is a standing motion rather than an event — its churn should not + * restart every time the ability is cast. + * @param {object} params live block; every key falls back to `swarmParams()` + * where it is absent. + */ + update(_now, params) { + const p = this._resolve(params ?? DEFAULT_PARAMS); + const u = this.material.uniforms; + + const count = Math.max(0, Math.min(this.capacity, Math.round(p.count))); + this.liveAgents = count; + this.geometry.instanceCount = count; + this.mesh.visible = count > 0 && p.opacity > 0 && p.size > 0; + + /* --- the lead's endpoints, resolved from the basis every frame --- */ + _leadA + .copy(this._origin) + .addScaledVector(this._direction, p.handForward) + .addScaledVector(this._side, p.handSide); + _leadA.y = p.handHeight; + _leadB.copy(this._origin).addScaledVector(this._direction, this._length); + _leadB.y = p.endHeight; + + u.uLeadMode.value = p.leadMode; + u.uLeadA.value.copy(_leadA); + u.uLeadB.value.copy(_leadB); + u.uForward.value.copy(this._direction); + u.uSideAxis.value.copy(this._side); + u.uLeadRise.value = p.leadRise; + u.uLeadS.value = p.leadS; + u.uLeadRate.value = p.leadRate; + u.uOrbitRadius.value = p.orbitRadius; + u.uOrbitHeight.value = p.orbitHeight; + u.uOrbitTurns.value = p.orbitTurns; + + /* --- the formation ---------------------------------------------- */ + u.uLattice.value.set( + Math.max(1, Math.round(p.latticeX)), + Math.max(1, Math.round(p.latticeY)), + Math.max(1, Math.round(p.latticeZ)) + ); + u.uSpacingSide.value = p.spacingSide; + u.uSpacingUp.value = p.spacingUp; + u.uLag.value = p.lag; + u.uJitter.value = p.jitter; + u.uChurn.value = p.churn; + u.uBreathe.value = p.breathe; + u.uBreatheRate.value = p.breatheRate; + u.uWander.value = p.wander; + u.uWanderScale.value = p.wanderScale; + u.uWanderSpeed.value = p.wanderSpeed; + u.uGather.value = p.gather; + + /* --- the body ---------------------------------------------------- */ + u.uSize.value = p.size; + u.uAspect.value = p.aspect; + u.uSizeJitter.value = p.sizeJitter; + u.uBillboard.value = p.billboard; + u.uBank.value = p.bank; + u.uBankMax.value = p.bankMax; + u.uDihedral.value = p.dihedral; + u.uFlapRate.value = p.flapRate; + u.uCurl.value = p.curl; + u.uEdgeStretch.value = p.edgeStretch; + u.uReveal.value = p.reveal; + u.uRevealSpread.value = p.revealSpread; + + /* --- the silhouette ---------------------------------------------- */ + u.uShape.value = p.silhouette; + u.uSweep.value = p.sweep; + u.uGlyphWeight.value = p.glyphWeight; + u.uGlyphStrokes.value = p.glyphStrokes; + u.uCardFrame.value = p.cardFrame; + u.uEdgeGain.value = p.edgeGain; + u.uLit.value = p.lit; + + u.uTint.value = p.tint; + u.uTintJitter.value = p.tintJitter; + u.uTintAlong.value = p.tintAlong; + u.uOpacity.value = p.opacity; + u.uGlow.value = p.glow; + u.uSoftFade.value = p.softFade; + } + + /** + * Where the lead point is right now, in world space. + * + * The CPU mirror of `leadAt()` — the ability needs it to place a light, a + * burst or an emitter on the front of the flock. Mirror, so if you change one + * change the other. + */ + leadPoint(out) { + const p = this._p; + if (p.leadMode === LeadPath.ORBIT) { + const a = p.leadS * Math.PI * 2 * p.orbitTurns; + out.copy(this._origin).addScaledVector(this._direction, this._length); + out.x += Math.cos(a) * p.orbitRadius; + out.z += Math.sin(a) * p.orbitRadius; + out.y = p.endHeight + p.orbitHeight; + return out; + } + if (p.leadMode === LeadPath.LINE) { + const t = Math.max(-0.5, Math.min(1, p.leadS)); + _leadA + .copy(this._origin) + .addScaledVector(this._direction, p.handForward) + .addScaledVector(this._side, p.handSide); + _leadA.y = p.handHeight; + _leadB.copy(this._origin).addScaledVector(this._direction, this._length); + _leadB.y = p.endHeight; + out.copy(_leadA).lerp(_leadB, t); + out.y += p.leadRise * Math.sin(Math.PI * Math.max(0, Math.min(1, t))); + return out; + } + out.copy(this._origin).addScaledVector(this._direction, this._length); + out.y = p.endHeight; + return out; + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + this.group.parent?.remove(this.group); + } +} + +/* ---------------------------------------------------------------------- */ +/* Params */ +/* ---------------------------------------------------------------------- */ + +/** + * Every key `update()` understands, with its default and its unit. + * + * Call it to seed a scratch block you fill from `settings[id]` each frame, or + * ignore it entirely and hand `update()` your settings block — anything absent + * falls back to the value here. Defaults exist so that a forgotten field gives + * a flock rather than a `NaN`, which is the most expensive mistake to debug in + * this codebase. + */ +export function swarmParams() { + return { + /* --- how many --- */ + count: 96, // live agents, clamped to capacity + + /* --- the lead --- */ + leadMode: LeadPath.LINE, // LeadPath.* + leadS: 0, // where the lead is now, 0..1 along its path + leadRate: 1, // d(s)/dt — how fast that parameter advances, per second + leadRise: 1.2, // metres the lead lofts at mid-span + handForward: 0.6, // metres downrange of the caster + handSide: 0.35, // metres to the caster's side + handHeight: 1.4, // metres off the floor + endHeight: 0.9, // metres off the floor at the target + orbitRadius: 3, // metres + orbitHeight: 1.5, // metres above the target + orbitTurns: 1, // turns per unit of s + + /* --- the formation --- */ + latticeX: 6, // cells across + latticeY: 3, // cells up + latticeZ: 8, // ranks strung out behind the lead + spacingSide: 0.5, // metres between lateral cells + spacingUp: 0.35, // metres between vertical cells + lag: 0.5, // seconds the back rank trails the lead by + jitter: 0.12, // metres of slop off the cell + churn: 0.7, // radians/second the formation rolls + breathe: 0.18, // fraction it swells by + breatheRate: 1.6, // radians/second + wander: 0.14, // metres of curl drift — keep under half the spacing + wanderScale: 0.5, // features per metre + wanderSpeed: 0.5, + gather: 1, // 0 collapses every agent onto the lead's own path + + /* --- the body --- */ + size: 0.32, // metres, nose to tail + aspect: 1.5, // span / length + sizeJitter: 0.3, // ±fraction + billboard: 0, // 0 agent frame, 1 camera facing + bank: 0.06, // radians of roll per m/s² of lateral acceleration + bankMax: 1.2, // radians + dihedral: 0.3, // wing fold, fraction of size + flapRate: 5, // beats/second + curl: 0, // leaf curl across the chord, fraction of size + edgeStretch: 1.6, // how much an edge-on card grows, ≥1 + reveal: 1, // 0..1 — agents appear as this passes their dice + revealSpread: 0.35, // width of that wave + + /* --- the silhouette --- */ + silhouette: Silhouette.BIRD, + sweep: 0.85, // BIRD: wing rake + glyphWeight: 0.07, // CARD: stroke half-width, card units + glyphStrokes: 4, // CARD: strokes in the walk, 1..6 + cardFrame: 0.35, // CARD: border brightness + edgeGain: 2.4, // emission multiplier when edge-on + lit: 0, // 0 emissive, 1 wrapped diffuse + + /* --- colour --- */ + tint: 0.2, // where in the gradient the flock sits + tintJitter: 0.25, // ±per-agent walk along it + tintAlong: 0.35, // extra walk from head to tail + opacity: 1, + glow: 1.3, + softFade: 0.35 // metres of depth feather against solid geometry + }; +} + +/** Resolved once at module load; `_resolve` walks its keys every frame. */ +const DEFAULT_PARAMS = swarmParams(); diff --git a/src/vfx/TimeControl.js b/src/vfx/TimeControl.js new file mode 100644 index 0000000..5b33e4b --- /dev/null +++ b/src/vfx/TimeControl.js @@ -0,0 +1,1460 @@ +import { + Color, + FrontSide, + Group, + Matrix4, + MeshStandardMaterial, + Quaternion, + Skeleton, + Vector3 +} from 'three'; +import { frame, MAX_TIME_REGIONS } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { patchOnBeforeCompile } from '../utils/shaderPatch.js'; +import { clamp, saturate } from '../utils/math.js'; +import { copyColor } from '../utils/color.js'; + +/* ---------------------------------------------------------------------- */ +/* TimeControl — the three things a chrono ability does to the clock */ +/* ---------------------------------------------------------------------- */ + +/** + * Recording the caster, clamping other systems' clocks inside a radius, and + * running the same closed forms with a negative time step. + * + * This is the most architecturally invasive module in the library and the one + * that draws the least. Two of its three capabilities draw *nothing at all*: + * they are a shared uniform block, a GLSL chunk, and some arithmetic. The read + * is entirely in what the rest of the frame does differently. + * + * ## 1 · RECORDER — the caster's own motion, played back + * + * `TimeRecorder` is a ring buffer of the caster's world transform and its bone + * pose, sampled at a live rate. `GhostRig` is a copy of the caster's skinned + * meshes with its own skeleton, which the recorder can be asked to drive to any + * instant on the track. N ghosts at N delays is N `GhostRig`s reading one + * `TimeRecorder`. + * + * **Full skeletal ghosting is feasible here and this module ships it.** The + * trade-offs are real and are stated rather than hidden: + * + * - Each ghost owns a clone of the bone hierarchy (about seventy `Object3D`s + * for the Mixamo rig in this project) and its own `Skeleton`. Geometry and + * the skin map are shared with the character; only the material and the + * bones are new. Three ghosts is roughly 210 extra scene-graph nodes and + * three extra draw calls. + * - The clone happens **once**, in `setSource()`, and never during a cast. + * That is a documented exception to I3 with an unavoidable cause: the + * character is an asynchronous FBX load that finishes *after* the ability + * pools can be warmed, so there is no earlier moment at which the source + * rig exists. Call `setSource()` from `createShaders()` — by the time a + * chrono ability is warmed on selection the character is long loaded — and + * the cast itself allocates nothing. `setSource()` on an already-sourced + * rig is a no-op, so calling it from `onSpawn()` as a fallback is free + * after the first cast. + * - The track stores **local position and rotation per bone, not scale.** + * Mixamo clips carry no scale animation, so a third of the buffer and a + * third of the per-frame write would be spent replaying a constant. Bone + * scale is copied from the bind pose when the rig is cloned. If a future + * rig animates scale, its ghosts will hold the bind scale and look + * correct-but-stiff rather than broken. + * - Interpolation is a lerp on position and a real `slerp` on rotation. + * Element-wise lerping the *matrices* was the first version; at 30 Hz it is + * almost right and then a wrist crosses ninety degrees between two samples + * and the hand collapses into the forearm for two frames. + * + * ## 2 · STASIS — other people's clocks + * + * `TimeField` is a pool of at most `MAX_TIME_REGIONS` spheres published as + * shared uniforms in `core/FrameUniforms.js`. Every shader that injects + * `shaders/lib/timewarp.glsl.js` reads them; the shared particle shader already + * does. A region carries a centre, a radius, a strength, a **hold timestamp** + * and a **rate**, and the one line in the chunk that does all the work is + * + * bent = mix(clock, hold + (clock - hold) * rate, weight) + * + * so a region with `rate = 0` is a stasis field, one with `rate = -1` runs its + * interior backwards, and one with `rate = 0.2` is slow motion. See the header + * of `shaders/lib/timewarp.glsl.js` for why it is published as an instant and a + * rate rather than as a time scale each consumer integrates — the short version + * is that there is nothing here to integrate *into*, because every effect in + * this project is a closed-form function of time with no per-object state. + * + * **The cost when nothing is held is one uniform float compare.** `acquire()` + * is the only thing that ever raises `uTimeRegionCount` above zero, and until + * an ability calls it every consumer returns its own clock on the first line. + * + * ## 3 · REVERSE — a negative time step + * + * `reverseTime(age, p)` is a closed form: forward at `rate` until `turnAt`, + * held for `hold`, then backwards at `back`, floored. Hand its output to any + * module's `now` and that module runs backwards, because every one of them is a + * pure function of the clock you give it. **Prefer the closed form to the + * integrator.** It is the version that survives the pause test: freeze the + * sandbox mid-rewind, drag `back`, and the whole reversal re-times itself + * retroactively, because nothing was accumulated. + * + * `TimeWarpClock` is the integrating alternative, for a rate that comes from + * somewhere that is not a slider. It is honest about its cost: a clock you + * integrate is a clock a paused slider cannot reach. + * + * ### What reverses cleanly + * + * | | | + * | --- | --- | + * | `GrowthField.update(now, p)` | **yes** — emergence is `g(now - birth)`; crystals retract into the floor | + * | `ShatterField.update(now, p)` | **yes** — fragments fly back and reassemble | + * | `FilamentPaths.sync(look, fade, seed)` + `role.draw(progress, …)` | **yes** — drive `progress` from the bent clock | + * | `Projectile.update(now, p)` | **yes**, but `arrivals` fires again on the way back; latch it with a `RewindGate` | + * | `Tube.sync(c, state)` / `Shell.sync(c, state)` | **yes** — time arrives on `state.time` / `state.t` | + * | `GroundField.update(p)` + `mark(x, z, time, s)` | **yes** — marks carry their own timestamp | + * | `LiquidSurface.update(now, p)` | **partly** — the surface reverses, injected ripples do not | + * + * ### What does not, and what to do instead + * + * | | | + * | --- | --- | + * | **the particle ring buffer** | A spawn is a log entry, not a state. Feeding a `RateEmitter` a negative `dt` emits nothing and thrashes its accumulator. Use `clock.emitDt`, which is `max(0, step)`. The *only* way particles genuinely run backwards is a time region with a negative rate — that path recomputes the closed form in the vertex shader and un-spawns them properly, at their emitter. | + * | **decals, fissures, bursts** | One-shot systems on the app's forward clock. Nothing can un-spawn one. Lay them on the forward pass and let them expire; do not spawn during a reverse leg. | + * | `ArcNetwork.update(dt, …)` | Integrates a hop cursor and fires `onNode` once per node, and `_fired` never counts down. Feed it `clock.emitDt` and `reset(seed)` on the turn. | + * | `Swarm` · `Curtain` · `VolumeHull` | Their motion lives in `frame.uTime` inside the shader; the first argument to `Swarm.update` and `Curtain.update` is ignored by design. An ability clock cannot reach them. A **time region** can, if they are taught to inject the chunk — none of them has been, and that is the obvious next piece of work. | + * | `Ability.advance(dt)` | The base class's front and phase machine are monotone and are not yours to reverse. Let the phases run forward and let the bent clock feed only the modules — `rewind` is a LINE cast whose *travel* still travels. | + * | `ctx.lights` | `LightPool.set(..., dt)` smooths toward a target. A negative `dt` is not meaningful; pass `clock.spanDt`. | + * + * ## Draw calls + * + * `TimeField`: **zero.** `TimeRecorder`: **zero.** `GhostRig`: one per skinned + * mesh in the source rig — one, for the character in this project. Three ghosts + * is three draw calls and leaves nine of the I7 budget. + * + * ## The one rule for using it well + * + * **Call `region.sync(p)` every frame with a params object filled from + * `settings[id]` that frame, and `region.release()` in `onDestroy()`.** The + * pool is four slots wide and `acquire()` returns `null` when they are gone, on + * exactly the `ctx.lights.acquire()` contract — guard it. A region that is + * never released freezes a piece of the world until the page is reloaded, which + * is the most alarming bug this library can produce. + * + * @example + * // stasisfield — the whole ability + * createShaders() { + * this._region = null; + * this._warp = timeRegionParams(); + * } + * onSpawn() { + * this._region = timeField.acquire(); // may be null — I6 + * this._region?.lock(); + * } + * onTravel(dt) { + * const c = settings.stasisfield; + * const region = this._region; + * if (!region) return; + * region.centre.copy(this.origin).setY(c.domeHeight); + * this._warp.radius = c.zoneRadius * settings.global.scale; + * this._warp.core = c.holdCore; + * this._warp.strength = c.holdStrength; + * this._warp.rate = 0; // stasis + * region.sync(this._warp); + * } + * onDestroy() { this._region = this._region?.release() ?? null; } + */ + +/* Module-scope scratch. Nothing in a frame path allocates — invariant I3. */ +const _pos = new Vector3(); +const _quat = new Quaternion(); +const _scale = new Vector3(); +const UP = new Vector3(0, 1, 0); +/** Where a lookup landed: two ring slots and the blend between them. */ +const _cursor = { index: 0, next: 0, blend: 0, inside: false }; +/** Four floats for one `Quaternion.slerpFlat` result. Reused by every bone. */ +const _slerp = new Float32Array(4); + +export { MAX_TIME_REGIONS }; + +/* ====================================================================== */ +/* 1 · The time field */ +/* ====================================================================== */ + +/** + * Canonical parameter names for one region, with their defaults. + * + * Read fresh on every `sync()`; a missing field falls back to the default here. + * Everything with a unit is in this object and nothing with a unit is anywhere + * else in a `TimeRegion` — invariant I1 in one sentence. + */ +export function timeRegionParams() { + return { + radius: 6, // metres — the outer edge, past which the clock is untouched + core: 0.65, // 0..1 of the radius that is held at full strength + strength: 1, // 0..1 master weight; 0 is a region that does nothing + rate: 0 // clock rate inside: 0 stasis, -1 rewind, 0.2 slow, 1 identity + }; +} + +/** + * One slot of the shared field. + * + * Holds two things and deliberately nothing else: the **slot index** it writes + * into and the **hold timestamp** stamped by `lock()`. A timestamp is the one + * kind of number a cast is allowed to capture, because it names an event rather + * than a quantity. Every metre and every rate arrives through `sync()`, from + * the ability's live settings, on every frame including a zero-length one. + * + * `centre` is a `Vector3` the caller writes each frame, the same contract as + * `ArcNetwork.from` / `.to`. The region never keeps a position of its own. + */ +export class TimeRegion { + /** + * @param {TimeField} field + * @param {number} index which pair of uniform slots this region owns + */ + constructor(field, index) { + this._field = field; + this.index = index; + /** World centre. Written by the caller every frame. */ + this.centre = new Vector3(); + /** The instant the region locked. A timestamp — see the class doc. */ + this._hold = 0; + this._live = false; + } + + /** True between `acquire()` and `release()`. */ + get isLive() { + return this._live; + } + + /** The instant this region locked, in the same clock `lock()` was given. */ + get hold() { + return this._hold; + } + + /** + * Stamp the instant the region snapped shut. + * + * Everything inside is held at this moment (`rate = 0`), mirrored about it + * (`rate = -1`) or dilated around it. Defaults to the world clock, which is + * what a stasis field wants; a rewind that should reach further back than the + * moment it was cast passes an earlier value. + * + * @param {number} [now] seconds, in `frame.uTime`'s clock + */ + lock(now = frame.uTime.value) { + this._hold = now; + return this; + } + + /** Place the centre in one call. `centre` is public; this is for readability. */ + place(position) { + this.centre.copy(position); + return this; + } + + placeXYZ(x, y, z) { + this.centre.set(x, y, z); + return this; + } + + /** + * Push this frame's dimensions into the shared uniforms. + * + * @param {object} p live params — see `timeRegionParams()` + */ + sync(p) { + if (!this._live) return this; + const region = frame.uTimeRegion.value[this.index]; + const warp = frame.uTimeRegionWarp.value[this.index]; + region.set(this.centre.x, this.centre.y, this.centre.z, Math.max(p?.radius ?? 0, 0)); + warp.set(saturate(p?.strength ?? 1), saturate(p?.core ?? 0.65), this._hold, p?.rate ?? 0); + return this; + } + + /** + * How strongly this region holds a world point, 0..1. + * + * The CPU mirror of `timeRegionFalloff` in the GLSL chunk, arithmetic for + * arithmetic. An ability uses it to decide whether it is worth emitting into + * its own frozen zone, or to fade a decal it is about to lay inside one. + */ + weightAt(worldPos) { + if (!this._live) return 0; + const region = frame.uTimeRegion.value[this.index]; + const warp = frame.uTimeRegionWarp.value[this.index]; + return falloff(worldPos, region, warp); + } + + /** Return the slot to the pool and leave it exactly identity. Returns null. */ + release() { + this._field?.release(this); + return null; + } + + /** Pool-side only. */ + _claim(now) { + this._live = true; + this._hold = now; + this.centre.set(0, 0, 0); + frame.uTimeRegion.value[this.index].set(0, 0, 0, 0); + frame.uTimeRegionWarp.value[this.index].set(0, 0, now, 1); + } + + /** Pool-side only. Strength 0 and rate 1 are both identity; write both. */ + _retire() { + this._live = false; + this._hold = 0; + frame.uTimeRegion.value[this.index].set(0, 0, 0, 0); + frame.uTimeRegionWarp.value[this.index].set(0, 0, 0, 1); + } +} + +/** The falloff, shared by the pool and by `TimeRegion.weightAt`. */ +function falloff(worldPos, region, warp) { + const outer = Math.max(region.w, 1e-4); + const inner = Math.min(saturate(warp.y) * outer, outer - 1e-3); + const dx = worldPos.x - region.x; + const dy = worldPos.y - region.y; + const dz = worldPos.z - region.z; + const d = Math.sqrt(dx * dx + dy * dy + dz * dz); + // smoothstep(inner, outer, d), inlined to keep the mirror obvious. + const s = saturate((d - inner) / (outer - inner)); + return saturate(warp.x) * (1 - s * s * (3 - 2 * s)); +} + +/** + * The pool of time regions, and the CPU-side mirror of the field. + * + * A singleton (`timeField`) rather than a service on the ability context, for + * the same reason `distortionWriters` lives in `core/Layers.js`: the consumers + * are the *shaders*, through uniform boxes that already exist whether or not + * anybody has claimed one, and threading a service through `AbilityManager` + * would mean editing the context every ability in the project is constructed + * against. Nothing outside this file has to change for the field to work. + */ +export class TimeField { + constructor() { + this._regions = []; + for (let i = 0; i < MAX_TIME_REGIONS; i++) this._regions.push(new TimeRegion(this, i)); + } + + /** Live regions. */ + get liveCount() { + let n = 0; + for (const region of this._regions) if (region._live) n++; + return n; + } + + /** + * Claim a slot. + * + * **Returns `null` when all four are taken**, on exactly the contract of + * `ctx.lights.acquire()` — guard every use of the handle. An ability that + * wants two regions must read acceptably with one. + * + * @param {number} [now] the hold instant to start with; `lock()` re-stamps it + * @returns {TimeRegion|null} + */ + acquire(now = frame.uTime.value) { + for (const region of this._regions) { + if (region._live) continue; + region._claim(now); + this._repack(); + return region; + } + return null; + } + + /** Give a slot back. Safe to call with `null` or with a foreign region. */ + release(region) { + if (!region || region._field !== this || !region._live) return null; + region._retire(); + this._repack(); + return null; + } + + /** + * The clock a world point lives on. The CPU mirror of `warpedTime`. + * + * The one place an ability needs this is when it must place something *on* a + * frozen effect — a light on a held ember, a decal under a stopped crystal. + * Everything drawn by a shader that injects the chunk bends itself. + */ + clockAt(clock, worldPos) { + const count = frame.uTimeRegionCount.value; + if (count < 0.5) return clock; + let bent = clock; + for (let i = 0; i < MAX_TIME_REGIONS && i < count; i++) { + const region = frame.uTimeRegion.value[i]; + const warp = frame.uTimeRegionWarp.value[i]; + const w = falloff(worldPos, region, warp); + bent += (warp.z + (bent - warp.z) * warp.w - bent) * w; + } + return bent; + } + + /** How held a world point is, 0..1. The CPU mirror of `timeRegionWeight`. */ + weightAt(worldPos) { + const count = frame.uTimeRegionCount.value; + if (count < 0.5) return 0; + let held = 0; + for (let i = 0; i < MAX_TIME_REGIONS && i < count; i++) { + held = Math.max(held, falloff(worldPos, frame.uTimeRegion.value[i], frame.uTimeRegionWarp.value[i])); + } + return held; + } + + /** + * Retire every region. For `App.reset()` and for teardown. + * + * Worth wiring into whatever clears the scene: a leaked region is invisible + * and stops a piece of the world for the rest of the session. + */ + reset() { + for (const region of this._regions) region._retire(); + frame.uTimeRegionCount.value = 0; + return this; + } + + /** + * Republish the live count. + * + * The count is the index of the highest live slot plus one, not the number of + * live regions — the shader loop walks slots in order and a hole in the + * middle has to still be walked past. A retired slot is exact identity + * (strength 0, rate 1), so walking it costs a `mix` by zero and changes + * nothing. + */ + _repack() { + let high = 0; + for (let i = 0; i < MAX_TIME_REGIONS; i++) if (this._regions[i]._live) high = i + 1; + frame.uTimeRegionCount.value = high; + } +} + +/** The one pool. Abilities import this, not the class. */ +export const timeField = new TimeField(); + +/* ====================================================================== */ +/* 2 · The recorder */ +/* ====================================================================== */ + +/** Hard ceiling on samples in the ring. The `window` slider clamps against it. */ +export const MAX_TRACK_SAMPLES = 240; +/** Hard ceiling on recorded bones. The Mixamo rig here uses about sixty-five. */ +export const MAX_TRACK_BONES = 128; +/** position(3) + quaternion(4). Scale is not recorded — see the module doc. */ +const FLOATS_PER_BONE = 7; + +/** + * Canonical parameter names for the recorder. + * + * `rate` and `window` are live sliders and are read on every `sample()`. The + * *capacity* of the ring is a constructor argument and a structural ceiling, in + * the same way `MAX_STRANDS` is in `ThunderAbility` — a buffer cannot be + * resized by a slider drag, but how much of it is used, and how finely, can. + */ +export function recorderParams() { + return { + rate: 30, // samples per second + window: 2.5 // seconds of history to keep; older samples are allowed to fall off + }; +} + +/** + * A ring buffer of the caster's transform and pose. + * + * ## What is in it + * + * Per sample: a **timestamp**, the source's world position and rotation, and + * every tracked bone's *local* position and rotation. Nothing else, and in + * particular no metres that are not the caster's own — a recording is a + * measurement of something that happened, which is the one category of state + * invariant I1 has no argument with. + * + * ## The property that makes this an effect rather than a feature + * + * Sampling is driven by timestamps, so a zero-length frame writes nothing. But + * *playback* is a function of the delay you ask for, and the delay is a slider. + * Pause the sandbox mid-cast with **P** and drag `ghostDelay`, and the three + * ghosts walk backwards and forwards through the recording while the world + * stands still. That is the pause test passing loudly, on an ability whose + * whole subject is time. + * + * ## Cost + * + * `240 samples × (1 + 7 + 128 × 7)` floats is 3.4 MB at the ceiling, and a + * sensible `new TimeRecorder({ capacity: 90, bones: 70 })` is 178 kB. + * Allocated once, at construction, and never again. Playback is a binary search + * plus one lerp and one slerp per bone per ghost. + */ +export class TimeRecorder { + /** + * @param {object} [options] + * @param {number} [options.capacity=120] samples in the ring, 2..MAX_TRACK_SAMPLES + * @param {number} [options.bones=MAX_TRACK_BONES] bone ceiling; 0 records the + * root transform only, which is all a silhouette proxy needs + */ + constructor({ capacity = 120, bones = MAX_TRACK_BONES } = {}) { + this.capacity = clamp(Math.round(capacity), 2, MAX_TRACK_SAMPLES); + this.boneCeiling = clamp(Math.round(bones), 0, MAX_TRACK_BONES); + + this._time = new Float32Array(this.capacity); + this._root = new Float32Array(this.capacity * FLOATS_PER_BONE); + this._pose = + this.boneCeiling > 0 + ? new Float32Array(this.capacity * this.boneCeiling * FLOATS_PER_BONE) + : null; + + /** The rig being watched, or null. */ + this.source = null; + /** Source bones in skeleton order; index-matched to every `GhostRig`. */ + this.bones = []; + this._count = 0; + this._cursor = 0; + this._last = -1e9; + } + + /** Bones actually being recorded. */ + get boneCount() { + return this.bones.length; + } + + /** Samples currently held. */ + get sampleCount() { + return this._count; + } + + /** Timestamp of the newest sample, or `-Infinity` on an empty track. */ + get newest() { + return this._count ? this._time[this._slot(this._count - 1)] : -Infinity; + } + + /** Timestamp of the oldest sample, or `Infinity` on an empty track. */ + get oldest() { + return this._count ? this._time[this._slot(0)] : Infinity; + } + + /** Seconds between the oldest and newest sample. */ + get span() { + return this._count > 1 ? this.newest - this.oldest : 0; + } + + /** + * Watch a rig. + * + * Discovers every skeleton under `source` and records the union of their + * bones in traversal order — which is the order a `GhostRig` cloned from the + * same source will produce, so bone `i` here is bone `i` there and no name + * matching is needed at playback. + * + * @param {import('three').Object3D|null} source + */ + attach(source) { + this.source = source ?? null; + this.bones.length = 0; + this.clear(); + if (!source || this.boneCeiling === 0) return this; + + const seen = new Set(); + source.traverse((node) => { + if (!node.isSkinnedMesh || !node.skeleton) return; + for (const bone of node.skeleton.bones) { + if (!bone || seen.has(bone)) continue; + seen.add(bone); + if (this.bones.length < this.boneCeiling) this.bones.push(bone); + } + }); + + if (seen.size > this.boneCeiling) { + console.warn( + `[TimeRecorder] rig has ${seen.size} bones and the ceiling is ${this.boneCeiling}; ` + + 'the tail of the skeleton will hold its bind pose' + ); + } + return this; + } + + detach() { + return this.attach(null); + } + + /** Empty the track. Leaves the recorder attached and reusable. */ + clear() { + this._count = 0; + this._cursor = 0; + this._last = -1e9; + return this; + } + + /** + * Write a sample if one is due. + * + * Refuses two things, both of them quietly: + * + * - a frame that has not advanced far enough for the live `rate`, which is + * also what makes a paused frame write nothing; + * - a `now` **earlier than the newest sample**, which is what happens the + * moment an ability points a reversed clock at its own recorder. A track + * is a measurement; running time backwards does not un-measure it, and + * letting a rewind interleave descending timestamps into the ring turns + * every subsequent binary search into nonsense. + * + * @param {number} now seconds — the caster's clock, usually `frame.uTime.value` + * @param {object} p live params — see `recorderParams()` + * @returns {boolean} true if a sample was written + */ + sample(now, p) { + const source = this.source; + if (!source) return false; + if (this._count > 0) { + if (now < this._last) return false; + const rate = clamp(p?.rate ?? 30, 1, 240); + if (now - this._last < 1 / rate) return false; + } + + const slot = this._cursor; + this._cursor = (this._cursor + 1) % this.capacity; + if (this._count < this.capacity) this._count++; + this._time[slot] = now; + this._last = now; + + source.updateWorldMatrix(true, false); + source.matrixWorld.decompose(_pos, _quat, _scale); + const r = slot * FLOATS_PER_BONE; + this._root[r + 0] = _pos.x; + this._root[r + 1] = _pos.y; + this._root[r + 2] = _pos.z; + this._root[r + 3] = _quat.x; + this._root[r + 4] = _quat.y; + this._root[r + 5] = _quat.z; + this._root[r + 6] = _quat.w; + + const pose = this._pose; + if (!pose) return true; + const base = slot * this.boneCeiling * FLOATS_PER_BONE; + for (let b = 0; b < this.bones.length; b++) { + const bone = this.bones[b]; + const o = base + b * FLOATS_PER_BONE; + pose[o + 0] = bone.position.x; + pose[o + 1] = bone.position.y; + pose[o + 2] = bone.position.z; + pose[o + 3] = bone.quaternion.x; + pose[o + 4] = bone.quaternion.y; + pose[o + 5] = bone.quaternion.z; + pose[o + 6] = bone.quaternion.w; + } + return true; + } + + /** + * Drop samples older than `now - window`. + * + * Optional: the ring already evicts by overwriting, and a shorter window is + * usually expressed by simply not asking for older instants. It exists so an + * ability can make `window` a genuine slider whose effect — the oldest ghost + * running out of history and fading — is visible while paused. + */ + trim(now, p) { + const window = Math.max(p?.window ?? 2.5, 0); + while (this._count > 1 && now - this._time[this._slot(0)] > window) this._count--; + return this; + } + + /** + * The caster's world transform at `t`. + * + * @param {number} t seconds, in the recorder's own clock + * @param {import('three').Vector3} outPosition + * @param {import('three').Quaternion} outQuaternion + * @returns {boolean} false when `t` is off either end of the track — the + * outputs are clamped to the nearest sample, so a caller that ignores this + * gets a pose rather than a NaN, but a ghost with no history should hide + */ + transformAt(t, outPosition, outQuaternion) { + this._locate(t); + if (this._count === 0) return false; + const a = _cursor.index * FLOATS_PER_BONE; + const b = _cursor.next * FLOATS_PER_BONE; + const k = _cursor.blend; + const r = this._root; + outPosition.set( + r[a + 0] + (r[b + 0] - r[a + 0]) * k, + r[a + 1] + (r[b + 1] - r[a + 1]) * k, + r[a + 2] + (r[b + 2] - r[a + 2]) * k + ); + Quaternion.slerpFlat(_slerp, 0, r, a + 3, r, b + 3, k); + outQuaternion.set(_slerp[0], _slerp[1], _slerp[2], _slerp[3]); + return _cursor.inside; + } + + /** + * Drive a ghost's bones to the pose at `t`. + * + * Writes local position and rotation; scale is whatever the clone inherited + * from the bind pose. The ghost's own `updateMatrixWorld` and three's + * once-per-frame `Skeleton.update()` do the rest, so this costs one lerp and + * one slerp per bone and no matrix work of its own. + * + * @param {number} t seconds + * @param {GhostRig} ghost + * @returns {boolean} false when `t` is off the end of the track + */ + poseAt(t, ghost) { + const pose = this._pose; + const bones = ghost?.bones; + if (!pose || !bones || bones.length === 0) return false; + this._locate(t); + if (this._count === 0) return false; + + const stride = this.boneCeiling * FLOATS_PER_BONE; + const a = _cursor.index * stride; + const b = _cursor.next * stride; + const k = _cursor.blend; + const n = Math.min(bones.length, this.bones.length); + + for (let i = 0; i < n; i++) { + const bone = bones[i]; + if (!bone) continue; + const oa = a + i * FLOATS_PER_BONE; + const ob = b + i * FLOATS_PER_BONE; + bone.position.set( + pose[oa + 0] + (pose[ob + 0] - pose[oa + 0]) * k, + pose[oa + 1] + (pose[ob + 1] - pose[oa + 1]) * k, + pose[oa + 2] + (pose[ob + 2] - pose[oa + 2]) * k + ); + Quaternion.slerpFlat(_slerp, 0, pose, oa + 3, pose, ob + 3, k); + bone.quaternion.set(_slerp[0], _slerp[1], _slerp[2], _slerp[3]); + } + return _cursor.inside; + } + + /** Ring slot of the `i`-th sample counting from the oldest. */ + _slot(i) { + return (this._cursor - this._count + i + this.capacity * 2) % this.capacity; + } + + /** + * Bracket `t` and leave the result in the module-scope `_cursor`. + * + * A binary search rather than a walk. Every ghost queries a *different, old* + * instant, so a walk from the newest end is close to a full sweep of the ring + * per ghost per frame; eight ghosts on a 240-sample ring is 1,920 compares + * against 64 for the search, and the search does not get worse when somebody + * raises the capacity. + * + * @returns {boolean} true when `t` is genuinely inside the track + */ + _locate(t) { + const n = this._count; + if (n === 0) { + _cursor.index = 0; + _cursor.next = 0; + _cursor.blend = 0; + _cursor.inside = false; + return false; + } + if (n === 1) { + _cursor.index = this._slot(0); + _cursor.next = _cursor.index; + _cursor.blend = 0; + _cursor.inside = false; + return false; + } + + const time = this._time; + let lo = 0; + let hi = n - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (time[this._slot(mid)] <= t) lo = mid; + else hi = mid - 1; + } + + const a = this._slot(lo); + const b = this._slot(Math.min(lo + 1, n - 1)); + const ta = time[a]; + const tb = time[b]; + _cursor.index = a; + _cursor.next = b; + _cursor.blend = tb > ta ? saturate((t - ta) / (tb - ta)) : 0; + _cursor.inside = t >= time[this._slot(0)] && t <= time[this._slot(n - 1)]; + return _cursor.inside; + } +} + +/* ====================================================================== */ +/* 3 · Ghosts */ +/* ====================================================================== */ + +/** + * Canonical look for a ghost. Every one of these is a slider or a picker — I5. + * + * The defaults are deliberately close to "the character, dimmer". A ghost that + * arrives already stylised into a blue silhouette cannot be tuned back into a + * copy of the caster, and a copy of the caster is the entire trick of + * `echostep`. + */ +export function ghostLook() { + return { + /* --- colour --- */ + tint: '#d5be8c', // where the skin was bright + deep: '#3b3526', // where the skin was dark + rimColor: '#f2e6c4', // the fresnel edge + bleach: 0.7, // 0..1 how far the skin is washed toward tint/deep + /* --- presence --- */ + fade: 1, // 0..1 master opacity; the per-ghost dimming lives here + facing: 0.35, // 0..1 how much thinner the ghost is head-on than edge-on + rim: 1.4, // fresnel emission + rimPower: 2.6, // fresnel tightness + /* --- the banding that says "recording", not "hologram" --- */ + glow: 0.35, // emission of the bands + bandScale: 5.5, // bands per metre of world height + bandSpeed: 0.9, // metres per second the bands travel down the body + /* --- coming apart --- */ + erode: 0, // 0..1 dissolve; 1 removes the ghost entirely + erodeScale: 3.4, // noise features per metre + edge: 0.14, // 0..1 width of the burning edge on the dissolve + edgeGlow: 2.2, // emission on that edge + seed: 0 // unitless dice, so two ghosts do not dissolve identically + }; +} + +/** + * A `MeshStandardMaterial` that reads as a copy of the character. + * + * ### Why not a `ShaderMaterial` + * + * The first version was one: a fresnel ramp over a flat colour, drawn on the + * cloned geometry. It looked like a mannequin. What makes a person recognisable + * at ten metres is not the silhouette, it is the *shading* — the normal map + * across the shoulders, the way the environment probe sits on the top of the + * head, the shadow terminator down the side of the ribcage. Every one of those + * arrives free from `MeshStandardMaterial` and costs a re-implementation + * otherwise, so the ghost is the real lit material with three things done to it + * on the way out. + * + * ### The three things + * + * 1. **Bleach, not tint.** The skin is mixed toward `deep → tint` *by its own + * luminance*, so the map's light and shade survive and only its hue is + * replaced. Multiplying by a tint instead — the obvious version — turns a + * dark jacket into black and the whole figure into a coloured blob, which is + * the exact failure the roster entry warns about. + * 2. **A fresnel rim, and alpha that follows it.** `facing` makes the ghost + * thinner head-on than edge-on, so it reads as a volume of air rather than a + * decal of a person. + * 3. **World-space banding and a world-space dissolve.** Both are measured in + * metres in *world* space, not in UV, so the bands stay put as the figure + * moves through them and two ghosts a metre apart are eroded differently + * without either of them owning a seed attribute. + * + * ### The texture + * + * The ghost shares the character's diffuse map. That is not a new I2 exception: + * it is the same one, reused. Nothing is loaded, nothing is generated, and the + * moment the caster's skin changes so does every echo of it. + * + * @param {import('three').Material|null} source the character's material, whose + * maps and roughness the ghost inherits. `null` builds an untextured ghost, + * which is what a rig with no material gets. + */ +export function createGhostMaterial(source = null) { + const material = new MeshStandardMaterial({ + name: 'GhostRig', + color: 0xffffff, + map: source?.map ?? null, + normalMap: source?.normalMap ?? null, + bumpMap: source?.normalMap ? null : (source?.bumpMap ?? null), + roughness: source?.roughness ?? 0.85, + metalness: 0, + side: source?.side ?? FrontSide, + transparent: true, + // A ghost is a stack of transparent copies of the same silhouette; writing + // depth makes whichever one drew first punch a hole in the rest. + depthWrite: false, + toneMapped: true + }); + + const uniforms = { + uTime: frame.uTime, + uGhostTint: { value: new Color(1, 1, 1) }, + uGhostDeep: { value: new Color(0.1, 0.1, 0.1) }, + uGhostRimColor: { value: new Color(1, 1, 1) }, + uGhostBleach: { value: 0.7 }, + uGhostFade: { value: 1 }, + uGhostFacing: { value: 0.35 }, + uGhostRim: { value: 1.4 }, + uGhostRimPower: { value: 2.6 }, + uGhostGlow: { value: 0.35 }, + uGhostBandScale: { value: 5.5 }, + uGhostBandSpeed: { value: 0.9 }, + uGhostErode: { value: 0 }, + uGhostErodeScale: { value: 3.4 }, + uGhostEdge: { value: 0.14 }, + uGhostEdgeGlow: { value: 2.2 }, + uGhostSeed: { value: 0 } + }; + + patchOnBeforeCompile(material, (shader) => { + Object.assign(shader.uniforms, uniforms); + + shader.vertexShader = shader.vertexShader + .replace('#include ', '#include \n varying vec3 vGhostWorld;') + .replace( + '#include ', + `#include + /* transformed is post-skinning, in the bind space; modelMatrix takes it + to the world. Exactly what three's own worldpos chunk does, except + that chunk only fires when an envmap or a shadow define asked for it + and the ghost cannot rely on either being on. */ + vGhostWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;` + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform float uTime; + uniform vec3 uGhostTint; + uniform vec3 uGhostDeep; + uniform vec3 uGhostRimColor; + uniform float uGhostBleach; + uniform float uGhostFade; + uniform float uGhostFacing; + uniform float uGhostRim; + uniform float uGhostRimPower; + uniform float uGhostGlow; + uniform float uGhostBandScale; + uniform float uGhostBandSpeed; + uniform float uGhostErode; + uniform float uGhostErodeScale; + uniform float uGhostEdge; + uniform float uGhostEdgeGlow; + uniform float uGhostSeed; + varying vec3 vGhostWorld; + ${noiseGLSL}` + ) + .replace( + '#include ', + `#include + { + /* --- bleach by luminance, so the map's shading survives --- */ + float luma = dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722)); + vec3 wash = mix(uGhostDeep, uGhostTint, clamp(luma, 0.0, 1.0)); + diffuseColor.rgb = mix(diffuseColor.rgb, wash, clamp(uGhostBleach, 0.0, 1.0)); + + /* --- fresnel: the edge is where a ghost is --- */ + vec3 N = normalize(normal); + float ndv = clamp(dot(N, normalize(vViewPosition)), 0.0, 1.0); + float rim = pow(1.0 - ndv, max(uGhostRimPower, 0.05)); + + /* --- bands travelling down the body, in world metres --- */ + float band = 0.5 + 0.5 * sin( + (vGhostWorld.y - uTime * uGhostBandSpeed) * uGhostBandScale * PI2); + + /* --- the dissolve, also in world metres --- */ + float n = clamp(fbm3(vGhostWorld * uGhostErodeScale + uGhostSeed) * 0.5 + 0.5, 0.0, 1.0); + float cut = clamp(uGhostErode, 0.0, 1.0); + /* The threshold is stretched past both ends so erode = 0 leaves the + whole body and erode = 1 takes all of it; a bare step(cut, n) + against a noise field that never quite reaches 0 or 1 leaves a + rind of the ghost standing at full erosion. */ + float threshold = cut * 1.2 - 0.1; + float keep = step(threshold, n); + float burn = clamp( + smoothstep(threshold, threshold + max(uGhostEdge, 1e-3), n) - keep, 0.0, 1.0); + /* Below one part in a thousand the ghost is gone; discarding beats + paying for a full standard-material shade on nothing. */ + if (keep * clamp(uGhostFade, 0.0, 1.0) < 0.001) discard; + + float presence = clamp(uGhostFade, 0.0, 1.0); + vec3 glow = uGhostRimColor * rim * uGhostRim; + glow += uGhostTint * band * uGhostGlow; + glow += uGhostRimColor * burn * uGhostEdgeGlow; + /* Reinhard ceiling, the IceMaterial convention: rim, band and burn + all peak on the silhouette of a dissolving ghost at once, and + bloom turns the sum into a white smear without it. */ + glow /= 1.0 + glow * 0.3; + totalEmissiveRadiance += glow * presence; + + diffuseColor.a *= presence * keep * mix(1.0, rim, clamp(uGhostFacing, 0.0, 1.0)); + }` + ); + }); + + // I8 — the harness's pause test looks for uniform boxes here, and reports + // sixteen live sliders as dead if it cannot find them. + material.userData.uniforms = uniforms; + material.userData.sync = (look) => applyGhostLook(uniforms, look); + return material; +} + +/** Push a `ghostLook()` into a ghost material's uniform boxes. */ +export function applyGhostLook(uniforms, look) { + const l = look ?? {}; + copyColor(uniforms.uGhostTint.value, l.tint ?? '#d5be8c'); + copyColor(uniforms.uGhostDeep.value, l.deep ?? '#3b3526'); + copyColor(uniforms.uGhostRimColor.value, l.rimColor ?? '#f2e6c4'); + uniforms.uGhostBleach.value = saturate(l.bleach ?? 0.7); + uniforms.uGhostFade.value = saturate(l.fade ?? 1); + uniforms.uGhostFacing.value = saturate(l.facing ?? 0.35); + uniforms.uGhostRim.value = Math.max(l.rim ?? 1.4, 0); + uniforms.uGhostRimPower.value = Math.max(l.rimPower ?? 2.6, 0.05); + uniforms.uGhostGlow.value = Math.max(l.glow ?? 0.35, 0); + uniforms.uGhostBandScale.value = l.bandScale ?? 5.5; + uniforms.uGhostBandSpeed.value = l.bandSpeed ?? 0.9; + uniforms.uGhostErode.value = saturate(l.erode ?? 0); + uniforms.uGhostErodeScale.value = Math.max(l.erodeScale ?? 3.4, 1e-3); + uniforms.uGhostEdge.value = Math.max(l.edge ?? 0.14, 1e-3); + uniforms.uGhostEdgeGlow.value = Math.max(l.edgeGlow ?? 2.2, 0); + uniforms.uGhostSeed.value = l.seed ?? 0; + return uniforms; +} + +/** Walk two hierarchies of identical shape at once. */ +function parallelTraverse(a, b, visit) { + visit(a, b); + const n = Math.min(a.children.length, b.children.length); + for (let i = 0; i < n; i++) parallelTraverse(a.children[i], b.children[i], visit); +} + +/** + * One echo of the caster: a real clone of its skinned meshes, on its own + * skeleton, that the recorder can drive to any past pose. + * + * ## Why a clone and not a second draw of the same mesh + * + * Skinning happens on the GPU from a per-skeleton matrix palette. Two ghosts + * showing two different instants need two palettes, therefore two `Skeleton`s, + * therefore two sets of `Bone` objects for `Skeleton.update()` to read. There + * is no arrangement in which N poses of one rig cost one draw call; the honest + * options were N clones, or a proxy that is not the character. The roster entry + * for `echostep` says "They are the character, not a proxy; that is what makes + * it unsettling", so it is N clones, and the ceiling is stated instead of + * hidden: about seventy `Object3D`s and one draw call each. + * + * **Geometry and textures are shared with the source.** `dispose()` frees the + * ghost material and nothing else — freeing the geometry would take the + * character down with it. + * + * ## The one rule + * + * **`setSource()` once, off the cast.** It is the only allocating call in the + * class, it is idempotent, and `createShaders()` is where it belongs. + */ +export class GhostRig { + /** + * @param {import('three').Object3D} parent the ability's group + * @param {object} [options] + * @param {import('three').Object3D} [options.source] the rig to copy; may be + * supplied later with `setSource()` + * @param {import('three').Material} [options.material] share one ghost + * material between several rigs, or leave null for one of its own + * @param {number} [options.layer=LAYER.VFX] VFX rather than WORLD, because + * the depth prepass renders WORLD with an override material and a + * transparent ghost written into it makes every soft particle behind it + * fade against a person who is not really there + * @param {number} [options.renderOrder=4] + */ + constructor(parent, options = {}) { + this.object3D = new Group(); + this.object3D.name = 'GhostRig'; + this.object3D.matrixAutoUpdate = true; + parent?.add(this.object3D); + + this._layer = options.layer ?? LAYER.VFX; + this._renderOrder = options.renderOrder ?? 4; + /** True when the material came from outside and must not be disposed here. */ + this._ownsMaterial = !options.material; + this.material = options.material ?? null; + + /** Cloned bones, index-matched to `TimeRecorder.bones`. */ + this.bones = []; + this.meshes = []; + this.source = null; + this._clone = null; + + if (options.source) this.setSource(options.source); + } + + /** One per skinned mesh in the source rig. Zero before `setSource()`. */ + get drawCalls() { + return this.meshes.length; + } + + get boneCount() { + return this.bones.length; + } + + get hasSource() { + return this.source !== null; + } + + get visible() { + return this.object3D.visible; + } + + set visible(value) { + this.object3D.visible = !!value; + } + + /** + * Build the clone. Idempotent, and the only allocating call in the class. + * + * @param {import('three').Object3D|null} source + * @returns {boolean} true once the rig is usable + */ + setSource(source) { + if (this.source === source) return this.source !== null; + this._teardown(); + if (!source) return false; + + const clone = source.clone(true); + const map = new Map(); + parallelTraverse(source, clone, (a, b) => map.set(a, b)); + + // The ghost material inherits from the first material it finds, so it keeps + // the character's skin and normal map rather than inventing a look. + if (!this.material) { + let first = null; + source.traverse((node) => { + if (first || !node.material) return; + first = Array.isArray(node.material) ? node.material[0] : node.material; + }); + this.material = createGhostMaterial(first); + this._ownsMaterial = true; + } + + for (const [original, copy] of map) { + if (!copy.isMesh && !copy.isSkinnedMesh) continue; + + copy.material = this.material; + copy.castShadow = false; + copy.receiveShadow = false; + // The clone is driven by bones the frustum test knows nothing about, and + // a ghost popping out at the edge of the screen is the sort of bug that + // takes an afternoon to attribute. + copy.frustumCulled = false; + copy.layers.set(this._layer); + copy.renderOrder = this._renderOrder; + this.meshes.push(copy); + + if (!original.isSkinnedMesh || !original.skeleton) continue; + + const sourceBones = original.skeleton.bones; + const bones = []; + let complete = true; + for (const bone of sourceBones) { + const cloned = map.get(bone); + if (!cloned) { + complete = false; + break; + } + bones.push(cloned); + } + if (!complete) { + // A rig whose bones live outside the subtree we cloned would leave the + // ghost bound to the *caster's* skeleton, so it would mirror the live + // character exactly and look like a rendering bug rather than an echo. + console.warn('[GhostRig] the source skeleton reaches outside the cloned subtree; not binding'); + continue; + } + + // Copies of the inverses, not the array and not the matrices. Skeleton + // only rewrites them in `calculateInverses()`, which nothing here calls — + // but sharing them means a future caller of it silently re-binds the + // character's own mesh, and that is not a bug anybody would find. + const inverses = original.skeleton.boneInverses.map((m) => new Matrix4().copy(m)); + copy.bind(new Skeleton(bones, inverses), original.bindMatrix); + } + + // The playback index. Built by walking the *source* exactly the way + // `TimeRecorder.attach()` walks it — traversal order, skeleton bones, + // deduplicated — rather than by taking the first skeleton's list, so bone + // `i` on the track is bone `i` here even for a rig whose second skinned + // mesh brings bones the first one never had. Getting this out of step is a + // ghost whose forearm is driven by somebody's jaw. + const seen = new Set(); + source.traverse((node) => { + if (!node.isSkinnedMesh || !node.skeleton) return; + for (const bone of node.skeleton.bones) { + if (!bone || seen.has(bone)) continue; + seen.add(bone); + const cloned = map.get(bone); + if (cloned) this.bones.push(cloned); + } + }); + + this.object3D.add(clone); + this._clone = clone; + this.source = source; + return true; + } + + /** + * Place the ghost in the world. + * + * Separate from the pose on purpose. In this sandbox the caster does not + * move, so a recorded *transform* track is almost a constant and the + * interesting part of a recording is the pose; the ability is what walks the + * echoes down the line. `TimeRecorder.transformAt()` is there for the day + * something does move. + * + * @param {import('three').Vector3} position + * @param {number|import('three').Quaternion} [heading] radians about world +Y, + * or a quaternion straight off the recorder + */ + place(position, heading = 0) { + this.object3D.position.copy(position); + if (typeof heading === 'number') this.object3D.quaternion.setFromAxisAngle(UP, heading); + else if (heading) this.object3D.quaternion.copy(heading); + return this; + } + + /** Uniform scale, metres per metre. A shrinking echo reads as a receding one. */ + setScale(scale) { + this.object3D.scale.setScalar(scale); + return this; + } + + /** Push this frame's look. See `ghostLook()`. */ + sync(look) { + if (this.material?.userData?.uniforms) applyGhostLook(this.material.userData.uniforms, look); + return this; + } + + /** Free the material. Geometry and textures belong to the character. */ + dispose() { + this._teardown(); + if (this._ownsMaterial) this.material?.dispose(); + this.material = null; + this.object3D.parent?.remove(this.object3D); + } + + _teardown() { + if (this._clone) this.object3D.remove(this._clone); + this._clone = null; + this.bones = []; + this.meshes.length = 0; + this.source = null; + } +} + +/** + * Find the caster in a scene, without editing anybody's context object. + * + * `CharacterController` names its root `'Character'` and `App` adds it straight + * to the scene, and `ctx.scene` is already in the context every ability is + * constructed with. That is the whole hook: no new service, no change to + * `AbilityManager`, no ordering problem with the character's asynchronous load. + * + * Call it **once**, in `createShaders()`, and keep the result. `getObjectByName` + * is a full traversal and has no business in a frame path. + */ +export function findCaster(scene) { + return scene?.getObjectByName?.('Character') ?? null; +} + +/* ====================================================================== */ +/* 4 · Reverse */ +/* ====================================================================== */ + +/** + * Canonical parameters for the reverse beat. + * + * Every one of them is a live slider, which is the point: `reverseTime` is a + * closed form over them, so dragging `back` while the sandbox is paused + * re-times a rewind that is already halfway through itself. + */ +export function reverseParams() { + return { + rate: 1, // × forward speed before the turn + turnAt: 0.55, // seconds of ability age at which time stops going forward + hold: 0.12, // seconds held at the turn — the beat you can see coming + back: 1.8, // × the speed it then runs backwards at + floor: 0 // the earliest instant the clock may reach, seconds + }; +} + +/** + * The bent clock, as a closed-form function of the ability's age. + * + * Three legs: forward at `rate`, a hold, then backwards at `back` until it hits + * `floor`. Hand the result to any module's `now` and that module runs the beat. + * + * The hold is not decoration. A reversal with no pause at the top reads as a + * glitch — the eye needs the moment where nothing moves to understand that what + * follows is the same motion inverted rather than a different motion. The + * `hourglass` roster entry calls the same thing "a beat you can see coming". + * + * @param {number} age seconds since the cast + * @param {object} p live params — see `reverseParams()` + */ +export function reverseTime(age, p) { + const rate = p?.rate ?? 1; + const turn = Math.max(p?.turnAt ?? 0.55, 0); + const hold = Math.max(p?.hold ?? 0.12, 0); + const back = p?.back ?? 1.8; + const floor = p?.floor ?? 0; + if (age <= turn) return Math.max(floor, age * rate); + const peak = turn * rate; + if (age <= turn + hold) return Math.max(floor, peak); + return Math.max(floor, peak - (age - turn - hold) * back); +} + +/** + * The signed speed of that clock, d(time)/d(age). + * + * `+rate` on the way out, `0` at the top and once the floor is reached, `-back` + * on the way home. What an ability switches its *irreversible* behaviour on: + * emit while this is positive, stop while it is not. + */ +export function reverseRate(age, p) { + const rate = p?.rate ?? 1; + const turn = Math.max(p?.turnAt ?? 0.55, 0); + const hold = Math.max(p?.hold ?? 0.12, 0); + const back = p?.back ?? 1.8; + const floor = p?.floor ?? 0; + if (age <= turn) return age * rate <= floor ? 0 : rate; + if (age <= turn + hold) return 0; + return turn * rate - (age - turn - hold) * back <= floor ? 0 : -back; +} + +/** + * The integrating alternative to `reverseTime`. + * + * Use it when the rate genuinely comes from somewhere that is not a slider — a + * held key, a chained ability, a rate that is itself a function of what the + * cast hit. Prefer the closed form everywhere else, and know what you are + * giving up when you do not: **an accumulated clock cannot be re-derived, so a + * paused slider drag has no retroactive effect on it.** The current frame's + * step is still `dt × rate`, so the sliders are live going forward; the history + * is simply already spent. + * + * It exists mostly for the two things it *does* own that a closed form cannot: + * `turned`, the frame the direction flipped, and `emitDt`, the guard rail that + * keeps a reversal from feeding a negative number to something that logs. + */ +export class TimeWarpClock { + constructor(start = 0) { + this.reset(start); + } + + reset(start = 0) { + /** The bent clock. Hand this to a module's `now`. */ + this.now = start; + /** The signed step taken on the last `advance()`. */ + this.step = 0; + /** The rate last asked for. */ + this.rate = 1; + this._sign = 1; + /** True on exactly the frame the direction of travel changed. */ + this.turned = false; + return this; + } + + /** + * @param {number} dt seconds of real simulation time + * @param {number} [rate] × — negative runs the clock backwards + * @param {number} [floor] the clock may not go below this + * @param {number} [ceiling] + * @returns {number} the signed step actually taken, after clamping + */ + advance(dt, rate = 1, floor = -Infinity, ceiling = Infinity) { + this.rate = rate; + const next = clamp(this.now + dt * rate, floor, ceiling); + this.step = next - this.now; + this.now = next; + + const sign = this.step > 1e-9 ? 1 : this.step < -1e-9 ? -1 : 0; + this.turned = sign !== 0 && sign !== this._sign; + if (sign !== 0) this._sign = sign; + return this.step; + } + + /** +1, -1, or the last non-zero direction while stalled. */ + get direction() { + return this._sign; + } + + get reversing() { + return this.step < 0; + } + + get stalled() { + return this.step === 0; + } + + /** + * The delta the **irreversible** consumers get: `max(0, step)`. + * + * `RateEmitter.tick`, `ArcNetwork.update`, anything that spawns a decal. + * Feeding a `RateEmitter` a negative delta drives its fractional accumulator + * the wrong way and emits nothing while quietly banking credit, so the first + * frame back on the forward leg dumps the lot. + */ + get emitDt() { + return Math.max(0, this.step); + } + + /** `|step|` — for things that only want to know how much clock passed. */ + get spanDt() { + return Math.abs(this.step); + } +} + +/** + * A one-shot that re-arms when time runs back past it. + * + * The guard rail for every beat an ability fires at an instant: the burst at + * the top of the arc, the decal under the impact, the light punch. On a + * monotone clock these are a boolean. On a clock that can reverse they are a + * *crossing*, and the difference is that the same beat has to be able to fire + * again — or to be undone — when the clock comes back through it. + * + * `mark` is passed to `poll()` rather than held, so it stays a live slider: drag + * the beat while the sandbox is paused and it fires under your cursor. + * + * @example + * // once, in createShaders() + * this._turnGate = new RewindGate(); + * // every frame + * const t = reverseTime(this.age, this._warp); + * const crossed = this._turnGate.poll(t, settings.rewind.crackAt); + * if (crossed > 0) this._openCrack(); + * else if (crossed < 0) this._closeCrack(); + */ +export class RewindGate { + constructor() { + this.reset(); + } + + /** + * Arm the gate on the "before" side, so the first forward crossing fires even + * if the clock starts exactly on the mark. + */ + reset() { + this._side = -1; + /** How many times this gate has fired, either way. */ + this.crossings = 0; + return this; + } + + /** True once the clock is past the mark. */ + get past() { + return this._side > 0; + } + + /** + * @param {number} time the bent clock + * @param {number} mark the instant to watch, live + * @returns {number} `+1` crossed forward, `-1` crossed backward, `0` nothing + */ + poll(time, mark) { + const side = time >= mark ? 1 : -1; + if (side === this._side) return 0; + this._side = side; + this.crossings++; + return side; + } +} diff --git a/src/vfx/Tube.js b/src/vfx/Tube.js new file mode 100644 index 0000000..8f53cf8 --- /dev/null +++ b/src/vfx/Tube.js @@ -0,0 +1,1649 @@ +import { + AdditiveBlending, + Color, + DoubleSide, + Group, + Mesh, + ShaderMaterial, + Vector3 +} from 'three'; +import { createBeamTubeGeometry } from '../assets/ProceduralGeometry.js'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms, frame } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { globals } from '../config/globals.js'; +import { getColor } from '../utils/color.js'; +import { num, str, prefixed, buildKeys, buildDefaults, auditBlock } from './prefixedBlock.js'; +// From the leaf, NOT from `./SceneHooks.js`, and the distinction is load- +// bearing rather than tidy. `SceneHooks` imports `config/settings.js`; settings +// imports every ability block; six ability blocks (`firewhip`, `geyser`, +// `railcoil`, `cyclone`, …) import *this* file for `tubeDefaults()`. Importing +// the field from `SceneHooks` therefore closes a ring — +// settings → abilities/index → firewhip → Tube → SceneHooks → settings — and a +// ring that reads a `const` across itself throws or does not depending purely +// on which module you enter the graph from. It survived `main.js` and killed +// `import('src/abilities/registry.js')` stone dead, with a `ReferenceError` +// naming `ABILITY_SETTINGS`, from a file nowhere near the change. `hookFields` +// imports one class from three and nothing else, so there is no ring to be +// lucky about. See its header. +import { disruptGLSL, disruptUniforms } from './hookFields.js'; +import { clamp, saturate, smoothstep } from '../utils/math.js'; + +/** + * `vfx/Tube.js` — the parametric tube, generalised. + * + * ## What it draws + * + * One `(t, a)` grid — `assets/ProceduralGeometry.js#createBeamTubeGeometry`, the + * same one Nova Beam is drawn on — placed in world space by a vertex shader and + * drawn **three times at three radii**. Nothing about the shape touches the CPU, + * so a tube of any length, any profile and any path costs the same. + * + * ## Draw-call cost + * + * **3**, always, whatever the path mode and however long the column is. If an + * ability wants coils, discs or a charge orb on it, those are `FilamentPaths` + * and `Shell` — this module deliberately stops at the column so the count stays + * a number you can hold in your head. + * + * ## The three layers, and why the middle one is inverted + * + * This is lifted wholesale from `materials/BeamMaterial.js`, because it is the + * single best idea in that file and re-deriving it badly in a second place would + * be a waste: + * + * HALO — widest, nothing but a rim term. The atmosphere the tube is shoving. + * SHEATH — rim-weighted, so it reads as *hollow* and its silhouette edges are + * the brightest part of it. + * CORE — narrow, and weighted the **opposite** way: brightest where the view + * ray runs down the barrel and its path through the tube is longest. + * + * Rim-weighted outside, axis-weighted inside, both faces adding: that is a + * volume integral, cheaply, and the inversion is the entire reason the middle + * reads as a solid rod rather than as a lit pipe. Widen `coreWidth` or push + * `coreFill` and the three collapse into one white tube — the sheath is only + * legible because the core leaves it room. (`SHELL` was renamed `SHEATH` here + * only so it does not collide with the sibling module `vfx/Shell.js`.) + * + * ## The one rule for using it well + * + * **Place everything else against `radiusAt()` and `pointAt()`, never against + * your own copy of the numbers.** Those two functions are the JS mirror of the + * `tubeRadius()` / `tubeAxis()` pair the vertex shader uses, and they are what + * keeps a dust skirt, a debris ribbon and a ground scour welded to the column + * when the profile is dragged — mid-cast, with the clock stopped. A funnel whose + * skirt was sized from `settings.x.tubeThroat * 3` at spawn is a funnel that + * comes apart the first time somebody touches a slider. + * + * The mirror is a deliberate duplication. The alternative — reading the tube's + * shape back off the GPU — is a pipeline stall per query, and the queries happen + * several times a frame. What is *not* mirrored is the `wander` noise: the JS + * side returns the **mean** axis, because a skirt placed on the noisy axis + * jitters against the tube it is supposed to hug, and the wobble is centimetres. + * + * ## Path modes + * + * | mode | what it is | + * | --- | --- | + * | `STRAIGHT` | the beam. A chord from origin to target with a smooth drift on it. | + * | `WHIP` | a curvature wave travelling handle → tip. See below. | + * | `FUNNEL` | a vortex: tight throat, flared skirt at the floor, flared mouth at the top. | + * | `VINE` | grows. The front *is* the length; the radius tapers to nothing at the tip; it can recoil on a spring. | + * | `ARC` | a bow, with the apex anywhere along the chord. | + * + * ### WHIP, and why the crack is not on a timer + * + * A bullwhip cracks because the loop travelling down it carries a fixed amount + * of energy through a decreasing mass per length, so the loop tightens and + * accelerates; when it reaches the tip the tip briefly goes supersonic. Two + * terms reproduce that here, and both of them are in the geometry: + * + * 1. **the lobe** — a Gaussian bump of lateral offset centred on `wavePhase`, + * whose amplitude grows as the phase approaches the tip (`waveGain`); + * 2. **arc-length conservation** — a whip does not stretch, so the length the + * lobe eats has to come out of the axial extent. To first order that excess + * is `½∫(dy/ds)² ds`, which for a Gaussian has the closed form + * `½·√(π/2)·A²/w ≈ 0.6267·A²/w`, distributed along the whip by the + * cumulative of the same bump. + * + * So while the loop is mid-whip the tip is pulled *back*, and as the loop runs + * off the end the lateral offset collapses and the axial extent returns — both + * at once. That is a speed spike, and it is a property of the curve rather than + * of a clock. + * + * `tipSpeed` is that curve differentiated with respect to its own driver + * (central difference on the wave phase, times the phase rate), so it is correct + * on a **zero-length frame** — pause the sandbox and drag `tubeWaveWidth` and + * the reported speed changes, because the shape did. `waveSpeed` is the speed + * the loop itself travels at, `length × waveRate`. When the first crosses the + * second times `crackRatio`, `tube.crack.fired` is true for exactly that frame + * and `tube.crack.point` is where the tip was standing. Fire the shock ring + * there. Do not schedule it. + * + * ### FUNNEL, and the one function + * + * `radiusAt(t)` *is* the vortex profile: `throat + skirt(t) + mouth(t)`, where + * the skirt is a falling power near the floor and the mouth is a rising one near + * the top. The debris ribbons, the dust skirt and the ground scour all read it, + * so dragging `tubeSkirtFlare` moves every one of them together. The first + * version of this had the skirt as its own slider on the ability and the scour + * as another; they were never the same number twice. + * + * ## What it reads from settings + * + * Everything in `tubeDefaults()`, by prefixed key, off whatever object is handed + * to `sync()` — plus `settings.global` for the master multipliers. Spread + * `tubeDefaults()` into the ability's settings block and `tubeSchema()` into its + * editor schema and every control below is a slider for free. Nothing is + * captured: `sync()` re-reads all of it every frame. + */ + +/* ---------------------------------------------------------------- */ +/* Enums */ +/* ---------------------------------------------------------------- */ + +/** Which parametric path the column is threaded along. Compile-time. */ +export const TubePath = Object.freeze({ + STRAIGHT: 0, // a chord — the beam + WHIP: 1, // a curvature wave running handle → tip + FUNNEL: 2, // a vortex profile standing on the floor + VINE: 3, // grows from the base, tapers to nothing, recoils on a spring + ARC: 4 // a bow with a placeable apex +}); + +/** Which of the three concentric layers a material is. Compile-time. */ +export const TubeLayer = Object.freeze({ + CORE: 0, // the axis-weighted rod + SHEATH: 1, // the rim-weighted hollow around it + HALO: 2 // the wide rim-only bloom +}); + +/** Enum → name, for the editor dropdown and for readable diagnostics. */ +export const TUBE_PATH_NAMES = Object.freeze(['STRAIGHT', 'WHIP', 'FUNNEL', 'VINE', 'ARC']); +const PATH_NAMES = TUBE_PATH_NAMES; + +const TAU = Math.PI * 2; + +/** + * Half the closed-form slope energy of a unit Gaussian lobe, `½·√(π/2)`. + * See the header: this is the length the whip's loop eats. + */ +const WHIP_EATEN = 0.6266570686577501; + +/* ---------------------------------------------------------------- */ +/* The settings contract */ +/* ---------------------------------------------------------------- */ + +/** + * Every value the tube reads, unprefixed, with its default and its unit. + * + * An ability spreads `tubeDefaults()` into its own settings block, so these + * become `tubeRadius`, `tubeFlare`… and appear in the editor like anything else + * it owns. Flat and prefixed rather than nested because the editor's schema + * walker works on flat keys, and because two tubes on one ability (a parent beam + * and its children) then simply take two prefixes. + */ +const TUBE_FIELDS = { + /* --- the radius profile (STRAIGHT / WHIP / ARC) --- */ + radius: 0.62, // half-width at the far end, metres + radiusNear: 0.22, // half-width at the muzzle, metres + radiusCurve: 0.7, // <1 opens early, >1 stays thin then opens late + flare: 0.9, // extra half-width where it lands, × radius + flareWidth: 0.22, // how much of the far end flares, fraction of length + throb: 0.06, // breathing amplitude, × radius + throbScale: 2.4, // pressure waves along the column, cycles per length + throbSpeed: 1.6, // Hz they travel at + + /* --- the axis --- */ + wander: 0.06, // smooth low-frequency drift of the axis, metres + wanderScale: 0.9, // drift features per length + wanderSpeed: 0.7, // Hz the drift crawls at + + /* --- the surface --- */ + ripple: 0.12, // radial break-up of the barrel, × radius + rippleBands: 1.6, // break-up features around the barrel + rippleScale: 3.2, // break-up features along it + rippleSpeed: 2.4, // Hz it crawls downrange at + streak: 0.9, // filaments streaming down the surface + streakSharp: 0.45, // 0 = soft wash, 1 = hard threads + streakScale: 5.5, // filament features per length + streakBands: 2.6, // filament features around the barrel + streakGlow: 1.1, // how hard the sheath's filaments burn to core colour + flowSpeed: 7.0, // metres-of-parameter per second the filaments run + bands: 0.0, // rings along the length, cycles per length (0 = off) + bandSharp: 2.0, // how tight each ring is + bandDepth: 0.5, // how much they modulate alpha, 0..1 + bandSpeed: 0.6, // Hz they travel at + + /* --- the three layers --- */ + coreWidth: 0.38, // core radius, × the profile + coreFill: 0.85, // how solid the core reads + coreSharp: 1.4, // axis-weighting exponent — the inversion + edgePower: 2.2, // rim-weighting exponent for the sheath + sheathWidth: 1.0, // sheath radius, × the profile + sheathRim: 0.9, // strength of the sheath's silhouette + sheathFill: 0.18, // how much body the sheath keeps + sheathOpacity: 0.9, + haloWidth: 1.9, // halo radius, × the profile + haloRim: 3.4, // rim exponent — high, so it is only a silhouette + haloOpacity: 0.5, + + /* --- the ends --- */ + muzzleGlow: 1.5, // brightness where the column leaves the caster + muzzleLength: 0.1, // how far that glow reaches, fraction of length + tipGlow: 1.6, // brightness of the leading edge + tipLength: 0.06, // how soft that edge is, fraction of length + + /* --- WHIP --- */ + waveRate: 1.35, // loops per second travelling handle → tip + waveWidth: 0.16, // how tight the loop is, fraction of length + waveAmp: 0.3, // lateral throw of the loop, fraction of length + waveGain: 2.2, // how much the loop grows on its way to the tip, × + waveCurve: 1.6, // when that growth happens, >1 = late + waveRoll: 0.0, // plane the loop cracks in, radians (0 = vertical) + sag: 0.12, // how far the whip hangs under its own weight, metres + crackRatio: 1.0, // tip speed ÷ wave speed at which the crack fires + + /* --- FUNNEL --- */ + throat: 0.55, // the vortex waist, metres + skirtFlare: 1.6, // extra radius at the floor, metres + skirtHeight: 0.24, // how far up the skirt reaches, fraction of height + skirtCurve: 1.7, // how abruptly it flares, >1 = tighter to the floor + mouthFlare: 2.4, // extra radius at the top, metres + mouthStart: 0.55, // where the mouth begins to open, fraction of height + mouthCurve: 1.4, // how abruptly it opens + spin: 0.9, // revolutions per second the surface rotates + spinTwist: 1.6, // extra revolutions from floor to mouth + sway: 0.35, // how far the axis precesses, metres + swayScale: 0.5, // twist of the precession along the height + swaySpeed: 0.25, // revolutions per second it precesses + swayCurve: 1.8, // how much of the sway is at the top, >1 = only the top + + /* --- VINE --- */ + tipTaper: 1.3, // how fast the radius falls to zero at the front + meander: 0.18, // helical wander of the stem, metres + meanderTurns: 1.4, // turns of that helix over the length + recoilAmp: 0.35, // how far the spring pulls the tip back, fraction + recoilFreq: 2.6, // Hz the spring rings at + recoilDamp: 3.4, // s⁻¹ it dies at + recoilSway: 0.6, // lateral bow while it is recoiling, metres + + /* --- ARC --- */ + arcHeight: 2.6, // apex height above the chord, metres + arcLateral: 0.0, // apex offset across the chord, metres + arcBias: 0.5, // where the apex sits, 0..1 along the chord + arcCurve: 1.0, // >1 pinches the apex, <1 flattens the top + + /* --- rendering --- */ + opacity: 1.0, + glow: 2.2, // emissive gain into bloom + softFade: 0.6, // metres of depth fade against the opaque scene + + /* --- colour (I5: four pickers, none derived from another) --- */ + colorCore: '#ffffff', // the axis-weighted middle + colorInner: '#d2f5ff', // just off the middle + colorOuter: '#4ac7ff', // the sheath body + colorHalo: '#0d33d9' // the outer bloom +}; + +const FIELD_NAMES = Object.freeze(Object.keys(TUBE_FIELDS)); + +/** + * Per-path tuning, applied on top of the field defaults. + * + * The same numbers do not suit a vortex and a bullwhip: a funnel with a 0.9 + * flare has a bell on the end of it, and a whip with the beam's 0.06 wander + * shimmers along its whole length instead of hanging still between cracks. + * These are starting points, not constraints — every one of them is a slider + * the moment the ability spreads the block. + */ +const PATH_DEFAULTS = { + [TubePath.WHIP]: { + radius: 0.16, radiusNear: 0.09, radiusCurve: 1.6, flare: 0, flareWidth: 0.1, + wander: 0.02, streakScale: 9, glow: 2.6, tipGlow: 2.4, tipLength: 0.04 + }, + [TubePath.FUNNEL]: { + flare: 0, flareWidth: 0.1, throb: 0.03, wander: 0.05, + rippleScale: 2.2, streakScale: 2.6, flowSpeed: -2.5, streakSharp: 0.6, + coreWidth: 0.94, coreFill: 0.2, sheathWidth: 1.0, haloWidth: 1.22, + haloRim: 2.2, glow: 1.2 + }, + [TubePath.VINE]: { + radius: 0.22, throb: 0.02, wander: 0.03, ripple: 0.25, streak: 0.35, + bands: 7, bandDepth: 0.45, coreWidth: 0.55, coreFill: 0.5, + haloWidth: 1.5, haloRim: 2.6, glow: 1.1 + }, + [TubePath.ARC]: { flare: 0.3, flareWidth: 0.3 } +}; + + +/** + * A fresh settings fragment for one tube. Spread into an ability's block. + * + * ```js + * export const firewhip = { range: 16, ..., ...tubeDefaults() }; + * ``` + * + * @param {string} [prefix] key prefix — take a second one for a second tube + * @param {number} [path] TubePath.*, which picks the per-path tuning above + * @param {object} [overrides] **prefixed** overrides, e.g. `{ lashRadius: 0.2 }`, + * so they read the way the ability will read them + */ +export function tubeDefaults(prefix = 'tube', path = TubePath.STRAIGHT, overrides = {}) { + return buildDefaults(FIELD_NAMES, TUBE_FIELDS, PATH_DEFAULTS[path] ?? {}, prefix, overrides); +} + +/** + * The unprefixed → prefixed key map, built once so `sync()` never concatenates + * a string on a frame. + */ +export function tubeKeys(prefix = 'tube') { + return buildKeys(FIELD_NAMES, prefix); +} + +/** Editor folders for one tube. Spread into an ability's schema. */ +export function tubeSchema(prefix = 'tube', path = TubePath.STRAIGHT) { + const k = (name) => prefixed(prefix, name); + const schema = { + 'The column': [ + [k('radius'), 0.01, 4, 0.01, 'far radius (m)'], + [k('radiusNear'), 0.01, 4, 0.01, 'near radius (m)'], + [k('radiusCurve'), 0.05, 4, 0.01, 'radius curve'], + [k('flare'), 0, 4, 0.01, 'flare'], + [k('flareWidth'), 0.01, 1, 0.01, 'flare width'], + [k('throb'), 0, 0.5, 0.001, 'throb'], + [k('throbScale'), 0, 12, 0.1, 'throb bands'], + [k('throbSpeed'), 0, 8, 0.01, 'throb Hz'], + [k('wander'), 0, 1, 0.001, 'axis drift (m)'], + [k('wanderScale'), 0, 6, 0.01, 'drift scale'], + [k('wanderSpeed'), 0, 4, 0.01, 'drift Hz'] + ], + 'Core / sheath / halo': [ + [k('coreWidth'), 0.02, 2, 0.01, 'core width'], + [k('coreFill'), 0, 2, 0.01, 'core fill'], + [k('coreSharp'), 0.05, 8, 0.01, 'core axis power'], + [k('edgePower'), 0.05, 8, 0.01, 'sheath rim power'], + [k('sheathWidth'), 0.05, 3, 0.01, 'sheath width'], + [k('sheathRim'), 0, 2, 0.01, 'sheath rim'], + [k('sheathFill'), 0, 1, 0.01, 'sheath fill'], + [k('sheathOpacity'), 0, 1, 0.01, 'sheath opacity'], + [k('haloWidth'), 0.05, 6, 0.01, 'halo width'], + [k('haloRim'), 0.05, 10, 0.01, 'halo rim power'], + [k('haloOpacity'), 0, 1, 0.01, 'halo opacity'] + ], + 'The surface': [ + [k('ripple'), 0, 1, 0.01, 'ripple'], + [k('rippleBands'), 0, 8, 0.01, 'ripple bands'], + [k('rippleScale'), 0, 12, 0.01, 'ripple scale'], + [k('rippleSpeed'), 0, 10, 0.01, 'ripple Hz'], + [k('streak'), 0, 2, 0.01, 'streaks'], + [k('streakSharp'), 0, 1, 0.01, 'streak sharpness'], + [k('streakScale'), 0, 20, 0.1, 'streak scale'], + [k('streakBands'), 0, 8, 0.01, 'streak bands'], + [k('streakGlow'), 0, 3, 0.01, 'streak glow'], + [k('flowSpeed'), 0, 24, 0.1, 'flow speed'], + [k('bands'), 0, 24, 0.1, 'rings/length'], + [k('bandSharp'), 0.05, 8, 0.01, 'ring sharpness'], + [k('bandDepth'), 0, 1, 0.01, 'ring depth'], + [k('bandSpeed'), -6, 6, 0.01, 'ring Hz'] + ], + 'The ends': [ + [k('muzzleGlow'), 0, 5, 0.01, 'muzzle glow'], + [k('muzzleLength'), 0, 0.6, 0.001, 'muzzle length'], + [k('tipGlow'), 0, 5, 0.01, 'tip glow'], + [k('tipLength'), 0.001, 0.4, 0.001, 'tip length'] + ], + 'Tube colour & render': [ + k('colorCore'), + k('colorInner'), + k('colorOuter'), + k('colorHalo'), + [k('opacity'), 0, 1, 0.01, 'opacity'], + [k('glow'), 0, 8, 0.01, 'glow'], + [k('softFade'), 0, 3, 0.01, 'soft fade (m)'] + ] + }; + + if (path === TubePath.WHIP) { + schema['The whip'] = [ + [k('waveRate'), 0, 6, 0.01, 'loops/second'], + [k('waveWidth'), 0.02, 0.6, 0.001, 'loop width'], + [k('waveAmp'), 0, 1, 0.001, 'loop throw'], + [k('waveGain'), 0.2, 6, 0.01, 'loop gain'], + [k('waveCurve'), 0.1, 6, 0.01, 'gain curve'], + [k('waveRoll'), 0, TAU, 0.01, 'crack plane (rad)'], + [k('sag'), 0, 2, 0.01, 'sag (m)'], + [k('crackRatio'), 0.2, 4, 0.01, 'crack ratio'] + ]; + } + if (path === TubePath.FUNNEL) { + schema['The vortex'] = [ + [k('throat'), 0.02, 4, 0.01, 'throat (m)'], + [k('skirtFlare'), 0, 8, 0.01, 'skirt flare (m)'], + [k('skirtHeight'), 0.01, 1, 0.01, 'skirt height'], + [k('skirtCurve'), 0.1, 6, 0.01, 'skirt curve'], + [k('mouthFlare'), 0, 12, 0.01, 'mouth flare (m)'], + [k('mouthStart'), 0, 0.99, 0.01, 'mouth start'], + [k('mouthCurve'), 0.1, 6, 0.01, 'mouth curve'], + [k('spin'), -6, 6, 0.01, 'spin (rev/s)'], + [k('spinTwist'), -8, 8, 0.01, 'twist'], + [k('sway'), 0, 4, 0.01, 'precession (m)'], + [k('swayScale'), 0, 3, 0.01, 'precession twist'], + [k('swaySpeed'), -3, 3, 0.01, 'precession (rev/s)'], + [k('swayCurve'), 0.1, 6, 0.01, 'precession curve'] + ]; + } + if (path === TubePath.VINE) { + schema['The vine'] = [ + [k('tipTaper'), 0.05, 6, 0.01, 'tip taper'], + [k('meander'), 0, 2, 0.01, 'meander (m)'], + [k('meanderTurns'), 0, 8, 0.01, 'meander turns'], + [k('recoilAmp'), 0, 1, 0.01, 'recoil'], + [k('recoilFreq'), 0, 10, 0.01, 'recoil Hz'], + [k('recoilDamp'), 0.1, 16, 0.01, 'recoil damping'], + [k('recoilSway'), 0, 4, 0.01, 'recoil bow (m)'] + ]; + } + if (path === TubePath.ARC) { + schema['The arc'] = [ + [k('arcHeight'), -12, 12, 0.01, 'apex height (m)'], + [k('arcLateral'), -12, 12, 0.01, 'apex offset (m)'], + [k('arcBias'), 0.05, 0.95, 0.01, 'apex position'], + [k('arcCurve'), 0.1, 4, 0.01, 'apex curve'] + ]; + } + return schema; +} + +/* ---------------------------------------------------------------- */ +/* GLSL */ +/* ---------------------------------------------------------------- */ + +const TUBE_UNIFORMS = /* glsl */ ` + #define PI 3.141592653589793 + #define TAU 6.283185307179586 + + #define PATH_STRAIGHT 0 + #define PATH_WHIP 1 + #define PATH_FUNNEL 2 + #define PATH_VINE 3 + #define PATH_ARC 4 + + uniform float uTime; + uniform vec3 uOrigin; + uniform vec3 uTarget; + uniform vec3 uSide; + uniform float uSeed; + uniform float uProgress; + uniform float uFade; + uniform float uWidthFade; + + uniform float uRadius; + uniform float uRadiusNear; + uniform float uRadiusCurve; + uniform float uRadiusScale; + uniform float uFlare; + uniform float uFlareWidth; + uniform float uThrob; + uniform float uThrobScale; + uniform float uThrobSpeed; + + uniform float uWander; + uniform float uWanderScale; + uniform float uWanderSpeed; + + uniform float uWavePhase; + uniform float uWaveWidth; + uniform float uWaveAmp; + uniform float uWaveGain; + uniform float uWaveCurve; + uniform float uWaveRoll; + uniform float uSag; + + uniform float uThroat; + uniform float uSkirtFlare; + uniform float uSkirtHeight; + uniform float uSkirtCurve; + uniform float uMouthFlare; + uniform float uMouthStart; + uniform float uMouthCurve; + uniform float uSpin; + uniform float uSpinTwist; + uniform float uSway; + uniform float uSwayScale; + uniform float uSwaySpeed; + uniform float uSwayCurve; + + uniform float uGrow; + uniform float uTipTaper; + uniform float uMeander; + uniform float uMeanderTurns; + uniform float uRecoil; + uniform float uRecoilSway; + + uniform float uArcHeight; + uniform float uArcLateral; + uniform float uArcBias; + uniform float uArcCurve; + + uniform float uRipple; + uniform float uRippleBands; + uniform float uRippleScale; + uniform float uRippleSpeed; + uniform float uStreak; + uniform float uStreakSharp; + uniform float uStreakScale; + uniform float uStreakBands; + uniform float uStreakGlow; + uniform float uFlowSpeed; + uniform float uBands; + uniform float uBandSharp; + uniform float uBandDepth; + uniform float uBandSpeed; + + uniform float uCoreSharp; + uniform float uCoreFill; + uniform float uEdgePower; + uniform float uSheathRim; + uniform float uSheathFill; + uniform float uHaloRim; + uniform float uLayerOpacity; + + uniform float uMuzzleGlow; + uniform float uMuzzleLength; + uniform float uTipGlow; + uniform float uTipLength; + + uniform float uOpacity; + uniform float uGlow; + uniform float uSoftFade; + uniform vec3 uColorCore; + uniform vec3 uColorInner; + uniform vec3 uColorOuter; + uniform vec3 uColorHalo; +`; + +const TUBE_VARYINGS = /* glsl */ ` + varying float vT; + varying float vSpin; + varying float vFacing; + varying float vViewZ; + varying float vDisrupt; // spellbreak's field, sampled per vertex — see SceneHooks +`; + +/** + * The shape. Every layer, and every consumer on the JS side, is built out of + * exactly these two functions — see `Tube#radiusAt` / `Tube#pointAt` for the + * mirror, and the module header for why the duplication is deliberate. + */ +const TUBE_SHAPE = /* glsl */ ` + /** + * Half-width of the column at t, metres. + * + * Three profiles, one signature. FUNNEL is a real vortex — a tight waist with + * a skirt flaring to the floor and a mouth flaring to the top — rather than a + * cone, because a cone has no throat and a vortex is *all* throat. + */ + float tubeRadius(float t) { + float u = clamp(t, 0.0, 1.0); + float r; + + #if TUBE_PATH == PATH_FUNNEL + float skirt = uSkirtFlare * + pow(1.0 - smoothstep(0.0, max(uSkirtHeight, 1e-3), u), max(uSkirtCurve, 0.05)); + float mouth = uMouthFlare * + pow(smoothstep(min(uMouthStart, 0.99), 1.0, u), max(uMouthCurve, 0.05)); + r = uThroat + skirt + mouth; + + #elif TUBE_PATH == PATH_VINE + // The front is the length, so t is renormalised against it and the taper + // is measured from the *growing* tip rather than from the far end. + float s = clamp(u / max(uGrow, 1e-3), 0.0, 1.0); + r = uRadius * pow(1.0 - s, max(uTipTaper, 0.01)); + + #else + r = mix(uRadiusNear, uRadius, pow(u, max(uRadiusCurve, 0.01))); + r *= 1.0 + uFlare * smoothstep(1.0 - max(uFlareWidth, 1e-3), 1.0, u); + #endif + + // Pressure waves travelling along it. Small — this is breathing, not a + // sausage. + r *= 1.0 + uThrob * sin((u * uThrobScale - uTime * uThrobSpeed) * TAU); + return max(r * uRadiusScale * uWidthFade, 1e-4); + } + + /** + * The column's frames. + * + * Two of them, and they are not the same thing. n1/n2 span the *cross + * section* and come from uSide by Gram-Schmidt, exactly as the beam does it, + * so the seam in the angular noise stays put as the axis tilts. upN/sideN + * are the *bending* frame: an honest up and an honest sideways, so a whip + * cracks vertically and a funnel precesses horizontally whatever the cast is + * doing. Using one frame for both put the whip's crack in a plane that rolled + * with the aim, which looked like a bug and was one. + */ + void tubeFrame(out vec3 dir, out float span, out vec3 n1, out vec3 n2, + out vec3 upN, out vec3 sideN) { + vec3 delta = uTarget - uOrigin; + span = max(length(delta), 0.01); + dir = delta / span; + + vec3 lateral = uSide - dir * dot(uSide, dir); + n1 = length(lateral) > 1e-4 ? normalize(lateral) : normalize(cross(dir, vec3(0.0, 1.0, 0.0))); + n2 = normalize(cross(dir, n1)); + + // Degenerate when the axis *is* vertical (a funnel), which is exactly when + // the fallback matters: n1 is then already horizontal, so the precession + // circle stays flat on the floor. + vec3 vertical = vec3(0.0, 1.0, 0.0) - dir * dir.y; + upN = length(vertical) > 1e-4 ? normalize(vertical) : n1; + sideN = normalize(cross(dir, upN)); + } + + /** + * Approximate normalised cumulative of the Gaussian lobe's slope energy. + * + * The exact thing is an error function, which GLSL ES 1.00 does not have and + * which nobody would be able to see anyway; a smoothstep matched at the + * inflection is within a percent over the range that matters. + */ + float whipCumulative(float x) { + return smoothstep(-1.6, 1.6, x); + } + + /** + * A point on the column's mean axis at t. + * + * drive is whatever scalar the path's motion is a function of — the wave + * phase for WHIP, seconds since the snap for VINE, ignored elsewhere. It is a + * parameter rather than a uniform read so the JS mirror can differentiate the + * curve with respect to it without lying about what it is differentiating. + */ + vec3 tubeAxis(float t, float drive, vec3 dir, float span, + vec3 n1, vec3 n2, vec3 upN, vec3 sideN) { + float axial = t; + vec3 lateral = vec3(0.0); + + #if TUBE_PATH == PATH_WHIP + float w = max(uWaveWidth, 1e-3); + float x = (t - drive) / w; + float lobe = exp(-x * x); + float amp = uWaveAmp * mix(1.0, uWaveGain, + pow(clamp(drive, 0.0, 1.0), max(uWaveCurve, 0.05))); + // A whip does not stretch: the length the loop eats comes out of the + // axial extent, distributed by the cumulative of the same lobe. This is + // the term that pulls the tip back mid-crack and lets it go afterwards. + axial = t - WHIP_EATEN * amp * amp / w * whipCumulative(x); + lateral = (upN * cos(uWaveRoll) + sideN * sin(uWaveRoll)) * amp * lobe * span; + lateral -= upN * uSag * sin(clamp(t, 0.0, 1.0) * PI); + + #elif TUBE_PATH == PATH_FUNNEL + // The whole column leans and precesses, weighted to the top so the foot + // stays planted on the scour it is standing in. + float ang = (uTime * uSwaySpeed + t * uSwayScale) * TAU + uSeed * 6.283; + lateral = (sideN * cos(ang) + upN * sin(ang)) * + uSway * pow(clamp(t, 0.0, 1.0), max(uSwayCurve, 0.05)); + + #elif TUBE_PATH == PATH_VINE + // uRecoil is the spring, resolved on the CPU from drive seconds and the + // three recoil sliders: 1 at rest, <1 hauled back, >1 overshooting. + axial = t * uRecoil; + float coil = (t * uMeanderTurns + uSeed) * TAU; + lateral = (n1 * cos(coil) + n2 * sin(coil)) * uMeander * t; + lateral += sideN * uRecoilSway * (1.0 - uRecoil) * sin(clamp(t, 0.0, 1.0) * PI); + + #elif TUBE_PATH == PATH_ARC + // A skewed bell rather than a parabola, so the apex can sit anywhere + // along the chord. smoothstep on the tent kills the kink at the top. + float b = clamp(uArcBias, 0.05, 0.95); + float k = t < b ? t / b : (1.0 - t) / (1.0 - b); + float bell = pow(smoothstep(0.0, 1.0, clamp(k, 0.0, 1.0)), max(uArcCurve, 0.05)); + lateral = upN * uArcHeight * bell + sideN * uArcLateral * bell; + #endif + + vec3 p = uOrigin + dir * (axial * span) + lateral; + + // Smooth drift, pinned at both ends. Deliberately smooth: the bolt's charm + // is that its noise keeps its corners, and a tube that kinks is a bolt. + float ends = sin(clamp(t, 0.0, 1.0) * PI); + float dx = snoise(vec3(t * uWanderScale, uTime * uWanderSpeed, uSeed)); + float dy = snoise(vec3(t * uWanderScale + 31.7, uTime * uWanderSpeed, uSeed + 7.3)); + return p + (n1 * dx + n2 * dy) * uWander * ends; + } +`; + +const TUBE_VERTEX = /* glsl */ ` + ${TUBE_UNIFORMS} + ${TUBE_VARYINGS} + + #define WHIP_EATEN ${WHIP_EATEN.toFixed(7)} + + ${noiseGLSL} + ${disruptGLSL} + ${TUBE_SHAPE} + + void main() { + vec3 dir, n1, n2, upN, sideN; + float span; + tubeFrame(dir, span, n1, n2, upN, sideN); + + float t = position.x; + float angle = position.y * TAU; + vec3 nrm = n1 * cos(angle) + n2 * sin(angle); + + // The vortex spins by rotating the *lookup*, not the mesh: a circular cross + // section rotated about its own centre is a no-op, and re-tessellating it + // would cost a swirl uniform nobody could see. + float sa = angle + (uSpin * uTime + uSpinTwist * t) * TAU; + + float rip = snoise(vec3( + cos(sa) * uRippleBands, + sin(sa) * uRippleBands, + t * uRippleScale - uTime * uRippleSpeed + uSeed + )); + float r = tubeRadius(t) * (1.0 + uRipple * rip); + + #if TUBE_PATH == PATH_WHIP + float drive = uWavePhase; + #else + float drive = 0.0; + #endif + + vec3 here = tubeAxis(t, drive, dir, span, n1, n2, upN, sideN) + nrm * r; + + vT = t; + vSpin = sa; + // 1 looking down the barrel, 0 at the silhouette. Both tube weightings are + // built out of this one number, in opposite directions. + vFacing = abs(dot(normalize(cameraPosition - here), nrm)); + // Opt-in to vfx/SceneHooks.js's disruption field. The tube's vertices are + // already in world space by this line, so this is one distance against a + // uniform — and exactly 0.0, uniformly across the draw call, when nothing + // is disrupting. A beam standing inside Spellbreak's zone is the reference + // case for the whole hook: without this line the one ability that is meant + // to be aware of the others cannot see the loudest thing on the stage. + vDisrupt = disruptAt(here); + + vec4 mv = viewMatrix * vec4(here, 1.0); + vViewZ = mv.z; + gl_Position = projectionMatrix * mv; + } +`; + +const TUBE_FRAGMENT = /* glsl */ ` + ${TUBE_UNIFORMS} + ${TUBE_VARYINGS} + + uniform float uGlobalGlow; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + + ${noiseGLSL} + ${commonGLSL} + ${disruptGLSL} + + void main() { + // Ahead of the leading edge there is no tube yet. The column is built whole + // and clipped here rather than scaled, so its *shape* never changes as the + // front travels — only how much of it exists. + float tip = max(uTipLength, 1e-3); + float drawn = smoothstep(uProgress, uProgress - tip, vT); + if (drawn <= 0.002) discard; + + // Filaments stretched hard along the axis and crawling with the flow. + float flow = ridged(vec3( + vT * uStreakScale - uTime * uFlowSpeed, + cos(vSpin) * uStreakBands, + sin(vSpin) * uStreakBands + uSeed + ), 4); + float streak = smoothstep(mix(0.42, 0.86, clamp(uStreakSharp, 0.0, 1.0)), 0.99, flow) * uStreak; + + float facing = clamp(vFacing, 0.0, 1.0); + float axisward = pow(facing, max(uCoreSharp, 0.05)); + float rim = pow(1.0 - facing, max(uEdgePower, 0.05)); + + vec3 color; + float alpha; + + #if TUBE_LAYER == 0 /* CORE */ + color = mix(uColorInner, uColorCore, clamp(0.35 + streak, 0.0, 1.0)); + alpha = uCoreFill * mix(0.28, 1.0, axisward) + streak * 0.35; + + #elif TUBE_LAYER == 1 /* SHEATH */ + color = mix(uColorOuter, uColorInner, clamp(rim * 0.55 + streak, 0.0, 1.0)); + color += uColorCore * streak * uStreakGlow; + alpha = rim * uSheathRim + uSheathFill * mix(0.12, 1.0, axisward) + streak * 0.4; + + #else /* HALO */ + float wide = pow(1.0 - facing, max(uHaloRim, 0.05)); + color = mix(uColorHalo, uColorOuter, wide); + alpha = wide; + #endif + + // Segment rings: vine nodes, pressure bands, the joints in a chitin tube. + // Off by default because they are the fastest way to make a rod of light + // look like plumbing. + if (uBands > 0.0) { + float b = 0.5 + 0.5 * sin((vT * uBands - uTime * uBandSpeed) * TAU); + alpha *= mix(1.0, pow(b, max(uBandSharp, 0.05)), clamp(uBandDepth, 0.0, 1.0)); + } + + float muzzle = smoothstep(uMuzzleLength, 0.0, vT); + color += uColorCore * muzzle * uMuzzleGlow; + alpha += muzzle * uMuzzleGlow * 0.2; + + float lead = smoothstep(uProgress - tip * 2.0, uProgress, vT); + color += uColorCore * lead * uTipGlow; + alpha += lead * uTipGlow * 0.18; + + alpha *= drawn * uFade * uOpacity * uLayerOpacity; + if (alpha < 0.003) discard; + + vec2 screenUV = gl_FragCoord.xy / uResolution; + alpha *= softFade(uSceneDepth, screenUV, vViewZ, uCameraNear, uCameraFar, uSoftFade); + if (alpha < 0.003) discard; + + color *= uGlow * uGlobalGlow; + + // Last, on the finished fragment. Draining a rod of light before its own + // glow gain is applied does nothing you can see — the gain is 3 to 8 here, + // so a desaturated colour multiplied by eight is still white. Ordering the + // other way round was the first attempt and the beam simply ignored the + // field. The cell erosion has to come after the soft fade too, or the two + // alpha tests fight and the shards flicker at the floor line. + disruptShade(color, alpha, vDisrupt, gl_FragCoord.xy); + if (alpha < 0.003) discard; + + gl_FragColor = vec4(color, alpha); + } +`; + +/** + * One layer of one tube. + * + * Uniforms are pushed by `Tube#sync` rather than by a `userData.sync` of their + * own — unlike `BeamMaterial`, which re-reads `settings.beam` once per pass. + * Three passes over one settings block is three times the property lookups and + * three times the `getColor()` calls for one identical answer, so the resolve + * happens once in the owner and the result is pushed thrice. + * + * @param {number} layer TubeLayer.* + * @param {number} path TubePath.* + */ +export function createTubeMaterial(layer = TubeLayer.CORE, path = TubePath.STRAIGHT) { + return new ShaderMaterial({ + defines: { TUBE_LAYER: layer, TUBE_PATH: path }, + transparent: true, + depthWrite: false, + depthTest: true, + blending: AdditiveBlending, + side: DoubleSide, + toneMapped: false, + uniforms: sharedUniforms({ + // Opt-in to vfx/SceneHooks.js's disruption field — the shared boxes, by + // identity, never a clone. A material that copies them reads a field that + // is permanently off and there is no error to find. + ...disruptUniforms(), + uOrigin: { value: new Vector3() }, + uTarget: { value: new Vector3(0, 0, 1) }, + uSide: { value: new Vector3(1, 0, 0) }, + uSeed: { value: 0 }, + uProgress: { value: 1 }, + uFade: { value: 1 }, + uWidthFade: { value: 1 }, + + uRadius: { value: TUBE_FIELDS.radius }, + uRadiusNear: { value: TUBE_FIELDS.radiusNear }, + uRadiusCurve: { value: TUBE_FIELDS.radiusCurve }, + uRadiusScale: { value: 1 }, + uFlare: { value: TUBE_FIELDS.flare }, + uFlareWidth: { value: TUBE_FIELDS.flareWidth }, + uThrob: { value: TUBE_FIELDS.throb }, + uThrobScale: { value: TUBE_FIELDS.throbScale }, + uThrobSpeed: { value: TUBE_FIELDS.throbSpeed }, + + uWander: { value: TUBE_FIELDS.wander }, + uWanderScale: { value: TUBE_FIELDS.wanderScale }, + uWanderSpeed: { value: TUBE_FIELDS.wanderSpeed }, + + uWavePhase: { value: 0 }, + uWaveWidth: { value: TUBE_FIELDS.waveWidth }, + uWaveAmp: { value: TUBE_FIELDS.waveAmp }, + uWaveGain: { value: TUBE_FIELDS.waveGain }, + uWaveCurve: { value: TUBE_FIELDS.waveCurve }, + uWaveRoll: { value: TUBE_FIELDS.waveRoll }, + uSag: { value: TUBE_FIELDS.sag }, + + uThroat: { value: TUBE_FIELDS.throat }, + uSkirtFlare: { value: TUBE_FIELDS.skirtFlare }, + uSkirtHeight: { value: TUBE_FIELDS.skirtHeight }, + uSkirtCurve: { value: TUBE_FIELDS.skirtCurve }, + uMouthFlare: { value: TUBE_FIELDS.mouthFlare }, + uMouthStart: { value: TUBE_FIELDS.mouthStart }, + uMouthCurve: { value: TUBE_FIELDS.mouthCurve }, + uSpin: { value: TUBE_FIELDS.spin }, + uSpinTwist: { value: TUBE_FIELDS.spinTwist }, + uSway: { value: TUBE_FIELDS.sway }, + uSwayScale: { value: TUBE_FIELDS.swayScale }, + uSwaySpeed: { value: TUBE_FIELDS.swaySpeed }, + uSwayCurve: { value: TUBE_FIELDS.swayCurve }, + + uGrow: { value: 1 }, + uTipTaper: { value: TUBE_FIELDS.tipTaper }, + uMeander: { value: TUBE_FIELDS.meander }, + uMeanderTurns: { value: TUBE_FIELDS.meanderTurns }, + uRecoil: { value: 1 }, + uRecoilSway: { value: TUBE_FIELDS.recoilSway }, + + uArcHeight: { value: TUBE_FIELDS.arcHeight }, + uArcLateral: { value: TUBE_FIELDS.arcLateral }, + uArcBias: { value: TUBE_FIELDS.arcBias }, + uArcCurve: { value: TUBE_FIELDS.arcCurve }, + + uRipple: { value: TUBE_FIELDS.ripple }, + uRippleBands: { value: TUBE_FIELDS.rippleBands }, + uRippleScale: { value: TUBE_FIELDS.rippleScale }, + uRippleSpeed: { value: TUBE_FIELDS.rippleSpeed }, + uStreak: { value: TUBE_FIELDS.streak }, + uStreakSharp: { value: TUBE_FIELDS.streakSharp }, + uStreakScale: { value: TUBE_FIELDS.streakScale }, + uStreakBands: { value: TUBE_FIELDS.streakBands }, + uStreakGlow: { value: TUBE_FIELDS.streakGlow }, + uFlowSpeed: { value: TUBE_FIELDS.flowSpeed }, + uBands: { value: TUBE_FIELDS.bands }, + uBandSharp: { value: TUBE_FIELDS.bandSharp }, + uBandDepth: { value: TUBE_FIELDS.bandDepth }, + uBandSpeed: { value: TUBE_FIELDS.bandSpeed }, + + uCoreSharp: { value: TUBE_FIELDS.coreSharp }, + uCoreFill: { value: TUBE_FIELDS.coreFill }, + uEdgePower: { value: TUBE_FIELDS.edgePower }, + uSheathRim: { value: TUBE_FIELDS.sheathRim }, + uSheathFill: { value: TUBE_FIELDS.sheathFill }, + uHaloRim: { value: TUBE_FIELDS.haloRim }, + uLayerOpacity: { value: 1 }, + + uMuzzleGlow: { value: TUBE_FIELDS.muzzleGlow }, + uMuzzleLength: { value: TUBE_FIELDS.muzzleLength }, + uTipGlow: { value: TUBE_FIELDS.tipGlow }, + uTipLength: { value: TUBE_FIELDS.tipLength }, + + uOpacity: { value: 1 }, + uGlow: { value: TUBE_FIELDS.glow }, + uSoftFade: { value: TUBE_FIELDS.softFade }, + uColorCore: { value: new Color(1, 1, 1) }, + uColorInner: { value: new Color(0.82, 0.96, 1) }, + uColorOuter: { value: new Color(0.29, 0.78, 1) }, + uColorHalo: { value: new Color(0.05, 0.2, 0.85) } + }), + vertexShader: TUBE_VERTEX, + fragmentShader: TUBE_FRAGMENT + }); +} + +/* ---------------------------------------------------------------- */ +/* Module scratch — I3 */ +/* ---------------------------------------------------------------- */ + +const _lat = new Vector3(); +const _pa = new Vector3(); +const _pb = new Vector3(); + +/** Default state, so a caller may omit anything it does not care about. */ +const DEFAULT_STATE = { + origin: null, + target: null, + side: null, + progress: 1, // 0..1 of the column that exists yet + fade: 1, // master alpha + widthFade: 1, // master radius multiplier — the collapse to a thread + seed: 0, // unitless dice roll from the cast + time: 0, // seconds since the path's own beat began + grow: 1, // VINE: the front, 0..1 + snapAge: -1 // VINE: seconds since the recoil was triggered, <0 = not yet +}; + +/* ---------------------------------------------------------------- */ +/* Tube */ +/* ---------------------------------------------------------------- */ + +export class Tube { + /** + * @param {object} [options] + * @param {number} [options.path] TubePath.* — compile-time, pick one per tube + * @param {string} [options.prefix] settings key prefix ('tube') + * @param {number} [options.nodes] samples along the column + * @param {number} [options.sides] facets around the barrel + * @param {number} [options.renderOrder] the halo's order; sheath +1, core +2 + */ + constructor({ + path = TubePath.STRAIGHT, + prefix = 'tube', + nodes = 96, + sides = 26, + renderOrder = 11 + } = {}) { + this.path = path; + this.prefix = prefix; + this.keys = tubeKeys(prefix); + + this.group = new Group(); + this.group.name = `Tube:${prefix}`; + this.group.matrixAutoUpdate = false; + + // WHIP and ARC bend hardest, so they want the samples; a straight column + // could get away with half of these and never show it. + this.geometry = createBeamTubeGeometry(nodes, sides); + + this.materials = {}; + this.meshes = {}; + // Halo first so the core adds on top of it. Everything is additive with + // depthWrite off, so the order is about layering, not correctness. + for (const [layer, name, order] of [ + [TubeLayer.HALO, 'halo', renderOrder], + [TubeLayer.SHEATH, 'sheath', renderOrder + 1], + [TubeLayer.CORE, 'core', renderOrder + 2] + ]) { + const material = createTubeMaterial(layer, path); + const mesh = new Mesh(this.geometry, material); + mesh.frustumCulled = false; + mesh.matrixAutoUpdate = false; + mesh.layers.set(LAYER.VFX); + mesh.renderOrder = order; + this.group.add(mesh); + this.materials[name] = material; + this.meshes[name] = mesh; + } + this._order = [ + [this.materials.core, TubeLayer.CORE], + [this.materials.sheath, TubeLayer.SHEATH], + [this.materials.halo, TubeLayer.HALO] + ]; + + /* --- the frame, resolved once per sync and reused by every query --- */ + this._origin = new Vector3(); + this._target = new Vector3(); + this._sideRef = new Vector3(1, 0, 0); + this._dir = new Vector3(0, 0, 1); + this._n1 = new Vector3(1, 0, 0); + this._n2 = new Vector3(0, 1, 0); + this._up = new Vector3(0, 1, 0); + this._side = new Vector3(1, 0, 0); + this._span = 1; + + /** + * Resolved dimensions. Written whole by `sync()` every frame and read by + * `radiusAt` / `pointAt`; never captured, never stale by more than the + * frame it was written in. + */ + this._r = {}; + for (const name of FIELD_NAMES) { + if (typeof TUBE_FIELDS[name] === 'number') this._r[name] = TUBE_FIELDS[name]; + } + this._r.grow = 1; + this._r.recoil = 1; + this._r.widthFade = 1; + this._r.wavePhase = 0; + + /** Where the tip is standing, live. Do not keep the reference. */ + this.tipPoint = new Vector3(); + /** Metres/second the tip is moving at, from the curve, not from a clock. */ + this.tipSpeed = 0; + /** Metres/second the curvature wave itself travels at. */ + this.waveSpeed = 0; + + /** + * The crack. `fired` is true on exactly the frame the tip first exceeds the + * wave speed, and false again once it drops back below — so an ability polls + * it after `sync()` and never needs a timer of its own. + */ + this.crack = { fired: false, point: new Vector3(), speed: 0, at: 0 }; + this._armed = false; + + /** Defaults for this path, so a missing key falls back rather than NaNs. */ + this._defaults = tubeDefaults(prefix, path); + /** The settings contract is audited once, on the first frame. */ + this._checked = false; + } + + /** Draw calls this tube costs. Three, always. */ + get drawCalls() { + return 3; + } + + set visible(value) { + this.group.visible = value; + } + + get visible() { + return this.group.visible; + } + + /* ------------------------------------------------------------------ */ + /* The frame update */ + /* ------------------------------------------------------------------ */ + + /** + * Re-resolve everything from settings and push it into the three layers. + * + * Call this every frame, including a zero-length one. Nothing here is + * cached between frames except the edge-detect on the crack, which is an + * event rather than a dimension. + * + * @param {object} c the ability's live settings block (`settings.`) + * @param {object} state { origin, target, side, progress, fade, widthFade, + * seed, time, grow, snapAge } — dice rolls and + * timestamps only + * @param {object} [g] `settings.global`; defaults to the live block. + * Taken from `config/globals.js` rather than from + * `config/settings.js`, because a settings module is + * allowed to spread this module's defaults and the + * round trip through `settings.js` closes a fatal + * import cycle — see `config/globals.js`. + */ + sync(c, state, g = globals) { + const s = state || DEFAULT_STATE; + const K = this.keys; + const D = this._defaults; + const r = this._r; + + if (!this._checked) this._audit(c); + + /* --- the cast's own geometry --- */ + if (s.origin) this._origin.copy(s.origin); + if (s.target) this._target.copy(s.target); + if (s.side) this._sideRef.copy(s.side); + this._buildFrame(); + + /* --- profile --- */ + r.radius = num(c[K.radius], D[K.radius]); + r.radiusNear = num(c[K.radiusNear], D[K.radiusNear]); + r.radiusCurve = num(c[K.radiusCurve], D[K.radiusCurve]); + r.flare = num(c[K.flare], D[K.flare]); + r.flareWidth = num(c[K.flareWidth], D[K.flareWidth]); + r.throb = num(c[K.throb], D[K.throb]); + r.throbScale = num(c[K.throbScale], D[K.throbScale]); + r.throbSpeed = num(c[K.throbSpeed], D[K.throbSpeed]) * g.animationSpeed; + r.widthFade = s.widthFade ?? 1; + + /* --- funnel --- */ + r.throat = num(c[K.throat], D[K.throat]); + r.skirtFlare = num(c[K.skirtFlare], D[K.skirtFlare]); + r.skirtHeight = num(c[K.skirtHeight], D[K.skirtHeight]); + r.skirtCurve = num(c[K.skirtCurve], D[K.skirtCurve]); + r.mouthFlare = num(c[K.mouthFlare], D[K.mouthFlare]); + r.mouthStart = num(c[K.mouthStart], D[K.mouthStart]); + r.mouthCurve = num(c[K.mouthCurve], D[K.mouthCurve]); + r.sway = num(c[K.sway], D[K.sway]); + r.swayScale = num(c[K.swayScale], D[K.swayScale]); + r.swaySpeed = num(c[K.swaySpeed], D[K.swaySpeed]) * g.animationSpeed; + r.swayCurve = num(c[K.swayCurve], D[K.swayCurve]); + + /* --- whip --- */ + const time = s.time ?? 0; + r.waveRate = num(c[K.waveRate], D[K.waveRate]) * g.animationSpeed; + r.waveWidth = num(c[K.waveWidth], D[K.waveWidth]); + r.waveAmp = num(c[K.waveAmp], D[K.waveAmp]); + r.waveGain = num(c[K.waveGain], D[K.waveGain]); + r.waveCurve = num(c[K.waveCurve], D[K.waveCurve]); + r.waveRoll = num(c[K.waveRoll], D[K.waveRoll]); + r.sag = num(c[K.sag], D[K.sag]); + r.crackRatio = num(c[K.crackRatio], D[K.crackRatio]); + // The phase is a *derived* quantity: seconds since the beat began times a + // live rate. Drag `waveRate` while paused and the loop moves. + r.wavePhase = time * r.waveRate; + + /* --- vine --- */ + r.grow = saturate(s.grow ?? 1); + r.tipTaper = num(c[K.tipTaper], D[K.tipTaper]); + r.meander = num(c[K.meander], D[K.meander]); + r.meanderTurns = num(c[K.meanderTurns], D[K.meanderTurns]); + r.recoilAmp = num(c[K.recoilAmp], D[K.recoilAmp]); + r.recoilFreq = num(c[K.recoilFreq], D[K.recoilFreq]) * g.animationSpeed; + r.recoilDamp = num(c[K.recoilDamp], D[K.recoilDamp]); + r.recoilSway = num(c[K.recoilSway], D[K.recoilSway]); + const snapAge = s.snapAge ?? -1; + r.snapAge = snapAge; + r.recoil = this._springAt(snapAge); + + /* --- arc --- */ + r.arcHeight = num(c[K.arcHeight], D[K.arcHeight]); + r.arcLateral = num(c[K.arcLateral], D[K.arcLateral]); + r.arcBias = num(c[K.arcBias], D[K.arcBias]); + r.arcCurve = num(c[K.arcCurve], D[K.arcCurve]); + + /* --- surface --- */ + r.ripple = num(c[K.ripple], D[K.ripple]) * g.noiseStrength; + r.rippleBands = num(c[K.rippleBands], D[K.rippleBands]); + r.rippleScale = num(c[K.rippleScale], D[K.rippleScale]) * g.noiseFrequency; + r.rippleSpeed = num(c[K.rippleSpeed], D[K.rippleSpeed]) * g.noiseSpeed; + r.streak = num(c[K.streak], D[K.streak]); + r.streakSharp = num(c[K.streakSharp], D[K.streakSharp]); + r.streakScale = num(c[K.streakScale], D[K.streakScale]) * g.noiseFrequency; + r.streakBands = num(c[K.streakBands], D[K.streakBands]); + r.streakGlow = num(c[K.streakGlow], D[K.streakGlow]); + r.flowSpeed = num(c[K.flowSpeed], D[K.flowSpeed]) * g.noiseSpeed; + r.bands = num(c[K.bands], D[K.bands]); + r.bandSharp = num(c[K.bandSharp], D[K.bandSharp]); + r.bandDepth = num(c[K.bandDepth], D[K.bandDepth]); + r.bandSpeed = num(c[K.bandSpeed], D[K.bandSpeed]) * g.animationSpeed; + r.spin = num(c[K.spin], D[K.spin]) * g.animationSpeed; + r.spinTwist = num(c[K.spinTwist], D[K.spinTwist]); + // The drift is the one place `randomness` and the noise multipliers have + // anything to bite on, so they scale here rather than everywhere. + r.wander = num(c[K.wander], D[K.wander]) * g.randomness * g.noiseStrength; + r.wanderScale = num(c[K.wanderScale], D[K.wanderScale]) * g.noiseFrequency; + r.wanderSpeed = num(c[K.wanderSpeed], D[K.wanderSpeed]) * g.noiseSpeed; + + /* --- layers, ends, render --- */ + r.coreWidth = num(c[K.coreWidth], D[K.coreWidth]); + r.coreFill = num(c[K.coreFill], D[K.coreFill]); + r.coreSharp = num(c[K.coreSharp], D[K.coreSharp]); + r.edgePower = num(c[K.edgePower], D[K.edgePower]); + r.sheathWidth = num(c[K.sheathWidth], D[K.sheathWidth]); + r.sheathRim = num(c[K.sheathRim], D[K.sheathRim]); + r.sheathFill = num(c[K.sheathFill], D[K.sheathFill]); + r.sheathOpacity = num(c[K.sheathOpacity], D[K.sheathOpacity]); + r.haloWidth = num(c[K.haloWidth], D[K.haloWidth]); + r.haloRim = num(c[K.haloRim], D[K.haloRim]); + r.haloOpacity = num(c[K.haloOpacity], D[K.haloOpacity]); + r.muzzleGlow = num(c[K.muzzleGlow], D[K.muzzleGlow]); + r.muzzleLength = num(c[K.muzzleLength], D[K.muzzleLength]); + r.tipGlow = num(c[K.tipGlow], D[K.tipGlow]); + r.tipLength = num(c[K.tipLength], D[K.tipLength]); + r.opacity = num(c[K.opacity], D[K.opacity]) * g.opacity; + r.glow = num(c[K.glow], D[K.glow]); + r.softFade = num(c[K.softFade], D[K.softFade]); + + r.seed = s.seed ?? 0; + r.progress = this.path === TubePath.VINE ? r.grow : (s.progress ?? 1); + r.fade = s.fade ?? 1; + + /* --- what the ability asks us about --- */ + this._measureTip(); + + /* --- push --- */ + const core = getColor(str(c[K.colorCore], D[K.colorCore])); + const inner = getColor(str(c[K.colorInner], D[K.colorInner])); + const outer = getColor(str(c[K.colorOuter], D[K.colorOuter])); + const halo = getColor(str(c[K.colorHalo], D[K.colorHalo])); + for (const [material, layer] of this._order) { + this._push(material, layer, core, inner, outer, halo); + } + } + + /* ------------------------------------------------------------------ */ + /* The queries — the JS mirror of the shader's two shape functions */ + /* ------------------------------------------------------------------ */ + + /** + * Half-width of the column at `t`, **metres**, before the per-layer width + * multiplier. Multiply by `coreWidth` / `sheathWidth` / `haloWidth` if you + * want one specific silhouette; leave it alone if you want the profile the + * three of them are all multiples of, which is almost always what a consumer + * placing itself against the tube actually wants. + * + * This is the funnel's vortex profile, and it is the one function the debris, + * the dust skirt and the ground scour should all be reading. + */ + radiusAt(t) { + const r = this._r; + const u = saturate(t); + let out; + + if (this.path === TubePath.FUNNEL) { + const skirt = + r.skirtFlare * Math.pow(1 - smoothstep(0, Math.max(r.skirtHeight, 1e-3), u), Math.max(r.skirtCurve, 0.05)); + const mouth = + r.mouthFlare * Math.pow(smoothstep(Math.min(r.mouthStart, 0.99), 1, u), Math.max(r.mouthCurve, 0.05)); + out = r.throat + skirt + mouth; + } else if (this.path === TubePath.VINE) { + const s = saturate(u / Math.max(r.grow, 1e-3)); + out = r.radius * Math.pow(1 - s, Math.max(r.tipTaper, 0.01)); + } else { + out = r.radiusNear + (r.radius - r.radiusNear) * Math.pow(u, Math.max(r.radiusCurve, 0.01)); + out *= 1 + r.flare * smoothstep(1 - Math.max(r.flareWidth, 1e-3), 1, u); + } + + out *= 1 + r.throb * Math.sin((u * r.throbScale - frame.uTime.value * r.throbSpeed) * TAU); + return Math.max(out * r.widthFade, 1e-4); + } + + /** + * A point on the column's **mean** axis at `t`, world space. + * + * Mean because the `wander` noise is not mirrored here — see the module + * header. Everything else is: the whip's lobe and its arc-length shortening, + * the funnel's precession, the vine's spring, the arc's bell. + * + * @param {number} t 0..1 from origin to target + * @param {THREE.Vector3} out + */ + pointAt(t, out) { + return this._axisInto(t, this._drive(), out); + } + + /** + * Unit tangent at `t`, world space. Central difference on the same curve, so + * a consumer that wants to orient something along the tube gets an answer + * consistent with where the tube actually is. + */ + tangentAt(t, out) { + const h = 1e-3; + const drive = this._drive(); + this._axisInto(clamp(t - h, 0, 1), drive, _pa); + this._axisInto(clamp(t + h, 0, 1), drive, _pb); + out.subVectors(_pb, _pa); + return out.lengthSq() > 1e-12 ? out.normalize() : out.copy(this._dir); + } + + /** Metres from origin to target, live. */ + get span() { + return this._span; + } + + /** + * The vortex mouth radius, or the far-end radius for any other path. Sugar + * for `radiusAt(1)`, named because that is what the ability means. + */ + get mouthRadius() { + return this.radiusAt(1); + } + + /** The radius where the column meets the floor. Sugar for `radiusAt(0)`. */ + get skirtRadius() { + return this.radiusAt(0); + } + + /* ------------------------------------------------------------------ */ + /* Internals */ + /* ------------------------------------------------------------------ */ + + /** The scalar the path's motion is a function of. See `tubeAxis`. */ + _drive() { + if (this.path === TubePath.WHIP) return this._r.wavePhase; + if (this.path === TubePath.VINE) return this._r.snapAge; + return 0; + } + + /** + * The recoil spring, evaluated from seconds since the snap. + * + * A damped cosine rather than an exponential: a vine that only eases back is + * a vine on a lift, and the overshoot past 1 is the bit that reads as elastic. + * Returns 1 exactly when the snap has not been triggered, so a growing vine + * pays nothing for the feature. + */ + _springAt(age) { + if (!(age >= 0)) return 1; + const r = this._r; + return 1 - r.recoilAmp * Math.exp(-r.recoilDamp * age) * Math.cos(TAU * r.recoilFreq * age); + } + + /** Both frames, mirroring `tubeFrame()` in the vertex shader exactly. */ + _buildFrame() { + this._dir.subVectors(this._target, this._origin); + this._span = Math.max(this._dir.length(), 0.01); + this._dir.multiplyScalar(1 / this._span); + + _lat.copy(this._sideRef).addScaledVector(this._dir, -this._sideRef.dot(this._dir)); + if (_lat.lengthSq() > 1e-8) this._n1.copy(_lat).normalize(); + else this._n1.set(0, 1, 0).cross(this._dir).normalize(); + this._n2.crossVectors(this._dir, this._n1).normalize(); + + this._up.set(0, 1, 0).addScaledVector(this._dir, -this._dir.y); + if (this._up.lengthSq() > 1e-8) this._up.normalize(); + else this._up.copy(this._n1); + this._side.crossVectors(this._dir, this._up).normalize(); + } + + /** `tubeAxis()`, in JS, minus the wander noise. */ + _axisInto(t, drive, out) { + const r = this._r; + let axial = t; + _lat.set(0, 0, 0); + + switch (this.path) { + case TubePath.WHIP: { + const w = Math.max(r.waveWidth, 1e-3); + const x = (t - drive) / w; + const lobe = Math.exp(-x * x); + const amp = + r.waveAmp * (1 + (r.waveGain - 1) * Math.pow(saturate(drive), Math.max(r.waveCurve, 0.05))); + axial = t - (WHIP_EATEN * amp * amp) / w * smoothstep(-1.6, 1.6, x); + const c = Math.cos(r.waveRoll); + const sn = Math.sin(r.waveRoll); + _lat.copy(this._up).multiplyScalar(c).addScaledVector(this._side, sn); + _lat.multiplyScalar(amp * lobe * this._span); + _lat.addScaledVector(this._up, -r.sag * Math.sin(saturate(t) * Math.PI)); + break; + } + case TubePath.FUNNEL: { + const ang = (frame.uTime.value * r.swaySpeed + t * r.swayScale) * TAU + r.seed * 6.283; + const amount = r.sway * Math.pow(saturate(t), Math.max(r.swayCurve, 0.05)); + _lat.copy(this._side).multiplyScalar(Math.cos(ang) * amount); + _lat.addScaledVector(this._up, Math.sin(ang) * amount); + break; + } + case TubePath.VINE: { + // The spring is recomputed from `drive` rather than read off `r.recoil` + // so the curve is differentiable in its own driver — `_measureTip` needs + // that, and for `drive === snapAge` the two are the same number, which + // is what keeps this in step with the `uRecoil` the shader was handed. + const recoil = this._springAt(drive); + axial = t * recoil; + const coil = (t * r.meanderTurns + r.seed) * TAU; + _lat.copy(this._n1).multiplyScalar(Math.cos(coil) * r.meander * t); + _lat.addScaledVector(this._n2, Math.sin(coil) * r.meander * t); + _lat.addScaledVector(this._side, r.recoilSway * (1 - recoil) * Math.sin(saturate(t) * Math.PI)); + break; + } + case TubePath.ARC: { + const b = clamp(r.arcBias, 0.05, 0.95); + const k = t < b ? t / b : (1 - t) / (1 - b); + const bell = Math.pow(smoothstep(0, 1, saturate(k)), Math.max(r.arcCurve, 0.05)); + _lat.copy(this._up).multiplyScalar(r.arcHeight * bell); + _lat.addScaledVector(this._side, r.arcLateral * bell); + break; + } + default: + break; + } + + return out.copy(this._origin).addScaledVector(this._dir, axial * this._span).add(_lat); + } + + /** + * The tip, its speed, and the crack. + * + * The speed is a central difference of the axis at the tip **with respect to + * the path's own driver**, times the rate that driver advances at. Not a + * position delta between frames: that would read zero on a paused frame and + * would make the crack a function of the frame rate, which is precisely the + * bug this module exists to avoid. + */ + _measureTip() { + const r = this._r; + const tipT = this.path === TubePath.VINE ? r.grow : 1; + const drive = this._drive(); + this._axisInto(tipT, drive, this.tipPoint); + + let rate = 0; + if (this.path === TubePath.WHIP) rate = r.waveRate; + else if (this.path === TubePath.VINE && r.snapAge >= 0) rate = 1; // driver is seconds + + if (rate === 0) { + this.tipSpeed = 0; + this.waveSpeed = 0; + this.crack.fired = false; + this._armed = false; + return; + } + + const h = 1e-3; + this._axisInto(tipT, drive - h, _pa); + this._axisInto(tipT, drive + h, _pb); + this.tipSpeed = (_pa.distanceTo(_pb) / (2 * h)) * rate; + + // The loop travels the whole whip in 1/waveRate seconds; that is the wave + // speed the tip has to beat. + this.waveSpeed = this.path === TubePath.WHIP ? this._span * r.waveRate : 0; + + const threshold = this.waveSpeed * Math.max(r.crackRatio, 0.01); + const over = this.waveSpeed > 0 && this.tipSpeed >= threshold; + this.crack.fired = over && !this._armed; + if (this.crack.fired) { + this.crack.point.copy(this.tipPoint); + this.crack.speed = this.tipSpeed; + this.crack.at = frame.uTime.value; + } + this._armed = over; + } + + /** Push the resolved block into one layer's uniforms. */ + _push(material, layer, core, inner, outer, halo) { + const r = this._r; + const u = material.uniforms; + + u.uOrigin.value.copy(this._origin); + u.uTarget.value.copy(this._target); + u.uSide.value.copy(this._sideRef); + u.uSeed.value = r.seed; + u.uProgress.value = r.progress; + u.uFade.value = r.fade; + u.uWidthFade.value = r.widthFade; + + u.uRadius.value = r.radius; + u.uRadiusNear.value = r.radiusNear; + u.uRadiusCurve.value = r.radiusCurve; + u.uFlare.value = r.flare; + u.uFlareWidth.value = r.flareWidth; + u.uThrob.value = r.throb; + u.uThrobScale.value = r.throbScale; + u.uThrobSpeed.value = r.throbSpeed; + + u.uWander.value = r.wander; + u.uWanderScale.value = r.wanderScale; + u.uWanderSpeed.value = r.wanderSpeed; + + u.uWavePhase.value = r.wavePhase; + u.uWaveWidth.value = r.waveWidth; + u.uWaveAmp.value = r.waveAmp; + u.uWaveGain.value = r.waveGain; + u.uWaveCurve.value = r.waveCurve; + u.uWaveRoll.value = r.waveRoll; + u.uSag.value = r.sag; + + u.uThroat.value = r.throat; + u.uSkirtFlare.value = r.skirtFlare; + u.uSkirtHeight.value = r.skirtHeight; + u.uSkirtCurve.value = r.skirtCurve; + u.uMouthFlare.value = r.mouthFlare; + u.uMouthStart.value = r.mouthStart; + u.uMouthCurve.value = r.mouthCurve; + u.uSpin.value = r.spin; + u.uSpinTwist.value = r.spinTwist; + u.uSway.value = r.sway; + u.uSwayScale.value = r.swayScale; + u.uSwaySpeed.value = r.swaySpeed; + u.uSwayCurve.value = r.swayCurve; + + u.uGrow.value = r.grow; + u.uTipTaper.value = r.tipTaper; + u.uMeander.value = r.meander; + u.uMeanderTurns.value = r.meanderTurns; + u.uRecoil.value = r.recoil; + u.uRecoilSway.value = r.recoilSway; + + u.uArcHeight.value = r.arcHeight; + u.uArcLateral.value = r.arcLateral; + u.uArcBias.value = r.arcBias; + u.uArcCurve.value = r.arcCurve; + + u.uRipple.value = r.ripple; + u.uRippleBands.value = r.rippleBands; + u.uRippleScale.value = r.rippleScale; + u.uRippleSpeed.value = r.rippleSpeed; + u.uStreak.value = r.streak; + u.uStreakSharp.value = r.streakSharp; + u.uStreakScale.value = r.streakScale; + u.uStreakBands.value = r.streakBands; + u.uStreakGlow.value = r.streakGlow; + u.uFlowSpeed.value = r.flowSpeed; + u.uBands.value = r.bands; + u.uBandSharp.value = r.bandSharp; + u.uBandDepth.value = r.bandDepth; + u.uBandSpeed.value = r.bandSpeed; + + u.uCoreSharp.value = r.coreSharp; + u.uCoreFill.value = r.coreFill; + u.uEdgePower.value = r.edgePower; + u.uSheathRim.value = r.sheathRim; + u.uSheathFill.value = r.sheathFill; + u.uHaloRim.value = r.haloRim; + + u.uMuzzleGlow.value = r.muzzleGlow; + u.uMuzzleLength.value = r.muzzleLength; + u.uTipGlow.value = r.tipGlow; + u.uTipLength.value = r.tipLength; + + // Which of the three radii this layer is, and how hard it is drawn. + switch (layer) { + case TubeLayer.CORE: + u.uRadiusScale.value = r.coreWidth; + u.uLayerOpacity.value = 1; + u.uGlow.value = r.glow; + break; + case TubeLayer.SHEATH: + u.uRadiusScale.value = r.sheathWidth; + u.uLayerOpacity.value = r.sheathOpacity; + u.uGlow.value = r.glow; + break; + default: + u.uRadiusScale.value = r.haloWidth; + u.uLayerOpacity.value = r.haloOpacity; + u.uGlow.value = r.glow * 0.8; + break; + } + + u.uOpacity.value = r.opacity; + u.uSoftFade.value = r.softFade; + u.uColorCore.value.copy(core); + u.uColorInner.value.copy(inner); + u.uColorOuter.value.copy(outer); + u.uColorHalo.value.copy(halo); + } + + /** + * Warn once about a settings block that has not been given the contract. + * + * A warning rather than a throw, and `num()` behind it, because the failure + * this replaces is the nastiest one in the project: a key that is not there + * reads `undefined`, multiplies through into a uniform, and the tube quietly + * becomes NaN geometry — a mesh that draws nothing, with no error anywhere and + * no clue which of seventy sliders was the one. `scripts/check.mjs` catches it + * statically; this catches it on the first frame, by name, while still + * drawing something. + */ + _audit(c) { + this._checked = true; + auditBlock( + `Tube:${this.prefix}`, + this.keys, + FIELD_NAMES, + c, + `tubeDefaults('${this.prefix}', TubePath.${PATH_NAMES[this.path]})` + ); + } + + /** Free GPU resources. App teardown only — pooled abilities keep theirs. */ + dispose() { + this.geometry.dispose(); + for (const key of Object.keys(this.materials)) this.materials[key].dispose(); + this.group.parent?.remove(this.group); + } +} diff --git a/src/vfx/VolumeHull.js b/src/vfx/VolumeHull.js new file mode 100644 index 0000000..7865d39 --- /dev/null +++ b/src/vfx/VolumeHull.js @@ -0,0 +1,1753 @@ +import { + Mesh, + BoxGeometry, + CylinderGeometry, + ConeGeometry, + SphereGeometry, + ShaderMaterial, + CustomBlending, + AddEquation, + OneFactor, + OneMinusSrcAlphaFactor, + AdditiveBlending, + BackSide, + Color, + Matrix4, + Vector3 +} from 'three'; +import { noiseGLSL } from '../shaders/lib/noise.glsl.js'; +import { commonGLSL } from '../shaders/lib/common.glsl.js'; +import { sharedUniforms } from '../core/FrameUniforms.js'; +import { LAYER } from '../core/Layers.js'; +import { getColor } from '../utils/color.js'; +import { num, auditBlock } from './prefixedBlock.js'; + +/* ---------------------------------------------------------------- */ +/* Enums */ +/* ---------------------------------------------------------------- */ + +/** + * The proxy hull the ray is marched inside. + * + * Every one of these is a *unit* primitive in object space, resized by the + * `uSize` uniform in the vertex shader — never by `mesh.scale` and never by + * rebuilding geometry. That is not a micro-optimisation, it is invariant I1: + * dragging a radius slider with the sim paused (`P`) has to resize the standing + * volume, and a CPU-side rebuild cannot happen on a zero-length frame. + * + * Local axes, in metres, after the vertex stage has applied `uSize`: + * + * BOX x ∈ [-Sx, Sx] y ∈ [0, Sy] z ∈ [-Sz, Sz] — a slab on the floor + * CYLINDER elliptic radius (Sx, Sz) in xz, y ∈ [0, Sy] — a column on the floor + * CONE apex at the origin, axis +Z, mouth radii (Sx, Sy) at z = Sz + * DOME half-ellipsoid, radii (Sx, Sy, Sz), y ≥ 0 — sits on the floor + * SPHERE ellipsoid, radii (Sx, Sy, Sz), centred on the origin + * + * CONE points down +Z because +Z is the cast heading everywhere else in this + * project (`Ability.direction`); the others stand up +Y because they sit on the + * ground and gravity does not care which way the caster is facing. + */ +export const HullShape = Object.freeze({ + BOX: 0, + CYLINDER: 1, + CONE: 2, + DOME: 3, + SPHERE: 4 +}); + +/** + * Which medium the shared shader compiles into. + * + * These are `#define`d variants of one fragment shader rather than eight + * shaders. The march, the hull intersection, the compositing and the depth + * clipping are identical in all eight — only twenty-odd lines in the middle, + * where density becomes colour, actually differ — and a raymarcher is expensive + * enough to compile that eight copies of the identical 400 lines would be a + * visible hitch the first time a school is opened. + * + * FLAME emissive gas. Colour from the four-stop ramp weighted by a heat + * term, radiated as pow(d, curve). The cheap cousin of + * `materials/VolumetricFireMaterial.js` — no black-body fit, no + * vortex roll-up. Use that one when the flame *is* the ability. + * SMOKE absorbing, non-emissive, lit only by the key direction through the + * self-shadow tap. Without the tap smoke is a flat grey blob. + * ASH smoke plus sparse embers on a hashed lattice, and a downward + * advection default because ash falls. + * SPORE thin, non-absorbing, bioluminescent in *glints* rather than as a + * glow — the emission is a sparse point field drifting inside a dim + * cloud, not the cloud itself lit up. + * SAND grain. The fbm is hard-thresholded so the medium reads as a + * curtain of particles rather than as a cloud tinted brown. + * MIST near-uniform, barely absorbing, strongly forward-scattering. It is + * the anisotropy term that makes it read as mist and not as fog. + * GAS_BOIL the interesting one — see `boilField()`. + * VOID subtractive. See the note on blending below. + */ +export const Medium = Object.freeze({ + FLAME: 0, + SMOKE: 1, + ASH: 2, + SPORE: 3, + SAND: 4, + MIST: 5, + GAS_BOIL: 6, + VOID: 7 +}); + +/** Debug/editor labels, index-aligned with the enums above. */ +export const HULL_NAMES = ['BOX', 'CYLINDER', 'CONE', 'DOME', 'SPHERE']; +export const MEDIUM_NAMES = ['FLAME', 'SMOKE', 'ASH', 'SPORE', 'SAND', 'MIST', 'GAS_BOIL', 'VOID']; + +/** + * Field samples per frame the whole scene may spend on raymarched volumes. + * + * samples = coveredPixels × steps × (1 + shadowTaps) + * + * A "sample" is one call to `mediumDensity()`: an fbm at `uOctaves` octaves, + * each octave eight `hash13`s, so four octaves is around three hundred ALU ops. + * Twenty million of them is roughly four milliseconds on a mid-range discrete + * GPU at 1080p, which is the whole of what this sandbox has spare once the + * bloom chain has been paid for. + * + * This is a budget, not a measurement. `VolumeHull#cost()` does the arithmetic; + * check it against this number before shipping an ability. + */ +export const VOLUME_SAMPLE_BUDGET = 20e6; + +/* ---------------------------------------------------------------- */ +/* Proxy geometry */ +/* ---------------------------------------------------------------- */ + +/** + * Unit hulls, built once and shared by every instance. + * + * Two things are non-obvious. + * + * **The tessellated hulls are circumscribed, not inscribed.** A twenty-sided + * prism drawn at radius 1 has its flats at radius cos(π/20) ≈ 0.988, so a + * volume that fills its nominal radius pokes out through twenty flat facets and + * is sliced off along them. Scaling the ring radius by 1/cos(π/n) puts the + * *flats* on the nominal radius and the corners outside it, which costs a + * sliver of overdraw and removes the failure mode entirely. This is the same + * lesson as `fireHullReach()`: a volume that ends one pixel outside its proxy + * is sliced along a dead straight line, and that reads as a bug immediately. + * + * **DOME uses the full sphere.** The obvious build is an open hemisphere, and + * it fails when the camera is inside the dome and looking down: back faces only + * exist above the horizon, so the lower half of the volume has no fragment to + * march it. Using a whole sphere and clipping the *field* to y ≥ 0 costs a few + * hundred vertices and nothing at all in fill rate — the sub-floor fragments + * discard on the first depth test, because their entire span is behind the + * opaque floor. + */ +const _geometryCache = new Map(); + +function unitHull(shape) { + // DOME and SPHERE share one geometry, so they share one cache slot too. + const slot = shape === HullShape.DOME ? HullShape.SPHERE : shape; + let geometry = _geometryCache.get(slot); + if (geometry) return geometry; + + const RADIAL = 20; + const circumscribe = 1 / Math.cos(Math.PI / RADIAL); + + switch (shape) { + case HullShape.BOX: + geometry = new BoxGeometry(2, 1, 2).translate(0, 0.5, 0); + break; + case HullShape.CYLINDER: + geometry = new CylinderGeometry(circumscribe, circumscribe, 1, RADIAL, 1).translate( + 0, + 0.5, + 0 + ); + break; + case HullShape.CONE: + // three's cone has its apex at +h/2 and its base at -h/2. Drop it by half + // a unit to put the apex on the origin, then rotate -90° about X, which + // sends -Y to +Z: apex at the origin, base circle at z = 1, exactly the + // frame hullSpan() and hullShape() assume. Get the sign of either + // transform wrong and the hull ends up behind its own z-clip, which + // renders as a two-pixel sliver rather than as an error. + geometry = new ConeGeometry(circumscribe, 1, RADIAL, 1) + .translate(0, -0.5, 0) + .rotateX(-Math.PI / 2); + break; + default: + // DOME and SPHERE share it — see the note above. + geometry = new SphereGeometry(1 / Math.cos(Math.PI / 12), 24, 12); + break; + } + + geometry.name = `VolumeHull:${HULL_NAMES[slot] ?? slot}`; + _geometryCache.set(slot, geometry); + return geometry; +} + +/** App teardown only. The hulls are shared, so no instance may dispose them. */ +export function disposeVolumeHullGeometry() { + for (const geometry of _geometryCache.values()) geometry.dispose(); + _geometryCache.clear(); +} + +/* ---------------------------------------------------------------- */ +/* Shaders */ +/* ---------------------------------------------------------------- */ + +const VERTEX = /* glsl */ ` + uniform vec3 uSize; + + varying vec3 vLocal; + varying vec3 vWorld; + + void main() { + // The hull is resized *here*, from a uniform, so a paused sim still + // re-sizes when a slider moves. Object space is therefore metres and the + // hull's half-extents are exactly uSize — which is what lets the fragment + // stage intersect it analytically without a single trig call. + vLocal = position * uSize; + vec4 world = modelMatrix * vec4(vLocal, 1.0); + vWorld = world.xyz; + gl_Position = projectionMatrix * viewMatrix * world; + } +`; + +const FRAGMENT = /* glsl */ ` + uniform mat4 uInvModel; + uniform vec3 uSize; + + /* march */ + uniform float uSteps; + uniform float uJitter; + uniform float uContact; + uniform float uSeed; + + /* silhouette */ + uniform float uRound; + uniform float uMargin; + uniform float uHeightBias; + uniform float uFeather; + uniform float uHollow; + uniform float uThroat; + + /* field */ + uniform float uDensity; + uniform float uDensityCurve; + uniform float uSoftness; + uniform float uNoiseFrequency; + uniform float uNoiseStrength; + uniform float uNoiseWarp; + uniform float uOctaves; + uniform float uDetail; + + /* advection */ + uniform vec3 uFlow; + uniform float uJet; + uniform float uRise; + uniform float uSwirl; + uniform float uFlatten; + + /* optics */ + uniform float uAbsorption; + uniform float uScatter; + uniform float uAmbient; + uniform float uAnisotropy; + uniform float uEmission; + uniform float uEmissionCurve; + uniform float uShadowTaps; + uniform float uShadowStrength; + uniform float uShadowLength; + uniform float uOpacity; + + /* GAS_BOIL */ + uniform float uBoilRate; + uniform float uBoilScale; + uniform float uBoilSize; + uniform float uBoilPop; + uniform float uBoilWarp; + uniform float uBoilDepth; + uniform float uBoilFlash; + + /* sparse points — ASH embers, SPORE glints, VOID stars */ + uniform float uSpeckDensity; + uniform float uSpeckScale; + uniform float uSpeckSize; + uniform float uSpeckGlow; + + /* VOID */ + uniform float uVoidBite; + + /* colour — five independent pickers, nothing derived (I5) */ + uniform vec3 uColorCore; + uniform vec3 uColorMid; + uniform vec3 uColorEdge; + uniform vec3 uColorDeep; + uniform vec3 uColorLight; + uniform vec3 uColorEvent; + uniform vec3 uColorSpeck; + + /* shared */ + uniform float uTime; + uniform vec2 uResolution; + uniform sampler2D uSceneDepth; + uniform float uCameraNear; + uniform float uCameraFar; + uniform vec3 uLightDir; + uniform float uShaderIntensity; + uniform float uGlobalGlow; + + varying vec3 vLocal; + varying vec3 vWorld; + + ${noiseGLSL} + ${commonGLSL} + + /* ---------------------------------------------------------------- */ + /* Noise */ + /* ---------------------------------------------------------------- */ + + /* Trilinear value noise on a hashed lattice — about a third of the cost of + the simplex noise the surface shaders use, and indistinguishable once four + octaves of it are being advected and warped. Inside a march the difference + is not academic: this function is called up to MAX_STEPS × (1 + taps) times + per pixel. */ + float vnoise(vec3 p) { + vec3 i = floor(p); + vec3 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + + float a = hash13(i); + float b = hash13(i + vec3(1.0, 0.0, 0.0)); + float c = hash13(i + vec3(0.0, 1.0, 0.0)); + float d = hash13(i + vec3(1.0, 1.0, 0.0)); + float e = hash13(i + vec3(0.0, 0.0, 1.0)); + float g = hash13(i + vec3(1.0, 0.0, 1.0)); + float h = hash13(i + vec3(0.0, 1.0, 1.0)); + float k = hash13(i + vec3(1.0, 1.0, 1.0)); + + return mix( + mix(mix(a, b, f.x), mix(c, d, f.x), f.y), + mix(mix(e, g, f.x), mix(h, k, f.x), f.y), + f.z + ); + } + + /* Unit-determinant rotation between octaves. Value noise sits on an + axis-aligned lattice; stacking octaves on the same axes lines their + features up into a grid you cannot unsee once you have seen it. */ + const mat3 OCTAVE_ROT = mat3( + 0.00, 0.80, 0.60, + -0.80, 0.36, -0.48, + -0.60, -0.48, 0.64 + ); + + /** Progressively domain-warped fbm, 0..1. */ + float volFbm(vec3 p) { + float v = 0.0; + float a = 0.5; + float norm = 0.0; + + for (int i = 0; i < 5; i++) { + if (float(i) >= uOctaves) break; + float n = vnoise(p); + v += a * n; + norm += a; + p = OCTAVE_ROT * p * 2.13 + (n - 0.5) * uNoiseWarp * vec3(1.7, 0.9, 1.3); + a *= 0.55; + } + + return v / max(norm, 1e-4); + } + + /** + * A sparse field of points on a world lattice — embers, spore glints, stars. + * + * The first version took these off a ridged octave of the fbm, which is the + * cheap-looking answer and is wrong for a reason worth writing down: value + * noise is a *smoothed* hash, so its values pile up around 0.5, and + * 1 - |2n - 1| therefore piles up around **one**. A ridged term built on it + * is bright nearly everywhere, so "sparse embers" came out as red television + * static covering the whole cloud. Nothing you can do to the threshold fixes + * a distribution that is wrong at the shape level. + * + * A hashed lattice is uniform by construction, so 'density' means exactly + * what it says: the fraction of cells that hold a point. One point per cell, + * radius capped below half a cell, so the containing cell is the only cell + * that can contribute — the same containment argument as 'boilField()', and + * the same one hash. + * + * @param id out — the point's hash, for per-point flicker + */ + float speck(vec3 p, float density, float size, out float id) { + vec3 cell = floor(p); + id = hash13(cell + 5.1); + if (id > clamp(density, 0.0, 1.0)) return 0.0; + vec3 centre = vec3(0.5) + (hash31(id * 77.0) - 0.5) * (1.0 - size); + return 1.0 - smoothstep(size * 0.3, size * 0.5, length(fract(p) - centre)); + } + + /** Henyey-Greenstein phase function. g > 0 forward-scatters. */ + float phaseHG(float cosTheta, float g) { + g = clamp(g, -0.92, 0.92); + float gg = g * g; + float d = 1.0 + gg - 2.0 * g * cosTheta; + return (1.0 - gg) / (12.566370614 * max(d * sqrt(max(d, 1e-4)), 1e-4)); + } + + /* ---------------------------------------------------------------- */ + /* Hull intersection — analytic, in the hull's own frame */ + /* ---------------------------------------------------------------- */ + + /* Clip an interval against the slab a ≤ o + d·t ≤ b. Returns false when the + interval has collapsed. */ + bool clipSlab(float o, float d, float a, float b, inout float lo, inout float hi) { + if (abs(d) < 1e-6) return o >= a && o <= b; + float ta = (a - o) / d; + float tb = (b - o) / d; + if (ta > tb) { float s = ta; ta = tb; tb = s; } + lo = max(lo, ta); + hi = min(hi, tb); + return hi > lo; + } + + /** + * Clip an interval against the solid { A·t² + 2B·t + C ≤ 0 }. + * + * The A < 0 branch is the cone's, and it is the one worth reading: a double + * cone's interior is the *outside* of the root pair, i.e. two rays heading + * off in opposite directions. Clipped to the +Z nappe by the z-slab the solid + * is convex, so a line can only meet it in one interval and at most one of + * those two ends can survive — which is why keeping the longer overlap is + * exact here and would not be for a general quadric. + */ + bool clipQuadric(float A, float B, float C, inout float lo, inout float hi) { + if (abs(A) < 1e-7) { + // The ray runs parallel to the surface: the inequality is linear in t. + if (abs(B) < 1e-9) return C <= 0.0; + float r = -C / (2.0 * B); + if (B > 0.0) hi = min(hi, r); else lo = max(lo, r); + return hi > lo; + } + + float disc = B * B - A * C; + // No roots with A < 0 means the quadratic never climbs to zero: the whole + // line is inside. With A > 0 it means the whole line is outside. + if (disc < 0.0) return A < 0.0; + + float s = sqrt(disc); + float r0 = (-B - s) / A; + float r1 = (-B + s) / A; + if (r0 > r1) { float t = r0; r0 = r1; r1 = t; } + + if (A > 0.0) { + lo = max(lo, r0); + hi = min(hi, r1); + } else { + float loA = max(lo, r1), hiA = hi; + float loB = lo, hiB = min(hi, r0); + if (hiA - loA >= hiB - loB) { lo = loA; hi = hiA; } + else { lo = loB; hi = hiB; } + } + return hi > lo; + } + + /** + * The ray's span inside the hull, in **world metres**. + * + * Bounding the volume with a sphere is what the obvious implementation does + * and it is ruinous for anything that is not round: a floor slab five metres + * across and half a metre tall inside its bounding sphere is ninety-odd per + * cent empty, and the fixed step budget gets spread over all of it. The hull + * is already exactly the shape the field lives in — intersecting *that* + * costs a handful of ALU and buys the whole factor back as step density. + */ + bool hullSpan(vec3 ro, vec3 rd, out float t0, out float t1) { + t0 = 0.0; + t1 = 1e6; + + #if HULL_SHAPE == 0 /* BOX */ + if (!clipSlab(ro.x, rd.x, -uSize.x, uSize.x, t0, t1)) return false; + if (!clipSlab(ro.y, rd.y, 0.0, uSize.y, t0, t1)) return false; + if (!clipSlab(ro.z, rd.z, -uSize.z, uSize.z, t0, t1)) return false; + + #elif HULL_SHAPE == 1 /* CYLINDER */ + if (!clipSlab(ro.y, rd.y, 0.0, uSize.y, t0, t1)) return false; + vec2 o = ro.xz / uSize.xz; + vec2 d = rd.xz / uSize.xz; + if (!clipQuadric(dot(d, d), dot(o, d), dot(o, o) - 1.0, t0, t1)) return false; + + #elif HULL_SHAPE == 2 /* CONE, apex at the origin, +Z */ + if (!clipSlab(ro.z, rd.z, 0.0, uSize.z, t0, t1)) return false; + vec3 o = ro / uSize; + vec3 d = rd / uSize; + if (!clipQuadric(d.x * d.x + d.y * d.y - d.z * d.z, + o.x * d.x + o.y * d.y - o.z * d.z, + o.x * o.x + o.y * o.y - o.z * o.z, t0, t1)) return false; + + #elif HULL_SHAPE == 3 /* DOME */ + if (!clipSlab(ro.y, rd.y, 0.0, uSize.y, t0, t1)) return false; + vec3 o = ro / uSize; + vec3 d = rd / uSize; + if (!clipQuadric(dot(d, d), dot(o, d), dot(o, o) - 1.0, t0, t1)) return false; + + #else /* SPHERE (ellipsoid) */ + vec3 o = ro / uSize; + vec3 d = rd / uSize; + if (!clipQuadric(dot(d, d), dot(o, d), dot(o, o) - 1.0, t0, t1)) return false; + #endif + + t0 = max(t0, 0.0); + return t1 > t0; + } + + /** + * Hull-relative silhouette: 1 in the core, 0 at the nominal surface of the + * medium, negative beyond it. + * + * This is **local** on purpose, and it is the one term that must be. The + * silhouette *is* the hull — sampled in world space it would slide out of its + * own proxy the moment the ability moved the anchor and get sliced along the + * hull's wall. Everything grainy is sampled in world space instead; see + * 'mediumDensity()'. + * + * 'uMargin' is the whole reason the volume does not end on a straight line. + * The nominal surface is placed a fraction of the hull *inside* the hull, so + * the erosion has somewhere to push density into before it runs out of proxy. + * Every normalised coordinate below is scaled by 'inv' and nothing else in + * the function knows about it — which is the point: the margin is a property + * of the hull, not of any one shape's algebra. + * + * Note the floor is not given margin on hulls that sit on it (y = 0 stays + * y = 0). It does not need any: the ground is opaque, so a volume that + * reaches through it is clipped by the depth prepass, not by the proxy. + */ + float hullShape(vec3 p) { + float inv = 1.0 / max(1.0 - uMargin, 0.05); + + #if HULL_SHAPE == 0 /* BOX */ + vec2 lat = abs(p.xz) / uSize.xz * inv; + // Chebyshev gives a genuinely rectangular footprint; Euclidean gives an + // ellipse inside it. uRound blends between them, because "rounded + // rectangle" is what almost every ground slab actually wants and a real + // rounded-box SDF costs more than this is worth inside a march. + float side = 1.0 - mix(max(lat.x, lat.y), length(lat), clamp(uRound, 0.0, 1.0)); + float yn = clamp(p.y / max(uSize.y, 1e-4) * inv, 0.0, 1.0); + return side * mix(1.0, 1.0 - yn, uHeightBias) + * (1.0 - smoothstep(1.0 - uFeather, 1.0, yn)); + + #elif HULL_SHAPE == 1 /* CYLINDER */ + float r = length(p.xz / uSize.xz) * inv; + float yn = clamp(p.y / max(uSize.y, 1e-4) * inv, 0.0, 1.0); + return (1.0 - r) * mix(1.0, 1.0 - yn, uHeightBias) + * (1.0 - smoothstep(1.0 - uFeather, 1.0, yn)); + + #elif HULL_SHAPE == 2 /* CONE */ + float zn = clamp(p.z / max(uSize.z, 1e-4) * inv, 0.0, 1.0); + // Normalise by the *local* radius so r is 0 on the axis and 1 on the + // lateral surface at every station along the cone: the field then + // describes the cross-section once instead of once per distance. + float w = max(zn, 0.02); + float r = length(vec2(p.x / (uSize.x * w), p.y / (uSize.y * w))) * inv; + // Hollow: move the density peak off the axis. A gout of flame from a + // nozzle is a sheath — unburnt fuel down the middle — and that is what + // lets you orbit it and see the far wall of the cone through the near + // one. A solid cone reads as a cardboard megaphone from every angle. + float core = 1.0 - abs(r - uHollow) / max(1.0 - uHollow, 1e-3); + return core * smoothstep(0.0, max(uThroat, 1e-3), zn) + * (1.0 - smoothstep(1.0 - uFeather, 1.0, zn)); + + #elif HULL_SHAPE == 3 /* DOME */ + float r = length(p / uSize) * inv; + float yn = clamp(p.y / max(uSize.y, 1e-4) * inv, 0.0, 1.0); + return (1.0 - r) * mix(1.0, 1.0 - yn, uHeightBias); + + #else /* SPHERE */ + float r = length(p / uSize) * inv; + float yn = clamp(p.y / max(uSize.y, 1e-4) * 0.5 + 0.5, 0.0, 1.0); + return (1.0 - r) * mix(1.0, 1.0 - yn, uHeightBias); + #endif + } + + /* ---------------------------------------------------------------- */ + /* Advection */ + /* ---------------------------------------------------------------- */ + + /** + * The **world**-space noise domain. + * + * World, so the medium keeps a fixed physical grain: a dome that contracts + * reveals more grain rather than magnifying the same grain, which is the + * entire reason Pyroclasm's implosion reads as an implosion and not as a + * zoom. It is also why two casts landing beside each other do not look like + * the same cloud printed twice. + * + * 'uFlatten' compresses nothing — it *stretches* the domain's Y, which makes + * every feature short and wide. A cloud that pours across the floor instead + * of billowing is a cloud whose eddies are pancakes, and no amount of + * squashing the hull will produce that on its own. + */ + vec3 worldDomain(vec3 wp) { + vec3 p = wp - uFlow * uTime; + p.y -= uRise * uTime; + p.y *= mix(1.0, 5.0, clamp(uFlatten, 0.0, 1.0)); + return p * uNoiseFrequency + uSeed; + } + + /** + * The **local** noise domain. + * + * Local, so the structure is carried by the hull rather than swum through: a + * jet's turbulence belongs to the jet, and a cone that yaws with the caster + * must take its tongues of flame with it. 'uJet' pushes the domain along the + * hull's own axis — +Z for a cone, +Y for anything standing on the floor — + * and 'uSwirl' rotates it about that same axis. + */ + vec3 localDomain(vec3 lp) { + vec3 p = lp; + #if HULL_SHAPE == 2 + p.z -= uJet * uTime; + p.xy = rot2(uSwirl * uTime) * p.xy; + #else + p.y -= uJet * uTime; + p.xz = rot2(uSwirl * uTime) * p.xz; + p.y *= mix(1.0, 5.0, clamp(uFlatten, 0.0, 1.0)); + #endif + return p * uNoiseFrequency + uSeed; + } + + /* ---------------------------------------------------------------- */ + /* GAS_BOIL */ + /* ---------------------------------------------------------------- */ + + #if MEDIUM == 6 + /** + * A cellular field whose cells inflate and pop on their own timers. + * + * The first version was a 3×3×3 Worley field, which is the textbook answer + * and is unaffordable here: twenty-seven hashed cells inside a forty-step + * march is eleven hundred hashes a pixel before the fbm has been touched. + * + * So each bubble is confined to its own lattice cell. Jitter the centre by at + * most (1 - size)/2 and cap the radius at size/2 and a bubble provably cannot + * leave its cell, which makes the containing cell the *only* cell that can + * contribute — one hash, no neighbourhood loop, exact. The price is that + * bubbles never overlap and the lattice would be plainly visible; one octave + * of value noise bending the domain first ('uBoilWarp') buys that back for a + * single extra tap. Two taps total, against twenty-seven. + * + * Each cell's clock is 'fract(t · rate · jitter + offset)', so the cloud is + * full of independent events rather than one global pulse: + * + * 0.00 → 0.55 inflate + * 0.55 → 0.80 hold + * 0.80 → 1.00 swell and thin — the pop + * + * @param event out — the pop flash, 0..1, spent on 'uColorEvent'. + */ + float boilField(vec3 p, out float event) { + event = 0.0; + + // One tap, reused on three axes with different weights. Properly it wants + // three independent noises; at three times the cost, for a warp whose only + // job is to stop a lattice reading as a lattice, it does not. + float w = vnoise(p * 0.4) - 0.5; + vec3 warped = p + w * uBoilWarp * vec3(1.7, 0.9, 1.3); + + vec3 cell = floor(warped); + vec3 f = warped - cell; + + float seed = hash13(cell + uSeed); + float phase = fract(uTime * uBoilRate * (0.55 + 0.9 * seed) + seed * 7.31); + + float grow = smoothstep(0.0, 0.55, phase); + float pop = smoothstep(0.80, 1.0, phase); + event = pop * (1.0 - smoothstep(0.96, 1.0, phase)); + + float size = clamp(uBoilSize, 0.05, 1.0); + // Clamped, not merely scaled: the containment proof above depends on the + // radius never exceeding size/2, and uBoilPop would otherwise push it past. + float radius = size * 0.5 * min(grow * (1.0 + uBoilPop * pop), 1.0); + vec3 centre = vec3(0.5) + (hash31(seed * 91.7) - 0.5) * (1.0 - size); + + float shell = 1.0 - smoothstep(radius * 0.45, radius, length(f - centre)); + // The bubble thins as it bursts rather than vanishing on a frame boundary. + return shell * (1.0 - pop * 0.85); + } + #endif + + /* ---------------------------------------------------------------- */ + /* The medium */ + /* ---------------------------------------------------------------- */ + + /** + * Density at a point, plus everything the shading needs. + * + * @param lp hull-local metres + * @param wp world metres + * @param shape out — the hull silhouette, for the colour ramp + * @param detail out — the raw fbm, 0..1, which SAND and SPORE cut into grain + * @param event out — a per-medium one-shot (GAS_BOIL's pop; 0 elsewhere) + */ + float mediumDensity(vec3 lp, vec3 wp, out float shape, out float detail, out float event) { + detail = 0.5; + event = 0.0; + + shape = hullShape(lp); + + // Conservative reject before paying for the fbm. 'fringe' is clamped at 2 + // below, so the erosion can lift the field by at most + // uNoiseStrength · (0.22 + 0.78 · 2²) = 3.34 · uNoiseStrength — anything + // further out than that provably cannot become density, whatever the noise + // does. It fires on the corners of a box hull and around the apex of a + // cone; it is a guard, not the main saving. The main saving is the empty- + // space stride in the march. + float reachable = -3.34 * uNoiseStrength; + #if MEDIUM == 6 + reachable -= 0.75 * uBoilDepth; + #endif + if (shape < reachable) return 0.0; + + #if MEDIUM == 0 /* FLAME — follows the hull */ + vec3 domain = localDomain(lp); + domain.y -= uRise * uTime * uNoiseFrequency; + #else /* everything else keeps world grain */ + vec3 domain = worldDomain(wp); + #endif + + detail = volFbm(domain); + float n = detail * 2.0 - 1.0; + + // Erosion is weak on the axis and violent at the fringe. That contrast — + // a solid heart with an edge dissolving into wisps — is most of what reads + // as a *volume* rather than as a shaded proxy mesh. A uniform erosion just + // makes the whole thing noisy, which looks like television static. + float fringe = clamp(1.0 - shape, 0.0, 2.0); + float erosion = uNoiseStrength * (0.22 + 0.78 * fringe * fringe); + + float field = shape + n * erosion; + + #if MEDIUM == 6 + float boil = boilField(wp * uBoilScale, event); + // Pockets swell out of the cloud and the gaps between them thin it, so + // the silhouette is genuinely eaten by the boil rather than decorated. + field += (boil - 0.25) * uBoilDepth; + event *= boil; + #endif + + float d = smoothstep(0.0, max(uSoftness, 0.02), field); + if (d <= 0.0) return 0.0; + d = pow(d, max(uDensityCurve, 0.05)); + + #if MEDIUM == 4 /* SAND */ + // Cut the fbm somewhere steep. Sand is not a brown cloud, it is a curtain + // of separate grains, and the only way to get separate grains out of a + // continuous field is to threshold it hard enough to break it up. + d *= mix(1.0, smoothstep(0.42, 0.72, detail), clamp(uDetail, 0.0, 1.0)); + #elif MEDIUM == 3 /* SPORE */ + d *= mix(1.0, 0.3 + 0.7 * detail, clamp(uDetail, 0.0, 1.0)); + #endif + + return d * uDensity; + } + + /** Density only — the self-shadow tap does not need the rest. */ + float shadowDensity(vec3 lp, vec3 wp) { + float shape, detail, event; + return mediumDensity(lp, wp, shape, detail, event); + } + + /** + * Emission and albedo for a sample. + * + * 'emit' radiates on its own; 'albedo' is what the medium *reflects* out of + * the key light. Splitting them is what separates a flame from smoke without + * two shaders: FLAME leaves albedo near zero and lives on emit, SMOKE leaves + * emit at zero and lives on albedo, ASH does both. + */ + void mediumShade(float shape, float detail, float event, vec3 wp, + out vec3 emit, out vec3 albedo) { + float s = clamp(shape, 0.0, 1.0); + albedo = gradient4(uColorCore, uColorMid, uColorEdge, uColorDeep, 1.0 - s); + emit = vec3(0.0); + + #if MEDIUM == 0 /* FLAME */ + // Temperature from the silhouette — geometry — rather than from the + // density field. The density field's level sets are nested closed loops, + // and a high emission exponent pointed at them renders the flame as + // polished agate: contour lines wrapping every blob. Blunter is better. + // The full treatment lives in materials/VolumetricFireMaterial.js. + float heat = clamp(s * 1.35, 0.0, 1.0); + emit = gradient4(uColorCore, uColorMid, uColorEdge, uColorDeep, pow(1.0 - heat, 0.85)); + albedo *= 0.15; + + #elif MEDIUM == 2 /* ASH */ + // Embers on the sparse lattice, each blinking on its own hash so they + // never pulse together. Deliberately gated on the silhouette too: an + // ember floating in the thin fringe has nothing burning around it. + float id; + float ember = speck(wp * uSpeckScale, uSpeckDensity, uSpeckSize, id); + ember *= 0.35 + 0.65 * sin(uTime * 9.0 + id * 61.0); + emit = uColorSpeck * max(ember, 0.0) * uSpeckGlow * s; + + #elif MEDIUM == 3 /* SPORE */ + // Glints, not a glow. Bioluminescence spread evenly through the medium + // reads as a fog machine with a green gel in it; discrete points drifting + // in a dim cloud read as something alive in there. + float gid; + float glint = speck(wp * uSpeckScale, uSpeckDensity, uSpeckSize, gid); + glint *= 0.4 + 0.6 * sin(uTime * 2.2 + gid * 47.0); + emit = uColorSpeck * max(glint, 0.0) * uSpeckGlow; + + #elif MEDIUM == 6 /* GAS_BOIL */ + emit = uColorEvent * event * uBoilFlash; + + #elif MEDIUM == 7 /* VOID */ + // Stars on the world lattice, so they belong to the sky and do not swim + // when the dome moves. + float sid; + float star = speck(wp * uSpeckScale, uSpeckDensity, uSpeckSize, sid); + star *= 0.55 + 0.45 * sin(uTime * 2.6 + sid * 51.0); + emit = uColorSpeck * star * uSpeckGlow; + // The residual interior colour, deliberately tiny: VOID's job is to take + // light away, and anything it adds back fights that. + albedo *= 0.06; + #endif + } + + /* ---------------------------------------------------------------- */ + /* March */ + /* ---------------------------------------------------------------- */ + + void main() { + vec3 roW = cameraPosition; + vec3 rdW = normalize(vWorld - roW); + + // The direction is transformed *without* normalising, on purpose: an affine + // map sends the world ray ro + rd·t to roL + rdL·t with the same t. That + // single fact is what lets the march keep one parameter in world metres — + // so 'uContact', 'uAbsorption' and the depth clip are all in real metres — + // while the hull test, which only makes sense in the hull's own frame, runs + // in local metres. It also means the hull's matrix must stay rigid: scale + // it with setSize(), never with mesh.scale, or t stops meaning metres. + vec3 roL = (uInvModel * vec4(roW, 1.0)).xyz; + vec3 rdL = (uInvModel * vec4(rdW, 0.0)).xyz; + + float t0, t1; + if (!hullSpan(roL, rdL, t0, t1)) discard; + t0 = max(t0, 0.02); + + // Clip the march on the opaque scene rather than depth-testing the hull. + // Testing the hull's own depth would slice the volume flat where it meets + // the floor; clipping per sample lets it *fade* into the contact, which is + // the difference between a volume and a decal of a volume. The depth buffer + // is half resolution (see PostProcessing), so uContact has to be generous + // enough to hide the stair-stepping — a metre is not too much. + float dzdt = (viewMatrix * vec4(rdW, 0.0)).z; + // Not called 'packed': that is a future-reserved word in GLSL ES, and while + // every desktop driver we have met lets it through, SwiftShader does not. + float depthBits = unpackRGBAToDepth(texture2D(uSceneDepth, gl_FragCoord.xy / uResolution)); + float sceneViewZ = perspectiveDepthToViewZ(depthBits, uCameraNear, uCameraFar); + float tScene = dzdt < -1e-5 ? sceneViewZ / dzdt : 1e6; + t1 = min(t1, tScene); + if (t1 <= t0) discard; + + float steps = clamp(uSteps, 4.0, float(MAX_STEPS)); + float baseStep = (t1 - t0) / steps; + + // Dither the entry point. A fixed start turns the slices into onion rings + // wrapped around the hull; a per-pixel offset turns the same error into + // grain, which the bloom eats. uJitter of 0 is only ever useful for + // screenshots of the banding you are trying to fix. + float dither = hash13(vec3(gl_FragCoord.xy, fract(uTime) * 64.0)); + float t = t0 + baseStep * mix(0.5, dither, clamp(uJitter, 0.0, 1.0)); + + vec3 lightL = (uInvModel * vec4(uLightDir, 0.0)).xyz; + float phase = phaseHG(dot(rdW, uLightDir), uAnisotropy) * 12.566370614; + + vec3 acc = vec3(0.0); + float transmittance = 1.0; + // A hull tight enough to be affordable is still mostly empty along any one + // ray. Coasting through the empty part at a coarser stride — and dropping + // back to the fine one the instant anything is hit — buys back most of the + // cost of the headroom the silhouette needs. + float stride = 1.0; + + for (int i = 0; i < MAX_STEPS; i++) { + if (t >= t1 || transmittance < 0.012) break; + + vec3 wp = roW + rdW * t; + vec3 lp = roL + rdL * t; + + float shape, detail, event; + float d = mediumDensity(lp, wp, shape, detail, event); + float stepSize = baseStep * stride; + + if (d > 0.002) { + stride = 1.0; + stepSize = baseStep; + + // Soften the contact with whatever the march ran into. + d *= clamp((tScene - t) / max(uContact, 1e-3), 0.0, 1.0); + + vec3 emit, albedo; + mediumShade(shape, detail, event, wp, emit, albedo); + + float shadow = 1.0; + #if VOLUME_SHADOW == 1 + // One or two taps up the key direction. This is the single most + // expensive option in the module — each tap is a whole extra field + // evaluation — and for absorbing media it is also the only thing that + // gives them a lit side and a dark side. Emissive media do not need + // it and should run at zero taps. + if (uShadowTaps > 0.5) { + float occ = 0.0; + float taps = 0.0; + for (int s = 0; s < 2; s++) { + if (float(s) >= uShadowTaps) break; + float h = uShadowLength * (0.45 + float(s) * 1.1); + occ += shadowDensity(lp + lightL * h, wp + uLightDir * h); + taps += 1.0; + } + // Averaged, then converted to an optical depth over *one* + // shadowLength. Summing the raw taps instead makes the second tap + // count for twice the first and turns any dense medium + // black — which is exactly what it did the first time. + shadow = exp(-occ / max(taps, 1.0) * uAbsorption * uShadowLength * uShadowStrength); + } + #endif + + // uAmbient stands in for multiple scattering. Real smoke is grey, not + // black, because light that failed to arrive along the key direction + // arrived after bouncing around inside the cloud; single scattering + // alone gives an unlit side with no light in it at all, and no amount + // of raising uScatter fixes that because it scales the lit side too. + vec3 radiance = emit * uEmission * pow(clamp(d, 0.0, 1.0), max(uEmissionCurve, 0.05)) + + albedo * uColorLight * uScatter * (phase * shadow + uAmbient); + + acc += radiance * d * transmittance * stepSize; + transmittance *= exp(-d * uAbsorption * stepSize); + } else { + stride = min(stride * 1.6, 2.4); + } + + t += stepSize; + } + + float alpha = clamp((1.0 - transmittance) * uOpacity, 0.0, 1.0); + #if MEDIUM == 7 + // VOID needs no special blend mode, and that surprises people. Premultiplied + // "over" with a near-black premultiplied colour *is* subtraction: the + // destination is multiplied by (1 - alpha) and almost nothing is added + // back. uVoidBite lets the dome occlude harder than its own density would, + // so it can go to genuine black without having to be made physically thick. + alpha = clamp(alpha * (1.0 + uVoidBite), 0.0, 1.0); + #endif + + vec3 color = acc * uOpacity * uGlobalGlow * mix(0.65, 1.0, uShaderIntensity); + if (alpha < 0.002 && max(color.r, max(color.g, color.b)) < 0.002) discard; + + gl_FragColor = vec4(color, alpha); + } +`; + +/* ---------------------------------------------------------------- */ +/* Settings schema */ +/* ---------------------------------------------------------------- */ + +/** + * The single table that drives everything on the settings side: the key list, + * the defaults an ability spreads into its block, the editor schema, and the + * suffix → full-key binding 'sync()' reads through. + * + * 'def' is the neutral default. Per-medium opinions live in MEDIUM_DEFAULTS + * below and are layered on top, so a medium only states what it disagrees with. + * + * Columns: [suffix, default, min, max, step, label, folder] + */ +const FIELDS = [ + /* --- march --- */ + ['Steps', 36, 4, 96, 1, 'steps', 'march'], + ['Jitter', 1.0, 0, 1, 0.01, 'step dither', 'march'], + ['Contact', 0.9, 0.05, 4, 0.01, 'contact fade (m)', 'march'], + + /* --- silhouette --- */ + ['Margin', 0.18, 0.02, 0.6, 0.01, 'headroom inside the hull', 'shape'], + ['Round', 0.35, 0, 1, 0.01, 'footprint rounding', 'shape'], + ['HeightBias', 0.35, 0, 1, 0.01, 'density falls with height', 'shape'], + ['Feather', 0.25, 0.01, 0.9, 0.01, 'far-edge feather', 'shape'], + ['Hollow', 0.0, 0, 0.9, 0.01, 'cone: peak off-axis', 'shape'], + ['Throat', 0.12, 0.01, 0.6, 0.01, 'cone: opening length', 'shape'], + + /* --- field --- */ + ['Density', 1.6, 0, 8, 0.01, 'density', 'field'], + ['DensityCurve', 1.0, 0.1, 4, 0.01, 'density curve', 'field'], + ['Softness', 0.45, 0.02, 1, 0.01, 'edge softness', 'field'], + ['NoiseFrequency', 1.4, 0.05, 8, 0.01, 'features per metre', 'field'], + ['NoiseStrength', 0.85, 0, 3, 0.01, 'erosion', 'field'], + ['NoiseWarp', 0.25, 0, 1.5, 0.01, 'domain warp', 'field'], + ['Octaves', 4, 1, 5, 1, 'octaves', 'field'], + ['Detail', 0.5, 0, 1, 0.01, 'fine-octave gain', 'field'], + + /* --- advection --- */ + ['FlowX', 0.0, -6, 6, 0.01, 'world flow X (m/s)', 'flow'], + ['FlowY', 0.0, -6, 6, 0.01, 'world flow Y (m/s)', 'flow'], + ['FlowZ', 0.0, -6, 6, 0.01, 'world flow Z (m/s)', 'flow'], + ['Jet', 0.0, -12, 12, 0.01, 'along hull axis (m/s)', 'flow'], + ['Rise', 0.6, -4, 6, 0.01, 'buoyant rise (m/s)', 'flow'], + ['Swirl', 0.0, -4, 4, 0.01, 'swirl about axis (rad/s)', 'flow'], + ['Flatten', 0.0, 0, 1, 0.01, 'pancake the eddies', 'flow'], + + /* --- optics --- */ + ['Absorption', 1.4, 0, 8, 0.01, 'absorption (1/m)', 'optics'], + ['Scatter', 1.0, 0, 4, 0.01, 'scattering', 'optics'], + ['Ambient', 0.25, 0, 2, 0.01, 'multi-scatter floor', 'optics'], + ['Anisotropy', 0.25, -0.9, 0.9, 0.01, 'forward scatter', 'optics'], + ['Emission', 0.0, 0, 12, 0.01, 'emission', 'optics'], + ['EmissionCurve', 1.0, 0.1, 4, 0.01, 'emission by density', 'optics'], + ['ShadowTaps', 1, 0, 2, 1, 'self-shadow taps', 'optics'], + ['ShadowStrength', 1.0, 0, 4, 0.01, 'self-shadow strength', 'optics'], + ['ShadowLength', 0.6, 0.05, 4, 0.01, 'self-shadow reach (m)', 'optics'], + ['Opacity', 1.0, 0, 1, 0.01, 'opacity', 'optics'], + + /* --- GAS_BOIL --- */ + ['BoilRate', 0.5, 0, 4, 0.01, 'pops per second', 'boil'], + ['BoilScale', 1.6, 0.1, 8, 0.01, 'cells per metre', 'boil'], + ['BoilSize', 0.72, 0.05, 1, 0.01, 'bubble size in a cell', 'boil'], + ['BoilPop', 0.55, 0, 1, 0.01, 'burst swell', 'boil'], + ['BoilWarp', 1.1, 0, 3, 0.01, 'lattice warp', 'boil'], + ['BoilDepth', 0.8, 0, 3, 0.01, 'how far bubbles eat the cloud', 'boil'], + ['BoilFlash', 2.2, 0, 12, 0.01, 'pop flash', 'boil'], + + /* --- sparse points: ASH embers, SPORE glints, VOID stars --- */ + ['SpeckDensity', 0.12, 0, 1, 0.005, 'fraction of cells with a point', 'speck'], + ['SpeckScale', 2.2, 0.1, 16, 0.01, 'point cells per metre', 'speck'], + ['SpeckSize', 0.3, 0.02, 0.9, 0.01, 'point size in a cell', 'speck'], + ['SpeckGlow', 3.0, 0, 24, 0.01, 'point brightness', 'speck'], + + /* --- VOID --- */ + ['VoidBite', 0.4, 0, 3, 0.01, 'extra occlusion', 'void'], + + /* --- colour (I5: five independent pickers, nothing derived) --- */ + ['ColorCore', '#ffffff', null, null, null, 'core', 'colour'], + ['ColorMid', '#b0b0b0', null, null, null, 'mid', 'colour'], + ['ColorEdge', '#606060', null, null, null, 'edge', 'colour'], + ['ColorDeep', '#181818', null, null, null, 'deep', 'colour'], + ['ColorLight', '#fff4e0', null, null, null, 'key light', 'colour'], + ['ColorEvent', '#ffb060', null, null, null, 'event flash', 'colour'], + ['ColorSpeck', '#c0d0ff', null, null, null, 'embers / glints / stars', 'colour'] +]; + +/** Bare suffixes, in table order. Prefix them to build a settings block. */ +export const VOLUME_HULL_KEYS = FIELDS.map((f) => f[0]); + +/** + * What each medium disagrees with in the neutral defaults. + * + * Palettes are the ROSTER's, so an ability that wants Pyroclasm's ash or + * Nightfall's void starts from the colours its entry already specifies and + * tunes from there rather than from grey. + */ +const MEDIUM_DEFAULTS = [ + /* FLAME */ { + Steps: 40, + Density: 2.4, + DensityCurve: 1.2, + Softness: 0.35, + NoiseFrequency: 2.4, + NoiseStrength: 1.0, + NoiseWarp: 0.35, + // Erosion at 1.0 throws density a long way past the silhouette; it needs + // the room. See "the one rule" on VolumeHull. + Margin: 0.3, + Rise: 2.6, + Absorption: 0.9, + Scatter: 0.35, + Anisotropy: 0.4, + Emission: 7.0, + EmissionCurve: 1.5, + ShadowTaps: 0, + Ambient: 0.1, + Feather: 0.3, + ColorCore: '#fff2c0', + ColorMid: '#ffb03a', + ColorEdge: '#e0400f', + ColorDeep: '#2a0a04', + ColorLight: '#ffd9a0', + ColorEvent: '#ffe0a0' + }, + /* SMOKE */ { + Steps: 32, + Density: 1.7, + // Scatter is deliberately close to absorption. The integral gathers + // albedo · scatter and loses transmittance at absorption, so a medium whose + // scatter is a third of its absorption comes out a third as bright as its + // own palette — which is how the first pass rendered slate-grey smoke as a + // black hole. If a medium looks too dark, this ratio is the first place to + // look, not the colour pickers. + Absorption: 2.6, + Scatter: 2.4, + Anisotropy: 0.2, + Emission: 0.0, + NoiseFrequency: 1.1, + Rise: 0.9, + ShadowTaps: 1, + Ambient: 0.42, + ColorCore: '#d2d2d2', + ColorMid: '#8e8e8e', + ColorEdge: '#4a4a4a', + ColorDeep: '#1a1a1a', + ColorLight: '#fff4e0' + }, + /* ASH */ { + Steps: 36, + Density: 2.0, + Absorption: 3.0, + Scatter: 1.9, + Anisotropy: 0.1, + Emission: 1.6, + EmissionCurve: 0.7, + NoiseFrequency: 1.6, + Detail: 0.8, + Rise: 0.2, + FlowY: -0.4, + ShadowTaps: 1, + Ambient: 0.34, + SpeckDensity: 0.1, + SpeckScale: 3.4, + SpeckSize: 0.16, + SpeckGlow: 9.0, + ColorCore: '#8a7a6a', + ColorMid: '#5c2a10', + ColorEdge: '#2a1a12', + ColorDeep: '#1a1210', + ColorLight: '#ffcf9a', + ColorEvent: '#ff7a2a' + }, + /* SPORE */ { + Steps: 28, + Density: 0.9, + Absorption: 0.5, + Scatter: 1.4, + Anisotropy: 0.5, + Emission: 2.2, + EmissionCurve: 0.6, + NoiseFrequency: 1.8, + Detail: 0.9, + Rise: 0.35, + Flatten: 0.4, + ShadowTaps: 0, + Ambient: 0.5, + SpeckDensity: 0.16, + SpeckScale: 2.8, + SpeckSize: 0.2, + SpeckGlow: 7.0, + ColorCore: '#c8ff9a', + ColorMid: '#7ad0a0', + ColorEdge: '#2a6b4a', + ColorDeep: '#0e2018', + ColorLight: '#d8ffe8' + }, + /* SAND */ { + Steps: 34, + Density: 2.2, + Absorption: 3.4, + Scatter: 2.4, + Anisotropy: 0.05, + Emission: 0.0, + NoiseFrequency: 2.2, + Detail: 1.0, + Rise: -0.3, + ShadowTaps: 1, + Ambient: 0.4, + ColorCore: '#c8b48a', + ColorMid: '#9a8460', + ColorEdge: '#565049', + ColorDeep: '#221f1c', + ColorLight: '#ffe8c0' + }, + /* MIST */ { + Steps: 24, + Density: 0.85, + DensityCurve: 1.4, + Softness: 0.7, + Absorption: 0.9, + Scatter: 2.0, + Anisotropy: 0.72, + Emission: 0.0, + Octaves: 2, + NoiseFrequency: 0.7, + NoiseStrength: 0.55, + Margin: 0.1, + Rise: 0.15, + Flatten: 0.5, + ShadowTaps: 0, + Ambient: 0.55, + ColorCore: '#e8f0ff', + ColorMid: '#b0c0d8', + ColorEdge: '#68788c', + ColorDeep: '#2a3340', + ColorLight: '#ffffff' + }, + /* GAS_BOIL */ { + Steps: 38, + Density: 1.8, + Absorption: 2.2, + Scatter: 1.9, + Anisotropy: 0.15, + Emission: 1.4, + EmissionCurve: 0.5, + NoiseFrequency: 0.9, + Octaves: 3, + Rise: 0.5, + ShadowTaps: 1, + Ambient: 0.4, + ColorCore: '#c8d86a', + ColorMid: '#9aa83a', + ColorEdge: '#4a5a18', + ColorDeep: '#161c08', + ColorLight: '#f0ffc0', + ColorEvent: '#e8ff9a' + }, + /* VOID */ { + Steps: 30, + Density: 1.8, + DensityCurve: 0.8, + Softness: 0.55, + // Deliberately modest, and the reason is the stars. Absorption high enough + // to black the dome out on its own kills the transmittance in the first + // half-metre, so every star behind that is integrated at T = 0 and never + // appears. VoidBite does the blacking-out instead — it works on the final + // alpha, after the emission has already been gathered. + Absorption: 1.9, + Scatter: 0.25, + Ambient: 0.06, + Anisotropy: 0.0, + Emission: 1.0, + EmissionCurve: 0.35, + NoiseFrequency: 0.6, + NoiseStrength: 0.5, + Octaves: 3, + Rise: 0.1, + ShadowTaps: 0, + VoidBite: 1.1, + SpeckDensity: 0.09, + SpeckScale: 1.8, + SpeckSize: 0.14, + SpeckGlow: 14.0, + ColorCore: '#0a0a18', + ColorMid: '#050510', + ColorEdge: '#020206', + ColorDeep: '#000000', + ColorLight: '#20203a', + ColorSpeck: '#c0d0ff' + } +]; + +/** + * A ready-made settings sub-block for one hull. + * + * Spread it into an ability's block so the editor, the presets and + * `scripts/check.mjs` all see real keys: + * + * ```js + * export const pyroclasm = { + * range: 22, minRange: 4, speed: 30, cooldown: 1.2, castAnim: 'cast2', + * zoneRadius: 5.5, + * ...volumeHullDefaults('ash', Medium.ASH, { ashSteps: 44, ashRise: 1.2 }) + * }; + * ``` + * + * Overrides are keyed by the **prefixed** name, so they read the same way the + * ability will read them. + * + * @param {string} prefix settings-key prefix, e.g. 'ash' + * @param {number} medium Medium.* — picks the palette and the tuning + * @param {object} overrides prefixed key → value + */ +export function volumeHullDefaults(prefix, medium = Medium.SMOKE, overrides = {}) { + const tuning = MEDIUM_DEFAULTS[medium] ?? {}; + const block = {}; + for (const [suffix, def] of FIELDS) { + block[prefix + suffix] = suffix in tuning ? tuning[suffix] : def; + } + return Object.assign(block, overrides); +} + +/** + * Editor folders for one hull, ready to spread into an ability's schema. + * + * @param {string} prefix + * @param {object} [options] + * @param {string} [options.label] folder prefix, defaults to the key prefix + * @param {string[]} [options.only] folder ids to emit ('march','shape','field', + * 'flow','optics','boil','void','colour'). + * Omit the two medium-specific ones unless the + * medium uses them, or the editor grows forty + * dead sliders per ability. + */ +export function volumeHullSchema(prefix, options = {}) { + const label = options.label ?? prefix; + const only = options.only ?? ['march', 'shape', 'field', 'flow', 'optics', 'colour']; + const schema = {}; + for (const [suffix, def, min, max, step, name, folder] of FIELDS) { + if (!only.includes(folder)) continue; + const key = `${label} · ${folder}`; + if (!schema[key]) schema[key] = []; + // Colours are detected by value, so they go in bare (see EXPANSION §2). + schema[key].push(typeof def === 'string' ? prefix + suffix : [prefix + suffix, min, max, step, name]); + } + return schema; +} + +/** suffix → prefixed key, built once per prefix. See the note in `sync()`. */ +const _keyCache = new Map(); + +function keysFor(prefix) { + let keys = _keyCache.get(prefix); + if (keys) return keys; + keys = {}; + for (const [suffix] of FIELDS) keys[suffix] = prefix + suffix; + _keyCache.set(prefix, keys); + return keys; +} + +/* ---------------------------------------------------------------- */ +/* The hull */ +/* ---------------------------------------------------------------- */ + +const _dir = new Vector3(); +const _up = new Vector3(0, 1, 0); + + +/** + * A raymarched volume inside a proxy hull. + * + * **What it draws.** One mesh — a unit BOX / CYLINDER / CONE / DOME / SPHERE — + * whose fragment shader fires a ray from the camera, intersects the hull + * analytically in the hull's own frame, and integrates a procedural density + * field front to back with an early-out on transmittance and a per-sample clip + * against the opaque depth prepass. Nothing is a texture; the field is value + * noise, an fbm and (for GAS_BOIL) a cellular lattice. + * + * **Draw calls.** One. Always one, whatever the medium. + * + * **What it reads from settings.** Everything, through a key prefix, every + * frame — see `sync()`. The one dimension it does *not* own is its own + * footprint: call `setSize()` each frame with metres you have just re-resolved + * yourself. That is deliberate. In every ability that wants a volume the + * footprint already belongs to something else — `zoneRadius`, the cast length, + * the cone's reach — and giving the hull its own radius slider would produce + * two numbers that have to agree and one bug report per ability when they do + * not. + * + * **The one rule.** *The hull must be the smallest shape that still contains + * the field.* Both failures are ugly and both are common. Too small and the + * volume is sliced off along a dead straight line where it meets the proxy — + * unmistakable, and the single most obvious way this technique fails. Too big + * and every ray spends its whole step budget crossing vacuum, so the volume + * both costs more *and* resolves less. + * + * The knob that reconciles them is `Margin`: it holds the medium's + * nominal surface that fraction of the hull inside the wall, leaving the + * erosion somewhere to push into. Erosion is what escapes, so the pair to + * watch is `Margin` against `NoiseStrength` — turn one up and you owe the + * other. If you see a straight edge, that is the pair, every time. + * + * **Cost.** `cost(pixels)` gives the field-sample count; keep the scene's total + * under `VOLUME_SAMPLE_BUDGET`. Rules of thumb, at 1080p: + * + * coverage steps taps samples verdict + * ──────── ───── ──── ──────── ─────────────────────────────── + * 10 % 32 0 6.6 M comfortable + * 25 % 36 0 18.7 M the working limit for a hero volume + * 25 % 36 1 37 M only if it is the only VFX on screen + * 50 % 48 1 100 M will not hold 60 fps anywhere + * + * There is no half-resolution VFX pass in this renderer, so a volume pays full + * fragment cost. The two levers in order of effect are **coverage** (shrink the + * hull, or move the camera) and **taps** (emissive media need none); dropping + * steps is the last resort because it is the one that shows. + * + * @example + * const hull = new VolumeHull({ + * hull: HullShape.DOME, medium: Medium.ASH, prefix: 'ash', maxSteps: 48 + * }); + * this.group.add(hull.mesh); + * // ...each frame, including a zero-length one: + * const r = settings.pyroclasm.zoneRadius * settings.pyroclasm.ashSpread; + * hull.place(this.position).setSize(r, r * 0.55, r).setFade(1 - t).sync(c, g); + */ +export class VolumeHull { + /** + * @param {object} options + * @param {number} [options.hull=HullShape.SPHERE] HullShape.* + * @param {number} [options.medium=Medium.SMOKE] Medium.* + * @param {string} [options.prefix='volume'] settings-key prefix + * @param {number} [options.maxSteps=48] compile-time loop cap + * @param {boolean} [options.shadow] compile the self-shadow + * tap at all. Defaults to true for absorbing media. Setting + * it false removes the branch and shrinks the shader. + * @param {boolean} [options.additive=false] additive instead of + * premultiplied "over". Cheaper to reason about for pure + * glow (MIST haloes), but the medium then cannot occlude + * anything, so never use it for SMOKE, SAND, ASH or VOID. + * @param {number} [options.renderOrder=12] + * @param {number} [options.seed] unitless; a dice roll + */ + constructor(options = {}) { + const { + hull = HullShape.SPHERE, + medium = Medium.SMOKE, + prefix = 'volume', + maxSteps = 48, + shadow = medium !== Medium.FLAME && medium !== Medium.VOID && medium !== Medium.SPORE, + additive = false, + renderOrder = 12, + seed = Math.random() * 97 + } = options; + + this.hull = hull; + this.medium = medium; + this.prefix = prefix; + this.maxSteps = Math.max(4, Math.round(maxSteps)); + this.shadowCompiled = !!shadow; + + this._keys = keysFor(prefix); + this._defaults = volumeHullDefaults(prefix, medium); + this._fade = 1; + this._steps = 0; + this._shadowTaps = 0; + this._checked = false; + + const blend = additive + ? { blending: AdditiveBlending } + : { + blending: CustomBlending, + blendEquation: AddEquation, + blendSrc: OneFactor, + blendDst: OneMinusSrcAlphaFactor, + blendSrcAlpha: OneFactor, + blendDstAlpha: OneMinusSrcAlphaFactor + }; + + this.material = new ShaderMaterial({ + defines: { + HULL_SHAPE: hull, + MEDIUM: medium, + MAX_STEPS: this.maxSteps, + VOLUME_SHADOW: this.shadowCompiled ? 1 : 0 + }, + transparent: true, + depthWrite: false, + // Per-sample scene-depth clipping inside the march does the occlusion job + // properly. Testing the hull's own depth would slice the volume flat. + depthTest: false, + // Back faces, so the camera can stand *inside* the volume and still get a + // fragment — front faces would be clipped away by the near plane and the + // volume would vanish exactly when the player walks into it. + side: BackSide, + toneMapped: false, + ...blend, + uniforms: sharedUniforms({ + uInvModel: { value: new Matrix4() }, + uSize: { value: new Vector3(1, 1, 1) }, + + uSteps: { value: 32 }, + uJitter: { value: 1 }, + uContact: { value: 0.9 }, + uSeed: { value: seed }, + + uRound: { value: 0.35 }, + uMargin: { value: 0.18 }, + uHeightBias: { value: 0.35 }, + uFeather: { value: 0.25 }, + uHollow: { value: 0 }, + uThroat: { value: 0.12 }, + + uDensity: { value: 1.6 }, + uDensityCurve: { value: 1 }, + uSoftness: { value: 0.45 }, + uNoiseFrequency: { value: 1.4 }, + uNoiseStrength: { value: 0.85 }, + uNoiseWarp: { value: 0.25 }, + uOctaves: { value: 4 }, + uDetail: { value: 0.5 }, + + uFlow: { value: new Vector3() }, + uJet: { value: 0 }, + uRise: { value: 0.6 }, + uSwirl: { value: 0 }, + uFlatten: { value: 0 }, + + uAbsorption: { value: 1.4 }, + uScatter: { value: 1 }, + uAmbient: { value: 0.25 }, + uAnisotropy: { value: 0.25 }, + uEmission: { value: 0 }, + uEmissionCurve: { value: 1 }, + uShadowTaps: { value: 1 }, + uShadowStrength: { value: 1 }, + uShadowLength: { value: 0.6 }, + uOpacity: { value: 1 }, + + uBoilRate: { value: 0.5 }, + uBoilScale: { value: 1.6 }, + uBoilSize: { value: 0.72 }, + uBoilPop: { value: 0.55 }, + uBoilWarp: { value: 1.1 }, + uBoilDepth: { value: 0.8 }, + uBoilFlash: { value: 2.2 }, + + uSpeckDensity: { value: 0.12 }, + uSpeckScale: { value: 2.2 }, + uSpeckSize: { value: 0.3 }, + uSpeckGlow: { value: 3 }, + uVoidBite: { value: 0.4 }, + + uColorCore: { value: new Color(1, 1, 1) }, + uColorMid: { value: new Color(0.7, 0.7, 0.7) }, + uColorEdge: { value: new Color(0.35, 0.35, 0.35) }, + uColorDeep: { value: new Color(0.08, 0.08, 0.08) }, + uColorLight: { value: new Color(1, 0.95, 0.88) }, + uColorEvent: { value: new Color(1, 0.7, 0.35) }, + uColorSpeck: { value: new Color(0.75, 0.82, 1) } + }), + vertexShader: VERTEX, + fragmentShader: FRAGMENT + }); + + this.mesh = new Mesh(unitHull(hull), this.material); + this.mesh.name = `VolumeHull:${HULL_NAMES[hull]}:${MEDIUM_NAMES[medium]}`; + this.mesh.layers.set(LAYER.VFX); + this.mesh.renderOrder = renderOrder; + // The geometry is unit-sized and stretched in the vertex shader, so three's + // bounding sphere describes a one-metre ball that has nothing to do with + // where this thing actually is. Culling against it would blink the volume + // out at the screen edge. + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.visible = false; + } + + /* ---- placement ---------------------------------------------------- */ + + /** + * Anchor the hull. `direction` is optional and **flat**: only yaw is applied, + * because a dome or a slab that pitched with an aim line would lift off the + * floor at one edge and bury itself at the other. + * + * @param {THREE.Vector3} position world anchor (the hull's local origin) + * @param {THREE.Vector3} [direction] cast heading; the hull's local +Z + */ + place(position, direction = null) { + this.mesh.position.copy(position); + if (direction) { + _dir.copy(direction).setY(0); + if (_dir.lengthSq() > 1e-8) { + this.mesh.quaternion.setFromAxisAngle(_up, Math.atan2(_dir.x, _dir.z)); + } + } + this.mesh.updateMatrix(); + return this; + } + + /** + * Half-extents in metres — see `HullShape` for what each axis means. + * + * Free to call every frame: it writes a uniform, nothing is rebuilt, and the + * hull's world matrix is untouched (which is what keeps the march's `t` in + * metres — see the note in `main()`). + */ + setSize(x, y = x, z = x) { + this.material.uniforms.uSize.value.set(Math.max(x, 1e-3), Math.max(y, 1e-3), Math.max(z, 1e-3)); + return this; + } + + /** + * Master 0..1 from the ability's phase clock. + * + * It thins the medium rather than merely making it transparent, because a + * volume that fades on opacity alone goes ghostly and evenly grey — real + * smoke disperses, which means it loses density first and coverage with it. + * + * At zero the mesh is hidden outright. That is a real cost control, not + * tidiness: `frustumCulled` is off, so an invisible-but-drawn hull would keep + * paying full fill rate for a volume nobody can see. + */ + setFade(k) { + this._fade = k > 0 ? (k < 1 ? k : 1) : 0; + this.mesh.visible = this._fade > 0.002; + return this; + } + + /* ---- the frame ---------------------------------------------------- */ + + /** + * Re-resolve every uniform from live settings. Call once per frame, from + * `onTravel` / `onFade`, **including on a zero-length frame** (I1). + * + * Keys are `prefix + Suffix` — `ashDensity`, `ashSteps`, `ashColorCore`. The + * prefixed strings are built once, when the hull is constructed, and cached + * per prefix module-wide: concatenating forty strings per frame per hull + * would allocate forty short-lived strings sixty times a second, which is + * exactly the kind of thing I3 exists to stop. + * + * `settings.global` scales what it scales elsewhere, plus one of its own: + * `global.volumeQuality` (default 1 when absent) multiplies the step count, + * so the whole expansion's raymarching can be dialled down from one slider. + * + * @param {object} c the ability's settings block (`settings.`) + * @param {object} g `settings.global` + */ + sync(c, g) { + const K = this._keys; + const D = this._defaults; + const u = this.material.uniforms; + + if (!this._checked) this._audit(c); + + /* --- march --- */ + const quality = num(g?.volumeQuality, 1); + const steps = Math.round(num(c[K.Steps], D[K.Steps]) * quality); + this._steps = Math.max(4, Math.min(this.maxSteps, steps)); + u.uSteps.value = this._steps; + u.uJitter.value = num(c[K.Jitter], D[K.Jitter]); + u.uContact.value = num(c[K.Contact], D[K.Contact]); + + /* --- silhouette --- */ + u.uMargin.value = num(c[K.Margin], D[K.Margin]); + u.uRound.value = num(c[K.Round], D[K.Round]); + u.uHeightBias.value = num(c[K.HeightBias], D[K.HeightBias]); + u.uFeather.value = num(c[K.Feather], D[K.Feather]); + u.uHollow.value = num(c[K.Hollow], D[K.Hollow]); + u.uThroat.value = num(c[K.Throat], D[K.Throat]); + + /* --- field. The fade thins the medium; see setFade(). --- */ + u.uDensity.value = num(c[K.Density], D[K.Density]) * this._fade; + u.uDensityCurve.value = num(c[K.DensityCurve], D[K.DensityCurve]); + u.uSoftness.value = num(c[K.Softness], D[K.Softness]); + u.uNoiseFrequency.value = + num(c[K.NoiseFrequency], D[K.NoiseFrequency]) * num(g?.noiseFrequency, 1); + u.uNoiseStrength.value = + num(c[K.NoiseStrength], D[K.NoiseStrength]) * num(g?.noiseStrength, 1) * num(g?.turbulence, 1); + u.uNoiseWarp.value = num(c[K.NoiseWarp], D[K.NoiseWarp]) * num(g?.turbulence, 1); + u.uOctaves.value = num(c[K.Octaves], D[K.Octaves]); + u.uDetail.value = num(c[K.Detail], D[K.Detail]); + + /* --- advection. Metres per second, so global.noiseSpeed belongs here. --- */ + const speed = num(g?.noiseSpeed, 1); + u.uFlow.value.set( + num(c[K.FlowX], D[K.FlowX]) * speed, + num(c[K.FlowY], D[K.FlowY]) * speed, + num(c[K.FlowZ], D[K.FlowZ]) * speed + ); + u.uJet.value = num(c[K.Jet], D[K.Jet]) * speed; + u.uRise.value = num(c[K.Rise], D[K.Rise]) * speed; + u.uSwirl.value = num(c[K.Swirl], D[K.Swirl]) * speed; + u.uFlatten.value = num(c[K.Flatten], D[K.Flatten]); + + /* --- optics --- */ + u.uAbsorption.value = num(c[K.Absorption], D[K.Absorption]); + u.uScatter.value = num(c[K.Scatter], D[K.Scatter]); + u.uAmbient.value = num(c[K.Ambient], D[K.Ambient]); + u.uAnisotropy.value = num(c[K.Anisotropy], D[K.Anisotropy]); + u.uEmission.value = num(c[K.Emission], D[K.Emission]) * num(g?.glow, 1); + u.uEmissionCurve.value = num(c[K.EmissionCurve], D[K.EmissionCurve]); + // The compile-time flag wins: asking for taps in a shader built without + // them would silently do nothing, and reporting the cost as if they ran + // would make `cost()` lie. + this._shadowTaps = this.shadowCompiled + ? Math.max(0, Math.min(2, Math.round(num(c[K.ShadowTaps], D[K.ShadowTaps]) * quality))) + : 0; + u.uShadowTaps.value = this._shadowTaps; + u.uShadowStrength.value = num(c[K.ShadowStrength], D[K.ShadowStrength]); + u.uShadowLength.value = num(c[K.ShadowLength], D[K.ShadowLength]); + u.uOpacity.value = num(c[K.Opacity], D[K.Opacity]) * num(g?.opacity, 1); + + /* --- GAS_BOIL --- */ + u.uBoilRate.value = num(c[K.BoilRate], D[K.BoilRate]) * speed; + u.uBoilScale.value = num(c[K.BoilScale], D[K.BoilScale]); + u.uBoilSize.value = num(c[K.BoilSize], D[K.BoilSize]); + u.uBoilPop.value = num(c[K.BoilPop], D[K.BoilPop]); + u.uBoilWarp.value = num(c[K.BoilWarp], D[K.BoilWarp]); + u.uBoilDepth.value = num(c[K.BoilDepth], D[K.BoilDepth]); + u.uBoilFlash.value = num(c[K.BoilFlash], D[K.BoilFlash]) * num(g?.glow, 1); + + /* --- sparse points --- */ + u.uSpeckDensity.value = num(c[K.SpeckDensity], D[K.SpeckDensity]); + u.uSpeckScale.value = num(c[K.SpeckScale], D[K.SpeckScale]); + u.uSpeckSize.value = num(c[K.SpeckSize], D[K.SpeckSize]); + u.uSpeckGlow.value = num(c[K.SpeckGlow], D[K.SpeckGlow]) * num(g?.glow, 1); + + /* --- VOID --- */ + u.uVoidBite.value = num(c[K.VoidBite], D[K.VoidBite]); + + /* --- colour --- */ + u.uColorCore.value.copy(getColor(c[K.ColorCore] ?? D[K.ColorCore])); + u.uColorMid.value.copy(getColor(c[K.ColorMid] ?? D[K.ColorMid])); + u.uColorEdge.value.copy(getColor(c[K.ColorEdge] ?? D[K.ColorEdge])); + u.uColorDeep.value.copy(getColor(c[K.ColorDeep] ?? D[K.ColorDeep])); + u.uColorLight.value.copy(getColor(c[K.ColorLight] ?? D[K.ColorLight])); + u.uColorEvent.value.copy(getColor(c[K.ColorEvent] ?? D[K.ColorEvent])); + u.uColorSpeck.value.copy(getColor(c[K.ColorSpeck] ?? D[K.ColorSpeck])); + + /* --- the frame's one matrix inverse --- + World → hull-local. The march needs it to turn the camera ray into the + frame the hull is analytic in. Rebuilt every frame rather than on a dirty + flag: it is one 4×4 invert against a rigid matrix, and a dirty flag that + misses one placement leaves the volume marching yesterday's pose. */ + this.mesh.updateWorldMatrix(true, false); + u.uInvModel.value.copy(this.mesh.matrixWorld).invert(); + + return this; + } + + /* ---- cost --------------------------------------------------------- */ + + /** Steps actually marched last frame, after the quality multiplier. */ + get steps() { + return this._steps; + } + + /** Shadow taps actually taken last frame. */ + get shadowTaps() { + return this._shadowTaps; + } + + /** + * Field samples per frame at a given screen coverage. + * + * @param {number} coveredPixels how many pixels the hull's silhouette fills. + * Eyeball it: a dome filling a quarter of a 1080p frame is 520 000. + * @returns {number} compare against VOLUME_SAMPLE_BUDGET + */ + cost(coveredPixels) { + return coveredPixels * this._steps * (1 + this._shadowTaps); + } + + /* ---- teardown ----------------------------------------------------- */ + + /** Material only — the unit hull is shared. See disposeVolumeHullGeometry(). */ + dispose() { + this.material.dispose(); + this.mesh.parent?.remove(this.mesh); + } + + /** + * One-shot check that the settings block actually has the keys this hull + * reads. `sync()` falls back to the medium's default for anything missing, so + * a half-written block renders instead of turning the volume into NaN — but + * silently falling back is how an ability ends up with a slider that does + * nothing, so it says so, once, and names every key. + */ + _audit(c) { + this._checked = true; + auditBlock( + `VolumeHull:${this.prefix}`, + this._keys, + VOLUME_HULL_KEYS, + c, + `volumeHullDefaults('${this.prefix}', Medium.${MEDIUM_NAMES[this.medium]})` + ); + } +} diff --git a/src/vfx/hookFields.js b/src/vfx/hookFields.js new file mode 100644 index 0000000..c00fbaf --- /dev/null +++ b/src/vfx/hookFields.js @@ -0,0 +1,228 @@ +import { Vector4 } from 'three'; + +/* ------------------------------------------------------------------------ */ +/* hookFields — the two published fields, and nothing else */ +/* ------------------------------------------------------------------------ */ +/** + * The uniform boxes and GLSL that `vfx/SceneHooks.js` **publishes** for other + * materials to opt into: DISRUPT (spellbreak) and GRAVITY (hourglass). + * + * This file exists for one reason, and it is a module-graph reason rather than + * a graphics one, so it is worth writing down properly. + * + * `SceneHooks.js` imports `config/settings.js` — it must, because `apply()` + * blends the key light and the grade *from settings* rather than from the live + * value, which is the only way a blend cannot compound frame over frame. And + * `config/settings.js` imports `config/abilities/index.js`, which imports every + * settings module, six of which (`firewhip`, `geyser`, `railcoil`, `cyclone`, + * …) import `vfx/Tube.js` for `tubeDefaults()`. So the moment `Tube` opted into + * the disruption field the graph closed a ring: + * + * ``` + * settings.js → abilities/index.js → firewhip.js → Tube.js → SceneHooks.js → settings.js + * ``` + * + * ES modules tolerate a ring; they do not tolerate one that reads a `const` + * across it before the defining body has run. Whether it explodes depends + * entirely on **which module you enter the graph from**. Enter at `src/main.js` + * and `settings.js` is entered first, so `abilities/index.js` finishes before + * `settings.js`'s body reaches `...ABILITY_SETTINGS` — fine. Enter at + * `abilities/registry.js` or at `config/abilities/index.js` — which any test, + * any script, any tool and any future entry point may reasonably do — and + * `settings.js`'s body runs first and throws + * `ReferenceError: Cannot access 'ABILITY_SETTINGS' before initialization`, + * from a file that has nothing to do with the change that broke it. + * + * That failure mode was live in the tree: `Tube.js` carried a long comment + * explaining that the ring was benign from the entries the app happens to use, + * and naming this file as the real fix. It was right about the fix and + * optimistic about the ring. Both of the two importable entry points that are + * not `main.js` were already dead. + * + * So the published fields live here instead, in a leaf that imports one class + * from three and nothing else. A material opting into a field now depends on + * the *field*, not on the ledger that drives it — which is also the honest + * dependency: `FilamentPaths` does not care that a token stack exists, it cares + * that there are three uniforms with agreed names and a function that reads + * them. `SceneHooks.js` imports these boxes and re-exports the public four + * unchanged, so every existing `import { disruptGLSL, disruptUniforms } from + * '../vfx/SceneHooks.js'` keeps working and no consumer had to be touched. + * + * MATERIAL AGE deliberately did **not** move. It is not published: there is one + * floor, `SceneHooks` patches it directly, and no other material opts in. A + * leaf module for a field with a single in-house consumer would be filing. + * + * ## Invariants + * + * - **I1** — not one dimension is authored here across a frame boundary. The + * values in these boxes are the *neutral*, which is a definition of off + * rather than a metre; every live value is written by `SceneHooks.apply()` + * from the holding ability's settings, every frame, zero-length ones + * included. + * - **I3** — the boxes are created once at module load and shared **by + * identity**. A material that spreads a *copy* reads a field that is + * permanently off and gets no error, which is the one trap in this file. + */ + +/* ---------------------------------------------------------------- */ +/* The published uniform blocks */ +/* ---------------------------------------------------------------- */ + +/** + * DISRUPT's three uniform boxes, shared **by identity** with every material + * that opts in — the `core/FrameUniforms.js` pattern. One write per frame in + * `SceneHooks.apply()` updates every opted-in shader in the app, which is the + * only way this could be cheap enough to put in `FilamentPaths` and mean it. + * + * `uDisruptRegion.w <= 0` is the off state and every consumer tests it first. + */ +export const disruptBlock = { + /** xyz centre in metres, w radius in metres. w <= 0 means no disruption. */ + uDisruptRegion: { value: new Vector4(0, 0, 0, 0) }, + /** x drain (desaturate), y fracture (dither erosion), z dim, w edge 0..1. */ + uDisruptPower: { value: new Vector4(0, 0, 0, 0.35) }, + /** Device pixels per fracture cell. Bigger cells read as bigger shards. */ + uDisruptGrain: { value: 6 } +}; + +/** GRAVITY's block. Same sharing rule. `uGravityWell.w <= 0` is off. */ +export const gravityBlock = { + /** xyz centre in metres, w radius in metres. */ + uGravityWell: { value: new Vector4(0, 0, 0, 0) }, + /** x multiplier inside, y multiplier outside, z edge 0..1, w unused. */ + uGravityMix: { value: new Vector4(1, 1, 0.25, 0) } +}; + +/** The neutral for DISRUPT. Written on release; see `SceneHooks`'s header. */ +export function neutraliseDisrupt() { + disruptBlock.uDisruptRegion.value.set(0, 0, 0, 0); + disruptBlock.uDisruptPower.value.set(0, 0, 0, 0.35); + disruptBlock.uDisruptGrain.value = 6; +} + +/** The neutral for GRAVITY — a multiplier of exactly 1 everywhere. */ +export function neutraliseGravity() { + gravityBlock.uGravityWell.value.set(0, 0, 0, 0); + gravityBlock.uGravityMix.value.set(1, 1, 0.25, 0); +} + +/** + * The uniform boxes a material spreads in to read the disruption field. + * + * ```js + * uniforms: sharedUniforms({ ...disruptUniforms(), uColorCore: ... }) + * ``` + * + * Returns the shared boxes, not copies — that is the point. A material that + * clones them reads a field that is permanently off, and there is no error. + */ +export function disruptUniforms() { + return disruptBlock; +} + +/** As `disruptUniforms()`, for the gravity multiplier. */ +export function gravityUniforms() { + return gravityBlock; +} + +/* ---------------------------------------------------------------- */ +/* GLSL — the opt-in */ +/* ---------------------------------------------------------------- */ + +/** + * The disrupt helper, for any stage of any material. + * + * Two functions and a guard. `disruptAt()` is meant for the **vertex** stage: + * the field is smooth over metres, sampling it per vertex and interpolating + * costs one varying instead of a world position and a distance per fragment, + * and on a filament strip that is the difference between free and not. + * `disruptShade()` then does the visible work in the fragment stage. + * + * The fracture is a screen-space dither rather than a displacement because this + * has to work in materials that have nothing in common: a ribbon strip whose + * vertices are placed by a parametric path, a ground quad with four vertices, + * an instanced flock, a swept tube and a growth instance. There is no shared + * vertex stage to push geometry apart in. Quantising `gl_FragCoord` into cells + * and erasing whole cells reads as the effect *breaking up* rather than fading + * out, which is the distinction that matters — a spell being torn apart, not a + * spell ending. + * + * The first version dithered per pixel with a plain hash and it read as + * dissolve-into-static, indistinguishable from a fade at any distance. The cell + * size is what makes it shards, and it is a slider (`uDisruptGrain`). + */ +export const disruptGLSL = /* glsl */ ` +#ifndef SCENE_HOOK_DISRUPT_INCLUDED +#define SCENE_HOOK_DISRUPT_INCLUDED + +uniform vec4 uDisruptRegion; // xyz centre metres, w radius metres (<=0 == off) +uniform vec4 uDisruptPower; // x drain, y fracture, z dim, w edge 0..1 +uniform float uDisruptGrain; // device pixels per fracture cell + +/** Cheap 2D hash. Local name so it cannot collide with the noise library. */ +float shDisruptHash(vec2 p) { + vec3 q = fract(vec3(p.xyx) * 0.1031); + q += dot(q, q.yzx + 33.33); + return fract((q.x + q.y) * q.z); +} + +/** + * Field strength at a world point, 0..1. The first line is the whole cost when + * nothing is disrupting: one compare against a uniform, the same answer for + * every vertex in the draw call. + */ +float disruptAt(vec3 worldPos) { + float r = uDisruptRegion.w; + if (r <= 0.0) return 0.0; + float d = distance(worldPos, uDisruptRegion.xyz); + float edge = clamp(uDisruptPower.w, 0.001, 1.0); + return 1.0 - smoothstep(r * (1.0 - edge), r, d); +} + +/** + * Apply the disruption to a shaded fragment. k is the interpolated field. + * cell is normally gl_FragCoord.xy. + */ +void disruptShade(inout vec3 colour, inout float alpha, float k, vec2 cell) { + if (k <= 0.0) return; + float drain = clamp(uDisruptPower.x * k, 0.0, 1.0); + float luma = dot(colour, vec3(0.2126, 0.7152, 0.0722)); + colour = mix(colour, vec3(luma), drain); + colour *= 1.0 - clamp(uDisruptPower.z * k, 0.0, 0.95); + float shard = shDisruptHash(floor(cell / max(uDisruptGrain, 1.0))); + alpha *= step(clamp(uDisruptPower.y * k, 0.0, 1.0), shard); +} + +#endif +`; + +/** + * The gravity helper — one function, for anything integrating a fall. + * + * A multiplier rather than a vector on purpose. Every falling thing in this + * project already owns a gravity in metres/second² that is a slider on its own + * block (`ShatterField`'s `gravity`, a particle system's `uGravity`), and I1 + * says that number must stay the ability's. Handing out a *replacement* vector + * would take that slider away from whoever is falling; handing out a signed + * scale leaves it exactly where it was and lets `hourglass` flip the sign of + * everything in the zone with one number. + */ +export const gravityGLSL = /* glsl */ ` +#ifndef SCENE_HOOK_GRAVITY_INCLUDED +#define SCENE_HOOK_GRAVITY_INCLUDED + +uniform vec4 uGravityWell; // xyz centre metres, w radius metres (<=0 == off) +uniform vec4 uGravityMix; // x inside, y outside, z edge 0..1, w unused + +/** Signed multiplier on this point's gravity. Exactly 1.0 when no hook is held. */ +float gravityScaleAt(vec3 worldPos) { + float r = uGravityWell.w; + if (r <= 0.0) return 1.0; + float d = distance(worldPos, uGravityWell.xyz); + float edge = clamp(uGravityMix.z, 0.001, 1.0); + float k = 1.0 - smoothstep(r * (1.0 - edge), r, d); + return mix(uGravityMix.y, uGravityMix.x, k); +} + +#endif +`; diff --git a/src/vfx/prefixedBlock.js b/src/vfx/prefixedBlock.js new file mode 100644 index 0000000..1c29e05 --- /dev/null +++ b/src/vfx/prefixedBlock.js @@ -0,0 +1,161 @@ +/** + * prefixedBlock.js — the plumbing behind prefixed settings blocks. + * + * Two conventions for reading settings grew up side by side in `src/vfx/`, and + * both are legitimate: + * + * - **Canonical names.** `GrowthField`, `GroundField`, `Projectile`, `Swarm`, + * `LiquidSurface`, `Curtain`, `ShatterField` and `ArcNetwork` take a params + * object each frame and read `p.radius ?? 1`. Short, obvious, and an ability + * can hand its settings block straight through when the names line up. + * - **Prefixed names.** `Tube`, `Shell` and `VolumeHull` read `c[keys.radius]` + * where `keys.radius === 'tubeRadius'`. Uglier, and it exists because those + * three are the modules an ability plausibly wants *two* of. Pyroclasm + * carries an ash hull and a flame hull; a whip ability could carry a tube + * for the lash and another for the recoil. Bare names collide the moment it + * does, and the collision is silent — the second one just quietly drives + * the first one's radius. + * + * This module is the shared half of the second convention. It was three + * verbatim copies of `num`, two of `str`, two of `prefixed` and two apiece of + * the defaults / keys / audit loops before it was a file: `Tube.js`, `Shell.js` + * and `VolumeHull.js` had each independently written the same eight lines. + * Nothing here is clever. It is here so that a fix to the audit message lands + * in one place, and so a fourth prefixed module costs its author nothing. + * + * `VolumeHull` uses `num()` and `auditBlock()` but keeps its own key builder: + * its field table already stores capitalised suffixes (`'Density'`), so it + * concatenates rather than capitalising, and forcing it through `prefixed()` + * would mean rewriting a hundred-odd table rows to prove a point about tidiness. + */ + +/* ---------------------------------------------------------------- */ +/* Coercion */ +/* ---------------------------------------------------------------- */ + +/** + * A finite number, or the fallback. + * + * The `value === value` is a NaN test, and it is the one that matters: an + * ability that spreads a defaults fragment but then computes one of the keys + * wrongly hands us a NaN, and a NaN in a uniform does not render as a wrong + * shape — it renders as nothing at all, silently, with no console output. A + * missing key at least has the decency to be `undefined`. + * + * @param {*} value + * @param {number} fallback + * @returns {number} + */ +export function num(value, fallback) { + return typeof value === 'number' && value === value ? value : fallback; +} + +/** + * A string (in practice a `#rrggbb` colour), or the fallback. + * + * @param {*} value + * @param {string} fallback + * @returns {string} + */ +export function str(value, fallback) { + return typeof value === 'string' ? value : fallback; +} + +/* ---------------------------------------------------------------- */ +/* Keys */ +/* ---------------------------------------------------------------- */ + +/** + * `('tube', 'radius')` → `'tubeRadius'`. An empty prefix passes the name + * through, which is what makes a single-hull ability able to opt out of the + * whole scheme and keep bare names. + * + * @param {string} prefix + * @param {string} name + * @returns {string} + */ +export function prefixed(prefix, name) { + return prefix ? prefix + name.charAt(0).toUpperCase() + name.slice(1) : name; +} + +/** + * The unprefixed → prefixed map, built once at construction so that `sync()` + * never concatenates a string on a frame (I3). + * + * @param {readonly string[]} fieldNames + * @param {string} prefix + * @returns {Object} + */ +export function buildKeys(fieldNames, prefix) { + const out = {}; + for (const name of fieldNames) out[name] = prefixed(prefix, name); + return out; +} + +/* ---------------------------------------------------------------- */ +/* Defaults */ +/* ---------------------------------------------------------------- */ + +/** + * Compose a settings fragment: base defaults, per-mode tuning on top, then the + * caller's overrides on top of that. + * + * The three layers are the whole point. `fields` is what the module thinks a + * neutral instance looks like; `tuning` is what it thinks a *funnel* looks + * like, because a vortex and a bullwhip genuinely do not want the same flare; + * and `overrides` is the ability author disagreeing with both, which they are + * entitled to do because every one of these is a slider the moment it lands in + * the block. + * + * Overrides are keyed **prefixed** (`{ lashRadius: 0.3 }`), because that is + * what the author sees in the editor and in their own settings module. + * + * @param {readonly string[]} fieldNames iteration order + * @param {Object} fields name → neutral default + * @param {Object} tuning name → per-mode default (may be empty) + * @param {string} prefix + * @param {Object} [overrides] prefixed keys + * @returns {Object} + */ +export function buildDefaults(fieldNames, fields, tuning, prefix, overrides = {}) { + const out = {}; + for (const name of fieldNames) { + out[prefixed(prefix, name)] = name in tuning ? tuning[name] : fields[name]; + } + return Object.assign(out, overrides); +} + +/* ---------------------------------------------------------------- */ +/* Audit */ +/* ---------------------------------------------------------------- */ + +/** + * Warn once, naming every key the block is missing. + * + * This warns rather than throwing on purpose. A half-written settings block is + * the normal state of an ability for the first hour of its life, and a module + * that refuses to render until every one of eighty keys exists is a module the + * author fights instead of using — `num()`/`str()` fall back, the thing draws, + * and the console says exactly which keys to paste in. The one-shot guard is + * the caller's (`this._checked`), because a warning on every frame of every + * cast is indistinguishable from no warning at all. + * + * @param {string} label e.g. `'Tube:lash'` — what to print in the brackets + * @param {Object} keys the map from `buildKeys` + * @param {readonly string[]} fieldNames + * @param {Object} block the ability's settings block + * @param {string} remedy the call that would fix it, e.g. `"tubeDefaults('lash', TubePath.WHIP)"` + * @returns {string[]} the missing keys, in case the caller wants them + */ +export function auditBlock(label, keys, fieldNames, block, remedy) { + const missing = []; + for (const name of fieldNames) if (!(keys[name] in block)) missing.push(keys[name]); + if (missing.length) { + console.warn( + `[${label}] settings block is missing ${missing.length} key(s); ` + + `falling back to defaults. Spread ${remedy} into the block. ` + + `Missing: ${missing.join(', ')}` + ); + } + return missing; +} diff --git a/src/vfx/quads.js b/src/vfx/quads.js new file mode 100644 index 0000000..a8aedb8 --- /dev/null +++ b/src/vfx/quads.js @@ -0,0 +1,117 @@ +import { PlaneGeometry } from 'three'; + +/* ------------------------------------------------------------------------ */ +/* The two unit quads the whole library draws on */ +/* ------------------------------------------------------------------------ */ +/** + * One flat quad and one upright quad, built once and shared by everything. + * + * ## Why this is its own file + * + * Five modules arrived with a private copy of one of these two eight-line + * pools: `GroundField` and `Caustics` for the flat one, `Distortion`, `Portal` + * and `Mirror` for the upright one. Caustics' comment argued its duplication + * was correct, and half of the argument was right — it must not import + * `GroundField`, because that would drag two thousand lines and a rune alphabet + * into the graph of any ability that only wanted water on the floor. The + * conclusion did not follow. The fix for "these two must not depend on each + * other" is a third file that neither of them is, which is exactly what + * `prefixedBlock.js` already is for shader assembly, and this is thirty lines + * rather than two thousand. + * + * What the duplication cost: with a caustic net, three ground marks, a portal + * and a heat shimmer standing, the app held **five** identical two-triangle + * buffers instead of two, on five independent reference counts. Cheap, and + * invisible until somebody profiles buffer uploads and finds four geometries + * that should not exist. + * + * ## The contract + * + * Both quads are **1 × 1, centred on the origin**, and neither carries a metre. + * An effect scales the mesh; a quad built at the effect's radius would be a + * dimension captured in a buffer, which is the invariant (I1) this whole + * project is organised around. Never mutate either of them, and never call + * `dispose()` on one — it is not yours. + */ + +/* ---------------------------------------------------------------- */ +/* Flat — 1 × 1 in the XZ plane, normal +Y. Refcounted. */ +/* ---------------------------------------------------------------- */ + +/** @type {THREE.PlaneGeometry|null} */ +let _ground = null; +let _groundRefs = 0; + +/** + * Borrow the shared ground quad. Returns the same geometry every time. + * + * `acquireGroundQuad()` in the constructor, `releaseGroundQuad()` in + * `dispose()`, exactly once each. + * + * @returns {THREE.PlaneGeometry} + */ +export function acquireGroundQuad() { + if (!_ground) { + _ground = new PlaneGeometry(1, 1, 1, 1).rotateX(-Math.PI / 2); + _ground.name = 'vfx.groundQuad'; + } + _groundRefs++; + return _ground; +} + +/** + * Give it back. The last release disposes the buffer, so a scene that has torn + * every ground effect down leaves nothing behind — and the next acquisition + * rebuilds it rather than handing out a disposed geometry. + */ +export function releaseGroundQuad() { + if (--_groundRefs <= 0) { + _ground?.dispose(); + _ground = null; + _groundRefs = 0; + } +} + +/** Live borrowers. For the harness and for a leak readout; not for logic. */ +export function groundQuadRefs() { + return _groundRefs; +} + +/* ---------------------------------------------------------------- */ +/* Upright — 1 × 1 in the XY plane, normal +Z. Not refcounted. */ +/* ---------------------------------------------------------------- */ + +/** @type {THREE.PlaneGeometry|null} */ +let _upright = null; + +/** + * The shared upright quad — the face of a portal, a mirror, a distortion plane. + * + * **Deliberately not refcounted**, matching the behaviour `Distortion` and + * `Portal` already shipped with. It is two triangles and twenty-four bytes of + * attribute data, it is wanted again the instant anything else is cast, and a + * refcount would mean the last portal closing frees a buffer the next one + * immediately rebuilds. `disposeQuads()` exists for a teardown that genuinely + * wants the context empty; nothing in the app calls it, and that is correct. + * + * @returns {THREE.PlaneGeometry} + */ +export function uprightQuad() { + if (!_upright) { + _upright = new PlaneGeometry(1, 1, 1, 1); + _upright.name = 'vfx.uprightQuad'; + } + return _upright; +} + +/** + * Drop both quads. For a harness tearing a context down, and for nothing else — + * a live mesh still holding one is left drawing a disposed geometry. + */ +export function disposeQuads() { + _ground?.dispose(); + _ground = null; + _groundRefs = 0; + _upright?.dispose(); + _upright = null; +} diff --git a/superbeam.jpg b/superbeam.jpg deleted file mode 100644 index c313851..0000000 Binary files a/superbeam.jpg and /dev/null differ diff --git a/thundercast.jpg b/thundercast.jpg deleted file mode 100644 index 5f5be7a..0000000 Binary files a/thundercast.jpg and /dev/null differ